← Files AkinatorARCHIVED FILE

scripts/extract_components.py

12 KB · Oct 3, 2026 · 06:33 UTC

↓ Download file

#!/usr/bin/env python3
"""Generate `context/components.md` from the tree.

Structural facts rot when they are written by hand and changed by code. This
extractor derives the component map from the repository itself, so the map
cannot drift: the one skill and its station references, the agents, the hook,
the tools, the build scripts, the installers and the templates are discovered,
not listed.

The one thing not derivable from the tree is which loop station a reference
serves - a fact about the design, not about the files - so it is declared here,
and the generator **fails** if a reference is missing from the declaration. A
new station reference cannot be added without deciding where in the loop it
belongs, which is the correct forcing function.

Deterministic: sorted iteration, no clock, no absolute paths.

Usage:
    python scripts/extract_components.py            # dry run
    python scripts/extract_components.py --write    # write the map
    python scripts/extract_components.py --check    # exit 1 if drifted
"""

from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path

GENERATOR = "scripts/extract_components.py"
TARGET = "context/components.md"
SKILL = Path("skills") / "everything"

# Which loop station each reference of the one skill serves. Declared, not
# derived - see the module docstring. A reference absent from this map is an
# error, not a default.
STATIONS: dict[str, str] = {
    "akinator": "2 RESOLVE - creed, loop, taxonomy",
    "procedure": "all - the full pass, step by step",
    "akinator-intake": "1 ASK",
    "akinator-audit": "3 AUDIT",
    "akinator-plan": "4 PLAN",
    "akinator-document-change": "6 DOCUMENT",
    "akinator-business-map": "6 DOCUMENT",
    "akinator-product-map": "6 DOCUMENT",
    "akinator-ops-map": "6 DOCUMENT",
    "akinator-adr": "6 DOCUMENT",
    "akinator-skillify": "7 SKILLIFY",
    "akinator-rule-forge": "8 RULE",
    "akinator-contextify": "9 CONTEXTIFY",
    "akinator-memoize": "10 MEMOIZE",
    "akinator-index-sync": "11 INDEX",
    "akinator-router-sync": "11 SYNC",
    "akinator-gate-economy": "12 VERIFY",
    "akinator-coverage": "12 VERIFY",
    "akinator-resource-guard": "discipline",
    "akinator-anti-gaming": "discipline",
    "akinator-onboard": "install",
    "akinator-wiki": "every - document every prompt everywhere it lands",
    "akinator-decide": "4 PLAN / 12 VERIFY - decide or recommend with evidence",
}


def banner() -> str:
    return (
        "<!--\n"
        "GENERATED FILE - DO NOT EDIT BY HAND.\n"
        f"Generated by `{GENERATOR}`. Edit the extractor, then regenerate with:\n"
        f"    python {GENERATOR} --write\n"
        "-->\n"
    )


def frontmatter(path: Path) -> dict[str, str]:
    text = path.read_text(encoding="utf-8")
    if not text.startswith("---"):
        return {}
    end = text.find("\n---", 3)
    if end == -1:
        return {}
    out: dict[str, str] = {}
    key: str | None = None
    for line in text[4:end].splitlines():
        if line and not line[0].isspace() and ":" in line:
            key, _, value = line.partition(":")
            key = key.strip()
            out[key] = value.strip()
        elif key and line.strip():
            out[key] = (out[key] + " " + line.strip()).strip()
    return out


def load_when(path: Path) -> str:
    """The reference's own 'Load when:' line - its trigger, kept from the days
    it was a skill of its own."""
    for line in path.read_text(encoding="utf-8").splitlines():
        if "Load when:" in line:
            return first_sentence(line.split("Load when:", 1)[1].strip())
    return "(no load-when line)"


def first_sentence(text: str, limit: int = 140) -> str:
    text = text.replace("|", "/").strip()
    for end in (". ", ".\n"):
        if end in text:
            text = text.split(end, 1)[0] + "."
            break
    if len(text) > limit:
        text = text[: limit - 1].rsplit(" ", 1)[0] + "..."
    return text


