docs(cli): reconcile README/ROADMAP/DEVELOPMENT/tests with reality

The docs had drifted badly behind the code: geochat was documented
nowhere, concord/zap/search/podcast20/nsite-publish were README-
invisible, the ROADMAP matrix contradicted its own nak table on six
shipped features, and DEVELOPMENT described the legacy FS event store
as the default when SQLite is.

- README: sections for search, zap (incl. --with auto-pay), podcast20,
  nsite/napplet (all four sub-verbs), concord (13 verbs), geochat, and
  a 'Which chat system?' comparison table; output section rewritten for
  the new contract (exit-code derivation, rejected, unknown-flag
  errors, -- terminator, per-command --help); layout diagram fixed for
  the SQLite default + operator/ + concord.json; the bunker nak-interop
  claim reworded honestly; RECIPES.md linked.
- ROADMAP: stale new-item rows flipped (follow, outbox, Blossom,
  bunker, search; zap partial), rows added for relaygroup/geochat/
  concord/nsite/napplet/podcast20/CLINK/fof, orphaned thread note
  fixed, test-suite section updated.
- DEVELOPMENT: canonical error-code list pinned, exit-code rule
  documented, no-prompts carve-outs, refreshed architecture tree +
  command template (USAGE/route(help=)/rejectUnknown/publishGuard),
  SQLite store section, testing table covers the new JVM suites.
- tests/README: all ten suite dirs listed, JVM contract suite noted,
  mis-spliced marmot row repaired.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
