Coverage for plugin/scripts/_rhiza_layout.py: 100%
26 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-15 14:46 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-15 14:46 +0000
1#!/usr/bin/env python3
2"""Where the plugin sits inside this repository, and where its commands are found.
4The shipped plugin lives in ``plugin/`` — ``commands/``, ``skills/`` and ``hooks/`` are
5*discovery locations* the Claude Code plugin spec requires at the *plugin* root, and
6``.claude-plugin/marketplace.json`` points at it with ``"source": "./plugin"``.
7``prompts/`` and ``scripts/`` are this repo's own conventions and sit alongside them.
8Everything that builds or checks the repo — ``docs/``, ``tests/``, ``paper/`` and the
9top-of-repo prose — stays at the *repository* root.
11Four checkers span both halves (``check_command_contracts.py`` reads the commands *and*
12the README, ``check_docs_nav.py`` and ``render_command_docs.py`` map commands to docs
13pages, ``check_prompt_wiring.py`` compares command names against procedure names).
14Without a shared definition each would hardcode ``"plugin"`` — and now the two command
15layouts — separately, which is the duplication this repo gates everywhere else. One
16name, one place to change.
17"""
19from __future__ import annotations
21from pathlib import Path
23PLUGIN_DIR = "plugin"
24"""The plugin root, relative to the repository root."""
26COMMANDS_DIR = f"{PLUGIN_DIR}/commands"
27"""Slash commands in the legacy flat layout, relative to the repository root."""
29SKILLS_DIR = f"{PLUGIN_DIR}/skills"
30"""Slash commands in the current per-directory layout, relative to the repository root."""
32SKILL_FILE = "SKILL.md"
33"""The file a skill directory must hold. The directory name, not this, is the command."""
35PROMPTS_DIR = f"{PLUGIN_DIR}/prompts"
36"""Internal procedures, relative to the repository root."""
38SCRIPTS_DIR = f"{PLUGIN_DIR}/scripts"
39"""Bundled stdlib-only Python, relative to the repository root."""
41PLUGIN_MANIFEST = f"{PLUGIN_DIR}/.claude-plugin/plugin.json"
42"""The plugin manifest. Lives with the plugin, not with the marketplace."""
44MARKETPLACE_MANIFEST = ".claude-plugin/marketplace.json"
45"""The marketplace catalogue. Stays at the repository root, where `add` looks."""
47DOCS_SKILLS_DIR = "docs/skills"
48"""Where a command's docs page lives, relative to the repository root.
50Named after the layout it documents, one page per ``skills/<name>/SKILL.md``. Both
51``check_docs_nav.py`` and ``render_command_docs.py`` resolve pages through here rather
52than spelling the directory out, because it was hardcoded in both and so drifted twice.
53"""
55DOCS_INTERNALS_DIR = "docs/internals"
56"""Where a procedure's docs page lives, relative to the repository root."""
59def command_files(root: Path) -> list[tuple[str, Path]]:
60 """Every slash command in the plugin at *root*, as ``(name, path)`` sorted by name.
62 Both layouts are discovered because Claude Code loads both. ``commands/<name>.md``
63 is the legacy flat spelling; ``skills/<name>/SKILL.md`` is the current one, and the
64 docs describe the former as "Skills as flat Markdown files". Either way *name* is
65 the segment that follows ``/rhiza:`` — for a skill that is the **directory** name,
66 since ``SKILL.md`` is the same basename for every one of them.
68 A name claimed by both layouts is returned twice rather than silently deduplicated;
69 ``check_command_contracts.py`` reports it, because which file wins at runtime is not
70 something this repo should be resting on.
72 The directory is the command, and every skill's file has the same basename — which is
73 why nothing may resolve a command by its path:
75 >>> import tempfile
76 >>> with tempfile.TemporaryDirectory() as tmp:
77 ... skill = Path(tmp) / SKILLS_DIR / "init"
78 ... skill.mkdir(parents=True)
79 ... _ = (skill / SKILL_FILE).write_text("stub", encoding="utf-8")
80 ... command_files(Path(tmp))[0][0]
81 'init'
82 """
83 found = [(path.stem, path) for path in (root / COMMANDS_DIR).glob("*.md")]
84 found += [(path.parent.name, path) for path in (root / SKILLS_DIR).glob(f"*/{SKILL_FILE}")]
85 return sorted(found)