Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,76 @@ amplifier profile use <TAB> # Shows available profiles
amplifier run --<TAB> # 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: "<file>"
---

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:
Expand Down
165 changes: 162 additions & 3 deletions amplifier_app_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,13 +279,87 @@ 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"):
self.session = session
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]]:
"""
Expand All @@ -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
Expand Down Expand Up @@ -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."
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
36 changes: 31 additions & 5 deletions amplifier_app_cli/runtime/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]:
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down