feat(cli): default amy stdout to human-readable text; --json opts in

amy's default stdout is now a YAML-ish render of the underlying result
map; the previous single-line JSON contract moves behind a global
`--json` flag. Errors mirror the same rule (`error: <code>: <detail>`
on stderr by default, JSON `{"error":...,"detail":...}` under --json).
Exit codes (0/1/2/124) and the --json shape itself are unchanged —
only the default presentation flips.

- Replaces Json.writeLine / Json.error with mode-aware
  Output.emit / Output.error. The same Jackson mapper is reused for
  on-disk JSON via Output.mapper.
- Adds `--json` to the global flag set in Main.kt; honoured even when
  argument parsing fails so error JSON keeps shape under --json.
- Updates the test harness wrappers (amy_a / amy_b / amy_d in
  cli/tests/{headless,dm,cache}/) and the few direct $AMY_BIN call
  sites whose stdout is consumed via $() / 2>&1 — they now pass
  --json so the existing jq pipelines keep working.
- Rewrites the README "Output contract" and DEVELOPMENT design
  principles to describe the new default, and clarifies that only
  --json is the public API; the text shape is allowed to drift.
This commit is contained in:
Claude
2026-04-25 12:23:37 +00:00
parent fb478d6019
commit 03eb7be509
31 changed files with 442 additions and 242 deletions
+25 -17
View File
@@ -16,13 +16,13 @@ or encryption in here, stop — that code belongs in `quartz/` or
## Design principles
1. **Non-interactive.** One verb = one JSON object on stdout = one
exit code. No REPL, no daemon, no prompts. Any network wait is an
1. **Non-interactive.** One verb = one result 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.
calls into `commons/` or `quartz/`, and emits a result map via
`Output.emit(...)`. A file longer than ~200 lines is a code smell
— the logic is living in the wrong module.
3. **Everything persistent is on-disk.** No in-memory caches that
survive between invocations. Every run reloads cursors, MLS state,
identity, and relay config. This is what makes Amy safe to run
@@ -30,9 +30,12 @@ or encryption in here, stop — that code belongs in `quartz/` or
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.
5. **The `--json` output shape is the public API.** Default stdout is
human-readable text (a YAML-ish render of the result map by way of
`Output.kt`'s default formatter) — that text shape can change
freely without warning. The `--json` shape cannot: changes to
keys, types, or nesting are breaking changes. Version them
explicitly in commit messages; update interop fixtures.
---
@@ -42,7 +45,7 @@ or encryption in here, stop — that code belongs in `quartz/` or
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
├── Output.kt # text/json mode emitter (--json flag)
├── Config.kt # Identity, RunState, DataDir
├── Context.kt # per-run wiring: signer + NostrClient +
│ # MarmotManager + publish/drain/sync helpers
@@ -77,7 +80,7 @@ try {
ctx.syncIncoming() // pull new gift-wraps + group events
// ...call into commons/ or quartz/ to build an event...
val ack = ctx.publish(event, targets)
Json.writeLine(mapOf(...))
Output.emit(mapOf(...))
} finally {
ctx.close() // flush RunState, disconnect
}
@@ -136,15 +139,15 @@ 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 NoteCommands {
suspend fun dispatch(dataDir: DataDir, tail: Array<String>): Int {
if (tail.isEmpty()) return Json.error("bad_args", "note <publish|read|…>")
if (tail.isEmpty()) return Output.error("bad_args", "note <publish|read|…>")
val rest = tail.drop(1).toTypedArray()
return when (tail[0]) {
"publish" -> publish(dataDir, rest)
else -> Json.error("bad_args", "note ${tail[0]}")
else -> Output.error("bad_args", "note ${tail[0]}")
}
}
@@ -156,7 +159,7 @@ object NoteCommands {
ctx.prepare()
val event = com.vitorpamplona.amethyst.commons.note.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 },
@@ -173,6 +176,11 @@ command table and [ROADMAP.md](./ROADMAP.md)'s parity matrix in sync.
### 4. Output-shape conventions
The result map you pass to `Output.emit(...)` IS the `--json` shape —
treat its keys and types as the public API. The default text render
is derived from the same map by `Output.kt` and intentionally has no
contract.
- Top-level object always.
- Stable snake_case keys.
- Event IDs as hex strings (not npub-style).
@@ -180,8 +188,8 @@ command table and [ROADMAP.md](./ROADMAP.md)'s parity matrix in sync.
primary subject (`"npub":…`).
- Relay URLs as strings, normalized, never objects.
- Lists of events under a plural key (`"messages"`, `"members"`).
- Errors via `Json.error("code","detail")` — single lower_snake code,
free-form detail.
- Errors via `Output.error("code","detail")` — single lower_snake
code, free-form detail.
---
@@ -195,7 +203,7 @@ Amy-specific layer still needs its own coverage:
|---|---|
| Argument parsing (`Args`, flag forms, `--data-dir=…` vs `--data-dir …`) | Plain JVM unit tests in `cli/src/test/kotlin/`. |
| Error / exit-code contract (bad args → 2, await timeout → 124, runtime → 1) | Table-driven tests invoking `main(argv)` with captured stdout/stderr. |
| JSON output shape (each command's keys and types) | Snapshot tests: run a command against a throwaway data-dir, assert the JSON matches a golden file. |
| JSON output shape (each command's keys and types under `--json`) | Snapshot tests: run a command with `--json` against a throwaway data-dir, assert the JSON matches a golden file. The default text render has no shape contract and shouldn't be snapshotted. |
| File layout on disk (`identity.json`, `events-store/…`, `marmot/groups/*.mls`, `marmot/keypackages.bundle`) | Structural assertions after a command sequence. |
| Round-trip between two data-dirs on a local relay | End-to-end shell harnesses under `cli/tests/`. Each harness spins up a local `nostr-rs-relay`, bootstraps two or more fresh identities in their own `--data-dir`s, and drives a scenario via `amy` (+ `wn` for Marmot interop against whitenoise-rs). Today there are two suites: `cli/tests/marmot/` (13 MLS scenarios vs whitenoise-rs) and `cli/tests/dm/` (NIP-17 DM round-trips between two `amy` clients). |
| Interop with other clients | Covered by `cli/tests/marmot/marmot-interop-headless.sh` (drives Amy against whitenoise-rs `wn`/`wnd`). Add new scenarios there or start a new sibling under `cli/tests/`. |