capyai 0.3.2 → 0.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +228 -0
- package/README.md +52 -44
- package/bin/capy.ts +16 -2
- package/package.json +4 -3
- package/skills/capy/SKILL.md +1 -1
- package/src/api.ts +10 -7
- package/src/commands/tasks.ts +12 -0
- package/src/mcp.ts +292 -88
- package/src/watch.ts +1 -1
package/AGENTS.md
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# Agent Setup
|
|
2
|
+
|
|
3
|
+
You are an AI agent. Follow every step below to install and configure capyai. Do not skip steps. Run every command.
|
|
4
|
+
|
|
5
|
+
## Step 1: Prerequisites
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
command -v bun >/dev/null 2>&1 || { curl -fsSL https://bun.sh/install | bash && export PATH="$HOME/.bun/bin:$PATH"; }
|
|
9
|
+
command -v gh >/dev/null 2>&1 || echo "WARN: GitHub CLI (gh) not found. Quality gates need it. Install: https://cli.github.com"
|
|
10
|
+
command -v jq >/dev/null 2>&1 || echo "WARN: jq not found. JSON parsing in shell needs it."
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Step 2: Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
bun i -g capyai
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Verify: `capy --version` should print a version number.
|
|
20
|
+
|
|
21
|
+
## Step 3: Configure
|
|
22
|
+
|
|
23
|
+
Ask the user for their Capy API key (`capy_...`). They get it from https://capy.ai/settings/api-keys.
|
|
24
|
+
|
|
25
|
+
Once you have the key, write the config:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
mkdir -p ~/.capy
|
|
29
|
+
|
|
30
|
+
# Fetch the user's project ID automatically
|
|
31
|
+
export CAPY_API_KEY="<key from user>"
|
|
32
|
+
PROJECT_ID=$(curl -s -H "Authorization: Bearer $CAPY_API_KEY" https://capy.ai/api/v1/projects | jq -r '.items[0].id')
|
|
33
|
+
|
|
34
|
+
cat > ~/.capy/config.json << EOF
|
|
35
|
+
{
|
|
36
|
+
"apiKey": "$CAPY_API_KEY",
|
|
37
|
+
"projectId": "$PROJECT_ID",
|
|
38
|
+
"server": "https://capy.ai/api/v1",
|
|
39
|
+
"defaultModel": "gpt-5.4",
|
|
40
|
+
"quality": {
|
|
41
|
+
"reviewProvider": "greptile",
|
|
42
|
+
"requireCI": true,
|
|
43
|
+
"requireTests": true
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
EOF
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Verify: `capy status --json` should return `{ "threads": [...], "tasks": [...] }`.
|
|
50
|
+
|
|
51
|
+
If it returns `{ "error": ... }`, the API key or project ID is wrong. Ask the user.
|
|
52
|
+
|
|
53
|
+
## Step 4: Install skill
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
npx skills add yazcaleb/capy-cli
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
This adds the `capy` skill to your skills directory so you have the full command reference available in context.
|
|
60
|
+
|
|
61
|
+
## Step 5: MCP server (if you support MCP)
|
|
62
|
+
|
|
63
|
+
Add this to your MCP configuration:
|
|
64
|
+
|
|
65
|
+
```json
|
|
66
|
+
{
|
|
67
|
+
"mcpServers": {
|
|
68
|
+
"capy": {
|
|
69
|
+
"command": "capy-mcp",
|
|
70
|
+
"env": {
|
|
71
|
+
"CAPY_API_KEY": "<same key>",
|
|
72
|
+
"CAPY_PROJECT_ID": "<same project ID>"
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Config file locations:
|
|
80
|
+
- Claude Code: `~/.claude.json` (global) or `.claude/settings.json` (project)
|
|
81
|
+
- Claude Desktop: `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS)
|
|
82
|
+
- Cursor: `.cursor/mcp.json`
|
|
83
|
+
|
|
84
|
+
14 MCP tools with full CLI parity:
|
|
85
|
+
|
|
86
|
+
| Tool | What it does | Annotations |
|
|
87
|
+
|------|-------------|-------------|
|
|
88
|
+
| `capy_captain` | Start Captain thread | openWorld |
|
|
89
|
+
| `capy_build` | Start Build agent | openWorld |
|
|
90
|
+
| `capy_wait` | Block until done | readOnly, idempotent |
|
|
91
|
+
| `capy_review` | Run quality gates | readOnly |
|
|
92
|
+
| `capy_approve` | Approve task | openWorld |
|
|
93
|
+
| `capy_retry` | Retry with context | openWorld |
|
|
94
|
+
| `capy_status` | Task/thread details or dashboard | readOnly, idempotent |
|
|
95
|
+
| `capy_list` | List tasks (filterable) | readOnly, idempotent |
|
|
96
|
+
| `capy_threads` | List threads | readOnly, idempotent |
|
|
97
|
+
| `capy_diff` | View diff | readOnly |
|
|
98
|
+
| `capy_msg` | Message task/thread | openWorld |
|
|
99
|
+
| `capy_stop` | Stop task/thread | destructive |
|
|
100
|
+
| `capy_pr` | Create PR | openWorld |
|
|
101
|
+
| `capy_models` | List models | readOnly, idempotent |
|
|
102
|
+
|
|
103
|
+
Tools with predictable outputs (`capy_captain`, `capy_build`, `capy_review`, `capy_approve`, `capy_retry`) declare `outputSchema` for typed structured content per the 2025-03-26 MCP spec.
|
|
104
|
+
|
|
105
|
+
If you don't support MCP, skip this step. The CLI works everywhere.
|
|
106
|
+
|
|
107
|
+
## Step 6: Verify everything
|
|
108
|
+
|
|
109
|
+
Run all of these. Every one must succeed:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
capy --version
|
|
113
|
+
capy status --json
|
|
114
|
+
capy models --json
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
You are now fully configured.
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
## How to use capyai
|
|
122
|
+
|
|
123
|
+
### Delegate work
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
capy captain "Implement feature X. Files: src/foo.ts. Tests required." --json
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Returns `{ "threadId": "...", "url": "..." }`. Save the `threadId`.
|
|
130
|
+
|
|
131
|
+
### Wait for completion
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
capy wait <threadId> --timeout=600 --json
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Blocks until the thread reaches a terminal state. Returns the full thread object with `tasks` array. Each task has an `identifier` (like `SCO-1`).
|
|
138
|
+
|
|
139
|
+
### Review quality
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
capy review <taskId> --json
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Returns `{ "task": "SCO-1", "quality": { "pass": true, "passed": 5, "total": 5, "gates": [...] } }`.
|
|
146
|
+
|
|
147
|
+
Read `quality.pass`. If `true`, approve. If `false`, read `quality.gates` for what failed.
|
|
148
|
+
|
|
149
|
+
### Approve or retry
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
# If quality.pass is true:
|
|
153
|
+
capy approve <taskId> --json
|
|
154
|
+
|
|
155
|
+
# If quality.pass is false:
|
|
156
|
+
capy retry <taskId> --fix="describe what to fix" --json
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
`retry` returns `{ "newThread": "..." }`. Wait on that new thread ID, then review again.
|
|
160
|
+
|
|
161
|
+
### The full loop
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
THREAD=$(capy captain "your prompt" --json | jq -r '.threadId')
|
|
165
|
+
capy wait "$THREAD" --timeout=600 --json
|
|
166
|
+
|
|
167
|
+
TASK=$(capy threads get "$THREAD" --json | jq -r '.tasks[0].identifier')
|
|
168
|
+
QUALITY=$(capy review "$TASK" --json)
|
|
169
|
+
PASS=$(echo "$QUALITY" | jq -r '.quality.pass')
|
|
170
|
+
|
|
171
|
+
while [ "$PASS" != "true" ]; do
|
|
172
|
+
GATES=$(echo "$QUALITY" | jq -r '.quality.gates[] | select(.pass == false) | .name + ": " + .detail')
|
|
173
|
+
NEW=$(capy retry "$TASK" --fix="Fix these failures: $GATES" --json | jq -r '.newThread')
|
|
174
|
+
capy wait "$NEW" --timeout=600 --json
|
|
175
|
+
TASK=$(capy threads get "$NEW" --json | jq -r '.tasks[0].identifier')
|
|
176
|
+
QUALITY=$(capy review "$TASK" --json)
|
|
177
|
+
PASS=$(echo "$QUALITY" | jq -r '.quality.pass')
|
|
178
|
+
done
|
|
179
|
+
|
|
180
|
+
capy approve "$TASK" --json
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### Background monitoring
|
|
184
|
+
|
|
185
|
+
For async fire-and-forget work. Sets a cron job that polls and runs your notification command when done.
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
capy watch <threadId>
|
|
189
|
+
capy config notifyCommand "<your notification command> {text}"
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
`{text}` is replaced with a summary when the task completes. Examples:
|
|
193
|
+
- `openclaw system event --text {text} --mode now`
|
|
194
|
+
- `echo {text} >> ~/capy-notifications.log`
|
|
195
|
+
|
|
196
|
+
List watches: `capy watches --json`. Remove: `capy unwatch <id>`.
|
|
197
|
+
|
|
198
|
+
### All commands
|
|
199
|
+
|
|
200
|
+
Every command supports `--json` for structured output. Errors always return `{ "error": { "code": "...", "message": "..." } }`.
|
|
201
|
+
|
|
202
|
+
| Command | What it does |
|
|
203
|
+
|---------|-------------|
|
|
204
|
+
| `capy captain "<prompt>"` | Start Captain thread |
|
|
205
|
+
| `capy build "<prompt>"` | Start Build agent (small isolated tasks) |
|
|
206
|
+
| `capy wait <id> --timeout=N` | Block until terminal state |
|
|
207
|
+
| `capy review <id>` | Run quality gates (pass/fail) |
|
|
208
|
+
| `capy approve <id>` | Approve if gates pass |
|
|
209
|
+
| `capy retry <id> --fix="..."` | Retry with context |
|
|
210
|
+
| `capy status` | Dashboard |
|
|
211
|
+
| `capy list [status]` | List tasks |
|
|
212
|
+
| `capy get <id>` | Task or thread details |
|
|
213
|
+
| `capy diff <id>` | View diff |
|
|
214
|
+
| `capy pr <id>` | Create PR |
|
|
215
|
+
| `capy watch <id>` | Cron poll + notify |
|
|
216
|
+
| `capy threads list` | List threads |
|
|
217
|
+
| `capy threads get <id>` | Thread details |
|
|
218
|
+
| `capy threads msg <id> "<text>"` | Message a thread |
|
|
219
|
+
| `capy config [key] [value]` | Get/set config |
|
|
220
|
+
| `capy models` | List available models |
|
|
221
|
+
|
|
222
|
+
### Prompting tips
|
|
223
|
+
|
|
224
|
+
Bad: `"Fix the CI issue"`
|
|
225
|
+
|
|
226
|
+
Good: `"Fix CI for crypto-trading pack. The changeset file is missing. Add a changeset entry for @veto/crypto-trading. Run changeset validation. Reference: PLW-201."`
|
|
227
|
+
|
|
228
|
+
Always include: specific files, specific functions, acceptance criteria, references to related tasks/issues.
|
package/README.md
CHANGED
|
@@ -1,24 +1,19 @@
|
|
|
1
1
|
# capyai
|
|
2
2
|
|
|
3
|
-
Agent orchestrator with quality gates for [Capy.ai](https://capy.ai).
|
|
3
|
+
Agent orchestrator with quality gates for [Capy.ai](https://capy.ai). Lets any AI agent delegate coding work to Capy agents, wait for completion, enforce quality, and approve or retry.
|
|
4
4
|
|
|
5
|
-
Works with Claude Code, Codex, OpenClaw, Poke, or any
|
|
5
|
+
Works with Claude Code, Codex, OpenClaw, Poke, or any agent that runs shell commands.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
```bash
|
|
10
|
-
npm i -g capyai
|
|
11
|
-
capy init
|
|
12
|
-
```
|
|
7
|
+
**AI agent?** Read [AGENTS.md](AGENTS.md) and follow every step.
|
|
13
8
|
|
|
14
|
-
|
|
9
|
+
## Install
|
|
15
10
|
|
|
16
11
|
```bash
|
|
17
|
-
|
|
18
|
-
|
|
12
|
+
npm i -g capyai # or: bun i -g capyai
|
|
13
|
+
capy init # interactive setup
|
|
19
14
|
```
|
|
20
15
|
|
|
21
|
-
Requires [Bun](https://bun.sh) runtime and GitHub CLI (`gh`) for quality gate checks.
|
|
16
|
+
Requires [Bun](https://bun.sh) runtime and [GitHub CLI](https://cli.github.com) (`gh`) for quality gate checks.
|
|
22
17
|
|
|
23
18
|
## Usage
|
|
24
19
|
|
|
@@ -42,37 +37,25 @@ capy retry <task-id> --fix="fix the failing test"
|
|
|
42
37
|
|
|
43
38
|
Every command supports `--json` for machine-readable output.
|
|
44
39
|
|
|
45
|
-
##
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
capy
|
|
51
|
-
capy wait <
|
|
52
|
-
capy review <
|
|
53
|
-
capy approve <
|
|
54
|
-
capy retry <
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
"capy": {
|
|
65
|
-
"command": "capy-mcp"
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
```
|
|
70
|
-
|
|
71
|
-
### Skills.sh
|
|
72
|
-
|
|
73
|
-
```bash
|
|
74
|
-
npx skills add yazcaleb/capy-cli
|
|
75
|
-
```
|
|
40
|
+
## Commands
|
|
41
|
+
|
|
42
|
+
| Command | What it does |
|
|
43
|
+
|---------|-------------|
|
|
44
|
+
| `capy captain "<prompt>"` | Start Captain thread (primary agent) |
|
|
45
|
+
| `capy build "<prompt>"` | Start Build agent (isolated, small tasks) |
|
|
46
|
+
| `capy wait <id>` | Block until task/thread reaches terminal state |
|
|
47
|
+
| `capy review <id>` | Run quality gates |
|
|
48
|
+
| `capy approve <id>` | Approve if all gates pass |
|
|
49
|
+
| `capy retry <id> --fix="..."` | Retry with failure context |
|
|
50
|
+
| `capy status` | Dashboard of threads and tasks |
|
|
51
|
+
| `capy list [status]` | List tasks, optionally filtered |
|
|
52
|
+
| `capy get <id>` | Task or thread details |
|
|
53
|
+
| `capy diff <id>` | View diff |
|
|
54
|
+
| `capy pr <id>` | Create PR for task |
|
|
55
|
+
| `capy watch <id>` | Cron poll + notify on completion |
|
|
56
|
+
| `capy threads [list\|get\|msg\|stop]` | Manage Captain threads |
|
|
57
|
+
| `capy models` | List available models |
|
|
58
|
+
| `capy config [key] [value]` | Get/set config |
|
|
76
59
|
|
|
77
60
|
## Quality Gates
|
|
78
61
|
|
|
@@ -88,7 +71,23 @@ npx skills add yazcaleb/capy-cli
|
|
|
88
71
|
| `threads` | No unresolved review threads |
|
|
89
72
|
| `tests` | Diff includes test files |
|
|
90
73
|
|
|
91
|
-
Configure
|
|
74
|
+
Configure with `capy config quality.reviewProvider greptile|capy|both|none`.
|
|
75
|
+
|
|
76
|
+
## MCP Server
|
|
77
|
+
|
|
78
|
+
For agents that prefer MCP over CLI:
|
|
79
|
+
|
|
80
|
+
```json
|
|
81
|
+
{
|
|
82
|
+
"mcpServers": {
|
|
83
|
+
"capy": {
|
|
84
|
+
"command": "capy-mcp"
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
14 tools with full CLI parity: `capy_captain`, `capy_build`, `capy_wait`, `capy_review`, `capy_approve`, `capy_retry`, `capy_status`, `capy_list`, `capy_threads`, `capy_diff`, `capy_msg`, `capy_stop`, `capy_pr`, `capy_models`.
|
|
92
91
|
|
|
93
92
|
## Config
|
|
94
93
|
|
|
@@ -96,10 +95,19 @@ Configure via `capy config quality.reviewProvider greptile|capy|both|none`.
|
|
|
96
95
|
capy config defaultModel gpt-5.4
|
|
97
96
|
capy config quality.reviewProvider both
|
|
98
97
|
capy config notifyCommand "notify-send {text}"
|
|
98
|
+
capy config approveCommand "your-hook {task} {pr}"
|
|
99
99
|
```
|
|
100
100
|
|
|
101
101
|
Env vars: `CAPY_API_KEY`, `CAPY_PROJECT_ID`, `CAPY_SERVER`, `CAPY_ENV_FILE`, `GREPTILE_API_KEY`.
|
|
102
102
|
|
|
103
|
+
Config file: `~/.capy/config.json`.
|
|
104
|
+
|
|
105
|
+
## Skills.sh
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
npx skills add yazcaleb/capy-cli
|
|
109
|
+
```
|
|
110
|
+
|
|
103
111
|
## License
|
|
104
112
|
|
|
105
113
|
MIT
|
package/bin/capy.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
|
-
import { defineCommand,
|
|
3
|
+
import { defineCommand, runCommand } from "citty";
|
|
4
4
|
import { createRequire } from "node:module";
|
|
5
|
+
import { CapyError } from "../src/api.js";
|
|
5
6
|
|
|
6
7
|
const require = createRequire(import.meta.url);
|
|
7
8
|
const { version } = require("../package.json");
|
|
@@ -40,4 +41,17 @@ const main = defineCommand({
|
|
|
40
41
|
},
|
|
41
42
|
});
|
|
42
43
|
|
|
43
|
-
|
|
44
|
+
try {
|
|
45
|
+
await runCommand(main, { rawArgs: process.argv.slice(2) });
|
|
46
|
+
} catch (e) {
|
|
47
|
+
if (e instanceof CapyError) {
|
|
48
|
+
if (process.argv.includes("--json")) {
|
|
49
|
+
console.log(JSON.stringify({ error: { code: e.code, message: e.message } }));
|
|
50
|
+
} else {
|
|
51
|
+
console.error(`capy: ${e.message}`);
|
|
52
|
+
}
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
console.error(e instanceof Error ? e.message : String(e));
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "capyai",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Unofficial Capy.ai CLI for agent orchestration with quality gates",
|
|
6
6
|
"bin": {
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
"files": [
|
|
14
14
|
"bin/",
|
|
15
15
|
"src/",
|
|
16
|
-
"skills/"
|
|
16
|
+
"skills/",
|
|
17
|
+
"AGENTS.md"
|
|
17
18
|
],
|
|
18
19
|
"engines": {
|
|
19
20
|
"node": ">=18"
|
|
@@ -35,7 +36,7 @@
|
|
|
35
36
|
],
|
|
36
37
|
"dependencies": {
|
|
37
38
|
"@clack/prompts": "^1.2.0",
|
|
38
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
39
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
39
40
|
"citty": "^0.2.2",
|
|
40
41
|
"zod": "^3.24.0"
|
|
41
42
|
}
|
package/skills/capy/SKILL.md
CHANGED
package/src/api.ts
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import * as config from "./config.js";
|
|
2
|
-
import { IS_JSON } from "./output.js";
|
|
3
2
|
import type { Task, Thread, ThreadMessage, DiffData, Model, ListResponse, PullRequestRef } from "./types.js";
|
|
4
3
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
4
|
+
export class CapyError extends Error {
|
|
5
|
+
code: string;
|
|
6
|
+
constructor(code: string, message: string) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.name = "CapyError";
|
|
10
10
|
}
|
|
11
|
-
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function fail(code: string, message: string): never {
|
|
14
|
+
throw new CapyError(code, message);
|
|
12
15
|
}
|
|
13
16
|
|
|
14
17
|
async function rawRequest(apiKey: string, server: string, method: string, path: string, body?: unknown): Promise<any> {
|
package/src/commands/tasks.ts
CHANGED
|
@@ -31,10 +31,22 @@ export const get = defineCommand({
|
|
|
31
31
|
},
|
|
32
32
|
async run({ args }) {
|
|
33
33
|
const api = await import("../api.js");
|
|
34
|
+
const config = await import("../config.js");
|
|
35
|
+
const github = await import("../github.js");
|
|
34
36
|
const fmt = await import("../output.js");
|
|
35
37
|
const { log } = await import("@clack/prompts");
|
|
36
38
|
|
|
37
39
|
const data = await api.getTask(args.id);
|
|
40
|
+
|
|
41
|
+
// Capy API reports merged PRs as "closed". Cross-ref with GitHub for real state.
|
|
42
|
+
if (data.pullRequest?.number && data.pullRequest.state === "closed") {
|
|
43
|
+
const repo = data.pullRequest.repoFullName || config.load().repos[0]?.repoFullName;
|
|
44
|
+
if (repo) {
|
|
45
|
+
const ghPR = github.getPR(repo, data.pullRequest.number);
|
|
46
|
+
if (ghPR) data.pullRequest.state = ghPR.state.toLowerCase();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
38
50
|
if (args.json) { fmt.out(data); return; }
|
|
39
51
|
log.info(`Task: ${data.identifier} \u2014 ${data.title}\nStatus: ${data.status}\nCreated: ${data.createdAt}`);
|
|
40
52
|
if (data.pullRequest) {
|
package/src/mcp.ts
CHANGED
|
@@ -3,123 +3,327 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { createRequire } from "node:module";
|
|
5
5
|
import * as api from "./api.js";
|
|
6
|
+
import { CapyError } from "./api.js";
|
|
6
7
|
import * as config from "./config.js";
|
|
7
8
|
|
|
8
9
|
const require = createRequire(import.meta.url);
|
|
9
10
|
const { version } = require("../package.json");
|
|
10
11
|
|
|
11
|
-
const server = new McpServer({
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
const server = new McpServer({ name: "capy", version });
|
|
13
|
+
|
|
14
|
+
function err(e: unknown) {
|
|
15
|
+
const error = e instanceof CapyError
|
|
16
|
+
? { code: e.code, message: e.message }
|
|
17
|
+
: { code: "internal", message: e instanceof Error ? e.message : String(e) };
|
|
18
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ error }) }], isError: true as const };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function text(data: unknown) {
|
|
22
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(data) }] };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function structured(data: Record<string, unknown>) {
|
|
26
|
+
return { structuredContent: data, content: [{ type: "text" as const, text: JSON.stringify(data) }] };
|
|
27
|
+
}
|
|
15
28
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
29
|
+
function isThreadId(id: string): boolean {
|
|
30
|
+
return id.length > 20 || (id.length > 10 && !id.match(/^[A-Z]+-\d+$/));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// --- Orchestration ---
|
|
34
|
+
|
|
35
|
+
server.registerTool("capy_captain", {
|
|
36
|
+
description: "Start a Captain thread to delegate coding work to a Capy agent",
|
|
37
|
+
inputSchema: {
|
|
38
|
+
prompt: z.string().describe("What the agent should do. Be specific: files, functions, acceptance criteria."),
|
|
39
|
+
model: z.string().optional().describe("Model ID override (default: config defaultModel)"),
|
|
40
|
+
},
|
|
41
|
+
outputSchema: {
|
|
42
|
+
threadId: z.string(),
|
|
43
|
+
url: z.string(),
|
|
44
|
+
},
|
|
45
|
+
annotations: { openWorldHint: true },
|
|
19
46
|
}, async ({ prompt, model }) => {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
47
|
+
try {
|
|
48
|
+
const cfg = config.load();
|
|
49
|
+
const data = await api.createThread(prompt, model);
|
|
50
|
+
return structured({ threadId: data.id, url: `https://capy.ai/project/${cfg.projectId}/captain/${data.id}` });
|
|
51
|
+
} catch (e) { return err(e); }
|
|
23
52
|
});
|
|
24
53
|
|
|
25
|
-
server.
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
54
|
+
server.registerTool("capy_build", {
|
|
55
|
+
description: "Start a Build agent for small isolated tasks (single-file fixes, scripts)",
|
|
56
|
+
inputSchema: {
|
|
57
|
+
prompt: z.string().describe("What to build. Be specific."),
|
|
58
|
+
model: z.string().optional().describe("Model ID override"),
|
|
59
|
+
title: z.string().optional().describe("Short task title"),
|
|
60
|
+
},
|
|
61
|
+
outputSchema: {
|
|
62
|
+
id: z.string(),
|
|
63
|
+
identifier: z.string(),
|
|
64
|
+
status: z.string(),
|
|
65
|
+
},
|
|
66
|
+
annotations: { openWorldHint: true },
|
|
67
|
+
}, async ({ prompt, model, title }) => {
|
|
68
|
+
try {
|
|
69
|
+
const data = await api.createTask(prompt, model, { title, start: true });
|
|
70
|
+
return structured({ id: data.id, identifier: data.identifier, status: data.status });
|
|
71
|
+
} catch (e) { return err(e); }
|
|
35
72
|
});
|
|
36
73
|
|
|
37
|
-
server.
|
|
38
|
-
|
|
74
|
+
server.registerTool("capy_wait", {
|
|
75
|
+
description: "Block until a task or thread reaches terminal state (needs_review, completed, failed, idle, archived)",
|
|
76
|
+
inputSchema: {
|
|
77
|
+
id: z.string().describe("Task or thread ID"),
|
|
78
|
+
timeout: z.number().optional().describe("Timeout in seconds (default 300)"),
|
|
79
|
+
interval: z.number().optional().describe("Poll interval in seconds (default 10)"),
|
|
80
|
+
},
|
|
81
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
82
|
+
}, async ({ id, timeout, interval }) => {
|
|
83
|
+
try {
|
|
84
|
+
const timeoutMs = (timeout || 300) * 1000;
|
|
85
|
+
const intervalMs = Math.max(5, Math.min(interval || 10, 60)) * 1000;
|
|
86
|
+
const isThread = isThreadId(id);
|
|
87
|
+
const terminal = isThread
|
|
88
|
+
? new Set(["idle", "archived", "completed"])
|
|
89
|
+
: new Set(["needs_review", "archived", "completed", "failed"]);
|
|
90
|
+
|
|
91
|
+
const start = Date.now();
|
|
92
|
+
while (Date.now() - start < timeoutMs) {
|
|
93
|
+
const data = isThread ? await api.getThread(id) : await api.getTask(id);
|
|
94
|
+
if (terminal.has(data.status)) return text(data);
|
|
95
|
+
await new Promise(r => setTimeout(r, intervalMs));
|
|
96
|
+
}
|
|
97
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ error: { code: "timeout", message: `Timed out after ${timeout || 300}s` } }) }], isError: true as const };
|
|
98
|
+
} catch (e) { return err(e); }
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
server.registerTool("capy_review", {
|
|
102
|
+
description: "Run quality gates on a task (pr_exists, pr_open, ci, greptile, threads, tests)",
|
|
103
|
+
inputSchema: {
|
|
104
|
+
id: z.string().describe("Task ID"),
|
|
105
|
+
},
|
|
106
|
+
outputSchema: {
|
|
107
|
+
task: z.string(),
|
|
108
|
+
quality: z.object({
|
|
109
|
+
pass: z.boolean(),
|
|
110
|
+
passed: z.number(),
|
|
111
|
+
total: z.number(),
|
|
112
|
+
summary: z.string(),
|
|
113
|
+
}),
|
|
114
|
+
},
|
|
115
|
+
annotations: { readOnlyHint: true },
|
|
39
116
|
}, async ({ id }) => {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
117
|
+
try {
|
|
118
|
+
const qualityEngine = await import("./quality-engine.js");
|
|
119
|
+
const task = await api.getTask(id);
|
|
120
|
+
if (!task.pullRequest?.number) {
|
|
121
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ error: { code: "no_pr", message: `Task ${task.identifier} has no PR` } }) }], isError: true as const };
|
|
122
|
+
}
|
|
123
|
+
const q = await qualityEngine.check(task);
|
|
124
|
+
return structured({ task: task.identifier, quality: q as unknown as Record<string, unknown> });
|
|
125
|
+
} catch (e) { return err(e); }
|
|
47
126
|
});
|
|
48
127
|
|
|
49
|
-
server.
|
|
50
|
-
|
|
51
|
-
|
|
128
|
+
server.registerTool("capy_approve", {
|
|
129
|
+
description: "Approve a task if quality gates pass. Runs approveCommand hook on success.",
|
|
130
|
+
inputSchema: {
|
|
131
|
+
id: z.string().describe("Task ID"),
|
|
132
|
+
force: z.boolean().optional().describe("Override failing gates"),
|
|
133
|
+
},
|
|
134
|
+
outputSchema: {
|
|
135
|
+
task: z.string(),
|
|
136
|
+
approved: z.boolean(),
|
|
137
|
+
},
|
|
138
|
+
annotations: { openWorldHint: true },
|
|
52
139
|
}, async ({ id, force }) => {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
140
|
+
try {
|
|
141
|
+
const qualityEngine = await import("./quality-engine.js");
|
|
142
|
+
const task = await api.getTask(id);
|
|
143
|
+
const cfg = config.load();
|
|
144
|
+
const q = await qualityEngine.check(task);
|
|
145
|
+
const approved = q.pass || !!force;
|
|
146
|
+
|
|
147
|
+
if (approved && cfg.approveCommand) {
|
|
148
|
+
try {
|
|
149
|
+
const { execFileSync } = await import("node:child_process");
|
|
150
|
+
const parts = cfg.approveCommand
|
|
151
|
+
.replace("{task}", task.identifier || task.id)
|
|
152
|
+
.replace("{title}", task.title || "")
|
|
153
|
+
.replace("{pr}", String(task.pullRequest?.number || ""))
|
|
154
|
+
.split(/\s+/);
|
|
155
|
+
execFileSync(parts[0], parts.slice(1), { encoding: "utf8", timeout: 15000, stdio: "pipe" });
|
|
156
|
+
} catch {}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return structured({ task: task.identifier, quality: q as unknown as Record<string, unknown>, approved });
|
|
160
|
+
} catch (e) { return err(e); }
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
server.registerTool("capy_retry", {
|
|
164
|
+
description: "Retry a failed task with context from previous attempt. Creates a new Captain thread.",
|
|
165
|
+
inputSchema: {
|
|
166
|
+
id: z.string().describe("Task ID to retry"),
|
|
167
|
+
fix: z.string().optional().describe("Specific fix instructions"),
|
|
168
|
+
model: z.string().optional().describe("Model ID override"),
|
|
169
|
+
},
|
|
170
|
+
outputSchema: {
|
|
171
|
+
originalTask: z.string(),
|
|
172
|
+
newThread: z.string(),
|
|
173
|
+
model: z.string(),
|
|
174
|
+
},
|
|
175
|
+
annotations: { openWorldHint: true },
|
|
176
|
+
}, async ({ id, fix, model }) => {
|
|
177
|
+
try {
|
|
178
|
+
const task = await api.getTask(id);
|
|
179
|
+
const cfg = config.load();
|
|
58
180
|
|
|
59
|
-
|
|
181
|
+
let context = `Previous attempt: ${task.identifier} "${task.title}" [${task.status}]\n`;
|
|
60
182
|
try {
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
.
|
|
64
|
-
|
|
65
|
-
.replace("{pr}", String(task.pullRequest?.number || ""))
|
|
66
|
-
.split(/\s+/);
|
|
67
|
-
execFileSync(parts[0], parts.slice(1), { encoding: "utf8", timeout: 15000, stdio: "pipe" });
|
|
183
|
+
const d = await api.getDiff(id);
|
|
184
|
+
if (d.stats?.files && d.stats.files > 0) {
|
|
185
|
+
context += `\nPrevious diff: +${d.stats.additions} -${d.stats.deletions} in ${d.stats.files} files\n`;
|
|
186
|
+
}
|
|
68
187
|
} catch {}
|
|
69
|
-
}
|
|
70
188
|
|
|
71
|
-
|
|
189
|
+
let retryPrompt = `RETRY: This is a retry of a previous attempt that had issues.\n\nOriginal task: ${task.prompt || task.title}\n\n--- CONTEXT ---\n${context}\n`;
|
|
190
|
+
if (fix) retryPrompt += `--- FIX ---\n${fix}\n\n`;
|
|
191
|
+
retryPrompt += `Fix the issues. Include tests. Run tests before completing.\n`;
|
|
192
|
+
|
|
193
|
+
if (task.status === "in_progress") {
|
|
194
|
+
await api.stopTask(id, "Retrying with fixes");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const m = model || cfg.defaultModel;
|
|
198
|
+
const data = await api.createThread(retryPrompt, m);
|
|
199
|
+
return structured({ originalTask: task.identifier, newThread: data.id, model: m });
|
|
200
|
+
} catch (e) { return err(e); }
|
|
72
201
|
});
|
|
73
202
|
|
|
74
|
-
|
|
75
|
-
id: z.string().describe("Task ID to retry"),
|
|
76
|
-
fix: z.string().optional().describe("Specific fix instructions"),
|
|
77
|
-
model: z.string().optional().describe("Model ID override"),
|
|
78
|
-
}, async ({ id, fix, model }) => {
|
|
79
|
-
const task = await api.getTask(id);
|
|
80
|
-
const cfg = config.load();
|
|
203
|
+
// --- Status & monitoring ---
|
|
81
204
|
|
|
82
|
-
|
|
205
|
+
server.registerTool("capy_status", {
|
|
206
|
+
description: "Get task or thread details by ID, or full dashboard (omit ID for dashboard)",
|
|
207
|
+
inputSchema: {
|
|
208
|
+
id: z.string().optional().describe("Task or thread ID. Omit for dashboard."),
|
|
209
|
+
},
|
|
210
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
211
|
+
}, async ({ id }) => {
|
|
83
212
|
try {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
213
|
+
if (id) {
|
|
214
|
+
const isThread = isThreadId(id);
|
|
215
|
+
const data: any = isThread ? await api.getThread(id) : await api.getTask(id);
|
|
216
|
+
if (!isThread && data.pullRequest?.number && data.pullRequest.state === "closed") {
|
|
217
|
+
const { getPR } = await import("./github.js");
|
|
218
|
+
const cfg = config.load();
|
|
219
|
+
const repo = data.pullRequest.repoFullName || cfg.repos[0]?.repoFullName;
|
|
220
|
+
if (repo) {
|
|
221
|
+
const ghPR = getPR(repo, data.pullRequest.number);
|
|
222
|
+
if (ghPR) data.pullRequest.state = ghPR.state.toLowerCase();
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return text(data);
|
|
87
226
|
}
|
|
88
|
-
|
|
227
|
+
const [threads, tasks] = await Promise.all([api.listThreads({ limit: 10 }), api.listTasks({ limit: 30 })]);
|
|
228
|
+
return text({ threads: threads.items || [], tasks: tasks.items || [] });
|
|
229
|
+
} catch (e) { return err(e); }
|
|
230
|
+
});
|
|
89
231
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
232
|
+
server.registerTool("capy_list", {
|
|
233
|
+
description: "List tasks, optionally filtered by status (in_progress, needs_review, backlog, archived)",
|
|
234
|
+
inputSchema: {
|
|
235
|
+
status: z.string().optional().describe("Filter by status"),
|
|
236
|
+
limit: z.number().optional().describe("Max results (default 30)"),
|
|
237
|
+
},
|
|
238
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
239
|
+
}, async ({ status, limit }) => {
|
|
240
|
+
try {
|
|
241
|
+
const data = await api.listTasks({ status, limit: limit || 30 });
|
|
242
|
+
return text(data.items || []);
|
|
243
|
+
} catch (e) { return err(e); }
|
|
244
|
+
});
|
|
93
245
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
246
|
+
server.registerTool("capy_threads", {
|
|
247
|
+
description: "List Captain threads",
|
|
248
|
+
inputSchema: {
|
|
249
|
+
limit: z.number().optional().describe("Max results (default 10)"),
|
|
250
|
+
},
|
|
251
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
252
|
+
}, async ({ limit }) => {
|
|
253
|
+
try {
|
|
254
|
+
const data = await api.listThreads({ limit: limit || 10 });
|
|
255
|
+
return text(data.items || []);
|
|
256
|
+
} catch (e) { return err(e); }
|
|
257
|
+
});
|
|
97
258
|
|
|
98
|
-
|
|
99
|
-
|
|
259
|
+
server.registerTool("capy_diff", {
|
|
260
|
+
description: "View the diff (code changes) from a task",
|
|
261
|
+
inputSchema: {
|
|
262
|
+
id: z.string().describe("Task ID"),
|
|
263
|
+
},
|
|
264
|
+
annotations: { readOnlyHint: true },
|
|
265
|
+
}, async ({ id }) => {
|
|
266
|
+
try {
|
|
267
|
+
const data = await api.getDiff(id);
|
|
268
|
+
return text(data);
|
|
269
|
+
} catch (e) { return err(e); }
|
|
100
270
|
});
|
|
101
271
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
272
|
+
// --- Actions ---
|
|
273
|
+
|
|
274
|
+
server.registerTool("capy_msg", {
|
|
275
|
+
description: "Send a message to a running task or thread",
|
|
276
|
+
inputSchema: {
|
|
277
|
+
id: z.string().describe("Task or thread ID"),
|
|
278
|
+
text: z.string().describe("Message text"),
|
|
279
|
+
},
|
|
280
|
+
annotations: { openWorldHint: true },
|
|
281
|
+
}, async ({ id, text: msg }) => {
|
|
282
|
+
try {
|
|
283
|
+
const isThread = isThreadId(id);
|
|
284
|
+
const result = isThread ? await api.messageThread(id, msg) : await api.messageTask(id, msg);
|
|
285
|
+
return text({ id, sent: true, type: isThread ? "thread" : "task", ...(result && typeof result === "object" ? result as Record<string, unknown> : {}) });
|
|
286
|
+
} catch (e) { return err(e); }
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
server.registerTool("capy_stop", {
|
|
290
|
+
description: "Stop a running task or thread",
|
|
291
|
+
inputSchema: {
|
|
292
|
+
id: z.string().describe("Task or thread ID"),
|
|
293
|
+
reason: z.string().optional().describe("Reason for stopping"),
|
|
294
|
+
},
|
|
295
|
+
annotations: { destructiveHint: true },
|
|
296
|
+
}, async ({ id, reason }) => {
|
|
297
|
+
try {
|
|
298
|
+
const isThread = isThreadId(id);
|
|
299
|
+
const result = isThread ? await api.stopThread(id) : await api.stopTask(id, reason);
|
|
300
|
+
return text(result);
|
|
301
|
+
} catch (e) { return err(e); }
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
server.registerTool("capy_pr", {
|
|
305
|
+
description: "Create a pull request for a completed task",
|
|
306
|
+
inputSchema: {
|
|
307
|
+
id: z.string().describe("Task ID"),
|
|
308
|
+
title: z.string().optional().describe("PR title override"),
|
|
309
|
+
},
|
|
310
|
+
annotations: { openWorldHint: true },
|
|
311
|
+
}, async ({ id, title }) => {
|
|
312
|
+
try {
|
|
313
|
+
const data = await api.createPR(id, title ? { title } : {});
|
|
314
|
+
return text(data);
|
|
315
|
+
} catch (e) { return err(e); }
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
server.registerTool("capy_models", {
|
|
319
|
+
description: "List available AI models",
|
|
320
|
+
inputSchema: {},
|
|
321
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
322
|
+
}, async () => {
|
|
323
|
+
try {
|
|
324
|
+
const data = await api.listModels();
|
|
325
|
+
return text(data.models || []);
|
|
326
|
+
} catch (e) { return err(e); }
|
|
123
327
|
});
|
|
124
328
|
|
|
125
329
|
const transport = new StdioServerTransport();
|
package/src/watch.ts
CHANGED
|
@@ -18,7 +18,7 @@ export function add(id: string, type: string, intervalMin: number): boolean {
|
|
|
18
18
|
|
|
19
19
|
const thisDir = path.dirname(new URL(import.meta.url).pathname);
|
|
20
20
|
const binPath = path.resolve(thisDir, "..", "bin", "capy.ts");
|
|
21
|
-
const runtime =
|
|
21
|
+
const runtime = process.execPath;
|
|
22
22
|
const tag = `# capy-watch:${id}`;
|
|
23
23
|
const cronLine = `*/${intervalMin} * * * * ${runtime} ${binPath} _poll ${id} ${type} ${tag}`;
|
|
24
24
|
|