docs(cli): split amy docs by responsibility + add amy-expert skill

Separate the single DEVELOPMENT.md into focused docs per audience:

- cli/README.md — trim agent/interop sub-sections; user-facing contract,
  commands, data-dir, troubleshooting only.
- cli/DEVELOPMENT.md — pared down to architecture, how-to-add-a-command,
  output conventions, testing, housekeeping.
- cli/ROADMAP.md (new) — north-star, parity matrix, ordered milestones,
  non-goals. The live checklist for CLI feature parity.
- cli/plans/2026-04-21-cli-distribution.md (new) — packaging strategy
  (Homebrew / winget / Scoop / deb / rpm / AUR / AppImage).
- commons/plans/2026-04-21-event-renderer.md (new) — cross-cutting
  renderer design owned by commons, consumed by cli + desktop + android.

Agentic routing:

- .claude/skills/amy-expert/SKILL.md (new) — routing triggers + the
  five hard rules (thin-layer, JSON contract, non-interactive,
  data-dir-is-world, extract-before-adding).
- references/command-template.md, extraction-recipe.md,
  output-conventions.md — bundled copy-paste references.

Root .claude/CLAUDE.md:

- Adds cli/ to the module list with its sharing rule.
- Registers amy-expert in the skills table.
- Documents per-module plans/ convention; freezes docs/plans/.

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