Skip to content

[FEAT] CLI crate #203

@erskingardner

Description

@erskingardner

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, []).awaitKind: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

  1. Load all active groups from storage
  2. Build relay set: union of all group relay URLs + any --relay flags
  3. For each group: subscribe to Kind:445 events with #h = nostr_group_id
  4. Global subscription: Kind:1059 events with #p = own pubkey (incoming gift-wraps)
  5. 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
  6. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels
    No fields configured for Tracking.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions