Files
amethyst/cli/README.md
T
Claude 99586fbe7e feat(cli): drop --data-dir; tests isolate via $HOME override
There is no installed base to protect, so the self-contained
`--data-dir P` escape hatch goes away entirely. amy now has exactly
one layout — the production multi-account one — and tests exercise
that same code path. Drops one global flag, one DataDir construction
mode, and the "tests use a different code path than users" footgun.

Layout (unchanged from the previous commits — just the only mode now):

    ~/.amy/
    ├── current                      # `amy use NAME` marker
    ├── shared/
    │   └── events-store/            # FsEventStore, one per machine
    ├── alice/
    │   ├── identity.json
    │   ├── state.json
    │   ├── aliases.json             # {"alice": "<own npub>"} after init
    │   └── marmot/
    └── bob/ ...

`DataDir.DEFAULT_ROOT` reads `$HOME` directly (falling back to
`user.home`) because JDK 21 resolves `user.home` via getpwuid and
ignores `$HOME` — which would have broken the standard CLI test
isolation pattern of `HOME=/tmp/foo amy …` (the same convention
git, gpg, npm follow).

Test sweep:

- `cli/tests/headless/helpers.sh`, `cli/tests/dm/setup.sh`,
  `cli/tests/cache/cache-headless.sh` wrappers all switch to
  `HOME=$STATE_DIR amy --name X …`.
- `ensure_identity_for` drops its `dir` parameter; the function and
  every harness call site go through `--name` only.
- `A_DIR`/`B_DIR`/`D_DIR` get repointed at `$STATE_DIR/.amy/X`. The
  one consumer (`cache-headless.sh`'s T6 `relays.json` check) still
  works since it's just a path probe.
- `cli/tests/dm/tests-dm.sh` ghost identities get their own
  short-lived `$HOME` so they don't pollute the main test root.

Cache-test T4 inverts: pre-shared-store, "B has not seen A → first
profile show is a relay miss, second is a cache hit" tested
per-account caching. With one shared events-store, B's first lookup
of A is already a cache hit because A wrote kind:0 there during
bootstrap. T4 now asserts that — drops the stale "second lookup hits
cache" half. T7 (no-identity maintenance verbs) gets a fresh fake
`$HOME` plus a throwaway `--name` so the empty store has no inherited
events.

Docs (README + DEVELOPMENT) rewritten to match — quick-start now uses
`amy --name alice create`, the on-disk-layout section shows the new
tree, and the global-flags table replaces `--data-dir` with `--name`
plus the new `amy use` verb.
2026-04-25 15:07:31 +00:00

17 KiB
Raw Blame History

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.

To extend Amy, see 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 --name alice --json marmot group list.
  • stderr is for humans. Progress, warnings, per-relay ACK traces. Safe to discard. Errors land here too: error: <code>: <detail> by default, or JSON {"error":"…","detail":"…"} under --json.
  • Exit codes are the real signal.
    • 0 — success
    • 1 — runtime error
    • 2 — bad arguments
    • 124await 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/<account>/; 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 --name 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.


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:

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:

# raw inspection
find ~/.amy/shared/events-store/events -name '*.json' | head
jq . ~/.amy/shared/events-store/replaceable/0/<pubkey>.json

# delete a specific event (tombstone NOT installed — see below)
rm ~/.amy/shared/events-store/events/<aa>/<bb>/<id>.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), 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 the alice account (~/.amy/alice/) with a full Amethyst-style
#    bootstrap: keypair, default NIP-65 / inbox / key-package relays,
#    nine bootstrap events.
amy --name alice create

# 2. Publish a fresh MLS KeyPackage so others can invite you.
amy --name alice marmot key-package publish

# 3. Create a group, invite someone, send a message.
amy --name alice marmot group create --name "Test Group"
amy --name alice marmot group add <GID> npub1...bob
amy --name alice marmot message send <GID> "hello"

# 4. On the receiving side, in ~/.amy/bob/.
amy --name bob marmot await group --name "Test Group" --timeout 60
amy --name bob marmot message list <GID>

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:

GID=$(amy --name alice --json marmot group create --name "Test" | jq -r .group_id)

For an interop-test script template, see DEVELOPMENT.md § Testing. The runnable harnesses live under cli/tests/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

  • --name ACCOUNT — pick which account under ~/.amy/<account>/ 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/<aa>/<bb>/…           # canonical kind:0 / 3 / 10002 / 10050 / 10051 / 1 / 5 / 1059 / …
│       ├── replaceable/<k>/…            # 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 --name 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/
│           ├── <gid>.mls                # MLS group state per group
│           └── <gid>.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 --name.
  • no account at ~/.amy — there's no account dir yet; create one with amy --name <name> init.
  • multiple accounts in ~/.amy (...) — disambiguate with --name 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.