mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
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:
@@ -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() {
|
||||
|
||||
100
desktop/src-tauri/src/managed_agents/nest_skill.md
Normal file
100
desktop/src-tauri/src/managed_agents/nest_skill.md
Normal 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`.
|
||||
Reference in New Issue
Block a user