rafaygen-cli 1.3.1 → 1.3.3
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/rgcli.js +302 -29
- package/package.json +2 -2
- package/src/agent.js +1297 -191
- package/src/auth.js +446 -105
- package/src/executor.js +737 -0
- package/src/state.js +254 -10
- package/src/ui.js +419 -51
package/src/executor.js
ADDED
|
@@ -0,0 +1,737 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { exec } from "child_process";
|
|
4
|
+
import chalk from "chalk";
|
|
5
|
+
import inquirer from "inquirer";
|
|
6
|
+
import boxen from "boxen";
|
|
7
|
+
import {
|
|
8
|
+
renderBox,
|
|
9
|
+
renderDiffBox,
|
|
10
|
+
renderCodeBox,
|
|
11
|
+
printSuccess,
|
|
12
|
+
printError,
|
|
13
|
+
printWarning,
|
|
14
|
+
} from "./ui.js";
|
|
15
|
+
import { getSessionState } from "./state.js";
|
|
16
|
+
|
|
17
|
+
// ──────────────────────────────────────────────
|
|
18
|
+
// Sandbox Policy
|
|
19
|
+
// ──────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
const SANDBOX_RULES = {
|
|
22
|
+
"read-only": {
|
|
23
|
+
allowed: new Set(["read"]),
|
|
24
|
+
label: "read-only",
|
|
25
|
+
},
|
|
26
|
+
"workspace-write": {
|
|
27
|
+
allowed: new Set(["read", "write", "patch", "delete", "mkdir"]),
|
|
28
|
+
label: "workspace-write",
|
|
29
|
+
},
|
|
30
|
+
"danger-full-access": {
|
|
31
|
+
allowed: new Set(["read", "write", "patch", "delete", "mkdir", "execute"]),
|
|
32
|
+
label: "danger-full-access",
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Checks whether the current sandbox mode permits the given action type.
|
|
38
|
+
* For workspace-write mode, also enforces that file paths stay within cwd.
|
|
39
|
+
* Returns { allowed: boolean, reason?: string }
|
|
40
|
+
*/
|
|
41
|
+
function checkSandbox(actionType, targetPath) {
|
|
42
|
+
const state = getSessionState();
|
|
43
|
+
const mode = state.sandboxMode || "danger-full-access";
|
|
44
|
+
const rules = SANDBOX_RULES[mode];
|
|
45
|
+
|
|
46
|
+
if (!rules) {
|
|
47
|
+
return {
|
|
48
|
+
allowed: false,
|
|
49
|
+
reason: `Unknown sandbox mode "${mode}".`,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!rules.allowed.has(actionType)) {
|
|
54
|
+
return {
|
|
55
|
+
allowed: false,
|
|
56
|
+
reason: `Sandbox mode "${rules.label}" does not allow "${actionType}" actions.`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// workspace-write: enforce cwd boundary for mutating file operations
|
|
61
|
+
if (mode === "workspace-write" && targetPath) {
|
|
62
|
+
const resolvedTarget = path.resolve(state.cwd, targetPath);
|
|
63
|
+
const resolvedCwd = path.resolve(state.cwd);
|
|
64
|
+
if (!resolvedTarget.startsWith(resolvedCwd + path.sep) && resolvedTarget !== resolvedCwd) {
|
|
65
|
+
return {
|
|
66
|
+
allowed: false,
|
|
67
|
+
reason: `Path "${targetPath}" is outside the current workspace. Sandbox mode "${rules.label}" only permits operations within "${resolvedCwd}".`,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return { allowed: true };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ──────────────────────────────────────────────
|
|
76
|
+
// Approval Policy
|
|
77
|
+
// ──────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Determines whether the user must be prompted for approval.
|
|
81
|
+
* Returns { proceed: boolean, autoApproved: boolean, blocked: boolean }
|
|
82
|
+
*
|
|
83
|
+
* approvalMode values:
|
|
84
|
+
* suggest – always ask for confirmation
|
|
85
|
+
* auto-edit – auto-approve writes/patch/delete/mkdir, ask for execute
|
|
86
|
+
* full-auto – auto-approve everything
|
|
87
|
+
* never – block everything (no actions allowed)
|
|
88
|
+
*/
|
|
89
|
+
function resolveApproval(actionType) {
|
|
90
|
+
const state = getSessionState();
|
|
91
|
+
const mode = state.approvalMode || "suggest";
|
|
92
|
+
|
|
93
|
+
switch (mode) {
|
|
94
|
+
case "never":
|
|
95
|
+
return { proceed: false, autoApproved: false, blocked: true };
|
|
96
|
+
|
|
97
|
+
case "full-auto":
|
|
98
|
+
return { proceed: true, autoApproved: true, blocked: false };
|
|
99
|
+
|
|
100
|
+
case "auto-edit": {
|
|
101
|
+
const autoTypes = new Set(["write", "patch", "delete", "mkdir", "read"]);
|
|
102
|
+
if (autoTypes.has(actionType)) {
|
|
103
|
+
return { proceed: true, autoApproved: true, blocked: false };
|
|
104
|
+
}
|
|
105
|
+
// execute still needs confirmation
|
|
106
|
+
return { proceed: false, autoApproved: false, blocked: false };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
case "suggest":
|
|
110
|
+
default:
|
|
111
|
+
// read is always auto-approved even in suggest mode
|
|
112
|
+
if (actionType === "read") {
|
|
113
|
+
return { proceed: true, autoApproved: true, blocked: false };
|
|
114
|
+
}
|
|
115
|
+
return { proceed: false, autoApproved: false, blocked: false };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Prompts the user for confirmation with a styled message.
|
|
121
|
+
* Returns true if user confirms.
|
|
122
|
+
*/
|
|
123
|
+
async function askConfirmation(message) {
|
|
124
|
+
const { confirmed } = await inquirer.prompt([
|
|
125
|
+
{
|
|
126
|
+
type: "confirm",
|
|
127
|
+
name: "confirmed",
|
|
128
|
+
message,
|
|
129
|
+
default: true,
|
|
130
|
+
},
|
|
131
|
+
]);
|
|
132
|
+
return confirmed;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ──────────────────────────────────────────────
|
|
136
|
+
// Shared helpers
|
|
137
|
+
// ──────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
function resolvePath(filePath) {
|
|
140
|
+
const state = getSessionState();
|
|
141
|
+
return path.resolve(state.cwd, filePath);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function detectLanguage(filePath) {
|
|
145
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
146
|
+
const map = {
|
|
147
|
+
".js": "javascript",
|
|
148
|
+
".mjs": "javascript",
|
|
149
|
+
".cjs": "javascript",
|
|
150
|
+
".jsx": "javascript",
|
|
151
|
+
".ts": "typescript",
|
|
152
|
+
".tsx": "typescript",
|
|
153
|
+
".py": "python",
|
|
154
|
+
".rb": "ruby",
|
|
155
|
+
".go": "go",
|
|
156
|
+
".rs": "rust",
|
|
157
|
+
".java": "java",
|
|
158
|
+
".c": "c",
|
|
159
|
+
".cpp": "cpp",
|
|
160
|
+
".h": "cpp",
|
|
161
|
+
".hpp": "cpp",
|
|
162
|
+
".cs": "csharp",
|
|
163
|
+
".php": "php",
|
|
164
|
+
".html": "html",
|
|
165
|
+
".htm": "html",
|
|
166
|
+
".css": "css",
|
|
167
|
+
".scss": "scss",
|
|
168
|
+
".sass": "sass",
|
|
169
|
+
".less": "less",
|
|
170
|
+
".json": "json",
|
|
171
|
+
".xml": "xml",
|
|
172
|
+
".yml": "yaml",
|
|
173
|
+
".yaml": "yaml",
|
|
174
|
+
".md": "markdown",
|
|
175
|
+
".sql": "sql",
|
|
176
|
+
".sh": "bash",
|
|
177
|
+
".bash": "bash",
|
|
178
|
+
".zsh": "bash",
|
|
179
|
+
".fish": "bash",
|
|
180
|
+
".ps1": "powershell",
|
|
181
|
+
".bat": "bat",
|
|
182
|
+
".cmd": "bat",
|
|
183
|
+
".dockerfile": "dockerfile",
|
|
184
|
+
".toml": "toml",
|
|
185
|
+
".ini": "ini",
|
|
186
|
+
".cfg": "ini",
|
|
187
|
+
".lua": "lua",
|
|
188
|
+
".r": "r",
|
|
189
|
+
".R": "r",
|
|
190
|
+
".swift": "swift",
|
|
191
|
+
".kt": "kotlin",
|
|
192
|
+
".kts": "kotlin",
|
|
193
|
+
".dart": "dart",
|
|
194
|
+
".ex": "elixir",
|
|
195
|
+
".exs": "elixir",
|
|
196
|
+
".erl": "erlang",
|
|
197
|
+
".hs": "haskell",
|
|
198
|
+
".vue": "html",
|
|
199
|
+
".svelte": "html",
|
|
200
|
+
};
|
|
201
|
+
return map[ext] || "plaintext";
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function formatBytes(bytes) {
|
|
205
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
206
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
207
|
+
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ──────────────────────────────────────────────
|
|
211
|
+
// Action Handlers
|
|
212
|
+
// ──────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* type='write' — Write content to a file.
|
|
216
|
+
* Shows a diff box comparing old vs new content, asks for approval,
|
|
217
|
+
* then writes the file (creating parent directories as needed).
|
|
218
|
+
*/
|
|
219
|
+
async function handleWrite(action) {
|
|
220
|
+
const filePath = resolvePath(action.file);
|
|
221
|
+
const relPath = action.file;
|
|
222
|
+
|
|
223
|
+
// Read existing content for diff (empty string if new file)
|
|
224
|
+
let original = "";
|
|
225
|
+
let isNew = true;
|
|
226
|
+
if (fs.existsSync(filePath)) {
|
|
227
|
+
original = fs.readFileSync(filePath, "utf-8");
|
|
228
|
+
isNew = false;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const content = action.content || "";
|
|
232
|
+
|
|
233
|
+
// Show diff
|
|
234
|
+
if (isNew) {
|
|
235
|
+
console.log(
|
|
236
|
+
chalk.cyan.bold("\n📄 New file: ") + chalk.white(relPath)
|
|
237
|
+
);
|
|
238
|
+
renderCodeBox(relPath, content, detectLanguage(relPath));
|
|
239
|
+
} else {
|
|
240
|
+
renderDiffBox(relPath, original, content);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Approval
|
|
244
|
+
const approval = resolveApproval("write");
|
|
245
|
+
if (approval.blocked) {
|
|
246
|
+
printWarning(`Action blocked: approval mode is set to "never".`);
|
|
247
|
+
return { applied: false, reason: "blocked" };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
let confirmed = approval.proceed;
|
|
251
|
+
if (!confirmed) {
|
|
252
|
+
confirmed = await askConfirmation(
|
|
253
|
+
`Allow writing to ${chalk.bold(relPath)}?`
|
|
254
|
+
);
|
|
255
|
+
} else if (approval.autoApproved) {
|
|
256
|
+
console.log(chalk.dim(` ⚡ Auto-approved (${getSessionState().approvalMode})`));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (!confirmed) {
|
|
260
|
+
console.log(chalk.yellow(" ↩ Skipped write."));
|
|
261
|
+
return { applied: false, reason: "declined" };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Write
|
|
265
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
266
|
+
fs.writeFileSync(filePath, content, "utf-8");
|
|
267
|
+
printSuccess(`Saved ${relPath} (${formatBytes(Buffer.byteLength(content, "utf-8"))})`);
|
|
268
|
+
return { applied: true };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* type='execute' — Execute a shell command.
|
|
273
|
+
* Shows the command in a box, asks for approval, runs it.
|
|
274
|
+
*/
|
|
275
|
+
async function handleExecute(action) {
|
|
276
|
+
const command = action.command;
|
|
277
|
+
const state = getSessionState();
|
|
278
|
+
|
|
279
|
+
// Display the command
|
|
280
|
+
renderBox(
|
|
281
|
+
" 🔧 Execute Command ",
|
|
282
|
+
chalk.yellowBright(command),
|
|
283
|
+
"magenta"
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
if (action.description) {
|
|
287
|
+
console.log(chalk.dim(` Description: ${action.description}`));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Approval
|
|
291
|
+
const approval = resolveApproval("execute");
|
|
292
|
+
if (approval.blocked) {
|
|
293
|
+
printWarning(`Action blocked: approval mode is set to "never".`);
|
|
294
|
+
return { applied: false, reason: "blocked" };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
let confirmed = approval.proceed;
|
|
298
|
+
if (!confirmed) {
|
|
299
|
+
confirmed = await askConfirmation(
|
|
300
|
+
`Allow executing: ${chalk.bold(command)}?`
|
|
301
|
+
);
|
|
302
|
+
} else if (approval.autoApproved) {
|
|
303
|
+
console.log(chalk.dim(` ⚡ Auto-approved (${state.approvalMode})`));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (!confirmed) {
|
|
307
|
+
console.log(chalk.yellow(" ↩ Skipped execution."));
|
|
308
|
+
return { applied: false, reason: "declined" };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Execute
|
|
312
|
+
return new Promise((resolve) => {
|
|
313
|
+
const cwd = action.cwd ? path.resolve(state.cwd, action.cwd) : state.cwd;
|
|
314
|
+
const timeout = action.timeout || 30000;
|
|
315
|
+
|
|
316
|
+
exec(command, { cwd, timeout, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
317
|
+
if (stdout && stdout.trim()) {
|
|
318
|
+
console.log(
|
|
319
|
+
boxen(chalk.gray(stdout.trimEnd()), {
|
|
320
|
+
title: chalk.dim(" stdout "),
|
|
321
|
+
titleAlignment: "left",
|
|
322
|
+
padding: { top: 0, bottom: 0, left: 1, right: 1 },
|
|
323
|
+
borderStyle: "single",
|
|
324
|
+
borderColor: "gray",
|
|
325
|
+
dimBorder: true,
|
|
326
|
+
})
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
if (stderr && stderr.trim()) {
|
|
330
|
+
console.log(
|
|
331
|
+
boxen(chalk.red(stderr.trimEnd()), {
|
|
332
|
+
title: chalk.dim(" stderr "),
|
|
333
|
+
titleAlignment: "left",
|
|
334
|
+
padding: { top: 0, bottom: 0, left: 1, right: 1 },
|
|
335
|
+
borderStyle: "single",
|
|
336
|
+
borderColor: "red",
|
|
337
|
+
dimBorder: true,
|
|
338
|
+
})
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
if (err) {
|
|
342
|
+
printError(`Command failed with exit code ${err.code || 1}`);
|
|
343
|
+
resolve({ applied: false, reason: "error", exitCode: err.code, stderr });
|
|
344
|
+
} else {
|
|
345
|
+
printSuccess("Command executed successfully.");
|
|
346
|
+
resolve({ applied: true, stdout, stderr });
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* type='patch' — Apply a unified diff / patch to an existing file.
|
|
354
|
+
* Reads the file, applies line-by-line additions/removals, writes back.
|
|
355
|
+
*/
|
|
356
|
+
async function handlePatch(action) {
|
|
357
|
+
const filePath = resolvePath(action.file);
|
|
358
|
+
const relPath = action.file;
|
|
359
|
+
|
|
360
|
+
if (!fs.existsSync(filePath)) {
|
|
361
|
+
printError(`Cannot patch "${relPath}": file does not exist.`);
|
|
362
|
+
return { applied: false, reason: "not_found" };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const original = fs.readFileSync(filePath, "utf-8");
|
|
366
|
+
let patched;
|
|
367
|
+
|
|
368
|
+
if (action.search && action.replace !== undefined) {
|
|
369
|
+
// Simple search-and-replace patch
|
|
370
|
+
if (!original.includes(action.search)) {
|
|
371
|
+
printError(`Patch failed: could not find the search text in "${relPath}".`);
|
|
372
|
+
return { applied: false, reason: "search_not_found" };
|
|
373
|
+
}
|
|
374
|
+
patched = original.replace(action.search, action.replace);
|
|
375
|
+
} else if (action.diff) {
|
|
376
|
+
// Unified diff format — apply manually
|
|
377
|
+
patched = applyUnifiedDiff(original, action.diff);
|
|
378
|
+
if (patched === null) {
|
|
379
|
+
printError(`Patch failed: could not apply the diff to "${relPath}".`);
|
|
380
|
+
return { applied: false, reason: "diff_apply_failed" };
|
|
381
|
+
}
|
|
382
|
+
} else if (action.content) {
|
|
383
|
+
// Full replacement content provided
|
|
384
|
+
patched = action.content;
|
|
385
|
+
} else {
|
|
386
|
+
printError(`Patch action missing "search"/"replace", "diff", or "content" field.`);
|
|
387
|
+
return { applied: false, reason: "invalid_patch" };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Show diff
|
|
391
|
+
renderDiffBox(relPath, original, patched);
|
|
392
|
+
|
|
393
|
+
// Approval
|
|
394
|
+
const approval = resolveApproval("patch");
|
|
395
|
+
if (approval.blocked) {
|
|
396
|
+
printWarning(`Action blocked: approval mode is set to "never".`);
|
|
397
|
+
return { applied: false, reason: "blocked" };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
let confirmed = approval.proceed;
|
|
401
|
+
if (!confirmed) {
|
|
402
|
+
confirmed = await askConfirmation(
|
|
403
|
+
`Allow patching ${chalk.bold(relPath)}?`
|
|
404
|
+
);
|
|
405
|
+
} else if (approval.autoApproved) {
|
|
406
|
+
console.log(chalk.dim(` ⚡ Auto-approved (${getSessionState().approvalMode})`));
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (!confirmed) {
|
|
410
|
+
console.log(chalk.yellow(" ↩ Skipped patch."));
|
|
411
|
+
return { applied: false, reason: "declined" };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
fs.writeFileSync(filePath, patched, "utf-8");
|
|
415
|
+
printSuccess(`Patched ${relPath}`);
|
|
416
|
+
return { applied: true };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* type='delete' — Delete a file with confirmation.
|
|
421
|
+
*/
|
|
422
|
+
async function handleDelete(action) {
|
|
423
|
+
const filePath = resolvePath(action.file);
|
|
424
|
+
const relPath = action.file;
|
|
425
|
+
|
|
426
|
+
if (!fs.existsSync(filePath)) {
|
|
427
|
+
printWarning(`File "${relPath}" does not exist — nothing to delete.`);
|
|
428
|
+
return { applied: false, reason: "not_found" };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const stat = fs.statSync(filePath);
|
|
432
|
+
const sizeStr = stat.isFile() ? ` (${formatBytes(stat.size)})` : " (directory)";
|
|
433
|
+
|
|
434
|
+
renderBox(
|
|
435
|
+
" 🗑️ Delete ",
|
|
436
|
+
`${chalk.red.bold("File:")} ${relPath}${chalk.dim(sizeStr)}`,
|
|
437
|
+
"red"
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
// Approval
|
|
441
|
+
const approval = resolveApproval("delete");
|
|
442
|
+
if (approval.blocked) {
|
|
443
|
+
printWarning(`Action blocked: approval mode is set to "never".`);
|
|
444
|
+
return { applied: false, reason: "blocked" };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
let confirmed = approval.proceed;
|
|
448
|
+
if (!confirmed) {
|
|
449
|
+
confirmed = await askConfirmation(
|
|
450
|
+
`Allow deleting ${chalk.bold(relPath)}?`
|
|
451
|
+
);
|
|
452
|
+
} else if (approval.autoApproved) {
|
|
453
|
+
console.log(chalk.dim(` ⚡ Auto-approved (${getSessionState().approvalMode})`));
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (!confirmed) {
|
|
457
|
+
console.log(chalk.yellow(" ↩ Skipped deletion."));
|
|
458
|
+
return { applied: false, reason: "declined" };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (stat.isDirectory()) {
|
|
462
|
+
fs.rmSync(filePath, { recursive: true, force: true });
|
|
463
|
+
} else {
|
|
464
|
+
fs.unlinkSync(filePath);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
printSuccess(`Deleted ${relPath}`);
|
|
468
|
+
return { applied: true };
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* type='mkdir' — Create a directory (recursively).
|
|
473
|
+
*/
|
|
474
|
+
async function handleMkdir(action) {
|
|
475
|
+
const dirPath = resolvePath(action.path || action.file);
|
|
476
|
+
const relPath = action.path || action.file;
|
|
477
|
+
|
|
478
|
+
if (fs.existsSync(dirPath)) {
|
|
479
|
+
printWarning(`Directory "${relPath}" already exists.`);
|
|
480
|
+
return { applied: true, reason: "already_exists" };
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
renderBox(
|
|
484
|
+
" 📁 Create Directory ",
|
|
485
|
+
chalk.cyan(relPath),
|
|
486
|
+
"cyan"
|
|
487
|
+
);
|
|
488
|
+
|
|
489
|
+
// Approval
|
|
490
|
+
const approval = resolveApproval("mkdir");
|
|
491
|
+
if (approval.blocked) {
|
|
492
|
+
printWarning(`Action blocked: approval mode is set to "never".`);
|
|
493
|
+
return { applied: false, reason: "blocked" };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
let confirmed = approval.proceed;
|
|
497
|
+
if (!confirmed) {
|
|
498
|
+
confirmed = await askConfirmation(
|
|
499
|
+
`Allow creating directory ${chalk.bold(relPath)}?`
|
|
500
|
+
);
|
|
501
|
+
} else if (approval.autoApproved) {
|
|
502
|
+
console.log(chalk.dim(` ⚡ Auto-approved (${getSessionState().approvalMode})`));
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (!confirmed) {
|
|
506
|
+
console.log(chalk.yellow(" ↩ Skipped mkdir."));
|
|
507
|
+
return { applied: false, reason: "declined" };
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
511
|
+
printSuccess(`Created directory ${relPath}`);
|
|
512
|
+
return { applied: true };
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* type='read' — Read and display file content in a code box.
|
|
517
|
+
*/
|
|
518
|
+
async function handleRead(action) {
|
|
519
|
+
const filePath = resolvePath(action.file);
|
|
520
|
+
const relPath = action.file;
|
|
521
|
+
|
|
522
|
+
if (!fs.existsSync(filePath)) {
|
|
523
|
+
printError(`File "${relPath}" does not exist.`);
|
|
524
|
+
return { applied: false, reason: "not_found" };
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const stat = fs.statSync(filePath);
|
|
528
|
+
|
|
529
|
+
if (stat.isDirectory()) {
|
|
530
|
+
const entries = fs.readdirSync(filePath);
|
|
531
|
+
const listing = entries
|
|
532
|
+
.map((entry) => {
|
|
533
|
+
const entryPath = path.join(filePath, entry);
|
|
534
|
+
const entryStat = fs.statSync(entryPath);
|
|
535
|
+
const icon = entryStat.isDirectory() ? "📁" : "📄";
|
|
536
|
+
const size = entryStat.isFile() ? chalk.dim(` (${formatBytes(entryStat.size)})`) : "";
|
|
537
|
+
return ` ${icon} ${entry}${size}`;
|
|
538
|
+
})
|
|
539
|
+
.join("\n");
|
|
540
|
+
renderBox(` 📂 Directory: ${relPath} `, listing || chalk.dim(" (empty)"), "blue");
|
|
541
|
+
return { applied: true, content: entries };
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Refuse to display very large binary files
|
|
545
|
+
if (stat.size > 1024 * 1024) {
|
|
546
|
+
printWarning(
|
|
547
|
+
`File "${relPath}" is ${formatBytes(stat.size)} — too large to display. Showing first 200 lines.`
|
|
548
|
+
);
|
|
549
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
550
|
+
const truncated = content.split("\n").slice(0, 200).join("\n");
|
|
551
|
+
renderCodeBox(relPath, truncated, detectLanguage(relPath));
|
|
552
|
+
return { applied: true, truncated: true, content: truncated };
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
556
|
+
const lineCount = content.split("\n").length;
|
|
557
|
+
|
|
558
|
+
console.log(
|
|
559
|
+
chalk.dim(
|
|
560
|
+
` ${lineCount} lines · ${formatBytes(stat.size)} · ${detectLanguage(relPath)}`
|
|
561
|
+
)
|
|
562
|
+
);
|
|
563
|
+
renderCodeBox(relPath, content, detectLanguage(relPath));
|
|
564
|
+
return { applied: true, content };
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// ──────────────────────────────────────────────
|
|
568
|
+
// Unified diff applier
|
|
569
|
+
// ──────────────────────────────────────────────
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Applies a unified-format diff string to the original file content.
|
|
573
|
+
* Handles lines starting with '+', '-', ' ', and hunk headers (@@ ... @@).
|
|
574
|
+
* Returns the patched string or null if application fails.
|
|
575
|
+
*/
|
|
576
|
+
function applyUnifiedDiff(original, diff) {
|
|
577
|
+
const originalLines = original.split("\n");
|
|
578
|
+
const diffLines = diff.split("\n");
|
|
579
|
+
const result = [];
|
|
580
|
+
let origIdx = 0;
|
|
581
|
+
|
|
582
|
+
for (let i = 0; i < diffLines.length; i++) {
|
|
583
|
+
const line = diffLines[i];
|
|
584
|
+
|
|
585
|
+
// Skip diff metadata lines
|
|
586
|
+
if (
|
|
587
|
+
line.startsWith("---") ||
|
|
588
|
+
line.startsWith("+++") ||
|
|
589
|
+
line.startsWith("diff ") ||
|
|
590
|
+
line.startsWith("index ")
|
|
591
|
+
) {
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Hunk header: @@ -start,count +start,count @@
|
|
596
|
+
const hunkMatch = line.match(/^@@\s+-(\d+)(?:,\d+)?\s+\+\d+(?:,\d+)?\s+@@/);
|
|
597
|
+
if (hunkMatch) {
|
|
598
|
+
const hunkStart = parseInt(hunkMatch[1], 10) - 1; // convert to 0-indexed
|
|
599
|
+
// Copy all lines from current position up to hunk start
|
|
600
|
+
while (origIdx < hunkStart && origIdx < originalLines.length) {
|
|
601
|
+
result.push(originalLines[origIdx]);
|
|
602
|
+
origIdx++;
|
|
603
|
+
}
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
if (line.startsWith("+")) {
|
|
608
|
+
// Added line — insert into result, don't advance original
|
|
609
|
+
result.push(line.substring(1));
|
|
610
|
+
} else if (line.startsWith("-")) {
|
|
611
|
+
// Removed line — skip in original
|
|
612
|
+
origIdx++;
|
|
613
|
+
} else if (line.startsWith(" ") || line === "") {
|
|
614
|
+
// Context line — copy from original
|
|
615
|
+
if (origIdx < originalLines.length) {
|
|
616
|
+
result.push(originalLines[origIdx]);
|
|
617
|
+
}
|
|
618
|
+
origIdx++;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Append any remaining original lines after the last hunk
|
|
623
|
+
while (origIdx < originalLines.length) {
|
|
624
|
+
result.push(originalLines[origIdx]);
|
|
625
|
+
origIdx++;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
return result.join("\n");
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// ──────────────────────────────────────────────
|
|
632
|
+
// Main dispatcher
|
|
633
|
+
// ──────────────────────────────────────────────
|
|
634
|
+
|
|
635
|
+
const ACTION_HANDLERS = {
|
|
636
|
+
write: handleWrite,
|
|
637
|
+
execute: handleExecute,
|
|
638
|
+
patch: handlePatch,
|
|
639
|
+
delete: handleDelete,
|
|
640
|
+
mkdir: handleMkdir,
|
|
641
|
+
read: handleRead,
|
|
642
|
+
};
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Main entry point — dispatches an action object from the backend.
|
|
646
|
+
*
|
|
647
|
+
* action shape:
|
|
648
|
+
* { type: 'write', file: string, content: string }
|
|
649
|
+
* { type: 'execute', command: string, cwd?: string, timeout?: number, description?: string }
|
|
650
|
+
* { type: 'patch', file: string, search?: string, replace?: string, diff?: string, content?: string }
|
|
651
|
+
* { type: 'delete', file: string }
|
|
652
|
+
* { type: 'mkdir', path: string } (also accepts file: string)
|
|
653
|
+
* { type: 'read', file: string }
|
|
654
|
+
*/
|
|
655
|
+
export async function executeAction(action) {
|
|
656
|
+
if (!action || !action.type) {
|
|
657
|
+
printError("Invalid action: missing type.");
|
|
658
|
+
return { applied: false, reason: "invalid" };
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const actionType = action.type.toLowerCase();
|
|
662
|
+
|
|
663
|
+
// 1. Sandbox check
|
|
664
|
+
const targetPath = action.file || action.path || null;
|
|
665
|
+
const sandboxResult = checkSandbox(actionType, targetPath);
|
|
666
|
+
if (!sandboxResult.allowed) {
|
|
667
|
+
printError(`Sandbox blocked: ${sandboxResult.reason}`);
|
|
668
|
+
return { applied: false, reason: "sandbox_blocked", detail: sandboxResult.reason };
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// 2. Dispatch to handler
|
|
672
|
+
const handler = ACTION_HANDLERS[actionType];
|
|
673
|
+
if (!handler) {
|
|
674
|
+
printError(`Unknown action type: "${action.type}".`);
|
|
675
|
+
return { applied: false, reason: "unknown_type" };
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
try {
|
|
679
|
+
return await handler(action);
|
|
680
|
+
} catch (err) {
|
|
681
|
+
printError(`Action "${actionType}" failed: ${err.message}`);
|
|
682
|
+
if (getSessionState().verbose) {
|
|
683
|
+
console.error(chalk.dim(err.stack));
|
|
684
|
+
}
|
|
685
|
+
return { applied: false, reason: "error", error: err.message };
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Executes a batch of actions sequentially.
|
|
691
|
+
* Returns an array of results, one per action.
|
|
692
|
+
*/
|
|
693
|
+
export async function executeActions(actions) {
|
|
694
|
+
if (!Array.isArray(actions) || actions.length === 0) {
|
|
695
|
+
return [];
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const results = [];
|
|
699
|
+
const total = actions.length;
|
|
700
|
+
|
|
701
|
+
console.log(
|
|
702
|
+
chalk.cyan.bold(`\n━━━ Executing ${total} action${total > 1 ? "s" : ""} ━━━\n`)
|
|
703
|
+
);
|
|
704
|
+
|
|
705
|
+
for (let i = 0; i < actions.length; i++) {
|
|
706
|
+
const action = actions[i];
|
|
707
|
+
console.log(
|
|
708
|
+
chalk.dim(`[${i + 1}/${total}] `) +
|
|
709
|
+
chalk.bold(action.type) +
|
|
710
|
+
chalk.dim(action.file ? ` → ${action.file}` : action.command ? ` → ${action.command}` : "")
|
|
711
|
+
);
|
|
712
|
+
|
|
713
|
+
const result = await executeAction(action);
|
|
714
|
+
results.push({ action: action.type, ...result });
|
|
715
|
+
|
|
716
|
+
// If an action fails with sandbox or never-mode block, continue with others
|
|
717
|
+
// but if it was a hard error in execute, optionally stop
|
|
718
|
+
if (result.reason === "error" && action.type === "execute" && action.stopOnError !== false) {
|
|
719
|
+
printWarning("Stopping batch execution due to command failure.");
|
|
720
|
+
break;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// Summary
|
|
725
|
+
const applied = results.filter((r) => r.applied).length;
|
|
726
|
+
const skipped = results.filter((r) => !r.applied).length;
|
|
727
|
+
|
|
728
|
+
console.log(
|
|
729
|
+
chalk.dim(`\n━━━ Done: `) +
|
|
730
|
+
chalk.green.bold(`${applied} applied`) +
|
|
731
|
+
chalk.dim(", ") +
|
|
732
|
+
(skipped > 0 ? chalk.yellow.bold(`${skipped} skipped`) : chalk.dim("0 skipped")) +
|
|
733
|
+
chalk.dim(` ━━━\n`)
|
|
734
|
+
);
|
|
735
|
+
|
|
736
|
+
return results;
|
|
737
|
+
}
|