The first pass only fixed `group add`. Auditing the rest of the CLI against MIP-00..03 turned up three more spots where `amy` either queried the wrong relays or silently skipped a required advertisement: 1. `marmot key-package check <npub>` used `anyRelays()` — i.e. the inviter's configured relays — so checking for a KeyPackage on a user who advertises `kind:10051` somewhere we don't know about always returned `not_found`. Now runs RecipientRelayFetcher against bootstrap seeds and fetches from the union of (target kind:10051, target kind:10002 write, bootstrap). Emits `found_on` so callers can see which relay served the hit. 2. `await key-package <npub>` had the same bug inside the poll loop. Resolved once up front, then the loop fetches from the target's advertised relays every tick. Throws an `AwaitTimeout` early if no relays can be discovered at all, instead of silently polling void. 3. `relay publish-lists` published kind:10002 + kind:10050 but never kind:10051. Per MIP-00 the KeyPackage Relay List is how other Marmot clients discover where our KPs live — without it the `key_package` bucket on disk is invisible to anyone else. Now also publishes kind:10051; falls back to the NIP-65 set if the bucket is empty so we never advertise an empty list. (`amy create` already publishes it via AccountBootstrapEvents.) Docs: cli/README adds a "Relay routing" section that lists the exact relay set used for publish vs fetch of every Marmot event kind, plus the bootstrap-pool definition, so agents + interop-test authors can reason about cross-user reachability without reading the code.
9.1 KiB
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:
- Humans using Amethyst from a terminal or remote shell.
- Agents / LLMs driving a Nostr account through a deterministic, JSON-typed interface — no interactive prompts, no screen scraping.
- 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.
To extend Amy, see DEVELOPMENT.md.
Output contract
What every caller — user, script, agent, CI — can rely on:
- stdout is JSON. One line. One object. Stable snake_case keys.
Pipe it into
jq, parse it from Python, hand it to an agent. - stderr is for humans. Progress, warnings, per-relay ACK traces. Safe to discard.
- Exit codes are the real signal.
0— success1— runtime error (JSON{"error":"…","detail":"…"}on stderr)2— bad arguments124—awaittimed out
- No interactive prompts, ever. Passwords, names, keys — all flags.
- Data-dir is the whole world. All state (identity, relays, MLS
epochs, message archives, run cursors) lives under
--data-dir PATH. Delete to reset; copy to move;AMETHYST_CLI_DATAenv var overrides the default./amethyst-cli-data.
The rationale behind each of these lives in DEVELOPMENT.md. Breaking any of them is a breaking change to Amy's public API.
Install
Until Amy ships as a signed native binary (see cli/plans/2026-04-21-cli-distribution.md), run it from source:
# 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
# 1. Create a data-dir with a full Amethyst-style account.
# Generates a keypair, seeds default NIP-65 / inbox / key-package
# relays, and publishes the nine bootstrap events.
amy --data-dir ./alice create --name "Alice"
# 2. Publish a fresh MLS KeyPackage so others can invite you.
amy --data-dir ./alice marmot key-package publish
# 3. Create a group, invite someone, send a message.
amy --data-dir ./alice marmot group create --name "Test Group"
amy --data-dir ./alice marmot group add <GID> npub1...bob
amy --data-dir ./alice marmot message send <GID> "hello"
# 4. On the receiving side — poll until Bob sees the invite.
amy --data-dir ./bob marmot await group --name "Test Group" --timeout 60
amy --data-dir ./bob marmot message list <GID>
Compose with jq to chain commands:
GID=$(amy --data-dir ./alice marmot group create --name "Test" | jq -r .group_id)
For an interop-test script template, see DEVELOPMENT.md § Testing.
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 identity stored in --data-dir. |
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. |
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
--data-dir PATH— defaults to./amethyst-cli-dataor$AMETHYST_CLI_DATA. Always an absolute path after resolution.--help/-h— usage summary.
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.
Data-dir layout
<data-dir>/
├── identity.json # nsec/npub/hex — the account
├── relays.json # nip65 / inbox / key_package buckets
├── state.json # sync cursors (giftWrapSince, groupSince)
├── keypackages.bundle # MLS KeyPackage bundles (NostrSignerInternal)
└── groups/
├── <gid>.mls # MLS group state per group
└── <gid>.log # decrypted inner events (one JSON per line)
All files are plain JSON or framed binary — human-inspectable, easy to diff across two data-dirs in a test run.
Troubleshooting
no identity— runinit,create, orloginfirst, or pass a different--data-dir.not_member— the group GID is unknown to this data-dir. Runmarmot group listto confirm, ormarmot await group --name …to wait for an invite to arrive.- Hang on a network verb — Amy connects to the relays in
relays.json; verify withamy relay list. Every network-bound operation has a timeout — use--timeoutforawait, or wrap the whole command intimeout(1)if you're scripting. - Nothing seems to publish — inspect stderr; each publish prints
per-relay
OK/REJECTvia the[cli] …traces.