mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
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:
+174
-71
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user