This is a tracking issue to start getting ideas together for the official Marmot CLI.
We should use this main issue description as the canonical source of truth for planning (everyone can/should edit when we come to agreement on something). We can use comments for discussion.
Here's a first draft of a basic plan as a starting point. It's mostly Claude so I imagine we can do better...
1. New Crate: crates/mdk-cli
A new workspace member. Binary target mdk.
crates/mdk-cli/
├── Cargo.toml
└── src/
├── main.rs — tokio::main, top-level error handling, exit codes
├── cli.rs — clap Cli struct + top-level subcommand enum
├── config.rs — MDK_SECRET_KEY, MDK_RELAYS, MDK_DATA_DIR env var resolution
├── output.rs — Output<T: Serialize> wrapper, --human printer trait
├── relay.rs — nostr-sdk Client builder helpers (connect, publish, fetch, gift-wrap)
├── storage.rs — open SQLite DB, resolve data dir via dirs::data_dir()
└── commands/
├── mod.rs
├── identity.rs — pubkey, key-package
├── group.rs — create, list, get, update, leave, sync, needs-update
├── member.rs — add, remove, pending
├── message.rs — send, list, get
├── welcome.rs — list, get, accept, decline
├── commit.rs — merge, clear, self-update
└── watch.rs — continuous subscription loop + auto self-update
2. Workspace Changes
Cargo.toml (workspace root): Add nostr-sdk = "0.44" to [workspace.dependencies] with features ["nip44", "nip59"].
Add mdk-cli as a workspace member.
3. Dependencies (crates/mdk-cli/Cargo.toml)
[dependencies]
mdk-core = { workspace = true }
mdk-sqlite-storage = { workspace = true }
nostr = { workspace = true, features = ["std", "nip44", "nip59"] }
nostr-sdk = { version = "0.44", features = ["nip44", "nip59"] }
clap = { version = "4", features = ["derive", "env"] }
tokio = { workspace = true, features = ["full"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
dirs = "5"
tracing = { workspace = true }
tracing-subscriber = { workspace = true, features = ["env-filter"] }
4. Configuration (config.rs)
rs pub struct Config { pub keys: Keys, // parsed from MDK_SECRET_KEY (nsec or hex) pub relays: Vec<RelayUrl>, // MDK_RELAYS comma-separated + any --relay flags pub data_dir: PathBuf, // MDK_DATA_DIR or dirs::data_dir()/mdk-cli }
Missing MDK_SECRET_KEY → print Error: MDK_SECRET_KEY is not set and exit(1).
5. Output Model (output.rs)
All commands return Result<Output, CliError>. In main.rs:
- JSON mode (default):
println!("{}", serde_json::to_string(&output)?)
- Success:
{"ok":true,"data":{...}}
- Error:
{"ok":false,"error":"..."}
- Human mode (
--human): formatted multi-line text via a HumanPrinter impl
mdk watch outputs newline-delimited JSON, one object per processed event, to stdout. Logs go to stderr via tracing-subscriber.
6. Full Command Surface
mdk [--human] [--relay <url>]... <SUBCOMMAND>
# Identity
mdk identity pubkey
→ { "pubkey": "<hex>" }
mdk identity key-package [--relay <url>]...
→ creates key package, publishes Kind:443 to relays
→ { "event_id": "...", "relays": [...] }
# Groups
mdk group create --name <n> [--description <d>] [--relay <url>]... [--member <pubkey>]...
→ calls create_group; gift-wraps + publishes welcomes; publishes commit if any
→ { "group_id": "...", "nostr_group_id": "...", "welcomed": ["pubkey",...] }
mdk group list
→ { "groups": [{ "id", "name", "state", "epoch", "member_count" }, ...] }
mdk group get <group-id>
→ full Group object as JSON
mdk group members <group-id>
→ { "members": ["pubkey", ...] }
mdk group relays <group-id>
→ { "relays": ["wss://...", ...] }
mdk group update <group-id> [--name <n>] [--description <d>] [--relay <url>]... [--admin <pubkey>]...
→ calls update_group_data; publishes commit
→ { "event_id": "...", "group_id": "..." }
mdk group leave <group-id>
→ calls leave_group; publishes leave proposal
→ { "event_id": "...", "group_id": "..." }
mdk group sync <group-id>
→ calls sync_group_metadata_from_mls
→ { "ok": true }
mdk group needs-update [--threshold-secs <n>]
→ calls groups_needing_self_update
→ { "groups": ["group-id", ...] }
# Members
mdk member add <group-id> <pubkey>...
→ fetches Kind:443 from relays for each pubkey, calls add_members,
calls merge_pending_commit, publishes commit + gift-wraps welcomes
→ { "event_id": "...", "welcomed": ["pubkey", ...] }
mdk member remove <group-id> <pubkey>...
→ calls remove_members, calls merge_pending_commit, publishes commit
→ { "event_id": "...", "removed": ["pubkey", ...] }
mdk member pending <group-id>
→ calls pending_member_changes
→ { "additions": [...], "removals": [...] }
# Messages
mdk message send <group-id> --content <text> [--kind <n>]
→ builds UnsignedEvent (Kind::TextNote by default), calls create_message, publishes
→ { "event_id": "..." }
mdk message list <group-id> [--limit <n>] [--offset <n>] [--order created|processed]
→ calls get_messages with Pagination
→ { "messages": [{ "id", "pubkey", "content", "created_at", "epoch", "state" }, ...] }
mdk message get <group-id> <event-id>
→ calls get_message
→ full Message object as JSON
# Welcomes
mdk welcome list [--limit <n>] [--offset <n>]
→ calls get_pending_welcomes
→ { "welcomes": [{ "id", "group_name", "welcomer", "member_count", "state" }, ...] }
mdk welcome get <event-id>
→ calls get_welcome
→ full Welcome object as JSON
mdk welcome accept <event-id>
→ calls get_welcome then accept_welcome, then self_update, publishes self-update commit
→ { "ok": true, "group_id": "..." }
mdk welcome decline <event-id>
→ calls get_welcome then decline_welcome
→ { "ok": true }
# Commits
mdk commit merge <group-id>
→ calls merge_pending_commit
→ { "ok": true }
mdk commit clear <group-id>
→ calls clear_pending_commit
→ { "ok": true }
mdk commit self-update <group-id>
→ calls self_update, calls merge_pending_commit, publishes commit
→ { "event_id": "..." }
# Watch (daemon)
mdk watch [<group-id>...]
→ subscribes to all active groups (or specified ones)
→ per-group relay subscriptions on Kind:445 + h tag filter
→ own-pubkey subscription on Kind:1059 (gift-wrapped welcomes)
→ for each event processed: emits JSON line to stdout
→ after any commit, checks groups_needing_self_update and auto-runs self_update
→ runs until SIGINT; reconnects on relay disconnect
7. NIP-59 Gift-Wrapping in the CLI
For welcome rumors (Vec<UnsignedEvent> returned by create_group / add_members):
for each (recipient_pubkey, rumor) in welcome_rumors:
EventBuilder::gift_wrap(&keys, &recipient_pubkey, rumor, []).await → Kind:1059 Event (signed)
client.send_event_to(recipient_relay_urls, gift_wrap_event).await
Using EventBuilder::gift_wrap directly from the nostr crate (already available in workspace at 0.44 with nip59 feature).
8. Watch Mode Design
- Load all active groups from storage
- Build relay set: union of all group relay URLs + any
--relay flags
- For each group: subscribe to Kind:445 events with
#h = nostr_group_id
- Global subscription: Kind:1059 events with
#p = own pubkey (incoming gift-wraps)
- handle_notifications loop:
a. RelayPoolNotification::Event received:
- Kind:445 →
mdk.process_message(&event) → emit JSON result line
- Kind:1059 →
extract_rumor(signer, event) →
- Kind:444 rumor →
mdk.process_welcome(wrapper_id, rumor) → emit JSON
- Kind:445 rumor →
mdk.process_message → emit JSON
b. After any Commit result: check groups_needing_self_update(threshold)
→ for each: self_update + merge_pending_commit + publish + emit JSON
- Reconnect loop: on relay disconnect, re-add and reconnect
9. Storage Path
| Platform |
Default path |
| macOS |
~/Library/Application Support/mdk-cli/mdk.db |
| Linux |
~/.local/share/mdk-cli/mdk.db |
| Windows |
%APPDATA%\mdk-cli\mdk.db |
Override with MDK_DATA_DIR=/path/to/dir (DB file is always mdk.db within that dir).
10. Exit Codes
| Code |
Meaning |
| 0 |
Success |
| 1 |
Configuration error (missing key, bad relay URL) |
| 2 |
Protocol / library error |
| 3 |
Relay / network error |
| 130 |
Interrupted (SIGINT in watch mode) |
11. What's NOT in Scope (for now)
- mip04 encrypted media commands (can be gated behind a mip04 feature flag later)
- Config file
- Shell completion generation (though clap makes this trivial to add later)
- NIP-46 remote signing
- TUI
This is a tracking issue to start getting ideas together for the official Marmot CLI.
We should use this main issue description as the canonical source of truth for planning (everyone can/should edit when we come to agreement on something). We can use comments for discussion.
Here's a first draft of a basic plan as a starting point. It's mostly Claude so I imagine we can do better...
1. New Crate: crates/mdk-cli
A new workspace member. Binary target mdk.
crates/mdk-cli/ ├── Cargo.toml └── src/ ├── main.rs — tokio::main, top-level error handling, exit codes ├── cli.rs — clap Cli struct + top-level subcommand enum ├── config.rs — MDK_SECRET_KEY, MDK_RELAYS, MDK_DATA_DIR env var resolution ├── output.rs — Output<T: Serialize> wrapper, --human printer trait ├── relay.rs — nostr-sdk Client builder helpers (connect, publish, fetch, gift-wrap) ├── storage.rs — open SQLite DB, resolve data dir via dirs::data_dir() └── commands/ ├── mod.rs ├── identity.rs — pubkey, key-package ├── group.rs — create, list, get, update, leave, sync, needs-update ├── member.rs — add, remove, pending ├── message.rs — send, list, get ├── welcome.rs — list, get, accept, decline ├── commit.rs — merge, clear, self-update └── watch.rs — continuous subscription loop + auto self-update2. Workspace Changes
Cargo.toml(workspace root): Addnostr-sdk = "0.44"to[workspace.dependencies]with features["nip44", "nip59"].Add
mdk-clias a workspace member.3. Dependencies (crates/mdk-cli/Cargo.toml)
4. Configuration (config.rs)
rs pub struct Config { pub keys: Keys, // parsed from MDK_SECRET_KEY (nsec or hex) pub relays: Vec<RelayUrl>, // MDK_RELAYS comma-separated + any --relay flags pub data_dir: PathBuf, // MDK_DATA_DIR or dirs::data_dir()/mdk-cli }Missing
MDK_SECRET_KEY→ printError: MDK_SECRET_KEY is not setandexit(1).5. Output Model (output.rs)
All commands return
Result<Output, CliError>. Inmain.rs:println!("{}", serde_json::to_string(&output)?){"ok":true,"data":{...}}{"ok":false,"error":"..."}--human): formatted multi-line text via aHumanPrinter implmdk watch outputs newline-delimited JSON, one object per processed event, to stdout. Logs go to stderr via tracing-subscriber.
6. Full Command Surface
7. NIP-59 Gift-Wrapping in the CLI
For welcome rumors (
Vec<UnsignedEvent>returned bycreate_group/add_members):for each
(recipient_pubkey, rumor)inwelcome_rumors:Using
EventBuilder::gift_wrapdirectly from the nostr crate (already available in workspace at 0.44 with nip59 feature).8. Watch Mode Design
--relayflags#h=nostr_group_id#p= own pubkey (incoming gift-wraps)a.
RelayPoolNotification::Eventreceived:mdk.process_message(&event)→ emit JSON result lineextract_rumor(signer, event)→mdk.process_welcome(wrapper_id, rumor)→ emit JSONmdk.process_message→ emit JSONb. After any
Commitresult:check groups_needing_self_update(threshold)→ for each:
self_update+merge_pending_commit+ publish + emit JSON9. Storage Path
Override with
MDK_DATA_DIR=/path/to/dir(DB file is alwaysmdk.dbwithin that dir).10. Exit Codes
11. What's NOT in Scope (for now)