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 @@ -255,6 +255,76 @@ amplifier bundle use <TAB> # Shows available bundles
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
12 changes: 8 additions & 4 deletions amplifier_app_cli/commands/reset.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
projects - Session transcripts and history
settings - User configuration (settings.yaml)
keys - API keys (keys.env)
commands - Custom slash commands (~/.amplifier/commands/)
cache - Downloaded bundles (auto-regenerates)
registry - Bundle mappings (auto-regenerates)

Expand All @@ -15,7 +16,7 @@
amplifier reset

# Scripted usage
amplifier reset --preserve projects,settings,keys -y
amplifier reset --preserve projects,settings,keys,commands -y
"""

from __future__ import annotations
Expand All @@ -35,24 +36,26 @@
"projects": ["projects"],
"settings": ["settings.yaml"],
"keys": ["keys.env"],
"commands": ["commands"],
"cache": ["cache"],
"registry": ["registry.json"],
}

# Display order for categories
CATEGORY_ORDER = ["projects", "settings", "keys", "cache", "registry"]
CATEGORY_ORDER = ["projects", "settings", "keys", "commands", "cache", "registry"]

# Descriptions for each category (used in UI)
CATEGORY_DESCRIPTIONS = {
"projects": "Session transcripts and history",
"settings": "User configuration (settings.yaml)",
"keys": "API keys (keys.env)",
"commands": "Custom slash commands (~/.amplifier/commands/)",
"cache": "Downloaded bundles (auto-regenerates)",
"registry": "Bundle mappings (auto-regenerates)",
}

# Default categories to preserve
DEFAULT_PRESERVE = {"projects", "settings", "keys"}
DEFAULT_PRESERVE = {"projects", "settings", "keys", "commands"}

# Default install source
DEFAULT_INSTALL_SOURCE = "git+https://github.com/microsoft/amplifier"
Expand Down Expand Up @@ -386,13 +389,14 @@ def reset(
projects - Session transcripts and history
settings - User configuration (settings.yaml)
keys - API keys (keys.env)
commands - Custom slash commands (~/.amplifier/commands/)
cache - Downloaded bundles (auto-regenerates)
registry - Bundle mappings (auto-regenerates)

\b
Examples:
amplifier reset Interactive mode (default)
amplifier reset --preserve projects,settings,keys -y
amplifier reset --preserve projects,settings,keys,commands -y
Scripted: preserve specific categories
amplifier reset --remove cache,registry -y
Scripted: remove specific categories
Expand Down
165 changes: 161 additions & 4 deletions amplifier_app_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,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",
},
}

# Dynamic shortcuts for modes (populated from mode definitions)
Expand All @@ -289,6 +293,9 @@ def __init__(self, session: AmplifierSession, bundle_name: str = "unknown"):
self.session.coordinator.session_state["active_mode"] = None
# Populate mode shortcuts from discovery (if available)
self._populate_mode_shortcuts()
# Custom slash commands
self.custom_commands: dict[str, dict[str, Any]] = {}
self._load_custom_commands()

def _populate_mode_shortcuts(self) -> None:
"""Populate MODE_SHORTCUTS from mode discovery."""
Expand All @@ -298,6 +305,73 @@ def _populate_mode_shortcuts(self) -> None:
# Update class-level shortcuts dict
CommandProcessor.MODE_SHORTCUTS.update(shortcuts)

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():
# cmd_name already has / prefix from registry.get_command_dict()
self.custom_commands[cmd_name] = {
"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():
# cmd_name already has / prefix from registry
self.custom_commands[cmd_name] = {
"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]]:
"""
Process user input and extract commands.
Expand All @@ -311,6 +385,7 @@ 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}
Expand All @@ -320,6 +395,15 @@ def process_input(self, user_input: str) -> tuple[str, dict[str, Any]]:
if shortcut_name in self.MODE_SHORTCUTS:
return "handle_mode", {"args": shortcut_name, "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 @@ -371,6 +455,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 @@ -480,6 +571,41 @@ async def _list_modes(self) -> str:
lines.append("\nUse /mode <name> to activate, /mode off to clear.")
return "\n".join(lines)

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 = await 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 @@ -722,10 +848,10 @@ async def _fork_session(self, args: str) -> str:
return f"Error forking session: {e}"

def _format_help(self) -> str:
"""Format help text with commands and dynamic modes section."""
"""Format help text with commands, dynamic modes, and custom commands."""
lines = ["Available Commands:"]
for cmd, info in self.COMMANDS.items():
lines.append(f" {cmd:<12} - {info['description']}")
lines.append(f" {cmd:<18} - {info['description']}")

# Add dynamic modes section if modes are available
session_state = self.session.coordinator.session_state
Expand All @@ -737,10 +863,25 @@ def _format_help(self) -> str:
lines.append("Mode Shortcuts:")
for name, description in modes:
if description:
lines.append(f" /{name:<11} - {description}")
lines.append(f" /{name:<17} - {description}")
else:
lines.append(f" /{name}")

# 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 @@ -1516,7 +1657,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
Loading