"""Slash commands system — Claude Code-style. Commands are markdown files with YAML frontmatter that define prompt templates triggered by `/command` syntax. Built-in commands live in code/commands/builtins/. User commands live in workspace's .sonicoder/commands/. """ from __future__ import annotations import logging import os import re from typing import Any from code.skills import _parse_frontmatter logger = logging.getLogger(__name__) _BUILTIN_COMMANDS_DIR = os.path.join(os.path.dirname(__file__), "builtins") _USER_COMMANDS_DIRNAME = ".sonicoder/commands" def _command_dirs() -> list[str]: dirs = [_BUILTIN_COMMANDS_DIR] try: from code.tools.fs import get_workspace_root user_dir = os.path.join(get_workspace_root(), _USER_COMMANDS_DIRNAME) if os.path.isdir(user_dir): dirs.append(user_dir) except Exception: pass return dirs def _load_command(filepath: str) -> dict[str, Any] | None: try: with open(filepath, "r", encoding="utf-8") as f: content = f.read() except Exception as exc: logger.warning("Failed to read %s: %s", filepath, exc) return None meta, body = _parse_frontmatter(content) name = meta.get("name") or os.path.splitext(os.path.basename(filepath))[0] return { "name": name, "description": meta.get("description", ""), "argument_hint": meta.get("argument-hint", ""), "allowed_tools": [t.strip() for t in meta.get("allowed-tools", "").split(",") if t.strip()], "body": body.strip(), "path": filepath, } def list_commands() -> list[dict[str, Any]]: """List all available slash commands.""" commands: list[dict[str, Any]] = [] seen: set[str] = set() for cmds_dir in _command_dirs(): if not os.path.isdir(cmds_dir): continue for entry in sorted(os.listdir(cmds_dir)): if not entry.endswith(".md"): continue filepath = os.path.join(cmds_dir, entry) cmd = _load_command(filepath) if cmd and cmd["name"] not in seen: seen.add(cmd["name"]) commands.append({ "name": cmd["name"], "description": cmd["description"], "argument_hint": cmd["argument_hint"], }) return commands def get_command(name: str) -> dict[str, Any] | None: """Get full command content by name.""" for cmds_dir in _command_dirs(): if not os.path.isdir(cmds_dir): continue # Try name.md and name/something.md direct = os.path.join(cmds_dir, f"{name}.md") if os.path.isfile(direct): return _load_command(direct) # Try subdirectory: name/command.md if os.path.isdir(os.path.join(cmds_dir, name)): for entry in os.listdir(os.path.join(cmds_dir, name)): if entry.endswith(".md"): return _load_command(os.path.join(cmds_dir, name, entry)) return None def parse_command_input(user_input: str) -> tuple[str | None, str]: """Parse a user input string for a slash command. Returns (command_name, arguments) or (None, user_input) if not a command. """ stripped = user_input.strip() if not stripped.startswith("/"): return None, user_input # Match /command-name or /namespace:command match = re.match(r"^/([a-zA-Z][\w:-]*)\s*(.*)$", stripped, re.DOTALL) if not match: return None, user_input return match.group(1), match.group(2).strip() def expand_command(name: str, arguments: str = "") -> dict[str, Any]: """Expand a slash command into a full prompt for the model. Replaces $ARGUMENTS placeholder with the user-provided arguments. """ cmd = get_command(name) if not cmd: return { "success": False, "error": f"Unknown command: /{name}", "available": [c["name"] for c in list_commands()], } body = cmd["body"] # Replace $ARGUMENTS expanded = body.replace("$ARGUMENTS", arguments) # Also support bash-style $(cmd) execution for context blocks (like Claude Code) # e.g. !`git status` becomes the output of `git status` from code.tools.bash import run_bash def _exec_bash(match: re.Match) -> str: cmd_str = match.group(1) result = run_bash(cmd_str, timeout=10) return result.get("stdout", "") + result.get("stderr", "") expanded = re.sub(r"!`([^`]+)`", _exec_bash, expanded) return { "success": True, "name": cmd["name"], "description": cmd["description"], "prompt": expanded, "allowed_tools": cmd["allowed_tools"], }