cli-skill-design · git:20260905.ae8c040 · 2026-09-05 · sha256 793880f633263d7e
cli-skill-design git:20260905.ae8c040A
Immutable. This exact content is served forever at /api/v1/blob/793880f633263d7e.
---
name: cli-skill-design
description: Design a `co <thing>` CLI surface and its SKILL.md together so an agent can drive it without guessing — every command ends by naming the next one, `--help` lists everything, and every failure says what to run instead. Use when adding a new CLI command group, writing or rewriting a SKILL.md for one, or auditing an existing one.
---
# Designing a CLI skill
A CLI skill is two files that have to agree: the command surface (`co <thing> ...`)
and the `SKILL.md` that tells an agent how to drive it. Design them together —
the agent's whole world is *what it types* and *what comes back*.
`co-browser` is the worked example. Read it before you write anything: its exit-code
table, its "read the output, not just the exit code" rule, and its Done checklist are
what this methodology generalizes. (Building a skill that drives a *website* through
`co browser`? That is the sibling skill `browser-workflow-skill-builder` — DOM,
selectors, verification scripts. This one is about the command surface itself.)
Two properties. Each has a test you run and paste the result of — not a principle
you assert in the PR.
## (a) Tip-tested discoverability
**Rule:** every command execution — success *and* failure — ends by naming the next
command, spelled out, with the argument shape filled in.
```
Read one with: co gmail read <#> ✅ names the command
See the docs for more options ❌ names nothing
```
### The tip test
A tip is good if an agent that has *only that tip* makes the right next call. That
is testable, so test it:
```python
from connectonion import llm_do
llm_do(
f"You just ran a shell command. Its full output was:\n\n{out}\n\n"
"Your goal: read the newest email. Reply with ONE shell command and nothing else.",
model="co/gemini-3.8-flash",
)
```
Use a **text-only** call (`llm_do`), not `co ai`. An agent with a shell will run the
command it picks — measured: the first attempt at this test executed `co gmail read 1`
and then `co auth google` against a real account. You are grading the reply, not the
mailbox.
- **Pass** — the reply is a command that exists and advances the goal (`co gmail read 1`).
- **Fail** — it invents a name (`co gmail open 1`), asks for help, or replies with prose.
Rules for the harness, or the result means nothing:
- Give it the **output only**. No `SKILL.md`, no `--help`, no conversation history —
those are exactly the crutches the tip exists to replace.
- Pin the model so a rerun compares like with like.
- Run it per command, not once. Score the whole surface in a table and paste it
into the PR:
| command | tip printed | goal given to the fresh agent | it replied | pass |
|---|---|---|---|---|
- Anything that fails: fix the tip, not the test.
### What makes a tip pass
- It contains the **literal command name**, not a description of it.
- Placeholders say where the value comes from: `<#> from this listing`, not `<id>`.
- **The tip survives piping.** Agents always pipe. A tip inside
`if console.is_terminal:` is invisible to every caller that needs it, and manual
testing never catches it because a human runs in a terminal. Check every one:
```bash
co <thing> <cmd> | cat # the tip must still be there
```
- **One** next step. Two tips is a fork, and the agent resolves a fork by guessing.
- Failures get tips too, and the tip is the fix (see (b)).
Measured on the mail surface (8 tips, `co/gemini-2.5-flash`, 2026-08): 5 passed. The
three failures are the three rules above, each in its pure form —
- a piped listing prints **no** tip, and the model invented `readmail 18f2a`;
- `Retry the same command with --idempotency-key <key>` never names the command, and
the model replied `!! --idempotency-key k-123`;
- `run co gmail to refresh` stops one step short of the goal, and the model replied
`co gmail && co gmail 3` — a command that does not exist.
A tip that reads fine to a human fails this test. That is the point of running it.
## (b) Self-diagnosing, self-correcting execution
### Rule 1 — `--help` enumerates every capability
An agent that cannot find a command from `--help` will invent one, and an invented
command name costs a round trip every time. So: no hidden commands, no capability
that only `SKILL.md` knows about.
**Check it, both directions:**
```bash
g=<thing>
# every subcommand the CLI has
co $g --help | sed -n '/─ Commands/,$p' | grep -oE '^│ [a-z-]+' | awk '{print $2}' | sort -u
# every command the skill mentions
grep -oE "co $g [a-z-]+" SKILL.md | awk '{print $3}' | sort -u
```
Diff the two lists. Every CLI command must be either documented or deliberately
skipped (say which, and why, in the PR). Every command the skill mentions must
exist — a skill naming a command that `--help` does not list is a documentation bug,
and it is the failure mode this check exists to catch.
Repeat one level down for command groups (`co outlook contact --help`).
### Rule 2 — every error path is a fix-it guide
The exit code says *what kind* of problem; the text says *what to run*. Both, every
time. Follow `co-browser`'s contract: a small, stable set of codes, and a table in
`SKILL.md` whose right-hand column is a command, not an adjective.
**Check it by producing each row.** For every exit code your surface can return,
write down the command that provokes it and run it:
```bash
co <thing> <cmd-that-fails>; echo "exit=$?"
```
Then assert two things about the output: it names the cause, and it names a command
to run next. Paste the reproduction table into the PR:
| exit | provoked by | printed | names a next command |
|---|---|---|---|
If a row cannot be provoked, you do not know that it behaves as documented — say so
rather than documenting it.
### Rule 3 — say so when failure exits 0
Some commands print `❌ Failed` and still exit `0`. That is fine as long as the
skill says it loudly, because an agent that chains `cmd && next` on such a surface
walks straight past the failure. Where any failure exits 0, `SKILL.md` opens with
co-browser's rule:
> **Always read the output, not just the exit code.**
and the exit-code table has a row for "exit 0, error text on stdout".
## Progressive disclosure
The skill is read top to bottom by an agent that wants to act now.
1. **Routing first** — if several commands could serve the request, the first
section is the table that picks one. Wrong-command errors are the expensive kind.
2. **The 80% commands next**, as copy-pasteable lines.
3. **The gotchas that change a result** — the ones that make an agent report
something false if it doesn't know them (stale numbering, prefix-only search,
silent export-on-download). Not trivia.
4. **Errors and recovery last.** By then the agent is only here because something
broke.
Everything else belongs in `--help`. If the skill is restating `--help`, delete it
from the skill: two copies drift, and the copy in the skill is the one that goes
stale.
## Honesty rule
Document only what you have run. Every command, flag, and exit code in a `SKILL.md`
must have been verified against the code or `--help` on the branch you are writing
against — not remembered, and not planned. Behavior that is designed but unshipped
gets a dated "not yet — today it works like this" note, never a present-tense
sentence. An agent cannot tell aspiration from fact, and it pays for the difference
with a failed run.
## Done checklist
- [ ] Routing table first, if more than one command could serve the request
- [ ] Every command in the skill exists in `--help` (diffed, both directions)
- [ ] Every command prints one next-step tip, and the tip survives `| cat`
- [ ] Tip test run per command, results table in the PR
- [ ] Exit-code table present, right column is a command
- [ ] Every exit code provoked at least once, reproduction table in the PR
- [ ] "Read the output, not just the exit code" stated if any failure exits 0
- [ ] Gotchas that change a reported result are written down
- [ ] Nothing documented that was not run