Skip to content
Merged
26 changes: 24 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ See CONTRIBUTING.md for full setup details and dependency requirements.

## Quality Gates

Run `just ci` before every PR. It runs: Rust `fmt` + `clippy`, desktop lint
(Biome), unit tests, desktop build, and Tauri check. All must pass.
Run `just ci` before every PR — it runs `fmt` + `clippy` + desktop lint +
unit tests + builds. Clippy passing does not mean fmt passes; run both.

Run `just test` for integration tests if you touched `sprout-relay`,
`sprout-db`, or `sprout-auth` — these require a running Postgres and Redis.
Expand Down Expand Up @@ -113,6 +113,19 @@ check existing reply handlers for the pattern.

---

## Agent CLI (`sprout-cli`)

`sprout` is the agent-first CLI replacing `sprout-mcp`. Auth env vars
(`SPROUT_RELAY_URL`, `SPROUT_PRIVATE_KEY`) are auto-injected by the ACP
harness into managed agent subprocesses.

All reads return sig-stripped JSON arrays; all writes return
`{event_id, accepted, message}`; creates add the entity ID. Exit codes:
0=ok, 1=input error, 3=auth missing. See `crates/sprout-cli/TESTING.md`
for the full live-testing runbook.

---

## Testing

```bash
Expand All @@ -136,6 +149,15 @@ See [TESTING.md](TESTING.md) for the full multi-agent E2E guide.

---

## Common Gotchas

1. **Kind `39000` for channel metadata, not `41`** — kind 41 is NIP-01 (unused). All kinds defined in `sprout-core/src/kind.rs`.
2. **Relay queries must specify `kinds`** — omitting `kinds` triggers the p-gate (403). Always include explicit kind filters.
3. **Worktrees: `cd` in the same command** — shell CWD doesn't persist between tool calls. Use `cd /path && cargo build` as one command.
4. **Desktop fmt check fails in worktrees** — run `just desktop-tauri-fmt-check` from the main checkout. CI is unaffected.

---

## Desktop App

The desktop app is Tauri 2 + React 19 + Vite + Tailwind CSS. Features are
Expand Down
14 changes: 2 additions & 12 deletions TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,18 +140,8 @@ export SPROUT_PRIVATE_KEY=$(echo "$GEN" | awk '/Secret key:/ {print $3}')
PUBKEY=$(echo "$GEN" | awk '/Public key:/ {print $3}')
echo "pubkey: $PUBKEY"

# Create a channel (the CLI generates the UUID client-side and embeds it in
# the kind:9007 event; it does NOT return the UUID in the response yet)
sprout channels create --name "smoke-$$" --type stream --visibility open

# Find your new channel's UUID. kind:39002 (channel metadata) lists you as
# owner; the channel UUID is in the `d` tag.
CHANNEL=$(sprout channels list --member \
| jq -r --arg pk "$PUBKEY" '
.[]
| select(any(.tags[]; .[0]=="p" and .[1]==$pk and .[3]=="owner"))
| (.tags[] | select(.[0]=="d") | .[1])' \
| head -1)
# Create a channel — the UUID is returned in the response
CHANNEL=$(sprout channels create --name "smoke-$$" --type stream --visibility open | jq -r '.channel_id')
echo "channel: $CHANNEL"

# Send a message and read it back
Expand Down
67 changes: 47 additions & 20 deletions crates/sprout-cli/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,43 +120,57 @@ earlier ones create resources that later ones need.
sprout channels create --name "test-stream" --type stream --visibility open \
--description "CLI test channel" | jq .
# Save the channel ID:
CHANNEL_ID=$(sprout channels create --name "test-cli" --type stream --visibility open | jq -r '.id')
CHANNEL_ID=$(sprout channels create --name "test-cli" --type stream --visibility open | jq -r '.channel_id')
# Expected: {"event_id":"...","accepted":true,"message":"...","channel_id":"<uuid>"}

# channels create (forum) — needed for messages vote later
FORUM_ID=$(sprout channels create --name "test-forum" --type forum --visibility open | jq -r '.id')
FORUM_ID=$(sprout channels create --name "test-forum" --type forum --visibility open | jq -r '.channel_id')

# channels list
sprout channels list | jq .
# Expected: [{"channel_id":"...","name":"...","description":"...","created_at":N}]
sprout channels list --visibility open | jq .
sprout channels list --member | jq .

# channels get
sprout channels get --channel "$CHANNEL_ID" | jq .
# Expected: {"channel_id":"...","name":"...","description":"...","created_at":N,"pubkey":"..."} or null

# channels update
sprout channels update --channel "$CHANNEL_ID" --name "test-cli-updated" \
--description "Updated" | jq .
# Expected: {"event_id":"...","accepted":true,"message":"..."}

# channels topic
sprout channels topic --channel "$CHANNEL_ID" --topic "Test topic" | jq .
# Expected: {"event_id":"...","accepted":true,"message":"..."}

# channels purpose
sprout channels purpose --channel "$CHANNEL_ID" --purpose "Testing" | jq .
# Expected: {"event_id":"...","accepted":true,"message":"..."}

# channels join (may already be a member from create)
sprout channels join --channel "$CHANNEL_ID" | jq .
# Expected: {"event_id":"...","accepted":true,"message":"..."}

# channels leave
# NOTE: Fails with 400 "cannot remove the last owner" if this identity is the
# sole owner (which it is after channels create). To test leave successfully,
# first add-member a second pubkey as owner. The relay enforces ≥1 owner.
sprout channels leave --channel "$CHANNEL_ID" | jq .
# Expected: {"event_id":"...","accepted":true,"message":"..."} (or 400 if last owner)

# Re-join so we can send messages
sprout channels join --channel "$CHANNEL_ID" | jq .
# Expected: {"event_id":"...","accepted":true,"message":"..."}

# channels archive (requires admin:channels scope)
sprout channels archive --channel "$CHANNEL_ID" | jq .
# Expected: {"event_id":"...","accepted":true,"message":"..."}

# channels unarchive
sprout channels unarchive --channel "$CHANNEL_ID" | jq .
# Expected: {"event_id":"...","accepted":true,"message":"..."}
```

