docs(cli): make USAGE.md the README; move contract material to DEVELOPMENT
USAGE.md was the better README — entry-point users want examples and quick start, not the public-API contract. Flip them and refresh the amy-expert skill so it matches the post-refactor reality. cli/README.md (was USAGE.md): - Install, quick start, seven worked examples, full command reference, output modes, multi-account workflows, agent recipes, troubleshooting. - Cross-refs point at DEVELOPMENT.md for the contract / architecture and ROADMAP.md for what's coming. cli/DEVELOPMENT.md absorbs the old README's architecture sections: - New "Public contract" section at the top — the stable promises (text-default + --json contract, stderr for humans, exit codes, ~/.amy/ as the world). - "Local event store" deep-dive with the cache-helper API. - "Relay routing" rules table. - "Full on-disk layout" tree with annotations. cli/ROADMAP.md, cli/USAGE.md: - ROADMAP cross-refs collapsed (no more USAGE.md row). - USAGE.md deleted — content lives in README now. .claude/skills/amy-expert refreshed end-to-end: - SKILL.md description + Rules 2 and 4 rewritten for the dual-output contract (text default, --json opt-in) and the ~/.amy/ layout. - "Where things live" listing matches the current source tree (Output.kt, Aliases.kt, UseCommand.kt, secrets/, all the new command files). - "Common mistakes" lists the new traps: don't read user.home directly, don't add a global flag that collides with subcommand --name, don't use Json.writeLine (it's gone). - references/command-template.md uses Output.emit / Output.error (Json.writeLine / Json.error helpers no longer exist). - references/output-conventions.md rewritten around the dual-mode contract — same JSON shape rules, but framed as "this is what --json emits" rather than "this is stdout."
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
---
|
||||
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.
|
||||
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 dual-output contract (text by default, single-line JSON object on stdout under `--json`, 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
|
||||
@@ -42,17 +42,28 @@ 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
|
||||
### Rule 2 — text by default, `--json` is the machine contract
|
||||
|
||||
- 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.
|
||||
amy ships a dual-output contract:
|
||||
|
||||
- **Default stdout is human-readable text.** A YAML-ish render of the
|
||||
result map. No shape promise — the renderer can change between
|
||||
releases.
|
||||
- **`--json` switches stdout to one JSON object, one line.** Stable
|
||||
snake_case keys; this shape is the public API.
|
||||
- **stderr is for humans.** Progress logs, warnings, per-relay ACK
|
||||
traces. Errors go here too — `error: <code>: <detail>` by default,
|
||||
JSON `{"error":"…","detail":"…"}` under `--json`.
|
||||
- **Exit codes:** `0` success · `1` runtime · `2` bad args · `124`
|
||||
await timeout.
|
||||
- Adding a `--json` key is safe; renaming or removing one is a
|
||||
breaking change and needs the commit message to say so.
|
||||
|
||||
Commands emit results via `Output.emit(mapOf(...))` and errors via
|
||||
`Output.error("code", "detail")`. The `Output` object (in
|
||||
`cli/src/main/kotlin/…/cli/Output.kt`) handles the text-vs-JSON
|
||||
branching automatically. Never `println(...)` user-facing output
|
||||
directly — `System.err.println(...)` is fine for progress logs only.
|
||||
|
||||
See `references/output-conventions.md`.
|
||||
|
||||
@@ -62,16 +73,37 @@ 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
|
||||
### Rule 4 — `~/.amy/` 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.
|
||||
State is reloaded from `~/.amy/` 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.
|
||||
The layout:
|
||||
|
||||
- `~/.amy/shared/events-store/` — one file-backed Nostr event store
|
||||
per machine, shared across every account.
|
||||
- `~/.amy/<account>/` — per-account dir: `identity.json`,
|
||||
`state.json`, `aliases.json`, `marmot/`.
|
||||
- `~/.amy/current` — marker file written by `amy use NAME` to pin
|
||||
the active account.
|
||||
|
||||
Account selection is via the global `--account NAME` flag (required
|
||||
when more than one account exists; auto-picked when exactly one
|
||||
does). `--account` cannot collide with subcommand flags, so commands
|
||||
like `marmot group create --name "Group"` or `profile edit --name "Alice"`
|
||||
keep their own `--name` parameter.
|
||||
|
||||
Tests isolate by overriding `$HOME` for the amy subprocess
|
||||
(`HOME=$(mktemp -d) amy --account alice init`). amy reads `$HOME`
|
||||
directly (not `user.home`, which JDK 21 derives from `getpwuid` and
|
||||
ignores `$HOME`), so the same convention `git`/`gpg`/`npm`/`ssh`
|
||||
follow Just Works.
|
||||
|
||||
If you need new persisted state, add it to `Config.kt`,
|
||||
`stores/FileStores.kt`, or a new helper (e.g. `Aliases.kt`) with a
|
||||
named JSON schema. Don't smuggle state into `~/.amy/` outside the
|
||||
documented files.
|
||||
|
||||
### Rule 5 — Extract before adding
|
||||
|
||||
@@ -92,9 +124,9 @@ 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.
|
||||
prepare, call into commons/quartz, publish or drain, emit one result
|
||||
via `Output.emit`. 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.
|
||||
@@ -107,34 +139,54 @@ Wire-up checklist:
|
||||
7. If the verb changes observable wire behaviour (a new event kind,
|
||||
a new relay-routing rule, a new JSON discriminator), add a case
|
||||
in the appropriate harness under `cli/tests/` — `cli/tests/marmot/`
|
||||
for MLS flows, `cli/tests/dm/` for NIP-17, or a new sibling suite
|
||||
if it's neither.
|
||||
for MLS flows, `cli/tests/dm/` for NIP-17, `cli/tests/cache/` for
|
||||
event-store behaviour, or a new sibling suite if it's none.
|
||||
|
||||
If you change output shape: note it in the commit message, bump the
|
||||
example in `README.md`, update any interop fixtures under
|
||||
`cli/tests/`.
|
||||
If you change `--json` output shape: note it in the commit message,
|
||||
bump the example in `cli/README.md`, update any interop fixtures
|
||||
under `cli/tests/`.
|
||||
|
||||
## Where things live
|
||||
|
||||
```
|
||||
cli/
|
||||
├── README.md # user-facing: commands, JSON contract, quick start
|
||||
├── DEVELOPMENT.md # touch-the-code: architecture, conventions, testing
|
||||
├── README.md # user-facing tour: install, examples, command tables
|
||||
├── DEVELOPMENT.md # public contract, architecture, design rules,
|
||||
│ # event-store, relay-routing, full on-disk layout
|
||||
├── ROADMAP.md # parity matrix + ordered milestones
|
||||
├── plans/ # dated design docs (use for new subsystems)
|
||||
├── tests/ # end-to-end shell harnesses against a local relay
|
||||
│ ├── lib.sh # shared logging + result tracking
|
||||
│ ├── headless/ # shared amy wrappers + assertions
|
||||
│ ├── marmot/ # MLS group-messaging interop (vs whitenoise-rs)
|
||||
│ └── dm/ # NIP-17 DM interop (two amy clients)
|
||||
│ ├── dm/ # NIP-17 DM interop (two amy clients)
|
||||
│ └── cache/ # FsEventStore behaviour vs the cache helpers
|
||||
└── src/main/kotlin/…/cli/
|
||||
├── Main.kt # argv dispatch
|
||||
├── Main.kt # argv dispatch, global flags
|
||||
├── Args.kt # flag parser
|
||||
├── Json.kt # stdout/stderr JSON
|
||||
├── Config.kt # Identity, RelayConfig, RunState, DataDir
|
||||
├── Output.kt # text/json mode emitter + colour
|
||||
├── Aliases.kt # per-account aliases.json read/write
|
||||
├── Config.kt # Identity, RunState, DataDir (~/.amy layout)
|
||||
├── Context.kt # per-run wiring — the backbone
|
||||
├── stores/ # file-backed persistence
|
||||
└── commands/ # one file per top-level verb group
|
||||
├── SecureFileIO.kt # 0600/0700 atomic writes, perm tighten
|
||||
├── stores/ # file-backed MLS / KP / message stores
|
||||
├── secrets/ # SecretStore backends (keychain / ncryptsec / plaintext)
|
||||
└── commands/ # one file (or group) per top-level verb
|
||||
├── UseCommand.kt # `amy use NAME`
|
||||
├── InitCommands.kt # init, whoami
|
||||
├── CreateCommand.kt + LoginCommand.kt
|
||||
├── RelayCommands.kt
|
||||
├── ProfileCommands.kt
|
||||
├── NotesCommands.kt + PostCommand.kt + FeedCommand.kt
|
||||
├── DmCommands.kt
|
||||
├── KeyPackageCommands.kt
|
||||
├── GroupCommands.kt + GroupCreateCommand.kt + GroupReadCommands.kt
|
||||
│ GroupAddMemberCommand.kt + GroupMembershipCommands.kt
|
||||
│ GroupMetadataCommands.kt
|
||||
├── MessageCommands.kt
|
||||
├── MarmotResetCommand.kt
|
||||
├── AwaitCommands.kt
|
||||
└── StoreCommands.kt
|
||||
```
|
||||
|
||||
Shared logic consumed by Amy lives in `commons/`:
|
||||
@@ -146,9 +198,10 @@ Shared logic consumed by Amy lives in `commons/`:
|
||||
## 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
|
||||
- **Silently changing a `--json` key.** Flag as breaking.
|
||||
- **Using `println` or `print` for command output.** Use
|
||||
`Output.emit(...)` / `Output.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`.
|
||||
@@ -158,6 +211,13 @@ Shared logic consumed by Amy lives in `commons/`:
|
||||
or `resolveUserHexOrNull` in `quartz/nip05DnsIdentifiers/`.
|
||||
- **Re-inventing publish-and-confirm.** Use `Context.publish`.
|
||||
- **Re-inventing one-shot subscription.** Use `Context.drain`.
|
||||
- **Reading `user.home` directly.** Use `DataDir.DEFAULT_ROOT`, which
|
||||
reads `$HOME` (the convention `git`/`gpg`/`npm` follow); JDK 21's
|
||||
`user.home` is derived from `getpwuid` and ignores `$HOME`, which
|
||||
silently breaks the test-isolation pattern.
|
||||
- **Adding a global flag that collides with subcommand flags.**
|
||||
`--name` is reserved for subcommand use (group/profile names).
|
||||
Account selection is `--account`.
|
||||
|
||||
## Plans & design docs
|
||||
|
||||
@@ -171,9 +231,10 @@ frozen.
|
||||
|
||||
## Cross-references
|
||||
|
||||
- [`cli/README.md`](../../../cli/README.md)
|
||||
- [`cli/DEVELOPMENT.md`](../../../cli/DEVELOPMENT.md)
|
||||
- [`cli/ROADMAP.md`](../../../cli/ROADMAP.md)
|
||||
- [`cli/README.md`](../../../cli/README.md) — user-facing tour
|
||||
- [`cli/DEVELOPMENT.md`](../../../cli/DEVELOPMENT.md) — public
|
||||
contract, architecture, on-disk layout
|
||||
- [`cli/ROADMAP.md`](../../../cli/ROADMAP.md) — parity matrix
|
||||
- `references/command-template.md`
|
||||
- `references/extraction-recipe.md`
|
||||
- `references/output-conventions.md`
|
||||
|
||||
@@ -11,7 +11,7 @@ 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
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
|
||||
object NotePublishCommand {
|
||||
suspend fun run(dataDir: DataDir, rest: Array<String>): Int {
|
||||
@@ -26,7 +26,7 @@ object NotePublishCommand {
|
||||
.buildTextNote(ctx.signer, text)
|
||||
val ack = ctx.publish(event, ctx.outboxRelays())
|
||||
|
||||
Json.writeLine(mapOf(
|
||||
Output.emit(mapOf(
|
||||
"event_id" to event.id,
|
||||
"kind" to event.kind,
|
||||
"published_to" to ack.filterValues { it }.keys.map { it.url },
|
||||
@@ -40,6 +40,10 @@ object NotePublishCommand {
|
||||
}
|
||||
```
|
||||
|
||||
`Output.emit(...)` handles the text-vs-JSON mode automatically. The
|
||||
result map IS the `--json` shape; the human-readable text default is
|
||||
derived from the same map by `Output.kt`'s renderer.
|
||||
|
||||
## Multi-verb group
|
||||
|
||||
When a feature has several verbs (`note publish`, `note show`,
|
||||
@@ -48,13 +52,13 @@ When a feature has several verbs (`note publish`, `note show`,
|
||||
```kotlin
|
||||
object NoteCommands {
|
||||
suspend fun dispatch(dataDir: DataDir, tail: Array<String>): Int {
|
||||
if (tail.isEmpty()) return Json.error("bad_args", "note <publish|show|react>")
|
||||
if (tail.isEmpty()) return Output.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]}")
|
||||
else -> Output.error("bad_args", "note ${tail[0]}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,13 +89,17 @@ For every new command:
|
||||
|
||||
- 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).
|
||||
`Output.emit(...)` / `Output.error(...)`. `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.
|
||||
them to `error: …` (text mode) / `{"error":…}` (JSON mode) plus the
|
||||
right 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.
|
||||
- No global flags that collide with subcommand flags. `--name` is
|
||||
reserved for subcommand use (group/profile name); the global
|
||||
account selector is `--account`.
|
||||
|
||||
## Output-shape rules
|
||||
|
||||
|
||||
@@ -1,25 +1,33 @@
|
||||
# Output conventions
|
||||
|
||||
Amy's JSON contract is its public API. Follow these rules.
|
||||
amy ships a dual-output contract. Default stdout is human-readable
|
||||
text (a YAML-ish render of the underlying result map); `--json` flips
|
||||
stdout to a single JSON object per success. The text shape can drift;
|
||||
the `--json` shape is the public API.
|
||||
|
||||
Commands always emit via `Output.emit(mapOf(...))`. The map IS the
|
||||
JSON shape — the renderer in `Output.kt` derives the text from the
|
||||
same map. Don't write two render paths; write one map and let
|
||||
`Output` pick.
|
||||
|
||||
## 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. |
|
||||
| Stream | Default mode | `--json` mode |
|
||||
|---|---|---|
|
||||
| **stdout** | YAML-ish text from `Output.emit(...)` | Exactly one JSON object per successful invocation |
|
||||
| **stderr** | Human progress logs, warnings, per-relay ACK traces, stack traces, `printUsage()` output, errors as `error: <code>: <detail>` | Same logs, plus errors as `{"error":...,"detail":...}` |
|
||||
|
||||
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.
|
||||
stdout and is automatically JSON under `--json`. 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. |
|
||||
| `0` | Success. |
|
||||
| `1` | Runtime error. |
|
||||
| `2` | Bad arguments. |
|
||||
| `124` | `await` timed out. |
|
||||
|
||||
Throw the right exception type in commands:
|
||||
@@ -32,7 +40,7 @@ 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
|
||||
## `--json` object shape
|
||||
|
||||
### Top-level
|
||||
|
||||
@@ -58,8 +66,9 @@ newline-delimited stream.
|
||||
| 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`. |
|
||||
| Timestamps | Unix seconds, integer. Key names end in `_at`. The text renderer auto-formats these as `2026-04-25 13:42:11Z (8m ago)`. |
|
||||
| Group ID (Marmot) | Hex string. Key: `group_id`. |
|
||||
| Byte counts | Integer. Key names end in `_bytes`. The text renderer auto-formats these as `8.7 KiB`. |
|
||||
|
||||
### Collections
|
||||
|
||||
@@ -70,7 +79,8 @@ newline-delimited stream.
|
||||
|
||||
### Booleans
|
||||
|
||||
- Use `true`/`false`, not `0`/`1`, not `"yes"`.
|
||||
- Use `true`/`false` in the result map. The text renderer prints them
|
||||
as `yes`/`no` (green/red); `--json` keeps the literal booleans.
|
||||
- Name keys so `true` is the expected/successful state:
|
||||
`is_member`, `published`, `accepted`.
|
||||
|
||||
@@ -93,22 +103,40 @@ appear in neither — add `timed_out_on` if you need to surface them.
|
||||
|
||||
### Error shape
|
||||
|
||||
Default mode (text):
|
||||
|
||||
```text
|
||||
error: not_member: <gid>
|
||||
```
|
||||
|
||||
Under `--json`:
|
||||
|
||||
```json
|
||||
{ "error": "code", "detail": "free-form explanation" }
|
||||
{ "error": "not_member", "detail": "<gid>" }
|
||||
```
|
||||
|
||||
- `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.
|
||||
- Common codes today: `bad_args`, `no_identity`, `no_account`,
|
||||
`exists`, `bad_key`, `not_member`, `no_dm_relays`, `timeout`,
|
||||
`runtime`. Reuse before inventing.
|
||||
|
||||
Use `Output.error("code", "detail")` from commands; it picks the
|
||||
right channel and format based on the active mode.
|
||||
|
||||
## 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.
|
||||
- `println(...)` of anything except `Output.emit(...)`.
|
||||
- `Json.writeLine` / `Json.error` — that helper is gone; use the
|
||||
`Output` object instead.
|
||||
- Multi-line JSON (pretty-printed) under `--json`. One line, always.
|
||||
- Mixing stdout lines — one command invocation emits one stdout line
|
||||
in `--json` mode. 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.
|
||||
result map.
|
||||
- Building text rendering by hand. Trust the `Output.kt` renderer:
|
||||
it handles alignment, colour, byte/timestamp formatting, nested
|
||||
maps and lists. If you need a bespoke render for one command,
|
||||
pass a custom render lambda — don't go around `Output`.
|
||||
|
||||
+144
-4
@@ -2,8 +2,7 @@
|
||||
|
||||
How to touch the `cli/` module without breaking its public contract.
|
||||
|
||||
- What Amy is and the public contract: [README.md](./README.md).
|
||||
- How to use it: [USAGE.md](./USAGE.md).
|
||||
- What Amy is + how to use it: [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.
|
||||
@@ -15,6 +14,37 @@ or encryption in here, stop — that code belongs in `quartz/` or
|
||||
|
||||
---
|
||||
|
||||
## Public contract
|
||||
|
||||
What every caller — user, script, agent, CI — can rely on:
|
||||
|
||||
- **Default stdout is human-readable text.** A YAML-ish render of the
|
||||
underlying result map. Friendly at a terminal; no shape promises.
|
||||
- **`--json` is the machine contract. One line. One object.** Stable
|
||||
snake_case keys. Pipe it into `jq`, parse it from Python, hand it to
|
||||
an agent. Pass `--json` anywhere before the subcommand.
|
||||
- **stderr is for humans.** Progress, warnings, per-relay ACK traces.
|
||||
Safe to discard. Errors land here too: `error: <code>: <detail>` by
|
||||
default, or JSON `{"error":"…","detail":"…"}` under `--json`.
|
||||
- **Exit codes are the real signal.**
|
||||
- `0` — success
|
||||
- `1` — runtime error
|
||||
- `2` — bad arguments
|
||||
- `124` — `await` timed out
|
||||
- **No interactive prompts, ever.** Passwords, names, keys — all flags.
|
||||
- **`~/.amy/` is the whole world.** Per-account dirs hold identity,
|
||||
cursors, MLS state, and aliases at `~/.amy/<account>/`; every observed
|
||||
Nostr event lands in `~/.amy/shared/events-store/`. Delete to reset;
|
||||
copy to move. Tests isolate by overriding `$HOME` for the amy
|
||||
subprocess (`HOME=/tmp/run.123 amy --account alice …`) — same
|
||||
convention `git`, `gpg`, and `npm` use.
|
||||
|
||||
Only the `--json` shape and the exit codes are public API. The default
|
||||
text format is allowed to change between releases. The five design
|
||||
principles below are how we keep that promise.
|
||||
|
||||
---
|
||||
|
||||
## Design principles
|
||||
|
||||
1. **Non-interactive.** One verb = one result on stdout = one exit
|
||||
@@ -179,7 +209,7 @@ object NoteCommands {
|
||||
|
||||
Wire it into `Commands.kt`, add a top-level branch in `Main.kt`'s
|
||||
`dispatch`, and extend `printUsage()`. Keep the command tour in
|
||||
[USAGE.md](./USAGE.md) and the parity matrix in
|
||||
[README.md](./README.md) and the parity matrix in
|
||||
[ROADMAP.md](./ROADMAP.md) in sync.
|
||||
|
||||
### 4. Output-shape conventions
|
||||
@@ -244,11 +274,121 @@ a gap — add it to [ROADMAP.md](./ROADMAP.md).
|
||||
|
||||
---
|
||||
|
||||
## Local event store — the source of truth
|
||||
|
||||
Every Nostr event amy observes is verified (NIP-01 id + signature
|
||||
check) and persisted to a file-backed store at
|
||||
`~/.amy/shared/events-store/` (one store per machine, shared across
|
||||
every account in `~/.amy/`). That includes:
|
||||
|
||||
- events received from any relay subscription (`amy notes feed`,
|
||||
`amy dm list`, `amy marmot key-package publish`, group sync, …),
|
||||
- events amy generates and publishes itself,
|
||||
- inner events unwrapped from NIP-59 gift wraps.
|
||||
|
||||
Malformed events are dropped before reaching command code. Persistence
|
||||
is best-effort — if the store fails (full disk, permissions), the relay
|
||||
subscription still works, but the event is not cached.
|
||||
|
||||
The store is the authoritative cache of everything amy has seen:
|
||||
profile metadata, relay lists (NIP-65 and NIP-02), gift wraps, group
|
||||
events, follow lists, etc. Commands that need any of these read from
|
||||
the store first and only fall back to a relay fetch on miss. Three
|
||||
convenience helpers exist on `Context`:
|
||||
|
||||
```kotlin
|
||||
ctx.profileOf(pubKey) // latest kind:0 (NIP-01)
|
||||
ctx.relaysOf(pubKey) // latest kind:10002 (NIP-65)
|
||||
ctx.contactsOf(pubKey) // latest kind:3 (NIP-02)
|
||||
ctx.dmInboxOf(pubKey) // latest kind:10050 (NIP-17 DM inbox)
|
||||
ctx.keyPackageRelaysOf(pubKey) // latest kind:10051 (MIP-00 KP relays)
|
||||
ctx.cachedRelayListsOf(pubKey) // RecipientRelayFetcher.Lists from cache
|
||||
```
|
||||
|
||||
The store implements every feature of the Quartz SQLite store —
|
||||
NIP-01 replaceable / addressable uniqueness, NIP-09 deletion
|
||||
tombstones, NIP-40 expiration, NIP-50 search, NIP-62 right-to-vanish,
|
||||
NIP-91 multi-tag AND. See
|
||||
[`cli/plans/2026-04-24-file-event-store-*.md`](./plans/) for the design
|
||||
and `quartz/.../store/fs/FsEventStore.kt` for the implementation. The
|
||||
on-disk layout is plain JSON files under shard directories,
|
||||
intentionally inspectable with `ls`, `cat`, `jq`, `grep`, `find`,
|
||||
`rsync`, and `git`. Deleting an event file is treated as a deliberate
|
||||
"I never saw this" by amy; dangling indexes are skipped at query time
|
||||
and can be cleaned up with `amy store scrub` / `amy store compact`.
|
||||
|
||||
---
|
||||
|
||||
## Relay routing
|
||||
|
||||
amy follows the Marmot protocol's per-event routing rules so two users
|
||||
with completely disjoint relay configurations can still marmot each
|
||||
other. No event ever ships blindly to "our configured relays" — amy
|
||||
looks up the right relay set per event per recipient.
|
||||
|
||||
| Event | Publish to | Fetch from |
|
||||
|---|---|---|
|
||||
| kind:30443 (our own KeyPackage) | `key_package` bucket → NIP-65 outbox → any configured | — |
|
||||
| kind:30443 (someone else's KeyPackage) | — | Their kind:10051 → their kind:10002 write → our bootstrap pool |
|
||||
| kind:10051 / 10050 / 10002 (our own lists) | All configured relays (broadcast) | — |
|
||||
| kind:10051 / 10050 / 10002 (someone else's) | — | Our bootstrap pool = configured relays ∪ Amethyst defaults |
|
||||
| kind:1059 Welcome gift wrap (kind:444 inside) | Recipient's kind:10050 → their kind:10002 read → `DefaultDMRelayList` → our outbox | — |
|
||||
| kind:1059 gift wraps addressed to us | — | Our kind:10050 |
|
||||
| kind:445 Group Event (Commit / Proposal / chat) | Group's MIP-01 `relays` field | Same |
|
||||
|
||||
**Bootstrap pool**: when amy needs to discover a user it's never talked
|
||||
to, it queries `configured relays ∪ Amethyst's default NIP-65 set ∪
|
||||
Amethyst's default DM-inbox set`. These defaults come from
|
||||
`commons.defaults.AmethystDefaults` and match what the Android/Desktop
|
||||
UI publishes to on first run, so any fresh Amethyst account is
|
||||
reachable via the bootstrap pool even before amy has seen any of their
|
||||
events.
|
||||
|
||||
---
|
||||
|
||||
## Full on-disk layout
|
||||
|
||||
```
|
||||
~/.amy/ ← root, follows $HOME
|
||||
├── current # marker file written by `amy use NAME`
|
||||
├── shared/
|
||||
│ └── events-store/ # FsEventStore — every observed Nostr event
|
||||
│ ├── events/<aa>/<bb>/… # canonical kind:0 / 3 / 10002 / 10050 / 10051 / 1 / 5 / 1059 / …
|
||||
│ ├── replaceable/<k>/… # one slot per (kind, pubkey) for kind:0/3/10000-19999
|
||||
│ ├── addressable/… # one slot per (kind, pubkey, d-tag) for kind:30000-39999
|
||||
│ ├── idx/ # hardlink indexes (kind / author / owner / tag / fts / expires_at)
|
||||
│ └── tombstones/ # NIP-09 / NIP-62 enforcement
|
||||
├── alice/ # one dir per account (`amy --account alice init`)
|
||||
│ ├── identity.json # nsec/npub/hex — the account
|
||||
│ ├── state.json # sync cursors (giftWrapSince, groupSince)
|
||||
│ ├── aliases.json # local name → npub map (init writes a self-entry)
|
||||
│ └── marmot/
|
||||
│ ├── keypackages.bundle # MLS KeyPackage bundles (NostrSignerInternal)
|
||||
│ └── groups/
|
||||
│ ├── <gid>.mls # MLS group state per group
|
||||
│ └── <gid>.log # decrypted inner events (one JSON per line)
|
||||
└── bob/ ... # additional accounts sit alongside
|
||||
```
|
||||
|
||||
All files are plain JSON or framed binary — human-inspectable, easy to
|
||||
diff across two accounts. Two accounts on the same machine share
|
||||
`~/.amy/shared/events-store/`, so a public event observed once doesn't
|
||||
get re-stored per account.
|
||||
|
||||
The local relay configuration (kind:10002 / 10050 / 10051) is **not** a
|
||||
separate file — it lives in the shared `events-store/` as signed events
|
||||
owned by the account that wrote them. `amy relay add` builds + signs +
|
||||
ingests a new relay-list event; `amy relay list` reads URLs straight
|
||||
out of the latest event for each kind; `amy relay publish-lists`
|
||||
broadcasts those events to upstream relays. There is no `relays.json`.
|
||||
|
||||
---
|
||||
|
||||
## Housekeeping
|
||||
|
||||
- Run `./gradlew spotlessApply` before every commit.
|
||||
- Keep three things in sync: `printUsage()` in `Main.kt`, the command
|
||||
tour in [USAGE.md](./USAGE.md), and the parity matrix in
|
||||
tour 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.
|
||||
|
||||
+405
-140
@@ -1,176 +1,441 @@
|
||||
# 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` is the command-line face of [Amethyst](https://github.com/vitorpamplona/amethyst).
|
||||
It speaks the same Nostr protocol as the Android and Desktop apps and shares
|
||||
their codebase. From a terminal you can post notes, send NIP-17 DMs,
|
||||
manage MLS group chats, switch identities, and pipe machine-readable JSON
|
||||
into the rest of your toolbox.
|
||||
|
||||
Amy exists for three audiences at once:
|
||||
`amy` is built for three audiences at once: humans at a terminal,
|
||||
agents/LLMs driving an account through a deterministic JSON interface,
|
||||
and interop test harnesses pinning Amethyst against the rest of the
|
||||
Nostr-client ecosystem.
|
||||
|
||||
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.
|
||||
|
||||
This file documents the **public contract** and the on-disk layout. For
|
||||
hands-on instructions and worked examples, see
|
||||
[USAGE.md](./USAGE.md). To extend amy, see
|
||||
[DEVELOPMENT.md](./DEVELOPMENT.md). For what's coming, see
|
||||
[ROADMAP.md](./ROADMAP.md).
|
||||
> **Looking for the architecture and the public-API contract?** See
|
||||
> [DEVELOPMENT.md](./DEVELOPMENT.md). For what's coming, see
|
||||
> [ROADMAP.md](./ROADMAP.md).
|
||||
|
||||
---
|
||||
|
||||
## Output contract
|
||||
## Install
|
||||
|
||||
What every caller — user, script, agent, CI — can rely on:
|
||||
`amy` builds from this repository — no package manager yet.
|
||||
|
||||
- **Default stdout is human-readable text.** A YAML-ish render of the
|
||||
underlying result map. Friendly at a terminal; no shape promises.
|
||||
- **`--json` is the machine contract. One line. One object.** Stable
|
||||
snake_case keys. Pipe it into `jq`, parse it from Python, hand it to
|
||||
an agent. Pass `--json` anywhere before the subcommand.
|
||||
- **stderr is for humans.** Progress, warnings, per-relay ACK traces.
|
||||
Safe to discard. Errors land here too: `error: <code>: <detail>` by
|
||||
default, or JSON `{"error":"…","detail":"…"}` under `--json`.
|
||||
- **Exit codes are the real signal.**
|
||||
- `0` — success
|
||||
- `1` — runtime error
|
||||
- `2` — bad arguments
|
||||
- `124` — `await` timed out
|
||||
- **No interactive prompts, ever.** Passwords, names, keys — all flags.
|
||||
- **`~/.amy/` is the whole world.** Per-account dirs hold identity,
|
||||
cursors, MLS state, and aliases at `~/.amy/<account>/`; every observed
|
||||
Nostr event lands in `~/.amy/shared/events-store/`. Delete to reset;
|
||||
copy to move. Tests isolate by overriding `$HOME` for the amy
|
||||
subprocess (`HOME=/tmp/run.123 amy --account alice …`) — same
|
||||
convention `git`, `gpg`, and `npm` use.
|
||||
```bash
|
||||
# build the runnable distribution
|
||||
./gradlew :cli:installDist
|
||||
|
||||
Only the `--json` shape and the exit codes are public API. The default
|
||||
text format is allowed to change between releases. The rationale lives
|
||||
in [DEVELOPMENT.md](./DEVELOPMENT.md).
|
||||
# the launch script
|
||||
./cli/build/install/amy/bin/amy --help
|
||||
|
||||
---
|
||||
|
||||
## Local event store — the source of truth
|
||||
|
||||
Every Nostr event amy observes is verified (NIP-01 id + signature
|
||||
check) and persisted to a file-backed store at
|
||||
`~/.amy/shared/events-store/` (one store per machine, shared across
|
||||
every account in `~/.amy/`). That includes:
|
||||
|
||||
- events received from any relay subscription (`amy notes feed`,
|
||||
`amy dm list`, `amy marmot key-package publish`, group sync, …),
|
||||
- events amy generates and publishes itself,
|
||||
- inner events unwrapped from NIP-59 gift wraps.
|
||||
|
||||
Malformed events are dropped before reaching command code. Persistence
|
||||
is best-effort — if the store fails (full disk, permissions), the relay
|
||||
subscription still works, but the event is not cached.
|
||||
|
||||
The store is the authoritative cache of everything amy has seen:
|
||||
profile metadata, relay lists (NIP-65 and NIP-02), gift wraps, group
|
||||
events, follow lists, etc. Commands that need any of these read from
|
||||
the store first and only fall back to a relay fetch on miss. Three
|
||||
convenience helpers exist on `Context`:
|
||||
|
||||
```kotlin
|
||||
ctx.profileOf(pubKey) // latest kind:0 (NIP-01)
|
||||
ctx.relaysOf(pubKey) // latest kind:10002 (NIP-65)
|
||||
ctx.contactsOf(pubKey) // latest kind:3 (NIP-02)
|
||||
ctx.dmInboxOf(pubKey) // latest kind:10050 (NIP-17 DM inbox)
|
||||
ctx.keyPackageRelaysOf(pubKey) // latest kind:10051 (MIP-00 KP relays)
|
||||
ctx.cachedRelayListsOf(pubKey) // RecipientRelayFetcher.Lists from cache
|
||||
# put it on your PATH if you want
|
||||
ln -s "$PWD/cli/build/install/amy/bin/amy" ~/.local/bin/amy
|
||||
```
|
||||
|
||||
The store implements every feature of the Quartz SQLite store —
|
||||
NIP-01 replaceable / addressable uniqueness, NIP-09 deletion
|
||||
tombstones, NIP-40 expiration, NIP-50 search, NIP-62 right-to-vanish,
|
||||
NIP-91 multi-tag AND. See
|
||||
[`cli/plans/2026-04-24-file-event-store-*.md`](./plans/) for the design
|
||||
and `quartz/.../store/fs/FsEventStore.kt` for the implementation. The
|
||||
on-disk layout is plain JSON files under shard directories,
|
||||
intentionally inspectable with `ls`, `cat`, `jq`, `grep`, `find`,
|
||||
`rsync`, and `git`. Deleting an event file is treated as a deliberate
|
||||
"I never saw this" by amy; dangling indexes are skipped at query time
|
||||
and can be cleaned up with `amy store scrub` / `amy store compact`.
|
||||
Requires **JDK 21**. All state lives under `~/.amy/` — delete to reset.
|
||||
|
||||
---
|
||||
|
||||
## Relay routing
|
||||
## Quick start
|
||||
|
||||
amy follows the Marmot protocol's per-event routing rules so two users
|
||||
with completely disjoint relay configurations can still marmot each
|
||||
other. No event ever ships blindly to "our configured relays" — amy
|
||||
looks up the right relay set per event per recipient.
|
||||
```bash
|
||||
# 1. Create an account named alice — keypair, default relays, kind:0 metadata,
|
||||
# everything Amethyst stamps on first run.
|
||||
amy --account alice create --name "Alice"
|
||||
|
||||
| Event | Publish to | Fetch from |
|
||||
|---|---|---|
|
||||
| kind:30443 (our own KeyPackage) | `key_package` bucket → NIP-65 outbox → any configured | — |
|
||||
| kind:30443 (someone else's KeyPackage) | — | Their kind:10051 → their kind:10002 write → our bootstrap pool |
|
||||
| kind:10051 / 10050 / 10002 (our own lists) | All configured relays (broadcast) | — |
|
||||
| kind:10051 / 10050 / 10002 (someone else's) | — | Our bootstrap pool = configured relays ∪ Amethyst defaults |
|
||||
| kind:1059 Welcome gift wrap (kind:444 inside) | Recipient's kind:10050 → their kind:10002 read → `DefaultDMRelayList` → our outbox | — |
|
||||
| kind:1059 gift wraps addressed to us | — | Our kind:10050 |
|
||||
| kind:445 Group Event (Commit / Proposal / chat) | Group's MIP-01 `relays` field | Same |
|
||||
# 2. With one account you can drop the flag from now on (auto-pick).
|
||||
amy whoami
|
||||
|
||||
**Bootstrap pool**: when amy needs to discover a user it's never talked
|
||||
to, it queries `configured relays ∪ Amethyst's default NIP-65 set ∪
|
||||
Amethyst's default DM-inbox set`. These defaults come from
|
||||
`commons.defaults.AmethystDefaults` and match what the Android/Desktop
|
||||
UI publishes to on first run, so any fresh Amethyst account is
|
||||
reachable via the bootstrap pool even before amy has seen any of their
|
||||
events.
|
||||
# 3. Post a short note.
|
||||
amy notes post "hello from amy"
|
||||
|
||||
# 4. Send a NIP-17 DM.
|
||||
amy dm send bob@example.com "hey"
|
||||
|
||||
# 5. Read your inbox.
|
||||
amy dm list
|
||||
```
|
||||
|
||||
That's the full loop. Add `--json` to any command if you want a single-line
|
||||
JSON object instead of human-readable text — same data, machine shape.
|
||||
|
||||
---
|
||||
|
||||
## On-disk layout
|
||||
## Examples
|
||||
|
||||
### 1. Post a note
|
||||
|
||||
```text
|
||||
$ amy notes post "good morning nostr"
|
||||
|
||||
event_id: a3c1f9c2…(64 hex)
|
||||
kind: 1
|
||||
accepted_by:
|
||||
- wss://relay.damus.io/
|
||||
- wss://nos.lol/
|
||||
rejected_by: (none)
|
||||
```
|
||||
|
||||
`amy notes feed` reads recent kind:1 notes from your follows; `--limit N`
|
||||
caps the count, `--author npub1…` narrows to one user.
|
||||
|
||||
### 2. Send a direct message
|
||||
|
||||
```text
|
||||
$ amy dm send npub1uu8m… "lunch friday?"
|
||||
|
||||
event_id: 18bd0a7e…
|
||||
kind: 14
|
||||
recipients:
|
||||
- pubkey: e70fb804…
|
||||
relay_source: kind_10050
|
||||
relays:
|
||||
- wss://nostr.wine/
|
||||
```
|
||||
|
||||
`recipients[*].relay_source` tells you how amy resolved the recipient's
|
||||
inbox — `kind_10050` is the strict NIP-17 inbox; `nip65_read` /
|
||||
`bootstrap` only fire when you pass `--allow-fallback`.
|
||||
|
||||
### 3. Read a DM thread
|
||||
|
||||
```text
|
||||
$ amy dm list --peer npub1uu8m… --limit 5
|
||||
|
||||
messages:
|
||||
- event_id: a82f04e1…
|
||||
author: 71cf3ab2…
|
||||
type: text
|
||||
created_at: 2026-04-25 13:42:11Z (8m ago)
|
||||
content: sounds good
|
||||
- event_id: 18bd0a7e…
|
||||
author: e70fb804…
|
||||
type: text
|
||||
created_at: 2026-04-25 13:30:02Z (20m ago)
|
||||
content: lunch friday?
|
||||
```
|
||||
|
||||
`amy dm await --peer NPUB --match TEXT --timeout 60` blocks until a matching
|
||||
DM arrives — useful in scripts.
|
||||
|
||||
### 4. View a profile
|
||||
|
||||
```text
|
||||
$ amy profile show npub1th9z…
|
||||
|
||||
pubkey: 5dca27ae…
|
||||
found: yes
|
||||
source: cache
|
||||
event_id: a041df5a…
|
||||
created_at: 2026-04-25 13:36:23Z (1h ago)
|
||||
metadata:
|
||||
name: Alice
|
||||
picture: https://example.test/a.png
|
||||
about: demo identity
|
||||
nip05: alice@example.test
|
||||
queried_relays: (none)
|
||||
```
|
||||
|
||||
`source: cache` means the local store served the lookup; pass `--refresh` to
|
||||
force a relay round-trip. Profiles for `name@domain.tld` (NIP-05) are
|
||||
resolved transparently.
|
||||
|
||||
### 5. Create a group, invite someone, send a message
|
||||
|
||||
```bash
|
||||
# Mint a group and invite Bob.
|
||||
GID=$(amy --json marmot group create --name "Lunch Plans" | jq -r .group_id)
|
||||
amy marmot group add "$GID" npub1...bob
|
||||
|
||||
# Send an MLS-encrypted message.
|
||||
amy marmot message send "$GID" "hello group"
|
||||
```
|
||||
|
||||
On the other side:
|
||||
|
||||
```bash
|
||||
# Bob waits for the invite to land, then sees the message.
|
||||
amy --account bob marmot await group --name "Lunch Plans" --timeout 60
|
||||
amy --account bob marmot message list "$GID"
|
||||
```
|
||||
|
||||
### 6. Switch between accounts
|
||||
|
||||
```text
|
||||
$ amy whoami
|
||||
error: bad_args: multiple accounts in /home/me/.amy (alice, bob); pick one with --account <name> or `amy use <name>`
|
||||
|
||||
$ amy use bob
|
||||
current: bob
|
||||
root: /home/me/.amy
|
||||
|
||||
$ amy whoami
|
||||
name: bob
|
||||
npub: npub1uu8m…
|
||||
data_dir: /home/me/.amy/bob
|
||||
```
|
||||
|
||||
`amy use --clear` removes the pin; `amy --account alice <cmd>` overrides
|
||||
it for one command.
|
||||
|
||||
### 7. Add a relay
|
||||
|
||||
```text
|
||||
$ amy relay add wss://nostr.wine
|
||||
|
||||
url: wss://nostr.wine
|
||||
added_to:
|
||||
- nip65
|
||||
- inbox
|
||||
- key_package
|
||||
already_present: (none)
|
||||
|
||||
$ amy relay publish-lists # broadcast updated kind:10002/10050/10051
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
### Identity
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `amy --account NAME init [--nsec NSEC]` | Create or import a bare keypair. No relay traffic. |
|
||||
| `amy --account NAME create [--name X]` | Full Amethyst-style bootstrap: keypair, default relays, kind:0, kind:3, the works. |
|
||||
| `amy login KEY [--password X]` | Import an existing identity (`nsec`/`ncryptsec`/mnemonic/`npub`/`nprofile`/hex/NIP-05). |
|
||||
| `amy whoami` | Print the active account's name + npub. |
|
||||
| `amy use NAME` / `--clear` / no-arg | Pin / clear / inspect the active account. |
|
||||
|
||||
### Social
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `amy notes post TEXT [--relay URL]` | Publish a kind:1 short text note. |
|
||||
| `amy notes feed [--author USER \| --following] [--limit N]` | Read recent kind:1 notes (yours, one user's, or your follow set). |
|
||||
| `amy profile show [USER]` | Print kind:0 metadata. USER accepts npub/nprofile/hex/NIP-05; defaults to self. |
|
||||
| `amy profile edit --name … --about … --picture URL …` | Patch and re-publish your kind:0. |
|
||||
|
||||
### Direct messages (NIP-17)
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `amy dm send RECIPIENT TEXT [--allow-fallback]` | Gift-wrap a kind:14 to RECIPIENT. Strict kind:10050 routing by default. |
|
||||
| `amy dm send-file RECIPIENT --file PATH --server URL` | Encrypt a local file, upload to a Blossom server, publish a kind:15 referencing it. |
|
||||
| `amy dm send-file RECIPIENT URL --key HEX --nonce HEX` | Reference-mode: file already uploaded; just publish the kind:15. |
|
||||
| `amy dm list [--peer NPUB] [--since TS] [--limit N]` | Drain and decrypt gift wraps. |
|
||||
| `amy dm await --peer NPUB --match TEXT [--timeout SECS]` | Block until a matching DM arrives. |
|
||||
|
||||
### Groups (Marmot / MLS)
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `amy marmot key-package publish` | Publish a fresh KeyPackage so others can invite you. |
|
||||
| `amy marmot key-package check NPUB` | Look up someone else's KeyPackage on relays. |
|
||||
| `amy marmot group create [--name X]` | New empty group with you as sole admin. |
|
||||
| `amy marmot group list` | All groups you're a member of. |
|
||||
| `amy marmot group show GID` | Members, admins, epoch, metadata. |
|
||||
| `amy marmot group add GID NPUB [NPUB…]` | Fetch KeyPackages and invite. |
|
||||
| `amy marmot group rename GID NAME` | Commit a metadata change. |
|
||||
| `amy marmot group promote / demote / remove GID NPUB` | Admin verbs. |
|
||||
| `amy marmot group leave GID` | Self-remove. |
|
||||
| `amy marmot message send GID TEXT` | Publish a kind:9 inner event into the group. |
|
||||
| `amy marmot message list GID [--limit N]` | Decrypted inner events, oldest first. |
|
||||
| `amy marmot message react GID EVENT_ID EMOJI` | Publish a kind:7 reaction. |
|
||||
| `amy marmot message delete GID EVENT_ID …` | Publish a kind:5 deletion. |
|
||||
|
||||
### Wait-for-condition (`await`)
|
||||
|
||||
Every `await` verb blocks until the condition holds, then prints the
|
||||
matching event/state. All accept `--timeout SECS` (default 30); on
|
||||
timeout the exit code is **124** so scripts can tell "didn't happen"
|
||||
from "command crashed".
|
||||
|
||||
| Command | Blocks until… |
|
||||
|---|---|
|
||||
| `amy marmot await key-package NPUB` | NPUB has a KeyPackage discoverable on their advertised relays. |
|
||||
| `amy marmot await group --name X` | You've been added to a group with that name. |
|
||||
| `amy marmot await member GID NPUB` | NPUB is in GID's member set. |
|
||||
| `amy marmot await admin GID NPUB` | NPUB is an admin of GID. |
|
||||
| `amy marmot await message GID --match TEXT` | A message containing TEXT lands in GID. |
|
||||
| `amy marmot await rename GID --name X` | GID's name matches X. |
|
||||
| `amy marmot await epoch GID --min N` | GID's MLS epoch reaches N. |
|
||||
| `amy dm await --peer NPUB --match TEXT` | A matching DM from NPUB arrives. |
|
||||
|
||||
### Relays
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `amy relay add URL [--type T]` | Add URL to a bucket: `nip65`, `inbox`, `key_package`, or `all`. |
|
||||
| `amy relay list` | Print the configured relays per bucket. |
|
||||
| `amy relay publish-lists` | Broadcast your kind:10002 / 10050 / 10051. |
|
||||
|
||||
### Local store maintenance
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `amy store stat` | Event count, kind histogram, disk usage, oldest/newest timestamps. |
|
||||
| `amy store sweep-expired` | Delete events past their NIP-40 expiration. |
|
||||
| `amy store scrub` | Rebuild the index after external edits or a crash. |
|
||||
| `amy store compact` | Drop dangling index entries (canonical event already gone). |
|
||||
|
||||
---
|
||||
|
||||
## Output: text by default, JSON on demand
|
||||
|
||||
By default amy writes a YAML-ish, colored, human-readable result to
|
||||
stdout. Pass `--json` and stdout becomes a single-line JSON object —
|
||||
same data, stable snake_case keys, ready for `jq`:
|
||||
|
||||
```bash
|
||||
$ amy --json whoami
|
||||
{"name":"alice","npub":"npub1th9z…","hex":"5dca27ae…","data_dir":"/home/me/.amy/alice"}
|
||||
|
||||
$ amy --json marmot group create --name "Lunch" | jq -r .group_id
|
||||
ab12cd34…
|
||||
```
|
||||
|
||||
Errors mirror the same rule. Default:
|
||||
|
||||
```text
|
||||
$ amy marmot group show abc123
|
||||
error: not_member: abc123 # exit 1
|
||||
```
|
||||
|
||||
Under `--json` the error goes to stderr as `{"error":"not_member","detail":"abc123"}`.
|
||||
|
||||
Color auto-disables when stdout is a pipe; force it with `CLICOLOR_FORCE=1`,
|
||||
turn it off entirely with `NO_COLOR=1`.
|
||||
|
||||
**Exit codes** — the real signal for scripts:
|
||||
|
||||
| Code | Meaning |
|
||||
|---|---|
|
||||
| 0 | success |
|
||||
| 1 | runtime error (network, permission, NIP rejection, …) |
|
||||
| 2 | bad arguments |
|
||||
| 124 | `await` timed out |
|
||||
|
||||
---
|
||||
|
||||
## Multi-account workflows
|
||||
|
||||
`amy` is built to host more than one identity per machine. The layout
|
||||
matches that:
|
||||
|
||||
```
|
||||
~/.amy/ ← root, follows $HOME
|
||||
├── current # marker file written by `amy use NAME`
|
||||
~/.amy/
|
||||
├── current # marker: which account `amy use NAME` pinned
|
||||
├── shared/
|
||||
│ └── events-store/ # FsEventStore — every observed Nostr event
|
||||
│ ├── events/<aa>/<bb>/… # canonical kind:0 / 3 / 10002 / 10050 / 10051 / 1 / 5 / 1059 / …
|
||||
│ ├── replaceable/<k>/… # one slot per (kind, pubkey) for kind:0/3/10000-19999
|
||||
│ ├── addressable/… # one slot per (kind, pubkey, d-tag) for kind:30000-39999
|
||||
│ ├── idx/ # hardlink indexes (kind / author / owner / tag / fts / expires_at)
|
||||
│ └── tombstones/ # NIP-09 / NIP-62 enforcement
|
||||
├── alice/ # one dir per account (e.g. created by `amy --account alice init`)
|
||||
│ ├── identity.json # nsec/npub/hex — the account
|
||||
│ ├── state.json # sync cursors (giftWrapSince, groupSince)
|
||||
│ ├── aliases.json # local name → npub map (init writes a self-entry)
|
||||
│ └── marmot/
|
||||
│ ├── keypackages.bundle # MLS KeyPackage bundles (NostrSignerInternal)
|
||||
│ └── groups/
|
||||
│ ├── <gid>.mls # MLS group state per group
|
||||
│ └── <gid>.log # decrypted inner events (one JSON per line)
|
||||
└── bob/ ... # additional accounts sit alongside
|
||||
│ └── events-store/ # one Nostr event store, shared by every account
|
||||
├── alice/
|
||||
│ ├── identity.json # keypair (or reference to keychain entry)
|
||||
│ ├── state.json # sync cursors
|
||||
│ ├── aliases.json # local name → npub map
|
||||
│ └── marmot/ # MLS state per group
|
||||
└── bob/
|
||||
└── …
|
||||
```
|
||||
|
||||
All files are plain JSON or framed binary — human-inspectable, easy to
|
||||
diff across two accounts. Two accounts on the same machine share
|
||||
`~/.amy/shared/events-store/`, so a public event observed once doesn't
|
||||
get re-stored per account.
|
||||
**Account selection** when you don't pass `--account`:
|
||||
|
||||
The local relay configuration (kind:10002 / 10050 / 10051) is **not** a
|
||||
separate file — it lives in the shared `events-store/` as signed events
|
||||
owned by the account that wrote them. `amy relay add` builds + signs +
|
||||
ingests a new relay-list event; `amy relay list` reads URLs straight
|
||||
out of the latest event for each kind; `amy relay publish-lists`
|
||||
broadcasts those events to upstream relays. There is no `relays.json`.
|
||||
1. If `~/.amy/current` is set, use it.
|
||||
2. Else if exactly one account exists, use it (silent auto-pick).
|
||||
3. Else error and list the candidates so you can disambiguate.
|
||||
|
||||
`amy use NAME` writes `~/.amy/current`; `amy use --clear` removes it.
|
||||
For one-off override, prepend `--account NAME` to any command.
|
||||
|
||||
`init` and `create` write a self-entry into `aliases.json` so you can
|
||||
refer to your own account by name in future commands. The alias resolver
|
||||
in recipient slots (`amy dm send alice "hi"`) is on the roadmap.
|
||||
|
||||
For the deeper layout (events-store internals, relay-routing rules, the
|
||||
public-contract guarantees) see [DEVELOPMENT.md](./DEVELOPMENT.md).
|
||||
|
||||
---
|
||||
|
||||
## For agents and scripts
|
||||
|
||||
Three contracts keep amy machine-safe:
|
||||
|
||||
1. **One JSON object per success on stdout** under `--json`. Stable
|
||||
snake_case keys; keys never disappear silently.
|
||||
2. **Errors as JSON on stderr** under `--json`: `{"error":"...","detail":"..."}`.
|
||||
3. **Exit codes mean specific things** (table above) — `124` for
|
||||
`await` timeout in particular lets you distinguish "condition never
|
||||
happened" from "the command itself crashed".
|
||||
|
||||
### Recipes
|
||||
|
||||
```bash
|
||||
# Capture a fresh group's id.
|
||||
GID=$(amy --json marmot group create --name "ops" | jq -r .group_id)
|
||||
|
||||
# Add several members at once and report which KeyPackages were missing.
|
||||
amy --json marmot group add "$GID" npub1aaa npub1bbb npub1ccc \
|
||||
| jq -r '.added[] | select(.status != "ok") | "missing: \(.pubkey)"'
|
||||
|
||||
# Wait up to 5 minutes for a particular message and capture its event id.
|
||||
EVT=$(amy --json marmot await message "$GID" --match "deploy starting" --timeout 300 \
|
||||
| jq -r .event_id)
|
||||
|
||||
# Run a command per follow.
|
||||
amy --json notes feed --following --limit 50 \
|
||||
| jq -r '.notes[].author' \
|
||||
| sort -u \
|
||||
| while read -r author; do
|
||||
amy --json profile show "$author" | jq -r '.metadata.name // "?"'
|
||||
done
|
||||
```
|
||||
|
||||
### Test isolation
|
||||
|
||||
amy reads `$HOME` directly to find `~/.amy/`, so harnesses isolate the
|
||||
exact same way `git`, `gpg`, `npm`, and `ssh` do — by overriding `$HOME`
|
||||
for the subprocess:
|
||||
|
||||
```bash
|
||||
HOME=$(mktemp -d) amy --account alice init
|
||||
HOME=$(mktemp -d) amy --account alice marmot group create --name "scratch"
|
||||
```
|
||||
|
||||
Inside the amy process there's no test mode — it just sees a fresh
|
||||
`~/.amy/` and behaves like a brand-new install.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **`no account at ~/.amy`** — you haven't created one yet. Run
|
||||
`amy --account NAME init` (bare keypair) or `amy --account NAME create`
|
||||
(full Amethyst-style bootstrap).
|
||||
- **`multiple accounts in ~/.amy (alice, bob)`** — pin one with
|
||||
`amy use NAME` or pass `--account NAME` per command.
|
||||
- **`current pins 'X' but ~/.amy/X doesn't exist`** — the active-account
|
||||
marker is stale. Rewrite with `amy use OTHER` or `amy use --clear`.
|
||||
- **`no_dm_relays`** — recipient hasn't published a kind:10050 inbox.
|
||||
Pass `--allow-fallback` to fall back to their kind:10002 read marker
|
||||
→ bootstrap pool. Or wait for them to publish one.
|
||||
- **`not_member`** — the group GID is unknown to this account. Run
|
||||
`amy marmot group list` to see what you're in, or `await group --name X`
|
||||
to wait for an invite.
|
||||
- **A network verb hangs** — every network verb has a relay timeout.
|
||||
Inspect what amy is connecting to with `amy relay list`. Wrap any
|
||||
command in `timeout(1)` if you're scripting and want a hard ceiling.
|
||||
- **Nothing seems to publish** — stderr carries `[cli] …` traces with
|
||||
per-relay `OK` / `REJECT`. Capture with `2> /tmp/amy.log` and grep.
|
||||
|
||||
---
|
||||
|
||||
## Where to go next
|
||||
|
||||
- **[USAGE.md](./USAGE.md)** — install, quick start, worked examples,
|
||||
the command reference, troubleshooting.
|
||||
- **[DEVELOPMENT.md](./DEVELOPMENT.md)** — design principles,
|
||||
architecture, how to add a command without breaking the contract.
|
||||
- **[ROADMAP.md](./ROADMAP.md)** — north-star goal and the parity
|
||||
matrix tracking what's left to extract from the Android app.
|
||||
architecture, the public contract, the local event store, relay
|
||||
routing, full on-disk layout, how to extend amy without breaking it.
|
||||
- **[ROADMAP.md](./ROADMAP.md)** — north-star goal and the parity matrix
|
||||
tracking what's left to extract from the Android app.
|
||||
- **[`plans/`](./plans/)** — design docs for cross-cutting work
|
||||
(CLI distribution, file-backed event store, NIP-17 DMs, …).
|
||||
- **[Nostr NIPs](https://github.com/nostr-protocol/nips)** — the
|
||||
protocol amy speaks.
|
||||
|
||||
+2
-3
@@ -7,9 +7,8 @@ full command-line mirror of Amethyst.
|
||||
feature. Move rows between tables, adjust ordering, add non-goals.
|
||||
This is the single source of truth for "what's left".
|
||||
|
||||
- What amy is + the public contract: [README.md](./README.md)
|
||||
- How to use it (examples + walkthroughs): [USAGE.md](./USAGE.md)
|
||||
- How to implement an item: [DEVELOPMENT.md](./DEVELOPMENT.md)
|
||||
- What amy is + how to use it: [README.md](./README.md)
|
||||
- The public contract + how to extend amy: [DEVELOPMENT.md](./DEVELOPMENT.md)
|
||||
- Ongoing design plans: [plans/](./plans/)
|
||||
- Shared work consumed here: [../commons/plans/](../commons/plans/)
|
||||
|
||||
|
||||
-431
@@ -1,431 +0,0 @@
|
||||
# amy — using the Amethyst CLI
|
||||
|
||||
`amy` is the command-line face of [Amethyst](https://github.com/vitorpamplona/amethyst).
|
||||
It speaks the same Nostr protocol as the Android and Desktop apps and shares
|
||||
their codebase. From a terminal you can post notes, send NIP-17 DMs,
|
||||
manage MLS group chats, switch identities, and pipe machine-readable JSON
|
||||
into the rest of your toolbox.
|
||||
|
||||
> This file is the user-facing tour. For the reference table and the
|
||||
> on-disk contract see [README.md](./README.md); for the design rules
|
||||
> see [DEVELOPMENT.md](./DEVELOPMENT.md).
|
||||
|
||||
---
|
||||
|
||||
## Install
|
||||
|
||||
`amy` builds from this repository — no package manager yet.
|
||||
|
||||
```bash
|
||||
# build the runnable distribution
|
||||
./gradlew :cli:installDist
|
||||
|
||||
# the launch script
|
||||
./cli/build/install/amy/bin/amy --help
|
||||
|
||||
# put it on your PATH if you want
|
||||
ln -s "$PWD/cli/build/install/amy/bin/amy" ~/.local/bin/amy
|
||||
```
|
||||
|
||||
Requires **JDK 21**. All state lives under `~/.amy/` — delete to reset.
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# 1. Create an account named alice — keypair, default relays, kind:0 metadata,
|
||||
# everything Amethyst stamps on first run.
|
||||
amy --account alice create --name "Alice"
|
||||
|
||||
# 2. With one account you can drop the flag from now on (auto-pick).
|
||||
amy whoami
|
||||
|
||||
# 3. Post a short note.
|
||||
amy notes post "hello from amy"
|
||||
|
||||
# 4. Send a NIP-17 DM.
|
||||
amy dm send bob@example.com "hey"
|
||||
|
||||
# 5. Read your inbox.
|
||||
amy dm list
|
||||
```
|
||||
|
||||
That's the full loop. Add `--json` to any command if you want a single-line
|
||||
JSON object instead of human-readable text — same data, machine shape.
|
||||
|
||||
---
|
||||
|
||||
## Examples
|
||||
|
||||
### 1. Post a note
|
||||
|
||||
```text
|
||||
$ amy notes post "good morning nostr"
|
||||
|
||||
event_id: a3c1f9c2…(64 hex)
|
||||
kind: 1
|
||||
accepted_by:
|
||||
- wss://relay.damus.io/
|
||||
- wss://nos.lol/
|
||||
rejected_by: (none)
|
||||
```
|
||||
|
||||
`amy notes feed` reads recent kind:1 notes from your follows; `--limit N`
|
||||
caps the count, `--author npub1…` narrows to one user.
|
||||
|
||||
### 2. Send a direct message
|
||||
|
||||
```text
|
||||
$ amy dm send npub1uu8m… "lunch friday?"
|
||||
|
||||
event_id: 18bd0a7e…
|
||||
kind: 14
|
||||
recipients:
|
||||
- pubkey: e70fb804…
|
||||
relay_source: kind_10050
|
||||
relays:
|
||||
- wss://nostr.wine/
|
||||
```
|
||||
|
||||
`recipients[*].relay_source` tells you how amy resolved the recipient's
|
||||
inbox — `kind_10050` is the strict NIP-17 inbox; `nip65_read` /
|
||||
`bootstrap` only fire when you pass `--allow-fallback`.
|
||||
|
||||
### 3. Read a DM thread
|
||||
|
||||
```text
|
||||
$ amy dm list --peer npub1uu8m… --limit 5
|
||||
|
||||
messages:
|
||||
- event_id: a82f04e1…
|
||||
author: 71cf3ab2…
|
||||
type: text
|
||||
created_at: 2026-04-25 13:42:11Z (8m ago)
|
||||
content: sounds good
|
||||
- event_id: 18bd0a7e…
|
||||
author: e70fb804…
|
||||
type: text
|
||||
created_at: 2026-04-25 13:30:02Z (20m ago)
|
||||
content: lunch friday?
|
||||
```
|
||||
|
||||
`amy dm await --peer NPUB --match TEXT --timeout 60` blocks until a matching
|
||||
DM arrives — useful in scripts.
|
||||
|
||||
### 4. View a profile
|
||||
|
||||
```text
|
||||
$ amy profile show npub1th9z…
|
||||
|
||||
pubkey: 5dca27ae…
|
||||
found: yes
|
||||
source: cache
|
||||
event_id: a041df5a…
|
||||
created_at: 2026-04-25 13:36:23Z (1h ago)
|
||||
metadata:
|
||||
name: Alice
|
||||
picture: https://example.test/a.png
|
||||
about: demo identity
|
||||
nip05: alice@example.test
|
||||
queried_relays: (none)
|
||||
```
|
||||
|
||||
`source: cache` means the local store served the lookup; pass `--refresh` to
|
||||
force a relay round-trip. Profiles for `name@domain.tld` (NIP-05) are
|
||||
resolved transparently.
|
||||
|
||||
### 5. Create a group, invite someone, send a message
|
||||
|
||||
```bash
|
||||
# Mint a group and invite Bob.
|
||||
GID=$(amy --json marmot group create --name "Lunch Plans" | jq -r .group_id)
|
||||
amy marmot group add "$GID" npub1...bob
|
||||
|
||||
# Send an MLS-encrypted message.
|
||||
amy marmot message send "$GID" "hello group"
|
||||
```
|
||||
|
||||
On the other side:
|
||||
|
||||
```bash
|
||||
# Bob waits for the invite to land, then sees the message.
|
||||
amy --account bob marmot await group --name "Lunch Plans" --timeout 60
|
||||
amy --account bob marmot message list "$GID"
|
||||
```
|
||||
|
||||
### 6. Switch between accounts
|
||||
|
||||
```text
|
||||
$ amy whoami
|
||||
error: bad_args: multiple accounts in /home/me/.amy (alice, bob); pick one with --account <name> or `amy use <name>`
|
||||
|
||||
$ amy use bob
|
||||
current: bob
|
||||
root: /home/me/.amy
|
||||
|
||||
$ amy whoami
|
||||
name: bob
|
||||
npub: npub1uu8m…
|
||||
data_dir: /home/me/.amy/bob
|
||||
```
|
||||
|
||||
`amy use --clear` removes the pin; `amy --account alice <cmd>` overrides
|
||||
it for one command.
|
||||
|
||||
### 7. Add a relay
|
||||
|
||||
```text
|
||||
$ amy relay add wss://nostr.wine
|
||||
|
||||
url: wss://nostr.wine
|
||||
added_to:
|
||||
- nip65
|
||||
- inbox
|
||||
- key_package
|
||||
already_present: (none)
|
||||
|
||||
$ amy relay publish-lists # broadcast updated kind:10002/10050/10051
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
### Identity
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `amy --account NAME init [--nsec NSEC]` | Create or import a bare keypair. No relay traffic. |
|
||||
| `amy --account NAME create [--name X]` | Full Amethyst-style bootstrap: keypair, default relays, kind:0, kind:3, the works. |
|
||||
| `amy login KEY [--password X]` | Import an existing identity (`nsec`/`ncryptsec`/mnemonic/`npub`/`nprofile`/hex/NIP-05). |
|
||||
| `amy whoami` | Print the active account's name + npub. |
|
||||
| `amy use NAME` / `--clear` / no-arg | Pin / clear / inspect the active account. |
|
||||
|
||||
### Social
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `amy notes post TEXT [--relay URL]` | Publish a kind:1 short text note. |
|
||||
| `amy notes feed [--author USER \| --following] [--limit N]` | Read recent kind:1 notes (yours, one user's, or your follow set). |
|
||||
| `amy profile show [USER]` | Print kind:0 metadata. USER accepts npub/nprofile/hex/NIP-05; defaults to self. |
|
||||
| `amy profile edit --name … --about … --picture URL …` | Patch and re-publish your kind:0. |
|
||||
|
||||
### Direct messages (NIP-17)
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `amy dm send RECIPIENT TEXT [--allow-fallback]` | Gift-wrap a kind:14 to RECIPIENT. Strict kind:10050 routing by default. |
|
||||
| `amy dm send-file RECIPIENT --file PATH --server URL` | Encrypt a local file, upload to a Blossom server, publish a kind:15 referencing it. |
|
||||
| `amy dm send-file RECIPIENT URL --key HEX --nonce HEX` | Reference-mode: file already uploaded; just publish the kind:15. |
|
||||
| `amy dm list [--peer NPUB] [--since TS] [--limit N]` | Drain and decrypt gift wraps. |
|
||||
| `amy dm await --peer NPUB --match TEXT [--timeout SECS]` | Block until a matching DM arrives. |
|
||||
|
||||
### Groups (Marmot / MLS)
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `amy marmot key-package publish` | Publish a fresh KeyPackage so others can invite you. |
|
||||
| `amy marmot key-package check NPUB` | Look up someone else's KeyPackage on relays. |
|
||||
| `amy marmot group create [--name X]` | New empty group with you as sole admin. |
|
||||
| `amy marmot group list` | All groups you're a member of. |
|
||||
| `amy marmot group show GID` | Members, admins, epoch, metadata. |
|
||||
| `amy marmot group add GID NPUB [NPUB…]` | Fetch KeyPackages and invite. |
|
||||
| `amy marmot group rename GID NAME` | Commit a metadata change. |
|
||||
| `amy marmot group promote / demote / remove GID NPUB` | Admin verbs. |
|
||||
| `amy marmot group leave GID` | Self-remove. |
|
||||
| `amy marmot message send GID TEXT` | Publish a kind:9 inner event into the group. |
|
||||
| `amy marmot message list GID [--limit N]` | Decrypted inner events, oldest first. |
|
||||
| `amy marmot message react GID EVENT_ID EMOJI` | Publish a kind:7 reaction. |
|
||||
| `amy marmot message delete GID EVENT_ID …` | Publish a kind:5 deletion. |
|
||||
|
||||
### Wait-for-condition (`await`)
|
||||
|
||||
Every `await` verb blocks until the condition holds, then prints the
|
||||
matching event/state. All accept `--timeout SECS` (default 30); on
|
||||
timeout the exit code is **124** so scripts can tell "didn't happen"
|
||||
from "command crashed".
|
||||
|
||||
| Command | Blocks until… |
|
||||
|---|---|
|
||||
| `amy marmot await key-package NPUB` | NPUB has a KeyPackage discoverable on their advertised relays. |
|
||||
| `amy marmot await group --name X` | You've been added to a group with that name. |
|
||||
| `amy marmot await member GID NPUB` | NPUB is in GID's member set. |
|
||||
| `amy marmot await admin GID NPUB` | NPUB is an admin of GID. |
|
||||
| `amy marmot await message GID --match TEXT` | A message containing TEXT lands in GID. |
|
||||
| `amy marmot await rename GID --name X` | GID's name matches X. |
|
||||
| `amy marmot await epoch GID --min N` | GID's MLS epoch reaches N. |
|
||||
| `amy dm await --peer NPUB --match TEXT` | A matching DM from NPUB arrives. |
|
||||
|
||||
### Relays
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `amy relay add URL [--type T]` | Add URL to a bucket: `nip65`, `inbox`, `key_package`, or `all`. |
|
||||
| `amy relay list` | Print the configured relays per bucket. |
|
||||
| `amy relay publish-lists` | Broadcast your kind:10002 / 10050 / 10051. |
|
||||
|
||||
### Local store maintenance
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `amy store stat` | Event count, kind histogram, disk usage, oldest/newest timestamps. |
|
||||
| `amy store sweep-expired` | Delete events past their NIP-40 expiration. |
|
||||
| `amy store scrub` | Rebuild the index after external edits or a crash. |
|
||||
| `amy store compact` | Drop dangling index entries (canonical event already gone). |
|
||||
|
||||
---
|
||||
|
||||
## Output: text by default, JSON on demand
|
||||
|
||||
By default amy writes a YAML-ish, colored, human-readable result to
|
||||
stdout. Pass `--json` and stdout becomes a single-line JSON object —
|
||||
same data, stable snake_case keys, ready for `jq`:
|
||||
|
||||
```bash
|
||||
$ amy --json whoami
|
||||
{"name":"alice","npub":"npub1th9z…","hex":"5dca27ae…","data_dir":"/home/me/.amy/alice"}
|
||||
|
||||
$ amy --json marmot group create --name "Lunch" | jq -r .group_id
|
||||
ab12cd34…
|
||||
```
|
||||
|
||||
Errors mirror the same rule. Default:
|
||||
|
||||
```text
|
||||
$ amy marmot group show abc123
|
||||
error: not_member: abc123 # exit 1
|
||||
```
|
||||
|
||||
Under `--json` the error goes to stderr as `{"error":"not_member","detail":"abc123"}`.
|
||||
|
||||
Color auto-disables when stdout is a pipe; force it with `CLICOLOR_FORCE=1`,
|
||||
turn it off entirely with `NO_COLOR=1`.
|
||||
|
||||
**Exit codes** — the real signal for scripts:
|
||||
|
||||
| Code | Meaning |
|
||||
|---|---|
|
||||
| 0 | success |
|
||||
| 1 | runtime error (network, permission, NIP rejection, …) |
|
||||
| 2 | bad arguments |
|
||||
| 124 | `await` timed out |
|
||||
|
||||
---
|
||||
|
||||
## Multi-account workflows
|
||||
|
||||
`amy` is built to host more than one identity per machine. The layout
|
||||
matches that:
|
||||
|
||||
```
|
||||
~/.amy/
|
||||
├── current # marker: which account `amy use NAME` pinned
|
||||
├── shared/
|
||||
│ └── events-store/ # one Nostr event store, shared by every account
|
||||
├── alice/
|
||||
│ ├── identity.json # keypair (or reference to keychain entry)
|
||||
│ ├── state.json # sync cursors
|
||||
│ ├── aliases.json # local name → npub map
|
||||
│ └── marmot/ # MLS state per group
|
||||
└── bob/
|
||||
└── …
|
||||
```
|
||||
|
||||
**Account selection** when you don't pass `--account`:
|
||||
|
||||
1. If `~/.amy/current` is set, use it.
|
||||
2. Else if exactly one account exists, use it (silent auto-pick).
|
||||
3. Else error and list the candidates so you can disambiguate.
|
||||
|
||||
`amy use NAME` writes `~/.amy/current`; `amy use --clear` removes it.
|
||||
For one-off override, prepend `--account NAME` to any command.
|
||||
|
||||
`init` and `create` write a self-entry into `aliases.json` so you can
|
||||
refer to your own account by name in future commands. The alias resolver
|
||||
in recipient slots (`amy dm send alice "hi"`) is on the roadmap.
|
||||
|
||||
---
|
||||
|
||||
## For agents and scripts
|
||||
|
||||
Three contracts keep amy machine-safe:
|
||||
|
||||
1. **One JSON object per success on stdout** under `--json`. Stable
|
||||
snake_case keys; keys never disappear silently.
|
||||
2. **Errors as JSON on stderr** under `--json`: `{"error":"...","detail":"..."}`.
|
||||
3. **Exit codes mean specific things** (table above) — `124` for
|
||||
`await` timeout in particular lets you distinguish "condition never
|
||||
happened" from "the command itself crashed".
|
||||
|
||||
### Recipes
|
||||
|
||||
```bash
|
||||
# Capture a fresh group's id.
|
||||
GID=$(amy --json marmot group create --name "ops" | jq -r .group_id)
|
||||
|
||||
# Add several members at once and report which KeyPackages were missing.
|
||||
amy --json marmot group add "$GID" npub1aaa npub1bbb npub1ccc \
|
||||
| jq -r '.added[] | select(.status != "ok") | "missing: \(.pubkey)"'
|
||||
|
||||
# Wait up to 5 minutes for a particular message and capture its event id.
|
||||
EVT=$(amy --json marmot await message "$GID" --match "deploy starting" --timeout 300 \
|
||||
| jq -r .event_id)
|
||||
|
||||
# Run a command per follow.
|
||||
amy --json notes feed --following --limit 50 \
|
||||
| jq -r '.notes[].author' \
|
||||
| sort -u \
|
||||
| while read -r author; do
|
||||
amy --json profile show "$author" | jq -r '.metadata.name // "?"'
|
||||
done
|
||||
```
|
||||
|
||||
### Test isolation
|
||||
|
||||
amy reads `$HOME` directly to find `~/.amy/`, so harnesses isolate the
|
||||
exact same way `git`, `gpg`, `npm`, and `ssh` do — by overriding `$HOME`
|
||||
for the subprocess:
|
||||
|
||||
```bash
|
||||
HOME=$(mktemp -d) amy --account alice init
|
||||
HOME=$(mktemp -d) amy --account alice marmot group create --name "scratch"
|
||||
```
|
||||
|
||||
Inside the amy process there's no test mode — it just sees a fresh
|
||||
`~/.amy/` and behaves like a brand-new install.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **`no account at ~/.amy`** — you haven't created one yet. Run
|
||||
`amy --account NAME init` (bare keypair) or `amy --account NAME create`
|
||||
(full Amethyst-style bootstrap).
|
||||
- **`multiple accounts in ~/.amy (alice, bob)`** — pin one with
|
||||
`amy use NAME` or pass `--account NAME` per command.
|
||||
- **`current pins 'X' but ~/.amy/X doesn't exist`** — the active-account
|
||||
marker is stale. Rewrite with `amy use OTHER` or `amy use --clear`.
|
||||
- **`no_dm_relays`** — recipient hasn't published a kind:10050 inbox.
|
||||
Pass `--allow-fallback` to fall back to their kind:10002 read marker
|
||||
→ bootstrap pool. Or wait for them to publish one.
|
||||
- **`not_member`** — the group GID is unknown to this account. Run
|
||||
`amy marmot group list` to see what you're in, or `await group --name X`
|
||||
to wait for an invite.
|
||||
- **A network verb hangs** — every network verb has a relay timeout.
|
||||
Inspect what amy is connecting to with `amy relay list`. Wrap any
|
||||
command in `timeout(1)` if you're scripting and want a hard ceiling.
|
||||
- **Nothing seems to publish** — stderr carries `[cli] …` traces with
|
||||
per-relay `OK` / `REJECT`. Capture with `2> /tmp/amy.log` and grep.
|
||||
|
||||
---
|
||||
|
||||
## Where to go next
|
||||
|
||||
- [README.md](./README.md) — full command reference table, on-disk
|
||||
contract, relay-routing rules.
|
||||
- [DEVELOPMENT.md](./DEVELOPMENT.md) — extend amy without breaking the
|
||||
contract.
|
||||
- [ROADMAP.md](./ROADMAP.md) — what's coming.
|
||||
- [Nostr NIPs](https://github.com/nostr-protocol/nips) — the protocol
|
||||
amy speaks.
|
||||
Reference in New Issue
Block a user