astrabot 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.
Files changed (47) hide show
  1. package/README.md +411 -0
  2. package/ai/ai.config.ts +27 -0
  3. package/ai/auto-retry.ts +117 -0
  4. package/ai/config-loader.ts +132 -0
  5. package/ai/index.ts +4 -0
  6. package/ai/retry-prompt.ts +30 -0
  7. package/bin/astra +2 -0
  8. package/core/retry/error-classifier.ts +208 -0
  9. package/core/retry/index.ts +29 -0
  10. package/core/retry/retry-config.ts +142 -0
  11. package/core/retry/retry-engine.ts +215 -0
  12. package/game/index.html +573 -0
  13. package/game/neon-breaker.html +1037 -0
  14. package/index.ts +140 -0
  15. package/modes/agent/action-tracker.ts +47 -0
  16. package/modes/agent/agent-tools.ts +338 -0
  17. package/modes/agent/approval.ts +184 -0
  18. package/modes/agent/diff-view.ts +34 -0
  19. package/modes/agent/orchestrator.ts +234 -0
  20. package/modes/agent/tool-executor.ts +993 -0
  21. package/modes/agent/types.ts +68 -0
  22. package/modes/ask/orchestrator.ts +230 -0
  23. package/modes/auto.ts +88 -0
  24. package/modes/cli.ts +43 -0
  25. package/modes/multi/agent-pool-manager.ts +337 -0
  26. package/modes/multi/examples.ts +441 -0
  27. package/modes/multi/message-broker.ts +179 -0
  28. package/modes/multi/multi-agent-orchestrator.ts +891 -0
  29. package/modes/multi/orchestrator.ts +414 -0
  30. package/modes/multi/types.ts +245 -0
  31. package/modes/multi/workflow-builder.ts +569 -0
  32. package/modes/plan/orchestrator.ts +198 -0
  33. package/modes/plan/planner.ts +121 -0
  34. package/modes/plan/selection.ts +43 -0
  35. package/modes/plan/types.ts +13 -0
  36. package/modes/plan/web-tools.ts +132 -0
  37. package/modes/setup.ts +210 -0
  38. package/package.json +62 -0
  39. package/session/index.ts +45 -0
  40. package/session/session-context.ts +188 -0
  41. package/session/session-manager.ts +374 -0
  42. package/session/session-tools.ts +109 -0
  43. package/session/store.ts +278 -0
  44. package/tsconfig.json +30 -0
  45. package/tui/spinner.ts +182 -0
  46. package/tui/terminal-md.ts +17 -0
  47. package/tui/wakeup.ts +231 -0
