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

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions