// Shell del proyecto: un intérprete POSIX-lite que opera SOBRE LOS FICHEROS // REALES de la carpeta abierta (el FileSystemDirectoryHandle de code.js). No es // una emulación: `ls`, `cat`, `grep`, `mkdir`, `rm`, `echo > fichero`… tocan el // disco de verdad y el editor/árbol se refrescan. Es el mismo mundo que ve la // elfa, así que también lo exponemos como herramienta `terminal.run`. // // Runtimes reales (node/npm/python): SOLO con un Bridge local conectado (ver // bridge.js) se ejecutan de VERDAD en tu máquina — ver `capabilities()`. import * as code from './tools/code.js'; import * as bridgeClient from './bridge.js'; let cwd = []; // directorio actual, segmentos relativos a la raíz const history = []; // historial de comandos (para ↑/↓ y `history`) let onFsChange = () => {}; // el IDE refresca árbol/pestañas tras mutaciones let onOpenFile = () => {}; // `open ` lo abre en Monaco export function setHooks({ fsChange, openFile } = {}) { if (fsChange) onFsChange = fsChange; if (openFile) onOpenFile = openFile; } export function reset() { cwd = []; } export function cwdString() { return '/' + cwd.join('/'); } export function getHistory() { return history.slice(); } // ¿Podemos lanzar runtimes reales? Solo por Bridge local conectado de verdad // (WebContainers requiere licencia comercial de StackBlitz en producción — // descartado por ahora, ver bridge.js). export function capabilities() { const bridge = bridgeClient.isConnected(); return { crossOriginIsolated: !!self.crossOriginIsolated, webcontainers: false, bridge, realRuntime: bridge }; } // ---- resolución de rutas ---- // Combina un argumento con el cwd y normaliza . y .. → segmentos desde la raíz. function resolve(arg = '') { const base = arg.startsWith('/') ? [] : [...cwd]; for (const seg of String(arg).split('/')) { if (!seg || seg === '.') continue; if (seg === '..') base.pop(); else base.push(seg); } return base; } const root = () => { const h = code.handle(); if (!h) throw new Error('no hay proyecto abierto'); return h; }; async function dirHandle(segs, { create = false } = {}) { let dir = root(); for (const s of segs) dir = await dir.getDirectoryHandle(s, { create }); return dir; } async function parentAndName(segs) { const s = [...segs]; const name = s.pop(); if (name == null) throw new Error('ruta vacía'); return { parent: await dirHandle(s), name }; } async function fileHandle(segs, opts) { const { parent, name } = await parentAndName(segs); return parent.getFileHandle(name, opts); } async function readText(segs) { return (await (await fileHandle(segs)).getFile()).text(); } async function writeText(segs, text) { const fh = await fileHandle(segs, { create: true }); const w = await fh.createWritable(); await w.write(text); await w.close(); code.invalidateFileList?.(); onFsChange(); } // ¿ruta es directorio? (para `ls fichero`, `grep dir`, `cd`) async function kindOf(segs) { if (!segs.length) return 'directory'; const { parent, name } = await parentAndName(segs); try { await parent.getDirectoryHandle(name); return 'directory'; } catch {} try { await parent.getFileHandle(name); return 'file'; } catch {} return null; } const IGNORE = new Set(['node_modules', '.git', 'dist', 'build', 'target', '__pycache__', '.next', 'venv', '.venv', '.DS_Store']); const CYAN = s => `\x1b[36m${s}\x1b[0m`, RED = s => `\x1b[31m${s}\x1b[0m`, DIM = s => `\x1b[90m${s}\x1b[0m`, GRN = s => `\x1b[32m${s}\x1b[0m`, YEL = s => `\x1b[33m${s}\x1b[0m`; const globToRe = g => new RegExp('^' + g.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$', 'i'); const human = n => n < 1024 ? n + 'B' : n < 1048576 ? (n / 1024).toFixed(1) + 'K' : (n / 1048576).toFixed(1) + 'M'; // ---- comandos: (args[], stdin) → stdout (string). Lanzan Error en fallo. ---- const CMDS = { help() { return 'Elffuss shell — opera sobre los ficheros REALES del proyecto:\n' + ' ' + GRN('ls') + ' [-la] [ruta] ' + GRN('cd') + ' [ruta] ' + GRN('pwd') + ' ' + GRN('tree') + ' [ruta]\n' + ' ' + GRN('cat') + ' ' + GRN('head') + '/' + GRN('tail') + ' [-n N] ' + GRN('wc') + ' [-l] ' + GRN('grep') + ' [-in] [ruta]\n' + ' ' + GRN('find') + ' [ruta] [-name g] ' + GRN('echo') + ' [> f] ' + GRN('mkdir') + '/' + GRN('touch') + '/' + GRN('rm') + ' [-r]/' + GRN('mv') + '/' + GRN('cp') + '\n' + ' ' + GRN('open') + ' (en el editor) ' + GRN('git') + ' status|log|branch ' + GRN('history') + ' ' + GRN('clear') + '\n' + ' Soporta tuberías ' + YEL('|') + ' y redirección ' + YEL('>') + ' ' + YEL('>>') + '. ' + (capabilities().bridge ? GRN('node/npm/python disponibles (Bridge local).') : DIM('node/npm/python: conecta el Bridge local (⚙ Ajustes).')); }, pwd() { return cwdString(); }, async cd(args) { const target = args.find(a => !a.startsWith('-')); if (!target || target === '~') { cwd = []; return ''; } const segs = resolve(target); if (await kindOf(segs) !== 'directory') throw new Error(`cd: no existe el directorio: ${target}`); cwd = segs; return ''; }, async ls(args) { const long = args.some(a => /^-\w*l/.test(a)), all = args.some(a => /^-\w*a/.test(a)); const target = args.find(a => !a.startsWith('-')) || ''; const segs = resolve(target); const k = await kindOf(segs); if (k === 'file') return target; // ls de un fichero if (k !== 'directory') throw new Error(`ls: no existe: ${target || '.'}`); const dir = await dirHandle(segs); const rows = []; for await (const e of dir.values()) { if (!all && (e.name.startsWith('.') || IGNORE.has(e.name))) continue; rows.push(e); } rows.sort((a, b) => (a.kind !== b.kind ? (a.kind === 'directory' ? -1 : 1) : a.name.localeCompare(b.name))); if (!long) return rows.map(e => e.kind === 'directory' ? CYAN(e.name + '/') : e.name).join(' '); const out = []; for (const e of rows) { if (e.kind === 'directory') out.push(`${DIM('dir ')} - ${CYAN(e.name + '/')}`); else { const f = await e.getFile(); out.push(`${DIM('file')} ${human(f.size).padStart(6)} ${e.name}`); } } return out.join('\n'); }, async cat(args, stdin) { const files = args.filter(a => !a.startsWith('-')); if (!files.length) return stdin; const parts = []; for (const f of files) { try { parts.push(await readText(resolve(f))); } catch { throw new Error(`cat: ${f}: no existe`); } } return parts.join(''); }, async head(args, stdin) { const n = +(args[args.indexOf('-n') + 1]) || 10; const f = args.find(a => !a.startsWith('-') && !/^\d+$/.test(a)); const text = f ? await readText(resolve(f)) : stdin; return text.split('\n').slice(0, n).join('\n'); }, async tail(args, stdin) { const n = +(args[args.indexOf('-n') + 1]) || 10; const f = args.find(a => !a.startsWith('-') && !/^\d+$/.test(a)); const text = f ? await readText(resolve(f)) : stdin; const lines = text.split('\n'); return lines.slice(Math.max(0, lines.length - n)).join('\n'); }, async wc(args, stdin) { const onlyLines = args.includes('-l'); const f = args.find(a => !a.startsWith('-')); const text = f ? await readText(resolve(f)) : stdin; const lines = text === '' ? 0 : text.split('\n').length; if (onlyLines) return String(lines); const words = (text.match(/\S+/g) || []).length; return `${lines}\t${words}\t${text.length}${f ? '\t' + f : ''}`; }, async tree(args) { const target = args.find(a => !a.startsWith('-')) || ''; return code.tree({ path: resolve(target).join('/'), depth: 3 }); }, async grep(args, stdin) { const insensitive = args.some(a => /^-\w*i/.test(a)), numbers = args.some(a => /^-\w*n/.test(a)); const rest = args.filter(a => !a.startsWith('-')); const pattern = rest.shift(); if (!pattern) throw new Error('grep: falta el patrón'); const re = new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), insensitive ? 'i' : ''); const hit = (line, i, prefix = '') => (prefix ? CYAN(prefix) + ':' : '') + (numbers ? DIM(i + 1 + ':') : '') + line.replace(re, m => `\x1b[1;31m${m}\x1b[0m`); if (!rest.length) { // desde stdin (tubería) return stdin.split('\n').map((l, i) => re.test(l) ? hit(l, i) : null).filter(x => x != null).join('\n'); } const out = []; let scanned = 0; const scanText = (text, label) => text.split('\n').forEach((l, i) => { if (re.test(l) && out.length < 400) out.push(hit(l, i, label)); }); for (const target of rest) { const segs = resolve(target); const k = await kindOf(segs); if (k === 'file') scanText(await readText(segs).catch(() => ''), target); else if (k === 'directory') { // recursivo const walk = async (dir, prefix) => { for await (const e of dir.values()) { if (IGNORE.has(e.name) || out.length >= 400 || scanned > 600) continue; const p = prefix ? prefix + '/' + e.name : e.name; if (e.kind === 'directory') { await walk(e, p); continue; } const f = await e.getFile(); if (f.size < 400_000) { scanned++; scanText(await f.text(), (target === '.' ? '' : target.replace(/\/$/, '') + '/') + p); } } }; await walk(await dirHandle(segs), ''); } else throw new Error(`grep: ${target}: no existe`); } return out.join('\n') || DIM('(sin coincidencias)'); }, async find(args) { const nameIdx = args.indexOf('-name'); const glob = nameIdx >= 0 ? globToRe(args[nameIdx + 1]) : null; const start = args.find(a => !a.startsWith('-') && a !== (nameIdx >= 0 ? args[nameIdx + 1] : null)) || '.'; const out = []; let count = 0; const walk = async (dir, prefix) => { for await (const e of dir.values()) { if (IGNORE.has(e.name) || count > 2000) continue; const p = prefix ? prefix + '/' + e.name : e.name; count++; if (!glob || glob.test(e.name)) out.push(e.kind === 'directory' ? CYAN(p) : p); if (e.kind === 'directory') await walk(e, p); } }; const segs = resolve(start === '.' ? '' : start); if (await kindOf(segs) !== 'directory') throw new Error(`find: ${start}: no es un directorio`); await walk(await dirHandle(segs), start === '.' ? '' : start); return out.join('\n') || DIM('(nada)'); }, echo(args) { return args.join(' ').replace(/^["']|["']$/g, ''); }, async mkdir(args) { const t = args.find(a => !a.startsWith('-')); if (!t) throw new Error('mkdir: falta el nombre'); await dirHandle(resolve(t), { create: true }); onFsChange(); return ''; }, async touch(args) { const t = args.find(a => !a.startsWith('-')); if (!t) throw new Error('touch: falta el nombre'); const segs = resolve(t); if (await kindOf(segs) == null) await writeText(segs, ''); else onFsChange(); return ''; }, // Soporta comodines (*, ?) y varios objetivos a la vez: `rm mejoras-*.md`. // Sin esto, un patrón con * se buscaba como nombre LITERAL → «no existe» // aunque hubiera decenas de coincidencias reales (bug real, visto en uso). async rm(args) { const recursive = args.some(a => /^-\w*r/.test(a)); const targets = args.filter(a => !a.startsWith('-')); if (!targets.length) throw new Error('rm: falta la ruta'); const removed = []; for (const t of targets) { if (/[*?]/.test(t)) { const parts = t.split('/'); const pattern = parts.pop(); const dirSegs = resolve(parts.join('/')); let dir; try { dir = await dirHandle(dirSegs); } catch { throw new Error(`rm: ${t}: no existe el directorio`); } const re = globToRe(pattern); const matches = []; for await (const e of dir.values()) if (re.test(e.name)) matches.push(e); if (!matches.length) throw new Error(`rm: ${t}: sin coincidencias`); for (const e of matches) { if (e.kind === 'directory' && !recursive) throw new Error(`rm: ${e.name}: es un directorio (usa -r)`); await dir.removeEntry(e.name, { recursive }); removed.push((dirSegs.length ? dirSegs.join('/') + '/' : '') + e.name); } } else { const segs = resolve(t); const k = await kindOf(segs); if (k == null) throw new Error(`rm: ${t}: no existe`); if (k === 'directory' && !recursive) throw new Error(`rm: ${t}: es un directorio (usa -r)`); const { parent, name } = await parentAndName(segs); await parent.removeEntry(name, { recursive }); removed.push(t); } } code.invalidateFileList?.(); onFsChange(); return removed.length > 1 ? `Eliminados ${removed.length}: ${removed.join(', ')}` : ''; }, async cp(args) { const rest = args.filter(a => !a.startsWith('-')); const [src, dst] = rest; if (!src || !dst) throw new Error('cp: uso: cp '); await copyPath(resolve(src), resolve(dst)); onFsChange(); return ''; }, async mv(args) { const rest = args.filter(a => !a.startsWith('-')); const [src, dst] = rest; if (!src || !dst) throw new Error('mv: uso: mv '); await copyPath(resolve(src), resolve(dst)); const { parent, name } = await parentAndName(resolve(src)); await parent.removeEntry(name, { recursive: true }); code.invalidateFileList?.(); onFsChange(); return ''; }, async open(args) { const t = args.find(a => !a.startsWith('-')); if (!t) throw new Error('open: falta el fichero'); const segs = resolve(t); if (await kindOf(segs) !== 'file') throw new Error(`open: ${t}: no es un fichero`); onOpenFile(segs.join('/')); return DIM(`abriendo ${segs.join('/')} en el editor…`); }, history() { return history.map((h, i) => `${DIM((i + 1) + '')} ${h}`).join('\n'); }, whoami() { return 'elffuss'; }, date() { return new Date().toString(); }, clear() { return '\f'; }, // la UI lo interpreta async git(args) { const sub = args[0]; const info = await code.gitInfo(); if (!info.isRepo) return DIM('no es un repositorio git (no hay .git)'); if (sub === 'branch' || !sub) return '* ' + GRN(info.branch); if (sub === 'status') return `En la rama ${GRN(info.branch)}\n` + (info.lastCommit ? DIM(`último commit: ${info.lastCommit.msg}`) : DIM('sin commits todavía')) + '\n' + DIM('(git de solo lectura aquí; commit/push reales no disponibles todavía)'); if (sub === 'log') return info.lastCommit ? `${YEL('commit ' + info.branch)}\nAutor: ${info.lastCommit.author}\nFecha: ${new Date(info.lastCommit.when).toLocaleString()}\n\n ${info.lastCommit.msg}` : DIM('sin commits'); return DIM(`git ${sub}: de solo lectura aquí (branch/status/log).`); }, }; // tokenize() ya quitó las comillas originales (p.ej. -e "console.log(1)" llega // como el arg SUELTO console.log(1)) — para reenviarlo al bridge (que lo pasa // a `sh -c`) hay que re-entrecomillarlo, si no un espacio o un paréntesis // rompe el comando al otro lado (bug real: -e "console.log('x')" fallaba). const shellQuote = s => `'${String(s).replace(/'/g, "'\\''")}'`; // runtimes REALES solo si hay un Bridge local conectado (⚙ Ajustes) — se // ejecutan en tu máquina de verdad, en la carpeta configurada del bridge (no // en el cwd virtual del proyecto). for (const r of ['node', 'npm', 'npx', 'python', 'python3', 'pip', 'yarn', 'pnpm', 'bun', 'deno', 'cargo', 'go']) CMDS[r] = async (args) => { if (!bridgeClient.isConnected()) { throw new Error(`${r}: runtime real no disponible.\n${DIM('Descarga y arranca el Bridge local (⚙ Ajustes → 🔌 Bridge local) para ejecución real en tu máquina — se conecta solo en cuanto lo detecte. Mientras tanto, la elfa edita ficheros y tú navegas con los comandos de arriba (help).')}`); } const full = r + (args.length ? ' ' + args.map(shellQuote).join(' ') : ''); let out = ''; const code = await bridgeClient.exec(full, { onOut: t => { out += t; }, onErr: t => { out += RED(t); } }); if (code) out += `\n${DIM('(código de salida ' + code + ')')}`; return out || DIM('(sin salida)'); }; // copia recursiva por handles (la File System API no tiene move nativo) async function copyPath(srcSegs, dstSegs) { const k = await kindOf(srcSegs); if (k == null) throw new Error('origen no existe'); if (k === 'file') { await writeText(dstSegs, await readText(srcSegs)); return; } const src = await dirHandle(srcSegs); await dirHandle(dstSegs, { create: true }); for await (const e of src.values()) await copyPath([...srcSegs, e.name], [...dstSegs, e.name]); } // ---- parser: tuberías, redirección, comillas ---- function splitTop(line, sep) { // parte por `sep` respetando comillas const parts = []; let cur = '', q = ''; for (const ch of line) { if (q) { cur += ch; if (ch === q) q = ''; } else if (ch === '"' || ch === "'") { q = ch; cur += ch; } else if (ch === sep) { parts.push(cur); cur = ''; } else cur += ch; } parts.push(cur); return parts; } function tokenize(seg) { const toks = []; let cur = '', q = ''; for (const ch of seg.trim()) { if (q) { if (ch === q) q = ''; else cur += ch; } else if (ch === '"' || ch === "'") q = ch; else if (/\s/.test(ch)) { if (cur) { toks.push(cur); cur = ''; } } else cur += ch; } if (cur) toks.push(cur); return toks; } // Ejecuta una línea completa (encadenado &&/;, tuberías, redirección). // `\f` en el retorno = orden de limpiar pantalla (para la UI). export async function exec(line, { record = true } = {}) { line = line.trim(); if (!line) return ''; if (record) history.push(line); const segments = tokenizeChain(line); if (segments.length === 1) return runPipeline(segments[0].cmd); // encadenado: A && B (B solo si A fue bien) · A ; B (B siempre) const outs = []; let ok = true; for (const { cmd, op } of segments) { if (op === '&&' && !ok) continue; try { outs.push(await runPipeline(cmd)); ok = true; } catch (e) { ok = false; outs.push('\x1b[31m' + (e && e.message || e) + '\x1b[0m'); } } if (outs.every(o => o === '\f')) return '\f'; return outs.filter(o => o && o !== '\f').join('\n'); } // Divide la línea en segmentos con su operador previo (&& / ; ), respetando comillas. function tokenizeChain(line) { const segs = []; let cur = '', q = '', op = null; for (let i = 0; i < line.length; i++) { const ch = line[i]; if (q) { cur += ch; if (ch === q) q = ''; continue; } if (ch === '"' || ch === "'") { q = ch; cur += ch; continue; } if (ch === '&' && line[i + 1] === '&') { segs.push({ cmd: cur.trim(), op }); op = '&&'; cur = ''; i++; continue; } if (ch === ';') { segs.push({ cmd: cur.trim(), op }); op = ';'; cur = ''; continue; } cur += ch; } if (cur.trim()) segs.push({ cmd: cur.trim(), op }); return segs.filter(s => s.cmd); } // Ejecuta un pipeline (una etapa o varias unidas por |, con redirección final). async function runPipeline(line) { const stages = splitTop(line, '|').map(s => s.trim()).filter(Boolean); let data = ''; for (let i = 0; i < stages.length; i++) { let seg = stages[i], redir = null; if (i === stages.length - 1) { const m = seg.match(/(>>?)\s*(\S+)\s*$/); if (m) { redir = { append: m[1] === '>>', file: m[2] }; seg = seg.slice(0, m.index); } } const toks = tokenize(seg); const name = toks.shift(); if (!name) continue; const fn = CMDS[name]; if (!fn) throw new Error(`${name}: comando no encontrado (prueba ${GRN('help')})`); data = (await fn(toks, data)) ?? ''; if (redir) { const segs = resolve(redir.file); const prev = redir.append ? await readText(segs).catch(() => '') : ''; await writeText(segs, prev + stripAnsi(data)); data = ''; } } return data; } export const stripAnsi = s => String(s).replace(/\x1b\[[0-9;]*m/g, ''); // Entradas de un directorio (para el completado con Tab del terminal). export async function listDir(dirPart = '') { const dir = await dirHandle(resolve(dirPart)); const out = []; for await (const e of dir.values()) if (!IGNORE.has(e.name)) out.push({ name: e.name, kind: e.kind }); return out; } // Ejecuta una orden desde la elfa (tool terminal.run): devuelve texto plano. export async function runForAgent(command) { const out = await exec(command, { record: true }); return out === '\f' ? '(pantalla limpiada)' : (stripAnsi(out) || '(sin salida)'); }