capyai 0.3.3 → 0.3.5

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