Add cli/README.md covering the JSON-output contract, install via installDist, today's command surface (identity / relays / marmot), and use from agents + interop tests. Add cli/DEVELOPMENT.md with the feature-parity matrix vs Amethyst, the extract-from-Android recipe (amethyst/ -> commons/ -> cli/), command template, EventRenderer plan shared with Desktop, testing strategy focused on what quartz and commons don't already cover, distribution matrix across macOS / Windows / Linux / Nix / Zapstore, and an ordered 10-step roadmap. https://claude.ai/code/session_01BQ5ZHwa8BAgEQ9zeM4CKhW
17 KiB
Developing Amy
This doc is the north-star plan for growing amy from a Marmot
test-bed into a full command-line mirror of Amethyst. It is meant to
be picked up by any future contributor (human or agent) who needs to
add a feature, extract logic out of amethyst/, or close an interop
gap.
User-facing reference lives in README.md. This file covers why things are shaped the way they are and what to build next.
1. North-star goal
For every feature of the Amethyst Android app, there is a way to exercise it through
amy, with byte-identical on-relay behaviour.
This matters because:
- Interop tests against the ~100 other Nostr clients need a reproducible harness that does not require running Android.
- Agents and LLMs can script real Amethyst flows without touching a GUI.
- Regressions in shared logic (signing, encryption, filter building, event parsing) become shell-scriptable.
- Power users get a command-line Amethyst for free.
The non-goal: Amy is not a second Nostr client implementation. It
is a thin assembly layer over quartz + commons. If you find yourself
writing protocol or business logic inside cli/, stop — that code
belongs in commons/ or quartz/.
2. Design principles
- Non-interactive. One verb = one JSON object on stdout = one exit code. No REPL, no daemon, no prompts.
- Thin command layer. Each file in
cli/src/.../commands/is glue: parse args → call intocommons/orquartz/→ print JSON. A file longer than ~200 lines is a code smell; the logic is probably living in the wrong module. - Everything persistent is on-disk. No in-memory caches that
survive between invocations. Cursors, MLS state, identity, relay
config — all reload on every run. This is what makes
amysafe to run from CI and from 100 parallel interop scenarios. - Shared defaults. When the Android app sets a default relay,
picks a kind, derives a tag — Amy calls the same helper. No hand-
rolled duplicates. If such a helper doesn't exist yet, the fix is to
extract it to
commons/, not to copy its body intocli/. - JSON is the public API. Output shape changes are breaking changes. Treat them as such, version them explicitly in commit messages, and update interop fixtures.
3. Current architecture
cli/
└── src/main/kotlin/com/vitorpamplona/amethyst/cli/
├── Main.kt # argv → subcommand dispatch
├── Args.kt # tiny flag parser (no framework)
├── Json.kt # single-line stdout + error printer
├── Config.kt # Identity, RelayConfig, RunState, DataDir
├── Context.kt # per-run wiring (signer + NostrClient +
│ # MarmotManager + publish/drain/sync helpers)
├── stores/FileStores.kt # File-backed MLS / KP / message stores
└── commands/
├── Commands.kt # dispatcher
├── InitCommands.kt # init, whoami
├── CreateCommand.kt # full bootstrap (→ commons/account/)
├── LoginCommand.kt # nsec/ncryptsec/mnemonic/npub/nprofile/hex/nip05
├── RelayCommands.kt # add/list/publish-lists
├── KeyPackageCommands.kt # marmot key-package publish / check
├── GroupCommands.kt # marmot group create/list/show/…
├── GroupCreateCommand.kt
├── GroupReadCommands.kt
├── GroupAddMemberCommand.kt
├── GroupMembershipCommands.kt
├── GroupMetadataCommands.kt
├── MessageCommands.kt # marmot message send / list
└── AwaitCommands.kt # poll-until-condition helpers
Dependencies: :quartz + :commons + kotlinx-coroutines + OkHttp +
Jackson. No Android, no Compose. This is intentional — amy compiles
on any JDK 21 target, including CI runners and headless interop hosts.
Context.kt is the backbone. Most commands follow this template:
val ctx = Context.open(dataDir)
try {
ctx.prepare() // restore MLS state + connect relays
ctx.syncIncoming() // pull new gift-wraps + group events
// ...call into commons/ or quartz/ to build an event...
val ack = ctx.publish(event, targets)
Json.writeLine(mapOf(...))
} finally {
ctx.close() // flush RunState, disconnect
}
Keep new commands in that shape.
4. Current surface vs. Amethyst surface
What Amy can drive today (✅), what's clearly gap (🆕), and where the
logic already lives in commons/ ready to be called (📦).
| Area | Status | Notes |
|---|---|---|
Identity create / import (nsec, ncryptsec, mnemonic, npub, nprofile, hex, NIP-05) |
✅ | LoginCommand + Quartz NIP-05 / NIP-06 / NIP-49 |
| Account bootstrap (nine events) | ✅ | commons/account/AccountBootstrapEvents.kt |
| Relay config + NIP-65 / NIP-10050 publish | ✅ | RelayCommands |
| MLS KeyPackage publish + fetch | ✅ | commons/marmot/MarmotManager |
| Marmot group create / add / rename / promote / demote / remove / leave | ✅ | commons/marmot/ |
| Marmot message send / list | ✅ | commons/marmot/ |
await polling for KP / group / member / admin / message / rename / epoch |
✅ | AwaitCommands |
NIP-01 note publish (amy note publish TEXT) |
🆕 | Needs a commons/ builder wrapper. |
NIP-01 feed read (amy feed home, amy feed hashtag #X, amy feed profile NPUB) |
🆕 | Extract FeedFilter usage from amethyst/ui/dal/ into commons/ entry points. |
| NIP-02 follow list add / remove / list | 🆕 | Logic in amethyst/model/nip02FollowLists/. |
| NIP-09 event deletion | 🆕 | Builder exists in quartz. |
| NIP-17 DMs send / list / read | 🆕 | Gift-wrap path is already in commons/ via Marmot — generalise. |
| NIP-18 reposts / quotes | 🆕 | |
| NIP-25 reactions | 🆕 | |
| NIP-51 lists (bookmarks, mute, follow sets) | 🆕 | amethyst/model/nip51Lists/ |
| NIP-57 zaps (send + verify) | 🆕 | Needs LN-URL plumbing; amethyst/service/lnurl/. |
| NIP-65 outbox model queries | 🆕 | |
| NIP-72 communities | 🆕 | |
| NIP-78 app-specific data (settings sync) | 🆕 | |
| Long-form (NIP-23) publish / read | 🆕 | |
| Live activities / chess (NIP-53 / NIP-64) | 🆕 | |
| Blossom uploads (NIP-B7) | 🆕 | |
| NIP-47 Wallet Connect | 🆕 | |
| NIP-46 bunker signer | 🆕 | Would need a signers abstraction in Amy. |
Profile view (amy profile show NPUB) |
🆕 | Renderer work — see §6. |
Thread view (amy thread show EVENT_ID) |
🆕 | |
| Notifications feed | 🆕 | |
| Search (NIP-50) | 🆕 |
Treat this as a live checklist — update it in the same PR that closes a gap.
5. How to add a command
Rule of thumb: no new logic in cli/. Every command is an
assembly of things that already work elsewhere. Follow these steps.
5.1. Audit (mandatory)
Before you write anything, answer three questions:
- Is the Nostr-protocol piece (event kind, tags, encryption) already
in
quartz/? If not, add it there first. - Is the business logic (state, default values, ordering, filter
assembly) already in
commons/? If not, extract it fromamethyst/intocommons/in a preceding commit. - What is the smallest signed event or query this command has to produce? Write that down — it is the JSON your command will echo.
If commons/ doesn't have the helper you need, see §5.2 before
touching cli/.
5.2. Extract-from-Android checklist
This is the single most important recurring task for Amy's growth.
Most Amethyst features today live in amethyst/src/main/java/…/model/
or …/service/ with Android-only imports (Context, SharedPreferences,
WorkManager). Amy cannot call those directly — they have to move.
Extraction recipe:
- Identify the class in
amethyst/(e.g.ReactionPost.kt). - List its Android dependencies. The usual offenders:
Context,SharedPreferences,WorkManager,Log,Bitmap. - For each one, choose:
- Inline-able (one call, trivial): delete it.
- Platform-abstractable: add an
expect/actualincommons/commonMain/…+commons/androidMain/…+commons/jvmMain/…. (Seekotlin-multiplatformskill.) - Inversion-of-control: take it as a constructor arg. Amy supplies a JVM flavour.
- Move the file to
commons/commonMain/…. - Update the Android caller to use the new location. Add a JVM test.
- Only now, add the
cli/commands/…file that calls it.
What to keep in amethyst/: screens, navigation, Android-specific
side-effects (notifications, background services, camera, Intents).
Everything else is a candidate to move.
5.3. Command file template
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() }
}
}
Then wire it in Commands.kt and add a top-level branch in Main.kt's
dispatch. Extend printUsage().
5.4. Output-shape conventions
- Top-level object always.
- Stable snake_case keys.
- Event IDs as hex strings (not npub-style).
- Pubkeys as hex (
"pubkey":…) and bech32 when it's the primary subject of the output ("npub":…). - Relay URLs as strings, normalized, never objects.
- Lists of events are under a plural key (
"messages","members"). - Errors via
Json.error("code","detail")— single lower_snake code, free-form detail.
6. Rendering Nostr events
Amy has to render every Nostr kind Amethyst understands, in a way that:
- a human reading a terminal can parse at a glance,
- an agent can parse mechanically,
- an interop test can diff against a fixture.
Plan:
- Build an
EventRendererinterface incommons/commonMain/.../rendering/with onerender(event, ctx)call returning a structuredRenderedEvent(title, summary, body, mentions, media refs, etc) — not a string. - Register a renderer per kind, keyed by
event.kind. Default renderer dumps raw tags + content. Specialised renderers cover the kinds Amethyst displays specially (kind:0 metadata, kind:1 notes, kind:6 reposts, kind:7 reactions, kind:9/445 Marmot, kind:1059 gift-wraps once unwrapped, kind:10002, kind:30023, kind:30043, kind:30311 live activities, etc). - Reuse these renderers from the Desktop app too — the
RenderedEventstructure is the input to Compose views and to Amy's JSON, via two different formatters. - Amy's formatter serialises
RenderedEventto JSON with a stable schema. A second formatter produces a human pretty-print (amy note show EID --format text).
This is the single biggest cross-cutting work item. It benefits the Desktop app and Amy simultaneously, and it unlocks a huge chunk of the "🆕" rows in §4 cheaply, because once the renderer exists, a feed command is just "query, render each, emit list".
7. Testing Amy
Most of what Amy does is exercised by tests in quartz and commons
already — the protocol, the builders, the state machines. The thin
Amy-specific layer still needs its own coverage:
| Layer | Test approach |
|---|---|
Argument parsing (Args, flag forms, --data-dir=… vs --data-dir …) |
Plain JVM unit tests in cli/src/test/kotlin/. |
| Error / exit-code contract (bad args → 2, await timeout → 124, runtime → 1) | Table-driven unit tests invoking main(argv) with a captured stdout/stderr. |
| JSON output shape (each command's keys and types) | Snapshot tests: run a command against a throwaway data-dir, assert the emitted JSON matches a golden file. |
File layout on disk (identity.json, relays.json, groups/*.mls, keypackages.bundle) |
Structural assertions after running a sequence of commands. |
| Round-trip between two data-dirs on a local ephemeral relay | End-to-end shell tests under cli/src/test/resources/scripts/. Spin up a local relay (e.g. nostr-rs-relay), run Alice + Bob, assert await verbs resolve. |
| Interop with other clients | A separate harness repo consumes amy as a binary; out of scope for this module's own test suite but the JSON contract is what keeps it stable. |
What not to test here: event signing, filter assembly, MLS
correctness, NIP-44 encryption. Those belong in quartz/commons.
If an Amy bug can only be caught by a test here, it's likely a
contract violation (wrong key name, wrong exit code) rather than a
protocol bug.
Setup hooks for a test suite:
- Add a
testImplementationblock tocli/build.gradle.kts(kotlin("test"),junit5,jackson). - Launch the CLI via
main(argv)in-process for fast tests; launch the builtinstallDistlauncher for end-to-end tests. - Use
@TempDirfor the data-dir so tests can run in parallel.
8. Distribution
Today Amy runs via ./gradlew :cli:run or installDist. That is fine
for dogfooding but not for the interop-test audience, which needs a
single-binary install on every OS. Target matrix:
| OS / channel | Strategy | Notes |
|---|---|---|
| macOS (arm64 + x64) | brew install amy-nostr |
Homebrew formula pointing at a tarball of the installDist tree + a native jlink runtime. Mirrors amethyst-nostr cask. |
| Windows | winget install VitorPamplona.Amy + scoop install amy |
.zip with amy.bat launcher + jlink runtime. |
| Debian / Ubuntu | .deb via jpackage --type deb |
Depends on libc only; jlink runtime bundled. |
| Fedora / RHEL / openSUSE | .rpm via jpackage --type rpm |
Same. |
| Arch | AUR amy-nostr-bin |
Wrap the .tar.gz. |
| Any Linux | .tar.gz + AppImage |
AppImage for users without a package manager. |
| Nix / NixOS | nixpkgs entry |
Straightforward wrapper around installDist. |
| Zapstore | zapstore.yaml entry (already exists for Amethyst) |
Signed by the same Nostr key as the Android app. |
| GitHub Release | Every version ships the above as release assets | Use scripts/asset-name.sh for consistent naming. |
Shortest path: extend the existing desktop jpackage flow in
desktopApp/ to also produce an amy-<version>-<os>-<arch> artefact.
No native image yet — GraalVM native-image is tempting for startup
time but loses FFI to secp256k1-kmp-jni-*; revisit once Quartz has a
pure-Kotlin signer fallback.
Auto-update: out of scope for v1. Package managers handle it.
Size budget: target < 80 MB installed with a jlink'd runtime. If we cross that, audit transitive deps — Amy should not pull in Compose or Android libs.
9. Roadmap (order of operations)
A rough proposed sequencing. Each step is a PR.
- Event rendering core (
commons/commonMain/.../rendering/) with renderers for kind:0 / 1 / 3 / 6 / 7 / 10002 / 10050. Unblocks everything in §4 marked 🆕. amy note publish/amy note show/amy note react. Smallest possible end-to-end write+read loop outside of Marmot.amy feed home|profile|hashtag|threadreading through the renderer.amy follow add|remove|list(NIP-02) — proves extraction of list-building logic fromamethyst/model/.amy dm send|list(NIP-17) — reuses the gift-wrap path already exercised by Marmot.amy list bookmarks|mute|pin …(NIP-51).amy zap send|verify(NIP-57).- Distribution — Homebrew + Scoop +
.debin the same release pipeline as desktop. - Test suite end-to-end against a local relay.
- Everything else in §4.
Each step should extract at least one file from amethyst/ into
commons/. If a step doesn't move anything, it's probably duplicating
logic — re-audit.
10. House-keeping
- Run
./gradlew spotlessApplybefore every commit. - Keep
printUsage()inMain.ktin sync with the command table in README.md and §4 above — the three drift apart fast. - Never add a Gradle dependency on
amethyst/ordesktopApp/. If you need something from there, move it tocommons/. - Never introduce a blocking prompt (
readLine(), interactive password input). Take it as a flag. - Keep each command file small. When one grows past ~200 lines,
split it — the Marmot
groupverbs are already a cautionary tale.