import { VirtualFileSystem } from './index'; import { drainCompileErrors, formatCompileErrors } from '@/lib/preview/compile-errors'; import { track } from '@/lib/telemetry'; import { isExternalCurl } from '@/lib/llm/permissions'; import { base64ToArrayBuffer } from '@/lib/vfs/binary-encoding'; /** * Minimal context passed from the orchestrator into the shell executor so * commands can emit progress events (e.g. `ask`, `brief`, `spec`) without * depending on browser globals. Defined here to avoid a circular import * with lib/llm; callers pass a compatible subset of ToolExecutionContext. */ export interface ShellContext { onProgress?: (event: string, data?: any) => void; /** Generates an image from the project's image model. Absent when no image * model is configured for the project. Injected from ToolExecutionContext. */ generateImage?: (prompt: string, opts: { aspectRatio?: string; imageSize?: string }) => Promise<{ base64: string; mimeType: string }>; } type ShellResult = { stdout: string; stderr: string; exitCode: number; /** * Optional reason flag the orchestrator can act on. Currently used by `ask` * to signal "awaiting_user" so the loop pauses for a chip selection. */ exitReason?: string; }; const TRUNCATE_CHARS = 100_000; function truncate(out: string): string { if (out.length <= TRUNCATE_CHARS) return out; return out.slice(0, TRUNCATE_CHARS) + `\n\n… [${out.length - TRUNCATE_CHARS} chars truncated] …`; } function normalizePath(p?: string): string | undefined { if (!p) return p; if (p.startsWith('/workspace')) { const rest = p.slice('/workspace'.length); p = rest.length ? rest : '/'; } // The VFS is rooted at '/' with no working directory, so the current-dir // forms ('.', './', './x') resolve relative to root. if (p === '.' || p === './') return '/'; if (p.startsWith('./')) p = p.slice(2); if (!p.startsWith('/')) p = '/' + p; return p; } async function ensureDirectory(vfs: VirtualFileSystem, projectId: string, path: string) { if (path === '/' || !path) return; const parts = path.split('/').filter(Boolean); let cur = ''; for (let i = 0; i < parts.length; i++) { cur = '/' + parts.slice(0, i + 1).join('/'); try { // relies on createDirectory being idempotent await vfs.createDirectory(projectId, cur); } catch { // ignore } } } /** * Strip bash stderr/stdout redirect operators that are no-ops in the virtual shell. * LLMs reflexively append patterns like `2>/dev/null`, `&>/dev/null`, `2>&1`, etc. * Handles both fused (`2>/dev/null`) and split (`2>` `/dev/null`) token forms. */ function stripBashRedirects(args: string[]): string[] { const result: string[] = []; for (let i = 0; i < args.length; i++) { const token = args[i]; // Exact fd-duplication: 2>&1 if (token === '2>&1') continue; // Bare redirect operator (2>, 2>>, 1>, 1>>, &>, &>>) — skip it AND the next token (the target path) if (/^(?:2|1|&)>>?$/.test(token)) { i++; continue; } // Fused redirect+path (2>/dev/null, 1>/tmp/err, &>/dev/null, 2>>/dev/null, etc.) if (/^(?:2|1|&)>>?./.test(token)) continue; result.push(token); } return result; } /** * Extract redirect operator from args: > (overwrite) or >> (append) * Returns cleaned args and redirect info */ function extractRedirect(args: string[]): { cleanArgs: string[]; redirect?: { file: string; append: boolean } } { const appendIdx = args.indexOf('>>'); const overwriteIdx = args.indexOf('>'); // Use whichever redirect appears first; prefer >> when at the same position let idx: number; if (appendIdx !== -1 && overwriteIdx !== -1) { idx = appendIdx <= overwriteIdx ? appendIdx : overwriteIdx; } else { idx = appendIdx !== -1 ? appendIdx : overwriteIdx; } if (idx === -1) return { cleanArgs: args }; const append = args[idx] === '>>'; const file = args[idx + 1]; if (!file) return { cleanArgs: args }; // No file after redirect — leave as-is const cleanArgs = [...args.slice(0, idx), ...args.slice(idx + 2)]; return { cleanArgs, redirect: { file, append } }; } /** * Apply redirect: write stdout to file (> = overwrite, >> = append) */ async function applyRedirect( vfs: VirtualFileSystem, projectId: string, content: string, redirect: { file: string; append: boolean } ): Promise { const path = normalizePath(redirect.file); if (!path) return { stdout: '', stderr: 'redirect: missing file path', exitCode: 2 }; try { const dirPath = path.split('/').slice(0, -1).join('/') || '/'; if (dirPath !== '/') await ensureDirectory(vfs, projectId, dirPath); if (redirect.append) { // Append: read existing + append let existing = ''; try { const file = await vfs.readFile(projectId, path); if (typeof file.content === 'string') existing = file.content; } catch { /* file doesn't exist yet */ } const newContent = existing ? existing + '\n' + content : content; try { await vfs.createFile(projectId, path, newContent); } catch { await vfs.updateFile(projectId, path, newContent); } } else { // Overwrite try { await vfs.createFile(projectId, path, content); } catch { await vfs.updateFile(projectId, path, content); } } return { stdout: '', stderr: '', exitCode: 0 }; } catch (e: any) { return { stdout: '', stderr: `redirect: ${path}: ${e?.message || 'cannot write file'}`, exitCode: 1 }; } } /** * Convert sed's Basic Regular Expression (BRE) to JavaScript Extended Regular Expression (ERE). * In BRE: ( ) { } + ? | are LITERAL unless preceded by \ * In ERE/JS: ( ) { } + ? | are SPECIAL unless preceded by \ * This swap ensures sed patterns like `darken(var(--primary), 10%)` match literally. */ function breToEre(pat: string): string { let result = ''; let escaped = false; let inCharClass = false; for (let i = 0; i < pat.length; i++) { const ch = pat[i]; if (escaped) { if (inCharClass) { // Inside [...], keep escapes as-is — no BRE-to-ERE swap result += '\\' + ch; } else { // \( in BRE = grouping → ( in ERE // \) in BRE = grouping → ) in ERE // \{ \} \+ \? \| — same swap if ('(){}+?|'.includes(ch)) { result += ch; // drop the backslash, keep special meaning } else { result += '\\' + ch; // keep escape as-is (\n, \d, \/, etc.) } } escaped = false; continue; } if (ch === '\\') { escaped = true; continue; } // Track character class boundaries if (ch === '[' && !inCharClass) { inCharClass = true; result += ch; continue; } if (ch === ']' && inCharClass) { inCharClass = false; result += ch; continue; } // Inside [...], all chars are literal — no BRE-to-ERE transformation if (inCharClass) { result += ch; continue; } // Unescaped ( ) { } + ? | in BRE are literal → escape for ERE if ('(){}+?|'.includes(ch)) { result += '\\' + ch; } else { result += ch; } } if (escaped) result += '\\'; // trailing backslash return result; } function parseSedExpression(expr: string): { pattern: RegExp; replacement: string } | { error: string } { if (!expr.startsWith('s')) return { error: `sed: invalid expression: ${expr}` }; const delim = expr[1]; if (!delim || !/[\/|#@]/.test(delim)) { return { error: `sed: invalid delimiter in expression: ${expr}` }; } // Split on unescaped delimiter const parts: string[] = []; let current = ''; let escaped = false; for (let i = 2; i < expr.length; i++) { const ch = expr[i]; if (escaped) { current += ch; escaped = false; continue; } if (ch === '\\') { escaped = true; current += ch; continue; } if (ch === delim) { parts.push(current); current = ''; continue; } current += ch; } parts.push(current); // flags part (may be empty) if (parts.length < 2) { return { error: `sed: incomplete expression: ${expr}\n\nUsage: sed 's/pattern/replacement/[flags]'\n flags: g (global)` }; } const [patStr, replStr, flagStr] = parts; // Detect multiline \n patterns — not supported in VFS sed if (patStr.includes('\\n') || replStr.includes('\\n')) { return { error: `sed: multiline patterns with \\n are not supported.\n\nFor multiline edits, use ss (supersed):\n ss /file << 'EOF'\n text to find\n =======\n replacement text\n EOF` }; } const globalFlag = (flagStr || '').includes('g'); try { // Convert BRE pattern to JavaScript ERE (unescaped parens become literal, etc.) const erePattern = breToEre(patStr); const pattern = new RegExp(erePattern, globalFlag ? 'g' : ''); // Unescape the replacement string (remove backslash-delimiter escapes) let replacement = replStr.replace(new RegExp('\\\\' + delim.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), delim); // Translate sed backreferences to JS: \1→$1, \2→$2, etc. and &→$& // First protect escaped ampersand (\&) and escaped backslash (\\) replacement = replacement .replace(/\\\\/g, '\x00BSLASH\x00') .replace(/\\&/g, '\x00AMP\x00') .replace(/&/g, '$$&') .replace(/\\([1-9])/g, '$$$1') .replace(/\x00AMP\x00/g, '&') .replace(/\x00BSLASH\x00/g, '\\'); return { pattern, replacement }; } catch (e: any) { return { error: `sed: invalid regex "${patStr}": ${e?.message || 'parse error'}` }; } } /** Address type for sed range commands */ type SedAddress = { type: 'line'; line: number } | { type: 'pattern'; pattern: RegExp } | { type: 'last' }; /** Parsed sed command — substitution, delete, change, insert, append, print, or group */ type SedCommand = | { kind: 'substitute'; pattern: RegExp; replacement: string; start?: SedAddress; end?: SedAddress; negate?: boolean } | { kind: 'delete'; start: SedAddress; end?: SedAddress; negate?: boolean } | { kind: 'change'; start: SedAddress; end?: SedAddress; text: string; negate?: boolean } | { kind: 'insert'; start: SedAddress; text: string; negate?: boolean } | { kind: 'append'; start: SedAddress; text: string; negate?: boolean } | { kind: 'print'; start: SedAddress; end?: SedAddress; negate?: boolean } | { kind: 'group'; start: SedAddress; end?: SedAddress; commands: SedCommand[] }; /** * Parse a sed address like /pattern/, a line number, or $ * Returns the address and the remaining string after it. */ function parseSedAddress(expr: string): { addr: SedAddress; rest: string } | null { if (!expr) return null; // Line number const lineMatch = expr.match(/^(\d+)(.*)/); if (lineMatch) { return { addr: { type: 'line', line: parseInt(lineMatch[1], 10) }, rest: lineMatch[2] }; } // $ = last line if (expr[0] === '$') { return { addr: { type: 'last' }, rest: expr.slice(1) }; } // /pattern/ or \xpatternx (alternate delimiter) if (expr[0] === '/' || expr[0] === '\\') { const delim = expr[0] === '\\' ? expr[1] : '/'; const start = expr[0] === '\\' ? 2 : 1; let pattern = ''; let escaped = false; let i = start; for (; i < expr.length; i++) { if (escaped) { pattern += expr[i]; escaped = false; continue; } if (expr[i] === '\\') { escaped = true; pattern += '\\'; continue; } if (expr[i] === delim) { i++; break; } pattern += expr[i]; } try { return { addr: { type: 'pattern', pattern: new RegExp(breToEre(pattern)) }, rest: expr.slice(i) }; } catch { return null; } } return null; } /** * Parse a full sed command expression including optional addresses. * Supports: /addr1/,/addr2/d /addr1/,/addr2/c\text /addr1/,/addr2/p s/old/new/g */ function parseSedCommand(expr: string): SedCommand | { error: string } { // Try substitution first (most common) if (expr.startsWith('s') && expr.length > 2 && /[\/|#@]/.test(expr[1])) { const parsed = parseSedExpression(expr); if ('error' in parsed) return parsed; return { kind: 'substitute', ...parsed }; } // Try address-based commands: /pattern/,/pattern/d or 5,10d etc. const addr1Result = parseSedAddress(expr); if (!addr1Result) { return { error: `sed: unrecognized command: ${expr}` }; } let addr2: SedAddress | undefined; let remaining = addr1Result.rest; // Check for ,addr2 if (remaining.startsWith(',')) { const addr2Result = parseSedAddress(remaining.slice(1)); if (!addr2Result) { return { error: `sed: invalid end address in: ${expr}` }; } addr2 = addr2Result.addr; remaining = addr2Result.rest; } // Parse the command character remaining = remaining.trim(); // Check for ! negate modifier let negate = false; if (remaining.startsWith('!')) { negate = true; remaining = remaining.slice(1).trim(); } // Check for {...} command group if (remaining.startsWith('{')) { const closeIdx = remaining.lastIndexOf('}'); if (closeIdx < 0) return { error: `sed: unmatched { in: ${expr}` }; const inner = remaining.slice(1, closeIdx).trim(); const innerParts = inner.split(';').map(s => s.trim()).filter(Boolean); const commands: SedCommand[] = []; for (const part of innerParts) { const parsed = parseSedCommand(part); if ('error' in parsed) return parsed; commands.push(parsed); } return { kind: 'group', start: addr1Result.addr, end: addr2, commands }; } const neg = negate ? { negate: true as const } : {}; if (remaining === 'd') { return { kind: 'delete', start: addr1Result.addr, end: addr2, ...neg }; } if (remaining === 'p') { return { kind: 'print', start: addr1Result.addr, end: addr2, ...neg }; } if (remaining.startsWith('c\\') || remaining.startsWith('c ')) { const text = remaining.slice(2).replace(/\\n/g, '\n'); return { kind: 'change', start: addr1Result.addr, end: addr2, text, ...neg }; } // i\ — insert text before matched line (single address only) if (remaining.startsWith('i\\') || remaining.startsWith('i ')) { const text = remaining.slice(2).replace(/\\n/g, '\n'); return { kind: 'insert', start: addr1Result.addr, text, ...neg }; } // a\ — append text after matched line (single address only) if (remaining.startsWith('a\\') || remaining.startsWith('a ')) { const text = remaining.slice(2).replace(/\\n/g, '\n'); return { kind: 'append', start: addr1Result.addr, text, ...neg }; } // Address + substitution: 6s/old/new/ or /pattern/s/old/new/g if (remaining.startsWith('s') && remaining.length > 2 && /[\/|#@]/.test(remaining[1])) { const parsed = parseSedExpression(remaining); if ('error' in parsed) return parsed; return { kind: 'substitute', ...parsed, start: addr1Result.addr, end: addr2, ...neg }; } return { error: `sed: unsupported command "${remaining}" in: ${expr}` }; } /** Check if a sed address matches a given line */ function addressMatches(addr: SedAddress, lineNum: number, lineContent: string, totalLines: number): boolean { switch (addr.type) { case 'line': return lineNum === addr.line; case 'last': return lineNum === totalLines; case 'pattern': return addr.pattern.test(lineContent); } } // ─── ss (supersed) utilities ─────────────────────────────────────────────── /** * Locate selector within content while relaxing leading indentation and trailing whitespace. * Tries exact match first, then trimmed variants. */ function ssFindSelectorMatch(content: string, selector: string): { index: number; normalizedSelector: string } | null { const variants: string[] = []; const seen = new Set(); const addVariant = (value: string) => { if (!value || seen.has(value)) return; seen.add(value); variants.push(value); }; addVariant(selector); addVariant(selector.replace(/^\s+/, '')); addVariant(selector.replace(/\s+$/, '')); addVariant(selector.replace(/^\s+/, '').replace(/\s+$/, '')); for (const variant of variants) { const index = content.indexOf(variant); if (index !== -1) { return { index, normalizedSelector: variant }; } } return null; } /** * Auto-detect whether the selector targets an HTML element (tag-matched) * or a bracket-matched entity (function, class, CSS rule, etc.). */ function ssIsHtmlEntity(selector: string): boolean { return selector.startsWith('<') && selector.includes('>'); } /** * Detect entity boundaries — dispatch to HTML tag matching or bracket matching. */ function ssDetectEntityBoundary( content: string, selectorIndex: number, selector: string, isHtml: boolean ): { start: number; end: number } | null { if (selectorIndex < 0 || selectorIndex >= content.length) return null; if (isHtml) { return ssDetectHtmlElementBoundary(content, selectorIndex, selector); } return ssDetectBracketBoundary(content, selectorIndex); } const VOID_ELEMENTS = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']); /** * Detect HTML element boundaries by matching opening and closing tags. * Handles nested tags of the same name and self-closing elements. */ function ssDetectHtmlElementBoundary( content: string, selectorIndex: number, selector: string ): { start: number; end: number } | null { const tagMatch = selector.match(/<(\w+)(?:\s|>|\/)/); if (!tagMatch) return null; const tagName = tagMatch[1]; const start = selectorIndex; // Self-closing:
, , or void elements if (selector.includes('/>') || VOID_ELEMENTS.has(tagName.toLowerCase())) { // Find closing '>' of tag, skipping '>' inside quoted attribute values let tagEnd = selectorIndex; let inQuote: string | null = null; while (tagEnd < content.length) { const ch = content[tagEnd]; if (inQuote) { if (ch === inQuote) inQuote = null; } else if (ch === '"' || ch === "'") { inQuote = ch; } else if (ch === '>') { return { start, end: tagEnd + 1 }; } tagEnd++; } return null; } // Track depth for nested same-name tags // Use quote-aware regex to handle > inside attribute values like
const openRe = new RegExp(`<${tagName}(?:\\s(?:[^>"']*|"[^"]*"|'[^']*')*)?>`, 'gi'); const closeRe = new RegExp(``, 'gi'); // Collect all open and close positions after selectorIndex const events: { pos: number; len: number; type: 'open' | 'close' }[] = []; openRe.lastIndex = selectorIndex; let m: RegExpExecArray | null; while ((m = openRe.exec(content)) !== null) { // Skip self-closing tags if (content[m.index + m[0].length - 2] === '/') continue; events.push({ pos: m.index, len: m[0].length, type: 'open' }); } closeRe.lastIndex = selectorIndex; while ((m = closeRe.exec(content)) !== null) { events.push({ pos: m.index, len: m[0].length, type: 'close' }); } events.sort((a, b) => a.pos - b.pos); let depth = 0; for (const ev of events) { if (ev.type === 'open') { depth++; } else { if (depth > 0) depth--; if (depth === 0) { return { start, end: ev.pos + ev.len }; } } } return null; } /** * Detect bracket-matched entity boundary (functions, classes, CSS rules). * Improved: skips braces inside strings, template literals, and comments. */ function ssDetectBracketBoundary( content: string, selectorIndex: number ): { start: number; end: number } | null { // Find the opening bracket const openPos = content.indexOf('{', selectorIndex); if (openPos === -1) return null; const start = selectorIndex; let depth = 0; let i = openPos; while (i < content.length) { const ch = content[i]; // Skip single-line comments if (ch === '/' && content[i + 1] === '/') { const eol = content.indexOf('\n', i); i = eol === -1 ? content.length : eol + 1; continue; } // Skip multi-line comments if (ch === '/' && content[i + 1] === '*') { const endComment = content.indexOf('*/', i + 2); i = endComment === -1 ? content.length : endComment + 2; continue; } // Skip double-quoted strings if (ch === '"') { i++; while (i < content.length) { if (content[i] === '\\') { i += 2; continue; } if (content[i] === '"') { i++; break; } i++; } continue; } // Skip single-quoted strings if (ch === "'") { i++; while (i < content.length) { if (content[i] === '\\') { i += 2; continue; } if (content[i] === "'") { i++; break; } i++; } continue; } // Skip template literals if (ch === '`') { i++; while (i < content.length) { if (content[i] === '\\') { i += 2; continue; } if (content[i] === '`') { i++; break; } // Skip ${...} expressions inside template literals if (content[i] === '$' && content[i + 1] === '{') { let tDepth = 1; i += 2; while (i < content.length && tDepth > 0) { if (content[i] === '{') tDepth++; else if (content[i] === '}') tDepth--; i++; } continue; } i++; } continue; } if (ch === '{') { depth++; } else if (ch === '}') { depth--; if (depth === 0) { return { start, end: i + 1 }; } } i++; } return null; } /** * Map a normalized (whitespace-collapsed) search string back to the original content. * Returns the start/end positions in the original content. */ const WS_RE = /\s/; function ssMapNormalizedToOriginal(content: string, normalizedSearch: string): { start: number; end: number } | null { // Build a mapping from normalized positions to original positions // Strategy: walk both the original content and the normalized search simultaneously const contentLen = content.length; const searchLen = normalizedSearch.length; // Try each position in the original content as a potential start for (let origStart = 0; origStart < contentLen; origStart++) { let oi = origStart; let si = 0; let matched = true; while (si < searchLen && oi < contentLen) { // In normalized form, whitespace runs collapse to a single space if (normalizedSearch[si] === ' ') { // The original must have at least one whitespace character here if (!WS_RE.test(content[oi])) { matched = false; break; } // Skip all whitespace in original while (oi < contentLen && WS_RE.test(content[oi])) oi++; si++; } else { if (content[oi] !== normalizedSearch[si]) { matched = false; break; } oi++; si++; } } if (!matched) continue; if (si === searchLen) { return { start: origStart, end: oi }; } } return null; } async function vfsShellExecute( vfs: VirtualFileSystem, projectId: string, cmd: string[], stdin?: string, ctx?: ShellContext ): Promise { // Validate inputs if (!projectId || typeof projectId !== 'string') { return { stdout: '', stderr: 'Invalid project ID provided', exitCode: 2 }; } if (!cmd || cmd.length === 0) { return { stdout: '', stderr: 'No command provided', exitCode: 2 }; } const cleanCmd = stripBashRedirects( cmd.filter(arg => arg !== undefined && arg !== null && arg !== '') ); if (cleanCmd.length === 0) { return { stdout: '', stderr: 'No valid command arguments provided', exitCode: 2 }; } // Handle ; separator - execute all sequentially regardless of exit codes if (cleanCmd.some(arg => arg === ';')) { const commands: string[][] = []; let currentCmd: string[] = []; for (const arg of cleanCmd) { if (arg === ';') { if (currentCmd.length > 0) { commands.push(currentCmd); currentCmd = []; } } else { currentCmd.push(arg); } } if (currentCmd.length > 0) { commands.push(currentCmd); } // Execute all commands sequentially regardless of exit codes const allStdout: string[] = []; const allStderr: string[] = []; let lastExitCode = 0; let lastExitReason: string | undefined; for (const singleCmd of commands) { const result = await vfsShellExecuteSingle(vfs, projectId, singleCmd, undefined, ctx); if (result.stdout) allStdout.push(result.stdout); if (result.stderr) allStderr.push(result.stderr); lastExitCode = result.exitCode; lastExitReason = result.exitReason; } return { stdout: allStdout.join('\n'), stderr: allStderr.join('\n'), exitCode: lastExitCode, exitReason: lastExitReason }; } // Handle && command chaining - execute sequentially, stop on first failure if (cleanCmd.some(arg => arg === '&&')) { const commands: string[][] = []; let currentCmd: string[] = []; for (const arg of cleanCmd) { if (arg === '&&') { if (currentCmd.length > 0) { commands.push(currentCmd); currentCmd = []; } } else { currentCmd.push(arg); } } if (currentCmd.length > 0) { commands.push(currentCmd); } // Execute commands sequentially const allStdout: string[] = []; const allStderr: string[] = []; let lastExitReason: string | undefined; for (const singleCmd of commands) { const result = await vfsShellExecuteSingle(vfs, projectId, singleCmd, undefined, ctx); if (result.stdout) allStdout.push(result.stdout); if (result.stderr) allStderr.push(result.stderr); lastExitReason = result.exitReason; // Stop on first failure (that's && semantics) if (result.exitCode !== 0) { return { stdout: allStdout.join('\n'), stderr: allStderr.join('\n'), exitCode: result.exitCode, exitReason: result.exitReason }; } } return { stdout: allStdout.join('\n'), stderr: allStderr.join('\n'), exitCode: 0, exitReason: lastExitReason }; } // Handle || fallback - execute sequentially, skip remaining on first success if (cleanCmd.some(arg => arg === '||')) { const commands: string[][] = []; let currentCmd: string[] = []; for (const arg of cleanCmd) { if (arg === '||') { if (currentCmd.length > 0) { commands.push(currentCmd); currentCmd = []; } } else { currentCmd.push(arg); } } if (currentCmd.length > 0) { commands.push(currentCmd); } // Execute commands sequentially, stop on first success let lastResult: ShellResult = { stdout: '', stderr: '', exitCode: 1 }; for (const singleCmd of commands) { lastResult = await vfsShellExecuteSingle(vfs, projectId, singleCmd, undefined, ctx); if (lastResult.exitCode === 0) { return lastResult; } } return lastResult; } // Handle pipe chains: cmd1 | cmd2 | cmd3 if (cleanCmd.some(arg => arg === '|')) { const segments: string[][] = []; let currentSeg: string[] = []; for (const arg of cleanCmd) { if (arg === '|') { if (currentSeg.length > 0) { segments.push(currentSeg); currentSeg = []; } } else { currentSeg.push(arg); } } if (currentSeg.length > 0) segments.push(currentSeg); if (segments.length < 2) { return vfsShellExecuteSingle(vfs, projectId, cleanCmd, undefined, ctx); } // Execute pipe chain left-to-right, passing stdout as stdin let pipeStdin: string | undefined = stdin; for (let i = 0; i < segments.length; i++) { const result = await vfsShellExecuteSingle(vfs, projectId, segments[i], pipeStdin, ctx); if (result.exitCode !== 0) return result; pipeStdin = result.stdout; } return { stdout: pipeStdin || '', stderr: '', exitCode: 0 }; } return vfsShellExecuteSingle(vfs, projectId, cleanCmd, stdin, ctx); } /** * Expand glob patterns (*, ?) in arguments against the VFS file listing. * Converts e.g. `/scripts/*.js` into ['/scripts/main.js', '/scripts/app.js']. * Only expands args that contain glob characters and aren't flags. * If a pattern matches nothing, the original arg is kept (bash default). */ async function expandGlobs( vfs: VirtualFileSystem, projectId: string, args: string[] ): Promise { // Quick check: any args need expansion? if (!args.some(a => a && !a.startsWith('-') && (a.includes('*') || a.includes('?')))) { return args; } // Get all file paths once const allEntries = await vfs.getAllFilesAndDirectories(projectId, { includeTransient: true }); const allPaths = allEntries.map((e: any) => e.path as string); const expanded: string[] = []; for (const arg of args) { if (!arg || arg.startsWith('-') || (!arg.includes('*') && !arg.includes('?'))) { expanded.push(arg); continue; } // Normalize path (adds / prefix if missing) const normalized = normalizePath(arg) || arg; // Convert glob to regex: escape regex chars, then replace * and ? const regexStr = normalized .replace(/[.+^${}()|[\]\\]/g, '\\$&') .replace(/\*/g, '[^/]*') .replace(/\?/g, '[^/]'); const regex = new RegExp(`^${regexStr}$`); const matches = allPaths.filter(p => regex.test(p)).sort(); if (matches.length > 0) { expanded.push(...matches); } else { expanded.push(arg); // No matches — keep original } } return expanded; } // Commands where file-path arguments should be glob-expanded. // Excludes: rg, grep, sed (pattern args), find (-name takes its own glob), // echo (text content), curl (URLs), status (special). const GLOB_EXPAND_COMMANDS = new Set([ 'wc', 'ls', 'cat', 'rm', 'cp', 'mv', 'touch', ]); async function vfsShellExecuteSingle( vfs: VirtualFileSystem, projectId: string, cleanCmd: string[], stdin?: string, ctx?: ShellContext ): Promise { // Extract redirect operators (> or >>) before processing the command const { cleanArgs: argsAfterRedirect, redirect } = extractRedirect(cleanCmd.slice(1)); const program = cleanCmd[0]; const args = GLOB_EXPAND_COMMANDS.has(program) ? await expandGlobs(vfs, projectId, argsAfterRedirect) : argsAfterRedirect; try { switch (program) { case 'ls': { // Support flags: -R (recursive), -l/-la/-lh (long format with size & date). const lsFlags = new Set(); const lsPaths: string[] = []; for (const a of args) { if (a && a.startsWith('-')) lsFlags.add(a); else if (a) lsPaths.push(a); } const recursive = lsFlags.has('-R') || lsFlags.has('-r'); const longFormat = lsFlags.has('-l') || lsFlags.has('-la') || lsFlags.has('-al') || lsFlags.has('-lh') || lsFlags.has('-lha') || lsFlags.has('-lah'); const humanReadable = lsFlags.has('-lh') || lsFlags.has('-lha') || lsFlags.has('-lah') || lsFlags.has('-h'); const formatFileSize = (bytes: number): string => { if (!humanReadable) return String(bytes).padStart(8); if (bytes < 1024) return `${bytes}B`.padStart(8); if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}K`.padStart(8); return `${(bytes / (1024 * 1024)).toFixed(1)}M`.padStart(8); }; const formatFileLong = (f: { path: string; size?: number; updatedAt?: Date }) => { const size = formatFileSize(f.size || 0); const date = f.updatedAt ? new Date(f.updatedAt).toISOString().slice(0, 16).replace('T', ' ') : ' '; return `${size} ${date} ${f.path}`; }; // Multiple paths: each could be a file or directory if (lsPaths.length > 1) { const lines: string[] = []; for (let pi = 0; pi < lsPaths.length; pi++) { const np = normalizePath(lsPaths[pi]); if (!np) continue; // Try as file first try { const file = await vfs.readFile(projectId, np); lines.push(longFormat ? formatFileLong(file) : file.path); continue; } catch { /* not a file — try as directory */ } // Try as directory const dirFiles = await vfs.listDirectory(projectId, np, { includeTransient: true }); if (dirFiles.length > 0) { if (pi > 0) lines.push(''); // blank line between directory sections lines.push(`${np}:`); const sorted = dirFiles.sort((a, b) => a.path.localeCompare(b.path)); for (const f of sorted) { lines.push(longFormat ? formatFileLong(f) : f.path); } } else { lines.push(`ls: ${np}: No such file or directory`); } } const lsOutput = lines.join('\n'); const lsResult: ShellResult = { stdout: truncate(lsOutput), stderr: '', exitCode: 0 }; if (redirect) return applyRedirect(vfs, projectId, lsResult.stdout, redirect); return lsResult; } // Single path: directory listing const lsPath = normalizePath(lsPaths[0]) || '/'; let lsOutput: string; if (!recursive) { const files = await vfs.listDirectory(projectId, lsPath, { includeTransient: true }); const sorted = files.sort((a, b) => a.path.localeCompare(b.path)); lsOutput = longFormat ? sorted.map(f => formatFileLong(f)).join('\n') : sorted.map(f => f.path).join('\n'); } else { const entries = await vfs.getAllFilesAndDirectories(projectId, { includeTransient: true }); const prefix = lsPath === '/' ? '/' : (lsPath.endsWith('/') ? lsPath : lsPath + '/'); const filtered = entries .filter((e: any) => e.path === lsPath || e.path.startsWith(prefix)) .sort((a: any, b: any) => a.path.localeCompare(b.path)); lsOutput = longFormat ? filtered.map((e: any) => formatFileLong(e)).join('\n') : filtered.map((e: any) => e.path).join('\n'); } const lsResult: ShellResult = { stdout: truncate(lsOutput), stderr: '', exitCode: 0 }; if (redirect) return applyRedirect(vfs, projectId, lsResult.stdout, redirect); return lsResult; } case 'tree': { // tree [path] [-L depth] let maxDepth = Infinity; let targetPath = '/'; for (let i = 0; i < args.length; i++) { const a = args[i]; if (a === '-L' && args[i + 1]) { maxDepth = parseInt(args[++i]) || Infinity; } else if (!a.startsWith('-')) { targetPath = a; } } const basePath = normalizePath(targetPath) || '/'; const entries = await vfs.getAllFilesAndDirectories(projectId, { includeTransient: true }); const prefix = basePath === '/' ? '' : basePath; // Build a set of all paths including implied directories const allPaths = new Set(); const dirPaths = new Set(); for (const entry of entries) { const entryPath = entry.path; // Only include entries under the target path if (basePath !== '/' && !entryPath.startsWith(basePath + '/') && entryPath !== basePath) { continue; } allPaths.add(entryPath); const isDir = 'type' in entry && entry.type === 'directory'; if (isDir) dirPaths.add(entryPath); // Add implied parent directories (for transient files like /.skills/foo.md) const parts = entryPath.split('/').filter(Boolean); let currentPath = ''; for (let i = 0; i < parts.length - 1; i++) { currentPath += '/' + parts[i]; if (!allPaths.has(currentPath)) { allPaths.add(currentPath); dirPaths.add(currentPath); } } } // Convert to sorted array, filtering by base path and depth const sortedPaths = Array.from(allPaths) .filter(p => { if (basePath === '/') return p !== '/'; return p.startsWith(basePath + '/') || p === basePath; }) .sort(); // Build tree output with proper indentation interface TreeNode { name: string; path: string; isDir: boolean; children: TreeNode[]; } // Build tree structure const root: TreeNode = { name: basePath === '/' ? '.' : basePath.split('/').pop() || '.', path: basePath, isDir: true, children: [] }; const nodeMap = new Map(); nodeMap.set(basePath === '/' ? '' : basePath, root); for (const p of sortedPaths) { if (p === basePath) continue; const relativePath = basePath === '/' ? p : p.slice(basePath.length); const parts = relativePath.split('/').filter(Boolean); const depth = parts.length; if (depth > maxDepth) continue; const name = parts[parts.length - 1]; const parentPath = basePath === '/' ? '/' + parts.slice(0, -1).join('/') : basePath + '/' + parts.slice(0, -1).join('/'); const normalizedParent = parentPath === '/' ? '' : parentPath.replace(/\/$/, ''); const node: TreeNode = { name, path: p, isDir: dirPaths.has(p), children: [] }; const parent = nodeMap.get(normalizedParent) || root; parent.children.push(node); nodeMap.set(p, node); } // Render tree with proper characters const lines: string[] = [basePath]; function renderNode(node: TreeNode, prefix: string, isLast: boolean, isRoot: boolean): void { if (!isRoot) { const connector = isLast ? '└── ' : '├── '; const suffix = node.isDir ? '/' : ''; lines.push(prefix + connector + node.name + suffix); } const childPrefix = isRoot ? '' : prefix + (isLast ? ' ' : '│ '); node.children.sort((a, b) => { // Directories first, then alphabetical if (a.isDir !== b.isDir) return a.isDir ? -1 : 1; return a.name.localeCompare(b.name); }); for (let i = 0; i < node.children.length; i++) { renderNode(node.children[i], childPrefix, i === node.children.length - 1, false); } } renderNode(root, '', true, true); const treeResult: ShellResult = { stdout: truncate(lines.join('\n')), stderr: '', exitCode: 0 }; if (redirect) return applyRedirect(vfs, projectId, treeResult.stdout, redirect); return treeResult; } case 'cat': { // Support up to 5 files at once const MAX_FILES = 5; const filePaths = args.filter(a => a && !a.startsWith('-')).map(p => normalizePath(p)); // If no file args but stdin is available, pass through stdin if (filePaths.length === 0 && stdin !== undefined) { const result: ShellResult = { stdout: truncate(stdin), stderr: '', exitCode: 0 }; if (redirect) return applyRedirect(vfs, projectId, result.stdout, redirect); return result; } if (filePaths.length === 0) { return { stdout: '', stderr: 'cat: missing file path', exitCode: 2 }; } if (filePaths.length > MAX_FILES) { return { stdout: '', stderr: `cat: too many files. You requested ${filePaths.length} files, but cat supports a maximum of ${MAX_FILES} files at a time. Please split into multiple cat calls.`, exitCode: 2 }; } const outputs: string[] = []; let hadError = false; const errorMessages: string[] = []; for (const path of filePaths) { if (!path) { errorMessages.push('cat: invalid path'); hadError = true; continue; } if (path.startsWith('/-')) { errorMessages.push(`cat: invalid path "${path}" (looks like an option)`); hadError = true; continue; } if (path === '/<<' || path?.startsWith('/<<') || path === '<<' || path?.startsWith('<<')) { errorMessages.push(`cat: heredoc syntax error — the << operator was not parsed correctly. Write each file in a separate tool call instead of chaining multiple heredocs.`); hadError = true; continue; } try { const file = await vfs.readFile(projectId, path); if (typeof file.content !== 'string') { errorMessages.push(`cat: ${path}: binary or non-text file`); hadError = true; } else { // For multiple files, add a header if (filePaths.length > 1) { outputs.push(`=== ${path} ===\n${file.content}`); } else { outputs.push(file.content); } } } catch (error) { const errMsg = error instanceof Error ? error.message : String(error); errorMessages.push(`cat: ${path}: ${errMsg}`); hadError = true; } } const stdout = outputs.join('\n\n'); const stderr = errorMessages.join('\n'); const catResult: ShellResult = { stdout: truncate(stdout), stderr, exitCode: hadError ? 1 : 0 }; if (redirect && !hadError) return applyRedirect(vfs, projectId, catResult.stdout, redirect); return catResult; } case 'head': { // head [-n lines | -lines] (or stdin via pipe) let numLines = 10; let filePath = ''; for (let i = 0; i < args.length; i++) { const a = args[i]; if (a === '-n' && args[i + 1]) { numLines = parseInt(args[++i]) || 10; } else if (/^-\d+$/.test(a)) { // Shorthand: head -20 (same as head -n 20) numLines = parseInt(a.slice(1)) || 10; } else if (!a.startsWith('-')) { filePath = a; } } // Use stdin if no file path and stdin is available if (!filePath && stdin !== undefined) { const lines = stdin.split(/\r?\n/); const output = lines.slice(0, numLines).join('\n'); const result: ShellResult = { stdout: truncate(output), stderr: '', exitCode: 0 }; if (redirect) return applyRedirect(vfs, projectId, result.stdout, redirect); return result; } const path = normalizePath(filePath); if (!path) return { stdout: '', stderr: 'head: missing file path', exitCode: 2 }; try { const file = await vfs.readFile(projectId, path); if (typeof file.content !== 'string') { return { stdout: '', stderr: `head: ${path}: binary file`, exitCode: 1 }; } const lines = file.content.split(/\r?\n/); const output = lines.slice(0, numLines).join('\n'); const result: ShellResult = { stdout: truncate(output), stderr: '', exitCode: 0 }; if (redirect) return applyRedirect(vfs, projectId, result.stdout, redirect); return result; } catch (e: any) { return { stdout: '', stderr: `head: ${path}: ${e?.message || 'file not found'}`, exitCode: 1 }; } } case 'tail': { // tail [-n lines | -lines] (or stdin via pipe) let numLines = 10; let filePath = ''; for (let i = 0; i < args.length; i++) { const a = args[i]; if (a === '-n' && args[i + 1]) { numLines = parseInt(args[++i]) || 10; } else if (/^-\d+$/.test(a)) { // Shorthand: tail -20 (same as tail -n 20) numLines = parseInt(a.slice(1)) || 10; } else if (!a.startsWith('-')) { filePath = a; } } // Use stdin if no file path and stdin is available if (!filePath && stdin !== undefined) { const lines = stdin.split(/\r?\n/); const output = lines.slice(-numLines).join('\n'); const result: ShellResult = { stdout: truncate(output), stderr: '', exitCode: 0 }; if (redirect) return applyRedirect(vfs, projectId, result.stdout, redirect); return result; } const path = normalizePath(filePath); if (!path) return { stdout: '', stderr: 'tail: missing file path', exitCode: 2 }; try { const file = await vfs.readFile(projectId, path); if (typeof file.content !== 'string') { return { stdout: '', stderr: `tail: ${path}: binary file`, exitCode: 1 }; } const lines = file.content.split(/\r?\n/); const output = lines.slice(-numLines).join('\n'); const result: ShellResult = { stdout: truncate(output), stderr: '', exitCode: 0 }; if (redirect) return applyRedirect(vfs, projectId, result.stdout, redirect); return result; } catch (e: any) { return { stdout: '', stderr: `tail: ${path}: ${e?.message || 'file not found'}`, exitCode: 1 }; } } case 'grep': { // Supported: grep [-n] [-i] [-o] [-F] [-P] [-A num] [-B num] [-C num] pattern path (always recursive) const flags: Record = { n: false, i: false, o: false, F: false, C: 0, A: 0, B: 0 }; const fargs: string[] = []; for (let i = 0; i < args.length; i++) { const a = args[i]; if (a.startsWith('-') && a.length > 1 && !/^-\d+$/.test(a)) { const flagStr = a.slice(1); for (let j = 0; j < flagStr.length; j++) { const ch = flagStr[j]; if (ch === 'n') flags.n = true; else if (ch === 'i') flags.i = true; else if (ch === 'o') flags.o = true; else if (ch === 'F') flags.F = true; else if (ch === 'P') {} // no-op — JS regex covers most PCRE patterns else if (ch === 'C') { flags.C = parseInt(args[++i]) || 2; break; } else if (ch === 'A') { flags.A = parseInt(args[++i]) || 2; break; } else if (ch === 'B') { flags.B = parseInt(args[++i]) || 2; break; } } } else { fargs.push(a); } } const pattern = fargs[0]; const path = normalizePath(fargs[1]) || '/'; if (!pattern) { return { stdout: '', stderr: `grep: missing pattern Usage: grep [FLAGS] PATTERN [PATH] Supported flags: -n Show line numbers -i Case insensitive search -o Print only the matched parts of each line (one per line) -F Treat pattern as literal string (not regex) -P Perl-compatible regex (accepted, JS regex used) -A NUM Show NUM lines after each match -B NUM Show NUM lines before each match -C NUM Show NUM lines of context (before and after) Examples: {"cmd": ["grep", "searchterm", "/path"]} {"cmd": ["grep", "-n", "pattern", "/file.txt"]} {"cmd": ["grep", "-i", "TODO", "/"]} {"cmd": ["grep", "-o", "href=\"[^\"]*\"", "/index.html"]} {"cmd": ["grep", "-F", "exact.string", "/src"]} {"cmd": ["grep", "-A", "3", "pattern", "/file.txt"]} {"cmd": ["grep", "-C", "5", "function", "/src"]} Note: grep always searches recursively. rg (ripgrep) is also available.`, exitCode: 2 }; } // Create regex - escape special chars if -F flag is used let regex: RegExp; if (flags.F) { const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); regex = new RegExp(escaped, flags.i ? 'i' : ''); } else { regex = new RegExp(pattern, flags.i ? 'i' : ''); } const outLines: string[] = []; const hasContext = flags.C > 0 || flags.A > 0 || flags.B > 0; const globalRegex = flags.o ? new RegExp(regex.source, regex.flags + 'g') : null; // If no file path provided and stdin is available, search stdin if (!fargs[1] && stdin !== undefined) { const stdinLines = stdin.split(/\r?\n/); if (flags.o) { for (let i = 0; i < stdinLines.length; i++) { const matches = [...stdinLines[i].matchAll(globalRegex!)]; for (const m of matches) { outLines.push(flags.n ? `${i + 1}:${m[0]}` : m[0]); } } } else if (hasContext) { const matchedStdinLines = new Set(); for (let i = 0; i < stdinLines.length; i++) { if (regex.test(stdinLines[i])) matchedStdinLines.add(i); } if (matchedStdinLines.size > 0) { const contextStdinLines = new Set(); const beforeCtx = flags.C || flags.B; const afterCtx = flags.C || flags.A; for (const ln of matchedStdinLines) { for (let j = Math.max(0, ln - beforeCtx); j <= Math.min(stdinLines.length - 1, ln + afterCtx); j++) { contextStdinLines.add(j); } } for (const ln of Array.from(contextStdinLines).sort((a, b) => a - b)) { outLines.push(flags.n ? `${ln + 1}:${stdinLines[ln]}` : stdinLines[ln]); } } } else { for (let i = 0; i < stdinLines.length; i++) { if (regex.test(stdinLines[i])) { outLines.push(flags.n ? `${i + 1}:${stdinLines[i]}` : stdinLines[i]); } } } } else { const entries = await vfs.getAllFilesAndDirectories(projectId, { includeTransient: true }); const dirPrefix = path === '/' ? '/' : (path.endsWith('/') ? path : path + '/'); for (const e of entries) { if ('type' in e && e.type === 'directory') continue; const file = e as any; if (!file.path.startsWith(dirPrefix) && file.path !== path) continue; if (typeof file.content !== 'string') continue; const lines = file.content.split(/\r?\n/); if (flags.o) { for (let i = 0; i < lines.length; i++) { const matches = [...lines[i].matchAll(globalRegex!)]; for (const m of matches) { outLines.push(`${file.path}${flags.n ? ':' + (i + 1) : ''}:${m[0]}`); } } } else if (hasContext) { const matchedLines = new Set(); for (let i = 0; i < lines.length; i++) { if (regex.test(lines[i])) matchedLines.add(i); } if (matchedLines.size === 0) continue; const contextLines = new Set(); const beforeContext = flags.C || flags.B; const afterContext = flags.C || flags.A; for (const lineNum of matchedLines) { for (let j = Math.max(0, lineNum - beforeContext); j <= Math.min(lines.length - 1, lineNum + afterContext); j++) { contextLines.add(j); } } const sortedLines = Array.from(contextLines).sort((a, b) => a - b); if (outLines.length > 0) outLines.push(''); // separator between files for (const lineNum of sortedLines) { outLines.push(`${file.path}${flags.n ? ':' + (lineNum + 1) : ''}:${lines[lineNum]}`); } } else { for (let i = 0; i < lines.length; i++) { if (regex.test(lines[i])) { outLines.push(`${file.path}${flags.n ? ':' + (i + 1) : ''}:${lines[i]}`); } } } } } const output = outLines.join('\n'); if (outLines.length === 0) { return { stdout: '', stderr: '', exitCode: 0 }; } const grepResult: ShellResult = { stdout: truncate(output), stderr: '', exitCode: 0 }; if (redirect) return applyRedirect(vfs, projectId, grepResult.stdout, redirect); return grepResult; } case 'rg': { // ripgrep with context flags: rg [-n] [-i] [-C num] [-A num] [-B num] pattern [path] // Also supports combined flags like -nC, -ni, etc. const flags: Record = { n: true, i: false, C: 0, A: 0, B: 0 }; const fargs: string[] = []; for (let i = 0; i < args.length; i++) { const a = args[i]; if (a.startsWith('-') && a.length > 1 && !/^-\d+$/.test(a)) { // Handle combined flags like -nC, -ni, -iC, etc. const flagStr = a.slice(1); for (let j = 0; j < flagStr.length; j++) { const ch = flagStr[j]; if (ch === 'n') flags.n = true; else if (ch === 'i') flags.i = true; else if (ch === 'C') { flags.C = parseInt(args[++i]) || 2; break; } else if (ch === 'A') { flags.A = parseInt(args[++i]) || 2; break; } else if (ch === 'B') { flags.B = parseInt(args[++i]) || 2; break; } } } else { fargs.push(a); } } const pattern = fargs[0]; const path = normalizePath(fargs[1]) || '/'; if (!pattern) { return { stdout: '', stderr: `rg: missing pattern Usage: rg [FLAGS] PATTERN [PATH] Supported flags: -C NUM Show NUM lines of context (before and after) -A NUM Show NUM lines after each match -B NUM Show NUM lines before each match -i Case insensitive search -n Show line numbers (enabled by default) Examples: {"cmd": ["rg", "searchterm", "/"]} {"cmd": ["rg", "-C", "3", "pattern", "/"]} {"cmd": ["rg", "-A", "5", "-B", "2", "function", "/src"]} {"cmd": ["rg", "-i", "todo", "/"]} Tip: Use -C for balanced context. PATH defaults to / if omitted.`, exitCode: 2 }; } const regex = new RegExp(pattern, flags.i ? 'i' : ''); const outLines: string[] = []; // If no file path provided and stdin is available, search stdin if (!fargs[1] && stdin !== undefined) { const stdinLines = stdin.split(/\r?\n/); const matchedStdinLines = new Set(); for (let i = 0; i < stdinLines.length; i++) { if (regex.test(stdinLines[i])) matchedStdinLines.add(i); } if (matchedStdinLines.size > 0) { const contextStdinLines = new Set(); const beforeCtx = flags.C || flags.B; const afterCtx = flags.C || flags.A; for (const ln of matchedStdinLines) { for (let j = Math.max(0, ln - beforeCtx); j <= Math.min(stdinLines.length - 1, ln + afterCtx); j++) { contextStdinLines.add(j); } } for (const ln of Array.from(contextStdinLines).sort((a, b) => a - b)) { const lineNumStr = flags.n ? `${ln + 1}:` : ''; outLines.push(`${lineNumStr}${stdinLines[ln]}`); } } } else { const entries = await vfs.getAllFilesAndDirectories(projectId, { includeTransient: true }); const dirPrefix = path === '/' ? '/' : (path.endsWith('/') ? path : path + '/'); for (const e of entries) { if ('type' in e && e.type === 'directory') continue; const file = e as any; if (!file.path.startsWith(dirPrefix) && file.path !== path) continue; if (typeof file.content !== 'string') continue; const lines = file.content.split(/\r?\n/); const matchedLines = new Set(); // Find all matches for (let i = 0; i < lines.length; i++) { if (regex.test(lines[i])) { matchedLines.add(i); } } if (matchedLines.size === 0) continue; // Add context lines const contextLines = new Set(); const beforeContext = flags.C || flags.B; const afterContext = flags.C || flags.A; for (const lineNum of matchedLines) { for (let j = Math.max(0, lineNum - beforeContext); j <= Math.min(lines.length - 1, lineNum + afterContext); j++) { contextLines.add(j); } } // Output with line numbers const sortedLines = Array.from(contextLines).sort((a, b) => a - b); if (outLines.length > 0) outLines.push(''); // Separator between files for (const lineNum of sortedLines) { const lineNumStr = flags.n ? `${lineNum + 1}:` : ''; outLines.push(`${file.path}:${lineNumStr}${lines[lineNum]}`); } } } if (outLines.length === 0) { return { stdout: '', stderr: '', exitCode: 0 }; } const rgResult: ShellResult = { stdout: truncate(outLines.join('\n')), stderr: '', exitCode: 0 }; if (redirect) return applyRedirect(vfs, projectId, rgResult.stdout, redirect); return rgResult; } case 'find': { // Supported: find [-type f|d] [-name ] [-maxdepth ] let rootArg: string | undefined; let pattern: string | undefined; let typeFilter: 'f' | 'd' | undefined; let maxDepth = Infinity; for (let i = 0; i < args.length; i++) { const a = args[i]; if (!a) continue; if (a === '-name') { pattern = args[i + 1]; i++; continue; } if (a === '-type') { const typeVal = args[i + 1]; if (typeVal === 'f' || typeVal === 'd') { typeFilter = typeVal; } i++; continue; } if (a === '-maxdepth') { maxDepth = parseInt(args[i + 1]) || 0; i++; continue; } if (!a.startsWith('-') && !rootArg) rootArg = a; } const root = normalizePath(rootArg) || '/'; const entries = await vfs.getAllFilesAndDirectories(projectId, { includeTransient: true }); const prefix = root === '/' ? '/' : (root.endsWith('/') ? root : root + '/'); const toGlob = (s: string) => new RegExp('^' + s.replace(/[.+^${}()|\[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$'); const regex = pattern ? toGlob(pattern) : null; // Count depth relative to root: /root/a = depth 1, /root/a/b = depth 2 const rootDepth = root === '/' ? 0 : root.split('/').filter(Boolean).length; const res = entries .filter((e: any) => e.path === root || e.path.startsWith(prefix)) .filter((e: any) => { // Filter by maxdepth const entryDepth = e.path === '/' ? 0 : e.path.split('/').filter(Boolean).length; if (entryDepth - rootDepth > maxDepth) return false; // Filter by type if specified if (typeFilter === 'f') { return !('type' in e) || e.type !== 'directory'; } if (typeFilter === 'd') { return 'type' in e && e.type === 'directory'; } return true; // No type filter, include all }) .map((e: any) => e.path) .filter(p => (regex ? regex.test(p.split('/').pop() || p) : true)) .sort(); const findResult: ShellResult = { stdout: truncate(res.join('\n')), stderr: '', exitCode: 0 }; if (redirect) return applyRedirect(vfs, projectId, findResult.stdout, redirect); return findResult; } case 'mkdir': { // Support: mkdir [-p] ... (multiple paths like real bash) const hasP = args.includes('-p'); const paths = args.filter(a => a && a !== '-p').map(p => normalizePath(p)); if (paths.length === 0) { return { stdout: '', stderr: 'mkdir: missing operand', exitCode: 2 }; } let hadError = false; const errors: string[] = []; for (const path of paths) { if (!path) continue; // Block mkdir under /.server/ - these are transient/auto-generated if (path.startsWith('/.server/')) { errors.push(`mkdir: cannot create '${path}': server context directories are auto-generated`); hadError = true; continue; } try { if (hasP) { await ensureDirectory(vfs, projectId, path); } else { await vfs.createDirectory(projectId, path); } } catch (e: any) { hadError = true; errors.push(`mkdir: cannot create directory '${path}': ${e?.message || 'unknown error'}`); } } return { stdout: '', stderr: errors.join('\n'), exitCode: hadError ? 1 : 0 }; } case 'touch': { // touch ... - create empty files or update timestamp (multiple files like real bash) const paths = args.filter(a => a && !a.startsWith('-')).map(p => normalizePath(p)); if (paths.length === 0) { return { stdout: '', stderr: 'touch: missing file operand', exitCode: 2 }; } let hadError = false; const errors: string[] = []; for (const path of paths) { if (!path) continue; try { // Check if file exists await vfs.readFile(projectId, path); // File exists, just continue (we don't update timestamps) } catch { // File doesn't exist, create it with empty content try { await vfs.createFile(projectId, path, ''); } catch (e: any) { hadError = true; errors.push(`touch: cannot touch '${path}': ${e?.message || 'cannot create file'}`); } } } return { stdout: '', stderr: errors.join('\n'), exitCode: hadError ? 1 : 0 }; } case 'rm': { // Enhanced rm command: rm [-rfv] // Parse flags including combined flags like -rf, -rfv let recursive = false; let force = false; let verbose = false; const targets: string[] = []; for (const arg of args) { if (arg && arg.startsWith('-')) { // Handle combined flags like -rf, -rfv if (arg.includes('r') || arg.includes('R')) recursive = true; if (arg.includes('f')) force = true; if (arg.includes('v')) verbose = true; } else if (arg) { targets.push(arg); } } if (targets.length === 0) return { stdout: '', stderr: 'rm: missing operand', exitCode: 2 }; let hadError = false; const verboseOutput: string[] = []; const errorMessages: string[] = []; for (const target of targets) { const path = normalizePath(target); if (!path) { if (!force) hadError = true; continue; } // Handle server context files (/.server/) if (path.startsWith('/.server/')) { try { await vfs.deleteServerContextFile(path); if (verbose) verboseOutput.push(`removed '${path}'`); } catch (e: any) { if (!force) { hadError = true; const msg = `rm: cannot remove '${path}': ${e?.message || 'unknown error'}`; errorMessages.push(msg); if (verbose) verboseOutput.push(msg); } } continue; } try { // Try to delete as file first await vfs.deleteFile(projectId, path); if (verbose) verboseOutput.push(`removed '${path}'`); } catch { // If not a file, try as directory if (recursive) { try { await vfs.deleteDirectory(projectId, path); if (verbose) verboseOutput.push(`removed directory '${path}'`); } catch { if (!force) { hadError = true; const msg = `rm: cannot remove '${path}': No such file or directory`; errorMessages.push(msg); if (verbose) verboseOutput.push(msg); } } } else { if (!force) { hadError = true; const msg = `rm: cannot remove '${path}': Is a directory (use -r to remove directories)`; errorMessages.push(msg); if (verbose) verboseOutput.push(msg); } } } } const stdout = verbose ? verboseOutput.join('\n') : ''; const stderr = hadError ? errorMessages.join('\n') : ''; return { stdout: truncate(stdout), stderr, exitCode: hadError ? 1 : 0 }; } case 'mv': { const [rold, rnew] = args; const oldPath = normalizePath(rold); const newPath = normalizePath(rnew); if (!oldPath || !newPath) return { stdout: '', stderr: 'mv: missing operands', exitCode: 2 }; // Try file move try { await vfs.renameFile(projectId, oldPath, newPath); return { stdout: '', stderr: '', exitCode: 0 }; } catch { // Try directory move await vfs.renameDirectory(projectId, oldPath, newPath); return { stdout: '', stderr: '', exitCode: 0 }; } } case 'cp': { // Support: cp | cp -r const recursive = args.includes('-r'); const filtered = args.filter(a => a !== '-r'); let [src, dst] = filtered; src = normalizePath(src) as string; dst = normalizePath(dst) as string; if (!src || !dst) return { stdout: '', stderr: 'cp: missing operands', exitCode: 2 }; // Attempt file copy try { const file = await vfs.readFile(projectId, src); try { await vfs.createFile(projectId, dst, file.content as any); } catch { await vfs.updateFile(projectId, dst, file.content as any); } return { stdout: '', stderr: '', exitCode: 0 }; } catch { if (!recursive) { return { stdout: '', stderr: 'cp: -r required for directories', exitCode: 1 }; } // Directory copy: copy all files under src prefix const entries = await vfs.getAllFilesAndDirectories(projectId, { includeTransient: true }); const srcPrefix = src.endsWith('/') ? src : src + '/'; for (const e2 of entries) { if ('type' in e2 && e2.type === 'directory') continue; const file = e2 as any; if (file.path === src || file.path.startsWith(srcPrefix)) { const rel = file.path.slice(src.length); const target = (dst.endsWith('/') ? dst.slice(0, -1) : dst) + rel; await ensureDirectory(vfs, projectId, target.split('/').slice(0, -1).join('/')); try { await vfs.createFile(projectId, target, file.content as any); } catch { await vfs.updateFile(projectId, target, file.content as any); } } } return { stdout: '', stderr: '', exitCode: 0 }; } } case 'echo': { // echo [-n] [-e] text — redirect handled generically by extractRedirect/applyRedirect let suppressNewline = false; let interpretEscapes = false; let startIdx = 0; // Consume leading flag args (bash behavior: only leading args that are purely valid flag chars) for (let i = 0; i < args.length; i++) { const a = args[i]; if (a.startsWith('-') && a.length > 1 && /^-[ne]+$/.test(a)) { for (const ch of a.slice(1)) { if (ch === 'n') suppressNewline = true; else if (ch === 'e') interpretEscapes = true; } startIdx = i + 1; } else { break; } } let output = args.slice(startIdx).join(' '); if (interpretEscapes) { output = output .replace(/\\n/g, '\n') .replace(/\\t/g, '\t') .replace(/\\\\/g, '\\'); } // suppressNewline: our shell doesn't auto-append newlines so it's effectively a no-op, // but the flag is consumed so it doesn't appear in output. if (redirect) return applyRedirect(vfs, projectId, output, redirect); return { stdout: truncate(output), stderr: '', exitCode: 0 }; } case 'sed': { // sed [-i] [-n] [-e expr]... 'expr' [file] // Supports: substitution, range delete, range change, range print let inPlace = false; let suppressOutput = false; const expressions: string[] = []; let filePath = ''; // Parse arguments for (let i = 0; i < args.length; i++) { const a = args[i]; // -i (GNU), -i '' (BSD/macOS), -i.bak (backup extension) — all mean in-place // Guard against combined flags like -in or -ie — only match -i alone or -i with non-alpha suffix (.bak) if (a === '-i' || (a.startsWith('-i') && a.length > 2 && !/^-i[a-z]$/i.test(a))) { inPlace = true; continue; } if (a === '-n') { suppressOutput = true; continue; } if (a === '-e' && args[i + 1]) { expressions.push(args[++i]); continue; } // Substitution expression (s/old/new/g) if (a.startsWith('s') && a.length > 2 && /[\/|#@]/.test(a[1])) { expressions.push(a); continue; } // Address-based expression (/pattern/d, 5,10d, $d, /p1/,/p2/c\text, etc.) // Must distinguish from file paths like /styles/style.css: // Address: /re/d, /re/p, /re/n (command letter at end of token) // /re/c\text, /re/a\text, /re/i\text (command letter + backslash) // /re/,/re2/... (range — comma right after the first addr) // Path: /dir/file.ext (second slash followed by arbitrary text) // The previous heuristic `/^\/[^/]*\/[,dpcians]/` misclassified any path whose // basename began with d/p/c/i/a/n/s (e.g. /src/index.ts → command `i`). if ( /^\d+!?[,dpcians{]/.test(a) || /^[\\$]/.test(a) || /^\/[^/]*\/(?:!?[dpn]$|!?[cai]\\|!?\{|,)/.test(a) ) { expressions.push(a); continue; } if (!a.startsWith('-') && a) filePath = a; } if (expressions.length === 0) { return { stdout: '', stderr: `sed: missing expression Usage: sed [-i] [-n] [-e expr] 'expr' [file] Commands: s/pattern/replacement/[g] Substitute (BRE: parens are literal) /pattern1/,/pattern2/d Delete lines in range /pattern1/,/pattern2/c\\text Replace range with text /pattern/i\\text Insert text before matching line /pattern/a\\text Append text after matching line -n '/pattern/p' Print matching lines only Examples: sed -i 's/old/new/g' /file.txt sed -i '/