feat(cli): add nak-style count, encrypt/decrypt, gift primitives

Fourth batch of nak parity:

- `amy count [filter]` — NIP-45 COUNT, per-relay match counts (reuses
  quartz INostrClient.count). Reports per-relay + max as `total`.
- `amy encrypt --to USER [TEXT]` / `amy decrypt --from USER [CIPHER]` —
  raw NIP-44 (default) or NIP-04 (--nip04) with the active account key.
- `amy gift wrap --to USER [EVENT]` / `amy gift unwrap [WRAP]` — NIP-59
  seal+wrap and unwrap+unseal (reuses SealedRumorEvent/GiftWrapEvent).

Text/ciphertext/JSON all read from arg or stdin. Verified with two
local accounts: NIP-44 + NIP-04 round-trips, gift wrap(1059)->unwrap
recovering the inner note + author, and count=60 against relay.damus.io.

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:38:01 +00:00
parent afc3c83baa
commit 0ec3fc69e8
7 changed files with 361 additions and 3 deletions
+10
View File
@@ -230,6 +230,16 @@ Filter flags are shared by `fetch` and `subscribe`: `--kind K[,K]`, `--author U[
|---|---|
| `amy fetch [filter flags] [--timeout SECS]` | One-shot query — collect until every relay sends EOSE (or `--timeout`, default 8s), dedupe, sort newest-first, print and exit. `--limit` defaults to 100. |
| `amy subscribe [filter flags] [--timeout SECS]` | Live stream — print each matching event as it arrives (NDJSON under `--json`). Runs until `--timeout` SECS or until interrupted. |
| `amy count [filter flags] [--timeout SECS]` | NIP-45 COUNT — per-relay match counts, no event download. |
### Encryption
| Command | What it does |
|---|---|
| `amy encrypt --to USER [TEXT] [--nip04]` | NIP-44 (default) or NIP-04 encrypt with the active account's key. Reads stdin when TEXT is omitted or `-`. USER accepts npub/nprofile/hex/NIP-05. |
| `amy decrypt --from USER [CIPHERTEXT] [--nip04]` | Inverse of `encrypt`. |
| `amy gift wrap --to USER [EVENT-JSON] [--relay …]` | NIP-59: seal a signed inner event for USER and wrap it in a kind:1059 gift wrap. Prints the wrap; `--relay` also broadcasts it. |
| `amy gift unwrap [GIFTWRAP-JSON]` | Decrypt + unseal a kind:1059 wrap addressed to the active account; prints the inner event. |
### Identity
+3 -3
View File
@@ -86,9 +86,9 @@ vs streaming `subscribe`). Stateless verbs run with no account or network.
| `publish` | `amy publish` | ✅ | broadcast a pre-made event JSON (verified first). |
| `req` (one-shot) | `amy fetch` | ✅ | filter → collect-until-EOSE, dedupe, sort, cap. |
| `req` (stream) | `amy subscribe` | ✅ | filter → live NDJSON stream to stdout. |
| `count` | `amy count` | 🆕 | NIP-45. |
| `encrypt` / `decrypt` | `amy encrypt\|decrypt` | 🆕 | raw NIP-44 / NIP-04. |
| `gift` | `amy gift wrap\|unwrap` | 🆕 | NIP-59 primitive (gift-wrap path already used by `dm`). |
| `count` | `amy count` | | NIP-45, per-relay counts. |
| `encrypt` / `decrypt` | `amy encrypt\|decrypt` | | raw NIP-44 (default) / NIP-04. |
| `gift` | `amy gift wrap\|unwrap` | | NIP-59 seal+wrap / unwrap+unseal. |
| `relay` (NIP-11) | `amy relay info` | 🆕 | amy's `relay` is config today; add an `info` verb. |
| `outbox` | `amy outbox` | 🆕 | NIP-65 relay discovery for a user. |
| `blossom` | `amy blossom` | 🆕 | upload/download/list/delete (client already used by `dm`/`nsite`). |
@@ -232,6 +232,22 @@ private suspend fun dispatch(argv: Array<String>): Int {
Commands.subscribe(dataDir, tail)
}
"count" -> {
Commands.count(dataDir, tail)
}
"encrypt" -> {
Commands.encrypt(dataDir, tail)
}
"decrypt" -> {
Commands.decrypt(dataDir, tail)
}
"gift" -> {
Commands.gift(dataDir, tail)
}
else -> {
System.err.println("unknown subcommand: $head")
printUsage()
@@ -430,6 +446,17 @@ private fun printUsage() {
| [--timeout SECS]
| subscribe [<same filter flags as fetch>] live stream: print each event as it arrives
| [--relay URL[,URL…]] [--timeout SECS] (NDJSON). Runs until --timeout or interrupt.
| count [<same filter flags as fetch>] NIP-45 COUNT: per-relay match counts, no
| [--relay URL[,URL…]] [--timeout SECS] event download.
|
|Encryption (active account's key):
| encrypt --to USER [TEXT] [--nip04] NIP-44 (default) or NIP-04 encrypt. Reads
| stdin when TEXT is omitted or `-`.
| decrypt --from USER [CIPHERTEXT] [--nip04] inverse of encrypt.
| gift wrap --to USER [EVENT-JSON] NIP-59: seal + wrap a signed inner event for
| [--relay URL[,URL…]] USER (add --relay to broadcast the wrap).
| gift unwrap [GIFTWRAP-JSON] decrypt + unseal a kind:1059 wrap addressed
| to the active account.
|
|Static websites (NIP-5A kind:15128/35128):
| nsite fetch AUTHOR [--d ID] [--path P] resolve one path over Nostr + Blossom and
@@ -155,4 +155,24 @@ object Commands {
dataDir: DataDir,
tail: Array<String>,
): Int = SubscribeCommand.run(dataDir, tail)
suspend fun count(
dataDir: DataDir,
tail: Array<String>,
): Int = CountCommand.run(dataDir, tail)
suspend fun encrypt(
dataDir: DataDir,
tail: Array<String>,
): Int = EncryptCommand.run(dataDir, tail)
suspend fun decrypt(
dataDir: DataDir,
tail: Array<String>,
): Int = DecryptCommand.run(dataDir, tail)
suspend fun gift(
dataDir: DataDir,
tail: Array<String>,
): Int = GiftCommands.dispatch(dataDir, tail)
}
@@ -0,0 +1,78 @@
/*
* 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.relay.client.accessories.count
/**
* `amy count [<same filter flags as fetch>] [--relay URL[,URL…]] [--timeout SECS]`
*
* NIP-45 COUNT: ask each relay how many events match the filter, without
* downloading them (nak's `count`). Reports each relay's reply plus a
* per-relay max as `total` (counts can't be deduplicated across relays, so
* summing would over-count overlapping stores — the max is the safer single
* number).
*
* Thin assembly only: the COUNT round-trip lives in quartz
* (`INostrClient.count`); this file builds the filter and shapes output.
*/
object CountCommand {
suspend fun run(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val timeoutMs = (args.flag("timeout")?.toLongOrNull() ?: 15L) * 1000
val filter = RawEventSupport.buildFilter(args)
val ctx = Context.open(dataDir)
try {
ctx.prepare()
val relays = RawEventSupport.queryTargets(ctx, args)
if (relays.isEmpty()) return Output.error("no_relays", "no relays available; pass --relay or run `amy relay add`")
val results = ctx.client.count(relays.associateWith { listOf(filter) }, timeoutMs)
Output.emit(
mapOf(
"queried_relays" to relays.map { it.url },
"responded" to results.size,
"total" to (results.values.maxOfOrNull { it.count } ?: 0),
"per_relay" to
results.map { (relay, res) ->
mapOf(
"relay" to relay.url,
"count" to res.count,
"approximate" to res.approximate,
)
},
),
)
return 0
} finally {
ctx.close()
}
}
}
@@ -0,0 +1,88 @@
/*
* 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
/**
* `amy encrypt --to USER [TEXT] [--nip04]` and
* `amy decrypt --from USER [CIPHERTEXT] [--nip04]` — raw NIP-44 (default) or
* NIP-04 message encryption with the active account's key (nak's
* `encrypt`/`decrypt`).
*
* The plaintext/ciphertext is taken from the positional argument or stdin
* (when omitted or `-`). USER accepts npub/nprofile/hex/NIP-05.
*
* Thin assembly only: the crypto lives in quartz (`NostrSigner.nip44*` /
* `nip04*`); this file resolves the peer and shuttles strings.
*/
object EncryptCommand {
suspend fun run(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val to = args.flag("to") ?: return Output.error("bad_args", "encrypt requires --to USER")
val nip04 = args.bool("nip04")
val text = RawEventSupport.readArgOrStdin(args)
if (text.isEmpty()) return Output.error("bad_args", "no plaintext on the argument or stdin")
val ctx = Context.open(dataDir)
try {
ctx.prepare()
val peer = ctx.requireUserHex(to)
val ciphertext =
if (nip04) ctx.signer.nip04Encrypt(text, peer) else ctx.signer.nip44Encrypt(text, peer)
Output.emit(mapOf("algorithm" to if (nip04) "nip04" else "nip44", "ciphertext" to ciphertext))
return 0
} finally {
ctx.close()
}
}
}
object DecryptCommand {
suspend fun run(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val from = args.flag("from") ?: return Output.error("bad_args", "decrypt requires --from USER")
val nip04 = args.bool("nip04")
val ciphertext = RawEventSupport.readArgOrStdin(args)
if (ciphertext.isEmpty()) return Output.error("bad_args", "no ciphertext on the argument or stdin")
val ctx = Context.open(dataDir)
try {
ctx.prepare()
val peer = ctx.requireUserHex(from)
val plaintext =
if (nip04) ctx.signer.nip04Decrypt(ciphertext, peer) else ctx.signer.nip44Decrypt(ciphertext, peer)
Output.emit(mapOf("algorithm" to if (nip04) "nip04" else "nip44", "plaintext" to plaintext))
return 0
} finally {
ctx.close()
}
}
}
@@ -0,0 +1,135 @@
/*
* 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.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
/**
* `amy gift wrap --to USER [EVENT-JSON]` and `amy gift unwrap [GIFTWRAP-JSON]`
* — the NIP-59 gift-wrap primitive (nak's `gift`).
*
* wrap seals a signed inner event for USER and wraps it in a kind:1059
* gift wrap (random ephemeral sender). Prints the wrap; pass
* `--relay URL[,URL…]` to also broadcast it.
* unwrap decrypts a kind:1059 gift wrap with the active account's key,
* unseals it, and prints the inner event.
*
* Inner/wrap JSON comes from the positional argument or stdin (`-`).
*
* Thin assembly only: seal/wrap/unwrap all live in quartz
* (`SealedRumorEvent`, `GiftWrapEvent`).
*/
object GiftCommands {
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): Int {
if (tail.isEmpty()) return Output.error("bad_args", "gift <wrap|unwrap>")
val rest = tail.drop(1).toTypedArray()
return when (tail[0]) {
"wrap" -> wrap(dataDir, rest)
"unwrap" -> unwrap(dataDir, rest)
else -> Output.error("bad_args", "gift ${tail[0]} (expected wrap|unwrap)")
}
}
private suspend fun wrap(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val to = args.flag("to") ?: return Output.error("bad_args", "gift wrap requires --to USER")
val json = RawEventSupport.readArgOrStdin(args)
if (json.isEmpty()) return Output.error("bad_args", "no inner event JSON on the argument or stdin")
val inner =
try {
Event.fromJson(json)
} catch (e: Exception) {
return Output.error("bad_args", "could not parse inner event JSON: ${e.message}")
}
val ctx = Context.open(dataDir)
try {
ctx.prepare()
val peer = ctx.requireUserHex(to)
val seal = SealedRumorEvent.create(event = inner, encryptTo = peer, signer = ctx.signer)
val giftWrap = GiftWrapEvent.create(event = seal, recipientPubKey = peer)
val wrapNode = Output.mapper.readTree(giftWrap.toJson())
val targets = RawEventSupport.relayFlag(args)
if (targets.isEmpty()) {
Output.emit(mapOf("event" to wrapNode, "published" to false))
return 0
}
val ack = ctx.publish(giftWrap, targets)
Output.emit(
mapOf(
"event" to wrapNode,
"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()
}
}
private suspend fun unwrap(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val json = RawEventSupport.readArgOrStdin(args)
if (json.isEmpty()) return Output.error("bad_args", "no gift wrap JSON on the argument or stdin")
val giftWrap =
try {
Event.fromJson(json) as? GiftWrapEvent
?: return Output.error("bad_args", "not a kind:1059 gift wrap event")
} catch (e: Exception) {
return Output.error("bad_args", "could not parse gift wrap JSON: ${e.message}")
}
val ctx = Context.open(dataDir)
try {
ctx.prepare()
val unwrapped =
try {
giftWrap.unwrapThrowing(ctx.signer)
} catch (e: Exception) {
return Output.error("decrypt_failed", "could not unwrap (is this gift addressed to the active account?): ${e.message}")
}
// The wrap holds a seal (kind:13); unseal it to recover the rumor.
val inner = if (unwrapped is SealedRumorEvent) unwrapped.unsealThrowing(ctx.signer) else unwrapped
Output.emit(mapOf("event" to Output.mapper.readTree(inner.toJson())))
return 0
} finally {
ctx.close()
}
}
}