diff --git a/README.md b/README.md index 4cd9f17..be0fc18 100644 --- a/README.md +++ b/README.md @@ -258,6 +258,76 @@ amplifier profile use # Shows available profiles amplifier run -- # Shows all options ``` +## Custom Slash Commands + +Amplifier supports extensible slash commands defined as Markdown files. Create your own commands to automate repetitive prompts. + +### Quick Start + +```bash +# Create a command +mkdir -p ~/.amplifier/commands +cat > ~/.amplifier/commands/review.md << 'EOF' +--- +description: Quick code review +argument-hint: "" +--- + +Review $ARGUMENTS for: +- Code quality and best practices +- Potential bugs or edge cases +- Suggestions for improvement +EOF + +# Use it in interactive mode +amplifier +> /help # Shows your custom commands +> /review src/main.py +``` + +### Command Locations + +Commands are discovered from (in precedence order): +1. `.amplifier/commands/` - Project-level (highest priority) +2. `~/.amplifier/commands/` - User-level + +### Template Syntax + +| Syntax | Description | Example | +|--------|-------------|---------| +| `$ARGUMENTS` | All arguments as-is | `/cmd foo bar` → `foo bar` | +| `$1`, `$2`, etc. | Positional arguments | `/cmd foo bar` → `$1=foo`, `$2=bar` | +| `{{$1 or "default"}}` | With default value | `/cmd` → `default` | + +### Example Commands + +```markdown +--- +description: Generate a standup summary from git history +argument-hint: "[days:1]" +--- + +Generate a standup summary from the last {{$1 or "1"}} days. + +Run `git log --oneline --since="{{$1 or "1"}} days ago"` and summarize. +``` + +### Built-in Commands + +| Command | Description | +|---------|-------------| +| `/help` | Show all commands (built-in + custom) | +| `/reload-commands` | Reload custom commands from disk | +| `/clear` | Clear conversation history | +| `/quit` | Exit interactive mode | + +### More Information + +See [amplifier-module-tool-slash-command](https://github.com/robotdad/amplifier-module-tool-slash-command) for: +- Full template syntax documentation +- Example commands for GitHub, dev workflows, and more +- Creating namespaced commands with subdirectories + ## Architecture This CLI is built on top of amplifier-core and provides: diff --git a/amplifier_app_cli/main.py b/amplifier_app_cli/main.py index 1007411..2800f7b 100644 --- a/amplifier_app_cli/main.py +++ b/amplifier_app_cli/main.py @@ -279,6 +279,10 @@ class CommandProcessor: "action": "fork_session", "description": "Fork session at turn N: /fork [turn]", }, + "/reload-commands": { + "action": "reload_commands", + "description": "Reload custom commands from disk", + }, } def __init__(self, session: AmplifierSession, profile_name: str = "unknown"): @@ -286,6 +290,76 @@ def __init__(self, session: AmplifierSession, profile_name: str = "unknown"): self.profile_name = profile_name self.plan_mode = False self.plan_mode_unregister = None # Store unregister function + self.custom_commands: dict[str, dict[str, Any]] = {} # Custom slash commands + self._load_custom_commands() + + def _load_custom_commands(self) -> None: + """Load custom commands from slash_command module if available.""" + try: + # Check if slash_command tool is available via coordinator + tools = self.session.coordinator.get("tools") + if not tools: + return + + # Look for the slash_command tool + slash_cmd_tool = tools.get("slash_command") + if not slash_cmd_tool: + return + + # Get the registry from the tool + if hasattr(slash_cmd_tool, "registry") and slash_cmd_tool.registry: + registry = slash_cmd_tool.registry + if hasattr(registry, "is_loaded") and registry.is_loaded(): + # Load commands into our custom_commands dict + for cmd_name, cmd_data in registry.get_command_dict().items(): + # Store with / prefix for lookup + key = f"/{cmd_name}" + self.custom_commands[key] = { + "action": "execute_custom_command", + "description": cmd_data.get( + "description", "Custom command" + ), + "metadata": cmd_data, + } + if self.custom_commands: + logger.debug( + f"Loaded {len(self.custom_commands)} custom commands" + ) + except Exception as e: + logger.debug(f"Could not load custom commands: {e}") + + def reload_custom_commands(self) -> int: + """Reload custom commands from disk. Returns count of commands loaded.""" + self.custom_commands.clear() + + try: + tools = self.session.coordinator.get("tools") + if not tools: + return 0 + + slash_cmd_tool = tools.get("slash_command") + if not slash_cmd_tool: + return 0 + + if hasattr(slash_cmd_tool, "registry") and slash_cmd_tool.registry: + # Reload from disk + slash_cmd_tool.registry.reload() + + # Reload into our dict + for ( + cmd_name, + cmd_data, + ) in slash_cmd_tool.registry.get_command_dict().items(): + key = f"/{cmd_name}" + self.custom_commands[key] = { + "action": "execute_custom_command", + "description": cmd_data.get("description", "Custom command"), + "metadata": cmd_data, + } + except Exception as e: + logger.debug(f"Could not reload custom commands: {e}") + + return len(self.custom_commands) def process_input(self, user_input: str) -> tuple[str, dict[str, Any]]: """ @@ -300,9 +374,20 @@ def process_input(self, user_input: str) -> tuple[str, dict[str, Any]]: command = parts[0].lower() args = parts[1] if len(parts) > 1 else "" + # Check built-in commands first if command in self.COMMANDS: cmd_info = self.COMMANDS[command] return cmd_info["action"], {"args": args, "command": command} + + # Check custom commands + if command in self.custom_commands: + cmd_info = self.custom_commands[command] + return cmd_info["action"], { + "args": args, + "command": command, + "metadata": cmd_info["metadata"], + } + return "unknown_command", {"command": command} # Regular prompt @@ -357,6 +442,13 @@ async def handle_command(self, action: str, data: dict[str, Any]) -> str: if action == "fork_session": return await self._fork_session(data.get("args", "")) + if action == "reload_commands": + count = self.reload_custom_commands() + return f"✓ Reloaded {count} custom commands" + + if action == "execute_custom_command": + return await self._execute_custom_command(data) + if action == "unknown_command": return ( f"Unknown command: {data['command']}. Use /help for available commands." @@ -396,6 +488,41 @@ async def plan_mode_hook( self.plan_mode_unregister() self.plan_mode_unregister = None + async def _execute_custom_command(self, data: dict[str, Any]) -> str: + """Execute a custom slash command by substituting template and returning as prompt. + + Returns the substituted prompt text which will be sent to the LLM. + """ + metadata = data.get("metadata", {}) + args = data.get("args", "") + command_name = data.get("command", "").lstrip("/") + + try: + # Get the slash_command tool + tools = self.session.coordinator.get("tools") + if not tools: + return "Error: No tools available" + + slash_cmd_tool = tools.get("slash_command") + if not slash_cmd_tool: + return "Error: slash_command tool not loaded" + + # Get executor from tool + if not hasattr(slash_cmd_tool, "executor") or not slash_cmd_tool.executor: + return "Error: Command executor not available" + + # Execute the command (substitute template variables) + prompt = slash_cmd_tool.executor.execute(command_name, args) + + # Return special marker so the REPL knows to execute this as a prompt + return f"__EXECUTE_PROMPT__:{prompt}" + + except ValueError as e: + return f"Error executing /{command_name}: {e}" + except Exception as e: + logger.exception(f"Error executing custom command /{command_name}") + return f"Error: {e}" + async def _save_transcript(self, filename: str) -> str: """Save current transcript with sanitization for non-JSON-serializable objects. @@ -647,9 +774,25 @@ async def _fork_session(self, args: str) -> str: def _format_help(self) -> str: """Format help text.""" - lines = ["Available Commands:"] + lines = ["Built-in Commands:"] for cmd, info in self.COMMANDS.items(): - lines.append(f" {cmd:<12} - {info['description']}") + lines.append(f" {cmd:<18} - {info['description']}") + + # Add custom commands if any + if self.custom_commands: + lines.append("") + lines.append("Custom Commands:") + for cmd, info in sorted(self.custom_commands.items()): + desc = info.get("description", "No description") + # Truncate long descriptions + if len(desc) > 50: + desc = desc[:47] + "..." + lines.append(f" {cmd:<18} - {desc}") + lines.append("") + lines.append( + "Tip: Use /reload-commands to reload custom commands from disk" + ) + return "\n".join(lines) async def _get_config_display(self) -> str: @@ -1423,7 +1566,23 @@ def sigint_handler(signum, frame): else: # Handle command result = await command_processor.handle_command(action, data) - console.print(f"[cyan]{result}[/cyan]") + + # Check if this is a custom command that should be executed as a prompt + if result.startswith("__EXECUTE_PROMPT__:"): + prompt_text = result[len("__EXECUTE_PROMPT__:") :] + console.print("\n[dim]Executing custom command...[/dim]") + console.print( + f"[dim]Prompt: {prompt_text[:100]}{'...' if len(prompt_text) > 100 else ''}[/dim]" + ) + console.print( + "\n[dim]Processing... (Ctrl+C to cancel)[/dim]" + ) + + # Process runtime @mentions in the generated prompt + await _process_runtime_mentions(session, prompt_text) + await _execute_with_interrupt(prompt_text) + else: + console.print(f"[cyan]{result}[/cyan]") except EOFError: # Ctrl-D - graceful exit diff --git a/amplifier_app_cli/runtime/config.py b/amplifier_app_cli/runtime/config.py index 5bbd8e3..c3055ec 100644 --- a/amplifier_app_cli/runtime/config.py +++ b/amplifier_app_cli/runtime/config.py @@ -66,12 +66,10 @@ async def resolve_bundle_config( if console: console.print(f"[dim]Preparing bundle '{bundle_name}'...[/dim]") - # Build behavior URIs from notification settings - # Notifications are an app-level policy: compose behavior bundles before prepare() + # Build behavior URIs for CLI-specific features + # These are app-level policies: compose behavior bundles before prepare() # so modules get properly downloaded and installed via normal bundle machinery - compose_behaviors = _build_notification_behaviors( - app_settings.get_notification_config() - ) + compose_behaviors = _build_cli_behaviors(app_settings) # Get source overrides from unified settings # This enables settings.yaml overrides to take effect at prepare time @@ -615,6 +613,34 @@ def inject_user_providers(config: dict, prepared_bundle: "PreparedBundle") -> No prepared_bundle.mount_plan["providers"] = config["providers"] +def _build_cli_behaviors(app_settings: AppSettings) -> list[str]: + """Build list of CLI-specific behavior URIs. + + The CLI composes additional behaviors onto the base bundle for features + that are specific to interactive CLI usage (not needed by other apps). + + Args: + app_settings: App settings for configuration. + + Returns: + List of behavior bundle URIs to compose onto the main bundle. + """ + behaviors: list[str] = [] + + # Slash commands - always enabled for interactive CLI + # This provides extensible /commands via .amplifier/commands/ directories + behaviors.append( + "git+https://github.com/microsoft/amplifier-module-tool-slash-command@main#subdirectory=behaviors/slash-command.yaml" + ) + + # Add notification behaviors based on settings + behaviors.extend( + _build_notification_behaviors(app_settings.get_notification_config()) + ) + + return behaviors + + def _build_notification_behaviors( notifications_config: dict[str, Any] | None, ) -> list[str]: diff --git a/pyproject.toml b/pyproject.toml index 54615cd..6597259 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ authors = [{ name = "Microsoft MADE:Explorations Team" }] dependencies = [ "click>=8.1.0", "rich>=13.0.0", + "pygments>=2.15.0", # Needed for pygments.lexers.markup "pydantic>=2.0.0", "amplifier-core", "amplifier-collections",