amy-expert

star 1.5k

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.

vitorpamplona By vitorpamplona schedule Updated 4/25/2026

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:

  1. Identify the class in amethyst/src/main/java/…/.
  2. List its Android-only dependencies (Context, SharedPreferences, WorkManager, Log, Bitmap, Uri, …).
  3. For each, choose: inline, platform-abstract via expect/actual, or take-as-constructor-arg.
  4. Move the file to commons/commonMain/….
  5. Update the Android caller to use the new location. Add a JVM test.
  6. 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:

  1. 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]).
  2. 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).
  3. Extend printUsage() in Main.kt.
  4. Add the row to cli/README.md's command table.
  5. Update cli/ROADMAP.md — move the row from 🆕 / 📦 to ✅.
  6. 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
Install via CLI
npx skills add https://github.com/vitorpamplona/amethyst --skill amy-expert
Repository Details
star Stars 1,547
call_split Forks 208
navigation Branch main
article Path SKILL.md
More from Creator
vitorpamplona
vitorpamplona Explore all skills →