capyai 0.3.0 → 0.3.2
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 +42 -13
- package/src/commands/monitoring.ts +39 -0
- package/src/commands/quality.ts +27 -13
- package/src/commands/setup.ts +80 -41
- 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 +126 -0
- package/src/output.ts +2 -1
- package/src/types.ts +1 -0
- 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.2",
|
|
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,15 +1,20 @@
|
|
|
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
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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}`);
|
|
9
10
|
}
|
|
10
|
-
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function rawRequest(apiKey: string, server: string, method: string, path: string, body?: unknown): Promise<any> {
|
|
15
|
+
const url = `${server}${path}`;
|
|
11
16
|
const headers: Record<string, string> = {
|
|
12
|
-
"Authorization": `Bearer ${
|
|
17
|
+
"Authorization": `Bearer ${apiKey}`,
|
|
13
18
|
"Accept": "application/json",
|
|
14
19
|
};
|
|
15
20
|
const init: RequestInit = { method, headers };
|
|
@@ -22,24 +27,48 @@ async function request(method: string, path: string, body?: unknown): Promise<an
|
|
|
22
27
|
try {
|
|
23
28
|
res = await fetch(url, init);
|
|
24
29
|
} catch (e: unknown) {
|
|
25
|
-
|
|
26
|
-
process.exit(1);
|
|
30
|
+
fail("network_error", `request failed — ${(e as Error).message}`);
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
const text = await res.text();
|
|
30
34
|
try {
|
|
31
35
|
const data = JSON.parse(text);
|
|
32
36
|
if (data.error) {
|
|
33
|
-
|
|
34
|
-
process.exit(1);
|
|
37
|
+
fail("api_error", `API error — ${data.error.message || data.error.code}`);
|
|
35
38
|
}
|
|
36
39
|
return data;
|
|
37
40
|
} catch {
|
|
38
|
-
|
|
39
|
-
process.exit(1);
|
|
41
|
+
fail("bad_response", `bad API response: ${text.slice(0, 200)}`);
|
|
40
42
|
}
|
|
41
43
|
}
|
|
42
44
|
|
|
45
|
+
async function request(method: string, path: string, body?: unknown): Promise<any> {
|
|
46
|
+
const cfg = config.load();
|
|
47
|
+
if (!cfg.apiKey) {
|
|
48
|
+
fail("no_api_key", "API key not configured. Run: capy init");
|
|
49
|
+
}
|
|
50
|
+
return rawRequest(cfg.apiKey, cfg.server, method, path, body);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// --- Init helpers (accept key directly, before config is saved) ---
|
|
54
|
+
export interface Project {
|
|
55
|
+
id: string;
|
|
56
|
+
name: string;
|
|
57
|
+
description?: string | null;
|
|
58
|
+
taskCode: string;
|
|
59
|
+
repos: { repoFullName: string; branch: string }[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function listProjects(apiKey: string, server = "https://capy.ai/api/v1"): Promise<Project[]> {
|
|
63
|
+
const data = await rawRequest(apiKey, server, "GET", "/projects");
|
|
64
|
+
return data.items || [];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function listModelsWithKey(apiKey: string, server = "https://capy.ai/api/v1"): Promise<Model[]> {
|
|
68
|
+
const data = await rawRequest(apiKey, server, "GET", "/models");
|
|
69
|
+
return data.models || [];
|
|
70
|
+
}
|
|
71
|
+
|
|
43
72
|
// --- Threads ---
|
|
44
73
|
export async function createThread(prompt: string, model?: string, repos?: unknown[]): Promise<Thread> {
|
|
45
74
|
const cfg = config.load();
|
|
@@ -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
|
@@ -7,55 +7,92 @@ export const init = defineCommand({
|
|
|
7
7
|
async run() {
|
|
8
8
|
const p = await import("@clack/prompts");
|
|
9
9
|
const config = await import("../config.js");
|
|
10
|
+
const api = await import("../api.js");
|
|
10
11
|
|
|
11
12
|
p.intro("capy setup");
|
|
12
13
|
|
|
13
14
|
const cfg = config.load();
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
}
|
|
20
|
-
projectId: () => p.text({
|
|
21
|
-
message: "Project ID",
|
|
22
|
-
initialValue: cfg.projectId || "",
|
|
23
|
-
validate: (v) => { if (!v) return "Project ID is required"; },
|
|
24
|
-
}),
|
|
25
|
-
repos: () => p.text({
|
|
26
|
-
message: "Repos (owner/repo:branch, comma-separated)",
|
|
27
|
-
initialValue: cfg.repos.map(r => `${r.repoFullName}:${r.branch}`).join(", ") || "",
|
|
28
|
-
placeholder: "owner/repo:main",
|
|
29
|
-
}),
|
|
30
|
-
defaultModel: () => p.text({
|
|
31
|
-
message: "Default model",
|
|
32
|
-
initialValue: cfg.defaultModel,
|
|
33
|
-
}),
|
|
34
|
-
reviewProvider: () => p.select({
|
|
35
|
-
message: "Review provider",
|
|
36
|
-
initialValue: cfg.quality?.reviewProvider || "greptile",
|
|
37
|
-
options: [
|
|
38
|
-
{ value: "greptile", label: "Greptile", hint: "AI code review via Greptile API" },
|
|
39
|
-
{ value: "capy", label: "Capy", hint: "GitHub unresolved review threads" },
|
|
40
|
-
{ value: "both", label: "Both", hint: "Strictest: Greptile + GitHub threads" },
|
|
41
|
-
{ value: "none", label: "None", hint: "Skip review gates" },
|
|
42
|
-
],
|
|
43
|
-
}),
|
|
15
|
+
|
|
16
|
+
// 1. API key
|
|
17
|
+
const apiKey = await p.password({
|
|
18
|
+
message: "Capy API key",
|
|
19
|
+
mask: "*",
|
|
20
|
+
validate: (v) => { if (!v) return "API key is required"; },
|
|
44
21
|
});
|
|
22
|
+
if (p.isCancel(apiKey)) { p.cancel("Setup cancelled."); process.exit(0); }
|
|
23
|
+
|
|
24
|
+
// 2. Fetch projects + models in parallel using the key
|
|
25
|
+
const s = p.spinner();
|
|
26
|
+
s.start("Fetching your projects and models...");
|
|
27
|
+
const [projects, models] = await Promise.all([
|
|
28
|
+
api.listProjects(apiKey, cfg.server),
|
|
29
|
+
api.listModelsWithKey(apiKey, cfg.server),
|
|
30
|
+
]);
|
|
31
|
+
s.stop(`Found ${projects.length} project${projects.length !== 1 ? "s" : ""}, ${models.length} models`);
|
|
45
32
|
|
|
46
|
-
if (
|
|
47
|
-
p.
|
|
48
|
-
process.exit(
|
|
33
|
+
if (!projects.length) {
|
|
34
|
+
p.log.error("No projects found for this API key. Create one at capy.ai first.");
|
|
35
|
+
process.exit(1);
|
|
49
36
|
}
|
|
50
37
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
38
|
+
// 3. Select project
|
|
39
|
+
const projectId = projects.length === 1
|
|
40
|
+
? (() => { p.log.info(`Project: ${projects[0].name} (${projects[0].taskCode})`); return projects[0].id; })()
|
|
41
|
+
: await (async () => {
|
|
42
|
+
const sel = await p.select({
|
|
43
|
+
message: "Select project",
|
|
44
|
+
initialValue: cfg.projectId || projects[0].id,
|
|
45
|
+
options: projects.map(proj => ({
|
|
46
|
+
value: proj.id,
|
|
47
|
+
label: proj.name,
|
|
48
|
+
hint: `${proj.taskCode} \u2022 ${proj.repos.length} repo${proj.repos.length !== 1 ? "s" : ""}`,
|
|
49
|
+
})),
|
|
50
|
+
});
|
|
51
|
+
if (p.isCancel(sel)) { p.cancel("Setup cancelled."); process.exit(0); }
|
|
52
|
+
return sel;
|
|
53
|
+
})();
|
|
54
|
+
|
|
55
|
+
const selectedProject = projects.find(proj => proj.id === projectId)!;
|
|
56
|
+
|
|
57
|
+
// 4. Show repos from project (auto-populated, no typing needed)
|
|
58
|
+
if (selectedProject.repos.length) {
|
|
59
|
+
p.log.info(`Repos:\n${selectedProject.repos.map(r => ` ${r.repoFullName} (${r.branch})`).join("\n")}`);
|
|
60
|
+
} else {
|
|
61
|
+
p.log.warn("No repos configured on this project. Add them at capy.ai.");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 5. Select default model
|
|
65
|
+
const captainModels = models.filter(m => m.captainEligible);
|
|
66
|
+
const defaultModel = await p.select({
|
|
67
|
+
message: "Default model",
|
|
68
|
+
initialValue: cfg.defaultModel || "gpt-5.4",
|
|
69
|
+
options: captainModels.map(m => ({
|
|
70
|
+
value: m.id,
|
|
71
|
+
label: m.name || m.id,
|
|
72
|
+
hint: m.provider || undefined,
|
|
73
|
+
})),
|
|
56
74
|
});
|
|
57
|
-
|
|
58
|
-
|
|
75
|
+
if (p.isCancel(defaultModel)) { p.cancel("Setup cancelled."); process.exit(0); }
|
|
76
|
+
|
|
77
|
+
// 6. Review provider
|
|
78
|
+
const reviewProvider = await p.select({
|
|
79
|
+
message: "Review provider",
|
|
80
|
+
initialValue: cfg.quality?.reviewProvider || "greptile",
|
|
81
|
+
options: [
|
|
82
|
+
{ value: "greptile", label: "Greptile", hint: "AI code review via Greptile API" },
|
|
83
|
+
{ value: "capy", label: "Capy", hint: "GitHub unresolved review threads" },
|
|
84
|
+
{ value: "both", label: "Both", hint: "Strictest: Greptile + GitHub threads" },
|
|
85
|
+
{ value: "none", label: "None", hint: "Skip review gates" },
|
|
86
|
+
],
|
|
87
|
+
});
|
|
88
|
+
if (p.isCancel(reviewProvider)) { p.cancel("Setup cancelled."); process.exit(0); }
|
|
89
|
+
|
|
90
|
+
// Save
|
|
91
|
+
cfg.apiKey = apiKey;
|
|
92
|
+
cfg.projectId = projectId as string;
|
|
93
|
+
cfg.repos = selectedProject.repos;
|
|
94
|
+
cfg.defaultModel = defaultModel as string;
|
|
95
|
+
cfg.quality.reviewProvider = reviewProvider as string;
|
|
59
96
|
|
|
60
97
|
config.save(cfg);
|
|
61
98
|
p.outro(`Config saved to ${config.CONFIG_PATH}`);
|
|
@@ -80,6 +117,7 @@ export const config = defineCommand({
|
|
|
80
117
|
if (!args.value) {
|
|
81
118
|
const val = configMod.get(args.key);
|
|
82
119
|
if (val === undefined) {
|
|
120
|
+
if (args.json) { fmt.out({ error: { code: "unknown_key", message: `unknown config key "${args.key}"` } }); process.exit(1); }
|
|
83
121
|
console.error(`capy: unknown config key "${args.key}"`);
|
|
84
122
|
process.exit(1);
|
|
85
123
|
}
|
|
@@ -136,6 +174,7 @@ export const tools = defineCommand({
|
|
|
136
174
|
"re-review":{ args: "<id>", desc: "Trigger Greptile re-review" },
|
|
137
175
|
approve: { args: "<id>", desc: "Approve if gates pass" },
|
|
138
176
|
retry: { args: "<id> [--fix=...]", desc: "Retry with failure context" },
|
|
177
|
+
wait: { args: "<id>", desc: "Block until done" },
|
|
139
178
|
watch: { args: "<id>", desc: "Poll + notify on completion" },
|
|
140
179
|
unwatch: { args: "<id>", desc: "Stop watching" },
|
|
141
180
|
watches: { args: "", desc: "List watches" },
|
|
@@ -182,7 +221,7 @@ export const status = defineCommand({
|
|
|
182
221
|
|
|
183
222
|
const cfg = config.load();
|
|
184
223
|
|
|
185
|
-
let threads:
|
|
224
|
+
let threads: Awaited<ReturnType<typeof api.listThreads>>, tasks: Awaited<ReturnType<typeof api.listTasks>>;
|
|
186
225
|
if (!args.json) {
|
|
187
226
|
const s = spinner();
|
|
188
227
|
s.start("Loading dashboard...");
|
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,126 @@
|
|
|
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 = isThread ? await api.getThread(id) : await api.getTask(id);
|
|
31
|
+
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
32
|
+
}
|
|
33
|
+
const [threads, tasks] = await Promise.all([api.listThreads({ limit: 10 }), api.listTasks({ limit: 30 })]);
|
|
34
|
+
return { content: [{ type: "text", text: JSON.stringify({ threads: threads.items || [], tasks: tasks.items || [] }) }] };
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
server.tool("capy_review", "Run quality gates on a task", {
|
|
38
|
+
id: z.string().describe("Task ID"),
|
|
39
|
+
}, async ({ id }) => {
|
|
40
|
+
const qualityEngine = await import("./quality-engine.js");
|
|
41
|
+
const task = await api.getTask(id);
|
|
42
|
+
if (!task.pullRequest?.number) {
|
|
43
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: "no_pr", task: task.identifier }) }] };
|
|
44
|
+
}
|
|
45
|
+
const q = await qualityEngine.check(task);
|
|
46
|
+
return { content: [{ type: "text", text: JSON.stringify({ task: task.identifier, quality: q }) }] };
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
server.tool("capy_approve", "Approve a task if quality gates pass. Runs the configured approveCommand hook on success.", {
|
|
50
|
+
id: z.string().describe("Task ID"),
|
|
51
|
+
force: z.boolean().optional().describe("Override failing gates"),
|
|
52
|
+
}, async ({ id, force }) => {
|
|
53
|
+
const qualityEngine = await import("./quality-engine.js");
|
|
54
|
+
const task = await api.getTask(id);
|
|
55
|
+
const cfg = config.load();
|
|
56
|
+
const q = await qualityEngine.check(task);
|
|
57
|
+
const approved = q.pass || !!force;
|
|
58
|
+
|
|
59
|
+
if (approved && cfg.approveCommand) {
|
|
60
|
+
try {
|
|
61
|
+
const { execFileSync } = await import("node:child_process");
|
|
62
|
+
const parts = cfg.approveCommand
|
|
63
|
+
.replace("{task}", task.identifier || task.id)
|
|
64
|
+
.replace("{title}", task.title || "")
|
|
65
|
+
.replace("{pr}", String(task.pullRequest?.number || ""))
|
|
66
|
+
.split(/\s+/);
|
|
67
|
+
execFileSync(parts[0], parts.slice(1), { encoding: "utf8", timeout: 15000, stdio: "pipe" });
|
|
68
|
+
} catch {}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return { content: [{ type: "text", text: JSON.stringify({ task: task.identifier, quality: q, approved }) }] };
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
server.tool("capy_retry", "Retry a failed task with context from previous attempt", {
|
|
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();
|
|
81
|
+
|
|
82
|
+
let context = `Previous attempt: ${task.identifier} "${task.title}" [${task.status}]\n`;
|
|
83
|
+
try {
|
|
84
|
+
const d = await api.getDiff(id);
|
|
85
|
+
if (d.stats?.files && d.stats.files > 0) {
|
|
86
|
+
context += `\nPrevious diff: +${d.stats.additions} -${d.stats.deletions} in ${d.stats.files} files\n`;
|
|
87
|
+
}
|
|
88
|
+
} catch {}
|
|
89
|
+
|
|
90
|
+
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`;
|
|
91
|
+
if (fix) retryPrompt += `--- FIX ---\n${fix}\n\n`;
|
|
92
|
+
retryPrompt += `Fix the issues. Include tests. Run tests before completing.\n`;
|
|
93
|
+
|
|
94
|
+
if (task.status === "in_progress") {
|
|
95
|
+
await api.stopTask(id, "Retrying with fixes");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const data = await api.createThread(retryPrompt, model || cfg.defaultModel);
|
|
99
|
+
return { content: [{ type: "text", text: JSON.stringify({ originalTask: task.identifier, newThread: data.id, model: model || cfg.defaultModel }) }] };
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
server.tool("capy_wait", "Poll until a task or thread reaches terminal state", {
|
|
103
|
+
id: z.string().describe("Task or thread ID"),
|
|
104
|
+
timeout: z.number().optional().describe("Timeout in seconds (default 300)"),
|
|
105
|
+
interval: z.number().optional().describe("Poll interval in seconds (default 10)"),
|
|
106
|
+
}, async ({ id, timeout, interval }) => {
|
|
107
|
+
const timeoutMs = (timeout || 300) * 1000;
|
|
108
|
+
const intervalMs = Math.max(5, Math.min(interval || 10, 60)) * 1000;
|
|
109
|
+
const isThread = id.length > 20 || (id.length > 10 && !id.match(/^[A-Z]+-\d+$/));
|
|
110
|
+
const terminalTask = new Set(["needs_review", "archived", "completed", "failed"]);
|
|
111
|
+
const terminalThread = new Set(["idle", "archived", "completed"]);
|
|
112
|
+
const terminal = isThread ? terminalThread : terminalTask;
|
|
113
|
+
|
|
114
|
+
const start = Date.now();
|
|
115
|
+
while (Date.now() - start < timeoutMs) {
|
|
116
|
+
const data = isThread ? await api.getThread(id) : await api.getTask(id);
|
|
117
|
+
if (terminal.has(data.status)) {
|
|
118
|
+
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
119
|
+
}
|
|
120
|
+
await new Promise(r => setTimeout(r, intervalMs));
|
|
121
|
+
}
|
|
122
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: { code: "timeout", message: `Timed out after ${timeout || 300}s` } }) }] };
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const transport = new StdioServerTransport();
|
|
126
|
+
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/types.ts
CHANGED
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; }
|