From 076859ca2dc81906bed52d76f997c8a208ee2fe3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Apr 2026 14:32:19 +0000 Subject: [PATCH] feat(cli): introduce ~/.amy account-mode layout + --name flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Default on-disk layout becomes: ~/.amy/ ├── shared/ │ └── events-store/ (lazy: created on first event) └── / (created by `amy --name X init`) ├── identity.json ├── state.json ├── aliases.json └── marmot/ Per-account state moves under `~/.amy//`; the events-store is shared across accounts under `~/.amy/shared/`. The shared store is safe to share for now because amy doesn't currently persist any decrypted inner events to it (NIP-17 DMs unwrap-and-display in DmCommands; MLS inner events go to the per-group .log under /marmot/groups/, not the event store). When that changes, a follow-up will introduce a per-account private-events-store and a composite reader. A new `--name X` global flag selects (or creates) `~/.amy/X/`. `--data-dir P` is preserved as a self-contained escape hatch — the test harness and ad-hoc throwaway dirs use it, events-store stays inside P. Pass exactly one of `--name` or `--data-dir`; passing both or neither is bad_args (exit 2). Names must match [a-zA-Z0-9_-]{1,64}; `shared` is reserved. `amy init --name alice` writes a self-entry into `/aliases.json` ({"alice":"npub1…"}) so future commands and the planned `amy alias add` / dm-recipient resolver can refer to the account by name. The init result map gains a `name` key (null in legacy mode). Open question for a follow-up: when only one account exists in ~/.amy/, should `amy whoami` work without --name? Today it doesn't — strict mode. Also pending: README rewrite, plus a sweep through commands that print `data_dir` to also surface `name` where useful. --- .../com/vitorpamplona/amethyst/cli/Aliases.kt | 53 +++++++++++ .../com/vitorpamplona/amethyst/cli/Config.kt | 89 +++++++++++++++++-- .../com/vitorpamplona/amethyst/cli/Main.kt | 15 +++- .../amethyst/cli/commands/InitCommands.kt | 10 +++ 4 files changed, 156 insertions(+), 11 deletions(-) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Aliases.kt diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Aliases.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Aliases.kt new file mode 100644 index 0000000000..59a55e5d9a --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Aliases.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli + +import com.fasterxml.jackson.module.kotlin.readValue + +/** + * Per-account `aliases.json` — short human-friendly names that map to + * npubs. Today it's only populated by `init --name X` (a self-entry so + * the user can refer to their own account by name); future verbs like + * `amy alias add bob npub1…` and recipient resolution in `dm send` will + * read from the same file. + * + * Shape on disk: a JSON object of `{name: npub}` pairs. We persist the + * npub form (not hex) so `cat aliases.json` is human-inspectable. + */ +object Aliases { + /** Read the alias map; returns empty when the file doesn't exist. */ + fun load(dataDir: DataDir): MutableMap { + val f = dataDir.aliasesFile + if (!f.exists()) return linkedMapOf() + return Output.mapper.readValue(f.readText()) + } + + /** Upsert one entry. Idempotent. */ + fun set( + dataDir: DataDir, + name: String, + npub: String, + ) { + val map = load(dataDir) + map[name] = npub + SecureFileIO.writeTextAtomic(dataDir.aliasesFile, Output.mapper.writeValueAsString(map)) + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt index 504103bcdf..f0cd851061 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt @@ -110,8 +110,21 @@ data class RunState( ) /** - * Root of the on-disk layout. Any absolute path chosen by `--data-dir` (or - * `$AMETHYST_CLI_DATA`) — defaults to `./amy`. + * Root of the on-disk layout for one account. + * + * Two construction modes: + * + * 1. **Account mode** (`--name X`): the canonical layout. Per-account + * state (identity, sync cursors, MLS material, aliases) lives at + * `//`; the public event store is shared across + * accounts at `/shared/events-store/`. `` defaults to + * `~/.amy/`. + * 2. **Self-contained mode** (`--data-dir P`): everything — including + * the event store — lives under `P`. Used by the test harness and + * ad-hoc throw-away dirs that don't want to touch a shared root. + * + * Use [resolve] to construct one from CLI flags; pass exactly one of + * `--name` or `--data-dir`. * * [secrets] is the [SecretStore] that mediates private-key persistence. * Owning it here keeps the call sites that already thread [DataDir] from @@ -119,17 +132,17 @@ data class RunState( */ class DataDir( val root: File, + val eventsDir: File, + val accountName: String?, val secrets: SecretStore, ) { val identityFile = File(root, "identity.json") val stateFile = File(root, "state.json") + val aliasesFile = File(root, "aliases.json") val marmotDir = File(root, "marmot") val groupsDir = File(marmotDir, "groups") val keyPackageBundleFile = File(marmotDir, "keypackages.bundle") - /** Root of the file-backed Nostr event store (`FsEventStore`). */ - val eventsDir = File(root, "events-store") - init { SecureFileIO.secureMkdirs(root) SecureFileIO.secureMkdirs(groupsDir) @@ -200,13 +213,71 @@ class DataDir( } companion object { + /** Per-user root under which `shared/` and `/` live. */ + val DEFAULT_ROOT: File get() = File(System.getProperty("user.home"), ".amy") + + /** + * Account names become directory names AND alias keys, so we + * keep them to a portable, shell-friendly subset. `shared` is + * reserved for the cross-account events-store sibling. + */ + private val NAME_REGEX = Regex("^[a-zA-Z0-9_-]{1,64}$") + private const val SHARED_DIR_NAME = "shared" + + fun validateName(name: String): String { + require(NAME_REGEX.matches(name)) { + "--name must match [a-zA-Z0-9_-]{1,64} (got '$name')" + } + require(name != SHARED_DIR_NAME) { + "--name must not be '$SHARED_DIR_NAME' (reserved for the shared events-store)" + } + return name + } + + /** + * Build a [DataDir] from the parsed CLI flags. Exactly one of + * `dataDirFlag` or `nameFlag` must be set; passing both is an + * `IllegalArgumentException` (caught by `main` as exit 2). + */ fun resolve( - flag: String?, + dataDirFlag: String?, + nameFlag: String?, secrets: SecretStore, ): DataDir { - val envPath = System.getenv("AMETHYST_CLI_DATA") - val path = flag ?: envPath ?: "./amy" - return DataDir(File(path).absoluteFile, secrets) + require(!(dataDirFlag != null && nameFlag != null)) { + "pass either --data-dir or --name, not both" + } + return when { + dataDirFlag != null -> { + val root = File(dataDirFlag).absoluteFile + DataDir( + root = root, + eventsDir = File(root, "events-store"), + accountName = null, + secrets = secrets, + ) + } + + nameFlag != null -> { + val name = validateName(nameFlag) + val rootBase = DEFAULT_ROOT + val accountRoot = File(rootBase, name).absoluteFile + val sharedEvents = File(rootBase, "$SHARED_DIR_NAME/events-store").absoluteFile + DataDir( + root = accountRoot, + eventsDir = sharedEvents, + accountName = name, + secrets = secrets, + ) + } + + else -> { + throw IllegalArgumentException( + "missing account selector: pass --name (creates ${DEFAULT_ROOT.absolutePath}//) " + + "or --data-dir for a self-contained dir", + ) + } + } } } } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index c0e79b5666..f139317aef 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -83,6 +83,7 @@ private suspend fun dispatch(argv: Array): Int { // only their own args. val filteredArgs = mutableListOf() var dataDirFlag: String? = null + var nameFlag: String? = null var secretBackendFlag: String? = null var passphraseFileFlag: String? = null var i = 0 @@ -91,6 +92,7 @@ private suspend fun dispatch(argv: Array): Int { val (matched, consumed) = extractGlobalFlag(a, argv, i) when (matched) { GlobalFlag.DATA_DIR -> dataDirFlag = consumed.value + GlobalFlag.NAME -> nameFlag = consumed.value GlobalFlag.SECRET_BACKEND -> secretBackendFlag = consumed.value GlobalFlag.PASSPHRASE_FILE -> passphraseFileFlag = consumed.value GlobalFlag.JSON -> Output.mode = Output.Mode.JSON @@ -104,7 +106,7 @@ private suspend fun dispatch(argv: Array): Int { } val secrets = SecretStore.from(backendFlag = secretBackendFlag, passphraseFile = passphraseFileFlag) - val dataDir = DataDir.resolve(dataDirFlag, secrets) + val dataDir = DataDir.resolve(dataDirFlag = dataDirFlag, nameFlag = nameFlag, secrets = secrets) val head = filteredArgs[0] val tail = filteredArgs.drop(1).toTypedArray() @@ -201,6 +203,7 @@ private enum class GlobalFlag( val takesValue: Boolean = true, ) { DATA_DIR("--data-dir"), + NAME("--name"), SECRET_BACKEND("--secret-backend"), PASSPHRASE_FILE("--passphrase-file"), JSON("--json", takesValue = false), @@ -243,12 +246,20 @@ private fun printUsage() { |amy — Amethyst command-line interface | |Usage: - | amy [--data-dir PATH] + | amy [--name ACCOUNT] (canonical: per-account dir under ~/.amy/) + | [--data-dir PATH] (escape hatch: self-contained dir at PATH) | [--secret-backend auto|keychain|ncryptsec|plaintext] | [--passphrase-file PATH] | [--json] | [args...] | + |Account selection: + | Default layout lives at ~/.amy/, with shared/events-store/ holding + | every observed Nostr event and ~/.amy// holding identity, + | cursors, MLS state, and aliases. Pass exactly one of --name or + | --data-dir; passing both or neither is a bad-args error. ACCOUNT + | must match [a-zA-Z0-9_-]{1,64} (no spaces, no slashes). + | |Output: | Default: human-readable text on stdout. | --json: one JSON object per success on stdout, JSON diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/InitCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/InitCommands.kt index 820133f6f0..db53b488a8 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/InitCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/InitCommands.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.cli.commands +import com.vitorpamplona.amethyst.cli.Aliases import com.vitorpamplona.amethyst.cli.Args import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Identity @@ -34,8 +35,12 @@ object InitCommands { // would trigger a keychain prompt / passphrase dialog even though the // caller clearly already has the identity set up. dataDir.loadIdentityFileOrNull()?.let { existing -> + // Idempotent self-alias upsert when --name was passed and the + // dir already exists (e.g. user re-runs `init --name alice`). + dataDir.accountName?.let { Aliases.set(dataDir, it, existing.npub) } Output.emit( mapOf( + "name" to dataDir.accountName, "npub" to existing.npub, "hex" to existing.pubKeyHex, "nsec" to null, @@ -48,8 +53,13 @@ object InitCommands { val nsec = args.flag("nsec") val created = if (nsec != null) Identity.fromNsec(nsec) else Identity.create() dataDir.saveIdentity(created) + // Self-alias: in account mode, record ` -> own npub` so the + // user can refer to their own account by name in scripts and (once + // the resolver lands) in recipient slots like `dm send`. + dataDir.accountName?.let { Aliases.set(dataDir, it, created.npub) } Output.emit( mapOf( + "name" to dataDir.accountName, "npub" to created.npub, "hex" to created.pubKeyHex, "nsec" to created.nsec,