Commit Graph
44 Commits
Author SHA1 Message Date
Vitor PamplonaandGitHub 325eb3ccfe Merge pull request #3492 from vitorpamplona/claude/amethyst-status-command-yf5be6
Add `amy status` command for cross-account disk overview
2026-07-07 19:05:26 -04:00
Claude 439b85a8e1 feat(cli): let read-only verbs run without an account
amy gated every non-primitive verb behind a chosen account: `DataDir.resolve`
threw when `~/.amy/` had no unambiguous account, and every networked command
called `Context.open`, which requires an identity — even though queries only
read relays and the shared store and never sign. `store` maintenance and the
local `offer`/`debit info` decoders were caught by the same gate despite
touching no account state.

Reads now work anonymously; only signing needs an account:

- `DataDir.resolveOptional` hands back an accountless dir (`hasAccount = false`)
  pointing only at the shared event store when there is no unambiguous account,
  instead of throwing.
- `Context.openOrAnonymous` uses the resolved account when present, else an
  ephemeral key-less `Identity.anonymous()` — can read, can't sign. Marmot
  stores are now lazy and run-state isn't persisted for anonymous runs, so an
  accountless read leaves `~/.amy/shared/` clean.
- `Context.open` (signing path) re-asserts the requirement with the "which
  account?" hint, so ambiguous/no-account signing verbs still exit 2.
- Main resolves optionally for every verb except the identity-lifecycle ones
  (`init`/`create`/`login`/`logoff`/`whoami`), which still need a concrete
  account. `offer info` / `debit info` join the stateless primitive block.
- Read subverbs (fetch, subscribe, count, publish, outbox, search, sync,
  store, profile/git/podcast/podcast20 reads, nsite/napplet fetch·serve·list,
  blossom download·check) switch to `openOrAnonymous`.

No `--json` shapes change. Docs updated (help text, README, DEVELOPMENT).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TRaoqGod5LUeSwq4GHRSNF
2026-07-07 22:58:22 +00:00
Claude 85d6f5a162 feat(cli): add amy status overview command
A cross-account, read-only snapshot of everything amy holds under
`~/.amy/`, built for the returning user: which accounts exist, which
one is pinned as current, each signer type (local keychain/ncryptsec/
plaintext, NIP-46 bunker, or read-only) and whether it can still sign,
the per-account local footprint (aliases, Marmot groups, published
KeyPackage bundle, Cashu wallet, sync cursors), and the shared event
store's size.

Like `use`, it dispatches before account resolution so it works with
zero, one, or many accounts. Strictly metadata-only: it never unlocks a
private key (no keychain prompt / NIP-49 passphrase) and never touches
the network.

Factors the on-disk event-store walk into a shared `StoreStats` helper
reused by both `status` and `store stat`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3GJb11JkvopP61ETWAVyy
2026-07-07 22:32:06 +00:00
Claude c8c4111e0c feat(cli): add amy logoff to clear an account's local data
Adds `amy logoff [--yes] [--keep-events]`, the CLI counterpart to logging
out: it removes everything an account left on the machine.

  - the identity file and any backend-held secret (keychain / ncryptsec /
    plaintext), via DataDir.deleteIdentity
  - the rest of the per-account directory ~/.amy/<account>/ (run-state
    cursors, aliases, cashu counters, all Marmot/MLS state)
  - the ~/.amy/current pin, when it points at this account
  - the account's events in the SHARED ~/.amy/shared/events-store/

The event store is shared across accounts, so logoff does not wipe it
wholesale — it deletes only the events that involve this account: those it
authored plus those addressed to it via a #p tag (gift wraps, nutzaps,
reactions, mentions). Other accounts' cached events are left untouched.
`--keep-events` skips the shared-cache purge entirely.

The public key is read straight from identity.json (never unlocking the
private key), so logoff needs no passphrase and pops no keychain prompt.
Destructive and irreversible, so it follows the `marmot reset` precedent:
`--yes` is required to execute; without it the command prints a dry run of
what would be deleted and exits 2.

Thin-assembly only — event deletion is quartz's FsEventStore.delete; this
just resolves the account, counts, and wires the filesystem teardown.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PH3rqz5KaA7CYFPAtxgoz1
2026-07-07 22:11:52 +00:00
Claude 5aae4368da refactor(cli): noun-first amy relay, outbox/inbox NIP-65 facets
Restructure `amy relay` from verb-first `relay add URL --type T` to noun-first
`relay <noun> <verb>`, matching amy's `marmot group …` / `cashu mint …`
convention. The relay-list type is now a required path segment (no implicit
default), and a bare noun lists that bucket.

NIP-65 (kind:10002) is fronted by two facet-nouns, `outbox` (write) and `inbox`
(read), replacing the `--marker` flag. They edit the single 10002 event and
apply the spec's merge rules:
- outbox add R on a read-only R  → both
- inbox  add R on a write-only R → both
- outbox remove R on a both-R    → read  (stays in inbox)
- inbox  remove R on a both-R    → write (stays in outbox)
- dropping the last facet removes R entirely
`relay nip65` shows the combined view; `nip65 remove`/`clear` edit the whole
event.

Other buckets are noun+verb: `relay dm|key-package|search|private|blocked|
trusted|proxy|indexer|broadcast|feeds <add|remove|set|clear|list>`. `set` needs
≥1 URL; `clear` empties. `relay add|remove URL` (no noun) stays as the
transport fan-out (nip65 both + dm + key-package).

BREAKING (cli --json/args): removes `relay add/remove/set --type T` and
`--marker`; `relay list` overview now keys nip65 as `outbox`/`inbox`/`nip65`
and the DM bucket as `dm` (was `inbox`). In-repo harnesses updated
(cache/dm/marmot setup drop `--type all`; cache T5 asserts `.dm`).

