From 03eb7be5091aa2e67dd926bdb6a66e8bd1321a2a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Apr 2026 12:23:37 +0000 Subject: [PATCH] feat(cli): default amy stdout to human-readable text; --json opts in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: : ` 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. --- cli/DEVELOPMENT.md | 42 ++-- cli/README.md | 24 ++- .../com/vitorpamplona/amethyst/cli/Config.kt | 10 +- .../com/vitorpamplona/amethyst/cli/Json.kt | 42 ---- .../com/vitorpamplona/amethyst/cli/Main.kt | 37 +++- .../com/vitorpamplona/amethyst/cli/Output.kt | 201 ++++++++++++++++++ .../amethyst/cli/commands/AwaitCommands.kt | 30 +-- .../amethyst/cli/commands/CreateCommand.kt | 6 +- .../amethyst/cli/commands/DmCommands.kt | 34 +-- .../amethyst/cli/commands/FeedCommand.kt | 12 +- .../cli/commands/GroupAddMemberCommand.kt | 8 +- .../amethyst/cli/commands/GroupCommands.kt | 6 +- .../cli/commands/GroupCreateCommand.kt | 4 +- .../cli/commands/GroupMembershipCommands.kt | 16 +- .../cli/commands/GroupMetadataCommands.kt | 12 +- .../cli/commands/GroupReadCommands.kt | 22 +- .../amethyst/cli/commands/InitCommands.kt | 10 +- .../cli/commands/KeyPackageCommands.kt | 18 +- .../amethyst/cli/commands/LoginCommand.kt | 10 +- .../cli/commands/MarmotResetCommand.kt | 6 +- .../amethyst/cli/commands/MessageCommands.kt | 36 ++-- .../amethyst/cli/commands/NotesCommands.kt | 6 +- .../amethyst/cli/commands/PostCommand.kt | 10 +- .../amethyst/cli/commands/ProfileCommands.kt | 18 +- .../amethyst/cli/commands/RelayCommands.kt | 16 +- .../amethyst/cli/commands/StoreCommands.kt | 16 +- cli/tests/cache/cache-headless.sh | 10 +- cli/tests/dm/setup.sh | 6 +- cli/tests/dm/tests-dm.sh | 10 +- cli/tests/headless/helpers.sh | 4 +- cli/tests/marmot/tests-extras.sh | 2 +- 31 files changed, 442 insertions(+), 242 deletions(-) delete mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Json.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Output.kt diff --git a/cli/DEVELOPMENT.md b/cli/DEVELOPMENT.md index 21dea1aef..469c8e93d 100644 --- a/cli/DEVELOPMENT.md +++ b/cli/DEVELOPMENT.md @@ -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): Int { - if (tail.isEmpty()) return Json.error("bad_args", "note ") + if (tail.isEmpty()) return Output.error("bad_args", "note ") 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/`. | diff --git a/cli/README.md b/cli/README.md index 4944509bb..e9a39d592 100644 --- a/cli/README.md +++ b/cli/README.md @@ -27,13 +27,18 @@ Amy exists for three audiences at once: What every caller — user, script, agent, CI — can rely on: -- **stdout is JSON. One line. One object.** Stable snake_case keys. - Pipe it into `jq`, parse it from Python, hand it to an agent. +- **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: + `amy --json whoami`, `amy --data-dir ./alice --json marmot group list`. - **stderr is for humans.** Progress, warnings, per-relay ACK traces. - Safe to discard. + Safe to discard. Errors land here too: `error: : ` by + default, or JSON `{"error":"…","detail":"…"}` under `--json`. - **Exit codes are the real signal.** - `0` — success - - `1` — runtime error (JSON `{"error":"…","detail":"…"}` on stderr) + - `1` — runtime error - `2` — bad arguments - `124` — `await` timed out - **No interactive prompts, ever.** Passwords, names, keys — all flags. @@ -42,9 +47,9 @@ What every caller — user, script, agent, CI — can rely on: Delete to reset; copy to move; `AMETHYST_CLI_DATA` env var overrides the default `./amy`. -The rationale behind each of these lives in -[DEVELOPMENT.md](./DEVELOPMENT.md). Breaking any of them is a breaking -change to Amy's public API. +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). --- @@ -159,10 +164,11 @@ amy --data-dir ./bob marmot await group --name "Test Group" --timeout 60 amy --data-dir ./bob marmot message list ``` -Compose with `jq` to chain commands: +Compose with `jq` to chain commands — pass `--json` so stdout switches +from the default human-readable text to the parseable single-line JSON: ```bash -GID=$(amy --data-dir ./alice marmot group create --name "Test" | jq -r .group_id) +GID=$(amy --data-dir ./alice --json marmot group create --name "Test" | jq -r .group_id) ``` For an interop-test script template, see diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt index f62c12f0b..504103bcd 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt @@ -145,7 +145,7 @@ class DataDir( * for "does an identity exist?" / "what's the npub?" checks that must * not pop a keychain prompt or ask for a passphrase. */ - fun loadIdentityFileOrNull(): IdentityFile? = if (identityFile.exists()) Json.mapper.readValue(identityFile.readText()) else null + fun loadIdentityFileOrNull(): IdentityFile? = if (identityFile.exists()) Output.mapper.readValue(identityFile.readText()) else null fun identityExists(): Boolean = identityFile.exists() @@ -177,14 +177,14 @@ class DataDir( fun saveIdentity(id: Identity) { val secret: IdentitySecret? = id.privKeyHex?.let { secrets.store(id.pubKeyHex, it) } val file = IdentityFile(pubKeyHex = id.pubKeyHex, npub = id.npub, secret = secret) - SecureFileIO.writeTextAtomic(identityFile, Json.mapper.writeValueAsString(file)) + SecureFileIO.writeTextAtomic(identityFile, Output.mapper.writeValueAsString(file)) } /** Remove the identity file and any backend-held secret. */ fun deleteIdentity() { if (identityFile.exists()) { runCatching { - val file = Json.mapper.readValue(identityFile.readText()) + val file = Output.mapper.readValue(identityFile.readText()) file.secret?.let { secrets.delete(it) } } if (!identityFile.delete() && identityFile.exists()) { @@ -193,10 +193,10 @@ class DataDir( } } - fun loadRunState(): RunState = if (stateFile.exists()) Json.mapper.readValue(stateFile.readText()) else RunState() + fun loadRunState(): RunState = if (stateFile.exists()) Output.mapper.readValue(stateFile.readText()) else RunState() fun saveRunState(s: RunState) { - SecureFileIO.writeTextAtomic(stateFile, Json.mapper.writeValueAsString(s)) + SecureFileIO.writeTextAtomic(stateFile, Output.mapper.writeValueAsString(s)) } companion object { diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Json.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Json.kt deleted file mode 100644 index 9efc18647..000000000 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Json.kt +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.cli - -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper - -object Json { - val mapper: ObjectMapper = jacksonObjectMapper() - - fun writeLine(obj: Any) { - println(mapper.writeValueAsString(obj)) - } - - fun error( - code: String, - detail: String? = null, - ): Int { - val payload = mutableMapOf("error" to code) - if (detail != null) payload["detail"] = detail - System.err.println(mapper.writeValueAsString(payload)) - return 1 - } -} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index a0c847830..c0e79b566 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -37,25 +37,33 @@ import kotlin.system.exitProcess * * Exit codes: * 0 — success - * 1 — runtime error (printed as JSON on stderr: {"error": "...", "detail": "..."}) + * 1 — runtime error * 2 — invalid arguments * 124 — await timeout * - * Every command that succeeds prints exactly one JSON object to stdout. - * Diagnostic logs go to stderr and are safe to discard. + * Default output is human-readable text on stdout. Pass `--json` to + * switch to the machine contract: a single JSON object on stdout per + * successful command, JSON `{"error": "...", "detail": "..."}` on stderr + * for failures. The JSON shape is amy's stable public API; the text + * shape is not. Diagnostic logs always go to stderr. */ fun main(argv: Array) { + // Set output mode before dispatch so even argument-parsing errors + // honour --json. + if (argv.any { it == "--json" || it == "--json=true" }) { + Output.mode = Output.Mode.JSON + } val code = try { runBlocking { dispatch(argv) } } catch (e: IllegalArgumentException) { - Json.error("bad_args", e.message) + Output.error("bad_args", e.message) 2 } catch (e: AwaitTimeout) { - Json.error("timeout", e.message) + Output.error("timeout", e.message) 124 } catch (e: Exception) { - Json.error("runtime", "${e::class.simpleName}: ${e.message}") + Output.error("runtime", "${e::class.simpleName}: ${e.message}") 1 } exitProcess(code) @@ -85,6 +93,7 @@ private suspend fun dispatch(argv: Array): Int { GlobalFlag.DATA_DIR -> dataDirFlag = consumed.value GlobalFlag.SECRET_BACKEND -> secretBackendFlag = consumed.value GlobalFlag.PASSPHRASE_FILE -> passphraseFileFlag = consumed.value + GlobalFlag.JSON -> Output.mode = Output.Mode.JSON null -> filteredArgs.add(a) } i += consumed.tokensConsumed @@ -189,10 +198,12 @@ private suspend fun marmotDispatch( private enum class GlobalFlag( val long: String, + val takesValue: Boolean = true, ) { DATA_DIR("--data-dir"), SECRET_BACKEND("--secret-backend"), PASSPHRASE_FILE("--passphrase-file"), + JSON("--json", takesValue = false), } private data class ConsumedFlag( @@ -212,7 +223,11 @@ private fun extractGlobalFlag( ): Pair { for (flag in GlobalFlag.values()) { if (token == flag.long) { - return flag to ConsumedFlag(argv.getOrNull(idx + 1), 2) + return if (flag.takesValue) { + flag to ConsumedFlag(argv.getOrNull(idx + 1), 2) + } else { + flag to ConsumedFlag(null, 1) + } } val prefix = "${flag.long}=" if (token.startsWith(prefix)) { @@ -231,8 +246,16 @@ private fun printUsage() { | amy [--data-dir PATH] | [--secret-backend auto|keychain|ncryptsec|plaintext] | [--passphrase-file PATH] + | [--json] | [args...] | + |Output: + | Default: human-readable text on stdout. + | --json: one JSON object per success on stdout, JSON + | {"error":...,"detail":...} on stderr for failures + | (the stable machine-readable contract — exit codes + | 0 success / 1 error / 2 bad args / 124 timeout). + | |Private-key storage: | Default (`auto`) uses the OS keychain when one is available | (macOS `security`, or Linux `secret-tool` on a session D-Bus) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Output.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Output.kt new file mode 100644 index 000000000..5c19a0ecf --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Output.kt @@ -0,0 +1,201 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper + +/** + * Stdout/stderr emitter for amy. + * + * Default mode is human-readable text. Pass `--json` on the command line + * to switch to the machine contract: a single JSON object on stdout per + * successful command, JSON `{"error": ..., "detail": ...}` on stderr for + * failures. The JSON shape is the public API — the text shape is not. + */ +object Output { + enum class Mode { TEXT, JSON } + + @Volatile var mode: Mode = Mode.TEXT + + val mapper: ObjectMapper = jacksonObjectMapper() + + fun emit(result: Any?) { + when (mode) { + Mode.JSON -> println(mapper.writeValueAsString(result)) + Mode.TEXT -> println(renderText(result)) + } + } + + fun error( + code: String, + detail: String? = null, + ): Int { + when (mode) { + Mode.JSON -> { + val payload = mutableMapOf("error" to code) + if (detail != null) payload["detail"] = detail + System.err.println(mapper.writeValueAsString(payload)) + } + + Mode.TEXT -> { + System.err.println(if (detail != null) "error: $code: $detail" else "error: $code") + } + } + return 1 + } + + private fun renderText(value: Any?): String { + val out = StringBuilder() + when (value) { + null -> {} + + is Map<*, *> -> { + renderMapBody(out, value, "") + } + + is List<*> -> { + renderListBody(out, value, "") + } + + else -> { + out.append(value.toString()).append('\n') + } + } + return out.toString().trimEnd('\n') + } + + private fun renderMapBody( + out: StringBuilder, + map: Map<*, *>, + prefix: String, + ) { + for ((k, v) in map) { + if (v == null) continue + val key = k.toString() + when (v) { + is Map<*, *> -> { + if (v.isEmpty()) { + out.append(prefix).append(key).append(": {}\n") + } else { + out.append(prefix).append(key).append(":\n") + renderMapBody(out, v, "$prefix ") + } + } + + is List<*> -> { + if (v.isEmpty()) { + out.append(prefix).append(key).append(": []\n") + } else { + out.append(prefix).append(key).append(":\n") + renderListBody(out, v, "$prefix ") + } + } + + else -> { + out + .append(prefix) + .append(key) + .append(": ") + .append(v.toString()) + .append('\n') + } + } + } + } + + private fun renderListBody( + out: StringBuilder, + list: List<*>, + prefix: String, + ) { + for (item in list) { + when (item) { + null -> {} + + is Map<*, *> -> { + val entries = item.entries.filter { it.value != null } + if (entries.isEmpty()) { + out.append(prefix).append("- {}\n") + continue + } + val first = entries.first() + val firstV = first.value + if (firstV is Map<*, *> || firstV is List<*>) { + out.append(prefix).append("-\n") + renderMapBody(out, item, "$prefix ") + } else { + out + .append(prefix) + .append("- ") + .append(first.key) + .append(": ") + .append(firstV.toString()) + .append('\n') + for ((rk, rv) in entries.drop(1)) { + when (rv) { + is Map<*, *> -> { + if (rv.isEmpty()) { + out.append("$prefix ").append(rk).append(": {}\n") + } else { + out.append("$prefix ").append(rk).append(":\n") + renderMapBody(out, rv, "$prefix ") + } + } + + is List<*> -> { + if (rv.isEmpty()) { + out.append("$prefix ").append(rk).append(": []\n") + } else { + out.append("$prefix ").append(rk).append(":\n") + renderListBody(out, rv, "$prefix ") + } + } + + else -> { + out + .append("$prefix ") + .append(rk) + .append(": ") + .append(rv.toString()) + .append('\n') + } + } + } + } + } + + is List<*> -> { + out.append(prefix).append("-\n") + renderListBody(out, item, "$prefix ") + } + + else -> { + out + .append(prefix) + .append("- ") + .append(item.toString()) + .append('\n') + } + } + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AwaitCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AwaitCommands.kt index a256818f5..eeea8b745 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AwaitCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AwaitCommands.kt @@ -25,7 +25,7 @@ import com.vitorpamplona.amethyst.cli.Args import com.vitorpamplona.amethyst.cli.AwaitTimeout import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.DataDir -import com.vitorpamplona.amethyst.cli.Json +import com.vitorpamplona.amethyst.cli.Output import com.vitorpamplona.amethyst.cli.commands.AwaitCommands.awaitAdmin import com.vitorpamplona.amethyst.cli.commands.AwaitCommands.awaitMember import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher @@ -43,7 +43,7 @@ object AwaitCommands { dataDir: DataDir, tail: Array, ): Int { - if (tail.isEmpty()) return Json.error("bad_args", "await ") + if (tail.isEmpty()) return Output.error("bad_args", "await ") val rest = tail.drop(1).toTypedArray() return when (tail[0]) { "key-package" -> awaitKeyPackage(dataDir, rest) @@ -53,7 +53,7 @@ object AwaitCommands { "message" -> awaitMessage(dataDir, rest) "rename" -> awaitRename(dataDir, rest) "epoch" -> awaitEpoch(dataDir, rest) - else -> Json.error("bad_args", "await ${tail[0]}") + else -> Output.error("bad_args", "await ${tail[0]}") } } @@ -61,7 +61,7 @@ object AwaitCommands { dataDir: DataDir, rest: Array, ): Int { - if (rest.isEmpty()) return Json.error("bad_args", "await key-package ") + if (rest.isEmpty()) return Output.error("bad_args", "await key-package ") val args = Args(rest.drop(1).toTypedArray()) val timeoutSecs = args.longFlag("timeout", 30) val ctx = Context.open(dataDir) @@ -100,7 +100,7 @@ object AwaitCommands { timeoutMs = 3_000, ) if (event is KeyPackageEvent) { - Json.writeLine( + Output.emit( mapOf( "event_id" to event.id, "author" to event.pubKey, @@ -135,7 +135,7 @@ object AwaitCommands { wantedName == null || ctx.marmot.groupMetadata(gid)?.name == wantedName } if (match != null) { - Json.writeLine( + Output.emit( mapOf( "group_id" to match, "mls_group_id" to ctx.marmot.mlsGroupIdHex(match), @@ -193,7 +193,7 @@ object AwaitCommands { dataDir: DataDir, rest: Array, ): Int { - if (rest.isEmpty()) return Json.error("bad_args", "await rename --name ") + if (rest.isEmpty()) return Output.error("bad_args", "await rename --name ") val args = Args(rest.drop(1).toTypedArray()) val wantedName = args.requireFlag("name") val timeoutSecs = args.longFlag("timeout", 30) @@ -206,7 +206,7 @@ object AwaitCommands { ctx.syncIncoming(timeoutMs = 3_000) val name = ctx.marmot.groupMetadata(gid)?.name if (name == wantedName) { - Json.writeLine(mapOf("group_id" to gid, "name" to name, "epoch" to ctx.marmot.groupEpoch(gid))) + Output.emit(mapOf("group_id" to gid, "name" to name, "epoch" to ctx.marmot.groupEpoch(gid))) return 0 } delay(1_500) @@ -221,7 +221,7 @@ object AwaitCommands { dataDir: DataDir, rest: Array, ): Int { - if (rest.isEmpty()) return Json.error("bad_args", "await epoch --min N") + if (rest.isEmpty()) return Output.error("bad_args", "await epoch --min N") val args = Args(rest.drop(1).toTypedArray()) val min = args.longFlag("min", 1) val timeoutSecs = args.longFlag("timeout", 30) @@ -234,7 +234,7 @@ object AwaitCommands { ctx.syncIncoming(timeoutMs = 3_000) val epoch = ctx.marmot.groupEpoch(gid) if (epoch != null && epoch >= min) { - Json.writeLine(mapOf("group_id" to gid, "epoch" to epoch)) + Output.emit(mapOf("group_id" to gid, "epoch" to epoch)) return 0 } delay(1_500) @@ -249,7 +249,7 @@ object AwaitCommands { dataDir: DataDir, rest: Array, ): Int { - if (rest.isEmpty()) return Json.error("bad_args", "await message --match STRING") + if (rest.isEmpty()) return Output.error("bad_args", "await message --match STRING") val args = Args(rest.drop(1).toTypedArray()) val needle = args.requireFlag("match") val timeoutSecs = args.longFlag("timeout", 30) @@ -264,13 +264,13 @@ object AwaitCommands { for (line in msgs.asReversed()) { val obj = try { - Json.mapper.readValue>(line) + Output.mapper.readValue>(line) } catch (_: Exception) { null } ?: continue val content = obj["content"]?.toString() ?: continue if (content.contains(needle)) { - Json.writeLine( + Output.emit( mapOf( "group_id" to gid, "id" to obj["id"], @@ -301,7 +301,7 @@ object AwaitCommands { targetIdx: Int, check: suspend (Context, Array) -> Map?, ): Int { - if (rest.size <= targetIdx) return Json.error("bad_args", usage) + if (rest.size <= targetIdx) return Output.error("bad_args", usage) val args = Args(rest.drop(targetIdx + 1).toTypedArray()) val timeoutSecs = args.longFlag("timeout", 30) val ctx = Context.open(dataDir) @@ -312,7 +312,7 @@ object AwaitCommands { ctx.syncIncoming(timeoutMs = 3_000) val hit = check(ctx, rest) if (hit != null) { - Json.writeLine(hit) + Output.emit(hit) return 0 } delay(1_500) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CreateCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CreateCommand.kt index dcdfdf193..ddae68bd8 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CreateCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CreateCommand.kt @@ -24,7 +24,7 @@ import com.vitorpamplona.amethyst.cli.Args import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Identity -import com.vitorpamplona.amethyst.cli.Json +import com.vitorpamplona.amethyst.cli.Output import com.vitorpamplona.amethyst.commons.account.bootstrapAccountEvents import com.vitorpamplona.amethyst.commons.defaults.DefaultDMRelayList import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65List @@ -52,7 +52,7 @@ object CreateCommand { rest: Array, ): Int { if (dataDir.identityExists()) { - return Json.error("exists", "identity already exists at ${dataDir.identityFile}") + return Output.error("exists", "identity already exists at ${dataDir.identityFile}") } val args = Args(rest) val name = args.flag("name") @@ -84,7 +84,7 @@ object CreateCommand { ctx.close() } - Json.writeLine( + Output.emit( mapOf( "npub" to identity.npub, "hex" to identity.pubKeyHex, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DmCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DmCommands.kt index 9153c1664..9fccae5dc 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DmCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DmCommands.kt @@ -24,7 +24,7 @@ import com.vitorpamplona.amethyst.cli.Args import com.vitorpamplona.amethyst.cli.AwaitTimeout import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.DataDir -import com.vitorpamplona.amethyst.cli.Json +import com.vitorpamplona.amethyst.cli.Output import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.filterGiftWrapsToPubkey import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.unwrapAndUnsealOrNull import com.vitorpamplona.amethyst.commons.service.upload.UploadOrchestrator @@ -62,14 +62,14 @@ object DmCommands { dataDir: DataDir, tail: Array, ): Int { - if (tail.isEmpty()) return Json.error("bad_args", "dm …") + if (tail.isEmpty()) return Output.error("bad_args", "dm …") val rest = tail.drop(1).toTypedArray() return when (tail[0]) { "send" -> send(dataDir, rest) "send-file" -> sendFile(dataDir, rest) "list" -> list(dataDir, rest) "await" -> await(dataDir, rest) - else -> Json.error("bad_args", "dm ${tail[0]}") + else -> Output.error("bad_args", "dm ${tail[0]}") } } @@ -77,7 +77,7 @@ object DmCommands { dataDir: DataDir, rest: Array, ): Int { - if (rest.size < 2) return Json.error("bad_args", "dm send [--allow-fallback]") + if (rest.size < 2) return Output.error("bad_args", "dm send [--allow-fallback]") val text = rest[1] val args = Args(rest.drop(2).toTypedArray()) val allowFallback = args.bool("allow-fallback") @@ -114,7 +114,7 @@ object DmCommands { dataDir: DataDir, rest: Array, ): Int { - if (rest.isEmpty()) return Json.error("bad_args", USAGE_SEND_FILE) + if (rest.isEmpty()) return Output.error("bad_args", USAGE_SEND_FILE) val args = Args(rest.drop(1).toTypedArray()) val allowFallback = args.bool("allow-fallback") val recipientInput = rest[0] @@ -147,7 +147,7 @@ object DmCommands { ): Pair, Map>? { val file = java.io.File(args.requireFlag("file")) if (!file.exists()) { - Json.error("bad_args", "file does not exist: ${file.absolutePath}") + Output.error("bad_args", "file does not exist: ${file.absolutePath}") return null } val server = args.requireFlag("server") @@ -158,7 +158,7 @@ object DmCommands { val uploaded = orchestrator.uploadEncrypted(file, cipher, server, ctx.signer) val uploadedUrl = uploaded.blossom.url ?: run { - Json.error("upload_failed", "Blossom server $server returned no URL") + Output.error("upload_failed", "Blossom server $server returned no URL") return null } val mimeType = args.flag("mime-type") ?: uploaded.metadata.mimeType @@ -202,19 +202,19 @@ object DmCommands { ): Pair, Map>? { val url = args.positionalOrNull(0) ?: run { - Json.error("bad_args", USAGE_SEND_FILE) + Output.error("bad_args", USAGE_SEND_FILE) return null } val keyHex = args.requireFlag("key") val nonceHex = args.requireFlag("nonce") val keyBytes = runCatching { keyHex.hexToByteArray() }.getOrElse { - Json.error("bad_args", "--key must be hex (got ${keyHex.length} chars)") + Output.error("bad_args", "--key must be hex (got ${keyHex.length} chars)") return null } val nonceBytes = runCatching { nonceHex.hexToByteArray() }.getOrElse { - Json.error("bad_args", "--nonce must be hex (got ${nonceHex.length} chars)") + Output.error("bad_args", "--nonce must be hex (got ${nonceHex.length} chars)") return null } val mimeType = args.flag("mime-type") @@ -227,7 +227,7 @@ object DmCommands { val match = Regex("^(\\d+)x(\\d+)$").matchEntire(raw) ?: run { - Json.error("bad_args", "--dim must be WxH (got '$raw')") + Output.error("bad_args", "--dim must be WxH (got '$raw')") return null } com.vitorpamplona.quartz.nip94FileMetadata.tags @@ -266,7 +266,7 @@ object DmCommands { val target = wrap.recipientPubKey() ?: continue val resolution = resolveDmRelays(ctx, target, allowFallback) if (resolution.relays.isEmpty()) { - return Json.error( + return Output.error( "no_dm_relays", "$target has no kind:10050; pass --allow-fallback to use NIP-65 read or bootstrap", ) @@ -289,7 +289,7 @@ object DmCommands { putAll(extra) put("recipients", recipientsOut) } - Json.writeLine(out) + Output.emit(out) return 0 } @@ -313,7 +313,7 @@ object DmCommands { .inboxRelays() .ifEmpty { ctx.outboxRelays() } .ifEmpty { ctx.bootstrapRelays() } - if (inbox.isEmpty()) return Json.error("no_inbox_relays", "configure relays or bootstrap defaults first") + if (inbox.isEmpty()) return Output.error("no_inbox_relays", "configure relays or bootstrap defaults first") val advanceCursor = peerInput == null && sinceFlag == null val since = sinceFlag ?: ctx.state.giftWrapSince @@ -338,7 +338,7 @@ object DmCommands { if (maxSeen != null) ctx.state.giftWrapSince = maxSeen } - Json.writeLine( + Output.emit( mapOf( "peer" to peerHex, "messages" to out, @@ -369,7 +369,7 @@ object DmCommands { .inboxRelays() .ifEmpty { ctx.outboxRelays() } .ifEmpty { ctx.bootstrapRelays() } - if (inbox.isEmpty()) return Json.error("no_inbox_relays", "configure relays or bootstrap defaults first") + if (inbox.isEmpty()) return Output.error("no_inbox_relays", "configure relays or bootstrap defaults first") val deadline = System.currentTimeMillis() + timeoutSecs * 1000 var since = ctx.state.giftWrapSince @@ -388,7 +388,7 @@ object DmCommands { // can grep for either with one --match flag. val hit = messages.firstOrNull { match in it.searchText } if (hit != null) { - Json.writeLine(hit.toJson()) + Output.emit(hit.toJson()) return 0 } val maxSeen = messages.maxOfOrNull { it.createdAt } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FeedCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FeedCommand.kt index 49f643c06..87e0c6bfb 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FeedCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FeedCommand.kt @@ -23,7 +23,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 import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -53,10 +53,10 @@ object FeedCommand { val author = args.flag("author") val following = args.bool("following") if (author != null && following) { - return Json.error("bad_args", "feed: pass either --author or --following, not both") + return Output.error("bad_args", "feed: pass either --author or --following, not both") } val limit = args.intFlag("limit", 50) - if (limit <= 0) return Json.error("bad_args", "feed: --limit must be > 0") + if (limit <= 0) return Output.error("bad_args", "feed: --limit must be > 0") val since = args.flag("since")?.toLongOrNull() val until = args.flag("until")?.toLongOrNull() val timeoutSecs = args.longFlag("timeout", 8L) @@ -73,7 +73,7 @@ object FeedCommand { } if (authors.isEmpty()) { - Json.writeLine( + Output.emit( mapOf( "mode" to mode, "authors" to emptyList(), @@ -85,7 +85,7 @@ object FeedCommand { val relays = relaysForReadingFeed(ctx, mode) if (relays.isEmpty()) { - return Json.error("no_relays", "no relays available; run `amy relay add` first") + return Output.error("no_relays", "no relays available; run `amy relay add` first") } val filter = @@ -121,7 +121,7 @@ object FeedCommand { ) }.toList() - Json.writeLine( + Output.emit( mapOf( "mode" to mode, "authors" to authors, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupAddMemberCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupAddMemberCommand.kt index b6e2ca9f1..c47e862bd 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupAddMemberCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupAddMemberCommand.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.cli.commands import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.DataDir -import com.vitorpamplona.amethyst.cli.Json +import com.vitorpamplona.amethyst.cli.Output import com.vitorpamplona.amethyst.commons.defaults.DefaultDMRelayList import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher @@ -45,13 +45,13 @@ object GroupAddMemberCommand { dataDir: DataDir, rest: Array, ): Int { - if (rest.size < 2) return Json.error("bad_args", "group add [ ...]") + if (rest.size < 2) return Output.error("bad_args", "group add [ ...]") val ctx = Context.open(dataDir) try { ctx.prepare() val gid = ctx.resolveGroupId(rest[0]) ctx.syncIncoming() - if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid) + if (!ctx.marmot.isMember(gid)) return Output.error("not_member", gid) // Accept any identifier the UI would: npub1…, nprofile1…, 64-hex, // NIP-05 (name@domain). Resolution fires NIP-05 HTTP fetches in parallel @@ -160,7 +160,7 @@ object GroupAddMemberCommand { ) } - Json.writeLine( + Output.emit( mapOf( "group_id" to gid, "epoch" to ctx.marmot.groupEpoch(gid), diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupCommands.kt index 863962b2b..7c519cbb7 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupCommands.kt @@ -21,14 +21,14 @@ package com.vitorpamplona.amethyst.cli.commands import com.vitorpamplona.amethyst.cli.DataDir -import com.vitorpamplona.amethyst.cli.Json +import com.vitorpamplona.amethyst.cli.Output object GroupCommands { suspend fun dispatch( dataDir: DataDir, tail: Array, ): Int { - if (tail.isEmpty()) return Json.error("bad_args", "group ") + if (tail.isEmpty()) return Output.error("bad_args", "group ") val rest = tail.drop(1).toTypedArray() return when (tail[0]) { "create" -> GroupCreateCommand.run(dataDir, rest) @@ -42,7 +42,7 @@ object GroupCommands { "demote" -> GroupMetadataCommands.demote(dataDir, rest) "remove" -> GroupMembershipCommands.remove(dataDir, rest) "leave" -> GroupMembershipCommands.leave(dataDir, rest) - else -> Json.error("bad_args", "group ${tail[0]}") + else -> Output.error("bad_args", "group ${tail[0]}") } } } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupCreateCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupCreateCommand.kt index 47cd31377..b639971c8 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupCreateCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupCreateCommand.kt @@ -23,7 +23,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 import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.utils.RandomInstance @@ -56,7 +56,7 @@ object GroupCreateCommand { ) ctx.marmot.createGroup(gid, initialMetadata = metadata) - Json.writeLine( + Output.emit( mapOf( "group_id" to gid, "mls_group_id" to ctx.marmot.mlsGroupIdHex(gid), diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMembershipCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMembershipCommands.kt index 2e7d84c52..e79dc0135 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMembershipCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMembershipCommands.kt @@ -22,30 +22,30 @@ package com.vitorpamplona.amethyst.cli.commands 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 GroupMembershipCommands { suspend fun remove( dataDir: DataDir, rest: Array, ): Int { - if (rest.size < 2) return Json.error("bad_args", "group remove ") + if (rest.size < 2) return Output.error("bad_args", "group remove ") val ctx = Context.open(dataDir) try { ctx.prepare() val gid = ctx.resolveGroupId(rest[0]) val target = ctx.requireUserHex(rest[1]) ctx.syncIncoming() - if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid) + if (!ctx.marmot.isMember(gid)) return Output.error("not_member", gid) val leafIndex = ctx.marmot.leafIndexOf(gid, target) - ?: return Json.error("not_in_group", target) + ?: return Output.error("not_in_group", target) val outbound = ctx.marmot.removeMember(nostrGroupId = gid, targetLeafIndex = leafIndex) val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() } val ack = ctx.publish(outbound.signedEvent, targets) - Json.writeLine( + Output.emit( mapOf( "group_id" to gid, "removed" to target, @@ -65,12 +65,12 @@ object GroupMembershipCommands { dataDir: DataDir, rest: Array, ): Int { - if (rest.isEmpty()) return Json.error("bad_args", "group leave ") + if (rest.isEmpty()) return Output.error("bad_args", "group leave ") val ctx = Context.open(dataDir) try { ctx.prepare() val gid = ctx.resolveGroupId(rest[0]) - if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid) + if (!ctx.marmot.isMember(gid)) return Output.error("not_member", gid) val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() } @@ -102,7 +102,7 @@ object GroupMembershipCommands { val outbound = ctx.marmot.leaveGroup(gid) val ack = ctx.publish(outbound.signedEvent, targets) - Json.writeLine( + Output.emit( mapOf( "group_id" to gid, "self_demote_event_id" to demoteEventId, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMetadataCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMetadataCommands.kt index 4afdb7f54..fe8940782 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMetadataCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMetadataCommands.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.cli.commands import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.DataDir -import com.vitorpamplona.amethyst.cli.Json +import com.vitorpamplona.amethyst.cli.Output import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -35,7 +35,7 @@ object GroupMetadataCommands { dataDir: DataDir, rest: Array, ): Int { - if (rest.size < 2) return Json.error("bad_args", "group rename ") + if (rest.size < 2) return Output.error("bad_args", "group rename ") return edit(dataDir, rest[0]) { _, cur -> cur.copy(name = rest[1]) } } @@ -43,7 +43,7 @@ object GroupMetadataCommands { dataDir: DataDir, rest: Array, ): Int { - if (rest.size < 2) return Json.error("bad_args", "group promote ") + if (rest.size < 2) return Output.error("bad_args", "group promote ") return edit(dataDir, rest[0]) { ctx, cur -> val newAdmin = ctx.requireUserHex(rest[1]) val admins = cur.adminPubkeys.toMutableList() @@ -56,7 +56,7 @@ object GroupMetadataCommands { dataDir: DataDir, rest: Array, ): Int { - if (rest.size < 2) return Json.error("bad_args", "group demote ") + if (rest.size < 2) return Output.error("bad_args", "group demote ") return edit(dataDir, rest[0]) { ctx, cur -> val target = ctx.requireUserHex(rest[1]) val admins = cur.adminPubkeys.filter { it != target } @@ -74,7 +74,7 @@ object GroupMetadataCommands { ctx.prepare() val gid = ctx.resolveGroupId(rawGid) ctx.syncIncoming() - if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid) + if (!ctx.marmot.isMember(gid)) return Output.error("not_member", gid) val outboxUrls = ctx.outboxRelays().map { it.url } val cur = ctx.marmot.groupMetadata(gid) @@ -89,7 +89,7 @@ object GroupMetadataCommands { val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() } val ack = ctx.publish(commit.signedEvent, targets) - Json.writeLine( + Output.emit( mapOf( "group_id" to gid, "name" to updated.name, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupReadCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupReadCommands.kt index 3337204cb..925685ff9 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupReadCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupReadCommands.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.cli.commands import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.DataDir -import com.vitorpamplona.amethyst.cli.Json +import com.vitorpamplona.amethyst.cli.Output /** * Read-only queries. None of these publish; they all sync-then-report. @@ -44,7 +44,7 @@ object GroupReadCommands { "epoch" to ctx.marmot.groupEpoch(id), ) } - Json.writeLine(mapOf("groups" to items)) + Output.emit(mapOf("groups" to items)) return 0 } finally { ctx.close() @@ -55,19 +55,19 @@ object GroupReadCommands { dataDir: DataDir, rest: Array, ): Int { - if (rest.isEmpty()) return Json.error("bad_args", "group show ") + if (rest.isEmpty()) return Output.error("bad_args", "group show ") val ctx = Context.open(dataDir) try { ctx.prepare() val gid = ctx.resolveGroupId(rest[0]) ctx.syncIncoming() - if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid) + if (!ctx.marmot.isMember(gid)) return Output.error("not_member", gid) val meta = ctx.marmot.groupMetadata(gid) val members = ctx.marmot.memberPubkeys(gid).map { mapOf("pubkey" to it.pubkey, "leaf_index" to it.leafIndex) } - Json.writeLine( + Output.emit( mapOf( "group_id" to gid, "mls_group_id" to ctx.marmot.mlsGroupIdHex(gid), @@ -90,18 +90,18 @@ object GroupReadCommands { dataDir: DataDir, rest: Array, ): Int { - if (rest.isEmpty()) return Json.error("bad_args", "group members ") + if (rest.isEmpty()) return Output.error("bad_args", "group members ") val ctx = Context.open(dataDir) try { ctx.prepare() val gid = ctx.resolveGroupId(rest[0]) ctx.syncIncoming() - if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid) + if (!ctx.marmot.isMember(gid)) return Output.error("not_member", gid) val members = ctx.marmot.memberPubkeys(gid).map { mapOf("pubkey" to it.pubkey, "leaf_index" to it.leafIndex) } - Json.writeLine(mapOf("group_id" to gid, "members" to members)) + Output.emit(mapOf("group_id" to gid, "members" to members)) return 0 } finally { ctx.close() @@ -112,15 +112,15 @@ object GroupReadCommands { dataDir: DataDir, rest: Array, ): Int { - if (rest.isEmpty()) return Json.error("bad_args", "group admins ") + if (rest.isEmpty()) return Output.error("bad_args", "group admins ") val ctx = Context.open(dataDir) try { ctx.prepare() val gid = ctx.resolveGroupId(rest[0]) ctx.syncIncoming() - if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid) + if (!ctx.marmot.isMember(gid)) return Output.error("not_member", gid) val m = ctx.marmot.groupMetadata(gid) - Json.writeLine(mapOf("group_id" to gid, "admins" to (m?.adminPubkeys ?: emptyList()))) + Output.emit(mapOf("group_id" to gid, "admins" to (m?.adminPubkeys ?: emptyList()))) return 0 } finally { ctx.close() diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/InitCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/InitCommands.kt index ccb773404..820133f6f 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/InitCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/InitCommands.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.cli.commands import com.vitorpamplona.amethyst.cli.Args import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Identity -import com.vitorpamplona.amethyst.cli.Json +import com.vitorpamplona.amethyst.cli.Output object InitCommands { suspend fun init( @@ -34,7 +34,7 @@ object InitCommands { // would trigger a keychain prompt / passphrase dialog even though the // caller clearly already has the identity set up. dataDir.loadIdentityFileOrNull()?.let { existing -> - Json.writeLine( + Output.emit( mapOf( "npub" to existing.npub, "hex" to existing.pubKeyHex, @@ -48,7 +48,7 @@ object InitCommands { val nsec = args.flag("nsec") val created = if (nsec != null) Identity.fromNsec(nsec) else Identity.create() dataDir.saveIdentity(created) - Json.writeLine( + Output.emit( mapOf( "npub" to created.npub, "hex" to created.pubKeyHex, @@ -65,9 +65,9 @@ object InitCommands { // or ask for a NIP-49 passphrase just to echo the npub. val file = dataDir.loadIdentityFileOrNull() if (file == null) { - return Json.error("no_identity", "No identity at ${dataDir.identityFile}. Run `init` first.") + return Output.error("no_identity", "No identity at ${dataDir.identityFile}. Run `init` first.") } - Json.writeLine( + Output.emit( mapOf( "npub" to file.npub, "hex" to file.pubKeyHex, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyPackageCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyPackageCommands.kt index 436ee3549..843fd1765 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyPackageCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyPackageCommands.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.cli.commands import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.DataDir -import com.vitorpamplona.amethyst.cli.Json +import com.vitorpamplona.amethyst.cli.Output import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher @@ -31,11 +31,11 @@ object KeyPackageCommands { dataDir: DataDir, tail: Array, ): Int { - if (tail.isEmpty()) return Json.error("bad_args", "key-package …") + if (tail.isEmpty()) return Output.error("bad_args", "key-package …") return when (tail[0]) { "publish" -> publish(dataDir) "check" -> check(dataDir, tail.drop(1).toTypedArray()) - else -> Json.error("bad_args", "key-package ${tail[0]}") + else -> Output.error("bad_args", "key-package ${tail[0]}") } } @@ -44,11 +44,11 @@ object KeyPackageCommands { try { ctx.prepare() val relays = ctx.keyPackageRelays().ifEmpty { ctx.outboxRelays() }.ifEmpty { ctx.anyRelays() } - if (relays.isEmpty()) return Json.error("no_relays", "configure relays first") + if (relays.isEmpty()) return Output.error("no_relays", "configure relays first") val event = ctx.marmot.generateKeyPackageEvent(relays.toList()) val ack = ctx.publish(event, relays) - Json.writeLine( + Output.emit( mapOf( "event_id" to event.id, "kind" to event.kind, @@ -66,7 +66,7 @@ object KeyPackageCommands { dataDir: DataDir, rest: Array, ): Int { - if (rest.isEmpty()) return Json.error("bad_args", "key-package check ") + if (rest.isEmpty()) return Output.error("bad_args", "key-package check ") val ctx = Context.open(dataDir) try { ctx.prepare() @@ -76,7 +76,7 @@ object KeyPackageCommands { // Look those up first from bootstrap seeds so `check` works even // when the target and inviter share no relays. val seed = ctx.bootstrapRelays() - if (seed.isEmpty()) return Json.error("no_relays", "configure relays first") + if (seed.isEmpty()) return Output.error("no_relays", "configure relays first") // Cache-first via Context.cachedRelayListsOf — replaceable // events live in the local store after the first sync. val recipient = @@ -96,9 +96,9 @@ object KeyPackageCommands { timeoutMs = 10_000, ) if (event == null) { - return Json.error("not_found", "no KeyPackage for $targetHex on ${relays.size} relay(s)") + return Output.error("not_found", "no KeyPackage for $targetHex on ${relays.size} relay(s)") } - Json.writeLine( + Output.emit( mapOf( "event_id" to event.id, "author" to event.pubKey, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/LoginCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/LoginCommand.kt index a8ae8119d..c7aa12e33 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/LoginCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/LoginCommand.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.cli.commands import com.vitorpamplona.amethyst.cli.Args import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Identity -import com.vitorpamplona.amethyst.cli.Json +import com.vitorpamplona.amethyst.cli.Output import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client import com.vitorpamplona.quartz.nip05DnsIdentifiers.OkHttpNip05Fetcher import com.vitorpamplona.quartz.nip05DnsIdentifiers.resolveUserHexOrNull @@ -50,10 +50,10 @@ object LoginCommand { rest: Array, ): Int { if (rest.isEmpty()) { - return Json.error("bad_args", "login [--password X]") + return Output.error("bad_args", "login [--password X]") } if (dataDir.identityExists()) { - return Json.error("exists", "identity already exists at ${dataDir.identityFile}; use a fresh --data-dir or delete it first") + return Output.error("exists", "identity already exists at ${dataDir.identityFile}; use a fresh --data-dir or delete it first") } val key = rest[0].trim() @@ -61,13 +61,13 @@ object LoginCommand { val identity = resolveIdentity(key, args) - ?: return Json.error( + ?: return Output.error( "bad_key", "could not parse '$key' as any supported identifier", ) dataDir.saveIdentity(identity) - Json.writeLine( + Output.emit( mapOf( "npub" to identity.npub, "hex" to identity.pubKeyHex, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/MarmotResetCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/MarmotResetCommand.kt index e781c23a7..8d1a118b5 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/MarmotResetCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/MarmotResetCommand.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.cli.commands import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.DataDir -import com.vitorpamplona.amethyst.cli.Json +import com.vitorpamplona.amethyst.cli.Output /** * `amy marmot reset [--yes]` — wipe all local Marmot state. @@ -56,7 +56,7 @@ object MarmotResetCommand { .sorted() if (!confirmed) { - Json.writeLine( + Output.emit( mapOf( "dry_run" to true, "would_wipe_groups" to groupIds, @@ -70,7 +70,7 @@ object MarmotResetCommand { ctx.state.giftWrapSince = null ctx.state.groupSince.clear() - Json.writeLine( + Output.emit( mapOf( "reset" to true, "wiped_groups" to groupIds, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/MessageCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/MessageCommands.kt index 1ed952258..0706e7dc1 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/MessageCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/MessageCommands.kt @@ -24,7 +24,7 @@ import com.fasterxml.jackson.module.kotlin.readValue 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 import com.vitorpamplona.quartz.nip01Core.core.Event object MessageCommands { @@ -32,14 +32,14 @@ object MessageCommands { dataDir: DataDir, tail: Array, ): Int { - if (tail.isEmpty()) return Json.error("bad_args", "message …") + if (tail.isEmpty()) return Output.error("bad_args", "message …") val rest = tail.drop(1).toTypedArray() return when (tail[0]) { "send" -> send(dataDir, rest) "list" -> list(dataDir, rest) "react" -> react(dataDir, rest) "delete" -> delete(dataDir, rest) - else -> Json.error("bad_args", "message ${tail[0]}") + else -> Output.error("bad_args", "message ${tail[0]}") } } @@ -47,20 +47,20 @@ object MessageCommands { dataDir: DataDir, rest: Array, ): Int { - if (rest.size < 2) return Json.error("bad_args", "message send ") + if (rest.size < 2) return Output.error("bad_args", "message send ") val text = rest[1] val ctx = Context.open(dataDir) try { ctx.prepare() val gid = ctx.resolveGroupId(rest[0]) ctx.syncIncoming() - if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid) + if (!ctx.marmot.isMember(gid)) return Output.error("not_member", gid) val bundle = ctx.marmot.buildTextMessage(gid, text) val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() } val ack = ctx.publish(bundle.outbound.signedEvent, targets) - Json.writeLine( + Output.emit( mapOf( "group_id" to gid, "inner_event_id" to bundle.innerEvent.id, @@ -79,7 +79,7 @@ object MessageCommands { dataDir: DataDir, rest: Array, ): Int { - if (rest.isEmpty()) return Json.error("bad_args", "message list ") + if (rest.isEmpty()) return Output.error("bad_args", "message list ") val args = Args(rest.drop(1).toTypedArray()) val limit = args.intFlag("limit", Int.MAX_VALUE) val ctx = Context.open(dataDir) @@ -87,7 +87,7 @@ object MessageCommands { ctx.prepare() val gid = ctx.resolveGroupId(rest[0]) ctx.syncIncoming() - if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid) + if (!ctx.marmot.isMember(gid)) return Output.error("not_member", gid) val raw = ctx.marmot.loadStoredMessages(gid) val items = @@ -95,7 +95,7 @@ object MessageCommands { .map { line -> try { @Suppress("UNCHECKED_CAST") - val obj = Json.mapper.readValue>(line) + val obj = Output.mapper.readValue>(line) mapOf( "id" to obj["id"], "author" to obj["pubkey"], @@ -108,7 +108,7 @@ object MessageCommands { } }.takeLast(limit) - Json.writeLine(mapOf("group_id" to gid, "messages" to items)) + Output.emit(mapOf("group_id" to gid, "messages" to items)) return 0 } finally { ctx.close() @@ -119,7 +119,7 @@ object MessageCommands { dataDir: DataDir, rest: Array, ): Int { - if (rest.size < 3) return Json.error("bad_args", "message react ") + if (rest.size < 3) return Output.error("bad_args", "message react ") val targetId = rest[1] val emoji = rest[2] val ctx = Context.open(dataDir) @@ -127,14 +127,14 @@ object MessageCommands { ctx.prepare() val gid = ctx.resolveGroupId(rest[0]) ctx.syncIncoming() - if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid) + if (!ctx.marmot.isMember(gid)) return Output.error("not_member", gid) - val target = findStoredInnerEvent(ctx, gid, targetId) ?: return Json.error("not_found", targetId) + val target = findStoredInnerEvent(ctx, gid, targetId) ?: return Output.error("not_found", targetId) val bundle = ctx.marmot.buildReactionMessage(gid, target, emoji) val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() } val ack = ctx.publish(bundle.outbound.signedEvent, targets) - Json.writeLine( + Output.emit( mapOf( "group_id" to gid, "inner_event_id" to bundle.innerEvent.id, @@ -155,25 +155,25 @@ object MessageCommands { dataDir: DataDir, rest: Array, ): Int { - if (rest.size < 2) return Json.error("bad_args", "message delete [target_event_id ...]") + if (rest.size < 2) return Output.error("bad_args", "message delete [target_event_id ...]") val ctx = Context.open(dataDir) try { ctx.prepare() val gid = ctx.resolveGroupId(rest[0]) ctx.syncIncoming() - if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid) + if (!ctx.marmot.isMember(gid)) return Output.error("not_member", gid) val targetIds = rest.drop(1) val targets = targetIds.map { id -> - findStoredInnerEvent(ctx, gid, id) ?: return Json.error("not_found", id) + findStoredInnerEvent(ctx, gid, id) ?: return Output.error("not_found", id) } val bundle = ctx.marmot.buildDeletionMessage(gid, targets) val relays = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() } val ack = ctx.publish(bundle.outbound.signedEvent, relays) - Json.writeLine( + Output.emit( mapOf( "group_id" to gid, "inner_event_id" to bundle.innerEvent.id, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NotesCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NotesCommands.kt index 98fd115b1..f3ecad460 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NotesCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NotesCommands.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.amethyst.cli.commands import com.vitorpamplona.amethyst.cli.DataDir -import com.vitorpamplona.amethyst.cli.Json +import com.vitorpamplona.amethyst.cli.Output /** * `amy notes ` — NIP-10 kind:1 short text notes. Sits alongside @@ -33,12 +33,12 @@ object NotesCommands { dataDir: DataDir, tail: Array, ): Int { - if (tail.isEmpty()) return Json.error("bad_args", "notes …") + if (tail.isEmpty()) return Output.error("bad_args", "notes …") val rest = tail.drop(1).toTypedArray() return when (tail[0]) { "post" -> PostCommand.run(dataDir, rest) "feed" -> FeedCommand.run(dataDir, rest) - else -> Json.error("bad_args", "notes ${tail[0]}") + else -> Output.error("bad_args", "notes ${tail[0]}") } } } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PostCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PostCommand.kt index a3a3be77c..dd27a8294 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PostCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PostCommand.kt @@ -23,7 +23,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 import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent /** @@ -39,9 +39,9 @@ object PostCommand { dataDir: DataDir, rest: Array, ): Int { - if (rest.isEmpty()) return Json.error("bad_args", "post [--relay URL …]") + if (rest.isEmpty()) return Output.error("bad_args", "post [--relay URL …]") val text = rest[0] - if (text.isBlank()) return Json.error("bad_args", "post text must not be blank") + if (text.isBlank()) return Output.error("bad_args", "post text must not be blank") val args = Args(rest.drop(1).toTypedArray()) val extraRelays = @@ -61,13 +61,13 @@ object PostCommand { } val targets = (outbox + extraNormalized).toSet() if (targets.isEmpty()) { - return Json.error("no_relays", "no outbox relays configured; pass --relay or run `amy relay add`") + return Output.error("no_relays", "no outbox relays configured; pass --relay or run `amy relay add`") } val signed = ctx.signer.sign(TextNoteEvent.build(text)) val ack = ctx.publish(signed, targets) - Json.writeLine( + Output.emit( mapOf( "event_id" to signed.id, "kind" to signed.kind, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ProfileCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ProfileCommands.kt index a223db61d..360e34fe9 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ProfileCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ProfileCommands.kt @@ -23,7 +23,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 import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -44,12 +44,12 @@ object ProfileCommands { dataDir: DataDir, tail: Array, ): Int { - if (tail.isEmpty()) return Json.error("bad_args", "profile …") + if (tail.isEmpty()) return Output.error("bad_args", "profile …") val rest = tail.drop(1).toTypedArray() return when (tail[0]) { "show" -> show(dataDir, rest) "edit" -> edit(dataDir, rest) - else -> Json.error("bad_args", "profile ${tail[0]}") + else -> Output.error("bad_args", "profile ${tail[0]}") } } @@ -88,7 +88,7 @@ object ProfileCommands { } if (event == null) { - Json.writeLine( + Output.emit( mapOf( "pubkey" to pubKey, "found" to false, @@ -100,11 +100,11 @@ object ProfileCommands { } val metadata = try { - Json.mapper.readTree(event.content) + Output.mapper.readTree(event.content) } catch (_: Exception) { null } - Json.writeLine( + Output.emit( mapOf( "pubkey" to pubKey, "found" to true, @@ -145,7 +145,7 @@ object ProfileCommands { listOf(name, displayName, about, picture, banner, website, nip05, lud16, lud06, pronouns, twitter, mastodon, github) .any { it != null } if (!touched) { - return Json.error( + return Output.error( "bad_args", "profile edit needs at least one of " + "--name --display-name --about --picture --banner --website " + @@ -204,13 +204,13 @@ object ProfileCommands { val signed = ctx.signer.sign(template) val ack = ctx.publish(signed, targets) - Json.writeLine( + Output.emit( mapOf( "event_id" to signed.id, "kind" to signed.kind, "created_at" to signed.createdAt, "based_on" to latest?.id, - "metadata" to Json.mapper.readTree(signed.content), + "metadata" to Output.mapper.readTree(signed.content), "published_to" to ack.filterValues { it }.keys.map { it.url }, "rejected_by" to ack.filterValues { !it }.keys.map { it.url }, ), diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt index d550e47e8..256e3b561 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt @@ -23,7 +23,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 import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull @@ -52,14 +52,14 @@ object RelayCommands { dataDir: DataDir, tail: Array, ): Int { - if (tail.isEmpty()) return Json.error("bad_args", "relay …") + if (tail.isEmpty()) return Output.error("bad_args", "relay …") val sub = tail[0] val rest = tail.drop(1).toTypedArray() return when (sub) { "add" -> add(dataDir, Args(rest)) "list" -> list(dataDir) "publish-lists" -> publishLists(dataDir) - else -> Json.error("bad_args", "relay $sub") + else -> Output.error("bad_args", "relay $sub") } } @@ -71,7 +71,7 @@ object RelayCommands { val type = args.flag("type", "all") ?: "all" val normalized = rawUrl.normalizeRelayUrlOrNull() - ?: return Json.error("bad_args", "invalid relay url: $rawUrl") + ?: return Output.error("bad_args", "invalid relay url: $rawUrl") val targets = if (type == "all") listOf("nip65", "inbox", "key_package") else listOf(type) val ctx = Context.open(dataDir) @@ -81,7 +81,7 @@ object RelayCommands { for (t in targets) { if (addToBucket(ctx, t, normalized)) addedTo.add(t) else alreadyPresent.add(t) } - Json.writeLine( + Output.emit( mapOf( "url" to rawUrl, "added_to" to addedTo, @@ -141,7 +141,7 @@ object RelayCommands { val ctx = Context.open(dataDir) try { val self = ctx.identity.pubKeyHex - Json.writeLine( + Output.emit( mapOf( "nip65" to (ctx.relaysOf(self)?.relaysNorm()?.map { it.url } ?: emptyList()), "inbox" to (ctx.dmInboxOf(self)?.relays()?.map { it.url } ?: emptyList()), @@ -164,7 +164,7 @@ object RelayCommands { val keyPackageEvent = ctx.keyPackageRelaysOf(self) if (nip65Event == null && inboxEvent == null && keyPackageEvent == null) { - return Json.error( + return Output.error( "no_relays", "no relay lists in the local store; run `amy relay add` first or `amy create` to bootstrap defaults", ) @@ -175,7 +175,7 @@ object RelayCommands { val inboxResult = inboxEvent?.let { ctx.publish(it, targets) }.orEmpty() val keyPackageResult = keyPackageEvent?.let { ctx.publish(it, targets) }.orEmpty() - Json.writeLine( + Output.emit( mapOf( "nip65_event_id" to nip65Event?.id, "inbox_event_id" to inboxEvent?.id, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StoreCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StoreCommands.kt index 8a20ad292..4ee0a7ee6 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StoreCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StoreCommands.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.amethyst.cli.commands import com.vitorpamplona.amethyst.cli.DataDir -import com.vitorpamplona.amethyst.cli.Json +import com.vitorpamplona.amethyst.cli.Output import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper import com.vitorpamplona.quartz.nip01Core.store.fs.FsEventStore import java.io.IOException @@ -51,21 +51,21 @@ object StoreCommands { dataDir: DataDir, tail: Array, ): Int { - if (tail.isEmpty()) return Json.error("bad_args", "store ") + if (tail.isEmpty()) return Output.error("bad_args", "store ") val rest = tail.drop(1).toTypedArray() return when (tail[0]) { "stat" -> stat(dataDir) "sweep-expired" -> sweepExpired(dataDir) "scrub" -> scrub(dataDir) "compact" -> compact(dataDir) - else -> Json.error("bad_args", "store ${tail[0]}") + else -> Output.error("bad_args", "store ${tail[0]}") } } private fun stat(dataDir: DataDir): Int { val storeRoot = dataDir.eventsDir.toPath() if (!storeRoot.exists()) { - Json.writeLine( + Output.emit( mapOf( "events" to 0, "by_kind" to emptyMap(), @@ -119,7 +119,7 @@ object StoreCommands { val diskBytes = walkSize(storeRoot) - Json.writeLine( + Output.emit( mapOf( "events" to count, "by_kind" to byKind, @@ -138,7 +138,7 @@ object StoreCommands { val before = countEntries(expiresAtDir) store.deleteExpiredEvents() val after = countEntries(expiresAtDir) - Json.writeLine( + Output.emit( mapOf( "swept" to (before - after).coerceAtLeast(0L), "remaining" to after, @@ -150,14 +150,14 @@ object StoreCommands { private fun scrub(dataDir: DataDir): Int = withStore(dataDir) { store -> store.scrub() - Json.writeLine(mapOf("ok" to true)) + Output.emit(mapOf("ok" to true)) 0 } private fun compact(dataDir: DataDir): Int = withStore(dataDir) { store -> store.compact() - Json.writeLine(mapOf("ok" to true)) + Output.emit(mapOf("ok" to true)) 0 } diff --git a/cli/tests/cache/cache-headless.sh b/cli/tests/cache/cache-headless.sh index b3daa702c..4212c77d6 100755 --- a/cli/tests/cache/cache-headless.sh +++ b/cli/tests/cache/cache-headless.sh @@ -81,7 +81,7 @@ source "$TESTS_DIR/headless/helpers.sh" # shellcheck source=../dm/setup.sh source "$TESTS_DIR/dm/setup.sh" -amy_b() { "$AMY_BIN" --data-dir "$B_DIR" "$@"; } +amy_b() { "$AMY_BIN" --data-dir "$B_DIR" --json "$@"; } # Helper: B-side amy_json. The dm headless's helpers.sh hardcodes A_DIR # in amy_a; we need a parallel for B without re-sourcing. @@ -249,10 +249,10 @@ fi # ---------------------------------------------------------------------- banner "T7 — store maintenance verbs (sweep/scrub/compact) without identity" TMP_NO_ID=$(mktemp -d) -T7_STAT=$(${AMY_BIN} --data-dir "$TMP_NO_ID" store stat) -T7_SWEEP=$(${AMY_BIN} --data-dir "$TMP_NO_ID" store sweep-expired) -T7_SCRUB=$(${AMY_BIN} --data-dir "$TMP_NO_ID" store scrub) -T7_COMPACT=$(${AMY_BIN} --data-dir "$TMP_NO_ID" store compact) +T7_STAT=$(${AMY_BIN} --data-dir "$TMP_NO_ID" --json store stat) +T7_SWEEP=$(${AMY_BIN} --data-dir "$TMP_NO_ID" --json store sweep-expired) +T7_SCRUB=$(${AMY_BIN} --data-dir "$TMP_NO_ID" --json store scrub) +T7_COMPACT=$(${AMY_BIN} --data-dir "$TMP_NO_ID" --json store compact) assert_eq "$(printf '%s' "$T7_STAT" | jq -r '.events')" "0" T7.stat.events "" \ && record_result T7.stat pass "stat works without identity" assert_eq "$(printf '%s' "$T7_SWEEP" | jq -r '.swept')" "0" T7.sweep.swept "" \ diff --git a/cli/tests/dm/setup.sh b/cli/tests/dm/setup.sh index ae72dc5ef..1b20dc60f 100644 --- a/cli/tests/dm/setup.sh +++ b/cli/tests/dm/setup.sh @@ -75,15 +75,15 @@ preflight_dm() { # `--secret-backend=plaintext` keeps these throwaway interop runs headless — # the default `auto` would try the OS keychain (not available in CI) and then # ask for a NIP-49 passphrase. Plaintext still writes 0600-owner-only. -amy_a() { "$AMY_BIN" --data-dir "$A_DIR" --secret-backend plaintext "$@"; } -amy_d() { "$AMY_BIN" --data-dir "$D_DIR" --secret-backend plaintext "$@"; } +amy_a() { "$AMY_BIN" --data-dir "$A_DIR" --secret-backend plaintext --json "$@"; } +amy_d() { "$AMY_BIN" --data-dir "$D_DIR" --secret-backend plaintext --json "$@"; } # --- identity bootstrap ------------------------------------------------------ ensure_identity_for() { local who="$1" dir="$2" step "initialising Identity $who (amy at $dir)" local out - out=$("$AMY_BIN" --data-dir "$dir" --secret-backend plaintext init) || { + out=$("$AMY_BIN" --data-dir "$dir" --secret-backend plaintext --json init) || { fail_msg "amy init failed for $who: $out"; exit 1 } local npub hex diff --git a/cli/tests/dm/tests-dm.sh b/cli/tests/dm/tests-dm.sh index f8f81ddd8..e7bab9f37 100644 --- a/cli/tests/dm/tests-dm.sh +++ b/cli/tests/dm/tests-dm.sh @@ -13,10 +13,12 @@ # dm-06 cursor advance (subsequent no-flag `dm list` is empty) # Wrap amy_json around either data-dir so the per-test code stays tight. +# `--json` opts into amy's machine-readable contract; assertions below +# parse with jq. amy_json_for() { local dir="$1"; shift local out - if ! out=$("$AMY_BIN" --data-dir "$dir" "$@" 2>>"$LOG_FILE"); then + if ! out=$("$AMY_BIN" --data-dir "$dir" --json "$@" 2>>"$LOG_FILE"); then fail_msg "amy --data-dir $dir $*: exit $? (see $LOG_FILE)" printf '%s\n' "$out" >>"$LOG_FILE" return 1 @@ -91,7 +93,7 @@ test_03_dm_send_rejects_no_inbox() { # Generate a throwaway identity but do NOT publish its kind:10050. local tmpdir; tmpdir=$(mktemp -d "${STATE_DIR}/ghost.XXXXXX") local ghost_out ghost_npub - ghost_out=$("$AMY_BIN" --data-dir "$tmpdir" --secret-backend plaintext init) || { + ghost_out=$("$AMY_BIN" --data-dir "$tmpdir" --secret-backend plaintext --json init) || { record_result "$id" fail "ghost init failed"; rm -rf "$tmpdir"; return } ghost_npub=$(printf '%s' "$ghost_out" | jq -r '.npub') @@ -99,7 +101,7 @@ test_03_dm_send_rejects_no_inbox() { # A sends without --allow-fallback; amy should refuse. local raw rc - raw=$("$AMY_BIN" --data-dir "$A_DIR" dm send "$ghost_npub" "should be rejected" 2>&1) + raw=$("$AMY_BIN" --data-dir "$A_DIR" --json dm send "$ghost_npub" "should be rejected" 2>&1) rc=$? rm -rf "$tmpdir" if [[ "$rc" -ne 0 ]] && printf '%s' "$raw" | grep -q '"error":"no_dm_relays"'; then @@ -121,7 +123,7 @@ test_04_dm_send_allow_fallback() { # publish should succeed even though the ghost has no 10050. local tmpdir; tmpdir=$(mktemp -d "${STATE_DIR}/ghost.XXXXXX") local ghost_out ghost_npub - ghost_out=$("$AMY_BIN" --data-dir "$tmpdir" --secret-backend plaintext init) || { + ghost_out=$("$AMY_BIN" --data-dir "$tmpdir" --secret-backend plaintext --json init) || { record_result "$id" fail "ghost init failed"; rm -rf "$tmpdir"; return } ghost_npub=$(printf '%s' "$ghost_out" | jq -r '.npub') diff --git a/cli/tests/headless/helpers.sh b/cli/tests/headless/helpers.sh index 616f1fda7..5dabad2de 100644 --- a/cli/tests/headless/helpers.sh +++ b/cli/tests/headless/helpers.sh @@ -6,7 +6,9 @@ # `--secret-backend=plaintext` keeps these throwaway interop runs headless — # the default `auto` would try the OS keychain (not available in CI) and then # ask for a NIP-49 passphrase. Plaintext still writes 0600-owner-only. -amy_a() { "$AMY_BIN" --data-dir "$A_DIR" --secret-backend plaintext "$@"; } +# `--json` opts into amy's machine-readable output (the harness parses it +# with jq); the default human-text output is for terminal use. +amy_a() { "$AMY_BIN" --data-dir "$A_DIR" --secret-backend plaintext --json "$@"; } # Run amy, log stderr, surface JSON on stdout, remember last result. amy_json() { diff --git a/cli/tests/marmot/tests-extras.sh b/cli/tests/marmot/tests-extras.sh index aa0959670..ec819d311 100644 --- a/cli/tests/marmot/tests-extras.sh +++ b/cli/tests/marmot/tests-extras.sh @@ -271,7 +271,7 @@ test_14_wn_removes_a() { local deadline=$(( $(date +%s) + 120 )) removed=0 while [[ $(date +%s) -lt $deadline ]]; do local show rc - show=$("$AMY_BIN" --data-dir "$A_DIR" --secret-backend plaintext \ + show=$("$AMY_BIN" --data-dir "$A_DIR" --secret-backend plaintext --json \ marmot group show "$a_gid" 2>&1) rc=$? printf '%s\n' "$show" >>"$LOG_FILE"