@@ -0,0 +1,993 @@
1
+ import fs, { existsSync, readFileSync } from 'fs'
2
+ import path from 'path'
3
+ import { homedir } from 'os'
4
+ import { spawnSync, spawn } from 'child_process'
5
+ import type { AgentConfig, ActionLog } from './types'
6
+ import { ActionTracker } from './action-tracker'
7
+
8
+ const TEXT_EXT = new Set([
9
+ '.ts',
10
+ '.tsx',
11
+ '.js',
12
+ '.jsx',
13
+ '.mjs',
14
+ '.cjs',
15
+ '.json',
16
+ '.md',
17
+ '.mdx',
18
+ '.css',
19
+ '.html',
20
+ '.yml',
21
+ '.yaml',
22
+ '.toml',
23
+ '.txt',
24
+ '.sql',
25
+ '.graphql',
26
+ '.gql',
27
+ '.sh',
28
+ '.bash',
29
+ '.zsh',
30
+ '.py',
31
+ '.go',
32
+ '.rs',
33
+ '.java',
34
+ '.kt',
35
+ '.swift',
36
+ '.php',
37
+ '.rb',
38
+ '.vue',
39
+ '.svelte',
40
+ '.xml',
41
+ '.ini',
42
+ '.conf',
43
+ '.dockerfile',
44
+ '.env.example',
45
+ '.gitignore',
46
+ '.editorconfig',
47
+ '.prettierrc',
48
+ '.eslintrc',
49
+ '.lock',
50
+ ])
51
+
52
+ function isProbablyTextFile(filePath: string): boolean {
53
+ const ext = path.extname(filePath).toLowerCase()
54
+ return TEXT_EXT.has(ext) || ext === ""
55
+ }
56
+
57
+ export class ToolExecutor {
58
+
59
+ private overlay = new Map<string, string>()
60
+ private deleted = new Set<string>()
61
+ private appliedActionIds = new Set<string>()
62
+ private readonly norm = (rel: string) => path.posix.normalize(rel.split(path.sep).join("/")).replace(/^\.\//, "");
63
+
64
+ constructor(
65
+ private readonly tracker: ActionTracker,
66
+ private readonly config: AgentConfig
67
+ ) {}
68
+
69
+ /**
70
+ * Resolves a relative path against the codebase root and performs strict
71
+ * validation checks against directory traversal vulnerabilities.
72
+ */
73
+ private resolveSafe(rel: string): string {
74
+ // Prevent obvious directory traversal sequences before joining
75
+ if (rel.includes('..') && (rel.split(/[/\\]/).includes('..'))) {
76
+ throw new Error(`Path traversal attempt detected via segment navigation: ${rel}`);
77
+ }
78
+
79
+ const root = path.resolve(this.config.codebasePath);
80
+ const abs = path.resolve(root, rel);
81
+
82
+ // Strict boundary validation checking via path relativity
83
+ const relCheck = path.relative(root, abs);
84
+ if (relCheck.startsWith('..') || path.isAbsolute(relCheck)) {
85
+ throw new Error(`Security Exception: Path is outside the designated workspace: ${rel}`);
86
+ }
87
+
88
+ return abs;
89
+ }
90
+
91
+ /**
92
+ * Hydrates the memory maps and execution sets using an external array of action logs.
93
+ * This completes the state synchronization loop, allowing the overlay system
94
+ * to pick up exactly where a previous session or sub-pipeline execution left off.
95
+ */
96
+ hydrateFromActions(actions: ActionLog[]): void {
97
+ // Reset local in-memory scratchpads first
98
+ this.overlay.clear();
99
+ this.deleted.clear();
100
+ this.appliedActionIds.clear();
101
+
102
+ // Sort historical mutations chronologically to accurately recreate sequential state
103
+ const sortedActions = [...actions].sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
104
+
105
+ for (const action of sortedActions) {
106
+ const key = this.norm(action.path);
107
+
108
+ if (action.status === "executed") {
109
+ this.appliedActionIds.add(action.id);
110
+ }
111
+
112
+ // Map pending or approved mutations to the virtual filesystem overlay layer
113
+ if (action.status === "pending" || action.status === "approved") {
114
+ if (action.type === "file_create" || action.type === "file_modify") {
115
+ if (action.details.after !== undefined) {
116
+ this.deleted.delete(key);
117
+ this.overlay.set(key, action.details.after);
118
+ }
119
+ } else if (action.type === "file_delete") {
120
+ this.overlay.delete(key);
121
+ this.deleted.add(key);
122
+ }
123
+ }
124
+ }
125
+ }
126
+
127
+ private excluded(relPath: string): boolean {
128
+ const norm = this.norm(relPath)
129
+ const segments = norm.split('/')
130
+ const base = segments[segments.length - 1] ?? ''
131
+
132
+ for (const pat of this.config.excludePatterns) {
133
+ if (pat === '*.log' && base.endsWith('.log')) return true
134
+ if (pat === '.env*' && base.startsWith('.env')) return true
135
+ if (pat.includes('*')) continue
136
+ if (segments.includes(pat) || norm === pat || norm.startsWith(`${pat}/`)) return true
137
+ }
138
+
139
+ return false
140
+ }
141
+
142
+ private assertNotExcluded(rel: string, op: string): void {
143
+ if (this.excluded(rel)) {
144
+ throw new Error(`${op}: path is excluded by policy: ${rel}`)
145
+ }
146
+ }
147
+
148
+ getEffectiveText(rel: string): string | undefined {
149
+ const key = this.norm(rel)
150
+ if (this.deleted.has(key)) return undefined
151
+ if (this.overlay.has(key)) return this.overlay.get(key)
152
+ const abs = this.resolveSafe(rel)
153
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) return undefined
154
+ return fs.readFileSync(abs, 'utf8')
155
+ }
156
+
157
+ normalizePath(rel: string): string {
158
+ return this.norm(rel)
159
+ }
160
+
161
+ readFile(rel: string): string {
162
+ this.assertNotExcluded(rel, "read_file");
163
+ const abs = this.resolveSafe(rel);
164
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) {
165
+ throw new Error(`File not found: ${rel}`);
166
+ }
167
+ const st = fs.statSync(abs);
168
+ if (st.size > this.config.maxFileSizeToRead) {
169
+ throw new Error(`File too large: ${rel}`);
170
+ }
171
+ const text = fs.readFileSync(abs, "utf8");
172
+ this.tracker.log({
173
+ type: "code_analysis",
174
+ path: this.norm(rel),
175
+ details: { after: text, toolName: "read_file" },
176
+ status: "executed",
177
+ });
178
+ return text;
179
+ }
180
+
181
+ createFile(rel: string, content: string): string {
182
+ if (!this.config.tools.allowFileCreation)
183
+ throw new Error("File creation disabled");
184
+ this.assertNotExcluded(rel, "create_file");
185
+ const key = this.norm(rel);
186
+ const abs = this.resolveSafe(rel);
187
+ if (fs.existsSync(abs) && !this.deleted.has(key)) {
188
+ throw new Error(`create_file: already exists: ${rel}`);
189
+ }
190
+ this.deleted.delete(key);
191
+ this.overlay.set(key, content);
192
+ this.tracker.log({
193
+ type: "file_create",
194
+ path: key,
195
+ details: { after: content },
196
+ status: "pending",
197
+ });
198
+ return `Staged new file: ${key}`;
199
+ }
200
+
201
+ modifyFile(rel: string, content: string): string {
202
+ if (!this.config.tools.allowFileModification)
203
+ throw new Error("File modification disabled");
204
+ this.assertNotExcluded(rel, "modify_file");
205
+ const before = this.getEffectiveText(rel);
206
+ if (before === undefined)
207
+ throw new Error(`modify_file: file not found: ${rel}`);
208
+ const key = this.norm(rel);
209
+ this.overlay.set(key, content);
210
+ this.tracker.log({
211
+ type: "file_modify",
212
+ path: key,
213
+ details: { before, after: content },
214
+ status: "pending",
215
+ });
216
+ return `Staged update: ${key}`;
217
+ }
218
+
219
+ deleteFile(rel: string): string {
220
+ if (!this.config.tools.allowFileModification)
221
+ throw new Error("File deletion disabled");
222
+ this.assertNotExcluded(rel, "delete_file");
223
+ const before = this.getEffectiveText(rel);
224
+ if (before === undefined)
225
+ throw new Error(`delete_file: file not found: ${rel}`);
226
+ const key = this.norm(rel);
227
+ this.overlay.delete(key);
228
+ this.deleted.add(key);
229
+ this.tracker.log({
230
+ type: "file_delete",
231
+ path: key,
232
+ details: { before },
233
+ status: "pending",
234
+ });
235
+ return `Staged delete: ${key}`;
236
+ }
237
+
238
+ createFolder(rel: string): string {
239
+ if (!this.config.tools.allowFolderCreation)
240
+ throw new Error("Folder creation disabled");
241
+ this.assertNotExcluded(rel, "create_folder");
242
+ const key = this.norm(rel);
243
+ this.tracker.log({
244
+ type: "folder_create",
245
+ path: key,
246
+ details: { after: key },
247
+ status: "pending",
248
+ });
249
+ return `Staged folder: ${key}`;
250
+ }
251
+
252
+ listFiles(rel: string, recursive: boolean): string {
253
+ this.assertNotExcluded(rel, "list_files");
254
+ const abs = this.resolveSafe(rel);
255
+ if (!fs.existsSync(abs)) throw new Error(`list_files: not found: ${rel}`);
256
+
257
+ const lines: string[] = [];
258
+ const walk = (dir: string, prefix: string) => {
259
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
260
+ for (const ent of entries) {
261
+ const full = path.join(dir, ent.name);
262
+ const relP = path.relative(this.config.codebasePath, full);
263
+ if (this.excluded(relP)) continue;
264
+ if (ent.isDirectory()) {
265
+ lines.push(`${prefix}${ent.name}/`);
266
+ if (recursive) walk(full, `${prefix}${ent.name}/`);
267
+ } else {
268
+ lines.push(`${prefix}${ent.name}`);
269
+ }
270
+ }
271
+ };
272
+
273
+ if (fs.statSync(abs).isDirectory()) walk(abs, "");
274
+ else lines.push(path.relative(this.config.codebasePath, abs));
275
+
276
+ const out = lines.sort().join("\n");
277
+ this.tracker.log({
278
+ type: "code_analysis",
279
+ path: this.norm(rel),
280
+ details: { after: out, toolName: "list_files" },
281
+ status: "executed",
282
+ });
283
+ return out || "(empty)";
284
+ }
285
+
286
+ searchFiles(
287
+ rootRel: string,
288
+ globPattern: string,
289
+ contentQuery?: string,
290
+ ): string {
291
+ this.assertNotExcluded(rootRel, "search_files");
292
+ const rootAbs = this.resolveSafe(rootRel);
293
+ if (!fs.existsSync(rootAbs))
294
+ throw new Error(`search_files: root not found: ${rootRel}`);
295
+
296
+ const results: string[] = [];
297
+ const regexFromGlob = (g: string): RegExp => {
298
+ // Convert glob pattern to regex:
299
+ // ** = match zero or more path segments (including /)
300
+ // * = match anything except /
301
+ // ? = match any single character except /
302
+ const escaped = g
303
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&") // escape regex metacharacters
304
+ .replace(/\\\*\\\*\//g, "(?:.+/)?") // **/ → optionally one-or-more-path-segments/
305
+ .replace(/\\\*\\\*/g, ".*") // ** → any characters including /
306
+ .replace(/\\\*/g, "[^/]*") // * → match anything except /
307
+ .replace(/\\\?/g, "[^/]"); // ? → match any single char except /
308
+ return new RegExp(`^${escaped}$`, "i");
309
+ };
310
+ const nameRe = regexFromGlob(globPattern.replace(/\\/g, "/"));
311
+
312
+ const walk = (dir: string) => {
313
+ for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
314
+ const full = path.join(dir, ent.name);
315
+ const relP = path
316
+ .relative(this.config.codebasePath, full)
317
+ .split(path.sep)
318
+ .join("/");
319
+ if (this.excluded(relP)) continue;
320
+ if (ent.isDirectory()) walk(full);
321
+ else if (nameRe.test(relP) || nameRe.test(ent.name)) {
322
+ if (contentQuery) {
323
+ if (!isProbablyTextFile(full)) continue;
324
+ const text = fs.readFileSync(full, "utf8");
325
+ if (!text.includes(contentQuery)) continue;
326
+ }
327
+ results.push(relP);
328
+ }
329
+ }
330
+ };
331
+
332
+ if (fs.statSync(rootAbs).isDirectory()) walk(rootAbs);
333
+ else {
334
+ const relP = path
335
+ .relative(this.config.codebasePath, rootAbs)
336
+ .split(path.sep)
337
+ .join("/");
338
+ results.push(relP);
339
+ }
340
+
341
+ const out = [...new Set(results)].sort().join("\n");
342
+ this.tracker.log({
343
+ type: "code_analysis",
344
+ path: this.norm(rootRel),
345
+ details: { after: out || "(no matches)", toolName: "search_files" },
346
+ status: "executed",
347
+ });
348
+ return out || "(no matches)";
349
+ }
350
+
351
+ analyzeCodebase(rootRel: string): string {
352
+ const rootAbs = this.resolveSafe(rootRel);
353
+ if (!fs.existsSync(rootAbs))
354
+ throw new Error(`analyze_codebase: not found: ${rootRel}`);
355
+
356
+ let files = 0;
357
+ let dirs = 0;
358
+ const walk = (dir: string) => {
359
+ for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
360
+ const full = path.join(dir, ent.name);
361
+ const relP = path.relative(this.config.codebasePath, full);
362
+ if (this.excluded(relP)) continue;
363
+ if (ent.isDirectory()) {
364
+ dirs++;
365
+ walk(full);
366
+ } else {
367
+ files++;
368
+ }
369
+ }
370
+ };
371
+ if (fs.statSync(rootAbs).isDirectory()) walk(rootAbs);
372
+ else files = 1;
373
+
374
+ const summary = `Files: ${files} | Directories: ${dirs}`;
375
+ this.tracker.log({
376
+ type: "code_analysis",
377
+ path: this.norm(rootRel),
378
+ details: { after: summary, toolName: "analyze_codebase" },
379
+ status: "executed",
380
+ });
381
+ return summary;
382
+ }
383
+
384
+ queueShell(command: string): string {
385
+ if (!this.config.tools.allowShellExecution)
386
+ throw new Error("Shell execution disabled");
387
+ this.tracker.log({
388
+ type: "tool_execute",
389
+ path: "shell",
390
+ details: { command, toolName: "execute_shell" },
391
+ status: "pending",
392
+ });
393
+ return `Shell queued: ${command}`;
394
+ }
395
+
396
+ readMultipleFiles(paths: string[]) {
397
+ const result: Record<string, string> = {};
398
+
399
+ for (const rel of paths) {
400
+ this.assertNotExcluded(rel, "read_multiple_files");
401
+ const abs = this.resolveSafe(rel);
402
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) {
403
+ throw new Error(`File not found: ${rel}`);
404
+ }
405
+ const st = fs.statSync(abs);
406
+ if (st.size > this.config.maxFileSizeToRead) {
407
+ throw new Error(`File too large: ${rel}`);
408
+ }
409
+ const content = fs.readFileSync(abs, "utf8");
410
+
411
+ // Log each file read individually for audit trail
412
+ this.tracker.log({
413
+ type: "code_analysis",
414
+ path: this.norm(rel),
415
+ details: { after: content, toolName: "read_multiple_files" },
416
+ status: "executed",
417
+ });
418
+
419
+ result[rel] = content;
420
+ }
421
+
422
+ return result;
423
+ }
424
+
425
+ replaceInFile(
426
+ rel: string,
427
+ search: string,
428
+ replace: string
429
+ ): string {
430
+ if (!this.config.tools.allowFileModification)
431
+ throw new Error("File modification disabled");
432
+
433
+ this.assertNotExcluded(rel, "replace_in_file");
434
+ const before = this.getEffectiveText(rel);
435
+
436
+ if (before === undefined)
437
+ throw new Error(`File not found: ${rel}`);
438
+
439
+ if (!before.includes(search))
440
+ throw new Error(`Search text not found in ${rel}`);
441
+
442
+ const after = before.replace(search, replace);
443
+ const key = this.norm(rel);
444
+
445
+ this.overlay.set(key, after);
446
+ this.deleted.delete(key);
447
+
448
+ this.tracker.log({
449
+ type: "file_modify",
450
+ path: key,
451
+ details: { before, after },
452
+ status: "pending"
453
+ });
454
+
455
+ return `Staged replacement in ${key}`;
456
+ }
457
+
458
+ appendToFile(
459
+ rel: string,
460
+ content: string
461
+ ): string {
462
+ if (!this.config.tools.allowFileModification)
463
+ throw new Error("File modification disabled");
464
+
465
+ this.assertNotExcluded(rel, "append_to_file");
466
+ const before = this.getEffectiveText(rel);
467
+
468
+ if (before === undefined)
469
+ throw new Error(`File not found: ${rel}`);
470
+
471
+ const after = before + content;
472
+ const key = this.norm(rel);
473
+
474
+ this.overlay.set(key, after);
475
+
476
+ this.tracker.log({
477
+ type: "file_modify",
478
+ path: key,
479
+ details: { before, after },
480
+ status: "pending"
481
+ });
482
+
483
+ return `Staged append: ${key}`;
484
+ }
485
+
486
+ insertAtLine(
487
+ rel: string,
488
+ line: number,
489
+ content: string
490
+ ): string {
491
+ if (!this.config.tools.allowFileModification)
492
+ throw new Error("File modification disabled");
493
+
494
+ this.assertNotExcluded(rel, "insert_at_line");
495
+ const before = this.getEffectiveText(rel);
496
+
497
+ if (before === undefined)
498
+ throw new Error(`File not found: ${rel}`);
499
+
500
+ const lines = before.split("\n");
501
+
502
+ if (line < 1 || line > lines.length + 1)
503
+ throw new Error(`Invalid line number: ${line}`);
504
+
505
+ lines.splice(line - 1, 0, content);
506
+ const after = lines.join("\n");
507
+ const key = this.norm(rel);
508
+
509
+ this.overlay.set(key, after);
510
+
511
+ this.tracker.log({
512
+ type: "file_modify",
513
+ path: key,
514
+ details: { before, after },
515
+ status: "pending"
516
+ });
517
+
518
+ return `Inserted content at line ${line} in ${key}`;
519
+ }
520
+
521
+ showPendingChanges() {
522
+ return this.tracker.getPendingMutations()
523
+ }
524
+
525
+ discardStagedPath(rel: string): void {
526
+ const key = this.norm(rel)
527
+ this.overlay.delete(key)
528
+ this.deleted.delete(key)
529
+ }
530
+
531
+ discardChanges(): string {
532
+ this.overlay.clear()
533
+ this.deleted.clear()
534
+ this.appliedActionIds.clear()
535
+ return "Discarded all staged changes"
536
+ }
537
+
538
+ gitStatus() {
539
+ const result = spawnSync(
540
+ "git",
541
+ ["status", "--short"],
542
+ {
543
+ cwd: this.config.codebasePath,
544
+ encoding: "utf8"
545
+ }
546
+ );
547
+ return result.stdout.trim();
548
+ }
549
+
550
+ gitDiff(staged = false) {
551
+ const args = staged ? ["diff", "--staged"] : ["diff"];
552
+ const result = spawnSync(
553
+ "git",
554
+ args,
555
+ {
556
+ cwd: this.config.codebasePath,
557
+ encoding: "utf8"
558
+ }
559
+ );
560
+ return result.stdout;
561
+ }
562
+
563
+ gitLog(limit = 20) {
564
+ const result = spawnSync(
565
+ "git",
566
+ [
567
+ "log",
568
+ "--oneline",
569
+ `-${limit}`
570
+ ],
571
+ {
572
+ cwd: this.config.codebasePath,
573
+ encoding: "utf8"
574
+ }
575
+ );
576
+ return result.stdout.trim();
577
+ }
578
+
579
+ runCommand(
580
+ command: string,
581
+ cwd?: string
582
+ ) {
583
+ const resolvedCwd = cwd ? this.resolveSafe(cwd) : this.config.codebasePath;
584
+ const result = spawnSync(
585
+ command,
586
+ {
587
+ cwd: resolvedCwd,
588
+ shell: true,
589
+ encoding: "utf8",
590
+ maxBuffer: 1024 * 1024 * 10
591
+ }
592
+ );
593
+ return {
594
+ exitCode: result.status,
595
+ stdout: result.stdout,
596
+ stderr: result.stderr
597
+ };
598
+ }
599
+
600
+ runBackgroundCommand(args: { command: string; cwd?: string }): string {
601
+ if (!this.config.tools.allowShellExecution)
602
+ throw new Error("Shell execution disabled");
603
+ const resolvedCwd = args.cwd ? this.resolveSafe(args.cwd) : this.config.codebasePath;
604
+ this.tracker.log({
605
+ type: "tool_execute",
606
+ path: "shell",
607
+ details: { command: args.command, toolName: "run_background_command" },
608
+ status: "pending",
609
+ });
610
+ const child = spawn(args.command, {
611
+ cwd: resolvedCwd,
612
+ shell: true,
613
+ detached: true,
614
+ stdio: "ignore",
615
+ });
616
+ child.unref();
617
+ return `Background process started (pid ${child.pid}): ${args.command}`;
618
+ }
619
+
620
+ grep(args: { root: string; query: string; caseSensitive: boolean }): string {
621
+ this.assertNotExcluded(args.root, "grep");
622
+ const rootAbs = this.resolveSafe(args.root);
623
+ if (!fs.existsSync(rootAbs))
624
+ throw new Error(`grep: root not found: ${args.root}`);
625
+
626
+ const flags = args.caseSensitive ? "" : "i";
627
+ const re = new RegExp(args.query, flags);
628
+ const matches: string[] = [];
629
+
630
+ const walk = (dir: string) => {
631
+ for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
632
+ const full = path.join(dir, ent.name);
633
+ const relP = path
634
+ .relative(this.config.codebasePath, full)
635
+ .split(path.sep)
636
+ .join("/");
637
+ if (this.excluded(relP)) continue;
638
+ if (ent.isDirectory()) {
639
+ walk(full);
640
+ } else if (isProbablyTextFile(full)) {
641
+ const text = fs.readFileSync(full, "utf8");
642
+ const lines = text.split("\n");
643
+ lines.forEach((line: string, idx: number) => {
644
+ if (re.test(line))
645
+ matches.push(`${relP}:${idx + 1}: ${line.trim()}`);
646
+ });
647
+ }
648
+ }
649
+ };
650
+
651
+ if (fs.statSync(rootAbs).isDirectory()) walk(rootAbs);
652
+ else if (isProbablyTextFile(rootAbs)) {
653
+ const text = fs.readFileSync(rootAbs, "utf8");
654
+ const lines = text.split("\n");
655
+ lines.forEach((line: string, idx: number) => {
656
+ if (re.test(line))
657
+ matches.push(`${args.root}:${idx + 1}: ${line.trim()}`);
658
+ });
659
+ }
660
+
661
+ const out = matches.join("\n");
662
+ this.tracker.log({
663
+ type: "code_analysis",
664
+ path: this.norm(args.root),
665
+ details: { after: out || "(no matches)", toolName: "grep" },
666
+ status: "executed",
667
+ });
668
+ return out || "(no matches)";
669
+ }
670
+
671
+ readPackageJson(): string {
672
+ const pkgPath = path.join(this.config.codebasePath, "package.json");
673
+ if (!fs.existsSync(pkgPath))
674
+ throw new Error("package.json not found in workspace root");
675
+ const text = fs.readFileSync(pkgPath, "utf8");
676
+ const pkg = JSON.parse(text);
677
+ const summary = {
678
+ name: pkg.name,
679
+ version: pkg.version,
680
+ description: pkg.description,
681
+ scripts: pkg.scripts ?? {},
682
+ dependencies: Object.keys(pkg.dependencies ?? {}),
683
+ devDependencies: Object.keys(pkg.devDependencies ?? {}),
684
+ };
685
+ this.tracker.log({
686
+ type: "code_analysis",
687
+ path: "package.json",
688
+ details: { after: JSON.stringify(summary, null, 2), toolName: "read_package_json" },
689
+ status: "executed",
690
+ });
691
+ return JSON.stringify(summary, null, 2);
692
+ }
693
+
694
+ runTests(filter?: string): { exitCode: number | null; stdout: string; stderr: string } {
695
+ const pkgPath = path.join(this.config.codebasePath, "package.json");
696
+ let testCmd = "npm test";
697
+ if (fs.existsSync(pkgPath)) {
698
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
699
+ if (pkg.scripts?.test) testCmd = "npm test";
700
+ else if (pkg.devDependencies?.vitest || pkg.dependencies?.vitest)
701
+ testCmd = "npx vitest run";
702
+ else if (pkg.devDependencies?.jest || pkg.dependencies?.jest)
703
+ testCmd = "npx jest";
704
+ }
705
+ const cmd = filter ? `${testCmd} -- ${filter}` : testCmd;
706
+ return this.runCommand(cmd);
707
+ }
708
+
709
+ runTestFile(filePath: string): { exitCode: number | null; stdout: string; stderr: string } {
710
+ this.assertNotExcluded(filePath, "run_test_file");
711
+ const pkgPath = path.join(this.config.codebasePath, "package.json");
712
+ let runner = "npx jest";
713
+ if (fs.existsSync(pkgPath)) {
714
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
715
+ if (pkg.devDependencies?.vitest || pkg.dependencies?.vitest)
716
+ runner = "npx vitest run";
717
+ }
718
+ return this.runCommand(`${runner} ${filePath}`);
719
+ }
720
+
721
+ lintProject(): { exitCode: number | null; stdout: string; stderr: string } {
722
+ const pkgPath = path.join(this.config.codebasePath, "package.json");
723
+ let lintCmd = "npx eslint .";
724
+ if (fs.existsSync(pkgPath)) {
725
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
726
+ if (pkg.scripts?.lint) lintCmd = "npm run lint";
727
+ }
728
+ return this.runCommand(lintCmd);
729
+ }
730
+
731
+ formatProject(): { exitCode: number | null; stdout: string; stderr: string } {
732
+ const pkgPath = path.join(this.config.codebasePath, "package.json");
733
+ let fmtCmd = "npx prettier --write .";
734
+ if (fs.existsSync(pkgPath)) {
735
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
736
+ if (pkg.scripts?.format) fmtCmd = "npm run format";
737
+ }
738
+ return this.runCommand(fmtCmd);
739
+ }
740
+
741
+ webSearch(query: string): string {
742
+ const encoded = encodeURIComponent(query);
743
+ const result = spawnSync(
744
+ "curl",
745
+ [
746
+ "-s",
747
+ "-A", "Mozilla/5.0",
748
+ `https://html.duckduckgo.com/html/?q=${encoded}`,
749
+ ],
750
+ { encoding: "utf8", maxBuffer: 1024 * 1024 * 5 }
751
+ );
752
+ if (result.status !== 0)
753
+ return `web_search error: ${result.stderr ?? "unknown"}`;
754
+ const text = result.stdout
755
+ .replace(/<style[\s\S]*?<\/style>/gi, "")
756
+ .replace(/<script[\s\S]*?<\/script>/gi, "")
757
+ .replace(/<[^>]+>/g, " ")
758
+ .replace(/\s{2,}/g, " ")
759
+ .trim()
760
+ .slice(0, 4000);
761
+ this.tracker.log({
762
+ type: "code_analysis",
763
+ path: "web",
764
+ details: { after: text, toolName: "web_search" },
765
+ status: "executed",
766
+ });
767
+ return text;
768
+ }
769
+
770
+ fetchUrl(url: string): string {
771
+ const result = spawnSync(
772
+ "curl",
773
+ ["-s", "-L", "-A", "Mozilla/5.0", "--max-time", "15", url],
774
+ { encoding: "utf8", maxBuffer: 1024 * 1024 * 5 }
775
+ );
776
+ if (result.status !== 0)
777
+ return `fetch_url error: ${result.stderr ?? "unknown"}`;
778
+ const text = result.stdout
779
+ .replace(/<style[\s\S]*?<\/style>/gi, "")
780
+ .replace(/<script[\s\S]*?<\/script>/gi, "")
781
+ .replace(/<[^>]+>/g, " ")
782
+ .replace(/\s{2,}/g, " ")
783
+ .trim()
784
+ .slice(0, 8000);
785
+ this.tracker.log({
786
+ type: "code_analysis",
787
+ path: url,
788
+ details: { after: text, toolName: "fetch_url" },
789
+ status: "executed",
790
+ });
791
+ return text;
792
+ }
793
+
794
+ private plan: { goal: string; steps: string[] } | null = null;
795
+
796
+ createPlan(goal: string): string {
797
+ this.plan = { goal, steps: [] };
798
+ this.tracker.log({
799
+ type: "code_analysis",
800
+ path: "plan",
801
+ details: { after: goal, toolName: "create_plan" },
802
+ status: "executed",
803
+ });
804
+ return `Plan created for goal: "${goal}". Use get_plan to retrieve it.`;
805
+ }
806
+
807
+ getPlan(): string {
808
+ if (!this.plan) return "(no active plan)";
809
+ return JSON.stringify(this.plan, null, 2);
810
+ }
811
+
812
+ private applyChanges(): { errors: string[] } {
813
+ for (const action of this.tracker.getActions()) {
814
+ if (action.status === "pending") {
815
+ action.status = "approved";
816
+ }
817
+ }
818
+ return this.applyApprovedFromTracker();
819
+ }
820
+
821
+ workspaceContext() {
822
+ return {
823
+ root: this.config.codebasePath,
824
+ pendingChanges: this.overlay.size,
825
+ pendingDeletes: this.deleted.size,
826
+ git: this.gitStatus(),
827
+ trackedActions: this.tracker.getPendingMutations().length
828
+ };
829
+ }
830
+
831
+ detectFramework() {
832
+ const pkgPath = path.join(this.config.codebasePath, "package.json");
833
+ if (!existsSync(pkgPath)) return { framework: "unknown" };
834
+
835
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
836
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
837
+
838
+ if ("next" in deps) return { framework: "Next.js" };
839
+ if ("react" in deps) return { framework: "React" };
840
+ if ("vue" in deps) return { framework: "Vue" };
841
+ if ("svelte" in deps) return { framework: "Svelte" };
842
+
843
+ return { framework: "Node.js" };
844
+ }
845
+
846
+ skillRoots(): string[] {
847
+ const extra = process.env.SKILLS_DIRS?.split(/[;]/).map((s: string) => s.trim()).filter(Boolean) ?? [];
848
+ return [
849
+ ...extra,
850
+ path.join(homedir(), ".cursor/skills-cursor"),
851
+ path.join(homedir(), ".claude/skills"),
852
+ ];
853
+ }
854
+
855
+ listSkills(): string {
856
+ const lines: string[] = [];
857
+ for (const root of this.skillRoots()) {
858
+ if (!fs.existsSync(root)) continue;
859
+ const walk = (dir: string) => {
860
+ for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
861
+ const full = path.join(dir, ent.name);
862
+ if (ent.isDirectory()) walk(full);
863
+ else if (ent.name === "SKILL.md") lines.push(full);
864
+ }
865
+ };
866
+ walk(root);
867
+ }
868
+ const out = lines.sort().join("\n");
869
+ this.tracker.log({
870
+ type: "code_analysis",
871
+ path: "skills",
872
+ details: { after: out || "(none)", toolName: "list_skills" },
873
+ status: "executed",
874
+ });
875
+ return out || "(none)";
876
+ }
877
+
878
+ readSkill(skillPath: string): string {
879
+ const abs = path.isAbsolute(skillPath)
880
+ ? path.normalize(skillPath)
881
+ : path.normalize(path.resolve(this.config.codebasePath, skillPath));
882
+ const allowed = this.skillRoots().some((root) => {
883
+ const r = path.resolve(root);
884
+ return abs === r || abs.startsWith(r + path.sep);
885
+ });
886
+ if (!allowed) throw new Error("read_skill: outside skill roots");
887
+ const text = fs.readFileSync(abs, "utf8");
888
+ this.tracker.log({
889
+ type: "code_analysis",
890
+ path: abs,
891
+ details: { after: text, toolName: "read_skill" },
892
+ status: "executed",
893
+ });
894
+ return text;
895
+ }
896
+
897
+ applyApprovedFromTracker(): { errors: string[] } {
898
+ const errors: string[] = [];
899
+ const all = [...this.tracker.getActions()];
900
+
901
+ for (const a of all.filter(
902
+ (x) => x.type === "folder_create" && x.status === "approved" && !this.appliedActionIds.has(x.id),
903
+ )) {
904
+ try {
905
+ fs.mkdirSync(this.resolveSafe(a.path), { recursive: true });
906
+ this.appliedActionIds.add(a.id);
907
+ } catch (e) {
908
+ errors.push(String(e));
909
+ }
910
+ }
911
+
912
+ const fileOps = all
913
+ .filter(
914
+ (a) =>
915
+ (a.type === "file_create" || a.type === "file_modify" || a.type === "file_delete") &&
916
+ a.status === "approved" &&
917
+ !this.appliedActionIds.has(a.id),
918
+ )
919
+ .sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
920
+
921
+ const opsByPath = new Map<string, ActionLog[]>();
922
+ for (const a of fileOps) {
923
+ const key = this.norm(a.path);
924
+ const existing = opsByPath.get(key) ?? [];
925
+ existing.push(a);
926
+ opsByPath.set(key, existing);
927
+ }
928
+
929
+ for (const [p, ops] of opsByPath) {
930
+ const a = ops[ops.length - 1];
931
+ if (!a) continue;
932
+ try {
933
+ if (a.type === "file_delete")
934
+ fs.rmSync(this.resolveSafe(p), { force: true });
935
+ else {
936
+ const target = this.resolveSafe(p);
937
+ fs.mkdirSync(path.dirname(target), { recursive: true });
938
+ fs.writeFileSync(target, a.details.after ?? "", "utf8");
939
+ }
940
+ for (const op of ops) this.appliedActionIds.add(op.id);
941
+ } catch (e) {
942
+ errors.push(String(e));
943
+ }
944
+ }
945
+
946
+ for (const a of all.filter(
947
+ (x) => x.type === "tool_execute" && x.status === "approved" && !this.appliedActionIds.has(x.id),
948
+ )) {
949
+ const cmd = a.details.command;
950
+ if (!cmd) continue;
951
+
952
+ try {
953
+ const r = spawnSync(cmd, {
954
+ shell: true,
955
+ cwd: this.config.codebasePath,
956
+ encoding: "utf8",
957
+ maxBuffer: 16 * 1024 * 1024, // 16MB allocation guard rail
958
+ timeout: 300000, // 5-minute definitive timeout threshold limit
959
+ });
960
+
961
+ // Check if the operation was forcefully killed due to a timeout hang
962
+ if (r.error && (r.error as any).code === "ETIMEDOUT") {
963
+ errors.push(`Command timed out after 5 minutes of inactivity: "${cmd}"`);
964
+ this.appliedActionIds.add(a.id);
965
+ continue;
966
+ }
967
+
968
+ // Truncate overly long command execution outputs to keep CLI context stable
969
+ const MAX_OUTPUT_LENGTH = 15000;
970
+ let cleanStdout = r.stdout || "";
971
+ let cleanStderr = r.stderr || "";
972
+
973
+ if (cleanStdout.length > MAX_OUTPUT_LENGTH) {
974
+ cleanStdout = cleanStdout.slice(0, MAX_OUTPUT_LENGTH) + "\n\n... [Output Truncated by Astra for context optimization] ...";
975
+ }
976
+ if (cleanStderr.length > MAX_OUTPUT_LENGTH) {
977
+ cleanStderr = cleanStderr.slice(0, MAX_OUTPUT_LENGTH) + "\n\n... [Error Log Truncated by Astra] ...";
978
+ }
979
+
980
+ if (r.status !== 0) {
981
+ errors.push(`Command "${cmd}" exited with code ${r.status}. Error: ${cleanStderr}`);
982
+ }
983
+
984
+ this.appliedActionIds.add(a.id);
985
+ } catch (spawnError) {
986
+ errors.push(`Execution error spawning command "${cmd}": ${(spawnError as Error).message}`);
987
+ this.appliedActionIds.add(a.id);
988
+ }
989
+ }
990
+
991
+ return { errors };
992
+ }
993
+ }