← Files AkinatorARCHIVED FILE
scripts/build_codex_pack.py
15.8 KB · Oct 3, 2026 · 06:33 UTC
#!/usr/bin/env python3
"""Generate the portable pack - Akinator's one skill, for Codex and Cursor.
Akinator is ONE skill. The canonical copy is `skills/everything/` - its
`SKILL.md`, its station `references/` and its host-repo tools in `scripts/`.
Claude Code loads it straight from the plugin, where it is `/akinator:everything`.
This script projects that same skill into the places Codex and Cursor read, plus
the always-on contract each of them needs because neither has Claude's
SessionStart hook:
.agents/skills/akinator/ the one skill - SKILL.md, references/,
scripts/. Codex AND Cursor both load skills
from .agents/skills (repo) and
~/.agents/skills (user), so one folder
serves both: $akinator on Codex, /akinator
on Cursor.
.agents/AGENTS.md the portable always-on contract. The
installer merges it, as a marked block,
into ~/.codex/AGENTS.md or a repo's
AGENTS.md - which Cursor reads too.
.agents/cursor/akinator.mdc the same contract as an alwaysApply Cursor
rule, for ~/.cursor/rules.
This repository's own AGENTS.md is NOT generated here - it is one of eleven
routers rendered from context/router-contract.md by scripts/render_routers.py.
**Why one skill, not twenty-one.** Codex has no way to hide a skill from its `$`
picker (a skill's `allow_implicit_invocation: false` hides it from the model,
not from the user), and every folder under .agents/skills is also a `/` entry in
Cursor. Twenty-one skills meant twenty-one entries on every platform, for a
plugin whose owner asked for exactly one. See
docs/adr/0009-one-skill-one-command-one-installer.md.
Everything here is a build output. Editing it by hand is a rule violation - see
rules/07-codex-pack-is-generated.md. Generation is deterministic: the same
source produces byte-identical output, which is what makes the drift check
meaningful.
Usage:
python scripts/build_codex_pack.py # dry run, report what differs
python scripts/build_codex_pack.py --write # write the pack
python scripts/build_codex_pack.py --check # exit 1 if the tree is drifted
Exit codes:
0 the pack matches the canonical skill (or --write succeeded)
1 drift detected (--check), or files would change (default dry run)
2 the generator could not run
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
SKILL_SOURCE = "skills/everything"
# Outside Claude there is no plugin namespace, so the name has to carry it:
# `everything` alone would be an ambiguous `$everything` in a shared skills
# folder. Claude's `/akinator:everything` and this `akinator` are the same skill.
PORTABLE_NAME = "akinator"
TARGET = f".agents/skills/{PORTABLE_NAME}"
CONTRACT = ".agents/AGENTS.md"
CURSOR_RULE = ".agents/cursor/akinator.mdc"
PACK_ROOTS = (".agents/skills", ".agents/cursor", CONTRACT)
# Frontmatter keys that mean something to Claude Code only. Codex ignores
# unknown keys, but a projection that carries dead keys invites someone to rely
# on them.
CLAUDE_ONLY_KEYS = ("argument-hint",)
def banner(what: str) -> str:
"""The pack banner for markdown. No timestamp - see the module docstring.
Names **no path and no filename**. Every file in the pack is copied into
other repositories, where a path to the generator, the source or a rule does
not exist; naming one made each installed file assert something untrue
about its host - the defect rules/12 exists to stop. So the banner gives the
two things a vendored file owes its reader: where it came from, and how to
get a fresh copy.
"""
return (
"<!--\n"
"DO NOT EDIT BY HAND.\n"
f"Installed from the Akinator plugin - {what}.\n"
"No generator is named by path: this file travels into repositories\n"
"that do not have one, where naming it would be a false claim.\n"
"To update: reinstall Akinator, or regenerate inside an Akinator\n"
"checkout. Local edits here are replaced either way.\n"
"-->\n"
)
def code_banner() -> str:
"""The same banner for the Python tools, as comments."""
return (
"# DO NOT EDIT BY HAND. Installed from the Akinator plugin - one of the\n"
"# tools of its one skill. To update: reinstall Akinator, or regenerate\n"
"# inside an Akinator checkout. Local edits here are replaced.\n"
)
def contract_banner() -> str:
"""Banner for the portable contract. Names no path and no filename."""
return "\n".join([
"<!--",
"Akinator behavioral contract - DO NOT EDIT BY HAND.",
"",
"Installed from the Akinator plugin. No generator is named by path or",
"by filename: this file travels into repositories that have neither,",
"where naming one would assert a file that is not in the tree.",
"",
"To update: reinstall Akinator, or regenerate inside an Akinator",
"checkout. Local edits here are replaced - keep this repository's own",
"content in its own router.",
"-->",
"",
])
def frontmatter_and_body(text: str) -> tuple[str, str]:
"""Split a SKILL.md into its frontmatter block and the rest."""
if not text.startswith("---"):
return "", text
end = text.find("\n---", 3)
if end == -1:
return "", text
close = text.find("\n", end + 1)
return text[: close + 1], text[close + 1 :]
def render_skill(text: str) -> str:
"""Project the one canonical skill into its portable form.
Two transformations, both deliberate and nothing else: the name becomes
`akinator` (see PORTABLE_NAME), and Claude-only keys are dropped. The banner
goes *after* the frontmatter - a comment before the opening `---` would stop
it being parsed as frontmatter at all.
"""
front, body = frontmatter_and_body(text)
if not front:
raise ValueError(f"{SKILL_SOURCE}/SKILL.md has no frontmatter")
lines = []
for line in front.splitlines():
if line.startswith("name:"):
lines.append(f"name: {PORTABLE_NAME}")
elif line.split(":", 1)[0] in CLAUDE_ONLY_KEYS:
continue
else:
lines.append(line)
return ("\n".join(lines) + "\n"
+ banner("its one skill, which Claude Code calls /akinator:everything")
+ body)
def render_tool(text: str) -> str:
"""A tool, with the banner as comments - after the shebang, which must stay
on line 1 for the file to remain directly executable."""
if text.startswith("#!"):
first, rest = text.split("\n", 1)
return first + "\n" + code_banner() + rest
return code_banner() + text
def render_contract_body() -> str:
"""The contract itself, without banner or frontmatter.
Names **no repository-specific paths**: it is installed into repositories
and home directories Akinator has never seen. It carries the creed, the loop
and the non-negotiables - true everywhere - and tells the agent to discover
the knowledge layer this particular repository actually has.
"""
return "\n".join([
"# Akinator — ALWAYS ON",
"",
"Ask everything. Document everything. Skillify everything. Rule everything.",
"",
"A change is never the code alone. A change is the code plus the knowledge that lets",
"the next agent act on it in seconds. Half a change is no change.",
"",
"## The loop",
"",
"Every user prompt enters this contract first. For repository-changing work, load",
"Akinator's one skill, `akinator`, and run its complete pass - all twelve stations:",
"",
"```",
"ASK -> RESOLVE -> AUDIT -> PLAN -> IMPLEMENT -> DOCUMENT ->",
"SKILLIFY -> RULE -> CONTEXTIFY -> MEMOIZE -> INDEX+SYNC -> VERIFY",
"```",
"",
"Each station is a reference file inside that skill, opened when the work reaches",
"it, and the skill's tools live in its own `scripts` folder. There is nothing else",
"to install and nothing to type: the explicit form - `$akinator` on Codex,",
"`/akinator` on Cursor, `/akinator:everything` on Claude Code - is a fallback.",
"",
"Non-negotiable:",
"",
"- Stations 6-11 happen in the same batch as station 5. \"I'll document in a",
" follow-up\" is a prohibited sentence.",
"- The knowledge delta is declared at PLAN time, **by path**, per batch. A batch",
" with no knowledge delta states why, explicitly.",
"- Gate once, at the end, scoped to what was touched. Never per edit, never per",
" commit, never all-workspace.",
"- Never add knowledge or documentation checks to git hooks. Hooks gate code.",
"- **Adopt, never impose.** Match this repository's existing conventions before",
" creating anything. A parallel structure beside an existing one is worse than",
" no structure - the agent picks the wrong one half the time.",
"- Never guess on money, permissions, deletion, security or public contracts.",
" Stop, ask, and write the answer down before coding past it.",
"",
"## Station 2 - RESOLVE, before anything",
"",
"Discover what this repository actually has, then read it in this order,",
"stopping when your question is answered:",
"",
"```",
"routers CLAUDE.md, AGENTS.md, CODEX.md, and any per-module ones",
"rules constraints you may not break",
"skills runbooks - follow one rather than improvising",
"context structural facts: ownership, routes, events, permissions",
"memory durable decisions, preferences, surprises",
"docs architecture, business, product, ops, decision records",
"```",
"",
"Those are the conventional homes, not a promise about this repo. Look first;",
"this repository may use different names, and if it does, **its** names win.",
"",
"If none of them exist, say so rather than inventing a structure, and onboard",
"the repository properly - the `akinator` skill carries the onboarding station.",
"",
])
def render_contract_md() -> str:
return contract_banner() + render_contract_body()
def render_cursor_rule() -> str:
"""The contract as an always-applied Cursor rule. Frontmatter first - Cursor
parses it only at the top of the file - then the banner."""
return ("---\n"
"description: Akinator - the always-on repository contract\n"
"alwaysApply: true\n"
"---\n"
+ contract_banner()
+ render_contract_body())
def _files(directory: Path, pattern: str) -> list[Path]:
"""Sorted, deterministic, and never a cache directory."""
if not directory.is_dir():
return []
return sorted(p for p in directory.glob(pattern)
if p.is_file() and "__pycache__" not in p.parts)
def plan(repo: Path) -> dict[str, str]:
"""The full desired content of the pack, keyed by repo-relative path."""
source = repo / SKILL_SOURCE
skill_md = source / "SKILL.md"
if not skill_md.is_file():
raise FileNotFoundError(f"{SKILL_SOURCE}/SKILL.md is missing")
out: dict[str, str] = {}
out[f"{TARGET}/SKILL.md"] = render_skill(
skill_md.read_text(encoding="utf-8"))
for ref in _files(source / "references", "*.md"):
out[f"{TARGET}/references/{ref.name}"] = (
banner(f"a station reference of its one skill ({ref.stem})")
+ ref.read_text(encoding="utf-8"))
for tool in _files(source / "scripts", "*.py"):
out[f"{TARGET}/scripts/{tool.name}"] = render_tool(
tool.read_text(encoding="utf-8"))
out[CONTRACT] = render_contract_md()
out[CURSOR_RULE] = render_cursor_rule()
return out
def existing_pack(repo: Path) -> set[str]:
"""Every file currently in the generated pack."""
found: set[str] = set()
for root in PACK_ROOTS:
path = repo / root
if path.is_file():
found.add(root)
elif path.is_dir():
for child in path.rglob("*"):
if child.is_file() and "__pycache__" not in child.parts:
found.add(child.relative_to(repo).as_posix())
return found
def diff(repo: Path) -> tuple[list[str], list[str], list[str]]:
"""(changed, missing, extra) repo-relative paths."""
desired = plan(repo)
present = existing_pack(repo)
changed: list[str] = []
missing: list[str] = []
for rel, content in sorted(desired.items()):
path = repo / rel
if not path.is_file():
missing.append(rel)
elif path.read_text(encoding="utf-8", errors="replace") != content:
changed.append(rel)
extra = sorted(present - set(desired))
return changed, missing, extra
def write(repo: Path) -> tuple[list[str], list[str]]:
"""Write the pack. Returns (written, removed)."""
desired = plan(repo)
present = existing_pack(repo)
written: list[str] = []
for rel, content in sorted(desired.items()):
path = repo / rel
path.parent.mkdir(parents=True, exist_ok=True)
current = (path.read_text(encoding="utf-8", errors="replace")
if path.is_file() else None)
if current != content:
path.write_text(content, encoding="utf-8", newline="\n")
written.append(rel)
removed: list[str] = []
for rel in sorted(present - set(desired)):
(repo / rel).unlink()
removed.append(rel)
# Prune every directory the removal emptied, deepest first, so a removed
# skill - or all twenty of the old per-station ones - leaves nothing behind.
for root in PACK_ROOTS:
base = repo / root
if not base.is_dir():
continue
for path in sorted((p for p in base.rglob("*") if p.is_dir()),
key=lambda p: len(p.parts), reverse=True):
if not any(path.iterdir()):
path.rmdir()
return written, removed
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
prog="build_codex_pack",
description="Generate the portable pack from Akinator's one skill.",
)
parser.add_argument("root", nargs="?", default=".")
parser.add_argument("--write", action="store_true",
help="write the pack to disk")
parser.add_argument("--check", action="store_true",
help="exit 1 if the pack is drifted; write nothing")
args = parser.parse_args(argv)
repo = Path(args.root).resolve()
if not (repo / SKILL_SOURCE / "SKILL.md").is_file():
print(f"no {SKILL_SOURCE}/SKILL.md under {repo}", file=sys.stderr)
return 2
if args.write:
written, removed = write(repo)
for rel in written:
print(f"wrote {rel}")
for rel in removed:
print(f"removed {rel}")
if not written and not removed:
print("pack already up to date")
return 0
changed, missing, extra = diff(repo)
for rel in missing:
print(f"missing {rel}")
for rel in changed:
print(f"changed {rel}")
for rel in extra:
print(f"extra {rel}")
if changed or missing or extra:
total = len(changed) + len(missing) + len(extra)
print(f"\n{total} file(s) differ from the canonical skill - "
"the portable pack is drifted.")
print("Fix with: python scripts/build_codex_pack.py --write")
return 1
print("Portable pack matches the canonical skill.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
SHA-256: 5c84102ec667a2bb64dd59bab975b2248c95cf4f563263ca793e34a60dda4a97