elffuss-code / js /shell.js
KikoCis's picture
Elffuss Code — browser IDE with a local AI agent (WebGPU)
b30b7c5 verified
Raw
History Blame Contribute Delete
20.7 kB
// 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 <fichero>` 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') + ' <f...> ' + GRN('head') + '/' + GRN('tail') + ' [-n N] ' + GRN('wc') + ' [-l] ' + GRN('grep') + ' [-in] <patrón> [ruta]\n' +
' ' + GRN('find') + ' [ruta] [-name g] ' + GRN('echo') + ' <txt> [> f] ' + GRN('mkdir') + '/' + GRN('touch') + '/' + GRN('rm') + ' [-r]/' + GRN('mv') + '/' + GRN('cp') + '\n' +
' ' + GRN('open') + ' <f> (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 <origen> <destino>');
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 <origen> <destino>');
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)');
}