codeply-cli 0.1.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.
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Multiline composer input.
3
+ *
4
+ * ink-text-input is single-line and submits on any \r, which also means pasting
5
+ * a code snippet fires a submit at the first newline. This replaces it.
6
+ *
7
+ * Getting a real shift+enter out of a terminal is the awkward part: by default
8
+ * both enter and shift+enter send the same byte (\r), so they are physically
9
+ * indistinguishable. We enable the kitty keyboard protocol's disambiguation
10
+ * flag on mount (\x1b[>1u), which makes modified enter arrive as CSI 13;<mod>u
11
+ * while leaving ordinary typing alone. Terminals that don't speak it ignore the
12
+ * sequence, so the fallbacks below carry them:
13
+ *
14
+ * shift+enter real, when the terminal supports CSI-u → newline
15
+ * alt+enter arrives as ESC \r → newline
16
+ * ctrl+j arrives as \n, always distinct from \r → newline
17
+ * trailing \ backslash at end of line, then enter → newline
18
+ * enter → submit
19
+ *
20
+ * Ink reports \r as key.return with input '\r'. \n parses as name 'enter', so
21
+ * key.return is false — that difference is what makes ctrl+j detectable.
22
+ */
23
+ import React from 'react';
24
+ import { Box, Text, useInput } from 'ink';
25
+
26
+ const KITTY_ON = '\x1b[>1u';
27
+ const KITTY_OFF = '\x1b[<u';
28
+ const KITTY_QUERY = '\x1b[?u';
29
+
30
+ // Replies the terminal sends back to our own queries. They arrive on stdin
31
+ // exactly like typed text, so without this they get inserted into the draft.
32
+ const TERMINAL_REPORT = /^\[[?0-9;]*[uRc]$/;
33
+
34
+ const INVERSE = '\x1b[7m'; // the cursor block
35
+ const INVERSE_OFF = '\x1b[27m';
36
+ const GREY = '\x1b[90m'; // placeholder
37
+ const GREY_OFF = '\x1b[39m';
38
+
39
+ // CSI 13;<modifier>u — enter with any modifier held.
40
+ const CSI_MODIFIED_ENTER = /^\[13;\d+u$/;
41
+
42
+ /**
43
+ * Ask the terminal for kitty key disambiguation, then ask whether it took.
44
+ *
45
+ * Windows Terminal does not implement this protocol (it has its own
46
+ * win32-input-mode instead), so on Windows the query simply goes unanswered and
47
+ * shift+enter stays physically identical to enter. That is why the UI reports
48
+ * whichever newline key actually works rather than promising shift+enter
49
+ * everywhere — see `modifiedEnterWorks` in tui.mjs.
50
+ */
51
+ export function enableModifiedEnter(stdout) {
52
+ if (!stdout || !stdout.isTTY) return () => {};
53
+ let restored = false;
54
+ const restore = () => {
55
+ if (restored) return;
56
+ restored = true;
57
+ try { stdout.write(KITTY_OFF); } catch {}
58
+ };
59
+ try {
60
+ stdout.write(KITTY_ON);
61
+ stdout.write(KITTY_QUERY); // a reply means the protocol is really supported
62
+ } catch {}
63
+ process.once('exit', restore);
64
+ return restore;
65
+ }
66
+
67
+ /** True for a terminal's answer to one of our capability queries. */
68
+ export function isTerminalReport(input) {
69
+ return TERMINAL_REPORT.test(input);
70
+ }
71
+
72
+ /** Split a string into rendered rows, wrapping each logical line at `width`. */
73
+ function layout(value, width) {
74
+ const rows = [];
75
+ const logical = value.split('\n');
76
+ for (let li = 0; li < logical.length; li++) {
77
+ const line = logical[li];
78
+ let start = 0;
79
+ do {
80
+ const chunk = line.slice(start, start + width);
81
+ rows.push({ line: li, start, text: chunk });
82
+ start += width;
83
+ } while (start < line.length);
84
+ }
85
+ return rows;
86
+ }
87
+
88
+ /** Absolute offset of the start of logical line `n`. */
89
+ function lineOffset(value, n) {
90
+ let offset = 0;
91
+ const lines = value.split('\n');
92
+ for (let i = 0; i < n; i++) offset += lines[i].length + 1;
93
+ return offset;
94
+ }
95
+
96
+ export default function TextArea({
97
+ value,
98
+ onChange,
99
+ onSubmit,
100
+ placeholder = '',
101
+ width = 60,
102
+ focus = true,
103
+ showCursor = true,
104
+ // When the command palette is open it owns up/down for selection, so the
105
+ // composer must not also move its cursor on those keys.
106
+ allowVertical = true,
107
+ }) {
108
+ const [cursor, setCursor] = React.useState(value.length);
109
+
110
+ // Keep the cursor inside the value when it changes from outside (e.g. a
111
+ // command completion replacing the draft).
112
+ React.useEffect(() => {
113
+ setCursor((c) => Math.min(c, value.length));
114
+ }, [value]);
115
+
116
+ const apply = React.useCallback((next, nextCursor) => {
117
+ onChange(next);
118
+ setCursor(Math.max(0, Math.min(nextCursor, next.length)));
119
+ }, [onChange]);
120
+
121
+ const insert = React.useCallback((text) => {
122
+ const next = value.slice(0, cursor) + text + value.slice(cursor);
123
+ apply(next, cursor + text.length);
124
+ }, [value, cursor, apply]);
125
+
126
+ const newline = React.useCallback(() => insert('\n'), [insert]);
127
+
128
+ useInput((input, key) => {
129
+ if (!focus) return;
130
+
131
+ // ── newline vs submit ──
132
+ if (CSI_MODIFIED_ENTER.test(input)) { newline(); return; }
133
+ // Capability replies must never land in the draft as literal text.
134
+ if (TERMINAL_REPORT.test(input)) return;
135
+ if (key.return && key.shift) { newline(); return; }
136
+ if (!key.return && (input === '\r' || input === '\n')) { newline(); return; } // alt+enter, ctrl+j
137
+
138
+ if (key.return) {
139
+ // Trailing backslash is the universal fallback for terminals that send
140
+ // the same byte for enter and shift+enter.
141
+ if (value.slice(0, cursor).endsWith('\\')) {
142
+ apply(value.slice(0, cursor - 1) + '\n' + value.slice(cursor), cursor);
143
+ return;
144
+ }
145
+ onSubmit(value);
146
+ return;
147
+ }
148
+
149
+ // ── multi-character input is a paste ──
150
+ if (input.length > 1 && !key.ctrl && !key.meta) {
151
+ insert(input.replace(/\r\n?/g, '\n'));
152
+ return;
153
+ }
154
+
155
+ // ── editing ──
156
+ if (key.backspace || (key.delete && !key.meta)) {
157
+ // Ink reports the DEL byte (0x7f) as `delete`, which is what most
158
+ // terminals actually send for the backspace key.
159
+ if (cursor > 0) apply(value.slice(0, cursor - 1) + value.slice(cursor), cursor - 1);
160
+ return;
161
+ }
162
+
163
+ if (key.ctrl) {
164
+ switch (input) {
165
+ case 'a': setCursor(lineOffset(value, value.slice(0, cursor).split('\n').length - 1)); return;
166
+ case 'e': {
167
+ const lines = value.split('\n');
168
+ const li = value.slice(0, cursor).split('\n').length - 1;
169
+ setCursor(lineOffset(value, li) + lines[li].length);
170
+ return;
171
+ }
172
+ case 'k': {
173
+ const rest = value.slice(cursor);
174
+ const nl = rest.indexOf('\n');
175
+ apply(value.slice(0, cursor) + (nl === -1 ? '' : rest.slice(nl)), cursor);
176
+ return;
177
+ }
178
+ case 'u': {
179
+ const li = value.slice(0, cursor).split('\n').length - 1;
180
+ const start = lineOffset(value, li);
181
+ apply(value.slice(0, start) + value.slice(cursor), start);
182
+ return;
183
+ }
184
+ case 'w': {
185
+ const before = value.slice(0, cursor);
186
+ const trimmed = before.replace(/\s*\S+$/, '');
187
+ apply(trimmed + value.slice(cursor), trimmed.length);
188
+ return;
189
+ }
190
+ default: return;
191
+ }
192
+ }
193
+
194
+ // ── movement ──
195
+ if (key.leftArrow) { setCursor((c) => Math.max(0, c - 1)); return; }
196
+ if (key.rightArrow) { setCursor((c) => Math.min(value.length, c + 1)); return; }
197
+
198
+ if (key.upArrow || key.downArrow) {
199
+ if (!allowVertical) return;
200
+ const lines = value.split('\n');
201
+ const li = value.slice(0, cursor).split('\n').length - 1;
202
+ const col = cursor - lineOffset(value, li);
203
+ const target = key.upArrow ? li - 1 : li + 1;
204
+ if (target < 0 || target >= lines.length) return;
205
+ setCursor(lineOffset(value, target) + Math.min(col, lines[target].length));
206
+ return;
207
+ }
208
+
209
+ // ── plain character ──
210
+ if (input && !key.meta && !key.ctrl && !key.tab && !key.escape) {
211
+ insert(input);
212
+ }
213
+ }, { isActive: focus });
214
+
215
+ // ── render ──
216
+ const w = Math.max(8, width);
217
+
218
+ if (value.length === 0) {
219
+ return React.createElement(
220
+ Text,
221
+ null,
222
+ showCursor && focus
223
+ ? INVERSE + (placeholder[0] || ' ') + INVERSE_OFF +
224
+ GREY + placeholder.slice(1) + GREY_OFF
225
+ : GREY + placeholder + GREY_OFF
226
+ );
227
+ }
228
+
229
+ const rows = layout(value, w);
230
+ const lines = value.split('\n');
231
+ const curLine = value.slice(0, cursor).split('\n').length - 1;
232
+ const curCol = cursor - lineOffset(value, curLine);
233
+
234
+ return React.createElement(
235
+ Box,
236
+ { flexDirection: 'column' },
237
+ ...rows.map((row, i) => {
238
+ const isCursorRow =
239
+ focus && showCursor &&
240
+ row.line === curLine &&
241
+ curCol >= row.start &&
242
+ (curCol < row.start + w || (row.start + w >= lines[row.line].length && curCol <= lines[row.line].length));
243
+
244
+ if (!isCursorRow) {
245
+ return React.createElement(Text, { key: i }, row.text || ' ');
246
+ }
247
+ const at = curCol - row.start;
248
+ const before = row.text.slice(0, at);
249
+ const under = row.text[at] ?? ' ';
250
+ const after = row.text.slice(at + 1);
251
+ return React.createElement(
252
+ Text,
253
+ { key: i },
254
+ before + INVERSE + under + INVERSE_OFF + after
255
+ );
256
+ })
257
+ );
258
+ }
package/lib/tools.mjs ADDED
@@ -0,0 +1,376 @@
1
+ /**
2
+ * Codeply agent tools.
3
+ *
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 —
6
+ * an untruncated file read is the fastest way to blow the context budget and
7
+ * make the agent stupid halfway through a task.
8
+ *
9
+ * Anything that changes the user's machine (write_file, edit_file, run) goes
10
+ * through ctx.approve() first and never side-steps it.
11
+ */
12
+ import fs from 'fs';
13
+ import path from 'path';
14
+ import os from 'os';
15
+ import { exec } from 'child_process';
16
+ import { createRequire } from 'module';
17
+
18
+ const require = createRequire(import.meta.url);
19
+ const editEngine = require('./edit-engine.js');
20
+ const applyLimit = require('./apply-limit.js');
21
+ const config = require('./config.js');
22
+
23
+ // A write is a real "apply" against the shared 100/day cap only when it's
24
+ // actually spending the shared codeply proxy — Ollama (local or the user's
25
+ // own cloud key) never touches Codeply's account/infrastructure, so it isn't
26
+ // gated by it.
27
+ const CAPPED_TOOLS = new Set(['write_file', 'edit_file']);
28
+
29
+ const MAX_TOOL_OUTPUT = 12000; // chars fed back to the model per tool call
30
+ const MAX_READ_LINES = 600;
31
+ const MAX_SEARCH_HITS = 60;
32
+ const RUN_TIMEOUT_MS = 120000;
33
+
34
+ const IGNORED_DIRS = new Set([
35
+ 'node_modules', '.git', 'dist', 'build', '.next', 'out', 'coverage',
36
+ '.cache', '.turbo', 'vendor', '__pycache__', '.venv', 'venv', 'target',
37
+ ]);
38
+
39
+ const TEXT_EXT = new Set([
40
+ '.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx', '.json', '.html', '.htm',
41
+ '.css', '.scss', '.sass', '.less', '.md', '.txt', '.py', '.rb', '.go',
42
+ '.rs', '.java', '.c', '.h', '.cpp', '.hpp', '.cs', '.php', '.sh', '.yml',
43
+ '.yaml', '.toml', '.xml', '.svg', '.vue', '.svelte', '.sql', '.env',
44
+ ]);
45
+
46
+ function truncate(text, max = MAX_TOOL_OUTPUT) {
47
+ if (text.length <= max) return text;
48
+ const kept = text.slice(0, max);
49
+ return `${kept}\n\n[… truncated, ${text.length - max} more characters. Narrow the request if you need the rest.]`;
50
+ }
51
+
52
+ /** Resolve a model-supplied path against cwd and report whether it escapes it. */
53
+ function resolvePath(p, cwd) {
54
+ const abs = path.isAbsolute(p) ? path.normalize(p) : path.resolve(cwd, p);
55
+ const rel = path.relative(cwd, abs);
56
+ const outside = rel.startsWith('..') || path.isAbsolute(rel);
57
+ return { abs, rel: outside ? abs : rel.replace(/\\/g, '/') || '.', outside };
58
+ }
59
+
60
+ /** Minimal glob -> RegExp. `**` crosses separators, `*` and `?` do not. */
61
+ function globToRegex(glob) {
62
+ let re = '';
63
+ for (let i = 0; i < glob.length; i++) {
64
+ const c = glob[i];
65
+ if (c === '*') {
66
+ if (glob[i + 1] === '*') {
67
+ i++;
68
+ if (glob[i + 1] === '/') { i++; re += '(?:.*/)?'; }
69
+ else re += '.*';
70
+ } else {
71
+ re += '[^/]*';
72
+ }
73
+ } else if (c === '?') {
74
+ re += '[^/]';
75
+ } else if ('.+^${}()|[]'.includes(c) || c === '\\') {
76
+ re += '\\' + c;
77
+ } else {
78
+ re += c;
79
+ }
80
+ }
81
+ return new RegExp(`^${re}$`, 'i');
82
+ }
83
+
84
+ /** Depth-first file walk that skips build/vendor noise and bails out early. */
85
+ function walkFiles(root, { maxFiles = 4000, maxDepth = 12 } = {}) {
86
+ const out = [];
87
+ const stack = [{ dir: root, depth: 0 }];
88
+ while (stack.length && out.length < maxFiles) {
89
+ const { dir, depth } = stack.pop();
90
+ if (depth > maxDepth) continue;
91
+ let entries;
92
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
93
+ for (const entry of entries) {
94
+ if (entry.name.startsWith('.') && entry.name !== '.env') {
95
+ if (entry.isDirectory()) continue;
96
+ }
97
+ const full = path.join(dir, entry.name);
98
+ if (entry.isDirectory()) {
99
+ if (IGNORED_DIRS.has(entry.name)) continue;
100
+ stack.push({ dir: full, depth: depth + 1 });
101
+ } else if (entry.isFile()) {
102
+ out.push(full);
103
+ if (out.length >= maxFiles) break;
104
+ }
105
+ }
106
+ }
107
+ return out;
108
+ }
109
+
110
+ // ─── Tools ──────────────────────────────────────────────────────────────────
111
+
112
+ async function list_dir(args, ctx) {
113
+ const target = args.path || '.';
114
+ const { abs, rel } = resolvePath(target, ctx.cwd);
115
+ if (!fs.existsSync(abs)) return { ok: false, output: `No such directory: ${rel}` };
116
+ if (!fs.statSync(abs).isDirectory()) return { ok: false, output: `Not a directory: ${rel}` };
117
+
118
+ let entries;
119
+ try { entries = fs.readdirSync(abs, { withFileTypes: true }); }
120
+ catch (e) { return { ok: false, output: `Cannot read ${rel}: ${e.message}` }; }
121
+
122
+ const dirs = [];
123
+ const files = [];
124
+ for (const e of entries) {
125
+ if (e.isDirectory()) {
126
+ dirs.push(IGNORED_DIRS.has(e.name) ? `${e.name}/ (skipped)` : `${e.name}/`);
127
+ } else if (e.isFile()) {
128
+ let size = '';
129
+ try { size = ` ${fs.statSync(path.join(abs, e.name)).size}b`; } catch {}
130
+ files.push(`${e.name}${size}`);
131
+ }
132
+ }
133
+ dirs.sort(); files.sort();
134
+ const body = [...dirs, ...files].join('\n') || '(empty)';
135
+ return { ok: true, output: truncate(`${rel}/\n${body}`), meta: { label: rel, count: dirs.length + files.length } };
136
+ }
137
+
138
+ async function read_file(args, ctx) {
139
+ const target = args.path;
140
+ if (!target) return { ok: false, output: 'read_file needs a <path>.' };
141
+ const { abs, rel } = resolvePath(target, ctx.cwd);
142
+ if (!fs.existsSync(abs)) return { ok: false, output: `No such file: ${rel}` };
143
+ if (fs.statSync(abs).isDirectory()) return { ok: false, output: `${rel} is a directory — use list_dir.` };
144
+
145
+ let content;
146
+ try { content = fs.readFileSync(abs, 'utf8'); }
147
+ catch (e) { return { ok: false, output: `Cannot read ${rel}: ${e.message}` }; }
148
+
149
+ const allLines = content.split('\n');
150
+ const offset = Math.max(0, parseInt(args.offset, 10) || 0);
151
+ const limit = Math.min(parseInt(args.limit, 10) || MAX_READ_LINES, MAX_READ_LINES);
152
+ const slice = allLines.slice(offset, offset + limit);
153
+ const width = String(offset + slice.length).length;
154
+
155
+ const numbered = slice
156
+ .map((l, i) => `${String(offset + i + 1).padStart(width)}│${l}`)
157
+ .join('\n');
158
+ const more = allLines.length > offset + slice.length
159
+ ? `\n[… ${allLines.length - offset - slice.length} more lines. Re-read with offset=${offset + slice.length}.]`
160
+ : '';
161
+
162
+ return {
163
+ ok: true,
164
+ output: truncate(`${rel} (${allLines.length} lines)\n${numbered}${more}`),
165
+ meta: { label: rel, count: allLines.length },
166
+ };
167
+ }
168
+
169
+ async function write_file(args, ctx) {
170
+ const target = args.path;
171
+ if (!target) return { ok: false, output: 'write_file needs a <path>.' };
172
+ if (args.content == null) return { ok: false, output: 'write_file needs a <content> block.' };
173
+
174
+ const { abs, rel, outside } = resolvePath(target, ctx.cwd);
175
+ const existed = fs.existsSync(abs);
176
+ const before = existed ? fs.readFileSync(abs, 'utf8') : '';
177
+ const after = args.content;
178
+ if (existed && before === after) {
179
+ return { ok: true, output: `${rel} already has exactly this content — nothing written.`, meta: { label: rel, noop: true } };
180
+ }
181
+
182
+ const beforeLines = existed ? before.split('\n').length : 0;
183
+ const afterLines = after.split('\n').length;
184
+
185
+ const verdict = await ctx.approve({
186
+ tool: 'write_file',
187
+ title: existed ? `Overwrite ${rel}` : `Create ${rel}`,
188
+ detail: existed
189
+ ? `${beforeLines} lines → ${afterLines} lines`
190
+ : `${afterLines} lines`,
191
+ danger: outside,
192
+ });
193
+ if (verdict === 'reject') return { ok: false, output: `User declined the write to ${rel}.`, meta: { rejected: true } };
194
+
195
+ try {
196
+ const dir = path.dirname(abs);
197
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
198
+ fs.writeFileSync(abs, after, 'utf8');
199
+ } catch (e) {
200
+ return { ok: false, output: `Cannot write ${rel}: ${e.message}` };
201
+ }
202
+
203
+ return {
204
+ ok: true,
205
+ output: `Wrote ${rel} (${afterLines} lines). The write succeeded exactly as you sent it — ` +
206
+ `do NOT read the file back to confirm it.`,
207
+ meta: { label: rel, added: afterLines, removed: beforeLines, wrote: true },
208
+ };
209
+ }
210
+
211
+ async function edit_file(args, ctx) {
212
+ const target = args.path;
213
+ if (!target) return { ok: false, output: 'edit_file needs a <path>.' };
214
+ if (!args.search) return { ok: false, output: 'edit_file needs a <search> block.' };
215
+ if (args.replace == null) return { ok: false, output: 'edit_file needs a <replace> block.' };
216
+
217
+ const { abs, rel, outside } = resolvePath(target, ctx.cwd);
218
+ if (!fs.existsSync(abs)) return { ok: false, output: `No such file: ${rel}. Use write_file to create it.` };
219
+
220
+ const before = fs.readFileSync(abs, 'utf8');
221
+ // Dry-run through the same matcher that will do the real edit, so the user
222
+ // is never asked to approve something that turns out not to apply.
223
+ const attempt = editEngine.applySearchReplace(before, args.search, args.replace);
224
+ if (!attempt.ok) {
225
+ const why = attempt.error === 'notfound'
226
+ ? 'that exact text is not in the file — re-read it and copy the block verbatim'
227
+ : attempt.error === 'multiple'
228
+ ? 'that text appears more than once — include more surrounding lines to make it unique'
229
+ : 'the search block was empty';
230
+ return { ok: false, output: `Edit to ${rel} did not apply: ${why}.` };
231
+ }
232
+
233
+ const removed = args.search.split('\n').length;
234
+ const added = args.replace.split('\n').length;
235
+
236
+ const verdict = await ctx.approve({
237
+ tool: 'edit_file',
238
+ title: `Edit ${rel}`,
239
+ detail: `-${removed} +${added} lines`,
240
+ diff: { search: args.search, replace: args.replace },
241
+ danger: outside,
242
+ });
243
+ if (verdict === 'reject') return { ok: false, output: `User declined the edit to ${rel}.`, meta: { rejected: true } };
244
+
245
+ try { fs.writeFileSync(abs, attempt.content, 'utf8'); }
246
+ catch (e) { return { ok: false, output: `Cannot write ${rel}: ${e.message}` }; }
247
+
248
+ const nowLines = attempt.content.split('\n').length;
249
+ return {
250
+ ok: true,
251
+ // The "do not re-read" line is load-bearing: without it the model burns a
252
+ // whole extra round-trip reading the file back, and stuffs the entire file
253
+ // into context again, which slows down every following step.
254
+ output: `Edited ${rel} (-${removed} +${added} lines). The file is now ${nowLines} lines. ` +
255
+ `The change was matched and applied successfully — do NOT read the file back to confirm it.`,
256
+ meta: { label: rel, added, removed, wrote: true },
257
+ };
258
+ }
259
+
260
+ async function search(args, ctx) {
261
+ const pattern = args.pattern;
262
+ if (!pattern) return { ok: false, output: 'search needs a <pattern>.' };
263
+
264
+ let re;
265
+ try { re = new RegExp(pattern, 'i'); }
266
+ catch (e) { return { ok: false, output: `Invalid regex: ${e.message}` }; }
267
+
268
+ const globRe = args.glob ? globToRegex(args.glob) : null;
269
+ const root = args.path ? resolvePath(args.path, ctx.cwd).abs : ctx.cwd;
270
+ const files = walkFiles(root);
271
+
272
+ const hits = [];
273
+ let scanned = 0;
274
+ for (const file of files) {
275
+ const rel = path.relative(ctx.cwd, file).replace(/\\/g, '/');
276
+ if (globRe && !globRe.test(rel)) continue;
277
+ const ext = path.extname(file).toLowerCase();
278
+ if (ext && !TEXT_EXT.has(ext)) continue;
279
+ let content;
280
+ try {
281
+ if (fs.statSync(file).size > 2_000_000) continue;
282
+ content = fs.readFileSync(file, 'utf8');
283
+ } catch { continue; }
284
+ scanned++;
285
+ const lines = content.split('\n');
286
+ for (let i = 0; i < lines.length; i++) {
287
+ if (re.test(lines[i])) {
288
+ hits.push(`${rel}:${i + 1}: ${lines[i].trim().slice(0, 200)}`);
289
+ if (hits.length >= MAX_SEARCH_HITS) break;
290
+ }
291
+ }
292
+ if (hits.length >= MAX_SEARCH_HITS) break;
293
+ }
294
+
295
+ const header = hits.length
296
+ ? `${hits.length}${hits.length >= MAX_SEARCH_HITS ? '+' : ''} match(es) across ${scanned} file(s):`
297
+ : `No matches for /${pattern}/ across ${scanned} file(s).`;
298
+ return { ok: true, output: truncate(`${header}\n${hits.join('\n')}`), meta: { label: pattern, count: hits.length } };
299
+ }
300
+
301
+ async function run(args, ctx) {
302
+ const command = (args.command || '').trim();
303
+ if (!command) return { ok: false, output: 'run needs a <command>.' };
304
+
305
+ const verdict = await ctx.approve({
306
+ tool: 'run',
307
+ title: 'Run command',
308
+ detail: command,
309
+ danger: /\brm\s+-rf\b|\bdel\s+\/[sf]\b|format\s|mkfs|>\s*\/dev\/sd|shutdown|reboot|:\(\)\{|curl[^|]*\|\s*(ba)?sh/i.test(command),
310
+ });
311
+ if (verdict === 'reject') return { ok: false, output: `User declined to run: ${command}`, meta: { rejected: true } };
312
+
313
+ return new Promise((resolve) => {
314
+ exec(command, { cwd: ctx.cwd, timeout: RUN_TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024, windowsHide: true },
315
+ (err, stdout, stderr) => {
316
+ const code = err ? (err.code == null ? 1 : err.code) : 0;
317
+ const parts = [];
318
+ if (stdout && stdout.trim()) parts.push(stdout.trimEnd());
319
+ if (stderr && stderr.trim()) parts.push(`[stderr]\n${stderr.trimEnd()}`);
320
+ if (err && err.killed) parts.push(`[timed out after ${RUN_TIMEOUT_MS / 1000}s]`);
321
+ const body = parts.join('\n') || '(no output)';
322
+ resolve({
323
+ ok: true, // a non-zero exit is a real result the model must see, not a tool failure
324
+ output: truncate(`$ ${command}\n[exit ${code}]\n${body}`),
325
+ meta: { label: command, exitCode: code },
326
+ });
327
+ });
328
+ });
329
+ }
330
+
331
+ export const TOOLS = { list_dir, read_file, write_file, edit_file, search, run };
332
+
333
+ export const TOOL_NEEDS_APPROVAL = new Set(['write_file', 'edit_file', 'run']);
334
+
335
+ /** Human-facing verb + colour hint for the transcript. */
336
+ export const TOOL_DISPLAY = {
337
+ list_dir: { verb: 'list', icon: '▸' },
338
+ read_file: { verb: 'read', icon: '▸' },
339
+ write_file: { verb: 'write', icon: '✎' },
340
+ edit_file: { verb: 'edit', icon: '✎' },
341
+ search: { verb: 'search', icon: '▸' },
342
+ run: { verb: 'run', icon: '$' },
343
+ };
344
+
345
+ export async function executeTool(name, args, ctx) {
346
+ const fn = TOOLS[name];
347
+ if (!fn) {
348
+ return { ok: false, output: `Unknown tool "${name}". Available: ${Object.keys(TOOLS).join(', ')}.` };
349
+ }
350
+
351
+ // Same cap the desktop app enforces, same account — checked BEFORE the
352
+ // approval prompt so the user is never asked to confirm a write that's
353
+ // about to be blocked anyway.
354
+ const capped = CAPPED_TOOLS.has(name) && config.getConfig().provider === 'codeply';
355
+ if (capped) {
356
+ const limit = await applyLimit.checkApplyLimit();
357
+ if (!limit.allowed) {
358
+ return {
359
+ ok: false,
360
+ output: limit.error || `Daily apply limit reached (${limit.count}/${limit.limit}). This is shared with the Codeply desktop app and resets at midnight UTC.`,
361
+ };
362
+ }
363
+ }
364
+
365
+ try {
366
+ const result = await fn(args, ctx);
367
+ if (capped && result.ok && result.meta?.wrote) {
368
+ await applyLimit.recordApplyEvent(args.path);
369
+ }
370
+ return result;
371
+ } catch (e) {
372
+ return { ok: false, output: `${name} failed: ${e.message}` };
373
+ }
374
+ }
375
+
376
+ export { resolvePath, walkFiles, truncate };
package/lib/tui.js ADDED
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Legacy CJS stub — the interactive TUI lives in tui.mjs (ESM + Ink).
3
+ * Entry point: bin/codeply.js → dynamic import('./lib/tui.mjs')
4
+ */
5
+ module.exports = function deprecatedTui() {
6
+ throw new Error('Use lib/tui.mjs via the codeply entrypoint (node bin/codeply.js).');
7
+ };