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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
This commit is contained in:
Claude
2026-07-18 20:56:23 +00:00
parent 471883a9f2
commit 11901df2c6
22 changed files with 431 additions and 17 deletions
@@ -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<String>): Int {
if (rest.firstOrNull() == "--help" || rest.firstOrNull() == "-h") {
System.err.println(USAGE)
return 0
}
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())
// 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"))))
@@ -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<String>,
): 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,
@@ -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
@@ -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 <nevent1…|naddr1…|nprofile1…|npub1…|note1…|name@domain>
| outbox-model resolution of a shared code.
""".trimMargin()
suspend fun run(
dataDir: DataDir,
rest: Array<String>,
): 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
@@ -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<String>): 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
}
@@ -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<String>,
@@ -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
@@ -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<String>,
@@ -60,6 +69,10 @@ object FollowCommand {
rest: Array<String>,
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 <user> [--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,
@@ -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<String>,
@@ -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 <geohash> [--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<NormalizedRelayUrl> {
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<String, Any?> =
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,
@@ -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<String>,
@@ -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<String>,
): 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 =
@@ -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<String>,
@@ -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,
@@ -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()
@@ -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<String>,
@@ -46,5 +66,6 @@ object GroupCommands {
"remove" to { rest -> GroupMembershipCommands.remove(dataDir, rest) },
"leave" to { rest -> GroupMembershipCommands.leave(dataDir, rest) },
),
help = USAGE,
)
}
@@ -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()
@@ -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,
@@ -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(
@@ -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.
@@ -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<String>): Int =
route(
"key",
rest,
"key <generate|public|encrypt|decrypt>",
"key <generate|public|encrypt|decrypt|validate>",
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<String>): 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<String>): Int {
val args = Args(rest)
args.rejectUnknown()
val input = args.positional(0, "secret-key").trim()
val id =
try {
@@ -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<String>,
@@ -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 },
),
)
@@ -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<String>): 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) {
@@ -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<String>,
): 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)
@@ -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<String>,
): 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" }
@@ -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<String>,
@@ -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<String>,
): Int {
if (rest.isEmpty()) return Output.error("bad_args", "message list <gid>")
if (rest.isEmpty()) return Output.error("bad_args", "message list <gid> [--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(