Source code for agent_framework.command

"""Command definitions, registry, and prompt rendering.

Commands are parametrized Markdown prompts stored in a dedicated directory.
Each command file follows the Claude Code frontmatter format:

    ---
    description: Short description of what this command does
    argument-hint: <argument description>    # optional
    allowed-tools:                           # optional
      - Read
      - Bash
    model: gpt-4o                            # optional model override
    ---
    The prompt template. Use $ARGUMENTS for the full raw argument string,
    or $1, $2, … $9 for positional tokens.

Command name = filename stem.  Nested directories are not supported in this
iteration (flat directory only).  Unknown commands dispatch to a
consumer-supplied callback registered on the host.
"""

from __future__ import annotations

import logging
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

import yaml

_LOGGER = logging.getLogger(__name__)

# Regex to match $1–$9 and $ARGUMENTS placeholders
_PLACEHOLDER_RE = re.compile(r"\$(ARGUMENTS|[1-9])")


[docs] @dataclass(frozen=True, slots=True) class CommandDefinition: """A single discovered command, fully loaded at discovery time. Attributes: name: Command name (filename stem, e.g. ``hello``). description: Short human-readable description. argument_hint: Optional hint shown to the user for arguments. allowed_tools: Optional set of tool names the command may use. model: Optional model override for this command. prompt_template: The raw prompt body with ``$ARGUMENTS`` / ``$1``–``$9`` placeholders. source_path: Absolute path to the ``.md`` file. """ name: str description: str argument_hint: str = "" allowed_tools: tuple[str, ...] = () model: str | None = None prompt_template: str = "" source_path: Path = field(default_factory=Path)
[docs] @dataclass(slots=True) class CommandRegistry: """Discovers and caches CommandDefinitions from configured directories. Commands are fully loaded at discovery time (prompts are cheap; no Python sidecars). Attributes: directories: Directories to scan for ``*.md`` command files. _cache: Maps command name → CommandDefinition. """ directories: tuple[Path, ...] _cache: dict[str, CommandDefinition] = field(default_factory=dict, repr=False)
[docs] @classmethod def from_config(cls, config: Any) -> "CommandRegistry": """Build a CommandRegistry from a HostConfig.""" dirs = getattr(config, "commands_directories", ()) or () return cls(directories=tuple(dirs))
[docs] def discover(self) -> None: """Scan all directories and fully parse every ``*.md`` command file. Missing or malformed frontmatter is logged as WARNING and skipped. First directory wins on duplicate command names. """ cache: dict[str, CommandDefinition] = {} for directory in self.directories: if not Path(directory).is_dir(): continue for md_path in sorted(Path(directory).glob("*.md")): defn = _parse_command_file(md_path) if defn is not None and defn.name not in cache: cache[defn.name] = defn self._cache = cache
[docs] def get(self, name: str) -> CommandDefinition: """Return a CommandDefinition by name. Raises KeyError if not found.""" if name not in self._cache: raise KeyError(f"Unknown command: {name!r}") return self._cache[name]
[docs] def get_all(self) -> tuple[CommandDefinition, ...]: """Return all discovered commands.""" return tuple(self._cache.values())
[docs] def reload(self) -> None: """Clear cache and re-discover from disk.""" self._cache.clear() self.discover()
[docs] def render(cmd: CommandDefinition, raw_args: str) -> str: """Render a command prompt by substituting argument placeholders. - ``$ARGUMENTS`` is replaced with the full ``raw_args`` string. - ``$1``–``$9`` are replaced with whitespace-split positional tokens (missing tokens expand to an empty string). Args: cmd: The command whose ``prompt_template`` is rendered. raw_args: Raw argument string supplied by the user (e.g. ``"World"``). Returns: The rendered prompt string ready to be injected as a user message. """ tokens = raw_args.split() def _substitute(match: re.Match) -> str: key = match.group(1) if key == "ARGUMENTS": return raw_args idx = int(key) # 1–9 return tokens[idx - 1] if idx <= len(tokens) else "" return _PLACEHOLDER_RE.sub(_substitute, cmd.prompt_template)
# --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- def _parse_command_file(md_path: Path) -> CommandDefinition | None: """Parse a command markdown file. Returns None and logs on any error.""" try: raw = md_path.read_text(encoding="utf-8") if not raw.startswith("---"): _LOGGER.warning("Command %s: missing YAML frontmatter — skipped.", md_path) return None parts = raw.split("---", 2) if len(parts) < 3: _LOGGER.warning("Command %s: unclosed frontmatter — skipped.", md_path) return None meta = yaml.safe_load(parts[1]) or {} prompt_body = parts[2].strip() name = md_path.stem description = str(meta.get("description", "")).strip() if not description: _LOGGER.warning("Command %s: 'description' is required — skipped.", md_path) return None argument_hint = str(meta.get("argument-hint", "")).strip() raw_tools = meta.get("allowed-tools", []) or [] if isinstance(raw_tools, str): raw_tools = [t.strip() for t in raw_tools.split(",") if t.strip()] allowed_tools = tuple(str(t).strip() for t in raw_tools if str(t).strip()) model_raw = meta.get("model", None) model = str(model_raw).strip() if model_raw else None return CommandDefinition( name=name, description=description, argument_hint=argument_hint, allowed_tools=allowed_tools, model=model, prompt_template=prompt_body, source_path=md_path.resolve(), ) except Exception as exc: # noqa: BLE001 _LOGGER.warning("Command %s: failed to parse — %s", md_path, exc) return None __all__ = ["CommandDefinition", "CommandRegistry", "render"]