Verified end-to-end: merge semantics, encrypted NIP-51 round-trips, facet
set/clear, fan-out, aliases, and error paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EjHzNewJ2sfBGCcSwe35Mc
2026-07-06 15:02:28 +00:00
Claude c7bde868e1 feat(cli): require --clear to empty a relay bucket
`relay set --type T` with no URLs is now rejected (bad_args, exit 2) instead
of silently wiping the list — a bare empty `set` is almost always a shell
variable that expanded to nothing. Emptying a bucket is explicit: pass
`--clear` (mutually exclusive with URLs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EjHzNewJ2sfBGCcSwe35Mc
2026-07-06 14:30:08 +00:00
Claude 0319809b8c feat(cli): full relay-settings parity for amy relay
Expand `amy relay` from the 3 transport lists (nip65/inbox/key_package) to
every relay-list bucket Amethyst's relay-settings screen manages, and add
remove/set verbs alongside add/list.

Buckets (kind): nip65 (10002, read/write markers), inbox/dm (10050),
key_package (10051), search (10007), private (10013), blocked (10006),
trusted (10089), proxy (10087), indexer (10086), broadcast (10088),
feeds/favorites (10012). The private NIP-51 lists are signed NIP-44-encrypted
via the quartz event factories, exactly like the app. Local relays (device
pref, no event) and named relay sets (30002) are intentionally out of scope.

New/changed commands:
- `relay add URL --type T [--marker read|write|both]` — `--marker` sets the
  nip65 role; `all` still means nip65+inbox+key_package.
- `relay remove URL --type T` — new.
- `relay set --type T [URL…] [--marker …]` — new; replace a whole bucket
  (no URLs clears it).
- `relay list [--type T]` — lists every bucket, or one.
- `relay publish-lists` — now broadcasts every configured list.

Thin-assembly only: buckets are a small registry over the existing quartz
`create`/`relays` factories; adds one generic `Context.latestReplaceable`
helper. `--json` is additive — legacy keys (`nip65`/`inbox`/`key_package`,
`nip65_event_id`/…) are unchanged, so the existing test harnesses keep passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EjHzNewJ2sfBGCcSwe35Mc
2026-07-06 13:59:47 +00:00
Vitor PamplonaandClaude Opus 4.8 112ef0536a fix(cli): --server reuses NamecoinSettings.parseServerString (keeps pinned trust store)
`amy namecoin resolve --server` hand-rolled its own ElectrumX server-string
parser that constructed `ElectrumxServer(host, port, useSsl)` and left
`usePinnedTrustStore` at its `false` default. The Namecoin ElectrumX servers
use self-signed certs, so a TLS connection with the default system trust
manager fails the handshake — meaning `--server electrumx.testls.space:50002`
could not connect even though that exact host resolves fine via the default
list. It also duplicated logic already in `commons`, violating the cli
thin-assembly-layer rule.

Delegate each comma-separated entry to the shared
`NamecoinSettings.parseServerString` (the same parser the Android/Desktop
Settings use), so the CLI inherits both the `host:port[:tcp]` syntax and
`usePinnedTrustStore = true`. The README claim that it "reuses the same …
pinned trust store as the apps" is now actually true for `--server` overrides.

Also: reject a non-integer `--timeout` as bad_args instead of silently
falling back to the default, and document exit code 2 + the `host:port[:tcp]`
syntax accurately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 18:27:32 -04:00
mstrofnoneandVitor Pamplona 305d6bc733 feat(cli): amy namecoin resolve + servers verbs
Add Namecoin NIP-05 resolution to the amy CLI as a stateless verb
group, matching the Android and Desktop apps' resolution surface.

  amy namecoin resolve IDENT [--server URL[,URL]] [--timeout SECS]
  amy namecoin servers

IDENT accepts the same shapes the apps accept: raw `d/` / `id/`
names, bare `.bit` domains, and `alice@example.bit` NIP-05-style
local-parts. Output is the resolved Nostr pubkey + relay list (+ the
resolved Namecoin name + matched local-part) as machine-readable
JSON (with `--json`) or human-readable text.

The verb is stateless — no account, no `~/.amy/`, no relays — so it
dispatches alongside `decode`/`encode`/`verify`/`nip`/`kind` before
account resolution and the secret store.

Zero new logic in cli/: the implementation is a thin command-file
wrapper around quartz's existing `NamecoinNameResolver` +
`ElectrumXClient` + the canonical `DEFAULT_ELECTRUMX_SERVERS` set
the apps already ship with, including the pinned trust store for
the self-signed Namecoin ElectrumX ecosystem.

amy is headless so no UI piece is wired in. The `--server` flag
accepts `host`, `host:port`, `tcp://`, `tls://`, `ssl://` per entry
(defaults to TLS on 50002); empty / malformed entries fail with
`bad_args` rather than silently using the default set, so a fat-
fingered override can't go unnoticed.

Outcomes from `NamecoinResolveOutcome` map to amy error codes:
  Success           -> emit JSON, exit 0
  NameNotFound      -> error not_found
  NoNostrField      -> error no_nostr_field
  MalformedRecord   -> error malformed_record (+ namecoin_name extra)
  ServersUnreachable-> error servers_unreachable
  InvalidIdentifier -> error invalid_identifier
  Timeout           -> error timeout

Smoke-tested end-to-end on macOS arm64 against the live ElectrumX
fleet:

  $ amy --json namecoin resolve d/testls
  {"identifier":"d/testls","namecoin_name":"d/testls",
   "local_part":"_","pubkey":"460c25e6…","relays":[]}

  $ amy namecoin servers
  count:   6
  servers:
    - host: electrumx.testls.space
      port: 50002
      tls:  yes
    …

No new runtime deps. The "no Compose UI in the amy image" CI
assertion still passes — `NamecoinNameResolver` + `ElectrumXClient`
are pure JVM (kotlinx.coroutines + kotlinx.serialization, both
already on the CLI classpath via :quartz).

Tests: the resolver, ElectrumX client, identifier parser, and the
default server set already have JVM tests under
`quartz/src/jvmTest/.../namecoin/` — no new core code in this PR,
so the existing coverage applies. CLI verbs are exercised via the
shell harnesses in `cli/tests/`; a Namecoin harness fits the same
pattern but isn't included here.

Parity matrix in `cli/ROADMAP.md` flags `name_history` and the
Namecoin Core JSON-RPC backend as pending separate PRs — both
already exist on Android and Desktop but aren't on upstream main
yet (open PRs against this repo carry them).
2026-06-26 18:23:13 -04:00
Claude 7a3c35c645 docs: document cashu, admin, serve, fetch code mode, key validate
Update the amy docs to reflect the new command surface:

- cli/README.md — add the Cashu (NIP-60/61), Relay management (NIP-86
  admin), and Run-a-relay (serve) sections; document fetch's nip19/nip05
  code mode and key validate.
- cli/DEVELOPMENT.md — add cashu.json to the on-disk layout and pin the
  command-family --json contracts (cashu keys/error codes + pointer to the
  cashu plan, admin {relay,method,result}, serve startup object).
- .claude/skills/amy-expert/SKILL.md — extend the "where things live" tree
  with AdminCommand/ServeCommand/cashu/, the commons/cashu + relayManagement
  shared modules, and the allowed :geode dependency.
- .claude/CLAUDE.md — note cli may depend on :geode (for serve), never on
  :amethyst/:desktopApp.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-22 02:14:11 +00:00
Claude b6c065d421 feat(quartz,cli): add KindNames registry + amy kind
Introduce a canonical, i18n-free event-kind registry in quartz:
`com.vitorpamplona.quartz.kinds.KindNames` maps each of the 145 known
kinds to an English label + the defining NIP. The data is ported from
Amethyst's relay-view `kindDisplayName` mapping (the NIP derived from
each event class's package), so the "what is kind N" knowledge now lives
once in quartz instead of only in the Android UI.

`amy kind <N|NAME>` looks a kind up by number (label + NIP) or searches
labels by name — a thin wrapper over KindNames, dispatched statelessly
(no account/network).

i18n split: quartz holds the canonical English (quartz is intentionally
translation-free); localized front ends overlay their own strings and can
fall back to KindNames.nameFor() for kinds they don't translate. amy
prints the English directly.

Verified: kind 1/0/30023/1059/24133 labels+NIPs, name search ("podcast"
→ 4), unknown → known:false, text + JSON output.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-21 21:00:54 +00:00
Claude 749a301469 feat(cli): cheap nak-parity wins — key nip49, nip lookup, blossom check/mirror
- `amy key encrypt|decrypt` (NIP-49): encrypt a secret key to ncryptsec1…
  and back. Bidirectionally interop-verified vs the real nak binary
  (amy-encrypt → nak-decrypt and nak-encrypt → amy-decrypt both match).
- `amy nip N` / `amy nip list`: look up a NIP — the nostr-protocol/nips
  git repo FIRST, then a Nostr fallback (NIP-50 search over wiki kind:30818
  + long-form kind:30023 on search-capable relays). Slug normalization
  handles 1→01 and hex-suffixed NIPs (7d→7D, 5a→5A); titles parsed from the
  setext headings NIP docs use.
- `amy blossom check|mirror`: HEAD-check blobs (exit 1 if any missing, like
  nak) and request BUD-04 mirroring of a blob from a source URL.

Also persists the full introspected nak comparison into ROADMAP. `kind`
stays deferred — it needs a kind→schema registry that doesn't exist in
quartz (would be data/logic that doesn't belong in cli).

Verified: key round-trip + nak cross-impl both ways; nip repo titles for
01/46/5A/7D + Nostr fallback on miss; blossom check 200/404 + exit codes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-21 20:50:02 +00:00
Claude 2c37ae642c feat(nip46): handle auth_url challenges in the remote-signer client
Adds NIP-46 `auth_url` (web-authorization) support to quartz's remote
signer, so amy (and Amethyst) can use auth-requiring bunkers like
nsec.app / nsecbunker:

- Both BunkerResponse deserializers (kotlinx + the JVM/Android Jackson
  one actually used by OptimizedJsonMapper) now special-case
  `{result:"auth_url", error:<url>}` BEFORE the generic error branch,
  which previously flattened it to a plain error and dropped the marker.
- RemoteSignerManager gained an `onAuthUrl` callback and a wait loop: on
  an auth_url response it surfaces the URL once and keeps waiting for the
  real response under the same request id (UNLIMITED channel so both are
  buffered). newResponse peeks the pending entry (get, not remove) so the
  follow-up isn't dropped; the waiter still removes it in finally.
- NostrSignerRemote forwards `onAuthUrl`; amy's Context prints the URL to
  stderr and the pending command keeps waiting.

Tests: a deserializer test (auth_url keeps result+error) and a manager
test (auth_url surfaced via callback, then the real response resumes the
request) — both green; existing duplicate/late-response manager tests
still pass after the get-vs-remove change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-21 20:19:01 +00:00
Claude 8b7caa5b01 feat(cli): NIP-46 nostrconnect:// reverse flow (both sides)
Add the client-initiated NostrConnect flow to complete NIP-46 parity:

- `amy login --nostrconnect [--relay …] [--name N]` (client) mints a
  transport keypair, prints a nostrconnect:// offer, subscribes, and
  waits for the signer's connect ACK (a kind:24133 whose decrypted
  result echoes our secret). The ACK's author is the signer; it persists
  a bunker account acting as that key.
- `amy bunker connect nostrconnect://…` (signer) parses a client offer,
  sends the secret-echo ACK, then services that client's requests on the
  offer's relays. Shares the serve loop with `amy bunker`.

NostrConnect.kt holds the offer parse/build (+percent-decode) and the
client handshake. BunkerCommand grew a `connect` sub-mode and factored
the request loop into serve().

Verified end-to-end:
- amy client ⇄ real `nak bunker connect`: amy learns nak's key and signs;
  event authored by nak, signature valid.
- amy client ⇄ amy `bunker connect`: same, authored by the host key.

Only `auth_url` challenges remain unimplemented in the NIP-46 surface.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-21 20:04:03 +00:00
Claude 695a5b4b25 fix(cli): nak-compatible bunker — percent-encoded URIs + get_relays
Close the NIP-46 interop gap against the real nak binary:

- `Identity.fromBunkerUri` now URL-decodes the relay/secret params. nak
  emits `bunker://<pk>?relay=wss%3A%2F%2F…&secret=…`; without decoding,
  `amy login` of a nak bunker URI produced a broken relay and never
  connected. (This was the one real break — found by testing vs nak.)
- `amy bunker` now percent-encodes the relay/secret in the URI it prints,
  matching nak's output format.
- `amy bunker` implements `get_relays` (returns its relay set), so nak
  clients that probe it get a proper reply instead of an error.

Verified end-to-end with `go install`-built nak over relay.damus.io,
both directions:
- `amy login bunker://` ⇄ `nak bunker`: amy signs, event authored by
  nak's key, signature valid.
- `nak event --sec bunker://<amy>` ⇄ `amy bunker`: nak signs through
  amy, event authored by amy's key; amy logs connect→ok, sign_event→ok.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-21 19:52:37 +00:00
Claude 54a6b44324 feat(cli): NIP-46 bunker — remote signer server + bunker login
Add both halves of NIP-46 remote signing so two amy processes interop:

- `amy bunker [--relay …] [--secret S] [--timeout SECS]` runs a remote
  signer for the active local-key account: prints a bunker:// URI, then
  subscribes to kind:24133, decrypts each BunkerRequest, dispatches to
  ctx.signer (connect/get_public_key/sign_event/nip04/nip44/ping), and
  publishes the encrypted BunkerResponse. Long-running like `subscribe`.
- `amy login bunker://PUBKEY?relay=…&secret=…` creates a remote-signer
  account: mints a local transport keypair, records the connection, and
  acts as PUBKEY. Context builds a NostrSignerRemote (vs the local
  NostrSignerInternal) and runs openSubscription()+connect() in prepare();
  every signing/encryption call is delegated to the bunker.

Storage: IdentityFile gains a `bunker` block; the transport key is kept
in the existing SecretStore `secret` field. Identity/DataDir load+save
handle the remote-signer account type; `canSign` reflects "writeable via
bunker".

Thin assembly over quartz nip46RemoteSigner (NostrSignerRemote,
BunkerRequest*/BunkerResponse*, NostrConnectEvent). Verified end-to-end
over relay.damus.io: alice hosts a bunker, bob logs in and `amy event`
signs remotely — the note is authored by alice's key and verifies
(id_ok + signature_ok); bunker logs connect→ok, sign_event→ok.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-21 19:38:56 +00:00
Claude 69d03bacca feat(cli): add nak-style git (NIP-34) + podcast (NIP-F4) commands
git (repository metadata + collaboration events; clone/push packfile
transport is out of scope):
- git announce  publish a kind:30617 repository announcement
- git list      list a user's repo announcements
- git show      print one announcement (naddr or kind:pubkey:id), cache-first
- git issue     publish a kind:1621 issue against a repo (fetches the repo
                announcement to build the EventHintBundle)

podcast (NIP-F4):
- podcast metadata  publish kind:10154 show metadata (replaceable)
- podcast publish   publish a kind:54 episode (--audio URL[,URL])
- podcast list      list a user's metadata + episodes

Thin assembly over quartz GitRepositoryEvent / GitIssueEvent /
PodcastMetadataEvent / PodcastEpisodeEvent. Verified offline: announce->
show cache round-trip (name/clone/hashtags), issue kind:1621 against the
repo, podcast metadata kind:10154 + episode kind:54.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-21 19:13:21 +00:00
Claude fb35c85de4 feat(cli): add nak-style sync (NIP-77 Negentropy) primitive
`amy sync --relay URL [filter] [--down] [--up]` reconciles the local
event store with a relay using NIP-77 Negentropy:

- negotiate the symmetric difference under the filter (drives quartz
  NegentropySession over a raw WebSocket, mirroring geode's interop
  driver),
- --down (default) downloads the ids the relay has and we lack via
  Context.drain,
- --up uploads the events we have and the relay lacks via
  Context.publish.

Filter flags match fetch/subscribe; an empty filter reconciles the
whole store. Verified against relay.damus.io: cold store -> need=60,
downloaded=60; immediate re-sync -> need=0 (idempotent).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-21 19:09:18 +00:00
Claude 8e1059a4ab feat(cli): add nak-style blossom blob commands (upload/download/list/delete)
Sixth batch of nak parity — Blossom (NIP-B7 / BUD-01/02/04):

- `amy blossom upload --server URL FILE [--mime-type M]` — authed upload,
  prints the blob URL + sha256.
- `amy blossom download URL|HASH [--out FILE]` — public download (accepts
  a full URL or a HASH + --server).
- `amy blossom list --server URL [USER]` — list a user's blobs (authed).
- `amy blossom delete HASH --server URL` — delete a blob you own.

Reuses commons BlossomClient + BlossomAuth and quartz
BlossomAuthorizationEvent / sha256; list/delete use OkHttp directly with
the quartz-built kind:24242 auth header. Verified a full upload->download
sha256 round-trip against blossom.primal.net and an authed list.

This completes the Tier-1 nak-parity surface (decode/encode/verify/key/
event/publish/fetch/subscribe/count/encrypt/decrypt/gift/filter/relay
info/outbox/blossom); only the kind/nip reference lookups remain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-21 17:46:27 +00:00
Claude 62d0b16740 feat(cli): add nak-style filter, outbox, relay info primitives
Fifth batch of nak parity:

- `amy filter [filter flags]` — stateless: assemble + print a NIP-01
  filter JSON from the same flags fetch/subscribe use (no query sent).
- `amy outbox USER` — show a user's NIP-65 read/write relays (outbox
  model), cache-first with a relay-drain fallback / --refresh.