def _files(directory: Path, suffixes: tuple[str, ...]) -> list[Path]:
    if not directory.is_dir():
        return []
    return sorted(p for p in directory.iterdir()
                  if p.is_file() and p.suffix in suffixes)


def render(repo: Path) -> str:
    skill_md = repo / SKILL / "SKILL.md"
    references = _files(repo / SKILL / "references", (".md",))
    tools = _files(repo / SKILL / "scripts", (".py",))
    agents = sorted((repo / "agents").glob("*.md"))
    build = _files(repo / "scripts", (".py", ".sh", ".ps1"))
    installers = [p for p in (repo / "install.sh", repo / "install.ps1") if p.is_file()]
    templates = sorted(p for p in (repo / "templates").glob("*.md")
                       if p.name.lower() != "readme.md")

    undeclared = sorted({p.stem for p in references} - set(STATIONS))
    if undeclared:
        raise SystemExit(
            f"{GENERATOR}: these references have no declared loop station: "
            f"{', '.join(undeclared)}\n"
            "Add them to STATIONS - a reference must belong somewhere in the loop.")

    meta = frontmatter(skill_md) if skill_md.is_file() else {}
    lines: list[str] = [banner()]
    lines += [
        "# Component map",
        "",
        "Every component this plugin ships, where it lives, and which platform reads it.",
        "",
        "## Scope",
        "",
        "- **Covers:** the plugin's own components - the one skill and its station",
        "  references, agents, the hook, tools, build scripts, installers and",
        "  templates - and which platform surface each serves.",
        "- **Does not cover:** what each component *does*. That is `docs/skills.md`,",
        "  `docs/architecture.md`, and the components themselves.",
        "",
        "## The skill - 1",
        "",
        "Akinator is one skill and one command. There is no `commands/` directory:",
        "Claude Code lists every skill in its `/` menu, so the skill itself is the",
        "command. Codex and Cursor read the generated copy in `.agents/skills/`.",
        "",
        "| Skill | Claude Code | Codex | Cursor |",
        "|---|---|---|---|",
        f"| `{meta.get('name', '?')}` (`{SKILL.as_posix()}/SKILL.md`) | "
        "`/akinator:everything` | `$akinator` | `/akinator` |",
        "",
        f"## Station references - {len(references)}",
        "",
        "Inside the one skill, opened when the work reaches that station. None is",
        "a skill of its own, so none is an entry in any menu.",
        "",
        "| Reference | Loop station | Load when |",
        "|---|---|---|",
    ]
    for path in references:
        lines.append(f"| `{path.stem}` | {STATIONS[path.stem]} | {load_when(path)} |")
    lines.append("")

    lines += [f"## Agents - {len(agents)}", "",
              "Read by Claude Code from `agents/`. Codex and Cursor have no equivalent",
              "subagent surface; the skill applies the same review lenses inline there.",
              "", "| Agent | Reviews for |", "|---|---|"]
    for path in agents:
        lines.append(f"| `{path.stem}` | "
                     f"{first_sentence(frontmatter(path).get('description', ''))} |")
    lines.append("")

    hooks_path = repo / "hooks" / "hooks.json"
    events: list[tuple[str, str]] = []
    if hooks_path.is_file():
        data = json.loads(hooks_path.read_text(encoding="utf-8"))
        for event, entries in sorted(data.get("hooks", {}).items()):
            for entry in entries:
                for hook in entry.get("hooks", []):
                    command = " ".join([hook.get("command", ""), *hook.get("args", [])])
                    events.append((event, command))
    lines += [f"## Hooks - {len(events)}", "",
              "Read by Claude Code from `hooks/hooks.json`, in exec form. Codex and",
              "Cursor have no SessionStart equivalent, so the installer gives them the",
              "same contract as an `AGENTS.md` block and an always-applied rule.",
              "", "| Event | Command |", "|---|---|"]
    for event, command in events:
        lines.append(f"| `{event}` | `{command}` |")
    lines.append("")

    lines += [f"## Tools - {len(tools)}", "",
              "Inside the skill, so they travel with it to every platform and run in",
              "whatever repository it is installed into.",
              "", "| Tool | Purpose |", "|---|---|"]
    for path in tools:
        lines.append(f"| `{SKILL.as_posix()}/scripts/{path.name}` | {_script_purpose(path)} |")
    lines.append("")

    lines += [f"## Build scripts - {len(build)}", "",
              "Only meaningful inside this checkout; they never travel.",
              "", "| Script | Purpose |", "|---|---|"]
    for path in build:
        lines.append(f"| `scripts/{path.name}` | {_script_purpose(path)} |")
    lines.append("")

    lines += [f"## Installers - {len(installers)}", "",
              "One installer for Claude Code, Codex and Cursor, in both shells.",
              "", "| Installer | Purpose |", "|---|---|"]
    for path in installers:
        lines.append(f"| `{path.name}` | {_script_purpose(path)} |")
    lines.append("")

    lines += [f"## Templates - {len(templates)}", "",
              "What Akinator writes into target repositories. Every template ships a",
              "filled example.", "", "| Template | Filled example |", "|---|---|"]
    for path in templates:
        example = repo / "templates" / "examples" / path.name
        marker = f"`templates/examples/{path.name}`" if example.is_file() else "**MISSING**"
        lines.append(f"| `templates/{path.name}` | {marker} |")
    lines.append("")

    lines += [
        "## Regenerate when",
        "",
        f"- Extractor: `{GENERATOR}`",
        f"- Regenerate with: `python {GENERATOR} --write`",
        f"- Drift check: `python {GENERATOR} --check` in CI - regenerates in",
        "  memory and diffs; exits non-zero if they differ.",
        "- Regenerate when: a reference, agent, hook, tool, script, installer or",
        "  template is added, removed or renamed.",
        "",
        "## Related",
        "",
        "- Docs: `docs/architecture.md` - what these components do and how they",
        "  interact",
        "- Docs: `docs/compatibility.md` - the platform contracts each surface",
        "  relies on",
        "- Skills: `docs/skills.md` - the one skill and its station references",
        "",
    ]
    return "\n".join(lines)


