Metadata-Version: 2.4
Name: agent-cli-scaffold
Version: 0.1.1
Summary: Build CLIs an agent can actually drive: the contract, the runtime, and the scaffold
Project-URL: Repository, https://github.com/ivorpad/agent-cli-scaffold
Author: Ivor Padilla
License-Expression: MIT
License-File: LICENSE
Requires-Python: >=3.11
Requires-Dist: pydantic-settings>=2.6
Requires-Dist: pydantic>=2.9
Requires-Dist: rich>=13.7
Requires-Dist: typer>=0.15
Description-Content-Type: text/markdown

# agent-cli-scaffold

[![ci](https://github.com/ivorpad/agent-cli-scaffold/actions/workflows/ci.yml/badge.svg)](https://github.com/ivorpad/agent-cli-scaffold/actions/workflows/ci.yml)

Build CLIs an agent can actually drive. A runtime you import, a scaffold that
generates a working tool, and a checklist that runs.

The command and the module are both `agtcli`; `agent-cli-scaffold` is only the
name it is published under.

The rules and the incident behind each are in [PLAYBOOK.md](PLAYBOOK.md). This
file is how to use the thing.

## Why a library and not a template

The same contract copied into five projects is five things that drift. orly and
mercadona-cli independently grew the same exit-code taxonomy, the same
compact/`--raw` split, and the same atomic-write helper, in two languages, and
the copies already disagree.

So the mechanical half is imported and tested once. `agtcli check` covers the
half that is still yours.

## Install

```bash
pip install agent-cli-scaffold     # or: uv tool install agent-cli-scaffold
agtcli --help
```

From a clone instead, which is what you want if you are changing the contract:

```bash
uv sync && uv tool install --editable .
```

Either works, and the difference shows up in what you scaffold. A generated
project depends on `agent-cli-scaffold`, and `agtcli new` pins that dependency
at wherever this copy came from: nothing to pin from an index, the local path
from a clone, the same commit from a git install. So a project scaffolded by
the released `agtcli` builds anywhere; one scaffolded from your clone builds
where that clone is.

## Recipes

### Scaffold a CLI and confirm it works

```bash
agtcli new hansard --description "Search UK Hansard debates." \
                    --triggers "hansard, what did parliament say about X"
cd hansard && uv sync && uv run pytest -q     # 14 passing
uv tool install --editable . && hansard --help
```

You get a tool that already has typed exit codes, a compact default with a
`--raw` escape hatch, atomic `0600` state, a credential-rotation hook, a
blast-radius guard, a thin skill, and contract tests that pass. Then replace
`src/hansard/api.py` with the real service.

### Audit one before shipping

```bash
agtcli check ./hansard          # exits non-zero while anything fails
agtcli check ./hansard --json   # one object per check
agtcli rules                    # just the checklist
```

### Gate a commit or a CI job on the checklist

```bash
agtcli check . --json | jq -e '.failed == 0'
```

### Find which rule a check is about

```bash
agtcli rules --json | jq -r '.[] | select(.name=="truncation-marked") | .why'
```

Fifteen items are checked by reading the project. Nine cannot be, so they are
reported as unverified rather than counted as passes.

```console
$ agtcli check ./hansard
  ✓ skill-shipped: skill/SKILL.md
  ✓ exit-codes-typed: inherited from agtcli.build
  ✓ truncation-marked: cuts are marked
  ✓ blast-radius-capped: costly commands consult the guard
  ...
  ? dumb-subagent-test-run: the only step that reliably finds what you missed

9 item(s) cannot be checked mechanically; verify them yourself.
```

## What you import

```python
import agtcli
from agtcli import Session

app = agtcli.build("mytool", help="...", notes=AGENT_NOTES, unit="EUR")

@app.command()
def get(ctx: typer.Context, id: str):
    s = Session.get(ctx)
    s.out.emit(record, compact=lambda r: {"id": r["id"]})

@app.command()
@agtcli.costly                       # cannot return without checking the cap
def submit(ctx: typer.Context, id: str):
    s = Session.get(ctx)
    s.guard.check(total, f"submitting {id}")

main = agtcli.main_for(app)
```

| | |
|---|---|
| `build` / `run` / `main_for` | a Typer app wired to the contract; one place decides every exit code |
| `ExitCode` | `0` ok, `1` retryable, `2` usage, `3` not-found, `4` auth, `5` timeout, `6` refused. The `--help` table is generated from this enum, so it cannot drift |
| `Output` | compact by default, `--raw` for everything, and under `--json` stdout carries JSON and nothing else |
| `trim` | cut a value yourself; the result says how much is missing. The one to reach for |
| `mark_truncation` / `elide` | the halves of `trim`. `mark_truncation` alone is for text *somebody else* cut, where the last character is the only evidence; wrapping your own `elide` in it marks whole values as truncated |
| `write_atomic` / `write_json` | mkstemp plus `os.replace`, `0600`, because agents run in parallel |
| `Guard` / `@costly` | a cap by flag, env or config that fails closed, and raises if a costly command never consults it |
| `Config` | pydantic-settings with the precedence the right way round: flag, then env, then file |

Errors are typed and carry what to do next:

```console
$ hansard submit 123 --json ; echo "exit $?"
{
  "error": "refused",
  "message": "submitting 123 is irreversible and --yes was not given",
  "exit_code": 6,
  "retryable": false,
  "remedy": "re-run with --yes once a human has agreed"
}
exit 6
```

## The skill

`skill/` is meant to be symlinked, so it versions with the code:

```bash
ln -s "$PWD/skill" ~/.claude/skills/agtcli
```

A CLI is invoked, not found, so the skill body is a one-liner that points at
`--help`. What the agent discovers progressively is the tool: `tool --help` for
the verbs, rules and exit codes; `tool <verb> --help` for one command, with the
global flags carried down so `--max-cost` is still visible on the command that
spends; and a next-step hint in the output itself.

## Development

```bash
uv run pytest -q
```

Every test is a rule from the playbook or a bug that actually happened. If you
change the contract, a test should fail.