- `amy relay info URL` — stateless NIP-11 info-document fetch over HTTP
  (Accept: application/nostr+json), parsed by quartz
  Nip11RelayInformation.

filter + relay info dispatch before account resolution (no ~/.amy
needed). Verified against relay.damus.io (NIP-11 doc) and fiatjaf's
kind:10002 (outbox read/write sets).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-21 17:42:28 +00:00
Claude 0ec3fc69e8 feat(cli): add nak-style count, encrypt/decrypt, gift primitives
Fourth batch of nak parity:

- `amy count [filter]` — NIP-45 COUNT, per-relay match counts (reuses
  quartz INostrClient.count). Reports per-relay + max as `total`.
- `amy encrypt --to USER [TEXT]` / `amy decrypt --from USER [CIPHER]` —
  raw NIP-44 (default) or NIP-04 (--nip04) with the active account key.
- `amy gift wrap --to USER [EVENT]` / `amy gift unwrap [WRAP]` — NIP-59
  seal+wrap and unwrap+unseal (reuses SealedRumorEvent/GiftWrapEvent).

Text/ciphertext/JSON all read from arg or stdin. Verified with two
local accounts: NIP-44 + NIP-04 round-trips, gift wrap(1059)->unwrap
recovering the inner note + author, and count=60 against relay.damus.io.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-21 17:38:01 +00:00
Claude afc3c83baa feat(cli): add nak-style fetch + subscribe query primitives
Third batch of nak parity — the fetch-vs-subscribe split of nak's `req`:

