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.
- substack_cli/__init__.py +13 -0
- substack_cli/__main__.py +10 -0
- substack_cli/cli/__init__.py +136 -0
- substack_cli/cli/_commands/__init__.py +1 -0
- substack_cli/cli/_commands/cli.py +43 -0
- substack_cli/cli/_commands/doctor.py +194 -0
- substack_cli/cli/_commands/explain.py +38 -0
- substack_cli/cli/_commands/learn.py +88 -0
- substack_cli/cli/_commands/overview.py +112 -0
- substack_cli/cli/_commands/whoami.py +106 -0
- substack_cli/cli/_errors.py +42 -0
- substack_cli/cli/_output.py +53 -0
- substack_cli/explain/__init__.py +24 -0
- substack_cli/explain/catalog.py +136 -0
- substack_cli-0.9.0.dist-info/METADATA +137 -0
- substack_cli-0.9.0.dist-info/RECORD +19 -0
- substack_cli-0.9.0.dist-info/WHEEL +4 -0
- substack_cli-0.9.0.dist-info/entry_points.txt +2 -0
- substack_cli-0.9.0.dist-info/licenses/LICENSE +201 -0
substack_cli/__init__.py
ADDED
|
@@ -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__"]
|
substack_cli/__main__.py
ADDED
|
@@ -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)
|