# 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.

- Skill: `vitorpamplona/amy-expert` (Agent Skill, multi-file: 4 files)
- Install (CLI): `npx skillmds add vitorpamplona/amy-expert`
- Raw SKILL.md: https://api.skillmd.com/api/skills/vitorpamplona/amy-expert/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: vitorpamplona (https://skillmd.com/u/vitorpamplona)
- Updated: 2026-09-09
- Page: https://skillmd.com/skills/vitorpamplona/amy-expert

---


# 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).
4. Extend `printUsage()` in `Main.kt`.
5. Add the row to `cli/README.md`'s command table.
6. Update `cli/ROADMAP.md` — move the row from 🆕 / 📦 to ✅.
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, `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`](../../../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`