- `amy fetch [filter] [--timeout SECS]` — one-shot query: open a
  subscription, collect until every relay sends EOSE (or timeout),
  dedupe by id, sort newest-first, cap at --limit (default 100), exit.
- `amy subscribe [filter] [--timeout SECS]` — live stream: print each
  matching event as it arrives (NDJSON under --json), until timeout or
  interrupt.

Shared filter flags (--kind/--author/--id/--tag/--since/--until/--limit
/--search) are assembled by RawEventSupport.buildFilter; --author/--id
accept npub/nevent/note/naddr or hex (local decode). fetch reuses
Context.drain; subscribe uses the client subscription directly and
verifies each event before printing. Verified against relay.damus.io
(kind:0 by author, kind:1 stream).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-21 17:34:24 +00:00
Claude b539609dfe feat(cli): add nak-style event + publish primitives
Second batch of nak parity:

- `amy event --kind N [--content …] [--tags JSON] [--created-at TS]`
  builds and signs an arbitrary event with the active account. Prints
  the signed event by default; `--publish` / `--relay` broadcasts it.
- `amy publish [EVENT-JSON] [--relay …]` broadcasts a pre-made, signed
  event (verified before broadcast; reads stdin when no arg).

Both reuse quartz EventTemplate/NostrSigner.sign and the existing
Context.publish path. New RawEventSupport holds the shared arg/stdin +
relay-target helpers for the raw-event verbs. Verified offline via an
event -> verify round-trip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-21 17:30:47 +00:00
Claude b41160f1cb feat(cli): add nak-style stateless primitives (decode, encode, verify, key)
Add the first batch of nak parity commands to amy — the army-knife
primitives that operate purely on their arguments, with no account or
network. They dispatch before account resolution (like `use`), so they
run with zero `~/.amy/` state:

