From 6ef0c372b1206772de79046ee37afcfc1d2c5777 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Apr 2026 16:35:10 +0000 Subject: [PATCH] docs(cli): make USAGE.md the README; move contract material to DEVELOPMENT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit USAGE.md was the better README — entry-point users want examples and quick start, not the public-API contract. Flip them and refresh the amy-expert skill so it matches the post-refactor reality. cli/README.md (was USAGE.md): - Install, quick start, seven worked examples, full command reference, output modes, multi-account workflows, agent recipes, troubleshooting. - Cross-refs point at DEVELOPMENT.md for the contract / architecture and ROADMAP.md for what's coming. cli/DEVELOPMENT.md absorbs the old README's architecture sections: - New "Public contract" section at the top — the stable promises (text-default + --json contract, stderr for humans, exit codes, ~/.amy/ as the world). - "Local event store" deep-dive with the cache-helper API. - "Relay routing" rules table. - "Full on-disk layout" tree with annotations. cli/ROADMAP.md, cli/USAGE.md: - ROADMAP cross-refs collapsed (no more USAGE.md row). - USAGE.md deleted — content lives in README now. .claude/skills/amy-expert refreshed end-to-end: - SKILL.md description + Rules 2 and 4 rewritten for the dual-output contract (text default, --json opt-in) and the ~/.amy/ layout. - "Where things live" listing matches the current source tree (Output.kt, Aliases.kt, UseCommand.kt, secrets/, all the new command files). - "Common mistakes" lists the new traps: don't read user.home directly, don't add a global flag that collides with subcommand --name, don't use Json.writeLine (it's gone). - references/command-template.md uses Output.emit / Output.error (Json.writeLine / Json.error helpers no longer exist). - references/output-conventions.md rewritten around the dual-mode contract — same JSON shape rules, but framed as "this is what --json emits" rather than "this is stdout." --- .claude/skills/amy-expert/SKILL.md | 143 +++-- .../amy-expert/references/command-template.md | 22 +- .../references/output-conventions.md | 70 ++- cli/DEVELOPMENT.md | 148 ++++- cli/README.md | 545 +++++++++++++----- cli/ROADMAP.md | 5 +- cli/USAGE.md | 431 -------------- 7 files changed, 717 insertions(+), 647 deletions(-) delete mode 100644 cli/USAGE.md diff --git a/.claude/skills/amy-expert/SKILL.md b/.claude/skills/amy-expert/SKILL.md index 1677296d17..c0139959cf 100644 --- a/.claude/skills/amy-expert/SKILL.md +++ b/.claude/skills/amy-expert/SKILL.md @@ -1,6 +1,6 @@ --- name: amy-expert -description: Patterns for extending `amy`, the Amethyst CLI in `cli/`. Use when adding an `amy ` command, touching files under `cli/src/main/kotlin/…/cli/`, wiring a new subcommand into `Main.kt`, writing an interop test script that drives Amy, or extracting logic out of `amethyst/` into `commons/` so a CLI command can call it. Enforces the thin-assembly-layer rule (no Nostr protocol or business logic inside `cli/`), the JSON-output contract (single-line object on stdout, exit codes 0/1/2/124), and the extract-from-Android recipe. Complements `nostr-expert` (protocol in Quartz), `kotlin-multiplatform` (expect/actual for extraction), and `feed-patterns` / `account-state` / `relay-client` (where the business logic should end up). NOT for general Nostr or Kotlin work — those have their own skills. +description: Patterns for extending `amy`, the Amethyst CLI in `cli/`. Use when adding an `amy ` command, touching files under `cli/src/main/kotlin/…/cli/`, wiring a new subcommand into `Main.kt`, writing an interop test script that drives Amy, or extracting logic out of `amethyst/` into `commons/` so a CLI command can call it. Enforces the thin-assembly-layer rule (no Nostr protocol or business logic inside `cli/`), the dual-output contract (text by default, single-line JSON object on stdout under `--json`, exit codes 0/1/2/124), and the extract-from-Android recipe. Complements `nostr-expert` (protocol in Quartz), `kotlin-multiplatform` (expect/actual for extraction), and `feed-patterns` / `account-state` / `relay-client` (where the business logic should end up). NOT for general Nostr or Kotlin work — those have their own skills. --- # Amy CLI Expert @@ -42,17 +42,28 @@ A `commands/*.kt` file longer than ~200 lines is a code smell. Either the command is doing too many things, or the logic has leaked in from where it should have lived. -### Rule 2 — stdout JSON, stderr humans +### Rule 2 — text by default, `--json` is the machine contract -- Every success: one line, one JSON object on stdout, via - `Json.writeLine(mapOf(...))`. -- Every failure: `Json.error("code", "detail")` → single-line JSON - on stderr, non-zero exit. -- Exit codes: `0` success · `1` runtime · `2` bad args · `124` await - timeout. -- Keys are stable, snake_case. Adding a key is safe; renaming or - removing one is a breaking change and needs the commit message to - say so. +amy ships a dual-output contract: + +- **Default stdout is human-readable text.** A YAML-ish render of the + result map. No shape promise — the renderer can change between + releases. +- **`--json` switches stdout to one JSON object, one line.** Stable + snake_case keys; this shape is the public API. +- **stderr is for humans.** Progress logs, warnings, per-relay ACK + traces. Errors go here too — `error: : ` by default, + JSON `{"error":"…","detail":"…"}` under `--json`. +- **Exit codes:** `0` success · `1` runtime · `2` bad args · `124` + await timeout. +- Adding a `--json` key is safe; renaming or removing one is a + breaking change and needs the commit message to say so. + +Commands emit results via `Output.emit(mapOf(...))` and errors via +`Output.error("code", "detail")`. The `Output` object (in +`cli/src/main/kotlin/…/cli/Output.kt`) handles the text-vs-JSON +branching automatically. Never `println(...)` user-facing output +directly — `System.err.println(...)` is fine for progress logs only. See `references/output-conventions.md`. @@ -62,16 +73,37 @@ No `readLine()`, no TTY prompts, no hidden interactive behaviour. Passwords, names, keys, anything — all flags. Any network wait is an explicit `await` verb with `--timeout`. -### Rule 4 — Data-dir is the whole world +### Rule 4 — `~/.amy/` is the whole world -State is reloaded from `--data-dir PATH` on every invocation. No -singletons, no in-process caches that survive across runs. This is -what lets 100 parallel interop scenarios share a harness safely. +State is reloaded from `~/.amy/` on every invocation. No singletons, +no in-process caches that survive across runs. This is what lets 100 +parallel interop scenarios share a harness safely. -Files live in well-known locations — see -`cli/README.md § Data-dir layout`. Don't add unstructured state; if -you need new persisted state, add it to `Config.kt` or -`stores/FileStores.kt` with a named JSON schema. +The layout: + +- `~/.amy/shared/events-store/` — one file-backed Nostr event store + per machine, shared across every account. +- `~/.amy//` — per-account dir: `identity.json`, + `state.json`, `aliases.json`, `marmot/`. +- `~/.amy/current` — marker file written by `amy use NAME` to pin + the active account. + +Account selection is via the global `--account NAME` flag (required +when more than one account exists; auto-picked when exactly one +does). `--account` cannot collide with subcommand flags, so commands +like `marmot group create --name "Group"` or `profile edit --name "Alice"` +keep their own `--name` parameter. + +Tests isolate by overriding `$HOME` for the amy subprocess +(`HOME=$(mktemp -d) amy --account alice init`). amy reads `$HOME` +directly (not `user.home`, which JDK 21 derives from `getpwuid` and +ignores `$HOME`), so the same convention `git`/`gpg`/`npm`/`ssh` +follow Just Works. + +If you need new persisted state, add it to `Config.kt`, +`stores/FileStores.kt`, or a new helper (e.g. `Aliases.kt`) with a +named JSON schema. Don't smuggle state into `~/.amy/` outside the +documented files. ### Rule 5 — Extract before adding @@ -92,9 +124,9 @@ Full checklist: `references/extraction-recipe.md`. ## Standard command shape Every new command follows the same shape — parse args, open Context, -prepare, call into commons/quartz, publish or drain, emit one JSON -line. The template is in `references/command-template.md`; copy it -rather than re-deriving it. +prepare, call into commons/quartz, publish or drain, emit one result +via `Output.emit`. The template is in `references/command-template.md`; +copy it rather than re-deriving it. Wire-up checklist: 1. New file in `cli/commands/` with the `object` pattern. @@ -107,34 +139,54 @@ Wire-up checklist: 7. If the verb changes observable wire behaviour (a new event kind, a new relay-routing rule, a new JSON discriminator), add a case in the appropriate harness under `cli/tests/` — `cli/tests/marmot/` - for MLS flows, `cli/tests/dm/` for NIP-17, or a new sibling suite - if it's neither. + for MLS flows, `cli/tests/dm/` for NIP-17, `cli/tests/cache/` for + event-store behaviour, or a new sibling suite if it's none. -If you change output shape: note it in the commit message, bump the -example in `README.md`, update any interop fixtures under -`cli/tests/`. +If you change `--json` output shape: note it in the commit message, +bump the example in `cli/README.md`, update any interop fixtures +under `cli/tests/`. ## Where things live ``` cli/ -├── README.md # user-facing: commands, JSON contract, quick start -├── DEVELOPMENT.md # touch-the-code: architecture, conventions, testing +├── README.md # user-facing tour: install, examples, command tables +├── DEVELOPMENT.md # public contract, architecture, design rules, +│ # event-store, relay-routing, full on-disk layout ├── ROADMAP.md # parity matrix + ordered milestones ├── plans/ # dated design docs (use for new subsystems) ├── tests/ # end-to-end shell harnesses against a local relay │ ├── lib.sh # shared logging + result tracking │ ├── headless/ # shared amy wrappers + assertions │ ├── marmot/ # MLS group-messaging interop (vs whitenoise-rs) -│ └── dm/ # NIP-17 DM interop (two amy clients) +│ ├── dm/ # NIP-17 DM interop (two amy clients) +│ └── cache/ # FsEventStore behaviour vs the cache helpers └── src/main/kotlin/…/cli/ - ├── Main.kt # argv dispatch + ├── Main.kt # argv dispatch, global flags ├── Args.kt # flag parser - ├── Json.kt # stdout/stderr JSON - ├── Config.kt # Identity, RelayConfig, RunState, DataDir + ├── Output.kt # text/json mode emitter + colour + ├── Aliases.kt # per-account aliases.json read/write + ├── Config.kt # Identity, RunState, DataDir (~/.amy layout) ├── Context.kt # per-run wiring — the backbone - ├── stores/ # file-backed persistence - └── commands/ # one file per top-level verb group + ├── SecureFileIO.kt # 0600/0700 atomic writes, perm tighten + ├── stores/ # file-backed MLS / KP / message stores + ├── secrets/ # SecretStore backends (keychain / ncryptsec / plaintext) + └── commands/ # one file (or group) per top-level verb + ├── UseCommand.kt # `amy use NAME` + ├── InitCommands.kt # init, whoami + ├── CreateCommand.kt + LoginCommand.kt + ├── RelayCommands.kt + ├── ProfileCommands.kt + ├── NotesCommands.kt + PostCommand.kt + FeedCommand.kt + ├── DmCommands.kt + ├── KeyPackageCommands.kt + ├── GroupCommands.kt + GroupCreateCommand.kt + GroupReadCommands.kt + │ GroupAddMemberCommand.kt + GroupMembershipCommands.kt + │ GroupMetadataCommands.kt + ├── MessageCommands.kt + ├── MarmotResetCommand.kt + ├── AwaitCommands.kt + └── StoreCommands.kt ``` Shared logic consumed by Amy lives in `commons/`: @@ -146,9 +198,10 @@ Shared logic consumed by Amy lives in `commons/`: ## Common mistakes to refuse - **Adding protocol logic to `cli/`.** Push back, offer to extract. -- **Silently changing a JSON key.** Flag as breaking. -- **Using `println` or `print`.** Use `Json.writeLine` / `Json.error`. - Plain `System.err.println` is fine for progress logs but never for +- **Silently changing a `--json` key.** Flag as breaking. +- **Using `println` or `print` for command output.** Use + `Output.emit(...)` / `Output.error(...)`. Plain + `System.err.println` is fine for progress logs but never for user-consumable output. - **`runBlocking` inside a command** — the top-level `main` already does that. Commands are `suspend fun`. @@ -158,6 +211,13 @@ Shared logic consumed by Amy lives in `commons/`: or `resolveUserHexOrNull` in `quartz/nip05DnsIdentifiers/`. - **Re-inventing publish-and-confirm.** Use `Context.publish`. - **Re-inventing one-shot subscription.** Use `Context.drain`. +- **Reading `user.home` directly.** Use `DataDir.DEFAULT_ROOT`, which + reads `$HOME` (the convention `git`/`gpg`/`npm` follow); JDK 21's + `user.home` is derived from `getpwuid` and ignores `$HOME`, which + silently breaks the test-isolation pattern. +- **Adding a global flag that collides with subcommand flags.** + `--name` is reserved for subcommand use (group/profile names). + Account selection is `--account`. ## Plans & design docs @@ -171,9 +231,10 @@ frozen. ## Cross-references -- [`cli/README.md`](../../../cli/README.md) -- [`cli/DEVELOPMENT.md`](../../../cli/DEVELOPMENT.md) -- [`cli/ROADMAP.md`](../../../cli/ROADMAP.md) +- [`cli/README.md`](../../../cli/README.md) — user-facing tour +- [`cli/DEVELOPMENT.md`](../../../cli/DEVELOPMENT.md) — public + contract, architecture, on-disk layout +- [`cli/ROADMAP.md`](../../../cli/ROADMAP.md) — parity matrix - `references/command-template.md` - `references/extraction-recipe.md` - `references/output-conventions.md` diff --git a/.claude/skills/amy-expert/references/command-template.md b/.claude/skills/amy-expert/references/command-template.md index 3d46073a28..b2adc99a75 100644 --- a/.claude/skills/amy-expert/references/command-template.md +++ b/.claude/skills/amy-expert/references/command-template.md @@ -11,7 +11,7 @@ package com.vitorpamplona.amethyst.cli.commands import com.vitorpamplona.amethyst.cli.Args import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.DataDir -import com.vitorpamplona.amethyst.cli.Json +import com.vitorpamplona.amethyst.cli.Output object NotePublishCommand { suspend fun run(dataDir: DataDir, rest: Array): Int { @@ -26,7 +26,7 @@ object NotePublishCommand { .buildTextNote(ctx.signer, text) val ack = ctx.publish(event, ctx.outboxRelays()) - Json.writeLine(mapOf( + Output.emit(mapOf( "event_id" to event.id, "kind" to event.kind, "published_to" to ack.filterValues { it }.keys.map { it.url }, @@ -40,6 +40,10 @@ object NotePublishCommand { } ``` +`Output.emit(...)` handles the text-vs-JSON mode automatically. The +result map IS the `--json` shape; the human-readable text default is +derived from the same map by `Output.kt`'s renderer. + ## Multi-verb group When a feature has several verbs (`note publish`, `note show`, @@ -48,13 +52,13 @@ When a feature has several verbs (`note publish`, `note show`, ```kotlin object NoteCommands { suspend fun dispatch(dataDir: DataDir, tail: Array): Int { - if (tail.isEmpty()) return Json.error("bad_args", "note ") + if (tail.isEmpty()) return Output.error("bad_args", "note ") val rest = tail.drop(1).toTypedArray() return when (tail[0]) { "publish" -> NotePublishCommand.run(dataDir, rest) "show" -> NoteShowCommand.run(dataDir, rest) "react" -> NoteReactCommand.run(dataDir, rest) - else -> Json.error("bad_args", "note ${tail[0]}") + else -> Output.error("bad_args", "note ${tail[0]}") } } } @@ -85,13 +89,17 @@ For every new command: - No `runBlocking` in a command body — `main()` already does it. - No `println` / `print` for command output — use - `Json.writeLine(...)`. `System.err.println(...)` is fine for - progress logs (they're already disposable). + `Output.emit(...)` / `Output.error(...)`. `System.err.println(...)` + is fine for progress logs (they're already disposable). - No swallowing errors — let exceptions bubble; `main()` translates - them to `{"error":...}` + exit code. + them to `error: …` (text mode) / `{"error":…}` (JSON mode) plus the + right exit code. - No holding a connection open across invocations — every run opens a fresh `Context` and closes it in `finally`. - No blocking reads for user input — take a flag. +- No global flags that collide with subcommand flags. `--name` is + reserved for subcommand use (group/profile name); the global + account selector is `--account`. ## Output-shape rules diff --git a/.claude/skills/amy-expert/references/output-conventions.md b/.claude/skills/amy-expert/references/output-conventions.md index dc812bb33f..815fe1228f 100644 --- a/.claude/skills/amy-expert/references/output-conventions.md +++ b/.claude/skills/amy-expert/references/output-conventions.md @@ -1,25 +1,33 @@ # Output conventions -Amy's JSON contract is its public API. Follow these rules. +amy ships a dual-output contract. Default stdout is human-readable +text (a YAML-ish render of the underlying result map); `--json` flips +stdout to a single JSON object per success. The text shape can drift; +the `--json` shape is the public API. + +Commands always emit via `Output.emit(mapOf(...))`. The map IS the +JSON shape — the renderer in `Output.kt` derives the text from the +same map. Don't write two render paths; write one map and let +`Output` pick. ## Channels -| Stream | What goes here | -|---|---| -| **stdout** | Exactly one JSON object per successful invocation. Nothing else. | -| **stderr** | Human progress logs, warnings, per-relay ACK traces, stack traces, `printUsage()` output. Safe to discard. Not machine-consumed. | +| Stream | Default mode | `--json` mode | +|---|---|---| +| **stdout** | YAML-ish text from `Output.emit(...)` | Exactly one JSON object per successful invocation | +| **stderr** | Human progress logs, warnings, per-relay ACK traces, stack traces, `printUsage()` output, errors as `error: : ` | Same logs, plus errors as `{"error":...,"detail":...}` | If a command needs to emit structured data for machines, it goes on -stdout. If it needs to explain what it's doing to a human watching, -stderr. +stdout and is automatically JSON under `--json`. If it needs to +explain what it's doing to a human watching, stderr. ## Exit codes | Code | Meaning | |---|---| -| `0` | Success. Stdout has a JSON object. | -| `1` | Runtime error. Stderr has `{"error":"...","detail":"..."}`. | -| `2` | Bad arguments. Stderr has a JSON error object and/or usage. | +| `0` | Success. | +| `1` | Runtime error. | +| `2` | Bad arguments. | | `124` | `await` timed out. | Throw the right exception type in commands: @@ -32,7 +40,7 @@ The top-level `main()` in `Main.kt` handles the translation. Don't try-catch at the command level unless you're converting a third-party exception into one of the above. -## Object shape +## `--json` object shape ### Top-level @@ -58,8 +66,9 @@ newline-delimited stream. | Pubkey (primary subject) | hex **and** bech32. Keys: `pubkey` + `npub`. | | Pubkey (secondary reference) | hex only. Key: `pubkey`. | | Relay URL | Normalized string (`wss://…`). Never an object. | -| Timestamps | Unix seconds, integer. Key names end in `_at`. | +| Timestamps | Unix seconds, integer. Key names end in `_at`. The text renderer auto-formats these as `2026-04-25 13:42:11Z (8m ago)`. | | Group ID (Marmot) | Hex string. Key: `group_id`. | +| Byte counts | Integer. Key names end in `_bytes`. The text renderer auto-formats these as `8.7 KiB`. | ### Collections @@ -70,7 +79,8 @@ newline-delimited stream. ### Booleans -- Use `true`/`false`, not `0`/`1`, not `"yes"`. +- Use `true`/`false` in the result map. The text renderer prints them + as `yes`/`no` (green/red); `--json` keeps the literal booleans. - Name keys so `true` is the expected/successful state: `is_member`, `published`, `accepted`. @@ -93,22 +103,40 @@ appear in neither — add `timed_out_on` if you need to surface them. ### Error shape +Default mode (text): + +```text +error: not_member: +``` + +Under `--json`: + ```json -{ "error": "code", "detail": "free-form explanation" } +{ "error": "not_member", "detail": "" } ``` - `error` is a short, stable, lower_snake code. Agents can branch on it. - `detail` is free text — OK to change between versions. -- Common codes today: `bad_args`, `no_identity`, `exists`, `bad_key`, - `not_member`, `timeout`, `runtime`. Reuse before inventing. +- Common codes today: `bad_args`, `no_identity`, `no_account`, + `exists`, `bad_key`, `not_member`, `no_dm_relays`, `timeout`, + `runtime`. Reuse before inventing. + +Use `Output.error("code", "detail")` from commands; it picks the +right channel and format based on the active mode. ## Never -- `println(...)` of anything except `Json.writeLine(...)`. -- Multi-line JSON (pretty-printed). One line, always. -- Mixing stdout lines — one command invocation emits one stdout line. - If you need progress updates, they go on stderr. +- `println(...)` of anything except `Output.emit(...)`. +- `Json.writeLine` / `Json.error` — that helper is gone; use the + `Output` object instead. +- Multi-line JSON (pretty-printed) under `--json`. One line, always. +- Mixing stdout lines — one command invocation emits one stdout line + in `--json` mode. If you need progress updates, they go on stderr. - Machine output to stderr. The whole point is clean separation. - Silent fallbacks — if a relay rejects your publish, say so in the - JSON. + result map. +- Building text rendering by hand. Trust the `Output.kt` renderer: + it handles alignment, colour, byte/timestamp formatting, nested + maps and lists. If you need a bespoke render for one command, + pass a custom render lambda — don't go around `Output`. diff --git a/cli/DEVELOPMENT.md b/cli/DEVELOPMENT.md index 7eda474239..00fd9a4bf9 100644 --- a/cli/DEVELOPMENT.md +++ b/cli/DEVELOPMENT.md @@ -2,8 +2,7 @@ How to touch the `cli/` module without breaking its public contract. -- What Amy is and the public contract: [README.md](./README.md). -- How to use it: [USAGE.md](./USAGE.md). +- What Amy is + how to use it: [README.md](./README.md). - What to build next and in what order: [ROADMAP.md](./ROADMAP.md). - Plans for cross-cutting work: see this module's `plans/` folder and `commons/plans/` for shared-code work consumed by Amy. @@ -15,6 +14,37 @@ or encryption in here, stop — that code belongs in `quartz/` or --- +## Public contract + +What every caller — user, script, agent, CI — can rely on: + +- **Default stdout is human-readable text.** A YAML-ish render of the + underlying result map. Friendly at a terminal; no shape promises. +- **`--json` is the machine contract. One line. One object.** Stable + snake_case keys. Pipe it into `jq`, parse it from Python, hand it to + an agent. Pass `--json` anywhere before the subcommand. +- **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.** + - `0` — success + - `1` — runtime error + - `2` — bad arguments + - `124` — `await` timed out +- **No interactive prompts, ever.** Passwords, names, keys — all flags. +- **`~/.amy/` is the whole world.** Per-account dirs hold identity, + cursors, MLS state, and aliases at `~/.amy//`; 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. + +Only the `--json` shape and the exit codes are public API. The default +text format is allowed to change between releases. The five design +principles below are how we keep that promise. + +--- + ## Design principles 1. **Non-interactive.** One verb = one result on stdout = one exit @@ -179,7 +209,7 @@ object NoteCommands { Wire it into `Commands.kt`, add a top-level branch in `Main.kt`'s `dispatch`, and extend `printUsage()`. Keep the command tour in -[USAGE.md](./USAGE.md) and the parity matrix in +[README.md](./README.md) and the parity matrix in [ROADMAP.md](./ROADMAP.md) in sync. ### 4. Output-shape conventions @@ -244,11 +274,121 @@ 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: + +- events received from any relay subscription (`amy notes feed`, + `amy dm list`, `amy marmot key-package publish`, group sync, …), +- events amy generates and publishes itself, +- inner events unwrapped from NIP-59 gift wraps. + +Malformed events are dropped before reaching command code. Persistence +is best-effort — if the store fails (full disk, permissions), the relay +subscription still works, but the event is not cached. + +The store is the authoritative cache of everything amy has seen: +profile metadata, relay lists (NIP-65 and NIP-02), gift wraps, group +events, follow lists, etc. Commands that need any of these read from +the store first and only fall back to a relay fetch on miss. Three +convenience helpers exist on `Context`: + +```kotlin +ctx.profileOf(pubKey) // latest kind:0 (NIP-01) +ctx.relaysOf(pubKey) // latest kind:10002 (NIP-65) +ctx.contactsOf(pubKey) // latest kind:3 (NIP-02) +ctx.dmInboxOf(pubKey) // latest kind:10050 (NIP-17 DM inbox) +ctx.keyPackageRelaysOf(pubKey) // latest kind:10051 (MIP-00 KP relays) +ctx.cachedRelayListsOf(pubKey) // RecipientRelayFetcher.Lists from cache +``` + +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`. + +--- + +## Relay routing + +amy follows the Marmot protocol's per-event routing rules so two users +with completely disjoint relay configurations can still marmot each +other. No event ever ships blindly to "our configured relays" — amy +looks up the right relay set per event per recipient. + +| Event | Publish to | Fetch from | +|---|---|---| +| kind:30443 (our own KeyPackage) | `key_package` bucket → NIP-65 outbox → any configured | — | +| kind:30443 (someone else's KeyPackage) | — | Their kind:10051 → their kind:10002 write → our bootstrap pool | +| kind:10051 / 10050 / 10002 (our own lists) | All configured relays (broadcast) | — | +| kind:10051 / 10050 / 10002 (someone else's) | — | Our bootstrap pool = configured relays ∪ Amethyst defaults | +| kind:1059 Welcome gift wrap (kind:444 inside) | Recipient's kind:10050 → their kind:10002 read → `DefaultDMRelayList` → our outbox | — | +| kind:1059 gift wraps addressed to us | — | Our kind:10050 | +| kind:445 Group Event (Commit / Proposal / chat) | Group's MIP-01 `relays` field | Same | + +**Bootstrap pool**: when amy needs to discover a user it's never talked +to, it queries `configured relays ∪ Amethyst's default NIP-65 set ∪ +Amethyst's default DM-inbox set`. These defaults come from +`commons.defaults.AmethystDefaults` and match what the Android/Desktop +UI publishes to on first run, so any fresh Amethyst account is +reachable via the bootstrap pool even before amy has seen any of their +events. + +--- + +## Full on-disk layout + +``` +~/.amy/ ← root, follows $HOME +├── current # marker file written by `amy use NAME` +├── shared/ +│ └── events-store/ # FsEventStore — every observed Nostr event +│ ├── 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 +│ ├── idx/ # hardlink indexes (kind / author / owner / tag / fts / expires_at) +│ └── tombstones/ # NIP-09 / NIP-62 enforcement +├── alice/ # one dir per account (`amy --account alice init`) +│ ├── identity.json # nsec/npub/hex — the account +│ ├── state.json # sync cursors (giftWrapSince, groupSince) +│ ├── aliases.json # local name → npub map (init writes a self-entry) +│ └── marmot/ +│ ├── keypackages.bundle # MLS KeyPackage bundles (NostrSignerInternal) +│ └── groups/ +│ ├── .mls # MLS group state per group +│ └── .log # decrypted inner events (one JSON per line) +└── bob/ ... # additional accounts sit alongside +``` + +All files are plain JSON or framed binary — human-inspectable, easy to +diff across two accounts. Two accounts on the same machine share +`~/.amy/shared/events-store/`, so a public event observed once doesn't +get re-stored per account. + +The local relay configuration (kind:10002 / 10050 / 10051) is **not** a +separate file — it lives in the shared `events-store/` as signed events +owned by the account that wrote them. `amy relay add` builds + signs + +ingests a new relay-list event; `amy relay list` reads URLs straight +out of the latest event for each kind; `amy relay publish-lists` +broadcasts those events to upstream relays. There is no `relays.json`. + +--- + ## Housekeeping - Run `./gradlew spotlessApply` before every commit. - Keep three things in sync: `printUsage()` in `Main.kt`, the command - tour in [USAGE.md](./USAGE.md), and the parity matrix in + 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. diff --git a/cli/README.md b/cli/README.md index 893ff1e899..b90d86d480 100644 --- a/cli/README.md +++ b/cli/README.md @@ -1,176 +1,441 @@ # Amy — Amethyst CLI -`amy` is the non-interactive command-line face of Amethyst. It speaks the -same Nostr protocol as the Android and Desktop apps, shares the same -`quartz` and `commons` code, and aims to eventually expose every feature -the GUI offers as a command you can script. +`amy` is the command-line face of [Amethyst](https://github.com/vitorpamplona/amethyst). +It speaks the same Nostr protocol as the Android and Desktop apps and shares +their codebase. From a terminal you can post notes, send NIP-17 DMs, +manage MLS group chats, switch identities, and pipe machine-readable JSON +into the rest of your toolbox. -Amy exists for three audiences at once: +`amy` is built for three audiences at once: humans at a terminal, +agents/LLMs driving an account through a deterministic JSON interface, +and interop test harnesses pinning Amethyst against the rest of the +Nostr-client ecosystem. -1. **Humans** using Amethyst from a terminal or remote shell. -2. **Agents / LLMs** driving a Nostr account through a deterministic, - JSON-typed interface — no interactive prompts, no screen scraping. -3. **Interop test harnesses** that put Amethyst side-by-side with the - other ~100 Nostr clients publishing and consuming the same events. - Any flow that is tested in the Amethyst app should be reproducible - through `amy` — that's the bar. - -This file documents the **public contract** and the on-disk layout. For -hands-on instructions and worked examples, see -[USAGE.md](./USAGE.md). To extend amy, see -[DEVELOPMENT.md](./DEVELOPMENT.md). For what's coming, see -[ROADMAP.md](./ROADMAP.md). +> **Looking for the architecture and the public-API contract?** See +> [DEVELOPMENT.md](./DEVELOPMENT.md). For what's coming, see +> [ROADMAP.md](./ROADMAP.md). --- -## Output contract +## Install -What every caller — user, script, agent, CI — can rely on: +`amy` builds from this repository — no package manager yet. -- **Default stdout is human-readable text.** A YAML-ish render of the - underlying result map. Friendly at a terminal; no shape promises. -- **`--json` is the machine contract. One line. One object.** Stable - snake_case keys. Pipe it into `jq`, parse it from Python, hand it to - an agent. Pass `--json` anywhere before the subcommand. -- **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.** - - `0` — success - - `1` — runtime error - - `2` — bad arguments - - `124` — `await` timed out -- **No interactive prompts, ever.** Passwords, names, keys — all flags. -- **`~/.amy/` is the whole world.** Per-account dirs hold identity, - cursors, MLS state, and aliases at `~/.amy//`; 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. +```bash +# build the runnable distribution +./gradlew :cli:installDist -Only the `--json` shape and the exit codes are public API. The default -text format is allowed to change between releases. The rationale lives -in [DEVELOPMENT.md](./DEVELOPMENT.md). +# the launch script +./cli/build/install/amy/bin/amy --help ---- - -## Local event store — the source of truth - -Every Nostr event amy observes is verified (NIP-01 id + signature -check) and persisted to a file-backed store at -`~/.amy/shared/events-store/` (one store per machine, shared across -every account in `~/.amy/`). That includes: - -- events received from any relay subscription (`amy notes feed`, - `amy dm list`, `amy marmot key-package publish`, group sync, …), -- events amy generates and publishes itself, -- inner events unwrapped from NIP-59 gift wraps. - -Malformed events are dropped before reaching command code. Persistence -is best-effort — if the store fails (full disk, permissions), the relay -subscription still works, but the event is not cached. - -The store is the authoritative cache of everything amy has seen: -profile metadata, relay lists (NIP-65 and NIP-02), gift wraps, group -events, follow lists, etc. Commands that need any of these read from -the store first and only fall back to a relay fetch on miss. Three -convenience helpers exist on `Context`: - -```kotlin -ctx.profileOf(pubKey) // latest kind:0 (NIP-01) -ctx.relaysOf(pubKey) // latest kind:10002 (NIP-65) -ctx.contactsOf(pubKey) // latest kind:3 (NIP-02) -ctx.dmInboxOf(pubKey) // latest kind:10050 (NIP-17 DM inbox) -ctx.keyPackageRelaysOf(pubKey) // latest kind:10051 (MIP-00 KP relays) -ctx.cachedRelayListsOf(pubKey) // RecipientRelayFetcher.Lists from cache +# put it on your PATH if you want +ln -s "$PWD/cli/build/install/amy/bin/amy" ~/.local/bin/amy ``` -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`. +Requires **JDK 21**. All state lives under `~/.amy/` — delete to reset. --- -## Relay routing +## Quick start -amy follows the Marmot protocol's per-event routing rules so two users -with completely disjoint relay configurations can still marmot each -other. No event ever ships blindly to "our configured relays" — amy -looks up the right relay set per event per recipient. +```bash +# 1. Create an account named alice — keypair, default relays, kind:0 metadata, +# everything Amethyst stamps on first run. +amy --account alice create --name "Alice" -| Event | Publish to | Fetch from | -|---|---|---| -| kind:30443 (our own KeyPackage) | `key_package` bucket → NIP-65 outbox → any configured | — | -| kind:30443 (someone else's KeyPackage) | — | Their kind:10051 → their kind:10002 write → our bootstrap pool | -| kind:10051 / 10050 / 10002 (our own lists) | All configured relays (broadcast) | — | -| kind:10051 / 10050 / 10002 (someone else's) | — | Our bootstrap pool = configured relays ∪ Amethyst defaults | -| kind:1059 Welcome gift wrap (kind:444 inside) | Recipient's kind:10050 → their kind:10002 read → `DefaultDMRelayList` → our outbox | — | -| kind:1059 gift wraps addressed to us | — | Our kind:10050 | -| kind:445 Group Event (Commit / Proposal / chat) | Group's MIP-01 `relays` field | Same | +# 2. With one account you can drop the flag from now on (auto-pick). +amy whoami -**Bootstrap pool**: when amy needs to discover a user it's never talked -to, it queries `configured relays ∪ Amethyst's default NIP-65 set ∪ -Amethyst's default DM-inbox set`. These defaults come from -`commons.defaults.AmethystDefaults` and match what the Android/Desktop -UI publishes to on first run, so any fresh Amethyst account is -reachable via the bootstrap pool even before amy has seen any of their -events. +# 3. Post a short note. +amy notes post "hello from amy" + +# 4. Send a NIP-17 DM. +amy dm send bob@example.com "hey" + +# 5. Read your inbox. +amy dm list +``` + +That's the full loop. Add `--json` to any command if you want a single-line +JSON object instead of human-readable text — same data, machine shape. --- -## On-disk layout +## Examples + +### 1. Post a note + +```text +$ amy notes post "good morning nostr" + +event_id: a3c1f9c2…(64 hex) +kind: 1 +accepted_by: + - wss://relay.damus.io/ + - wss://nos.lol/ +rejected_by: (none) +``` + +`amy notes feed` reads recent kind:1 notes from your follows; `--limit N` +caps the count, `--author npub1…` narrows to one user. + +### 2. Send a direct message + +```text +$ amy dm send npub1uu8m… "lunch friday?" + +event_id: 18bd0a7e… +kind: 14 +recipients: + - pubkey: e70fb804… + relay_source: kind_10050 + relays: + - wss://nostr.wine/ +``` + +`recipients[*].relay_source` tells you how amy resolved the recipient's +inbox — `kind_10050` is the strict NIP-17 inbox; `nip65_read` / +`bootstrap` only fire when you pass `--allow-fallback`. + +### 3. Read a DM thread + +```text +$ amy dm list --peer npub1uu8m… --limit 5 + +messages: + - event_id: a82f04e1… + author: 71cf3ab2… + type: text + created_at: 2026-04-25 13:42:11Z (8m ago) + content: sounds good + - event_id: 18bd0a7e… + author: e70fb804… + type: text + created_at: 2026-04-25 13:30:02Z (20m ago) + content: lunch friday? +``` + +`amy dm await --peer NPUB --match TEXT --timeout 60` blocks until a matching +DM arrives — useful in scripts. + +### 4. View a profile + +```text +$ amy profile show npub1th9z… + +pubkey: 5dca27ae… +found: yes +source: cache +event_id: a041df5a… +created_at: 2026-04-25 13:36:23Z (1h ago) +metadata: + name: Alice + picture: https://example.test/a.png + about: demo identity + nip05: alice@example.test +queried_relays: (none) +``` + +`source: cache` means the local store served the lookup; pass `--refresh` to +force a relay round-trip. Profiles for `name@domain.tld` (NIP-05) are +resolved transparently. + +### 5. Create a group, invite someone, send a message + +```bash +# Mint a group and invite Bob. +GID=$(amy --json marmot group create --name "Lunch Plans" | jq -r .group_id) +amy marmot group add "$GID" npub1...bob + +# Send an MLS-encrypted message. +amy marmot message send "$GID" "hello group" +``` + +On the other side: + +```bash +# Bob waits for the invite to land, then sees the message. +amy --account bob marmot await group --name "Lunch Plans" --timeout 60 +amy --account bob marmot message list "$GID" +``` + +### 6. Switch between accounts + +```text +$ amy whoami +error: bad_args: multiple accounts in /home/me/.amy (alice, bob); pick one with --account or `amy use ` + +$ amy use bob +current: bob +root: /home/me/.amy + +$ amy whoami +name: bob +npub: npub1uu8m… +data_dir: /home/me/.amy/bob +``` + +`amy use --clear` removes the pin; `amy --account alice ` overrides +it for one command. + +### 7. Add a relay + +```text +$ amy relay add wss://nostr.wine + +url: wss://nostr.wine +added_to: + - nip65 + - inbox + - key_package +already_present: (none) + +$ amy relay publish-lists # broadcast updated kind:10002/10050/10051 +``` + +--- + +## Commands + +### Identity + +| Command | What it does | +|---|---| +| `amy --account NAME init [--nsec NSEC]` | Create or import a bare keypair. No relay traffic. | +| `amy --account NAME create [--name X]` | Full Amethyst-style bootstrap: keypair, default relays, kind:0, kind:3, the works. | +| `amy login KEY [--password X]` | Import an existing identity (`nsec`/`ncryptsec`/mnemonic/`npub`/`nprofile`/hex/NIP-05). | +| `amy whoami` | Print the active account's name + npub. | +| `amy use NAME` / `--clear` / no-arg | Pin / clear / inspect the active account. | + +### Social + +| Command | What it does | +|---|---| +| `amy notes post TEXT [--relay URL]` | Publish a kind:1 short text note. | +| `amy notes feed [--author USER \| --following] [--limit N]` | Read recent kind:1 notes (yours, one user's, or your follow set). | +| `amy profile show [USER]` | Print kind:0 metadata. USER accepts npub/nprofile/hex/NIP-05; defaults to self. | +| `amy profile edit --name … --about … --picture URL …` | Patch and re-publish your kind:0. | + +### Direct messages (NIP-17) + +| Command | What it does | +|---|---| +| `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. | + +### Groups (Marmot / MLS) + +| Command | What it does | +|---|---| +| `amy marmot key-package publish` | Publish a fresh KeyPackage so others can invite you. | +| `amy marmot key-package check NPUB` | Look up someone else's KeyPackage on relays. | +| `amy marmot group create [--name X]` | New empty group with you as sole admin. | +| `amy marmot group list` | All groups you're a member of. | +| `amy marmot group show GID` | Members, admins, epoch, metadata. | +| `amy marmot group add GID NPUB [NPUB…]` | Fetch KeyPackages and invite. | +| `amy marmot group rename GID NAME` | Commit a metadata change. | +| `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 react GID EVENT_ID EMOJI` | Publish a kind:7 reaction. | +| `amy marmot message delete GID EVENT_ID …` | Publish a kind:5 deletion. | + +### Wait-for-condition (`await`) + +Every `await` verb blocks until the condition holds, then prints the +matching event/state. All accept `--timeout SECS` (default 30); on +timeout the exit code is **124** so scripts can tell "didn't happen" +from "command crashed". + +| Command | Blocks until… | +|---|---| +| `amy marmot await key-package NPUB` | NPUB has a KeyPackage discoverable on their advertised relays. | +| `amy marmot await group --name X` | You've been added to a group with that name. | +| `amy marmot await member GID NPUB` | NPUB is in GID's member set. | +| `amy marmot await admin GID NPUB` | NPUB is an admin of GID. | +| `amy marmot await message GID --match TEXT` | A message containing TEXT lands in GID. | +| `amy marmot await rename GID --name X` | GID's name matches X. | +| `amy marmot await epoch GID --min N` | GID's MLS epoch reaches N. | +| `amy dm await --peer NPUB --match TEXT` | A matching DM from NPUB arrives. | + +### Relays + +| Command | What it does | +|---|---| +| `amy relay add URL [--type T]` | Add URL to a bucket: `nip65`, `inbox`, `key_package`, or `all`. | +| `amy relay list` | Print the configured relays per bucket. | +| `amy relay publish-lists` | Broadcast your kind:10002 / 10050 / 10051. | + +### Local store maintenance + +| Command | What it does | +|---|---| +| `amy store stat` | Event count, kind histogram, disk usage, oldest/newest timestamps. | +| `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). | + +--- + +## Output: text by default, JSON on demand + +By default amy writes a YAML-ish, colored, human-readable result to +stdout. Pass `--json` and stdout becomes a single-line JSON object — +same data, stable snake_case keys, ready for `jq`: + +```bash +$ amy --json whoami +{"name":"alice","npub":"npub1th9z…","hex":"5dca27ae…","data_dir":"/home/me/.amy/alice"} + +$ amy --json marmot group create --name "Lunch" | jq -r .group_id +ab12cd34… +``` + +Errors mirror the same rule. Default: + +```text +$ amy marmot group show abc123 +error: not_member: abc123 # exit 1 +``` + +Under `--json` the error goes to stderr as `{"error":"not_member","detail":"abc123"}`. + +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: + +| Code | Meaning | +|---|---| +| 0 | success | +| 1 | runtime error (network, permission, NIP rejection, …) | +| 2 | bad arguments | +| 124 | `await` timed out | + +--- + +## Multi-account workflows + +`amy` is built to host more than one identity per machine. The layout +matches that: ``` -~/.amy/ ← root, follows $HOME -├── current # marker file written by `amy use NAME` +~/.amy/ +├── current # marker: which account `amy use NAME` pinned ├── shared/ -│ └── events-store/ # FsEventStore — every observed Nostr event -│ ├── 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 -│ ├── idx/ # hardlink indexes (kind / author / owner / tag / fts / expires_at) -│ └── tombstones/ # NIP-09 / NIP-62 enforcement -├── alice/ # one dir per account (e.g. created by `amy --account alice init`) -│ ├── identity.json # nsec/npub/hex — the account -│ ├── state.json # sync cursors (giftWrapSince, groupSince) -│ ├── aliases.json # local name → npub map (init writes a self-entry) -│ └── marmot/ -│ ├── keypackages.bundle # MLS KeyPackage bundles (NostrSignerInternal) -│ └── groups/ -│ ├── .mls # MLS group state per group -│ └── .log # decrypted inner events (one JSON per line) -└── bob/ ... # additional accounts sit alongside +│ └── events-store/ # one Nostr event store, shared by every account +├── alice/ +│ ├── identity.json # keypair (or reference to keychain entry) +│ ├── state.json # sync cursors +│ ├── aliases.json # local name → npub map +│ └── marmot/ # MLS state per group +└── bob/ + └── … ``` -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. +**Account selection** when you don't pass `--account`: -The local relay configuration (kind:10002 / 10050 / 10051) is **not** a -separate file — it lives in the shared `events-store/` as signed events -owned by the account that wrote them. `amy relay add` builds + signs + -ingests a new relay-list event; `amy relay list` reads URLs straight -out of the latest event for each kind; `amy relay publish-lists` -broadcasts those events to upstream relays. There is no `relays.json`. +1. If `~/.amy/current` is set, use it. +2. Else if exactly one account exists, use it (silent auto-pick). +3. Else error and list the candidates so you can disambiguate. + +`amy use NAME` writes `~/.amy/current`; `amy use --clear` removes it. +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. + +For the deeper layout (events-store internals, relay-routing rules, the +public-contract guarantees) see [DEVELOPMENT.md](./DEVELOPMENT.md). + +--- + +## For agents and scripts + +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". + +### Recipes + +```bash +# Capture a fresh group's id. +GID=$(amy --json marmot group create --name "ops" | jq -r .group_id) + +# Add several members at once and report which KeyPackages were missing. +amy --json marmot group add "$GID" npub1aaa npub1bbb npub1ccc \ + | jq -r '.added[] | select(.status != "ok") | "missing: \(.pubkey)"' + +# Wait up to 5 minutes for a particular message and capture its event id. +EVT=$(amy --json marmot await message "$GID" --match "deploy starting" --timeout 300 \ + | jq -r .event_id) + +# Run a command per follow. +amy --json notes feed --following --limit 50 \ + | jq -r '.notes[].author' \ + | sort -u \ + | while read -r author; do + amy --json profile show "$author" | jq -r '.metadata.name // "?"' + done +``` + +### Test isolation + +amy reads `$HOME` directly to find `~/.amy/`, so harnesses isolate the +exact same way `git`, `gpg`, `npm`, and `ssh` do — by overriding `$HOME` +for the subprocess: + +```bash +HOME=$(mktemp -d) amy --account alice init +HOME=$(mktemp -d) amy --account alice marmot group create --name "scratch" +``` + +Inside the amy process there's no test mode — it just sees a fresh +`~/.amy/` and behaves like a brand-new install. + +--- + +## Troubleshooting + +- **`no account at ~/.amy`** — you haven't created one yet. Run + `amy --account NAME init` (bare keypair) or `amy --account NAME create` + (full Amethyst-style bootstrap). +- **`multiple accounts in ~/.amy (alice, bob)`** — pin one with + `amy use NAME` or pass `--account NAME` per command. +- **`current pins 'X' but ~/.amy/X doesn't exist`** — the active-account + marker is stale. Rewrite with `amy use OTHER` or `amy use --clear`. +- **`no_dm_relays`** — recipient hasn't published a kind:10050 inbox. + Pass `--allow-fallback` to fall back to their kind:10002 read marker + → bootstrap pool. Or wait for them to publish one. +- **`not_member`** — the group GID is unknown to this account. Run + `amy marmot group list` to see what you're in, or `await group --name X` + to wait for an invite. +- **A network verb hangs** — every network verb has a relay timeout. + Inspect what amy is connecting to with `amy relay list`. Wrap any + command in `timeout(1)` if you're scripting and want a hard ceiling. +- **Nothing seems to publish** — stderr carries `[cli] …` traces with + per-relay `OK` / `REJECT`. Capture with `2> /tmp/amy.log` and grep. --- ## Where to go next -- **[USAGE.md](./USAGE.md)** — install, quick start, worked examples, - the command reference, troubleshooting. - **[DEVELOPMENT.md](./DEVELOPMENT.md)** — design principles, - architecture, how to add a command without breaking the contract. -- **[ROADMAP.md](./ROADMAP.md)** — north-star goal and the parity - matrix tracking what's left to extract from the Android app. + architecture, the public contract, the local event store, relay + routing, full on-disk layout, how to extend amy without breaking it. +- **[ROADMAP.md](./ROADMAP.md)** — north-star goal and the parity matrix + tracking what's left to extract from the Android app. - **[`plans/`](./plans/)** — design docs for cross-cutting work (CLI distribution, file-backed event store, NIP-17 DMs, …). +- **[Nostr NIPs](https://github.com/nostr-protocol/nips)** — the + protocol amy speaks. diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index 907bd95d49..4dd633c015 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -7,9 +7,8 @@ full command-line mirror of Amethyst. feature. Move rows between tables, adjust ordering, add non-goals. This is the single source of truth for "what's left". -- What amy is + the public contract: [README.md](./README.md) -- How to use it (examples + walkthroughs): [USAGE.md](./USAGE.md) -- How to implement an item: [DEVELOPMENT.md](./DEVELOPMENT.md) +- What amy is + how to use it: [README.md](./README.md) +- The public contract + how to extend amy: [DEVELOPMENT.md](./DEVELOPMENT.md) - Ongoing design plans: [plans/](./plans/) - Shared work consumed here: [../commons/plans/](../commons/plans/) diff --git a/cli/USAGE.md b/cli/USAGE.md deleted file mode 100644 index 4d0fd6895a..0000000000 --- a/cli/USAGE.md +++ /dev/null @@ -1,431 +0,0 @@ -# amy — using the Amethyst CLI - -`amy` is the command-line face of [Amethyst](https://github.com/vitorpamplona/amethyst). -It speaks the same Nostr protocol as the Android and Desktop apps and shares -their codebase. From a terminal you can post notes, send NIP-17 DMs, -manage MLS group chats, switch identities, and pipe machine-readable JSON -into the rest of your toolbox. - -> This file is the user-facing tour. For the reference table and the -> on-disk contract see [README.md](./README.md); for the design rules -> see [DEVELOPMENT.md](./DEVELOPMENT.md). - ---- - -## Install - -`amy` builds from this repository — no package manager yet. - -```bash -# build the runnable distribution -./gradlew :cli:installDist - -# the launch script -./cli/build/install/amy/bin/amy --help - -# put it on your PATH if you want -ln -s "$PWD/cli/build/install/amy/bin/amy" ~/.local/bin/amy -``` - -Requires **JDK 21**. All state lives under `~/.amy/` — delete to reset. - ---- - -## Quick start - -```bash -# 1. Create an account named alice — keypair, default relays, kind:0 metadata, -# everything Amethyst stamps on first run. -amy --account alice create --name "Alice" - -# 2. With one account you can drop the flag from now on (auto-pick). -amy whoami - -# 3. Post a short note. -amy notes post "hello from amy" - -# 4. Send a NIP-17 DM. -amy dm send bob@example.com "hey" - -# 5. Read your inbox. -amy dm list -``` - -That's the full loop. Add `--json` to any command if you want a single-line -JSON object instead of human-readable text — same data, machine shape. - ---- - -## Examples - -### 1. Post a note - -```text -$ amy notes post "good morning nostr" - -event_id: a3c1f9c2…(64 hex) -kind: 1 -accepted_by: - - wss://relay.damus.io/ - - wss://nos.lol/ -rejected_by: (none) -``` - -`amy notes feed` reads recent kind:1 notes from your follows; `--limit N` -caps the count, `--author npub1…` narrows to one user. - -### 2. Send a direct message - -```text -$ amy dm send npub1uu8m… "lunch friday?" - -event_id: 18bd0a7e… -kind: 14 -recipients: - - pubkey: e70fb804… - relay_source: kind_10050 - relays: - - wss://nostr.wine/ -``` - -`recipients[*].relay_source` tells you how amy resolved the recipient's -inbox — `kind_10050` is the strict NIP-17 inbox; `nip65_read` / -`bootstrap` only fire when you pass `--allow-fallback`. - -### 3. Read a DM thread - -```text -$ amy dm list --peer npub1uu8m… --limit 5 - -messages: - - event_id: a82f04e1… - author: 71cf3ab2… - type: text - created_at: 2026-04-25 13:42:11Z (8m ago) - content: sounds good - - event_id: 18bd0a7e… - author: e70fb804… - type: text - created_at: 2026-04-25 13:30:02Z (20m ago) - content: lunch friday? -``` - -`amy dm await --peer NPUB --match TEXT --timeout 60` blocks until a matching -DM arrives — useful in scripts. - -### 4. View a profile - -```text -$ amy profile show npub1th9z… - -pubkey: 5dca27ae… -found: yes -source: cache -event_id: a041df5a… -created_at: 2026-04-25 13:36:23Z (1h ago) -metadata: - name: Alice - picture: https://example.test/a.png - about: demo identity - nip05: alice@example.test -queried_relays: (none) -``` - -`source: cache` means the local store served the lookup; pass `--refresh` to -force a relay round-trip. Profiles for `name@domain.tld` (NIP-05) are -resolved transparently. - -### 5. Create a group, invite someone, send a message - -```bash -# Mint a group and invite Bob. -GID=$(amy --json marmot group create --name "Lunch Plans" | jq -r .group_id) -amy marmot group add "$GID" npub1...bob - -# Send an MLS-encrypted message. -amy marmot message send "$GID" "hello group" -``` - -On the other side: - -```bash -# Bob waits for the invite to land, then sees the message. -amy --account bob marmot await group --name "Lunch Plans" --timeout 60 -amy --account bob marmot message list "$GID" -``` - -### 6. Switch between accounts - -```text -$ amy whoami -error: bad_args: multiple accounts in /home/me/.amy (alice, bob); pick one with --account or `amy use ` - -$ amy use bob -current: bob -root: /home/me/.amy - -$ amy whoami -name: bob -npub: npub1uu8m… -data_dir: /home/me/.amy/bob -``` - -`amy use --clear` removes the pin; `amy --account alice ` overrides -it for one command. - -### 7. Add a relay - -```text -$ amy relay add wss://nostr.wine - -url: wss://nostr.wine -added_to: - - nip65 - - inbox - - key_package -already_present: (none) - -$ amy relay publish-lists # broadcast updated kind:10002/10050/10051 -``` - ---- - -## Commands - -### Identity - -| Command | What it does | -|---|---| -| `amy --account NAME init [--nsec NSEC]` | Create or import a bare keypair. No relay traffic. | -| `amy --account NAME create [--name X]` | Full Amethyst-style bootstrap: keypair, default relays, kind:0, kind:3, the works. | -| `amy login KEY [--password X]` | Import an existing identity (`nsec`/`ncryptsec`/mnemonic/`npub`/`nprofile`/hex/NIP-05). | -| `amy whoami` | Print the active account's name + npub. | -| `amy use NAME` / `--clear` / no-arg | Pin / clear / inspect the active account. | - -### Social - -| Command | What it does | -|---|---| -| `amy notes post TEXT [--relay URL]` | Publish a kind:1 short text note. | -| `amy notes feed [--author USER \| --following] [--limit N]` | Read recent kind:1 notes (yours, one user's, or your follow set). | -| `amy profile show [USER]` | Print kind:0 metadata. USER accepts npub/nprofile/hex/NIP-05; defaults to self. | -| `amy profile edit --name … --about … --picture URL …` | Patch and re-publish your kind:0. | - -### Direct messages (NIP-17) - -| Command | What it does | -|---|---| -| `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. | - -### Groups (Marmot / MLS) - -| Command | What it does | -|---|---| -| `amy marmot key-package publish` | Publish a fresh KeyPackage so others can invite you. | -| `amy marmot key-package check NPUB` | Look up someone else's KeyPackage on relays. | -| `amy marmot group create [--name X]` | New empty group with you as sole admin. | -| `amy marmot group list` | All groups you're a member of. | -| `amy marmot group show GID` | Members, admins, epoch, metadata. | -| `amy marmot group add GID NPUB [NPUB…]` | Fetch KeyPackages and invite. | -| `amy marmot group rename GID NAME` | Commit a metadata change. | -| `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 react GID EVENT_ID EMOJI` | Publish a kind:7 reaction. | -| `amy marmot message delete GID EVENT_ID …` | Publish a kind:5 deletion. | - -### Wait-for-condition (`await`) - -Every `await` verb blocks until the condition holds, then prints the -matching event/state. All accept `--timeout SECS` (default 30); on -timeout the exit code is **124** so scripts can tell "didn't happen" -from "command crashed". - -| Command | Blocks until… | -|---|---| -| `amy marmot await key-package NPUB` | NPUB has a KeyPackage discoverable on their advertised relays. | -| `amy marmot await group --name X` | You've been added to a group with that name. | -| `amy marmot await member GID NPUB` | NPUB is in GID's member set. | -| `amy marmot await admin GID NPUB` | NPUB is an admin of GID. | -| `amy marmot await message GID --match TEXT` | A message containing TEXT lands in GID. | -| `amy marmot await rename GID --name X` | GID's name matches X. | -| `amy marmot await epoch GID --min N` | GID's MLS epoch reaches N. | -| `amy dm await --peer NPUB --match TEXT` | A matching DM from NPUB arrives. | - -### Relays - -| Command | What it does | -|---|---| -| `amy relay add URL [--type T]` | Add URL to a bucket: `nip65`, `inbox`, `key_package`, or `all`. | -| `amy relay list` | Print the configured relays per bucket. | -| `amy relay publish-lists` | Broadcast your kind:10002 / 10050 / 10051. | - -### Local store maintenance - -| Command | What it does | -|---|---| -| `amy store stat` | Event count, kind histogram, disk usage, oldest/newest timestamps. | -| `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). | - ---- - -## Output: text by default, JSON on demand - -By default amy writes a YAML-ish, colored, human-readable result to -stdout. Pass `--json` and stdout becomes a single-line JSON object — -same data, stable snake_case keys, ready for `jq`: - -```bash -$ amy --json whoami -{"name":"alice","npub":"npub1th9z…","hex":"5dca27ae…","data_dir":"/home/me/.amy/alice"} - -$ amy --json marmot group create --name "Lunch" | jq -r .group_id -ab12cd34… -``` - -Errors mirror the same rule. Default: - -```text -$ amy marmot group show abc123 -error: not_member: abc123 # exit 1 -``` - -Under `--json` the error goes to stderr as `{"error":"not_member","detail":"abc123"}`. - -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: - -| Code | Meaning | -|---|---| -| 0 | success | -| 1 | runtime error (network, permission, NIP rejection, …) | -| 2 | bad arguments | -| 124 | `await` timed out | - ---- - -## Multi-account workflows - -`amy` is built to host more than one identity per machine. The layout -matches that: - -``` -~/.amy/ -├── current # marker: which account `amy use NAME` pinned -├── shared/ -│ └── events-store/ # one Nostr event store, shared by every account -├── alice/ -│ ├── identity.json # keypair (or reference to keychain entry) -│ ├── state.json # sync cursors -│ ├── aliases.json # local name → npub map -│ └── marmot/ # MLS state per group -└── bob/ - └── … -``` - -**Account selection** when you don't pass `--account`: - -1. If `~/.amy/current` is set, use it. -2. Else if exactly one account exists, use it (silent auto-pick). -3. Else error and list the candidates so you can disambiguate. - -`amy use NAME` writes `~/.amy/current`; `amy use --clear` removes it. -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. - ---- - -## For agents and scripts - -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". - -### Recipes - -```bash -# Capture a fresh group's id. -GID=$(amy --json marmot group create --name "ops" | jq -r .group_id) - -# Add several members at once and report which KeyPackages were missing. -amy --json marmot group add "$GID" npub1aaa npub1bbb npub1ccc \ - | jq -r '.added[] | select(.status != "ok") | "missing: \(.pubkey)"' - -# Wait up to 5 minutes for a particular message and capture its event id. -EVT=$(amy --json marmot await message "$GID" --match "deploy starting" --timeout 300 \ - | jq -r .event_id) - -# Run a command per follow. -amy --json notes feed --following --limit 50 \ - | jq -r '.notes[].author' \ - | sort -u \ - | while read -r author; do - amy --json profile show "$author" | jq -r '.metadata.name // "?"' - done -``` - -### Test isolation - -amy reads `$HOME` directly to find `~/.amy/`, so harnesses isolate the -exact same way `git`, `gpg`, `npm`, and `ssh` do — by overriding `$HOME` -for the subprocess: - -```bash -HOME=$(mktemp -d) amy --account alice init -HOME=$(mktemp -d) amy --account alice marmot group create --name "scratch" -``` - -Inside the amy process there's no test mode — it just sees a fresh -`~/.amy/` and behaves like a brand-new install. - ---- - -## Troubleshooting - -- **`no account at ~/.amy`** — you haven't created one yet. Run - `amy --account NAME init` (bare keypair) or `amy --account NAME create` - (full Amethyst-style bootstrap). -- **`multiple accounts in ~/.amy (alice, bob)`** — pin one with - `amy use NAME` or pass `--account NAME` per command. -- **`current pins 'X' but ~/.amy/X doesn't exist`** — the active-account - marker is stale. Rewrite with `amy use OTHER` or `amy use --clear`. -- **`no_dm_relays`** — recipient hasn't published a kind:10050 inbox. - Pass `--allow-fallback` to fall back to their kind:10002 read marker - → bootstrap pool. Or wait for them to publish one. -- **`not_member`** — the group GID is unknown to this account. Run - `amy marmot group list` to see what you're in, or `await group --name X` - to wait for an invite. -- **A network verb hangs** — every network verb has a relay timeout. - Inspect what amy is connecting to with `amy relay list`. Wrap any - command in `timeout(1)` if you're scripting and want a hard ceiling. -- **Nothing seems to publish** — stderr carries `[cli] …` traces with - per-relay `OK` / `REJECT`. Capture with `2> /tmp/amy.log` and grep. - ---- - -## Where to go next - -- [README.md](./README.md) — full command reference table, on-disk - contract, relay-routing rules. -- [DEVELOPMENT.md](./DEVELOPMENT.md) — extend amy without breaking the - contract. -- [ROADMAP.md](./ROADMAP.md) — what's coming. -- [Nostr NIPs](https://github.com/nostr-protocol/nips) — the protocol - amy speaks.