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:
@@ -0,0 +1,168 @@
|
||||
---
|
||||
name: amy-expert
|
||||
description: Patterns for extending `amy`, the Amethyst CLI in `cli/`. Use when adding an `amy <verb>` command, touching files under `cli/src/main/kotlin/…/cli/`, wiring a new subcommand into `Main.kt`, writing an interop test script that drives Amy, or extracting logic out of `amethyst/` into `commons/` so a CLI command can call it. Enforces the thin-assembly-layer rule (no Nostr protocol or business logic inside `cli/`), the JSON-output contract (single-line object on stdout, exit codes 0/1/2/124), and the extract-from-Android recipe. Complements `nostr-expert` (protocol in Quartz), `kotlin-multiplatform` (expect/actual for extraction), and `feed-patterns` / `account-state` / `relay-client` (where the business logic should end up). NOT for general Nostr or Kotlin work — those have their own skills.
|
||||
---
|
||||
|
||||
# Amy CLI Expert
|
||||
|
||||
Practical patterns for touching the `cli/` module without breaking
|
||||
its public contract.
|
||||
|
||||
## When to use this skill
|
||||
|
||||
- Adding a new `amy <verb>` subcommand.
|
||||
- Editing anything under `cli/src/main/kotlin/…/cli/`.
|
||||
- Writing a shell script or test harness that drives Amy.
|
||||
- Extracting code out of `amethyst/` so the CLI can call it (this is
|
||||
the single most common reason an Amy feature request stalls).
|
||||
- Deciding whether a piece of logic belongs in `cli/` vs `commons/`
|
||||
vs `quartz/` (answer: almost never `cli/`).
|
||||
|
||||
**Not for:** general Nostr protocol work (`nostr-expert`), general
|
||||
Kotlin (`kotlin-expert`), Compose UI (`compose-expert`), Android-only
|
||||
flows (`android-expert`), gradle/build (`gradle-expert`).
|
||||
|
||||
## The rules that matter
|
||||
|
||||
Amy has a small number of hard rules. Any change that breaks them is
|
||||
a breaking change to the CLI's public API, and breaks the interop-
|
||||
test harnesses that depend on it.
|
||||
|
||||
### Rule 1 — `cli/` is a thin assembly layer
|
||||
|
||||
No new Nostr protocol, filter assembly, state machines, or encryption
|
||||
lives in `cli/`. Ever. If you need logic that doesn't exist yet:
|
||||
|
||||
- Protocol piece (event kind, tags, signing)? Add it to `quartz/`.
|
||||
- Business logic (state, defaults, ordering, filter assembly)?
|
||||
Add it to `commons/` — extract from `amethyst/` first if needed
|
||||
(see Rule 5).
|
||||
|
||||
A `commands/*.kt` file longer than ~200 lines is a code smell.
|
||||
Either the command is doing too many things, or the logic has
|
||||
leaked in from where it should have lived.
|
||||
|
||||
### Rule 2 — stdout JSON, stderr humans
|
||||
|
||||
- Every success: one line, one JSON object on stdout, via
|
||||
`Json.writeLine(mapOf(...))`.
|
||||
- Every failure: `Json.error("code", "detail")` → single-line JSON
|
||||
on stderr, non-zero exit.
|
||||
- Exit codes: `0` success · `1` runtime · `2` bad args · `124` await
|
||||
timeout.
|
||||
- Keys are stable, snake_case. Adding a key is safe; renaming or
|
||||
removing one is a breaking change and needs the commit message to
|
||||
say so.
|
||||
|
||||
See `references/output-conventions.md`.
|
||||
|
||||
### Rule 3 — Non-interactive, ever
|
||||
|
||||
No `readLine()`, no TTY prompts, no hidden interactive behaviour.
|
||||
Passwords, names, keys, anything — all flags. Any network wait is
|
||||
an explicit `await` verb with `--timeout`.
|
||||
|
||||
### Rule 4 — Data-dir is the whole world
|
||||
|
||||
State is reloaded from `--data-dir PATH` on every invocation. No
|
||||
singletons, no in-process caches that survive across runs. This is
|
||||
what lets 100 parallel interop scenarios share a harness safely.
|
||||
|
||||
Files live in well-known locations — see
|
||||
`cli/README.md § Data-dir layout`. Don't add unstructured state; if
|
||||
you need new persisted state, add it to `Config.kt` or
|
||||
`stores/FileStores.kt` with a named JSON schema.
|
||||
|
||||
### Rule 5 — Extract before adding
|
||||
|
||||
If the command you're about to add needs logic from `amethyst/`,
|
||||
land the extraction first, in its own commit:
|
||||
|
||||
1. Identify the class in `amethyst/src/main/java/…/`.
|
||||
2. List its Android-only dependencies (`Context`, `SharedPreferences`,
|
||||
`WorkManager`, `Log`, `Bitmap`, `Uri`, …).
|
||||
3. For each, choose: inline, platform-abstract via expect/actual, or
|
||||
take-as-constructor-arg.
|
||||
4. Move the file to `commons/commonMain/…`.
|
||||
5. Update the Android caller to use the new location. Add a JVM test.
|
||||
6. **Then** add the `cli/commands/…` file.
|
||||
|
||||
Full checklist: `references/extraction-recipe.md`.
|
||||
|
||||
## Standard command shape
|
||||
|
||||
Every new command follows the same shape — parse args, open Context,
|
||||
prepare, call into commons/quartz, publish or drain, emit one JSON
|
||||
line. The template is in `references/command-template.md`; copy it
|
||||
rather than re-deriving it.
|
||||
|
||||
Wire-up checklist:
|
||||
1. New file in `cli/commands/` with the `object` pattern.
|
||||
2. Add a branch in `Commands.kt`.
|
||||
3. Add a branch in `Main.kt`'s `dispatch` (or under `marmotDispatch`
|
||||
/ a new group dispatcher).
|
||||
4. Extend `printUsage()` in `Main.kt`.
|
||||
5. Add the row to `cli/README.md`'s command table.
|
||||
6. Update `cli/ROADMAP.md` — move the row from 🆕 / 📦 to ✅.
|
||||
|
||||
If you change output shape: note it in the commit message, bump the
|
||||
example in `README.md`, update any interop fixtures.
|
||||
|
||||
## Where things live
|
||||
|
||||
```
|
||||
cli/
|
||||
├── README.md # user-facing: commands, JSON contract, quick start
|
||||
├── DEVELOPMENT.md # touch-the-code: architecture, conventions, testing
|
||||
├── ROADMAP.md # parity matrix + ordered milestones
|
||||
├── plans/ # dated design docs (use for new subsystems)
|
||||
└── src/main/kotlin/…/cli/
|
||||
├── Main.kt # argv dispatch
|
||||
├── Args.kt # flag parser
|
||||
├── Json.kt # stdout/stderr JSON
|
||||
├── Config.kt # Identity, RelayConfig, RunState, DataDir
|
||||
├── Context.kt # per-run wiring — the backbone
|
||||
├── stores/ # file-backed persistence
|
||||
└── commands/ # one file per top-level verb group
|
||||
```
|
||||
|
||||
Shared logic consumed by Amy lives in `commons/`:
|
||||
- `commons/account/` — account bootstrap
|
||||
- `commons/marmot/` — MLS / group state
|
||||
- `commons/defaults/` — default relays, kinds
|
||||
- Consult `commons/plans/` for cross-cutting design work in flight.
|
||||
|
||||
## Common mistakes to refuse
|
||||
|
||||
- **Adding protocol logic to `cli/`.** Push back, offer to extract.
|
||||
- **Silently changing a JSON key.** Flag as breaking.
|
||||
- **Using `println` or `print`.** Use `Json.writeLine` / `Json.error`.
|
||||
Plain `System.err.println` is fine for progress logs but never for
|
||||
user-consumable output.
|
||||
- **`runBlocking` inside a command** — the top-level `main` already
|
||||
does that. Commands are `suspend fun`.
|
||||
- **Depending on `:amethyst` or `:desktopApp`.** Never. If you need
|
||||
something from there, Rule 5.
|
||||
- **Re-inventing identifier parsing.** Use `Context.requireUserHex`
|
||||
or `resolveUserHexOrNull` in `quartz/nip05DnsIdentifiers/`.
|
||||
- **Re-inventing publish-and-confirm.** Use `Context.publish`.
|
||||
- **Re-inventing one-shot subscription.** Use `Context.drain`.
|
||||
|
||||
## Plans & design docs
|
||||
|
||||
Cross-cutting design work goes in dated plan docs, in the module
|
||||
that owns the code being created — not in `docs/plans/`, which is
|
||||
frozen.
|
||||
|
||||
- `cli/plans/` — Amy-specific subsystems.
|
||||
- `commons/plans/` — shared code Amy consumes (e.g.
|
||||
`2026-04-21-event-renderer.md`).
|
||||
|
||||
## Cross-references
|
||||
|
||||
- [`cli/README.md`](../../../cli/README.md)
|
||||
- [`cli/DEVELOPMENT.md`](../../../cli/DEVELOPMENT.md)
|
||||
- [`cli/ROADMAP.md`](../../../cli/ROADMAP.md)
|
||||
- `references/command-template.md`
|
||||
- `references/extraction-recipe.md`
|
||||
- `references/output-conventions.md`
|
||||
@@ -0,0 +1,98 @@
|
||||
# Command-file template
|
||||
|
||||
Copy this shape for every new Amy verb. Resist the urge to deviate —
|
||||
the uniform shape is what makes commands easy to audit and test.
|
||||
|
||||
## Single-verb command
|
||||
|
||||
```kotlin
|
||||
package com.vitorpamplona.amethyst.cli.commands
|
||||
|
||||
import com.vitorpamplona.amethyst.cli.Args
|
||||
import com.vitorpamplona.amethyst.cli.Context
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Json
|
||||
|
||||
object NotePublishCommand {
|
||||
suspend fun run(dataDir: DataDir, rest: Array<String>): Int {
|
||||
val args = Args(rest)
|
||||
val text = args.positional(0, "text")
|
||||
|
||||
val ctx = Context.open(dataDir)
|
||||
try {
|
||||
ctx.prepare()
|
||||
|
||||
val event = com.vitorpamplona.amethyst.commons.note
|
||||
.buildTextNote(ctx.signer, text)
|
||||
val ack = ctx.publish(event, ctx.outboxRelays())
|
||||
|
||||
Json.writeLine(mapOf(
|
||||
"event_id" to event.id,
|
||||
"kind" to event.kind,
|
||||
"published_to" to ack.filterValues { it }.keys.map { it.url },
|
||||
"rejected_by" to ack.filterValues { !it }.keys.map { it.url },
|
||||
))
|
||||
return 0
|
||||
} finally {
|
||||
ctx.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Multi-verb group
|
||||
|
||||
When a feature has several verbs (`note publish`, `note show`,
|
||||
`note react`), group them:
|
||||
|
||||
```kotlin
|
||||
object NoteCommands {
|
||||
suspend fun dispatch(dataDir: DataDir, tail: Array<String>): Int {
|
||||
if (tail.isEmpty()) return Json.error("bad_args", "note <publish|show|react>")
|
||||
val rest = tail.drop(1).toTypedArray()
|
||||
return when (tail[0]) {
|
||||
"publish" -> NotePublishCommand.run(dataDir, rest)
|
||||
"show" -> NoteShowCommand.run(dataDir, rest)
|
||||
"react" -> NoteReactCommand.run(dataDir, rest)
|
||||
else -> Json.error("bad_args", "note ${tail[0]}")
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Each verb gets its own file. Once a single file crosses ~200 lines,
|
||||
split it — see `GroupCommands.kt` and its siblings as the reference.
|
||||
|
||||
## Wire-up checklist
|
||||
|
||||
For every new command:
|
||||
|
||||
1. File under `cli/commands/`.
|
||||
2. Branch in `Commands.kt`:
|
||||
```kotlin
|
||||
suspend fun note(dataDir: DataDir, tail: Array<String>): Int =
|
||||
NoteCommands.dispatch(dataDir, tail)
|
||||
```
|
||||
3. Branch in `Main.kt`'s top-level `dispatch`:
|
||||
```kotlin
|
||||
"note" -> Commands.note(dataDir, tail)
|
||||
```
|
||||
4. Line in `printUsage()` explaining the verb.
|
||||
5. Row in `cli/README.md`'s command table.
|
||||
6. Status flip in `cli/ROADMAP.md` (🆕 / 📦 → ✅).
|
||||
|
||||
## What not to do
|
||||
|
||||
- No `runBlocking` in a command body — `main()` already does it.
|
||||
- No `println` / `print` for command output — use
|
||||
`Json.writeLine(...)`. `System.err.println(...)` is fine for
|
||||
progress logs (they're already disposable).
|
||||
- No swallowing errors — let exceptions bubble; `main()` translates
|
||||
them to `{"error":...}` + exit code.
|
||||
- No holding a connection open across invocations — every run opens
|
||||
a fresh `Context` and closes it in `finally`.
|
||||
- No blocking reads for user input — take a flag.
|
||||
|
||||
## Output-shape rules
|
||||
|
||||
See `output-conventions.md`.
|
||||
@@ -0,0 +1,137 @@
|
||||
# Extract-from-Android recipe
|
||||
|
||||
The single most common reason an Amy feature request stalls: the
|
||||
logic it needs lives in `amethyst/` with Android-only imports. You
|
||||
cannot call it from `cli/`. You have to move it first.
|
||||
|
||||
This recipe is how.
|
||||
|
||||
## When to extract
|
||||
|
||||
Before writing a new command, ask:
|
||||
|
||||
1. Does the piece of logic I need exist in `quartz/` or `commons/`?
|
||||
- **Yes** → use it.
|
||||
- **No, but it's in `amethyst/`** → extract. This file.
|
||||
- **No, it doesn't exist anywhere** → design it in `commons/`
|
||||
directly. Write a plan doc under `commons/plans/` if it's a
|
||||
new subsystem.
|
||||
|
||||
2. Never duplicate `amethyst/` logic into `cli/`. That's a debt you
|
||||
will pay later when the Android caller drifts.
|
||||
|
||||
## Recipe
|
||||
|
||||
Land this as its own commit, **before** the commit that adds the
|
||||
CLI command.
|
||||
|
||||
### Step 1 — Find the class
|
||||
|
||||
```bash
|
||||
grep -rn "fun followUser\|class FollowListManager" amethyst/src/main/java/
|
||||
```
|
||||
|
||||
Identify the minimum unit to move. Sometimes it's a whole file,
|
||||
sometimes one function. Prefer the smallest unit that makes the
|
||||
command possible.
|
||||
|
||||
### Step 2 — List Android-only dependencies
|
||||
|
||||
Walk the imports. The usual offenders:
|
||||
|
||||
| Dependency | Treatment |
|
||||
|---|---|
|
||||
| `android.content.Context` | Often accidental — inline if only used for logging or preferences. Otherwise, invert as constructor arg. |
|
||||
| `android.content.SharedPreferences` | Abstract behind an interface in `commons/`; Android actual uses SharedPreferences, JVM actual uses a JSON file. |
|
||||
| `androidx.work.WorkManager` | Rarely shareable — if the CLI needs it, simplify the flow to not require background scheduling. |
|
||||
| `android.util.Log` | Replace with `quartz` `PlatformLog` (already multiplatform). |
|
||||
| `android.graphics.Bitmap` | Almost never needed by Amy. Keep in Android and split the function. |
|
||||
| `android.net.Uri` | Replace with `kotlinx.io` path types or a plain `String`. |
|
||||
| `androidx.compose.*` | Must stay out of `commons/commonMain` unless you're in a Compose-Multiplatform module. Amy doesn't depend on Compose. |
|
||||
|
||||
### Step 3 — Pick a migration strategy per dependency
|
||||
|
||||
- **Inline-able.** One call, trivial. Delete it.
|
||||
- **Platform-abstractable.** Add `expect` in `commons/commonMain/` +
|
||||
`actual` in `commons/androidMain/` + `actual` in `commons/jvmMain/`.
|
||||
See `kotlin-multiplatform` skill for the mechanics and for the
|
||||
`jvmAndroid` source-set pattern used throughout this repo.
|
||||
- **Inversion-of-control.** Take the Android dependency as a
|
||||
constructor arg with an interface type. Amy supplies a JVM flavour;
|
||||
Android supplies the Context-backed one.
|
||||
|
||||
### Step 4 — Move the code
|
||||
|
||||
```bash
|
||||
# Target location depends on what it is:
|
||||
# - Protocol → quartz/src/commonMain/kotlin/…
|
||||
# - Business logic → commons/src/commonMain/kotlin/…
|
||||
# - UI → commons/src/commonMain/… (needs Compose Multiplatform)
|
||||
git mv amethyst/src/main/java/com/.../FollowListManager.kt \
|
||||
commons/src/commonMain/kotlin/com/.../FollowListManager.kt
|
||||
```
|
||||
|
||||
Update package declarations. Run `./gradlew spotlessApply`.
|
||||
|
||||
### Step 5 — Update the Android caller
|
||||
|
||||
The amethyst/ caller now imports from the new location. Often this
|
||||
is the only code change visible in the Android app.
|
||||
|
||||
If the Android caller was using a concrete Android-backed
|
||||
dependency, it now supplies that concrete dependency explicitly.
|
||||
|
||||
### Step 6 — Add a JVM test
|
||||
|
||||
In `commons/src/commonTest/kotlin/…` or `commons/src/jvmTest/kotlin/…`
|
||||
(depending on what the code exercises), add a test that runs on JVM.
|
||||
This guards against Android-only imports sneaking back in, and it's
|
||||
the only way to be sure Amy can now call the code.
|
||||
|
||||
### Step 7 — Commit
|
||||
|
||||
Single commit, descriptive:
|
||||
|
||||
> refactor(follow): extract FollowListManager to commons for CLI reuse
|
||||
>
|
||||
> Move FollowListManager from amethyst/model/nip02FollowLists/ to
|
||||
> commons/commonMain/.../followLists/. Android's SharedPreferences
|
||||
> dependency is inverted behind FollowListStore (interface); Android
|
||||
> keeps the SharedPreferences-backed actual, new JvmFollowListStore
|
||||
> writes JSON to disk. No behaviour change on Android.
|
||||
|
||||
### Step 8 — Now add the CLI command
|
||||
|
||||
Separate commit. Follows the pattern in `command-template.md`.
|
||||
|
||||
## Cautionary notes
|
||||
|
||||
- **Don't extract speculatively.** Only extract what the current
|
||||
command needs. A feature-complete port can happen later; right now
|
||||
the goal is to unblock one command without adding surface area you
|
||||
don't have a second caller for.
|
||||
- **Android is allowed to keep side-effects.** Notifications,
|
||||
background services, Intents, camera, permissions dialogs — those
|
||||
stay in `amethyst/`. Amy's job isn't to replicate UX, it's to
|
||||
exercise the protocol underneath.
|
||||
- **Check the consumers.** Sometimes the "logic" you want is already
|
||||
partially in `commons/`, and the `amethyst/` class is just a thin
|
||||
wrapper. In that case, re-use the `commons/` class directly and
|
||||
delete the wrapper or keep it if Android genuinely needs it.
|
||||
- **Tests first if you're nervous.** Copy the existing
|
||||
`amethyst/`-side test (if any), make it JVM-only by removing
|
||||
Android imports, and watch it pass after the move.
|
||||
|
||||
## Red flags during extraction
|
||||
|
||||
Stop and reconsider if:
|
||||
|
||||
- The Android class is 1000+ lines. Extract only the piece the CLI
|
||||
needs; leave the rest for a follow-up.
|
||||
- You need `Context` in 30 places. It's probably being used as a
|
||||
grab-bag; sort by actual use (strings, preferences, services, …)
|
||||
and abstract those individually.
|
||||
- You find yourself writing `expect class` with a dozen methods. A
|
||||
fine-grained interface is usually clearer than a monolithic
|
||||
expect-actual.
|
||||
- You're about to add a Compose import to `cli/`. Stop.
|
||||
@@ -0,0 +1,114 @@
|
||||
# Output conventions
|
||||
|
||||
Amy's JSON contract is its public API. Follow these rules.
|
||||
|
||||
## Channels
|
||||
|
||||
| Stream | What goes here |
|
||||
|---|---|
|
||||
| **stdout** | Exactly one JSON object per successful invocation. Nothing else. |
|
||||
| **stderr** | Human progress logs, warnings, per-relay ACK traces, stack traces, `printUsage()` output. Safe to discard. Not machine-consumed. |
|
||||
|
||||
If a command needs to emit structured data for machines, it goes on
|
||||
stdout. If it needs to explain what it's doing to a human watching,
|
||||
stderr.
|
||||
|
||||
## Exit codes
|
||||
|
||||
| Code | Meaning |
|
||||
|---|---|
|
||||
| `0` | Success. Stdout has a JSON object. |
|
||||
| `1` | Runtime error. Stderr has `{"error":"...","detail":"..."}`. |
|
||||
| `2` | Bad arguments. Stderr has a JSON error object and/or usage. |
|
||||
| `124` | `await` timed out. |
|
||||
|
||||
Throw the right exception type in commands:
|
||||
|
||||
- `IllegalArgumentException` → exit 2 automatically.
|
||||
- `AwaitTimeout` → exit 124 automatically.
|
||||
- Anything else → exit 1.
|
||||
|
||||
The top-level `main()` in `Main.kt` handles the translation. Don't
|
||||
try-catch at the command level unless you're converting a third-party
|
||||
exception into one of the above.
|
||||
|
||||
## Object shape
|
||||
|
||||
### Top-level
|
||||
|
||||
Always an object. Never an array, never a primitive, never a
|
||||
newline-delimited stream.
|
||||
|
||||
```json
|
||||
{ "event_id": "...", "kind": 1, "published_to": [...] }
|
||||
```
|
||||
|
||||
### Keys
|
||||
|
||||
- Stable snake_case.
|
||||
- Additive evolution is safe; renaming or removing a key is a
|
||||
breaking change.
|
||||
- Don't nest unnecessarily. `{"data":{...}}` is noise.
|
||||
|
||||
### Identifiers
|
||||
|
||||
| Thing | Form |
|
||||
|---|---|
|
||||
| Event ID | 64-char lowercase hex string. Key name: `event_id`. |
|
||||
| Pubkey (primary subject) | hex **and** bech32. Keys: `pubkey` + `npub`. |
|
||||
| Pubkey (secondary reference) | hex only. Key: `pubkey`. |
|
||||
| Relay URL | Normalized string (`wss://…`). Never an object. |
|
||||
| Timestamps | Unix seconds, integer. Key names end in `_at`. |
|
||||
| Group ID (Marmot) | Hex string. Key: `group_id`. |
|
||||
|
||||
### Collections
|
||||
|
||||
- Pluralise: `messages`, `members`, `admins`, `events`.
|
||||
- Always an array (possibly empty), never `null`.
|
||||
- Order: oldest-first unless there's a good reason otherwise — state
|
||||
it in the key name (`messages_newest_first`) if you flip it.
|
||||
|
||||
### Booleans
|
||||
|
||||
- Use `true`/`false`, not `0`/`1`, not `"yes"`.
|
||||
- Name keys so `true` is the expected/successful state:
|
||||
`is_member`, `published`, `accepted`.
|
||||
|
||||
### Publish results
|
||||
|
||||
When a command publishes an event, the canonical output shape is:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "<hex>",
|
||||
"kind": 1,
|
||||
"published_to": ["wss://relay.a/", "wss://relay.b/"],
|
||||
"rejected_by": ["wss://relay.c/"]
|
||||
}
|
||||
```
|
||||
|
||||
`published_to` is relays that ACK'd `true`. `rejected_by` is relays
|
||||
that ACK'd `false`. Relays that didn't answer before the timeout
|
||||
appear in neither — add `timed_out_on` if you need to surface them.
|
||||
|
||||
### Error shape
|
||||
|
||||
```json
|
||||
{ "error": "code", "detail": "free-form explanation" }
|
||||
```
|
||||
|
||||
- `error` is a short, stable, lower_snake code. Agents can branch on
|
||||
it.
|
||||
- `detail` is free text — OK to change between versions.
|
||||
- Common codes today: `bad_args`, `no_identity`, `exists`, `bad_key`,
|
||||
`not_member`, `timeout`, `runtime`. Reuse before inventing.
|
||||
|
||||
## Never
|
||||
|
||||
- `println(...)` of anything except `Json.writeLine(...)`.
|
||||
- Multi-line JSON (pretty-printed). One line, always.
|
||||
- Mixing stdout lines — one command invocation emits one stdout line.
|
||||
If you need progress updates, they go on stderr.
|
||||
- Machine output to stderr. The whole point is clean separation.
|
||||
- Silent fallbacks — if a relay rejects your publish, say so in the
|
||||
JSON.
|
||||
Reference in New Issue
Block a user