From 6946ab03fd21cbf5483c2783f745b064bea343a1 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 18 May 2026 18:36:30 -0400 Subject: [PATCH 1/5] feat(nest): add sprout-cli agent skill to nest initialization Agents migrating from the MCP server to the CLI have no structured guidance on command usage, output formats, or polling patterns. Ships a 1,350-word SKILL.md covering all 12 command groups, written to ~/.sprout/.claude/skills/sprout-cli/ on first launch using the same idempotent O_CREAT|O_EXCL pattern as AGENTS.md. --- desktop/src-tauri/src/managed_agents/nest.rs | 72 ++++ .../src/managed_agents/nest_skill.md | 364 ++++++++++++++++++ 2 files changed, 436 insertions(+) create mode 100644 desktop/src-tauri/src/managed_agents/nest_skill.md diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index 529455165a..e2d7ab9173 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -24,6 +24,10 @@ const NEST_DIRS: &[&str] = &[ /// Fully static — no runtime interpolation, no secrets, no user paths. const AGENTS_MD: &str = include_str!("nest_agents.md"); +/// Default SKILL.md content for the sprout-cli Claude Code skill. +/// Written to ~/.sprout/.claude/skills/sprout-cli/SKILL.md on first init. +const SPROUT_CLI_SKILL_MD: &str = include_str!("nest_skill.md"); + /// Returns the nest root path (`~/.sprout`), or `None` if the home /// directory cannot be resolved. pub fn nest_dir() -> Option { @@ -98,6 +102,28 @@ pub fn ensure_nest_at(root: &Path) -> Result<(), String> { } } + // Write sprout-cli skill alongside AGENTS.md (same idempotent pattern). + let skill_dir = root.join(".claude/skills/sprout-cli"); + fs::create_dir_all(&skill_dir) + .map_err(|e| format!("create {}: {e}", skill_dir.display()))?; + + let skill_md = root.join(".claude/skills/sprout-cli/SKILL.md"); + match fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&skill_md) + { + Ok(mut file) => { + use std::io::Write; + file.write_all(SPROUT_CLI_SKILL_MD.as_bytes()) + .map_err(|e| format!("write {}: {e}", skill_md.display()))?; + } + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(e) => { + return Err(format!("create {}: {e}", skill_md.display())); + } + } + // Set owner-only permissions on root and all subdirectories. // Skip any path that is a symlink — chmod would affect the target. #[cfg(unix)] @@ -117,6 +143,15 @@ pub fn ensure_nest_at(root: &Path) -> Result<(), String> { .map_err(|e| format!("set permissions on {}: {e}", path.display()))?; } } + // Skill directory also gets 700 permissions. + let is_symlink = skill_dir + .symlink_metadata() + .map(|m| m.file_type().is_symlink()) + .unwrap_or(false); + if !is_symlink { + fs::set_permissions(&skill_dir, perms.clone()) + .map_err(|e| format!("set permissions on {}: {e}", skill_dir.display()))?; + } } Ok(()) @@ -260,6 +295,43 @@ mod tests { assert!(result.unwrap_err().contains("symlink")); } + #[test] + fn ensure_nest_creates_skill_file() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".sprout"); + ensure_nest_at(&root).unwrap(); + let skill = root.join(".claude/skills/sprout-cli/SKILL.md"); + assert!(skill.exists(), "SKILL.md should exist"); + let content = fs::read_to_string(&skill).unwrap(); + assert_eq!(content, SPROUT_CLI_SKILL_MD); + } + + #[test] + fn ensure_nest_does_not_overwrite_skill_file() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".sprout"); + ensure_nest_at(&root).unwrap(); + let skill = root.join(".claude/skills/sprout-cli/SKILL.md"); + fs::write(&skill, "custom skill content").unwrap(); + ensure_nest_at(&root).unwrap(); + assert_eq!( + fs::read_to_string(&skill).unwrap(), + "custom skill content" + ); + } + + #[cfg(unix)] + #[test] + fn ensure_nest_skill_dir_has_700_permissions() { + use std::os::unix::fs::PermissionsExt; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".sprout"); + ensure_nest_at(&root).unwrap(); + let skill_dir = root.join(".claude/skills/sprout-cli"); + let mode = fs::metadata(&skill_dir).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o700, "skill dir should be 700"); + } + #[cfg(unix)] #[test] fn ensure_nest_skips_permissions_on_symlinked_child() { diff --git a/desktop/src-tauri/src/managed_agents/nest_skill.md b/desktop/src-tauri/src/managed_agents/nest_skill.md new file mode 100644 index 0000000000..9499dbc73f --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/nest_skill.md @@ -0,0 +1,364 @@ +--- +name: sprout-cli +description: > + Use the Sprout CLI (`sprout` command) to interact with a Sprout relay: send + and read messages in channels, post threaded replies, manage channels + (create, list, join, leave, archive), set canvas documents, add reactions, + open direct messages, query user profiles and presence, trigger and approve + workflows, search messages, post code diffs, and manage repositories. + Activate when the task involves messaging, channels, feeds, DMs, reactions, + workflows, or any Sprout relay operation via the `sprout` command. +version: 1 +--- + +# Sprout CLI Skill + +## Environment + +`SPROUT_PRIVATE_KEY` is pre-set by the harness. Never prompt for it, never read it, never echo it. All authentication is handled automatically via NIP-98 Schnorr signatures derived from this key. + +`SPROUT_RELAY_URL` defaults to `http://localhost:3000`. Override only if explicitly instructed. + +All output is JSON on stdout. Commands that return lists return JSON arrays; commands that return a single resource return a JSON object. + +Errors go to stderr as `{"error": "category", "message": "detail"}`. Exit codes: 0=ok, 1=input error, 2=network/relay error, 3=auth error, 4=other error. On non-zero exit, parse stderr for the error message before retrying or escalating. + +## Parameter Conventions + +- `--channel` accepts UUID format (e.g., `550e8400-e29b-41d4-a716-446655440000`) +- `--event` accepts 64-character lowercase hex (e.g., `a3f2...`). Do not pass Bech32-encoded `note1...` identifiers — convert first if needed. +- `--pubkey` accepts 64-character lowercase hex. Do not pass `npub...` identifiers. +- `--content -` reads content from stdin, enabling pipe-friendly workflows. +- Content max 65,536 bytes. Larger content will be rejected with exit code 1. +- Diffs max 61,440 bytes; the CLI auto-truncates at a hunk boundary if the diff exceeds this limit. + +## Messaging + +Send a message to a channel: + +```bash +sprout messages send --channel --content "text" +``` + +Send a threaded reply: + +```bash +sprout messages send --channel --content "reply text" --reply-to +``` + +Read recent messages (returns array, newest messages at the end when no `--since`): + +```bash +sprout messages get --channel --limit 20 +sprout messages get --channel --limit 50 --since 1716000000 +sprout messages get --channel --limit 50 --before 1716050000 +``` + +Get a thread rooted at a specific event: + +```bash +sprout messages thread --channel --event +``` + +Full-text search across all channels you can access: + +```bash +sprout messages search --query "architecture decision" +``` + +Send a code diff with repository metadata (pipe the diff via stdin): + +```bash +git diff HEAD~1 | sprout messages send-diff --channel --diff - --repo https://github.com/org/repo --commit abc123def456 +``` + +Edit a message you sent: + +```bash +sprout messages edit --event --content "updated text" +``` + +Delete a message: + +```bash +sprout messages delete --event +``` + +Vote on a forum post: + +```bash +sprout messages vote --event --direction up +sprout messages vote --event --direction down +``` + +## Channels + +List all visible channels: + +```bash +sprout channels list +``` + +Returns `[{channel_id, name, description, created_at}]`. + +Filter channel lists: + +```bash +sprout channels list --member # only channels you've joined +sprout channels list --visibility open # open or private +``` + +Create a channel: + +```bash +sprout channels create --name "eng-backend" --type stream --visibility open +``` + +Returns `{event_id, channel_id, accepted, message}`. Use `channel_id` for subsequent operations. + +Get channel details: + +```bash +sprout channels get --channel +``` + +Join or leave: + +```bash +sprout channels join --channel +sprout channels leave --channel +``` + +Set topic or purpose: + +```bash +sprout channels topic --channel --topic "Sprint 42 coordination" +sprout channels purpose --channel --purpose "Backend team daily sync" +``` + +List members: + +```bash +sprout channels members --channel +``` + +Returns `[{pubkey, role}]`. + +Admin operations (require `admin:channels` scope): + +```bash +sprout channels archive --channel +sprout channels unarchive --channel +sprout channels delete --channel +``` + +## Canvas + +Get the canvas document for a channel (returns the markdown content string directly, not JSON-wrapped): + +```bash +sprout canvas get --channel +``` + +Set the canvas (inline or via stdin): + +```bash +sprout canvas set --channel --content "# Project Brief\n\nObjectives..." +echo "# Doc" | sprout canvas set --channel --content - +``` + +## Reactions + +Add a reaction to any event (message, note, etc.): + +```bash +sprout reactions add --event --emoji "👍" +``` + +Remove a reaction: + +```bash +sprout reactions remove --event --emoji "👍" +``` + +Get all reactions on an event: + +```bash +sprout reactions get --event +``` + +Returns `{"reactions": [{emoji, count, pubkeys}]}`. + +## DMs + +List existing DM conversations: + +```bash +sprout dms list +``` + +Returns `[{dm_id, participants, created_at}]`. + +Open a new DM (creates a group DM conversation): + +```bash +sprout dms open --pubkey +``` + +Returns `{event_id, dm_id, accepted, message}`. Use `dm_id` as the `--channel` value for subsequent `messages` commands. + +Add a member to a DM group: + +```bash +sprout dms add-member --channel --pubkey +``` + +## Users + +Get your own profile: + +```bash +sprout users get +``` + +Returns a flat profile object: `{display_name, about, picture, pubkey, ...}`. + +Get a specific user's profile: + +```bash +sprout users get --pubkey +``` + +Batch lookup (up to 200 pubkeys): + +```bash +sprout users get --pubkey --pubkey --pubkey +``` + +Search by display name: + +```bash +sprout users get --name "alice" +``` + +Update your profile: + +```bash +sprout users set-profile --name "Alice" --avatar "https://example.com/avatar.png" --about "Backend engineer" --nip05 "alice@example.com" +``` + +Get presence for one or more users: + +```bash +sprout users presence --pubkeys , +``` + +Returns `[{pubkey, status, updated_at}]`. Status values: `online`, `away`, `offline`. + +Set your own presence: + +```bash +sprout users set-presence --status online +sprout users set-presence --status away +sprout users set-presence --status offline +``` + +## Workflows + +List workflows for a channel: + +```bash +sprout workflows list --channel +``` + +Returns `[{workflow_id, content, created_at}]`. + +Get a specific workflow definition: + +```bash +sprout workflows get --workflow +``` + +Create a workflow (YAML definition inline or via stdin): + +```bash +sprout workflows create --channel --yaml "name: review\nsteps: ..." +cat workflow.yaml | sprout workflows create --channel --yaml - +``` + +Returns `{event_id, workflow_id, accepted, message}`. + +Trigger a workflow: + +```bash +sprout workflows trigger --workflow +``` + +Approve or deny a pending workflow step: + +```bash +sprout workflows approve --token # approve (default) +sprout workflows approve --token --approved false --note "needs revision" +``` + +Get run history for a workflow: + +```bash +sprout workflows runs --workflow +``` + +Returns `[{event_id, kind, content, created_at, tags}]`. + +## Feed + +Get your activity feed (mentions, needs-action items, recent channel activity): + +```bash +sprout feed get --limit 20 +``` + +Returns events sorted newest-first. + +Poll for recent activity since a timestamp: + +```bash +sprout feed get --since 1716000000 --limit 50 +``` + +## Polling Pattern + +The Sprout relay has no push or webhook support. Poll with `--since` and sleep between iterations. + +When `--since` is set without `--before`, `messages get` returns results oldest-first (chronological order). `feed get` always returns newest-first regardless of `--since`. + +Recommended poll loop: + +1. Run `sprout messages get --channel --limit 50` — note the maximum `created_at` value from results. +2. Sleep 10–30 seconds. +3. Run `sprout messages get --channel --since --limit 50`. +4. Repeat from step 2, advancing `--since` each iteration. + +Use shorter intervals (10s) when latency matters; longer intervals (30s) for background monitoring. Avoid intervals under 5 seconds to prevent relay rate limiting. + +## Quick Reference + +| Command | Required Flags | Returns | +|---------|---------------|---------| +| `messages send` | `--channel`, `--content` | `{event_id, ...}` | +| `messages get` | `--channel` | array of message objects | +| `messages thread` | `--channel`, `--event` | array of thread events | +| `messages search` | `--query` | array of matching messages | +| `messages send-diff` | `--channel`, `--diff`, `--repo`, `--commit` | `{event_id, ...}` | +| `channels list` | — | `[{channel_id, name, description, created_at}]` | +| `channels create` | `--name`, `--type`, `--visibility` | `{event_id, channel_id, accepted, message}` | +| `channels join` | `--channel` | `{event_id, accepted, message}` | +| `channels members` | `--channel` | `[{pubkey, role}]` | +| `canvas get` | `--channel` | markdown string (not JSON) | +| `canvas set` | `--channel`, `--content` | `{event_id, accepted, message}` | +| `reactions add` | `--event`, `--emoji` | `{event_id, accepted, message}` | +| `reactions get` | `--event` | `{"reactions": [{emoji, count, pubkeys}]}` | +| `dms list` | — | `[{dm_id, participants, created_at}]` | +| `dms open` | `--pubkey` | `{event_id, dm_id, accepted, message}` | +| `users get` | — | flat profile object | +| `workflows list` | `--channel` | `[{workflow_id, content, created_at}]` | +| `feed get` | — | array of feed events, newest-first | From 8339e5f832c9ba861e6abd379839163fa021fb8d Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 18 May 2026 19:08:26 -0400 Subject: [PATCH 2/5] fix(nest): correct skill content inaccuracies + chmod intermediate dirs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four inaccuracies in nest_skill.md caught by comparing against CLI source: 1. canvas get returns a JSON array of kind:40100 events (markdown in `content`), not a plain markdown string. Agents parsing this as a raw string would get the Nostr event wrapper, not the content. 2. reactions get returns a raw JSON array of kind:7 events, not the aggregated {"reactions": [...]} shape. No aggregation happens in cmd_get_reactions. 3. Error stderr category field values were placeholder text ("input error", "network/relay error") instead of the actual enum values emitted by error.rs: user_error, relay_error, network_error, auth_error, key_error. 4. set-presence documents as working but cmd_set_presence has an explicit comment noting it always fails — kind:20001 is ephemeral and the relay rejects it over HTTP POST. Added a "this will fail" caveat. Also fixes a permission gap in nest.rs: create_dir_all(".claude/skills/ sprout-cli") created the intermediate .claude/ and .claude/skills/ dirs with umask-default permissions (typically 755), but only the final sprout-cli/ dir was chmod'd to 700. Lock down all three dirs in the skill path, matching how NEST_DIRS are handled. Test updated to assert all three dirs get 700 on Unix. Docstring for ensure_nest_at updated to mention SKILL.md. --- desktop/src-tauri/src/managed_agents/nest.rs | 39 ++++++++++++------- .../src/managed_agents/nest_skill.md | 12 +++--- 2 files changed, 33 insertions(+), 18 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index e2d7ab9173..a84ebeb7bc 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -47,10 +47,12 @@ pub fn ensure_nest() -> Result<(), String> { /// /// - Creates the root directory and all subdirectories. /// - Writes `AGENTS.md` only if it doesn't already exist. -/// - Sets 700 permissions on the root and all subdirectories (Unix). +/// - Writes `.claude/skills/sprout-cli/SKILL.md` only if it doesn't already exist. +/// - Sets 700 permissions on the root, all subdirectories, and the skill +/// directory tree (Unix). /// /// Idempotent: safe to call on every launch. Existing files are never -/// overwritten — users can freely edit AGENTS.md and it will persist. +/// overwritten — users can freely edit AGENTS.md or SKILL.md and they persist. /// /// Rejects symlinks at the root path to prevent redirect attacks. /// @@ -143,14 +145,22 @@ pub fn ensure_nest_at(root: &Path) -> Result<(), String> { .map_err(|e| format!("set permissions on {}: {e}", path.display()))?; } } - // Skill directory also gets 700 permissions. - let is_symlink = skill_dir - .symlink_metadata() - .map(|m| m.file_type().is_symlink()) - .unwrap_or(false); - if !is_symlink { - fs::set_permissions(&skill_dir, perms.clone()) - .map_err(|e| format!("set permissions on {}: {e}", skill_dir.display()))?; + // Skill directory and its intermediate parents inside root get 700. + // create_dir_all creates .claude/ and .claude/skills/ with umask + // defaults — lock them down the same way we do NEST_DIRS. + for dir in [ + root.join(".claude"), + root.join(".claude/skills"), + skill_dir.clone(), + ] { + let is_symlink = dir + .symlink_metadata() + .map(|m| m.file_type().is_symlink()) + .unwrap_or(false); + if !is_symlink { + fs::set_permissions(&dir, perms.clone()) + .map_err(|e| format!("set permissions on {}: {e}", dir.display()))?; + } } } @@ -327,9 +337,12 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path().join(".sprout"); ensure_nest_at(&root).unwrap(); - let skill_dir = root.join(".claude/skills/sprout-cli"); - let mode = fs::metadata(&skill_dir).unwrap().permissions().mode() & 0o777; - assert_eq!(mode, 0o700, "skill dir should be 700"); + // All three dirs in the skill path should be locked down. + for dir in [".claude", ".claude/skills", ".claude/skills/sprout-cli"] { + let path = root.join(dir); + let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o700, "{dir} should be 700"); + } } #[cfg(unix)] diff --git a/desktop/src-tauri/src/managed_agents/nest_skill.md b/desktop/src-tauri/src/managed_agents/nest_skill.md index 9499dbc73f..a230c51da8 100644 --- a/desktop/src-tauri/src/managed_agents/nest_skill.md +++ b/desktop/src-tauri/src/managed_agents/nest_skill.md @@ -21,7 +21,7 @@ version: 1 All output is JSON on stdout. Commands that return lists return JSON arrays; commands that return a single resource return a JSON object. -Errors go to stderr as `{"error": "category", "message": "detail"}`. Exit codes: 0=ok, 1=input error, 2=network/relay error, 3=auth error, 4=other error. On non-zero exit, parse stderr for the error message before retrying or escalating. +Errors go to stderr as `{"error": "", "message": ""}`. Category values: `user_error` (exit 1), `relay_error` / `network_error` (exit 2), `auth_error` / `key_error` (exit 3), `error` (exit 4). On non-zero exit, parse stderr for the error message before retrying or escalating. ## Parameter Conventions @@ -154,7 +154,7 @@ sprout channels delete --channel ## Canvas -Get the canvas document for a channel (returns the markdown content string directly, not JSON-wrapped): +Get the canvas document for a channel (returns a JSON array of kind:40100 events; the canvas markdown is in the `content` field of the first element): ```bash sprout canvas get --channel @@ -187,7 +187,7 @@ Get all reactions on an event: sprout reactions get --event ``` -Returns `{"reactions": [{emoji, count, pubkeys}]}`. +Returns a JSON array of raw kind:7 reaction events. Each event's `content` field is the emoji character, and the `pubkey` field identifies the reactor. ## DMs @@ -263,6 +263,8 @@ sprout users set-presence --status away sprout users set-presence --status offline ``` +Note: `set-presence` sends an ephemeral kind:20001 event that requires a WebSocket connection. The current CLI uses HTTP POST, which the relay rejects for ephemeral events. This command will fail until WebSocket support is added. + ## Workflows List workflows for a channel: @@ -353,10 +355,10 @@ Use shorter intervals (10s) when latency matters; longer intervals (30s) for bac | `channels create` | `--name`, `--type`, `--visibility` | `{event_id, channel_id, accepted, message}` | | `channels join` | `--channel` | `{event_id, accepted, message}` | | `channels members` | `--channel` | `[{pubkey, role}]` | -| `canvas get` | `--channel` | markdown string (not JSON) | +| `canvas get` | `--channel` | JSON array of kind:40100 events (markdown in `content`) | | `canvas set` | `--channel`, `--content` | `{event_id, accepted, message}` | | `reactions add` | `--event`, `--emoji` | `{event_id, accepted, message}` | -| `reactions get` | `--event` | `{"reactions": [{emoji, count, pubkeys}]}` | +| `reactions get` | `--event` | JSON array of kind:7 reaction events | | `dms list` | — | `[{dm_id, participants, created_at}]` | | `dms open` | `--pubkey` | `{event_id, dm_id, accepted, message}` | | `users get` | — | flat profile object | From f60f36e6b132a024dd9ff642d778f7b65a0fa842 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 18 May 2026 19:37:12 -0400 Subject: [PATCH 3/5] fix(nest): update skill content for CLI parity changes Sync SKILL.md descriptions with post-review CLI output shapes: canvas get returns markdown string or null (not event array), reactions get returns grouped {emoji, count, pubkeys} (not raw events), users get always returns array, workflows get documents normalized shape, workflows runs notes empty-result limitation. Quick reference table updated. --- .../src-tauri/src/managed_agents/nest_skill.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/nest_skill.md b/desktop/src-tauri/src/managed_agents/nest_skill.md index a230c51da8..0c1425ea2f 100644 --- a/desktop/src-tauri/src/managed_agents/nest_skill.md +++ b/desktop/src-tauri/src/managed_agents/nest_skill.md @@ -154,7 +154,7 @@ sprout channels delete --channel ## Canvas -Get the canvas document for a channel (returns a JSON array of kind:40100 events; the canvas markdown is in the `content` field of the first element): +Get the canvas document for a channel (returns the markdown content string directly, or `null` if no canvas is set): ```bash sprout canvas get --channel @@ -187,7 +187,7 @@ Get all reactions on an event: sprout reactions get --event ``` -Returns a JSON array of raw kind:7 reaction events. Each event's `content` field is the emoji character, and the `pubkey` field identifies the reactor. +Returns `{"reactions": [{emoji, count, pubkeys}]}` — reactions grouped by emoji with reactor pubkeys. Empty content on a reaction is normalized to `"+"`. ## DMs @@ -221,7 +221,7 @@ Get your own profile: sprout users get ``` -Returns a flat profile object: `{display_name, about, picture, pubkey, ...}`. +Returns `[{display_name, about, picture, pubkey, ...}]` — always an array, even for a single profile lookup. Get a specific user's profile: @@ -281,6 +281,8 @@ Get a specific workflow definition: sprout workflows get --workflow ``` +Returns `{workflow_id, content, created_at, pubkey}`, or `null` if not found. + Create a workflow (YAML definition inline or via stdin): ```bash @@ -309,7 +311,7 @@ Get run history for a workflow: sprout workflows runs --workflow ``` -Returns `[{event_id, kind, content, created_at, tags}]`. +Returns `[{event_id, kind, content, created_at, tags}]`. Note: currently returns empty results because run history is stored in the database rather than as Nostr events. ## Feed @@ -355,12 +357,12 @@ Use shorter intervals (10s) when latency matters; longer intervals (30s) for bac | `channels create` | `--name`, `--type`, `--visibility` | `{event_id, channel_id, accepted, message}` | | `channels join` | `--channel` | `{event_id, accepted, message}` | | `channels members` | `--channel` | `[{pubkey, role}]` | -| `canvas get` | `--channel` | JSON array of kind:40100 events (markdown in `content`) | +| `canvas get` | `--channel` | markdown string or `null` | | `canvas set` | `--channel`, `--content` | `{event_id, accepted, message}` | | `reactions add` | `--event`, `--emoji` | `{event_id, accepted, message}` | -| `reactions get` | `--event` | JSON array of kind:7 reaction events | +| `reactions get` | `--event` | `{"reactions": [{emoji, count, pubkeys}]}` | | `dms list` | — | `[{dm_id, participants, created_at}]` | | `dms open` | `--pubkey` | `{event_id, dm_id, accepted, message}` | -| `users get` | — | flat profile object | +| `users get` | — | `[{display_name, about, picture, pubkey, ...}]` | | `workflows list` | `--channel` | `[{workflow_id, content, created_at}]` | | `feed get` | — | array of feed events, newest-first | From 3474e1e5f093222a301f4494e77405af702acec8 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Wed, 20 May 2026 14:18:14 -0400 Subject: [PATCH 4/5] style: fix rustfmt formatting after rebase Reformat code after rebasing onto main (which includes PR #614's nest.rs additions). No logic changes. --- desktop/src-tauri/src/managed_agents/nest.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index a84ebeb7bc..25639b8d91 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -106,8 +106,7 @@ pub fn ensure_nest_at(root: &Path) -> Result<(), String> { // Write sprout-cli skill alongside AGENTS.md (same idempotent pattern). let skill_dir = root.join(".claude/skills/sprout-cli"); - fs::create_dir_all(&skill_dir) - .map_err(|e| format!("create {}: {e}", skill_dir.display()))?; + fs::create_dir_all(&skill_dir).map_err(|e| format!("create {}: {e}", skill_dir.display()))?; let skill_md = root.join(".claude/skills/sprout-cli/SKILL.md"); match fs::OpenOptions::new() @@ -324,10 +323,7 @@ mod tests { let skill = root.join(".claude/skills/sprout-cli/SKILL.md"); fs::write(&skill, "custom skill content").unwrap(); ensure_nest_at(&root).unwrap(); - assert_eq!( - fs::read_to_string(&skill).unwrap(), - "custom skill content" - ); + assert_eq!(fs::read_to_string(&skill).unwrap(), "custom skill content"); } #[cfg(unix)] From bcb8279f34823e60ece6fb787d2910093c228779 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Thu, 21 May 2026 12:12:34 -0400 Subject: [PATCH 5/5] docs(cli-skill): update skill file to match CLI parity changes Brings the agent skill file to parity with all 62 CLI subcommands across 13 command groups after PR #612's output normalization and bug fixes. Adds 5 missing command groups (social, repos, upload, mem, pack), 5 missing subcommands in existing groups (channels update/ add-member/remove-member, workflows update/delete), missing flags (--file, --mention, --kinds, --depth-limit, --limit), fixes output contract inaccuracies (channels get pubkey field, workflows list pubkey, exit code 5, feed description), and restructures the Quick Reference table by output pattern for scannability. --- .../src/managed_agents/nest_skill.md | 307 ++++++++++++++++-- 1 file changed, 281 insertions(+), 26 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/nest_skill.md b/desktop/src-tauri/src/managed_agents/nest_skill.md index 0c1425ea2f..30c21b3085 100644 --- a/desktop/src-tauri/src/managed_agents/nest_skill.md +++ b/desktop/src-tauri/src/managed_agents/nest_skill.md @@ -2,12 +2,12 @@ name: sprout-cli description: > Use the Sprout CLI (`sprout` command) to interact with a Sprout relay: send - and read messages in channels, post threaded replies, manage channels - (create, list, join, leave, archive), set canvas documents, add reactions, - open direct messages, query user profiles and presence, trigger and approve - workflows, search messages, post code diffs, and manage repositories. - Activate when the task involves messaging, channels, feeds, DMs, reactions, - workflows, or any Sprout relay operation via the `sprout` command. + and read messages, manage channels, set canvas documents, add reactions, + open DMs, query user profiles, trigger workflows, search messages, post code + diffs, manage repositories, publish social notes, upload files, and manage + persistent agent memory. Activate when the task involves messaging, channels, + feeds, DMs, reactions, workflows, social notes, repos, uploads, agent memory, + or any Sprout relay operation via the `sprout` command. version: 1 --- @@ -19,9 +19,9 @@ version: 1 `SPROUT_RELAY_URL` defaults to `http://localhost:3000`. Override only if explicitly instructed. -All output is JSON on stdout. Commands that return lists return JSON arrays; commands that return a single resource return a JSON object. +All output is JSON on stdout unless noted otherwise. Commands that return lists return JSON arrays; commands that return a single resource return a JSON object. Exceptions: `canvas get` returns a raw markdown string (not JSON); `mem get`/`mem hash` write raw values to stdout; `pack inspect` outputs human-readable text; `social` and `repos` commands return raw Nostr event JSON (includes `sig` and relay-specific fields — not the normalized format used by `messages`, `channels`, etc.). -Errors go to stderr as `{"error": "", "message": ""}`. Category values: `user_error` (exit 1), `relay_error` / `network_error` (exit 2), `auth_error` / `key_error` (exit 3), `error` (exit 4). On non-zero exit, parse stderr for the error message before retrying or escalating. +Errors go to stderr as `{"error": "", "message": ""}`. Category values: `user_error` (exit 1), `relay_error` / `network_error` (exit 2), `auth_error` / `key_error` (exit 3), `error` (exit 4), `conflict` (exit 5 — write conflict, relay rejected as superseded). On non-zero exit, parse stderr for the error message before retrying or escalating. ## Parameter Conventions @@ -46,24 +46,50 @@ Send a threaded reply: sprout messages send --channel --content "reply text" --reply-to ``` -Read recent messages (returns array, newest messages at the end when no `--since`): +Send a forum post (kind 45001) or forum comment (kind 45003): + +```bash +sprout messages send --channel --content "post title" --kind 45001 +sprout messages send --channel --content "comment" --kind 45003 --reply-to +``` + +`--kind` routing: omitted or `9` sends a stream message; `45001` sends a forum post; `45003` sends a forum comment (requires `--reply-to`). Other kinds are rejected. + +Attach files (uploads to Blossom, includes as imeta tags): + +```bash +sprout messages send --channel --content "see attached" --file /path/to/image.png +``` + +Explicitly mention users by pubkey (in addition to @name auto-resolution from message body): + +```bash +sprout messages send --channel --content "cc @alice" --mention +``` + +`--file` and `--mention` are repeatable. + +Read recent messages (default kinds: `[9, 40002, 40008, 45001, 45003]`): ```bash sprout messages get --channel --limit 20 sprout messages get --channel --limit 50 --since 1716000000 sprout messages get --channel --limit 50 --before 1716050000 +sprout messages get --channel --kinds "1,1984" # override default kinds ``` Get a thread rooted at a specific event: ```bash sprout messages thread --channel --event +sprout messages thread --channel --event --limit 100 --depth-limit 5 ``` Full-text search across all channels you can access: ```bash sprout messages search --query "architecture decision" +sprout messages search --query "deploy" --limit 50 ``` Send a code diff with repository metadata (pipe the diff via stdin): @@ -112,6 +138,7 @@ Create a channel: ```bash sprout channels create --name "eng-backend" --type stream --visibility open +sprout channels create --name "rfcs" --type forum --visibility open --description "Architecture RFCs" ``` Returns `{event_id, channel_id, accepted, message}`. Use `channel_id` for subsequent operations. @@ -122,6 +149,14 @@ Get channel details: sprout channels get --channel ``` +Returns `{channel_id, name, description, created_at, pubkey}`, or `null` if not found. + +Update channel metadata: + +```bash +sprout channels update --channel --name "new-name" --description "new description" +``` + Join or leave: ```bash @@ -142,7 +177,16 @@ List members: sprout channels members --channel ``` -Returns `[{pubkey, role}]`. +Returns `[{pubkey, role}]`. Roles: `owner`, `admin`, `member`, `guest`, `bot`. + +Manage members: + +```bash +sprout channels add-member --channel --pubkey --role member +sprout channels remove-member --channel --pubkey +``` + +`--role` is optional (defaults to `member`). Valid roles: `owner`, `admin`, `member`, `guest`, `bot`. Admin operations (require `admin:channels` scope): @@ -154,7 +198,7 @@ sprout channels delete --channel ## Canvas -Get the canvas document for a channel (returns the markdown content string directly, or `null` if no canvas is set): +Get the canvas document for a channel (returns the markdown content string directly, or `null` if no canvas is set — this is NOT a JSON envelope): ```bash sprout canvas get --channel @@ -195,6 +239,7 @@ List existing DM conversations: ```bash sprout dms list +sprout dms list --limit 10 ``` Returns `[{dm_id, participants, created_at}]`. @@ -203,6 +248,7 @@ Open a new DM (creates a group DM conversation): ```bash sprout dms open --pubkey +sprout dms open --pubkey --pubkey # group DM (up to 8) ``` Returns `{event_id, dm_id, accepted, message}`. Use `dm_id` as the `--channel` value for subsequent `messages` commands. @@ -273,7 +319,7 @@ List workflows for a channel: sprout workflows list --channel ``` -Returns `[{workflow_id, content, created_at}]`. +Returns `[{workflow_id, content, created_at, pubkey}]`. Get a specific workflow definition: @@ -292,6 +338,18 @@ cat workflow.yaml | sprout workflows create --channel --yaml - Returns `{event_id, workflow_id, accepted, message}`. +Update a workflow (requires both `--channel` and `--workflow`): + +```bash +sprout workflows update --channel --workflow --yaml "name: updated\nsteps: ..." +``` + +Delete a workflow: + +```bash +sprout workflows delete --workflow +``` + Trigger a workflow: ```bash @@ -309,19 +367,20 @@ Get run history for a workflow: ```bash sprout workflows runs --workflow +sprout workflows runs --workflow --limit 10 ``` Returns `[{event_id, kind, content, created_at, tags}]`. Note: currently returns empty results because run history is stored in the database rather than as Nostr events. ## Feed -Get your activity feed (mentions, needs-action items, recent channel activity): +Get your activity feed (mentions of your pubkey): ```bash sprout feed get --limit 20 ``` -Returns events sorted newest-first. +Returns events sorted newest-first (descending `created_at`). This is the only list command that sorts newest-first; all others sort oldest-first. Poll for recent activity since a timestamp: @@ -329,6 +388,149 @@ Poll for recent activity since a timestamp: sprout feed get --since 1716000000 --limit 50 ``` +## Social + +Nostr social protocol commands (NIP-01, NIP-02, NIP-51/NIP-65). These commands return **raw Nostr event JSON** including `sig` and all relay fields — not the normalized format used by `messages`, `channels`, etc. + +Publish a text note (kind:1): + +```bash +sprout social publish --content "Hello world" +sprout social publish --content "reply text" --reply-to +``` + +Set your contact list (kind:3): + +```bash +sprout social set-contacts --contacts '[{"pubkey":"","relay_url":"","petname":"alice"}]' +``` + +Get a single event by ID: + +```bash +sprout social event --event +``` + +Get a user's recent notes: + +```bash +sprout social notes --pubkey --limit 20 +sprout social notes --pubkey --limit 20 --before 1716050000 +``` + +Get a user's contact list: + +```bash +sprout social contacts --pubkey +``` + +Publish a NIP-51/NIP-65 social list (supported kinds: 10000, 10001, 10002, 10003, 30000, 30003): + +```bash +sprout social set-list --kind 10002 --tags '[["r","wss://relay.example.com"]]' +``` + +Read a user's social lists by kind: + +```bash +sprout social list --pubkey --kind 10002 +sprout social list --pubkey --kind 30000 --d-tag "friends" +``` + +## Repos + +Git repository announcements (NIP-34 kind:30617). Returns raw Nostr event JSON. + +Create a repository announcement: + +```bash +sprout repos create --id "my-repo" --name "My Repo" --description "A project" --clone https://github.com/org/repo.git +``` + +`--clone`, `--web`, and `--nostr-relay` flags are optional; `--clone` is repeatable. + +Get a repository: + +```bash +sprout repos get --id "my-repo" +sprout repos get --id "my-repo" --owner +``` + +List repositories: + +```bash +sprout repos list +sprout repos list --owner --limit 10 +``` + +## Upload + +Upload a file to the relay's Blossom store: + +```bash +sprout upload file --file /path/to/image.png +``` + +Returns a pretty-printed (multi-line) JSON `BlobDescriptor`: `{url, sha256, size, type, uploaded}`. Optional fields: `dim`, `blurhash`, `thumb`, `duration`. + +## Mem (Agent Memory) + +Persistent agent memory (NIP-AE engrams). Progress messages go to stderr; data goes to stdout. Exit code 5 on write conflicts. + +List memory entries: + +```bash +sprout mem ls # tab-delimited: slug, created_at, event_id +sprout mem ls --json # JSON array: [{slug, event_id, created_at}] +``` + +Read a memory value (raw bytes to stdout, no trailing newline): + +```bash +sprout mem get +``` + +Get the SHA-256 hash of a value (use as `--base-hash` for safe patching): + +```bash +sprout mem hash +``` + +Set a memory value: + +```bash +sprout mem set "value" +echo "value" | sprout mem set - # read from stdin +sprout mem set - --allow-empty # allow empty stdin +``` + +Patch a memory value with a unified diff (safer than `set` for concurrent access): + +```bash +sprout mem hash # get base hash first +diff -u old.txt new.txt | sprout mem patch --base-hash +sprout mem patch --patch-file changes.patch --base-hash +sprout mem patch --no-base-hash # skip hash check (unsafe) +sprout mem patch --base-hash --dry-run # preview without writing +``` + +Delete (tombstone) a memory entry: + +```bash +sprout mem rm +``` + +The `core` slug cannot be deleted. + +## Pack (Local-Only) + +Persona pack operations. No relay connection or `--private-key` needed. + +```bash +sprout pack validate /path/to/pack # validate pack directory structure +sprout pack inspect /path/to/pack # show pack metadata (human-readable text, not JSON) +``` + ## Polling Pattern The Sprout relay has no push or webhook support. Poll with `--since` and sleep between iterations. @@ -346,23 +548,76 @@ Use shorter intervals (10s) when latency matters; longer intervals (30s) for bac ## Quick Reference +Most write commands return `{event_id, accepted, message}`. Exceptions noted below. + +**Read commands:** + | Command | Required Flags | Returns | |---------|---------------|---------| -| `messages send` | `--channel`, `--content` | `{event_id, ...}` | -| `messages get` | `--channel` | array of message objects | -| `messages thread` | `--channel`, `--event` | array of thread events | -| `messages search` | `--query` | array of matching messages | -| `messages send-diff` | `--channel`, `--diff`, `--repo`, `--commit` | `{event_id, ...}` | +| `messages get` | `--channel` | `[{id, pubkey, kind, content, created_at, tags}]` | +| `messages thread` | `--channel`, `--event` | `[{id, pubkey, kind, content, created_at, tags}]` | +| `messages search` | `--query` | `[{id, pubkey, kind, content, created_at, tags}]` | | `channels list` | — | `[{channel_id, name, description, created_at}]` | -| `channels create` | `--name`, `--type`, `--visibility` | `{event_id, channel_id, accepted, message}` | -| `channels join` | `--channel` | `{event_id, accepted, message}` | +| `channels get` | `--channel` | `{channel_id, name, description, created_at, pubkey}` or `null` | | `channels members` | `--channel` | `[{pubkey, role}]` | | `canvas get` | `--channel` | markdown string or `null` | -| `canvas set` | `--channel`, `--content` | `{event_id, accepted, message}` | -| `reactions add` | `--event`, `--emoji` | `{event_id, accepted, message}` | | `reactions get` | `--event` | `{"reactions": [{emoji, count, pubkeys}]}` | | `dms list` | — | `[{dm_id, participants, created_at}]` | -| `dms open` | `--pubkey` | `{event_id, dm_id, accepted, message}` | | `users get` | — | `[{display_name, about, picture, pubkey, ...}]` | -| `workflows list` | `--channel` | `[{workflow_id, content, created_at}]` | -| `feed get` | — | array of feed events, newest-first | +| `users presence` | `--pubkeys` | `[{pubkey, status, updated_at}]` | +| `workflows list` | `--channel` | `[{workflow_id, content, created_at, pubkey}]` | +| `workflows get` | `--workflow` | `{workflow_id, content, created_at, pubkey}` or `null` | +| `workflows runs` | `--workflow` | `[{event_id, kind, content, created_at, tags}]` | +| `feed get` | — | event array, newest-first | + +**Write commands** (all return `{event_id, accepted, message}` unless noted): + +| Command | Required Flags | Notes | +|---------|---------------|-------| +| `messages send` | `--channel`, `--content` | | +| `messages send-diff` | `--channel`, `--diff`, `--repo`, `--commit` | | +| `messages edit` | `--event`, `--content` | | +| `messages delete` | `--event` | | +| `messages vote` | `--event`, `--direction` | | +| `channels create` | `--name`, `--type`, `--visibility` | adds `channel_id` | +| `channels update/join/leave/topic/purpose` | `--channel` (+ value flag) | | +| `channels add-member/remove-member` | `--channel`, `--pubkey` | | +| `channels archive/unarchive/delete` | `--channel` | | +| `canvas set` | `--channel`, `--content` | | +| `reactions add/remove` | `--event`, `--emoji` | | +| `dms open` | `--pubkey` | adds `dm_id` | +| `dms add-member` | `--channel`, `--pubkey` | | +| `users set-profile` | one of `--name`/`--avatar`/`--about`/`--nip05` | | +| `workflows create` | `--channel`, `--yaml` | adds `workflow_id` | +| `workflows update` | `--channel`, `--workflow`, `--yaml` | | +| `workflows delete/trigger` | `--workflow` | | +| `workflows approve` | `--token` | | +| `social publish` | `--content` | | +| `social set-contacts` | `--contacts` | | + +**Raw output commands** (return unprocessed Nostr event JSON including `sig`): + +| Command | Required Flags | +|---------|---------------| +| `social event` | `--event` | +| `social notes` | `--pubkey` | +| `social contacts` | `--pubkey` | +| `social set-list` | `--kind`, `--tags` | +| `social list` | `--pubkey`, `--kind` | +| `repos create` | `--id` | +| `repos get` | `--id` | +| `repos list` | — | + +**Other output formats:** + +| Command | Required Flags | Returns | +|---------|---------------|---------| +| `upload file` | `--file` | `{url, sha256, size, type, uploaded}` (pretty-printed) | +| `mem ls` | — | tab-delimited lines or `--json` array | +| `mem get` | `` | raw value (stdout, no newline) | +| `mem hash` | `` | SHA-256 hex string | +| `mem set` | ``, `` | stderr progress only | +| `mem patch` | ``, `--base-hash` or `--no-base-hash` | stderr progress only | +| `mem rm` | `` | stderr progress only | +| `pack validate` | `` | "Valid." or errors | +| `pack inspect` | `` | human-readable text |