- `amy decode ENTITY`   NIP-19/21 entity -> JSON
- `amy encode <type> …` raw parts -> NIP-19 entity
- `amy verify [JSON]`   id-hash + signature check (reads stdin)
- `amy key generate|public`  mint a keypair / derive a pubkey

All four are thin wrappers over quartz (Nip19Parser, the NIP-19
entities, Event.verifyId/verifySignature, KeyPair) per the cli
thin-assembly rule. README + ROADMAP updated with a nak-parity matrix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-21 17:13:05 +00:00
Claude 18c21ad51b feat(cli): add --payer-data to amy offer request
Offers can be configured (via nmanage / ShockWallet) to require payer
fields; Lightning.Pub rejects requests missing them with the misleading
"Invalid Offer" (code 1) reply. A flag to attach payer_data makes such
offers testable from the CLI.

https://claude.ai/code/session_01Fh7MRv8477pJiAJZ7yF87r
2026-06-12 15:18:45 +00:00
Claude 904032cea8 feat(cli): amy debit command for CLINK debits (info + pay + budget)
Completes amy's CLINK coverage alongside 'amy offer', reusing the
Context.requestResponse round-trip primitive:
- debit info NDEBIT: local decode of an ndebit1… pointer (pubkey, relays,
  pointer id, session flag), no network.
- debit pay NDEBIT BOLT11 [--amount SATS] [--timeout MS]: kind-21002 round
  trip asking the wallet to pay the invoice; prints preimage or GFY error.
- debit budget NDEBIT --amount SATS [--frequency day|week|month] [--timeout MS]:
  authorize a one-time or recurring spending budget.

Thin-assembly: ClinkPointerParser + DebitClient (quartz) do the protocol; the
command shares one roundTrip helper. Verified: 'debit info' decodes an interop
vector correctly (text + --json), and budget arg validation returns exit 1.
pay/budget need a live debit service to exercise fully.
2026-06-10 06:44:45 +00:00
Claude 7d9a42a51b feat(cli): amy offer command for CLINK offers (info + request)
Adds headless CLINK Offers support to amy for interop testing against real
offer services:
- offer info NOFFER: local decode of a noffer1… pointer (pubkey, relays,
  pointer id, price type/amount), no network.
- offer request NOFFER [--amount SATS] [--timeout MS]: the kind-21001 round
  trip — publishes the request to the pointer's relays and prints the returned
  BOLT11, or the service's error.

Thin-assembly per the CLI contract: pointer decode + request/response events
live in quartz (ClinkPointerParser, OfferClient); the round-trip uses a new
Context.requestResponse primitive (publish then await the first matching live
reply — unlike drain, which returns at EOSE).

Verified: 'offer info' runs end-to-end against an interop vector (correct
pubkey/relays/price-type, text + --json modes, bad-pointer error contract +
exit 1). The request round-trip needs a live service to exercise fully.
2026-06-10 06:03:07 +00:00
Claude 6ef0c372b1 docs(cli): make USAGE.md the README; move contract material to DEVELOPMENT
USAGE.md was the better README — entry-point users want examples and
quick start, not the public-API contract. Flip them and refresh the
amy-expert skill so it matches the post-refactor reality.

cli/README.md (was USAGE.md):
- Install, quick start, seven worked examples, full command reference,
  output modes, multi-account workflows, agent recipes, troubleshooting.
- Cross-refs point at DEVELOPMENT.md for the contract / architecture
  and ROADMAP.md for what's coming.

cli/DEVELOPMENT.md absorbs the old README's architecture sections:
- New "Public contract" section at the top — the stable promises
  (text-default + --json contract, stderr for humans, exit codes,
  ~/.amy/ as the world).
- "Local event store" deep-dive with the cache-helper API.
- "Relay routing" rules table.
- "Full on-disk layout" tree with annotations.

cli/ROADMAP.md, cli/USAGE.md:
- ROADMAP cross-refs collapsed (no more USAGE.md row).
- USAGE.md deleted — content lives in README now.

.claude/skills/amy-expert refreshed end-to-end:
- SKILL.md description + Rules 2 and 4 rewritten for the dual-output
  contract (text default, --json opt-in) and the ~/.amy/ layout.
- "Where things live" listing matches the current source tree
  (Output.kt, Aliases.kt, UseCommand.kt, secrets/, all the new
  command files).
- "Common mistakes" lists the new traps: don't read user.home
  directly, don't add a global flag that collides with subcommand
  --name, don't use Json.writeLine (it's gone).
- references/command-template.md uses Output.emit / Output.error
  (Json.writeLine / Json.error helpers no longer exist).
- references/output-conventions.md rewritten around the dual-mode
  contract — same JSON shape rules, but framed as "this is what
  --json emits" rather than "this is stdout."
2026-04-25 16:35:10 +00:00
Claude fa48d7188f docs(cli): slim README, refresh DEVELOPMENT + ROADMAP for USAGE.md split
USAGE.md is now the single home for "how do I use amy" — install,
quick start, examples, command reference, troubleshooting. README
was carrying all of that PLUS the public-contract material; with
USAGE in place it can collapse to just the contract.

README.md (338 → 176 lines):
- Keep: 1-paragraph intro (three audiences), output contract, local
  event-store explainer, relay-routing rules, on-disk layout, cross-
  references to USAGE/DEVELOPMENT/ROADMAP.
- Drop: install, quick start, command reference table, global flags
  table, account-management verbs, troubleshooting (all in USAGE).

DEVELOPMENT.md:
- Architecture diagram updated to reflect new files (Aliases.kt,
  UseCommand.kt, ProfileCommands.kt, DmCommands.kt, NotesCommands /
  PostCommand / FeedCommand, MarmotResetCommand, StoreCommands,
  SecureFileIO, secrets/ subtree).
- "Keep three things in sync" pointers redirected from README's
  command table to USAGE.md.
- Testing table loses the duplicate "Interop with other clients" row
  (already covered by the harness row above).
- Cross-reference list at the top now mentions USAGE.md.

ROADMAP.md:
- Cross-reference list adds USAGE.md.
- Parity matrix marked  for items shipped on this branch:
  notes post + feed (PostCommand/FeedCommand), profile show+edit
  (ProfileCommands), DMs (already ).
- Reactions split into " in groups, 🆕 elsewhere" since
  marmot message react is shipped but outer-event reactions aren't.
- Order-of-operations entries marked  where relevant.
2026-04-25 15:48:32 +00:00
Claude e17ef42e54 fix(cli): rename global --name to --account to free --name for subcommands
The marmot harness surfaced the bug on first run: amy stripped
`marmot group create --name "Interop-02"` thinking the global
account selector ran into the group's display-name flag. Result:
amy resolved the "Interop-02" account, found no identity.json, and
errored — no group ever got created.

