fauxnix-cli 0.4.0 → 0.5.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/README.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # fauxnix
2
2
 
3
+ [![CI](https://github.com/20000419/fauxnix/actions/workflows/ci.yml/badge.svg)](https://github.com/20000419/fauxnix/actions/workflows/ci.yml)
4
+ [![npm version](https://img.shields.io/npm/v/fauxnix-cli.svg)](https://www.npmjs.com/package/fauxnix-cli)
5
+ [![npm downloads](https://img.shields.io/npm/dt/fauxnix-cli.svg)](https://www.npmjs.com/package/fauxnix-cli)
6
+ [![license](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
7
+ [![20000419/fauxnix MCP server](https://glama.ai/mcp/servers/20000419/fauxnix/badges/score.svg)](https://glama.ai/mcp/servers/20000419/fauxnix)
8
+
3
9
  **Run Linux-style commands on Windows — natively, deterministically, with no VM and no WSL.**
4
10
 
5
11
  fauxnix is a bash→PowerShell translation layer built for AI agents. Your agent keeps writing the
@@ -8,15 +14,37 @@ fauxnix deterministically translates each command into PowerShell, executes it n
8
14
  back output that looks like GNU/Linux: `ls -l` columns, bash-style error messages, coreutils exit
9
15
  codes, UTF-8/GBK handled automatically.
10
16
 
17
+ ```bash
18
+ npm install -g fauxnix-cli # then point any MCP harness at `fauxnix mcp`
19
+ ```
20
+
21
+ ![fauxnix demo](docs/assets/demo.svg)
22
+
11
23
  ```
12
24
  $ fauxnix "ls -la src | head -2"
13
25
  -rw-r--r-- 1 me me 1204 Aug 16 09:12 ast.ts
14
- -rw-r--r-- 1 me me 8192 Aug 16 09:12 cli.ts
26
+ -rw-r--r-- 1 me me 8192 Aug 16 09:12 cli.ts
15
27
 
16
28
  $ fauxnix "cat nope.txt"
17
29
  cat: nope.txt: No such file or directory # not a PowerShell stack trace
18
30
  ```
19
31
 
32
+ ## Measured: your model is probably worse at PowerShell than you think
33
+
34
+ Same model (DeepSeek-V4-Pro), same 5 tasks, three execution modes on one Windows machine —
35
+ full data in [`docs/benchmark-deepseek-v4-pro.md`](docs/benchmark-deepseek-v4-pro.md) and
36
+ [`docs/benchmark-ark-models.md](docs/benchmark-ark-models.md):
37
+
38
+ | | PowerShell | **fauxnix** | Git Bash |
39
+ |---|---|---|---|
40
+ | tool calls / unexpected errors | 14 / 9 | **7 / 0** | 4 / 0 |
41
+ | time (T1–T4) | 163s | **66s** | 57s |
42
+
43
+ Across 7 models on the Volcano Ark Coding Plan, the PowerShell-vs-fauxnix gap held for every
44
+ model tested — worst case (kimi-k2-thinking): **3.1× slower with 24 error events** writing
45
+ PowerShell vs zero errors through fauxnix. fauxnix lands within ~15% of the real-bash ceiling
46
+ with no bash toolchain installed.
47
+
20
48
  ## Why
21
49
 
22
50
  LLM agents are dramatically better at bash than at PowerShell — bash dominates training data, so
@@ -165,8 +193,12 @@ fauxnix optimizes for the commands agents actually run. Documented deviations:
165
193
  word expansion precedes the temporary environment).
166
194
  - `yes` is capped at 65,536 lines — PS 5.1 pipelines cannot signal upstream producers to stop, so
167
195
  an unbounded `yes | head` would hang.
168
- - `tail -f`, `source`, `eval`, `alias`, heredocs, backticks, shell control flow (`if`/`for`/`while`)
196
+ - `tail -f`, `eval`, `alias`, heredocs, `while`/`until`/`case`
169
197
  and background `&` are rejected with actionable error messages instead of misbehaving.
198
+ (`if/then/else/fi`, `for x in ...`, backtick substitution, `command -v`, pipeline `read`,
199
+ and dotenv-style `source` are supported.)
200
+ - `command -v <builtin>` prints `/usr/bin/<name>` where bash prints the bare builtin name;
201
+ exit codes and empty-result semantics match.
170
202
  - `chmod` maps only the read-only bit; exec bits are no-ops on Windows. `chown` is a silent no-op
171
203
  (as in Git Bash).
172
204
  - `ps aux` columns are approximations (no per-process CPU% accounting, USER shows `?`).
package/dist/ast.d.ts CHANGED
@@ -7,12 +7,12 @@
7
7
  * - redirections: > >> 2> 2>> &> 2>&1 1>&2 <
8
8
  * - quoting: 'literal' "interp $VAR $(cmd)"
9
9
  * - variables: $VAR ${VAR} ${VAR[n]} plus special cases ($HOME $USER $PATH ...)
10
- * - command substitution: $(...) (recursively translated)
10
+ * - command substitution: $(...) and `...` (recursively translated)
11
11
  * - env assignment prefix: VAR=value cmd
12
12
  *
13
13
  * Explicitly unsupported (parser throws a helpful FauxnixError):
14
- * heredocs, backticks, subshells (...), background &, control flow
15
- * (if/for/while), globs inside quotes, process substitution <(...).
14
+ * heredocs, subshells (...), background &, while/until/case,
15
+ * globs inside quotes, process substitution <(...).
16
16
  */
17
17
  export interface CommandList {
18
18
  kind: 'CommandList';
@@ -24,9 +24,24 @@ export interface ListSegment {
24
24
  /** ';' for the first segment, otherwise the operator seen before this one. */
25
25
  op: ';' | '&&' | '||';
26
26
  }
27
+ export type ShellCommand = SimpleCommand | IfCommand | ForCommand;
27
28
  export interface Pipeline {
28
29
  kind: 'Pipeline';
29
- commands: SimpleCommand[];
30
+ commands: ShellCommand[];
31
+ }
32
+ export interface IfCommand {
33
+ kind: 'If';
34
+ test: CommandList;
35
+ then: CommandList;
36
+ else?: CommandList;
37
+ redirects: Redirect[];
38
+ }
39
+ export interface ForCommand {
40
+ kind: 'For';
41
+ name: string;
42
+ words: Word[];
43
+ body: CommandList;
44
+ redirects: Redirect[];
30
45
  }
31
46
  export interface SimpleCommand {
32
47
  kind: 'SimpleCommand';
@@ -63,6 +78,13 @@ export type WordPart = {
63
78
  kind: 'Var';
64
79
  name: string;
65
80
  index?: string;
81
+ /** `${name:-word}` / `${name:+word}` / `${name:?word}` (and non-colon). */
82
+ param?: {
83
+ op: ':-' | ':=' | ':+' | ':?' | '-' | '+' | '?';
84
+ word: string;
85
+ };
86
+ /** `${#name}` / `${#name[@]}` — string/array length expansion. */
87
+ length?: boolean;
66
88
  } | {
67
89
  kind: 'CmdSub';
68
90
  cmd: string;
package/dist/ast.js CHANGED
@@ -7,12 +7,12 @@
7
7
  * - redirections: > >> 2> 2>> &> 2>&1 1>&2 <
8
8
  * - quoting: 'literal' "interp $VAR $(cmd)"
9
9
  * - variables: $VAR ${VAR} ${VAR[n]} plus special cases ($HOME $USER $PATH ...)
10
- * - command substitution: $(...) (recursively translated)
10
+ * - command substitution: $(...) and `...` (recursively translated)
11
11
  * - env assignment prefix: VAR=value cmd
12
12
  *
13
13
  * Explicitly unsupported (parser throws a helpful FauxnixError):
14
- * heredocs, backticks, subshells (...), background &, control flow
15
- * (if/for/while), globs inside quotes, process substitution <(...).
14
+ * heredocs, subshells (...), background &, while/until/case,
15
+ * globs inside quotes, process substitution <(...).
16
16
  */
17
17
  export function wordToString(w) {
18
18
  return w.map(partToString).join('');
@@ -1,6 +1,6 @@
1
1
  import { FauxnixParseError, isUnquotedLiteral, wordToString } from '../ast.js';
2
2
  import { lookup, parseWords, psStr, registeredNames } from '../registry.js';
3
- import { argListExpr, exprOfWord, operandExpr, translateSimple, wrapTempEnv, encodeSetValExpr, escapeDq, translateCmdSub, normalizeLiteralPath, pathExpr, } from '../translator.js';
3
+ import { argListExpr, exprOfWord, operandExpr, translateSimple, wrapTempEnv, encodeSetValExpr, escapeDq, translateCmdSub, normalizeLiteralPath, pathExpr, paramExpr, varExpr, } from '../translator.js';
4
4
  import { handlers as textIoHandlers } from './text-io.js';
5
5
  /* ------------------------------------------------------------------ */
6
6
  /* Shared TS helpers */
@@ -545,6 +545,55 @@ const type = (args) => {
545
545
  '}',
546
546
  ].join('\n');
547
547
  };
548
+ /** `command -v name` (and bare `command name args` as a no-alias run). */
549
+ const commandCmd = (args, ctx) => {
550
+ let i = 0;
551
+ let identify = false;
552
+ while (i < args.length) {
553
+ const t = wordToString(args[i]);
554
+ if (t === '-v' || t === '-V') {
555
+ identify = true;
556
+ i++;
557
+ continue;
558
+ }
559
+ if (t === '--') {
560
+ i++;
561
+ break;
562
+ }
563
+ if (t.startsWith('-')) {
564
+ i++;
565
+ continue;
566
+ }
567
+ break;
568
+ }
569
+ const rest = args.slice(i);
570
+ if (identify) {
571
+ if (rest.length === 0)
572
+ return '';
573
+ return [
574
+ PS_WHICH_FN,
575
+ '$fx_b = @(' + builtinNames().map(psStr).join(', ') + ')',
576
+ '$fx_ns = ' + textArgs(rest),
577
+ 'foreach ($fx_n in $fx_ns) {',
578
+ " if ($fx_b -contains $fx_n) { '/usr/bin/' + $fx_n }",
579
+ ' else {',
580
+ ' $fx_w = fx-which $fx_n',
581
+ " if ($fx_w -eq '') { $script:fx_exit = 1 }",
582
+ " else { $fx_w.Replace('\\', '/') }",
583
+ ' }',
584
+ '}',
585
+ ].join('\n');
586
+ }
587
+ if (rest.length === 0)
588
+ return '';
589
+ return translateSimple({
590
+ kind: 'SimpleCommand',
591
+ assignments: [],
592
+ name: rest[0],
593
+ args: rest.slice(1),
594
+ redirects: [],
595
+ }, ctx.position, ctx.hasStdin);
596
+ };
548
597
  /* ------------------------------------------------------------------ */
549
598
  /* whoami / id / groups */
550
599
  /* ------------------------------------------------------------------ */
@@ -1344,6 +1393,12 @@ function kshExprOfWord(w) {
1344
1393
  if (tilde && expanded.length === 0)
1345
1394
  return '(fx-home)';
1346
1395
  if (!tilde && expanded.length === 1 && expanded[0].kind === 'Var') {
1396
+ if (expanded[0].param) {
1397
+ return paramExpr(expanded[0].name, expanded[0].param.op, expanded[0].param.word);
1398
+ }
1399
+ if (expanded[0].length) {
1400
+ return varExpr(expanded[0].name, expanded[0].index, undefined, true);
1401
+ }
1347
1402
  if (expanded[0].index !== undefined) {
1348
1403
  return '(fx-subget ' + psStr(expanded[0].name) + ' ' + psStr(expanded[0].index) + ')';
1349
1404
  }
@@ -1369,9 +1424,11 @@ function kshExprOfWord(w) {
1369
1424
  break;
1370
1425
  case 'Var':
1371
1426
  out +=
1372
- p.index !== undefined
1373
- ? '$(fx-subget ' + psStr(p.name) + ' ' + psStr(p.index) + ')'
1374
- : '$(fx-envget ' + psStr(p.name) + ')';
1427
+ p.param
1428
+ ? '$(' + paramExpr(p.name, p.param.op, p.param.word) + ')'
1429
+ : p.index !== undefined
1430
+ ? '$(fx-subget ' + psStr(p.name) + ' ' + psStr(p.index) + ')'
1431
+ : '$(fx-envget ' + psStr(p.name) + ')';
1375
1432
  break;
1376
1433
  case 'CmdSub':
1377
1434
  // [[ ]] does not IFS-split, so keep the newline contract.
@@ -2310,12 +2367,107 @@ const less = (args, ctx) => {
2310
2367
  return cat(files, ctx);
2311
2368
  };
2312
2369
  /* ------------------------------------------------------------------ */
2370
+ /* read */
2371
+ /* ------------------------------------------------------------------ */
2372
+ const readCmd = (args, ctx) => {
2373
+ const names = [];
2374
+ for (const w of args) {
2375
+ const t = wordToString(w);
2376
+ if (t === '-r' || t === '-a' || t.startsWith('-'))
2377
+ continue;
2378
+ if (!NAME_RE.test(t)) {
2379
+ return ('[Console]::Error.WriteLine(' +
2380
+ psStr('bash: read: `' + t + "': not a valid identifier") +
2381
+ '); $script:fx_exit = 2');
2382
+ }
2383
+ names.push(t);
2384
+ }
2385
+ if (names.length === 0)
2386
+ names.push('REPLY');
2387
+ const first = names[0];
2388
+ return [
2389
+ ctx.hasStdin
2390
+ ? "$fx_line = [string]((@($input) | Select-Object -First 1))"
2391
+ : "$fx_line = ''",
2392
+ ctx.hasStdin ? '' : '$script:fx_exit = 1',
2393
+ names.length === 1
2394
+ ? [
2395
+ "Set-Item -LiteralPath ('Env:\\' + " + psStr(first) + ') -Value $fx_line',
2396
+ " $n = " + psStr(first),
2397
+ " $env:FAUXNIX_SETVARS = ((@($env:FAUXNIX_SETVARS -split ';' | Where-Object { $_ -ne '' -and $_ -cne $n }) + $n) -join ';')",
2398
+ " $env:FAUXNIX_UNSETVARS = (@($env:FAUXNIX_UNSETVARS -split ';' | Where-Object { $_ -ne '' -and $_ -cne $n }) -join ';')",
2399
+ ' fx-arrdrop $n',
2400
+ ' $fx_sv = @(); foreach ($fx_pair in @($env:FAUXNIX_SETVALS -split [string][char]10)) { $fx_eq = $fx_pair.IndexOf([char]61); if ($fx_eq -lt 1) { continue }; if ($fx_pair.Substring(0, $fx_eq) -cne $n) { $fx_sv += $fx_pair } }',
2401
+ ' $fx_sv += ($n + [string][char]61 + (fx-svenc $fx_line)); $env:FAUXNIX_SETVALS = ($fx_sv -join [string][char]10)',
2402
+ ].join('\n')
2403
+ : [
2404
+ "$fx_fs = @($fx_line -split '\\s+', " + names.length + ')',
2405
+ 'foreach ($fx_i in 0..' + (names.length - 1) + ') {',
2406
+ ' $n = @(' + names.map(psStr).join(',') + ')[$fx_i]',
2407
+ " $fx_v = $(if ($fx_i -lt $fx_fs.Count) { [string]$fx_fs[$fx_i] } else { '' })",
2408
+ " Set-Item -LiteralPath ('Env:\\' + $n) -Value $fx_v",
2409
+ " $env:FAUXNIX_SETVARS = ((@($env:FAUXNIX_SETVARS -split ';' | Where-Object { $_ -ne '' -and $_ -cne $n }) + $n) -join ';')",
2410
+ " $env:FAUXNIX_UNSETVARS = (@($env:FAUXNIX_UNSETVARS -split ';' | Where-Object { $_ -ne '' -and $_ -cne $n }) -join ';')",
2411
+ ' fx-arrdrop $n',
2412
+ ' $fx_sv = @(); foreach ($fx_pair in @($env:FAUXNIX_SETVALS -split [string][char]10)) { $fx_eq = $fx_pair.IndexOf([char]61); if ($fx_eq -lt 1) { continue }; if ($fx_pair.Substring(0, $fx_eq) -cne $n) { $fx_sv += $fx_pair } }',
2413
+ ' $fx_sv += ($n + [string][char]61 + (fx-svenc $fx_v)); $env:FAUXNIX_SETVALS = ($fx_sv -join [string][char]10)',
2414
+ '}',
2415
+ ].join('\n'),
2416
+ ]
2417
+ .filter((l) => l !== '')
2418
+ .join('\n');
2419
+ };
2420
+ /* ------------------------------------------------------------------ */
2313
2421
  /* source / . / eval / exit / alias / set */
2314
2422
  /* ------------------------------------------------------------------ */
2315
- const source = () => {
2316
- return ('[Console]::Error.WriteLine(' +
2317
- psStr('fauxnix: source/. requires a persistent shell; fauxnix persists cwd and env across calls but not shell functions') +
2318
- '); $script:fx_exit = 1');
2423
+ const source = (args) => {
2424
+ const files = stripFlags(args);
2425
+ if (files.length === 0) {
2426
+ return ('[Console]::Error.WriteLine(' +
2427
+ psStr('bash: source: filename argument required') +
2428
+ '); $script:fx_exit = 2');
2429
+ }
2430
+ const q = (c) => '[char]' + c.charCodeAt(0);
2431
+ return [
2432
+ '$fx_files = ' + argListExpr(files, operandExpr),
2433
+ 'foreach ($fx_f in $fx_files) {',
2434
+ ' if (-not (Test-Path -LiteralPath $fx_f -PathType Leaf)) {',
2435
+ " [Console]::Error.WriteLine('bash: source: ' + $fx_f + ': No such file or directory'); $script:fx_exit = 1; continue",
2436
+ ' }',
2437
+ ' $fx_raw = [IO.File]::ReadAllText($fx_f) -replace ([string][char]13 + [string][char]10), [string][char]10',
2438
+ ' foreach ($fx_line in @($fx_raw -split [string][char]10)) {',
2439
+ ' $fx_t = $fx_line.Trim()',
2440
+ " if ($fx_t -eq '' -or $fx_t.StartsWith([string][char]35)) { continue }",
2441
+ ' if ($fx_t.StartsWith(' +
2442
+ psStr('export ') +
2443
+ ') -or $fx_t.StartsWith(' +
2444
+ psStr('export\t') +
2445
+ ')) { $fx_t = $fx_t.Substring(7).Trim() }',
2446
+ ' $fx_eq = $fx_t.IndexOf([char]61)',
2447
+ ' if ($fx_eq -lt 1) { [Console]::Error.WriteLine(' +
2448
+ psStr('fauxnix: source: only NAME=VALUE lines are supported') +
2449
+ '); $script:fx_exit = 1; continue }',
2450
+ ' $fx_n = $fx_t.Substring(0, $fx_eq)',
2451
+ " if ($fx_n -notmatch '^[A-Za-z_][A-Za-z0-9_]*$') { [Console]::Error.WriteLine('bash: source: ' + $fx_n + ': not a valid identifier'); $script:fx_exit = 1; continue }",
2452
+ ' $fx_v = $fx_t.Substring($fx_eq + 1)',
2453
+ ' if ($fx_v.Length -ge 2 -and (($fx_v[0] -eq ' +
2454
+ q('"') +
2455
+ ' -and $fx_v[$fx_v.Length-1] -eq ' +
2456
+ q('"') +
2457
+ ') -or ($fx_v[0] -eq ' +
2458
+ q("'") +
2459
+ ' -and $fx_v[$fx_v.Length-1] -eq ' +
2460
+ q("'") +
2461
+ '))) { $fx_v = $fx_v.Substring(1, $fx_v.Length - 2) }',
2462
+ " Set-Item -LiteralPath ('Env:\\' + $fx_n) -Value $fx_v",
2463
+ " $env:FAUXNIX_SETVARS = ((@($env:FAUXNIX_SETVARS -split ';' | Where-Object { $_ -ne '' -and $_ -cne $fx_n }) + $fx_n) -join ';')",
2464
+ " $env:FAUXNIX_UNSETVARS = (@($env:FAUXNIX_UNSETVARS -split ';' | Where-Object { $_ -ne '' -and $_ -cne $fx_n }) -join ';')",
2465
+ ' fx-arrdrop $fx_n',
2466
+ ' $fx_sv = @(); foreach ($fx_pair in @($env:FAUXNIX_SETVALS -split [string][char]10)) { $fx_eq2 = $fx_pair.IndexOf([char]61); if ($fx_eq2 -lt 1) { continue }; if ($fx_pair.Substring(0, $fx_eq2) -cne $fx_n) { $fx_sv += $fx_pair } }',
2467
+ ' $fx_sv += ($fx_n + [string][char]61 + (fx-svenc $fx_v)); $env:FAUXNIX_SETVALS = ($fx_sv -join [string][char]10)',
2468
+ ' }',
2469
+ '}',
2470
+ ].join('\n');
2319
2471
  };
2320
2472
  const evalCmd = () => {
2321
2473
  return ("[Console]::Error.WriteLine('fauxnix: eval is not supported; pass the command itself'); $script:fx_exit = 1");
@@ -2352,7 +2504,28 @@ const alias = (args) => {
2352
2504
  return '';
2353
2505
  return "[Console]::Error.WriteLine('fauxnix: alias is not supported'); $script:fx_exit = 1";
2354
2506
  };
2355
- const set = () => ''; // silently ignore (`set -e`, `set --` ... no-op)
2507
+ const set = (args) => {
2508
+ const raw = args.map(wordToString);
2509
+ const unsupported = raw.filter((t) => t === '-e' ||
2510
+ t === '-u' ||
2511
+ t === '-x' ||
2512
+ t === '-o' ||
2513
+ t === '+e' ||
2514
+ t === '+u' ||
2515
+ t === '+x' ||
2516
+ t.startsWith('-o') ||
2517
+ t.startsWith('+o') ||
2518
+ t === '-eu' ||
2519
+ t === '-ue' ||
2520
+ t === '-eux' ||
2521
+ /^-.*[eux]/.test(t));
2522
+ if (unsupported.length > 0) {
2523
+ return ('[Console]::Error.WriteLine(' +
2524
+ psStr('fauxnix: set -e/-u/-x is not supported (would silently lie); use explicit || exit') +
2525
+ '); $script:fx_exit = 2');
2526
+ }
2527
+ return '';
2528
+ };
2356
2529
  /* ------------------------------------------------------------------ */
2357
2530
  export const handlers = {
2358
2531
  cd,
@@ -2368,6 +2541,7 @@ export const handlers = {
2368
2541
  sleep,
2369
2542
  which,
2370
2543
  type,
2544
+ command: commandCmd,
2371
2545
  whoami,
2372
2546
  id,
2373
2547
  groups,
@@ -2393,6 +2567,7 @@ export const handlers = {
2393
2567
  history,
2394
2568
  less,
2395
2569
  more: less,
2570
+ read: readCmd,
2396
2571
  source,
2397
2572
  '.': source,
2398
2573
  eval: evalCmd,
package/dist/executor.js CHANGED
@@ -350,7 +350,15 @@ async function runPlans(plans, session, opts, afterSegment, scriptFile) {
350
350
  }, timeoutMs);
351
351
  const code = await new Promise((resolve) => {
352
352
  child.on('error', (e) => {
353
- stderr += 'fauxnix: failed to start powershell.exe: ' + e.message + '\n';
353
+ if (e.code === 'ENOENT') {
354
+ stderr +=
355
+ 'fauxnix: powershell.exe not found — fauxnix executes bash via native Windows PowerShell 5.1+.\n' +
356
+ 'This host has no PowerShell on PATH (typical for Linux containers/sandboxes).\n' +
357
+ 'Run fauxnix on Windows, or install PowerShell and make powershell.exe reachable on PATH.\n';
358
+ }
359
+ else {
360
+ stderr += 'fauxnix: failed to start powershell.exe: ' + e.message + '\n';
361
+ }
354
362
  resolve(127);
355
363
  });
356
364
  child.on('close', (c) => resolve(running.killed ? 124 : (c ?? 0)));
package/dist/mcp.js CHANGED
@@ -1,25 +1,50 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import { readFileSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
3
5
  import { z } from 'zod';
4
6
  import { FauxnixSession } from './executor.js';
5
7
  import { parseCommand } from './parser.js';
6
8
  import { translateCommandList, wrapScript, translatePipelineBody } from './translator.js';
7
9
  import { registeredNames } from './registry.js';
8
10
  import './commands/install-all.js';
11
+ // single source of truth: the npm package version in package.json
12
+ // (src/ and dist/ sit one level below the root, so the relative path holds in both)
13
+ const pkgVersion = JSON.parse(readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8')).version;
9
14
  const TOOL_NAME = process.env.FAUXNIX_TOOL_NAME || 'bash';
10
- const TOOL_DESCRIPTION = `Execute a Linux/bash-style command on this Windows machine.
11
-
12
- Commands are deterministically translated to PowerShell and executed natively — no WSL or VM.
13
- Output is formatted to look like GNU/Linux tooling (ls -l, ps aux, df -h ...), errors look like bash errors, and text encoding (UTF-8/GBK) is handled automatically.
14
-
15
- Supported: pipes (|), && / || / ;, redirections (> >> 2> 2>&1 < /dev/null), variables ($VAR $HOME ~), command substitution $(...), and ${registeredNames().length}+ coreutils-style commands (${registeredNames().slice(0, 18).join(', ')}...).
16
- Unknown commands (git, node, npm, python, cargo...) are passed through and executed natively with argv-style quoting.
17
- Not supported: heredocs, backticks, control flow (if/for/while), background jobs.
18
-
19
- CWD, environment variables, export/unset and cd persist across calls within this session — but prefer COMBINING related commands in one call with ; or && (e.g. 'cd src && ls | wc -l'); each call is a fresh translation+process, so batching is faster than many tiny calls.
20
- Exit codes follow bash conventions (0 ok, 1 fail, 2 usage/serious, 127 command not found, 124 timeout).`;
15
+ const EXEC_ANNOTATIONS = {
16
+ readOnlyHint: false,
17
+ destructiveHint: true,
18
+ idempotentHint: false,
19
+ openWorldHint: true,
20
+ };
21
+ const TRANSLATE_ANNOTATIONS = {
22
+ readOnlyHint: true,
23
+ destructiveHint: false,
24
+ idempotentHint: true,
25
+ openWorldHint: false,
26
+ };
27
+ const SESSION_ANNOTATIONS = {
28
+ readOnlyHint: false,
29
+ destructiveHint: false,
30
+ idempotentHint: true,
31
+ openWorldHint: false,
32
+ };
33
+ const TOOL_DESCRIPTION = `Execute a Linux/bash-style command on this Windows machine.
34
+
35
+ Commands are deterministically translated to PowerShell and executed natively — no WSL or VM.
36
+ Output is formatted to look like GNU/Linux tooling (ls -l, ps aux, df -h ...), errors look like bash errors, and text encoding (UTF-8/GBK) is handled automatically.
37
+
38
+ Supported: pipes (|), && / || / ;, redirections (> >> 2> 2>&1 < /dev/null), variables ($VAR $HOME ~), command substitution $(...), and ${registeredNames().length}+ coreutils-style commands (${registeredNames().slice(0, 18).join(', ')}...).
39
+ Unknown commands (git, node, npm, python, cargo...) are passed through and executed natively with argv-style quoting.
40
+ Not supported: heredocs, while/until/case, background jobs. if/then/else/fi and for-in loops are supported.
41
+
42
+ CWD, environment variables, export/unset and cd persist across calls within this session — but prefer COMBINING related commands in one call with ; or && (e.g. 'cd src && ls | wc -l'); each call is a fresh translation+process, so batching is faster than many tiny calls.
43
+ Exit codes follow bash conventions (0 ok, 1 fail, 2 usage/serious, 127 command not found, 124 timeout).
44
+
45
+ Platform requirement: the execution backend is native Windows PowerShell 5.1+. On hosts without PowerShell on PATH (e.g. Linux containers/sandboxes), the bash tool returns exit code 127 with an actionable error instead of running the command.`;
21
46
  export async function startMcpServer() {
22
- const server = new McpServer({ name: 'fauxnix', version: '0.4.0' }, { capabilities: { tools: {} } });
47
+ const server = new McpServer({ name: 'fauxnix', version: pkgVersion }, { capabilities: { tools: {} } });
23
48
  const session = new FauxnixSession();
24
49
  server.tool(TOOL_NAME, TOOL_DESCRIPTION, {
25
50
  command: z.string().describe('The bash-style command line to run'),
@@ -30,7 +55,7 @@ export async function startMcpServer() {
30
55
  .max(600_000)
31
56
  .optional()
32
57
  .describe('Timeout in milliseconds (default 120000)'),
33
- }, async ({ command, timeout_ms }) => {
58
+ }, EXEC_ANNOTATIONS, async ({ command, timeout_ms }) => {
34
59
  try {
35
60
  const plans = translateCommandList(parseCommand(command));
36
61
  const result = await session.run(plans, { timeoutMs: timeout_ms });
@@ -49,7 +74,7 @@ export async function startMcpServer() {
49
74
  return { content: [{ type: 'text', text: msg }], isError: true };
50
75
  }
51
76
  });
52
- 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() }, async ({ command }) => {
77
+ 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 }) => {
53
78
  try {
54
79
  const list = parseCommand(command);
55
80
  const plans = translateCommandList(list);
@@ -61,7 +86,12 @@ export async function startMcpServer() {
61
86
  return { content: [{ type: 'text', text: msg }], isError: true };
62
87
  }
63
88
  });
64
- server.tool('fauxnix_session', 'Inspect or reset the persistent fauxnix shell session (current directory, environment, session id). Actions: "status" (default) or "reset".', { action: z.enum(['status', 'reset']).default('status') }, async ({ action }) => {
89
+ server.tool('fauxnix_session', 'Inspect or reset the persistent fauxnix shell session (current directory, environment, session id). Actions: "status" (default) or "reset".', {
90
+ action: z
91
+ .enum(['status', 'reset'])
92
+ .default('status')
93
+ .describe('"status" shows the session state (cwd, tracked env keys); "reset" clears it back to a fresh shell'),
94
+ }, SESSION_ANNOTATIONS, async ({ action }) => {
65
95
  if (action === 'reset') {
66
96
  await session.dispose();
67
97
  return { content: [{ type: 'text', text: 'fauxnix: session reset' }] };
package/dist/parser.js CHANGED
@@ -127,6 +127,16 @@ export function tokenize(input) {
127
127
  continue;
128
128
  }
129
129
  }
130
+ if (c === '`') {
131
+ const v = readBacktick(input, i);
132
+ if (buf) {
133
+ parts.push({ kind: 'Text', text: buf });
134
+ buf = '';
135
+ }
136
+ parts.push(v.part);
137
+ i = v.next;
138
+ continue;
139
+ }
130
140
  buf += c;
131
141
  i++;
132
142
  }
@@ -153,7 +163,11 @@ export function tokenize(input) {
153
163
  continue;
154
164
  }
155
165
  if (ch === '`') {
156
- throw new FauxnixParseError('fauxnix: backticks are not supported. Use $(...) command substitution instead.');
166
+ const v = readBacktick(input, i);
167
+ beginWordPart();
168
+ cur.push(v.part);
169
+ i = v.next;
170
+ continue;
157
171
  }
158
172
  // escape outside quotes — keep the escape so [[ =~ ]] / == can
159
173
  // treat `\*` as a literal rather than a metacharacter
@@ -186,6 +200,23 @@ function isNameStart(c) {
186
200
  function isNameChar(c) {
187
201
  return /[A-Za-z0-9_]/.test(c);
188
202
  }
203
+ /** Parse `cmd` as command substitution (same AST as $(cmd)). */
204
+ function readBacktick(input, i) {
205
+ if (input[i] !== '`')
206
+ throw new FauxnixParseError('fauxnix: expected backtick');
207
+ let k = i + 1;
208
+ while (k < input.length) {
209
+ if (input[k] === '\\' && k + 1 < input.length) {
210
+ k += 2;
211
+ continue;
212
+ }
213
+ if (input[k] === '`') {
214
+ return { part: { kind: 'CmdSub', cmd: input.slice(i + 1, k) }, next: k + 1 };
215
+ }
216
+ k++;
217
+ }
218
+ throw new FauxnixParseError('fauxnix: unclosed backtick');
219
+ }
189
220
  /** Parse $VAR, ${VAR}, $(cmd substitution). Returns null when not a valid dollar construct. */
190
221
  function readDollar(input, i) {
191
222
  const n = input.length;
@@ -204,8 +235,23 @@ function readDollar(input, i) {
204
235
  if (sub) {
205
236
  return { part: { kind: 'Var', name: sub[1], index: sub[2] }, next: end + 1 };
206
237
  }
238
+ const pm = name.match(/^([A-Za-z_][A-Za-z0-9_]*)(:?[-+?])(.*)$/);
239
+ if (pm) {
240
+ const op = pm[2];
241
+ return {
242
+ part: { kind: 'Var', name: pm[1], param: { op, word: pm[3] } },
243
+ next: end + 1,
244
+ };
245
+ }
246
+ const hash = name.match(/^#([A-Za-z_][A-Za-z0-9_]*)(\[([0-9]+|@|\*)\])?$/);
247
+ if (hash) {
248
+ return {
249
+ part: { kind: 'Var', name: hash[1], index: hash[3], length: true },
250
+ next: end + 1,
251
+ };
252
+ }
207
253
  if (!name || !isNameStart(name[0]) || !name.split('').every(isNameChar)) {
208
- // ${VAR:-default} etc. unsupported, kept as raw text
254
+ // ${VAR:=default} etc. still raw text
209
255
  return { part: { kind: 'Text', text: input.slice(i, end + 1) }, next: end + 1 };
210
256
  }
211
257
  return { part: { kind: 'Var', name }, next: end + 1 };
@@ -509,7 +555,22 @@ export function parseCommand(input) {
509
555
  const parsePipeline = () => {
510
556
  const commands = [];
511
557
  for (;;) {
512
- commands.push(parseSimple());
558
+ const kw = peekKw();
559
+ if (kw === 'if') {
560
+ if (commands.length > 0) {
561
+ throw new FauxnixParseError('fauxnix: if in a pipeline is not supported');
562
+ }
563
+ commands.push(parseIf());
564
+ }
565
+ else if (kw === 'for') {
566
+ if (commands.length > 0) {
567
+ throw new FauxnixParseError('fauxnix: for in a pipeline is not supported');
568
+ }
569
+ commands.push(parseFor());
570
+ }
571
+ else {
572
+ commands.push(parseSimple());
573
+ }
513
574
  const t = peek();
514
575
  if (t.type === 'OP' && t.op === '|') {
515
576
  next();
@@ -519,6 +580,108 @@ export function parseCommand(input) {
519
580
  }
520
581
  return { kind: 'Pipeline', commands };
521
582
  };
583
+ const peekKw = () => {
584
+ const t = peek();
585
+ if (t.type !== 'WORD' || !t.parts)
586
+ return null;
587
+ const s = wordToString(t.parts);
588
+ if (!isUnquotedLiteral(t.parts, s))
589
+ return null;
590
+ if (s === 'if' ||
591
+ s === 'then' ||
592
+ s === 'else' ||
593
+ s === 'elif' ||
594
+ s === 'fi' ||
595
+ s === 'for' ||
596
+ s === 'in' ||
597
+ s === 'do' ||
598
+ s === 'done' ||
599
+ s === 'while') {
600
+ return s;
601
+ }
602
+ return null;
603
+ };
604
+ const expectKw = (k) => {
605
+ if (peekKw() !== k) {
606
+ throw new FauxnixParseError('fauxnix: expected `' + k + "'");
607
+ }
608
+ next();
609
+ };
610
+ const parseListUntil = (stops) => {
611
+ const stop = new Set(stops);
612
+ const segments = [];
613
+ let op = ';';
614
+ const isListSep = (o) => o === ';' || o === '\n';
615
+ while (peek().type === 'OP' && isListSep(peek().op))
616
+ next();
617
+ while (peek().type !== 'EOF') {
618
+ const kw = peekKw();
619
+ if (kw && stop.has(kw))
620
+ break;
621
+ const pipeline = parsePipeline();
622
+ segments.push({ pipeline, op });
623
+ const t = peek();
624
+ if (t.type === 'OP' && (t.op === '&&' || t.op === '||' || isListSep(t.op))) {
625
+ op = t.op === '\n' ? ';' : t.op;
626
+ next();
627
+ while (peek().type === 'OP' && isListSep(peek().op))
628
+ next();
629
+ }
630
+ else {
631
+ break;
632
+ }
633
+ }
634
+ if (segments.length === 0) {
635
+ throw new FauxnixParseError('fauxnix: empty command');
636
+ }
637
+ return { kind: 'CommandList', segments };
638
+ };
639
+ const parseIf = () => {
640
+ expectKw('if');
641
+ const test = parseListUntil(['then']);
642
+ expectKw('then');
643
+ const thenL = parseListUntil(['else', 'elif', 'fi']);
644
+ let elseL;
645
+ if (peekKw() === 'elif') {
646
+ throw new FauxnixParseError('fauxnix: elif is not supported yet; use else + if');
647
+ }
648
+ if (peekKw() === 'else') {
649
+ next();
650
+ elseL = parseListUntil(['fi']);
651
+ }
652
+ expectKw('fi');
653
+ return { kind: 'If', test, then: thenL, else: elseL, redirects: [] };
654
+ };
655
+ const parseFor = () => {
656
+ expectKw('for');
657
+ const nt = peek();
658
+ if (nt.type !== 'WORD' || !nt.parts) {
659
+ throw new FauxnixParseError('fauxnix: `for` expected a name');
660
+ }
661
+ const name = wordToString(nt.parts);
662
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || !isUnquotedLiteral(nt.parts, name)) {
663
+ throw new FauxnixParseError('fauxnix: `for` name must be an identifier');
664
+ }
665
+ next();
666
+ expectKw('in');
667
+ const words = [];
668
+ for (;;) {
669
+ while (peek().type === 'OP' && (peek().op === ';' || peek().op === '\n'))
670
+ next();
671
+ if (peekKw() === 'do')
672
+ break;
673
+ const t = peek();
674
+ if (t.type !== 'WORD' || !t.parts) {
675
+ throw new FauxnixParseError("fauxnix: `for` expected `do`");
676
+ }
677
+ words.push(t.parts);
678
+ next();
679
+ }
680
+ expectKw('do');
681
+ const body = parseListUntil(['done']);
682
+ expectKw('done');
683
+ return { kind: 'For', name, words, body, redirects: [] };
684
+ };
522
685
  const parseSimple = () => {
523
686
  const assignments = [];
524
687
  let redirects = [];
@@ -1,7 +1,12 @@
1
- import { Assignment, CommandList, Redirect, SimpleCommand, Word } from './ast.js';
1
+ import { Assignment, CommandList, Redirect, SimpleCommand, IfCommand, ForCommand, Word } from './ast.js';
2
2
  import { PipelineCtx } from './registry.js';
3
+ /** `${name:-word}` and friends using case-exact fx-scalar0. */
4
+ export declare function paramExpr(name: string, op: ':-' | ':=' | ':+' | ':?' | '-' | '+' | '?', word: string): string;
3
5
  /** Map a bash $VAR name to a PowerShell expression (usable inside $(...)). */
4
- export declare function varExpr(name: string, index?: string): string;
6
+ export declare function varExpr(name: string, index?: string, param?: {
7
+ op: ':-' | ':=' | ':+' | ':?' | '-' | '+' | '?';
8
+ word: string;
9
+ }, length?: boolean): string;
5
10
  /** Escape text destined for the inside of a PS double-quoted string. */
6
11
  export declare function escapeDq(s: string): string;
7
12
  /** Normalize a literal POSIX-ish path to its Windows equivalent. */
@@ -66,13 +71,8 @@ export interface PipelineParts {
66
71
  /** The pipeline invocation itself. */
67
72
  call: string;
68
73
  }
69
- /**
70
- * Pipeline body. A lone command runs as a plain script-block expression;
71
- * multi-command pipelines become generated functions chained with `|`
72
- * (PS 5.1 forbids parenthesized expressions as non-first pipeline elements).
73
- */
74
74
  export declare function translatePipelineBody(p: {
75
- commands: SimpleCommand[];
75
+ commands: Array<SimpleCommand | IfCommand | ForCommand>;
76
76
  }): PipelineParts;
77
77
  export interface SegmentPlan {
78
78
  op: ';' | '&&' | '||';
@@ -4,8 +4,55 @@ import { lookup, psStr } from './registry.js';
4
4
  /* ------------------------------------------------------------------ */
5
5
  /* Variable mapping */
6
6
  /* ------------------------------------------------------------------ */
7
+ function paramWordExpr(word) {
8
+ if (word.startsWith('$') && /^\$[A-Za-z_][A-Za-z0-9_]*$/.test(word)) {
9
+ return varExpr(word.slice(1));
10
+ }
11
+ return psStr(word);
12
+ }
13
+ /** `${name:-word}` and friends using case-exact fx-scalar0. */
14
+ export function paramExpr(name, op, word) {
15
+ const alt = paramWordExpr(word);
16
+ const get = '(fx-scalar0 ' + psStr(name) + ')';
17
+ const empty = '($null -eq $fx_pv -or [string]$fx_pv -eq \'\')';
18
+ const unset = '($null -eq $fx_pv)';
19
+ if (op === ':-') {
20
+ return '$( $fx_pv = ' + get + '; if (' + empty + ') { ' + alt + ' } else { $fx_pv } )';
21
+ }
22
+ if (op === '-') {
23
+ return '$( $fx_pv = ' + get + '; if (' + unset + ') { ' + alt + ' } else { $fx_pv } )';
24
+ }
25
+ if (op === ':+') {
26
+ return '$( $fx_pv = ' + get + '; if (' + empty + ') { \'\' } else { ' + alt + ' } )';
27
+ }
28
+ if (op === '+') {
29
+ return '$( $fx_pv = ' + get + '; if (' + unset + ') { \'\' } else { ' + alt + ' } )';
30
+ }
31
+ const msg = word === '' ? name + ': parameter null or not set' : name + ': ' + word;
32
+ const cond = op === ':?' ? empty : unset;
33
+ return ('$( $fx_pv = ' +
34
+ get +
35
+ '; if (' +
36
+ cond +
37
+ ') { [Console]::Error.WriteLine(' +
38
+ psStr('bash: ' + msg) +
39
+ '); $script:fx_exit = 1; \'\' } else { $fx_pv } )');
40
+ }
7
41
  /** Map a bash $VAR name to a PowerShell expression (usable inside $(...)). */
8
- export function varExpr(name, index) {
42
+ export function varExpr(name, index, param, length = false) {
43
+ if (param)
44
+ return paramExpr(name, param.op, param.word);
45
+ if (length) {
46
+ if (index === '@' || index === '*') {
47
+ return '@(fx-arrload ' + psStr(name) + ').Count';
48
+ }
49
+ if (index !== undefined) {
50
+ return '([string](fx-subget ' + psStr(name) + ' ' + psStr(index) + ')).Length';
51
+ }
52
+ return ('([string]$(if ($null -eq ($fx_pv = fx-scalar0 ' +
53
+ psStr(name) +
54
+ ')) { \'\' } else { $fx_pv })).Length');
55
+ }
9
56
  // Indexed reads always go through fx-subget → fx-arrload → fx-scalar0 so
10
57
  // ${PWD[0]} keeps the special mapping and ${bash_rematch[0]} stays
11
58
  // case-exact (a `$env:name` fallback would alias BASH_REMATCH on Windows).
@@ -106,7 +153,8 @@ export function exprOfWord(w, opts) {
106
153
  }
107
154
  // single bare variable → bare expression
108
155
  if (expanded.length === 1 && expanded[0].kind === 'Var') {
109
- return varExpr(expanded[0].name, expanded[0].index);
156
+ const v = expanded[0];
157
+ return varExpr(v.name, v.index, v.param, v.length === true);
110
158
  }
111
159
  // Bare `$(...)` must not sit inside a PS expandable string: the
112
160
  // substitution body contains `"` / `$_` that would break interpolation.
@@ -133,7 +181,7 @@ export function exprOfWord(w, opts) {
133
181
  emitPart(q, true);
134
182
  break;
135
183
  case 'Var':
136
- out += '$(' + varExpr(p.name, p.index) + ')';
184
+ out += '$(' + varExpr(p.name, p.index, p.param, p.length === true) + ')';
137
185
  break;
138
186
  case 'CmdSub':
139
187
  out += '$(' + translateCmdSub(p.cmd, quoted || opts?.preserveCmdSub === true) + ')';
@@ -189,7 +237,9 @@ export function splatSpec(w) {
189
237
  let suffix = '';
190
238
  let seen = false;
191
239
  for (const { part: p, quoted } of parts) {
192
- const splat = p.kind === 'Var' && (p.index === '@' || (p.index === '*' && !quoted));
240
+ const splat = p.kind === 'Var' &&
241
+ !p.length &&
242
+ (p.index === '@' || (p.index === '*' && !quoted));
193
243
  if (splat) {
194
244
  if (seen)
195
245
  return null;
@@ -258,6 +308,12 @@ export function translateCmdSub(cmdText, keepNl = false) {
258
308
  /* ------------------------------------------------------------------ */
259
309
  /* Simple command translation */
260
310
  /* ------------------------------------------------------------------ */
311
+ function indentBlock(s) {
312
+ return s
313
+ .split('\n')
314
+ .map((l) => (l ? ' ' + l : l))
315
+ .join('\n');
316
+ }
261
317
  export function translateSimple(cmd, position, hasStdin) {
262
318
  // assignment-only segment (`X=1; cmd`): bash semantics are "set for the
263
319
  // rest of the shell". Reuse the export code path — persist + env shadow —
@@ -276,20 +332,35 @@ export function translateSimple(cmd, position, hasStdin) {
276
332
  let body;
277
333
  if (nameSplat) {
278
334
  const invoke = '& $fx_cmd @fx_na';
279
- body = [
335
+ const hasAffix = !!(nameSplat.prefix || nameSplat.suffix);
336
+ const promoted = cmd.args.length === 0
337
+ ? ''
338
+ : translateSimple({
339
+ kind: 'SimpleCommand',
340
+ assignments: [],
341
+ name: cmd.args[0],
342
+ args: cmd.args.slice(1),
343
+ redirects: cmd.redirects,
344
+ }, position, hasStdin);
345
+ const emptyCmdLines = [
280
346
  '$fx_cw = @(fx-arrload ' + psStr(nameSplat.name) + ')',
281
- 'if ($fx_cw.Count -eq 0) { $fx_cw = @(' + psStr(nameSplat.prefix + nameSplat.suffix) + ') }',
282
- 'else { $fx_cw[0] = ' +
347
+ ];
348
+ if (hasAffix) {
349
+ emptyCmdLines.push('if ($fx_cw.Count -eq 0) { $fx_cw = @(' +
350
+ psStr(nameSplat.prefix + nameSplat.suffix) +
351
+ ') } else { $fx_cw[0] = ' +
283
352
  psStr(nameSplat.prefix) +
284
353
  ' + $fx_cw[0]; $fx_cw[$fx_cw.Count-1] = $fx_cw[$fx_cw.Count-1] + ' +
285
354
  psStr(nameSplat.suffix) +
286
- ' }',
287
- '$fx_cmd = [string]$fx_cw[0]',
288
- '$fx_na = ' + argListExpr(cmd.args),
289
- 'if ($fx_cw.Count -gt 1) { $fx_na = @($fx_cw[1..($fx_cw.Count - 1)]) + $fx_na }',
290
- (hasStdin ? '($input | ' + invoke + ')' : invoke) + ' | ForEach-Object { [string]$_ }',
291
- 'if ($LASTEXITCODE -gt 0) { $script:fx_exit = $LASTEXITCODE } elseif ($LASTEXITCODE -lt 0) { $script:fx_exit = 1 }',
292
- ].join('\n');
355
+ ' }');
356
+ }
357
+ emptyCmdLines.push('if ($fx_cw.Count -eq 0) {',
358
+ // No words left bash null command (exit 0). Remaining words are
359
+ // known at compile time, so reuse translateSimple (handlers, not `&`).
360
+ promoted ? indentBlock(promoted) : ' ', '} else {', ' $fx_na = [object[]]@(' + argListExpr(cmd.args) + ')', ' $fx_cmd = [string]$fx_cw[0]', ' if ($fx_cw.Count -gt 1) { $fx_na = @($fx_cw[1..($fx_cw.Count - 1)]) + $fx_na }', ' ' +
361
+ (hasStdin ? '($input | ' + invoke + ')' : invoke) +
362
+ ' | ForEach-Object { [string]$_ }', ' if ($LASTEXITCODE -gt 0) { $script:fx_exit = $LASTEXITCODE } elseif ($LASTEXITCODE -lt 0) { $script:fx_exit = 1 }', '}');
363
+ body = emptyCmdLines.join('\n');
293
364
  }
294
365
  else if (nameLit !== null) {
295
366
  const handler = lookup(nameLit);
@@ -591,12 +662,77 @@ let stageSeq = 0;
591
662
  * multi-command pipelines become generated functions chained with `|`
592
663
  * (PS 5.1 forbids parenthesized expressions as non-first pipeline elements).
593
664
  */
665
+ function translateListInline(list) {
666
+ const chunks = [];
667
+ for (const seg of list.segments) {
668
+ const { defs, call } = translatePipelineBody(seg.pipeline);
669
+ const body = (defs ? defs + '\n' : '') + call;
670
+ if (seg.op === '&&') {
671
+ chunks.push('if ($script:fx_exit -eq 0) {\n' + body + '\n}');
672
+ }
673
+ else if (seg.op === '||') {
674
+ chunks.push('if ($script:fx_exit -ne 0) {\n' + body + '\n}');
675
+ }
676
+ else {
677
+ chunks.push(body);
678
+ }
679
+ }
680
+ return chunks.join('\n');
681
+ }
682
+ function translateIf(cmd) {
683
+ // Branch bodies reset fx_exit first: the compound's exit status must come
684
+ // from the taken branch's last command (bash semantics), not leak the test's
685
+ // failure — `if false; then A; else B; fi` exits 0 in bash.
686
+ const lines = [translateListInline(cmd.test), 'if ($script:fx_exit -eq 0) {', ' $script:fx_exit = 0'];
687
+ for (const l of translateListInline(cmd.then).split('\n'))
688
+ lines.push(l ? ' ' + l : l);
689
+ lines.push('} else {', ' $script:fx_exit = 0');
690
+ if (cmd.else) {
691
+ for (const l of translateListInline(cmd.else).split('\n'))
692
+ lines.push(l ? ' ' + l : l);
693
+ }
694
+ lines.push('}');
695
+ return lines.join('\n');
696
+ }
697
+ function translateFor(cmd) {
698
+ const n = cmd.name.replace(/'/g, "''");
699
+ const lines = [
700
+ '$fx_for = ' + argListExpr(cmd.words),
701
+ 'foreach ($fx_it in @($fx_for)) {',
702
+ " Set-Item -LiteralPath ('Env:\\' + '" + n + "') -Value ([string]$fx_it)",
703
+ " $env:FAUXNIX_SETVARS = ((@($env:FAUXNIX_SETVARS -split ';' | Where-Object { $_ -ne '' -and $_ -cne '" +
704
+ n +
705
+ "' }) + '" +
706
+ n +
707
+ "') -join ';')",
708
+ " $env:FAUXNIX_UNSETVARS = (@($env:FAUXNIX_UNSETVARS -split ';' | Where-Object { $_ -ne '' -and $_ -cne '" +
709
+ n +
710
+ "' }) -join ';')",
711
+ ' fx-arrdrop ' + psStr(cmd.name),
712
+ " $fx_sv = @(); foreach ($fx_pair in @($env:FAUXNIX_SETVALS -split [string][char]10)) { $fx_eq = $fx_pair.IndexOf([char]61); if ($fx_eq -lt 1) { continue }; if ($fx_pair.Substring(0, $fx_eq) -cne '" +
713
+ n +
714
+ "') { $fx_sv += $fx_pair } }",
715
+ " $fx_sv += ('" +
716
+ n +
717
+ "' + [string][char]61 + (fx-svenc ([string]$fx_it))); $env:FAUXNIX_SETVALS = ($fx_sv -join [string][char]10)",
718
+ ];
719
+ for (const l of translateListInline(cmd.body).split('\n'))
720
+ lines.push(l ? ' ' + l : l);
721
+ lines.push('}');
722
+ return lines.join('\n');
723
+ }
594
724
  export function translatePipelineBody(p) {
595
725
  const bodies = [];
596
726
  for (let i = 0; i < p.commands.length; i++) {
597
- const hasStdin = i > 0 || p.commands[i].redirects.some((r) => r.op === '<');
727
+ const c = p.commands[i];
728
+ const hasStdin = i > 0 || c.redirects.some((r) => r.op === '<');
598
729
  const position = i === 0 ? 'first' : i === p.commands.length - 1 ? 'last' : 'middle';
599
- bodies.push(translateSimple(p.commands[i], position, hasStdin));
730
+ if (c.kind === 'If')
731
+ bodies.push(translateIf(c));
732
+ else if (c.kind === 'For')
733
+ bodies.push(translateFor(c));
734
+ else
735
+ bodies.push(translateSimple(c, position, hasStdin));
600
736
  }
601
737
  if (bodies.length === 1) {
602
738
  return { defs: '', call: '(& {\n' + bodies[0] + '\n})' };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fauxnix-cli",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Fauxnix — run Linux-style commands on Windows via deterministic PowerShell translation. No VM, no WSL. MCP server + CLI for AI agents.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -53,6 +53,6 @@
53
53
  "@types/node": "^20.14.0",
54
54
  "tsx": "^4.19.0",
55
55
  "typescript": "^5.5.0",
56
- "vitest": "^2.1.0"
56
+ "vitest": "3.2.6"
57
57
  }
58
58
  }