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.
- package/bin/codeply.js +259 -0
- package/lib/agent.mjs +533 -0
- package/lib/ai.js +181 -0
- package/lib/ai.mjs +6 -0
- package/lib/apply-limit.js +41 -0
- package/lib/auth.js +112 -0
- package/lib/auth.mjs +6 -0
- package/lib/config.js +89 -0
- package/lib/edit-engine.js +207 -0
- package/lib/render-md.mjs +617 -0
- package/lib/textarea.mjs +258 -0
- package/lib/tools.mjs +376 -0
- package/lib/tui.js +7 -0
- package/lib/tui.mjs +899 -0
- package/package.json +45 -0
package/lib/textarea.mjs
ADDED
|
@@ -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
|
+
};
|