fauxnix-cli 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/executor.js CHANGED
@@ -1,13 +1,35 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { promises as fs, readFileSync, existsSync, openSync, closeSync, writeSync } from 'node:fs';
2
+ import { promises as fs, readFileSync, existsSync, openSync, closeSync, readSync, rmSync, writeSync, } from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { normalizeLiteralPath, wrapScript } from './translator.js';
6
- import { decodeOutput, normalizeHostNewlines, resolveNativePref } from './encoding.js';
6
+ import { decodeOutput, resolveNativePref } from './encoding.js';
7
7
  import { normalizeStderr } from './errors.js';
8
- import { DEFAULT_STDERR_LIMIT, DEFAULT_STDOUT_LIMIT, PowerShellHost, PS_MISSING_MESSAGE, } from './ps-host.js';
8
+ import { DEFAULT_STDERR_LIMIT, DEFAULT_STDOUT_LIMIT, PowerShellHost, } from './ps-host.js';
9
+ import { resolvePowerShell } from './powershell.js';
9
10
  const DEFAULT_TIMEOUT_MS = 120_000;
10
11
  const DEFAULT_WINDOWS_PATHEXT = '.COM;.EXE;.BAT;.CMD';
12
+ const MAX_HOST_CAPTURE_LIMIT = 0x7fffffff;
13
+ const UTF8_BOUNDARY_SUFFIX = 4;
14
+ const MAX_CALLER_OUTPUT_LIMIT = MAX_HOST_CAPTURE_LIMIT - UTF8_BOUNDARY_SUFFIX;
15
+ function validateOutputLimit(name, value) {
16
+ if (!Number.isSafeInteger(value) || value < 0 || value > MAX_CALLER_OUTPUT_LIMIT) {
17
+ throw new RangeError('fauxnix: ' + name + ' must be an integer from 0 to ' + MAX_CALLER_OUTPUT_LIMIT);
18
+ }
19
+ return value;
20
+ }
21
+ /**
22
+ * Retain one complete codepoint beyond the logical caller budget so Node can
23
+ * detect truncation and clip at a UTF-8 boundary. The host itself stays
24
+ * bounded even when the caller has no remaining budget.
25
+ */
26
+ function rawHostCaptureLimit(logicalBytes) {
27
+ if (!Number.isFinite(logicalBytes)) {
28
+ return logicalBytes === Number.POSITIVE_INFINITY ? MAX_HOST_CAPTURE_LIMIT : 0;
29
+ }
30
+ const bytes = Math.max(0, Math.floor(logicalBytes));
31
+ return Math.min(MAX_HOST_CAPTURE_LIMIT, bytes + UTF8_BOUNDARY_SUFFIX);
32
+ }
11
33
  function hasEnvKey(env, name) {
12
34
  const normalized = name.toUpperCase();
13
35
  return Object.keys(env).some((key) => key.toUpperCase() === normalized);
@@ -201,8 +223,12 @@ export class FauxnixSession {
201
223
  hostFile;
202
224
  host = null;
203
225
  lifecycleLock = Promise.resolve();
226
+ /** True once `env` was loaded from the host's complete environment snapshot. */
227
+ hasEnvSnapshot = false;
228
+ powerShell;
204
229
  constructor() {
205
230
  this.id = randomUUID().slice(0, 8);
231
+ this.powerShell = resolvePowerShell();
206
232
  this.bindFiles(this.id);
207
233
  }
208
234
  bindFiles(id) {
@@ -231,8 +257,10 @@ export class FauxnixSession {
231
257
  try {
232
258
  if (existsSync(this.envFile)) {
233
259
  const raw = readFileSync(this.envFile, 'utf8');
234
- if (raw.trim())
260
+ if (raw.trim()) {
235
261
  this.env = JSON.parse(raw);
262
+ this.hasEnvSnapshot = true;
263
+ }
236
264
  }
237
265
  }
238
266
  catch {
@@ -241,11 +269,11 @@ export class FauxnixSession {
241
269
  }
242
270
  ensureHost() {
243
271
  if (!this.host) {
244
- this.host = new PowerShellHost(this.hostFile, () => this.childEnv());
272
+ this.host = new PowerShellHost(this.hostFile, () => this.childEnv(), this.powerShell);
245
273
  }
246
274
  return this.host;
247
275
  }
248
- /** Boot powershell.exe now so the first run() is not the 1.1s cold start. */
276
+ /** Boot the selected PowerShell now so the first run() is not a cold start. */
249
277
  prewarm() {
250
278
  return this.withLock(async () => {
251
279
  await this.ensureHost().ready();
@@ -268,6 +296,7 @@ export class FauxnixSession {
268
296
  }
269
297
  this.cwd = null;
270
298
  this.env = {};
299
+ this.hasEnvSnapshot = false;
271
300
  this.prevExit = null;
272
301
  await Promise.allSettled([
273
302
  fs.rm(this.cwdFile, { force: true }),
@@ -279,12 +308,19 @@ export class FauxnixSession {
279
308
  }
280
309
  /** env for the child powershell process. */
281
310
  childEnv(cwdOverride, stdinFile) {
282
- const env = { ...process.env };
283
- for (const [k, v] of Object.entries(this.env)) {
284
- if (v === undefined)
285
- delete env[k];
286
- else
287
- env[k] = v;
311
+ // Before the first completed request, `env` contains optional caller
312
+ // overrides and is layered over the process baseline. Afterwards it is a
313
+ // complete snapshot written by the resident host. Restore that snapshot
314
+ // verbatim on transparent host restarts: merging it with process.env would
315
+ // resurrect inherited variables that the shell successfully unset.
316
+ const env = this.hasEnvSnapshot ? { ...this.env } : { ...process.env };
317
+ if (!this.hasEnvSnapshot) {
318
+ for (const [k, v] of Object.entries(this.env)) {
319
+ if (v === undefined)
320
+ delete env[k];
321
+ else
322
+ env[k] = v;
323
+ }
288
324
  }
289
325
  // The MCP SDK's safe Windows stdio environment omits PATHEXT. Without it,
290
326
  // PowerShell cannot resolve extensionless native commands such as `node`.
@@ -318,16 +354,70 @@ export class FauxnixSession {
318
354
  async function runPlans(plans, session, opts, afterSegment, ensureHost) {
319
355
  const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
320
356
  const deadline = Date.now() + timeoutMs;
321
- const stdoutLimit = opts.stdoutLimit ?? DEFAULT_STDOUT_LIMIT;
322
- const stderrLimit = opts.stderrLimit ?? DEFAULT_STDERR_LIMIT;
357
+ const stdoutLimit = validateOutputLimit('stdoutLimit', opts.stdoutLimit ?? DEFAULT_STDOUT_LIMIT);
358
+ const stderrLimit = validateOutputLimit('stderrLimit', opts.stderrLimit ?? DEFAULT_STDERR_LIMIT);
323
359
  const timeoutMessage = '\nbash: command timed out after ' + Math.round(timeoutMs / 1000) + 's';
324
360
  let stdout = '';
325
361
  let stderr = '';
362
+ let stdoutBytes = 0;
363
+ let stderrBytes = 0;
364
+ let stdoutClosed = false;
365
+ let stderrClosed = false;
326
366
  let exitCode = 0;
327
367
  let timedOut = false;
328
368
  let cancelled = false;
329
369
  let truncated = false;
330
370
  let spawnError;
371
+ const appendCaller = (fd, data) => {
372
+ if (!data)
373
+ return;
374
+ if (fd === 1 ? stdoutClosed : stderrClosed)
375
+ return;
376
+ const used = fd === 1 ? stdoutBytes : stderrBytes;
377
+ const limit = fd === 1 ? stdoutLimit : stderrLimit;
378
+ const clipped = clipUtf8(data, Math.max(0, limit - used));
379
+ if (fd === 1) {
380
+ stdout += clipped.text;
381
+ stdoutBytes += Buffer.byteLength(clipped.text, 'utf8');
382
+ }
383
+ else {
384
+ stderr += clipped.text;
385
+ stderrBytes += Buffer.byteLength(clipped.text, 'utf8');
386
+ }
387
+ if (clipped.truncated) {
388
+ if (fd === 1)
389
+ stdoutClosed = true;
390
+ else
391
+ stderrClosed = true;
392
+ truncated = true;
393
+ }
394
+ };
395
+ const remainingFor = (dest) => {
396
+ if (dest.kind !== 'caller')
397
+ return 0;
398
+ if (dest.fd === 1 ? stdoutClosed : stderrClosed)
399
+ return 0;
400
+ const limit = dest.fd === 1 ? stdoutLimit : stderrLimit;
401
+ const used = dest.fd === 1 ? stdoutBytes : stderrBytes;
402
+ return Math.max(0, limit - used);
403
+ };
404
+ const closeCallerDest = (dest) => {
405
+ if (dest.kind !== 'caller')
406
+ return;
407
+ if (dest.fd === 1)
408
+ stdoutClosed = true;
409
+ else
410
+ stderrClosed = true;
411
+ };
412
+ const hostStream = (dest) => {
413
+ if (dest.kind === 'file')
414
+ return { mode: 'spool', limit: 0 };
415
+ if (dest.kind === 'nul')
416
+ return { mode: 'discard', limit: 0 };
417
+ if (dest.fd === 1 ? stdoutClosed : stderrClosed)
418
+ return { mode: 'discard', limit: 0 };
419
+ return { mode: 'capture', limit: rawHostCaptureLimit(remainingFor(dest)) };
420
+ };
331
421
  // bash list semantics: `a && b ; c` runs c regardless of a; `a && b && c`
332
422
  // skips b AND c when a fails. chainOk models the value of the current
333
423
  // &&/|| chain; `;` segments always run and restart the chain.
@@ -350,7 +440,7 @@ async function runPlans(plans, session, opts, afterSegment, ensureHost) {
350
440
  break;
351
441
  }
352
442
  if (Date.now() >= deadline) {
353
- stderr += timeoutMessage;
443
+ appendCaller(2, timeoutMessage);
354
444
  exitCode = 124;
355
445
  timedOut = true;
356
446
  session.prevExit = exitCode;
@@ -377,10 +467,10 @@ async function runPlans(plans, session, opts, afterSegment, ensureHost) {
377
467
  let prepStderr = { kind: 'caller', fd: 2 };
378
468
  const emitPrepError = (msg) => emitToPrepDest(prepStderr, msg, prepFds, {
379
469
  stdout: (s) => {
380
- stdout += s;
470
+ appendCaller(1, s);
381
471
  },
382
472
  stderr: (s) => {
383
- stderr += s;
473
+ appendCaller(2, s);
384
474
  },
385
475
  });
386
476
  for (const r of plan.redirects) {
@@ -424,7 +514,7 @@ async function runPlans(plans, session, opts, afterSegment, ensureHost) {
424
514
  break;
425
515
  }
426
516
  if (remainingMs <= 0) {
427
- stderr += timeoutMessage;
517
+ appendCaller(2, timeoutMessage);
428
518
  exitCode = 124;
429
519
  timedOut = true;
430
520
  session.prevExit = exitCode;
@@ -435,20 +525,23 @@ async function runPlans(plans, session, opts, afterSegment, ensureHost) {
435
525
  // stderr onto the caller's stdout before stdout is pointed at NUL, so
436
526
  // captured stderr is still returned; `>/dev/null 2>&1` points both at NUL.
437
527
  const applyDests = lastStageOutputDests(plan.outputRedirects, resolveTarget);
438
- // Response budgets cap what the CALLER receives streams redirected to
439
- // files must never be truncated by them (Codex-review P1: `printf … > f`
440
- // with a small stdoutLimit was writing a clipped file). The final
441
- // clipUtf8 below still enforces the returned-data budget.
442
- const fileRedirected = applyDests.stdout.kind === 'file' || applyDests.stderr.kind === 'file';
528
+ // Each source stream is bounded according to its final destination.
529
+ // A file keeps the complete stream, /dev/null retains nothing, and a
530
+ // caller stream receives only the budget left by earlier list segments.
531
+ const hostStdout = hostStream(applyDests.stdout);
532
+ const hostStderr = hostStream(applyDests.stderr);
443
533
  const inv = await ensureHost().invoke(encoded, {
444
534
  FAUXNIX_CWD: currentDir,
445
535
  FAUXNIX_PREV_EXIT: session.prevExit === null ? '' : String(session.prevExit),
446
536
  FAUXNIX_STDIN_FILE: red.stdinFile || '',
447
- }, remainingMs, opts.signal, fileRedirected
448
- ? { stdoutLimit: 0, stderrLimit: 0 }
449
- : { stdoutLimit, stderrLimit });
537
+ }, remainingMs, opts.signal, {
538
+ stdoutLimit: hostStdout.limit,
539
+ stderrLimit: hostStderr.limit,
540
+ stdoutMode: hostStdout.mode,
541
+ stderrMode: hostStderr.mode,
542
+ });
450
543
  if (inv.spawnError === 'ENOENT' || inv.spawnError === 'START') {
451
- stderr += inv.stderr.toString('utf8') || PS_MISSING_MESSAGE;
544
+ appendCaller(2, inv.stderr.toString('utf8') || 'fauxnix: failed to start the selected PowerShell host\n');
452
545
  exitCode = 127;
453
546
  spawnError = inv.spawnError;
454
547
  session.prevExit = exitCode;
@@ -464,11 +557,14 @@ async function runPlans(plans, session, opts, afterSegment, ensureHost) {
464
557
  }
465
558
  afterSegment();
466
559
  const decodePref = resolveNativePref();
467
- // GNU line discipline: the PS host terminates Write-Output lines with
468
- // CRLF. Exact writers (fx-write / printf / echo -n) must keep embedded
469
- // CR so `printf 'a\r\nb' > out` stays 4 bytes.
470
- const segOut = normalizeHostNewlines(decodeOutput(inv.stdout, decodePref));
471
- let segErr = normalizeHostNewlines(normalizeStderr(decodeOutput(inv.stderr, decodePref)));
560
+ // Captured protocol frames are always UTF-8. FAUXNIX_NATIVE_ENCODING is
561
+ // consumed at the native-process boundary inside fx-native; applying it
562
+ // again here double-decodes every translated/non-ASCII frame. Only bytes
563
+ // written directly to the host's OS stderr pipe retain a native encoding.
564
+ const segOut = decodeOutput(inv.stdout, 'utf8');
565
+ const framedErr = decodeOutput(inv.stderr, 'utf8');
566
+ const nativeErr = decodeOutput(inv.nativeStderr ?? Buffer.alloc(0), decodePref);
567
+ let segErr = normalizeStderr(framedErr + nativeErr);
472
568
  if (inv.timedOut) {
473
569
  segErr += timeoutMessage;
474
570
  }
@@ -488,27 +584,53 @@ async function runPlans(plans, session, opts, afterSegment, ensureHost) {
488
584
  }
489
585
  catch (e) {
490
586
  if (fromStdout) {
491
- stderr += 'bash: ' + dest.path + ': cannot create: ' + e.message + '\n';
587
+ appendCaller(2, 'bash: ' + dest.path + ': cannot create: ' + e.message + '\n');
492
588
  exitCode = 1;
493
589
  redirectOk = false;
494
- stdout += data;
590
+ appendCaller(1, data);
495
591
  }
496
592
  else {
497
- stderr += data;
593
+ appendCaller(2, data);
498
594
  }
499
595
  }
500
596
  return;
501
597
  }
502
- if (dest.fd === 1)
503
- stdout += data;
504
- else
505
- stderr += data;
598
+ appendCaller(dest.fd, data);
506
599
  };
507
- deliverCaptured(applyDests.stdout, segOut, true);
508
- deliverCaptured(applyDests.stderr, segErr, false);
600
+ const deliverSpool = (dest, spool, fromStdout) => {
601
+ if (!spool)
602
+ return;
603
+ if (dest.kind !== 'file') {
604
+ throw new Error('fauxnix: host returned a spool for a non-file destination');
605
+ }
606
+ try {
607
+ writeSpoolToPrepFd(prepFds, spool, dest.path);
608
+ }
609
+ catch (e) {
610
+ appendCaller(2, 'bash: ' + dest.path + ': cannot write: ' + e.message + '\n');
611
+ exitCode = 1;
612
+ redirectOk = false;
613
+ }
614
+ };
615
+ const spools = [inv.stdoutSpool, inv.stderrSpool, inv.nativeStderrSpool].filter((file) => !!file);
616
+ try {
617
+ deliverCaptured(applyDests.stdout, segOut, true);
618
+ if (inv.stdoutTruncated)
619
+ closeCallerDest(applyDests.stdout);
620
+ deliverCaptured(applyDests.stderr, segErr, false);
621
+ if (inv.stderrTruncated)
622
+ closeCallerDest(applyDests.stderr);
623
+ deliverSpool(applyDests.stdout, inv.stdoutSpool, true);
624
+ deliverSpool(applyDests.stderr, inv.stderrSpool, false);
625
+ deliverSpool(applyDests.stderr, inv.nativeStderrSpool, false);
626
+ }
627
+ finally {
628
+ for (const spool of spools)
629
+ rmSync(spool, { force: true });
630
+ }
509
631
  if (inv.truncated)
510
632
  truncated = true;
511
- exitCode = inv.timedOut ? 124 : inv.cancelled ? 130 : inv.exitCode;
633
+ exitCode = !redirectOk ? 1 : inv.timedOut ? 124 : inv.cancelled ? 130 : inv.exitCode;
512
634
  if (inv.timedOut)
513
635
  timedOut = true;
514
636
  session.prevExit = exitCode;
@@ -525,13 +647,9 @@ async function runPlans(plans, session, opts, afterSegment, ensureHost) {
525
647
  closePrepFds(prepFds);
526
648
  }
527
649
  }
528
- const clippedOut = clipUtf8(stdout, stdoutLimit);
529
- const clippedErr = clipUtf8(stderr, stderrLimit);
530
- if (clippedOut.truncated || clippedErr.truncated)
531
- truncated = true;
532
650
  return {
533
- stdout: clippedOut.text,
534
- stderr: clippedErr.text,
651
+ stdout,
652
+ stderr,
535
653
  exitCode,
536
654
  timedOut,
537
655
  cancelled,
@@ -542,8 +660,33 @@ async function runPlans(plans, session, opts, afterSegment, ensureHost) {
542
660
  function clipUtf8(text, limit) {
543
661
  if (Buffer.byteLength(text, 'utf8') <= limit)
544
662
  return { text, truncated: false };
545
- let end = text.length;
546
- while (end > 0 && Buffer.byteLength(text.slice(0, end), 'utf8') > limit)
547
- end--;
663
+ let used = 0;
664
+ let end = 0;
665
+ for (const codepoint of text) {
666
+ const size = Buffer.byteLength(codepoint, 'utf8');
667
+ if (used + size > limit)
668
+ break;
669
+ used += size;
670
+ end += codepoint.length;
671
+ }
548
672
  return { text: text.slice(0, end), truncated: true };
549
673
  }
674
+ /** Copy a host spool into an already-open redirect fd with bounded memory. */
675
+ function writeSpoolToPrepFd(fds, file, target) {
676
+ const outFd = fds.get(target);
677
+ if (outFd === undefined)
678
+ throw new Error('fauxnix: redirect fd missing for ' + target);
679
+ const inFd = openSync(file, 'r');
680
+ const input = Buffer.allocUnsafe(65_536);
681
+ try {
682
+ while (true) {
683
+ const n = readSync(inFd, input, 0, input.length, null);
684
+ if (n <= 0)
685
+ break;
686
+ writeAllSync(outFd, input.subarray(0, n));
687
+ }
688
+ }
689
+ finally {
690
+ closeSync(inFd);
691
+ }
692
+ }
package/dist/install.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { dirname, join } from 'node:path';
4
- import { claudeUserConfigPath, codexConfigPath, hasCodexFauxnix, hasOpenCodeFauxnix, isServerMap, openCodeConfigPath, serverMapHasFauxnix, } from './doctor.js';
4
+ import { claudeUserConfigPath, codexConfigPath, fauxnixServerNames, hasCodexFauxnix, hasOpenCodeFauxnix, isServerMap, openCodeConfigPath, serverMapHasFauxnix, } from './doctor.js';
5
+ import { resolveQwenLaunchTuple, sameQwenLaunchTuple } from './qwen-launch.js';
5
6
  export const INSTALL_FLAGS = ['claude', 'codex', 'opencode', 'kimi', 'qwen'];
6
7
  const STDIO = { command: 'fauxnix', args: ['mcp'] };
7
8
  const OPENCODE_STDIO = { type: 'local', command: ['fauxnix', 'mcp'] };
@@ -71,9 +72,49 @@ function installHarness(name, ctx) {
71
72
  case 'kimi':
72
73
  return patchMcpServers(kimiConfigPath(ctx.home, ctx.env), 'kimi');
73
74
  case 'qwen':
74
- return patchMcpServers(qwenConfigPath(ctx.home, ctx.env), 'qwen');
75
+ return patchQwen(qwenConfigPath(ctx.home, ctx.env));
75
76
  }
76
77
  }
78
+ function patchQwen(path) {
79
+ const launch = resolveQwenLaunchTuple();
80
+ if (!launch.ok)
81
+ return { ok: false, line: `qwen: ${launch.reason} — not modified` };
82
+ const read = readJsonObject(path);
83
+ if (read.state === 'invalid') {
84
+ return { ok: false, line: `qwen: ${path} is ${read.reason} — not modified` };
85
+ }
86
+ const existed = read.state !== 'missing';
87
+ const data = read.state === 'ok' ? read.data : {};
88
+ if (data.mcpServers != null && !isServerMap(data.mcpServers)) {
89
+ return { ok: false, line: `qwen: ${path} mcpServers is not an object — not modified` };
90
+ }
91
+ if (!isServerMap(data.mcpServers))
92
+ data.mcpServers = {};
93
+ const servers = data.mcpServers;
94
+ const extraNames = fauxnixServerNames(servers).filter((name) => name !== 'fauxnix');
95
+ if (extraNames.length) {
96
+ return {
97
+ ok: false,
98
+ line: `qwen: ${path} has another fauxnix MCP entry (${extraNames.join(', ')}) — remove the extra entry, then retry; not modified`,
99
+ };
100
+ }
101
+ const current = servers.fauxnix;
102
+ if (current != null && !isServerMap(current)) {
103
+ return {
104
+ ok: false,
105
+ line: `qwen: ${path} mcpServers.fauxnix is not an object — not modified`,
106
+ };
107
+ }
108
+ if (sameQwenLaunchTuple(current, launch.value)) {
109
+ return { ok: true, line: `qwen: already configured with an absolute launcher (${path})` };
110
+ }
111
+ servers.fauxnix = {
112
+ ...(isServerMap(current) ? current : {}),
113
+ command: launch.value.command,
114
+ args: [...launch.value.args],
115
+ };
116
+ return writeJson(path, data, 'qwen', existed, current == null ? 'added mcpServers.fauxnix' : 'updated mcpServers.fauxnix launcher');
117
+ }
77
118
  function patchMcpServers(path, harness) {
78
119
  const read = readJsonObject(path);
79
120
  if (read.state === 'invalid') {
package/dist/mcp.d.ts CHANGED
@@ -19,6 +19,19 @@ export declare function bashToolResult(r: ExecResult, sessionId: string, infra:
19
19
  sessionId: string;
20
20
  };
21
21
  };
22
+ export declare function translateToolResult(command: string): {
23
+ content: {
24
+ type: "text";
25
+ text: string;
26
+ }[];
27
+ isError?: undefined;
28
+ } | {
29
+ content: {
30
+ type: "text";
31
+ text: string;
32
+ }[];
33
+ isError: true;
34
+ };
22
35
  export declare function positionalCountFromEnv(env: Record<string, string>): number;
23
36
  export declare function formatSessionStatus(session: {
24
37
  cwd: string | null;
package/dist/mcp.js CHANGED
@@ -3,7 +3,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
3
3
  import { z } from 'zod';
4
4
  import { FauxnixSession } from './executor.js';
5
5
  import { parseCommand } from './parser.js';
6
- import { translateCommandList, wrapScript, translatePipelineBody } from './translator.js';
6
+ import { EXECUTE_TRANSLATION, PURE_TRANSLATION, translateCommandList, wrapScript, translatePipelineBody, } from './translator.js';
7
7
  import { registeredNames } from './registry.js';
8
8
  import { packageVersion } from './version.js';
9
9
  import './commands/install-all.js';
@@ -69,6 +69,18 @@ export function bashToolResult(r, sessionId, infra) {
69
69
  ...(infra ? { isError: true } : {}),
70
70
  };
71
71
  }
72
+ export function translateToolResult(command) {
73
+ try {
74
+ const list = parseCommand(command);
75
+ const plans = translateCommandList(list, PURE_TRANSLATION);
76
+ const script = wrapScript(plans.map((p) => p.script).join('\n# ---- next segment ----\n'));
77
+ return { content: [{ type: 'text', text: script }] };
78
+ }
79
+ catch (e) {
80
+ const msg = e instanceof Error ? e.message : String(e);
81
+ return { content: [{ type: 'text', text: msg }], isError: true };
82
+ }
83
+ }
72
84
  /** Packed FAUXNIX_POS uses char-30 separators (same as array sidecar). */
73
85
  const POS_SEP = '\x1e';
74
86
  export function positionalCountFromEnv(env) {
@@ -112,7 +124,7 @@ export async function startMcpServer() {
112
124
  .describe('Timeout in milliseconds (default 120000)'),
113
125
  }, EXEC_ANNOTATIONS, async ({ command, timeout_ms }, extra) => {
114
126
  try {
115
- const plans = translateCommandList(parseCommand(command));
127
+ const plans = translateCommandList(parseCommand(command), EXECUTE_TRANSLATION);
116
128
  const result = await session.run(plans, {
117
129
  timeoutMs: timeout_ms,
118
130
  signal: extra.signal,
@@ -132,18 +144,7 @@ export async function startMcpServer() {
132
144
  }, session.id, true);
133
145
  }
134
146
  });
135
- server.tool('fauxnix_translate', 'Translate a bash-style command into the equivalent PowerShell script WITHOUT executing it. Useful for learning/debugging what fauxnix does under the hood.', { command: z.string().describe('The bash-style command line to translate (never executed)') }, TRANSLATE_ANNOTATIONS, async ({ command }) => {
136
- try {
137
- const list = parseCommand(command);
138
- const plans = translateCommandList(list);
139
- const script = wrapScript(plans.map((p) => p.script).join('\n# ---- next segment ----\n'));
140
- return { content: [{ type: 'text', text: script }] };
141
- }
142
- catch (e) {
143
- const msg = e instanceof Error ? e.message : String(e);
144
- return { content: [{ type: 'text', text: msg }], isError: true };
145
- }
146
- });
147
+ server.tool('fauxnix_translate', 'Translate a bash-style command into the equivalent PowerShell script WITHOUT executing it. Useful for learning/debugging what fauxnix does under the hood.', { command: z.string().describe('The bash-style command line to translate (never executed)') }, TRANSLATE_ANNOTATIONS, async ({ command }) => translateToolResult(command));
147
148
  server.tool('fauxnix_session', 'Inspect or reset the persistent fauxnix shell session (current directory, environment, positional count, session id). Actions: "status" (default) or "reset".', {
148
149
  action: z
149
150
  .enum(['status', 'reset'])
package/dist/parser.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { FauxnixParseError, isUnquotedLiteral, wordToString, } from './ast.js';
2
2
  const OPERATORS = [
3
- '&&', '||', '>>', '<<', '2>&1', '1>&2', '2>', '&>>', '&>', '>', '<', '|', ';;', ';', '&',
3
+ '&&', '||', '2>>', '>>', '<<', '2>&1', '1>&2', '2>', '&>>', '&>', '>', '<', '|', ';;', ';', '&',
4
4
  ];
5
5
  const BACKGROUND_MSG = 'fauxnix: background & is not supported yet. Run the command in the foreground instead.';
6
6
  const WHILE_UNTIL_MSG = 'fauxnix: while/until loops are not supported yet. Use `for x in ...; do ...; done` over a known list instead.';
@@ -61,6 +61,12 @@ export function tokenize(input) {
61
61
  // try operators (longest first — list above is ordered)
62
62
  let matched;
63
63
  for (const op of OPERATORS) {
64
+ // A digit-leading fd operator is only a direct token at a word boundary.
65
+ // Otherwise consume the digit normally: `file2>>out` is word `file2`
66
+ // plus stdout `>>out`, while `12>>out` stays one unsupported fd token
67
+ // and fails loud instead of being stolen by `2>>`.
68
+ if (cur.length > 0 && /^\d/.test(op))
69
+ continue;
64
70
  if (input.startsWith(op, i)) {
65
71
  matched = op;
66
72
  break;
@@ -184,8 +190,10 @@ export function tokenize(input) {
184
190
  i += 2;
185
191
  continue;
186
192
  }
187
- // track leading digits (potential fd number for redirects)
188
- if (/[0-9]/.test(ch) && cur.length === 0 && fdDigits.length < 2) {
193
+ // Track an all-digit word as a potential fd number. Keep consuming every
194
+ // leading digit so unsupported multi-digit fds stay intact and fail loud
195
+ // instead of becoming argv plus a different redirect.
196
+ if (/[0-9]/.test(ch) && (cur.length === 0 || fdDigits.length === cur.length)) {
189
197
  beginWordPart();
190
198
  fdDigits += ch;
191
199
  cur.push({ kind: 'Text', text: ch });
@@ -0,0 +1,22 @@
1
+ export declare const POWERSHELL_ARGS: readonly ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass"];
2
+ export type PowerShellEdition = 'Desktop' | 'Core';
3
+ export interface PowerShellSelection {
4
+ /** Absolute executable path resolved once for this check/session. */
5
+ readonly executable: string;
6
+ readonly expectedEdition: PowerShellEdition;
7
+ readonly configured: boolean;
8
+ readonly requested?: string;
9
+ readonly error?: string;
10
+ }
11
+ export interface PowerShellResolveOptions {
12
+ cwd?: string;
13
+ exists?: (candidate: string) => boolean;
14
+ }
15
+ /**
16
+ * Select the process-wide PowerShell host. FAUXNIX_PS is intentionally a
17
+ * small enum, not a command line: spawn() receives one executable and the
18
+ * fixed fauxnix arguments separately.
19
+ */
20
+ export declare function resolvePowerShell(env?: NodeJS.ProcessEnv, options?: PowerShellResolveOptions): PowerShellSelection;
21
+ export declare function powerShellDisplay(selection: PowerShellSelection): string;
22
+ export declare function powerShellMissingMessage(selection: PowerShellSelection): string;