Re-introspected the real nak binary: 34 functional commands. Fixes the
stale count — adds nsite to full (amy has NsiteCommands), corrects missing
to 7, and clarifies group/nip29 is an intentional MLS/Marmot divergence,
not a gap. Drops the stale "validate" cheap-win (key validate shipped).
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
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
`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
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
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
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."
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.
`tools/` is for libraries (arti-build); shell-based interop harnesses
that drive the `amy` binary belong with the CLI module. New layout:
cli/tests/
├── lib.sh # shared logging + result tracking
├── headless/helpers.sh # shared amy_a / amy_json / assertions
├── marmot/ # MLS group-messaging interop (vs whitenoise-rs)
│ ├── marmot-interop.sh
│ ├── marmot-interop-headless.sh
│ ├── setup.sh
│ ├── tests-create.sh
│ ├── tests-manage.sh
│ ├── tests-extras.sh
│ └── patches/
└── dm/ # NIP-17 DM interop (amy ↔ amy)
├── dm-interop-headless.sh
├── setup.sh
└── tests-dm.sh
Per-suite changes:
- Each suite owns its own setup.sh; previously the Marmot setup was at
headless/setup.sh and the DM setup at headless/setup-dm.sh, which
implied shared infra they don't actually share.
- `cli/tests/lib.sh` and `cli/tests/headless/helpers.sh` are the only
shared bits across suites.
- The DM harness still reuses Marmot's `start_local_relay` /
`stop_local_relay` (sourced via `../marmot/setup.sh`) — same relay
binary, no duplication.
- All sourcing paths updated; REPO_ROOT now climbs three levels (was
two when the harness lived under `tools/`); patches/ is now a sibling
of marmot/setup.sh rather than under marmot/headless/.
- `.gitignore` updated to cover both suites' state dirs.
cli/ROADMAP.md and cli/tests/README.md updated to point at the new paths.
No behavioural change — every test runs the same code; only the file
layout and source paths moved.
Adds `tools/marmot-interop/dm-interop-headless.sh` — a zero-prompt
end-to-end harness that runs two independent `amy` processes against
a loopback nostr-rs-relay and verifies the NIP-17 DM surface.
Six scenarios:
dm-01 text round-trip (kind:14) in both directions
dm-02 `dm list` surfaces prior exchange with `type:text` discriminator
dm-03 strict kind:10050 refuses sends to an inboxless recipient
(surfaces the `no_dm_relays` error JSON)
dm-04 `--allow-fallback` opts into NIP-65 read / bootstrap chain
(asserts a `relay_source: "bootstrap"` or `"nip65_read"` row)
dm-05 file message reference mode (kind:15) — send-file with
--key / --nonce, verify recipient decodes url + key + nonce + mime
dm-06 no-flag `dm list` advances `state.giftWrapSince`; second call
returns an empty `messages` array
Shape matches the existing Marmot harness: reuses `lib.sh` for logging
+ result tracking, reuses `setup.sh` for the nostr-rs-relay lifecycle
(`start_local_relay` / `stop_local_relay`), adds a slim `setup-dm.sh`
preflight (amy + relay only, no whitenoise-rs / Marmot patches), and
defines tests in `tests-dm.sh`.
No new CI job yet — the relay build needs Rust + ~3 minutes on a
cold cache, same constraint as the Marmot harness.
Upload-mode (`dm send-file --file PATH --server URL`) is not scripted
here: it needs a local Blossom server, which is out of scope for this
pass. The shared upload classes are unit-tested on desktop at
`desktopApp/src/jvmTest/kotlin/.../service/upload/`.
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.