aica-cli 0.0.1

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/lib/server.js ADDED
@@ -0,0 +1,699 @@
1
+ import express from 'express';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { createHash } from 'crypto';
5
+ import chalk from 'chalk';
6
+ import { validatePath, validateCommand, loadAllowedCommands } from './security.js';
7
+ import { Logger } from './logger.js';
8
+ import { parseActionFile } from './parser.js';
9
+ import { ActionExecutor, SequenceError } from './actions.js';
10
+ import { showPatchUI, showSequenceUI, showResult, ask } from './ui.js';
11
+
12
+ export async function startServer({ workdir, role, password, port, listenHost = '127.0.0.1', requestMode = 'mixed', autoMode = false, publicUrl }) {
13
+ const app = express();
14
+ app.use(express.json({ limit: '10mb' }));
15
+
16
+ const logger = new Logger(workdir);
17
+ const logDir = path.join(workdir, '.ai-log');
18
+ const executor = new ActionExecutor(workdir, logDir);
19
+ const allowedCommands = loadAllowedCommands(workdir);
20
+
21
+ const agentsMdPath = path.join(workdir, 'AGENTS.md');
22
+ const hasAgentsMd = fs.existsSync(agentsMdPath);
23
+
24
+ let patchQueue = Promise.resolve();
25
+
26
+ // Auth middleware
27
+ const auth = (req, res, next) => {
28
+ const queryPassword = req.query.password;
29
+ const headerToken = req.headers.authorization?.replace('Bearer ', '');
30
+
31
+ if (queryPassword === password || headerToken === password) {
32
+ return next();
33
+ }
34
+
35
+ return res.status(401).json({ error: 'Unauthorized' });
36
+ };
37
+
38
+ app.use(auth);
39
+
40
+ app.use((req, res, next) => {
41
+ if (req.path !== '/help') {
42
+ logger.logRequest(req.method, req.path, req.body || req.query || {});
43
+ }
44
+ next();
45
+ });
46
+
47
+ // Генерация markdown help в зависимости от режима
48
+ function generateHelpMarkdown() {
49
+ let md = `# aica-агент ${role}\n\n`;
50
+
51
+ md += `## Режим: ${requestMode}${autoMode ? ' (автономный)' : ''}\n\n`;
52
+
53
+ if (requestMode === 'post') {
54
+ // POST режим
55
+ md += `## Чтение файлов\n`;
56
+ md += `Все запросы через POST с JSON body.\n`;
57
+ md += `Авторизация: \`Authorization: Bearer ${password}\`\n\n`;
58
+ md += `Примеры:\n`;
59
+ md += `\`\`\`bash\n`;
60
+ md += `curl -X POST ${getServerUrl()}/help -H "Authorization: Bearer ${password}"\n`;
61
+ md += `curl -X POST ${getServerUrl()}/get-file -H "Authorization: Bearer ${password}" -d '{"path":"lib/server.js"}'\n`;
62
+ md += `curl -X POST ${getServerUrl()}/list-files -H "Authorization: Bearer ${password}" -d '{"path":".","recursive":false}'\n`;
63
+ md += `curl -X POST ${getServerUrl()}/grep -H "Authorization: Bearer ${password}" -d '{"pattern":"TODO","path":"."}'\n`;
64
+ md += `curl -X POST ${getServerUrl()}/tree -H "Authorization: Bearer ${password}" -d '{"path":".","depth":2}'\n`;
65
+ md += `\`\`\`\n\n`;
66
+ } else {
67
+ // GET или mixed режим
68
+ md += `## Чтение файлов\n`;
69
+ md += `Все запросы через GET с password в query параметре.\n\n`;
70
+ md += `Примеры:\n`;
71
+ md += `- \`GET ${getServerUrl()}/help?password=${password}\` — эта инструкция\n`;
72
+ md += `- \`GET ${getServerUrl()}/get-file?path=lib/server.js&password=${password}\` — прочитать файл\n`;
73
+ md += `- \`GET ${getServerUrl()}/list-files?path=.&recursive=false&password=${password}\` — список файлов\n`;
74
+ md += `- \`GET ${getServerUrl()}/grep?pattern=TODO&path=.&password=${password}\` — поиск в файлах\n`;
75
+ md += `- \`GET ${getServerUrl()}/tree?path=.&depth=2&password=${password}\` — дерево проекта\n\n`;
76
+ }
77
+
78
+ md += `## Изменения\n`;
79
+ md += `Метод: POST /create-patch\n`;
80
+ md += `Авторизация: \`Authorization: Bearer ${password}\`\n\n`;
81
+ md += `Body:\n`;
82
+ md += `\`\`\`json\n`;
83
+ md += `{\n`;
84
+ md += ` "action": "patch|replace|create|delete|rename|append|exec|sequence",\n`;
85
+ md += ` "file": "путь/к/файлу",\n`;
86
+ md += ` "description": "что делаешь",\n`;
87
+ md += ` "reason": "почему (опционально)",\n`;
88
+ md += ` "content": "diff или содержимое"\n`;
89
+ md += `}\n`;
90
+ md += `\`\`\`\n\n`;
91
+
92
+ md += `Доступные actions:\n`;
93
+ md += `- \`patch\` — unified diff\n`;
94
+ md += `- \`replace\` — замена файла\n`;
95
+ md += `- \`create\` — создание файла\n`;
96
+ md += `- \`delete\` — удаление файла\n`;
97
+ md += `- \`rename\` — переименование (добавь "to": "новый_путь")\n`;
98
+ md += `- \`append\` — дописать в конец\n`;
99
+ md += `- \`exec\` — команда из whitelist (добавь "command": "...")\n`;
100
+ md += `- \`sequence\` — последовательность (добавь "steps": [...])\n\n`;
101
+
102
+ md += `## Whitelist команд\n`;
103
+ for (const cmd of allowedCommands) {
104
+ md += `- ${cmd}\n`;
105
+ }
106
+ md += `\n`;
107
+
108
+ md += `## Правила\n`;
109
+ md += `Не трогай node_modules, .env, .git, .ai-log\n`;
110
+ md += `Стоп-фраза: ~agent-stop\n\n`;
111
+ md += `Критические правила:\n`;
112
+ md += `- НИКОГДА не эмулируй ответы сервера (applied:NNN, rejected:NNN)\n`;
113
+ md += `- Если сервер недоступен — честно сообщи\n`;
114
+ md += `- Если человек не прислал результат — жди\n`;
115
+ md += `- Запрещено додумывать содержимое файлов\n`;
116
+
117
+ if (hasAgentsMd) {
118
+ md += `\n## Контекст проекта\n`;
119
+ md += `Прочитай AGENTS.md в корне проекта и жди дальнейших инструкции\n`;
120
+ }
121
+
122
+ return md;
123
+ }
124
+
125
+ function getServerUrl() {
126
+ // Для markdown используем placeholder
127
+ return publicUrl || 'http://server';
128
+ }
129
+
130
+ // HELP — markdown для LLM, JSON для программных клиентов
131
+ app.get('/help', (req, res) => {
132
+ const accept = req.headers.accept || '';
133
+
134
+ if (accept.includes('application/json')) {
135
+ return res.json({ success: true, help: { role, mode: requestMode, autoMode } });
136
+ }
137
+
138
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
139
+ return res.send(generateHelpMarkdown());
140
+ });
141
+
142
+ app.post('/help', (req, res) => {
143
+ const accept = req.headers.accept || '';
144
+
145
+ if (accept.includes('application/json')) {
146
+ return res.json({ success: true, help: { role, mode: requestMode, autoMode } });
147
+ }
148
+
149
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
150
+ return res.send(generateHelpMarkdown());
151
+ });
152
+
153
+ // GET эндпоинты для чтения
154
+ if (requestMode === 'get' || requestMode === 'mixed') {
155
+ app.get('/get-file', (req, res) => {
156
+ try {
157
+ const abs = validatePath(req.query.path, workdir);
158
+ if (!fs.existsSync(abs)) return res.json({ success: false, error: 'file_not_found' });
159
+ const content = fs.readFileSync(abs, 'utf8');
160
+ const hash = createHash('md5').update(content).digest('hex').slice(0, 8);
161
+ return res.json({ success: true, content, size: content.length, hash, lines: content.split('\n').length });
162
+ } catch (e) {
163
+ return res.json({ success: false, error: e.message });
164
+ }
165
+ });
166
+
167
+ app.get('/list-files', (req, res) => {
168
+ try {
169
+ const abs = validatePath(req.query.path, workdir);
170
+ const recursive = req.query.recursive === 'true';
171
+ const files = listFiles(abs, recursive);
172
+ return res.json({ success: true, files });
173
+ } catch (e) {
174
+ return res.json({ success: false, error: e.message });
175
+ }
176
+ });
177
+
178
+ app.get('/grep', (req, res) => {
179
+ try {
180
+ const abs = validatePath(req.query.path, workdir);
181
+ const regex = req.query.regex === 'true';
182
+ const matches = grepFiles(abs, req.query.pattern, regex);
183
+ return res.json({ success: true, matches, count: matches.length });
184
+ } catch (e) {
185
+ return res.json({ success: false, error: e.message });
186
+ }
187
+ });
188
+
189
+ app.get('/file-info', (req, res) => {
190
+ try {
191
+ const abs = validatePath(req.query.path, workdir);
192
+ const stat = fs.statSync(abs);
193
+ const content = fs.readFileSync(abs, 'utf8');
194
+ const hash = createHash('md5').update(content).digest('hex').slice(0, 8);
195
+ return res.json({ success: true, size: stat.size, modified: stat.mtime.toISOString(), hash, lines: content.split('\n').length });
196
+ } catch (e) {
197
+ return res.json({ success: false, error: e.message });
198
+ }
199
+ });
200
+
201
+ app.get('/tree', (req, res) => {
202
+ try {
203
+ const abs = validatePath(req.query.path, workdir);
204
+ const depth = parseInt(req.query.depth) || 3;
205
+ const tree = buildTree(abs, depth);
206
+ return res.json({ success: true, tree });
207
+ } catch (e) {
208
+ return res.json({ success: false, error: e.message });
209
+ }
210
+ });
211
+ }
212
+
213
+ // POST эндпоинты для чтения (всегда доступны)
214
+ app.post('/get-file', (req, res) => {
215
+ try {
216
+ const abs = validatePath(req.body.path, workdir);
217
+ if (!fs.existsSync(abs)) return res.json({ success: false, error: 'file_not_found' });
218
+ const content = fs.readFileSync(abs, 'utf8');
219
+ const hash = createHash('md5').update(content).digest('hex').slice(0, 8);
220
+ return res.json({ success: true, content, size: content.length, hash, lines: content.split('\n').length });
221
+ } catch (e) {
222
+ return res.json({ success: false, error: e.message });
223
+ }
224
+ });
225
+
226
+ app.post('/list-files', (req, res) => {
227
+ try {
228
+ const abs = validatePath(req.body.path, workdir);
229
+ const files = listFiles(abs, req.body.recursive || false);
230
+ return res.json({ success: true, files });
231
+ } catch (e) {
232
+ return res.json({ success: false, error: e.message });
233
+ }
234
+ });
235
+
236
+ app.post('/grep', (req, res) => {
237
+ try {
238
+ const abs = validatePath(req.body.path, workdir);
239
+ const matches = grepFiles(abs, req.body.pattern, req.body.regex);
240
+ return res.json({ success: true, matches, count: matches.length });
241
+ } catch (e) {
242
+ return res.json({ success: false, error: e.message });
243
+ }
244
+ });
245
+
246
+ app.post('/file-info', (req, res) => {
247
+ try {
248
+ const abs = validatePath(req.body.path, workdir);
249
+ const stat = fs.statSync(abs);
250
+ const content = fs.readFileSync(abs, 'utf8');
251
+ const hash = createHash('md5').update(content).digest('hex').slice(0, 8);
252
+ return res.json({ success: true, size: stat.size, modified: stat.mtime.toISOString(), hash, lines: content.split('\n').length });
253
+ } catch (e) {
254
+ return res.json({ success: false, error: e.message });
255
+ }
256
+ });
257
+
258
+ app.post('/tree', (req, res) => {
259
+ try {
260
+ const abs = validatePath(req.body.path, workdir);
261
+ const tree = buildTree(abs, req.body.depth || 3);
262
+ return res.json({ success: true, tree });
263
+ } catch (e) {
264
+ return res.json({ success: false, error: e.message });
265
+ }
266
+ });
267
+
268
+ // POST /create-patch (всегда POST)
269
+ app.post('/create-patch', async (req, res) => {
270
+ try {
271
+ const body = req.body;
272
+
273
+ if (!body.action) {
274
+ return res.json({ success: false, error: 'parse_error: missing action' });
275
+ }
276
+
277
+ if (body.action === 'sequence') {
278
+ return await createAndProcessSequence(body, workdir, role, logger, executor, allowedCommands, res, autoMode);
279
+ }
280
+
281
+ return await createAndProcessSingle(body, workdir, role, logger, executor, allowedCommands, res, autoMode);
282
+
283
+ } catch (e) {
284
+ return res.json({ success: false, error: e.message });
285
+ }
286
+ });
287
+
288
+ return new Promise((resolve) => {
289
+ const server = app.listen(port, listenHost, () => {
290
+ resolve(server);
291
+ });
292
+ });
293
+ }
294
+
295
+ function findFreePatchFile(workdir, role) {
296
+ let patchFile = path.join(workdir, `ai-patch-${role}.txt`);
297
+ let fileNumber = 1;
298
+
299
+ while (fs.existsSync(patchFile) && fs.readFileSync(patchFile, 'utf8').trim()) {
300
+ fileNumber++;
301
+ patchFile = path.join(workdir, `ai-patch-${role}-${fileNumber}.txt`);
302
+ }
303
+
304
+ return patchFile;
305
+ }
306
+
307
+ async function createAndProcessSingle(body, workdir, role, logger, executor, allowedCommands, res, autoMode) {
308
+ const { action, file, description, reason, content, to, command, notify } = body;
309
+
310
+ const validActions = ['patch', 'replace', 'create', 'delete', 'rename', 'append', 'exec'];
311
+ if (!validActions.includes(action)) {
312
+ return res.json({ success: false, error: `parse_error: unknown action "${action}"` });
313
+ }
314
+
315
+ if (['patch', 'replace', 'create', 'delete', 'rename', 'append'].includes(action)) {
316
+ if (!file) return res.json({ success: false, error: 'parse_error: missing file' });
317
+ try {
318
+ validatePath(file, workdir);
319
+ } catch (e) {
320
+ return res.json({ success: false, error: e.message });
321
+ }
322
+ }
323
+
324
+ if (['patch', 'replace', 'create', 'append'].includes(action) && !content) {
325
+ return res.json({ success: false, error: 'parse_error: content is required' });
326
+ }
327
+ if (action === 'rename' && !to) {
328
+ return res.json({ success: false, error: 'parse_error: "to" is required for rename' });
329
+ }
330
+ if (action === 'exec') {
331
+ if (!command) return res.json({ success: false, error: 'parse_error: command is required' });
332
+ try {
333
+ validateCommand(command, allowedCommands);
334
+ } catch (e) {
335
+ return res.json({ success: false, error: e.message });
336
+ }
337
+ }
338
+
339
+ const patchFile = findFreePatchFile(workdir, role);
340
+
341
+ let fileContent = `Action: ${action}\n`;
342
+ if (file) fileContent += `File: ${file}\n`;
343
+ if (description) fileContent += `Description: ${description}\n`;
344
+ if (reason) fileContent += `Reason: ${reason}\n`;
345
+ if (to) fileContent += `To: ${to}\n`;
346
+ if (command) fileContent += `Command: ${command}\n`;
347
+ if (notify !== undefined) fileContent += `Notify: ${notify}\n`;
348
+
349
+ if (content !== undefined) {
350
+ fileContent += `\n${content}`;
351
+ }
352
+
353
+ fs.writeFileSync(patchFile, fileContent);
354
+ const id = logger.getNextId();
355
+
356
+ const effectiveNotify = notify !== false;
357
+
358
+ // Отправляем ответ и завершаем
359
+ return res.json({
360
+ success: true,
361
+ id: id,
362
+ filename: path.basename(patchFile),
363
+ status: autoMode ? 'auto-applied' : (effectiveNotify ? 'pending' : 'auto-applied'),
364
+ message: autoMode
365
+ ? 'Автономный режим: патч применён автоматически.'
366
+ : (effectiveNotify
367
+ ? 'Файл создан. Человек подтвердит в консоли.'
368
+ : 'Патч применён автоматически (notify=false).')
369
+ });
370
+
371
+ // Код ниже не выполнится из-за return выше
372
+ // Но оставлен для совместимости
373
+ if (autoMode || !effectiveNotify) {
374
+ try {
375
+ const parsedAction = parseActionFile(fileContent);
376
+ executor.execute(parsedAction);
377
+ moveToLog(patchFile, 'applied', logger);
378
+ console.log(chalk.gray(` 🔕 Патч #${id} применён автоматически`));
379
+ } catch (e) {
380
+ moveToLog(patchFile, 'error', logger);
381
+ console.log(chalk.red(` ❌ Ошибка автоприменения патча #${id}: ${e.message}`));
382
+ }
383
+ } else {
384
+ patchQueue = patchQueue.then(() => handlePatchFile(patchFile, logger, executor, id, false));
385
+ }
386
+ }
387
+
388
+ async function createAndProcessSequence(body, workdir, role, logger, executor, allowedCommands, res, autoMode) {
389
+ const { description, reason, steps, notify } = body;
390
+
391
+ if (!Array.isArray(steps) || steps.length === 0) {
392
+ return res.json({ success: false, error: 'parse_error: steps must be non-empty array' });
393
+ }
394
+
395
+ for (let i = 0; i < steps.length; i++) {
396
+ const step = steps[i];
397
+ if (!step.action) {
398
+ return res.json({ success: false, error: `parse_error: step ${i + 1} missing action` });
399
+ }
400
+
401
+ if (step.action === 'exec') {
402
+ if (!step.command) {
403
+ return res.json({ success: false, error: `parse_error: step ${i + 1} missing command` });
404
+ }
405
+ try {
406
+ validateCommand(step.command, allowedCommands);
407
+ } catch (e) {
408
+ return res.json({ success: false, error: `step ${i + 1}: ${e.message}` });
409
+ }
410
+ } else if (['patch', 'replace', 'create', 'delete', 'rename', 'append'].includes(step.action)) {
411
+ if (!step.file) {
412
+ return res.json({ success: false, error: `parse_error: step ${i + 1} missing file` });
413
+ }
414
+ try {
415
+ validatePath(step.file, workdir);
416
+ } catch (e) {
417
+ return res.json({ success: false, error: `step ${i + 1}: ${e.message}` });
418
+ }
419
+ } else {
420
+ return res.json({ success: false, error: `parse_error: step ${i + 1} unknown action "${step.action}"` });
421
+ }
422
+ }
423
+
424
+ const patchFile = findFreePatchFile(workdir, role);
425
+
426
+ let fileContent = `Action: sequence\n`;
427
+ if (description) fileContent += `Description: ${description}\n`;
428
+ if (reason) fileContent += `Reason: ${reason}\n`;
429
+ if (notify !== undefined) fileContent += `Notify: ${notify}\n`;
430
+
431
+ for (let i = 0; i < steps.length; i++) {
432
+ fileContent += `\n---\n`;
433
+ const step = steps[i];
434
+ fileContent += `Action: ${step.action}\n`;
435
+ if (step.file) fileContent += `File: ${step.file}\n`;
436
+ if (step.description) fileContent += `Description: ${step.description}\n`;
437
+ if (step.to) fileContent += `To: ${step.to}\n`;
438
+ if (step.command) fileContent += `Command: ${step.command}\n`;
439
+ if (step.content !== undefined) {
440
+ fileContent += `\n${step.content}`;
441
+ }
442
+ }
443
+
444
+ fs.writeFileSync(patchFile, fileContent);
445
+ const id = logger.getNextId();
446
+
447
+ const effectiveNotify = notify !== false;
448
+
449
+ // Отправляем ответ и завершаем
450
+ return res.json({
451
+ success: true,
452
+ id: id,
453
+ filename: path.basename(patchFile),
454
+ status: autoMode ? 'auto-applied' : (effectiveNotify ? 'pending' : 'auto-applied'),
455
+ steps: steps.length,
456
+ message: autoMode
457
+ ? 'Автономный режим: sequence применена автоматически.'
458
+ : (effectiveNotify
459
+ ? 'Sequence создана. Человек подтвердит в консоли.'
460
+ : 'Sequence применена автоматически (notify=false).')
461
+ });
462
+
463
+ // Код ниже не выполнится из-за return выше
464
+ if (autoMode || !effectiveNotify) {
465
+ try {
466
+ const parsedAction = parseActionFile(fileContent);
467
+ executor.execute(parsedAction);
468
+ moveToLog(patchFile, 'applied', logger);
469
+ console.log(chalk.gray(` 🔕 Sequence #${id} применена автоматически`));
470
+ } catch (e) {
471
+ moveToLog(patchFile, 'error', logger);
472
+ console.log(chalk.red(` ❌ Ошибка автоприменения sequence #${id}: ${e.message}`));
473
+ }
474
+ } else {
475
+ patchQueue = patchQueue.then(() => handlePatchFile(patchFile, logger, executor, id, false));
476
+ }
477
+ }
478
+
479
+ async function handlePatchFile(filePath, logger, executor, existingId = null, autoMode = false) {
480
+ if (!fs.existsSync(filePath)) {
481
+ console.log(chalk.yellow(` ⚠️ Файл не найден: ${filePath}`));
482
+ return;
483
+ }
484
+
485
+ const text = fs.readFileSync(filePath, 'utf8');
486
+ if (!text.trim()) {
487
+ console.log(chalk.yellow(` ⚠️ Файл пустой: ${path.basename(filePath)}`));
488
+ return;
489
+ }
490
+
491
+ let action;
492
+ try {
493
+ action = parseActionFile(text);
494
+ } catch (e) {
495
+ console.error(`❌ ${e.message}`);
496
+ moveToLog(filePath, 'error', logger);
497
+ return;
498
+ }
499
+
500
+ const id = existingId || logger.getNextId();
501
+ const recentRequests = logger.getRecentRequests(5);
502
+
503
+ if (action.action === 'sequence') {
504
+ showSequenceUI(id, action, recentRequests);
505
+ } else {
506
+ showPatchUI(id, action, recentRequests);
507
+ }
508
+
509
+ // Автономный режим — применяем без подтверждения
510
+ if (autoMode) {
511
+ console.log(chalk.gray(` 🤖 Автоприменение...`));
512
+ try {
513
+ const result = executor.execute(action);
514
+
515
+ if (action.action === 'sequence') {
516
+ for (const r of result.results) {
517
+ if (r.status === 'success') {
518
+ console.log(` ✅ шаг ${r.step}: ${r.action}`);
519
+ } else {
520
+ console.log(` ❌ шаг ${r.step}: ${r.action} — ${r.error}`);
521
+ }
522
+ }
523
+ } else {
524
+ console.log(` ✅ ${result}: ${action.file || action.path || action.command}`);
525
+ }
526
+
527
+ moveToLog(filePath, 'applied', logger);
528
+ showResult(id, 'applied', action.notify);
529
+ } catch (e) {
530
+ if (e instanceof SequenceError) {
531
+ console.log();
532
+ for (const r of e.results) {
533
+ if (r.status === 'success') {
534
+ console.log(` ✅ шаг ${r.step}: ${r.action}`);
535
+ } else {
536
+ console.log(` ❌ шаг ${r.step}: ${r.action} — ${r.error}`);
537
+ }
538
+ }
539
+ moveToLog(filePath, 'partial', logger);
540
+ showResult(id, `partial:${e.message.split(':')[0]}`, action.notify);
541
+ } else {
542
+ console.error(` ❌ ${e.message}`);
543
+ moveToLog(filePath, 'error', logger);
544
+ showResult(id, e.message.split(':')[0], action.notify);
545
+ }
546
+ }
547
+ return;
548
+ }
549
+
550
+ // Обычный режим — запрашиваем подтверждение
551
+ const answer = await ask('? применить? [Y/n/q] ');
552
+
553
+ if (answer.toLowerCase() === 'q') {
554
+ process.exit(0);
555
+ }
556
+
557
+ if (answer.toLowerCase() === 'n') {
558
+ moveToLog(filePath, 'rejected', logger);
559
+ showResult(id, 'rejected', action.notify);
560
+ return;
561
+ }
562
+
563
+ try {
564
+ const result = executor.execute(action);
565
+
566
+ if (action.action === 'sequence') {
567
+ for (const r of result.results) {
568
+ if (r.status === 'success') {
569
+ console.log(` ✅ шаг ${r.step}: ${r.action}`);
570
+ } else {
571
+ console.log(` ❌ шаг ${r.step}: ${r.action} — ${r.error}`);
572
+ }
573
+ }
574
+ moveToLog(filePath, 'applied', logger);
575
+ showResult(id, 'applied', action.notify);
576
+ } else {
577
+ console.log(` ✅ ${result}: ${action.file || action.path || action.command}`);
578
+ moveToLog(filePath, 'applied', logger);
579
+ showResult(id, 'applied', action.notify);
580
+ }
581
+
582
+ } catch (e) {
583
+ if (e instanceof SequenceError) {
584
+ console.log();
585
+ for (const r of e.results) {
586
+ if (r.status === 'success') {
587
+ console.log(` ✅ шаг ${r.step}: ${r.action}`);
588
+ } else {
589
+ console.log(` ❌ шаг ${r.step}: ${r.action} — ${r.error}`);
590
+ }
591
+ }
592
+ moveToLog(filePath, 'partial', logger);
593
+ showResult(id, `partial:${e.message.split(':')[0]}`, action.notify);
594
+ } else {
595
+ console.error(` ❌ ${e.message}`);
596
+ moveToLog(filePath, 'error', logger);
597
+ showResult(id, e.message.split(':')[0], action.notify);
598
+ }
599
+ }
600
+ }
601
+
602
+ function moveToLog(filePath, status, logger) {
603
+ const basename = path.basename(filePath);
604
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
605
+ const dest = path.join(logger.logDir, `${ts}_${status}_${basename}`);
606
+ fs.renameSync(filePath, dest);
607
+ }
608
+
609
+ const SKIP_DIRS = ['node_modules', '.git', '.ai-log', 'dist', 'build', '.cache'];
610
+
611
+ function listFiles(dir, recursive) {
612
+ const result = [];
613
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
614
+
615
+ for (const entry of entries) {
616
+ if (entry.name.startsWith('.') || SKIP_DIRS.includes(entry.name)) continue;
617
+
618
+ const fullPath = path.join(dir, entry.name);
619
+ const stat = fs.statSync(fullPath);
620
+
621
+ result.push({
622
+ name: entry.name,
623
+ type: entry.isDirectory() ? 'dir' : 'file',
624
+ size: stat.size,
625
+ modified: stat.mtime.toISOString()
626
+ });
627
+
628
+ if (recursive && entry.isDirectory()) {
629
+ result.push(...listFiles(fullPath, true).map(f => ({
630
+ ...f,
631
+ name: path.join(entry.name, f.name)
632
+ })));
633
+ }
634
+ }
635
+ return result;
636
+ }
637
+
638
+ function grepFiles(dir, pattern, useRegex) {
639
+ const matches = [];
640
+ const regex = useRegex ? new RegExp(pattern) : null;
641
+
642
+ function search(currentDir) {
643
+ const entries = fs.readdirSync(currentDir, { withFileTypes: true });
644
+
645
+ for (const entry of entries) {
646
+ if (entry.name.startsWith('.') || SKIP_DIRS.includes(entry.name)) continue;
647
+ const fullPath = path.join(currentDir, entry.name);
648
+
649
+ if (entry.isDirectory()) {
650
+ search(fullPath);
651
+ } else if (entry.isFile()) {
652
+ try {
653
+ const content = fs.readFileSync(fullPath, 'utf8');
654
+ const lines = content.split('\n');
655
+
656
+ for (let i = 0; i < lines.length; i++) {
657
+ const found = useRegex
658
+ ? regex.test(lines[i])
659
+ : lines[i].includes(pattern);
660
+
661
+ if (found) {
662
+ matches.push({
663
+ file: path.relative(dir, fullPath),
664
+ line: i + 1,
665
+ text: lines[i].trim()
666
+ });
667
+ }
668
+ }
669
+ } catch {
670
+ // Бинарные файлы
671
+ }
672
+ }
673
+ }
674
+ }
675
+
676
+ search(dir);
677
+ return matches;
678
+ }
679
+
680
+ function buildTree(dir, maxDepth, currentDepth = 0) {
681
+ if (currentDepth >= maxDepth) return null;
682
+
683
+ const result = { name: path.basename(dir), type: 'dir', children: [] };
684
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
685
+
686
+ for (const entry of entries) {
687
+ if (entry.name.startsWith('.') || SKIP_DIRS.includes(entry.name)) continue;
688
+
689
+ const fullPath = path.join(dir, entry.name);
690
+
691
+ if (entry.isDirectory()) {
692
+ result.children.push(buildTree(fullPath, maxDepth, currentDepth + 1));
693
+ } else {
694
+ result.children.push({ name: entry.name, type: 'file' });
695
+ }
696
+ }
697
+
698
+ return result;
699
+ }