File size: 20,680 Bytes
b30b7c5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
// 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)');
}