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."
11 KiB
name, description
| name | description |
|---|---|
| amy-expert | 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
Practical patterns for touching the cli/ module without breaking
its public contract.
When to use this skill
- Adding a new
amy <verb>subcommand. - Editing anything under
cli/src/main/kotlin/…/cli/. - Writing a shell script or test harness that drives Amy.
- Extracting code out of
amethyst/so the CLI can call it (this is the single most common reason an Amy feature request stalls). - Deciding whether a piece of logic belongs in
cli/vscommons/vsquartz/(answer: almost nevercli/).
Not for: general Nostr protocol work (nostr-expert), general
Kotlin (kotlin-expert), Compose UI (compose-expert), Android-only
flows (android-expert), gradle/build (gradle-expert).
The rules that matter
Amy has a small number of hard rules. Any change that breaks them is a breaking change to the CLI's public API, and breaks the interop- test harnesses that depend on it.
Rule 1 — cli/ is a thin assembly layer
No new Nostr protocol, filter assembly, state machines, or encryption
lives in cli/. Ever. If you need logic that doesn't exist yet:
- Protocol piece (event kind, tags, signing)? Add it to
quartz/. - Business logic (state, defaults, ordering, filter assembly)?
Add it to
commons/— extract fromamethyst/first if needed (see Rule 5).
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 — text by default, --json is the machine contract
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.
--jsonswitches 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:
0success ·1runtime ·2bad args ·124await timeout. - Adding a
--jsonkey 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.
Rule 3 — Non-interactive, ever
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 — ~/.amy/ is the whole world
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.
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 byamy use NAMEto 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
If the command you're about to add needs logic from amethyst/,
land the extraction first, in its own commit:
- Identify the class in
amethyst/src/main/java/…/. - List its Android-only dependencies (
Context,SharedPreferences,WorkManager,Log,Bitmap,Uri, …). - For each, choose: inline, platform-abstract via expect/actual, or take-as-constructor-arg.
- Move the file to
commons/commonMain/…. - Update the Android caller to use the new location. Add a JVM test.
- Then add the
cli/commands/…file.
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 result
via Output.emit. The template is in references/command-template.md;
copy it rather than re-deriving it.
Wire-up checklist:
- New file in
cli/commands/with theobjectpattern. - Add a branch in
Commands.kt. - Add a branch in
Main.kt'sdispatch(or undermarmotDispatch/ a new group dispatcher). - Extend
printUsage()inMain.kt. - Add the row to
cli/README.md's command table. - Update
cli/ROADMAP.md— move the row from 🆕 / 📦 to ✅. - 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,cli/tests/cache/for event-store behaviour, or a new sibling suite if it's none.
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 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)
│ └── cache/ # FsEventStore behaviour vs the cache helpers
└── src/main/kotlin/…/cli/
├── Main.kt # argv dispatch, global flags
├── Args.kt # flag parser
├── 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
├── 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/:
commons/account/— account bootstrapcommons/marmot/— MLS / group statecommons/defaults/— default relays, kinds- Consult
commons/plans/for cross-cutting design work in flight.
Common mistakes to refuse
- Adding protocol logic to
cli/. Push back, offer to extract. - Silently changing a
--jsonkey. Flag as breaking. - Using
printlnorprintfor command output. UseOutput.emit(...)/Output.error(...). PlainSystem.err.printlnis fine for progress logs but never for user-consumable output. runBlockinginside a command — the top-levelmainalready does that. Commands aresuspend fun.- Depending on
:amethystor:desktopApp. Never. If you need something from there, Rule 5. - Re-inventing identifier parsing. Use
Context.requireUserHexorresolveUserHexOrNullinquartz/nip05DnsIdentifiers/. - Re-inventing publish-and-confirm. Use
Context.publish. - Re-inventing one-shot subscription. Use
Context.drain. - Reading
user.homedirectly. UseDataDir.DEFAULT_ROOT, which reads$HOME(the conventiongit/gpg/npmfollow); JDK 21'suser.homeis derived fromgetpwuidand ignores$HOME, which silently breaks the test-isolation pattern. - Adding a global flag that collides with subcommand flags.
--nameis reserved for subcommand use (group/profile names). Account selection is--account.
Plans & design docs
Cross-cutting design work goes in dated plan docs, in the module
that owns the code being created — not in docs/plans/, which is
frozen.
cli/plans/— Amy-specific subsystems.commons/plans/— shared code Amy consumes (e.g.2026-04-21-event-renderer.md).
Cross-references
cli/README.md— user-facing tourcli/DEVELOPMENT.md— public contract, architecture, on-disk layoutcli/ROADMAP.md— parity matrixreferences/command-template.mdreferences/extraction-recipe.mdreferences/output-conventions.md