feat(cli): add nak-style filter, outbox, relay info primitives

Fifth batch of nak parity:

- `amy filter [filter flags]` — stateless: assemble + print a NIP-01
  filter JSON from the same flags fetch/subscribe use (no query sent).
- `amy outbox USER` — show a user's NIP-65 read/write relays (outbox
  model), cache-first with a relay-drain fallback / --refresh.
- `amy relay info URL` — stateless NIP-11 info-document fetch over HTTP
  (Accept: application/nostr+json), parsed by quartz
  Nip11RelayInformation.

filter + relay info dispatch before account resolution (no ~/.amy
needed). Verified against relay.damus.io (NIP-11 doc) and fiatjaf's
kind:10002 (outbox read/write sets).

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:42:28 +00:00
parent 0ec3fc69e8
commit 62d0b16740
7 changed files with 209 additions and 4 deletions
+3
View File
@@ -213,6 +213,8 @@ Army-knife verbs that operate purely on their arguments. They never touch
| `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. |
| `amy filter [filter flags]` | Assemble and print a NIP-01 filter JSON from the same flags `fetch`/`subscribe` use — no query is sent. |
| `amy relay info URL` | Fetch and print a relay's NIP-11 information document. |
### Raw events
@@ -231,6 +233,7 @@ 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. |
| `amy outbox USER [--refresh] [--timeout SECS]` | Show USER's NIP-65 read/write relays (outbox model). Cache-first; `--refresh` forces a relay drain. |
### Encryption
+4 -3
View File
@@ -89,10 +89,11 @@ vs streaming `subscribe`). Stateless verbs run with no account or network.
| `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. |
| `relay` (NIP-11) | `amy relay info` | | stateless NIP-11 doc fetch. |
| `outbox` | `amy outbox` | | NIP-65 read/write relays, cache-first. |
| `filter` | `amy filter` | ✅ | stateless — assemble + print a filter JSON. |
| `blossom` | `amy blossom` | 🆕 | upload/download/list/delete (client already used by `dm`/`nsite`). |
| `kind` / `nip` | `amy kind` / `amy nip` | 🆕 | reference lookups. |
| `kind` / `nip` | `amy kind` / `amy nip` | 🆕 | reference lookups (needs a kind registry). |
| `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. |
@@ -138,6 +138,17 @@ private suspend fun dispatch(argv: Array<String>): Int {
"key" ->
return com.vitorpamplona.amethyst.cli.commands.KeyCommands
.dispatch(tail)
"filter" ->
return com.vitorpamplona.amethyst.cli.commands.FilterCommand
.run(tail)
}
// `relay info URL` is a stateless NIP-11 fetch — no account needed. The
// rest of `relay …` (add/list/publish-lists) operates on the account and
// falls through to the normal path below.
if (head == "relay" && tail.firstOrNull() == "info") {
return com.vitorpamplona.amethyst.cli.commands.RelayCommands
.info(tail.drop(1).toTypedArray())
}
val secrets = SecretStore.from(backendFlag = secretBackendFlag, passphraseFile = passphraseFileFlag)
@@ -248,6 +259,10 @@ private suspend fun dispatch(argv: Array<String>): Int {
Commands.gift(dataDir, tail)
}
"outbox" -> {
Commands.outbox(dataDir, tail)
}
else -> {
System.err.println("unknown subcommand: $head")
printUsage()
@@ -396,6 +411,8 @@ private fun printUsage() {
| (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
| filter [--kind …] [--author …] assemble + print a NIP-01 filter JSON from the
| [--id …] [--tag …] … same flags fetch/subscribe use (no query sent)
|
|Identity:
| init [--nsec NSEC] create or import a bare identity (no defaults published)
@@ -407,6 +424,9 @@ private fun printUsage() {
| relay add URL [--type T] T=nip65|inbox|key_package|all (default all)
| relay list print configured relays
| relay publish-lists publish kind:10002 + kind:10050
| relay info URL fetch + print a relay's NIP-11 info document
| outbox USER [--refresh] show USER's NIP-65 read/write relays (outbox model)
| [--timeout SECS] (USER: npub|nprofile|hex|name@domain)
|
|Profile (NIP-01 kind:0):
| profile show [USER] [--timeout SECS] fetch latest kind:0 metadata
@@ -175,4 +175,9 @@ object Commands {
dataDir: DataDir,
tail: Array<String>,
): Int = GiftCommands.dispatch(dataDir, tail)
suspend fun outbox(
dataDir: DataDir,
tail: Array<String>,
): Int = OutboxCommand.run(dataDir, tail)
}
@@ -0,0 +1,42 @@
/*
* 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
/**
* `amy filter [--kind …] [--author …] [--id …] [--tag …] [--since/--until TS]
* [--limit N] [--search TEXT]` — assemble a NIP-01 filter JSON
* from flags and print it (nak's `filter`). Local, no network, no account —
* handy for piping into another tool or eyeballing what `fetch`/`subscribe`
* would send.
*
* Same flag grammar as `fetch`/`subscribe`; `--author`/`--id` accept
* npub/nevent/note/naddr or hex (local decode only).
*/
object FilterCommand {
fun run(rest: Array<String>): Int {
val filter = RawEventSupport.buildFilter(Args(rest))
Output.emit(Output.mapper.readTree(filter.toJson()))
return 0
}
}
@@ -0,0 +1,86 @@
/*
* 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.nip65RelayList.AdvertisedRelayListEvent
/**
* `amy outbox USER [--refresh] [--timeout SECS]` — show a user's NIP-65
* (kind:10002) read/write relays — the outbox-model relay hints (nak's
* `outbox`). USER accepts npub/nprofile/hex/NIP-05.
*
* Cache-first: reads the local store, falling back to a relay drain on a
* miss (or always with `--refresh`).
*/
object OutboxCommand {
suspend fun run(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val user = args.positional(0, "user")
val refresh = args.bool("refresh")
val timeoutMs = (args.flag("timeout")?.toLongOrNull() ?: 8L) * 1000
val ctx = Context.open(dataDir)
try {
ctx.prepare()
val pubkey = ctx.requireUserHex(user)
var event = if (refresh) null else ctx.relaysOf(pubkey)
if (event == null) {
ctx.drain(
ctx.bootstrapRelays().associateWith {
listOf(
com.vitorpamplona.quartz.nip01Core.relay.filters
.Filter(authors = listOf(pubkey), kinds = listOf(AdvertisedRelayListEvent.KIND), limit = 1),
)
},
timeoutMs,
)
event = ctx.relaysOf(pubkey)
}
if (event == null) {
Output.emit(mapOf("pubkey" to pubkey, "found" to false))
return 0
}
Output.emit(
mapOf(
"pubkey" to pubkey,
"found" to true,
"created_at" to event.createdAt,
"read" to (event.readRelaysNorm()?.map { it.url } ?: emptyList()),
"write" to (event.writeRelaysNorm()?.map { it.url } ?: emptyList()),
"all" to event.relaysNorm().map { it.url },
),
)
return 0
} finally {
ctx.close()
}
}
}
@@ -27,10 +27,14 @@ import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType
import okhttp3.OkHttpClient
import okhttp3.Request
/**
* `amy relay <add|list|publish-lists>` — manage this account's relay sets.
@@ -52,17 +56,61 @@ object RelayCommands {
dataDir: DataDir,
tail: Array<String>,
): Int {
if (tail.isEmpty()) return Output.error("bad_args", "relay <add|list|publish-lists> …")
if (tail.isEmpty()) return Output.error("bad_args", "relay <add|list|publish-lists|info> …")
val sub = tail[0]
val rest = tail.drop(1).toTypedArray()
return when (sub) {
"add" -> add(dataDir, Args(rest))
"list" -> list(dataDir)
"publish-lists" -> publishLists(dataDir)
"info" -> info(rest)
else -> Output.error("bad_args", "relay $sub")
}
}
/**
* `relay info URL` — fetch a relay's NIP-11 information document over
* HTTP (`Accept: application/nostr+json`) and print it. Local/stateless:
* no account, no websocket. Parsing lives in quartz
* ([Nip11RelayInformation.fromJson]); this only does the GET.
*/
fun info(rest: Array<String>): Int {
val args = Args(rest)
val raw = args.positional(0, "relay-url")
val normalized =
raw.normalizeRelayUrlOrNull()
?: return Output.error("bad_args", "invalid relay url: $raw")
val httpUrl = normalized.toHttp()
val request =
Request
.Builder()
.url(httpUrl)
.header("Accept", Nip11RelayInformation.CONTENT_TYPE)
.get()
.build()
return try {
OkHttpClient().newCall(request).execute().use { response ->
if (!response.isSuccessful) {
return Output.error("http_error", "relay returned HTTP ${response.code} for $httpUrl")
}
val body = response.body.string()
val info = Nip11RelayInformation.fromJson(body)
Output.emit(
mapOf(
"relay" to normalized.url,
"url" to httpUrl,
"info" to Output.mapper.readTree(info.toJson()),
),
)
0
}
} catch (e: Exception) {
Output.error("fetch_failed", "could not fetch NIP-11 from $httpUrl: ${e.message}")
}
}
private suspend fun add(
dataDir: DataDir,
args: Args,