convertible-cli 0.1.2__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
+ """convertible — 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("convertible-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 convertible``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from convertible.cli import main
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(main())
@@ -0,0 +1,136 @@
1
+ """Unified CLI entry point for convertible.
2
+
3
+ The agent-first global verbs (``whoami``, ``learn``, ``explain``, ``overview``,
4
+ ``doctor``) are registered here under :mod:`convertible.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:`convertible.cli._errors.CliError` on
11
+ failure; ``main()`` catches it via :func:`_dispatch` and routes through
12
+ :mod:`convertible.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 convertible import __version__
28
+ from convertible.cli._errors import EXIT_USER_ERROR, CliError
29
+ from convertible.cli._output import emit_error
30
+
31
+ _ISSUES_URL = "https://github.com/agentculture/convertible/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 convertible.cli._commands import cli as _cli_group
66
+ from convertible.cli._commands import doctor as _doctor_cmd
67
+ from convertible.cli._commands import explain as _explain_cmd
68
+ from convertible.cli._commands import learn as _learn_cmd
69
+ from convertible.cli._commands import overview as _overview_cmd
70
+ from convertible.cli._commands import whoami as _whoami_cmd
71
+
72
+ parser = _CliArgumentParser(
73
+ prog="convertible",
74
+ description="convertible — 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 convertible.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
+ """``convertible 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 convertible.cli._commands.overview import cli_sections, emit_overview
14
+
15
+
16
+ def cmd_cli_overview(args: argparse.Namespace) -> int:
17
+ emit_overview(
18
+ "convertible 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
+ # `convertible 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 'convertible 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 convertible 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,122 @@
1
+ """``convertible 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`` matches the prompt file
8
+ (``claude`` → ``CLAUDE.md``, ``acp`` → ``AGENTS.md``, ``gemini`` → ``GEMINI.md``).
9
+
10
+ Plus a **skills-present** check (the vendored ``.claude/skills/`` kit). Read-only.
11
+
12
+ Reports the rubric-shaped contract
13
+ ``{healthy, checks: [{id, passed, severity, message, remediation}]}`` so the
14
+ agent-first rubric's bundle 7 passes. When run from a wheel install (no
15
+ ``culture.yaml`` alongside the package), it reports a single info check and
16
+ exits 0 — there is nothing to diagnose.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+
23
+ from convertible.cli._commands.whoami import find_culture_yaml, read_agent_fields
24
+ from convertible.cli._output import emit_result
25
+
26
+ # backend → required prompt file (the backend-consistency mapping).
27
+ _PROMPT_FILE = {
28
+ "claude": "CLAUDE.md",
29
+ "acp": "AGENTS.md",
30
+ "gemini": "GEMINI.md",
31
+ }
32
+
33
+
34
+ def _diagnose() -> dict[str, object]:
35
+ cfg = find_culture_yaml()
36
+ if cfg is None:
37
+ check = {
38
+ "id": "source_checkout",
39
+ "passed": True,
40
+ "severity": "info",
41
+ "message": "no culture.yaml found alongside the package; identity checks skipped",
42
+ "remediation": "",
43
+ }
44
+ return {"healthy": True, "checks": [check]}
45
+
46
+ root = cfg.parent
47
+ fields = read_agent_fields()
48
+ backend = fields["backend"]
49
+ checks: list[dict[str, object]] = []
50
+
51
+ # 1. backend-consistency: the prompt file for the declared backend exists.
52
+ expected = _PROMPT_FILE.get(backend)
53
+ if expected is None:
54
+ checks.append(
55
+ {
56
+ "id": "backend_consistency",
57
+ "passed": False,
58
+ "severity": "error",
59
+ "message": f"unknown backend '{backend}' in culture.yaml",
60
+ "remediation": f"set backend to one of: {', '.join(sorted(_PROMPT_FILE))}",
61
+ }
62
+ )
63
+ else:
64
+ present = (root / expected).is_file()
65
+ checks.append(
66
+ {
67
+ "id": "prompt_file_present",
68
+ "passed": present,
69
+ "severity": "error",
70
+ "message": (
71
+ f"backend '{backend}' requires {expected} — "
72
+ + ("present" if present else "missing")
73
+ ),
74
+ "remediation": "" if present else f"create {expected} at the repo root",
75
+ }
76
+ )
77
+
78
+ # 2. skills-present: the vendored skill kit is on disk.
79
+ skills_dir = root / ".claude" / "skills"
80
+ has_skills = skills_dir.is_dir() and any(skills_dir.iterdir())
81
+ checks.append(
82
+ {
83
+ "id": "skills_present",
84
+ "passed": has_skills,
85
+ "severity": "warning",
86
+ "message": (
87
+ ".claude/skills/ vendored" if has_skills else ".claude/skills/ missing or empty"
88
+ ),
89
+ "remediation": (
90
+ "" if has_skills else "vendor the skill kit (see docs/skill-sources.md)"
91
+ ),
92
+ }
93
+ )
94
+
95
+ healthy = all(c["passed"] for c in checks)
96
+ return {"healthy": healthy, "checks": checks}
97
+
98
+
99
+ def cmd_doctor(args: argparse.Namespace) -> int:
100
+ report = _diagnose()
101
+ json_mode = bool(getattr(args, "json", False))
102
+ if json_mode:
103
+ emit_result(report, json_mode=True)
104
+ else:
105
+ status = "healthy" if report["healthy"] else "unhealthy"
106
+ lines = [f"convertible doctor: {status}", ""]
107
+ for check in report["checks"]:
108
+ mark = "ok" if check["passed"] else "FAIL"
109
+ lines.append(f"[{mark}] {check['id']}: {check['message']}")
110
+ if not check["passed"] and check["remediation"]:
111
+ lines.append(f" hint: {check['remediation']}")
112
+ emit_result("\n".join(lines), json_mode=False)
113
+ return 0 if report["healthy"] else 1
114
+
115
+
116
+ def register(sub: argparse._SubParsersAction) -> None:
117
+ p = sub.add_parser(
118
+ "doctor",
119
+ help="Check the agent-identity invariants (prompt-file-present, backend-consistency).",
120
+ )
121
+ p.add_argument("--json", action="store_true", help="Emit structured JSON.")
122
+ p.set_defaults(func=cmd_doctor)
@@ -0,0 +1,38 @@
1
+ """``convertible 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:`convertible.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 convertible.cli._output import emit_result
13
+ from convertible.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 'convertible').",
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
+ """``convertible 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 convertible import __version__
12
+ from convertible.cli._output import emit_result
13
+
14
+ _TEXT = """\
15
+ convertible — 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
+ convertible whoami Identity from culture.yaml.
27
+ convertible learn This self-teaching prompt.
28
+ convertible explain <path>... Markdown docs for any noun/verb path.
29
+ convertible overview Descriptive snapshot of the agent.
30
+ convertible doctor Check the agent-identity invariants.
31
+ convertible 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
+ convertible explain convertible
48
+ """
49
+
50
+
51
+ def _as_json_payload() -> dict[str, object]:
52
+ return {
53
+ "tool": "convertible",
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": "convertible 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
+ """``convertible 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:`convertible.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 convertible.cli._commands.whoami import report
18
+ from convertible.cli._output import emit_result
19
+
20
+ _ARTIFACTS = [
21
+ "culture.yaml + CLAUDE.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
+ "convertible",
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)
@@ -0,0 +1,106 @@
1
+ """``convertible whoami`` — the smallest identity probe.
2
+
3
+ Reports the agent's identity as declared in ``culture.yaml``: its nick
4
+ (``suffix``), the backend it runs on, and the served model (if any) — plus the
5
+ package version. Read-only; touches nothing but its own ``culture.yaml``.
6
+
7
+ When you clone this template, rename the package and update ``culture.yaml`` —
8
+ ``whoami`` then reflects your new agent's identity with no code change.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ from pathlib import Path
15
+
16
+ from convertible import __version__
17
+ from convertible.cli._output import emit_result
18
+
19
+ _FALLBACK_NICK = "convertible"
20
+
21
+
22
+ def find_culture_yaml() -> Path | None:
23
+ """Locate this agent's own ``culture.yaml`` by walking up from this module.
24
+
25
+ The identity must be the agent's own, not whatever ``culture.yaml`` happens
26
+ to sit in the caller's current working directory. In an editable / source
27
+ install, walking up from ``__file__`` finds the repo root; in a wheel
28
+ install no ``culture.yaml`` ships alongside the package and the caller falls
29
+ back to the literal defaults.
30
+ """
31
+ here = Path(__file__).resolve()
32
+ for parent in here.parents:
33
+ candidate = parent / "culture.yaml"
34
+ if candidate.is_file():
35
+ return candidate
36
+ return None
37
+
38
+
39
+ def read_agent_fields() -> dict[str, str]:
40
+ """Return ``suffix``/``backend``/``model`` from the first agent block.
41
+
42
+ Parsed without a YAML dependency to keep the runtime deps empty. Reads
43
+ top-level ``key: value`` lines within the first agent entry; anything
44
+ fancier than the documented shape falls back to the defaults below.
45
+ """
46
+ fields = {"nick": _FALLBACK_NICK, "backend": "unknown", "model": "unknown"}
47
+ cfg = find_culture_yaml()
48
+ if cfg is None:
49
+ return fields
50
+ try:
51
+ text = cfg.read_text(encoding="utf-8")
52
+ except OSError:
53
+ return fields
54
+ seen_agent = False
55
+ for line in text.splitlines():
56
+ stripped = line.strip()
57
+ if stripped.startswith(("- suffix:", "suffix:")):
58
+ if seen_agent: # second agent block — stop at the first
59
+ break
60
+ seen_agent = True
61
+ fields["nick"] = _scalar(stripped, "suffix")
62
+ elif seen_agent and stripped.startswith("backend:"):
63
+ fields["backend"] = _scalar(stripped, "backend")
64
+ elif seen_agent and stripped.startswith("model:"):
65
+ fields["model"] = _scalar(stripped, "model")
66
+ return fields
67
+
68
+
69
+ def _scalar(line: str, key: str) -> str:
70
+ """Extract the scalar after ``key:`` from a ``culture.yaml`` line."""
71
+ _, _, value = line.partition(f"{key}:")
72
+ return value.strip().strip("'\"") or "unknown"
73
+
74
+
75
+ def report() -> dict[str, object]:
76
+ fields = read_agent_fields()
77
+ return {
78
+ "nick": fields["nick"],
79
+ "version": __version__,
80
+ "backend": fields["backend"],
81
+ "model": fields["model"],
82
+ }
83
+
84
+
85
+ def cmd_whoami(args: argparse.Namespace) -> None:
86
+ identity = report()
87
+ json_mode = bool(getattr(args, "json", False))
88
+ if json_mode:
89
+ emit_result(identity, json_mode=True)
90
+ return
91
+ text = (
92
+ f"nick: {identity['nick']}\n"
93
+ f"version: {identity['version']}\n"
94
+ f"backend: {identity['backend']}\n"
95
+ f"model: {identity['model']}"
96
+ )
97
+ emit_result(text, json_mode=False)
98
+
99
+
100
+ def register(sub: argparse._SubParsersAction) -> None:
101
+ p = sub.add_parser(
102
+ "whoami",
103
+ help="Report this agent's nick, version, backend, and served model.",
104
+ )
105
+ p.add_argument("--json", action="store_true", help="Emit structured JSON.")
106
+ p.set_defaults(func=cmd_whoami)
@@ -0,0 +1,42 @@
1
+ """CliError and exit-code policy (stable-contract).
2
+
3
+ Every failure inside convertible raises :class:`CliError`. The
4
+ top-level ``main()`` catches it, formats via :mod:`convertible.cli._output`,
5
+ and exits with :attr:`CliError.code`. This guarantees:
6
+
7
+ * no Python traceback leaks to stderr (the agent-first error contract);
8
+ * every error has a structured shape ``{code, message, remediation}``;
9
+ * the exit-code policy is centralised in one place.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass
15
+
16
+ # Exit-code policy. Documented in ``convertible learn`` output.
17
+ # 0 = success
18
+ # 1 = user-input error (bad flag, missing required arg, unknown path)
19
+ # 2 = environment / setup error (tool not installed, file unreadable)
20
+ # 3+ = reserved for future categorisation
21
+ EXIT_SUCCESS = 0
22
+ EXIT_USER_ERROR = 1
23
+ EXIT_ENV_ERROR = 2
24
+
25
+
26
+ @dataclass
27
+ class CliError(Exception):
28
+ """Structured error raised within the CLI; carries a remediation hint for agents."""
29
+
30
+ code: int
31
+ message: str
32
+ remediation: str = ""
33
+
34
+ def __post_init__(self) -> None:
35
+ super().__init__(self.message)
36
+
37
+ def to_dict(self) -> dict[str, object]:
38
+ return {
39
+ "code": self.code,
40
+ "message": self.message,
41
+ "remediation": self.remediation,
42
+ }
@@ -0,0 +1,53 @@
1
+ """stdout / stderr helpers with a strict split (stable-contract).
2
+
3
+ Rule: **results go to stdout, diagnostics and errors go to stderr.** Agents
4
+ parsing output can rely on this invariant. JSON mode routes structured
5
+ payloads to the same streams — never mixes them.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import sys
12
+ from typing import Any, TextIO
13
+
14
+ from convertible.cli._errors import CliError
15
+
16
+
17
+ def emit_result(data: Any, *, json_mode: bool, stream: TextIO | None = None) -> None:
18
+ """Write a command result to stdout (or ``stream``)."""
19
+ s = stream if stream is not None else sys.stdout
20
+ if json_mode:
21
+ json.dump(data, s, ensure_ascii=False)
22
+ s.write("\n")
23
+ return
24
+ text = data if isinstance(data, str) else str(data)
25
+ s.write(text)
26
+ if not text.endswith("\n"):
27
+ s.write("\n")
28
+
29
+
30
+ def emit_error(err: CliError, *, json_mode: bool, stream: TextIO | None = None) -> None:
31
+ """Write a :class:`CliError` to stderr.
32
+
33
+ Text mode renders as two lines when a remediation is present::
34
+
35
+ error: <message>
36
+ hint: <remediation>
37
+
38
+ The ``hint:`` prefix is required by the agent-first error rubric.
39
+ """
40
+ s = stream if stream is not None else sys.stderr
41
+ if json_mode:
42
+ json.dump(err.to_dict(), s, ensure_ascii=False)
43
+ s.write("\n")
44
+ return
45
+ s.write(f"error: {err.message}\n")
46
+ if err.remediation:
47
+ s.write(f"hint: {err.remediation}\n")
48
+
49
+
50
+ def emit_diagnostic(message: str, *, stream: TextIO | None = None) -> None:
51
+ """Write a human diagnostic (progress, summary) to stderr."""
52
+ s = stream if stream is not None else sys.stderr
53
+ s.write(message if message.endswith("\n") else message + "\n")
@@ -0,0 +1,24 @@
1
+ """Explain catalog — markdown keyed by command-path tuples (stable-contract).
2
+
3
+ Every noun/verb registered in the CLI should have a catalog entry.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from convertible.cli._errors import EXIT_USER_ERROR, CliError
9
+ from convertible.explain.catalog import ENTRIES
10
+
11
+
12
+ def resolve(path: tuple[str, ...]) -> str:
13
+ if path in ENTRIES:
14
+ return ENTRIES[path]
15
+ display = " ".join(path) if path else "<root>"
16
+ raise CliError(
17
+ code=EXIT_USER_ERROR,
18
+ message=f"no explain entry for: {display}",
19
+ remediation="list entries with: convertible explain convertible",
20
+ )
21
+
22
+
23
+ def known_paths() -> list[tuple[str, ...]]:
24
+ return list(ENTRIES.keys())
@@ -0,0 +1,129 @@
1
+ """Markdown catalog for ``convertible explain <path>``.
2
+
3
+ Each entry is verbatim markdown. Keys are command-path tuples. The empty tuple
4
+ and ``("convertible",)`` both resolve to the root entry.
5
+
6
+ Keep bodies self-contained: an agent reading one entry should get enough
7
+ context without chaining reads.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ _ROOT = """\
13
+ # convertible
14
+
15
+ A clonable template for AgentCulture mesh agents. It carries an agent-first CLI
16
+ (cited from the teken `python-cli` reference), a mesh identity (`culture.yaml` +
17
+ `CLAUDE.md`), the canonical guildmaster skill kit under `.claude/skills/`, and a
18
+ buildable/deployable package baseline. Clone it, rename the package, edit
19
+ `culture.yaml`, and you have a new agent.
20
+
21
+ ## Verbs
22
+
23
+ - `convertible whoami` — identity probe from `culture.yaml`.
24
+ - `convertible learn` — structured self-teaching prompt.
25
+ - `convertible explain <path>` — markdown docs for any noun/verb.
26
+ - `convertible overview` — descriptive snapshot of the agent.
27
+ - `convertible doctor` — check the agent-identity invariants.
28
+ - `convertible cli overview` — describe the CLI surface.
29
+
30
+ ## Exit-code policy
31
+
32
+ - `0` success
33
+ - `1` user-input error
34
+ - `2` environment / setup error
35
+ - `3+` reserved
36
+
37
+ ## See also
38
+
39
+ - `convertible explain whoami`
40
+ - `convertible explain doctor`
41
+ """
42
+
43
+ _WHOAMI = """\
44
+ # convertible whoami
45
+
46
+ Reports the agent's identity from `culture.yaml`: nick (`suffix`), backend,
47
+ served model, and the package version. Read-only.
48
+
49
+ ## Usage
50
+
51
+ convertible whoami
52
+ convertible whoami --json
53
+ """
54
+
55
+ _LEARN = """\
56
+ # convertible learn
57
+
58
+ Prints a structured self-teaching prompt covering purpose, command map,
59
+ exit-code policy, `--json` support, and the `explain` pointer.
60
+
61
+ ## Usage
62
+
63
+ convertible learn
64
+ convertible learn --json
65
+ """
66
+
67
+ _EXPLAIN = """\
68
+ # convertible explain <path>
69
+
70
+ Prints markdown documentation for any noun/verb path. Unlike `--help` (terse,
71
+ positional), `explain` is global and addressable by path.
72
+
73
+ ## Usage
74
+
75
+ convertible explain convertible
76
+ convertible explain whoami
77
+ convertible explain --json <path>
78
+ """
79
+
80
+ _OVERVIEW = """\
81
+ # convertible overview
82
+
83
+ Read-only descriptive snapshot of the agent: identity (from `culture.yaml`), the
84
+ verb surface, and the sibling-pattern artifacts the template carries. Accepts an
85
+ ignored `target` so a stray path never hard-fails.
86
+
87
+ ## Usage
88
+
89
+ convertible overview
90
+ convertible overview --json
91
+ """
92
+
93
+ _DOCTOR = """\
94
+ # convertible doctor
95
+
96
+ Checks the agent-identity invariants `steward doctor` verifies:
97
+ prompt-file-present and backend-consistency (`claude` → `CLAUDE.md`), plus a
98
+ skills-present check. Exits 1 when unhealthy.
99
+
100
+ ## Usage
101
+
102
+ convertible doctor
103
+ convertible doctor --json
104
+ """
105
+
106
+ _CLI = """\
107
+ # convertible cli
108
+
109
+ Noun group for CLI-surface introspection. `cli overview` describes the CLI
110
+ itself (distinct from the global `overview`, which describes the agent).
111
+
112
+ ## Usage
113
+
114
+ convertible cli overview
115
+ convertible cli overview --json
116
+ """
117
+
118
+
119
+ ENTRIES: dict[tuple[str, ...], str] = {
120
+ (): _ROOT,
121
+ ("convertible",): _ROOT,
122
+ ("whoami",): _WHOAMI,
123
+ ("learn",): _LEARN,
124
+ ("explain",): _EXPLAIN,
125
+ ("overview",): _OVERVIEW,
126
+ ("doctor",): _DOCTOR,
127
+ ("cli",): _CLI,
128
+ ("cli", "overview"): _CLI,
129
+ }
@@ -0,0 +1,73 @@
1
+ Metadata-Version: 2.4
2
+ Name: convertible-cli
3
+ Version: 0.1.2
4
+ Summary: Convertible CLI is a swappable coder-agent harness that turns different models into repo workers behind one shared task contract.
5
+ Project-URL: Homepage, https://github.com/agentculture/convertible
6
+ Project-URL: Issues, https://github.com/agentculture/convertible/issues
7
+ Author: AgentCulture
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Topic :: Software Development
15
+ Requires-Python: >=3.12
16
+ Description-Content-Type: text/markdown
17
+
18
+ # convertible
19
+
20
+ Convertible CLI is a swappable coder-agent harness that turns different models into repo workers behind one shared task contract.
21
+
22
+ ## What you get
23
+
24
+ - **An agent-first CLI** cited from [teken](https://github.com/agentculture/teken)
25
+ (`afi-cli`) — the runtime package has no third-party dependencies.
26
+ - **A mesh identity** — `culture.yaml` (`suffix` + `backend`) and the matching
27
+ prompt file (`CLAUDE.md` for `backend: claude`).
28
+ - **The canonical guildmaster skill kit** (11 skills) under `.claude/skills/`,
29
+ vendored cite-don't-import. See [`docs/skill-sources.md`](docs/skill-sources.md).
30
+ - **A build + deploy baseline** — pytest, lint, the agent-first rubric gate, and
31
+ PyPI Trusted Publishing wired into GitHub Actions.
32
+
33
+ ## Quickstart
34
+
35
+ ```bash
36
+ uv sync
37
+ uv run pytest -n auto # run the test suite
38
+ uv run convertible whoami # identity from culture.yaml
39
+ uv run convertible learn # self-teaching prompt (add --json)
40
+ uv run teken cli doctor . --strict # the agent-first rubric gate CI runs
41
+ ```
42
+
43
+ ## CLI
44
+
45
+ | Verb | What it does |
46
+ |------|--------------|
47
+ | `whoami` | Report this agent's nick, version, backend, and model from `culture.yaml`. |
48
+ | `learn` | Print a structured self-teaching prompt. |
49
+ | `explain <path>` | Markdown docs for any noun/verb path. |
50
+ | `overview` | Read-only descriptive snapshot of the agent. |
51
+ | `doctor` | Check the agent-identity invariants (prompt-file-present, backend-consistency). |
52
+ | `cli overview` | Describe the CLI surface itself. |
53
+
54
+ Every command supports `--json`. Results go to stdout, errors/diagnostics to
55
+ stderr (never mixed). Exit codes: `0` success, `1` user error, `2` environment
56
+ error, `3+` reserved.
57
+
58
+ ## Make it your own
59
+
60
+ 1. Rename the package `convertible/` and the `convertible`
61
+ CLI/dist name throughout `pyproject.toml`, the package, `tests/`, and
62
+ `sonar-project.properties`.
63
+ 2. Edit `culture.yaml` with your `suffix` and `backend`.
64
+ 3. Rewrite `CLAUDE.md` for your agent and run `/init`.
65
+ 4. Re-vendor only the skills you need from guildmaster (see
66
+ [`docs/skill-sources.md`](docs/skill-sources.md)).
67
+
68
+ See [`CLAUDE.md`](CLAUDE.md) for the full conventions (version-bump-every-PR,
69
+ the `cicd` PR lane, deploy setup).
70
+
71
+ ## License
72
+
73
+ MIT — see [`LICENSE`](LICENSE).
@@ -0,0 +1,19 @@
1
+ convertible/__init__.py,sha256=JYtnSOfHnF_Mi1-X2zf4UrFykjHuvZFT-0zkt6NEB4A,408
2
+ convertible/__main__.py,sha256=WHuLyDe2jxViy3egr8ULsKn1mbEEah8vhjyy4OiHcxQ,180
3
+ convertible/cli/__init__.py,sha256=WAqekKV1Ynt-Qaw0RcDDNnjh0CiKZtwTPvwzEKeGN6A,4999
4
+ convertible/cli/_errors.py,sha256=U-yoWAPzhcC189gA2d88-4Lit7b_hHeoPYQwc_NpNgE,1304
5
+ convertible/cli/_output.py,sha256=p-SAa8yucaWtid13Yb6QUDrQeTn3ZboFyeOY-cpX5LU,1710
6
+ convertible/cli/_commands/__init__.py,sha256=DqpfVkhBo7cj6eWOpSlwx1ag0nTbrYJkV8pnPgl2gyU,70
7
+ convertible/cli/_commands/cli.py,sha256=9N7uRfJkA9UMiPtsg4MRbsEE-SdnaTIItjT9hV_mk8s,1683
8
+ convertible/cli/_commands/doctor.py,sha256=ZT-Gml3X0en3pQjpW36KM1cprCcpG2GsL4BM7oloE3I,4399
9
+ convertible/cli/_commands/explain.py,sha256=IlXlP94TT92Ne8nPqll2WIaPcKFTsikAslfNF8jYH4M,1232
10
+ convertible/cli/_commands/learn.py,sha256=tcAh2oUYTr2m2a4L8oALgLgFCC-joNHX56ocHGf1Qyg,3013
11
+ convertible/cli/_commands/overview.py,sha256=6oN1PLHsGkdaRAaoNg69gl8AJ1Wq7WLIAZzopVoMRVA,3956
12
+ convertible/cli/_commands/whoami.py,sha256=je7WAjb4SzBH6DdGMP5DzUvANFsr-0WhiK4ARUaFdsk,3654
13
+ convertible/explain/__init__.py,sha256=YPZcsSN_YPXzAUbPajnqVawEU2sdt5Q0oyfi0ZHj7H4,708
14
+ convertible/explain/catalog.py,sha256=evKCfcLxbUVACBKpwitNlqLXHArbZFX1OutPwU8Thio,3228
15
+ convertible_cli-0.1.2.dist-info/METADATA,sha256=syXr0mAFesi8UpBO5ytXNpPxS4HREv2BhuVLuCFcYwU,2964
16
+ convertible_cli-0.1.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
17
+ convertible_cli-0.1.2.dist-info/entry_points.txt,sha256=ZZ5pg_AcVNlbDlKrT5oe9J8UqSn_YLaj7sXSKb1wlts,53
18
+ convertible_cli-0.1.2.dist-info/licenses/LICENSE,sha256=wCcdPywGtFXx1P8N0j0eEDINSWfSjrIsU7ds1YZl-MA,1069
19
+ convertible_cli-0.1.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ convertible = convertible.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AgentCulture
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.