feat(cli): add nak-style event + publish primitives

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
This commit is contained in:
Claude
2026-06-21 17:30:47 +00:00
parent b41160f1cb
commit b539609dfe
7 changed files with 285 additions and 2 deletions
+8
View File
@@ -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","<id>"]]'`. |
| `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 |
+2 -2
View File
@@ -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. |
@@ -216,6 +216,14 @@ private suspend fun dispatch(argv: Array<String>): 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
@@ -135,4 +135,14 @@ object Commands {
dataDir: DataDir,
tail: Array<String>,
): Int = DebitCommands.dispatch(dataDir, tail)
suspend fun event(
dataDir: DataDir,
tail: Array<String>,
): Int = EventCommand.run(dataDir, tail)
suspend fun publish(
dataDir: DataDir,
tail: Array<String>,
): Int = PublishCommand.run(dataDir, tail)
}
@@ -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","<hex>"],["t","nostr"],["e","<id>","","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<String>,
): 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<Array<String>> =
try {
args
.flag("tags")
?.let { Output.mapper.readValue<List<List<String>>>(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()
}
}
}
@@ -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<String>,
): 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()
}
}
}
@@ -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<NormalizedRelayUrl> =
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<NormalizedRelayUrl> = relayFlag(args).ifEmpty { ctx.outboxRelays() }
}