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:
+14
-6
@@ -3,12 +3,13 @@
|
|||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
Amethyst is a Nostr Client for Android that was made for Android-only and has been slowly switching
|
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`,
|
over to a Kotlin Multiplatform project. This project has 5 main modules: `quartz`, `commons`,
|
||||||
`amethyst` and `desktopApp`. Quartz should contain implementations of Nostr specifications and
|
`amethyst`, `desktopApp`, and `cli`. Quartz should contain implementations of Nostr specifications
|
||||||
utilities to help implement them. Commons stores shared code between Amethyst Android (`amethyst`)
|
and utilities to help implement them. Commons stores shared code between Amethyst Android
|
||||||
and Amethyst Desktop (`desktopApp`). The Desktop App is designed to be mouse first and so uses a
|
(`amethyst`) and Amethyst Desktop (`desktopApp`). The Desktop App is designed to be mouse first and
|
||||||
completely different screen and navigation architecture while sharing the back end components with
|
so uses a completely different screen and navigation architecture while sharing the back end
|
||||||
the android counterpart.
|
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
|
## Architecture
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ amethyst/
|
|||||||
│ └── jvmMain/ # Desktop-specific UI utilities
|
│ └── jvmMain/ # Desktop-specific UI utilities
|
||||||
├── desktopApp/ # Desktop JVM application (layouts, navigation)
|
├── desktopApp/ # Desktop JVM application (layouts, navigation)
|
||||||
├── amethyst/ # Android app (layouts, navigation)
|
├── amethyst/ # Android app (layouts, navigation)
|
||||||
|
├── cli/ # Amy — non-interactive CLI (JVM only, no Compose)
|
||||||
└── ammolite/ # Support module (unused)
|
└── ammolite/ # Support module (unused)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -34,6 +36,11 @@ amethyst/
|
|||||||
- `quartz/` = Nostr business logic, protocol, data (no UI)
|
- `quartz/` = Nostr business logic, protocol, data (no UI)
|
||||||
- `commons/` = Shared UI components, icons, composables, flows and ViewModels
|
- `commons/` = Shared UI components, icons, composables, flows and ViewModels
|
||||||
- `amethyst/` & `desktopApp/` = Platform-native layouts and navigation
|
- `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
|
## Tech Stack
|
||||||
|
|
||||||
@@ -66,6 +73,7 @@ Specialized skills provide domain expertise with bundled resources and patterns:
|
|||||||
| `feed-patterns` | Feeds & DAL | `FeedFilter`, `AdditiveComplexFeedFilter`, `FeedViewModel` family |
|
| `feed-patterns` | Feeds & DAL | `FeedFilter`, `AdditiveComplexFeedFilter`, `FeedViewModel` family |
|
||||||
| `auth-signers` | `NostrSigner` implementations | Local, NIP-46 bunker, NIP-55 Android external signer |
|
| `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 |
|
| `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-missing-translations` | Utility | Extract untranslated Android strings |
|
||||||
| `find-non-lambda-logs` | Utility | Audit Log calls for lambda overloads |
|
| `find-non-lambda-logs` | Utility | Audit Log calls for lambda overloads |
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
@@ -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
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user