Renames the global account-selector flag to `--account`. The per-
subcommand `--name` flags (`marmot group create --name "Demo"`,
`profile edit --name "Alice"`, `marmot await group --name X`) are
untouched — they're free of the global parser now that it doesn't
claim the same name.

Sweep:
- `Main.kt`: GlobalFlag.NAME → ACCOUNT, long "--account".
- `Config.kt`: DataDir.resolve param renamed nameFlag → accountFlag;
  every error message points the user at --account.
- `UseCommand.kt`: error hint says `amy --account NAME init`.
- All test wrappers + direct $AMY_BIN calls under cli/tests/ swap
  the global `--name X` for `--account X` (subcommand --name kept
  exactly where it appeared).
- README + DEVELOPMENT updated.
2026-04-25 15:26:17 +00:00
Claude 99586fbe7e feat(cli): drop --data-dir; tests isolate via $HOME override
There is no installed base to protect, so the self-contained
`--data-dir P` escape hatch goes away entirely. amy now has exactly
one layout — the production multi-account one — and tests exercise
that same code path. Drops one global flag, one DataDir construction
mode, and the "tests use a different code path than users" footgun.

Layout (unchanged from the previous commits — just the only mode now):

    ~/.amy/
    ├── current                      # `amy use NAME` marker
    ├── shared/
    │   └── events-store/            # FsEventStore, one per machine
    ├── alice/
    │   ├── identity.json
    │   ├── state.json
    │   ├── aliases.json             # {"alice": "<own npub>"} after init
    │   └── marmot/
    └── bob/ ...

`DataDir.DEFAULT_ROOT` reads `$HOME` directly (falling back to
`user.home`) because JDK 21 resolves `user.home` via getpwuid and
ignores `$HOME` — which would have broken the standard CLI test
isolation pattern of `HOME=/tmp/foo amy …` (the same convention
git, gpg, npm follow).

Test sweep:

- `cli/tests/headless/helpers.sh`, `cli/tests/dm/setup.sh`,
  `cli/tests/cache/cache-headless.sh` wrappers all switch to
  `HOME=$STATE_DIR amy --name X …`.
- `ensure_identity_for` drops its `dir` parameter; the function and
  every harness call site go through `--name` only.
- `A_DIR`/`B_DIR`/`D_DIR` get repointed at `$STATE_DIR/.amy/X`. The
  one consumer (`cache-headless.sh`'s T6 `relays.json` check) still
  works since it's just a path probe.
- `cli/tests/dm/tests-dm.sh` ghost identities get their own
  short-lived `$HOME` so they don't pollute the main test root.

Cache-test T4 inverts: pre-shared-store, "B has not seen A → first
profile show is a relay miss, second is a cache hit" tested
per-account caching. With one shared events-store, B's first lookup
of A is already a cache hit because A wrote kind:0 there during
bootstrap. T4 now asserts that — drops the stale "second lookup hits
cache" half. T7 (no-identity maintenance verbs) gets a fresh fake
`$HOME` plus a throwaway `--name` so the empty store has no inherited
events.

Docs (README + DEVELOPMENT) rewritten to match — quick-start now uses
`amy --name alice create`, the on-disk-layout section shows the new
tree, and the global-flags table replaces `--data-dir` with `--name`
plus the new `amy use` verb.
2026-04-25 15:07:31 +00:00
Claude 03eb7be509 feat(cli): default amy stdout to human-readable text; --json opts in
amy's default stdout is now a YAML-ish render of the underlying result
map; the previous single-line JSON contract moves behind a global
`--json` flag. Errors mirror the same rule (`error: <code>: <detail>`
on stderr by default, JSON `{"error":...,"detail":...}` under --json).
Exit codes (0/1/2/124) and the --json shape itself are unchanged —
only the default presentation flips.

- Replaces Json.writeLine / Json.error with mode-aware
  Output.emit / Output.error. The same Jackson mapper is reused for
  on-disk JSON via Output.mapper.
- Adds `--json` to the global flag set in Main.kt; honoured even when
  argument parsing fails so error JSON keeps shape under --json.
- Updates the test harness wrappers (amy_a / amy_b / amy_d in
  cli/tests/{headless,dm,cache}/) and the few direct $AMY_BIN call
  sites whose stdout is consumed via $() / 2>&1 — they now pass
  --json so the existing jq pipelines keep working.
- Rewrites the README "Output contract" and DEVELOPMENT design
  principles to describe the new default, and clarifies that only
  --json is the public API; the text shape is allowed to drift.
2026-04-25 12:23:37 +00:00
Claude 37a4f89178 feat(cli): drop relays.json — kind:10002/10050/10051 in the store ARE the config
The local relay configuration was redundant once the event store
became the source of truth: every account already publishes signed
kind:10002 (NIP-65), kind:10050 (DM inbox), and kind:10051
(KeyPackage relays) events, and Context now reads each set straight
out of the local store.

Removed:
  - data class RelayConfig (nip65/inbox/keyPackage buckets)
  - DataDir.relaysFile / loadRelays() / saveRelays()
  - Context.relays parameter — Context.open() no longer reads JSON
  - CreateCommand.defaultRelayConfig() helper
  - CreateCommand's saveRelays() call (redundant: ctx.publish persists
    the bootstrap events into the store anyway)

Context now serves the four relay accessors from the store with sane
fallbacks:
  outboxRelays()      = relaysOf(self)?.writeRelaysNorm() ?: DefaultNIP65RelaySet
  inboxRelays()       = dmInboxOf(self)?.relays() ?: DefaultDMRelayList.toSet()
  keyPackageRelays()  = keyPackageRelaysOf(self)?.relays() ?: outboxRelays()
  anyRelays()         = union of the three
  bootstrapRelays()   = anyRelays() ∪ DefaultNIP65RelaySet ∪ DefaultDMRelayList

RelayCommands rewritten:
  - `relay add URL --type T` — read existing relay-list event from the
    store, append URL, build + sign + ingest a new event of the
    matching kind. The replaceable slot mechanism replaces the old
    winner atomically.
  - `relay list` — dump URLs from the latest event in each bucket.
  - `relay publish-lists` — broadcast whichever events are present in
    the store; errors out cleanly when none exist (suggests `relay add`
    or `create`).

No migration: existing data dirs that still have `relays.json` will
ignore it. Run `amy relay add` or `amy create` to repopulate. Per
discussion this is a clean break, not a backwards-compat shim.

README + DEVELOPMENT updated to reflect the new on-disk layout (no
more `relays.json`; events-store/ is the relay configuration).
2026-04-25 03:45:12 +00:00
Claude b2bf874c15 feat(cli): cache-first reads across feed, dm, marmot commands
Audit found four commands draining replaceable relay-list events
(kind:10050, 10051, 10002) on every invocation, plus one fetching
the user's own kind:3 contact list. All five are now cache-first.

