docs(cli): make USAGE.md the README; move contract material to DEVELOPMENT

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."
This commit is contained in:
Claude
2026-04-25 16:35:10 +00:00
parent 1b307e5955
commit 6ef0c372b1
7 changed files with 717 additions and 647 deletions
+102 -41
View File
@@ -1,6 +1,6 @@
---
name: amy-expert
description: Patterns for extending `amy`, the Amethyst CLI in `cli/`. Use when adding an `amy <verb>` command, touching files under `cli/src/main/kotlin/…/cli/`, wiring a new subcommand into `Main.kt`, writing an interop test script that drives Amy, or extracting logic out of `amethyst/` into `commons/` so a CLI command can call it. Enforces the thin-assembly-layer rule (no Nostr protocol or business logic inside `cli/`), the JSON-output contract (single-line object on stdout, exit codes 0/1/2/124), and the extract-from-Android recipe. Complements `nostr-expert` (protocol in Quartz), `kotlin-multiplatform` (expect/actual for extraction), and `feed-patterns` / `account-state` / `relay-client` (where the business logic should end up). NOT for general Nostr or Kotlin work — those have their own skills.
description: Patterns for extending `amy`, the Amethyst CLI in `cli/`. Use when adding an `amy <verb>` command, touching files under `cli/src/main/kotlin/…/cli/`, wiring a new subcommand into `Main.kt`, writing an interop test script that drives Amy, or extracting logic out of `amethyst/` into `commons/` so a CLI command can call it. Enforces the thin-assembly-layer rule (no Nostr protocol or business logic inside `cli/`), the dual-output contract (text by default, single-line JSON object on stdout under `--json`, exit codes 0/1/2/124), and the extract-from-Android recipe. Complements `nostr-expert` (protocol in Quartz), `kotlin-multiplatform` (expect/actual for extraction), and `feed-patterns` / `account-state` / `relay-client` (where the business logic should end up). NOT for general Nostr or Kotlin work — those have their own skills.
---
# Amy CLI Expert
@@ -42,17 +42,28 @@ A `commands/*.kt` file longer than ~200 lines is a code smell.
Either the command is doing too many things, or the logic has
leaked in from where it should have lived.
### Rule 2 — stdout JSON, stderr humans
### Rule 2 — text by default, `--json` is the machine contract
- Every success: one line, one JSON object on stdout, via
`Json.writeLine(mapOf(...))`.
- Every failure: `Json.error("code", "detail")` → single-line JSON
on stderr, non-zero exit.
- Exit codes: `0` success · `1` runtime · `2` bad args · `124` await
timeout.
- Keys are stable, snake_case. Adding a key is safe; renaming or
removing one is a breaking change and needs the commit message to
say so.
amy ships a dual-output contract:
- **Default stdout is human-readable text.** A YAML-ish render of the
result map. No shape promise — the renderer can change between
releases.
- **`--json` switches stdout to one JSON object, one line.** Stable
snake_case keys; this shape is the public API.
- **stderr is for humans.** Progress logs, warnings, per-relay ACK
traces. Errors go here too — `error: <code>: <detail>` by default,
JSON `{"error":"…","detail":"…"}` under `--json`.
- **Exit codes:** `0` success · `1` runtime · `2` bad args · `124`
await timeout.
- Adding a `--json` key is safe; renaming or removing one is a
breaking change and needs the commit message to say so.
Commands emit results via `Output.emit(mapOf(...))` and errors via
`Output.error("code", "detail")`. The `Output` object (in
`cli/src/main/kotlin/…/cli/Output.kt`) handles the text-vs-JSON
branching automatically. Never `println(...)` user-facing output
directly — `System.err.println(...)` is fine for progress logs only.
See `references/output-conventions.md`.
@@ -62,16 +73,37 @@ No `readLine()`, no TTY prompts, no hidden interactive behaviour.
Passwords, names, keys, anything — all flags. Any network wait is
an explicit `await` verb with `--timeout`.
### Rule 4 — Data-dir is the whole world
### Rule 4 — `~/.amy/` is the whole world
State is reloaded from `--data-dir PATH` on every invocation. No
singletons, no in-process caches that survive across runs. This is
what lets 100 parallel interop scenarios share a harness safely.
State is reloaded from `~/.amy/` on every invocation. No singletons,
no in-process caches that survive across runs. This is what lets 100
parallel interop scenarios share a harness safely.
Files live in well-known locations — see
`cli/README.md § Data-dir layout`. Don't add unstructured state; if
you need new persisted state, add it to `Config.kt` or
`stores/FileStores.kt` with a named JSON schema.
The layout:
- `~/.amy/shared/events-store/` — one file-backed Nostr event store
per machine, shared across every account.
- `~/.amy/<account>/` — per-account dir: `identity.json`,
`state.json`, `aliases.json`, `marmot/`.
- `~/.amy/current` — marker file written by `amy use NAME` to pin
the active account.
Account selection is via the global `--account NAME` flag (required
when more than one account exists; auto-picked when exactly one
does). `--account` cannot collide with subcommand flags, so commands
like `marmot group create --name "Group"` or `profile edit --name "Alice"`
keep their own `--name` parameter.
Tests isolate by overriding `$HOME` for the amy subprocess
(`HOME=$(mktemp -d) amy --account alice init`). amy reads `$HOME`
directly (not `user.home`, which JDK 21 derives from `getpwuid` and
ignores `$HOME`), so the same convention `git`/`gpg`/`npm`/`ssh`
follow Just Works.
If you need new persisted state, add it to `Config.kt`,
`stores/FileStores.kt`, or a new helper (e.g. `Aliases.kt`) with a
named JSON schema. Don't smuggle state into `~/.amy/` outside the
documented files.
### Rule 5 — Extract before adding
@@ -92,9 +124,9 @@ Full checklist: `references/extraction-recipe.md`.
## Standard command shape
Every new command follows the same shape — parse args, open Context,
prepare, call into commons/quartz, publish or drain, emit one JSON
line. The template is in `references/command-template.md`; copy it
rather than re-deriving it.
prepare, call into commons/quartz, publish or drain, emit one result
via `Output.emit`. The template is in `references/command-template.md`;
copy it rather than re-deriving it.
Wire-up checklist:
1. New file in `cli/commands/` with the `object` pattern.
@@ -107,34 +139,54 @@ Wire-up checklist:
7. If the verb changes observable wire behaviour (a new event kind,
a new relay-routing rule, a new JSON discriminator), add a case
in the appropriate harness under `cli/tests/``cli/tests/marmot/`
for MLS flows, `cli/tests/dm/` for NIP-17, or a new sibling suite
if it's neither.
for MLS flows, `cli/tests/dm/` for NIP-17, `cli/tests/cache/` for
event-store behaviour, or a new sibling suite if it's none.
If you change output shape: note it in the commit message, bump the
example in `README.md`, update any interop fixtures under
`cli/tests/`.
If you change `--json` output shape: note it in the commit message,
bump the example in `cli/README.md`, update any interop fixtures
under `cli/tests/`.
## Where things live
```
cli/
├── README.md # user-facing: commands, JSON contract, quick start
├── DEVELOPMENT.md # touch-the-code: architecture, conventions, testing
├── README.md # user-facing tour: install, examples, command tables
├── DEVELOPMENT.md # public contract, architecture, design rules,
│ # event-store, relay-routing, full on-disk layout
├── ROADMAP.md # parity matrix + ordered milestones
├── plans/ # dated design docs (use for new subsystems)
├── tests/ # end-to-end shell harnesses against a local relay
│ ├── lib.sh # shared logging + result tracking
│ ├── headless/ # shared amy wrappers + assertions
│ ├── marmot/ # MLS group-messaging interop (vs whitenoise-rs)
── dm/ # NIP-17 DM interop (two amy clients)
── dm/ # NIP-17 DM interop (two amy clients)
│ └── cache/ # FsEventStore behaviour vs the cache helpers
└── src/main/kotlin/…/cli/
├── Main.kt # argv dispatch
├── Main.kt # argv dispatch, global flags
├── Args.kt # flag parser
├── Json.kt # stdout/stderr JSON
├── Config.kt # Identity, RelayConfig, RunState, DataDir
├── Output.kt # text/json mode emitter + colour
├── Aliases.kt # per-account aliases.json read/write
├── Config.kt # Identity, RunState, DataDir (~/.amy layout)
├── Context.kt # per-run wiring — the backbone
├── stores/ # file-backed persistence
── commands/ # one file per top-level verb group
├── SecureFileIO.kt # 0600/0700 atomic writes, perm tighten
── stores/ # file-backed MLS / KP / message stores
├── secrets/ # SecretStore backends (keychain / ncryptsec / plaintext)
└── commands/ # one file (or group) per top-level verb
├── UseCommand.kt # `amy use NAME`
├── InitCommands.kt # init, whoami
├── CreateCommand.kt + LoginCommand.kt
├── RelayCommands.kt
├── ProfileCommands.kt
├── NotesCommands.kt + PostCommand.kt + FeedCommand.kt
├── DmCommands.kt
├── KeyPackageCommands.kt
├── GroupCommands.kt + GroupCreateCommand.kt + GroupReadCommands.kt
│ GroupAddMemberCommand.kt + GroupMembershipCommands.kt
│ GroupMetadataCommands.kt
├── MessageCommands.kt
├── MarmotResetCommand.kt
├── AwaitCommands.kt
└── StoreCommands.kt
```
Shared logic consumed by Amy lives in `commons/`:
@@ -146,9 +198,10 @@ Shared logic consumed by Amy lives in `commons/`:
## Common mistakes to refuse
- **Adding protocol logic to `cli/`.** Push back, offer to extract.
- **Silently changing a JSON key.** Flag as breaking.
- **Using `println` or `print`.** Use `Json.writeLine` / `Json.error`.
Plain `System.err.println` is fine for progress logs but never for
- **Silently changing a `--json` key.** Flag as breaking.
- **Using `println` or `print` for command output.** Use
`Output.emit(...)` / `Output.error(...)`. Plain
`System.err.println` is fine for progress logs but never for
user-consumable output.
- **`runBlocking` inside a command** — the top-level `main` already
does that. Commands are `suspend fun`.
@@ -158,6 +211,13 @@ Shared logic consumed by Amy lives in `commons/`:
or `resolveUserHexOrNull` in `quartz/nip05DnsIdentifiers/`.
- **Re-inventing publish-and-confirm.** Use `Context.publish`.
- **Re-inventing one-shot subscription.** Use `Context.drain`.
- **Reading `user.home` directly.** Use `DataDir.DEFAULT_ROOT`, which
reads `$HOME` (the convention `git`/`gpg`/`npm` follow); JDK 21's
`user.home` is derived from `getpwuid` and ignores `$HOME`, which
silently breaks the test-isolation pattern.
- **Adding a global flag that collides with subcommand flags.**
`--name` is reserved for subcommand use (group/profile names).
Account selection is `--account`.
## Plans & design docs
@@ -171,9 +231,10 @@ frozen.
## Cross-references
- [`cli/README.md`](../../../cli/README.md)
- [`cli/DEVELOPMENT.md`](../../../cli/DEVELOPMENT.md)
- [`cli/ROADMAP.md`](../../../cli/ROADMAP.md)
- [`cli/README.md`](../../../cli/README.md) — user-facing tour
- [`cli/DEVELOPMENT.md`](../../../cli/DEVELOPMENT.md) — public
contract, architecture, on-disk layout
- [`cli/ROADMAP.md`](../../../cli/ROADMAP.md) — parity matrix
- `references/command-template.md`
- `references/extraction-recipe.md`
- `references/output-conventions.md`
@@ -11,7 +11,7 @@ package com.vitorpamplona.amethyst.cli.commands
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Json
import com.vitorpamplona.amethyst.cli.Output
object NotePublishCommand {
suspend fun run(dataDir: DataDir, rest: Array<String>): Int {
@@ -26,7 +26,7 @@ object NotePublishCommand {
.buildTextNote(ctx.signer, text)
val ack = ctx.publish(event, ctx.outboxRelays())
Json.writeLine(mapOf(
Output.emit(mapOf(
"event_id" to event.id,
"kind" to event.kind,
"published_to" to ack.filterValues { it }.keys.map { it.url },
@@ -40,6 +40,10 @@ object NotePublishCommand {
}
```
`Output.emit(...)` handles the text-vs-JSON mode automatically. The
result map IS the `--json` shape; the human-readable text default is
derived from the same map by `Output.kt`'s renderer.
## Multi-verb group
When a feature has several verbs (`note publish`, `note show`,
@@ -48,13 +52,13 @@ When a feature has several verbs (`note publish`, `note show`,
```kotlin
object NoteCommands {
suspend fun dispatch(dataDir: DataDir, tail: Array<String>): Int {
if (tail.isEmpty()) return Json.error("bad_args", "note <publish|show|react>")
if (tail.isEmpty()) return Output.error("bad_args", "note <publish|show|react>")
val rest = tail.drop(1).toTypedArray()
return when (tail[0]) {
"publish" -> NotePublishCommand.run(dataDir, rest)
"show" -> NoteShowCommand.run(dataDir, rest)
"react" -> NoteReactCommand.run(dataDir, rest)
else -> Json.error("bad_args", "note ${tail[0]}")
else -> Output.error("bad_args", "note ${tail[0]}")
}
}
}
@@ -85,13 +89,17 @@ For every new command:
- No `runBlocking` in a command body — `main()` already does it.
- No `println` / `print` for command output — use
`Json.writeLine(...)`. `System.err.println(...)` is fine for
progress logs (they're already disposable).
`Output.emit(...)` / `Output.error(...)`. `System.err.println(...)`
is fine for progress logs (they're already disposable).
- No swallowing errors — let exceptions bubble; `main()` translates
them to `{"error":...}` + exit code.
them to `error: …` (text mode) / `{"error":}` (JSON mode) plus the
right exit code.
- No holding a connection open across invocations — every run opens
a fresh `Context` and closes it in `finally`.
- No blocking reads for user input — take a flag.
- No global flags that collide with subcommand flags. `--name` is
reserved for subcommand use (group/profile name); the global
account selector is `--account`.
## Output-shape rules
@@ -1,25 +1,33 @@
# Output conventions
Amy's JSON contract is its public API. Follow these rules.
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 | What goes here |
|---|---|
| **stdout** | Exactly one JSON object per successful invocation. Nothing else. |
| **stderr** | Human progress logs, warnings, per-relay ACK traces, stack traces, `printUsage()` output. Safe to discard. Not machine-consumed. |
| 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. If it needs to explain what it's doing to a human watching,
stderr.
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. Stdout has a JSON object. |
| `1` | Runtime error. Stderr has `{"error":"...","detail":"..."}`. |
| `2` | Bad arguments. Stderr has a JSON error object and/or usage. |
| `0` | Success. |
| `1` | Runtime error. |
| `2` | Bad arguments. |
| `124` | `await` timed out. |
Throw the right exception type in commands:
@@ -32,7 +40,7 @@ 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.
## Object shape
## `--json` object shape
### Top-level
@@ -58,8 +66,9 @@ newline-delimited stream.
| 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`. |
| 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
@@ -70,7 +79,8 @@ newline-delimited stream.
### Booleans
- Use `true`/`false`, not `0`/`1`, not `"yes"`.
- Use `true`/`false` in the result map. The text renderer prints them
as `yes`/`no` (green/red); `--json` keeps the literal booleans.
- Name keys so `true` is the expected/successful state:
`is_member`, `published`, `accepted`.
@@ -93,22 +103,40 @@ appear in neither — add `timed_out_on` if you need to surface them.
### Error shape
Default mode (text):
```text
error: not_member: <gid>
```
Under `--json`:
```json
{ "error": "code", "detail": "free-form explanation" }
{ "error": "not_member", "detail": "<gid>" }
```
- `error` is a short, stable, lower_snake code. Agents can branch on
it.
- `detail` is free text — OK to change between versions.
- Common codes today: `bad_args`, `no_identity`, `exists`, `bad_key`,
`not_member`, `timeout`, `runtime`. Reuse before inventing.
- 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 except `Json.writeLine(...)`.
- Multi-line JSON (pretty-printed). One line, always.
- Mixing stdout lines — one command invocation emits one stdout line.
If you need progress updates, they go on stderr.
- `println(...)` of anything except `Output.emit(...)`.
- `Json.writeLine` / `Json.error` — that helper is gone; use the
`Output` object instead.
- Multi-line JSON (pretty-printed) under `--json`. One line, always.
- Mixing stdout lines — one command invocation emits one stdout line
in `--json` mode. 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
JSON.
result map.
- Building text rendering by hand. Trust the `Output.kt` renderer:
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 around `Output`.