This commit is contained in:
Claude
2026-07-18 22:59:37 +00:00
parent 6e664b2db6
commit 82f041b1f5
4 changed files with 426 additions and 134 deletions
+174 -71
View File
@@ -26,18 +26,30 @@ What every caller — user, script, agent, CI — can rely on:
- **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.**
- **Exit codes are the real signal.** The exit code is *derived from the
error-code string* — `Output.error(code, …)` returns the exit code, so
`return Output.error(…)` always honours the contract with no per-site
bookkeeping:
- `0` — success
- `1` — runtime error
- `2` — bad arguments
- `124` — `await` timed out
- **No interactive prompts, ever.** Passwords, names, keys — all flags.
- `2` — the code is `bad_args` (every bad argument, including unknown
flags and malformed numeric/relay/`--author`/`--id` values)
- `124` — the code is `timeout` (every timeout: `await` verbs,
`pow mine`, offer/debit round-trips)
- `1` — every other code (runtime errors, `rejected`, `not_member`, …)
- **No interactive prompts** — passwords, names, keys are all flags — with
exactly two deliberate, opt-in exceptions: the ncryptsec secret-backend
passphrase falls back to a TTY prompt when neither `--passphrase-file`
nor `$AMY_PASSPHRASE` is set, and `bunker --interactive` prompts y/N per
signing request (it errors with `no_tty` without a terminal). Neither
can trigger in a correctly-configured script.
- **`~/.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/`. 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.
Nostr event lands in the shared store under `~/.amy/shared/` (a SQLite
`events.db` by default; the `events-store/` tree when `AMY_STORE=fs`).
Delete to reset; copy to move. Shell tests isolate by overriding `$HOME`
for the amy subprocess (`HOME=/tmp/run.123 amy --account alice …`) —
same convention `git`, `gpg`, and `npm` use; the in-process JVM tests
use the `amy.home` system-property seam instead.
- **An account is only required to _sign_.** Read-only verbs (relay
queries, the shared `store`, `offer`/`debit info`, and the stateless
primitives) run against an empty `~/.amy/` — `DataDir.resolveOptional`
@@ -83,39 +95,38 @@ principles below are how we keep that promise.
```
cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/
├── Main.kt # argv → subcommand dispatch
├── Args.kt # tiny flag parser (no framework)
├── Output.kt # text/json mode emitter (--json flag)
├── Main.kt # argv → subcommand dispatch (runCli is the
│ # testable seam; main() adds exitProcess)
├── Args.kt # tiny flag parser: --key value / --key=value,
│ # `--` ends flag parsing, rejectUnknown()
├── Output.kt # text/json emitter; error(code) → exit code
├── Aliases.kt # per-account aliases.json read/write
├── Config.kt # Identity, RunState, DataDir (~/.amy layout)
├── Config.kt # Identity, RunState, DataDir (~/.amy layout,
│ # `amy.home` system-property override)
├── Context.kt # per-run wiring: signer + NostrClient +
│ # MarmotManager + publish/drain/sync helpers
│ # MarmotManager + publish/drain helpers;
│ # syncIncoming delegates to the shared
│ # commons/marmot/MarmotSyncPolicy
├── StoreFactory.kt # AMY_STORE backend pick: sqlite | fs
├── StoreStats.kt # shared store-size reporting (status, store stat)
├── OperatorKeys.kt # ~/.amy/operator/ GrapeRank operator keys
├── RelayDiagnostics.kt # per-relay OK/REJECT stderr traces
├── SecureFileIO.kt # 0600/0700 atomic writes, perm tighten
├── stores/FileStores.kt # File-backed MLS / KP / message stores
├── stores/ # File-backed MLS/KP/message stores, ConcordStore,
│ # cashu keyset counters
├── secrets/ # SecretStore backends (keychain / ncryptsec / plaintext)
└── commands/
├── Router.kt # `route(...)` shared sub-verb dispatcher
├── UseCommand.kt # `amy use NAME` — pin active account
├── InitCommands.kt # init, whoami
├── CreateCommand.kt # full bootstrap (→ commons/account/)
├── LoginCommand.kt # nsec/ncryptsec/mnemonic/npub/nprofile/hex/nip05
├── RelayCommands.kt # add/list/publish-lists
├── ProfileCommands.kt # profile show / edit (kind:0)
├── NotesCommands.kt + PostCommand.kt + FeedCommand.kt # kind:1
├── DmCommands.kt # NIP-17 dm send / send-file / list / await
├── KeyPackageCommands.kt # marmot key-package publish / check
├── GroupCommands.kt + GroupCreateCommand.kt
│ GroupReadCommands.kt + GroupAddMemberCommand.kt
│ GroupMembershipCommands.kt + GroupMetadataCommands.kt
├── MessageCommands.kt # marmot message send / list / react / delete
├── MarmotResetCommand.kt # destructive wipe of MLS state
├── AwaitCommands.kt # poll-until-condition helpers
└── StoreCommands.kt # store stat / sweep-expired / scrub / compact
└── commands/ # ~70 command files + commands/cashu/ (8) — one
├── Router.kt # file per verb family, named <Verb>Command(s).kt.
└── … # Router.kt's `route(name, tail, usage, routes,
# help=USAGE)` is the shared sub-verb dispatcher;
# most families expose a `val USAGE` that both
# `--help` and the README are kept in sync with.
```
**Dependencies:** `:quartz` + `:commons` + kotlinx-coroutines + OkHttp
+ Jackson. **No Android, no Compose.** Amy compiles on any JDK 21
host. Never add a Gradle dependency on `:amethyst` or `:desktopApp`.
**Dependencies:** `:quartz` + `:commons` (+ `:geode` for `amy serve`) +
kotlinx-coroutines + OkHttp + Jackson. **No Android, no Compose.** Amy
compiles on any JDK 21 host. Never add a Gradle dependency on `:amethyst`
or `:desktopApp`.
**`Context.kt` is the backbone.** Most commands follow this template:
@@ -189,22 +200,34 @@ import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
object NoteCommands {
// The per-group usage text. `amy note --help` prints it; keep it in
// lock-step with printUsage() in Main.kt and the README table.
val USAGE: String =
"""
|Notes:
| note publish TEXT [--relay URL[,URL…]] publish a kind:1 note
""".trimMargin()
suspend fun dispatch(dataDir: DataDir, tail: Array<String>): Int =
route("note", tail, "note <publish|read|…>", mapOf(
route("note", tail, "note <publish|read|…>", help = USAGE, routes = mapOf(
"publish" to { rest -> publish(dataDir, rest) },
))
private suspend fun publish(dataDir: DataDir, rest: Array<String>): Int {
val args = Args(rest)
val text = args.positional(0, "text")
val relays = RawEventSupport.relayFlag(args) // malformed URL → bad_args
args.rejectUnknown() // --typo → bad_args, exit 2
Context.open(dataDir).use { ctx ->
ctx.prepare()
val event = com.vitorpamplona.amethyst.commons.note.buildTextNote(ctx.signer, text)
val ack = ctx.publish(event, ctx.outboxRelays())
val ack = ctx.publish(event, relays.ifEmpty { ctx.outboxRelays() })
RawEventSupport.publishGuard(ack, event.id)?.let { return it } // all-rejected → `rejected`, exit 1
Output.emit(mapOf(
"event_id" to event.id,
"kind" to event.kind,
"published_to" to ack.filterValues { it }.keys.map { it.url },
"rejected_by" to ack.filterValues { !it }.keys.map { it.url },
))
return 0
}
@@ -212,10 +235,26 @@ object NoteCommands {
}
```
The shared `route(name, tail, usage, routes)` helper (in `Router.kt`)
handles the empty-input and unknown-verb `bad_args` cases, so each
`dispatch` is just the verb→handler map. Add a top-level branch in
`Main.kt`'s `dispatch` and extend `printUsage()`. Keep the command tour in
The shared `route(name, tail, usage, routes, help)` helper (in `Router.kt`)
handles the empty-input and unknown-verb `bad_args` cases (an unknown verb
echoes the expected verb list) and answers `--help` / `-h` / `help` with the
group's `USAGE` text — so each `dispatch` is just the verb→handler map plus
a `USAGE` constant. This is the pattern for every new command group.
Inside a handler, the `Args` contract does the policing for you:
- Read every supported flag through an accessor (`flag`, `bool`, `intFlag`,
`longFlag`, `requireFlag`), then call **`args.rejectUnknown()`** — any
leftover `--typo` becomes `bad_args` (exit 2) instead of a silent no-op.
- `intFlag`/`longFlag` throw `bad_args` on non-numeric values; use
`RawEventSupport.relayFlag` / `Output.invalidRelayUrl` for relay URLs so
malformed inputs fail the same way everywhere.
- After a publish, call **`RawEventSupport.publishGuard(ack, id)`** — it
returns the `rejected` (exit 1) error when *every* targeted relay refused
the event, and `null` otherwise.
Add a top-level branch in `Main.kt`'s `dispatch` and extend both
`printUsage()` and `printVerbList()`. Keep the command tour in
[README.md](./README.md) and the parity matrix in
[ROADMAP.md](./ROADMAP.md) in sync.
@@ -234,7 +273,39 @@ contract.
- Relay URLs as strings, normalized, never objects.
- Lists of events under a plural key (`"messages"`, `"members"`).
- Errors via `Output.error("code","detail")` — single lower_snake
code, free-form detail.
code, free-form detail. The return value **is** the exit code
(`bad_args` → 2, `timeout` → 124, else 1), so handlers
`return Output.error(…)`.
- Publish results use `published_to` (+ `rejected_by`) everywhere, and
freshly published/minted events report `event_id`. Don't invent
`accepted_by`-style variants.
**Error codes — the canonical set.** The code string is part of the
`--json` contract; reuse an existing code before minting a new one:
- **Contract-wide:** `bad_args` (exit 2), `timeout` (exit 124),
`rejected` (every targeted relay refused a publish; payload carries
`event_id` + `rejected_by`), `runtime` (uncaught exception),
`invalid_event` (event/template fails id or signature checks),
`http_error`, `bad_response`, `not_found`, `exists`, `read_only`,
`no_identity`, `bad_account`, `signer_error`, `decrypt_failed`.
- **Relay routing:** `no_relays`, `no_dm_relays`, `no_inbox_relays`,
`no_servers`, `servers_unreachable`, `relay_error`, `sync_error`.
- **Groups:** `not_member` (you aren't in the group / group unknown),
`target_not_member` (the *other* user isn't).
- **Cashu:** `no_wallet`, `no_mint`, `insufficient_funds`,
`mint_unreachable`, `mint_http_<status>`, `mint_proofs_spent`,
`mint_quote_gone`, `nutzap_locked_to_wrong_key`, `invoice_failed`.
- **CLINK / zaps:** `bad_pointer`, `offer_error`, `debit_error`,
`no_lightning`.
- **Blossom / nsite:** `upload_failed`, `hash_mismatch`,
`aggregate_mismatch`.
- **Misc:** `no_follows` (fof), `invalid_identifier` (namecoin),
`no_tty` (`bunker --interactive` without a terminal).
Old spellings are gone — `bad_event`/`bad_template` collapsed into
`invalid_event`, `fetch_failed` into `http_error`, `not_in_group` into
`target_not_member`, and `pow_timeout` into `timeout`.
**Command-family schemas:**
@@ -262,11 +333,19 @@ Amy-specific layer still needs its own coverage:
| Layer | Test approach |
|---|---|
| Argument parsing (`Args`, flag forms, `--account=…` vs `--account …`) | Plain JVM unit tests in `cli/src/test/kotlin/`. |
| Error / exit-code contract (bad args → 2, await timeout → 124, runtime → 1) | Table-driven tests invoking `main(argv)` with captured stdout/stderr. |
| JSON output shape (each command's keys and types under `--json`) | Snapshot tests: run a command with `--json` against a throwaway `$HOME` (`HOME=$(mktemp -d) amy --account X …`), assert the JSON matches a golden file. The default text render has no shape contract and shouldn't be snapshotted. |
| File layout on disk (`identity.json`, `events-store/…`, `marmot/groups/*.mls`, `marmot/keypackages.bundle`) | Structural assertions after a command sequence. |
| Round-trip between two accounts on a local relay | End-to-end shell harnesses under `cli/tests/`: each spins up a local `nostr-rs-relay` and a fresh `$HOME=$STATE_DIR` so amy sees a virgin `~/.amy/`, then bootstraps multiple accounts (`--account A`, `--account D`, …) sharing one `~/.amy/shared/events-store/` and drives a scenario through them. Today: `cli/tests/dm/` (NIP-17 DMs between two amy accounts) and `cli/tests/marmot/` (MLS scenarios vs whitenoise-rs `wn`/`wnd`). |
| Argument parsing (`Args`, flag forms, `--` terminator, unknown-flag rejection) | `ArgsTest` in `cli/src/test/kotlin/` — plain JVM unit tests. |
| Error / exit-code contract (bad args → 2, timeout → 124, `rejected` → 1) | `ExitCodeContractTest` — table-driven tests invoking `runCli(argv)` with captured stdout/stderr. |
| JSON output shape (keys and types under `--json`) | `JsonContractTest` — runs commands under `--json` and asserts on the parsed object. The default text render has no shape contract and isn't asserted on. |
| File layout on disk (`identity.json`, `shared/events.db`, `marmot/groups/*.mls`, …) | Structural assertions after a command sequence. |
| Round-trip between two accounts on a local relay | End-to-end shell harnesses under `cli/tests/`: each spins up a local `nostr-rs-relay` and a fresh `$HOME=$STATE_DIR` so amy sees a virgin `~/.amy/`, then bootstraps multiple accounts sharing one store and drives a scenario through them. Nine suites today — see [`cli/tests/README.md`](./tests/README.md). |
The JVM suite drives `runCli` **in-process** through the shared
`amy(vararg argv)` harness in `CliResult.kt`: it captures stdout/stderr,
resets the global `Output.mode` around every run, and isolates `~/.amy`
via the **`amy.home` system-property seam** in `DataDir` (a temp dir per
invocation, deleted afterwards) — no subprocess, no `$HOME` games, so the
contract tests run in milliseconds with plain `./gradlew :cli:test`.
`runCli` exists precisely for this: it is `main()` minus `exitProcess`.
**What not to test here:** event signing, filter assembly, MLS
correctness, NIP-44 encryption. Those belong in `quartz`/`commons`.
@@ -300,9 +379,9 @@ a gap — add it to [ROADMAP.md](./ROADMAP.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:
check) and persisted to a shared store under `~/.amy/shared/` (one
store per machine, shared across every account in `~/.amy/`). That
includes:
- events received from any relay subscription (`amy notes feed`,
`amy dm list`, `amy marmot key-package publish`, group sync, …),
@@ -328,17 +407,32 @@ ctx.keyPackageRelaysOf(pubKey) // latest kind:10051 (MIP-00 KP relays)
ctx.cachedRelayListsOf(pubKey) // RecipientRelayFetcher.Lists 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`](./plans/) 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`. Deleting an event file is treated as a deliberate
"I never saw this" by amy; dangling indexes are skipped at query time
and can be cleaned up with `amy store scrub` / `amy store compact`.
**Two backends, one interface.** `StoreFactory.kt` picks the backend from
the `AMY_STORE` environment variable; both implement quartz's
`IEventStore`, so every command works unchanged either way:
- **`sqlite` (the default)** — quartz's SQLite store
(`quartz/.../store/sqlite/EventStore.kt`) in a single database file at
`~/.amy/shared/events.db`. Index postings live in shared B-tree pages,
so an event's kind/author/tag indexes cost a handful of rows — for
crawl-scale corpora (hundreds of thousands of follow lists) this is
several times smaller on disk than the FS tree.
- **`fs` (`AMY_STORE=fs`)** — the file tree at `~/.amy/shared/events-store/`
(`quartz/.../store/fs/FsEventStore.kt`): one pretty-printed JSON file per
event plus one file per index posting, under shard directories.
Intentionally inspectable with `ls`, `cat`, `jq`, `grep`, `find`,
`rsync`, and `git` — but every posting rounds up to a filesystem block,
so a large corpus balloons. Deleting an event file is treated as a
deliberate "I never saw this"; dangling indexes are skipped at query
time and cleaned up with `amy store scrub` / `amy store compact`
(on sqlite those verbs are a no-op / `VACUUM` respectively).
Both backends implement the full feature set — NIP-01 replaceable /
addressable uniqueness, NIP-09 deletion tombstones, NIP-40 expiration,
NIP-50 full-text search (`amy store reindex-fts` rebuilds the index),
NIP-62 right-to-vanish, NIP-91 multi-tag AND. See
[`cli/plans/2026-04-24-file-event-store-*.md`](./plans/) for the FS
design.
---
@@ -372,10 +466,14 @@ events.
## Full on-disk layout
```
~/.amy/ ← root, follows $HOME
~/.amy/ ← root, follows $HOME (or the amy.home property)
├── current # marker file written by `amy use NAME`
├── operator/ # machine-level GrapeRank operator keys
│ # (independent of any account; OperatorKeys.kt)
├── shared/
│ └── events-store/ # FsEventStore — every observed Nostr event
│ ├── events.db # SQLite event store — the DEFAULT backend
│ ├── dns-cache.bin # NIP-05 DNS lookup cache
│ └── events-store/ # FsEventStore — only when AMY_STORE=fs
│ ├── 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
@@ -386,6 +484,7 @@ events.
│ ├── state.json # sync cursors (giftWrapSince, groupSince)
│ ├── aliases.json # local name → npub map (init writes a self-entry)
│ ├── cashu.json # NIP-60 NUT-13 counters: {"keyset_counters":{"<id>":<long>}}
│ ├── concord.json # Concord community secrets (ConcordStore)
│ └── marmot/
│ ├── keypackages.bundle # MLS KeyPackage bundles (NostrSignerInternal)
│ └── groups/
@@ -394,10 +493,11 @@ events.
└── 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.
Per-account files are plain JSON or framed binary — human-inspectable,
easy to diff across two accounts. Every account on the machine shares the
one store under `~/.amy/shared/`, so a public event observed once doesn't
get re-stored per account. `operator/` sits *beside* the account dirs and
is excluded from account auto-selection (it is not an account).
The local relay configuration (kind:10002 / 10050 / 10051) is **not** a
separate file — it lives in the shared `events-store/` as signed events
@@ -411,12 +511,15 @@ broadcasts those events to upstream relays. There is no `relays.json`.
## Housekeeping
- Run `./gradlew spotlessApply` before every commit.
- Keep three things in sync: `printUsage()` in `Main.kt`, the command
tour in [README.md](./README.md), and the parity matrix in
- Keep four things in sync: `printUsage()` in `Main.kt`, the per-group
`USAGE` constants (`amy <cmd> --help`), the command tour in
[README.md](./README.md), and the parity matrix in
[ROADMAP.md](./ROADMAP.md). They drift fast.
- Never add a Gradle dependency on `:amethyst` or `:desktopApp`. If
you need something from there, move it to `commons/` first.
- Never introduce a blocking prompt (`readLine()`, interactive
password input). Take it as a flag.
password input). Take it as a flag. The only sanctioned exceptions
are the two opt-in TTY paths listed under the public contract
(ncryptsec passphrase fallback, `bunker --interactive`).
- Keep each command file small. Past ~200 lines, split — the Marmot
`group` verbs are already a cautionary tale.
+169 -27
View File
@@ -68,14 +68,17 @@ JSON object instead of human-readable text — same data, machine shape.
```text
$ amy notes post "good morning nostr"
event_id: a3c1f9c2…(64 hex)
kind: 1
accepted_by:
event_id: a3c1f9c2…(64 hex)
kind: 1
published_to:
- wss://relay.damus.io/
- wss://nos.lol/
rejected_by: (none)
rejected_by: (none)
```
If **every** targeted relay refuses the event, amy reports
`error: rejected` and exits 1 — a total rejection never exits 0.
`amy notes feed` reads recent kind:1 notes from your follows; `--limit N`
caps the count, `--author npub1…` narrows to one user.
@@ -241,9 +244,12 @@ Two amy processes can talk: one **hosts** a bunker with its local key; the other
| `amy bunker connect nostrconnect://…` | Client-initiated (NostrConnect) flow, signer side: ack a client's offer (echo its secret) and service its requests. |
| `amy login --nostrconnect [--relay URL[,URL…]] [--name N] [--timeout SECS]` | Client-initiated flow, client side: print a `nostrconnect://` offer, wait for a signer to connect, then persist a bunker account that acts as the signer's key. |
Interop-tested against the real [`nak`](https://github.com/fiatjaf/nak) binary:
- **bunker:// both directions** — `amy login bunker://` ⇄ `nak bunker`, and `nak event --sec bunker://` ⇄ `amy bunker`.
- **nostrconnect:// client** — `amy login --nostrconnect` ⇄ `nak bunker connect` (amy signs, event authored by nak's key).
The flows were verified against the real [`nak`](https://github.com/fiatjaf/nak)
binary during development — `amy login bunker://` ⇄ `nak bunker`, `nak event
--sec bunker://` ⇄ `amy bunker`, and `amy login --nostrconnect` ⇄ `nak bunker
connect` (amy signs, event authored by nak's key). A scripted nak harness
under `cli/tests/` is still pending, so treat these as dev-verified, not
CI-pinned.
Supports `connect` (secret-checked), `get_public_key`, `get_relays`, `sign_event`, `nip04_encrypt/decrypt`, `nip44_encrypt/decrypt`, `ping`. When a bunker answers with an `auth_url` challenge, amy prints the authorization URL to stderr and keeps waiting for the real response (open the URL in a browser to authorize).
@@ -280,6 +286,16 @@ Filter flags are shared by `fetch` and `subscribe`: `--kind K[,K]`, `--author U[
| `amy outbox USER [--refresh] [--timeout SECS]` | Show USER's NIP-65 read/write relays (outbox model). Cache-first; `--refresh` forces a relay drain. |
| `amy sync --relay URL [filter flags] [--down] [--up]` | NIP-77 Negentropy reconcile between the local store and a relay. `--down` (default) pulls events we lack; `--up` pushes events the relay lacks; both for bidirectional. |
### Search (NIP-50)
Runs against your kind:10007 search-relay list, falling back to Amethyst's
default search relays.
| Command | What it does |
|---|---|
| `amy search user QUERY [--limit N] [--timeout SECS]` | Search kind:0 profiles. Default `--limit 50`. |
| `amy search note QUERY [--kind K[,K…]] [--limit N] [--timeout SECS]` | Search event content. Default kind:1 (e.g. `--kind 1,30023`); `--kinds` is accepted as an alias. |
### Encryption
| Command | What it does |
@@ -308,6 +324,35 @@ nak's `clone`/`push`/`pull` (git-packfile transport over relays/GRASP) are out o
| `amy podcast publish --title T --description D --audio URL[,URL] [--audio-type MIME] [--image URL] [--content MD]` | Publish a kind:54 episode. |
| `amy podcast list [USER] [--limit N]` | List a user's show metadata + episodes. |
### Podcasts (Podcasting 2.0 / podstr)
The podstr-compatible surface — Podcasting 2.0 tags carried in addressable
events. `--identifier` is accepted as an alias of `--d` everywhere here.
| Command | What it does |
|---|---|
| `amy podcast20 metadata --title T [--description D] [--author A] [--email E] [--image URL] [--language L] [--categories A,B] [--funding URL,URL] [--website URL] [--copyright C] [--type episodic\|serial] [--explicit] [--complete] [--locked] [--guid G] [--value-json JSON] [--relay URL[,URL…]]` | Publish kind:30078 show metadata (JSON body). `--value-json` is the value-for-value split block. |
| `amy podcast20 episode --title T --audio URL[,URL] [--d ID] [--audio-type MIME] [--description D] [--image URL] [--duration SECS] [--video URL] [--video-type MIME] [--episode N] [--season N] [--transcript URL] [--chapters URL] [--value-json JSON] [--topic A,B] [--content MARKDOWN] [--pubdate RFC2822] [--relay URL[,URL…]]` | Publish a kind:30054 episode. |
| `amy podcast20 trailer --title T --url URL [--d ID] [--type MIME] [--length BYTES] [--season N] [--pubdate RFC2822] [--relay URL[,URL…]]` | Publish a kind:30055 trailer. |
| `amy podcast20 list [USER] [--limit N] [--relay URL[,URL…]]` | List a creator's metadata + episodes + trailers. |
### Static websites & napplets (NIP-5A / NIP-5D)
Sites and napplets are manifests on Nostr with content on Blossom; every
fetch is verified against the manifest's sha256 pins. `--identifier` is an
alias of `--d`; `--d` selects a named site/napplet, else the root one.
| Command | What it does |
|---|---|
| `amy nsite fetch AUTHOR [--d ID] [--path P] [--server URL[,URL]] [--relay URL[,URL]] [--out FILE] [--max-inline-bytes N] [--timeout SECS]` | Resolve one path over Nostr + Blossom and verify it against the manifest's sha256 pin (kind:15128 root, or kind:35128 named with `--d`; `--path` defaults to `/`). |
| `amy nsite publish DIR --server URL[,URL] [--d ID] [--relay URL[,URL]] [--title T] [--description D] [--source URL] [--icon URL]` | Upload a directory to Blossom and broadcast its NIP-5A manifest, including the `x` aggregate hash so it is self-verifying. |
| `amy nsite serve AUTHOR [--d ID] [--port N] [--server URL[,URL]] [--relay URL[,URL]] [--timeout SECS]` | Fetch the manifest and serve it over a local HTTP server (sha256-verified per request) so you can open it in a browser. |
| `amy nsite list AUTHOR [--relay URL[,URL]] [--timeout SECS]` | Enumerate an author's sites: the root and every named one, latest per identifier. |
| `amy napplet fetch AUTHOR [--d ID] [--path P] …` | Like `nsite fetch`, plus NIP-5D verification: recompute + check the `x` aggregate hash and report the napplet's `requires` capabilities. `--snapshot EVENT-ID` pins a kind:5129 immutable snapshot by event id. |
| `amy napplet publish DIR --server URL[,URL] [--requires identity,relay,…] [--d ID] [--relay URL[,URL]] [--title T] [--description D] [--source URL] [--icon URL]` | Upload a napplet directory and broadcast its NIP-5D manifest (kind:15129 root / 35129 named) with the `x` aggregate hash and the `requires` capability tags the shell gates on. |
| `amy napplet serve AUTHOR [--d ID] [--port N] …` | Fetch + aggregate-verify the manifest and serve its static content over local HTTP. |
| `amy napplet list AUTHOR [--relay URL[,URL]] [--timeout SECS]` | Enumerate an author's napplets, latest per identifier. |
### Blossom blobs (NIP-B7)
| Command | What it does |
@@ -335,7 +380,7 @@ amy's on-relay events match the app's. NUT-13 counters persist in
| `amy cashu balance [--mint URL]` | Spendable balance from the local store (optionally one mint). |
| `amy cashu mint ping URL` / `info URL` | Stateless `/v1/info` probe (name/pubkey/version) / full DTO. |
| `amy cashu receive ln SATS [--mint URL]` | Request a mint quote; prints the bolt11 + kind:7374 quote. |
| `amy cashu receive complete QUOTE_ID` / `resume QUOTE_ID` | Poll the quote; once the invoice is settled, mint proofs (kind:7375 + kind:7376). |
| `amy cashu receive complete QUOTE_ID` | Poll the quote; once the invoice is settled, mint proofs (kind:7375 + kind:7376). (`resume` is a deprecated alias.) |
| `amy cashu receive token TOKEN` | Redeem a `cashuB…` token into the wallet. |
| `amy cashu receive nutzap-sweep [--mint URL]` | Redeem inbound NIP-61 nutzaps locked to your wallet key. |
| `amy cashu send ln INVOICE [--mint URL]` | Melt proofs to pay a bolt11 (scrubs stale proofs first). |
@@ -455,8 +500,8 @@ kind:10040 out-of-band.
| `amy dm send RECIPIENT TEXT [--allow-fallback]` | Gift-wrap a kind:14 to RECIPIENT. Strict kind:10050 routing by default. |
| `amy dm send-file RECIPIENT --file PATH --server URL` | Encrypt a local file, upload to a Blossom server, publish a kind:15 referencing it. |
| `amy dm send-file RECIPIENT URL --key HEX --nonce HEX` | Reference-mode: file already uploaded; just publish the kind:15. |
| `amy dm list [--peer NPUB] [--since TS] [--limit N]` | Drain and decrypt gift wraps. |
| `amy dm await --peer NPUB --match TEXT [--timeout SECS]` | Block until a matching DM arrives. |
| `amy dm list [USER] [--peer USER] [--since TS] [--limit N]` | Drain and decrypt gift wraps. Positional USER is an alternative to `--peer` (the flag wins). Default `--limit 50`. |
| `amy dm await [USER] --match TEXT [--peer USER] [--timeout SECS]` | Block until a matching DM arrives (positional USER or `--peer`; the flag wins). |
### Groups (Marmot / MLS)
@@ -472,7 +517,7 @@ kind:10040 out-of-band.
| `amy marmot group promote / demote / remove GID NPUB` | Admin verbs. |
| `amy marmot group leave GID` | Self-remove. |
| `amy marmot message send GID TEXT` | Publish a kind:9 inner event into the group. |
| `amy marmot message list GID [--limit N]` | Decrypted inner events, oldest first. |
| `amy marmot message list GID [--limit N]` | Decrypted inner events, oldest first. Default `--limit 50`. |
| `amy marmot message react GID EVENT_ID EMOJI` | Publish a kind:7 reaction. |
| `amy marmot message delete GID EVENT_ID …` | Publish a kind:5 deletion. |
@@ -497,20 +542,83 @@ screen speaks.
| `amy relaygroup put-user RELAY GID PUBKEY [--role admin\|moderator]` | Add or promote a user (9000, moderator). |
| `amy relaygroup remove-user RELAY GID PUBKEY` | Kick a user (9001, moderator). |
### Concord Channels (encrypted communities)
Encrypted, serverless communities (the CORD specs). Community secrets
persist in `~/.amy/<account>/concord.json`; your joined-community list is
also carried on-relay as an encrypted kind:13302.
| Command | What it does |
|---|---|
| `amy concord create --name NAME [--about T] [--relay wss://a,wss://b]` | Create an encrypted Concord community. `--relay` is canonical; `--relays` is accepted as an alias. |
| `amy concord list` | List joined Concord communities. |
| `amy concord import` | Fetch + decrypt this account's kind:13302 community list (carries heldRoots, CORD-06). |
| `amy concord channels COMMUNITY` | List a community's channels. |
| `amy concord send COMMUNITY CHANNEL TEXT` | Post a message (CHANNEL = `general`\|name\|id). |
| `amy concord read COMMUNITY CHANNEL [--limit N] [--epoch N] [--root HEX]` | Read a channel's messages (default 50); `--epoch`/`--root` read a prior epoch's plane. |
| `amy concord invite COMMUNITY [--base URL]` | Mint + publish a shareable invite link. |
| `amy concord join URL` | Redeem an invite link and save the community. |
| `amy concord roles COMMUNITY` | List live roles + the current banlist (CORD-04). |
| `amy concord role COMMUNITY NAME POSITION PERM…` | Define a role (perms by name, e.g. `BAN KICK`). |
| `amy concord grant COMMUNITY USER ROLE-ID` | Grant a role to a member. |
| `amy concord ban COMMUNITY USER` / `unban COMMUNITY USER` | Ban / unban a member. |
### Geochat (Bitchat geohash channels)
Bitchat-interoperable public location chat: ephemeral kind:20000 events
tagged `["g", geohash]`, signed with a per-geohash **throwaway identity**
and routed to the relays geographically nearest the cell. Relays broadcast
ephemeral events live but don't store them, so `listen` holds an open
subscription for a window.
| Command | What it does |
|---|---|
| `amy geochat listen GEOHASH [--seconds N] [--limit N] [--relay URL[,URL…]] [--no-fetch]` | Hold a live subscription to the cell and report messages + present pubkeys seen in the window (default `--seconds 30`, `--limit 50`). |
| `amy geochat send GEOHASH MESSAGE [--nick NAME] [--teleport] [--pow BITS] [--pow-timeout SECS] [--seed HEX] [--relay URL[,URL…]] [--no-fetch]` | Sign with the per-geohash throwaway identity and publish to the cell's nearest relays. |
| `amy geochat keys GEOHASH [--seed HEX]` | Print the per-geohash derived pubkey. |
`--no-fetch` skips the geo-relay directory refresh.
### Which chat system?
Four group-chat surfaces coexist — pick by threat model and topology:
| Verb | Protocol | Encryption | Where it lives | Use when |
|---|---|---|---|---|
| `marmot` | Marmot / MLS | E2EE (MLS) with forward secrecy | Gift-wrapped events on ordinary relays | Private groups; strongest crypto; membership managed by commits. |
| `relaygroup` | NIP-29 | None (relay-enforced access) | One **host relay** per group, which moderates | Public/moderated communities à la Armada/relay29. |
| `concord` | Concord (CORD) | Encrypted, serverless | Ordinary relays; secrets in `concord.json` | Encrypted communities with channels + roles, no host relay to trust. |
| `geochat` | Bitchat geohash | None (public, throwaway identity) | Ephemeral kind:20000 on geo-nearest relays | Location-based public chat; Bitchat interop. |
### Zaps (NIP-57)
Builds the kind:9734 zap request and fetches a BOLT11 invoice from the
recipient's Lightning service. No auto-payment by default — paste the
invoice into a wallet — unless you pass `--with NDEBIT`, which settles each
fetched invoice in-place through a CLINK debit pointer (kind:21002); the
output then also reports `paid` + the preimage.
| Command | What it does |
|---|---|
| `amy zap user USER SATS [--comment X] [--anon\|--private] [--with NDEBIT] [--timeout SECS]` | Profile zap: build the zap request and fetch a BOLT11 from USER's LN service. |
| `amy zap event EVENT-ID SATS [--comment X] [--anon\|--private] [--with NDEBIT] [--timeout SECS]` | Same, attributed to a specific event (must be in the local store). Zap splits are honored — one invoice per recipient. |
### CLINK Offers
| Command | What it does |
|---|---|
| `amy offer info NOFFER` | Decode a `noffer1…` pointer (pubkey, relays, price type/amount). Local, no network. |
| `amy offer request NOFFER [--amount SATS] [--timeout MS] [--payer-data K=V,…]` | kind:21001 round-trip: publish the request to the pointer's relays and print the returned BOLT11. `--amount` is required for spontaneous offers; fixed offers default to the pointer's price. `--payer-data` attaches payer fields (e.g. `email=a@b.c`) for offers that require them — Lightning.Pub answers "Invalid Offer" (code 1) when they are missing. |
| `amy offer discover NIP05` | Resolve a profile's advertised offer from its NIP-05 `.well-known` (e.g. `bob@example.com`). |
| `amy offer request NOFFER [--amount SATS] [--timeout SECS] [--follow] [--payer-data K=V,…]` | kind:21001 round-trip: publish the request to the pointer's relays and print the returned BOLT11. `--amount` is required for spontaneous offers; fixed offers default to the pointer's price. `--follow` chases an "Expired or Moved" (code 3) reply to the `latest` pointer. `--payer-data` attaches payer fields (e.g. `email=a@b.c`) for offers that require them — Lightning.Pub answers "Invalid Offer" (code 1) when they are missing. |
| `amy offer pay NOFFER --with NDEBIT [--amount SATS] [--timeout SECS]` | Fetch the invoice and settle it end-to-end through a CLINK debit pointer (kind:21002). |
### CLINK Debits
| Command | What it does |
|---|---|
| `amy debit info NDEBIT` | Decode an `ndebit1…` pointer (pubkey, relays, pointer id, session flag). Local, no network. |
| `amy debit pay NDEBIT BOLT11 [--amount SATS] [--timeout MS]` | kind:21002 round-trip: ask the pointed-to wallet to pay the invoice; print the preimage or the service's GFY error. |
| `amy debit budget NDEBIT --amount SATS [--frequency day\|week\|month] [--timeout MS]` | Authorize a spending budget; omit `--frequency` for a one-time budget. |
| `amy debit pay NDEBIT BOLT11 [--amount SATS] [--timeout SECS]` | kind:21002 round-trip: ask the pointed-to wallet to pay the invoice; print the preimage or the service's GFY error. |
| `amy debit budget NDEBIT --amount SATS [--frequency day\|week\|month] [--timeout SECS]` | Authorize a spending budget; omit `--frequency` for a one-time budget. |
### Wait-for-condition (`await`)
@@ -570,12 +678,17 @@ the last facet removes R entirely.
### Local store maintenance
The shared store lives under `~/.amy/shared/` — a SQLite `events.db` by
default, or the `events-store/` file tree when `AMY_STORE=fs` (see
[DEVELOPMENT.md](./DEVELOPMENT.md)).
| Command | What it does |
|---|---|
| `amy store stat` | Event count, kind histogram, disk usage, oldest/newest timestamps. |
| `amy store stat` | Event count + disk usage (kind histogram/mtime on the fs backend). |
| `amy store sweep-expired` | Delete events past their NIP-40 expiration. |
| `amy store scrub` | Rebuild the index after external edits or a crash. |
| `amy store compact` | Drop dangling index entries (canonical event already gone). |
| `amy store scrub` | fs: rebuild `idx/` from canonical events; sqlite: no-op. |
| `amy store compact` | fs: drop dangling index entries; sqlite: `VACUUM`. |
| `amy store reindex-fts` | Rebuild the NIP-50 search index (after a searchable-kinds change). |
---
@@ -605,14 +718,34 @@ Under `--json` the error goes to stderr as `{"error":"not_member","detail":"abc1
Color auto-disables when stdout is a pipe; force it with `CLICOLOR_FORCE=1`,
turn it off entirely with `NO_COLOR=1`.
**Exit codes** — the real signal for scripts:
**Exit codes** — the real signal for scripts. The error **code string picks
the exit code**: `bad_args` → 2, `timeout` → 124, every other code → 1.
| Code | Meaning |
|---|---|
| 0 | success |
| 1 | runtime error (network, permission, NIP rejection, …) |
| 2 | bad arguments |
| 124 | `await` timed out |
| 1 | runtime error (network, permission, `rejected`, `not_member`, …) |
| 2 | bad arguments — **any** `bad_args`, including unknown flags and malformed values |
| 124 | timed out — `await` verbs, `pow mine`, offer/debit round-trips |
Notable error codes (the full canonical list is in
[DEVELOPMENT.md](./DEVELOPMENT.md)):
- **`rejected`** (exit 1) — a publish was refused by **every** targeted
relay; the payload carries `event_id` + `rejected_by`. Partial acceptance
still exits 0 and reports `published_to` / `rejected_by`.
- **`bad_args`** (exit 2) — also raised for **unknown flags** (`--limt 5`
fails instead of silently no-oping) and malformed numeric / relay-URL /
`--author` / `--id` values.
- **`timeout`** (exit 124) — every timeout error, not just `await`.
**Argument-parsing conveniences:**
- `amy <cmd> --help` prints that command group's usage (and an unknown
sub-verb echoes the expected verb list). An unknown top-level verb prints
a one-screen verb list; `amy --help` remains the full reference.
- A literal `--` ends flag parsing — everything after it is positional even
if it starts with `--` (`amy notes post -- "--good morning"`).
---
@@ -624,12 +757,16 @@ matches that:
```
~/.amy/
├── current # marker: which account `amy use NAME` pinned
├── operator/ # machine-level GrapeRank operator keys (no account)
├── shared/
│ └── events-store/ # one Nostr event store, shared by every account
│ ├── events.db # the shared event store — SQLite, the default
│ └── events-store/ # …or this file tree instead, when AMY_STORE=fs
├── alice/
│ ├── identity.json # keypair (or reference to keychain entry)
│ ├── state.json # sync cursors
│ ├── aliases.json # local name → npub map
│ ├── cashu.json # NIP-60 NUT-13 counters
│ ├── concord.json # Concord community secrets
│ └── marmot/ # MLS state per group
└── bob/
└── …
@@ -656,8 +793,10 @@ upload/list/delete, cashu, …) require an account — and say so.
For one-off override, prepend `--account NAME` to any command.
`init` and `create` write a self-entry into `aliases.json` so you can
refer to your own account by name in future commands. The alias resolver
in recipient slots (`amy dm send alice "hi"`) is on the roadmap.
refer to your own account by name in future commands. Aliases **resolve in
every user slot**: anywhere a command takes a USER (`amy dm send bob "hi"`,
`amy follow bob`, `amy profile show bob`, …) the input is checked against
`aliases.json` first, then parsed as npub/nprofile/hex/NIP-05.
For the deeper layout (events-store internals, relay-routing rules, the
public-contract guarantees) see [DEVELOPMENT.md](./DEVELOPMENT.md).
@@ -671,9 +810,10 @@ Three contracts keep amy machine-safe:
1. **One JSON object per success on stdout** under `--json`. Stable
snake_case keys; keys never disappear silently.
2. **Errors as JSON on stderr** under `--json`: `{"error":"...","detail":"..."}`.
3. **Exit codes mean specific things** (table above) — `124` for
`await` timeout in particular lets you distinguish "condition never
happened" from "the command itself crashed".
3. **Exit codes mean specific things** (table above) — `124` for a
timeout in particular lets you distinguish "condition never
happened" from "the command itself crashed", and `rejected` (exit 1)
means no targeted relay accepted a publish.
### Recipes
@@ -740,6 +880,8 @@ Inside the amy process there's no test mode — it just sees a fresh
## Where to go next
- **[RECIPES.md](./RECIPES.md)** — task-shaped walkthroughs: run a
relay, bunker, marmot, cashu, nsite, graperank.
- **[DEVELOPMENT.md](./DEVELOPMENT.md)** — design principles,
architecture, the public contract, the local event store, relay
routing, full on-disk layout, how to extend amy without breaking it.
+33 -22
View File
@@ -53,27 +53,34 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command ·
| NIP-01 note publish (`amy notes post TEXT`) | ✅ | `PostCommand` — outbox via `RelayCommands` configured set. |
| NIP-13 proof of work (`amy notes post --pow N`, `amy pow check/mine/bench`) | ✅ | `PostCommand` + `PowCommands` — mines pre-signature via quartz `PoWMiner`; `pow mine --pubkey` covers delegated PoW; `pow check` applies the commitment cap. |
| NIP-01 feed read (`amy notes feed [--following \| --author NPUB]`) | ✅ | `FeedCommand`. Hashtag / community feeds still pending. |
| NIP-02 follow list add / remove / list | 🆕 | Logic in `amethyst/model/nip02FollowLists/`. |
| NIP-02 follow list add / remove / list | ✅ | `FollowCommand` — `amy follow USER` / `amy unfollow USER` (fetches the freshest kind:3 first). |
| NIP-09 event deletion | 🆕 | Builder exists in quartz. |
| NIP-17 DMs send / list / await | ✅ | `DmCommands` — reuses Quartz `NIP17Factory` + `RecipientRelayFetcher`; filter extracted to `commons/relayClient/nip17Dm/`. Plan: [`cli/plans/2026-04-23-nip17-dm.md`](./plans/2026-04-23-nip17-dm.md). |
| NIP-18 reposts / quotes | 🆕 | |
| NIP-25 reactions | ✅ in groups · 🆕 elsewhere | `marmot message react` covers MLS group reactions; outer-event reactions still pending. |
| NIP-29 relay groups (`amy relaygroup`) | ✅ | `RelayGroupCommands` — list/browse/info/create/join/leave/message/edit/invite/put-user/remove-user against a host relay; kind:10009 joined-list kept in sync. |
| NIP-51 lists (bookmarks, mute, follow sets) | 🆕 | `amethyst/model/nip51Lists/` |
| NIP-57 zaps (send + verify) | 🆕 | Needs LN-URL plumbing; `amethyst/service/lnurl/`. |
| NIP-65 outbox model queries | 🆕 | |
| NIP-57 zaps (send) | ✅ partial | `ZapCommand` — `zap user`/`zap event` build the kind:9734 request and fetch the BOLT11 (zap splits honored, one invoice per recipient); `--with NDEBIT` auto-pays through a CLINK debit pointer. Receipt (kind:9735) verification still 🆕. |
| NIP-65 outbox model queries | ✅ | `OutboxCommand` — `amy outbox USER [--refresh]`, cache-first. |
| CLINK offers + debits (`amy offer` / `amy debit`) | ✅ | `OfferCommands` + `DebitCommands` — pointer decode, NIP-05 discover, kind:21001/21002 round-trips, `offer pay --with NDEBIT` end-to-end settlement. `--timeout` is SECONDS. |
| Geochat (Bitchat geohash, ephemeral kind:20000) | ✅ | `GeochatCommands` — listen/send/keys with per-geohash throwaway identity + geo-nearest relay routing; doubles as the Bitchat interop harness. |
| Concord Channels (encrypted communities) | ✅ | `ConcordCommands` — 13 sub-verbs (create/list/import/channels/send/read/invite/join/roles/role/grant/ban/unban) over shared `commons` `ConcordActions`; secrets in `concord.json`. |
| NIP-5A nsites + NIP-5D napplets | ✅ | `NsiteCommands` + `NappletCommands` — fetch/publish/serve/list with sha256 + aggregate-hash verification and `requires` capability reporting. |
| Podcasting 2.0 / podstr (`amy podcast20`) | ✅ | `Podcast20Commands` — kind:30078 metadata, 30054 episodes, 30055 trailers, list. |
| Follows-of-follows (`amy fof get/list/sync`) | ✅ | `FofCommand` — single-hop social proof from the local store (`wot` kept as deprecation alias). |
| NIP-85 GrapeRank web-of-trust (`amy graperank`) | ✅ | `GrapeRankCommand` — outbox-model crawl + scoring engine in `commons/wot/` (`GrapeRank`, `TrustGraph`, `TrustGraphBuilder`); every score run persists kind:30382 `ContactCardEvent` cards to the local store (diffed against prior ranks, kind:5 retractions), `publish` mirrors that set to the operator relays via NIP-77 up-sync, `rank` reads cards back, plus `register` / `unregister` / `providers` for the kind:10040 `TrustProviderListEvent` discovery layer. |
| NIP-72 communities | 🆕 | |
| NIP-78 app-specific data (settings sync) | 🆕 | |
| Long-form (NIP-23) publish / read | 🆕 | |
| Live activities / chess (NIP-53 / NIP-64) | 🆕 | |
| Blossom uploads (NIP-B7) | 🆕 | |
| Blossom blobs (NIP-B7) | ✅ | `BlossomCommands` — upload/download/list/delete/check/mirror on shared `commons` `BlossomClient`; live-server harness at `cli/tests/blossom/`. |
| NIP-60 / 61 Cashu wallet + nutzaps | ✅ | Full surface: `cashu wallet {create,show,export-key,destroy}`, `mint {ping,info}`, `balance`, `receive {ln,complete,resume,token,nutzap-sweep}`, `send {ln,token,nutzap}`, `maintenance {scrub,restore,migrate-keysets}`, `mint-rec {show,add,remove}` — all on shared `commons` `CashuWalletOps` + `CashuWalletReader` (the exact path the Android wallet runs). Interop harness pending. Plan: [`cli/plans/2026-05-28-cashu-cli.md`](./plans/2026-05-28-cashu-cli.md). |
| NIP-47 Wallet Connect | 🆕 | |
| NIP-46 bunker signer | 🆕 | Needs a signers abstraction in Amy. |
| NIP-46 bunker signer | ✅ | `BunkerCommand` + `NostrConnect` + `LoginCommand` — host (`amy bunker[ connect]`) and client (`amy login bunker://` / `--nostrconnect`) sides, `--perms`/`--interactive` gating, `auth_url` challenges. |
| Profile view (`amy profile show NPUB`) + edit | ✅ | `ProfileCommands`. Cache-first; `--refresh` forces a relay drain. |
| Thread view (`amy thread show EVENT_ID`) | ⚠️ | Same. |
| Thread view (`amy thread show EVENT_ID`) | 🆕 | No `thread` verb yet — needs the event-renderer read path (Order of operations §1/§3). |
| Notifications feed | 🆕 | |
| Search (NIP-50) | 🆕 | |
| Search (NIP-50) | ✅ | `SearchCommand` — `search user` (kind:0) + `search note` (`--kind`, `--kinds` alias) over the kind:10007 search-relay list; default limit 50. |
| Namecoin NIP-05 resolve (`amy namecoin resolve .bit\|d/\|id/`) | ✅ | `NamecoinCommand` — reuses Quartz `NamecoinNameResolver` + `ElectrumXClient` + the default ElectrumX server set the Android/Desktop apps ship with. Stateless. On-chain `name_history` + Core RPC backend pending separate PRs. |
### `nak` parity — army-knife primitives
@@ -126,15 +133,14 @@ nak has 34 functional commands (introspected from `nak --help`). Coverage:
nak's local hints DB).
- **Missing (6):** `dekey` (NIP-4E), `mcp`, `curl` (NIP-98), `fs` (FUSE),
`spell` (MuSig2/FROST), and `validate` (event-schema validation).
`relaygroup` (NIP-29) now ships alongside MLS/Marmot — the two group models
are offered side by side rather than one substituting for the other.
**Design differences (not gaps):** amy is a *stateful client* (accounts,
`~/.amy/`, shared event store) with a stable JSON contract; nak is a *stateless*
per-invocation tool that prints bare values for shell substitution. amy also has
a large surface nak lacks: Marmot/MLS, NIP-17 DMs, zaps, CLINK offer/debit,
NIP-02 follow, NIP-50 search, napplets, profile edit, store management, account
management.
a large surface nak lacks: Marmot/MLS, NIP-29 relay groups (offered side by
side with MLS, not substituting for it), Concord communities, geochat, NIP-17
DMs, zaps, CLINK offer/debit, NIP-02 follow, NIP-50 search, nsites/napplets,
podcast20, GrapeRank/fof, profile edit, store management, account management.
**Cheap remaining wins:** the `key` `expand` (hex left-pad) and `default`
(print the active account's key) sub-verbs. `key combine` needs MuSig2.
@@ -149,7 +155,7 @@ move anything, re-audit — you're probably duplicating logic.
1. **Event rendering core** in `commons/commonMain/.../rendering/`
with renderers for kinds 0 / 1 / 3 / 6 / 7 / 10002 / 10050.
Unblocks all the 🆕 and ⚠️ read-path rows below.
Unblocks the remaining 🆕 read-path rows (thread view, notifications).
Design: `commons/plans/2026-04-21-event-renderer.md`.
2. **`amy notes post` / `amy notes show` / `amy notes react`** —
smallest end-to-end write+read loop outside Marmot. Post + feed
@@ -157,20 +163,25 @@ move anything, re-audit — you're probably duplicating logic.
3. **`amy notes feed home|profile|hashtag|thread`** reading through the
renderer. `--following` and `--author NPUB` ✅; hashtag/thread
variants still pending.
4. **`amy follow add|remove|list`** (NIP-02) — proves extraction of
list-building logic from `amethyst/model/`.
4. **`amy follow|unfollow`** (NIP-02) — ✅ shipped. A standalone
`follow list` view is still pending.
5. **`amy dm send|list`** (NIP-17) — ✅ shipped. Reuses the gift-wrap
path also exercised by Marmot.
6. **`amy list bookmarks|mute|pin …`** (NIP-51).
7. **`amy zap send|verify`** (NIP-57).
7. **`amy zap send|verify`** (NIP-57) — send ✅ (invoice fetch +
optional CLINK auto-pay via `--with NDEBIT`); receipt verify pending.
8. **Distribution** — Homebrew + Scoop + `.deb` in the same release
pipeline as desktop. Plan: `cli/plans/2026-04-21-cli-distribution.md`.
9. **Test suite** — end-to-end against a local relay. Marmot interop is
covered by `cli/tests/marmot/marmot-interop-headless.sh`; NIP-17 DM
interop between two `amy` clients is covered by
`cli/tests/dm/dm-interop-headless.sh` (text + file + strict 10050 +
fallback + cursor-advance). Neither runs in CI yet (both need Rust +
~3 min cold relay build).
9. **Test suite** — largely in place, two layers:
- **Shell harnesses** under `cli/tests/` — nine suites: `blossom`
(live servers), `cache`, `clink`, `dm`, `marmot` (vs whitenoise-rs),
`nests` (manual audio-rooms matrix), `pow`, `relaygroup`, `sync`,
plus the shared `headless/` helpers. See `cli/tests/README.md`.
None run in CI yet (the relay-backed ones need Rust + a ~3 min
cold `nostr-rs-relay` build).
- **JVM unit suite** at `cli/src/test/kotlin/` — `Args` parsing,
exit-code contract, and `--json` shape tests driving `runCli`
in-process via the `amy.home` isolation seam.
10. **Everything else in the matrix.**
---
+50 -14
View File
@@ -1,13 +1,24 @@
# amy CLI test harnesses
Shell-based end-to-end harnesses that drive the `amy` CLI binary against a
loopback `nostr-rs-relay`. Layout:
Shell-based end-to-end harnesses that drive the `amy` CLI binary — against a
loopback `nostr-rs-relay`, an embedded `amy serve` relay, live public servers,
or no relay at all, depending on the suite. Ten directories:
```
cli/tests/
├── lib.sh # shared logging, results, assertions
├── headless/ # shared bits used by every harness
│ └── helpers.sh
├── blossom/ # Blossom blob lifecycle vs LIVE public servers
│ └── blossom-live.sh
├── cache/ # local-store-as-cache semantics (profile show
│ └── cache-headless.sh # cache/refresh, store stat) vs nostr-rs-relay
├── clink/ # CLINK pointer decode — local-only, no relay
│ └── clink-headless.sh
├── dm/ # NIP-17 DM interop (amy ↔ amy)
│ ├── dm-interop-headless.sh
│ ├── setup.sh # preflight + identities
│ └── tests-dm.sh
├── marmot/ # Marmot / MLS group-messaging interop
│ ├── marmot-interop.sh # interactive — prompts Amethyst Android UI
│ ├── marmot-interop-headless.sh # zero-prompt
@@ -16,19 +27,44 @@ cli/tests/
│ ├── tests-manage.sh # tests 06–08, 11
│ ├── tests-extras.sh # tests 09, 10, 12, 13
│ └── patches/ # whitenoise-rs harness patches
├── dm/ # NIP-17 DM interop (amy ↔ amy)
│ ├── dm-interop-headless.sh
│ ├── setup.sh # preflight + identities
│ └── tests-dm.sh
└── nests/ # Audio-rooms interop (Amethyst ↔ nostrnests.com)
├── nests-interop.sh # 47-test manual harness
└── README.md # operator brief + per-test matrix
├── nests/ # Audio-rooms interop (Amethyst ↔ nostrnests.com)
│ ├── nests-interop.sh # 47-test manual harness
│ └── README.md # operator brief + per-test matrix
├── pow/ # NIP-13 primitives (bench/mine/check) — no relay
│ └── pow-headless.sh
├── relaygroup/ # NIP-29 round-trip vs embedded `amy serve` (geode)
│ └── relaygroup-headless.sh
└── sync/ # NIP-77 deletion propagation vs `amy serve`
└── sync-deletions-headless.sh
```
The CLINK suite is local-only (no relay): `clink/clink-headless.sh` asserts that
`amy offer info` / `amy debit info` decode the canonical interop vectors to the
right fields, plus the argument-error paths. The round-trip verbs (`offer
request`, `debit pay/budget`) need a live CLINK service and aren't covered here.
These shell suites are the *interop* layer. The *contract* net — `Args`
parsing, the exit-code contract (bad_args → 2, timeout → 124), and `--json`
shapes — is the JVM unit suite at `cli/src/test/kotlin/` (`ArgsTest`,
`ExitCodeContractTest`, `JsonContractTest`), which drives `runCli`
in-process with an isolated `~/.amy` via the `amy.home` seam. Run it with
`./gradlew :cli:test` — no relay, no Rust, milliseconds.
Suite notes:
- **`clink/clink-headless.sh`** is local-only (no relay): asserts that
`amy offer info` / `amy debit info` decode the canonical interop vectors
(the same fixtures quartz's `ClinkInteropTest` uses) to the right fields,
plus the argument-error paths. The round-trip verbs (`offer request`,
`debit pay/budget`) need a live CLINK service and aren't covered here.
- **`pow/pow-headless.sh`** is also relay-free: `pow bench` sanity,
`pow mine` hitting its target (and exiting 124 on an impossible one),
and mined-nonce round-trips through `pow check`.
- **`cache/cache-headless.sh`** proves the local store is the source of
truth for reads: `profile show` served from cache vs `--refresh`, and
`store stat` reporting the right histogram, vs a loopback nostr-rs-relay.
- **`relaygroup/relaygroup-headless.sh`** runs NIP-29 create/message/join/
list/browse against an embedded relay (`amy serve`, which boots geode) —
no external relay binary. geode doesn't sign 39000-39003, so browse/info
emptiness is a relay capability, not a client bug.
- **`sync/sync-deletions-headless.sh`** proves NIP-77 deletion propagation
both directions (plus the `--no-sync-deletions` opt-out) against
`amy serve`, with one `$HOME` per account so stores don't share.
The Marmot harnesses come in two flavours, same scenarios:
@@ -95,6 +131,7 @@ the end of the run.
| 11 | Leave group | – |
| 12 | Offline catch-up / replay | – |
| 13 | KeyPackage rotation | – |
| 14 | Push notifications (MIP-05) | opt-in via `--transponder` |
### DM (amy ↔ amy, NIP-17) — `dm/dm-interop-headless.sh`
@@ -120,7 +157,6 @@ pure loopback and isn't matched by that filter. Override with
(`dm send-file --file PATH --server URL`) needs a local Blossom server
and isn't scripted here — the upload classes are unit-tested on desktop
at `desktopApp/src/jvmTest/kotlin/.../service/upload/`.
| 14 | Push notifications (MIP-05) | opt-in via `--transponder` |
## Prerequisites