feat(cli): introduce ~/.amy account-mode layout + --name flag

Default on-disk layout becomes:

    ~/.amy/
    ├── shared/
    │   └── events-store/        (lazy: created on first event)
    └── <account>/               (created by `amy --name X init`)
        ├── identity.json
        ├── state.json
        ├── aliases.json
        └── marmot/

Per-account state moves under `~/.amy/<account>/`; 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
<account>/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
`<account>/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.
This commit is contained in:
Claude
2026-04-25 14:32:19 +00:00
parent cd0b43afd9
commit 076859ca2d
4 changed files with 156 additions and 11 deletions
@@ -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<String, String> {
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))
}
}
@@ -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
* `<root>/<name>/`; the public event store is shared across
* accounts at `<root>/shared/events-store/`. `<root>` 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 `<account>/` 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 <account> (creates ${DEFAULT_ROOT.absolutePath}/<account>/) " +
"or --data-dir <path> for a self-contained dir",
)
}
}
}
}
}
@@ -83,6 +83,7 @@ private suspend fun dispatch(argv: Array<String>): Int {
// only their own args.
val filteredArgs = mutableListOf<String>()
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<String>): 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<String>): 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]
| <cmd> [args...]
|
|Account selection:
| Default layout lives at ~/.amy/, with shared/events-store/ holding
| every observed Nostr event and ~/.amy/<account>/ 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
@@ -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 `<name> -> 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,