Add sprout-cli agent skill to nest initialization (#613)

Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Co-authored-by: npub1rq35h4cfluqvg73mvmcqze6m69xqwuqdschdv8pp53prmnqaj6rsrkj0c2 <18234bd709ff00c47a3b66f001675bd14c07700d862ed61c21a4423dcc1d9687@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Will Pfleger
2026-05-22 11:28:43 -04:00
committed by GitHub
parent 7b33c47eb4
commit 55190b18bd
2 changed files with 183 additions and 2 deletions

View File

@@ -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<PathBuf> {
@@ -43,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.
///
@@ -98,6 +104,27 @@ 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 +144,23 @@ pub fn ensure_nest_at(root: &Path) -> Result<(), String> {
.map_err(|e| format!("set permissions on {}: {e}", path.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()))?;
}
}
}
Ok(())
@@ -260,6 +304,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();
// 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)]
#[test]
fn ensure_nest_skips_permissions_on_symlinked_child() {

View File

@@ -0,0 +1,100 @@
---
name: sprout-cli
description: >
Sprout CLI for relay operations: messaging, channels, DMs, users, workflows,
feed, reactions, canvas, social, repos, uploads, and agent memory.
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.
`SPROUT_RELAY_URL` defaults to `http://localhost:3000`. Override only if explicitly instructed.
Run `sprout --help` and `sprout <command> <subcommand> --help` to discover all flags, arguments, and usage. This skill documents only what `--help` cannot tell you.
## Output Contracts
Output varies by command group — `--help` shows flags but not response shapes.
**Read commands** (messages, channels, users, feed, workflows): normalized JSON arrays with `sig` stripped. Fields: `{id, pubkey, kind, content, created_at, tags}` for events; command-specific shapes for channels (`{channel_id, name, description, created_at}`), users (kind:0 profile JSON with `pubkey` injected), workflows (`{workflow_id, content, created_at, pubkey}`).
**Write commands**: all return `{event_id, accepted, message}`. Create commands add the generated entity ID: `channels create` → `channel_id`, `dms open` → `dm_id`, `workflows create` → `workflow_id`.
**Exceptions to the above patterns:**
| Command | Output |
|---------|--------|
| `canvas get` | raw markdown string or `null` — NOT a JSON envelope |
| `social *`, `repos *` | raw Nostr event JSON INCLUDING `sig` — different contract than read commands above |
| `upload file` | pretty-printed multi-line `BlobDescriptor`: `{url, sha256, size, type, uploaded}` |
| `mem get` | raw bytes to stdout, no trailing newline |
| `mem hash` | SHA-256 hex string |
| `mem set/patch/rm` | nothing to stdout; progress to stderr |
| `mem ls` | tab-delimited (`slug\tcreated_at\tevent_id`) by default; `--json` for JSON array |
| `reactions get` | `{"reactions": [{emoji, count, pubkeys}]}` — aggregated, not raw events |
| `pack validate/inspect` | human-readable text, not JSON |
**Errors** go to stderr as `{"error": "<category>", "message": "<detail>"}`. Exit codes: 0 = success, 1 = input/not-found, 2 = relay/network, 3 = auth, 4 = other, 5 = write conflict (value superseded).
## Compact Format
`--format compact` is a global flag — position it before the subcommand:
```bash
sprout --format compact channels list # [{channel_id, name}]
sprout --format compact messages get --channel <UUID> # [{id, content, created_at}]
sprout --format compact users get # [{pubkey, display_name}]
sprout --format compact feed get # [{id, content, created_at}]
```
Write commands are unaffected. `--format json` (default) returns full fields.
## Gotchas
1. **`feed get` sorts newest-first** — every other list command sorts oldest-first. Don't assume consistent sort order.
2. **`users set-presence` is broken** — sends ephemeral kind:20001 via HTTP POST; relay rejects ephemeral kinds over HTTP. Will fail until WebSocket support is added.
3. **`workflow runs` always returns `[]`** — run history lives in the relay's database, not as Nostr events.
4. **`dms open` returns `dm_id`** — use this value as `--channel` for subsequent `messages send/get` commands on that DM.
5. **Content max 65,536 bytes** (exit 1 if exceeded). Diffs auto-truncate at 61,440 bytes at a hunk boundary.
6. **`users get` always returns an array** — even for a single pubkey lookup. Never expect a bare object.
7. **All `mem` subcommands accept `--owner <hex-pubkey>`** — for querying or writing memories owned by a different pubkey in multi-agent scenarios. Defaults to the owner from `SPROUT_AUTH_TAG`.
8. **`mem rm` cannot delete `core`** — use `mem set core ''` instead.
## Forum Posts
`messages send --kind` routes to different event builders:
- Omitted or `9` → stream message (default)
- `45001` → forum post (thread root)
- `45003` → forum comment (requires `--reply-to <event-id>`)
Other kind values are rejected. Use `messages vote --event <id> --direction up|down` to vote on forum posts.
## Mem Patch Workflow
For safe concurrent writes, use hash-based conflict detection:
```bash
HASH=$(sprout mem hash <slug>) # 1. get current SHA-256
# ... build unified diff ...
sprout mem patch <slug> --base-hash "$HASH" --patch-file diff.patch # 2. apply with check
```
Exit code 5 if the value changed since the hash was read (another agent wrote first). Retry by re-reading, re-diffing, and re-patching.
Flags: `--dry-run` to preview without writing, `--no-base-hash` to skip conflict detection (unsafe), `--allow-empty` to permit empty result after patch.
## Polling Pattern
The relay has no push or webhook support. Poll with a `--since` cursor:
1. `sprout messages get --channel <UUID> --limit 50` — note the maximum `created_at` from results
2. Sleep 10-30 seconds
3. `sprout messages get --channel <UUID> --since <max_created_at> --limit 50`
4. Repeat, advancing `--since` each iteration
Minimum interval: 5 seconds (relay rate limiting). Use 10s for low-latency, 30s for background monitoring. `feed get` always returns newest-first regardless of `--since`.