From b539609dfecbbf38135cf7ad05a8fe9f610c4e88 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 17:30:47 +0000 Subject: [PATCH] feat(cli): add nak-style event + publish primitives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second batch of nak parity: - `amy event --kind N [--content …] [--tags JSON] [--created-at TS]` builds and signs an arbitrary event with the active account. Prints the signed event by default; `--publish` / `--relay` broadcasts it. - `amy publish [EVENT-JSON] [--relay …]` broadcasts a pre-made, signed event (verified before broadcast; reads stdin when no arg). Both reuse quartz EventTemplate/NostrSigner.sign and the existing Context.publish path. New RawEventSupport holds the shared arg/stdin + relay-target helpers for the raw-event verbs. Verified offline via an event -> verify round-trip. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY --- cli/README.md | 8 ++ cli/ROADMAP.md | 4 +- .../com/vitorpamplona/amethyst/cli/Main.kt | 16 +++ .../amethyst/cli/commands/Commands.kt | 10 ++ .../amethyst/cli/commands/EventCommand.kt | 102 ++++++++++++++++++ .../amethyst/cli/commands/PublishCommand.kt | 79 ++++++++++++++ .../amethyst/cli/commands/RawEventSupport.kt | 68 ++++++++++++ 7 files changed, 285 insertions(+), 2 deletions(-) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/EventCommand.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PublishCommand.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RawEventSupport.kt diff --git a/cli/README.md b/cli/README.md index df8b6c4228..7bfb2d9a28 100644 --- a/cli/README.md +++ b/cli/README.md @@ -214,6 +214,14 @@ Army-knife verbs that operate purely on their arguments. They never touch | `amy key generate` | Mint a fresh keypair (`nsec` + `npub` + hex). Does not persist — use `init`/`login` for that. | | `amy key public NSEC\|HEX` | Derive the public key from a secret key. | +### Raw events + +| Command | What it does | +|---|---| +| `amy event --kind N [--content TEXT] [--tags JSON] [--created-at TS]` | Build + sign an arbitrary event with the active account. Prints the signed event. `--tags` is a JSON array-of-arrays, e.g. `'[["t","nostr"],["e",""]]'`. | +| `amy event … --publish` / `--relay URL[,URL…]` | As above, then broadcast (to the outbox, or to the given relays). | +| `amy publish [EVENT-JSON] [--relay URL[,URL…]]` | Broadcast a pre-made signed event (verified first). Reads stdin when the argument is omitted or `-`. | + ### Identity | Command | What it does | diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index 39971a418c..da49fa1fc8 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -82,8 +82,8 @@ vs streaming `subscribe`). Stateless verbs run with no account or network. | `encode` | `amy encode` | ✅ | npub/nsec/note/nevent/nprofile/naddr. | | `verify` / `validate` | `amy verify` | ✅ | id-hash + signature, reported separately. | | `key` | `amy key generate\|public` | ✅ in part · 🆕 | generate + derive done; NIP-49 encrypt/decrypt pending. | -| `event` | `amy event` | 🆕 | build/sign an arbitrary event, optional `--publish`. | -| `publish` | `amy publish` | 🆕 | broadcast a pre-made event JSON. | +| `event` | `amy event` | ✅ | build/sign an arbitrary event, optional `--publish`/`--relay`. | +| `publish` | `amy publish` | ✅ | broadcast a pre-made event JSON (verified first). | | `req` (one-shot) | `amy fetch` | 🆕 | filter → collect-until-EOSE. | | `req` (stream) | `amy subscribe` | 🆕 | filter → live stream to stdout. | | `count` | `amy count` | 🆕 | NIP-45. | 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 1c28fedc2e..fc675a43f0 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -216,6 +216,14 @@ private suspend fun dispatch(argv: Array): Int { Commands.debit(dataDir, tail) } + "event" -> { + Commands.event(dataDir, tail) + } + + "publish" -> { + Commands.publish(dataDir, tail) + } + else -> { System.err.println("unknown subcommand: $head") printUsage() @@ -398,6 +406,14 @@ private fun printUsage() { | [--since TS] [--until TS] | [--timeout SECS] | + |Raw events (build / sign / broadcast): + | event --kind N [--content TEXT] build + sign an arbitrary event with the active + | [--tags JSON] [--created-at TS] account. Prints the signed event; add --publish + | [--publish] [--relay URL[,URL…]] (or --relay) to broadcast. --tags takes a JSON + | array-of-arrays, e.g. '[["t","nostr"]]'. + | publish [EVENT-JSON] [--relay URL[,URL…]] broadcast a pre-made signed event (verified + | first; reads stdin when the arg is omitted/`-`) + | |Static websites (NIP-5A kind:15128/35128): | nsite fetch AUTHOR [--d ID] [--path P] resolve one path over Nostr + Blossom and | [--server URL[,URL]] [--relay URL[,URL]] VERIFY it against the manifest's sha256 pin diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt index 0859b4dc8f..6335416358 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt @@ -135,4 +135,14 @@ object Commands { dataDir: DataDir, tail: Array, ): Int = DebitCommands.dispatch(dataDir, tail) + + suspend fun event( + dataDir: DataDir, + tail: Array, + ): Int = EventCommand.run(dataDir, tail) + + suspend fun publish( + dataDir: DataDir, + tail: Array, + ): Int = PublishCommand.run(dataDir, tail) } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/EventCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/EventCommand.kt new file mode 100644 index 0000000000..845f444364 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/EventCommand.kt @@ -0,0 +1,102 @@ +/* + * 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.commands + +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.Output +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * `amy event --kind N [--content TEXT] [--tags JSON] [--created-at TS] + * [--publish] [--relay URL[,URL…]]` — build and sign an arbitrary + * Nostr event (nak's `event`). + * + * Signs with the active account. By default it only prints the signed event + * (no relay traffic). Pass `--publish` to broadcast to the account's outbox, + * or `--relay` to broadcast to a specific set (implies publish). + * + * `--tags` takes a JSON array-of-arrays, e.g. + * --tags '[["p",""],["t","nostr"],["e","","","root"]]' + * + * Thin assembly only: event construction + signing live in quartz + * (`EventTemplate` / `NostrSigner.sign`); this file parses flags. + */ +object EventCommand { + suspend fun run( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val kind = + args.flag("kind")?.toIntOrNull() + ?: return Output.error("bad_args", "event requires --kind N") + val content = args.flag("content", "") ?: "" + val createdAt = args.flag("created-at")?.toLongOrNull() ?: TimeUtils.now() + + val tags: Array> = + try { + args + .flag("tags") + ?.let { Output.mapper.readValue>>(it) } + ?.map { it.toTypedArray() } + ?.toTypedArray() + ?: emptyArray() + } catch (e: Exception) { + return Output.error("bad_args", "--tags must be a JSON array of string arrays: ${e.message}") + } + + // Publish when explicitly asked (--publish) or when a relay set is given. + val wantPublish = args.bool("publish") || args.flag("relay") != null + + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val signed: Event = ctx.signer.sign(createdAt, kind, tags, content) + val eventNode = Output.mapper.readTree(signed.toJson()) + + if (!wantPublish) { + Output.emit(mapOf("event" to eventNode, "published" to false)) + return 0 + } + + val targets = RawEventSupport.publishTargets(ctx, args) + if (targets.isEmpty()) { + return Output.error("no_relays", "no outbox relays configured; pass --relay or run `amy relay add`") + } + val ack = ctx.publish(signed, targets) + Output.emit( + mapOf( + "event" to eventNode, + "published" to true, + "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() + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PublishCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PublishCommand.kt new file mode 100644 index 0000000000..b668b1fdcf --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PublishCommand.kt @@ -0,0 +1,79 @@ +/* + * 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.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.verify + +/** + * `amy publish [EVENT-JSON] [--relay URL[,URL…]]` — broadcast a pre-made, + * already-signed event (nak's `publish`). The event JSON comes from the + * positional argument or from stdin when omitted or `-`. + * + * The event is verified before broadcast — a bad id/signature is rejected + * rather than published. Targets default to the account's outbox when + * `--relay` is not given. + */ +object PublishCommand { + suspend fun run( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val json = RawEventSupport.readArgOrStdin(args) + if (json.isEmpty()) return Output.error("bad_args", "no event JSON on the argument or stdin") + + val event = + try { + Event.fromJson(json) + } catch (e: Exception) { + return Output.error("bad_args", "could not parse event JSON: ${e.message}") + } + if (!event.verify()) { + return Output.error("invalid_event", "event id/signature does not verify — refusing to publish") + } + + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val targets = RawEventSupport.publishTargets(ctx, args) + if (targets.isEmpty()) { + return Output.error("no_relays", "no outbox relays configured; pass --relay or run `amy relay add`") + } + val ack = ctx.publish(event, targets) + Output.emit( + 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() + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RawEventSupport.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RawEventSupport.kt new file mode 100644 index 0000000000..7401d5f165 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RawEventSupport.kt @@ -0,0 +1,68 @@ +/* + * 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.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer + +/** + * Shared helpers for the nak-style raw-event verbs (`event`, `publish`, + * `fetch`, `subscribe`, `count`). Kept tiny — parsing/normalisation only, + * no protocol logic. + */ +object RawEventSupport { + /** + * Read a blob from the first positional argument, or from stdin when the + * argument is omitted or `-`. Used by verbs that take event/filter JSON. + */ + fun readArgOrStdin(args: Args): String { + val arg = args.positionalOrNull(0) + return if (arg == null || arg == "-") { + System.`in` + .readBytes() + .decodeToString() + .trim() + } else { + arg.trim() + } + } + + /** Parse a `--relay a,b,c` flag into normalized relay URLs (silently drops un-normalizable entries). */ + fun relayFlag(args: Args): Set = + args + .flag("relay") + ?.split(',') + ?.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it.trim()) } + ?.toSet() + .orEmpty() + + /** + * Resolve where to publish: the explicit `--relay` set when given, + * otherwise the account's NIP-65 outbox. Empty only when neither is + * available (caller turns that into a `no_relays` error). + */ + suspend fun publishTargets( + ctx: Context, + args: Args, + ): Set = relayFlag(args).ifEmpty { ctx.outboxRelays() } +}