teken 0.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. teken/__init__.py +13 -0
  2. teken/__main__.py +8 -0
  3. teken/_brand.py +27 -0
  4. teken/cite/__init__.py +19 -0
  5. teken/cite/_engine.py +181 -0
  6. teken/cite/references/python-cli/AGENT.md +98 -0
  7. teken/cite/references/python-cli/MANIFEST.json +65 -0
  8. teken/cite/references/python-cli/tests/test_cli.py +46 -0
  9. teken/cite/references/python-cli/{{slug}}/__init__.py +12 -0
  10. teken/cite/references/python-cli/{{slug}}/__main__.py +10 -0
  11. teken/cite/references/python-cli/{{slug}}/cli/__init__.py +84 -0
  12. teken/cite/references/python-cli/{{slug}}/cli/_commands/__init__.py +0 -0
  13. teken/cite/references/python-cli/{{slug}}/cli/_commands/explain.py +38 -0
  14. teken/cite/references/python-cli/{{slug}}/cli/_commands/learn.py +81 -0
  15. teken/cite/references/python-cli/{{slug}}/cli/_errors.py +41 -0
  16. teken/cite/references/python-cli/{{slug}}/cli/_output.py +53 -0
  17. teken/cite/references/python-cli/{{slug}}/explain/__init__.py +24 -0
  18. teken/cite/references/python-cli/{{slug}}/explain/catalog.py +66 -0
  19. teken/cli/__init__.py +160 -0
  20. teken/cli/_commands/__init__.py +0 -0
  21. teken/cli/_commands/cli.py +198 -0
  22. teken/cli/_commands/doctor.py +564 -0
  23. teken/cli/_commands/explain.py +38 -0
  24. teken/cli/_commands/learn.py +130 -0
  25. teken/cli/_commands/overview.py +51 -0
  26. teken/cli/_errors.py +42 -0
  27. teken/cli/_output.py +58 -0
  28. teken/doctor/__init__.py +66 -0
  29. teken/doctor/_self_checks.py +347 -0
  30. teken/doctor/fixes.py +73 -0
  31. teken/explain/__init__.py +28 -0
  32. teken/explain/catalog.py +390 -0
  33. teken/overview/__init__.py +136 -0
  34. teken/overview/cli_surface.py +455 -0
  35. teken/rubric/__init__.py +53 -0
  36. teken/rubric/_runner.py +102 -0
  37. teken/rubric/_types.py +70 -0
  38. teken/rubric/checks/__init__.py +0 -0
  39. teken/rubric/checks/doctor.py +270 -0
  40. teken/rubric/checks/errors.py +107 -0
  41. teken/rubric/checks/explain_cmd.py +87 -0
  42. teken/rubric/checks/json_output.py +97 -0
  43. teken/rubric/checks/learnability.py +74 -0
  44. teken/rubric/checks/overview_cmd.py +181 -0
  45. teken/rubric/checks/structure.py +297 -0
  46. teken-0.8.0.dist-info/METADATA +93 -0
  47. teken-0.8.0.dist-info/RECORD +50 -0
  48. teken-0.8.0.dist-info/WHEEL +4 -0
  49. teken-0.8.0.dist-info/entry_points.txt +3 -0
  50. teken-0.8.0.dist-info/licenses/LICENSE +21 -0
