feat(cli): add amy CLI with Marmot subcommand surface

New :cli JVM module producing an `amy` binary that drives Amethyst
functionality headlessly. Identity and relay configuration sit at the
root (`amy init`, `amy whoami`, `amy relay …`); Marmot/MLS lives under
`amy marmot …` so future verbs (dm, feed, profile) can slot in cleanly.

Marmot surface covered:
- key-package publish / check
- group create / list / show / members / admins / add / rename /
  promote / demote / remove / leave
- message send / list (kind:9 inner events)
- await key-package / group / member / admin / message / rename / epoch
  (non-interactive polling with --timeout; exit 124 on timeout)

Wiring:
- NostrClient + BasicOkHttpWebSocket.Builder, reusing the existing
  publishAndConfirmDetailed and fetchFirst accessories
- MarmotManager from :commons with file-backed MlsGroupStateStore,
  KeyPackageBundleStore, and MarmotMessageStore (unencrypted — scratch
  harness use only)
- each invocation is one-shot: prepare → syncIncoming → run command →
  persist → disconnect

All commands emit one JSON object on stdout; diagnostics on stderr.
Designed so shell harnesses can pipe through jq without bespoke parsing.

https://claude.ai/code/session_01M6dCKAF5Y1VyHGZPjzwDXq
This commit is contained in:
Claude
2026-04-21 20:09:11 +00:00
parent 17a971b384
commit 3fa45a47c6
21 changed files with 2377 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.jetbrainsKotlinJvm)
application
}
kotlin {
jvmToolchain(21)
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
}
}
sourceSets {
main {
kotlin.srcDir("src/main/kotlin")
resources.srcDir("src/main/resources")
}
}
dependencies {
implementation(project(":quartz"))
implementation(project(":commons"))
implementation(libs.kotlinx.coroutines.core)
implementation(libs.okhttp)
implementation(libs.okhttpCoroutines)
implementation(libs.jackson.module.kotlin)
implementation(libs.slf4j.nop)
}
application {
mainClass.set("com.vitorpamplona.amethyst.cli.MainKt")
applicationName = "amy"
}
@@ -0,0 +1,103 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.cli
/**
* Minimal argv parser. Splits flags (--key value or --key=value) from positional args.
* Boolean flags are those whose next token starts with "--" or is absent.
*
* The parser is intentionally tiny — this CLI is driven by shell scripts, not humans,
* so we don't need subcommand groups, short flags, or help text generation.
*/
class Args(
argv: Array<String>,
) {
val flags: Map<String, String>
val booleans: Set<String>
val positional: List<String>
init {
val f = mutableMapOf<String, String>()
val b = mutableSetOf<String>()
val p = mutableListOf<String>()
var i = 0
while (i < argv.size) {
val a = argv[i]
if (a.startsWith("--")) {
val eq = a.indexOf('=')
if (eq >= 0) {
f[a.substring(2, eq)] = a.substring(eq + 1)
i++
} else {
val key = a.substring(2)
val next = argv.getOrNull(i + 1)
if (next == null || next.startsWith("--")) {
b.add(key)
i++
} else {
f[key] = next
i += 2
}
}
} else {
p.add(a)
i++
}
}
flags = f
booleans = b
positional = p
}
fun flag(
name: String,
default: String? = null,
): String? = flags[name] ?: default
fun intFlag(
name: String,
default: Int,
): Int = flags[name]?.toIntOrNull() ?: default
fun longFlag(
name: String,
default: Long,
): Long = flags[name]?.toLongOrNull() ?: default
fun requireFlag(name: String): String =
flags[name] ?: run {
System.err.println("missing required flag: --$name")
throw IllegalArgumentException("missing flag $name")
}
fun bool(name: String): Boolean = name in booleans
fun positional(
index: Int,
name: String,
): String =
positional.getOrNull(index) ?: run {
System.err.println("missing positional arg: $name (index $index)")
throw IllegalArgumentException("missing positional $name")
}
fun positionalOrNull(index: Int): String? = positional.getOrNull(index)
}
@@ -0,0 +1,161 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.cli
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes
import com.vitorpamplona.quartz.nip19Bech32.toNpub
import com.vitorpamplona.quartz.nip19Bech32.toNsec
import java.io.File
/** Persisted identity (hex keys + cached bech32 forms for convenience). */
data class Identity(
val privKeyHex: String,
val pubKeyHex: String,
val nsec: String,
val npub: String,
) {
fun keyPair(): KeyPair = KeyPair(privKey = privKeyHex.hexToByteArray(), pubKey = pubKeyHex.hexToByteArray())
companion object {
fun create(): Identity {
val kp = KeyPair()
val priv = kp.privKey!!
val pub = kp.pubKey
return Identity(
privKeyHex = priv.toHexKey(),
pubKeyHex = pub.toHexKey(),
nsec = priv.toNsec(),
npub = pub.toNpub(),
)
}
fun fromNsec(nsec: String): Identity {
val priv = nsec.bechToBytes()
val kp = KeyPair(privKey = priv)
return Identity(
privKeyHex = priv.toHexKey(),
pubKeyHex = kp.pubKey.toHexKey(),
nsec = priv.toNsec(),
npub = kp.pubKey.toNpub(),
)
}
}
}
/**
* On-disk relay configuration, bucketed by purpose (mirrors `wn relays add --type`).
*
* - `nip65`: advertised read/write (kind:10002)
* - `inbox`: DM inbox / gift-wrap delivery (kind:10050)
* - `keyPackage`: where this account's KeyPackages (kind:30443) live
*/
data class RelayConfig(
val nip65: MutableList<String> = mutableListOf(),
val inbox: MutableList<String> = mutableListOf(),
val keyPackage: MutableList<String> = mutableListOf(),
) {
fun all(): Set<String> = (nip65 + inbox + keyPackage).toSet()
fun add(
type: String,
url: String,
): Boolean {
val list =
when (type) {
"nip65" -> nip65
"inbox" -> inbox
"key_package", "keyPackage" -> keyPackage
else -> throw IllegalArgumentException("unknown relay type: $type")
}
if (list.contains(url)) return false
list.add(url)
return true
}
fun normalized(kind: String): Set<NormalizedRelayUrl> {
val src =
when (kind) {
"nip65" -> nip65
"inbox" -> inbox
"key_package" -> keyPackage
"all" -> all().toList()
else -> throw IllegalArgumentException("unknown relay selector: $kind")
}
return src.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet()
}
}
/** Opaque per-run state (subscription cursors, etc). Stored alongside identity. */
data class RunState(
var giftWrapSince: Long? = null,
val groupSince: MutableMap<String, Long> = mutableMapOf(),
)
/**
* Root of the on-disk layout. Any absolute path chosen by `--data-dir` (or
* `$AMETHYST_CLI_DATA`) — defaults to `./amethyst-cli-data`.
*/
class DataDir(
val root: File,
) {
val identityFile = File(root, "identity.json")
val relaysFile = File(root, "relays.json")
val stateFile = File(root, "state.json")
val groupsDir = File(root, "groups")
val keyPackageBundleFile = File(root, "keypackages.bundle")
init {
root.mkdirs()
groupsDir.mkdirs()
}
fun loadIdentityOrNull(): Identity? = if (identityFile.exists()) Json.mapper.readValue<Identity>(identityFile.readText()) else null
fun saveIdentity(id: Identity) {
identityFile.writeText(Json.mapper.writeValueAsString(id))
}
fun loadRelays(): RelayConfig = if (relaysFile.exists()) Json.mapper.readValue(relaysFile.readText()) else RelayConfig()
fun saveRelays(r: RelayConfig) {
relaysFile.writeText(Json.mapper.writeValueAsString(r))
}
fun loadRunState(): RunState = if (stateFile.exists()) Json.mapper.readValue(stateFile.readText()) else RunState()
fun saveRunState(s: RunState) {
stateFile.writeText(Json.mapper.writeValueAsString(s))
}
companion object {
fun resolve(flag: String?): DataDir {
val envPath = System.getenv("AMETHYST_CLI_DATA")
val path = flag ?: envPath ?: "./amethyst-cli-data"
return DataDir(File(path).absoluteFile)
}
}
}
@@ -0,0 +1,319 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.cli
import com.vitorpamplona.amethyst.cli.stores.FileKeyPackageBundleStore
import com.vitorpamplona.amethyst.cli.stores.FileMarmotMessageStore
import com.vitorpamplona.amethyst.cli.stores.FileMlsGroupStateStore
import com.vitorpamplona.amethyst.commons.marmot.MarmotManager
import com.vitorpamplona.quartz.marmot.MarmotFilters
import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirmDetailed
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
import kotlinx.coroutines.selects.select
import kotlinx.coroutines.withTimeoutOrNull
import okhttp3.OkHttpClient
/**
* Per-invocation wiring. Each CLI run constructs a Context, does its work,
* and then closes it — no daemon.
*
* Responsibilities:
* - load identity + relay + run-state from the data-dir,
* - wire up a [NostrClient] pointing at those relays,
* - wire up the [MarmotManager] pipeline with file-backed stores,
* - expose helpers that every command needs (sync, publish-and-confirm,
* process-incoming, etc).
*
* Closing flushes run-state to disk and disconnects the client.
*/
class Context(
val dataDir: DataDir,
val identity: Identity,
val relays: RelayConfig,
val state: RunState,
) : AutoCloseable {
val signer = NostrSignerInternal(identity.keyPair())
private val okhttp = OkHttpClient.Builder().build()
val client: NostrClient =
NostrClient(
websocketBuilder = BasicOkHttpWebSocket.Builder { okhttp },
)
private val mlsStore = FileMlsGroupStateStore(dataDir.groupsDir)
private val keyPackageStore = FileKeyPackageBundleStore(dataDir.keyPackageBundleFile)
private val messageStore = FileMarmotMessageStore(dataDir.groupsDir)
/** Fully-wired manager. Call [prepare] once before use to load persisted state. */
val marmot: MarmotManager = MarmotManager(signer, mlsStore, messageStore, keyPackageStore)
private var prepared = false
/**
* Hydrate MarmotManager from disk (groups + KeyPackage bundles) and
* connect to relays. Safe to call multiple times — subsequent calls are
* no-ops.
*/
suspend fun prepare() {
if (prepared) return
marmot.restoreAll()
client.connect()
prepared = true
}
fun outboxRelays(): Set<NormalizedRelayUrl> = relays.normalized("nip65")
fun inboxRelays(): Set<NormalizedRelayUrl> = relays.normalized("inbox")
fun keyPackageRelays(): Set<NormalizedRelayUrl> = relays.normalized("key_package")
fun anyRelays(): Set<NormalizedRelayUrl> = relays.normalized("all")
/**
* Publish an event to the given relays and wait for OK confirmations.
*
* Returns the set of relays that ACK'd `true`. Does not throw on rejection —
* callers inspect the map and decide.
*/
suspend fun publish(
event: Event,
relayList: Set<NormalizedRelayUrl>,
timeoutSecs: Long = 15,
): Map<NormalizedRelayUrl, Boolean> {
if (relayList.isEmpty()) return emptyMap()
return client.publishAndConfirmDetailed(event, relayList, timeoutSecs)
}
/**
* Subscribe to the given filters across the given relays, drain all events
* until either every relay has sent EOSE or the timeout elapses, and
* return them. Used for one-shot catch-up queries — not live subscriptions.
*/
suspend fun drain(
filters: Map<NormalizedRelayUrl, List<Filter>>,
timeoutMs: Long = 8_000,
): List<Pair<NormalizedRelayUrl, Event>> {
if (filters.isEmpty()) return emptyList()
val eventChannel = Channel<Pair<NormalizedRelayUrl, Event>>(UNLIMITED)
val doneChannel = Channel<NormalizedRelayUrl>(UNLIMITED)
val remaining = filters.keys.toMutableSet()
val subId = newSubId()
val listener =
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
eventChannel.trySend(relay to event)
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
doneChannel.trySend(relay)
}
override fun onClosed(
message: String,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
doneChannel.trySend(relay)
}
override fun onCannotConnect(
relay: NormalizedRelayUrl,
message: String,
forFilters: List<Filter>?,
) {
doneChannel.trySend(relay)
}
}
val collected = mutableListOf<Pair<NormalizedRelayUrl, Event>>()
try {
client.subscribe(subId, filters, listener)
withTimeoutOrNull(timeoutMs) {
while (remaining.isNotEmpty()) {
select {
eventChannel.onReceive { collected.add(it) }
doneChannel.onReceive { r -> remaining.remove(r) }
}
}
// Drain any events that landed after EOSE but before cancel
while (true) {
val r = eventChannel.tryReceive()
if (!r.isSuccess) break
collected.add(r.getOrThrow())
}
}
} finally {
client.unsubscribe(subId)
eventChannel.close()
doneChannel.close()
}
return collected
}
/**
* Pull down everything needed to bring local Marmot state current:
* - kind:1059 gift wraps on inbox relays → try to unwrap Welcomes
* - kind:445 group events per active group → feed into inbound processor
*
* Incrementally advances the `since` cursors in [state] so the next run
* only asks relays for newer events.
*/
suspend fun syncIncoming(timeoutMs: Long = 8_000) {
val inbox = inboxRelays().ifEmpty { anyRelays() }
val gwSince = state.giftWrapSince
val gwFilter =
if (gwSince != null) {
MarmotFilters.giftWrapsForUserSince(identity.pubKeyHex, gwSince)
} else {
MarmotFilters.giftWrapsForUser(identity.pubKeyHex)
}
val activeGroupIds = marmot.subscriptionManager.activeGroupIdsSnapshot().toList()
val perGroupFilters: Map<HexKey, Filter> =
activeGroupIds.associateWith { gid ->
val since = state.groupSince[gid]
if (since != null) {
MarmotFilters.groupEventsByGroupIdSince(gid, since)
} else {
MarmotFilters.groupEventsByGroupId(gid)
}
}
// Group filters go to each group's configured relays, not the user's
// inbox — kind:445 is delivered to the group's relay set advertised in
// its MIP-01 metadata (falls back to our outbox if the group never
// stamped any).
val filterMap = mutableMapOf<NormalizedRelayUrl, MutableList<Filter>>()
for (r in inbox) filterMap.getOrPut(r) { mutableListOf() }.add(gwFilter)
for ((gid, filter) in perGroupFilters) {
val groupRelays = marmotGroupRelays(gid).ifEmpty { outboxRelays() }
for (r in groupRelays) filterMap.getOrPut(r) { mutableListOf() }.add(filter)
}
if (filterMap.isEmpty()) return
val events = drain(filterMap, timeoutMs)
val now = System.currentTimeMillis() / 1000
var maxGwSeen = gwSince ?: 0L
val maxGroupSeen = perGroupFilters.keys.associateWith { state.groupSince[it] ?: 0L }.toMutableMap()
for ((relay, event) in events) {
when (event.kind) {
GiftWrapEvent.KIND -> {
// kind:1059 — try to unwrap. If it's a Welcome, let the
// inbound processor apply it; anything else is ignored.
val gw = event as? GiftWrapEvent ?: continue
try {
val inner = gw.unwrapOrNull(signer) ?: continue
if (inner.kind == WelcomeEvent.KIND && inner is WelcomeEvent) {
val hint = inner.nostrGroupId()
val res = marmot.processWelcome(inner, hint)
System.err.println("[cli] Welcome via $relay$res")
}
} catch (e: Exception) {
System.err.println("[cli] failed to unwrap giftwrap ${event.id.take(8)}: ${e.message}")
}
if (event.createdAt > maxGwSeen) maxGwSeen = event.createdAt
}
GroupEvent.KIND -> {
val ge = event as? GroupEvent ?: continue
val gid = ge.groupId() ?: continue
try {
val res = marmot.processGroupEvent(ge)
// Mirror DecryptAndIndexProcessor on Amethyst: application
// messages get persisted at decrypt-time, because MLS ratchet
// advancement makes the ciphertext un-re-decryptable later.
if (res is com.vitorpamplona.quartz.marmot.GroupEventResult.ApplicationMessage) {
marmot.persistDecryptedMessage(res.groupId, res.innerEventJson)
}
System.err.println("[cli] GroupEvent ${event.id.take(8)}${res::class.simpleName}")
} catch (e: Exception) {
System.err.println("[cli] processGroupEvent failed: ${e.message}")
}
val prev = maxGroupSeen[gid] ?: 0L
if (event.createdAt > prev) maxGroupSeen[gid] = event.createdAt
}
}
}
state.giftWrapSince = if (maxGwSeen > 0) maxGwSeen else now
for ((gid, seen) in maxGroupSeen) {
state.groupSince[gid] = if (seen > 0) seen else now
}
}
fun marmotGroupRelays(nostrGroupId: HexKey): Set<NormalizedRelayUrl> {
val m = marmot.groupMetadata(nostrGroupId) ?: return emptySet()
return m.relays
.mapNotNull {
com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
.normalizeOrNull(it)
}.toSet()
}
override fun close() {
dataDir.saveRunState(state)
try {
client.close()
} catch (_: Exception) {
}
}
companion object {
/** Build a Context but require an identity to already exist — most commands can't run without one. */
fun open(dataDir: DataDir): Context {
val identity =
dataDir.loadIdentityOrNull()
?: run {
System.err.println("No identity found at ${dataDir.identityFile}. Run `amethyst-cli init` first.")
throw IllegalStateException("no identity")
}
return Context(
dataDir = dataDir,
identity = identity,
relays = dataDir.loadRelays(),
state = dataDir.loadRunState(),
)
}
}
}
@@ -0,0 +1,42 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.cli
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
object Json {
val mapper: ObjectMapper = jacksonObjectMapper()
fun writeLine(obj: Any) {
println(mapper.writeValueAsString(obj))
}
fun error(
code: String,
detail: String? = null,
): Int {
val payload = mutableMapOf<String, Any>("error" to code)
if (detail != null) payload["detail"] = detail
System.err.println(mapper.writeValueAsString(payload))
return 1
}
}
@@ -0,0 +1,210 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.cli
import com.vitorpamplona.amethyst.cli.commands.Commands
import kotlinx.coroutines.runBlocking
import kotlin.system.exitProcess
/**
* amy — non-interactive command-line interface to Amethyst.
*
* Today this covers the Marmot/MLS surface (`amy marmot …`) plus identity
* and relay configuration at the root level. The layout is intentionally
* extensible — future verbs (`amy dm`, `amy feed`, `amy profile`) slot in
* as new top-level subcommands.
*
* Usage: amy --data-dir PATH SUBCOMMAND ARGS
*
* Exit codes:
* 0 — success
* 1 — runtime error (printed as JSON on stderr: {"error": "...", "detail": "..."})
* 2 — invalid arguments
* 124 — await timeout
*
* Every command that succeeds prints exactly one JSON object to stdout.
* Diagnostic logs go to stderr and are safe to discard.
*/
fun main(argv: Array<String>) {
val code =
try {
runBlocking { dispatch(argv) }
} catch (e: IllegalArgumentException) {
Json.error("bad_args", e.message)
2
} catch (e: AwaitTimeout) {
Json.error("timeout", e.message)
124
} catch (e: Exception) {
Json.error("runtime", "${e::class.simpleName}: ${e.message}")
1
}
exitProcess(code)
}
class AwaitTimeout(
message: String,
) : RuntimeException(message)
private suspend fun dispatch(argv: Array<String>): Int {
if (argv.isEmpty() || argv[0] == "--help" || argv[0] == "-h") {
printUsage()
return 0
}
// Pull --data-dir out of argv before subcommand parsing so subcommands see
// only their own args.
val filteredArgs = mutableListOf<String>()
var dataDirFlag: String? = null
var i = 0
while (i < argv.size) {
when (val a = argv[i]) {
"--data-dir" -> {
dataDirFlag = argv.getOrNull(i + 1)
i += 2
}
else -> {
if (a.startsWith("--data-dir=")) {
dataDirFlag = a.removePrefix("--data-dir=")
i++
} else {
filteredArgs.add(a)
i++
}
}
}
}
if (filteredArgs.isEmpty()) {
printUsage()
return 2
}
val dataDir = DataDir.resolve(dataDirFlag)
val head = filteredArgs[0]
val tail = filteredArgs.drop(1).toTypedArray()
return when (head) {
"init" -> {
Commands.init(dataDir, Args(tail))
}
"whoami" -> {
Commands.whoami(dataDir)
}
"relay" -> {
Commands.relay(dataDir, tail)
}
"marmot" -> {
marmotDispatch(dataDir, tail)
}
else -> {
System.err.println("unknown subcommand: $head")
printUsage()
2
}
}
}
private suspend fun marmotDispatch(
dataDir: DataDir,
tail: Array<String>,
): Int {
if (tail.isEmpty()) {
printUsage()
return 2
}
val head = tail[0]
val rest = tail.drop(1).toTypedArray()
return when (head) {
"key-package" -> {
Commands.keyPackage(dataDir, rest)
}
"group" -> {
Commands.group(dataDir, rest)
}
"message" -> {
Commands.message(dataDir, rest)
}
"await" -> {
Commands.await(dataDir, rest)
}
else -> {
System.err.println("unknown marmot subcommand: $head")
printUsage()
2
}
}
}
private fun printUsage() {
System.err.println(
"""
|amy — Amethyst command-line interface
|
|Usage:
| amy [--data-dir PATH] <cmd> [args...]
|
|Identity:
| init [--nsec NSEC] create or import identity
| whoami print current identity
|
|Relays:
| relay add URL [--type T] T=nip65|inbox|key_package|all (default all)
| relay list print configured relays
| relay publish-lists publish kind:10002 + kind:10050
|
|Marmot (MLS group messaging):
| marmot key-package publish publish a fresh KeyPackage
| marmot key-package check NPUB fetch NPUB's KeyPackage from relays
|
| 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 remove GID NPUB remove member
| marmot group leave GID self-remove
|
| marmot message send GID TEXT publish kind:9 inner event into the group
| marmot message list GID [--limit N] dump decrypted inner events
|
| marmot await key-package NPUB (all await verbs take --timeout SECS, default 30;
| marmot await group --name NAME exit 124 on timeout)
| marmot await member GID NPUB
| marmot await admin GID NPUB
| marmot await message GID --match TEXT
| marmot await rename GID --name NAME
| marmot await epoch GID --min N
""".trimMargin(),
)
}
@@ -0,0 +1,295 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.cli.commands
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.AwaitTimeout
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Json
import com.vitorpamplona.amethyst.cli.util.Npubs
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst
import kotlinx.coroutines.delay
/**
* `await` subcommands. Each polls until a condition is met or the timeout elapses;
* on timeout we throw [AwaitTimeout] which maps to exit code 124.
*/
object AwaitCommands {
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): Int {
if (tail.isEmpty()) return Json.error("bad_args", "await <key-package|group|member|admin|message|rename|epoch>")
val rest = tail.drop(1).toTypedArray()
return when (tail[0]) {
"key-package" -> awaitKeyPackage(dataDir, rest)
"group" -> awaitGroup(dataDir, rest)
"member" -> awaitMember(dataDir, rest)
"admin" -> awaitAdmin(dataDir, rest)
"message" -> awaitMessage(dataDir, rest)
"rename" -> awaitRename(dataDir, rest)
"epoch" -> awaitEpoch(dataDir, rest)
else -> Json.error("bad_args", "await ${tail[0]}")
}
}
private suspend fun awaitKeyPackage(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.isEmpty()) return Json.error("bad_args", "await key-package <npub>")
val target = Npubs.resolveToHex(rest[0])
val args = Args(rest.drop(1).toTypedArray())
val timeoutSecs = args.longFlag("timeout", 30)
val ctx = Context.open(dataDir)
try {
ctx.prepare()
val filter = ctx.marmot.subscriptionManager.keyPackageFilter(target)
val deadline = System.currentTimeMillis() + timeoutSecs * 1000
while (System.currentTimeMillis() < deadline) {
val relays = ctx.anyRelays()
if (relays.isNotEmpty()) {
val event =
ctx.client.fetchFirst(
filters = relays.associateWith { listOf(filter) },
timeoutMs = 3_000,
)
if (event is KeyPackageEvent) {
Json.writeLine(mapOf("event_id" to event.id, "author" to event.pubKey))
return 0
}
}
delay(2_000)
}
throw AwaitTimeout("no KeyPackage for $target within ${timeoutSecs}s")
} finally {
ctx.close()
}
}
private suspend fun awaitGroup(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val wantedName = args.flag("name")
val timeoutSecs = args.longFlag("timeout", 30)
val ctx = Context.open(dataDir)
try {
ctx.prepare()
val deadline = System.currentTimeMillis() + timeoutSecs * 1000
while (System.currentTimeMillis() < deadline) {
ctx.syncIncoming(timeoutMs = 3_000)
val match =
ctx.marmot.activeGroupIds().firstOrNull { gid ->
wantedName == null || ctx.marmot.groupMetadata(gid)?.name == wantedName
}
if (match != null) {
Json.writeLine(
mapOf(
"group_id" to match,
"name" to (ctx.marmot.groupMetadata(match)?.name ?: ""),
"epoch" to ctx.marmot.groupEpoch(match),
),
)
return 0
}
delay(1_500)
}
throw AwaitTimeout("no group with name=$wantedName within ${timeoutSecs}s")
} finally {
ctx.close()
}
}
private suspend fun awaitMember(
dataDir: DataDir,
rest: Array<String>,
): Int =
pollCondition(dataDir, rest, "await member <gid> <npub>", targetIdx = 1) { ctx, rawArgs ->
val gid = rawArgs[0]
val target = Npubs.resolveToHex(rawArgs[1])
if (!ctx.marmot.isMember(gid)) {
null
} else if (ctx.marmot.memberPubkeys(gid).any { it.pubkey == target }) {
mapOf("group_id" to gid, "pubkey" to target, "epoch" to ctx.marmot.groupEpoch(gid))
} else {
null
}
}
private suspend fun awaitAdmin(
dataDir: DataDir,
rest: Array<String>,
): Int =
pollCondition(dataDir, rest, "await admin <gid> <npub>", targetIdx = 1) { ctx, rawArgs ->
val gid = rawArgs[0]
val target = Npubs.resolveToHex(rawArgs[1])
if (!ctx.marmot.isMember(gid)) {
null
} else if (ctx.marmot
.groupMetadata(gid)
?.adminPubkeys
?.contains(target) == true
) {
mapOf("group_id" to gid, "pubkey" to target, "epoch" to ctx.marmot.groupEpoch(gid))
} else {
null
}
}
private suspend fun awaitRename(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.isEmpty()) return Json.error("bad_args", "await rename <gid> --name <name>")
val gid = rest[0]
val args = Args(rest.drop(1).toTypedArray())
val wantedName = args.requireFlag("name")
val timeoutSecs = args.longFlag("timeout", 30)
val ctx = Context.open(dataDir)
try {
ctx.prepare()
val deadline = System.currentTimeMillis() + timeoutSecs * 1000
while (System.currentTimeMillis() < deadline) {
ctx.syncIncoming(timeoutMs = 3_000)
val name = ctx.marmot.groupMetadata(gid)?.name
if (name == wantedName) {
Json.writeLine(mapOf("group_id" to gid, "name" to name, "epoch" to ctx.marmot.groupEpoch(gid)))
return 0
}
delay(1_500)
}
throw AwaitTimeout("group $gid never renamed to $wantedName within ${timeoutSecs}s")
} finally {
ctx.close()
}
}
private suspend fun awaitEpoch(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.isEmpty()) return Json.error("bad_args", "await epoch <gid> --min N")
val gid = rest[0]
val args = Args(rest.drop(1).toTypedArray())
val min = args.longFlag("min", 1)
val timeoutSecs = args.longFlag("timeout", 30)
val ctx = Context.open(dataDir)
try {
ctx.prepare()
val deadline = System.currentTimeMillis() + timeoutSecs * 1000
while (System.currentTimeMillis() < deadline) {
ctx.syncIncoming(timeoutMs = 3_000)
val epoch = ctx.marmot.groupEpoch(gid)
if (epoch != null && epoch >= min) {
Json.writeLine(mapOf("group_id" to gid, "epoch" to epoch))
return 0
}
delay(1_500)
}
throw AwaitTimeout("group $gid epoch never reached $min within ${timeoutSecs}s")
} finally {
ctx.close()
}
}
private suspend fun awaitMessage(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.isEmpty()) return Json.error("bad_args", "await message <gid> --match STRING")
val gid = rest[0]
val args = Args(rest.drop(1).toTypedArray())
val needle = args.requireFlag("match")
val timeoutSecs = args.longFlag("timeout", 30)
val ctx = Context.open(dataDir)
try {
ctx.prepare()
val deadline = System.currentTimeMillis() + timeoutSecs * 1000
while (System.currentTimeMillis() < deadline) {
ctx.syncIncoming(timeoutMs = 3_000)
val msgs = ctx.marmot.loadStoredMessages(gid)
for (line in msgs.asReversed()) {
val obj =
try {
Json.mapper.readValue<Map<String, Any?>>(line)
} catch (_: Exception) {
null
} ?: continue
val content = obj["content"]?.toString() ?: continue
if (content.contains(needle)) {
Json.writeLine(
mapOf(
"group_id" to gid,
"id" to obj["id"],
"author" to obj["pubkey"],
"content" to content,
"kind" to obj["kind"],
),
)
return 0
}
}
delay(1_500)
}
throw AwaitTimeout("no message matching '$needle' in $gid within ${timeoutSecs}s")
} finally {
ctx.close()
}
}
/**
* Generic poll loop used by [awaitMember] / [awaitAdmin] — both take the
* same `<gid> <npub>` positional shape and differ only in the predicate.
*/
private suspend fun pollCondition(
dataDir: DataDir,
rest: Array<String>,
usage: String,
targetIdx: Int,
check: suspend (Context, Array<String>) -> Map<String, Any?>?,
): Int {
if (rest.size <= targetIdx) return Json.error("bad_args", usage)
val args = Args(rest.drop(targetIdx + 1).toTypedArray())
val timeoutSecs = args.longFlag("timeout", 30)
val ctx = Context.open(dataDir)
try {
ctx.prepare()
val deadline = System.currentTimeMillis() + timeoutSecs * 1000
while (System.currentTimeMillis() < deadline) {
ctx.syncIncoming(timeoutMs = 3_000)
val hit = check(ctx, rest)
if (hit != null) {
Json.writeLine(hit)
return 0
}
delay(1_500)
}
throw AwaitTimeout("condition never satisfied within ${timeoutSecs}s ($usage)")
} finally {
ctx.close()
}
}
}
@@ -0,0 +1,63 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.cli.commands
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.DataDir
/**
* Tiny dispatcher over the per-verb command groups. Each top-level subcommand
* (`init`, `relay`, `group`, `message`, …) gets its own file so no single
* file is too big to edit safely.
*/
object Commands {
suspend fun init(
dataDir: DataDir,
args: Args,
): Int = InitCommands.init(dataDir, args)
suspend fun whoami(dataDir: DataDir): Int = InitCommands.whoami(dataDir)
suspend fun relay(
dataDir: DataDir,
tail: Array<String>,
): Int = RelayCommands.dispatch(dataDir, tail)
suspend fun keyPackage(
dataDir: DataDir,
tail: Array<String>,
): Int = KeyPackageCommands.dispatch(dataDir, tail)
suspend fun group(
dataDir: DataDir,
tail: Array<String>,
): Int = GroupCommands.dispatch(dataDir, tail)
suspend fun message(
dataDir: DataDir,
tail: Array<String>,
): Int = MessageCommands.dispatch(dataDir, tail)
suspend fun await(
dataDir: DataDir,
tail: Array<String>,
): Int = AwaitCommands.dispatch(dataDir, tail)
}
@@ -0,0 +1,113 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.cli.commands
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Json
import com.vitorpamplona.amethyst.cli.util.Npubs
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
/**
* `group add <group_id> <npub> [<npub> ...]` — fetch each invitee's
* KeyPackage from the union of (our relays + any known KeyPackage relays
* for them) and run the full add-member flow for each one: build commit,
* publish commit to the group's relays, then wrap + publish the Welcome
* gift wrap.
*/
object GroupAddMemberCommand {
@OptIn(ExperimentalEncodingApi::class)
suspend fun run(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.size < 2) return Json.error("bad_args", "group add <group_id> <npub> [<npub> ...]")
val gid = rest[0]
val invitees = rest.drop(1).map { Npubs.resolveToHex(it) }
val ctx = Context.open(dataDir)
try {
ctx.prepare()
ctx.syncIncoming()
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
val groupRelays = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() }
val report = mutableListOf<Map<String, Any?>>()
for (pub in invitees) {
val filter = ctx.marmot.subscriptionManager.keyPackageFilter(pub)
val relays = ctx.anyRelays()
val filters = relays.associateWith { listOf(filter) }
val kpEvent = ctx.client.fetchFirst(filters = filters, timeoutMs = 10_000)
if (kpEvent == null || kpEvent !is KeyPackageEvent) {
report.add(mapOf("pubkey" to pub, "status" to "no_key_package"))
continue
}
val kpBytes = Base64.decode(kpEvent.keyPackageBase64())
val (commitEvent, welcomeDelivery) =
ctx.marmot.addMember(
nostrGroupId = gid,
memberPubKey = pub,
keyPackageBytes = kpBytes,
keyPackageEventId = kpEvent.id,
relays = groupRelays.toList(),
)
// Order matters: commit first (so invitee doesn't join at a future epoch),
// then welcome.
val commitAck = ctx.publish(commitEvent.signedEvent, groupRelays)
val welcomeAck =
if (welcomeDelivery != null) {
val inbox = ctx.inboxRelays().ifEmpty { ctx.outboxRelays() }
ctx.publish(welcomeDelivery.giftWrapEvent, inbox)
} else {
emptyMap()
}
report.add(
mapOf(
"pubkey" to pub,
"status" to "invited",
"key_package_event_id" to kpEvent.id,
"commit_event_id" to commitEvent.signedEvent.id,
"welcome_event_id" to welcomeDelivery?.giftWrapEvent?.id,
"commit_accepted_by" to commitAck.filterValues { it }.keys.map { it.url },
"welcome_accepted_by" to welcomeAck.filterValues { it }.keys.map { it.url },
),
)
}
Json.writeLine(
mapOf(
"group_id" to gid,
"epoch" to ctx.marmot.groupEpoch(gid),
"results" to report,
),
)
return 0
} finally {
ctx.close()
}
}
}
@@ -0,0 +1,48 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.cli.commands
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Json
object GroupCommands {
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): Int {
if (tail.isEmpty()) return Json.error("bad_args", "group <create|list|show|…>")
val rest = tail.drop(1).toTypedArray()
return when (tail[0]) {
"create" -> GroupCreateCommand.run(dataDir, rest)
"list" -> GroupReadCommands.list(dataDir)
"show" -> GroupReadCommands.show(dataDir, rest)
"members" -> GroupReadCommands.members(dataDir, rest)
"admins" -> GroupReadCommands.admins(dataDir, rest)
"add" -> GroupAddMemberCommand.run(dataDir, rest)
"rename" -> GroupMetadataCommands.rename(dataDir, rest)
"promote" -> GroupMetadataCommands.promote(dataDir, rest)
"demote" -> GroupMetadataCommands.demote(dataDir, rest)
"remove" -> GroupMembershipCommands.remove(dataDir, rest)
"leave" -> GroupMembershipCommands.leave(dataDir, rest)
else -> Json.error("bad_args", "group ${tail[0]}")
}
}
}
@@ -0,0 +1,76 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.cli.commands
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Json
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.utils.RandomInstance
object GroupCreateCommand {
suspend fun run(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val name = args.flag("name", "")!!
val ctx = Context.open(dataDir)
try {
ctx.prepare()
val gid = RandomInstance.bytes(32).toHexKey()
ctx.marmot.createGroup(gid)
// Stamp initial metadata: name + our outbox relays + self as admin.
// Mirrors CreateGroupScreen.proceedWithCreate() on the Amethyst side.
val outboxUrls = ctx.outboxRelays().map { it.url }
val metadata =
MarmotGroupData(
nostrGroupId = gid,
name = name,
description = "",
adminPubkeys = listOf(ctx.identity.pubKeyHex),
relays = outboxUrls,
)
val commit = ctx.marmot.updateGroupMetadata(gid, metadata)
// Group relays == what the metadata carries, which on first commit is our outbox.
val targets = ctx.outboxRelays()
val ack = ctx.publish(commit.signedEvent, targets)
Json.writeLine(
mapOf(
"group_id" to gid,
"name" to name,
"epoch" to ctx.marmot.groupEpoch(gid),
"commit_event_id" to commit.signedEvent.id,
"published_to" to ack.filterValues { it }.keys.map { it.url },
),
)
return 0
} finally {
ctx.close()
}
}
}
@@ -0,0 +1,92 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.cli.commands
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Json
import com.vitorpamplona.amethyst.cli.util.Npubs
object GroupMembershipCommands {
suspend fun remove(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.size < 2) return Json.error("bad_args", "group remove <gid> <npub>")
val gid = rest[0]
val target = Npubs.resolveToHex(rest[1])
val ctx = Context.open(dataDir)
try {
ctx.prepare()
ctx.syncIncoming()
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
val member =
ctx.marmot.memberPubkeys(gid).firstOrNull { it.pubkey == target }
?: return Json.error("not_in_group", target)
val outbound =
ctx.marmot.removeMember(nostrGroupId = gid, targetLeafIndex = member.leafIndex)
val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() }
val ack = ctx.publish(outbound.signedEvent, targets)
Json.writeLine(
mapOf(
"group_id" to gid,
"removed" to target,
"leaf_index" to member.leafIndex,
"epoch" to ctx.marmot.groupEpoch(gid),
"commit_event_id" to outbound.signedEvent.id,
"published_to" to ack.filterValues { it }.keys.map { it.url },
),
)
return 0
} finally {
ctx.close()
}
}
suspend fun leave(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.isEmpty()) return Json.error("bad_args", "group leave <gid>")
val gid = rest[0]
val ctx = Context.open(dataDir)
try {
ctx.prepare()
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() }
val outbound = ctx.marmot.leaveGroup(gid)
val ack = ctx.publish(outbound.signedEvent, targets)
Json.writeLine(
mapOf(
"group_id" to gid,
"proposal_event_id" to outbound.signedEvent.id,
"published_to" to ack.filterValues { it }.keys.map { it.url },
),
)
return 0
} finally {
ctx.close()
}
}
}
@@ -0,0 +1,108 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.cli.commands
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Json
import com.vitorpamplona.amethyst.cli.util.Npubs
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
import com.vitorpamplona.quartz.nip01Core.core.HexKey
/**
* Metadata-only commits: rename, promote/demote. Each loads current metadata,
* edits the right field, publishes a GCE commit to the group relays.
*/
object GroupMetadataCommands {
suspend fun rename(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.size < 2) return Json.error("bad_args", "group rename <gid> <name>")
return edit(dataDir, rest[0]) { it.copy(name = rest[1]) }
}
suspend fun promote(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.size < 2) return Json.error("bad_args", "group promote <gid> <npub>")
val newAdmin = Npubs.resolveToHex(rest[1])
return edit(dataDir, rest[0]) { cur ->
val admins = cur.adminPubkeys.toMutableList()
if (newAdmin !in admins) admins.add(newAdmin)
cur.copy(adminPubkeys = admins)
}
}
suspend fun demote(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.size < 2) return Json.error("bad_args", "group demote <gid> <npub>")
val target = Npubs.resolveToHex(rest[1])
return edit(dataDir, rest[0]) { cur ->
val admins = cur.adminPubkeys.filter { it != target }
cur.copy(adminPubkeys = admins)
}
}
private suspend fun edit(
dataDir: DataDir,
gid: HexKey,
mutate: (MarmotGroupData) -> MarmotGroupData,
): Int {
val ctx = Context.open(dataDir)
try {
ctx.prepare()
ctx.syncIncoming()
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
val cur =
ctx.marmot.groupMetadata(gid)
?: MarmotGroupData(
nostrGroupId = gid,
name = "",
description = "",
adminPubkeys = listOf(ctx.identity.pubKeyHex),
relays = ctx.outboxRelays().map { it.url },
)
val updated = mutate(cur)
val commit = ctx.marmot.updateGroupMetadata(gid, updated)
val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() }
val ack = ctx.publish(commit.signedEvent, targets)
Json.writeLine(
mapOf(
"group_id" to gid,
"name" to updated.name,
"admins" to updated.adminPubkeys,
"epoch" to ctx.marmot.groupEpoch(gid),
"commit_event_id" to commit.signedEvent.id,
"published_to" to ack.filterValues { it }.keys.map { it.url },
),
)
return 0
} finally {
ctx.close()
}
}
}
@@ -0,0 +1,128 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.cli.commands
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Json
/**
* Read-only queries. None of these publish; they all sync-then-report.
*/
object GroupReadCommands {
suspend fun list(dataDir: DataDir): Int {
val ctx = Context.open(dataDir)
try {
ctx.prepare()
ctx.syncIncoming()
val ids = ctx.marmot.activeGroupIds()
val items =
ids.map { id ->
val m = ctx.marmot.groupMetadata(id)
mapOf(
"group_id" to id,
"name" to (m?.name ?: ""),
"members" to ctx.marmot.memberCount(id),
"epoch" to ctx.marmot.groupEpoch(id),
)
}
Json.writeLine(mapOf("groups" to items))
return 0
} finally {
ctx.close()
}
}
suspend fun show(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.isEmpty()) return Json.error("bad_args", "group show <group_id>")
val gid = rest[0]
val ctx = Context.open(dataDir)
try {
ctx.prepare()
ctx.syncIncoming()
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
val meta = ctx.marmot.groupMetadata(gid)
val members =
ctx.marmot.memberPubkeys(gid).map {
mapOf("pubkey" to it.pubkey, "leaf_index" to it.leafIndex)
}
Json.writeLine(
mapOf(
"group_id" to gid,
"name" to (meta?.name ?: ""),
"description" to (meta?.description ?: ""),
"epoch" to ctx.marmot.groupEpoch(gid),
"admins" to (meta?.adminPubkeys ?: emptyList()),
"relays" to (meta?.relays ?: emptyList()),
"members" to members,
"is_admin" to (meta?.adminPubkeys?.contains(ctx.identity.pubKeyHex) == true),
),
)
return 0
} finally {
ctx.close()
}
}
suspend fun members(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.isEmpty()) return Json.error("bad_args", "group members <group_id>")
val gid = rest[0]
val ctx = Context.open(dataDir)
try {
ctx.prepare()
ctx.syncIncoming()
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
val members =
ctx.marmot.memberPubkeys(gid).map {
mapOf("pubkey" to it.pubkey, "leaf_index" to it.leafIndex)
}
Json.writeLine(mapOf("group_id" to gid, "members" to members))
return 0
} finally {
ctx.close()
}
}
suspend fun admins(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.isEmpty()) return Json.error("bad_args", "group admins <group_id>")
val gid = rest[0]
val ctx = Context.open(dataDir)
try {
ctx.prepare()
ctx.syncIncoming()
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
val m = ctx.marmot.groupMetadata(gid)
Json.writeLine(mapOf("group_id" to gid, "admins" to (m?.adminPubkeys ?: emptyList())))
return 0
} finally {
ctx.close()
}
}
}
@@ -0,0 +1,67 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.cli.commands
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Identity
import com.vitorpamplona.amethyst.cli.Json
object InitCommands {
suspend fun init(
dataDir: DataDir,
args: Args,
): Int {
val existing = dataDir.loadIdentityOrNull()
val id =
existing ?: run {
val nsec = args.flag("nsec")
val created = if (nsec != null) Identity.fromNsec(nsec) else Identity.create()
dataDir.saveIdentity(created)
created
}
Json.writeLine(
mapOf(
"npub" to id.npub,
"hex" to id.pubKeyHex,
"nsec" to id.nsec,
"existing" to (existing != null),
"data_dir" to dataDir.root.absolutePath,
),
)
return 0
}
suspend fun whoami(dataDir: DataDir): Int {
val id = dataDir.loadIdentityOrNull()
if (id == null) {
return Json.error("no_identity", "No identity at ${dataDir.identityFile}. Run `init` first.")
}
Json.writeLine(
mapOf(
"npub" to id.npub,
"hex" to id.pubKeyHex,
"data_dir" to dataDir.root.absolutePath,
),
)
return 0
}
}
@@ -0,0 +1,97 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.cli.commands
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Json
import com.vitorpamplona.amethyst.cli.util.Npubs
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst
object KeyPackageCommands {
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): Int {
if (tail.isEmpty()) return Json.error("bad_args", "key-package <publish|check> …")
return when (tail[0]) {
"publish" -> publish(dataDir)
"check" -> check(dataDir, tail.drop(1).toTypedArray())
else -> Json.error("bad_args", "key-package ${tail[0]}")
}
}
private suspend fun publish(dataDir: DataDir): Int {
val ctx = Context.open(dataDir)
try {
ctx.prepare()
val relays = ctx.keyPackageRelays().ifEmpty { ctx.outboxRelays() }.ifEmpty { ctx.anyRelays() }
if (relays.isEmpty()) return Json.error("no_relays", "configure relays first")
val event = ctx.marmot.generateKeyPackageEvent(relays.toList())
val ack = ctx.publish(event, relays)
Json.writeLine(
mapOf(
"event_id" to event.id,
"kind" to event.kind,
"accepted_by" to ack.filterValues { it }.keys.map { it.url },
"rejected_by" to ack.filterValues { !it }.keys.map { it.url },
),
)
return 0
} finally {
ctx.close()
}
}
private suspend fun check(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.isEmpty()) return Json.error("bad_args", "key-package check <npub>")
val targetHex = Npubs.resolveToHex(rest[0])
val ctx = Context.open(dataDir)
try {
ctx.prepare()
val filter = ctx.marmot.subscriptionManager.keyPackageFilter(targetHex)
val relays = ctx.anyRelays()
if (relays.isEmpty()) return Json.error("no_relays", "configure relays first")
val filtersByRelay = relays.associateWith { listOf(filter) }
val event = ctx.client.fetchFirst(filters = filtersByRelay, timeoutMs = 10_000)
if (event == null || event !is KeyPackageEvent) {
return Json.error("not_found", "no KeyPackage for $targetHex on ${relays.size} relay(s)")
}
Json.writeLine(
mapOf(
"event_id" to event.id,
"author" to event.pubKey,
"kind" to event.kind,
"created_at" to event.createdAt,
"has_content" to event.content.isNotBlank(),
),
)
return 0
} finally {
ctx.close()
}
}
}
@@ -0,0 +1,123 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.cli.commands
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Json
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
object MessageCommands {
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): Int {
if (tail.isEmpty()) return Json.error("bad_args", "message <send|list> …")
val rest = tail.drop(1).toTypedArray()
return when (tail[0]) {
"send" -> send(dataDir, rest)
"list" -> list(dataDir, rest)
else -> Json.error("bad_args", "message ${tail[0]}")
}
}
private suspend fun send(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.size < 2) return Json.error("bad_args", "message send <gid> <text>")
val gid = rest[0]
val text = rest[1]
val ctx = Context.open(dataDir)
try {
ctx.prepare()
ctx.syncIncoming()
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
val template =
eventTemplate<com.vitorpamplona.quartz.nip01Core.core.Event>(kind = 9, description = text)
val innerEvent = ctx.signer.sign<com.vitorpamplona.quartz.nip01Core.core.Event>(template)
val outbound = ctx.marmot.buildGroupMessage(gid, innerEvent)
// Persist our own outbound inner event so `message list` includes it
// alongside remote messages.
ctx.marmot.persistDecryptedMessage(gid, innerEvent.toJson())
val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() }
val ack = ctx.publish(outbound.signedEvent, targets)
Json.writeLine(
mapOf(
"group_id" to gid,
"inner_event_id" to innerEvent.id,
"outer_event_id" to outbound.signedEvent.id,
"kind" to innerEvent.kind,
"published_to" to ack.filterValues { it }.keys.map { it.url },
),
)
return 0
} finally {
ctx.close()
}
}
private suspend fun list(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.isEmpty()) return Json.error("bad_args", "message list <gid>")
val gid = rest[0]
val args = Args(rest.drop(1).toTypedArray())
val limit = args.intFlag("limit", Int.MAX_VALUE)
val ctx = Context.open(dataDir)
try {
ctx.prepare()
ctx.syncIncoming()
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
val raw = ctx.marmot.loadStoredMessages(gid)
val items =
raw
.map { line ->
try {
@Suppress("UNCHECKED_CAST")
val obj = Json.mapper.readValue<Map<String, Any?>>(line)
mapOf(
"id" to obj["id"],
"author" to obj["pubkey"],
"kind" to obj["kind"],
"content" to obj["content"],
"created_at" to obj["created_at"],
)
} catch (_: Exception) {
mapOf("raw" to line)
}
}.takeLast(limit)
Json.writeLine(mapOf("group_id" to gid, "messages" to items))
return 0
} finally {
ctx.close()
}
}
}
@@ -0,0 +1,114 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.cli.commands
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Json
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType
object RelayCommands {
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): Int {
if (tail.isEmpty()) return Json.error("bad_args", "relay <add|list|publish-lists> …")
val sub = tail[0]
val rest = tail.drop(1).toTypedArray()
return when (sub) {
"add" -> add(dataDir, Args(rest))
"list" -> list(dataDir)
"publish-lists" -> publishLists(dataDir)
else -> Json.error("bad_args", "relay $sub")
}
}
private fun add(
dataDir: DataDir,
args: Args,
): Int {
val url = args.positional(0, "url")
val type = args.flag("type", "all") ?: "all"
val cfg = dataDir.loadRelays()
val addedTo = mutableListOf<String>()
val targets = if (type == "all") listOf("nip65", "inbox", "key_package") else listOf(type)
for (t in targets) {
if (cfg.add(t, url)) addedTo.add(t)
}
dataDir.saveRelays(cfg)
Json.writeLine(
mapOf(
"url" to url,
"added_to" to addedTo,
"already_present" to (targets - addedTo.toSet()),
),
)
return 0
}
private fun list(dataDir: DataDir): Int {
val cfg = dataDir.loadRelays()
Json.writeLine(
mapOf(
"nip65" to cfg.nip65,
"inbox" to cfg.inbox,
"key_package" to cfg.keyPackage,
),
)
return 0
}
private suspend fun publishLists(dataDir: DataDir): Int {
val ctx = Context.open(dataDir)
try {
ctx.prepare()
val nip65Relays = ctx.relays.normalized("nip65").toList()
val inboxRelays = ctx.relays.normalized("inbox").toList()
val nip65Infos = nip65Relays.map { AdvertisedRelayInfo(it, AdvertisedRelayType.BOTH) }
val nip65Event = AdvertisedRelayListEvent.create(nip65Infos, ctx.signer)
val inboxEvent = ChatMessageRelayListEvent.create(inboxRelays, ctx.signer)
val targets = ctx.anyRelays()
val nip65Result = ctx.publish(nip65Event, targets)
val inboxResult = ctx.publish(inboxEvent, targets)
Json.writeLine(
mapOf(
"nip65_event_id" to nip65Event.id,
"inbox_event_id" to inboxEvent.id,
"accepted_by" to
mapOf(
"nip65" to nip65Result.filterValues { it }.keys.map { it.url },
"inbox" to inboxResult.filterValues { it }.keys.map { it.url },
),
),
)
return 0
} finally {
ctx.close()
}
}
}
@@ -0,0 +1,142 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.cli.stores
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore
import com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore
import java.io.File
/**
* Test-harness file stores. **Unencrypted** the production interfaces
* document that implementations MUST encrypt at rest, but this CLI is a
* throwaway interop driver running against scratch keys and local scratch
* state. Do not point it at real account material.
*/
class FileMlsGroupStateStore(
private val dir: File,
) : MlsGroupStateStore {
init {
dir.mkdirs()
}
private fun stateFile(id: String) = File(dir, "$id.state")
private fun retainedFile(id: String) = File(dir, "$id.retained")
override suspend fun save(
nostrGroupId: String,
state: ByteArray,
) {
stateFile(nostrGroupId).writeBytes(state)
}
override suspend fun load(nostrGroupId: String): ByteArray? = stateFile(nostrGroupId).takeIf { it.exists() }?.readBytes()
override suspend fun delete(nostrGroupId: String) {
stateFile(nostrGroupId).delete()
retainedFile(nostrGroupId).delete()
}
override suspend fun listGroups(): List<String> =
dir
.listFiles { f -> f.name.endsWith(".state") }
?.map { it.name.removeSuffix(".state") }
?: emptyList()
override suspend fun saveRetainedEpochs(
nostrGroupId: String,
retainedSecrets: List<ByteArray>,
) {
// Layout: [u32 count][(u32 len, bytes) …] — tiny framing so readers
// can recover independent byte arrays without TLS plumbing.
val f = retainedFile(nostrGroupId)
f.outputStream().use { out ->
val buf = java.nio.ByteBuffer.allocate(4)
buf.putInt(retainedSecrets.size)
out.write(buf.array())
for (secret in retainedSecrets) {
buf.clear()
buf.putInt(secret.size)
out.write(buf.array())
out.write(secret)
}
}
}
override suspend fun loadRetainedEpochs(nostrGroupId: String): List<ByteArray> {
val f = retainedFile(nostrGroupId)
if (!f.exists()) return emptyList()
val bytes = f.readBytes()
if (bytes.size < 4) return emptyList()
val buf = java.nio.ByteBuffer.wrap(bytes)
val count = buf.int
val result = ArrayList<ByteArray>(count)
repeat(count) {
val len = buf.int
val arr = ByteArray(len)
buf.get(arr)
result.add(arr)
}
return result
}
}
class FileKeyPackageBundleStore(
private val file: File,
) : KeyPackageBundleStore {
override suspend fun save(snapshot: ByteArray) {
file.parentFile?.mkdirs()
file.writeBytes(snapshot)
}
override suspend fun load(): ByteArray? = file.takeIf { it.exists() }?.readBytes()
override suspend fun delete() {
file.delete()
}
}
class FileMarmotMessageStore(
private val dir: File,
) : MarmotMessageStore {
init {
dir.mkdirs()
}
private fun file(id: String) = File(dir, "$id.messages")
override suspend fun appendMessage(
nostrGroupId: String,
innerEventJson: String,
) {
// Each line is one inner event JSON. The store doc tolerates duplicates
// so we don't bother deduping here — readers can do it.
file(nostrGroupId).appendText(innerEventJson.replace("\n", " ") + "\n")
}
override suspend fun loadMessages(nostrGroupId: String): List<String> = file(nostrGroupId).takeIf { it.exists() }?.readLines()?.filter { it.isNotBlank() } ?: emptyList()
override suspend fun delete(nostrGroupId: String) {
file(nostrGroupId).delete()
}
}
@@ -0,0 +1,39 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.cli.util
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes
/** Accept either an `npub1…` or a raw 64-hex pubkey; always return hex. */
object Npubs {
fun resolveToHex(input: String): String {
val trimmed = input.trim()
// Already hex?
if (trimmed.length == 64 && trimmed.all { it.isDigit() || it.lowercaseChar() in 'a'..'f' }) {
return trimmed.lowercase()
}
if (trimmed.startsWith("npub1") || trimmed.startsWith("npub")) {
return trimmed.bechToBytes().toHexKey()
}
throw IllegalArgumentException("expected npub or 64-hex pubkey, got: $input")
}
}