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 CHANGED
@@ -1,13 +1,13 @@
1
- # capy-cli
1
+ # capyai
2
2
 
3
- Agent orchestrator with quality gates for [Capy.ai](https://capy.ai). Zero dependencies.
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 capy-cli
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 which gates run via `capy config quality.reviewProvider greptile|capy|both|none`.
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
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bun
2
+ import "../src/mcp.js";
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.0",
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/PlawIO/capy-cli"
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
- "citty": "^0.2.2"
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
- async function request(method: string, path: string, body?: unknown): Promise<any> {
5
- const cfg = config.load();
6
- if (!cfg.apiKey) {
7
- console.error("capy: API key not configured. Run: capy init");
8
- process.exit(1);
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
- const url = `${cfg.server}${path}`;
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 ${cfg.apiKey}`,
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
- console.error(`capy: request failed — ${(e as Error).message}`);
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
- console.error(`capy: API error — ${data.error.message || data.error.code}`);
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
- console.error("capy: bad API response:", text.slice(0, 200));
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: {
@@ -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
- if (args.json) { fmt.out({ task: task.identifier, quality: q, approved: q.pass || args.force }); return; }
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 (!q.pass && !args.force) {
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 { execSync } = await import("node:child_process");
201
- const expanded = approveCmd
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
- execSync(expanded, { encoding: "utf8", timeout: 15000, stdio: "pipe" });
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.`);
@@ -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
- const result = await p.group({
15
- apiKey: () => p.password({
16
- message: "Capy API key",
17
- mask: "*",
18
- validate: (v) => { if (!v) return "API key is required"; },
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 (p.isCancel(result)) {
47
- p.cancel("Setup cancelled.");
48
- process.exit(0);
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
- cfg.apiKey = result.apiKey;
52
- cfg.projectId = result.projectId;
53
- cfg.repos = (result.repos || "").split(",").filter(Boolean).map(s => {
54
- const [repo, branch] = s.trim().split(":");
55
- return { repoFullName: repo, branch: branch || "main" };
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
- cfg.defaultModel = result.defaultModel;
58
- cfg.quality.reviewProvider = result.reviewProvider as string;
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: any, tasks: any;
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...");
@@ -51,7 +51,9 @@ const msg = defineCommand({
51
51
  },
52
52
  async run({ args }) {
53
53
  const api = await import("../api.js");
54
- await api.messageThread(args.id, args.text);
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 api.stopThread(args.id);
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:"${repo.split("/")[0]}", name:"${repo.split("/")[1]}") { pullRequest(number:${number}) { reviewThreads(first:100) { nodes { isResolved isOutdated comments(first:1) { nodes { body author { login } } } } } } } }`;
102
- const out = execFileSync("gh", ["api", "graphql", "-f", `query=${query}`], {
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
- console.error("capy: GREPTILE_API_KEY not set. Run: capy config greptileApiKey <key>");
11
- process.exit(1);
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
- console.error(`greptile: request failed — ${(e as Error).message}`);
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
- console.error(`greptile: ${data.error.message || JSON.stringify(data.error)}`);
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
- console.error("greptile: bad response:", text.slice(0, 300));
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
- console.error(`greptile: review triggered (${reviewId}), waiting...`);
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
- console.log(JSON.stringify(rows, null, 2));
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
@@ -87,6 +87,7 @@ export interface DiffData {
87
87
 
88
88
  export interface Model {
89
89
  id: string;
90
+ name?: string;
90
91
  provider?: string;
91
92
  captainEligible?: boolean;
92
93
  }
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(`echo ${JSON.stringify(content)} | crontab -`, { encoding: "utf8" });
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
- execSync(cmd.replace("{text}", JSON.stringify(text)), {
57
- encoding: "utf8", timeout: 15000,
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; }