### 6.2 Canvas
Expand All @@ -169,7 +183,8 @@ sprout canvas set --channel "$CHANNEL_ID" --content "# Test Canvas" | jq .
echo "# Canvas from stdin" | sprout canvas set --channel "$CHANNEL_ID" --content - | jq .

# canvas get
sprout canvas get --channel "$CHANNEL_ID" | jq .
sprout canvas get --channel "$CHANNEL_ID"
# Expected: raw markdown string, or: null
```

### 6.3 Messages
Expand All @@ -178,13 +193,13 @@ sprout canvas get --channel "$CHANNEL_ID" | jq .
# messages send
MSG=$(sprout messages send --channel "$CHANNEL_ID" --content "Hello from CLI test" | jq .)
echo "$MSG"
EVENT_ID=$(echo "$MSG" | jq -r '.id // .event_id')
EVENT_ID=$(echo "$MSG" | jq -r '.event_id')

# messages send with reply + broadcast
REPLY=$(sprout messages send --channel "$CHANNEL_ID" --content "Reply" \
--reply-to "$EVENT_ID" --broadcast | jq .)
echo "$REPLY"
REPLY_ID=$(echo "$REPLY" | jq -r '.id // .event_id')
REPLY_ID=$(echo "$REPLY" | jq -r '.event_id')

# messages send with mentions
sprout messages send --channel "$CHANNEL_ID" --content "Hey @someone" \
Expand Down Expand Up @@ -254,13 +269,14 @@ echo "diff content" | sprout messages send-diff \
```bash
# Send a message to react to
REACT_MSG=$(sprout messages send --channel "$CHANNEL_ID" --content "React to this")
REACT_ID=$(echo "$REACT_MSG" | jq -r '.id // .event_id')
REACT_ID=$(echo "$REACT_MSG" | jq -r '.event_id')

# reactions add
sprout reactions add --event "$REACT_ID" --emoji "👍" | jq .

# reactions get
sprout reactions get --event "$REACT_ID" | jq .
# Expected: {"reactions":[{"emoji":"...","count":N,"pubkeys":["..."]}]}

# reactions remove
sprout reactions remove --event "$REACT_ID" --emoji "👍" | jq .
Expand All @@ -271,16 +287,18 @@ sprout reactions remove --event "$REACT_ID" --emoji "👍" | jq .
```bash
# dms list
sprout dms list | jq .
# Expected: [{"dm_id":"...","participants":["..."],"created_at":N}]

# dms open (needs a real pubkey — use your own or a test one)
# Get your own pubkey first:
MY_PUBKEY=$(sprout users get | jq -r '.pubkey // .[0].pubkey // empty')
MY_PUBKEY=$(sprout users get | jq -r '.[0].pubkey // empty')
echo "My pubkey: $MY_PUBKEY"

# dms open with a synthetic pubkey (relay will create the user)
DM_RESULT=$(sprout dms open --pubkey "0000000000000000000000000000000000000000000000000000000000000001")
echo "$DM_RESULT" | jq .
DM_ID=$(echo "$DM_RESULT" | jq -r '.channel_id // .id // empty')
# Expected: {"event_id":"...","accepted":true,"message":"...","dm_id":"<uuid>"}
DM_ID=$(echo "$DM_RESULT" | jq -r '.dm_id')

# dms add-member (requires messages:write scope — NOT admin:channels)
sprout dms add-member --channel "$DM_ID" \
Expand All @@ -292,6 +310,7 @@ sprout dms add-member --channel "$DM_ID" \
```bash
# users get — own profile (0 pubkeys)
sprout users get | jq .
# Expected: [{...profile...}] — always returns an array, even for single results

