Merge pull request #2492 from vitorpamplona/claude/amy-cli-guide-cuLaP

docs: add Amy CLI development guides and skill reference
This commit is contained in:
Vitor Pamplona
2026-04-21 20:42:27 -04:00
committed by GitHub
10 changed files with 1298 additions and 6 deletions
+14 -6
View File
@@ -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-<slug>.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 <verb>` 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 |
+168
View File
@@ -0,0 +1,168 @@
---
name: amy-expert
description: Patterns for extending `amy`, the Amethyst CLI in `cli/`. Use when adding an `amy <verb>` 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 <verb>` 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`
@@ -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<String>): 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<String>): Int {
if (tail.isEmpty()) return Json.error("bad_args", "note <publish|show|react>")
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<String>): 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`.
@@ -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.
@@ -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": "<hex>",
"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.
+239
View File
@@ -0,0 +1,239 @@
# Developing Amy
How to touch the `cli/` module without breaking its public contract.
- 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/`.
---
## Design principles
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. 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.
---
## 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.** 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:
```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
}
```
---
## How to add a command
Rule of thumb: **no new logic in `cli/`**. Every command is an
assembly of things that already work elsewhere.
### 1. Audit (mandatory)
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.
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. See the
[extraction recipe](#extract-from-android) below.
3. What is the smallest signed event or query this command has to
produce? That shape is the JSON your command will echo.
### 2. Extract from Android
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.
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/…`.
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.
### 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<String>): Int {
if (tail.isEmpty()) return Json.error("bad_args", "note <publish|read|…>")
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<String>): 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() }
}
}
```
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.
### 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 the pubkey is the
primary subject (`"npub":…`).
- Relay URLs as strings, normalized, never objects.
- Lists of events under a plural key (`"messages"`, `"members"`).
- Errors via `Json.error("code","detail")` — single lower_snake code,
free-form detail.
---
## Testing
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 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 here, it's a contract violation
(wrong key name, wrong exit code), not a protocol bug.
**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).
---
## Housekeeping
- Run `./gradlew spotlessApply` before every commit.
- 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. Past ~200 lines, split — the Marmot
`group` verbs are already a cautionary tale.
+183
View File
@@ -0,0 +1,183 @@
# 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 for three audiences at once:
1. **Humans** using Amethyst from a terminal or remote shell.
2. **Agents / LLMs** driving a Nostr account through a deterministic,
JSON-typed interface — no interactive prompts, no screen scraping.
3. **Interop test harnesses** that put Amethyst side-by-side with the
other ~100 Nostr clients publishing and consuming the same events.
Any flow that is tested in the Amethyst app should be reproducible
through `amy` — that's the bar.
> 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 [ROADMAP.md](./ROADMAP.md).
>
> To extend Amy, see [DEVELOPMENT.md](./DEVELOPMENT.md).
---
## Output contract
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
- **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`.
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
[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 one 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
(JVM launcher + jars) and is what downstream packaging 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.
amy --data-dir ./alice create --name "Alice"
# 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.
amy --data-dir ./alice marmot group create --name "Test Group"
amy --data-dir ./alice marmot group add <GID> npub1...bob
amy --data-dir ./alice marmot message send <GID> "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 <GID>
```
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).
---
## 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 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 distinguish "condition never happened" from "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
```
<data-dir>/
├── 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/
├── <gid>.mls # MLS group state per group
└── <gid>.log # decrypted inner events (one JSON per line)
```
All files are plain JSON or framed binary — human-inspectable, easy to
diff across two data-dirs in a test run.
---
## 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] …` traces.
+109
View File
@@ -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.
+89
View File
@@ -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-<version>-<os>-<arch>` 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-<version>-<os>-<arch>` 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.
+147
View File
@@ -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<E : Event> {
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<BodySpan>, // text, mention, link, image, video, code
val mentions: List<MentionRef>,
val media: List<MediaRef>,
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.