# Amy — Amethyst CLI `amy` is the non-interactive command-line face of Amethyst. It speaks the same Nostr protocol as the Android and Desktop apps, shares the same `quartz` and `commons` code, and aims to eventually expose every feature the GUI offers as a command you can script. Amy exists for three audiences at once: 1. **Humans** using Amethyst from a terminal or remote shell. 2. **Agents / LLMs** driving a Nostr account through a deterministic, JSON-typed interface — no interactive prompts, no screen scraping. 3. **Interop test harnesses** that put Amethyst side-by-side with the other ~100 Nostr clients publishing and consuming the same events. Any flow that is tested in the Amethyst app should be reproducible through `amy` — that's the bar. > Today Amy covers identity, relay config, account bootstrap, and > Marmot / MLS group chat (MIP-00 / NIP-445). Everything else from the > Android app is on the roadmap — see [ROADMAP.md](./ROADMAP.md). > > To extend Amy, see [DEVELOPMENT.md](./DEVELOPMENT.md). --- ## Output contract What every caller — user, script, agent, CI — can rely on: - **Default stdout is human-readable text.** A YAML-ish render of the underlying result map. Friendly at a terminal; no shape promises. - **`--json` is the machine contract. One line. One object.** Stable snake_case keys. Pipe it into `jq`, parse it from Python, hand it to an agent. Pass `--json` anywhere before the subcommand: `amy --json whoami`, `amy --account alice --json marmot group list`. - **stderr is for humans.** Progress, warnings, per-relay ACK traces. Safe to discard. Errors land here too: `error: : ` by default, or JSON `{"error":"…","detail":"…"}` under `--json`. - **Exit codes are the real signal.** - `0` — success - `1` — runtime error - `2` — bad arguments - `124` — `await` timed out - **No interactive prompts, ever.** Passwords, names, keys — all flags. - **`~/.amy/` is the whole world.** Per-account dirs hold identity, cursors, MLS state, and aliases at `~/.amy//`; every observed Nostr event lands in `~/.amy/shared/events-store/` (one cache for every account on the machine). Delete to reset; copy to move. Tests isolate by overriding `$HOME` for the amy subprocess (`HOME=/tmp/run.123 amy --account alice …`) — same convention `git`, `gpg`, and `npm` use. Only the `--json` shape and the exit codes are public API. The default text format is allowed to change between releases. The rationale lives in [DEVELOPMENT.md](./DEVELOPMENT.md). --- ## Local event store — the source of truth Every Nostr event Amy observes is verified (NIP-01 id + signature check) and persisted to a file-backed store at `~/.amy/shared/events-store/` (one store per machine, shared across every account in `~/.amy/`). That includes: - events received from any relay subscription (`amy feed`, `amy dm list`, `amy keypackage publish`, group sync, …), - events Amy generates and publishes itself, - inner events unwrapped from NIP-59 gift wraps. Malformed events are dropped before reaching command code. Persistence is best-effort — if the store fails (full disk, permissions), the relay subscription still works, but the event is not cached. The store is the authoritative cache of everything Amy has seen: profile metadata, relay lists (NIP-65 and NIP-02), gift wraps, group events, follow lists, etc. Commands that need any of these should read from the store first and only fall back to a relay fetch on miss. Three convenience helpers exist on `Context`: ```kotlin ctx.profileOf(pubKey) // latest kind:0 (NIP-01) ctx.relaysOf(pubKey) // latest kind:10002 (NIP-65) ctx.contactsOf(pubKey) // latest kind:3 (NIP-02) ctx.dmInboxOf(pubKey) // latest kind:10050 (NIP-17 DM inbox) ctx.keyPackageRelaysOf(pubKey) // latest kind:10051 (MIP-00 KP relays) ctx.cachedRelayListsOf(pubKey) // RecipientRelayFetcher.Lists from cache ``` Commands that already read these cache-first: - `amy profile show` — `--refresh` to bypass. - `amy feed --following` — local kind:3 served via slot lookup; falls back to a relay drain on first run. - `amy dm send` — recipient's kind:10050 / 10051 / 10002 served from cache before falling back to `RecipientRelayFetcher`. - `amy marmot key-package check` and `amy marmot await key-package` — same recipient-relay lookup, cache-first. - `amy marmot group add` — invitee relay lists served from cache. The store implements every feature of the Quartz SQLite store — NIP-01 replaceable / addressable uniqueness, NIP-09 deletion tombstones, NIP-40 expiration, NIP-50 search, NIP-62 right-to-vanish, NIP-91 multi-tag AND. See `cli/plans/2026-04-24-file-event-store-*.md` for the design and `quartz/.../store/fs/FsEventStore.kt` for the implementation. The on-disk layout is plain JSON files under shard directories, intentionally inspectable with `ls`, `cat`, `jq`, `grep`, `find`, `rsync`, and `git`. To manage the store directly: ```sh # raw inspection find ~/.amy/shared/events-store/events -name '*.json' | head jq . ~/.amy/shared/events-store/replaceable/0/.json # delete a specific event (tombstone NOT installed — see below) rm ~/.amy/shared/events-store/events///.json ``` Deleting an event file is treated as a deliberate "I never saw this" by Amy. The store tolerates external edits: dangling index entries are skipped at query time and can be cleaned up with `compact()` / `scrub()` from the API. --- ## Install Until Amy ships as a signed native binary (see [cli/plans/2026-04-21-cli-distribution.md](./plans/2026-04-21-cli-distribution.md)), run it from source: ```bash # One-shot run — positional args go after `--args`, quoted as one string ./gradlew :cli:run --quiet --args="whoami" # Or build a runnable distribution and use the generated launch script ./gradlew :cli:installDist ./cli/build/install/amy/bin/amy whoami ``` The `installDist` tree under `cli/build/install/amy/` is self-contained (JVM launcher + jars) and is what downstream packaging will wrap. **Requirements:** JDK 21. --- ## Quick start ```bash # 1. Create the alice account (~/.amy/alice/) with a full Amethyst-style # bootstrap: keypair, default NIP-65 / inbox / key-package relays, # nine bootstrap events. amy --account alice create # 2. Publish a fresh MLS KeyPackage so others can invite you. amy --account alice marmot key-package publish # 3. Create a group, invite someone, send a message. amy --account alice marmot group create --name "Test Group" amy --account alice marmot group add npub1...bob amy --account alice marmot message send "hello" # 4. On the receiving side, in ~/.amy/bob/. amy --account bob marmot await group --name "Test Group" --timeout 60 amy --account bob marmot message list ``` With one account you can drop the flag (auto-pick); with several, pin one with `amy use alice` and the same commands work flagless. Compose with `jq` by adding `--json` to switch stdout from human text to a parseable single-line JSON object: ```bash GID=$(amy --account alice --json marmot group create --name "Test" | jq -r .group_id) ``` For an interop-test script template, see [DEVELOPMENT.md § Testing](./DEVELOPMENT.md#testing). The runnable harnesses live under [`cli/tests/`](./tests/README.md) — `cli/tests/marmot/` for MLS group messaging vs whitenoise-rs, `cli/tests/dm/` for NIP-17 DMs between two `amy` clients. --- ## Command reference Run `amy --help` for the canonical list. As of today: | Verb | Summary | |---|---| | `init [--nsec NSEC]` | Create or import a bare identity. Does not publish anything. | | `create [--name NAME]` | Provision a full account + publish the nine Amethyst bootstrap events. | | `login KEY [--password X] [--private]` | Import `nsec` / `ncryptsec` / BIP-39 mnemonic / `npub` / `nprofile` / hex / NIP-05. Read-only when no secret material is supplied. | | `whoami` | Print the active account's name + npub. | | `use NAME` | Pin NAME as the active account (`~/.amy/current`). `use --clear` removes the pin; `use` prints the pin and the available accounts. | | `profile show [PUBKEY] [--refresh] [--timeout SECS]` | Print kind:0 metadata. Default reads from the local store (cache-first); `--refresh` forces a relay drain. PUBKEY accepts `npub` / `nprofile` / hex / `name@domain.tld`; omit for self. | | `profile edit --name X [--display-name X] …` | Build + publish a new kind:0 starting from the current cached metadata (or fetched if missing). | | `relay add URL [--type T]` | `T = nip65 \| inbox \| key_package \| all`. | | `relay list` | Dump configured relays by bucket. | | `relay publish-lists` | Publish kind:10002 (NIP-65) + kind:10050 (DM inbox) + kind:10051 (KeyPackage relay list). | | `marmot key-package publish` | Publish a fresh MLS KeyPackage (kind:30443) to the configured `key_package` bucket (fallback: NIP-65 outbox). | | `marmot key-package check NPUB` | Look up NPUB's kind:10051 / kind:10002 on bootstrap relays, then fetch their KeyPackage from those relays. | | `marmot group create [--name NAME]` | New empty group with you as sole admin. | | `marmot group list` | All groups you're a member of. | | `marmot group show GID` | Full group state (members, admins, epoch, metadata). | | `marmot group members GID` | Members only. | | `marmot group admins GID` | Admins only. | | `marmot group add GID NPUB [NPUB…]` | Fetch KeyPackages for the npubs and commit an add. | | `marmot group rename GID NAME` | Commit a metadata change. | | `marmot group promote GID NPUB` | Make an existing member an admin. | | `marmot group demote GID NPUB` | Revoke admin. | | `marmot group remove GID NPUB` | Remove a member. | | `marmot group leave GID` | Self-remove. | | `marmot message send GID TEXT` | Publish a kind:9 inner event into the group. | | `marmot message list GID [--limit N]` | Decrypted inner events, oldest first. | | `marmot await key-package NPUB` | Block until a KeyPackage is seen on NPUB's advertised relays (kind:10051 / kind:10002). | | `marmot await group --name NAME` | Block until we're added to a group with that name. | | `marmot await member GID NPUB` | Block until NPUB is in GID's member set. | | `marmot await admin GID NPUB` | Block until NPUB is an admin of GID. | | `marmot await message GID --match TEXT` | Block until a message containing `TEXT` lands. | | `marmot await rename GID --name NAME` | Block until GID's name matches. | | `marmot await epoch GID --min N` | Block until GID's MLS epoch is ≥ N. | | `dm send RECIPIENT TEXT [--allow-fallback]` | Send a NIP-17 gift-wrapped text DM (kind:14 inside kind:1059). Default delivers only to the recipient's kind:10050 (per NIP-17); pass `--allow-fallback` to fall back to kind:10002 read marker → bootstrap pool. | | `dm send-file RECIPIENT --file PATH --server URL [--mime-type M] [--allow-fallback]` | Encrypt the local file with a fresh AES-GCM cipher, upload the ciphertext to the Blossom server, then publish a kind:15 NIP-17 file message referencing the returned URL. The auto-detected hash, size, dimensions, and blurhash from the upload are folded into the event. The response also surfaces the encryption key + nonce so the same blob can be re-shared without re-uploading. | | `dm send-file RECIPIENT URL --key HEX --nonce HEX [--mime-type M] [--hash H] [--original-hash H] [--size N] [--dim WxH] [--blurhash S] [--allow-fallback]` | Reference-mode variant: the file is already uploaded; `--key`/`--nonce` carry the AES-GCM material that recipients use to decrypt the bytes at `URL`. Useful when the upload happened elsewhere or to re-publish a previously-uploaded blob. | | `dm list [--peer NPUB] [--since TS] [--limit N] [--timeout SECS]` | Drain and decrypt gift wraps on our inbox relays. Returns kind:14 (text) and kind:15 (file) messages with a `type` discriminator. With neither `--peer` nor `--since` the gift-wrap cursor in `state.json` is advanced to the newest message seen. | | `dm await --peer NPUB --match TEXT [--timeout SECS]` | Block until a DM from NPUB containing TEXT arrives (matches text content for kind:14, URL for kind:15). Timeout exits 124. | All `await` verbs accept `--timeout SECS` (default 30). Timeout exits 124 so scripts can distinguish "condition never happened" from "the command itself crashed". ### Global flags - `--account ACCOUNT` — pick which account under `~/.amy//` to operate on. Required only when more than one account exists; with a single account amy auto-picks. Override with `amy use NAME` to pin one as the default. ACCOUNT must match `[a-zA-Z0-9_-]{1,64}`. - `--json` — switch stdout to the machine-readable contract. - `--secret-backend auto|keychain|ncryptsec|plaintext` — where to persist the private key. - `--passphrase-file PATH` — passphrase for the `ncryptsec` backend. - `--help` / `-h` — usage summary. ### Account-management verbs - `amy use NAME` — pin NAME as the active account (`~/.amy/current`). - `amy use --clear` — remove the pin. - `amy use` — print the current pin and the list of available accounts. --- ## Relay routing Amy follows the Marmot protocol's per-event routing rules so two users with completely disjoint relay configurations can still marmot each other. No event ever ships blindly to "our configured relays" — Amy looks up the right relay set per event per recipient. | Event | Publish to | Fetch from | |---|---|---| | kind:30443 (our own KeyPackage) | `key_package` bucket → NIP-65 outbox → any configured | — | | kind:30443 (someone else's KeyPackage) | — | Their kind:10051 → their kind:10002 write → our bootstrap pool | | kind:10051 / 10050 / 10002 (our own lists) | All configured relays (broadcast) | — | | kind:10051 / 10050 / 10002 (someone else's) | — | Our bootstrap pool = configured relays ∪ Amethyst defaults | | kind:1059 Welcome gift wrap (kind:444 inside) | Recipient's kind:10050 → their kind:10002 read → `DefaultDMRelayList` → our outbox | — | | kind:1059 gift wraps addressed to us | — | Our kind:10050 | | kind:445 Group Event (Commit / Proposal / chat) | Group's MIP-01 `relays` field | Same | **Bootstrap pool**: when Amy needs to discover a user it's never talked to, it queries `configured relays ∪ Amethyst's default NIP-65 set ∪ Amethyst's default DM-inbox set`. These defaults come from `commons.defaults.AmethystDefaults` and match what the Android/Desktop UI publishes to on first run, so any fresh Amethyst account is reachable via the bootstrap pool even before Amy has seen any of their events. --- ## On-disk layout ``` ~/.amy/ ← root, follows $HOME ├── current # marker file written by `amy use NAME` ├── shared/ │ └── events-store/ # FsEventStore — every observed Nostr event │ ├── events///… # canonical kind:0 / 3 / 10002 / 10050 / 10051 / 1 / 5 / 1059 / … │ ├── replaceable//… # one slot per (kind, pubkey) for kind:0/3/10000-19999 │ ├── addressable/… # one slot per (kind, pubkey, d-tag) for kind:30000-39999 │ ├── idx/ # hardlink indexes (kind / author / owner / tag / fts / expires_at) │ └── tombstones/ # NIP-09 / NIP-62 enforcement ├── alice/ # one dir per account (e.g. created by `amy --account alice init`) │ ├── identity.json # nsec/npub/hex — the account │ ├── state.json # sync cursors (giftWrapSince, groupSince) │ ├── aliases.json # local name → npub map (init writes a self-entry) │ └── marmot/ │ ├── keypackages.bundle # MLS KeyPackage bundles (NostrSignerInternal) │ └── groups/ │ ├── .mls # MLS group state per group │ └── .log # decrypted inner events (one JSON per line) └── bob/ ... # additional accounts sit alongside ``` All files are plain JSON or framed binary — human-inspectable, easy to diff across two accounts. Two accounts on the same machine share `~/.amy/shared/events-store/`, so a public event observed once doesn't get re-stored per account. The local relay configuration (kind:10002 / 10050 / 10051) is **not** a separate file — it lives in the shared `events-store/` as signed events owned by the account that wrote them. `amy relay add` builds + signs + ingests a new relay-list event; `amy relay list` reads URLs straight out of the latest event for each kind; `amy relay publish-lists` broadcasts those events to upstream relays. There is no `relays.json`. --- ## Troubleshooting - **`no identity`** — run `init`, `create`, or `login` first, or pass a different `--account`. - **`no account at ~/.amy`** — there's no account dir yet; create one with `amy --account init`. - **`multiple accounts in ~/.amy (...)`** — disambiguate with `--account` on the next call, or pin a default via `amy use NAME`. - **`not_member`** — the group GID is unknown to this account. Run `marmot group list` to confirm, or `marmot await group --name …` to wait for an invite to arrive. - **Hang on a network verb** — Amy connects to the relays advertised in your local kind:10002 / 10050 / 10051 events; inspect with `amy relay list`. Every network-bound operation has a timeout — use `--timeout` for `await`, or wrap the whole command in `timeout(1)` if you're scripting. - **Nothing seems to publish** — inspect stderr; each publish prints per-relay `OK` / `REJECT` via the `[cli] …` traces.