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

1#!/usr/bin/env python3 

2"""Where the plugin sits inside this repository, and where its commands are found. 

3 

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. 

10 

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""" 

18 

19from __future__ import annotations 

20 

21from pathlib import Path 

22 

23PLUGIN_DIR = "plugin" 

24"""The plugin root, relative to the repository root.""" 

25 

26COMMANDS_DIR = f"{PLUGIN_DIR}/commands" 

27"""Slash commands in the legacy flat layout, relative to the repository root.""" 

28 

29SKILLS_DIR = f"{PLUGIN_DIR}/skills" 

30"""Slash commands in the current per-directory layout, relative to the repository root.""" 

31 

32SKILL_FILE = "SKILL.md" 

33"""The file a skill directory must hold. The directory name, not this, is the command.""" 

34 

35PROMPTS_DIR = f"{PLUGIN_DIR}/prompts" 

36"""Internal procedures, relative to the repository root.""" 

37 

38SCRIPTS_DIR = f"{PLUGIN_DIR}/scripts" 

39"""Bundled stdlib-only Python, relative to the repository root.""" 

40 

41PLUGIN_MANIFEST = f"{PLUGIN_DIR}/.claude-plugin/plugin.json" 

42"""The plugin manifest. Lives with the plugin, not with the marketplace.""" 

43 

44MARKETPLACE_MANIFEST = ".claude-plugin/marketplace.json" 

45"""The marketplace catalogue. Stays at the repository root, where `add` looks.""" 

46 

47DOCS_SKILLS_DIR = "docs/skills" 

48"""Where a command's docs page lives, relative to the repository root. 

49 

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""" 

54 

55DOCS_INTERNALS_DIR = "docs/internals" 

56"""Where a procedure's docs page lives, relative to the repository root.""" 

57 

58 

59def command_files(root: Path) -> list[tuple[str, Path]]: 

60 """Every slash command in the plugin at *root*, as ``(name, path)`` sorted by name. 

61 

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. 

67 

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. 

71 

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: 

74 

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)