capyai 0.2.0
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 +79 -0
- package/bin/capy.js +61 -0
- package/bin/capy.ts +62 -0
- package/dist/capy.js +1619 -0
- package/package.json +36 -0
- package/src/api.ts +125 -0
- package/src/cli.ts +722 -0
- package/src/config.ts +93 -0
- package/src/format.ts +45 -0
- package/src/github.ts +112 -0
- package/src/greptile.ts +151 -0
- package/src/quality.ts +131 -0
- package/src/types.ts +181 -0
- package/src/watch.ts +61 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
import * as api from "./api.js";
|
|
2
|
+
import * as config from "./config.js";
|
|
3
|
+
import * as github from "./github.js";
|
|
4
|
+
import * as quality from "./quality.js";
|
|
5
|
+
import * as watch from "./watch.js";
|
|
6
|
+
import * as fmt from "./format.js";
|
|
7
|
+
import * as greptileApi from "./greptile.js";
|
|
8
|
+
import type { CapyConfig } from "./types.js";
|
|
9
|
+
|
|
10
|
+
function parseModel(argv: string[]): string | null {
|
|
11
|
+
const f = argv.find(a => a.startsWith("--model="));
|
|
12
|
+
if (f) return f.split("=")[1];
|
|
13
|
+
if (argv.includes("--opus")) return "claude-opus-4-6";
|
|
14
|
+
if (argv.includes("--sonnet")) return "claude-sonnet-4-6";
|
|
15
|
+
if (argv.includes("--mini")) return "gpt-5.4-mini";
|
|
16
|
+
if (argv.includes("--fast")) return "gpt-5.4-fast";
|
|
17
|
+
if (argv.includes("--kimi")) return "kimi-k2.5";
|
|
18
|
+
if (argv.includes("--glm")) return "glm-5";
|
|
19
|
+
if (argv.includes("--gemini")) return "gemini-3.1-pro";
|
|
20
|
+
if (argv.includes("--grok")) return "grok-4.1-fast";
|
|
21
|
+
if (argv.includes("--qwen")) return "qwen-3-coder";
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function strip(argv: string[]): string[] { return argv.filter(a => !a.startsWith("--")); }
|
|
26
|
+
function getMode(argv: string[]): string {
|
|
27
|
+
const f = argv.find(a => a.startsWith("--mode="));
|
|
28
|
+
return f ? f.split("=")[1] : "run";
|
|
29
|
+
}
|
|
30
|
+
function getInterval(argv: string[]): number {
|
|
31
|
+
const f = argv.find(a => a.startsWith("--interval="));
|
|
32
|
+
return f ? Math.max(1, Math.min(parseInt(f.split("=")[1]), 30)) : config.load().watchInterval;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const commands: Record<string, (argv: string[]) => Promise<void> | void> = {};
|
|
36
|
+
|
|
37
|
+
// --- init ---
|
|
38
|
+
commands.init = async function(argv: string[]) {
|
|
39
|
+
const cfg = config.load();
|
|
40
|
+
const readline = await import("node:readline");
|
|
41
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
42
|
+
const ask = (q: string, def: string): Promise<string> =>
|
|
43
|
+
new Promise(r => rl.question(`${q} [${def}]: `, (a: string) => r(a.trim() || def)));
|
|
44
|
+
|
|
45
|
+
cfg.apiKey = await ask("Capy API key", cfg.apiKey || "capy_...");
|
|
46
|
+
cfg.projectId = await ask("Project ID", cfg.projectId || "");
|
|
47
|
+
const repoStr = await ask("Repos (owner/repo:branch, comma-sep)",
|
|
48
|
+
cfg.repos.map(r => `${r.repoFullName}:${r.branch}`).join(",") || "owner/repo:main");
|
|
49
|
+
cfg.repos = repoStr.split(",").map(s => {
|
|
50
|
+
const [repo, branch] = s.trim().split(":");
|
|
51
|
+
return { repoFullName: repo, branch: branch || "main" };
|
|
52
|
+
});
|
|
53
|
+
cfg.defaultModel = await ask("Default model", cfg.defaultModel);
|
|
54
|
+
cfg.quality.minReviewScore = parseInt(await ask("Min review score (1-5)", String(cfg.quality.minReviewScore)));
|
|
55
|
+
rl.close();
|
|
56
|
+
config.save(cfg);
|
|
57
|
+
console.log(`\nConfig saved to ${config.CONFIG_PATH}`);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// --- config ---
|
|
61
|
+
commands.config = function(argv: string[]) {
|
|
62
|
+
const args = strip(argv);
|
|
63
|
+
if (args.length === 0) {
|
|
64
|
+
fmt.out(config.load());
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (args.length === 1) {
|
|
68
|
+
const val = config.get(args[0]);
|
|
69
|
+
if (val === undefined) {
|
|
70
|
+
console.error(`capy: unknown config key "${args[0]}"`);
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
if (fmt.IS_JSON || typeof val === "object") {
|
|
74
|
+
fmt.out(fmt.IS_JSON ? { [args[0]]: val } : val);
|
|
75
|
+
} else {
|
|
76
|
+
console.log(String(val));
|
|
77
|
+
}
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
config.set(args[0], args.slice(1).join(" "));
|
|
81
|
+
console.log(`Set ${args[0]} = ${config.get(args[0])}`);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// --- captain ---
|
|
85
|
+
commands.captain = commands.plan = async function(argv: string[]) {
|
|
86
|
+
const prompt = strip(argv).join(" ");
|
|
87
|
+
if (!prompt) { console.error("Usage: capy captain <prompt>"); process.exit(1); }
|
|
88
|
+
const model = parseModel(argv) || config.load().defaultModel;
|
|
89
|
+
const data = await api.createThread(prompt, model);
|
|
90
|
+
if (fmt.IS_JSON) { fmt.out(data); return; }
|
|
91
|
+
console.log(`Captain started: https://app.capy.ai/threads/${data.id}`);
|
|
92
|
+
console.log(`Thread: ${data.id} Model: ${model}`);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// --- threads ---
|
|
96
|
+
commands.threads = async function(argv: string[]) {
|
|
97
|
+
const sub = strip(argv)[0] || "list";
|
|
98
|
+
if (sub === "list") {
|
|
99
|
+
const data = await api.listThreads();
|
|
100
|
+
if (fmt.IS_JSON) { fmt.out(data.items || []); return; }
|
|
101
|
+
if (!data.items?.length) { console.log("No threads."); return; }
|
|
102
|
+
fmt.table(["ID", "STATUS", "TITLE"], data.items.map(t => [
|
|
103
|
+
t.id.slice(0, 16), t.status, (t.title || "(untitled)").slice(0, 40),
|
|
104
|
+
]));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (sub === "get") {
|
|
108
|
+
const id = strip(argv)[1];
|
|
109
|
+
if (!id) { console.error("Usage: capy threads get <id>"); process.exit(1); }
|
|
110
|
+
const data = await api.getThread(id);
|
|
111
|
+
if (fmt.IS_JSON) { fmt.out(data); return; }
|
|
112
|
+
console.log(`Thread: ${data.id}`);
|
|
113
|
+
console.log(`Title: ${data.title || "(untitled)"}`);
|
|
114
|
+
console.log(`Status: ${data.status}`);
|
|
115
|
+
if (data.tasks?.length) {
|
|
116
|
+
console.log(`\nTasks (${data.tasks.length}):`);
|
|
117
|
+
data.tasks.forEach(t => console.log(` ${t.identifier} ${t.title} [${t.status}]`));
|
|
118
|
+
}
|
|
119
|
+
if (data.pullRequests?.length) {
|
|
120
|
+
console.log(`\nPRs:`);
|
|
121
|
+
data.pullRequests.forEach(p => console.log(` PR#${p.number} ${p.url} [${p.state}]`));
|
|
122
|
+
}
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (sub === "msg" || sub === "message") {
|
|
126
|
+
const id = strip(argv)[1], msg = strip(argv).slice(2).join(" ");
|
|
127
|
+
if (!id || !msg) { console.error("Usage: capy threads msg <id> <text>"); process.exit(1); }
|
|
128
|
+
await api.messageThread(id, msg);
|
|
129
|
+
console.log("Message sent.");
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (sub === "stop") {
|
|
133
|
+
const id = strip(argv)[1];
|
|
134
|
+
if (!id) { console.error("Usage: capy threads stop <id>"); process.exit(1); }
|
|
135
|
+
await api.stopThread(id);
|
|
136
|
+
console.log(`Stopped thread ${id}.`);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (sub === "messages" || sub === "msgs") {
|
|
140
|
+
const id = strip(argv)[1];
|
|
141
|
+
if (!id) { console.error("Usage: capy threads messages <id>"); process.exit(1); }
|
|
142
|
+
const data = await api.getThreadMessages(id);
|
|
143
|
+
if (fmt.IS_JSON) { fmt.out(data.items || []); return; }
|
|
144
|
+
(data.items || []).forEach(m => {
|
|
145
|
+
console.log(`[${m.source}] ${m.content.slice(0, 200)}`);
|
|
146
|
+
console.log();
|
|
147
|
+
});
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
console.error("Usage: capy threads [list|get|msg|stop|messages]");
|
|
151
|
+
process.exit(1);
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// --- build ---
|
|
155
|
+
commands.build = commands.run = async function(argv: string[]) {
|
|
156
|
+
const prompt = strip(argv).join(" ");
|
|
157
|
+
if (!prompt) { console.error("Usage: capy build <prompt>"); process.exit(1); }
|
|
158
|
+
const model = parseModel(argv) || config.load().defaultModel;
|
|
159
|
+
const data = await api.createTask(prompt, model);
|
|
160
|
+
if (fmt.IS_JSON) { fmt.out(data); return; }
|
|
161
|
+
console.log(`Build started: https://app.capy.ai/tasks/${data.id}`);
|
|
162
|
+
console.log(`ID: ${data.identifier} Model: ${model}`);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// --- list ---
|
|
166
|
+
commands.list = commands.ls = async function(argv: string[]) {
|
|
167
|
+
const status = strip(argv)[0];
|
|
168
|
+
const data = await api.listTasks({ status });
|
|
169
|
+
if (fmt.IS_JSON) { fmt.out(data.items || []); return; }
|
|
170
|
+
if (!data.items?.length) { console.log("No tasks."); return; }
|
|
171
|
+
fmt.table(["ID", "STATUS", "TITLE", "PR"], data.items.map(t => [
|
|
172
|
+
t.identifier,
|
|
173
|
+
t.status,
|
|
174
|
+
(t.title || "").slice(0, 45),
|
|
175
|
+
t.pullRequest ? `PR#${t.pullRequest.number} [${t.pullRequest.state}]` : "\u2014",
|
|
176
|
+
]));
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// --- get ---
|
|
180
|
+
commands.get = commands.show = async function(argv: string[]) {
|
|
181
|
+
const id = strip(argv)[0];
|
|
182
|
+
if (!id) { console.error("Usage: capy get <id>"); process.exit(1); }
|
|
183
|
+
const data = await api.getTask(id);
|
|
184
|
+
if (fmt.IS_JSON) { fmt.out(data); return; }
|
|
185
|
+
console.log(`Task: ${data.identifier} \u2014 ${data.title}`);
|
|
186
|
+
console.log(`Status: ${data.status}`);
|
|
187
|
+
console.log(`Created: ${data.createdAt}`);
|
|
188
|
+
if (data.pullRequest) {
|
|
189
|
+
console.log(`PR: ${data.pullRequest.url || `#${data.pullRequest.number}`} [${data.pullRequest.state}]`);
|
|
190
|
+
}
|
|
191
|
+
if (data.jams?.length) {
|
|
192
|
+
console.log(`\nJams (${data.jams.length}):`);
|
|
193
|
+
data.jams.forEach((j, i) => {
|
|
194
|
+
console.log(` ${i+1}. model=${j.model || "?"} status=${j.status || "?"} credits=${fmt.credits(j.credits)}`);
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
// --- start/stop/msg ---
|
|
200
|
+
commands.start = async function(argv: string[]) {
|
|
201
|
+
const id = strip(argv)[0];
|
|
202
|
+
if (!id) { console.error("Usage: capy start <id>"); process.exit(1); }
|
|
203
|
+
const model = parseModel(argv) || config.load().defaultModel;
|
|
204
|
+
const data = await api.startTask(id, model);
|
|
205
|
+
if (fmt.IS_JSON) { fmt.out(data); return; }
|
|
206
|
+
console.log(`Started ${data.identifier || id} \u2192 ${data.status}`);
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
commands.stop = commands.kill = async function(argv: string[]) {
|
|
210
|
+
const id = strip(argv)[0], reason = strip(argv).slice(1).join(" ");
|
|
211
|
+
if (!id) { console.error("Usage: capy stop <id>"); process.exit(1); }
|
|
212
|
+
const data = await api.stopTask(id, reason);
|
|
213
|
+
if (fmt.IS_JSON) { fmt.out(data); return; }
|
|
214
|
+
console.log(`Stopped ${data.identifier || id} \u2192 ${data.status}`);
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
commands.msg = commands.message = async function(argv: string[]) {
|
|
218
|
+
const id = strip(argv)[0], msg = strip(argv).slice(1).join(" ");
|
|
219
|
+
if (!id || !msg) { console.error("Usage: capy msg <id> <text>"); process.exit(1); }
|
|
220
|
+
await api.messageTask(id, msg);
|
|
221
|
+
if (fmt.IS_JSON) { fmt.out({ id, message: msg, status: "sent" }); return; }
|
|
222
|
+
console.log("Message sent.");
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
// --- diff ---
|
|
226
|
+
commands.diff = async function(argv: string[]) {
|
|
227
|
+
const id = strip(argv)[0];
|
|
228
|
+
if (!id) { console.error("Usage: capy diff <id>"); process.exit(1); }
|
|
229
|
+
const data = await api.getDiff(id, getMode(argv));
|
|
230
|
+
if (fmt.IS_JSON) { fmt.out(data); return; }
|
|
231
|
+
console.log(`Diff (${data.source || "unknown"}): +${data.stats?.additions || 0} -${data.stats?.deletions || 0} in ${data.stats?.files || 0} files\n`);
|
|
232
|
+
if (data.files) {
|
|
233
|
+
data.files.forEach(f => {
|
|
234
|
+
console.log(`--- ${f.path} (${f.state}) +${f.additions} -${f.deletions}`);
|
|
235
|
+
if (f.patch) console.log(f.patch);
|
|
236
|
+
console.log();
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
// --- pr ---
|
|
242
|
+
commands.pr = async function(argv: string[]) {
|
|
243
|
+
const id = strip(argv)[0], title = strip(argv).slice(1).join(" ");
|
|
244
|
+
if (!id) { console.error("Usage: capy pr <id> [title]"); process.exit(1); }
|
|
245
|
+
const body = title ? { title } : {};
|
|
246
|
+
const data = await api.createPR(id, body);
|
|
247
|
+
if (fmt.IS_JSON) { fmt.out(data); return; }
|
|
248
|
+
console.log(`PR: ${data.url}`);
|
|
249
|
+
console.log(`#${data.number} ${data.title} (${data.headRef} \u2192 ${data.baseRef})`);
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
// --- models ---
|
|
253
|
+
commands.models = async function() {
|
|
254
|
+
const data = await api.listModels();
|
|
255
|
+
if (fmt.IS_JSON) { fmt.out(data.models || []); return; }
|
|
256
|
+
if (data.models) {
|
|
257
|
+
fmt.table(["MODEL", "PROVIDER", "CAPTAIN"], data.models.map(m => [
|
|
258
|
+
m.id, m.provider || "?", m.captainEligible ? "yes" : "no",
|
|
259
|
+
]));
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
// --- tools ---
|
|
264
|
+
commands.tools = commands.commands = function(argv: string[]) {
|
|
265
|
+
const all: Record<string, { args: string; desc: string }> = {
|
|
266
|
+
captain: { args: "<prompt>", desc: "Start Captain thread" },
|
|
267
|
+
build: { args: "<prompt>", desc: "Start Build agent (isolated)" },
|
|
268
|
+
threads: { args: "[list|get|msg|stop]", desc: "Manage threads" },
|
|
269
|
+
status: { args: "", desc: "Dashboard" },
|
|
270
|
+
list: { args: "[status]", desc: "List tasks" },
|
|
271
|
+
get: { args: "<id>", desc: "Task details" },
|
|
272
|
+
start: { args: "<id>", desc: "Start task" },
|
|
273
|
+
stop: { args: "<id> [reason]", desc: "Stop task" },
|
|
274
|
+
msg: { args: "<id> <text>", desc: "Message task" },
|
|
275
|
+
diff: { args: "<id>", desc: "View diff" },
|
|
276
|
+
pr: { args: "<id> [title]", desc: "Create PR" },
|
|
277
|
+
review: { args: "<id>", desc: "Quality gates check" },
|
|
278
|
+
"re-review":{ args: "<id>", desc: "Trigger Greptile re-review" },
|
|
279
|
+
approve: { args: "<id>", desc: "Approve if gates pass" },
|
|
280
|
+
retry: { args: "<id> [--fix=...]", desc: "Retry with failure context" },
|
|
281
|
+
watch: { args: "<id>", desc: "Poll + notify on completion" },
|
|
282
|
+
unwatch: { args: "<id>", desc: "Stop watching" },
|
|
283
|
+
watches: { args: "", desc: "List watches" },
|
|
284
|
+
models: { args: "", desc: "List models" },
|
|
285
|
+
tools: { args: "", desc: "This list" },
|
|
286
|
+
config: { args: "[key] [value]", desc: "Get/set config" },
|
|
287
|
+
init: { args: "", desc: "Interactive setup" },
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
if (fmt.IS_JSON) { fmt.out(all); return; }
|
|
291
|
+
|
|
292
|
+
const cfg = config.load();
|
|
293
|
+
console.log("Available commands:\n");
|
|
294
|
+
for (const [name, t] of Object.entries(all)) {
|
|
295
|
+
console.log(` ${fmt.pad(name, 14)} ${fmt.pad(t.args, 24)} ${t.desc}`);
|
|
296
|
+
}
|
|
297
|
+
console.log(`\nConfig: ${config.CONFIG_PATH}`);
|
|
298
|
+
console.log(`Review provider: ${cfg.quality?.reviewProvider || "greptile"}`);
|
|
299
|
+
console.log(`Default model: ${cfg.defaultModel}`);
|
|
300
|
+
console.log(`Repos: ${(cfg.repos || []).map(r => r.repoFullName).join(", ") || "none"}`);
|
|
301
|
+
|
|
302
|
+
const envVars: [string, string][] = [
|
|
303
|
+
["CAPY_API_KEY", "API key (overrides config)"],
|
|
304
|
+
["CAPY_PROJECT_ID", "Project ID (overrides config)"],
|
|
305
|
+
["CAPY_SERVER", "API server URL"],
|
|
306
|
+
["CAPY_ENV_FILE", "Path to .env file"],
|
|
307
|
+
["GREPTILE_API_KEY", "Greptile API key"],
|
|
308
|
+
];
|
|
309
|
+
console.log("\nEnvironment variables:");
|
|
310
|
+
envVars.forEach(([k, v]) => console.log(` ${fmt.pad(k, 20)} ${v}`));
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
// --- status ---
|
|
314
|
+
commands.status = commands.dashboard = async function(argv: string[]) {
|
|
315
|
+
const cfg = config.load();
|
|
316
|
+
const threads = await api.listThreads({ limit: 10 });
|
|
317
|
+
const tasks = await api.listTasks({ limit: 30 });
|
|
318
|
+
|
|
319
|
+
if (fmt.IS_JSON) {
|
|
320
|
+
fmt.out({
|
|
321
|
+
threads: threads.items || [],
|
|
322
|
+
tasks: tasks.items || [],
|
|
323
|
+
watches: watch.list(),
|
|
324
|
+
});
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const active = (threads.items || []).filter(t => t.status === "active");
|
|
329
|
+
if (active.length) {
|
|
330
|
+
fmt.section("ACTIVE THREADS");
|
|
331
|
+
active.forEach(t => console.log(` ${t.id.slice(0, 14)} ${(t.title || "(untitled)").slice(0, 50)} [active]`));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const allTasks = tasks.items || [];
|
|
335
|
+
const buckets: Record<string, typeof allTasks> = {};
|
|
336
|
+
allTasks.forEach(t => { (buckets[t.status] = buckets[t.status] || []).push(t); });
|
|
337
|
+
|
|
338
|
+
if (buckets.in_progress?.length) {
|
|
339
|
+
fmt.section("IN PROGRESS");
|
|
340
|
+
buckets.in_progress.forEach(t => {
|
|
341
|
+
const j = (t.jams || []).at(-1);
|
|
342
|
+
const stuck = j && j.status === "idle" && (!j.credits || (typeof j.credits === "object" && j.credits.llm === 0 && j.credits.vm === 0));
|
|
343
|
+
console.log(` ${fmt.pad(t.identifier, 10)} ${fmt.pad((t.title || "").slice(0, 48), 50)}${stuck ? " !! STUCK" : ""}`);
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (buckets.needs_review?.length) {
|
|
348
|
+
fmt.section("NEEDS REVIEW");
|
|
349
|
+
buckets.needs_review.forEach(t => {
|
|
350
|
+
let prInfo = "no PR";
|
|
351
|
+
if (t.pullRequest?.number) {
|
|
352
|
+
const repo = t.pullRequest.repoFullName || cfg.repos[0]?.repoFullName || "";
|
|
353
|
+
const pr = github.getPR(repo, t.pullRequest.number);
|
|
354
|
+
const state = pr ? pr.state : t.pullRequest.state || "?";
|
|
355
|
+
const ci = github.getCIStatus(repo, t.pullRequest.number, pr);
|
|
356
|
+
const ciStr = ci ? (ci.allGreen ? "CI pass" : ci.noChecks ? "no CI" : "CI FAIL") : "?";
|
|
357
|
+
prInfo = `PR#${t.pullRequest.number} [${state}] ${ciStr}`;
|
|
358
|
+
}
|
|
359
|
+
console.log(` ${fmt.pad(t.identifier, 10)} ${fmt.pad((t.title || "").slice(0, 42), 44)} ${prInfo}`);
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (buckets.backlog?.length) {
|
|
364
|
+
fmt.section(`BACKLOG (${buckets.backlog.length})`);
|
|
365
|
+
buckets.backlog.forEach(t => console.log(` ${fmt.pad(t.identifier, 10)} ${(t.title || "").slice(0, 60)}`));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const watches = watch.list();
|
|
369
|
+
if (watches.length) {
|
|
370
|
+
fmt.section(`ACTIVE WATCHES (${watches.length})`);
|
|
371
|
+
watches.forEach(w => console.log(` ${fmt.pad(w.id.slice(0, 18), 20)} type=${w.type} every ${w.intervalMin}min`));
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const stuckCount = (buckets.in_progress || []).filter(t => {
|
|
375
|
+
const j = (t.jams || []).at(-1);
|
|
376
|
+
return j && j.status === "idle" && (!j.credits || (typeof j.credits === "object" && j.credits.llm === 0 && j.credits.vm === 0));
|
|
377
|
+
}).length;
|
|
378
|
+
console.log(`\nSummary: ${allTasks.length} tasks, ${(buckets.in_progress || []).length} active, ${(buckets.needs_review || []).length} review, ${stuckCount} stuck`);
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
// --- review ---
|
|
382
|
+
commands.review = async function(argv: string[]) {
|
|
383
|
+
const id = strip(argv)[0];
|
|
384
|
+
if (!id) { console.error("Usage: capy review <id>"); process.exit(1); }
|
|
385
|
+
|
|
386
|
+
const task = await api.getTask(id);
|
|
387
|
+
const cfg = config.load();
|
|
388
|
+
const reviewProvider = cfg.quality?.reviewProvider || "greptile";
|
|
389
|
+
|
|
390
|
+
if (!task.pullRequest?.number) {
|
|
391
|
+
if (fmt.IS_JSON) { fmt.out({ error: "no_pr", task: task.identifier }); return; }
|
|
392
|
+
console.log(`${task.identifier}: No PR. Create one first: capy pr ${task.identifier}`);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const repo = task.pullRequest.repoFullName || cfg.repos[0]?.repoFullName || "";
|
|
397
|
+
const prNum = task.pullRequest.number;
|
|
398
|
+
const defaultBranch = cfg.repos.find(r => r.repoFullName === repo)?.branch || "main";
|
|
399
|
+
|
|
400
|
+
let diffStats = null;
|
|
401
|
+
try {
|
|
402
|
+
const diff = await api.getDiff(id);
|
|
403
|
+
diffStats = diff.stats || null;
|
|
404
|
+
} catch {}
|
|
405
|
+
|
|
406
|
+
const q = await quality.check(task);
|
|
407
|
+
|
|
408
|
+
let unaddressed: Awaited<ReturnType<typeof greptileApi.getUnaddressedIssues>> = [];
|
|
409
|
+
const hasGreptileKey = !!(cfg.greptileApiKey || process.env.GREPTILE_API_KEY);
|
|
410
|
+
|
|
411
|
+
if ((reviewProvider === "greptile" || reviewProvider === "both") && hasGreptileKey) {
|
|
412
|
+
unaddressed = await greptileApi.getUnaddressedIssues(repo, prNum, defaultBranch);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (fmt.IS_JSON) {
|
|
416
|
+
fmt.out({
|
|
417
|
+
task: task.identifier,
|
|
418
|
+
quality: q,
|
|
419
|
+
unaddressed,
|
|
420
|
+
reviewProvider,
|
|
421
|
+
diff: diffStats ? { files: diffStats.files || 0, additions: diffStats.additions || 0, deletions: diffStats.deletions || 0 } : null,
|
|
422
|
+
});
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const prOpen = q.gates.find(g => g.name === "pr_open");
|
|
427
|
+
console.log(`Review: ${task.identifier} — ${task.title}`);
|
|
428
|
+
console.log(`PR: #${prNum} [${prOpen?.detail || task.pullRequest?.state || "?"}]`);
|
|
429
|
+
if (diffStats) console.log(`Diff: +${diffStats.additions || 0} -${diffStats.deletions || 0} in ${diffStats.files || 0} files`);
|
|
430
|
+
console.log(`Review: ${reviewProvider}`);
|
|
431
|
+
console.log();
|
|
432
|
+
|
|
433
|
+
q.gates.forEach(g => {
|
|
434
|
+
const icon = g.pass ? "\u2713" : "\u2717";
|
|
435
|
+
console.log(` ${icon} ${g.name}: ${g.detail}`);
|
|
436
|
+
if (g.name === "ci" && g.failing?.length) {
|
|
437
|
+
g.failing.forEach(f => console.log(` \u2717 ${f.name} (${f.conclusion || f.status})`));
|
|
438
|
+
}
|
|
439
|
+
if (g.name === "ci" && g.pending?.length) {
|
|
440
|
+
g.pending.forEach(f => console.log(` ... ${f.name} (${f.status})`));
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
if (unaddressed.length > 0) {
|
|
445
|
+
console.log(`\nUnaddressed Greptile issues (${unaddressed.length}):`);
|
|
446
|
+
unaddressed.forEach(u => {
|
|
447
|
+
console.log(` ${u.file}:${u.line} ${u.body}`);
|
|
448
|
+
if (u.hasSuggestion) console.log(` ^ has suggested fix`);
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
console.log(`\n${q.summary}`);
|
|
453
|
+
|
|
454
|
+
const greptileGate = q.gates.find(g => g.name === "greptile");
|
|
455
|
+
if (greptileGate && !greptileGate.pass) {
|
|
456
|
+
if (greptileGate.detail.includes("processing")) {
|
|
457
|
+
console.log(`\nGreptile is still processing. Wait a minute, then: capy review ${task.identifier}`);
|
|
458
|
+
} else {
|
|
459
|
+
console.log(`\nFix the unaddressed issues, push, and Greptile will auto-re-review.`);
|
|
460
|
+
console.log(`Then: capy review ${task.identifier}`);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
// --- re-review ---
|
|
466
|
+
commands["re-review"] = commands.rereview = async function(argv: string[]) {
|
|
467
|
+
const id = strip(argv)[0];
|
|
468
|
+
if (!id) { console.error("Usage: capy re-review <id>"); process.exit(1); }
|
|
469
|
+
|
|
470
|
+
const cfg = config.load();
|
|
471
|
+
const reviewProvider = cfg.quality?.reviewProvider || "greptile";
|
|
472
|
+
|
|
473
|
+
if (reviewProvider !== "greptile" && reviewProvider !== "both") {
|
|
474
|
+
console.error(`capy: re-review requires Greptile provider (current: ${reviewProvider})`);
|
|
475
|
+
process.exit(1);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (!cfg.greptileApiKey && !process.env.GREPTILE_API_KEY) {
|
|
479
|
+
console.error("capy: GREPTILE_API_KEY not set. Run: capy config greptileApiKey <key>");
|
|
480
|
+
process.exit(1);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const task = await api.getTask(id);
|
|
484
|
+
if (!task.pullRequest?.number) {
|
|
485
|
+
console.error(`${task.identifier}: No PR. Create one first: capy pr ${task.identifier}`);
|
|
486
|
+
process.exit(1);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const repo = task.pullRequest.repoFullName || cfg.repos[0]?.repoFullName || "";
|
|
490
|
+
const prNum = task.pullRequest.number;
|
|
491
|
+
const defaultBranch = cfg.repos.find(r => r.repoFullName === repo)?.branch || "main";
|
|
492
|
+
|
|
493
|
+
console.log(`Triggering fresh Greptile review for PR#${prNum}...`);
|
|
494
|
+
console.log(`(Note: Greptile auto-reviews on every push via triggerOnUpdates. This is a manual override.)`);
|
|
495
|
+
const result = await greptileApi.freshReview(repo, prNum, defaultBranch);
|
|
496
|
+
|
|
497
|
+
if (fmt.IS_JSON) { fmt.out(result); return; }
|
|
498
|
+
|
|
499
|
+
if (result) {
|
|
500
|
+
if (result.status === "COMPLETED") {
|
|
501
|
+
console.log("Review completed.");
|
|
502
|
+
} else if (result.status === "FAILED") {
|
|
503
|
+
console.log("Review failed. Check the PR state.");
|
|
504
|
+
} else {
|
|
505
|
+
console.log(`Review status: ${result.status || "unknown"}`);
|
|
506
|
+
}
|
|
507
|
+
} else {
|
|
508
|
+
console.log("Review triggered. Check back shortly or run: capy review " + task.identifier);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const unaddressed = await greptileApi.getUnaddressedIssues(repo, prNum, defaultBranch);
|
|
512
|
+
if (unaddressed.length > 0) {
|
|
513
|
+
console.log(`\nUnaddressed issues: ${unaddressed.length}`);
|
|
514
|
+
unaddressed.forEach(u => console.log(` ${u.file}:${u.line} ${u.body}`));
|
|
515
|
+
} else {
|
|
516
|
+
console.log("\nAll issues addressed.");
|
|
517
|
+
}
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
// --- approve ---
|
|
521
|
+
commands.approve = async function(argv: string[]) {
|
|
522
|
+
const id = strip(argv)[0];
|
|
523
|
+
if (!id) { console.error("Usage: capy approve <id>"); process.exit(1); }
|
|
524
|
+
const force = argv.includes("--force");
|
|
525
|
+
|
|
526
|
+
const task = await api.getTask(id);
|
|
527
|
+
const cfg = config.load();
|
|
528
|
+
const q = await quality.check(task);
|
|
529
|
+
|
|
530
|
+
if (fmt.IS_JSON) { fmt.out({ task: task.identifier, quality: q, approved: q.pass || force }); return; }
|
|
531
|
+
|
|
532
|
+
console.log(`${task.identifier} — ${task.title}\n`);
|
|
533
|
+
q.gates.forEach(g => {
|
|
534
|
+
const icon = g.pass ? "\u2713" : "\u2717";
|
|
535
|
+
console.log(` ${icon} ${g.name}: ${g.detail}`);
|
|
536
|
+
});
|
|
537
|
+
console.log(`\n${q.summary}`);
|
|
538
|
+
|
|
539
|
+
if (!q.pass && !force) {
|
|
540
|
+
console.log(`\nBlocked. Fix the failing gates or use --force to override.`);
|
|
541
|
+
process.exit(1);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (q.pass || force) {
|
|
545
|
+
console.log(`\n\u2713 Approved.${force && !q.pass ? " (forced)" : ""}`);
|
|
546
|
+
const approveCmd = cfg.approveCommand;
|
|
547
|
+
if (approveCmd) {
|
|
548
|
+
try {
|
|
549
|
+
const { execSync } = await import("node:child_process");
|
|
550
|
+
const expanded = approveCmd
|
|
551
|
+
.replace("{task}", task.identifier || task.id)
|
|
552
|
+
.replace("{title}", task.title || "")
|
|
553
|
+
.replace("{pr}", String(task.pullRequest?.number || ""));
|
|
554
|
+
execSync(expanded, { encoding: "utf8", timeout: 15000, stdio: "pipe" });
|
|
555
|
+
console.log("Post-approve hook ran.");
|
|
556
|
+
} catch {}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
// --- retry ---
|
|
562
|
+
commands.retry = async function(argv: string[]) {
|
|
563
|
+
const id = strip(argv)[0];
|
|
564
|
+
if (!id) { console.error("Usage: capy retry <id> [--fix \"what to fix\"]"); process.exit(1); }
|
|
565
|
+
|
|
566
|
+
const fixFlag = argv.find(a => a.startsWith("--fix="));
|
|
567
|
+
const fixArg = fixFlag ? fixFlag.split("=").slice(1).join("=") : null;
|
|
568
|
+
|
|
569
|
+
const task = await api.getTask(id);
|
|
570
|
+
const cfg = config.load();
|
|
571
|
+
|
|
572
|
+
let context = `Previous attempt: ${task.identifier} "${task.title}" [${task.status}]\n`;
|
|
573
|
+
|
|
574
|
+
try {
|
|
575
|
+
const diff = await api.getDiff(id);
|
|
576
|
+
if (diff.stats?.files && diff.stats.files > 0) {
|
|
577
|
+
context += `\nPrevious diff: +${diff.stats.additions} -${diff.stats.deletions} in ${diff.stats.files} files\n`;
|
|
578
|
+
context += `Files changed: ${(diff.files || []).map(f => f.path).join(", ")}\n`;
|
|
579
|
+
} else {
|
|
580
|
+
context += `\nPrevious diff: empty (agent produced no changes)\n`;
|
|
581
|
+
}
|
|
582
|
+
} catch { context += "\nPrevious diff: unavailable\n"; }
|
|
583
|
+
|
|
584
|
+
if (task.pullRequest?.number) {
|
|
585
|
+
const repo = task.pullRequest.repoFullName || cfg.repos[0]?.repoFullName || "";
|
|
586
|
+
const prNum = task.pullRequest.number;
|
|
587
|
+
const defaultBranch = cfg.repos.find(r => r.repoFullName === repo)?.branch || "main";
|
|
588
|
+
const reviewComments = github.getPRReviewComments(repo, prNum);
|
|
589
|
+
const ci = github.getCIStatus(repo, prNum);
|
|
590
|
+
|
|
591
|
+
const reviewProvider = cfg.quality?.reviewProvider || "greptile";
|
|
592
|
+
const hasGreptileKey = !!(cfg.greptileApiKey || process.env.GREPTILE_API_KEY);
|
|
593
|
+
|
|
594
|
+
if (reviewProvider === "greptile" && hasGreptileKey) {
|
|
595
|
+
const unaddressed = await greptileApi.getUnaddressedIssues(repo, prNum, defaultBranch);
|
|
596
|
+
if (unaddressed.length > 0) {
|
|
597
|
+
context += `\nUnaddressed Greptile issues (${unaddressed.length}):\n`;
|
|
598
|
+
unaddressed.forEach(u => {
|
|
599
|
+
context += ` ${u.file}:${u.line}: ${u.body}\n`;
|
|
600
|
+
if (u.suggestedCode) context += ` Suggested fix: ${u.suggestedCode.slice(0, 200)}\n`;
|
|
601
|
+
});
|
|
602
|
+
} else {
|
|
603
|
+
context += `\nGreptile: all issues addressed\n`;
|
|
604
|
+
}
|
|
605
|
+
} else {
|
|
606
|
+
const issueComments = github.getPRIssueComments(repo, prNum);
|
|
607
|
+
const greptileReview = github.parseGreptileReview(issueComments);
|
|
608
|
+
if (greptileReview) {
|
|
609
|
+
context += `\nGreptile review: ${greptileReview.score}/5 (stale — may not reflect latest)\n`;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
if (ci && !ci.allGreen) {
|
|
614
|
+
context += `\nCI failures: ${ci.failing.map(f => f.name).join(", ")}\n`;
|
|
615
|
+
}
|
|
616
|
+
if (reviewComments.length) {
|
|
617
|
+
context += `\nReview comments (${reviewComments.length}):\n`;
|
|
618
|
+
reviewComments.slice(0, 5).forEach((c: any) => {
|
|
619
|
+
context += ` ${c.path}:${c.line || "?"}: ${(c.body || "").slice(0, 150)}\n`;
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const originalPrompt = task.prompt || task.title;
|
|
625
|
+
let retryPrompt = `RETRY: This is a retry of a previous attempt that had issues.\n\n`;
|
|
626
|
+
retryPrompt += `Original task: ${originalPrompt}\n\n`;
|
|
627
|
+
retryPrompt += `--- CONTEXT FROM PREVIOUS ATTEMPT ---\n${context}\n`;
|
|
628
|
+
|
|
629
|
+
if (fixArg) {
|
|
630
|
+
retryPrompt += `--- SPECIFIC FIX REQUESTED ---\n${fixArg}\n\n`;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
retryPrompt += `--- INSTRUCTIONS ---\n`;
|
|
634
|
+
retryPrompt += `Fix the issues from the previous attempt. Do not repeat the same mistakes.\n`;
|
|
635
|
+
retryPrompt += `Include tests. Run tests before completing. Verify CI will pass.\n`;
|
|
636
|
+
|
|
637
|
+
if (fmt.IS_JSON) {
|
|
638
|
+
fmt.out({ originalTask: task.identifier, retryPrompt, context });
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
if (task.status === "in_progress") {
|
|
643
|
+
await api.stopTask(id, "Retrying with fixes");
|
|
644
|
+
console.log(`Stopped ${task.identifier}.`);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const model = parseModel(argv) || cfg.defaultModel;
|
|
648
|
+
const data = await api.createThread(retryPrompt, model);
|
|
649
|
+
console.log(`Retry started: https://app.capy.ai/threads/${data.id}`);
|
|
650
|
+
console.log(`Thread: ${data.id} Model: ${model}`);
|
|
651
|
+
console.log(`\nContext included: ${context.split("\n").length} lines from previous attempt.`);
|
|
652
|
+
};
|
|
653
|
+
|
|
654
|
+
// --- watch/unwatch/watches ---
|
|
655
|
+
commands.watch = function(argv: string[]) {
|
|
656
|
+
const id = strip(argv)[0];
|
|
657
|
+
if (!id) { console.error("Usage: capy watch <id> [--interval=3]"); process.exit(1); }
|
|
658
|
+
const interval = getInterval(argv);
|
|
659
|
+
const type = (id.length > 20 || (id.length > 10 && !id.match(/^[A-Z]+-\d+$/))) ? "thread" : "task";
|
|
660
|
+
const added = watch.add(id, type, interval);
|
|
661
|
+
if (fmt.IS_JSON) { fmt.out({ id, type, interval, added }); return; }
|
|
662
|
+
if (added) {
|
|
663
|
+
console.log(`Watching ${id} (${type}) every ${interval}min. Will notify when done.`);
|
|
664
|
+
} else {
|
|
665
|
+
console.log(`Already watching ${id}.`);
|
|
666
|
+
}
|
|
667
|
+
};
|
|
668
|
+
|
|
669
|
+
commands.unwatch = function(argv: string[]) {
|
|
670
|
+
const id = strip(argv)[0];
|
|
671
|
+
if (!id) { console.error("Usage: capy unwatch <id>"); process.exit(1); }
|
|
672
|
+
watch.remove(id);
|
|
673
|
+
if (fmt.IS_JSON) { fmt.out({ id, status: "removed" }); return; }
|
|
674
|
+
console.log(`Stopped watching ${id}.`);
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
commands.watches = function() {
|
|
678
|
+
const w = watch.list();
|
|
679
|
+
if (fmt.IS_JSON) { fmt.out(w); return; }
|
|
680
|
+
if (!w.length) { console.log("No active watches."); return; }
|
|
681
|
+
w.forEach(e => console.log(`${fmt.pad(e.id.slice(0, 20), 22)} type=${e.type} every ${e.intervalMin}min since ${e.created}`));
|
|
682
|
+
};
|
|
683
|
+
|
|
684
|
+
// --- _poll (cron internal) ---
|
|
685
|
+
commands._poll = async function(argv: string[]) {
|
|
686
|
+
const id = argv[0], type = argv[1] || "task";
|
|
687
|
+
if (!id) process.exit(1);
|
|
688
|
+
|
|
689
|
+
if (type === "thread") {
|
|
690
|
+
const data = await api.getThread(id);
|
|
691
|
+
if (data.status === "idle" || data.status === "archived") {
|
|
692
|
+
const taskLines = (data.tasks || []).map(t => ` ${t.identifier}: ${t.title} [${t.status}]`).join("\n");
|
|
693
|
+
const prLines = (data.pullRequests || []).map(p => ` PR#${p.number}: ${p.url} [${p.state}]`).join("\n");
|
|
694
|
+
let msg = `[Capy] Captain thread finished.\nTitle: ${data.title || "(untitled)"}\nStatus: ${data.status}`;
|
|
695
|
+
if (taskLines) msg += `\n\nTasks:\n${taskLines}`;
|
|
696
|
+
if (prLines) msg += `\n\nPRs:\n${prLines}`;
|
|
697
|
+
msg += `\n\nRun: capy review <task-id> for each task, then capy approve <task-id> if quality passes.`;
|
|
698
|
+
watch.notify(msg);
|
|
699
|
+
watch.remove(id);
|
|
700
|
+
}
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
const data = await api.getTask(id);
|
|
705
|
+
if (data.status === "needs_review" || data.status === "archived") {
|
|
706
|
+
let msg = `[Capy] Task ${data.identifier} ready.\nTitle: ${data.title}\nStatus: ${data.status}`;
|
|
707
|
+
if (data.pullRequest) msg += `\nPR: ${data.pullRequest.url || "#" + data.pullRequest.number}`;
|
|
708
|
+
msg += `\n\nRun: capy review ${data.identifier}, then capy approve ${data.identifier} if quality passes.`;
|
|
709
|
+
watch.notify(msg);
|
|
710
|
+
watch.remove(id);
|
|
711
|
+
}
|
|
712
|
+
};
|
|
713
|
+
|
|
714
|
+
// --- run ---
|
|
715
|
+
export async function run(cmd: string, argv: string[]): Promise<void> {
|
|
716
|
+
const handler = commands[cmd];
|
|
717
|
+
if (!handler) {
|
|
718
|
+
console.error(`capy: unknown command "${cmd}". Run: capy help`);
|
|
719
|
+
process.exit(1);
|
|
720
|
+
}
|
|
721
|
+
await handler(argv);
|
|
722
|
+
}
|