diff --git a/cli/README.md b/cli/README.md index 2b24b2ea5d..0937ea7dc0 100644 --- a/cli/README.md +++ b/cli/README.md @@ -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 diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index 9acbcb0a51..a18f7413fe 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -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. | diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index fc2fccebfa..308c2cf6e0 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -138,6 +138,17 @@ private suspend fun dispatch(argv: Array): 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): 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 diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt index 7d7f42020a..c65d29190d 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt @@ -175,4 +175,9 @@ object Commands { dataDir: DataDir, tail: Array, ): Int = GiftCommands.dispatch(dataDir, tail) + + suspend fun outbox( + dataDir: DataDir, + tail: Array, + ): Int = OutboxCommand.run(dataDir, tail) } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FilterCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FilterCommand.kt new file mode 100644 index 0000000000..2f4af8fd37 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FilterCommand.kt @@ -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): Int { + val filter = RawEventSupport.buildFilter(Args(rest)) + Output.emit(Output.mapper.readTree(filter.toJson())) + return 0 + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/OutboxCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/OutboxCommand.kt new file mode 100644 index 0000000000..0755280b3e --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/OutboxCommand.kt @@ -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, + ): 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() + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt index aade5d8298..c381ccd641 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt @@ -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 ` — manage this account's relay sets. @@ -52,17 +56,61 @@ object RelayCommands { dataDir: DataDir, tail: Array, ): Int { - if (tail.isEmpty()) return Output.error("bad_args", "relay …") + if (tail.isEmpty()) return Output.error("bad_args", "relay …") 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): 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,