New Context helpers:
  - dmInboxOf(pk)         → kind:10050  (ChatMessageRelayListEvent)
  - keyPackageRelaysOf(pk)→ kind:10051  (KeyPackageRelayListEvent)
  - cachedRelayListsOf(pk)→ assembles a RecipientRelayFetcher.Lists
                             from the local store; returns null if
                             nothing is cached so callers can fall
                             back with `?:`.

Wired into:
  - FeedCommand.resolveFollowing — replaces a kind:3 drain with
    ctx.contactsOf(self).
  - DmCommands.resolveDmRelays — `amy dm send` / `dm list`.
  - KeyPackageCommands.check.
  - AwaitCommands (key-package / member / admin polling loops).
  - GroupAddMemberCommand (one cache hit per invitee).

All five sites now go: try cache → if miss, run the existing
RecipientRelayFetcher / drain. The drain itself populates the cache
via verifyAndStore, so subsequent runs are local-only. Replaceable
slot shortcut means each cached read is one stat + one readString,
not a directory walk.

README "Local event store" section gains a list of which commands
are cache-first today.
2026-04-25 03:34:00 +00:00
Claude bf1c4c23cb feat(cli): amy profile show is now cache-first
The first command to consume the local event store as a read source.
Default behaviour: read from the FsEventStore via Context.profileOf().
Pass --refresh to skip the cache and drain from relays (which then
re-populates the store as a side effect, like every other drain).

Output JSON gains a "source" field so scripts/agents can tell whether
the result came from disk or the network:

  source = "cache"  → served from <data-dir>/events-store/, queried_relays = []
  source = "relays" → fresh relay drain, queried_relays = list

Cache hit goes through the planner's slot shortcut from the previous
commit: one stat + one read on replaceable/0/<pubkey>.json, no
directory scan.

profile show + profile edit now have entries in the verb table in
cli/README.md.
2026-04-25 03:25:11 +00:00
Claude 22a418bea2 feat(cli): make FsEventStore the source of truth for relay events
Every event Amy observes — drained from a relay subscription,
unwrapped from a NIP-59 gift wrap, or generated locally for publish —
now flows through Context.verifyAndStore: NIP-01 id + signature check
via Event.verify(), then store.insert(). Bad events are dropped with
a stderr log and never reach command code; persistence failures are
logged but do not propagate, so a broken store never breaks a relay
subscription.

- Context.drain() now persists every received event before surfacing
  it to the caller. Existing callers (FeedCommand, DmCommands,
  ProfileCommands, Marmot sync) get caching for free.
- Context.publish() persists outbound events too, so the local store
  reflects what Amy has done even when every relay rejects.
- Three cache-first read helpers expose the most common lookups
  without hitting relays: profileOf(pubKey) → kind:0,
  relaysOf(pubKey) → kind:10002, contactsOf(pubKey) → kind:3.
- Class doc on Context spells out the contract; README.md gets a new
  "Local event store — the source of truth" section pointing at the
  on-disk layout and the design plans.
2026-04-25 01:40:05 +00:00
Claude 8cbe8c67fe refactor(cli): rename default data-dir to ./amy and nest MLS state under marmot/
Two on-disk layout changes that only affect freshly-created data-dirs (no
migration shim since `amethyst-cli-data` was never tagged or released):

- Default `--data-dir` is now `./amy` instead of `./amethyst-cli-data` —
  shorter, matches the binary name, and still overridable via the flag or
  `$AMETHYST_CLI_DATA`.
- MLS-only files (`groups/` and `keypackages.bundle`) move under a new
  `marmot/` subdirectory so the top-level root stays tidy as more
  non-Marmot state lands (notes, profile caches, etc). Existing
  top-level files (`identity.json`, `relays.json`, `state.json`) are
  unchanged.

README tree diagram and DEVELOPMENT.md test table updated to match.
2026-04-24 19:15:58 +00:00
Claude bd88f68dcd docs: point README / DEVELOPMENT / amy-expert at cli/tests/
After the harness move from tools/marmot-interop/ to cli/tests/,
three doc locations were still describing the old layout:

- cli/DEVELOPMENT.md § Testing — the "round-trip" row pointed at a
  non-existent `cli/src/test/resources/scripts/`, and the "interop
  with other clients" row claimed it was out of scope. Both now name
  `cli/tests/marmot/` and `cli/tests/dm/` with what each covers, and
  the interop-script template points at the concrete test files
  rather than re-inventing a smaller example.

- cli/README.md — the "For an interop-test script template" pointer
  now links to `cli/tests/README.md` alongside the DEVELOPMENT.md
  section.

- .claude/skills/amy-expert/SKILL.md — the "Where things live" tree
  omitted the new `tests/` subtree entirely. Added it with a
  one-line description per suite. Also extended the wire-up checklist
  with a step 7: add a harness case when a new verb changes observable
  wire behaviour.

No content changes elsewhere; just path corrections.
2026-04-23 21:36:52 +00:00
Claude c614400b23 feat(cli): amy dm send-file --file PATH (encrypt + upload + publish)
`dm send-file` now has two modes:

- **Upload mode** — `dm send-file RECIPIENT --file PATH --server URL`:
  generate a fresh AES-GCM cipher, encrypt the file, upload the
  ciphertext to the Blossom server, then publish a kind:15 referencing
  the returned URL. Auto-detected metadata (encrypted hash + size,
  original sha256, mime type, image dimensions, blurhash) is folded
  into the kind:15 tags. The response surfaces the encryption key and
  nonce so the same encrypted blob can be re-shared without
  re-uploading.

- **Reference mode** — the previous shape still works:
  `dm send-file RECIPIENT URL --key HEX --nonce HEX [...]`. Useful
  when the upload happened elsewhere (Android client, third-party
  tool) or for replaying a previous upload.

Mode is selected by the presence of `--file`. Both paths share the
same wrap-and-publish pipeline (`publishWraps`) and respect the strict
NIP-17 §6 relay rule: kind:1059 only reaches the recipient's kind:10050
unless `--allow-fallback` is passed.

Implementation entirely reuses the upload helpers just promoted to
`commons/jvmMain/.../service/upload/`, so no new business logic lives
in `cli/`. The Blossom auth event (kind:24242) is built and signed via
`BlossomAuth.createUploadAuth(... ctx.signer ...)`.
2026-04-23 19:51:06 +00:00
Claude 305ce20b52 feat(cli): NIP-17 file DMs (kind:15) on receive + send, strict 10050
Receive:
- decryptDms now accepts both ChatMessageEvent (kind:14) and
  ChatMessageEncryptedFileHeaderEvent (kind:15). The result is a sealed
  DecryptedDm hierarchy (TextDm / FileDm) with a `type: "text"|"file"`
  discriminator in JSON. File messages emit url, mime_type,
  encryption_algorithm, decryption_key, decryption_nonce, hash,
  original_hash, size, dim, blurhash. `dm await --match X` searches the
  text body for kind:14 and the URL for kind:15.

