USAGE.md was the better README — entry-point users want examples and quick start, not the public-API contract. Flip them and refresh the amy-expert skill so it matches the post-refactor reality. cli/README.md (was USAGE.md): - Install, quick start, seven worked examples, full command reference, output modes, multi-account workflows, agent recipes, troubleshooting. - Cross-refs point at DEVELOPMENT.md for the contract / architecture and ROADMAP.md for what's coming. cli/DEVELOPMENT.md absorbs the old README's architecture sections: - New "Public contract" section at the top — the stable promises (text-default + --json contract, stderr for humans, exit codes, ~/.amy/ as the world). - "Local event store" deep-dive with the cache-helper API. - "Relay routing" rules table. - "Full on-disk layout" tree with annotations. cli/ROADMAP.md, cli/USAGE.md: - ROADMAP cross-refs collapsed (no more USAGE.md row). - USAGE.md deleted — content lives in README now. .claude/skills/amy-expert refreshed end-to-end: - SKILL.md description + Rules 2 and 4 rewritten for the dual-output contract (text default, --json opt-in) and the ~/.amy/ layout. - "Where things live" listing matches the current source tree (Output.kt, Aliases.kt, UseCommand.kt, secrets/, all the new command files). - "Common mistakes" lists the new traps: don't read user.home directly, don't add a global flag that collides with subcommand --name, don't use Json.writeLine (it's gone). - references/command-template.md uses Output.emit / Output.error (Json.writeLine / Json.error helpers no longer exist). - references/output-conventions.md rewritten around the dual-mode contract — same JSON shape rules, but framed as "this is what --json emits" rather than "this is stdout."
4.7 KiB
Output conventions
amy ships a dual-output contract. Default stdout is human-readable
text (a YAML-ish render of the underlying result map); --json flips
stdout to a single JSON object per success. The text shape can drift;
the --json shape is the public API.
Commands always emit via Output.emit(mapOf(...)). The map IS the
JSON shape — the renderer in Output.kt derives the text from the
same map. Don't write two render paths; write one map and let
Output pick.
Channels
| Stream | Default mode | --json mode |
|---|---|---|
| stdout | YAML-ish text from Output.emit(...) |
Exactly one JSON object per successful invocation |
| stderr | Human progress logs, warnings, per-relay ACK traces, stack traces, printUsage() output, errors as error: <code>: <detail> |
Same logs, plus errors as {"error":...,"detail":...} |
If a command needs to emit structured data for machines, it goes on
stdout and is automatically JSON under --json. If it needs to
explain what it's doing to a human watching, stderr.
Exit codes
| Code | Meaning |
|---|---|
0 |
Success. |
1 |
Runtime error. |
2 |
Bad arguments. |
124 |
await timed out. |
Throw the right exception type in commands:
IllegalArgumentException→ exit 2 automatically.AwaitTimeout→ exit 124 automatically.- Anything else → exit 1.
The top-level main() in Main.kt handles the translation. Don't
try-catch at the command level unless you're converting a third-party
exception into one of the above.
--json object shape
Top-level
Always an object. Never an array, never a primitive, never a newline-delimited stream.
{ "event_id": "...", "kind": 1, "published_to": [...] }
Keys
- Stable snake_case.
- Additive evolution is safe; renaming or removing a key is a breaking change.
- Don't nest unnecessarily.
{"data":{...}}is noise.
Identifiers
| Thing | Form |
|---|---|
| Event ID | 64-char lowercase hex string. Key name: event_id. |
| Pubkey (primary subject) | hex and bech32. Keys: pubkey + npub. |
| Pubkey (secondary reference) | hex only. Key: pubkey. |
| Relay URL | Normalized string (wss://…). Never an object. |
| Timestamps | Unix seconds, integer. Key names end in _at. The text renderer auto-formats these as 2026-04-25 13:42:11Z (8m ago). |
| Group ID (Marmot) | Hex string. Key: group_id. |
| Byte counts | Integer. Key names end in _bytes. The text renderer auto-formats these as 8.7 KiB. |
Collections
- Pluralise:
messages,members,admins,events. - Always an array (possibly empty), never
null. - Order: oldest-first unless there's a good reason otherwise — state
it in the key name (
messages_newest_first) if you flip it.
Booleans
- Use
true/falsein the result map. The text renderer prints them asyes/no(green/red);--jsonkeeps the literal booleans. - Name keys so
trueis the expected/successful state:is_member,published,accepted.
Publish results
When a command publishes an event, the canonical output shape is:
{
"event_id": "<hex>",
"kind": 1,
"published_to": ["wss://relay.a/", "wss://relay.b/"],
"rejected_by": ["wss://relay.c/"]
}
published_to is relays that ACK'd true. rejected_by is relays
that ACK'd false. Relays that didn't answer before the timeout
appear in neither — add timed_out_on if you need to surface them.
Error shape
Default mode (text):
error: not_member: <gid>
Under --json:
{ "error": "not_member", "detail": "<gid>" }
erroris a short, stable, lower_snake code. Agents can branch on it.detailis free text — OK to change between versions.- Common codes today:
bad_args,no_identity,no_account,exists,bad_key,not_member,no_dm_relays,timeout,runtime. Reuse before inventing.
Use Output.error("code", "detail") from commands; it picks the
right channel and format based on the active mode.
Never
println(...)of anything exceptOutput.emit(...).Json.writeLine/Json.error— that helper is gone; use theOutputobject instead.- Multi-line JSON (pretty-printed) under
--json. One line, always. - Mixing stdout lines — one command invocation emits one stdout line
in
--jsonmode. If you need progress updates, they go on stderr. - Machine output to stderr. The whole point is clean separation.
- Silent fallbacks — if a relay rejects your publish, say so in the result map.
- Building text rendering by hand. Trust the
Output.ktrenderer: it handles alignment, colour, byte/timestamp formatting, nested maps and lists. If you need a bespoke render for one command, pass a custom render lambda — don't go aroundOutput.