diff --git a/cli/DEVELOPMENT.md b/cli/DEVELOPMENT.md index 5f9203cd89..058a6591c4 100644 --- a/cli/DEVELOPMENT.md +++ b/cli/DEVELOPMENT.md @@ -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: : ` 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//`; 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 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): Int = - route("note", tail, "note ", mapOf( + route("note", tail, "note ", help = USAGE, routes = mapOf( "publish" to { rest -> publish(dataDir, rest) }, )) private suspend fun publish(dataDir: DataDir, rest: Array): 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_`, `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///… # 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 @@ -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":{"":}} +│ ├── 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 --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. diff --git a/cli/README.md b/cli/README.md index 257e5af825..a013cb0206 100644 --- a/cli/README.md +++ b/cli/README.md @@ -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//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 --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. diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index 217e53814d..07747f6508 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -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.** --- diff --git a/cli/tests/README.md b/cli/tests/README.md index 3b4a882210..3772ca1884 100644 --- a/cli/tests/README.md +++ b/cli/tests/README.md @@ -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