diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index c255ce1805..aecdccd51e 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -3,12 +3,13 @@ ## Project Overview Amethyst is a Nostr Client for Android that was made for Android-only and has been slowly switching -over to a Kotlin Multiplatform project. This project has 4 main modules: `quartz`, `commons`, -`amethyst` and `desktopApp`. Quartz should contain implementations of Nostr specifications and -utilities to help implement them. Commons stores shared code between Amethyst Android (`amethyst`) -and Amethyst Desktop (`desktopApp`). The Desktop App is designed to be mouse first and so uses a -completely different screen and navigation architecture while sharing the back end components with -the android counterpart. +over to a Kotlin Multiplatform project. This project has 5 main modules: `quartz`, `commons`, +`amethyst`, `desktopApp`, and `cli`. Quartz should contain implementations of Nostr specifications +and utilities to help implement them. Commons stores shared code between Amethyst Android +(`amethyst`) and Amethyst Desktop (`desktopApp`). The Desktop App is designed to be mouse first and +so uses a completely different screen and navigation architecture while sharing the back end +components with the android counterpart. `cli` ships `amy`, a non-interactive JVM command-line +client that drives the same `quartz` + `commons` code — used by humans, agents, and interop tests. ## Architecture @@ -27,6 +28,7 @@ amethyst/ │ └── jvmMain/ # Desktop-specific UI utilities ├── desktopApp/ # Desktop JVM application (layouts, navigation) ├── amethyst/ # Android app (layouts, navigation) +├── cli/ # Amy — non-interactive CLI (JVM only, no Compose) └── ammolite/ # Support module (unused) ``` @@ -34,6 +36,11 @@ amethyst/ - `quartz/` = Nostr business logic, protocol, data (no UI) - `commons/` = Shared UI components, icons, composables, flows and ViewModels - `amethyst/` & `desktopApp/` = Platform-native layouts and navigation +- `cli/` = Thin assembly layer over `quartz/` + `commons/` (no new logic allowed) + +**Plans per module:** design docs for new subsystems live in the owning +module's `plans/YYYY-MM-DD-.md` (e.g. `cli/plans/`, `commons/plans/`). +The global `docs/plans/` folder is frozen — don't add new plans there. ## Tech Stack @@ -66,6 +73,7 @@ Specialized skills provide domain expertise with bundled resources and patterns: | `feed-patterns` | Feeds & DAL | `FeedFilter`, `AdditiveComplexFeedFilter`, `FeedViewModel` family | | `auth-signers` | `NostrSigner` implementations | Local, NIP-46 bunker, NIP-55 Android external signer | | `quartz-integration` | Quartz as an external library | Gradle setup, `NostrClient`, `KeyPair`, for external projects | +| `amy-expert` | Amy CLI (`cli/` module) | Adding `amy ` commands, JSON output contract, extracting logic from `amethyst/` into `commons/` so CLI can call it | | `find-missing-translations` | Utility | Extract untranslated Android strings | | `find-non-lambda-logs` | Utility | Audit Log calls for lambda overloads | diff --git a/.claude/skills/amy-expert/SKILL.md b/.claude/skills/amy-expert/SKILL.md new file mode 100644 index 0000000000..bf423a0250 --- /dev/null +++ b/.claude/skills/amy-expert/SKILL.md @@ -0,0 +1,168 @@ +--- +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. +--- + +# Amy CLI Expert + +Practical patterns for touching the `cli/` module without breaking +its public contract. + +## When to use this skill + +- Adding a new `amy ` subcommand. +- Editing anything under `cli/src/main/kotlin/…/cli/`. +- Writing a shell script or test harness that drives Amy. +- Extracting code out of `amethyst/` so the CLI can call it (this is + the single most common reason an Amy feature request stalls). +- Deciding whether a piece of logic belongs in `cli/` vs `commons/` + vs `quartz/` (answer: almost never `cli/`). + +**Not for:** general Nostr protocol work (`nostr-expert`), general +Kotlin (`kotlin-expert`), Compose UI (`compose-expert`), Android-only +flows (`android-expert`), gradle/build (`gradle-expert`). + +## The rules that matter + +Amy has a small number of hard rules. Any change that breaks them is +a breaking change to the CLI's public API, and breaks the interop- +test harnesses that depend on it. + +### Rule 1 — `cli/` is a thin assembly layer + +No new Nostr protocol, filter assembly, state machines, or encryption +lives in `cli/`. Ever. If you need logic that doesn't exist yet: + +- Protocol piece (event kind, tags, signing)? Add it to `quartz/`. +- Business logic (state, defaults, ordering, filter assembly)? + Add it to `commons/` — extract from `amethyst/` first if needed + (see Rule 5). + +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 + +- 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. + +See `references/output-conventions.md`. + +### Rule 3 — Non-interactive, ever + +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 + +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. + +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. + +### Rule 5 — Extract before adding + +If the command you're about to add needs logic from `amethyst/`, +land the extraction first, in its own commit: + +1. Identify the class in `amethyst/src/main/java/…/`. +2. List its Android-only dependencies (`Context`, `SharedPreferences`, + `WorkManager`, `Log`, `Bitmap`, `Uri`, …). +3. For each, choose: inline, platform-abstract via expect/actual, or + take-as-constructor-arg. +4. Move the file to `commons/commonMain/…`. +5. Update the Android caller to use the new location. Add a JVM test. +6. **Then** add the `cli/commands/…` file. + +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. + +Wire-up checklist: +1. New file in `cli/commands/` with the `object` pattern. +2. Add a branch in `Commands.kt`. +3. Add a branch in `Main.kt`'s `dispatch` (or under `marmotDispatch` + / a new group dispatcher). +4. Extend `printUsage()` in `Main.kt`. +5. Add the row to `cli/README.md`'s command table. +6. Update `cli/ROADMAP.md` — move the row from 🆕 / 📦 to ✅. + +If you change output shape: note it in the commit message, bump the +example in `README.md`, update any interop fixtures. + +## Where things live + +``` +cli/ +├── README.md # user-facing: commands, JSON contract, quick start +├── DEVELOPMENT.md # touch-the-code: architecture, conventions, testing +├── ROADMAP.md # parity matrix + ordered milestones +├── plans/ # dated design docs (use for new subsystems) +└── src/main/kotlin/…/cli/ + ├── Main.kt # argv dispatch + ├── Args.kt # flag parser + ├── Json.kt # stdout/stderr JSON + ├── Config.kt # Identity, RelayConfig, RunState, DataDir + ├── Context.kt # per-run wiring — the backbone + ├── stores/ # file-backed persistence + └── commands/ # one file per top-level verb group +``` + +Shared logic consumed by Amy lives in `commons/`: +- `commons/account/` — account bootstrap +- `commons/marmot/` — MLS / group state +- `commons/defaults/` — default relays, kinds +- Consult `commons/plans/` for cross-cutting design work in flight. + +## 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 + user-consumable output. +- **`runBlocking` inside a command** — the top-level `main` already + does that. Commands are `suspend fun`. +- **Depending on `:amethyst` or `:desktopApp`.** Never. If you need + something from there, Rule 5. +- **Re-inventing identifier parsing.** Use `Context.requireUserHex` + or `resolveUserHexOrNull` in `quartz/nip05DnsIdentifiers/`. +- **Re-inventing publish-and-confirm.** Use `Context.publish`. +- **Re-inventing one-shot subscription.** Use `Context.drain`. + +## Plans & design docs + +Cross-cutting design work goes in dated plan docs, in the module +that owns the code being created — not in `docs/plans/`, which is +frozen. + +- `cli/plans/` — Amy-specific subsystems. +- `commons/plans/` — shared code Amy consumes (e.g. + `2026-04-21-event-renderer.md`). + +## Cross-references + +- [`cli/README.md`](../../../cli/README.md) +- [`cli/DEVELOPMENT.md`](../../../cli/DEVELOPMENT.md) +- [`cli/ROADMAP.md`](../../../cli/ROADMAP.md) +- `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 new file mode 100644 index 0000000000..3d46073a28 --- /dev/null +++ b/.claude/skills/amy-expert/references/command-template.md @@ -0,0 +1,98 @@ +# Command-file template + +Copy this shape for every new Amy verb. Resist the urge to deviate — +the uniform shape is what makes commands easy to audit and test. + +## Single-verb command + +```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 NotePublishCommand { + suspend fun run(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 }, + "rejected_by" to ack.filterValues { !it }.keys.map { it.url }, + )) + return 0 + } finally { + ctx.close() + } + } +} +``` + +## Multi-verb group + +When a feature has several verbs (`note publish`, `note show`, +`note react`), group them: + +```kotlin +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" -> NotePublishCommand.run(dataDir, rest) + "show" -> NoteShowCommand.run(dataDir, rest) + "react" -> NoteReactCommand.run(dataDir, rest) + else -> Json.error("bad_args", "note ${tail[0]}") + } + } +} +``` + +Each verb gets its own file. Once a single file crosses ~200 lines, +split it — see `GroupCommands.kt` and its siblings as the reference. + +## Wire-up checklist + +For every new command: + +1. File under `cli/commands/`. +2. Branch in `Commands.kt`: + ```kotlin + suspend fun note(dataDir: DataDir, tail: Array): Int = + NoteCommands.dispatch(dataDir, tail) + ``` +3. Branch in `Main.kt`'s top-level `dispatch`: + ```kotlin + "note" -> Commands.note(dataDir, tail) + ``` +4. Line in `printUsage()` explaining the verb. +5. Row in `cli/README.md`'s command table. +6. Status flip in `cli/ROADMAP.md` (🆕 / 📦 → ✅). + +## What not to do + +- 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). +- No swallowing errors — let exceptions bubble; `main()` translates + them to `{"error":...}` + 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. + +## Output-shape rules + +See `output-conventions.md`. diff --git a/.claude/skills/amy-expert/references/extraction-recipe.md b/.claude/skills/amy-expert/references/extraction-recipe.md new file mode 100644 index 0000000000..2c3b2daa3f --- /dev/null +++ b/.claude/skills/amy-expert/references/extraction-recipe.md @@ -0,0 +1,137 @@ +# Extract-from-Android recipe + +The single most common reason an Amy feature request stalls: the +logic it needs lives in `amethyst/` with Android-only imports. You +cannot call it from `cli/`. You have to move it first. + +This recipe is how. + +## When to extract + +Before writing a new command, ask: + +1. Does the piece of logic I need exist in `quartz/` or `commons/`? + - **Yes** → use it. + - **No, but it's in `amethyst/`** → extract. This file. + - **No, it doesn't exist anywhere** → design it in `commons/` + directly. Write a plan doc under `commons/plans/` if it's a + new subsystem. + +2. Never duplicate `amethyst/` logic into `cli/`. That's a debt you + will pay later when the Android caller drifts. + +## Recipe + +Land this as its own commit, **before** the commit that adds the +CLI command. + +### Step 1 — Find the class + +```bash +grep -rn "fun followUser\|class FollowListManager" amethyst/src/main/java/ +``` + +Identify the minimum unit to move. Sometimes it's a whole file, +sometimes one function. Prefer the smallest unit that makes the +command possible. + +### Step 2 — List Android-only dependencies + +Walk the imports. The usual offenders: + +| Dependency | Treatment | +|---|---| +| `android.content.Context` | Often accidental — inline if only used for logging or preferences. Otherwise, invert as constructor arg. | +| `android.content.SharedPreferences` | Abstract behind an interface in `commons/`; Android actual uses SharedPreferences, JVM actual uses a JSON file. | +| `androidx.work.WorkManager` | Rarely shareable — if the CLI needs it, simplify the flow to not require background scheduling. | +| `android.util.Log` | Replace with `quartz` `PlatformLog` (already multiplatform). | +| `android.graphics.Bitmap` | Almost never needed by Amy. Keep in Android and split the function. | +| `android.net.Uri` | Replace with `kotlinx.io` path types or a plain `String`. | +| `androidx.compose.*` | Must stay out of `commons/commonMain` unless you're in a Compose-Multiplatform module. Amy doesn't depend on Compose. | + +### Step 3 — Pick a migration strategy per dependency + +- **Inline-able.** One call, trivial. Delete it. +- **Platform-abstractable.** Add `expect` in `commons/commonMain/` + + `actual` in `commons/androidMain/` + `actual` in `commons/jvmMain/`. + See `kotlin-multiplatform` skill for the mechanics and for the + `jvmAndroid` source-set pattern used throughout this repo. +- **Inversion-of-control.** Take the Android dependency as a + constructor arg with an interface type. Amy supplies a JVM flavour; + Android supplies the Context-backed one. + +### Step 4 — Move the code + +```bash +# Target location depends on what it is: +# - Protocol → quartz/src/commonMain/kotlin/… +# - Business logic → commons/src/commonMain/kotlin/… +# - UI → commons/src/commonMain/… (needs Compose Multiplatform) +git mv amethyst/src/main/java/com/.../FollowListManager.kt \ + commons/src/commonMain/kotlin/com/.../FollowListManager.kt +``` + +Update package declarations. Run `./gradlew spotlessApply`. + +### Step 5 — Update the Android caller + +The amethyst/ caller now imports from the new location. Often this +is the only code change visible in the Android app. + +If the Android caller was using a concrete Android-backed +dependency, it now supplies that concrete dependency explicitly. + +### Step 6 — Add a JVM test + +In `commons/src/commonTest/kotlin/…` or `commons/src/jvmTest/kotlin/…` +(depending on what the code exercises), add a test that runs on JVM. +This guards against Android-only imports sneaking back in, and it's +the only way to be sure Amy can now call the code. + +### Step 7 — Commit + +Single commit, descriptive: + +> refactor(follow): extract FollowListManager to commons for CLI reuse +> +> Move FollowListManager from amethyst/model/nip02FollowLists/ to +> commons/commonMain/.../followLists/. Android's SharedPreferences +> dependency is inverted behind FollowListStore (interface); Android +> keeps the SharedPreferences-backed actual, new JvmFollowListStore +> writes JSON to disk. No behaviour change on Android. + +### Step 8 — Now add the CLI command + +Separate commit. Follows the pattern in `command-template.md`. + +## Cautionary notes + +- **Don't extract speculatively.** Only extract what the current + command needs. A feature-complete port can happen later; right now + the goal is to unblock one command without adding surface area you + don't have a second caller for. +- **Android is allowed to keep side-effects.** Notifications, + background services, Intents, camera, permissions dialogs — those + stay in `amethyst/`. Amy's job isn't to replicate UX, it's to + exercise the protocol underneath. +- **Check the consumers.** Sometimes the "logic" you want is already + partially in `commons/`, and the `amethyst/` class is just a thin + wrapper. In that case, re-use the `commons/` class directly and + delete the wrapper or keep it if Android genuinely needs it. +- **Tests first if you're nervous.** Copy the existing + `amethyst/`-side test (if any), make it JVM-only by removing + Android imports, and watch it pass after the move. + +## Red flags during extraction + +Stop and reconsider if: + +- The Android class is 1000+ lines. Extract only the piece the CLI + needs; leave the rest for a follow-up. +- You need `Context` in 30 places. It's probably being used as a + grab-bag; sort by actual use (strings, preferences, services, …) + and abstract those individually. +- You find yourself writing `expect class` with a dozen methods. A + fine-grained interface is usually clearer than a monolithic + expect-actual. +- You're about to add a Compose import to `cli/`. Stop. diff --git a/.claude/skills/amy-expert/references/output-conventions.md b/.claude/skills/amy-expert/references/output-conventions.md new file mode 100644 index 0000000000..dc812bb33f --- /dev/null +++ b/.claude/skills/amy-expert/references/output-conventions.md @@ -0,0 +1,114 @@ +# Output conventions + +Amy's JSON contract is its public API. Follow these rules. + +## 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. | + +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. + +## 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. | +| `124` | `await` timed out. | + +Throw the right exception type in commands: + +- `IllegalArgumentException` → exit 2 automatically. +- `AwaitTimeout` → exit 124 automatically. +- Anything else → exit 1. + +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 + +### Top-level + +Always an object. Never an array, never a primitive, never a +newline-delimited stream. + +```json +{ "event_id": "...", "kind": 1, "published_to": [...] } +``` + +### Keys + +- Stable snake_case. +- Additive evolution is safe; renaming or removing a key is a + breaking change. +- Don't nest unnecessarily. `{"data":{...}}` is noise. + +### Identifiers + +| Thing | Form | +|---|---| +| Event ID | 64-char lowercase hex string. Key name: `event_id`. | +| 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`. | +| Group ID (Marmot) | Hex string. Key: `group_id`. | + +### Collections + +- Pluralise: `messages`, `members`, `admins`, `events`. +- Always an array (possibly empty), never `null`. +- Order: oldest-first unless there's a good reason otherwise — state + it in the key name (`messages_newest_first`) if you flip it. + +### Booleans + +- Use `true`/`false`, not `0`/`1`, not `"yes"`. +- Name keys so `true` is the expected/successful state: + `is_member`, `published`, `accepted`. + +### Publish results + +When a command publishes an event, the canonical output shape is: + +```json +{ + "event_id": "", + "kind": 1, + "published_to": ["wss://relay.a/", "wss://relay.b/"], + "rejected_by": ["wss://relay.c/"] +} +``` + +`published_to` is relays that ACK'd `true`. `rejected_by` is relays +that ACK'd `false`. Relays that didn't answer before the timeout +appear in neither — add `timed_out_on` if you need to surface them. + +### Error shape + +```json +{ "error": "code", "detail": "free-form explanation" } +``` + +- `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. + +## 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. +- Machine output to stderr. The whole point is clean separation. +- Silent fallbacks — if a relay rejects your publish, say so in the + JSON. diff --git a/cli/DEVELOPMENT.md b/cli/DEVELOPMENT.md index bd4ef2e4a4..2d5b990c2a 100644 --- a/cli/DEVELOPMENT.md +++ b/cli/DEVELOPMENT.md @@ -1,94 +1,74 @@ # 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. +How to touch the `cli/` module without breaking its public contract. -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**. +- What Amy is and what's already shipped: [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. + +The rule this doc defends: **`cli/` is a thin assembly layer**. If +you're writing Nostr protocol logic, filter building, state machines, +or encryption in here, stop — that code belongs in `quartz/` or +`commons/`. --- -## 1. North-star goal +## Design principles -> 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. +1. **Non-interactive.** One verb = one JSON object on stdout = one + exit code. No REPL, no daemon, no prompts. Any network wait is an + explicit `await` verb with a `--timeout`. +2. **Thin command layer.** Each file in `commands/` parses args, + calls into `commons/` or `quartz/`, and prints JSON. A file longer + than ~200 lines is a code smell — the logic is 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. + survive between invocations. Every run reloads cursors, MLS state, + identity, and relay config. This is what makes Amy safe to run + from CI and from 100 parallel interop scenarios. +4. **Shared defaults.** When Amethyst picks a default relay, kind, or + tag — Amy calls the same helper. No hand-rolled duplicates. If the + helper doesn't exist yet, extract it to `commons/` first. +5. **JSON is the public API.** Output-shape changes are breaking + changes. Version them explicitly in commit messages; update interop + fixtures. --- -## 3. Current architecture +## 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 +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. +**Dependencies:** `:quartz` + `:commons` + kotlinx-coroutines + OkHttp ++ Jackson. **No Android, no Compose.** Amy compiles on any JDK 21 +host. Never add a Gradle dependency on `:amethyst` or `:desktopApp`. -**Context.kt is the backbone.** Most commands follow this template: +**`Context.kt` is the backbone.** Most commands follow this template: ```kotlin val ctx = Context.open(dataDir) @@ -103,88 +83,41 @@ try { } ``` -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 +## 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. +assembly of things that already work elsewhere. -### 5.1. Audit (mandatory) +### 1. Audit (mandatory) -Before you write anything, answer three questions: +Before writing anything, answer three questions: -1. Is the Nostr-protocol piece (event kind, tags, encryption) already - in `quartz/`? If not, add it there first. +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. + assembly) already in `commons/`? If not, extract it from + `amethyst/` into `commons/` in a preceding commit. See the + [extraction recipe](#extract-from-android) below. 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. + produce? That shape is the JSON your command will echo. -If `commons/` doesn't have the helper you need, see §5.2 before -touching `cli/`. +### 2. Extract from Android -### 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: +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`, `Log`, `Bitmap`). Amy cannot call those directly — they +have to move. 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.) +2. List its Android dependencies. +3. For each dependency, choose: + - **Inline-able** (one call, trivial): delete. + - **Platform-abstractable**: add `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/…`. @@ -195,7 +128,7 @@ Extraction recipe: side-effects (notifications, background services, camera, Intents). Everything else is a candidate to move. -### 5.3. Command file template +### 3. Command file template ```kotlin package com.vitorpamplona.amethyst.cli.commands @@ -234,155 +167,73 @@ object NoteCommands { } ``` -Then wire it in `Commands.kt` and add a top-level branch in `Main.kt`'s -`dispatch`. Extend `printUsage()`. +Wire it into `Commands.kt`, add a top-level branch in `Main.kt`'s +`dispatch`, and extend `printUsage()`. Keep [README.md](./README.md)'s +command table and [ROADMAP.md](./ROADMAP.md)'s parity matrix in sync. -### 5.4. Output-shape conventions +### 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":…`). +- Pubkeys as hex (`"pubkey":…`) **and** bech32 when the pubkey is the + primary subject (`"npub":…`). - Relay URLs as strings, normalized, never objects. -- Lists of events are under a plural key (`"messages"`, `"members"`). +- Lists of events under a plural key (`"messages"`, `"members"`). - Errors via `Json.error("code","detail")` — single lower_snake code, free-form detail. --- -## 6. Rendering Nostr events +## Testing -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 +Most of what Amy does is already exercised by tests in `quartz` and +`commons` — 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. | +| Error / exit-code contract (bad args → 2, await timeout → 124, runtime → 1) | Table-driven tests invoking `main(argv)` with captured stdout/stderr. | +| JSON output shape (each command's keys and types) | Snapshot tests: run a command against a throwaway data-dir, assert the JSON matches a golden file. | +| File layout on disk (`identity.json`, `relays.json`, `groups/*.mls`, `keypackages.bundle`) | Structural assertions after a command sequence. | +| Round-trip between two data-dirs on a local relay | End-to-end shell scripts under `cli/src/test/resources/scripts/`. Spin up `nostr-rs-relay`, run Alice + Bob, assert await verbs resolve. | +| Interop with other clients | External harness consumes Amy as a binary; out of scope here 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. +If an Amy bug can only be caught here, it's a contract violation +(wrong key name, wrong exit code), not 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. +**Interop-test script 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's +a gap — add it to [ROADMAP.md](./ROADMAP.md). --- -## 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 +## Housekeeping - 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/`. +- Keep three things in sync: `printUsage()` in `Main.kt`, the command + table in [README.md](./README.md), and the parity matrix in + [ROADMAP.md](./ROADMAP.md). They drift fast. +- Never add a Gradle dependency on `:amethyst` or `:desktopApp`. If + you need something from there, move it to `commons/` first. - Never introduce a blocking prompt (`readLine()`, interactive password input). Take it as a flag. -- Keep each command file small. When one grows past ~200 lines, - split it — the Marmot `group` verbs are already a cautionary tale. +- Keep each command file small. Past ~200 lines, split — the Marmot + `group` verbs are already a cautionary tale. diff --git a/cli/README.md b/cli/README.md index 4e67246589..6304b15d9d 100644 --- a/cli/README.md +++ b/cli/README.md @@ -5,11 +5,11 @@ 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: +Amy exists for three audiences at once: -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. +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 @@ -17,43 +17,45 @@ Amy exists to serve three audiences: > 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). +> Android app is on the roadmap — see [ROADMAP.md](./ROADMAP.md). +> +> To extend Amy, see [DEVELOPMENT.md](./DEVELOPMENT.md). --- -## Design contract +## Output 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. +What every caller — user, script, agent, CI — can rely on: + +- **stdout is JSON. One line. One object.** Stable snake_case keys. + Pipe it into `jq`, parse it from Python, hand it to an agent. +- **stderr is for humans.** Progress, warnings, per-relay ACK traces. + 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`. +- **No interactive prompts, ever.** Passwords, names, keys — all flags. +- **Data-dir is the whole world.** All state (identity, relays, MLS + epochs, message archives, run cursors) lives under `--data-dir PATH`. + Delete to reset; copy 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. +The rationale behind each of these lives in +[DEVELOPMENT.md](./DEVELOPMENT.md). Breaking any of them is a breaking +change to Amy's public API. --- ## Install -Until Amy ships as a signed native binary (see the "Distribution" section -in [DEVELOPMENT.md](./DEVELOPMENT.md)), run it from source: +Until Amy ships as a signed native binary (see +[cli/plans/2026-04-21-cli-distribution.md](./plans/2026-04-21-cli-distribution.md)), +run it from source: ```bash -# One-shot run — positional args go after `--args`, quoted as a single string +# One-shot run — positional args go after `--args`, quoted as one string ./gradlew :cli:run --quiet --args="whoami" # Or build a runnable distribution and use the generated launch script @@ -62,8 +64,7 @@ in [DEVELOPMENT.md](./DEVELOPMENT.md)), run it from source: ``` 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. +(JVM launcher + jars) and is what downstream packaging will wrap. **Requirements:** JDK 21. @@ -74,11 +75,10 @@ The `installDist` tree under `cli/build/install/amy/` is self-contained ```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). +# relays, and publishes the nine bootstrap events. amy --data-dir ./alice create --name "Alice" -# 2. Publish a fresh MLS KeyPackage so other users can invite you. +# 2. Publish a fresh MLS KeyPackage so others can invite you. amy --data-dir ./alice marmot key-package publish # 3. Create a group, invite someone, send a message. @@ -91,16 +91,18 @@ 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: +Compose with `jq` to chain commands: ```bash GID=$(amy --data-dir ./alice marmot group create --name "Test" | jq -r .group_id) ``` +For an interop-test script template, see +[DEVELOPMENT.md § Testing](./DEVELOPMENT.md#testing). + --- -## Full command reference +## Command reference Run `amy --help` for the canonical list. As of today: @@ -116,7 +118,7 @@ Run `amy --help` for the canonical list. As of today: | `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 list` | All groups you're 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. | @@ -137,8 +139,8 @@ Run `amy --help` for the canonical list. As of today: | `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". +so scripts can distinguish "condition never happened" from "the command +itself crashed". ### Global flags @@ -162,54 +164,7 @@ and "the command itself crashed". ``` 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). +diff across two data-dirs in a test run. --- @@ -225,4 +180,4 @@ gap to close — see the roadmap in [DEVELOPMENT.md](./DEVELOPMENT.md). 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. + per-relay `OK` / `REJECT` via the `[cli] …` traces. diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md new file mode 100644 index 0000000000..fe42a3f408 --- /dev/null +++ b/cli/ROADMAP.md @@ -0,0 +1,109 @@ +# Amy Roadmap + +The live checklist for growing `amy` from a Marmot test-bed into a +full command-line mirror of Amethyst. + +**How to use this file:** update it in the same PR that ships a +feature. Move rows between tables, adjust ordering, add non-goals. +This is the single source of truth for "what's left". + +- How the CLI is used today: [README.md](./README.md) +- How to implement an item: [DEVELOPMENT.md](./DEVELOPMENT.md) +- Ongoing design plans: [plans/](./plans/) +- Shared work consumed here: [../commons/plans/](../commons/plans/) + +--- + +## 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. + +Why: + +- 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 a GUI. +- Regressions in shared logic (signing, encryption, filter building, + event parsing) become shell-scriptable. +- Power users get a command-line Amethyst for free. + +**Non-goal:** Amy is not a second Nostr client implementation. It is +a thin assembly layer over `quartz` + `commons`. Protocol and business +logic in `cli/` are bugs. + +--- + +## Parity matrix + +Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command · +🆕 needs extraction from `amethyst/` first · ⚠️ blocked (see Notes). + +| 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 (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 | 🆕 | Needs a signers abstraction in Amy. | +| Profile view (`amy profile show NPUB`) | ⚠️ | Blocked on event-renderer plan in `commons/plans/`. | +| Thread view (`amy thread show EVENT_ID`) | ⚠️ | Same. | +| Notifications feed | 🆕 | | +| Search (NIP-50) | 🆕 | | + +--- + +## Order of operations + +Proposed sequencing. Each step is one PR. Each step should extract +at least one file from `amethyst/` into `commons/`; if it doesn't +move anything, re-audit — you're probably duplicating logic. + +1. **Event rendering core** in `commons/commonMain/.../rendering/` + with renderers for kinds 0 / 1 / 3 / 6 / 7 / 10002 / 10050. + Unblocks all the 🆕 and ⚠️ read-path rows below. + Design: `commons/plans/2026-04-21-event-renderer.md`. +2. **`amy note publish` / `amy note show` / `amy note react`** — + smallest end-to-end write+read loop outside 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. Plan: `cli/plans/2026-04-21-cli-distribution.md`. +9. **Test suite** — end-to-end against a local relay. +10. **Everything else in the matrix.** + +--- + +## Non-goals + +- Interactive TUI or REPL. +- Native image (GraalVM) until Quartz has a pure-Kotlin signer + fallback — `secp256k1-kmp-jni-*` needs JNI today. +- A Gradle dependency on `:amethyst` or `:desktopApp`. Ever. +- Re-implementing any Nostr protocol piece that's already in + `quartz/` or in another client's library. diff --git a/cli/plans/2026-04-21-cli-distribution.md b/cli/plans/2026-04-21-cli-distribution.md new file mode 100644 index 0000000000..9726d6c166 --- /dev/null +++ b/cli/plans/2026-04-21-cli-distribution.md @@ -0,0 +1,89 @@ +--- +title: "feat(cli): native distribution across macOS / Windows / Linux" +type: feat +status: proposed +date: 2026-04-21 +owner: cli +--- + +# feat(cli): native distribution + +## Overview + +Today `amy` ships only as `./gradlew :cli:run` or a raw `installDist` +tree. Fine for dogfooding, not fine for the interop-test audience, +who need a single-binary install on every OS. + +Goal: publish `amy---` artefacts on every GitHub +release, wrapped in the native package managers users already have. + +## Target matrix + +| OS / channel | Strategy | Notes | +|---|---|---| +| macOS (arm64 + x64) | `brew install amy-nostr` | Homebrew formula pointing at a tarball of `installDist` + a jlink'd JRE. Mirrors the existing `amethyst-nostr` cask. | +| Windows | `winget install VitorPamplona.Amy` + `scoop install amy` | `.zip` with `amy.bat` launcher + jlink JRE. | +| Debian / Ubuntu | `.deb` via `jpackage --type deb` | Depends on libc only; jlink JRE 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 | Wrapper around `installDist`. | +| Zapstore | Extend `zapstore.yaml` | Signed by the same Nostr key as the Android app. | +| GitHub Release | All of the above attached as assets | Use `scripts/asset-name.sh` for consistent naming. | + +## Shortest path to first release + +1. Extend the existing `desktopApp` `jpackage` flow to also produce an + `amy---` artefact. Reuse the signing and + notarisation already configured for the desktop build. +2. CI matrix: macos-14 (arm64), macos-13 (x64), windows-latest, + ubuntu-latest. Each runner produces one tarball + one native + installer (`.dmg` / `.msi` / `.deb`). +3. Publish as release assets; no package-manager submission yet. +4. Only after the artefacts are stable (no path churn, no JRE + incompatibilities): submit Homebrew formula, winget manifest, + Scoop bucket, AUR PKGBUILD. + +## Why not GraalVM native-image + +Tempting for startup time but loses FFI to `secp256k1-kmp-jni-*`, +which is how Quartz signs today. Revisit when Quartz ships a +pure-Kotlin fallback signer. + +## Auto-update + +Out of scope for v1. Package managers handle it (`brew upgrade`, +`winget upgrade`, etc). Revisit if manual-install users complain. + +## 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. Verify with `./gradlew :cli:installDist && du -sh +cli/build/install/amy/` on CI and fail the build over a threshold. + +## Risks + +- **Code signing on macOS and Windows** costs real money and requires + secrets rotation. Reuse the desktop app's existing signing setup + rather than standing up a separate keychain. +- **Package-manager review latency.** Homebrew / winget reviews can + take days. Ship tarballs first; submissions later. +- **JRE size.** A jlink'd JDK 21 image is ~40 MB; adding OkHttp + + Jackson + Quartz should land well under the 80 MB budget, but a + bad transitive dep can double it overnight. + +## Open questions + +- Single brew cask `amethyst-nostr` with an `amy` formula alongside, + or a separate `amy-nostr` tap? Probably separate — different update + cadence, different audience. +- Should Amy ship in the same release as the desktop app, or on its + own cadence? Leaning same release — shared CI, shared version — + until the surfaces diverge. + +## Out of scope + +- In-app auto-update UX (no CLI auto-update). +- macOS app notarization of the CLI (only signing). Notarization is + for bundled `.app` GUIs. diff --git a/commons/plans/2026-04-21-event-renderer.md b/commons/plans/2026-04-21-event-renderer.md new file mode 100644 index 0000000000..5baf5ac510 --- /dev/null +++ b/commons/plans/2026-04-21-event-renderer.md @@ -0,0 +1,147 @@ +--- +title: "feat(commons): cross-platform event renderer" +type: feat +status: proposed +date: 2026-04-21 +owner: commons +consumers: cli, desktopApp, amethyst +--- + +# feat(commons): cross-platform event renderer + +## Overview + +Introduce a `commons/commonMain/.../rendering/` subsystem that turns a +`quartz` `Event` into a structured, UI-agnostic `RenderedEvent` value. +Three consumers plug into the same output: + +- **Amy** (`cli/`) — serialises `RenderedEvent` to stable JSON. +- **Desktop** (`desktopApp/`) — feeds `RenderedEvent` into Compose views. +- **Amethyst Android** (`amethyst/`) — same, but Android-native layouts. + +This is a `commons/`-owned subsystem because it's consumed by three +modules. Amy drives the need first (it can't read any event it can't +render), but the interface isn't Amy-specific. + +## Problem + +Today every surface that displays a Nostr event re-parses the raw +event structure. Kind:1 rendering, mention resolution, thread roots, +image extraction, hashtag indexing — each lives inline in Compose +components in `amethyst/ui/note/`. Amy cannot share any of it because +it can't depend on Compose or on `amethyst/`. + +Consequences: +- `amy note show` cannot exist without either (a) duplicating the + parsing or (b) extracting it somewhere `cli/` can call. +- Option (b) is the right answer and also fixes Desktop, which is + currently duplicating Android's parsing in a slightly different way. + +## Design + +```kotlin +// commons/commonMain/.../rendering/EventRenderer.kt +interface EventRenderer { + fun render(event: E, ctx: RenderContext): RenderedEvent +} + +data class RenderedEvent( + val kind: Int, + val eventId: HexKey, + val author: AuthorRef, + val createdAt: Long, + val title: String?, + val summary: String?, + val body: List, // text, mention, link, image, video, code + val mentions: List, + val media: List, + val replyTo: EventRef?, + val root: EventRef?, + val raw: Event, // escape hatch +) + +object EventRendererRegistry { + fun register(kind: Int, renderer: EventRenderer<*>) + fun render(event: Event, ctx: RenderContext): RenderedEvent +} +``` + +- Registry keyed by `event.kind`. Default renderer dumps raw tags + + content so nothing is ever un-renderable. +- Per-kind specialised renderers cover what Amethyst displays + specially: 0, 1, 3, 6, 7, 9, 445, 1059 (unwrapped), 10002, 10050, + 30023, 30043, 30311 … +- `RenderContext` carries anything kind-specific needs that's not on + the event itself: a pubkey → metadata lookup, a NIP-05 resolver, a + media-preview cache, etc. Default implementation in `commons/` + with test-friendly no-op behaviour. +- Formatters are separate: + - `JsonEventFormatter.format(rendered): String` — Amy's output. + - `@Composable Render(rendered)` — Desktop + Android. + - `TextEventFormatter.format(rendered): String` — optional + human-readable terminal mode (`amy note show EID --format text`). + +## Migration strategy + +1. Land the interface and `RenderedEvent` data model. No consumers. +2. Port a single kind (kind:1) end-to-end: + - Renderer in `commons/commonMain/.../rendering/`. + - JSON formatter in `commons/commonMain/.../rendering/json/`. + - Compose formatter in `commons/commonMain/.../rendering/compose/`. + - Unit tests in `commonTest`. +3. Desktop and Amethyst switch their kind:1 path to the new renderer. + Delete the old inline parsing. +4. Repeat for kind:0, kind:7, kind:3, kind:6, kind:10002, kind:10050 + (this unblocks Amy's feed commands). +5. Then the long tail. + +Each step is small and reversible. Each step deletes duplicated +parsing somewhere. + +## Consumer touch-points + +- **Amy** — `cli/commands/NoteCommands.kt#show`: + ```kotlin + val rendered = EventRendererRegistry.render(event, ctx.renderCtx) + Json.writeLine(JsonEventFormatter.toMap(rendered)) + ``` +- **Desktop** — replace ad-hoc parsing in `desktopApp/.../note/*` with + `EventRendererRegistry.render(event, renderCtx)` + `Render(rendered)`. +- **Android** — same swap in `amethyst/ui/note/*`. + +## Test strategy + +- `commonTest`: golden tests per kind. A fixture event → a fixture + `RenderedEvent`. Same fixtures feed both formatters. +- JSON formatter tests become Amy's snapshot tests for free. + +## Open questions + +- `RenderContext` scope: should async lookups (pubkey metadata) be + inside the renderer or pre-resolved by the caller? Leaning pre- + resolved — renderers stay pure, caller decides hydration policy. +- Do we render gift-wraps at all, or only their unwrapped inner + events? Leaning: unwrap first in `quartz`/`commons`, then render + the inner event. Outer kind:1059 gets a trivial default renderer. +- `BodySpan` granularity: does rich-text parsing (hashtag, mention, + link detection) live inside the kind-specific renderer or in a + shared post-processor? Leaning post-processor so every text-bearing + kind gets it for free. + +## Risks + +- Scope creep: this is a new subsystem. Keep the first PR tiny + (interface + default renderer only, zero consumers). +- Compose lock-in: the `@Composable Render` surface must live in + `commons/commonMain/` to avoid forcing Amy to see Compose. Verify + that's possible with Compose Multiplatform 1.10.3 — if not, split + into `commons-render-core` (pure Kotlin) + `commons-render-compose`. + +## Out of scope + +- Event authoring / composition (new-post editor). Renderer is + read-only. +- Relay-subscription plumbing. Renderer takes an already-received + `Event`, not a filter. +- Media loading (image download, video playback). Renderer emits + `MediaRef`; the consumer decides what to do with it.