codeply-cli 0.2.0 → 0.3.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/THIRD_PARTY_NOTICES.md +63 -0
- package/bin/codeply.js +562 -370
- package/lib/agent.mjs +1627 -160
- package/lib/ai.js +1107 -387
- package/lib/apply-limit.js +33 -51
- package/lib/apply-patch.js +217 -0
- package/lib/arity-table.json +138 -0
- package/lib/arity.js +70 -0
- package/lib/auth.js +118 -112
- package/lib/commands.js +103 -0
- package/lib/config.js +392 -136
- package/lib/design-library/artifact-template.html +500 -0
- package/lib/design-library/assemble-artifact.mjs +21 -0
- package/lib/design-library/build.mjs +226 -0
- package/lib/design-library/cleanup.mjs +45 -0
- package/lib/design-library/index.json +22380 -0
- package/lib/design-library/make-table-data.mjs +39 -0
- package/lib/design-library/query.mjs +104 -0
- package/lib/diagnostics.js +192 -0
- package/lib/edit-engine.js +171 -14
- package/lib/github-agent.mjs +290 -0
- package/lib/history.js +75 -0
- package/lib/mcp.js +258 -0
- package/lib/native-tools.mjs +189 -0
- package/lib/oauth-connectors.js +478 -0
- package/lib/permissions.js +109 -0
- package/lib/plugins.js +353 -0
- package/lib/render-md.mjs +617 -617
- package/lib/server.mjs +457 -0
- package/lib/session-db.js +220 -0
- package/lib/share.js +168 -0
- package/lib/skills.js +80 -22
- package/lib/snapshot.js +146 -0
- package/lib/subagents.js +164 -0
- package/lib/terminal-hints.js +154 -0
- package/lib/textarea.mjs +258 -258
- package/lib/tools.mjs +1658 -79
- package/lib/tui.js +7 -7
- package/lib/tui.mjs +1086 -992
- package/lib/typecheck-worker.js +147 -0
- package/lib/web-tools.js +158 -0
- package/package.json +4 -3
- package/skills/premium-web-design/SKILL.md +154 -0
package/lib/tools.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Codeply agent tools.
|
|
3
3
|
*
|
|
4
4
|
* Each tool is a plain async function returning { ok, output, meta }. `output`
|
|
5
|
-
* is what gets fed back to the model, so it is always truncated to a sane size
|
|
5
|
+
* is what gets fed back to the model, so it is always truncated to a sane size -
|
|
6
6
|
* an untruncated file read is the fastest way to blow the context budget and
|
|
7
7
|
* make the agent stupid halfway through a task.
|
|
8
8
|
*
|
|
@@ -12,20 +12,27 @@
|
|
|
12
12
|
import fs from 'fs';
|
|
13
13
|
import path from 'path';
|
|
14
14
|
import os from 'os';
|
|
15
|
+
import crypto from 'crypto';
|
|
15
16
|
import { exec } from 'child_process';
|
|
16
17
|
import { createRequire } from 'module';
|
|
18
|
+
import { searchLibrary, listCategories, designLibraryConfigured, CATEGORY_LABELS } from './design-library/query.mjs';
|
|
17
19
|
|
|
18
20
|
const require = createRequire(import.meta.url);
|
|
19
21
|
const editEngine = require('./edit-engine.js');
|
|
22
|
+
const terminalHints = require('./terminal-hints.js');
|
|
23
|
+
const diagnostics = require('./diagnostics.js');
|
|
24
|
+
const { commandPatterns } = require('./arity.js');
|
|
25
|
+
const mcpLib = require('./mcp.js');
|
|
20
26
|
const applyLimit = require('./apply-limit.js');
|
|
21
27
|
const config = require('./config.js');
|
|
22
28
|
const skills = require('./skills.js');
|
|
29
|
+
const oauth = require('./oauth-connectors.js');
|
|
30
|
+
const webTools = require('./web-tools.js');
|
|
31
|
+
const applyPatchLib = require('./apply-patch.js');
|
|
23
32
|
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
// gated by it.
|
|
28
|
-
const CAPPED_TOOLS = new Set(['write_file', 'edit_file']);
|
|
33
|
+
// Successful writes made while using the hosted model are logged (counts
|
|
34
|
+
// only) for the usage dashboard. There is no cap - see apply-limit.js.
|
|
35
|
+
const RECORDED_TOOLS = new Set(['write_file', 'edit_file']);
|
|
29
36
|
|
|
30
37
|
const MAX_TOOL_OUTPUT = 12000; // chars fed back to the model per tool call
|
|
31
38
|
const MAX_READ_LINES = 600;
|
|
@@ -44,10 +51,49 @@ const TEXT_EXT = new Set([
|
|
|
44
51
|
'.yaml', '.toml', '.xml', '.svg', '.vue', '.svelte', '.sql', '.env',
|
|
45
52
|
]);
|
|
46
53
|
|
|
47
|
-
|
|
54
|
+
// Oversized output is saved in full to a file the model can page through
|
|
55
|
+
// with read_file, instead of the rest just being lost. Same idea as
|
|
56
|
+
// opencode's tool/truncate.ts (MIT). Old files are swept after a week.
|
|
57
|
+
const SPILL_DIR = path.join(os.tmpdir(), 'codeply-tool-output');
|
|
58
|
+
const SPILL_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
59
|
+
let spillSwept = false;
|
|
60
|
+
|
|
61
|
+
function spillToFile(text) {
|
|
62
|
+
try {
|
|
63
|
+
fs.mkdirSync(SPILL_DIR, { recursive: true });
|
|
64
|
+
if (!spillSwept) {
|
|
65
|
+
spillSwept = true;
|
|
66
|
+
const cutoff = Date.now() - SPILL_MAX_AGE_MS;
|
|
67
|
+
for (const f of fs.readdirSync(SPILL_DIR)) {
|
|
68
|
+
const p = path.join(SPILL_DIR, f);
|
|
69
|
+
try { if (fs.statSync(p).mtimeMs < cutoff) fs.unlinkSync(p); } catch {}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const file = path.join(SPILL_DIR, `out-${Date.now().toString(36)}-${crypto.randomBytes(3).toString('hex')}.txt`);
|
|
73
|
+
fs.writeFileSync(file, text, 'utf8');
|
|
74
|
+
return file;
|
|
75
|
+
} catch { return null; }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @param {string} text
|
|
80
|
+
* @param {number} [max]
|
|
81
|
+
* @param {{keep?: 'head'|'ends', spill?: boolean}} [opts]
|
|
82
|
+
* keep 'ends' shows the start and the end (command output: errors and
|
|
83
|
+
* summaries are usually last). spill saves the full text to a file.
|
|
84
|
+
*/
|
|
85
|
+
function truncate(text, max = MAX_TOOL_OUTPUT, { keep = 'head', spill = true } = {}) {
|
|
48
86
|
if (text.length <= max) return text;
|
|
49
|
-
const
|
|
50
|
-
|
|
87
|
+
const file = spill ? spillToFile(text) : null;
|
|
88
|
+
const where = file
|
|
89
|
+
? `The full output (${text.length} characters, ${text.split('\n').length} lines) is saved at ${file} - read_file it with offset/limit, or search inside it with <path>, instead of re-running.`
|
|
90
|
+
: 'Narrow the request if you need the rest.';
|
|
91
|
+
if (keep === 'ends') {
|
|
92
|
+
const head = text.slice(0, Math.floor(max * 0.35));
|
|
93
|
+
const tail = text.slice(text.length - Math.floor(max * 0.6));
|
|
94
|
+
return `${head}\n\n[… ${text.length - head.length - tail.length} characters cut from the middle. ${where}]\n\n${tail}`;
|
|
95
|
+
}
|
|
96
|
+
return `${text.slice(0, max)}\n\n[… truncated, ${text.length - max} more characters. ${where}]`;
|
|
51
97
|
}
|
|
52
98
|
|
|
53
99
|
/** Resolve a model-supplied path against cwd and report whether it escapes it. */
|
|
@@ -108,6 +154,138 @@ function walkFiles(root, { maxFiles = 4000, maxDepth = 12 } = {}) {
|
|
|
108
154
|
return out;
|
|
109
155
|
}
|
|
110
156
|
|
|
157
|
+
// ─── Read helpers ───────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
const MAX_LINE_CHARS = 2000;
|
|
160
|
+
|
|
161
|
+
/** NUL bytes, or mostly non-printable bytes, in the first 4KB. */
|
|
162
|
+
function looksBinary(buf) {
|
|
163
|
+
const n = Math.min(buf.length, 4096);
|
|
164
|
+
if (!n) return false;
|
|
165
|
+
let odd = 0;
|
|
166
|
+
for (let i = 0; i < n; i++) {
|
|
167
|
+
const b = buf[i];
|
|
168
|
+
if (b === 0) return true;
|
|
169
|
+
if (b < 7 || (b > 13 && b < 32)) odd++;
|
|
170
|
+
}
|
|
171
|
+
return odd / n > 0.3;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Up to 3 existing files whose names resemble a missing one. */
|
|
175
|
+
function similarPaths(abs, cwd) {
|
|
176
|
+
const want = path.basename(abs).toLowerCase();
|
|
177
|
+
const stem = want.replace(/\.[^.]+$/, '');
|
|
178
|
+
const scored = [];
|
|
179
|
+
const consider = (full) => {
|
|
180
|
+
const name = path.basename(full).toLowerCase();
|
|
181
|
+
const s = name === want ? 1 : name.replace(/\.[^.]+$/, '') === stem ? 0.9 : lineSimilarity(name, want);
|
|
182
|
+
if (s >= 0.55) scored.push([s, full]);
|
|
183
|
+
};
|
|
184
|
+
const dir = path.dirname(abs);
|
|
185
|
+
try { if (fs.existsSync(dir)) for (const f of fs.readdirSync(dir)) consider(path.join(dir, f)); } catch {}
|
|
186
|
+
// Same name somewhere else in the project is the other common slip.
|
|
187
|
+
for (const f of walkFiles(cwd, { maxFiles: 3000, maxDepth: 8 })) if (path.basename(f).toLowerCase() === want) scored.push([0.95, f]);
|
|
188
|
+
return [...new Map(scored.sort((a, b) => b[0] - a[0]).map(([, f]) => [f, f])).keys()]
|
|
189
|
+
.slice(0, 3)
|
|
190
|
+
.map((f) => resolvePath(f, cwd).rel);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ─── Edit helpers ───────────────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
// ctx.fileState maps an absolute path to the mtime it had when this turn last
|
|
196
|
+
// read or wrote it. If the file changes on disk after that (the user saved it
|
|
197
|
+
// in their editor, a formatter ran), an edit built from the old read would be
|
|
198
|
+
// matched against content the model has never seen, so it is refused until
|
|
199
|
+
// the file is read again.
|
|
200
|
+
function rememberDiskState(ctx, abs) {
|
|
201
|
+
if (!ctx.fileState) return;
|
|
202
|
+
try { ctx.fileState.set(abs, fs.statSync(abs).mtimeMs); } catch {}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function changedSinceSeen(ctx, abs) {
|
|
206
|
+
if (!ctx.fileState || !ctx.fileState.has(abs)) return false;
|
|
207
|
+
try { return fs.statSync(abs).mtimeMs !== ctx.fileState.get(abs); } catch { return false; }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function bigrams(s) {
|
|
211
|
+
const out = new Map();
|
|
212
|
+
for (let i = 0; i < s.length - 1; i++) {
|
|
213
|
+
const g = s.slice(i, i + 2);
|
|
214
|
+
out.set(g, (out.get(g) || 0) + 1);
|
|
215
|
+
}
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Dice similarity of two trimmed lines, 0..1. */
|
|
220
|
+
function lineSimilarity(a, b) {
|
|
221
|
+
if (a === b) return 1;
|
|
222
|
+
if (a.length < 2 || b.length < 2) return 0;
|
|
223
|
+
const A = bigrams(a), B = bigrams(b);
|
|
224
|
+
let overlap = 0;
|
|
225
|
+
for (const [g, n] of A) overlap += Math.min(n, B.get(g) || 0);
|
|
226
|
+
return (2 * overlap) / (a.length - 1 + b.length - 1);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* When a search block does not match, show the model the region of the file
|
|
231
|
+
* it most likely meant, with line numbers, so it can copy the real text on
|
|
232
|
+
* the next reply instead of re-reading a long file from the top or guessing
|
|
233
|
+
* again from memory.
|
|
234
|
+
*/
|
|
235
|
+
function nearestMatchHint(content, search) {
|
|
236
|
+
const lines = content.replace(/\r\n/g, '\n').split('\n');
|
|
237
|
+
const want = search.replace(/\r\n/g, '\n').split('\n').map((l) => l.trim()).filter(Boolean);
|
|
238
|
+
if (!want.length || !lines.length) return '';
|
|
239
|
+
|
|
240
|
+
let best = -1, bestScore = 0;
|
|
241
|
+
for (let i = 0; i < lines.length; i++) {
|
|
242
|
+
const t = lines[i].trim();
|
|
243
|
+
if (!t) continue;
|
|
244
|
+
// Score a candidate start by how well the next few file lines line up
|
|
245
|
+
// with the first few search lines, not by one line alone - a single
|
|
246
|
+
// closing brace or "return x;" matches all over a file.
|
|
247
|
+
let score = 0, n = 0;
|
|
248
|
+
for (let k = 0, fi = i; k < Math.min(3, want.length) && fi < lines.length; fi++) {
|
|
249
|
+
const ft = lines[fi].trim();
|
|
250
|
+
if (!ft) continue;
|
|
251
|
+
score += lineSimilarity(ft, want[k]);
|
|
252
|
+
n++; k++;
|
|
253
|
+
}
|
|
254
|
+
score = n ? score / Math.min(3, want.length) : 0;
|
|
255
|
+
if (score > bestScore) { bestScore = score; best = i; }
|
|
256
|
+
}
|
|
257
|
+
if (best === -1 || bestScore < 0.45) return '';
|
|
258
|
+
|
|
259
|
+
const from = Math.max(0, best - 2);
|
|
260
|
+
const to = Math.min(lines.length, best + want.length + 3);
|
|
261
|
+
const width = String(to).length;
|
|
262
|
+
const excerpt = lines.slice(from, to).map((l, i) => `${String(from + i + 1).padStart(width)}│${l}`).join('\n');
|
|
263
|
+
return `\nClosest match in the file right now (lines ${from + 1}-${to}). Copy your search text from here, without the line-number prefix:\n${excerpt}`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** 1-based line numbers where an exact block starts. */
|
|
267
|
+
function exactMatchLines(content, block) {
|
|
268
|
+
const text = content.replace(/\r\n/g, '\n');
|
|
269
|
+
const needle = block.replace(/\r\n/g, '\n');
|
|
270
|
+
const out = [];
|
|
271
|
+
for (let at = text.indexOf(needle); at !== -1 && out.length < 20; at = text.indexOf(needle, at + 1)) {
|
|
272
|
+
out.push(text.slice(0, at).split('\n').length);
|
|
273
|
+
}
|
|
274
|
+
return out;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function isTruthy(v) {
|
|
278
|
+
return /^(1|true|yes|all)$/i.test(String(v || '').trim());
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Plan mode's one exception to "read-only": the plan file itself.
|
|
282
|
+
const PLAN_DIR = '.codeply/plans';
|
|
283
|
+
function isPlanPath(p, cwd) {
|
|
284
|
+
if (!p) return false;
|
|
285
|
+
const { rel, outside } = resolvePath(String(p), cwd);
|
|
286
|
+
return !outside && /^\.codeply\/plans\/[^/]+\.md$/i.test(rel);
|
|
287
|
+
}
|
|
288
|
+
|
|
111
289
|
// ─── Tools ──────────────────────────────────────────────────────────────────
|
|
112
290
|
|
|
113
291
|
async function list_dir(args, ctx) {
|
|
@@ -140,30 +318,52 @@ async function read_file(args, ctx) {
|
|
|
140
318
|
const target = args.path;
|
|
141
319
|
if (!target) return { ok: false, output: 'read_file needs a <path>.' };
|
|
142
320
|
const { abs, rel } = resolvePath(target, ctx.cwd);
|
|
143
|
-
if (!fs.existsSync(abs))
|
|
144
|
-
|
|
321
|
+
if (!fs.existsSync(abs)) {
|
|
322
|
+
const alts = similarPaths(abs, ctx.cwd);
|
|
323
|
+
return { ok: false, output: `No such file: ${rel}${alts.length ? `. Did you mean: ${alts.join(', ')}?` : ''}` };
|
|
324
|
+
}
|
|
325
|
+
if (fs.statSync(abs).isDirectory()) return { ok: false, output: `${rel} is a directory - use list_dir.` };
|
|
145
326
|
|
|
146
|
-
let
|
|
147
|
-
try {
|
|
327
|
+
let buf;
|
|
328
|
+
try { buf = fs.readFileSync(abs); }
|
|
148
329
|
catch (e) { return { ok: false, output: `Cannot read ${rel}: ${e.message}` }; }
|
|
330
|
+
if (looksBinary(buf)) {
|
|
331
|
+
return { ok: false, output: `${rel} is a binary file (${buf.length} bytes), not text. Don't read it; use view_images for pictures, or a tool that understands the format.` };
|
|
332
|
+
}
|
|
333
|
+
const content = buf.toString('utf8');
|
|
149
334
|
|
|
150
335
|
const allLines = content.split('\n');
|
|
151
336
|
const offset = Math.max(0, parseInt(args.offset, 10) || 0);
|
|
337
|
+
if (offset > 0 && offset >= allLines.length) {
|
|
338
|
+
return { ok: false, output: `${rel} has only ${allLines.length} lines, so offset ${offset} is past the end. Use an offset below ${allLines.length}.` };
|
|
339
|
+
}
|
|
152
340
|
const limit = Math.min(parseInt(args.limit, 10) || MAX_READ_LINES, MAX_READ_LINES);
|
|
153
341
|
const slice = allLines.slice(offset, offset + limit);
|
|
154
342
|
const width = String(offset + slice.length).length;
|
|
155
343
|
|
|
344
|
+
// Minified bundles put a whole file on one line; one of those would eat
|
|
345
|
+
// the entire output budget. Lines are shown without a CRLF file's \r.
|
|
156
346
|
const numbered = slice
|
|
157
|
-
.map((l, i) =>
|
|
347
|
+
.map((l, i) => {
|
|
348
|
+
const line = l.replace(/\r$/, '');
|
|
349
|
+
const shown = line.length > MAX_LINE_CHARS ? `${line.slice(0, MAX_LINE_CHARS)}… (line cut, ${line.length} chars)` : line;
|
|
350
|
+
return `${String(offset + i + 1).padStart(width)}│${shown}`;
|
|
351
|
+
})
|
|
158
352
|
.join('\n');
|
|
159
353
|
const more = allLines.length > offset + slice.length
|
|
160
354
|
? `\n[… ${allLines.length - offset - slice.length} more lines. Re-read with offset=${offset + slice.length}.]`
|
|
161
355
|
: '';
|
|
162
356
|
|
|
357
|
+
rememberDiskState(ctx, abs);
|
|
163
358
|
return {
|
|
164
359
|
ok: true,
|
|
165
|
-
output: truncate(`${rel} (${allLines.length} lines)\n${numbered}${more}
|
|
166
|
-
|
|
360
|
+
output: truncate(`${rel} (${allLines.length} lines)\n${numbered}${more}`, MAX_TOOL_OUTPUT, { spill: false }),
|
|
361
|
+
// offset/linesShown/hasMore let agent.mjs auto-continue a later call on
|
|
362
|
+
// this same path that omits <offset> - a model re-requesting "the rest"
|
|
363
|
+
// of a long file doesn't reliably track and restate the right offset
|
|
364
|
+
// itself, so leaving that entirely up to it meant it would often just
|
|
365
|
+
// land back at the top again instead of actually seeing new content.
|
|
366
|
+
meta: { label: rel, count: allLines.length, offset, linesShown: slice.length, hasMore: allLines.length > offset + slice.length },
|
|
167
367
|
};
|
|
168
368
|
}
|
|
169
369
|
|
|
@@ -177,14 +377,15 @@ async function write_file(args, ctx) {
|
|
|
177
377
|
const before = existed ? fs.readFileSync(abs, 'utf8') : '';
|
|
178
378
|
const after = args.content;
|
|
179
379
|
if (existed && before === after) {
|
|
180
|
-
return { ok: true, output: `${rel} already has exactly this content
|
|
380
|
+
return { ok: true, output: `${rel} already has exactly this content - nothing written.`, meta: { label: rel, noop: true } };
|
|
181
381
|
}
|
|
182
382
|
|
|
183
383
|
const beforeLines = existed ? before.split('\n').length : 0;
|
|
184
384
|
const afterLines = after.split('\n').length;
|
|
185
385
|
|
|
186
|
-
const verdict = await ctx.approve({
|
|
386
|
+
const verdict = ctx.mode === 'Plan' && isPlanPath(target, ctx.cwd) ? 'allow' : await ctx.approve({
|
|
187
387
|
tool: 'write_file',
|
|
388
|
+
path: rel,
|
|
188
389
|
title: existed ? `Overwrite ${rel}` : `Create ${rel}`,
|
|
189
390
|
detail: existed
|
|
190
391
|
? `${beforeLines} lines → ${afterLines} lines`
|
|
@@ -200,15 +401,23 @@ async function write_file(args, ctx) {
|
|
|
200
401
|
} catch (e) {
|
|
201
402
|
return { ok: false, output: `Cannot write ${rel}: ${e.message}` };
|
|
202
403
|
}
|
|
404
|
+
rememberDiskState(ctx, abs);
|
|
405
|
+
const problems = await diagnostics.checkFile(abs, ctx.cwd);
|
|
203
406
|
|
|
204
407
|
return {
|
|
205
408
|
ok: true,
|
|
206
|
-
output: `Wrote ${rel} (${afterLines} lines). The write succeeded exactly as you sent it
|
|
207
|
-
`do NOT read the file back to confirm it
|
|
208
|
-
meta: { label: rel, added: afterLines, removed: beforeLines, wrote: true },
|
|
409
|
+
output: `Wrote ${rel} (${afterLines} lines). The write succeeded exactly as you sent it - ` +
|
|
410
|
+
`do NOT read the file back to confirm it.` + problemsNote(rel, problems),
|
|
411
|
+
meta: { label: rel, added: afterLines, removed: beforeLines, wrote: true, problems },
|
|
209
412
|
};
|
|
210
413
|
}
|
|
211
414
|
|
|
415
|
+
function problemsNote(rel, problems) {
|
|
416
|
+
if (!problems.length) return '';
|
|
417
|
+
return `\n\nSyntax check on ${rel} found ${problems.length === 1 ? 'a problem' : `${problems.length} problems`} ` +
|
|
418
|
+
`(fix before moving on):\n${problems.map((p) => `- ${p}`).join('\n')}`;
|
|
419
|
+
}
|
|
420
|
+
|
|
212
421
|
async function edit_file(args, ctx) {
|
|
213
422
|
const target = args.path;
|
|
214
423
|
if (!target) return { ok: false, output: 'edit_file needs a <path>.' };
|
|
@@ -218,24 +427,55 @@ async function edit_file(args, ctx) {
|
|
|
218
427
|
const { abs, rel, outside } = resolvePath(target, ctx.cwd);
|
|
219
428
|
if (!fs.existsSync(abs)) return { ok: false, output: `No such file: ${rel}. Use write_file to create it.` };
|
|
220
429
|
|
|
430
|
+
if (changedSinceSeen(ctx, abs)) {
|
|
431
|
+
ctx.fileState.delete(abs);
|
|
432
|
+
return {
|
|
433
|
+
ok: false,
|
|
434
|
+
output: `Edit to ${rel} did not apply: the file changed on disk after you last read it (edited outside this conversation). ` +
|
|
435
|
+
'Read it again and build the edit from its current content.',
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
221
439
|
const before = fs.readFileSync(abs, 'utf8');
|
|
440
|
+
const replaceAll = isTruthy(args.all);
|
|
222
441
|
// Dry-run through the same matcher that will do the real edit, so the user
|
|
223
442
|
// is never asked to approve something that turns out not to apply.
|
|
224
|
-
|
|
443
|
+
let attempt;
|
|
444
|
+
let occurrences = 1;
|
|
445
|
+
if (replaceAll) {
|
|
446
|
+
// Every occurrence, exact text only: the whitespace-tolerant matcher is
|
|
447
|
+
// for finding ONE intended block, and applying its guesses file-wide
|
|
448
|
+
// would be a good way to change lines nobody meant to touch.
|
|
449
|
+
const text = before.replace(/\r\n/g, '\n');
|
|
450
|
+
const needle = args.search.replace(/\r\n/g, '\n');
|
|
451
|
+
occurrences = needle ? text.split(needle).length - 1 : 0;
|
|
452
|
+
attempt = occurrences > 0
|
|
453
|
+
? { ok: true, content: text.split(needle).join(args.replace.replace(/\r\n/g, '\n')) }
|
|
454
|
+
: { ok: false, error: needle ? 'notfound' : 'empty' };
|
|
455
|
+
if (attempt.ok && /\r\n/.test(before)) attempt.content = attempt.content.replace(/\n/g, '\r\n');
|
|
456
|
+
} else {
|
|
457
|
+
attempt = editEngine.applySearchReplace(before, args.search, args.replace);
|
|
458
|
+
}
|
|
225
459
|
if (!attempt.ok) {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
460
|
+
let why;
|
|
461
|
+
if (attempt.error === 'notfound') {
|
|
462
|
+
why = 'that exact text is not in the file.' + (nearestMatchHint(before, args.search) || ' Re-read the part you want to change and copy the block verbatim.');
|
|
463
|
+
} else if (attempt.error === 'multiple') {
|
|
464
|
+
const at = exactMatchLines(before, args.search);
|
|
465
|
+
why = `that text appears more than once${at.length ? ` (starting at lines ${at.join(', ')})` : ''}. ` +
|
|
466
|
+
'Include more surrounding lines so it matches exactly one place, or add <all>true</all> if every occurrence should change.';
|
|
467
|
+
} else {
|
|
468
|
+
why = 'the search block was empty.';
|
|
469
|
+
}
|
|
470
|
+
return { ok: false, output: `Edit to ${rel} did not apply: ${why}` };
|
|
232
471
|
}
|
|
233
472
|
|
|
234
|
-
const removed = args.search.split('\n').length;
|
|
235
|
-
const added = args.replace.split('\n').length;
|
|
473
|
+
const removed = args.search.split('\n').length * occurrences;
|
|
474
|
+
const added = args.replace.split('\n').length * occurrences;
|
|
236
475
|
|
|
237
|
-
const verdict = await ctx.approve({
|
|
476
|
+
const verdict = ctx.mode === 'Plan' && isPlanPath(target, ctx.cwd) ? 'allow' : await ctx.approve({
|
|
238
477
|
tool: 'edit_file',
|
|
478
|
+
path: rel,
|
|
239
479
|
title: `Edit ${rel}`,
|
|
240
480
|
detail: `-${removed} +${added} lines`,
|
|
241
481
|
diff: { search: args.search, replace: args.replace },
|
|
@@ -245,6 +485,8 @@ async function edit_file(args, ctx) {
|
|
|
245
485
|
|
|
246
486
|
try { fs.writeFileSync(abs, attempt.content, 'utf8'); }
|
|
247
487
|
catch (e) { return { ok: false, output: `Cannot write ${rel}: ${e.message}` }; }
|
|
488
|
+
rememberDiskState(ctx, abs);
|
|
489
|
+
const problems = await diagnostics.checkFile(abs, ctx.cwd);
|
|
248
490
|
|
|
249
491
|
const nowLines = attempt.content.split('\n').length;
|
|
250
492
|
return {
|
|
@@ -252,29 +494,69 @@ async function edit_file(args, ctx) {
|
|
|
252
494
|
// The "do not re-read" line is load-bearing: without it the model burns a
|
|
253
495
|
// whole extra round-trip reading the file back, and stuffs the entire file
|
|
254
496
|
// into context again, which slows down every following step.
|
|
255
|
-
output: `Edited ${rel} (-${removed} +${added} lines). The file is now ${nowLines} lines. ` +
|
|
256
|
-
`The change was matched and applied successfully
|
|
257
|
-
|
|
497
|
+
output: `Edited ${rel} (-${removed} +${added} lines${occurrences > 1 ? `, ${occurrences} occurrences` : ''}). The file is now ${nowLines} lines. ` +
|
|
498
|
+
`The change was matched and applied successfully - do NOT read the file back to confirm it.` +
|
|
499
|
+
(attempt.fuzzy ? ' (Your search text did not match exactly; the closest unique block was used. Copy search text exactly next time.)' : '') +
|
|
500
|
+
problemsNote(rel, problems),
|
|
501
|
+
meta: { label: rel, added, removed, wrote: true, problems },
|
|
258
502
|
};
|
|
259
503
|
}
|
|
260
504
|
|
|
505
|
+
const MAX_SEARCH_CONTEXT = 5;
|
|
506
|
+
const MAX_FOUND_FILES = 200;
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Content search, or a file-name search when only <glob> is given.
|
|
510
|
+
*
|
|
511
|
+
* <pattern> regex matched per line (case-insensitive)
|
|
512
|
+
* <glob> limit to matching paths; alone, it lists matching files
|
|
513
|
+
* <path> folder to search under (default: project root)
|
|
514
|
+
* <context> lines of surrounding code to show per hit (0-5), so a hit
|
|
515
|
+
* can often be edited without a separate read_file
|
|
516
|
+
* <files_only> true: list each matching file once with its hit count
|
|
517
|
+
*/
|
|
261
518
|
async function search(args, ctx) {
|
|
262
519
|
const pattern = args.pattern;
|
|
263
|
-
|
|
520
|
+
const globRe = args.glob ? globToRegex(args.glob) : null;
|
|
521
|
+
if (!pattern && !globRe) return { ok: false, output: 'search needs a <pattern>, or a <glob> to find files by name.' };
|
|
264
522
|
|
|
265
|
-
let re;
|
|
266
|
-
|
|
267
|
-
|
|
523
|
+
let re = null;
|
|
524
|
+
if (pattern) {
|
|
525
|
+
try { re = new RegExp(pattern, 'i'); }
|
|
526
|
+
catch (e) { return { ok: false, output: `Invalid regex: ${e.message}` }; }
|
|
527
|
+
}
|
|
268
528
|
|
|
269
|
-
const globRe = args.glob ? globToRegex(args.glob) : null;
|
|
270
529
|
const root = args.path ? resolvePath(args.path, ctx.cwd).abs : ctx.cwd;
|
|
271
|
-
|
|
530
|
+
if (!fs.existsSync(root)) return { ok: false, output: `No such folder: ${args.path}` };
|
|
531
|
+
// A single file works too (e.g. a saved long command output).
|
|
532
|
+
const files = fs.statSync(root).isFile() ? [root] : walkFiles(root);
|
|
533
|
+
|
|
534
|
+
if (!re) {
|
|
535
|
+
// Also match against just the file name, so "*.test.js" finds nested files
|
|
536
|
+
// without the model having to know to write "**/*.test.js".
|
|
537
|
+
const found = files
|
|
538
|
+
.map((f) => path.relative(ctx.cwd, f).replace(/\\/g, '/'))
|
|
539
|
+
.filter((rel) => globRe.test(rel) || globRe.test(rel.split('/').pop()))
|
|
540
|
+
.sort();
|
|
541
|
+
const shown = found.slice(0, MAX_FOUND_FILES);
|
|
542
|
+
const header = found.length
|
|
543
|
+
? `${found.length} file(s) matching ${args.glob}${found.length > shown.length ? ` (first ${shown.length} shown)` : ''}:`
|
|
544
|
+
: `No files matching ${args.glob}.`;
|
|
545
|
+
return { ok: true, output: truncate(`${header}\n${shown.join('\n')}`), meta: { label: args.glob, count: found.length } };
|
|
546
|
+
}
|
|
272
547
|
|
|
548
|
+
const context = Math.min(MAX_SEARCH_CONTEXT, Math.max(0, parseInt(args.context, 10) || 0));
|
|
549
|
+
const filesOnly = isTruthy(args.files_only);
|
|
550
|
+
|
|
551
|
+
// Context blocks spend several output lines per hit, so the cap scales with them.
|
|
552
|
+
const maxLines = MAX_SEARCH_HITS * (context ? 4 : 1);
|
|
273
553
|
const hits = [];
|
|
554
|
+
const fileCounts = [];
|
|
555
|
+
let totalHits = 0;
|
|
274
556
|
let scanned = 0;
|
|
275
557
|
for (const file of files) {
|
|
276
558
|
const rel = path.relative(ctx.cwd, file).replace(/\\/g, '/');
|
|
277
|
-
if (globRe && !globRe.test(rel)) continue;
|
|
559
|
+
if (globRe && !globRe.test(rel) && !globRe.test(rel.split('/').pop())) continue;
|
|
278
560
|
const ext = path.extname(file).toLowerCase();
|
|
279
561
|
if (ext && !TEXT_EXT.has(ext)) continue;
|
|
280
562
|
let content;
|
|
@@ -282,37 +564,698 @@ async function search(args, ctx) {
|
|
|
282
564
|
if (fs.statSync(file).size > 2_000_000) continue;
|
|
283
565
|
content = fs.readFileSync(file, 'utf8');
|
|
284
566
|
} catch { continue; }
|
|
567
|
+
if (content.includes('\u0000')) continue; // binary that happens to have a text extension
|
|
285
568
|
scanned++;
|
|
286
569
|
const lines = content.split('\n');
|
|
570
|
+
let inFile = 0;
|
|
571
|
+
let lastShown = -1;
|
|
287
572
|
for (let i = 0; i < lines.length; i++) {
|
|
288
|
-
if (re.test(lines[i]))
|
|
573
|
+
if (!re.test(lines[i])) continue;
|
|
574
|
+
inFile++;
|
|
575
|
+
totalHits++;
|
|
576
|
+
if (filesOnly || hits.length >= maxLines) continue;
|
|
577
|
+
if (!context) {
|
|
289
578
|
hits.push(`${rel}:${i + 1}: ${lines[i].trim().slice(0, 200)}`);
|
|
290
|
-
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
// Context blocks: hit line marked with ":", neighbours with "-", and
|
|
582
|
+
// overlapping windows merged so nearby hits don't repeat lines.
|
|
583
|
+
const from = Math.max(lastShown + 1, i - context);
|
|
584
|
+
const to = Math.min(lines.length - 1, i + context);
|
|
585
|
+
if (from > lastShown + 1 || lastShown === -1) hits.push(`--`);
|
|
586
|
+
for (let k = from; k <= to; k++) {
|
|
587
|
+
const mark = re.test(lines[k]) ? ':' : '-';
|
|
588
|
+
hits.push(`${rel}${mark}${k + 1}${mark} ${lines[k].slice(0, 200)}`);
|
|
291
589
|
}
|
|
590
|
+
lastShown = to;
|
|
292
591
|
}
|
|
293
|
-
if (
|
|
592
|
+
if (inFile) fileCounts.push(`${rel} (${inFile})`);
|
|
593
|
+
if (!filesOnly && hits.length >= maxLines) break;
|
|
294
594
|
}
|
|
295
595
|
|
|
296
|
-
|
|
297
|
-
|
|
596
|
+
if (filesOnly) {
|
|
597
|
+
const header = fileCounts.length
|
|
598
|
+
? `${totalHits} match(es) in ${fileCounts.length} file(s), out of ${scanned} scanned:`
|
|
599
|
+
: `No matches for /${pattern}/ across ${scanned} file(s).`;
|
|
600
|
+
return { ok: true, output: truncate(`${header}\n${fileCounts.join('\n')}`), meta: { label: pattern, count: totalHits } };
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const capped = hits.length >= maxLines;
|
|
604
|
+
const header = totalHits
|
|
605
|
+
? `${totalHits}${capped ? '+' : ''} match(es) across ${scanned} file(s)${capped ? '. Output capped: narrow with <glob>/<path>, or use <files_only>true</files_only> to see which files match' : ''}:`
|
|
298
606
|
: `No matches for /${pattern}/ across ${scanned} file(s).`;
|
|
299
|
-
return { ok: true, output: truncate(`${header}\n${hits.join('\n')}`), meta: { label: pattern, count:
|
|
607
|
+
return { ok: true, output: truncate(`${header}\n${hits.join('\n')}`), meta: { label: pattern, count: totalHits } };
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const MAX_IMAGE_BYTES = 8 * 1024 * 1024; // 8MB - plenty for web assets, small enough to not stall a turn
|
|
611
|
+
const FETCH_TIMEOUT_MS = 20000;
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Download an image from the internet and save it to disk. This is the
|
|
615
|
+
* agent's only sanctioned path to the network for assets - `run` can already
|
|
616
|
+
* reach curl/Invoke-WebRequest, but that gives the model no feedback about
|
|
617
|
+
* whether it actually got an image back, no size cap, and a generic "Run
|
|
618
|
+
* command" approval prompt instead of one that tells the user what's about
|
|
619
|
+
* to land on their disk and from where.
|
|
620
|
+
*/
|
|
621
|
+
async function fetch_image(args, ctx) {
|
|
622
|
+
const url = (args.url || '').trim();
|
|
623
|
+
const target = (args.path || '').trim();
|
|
624
|
+
if (!url) return { ok: false, output: 'fetch_image needs a <url>.' };
|
|
625
|
+
if (!target) return { ok: false, output: 'fetch_image needs a <path> to save the image to.' };
|
|
626
|
+
|
|
627
|
+
let parsed;
|
|
628
|
+
try { parsed = new URL(url); }
|
|
629
|
+
catch { return { ok: false, output: `Not a valid URL: ${url}` }; }
|
|
630
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
631
|
+
return { ok: false, output: 'fetch_image only supports http:// and https:// URLs.' };
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const { abs, rel, outside } = resolvePath(target, ctx.cwd);
|
|
635
|
+
|
|
636
|
+
// `path` rides alongside `detail` (the original url) so a host that wants to
|
|
637
|
+
// offer a real picker doesn't have to scrape the human-readable `title` text
|
|
638
|
+
// to know where the image is headed.
|
|
639
|
+
const verdict = await ctx.approve({
|
|
640
|
+
tool: 'fetch_image',
|
|
641
|
+
title: `Download image to ${rel}`,
|
|
642
|
+
detail: url,
|
|
643
|
+
path: rel,
|
|
644
|
+
danger: outside,
|
|
645
|
+
});
|
|
646
|
+
const verdictAction = typeof verdict === 'string' ? verdict : verdict?.action;
|
|
647
|
+
if (verdictAction === 'reject') return { ok: false, output: `User declined the download to ${rel}.`, meta: { rejected: true } };
|
|
648
|
+
|
|
649
|
+
// A host UI (e.g. an image picker the user searched and clicked through)
|
|
650
|
+
// can hand back a different URL than the one the model proposed - that
|
|
651
|
+
// replaces it here, but everything downstream (fetch, validation, write)
|
|
652
|
+
// is identical either way.
|
|
653
|
+
const fetchUrl = (verdict && typeof verdict === 'object' && verdict.url) ? verdict.url : url;
|
|
654
|
+
|
|
655
|
+
let res;
|
|
656
|
+
try {
|
|
657
|
+
res = await fetch(fetchUrl, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), redirect: 'follow' });
|
|
658
|
+
} catch (e) {
|
|
659
|
+
return { ok: false, output: `Could not reach ${fetchUrl}: ${e.message}` };
|
|
660
|
+
}
|
|
661
|
+
if (!res.ok) return { ok: false, output: `${fetchUrl} returned HTTP ${res.status}.` };
|
|
662
|
+
|
|
663
|
+
const contentType = (res.headers.get('content-type') || '').split(';')[0].trim();
|
|
664
|
+
if (!contentType.startsWith('image/')) {
|
|
665
|
+
return { ok: false, output: `${fetchUrl} did not return an image (content-type: ${contentType || 'unknown'}). Nothing was saved.` };
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
let buf;
|
|
669
|
+
try { buf = Buffer.from(await res.arrayBuffer()); }
|
|
670
|
+
catch (e) { return { ok: false, output: `Failed reading the response from ${fetchUrl}: ${e.message}` }; }
|
|
671
|
+
|
|
672
|
+
if (buf.length > MAX_IMAGE_BYTES) {
|
|
673
|
+
return { ok: false, output: `Image is ${(buf.length / 1e6).toFixed(1)}MB, over the ${MAX_IMAGE_BYTES / 1e6}MB limit. Try a smaller/compressed source.` };
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
try {
|
|
677
|
+
const dir = path.dirname(abs);
|
|
678
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
679
|
+
fs.writeFileSync(abs, buf);
|
|
680
|
+
} catch (e) {
|
|
681
|
+
return { ok: false, output: `Cannot write ${rel}: ${e.message}` };
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
return {
|
|
685
|
+
ok: true,
|
|
686
|
+
output: `Downloaded ${rel} (${contentType}, ${(buf.length / 1024).toFixed(0)}KB) from ${fetchUrl}. ` +
|
|
687
|
+
`Reference it in markup with a normal relative path - do NOT read the file back to confirm it.`,
|
|
688
|
+
meta: { label: rel, wrote: true },
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Gmail access tokens expire in ~1hr; the refresh token doesn't. Refreshed
|
|
694
|
+
* transparently here before every call rather than waiting for a 401, since
|
|
695
|
+
* "expired 4 minutes ago" and "expires in 4 minutes" both need the same
|
|
696
|
+
* refresh anyway and there's no benefit to waiting for the failure first.
|
|
697
|
+
*/
|
|
698
|
+
async function getValidGmailToken() {
|
|
699
|
+
const gmail = config.getIntegration('gmail');
|
|
700
|
+
if (!gmail.accessToken) return null;
|
|
701
|
+
if (gmail.expiresAt && Date.now() < gmail.expiresAt - 60000) return gmail.accessToken;
|
|
702
|
+
if (!gmail.refreshToken) return gmail.accessToken; // nothing to refresh with - let the call itself fail if it's actually expired
|
|
703
|
+
const refreshed = await oauth.refreshGmailToken(gmail.clientId, gmail.clientSecret, gmail.refreshToken);
|
|
704
|
+
config.saveIntegration('gmail', { accessToken: refreshed.access_token, expiresAt: Date.now() + (refreshed.expires_in || 3600) * 1000 });
|
|
705
|
+
return refreshed.access_token;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
async function gmail_send(args, ctx) {
|
|
709
|
+
const to = (args.to || '').trim();
|
|
710
|
+
const subject = (args.subject || '').trim();
|
|
711
|
+
const body = args.body || '';
|
|
712
|
+
if (!to) return { ok: false, output: 'gmail_send needs a <to> address.' };
|
|
713
|
+
if (!subject) return { ok: false, output: 'gmail_send needs a <subject>.' };
|
|
714
|
+
|
|
715
|
+
const token = await getValidGmailToken();
|
|
716
|
+
if (!token) return { ok: false, output: 'Gmail is not connected. Ask the user to connect it from Connect Apps (account menu → Connect Apps) first.' };
|
|
717
|
+
|
|
718
|
+
const verdict = await ctx.approve({
|
|
719
|
+
tool: 'gmail_send',
|
|
720
|
+
title: `Send email to ${to}`,
|
|
721
|
+
detail: `Subject: ${subject}\n\n${body}`,
|
|
722
|
+
danger: false,
|
|
723
|
+
});
|
|
724
|
+
if (verdict === 'reject') return { ok: false, output: `User declined to send the email to ${to}.`, meta: { rejected: true } };
|
|
725
|
+
|
|
726
|
+
try {
|
|
727
|
+
const result = await oauth.gmailSend(token, { to, subject, body });
|
|
728
|
+
return { ok: true, output: `Email sent to ${to} (message id ${result.id}).`, meta: { label: `to ${to}: ${subject}` } };
|
|
729
|
+
} catch (e) {
|
|
730
|
+
return { ok: false, output: `Gmail send failed: ${e.message}` };
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
async function gmail_search(args, ctx) {
|
|
735
|
+
const query = (args.query || '').trim();
|
|
736
|
+
if (!query) return { ok: false, output: 'gmail_search needs a <query> (Gmail search syntax, e.g. "from:x@y.com is:unread").' };
|
|
737
|
+
|
|
738
|
+
const token = await getValidGmailToken();
|
|
739
|
+
if (!token) return { ok: false, output: 'Gmail is not connected. Ask the user to connect it from Connect Apps (account menu → Connect Apps) first.' };
|
|
740
|
+
|
|
741
|
+
try {
|
|
742
|
+
const results = await oauth.gmailSearch(token, query);
|
|
743
|
+
if (!results.length) return { ok: true, output: `No messages matched "${query}".`, meta: { label: query } };
|
|
744
|
+
const lines = results.map((m) => `- ${m.subject || '(no subject)'} - from ${m.from} - ${m.date}\n ${m.snippet}`);
|
|
745
|
+
return { ok: true, output: `${results.length} message(s) matching "${query}":\n\n${lines.join('\n')}`, meta: { label: query, count: results.length } };
|
|
746
|
+
} catch (e) {
|
|
747
|
+
return { ok: false, output: `Gmail search failed: ${e.message}` };
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// Slack's own docs are inconsistent about whether chat.postMessage accepts a
|
|
752
|
+
// bare channel name - it's legacy behavior some workspaces get and others
|
|
753
|
+
// don't. Resolving to a real ID via conversations.list first is the only
|
|
754
|
+
// reliable path, and it's one extra call, not a real cost.
|
|
755
|
+
async function resolveSlackChannel(accessToken, input) {
|
|
756
|
+
const name = input.replace(/^#/, '').toLowerCase();
|
|
757
|
+
if (/^[CGD][A-Z0-9]{8,}$/.test(input)) return input; // already looks like a real Slack ID
|
|
758
|
+
const channels = await oauth.slackListChannels(accessToken);
|
|
759
|
+
const match = channels.find((c) => c.name.toLowerCase() === name);
|
|
760
|
+
if (!match) throw new Error(`No channel named "${input}" found (or the bot hasn't been added to it).`);
|
|
761
|
+
return match.id;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
async function slack_post_message(args, ctx) {
|
|
765
|
+
const channelInput = (args.channel || '').trim();
|
|
766
|
+
const text = args.text || '';
|
|
767
|
+
if (!channelInput) return { ok: false, output: 'slack_post_message needs a <channel> (name like "general" or a channel ID).' };
|
|
768
|
+
if (!text.trim()) return { ok: false, output: 'slack_post_message needs <text> to send.' };
|
|
769
|
+
|
|
770
|
+
const slack = config.getIntegration('slack');
|
|
771
|
+
if (!slack.accessToken) return { ok: false, output: 'Slack is not connected. Ask the user to connect it from Connect Apps (account menu → Connect Apps) first.' };
|
|
772
|
+
|
|
773
|
+
const verdict = await ctx.approve({
|
|
774
|
+
tool: 'slack_post_message',
|
|
775
|
+
title: `Post to #${channelInput.replace(/^#/, '')}`,
|
|
776
|
+
detail: text,
|
|
777
|
+
danger: false,
|
|
778
|
+
});
|
|
779
|
+
if (verdict === 'reject') return { ok: false, output: `User declined to post to ${channelInput}.`, meta: { rejected: true } };
|
|
780
|
+
|
|
781
|
+
try {
|
|
782
|
+
// Channel lookup always uses the bot token - channels:read is a bot-only
|
|
783
|
+
// scope in this app's setup regardless of which token ends up posting.
|
|
784
|
+
const channelId = await resolveSlackChannel(slack.accessToken, channelInput);
|
|
785
|
+
// Posting defaults to the user token when connected - messages read as
|
|
786
|
+
// coming from the actual person, not the "Codeply Craft APP" bot. Falls
|
|
787
|
+
// back to the bot token for anyone who connected before user-token
|
|
788
|
+
// support existed, or who only approved the bot scopes.
|
|
789
|
+
const useUserToken = !!slack.userAccessToken;
|
|
790
|
+
const postToken = useUserToken ? slack.userAccessToken : slack.accessToken;
|
|
791
|
+
await oauth.slackPostMessage(postToken, { channel: channelId, text }, { isUserToken: useUserToken });
|
|
792
|
+
return { ok: true, output: `Posted to ${channelInput}${useUserToken ? ' as you' : ''}.`, meta: { label: `#${channelInput.replace(/^#/, '')}` } };
|
|
793
|
+
} catch (e) {
|
|
794
|
+
return { ok: false, output: `Slack post failed: ${e.message}` };
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function sanitizeProjectName(raw) {
|
|
799
|
+
const base = (raw || '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 52);
|
|
800
|
+
return base || 'codeply-project';
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/** Add/replace KEY=value lines in an .env file without disturbing anything else already in it. */
|
|
804
|
+
function upsertEnvFile(envPath, vars) {
|
|
805
|
+
let existing = '';
|
|
806
|
+
try { existing = fs.readFileSync(envPath, 'utf8'); } catch {}
|
|
807
|
+
const lines = existing.length ? existing.split(/\r?\n/) : [];
|
|
808
|
+
for (const { key, value } of vars) {
|
|
809
|
+
const idx = lines.findIndex((l) => l.startsWith(`${key}=`));
|
|
810
|
+
const line = `${key}=${value}`;
|
|
811
|
+
if (idx === -1) lines.push(line);
|
|
812
|
+
else lines[idx] = line;
|
|
813
|
+
}
|
|
814
|
+
while (lines.length && lines[lines.length - 1] === '') lines.pop();
|
|
815
|
+
fs.writeFileSync(envPath, `${lines.join('\n')}\n`, 'utf8');
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
const VERCEL_MAX_INLINE_BYTES = 15 * 1024 * 1024; // inline base64 deploy body - plenty for a hand-built site, not an asset-heavy monorepo
|
|
819
|
+
const ENV_FILENAME_RE = /^\.env(\..*)?$/;
|
|
820
|
+
|
|
821
|
+
async function vercel_deploy(args, ctx) {
|
|
822
|
+
const root = args.path ? resolvePath(args.path, ctx.cwd).abs : ctx.cwd;
|
|
823
|
+
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
|
|
824
|
+
return { ok: false, output: `${args.path || '.'} is not a directory.` };
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
const vercel = config.getIntegration('vercel');
|
|
828
|
+
if (!vercel.accessToken) return { ok: false, output: 'Vercel is not connected. Ask the user to connect it from Connect Apps (account menu → Connect Apps) first.' };
|
|
829
|
+
|
|
830
|
+
// .env files never ship in the deployment bundle - they exist to become
|
|
831
|
+
// Vercel project env vars (see supabase_create_project), not static files a
|
|
832
|
+
// visitor could fetch by URL.
|
|
833
|
+
const paths = walkFiles(root).filter((abs) => !ENV_FILENAME_RE.test(path.basename(abs)));
|
|
834
|
+
if (!paths.length) return { ok: false, output: `No deployable files found under ${path.relative(ctx.cwd, root) || '.'}.` };
|
|
835
|
+
|
|
836
|
+
const files = [];
|
|
837
|
+
let totalBytes = 0;
|
|
838
|
+
for (const abs of paths) {
|
|
839
|
+
const rel = path.relative(root, abs).replace(/\\/g, '/');
|
|
840
|
+
const buf = fs.readFileSync(abs);
|
|
841
|
+
totalBytes += buf.length;
|
|
842
|
+
if (totalBytes > VERCEL_MAX_INLINE_BYTES) {
|
|
843
|
+
return {
|
|
844
|
+
ok: false,
|
|
845
|
+
output: `${path.relative(ctx.cwd, root) || 'This project'} is over the ${VERCEL_MAX_INLINE_BYTES / 1e6}MB inline-deploy limit ` +
|
|
846
|
+
`(stopped at ${rel}). Remove large binary assets from the deploy folder, or deploy a smaller subdirectory.`,
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
files.push({ file: rel, data: buf.toString('base64'), encoding: 'base64' });
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
const projectName = sanitizeProjectName(path.basename(root));
|
|
853
|
+
|
|
854
|
+
const verdict = await ctx.approve({
|
|
855
|
+
tool: 'vercel_deploy',
|
|
856
|
+
title: `Deploy "${projectName}" to Vercel`,
|
|
857
|
+
detail: `${files.length} file(s), ${(totalBytes / 1024).toFixed(0)}KB - production deployment`,
|
|
858
|
+
danger: true,
|
|
859
|
+
});
|
|
860
|
+
if (verdict === 'reject') return { ok: false, output: `User declined the deploy of ${projectName}.`, meta: { rejected: true } };
|
|
861
|
+
|
|
862
|
+
try {
|
|
863
|
+
const deployment = await oauth.vercelDeploy(vercel.accessToken, vercel.teamId, projectName, files);
|
|
864
|
+
// Every project gets `<project-name>.vercel.app` as its default, always-
|
|
865
|
+
// public production domain - separate from (and not listed in) this
|
|
866
|
+
// deployment response's own .url (a per-deploy generated URL with a
|
|
867
|
+
// random hash) or .alias (a team-suffixed alias), both of which stay
|
|
868
|
+
// gated behind Vercel Authentication under this account's protection
|
|
869
|
+
// settings. Reporting either of those hands back a URL that looks broken
|
|
870
|
+
// to anyone without a Vercel login, even though the deploy itself worked.
|
|
871
|
+
const publicUrl = `https://${projectName}.vercel.app`;
|
|
872
|
+
return {
|
|
873
|
+
ok: true,
|
|
874
|
+
output: `Deployed "${projectName}" to Vercel - ${publicUrl} (state: ${deployment.readyState || 'unknown'}). ` +
|
|
875
|
+
`The build runs on Vercel's side - if that state isn't READY yet, it will finish shortly.`,
|
|
876
|
+
meta: { label: projectName },
|
|
877
|
+
};
|
|
878
|
+
} catch (e) {
|
|
879
|
+
return { ok: false, output: `Vercel deploy failed: ${e.message}` };
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
async function supabase_create_project(args, ctx) {
|
|
884
|
+
const supabaseToken = await getValidSupabaseToken();
|
|
885
|
+
if (!supabaseToken) return { ok: false, output: SUPABASE_NOT_CONNECTED };
|
|
886
|
+
|
|
887
|
+
const projectName = sanitizeProjectName(args.name || path.basename(ctx.cwd));
|
|
888
|
+
|
|
889
|
+
let orgs;
|
|
890
|
+
try { orgs = await oauth.supabaseListOrganizations(supabaseToken); }
|
|
891
|
+
catch (e) { return { ok: false, output: `Could not look up Supabase organizations: ${e.message}` }; }
|
|
892
|
+
if (!orgs?.length) return { ok: false, output: 'No Supabase organizations found for this account - create one at supabase.com first.' };
|
|
893
|
+
const org = orgs[0];
|
|
894
|
+
|
|
895
|
+
const vercel = config.getIntegration('vercel');
|
|
896
|
+
const willPushToVercel = !!vercel.accessToken;
|
|
897
|
+
const vercelProjectName = sanitizeProjectName(path.basename(ctx.cwd));
|
|
898
|
+
|
|
899
|
+
const verdict = await ctx.approve({
|
|
900
|
+
tool: 'supabase_create_project',
|
|
901
|
+
title: `Create Supabase project "${projectName}"`,
|
|
902
|
+
detail: `Organization: ${org.name} (${org.slug}). Writes SUPABASE_URL/SUPABASE_ANON_KEY/DATABASE_URL to .env` +
|
|
903
|
+
(willPushToVercel ? ` and pushes the same to the "${vercelProjectName}" Vercel project.` : '.'),
|
|
904
|
+
danger: false,
|
|
905
|
+
});
|
|
906
|
+
if (verdict === 'reject') return { ok: false, output: `User declined creating the Supabase project.`, meta: { rejected: true } };
|
|
907
|
+
|
|
908
|
+
const dbPass = crypto.randomBytes(24).toString('base64url');
|
|
909
|
+
|
|
910
|
+
let project;
|
|
911
|
+
try {
|
|
912
|
+
project = await oauth.supabaseCreateProject(supabaseToken, { name: projectName, organizationSlug: org.slug, dbPass });
|
|
913
|
+
} catch (e) {
|
|
914
|
+
return { ok: false, output: `Supabase project creation failed: ${e.message}` };
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
let keys;
|
|
918
|
+
try {
|
|
919
|
+
keys = await oauth.supabaseGetProjectKeys(supabaseToken, project.ref, dbPass);
|
|
920
|
+
} catch (e) {
|
|
921
|
+
return {
|
|
922
|
+
ok: true,
|
|
923
|
+
output: `Created Supabase project "${projectName}" (ref ${project.ref}), but could not fetch its API keys yet: ${e.message} ` +
|
|
924
|
+
`The project exists - check the Supabase dashboard for its URL/anon key.`,
|
|
925
|
+
meta: { label: projectName },
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
const envVars = [
|
|
930
|
+
{ key: 'SUPABASE_URL', value: keys.url },
|
|
931
|
+
{ key: 'SUPABASE_ANON_KEY', value: keys.anonKey },
|
|
932
|
+
{ key: 'DATABASE_URL', value: keys.databaseUrl },
|
|
933
|
+
];
|
|
934
|
+
|
|
935
|
+
try {
|
|
936
|
+
upsertEnvFile(path.join(ctx.cwd, '.env'), envVars);
|
|
937
|
+
} catch (e) {
|
|
938
|
+
return {
|
|
939
|
+
ok: true,
|
|
940
|
+
output: `Created Supabase project "${projectName}" (${keys.url}), but could not write .env: ${e.message}\n` +
|
|
941
|
+
envVars.map((v) => `${v.key}=${v.value}`).join('\n'),
|
|
942
|
+
meta: { label: projectName },
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
let vercelNote = '';
|
|
947
|
+
if (willPushToVercel) {
|
|
948
|
+
try {
|
|
949
|
+
await oauth.vercelSetEnvVars(vercel.accessToken, vercel.teamId, vercelProjectName, envVars);
|
|
950
|
+
vercelNote = ` Pushed the same env vars to the "${vercelProjectName}" Vercel project.`;
|
|
951
|
+
} catch (e) {
|
|
952
|
+
vercelNote = ` Wrote .env locally, but pushing env vars to Vercel failed: ${e.message} (Vercel project "${vercelProjectName}" may not exist yet - deploy it first.)`;
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
return {
|
|
957
|
+
ok: true,
|
|
958
|
+
output: `Created Supabase project "${projectName}" (${keys.url}) and wrote SUPABASE_URL/SUPABASE_ANON_KEY/DATABASE_URL to .env.${vercelNote}`,
|
|
959
|
+
meta: { label: projectName },
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
async function supabase_delete_project(args, ctx) {
|
|
964
|
+
const supabaseToken = await getValidSupabaseToken();
|
|
965
|
+
if (!supabaseToken) return { ok: false, output: SUPABASE_NOT_CONNECTED };
|
|
966
|
+
|
|
967
|
+
const target = (args.ref || args.name || path.basename(ctx.cwd)).trim();
|
|
968
|
+
if (!target) return { ok: false, output: 'supabase_delete_project needs a <name> or <ref> to identify the project.' };
|
|
969
|
+
|
|
970
|
+
let projects;
|
|
971
|
+
try { projects = await oauth.supabaseListProjects(supabaseToken); }
|
|
972
|
+
catch (e) { return { ok: false, output: `Could not look up Supabase projects: ${e.message}` }; }
|
|
973
|
+
|
|
974
|
+
// A ref (Supabase's project id, e.g. "abcdefghijklmnop") never collides
|
|
975
|
+
// with a name, so try that first before falling back to a name match.
|
|
976
|
+
let project = projects.find((p) => p.ref === target);
|
|
977
|
+
if (!project) {
|
|
978
|
+
const nameLower = sanitizeProjectName(target).toLowerCase();
|
|
979
|
+
const matches = projects.filter((p) => p.name.toLowerCase() === nameLower);
|
|
980
|
+
if (matches.length > 1) {
|
|
981
|
+
return {
|
|
982
|
+
ok: false,
|
|
983
|
+
output: `Multiple Supabase projects are named "${target}" - specify which by <ref> instead: ` +
|
|
984
|
+
matches.map((p) => `${p.ref} (${p.region})`).join(', '),
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
project = matches[0];
|
|
988
|
+
}
|
|
989
|
+
if (!project) return { ok: false, output: `No Supabase project found matching "${target}".` };
|
|
990
|
+
|
|
991
|
+
const verdict = await ctx.approve({
|
|
992
|
+
tool: 'supabase_delete_project',
|
|
993
|
+
title: `Delete Supabase project "${project.name}"`,
|
|
994
|
+
detail: `ref ${project.ref} - this permanently deletes the project and its database. Supabase does not support undo or restore.`,
|
|
995
|
+
danger: true,
|
|
996
|
+
});
|
|
997
|
+
if (verdict === 'reject') return { ok: false, output: `User declined deleting the Supabase project.`, meta: { rejected: true } };
|
|
998
|
+
|
|
999
|
+
try {
|
|
1000
|
+
await oauth.supabaseDeleteProject(supabaseToken, project.ref);
|
|
1001
|
+
} catch (e) {
|
|
1002
|
+
return { ok: false, output: `Supabase project deletion failed: ${e.message}` };
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
return {
|
|
1006
|
+
ok: true,
|
|
1007
|
+
output: `Deleted Supabase project "${project.name}" (ref ${project.ref}). This did not touch .env or any Vercel env vars pointing at it - remove those separately if they're now stale.`,
|
|
1008
|
+
meta: { label: project.name },
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/** GitHub repo names allow more than Vercel/Supabase project names - letters (any case), digits, dot/dash/underscore. */
|
|
1013
|
+
function sanitizeRepoName(raw) {
|
|
1014
|
+
const base = (raw || '').trim().replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
1015
|
+
return base || 'codeply-project';
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// Async on purpose, same as run()'s exec() below - this is the ONE thing
|
|
1019
|
+
// every concurrently-running session shares (Electron's single main-process
|
|
1020
|
+
// event loop), so a synchronous git call (execSync) here doesn't just block
|
|
1021
|
+
// its own turn, it freezes every other session - the main chat included -
|
|
1022
|
+
// for as long as it runs. That's harmless for a local `git init`/`commit`
|
|
1023
|
+
// (milliseconds) but genuinely dangerous for `git push`, a real network
|
|
1024
|
+
// call that can take seconds to tens of seconds: a synchronous push used to
|
|
1025
|
+
// hang the entire app solid until it finished.
|
|
1026
|
+
function execAsync(cmd, opts) {
|
|
1027
|
+
return new Promise((resolve, reject) => {
|
|
1028
|
+
exec(cmd, opts, (err, stdout) => {
|
|
1029
|
+
if (err) { reject(err); return; }
|
|
1030
|
+
resolve(stdout);
|
|
1031
|
+
});
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
function runGit(args, cwd) {
|
|
1036
|
+
return execAsync(`git ${args}`, { cwd, windowsHide: true, encoding: 'utf8', timeout: RUN_TIMEOUT_MS });
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
async function github_create_repo(args, ctx) {
|
|
1040
|
+
const root = args.path ? resolvePath(args.path, ctx.cwd).abs : ctx.cwd;
|
|
1041
|
+
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
|
|
1042
|
+
return { ok: false, output: `${args.path || '.'} is not a directory.` };
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
const github = config.getIntegration('github');
|
|
1046
|
+
if (!github.accessToken) return { ok: false, output: 'GitHub is not connected. Ask the user to connect it from Connect Apps (account menu → Connect Apps) first.' };
|
|
1047
|
+
|
|
1048
|
+
const repoName = sanitizeRepoName(args.name || path.basename(root));
|
|
1049
|
+
|
|
1050
|
+
const verdict = await ctx.approve({
|
|
1051
|
+
tool: 'github_create_repo',
|
|
1052
|
+
title: `Create GitHub repo "${repoName}" and push`,
|
|
1053
|
+
detail: `Creates a new private repo under ${github.userName || 'your account'} from ${path.relative(ctx.cwd, root) || '.'} and pushes it - no need to create the repo on github.com first.`,
|
|
1054
|
+
danger: false,
|
|
1055
|
+
});
|
|
1056
|
+
if (verdict === 'reject') return { ok: false, output: 'User declined creating the GitHub repo.', meta: { rejected: true } };
|
|
1057
|
+
|
|
1058
|
+
try {
|
|
1059
|
+
// Turn the folder into a repo if it isn't one yet, and make sure it has
|
|
1060
|
+
// at least one commit - a brand-new GitHub repo (created with no
|
|
1061
|
+
// auto_init below) starts completely empty, so pushing an empty local
|
|
1062
|
+
// repo would just fail with "src refspec main does not match any".
|
|
1063
|
+
const isRepo = fs.existsSync(path.join(root, '.git'));
|
|
1064
|
+
if (!isRepo) await runGit('init', root);
|
|
1065
|
+
let hasCommit = true;
|
|
1066
|
+
try { await runGit('rev-parse HEAD', root); } catch { hasCommit = false; }
|
|
1067
|
+
if (!hasCommit) {
|
|
1068
|
+
await runGit('add -A', root);
|
|
1069
|
+
await runGit('commit -m "Initial commit"', root);
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
const repo = await oauth.githubCreateRepo(github.accessToken, repoName, { private: true });
|
|
1073
|
+
|
|
1074
|
+
// The access token rides the remote URL only for this one push, then
|
|
1075
|
+
// gets scrubbed back out - .git/config is plaintext on disk, and leaving
|
|
1076
|
+
// a live token sitting in it is a real credential leak, not a theoretical
|
|
1077
|
+
// one. Future pushes from the user's own terminal go through their normal
|
|
1078
|
+
// Git credential helper against the clean URL instead.
|
|
1079
|
+
const cleanUrl = repo.clone_url;
|
|
1080
|
+
const tokenUrl = cleanUrl.replace('https://', `https://x-access-token:${github.accessToken}@`);
|
|
1081
|
+
let hasOrigin = true;
|
|
1082
|
+
try { await runGit('remote get-url origin', root); } catch { hasOrigin = false; }
|
|
1083
|
+
await runGit(`remote ${hasOrigin ? 'set-url' : 'add'} origin ${tokenUrl}`, root);
|
|
1084
|
+
|
|
1085
|
+
await runGit('branch -M main', root);
|
|
1086
|
+
try {
|
|
1087
|
+
await runGit('push -u origin main', root);
|
|
1088
|
+
} finally {
|
|
1089
|
+
await runGit(`remote set-url origin ${cleanUrl}`, root);
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
return {
|
|
1093
|
+
ok: true,
|
|
1094
|
+
output: `Created ${repo.full_name} (private) and pushed ${path.relative(ctx.cwd, root) || '.'} to it - ${repo.html_url}`,
|
|
1095
|
+
meta: { label: repo.full_name },
|
|
1096
|
+
};
|
|
1097
|
+
} catch (e) {
|
|
1098
|
+
return { ok: false, output: `GitHub repo creation/push failed: ${e.message}` };
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
const BROWSER_CHECK_DEFAULT_WAIT_MS = 700;
|
|
1103
|
+
const BROWSER_CHECK_MAX_WAIT_MS = 5000;
|
|
1104
|
+
|
|
1105
|
+
/**
|
|
1106
|
+
* Opens a page in a real browser and reports what actually happened -
|
|
1107
|
+
* console errors/warnings, failed requests, broken images, visible text.
|
|
1108
|
+
* Read-only (no ctx.approve - nothing on disk or on the network changes) and
|
|
1109
|
+
* not always available: it depends on ctx.browser, a function only a host
|
|
1110
|
+
* with a real embedded browser provides (the Codeply Craft desktop app).
|
|
1111
|
+
* The plain terminal CLI has no browser to hand it, so ctx.browser is simply
|
|
1112
|
+
* absent there and this reports itself unavailable rather than throwing.
|
|
1113
|
+
*/
|
|
1114
|
+
async function browser_check(args, ctx) {
|
|
1115
|
+
const url = (args.url || '').trim();
|
|
1116
|
+
if (!url) return { ok: false, output: 'browser_check needs a <url>.' };
|
|
1117
|
+
|
|
1118
|
+
let parsed;
|
|
1119
|
+
try { parsed = new URL(url); }
|
|
1120
|
+
catch { return { ok: false, output: `Not a valid URL: ${url}. Use file:///<absolute path> for a local file, or http://localhost:<port>/... for a running server.` }; }
|
|
1121
|
+
if (!['http:', 'https:', 'file:'].includes(parsed.protocol)) {
|
|
1122
|
+
return { ok: false, output: 'browser_check only supports http://, https://, and file:// URLs.' };
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
if (!ctx.browser) {
|
|
1126
|
+
return {
|
|
1127
|
+
ok: false,
|
|
1128
|
+
output: 'browser_check is not available here - this environment has no embedded browser to check with ' +
|
|
1129
|
+
'(that capability is only provided by the Codeply Craft desktop app, not the terminal CLI). ' +
|
|
1130
|
+
'Continue by reasoning from the source instead; do not retry this.',
|
|
1131
|
+
};
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
const wait = Math.min(BROWSER_CHECK_MAX_WAIT_MS, Math.max(0, parseInt(args.wait, 10) || BROWSER_CHECK_DEFAULT_WAIT_MS));
|
|
1135
|
+
|
|
1136
|
+
const viewport = String(args.viewport || 'desktop').trim().toLowerCase();
|
|
1137
|
+
|
|
1138
|
+
let report;
|
|
1139
|
+
try {
|
|
1140
|
+
report = await ctx.browser(url, { wait, viewport });
|
|
1141
|
+
} catch (e) {
|
|
1142
|
+
return { ok: false, output: `browser_check failed: ${e.message}` };
|
|
1143
|
+
}
|
|
1144
|
+
if (!report || !report.ok) {
|
|
1145
|
+
return { ok: false, output: `Could not load ${url}: ${(report && report.error) || 'unknown error'}` };
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
const lines = [`Loaded ${url}`, `Title: ${report.title || '(none)'}`, `Viewport: ${report.viewport || 'desktop'}`];
|
|
1149
|
+
if (report.overflowX) {
|
|
1150
|
+
lines.push('', `NOT RESPONSIVE: the page is ${report.pageWidth}px wide in a ${report.viewportWidth}px viewport, so it scrolls sideways.` +
|
|
1151
|
+
(report.wideElements?.length ? ` Elements past the right edge: ${report.wideElements.join(', ')}.` : ''));
|
|
1152
|
+
}
|
|
1153
|
+
if (report.viewport && report.viewport !== 'desktop' && report.hasViewportMeta === false) {
|
|
1154
|
+
lines.push('', 'Missing <meta name="viewport" content="width=device-width, initial-scale=1">: phones will render the desktop layout zoomed out.');
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
if (report.consoleErrors?.length) {
|
|
1158
|
+
lines.push('', `Console errors (${report.consoleErrors.length}):`, ...report.consoleErrors.slice(0, 30).map((m) => ` ${m}`));
|
|
1159
|
+
} else {
|
|
1160
|
+
lines.push('', 'Console errors: none');
|
|
1161
|
+
}
|
|
1162
|
+
if (report.consoleWarnings?.length) {
|
|
1163
|
+
lines.push('', `Console warnings (${report.consoleWarnings.length}):`, ...report.consoleWarnings.slice(0, 15).map((m) => ` ${m}`));
|
|
1164
|
+
}
|
|
1165
|
+
if (report.failedRequests?.length) {
|
|
1166
|
+
lines.push('', `Failed network requests (${report.failedRequests.length}):`, ...report.failedRequests.slice(0, 20).map((m) => ` ${m}`));
|
|
1167
|
+
}
|
|
1168
|
+
if (report.brokenImages?.length) {
|
|
1169
|
+
lines.push('', `Broken images (${report.brokenImages.length}):`, ...report.brokenImages.slice(0, 20).map((m) => ` ${m}`));
|
|
1170
|
+
}
|
|
1171
|
+
lines.push('', 'Visible page text (truncated):', report.text ? report.text.slice(0, 1500) : '(empty page)');
|
|
1172
|
+
if (report.screenshotPath) lines.push('', `Screenshot saved to disk: ${report.screenshotPath}`);
|
|
1173
|
+
if (report.screenshotDataUrl) lines.push('', 'A screenshot of the actual rendered page is attached to this result - look at it before judging whether the page is correct, not just the text above.');
|
|
1174
|
+
|
|
1175
|
+
const clean = !(report.consoleErrors?.length || report.failedRequests?.length || report.brokenImages?.length || report.overflowX);
|
|
1176
|
+
|
|
1177
|
+
return {
|
|
1178
|
+
ok: true,
|
|
1179
|
+
output: truncate(lines.join('\n')),
|
|
1180
|
+
// screenshotDataUrl rides in meta, not output: agent.mjs turns it into a
|
|
1181
|
+
// real image_url content part on the tool-result message (the same shape
|
|
1182
|
+
// already used for pasted user images) so the model actually sees the
|
|
1183
|
+
// page instead of only reading a text description of it.
|
|
1184
|
+
meta: {
|
|
1185
|
+
label: report.viewport && report.viewport !== 'desktop' ? `${url} · ${report.viewport.split(' ')[0]}` : url,
|
|
1186
|
+
clean, errorCount: report.consoleErrors?.length || 0, overflowX: !!report.overflowX,
|
|
1187
|
+
screenshotDataUrl: report.screenshotDataUrl || null,
|
|
1188
|
+
// Persisted on the session message so a screenshot survives reopening
|
|
1189
|
+
// the chat later - the data: URL above is only ever sent over the
|
|
1190
|
+
// live event stream, never written to the session store (it'd bloat
|
|
1191
|
+
// every saved chat by hundreds of KB per check).
|
|
1192
|
+
screenshotPath: report.screenshotPath || null,
|
|
1193
|
+
},
|
|
1194
|
+
};
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
const CODEPLY_COMMIT_EMAIL = 'noreply.codeplyai@gmail.com';
|
|
1198
|
+
const GIT_COMMIT_RE = /\bgit\s+commit\b/;
|
|
1199
|
+
|
|
1200
|
+
// Cached after the first run() call - a version check on every single command
|
|
1201
|
+
// would be wasted work, and the answer can't change mid-session. Cached as a
|
|
1202
|
+
// Promise (not the resolved boolean) so two overlapping first calls share the
|
|
1203
|
+
// same in-flight check instead of both firing `git --version`.
|
|
1204
|
+
let gitTrailerCheck = null;
|
|
1205
|
+
function checkGitTrailerSupport() {
|
|
1206
|
+
if (!gitTrailerCheck) {
|
|
1207
|
+
gitTrailerCheck = execAsync('git --version', { windowsHide: true, timeout: 3000 })
|
|
1208
|
+
.then((out) => {
|
|
1209
|
+
const m = /(\d+)\.(\d+)\.(\d+)/.exec(out);
|
|
1210
|
+
return m ? (Number(m[1]) > 2 || (Number(m[1]) === 2 && Number(m[2]) >= 32)) : false;
|
|
1211
|
+
})
|
|
1212
|
+
.catch(() => false);
|
|
1213
|
+
}
|
|
1214
|
+
return gitTrailerCheck;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
/**
|
|
1218
|
+
* Every commit the agent makes gets credited to the shared Codeply GitHub
|
|
1219
|
+
* account (CodeplyAI, noreply.codeplyai@gmail.com verified on it) via git's
|
|
1220
|
+
* built-in --trailer flag (git >= 2.32), so it shows up in GitHub's commit/PR
|
|
1221
|
+
* view as a linked contributor with the Codeply avatar - the same mechanism
|
|
1222
|
+
* Claude Code uses for its own "Co-Authored-By: Claude" commits. This has to
|
|
1223
|
+
* happen here, not by asking the model to remember to type the trailer
|
|
1224
|
+
* itself: --trailer works no matter how the model wrote the commit (-m, an
|
|
1225
|
+
* editor, multiple -m flags), and doing it centrally means it's never missed.
|
|
1226
|
+
* Skipped silently on older git (no --trailer support, checked once above)
|
|
1227
|
+
* or if the model already wrote its own Co-authored-by line, rather than
|
|
1228
|
+
* risk breaking every single commit over a flag an old git doesn't recognize.
|
|
1229
|
+
*/
|
|
1230
|
+
async function withCodeplyTrailer(command) {
|
|
1231
|
+
if (!GIT_COMMIT_RE.test(command) || /co-authored-by/i.test(command) || !(await checkGitTrailerSupport())) return command;
|
|
1232
|
+
return command.replace(GIT_COMMIT_RE, `git commit --trailer "Co-authored-by=Codeply <${CODEPLY_COMMIT_EMAIL}>"`);
|
|
300
1233
|
}
|
|
301
1234
|
|
|
302
1235
|
async function run(args, ctx) {
|
|
303
|
-
|
|
304
|
-
|
|
1236
|
+
// displayCommand is what the user sees - the approval card, the result
|
|
1237
|
+
// label, the echoed "$ ..." line - and stays exactly what the model wrote.
|
|
1238
|
+
// execCommand is what actually runs, with the Codeply co-author trailer
|
|
1239
|
+
// spliced into any git commit. Keeping them separate means the contributor
|
|
1240
|
+
// bookkeeping is invisible: the user approves "git add . && git commit -m
|
|
1241
|
+
// '...' && git push" and that's the only thing that ever appears anywhere
|
|
1242
|
+
// in the UI, even though the real command run on disk carries the trailer.
|
|
1243
|
+
const displayCommand = (args.command || '').trim();
|
|
1244
|
+
if (!displayCommand) return { ok: false, output: 'run needs a <command>.' };
|
|
1245
|
+
const execCommand = await withCodeplyTrailer(displayCommand);
|
|
305
1246
|
|
|
306
1247
|
const verdict = await ctx.approve({
|
|
307
1248
|
tool: 'run',
|
|
308
1249
|
title: 'Run command',
|
|
309
|
-
detail:
|
|
310
|
-
|
|
1250
|
+
detail: displayCommand,
|
|
1251
|
+
// What "always allow" covers: these command names, not every command.
|
|
1252
|
+
patterns: commandPatterns(displayCommand),
|
|
1253
|
+
danger: /\brm\s+-rf\b|\bdel\s+\/[sf]\b|format\s|mkfs|>\s*\/dev\/sd|shutdown|reboot|:\(\)\{|curl[^|]*\|\s*(ba)?sh/i.test(displayCommand),
|
|
311
1254
|
});
|
|
312
|
-
if (verdict === 'reject') return { ok: false, output: `User declined to run: ${
|
|
1255
|
+
if (verdict === 'reject') return { ok: false, output: `User declined to run: ${displayCommand}`, meta: { rejected: true } };
|
|
313
1256
|
|
|
314
1257
|
return new Promise((resolve) => {
|
|
315
|
-
exec(
|
|
1258
|
+
exec(execCommand, { cwd: ctx.cwd, timeout: RUN_TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024, windowsHide: true },
|
|
316
1259
|
(err, stdout, stderr) => {
|
|
317
1260
|
const code = err ? (err.code == null ? 1 : err.code) : 0;
|
|
318
1261
|
const parts = [];
|
|
@@ -320,17 +1263,25 @@ async function run(args, ctx) {
|
|
|
320
1263
|
if (stderr && stderr.trim()) parts.push(`[stderr]\n${stderr.trimEnd()}`);
|
|
321
1264
|
if (err && err.killed) parts.push(`[timed out after ${RUN_TIMEOUT_MS / 1000}s]`);
|
|
322
1265
|
const body = parts.join('\n') || '(no output)';
|
|
1266
|
+
// Hints are computed on the untruncated output but appended after
|
|
1267
|
+
// truncation, so they are never the part that gets cut off.
|
|
1268
|
+
const combined = `${stdout || ''}\n${stderr || ''}`;
|
|
1269
|
+
const hint = code ? terminalHints.annotateFailure(displayCommand, code, combined)
|
|
1270
|
+
: terminalHints.annotateMaskedSuccess(displayCommand, combined);
|
|
1271
|
+
// A masked failure is still reported as failed to the loop, so the
|
|
1272
|
+
// "last command failed" and verification checks treat it as one.
|
|
1273
|
+
const effectiveCode = !code && hint ? 1 : code;
|
|
323
1274
|
resolve({
|
|
324
1275
|
ok: true, // a non-zero exit is a real result the model must see, not a tool failure
|
|
325
|
-
output: truncate(`$ ${
|
|
326
|
-
meta: { label:
|
|
1276
|
+
output: truncate(`$ ${displayCommand}\n[exit ${code}]\n${body}`, MAX_TOOL_OUTPUT, { keep: 'ends' }) + (hint ? `\n[hint] ${hint}` : ''),
|
|
1277
|
+
meta: { label: displayCommand, exitCode: effectiveCode, ...(effectiveCode !== code ? { maskedFailure: true } : {}) },
|
|
327
1278
|
});
|
|
328
1279
|
});
|
|
329
1280
|
});
|
|
330
1281
|
}
|
|
331
1282
|
|
|
332
1283
|
/**
|
|
333
|
-
* Load one skill's full instructions on demand. Read-only, no approval
|
|
1284
|
+
* Load one skill's full instructions on demand. Read-only, no approval - the
|
|
334
1285
|
* whole point of the skill index living in the system prompt (see
|
|
335
1286
|
* lib/skills.js) is that only a name and a one-line description cost anything
|
|
336
1287
|
* until the agent actually decides a skill is relevant, at which point this is
|
|
@@ -340,12 +1291,12 @@ async function use_skill(args, ctx) {
|
|
|
340
1291
|
const name = (args.name || '').trim();
|
|
341
1292
|
if (!name) return { ok: false, output: 'use_skill needs a <name>.' };
|
|
342
1293
|
|
|
343
|
-
const body = skills.loadSkillBody(name);
|
|
1294
|
+
const body = skills.loadSkillBody(name, ctx.cwd);
|
|
344
1295
|
if (body == null) {
|
|
345
1296
|
// Capped rather than dumping the full library (281 names) into a reply
|
|
346
1297
|
// that only exists because of one wrong guess.
|
|
347
|
-
const all = skills.listSkills().map((s) => s.name);
|
|
348
|
-
const shown = all.slice(0, 40).join(', ') + (all.length > 40 ? `, … (${all.length - 40} more
|
|
1298
|
+
const all = skills.listSkills(ctx.cwd).map((s) => s.name);
|
|
1299
|
+
const shown = all.slice(0, 40).join(', ') + (all.length > 40 ? `, … (${all.length - 40} more - see list_skills)` : '');
|
|
349
1300
|
return {
|
|
350
1301
|
ok: false,
|
|
351
1302
|
output: `No skill named "${name}". ${all.length ? `Some available: ${shown}` : '(none installed)'}`,
|
|
@@ -355,18 +1306,18 @@ async function use_skill(args, ctx) {
|
|
|
355
1306
|
}
|
|
356
1307
|
|
|
357
1308
|
/**
|
|
358
|
-
* The full skill catalog
|
|
1309
|
+
* The full skill catalog - the counterpart to the DAILY-only index that
|
|
359
1310
|
* always rides in the system prompt (see lib/skills.js). One call, paid once,
|
|
360
1311
|
* only when the task actually needs something outside that curated set.
|
|
361
1312
|
*
|
|
362
|
-
* At 281 skills, name+description together run past MAX_TOOL_OUTPUT
|
|
1313
|
+
* At 281 skills, name+description together run past MAX_TOOL_OUTPUT - an
|
|
363
1314
|
* optional query narrows to matching names/descriptions before formatting, so
|
|
364
1315
|
* the normal case (looking for "something about X") returns a handful of
|
|
365
1316
|
* relevant lines instead of a wall of text truncated at an arbitrary,
|
|
366
1317
|
* alphabetically-biased cutoff.
|
|
367
1318
|
*/
|
|
368
|
-
async function list_skills(args) {
|
|
369
|
-
const all = skills.listSkills();
|
|
1319
|
+
async function list_skills(args, ctx) {
|
|
1320
|
+
const all = skills.listSkills(ctx.cwd);
|
|
370
1321
|
const query = (args.query || '').trim().toLowerCase();
|
|
371
1322
|
const matched = query
|
|
372
1323
|
? all.filter((s) => s.name.toLowerCase().includes(query) || s.description.toLowerCase().includes(query))
|
|
@@ -385,13 +1336,622 @@ async function list_skills(args) {
|
|
|
385
1336
|
};
|
|
386
1337
|
}
|
|
387
1338
|
|
|
388
|
-
|
|
1339
|
+
/**
|
|
1340
|
+
* Real-app UI reference search against the private design library built
|
|
1341
|
+
* locally in lib/design-library/index.json (914 apps / 6,433 real App Store
|
|
1342
|
+
* screenshots, 10 categories, sourced from Apple's public iTunes Search
|
|
1343
|
+
* API). This is the required design-reference step for any UI work - it
|
|
1344
|
+
* doesn't depend on any external service being reachable or authenticated,
|
|
1345
|
+
* since this data lives on disk. Read-only, no ctx.approve().
|
|
1346
|
+
*/
|
|
1347
|
+
async function design_reference_search(args, ctx) {
|
|
1348
|
+
const term = (args.term || '').trim();
|
|
1349
|
+
const category = (args.category || '').trim();
|
|
1350
|
+
if (!term && !category) {
|
|
1351
|
+
return {
|
|
1352
|
+
ok: false,
|
|
1353
|
+
output: `design_reference_search needs a term and/or category. Categories: ${Object.keys(CATEGORY_LABELS).join(', ')}.`,
|
|
1354
|
+
};
|
|
1355
|
+
}
|
|
1356
|
+
if (!designLibraryConfigured()) {
|
|
1357
|
+
return { ok: false, output: 'The design reference library is not built on this machine (index.json missing). Say so plainly rather than inventing a reference.' };
|
|
1358
|
+
}
|
|
389
1359
|
|
|
390
|
-
|
|
1360
|
+
const results = searchLibrary({ term, category });
|
|
1361
|
+
if (!results.length) {
|
|
1362
|
+
const cats = listCategories().map((c) => `${c.label} (${c.appCount})`).join(', ');
|
|
1363
|
+
return {
|
|
1364
|
+
ok: true,
|
|
1365
|
+
output: `No matches for "${term || category}". Available categories: ${cats}.`,
|
|
1366
|
+
meta: { label: term || category },
|
|
1367
|
+
};
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
const lines = results.map((r, i) =>
|
|
1371
|
+
`${i + 1}. ${r.name} - ${r.categoryLabel}${r.rating ? ` (${r.rating}★)` : ''}\n` +
|
|
1372
|
+
r.screenshots.map((url) => ` screenshot: ${url}`).join('\n'));
|
|
1373
|
+
|
|
1374
|
+
const fallbackNote = results.usedFallback
|
|
1375
|
+
? `No app is literally named "${term}" - this library only indexes app names, not per-screen content, so a pattern-style ` +
|
|
1376
|
+
`term rarely matches directly. These are the top-rated ${category} apps instead; browse their screenshots for the ` +
|
|
1377
|
+
`specific screen type you need.\n\n`
|
|
1378
|
+
: '';
|
|
1379
|
+
|
|
1380
|
+
return {
|
|
1381
|
+
ok: true,
|
|
1382
|
+
output: `${fallbackNote}${results.length} app(s) matching "${term || category}":\n\n${lines.join('\n')}\n\n` +
|
|
1383
|
+
`MANDATORY NEXT STEP: these are just URLs, not a design brief - you have not "used" this reference yet, and ` +
|
|
1384
|
+
`fetch_image will NOT show you what they look like (it only downloads to disk, silently, no vision). ` +
|
|
1385
|
+
`Before writing any markup, call view_images with 2-4 of the "screenshot:" URLs above (pick from different apps, ` +
|
|
1386
|
+
`not all from #1) - that is the only way to actually see the real layout structure, spacing, type scale, color ` +
|
|
1387
|
+
`choices, and component patterns those apps ship with. Build FROM what you saw, not from a generic idea of what ` +
|
|
1388
|
+
`that kind of app "usually" looks like. If you skip view_images and design from memory instead, do not tell the ` +
|
|
1389
|
+
`user you referenced real apps - say plainly that you designed from your own judgment this time.`,
|
|
1390
|
+
meta: { label: term || category, count: results.length },
|
|
1391
|
+
};
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
const MAX_VIEW_IMAGES = 4;
|
|
1395
|
+
const MAX_VIEW_IMAGES_TOTAL_BYTES = 20 * 1024 * 1024;
|
|
1396
|
+
|
|
1397
|
+
/**
|
|
1398
|
+
* Fetch 1-4 image URLs and hand them back as real vision content on the
|
|
1399
|
+
* tool-result message (same image_url content-part mechanism browser_check
|
|
1400
|
+
* uses for its screenshot) - nothing is written to disk, nothing on the
|
|
1401
|
+
* user's machine changes, so this is read-only like
|
|
1402
|
+
* design_reference_search, not fetch_image (which downloads an asset to a
|
|
1403
|
+
* path and deliberately does NOT attach it as vision, since most fetch_image
|
|
1404
|
+
* calls are just pulling a hero photo, not something worth spending a vision
|
|
1405
|
+
* pass on). This exists specifically so a reference-search result - a list
|
|
1406
|
+
* of screenshot URLs - can actually be looked at instead of just cited.
|
|
1407
|
+
*/
|
|
1408
|
+
async function view_images(args, ctx) {
|
|
1409
|
+
const raw = (args.urls || '').trim();
|
|
1410
|
+
if (!raw) return { ok: false, output: 'view_images needs <urls> - one or more image URLs, comma-separated.' };
|
|
1411
|
+
const urls = raw.split(',').map((u) => u.trim()).filter(Boolean).slice(0, MAX_VIEW_IMAGES);
|
|
1412
|
+
if (!urls.length) return { ok: false, output: 'No valid URLs in <urls>.' };
|
|
1413
|
+
|
|
1414
|
+
const dataUrls = [];
|
|
1415
|
+
const failed = [];
|
|
1416
|
+
let totalBytes = 0;
|
|
1417
|
+
|
|
1418
|
+
for (const url of urls) {
|
|
1419
|
+
let parsed;
|
|
1420
|
+
try { parsed = new URL(url); }
|
|
1421
|
+
catch { failed.push(`${url} - not a valid URL`); continue; }
|
|
1422
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
1423
|
+
failed.push(`${url} - only http/https supported`);
|
|
1424
|
+
continue;
|
|
1425
|
+
}
|
|
1426
|
+
try {
|
|
1427
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), redirect: 'follow' });
|
|
1428
|
+
if (!res.ok) { failed.push(`${url} - HTTP ${res.status}`); continue; }
|
|
1429
|
+
const contentType = (res.headers.get('content-type') || '').split(';')[0].trim();
|
|
1430
|
+
if (!contentType.startsWith('image/')) { failed.push(`${url} - not an image (${contentType || 'unknown'})`); continue; }
|
|
1431
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
1432
|
+
if (totalBytes + buf.length > MAX_VIEW_IMAGES_TOTAL_BYTES) { failed.push(`${url} - skipped, combined size limit reached`); continue; }
|
|
1433
|
+
totalBytes += buf.length;
|
|
1434
|
+
dataUrls.push(`data:${contentType};base64,${buf.toString('base64')}`);
|
|
1435
|
+
} catch (e) {
|
|
1436
|
+
failed.push(`${url} - ${e.message}`);
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
if (!dataUrls.length) {
|
|
1441
|
+
return { ok: false, output: `Could not load any of the requested images:\n${failed.join('\n')}` };
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
const lines = [`${dataUrls.length} image(s) attached below - look at them before continuing.`];
|
|
1445
|
+
if (failed.length) lines.push('', `${failed.length} failed:`, ...failed.map((f) => ` ${f}`));
|
|
1446
|
+
|
|
1447
|
+
return {
|
|
1448
|
+
ok: true,
|
|
1449
|
+
output: lines.join('\n'),
|
|
1450
|
+
meta: { label: `${dataUrls.length} image(s)`, imageDataUrls: dataUrls },
|
|
1451
|
+
};
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
// ─── Full-access Supabase / Vercel ──────────────────────────────────────────
|
|
1455
|
+
// Once the user connects Supabase or Vercel, these give the agent the whole
|
|
1456
|
+
// Management/REST API rather than a handful of canned operations. Reads (GET,
|
|
1457
|
+
// read-only SQL) run without a prompt; anything that changes something goes
|
|
1458
|
+
// through the normal approval card, flagged as dangerous for deletes.
|
|
1459
|
+
|
|
1460
|
+
const API_OUTPUT_MAX = 10000;
|
|
1461
|
+
const READ_ONLY_MODES = new Set(['Plan', 'Ask']);
|
|
1462
|
+
|
|
1463
|
+
function normalizeMethod(raw) {
|
|
1464
|
+
const m = String(raw || 'GET').trim().toUpperCase();
|
|
1465
|
+
return ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(m) ? m : null;
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
function parseJsonBody(raw) {
|
|
1469
|
+
const text = String(raw || '').trim();
|
|
1470
|
+
if (!text) return { ok: true, value: undefined };
|
|
1471
|
+
try { return { ok: true, value: JSON.parse(text) }; }
|
|
1472
|
+
catch (e) { return { ok: false, error: `The <body> is not valid JSON (${e.message}). Send a JSON object.` }; }
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
function formatApiResult(service, method, apiPath, r) {
|
|
1476
|
+
const shown = typeof r.body === 'string' ? r.body : JSON.stringify(r.body, null, 2);
|
|
1477
|
+
const status = `${service} ${method} ${apiPath} → HTTP ${r.status}${r.ok ? '' : ' (FAILED)'}`;
|
|
1478
|
+
return truncate(`${status}\n${shown ?? '(empty body)'}`, API_OUTPUT_MAX);
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
/**
|
|
1482
|
+
* Supabase OAuth tokens expire. Refreshed shortly before expiry using the app's
|
|
1483
|
+
* OAuth credentials (config.getIntegration fills those from the environment).
|
|
1484
|
+
*/
|
|
1485
|
+
async function getValidSupabaseToken() {
|
|
1486
|
+
const sb = config.getIntegration('supabase');
|
|
1487
|
+
if (!sb.accessToken) return null;
|
|
1488
|
+
if (!sb.expiresAt || Date.now() < sb.expiresAt - 120000) return sb.accessToken;
|
|
1489
|
+
if (!sb.refreshToken || !sb.clientId || !sb.clientSecret) return sb.accessToken;
|
|
1490
|
+
try {
|
|
1491
|
+
const t = await oauth.refreshSupabaseToken(sb.clientId, sb.clientSecret, sb.refreshToken);
|
|
1492
|
+
config.saveIntegration('supabase', {
|
|
1493
|
+
accessToken: t.access_token,
|
|
1494
|
+
refreshToken: t.refresh_token || sb.refreshToken,
|
|
1495
|
+
expiresAt: Date.now() + (t.expires_in || 3600) * 1000,
|
|
1496
|
+
});
|
|
1497
|
+
return t.access_token;
|
|
1498
|
+
} catch {
|
|
1499
|
+
return sb.accessToken; // let the call itself report the auth failure
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
const SUPABASE_NOT_CONNECTED = 'Supabase is not connected. Tell the user to connect it from Connect Apps (account menu → Connect Apps → Supabase). Do not work around it with the CLI.';
|
|
1504
|
+
const VERCEL_NOT_CONNECTED = 'Vercel is not connected. Tell the user to connect it from Connect Apps (account menu → Connect Apps → Vercel). Do not work around it with the CLI.';
|
|
1505
|
+
|
|
1506
|
+
async function supabase_api(args, ctx) {
|
|
1507
|
+
const method = normalizeMethod(args.method);
|
|
1508
|
+
if (!method) return { ok: false, output: 'supabase_api <method> must be GET, POST, PUT, PATCH or DELETE.' };
|
|
1509
|
+
const apiPath = String(args.path || '').trim();
|
|
1510
|
+
if (!apiPath.startsWith('/v1/')) return { ok: false, output: 'supabase_api <path> must start with /v1/ (e.g. /v1/projects, /v1/projects/{ref}/functions).' };
|
|
1511
|
+
const body = parseJsonBody(args.body);
|
|
1512
|
+
if (!body.ok) return { ok: false, output: body.error };
|
|
1513
|
+
if (method !== 'GET' && READ_ONLY_MODES.has(ctx.mode)) return { ok: false, output: `Blocked - ${ctx.mode} mode is read-only; only GET requests are allowed.` };
|
|
1514
|
+
|
|
1515
|
+
const token = await getValidSupabaseToken();
|
|
1516
|
+
if (!token) return { ok: false, output: SUPABASE_NOT_CONNECTED };
|
|
1517
|
+
|
|
1518
|
+
if (method !== 'GET') {
|
|
1519
|
+
const verdict = await ctx.approve({
|
|
1520
|
+
tool: 'supabase_api',
|
|
1521
|
+
title: `Supabase ${method} ${apiPath}`,
|
|
1522
|
+
detail: body.value !== undefined ? JSON.stringify(body.value, null, 2).slice(0, 2000) : '(no body)',
|
|
1523
|
+
danger: method === 'DELETE',
|
|
1524
|
+
});
|
|
1525
|
+
if (verdict === 'reject') return { ok: false, output: `User declined the Supabase ${method} ${apiPath} request.`, meta: { rejected: true } };
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
let r;
|
|
1529
|
+
try { r = await oauth.supabaseApi(token, method, apiPath, body.value); }
|
|
1530
|
+
catch (e) { return { ok: false, output: `Supabase request failed: ${e.message}` }; }
|
|
1531
|
+
return { ok: r.ok, output: formatApiResult('Supabase', method, apiPath, r), meta: { label: `${method} ${apiPath}`, status: r.status } };
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
// A SELECT can still change things (SELECT INTO, or calling a function with
|
|
1535
|
+
// side effects), so only plain reads using known-safe functions skip approval.
|
|
1536
|
+
const SAFE_SQL_FUNCS = new Set(['count', 'sum', 'avg', 'min', 'max', 'coalesce', 'nullif', 'greatest', 'least', 'lower', 'upper',
|
|
1537
|
+
'length', 'trim', 'round', 'abs', 'now', 'cast', 'to_char', 'date_trunc', 'array_agg', 'string_agg', 'json_agg', 'jsonb_agg',
|
|
1538
|
+
'row_number', 'rank', 'exists', 'in', 'any', 'all', 'values', 'over', 'filter', 'format_type', 'pg_get_expr', 'obj_description',
|
|
1539
|
+
'col_description', 'pg_size_pretty', 'pg_total_relation_size', 'pg_relation_size', 'current_setting', 'as', 'and', 'or', 'not', 'on', 'using']);
|
|
1540
|
+
function isReadOnlySql(q) {
|
|
1541
|
+
if (!/^\s*(select|with|explain|show)\b/i.test(q)) return false;
|
|
1542
|
+
if (/\b(insert|update|delete|drop|alter|create|truncate|grant|revoke|comment|vacuum|reindex|copy|into|call|do|lock|set|notify|listen)\b/i.test(q)) return false;
|
|
1543
|
+
if (q.includes(';') && q.trim().replace(/;\s*$/, '').includes(';')) return false; // multiple statements
|
|
1544
|
+
for (const m of q.matchAll(/\b([a-z_][a-z0-9_]*)\s*\(/gi)) {
|
|
1545
|
+
if (!SAFE_SQL_FUNCS.has(m[1].toLowerCase())) return false;
|
|
1546
|
+
}
|
|
1547
|
+
return true;
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
async function supabase_sql(args, ctx) {
|
|
1551
|
+
const ref = String(args.ref || '').trim();
|
|
1552
|
+
const query = String(args.query || '').trim();
|
|
1553
|
+
if (!ref) return { ok: false, output: 'supabase_sql needs a <ref> (the project ref - list projects with supabase_api GET /v1/projects).' };
|
|
1554
|
+
if (!query) return { ok: false, output: 'supabase_sql needs a <query>.' };
|
|
1555
|
+
const readOnly = isReadOnlySql(query);
|
|
1556
|
+
if (!readOnly && READ_ONLY_MODES.has(ctx.mode)) return { ok: false, output: `Blocked - ${ctx.mode} mode is read-only; only SELECT queries are allowed.` };
|
|
1557
|
+
|
|
1558
|
+
const token = await getValidSupabaseToken();
|
|
1559
|
+
if (!token) return { ok: false, output: SUPABASE_NOT_CONNECTED };
|
|
1560
|
+
|
|
1561
|
+
if (!readOnly) {
|
|
1562
|
+
const verdict = await ctx.approve({
|
|
1563
|
+
tool: 'supabase_sql',
|
|
1564
|
+
title: `Run SQL on Supabase project ${ref}`,
|
|
1565
|
+
detail: query.slice(0, 3000),
|
|
1566
|
+
danger: /\b(drop|truncate|delete)\b/i.test(query),
|
|
1567
|
+
});
|
|
1568
|
+
if (verdict === 'reject') return { ok: false, output: 'User declined running that SQL.', meta: { rejected: true } };
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
let r;
|
|
1572
|
+
try { r = await oauth.supabaseQuery(token, ref, query); }
|
|
1573
|
+
catch (e) { return { ok: false, output: `Supabase SQL failed: ${e.message}` }; }
|
|
1574
|
+
const firstLine = query.split('\n')[0].slice(0, 80);
|
|
1575
|
+
return { ok: r.ok, output: formatApiResult('Supabase SQL', 'POST', `/v1/projects/${ref}/database/query`, r), meta: { label: firstLine, status: r.status } };
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
async function vercel_api(args, ctx) {
|
|
1579
|
+
const method = normalizeMethod(args.method);
|
|
1580
|
+
if (!method) return { ok: false, output: 'vercel_api <method> must be GET, POST, PUT, PATCH or DELETE.' };
|
|
1581
|
+
const apiPath = String(args.path || '').trim();
|
|
1582
|
+
if (!/^\/v\d+\//.test(apiPath)) return { ok: false, output: 'vercel_api <path> must start with a version, e.g. /v9/projects or /v6/deployments.' };
|
|
1583
|
+
const body = parseJsonBody(args.body);
|
|
1584
|
+
if (!body.ok) return { ok: false, output: body.error };
|
|
1585
|
+
if (method !== 'GET' && READ_ONLY_MODES.has(ctx.mode)) return { ok: false, output: `Blocked - ${ctx.mode} mode is read-only; only GET requests are allowed.` };
|
|
1586
|
+
|
|
1587
|
+
const vercel = config.getIntegration('vercel');
|
|
1588
|
+
if (!vercel.accessToken) return { ok: false, output: VERCEL_NOT_CONNECTED };
|
|
1589
|
+
|
|
1590
|
+
if (method !== 'GET') {
|
|
1591
|
+
const verdict = await ctx.approve({
|
|
1592
|
+
tool: 'vercel_api',
|
|
1593
|
+
title: `Vercel ${method} ${apiPath}`,
|
|
1594
|
+
detail: body.value !== undefined ? JSON.stringify(body.value, null, 2).slice(0, 2000) : '(no body)',
|
|
1595
|
+
danger: method === 'DELETE',
|
|
1596
|
+
});
|
|
1597
|
+
if (verdict === 'reject') return { ok: false, output: `User declined the Vercel ${method} ${apiPath} request.`, meta: { rejected: true } };
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
let r;
|
|
1601
|
+
try { r = await oauth.vercelApi(vercel.accessToken, vercel.teamId, method, apiPath, body.value); }
|
|
1602
|
+
catch (e) { return { ok: false, output: `Vercel request failed: ${e.message}` }; }
|
|
1603
|
+
return { ok: r.ok, output: formatApiResult('Vercel', method, apiPath, r), meta: { label: `${method} ${apiPath}`, status: r.status } };
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
// ─── Task list ──────────────────────────────────────────────────────────────
|
|
1607
|
+
// The model's own checklist for a multi-step task. Each call sends the whole
|
|
1608
|
+
// list, one item per line: [ ] pending [>] in progress [x] done [-] dropped
|
|
1609
|
+
// Idea from Hermes Agent's todo tool (MIT, Nous Research): the full list comes
|
|
1610
|
+
// back every time, at most one item is in progress, and "done" is only for
|
|
1611
|
+
// work a tool result has confirmed.
|
|
1612
|
+
|
|
1613
|
+
const TODO_MARKS = { ' ': 'pending', '>': 'in_progress', x: 'done', X: 'done', '-': 'dropped', '~': 'dropped' };
|
|
1614
|
+
const TODO_LABEL = { pending: '[ ]', in_progress: '[>]', done: '[x]', dropped: '[-]' };
|
|
1615
|
+
const MAX_TODOS = 30;
|
|
1616
|
+
|
|
1617
|
+
export function parseTodos(text) {
|
|
1618
|
+
const items = [];
|
|
1619
|
+
for (const raw of String(text || '').split('\n')) {
|
|
1620
|
+
const line = raw.trim().replace(/^[-*]\s+(?=\[)/, '');
|
|
1621
|
+
if (!line) continue;
|
|
1622
|
+
const m = line.match(/^\[(.)\]\s*(.+)$/);
|
|
1623
|
+
items.push(m && TODO_MARKS[m[1]] ? { status: TODO_MARKS[m[1]], text: m[2].trim() } : { status: 'pending', text: line });
|
|
1624
|
+
}
|
|
1625
|
+
return items.slice(0, MAX_TODOS);
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
export function formatTodos(items, { openOnly = false } = {}) {
|
|
1629
|
+
return items
|
|
1630
|
+
.filter((t) => !openOnly || t.status === 'pending' || t.status === 'in_progress')
|
|
1631
|
+
.map((t) => `${TODO_LABEL[t.status]} ${t.text}`)
|
|
1632
|
+
.join('\n');
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
async function todo(args, ctx) {
|
|
1636
|
+
const items = parseTodos(args.items);
|
|
1637
|
+
if (!items.length) return { ok: false, output: 'todo needs <items>: one task per line, like "[ ] write the header" or "[x] read index.html".' };
|
|
1638
|
+
// One thing in progress at a time: keep the first, demote the rest.
|
|
1639
|
+
let seenActive = false;
|
|
1640
|
+
for (const t of items) {
|
|
1641
|
+
if (t.status !== 'in_progress') continue;
|
|
1642
|
+
if (seenActive) t.status = 'pending';
|
|
1643
|
+
seenActive = true;
|
|
1644
|
+
}
|
|
1645
|
+
ctx.todos = items;
|
|
1646
|
+
const count = (s) => items.filter((t) => t.status === s).length;
|
|
1647
|
+
const open = count('pending') + count('in_progress');
|
|
1648
|
+
return {
|
|
1649
|
+
ok: true,
|
|
1650
|
+
output: `Task list (${count('done')} done, ${open} open${count('dropped') ? `, ${count('dropped')} dropped` : ''}):\n${formatTodos(items)}` +
|
|
1651
|
+
(open ? '' : '\nEvery item is closed. Finish with your summary unless something is still unverified.'),
|
|
1652
|
+
meta: { label: `${count('done')}/${items.length - count('dropped')} done`, todos: items },
|
|
1653
|
+
};
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
// ─── Question to the user ───────────────────────────────────────────────────
|
|
1657
|
+
// A real decision only the user can make, asked as a short multiple-choice
|
|
1658
|
+
// question they can answer with one tap (on the PC or the phone). Modeled on
|
|
1659
|
+
// opencode's question tool: short options, the recommended one first, and the
|
|
1660
|
+
// user can always type their own answer instead.
|
|
1661
|
+
|
|
1662
|
+
const MAX_OPTIONS = 5;
|
|
1663
|
+
|
|
1664
|
+
async function ask_user(args, ctx) {
|
|
1665
|
+
const question = String(args.question || '').trim();
|
|
1666
|
+
if (!question) return { ok: false, output: 'ask_user needs a <question>.' };
|
|
1667
|
+
const options = String(args.options || '')
|
|
1668
|
+
.split('\n')
|
|
1669
|
+
.map((l) => l.trim().replace(/^[-*\d.)\s]+/, '').trim())
|
|
1670
|
+
.filter(Boolean)
|
|
1671
|
+
.slice(0, MAX_OPTIONS);
|
|
1672
|
+
if (typeof ctx.ask !== 'function') {
|
|
1673
|
+
return {
|
|
1674
|
+
ok: false,
|
|
1675
|
+
output: 'Asking the user is not available here (unattended run). Pick the most sensible option yourself, say which one and why in your summary, and continue.',
|
|
1676
|
+
};
|
|
1677
|
+
}
|
|
1678
|
+
const answer = await ctx.ask({ question, options });
|
|
1679
|
+
if (answer == null || answer === '') {
|
|
1680
|
+
return { ok: false, output: 'The user dismissed the question without answering. Make a sensible choice yourself, say which in your summary, and continue.', meta: { label: question.slice(0, 80) } };
|
|
1681
|
+
}
|
|
1682
|
+
return {
|
|
1683
|
+
ok: true,
|
|
1684
|
+
output: `The user answered: ${answer}\nFollow that answer.`,
|
|
1685
|
+
meta: { label: question.slice(0, 80), answer: String(answer).slice(0, 200) },
|
|
1686
|
+
};
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
// ─── MCP ────────────────────────────────────────────────────────────────────
|
|
1690
|
+
// Tools from the user's configured MCP servers (lib/mcp.js). Anything that
|
|
1691
|
+
// isn't marked read-only by the server itself asks for approval first.
|
|
1692
|
+
|
|
1693
|
+
async function mcp(args, ctx) {
|
|
1694
|
+
const server = String(args.server || '').trim();
|
|
1695
|
+
const tool = String(args.tool || '').trim();
|
|
1696
|
+
if (!server || !tool) return { ok: false, output: 'mcp needs a <server> and a <tool>.' };
|
|
1697
|
+
let toolArgs = {};
|
|
1698
|
+
if (args.args && String(args.args).trim()) {
|
|
1699
|
+
try { toolArgs = JSON.parse(args.args); }
|
|
1700
|
+
catch { return { ok: false, output: '<args> must be a JSON object, e.g. {"query": "x"}.' }; }
|
|
1701
|
+
}
|
|
1702
|
+
const def = mcpLib.findTool(ctx.cwd, server, tool);
|
|
1703
|
+
if (!def) return { ok: false, output: `No tool "${tool}" on MCP server "${server}". Use one listed under MCP SERVERS.` };
|
|
1704
|
+
const label = `${server}/${tool}`;
|
|
1705
|
+
if (!def.annotations?.readOnlyHint) {
|
|
1706
|
+
const verdict = await ctx.approve({
|
|
1707
|
+
tool: 'mcp',
|
|
1708
|
+
title: `Use ${tool} (${server})`,
|
|
1709
|
+
detail: JSON.stringify(toolArgs).slice(0, 600),
|
|
1710
|
+
danger: !!def.annotations?.destructiveHint,
|
|
1711
|
+
});
|
|
1712
|
+
if (verdict === 'reject') return { ok: false, output: `User declined ${label}.`, meta: { rejected: true, label } };
|
|
1713
|
+
}
|
|
1714
|
+
let r;
|
|
1715
|
+
try { r = await mcpLib.callTool(ctx.cwd, server, tool, toolArgs); }
|
|
1716
|
+
catch (e) { return { ok: false, output: `${label} failed: ${e.message}`, meta: { label } }; }
|
|
1717
|
+
return {
|
|
1718
|
+
ok: !r.isError,
|
|
1719
|
+
output: truncate(`${r.isError ? '[the tool reported an error]\n' : ''}${r.text}`),
|
|
1720
|
+
meta: { label, ...(r.images.length ? { imageDataUrls: r.images.slice(0, 4) } : {}) },
|
|
1721
|
+
};
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
// ─── Web ────────────────────────────────────────────────────────────────────
|
|
1725
|
+
|
|
1726
|
+
async function web_fetch(args, ctx) {
|
|
1727
|
+
const url = String(args.url || '').trim();
|
|
1728
|
+
if (!url) return { ok: false, output: 'web_fetch needs a <url>.' };
|
|
1729
|
+
const format = /^(html|raw)$/i.test(String(args.format || '').trim()) ? String(args.format).trim().toLowerCase() : 'text';
|
|
1730
|
+
const r = await webTools.fetchPage(url, { format, signal: ctx.signal });
|
|
1731
|
+
if (!r.ok) return { ok: false, output: r.error, meta: { label: url } };
|
|
1732
|
+
const note = r.finalUrl && r.finalUrl !== url ? `(redirected to ${r.finalUrl})\n\n` : '';
|
|
1733
|
+
return { ok: true, output: truncate(note + (r.text || '(the page has no text content)')), meta: { label: url } };
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
async function web_search(args, ctx) {
|
|
1737
|
+
const query = String(args.query || '').trim();
|
|
1738
|
+
if (!query) return { ok: false, output: 'web_search needs a <query>.' };
|
|
1739
|
+
const r = await webTools.webSearch(query, { num: Number(args.num) || 8, signal: ctx.signal });
|
|
1740
|
+
if (!r.ok) return { ok: false, output: r.error, meta: { label: query } };
|
|
1741
|
+
return { ok: true, output: truncate(r.text), meta: { label: query } };
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
// ─── lsp ────────────────────────────────────────────────────────────────────
|
|
1745
|
+
|
|
1746
|
+
const LSP_OPS = new Set(['definition', 'references', 'implementation', 'hover', 'documentsymbol', 'workspacesymbol']);
|
|
1747
|
+
const LSP_CANON = { documentsymbol: 'documentSymbol', workspacesymbol: 'workspaceSymbol' };
|
|
1748
|
+
|
|
1749
|
+
async function lsp(args, ctx) {
|
|
1750
|
+
const key = String(args.operation || '').trim().toLowerCase().replace(/[\s_-]/g, '');
|
|
1751
|
+
if (!LSP_OPS.has(key)) return { ok: false, output: 'lsp needs an <operation>: definition, references, implementation, hover, documentSymbol or workspaceSymbol.' };
|
|
1752
|
+
const op = LSP_CANON[key] || key;
|
|
1753
|
+
const req = { op };
|
|
1754
|
+
if (op === 'workspaceSymbol') {
|
|
1755
|
+
if (!String(args.query || args.symbol || '').trim()) return { ok: false, output: 'workspaceSymbol needs a <query>.' };
|
|
1756
|
+
req.query = String(args.query || args.symbol).trim();
|
|
1757
|
+
} else {
|
|
1758
|
+
if (!args.path) return { ok: false, output: `lsp ${op} needs a <path>.` };
|
|
1759
|
+
const { abs, rel, outside } = resolvePath(args.path, ctx.cwd);
|
|
1760
|
+
if (outside) return { ok: false, output: `${rel} is outside the project.` };
|
|
1761
|
+
if (!fs.existsSync(abs)) return { ok: false, output: `No such file: ${rel}.` };
|
|
1762
|
+
req.file = abs;
|
|
1763
|
+
if (op !== 'documentSymbol') {
|
|
1764
|
+
req.line = Number(args.line) || 1;
|
|
1765
|
+
if (args.character) req.character = Number(args.character);
|
|
1766
|
+
if (args.symbol) req.symbol = String(args.symbol).trim();
|
|
1767
|
+
if (!req.character && !req.symbol) return { ok: false, output: `lsp ${op} needs a <symbol> (or a <character> column) to know what to look up.` };
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
const r = await diagnostics.navigate(req, ctx.cwd);
|
|
1771
|
+
const label = op === 'workspaceSymbol' ? `${op} ${req.query}` : `${op} ${resolvePath(args.path, ctx.cwd).rel}${args.symbol ? ` ${args.symbol}` : ''}`;
|
|
1772
|
+
return { ok: r.ok, output: truncate(r.text), meta: { label } };
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
// ─── apply_patch ────────────────────────────────────────────────────────────
|
|
1776
|
+
// Several file changes in one block (add / update / delete / move). Planned
|
|
1777
|
+
// purely first, shown to the user as one approval, then written.
|
|
1778
|
+
|
|
1779
|
+
async function apply_patch(args, ctx) {
|
|
1780
|
+
const patch = args.patch;
|
|
1781
|
+
if (!patch || !String(patch).trim()) return { ok: false, output: 'apply_patch needs a <patch> block starting with "*** Begin Patch".' };
|
|
1782
|
+
|
|
1783
|
+
const readFile = (p) => {
|
|
1784
|
+
const { abs } = resolvePath(p, ctx.cwd);
|
|
1785
|
+
try { return fs.statSync(abs).isFile() ? fs.readFileSync(abs, 'utf8') : null; } catch { return null; }
|
|
1786
|
+
};
|
|
1787
|
+
const plan = applyPatchLib.planPatch(patch, readFile);
|
|
1788
|
+
if (plan.error) return { ok: false, output: `Patch not applied: ${plan.error}` };
|
|
1789
|
+
|
|
1790
|
+
for (const c of plan.changes) {
|
|
1791
|
+
if (c.kind === 'add' || c.kind === 'delete') continue;
|
|
1792
|
+
const { abs } = resolvePath(c.path, ctx.cwd);
|
|
1793
|
+
if (changedSinceSeen(ctx, abs)) {
|
|
1794
|
+
ctx.fileState.delete(abs);
|
|
1795
|
+
return { ok: false, output: `Patch not applied: ${c.path} changed on disk after you last read it. Read it again and rebuild the patch from its current content.` };
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
const resolved = plan.changes.map((c) => ({
|
|
1800
|
+
...c,
|
|
1801
|
+
abs: resolvePath(c.path, ctx.cwd),
|
|
1802
|
+
absNew: c.newPath ? resolvePath(c.newPath, ctx.cwd) : null,
|
|
1803
|
+
}));
|
|
1804
|
+
const label = (c) => (c.kind === 'move' ? `${c.path} -> ${c.newPath}` : c.path);
|
|
1805
|
+
const verb = { add: 'add', update: 'update', delete: 'delete', move: 'move' };
|
|
1806
|
+
const detail = resolved.map((c) => `${verb[c.kind]} ${label(c)} (+${c.added} -${c.removed})`).join('\n');
|
|
1807
|
+
const totalAdded = plan.changes.reduce((n, c) => n + c.added, 0);
|
|
1808
|
+
const totalRemoved = plan.changes.reduce((n, c) => n + c.removed, 0);
|
|
1809
|
+
const danger = resolved.some((c) => c.abs.outside || c.absNew?.outside) || plan.changes.some((c) => c.kind === 'delete');
|
|
1810
|
+
// The approval card shows removed and added lines side by side, as it does for edit_file.
|
|
1811
|
+
const patchLines = (sign) => String(patch).split('\n')
|
|
1812
|
+
.filter((l) => l.startsWith(sign) && !l.startsWith(sign.repeat(3) + ' '))
|
|
1813
|
+
.map((l) => l.slice(1)).join('\n').slice(0, 4000);
|
|
1814
|
+
|
|
1815
|
+
const verdict = await ctx.approve({
|
|
1816
|
+
tool: 'apply_patch',
|
|
1817
|
+
title: plan.changes.length === 1 ? `Patch ${label(plan.changes[0])}` : `Apply patch (${plan.changes.length} files)`,
|
|
1818
|
+
detail,
|
|
1819
|
+
diff: {
|
|
1820
|
+
search: patchLines('-'),
|
|
1821
|
+
replace: patchLines('+'),
|
|
1822
|
+
},
|
|
1823
|
+
danger,
|
|
1824
|
+
});
|
|
1825
|
+
if (verdict === 'reject') return { ok: false, output: 'User declined the patch. Nothing was changed.', meta: { rejected: true } };
|
|
1826
|
+
|
|
1827
|
+
const done = [];
|
|
1828
|
+
const files = [];
|
|
1829
|
+
const changes = [];
|
|
1830
|
+
const problemsByFile = {};
|
|
1831
|
+
try {
|
|
1832
|
+
for (const c of resolved) {
|
|
1833
|
+
if (c.kind === 'delete') {
|
|
1834
|
+
fs.unlinkSync(c.abs.abs);
|
|
1835
|
+
ctx.fileState?.delete(c.abs.abs);
|
|
1836
|
+
done.push(`deleted ${c.path}`);
|
|
1837
|
+
files.push(c.path);
|
|
1838
|
+
changes.push({ path: c.path, kind: 'delete', added: 0, removed: c.removed });
|
|
1839
|
+
continue;
|
|
1840
|
+
}
|
|
1841
|
+
const dest = c.kind === 'move' ? c.absNew : c.abs;
|
|
1842
|
+
fs.mkdirSync(path.dirname(dest.abs), { recursive: true });
|
|
1843
|
+
fs.writeFileSync(dest.abs, c.after, 'utf8');
|
|
1844
|
+
if (c.kind === 'move' && c.abs.abs !== dest.abs) {
|
|
1845
|
+
fs.unlinkSync(c.abs.abs);
|
|
1846
|
+
ctx.fileState?.delete(c.abs.abs);
|
|
1847
|
+
}
|
|
1848
|
+
rememberDiskState(ctx, dest.abs);
|
|
1849
|
+
const shown = c.kind === 'move' ? c.newPath : c.path;
|
|
1850
|
+
done.push(c.kind === 'add' ? `added ${shown}` : c.kind === 'move' ? `moved ${c.path} to ${c.newPath}` : `updated ${shown}`);
|
|
1851
|
+
files.push(shown);
|
|
1852
|
+
changes.push({ path: shown, kind: c.kind, added: c.added, removed: c.removed });
|
|
1853
|
+
const problems = await diagnostics.checkFile(dest.abs, ctx.cwd);
|
|
1854
|
+
if (problems.length) problemsByFile[shown] = problems;
|
|
1855
|
+
}
|
|
1856
|
+
} catch (e) {
|
|
1857
|
+
return {
|
|
1858
|
+
ok: false,
|
|
1859
|
+
output: `Patch failed part-way: ${e.message}. Done so far: ${done.join('; ') || 'nothing'}. Check the project state with list_dir/read_file before retrying.`,
|
|
1860
|
+
meta: { files, changes, wrote: files.length > 0 },
|
|
1861
|
+
};
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
const problemText = Object.entries(problemsByFile).map(([f, p]) => problemsNote(f, p)).join('');
|
|
1865
|
+
return {
|
|
1866
|
+
ok: true,
|
|
1867
|
+
output: `Patch applied: ${done.join('; ')}. The changes succeeded exactly as sent - do NOT read the files back to confirm.` + problemText,
|
|
1868
|
+
meta: { label: files.length === 1 ? files[0] : `${files.length} files`, added: totalAdded, removed: totalRemoved, wrote: true, files, changes, problems: Object.values(problemsByFile).flat(), problemsByFile },
|
|
1869
|
+
};
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
// ─── Plan mode ──────────────────────────────────────────────────────────────
|
|
1873
|
+
// Plan mode writes its plan to .codeply/plans/<slug>.md, then plan_exit asks
|
|
1874
|
+
// the user to switch to Build. plan_enter is the reverse, and always asks.
|
|
1875
|
+
|
|
1876
|
+
function latestPlanFile(cwd) {
|
|
1877
|
+
const dir = path.join(cwd, PLAN_DIR);
|
|
1878
|
+
let best = null;
|
|
1879
|
+
try {
|
|
1880
|
+
for (const f of fs.readdirSync(dir)) {
|
|
1881
|
+
if (!/\.md$/i.test(f)) continue;
|
|
1882
|
+
const m = fs.statSync(path.join(dir, f)).mtimeMs;
|
|
1883
|
+
if (!best || m > best.m) best = { f: `${PLAN_DIR}/${f}`, m };
|
|
1884
|
+
}
|
|
1885
|
+
} catch {}
|
|
1886
|
+
return best ? best.f : null;
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
async function plan_exit(args, ctx) {
|
|
1890
|
+
if (ctx.mode !== 'Plan') return { ok: false, output: 'plan_exit only works in Plan mode.' };
|
|
1891
|
+
let rel = args.path ? resolvePath(String(args.path).trim(), ctx.cwd).rel : latestPlanFile(ctx.cwd);
|
|
1892
|
+
if (!rel || !isPlanPath(rel, ctx.cwd) || !fs.existsSync(resolvePath(rel, ctx.cwd).abs)) {
|
|
1893
|
+
return { ok: false, output: `Write your plan to ${PLAN_DIR}/<short-name>.md with write_file first, then call plan_exit.` };
|
|
1894
|
+
}
|
|
1895
|
+
if (typeof ctx.ask !== 'function') {
|
|
1896
|
+
return { ok: false, output: `Switching modes needs the user's answer, and asking is not available here. The plan is saved at ${rel}; stay in Plan mode and summarise it.`, meta: { label: rel } };
|
|
1897
|
+
}
|
|
1898
|
+
const answer = await ctx.ask({
|
|
1899
|
+
question: `The plan is ready (${rel}). Switch to Build mode and start implementing it?`,
|
|
1900
|
+
options: ['Yes, start building (Recommended)', 'No, keep planning'],
|
|
1901
|
+
});
|
|
1902
|
+
if (answer != null && /^\s*yes/i.test(String(answer))) {
|
|
1903
|
+
return {
|
|
1904
|
+
ok: true,
|
|
1905
|
+
output: `The user approved the plan. You are now in Build mode. Implement the plan in ${rel}: read it, then work through it, ticking off each step.`,
|
|
1906
|
+
meta: { label: rel, switchMode: 'Build', planPath: rel },
|
|
1907
|
+
};
|
|
1908
|
+
}
|
|
1909
|
+
const feedback = answer && !/^\s*no\b/i.test(String(answer)) ? ` The user said: ${answer}` : '';
|
|
1910
|
+
return { ok: true, output: `The user wants to keep planning.${feedback} Revise the plan file and call plan_exit again when it is ready.`, meta: { label: rel } };
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
async function plan_enter(args, ctx) {
|
|
1914
|
+
if (ctx.mode !== 'Build') return { ok: false, output: 'plan_enter only works in Build mode.' };
|
|
1915
|
+
if (typeof ctx.ask !== 'function') return { ok: false, output: 'Switching modes needs the user\'s answer, and asking is not available here. Carry on in Build mode.' };
|
|
1916
|
+
const reason = String(args.reason || '').trim();
|
|
1917
|
+
const answer = await ctx.ask({
|
|
1918
|
+
question: `Switch to Plan mode${reason ? ` (${reason})` : ''}? Nothing will be changed until you approve a plan.`,
|
|
1919
|
+
options: ['Yes, plan first', 'No, keep building (Recommended)'],
|
|
1920
|
+
});
|
|
1921
|
+
if (answer != null && /^\s*yes/i.test(String(answer))) {
|
|
1922
|
+
return { ok: true, output: `You are now in Plan mode (read-only). Explore, write the plan to ${PLAN_DIR}/<short-name>.md, then call plan_exit.`, meta: { label: 'plan', switchMode: 'Plan' } };
|
|
1923
|
+
}
|
|
1924
|
+
return { ok: true, output: 'The user wants to keep building. Carry on in Build mode.', meta: { label: 'plan' } };
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
export const TOOLS = {
|
|
1928
|
+
todo,
|
|
1929
|
+
ask_user,
|
|
1930
|
+
mcp,
|
|
1931
|
+
web_fetch, web_search, apply_patch, plan_exit, plan_enter, lsp,
|
|
1932
|
+
list_dir, read_file, write_file, edit_file, search, run, use_skill, list_skills, fetch_image, browser_check,
|
|
1933
|
+
gmail_send, gmail_search, slack_post_message, vercel_deploy, supabase_create_project, supabase_delete_project, github_create_repo,
|
|
1934
|
+
design_reference_search, view_images, supabase_api, supabase_sql, vercel_api,
|
|
1935
|
+
};
|
|
1936
|
+
|
|
1937
|
+
// Tools that can change something. supabase_api/supabase_sql/vercel_api only
|
|
1938
|
+
// actually prompt for non-read requests (see above); read-only tools
|
|
1939
|
+
// (browser_check, gmail_search, design_reference_search, view_images, ...)
|
|
1940
|
+
// never prompt.
|
|
1941
|
+
export const TOOL_NEEDS_APPROVAL = new Set(['write_file', 'edit_file', 'apply_patch', 'run', 'fetch_image', 'gmail_send', 'slack_post_message', 'vercel_deploy', 'supabase_create_project', 'supabase_delete_project', 'github_create_repo', 'supabase_api', 'supabase_sql', 'vercel_api']);
|
|
391
1942
|
|
|
392
1943
|
/** Human-facing verb + colour hint for the transcript. */
|
|
393
1944
|
export const TOOL_DISPLAY = {
|
|
394
|
-
|
|
1945
|
+
todo: { verb: 'plan', icon: '☐' },
|
|
1946
|
+
ask_user: { verb: 'ask', icon: '?' },
|
|
1947
|
+
mcp: { verb: 'mcp', icon: '⧉' },
|
|
1948
|
+
web_fetch: { verb: 'fetch', icon: '⇩' },
|
|
1949
|
+
web_search: { verb: 'web', icon: '▸' },
|
|
1950
|
+
apply_patch:{ verb: 'patch', icon: '✎' },
|
|
1951
|
+
plan_exit: { verb: 'plan', icon: '☐' },
|
|
1952
|
+
plan_enter: { verb: 'plan', icon: '☐' },
|
|
1953
|
+
lsp: { verb: 'lsp', icon: '▸' },
|
|
1954
|
+
list_dir: { verb: 'list', icon: '▸' },
|
|
395
1955
|
read_file: { verb: 'read', icon: '▸' },
|
|
396
1956
|
write_file: { verb: 'write', icon: '✎' },
|
|
397
1957
|
edit_file: { verb: 'edit', icon: '✎' },
|
|
@@ -399,30 +1959,49 @@ export const TOOL_DISPLAY = {
|
|
|
399
1959
|
run: { verb: 'run', icon: '$' },
|
|
400
1960
|
use_skill: { verb: 'skill', icon: '★' },
|
|
401
1961
|
list_skills:{ verb: 'skills', icon: '★' },
|
|
1962
|
+
fetch_image:{ verb: 'fetch', icon: '⇩' },
|
|
1963
|
+
browser_check:{ verb: 'check', icon: '◎' },
|
|
1964
|
+
gmail_send: { verb: 'email', icon: '✉' },
|
|
1965
|
+
gmail_search:{ verb: 'search', icon: '✉' },
|
|
1966
|
+
slack_post_message:{ verb: 'post', icon: '#' },
|
|
1967
|
+
vercel_deploy:{ verb: 'deploy', icon: '▲' },
|
|
1968
|
+
supabase_create_project:{ verb: 'provision', icon: '◆' },
|
|
1969
|
+
supabase_delete_project:{ verb: 'delete', icon: '◆' },
|
|
1970
|
+
github_create_repo:{ verb: 'push', icon: '⎇' },
|
|
1971
|
+
design_reference_search:{ verb: 'reference', icon: '◫' },
|
|
1972
|
+
view_images:{ verb: 'view', icon: '◉' },
|
|
1973
|
+
supabase_api:{ verb: 'supabase', icon: '◆' },
|
|
1974
|
+
supabase_sql:{ verb: 'sql', icon: '◆' },
|
|
1975
|
+
vercel_api:{ verb: 'vercel', icon: '▲' },
|
|
402
1976
|
};
|
|
403
1977
|
|
|
1978
|
+
// Plan and Ask are read-only. write_file/edit_file are also refused in
|
|
1979
|
+
// agent.mjs; this covers every other tool that changes something outside the
|
|
1980
|
+
// conversation, so read-only can't be escaped through a side door.
|
|
1981
|
+
const MUTATING_TOOLS = new Set([
|
|
1982
|
+
'write_file', 'edit_file', 'apply_patch', 'fetch_image', 'gmail_send', 'slack_post_message',
|
|
1983
|
+
'vercel_deploy', 'supabase_create_project', 'supabase_delete_project', 'github_create_repo',
|
|
1984
|
+
]);
|
|
1985
|
+
|
|
404
1986
|
export async function executeTool(name, args, ctx) {
|
|
405
1987
|
const fn = TOOLS[name];
|
|
406
1988
|
if (!fn) {
|
|
407
1989
|
return { ok: false, output: `Unknown tool "${name}". Available: ${Object.keys(TOOLS).join(', ')}.` };
|
|
408
1990
|
}
|
|
409
1991
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
const capped = CAPPED_TOOLS.has(name) && config.getConfig().provider === 'codeply';
|
|
414
|
-
if (capped) {
|
|
415
|
-
const limit = await applyLimit.checkApplyLimit();
|
|
416
|
-
if (!limit.allowed) {
|
|
417
|
-
const base = limit.error || `Daily apply limit reached (${limit.count}/${limit.limit}). This is shared with the Codeply desktop app and resets at midnight UTC.`;
|
|
418
|
-
return { ok: false, output: `${base} ${config.byokHint()}` };
|
|
419
|
-
}
|
|
1992
|
+
const planFileWrite = ctx.mode === 'Plan' && (name === 'write_file' || name === 'edit_file') && isPlanPath(args.path, ctx.cwd);
|
|
1993
|
+
if (READ_ONLY_MODES.has(ctx.mode) && MUTATING_TOOLS.has(name) && !planFileWrite) {
|
|
1994
|
+
return { ok: false, output: `Blocked - ${ctx.mode} mode is read-only, so ${name} is not available. Describe what you would do instead, or tell the user to switch to Build mode.` };
|
|
420
1995
|
}
|
|
421
1996
|
|
|
1997
|
+
const usingHosted = ctx.route ? !!ctx.route.auto : config.getConfig().provider === 'codeply';
|
|
1998
|
+
|
|
422
1999
|
try {
|
|
423
2000
|
const result = await fn(args, ctx);
|
|
424
|
-
if (
|
|
425
|
-
|
|
2001
|
+
if (usingHosted && RECORDED_TOOLS.has(name) && result.ok && result.meta?.wrote) {
|
|
2002
|
+
applyLimit.recordApplyEvent(args.path, result.meta.added, result.meta.removed); // fire-and-forget
|
|
2003
|
+
} else if (usingHosted && name === 'apply_patch' && result.meta?.changes) {
|
|
2004
|
+
for (const c of result.meta.changes) applyLimit.recordApplyEvent(c.path, c.added, c.removed);
|
|
426
2005
|
}
|
|
427
2006
|
return result;
|
|
428
2007
|
} catch (e) {
|
|
@@ -430,4 +2009,4 @@ export async function executeTool(name, args, ctx) {
|
|
|
430
2009
|
}
|
|
431
2010
|
}
|
|
432
2011
|
|
|
433
|
-
export { resolvePath, walkFiles, truncate };
|
|
2012
|
+
export { resolvePath, walkFiles, truncate, isPlanPath };
|