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