refactor(cli): simplify amy dispatch, Context lifecycle, and store handling

Four mechanical simplifications to amy with no change to the public
CLI/JSON contract:

1. Drop the Commands.kt pass-through layer. Main.kt now calls each
   command object directly; the file is repurposed into Router.kt,
   holding a single shared `route(name, tail, usage, routes)` helper.

2. Replace the `Context.open(dataDir)` + `try { } finally { ctx.close() }`
   boilerplate (~46 sites) with `Context.open(dataDir).use { ctx -> }`
   now that Context is AutoCloseable.

3. Remove the reflection-based `storeIsInitialized()` in Context; track
   the lazy event store via `Lazy.isInitialized()` instead.

4. Route every `*Commands.dispatch` through the `route` helper, dropping
   the repeated empty-check + unknown-verb `when` boilerplate.

Net -282 lines. Docs (cli/DEVELOPMENT.md, amy-expert skill + template)
updated to the new wiring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QjEvS812aPLZ6nM2XLobzF
This commit is contained in:
Claude
2026-06-21 17:24:00 +00:00
parent fa11a94613
commit 48b988d2f7
32 changed files with 362 additions and 644 deletions
+7 -4
View File
@@ -129,10 +129,12 @@ via `Output.emit`. The template is in `references/command-template.md`;
copy it rather than re-deriving it.
Wire-up checklist:
1. New file in `cli/commands/` with the `object` pattern.
2. Add a branch in `Commands.kt`.
3. Add a branch in `Main.kt`'s `dispatch` (or under `marmotDispatch`
/ a new group dispatcher).
1. New file in `cli/commands/` with the `object` pattern. Sub-verb
`dispatch` functions use the shared `route(...)` helper in
`Router.kt` rather than a hand-rolled `when (tail[0])`.
2. Add a branch in `Main.kt`'s `dispatch` (top-level verbs call the
command object directly, e.g. `"relay" -> RelayCommands.dispatch(…)`;
`marmot` sub-verbs go through `marmotDispatch`'s `route` map).
4. Extend `printUsage()` in `Main.kt`.
5. Add the row to `cli/README.md`'s command table.
6. Update `cli/ROADMAP.md` — move the row from 🆕 / 📦 to ✅.
@@ -173,6 +175,7 @@ cli/
├── secrets/ # SecretStore backends (keychain / ncryptsec / plaintext)
└── commands/ # one file (or group) per top-level verb
├── UseCommand.kt # `amy use NAME`
├── Router.kt # `route(...)` shared sub-verb dispatcher
├── InitCommands.kt # init, whoami
├── CreateCommand.kt + LoginCommand.kt
├── RelayCommands.kt
@@ -18,8 +18,7 @@ object NotePublishCommand {
val args = Args(rest)
val text = args.positional(0, "text")
val ctx = Context.open(dataDir)
try {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val event = com.vitorpamplona.amethyst.commons.note
@@ -33,13 +32,15 @@ object NotePublishCommand {
"rejected_by" to ack.filterValues { !it }.keys.map { it.url },
))
return 0
} finally {
ctx.close()
}
}
}
```
`Context` is `AutoCloseable`; wrap it in `use { }` so it's closed
(RunState flushed, relays disconnected) on every exit path — never a
hand-rolled `try { } finally { ctx.close() }`.
`Output.emit(...)` handles the text-vs-JSON mode automatically. The
result map IS the `--json` shape; the human-readable text default is
derived from the same map by `Output.kt`'s renderer.
@@ -51,39 +52,34 @@ When a feature has several verbs (`note publish`, `note show`,
```kotlin
object NoteCommands {
suspend fun dispatch(dataDir: DataDir, tail: Array<String>): Int {
if (tail.isEmpty()) return Output.error("bad_args", "note <publish|show|react>")
val rest = tail.drop(1).toTypedArray()
return when (tail[0]) {
"publish" -> NotePublishCommand.run(dataDir, rest)
"show" -> NoteShowCommand.run(dataDir, rest)
"react" -> NoteReactCommand.run(dataDir, rest)
else -> Output.error("bad_args", "note ${tail[0]}")
}
}
suspend fun dispatch(dataDir: DataDir, tail: Array<String>): Int =
route("note", tail, "note <publish|show|react>", mapOf(
"publish" to { rest -> NotePublishCommand.run(dataDir, rest) },
"show" to { rest -> NoteShowCommand.run(dataDir, rest) },
"react" to { rest -> NoteReactCommand.run(dataDir, rest) },
))
}
```
Each verb gets its own file. Once a single file crosses ~200 lines,
split it — see `GroupCommands.kt` and its siblings as the reference.
The shared `route(name, tail, usage, routes)` helper (`Router.kt`)
handles the empty-input and unknown-verb `bad_args` branches, so the
`dispatch` body is just the verb→handler map. Each verb gets its own
file. Once a single file crosses ~200 lines, split it — see
`GroupCommands.kt` and its siblings as the reference.
## Wire-up checklist
For every new command:
1. File under `cli/commands/`.
2. Branch in `Commands.kt`:
2. Branch in `Main.kt`'s top-level `dispatch`, calling the command
object directly:
```kotlin
suspend fun note(dataDir: DataDir, tail: Array<String>): Int =
NoteCommands.dispatch(dataDir, tail)
"note" -> NoteCommands.dispatch(dataDir, tail)
```
3. Branch in `Main.kt`'s top-level `dispatch`:
```kotlin
"note" -> Commands.note(dataDir, tail)
```
4. Line in `printUsage()` explaining the verb.
5. Row in `cli/README.md`'s command table.
6. Status flip in `cli/ROADMAP.md` (🆕 / 📦 → ✅).
3. Line in `printUsage()` explaining the verb.
4. Row in `cli/README.md`'s command table.
5. Status flip in `cli/ROADMAP.md` (🆕 / 📦 → ✅).
## What not to do
@@ -95,7 +91,7 @@ For every new command:
them to `error: …` (text mode) / `{"error":…}` (JSON mode) plus the
right exit code.
- No holding a connection open across invocations — every run opens
a fresh `Context` and closes it in `finally`.
a fresh `Context` inside `use { }` so it closes on every exit path.
- No blocking reads for user input — take a flag.
- No global flags that collide with subcommand flags. `--name` is
reserved for subcommand use (group/profile name); the global
+17 -19
View File
@@ -85,7 +85,7 @@ cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/
├── stores/FileStores.kt # File-backed MLS / KP / message stores
├── secrets/ # SecretStore backends (keychain / ncryptsec / plaintext)
└── commands/
├── Commands.kt # dispatcher tables
├── Router.kt # `route(...)` shared sub-verb dispatcher
├── UseCommand.kt # `amy use NAME` — pin active account
├── InitCommands.kt # init, whoami
├── CreateCommand.kt # full bootstrap (→ commons/account/)
@@ -111,18 +111,19 @@ host. Never add a Gradle dependency on `:amethyst` or `:desktopApp`.
**`Context.kt` is the backbone.** Most commands follow this template:
```kotlin
val ctx = Context.open(dataDir)
try {
Context.open(dataDir).use { ctx -> // .use closes the Context on exit
ctx.prepare() // restore MLS state + connect relays
ctx.syncIncoming() // pull new gift-wraps + group events
// ...call into commons/ or quartz/ to build an event...
val ack = ctx.publish(event, targets)
Output.emit(mapOf(...))
} finally {
ctx.close() // flush RunState, disconnect
}
return 0
} // close() flushes RunState + disconnects
```
`Context` is `AutoCloseable`, so wrap it in `use { }` rather than a
hand-rolled `try { } finally { ctx.close() }`.
---
## How to add a command
@@ -179,20 +180,15 @@ import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
object NoteCommands {
suspend fun dispatch(dataDir: DataDir, tail: Array<String>): Int {
if (tail.isEmpty()) return Output.error("bad_args", "note <publish|read|…>")
val rest = tail.drop(1).toTypedArray()
return when (tail[0]) {
"publish" -> publish(dataDir, rest)
else -> Output.error("bad_args", "note ${tail[0]}")
}
}
suspend fun dispatch(dataDir: DataDir, tail: Array<String>): Int =
route("note", tail, "note <publish|read|…>", mapOf(
"publish" to { rest -> publish(dataDir, rest) },
))
private suspend fun publish(dataDir: DataDir, rest: Array<String>): Int {
val args = Args(rest)
val text = args.positional(0, "text")
val ctx = Context.open(dataDir)
try {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val event = com.vitorpamplona.amethyst.commons.note.buildTextNote(ctx.signer, text)
val ack = ctx.publish(event, ctx.outboxRelays())
@@ -202,13 +198,15 @@ object NoteCommands {
"published_to" to ack.filterValues { it }.keys.map { it.url },
))
return 0
} finally { ctx.close() }
}
}
}
```
Wire it into `Commands.kt`, add a top-level branch in `Main.kt`'s
`dispatch`, and extend `printUsage()`. Keep the command tour in
The shared `route(name, tail, usage, routes)` helper (in `Router.kt`)
handles the empty-input and unknown-verb `bad_args` cases, so each
`dispatch` is just the verb→handler map. Add a top-level branch in
`Main.kt`'s `dispatch` and extend `printUsage()`. Keep the command tour in
[README.md](./README.md) and the parity matrix in
[ROADMAP.md](./ROADMAP.md) in sync.
@@ -34,6 +34,7 @@ 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.crypto.verify
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirmDetailed
@@ -128,12 +129,14 @@ class Context(
* humans inspect these files. Verification always re-canonicalises,
* so the stored bytes never feed back into a signature check.
*/
val store: IEventStore by lazy {
FsEventStore(
root = dataDir.eventsDir.toPath(),
eventToJson = com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper::toJsonPretty,
)
}
private val storeDelegate: Lazy<IEventStore> =
lazy {
FsEventStore(
root = dataDir.eventsDir.toPath(),
eventToJson = JacksonMapper::toJsonPretty,
)
}
val store: IEventStore by storeDelegate
/** Fully-wired manager. Call [prepare] once before use to load persisted state. */
val marmot: MarmotManager = MarmotManager(signer, mlsStore, messageStore, keyPackageStore)
@@ -611,25 +614,14 @@ class Context(
}
// Only close the store if it was actually opened — by-lazy
// otherwise allocates the lock channel just to release it.
if (storeIsInitialized()) {
if (storeDelegate.isInitialized()) {
try {
store.close()
storeDelegate.value.close()
} catch (_: Exception) {
}
}
}
private fun storeIsInitialized(): Boolean {
// Reflect on the lazy delegate to avoid forcing initialisation in close().
return try {
val field = javaClass.getDeclaredField("store\$delegate").apply { isAccessible = true }
val delegate = field.get(this) as Lazy<*>
delegate.isInitialized()
} catch (_: Throwable) {
false
}
}
companion object {
/**
* Lookback applied to the gift-wrap `since` filter to compensate for
@@ -20,7 +20,28 @@
*/
package com.vitorpamplona.amethyst.cli
import com.vitorpamplona.amethyst.cli.commands.Commands
import com.vitorpamplona.amethyst.cli.commands.AwaitCommands
import com.vitorpamplona.amethyst.cli.commands.CreateCommand
import com.vitorpamplona.amethyst.cli.commands.DebitCommands
import com.vitorpamplona.amethyst.cli.commands.DmCommands
import com.vitorpamplona.amethyst.cli.commands.FollowCommand
import com.vitorpamplona.amethyst.cli.commands.GroupCommands
import com.vitorpamplona.amethyst.cli.commands.InitCommands
import com.vitorpamplona.amethyst.cli.commands.KeyPackageCommands
import com.vitorpamplona.amethyst.cli.commands.LoginCommand
import com.vitorpamplona.amethyst.cli.commands.MarmotResetCommand
import com.vitorpamplona.amethyst.cli.commands.MessageCommands
import com.vitorpamplona.amethyst.cli.commands.NappletCommands
import com.vitorpamplona.amethyst.cli.commands.NotesCommands
import com.vitorpamplona.amethyst.cli.commands.NsiteCommands
import com.vitorpamplona.amethyst.cli.commands.OfferCommands
import com.vitorpamplona.amethyst.cli.commands.ProfileCommands
import com.vitorpamplona.amethyst.cli.commands.RelayCommands
import com.vitorpamplona.amethyst.cli.commands.SearchCommand
import com.vitorpamplona.amethyst.cli.commands.StoreCommands
import com.vitorpamplona.amethyst.cli.commands.UseCommand
import com.vitorpamplona.amethyst.cli.commands.ZapCommand
import com.vitorpamplona.amethyst.cli.commands.route
import com.vitorpamplona.amethyst.cli.secrets.SecretStore
import kotlinx.coroutines.runBlocking
import kotlin.system.exitProcess
@@ -118,86 +139,31 @@ private suspend fun dispatch(argv: Array<String>): Int {
// resolve "multiple accounts, ambiguous" cases) — so it skips
// DataDir.resolve. Other commands fall through to the normal path.
if (head == "use") {
return com.vitorpamplona.amethyst.cli.commands.UseCommand
.run(tail)
return UseCommand.run(tail)
}
val secrets = SecretStore.from(backendFlag = secretBackendFlag, passphraseFile = passphraseFileFlag)
val dataDir = DataDir.resolve(accountFlag = accountFlag, secrets = secrets)
return when (head) {
"init" -> {
Commands.init(dataDir, Args(tail))
}
"create" -> {
Commands.create(dataDir, tail)
}
"login" -> {
Commands.login(dataDir, tail)
}
"whoami" -> {
Commands.whoami(dataDir)
}
"relay" -> {
Commands.relay(dataDir, tail)
}
"marmot" -> {
marmotDispatch(dataDir, tail)
}
"dm" -> {
Commands.dm(dataDir, tail)
}
"profile" -> {
Commands.profile(dataDir, tail)
}
"notes" -> {
Commands.notes(dataDir, tail)
}
"nsite" -> {
Commands.nsite(dataDir, tail)
}
"napplet" -> {
Commands.napplet(dataDir, tail)
}
"store" -> {
Commands.store(dataDir, tail)
}
"follow" -> {
Commands.follow(dataDir, tail)
}
"unfollow" -> {
Commands.unfollow(dataDir, tail)
}
"search" -> {
Commands.search(dataDir, tail)
}
"zap" -> {
Commands.zap(dataDir, tail)
}
"offer" -> {
Commands.offer(dataDir, tail)
}
"debit" -> {
Commands.debit(dataDir, tail)
}
"init" -> InitCommands.init(dataDir, Args(tail))
"create" -> CreateCommand.run(dataDir, tail)
"login" -> LoginCommand.run(dataDir, tail)
"whoami" -> InitCommands.whoami(dataDir)
"relay" -> RelayCommands.dispatch(dataDir, tail)
"marmot" -> marmotDispatch(dataDir, tail)
"dm" -> DmCommands.dispatch(dataDir, tail)
"profile" -> ProfileCommands.dispatch(dataDir, tail)
"notes" -> NotesCommands.dispatch(dataDir, tail)
"nsite" -> NsiteCommands.dispatch(dataDir, tail)
"napplet" -> NappletCommands.dispatch(dataDir, tail)
"store" -> StoreCommands.dispatch(dataDir, tail)
"follow" -> FollowCommand.follow(dataDir, tail)
"unfollow" -> FollowCommand.unfollow(dataDir, tail)
"search" -> SearchCommand.dispatch(dataDir, tail)
"zap" -> ZapCommand.dispatch(dataDir, tail)
"offer" -> OfferCommands.dispatch(dataDir, tail)
"debit" -> DebitCommands.dispatch(dataDir, tail)
else -> {
System.err.println("unknown subcommand: $head")
printUsage()
@@ -209,41 +175,20 @@ private suspend fun dispatch(argv: Array<String>): Int {
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)
}
"reset" -> {
Commands.reset(dataDir, rest)
}
else -> {
System.err.println("unknown marmot subcommand: $head")
printUsage()
2
}
}
}
): Int =
route(
name = "marmot",
tail = tail,
usage = "marmot <key-package|group|message|await|reset>",
routes =
mapOf(
"key-package" to { rest -> KeyPackageCommands.dispatch(dataDir, rest) },
"group" to { rest -> GroupCommands.dispatch(dataDir, rest) },
"message" to { rest -> MessageCommands.dispatch(dataDir, rest) },
"await" to { rest -> AwaitCommands.dispatch(dataDir, rest) },
"reset" to { rest -> MarmotResetCommand.run(dataDir, rest) },
),
)
private enum class GlobalFlag(
val long: String,
@@ -42,20 +42,21 @@ object AwaitCommands {
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): Int {
if (tail.isEmpty()) return Output.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 -> Output.error("bad_args", "await ${tail[0]}")
}
}
): Int =
route(
"await",
tail,
"await <key-package|group|member|admin|message|rename|epoch>",
mapOf(
"key-package" to { rest -> awaitKeyPackage(dataDir, rest) },
"group" to { rest -> awaitGroup(dataDir, rest) },
"member" to { rest -> awaitMember(dataDir, rest) },
"admin" to { rest -> awaitAdmin(dataDir, rest) },
"message" to { rest -> awaitMessage(dataDir, rest) },
"rename" to { rest -> awaitRename(dataDir, rest) },
"epoch" to { rest -> awaitEpoch(dataDir, rest) },
),
)
private suspend fun awaitKeyPackage(
dataDir: DataDir,
@@ -64,8 +65,7 @@ object AwaitCommands {
if (rest.isEmpty()) return Output.error("bad_args", "await key-package <npub>")
val args = Args(rest.drop(1).toTypedArray())
val timeoutSecs = args.longFlag("timeout", 30)
val ctx = Context.open(dataDir)
try {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val target = ctx.requireUserHex(rest[0])
val filter = ctx.marmot.subscriptionManager.keyPackageFilter(target)
@@ -112,8 +112,6 @@ object AwaitCommands {
delay(2_000)
}
throw AwaitTimeout("no KeyPackage for $target within ${timeoutSecs}s")
} finally {
ctx.close()
}
}
@@ -124,8 +122,7 @@ object AwaitCommands {
val args = Args(rest)
val wantedName = args.flag("name")
val timeoutSecs = args.longFlag("timeout", 30)
val ctx = Context.open(dataDir)
try {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val deadline = System.currentTimeMillis() + timeoutSecs * 1000
while (System.currentTimeMillis() < deadline) {
@@ -148,8 +145,6 @@ object AwaitCommands {
delay(1_500)
}
throw AwaitTimeout("no group with name=$wantedName within ${timeoutSecs}s")
} finally {
ctx.close()
}
}
@@ -197,8 +192,7 @@ object AwaitCommands {
val args = Args(rest.drop(1).toTypedArray())
val wantedName = args.requireFlag("name")
val timeoutSecs = args.longFlag("timeout", 30)
val ctx = Context.open(dataDir)
try {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val gid = ctx.resolveGroupId(rest[0])
val deadline = System.currentTimeMillis() + timeoutSecs * 1000
@@ -212,8 +206,6 @@ object AwaitCommands {
delay(1_500)
}
throw AwaitTimeout("group $gid never renamed to $wantedName within ${timeoutSecs}s")
} finally {
ctx.close()
}
}
@@ -225,8 +217,7 @@ object AwaitCommands {
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 {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val gid = ctx.resolveGroupId(rest[0])
val deadline = System.currentTimeMillis() + timeoutSecs * 1000
@@ -240,8 +231,6 @@ object AwaitCommands {
delay(1_500)
}
throw AwaitTimeout("group $gid epoch never reached $min within ${timeoutSecs}s")
} finally {
ctx.close()
}
}
@@ -253,8 +242,7 @@ object AwaitCommands {
val args = Args(rest.drop(1).toTypedArray())
val needle = args.requireFlag("match")
val timeoutSecs = args.longFlag("timeout", 30)
val ctx = Context.open(dataDir)
try {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val gid = ctx.resolveGroupId(rest[0])
val deadline = System.currentTimeMillis() + timeoutSecs * 1000
@@ -285,8 +273,6 @@ object AwaitCommands {
delay(1_500)
}
throw AwaitTimeout("no message matching '$needle' in $gid within ${timeoutSecs}s")
} finally {
ctx.close()
}
}
@@ -304,8 +290,7 @@ object AwaitCommands {
if (rest.size <= targetIdx) return Output.error("bad_args", usage)
val args = Args(rest.drop(targetIdx + 1).toTypedArray())
val timeoutSecs = args.longFlag("timeout", 30)
val ctx = Context.open(dataDir)
try {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val deadline = System.currentTimeMillis() + timeoutSecs * 1000
while (System.currentTimeMillis() < deadline) {
@@ -318,8 +303,6 @@ object AwaitCommands {
delay(1_500)
}
throw AwaitTimeout("condition never satisfied within ${timeoutSecs}s ($usage)")
} finally {
ctx.close()
}
}
}
@@ -1,138 +0,0 @@
/*
* 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 create(
dataDir: DataDir,
tail: Array<String>,
): Int = CreateCommand.run(dataDir, tail)
suspend fun login(
dataDir: DataDir,
tail: Array<String>,
): Int = LoginCommand.run(dataDir, tail)
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)
suspend fun reset(
dataDir: DataDir,
tail: Array<String>,
): Int = MarmotResetCommand.run(dataDir, tail)
suspend fun dm(
dataDir: DataDir,
tail: Array<String>,
): Int = DmCommands.dispatch(dataDir, tail)
suspend fun profile(
dataDir: DataDir,
tail: Array<String>,
): Int = ProfileCommands.dispatch(dataDir, tail)
suspend fun notes(
dataDir: DataDir,
tail: Array<String>,
): Int = NotesCommands.dispatch(dataDir, tail)
suspend fun nsite(
dataDir: DataDir,
tail: Array<String>,
): Int = NsiteCommands.dispatch(dataDir, tail)
suspend fun napplet(
dataDir: DataDir,
tail: Array<String>,
): Int = NappletCommands.dispatch(dataDir, tail)
suspend fun store(
dataDir: DataDir,
tail: Array<String>,
): Int = StoreCommands.dispatch(dataDir, tail)
suspend fun follow(
dataDir: DataDir,
tail: Array<String>,
): Int = FollowCommand.follow(dataDir, tail)
suspend fun unfollow(
dataDir: DataDir,
tail: Array<String>,
): Int = FollowCommand.unfollow(dataDir, tail)
suspend fun search(
dataDir: DataDir,
tail: Array<String>,
): Int = SearchCommand.dispatch(dataDir, tail)
suspend fun zap(
dataDir: DataDir,
tail: Array<String>,
): Int = ZapCommand.dispatch(dataDir, tail)
suspend fun offer(
dataDir: DataDir,
tail: Array<String>,
): Int = OfferCommands.dispatch(dataDir, tail)
suspend fun debit(
dataDir: DataDir,
tail: Array<String>,
): Int = DebitCommands.dispatch(dataDir, tail)
}
@@ -71,17 +71,14 @@ object CreateCommand {
// store via verifyAndStore — so kind:10002 / 10050 / 10051 are
// immediately readable by outboxRelays() / inboxRelays() /
// keyPackageRelays() without a separate config file.
val ctx = Context.open(dataDir)
val accepted = mutableMapOf<String, List<String>>()
try {
Context.open(dataDir).use { ctx ->
ctx.prepare()
for (event in bootstrap.all()) {
val ack = ctx.publish(event, DefaultNIP65RelaySet)
accepted[event.kind.toString()] =
ack.filterValues { it }.keys.map { it.url }
}
} finally {
ctx.close()
}
Output.emit(
@@ -47,16 +47,17 @@ object DebitCommands {
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): Int {
if (tail.isEmpty()) return Output.error("bad_args", "debit <info|pay|budget>")
val rest = tail.drop(1).toTypedArray()
return when (tail[0]) {
"info" -> info(rest)
"pay" -> pay(dataDir, rest)
"budget" -> budget(dataDir, rest)
else -> Output.error("bad_args", "debit ${tail[0]} (expected info|pay|budget)")
}
}
): Int =
route(
"debit",
tail,
"debit <info|pay|budget>",
mapOf(
"info" to { rest -> info(rest) },
"pay" to { rest -> pay(dataDir, rest) },
"budget" to { rest -> budget(dataDir, rest) },
),
)
/** Local decode of an `ndebit` pointer — no network, no account needed. */
private fun info(rest: Array<String>): Int {
@@ -119,8 +120,7 @@ object DebitCommands {
?: return Output.error("bad_args", "not a valid ndebit pointer")
if (debit.relays.isEmpty()) return Output.error("bad_pointer", "ndebit carries no relay to reach")
val ctx = Context.open(dataDir)
try {
Context.open(dataDir).use { ctx ->
ctx.prepare()
return when (val outcome = settle(ctx, debit, timeoutMs, buildRequest)) {
Settle.Timeout -> {
@@ -130,8 +130,6 @@ object DebitCommands {
Settle.BadReply -> Output.error("bad_response", "service reply was not a kind-21002 debit event")
is Settle.Replied -> emitDebit(outcome, debit.pubKey)
}
} finally {
ctx.close()
}
}
@@ -61,17 +61,18 @@ object DmCommands {
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): Int {
if (tail.isEmpty()) return Output.error("bad_args", "dm <send|send-file|list|await> …")
val rest = tail.drop(1).toTypedArray()
return when (tail[0]) {
"send" -> send(dataDir, rest)
"send-file" -> sendFile(dataDir, rest)
"list" -> list(dataDir, rest)
"await" -> await(dataDir, rest)
else -> Output.error("bad_args", "dm ${tail[0]}")
}
}
): Int =
route(
"dm",
tail,
"dm <send|send-file|list|await> …",
mapOf(
"send" to { rest -> send(dataDir, rest) },
"send-file" to { rest -> sendFile(dataDir, rest) },
"list" to { rest -> list(dataDir, rest) },
"await" to { rest -> await(dataDir, rest) },
),
)
private suspend fun send(
dataDir: DataDir,
@@ -82,14 +83,11 @@ object DmCommands {
val args = Args(rest.drop(2).toTypedArray())
val allowFallback = args.bool("allow-fallback")
val ctx = Context.open(dataDir)
try {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val recipient = ctx.requireUserHex(rest[0])
val result = DmActions.buildTextDm(ctx.signer, recipient, text)
return publishWraps(ctx, result, allowFallback)
} finally {
ctx.close()
}
}
@@ -118,8 +116,7 @@ object DmCommands {
val allowFallback = args.bool("allow-fallback")
val recipientInput = rest[0]
val ctx = Context.open(dataDir)
try {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val recipient = ctx.requireUserHex(recipientInput)
@@ -133,8 +130,6 @@ object DmCommands {
}
return publishWraps(ctx, result, allowFallback, extra = summary)
} finally {
ctx.close()
}
}
@@ -315,8 +310,7 @@ object DmCommands {
val limit = args.intFlag("limit", Int.MAX_VALUE)
val timeoutSecs = args.longFlag("timeout", 8)
val ctx = Context.open(dataDir)
try {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val peerHex = peerInput?.let { ctx.requireUserHex(it) }
@@ -357,8 +351,6 @@ object DmCommands {
),
)
return 0
} finally {
ctx.close()
}
}
@@ -371,8 +363,7 @@ object DmCommands {
val match = args.requireFlag("match")
val timeoutSecs = args.longFlag("timeout", 30)
val ctx = Context.open(dataDir)
try {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val peerHex = ctx.requireUserHex(peerInput)
@@ -408,8 +399,6 @@ object DmCommands {
delay(2_000)
}
throw AwaitTimeout("no DM from $peerHex matching '$match' within ${timeoutSecs}s")
} finally {
ctx.close()
}
}
@@ -61,8 +61,7 @@ object FeedCommand {
val until = args.flag("until")?.toLongOrNull()
val timeoutSecs = args.longFlag("timeout", 8L)
val ctx = Context.open(dataDir)
try {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val (authors, mode) =
@@ -131,8 +130,6 @@ object FeedCommand {
),
)
return 0
} finally {
ctx.close()
}
}
@@ -68,8 +68,7 @@ object FollowCommand {
val args = Args(rest.drop(1).toTypedArray())
val timeoutSecs = args.longFlag("timeout", 8L)
val ctx = Context.open(dataDir)
try {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val target = ctx.requireUserHex(userArg)
val self = ctx.identity.pubKeyHex
@@ -148,8 +147,6 @@ object FollowCommand {
),
)
return 0
} finally {
ctx.close()
}
}
@@ -46,8 +46,7 @@ object GroupAddMemberCommand {
rest: Array<String>,
): Int {
if (rest.size < 2) return Output.error("bad_args", "group add <group_id> <npub> [<npub> ...]")
val ctx = Context.open(dataDir)
try {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val gid = ctx.resolveGroupId(rest[0])
ctx.syncIncoming()
@@ -168,8 +167,6 @@ object GroupAddMemberCommand {
),
)
return 0
} finally {
ctx.close()
}
}
}
@@ -21,28 +21,28 @@
package com.vitorpamplona.amethyst.cli.commands
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
object GroupCommands {
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): Int {
if (tail.isEmpty()) return Output.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 -> Output.error("bad_args", "group ${tail[0]}")
}
}
): Int =
route(
"group",
tail,
"group <create|list|show|…>",
mapOf(
"create" to { rest -> GroupCreateCommand.run(dataDir, rest) },
"list" to { _ -> GroupReadCommands.list(dataDir) },
"show" to { rest -> GroupReadCommands.show(dataDir, rest) },
"members" to { rest -> GroupReadCommands.members(dataDir, rest) },
"admins" to { rest -> GroupReadCommands.admins(dataDir, rest) },
"add" to { rest -> GroupAddMemberCommand.run(dataDir, rest) },
"rename" to { rest -> GroupMetadataCommands.rename(dataDir, rest) },
"promote" to { rest -> GroupMetadataCommands.promote(dataDir, rest) },
"demote" to { rest -> GroupMetadataCommands.demote(dataDir, rest) },
"remove" to { rest -> GroupMembershipCommands.remove(dataDir, rest) },
"leave" to { rest -> GroupMembershipCommands.leave(dataDir, rest) },
),
)
}
@@ -35,8 +35,7 @@ object GroupCreateCommand {
): Int {
val args = Args(rest)
val name = args.flag("name", "")!!
val ctx = Context.open(dataDir)
try {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val gid = RandomInstance.bytes(32).toHexKey()
@@ -65,8 +64,6 @@ object GroupCreateCommand {
),
)
return 0
} finally {
ctx.close()
}
}
}
@@ -30,8 +30,7 @@ object GroupMembershipCommands {
rest: Array<String>,
): Int {
if (rest.size < 2) return Output.error("bad_args", "group remove <gid> <npub>")
val ctx = Context.open(dataDir)
try {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val gid = ctx.resolveGroupId(rest[0])
val target = ctx.requireUserHex(rest[1])
@@ -56,8 +55,6 @@ object GroupMembershipCommands {
),
)
return 0
} finally {
ctx.close()
}
}
@@ -66,8 +63,7 @@ object GroupMembershipCommands {
rest: Array<String>,
): Int {
if (rest.isEmpty()) return Output.error("bad_args", "group leave <gid>")
val ctx = Context.open(dataDir)
try {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val gid = ctx.resolveGroupId(rest[0])
if (!ctx.marmot.isMember(gid)) return Output.error("not_member", gid)
@@ -111,8 +107,6 @@ object GroupMembershipCommands {
),
)
return 0
} finally {
ctx.close()
}
}
}
@@ -69,8 +69,7 @@ object GroupMetadataCommands {
rawGid: HexKey,
mutate: suspend (Context, MarmotGroupData) -> MarmotGroupData,
): Int {
val ctx = Context.open(dataDir)
try {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val gid = ctx.resolveGroupId(rawGid)
ctx.syncIncoming()
@@ -100,8 +99,6 @@ object GroupMetadataCommands {
),
)
return 0
} finally {
ctx.close()
}
}
}
@@ -29,8 +29,7 @@ import com.vitorpamplona.amethyst.cli.Output
*/
object GroupReadCommands {
suspend fun list(dataDir: DataDir): Int {
val ctx = Context.open(dataDir)
try {
Context.open(dataDir).use { ctx ->
ctx.prepare()
ctx.syncIncoming()
val ids = ctx.marmot.activeGroupIds()
@@ -46,8 +45,6 @@ object GroupReadCommands {
}
Output.emit(mapOf("groups" to items))
return 0
} finally {
ctx.close()
}
}
@@ -56,8 +53,7 @@ object GroupReadCommands {
rest: Array<String>,
): Int {
if (rest.isEmpty()) return Output.error("bad_args", "group show <group_id>")
val ctx = Context.open(dataDir)
try {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val gid = ctx.resolveGroupId(rest[0])
ctx.syncIncoming()
@@ -81,8 +77,6 @@ object GroupReadCommands {
),
)
return 0
} finally {
ctx.close()
}
}
@@ -91,8 +85,7 @@ object GroupReadCommands {
rest: Array<String>,
): Int {
if (rest.isEmpty()) return Output.error("bad_args", "group members <group_id>")
val ctx = Context.open(dataDir)
try {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val gid = ctx.resolveGroupId(rest[0])
ctx.syncIncoming()
@@ -103,8 +96,6 @@ object GroupReadCommands {
}
Output.emit(mapOf("group_id" to gid, "members" to members))
return 0
} finally {
ctx.close()
}
}
@@ -113,8 +104,7 @@ object GroupReadCommands {
rest: Array<String>,
): Int {
if (rest.isEmpty()) return Output.error("bad_args", "group admins <group_id>")
val ctx = Context.open(dataDir)
try {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val gid = ctx.resolveGroupId(rest[0])
ctx.syncIncoming()
@@ -122,8 +112,6 @@ object GroupReadCommands {
val m = ctx.marmot.groupMetadata(gid)
Output.emit(mapOf("group_id" to gid, "admins" to (m?.adminPubkeys ?: emptyList())))
return 0
} finally {
ctx.close()
}
}
}
@@ -30,18 +30,19 @@ object KeyPackageCommands {
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): Int {
if (tail.isEmpty()) return Output.error("bad_args", "key-package <publish|check> …")
return when (tail[0]) {
"publish" -> publish(dataDir)
"check" -> check(dataDir, tail.drop(1).toTypedArray())
else -> Output.error("bad_args", "key-package ${tail[0]}")
}
}
): Int =
route(
"key-package",
tail,
"key-package <publish|check> …",
mapOf(
"publish" to { _ -> publish(dataDir) },
"check" to { rest -> check(dataDir, rest) },
),
)
private suspend fun publish(dataDir: DataDir): Int {
val ctx = Context.open(dataDir)
try {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val relays = ctx.keyPackageRelays().ifEmpty { ctx.outboxRelays() }.ifEmpty { ctx.anyRelays() }
if (relays.isEmpty()) return Output.error("no_relays", "configure relays first")
@@ -57,8 +58,6 @@ object KeyPackageCommands {
),
)
return 0
} finally {
ctx.close()
}
}
@@ -67,8 +66,7 @@ object KeyPackageCommands {
rest: Array<String>,
): Int {
if (rest.isEmpty()) return Output.error("bad_args", "key-package check <npub>")
val ctx = Context.open(dataDir)
try {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val targetHex = ctx.requireUserHex(rest[0])
// Per MIP-00: a user's KeyPackages live on the relays advertised
@@ -109,8 +107,6 @@ object KeyPackageCommands {
),
)
return 0
} finally {
ctx.close()
}
}
}
@@ -46,8 +46,7 @@ object MarmotResetCommand {
rest: Array<String>,
): Int {
val confirmed = rest.any { it == "--yes" || it == "-y" }
val ctx = Context.open(dataDir)
try {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val groupIds =
ctx.marmot
@@ -77,8 +76,6 @@ object MarmotResetCommand {
),
)
return 0
} finally {
ctx.close()
}
}
}
@@ -31,17 +31,18 @@ object MessageCommands {
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): Int {
if (tail.isEmpty()) return Output.error("bad_args", "message <send|list|react|delete> …")
val rest = tail.drop(1).toTypedArray()
return when (tail[0]) {
"send" -> send(dataDir, rest)
"list" -> list(dataDir, rest)
"react" -> react(dataDir, rest)
"delete" -> delete(dataDir, rest)
else -> Output.error("bad_args", "message ${tail[0]}")
}
}
): Int =
route(
"message",
tail,
"message <send|list|react|delete> …",
mapOf(
"send" to { rest -> send(dataDir, rest) },
"list" to { rest -> list(dataDir, rest) },
"react" to { rest -> react(dataDir, rest) },
"delete" to { rest -> delete(dataDir, rest) },
),
)
private suspend fun send(
dataDir: DataDir,
@@ -49,8 +50,7 @@ object MessageCommands {
): Int {
if (rest.size < 2) return Output.error("bad_args", "message send <gid> <text>")
val text = rest[1]
val ctx = Context.open(dataDir)
try {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val gid = ctx.resolveGroupId(rest[0])
ctx.syncIncoming()
@@ -70,8 +70,6 @@ object MessageCommands {
),
)
return 0
} finally {
ctx.close()
}
}
@@ -82,8 +80,7 @@ object MessageCommands {
if (rest.isEmpty()) return Output.error("bad_args", "message list <gid>")
val args = Args(rest.drop(1).toTypedArray())
val limit = args.intFlag("limit", Int.MAX_VALUE)
val ctx = Context.open(dataDir)
try {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val gid = ctx.resolveGroupId(rest[0])
ctx.syncIncoming()
@@ -110,8 +107,6 @@ object MessageCommands {
Output.emit(mapOf("group_id" to gid, "messages" to items))
return 0
} finally {
ctx.close()
}
}
@@ -122,8 +117,7 @@ object MessageCommands {
if (rest.size < 3) return Output.error("bad_args", "message react <gid> <target_event_id> <emoji>")
val targetId = rest[1]
val emoji = rest[2]
val ctx = Context.open(dataDir)
try {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val gid = ctx.resolveGroupId(rest[0])
ctx.syncIncoming()
@@ -146,8 +140,6 @@ object MessageCommands {
),
)
return 0
} finally {
ctx.close()
}
}
@@ -156,8 +148,7 @@ object MessageCommands {
rest: Array<String>,
): Int {
if (rest.size < 2) return Output.error("bad_args", "message delete <gid> <target_event_id> [target_event_id ...]")
val ctx = Context.open(dataDir)
try {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val gid = ctx.resolveGroupId(rest[0])
ctx.syncIncoming()
@@ -184,8 +175,6 @@ object MessageCommands {
),
)
return 0
} finally {
ctx.close()
}
}
@@ -53,14 +53,15 @@ object NappletCommands {
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): Int {
if (tail.isEmpty()) return Output.error("bad_args", "napplet <fetch> …")
val rest = tail.drop(1).toTypedArray()
return when (tail[0]) {
"fetch" -> fetch(dataDir, rest)
else -> Output.error("bad_args", "napplet ${tail[0]}")
}
}
): Int =
route(
"napplet",
tail,
"napplet <fetch> …",
mapOf(
"fetch" to { rest -> fetch(dataDir, rest) },
),
)
private suspend fun fetch(
dataDir: DataDir,
@@ -80,8 +81,7 @@ object NappletCommands {
val extraServers = StaticSiteFetch.commaList(args.flag("server"))
val extraRelays = StaticSiteFetch.commaList(args.flag("relay"))
val ctx = Context.open(dataDir)
try {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val relays =
extraRelays
@@ -143,8 +143,6 @@ object NappletCommands {
outFile = outFile,
maxInlineBytes = maxInlineBytes,
)
} finally {
ctx.close()
}
}
@@ -21,7 +21,6 @@
package com.vitorpamplona.amethyst.cli.commands
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
/**
* `amy notes <post|feed>` — NIP-10 kind:1 short text notes. Sits alongside
@@ -32,13 +31,14 @@ object NotesCommands {
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): Int {
if (tail.isEmpty()) return Output.error("bad_args", "notes <post|feed> …")
val rest = tail.drop(1).toTypedArray()
return when (tail[0]) {
"post" -> PostCommand.run(dataDir, rest)
"feed" -> FeedCommand.run(dataDir, rest)
else -> Output.error("bad_args", "notes ${tail[0]}")
}
}
): Int =
route(
"notes",
tail,
"notes <post|feed> …",
mapOf(
"post" to { rest -> PostCommand.run(dataDir, rest) },
"feed" to { rest -> FeedCommand.run(dataDir, rest) },
),
)
}
@@ -52,14 +52,15 @@ object NsiteCommands {
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): Int {
if (tail.isEmpty()) return Output.error("bad_args", "nsite <fetch> …")
val rest = tail.drop(1).toTypedArray()
return when (tail[0]) {
"fetch" -> fetch(dataDir, rest)
else -> Output.error("bad_args", "nsite ${tail[0]}")
}
}
): Int =
route(
"nsite",
tail,
"nsite <fetch> …",
mapOf(
"fetch" to { rest -> fetch(dataDir, rest) },
),
)
private suspend fun fetch(
dataDir: DataDir,
@@ -75,8 +76,7 @@ object NsiteCommands {
val extraServers = StaticSiteFetch.commaList(args.flag("server"))
val extraRelays = StaticSiteFetch.commaList(args.flag("relay"))
val ctx = Context.open(dataDir)
try {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val authorHex = ctx.requireUserHex(author)
@@ -113,8 +113,6 @@ object NsiteCommands {
outFile = outFile,
maxInlineBytes = maxInlineBytes,
)
} finally {
ctx.close()
}
}
@@ -57,17 +57,18 @@ object OfferCommands {
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): Int {
if (tail.isEmpty()) return Output.error("bad_args", "offer <info|discover|request|pay>")
val rest = tail.drop(1).toTypedArray()
return when (tail[0]) {
"info" -> info(rest)
"discover" -> discover(dataDir, rest)
"request" -> request(dataDir, rest)
"pay" -> pay(dataDir, rest)
else -> Output.error("bad_args", "offer ${tail[0]} (expected info|discover|request|pay)")
}
}
): Int =
route(
"offer",
tail,
"offer <info|discover|request|pay>",
mapOf(
"info" to { rest -> info(rest) },
"discover" to { rest -> discover(dataDir, rest) },
"request" to { rest -> request(dataDir, rest) },
"pay" to { rest -> pay(dataDir, rest) },
),
)
/**
* Resolve a profile's advertised offer from its NIP-05 `.well-known/nostr.json` `clink_offer`
@@ -83,8 +84,7 @@ object OfferCommands {
Nip05Id.parse(args.positional(0, "nip05").trim())
?: return Output.error("bad_args", "not a valid NIP-05 address (e.g. bob@example.com)")
val ctx = Context.open(dataDir)
try {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val noffer = ctx.nip05Client.loadClinkOffer(id)
if (noffer == null) {
@@ -105,8 +105,6 @@ object OfferCommands {
),
)
return 0
} finally {
ctx.close()
}
}
@@ -152,8 +150,7 @@ object OfferCommands {
ClinkPointerParser.parse(args.positional(0, "noffer").trim()) as? NOffer
?: return Output.error("bad_args", ERR_NOT_A_NOFFER)
val ctx = Context.open(dataDir)
try {
Context.open(dataDir).use { ctx ->
ctx.prepare()
var hops = 0
while (hops <= MAX_FOLLOW_HOPS) {
@@ -200,8 +197,6 @@ object OfferCommands {
)
}
return Output.error("offer_error", "too many redirects following moved offers (>$MAX_FOLLOW_HOPS)")
} finally {
ctx.close()
}
}
@@ -232,8 +227,7 @@ object OfferCommands {
if (offerRelays.isEmpty()) return Output.error("bad_pointer", "noffer carries no relay to reach")
if (debit.relays.isEmpty()) return Output.error("bad_pointer", "ndebit carries no relay to reach")
val ctx = Context.open(dataDir)
try {
Context.open(dataDir).use { ctx ->
ctx.prepare()
// 1. fetch a fresh BOLT-11 from the offer service.
@@ -287,8 +281,6 @@ object OfferCommands {
)
}
}
} finally {
ctx.close()
}
}
@@ -50,8 +50,7 @@ object PostCommand {
?.map { it.trim() }
?.filter { it.isNotEmpty() } ?: emptyList()
val ctx = Context.open(dataDir)
try {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val outbox = ctx.outboxRelays()
val extraNormalized =
@@ -78,8 +77,6 @@ object PostCommand {
),
)
return 0
} finally {
ctx.close()
}
}
}
@@ -45,15 +45,16 @@ object ProfileCommands {
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): Int {
if (tail.isEmpty()) return Output.error("bad_args", "profile <show|edit> …")
val rest = tail.drop(1).toTypedArray()
return when (tail[0]) {
"show" -> show(dataDir, rest)
"edit" -> edit(dataDir, rest)
else -> Output.error("bad_args", "profile ${tail[0]}")
}
}
): Int =
route(
"profile",
tail,
"profile <show|edit> …",
mapOf(
"show" to { rest -> show(dataDir, rest) },
"edit" to { rest -> edit(dataDir, rest) },
),
)
private suspend fun show(
dataDir: DataDir,
@@ -62,8 +63,7 @@ object ProfileCommands {
val args = Args(rest)
val refresh = args.bool("refresh")
val timeoutSecs = args.longFlag("timeout", 8L)
val ctx = Context.open(dataDir)
try {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val pubKey =
args.positionalOrNull(0)?.let { ctx.requireUserHex(it) }
@@ -118,8 +118,6 @@ object ProfileCommands {
),
)
return 0
} finally {
ctx.close()
}
}
@@ -161,8 +159,7 @@ object ProfileCommands {
)
}
val ctx = Context.open(dataDir)
try {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val targets = ctx.outboxRelays().ifEmpty { ctx.bootstrapRelays() }
val latest =
@@ -226,8 +223,6 @@ object ProfileCommands {
),
)
return 0
} finally {
ctx.close()
}
}
@@ -51,17 +51,17 @@ object RelayCommands {
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): Int {
if (tail.isEmpty()) return Output.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 -> Output.error("bad_args", "relay $sub")
}
}
): Int =
route(
"relay",
tail,
"relay <add|list|publish-lists> …",
mapOf(
"add" to { rest -> add(dataDir, Args(rest)) },
"list" to { _ -> list(dataDir) },
"publish-lists" to { _ -> publishLists(dataDir) },
),
)
private suspend fun add(
dataDir: DataDir,
@@ -74,8 +74,7 @@ object RelayCommands {
?: return Output.error("bad_args", "invalid relay url: $rawUrl")
val targets = if (type == "all") listOf("nip65", "inbox", "key_package") else listOf(type)
val ctx = Context.open(dataDir)
try {
Context.open(dataDir).use { ctx ->
val addedTo = mutableListOf<String>()
val alreadyPresent = mutableListOf<String>()
for (t in targets) {
@@ -89,8 +88,6 @@ object RelayCommands {
),
)
return 0
} finally {
ctx.close()
}
}
@@ -138,8 +135,7 @@ object RelayCommands {
}
private suspend fun list(dataDir: DataDir): Int {
val ctx = Context.open(dataDir)
try {
Context.open(dataDir).use { ctx ->
val self = ctx.identity.pubKeyHex
Output.emit(
mapOf(
@@ -149,14 +145,11 @@ object RelayCommands {
),
)
return 0
} finally {
ctx.close()
}
}
private suspend fun publishLists(dataDir: DataDir): Int {
val ctx = Context.open(dataDir)
try {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val self = ctx.identity.pubKeyHex
val nip65Event = ctx.relaysOf(self)
@@ -189,8 +182,6 @@ object RelayCommands {
),
)
return 0
} finally {
ctx.close()
}
}
}
@@ -0,0 +1,42 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.cli.commands
import com.vitorpamplona.amethyst.cli.Output
/**
* Shared sub-verb router used by every `*Commands.dispatch`.
*
* Maps the first token of [tail] to a handler over the remaining args.
* Empty input emits `bad_args: <usage>`; an unrecognised verb emits
* `bad_args: <name> <verb>`. Handlers receive the args *after* the verb,
* mirroring the old hand-rolled `when (tail[0]) { … }` blocks.
*/
suspend fun route(
name: String,
tail: Array<String>,
usage: String,
routes: Map<String, suspend (Array<String>) -> Int>,
): Int {
if (tail.isEmpty()) return Output.error("bad_args", usage)
val handler = routes[tail[0]] ?: return Output.error("bad_args", "$name ${tail[0]}")
return handler(tail.drop(1).toTypedArray())
}
@@ -52,15 +52,16 @@ object SearchCommand {
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): Int {
if (tail.isEmpty()) return Output.error("bad_args", "search <user|note> <query> [--limit N] [--timeout SECS]")
val rest = tail.drop(1).toTypedArray()
return when (tail[0]) {
"user" -> searchUsers(dataDir, rest)
"note" -> searchNotes(dataDir, rest)
else -> Output.error("bad_args", "search ${tail[0]} — expected user|note")
}
}
): Int =
route(
"search",
tail,
"search <user|note> <query> [--limit N] [--timeout SECS]",
mapOf(
"user" to { rest -> searchUsers(dataDir, rest) },
"note" to { rest -> searchNotes(dataDir, rest) },
),
)
private suspend fun searchUsers(
dataDir: DataDir,
@@ -144,8 +145,7 @@ object SearchCommand {
timeoutMs: Long,
render: (List<Event>) -> List<Map<String, Any?>>,
): Int {
val ctx = Context.open(dataDir)
try {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val relays =
SearchActions.resolveSearchRelays(
@@ -172,8 +172,6 @@ object SearchCommand {
),
)
return 0
} finally {
ctx.close()
}
}
@@ -54,18 +54,19 @@ object StoreCommands {
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): Int {
if (tail.isEmpty()) return Output.error("bad_args", "store <stat|sweep-expired|scrub|compact|reindex-fts>")
val rest = tail.drop(1).toTypedArray()
return when (tail[0]) {
"stat" -> stat(dataDir)
"sweep-expired" -> sweepExpired(dataDir)
"scrub" -> scrub(dataDir)
"compact" -> compact(dataDir)
"reindex-fts" -> reindexFts(dataDir)
else -> Output.error("bad_args", "store ${tail[0]}")
}
}
): Int =
route(
"store",
tail,
"store <stat|sweep-expired|scrub|compact|reindex-fts>",
mapOf(
"stat" to { _ -> stat(dataDir) },
"sweep-expired" to { _ -> sweepExpired(dataDir) },
"scrub" to { _ -> scrub(dataDir) },
"compact" to { _ -> compact(dataDir) },
"reindex-fts" to { _ -> reindexFts(dataDir) },
),
)
private fun stat(dataDir: DataDir): Int {
val storeRoot = dataDir.eventsDir.toPath()
@@ -94,8 +94,7 @@ object ZapCommand {
?: return Output.error("bad_args", "--with must be a valid ndebit pointer with a relay")
}
val ctx = Context.open(dataDir)
try {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val recipient = ctx.requireUserHex(userArg)
val metadata =
@@ -117,8 +116,6 @@ object ZapCommand {
emitZapResult(ctx, sats, lnAddress, comment, request, zapType, timeoutMs, settleWith)
return 0
} finally {
ctx.close()
}
}
@@ -145,8 +142,7 @@ object ZapCommand {
?: return Output.error("bad_args", "--with must be a valid ndebit pointer with a relay")
}
val ctx = Context.open(dataDir)
try {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val zappedEvent =
ctx.store.query<Event>(Filter(ids = listOf(eventId), limit = 1)).firstOrNull()
@@ -197,8 +193,6 @@ object ZapCommand {
emitSplitZapResult(ctx, sats, comment, zappedEvent.id, zapType, requests, timeoutMs, settleWith)
return 0
} finally {
ctx.close()
}
}