File size: 4,765 Bytes
81aa0b5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
"""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"],
    }