"""Workspace-scoped Claude Code slash-command installer. Sibling to `cli/lib/hooks.py`. Where hooks live in `/.claude/settings.json`, slash commands live as one markdown file per command in `/.claude/commands/`. This module installs the Agnes-managed slash commands into a workspace. Design notes: - Workspace-scoped (`/.claude/commands/.md`), NOT user-home. The slash commands appear only when Claude Code opens this workspace, matching the hook scoping in `hooks.py`. - Idempotent: always overwrites *our* files (server-managed canonical content, naturally evolves with the CLI version) but never touches third-party slash commands the user (or another tool) may have authored under `.claude/commands/`. Listing files individually rather than wiping the directory keeps custom commands safe. - Templates ship inside the wheel under `cli/templates/commands/`. `pyproject.toml` declares `cli` as a hatch wheel package, so hatchling includes the markdown bodies during the build the same way it ships `config/agnes_workspace_template.txt`. """ from __future__ import annotations import sys from pathlib import Path # Slash commands managed by `agnes init`. Source (template name on # disk under `cli/templates/commands/`) → destination filename in # `/.claude/commands/`. Today both names match; the indirection # keeps the door open for renaming (e.g. internal template name vs the # `/` slug exposed to Claude Code). _MANAGED_COMMANDS: tuple[tuple[str, str], ...] = ( ("update-agnes-plugins.md", "update-agnes-plugins.md"), ("agnes-private.md", "agnes-private.md"), ) # Defensive fallbacks used when the bundled template is missing on disk # (broken install, stripped-down test environment). Keyed by source # template filename so a missing `agnes-private.md` doesn't get # clobbered with `update-agnes-plugins` content. _FALLBACK_BODIES: dict[str, str] = { "update-agnes-plugins.md": ( "---\n" "description: Update Agnes marketplace plugins to latest versions\n" "---\n" "\n" "Run `agnes refresh-marketplace` and report the output.\n" ), "agnes-private.md": ( "---\n" "description: Mark the current Claude Code session as private\n" "---\n" "\n" "!`agnes mark-private`\n" ), } def _templates_dir() -> Path: """Locate the bundled-template directory. `cli/lib/commands.py` → `cli/templates/commands/`. Two `.parent` hops: lib/ → cli/, then descend into templates/commands/. """ return Path(__file__).parent.parent / "templates" / "commands" def install_claude_commands(workspace: Path) -> None: """Install Agnes-managed slash commands into `/.claude/commands/`. Always writes (overwrites) the managed command files; never touches other files the user may have under `.claude/commands/`. Idempotent. """ commands_dir = workspace / ".claude" / "commands" commands_dir.mkdir(parents=True, exist_ok=True) templates_dir = _templates_dir() for source_name, dest_name in _MANAGED_COMMANDS: source_path = templates_dir / source_name try: body = source_path.read_text(encoding="utf-8") except OSError: print( f"Warning: bundled slash-command template " f"{source_path} missing; writing defensive fallback.", file=sys.stderr, ) body = _FALLBACK_BODIES.get(source_name, "") (commands_dir / dest_name).write_text(body, encoding="utf-8")