Merge pull request #4006 from vitorpamplona/claude/amy-status-redesign-xjgo7u

feat(cli): redesign `amy status` around who is signed in and what they saved
This commit is contained in:
Vitor Pamplona
2026-08-28 20:55:28 -04:00
committed by GitHub
14 changed files with 1086 additions and 226 deletions
+1 -1
View File
@@ -472,7 +472,7 @@ HTTP endpoint. Reuses quartz's `Nip86Client` and the shared `Nip86Retriever`
| `amy login KEY [--password X]` | Import an existing identity (`nsec`/`ncryptsec`/mnemonic/`npub`/`nprofile`/hex/NIP-05). |
| `amy whoami` | Print the active account's name + npub. |
| `amy use NAME` / `--clear` / no-arg | Pin / clear / inspect the active account. |
| `amy status` | Read-only overview of everything under `~/.amy/`: every account, which one is current, each signer type (local keychain/ncryptsec/plaintext, NIP-46 bunker, or read-only) and whether it can sign, the local Marmot / Cashu / alias / sync-cursor footprint per account, and the shared event store's size. Built for the returning user. No keychain prompt, no network. |
| `amy status` | Who is signed in and what each account has saved. Per account: the profile name/NIP-05 amy holds locally, the npub, how it signs (local key — keychain/passphrase/plaintext — NIP-46 bunker, or read-only), and its local footprint: follows, NIP-65 relays (with the read/write split) and NIP-17 DM inbox, own events in the store, contacts, Marmot groups + messages, a Marmot key package, Concord communities, a Cashu wallet, DM sync cursor. Warns up front when **no account is selected** (a stale `current` pin, or several accounts and no pin) — the state that makes every other command fail — and reports the machine-level GrapeRank operator key. Whatever an account doesn't have is left out, so a fresh one is four lines. Built for the returning user: no keychain prompt, no network. (Event-store size and backend: `amy store stat`.) |
| `amy logoff [--yes] [--keep-events]` | Log off an account: delete its key + backend secret, the whole `~/.amy/<account>/` directory (run-state, aliases, cashu counters, Marmot state), the `current` pin if it points here, and the account's events (authored + `#p`-addressed) in the shared store. `--keep-events` leaves the shared cache alone. Destructive and irreversible — requires `--yes`; without it, prints a dry run and exits 2. |
### Social
+1 -1
View File
@@ -44,7 +44,7 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command ·
| Identity create / import (`nsec`, `ncryptsec`, mnemonic, `npub`, `nprofile`, hex, NIP-05) | ✅ | `LoginCommand` + Quartz NIP-05 / NIP-06 / NIP-49 |
| Account bootstrap (nine events) | ✅ | `commons/account/AccountBootstrapEvents.kt` |
| Account logoff (`amy logoff`) — delete key + per-account state + the account's events in the shared store | ✅ | `LogoffCommand`. `--yes`-gated; `--keep-events` skips the shared-cache purge. |
| Status overview (`amy status`) — every account, current pin, signer type + can-sign, per-account Marmot/Cashu/alias/cursor footprint, shared event-store size | ✅ | `StatusCommand`. Cross-account, read-only, metadata-only (no keychain prompt, no network). Store stats via shared `StoreStats`. |
| Status overview (`amy status`) — who is signed in (profile name, npub, signer + can-sign), what each account has saved (follows, relay lists, own events, contacts, Marmot groups + messages, key package, Concord communities, Cashu wallet, DM cursor), whether an account is actually selected, and the machine operator key | ✅ | `StatusCommand` + `StatusReport` + `StatusText`. Cross-account and read-only: no keychain prompt, no network, and it never creates the event store it reads. Store size/backend is `amy store stat`. |
| Relay config — every relay-list bucket (nip65 10002 via `outbox`/`inbox`/`nip65` nouns with spec read/write merge, dm 10050, key-package 10051, search 10007, private-outbox 10013, blocked 10006, trusted 10089, proxy 10087, indexer 10086, broadcast 10088, favorite 10012) — noun-first `relay <noun> add/remove/set/clear/list` + fan-out `relay add/remove` + publish | ✅ | `RelayCommands`. Mirrors the Android relay-settings screen. Local relays (device pref) + relay sets (30002) intentionally out of scope. |
| MLS KeyPackage publish + fetch | ✅ | `commons/marmot/MarmotManager` |
| Marmot group create / add / rename / promote / demote / remove / leave | ✅ | `commons/marmot/` |
@@ -418,7 +418,7 @@ class DataDir(
val rootBase = DEFAULT_ROOT
val name = if (accountFlag != null) validateName(accountFlag) else pickAccount(rootBase)
val accountRoot = File(rootBase, name).absoluteFile
val sharedEvents = File(rootBase, "$SHARED_DIR_NAME/events-store").absoluteFile
val sharedEvents = sharedEventsDir(rootBase)
return DataDir(
root = accountRoot,
eventsDir = sharedEvents,
@@ -442,7 +442,7 @@ class DataDir(
secrets: SecretStore,
): DataDir {
val rootBase = DEFAULT_ROOT
val sharedEvents = File(rootBase, "$SHARED_DIR_NAME/events-store").absoluteFile
val sharedEvents = sharedEventsDir(rootBase)
if (accountFlag != null) {
val name = validateName(accountFlag)
return DataDir(File(rootBase, name).absoluteFile, sharedEvents, name, secrets)
@@ -531,6 +531,17 @@ class DataDir(
}
}
/**
* The cross-account event store under [rootBase], as the two backends
* lay it out (see [com.vitorpamplona.amethyst.cli.StoreFactory]). These
* mirror the per-instance [eventsDir] / [eventsDbFile] but take the
* root directly, so a cross-account command like `status` can reach the
* shared store without resolving — or creating — an account directory.
*/
fun sharedEventsDir(rootBase: File): File = File(rootBase, "$SHARED_DIR_NAME/events-store").absoluteFile
fun sharedEventsDbFile(rootBase: File): File = File(rootBase, "$SHARED_DIR_NAME/events.db").absoluteFile
/** Subdirectories of `<root>/` that look like accounts (excludes `shared/`). */
fun listAccounts(rootBase: File): List<String> =
rootBase
@@ -221,8 +221,8 @@ private suspend fun dispatch(argv: Array<String>): Int {
return UseCommand.run(tail)
}
// `status` is a cross-account, read-only overview of everything on
// disk under ~/.amy/. Like `use`, it must work regardless of how many
// `status` is the cross-account, read-only "who am I and what have I
// got" overview. Like `use`, it must work regardless of how many
// accounts exist (zero, one, or many), so it dispatches before account
// resolution rather than through the single-account DataDir path.
if (head == "status") {
@@ -462,9 +462,9 @@ private fun printUsage() {
| use NAME pin NAME as the active account
| use --clear remove the pin
| use print current pin + available accounts
| status read-only overview of every account, signer
| type, local Marmot/Cashu state, and the shared
| event store (no keychain prompt, no network)
| status who is signed in and what each account has
| saved locally (no keychain prompt, no network;
| store size lives in `store stat`)
|
|Output:
| Default: human-readable text on stdout.
@@ -169,6 +169,24 @@ class OperatorKeys(
companion object {
private const val DIR_NAME = "operator"
private const val CONFIG_NAME = "operator.json"
/**
* Read the manifest at `<amyHome>/operator/operator.json` without a
* [SecretStore] and without creating anything — null when this machine
* has no operator yet.
*
* The instance API can't answer "is there one, and what is it?" for a
* read-only caller: it needs a [SecretStore] to construct, and
* [masterPubKey] mints a master on first use. `amy status` must do
* neither, so it peeks. Only public data is returned to the caller —
* [Config.master] is a [SecretStore] descriptor, not the key.
*/
fun peek(amyHome: File): Config? =
File(File(amyHome, DIR_NAME), CONFIG_NAME)
.takeIf { it.isFile }
?.let { runCatching { Output.mapper.readValue<Config>(it.readText()) }.getOrNull() }
?.takeIf { it.masterPubKey.isNotEmpty() }
private const val DERIVATION_LABEL = "graperank-provider:"
private const val MONITOR_LABEL = "relay-monitor:"
}
@@ -60,6 +60,29 @@ object Output {
}
}
/**
* Emit a result that has earned a purpose-built human rendering.
* [result] is still the `--json` shape (the public contract); [text] is
* asked for the terminal form only in TEXT mode, and is handed the
* already-resolved [Ansi] so it paints under the same TTY/`NO_COLOR`
* rules as everything else.
*
* Reach for this only when the generic key/value render genuinely buries
* the answer — an overview that has to group and prioritise, rather than
* list. `amy status` is the reference case. Everything else should stay
* on the plain [emit], so the default text shape keeps being derived from
* one place.
*/
internal fun emit(
result: Any?,
text: (Ansi) -> String,
) {
when (mode) {
Mode.JSON -> println(mapper.writeValueAsString(result))
Mode.TEXT -> println(text(Ansi.forStream(isStderr = false)))
}
}
/**
* Report a failure and return the exit code the process should end with.
* The code string picks the exit code — `bad_args` → 2, `timeout` → 124,
@@ -341,7 +364,12 @@ object Output {
return "$iso ${color.dim("($rel)")}"
}
private fun relativeTime(secondsAgo: Long): String {
/**
* `2h ago` / `3d ago` / `5m from now` for a delta in seconds. Internal
* so a command with its own text renderer (see the [emit] overload)
* words elapsed time exactly the way the generic renderer does.
*/
internal fun relativeTime(secondsAgo: Long): String {
val s = if (secondsAgo < 0) -secondsAgo else secondsAgo
val suffix = if (secondsAgo < 0) "from now" else "ago"
val unit =
@@ -24,6 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.nip01Core.store.fs.FsEventStore
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
import java.io.File
import kotlin.io.path.Path
/** On-disk backend for the shared event store. */
@@ -86,4 +87,28 @@ object StoreFactory {
eventToJson = JacksonMapper::toJsonPretty,
)
}
/**
* Open the shared, cross-account store under [rootBase] **only if it is
* already there** — returns null otherwise instead of creating it. The
* account-scoped [open] happily materialises an empty database, which is
* right for a command that is about to write; a read-only inspector like
* `amy status` must not leave a store behind on a machine that has none.
* It also takes the root rather than a [DataDir] so it never has to pick
* an account. Caller owns [IEventStore.close].
*/
fun openExistingShared(rootBase: File): IEventStore? =
when (backend()) {
StoreBackend.SQLITE ->
DataDir
.sharedEventsDbFile(rootBase)
.takeIf { it.isFile }
?.let { EventStore(dbName = it.absolutePath, relay = null) }
StoreBackend.FS ->
DataDir
.sharedEventsDir(rootBase)
.takeIf { it.isDirectory }
?.let { FsEventStore(root = Path(it.absolutePath), eventToJson = JacksonMapper::toJsonPretty) }
}
}
@@ -30,8 +30,10 @@ import kotlin.io.path.exists
* Read-only introspection of a file-backed Nostr event store on disk.
*
* Pure filesystem walk — no relay traffic, no writer lock, no [Context].
* Shared by `amy store stat` (full detail) and `amy status` (a compact
* roll-up alongside the account overview).
* Backs `amy store stat` on the FS backend. Filesystem-only by nature: the
* per-kind histogram reads `idx/kind/` and the age range reads file mtimes,
* neither of which the SQLite backend has, so `store stat` answers that one
* from the database instead.
*/
data class StoreStats(
val events: Long,
@@ -20,43 +20,52 @@
*/
package com.vitorpamplona.amethyst.cli.commands
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.amethyst.cli.RunState
import com.vitorpamplona.amethyst.cli.StoreStats
import com.vitorpamplona.amethyst.cli.secrets.IdentityFile
import com.vitorpamplona.amethyst.cli.secrets.IdentitySecret
import java.io.File
import com.vitorpamplona.amethyst.cli.StoreFactory
/**
* `amy status` — a single at-a-glance overview of everything amy is
* holding on disk under `~/.amy/`. Built for the returning user: "I
* haven't run this in months — what accounts do I have, which one is
* active, can they still sign, and how big is the local database?"
* `amy status` — who is signed in, and what each of them has saved.
*
* Two questions, in that order. **Who**: every account under `~/.amy/`,
* which one commands run as, the name/NIP-05 on its profile, and how (or
* whether) it can sign. **What's saved**: the local footprint that account
* has accumulated — follows and relay lists, its own events in the shared
* store, address-book aliases, Marmot groups and their messages, a Marmot
* KeyPackage, Concord communities, a Cashu wallet, and how far the DM
* catch-up cursor has run. Anything an account does *not* have is left out
* rather than printed as a zero, so a fresh account reads as one short
* block instead of a wall of `no`.
*
* It also reports the one thing no other verb can: that **no account is
* selected**. Everything but `use` and `status` resolves an account before
* it runs and dies on a stale `current` pin or an ambiguous `~/.amy/`, so
* the command you reach for to find out why has to name the cause. The
* machine-level GrapeRank operator key gets a line for the same reason —
* `listAccounts` skips it as a reserved name, so nothing else reports it.
*
* Deliberately out of scope: event-store internals (backend, disk bytes,
* kind histogram) — that is `amy store stat`, and duplicating a partial,
* FS-only view of it here is what made the old output so noisy.
*
* Cross-account by design, so it dispatches *before* account resolution
* (like `use`) and never fails on "zero accounts" or "ambiguous account".
* It is strictly read-only and metadata-only: it parses the on-disk
* `identity.json` / `state.json` / `aliases.json` and walks the shared
* event store, but it never unlocks a private key (no keychain prompt,
* no NIP-49 passphrase) and never touches the network.
* Strictly read-only: [StatusReport] parses the on-disk JSON and reads —
* never creates — the shared event store. It never unlocks a private key
* (no keychain prompt, no NIP-49 passphrase) and never touches the network.
*
* Per account it reports the npub, how the key is stored (local keychain
* / ncryptsec / plaintext, a NIP-46 bunker, or read-only), whether it can
* sign, and the local footprint that account has accumulated: aliases,
* Marmot groups, a published KeyPackage bundle, a Cashu wallet, and the
* sync cursors that tell catch-up commands where they left off.
* [StatusReport] holds the data model and the `--json` contract;
* [StatusText] holds the terminal rendering.
*/
object StatusCommand {
val USAGE: String =
"""
|amy status — read-only overview of every account, signer type, local
|Marmot/Cashu state, and the shared event store (no keychain prompt,
|no network). Takes no arguments.
|amy status — who is signed in and what each account has saved
|locally. Read-only: no keychain prompt, no network. Takes no
|arguments. For event-store size and backend, see `amy store stat`.
""".trimMargin()
fun run(tail: Array<String>): Int {
suspend fun run(tail: Array<String>): Int {
if (tail.firstOrNull() == "--help" || tail.firstOrNull() == "-h") {
System.err.println(USAGE)
return 0
@@ -65,114 +74,18 @@ object StatusCommand {
// rather than erroring — it's a read-only inspection command.
val rootBase = DataDir.DEFAULT_ROOT
val currentPin =
File(rootBase, DataDir.CURRENT_MARKER_NAME)
.takeIf { it.isFile }
?.readText()
?.trim()
?.ifEmpty { null }
// One store handle for every account — it's shared. Null when the
// machine has no store yet; a store we can't open (locked, corrupt)
// degrades to the same thing, so the on-disk half still prints.
val store = runCatching { StoreFactory.openExistingShared(rootBase) }.getOrNull()
val overview =
try {
StatusReport.overview(rootBase, store)
} finally {
runCatching { store?.close() }
}
val accountNames = DataDir.listAccounts(rootBase)
val accounts = accountNames.map { accountRow(File(rootBase, it), it, it == currentPin) }
// The event store is shared across every account.
val store = StoreStats.of(File(rootBase, "shared/events-store").toPath())
Output.emit(
mapOf(
"root" to rootBase.absolutePath,
"current" to currentPin,
"account_count" to accounts.size,
"accounts" to accounts,
"store" to
mapOf(
"events" to store.events,
"distinct_kinds" to store.distinctKinds,
"disk_bytes" to store.diskBytes,
"oldest_at" to store.oldestAt,
"newest_at" to store.newestAt,
"root" to store.root.toString(),
),
),
)
Output.emit(overview.toJson()) { color -> StatusText.render(overview, color) }
return 0
}
private fun accountRow(
accountRoot: File,
name: String,
isCurrent: Boolean,
): Map<String, Any?> {
val identity = readIdentity(File(accountRoot, "identity.json"))
val signer = classifySigner(identity)
val marmotGroups =
File(accountRoot, "marmot/groups")
.listFiles { f -> f.name.endsWith(".state") }
?.size ?: 0
val hasKeyPackage = File(accountRoot, "marmot/keypackages.bundle").isFile
val hasCashuWallet = File(accountRoot, "cashu.json").isFile
val aliasCount = readAliases(File(accountRoot, "aliases.json")).size
val runState = readRunState(File(accountRoot, "state.json"))
// LinkedHashMap so the text renderer prints fields in this order.
val row = LinkedHashMap<String, Any?>()
row["name"] = name
row["current"] = isCurrent
row["npub"] = identity?.npub
row["hex"] = identity?.pubKeyHex
row["signer"] = signer.kind
row["key_storage"] = signer.storage
row["can_sign"] = signer.canSign
if (signer.bunkerRelays != null) row["bunker_relays"] = signer.bunkerRelays
row["aliases"] = aliasCount
row["marmot_groups"] = marmotGroups
row["key_package_published"] = hasKeyPackage
row["cashu_wallet"] = hasCashuWallet
row["dm_cursor_at"] = runState.giftWrapSince
row["marmot_group_cursors"] = runState.groupSince.size
return row
}
/**
* How this account can sign, derived purely from the on-disk
* [IdentityFile] — never resolves the secret itself.
* - `local` — an on-device private key ([storage] says where).
* - `bunker` — a NIP-46 remote signer ([bunkerRelays] lists it).
* - `read-only` — imported from an npub/nprofile/NIP-05; cannot sign.
*/
private data class SignerInfo(
val kind: String,
val storage: String?,
val canSign: Boolean,
val bunkerRelays: List<String>?,
)
private fun classifySigner(identity: IdentityFile?): SignerInfo {
if (identity == null) return SignerInfo("unknown", null, false, null)
identity.bunker?.let { bunker ->
return SignerInfo("bunker", secretStorageLabel(identity.secret), true, bunker.relays)
}
val storage = secretStorageLabel(identity.secret)
return when {
identity.secret != null -> SignerInfo("local", storage, true, null)
// Pre-secret-store data-dirs kept the key inline; still signable.
identity.privKeyHex != null || identity.nsec != null -> SignerInfo("local", "legacy-plaintext", true, null)
else -> SignerInfo("read-only", null, false, null)
}
}
private fun secretStorageLabel(secret: IdentitySecret?): String? =
when (secret) {
is IdentitySecret.Keychain -> "keychain:${secret.backend}"
is IdentitySecret.Ncryptsec -> "ncryptsec"
is IdentitySecret.Plaintext -> "plaintext"
null -> null
}
private fun readIdentity(file: File): IdentityFile? = if (file.isFile) runCatching { Output.mapper.readValue<IdentityFile>(file.readText()) }.getOrNull() else null
private fun readAliases(file: File): Map<String, String> = if (file.isFile) runCatching { Output.mapper.readValue<Map<String, String>>(file.readText()) }.getOrElse { emptyMap() } else emptyMap()
private fun readRunState(file: File): RunState = if (file.isFile) runCatching { Output.mapper.readValue<RunState>(file.readText()) }.getOrElse { RunState() } else RunState()
}
@@ -0,0 +1,436 @@
/*
* 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.DataDir
import com.vitorpamplona.amethyst.cli.OperatorKeys
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.amethyst.cli.RunState
import com.vitorpamplona.amethyst.cli.secrets.IdentityFile
import com.vitorpamplona.amethyst.cli.secrets.IdentitySecret
import com.vitorpamplona.amethyst.cli.stores.ConcordStore
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import java.io.File
/**
* What [StatusCommand] gathers about one account, and how it reads it off
* disk. Kept apart from the command so the command stays what every other
* one in `commands/` is: parse, call, emit.
*
* Everything here is read-only and prompt-free by construction — it parses
* the account's own JSON files and asks the (already-open) shared event
* store about the account's pubkey. It never resolves a private key, so it
* cannot pop a keychain dialog or ask for a NIP-49 passphrase.
*/
internal object StatusReport {
/**
* The whole answer: the machine's `~/.amy/`, which account is selected,
* every account, and the machine-level operator key if one exists.
*/
data class Overview(
val root: File,
val currentPin: String?,
/**
* Whether [currentPin] resolves to a directory that is actually
* there. A pin left behind by a deleted account makes *every* other
* verb fail with "pins 'x' but … doesn't exist" — so status, the
* command you run to work out why, has to be able to say so.
*/
val currentExists: Boolean,
val accounts: List<AccountReport>,
val operator: Operator?,
) {
/**
* True when no account is selected and amy cannot pick one for you —
* a stale pin, or several accounts and no pin. Until it's resolved
* every verb needs an explicit `--account`.
*/
val selectionBroken: Boolean
get() = if (currentPin != null) !currentExists else accounts.size > 1
fun toJson(): Map<String, Any?> =
linkedMapOf(
"root" to root.absolutePath,
"current" to currentPin,
"current_exists" to currentExists,
"accounts" to accounts.map { it.toJson() },
"operator" to operator?.toJson(),
)
}
/**
* The machine-level GrapeRank operator key at `~/.amy/operator/`, shared
* by every account. `listAccounts` skips it as a reserved name, so
* without this it is the one thing under `~/.amy/` nothing reports.
*/
data class Operator(
val pubkey: String,
val npub: String,
val relays: List<String>,
) {
fun toJson(): Map<String, Any?> = linkedMapOf("pubkey" to pubkey, "npub" to npub, "relays" to relays)
}
/** One account's answer to "who is this" and "what have they got". */
data class AccountReport(
val name: String,
val isCurrent: Boolean,
val npub: String?,
val pubkey: String?,
val profileName: String?,
val nip05: String?,
val signer: SignerInfo,
val saved: Saved,
) {
/** The `--json` projection — this shape is the public contract. */
fun toJson(): Map<String, Any?> {
// LinkedHashMap so `--json` key order matches the text block.
val row = LinkedHashMap<String, Any?>()
row["name"] = name
row["current"] = isCurrent
row["npub"] = npub
row["pubkey"] = pubkey
row["profile_name"] = profileName
row["nip05"] = nip05
row["signer"] = signer.kind
row["key_storage"] = signer.storage
row["can_sign"] = signer.canSign
if (signer.bunkerRelays != null) row["bunker_relays"] = signer.bunkerRelays
row["saved"] = saved.toJson()
return row
}
}
/**
* What this account has accumulated on this machine. [events] and
* [cashuWallet] come from the shared event store; everything else from
* the account's own directory.
*/
data class Saved(
val follows: Int,
/**
* NIP-65 (kind 10002). A bare `r` tag advertises a relay for both
* directions, so [relaysWrite] + [relaysRead] can exceed [relays] —
* that is the spec, not double counting.
*/
val relays: Int,
val relaysWrite: Int,
val relaysRead: Int,
/** NIP-17 DM inbox (kind 10050) — where others send this account DMs. */
val dmRelays: Int,
val events: Int,
val newestEventAt: Long?,
val contacts: Int,
val marmotGroups: Int,
val marmotMessages: Int,
val keyPackage: Boolean,
val concordCommunities: Int,
val cashuWallet: Boolean,
val dmCursorAt: Long?,
val marmotGroupCursors: Int,
) {
/** True when this account has nothing but its key — the fresh-`init` state. */
val isEmpty: Boolean
get() =
follows == 0 && relays == 0 && dmRelays == 0 && events == 0 && contacts == 0 &&
marmotGroups == 0 && !keyPackage && concordCommunities == 0 && !cashuWallet &&
dmCursorAt == null
fun toJson(): Map<String, Any?> =
linkedMapOf(
"follows" to follows,
"relays" to relays,
"relays_write" to relaysWrite,
"relays_read" to relaysRead,
"dm_relays" to dmRelays,
"events" to events,
"newest_event_at" to newestEventAt,
"contacts" to contacts,
"marmot_groups" to marmotGroups,
"marmot_messages" to marmotMessages,
"key_package" to keyPackage,
"concord_communities" to concordCommunities,
"cashu_wallet" to cashuWallet,
"dm_cursor_at" to dmCursorAt,
"marmot_group_cursors" to marmotGroupCursors,
)
}
/**
* How this account can sign, derived purely from the on-disk
* [IdentityFile] — never resolves the secret itself.
* - `local` — an on-device private key ([storage] says where).
* - `bunker` — a NIP-46 remote signer ([bunkerRelays] lists it).
* - `read-only` — imported from an npub/nprofile/NIP-05; cannot sign.
* - `none` — no `identity.json` at all; the directory is a shell.
* - `unreadable` — the file is there but won't parse. Kept distinct from
* `none` because the fixes are opposite: `none` wants `init`, and
* running `init` over a file amy merely failed to READ would mint a
* new key and strand the old one.
*/
data class SignerInfo(
val kind: String,
val storage: String?,
val canSign: Boolean,
val bunkerRelays: List<String>?,
)
/**
* Read every account under [rootBase], plus the selection state and the
* machine-level operator key. [store] is the shared event store, already
* open, or null when this machine has none.
*/
suspend fun overview(
rootBase: File,
store: IEventStore?,
): Overview {
val pin =
File(rootBase, DataDir.CURRENT_MARKER_NAME)
.takeIf { it.isFile }
?.readText()
?.trim()
?.ifEmpty { null }
return Overview(
root = rootBase,
currentPin = pin,
currentExists = pin != null && File(rootBase, pin).isDirectory,
accounts =
DataDir.listAccounts(rootBase).map { name ->
of(File(rootBase, name), name, name == pin, store)
},
operator =
OperatorKeys.peek(rootBase)?.let { cfg ->
Operator(
pubkey = cfg.masterPubKey,
npub = runCatching { NPub.create(cfg.masterPubKey) }.getOrElse { cfg.masterPubKey },
relays = cfg.relays,
)
},
)
}
/**
* Read one account rooted at [accountRoot]. [store] is the shared event
* store, already open, or null when this machine has none — in which
* case the store-backed fields simply come back empty.
*/
suspend fun of(
accountRoot: File,
name: String,
isCurrent: Boolean,
store: IEventStore?,
): AccountReport {
val identityFile = File(accountRoot, "identity.json")
val identity = readIdentity(identityFile)
val pubkey = identity?.pubKeyHex
val runState = readRunState(File(accountRoot, "state.json"))
// Everything the store can answer about this pubkey, in one hop.
val own = if (pubkey != null && store != null) ownEvents(store, pubkey) else OwnEvents()
return AccountReport(
name = name,
isCurrent = isCurrent,
npub = identity?.npub,
pubkey = pubkey,
profileName = own.profile?.bestName()?.takeIf { it.isNotBlank() },
nip05 = own.profile?.nip05?.takeIf { it.isNotBlank() },
signer = classifySigner(identity, identityFile.isFile),
saved =
Saved(
follows = own.follows,
relays = own.relays,
relaysWrite = own.relaysWrite,
relaysRead = own.relaysRead,
dmRelays = own.dmRelays,
events = own.count,
newestEventAt = own.newestAt,
contacts = contactCount(File(accountRoot, "aliases.json"), identity?.npub),
marmotGroups = marmotFiles(accountRoot, ".state").size,
marmotMessages = marmotFiles(accountRoot, ".messages").sumOf { countLines(it) },
keyPackage = File(accountRoot, "marmot/keypackages.bundle").isFile,
concordCommunities = ConcordStore(File(accountRoot, "concord.json")).load().size,
cashuWallet = own.hasWallet,
dmCursorAt = runState.giftWrapSince,
marmotGroupCursors = runState.groupSince.size,
),
)
}
/** What the shared store holds for one pubkey. All-empty when it holds nothing. */
private data class OwnEvents(
val count: Int = 0,
val newestAt: Long? = null,
val profile: UserMetadata? = null,
val hasWallet: Boolean = false,
val follows: Int = 0,
val relays: Int = 0,
val relaysWrite: Int = 0,
val relaysRead: Int = 0,
val dmRelays: Int = 0,
)
/**
* The store-backed facts about an account: how many of its events we
* hold, when the newest one is from, and the replaceables that say who
* it is (kind 0) and whether it has a wallet (kind 17375). A store that
* errors — locked by a concurrent writer, mid-migration — degrades the
* whole group to empty rather than failing the command; the on-disk half
* of the report is still worth printing.
*/
private suspend fun ownEvents(
store: IEventStore,
pubkey: String,
): OwnEvents =
runCatching {
val authors = listOf(pubkey)
var newestAt: Long? = null
var profile: MetadataEvent? = null
var contacts: ContactListEvent? = null
var relayList: AdvertisedRelayListEvent? = null
var dmInbox: ChatMessageRelayListEvent? = null
var wallet = false
// Newest-first with limit 1 gives the last-activity stamp.
store.query<Event>(Filter(authors = authors, limit = 1)) { newestAt = it.createdAt }
// Every replaceable that describes the account, in ONE pass —
// each is at most one row, so adding a kind here is free.
val describes =
listOf(
MetadataEvent.KIND,
ContactListEvent.KIND,
AdvertisedRelayListEvent.KIND,
ChatMessageRelayListEvent.KIND,
CashuWalletEvent.KIND,
)
store.query<Event>(Filter(authors = authors, kinds = describes)) { event ->
when (event) {
// The store supersedes replaceables, so each of these is
// normally a single row — keep the newest anyway.
is MetadataEvent -> if (isNewer(event, profile)) profile = event
is ContactListEvent -> if (isNewer(event, contacts)) contacts = event
is AdvertisedRelayListEvent -> if (isNewer(event, relayList)) relayList = event
is ChatMessageRelayListEvent -> if (isNewer(event, dmInbox)) dmInbox = event
is CashuWalletEvent -> wallet = true
else -> Unit
}
}
OwnEvents(
count = store.count(Filter(authors = authors)),
newestAt = newestAt,
profile = profile?.contactMetaData(),
hasWallet = wallet,
follows = contacts?.unverifiedFollowKeySet()?.size ?: 0,
relays = relayList?.relays()?.size ?: 0,
relaysWrite = relayList?.writeRelays()?.size ?: 0,
relaysRead = relayList?.readRelays()?.size ?: 0,
dmRelays = dmInbox?.relays()?.size ?: 0,
)
}.getOrElse { OwnEvents() }
private fun isNewer(
candidate: Event,
known: Event?,
): Boolean = known == null || candidate.createdAt > known.createdAt
/** Marmot per-group files with the given suffix, under `marmot/groups/`. */
private fun marmotFiles(
accountRoot: File,
suffix: String,
): List<File> =
File(accountRoot, "marmot/groups")
.listFiles { f -> f.name.endsWith(suffix) }
?.toList()
.orEmpty()
/**
* Lines in [file] — one decrypted group message per line, per
* `FileMarmotMessageStore`. Streams bytes rather than reading the file in,
* so a long chat history costs a scan and not its size in heap.
*/
private fun countLines(file: File): Int =
runCatching {
file.inputStream().buffered().use { input ->
var lines = 0
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
while (true) {
val read = input.read(buffer)
if (read < 0) break
for (i in 0 until read) if (buffer[i] == '\n'.code.toByte()) lines++
}
lines
}
}.getOrDefault(0)
private fun classifySigner(
identity: IdentityFile?,
fileExists: Boolean,
): SignerInfo {
if (identity == null) return SignerInfo(if (fileExists) "unreadable" else "none", null, false, null)
identity.bunker?.let { bunker ->
return SignerInfo("bunker", secretStorageLabel(identity.secret), true, bunker.relays)
}
val storage = secretStorageLabel(identity.secret)
return when {
identity.secret != null -> SignerInfo("local", storage, true, null)
// Pre-secret-store data-dirs kept the key inline; still signable.
identity.privKeyHex != null || identity.nsec != null -> SignerInfo("local", "legacy-plaintext", true, null)
else -> SignerInfo("read-only", null, false, null)
}
}
private fun secretStorageLabel(secret: IdentitySecret?): String? =
when (secret) {
is IdentitySecret.Keychain -> "keychain:${secret.backend}"
is IdentitySecret.Ncryptsec -> "ncryptsec"
is IdentitySecret.Plaintext -> "plaintext"
null -> null
}
/**
* Address-book entries, minus the self-alias `init` writes so the user
* can name their own account. Counting that one made every brand-new
* account claim to have saved a contact.
*/
private fun contactCount(
file: File,
ownNpub: String?,
): Int = readAliases(file).count { (_, npub) -> npub != ownNpub }
private fun readIdentity(file: File): IdentityFile? = if (file.isFile) runCatching { Output.mapper.readValue<IdentityFile>(file.readText()) }.getOrNull() else null
private fun readAliases(file: File): Map<String, String> = if (file.isFile) runCatching { Output.mapper.readValue<Map<String, String>>(file.readText()) }.getOrElse { emptyMap() } else emptyMap()
private fun readRunState(file: File): RunState = if (file.isFile) runCatching { Output.mapper.readValue<RunState>(file.readText()) }.getOrElse { RunState() } else RunState()
}
@@ -0,0 +1,251 @@
/*
* 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.Ansi
import com.vitorpamplona.amethyst.cli.Output
/**
* The terminal rendering of [StatusCommand] — the one command whose answer
* the generic key/value renderer buries.
*
* One block per account — three lines of identity, then one line per
* thing the account has saved:
*
* ```
* alice (current)
* Alice Jones · alice@example.com
* npub1hje47kz5qeneyqrxc9nzgmz06ml6l9lguqv0qtsz4rkwqkmf636qvg4sz3
* local key, in the login keychain
* saved: 312 follows
* 5 relays (4 write, 3 read)
* DM inbox on 2 relays
* 128 events (newest 2h ago)
* 3 contacts
* ```
*
* A `No account selected` warning leads the whole report when amy cannot
* resolve an account on its own; the footer carries the account count and
* the machine-level operator key.
*
* The footprint gets a line per item rather than one `·`-joined run: a
* busy account lists six or seven of them, and a column of short lines
* scans in one pass where a wrapped sentence does not.
*
* The rule that keeps it short: **absent is silent**. A profile the store
* hasn't seen, a footprint an account doesn't have — those lines simply
* don't print, instead of printing as `(none)` / `no` / `0`. Only the npub
* and the signer line are unconditional, because those two ARE the status.
*
* The `--json` shape is the public contract and lives in [StatusReport];
* this file is free to change with taste.
*/
internal object StatusText {
private const val DOT = " · "
/** Hanging indent for the `saved:` list — the visible width of `" saved: "`. */
private val SAVED_INDENT = " ".repeat(" saved: ".length)
fun render(
overview: StatusReport.Overview,
color: Ansi,
): String {
if (overview.accounts.isEmpty()) {
return buildString {
append(color.dim("No accounts under ${overview.root.absolutePath}"))
append("\n\n")
append("Create one with `amy --account <name> init`")
}
}
val out = StringBuilder()
// Leads, because it's the reason the next command is about to fail.
// No trailing blank line — the account loop below adds the separator.
selectionWarning(overview, color)?.let { out.append(it).append('\n') }
for (account in overview.accounts) {
if (out.isNotEmpty()) out.append('\n')
appendAccount(out, account, color)
}
out.append('\n').append(footer(overview, color))
return out.toString()
}
/**
* The one thing status can say that no other command will: *no account is
* selected*. Every verb but `use` and `status` resolves an account first
* and dies with "pins 'x' but … doesn't exist" or "multiple accounts …
* pick one" — so the command you run to diagnose that has to name it.
* Null when amy can resolve an account on its own (a good pin, or exactly
* one account).
*/
private fun selectionWarning(
overview: StatusReport.Overview,
color: Ansi,
): String? {
if (!overview.selectionBroken) return null
val detail =
if (overview.currentPin != null) {
"the `current` pin names '${overview.currentPin}', which no longer exists"
} else {
"${overview.accounts.size} accounts and no pin"
}
return color.yellow("No account selected") +
" — $detail. Every command needs `--account <name>` until you run `amy use <name>`."
}
private fun appendAccount(
out: StringBuilder,
account: StatusReport.AccountReport,
color: Ansi,
) {
out.append(color.bold(account.name))
if (account.isCurrent) out.append(' ').append(color.green("(current)"))
out.append('\n')
// Line 1 — the human, when the local store knows one. A profile amy
// has never fetched simply doesn't get a line.
val who = listOfNotNull(account.profileName, account.nip05)
if (who.isNotEmpty()) out.append(" ").append(who.joinToString(DOT)).append('\n')
// Line 2 — the identifier, whenever there is one to print.
account.npub?.let { out.append(" ").append(it).append('\n') }
// Line 3 — can this account act, and with what key. For a directory
// with no usable identity.json this line IS the whole story, which is
// why the npub above and the footprint below both drop out.
out.append(" ").append(signerLine(account.name, account.signer, color)).append('\n')
if (account.npub == null) return
// Then the footprint — one line per item, hanging off the label — or
// one honest word when there isn't one.
val label = " " + color.dim("saved:") + " "
if (account.saved.isEmpty) {
out.append(label).append(color.dim("nothing yet")).append('\n')
} else {
savedParts(account.saved).forEachIndexed { i, part ->
out.append(if (i == 0) label else SAVED_INDENT).append(part).append('\n')
}
}
}
/**
* Plain-English "how do I sign", because `signer: local` +
* `key_storage: keychain:login` + `can_sign: yes` on three lines was
* three facts where users only ever wanted one sentence.
*/
private fun signerLine(
name: String,
signer: StatusReport.SignerInfo,
color: Ansi,
): String =
when (signer.kind) {
"local" ->
when (val storage = signer.storage) {
"ncryptsec" -> "local key, passphrase-encrypted"
// Worth a warning colour: the key is readable by anything
// running as this user.
"plaintext" -> "local key, " + color.yellow("stored unencrypted")
"legacy-plaintext" -> "local key, " + color.yellow("stored unencrypted") + " (pre-secret-store format)"
null -> "local key"
else -> "local key, in the ${storage.removePrefix("keychain:")} keychain"
}
"bunker" -> {
val via = signer.bunkerRelays?.firstOrNull()
val more = (signer.bunkerRelays?.size ?: 0) - 1
val suffix =
when {
via == null -> ""
more > 0 -> " via $via (+$more more)"
else -> " via $via"
}
"remote signer (NIP-46)$suffix"
}
"read-only" -> color.dim("read-only — public key only, cannot sign")
"unreadable" -> color.red("identity.json is unreadable — check the file before re-running `init`")
// No identity.json at all: the directory exists but `init` never ran.
else -> color.red("no identity — run `amy --account $name init`")
}
/**
* The footprint, most-interesting first, absent items omitted. One
* self-contained phrase per entry, since each gets its own line.
*/
private fun savedParts(saved: StatusReport.Saved): List<String> {
val parts = mutableListOf<String>()
// The two numbers that define a nostr account come first.
if (saved.follows > 0) parts += plural(saved.follows, "follow")
if (saved.relays > 0) parts += relayLine(saved)
if (saved.dmRelays > 0) parts += "DM inbox on ${plural(saved.dmRelays, "relay")}"
if (saved.events > 0) {
val age = saved.newestEventAt?.let { " (newest ${ago(it)})" } ?: ""
parts += "${plural(saved.events, "event")}$age"
}
if (saved.contacts > 0) parts += plural(saved.contacts, "contact")
if (saved.marmotGroups > 0) {
val messages = if (saved.marmotMessages > 0) ", ${plural(saved.marmotMessages, "message")}" else ""
parts += plural(saved.marmotGroups, "Marmot group") + messages
}
// NOT "published": keypackages.bundle is the local private MLS
// material. The kind:30443 announcement is a separate thing.
if (saved.keyPackage) parts += "a Marmot key package"
if (saved.concordCommunities > 0) parts += plural(saved.concordCommunities, "Concord community", "Concord communities")
if (saved.cashuWallet) parts += "a Cashu wallet"
if (saved.dmCursorAt != null) parts += "DMs synced ${ago(saved.dmCursorAt)}"
return parts
}
/**
* `5 relays (4 write, 3 read)`. The breakdown is dropped when every relay
* is advertised for both directions — NIP-65's bare `r` tag — because
* then it just restates the total twice.
*/
private fun relayLine(saved: StatusReport.Saved): String {
val total = plural(saved.relays, "relay")
val bothWays = saved.relaysWrite == saved.relays && saved.relaysRead == saved.relays
return if (bothWays) total else "$total (${saved.relaysWrite} write, ${saved.relaysRead} read)"
}
private fun footer(
overview: StatusReport.Overview,
color: Ansi,
): String {
val head = "${plural(overview.accounts.size, "account")} under ${overview.root.absolutePath}"
// Only nag about switching when there is somewhere to switch to, and
// not when the warning above already said it louder.
val hint = if (overview.accounts.size > 1 && !overview.selectionBroken) DOT + "switch with `amy use <name>`" else ""
val operator =
overview.operator?.let {
val relays = if (it.relays.isEmpty()) "no relays set" else plural(it.relays.size, "relay")
"\nGrapeRank operator key ${it.npub} ($relays)"
} ?: ""
return color.dim(head + hint + operator)
}
private fun ago(epochSeconds: Long): String = Output.relativeTime(System.currentTimeMillis() / 1000 - epochSeconds)
private fun plural(
n: Int,
singular: String,
plural: String = singular + "s",
): String = if (n == 1) "$n $singular" else "$n $plural"
}
@@ -24,16 +24,14 @@ import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.amethyst.cli.StoreBackend
import com.vitorpamplona.amethyst.cli.StoreFactory
import com.vitorpamplona.amethyst.cli.StoreStats
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.nip01Core.store.fs.FsEventStore
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
import java.io.File
import java.io.IOException
import java.nio.file.Files
import java.nio.file.Path
import java.util.concurrent.TimeUnit
import kotlin.io.path.exists
/**
* `amy store <stat|sweep-expired|scrub|compact>` — direct introspection
@@ -134,71 +132,22 @@ object StoreCommands {
return 0
}
/**
* FS `stat`: the full picture — event count, per-kind histogram, disk
* bytes and the mtime range — all of which are filesystem-store concepts
* read straight off the tree by [StoreStats]. A missing store walks to
* all-zero rather than erroring.
*/
private fun fsStat(dataDir: DataDir): Int {
val storeRoot = dataDir.eventsDir.toPath()
if (!storeRoot.exists()) {
val stats = StoreStats.of(dataDir.eventsDir.toPath())
Output.emit(
mapOf(
"events" to 0,
"by_kind" to emptyMap<String, Long>(),
"disk_bytes" to 0L,
"oldest_at" to null,
"newest_at" to null,
"root" to storeRoot.toAbsolutePath().toString(),
),
)
return 0
}
val eventsRoot = storeRoot.resolve("events")
var count = 0L
var oldest: Long? = null
var newest: Long? = null
if (Files.isDirectory(eventsRoot)) {
Files.walk(eventsRoot).use { stream ->
for (p in stream) {
if (!Files.isRegularFile(p)) continue
if (!p.fileName.toString().endsWith(".json")) continue
count++
val mt =
try {
Files.getLastModifiedTime(p).to(TimeUnit.SECONDS)
} catch (_: IOException) {
continue
}
val o = oldest
if (o == null || mt < o) oldest = mt
val n = newest
if (n == null || mt > n) newest = mt
}
}
}
// Histogram from idx/kind/<k>/ — for a healthy store this is
// exactly one entry per (kind, event), so summing matches `count`.
// Mismatch points at index drift; run `amy store scrub` to fix.
val kindRoot = storeRoot.resolve("idx/kind")
val byKind = sortedMapOf<String, Long>()
if (Files.isDirectory(kindRoot)) {
Files.list(kindRoot).use { stream ->
for (kindDir in stream) {
if (!Files.isDirectory(kindDir)) continue
val n = Files.list(kindDir).use { it.count() }
byKind[kindDir.fileName.toString()] = n
}
}
}
val diskBytes = walkSize(storeRoot)
Output.emit(
mapOf(
"events" to count,
"by_kind" to byKind,
"disk_bytes" to diskBytes,
"oldest_at" to oldest,
"newest_at" to newest,
"root" to storeRoot.toAbsolutePath().toString(),
"events" to stats.events,
"by_kind" to stats.byKind,
"disk_bytes" to stats.diskBytes,
"oldest_at" to stats.oldestAt,
"newest_at" to stats.newestAt,
"root" to stats.root.toString(),
),
)
return 0
@@ -313,23 +262,6 @@ object StoreCommands {
}
}
private fun walkSize(root: Path): Long {
if (!Files.exists(root)) return 0L
var total = 0L
Files.walk(root).use { stream ->
for (p in stream) {
if (!Files.isRegularFile(p)) continue
total +=
try {
Files.size(p)
} catch (_: IOException) {
0L
}
}
}
return total
}
private fun countEntries(dir: Path): Long {
if (!Files.isDirectory(dir)) return 0L
return Files.list(dir).use { it.count() }
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.cli
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.PrintStream
import java.nio.file.Files
@@ -38,13 +39,39 @@ data class CliResult(
val stdoutLines: List<String> get() = stdout.trim().lines().filter { it.isNotBlank() }
}
/**
* The `~/.amy` parent [withSharedAmyHome] pins, or null when each [amy] call
* should mint (and delete) its own. Not thread-safe by design — the JVM suite
* runs these serially, and so does the `amy.home` property they both drive.
*/
private var sharedHome: File? = null
/**
* Run [body] with every [amy] call inside it sharing ONE isolated `~/.amy`,
* so state written by one invocation is visible to the next. Needed by the
* commands whose whole job is to report accumulated on-disk state — `status`
* has nothing to say about accounts a previous, discarded home created.
*/
fun <T> withSharedAmyHome(body: (File) -> T): T {
val previous = sharedHome
val home = Files.createTempDirectory("amy-test-shared").toFile()
sharedHome = home
return try {
body(home)
} finally {
sharedHome = previous
home.deleteRecursively()
}
}
fun amy(vararg argv: String): CliResult {
val outBuf = ByteArrayOutputStream()
val errBuf = ByteArrayOutputStream()
val prevOut = System.out
val prevErr = System.err
val prevHome = System.getProperty("amy.home")
val tempHome = Files.createTempDirectory("amy-test").toFile()
val shared = sharedHome
val tempHome = shared ?: Files.createTempDirectory("amy-test").toFile()
System.setProperty("amy.home", tempHome.absolutePath)
Output.mode = Output.Mode.TEXT
return try {
@@ -57,6 +84,6 @@ fun amy(vararg argv: String): CliResult {
System.setErr(prevErr)
Output.mode = Output.Mode.TEXT
if (prevHome == null) System.clearProperty("amy.home") else System.setProperty("amy.home", prevHome)
tempHome.deleteRecursively()
if (shared == null) tempHome.deleteRecursively()
}
}
@@ -0,0 +1,217 @@
/*
* 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.JsonNode
import java.io.File
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
/**
* `amy status` answers two questions — who is signed in, and what each of
* them has saved — so these pin both: the `--json` contract that carries the
* answers, and the promises that make the command safe to run blind (works
* with zero or many accounts, never prompts, never writes).
*
* The text rendering is deliberately NOT asserted on beyond "the answer is
* in there": per `cli/DEVELOPMENT.md` only the JSON shape is public API.
*/
class StatusCommandTest {
private fun statusJson(): JsonNode {
val r = amy("--json", "status")
assertEquals(0, r.exit, "status should always exit 0, stderr: ${r.stderr}")
assertEquals(1, r.stdoutLines.size, "expected exactly one stdout line, got: ${r.stdout}")
return Output.mapper.readTree(r.stdoutLines.single())
}
private fun initAccount(name: String) {
val r = amy("--secret-backend", "plaintext", "--account", name, "init")
assertEquals(0, r.exit, "init $name failed: ${r.stderr}")
}
private fun accountNamed(
json: JsonNode,
name: String,
): JsonNode = assertNotNull(json["accounts"].firstOrNull { it["name"].asText() == name }, "no account '$name' in $json")
@Test
fun reportsNoAccountsWithoutFailing() =
withSharedAmyHome {
val json = statusJson()
assertTrue(json["accounts"].isEmpty, "a fresh machine has no accounts: $json")
assertTrue(json["current"].isNull, "nothing can be current when nothing exists: $json")
}
/**
* The read-only promise. `status` is the command a confused user runs
* first, so it must not leave an event store (or anything else) behind on
* a machine that has none.
*/
@Test
fun writesNothingToDisk() =
withSharedAmyHome { home ->
assertEquals(0, amy("--json", "status").exit)
assertFalse(File(home, ".amy").exists(), "status must not create ~/.amy")
}
@Test
fun namesEveryAccountAndMarksTheCurrentOne() =
withSharedAmyHome {
initAccount("alice")
initAccount("bob")
assertEquals(0, amy("use", "alice").exit)
val json = statusJson()
assertEquals("alice", json["current"].asText())
assertEquals(listOf("alice", "bob"), json["accounts"].map { it["name"].asText() })
assertTrue(accountNamed(json, "alice")["current"].asBoolean())
assertFalse(accountNamed(json, "bob")["current"].asBoolean())
}
@Test
fun reportsHowEachAccountSigns() =
withSharedAmyHome {
initAccount("alice")
val alice = accountNamed(statusJson(), "alice")
assertEquals("local", alice["signer"].asText())
assertEquals("plaintext", alice["key_storage"].asText())
assertTrue(alice["can_sign"].asBoolean())
assertTrue(alice["npub"].asText().startsWith("npub1"))
assertEquals(64, alice["pubkey"].asText().length)
}
/**
* A brand-new account has saved nothing — including no contacts. `init`
* writes a self-alias so you can name your own account; counting it made
* every fresh account claim an address book it doesn't have.
*/
@Test
fun freshAccountHasSavedNothing() =
withSharedAmyHome {
initAccount("alice")
val saved = accountNamed(statusJson(), "alice")["saved"]
assertEquals(0, saved["contacts"].asInt(), "the self-alias is not a saved contact")
assertEquals(0, saved["events"].asInt())
assertEquals(0, saved["follows"].asInt())
assertEquals(0, saved["relays"].asInt())
assertEquals(0, saved["dm_relays"].asInt())
assertEquals(0, saved["marmot_groups"].asInt())
assertEquals(0, saved["marmot_messages"].asInt())
assertEquals(0, saved["concord_communities"].asInt())
assertFalse(saved["key_package"].asBoolean())
assertFalse(saved["cashu_wallet"].asBoolean())
assertTrue(saved["dm_cursor_at"].isNull)
val text = amy("status").stdout
assertTrue(text.contains("nothing yet"), "text should say so plainly: $text")
}
@Test
fun countsSavedContactsOnceTheAddressBookGrows() =
withSharedAmyHome { home ->
initAccount("alice")
val aliases = File(home, ".amy/alice/aliases.json")
val map = Output.mapper.readValue(aliases, Map::class.java).toMutableMap()
map["bob"] = "npub1ngs0702f9mzph9j8csd22vj8ycg97q3jxzcs4qvdymmmet079nqsr4f60n"
aliases.writeText(Output.mapper.writeValueAsString(map))
assertEquals(1, accountNamed(statusJson(), "alice")["saved"]["contacts"].asInt())
}
/**
* The selection state is the thing only `status` can report. Every other
* verb resolves an account first and dies; a user with several accounts
* and no pin needs to be told that, not left to read a list.
*/
@Test
fun warnsWhenSeveralAccountsAndNoPin() =
withSharedAmyHome {
initAccount("alice")
initAccount("bob")
val json = statusJson()
assertTrue(json["current"].isNull)
assertFalse(json["current_exists"].asBoolean())
assertTrue(amy("status").stdout.contains("No account selected"), "status should say nothing is selected")
}
/**
* A pin left behind by a deleted account makes every other verb fail with
* "pins 'x' but … doesn't exist". Status has to name the same cause.
*/
@Test
fun warnsWhenTheCurrentPinIsStale() =
withSharedAmyHome { home ->
initAccount("alice")
File(home, ".amy/current").writeText("ghost")
val json = statusJson()
assertEquals("ghost", json["current"].asText())
assertFalse(json["current_exists"].asBoolean(), "the pinned directory is gone")
val text = amy("status").stdout
assertTrue(text.contains("No account selected"), "status should flag the broken pin: $text")
assertTrue(text.contains("ghost"), "status should name the dangling pin: $text")
}
/** A single account, or a good pin, resolves on its own — no nagging. */
@Test
fun staysQuietWhenAnAccountResolves() =
withSharedAmyHome {
initAccount("alice")
assertTrue(statusJson()["current"].isNull, "one account needs no pin")
assertFalse(amy("status").stdout.contains("No account selected"))
initAccount("bob")
assertEquals(0, amy("use", "bob").exit)
assertTrue(statusJson()["current_exists"].asBoolean())
assertFalse(amy("status").stdout.contains("No account selected"), "a good pin is not a warning")
}
/** The machine-level operator key is reported, and absent by default. */
@Test
fun reportsNoOperatorKeyUntilOneExists() =
withSharedAmyHome {
initAccount("alice")
assertTrue(statusJson()["operator"].isNull, "graperank was never run")
assertFalse(amy("status").stdout.contains("operator"))
}
/** Text mode has no shape contract, but it must still carry the answers. */
@Test
fun textModeCarriesTheSameAnswers() =
withSharedAmyHome {
initAccount("alice")
initAccount("bob")
assertEquals(0, amy("use", "bob").exit)
val json = statusJson()
val text = amy("status").stdout
assertTrue(text.contains("alice"), "text should name every account: $text")
assertTrue(text.contains(accountNamed(json, "bob")["npub"].asText()), "text should print the npub: $text")
assertTrue(text.contains("current"), "text should mark the active account: $text")
}
}