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