Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 37 additions & 21 deletions coact/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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:
Expand All @@ -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,
*,
Expand Down Expand Up @@ -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__":
Expand Down
6 changes: 3 additions & 3 deletions misc/docs/COACT_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

---
Expand All @@ -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
```
Expand All @@ -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).

Expand Down
2 changes: 1 addition & 1 deletion misc/docs/REUSE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ classifiers = [
license = "MIT"
dependencies = [
"skill",
"argh",
"cw",
]

[project.urls]
Expand Down
34 changes: 26 additions & 8 deletions skills/ai-assistant-command-mcp/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

<!-- argh-migration stage 2: name the house CLI adapter here and replace the argparse
sample with its dispatch-table idiom. Marker string: argh-migration -->
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)

Expand Down
2 changes: 1 addition & 1 deletion skills/coact-dev/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion tests/test_analysis_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
# ---------------------------------------------------------------------------


Expand Down
106 changes: 92 additions & 14 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -8,7 +8,9 @@
from __future__ import annotations

import importlib.util
import io

import cw
import pytest

from coact import __main__ as cli
Expand Down Expand Up @@ -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
Loading