capyai 0.3.1 → 0.3.3
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/README.md +42 -16
- package/bin/capy-mcp.ts +2 -0
- package/bin/capy.ts +1 -0
- package/package.json +9 -5
- package/skills/capy/SKILL.md +103 -0
- package/src/api.ts +14 -8
- package/src/commands/monitoring.ts +39 -0
- package/src/commands/quality.ts +27 -13
- package/src/commands/setup.ts +3 -1
- package/src/commands/tasks.ts +12 -0
- package/src/commands/threads.ts +6 -2
- package/src/config.ts +1 -0
- package/src/github.ts +3 -2
- package/src/greptile.ts +11 -6
- package/src/mcp.ts +136 -0
- package/src/output.ts +2 -1
- package/src/watch.ts +5 -4
package/README.md
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
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).
|
|
4
4
|
|
|
5
|
-
Works with Claude Code, Codex, OpenClaw, or any AI agent that can run shell commands.
|
|
5
|
+
Works with Claude Code, Codex, OpenClaw, Poke, or any AI agent that can run shell commands.
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
npm i -g
|
|
10
|
+
npm i -g capyai
|
|
11
11
|
capy init
|
|
12
12
|
```
|
|
13
13
|
|
|
@@ -18,6 +18,8 @@ export CAPY_API_KEY=capy_...
|
|
|
18
18
|
export CAPY_PROJECT_ID=...
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
+
Requires [Bun](https://bun.sh) runtime and GitHub CLI (`gh`) for quality gate checks.
|
|
22
|
+
|
|
21
23
|
## Usage
|
|
22
24
|
|
|
23
25
|
```bash
|
|
@@ -25,6 +27,9 @@ export CAPY_PROJECT_ID=...
|
|
|
25
27
|
capy captain "Implement feature X. Files: src/foo.ts. Tests required."
|
|
26
28
|
capy build "Fix typo in README"
|
|
27
29
|
|
|
30
|
+
# Wait for completion (blocks until done)
|
|
31
|
+
capy wait <thread-id> --timeout=600
|
|
32
|
+
|
|
28
33
|
# Monitor
|
|
29
34
|
capy status
|
|
30
35
|
capy watch <thread-id>
|
|
@@ -37,6 +42,38 @@ capy retry <task-id> --fix="fix the failing test"
|
|
|
37
42
|
|
|
38
43
|
Every command supports `--json` for machine-readable output.
|
|
39
44
|
|
|
45
|
+
## For Agents
|
|
46
|
+
|
|
47
|
+
The core orchestration loop:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
capy captain "precise prompt" --json # start work
|
|
51
|
+
capy wait <thread-id> --timeout=600 --json # block until done
|
|
52
|
+
capy review <task-id> --json # check quality gates
|
|
53
|
+
capy approve <task-id> --json # approve if gates pass
|
|
54
|
+
capy retry <task-id> --fix="..." --json # or retry with context
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### MCP Server
|
|
58
|
+
|
|
59
|
+
For agents that prefer MCP (Cursor, some Codex configs):
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
{
|
|
63
|
+
"mcpServers": {
|
|
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
|
+
```
|
|
76
|
+
|
|
40
77
|
## Quality Gates
|
|
41
78
|
|
|
42
79
|
`capy review` checks pass/fail gates:
|
|
@@ -51,14 +88,7 @@ Every command supports `--json` for machine-readable output.
|
|
|
51
88
|
| `threads` | No unresolved review threads |
|
|
52
89
|
| `tests` | Diff includes test files |
|
|
53
90
|
|
|
54
|
-
Configure
|
|
55
|
-
|
|
56
|
-
## For Agents
|
|
57
|
-
|
|
58
|
-
```bash
|
|
59
|
-
capy review TASK-1 --json # parse quality.pass boolean
|
|
60
|
-
capy status --json # full state dump
|
|
61
|
-
```
|
|
91
|
+
Configure via `capy config quality.reviewProvider greptile|capy|both|none`.
|
|
62
92
|
|
|
63
93
|
## Config
|
|
64
94
|
|
|
@@ -70,10 +100,6 @@ capy config notifyCommand "notify-send {text}"
|
|
|
70
100
|
|
|
71
101
|
Env vars: `CAPY_API_KEY`, `CAPY_PROJECT_ID`, `CAPY_SERVER`, `CAPY_ENV_FILE`, `GREPTILE_API_KEY`.
|
|
72
102
|
|
|
73
|
-
## Requirements
|
|
74
|
-
|
|
75
|
-
[Bun](https://bun.sh) runtime. GitHub CLI (`gh`) for quality gate checks.
|
|
76
|
-
|
|
77
103
|
## License
|
|
78
104
|
|
|
79
105
|
MIT
|
package/bin/capy-mcp.ts
ADDED
package/bin/capy.ts
CHANGED
|
@@ -33,6 +33,7 @@ const main = defineCommand({
|
|
|
33
33
|
watch: () => import("../src/commands/monitoring.js").then(m => m.watch),
|
|
34
34
|
unwatch: () => import("../src/commands/monitoring.js").then(m => m.unwatch),
|
|
35
35
|
watches: () => import("../src/commands/monitoring.js").then(m => m.watches),
|
|
36
|
+
wait: () => import("../src/commands/monitoring.js").then(m => m.wait),
|
|
36
37
|
_poll: () => import("../src/commands/monitoring.js").then(m => m._poll),
|
|
37
38
|
init: () => import("../src/commands/setup.js").then(m => m.init),
|
|
38
39
|
config: () => import("../src/commands/setup.js").then(m => m.config),
|
package/package.json
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "capyai",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Unofficial Capy.ai CLI for agent orchestration with quality gates",
|
|
6
6
|
"bin": {
|
|
7
|
-
"capy": "./bin/capy.js"
|
|
7
|
+
"capy": "./bin/capy.js",
|
|
8
|
+
"capy-mcp": "./bin/capy-mcp.ts"
|
|
8
9
|
},
|
|
9
10
|
"scripts": {
|
|
10
11
|
"typecheck": "tsc --noEmit"
|
|
11
12
|
},
|
|
12
13
|
"files": [
|
|
13
14
|
"bin/",
|
|
14
|
-
"src/"
|
|
15
|
+
"src/",
|
|
16
|
+
"skills/"
|
|
15
17
|
],
|
|
16
18
|
"engines": {
|
|
17
19
|
"node": ">=18"
|
|
@@ -22,7 +24,7 @@
|
|
|
22
24
|
"license": "MIT",
|
|
23
25
|
"repository": {
|
|
24
26
|
"type": "git",
|
|
25
|
-
"url": "https://github.com/
|
|
27
|
+
"url": "https://github.com/yazcaleb/capy-cli"
|
|
26
28
|
},
|
|
27
29
|
"keywords": [
|
|
28
30
|
"capy",
|
|
@@ -33,6 +35,8 @@
|
|
|
33
35
|
],
|
|
34
36
|
"dependencies": {
|
|
35
37
|
"@clack/prompts": "^1.2.0",
|
|
36
|
-
"
|
|
38
|
+
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
39
|
+
"citty": "^0.2.2",
|
|
40
|
+
"zod": "^3.24.0"
|
|
37
41
|
}
|
|
38
42
|
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: capy
|
|
3
|
+
description: Orchestrate Capy.ai coding agents with quality gates. Delegate coding work, wait for completion, review quality, approve or retry.
|
|
4
|
+
metadata:
|
|
5
|
+
author: yazcaleb
|
|
6
|
+
version: "0.3.2"
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# capy
|
|
10
|
+
|
|
11
|
+
Orchestrate Capy.ai coding agents. Start tasks, wait for them, enforce quality gates, approve or retry. Works with any AI agent (Claude Code, Codex, OpenClaw, Poke).
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm i -g capyai
|
|
17
|
+
capy init
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Or set env vars directly:
|
|
21
|
+
```bash
|
|
22
|
+
export CAPY_API_KEY=capy_...
|
|
23
|
+
export CAPY_PROJECT_ID=...
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Agent workflow
|
|
27
|
+
|
|
28
|
+
The core loop for any agent:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
# 1. Start work
|
|
32
|
+
capy captain "Implement feature X. Files: src/foo.ts. Tests required." --json
|
|
33
|
+
|
|
34
|
+
# 2. Wait for completion (blocks until done)
|
|
35
|
+
capy wait <thread-id> --timeout=600 --json
|
|
36
|
+
|
|
37
|
+
# 3. Check quality gates
|
|
38
|
+
capy review <task-id> --json
|
|
39
|
+
# Parse quality.pass boolean
|
|
40
|
+
|
|
41
|
+
# 4a. If pass: approve
|
|
42
|
+
capy approve <task-id> --json
|
|
43
|
+
|
|
44
|
+
# 4b. If fail: retry with context
|
|
45
|
+
capy retry <task-id> --fix="fix the failing CI check" --json
|
|
46
|
+
# Go back to step 2
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Commands
|
|
50
|
+
|
|
51
|
+
| Command | What it does |
|
|
52
|
+
|---------|-------------|
|
|
53
|
+
| `capy captain "<prompt>"` | Start Captain thread (primary agent) |
|
|
54
|
+
| `capy build "<prompt>"` | Start Build agent (isolated, small tasks) |
|
|
55
|
+
| `capy wait <id>` | Block until task/thread reaches terminal state |
|
|
56
|
+
| `capy review <id>` | Run quality gates (pr_exists, ci, greptile, threads, tests) |
|
|
57
|
+
| `capy approve <id>` | Approve if all gates pass |
|
|
58
|
+
| `capy retry <id> --fix="..."` | Retry with failure context from previous attempt |
|
|
59
|
+
| `capy status` | Dashboard of all threads and tasks |
|
|
60
|
+
| `capy list [status]` | List tasks, optionally filtered |
|
|
61
|
+
| `capy get <id>` | Task or thread details |
|
|
62
|
+
| `capy diff <id>` | View diff from task |
|
|
63
|
+
| `capy pr <id>` | Create PR for task |
|
|
64
|
+
| `capy threads list` | List Captain threads |
|
|
65
|
+
| `capy threads get <id>` | Thread details |
|
|
66
|
+
| `capy threads msg <id> <text>` | Message a thread |
|
|
67
|
+
| `capy threads stop <id>` | Stop a thread |
|
|
68
|
+
| `capy threads messages <id>` | View thread messages |
|
|
69
|
+
|
|
70
|
+
All commands support `--json` for machine-readable output.
|
|
71
|
+
|
|
72
|
+
## Quality gates
|
|
73
|
+
|
|
74
|
+
`capy review` checks pass/fail gates:
|
|
75
|
+
|
|
76
|
+
- **pr_exists** — PR was created
|
|
77
|
+
- **pr_open** — PR is OPEN or MERGED
|
|
78
|
+
- **ci** — CI checks passing
|
|
79
|
+
- **greptile** — No unaddressed Greptile issues
|
|
80
|
+
- **threads** — No unresolved GitHub review threads
|
|
81
|
+
- **tests** — Diff includes test files
|
|
82
|
+
|
|
83
|
+
Configure which run via `capy config quality.reviewProvider greptile|capy|both|none`.
|
|
84
|
+
|
|
85
|
+
## JSON output
|
|
86
|
+
|
|
87
|
+
Every command returns structured JSON with `--json`. Errors return `{ "error": { "code": "...", "message": "..." } }`.
|
|
88
|
+
|
|
89
|
+
Key fields for agents:
|
|
90
|
+
- `capy review --json` → `quality.pass` (boolean), `quality.gates` (array)
|
|
91
|
+
- `capy wait --json` → full task/thread object on completion, `error.code: "timeout"` on timeout
|
|
92
|
+
- `capy retry --json` → `newThread` (thread ID to wait on)
|
|
93
|
+
|
|
94
|
+
## Prompting tips
|
|
95
|
+
|
|
96
|
+
Bad: "Fix the CI issue"
|
|
97
|
+
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."
|
|
98
|
+
|
|
99
|
+
Specific files, specific functions, specific acceptance criteria.
|
|
100
|
+
|
|
101
|
+
## Triggers
|
|
102
|
+
|
|
103
|
+
Keywords: capy, captain, build agent, quality gates, delegate coding, orchestrate agents, send to capy, approve task, retry task
|
package/src/api.ts
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import * as config from "./config.js";
|
|
2
|
+
import { IS_JSON } from "./output.js";
|
|
2
3
|
import type { Task, Thread, ThreadMessage, DiffData, Model, ListResponse, PullRequestRef } from "./types.js";
|
|
3
4
|
|
|
5
|
+
function fail(code: string, message: string): never {
|
|
6
|
+
if (IS_JSON) {
|
|
7
|
+
console.log(JSON.stringify({ error: { code, message } }));
|
|
8
|
+
} else {
|
|
9
|
+
console.error(`capy: ${message}`);
|
|
10
|
+
}
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
|
|
4
14
|
async function rawRequest(apiKey: string, server: string, method: string, path: string, body?: unknown): Promise<any> {
|
|
5
15
|
const url = `${server}${path}`;
|
|
6
16
|
const headers: Record<string, string> = {
|
|
@@ -17,29 +27,25 @@ async function rawRequest(apiKey: string, server: string, method: string, path:
|
|
|
17
27
|
try {
|
|
18
28
|
res = await fetch(url, init);
|
|
19
29
|
} catch (e: unknown) {
|
|
20
|
-
|
|
21
|
-
process.exit(1);
|
|
30
|
+
fail("network_error", `request failed — ${(e as Error).message}`);
|
|
22
31
|
}
|
|
23
32
|
|
|
24
33
|
const text = await res.text();
|
|
25
34
|
try {
|
|
26
35
|
const data = JSON.parse(text);
|
|
27
36
|
if (data.error) {
|
|
28
|
-
|
|
29
|
-
process.exit(1);
|
|
37
|
+
fail("api_error", `API error — ${data.error.message || data.error.code}`);
|
|
30
38
|
}
|
|
31
39
|
return data;
|
|
32
40
|
} catch {
|
|
33
|
-
|
|
34
|
-
process.exit(1);
|
|
41
|
+
fail("bad_response", `bad API response: ${text.slice(0, 200)}`);
|
|
35
42
|
}
|
|
36
43
|
}
|
|
37
44
|
|
|
38
45
|
async function request(method: string, path: string, body?: unknown): Promise<any> {
|
|
39
46
|
const cfg = config.load();
|
|
40
47
|
if (!cfg.apiKey) {
|
|
41
|
-
|
|
42
|
-
process.exit(1);
|
|
48
|
+
fail("no_api_key", "API key not configured. Run: capy init");
|
|
43
49
|
}
|
|
44
50
|
return rawRequest(cfg.apiKey, cfg.server, method, path, body);
|
|
45
51
|
}
|
|
@@ -56,6 +56,45 @@ export const watches = defineCommand({
|
|
|
56
56
|
},
|
|
57
57
|
});
|
|
58
58
|
|
|
59
|
+
export const wait = defineCommand({
|
|
60
|
+
meta: { name: "wait", description: "Block until task/thread reaches terminal state" },
|
|
61
|
+
args: {
|
|
62
|
+
id: { type: "positional", description: "Task or thread ID", required: true },
|
|
63
|
+
timeout: { type: "string", description: "Timeout in seconds", default: "300" },
|
|
64
|
+
interval: { type: "string", description: "Poll interval in seconds", default: "10" },
|
|
65
|
+
...jsonArg,
|
|
66
|
+
},
|
|
67
|
+
async run({ args }) {
|
|
68
|
+
const api = await import("../api.js");
|
|
69
|
+
const fmt = await import("../output.js");
|
|
70
|
+
|
|
71
|
+
const timeoutMs = Math.max(10, parseInt(args.timeout) || 300) * 1000;
|
|
72
|
+
const intervalMs = Math.max(5, Math.min(parseInt(args.interval) || 10, 60)) * 1000;
|
|
73
|
+
const isThread = args.id.length > 20 || (args.id.length > 10 && !args.id.match(/^[A-Z]+-\d+$/));
|
|
74
|
+
const terminalTask = new Set(["needs_review", "archived", "completed", "failed"]);
|
|
75
|
+
const terminalThread = new Set(["idle", "archived", "completed"]);
|
|
76
|
+
const terminal = isThread ? terminalThread : terminalTask;
|
|
77
|
+
|
|
78
|
+
const start = Date.now();
|
|
79
|
+
if (!args.json) process.stderr.write(`Waiting for ${args.id} (${isThread ? "thread" : "task"})...`);
|
|
80
|
+
|
|
81
|
+
while (Date.now() - start < timeoutMs) {
|
|
82
|
+
const data = isThread ? await api.getThread(args.id) : await api.getTask(args.id);
|
|
83
|
+
if (terminal.has(data.status)) {
|
|
84
|
+
if (args.json) { fmt.out(data); return; }
|
|
85
|
+
console.log(`\n${isThread ? "Thread" : "Task"} ${data.status}.`);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (!args.json) process.stderr.write(".");
|
|
89
|
+
await new Promise(r => setTimeout(r, intervalMs));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (args.json) { fmt.out({ error: { code: "timeout", message: `Timed out after ${args.timeout}s` } }); process.exit(1); }
|
|
93
|
+
console.error(`\ncapy: timed out after ${args.timeout}s`);
|
|
94
|
+
process.exit(1);
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
|
|
59
98
|
export const _poll = defineCommand({
|
|
60
99
|
meta: { name: "_poll", description: "Internal cron poll", hidden: true },
|
|
61
100
|
args: {
|
package/src/commands/quality.ts
CHANGED
|
@@ -21,7 +21,7 @@ export const review = defineCommand({
|
|
|
21
21
|
const reviewProvider = cfg.quality?.reviewProvider || "greptile";
|
|
22
22
|
|
|
23
23
|
if (!task.pullRequest?.number) {
|
|
24
|
-
if (args.json) { fmt.out({ error: "no_pr", task: task.identifier }); return; }
|
|
24
|
+
if (args.json) { fmt.out({ error: { code: "no_pr", message: `${task.identifier}: No PR` }, task: task.identifier }); return; }
|
|
25
25
|
log.warn(`${task.identifier}: No PR. Create one first: capy pr ${task.identifier}`);
|
|
26
26
|
return;
|
|
27
27
|
}
|
|
@@ -115,17 +115,20 @@ export const reReview = defineCommand({
|
|
|
115
115
|
const reviewProvider = cfg.quality?.reviewProvider || "greptile";
|
|
116
116
|
|
|
117
117
|
if (reviewProvider !== "greptile" && reviewProvider !== "both") {
|
|
118
|
+
if (args.json) { fmt.out({ error: { code: "wrong_provider", message: `re-review requires Greptile provider (current: ${reviewProvider})` } }); process.exit(1); }
|
|
118
119
|
console.error(`capy: re-review requires Greptile provider (current: ${reviewProvider})`);
|
|
119
120
|
process.exit(1);
|
|
120
121
|
}
|
|
121
122
|
|
|
122
123
|
if (!cfg.greptileApiKey && !process.env.GREPTILE_API_KEY) {
|
|
124
|
+
if (args.json) { fmt.out({ error: { code: "no_greptile_key", message: "GREPTILE_API_KEY not set" } }); process.exit(1); }
|
|
123
125
|
console.error("capy: GREPTILE_API_KEY not set. Run: capy config greptileApiKey <key>");
|
|
124
126
|
process.exit(1);
|
|
125
127
|
}
|
|
126
128
|
|
|
127
129
|
const task = await api.getTask(args.id);
|
|
128
130
|
if (!task.pullRequest?.number) {
|
|
131
|
+
if (args.json) { fmt.out({ error: { code: "no_pr", message: `${task.identifier}: No PR` } }); process.exit(1); }
|
|
129
132
|
console.error(`${task.identifier}: No PR. Create one first: capy pr ${task.identifier}`);
|
|
130
133
|
process.exit(1);
|
|
131
134
|
}
|
|
@@ -176,7 +179,16 @@ export const approve = defineCommand({
|
|
|
176
179
|
const cfg = config.load();
|
|
177
180
|
const q = await quality.check(task);
|
|
178
181
|
|
|
179
|
-
|
|
182
|
+
const approved = q.pass || !!args.force;
|
|
183
|
+
|
|
184
|
+
if (args.json) {
|
|
185
|
+
if (!approved) {
|
|
186
|
+
fmt.out({ task: task.identifier, quality: q, approved: false, error: { code: "gates_failed", message: q.summary } });
|
|
187
|
+
process.exit(1);
|
|
188
|
+
}
|
|
189
|
+
fmt.out({ task: task.identifier, quality: q, approved: true });
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
180
192
|
|
|
181
193
|
log.info(`${task.identifier} \u2014 ${task.title}\n`);
|
|
182
194
|
q.gates.forEach(g => {
|
|
@@ -187,7 +199,7 @@ export const approve = defineCommand({
|
|
|
187
199
|
if (q.pass) log.success(q.summary);
|
|
188
200
|
else log.warn(q.summary);
|
|
189
201
|
|
|
190
|
-
if (!
|
|
202
|
+
if (!approved) {
|
|
191
203
|
log.error("Blocked. Fix the failing gates or use --force to override.");
|
|
192
204
|
process.exit(1);
|
|
193
205
|
}
|
|
@@ -197,12 +209,13 @@ export const approve = defineCommand({
|
|
|
197
209
|
const approveCmd = cfg.approveCommand;
|
|
198
210
|
if (approveCmd) {
|
|
199
211
|
try {
|
|
200
|
-
const {
|
|
201
|
-
const
|
|
212
|
+
const { execFileSync } = await import("node:child_process");
|
|
213
|
+
const parts = approveCmd
|
|
202
214
|
.replace("{task}", task.identifier || task.id)
|
|
203
215
|
.replace("{title}", task.title || "")
|
|
204
|
-
.replace("{pr}", String(task.pullRequest?.number || ""))
|
|
205
|
-
|
|
216
|
+
.replace("{pr}", String(task.pullRequest?.number || ""))
|
|
217
|
+
.split(/\s+/);
|
|
218
|
+
execFileSync(parts[0], parts.slice(1), { encoding: "utf8", timeout: 15000, stdio: "pipe" });
|
|
206
219
|
log.info("Post-approve hook ran.");
|
|
207
220
|
} catch {}
|
|
208
221
|
}
|
|
@@ -294,18 +307,19 @@ export const retry = defineCommand({
|
|
|
294
307
|
retryPrompt += `Fix the issues from the previous attempt. Do not repeat the same mistakes.\n`;
|
|
295
308
|
retryPrompt += `Include tests. Run tests before completing. Verify CI will pass.\n`;
|
|
296
309
|
|
|
297
|
-
if (args.json) {
|
|
298
|
-
fmt.out({ originalTask: task.identifier, retryPrompt, context });
|
|
299
|
-
return;
|
|
300
|
-
}
|
|
301
|
-
|
|
302
310
|
if (task.status === "in_progress") {
|
|
303
311
|
await api.stopTask(args.id, "Retrying with fixes");
|
|
304
|
-
log.info(`Stopped ${task.identifier}.`);
|
|
312
|
+
if (!args.json) log.info(`Stopped ${task.identifier}.`);
|
|
305
313
|
}
|
|
306
314
|
|
|
307
315
|
const model = resolveModel(args) || cfg.defaultModel;
|
|
308
316
|
const data = await api.createThread(retryPrompt, model);
|
|
317
|
+
|
|
318
|
+
if (args.json) {
|
|
319
|
+
fmt.out({ originalTask: task.identifier, newThread: data.id, model, contextLines: context.split("\n").length });
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
309
323
|
log.success(`Retry started: https://capy.ai/project/${cfg.projectId}/captain/${data.id}`);
|
|
310
324
|
log.info(`Thread: ${data.id} Model: ${model}`);
|
|
311
325
|
log.info(`Context included: ${context.split("\n").length} lines from previous attempt.`);
|
package/src/commands/setup.ts
CHANGED
|
@@ -117,6 +117,7 @@ export const config = defineCommand({
|
|
|
117
117
|
if (!args.value) {
|
|
118
118
|
const val = configMod.get(args.key);
|
|
119
119
|
if (val === undefined) {
|
|
120
|
+
if (args.json) { fmt.out({ error: { code: "unknown_key", message: `unknown config key "${args.key}"` } }); process.exit(1); }
|
|
120
121
|
console.error(`capy: unknown config key "${args.key}"`);
|
|
121
122
|
process.exit(1);
|
|
122
123
|
}
|
|
@@ -173,6 +174,7 @@ export const tools = defineCommand({
|
|
|
173
174
|
"re-review":{ args: "<id>", desc: "Trigger Greptile re-review" },
|
|
174
175
|
approve: { args: "<id>", desc: "Approve if gates pass" },
|
|
175
176
|
retry: { args: "<id> [--fix=...]", desc: "Retry with failure context" },
|
|
177
|
+
wait: { args: "<id>", desc: "Block until done" },
|
|
176
178
|
watch: { args: "<id>", desc: "Poll + notify on completion" },
|
|
177
179
|
unwatch: { args: "<id>", desc: "Stop watching" },
|
|
178
180
|
watches: { args: "", desc: "List watches" },
|
|
@@ -219,7 +221,7 @@ export const status = defineCommand({
|
|
|
219
221
|
|
|
220
222
|
const cfg = config.load();
|
|
221
223
|
|
|
222
|
-
let threads:
|
|
224
|
+
let threads: Awaited<ReturnType<typeof api.listThreads>>, tasks: Awaited<ReturnType<typeof api.listTasks>>;
|
|
223
225
|
if (!args.json) {
|
|
224
226
|
const s = spinner();
|
|
225
227
|
s.start("Loading dashboard...");
|
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/commands/threads.ts
CHANGED
|
@@ -51,7 +51,9 @@ const msg = defineCommand({
|
|
|
51
51
|
},
|
|
52
52
|
async run({ args }) {
|
|
53
53
|
const api = await import("../api.js");
|
|
54
|
-
await
|
|
54
|
+
const fmt = await import("../output.js");
|
|
55
|
+
const result = await api.messageThread(args.id, args.text);
|
|
56
|
+
if (args.json) { fmt.out(result); return; }
|
|
55
57
|
console.log("Message sent.");
|
|
56
58
|
},
|
|
57
59
|
});
|
|
@@ -64,7 +66,9 @@ const stop = defineCommand({
|
|
|
64
66
|
},
|
|
65
67
|
async run({ args }) {
|
|
66
68
|
const api = await import("../api.js");
|
|
67
|
-
await
|
|
69
|
+
const fmt = await import("../output.js");
|
|
70
|
+
const result = await api.stopThread(args.id);
|
|
71
|
+
if (args.json) { fmt.out(result); return; }
|
|
68
72
|
console.log(`Stopped thread ${args.id}.`);
|
|
69
73
|
},
|
|
70
74
|
});
|
package/src/config.ts
CHANGED
|
@@ -85,6 +85,7 @@ export function set(key: string, value: string): void {
|
|
|
85
85
|
if (value === "true") parsed = true;
|
|
86
86
|
else if (value === "false") parsed = false;
|
|
87
87
|
else if (/^\d+$/.test(value)) parsed = parseInt(value);
|
|
88
|
+
else if (/^\d+\.\d+$/.test(value)) parsed = parseFloat(value);
|
|
88
89
|
obj[parts[parts.length - 1]] = parsed;
|
|
89
90
|
} else {
|
|
90
91
|
cfg[key] = value;
|
package/src/github.ts
CHANGED
|
@@ -98,8 +98,9 @@ export function diffHasTests(files: DiffFile[]): boolean {
|
|
|
98
98
|
|
|
99
99
|
export function getUnresolvedThreads(repo: string, number: number): { body: string; author: string }[] {
|
|
100
100
|
try {
|
|
101
|
-
const query = `query { repository(owner:
|
|
102
|
-
const
|
|
101
|
+
const query = `query($owner: String!, $name: String!, $number: Int!) { repository(owner: $owner, name: $name) { pullRequest(number: $number) { reviewThreads(first:100) { nodes { isResolved isOutdated comments(first:1) { nodes { body author { login } } } } } } } }`;
|
|
102
|
+
const [owner, name] = repo.split("/");
|
|
103
|
+
const out = execFileSync("gh", ["api", "graphql", "-f", `query=${query}`, "-F", `owner=${owner}`, "-F", `name=${name}`, "-F", `number=${number}`], {
|
|
103
104
|
encoding: "utf8", timeout: 15000,
|
|
104
105
|
});
|
|
105
106
|
const data = JSON.parse(out);
|
package/src/greptile.ts
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
import * as config from "./config.js";
|
|
2
|
+
import { IS_JSON } from "./output.js";
|
|
2
3
|
import type { UnaddressedIssue } from "./types.js";
|
|
3
4
|
|
|
4
5
|
const MCP_URL = "https://api.greptile.com/mcp";
|
|
5
6
|
|
|
7
|
+
function warn(msg: string): void {
|
|
8
|
+
if (!IS_JSON) console.error(msg);
|
|
9
|
+
}
|
|
10
|
+
|
|
6
11
|
async function mcp(method: string, params: Record<string, unknown>): Promise<any> {
|
|
7
12
|
const cfg = config.load();
|
|
8
13
|
const apiKey = cfg.greptileApiKey || process.env.GREPTILE_API_KEY || "";
|
|
9
14
|
if (!apiKey) {
|
|
10
|
-
|
|
11
|
-
|
|
15
|
+
warn("capy: GREPTILE_API_KEY not set. Run: capy config greptileApiKey <key>");
|
|
16
|
+
return null;
|
|
12
17
|
}
|
|
13
18
|
|
|
14
19
|
const body = {
|
|
@@ -34,7 +39,7 @@ async function mcp(method: string, params: Record<string, unknown>): Promise<any
|
|
|
34
39
|
signal: AbortSignal.timeout(30000),
|
|
35
40
|
});
|
|
36
41
|
} catch (e: unknown) {
|
|
37
|
-
|
|
42
|
+
warn(`greptile: request failed — ${(e as Error).message}`);
|
|
38
43
|
return null;
|
|
39
44
|
}
|
|
40
45
|
|
|
@@ -42,7 +47,7 @@ async function mcp(method: string, params: Record<string, unknown>): Promise<any
|
|
|
42
47
|
try {
|
|
43
48
|
const data = JSON.parse(text);
|
|
44
49
|
if (data.error) {
|
|
45
|
-
|
|
50
|
+
warn(`greptile: ${data.error.message || JSON.stringify(data.error)}`);
|
|
46
51
|
return null;
|
|
47
52
|
}
|
|
48
53
|
if (data.result?.content) {
|
|
@@ -53,7 +58,7 @@ async function mcp(method: string, params: Record<string, unknown>): Promise<any
|
|
|
53
58
|
}
|
|
54
59
|
return data.result;
|
|
55
60
|
} catch {
|
|
56
|
-
|
|
61
|
+
warn(`greptile: bad response: ${text.slice(0, 300)}`);
|
|
57
62
|
return null;
|
|
58
63
|
}
|
|
59
64
|
}
|
|
@@ -121,7 +126,7 @@ export async function freshReview(repo: string, prNumber: number, defaultBranch?
|
|
|
121
126
|
const reviewId = trigger.codeReviewId || trigger.id;
|
|
122
127
|
if (!reviewId) return trigger;
|
|
123
128
|
|
|
124
|
-
|
|
129
|
+
warn(`greptile: review triggered (${reviewId}), waiting...`);
|
|
125
130
|
return waitForReview(reviewId);
|
|
126
131
|
}
|
|
127
132
|
|
package/src/mcp.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import * as api from "./api.js";
|
|
6
|
+
import * as config from "./config.js";
|
|
7
|
+
|
|
8
|
+
const require = createRequire(import.meta.url);
|
|
9
|
+
const { version } = require("../package.json");
|
|
10
|
+
|
|
11
|
+
const server = new McpServer({
|
|
12
|
+
name: "capy",
|
|
13
|
+
version,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
server.tool("capy_captain", "Start a Captain thread to delegate coding work", {
|
|
17
|
+
prompt: z.string().describe("What the agent should do. Be specific: files, functions, acceptance criteria."),
|
|
18
|
+
model: z.string().optional().describe("Model ID override"),
|
|
19
|
+
}, async ({ prompt, model }) => {
|
|
20
|
+
const cfg = config.load();
|
|
21
|
+
const data = await api.createThread(prompt, model);
|
|
22
|
+
return { content: [{ type: "text", text: JSON.stringify({ threadId: data.id, url: `https://capy.ai/project/${cfg.projectId}/captain/${data.id}` }) }] };
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
server.tool("capy_status", "Get task or thread status, or full dashboard", {
|
|
26
|
+
id: z.string().optional().describe("Task or thread ID. Omit for dashboard."),
|
|
27
|
+
}, async ({ id }) => {
|
|
28
|
+
if (id) {
|
|
29
|
+
const isThread = id.length > 20 || (id.length > 10 && !id.match(/^[A-Z]+-\d+$/));
|
|
30
|
+
const data: any = isThread ? await api.getThread(id) : await api.getTask(id);
|
|
31
|
+
// Capy API reports merged PRs as "closed". Cross-ref with GitHub for real state.
|
|
32
|
+
if (!isThread && data.pullRequest?.number && data.pullRequest.state === "closed") {
|
|
33
|
+
const { getPR } = await import("./github.js");
|
|
34
|
+
const cfg = config.load();
|
|
35
|
+
const repo = data.pullRequest.repoFullName || cfg.repos[0]?.repoFullName;
|
|
36
|
+
if (repo) {
|
|
37
|
+
const ghPR = getPR(repo, data.pullRequest.number);
|
|
38
|
+
if (ghPR) data.pullRequest.state = ghPR.state.toLowerCase();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
42
|
+
}
|
|
43
|
+
const [threads, tasks] = await Promise.all([api.listThreads({ limit: 10 }), api.listTasks({ limit: 30 })]);
|
|
44
|
+
return { content: [{ type: "text", text: JSON.stringify({ threads: threads.items || [], tasks: tasks.items || [] }) }] };
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
server.tool("capy_review", "Run quality gates on a task", {
|
|
48
|
+
id: z.string().describe("Task ID"),
|
|
49
|
+
}, async ({ id }) => {
|
|
50
|
+
const qualityEngine = await import("./quality-engine.js");
|
|
51
|
+
const task = await api.getTask(id);
|
|
52
|
+
if (!task.pullRequest?.number) {
|
|
53
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: "no_pr", task: task.identifier }) }] };
|
|
54
|
+
}
|
|
55
|
+
const q = await qualityEngine.check(task);
|
|
56
|
+
return { content: [{ type: "text", text: JSON.stringify({ task: task.identifier, quality: q }) }] };
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
server.tool("capy_approve", "Approve a task if quality gates pass. Runs the configured approveCommand hook on success.", {
|
|
60
|
+
id: z.string().describe("Task ID"),
|
|
61
|
+
force: z.boolean().optional().describe("Override failing gates"),
|
|
62
|
+
}, async ({ id, force }) => {
|
|
63
|
+
const qualityEngine = await import("./quality-engine.js");
|
|
64
|
+
const task = await api.getTask(id);
|
|
65
|
+
const cfg = config.load();
|
|
66
|
+
const q = await qualityEngine.check(task);
|
|
67
|
+
const approved = q.pass || !!force;
|
|
68
|
+
|
|
69
|
+
if (approved && cfg.approveCommand) {
|
|
70
|
+
try {
|
|
71
|
+
const { execFileSync } = await import("node:child_process");
|
|
72
|
+
const parts = cfg.approveCommand
|
|
73
|
+
.replace("{task}", task.identifier || task.id)
|
|
74
|
+
.replace("{title}", task.title || "")
|
|
75
|
+
.replace("{pr}", String(task.pullRequest?.number || ""))
|
|
76
|
+
.split(/\s+/);
|
|
77
|
+
execFileSync(parts[0], parts.slice(1), { encoding: "utf8", timeout: 15000, stdio: "pipe" });
|
|
78
|
+
} catch {}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { content: [{ type: "text", text: JSON.stringify({ task: task.identifier, quality: q, approved }) }] };
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
server.tool("capy_retry", "Retry a failed task with context from previous attempt", {
|
|
85
|
+
id: z.string().describe("Task ID to retry"),
|
|
86
|
+
fix: z.string().optional().describe("Specific fix instructions"),
|
|
87
|
+
model: z.string().optional().describe("Model ID override"),
|
|
88
|
+
}, async ({ id, fix, model }) => {
|
|
89
|
+
const task = await api.getTask(id);
|
|
90
|
+
const cfg = config.load();
|
|
91
|
+
|
|
92
|
+
let context = `Previous attempt: ${task.identifier} "${task.title}" [${task.status}]\n`;
|
|
93
|
+
try {
|
|
94
|
+
const d = await api.getDiff(id);
|
|
95
|
+
if (d.stats?.files && d.stats.files > 0) {
|
|
96
|
+
context += `\nPrevious diff: +${d.stats.additions} -${d.stats.deletions} in ${d.stats.files} files\n`;
|
|
97
|
+
}
|
|
98
|
+
} catch {}
|
|
99
|
+
|
|
100
|
+
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`;
|
|
101
|
+
if (fix) retryPrompt += `--- FIX ---\n${fix}\n\n`;
|
|
102
|
+
retryPrompt += `Fix the issues. Include tests. Run tests before completing.\n`;
|
|
103
|
+
|
|
104
|
+
if (task.status === "in_progress") {
|
|
105
|
+
await api.stopTask(id, "Retrying with fixes");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const data = await api.createThread(retryPrompt, model || cfg.defaultModel);
|
|
109
|
+
return { content: [{ type: "text", text: JSON.stringify({ originalTask: task.identifier, newThread: data.id, model: model || cfg.defaultModel }) }] };
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
server.tool("capy_wait", "Poll until a task or thread reaches terminal state", {
|
|
113
|
+
id: z.string().describe("Task or thread ID"),
|
|
114
|
+
timeout: z.number().optional().describe("Timeout in seconds (default 300)"),
|
|
115
|
+
interval: z.number().optional().describe("Poll interval in seconds (default 10)"),
|
|
116
|
+
}, async ({ id, timeout, interval }) => {
|
|
117
|
+
const timeoutMs = (timeout || 300) * 1000;
|
|
118
|
+
const intervalMs = Math.max(5, Math.min(interval || 10, 60)) * 1000;
|
|
119
|
+
const isThread = id.length > 20 || (id.length > 10 && !id.match(/^[A-Z]+-\d+$/));
|
|
120
|
+
const terminalTask = new Set(["needs_review", "archived", "completed", "failed"]);
|
|
121
|
+
const terminalThread = new Set(["idle", "archived", "completed"]);
|
|
122
|
+
const terminal = isThread ? terminalThread : terminalTask;
|
|
123
|
+
|
|
124
|
+
const start = Date.now();
|
|
125
|
+
while (Date.now() - start < timeoutMs) {
|
|
126
|
+
const data = isThread ? await api.getThread(id) : await api.getTask(id);
|
|
127
|
+
if (terminal.has(data.status)) {
|
|
128
|
+
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
129
|
+
}
|
|
130
|
+
await new Promise(r => setTimeout(r, intervalMs));
|
|
131
|
+
}
|
|
132
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: { code: "timeout", message: `Timed out after ${timeout || 300}s` } }) }] };
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const transport = new StdioServerTransport();
|
|
136
|
+
await server.connect(transport);
|
package/src/output.ts
CHANGED
|
@@ -19,7 +19,8 @@ export function out(data: unknown): void {
|
|
|
19
19
|
|
|
20
20
|
export function table(headers: string[], rows: (string | number | null | undefined)[][]): void {
|
|
21
21
|
if (IS_JSON) {
|
|
22
|
-
|
|
22
|
+
const keyed = rows.map(r => Object.fromEntries(headers.map((h, i) => [h.toLowerCase(), r[i] ?? null])));
|
|
23
|
+
console.log(JSON.stringify(keyed, null, 2));
|
|
23
24
|
return;
|
|
24
25
|
}
|
|
25
26
|
const widths = headers.map((h, i) =>
|
package/src/watch.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { execSync } from "node:child_process";
|
|
3
|
+
import { execSync, execFileSync } from "node:child_process";
|
|
4
4
|
import * as config from "./config.js";
|
|
5
5
|
import type { WatchEntry } from "./types.js";
|
|
6
6
|
|
|
@@ -9,7 +9,7 @@ function getCrontab(): string {
|
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
function setCrontab(content: string): void {
|
|
12
|
-
execSync(
|
|
12
|
+
execSync("crontab -", { input: content, encoding: "utf8" });
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export function add(id: string, type: string, intervalMin: number): boolean {
|
|
@@ -53,8 +53,9 @@ export function notify(text: string): boolean {
|
|
|
53
53
|
const cfg = config.load();
|
|
54
54
|
const cmd = cfg.notifyCommand || "openclaw system event --text {text} --mode now";
|
|
55
55
|
try {
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
const parts = cmd.replace("{text}", text).split(/\s+/);
|
|
57
|
+
execFileSync(parts[0], parts.slice(1), {
|
|
58
|
+
encoding: "utf8", timeout: 15000, stdio: "pipe",
|
|
58
59
|
});
|
|
59
60
|
return true;
|
|
60
61
|
} catch { return false; }
|