substack-cli 0.9.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.
@@ -0,0 +1,13 @@
1
+ """substack-cli — agent-first CLI for an AgentCulture mesh agent."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import PackageNotFoundError
6
+ from importlib.metadata import version as _pkg_version
7
+
8
+ try:
9
+ __version__ = _pkg_version("substack-cli")
10
+ except PackageNotFoundError: # pragma: no cover - editable install without metadata
11
+ __version__ = "0.0.0"
12
+
13
+ __all__ = ["__version__"]
@@ -0,0 +1,10 @@
1
+ """Entry point for ``python -m substack_cli``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from substack_cli.cli import main
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(main())
@@ -0,0 +1,136 @@
1
+ """Unified CLI entry point for substack-cli.
2
+
3
+ The agent-first global verbs (``whoami``, ``learn``, ``explain``, ``overview``,
4
+ ``doctor``) are registered here under :mod:`substack_cli.cli._commands`,
5
+ alongside the ``cli`` noun group. Future noun groups register via their own
6
+ ``register()`` functions following the same pattern.
7
+
8
+ Error propagation contract
9
+ --------------------------
10
+ Every handler raises :class:`substack_cli.cli._errors.CliError` on
11
+ failure; ``main()`` catches it via :func:`_dispatch` and routes through
12
+ :mod:`substack_cli.cli._output`. Unknown exceptions are wrapped into a
13
+ ``CliError`` so no Python traceback leaks to stderr.
14
+
15
+ Argparse errors (unknown verb, missing arg) also route through the structured
16
+ format — ``_CliArgumentParser`` overrides ``.error()`` and the subparsers are
17
+ built with ``parser_class=_CliArgumentParser``. Whether errors render as text or
18
+ JSON depends on whether ``--json`` appears in the raw argv (:func:`main` sets
19
+ ``_json_hint`` before ``parse_args``).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import sys
26
+
27
+ from substack_cli import __version__
28
+ from substack_cli.cli._errors import EXIT_USER_ERROR, CliError
29
+ from substack_cli.cli._output import emit_error
30
+
31
+ _ISSUES_URL = "https://github.com/agentculture/substack-cli/issues"
32
+
33
+
34
+ class _CliArgumentParser(argparse.ArgumentParser):
35
+ """ArgumentParser that routes errors through :func:`emit_error`.
36
+
37
+ Argparse's default error handler writes ``prog: error: <msg>`` to stderr
38
+ and exits 2, skipping the CliError plumbing (and the ``hint:`` line agents
39
+ look for). This subclass emits the structured format and exits with
40
+ :attr:`EXIT_USER_ERROR`.
41
+
42
+ JSON mode: parse-time errors happen before ``args.json`` exists, so we rely
43
+ on a class-level ``_json_hint`` that :func:`main` pre-populates by scanning
44
+ raw argv for ``--json``. Shared across all subparser instances.
45
+ """
46
+
47
+ _json_hint: bool = False
48
+
49
+ def error(self, message: str) -> None: # type: ignore[override]
50
+ err = CliError(
51
+ code=EXIT_USER_ERROR,
52
+ message=message,
53
+ remediation=f"run '{self.prog} --help' to see valid arguments",
54
+ )
55
+ emit_error(err, json_mode=type(self)._json_hint)
56
+ raise SystemExit(err.code)
57
+
58
+
59
+ def _argv_has_json(argv: list[str] | None) -> bool:
60
+ tokens = argv if argv is not None else sys.argv[1:]
61
+ return any(t == "--json" or t.startswith("--json=") for t in tokens)
62
+
63
+
64
+ def _build_parser() -> argparse.ArgumentParser:
65
+ from substack_cli.cli._commands import cli as _cli_group
66
+ from substack_cli.cli._commands import doctor as _doctor_cmd
67
+ from substack_cli.cli._commands import explain as _explain_cmd
68
+ from substack_cli.cli._commands import learn as _learn_cmd
69
+ from substack_cli.cli._commands import overview as _overview_cmd
70
+ from substack_cli.cli._commands import whoami as _whoami_cmd
71
+
72
+ parser = _CliArgumentParser(
73
+ prog="substack-cli",
74
+ description="substack-cli — a clonable template for AgentCulture mesh agents.",
75
+ )
76
+ parser.add_argument(
77
+ "--version",
78
+ action="version",
79
+ version=f"%(prog)s {__version__}",
80
+ )
81
+ # parser_class propagates to every subparser so their .error() routes
82
+ # through _CliArgumentParser too.
83
+ sub = parser.add_subparsers(dest="command", parser_class=_CliArgumentParser)
84
+
85
+ _whoami_cmd.register(sub)
86
+ _learn_cmd.register(sub)
87
+ _explain_cmd.register(sub)
88
+ _overview_cmd.register(sub)
89
+ _doctor_cmd.register(sub)
90
+ _cli_group.register(sub)
91
+ # Register your own noun groups here:
92
+ # from substack_cli.cli._commands import my_noun as _my_noun_group
93
+ # _my_noun_group.register(sub)
94
+
95
+ return parser
96
+
97
+
98
+ def _dispatch(args: argparse.Namespace) -> int:
99
+ """Invoke the registered handler and translate exceptions to exit codes.
100
+
101
+ A handler may return ``None`` (success, exit 0) or an ``int`` exit code.
102
+ Failures MUST raise :class:`CliError`; any other exception is wrapped into
103
+ one so no Python traceback leaks.
104
+ """
105
+ json_mode = bool(getattr(args, "json", False))
106
+ try:
107
+ rc = args.func(args)
108
+ except CliError as err:
109
+ emit_error(err, json_mode=json_mode)
110
+ return err.code
111
+ except Exception as err: # noqa: BLE001 - last-resort; wrap and route cleanly
112
+ wrapped = CliError(
113
+ code=EXIT_USER_ERROR,
114
+ message=f"unexpected: {err.__class__.__name__}: {err}",
115
+ remediation=f"file a bug at {_ISSUES_URL}",
116
+ )
117
+ emit_error(wrapped, json_mode=json_mode)
118
+ return wrapped.code
119
+ return rc if rc is not None else 0
120
+
121
+
122
+ def main(argv: list[str] | None = None) -> int:
123
+ # Pre-parse peek so argparse-level errors honour --json.
124
+ _CliArgumentParser._json_hint = _argv_has_json(argv)
125
+ parser = _build_parser()
126
+ args = parser.parse_args(argv)
127
+
128
+ if args.command is None:
129
+ parser.print_help()
130
+ return 0
131
+
132
+ return _dispatch(args)
133
+
134
+
135
+ if __name__ == "__main__":
136
+ sys.exit(main())
@@ -0,0 +1 @@
1
+ """CLI command modules. Each exposes a ``register(sub)`` function."""
@@ -0,0 +1,43 @@
1
+ """``substack-cli cli`` — noun grouping CLI-surface introspection.
2
+
3
+ Exists to satisfy the agent-first rubric's ``overview_cli_noun_exists`` check:
4
+ any noun with action-verbs must also expose ``overview``. There are no
5
+ action-verbs under ``cli`` today, but ``cli overview`` describes the CLI surface
6
+ (distinct from the global ``overview``, which describes the agent).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+
13
+ from substack_cli.cli._commands.overview import cli_sections, emit_overview
14
+
15
+
16
+ def cmd_cli_overview(args: argparse.Namespace) -> int:
17
+ emit_overview(
18
+ "substack-cli cli",
19
+ cli_sections(),
20
+ json_mode=bool(getattr(args, "json", False)),
21
+ )
22
+ return 0
23
+
24
+
25
+ def _no_verb(args: argparse.Namespace) -> int:
26
+ # `substack-cli cli` with no sub-verb prints the noun's overview.
27
+ return cmd_cli_overview(args)
28
+
29
+
30
+ def register(sub: argparse._SubParsersAction) -> None:
31
+ p = sub.add_parser(
32
+ "cli",
33
+ help="CLI-surface introspection (see 'substack-cli cli overview').",
34
+ )
35
+ p.add_argument("--json", action="store_true", help="Emit structured JSON.")
36
+ p.set_defaults(func=_no_verb, json=False)
37
+ # `p` is a _CliArgumentParser (the top-level subparsers were built with that
38
+ # parser_class); propagate it so `cli overview` parse errors route through
39
+ # the structured error contract instead of argparse's default stderr/exit 2.
40
+ noun_sub = p.add_subparsers(dest="cli_command", parser_class=type(p))
41
+ ov = noun_sub.add_parser("overview", help="Describe the substack-cli CLI surface.")
42
+ ov.add_argument("--json", action="store_true", help="Emit structured JSON.")
43
+ ov.set_defaults(func=cmd_cli_overview)
@@ -0,0 +1,194 @@
1
+ """``substack-cli doctor`` — check the agent-identity invariants.
2
+
3
+ Mirrors the two invariants ``steward doctor`` verifies for a mesh agent:
4
+
5
+ * **prompt-file-present** — the repo declares an agent in ``culture.yaml`` and
6
+ has the matching prompt file on disk;
7
+ * **backend-consistency** — the declared ``backend`` is one this template
8
+ knows, and the *resident* prompt file that backend actually reads is on disk
9
+ (``claude`` → ``CLAUDE.md``, ``colleague`` → ``AGENTS.colleague.md``,
10
+ ``acp``/``codex``/``copilot`` → ``AGENTS.md``, ``gemini`` → ``GEMINI.md``).
11
+
12
+ Additionally a **harness-prompts** info check reports which *other* recognized
13
+ harness prompt files are present. Those files (``AGENTS.override.md``,
14
+ ``.pi/SYSTEM.md``, ``QWEN.md``) belong to interactively available harnesses
15
+ that ride the same backend name; they are never accepted as substitutes for
16
+ the resident prompt, because the Culture daemon does not read them.
17
+
18
+ Plus a **skills-present** check (the vendored ``.claude/skills/`` kit). Read-only.
19
+
20
+ Reports the rubric-shaped contract
21
+ ``{healthy, checks: [{id, passed, severity, message, remediation}]}`` so the
22
+ agent-first rubric's bundle 7 passes. When run from a wheel install (no
23
+ ``culture.yaml`` alongside the package), it reports a single info check and
24
+ exits 0 — there is nothing to diagnose.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import argparse
30
+
31
+ from substack_cli.cli._commands.whoami import find_culture_yaml, read_agent_fields
32
+ from substack_cli.cli._output import emit_result
33
+
34
+ #: Shared by every AGENTS.md-reading backend (codex, acp, copilot).
35
+ _AGENTS_MD = "AGENTS.md"
36
+
37
+ # backend → every prompt file RECOGNIZED under that backend name.
38
+ #
39
+ # Values are tuples because four harnesses occupy only three backend names in
40
+ # this template: Qwen Code runs on ``acp`` (QWEN.md), and associate/Pi runs on
41
+ # ``colleague`` (AGENTS.override.md as context plus .pi/SYSTEM.md as the system
42
+ # prompt). This is a *recognition* table — it answers "does some harness on
43
+ # this backend read this file?", which is what tooling needs when it walks a
44
+ # checkout and attributes files to backends.
45
+ #
46
+ # It is deliberately NOT the health check. See ``_RESIDENT_PROMPT`` below.
47
+ #
48
+ # This mirrors ``backends[*].prompt`` in
49
+ # ``.claude/skills/agent-config/data/backend-fingerprints.yaml``;
50
+ # ``tests/test_harness_registries.py`` asserts the two never drift apart.
51
+ _PROMPT_FILE = {
52
+ "claude": ("CLAUDE.md",),
53
+ "colleague": ("AGENTS.colleague.md", "AGENTS.override.md", ".pi/SYSTEM.md"),
54
+ "acp": (_AGENTS_MD, "QWEN.md"),
55
+ "codex": (_AGENTS_MD,),
56
+ "copilot": (_AGENTS_MD,),
57
+ "gemini": ("GEMINI.md",),
58
+ }
59
+
60
+ # backend → the ONE prompt file the Culture daemon reads for a resident on
61
+ # that backend. This is what ``prompt_file_present`` requires.
62
+ #
63
+ # The distinction from ``_PROMPT_FILE`` is load-bearing. Several files are
64
+ # recognized under ``colleague`` and ``acp`` because *interactive harnesses*
65
+ # ride those backend names — Pi reads ``AGENTS.override.md`` + ``.pi/SYSTEM.md``
66
+ # and Qwen Code reads ``QWEN.md`` — but the mesh resident reads neither. A
67
+ # clone declaring ``backend: colleague`` with only Pi's files on disk has no
68
+ # resident prompt at all, and treating the harness files as interchangeable
69
+ # alternatives let exactly that pass as healthy.
70
+ _RESIDENT_PROMPT = {
71
+ "claude": "CLAUDE.md",
72
+ "colleague": "AGENTS.colleague.md",
73
+ "acp": _AGENTS_MD,
74
+ "codex": _AGENTS_MD,
75
+ "copilot": _AGENTS_MD,
76
+ "gemini": "GEMINI.md",
77
+ }
78
+
79
+
80
+ def _diagnose() -> dict[str, object]:
81
+ cfg = find_culture_yaml()
82
+ if cfg is None:
83
+ check = {
84
+ "id": "source_checkout",
85
+ "passed": True,
86
+ "severity": "info",
87
+ "message": "no culture.yaml found alongside the package; identity checks skipped",
88
+ "remediation": "",
89
+ }
90
+ return {"healthy": True, "checks": [check]}
91
+
92
+ root = cfg.parent
93
+ fields = read_agent_fields()
94
+ backend = fields["backend"]
95
+ checks: list[dict[str, object]] = []
96
+
97
+ # 1. backend-consistency: the RESIDENT prompt file for the declared
98
+ # backend exists. Other harness prompt files recognized under the same
99
+ # backend name are reported separately (check 2) and never substituted
100
+ # here — the mesh daemon does not read them.
101
+ resident = _RESIDENT_PROMPT.get(backend)
102
+ if resident is None:
103
+ checks.append(
104
+ {
105
+ "id": "backend_consistency",
106
+ "passed": False,
107
+ "severity": "error",
108
+ "message": f"unknown backend '{backend}' in culture.yaml",
109
+ "remediation": f"set backend to one of: {', '.join(sorted(_RESIDENT_PROMPT))}",
110
+ }
111
+ )
112
+ else:
113
+ present = (root / resident).is_file()
114
+ checks.append(
115
+ {
116
+ "id": "prompt_file_present",
117
+ "passed": present,
118
+ "severity": "error",
119
+ "message": (
120
+ f"backend '{backend}' reads {resident} as its resident prompt — "
121
+ + ("present" if present else "missing")
122
+ ),
123
+ "remediation": "" if present else f"create {resident} at the repo root",
124
+ }
125
+ )
126
+
127
+ # 2. harness-prompts: report the other recognized prompt files on
128
+ # disk for this backend. Informational — an interactively available
129
+ # harness is not a health requirement, and its absence is not a
130
+ # failure — but it must never stand in for the resident prompt.
131
+ others = [
132
+ name
133
+ for name in _PROMPT_FILE.get(backend, ())
134
+ if name != resident and (root / name).is_file()
135
+ ]
136
+ checks.append(
137
+ {
138
+ "id": "harness_prompts",
139
+ "passed": True,
140
+ "severity": "info",
141
+ "message": (
142
+ "other harness prompt files on this backend: "
143
+ + (", ".join(others) if others else "none")
144
+ + " (not substitutes for the resident prompt)"
145
+ ),
146
+ "remediation": "",
147
+ }
148
+ )
149
+
150
+ # 3. skills-present: the vendored skill kit is on disk.
151
+ skills_dir = root / ".claude" / "skills"
152
+ has_skills = skills_dir.is_dir() and any(skills_dir.iterdir())
153
+ checks.append(
154
+ {
155
+ "id": "skills_present",
156
+ "passed": has_skills,
157
+ "severity": "warning",
158
+ "message": (
159
+ ".claude/skills/ vendored" if has_skills else ".claude/skills/ missing or empty"
160
+ ),
161
+ "remediation": (
162
+ "" if has_skills else "vendor the skill kit (see docs/skill-sources.md)"
163
+ ),
164
+ }
165
+ )
166
+
167
+ healthy = all(c["passed"] for c in checks)
168
+ return {"healthy": healthy, "checks": checks}
169
+
170
+
171
+ def cmd_doctor(args: argparse.Namespace) -> int:
172
+ report = _diagnose()
173
+ json_mode = bool(getattr(args, "json", False))
174
+ if json_mode:
175
+ emit_result(report, json_mode=True)
176
+ else:
177
+ status = "healthy" if report["healthy"] else "unhealthy"
178
+ lines = [f"substack-cli doctor: {status}", ""]
179
+ for check in report["checks"]:
180
+ mark = "ok" if check["passed"] else "FAIL"
181
+ lines.append(f"[{mark}] {check['id']}: {check['message']}")
182
+ if not check["passed"] and check["remediation"]:
183
+ lines.append(f" hint: {check['remediation']}")
184
+ emit_result("\n".join(lines), json_mode=False)
185
+ return 0 if report["healthy"] else 1
186
+
187
+
188
+ def register(sub: argparse._SubParsersAction) -> None:
189
+ p = sub.add_parser(
190
+ "doctor",
191
+ help="Check the agent-identity invariants (prompt-file-present, backend-consistency).",
192
+ )
193
+ p.add_argument("--json", action="store_true", help="Emit structured JSON.")
194
+ p.set_defaults(func=cmd_doctor)
@@ -0,0 +1,38 @@
1
+ """``substack-cli 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:`substack_cli.explain`.
5
+ Unknown paths raise :class:`CliError` with a remediation hint.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+
12
+ from substack_cli.cli._output import emit_result
13
+ from substack_cli.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. Supports --json.",
31
+ )
32
+ p.add_argument(
33
+ "path",
34
+ nargs="*",
35
+ help="Command path tokens; empty = root (same as 'substack-cli').",
36
+ )
37
+ p.add_argument("--json", action="store_true", help="Emit structured JSON.")
38
+ p.set_defaults(func=cmd_explain)
@@ -0,0 +1,88 @@
1
+ """``substack-cli learn`` — the learnability affordance.
2
+
3
+ Prints a structured self-teaching prompt. Must satisfy the agent-first rubric:
4
+ >=200 chars and mention purpose, command map, exit codes, --json, and explain.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+
11
+ from substack_cli import __version__
12
+ from substack_cli.cli._output import emit_result
13
+
14
+ _TEXT = """\
15
+ substack-cli — a clonable template for AgentCulture mesh agents.
16
+
17
+ Purpose
18
+ -------
19
+ Scaffold for a new Culture mesh agent: an agent-first CLI (cited from the teken
20
+ `python-cli` reference), an identity (culture.yaml + CLAUDE.md), the canonical
21
+ guildmaster skill kit under .claude/skills/, and a deploy/CI baseline. Clone it,
22
+ rename the package, and edit culture.yaml to mint a new agent.
23
+
24
+ Commands
25
+ --------
26
+ substack-cli whoami Identity from culture.yaml.
27
+ substack-cli learn This self-teaching prompt.
28
+ substack-cli explain <path>... Markdown docs for any noun/verb path.
29
+ substack-cli overview Descriptive snapshot of the agent.
30
+ substack-cli doctor Check the agent-identity invariants.
31
+ substack-cli cli overview Describe the CLI surface itself.
32
+
33
+ Machine-readable output
34
+ -----------------------
35
+ Every command supports --json. Errors in JSON mode emit
36
+ {"code", "message", "remediation"} to stderr. Stdout and stderr never mix.
37
+
38
+ Exit-code policy
39
+ ----------------
40
+ 0 success
41
+ 1 user-input error (bad flag, bad path, missing arg)
42
+ 2 environment / setup error
43
+ 3+ reserved
44
+
45
+ More detail
46
+ -----------
47
+ substack-cli explain substack-cli
48
+ """
49
+
50
+
51
+ def _as_json_payload() -> dict[str, object]:
52
+ return {
53
+ "tool": "substack-cli",
54
+ "version": __version__,
55
+ "purpose": "Clonable scaffold for a new AgentCulture mesh agent.",
56
+ "commands": [
57
+ {"path": ["whoami"], "summary": "Identity probe from culture.yaml."},
58
+ {"path": ["learn"], "summary": "Self-teaching prompt."},
59
+ {"path": ["explain"], "summary": "Markdown docs by path."},
60
+ {"path": ["overview"], "summary": "Descriptive snapshot of the agent."},
61
+ {"path": ["doctor"], "summary": "Check the agent-identity invariants."},
62
+ {"path": ["cli", "overview"], "summary": "Describe the CLI surface."},
63
+ ],
64
+ "exit_codes": {
65
+ "0": "success",
66
+ "1": "user-input error",
67
+ "2": "environment/setup error",
68
+ },
69
+ "json_support": True,
70
+ "explain_pointer": "substack-cli explain <path>",
71
+ }
72
+
73
+
74
+ def cmd_learn(args: argparse.Namespace) -> int:
75
+ if getattr(args, "json", False):
76
+ emit_result(_as_json_payload(), json_mode=True)
77
+ else:
78
+ emit_result(_TEXT, json_mode=False)
79
+ return 0
80
+
81
+
82
+ def register(sub: argparse._SubParsersAction) -> None:
83
+ p = sub.add_parser(
84
+ "learn",
85
+ help="Print a structured self-teaching prompt for agent consumers.",
86
+ )
87
+ p.add_argument("--json", action="store_true", help="Emit structured JSON.")
88
+ p.set_defaults(func=cmd_learn)
@@ -0,0 +1,112 @@
1
+ """``substack-cli overview`` — read-only descriptive snapshot of the agent.
2
+
3
+ Describes the agent to an agent reader: identity (from culture.yaml), the verb
4
+ surface, and the sibling-pattern artifacts this template carries. The shared
5
+ section/render helpers here are reused by the ``cli`` noun's ``overview`` (see
6
+ :mod:`substack_cli.cli._commands.cli`).
7
+
8
+ Descriptive verbs never hard-fail on a missing target path — an optional
9
+ positional ``target`` is accepted and ignored (overview describes this agent,
10
+ not an external target), so ``overview <bogus-path>`` still exits 0.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+
17
+ from substack_cli.cli._commands.whoami import report
18
+ from substack_cli.cli._output import emit_result
19
+
20
+ _ARTIFACTS = [
21
+ "culture.yaml + AGENTS.colleague.md — mesh identity (suffix + backend)",
22
+ ".claude/skills/ — the canonical guildmaster skill kit (cite-don't-import)",
23
+ "docs/skill-sources.md — skill provenance ledger",
24
+ "pyproject.toml + .github/workflows/ — buildable, deployable package baseline",
25
+ ]
26
+
27
+ _VERBS = [
28
+ "whoami — identity probe (nick, version, backend, model)",
29
+ "learn — structured self-teaching prompt",
30
+ "explain <path> — markdown docs for a topic",
31
+ "overview — this descriptive snapshot",
32
+ "doctor — check the agent-identity invariants",
33
+ ]
34
+
35
+
36
+ def agent_sections() -> list[dict[str, object]]:
37
+ """Sections describing the agent (used by the global verb)."""
38
+ ident = report()
39
+ return [
40
+ {
41
+ "title": "Identity",
42
+ "items": [
43
+ f"nick: {ident['nick']}",
44
+ f"version: {ident['version']}",
45
+ f"backend: {ident['backend']}",
46
+ f"model: {ident['model']}",
47
+ ],
48
+ },
49
+ {"title": "Verbs", "items": list(_VERBS)},
50
+ {"title": "Sibling-pattern artifacts", "items": list(_ARTIFACTS)},
51
+ ]
52
+
53
+
54
+ def cli_sections() -> list[dict[str, object]]:
55
+ """Sections describing the CLI surface itself (used by `cli overview`)."""
56
+ return [
57
+ {
58
+ "title": "Verbs",
59
+ "items": list(_VERBS) + ["cli overview — describe the CLI surface (this command)"],
60
+ },
61
+ {
62
+ "title": "Conventions",
63
+ "items": [
64
+ "every command supports --json",
65
+ "results to stdout, errors/diagnostics to stderr (never mixed)",
66
+ "exit codes: 0 success, 1 user error, 2 environment error, 3+ reserved",
67
+ ],
68
+ },
69
+ ]
70
+
71
+
72
+ def render_text(subject: str, sections: list[dict[str, object]]) -> str:
73
+ lines = [f"# {subject}", ""]
74
+ for section in sections:
75
+ lines.append(f"## {section['title']}")
76
+ for item in section["items"]:
77
+ lines.append(f"- {item}")
78
+ lines.append("")
79
+ return "\n".join(lines).rstrip()
80
+
81
+
82
+ def emit_overview(subject: str, sections: list[dict[str, object]], *, json_mode: bool) -> None:
83
+ if json_mode:
84
+ emit_result({"subject": subject, "sections": sections}, json_mode=True)
85
+ else:
86
+ emit_result(render_text(subject, sections), json_mode=False)
87
+
88
+
89
+ def cmd_overview(args: argparse.Namespace) -> int:
90
+ # `target` is accepted for rubric compatibility (descriptive verbs must not
91
+ # hard-fail on a missing path) but overview describes this agent itself.
92
+ emit_overview(
93
+ "substack-cli",
94
+ agent_sections(),
95
+ json_mode=bool(getattr(args, "json", False)),
96
+ )
97
+ return 0
98
+
99
+
100
+ def register(sub: argparse._SubParsersAction) -> None:
101
+ p = sub.add_parser(
102
+ "overview",
103
+ help="Read-only descriptive snapshot of the agent (identity, verbs, artifacts).",
104
+ )
105
+ p.add_argument(
106
+ "target",
107
+ nargs="?",
108
+ help="Ignored — overview always describes this agent itself. Accepted so a "
109
+ "stray path argument never hard-fails.",
110
+ )
111
+ p.add_argument("--json", action="store_true", help="Emit structured JSON.")
112
+ p.set_defaults(func=cmd_overview)