| 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 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/ vs commons/
vs quartz/ (answer: almost never cli/).
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 from amethyst/ 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.
--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.
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 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
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 the object pattern. Sub-verb
dispatch functions use the shared route(...) helper in
Router.kt rather than a hand-rolled when (tail[0]).
- Add a branch in
Main.kt's dispatch (top-level verbs call the
command object directly, e.g. "relay" -> RelayCommands.dispatch(โฆ);
marmot sub-verbs go through marmotDispatch's route map).
- Extend
printUsage() in Main.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`
โโโ Router.kt # `route(...)` shared sub-verb dispatcher
โโโ 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
โโโ AdminCommand.kt # `amy admin RELAY METHOD` (NIP-86)
โโโ ServeCommand.kt # `amy serve` (embeds :geode)
โโโ cashu/ # `amy cashu โฆ` (NIP-60/61) โ thin wrappers
โโโ CashuCommands.kt # over commons CashuWalletOps / CashuWalletReader
โโโ CashuWalletCommands.kt + CashuBalanceCommand.kt + CashuMintCommands.kt
โโโ CashuReceiveCommands.kt + CashuSendCommands.kt
+ CashuMaintenanceCommands.kt + CashuMintRecCommands.kt
Shared logic consumed by Amy lives in commons/:
commons/account/ โ account bootstrap
commons/marmot/ โ MLS / group state
commons/cashu/ โ ops/CashuWalletOps (jvmAndroid) + CashuWalletReader
CashuKeysetCounterStore; the NIP-60/61 wallet, shared with Android.
commons/relayManagement/Nip86Retriever โ NIP-86 HTTP client, shared with
the Android relay-management screen.
commons/defaults/ โ default relays, kinds
- Consult
commons/plans/ for cross-cutting design work in flight.
A few amy verbs lean on modules beyond quartz/commons: amy serve
depends on :geode (the standalone relay) โ the one allowed extra module
dependency. :amethyst / :desktopApp remain forbidden (Rule 5).
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 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.
- Depending on
:amethyst or :desktopApp. Never. If you need
something from there, Rule 5.
- Re-inventing identifier parsing. Use
Context.requireUserHex
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
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 tour
cli/DEVELOPMENT.md โ public
contract, architecture, on-disk layout
cli/ROADMAP.md โ parity matrix
references/command-template.md
references/extraction-recipe.md
references/output-conventions.md