From 11901df2c6d2184fa320b892485c6b87b97af4d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 20:56:23 +0000 Subject: [PATCH] feat(cli): contract sweep for event/git/geochat/graperank/group/key/login verbs Same contract as the A-D sweep, applied to the E-M command families: - rejectUnknown() everywhere Args is constructed (typo'd flags -> bad_args) - publishGuard on single-event publishes (total rejection -> non-zero) - USAGE constants + route(help=...) / --help fast-paths; graperank's full sub-verb set (including the previously undocumented 'followers') and 'key validate' + the --pw alias are now documented in-binary - geochat --relay is a strict comma-list (bare positional relays kept); geochat listen default limit 500 -> 50 - graperank update/probe aliases now print deprecation notes - git --identifier accepted as alias of --d - marmot message list defaults to --limit 50 (0 = everything) - dm-style guards for marmot message send/react/delete and group edits Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj --- .../amethyst/cli/commands/EncodeCommand.kt | 18 +++ .../amethyst/cli/commands/EventCommand.kt | 15 +++ .../amethyst/cli/commands/FeedCommand.kt | 1 + .../amethyst/cli/commands/FetchCommand.kt | 22 ++++ .../amethyst/cli/commands/FilterCommand.kt | 18 ++- .../amethyst/cli/commands/FofCommand.kt | 18 +++ .../amethyst/cli/commands/FollowCommand.kt | 15 +++ .../amethyst/cli/commands/GeochatCommands.kt | 41 ++++++- .../amethyst/cli/commands/GiftCommands.kt | 14 +++ .../amethyst/cli/commands/GitCommands.kt | 31 ++++- .../amethyst/cli/commands/GrapeRankCommand.kt | 111 +++++++++++++++++- .../amethyst/cli/commands/GroupCommands.kt | 21 ++++ .../cli/commands/GroupCreateCommand.kt | 1 + .../cli/commands/GroupMembershipCommands.kt | 3 +- .../cli/commands/GroupMetadataCommands.kt | 2 + .../amethyst/cli/commands/InitCommands.kt | 13 ++ .../amethyst/cli/commands/KeyCommands.kt | 24 +++- .../cli/commands/KeyPackageCommands.kt | 12 +- .../amethyst/cli/commands/KindCommand.kt | 13 ++ .../amethyst/cli/commands/LoginCommand.kt | 20 ++++ .../amethyst/cli/commands/LogoffCommand.kt | 13 ++ .../amethyst/cli/commands/MessageCommands.kt | 22 +++- 22 files changed, 431 insertions(+), 17 deletions(-) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/EncodeCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/EncodeCommand.kt index fc5322586d..6d2892ed3c 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/EncodeCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/EncodeCommand.kt @@ -47,10 +47,28 @@ import com.vitorpamplona.quartz.nip19Bech32.toNsec * file parses flags and calls them. */ object EncodeCommand { + val USAGE: String = + """ + |amy encode — build a NIP-19 entity from raw parts (local, no account) + | + | encode npub HEX encode a public key + | encode nsec HEX encode a private key + | encode note ID encode an event id + | encode nevent ID [--author HEX] [--kind N] [--relay URL[,URL…]] + | encode nprofile HEX [--relay URL[,URL…]] + | encode naddr --kind N --pubkey HEX --identifier D [--relay URL[,URL…]] + """.trimMargin() + fun run(rest: Array): Int { + if (rest.firstOrNull() == "--help" || rest.firstOrNull() == "-h") { + System.err.println(USAGE) + return 0 + } if (rest.isEmpty()) return Output.error("bad_args", "encode …") val type = rest[0] val args = Args(rest.drop(1).toTypedArray()) + // Flags are read branch-dependently below, so whitelist the union. + args.rejectUnknown("author", "kind", "relay", "pubkey", "identifier") return when (type) { "npub" -> emit("npub", NPub.create(hex32(args.positional(0, "pubkey-hex")))) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/EventCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/EventCommand.kt index d5a7bd5fdf..3fd528b6f9 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/EventCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/EventCommand.kt @@ -44,10 +44,23 @@ import com.vitorpamplona.quartz.utils.TimeUtils * (`EventTemplate` / `NostrSigner.sign`); this file parses flags. */ object EventCommand { + val USAGE: String = + """ + |amy event — build + sign an arbitrary event with the active account + | + | event --kind N [--content TEXT] prints the signed event; add --publish + | [--tags JSON] [--created-at TS] (or --relay) to broadcast. --tags takes a + | [--publish] [--relay URL[,URL…]] JSON array-of-arrays, e.g. '[["t","nostr"]]'. + """.trimMargin() + suspend fun run( dataDir: DataDir, rest: Array, ): Int { + if (rest.firstOrNull() == "--help" || rest.firstOrNull() == "-h") { + System.err.println(USAGE) + return 0 + } val args = Args(rest) val kind = args.flag("kind")?.toIntOrNull() @@ -69,6 +82,7 @@ object EventCommand { // Publish when explicitly asked (--publish) or when a relay set is given. val wantPublish = args.bool("publish") || args.flag("relay") != null + args.rejectUnknown() Context.open(dataDir).use { ctx -> ctx.prepare() @@ -85,6 +99,7 @@ object EventCommand { return Output.error("no_relays", "no outbox relays configured; pass --relay or run `amy relay add`") } val ack = ctx.publish(signed, targets) + RawEventSupport.publishGuard(ack, signed.id)?.let { return it } Output.emit( mapOf( "event" to eventNode, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FeedCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FeedCommand.kt index cf207405f0..a144bfb51c 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FeedCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FeedCommand.kt @@ -60,6 +60,7 @@ object FeedCommand { val since = args.flag("since")?.toLongOrNull() val until = args.flag("until")?.toLongOrNull() val timeoutSecs = args.longFlag("timeout", 8L) + args.rejectUnknown() // Read-only: runs anonymously when there is no account. `--author` / // `--following` still work; the bare "self" feed just has no self to diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FetchCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FetchCommand.kt index 9118f8f925..b77cf14ac9 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FetchCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FetchCommand.kt @@ -68,10 +68,28 @@ object FetchCommand { /** Output/paging cap for a fetch (either path) when `--limit` is omitted. */ private const val DEFAULT_LIMIT = 100 + val USAGE: String = + """ + |amy fetch — one-shot query: collect until EOSE, print, exit + | + | fetch [--kind K[,K]] [--author U[,U]] --author/--id accept npub/nevent/note/hex. + | [--id ID[,ID]] [--tag e=ID,p=PK,…] default --limit 100 (0 = unbounded), + | [--since TS] [--until TS] [--limit N] --timeout 8s. + | [--search TEXT] [--relay URL[,URL…]] + | [--timeout SECS] [--paginate|--all] --paginate walks each relay page-by-page + | past its per-REQ cap (alias --all). + | fetch + | outbox-model resolution of a shared code. + """.trimMargin() + suspend fun run( dataDir: DataDir, rest: Array, ): Int { + if (rest.firstOrNull() == "--help" || rest.firstOrNull() == "-h") { + System.err.println(USAGE) + return 0 + } val args = Args(rest) // `--limit`: omitted → DEFAULT_LIMIT on BOTH the plain and --paginate paths; // `0` → unbounded (drain everything — only useful with --paginate); negative @@ -80,6 +98,10 @@ object FetchCommand { if (explicitLimit != null && explicitLimit < 0) return Output.error("bad_args", "--limit must be >= 0 (0 = unbounded)") val effectiveLimit: Int? = if (explicitLimit == 0) null else (explicitLimit ?: DEFAULT_LIMIT) val timeoutMs = (args.flag("timeout")?.toLongOrNull() ?: 8L) * 1000 + // The filter/relay/paging flags are read later (buildFilter, queryTargets, + // the --paginate branch) and code mode skips them entirely, so whitelist + // them here where both paths still share the flow. + args.rejectUnknown("kind", "author", "id", "tag", "since", "until", "search", "relay", "paginate", "all") // Code mode: a nip19/nip05 positional resolves its own relays via the // outbox model rather than using a hand-built filter. It fetches a single 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 index 2f4af8fd37..b76b39ca43 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FilterCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FilterCommand.kt @@ -34,8 +34,24 @@ import com.vitorpamplona.amethyst.cli.Output * npub/nevent/note/naddr or hex (local decode only). */ object FilterCommand { + val USAGE: String = + """ + |amy filter — assemble + print a NIP-01 filter JSON (local, no query sent) + | + | filter [--kind K[,K]] [--author U[,U]] same flag grammar as fetch/subscribe; + | [--id ID[,ID]] [--tag e=ID,p=PK,…] --author/--id accept npub/nevent/note/ + | [--since TS] [--until TS] naddr or hex (local decode only). + | [--limit N] [--search TEXT] + """.trimMargin() + fun run(rest: Array): Int { - val filter = RawEventSupport.buildFilter(Args(rest)) + if (rest.firstOrNull() == "--help" || rest.firstOrNull() == "-h") { + System.err.println(USAGE) + return 0 + } + val args = Args(rest) + val filter = RawEventSupport.buildFilter(args) + args.rejectUnknown() Output.emit(Output.mapper.readTree(filter.toJson())) return 0 } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FofCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FofCommand.kt index a10d700fbc..a4055cf856 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FofCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FofCommand.kt @@ -53,6 +53,18 @@ import java.util.Collections * - `fof sync` — refresh your follows' kind:3 from relays. */ object FofCommand { + val USAGE: String = + """ + |amy fof — follows-of-follows (social proof — the cheap counterpart to graperank) + | + | fof get USER USER's score: how many accounts you follow also + | follow them (single-hop social proof, not trust). + | fof list [--threshold N] [--limit N] accounts ranked by that score — who's most + | followed inside your network (default N: 1 / 50). + | fof sync [--timeout SECS] refresh your follows' kind:3 from the index relays + | so the next get/list is current. + """.trimMargin() + suspend fun dispatch( dataDir: DataDir, rest: Array, @@ -60,6 +72,10 @@ object FofCommand { val head = rest.firstOrNull() ?: return usage() val tail = rest.drop(1).toTypedArray() return when (head) { + "--help", "-h", "help" -> { + System.err.println(USAGE) + 0 + } "get" -> get(dataDir, tail) "list" -> list(dataDir, tail) "sync" -> sync(dataDir, tail) @@ -96,6 +112,7 @@ object FofCommand { val args = Args(rest) val threshold = args.flag("threshold")?.toIntOrNull() ?: 1 val limit = args.flag("limit")?.toIntOrNull() ?: 50 + args.rejectUnknown() Context.open(dataDir).use { ctx -> ctx.prepare() val (svc, scope) = buildHydratedService(ctx) @@ -126,6 +143,7 @@ object FofCommand { // Overall timeout; per-relay budget is set by OutboxDispatcher's // default (4s). `--timeout N` overrides the overall cap. val overallTimeoutMs = args.flag("timeout")?.toLongOrNull()?.times(1000) ?: 8_000L + args.rejectUnknown() Context.open(dataDir).use { ctx -> ctx.prepare() val self = ctx.identity.pubKeyHex diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FollowCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FollowCommand.kt index 85c6123390..a0d575383e 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FollowCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FollowCommand.kt @@ -43,6 +43,15 @@ import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent * `name@domain.tld` — same set [Context.requireUserHex] handles. */ object FollowCommand { + val USAGE: String = + """ + |amy follow / unfollow — update the NIP-02 kind:3 contact list + | + | follow USER [--timeout SECS] add USER to your contact list + | unfollow USER [--timeout SECS] remove USER from your contact list + | (USER: npub|nprofile|hex|name@domain) + """.trimMargin() + suspend fun follow( dataDir: DataDir, rest: Array, @@ -60,6 +69,10 @@ object FollowCommand { rest: Array, op: FollowOp, ): Int { + if (rest.firstOrNull() == "--help" || rest.firstOrNull() == "-h") { + System.err.println(USAGE) + return 0 + } if (rest.isEmpty()) { val verb = if (op == FollowOp.FOLLOW) "follow" else "unfollow" return Output.error("bad_args", "$verb [--timeout SECS]") @@ -67,6 +80,7 @@ object FollowCommand { val userArg = rest[0] val args = Args(rest.drop(1).toTypedArray()) val timeoutSecs = args.longFlag("timeout", 8L) + args.rejectUnknown() Context.open(dataDir).use { ctx -> ctx.prepare() @@ -132,6 +146,7 @@ object FollowCommand { } val ack = ctx.publish(newEvent, outbox) + RawEventSupport.publishGuard(ack, newEvent.id)?.let { return it } Output.emit( mapOf( "target" to target, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GeochatCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GeochatCommands.kt index d37489a961..ec5ebd2879 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GeochatCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GeochatCommands.kt @@ -66,9 +66,27 @@ import java.util.concurrent.CopyOnWriteArrayList */ object GeochatCommands { private const val DEFAULT_LISTEN_SECONDS = 30L - private const val DEFAULT_LIMIT = 500 + private const val DEFAULT_LIMIT = 50 private const val DEFAULT_POW_TIMEOUT_SECS = 5L + val USAGE: String = + """ + |amy geochat — Bitchat-interoperable public geohash chat (ephemeral kind:20000) + | + | geochat listen GEOHASH [--seconds N] hold a live subscription to the cell and report + | [--limit N] [--relay URL[,URL…]] messages + present pubkeys seen in the window + | [--no-fetch] (default --seconds 30, --limit 50) + | geochat send GEOHASH MESSAGE [--nick NAME] sign with the per-geohash throwaway identity and + | [--teleport] [--pow BITS] publish to the cell's nearest relays + | [--pow-timeout SECS] [--seed HEX] + | [--relay URL[,URL…]] [--no-fetch] + | geochat keys GEOHASH [--seed HEX] print the per-geohash derived pubkey + | + | --relay accepts a comma-separated list; bare wss://… positionals are + | also accepted for back-compat. --no-fetch skips the geo-relay + | directory refresh. + """.trimMargin() + suspend fun dispatch( dataDir: DataDir, tail: Array, @@ -82,6 +100,7 @@ object GeochatCommands { "send" to { rest -> send(dataDir, rest) }, "keys" to { rest -> keys(rest) }, ), + help = USAGE, ) /** @@ -102,6 +121,7 @@ object GeochatCommands { val seconds = args.longFlag("seconds", DEFAULT_LISTEN_SECONDS) val limit = args.intFlag("limit", DEFAULT_LIMIT) val relays = resolveRelays(args, geohash) + args.rejectUnknown() if (relays.isEmpty()) return Output.error("no_relays", "no relays for geohash $geohash (directory empty / bad --relay)") val since = TimeUtils.now() - seconds.coerceAtLeast(1) @@ -191,13 +211,16 @@ object GeochatCommands { val relays = resolveRelays(args, geohash) if (relays.isEmpty()) return Output.error("no_relays", "no relays for geohash $geohash (directory empty / bad --relay)") + // --pow-timeout is only read when --pow is set; whitelist it either way. + args.rejectUnknown("pow-timeout") Context.openOrAnonymous(dataDir).use { ctx -> ctx.prepare() val acks = ctx.publish(event, relays.toSet()) + RawEventSupport.publishGuard(acks, event.id)?.let { return it } Output.emit( mapOf( - "id" to event.id, + "event_id" to event.id, "pubkey" to event.pubKey, "geohash" to geohash, "nickname" to nick, @@ -222,6 +245,7 @@ object GeochatCommands { val geohash = args.positional.firstOrNull()?.lowercase() ?: return Output.error("bad_args", "geochat keys [--seed HEX]") if (!isGeohash(geohash)) return Output.error("bad_args", "not a geohash: $geohash") val seed = resolveSeed(args) ?: return Output.error("bad_args", "--seed must be 64 hex chars (32 bytes)") + args.rejectUnknown() val keyPair = GeohashKeyDerivation.deriveKeyPair(seed, geohash) Output.emit( mapOf( @@ -235,12 +259,17 @@ object GeochatCommands { // ------------------------------------------------------------------ - /** Explicit `--relay` list wins; otherwise the closest relays from the (optionally refreshed) directory. */ + /** + * Explicit `--relay URL[,URL…]` list wins (strictly validated — a bad entry + * is a `bad_args` failure, like every other `--relay` in amy); bare + * `wss://…` positionals are still accepted for back-compat. Otherwise the + * closest relays from the (optionally refreshed) directory. + */ private suspend fun resolveRelays( args: Args, geohash: String, ): List { - val explicit = args.flags["relay"]?.let { listOfNotNull(RelayUrlNormalizer.normalizeOrNull(it)) } ?: emptyList() + val explicit = RawEventSupport.relayFlag(args).toList() val allExplicit = (explicit + args.positional.mapNotNull { if (it.startsWith("wss://") || it.startsWith("ws://")) RelayUrlNormalizer.normalizeOrNull(it) else null }) if (allExplicit.isNotEmpty()) return allExplicit.distinct() @@ -260,8 +289,8 @@ object GeochatCommands { private fun messageJson(event: GeohashChatEvent): Map = mapOf( - "id" to event.id, - "pubkey" to event.pubKey, + "event_id" to event.id, + "author" to event.pubKey, "nickname" to event.nickname(), "teleported" to event.isTeleported(), "content" to event.content, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GiftCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GiftCommands.kt index dfd06917ca..89a61161ee 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GiftCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GiftCommands.kt @@ -44,6 +44,16 @@ import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent * (`SealedRumorEvent`, `GiftWrapEvent`). */ object GiftCommands { + val USAGE: String = + """ + |amy gift — NIP-59 gift-wrap primitives (inner/wrap JSON from arg or stdin/`-`) + | + | gift wrap --to USER [EVENT-JSON] 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 + """.trimMargin() + suspend fun dispatch( dataDir: DataDir, tail: Array, @@ -56,6 +66,7 @@ object GiftCommands { "wrap" to { rest -> wrap(dataDir, rest) }, "unwrap" to { rest -> unwrap(dataDir, rest) }, ), + help = USAGE, ) private suspend fun wrap( @@ -81,11 +92,13 @@ object GiftCommands { val wrapNode = Output.mapper.readTree(giftWrap.toJson()) val targets = RawEventSupport.relayFlag(args) + args.rejectUnknown() if (targets.isEmpty()) { Output.emit(mapOf("event" to wrapNode, "published" to false)) return 0 } val ack = ctx.publish(giftWrap, targets) + RawEventSupport.publishGuard(ack, giftWrap.id)?.let { return it } Output.emit( mapOf( "event" to wrapNode, @@ -103,6 +116,7 @@ object GiftCommands { rest: Array, ): Int { val args = Args(rest) + args.rejectUnknown() val json = RawEventSupport.readArgOrStdin(args) if (json.isEmpty()) return Output.error("bad_args", "no gift wrap JSON on the argument or stdin") val giftWrap = diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommands.kt index 6c4d0c8054..fa1d8a6e72 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommands.kt @@ -48,6 +48,22 @@ import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent * (`GitRepositoryEvent`, `GitIssueEvent`). */ object GitCommands { + val USAGE: String = + """ + |amy git — NIP-34 Nostr-native git repositories + | + | git announce --name N [--description D] publish a kind:30617 repo announcement + | [--clone URL[,URL]] [--web URL[,URL]] (--d / --identifier sets the identifier; + | [--relay URL[,URL]] [--maintainer HEX[,]] defaults to name) + | [--hashtag T[,T]] [--earliest-commit C] + | [--personal-fork] [--d ID | --identifier ID] + | git list [USER] [--relay URL[,URL]] list a user's repo announcements (default self) + | git show NADDR|kind:pubkey:id print one repo announcement + | [--relay URL[,URL]] + | git issue NADDR|coords --subject S [BODY] publish a kind:1621 issue against a repo + | [--hashtag T[,T]] [--relay URL[,URL]] (BODY from arg or stdin) + """.trimMargin() + suspend fun dispatch( dataDir: DataDir, tail: Array, @@ -62,6 +78,7 @@ object GitCommands { "show" to { rest -> show(dataDir, rest) }, "issue" to { rest -> issue(dataDir, rest) }, ), + help = USAGE, ) private suspend fun announce( @@ -70,6 +87,8 @@ object GitCommands { ): Int { val args = Args(rest) val name = args.flag("name") ?: return Output.error("bad_args", "git announce requires --name") + // `--identifier` is the spelled-out alias of `--d` (the d-tag). + val identifier = args.flag("d") ?: args.flag("identifier") ?: name val csv = { key: String -> args .flag(key) @@ -92,15 +111,17 @@ object GitCommands { hashtags = csv("hashtag"), earliestUniqueCommit = args.flag("earliest-commit"), personalFork = args.bool("personal-fork"), - dTag = args.flag("d") ?: name, + dTag = identifier, ) val signed = ctx.signer.sign(template) val targets = RawEventSupport.publishTargets(ctx, args) + args.rejectUnknown() val ack = ctx.publish(signed, targets) + RawEventSupport.publishGuard(ack, signed.id)?.let { return it } Output.emit( mapOf( "event_id" to signed.id, - "address" to Address.assemble(signed.kind, signed.pubKey, args.flag("d") ?: name), + "address" to Address.assemble(signed.kind, signed.pubKey, identifier), "published_to" to ack.filterValues { it }.keys.map { it.url }, ), ) @@ -119,6 +140,7 @@ object GitCommands { ctx.prepare() val author = args.positionalOrNull(0)?.let { ctx.requireUserHex(it) } ?: ctx.identity.pubKeyHex val relays = RawEventSupport.queryTargets(ctx, args) + args.rejectUnknown() val received = ctx.drain(relays.associateWith { listOf(Filter(kinds = listOf(GitRepositoryEvent.KIND), authors = listOf(author))) }) val repos = received @@ -140,6 +162,8 @@ object GitCommands { ): Int { val args = Args(rest) val coord = args.positional(0, "naddr-or-coordinates") + // `--relay` is read later inside fetchRepo's queryTargets. + args.rejectUnknown("relay") val addr = resolveAddress(coord) ?: return Output.error("bad_args", "expected an naddr or kind:pubkey:identifier (or pubkey:identifier)") if (addr.kind != GitRepositoryEvent.KIND) { return Output.error("bad_args", "not a git repository address (expected kind ${GitRepositoryEvent.KIND}, got ${addr.kind})") @@ -169,6 +193,8 @@ object GitCommands { ?.map { it.trim() } ?.filter { it.isNotEmpty() } .orEmpty() + // `--relay` is read later (relayFlag + fetchRepo's queryTargets). + args.rejectUnknown("relay") Context.open(dataDir).use { ctx -> ctx.prepare() @@ -186,6 +212,7 @@ object GitCommands { val repoRelays = repo.relays().mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet() val targets = RawEventSupport.relayFlag(args).ifEmpty { repoRelays }.ifEmpty { ctx.outboxRelays() } val ack = ctx.publish(signed, targets) + RawEventSupport.publishGuard(ack, signed.id)?.let { return it } Output.emit( mapOf( "event_id" to signed.id, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GrapeRankCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GrapeRankCommand.kt index 4ffc4bd398..4266eb85c1 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GrapeRankCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GrapeRankCommand.kt @@ -116,6 +116,48 @@ import kotlin.math.roundToInt * - `amy graperank providers [USER]` — list a user's trusted providers. */ object GrapeRankCommand { + val USAGE: String = + """ + |amy graperank — GrapeRank web-of-trust: crawl + score + publish NIP-85 cards + | + | graperank [OBSERVER] crawl + score: subjective trust (0..1) over the + | [--min-rank N] [--offline] follow/mute/report graph, then persist the result + | [--limit N] [--min-score X] as local NIP-85 kind:30382 cards (ranks >= + | [--rigor X] [--attenuation X] --min-rank, default 2). --offline skips the crawl. + | [--max-hops N] [--diagnose] OBSERVER: npub|nprofile|hex|name@domain (self). + | graperank crawl [OBSERVER] network only: crawl the graph (kind 3/10000/1984/ + | [--max-hops N] [--max-rounds N] 10002) into the local store, no scoring. + | [--no-preconnect] [--preconnect-cap N] Idempotent — run a few times to load everything. + | graperank followers [OBSERVER] reverse crawl: pull kind:3 lists that #p-tag the + | [--relay URL[,URL…]] [--max N] observer from every relay the store knows, so + | [--timeout SECS] [--relay-concurrency N] every follower becomes a graph edge for `score`. + | [--insert-batch N] + | graperank score [OBSERVER] local only: score from the store + persist cards + | (= bare --offline; same flags). No network. + | graperank publish [OBSERVER] push local cards to the operator relay(s) via a + | [--relay URL[,URL…]] [--timeout SECS] NIP-77 up-sync (nothing re-scored), and refresh + | [--relay-concurrency N] the observer's kind:10040 when we hold their key. + | graperank rank USER [--provider PUBKEY] read the kind:30382 cards about USER, one rank per + | [--refresh] [--timeout SECS] provider; --refresh drains relays on a miss. + | graperank status read-only local inventory: record counts, cache + | freshness, operator state, cards per observer. + | graperank refresh [--down] [--up] re-sync known authors' records (kind 0/3/10002/ + | [--relay-concurrency N] [--author-chunk N] 1984) from their outboxes via NIP-77, so score + | [--min-authors N] [--report-limit N] runs on current data. (`update` is a deprecated + | [--no-sync-deletions] [--timeout SECS] alias.) + | graperank register [PROVIDER] declare a NIP-85 provider in your kind:10040 + | [--service KIND:TAG] [--relay URL] (default: self as 30382:rank at your 1st outbox). + | [--private] + | graperank unregister PROVIDER remove matching entries from your kind:10040; + | [--service KIND:TAG] [--relay URL] --service/--relay narrow, else all for that key. + | graperank providers [USER] [--refresh] list a user's declared NIP-85 providers. + | [--timeout SECS] + | graperank operator operator keys (~/.amy/operator/): `relay URL…` + | [status | relay URL… | keys] sets the publish target; `keys` maps observer + | -> service-key. (default: status) + | graperank probe deprecated alias for `relay probe` (relay census). + """.trimMargin() + private const val FLAG_INSERT_BATCH = "insert-batch" private const val FLAG_RELAY_CONCURRENCY = "relay-concurrency" private const val FLAG_CONCURRENCY = "concurrency" @@ -213,7 +255,13 @@ object GrapeRankCommand { ): Int = // Sub-verbs are explicit words; anything else (npub / hex / nprofile / // NIP-05, or nothing) is the OBSERVER positional for a score computation. + // The bare-positional default means route() can't be used here, so + // handle --help explicitly before the fall-through. when (tail.firstOrNull()) { + "--help", "-h", "help" -> { + System.err.println(USAGE) + 0 + } "register" -> register(dataDir, tail.drop(1).toTypedArray()) "unregister" -> unregister(dataDir, tail.drop(1).toTypedArray()) "providers" -> providers(dataDir, tail.drop(1).toTypedArray()) @@ -224,10 +272,17 @@ object GrapeRankCommand { // The relay census outgrew graperank (it feeds the shared NIP-66 // reachability cache every command reads) and moved to `amy relay // probe`; this alias keeps the old spelling working. - "probe" -> RelayCommands.probe(dataDir, tail.drop(1).toTypedArray()) + "probe" -> { + System.err.println("[amy] `graperank probe` is deprecated — use `relay probe` (the relay census).") + RelayCommands.probe(dataDir, tail.drop(1).toTypedArray()) + } // `refresh` is canonical (it refreshes the WoT record kinds from each // author's outbox); `update` is the pre-rename back-compat alias. - "refresh", "update" -> refresh(dataDir, tail.drop(1).toTypedArray()) + "refresh" -> refresh(dataDir, tail.drop(1).toTypedArray()) + "update" -> { + System.err.println("[amy] `graperank update` is deprecated — use `graperank refresh`.") + refresh(dataDir, tail.drop(1).toTypedArray()) + } "score" -> run(dataDir, tail.drop(1).toTypedArray(), forceOffline = true) "publish" -> publish(dataDir, tail.drop(1).toTypedArray()) "rank" -> rank(dataDir, tail.drop(1).toTypedArray()) @@ -269,6 +324,21 @@ object GrapeRankCommand { attenuation = args.flag("attenuation")?.toDoubleOrNull() ?: GrapeRankParams().attenuation, rigor = args.flag("rigor")?.toDoubleOrNull() ?: GrapeRankParams().rigor, ) + // Crawl-tuning flags are read later inside newCrawler/flushReachability + // (and only on the online path), so whitelist them here. + args.rejectUnknown( + "max-rounds", + "max-hops", + "timeout", + "diagnose", + "drain-concurrency", + "timeout-evict", + "no-probe", + "no-aggregators", + "no-preconnect", + "preconnect-cap", + NO_REACHABILITY_CACHE_FLAG, + ) // Crawl never signs and score signs cards with the machine-level operator // key (`~/.amy/operator/`, independent of any account), so neither needs a @@ -570,6 +640,23 @@ object GrapeRankCommand { ): Int { val args = Args(rest) val observerArg = args.positionalOrNull(0) + // Every crawl flag is read later inside newCrawler/flushReachability, + // so whitelist the full set up front. + args.rejectUnknown( + "max-rounds", + "max-hops", + "timeout", + "park-timeout", + "diagnose", + FLAG_INSERT_BATCH, + "drain-concurrency", + "timeout-evict", + "no-probe", + "no-aggregators", + "no-preconnect", + "preconnect-cap", + NO_REACHABILITY_CACHE_FLAG, + ) // Crawl never signs, so no account is needed — run anonymously when there is // none, requiring an explicit observer (no logged-in user to default to). Context.openOrAnonymous(dataDir).use { ctx -> @@ -637,6 +724,9 @@ object GrapeRankCommand { val observerArg = args.positionalOrNull(0) val relayArg = args.flag("relay") val relayConcurrency = args.intFlag(FLAG_RELAY_CONCURRENCY, args.intFlag(FLAG_CONCURRENCY, 16)) + // --max/--timeout/--insert-batch are read later inside the crawler + // Config; --no-reachability-cache inside allKnownRelays. + args.rejectUnknown("max", "timeout", FLAG_INSERT_BATCH, NO_REACHABILITY_CACHE_FLAG) // Read-only + never signs, so it runs anonymously — but then an OBSERVER // positional is required (there's no account to default to). @@ -833,6 +923,16 @@ object GrapeRankCommand { // Default is bidirectional; a single --down/--up narrows to that direction. val downFlag = args.bool("down") val upFlag = args.bool("up") + // The remaining flags are read later inside the updater Config. + args.rejectUnknown( + "no-sync-deletions", + FLAG_RELAY_CONCURRENCY, + FLAG_CONCURRENCY, + "author-chunk", + "min-authors", + "timeout", + NO_REACHABILITY_CACHE_FLAG, + ) Context.openOrAnonymous(dataDir).use { ctx -> ctx.prepare() @@ -941,6 +1041,7 @@ object GrapeRankCommand { val relayConcurrency = args.intFlag(FLAG_RELAY_CONCURRENCY, args.intFlag(FLAG_CONCURRENCY, 4)) // Idle watchdog per relay reconcile (not a total budget), like `refresh`. val idleTimeoutMs = args.longFlag("timeout", 30L) * 1000 + args.rejectUnknown() Context.open(dataDir).use { ctx -> ctx.prepare() @@ -1036,6 +1137,7 @@ object GrapeRankCommand { val providerArg = args.flag("provider") val refresh = args.bool("refresh") val timeoutMs = args.longFlag("timeout", 8L) * 1000 + args.rejectUnknown() Context.openOrAnonymous(dataDir).use { ctx -> ctx.prepare() @@ -1196,6 +1298,7 @@ object GrapeRankCommand { val relayArg = args.flag("relay") val isPrivate = args.bool("private") val timeoutMs = args.longFlag("timeout", 8L) * 1000 + args.rejectUnknown() val service = serviceArg?.let { @@ -1241,6 +1344,7 @@ object GrapeRankCommand { } val ack = ctx.publish(event, outbox) + RawEventSupport.publishGuard(ack, event.id)?.let { return it } Output.emit( mapOf( "service" to service.toValue(), @@ -1280,6 +1384,7 @@ object GrapeRankCommand { val serviceArg = args.flag("service") val relayArg = args.flag("relay") val timeoutMs = args.longFlag("timeout", 8L) * 1000 + args.rejectUnknown() val service = serviceArg?.let { @@ -1333,6 +1438,7 @@ object GrapeRankCommand { } val ack = ctx.publish(event, outbox) + RawEventSupport.publishGuard(ack, event.id)?.let { return it } Output.emit( mapOf( "provider" to provider, @@ -1364,6 +1470,7 @@ object GrapeRankCommand { val userArg = args.positionalOrNull(0) val refresh = args.bool("refresh") val timeoutMs = args.longFlag("timeout", 8L) * 1000 + args.rejectUnknown() Context.open(dataDir).use { ctx -> ctx.prepare() diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupCommands.kt index 7f3dd85719..a729eb6cbb 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupCommands.kt @@ -23,6 +23,26 @@ package com.vitorpamplona.amethyst.cli.commands import com.vitorpamplona.amethyst.cli.DataDir object GroupCommands { + val USAGE: String = + """ + |amy marmot group — MLS group management + | + | marmot group create [--name NAME] create an empty group (self-only) + | marmot group list list joined groups + | marmot group show GID print full group details + | marmot group members GID print members + | marmot group admins GID print admins + | marmot group add GID NPUB [NPUB...] fetch KPs and invite + | marmot group rename GID NAME commit a rename + | marmot group promote GID NPUB add admin + | marmot group demote GID NPUB remove admin + | marmot group set-image GID FILE encrypt + commit a group avatar + | [--server URL] (--server uploads the ciphertext to Blossom) + | marmot group clear-image GID remove the group avatar + | marmot group remove GID NPUB remove member + | marmot group leave GID self-remove + """.trimMargin() + suspend fun dispatch( dataDir: DataDir, tail: Array, @@ -46,5 +66,6 @@ object GroupCommands { "remove" to { rest -> GroupMembershipCommands.remove(dataDir, rest) }, "leave" to { rest -> GroupMembershipCommands.leave(dataDir, rest) }, ), + help = USAGE, ) } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupCreateCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupCreateCommand.kt index ee138c4626..3f72301a57 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupCreateCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupCreateCommand.kt @@ -35,6 +35,7 @@ object GroupCreateCommand { ): Int { val args = Args(rest) val name = args.flag("name", "")!! + args.rejectUnknown() Context.open(dataDir).use { ctx -> ctx.prepare() val gid = RandomInstance.bytes(32).toHexKey() diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMembershipCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMembershipCommands.kt index 1239996981..47f514ab54 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMembershipCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMembershipCommands.kt @@ -39,11 +39,12 @@ object GroupMembershipCommands { val leafIndex = ctx.marmot.leafIndexOf(gid, target) - ?: return Output.error("not_in_group", target) + ?: return Output.error("target_not_member", target) val outbound = ctx.marmot.removeMember(nostrGroupId = gid, targetLeafIndex = leafIndex) val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() } val ack = ctx.publish(outbound.signedEvent, targets) + RawEventSupport.publishGuard(ack, outbound.signedEvent.id)?.let { return it } Output.emit( mapOf( "group_id" to gid, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMetadataCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMetadataCommands.kt index 68a5c035ba..6ac8bf4b49 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMetadataCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMetadataCommands.kt @@ -87,6 +87,7 @@ object GroupMetadataCommands { val gid = args.positional(0, "gid") val path = args.positional(1, "image-file") val server = args.flag("server") + args.rejectUnknown() val file = File(path) if (!file.isFile) return Output.error("bad_args", "no such file: $path") @@ -151,6 +152,7 @@ object GroupMetadataCommands { val commit = ctx.marmot.updateGroupMetadata(gid, updated) val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() } val ack = ctx.publish(commit.signedEvent, targets) + RawEventSupport.publishGuard(ack, commit.signedEvent.id)?.let { return it } Output.emit( mapOf( diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/InitCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/InitCommands.kt index 1a152f3743..bc35794c9f 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/InitCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/InitCommands.kt @@ -27,10 +27,23 @@ import com.vitorpamplona.amethyst.cli.Identity import com.vitorpamplona.amethyst.cli.Output object InitCommands { + val USAGE: String = + """ + |amy init — create or import a bare identity (no defaults published) + | + | init [--nsec NSEC] mint a fresh keypair, or import NSEC; idempotent + | on re-run (prints the existing identity). + """.trimMargin() + suspend fun init( dataDir: DataDir, args: Args, ): Int { + if (args.help) { + System.err.println(USAGE) + return 0 + } + args.rejectUnknown("nsec") // On re-run we return metadata only. Unlocking the stored secret here // would trigger a keychain prompt / passphrase dialog even though the // caller clearly already has the identity set up. diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyCommands.kt index 549608c252..ca55ba9395 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyCommands.kt @@ -43,11 +43,25 @@ import com.vitorpamplona.quartz.nip49PrivKeyEnc.Nip49 * (reused here via [Identity] / [Nip49]). */ object KeyCommands { + val USAGE: String = + """ + |amy key — standalone key utilities (local, no network, no account) + | + | key generate mint a fresh keypair (nsec + npub + hex) + | key public NSEC|HEX derive the public key from a secret key + | key encrypt NSEC|HEX --password X NIP-49 encrypt to ncryptsec1… + | key decrypt NCRYPTSEC --password X NIP-49 decrypt back to a secret key + | key validate PUBKEY check an npub/64-hex pubkey is structurally valid + | (reports {"valid": false} instead of erroring) + | + | --pw is accepted as an alias of --password. + """.trimMargin() + suspend fun dispatch(rest: Array): Int = route( "key", rest, - "key ", + "key ", mapOf( "generate" to { _ -> generate() }, "public" to { tail -> public(tail) }, @@ -55,6 +69,7 @@ object KeyCommands { "decrypt" to { tail -> decrypt(tail) }, "validate" to { tail -> validate(tail) }, ), + help = USAGE, ) /** @@ -64,7 +79,9 @@ object KeyCommands { * branch on the field rather than the exit code. */ private fun validate(rest: Array): Int { - val input = Args(rest).positional(0, "pubkey").trim() + val args = Args(rest) + args.rejectUnknown() + val input = args.positional(0, "pubkey").trim() val hex = when { input.startsWith("npub") -> runCatching { input.bechToBytes().toHexKey() }.getOrNull() @@ -94,6 +111,7 @@ object KeyCommands { val args = Args(rest) val priv = privHexOrNull(args.positional(0, "secret-key").trim()) ?: return Output.error("bad_args", "expected an nsec or 64-char hex secret key") val password = args.flag("password") ?: args.flag("pw") ?: return Output.error("bad_args", "key encrypt requires --password") + args.rejectUnknown() val ncryptsec = Nip49().encrypt(priv, password) Output.emit(mapOf("ncryptsec" to ncryptsec)) return 0 @@ -104,6 +122,7 @@ object KeyCommands { val ncryptsec = args.positional(0, "ncryptsec").trim() if (!ncryptsec.startsWith("ncryptsec")) return Output.error("bad_args", "expected an ncryptsec1… string") val password = args.flag("password") ?: args.flag("pw") ?: return Output.error("bad_args", "key decrypt requires --password") + args.rejectUnknown() val privHex = try { Nip49().decrypt(ncryptsec, password) @@ -130,6 +149,7 @@ object KeyCommands { private fun public(rest: Array): Int { val args = Args(rest) + args.rejectUnknown() val input = args.positional(0, "secret-key").trim() val id = try { diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyPackageCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyPackageCommands.kt index ac35fca36a..f36a4a1f4a 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyPackageCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyPackageCommands.kt @@ -27,6 +27,14 @@ import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher object KeyPackageCommands { + val USAGE: String = + """ + |amy marmot key-package — MLS KeyPackage publication + discovery (MIP-00) + | + | marmot key-package publish publish a fresh KeyPackage + | marmot key-package check NPUB fetch NPUB's KeyPackage from relays + """.trimMargin() + suspend fun dispatch( dataDir: DataDir, tail: Array, @@ -39,6 +47,7 @@ object KeyPackageCommands { "publish" to { _ -> publish(dataDir) }, "check" to { rest -> check(dataDir, rest) }, ), + help = USAGE, ) private suspend fun publish(dataDir: DataDir): Int { @@ -49,11 +58,12 @@ object KeyPackageCommands { val event = ctx.marmot.generateKeyPackageEvent(relays.toList()) val ack = ctx.publish(event, relays) + RawEventSupport.publishGuard(ack, event.id)?.let { return it } Output.emit( mapOf( "event_id" to event.id, "kind" to event.kind, - "accepted_by" to ack.filterValues { it }.keys.map { it.url }, + "published_to" to ack.filterValues { it }.keys.map { it.url }, "rejected_by" to ack.filterValues { !it }.keys.map { it.url }, ), ) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KindCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KindCommand.kt index e2654428d6..14b02fd461 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KindCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KindCommand.kt @@ -36,8 +36,21 @@ import com.vitorpamplona.quartz.kinds.KindNames * (`KindNames`) — the same data the Android relay view localizes on top of. */ object KindCommand { + val USAGE: String = + """ + |amy kind — look up a Nostr event kind (local, accountless) + | + | kind N print kind N's label + defining NIP + | kind NAME search kind labels by name + """.trimMargin() + fun run(rest: Array): Int { + if (rest.firstOrNull() == "--help" || rest.firstOrNull() == "-h") { + System.err.println(USAGE) + return 0 + } val args = Args(rest) + args.rejectUnknown() val arg = args.positional(0, "kind-number-or-name").trim() val n = arg.toIntOrNull() if (n != null) { diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/LoginCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/LoginCommand.kt index 27bc1d274c..4604a553d4 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/LoginCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/LoginCommand.kt @@ -49,10 +49,27 @@ import okhttp3.OkHttpClient * - NIP-05 identifier (name@domain.tld) → read-only (HTTP lookup) */ object LoginCommand { + val USAGE: String = + """ + |amy login — import an identifier and persist the identity + | + | login KEY [--password X] KEY: nsec | ncryptsec (needs --password, alias --pw) | + | BIP-39 mnemonic | 64-hex privkey (with --private) | + | npub/nprofile/64-hex pubkey (read-only) | + | NIP-05 name@domain (read-only) | bunker://… + | login bunker://PUBKEY?relay=…&secret=… sign through a remote NIP-46 bunker + | login --nostrconnect [--relay URL[,URL…]] client-initiated: print a nostrconnect:// + | [--name N] [--perms P] [--timeout SECS] offer, wait for a signer, persist it + """.trimMargin() + suspend fun run( dataDir: DataDir, rest: Array, ): Int { + if (rest.firstOrNull() == "--help" || rest.firstOrNull() == "-h") { + System.err.println(USAGE) + return 0 + } // NIP-46 NostrConnect (client-initiated) login: no key positional — // amy mints a transport key, prints an offer, and waits for a signer. val preArgs = Args(rest) @@ -68,6 +85,9 @@ object LoginCommand { val key = rest[0].trim() val args = Args(rest.drop(1).toTypedArray()) + // --password/--pw/--private are read branch-dependently inside + // resolveIdentity, so whitelist them up front. + args.rejectUnknown("password", "pw", "private") val identity = resolveIdentity(key, args) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/LogoffCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/LogoffCommand.kt index 3312c8f61b..393deadc7a 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/LogoffCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/LogoffCommand.kt @@ -61,10 +61,23 @@ import java.io.File * the command reports what it would delete and exits with code 2. */ object LogoffCommand { + val USAGE: String = + """ + |amy logoff — log off: delete this account's key, per-account state, and its + |events in the shared store + | + | logoff [--yes] [--keep-events] requires --yes; without it, prints a dry run + | (exit 2). --keep-events skips the cache purge. + """.trimMargin() + suspend fun run( dataDir: DataDir, tail: Array, ): Int { + if (tail.firstOrNull() == "--help" || tail.firstOrNull() == "-h") { + System.err.println(USAGE) + return 0 + } val confirmed = tail.any { it == "--yes" || it == "-y" } val keepEvents = tail.any { it == "--keep-events" } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/MessageCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/MessageCommands.kt index fc62fb9efa..be3c329b1f 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/MessageCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/MessageCommands.kt @@ -28,6 +28,17 @@ import com.vitorpamplona.amethyst.cli.Output import com.vitorpamplona.quartz.nip01Core.core.Event object MessageCommands { + val USAGE: String = + """ + |amy marmot message — MLS group messaging + | + | marmot message send GID TEXT publish kind:9 inner event into the group + | marmot message list GID [--limit N] dump decrypted inner events (default --limit 50; + | --limit 0 = unlimited) + | marmot message react GID EVENT_ID EMOJI publish kind:7 reaction targeting an inner event + | marmot message delete GID EVENT_ID… publish kind:5 deletion targeting inner events + """.trimMargin() + suspend fun dispatch( dataDir: DataDir, tail: Array, @@ -42,6 +53,7 @@ object MessageCommands { "react" to { rest -> react(dataDir, rest) }, "delete" to { rest -> delete(dataDir, rest) }, ), + help = USAGE, ) private suspend fun send( @@ -59,6 +71,7 @@ object MessageCommands { val bundle = ctx.marmot.buildTextMessage(gid, text) val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() } val ack = ctx.publish(bundle.outbound.signedEvent, targets) + RawEventSupport.publishGuard(ack, bundle.outbound.signedEvent.id)?.let { return it } Output.emit( mapOf( @@ -77,9 +90,12 @@ object MessageCommands { dataDir: DataDir, rest: Array, ): Int { - if (rest.isEmpty()) return Output.error("bad_args", "message list ") + if (rest.isEmpty()) return Output.error("bad_args", "message list [--limit N]") val args = Args(rest.drop(1).toTypedArray()) - val limit = args.intFlag("limit", Int.MAX_VALUE) + // Default 50 newest messages; `--limit 0` restores the old dump-everything. + val limitFlag = args.intFlag("limit", 50) + args.rejectUnknown() + val limit = if (limitFlag <= 0) Int.MAX_VALUE else limitFlag Context.open(dataDir).use { ctx -> ctx.prepare() val gid = ctx.resolveGroupId(rest[0]) @@ -127,6 +143,7 @@ object MessageCommands { val bundle = ctx.marmot.buildReactionMessage(gid, target, emoji) val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() } val ack = ctx.publish(bundle.outbound.signedEvent, targets) + RawEventSupport.publishGuard(ack, bundle.outbound.signedEvent.id)?.let { return it } Output.emit( mapOf( @@ -163,6 +180,7 @@ object MessageCommands { val bundle = ctx.marmot.buildDeletionMessage(gid, targets) val relays = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() } val ack = ctx.publish(bundle.outbound.signedEvent, relays) + RawEventSupport.publishGuard(ack, bundle.outbound.signedEvent.id)?.let { return it } Output.emit( mapOf(