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.
- teken/__init__.py +13 -0
- teken/__main__.py +8 -0
- teken/_brand.py +27 -0
- teken/cite/__init__.py +19 -0
- teken/cite/_engine.py +181 -0
- teken/cite/references/python-cli/AGENT.md +98 -0
- teken/cite/references/python-cli/MANIFEST.json +65 -0
- teken/cite/references/python-cli/tests/test_cli.py +46 -0
- teken/cite/references/python-cli/{{slug}}/__init__.py +12 -0
- teken/cite/references/python-cli/{{slug}}/__main__.py +10 -0
- teken/cite/references/python-cli/{{slug}}/cli/__init__.py +84 -0
- teken/cite/references/python-cli/{{slug}}/cli/_commands/__init__.py +0 -0
- teken/cite/references/python-cli/{{slug}}/cli/_commands/explain.py +38 -0
- teken/cite/references/python-cli/{{slug}}/cli/_commands/learn.py +81 -0
- teken/cite/references/python-cli/{{slug}}/cli/_errors.py +41 -0
- teken/cite/references/python-cli/{{slug}}/cli/_output.py +53 -0
- teken/cite/references/python-cli/{{slug}}/explain/__init__.py +24 -0
- teken/cite/references/python-cli/{{slug}}/explain/catalog.py +66 -0
- teken/cli/__init__.py +160 -0
- teken/cli/_commands/__init__.py +0 -0
- teken/cli/_commands/cli.py +198 -0
- teken/cli/_commands/doctor.py +564 -0
- teken/cli/_commands/explain.py +38 -0
- teken/cli/_commands/learn.py +130 -0
- teken/cli/_commands/overview.py +51 -0
- teken/cli/_errors.py +42 -0
- teken/cli/_output.py +58 -0
- teken/doctor/__init__.py +66 -0
- teken/doctor/_self_checks.py +347 -0
- teken/doctor/fixes.py +73 -0
- teken/explain/__init__.py +28 -0
- teken/explain/catalog.py +390 -0
- teken/overview/__init__.py +136 -0
- teken/overview/cli_surface.py +455 -0
- teken/rubric/__init__.py +53 -0
- teken/rubric/_runner.py +102 -0
- teken/rubric/_types.py +70 -0
- teken/rubric/checks/__init__.py +0 -0
- teken/rubric/checks/doctor.py +270 -0
- teken/rubric/checks/errors.py +107 -0
- teken/rubric/checks/explain_cmd.py +87 -0
- teken/rubric/checks/json_output.py +97 -0
- teken/rubric/checks/learnability.py +74 -0
- teken/rubric/checks/overview_cmd.py +181 -0
- teken/rubric/checks/structure.py +297 -0
- teken-0.8.0.dist-info/METADATA +93 -0
- teken-0.8.0.dist-info/RECORD +50 -0
- teken-0.8.0.dist-info/WHEEL +4 -0
- teken-0.8.0.dist-info/entry_points.txt +3 -0
- 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
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,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())
|
|
File without changes
|
|
@@ -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)
|