def _script_purpose(path: Path) -> str:
    """The first line of the script's docstring or header comment."""
    text = path.read_text(encoding="utf-8", errors="replace")
    for line in text.splitlines():
        stripped = line.strip()
        if stripped.startswith('"""'):
            return first_sentence(stripped.strip('"').strip(), 100)
        if stripped.startswith("# ") and "!/" not in stripped:
            return first_sentence(stripped[2:].strip(), 100)
        if stripped.startswith(".SYNOPSIS"):
            continue
        if stripped.startswith("Akinator installer"):
            return first_sentence(stripped, 100)
    return "(no description)"


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(prog="extract_components")
    parser.add_argument("root", nargs="?", default=".")
    parser.add_argument("--write", action="store_true")
    parser.add_argument("--check", action="store_true")
    args = parser.parse_args(argv)

    repo = Path(args.root).resolve()
    if not (repo / SKILL / "SKILL.md").is_file():
        print(f"no {SKILL.as_posix()}/SKILL.md under {repo}", file=sys.stderr)
        return 2

    desired = render(repo)
    target = repo / TARGET
    current = target.read_text(encoding="utf-8") if target.is_file() else None

    if args.write:
        if current == desired:
            print(f"{TARGET} already up to date")
            return 0
        target.parent.mkdir(parents=True, exist_ok=True)
        target.write_text(desired, encoding="utf-8", newline="\n")
        print(f"wrote {TARGET}")
        return 0

    if current == desired:
        print(f"{TARGET} matches the tree.")
        return 0

    print(f"{TARGET} is drifted." if current else f"{TARGET} is missing.")
    print(f"Fix with: python {GENERATOR} --write")
    return 1


if __name__ == "__main__":
    raise SystemExit(main())

SHA-256: 54a26087b405e6b19dc78ef641e1a62cd1e680ec9cfba8a7472c01e8d15f8387