fix(nest): correct skill content inaccuracies + chmod intermediate dirs

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.
This commit is contained in:
Will Pfleger
2026-05-18 19:08:26 -04:00
parent 6946ab03fd
commit 8339e5f832
2 changed files with 33 additions and 18 deletions

View File

@@ -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)]

View File

@@ -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": "<category>", "message": "<detail>"}`. 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 <UUID>
## 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 <UUID>
@@ -187,7 +187,7 @@ Get all reactions on an event:
sprout reactions get --event <hex-event-id>
```
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 |