diff --git a/coact/__main__.py b/coact/__main__.py index d907610..2ecb767 100644 --- a/coact/__main__.py +++ b/coact/__main__.py @@ -23,7 +23,7 @@ from __future__ import annotations -import argh +import cw from coact import complete as _complete from coact import emit_agent as _emit @@ -114,7 +114,6 @@ def estimate(*agents: str) -> str: return _estimate(list(agents)).render() -@argh.arg("project", nargs="?", default=".", help="Project root (default: current dir)") def inventory(project: str) -> str: """Enumerate a project's skills, derived agents, and MCP-exposed tools.""" return _inventory(project).render() @@ -125,7 +124,6 @@ def back(agent: str) -> str: return _back(agent).to_string() -@argh.arg("agents", nargs="+", help="Agent .md files / skill sources to wire") def scaffold( agents: list, *, dest: str | None = None, agents_dir: str = ".claude/agents" ) -> str: @@ -136,9 +134,6 @@ def scaffold( return str(result) -@argh.arg( - "source", nargs="+", help="Tool refs ('module:function'), a skill dir/SKILL.md, or both" -) def publish( source: list, *, @@ -197,23 +192,44 @@ def describe( return spec.render() +#: The verbs, in the order ``--help`` lists them. +COMMANDS = [ + plan, + complete, + emit, + realize, + diff, + estimate, + inventory, + back, + scaffold, + publish, + describe, +] + +#: Per-parameter ``add_argument`` particulars the signatures cannot express. +#: Keyed like ``COMMANDS``: command name, then parameter name. +CONFIG = { + "inventory": { + "project": dict( + nargs="?", default=".", help="Project root (default: current dir)" + ) + }, + "scaffold": { + "agents": dict(nargs="+", help="Agent .md files / skill sources to wire") + }, + "publish": { + "source": dict( + nargs="+", + help="Tool refs ('module:function'), a skill dir/SKILL.md, or both", + ) + }, +} + + def main() -> None: """Dispatch the coact CLI.""" - argh.dispatch_commands( - [ - plan, - complete, - emit, - realize, - diff, - estimate, - inventory, - back, - scaffold, - publish, - describe, - ] - ) + raise SystemExit(cw.dispatch(COMMANDS, config=CONFIG)) if __name__ == "__main__": diff --git a/misc/docs/COACT_SPEC.md b/misc/docs/COACT_SPEC.md index 29f03f7..24124ab 100644 --- a/misc/docs/COACT_SPEC.md +++ b/misc/docs/COACT_SPEC.md @@ -214,7 +214,7 @@ Follow the user's two loaded skills (`python-coding-standards`, `python-package- - **Dependency injection / no provider lock-in**: any LLM use goes through an injected facade (reuse `aw`'s `llm` / `StepConfig`; honor `ov` #4's provider-agnostic `structured(prompt, schema)->model` shape). `coact` must run its mechanical paths with **no LLM at all**. - **Mapping interfaces**: prefer `Mapping`/`MutableMapping` facades (a skills store, an agents store) consistent with `dol`/`py2mcp` idioms; this also makes `py2mcp.mk_mcp_from_store` reuse natural. - **Progressive disclosure**: simple things simple (`complete(skill)` one-liner with good defaults), complex things possible (full policy injection, custom emitters/backends). Always offer a dry-run/plan before a writing/spawning action. -- **CLI via `argh`**, dispatch-to-interface pattern (CLI / http via `qh` / programmatic share one core), package structure per `python-package-architecture` (`__init__.py` curated exports, `base.py`, `util.py`, `__main__.py`). The verbs (`complete`, `realize`, `diff`, `estimate`, `inventory`) are both Python functions and CLI subcommands. +- **CLI via `cw`**, dispatch-to-interface pattern (CLI / http via `qh` / programmatic share one core), package structure per `python-package-architecture` (`__init__.py` curated exports, `base.py`, `util.py`, `__main__.py`). The verbs (`complete`, `realize`, `diff`, `estimate`, `inventory`) are both Python functions and CLI subcommands. - **Informative errors & `check_requirements`** for optional backends/deps (Agent SDK, FastMCP/`py2mcp`, an LLM provider). --- @@ -233,7 +233,7 @@ coact/ ├── analysis.py # diff, estimate, inventory ├── llm.py # provider-agnostic LLM facade (thin; prefer importing aw's; only add structured() helper if absent) ├── util.py -└── __main__.py # argh CLI dispatch +└── __main__.py # cw CLI dispatch misc/docs/ └── REUSE.md # output of §1 ``` @@ -253,7 +253,7 @@ If `complete.py`/`realize.py` grow, split into subpackages with their own regist 7. **Persona/return-contract synthesis with optional injected LLM** (§5.5). 8. **REALIZE `mcp` backend via `py2mcp`** (§6.1.3). 9. **Analysis utilities** (§7): `diff`, `estimate`, `inventory`. -10. **CLI** (`argh`, §8) wrapping the verbs; completion via the same pattern `skill` uses. +10. **CLI** (`cw`, §8) wrapping the verbs; completion via the same pattern `skill` uses. Each milestone should be independently useful and shippable (progressive disclosure applies to the *roadmap* too). diff --git a/misc/docs/REUSE.md b/misc/docs/REUSE.md index c08c03c..0e6f5c9 100644 --- a/misc/docs/REUSE.md +++ b/misc/docs/REUSE.md @@ -27,7 +27,7 @@ architecture. **Reused symbols:** | `ParsedKey`, `find_project_root`, `atomic_write` | `skill.util` | Key normalization, project-root detection, safe writes. | | `chat`, `is_ai_available` | `skill.ai` | Provider-agnostic LLM facade (aisuite→anthropic→openai, lazy, degrades gracefully). `coact.llm` wraps this as one option for the *optional* persona-synthesis path. | | `load_config`, `data_dir`, `skills_dir` | `skill.config` | platformdirs-based config; `coact.config` mirrors the pattern for policy defaults. | -| argh `dispatch_commands` + `cli_format` pattern | `skill.__main__` | `coact.__main__` mirrors the dispatch-to-interface CLI shape so the two packages feel like one toolkit. | +| `cw.dispatch` + `cli_format` pattern | `skill.__main__` | `coact.__main__` mirrors the dispatch-to-interface CLI shape so the two packages feel like one toolkit. | **`coact` does NOT build:** a skill parser, a skills store, a plugin-registry mechanism, a validation framework, a config system, a cross-agent installer, diff --git a/pyproject.toml b/pyproject.toml index 6350b87..aad9cff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ classifiers = [ license = "MIT" dependencies = [ "skill", - "argh", + "cw", ] [project.urls] diff --git a/skills/ai-assistant-command-mcp/SKILL.md b/skills/ai-assistant-command-mcp/SKILL.md index 1913299..ccadfc9 100644 --- a/skills/ai-assistant-command-mcp/SKILL.md +++ b/skills/ai-assistant-command-mcp/SKILL.md @@ -184,7 +184,7 @@ Frontend build step (in `package.json`): "build:schemas": "json-schema-to-zod -i ../schemas/commands.json -o src/commands.gen.ts" ``` -## Emitter C: CLI (argparse / Click) +## Emitter C: CLI (`cw` / argparse) Follow Thor's `python-package-architecture` skill conventions. The dispatch table is `{spec.id: spec.handler}` — the same registry every other emitter reads, so the CLI is a @@ -215,14 +215,32 @@ def main(argv=None) -> None: print(json.dumps(handler(payload), default=str)) ``` -**Do not use `argh`.** It is LGPL-3.0-or-later, and it is being removed across this fleet; -new code uses stdlib `argparse` or Click. A house CLI adapter is being built to take this -emitter's place — a third string-referenced adapter beside `py2mcp` (MCP) and `qh` (HTTP) -over the same plain Python functions — and this section will name it and show its idiom -once it ships. +**Do not use `argh`.** It is LGPL-3.0-or-later and has been removed across this fleet. - +The house CLI adapter is **`cw`** (MIT, no dependencies) — the third string-referenced +adapter beside `py2mcp` (MCP) and `qh` (HTTP) over the same plain Python functions. It reads +the dispatch table directly, so the whole emitter above collapses to: + +```python +# emitters/cli_emitter.py +import cw +from command_registry.core import registry + +COMMANDS = { + spec.id: spec.handler for spec in registry().values() if "cli" in spec.surfaces +} + +def main() -> None: + raise SystemExit(cw.dispatch(COMMANDS, prog="app")) +``` + +`cw.dispatch` reads each handler's signature and docstring; `cw.mk_parser(COMMANDS)` returns +the same thing as a plain `argparse.ArgumentParser` when you want to inspect or extend it. +Per-parameter `add_argument` particulars that a signature cannot express go in a `config` +dict shaped like `COMMANDS` — see `coact/__main__.py` for a worked example. + +`cw.dispatch` *returns* an exit code rather than exiting, so the `raise SystemExit(...)` +above is load-bearing: drop it and every usage error exits 0. ## Emitter D: OpenAPI (FastAPI) diff --git a/skills/coact-dev/SKILL.md b/skills/coact-dev/SKILL.md index 11b3557..7ebcca5 100644 --- a/skills/coact-dev/SKILL.md +++ b/skills/coact-dev/SKILL.md @@ -40,7 +40,7 @@ job belongs to one of those, delegate — don't reimplement. | `analysis.py` | `diff` / `estimate` / `inventory` / `back` — tooling to see and move between the layers. | | `stores.py` | `AgentStore` (a `dol`-style mapping over `.claude/agents/`), `agents_dir`. | | `util.py` | `check_requirements`, `import_object`, and small shared helpers. | -| `__main__.py` | argh CLI — thin wrappers over the same core functions (incl. `realize --dry-run`, `scaffold`). | +| `__main__.py` | `cw` CLI — thin wrappers over the same core functions (incl. `realize --dry-run`, `scaffold`). | The public facade is `coact/__init__.py` (its `__all__` is the supported API). Decisions are logged in `misc/docs/DECISIONS.md`; the build brief is diff --git a/tests/test_analysis_cli.py b/tests/test_analysis_cli.py index 9be9312..2600c99 100644 --- a/tests/test_analysis_cli.py +++ b/tests/test_analysis_cli.py @@ -138,7 +138,7 @@ def test_aw_emitters_registered_when_aw_present(): # --------------------------------------------------------------------------- -# CLI smoke (exercises argh dispatch end-to-end) +# CLI smoke (exercises cw dispatch end-to-end) # --------------------------------------------------------------------------- diff --git a/tests/test_cli.py b/tests/test_cli.py index 82b9b95..37d4830 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,4 +1,4 @@ -"""Tests for the argh CLI verbs (``coact.__main__``). +"""Tests for the CLI verbs (``coact.__main__``). The CLI wrappers format core results for the terminal; calling them directly (rather than via a subprocess) exercises ``__main__.py`` and keeps the suite fast @@ -8,7 +8,9 @@ from __future__ import annotations import importlib.util +import io +import cw import pytest from coact import __main__ as cli @@ -151,18 +153,94 @@ def test_cli_describe_renders_draft(monkeypatch): assert "get_weather" in out and "proposed" in out -def test_main_wires_every_verb(monkeypatch): - # main() registers exactly the documented verbs into argh.dispatch_commands. - registered = {} +VERBS = { + "plan", "complete", "emit", "realize", "diff", "estimate", + "inventory", "back", "scaffold", "publish", "describe", +} - def fake_dispatch(commands): - registered["names"] = [c.__name__ for c in commands] - registered["callable"] = all(callable(c) for c in commands) - monkeypatch.setattr(cli.argh, "dispatch_commands", fake_dispatch) - cli.main() - assert registered["callable"] - assert set(registered["names"]) == { - "plan", "complete", "emit", "realize", - "diff", "estimate", "inventory", "back", "scaffold", "publish", "describe", - } +def _subcommands(parser): + """The subcommand names a built parser actually offers.""" + import argparse + + for action in parser._actions: + if isinstance(action, argparse._SubParsersAction): + return set(action.choices) + raise AssertionError("parser has no subcommands") + + +def test_parser_offers_every_verb(): + # The parser cw builds is a plain argparse parser, so the wiring is + # inspectable directly -- no monkeypatching the dispatcher to find out. + parser = cw.mk_parser(cli.COMMANDS, config=cli.CONFIG, prog="coact") + assert all(callable(c) for c in cli.COMMANDS) + assert _subcommands(parser) == VERBS + + +@pytest.mark.parametrize( + "verb,param,nargs,default", + [ + ("inventory", "project", "?", "."), # optional positional with a default + ("scaffold", "agents", "+", None), # one-or-more + ("publish", "source", "+", None), # one-or-more + ("estimate", "agents", "*", None), # inferred from *args + ], +) +def test_positional_nargs_shapes(verb, param, nargs, default): + # The three declared nargs shapes (plus the one inferred from *args) are + # what this CLI's grammar hangs on; assert them on the real parser. + parser = cw.mk_parser(cli.COMMANDS, config=cli.CONFIG, prog="coact") + sub = _subcommands_parser(parser, verb) + action = next(a for a in sub._actions if a.dest == param) + assert action.nargs == nargs + assert action.default == default + assert not action.option_strings # still positional + + +def _subcommands_parser(parser, name): + import argparse + + for action in parser._actions: + if isinstance(action, argparse._SubParsersAction): + return action.choices[name] + raise AssertionError("parser has no subcommands") + + +def test_dispatch_runs_a_verb_end_to_end(tmp_path): + # The whole path -- parse, bind, call, print -- without a subprocess. + _skill(tmp_path) + buf = io.StringIO() + code = cw.dispatch( + cli.COMMANDS, + ["inventory", str(tmp_path)], + config=cli.CONFIG, + prog="coact", + out=buf, + ) + assert code == 0 + assert "inventory:" in buf.getvalue() and "ux" in buf.getvalue() + + +def test_inventory_project_defaults_to_cwd(tmp_path, monkeypatch): + # nargs='?' + default='.': the verb is callable with no positional at all. + _skill(tmp_path) + monkeypatch.chdir(tmp_path) + buf = io.StringIO() + assert cw.dispatch(cli.COMMANDS, ["inventory"], config=cli.CONFIG, out=buf) == 0 + assert "ux" in buf.getvalue() + + +def test_bad_command_line_still_exits_2(monkeypatch): + # A console script that starts exiting 0 on a bad command line breaks every + # CI step that checks $?. cw.dispatch *returns* the code, so main() must + # re-raise it -- assert the console-script path, not just the return value. + buf, err = io.StringIO(), io.StringIO() + assert ( + cw.dispatch(cli.COMMANDS, ["scaffold"], config=cli.CONFIG, out=buf, err=err) + == 2 + ) + + monkeypatch.setattr("sys.argv", ["coact", "scaffold"]) + with pytest.raises(SystemExit) as excinfo: + cli.main() + assert excinfo.value.code == 2