cli-surf 0.11.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/cliTools.js ADDED
@@ -0,0 +1,490 @@
1
+ /**
2
+ * Локальные инструменты coding-агента (surf code): read, glob, grep,
3
+ * edit, write, bash. Исполняются на машине пользователя в пределах cwd;
4
+ * сервер их только описывает модели и считает токены в общий лимит.
5
+ * Без внешних зависимостей.
6
+ */
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { exec } from 'node:child_process';
10
+ import readline from 'node:readline/promises';
11
+ import { stdin as input, stdout as output } from 'node:process';
12
+ import { paint } from './cliUi.js';
13
+ import { discoverSkills, loadSkill, builtinSkillsDir, userSkillsDir, projectSkillsDir } from './cliSkills.js';
14
+ export const CODE_TOOLS = [
15
+ {
16
+ type: 'function',
17
+ function: {
18
+ name: 'read',
19
+ description: 'Read a text file (returns numbered lines) or list a directory. Paths are relative to the working directory.',
20
+ parameters: {
21
+ type: 'object',
22
+ properties: {
23
+ path: { type: 'string', description: 'File or directory path, relative to cwd' },
24
+ offset: { type: 'number', description: 'First line (1-based), for files' },
25
+ limit: { type: 'number', description: 'Max lines, for files' },
26
+ },
27
+ required: ['path'],
28
+ },
29
+ },
30
+ },
31
+ {
32
+ type: 'function',
33
+ function: {
34
+ name: 'glob',
35
+ description: 'Find files by glob pattern (*, ?, **). Searches recursively from cwd, skips node_modules and .git.',
36
+ parameters: {
37
+ type: 'object',
38
+ properties: {
39
+ pattern: { type: 'string', description: 'Glob like src/**/*.ts' },
40
+ },
41
+ required: ['pattern'],
42
+ },
43
+ },
44
+ },
45
+ {
46
+ type: 'function',
47
+ function: {
48
+ name: 'grep',
49
+ description: 'Search file contents by regex. Returns path:line matches. Skips node_modules, .git and binary files.',
50
+ parameters: {
51
+ type: 'object',
52
+ properties: {
53
+ pattern: { type: 'string', description: 'JavaScript regex' },
54
+ include: { type: 'string', description: 'Optional glob filter, e.g. *.ts' },
55
+ dir: { type: 'string', description: 'Subdirectory to search, default cwd' },
56
+ },
57
+ required: ['pattern'],
58
+ },
59
+ },
60
+ },
61
+ {
62
+ type: 'function',
63
+ function: {
64
+ name: 'edit',
65
+ description: 'Exact string replacement in a file. The file MUST be read first. oldString must match exactly once (or use replaceAll). Prefer small replacements.',
66
+ parameters: {
67
+ type: 'object',
68
+ properties: {
69
+ path: { type: 'string', description: 'File path, relative to cwd' },
70
+ oldString: { type: 'string', description: 'Exact text to replace' },
71
+ newString: { type: 'string', description: 'Replacement text' },
72
+ replaceAll: { type: 'boolean', description: 'Replace every occurrence' },
73
+ },
74
+ required: ['path', 'oldString', 'newString'],
75
+ },
76
+ },
77
+ },
78
+ {
79
+ type: 'function',
80
+ function: {
81
+ name: 'write',
82
+ description: 'Create a new file or overwrite an existing one. Parent directories are created. The local permission mode determines whether confirmation is needed.',
83
+ parameters: {
84
+ type: 'object',
85
+ properties: {
86
+ path: { type: 'string', description: 'File path, relative to cwd' },
87
+ content: { type: 'string', description: 'Full file content' },
88
+ },
89
+ required: ['path', 'content'],
90
+ },
91
+ },
92
+ },
93
+ {
94
+ type: 'function',
95
+ function: {
96
+ name: 'bash',
97
+ description: 'Run a shell command (tests, builds, git status, etc.). The local permission mode determines whether confirmation is needed. Output is truncated.',
98
+ parameters: {
99
+ type: 'object',
100
+ properties: {
101
+ command: { type: 'string', description: 'Shell command' },
102
+ timeout: { type: 'number', description: 'Timeout in ms, default 60000, max 300000' },
103
+ workdir: { type: 'string', description: 'Subdirectory to run in, default cwd' },
104
+ },
105
+ required: ['command'],
106
+ },
107
+ },
108
+ },
109
+ {
110
+ type: 'function',
111
+ function: {
112
+ name: 'skill',
113
+ description: 'Agent Skills: list installed skills or read a skill\'s SKILL.md instructions. When a task matches an installed skill, read it first and follow it exactly, including running its bundled scripts via bash.',
114
+ parameters: {
115
+ type: 'object',
116
+ properties: {
117
+ action: { type: 'string', description: "'list' shows the catalog, 'read' returns full instructions" },
118
+ name: { type: 'string', description: 'Skill name for action=read' },
119
+ },
120
+ required: ['action'],
121
+ },
122
+ },
123
+ },
124
+ ];
125
+ export function createToolContext(cwd, yesAll, permissionMode = 'important') {
126
+ return { cwd: path.resolve(cwd), yesAll, permissionMode, readFiles: new Set() };
127
+ }
128
+ const RESULT_CAP = 12000;
129
+ function truncateResult(text) {
130
+ if (text.length <= RESULT_CAP)
131
+ return text;
132
+ return `${text.slice(0, RESULT_CAP)}\n…(обрезано, всего ${text.length} символов)`;
133
+ }
134
+ function resolveInCwd(ctx, p) {
135
+ if (typeof p !== 'string' || !p)
136
+ throw new Error('нужен path');
137
+ const abs = path.resolve(ctx.cwd, p);
138
+ const root = path.resolve(ctx.cwd);
139
+ if (abs === root || abs.startsWith(root + path.sep))
140
+ return abs;
141
+ // Доверенные корни скиллов (свои скрипты/ресурсы): builtin, user, project.
142
+ const builtin = builtinSkillsDir();
143
+ const roots = [builtin, userSkillsDir(), projectSkillsDir(ctx.cwd)]
144
+ .filter((d) => !!d)
145
+ .map(d => path.resolve(d));
146
+ if (roots.some(r => abs === r || abs.startsWith(r + path.sep)))
147
+ return abs;
148
+ throw new Error(`только внутри рабочей папки ${ctx.cwd}`);
149
+ }
150
+ function toolRead(ctx, args) {
151
+ const abs = resolveInCwd(ctx, String(args.path ?? ''));
152
+ const stat = fs.statSync(abs);
153
+ if (stat.isDirectory()) {
154
+ const entries = fs.readdirSync(abs, { withFileTypes: true })
155
+ .map(e => (e.isDirectory() ? `${e.name}/` : e.name)).sort();
156
+ ctx.readFiles.add(abs);
157
+ return [`каталог ${path.relative(ctx.cwd, abs) || '.'}:`, ...entries].join('\n');
158
+ }
159
+ if (stat.size > 1024 * 1024)
160
+ throw new Error('файл больше 1 МБ');
161
+ const content = fs.readFileSync(abs, 'utf8');
162
+ if (content.includes('\0'))
163
+ throw new Error('бинарный файл, пропущен');
164
+ ctx.readFiles.add(abs);
165
+ const lines = content.split('\n');
166
+ const offset = Math.max(1, Number(args.offset) || 1);
167
+ const limit = Math.min(500, Math.max(1, Number(args.limit) || 500));
168
+ const slice = lines.slice(offset - 1, offset - 1 + limit);
169
+ const numbered = slice.map((l, i) => `${offset + i}| ${l.length > 500 ? l.slice(0, 500) + '…' : l}`);
170
+ const tail = offset - 1 + limit < lines.length ? `\n…(ещё ${lines.length - (offset - 1 + limit)} строк)` : '';
171
+ return `${path.relative(ctx.cwd, abs)} (${lines.length} строк):\n${numbered.join('\n')}${tail}`;
172
+ }
173
+ function globToRegExp(pattern) {
174
+ let re = '';
175
+ for (let i = 0; i < pattern.length; i += 1) {
176
+ const c = pattern[i];
177
+ if (c === '*') {
178
+ if (pattern[i + 1] === '*') {
179
+ re += '.*';
180
+ i += 1;
181
+ if (pattern[i + 1] === '/')
182
+ i += 1;
183
+ }
184
+ else {
185
+ re += '[^/]*';
186
+ }
187
+ }
188
+ else if (c === '?') {
189
+ re += '[^/]';
190
+ }
191
+ else {
192
+ re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
193
+ }
194
+ }
195
+ return new RegExp(`^${re}$`);
196
+ }
197
+ const WALK_SKIP = new Set(['node_modules', '.git']);
198
+ function walkFiles(root, out) {
199
+ let entries;
200
+ try {
201
+ entries = fs.readdirSync(root, { withFileTypes: true });
202
+ }
203
+ catch {
204
+ return;
205
+ }
206
+ for (const e of entries) {
207
+ if (WALK_SKIP.has(e.name))
208
+ continue;
209
+ const full = path.join(root, e.name);
210
+ if (e.isDirectory())
211
+ walkFiles(full, out);
212
+ else if (e.isFile())
213
+ out.push(full);
214
+ }
215
+ }
216
+ function toolGlob(ctx, args) {
217
+ const pattern = String(args.pattern ?? '');
218
+ if (!pattern)
219
+ throw new Error('нужен pattern');
220
+ const re = globToRegExp(pattern);
221
+ const all = [];
222
+ walkFiles(ctx.cwd, all);
223
+ const matched = all
224
+ .map(f => path.relative(ctx.cwd, f).split(path.sep).join('/'))
225
+ .filter(rel => re.test(rel) || re.test(`./${rel}`) || re.test(rel.split('/').pop() ?? ''))
226
+ .slice(0, 100);
227
+ if (!matched.length)
228
+ return 'ничего не найдено';
229
+ const tail = matched.length === 100 ? '\n…(первые 100)' : '';
230
+ return `${matched.join('\n')}${tail}`;
231
+ }
232
+ function toolGrep(ctx, args) {
233
+ const pattern = String(args.pattern ?? '');
234
+ if (!pattern)
235
+ throw new Error('нужен pattern');
236
+ let re;
237
+ try {
238
+ re = new RegExp(pattern, 'gm');
239
+ }
240
+ catch {
241
+ throw new Error('невалидный regex');
242
+ }
243
+ const include = typeof args.include === 'string' && args.include ? globToRegExp(args.include) : null;
244
+ const dir = typeof args.dir === 'string' && args.dir ? resolveInCwd(ctx, args.dir) : ctx.cwd;
245
+ const all = [];
246
+ walkFiles(dir, all);
247
+ const hits = [];
248
+ for (const f of all) {
249
+ if (hits.length >= 60)
250
+ break;
251
+ const rel = path.relative(ctx.cwd, f).split(path.sep).join('/');
252
+ if (include && !include.test(rel) && !include.test(rel.split('/').pop() ?? ''))
253
+ continue;
254
+ let stat;
255
+ try {
256
+ stat = fs.statSync(f);
257
+ }
258
+ catch {
259
+ continue;
260
+ }
261
+ if (stat.size > 1024 * 1024)
262
+ continue;
263
+ let content;
264
+ try {
265
+ content = fs.readFileSync(f, 'utf8');
266
+ }
267
+ catch {
268
+ continue;
269
+ }
270
+ if (content.includes('\0'))
271
+ continue;
272
+ const lines = content.split('\n');
273
+ lines.forEach((line, i) => {
274
+ if (hits.length >= 60)
275
+ return;
276
+ re.lastIndex = 0;
277
+ if (re.test(line))
278
+ hits.push(`${rel}:${i + 1}: ${line.trim().slice(0, 200)}`);
279
+ });
280
+ }
281
+ if (!hits.length)
282
+ return 'ничего не найдено';
283
+ return hits.join('\n');
284
+ }
285
+ function toolSkill(ctx, args) {
286
+ const action = args.action;
287
+ if (action === 'list') {
288
+ const { skills } = discoverSkills(ctx.cwd);
289
+ if (skills.length === 0)
290
+ return 'навыков не установлено';
291
+ return skills.map(s => `- ${s.name}: ${s.description} [${s.source}]`).join('\n');
292
+ }
293
+ if (action === 'read') {
294
+ const name = typeof args.name === 'string' ? args.name : '';
295
+ const skill = loadSkill(ctx.cwd, name);
296
+ if (!skill)
297
+ return `ошибка: навык «${name}» не найден`;
298
+ const files = skill.files.length > 0
299
+ ? `\n\nПриложенные файлы (пути от корня скилла ${skill.meta.dir}, запускай скрипты через bash):\n${skill.files.map(f => `- ${f}`).join('\n')}`
300
+ : '';
301
+ return `# ${skill.meta.name}${skill.meta.version ? ` v${skill.meta.version}` : ''}\n\n${skill.instructions}${files}`;
302
+ }
303
+ return 'ошибка: action должен быть list или read';
304
+ }
305
+ function diffPreview(fileLines, at, oldBlock, newBlock) {
306
+ const show = (prefix, lines, color) => lines.slice(0, 12).map(l => paint(`${prefix} ${l.length > 300 ? l.slice(0, 300) + '…' : l}`, color)).join('\n');
307
+ return `${show('-', oldBlock, 'red')}\n${show('+', newBlock, 'green')}\n${paint(`(контекст: строка ${at + 1})`, 'gray')}`;
308
+ }
309
+ /**
310
+ * В режиме «только важное» обычные правки и проверочные команды не прерывают
311
+ * работу. Спрашиваем перед необратимыми/внешними действиями и установкой ПО.
312
+ * Это намеренно консервативный список: при сомнении агент всё ещё ограничен cwd
313
+ * и системными правилами, а «всегда» остаётся для полного контроля.
314
+ */
315
+ function isImportantShellCommand(command) {
316
+ const value = command.toLowerCase();
317
+ return /(?:^|[;&|\n]\s*)(?:rm|rmdir|del|erase|remove-item|clear-content|format|rd|git\s+(?:reset|clean|push|commit|rebase)|npm\s+(?:publish|install|uninstall|update)|pnpm\s+(?:publish|add|remove|install|update)|yarn\s+(?:publish|add|remove|install|upgrade)|pip(?:3)?\s+install|cargo\s+(?:install|publish)|(?:docker|kubectl|terraform)\s+(?:push|apply|destroy)|(?:vercel|netlify|flyctl)\s+(?:deploy|publish)|curl\b[^\n|]*\|\s*(?:sh|bash|zsh)|wget\b[^\n|]*\|\s*(?:sh|bash|zsh))\b/i.test(value)
318
+ || /(?:^|[;&|\n])\s*(?:move-item|mv|rename-item|ren)\b/i.test(value)
319
+ || /(?:^|[^>&])>{1,2}(?!&)/.test(value);
320
+ }
321
+ function requiresApproval(ctx, kind, bashCommand) {
322
+ if (ctx.yesAll || ctx.permissionMode === 'never')
323
+ return false;
324
+ if (ctx.permissionMode === 'always')
325
+ return true;
326
+ return kind === 'bash' && !!bashCommand && isImportantShellCommand(bashCommand);
327
+ }
328
+ async function askApproval(question, ctx) {
329
+ if (ctx.yesAll)
330
+ return 'yes';
331
+ if (!process.stdin.isTTY)
332
+ return 'no';
333
+ const rl = readline.createInterface({ input, output });
334
+ try {
335
+ const answer = (await rl.question(`${paint('❓', 'accent')} ${question} ${paint('[y/n]', 'gray')} `)).trim().toLowerCase();
336
+ if (['y', 'yes', 'д', 'да'].includes(answer))
337
+ return 'yes';
338
+ return 'no';
339
+ }
340
+ catch {
341
+ return 'no';
342
+ }
343
+ finally {
344
+ rl.close();
345
+ }
346
+ }
347
+ function toolEdit(ctx, args) {
348
+ const abs = resolveInCwd(ctx, String(args.path ?? ''));
349
+ const oldString = typeof args.oldString === 'string' ? args.oldString : null;
350
+ const newString = typeof args.newString === 'string' ? args.newString : null;
351
+ if (oldString === null || oldString === '' || newString === null) {
352
+ return Promise.resolve('ошибка: нужны непустые oldString и newString');
353
+ }
354
+ let content;
355
+ try {
356
+ content = fs.readFileSync(abs, 'utf8');
357
+ }
358
+ catch {
359
+ return Promise.resolve(`ошибка: файл не читается: ${args.path}`);
360
+ }
361
+ if (!ctx.readFiles.has(abs))
362
+ return Promise.resolve('ошибка: сначала прочитайте файл инструментом read');
363
+ if (oldString === newString)
364
+ return Promise.resolve('ошибка: oldString и newString одинаковые');
365
+ const replaceAll = args.replaceAll === true;
366
+ const occurrences = content.split(oldString).length - 1;
367
+ if (occurrences === 0)
368
+ return Promise.resolve('ошибка: oldString не найден');
369
+ if (occurrences > 1 && !replaceAll) {
370
+ return Promise.resolve(`ошибка: найдено совпадений: ${occurrences}, уточните контекст или используйте replaceAll`);
371
+ }
372
+ const at = content.indexOf(oldString);
373
+ const before = content.slice(0, at).split('\n');
374
+ const startLine = Math.max(0, before.length - 4);
375
+ const oldBlock = oldString.split('\n');
376
+ const newBlock = newString.split('\n');
377
+ const preview = diffPreview(content.split('\n'), startLine, oldBlock, newBlock);
378
+ const apply = () => {
379
+ const next = replaceAll ? content.split(oldString).join(newString) : content.replace(oldString, newString);
380
+ fs.writeFileSync(abs, next);
381
+ ctx.readFiles.add(abs);
382
+ return `готово: заменено ${replaceAll ? occurrences : 1} в ${path.relative(ctx.cwd, abs)}`;
383
+ };
384
+ if (!requiresApproval(ctx, 'edit'))
385
+ return Promise.resolve(apply());
386
+ if (!process.stdin.isTTY)
387
+ return Promise.resolve('отклонено: неинтерактивный режим без --yes');
388
+ return (async () => {
389
+ console.log(`${paint('edit', 'white')} ${paint(String(args.path), 'accent')}\n${preview}`);
390
+ const decision = await askApproval('Применить правку?', ctx);
391
+ if (decision === 'no')
392
+ return 'отклонено пользователем';
393
+ return apply();
394
+ })();
395
+ }
396
+ function toolWrite(ctx, args) {
397
+ const abs = resolveInCwd(ctx, String(args.path ?? ''));
398
+ const content = typeof args.content === 'string' ? args.content : null;
399
+ if (content === null)
400
+ return Promise.resolve('ошибка: нужен content');
401
+ const exists = fs.existsSync(abs);
402
+ const head = content.split('\n').slice(0, 20).join('\n').slice(0, 2000);
403
+ const apply = () => {
404
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
405
+ fs.writeFileSync(abs, content);
406
+ ctx.readFiles.add(abs);
407
+ return `готово: ${exists ? 'перезаписан' : 'создан'} ${path.relative(ctx.cwd, abs)} (${content.length} символов)`;
408
+ };
409
+ if (!requiresApproval(ctx, 'write'))
410
+ return Promise.resolve(apply());
411
+ if (!process.stdin.isTTY)
412
+ return Promise.resolve('отклонено: неинтерактивный режим без --yes');
413
+ return (async () => {
414
+ console.log(`${paint(exists ? 'overwrite' : 'write', 'white')} ${paint(String(args.path), 'accent')}\n${paint(head, 'gray')}`);
415
+ const decision = await askApproval(exists ? 'Перезаписать файл?' : 'Создать файл?', ctx);
416
+ if (decision === 'no')
417
+ return 'отклонено пользователем';
418
+ return apply();
419
+ })();
420
+ }
421
+ function toolBash(ctx, args) {
422
+ const command = typeof args.command === 'string' ? args.command : '';
423
+ if (!command)
424
+ return Promise.resolve('ошибка: нужен command');
425
+ const timeout = Math.min(300000, Math.max(1000, Number(args.timeout) || 60000));
426
+ let workdir = ctx.cwd;
427
+ if (typeof args.workdir === 'string' && args.workdir) {
428
+ try {
429
+ workdir = resolveInCwd(ctx, args.workdir);
430
+ }
431
+ catch (err) {
432
+ return Promise.resolve(`ошибка: ${err.message}`);
433
+ }
434
+ }
435
+ const run = () => new Promise(resolve => {
436
+ exec(command, { cwd: workdir, timeout, maxBuffer: 1024 * 1024, windowsHide: true }, (err, stdout, stderr) => {
437
+ const out = `${stdout || ''}${stderr ? `\n[stderr]\n${stderr}` : ''}`.trim().slice(0, 30000);
438
+ if (err) {
439
+ const code = err.code;
440
+ resolve(`exit ${typeof code === 'number' ? code : 'ошибка'}: ${err.message.split('\n')[0]}\n${out}`);
441
+ }
442
+ else {
443
+ resolve(out || '(пустой вывод)');
444
+ }
445
+ });
446
+ });
447
+ if (!requiresApproval(ctx, 'bash', command))
448
+ return run();
449
+ if (!process.stdin.isTTY)
450
+ return Promise.resolve('отклонено: неинтерактивный режим без --yes');
451
+ return (async () => {
452
+ const decision = await askApproval(`Выполнить важное действие: ${command}`, ctx);
453
+ if (decision === 'no')
454
+ return 'отклонено пользователем';
455
+ const spinner = paint('…выполняю', 'gray');
456
+ process.stderr.write(`${spinner}\r`);
457
+ const result = await run();
458
+ process.stderr.write('\r\x1b[K');
459
+ return result;
460
+ })();
461
+ }
462
+ /** Выполнить вызов инструмента модели, вернуть текст результата. */
463
+ export async function executeAgentTool(ctx, call) {
464
+ let args;
465
+ try {
466
+ const parsed = JSON.parse(call.argsJson || '{}');
467
+ args = parsed && typeof parsed === 'object' && !Array.isArray(parsed)
468
+ ? parsed
469
+ : {};
470
+ }
471
+ catch {
472
+ return 'ошибка: arguments не JSON';
473
+ }
474
+ try {
475
+ switch (call.name) {
476
+ case 'read': return truncateResult(toolRead(ctx, args));
477
+ case 'glob': return truncateResult(toolGlob(ctx, args));
478
+ case 'grep': return truncateResult(toolGrep(ctx, args));
479
+ case 'edit': return truncateResult(await toolEdit(ctx, args));
480
+ case 'write': return truncateResult(await toolWrite(ctx, args));
481
+ case 'bash': return truncateResult(await toolBash(ctx, args));
482
+ case 'skill': return truncateResult(toolSkill(ctx, args));
483
+ default: return `ошибка: неизвестный инструмент ${call.name}`;
484
+ }
485
+ }
486
+ catch (err) {
487
+ return `ошибка: ${err.message}`;
488
+ }
489
+ }
490
+ //# sourceMappingURL=cliTools.js.map