feat(cli): add nak-style stateless primitives (decode, encode, verify, key)

Add the first batch of nak parity commands to amy — the army-knife
primitives that operate purely on their arguments, with no account or
network. They dispatch before account resolution (like `use`), so they
run with zero `~/.amy/` state:

- `amy decode ENTITY`   NIP-19/21 entity -> JSON
- `amy encode <type> …` raw parts -> NIP-19 entity
- `amy verify [JSON]`   id-hash + signature check (reads stdin)
- `amy key generate|public`  mint a keypair / derive a pubkey

All four are thin wrappers over quartz (Nip19Parser, the NIP-19
entities, Event.verifyId/verifySignature, KeyPair) per the cli
thin-assembly rule. README + ROADMAP updated with a nak-parity matrix.

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:13:05 +00:00
parent fa11a94613
commit b41160f1cb
7 changed files with 431 additions and 0 deletions
+17
View File
@@ -197,6 +197,23 @@ $ amy relay publish-lists # broadcast updated kind:10002/10050/10051
## Commands
### Primitives (stateless — no account or network)
Army-knife verbs that operate purely on their arguments. They never touch
`~/.amy/`, so they run with zero state — handy for scripting and piping
(`amy decode … | jq`, `… | amy verify`).
| Command | What it does |
|---|---|
| `amy decode ENTITY` | Decode a NIP-19/21 entity (`npub`/`nsec`/`note`/`nevent`/`nprofile`/`naddr`/`nrelay`/`nembed`) to JSON. Accepts an optional `nostr:` prefix. |
| `amy encode npub HEX` / `nsec HEX` / `note ID` | Encode a single 32-byte hex value into the matching NIP-19 entity. |
| `amy encode nevent ID [--author HEX] [--kind N] [--relay URL[,URL…]]` | Encode an event pointer with optional author/kind/relay hints. |
| `amy encode nprofile HEX [--relay URL[,URL…]]` | Encode a profile pointer with optional relay hints. |
| `amy encode naddr --kind N --pubkey HEX --identifier D [--relay URL[,URL…]]` | Encode an addressable-event (`a` tag) pointer. |
| `amy verify [EVENT-JSON]` | Check an event's id hash and signature. Reads stdin when the argument is omitted or `-`. Reports `id_ok` + `signature_ok` separately. |
| `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. |
### Identity
| Command | What it does |
+26
View File
@@ -70,6 +70,32 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command ·
| Notifications feed | 🆕 | |
| Search (NIP-50) | 🆕 | |
### `nak` parity — army-knife primitives
Tracking [`fiatjaf/nak`](https://github.com/fiatjaf/nak)'s command surface. amy
adapts the verbiage where its own conventions differ (`req` → one-shot `fetch`
vs streaming `subscribe`). Stateless verbs run with no account or network.
| nak command | amy verb | Status | Notes |
|---|---|---|---|
| `decode` | `amy decode` | ✅ | NIP-19/21 → JSON. Quartz `Nip19Parser`. |
| `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. |
| `req` (one-shot) | `amy fetch` | 🆕 | filter → collect-until-EOSE. |
| `req` (stream) | `amy subscribe` | 🆕 | filter → live 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`). |
| `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`). |
| `kind` / `nip` | `amy kind` / `amy nip` | 🆕 | reference lookups. |
| `sync` | `amy relay sync` | 🆕 | NIP-77 Negentropy. |
| `bunker` / `serve` / `admin` / `wallet` / `git` / `podcast` / `mcp` / `fs` / `spell` | — | 🆕 (tier 2/3) | larger/niche; some pull new deps. |
---
## Order of operations
@@ -122,6 +122,24 @@ private suspend fun dispatch(argv: Array<String>): Int {
.run(tail)
}
// Stateless local primitives (nak-style army-knife verbs). They operate
// purely on their arguments — no identity, no relays, no `~/.amy/` — so
// they dispatch before account resolution and work with zero state.
when (head) {
"decode" ->
return com.vitorpamplona.amethyst.cli.commands.DecodeCommand
.run(tail)
"encode" ->
return com.vitorpamplona.amethyst.cli.commands.EncodeCommand
.run(tail)
"verify" ->
return com.vitorpamplona.amethyst.cli.commands.VerifyCommand
.run(tail)
"key" ->
return com.vitorpamplona.amethyst.cli.commands.KeyCommands
.dispatch(tail)
}
val secrets = SecretStore.from(backendFlag = secretBackendFlag, passphraseFile = passphraseFileFlag)
val dataDir = DataDir.resolve(accountFlag = accountFlag, secrets = secrets)
@@ -333,6 +351,20 @@ private fun printUsage() {
| then ${'$'}AMY_PASSPHRASE, then a TTY prompt. `plaintext` writes the
| private key directly into identity.json (still 0600) — dev only.
|
|Primitives (stateless — no account or network needed):
| decode ENTITY decode a NIP-19/21 entity (npub|nsec|note|nevent|
| nprofile|naddr|nrelay|nembed) to JSON
| encode npub HEX encode raw parts into a NIP-19 entity:
| encode nsec HEX nevent/nprofile/naddr accept --relay URL[,URL…];
| encode note ID nevent accepts --author HEX --kind N;
| encode nevent ID [...] naddr needs --kind N --pubkey HEX --identifier D
| encode nprofile HEX [...]
| encode naddr --kind N --pubkey HEX --identifier D [--relay URL[,URL…]]
| verify [EVENT-JSON] check an event's id hash + signature
| (reads stdin when the arg is omitted or `-`)
| key generate mint a fresh keypair (nsec + npub + hex)
| key public NSEC|HEX derive the public key from a secret key
|
|Identity:
| init [--nsec NSEC] create or import a bare identity (no defaults published)
| create [--name NAME] provision a full Amethyst-style account + publish bootstrap events
@@ -0,0 +1,87 @@
/*
* 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.Output
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay
import com.vitorpamplona.quartz.nip19Bech32.entities.NSec
/**
* `amy decode <bech32>` — turn a NIP-19 / NIP-21 entity into its structured
* parts (nak's `decode`). Local, no network, no account. Accepts an optional
* `nostr:` prefix.
*
* Thin assembly only: all parsing lives in quartz's [Nip19Parser]; this file
* just maps the parsed entity onto amy's result-map output contract.
*/
object DecodeCommand {
fun run(rest: Array<String>): Int {
val args = Args(rest)
val input = args.positional(0, "entity").trim()
val entity =
Nip19Parser.uriToRoute(input)?.entity
?: return Output.error("bad_args", "not a recognized NIP-19 entity (npub, nsec, note, nevent, nprofile, naddr, nrelay, nembed)")
val result: Map<String, Any?> =
when (entity) {
is NPub -> mapOf("type" to "npub", "pubkey" to entity.hex)
is NSec -> mapOf("type" to "nsec", "private_key" to entity.hex, "pubkey" to entity.toPubKeyHex())
is NNote -> mapOf("type" to "note", "id" to entity.hex)
is NProfile ->
mapOf(
"type" to "nprofile",
"pubkey" to entity.hex,
"relays" to entity.relay.map { it.url },
)
is NEvent ->
mapOf(
"type" to "nevent",
"id" to entity.hex,
"author" to entity.author,
"kind" to entity.kind,
"relays" to entity.relay.map { it.url },
)
is NAddress ->
mapOf(
"type" to "naddr",
"kind" to entity.kind,
"pubkey" to entity.author,
"identifier" to entity.dTag,
"relays" to entity.relay.map { it.url },
)
is NRelay -> mapOf("type" to "nrelay", "relays" to entity.relay)
is NEmbed -> mapOf("type" to "nembed", "event" to Output.mapper.readTree(entity.event.toJson()))
else -> return Output.error("bad_args", "unsupported entity type")
}
Output.emit(result)
return 0
}
}
@@ -0,0 +1,114 @@
/*
* 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.Output
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip19Bech32.toNsec
/**
* `amy encode <type> …` — build a NIP-19 entity from raw parts (nak's
* `encode`). Local, no network, no account.
*
* encode npub <hex>
* encode nsec <hex>
* encode note <event-id-hex>
* encode nevent <event-id-hex> [--author HEX] [--kind N] [--relay URL[,URL…]]
* encode nprofile <pubkey-hex> [--relay URL[,URL…]]
* encode naddr --kind N --pubkey HEX --identifier D [--relay URL[,URL…]]
*
* Thin assembly only: every encoder lives in quartz's NIP-19 entities; this
* file parses flags and calls them.
*/
object EncodeCommand {
fun run(rest: Array<String>): Int {
if (rest.isEmpty()) return Output.error("bad_args", "encode <npub|nsec|note|nevent|nprofile|naddr> …")
val type = rest[0]
val args = Args(rest.drop(1).toTypedArray())
return when (type) {
"npub" -> emit("npub", NPub.create(hex32(args.positional(0, "pubkey-hex"))))
"nsec" -> emit("nsec", hex32(args.positional(0, "private-key-hex")).hexToByteArray().toNsec())
"note" -> emit("note", NNote.create(hex32(args.positional(0, "event-id-hex"))))
"nevent" ->
emit(
"nevent",
NEvent.create(
idHex = hex32(args.positional(0, "event-id-hex")),
author = args.flag("author"),
kind = args.flag("kind")?.toIntOrNull(),
relays = relays(args),
),
)
"nprofile" ->
emit(
"nprofile",
NProfile.create(
authorPubKeyHex = hex32(args.positional(0, "pubkey-hex")),
relays = relays(args),
),
)
"naddr" ->
emit(
"naddr",
NAddress.create(
kind = args.requireFlag("kind").toIntOrNull() ?: return Output.error("bad_args", "--kind must be an integer"),
pubKeyHex = hex32(args.requireFlag("pubkey")),
dTag = args.flag("identifier", "") ?: "",
relays = relays(args),
),
)
else -> Output.error("bad_args", "encode $type (expected npub|nsec|note|nevent|nprofile|naddr)")
}
}
/** Validate a 64-char hex key/id; bare-hex only — bech32 inputs go through `decode` first. */
private fun hex32(value: String): String {
val v = value.trim().lowercase()
require(v.length == 64 && v.all { it in "0123456789abcdef" }) {
"expected 64-char hex, got '$value'"
}
return v
}
private fun relays(args: Args): List<NormalizedRelayUrl> =
args
.flag("relay")
?.split(',')
?.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it.trim()) }
.orEmpty()
private fun emit(
key: String,
value: String,
): Int {
Output.emit(mapOf(key to value))
return 0
}
}
@@ -0,0 +1,80 @@
/*
* 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.Identity
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
/**
* `amy key …` — standalone key utilities (nak's `key`). Local, no network,
* no account; these never touch `~/.amy/` — they operate purely on the keys
* passed in. Use `amy init` / `amy login` to persist an identity.
*
* key generate mint a fresh keypair (prints nsec + npub + hex)
* key public <nsec|hex-priv> derive the public key from a secret key
*
* Thin assembly only: key generation + bech32 derivation live in quartz
* (reused here via [Identity], which wraps Quartz's `KeyPair`).
*/
object KeyCommands {
fun dispatch(rest: Array<String>): Int {
if (rest.isEmpty()) return Output.error("bad_args", "key <generate|public>")
val tail = rest.drop(1).toTypedArray()
return when (rest[0]) {
"generate" -> generate()
"public" -> public(tail)
else -> Output.error("bad_args", "key ${rest[0]} (expected generate|public)")
}
}
private fun generate(): Int {
val id = Identity.create()
Output.emit(
mapOf(
"nsec" to id.nsec,
"npub" to id.npub,
"private_key" to id.privKeyHex,
"pubkey" to id.pubKeyHex,
),
)
return 0
}
private fun public(rest: Array<String>): Int {
val args = Args(rest)
val input = args.positional(0, "secret-key").trim()
val id =
try {
when {
input.startsWith("nsec") -> Identity.fromNsec(input)
input.length == 64 && input.lowercase().all { it in "0123456789abcdef" } ->
Identity.fromPrivateKey(input.hexToByteArray())
else -> return Output.error("bad_args", "expected an nsec or a 64-char hex private key")
}
} catch (e: Exception) {
return Output.error("bad_args", "could not parse secret key: ${e.message}")
}
Output.emit(mapOf("npub" to id.npub, "pubkey" to id.pubKeyHex))
return 0
}
}
@@ -0,0 +1,75 @@
/*
* 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.Output
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.crypto.verifyId
import com.vitorpamplona.quartz.nip01Core.crypto.verifySignature
/**
* `amy verify [EVENT-JSON]` — check a Nostr event's id hash and signature
* (nak's `verify`). Local, no network, no account. The event JSON is read
* from the positional argument, or from stdin when the argument is omitted
* or `-`.
*
* Reports `id_ok` (computed id matches the `id` field) and `signature_ok`
* separately so a caller can tell a tampered id apart from a bad sig.
* `valid` is the conjunction. Exit code stays 0 — the result is data, not
* a runtime failure (parse errors are runtime/bad_args).
*/
object VerifyCommand {
fun run(rest: Array<String>): Int {
val args = Args(rest)
val arg = args.positionalOrNull(0)
val json =
if (arg == null || arg == "-") {
System.`in`
.readBytes()
.decodeToString()
.trim()
} else {
arg.trim()
}
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}")
}
val idOk = event.verifyId()
val sigOk = event.verifySignature()
Output.emit(
mapOf(
"valid" to (idOk && sigOk),
"id" to event.id,
"id_ok" to idOk,
"signature_ok" to sigOk,
),
)
return 0
}
}