diff --git a/cli/DEVELOPMENT.md b/cli/DEVELOPMENT.md new file mode 100644 index 000000000..bd4ef2e4a --- /dev/null +++ b/cli/DEVELOPMENT.md @@ -0,0 +1,388 @@ +# Developing Amy + +This doc is the north-star plan for growing `amy` from a Marmot +test-bed into a full command-line mirror of Amethyst. It is meant to +be picked up by any future contributor (human or agent) who needs to +add a feature, extract logic out of `amethyst/`, or close an interop +gap. + +User-facing reference lives in [README.md](./README.md). This file +covers **why** things are shaped the way they are and **what to build +next**. + +--- + +## 1. North-star goal + +> For every feature of the Amethyst Android app, there is a way to +> exercise it through `amy`, with byte-identical on-relay behaviour. + +This matters because: + +- Interop tests against the ~100 other Nostr clients need a reproducible + harness that does not require running Android. +- Agents and LLMs can script real Amethyst flows without touching a GUI. +- Regressions in shared logic (signing, encryption, filter building, + event parsing) become shell-scriptable. +- Power users get a command-line Amethyst for free. + +The non-goal: Amy is **not** a second Nostr client implementation. It +is a thin assembly layer over `quartz` + `commons`. If you find yourself +writing protocol or business logic inside `cli/`, stop — that code +belongs in `commons/` or `quartz/`. + +--- + +## 2. Design principles + +1. **Non-interactive.** One verb = one JSON object on stdout = one exit + code. No REPL, no daemon, no prompts. +2. **Thin command layer.** Each file in `cli/src/.../commands/` is glue: + parse args → call into `commons/` or `quartz/` → print JSON. A file + longer than ~200 lines is a code smell; the logic is probably living + in the wrong module. +3. **Everything persistent is on-disk.** No in-memory caches that + survive between invocations. Cursors, MLS state, identity, relay + config — all reload on every run. This is what makes `amy` safe to + run from CI and from 100 parallel interop scenarios. +4. **Shared defaults.** When the Android app sets a default relay, + picks a kind, derives a tag — Amy calls the same helper. No hand- + rolled duplicates. If such a helper doesn't exist yet, the fix is to + extract it to `commons/`, not to copy its body into `cli/`. +5. **JSON is the public API.** Output shape changes are breaking + changes. Treat them as such, version them explicitly in commit + messages, and update interop fixtures. + +--- + +## 3. Current architecture + +``` +cli/ +└── src/main/kotlin/com/vitorpamplona/amethyst/cli/ + ├── Main.kt # argv → subcommand dispatch + ├── Args.kt # tiny flag parser (no framework) + ├── Json.kt # single-line stdout + error printer + ├── Config.kt # Identity, RelayConfig, RunState, DataDir + ├── Context.kt # per-run wiring (signer + NostrClient + + │ # MarmotManager + publish/drain/sync helpers) + ├── stores/FileStores.kt # File-backed MLS / KP / message stores + └── commands/ + ├── Commands.kt # dispatcher + ├── 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 + ├── KeyPackageCommands.kt # marmot key-package publish / check + ├── GroupCommands.kt # marmot group create/list/show/… + ├── GroupCreateCommand.kt + ├── GroupReadCommands.kt + ├── GroupAddMemberCommand.kt + ├── GroupMembershipCommands.kt + ├── GroupMetadataCommands.kt + ├── MessageCommands.kt # marmot message send / list + └── AwaitCommands.kt # poll-until-condition helpers +``` + +**Dependencies:** `:quartz` + `:commons` + kotlinx-coroutines + OkHttp + +Jackson. No Android, no Compose. This is intentional — `amy` compiles +on any JDK 21 target, including CI runners and headless interop hosts. + +**Context.kt is the backbone.** Most commands follow this template: + +```kotlin +val ctx = Context.open(dataDir) +try { + ctx.prepare() // restore MLS state + connect relays + ctx.syncIncoming() // pull new gift-wraps + group events + // ...call into commons/ or quartz/ to build an event... + val ack = ctx.publish(event, targets) + Json.writeLine(mapOf(...)) +} finally { + ctx.close() // flush RunState, disconnect +} +``` + +Keep new commands in that shape. + +--- + +## 4. Current surface vs. Amethyst surface + +What Amy can drive today (✅), what's clearly gap (🆕), and where the +logic already lives in `commons/` ready to be called (📦). + +| Area | Status | Notes | +|---|---|---| +| Identity create / import (`nsec`, `ncryptsec`, mnemonic, `npub`, `nprofile`, hex, NIP-05) | ✅ | `LoginCommand` + Quartz NIP-05 / NIP-06 / NIP-49 | +| Account bootstrap (nine events) | ✅ | `commons/account/AccountBootstrapEvents.kt` | +| Relay config + NIP-65 / NIP-10050 publish | ✅ | `RelayCommands` | +| MLS KeyPackage publish + fetch | ✅ | `commons/marmot/MarmotManager` | +| Marmot group create / add / rename / promote / demote / remove / leave | ✅ | `commons/marmot/` | +| Marmot message send / list | ✅ | `commons/marmot/` | +| `await` polling for KP / group / member / admin / message / rename / epoch | ✅ | `AwaitCommands` | +| NIP-01 note publish (`amy note publish TEXT`) | 🆕 | Needs a `commons/` builder wrapper. | +| NIP-01 feed read (`amy feed home`, `amy feed hashtag #X`, `amy feed profile NPUB`) | 🆕 | Extract `FeedFilter` usage from `amethyst/ui/dal/` into `commons/` entry points. | +| NIP-02 follow list add / remove / list | 🆕 | Logic in `amethyst/model/nip02FollowLists/`. | +| NIP-09 event deletion | 🆕 | Builder exists in quartz. | +| NIP-17 DMs send / list / read | 🆕 | Gift-wrap path is already in `commons/` via Marmot — generalise. | +| NIP-18 reposts / quotes | 🆕 | | +| NIP-25 reactions | 🆕 | | +| 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-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) | 🆕 | | +| NIP-47 Wallet Connect | 🆕 | | +| NIP-46 bunker signer | 🆕 | Would need a `signers` abstraction in Amy. | +| Profile view (`amy profile show NPUB`) | 🆕 | Renderer work — see §6. | +| Thread view (`amy thread show EVENT_ID`) | 🆕 | | +| Notifications feed | 🆕 | | +| Search (NIP-50) | 🆕 | | + +Treat this as a live checklist — update it in the same PR that closes +a gap. + +--- + +## 5. How to add a command + +Rule of thumb: **no new logic in `cli/`**. Every command is an +assembly of things that already work elsewhere. Follow these steps. + +### 5.1. Audit (mandatory) + +Before you write anything, answer three questions: + +1. Is the Nostr-protocol piece (event kind, tags, encryption) already + in `quartz/`? If not, add it there first. +2. Is the business logic (state, default values, ordering, filter + assembly) already in `commons/`? If not, **extract it from + `amethyst/` into `commons/`** in a preceding commit. +3. What is the smallest signed event or query this command has to + produce? Write that down — it is the JSON your command will echo. + +If `commons/` doesn't have the helper you need, see §5.2 before +touching `cli/`. + +### 5.2. Extract-from-Android checklist + +This is the single most important recurring task for Amy's growth. +Most Amethyst features today live in `amethyst/src/main/java/…/model/` +or `…/service/` with Android-only imports (Context, SharedPreferences, +WorkManager). Amy cannot call those directly — they have to move. + +Extraction recipe: + +1. Identify the class in `amethyst/` (e.g. `ReactionPost.kt`). +2. List its Android dependencies. The usual offenders: `Context`, + `SharedPreferences`, `WorkManager`, `Log`, `Bitmap`. +3. For each one, choose: + - **Inline-able** (one call, trivial): delete it. + - **Platform-abstractable**: add an `expect`/`actual` in + `commons/commonMain/…` + `commons/androidMain/…` + `commons/jvmMain/…`. + (See `kotlin-multiplatform` skill.) + - **Inversion-of-control**: take it as a constructor arg. Amy + supplies a JVM flavour. +4. Move the file to `commons/commonMain/…`. +5. Update the Android caller to use the new location. Add a JVM test. +6. Only now, add the `cli/commands/…` file that calls it. + +**What to keep in `amethyst/`:** screens, navigation, Android-specific +side-effects (notifications, background services, camera, Intents). +Everything else is a candidate to move. + +### 5.3. Command file template + +```kotlin +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 + +object NoteCommands { + suspend fun dispatch(dataDir: DataDir, tail: Array): Int { + if (tail.isEmpty()) return Json.error("bad_args", "note ") + val rest = tail.drop(1).toTypedArray() + return when (tail[0]) { + "publish" -> publish(dataDir, rest) + else -> Json.error("bad_args", "note ${tail[0]}") + } + } + + private suspend fun publish(dataDir: DataDir, rest: Array): Int { + val args = Args(rest) + val text = args.positional(0, "text") + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val event = com.vitorpamplona.amethyst.commons.note.buildTextNote(ctx.signer, text) + val ack = ctx.publish(event, ctx.outboxRelays()) + Json.writeLine(mapOf( + "event_id" to event.id, + "kind" to event.kind, + "published_to" to ack.filterValues { it }.keys.map { it.url }, + )) + return 0 + } finally { ctx.close() } + } +} +``` + +Then wire it in `Commands.kt` and add a top-level branch in `Main.kt`'s +`dispatch`. Extend `printUsage()`. + +### 5.4. Output-shape conventions + +- Top-level object always. +- Stable snake_case keys. +- Event IDs as hex strings (not npub-style). +- Pubkeys as hex (`"pubkey":…`) **and** bech32 when it's the primary + subject of the output (`"npub":…`). +- Relay URLs as strings, normalized, never objects. +- Lists of events are under a plural key (`"messages"`, `"members"`). +- Errors via `Json.error("code","detail")` — single lower_snake code, + free-form detail. + +--- + +## 6. Rendering Nostr events + +Amy has to render every Nostr kind Amethyst understands, in a way that: + +- a human reading a terminal can parse at a glance, +- an agent can parse mechanically, +- an interop test can diff against a fixture. + +**Plan:** + +1. Build an `EventRenderer` interface in + `commons/commonMain/.../rendering/` with one `render(event, ctx)` + call returning a structured `RenderedEvent` (title, summary, body, + mentions, media refs, etc) — not a string. +2. Register a renderer per kind, keyed by `event.kind`. Default + renderer dumps raw tags + content. Specialised renderers cover the + kinds Amethyst displays specially (kind:0 metadata, kind:1 notes, + kind:6 reposts, kind:7 reactions, kind:9/445 Marmot, kind:1059 + gift-wraps once unwrapped, kind:10002, kind:30023, kind:30043, + kind:30311 live activities, etc). +3. Reuse these renderers from the Desktop app too — the `RenderedEvent` + structure is the input to Compose views and to Amy's JSON, via two + different formatters. +4. Amy's formatter serialises `RenderedEvent` to JSON with a stable + schema. A second formatter produces a human pretty-print (`amy note + show EID --format text`). + +This is the single biggest cross-cutting work item. It benefits the +Desktop app and Amy simultaneously, and it unlocks a huge chunk of the +"🆕" rows in §4 cheaply, because once the renderer exists, a feed +command is just "query, render each, emit list". + +--- + +## 7. Testing Amy + +Most of what Amy does is exercised by tests in `quartz` and `commons` +already — the protocol, the builders, the state machines. The thin +Amy-specific layer still needs its own coverage: + +| Layer | Test approach | +|---|---| +| Argument parsing (`Args`, flag forms, `--data-dir=…` vs `--data-dir …`) | Plain JVM unit tests in `cli/src/test/kotlin/`. | +| Error / exit-code contract (bad args → 2, await timeout → 124, runtime → 1) | Table-driven unit tests invoking `main(argv)` with a captured stdout/stderr. | +| JSON output shape (each command's keys and types) | Snapshot tests: run a command against a throwaway data-dir, assert the emitted JSON matches a golden file. | +| File layout on disk (`identity.json`, `relays.json`, `groups/*.mls`, `keypackages.bundle`) | Structural assertions after running a sequence of commands. | +| Round-trip between two data-dirs on a local ephemeral relay | End-to-end shell tests under `cli/src/test/resources/scripts/`. Spin up a local relay (e.g. `nostr-rs-relay`), run Alice + Bob, assert await verbs resolve. | +| Interop with other clients | A separate harness repo consumes `amy` as a binary; out of scope for this module's own test suite but the JSON contract is what keeps it stable. | + +**What not to test here:** event signing, filter assembly, MLS +correctness, NIP-44 encryption. Those belong in `quartz`/`commons`. +If an Amy bug can only be caught by a test here, it's likely a +contract violation (wrong key name, wrong exit code) rather than a +protocol bug. + +**Setup hooks for a test suite:** +- Add a `testImplementation` block to `cli/build.gradle.kts` + (`kotlin("test")`, `junit5`, `jackson`). +- Launch the CLI via `main(argv)` in-process for fast tests; launch + the built `installDist` launcher for end-to-end tests. +- Use `@TempDir` for the data-dir so tests can run in parallel. + +--- + +## 8. Distribution + +Today Amy runs via `./gradlew :cli:run` or `installDist`. That is fine +for dogfooding but not for the interop-test audience, which needs a +single-binary install on every OS. Target matrix: + +| OS / channel | Strategy | Notes | +|---|---|---| +| macOS (arm64 + x64) | `brew install amy-nostr` | Homebrew formula pointing at a tarball of the `installDist` tree + a native `jlink` runtime. Mirrors `amethyst-nostr` cask. | +| Windows | `winget install VitorPamplona.Amy` + `scoop install amy` | `.zip` with `amy.bat` launcher + jlink runtime. | +| Debian / Ubuntu | `.deb` via `jpackage --type deb` | Depends on libc only; jlink runtime bundled. | +| Fedora / RHEL / openSUSE | `.rpm` via `jpackage --type rpm` | Same. | +| Arch | AUR `amy-nostr-bin` | Wrap the `.tar.gz`. | +| Any Linux | `.tar.gz` + AppImage | AppImage for users without a package manager. | +| Nix / NixOS | `nixpkgs` entry | Straightforward wrapper around `installDist`. | +| Zapstore | `zapstore.yaml` entry (already exists for Amethyst) | Signed by the same Nostr key as the Android app. | +| GitHub Release | Every version ships the above as release assets | Use `scripts/asset-name.sh` for consistent naming. | + +**Shortest path:** extend the existing desktop `jpackage` flow in +`desktopApp/` to also produce an `amy---` artefact. +No native image yet — GraalVM `native-image` is tempting for startup +time but loses FFI to `secp256k1-kmp-jni-*`; revisit once Quartz has a +pure-Kotlin signer fallback. + +**Auto-update:** out of scope for v1. Package managers handle it. + +**Size budget:** target < 80 MB installed with a jlink'd runtime. If +we cross that, audit transitive deps — Amy should not pull in Compose +or Android libs. + +--- + +## 9. Roadmap (order of operations) + +A rough proposed sequencing. Each step is a PR. + +1. **Event rendering core** (`commons/commonMain/.../rendering/`) with + renderers for kind:0 / 1 / 3 / 6 / 7 / 10002 / 10050. Unblocks + everything in §4 marked 🆕. +2. **`amy note publish` / `amy note show` / `amy note react`.** Smallest + possible end-to-end write+read loop outside of Marmot. +3. **`amy feed home|profile|hashtag|thread`** reading through the + renderer. +4. **`amy follow add|remove|list`** (NIP-02) — proves extraction of + list-building logic from `amethyst/model/`. +5. **`amy dm send|list`** (NIP-17) — reuses the gift-wrap path already + exercised by Marmot. +6. **`amy list bookmarks|mute|pin …`** (NIP-51). +7. **`amy zap send|verify`** (NIP-57). +8. **Distribution** — Homebrew + Scoop + `.deb` in the same release + pipeline as desktop. +9. **Test suite** end-to-end against a local relay. +10. **Everything else** in §4. + +Each step should extract at least one file from `amethyst/` into +`commons/`. If a step doesn't move anything, it's probably duplicating +logic — re-audit. + +--- + +## 10. House-keeping + +- Run `./gradlew spotlessApply` before every commit. +- Keep `printUsage()` in `Main.kt` in sync with the command table in + [README.md](./README.md) and §4 above — the three drift apart fast. +- Never add a Gradle dependency on `amethyst/` or `desktopApp/`. If you + need something from there, move it to `commons/`. +- Never introduce a blocking prompt (`readLine()`, interactive + password input). Take it as a flag. +- Keep each command file small. When one grows past ~200 lines, + split it — the Marmot `group` verbs are already a cautionary tale. diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 000000000..4e6724658 --- /dev/null +++ b/cli/README.md @@ -0,0 +1,228 @@ +# Amy — Amethyst CLI + +`amy` is the non-interactive command-line face of Amethyst. It speaks the +same Nostr protocol as the Android and Desktop apps, shares the same +`quartz` and `commons` code, and aims to eventually expose every feature +the GUI offers as a command you can script. + +Amy exists to serve three audiences: + +1. **Humans** who want to use Amethyst from a terminal or remote shell. +2. **Agents / LLMs** that need a deterministic, JSON-typed interface to + drive a Nostr account. +3. **Interop test harnesses** that put Amethyst side-by-side with the + other ~100 Nostr clients publishing and consuming the same events. + Any flow that is tested in the Amethyst app should be reproducible + through `amy` — that's the bar. + +> Today Amy covers identity, relay config, account bootstrap, and +> Marmot / MLS group chat (MIP-00 / NIP-445). Everything else from the +> Android app is on the roadmap — see [DEVELOPMENT.md](./DEVELOPMENT.md). + +--- + +## Design contract + +- **Non-interactive.** One invocation, one job, exits cleanly. No REPL, + no daemon, no hidden prompts. If a subcommand would need to block + waiting for something on the network, it is an explicit `await` verb + with a `--timeout`. +- **stdout is JSON. One line. One object.** Always. Pipe it into `jq`, + parse it from Python, feed it to an agent — the shape is stable. +- **stderr is for humans.** Progress logs, warnings, `[cli] ingest …` + traces all go to stderr and are safe to discard. +- **Exit codes are the real signal.** + - `0` — success + - `1` — runtime error (JSON `{"error":"…","detail":"…"}` on stderr) + - `2` — bad arguments + - `124` — `await` timed out +- **Data-dir is the whole world.** All persisted state (identity, + relays, MLS epochs, message archives, run cursors) lives under + `--data-dir PATH`. Delete it to reset; copy it to move; `AMETHYST_CLI_DATA` + env var overrides the default `./amethyst-cli-data`. + +Those rules are not incidental — they make `amy` cleanly consumable +from CI, from agents, and from other Nostr clients running the same +test matrix. + +--- + +## Install + +Until Amy ships as a signed native binary (see the "Distribution" section +in [DEVELOPMENT.md](./DEVELOPMENT.md)), run it from source: + +```bash +# One-shot run — positional args go after `--args`, quoted as a single string +./gradlew :cli:run --quiet --args="whoami" + +# Or build a runnable distribution and use the generated launch script +./gradlew :cli:installDist +./cli/build/install/amy/bin/amy whoami +``` + +The `installDist` tree under `cli/build/install/amy/` is self-contained +(just a JVM launcher + jars) and is what downstream packaging +(Homebrew formula, `.deb`, `.msi`, etc.) will wrap. + +**Requirements:** JDK 21. + +--- + +## Quick start + +```bash +# 1. Create a data-dir with a full Amethyst-style account. +# Generates a keypair, seeds default NIP-65 / inbox / key-package +# relays, and publishes the nine bootstrap events (profile, contacts, +# relay lists, etc). +amy --data-dir ./alice create --name "Alice" + +# 2. Publish a fresh MLS KeyPackage so other users can invite you. +amy --data-dir ./alice marmot key-package publish + +# 3. Create a group, invite someone, send a message. +amy --data-dir ./alice marmot group create --name "Test Group" +amy --data-dir ./alice marmot group add npub1...bob +amy --data-dir ./alice marmot message send "hello" + +# 4. On the receiving side — poll until Bob sees the invite. +amy --data-dir ./bob marmot await group --name "Test Group" --timeout 60 +amy --data-dir ./bob marmot message list +``` + +Every line above prints a single JSON object on success. Compose them +with `jq` to extract IDs: + +```bash +GID=$(amy --data-dir ./alice marmot group create --name "Test" | jq -r .group_id) +``` + +--- + +## Full command reference + +Run `amy --help` for the canonical list. As of today: + +| Verb | Summary | +|---|---| +| `init [--nsec NSEC]` | Create or import a bare identity. Does not publish anything. | +| `create [--name NAME]` | Provision a full account + publish the nine Amethyst bootstrap events. | +| `login KEY [--password X] [--private]` | Import `nsec` / `ncryptsec` / BIP-39 mnemonic / `npub` / `nprofile` / hex / NIP-05. Read-only when no secret material is supplied. | +| `whoami` | Print the identity stored in `--data-dir`. | +| `relay add URL [--type T]` | `T = nip65 \| inbox \| key_package \| all`. | +| `relay list` | Dump configured relays by bucket. | +| `relay publish-lists` | Publish kind:10002 (NIP-65) + kind:10050 (DM inbox). | +| `marmot key-package publish` | Publish a fresh MLS KeyPackage (kind:30443). | +| `marmot key-package check NPUB` | Fetch someone else's KeyPackage from their advertised relays. | +| `marmot group create [--name NAME]` | New empty group with you as sole admin. | +| `marmot group list` | All groups you're currently a member of. | +| `marmot group show GID` | Full group state (members, admins, epoch, metadata). | +| `marmot group members GID` | Members only. | +| `marmot group admins GID` | Admins only. | +| `marmot group add GID NPUB [NPUB…]` | Fetch KeyPackages for the npubs and commit an add. | +| `marmot group rename GID NAME` | Commit a metadata change. | +| `marmot group promote GID NPUB` | Make an existing member an admin. | +| `marmot group demote GID NPUB` | Revoke admin. | +| `marmot group remove GID NPUB` | Remove a member. | +| `marmot group leave GID` | Self-remove. | +| `marmot message send GID TEXT` | Publish a kind:9 inner event into the group. | +| `marmot message list GID [--limit N]` | Decrypted inner events, oldest first. | +| `marmot await key-package NPUB` | Block until a KeyPackage is seen on relays. | +| `marmot await group --name NAME` | Block until we're added to a group with that name. | +| `marmot await member GID NPUB` | Block until NPUB is in GID's member set. | +| `marmot await admin GID NPUB` | Block until NPUB is an admin of GID. | +| `marmot await message GID --match TEXT` | Block until a message containing `TEXT` lands. | +| `marmot await rename GID --name NAME` | Block until GID's name matches. | +| `marmot await epoch GID --min N` | Block until GID's MLS epoch is ≥ N. | + +All `await` verbs accept `--timeout SECS` (default 30). Timeout exits 124 +so scripts can tell the difference between "condition never happened" +and "the command itself crashed". + +### Global flags + +- `--data-dir PATH` — defaults to `./amethyst-cli-data` or + `$AMETHYST_CLI_DATA`. Always an absolute path after resolution. +- `--help` / `-h` — usage summary. + +--- + +## Data-dir layout + +``` +/ +├── identity.json # nsec/npub/hex — the account +├── relays.json # nip65 / inbox / key_package buckets +├── state.json # sync cursors (giftWrapSince, groupSince) +├── keypackages.bundle # MLS KeyPackage bundles (NostrSignerInternal) +└── groups/ + ├── .mls # MLS group state per group + └── .log # decrypted inner events (one JSON per line) +``` + +All files are plain JSON or framed binary — human-inspectable, easy to +diff across two accounts in a test run. + +--- + +## Use from agents + +Amy is intentionally easy for an LLM to drive: + +1. Every stdout is one JSON object — `jq`-ready, schema-stable. +2. Errors are JSON too, so "did it work?" is a machine question. +3. No interactive prompts — even password input takes `--password`. +4. `await` verbs mean an agent can say "send this, then wait for the + other account to see it" without implementing its own polling. + +Recommended agent loop: + +```text +plan → amy --data-dir A → parse JSON → amy --data-dir B … +``` + +When Amy prints `{"error":"bad_args",…}` or exits 2, the agent should +re-read `--help` rather than retry blindly. + +--- + +## Use from interop tests + +The test goal that drives Amy's roadmap: every canonical Amethyst +user-flow should be expressible as a short shell script that can also +be aimed at any other Nostr client with a similar harness. + +Template: + +```bash +set -euo pipefail +TMP=$(mktemp -d) +A=$TMP/alice; B=$TMP/bob + +amy --data-dir "$A" create --name Alice +amy --data-dir "$B" create --name Bob + +# ... the scenario under test ... + +amy --data-dir "$B" marmot await message "$GID" --match "hello" --timeout 60 +``` + +If an Amethyst scenario cannot be scripted through `amy` yet, that is a +gap to close — see the roadmap in [DEVELOPMENT.md](./DEVELOPMENT.md). + +--- + +## Troubleshooting + +- **`no identity`** — run `init`, `create`, or `login` first, or pass a + different `--data-dir`. +- **`not_member`** — the group GID is unknown to this data-dir. Run + `marmot group list` to confirm, or `marmot await group --name …` to + wait for an invite to arrive. +- **Hang on a network verb** — Amy connects to the relays in + `relays.json`; verify with `amy relay list`. Every network-bound + operation has a timeout — use `--timeout` for `await`, or wrap the + whole command in `timeout(1)` if you're scripting. +- **Nothing seems to publish** — inspect stderr; each publish prints + per-relay `OK` / `REJECT` via the `[cli] ingest …` traces.