mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
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:
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user