teken/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """teken — Agent First Interface scaffolder."""
2
+
3
+ from importlib.metadata import PackageNotFoundError
4
+ from importlib.metadata import version as _v
5
+
6
+ from teken import _brand
7
+
8
+ try:
9
+ __version__ = _v(_brand.DIST)
10
+ except PackageNotFoundError: # editable install without metadata
11
+ __version__ = "0.0.0+local"
12
+
13
+ __all__ = ["__version__"]
teken/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Allow running teken as ``python -m teken``."""
2
+
3
+ import sys
4
+
5
+ from teken.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
teken/_brand.py ADDED
@@ -0,0 +1,27 @@
1
+ """Single source of truth for brand-identifying strings.
2
+
3
+ The project was renamed from ``afi`` to ``teken`` (Hebrew תֶּקֶן, "standard").
4
+ Every user-facing reference to the program name, distribution, or dot-directory
5
+ should read from here so a future rename is a one-line change. ``LEGACY_*``
6
+ values keep the old ``afi`` surface working during the migration.
7
+ """
8
+
9
+ PROG = "teken" # primary CLI command + argparse prog
10
+ LEGACY_PROG = "afi" # deprecated alias command
11
+ DIST = "teken" # canonical PyPI distribution (importlib.metadata key)
12
+ LEGACY_DIST = "afi-cli" # wrapper distribution; self-doctor still recognises it
13
+ DOTDIR = ".teken" # primary dot-directory for cited references
14
+ LEGACY_DOTDIR = ".afi" # read-fallback dot-directory (existing trees)
15
+ REPO_URL = "https://github.com/agentculture/teken"
16
+ ISSUES_URL = f"{REPO_URL}/issues"
17
+
18
+ __all__ = [
19
+ "PROG",
20
+ "LEGACY_PROG",
21
+ "DIST",
22
+ "LEGACY_DIST",
23
+ "DOTDIR",
24
+ "LEGACY_DOTDIR",
25
+ "REPO_URL",
26
+ "ISSUES_URL",
27
+ ]
teken/cite/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ """``teken cli cite`` engine — emit the agent-first reference tree.
2
+
3
+ Public API:
4
+
5
+ * :func:`emit_reference` — copy the reference tree for ``lang`` into
6
+ ``<target>/.teken/reference/<lang>-cli/`` (tokens left literal) and add
7
+ ``.teken/`` to ``.gitignore`` if missing.
8
+ * :class:`CiteReport` — structured outcome (used by CLI for json/text output).
9
+ * :data:`SUPPORTED_LANGS` — tuple of renderer names shipped today.
10
+
11
+ The tree under :mod:`teken.cite.references` is package data; token substitution
12
+ is *not* performed — the consuming agent does that.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from teken.cite._engine import SUPPORTED_LANGS, CiteReport, emit_reference
18
+
19
+ __all__ = ["CiteReport", "SUPPORTED_LANGS", "emit_reference"]
teken/cite/_engine.py ADDED
@@ -0,0 +1,181 @@
1
+ """Cite engine — copies the reference tree and updates ``.gitignore``.
2
+
3
+ The operation is safe by construction:
4
+
5
+ * writes only under ``out`` (default ``<target>/.teken/reference/<lang>-cli/``);
6
+ * ``out`` is required to resolve to a path strictly inside ``target_path`` —
7
+ this bounds the :func:`shutil.rmtree`/``copytree`` blast radius to the
8
+ caller's project, mitigating path-injection (S2083) from a hostile
9
+ ``--out`` override;
10
+ * adds one line to ``<target>/.gitignore`` only when ``.teken/`` is absent;
11
+ * touches nothing else in the target project;
12
+ * re-running wipes and re-copies ``out`` — always the latest reference.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import shutil
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+
21
+ from teken import _brand
22
+ from teken.cli._errors import EXIT_ENV_ERROR, EXIT_USER_ERROR, AfiError
23
+
24
+ SUPPORTED_LANGS = ("python",)
25
+
26
+ GITIGNORE_ENTRY = f"{_brand.DOTDIR}/"
27
+
28
+ _REFERENCES_DIR = Path(__file__).resolve().parent / "references"
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class CiteReport:
33
+ out: Path
34
+ written_count: int
35
+ gitignore_updated: bool
36
+
37
+ def describe_next_steps(self) -> list[str]:
38
+ return [
39
+ f"Read {self.out / 'AGENT.md'} — describes each file's role "
40
+ "(stable-contract vs shape-adapt) and lists the {{tokens}} to substitute.",
41
+ "Apply the pattern to your project: copy stable-contract files verbatim, "
42
+ "reshape shape-adapt files to your module layout, then substitute "
43
+ "{{project_name}}, {{slug}}, {{module}} throughout.",
44
+ "Run `teken cli verify .` to confirm the result satisfies the agent-first rubric.",
45
+ ]
46
+
47
+ def to_dict(self) -> dict[str, object]:
48
+ return {
49
+ "out": str(self.out),
50
+ "written_count": self.written_count,
51
+ "gitignore_updated": self.gitignore_updated,
52
+ "next_steps": self.describe_next_steps(),
53
+ "further_reading": {
54
+ "agent_md": str(self.out / "AGENT.md"),
55
+ "explain": ["teken explain cli cite", "teken explain cli verify"],
56
+ },
57
+ }
58
+
59
+
60
+ def _validated_out(out: Path, target_path: Path) -> Path:
61
+ """Resolve ``out`` and verify it sits strictly inside ``target_path``.
62
+
63
+ This is the anti-path-injection gate. ``shutil.rmtree`` is destructive; we
64
+ only accept an ``out`` that resolves to a descendant of the already
65
+ resolved target directory. ``out == target_path`` is also rejected (would
66
+ wipe the whole project). Symlinks are followed before comparison.
67
+ """
68
+ resolved = out.resolve()
69
+ if resolved == target_path:
70
+ raise AfiError(
71
+ code=EXIT_USER_ERROR,
72
+ message="--out cannot equal the target path (would wipe the project)",
73
+ remediation="pass a subpath inside the target, e.g. --out ./reference/",
74
+ )
75
+ try:
76
+ resolved.relative_to(target_path)
77
+ except ValueError as err:
78
+ raise AfiError(
79
+ code=EXIT_USER_ERROR,
80
+ message=f"--out must be inside the target path: {resolved} is not under {target_path}",
81
+ remediation="pick a path inside the target project, or omit --out for the default",
82
+ ) from err
83
+ return resolved
84
+
85
+
86
+ def emit_reference(
87
+ target_path: Path,
88
+ *,
89
+ lang: str = "python",
90
+ out: Path | None = None,
91
+ ) -> CiteReport:
92
+ """Copy the ``lang`` reference tree into ``out`` and touch ``.gitignore``."""
93
+ if lang not in SUPPORTED_LANGS:
94
+ raise AfiError(
95
+ code=EXIT_USER_ERROR,
96
+ message=f"unsupported lang: {lang}",
97
+ remediation=f"supported langs: {', '.join(SUPPORTED_LANGS)}",
98
+ )
99
+ try:
100
+ target_path = target_path.resolve(strict=True)
101
+ except FileNotFoundError as err:
102
+ raise AfiError(
103
+ code=EXIT_USER_ERROR,
104
+ message=f"target path does not exist: {target_path}",
105
+ remediation="pass a path to an existing directory, or '.' for cwd",
106
+ ) from err
107
+ if not target_path.is_dir():
108
+ raise AfiError(
109
+ code=EXIT_USER_ERROR,
110
+ message=f"target path is not a directory: {target_path}",
111
+ remediation="pass a path to an existing directory, or '.' for cwd",
112
+ )
113
+
114
+ if out is None:
115
+ out = target_path / _brand.DOTDIR / "reference" / f"{lang}-cli"
116
+ # Canonicalise + validate: out must resolve inside target_path. Bounds the
117
+ # blast radius of the `shutil.rmtree(out)` call below (CWE-22 / S2083).
118
+ out = _validated_out(out, target_path)
119
+ if out.exists() and not out.is_dir():
120
+ raise AfiError(
121
+ code=EXIT_USER_ERROR,
122
+ message=f"--out exists but is not a directory: {out}",
123
+ remediation="remove that file or pick a different --out",
124
+ )
125
+
126
+ src = _REFERENCES_DIR / f"{lang}-cli"
127
+ if not src.is_dir():
128
+ raise AfiError(
129
+ code=EXIT_ENV_ERROR,
130
+ message=f"reference tree missing at {src}",
131
+ remediation="file a bug — reference data not packaged",
132
+ )
133
+
134
+ if out.exists():
135
+ shutil.rmtree(out) # out has been validated to live inside target_path
136
+ out.parent.mkdir(parents=True, exist_ok=True)
137
+ shutil.copytree(src, out)
138
+
139
+ written = sum(1 for p in out.rglob("*") if p.is_file())
140
+ gitignore_updated = _ensure_gitignore_line(target_path)
141
+
142
+ return CiteReport(out=out, written_count=written, gitignore_updated=gitignore_updated)
143
+
144
+
145
+ def _ensure_gitignore_line(project_root: Path) -> bool:
146
+ """Ensure ``GITIGNORE_ENTRY`` is present in ``<project_root>/.gitignore``.
147
+
148
+ Security (S2083 defence in depth): ``project_root`` must be an already
149
+ resolved, absolute directory. The callee *validates* that precondition
150
+ before touching the filesystem, then constructs the path using a literal
151
+ filename (``.gitignore``) — no user-controlled traversal component can
152
+ escape ``project_root``.
153
+
154
+ Returns ``True`` if the file was created or appended; ``False`` if the
155
+ line (or an equivalent glob) was already present.
156
+ """
157
+ if not project_root.is_absolute() or not project_root.is_dir():
158
+ raise AfiError(
159
+ code=EXIT_USER_ERROR,
160
+ message=f"project_root is not a resolved directory: {project_root}",
161
+ remediation="pass an existing directory; '.' is resolved internally",
162
+ )
163
+ # Literal filename — the only path component we concatenate onto the
164
+ # pre-validated project_root. S2083 sanitiser pattern: validated root +
165
+ # static leaf = bounded write target.
166
+ gitignore = project_root / ".gitignore"
167
+
168
+ line = GITIGNORE_ENTRY
169
+ equivalents = {line, line.rstrip("/"), line + "**", line.rstrip("/") + "/**"}
170
+ if gitignore.is_file():
171
+ existing = {s.strip() for s in gitignore.read_text().splitlines()}
172
+ if existing & equivalents:
173
+ return False
174
+ body = gitignore.read_text()
175
+ if body and not body.endswith("\n"):
176
+ body += "\n"
177
+ body += line + "\n"
178
+ gitignore.write_text(body)
179
+ return True
180
+ gitignore.write_text(line + "\n")
181
+ return True
@@ -0,0 +1,98 @@
1
+ # Agent-first CLI reference — integration guide
2
+
3
+ You (the agent) are reading this because someone ran `teken cli cite` against
4
+ their project. This folder contains the Python agent-first CLI pattern as a
5
+ reference — `{{tokens}}` are **not substituted**. Your job is to apply the
6
+ pattern to the host project on its own terms.
7
+
8
+ ## Tokens
9
+
10
+ Substitute these throughout every file you integrate:
11
+
12
+ | Token | Meaning | Example |
13
+ | ----------------- | ------------------------------------------ | -------------- |
14
+ | `{{project_name}}` | The published package / CLI name. | `acme-tool` |
15
+ | `{{slug}}` | snake_case package slug (dashes→underscores). | `acme_tool` |
16
+ | `{{module}}` | Importable top-level module (same as slug). | `acme_tool` |
17
+
18
+ ## File roles
19
+
20
+ ### stable-contract — copy verbatim, then token-substitute
21
+
22
+ These files express the contract the rubric checks. Reshape them only if
23
+ your project already has equivalents; otherwise copy byte-for-byte.
24
+
25
+ - `{{slug}}/cli/_errors.py` — `AfiError`, exit-code policy.
26
+ - `{{slug}}/cli/_output.py` — stdout/stderr split + `--json` helpers.
27
+ - `{{slug}}/cli/_commands/explain.py` — the `explain` command.
28
+ - `{{slug}}/explain/__init__.py` + `{{slug}}/explain/catalog.py` — catalog resolver.
29
+
30
+ ### shape-adapt — model the structure; rewrite to fit the host project
31
+
32
+ These show the target shape but will almost certainly be reshaped to match
33
+ the host project's existing module layout, prog name, and commands.
34
+
35
+ - `{{slug}}/cli/__init__.py` — the parser + `_dispatch`. Reuse the
36
+ `_ArgumentParser` override and the try/except pattern verbatim; add your
37
+ own noun groups in the marked location.
38
+ - `{{slug}}/cli/_commands/learn.py` — keep the structure (TEXT body + JSON
39
+ payload) but rewrite the content to describe the host tool.
40
+ - `{{slug}}/explain/catalog.py` — rewrite entries for the host tool's
41
+ commands; keep the `()` / `("{{project_name}}",)` alias pattern.
42
+ - `{{slug}}/__init__.py`, `{{slug}}/__main__.py` — usually already exist in
43
+ the host project; update only if missing.
44
+ - `tests/test_cli.py` — merge into the host's test suite; adjust imports
45
+ and the `{{project_name}}` literal expectations.
46
+
47
+ ## Integration workflow (recommended)
48
+
49
+ 1. Read `MANIFEST.json` for a machine-readable file inventory.
50
+ 2. Copy the **stable-contract** files into the host project at the
51
+ equivalent paths under the host's package. Substitute tokens.
52
+ 3. Port the **shape-adapt** files: read them, take the structure, rewrite
53
+ content to match the host's naming and command surface.
54
+ 4. Wire the parser: import `learn` and `explain` modules and call their
55
+ `register(sub)` functions in the host's argparse setup.
56
+ 5. Ensure the host's top-level parser installs the `_ArgumentParser`
57
+ override so unknown-verb errors emit with a `hint:` line.
58
+ 6. Run `teken cli doctor .` from the host project to confirm the seven
59
+ rubric bundles pass.
60
+
61
+ ## Rubric bundles checked by `teken cli doctor`
62
+
63
+ 1. **Structure** — `pyproject.toml` with `[project.scripts]`, `tests/` dir,
64
+ `<tool> --help` exits 0, `main(argv) -> int` signature conforms.
65
+ 2. **Learnability** — `<tool> learn` exits 0, stdout ≥ 200 chars,
66
+ mentions purpose, commands, exit codes, `--json`, `explain`.
67
+ 3. **JSON** — `<tool> learn --json` is parseable; stderr clean on success;
68
+ `<tool> explain --json` works.
69
+ 4. **Errors** — bogus verb exits non-zero with a `hint:` line and no
70
+ Python traceback.
71
+ 5. **Explain** — `explain`, `explain <tool>`, and bogus-path-failure with
72
+ hint all work.
73
+ 6. **Overview** — `<tool> overview` and `<tool> cli overview` succeed;
74
+ `overview --json` carries `subject` + `sections` keys; missing target
75
+ paths fall back gracefully (descriptive verbs do not hard-fail).
76
+ 7. **Doctor** — `<tool> doctor` produces a non-empty report;
77
+ `<tool> doctor --json` carries `healthy` (bool) + `checks` (list);
78
+ each check entry has `id`, `passed`, `severity`, `message`; failed
79
+ checks supply a non-empty `remediation`.
80
+
81
+ > Note: this reference tree currently scaffolds `learn` and `explain` only.
82
+ > A target CLI must also implement `overview` (bundle 6) and `doctor`
83
+ > (bundle 7) to pass the full rubric. Use teken's own implementations as
84
+ > templates: `teken explain overview` and `teken explain doctor` describe the
85
+ > contract; the source under `teken/overview/` and `teken/doctor/` shows the
86
+ > shape. (Tracked: ship `overview.py` and `doctor.py` reference templates
87
+ > in a follow-up cite refresh.)
88
+
89
+ ## After integration
90
+
91
+ Delete this reference (`rm -rf .teken/reference/`) or re-run `teken cli cite` to
92
+ refresh it. The `.teken/` entry in `.gitignore` keeps it out of commits.
93
+
94
+ ## Rubric audit verb
95
+
96
+ Run `teken cli doctor .` from the host project to confirm the seven rubric
97
+ bundles pass. `teken cli verify` is a deprecated alias for the same command
98
+ and will be removed in v0.6.0.
@@ -0,0 +1,65 @@
1
+ {
2
+ "lang": "python",
3
+ "tokens": {
4
+ "project_name": "Published package / CLI name (e.g. 'acme-tool').",
5
+ "slug": "snake_case package slug (dashes replaced by underscores).",
6
+ "module": "Importable top-level module name (same as slug)."
7
+ },
8
+ "files": [
9
+ {
10
+ "path": "{{slug}}/__init__.py",
11
+ "role": "shape-adapt",
12
+ "summary": "Top-level package init; exports __version__ via importlib.metadata."
13
+ },
14
+ {
15
+ "path": "{{slug}}/__main__.py",
16
+ "role": "shape-adapt",
17
+ "summary": "`python -m {{module}}` entry point; delegates to cli.main."
18
+ },
19
+ {
20
+ "path": "{{slug}}/cli/__init__.py",
21
+ "role": "shape-adapt",
22
+ "summary": "Parser + _dispatch. _ArgumentParser override and try/except are stable-contract sub-blocks within this file."
23
+ },
24
+ {
25
+ "path": "{{slug}}/cli/_errors.py",
26
+ "role": "stable-contract",
27
+ "summary": "AfiError dataclass + exit-code policy (EXIT_SUCCESS/USER/ENV)."
28
+ },
29
+ {
30
+ "path": "{{slug}}/cli/_output.py",
31
+ "role": "stable-contract",
32
+ "summary": "emit_result / emit_error / emit_diagnostic with stdout/stderr split."
33
+ },
34
+ {
35
+ "path": "{{slug}}/cli/_commands/__init__.py",
36
+ "role": "stable-contract",
37
+ "summary": "Empty package init."
38
+ },
39
+ {
40
+ "path": "{{slug}}/cli/_commands/learn.py",
41
+ "role": "shape-adapt",
42
+ "summary": "The learn command. Structure is stable; content describes the host tool."
43
+ },
44
+ {
45
+ "path": "{{slug}}/cli/_commands/explain.py",
46
+ "role": "stable-contract",
47
+ "summary": "The explain command; resolves against the explain catalog."
48
+ },
49
+ {
50
+ "path": "{{slug}}/explain/__init__.py",
51
+ "role": "stable-contract",
52
+ "summary": "resolve() + known_paths() against ENTRIES."
53
+ },
54
+ {
55
+ "path": "{{slug}}/explain/catalog.py",
56
+ "role": "shape-adapt",
57
+ "summary": "ENTRIES dict — rewrite for the host tool's command set."
58
+ },
59
+ {
60
+ "path": "tests/test_cli.py",
61
+ "role": "shape-adapt",
62
+ "summary": "Smoke tests for --version, learn, learn --json, explain, bogus-path-fails."
63
+ }
64
+ ]
65
+ }
@@ -0,0 +1,46 @@
1
+ """Smoke tests for {{project_name}}'s CLI (shape-adapt)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import pytest
8
+
9
+ from {{module}} import __version__
10
+ from {{module}}.cli import main
11
+
12
+
13
+ def test_version_flag(capsys: pytest.CaptureFixture[str]) -> None:
14
+ with pytest.raises(SystemExit) as exc:
15
+ main(["--version"])
16
+ assert exc.value.code == 0
17
+ assert __version__ in capsys.readouterr().out
18
+
19
+
20
+ def test_learn_exits_zero(capsys: pytest.CaptureFixture[str]) -> None:
21
+ assert main(["learn"]) == 0
22
+ out = capsys.readouterr().out
23
+ assert len(out) >= 200
24
+ for marker in ["purpose", "commands", "exit", "--json", "explain"]:
25
+ assert marker.lower() in out.lower()
26
+
27
+
28
+ def test_learn_json_parseable(capsys: pytest.CaptureFixture[str]) -> None:
29
+ assert main(["learn", "--json"]) == 0
30
+ payload = json.loads(capsys.readouterr().out)
31
+ assert payload["tool"] == "{{project_name}}"
32
+
33
+
34
+ def test_explain_self(capsys: pytest.CaptureFixture[str]) -> None:
35
+ assert main(["explain", "{{project_name}}"]) == 0
36
+ assert capsys.readouterr().out.startswith("#")
37
+
38
+
39
+ def test_explain_unknown_path_fails_with_hint(
40
+ capsys: pytest.CaptureFixture[str],
41
+ ) -> None:
42
+ rc = main(["explain", "zzz-not-a-real-noun"])
43
+ assert rc != 0
44
+ err = capsys.readouterr().err
45
+ assert "error:" in err
46
+ assert "hint:" in err
@@ -0,0 +1,12 @@
1
+ """{{project_name}} — agent-first CLI package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import PackageNotFoundError, version as _pkg_version
6
+
7
+ try:
8
+ __version__ = _pkg_version("{{project_name}}")
9
+ except PackageNotFoundError: # pragma: no cover
10
+ __version__ = "0.0.0"
11
+
12
+ __all__ = ["__version__"]
@@ -0,0 +1,10 @@
1
+ """Entry point for ``python -m {{module}}``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from {{module}}.cli import main
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(main())
@@ -0,0 +1,84 @@
1
+ """Unified CLI entry point for {{project_name}}.
2
+
3
+ Noun-based command groups and globals are registered here. Top-level globals
4
+ (``learn``, ``explain``) live under :mod:`{{module}}.cli._commands`; per-noun
5
+ groups follow the same pattern.
6
+
7
+ Error-propagation contract: every handler raises
8
+ :class:`{{module}}.cli._errors.AfiError` on failure; :func:`main` catches it
9
+ via :func:`_dispatch` and routes through :mod:`{{module}}.cli._output`.
10
+ Unknown exceptions are wrapped so no Python traceback leaks.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import sys
17
+
18
+ from {{module}} import __version__
19
+ from {{module}}.cli._commands import explain as _explain_cmd
20
+ from {{module}}.cli._commands import learn as _learn_cmd
21
+ from {{module}}.cli._errors import EXIT_USER_ERROR, AfiError
22
+ from {{module}}.cli._output import emit_error
23
+
24
+
25
+ class _ArgumentParser(argparse.ArgumentParser):
26
+ """ArgumentParser that emits errors via our structured format."""
27
+
28
+ def error(self, message: str) -> None: # type: ignore[override]
29
+ err = AfiError(
30
+ code=EXIT_USER_ERROR,
31
+ message=message,
32
+ remediation=f"run '{self.prog} --help' to see valid arguments",
33
+ )
34
+ emit_error(err, json_mode=False)
35
+ raise SystemExit(err.code)
36
+
37
+
38
+ def _build_parser() -> argparse.ArgumentParser:
39
+ parser = _ArgumentParser(
40
+ prog="{{project_name}}",
41
+ description="{{project_name}} — agent-first CLI.",
42
+ )
43
+ parser.add_argument(
44
+ "--version", action="version", version=f"%(prog)s {__version__}"
45
+ )
46
+ sub = parser.add_subparsers(dest="command")
47
+
48
+ _learn_cmd.register(sub)
49
+ _explain_cmd.register(sub)
50
+ # Register noun groups here:
51
+ # from {{module}}.cli._commands import my_noun as _my_noun_group
52
+ # _my_noun_group.register(sub)
53
+
54
+ return parser
55
+
56
+
57
+ def _dispatch(args: argparse.Namespace) -> int:
58
+ json_mode = bool(getattr(args, "json", False))
59
+ try:
60
+ return args.func(args)
61
+ except AfiError as err:
62
+ emit_error(err, json_mode=json_mode)
63
+ return err.code
64
+ except Exception as err: # noqa: BLE001 - last-resort
65
+ wrapped = AfiError(
66
+ code=EXIT_USER_ERROR,
67
+ message=f"unexpected: {err.__class__.__name__}: {err}",
68
+ remediation="file a bug",
69
+ )
70
+ emit_error(wrapped, json_mode=json_mode)
71
+ return wrapped.code
72
+
73
+
74
+ def main(argv: list[str] | None = None) -> int:
75
+ parser = _build_parser()
76
+ args = parser.parse_args(argv)
77
+ if args.command is None:
78
+ parser.print_help()
79
+ return 0
80
+ return _dispatch(args)
81
+
82
+
83
+ if __name__ == "__main__":
84
+ sys.exit(main())
@@ -0,0 +1,38 @@
1
+ """``{{project_name}} explain <path>...`` — global markdown catalog lookup (stable-contract).
2
+
3
+ ``explain`` is global (not nested under a noun). It takes zero or more path
4
+ tokens and resolves them via the catalog in :mod:`{{module}}.explain`.
5
+ Unknown paths raise :class:`AfiError` with a remediation hint.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+
12
+ from {{module}}.cli._output import emit_result
13
+ from {{module}}.explain import resolve
14
+
15
+
16
+ def cmd_explain(args: argparse.Namespace) -> int:
17
+ path = tuple(args.path) if args.path else ()
18
+ markdown = resolve(path)
19
+ json_mode = bool(getattr(args, "json", False))
20
+ if json_mode:
21
+ emit_result({"path": list(path), "markdown": markdown}, json_mode=True)
22
+ else:
23
+ emit_result(markdown, json_mode=False)
24
+ return 0
25
+
26
+
27
+ def register(sub: argparse._SubParsersAction) -> None:
28
+ p = sub.add_parser(
29
+ "explain",
30
+ help="Print markdown docs for a noun/verb path (e.g. '{{project_name}} explain cli foo').",
31
+ )
32
+ p.add_argument(
33
+ "path",
34
+ nargs="*",
35
+ help="Command path tokens; empty = root (same as '{{project_name}}').",
36
+ )
37
+ p.add_argument("--json", action="store_true", help="Emit structured JSON.")
38
+ p.set_defaults(func=cmd_explain)