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
- `key validate PUBKEY` — nak's `key validate`: parse an npub or 64-hex
pubkey and report {valid, pubkey, npub}; never errors on bad input
(reports valid:false) so scripts branch on the field.
- `fetch <nevent|naddr|nprofile|npub|note|nip05>` — code mode that resolves
relays the way the Android app opens a shared link: the relay hints
embedded in the nip19 code UNION the author's NIP-65 write (outbox)
relays, draining the author's kind:10002 on a cache miss. This is nak's
`fetch` (nip19-hint resolution); filter mode is unchanged.
Verified: key validate accepts fiatjaf's npub/hex and rejects garbage +
bad-checksum npubs; `fetch <npub>` drained the author's kind:10002,
queried their actual advertised write relays, and returned kind:0.
nak parity: 23 full / 3 partial / 6 missing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
Two new nak-parity commands:
- `amy admin RELAY METHOD [args]` — NIP-86 Relay Management API over NIP-98
HTTP auth. Full method set: ban/unban + allow/unallow pubkey, ban/allow
event, allow/disallow kind, block/unblock IP, change name/description/icon,
and all list-* queries. Reuses quartz's Nip86Client (request build + NIP-98
auth + parse) and the Nip86Retriever HTTP path — extracted from amethyst to
commons/jvmAndroid so amy and the Android relay-management screen share it.
- `amy serve [--host --port --path --db --admin]` — runs a Nostr relay by
embedding geode (the standalone Ktor relay on quartz's relay-server code).
In-memory by default (ephemeral, like nak serve); --db FILE for SQLite. The
account's own pubkey is always an admin, so `amy admin` works against it out
of the box. cli gains a :geode dependency (geode depends only on :quartz) and
kotlinx-serialization-json (to render NIP-86 JSON results).
Verified end-to-end: `amy serve` + `amy admin ws://127.0.0.1:PORT
supported-methods|change-name|ban-pubkey|list-banned-pubkeys` round-trip
cleanly over real HTTP + NIP-98 against the live geode relay.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
Two behavioral divergences from the Android wallet, found while auditing
for parity:
- Publish targets: Amethyst sends every cashu event via
sendLiterallyEverywhere (all the user's relays). amy published only to
outboxRelays(); switch to anyRelays() (outbox + inbox + keypackage), the
CLI's closest analog, so the wallet lands on the same broad relay set.
- Nutzap relays on create: Amethyst always advertises the account's outbox
relays in kind:10019 (so senders publish nutzaps where the user reads).
amy defaulted to none; default to outboxRelays() unless --relay overrides.
Also align cashuSnapshot()'s store query with commons'
CashuWalletFilterAssembler exactly (six authored kinds by authors=[pk],
inbound nutzaps by #p) so amy projects the same event set the app
subscribes to. Verified the emitted kind:10019 now carries relay/mint/
pubkey tags identical to NutzapInfoEvent.build.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
Add the offline Cashu command tier to amy, all driven by the shared
commons wallet code so amy exercises the same path as the Android app:
amy cashu wallet create [--mint URL] [--mints a,b] [--privkey HEX] [--relay r1,r2]
amy cashu wallet show
amy cashu wallet export-key
amy cashu wallet destroy
amy cashu mint ping URL (stateless)
amy cashu mint info URL (stateless)
amy cashu balance [--mint URL]
- create/destroy reuse commons CashuWalletOps.publishWalletEvents /
deleteWallet; show/balance reuse the CashuWalletReader projection over
the local event store; mint ping/info hit quartz's MintHttpClient.
- Context gains cashuOps() (wired to the file NUT-13 counter store + a
per-run seed cache) and cashuSnapshot(); DataDir gains cashu.json.
- Extraction D: CashuKeysetCounterStore contract in commons +
FileCashuKeysetCounterStore (atomic ~/.amy/<account>/cashu.json).
PRs 4 of cli/plans/2026-05-28-cashu-cli.md (extractions A–D + offline
tier). receive/send/maintenance/mint-rec + interop harness still pending.
Verified end-to-end against mint.minibits.cash and live relays.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
Fill the KindNames registry from 145 to 280 entries so every event kind
quartz defines a class for has a canonical English label + NIP. Adds the
whole NIP-90 DVM request/response set, NIP-29 relay groups, NIP-60/61
Cashu wallet + nutzaps, NIP-43 relay members, WebRTC calls (NIP-AC),
marketplace (NIP-15), git PRs/state (NIP-34), CLINK, NIP-51 curation
sets, NIP-85 assertions, Marmot MLS events, and more.
For the handful of kind numbers shared by multiple classes, the registry
keeps one canonical entry (e.g. CashuToken over the deprecated nip61
TokenEvent, ExternalIdentities over GalleryList, ReleaseArtifactSet over
SoftwareRelease). Kind 1 stays "Notes" rather than the bounty value-add
helper that reuses it.
Also point `amy nip`'s Nostr fallback at quartz's canonical NipText kind
30817 (NipTextEvent) alongside the wiki and long-form kinds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
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
- `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
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
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
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
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
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
`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
Integrate the `refactor(cli): simplify amy dispatch, Context lifecycle,
and store handling` change (Commands.kt removed, new route() sub-verb
helper, Context.open(dataDir).use { } lifecycle) with the nak-parity
Tier-1 commands added on this branch.
Re-wiring:
- Dropped Commands.kt; wired the 16 new verbs directly into Main.kt's
dispatch (stateless: decode/encode/verify/key/filter + relay info
interception; account-path: event/publish/fetch/subscribe/count/
encrypt/decrypt/gift/outbox/blossom).
- Converted every new command from hand-rolled try/finally to
Context.open(dataDir).use { ctx -> } and the multi-verb dispatchers
(gift/blossom/key) to the shared route() helper.
- RelayCommands.dispatch now routes via route(); `relay info` stays a
stateless Main interception and is also in the route map.
Verified post-merge: build green + smoke test across stateless,
account-path, and route() sub-verb paths (decode/key/filter, event->
verify round-trip, gift/blossom bad-verb errors, relay info on damus).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
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
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
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
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
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
Four mechanical simplifications to amy with no change to the public
CLI/JSON contract:
1. Drop the Commands.kt pass-through layer. Main.kt now calls each
command object directly; the file is repurposed into Router.kt,
holding a single shared `route(name, tail, usage, routes)` helper.
2. Replace the `Context.open(dataDir)` + `try { } finally { ctx.close() }`
boilerplate (~46 sites) with `Context.open(dataDir).use { ctx -> }`
now that Context is AutoCloseable.
3. Remove the reflection-based `storeIsInitialized()` in Context; track
the lazy event store via `Lazy.isInitialized()` instead.
4. Route every `*Commands.dispatch` through the `route` helper, dropping
the repeated empty-check + unknown-verb `when` boilerplate.
Net -282 lines. Docs (cli/DEVELOPMENT.md, amy-expert skill + template)
updated to the new wiring.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QjEvS812aPLZ6nM2XLobzF
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
Extends the CLI to fetch and verify NIP-5D napplet kinds, mirroring `amy nsite`
but adding the napplet-specific runtime checks.
- NappletCommands: `amy napplet fetch AUTHOR [--d ID] | --snapshot EVENT-ID
[--path P] [--server …] [--relay …] [--out FILE] [--timeout SECS]`. Fetches a
root (15129), named (35129, via --d), or snapshot (5129, via --snapshot
<event-id>) manifest; recomputes the NIP-5A aggregate hash and refuses a
manifest whose `x` tag doesn't match its path tags (`aggregate_mismatch`)
before touching any blob; then resolves the path with per-blob sha256
verification. Output adds `requires` (NAP capabilities), `aggregate_sha256`,
and `aggregate_verified`.
- StaticSiteFetch: new shared helper holding the Blossom download + resolve +
emit logic, so `nsite` and `napplet` don't duplicate it. NsiteCommands is
slimmed down to use it (also now reports the manifest `kind`).
Smoke-tested offline: bad-args, help, and dead-relay runs resolving cleanly to
not_found with the correct kind for all three napplet variants (15129/35129/5129)
plus a no-regression check on `nsite fetch`. The aggregate/per-blob verification
logic itself is covered by the quartz unit tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CdAJMbnHJfiMY7UcS99T6C
Wires the quartz NIP-5A resolver end-to-end so it can be exercised against
real manifests (interop / agents), without building the security-sensitive
WebView shell yet.
- commons BlossomClient: add download(url) — a Blossom GET returning raw bytes
(null on non-2xx; connection failures propagate so callers try the next
server). Does not verify the hash; that is the resolver's job.
- cli NsiteCommands: `amy nsite fetch AUTHOR [--d ID] [--path P] [--server …]
[--relay …] [--out FILE] [--timeout SECS] [--max-inline-bytes N]`. Fetches
the manifest (kind 15128 root, or 35128 named with --d) from relays, then
resolves one path through StaticSiteResolver, downloading from the manifest's
Blossom servers (plus any --server fallbacks) and accepting only the first
blob whose sha256 matches the manifest pin. Emits the verified path's bytes
(inlined for small text, or written to --out) with hash/server/content-type,
or a structured not_found / path_not_found / unresolvable error.
Thin-assembly only: all resolution + verification stays in quartz, the byte
fetch in commons. Smoke-tested offline: bad-args, help, and a dead-relay run
that resolves cleanly to not_found in both text and --json modes.
Also converts the StaticSitePathLookup file-overview KDoc to a plain block
comment to satisfy ktlint no-consecutive-comments.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CdAJMbnHJfiMY7UcS99T6C
A full FTS rebuild can run for a long time on a big store, so add a
resumable, batched overload alongside the one-shot:
reindexFullTextSearch(resumeFrom: String?, batchSize): FtsReindexProgress
Each call processes ~batchSize events in its own write transaction and
returns an opaque cursor + done flag. The caller loops until done and may
stop at any point — the cursor is durable across crash/app-restart, and
the writer lock is released between batches, so "pause" is just "don't
make the next call". The path is additive/refresh and keeps search usable
throughout (no up-front wipe); the one-shot variant remains for a
guaranteed-clean rebuild.
- SQLite: FullTextSearchModule.reindexBatch walks event_headers ordered
by the monotonic row_id (a free, stable cursor), restricted to
searchable kinds, delete-then-insert per event so batches are
idempotent and never duplicate rows.
- Filesystem: FsEventStore walks one idx/kind/<k>/ dir per step (linear,
no re-sort); cursor is the next searchable kind. Idempotent linkFts, so
nothing is wiped. Pauses between kinds.
- Wrappers delegate; new FtsReindexProgress value type carries cursor +
progress + done.
- cli: `amy store reindex-fts` now loops the batched path to completion
and reports processed/batch counts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BZqPFds2TPPUKkMmBngwys
The set of event kinds that implement SearchableEvent — and the text
each contributes via indexableContent() — is baked into the quartz
build, so it changes across app versions. Events stored under older code
keep their old (or missing) NIP-50 full-text-search rows, so search
silently misses them after an upgrade.
Add IEventStore.reindexFullTextSearch() so the app can wipe and rebuild
the FTS index from already-stored events when it has spare cycles.
Speed: only kinds that currently map to a SearchableEvent are scanned.
Kind alone selects the event class in EventFactory, so a single probe per
distinct kind is authoritative, letting us push a `kind IN (...)` filter
(SQLite) / skip whole idx/kind dirs (filesystem) so the non-searchable
bulk — reactions, zaps, follow lists — is never deserialised.
- SQLite: FullTextSearchModule.reindexAll drops+recreates the virtual
table (O(1) wipe) then streams only searchable-kind rows in one write
transaction, reusing a single INSERT statement.
- Filesystem: rebuilds only idx/fts/, driving the walk from
idx/kind/<searchable kind>/ via the new FsIndexer.linkFts.
- Wrappers (EventStore, ObservableEventStore, InterningEventStore)
delegate; the observable layer emits nothing since no event changes.
- cli: `amy store reindex-fts`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BZqPFds2TPPUKkMmBngwys
The Homebrew formula is the cli module's product (amy), and the CLI already
owns its packaging artifacts under cli/packaging/ (cli/packaging/macos/
amy.entitlements). The root packaging/ dir was new in this branch and held
nothing else, so co-locate the formula with the module that owns it and drop
the stray root dir. Updates the two BUILDING.md references.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sso31DfSF9B6EFCVkEqWD
amy is headless and compiles against zero Compose UI (the Compose deps are
`implementation` in :commons, so they never hit the CLI compile classpath),
but they still rode the runtime classpath into the shipped image — ~29 MB of
Compose desktop render stack, including skiko's native .dylibs that enlarged
the macOS notarization surface.
Exclude skiko + the org.jetbrains.compose UI groups (ui/foundation/material/
material3/animation) from :cli runtimeClasspath. Keep androidx.compose.runtime
(snapshot state + @Stable/@Immutable) — that IS CLI-safe and used by commons
models/state. This avoids the commons → commons/commons-ui module split: the
single-module, feature-cohesive design (commons/ARCHITECTURE.md §1/§3) is
preserved; only the runtime artifact is trimmed.
Result: amy image lib 77 MB -> 48 MB (-38%), and all 4 Compose/skiko notary
dylibs gone (only secp256k1/jna/sqlite natives remain — the ones actually
loaded). A create-release.yml assertion fails the build if the UI stack ever
leaks back.
Verified with the SDK hidden + an offline amy command battery (init/whoami/
--json/relay/marmot/login, plus a real 6-relay key-package round-trip): zero
NoClassDefFoundError/linkage errors; init derives a secp256k1 key cleanly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sso31DfSF9B6EFCVkEqWD
Sign the macOS jlink image (amy-<version>-macos-arm64.tar.gz) so users who
download it directly clear Gatekeeper. Reuses the same Developer ID cert and
the six MAC_* secrets as the desktop DMG; no-op when they're absent.
- .github/actions/import-macos-cert: factor the throwaway-keychain cert import
into a composite action; the desktop leg now uses it too (was inline).
- create-release.yml (build-cli macOS leg): import the cert, then codesign
every Mach-O binary in the bundled JRE (executables get hardened-runtime
entitlements, dylibs don't) and notarize via notarytool --wait. Runs before
the collect step so the tarred image is signed. Job timeout 30->45 min for
notarization headroom.
- cli/packaging/macos/amy.entitlements: hardened-runtime entitlements; the
disable-library-validation key lets the JVM load the secp256k1 native dylib
it extracts from a jar at runtime (would otherwise crash under notarization).
- BUILDING.md: document the tarball signing, the no-stapling/online-check
caveat, and that the Homebrew-core jvm bundle is intentionally left unsigned.
Untested end-to-end (no macOS runner / Apple creds here) — validate with a
workflow_dispatch dry-run once the secrets are provisioned.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sso31DfSF9B6EFCVkEqWD
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
The Marmot subscription since, the processed-event dedup set, and the
application ratchet position (group state persists only at commits) are
all in-memory only. On restart, relays therefore redeliver the group's
entire kind:445 history and the rewound ratchet re-decrypts old
application messages as if they had just arrived — wasted decryption
work and, when a replay beats the disk restore, duplicate entries
appended to the persisted plaintext message log.
Two defenses:
- MarmotManager.restoreAll() now seeds each restored group's
subscription since from the newest persisted decrypted message, minus
a one-day overlap window for late/out-of-order publishes. Seeding
happens before syncWithGroupManager registers default entries, so
even the first filter set sent to relays carries it. The CLI is
unaffected: it builds group filters from its own persisted since.
- MarmotMessageStore appends are now explicitly idempotent (contract
was previously ambiguous and both real stores appended blindly):
the Android and CLI file stores skip an entry that is already in the
group's log, so replays inside the overlap window cannot grow it.
Covered by MarmotManagerRestoreTest in commons jvmTest — placed there
rather than androidHostTest because CI only runs :commons:jvmTest (the
androidHostTest task currently fails on android.util.Log stubs even
for the pre-existing Marmot test).
Mirrors the app's NIP-05 .well-known clink_offer discovery fallback (kind-0
offers are already readable via 'amy profile show'). Reuses the Context's
nip05Client.loadClinkOffer and decodes the resolved noffer into its fields.
Adds a bad-nip05 validation case to the headless harness; 17/17 pass.
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
amy zap printed the invoice but never paid it. With --with <ndebit> it now
settles the fetched BOLT-11 in-place through a CLINK debit pointer (kind-21002,
reusing DebitCommands.settle), mirroring how the app routes a zap through its
default payment source. Works for both single-recipient (zap user) and
split zaps (zap event) — each recipient reports paid + preimage (or pay_error).
Adds a --with validation case to the headless harness; 16/16 pass.
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
Brings amy's CLINK surface closer to the app's:
- profile edit --clink-offer <noffer|"">: set/clear the kind-0 clink_offer
(validated as a real noffer; "" clears). MetadataEvent already carried the field.
- offer request --follow: chase an 'Expired or Moved' (code 3) reply to its
'latest' pointer (bounded hops), mirroring the app; the error output now also
carries code/latest/range so a script can follow or correct manually.
- offer pay <noffer> --with <ndebit> [--amount]: end-to-end — fetch the invoice
(21001) and settle it through a debit pointer (21002), reusing DebitCommands.settle.
- Structured GFY detail (code, range, retry_after, delta) in debit/offer errors,
via a new Output.error(extra=) overload.
Adds local-validation cases to the headless harness (offer pay --with, profile
edit --clink-offer); 15/15 pass.
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
Local-only shell suite (no relay): asserts amy decodes the canonical CLINK
interop vectors (same fixtures as quartz ClinkInteropTest) to the right fields
for 'offer info' and 'debit info', plus the argument-error paths (bad pointer,
unknown budget frequency, missing --amount). The round-trip verbs need a live
service and stay out of scope.
12/12 assertions pass locally (amy init -> decode, no network).
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.
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.
Phase 0 of the desktop image compression plan
(docs/plans/2026-06-08-feat-desktop-image-compression-plan.md).
- commons jvmMain gains net.coobird:thumbnailator:0.4.21 (pure-Java,
MIT) — to be consumed by the new ImageReencoder in Phase 1.
- amy CLI now sets -Djava.awt.headless=true via three paths so any
transitive ImageIO/AWT touch never spawns a GUI thread:
* applicationDefaultJvmArgs in cli/build.gradle.kts (covers the
installDist startup scripts and any future jpackage launcher),
* the amyImage custom Unix launcher in cli/build.gradle.kts,
* System.setProperty as the first line of cli Main.kt — belt-
and-braces for invocations that bypass the launcher scripts.
- commons:jvmTest forces -Djava.awt.headless=true for the same
reason during test runs.
Smoke tests (CompressionSmokeTest.kt) document Thumbnailator's
upscale-by-default behavior — ImageReencoder must gate the resize
itself in Phase 1.
Document the commons module's purpose, source-set layout, and the CLI-safe vs
UI boundary in commons/ARCHITECTURE.md, then clean up the clearest package
overlaps that had accumulated:
- merge duplicate util/utils -> util (all source sets)
- unify service/services -> service (jvmAndroid)
- move data/UserMetadataCache -> model/cache
- fold compose/ into ui/ (ui/article, editor, elements, layouts, markdown,
nip53LiveActivities, and Compose helpers in ui/state + ui/text)
- move ProfileBroadcastBanner composable into profile/ui
All changes are whole-file/whole-package moves with import rewrites; no logic
changed. The chess logic/UI split is documented as deferred debt (it needs
file-level surgery, not moves). Marks docs/shared-ui-analysis.md superseded.
https://claude.ai/code/session_01KXLzsvx9Gyrm3Yz4Rims55
The framing: Amy gains every cashu action the Amethyst UI exposes, each
one reusing the exact same quartz + commons code the Android wallet
runs in production. The deliverable is a shell harness under
cli/tests/cashu/ that walks two Amy accounts through the full wallet
lifecycle against a production mint, so regressions in the cashu code
path fail on the JVM in CI without an emulator.
Plan covers:
- Three extractions before any verb lands: cashu token parsers to
quartz, CashuWalletOps to commons, CashuWalletReader projection
helpers to commons.
- One storage addition: ~/.amy/<account>/cashu.json for NUT-13 keyset
counters (deterministic secrets need durable counter state across
invocations).
- Command surface under amy cashu …: wallet, mint, balance, receive,
send, mint-rec, maintenance — mirrors every action the Android
wallet exposes.
- Stable --json shape per verb.
- 9-PR sequencing: extractions first, then verbs grouped by user
intent, then the 10-scenario interop harness.
- Acceptance criteria: harness passes against mint.minibits.cash and
the on-relay events are byte-equivalent to what Amethyst would
produce.
Fourth verb extraction alongside FollowActions / SearchActions /
ZapActions. Closes the largest remaining amy-expert "thin assembly"
violation in cli/.
Two pieces moved out of cli/.../DmCommands.kt into commons:
* DmActions.resolveDmRelays applies the strict-kind:10050 → NIP-65-
read → bootstrap fallback policy the in-app flow uses. Returns a
DmRelaySet with a typed RelaySource (KIND_10050 / NIP65_READ /
BOOTSTRAP / NONE) so callers can surface the source — amy emits
it on stdout, a future Gemini adapter could mention it in the
assistant response.
* DmActions.buildTextDm / buildFileDmReference are thin wrappers
over NIP17Factory.createMessageNIP17 / createEncryptedFileNIP17
that build the kind:14 / kind:15 template and gift-wrap in one
call. Matches the FollowActions / ZapActions builder shape.
amy's DmCommands is now genuinely thin assembly: requireUserHex,
flag plumbing, call DmActions, render JSON. The 583-line file shrank
slightly and — more importantly — no longer carries NIP-17 logic
the rest of the codebase needs to look at.
Receive-side decrypt loop (3 lines of unwrapAndUnsealOrNull) stays in
amy; too small to extract and tightly coupled to amy's per-relay
attribution.
10 new tests for DmActions: strict/permissive fallback chain, null
recipient lists, RelaySource enum stability, and a smoke test that
buildTextDm produces a kind:14 with the right wrap count (sender +
recipient).
Closes the remaining items from the comparative review of the extracted
actions against the in-app Amethyst flows. All small, all surfaced by the
review.
* amy follow now stamps the relay hint on new contact-list `p` tags.
Best-effort read from the target's cached kind:10002 advertised
relay list (first writeRelaysNorm). Mirrors User.bestRelayHint() —
follows added via amy no longer have empty relayUri.
* amy search user now dedups by pubkey (sorted newest-first) instead
of by event id, matching the App Functions adapter. Multiple relays
surfacing different kind:0 revisions for the same author collapse
to one hit.
* AmethystAppFunctions.searchProfiles captures the active account AND
the relay client at function entry, then never touches sessionManager
or Amethyst.instance again during the drain. Closes the account-
switch race surfaced in the review.
* FollowActions / SearchActions / ZapActions kdoc now lists the
caller-side responsibilities each builder leaves to the consumer
(publish, writeable check, relay hint, pseudo-kind filtering,
LN round-trip, receipt verification, etc.). Documents the design
rather than letting it leak through reviews.
The previous ZapActions.buildEventZapRequest signed a single zap request
to a single recipient. Notes carrying NIP-57 zap-split tags, NIP-53
live-activity host tags, or NIP-89 app-definition metadata expect the
payment to be distributed across multiple parties — so `amy zap event`
silently overpaid one party and underpaid the rest. The correctness
review on the action-set flagged this as the only real bug in the
extracted verbs; this commit fixes it.
* ZapSplitResolver — new commonMain object mirroring the resolution
order in ZapPaymentHandler.kt (splits > live-activity hosts > app
metadata > author fallback). Pure logic; pubkey→LN-address lookup
is passed in as a suspend lambda so amy reads from its file store
and Android reads from LocalCache, no shared cache-coupling.
* ZapActions.buildEventZapRequestsForSplits — high-level helper that
composes the resolver with per-share LnZapRequestEvent signing.
Each request's `relays` tag unions sender + author + recipient
inbox relays so the kind:9735 receipt routes to every interested
party (matches signAllZapRequests in the Android handler).
* amy zap event — rewired to the split-aware path. JSON output now
enumerates each recipient with its share, LN address, request id,
and BOLT11 invoice (or per-recipient invoice_error). Profile zaps
(amy zap user) keep the simple single-recipient path since they
have no split tags.
Tests: 12 new cases — LN-address splits, weighted pubkey splits, author
fallback, drop-silently-on-missing-LN, relay unioning, share rounding.
All 41 action tests green; both Android flavors compile.
Third verb extraction alongside FollowActions / SearchActions, scoped
to event building so the action stays target-agnostic (commonMain,
no JVM/Android coupling).
* buildUserZapRequest / buildEventZapRequest wrap the two
LnZapRequestEvent.create overloads with a uniform call shape and
sensible defaults (PUBLIC zap, no LNURL, no poll).
* extractLnAddress pulls lud16 (preferred) or lud06 from a kind:0
metadata event, returning null when neither is set.
* satsToMillisats covers the sats→msats conversion that every
caller would otherwise duplicate.
Wires up amy zap user|event as the first consumer. The Lightning
round-trip (LNURL fetch + invoice retrieval) goes through the existing
LightningAddressResolver in commons/jvmAndroid; the BOLT11 invoice is
printed but not auto-paid since amy has no NWC wallet wired up yet.
Introduce SearchActions alongside FollowActions as the second of the
shared "verbs" usable by amy CLI and a future Android App Functions
adapter for Gemini.
* searchProfilesFilter / searchNotesFilter build the relay-side
Filter with the NIP-50 `search` field set; blank queries return
null so callers don't issue unconstrained searches that relays
would reject anyway.
* resolveSearchRelays picks the caller's kind:10007 list when
configured (decrypting NIP-44 private entries via the signer) and
falls back to DefaultSearchRelayList — the same set the Android UI
uses when the user has no list of their own.
Wires up amy search user|note as the first consumer.
Introduce commons/.../actions/FollowActions as the canonical, non-UI
entry point for NIP-02 kind:3 mutations. Accepts pubkeys as HexKey
rather than the Compose-bound User model, so callers without a cache
(amy CLI, future Android App Functions adapter for Gemini, automation
scripts) can drive follow/unfollow directly.
Kind3FollowListState.follow/unfollow now delegate to FollowActions,
preserving the existing Account.follow(user) signature on Android.
Behavior is unchanged for UI callers.
Wires up amy follow/unfollow as the first consumer — fetches the
freshest kind:3 from outbox relays before mutating so concurrent
follows from another client are preserved.