Send:
- New `dm send-file RECIPIENT URL --key HEX --nonce HEX [...]` verb.
  Builds a kind:15 ChatMessageEncryptedFileHeaderEvent via the existing
  ChatMessageEncryptedFileHeaderEvent.build + AESGCM(key, nonce), then
  publishes through NIP17Factory.createEncryptedFileNIP17. The file is
  expected to already be uploaded; carrying the upload itself in `amy`
  is the next change.

Spec compliance (NIP-17 §6, "if no kind:10050, shouldn't try"):
- resolveDmRelays now returns kind:10050 only by default. If the
  recipient has none, send/send-file exit with `no_dm_relays` and a
  message recommending `--allow-fallback`. The fallback chain (kind:10002
  read marker → bootstrap) is preserved behind that opt-in flag for
  interop tests and brand-new accounts.

Each recipient row in the send response now includes `relay_source`
("kind_10050" / "nip65_read" / "bootstrap") so callers can tell where
the wraps actually went.

Note: Quartz already enforces the NIP-17 §7 step-3 impersonation guard
(seal pubkey is forced over the rumor's claimed pubkey inside
`Rumor.mergeWith`), so no extra check is needed in the CLI.
2026-04-23 19:34:52 +00:00
Claude 15205c24b5 feat(cli): amy dm send|list|await (NIP-17)
Adds three verbs wired into the top-level dispatch:

- `amy dm send RECIPIENT TEXT` — builds a kind:14 ChatMessageEvent,
  runs it through NIP17Factory to seal and gift-wrap, then publishes
  each wrap to its recipient's DM inbox (kind:10050, fallback kind:10002
  read, final fallback bootstrap). Emits one JSON line with the inner
  kind:14 id, per-recipient wrap ids, and per-relay ACK status.

- `amy dm list [--peer NPUB] [--since TS] [--limit N] [--timeout SECS]`
  — drains gift wraps addressed to us from our inbox relays (or outbox /
  bootstrap fallbacks), unwraps+unseals each one, dedupes by inner id,
  and returns them oldest-first. With no flags it advances the
  giftWrapSince cursor in state.json (matches the Marmot syncIncoming
  convention); with --peer/--since it's a stateless query.

- `amy dm await --peer NPUB --match TEXT [--timeout SECS]` — polls the
  same drain on a 2s tick until a DM from NPUB contains TEXT. Timeout
  exits 124 like the other await verbs.

All three reuse Quartz entirely (NIP17Factory, GiftWrapEvent,
SealedRumorEvent, RecipientRelayFetcher); the only shared piece we
needed in commons — filterGiftWrapsToPubkey — was extracted in the
previous commit. No business logic leaks into cli/.

Plan: cli/plans/2026-04-23-nip17-dm.md.
2026-04-23 18:23:12 +00:00
Claude 0657f6e1c7 fix(cli): apply Marmot relay-routing rules across every marmot command
The first pass only fixed `group add`. Auditing the rest of the CLI
against MIP-00..03 turned up three more spots where `amy` either
queried the wrong relays or silently skipped a required advertisement:

1. `marmot key-package check <npub>` used `anyRelays()` — i.e. the
   inviter's configured relays — so checking for a KeyPackage on a
   user who advertises `kind:10051` somewhere we don't know about
   always returned `not_found`. Now runs RecipientRelayFetcher against
   bootstrap seeds and fetches from the union of (target kind:10051,
   target kind:10002 write, bootstrap). Emits `found_on` so callers
   can see which relay served the hit.

2. `await key-package <npub>` had the same bug inside the poll loop.
   Resolved once up front, then the loop fetches from the target's
   advertised relays every tick. Throws an `AwaitTimeout` early if no
   relays can be discovered at all, instead of silently polling void.

3. `relay publish-lists` published kind:10002 + kind:10050 but never
   kind:10051. Per MIP-00 the KeyPackage Relay List is how other
   Marmot clients discover where our KPs live — without it the
   `key_package` bucket on disk is invisible to anyone else. Now also
   publishes kind:10051; falls back to the NIP-65 set if the bucket
   is empty so we never advertise an empty list. (`amy create`
   already publishes it via AccountBootstrapEvents.)

Docs: cli/README adds a "Relay routing" section that lists the exact
relay set used for publish vs fetch of every Marmot event kind, plus
the bootstrap-pool definition, so agents + interop-test authors can
reason about cross-user reachability without reading the code.
2026-04-22 22:15:10 +00:00
Claude 9b9084ffaf docs(cli): split amy docs by responsibility + add amy-expert skill
Separate the single DEVELOPMENT.md into focused docs per audience:

- cli/README.md — trim agent/interop sub-sections; user-facing contract,
  commands, data-dir, troubleshooting only.
- cli/DEVELOPMENT.md — pared down to architecture, how-to-add-a-command,
  output conventions, testing, housekeeping.
- cli/ROADMAP.md (new) — north-star, parity matrix, ordered milestones,
  non-goals. The live checklist for CLI feature parity.
- cli/plans/2026-04-21-cli-distribution.md (new) — packaging strategy
  (Homebrew / winget / Scoop / deb / rpm / AUR / AppImage).
- commons/plans/2026-04-21-event-renderer.md (new) — cross-cutting
  renderer design owned by commons, consumed by cli + desktop + android.

Agentic routing:

- .claude/skills/amy-expert/SKILL.md (new) — routing triggers + the
  five hard rules (thin-layer, JSON contract, non-interactive,
  data-dir-is-world, extract-before-adding).
- references/command-template.md, extraction-recipe.md,
  output-conventions.md — bundled copy-paste references.

Root .claude/CLAUDE.md:

- Adds cli/ to the module list with its sharing rule.
- Registers amy-expert in the skills table.
- Documents per-module plans/ convention; freezes docs/plans/.

https://claude.ai/code/session_01BQ5ZHwa8BAgEQ9zeM4CKhW
2026-04-22 00:23:00 +00:00
Claude 35a84ffaf9 docs(cli): add user README and development roadmap for amy
Add cli/README.md covering the JSON-output contract, install via
installDist, today's command surface (identity / relays / marmot),
and use from agents + interop tests. Add cli/DEVELOPMENT.md with the
feature-parity matrix vs Amethyst, the extract-from-Android recipe
(amethyst/ -> commons/ -> cli/), command template, EventRenderer
plan shared with Desktop, testing strategy focused on what quartz
and commons don't already cover, distribution matrix across macOS /
Windows / Linux / Nix / Zapstore, and an ordered 10-step roadmap.

https://claude.ai/code/session_01BQ5ZHwa8BAgEQ9zeM4CKhW
2026-04-21 23:26:06 +00:00