# users get — single pubkey
sprout users get --pubkey "$MY_PUBKEY" | jq .
Expand All @@ -309,6 +328,7 @@ sprout users presence --pubkeys "$MY_PUBKEY" | jq .
sprout users set-presence --status online | jq .
sprout users set-presence --status away | jq .
sprout users set-presence --status offline | jq .
# Note: set-presence may fail — kind:20001 is ephemeral and rejected by the HTTP bridge
```

### 6.8 Channel Members (add/remove require admin:channels)
Expand All @@ -321,6 +341,7 @@ sprout channels add-member --channel "$CHANNEL_ID" \

# channels members
sprout channels members --channel "$CHANNEL_ID" | jq .
# Expected: [{"pubkey":"...","role":"..."}]

# channels remove-member
sprout channels remove-member --channel "$CHANNEL_ID" \
Expand All @@ -343,16 +364,17 @@ steps:
action: send_message
text: "Hello from workflow"' | jq .)
echo "$WF"
WF_ID=$(echo "$WF" | jq -r '.id')
WF_ID=$(echo "$WF" | jq -r '.workflow_id')

# workflows list
sprout workflows list --channel "$CHANNEL_ID" | jq .

# workflows get
sprout workflows get --workflow "$WF_ID" | jq .
# Expected: {"workflow_id":"...","content":"<yaml>","created_at":N,"pubkey":"..."} or null

# workflows update
sprout workflows update --workflow "$WF_ID" \
# workflows update (requires --channel)
sprout workflows update --channel "$CHANNEL_ID" --workflow "$WF_ID" \
--yaml 'name: test-wf-updated
trigger:
on: webhook
Expand All @@ -362,10 +384,14 @@ steps:
text: "Updated"' | jq .

# workflows trigger
# NOTE: May return 400 "workflow not found" — the relay indexes workflow
# definitions into a DB table asynchronously. If the definition event hasn't
# been indexed yet, the trigger handler won't find it.
sprout workflows trigger --workflow "$WF_ID" | jq .

# workflows runs
sprout workflows runs --workflow "$WF_ID" | jq .
# Expected: [] — relay stores runs in DB, not as Nostr events; empty is normal

# workflows approve — requires a workflow run waiting for approval
# This is hard to test ad-hoc without a workflow that has an approval gate.
Expand All @@ -383,6 +409,7 @@ sprout workflows delete --workflow "$WF_ID" | jq .
```bash
sprout feed get | jq .
sprout feed get --limit 5 | jq .
# Expected: [{id,pubkey,kind,content,created_at,tags}] — sig-stripped, sorted newest-first
```

### 6.11 Forum & Voting
Expand All @@ -392,7 +419,7 @@ sprout feed get --limit 5 | jq .
FORUM_POST=$(sprout messages send --channel "$FORUM_ID" \
--content "Forum post for vote testing" --kind 45001 | jq .)
echo "$FORUM_POST"
FORUM_EVENT_ID=$(echo "$FORUM_POST" | jq -r '.id // .event_id')
FORUM_EVENT_ID=$(echo "$FORUM_POST" | jq -r '.event_id')

# messages vote (up)
sprout messages vote --event "$FORUM_EVENT_ID" --direction up | jq .
Expand All @@ -418,9 +445,9 @@ sprout messages delete --event "not-hex" 2>&1; echo "exit: $?"
# stderr: {"error":"user_error","message":"must be a 64-character hex string: not-hex"}
# exit: 1

# Exit 1: Invalid --type value
# Exit 1: Invalid --type value (clap validates the enum — multi-line error)
sprout channels create --name x --type invalid --visibility open 2>&1; echo "exit: $?"
# stderr: {"error":"user_error","message":"--type must be 'stream' or 'forum' (got: invalid)"}
# stderr: {"error":"user_error","message":"error: invalid value 'invalid' for '--type <CHANNEL_TYPE>'\n [possible values: stream, forum]\n..."}
# exit: 1

# Exit 1: Invalid --direction value
Expand All @@ -435,13 +462,13 @@ sprout users set-profile 2>&1; echo "exit: $?"
# Exit 3: No auth configured
env -u SPROUT_PRIVATE_KEY \
cargo run -p sprout-cli -- channels list 2>&1; echo "exit: $?"
# stderr: {"error":"auth_error","message":"SPROUT_PRIVATE_KEY is required (use --private-key or set env var)"}
# stderr: {"error":"auth_error","message":"auth error: SPROUT_PRIVATE_KEY is required (use --private-key or set env var)"}
# exit: 3

# Exit 2: Non-existent channel (valid UUID)
sprout channels get --channel "00000000-0000-0000-0000-000000000000" 2>&1; echo "exit: $?"
# stderr: {"error":"relay_error","message":"..."}
# exit: 2
# Not-found returns null, not an error (exit 0)
sprout channels get --channel "00000000-0000-0000-0000-000000000000"
# stdout: null
# exit: 0
```

---
Expand All @@ -458,7 +485,7 @@ SPROUT_PRIVATE_KEY="nsec1..." sprout channels list | jq .
# No auth → exit 3
env -u SPROUT_PRIVATE_KEY \
cargo run -p sprout-cli -- channels list 2>&1; echo "exit: $?"
# stderr: {"error":"auth_error","message":"SPROUT_PRIVATE_KEY is required (use --private-key or set env var)"}
# stderr: {"error":"auth_error","message":"auth error: SPROUT_PRIVATE_KEY is required (use --private-key or set env var)"}
# exit: 3
```

Expand Down
Loading
Loading