fauxnix-cli 0.3.0 → 0.4.2

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
package/dist/ast.d.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * - lists: cmd1 ; cmd2 && cmd3 || cmd4 (newlines act as ';')
7
7
  * - redirections: > >> 2> 2>> &> 2>&1 1>&2 <
8
8
  * - quoting: 'literal' "interp $VAR $(cmd)"
9
- * - variables: $VAR ${VAR} plus special cases ($HOME $USER $PATH ...)
9
+ * - variables: $VAR ${VAR} ${VAR[n]} plus special cases ($HOME $USER $PATH ...)
10
10
  * - command substitution: $(...) (recursively translated)
11
11
  * - env assignment prefix: VAR=value cmd
12
12
  *
@@ -62,6 +62,7 @@ export type WordPart = {
62
62
  } | {
63
63
  kind: 'Var';
64
64
  name: string;
65
+ index?: string;
65
66
  } | {
66
67
  kind: 'CmdSub';
67
68
  cmd: string;
package/dist/ast.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * - lists: cmd1 ; cmd2 && cmd3 || cmd4 (newlines act as ';')
7
7
  * - redirections: > >> 2> 2>> &> 2>&1 1>&2 <
8
8
  * - quoting: 'literal' "interp $VAR $(cmd)"
9
- * - variables: $VAR ${VAR} plus special cases ($HOME $USER $PATH ...)
9
+ * - variables: $VAR ${VAR} ${VAR[n]} plus special cases ($HOME $USER $PATH ...)
10
10
  * - command substitution: $(...) (recursively translated)
11
11
  * - env assignment prefix: VAR=value cmd
12
12
  *
@@ -36,7 +36,7 @@ function partToString(p) {
36
36
  case 'DoubleQuoted':
37
37
  return p.parts.map(partToString).join('');
38
38
  case 'Var':
39
- return `$${p.name}`;
39
+ return p.index !== undefined ? `\${${p.name}[${p.index}]}` : `$${p.name}`;
40
40
  case 'CmdSub':
41
41
  return '$(' + p.cmd + ')';
42
42
  }
package/dist/cli.js CHANGED
@@ -26,7 +26,7 @@ export async function runCli(argv) {
26
26
  }
27
27
  const [verb, ...rest] = argv;
28
28
  if (verb === '--version' || verb === '-v') {
29
- console.log('fauxnix 0.3.0');
29
+ console.log('fauxnix 0.4.0');
30
30
  return;
31
31
  }
32
32
  if (verb === 'list') {
@@ -1,6 +1,6 @@
1
1
  import { wordToString } from '../ast.js';
2
2
  import { psStr } from '../registry.js';
3
- import { exprOfWord, operandExpr } from '../translator.js';
3
+ import { argListExpr, exprOfWord, operandExpr } from '../translator.js';
4
4
  /* ------------------------------------------------------------------ */
5
5
  /* gzip family — .NET GZipStream helpers */
6
6
  /* ------------------------------------------------------------------ */
@@ -188,7 +188,7 @@ function gzBlock(args, ctx, forced) {
188
188
  fileLoop.push(' }');
189
189
  return [
190
190
  PS_GZ_FNS,
191
- '$fx_files = @(' + (p.files.length ? p.files.map(operandExpr).join(', ') : '') + ')',
191
+ '$fx_files = ' + (p.files.length ? argListExpr(p.files, operandExpr) : '@()'),
192
192
  'if ($fx_files.Count -eq 0) {',
193
193
  stdinBranch,
194
194
  '} else {',
@@ -204,7 +204,7 @@ const zcat = (args, ctx) => gzBlock(args, ctx, { decompress: true, stdout: true
204
204
  /* ------------------------------------------------------------------ */
205
205
  const tar = (args) => {
206
206
  return [
207
- "$fx_args = @(" + args.map(exprOfWord).join(', ') + ')',
207
+ '$fx_args = ' + argListExpr(args, exprOfWord),
208
208
  // Prefer the Windows-shipped bsdtar (System32): it accepts both path
209
209
  // styles. A PATH lookup could resolve to Git Bash's GNU tar, which
210
210
  // misreads `C:\...` argv as a remote-host spec (host:path syntax).
@@ -255,10 +255,10 @@ const zip = (args) => {
255
255
  "[Console]::Error.WriteLine('zip error: Nothing to do! (fauxnix: usage: zip [-r] ARCHIVE FILES...)'); $script:fx_exit = 12");
256
256
  }
257
257
  const arc = operandExpr(rest[0]);
258
- const inputs = rest.slice(1).map(operandExpr).join(', ');
258
+ const inputs = argListExpr(rest.slice(1), operandExpr);
259
259
  return [
260
260
  note + '$fx_arc = ' + arc,
261
- '$fx_inputs = @(' + inputs + ')',
261
+ '$fx_inputs = ' + inputs,
262
262
  '$fx_valid = @()',
263
263
  'foreach ($fx_p in $fx_inputs) {',
264
264
  ' if (Test-Path -LiteralPath $fx_p) { $fx_valid += $fx_p }',
@@ -1,6 +1,6 @@
1
1
  import { wordToString } from '../ast.js';
2
2
  import { parseWords, psStr } from '../registry.js';
3
- import { exprOfWord, operandExpr } from '../translator.js';
3
+ import { argListExpr, exprOfWord, operandExpr } from '../translator.js';
4
4
  /* ------------------------------------------------------------------ */
5
5
  /* Shared PS snippets */
6
6
  /* ------------------------------------------------------------------ */
@@ -45,9 +45,7 @@ const PS_HSIZE_FN = [
45
45
  ].join('\n');
46
46
  /** Operand Words → PS array expression of string exprs. */
47
47
  function psArray(words, fn = operandExpr) {
48
- if (words.length === 0)
49
- return '@()';
50
- return '@(' + words.map(fn).join(', ') + ')';
48
+ return argListExpr(words, fn);
51
49
  }
52
50
  /* ------------------------------------------------------------------ */
53
51
  /* ls */
@@ -63,7 +61,7 @@ const ls = (args) => {
63
61
  const sortByTime = flags.has('t');
64
62
  const sortBySize = flags.has('S');
65
63
  const reverse = flags.has('r');
66
- const targets = operandWords.length ? operandWords.map((w) => operandExpr(w)) : ["'.'"];
64
+ const targets = operandWords.length ? argListExpr(operandWords) : "@('.')";
67
65
  return [
68
66
  PS_GLOB_FN,
69
67
  PS_FTIME_FN,
@@ -84,7 +82,7 @@ const ls = (args) => {
84
82
  ' }',
85
83
  ' return $n',
86
84
  '}',
87
- '$fx_targets = @(' + targets.join(', ') + ')',
85
+ '$fx_targets = ' + targets,
88
86
  '$fx_all = @()',
89
87
  'foreach ($fx_t in $fx_targets) {',
90
88
  ' foreach ($fx_g in (fx-glob $fx_t)) {',
@@ -126,12 +124,10 @@ const cp = (args) => {
126
124
  const { flags, longs, operandWords } = parseWords(args);
127
125
  const recurse = flags.has('r') || flags.has('R') || longs.has('--recursive');
128
126
  const verbose = flags.has('v') || longs.has('--verbose');
129
- const srcs = psArray(operandWords.slice(0, -1));
130
- const dst = operandWords.length >= 2 ? operandExpr(operandWords[operandWords.length - 1]) : "''";
131
127
  return [
132
128
  PS_GLOB_FN,
133
- '$fx_srcs = ' + srcs,
134
- '$fx_dst = ' + dst,
129
+ '$fx_all = ' + argListExpr(operandWords),
130
+ "if ($fx_all.Count -lt 2) { $fx_srcs = @(); $fx_dst = '' } else { $fx_dst = [string]$fx_all[$fx_all.Count - 1]; $fx_srcs = @($fx_all[0..($fx_all.Count - 2)]) }",
135
131
  "if ($fx_srcs.Count -eq 0) { [Console]::Error.WriteLine('cp: missing file operand'); $script:fx_exit = 1 }",
136
132
  "elseif ($fx_dst -eq '') { [Console]::Error.WriteLine('cp: missing destination file operand'); $script:fx_exit = 1 }",
137
133
  'else {',
@@ -154,12 +150,10 @@ const cp = (args) => {
154
150
  const mv = (args) => {
155
151
  const { flags, longs, operandWords } = parseWords(args);
156
152
  const verbose = flags.has('v') || longs.has('--verbose');
157
- const srcs = psArray(operandWords.slice(0, -1));
158
- const dst = operandWords.length >= 2 ? operandExpr(operandWords[operandWords.length - 1]) : "''";
159
153
  return [
160
154
  PS_GLOB_FN,
161
- '$fx_srcs = ' + srcs,
162
- '$fx_dst = ' + dst,
155
+ '$fx_all = ' + argListExpr(operandWords),
156
+ "if ($fx_all.Count -lt 2) { $fx_srcs = @(); $fx_dst = '' } else { $fx_dst = [string]$fx_all[$fx_all.Count - 1]; $fx_srcs = @($fx_all[0..($fx_all.Count - 2)]) }",
163
157
  "if ($fx_srcs.Count -eq 0) { [Console]::Error.WriteLine('mv: missing file operand'); $script:fx_exit = 1 }",
164
158
  "elseif ($fx_dst -eq '') { [Console]::Error.WriteLine('mv: missing destination file operand'); $script:fx_exit = 1 }",
165
159
  'elseif ($fx_srcs.Count -gt 1 -and -not (Test-Path -LiteralPath $fx_dst -PathType Container)) { [Console]::Error.WriteLine("mv: target \'" + $fx_dst + "\' is not a directory"); $script:fx_exit = 1 }',
@@ -274,13 +268,11 @@ const mktemp = (args) => {
274
268
  const ln = (args) => {
275
269
  const { flags, operandWords } = parseWords(args);
276
270
  const sym = flags.has('s');
277
- const src = operandWords.length >= 2 ? operandExpr(operandWords[0]) : "''";
278
- const dst = operandWords.length >= 2 ? operandExpr(operandWords[1]) : "''";
279
271
  const kind = sym ? 'SymbolicLink' : 'HardLink';
280
272
  const label = sym ? 'symbolic link' : 'hard link';
281
273
  return [
282
- '$fx_src = ' + src,
283
- '$fx_dst = ' + dst,
274
+ '$fx_ops = ' + argListExpr(operandWords),
275
+ "if ($fx_ops.Count -lt 2) { $fx_src = ''; $fx_dst = '' } else { $fx_src = [string]$fx_ops[0]; $fx_dst = [string]$fx_ops[1] }",
284
276
  "if ($fx_src -eq '' -or $fx_dst -eq '') { [Console]::Error.WriteLine('ln: missing file operand'); $script:fx_exit = 1 }",
285
277
  'else {',
286
278
  ' if (Test-Path -LiteralPath $fx_dst -PathType Container) { $fx_dst = Join-Path $fx_dst (Split-Path $fx_src -Leaf) }',
@@ -321,29 +313,28 @@ const realpath = (args) => {
321
313
  /* basename / dirname */
322
314
  /* ------------------------------------------------------------------ */
323
315
  const basename = (args) => {
324
- if (args.length === 2) {
325
- return [
326
- '$fx_p = ' + exprOfWord(args[0]),
327
- '$fx_sfx = ' + exprOfWord(args[1]),
328
- "$fx_n = [IO.Path]::GetFileName(($fx_p.TrimEnd('/')).TrimEnd('\\'))",
329
- "if ($fx_n -eq '') { $fx_n = '/' }",
330
- "if ($fx_sfx -ne '' -and $fx_n.EndsWith($fx_sfx) -and ($fx_n.Length -gt $fx_sfx.Length)) { $fx_n = $fx_n.Substring(0, $fx_n.Length - $fx_sfx.Length) }",
331
- '$fx_n',
332
- ].join('\n');
333
- }
334
316
  return [
335
- '$fx_ps = @(' + args.map(exprOfWord).join(', ') + ')',
317
+ '$fx_ps = ' + argListExpr(args, exprOfWord),
336
318
  "if ($fx_ps.Count -eq 0) { [Console]::Error.WriteLine('basename: missing operand'); $script:fx_exit = 1 }",
337
- 'foreach ($fx_p in $fx_ps) {',
319
+ 'elseif ($fx_ps.Count -eq 2) {',
320
+ ' $fx_p = [string]$fx_ps[0]',
321
+ ' $fx_sfx = [string]$fx_ps[1]',
338
322
  " $fx_n = [IO.Path]::GetFileName(($fx_p.TrimEnd('/')).TrimEnd('\\'))",
339
323
  " if ($fx_n -eq '') { $fx_n = '/' }",
324
+ " if ($fx_sfx -ne '' -and $fx_n.EndsWith($fx_sfx) -and ($fx_n.Length -gt $fx_sfx.Length)) { $fx_n = $fx_n.Substring(0, $fx_n.Length - $fx_sfx.Length) }",
340
325
  ' $fx_n',
326
+ '} else {',
327
+ ' foreach ($fx_p in $fx_ps) {',
328
+ " $fx_n = [IO.Path]::GetFileName(($fx_p.TrimEnd('/')).TrimEnd('\\'))",
329
+ " if ($fx_n -eq '') { $fx_n = '/' }",
330
+ ' $fx_n',
331
+ ' }',
341
332
  '}',
342
333
  ].join('\n');
343
334
  };
344
335
  const dirname = (args) => {
345
336
  return [
346
- '$fx_ps = @(' + args.map(exprOfWord).join(', ') + ')',
337
+ '$fx_ps = ' + argListExpr(args, exprOfWord),
347
338
  "if ($fx_ps.Count -eq 0) { [Console]::Error.WriteLine('dirname: missing operand'); $script:fx_exit = 1 }",
348
339
  'foreach ($fx_p in $fx_ps) {',
349
340
  " $fx_n = ($fx_p.TrimEnd('/')).TrimEnd('\\')",
@@ -428,7 +419,7 @@ const du = (args) => {
428
419
  const { flags, longs, operandWords } = parseWords(args, [], ['--max-depth']);
429
420
  const sum = flags.has('s') || longs.has('--summarize');
430
421
  const human = flags.has('h') || longs.has('--human-readable');
431
- const targets = operandWords.length ? operandWords.map((w) => operandExpr(w)) : ["'.'"];
422
+ const targets = operandWords.length ? argListExpr(operandWords) : "@('.')";
432
423
  return [
433
424
  PS_HSIZE_FN,
434
425
  'function fx-size($p) {',
@@ -436,7 +427,7 @@ const du = (args) => {
436
427
  ' Get-ChildItem -LiteralPath $p -Recurse -Force -File -ErrorAction SilentlyContinue | ForEach-Object { $t += $_.Length }',
437
428
  ' return [math]::Ceiling($t / 1KB)',
438
429
  '}',
439
- '$fx_ts = @(' + targets.join(', ') + ')',
430
+ '$fx_ts = ' + targets,
440
431
  'foreach ($fx_t in $fx_ts) {',
441
432
  ' if (-not (Test-Path -LiteralPath $fx_t)) { [Console]::Error.WriteLine("du: cannot access \'" + $fx_t + "\': No such file or directory"); $script:fx_exit = 1; continue }',
442
433
  ' if (' + (sum ? '$true' : '$false') + ') {',
@@ -500,7 +491,7 @@ const find = (args) => {
500
491
  const sizeExpr = extractValue(preds, ['-size']);
501
492
  const mtimeExpr = extractValue(preds, ['-mtime']);
502
493
  const wantDelete = preds.includes('-delete');
503
- const paths = pathWords.length ? pathWords.map((w) => operandExpr(w)) : ["'.'"];
494
+ const paths = pathWords.length ? argListExpr(pathWords) : "@('.')";
504
495
  const conditions = [];
505
496
  if (namePat !== null)
506
497
  conditions.push("($fx_i.Name -like '" + likeOf(namePat) + "')");
@@ -516,7 +507,7 @@ const find = (args) => {
516
507
  const sizeCond = sizeOf(sizeExpr);
517
508
  const mtimeCond = mtimeOf(mtimeExpr);
518
509
  return [
519
- '$fx_paths = @(' + paths.join(', ') + ')',
510
+ '$fx_paths = ' + paths,
520
511
  'foreach ($fx_p in $fx_paths) {',
521
512
  ' if (-not (Test-Path -LiteralPath $fx_p)) { [Console]::Error.WriteLine("find: \'" + $fx_p + "\': No such file or directory"); $script:fx_exit = 1; continue }',
522
513
  ' $fx_root = (Get-Item -LiteralPath $fx_p -Force).FullName',
@@ -632,13 +623,11 @@ const diff = (args) => {
632
623
  const unified = flags.has('u') || flags.has('U');
633
624
  const brief = flags.has('q') || flags.has('brief');
634
625
  void unified;
635
- const a = operandWords.length > 0 ? operandExpr(operandWords[0]) : "''";
636
- const b = operandWords.length > 1 ? operandExpr(operandWords[1]) : "''";
637
626
  return [
638
627
  PS_READTEXT_FN,
639
628
  'function fx-dr($x, $y) { if ($x -eq $y) { return [string]$x } else { return ([string]$x) + \',\' + ([string]$y) } }',
640
- '$fx_a = ' + a,
641
- '$fx_b = ' + b,
629
+ '$fx_ops = ' + argListExpr(operandWords),
630
+ "if ($fx_ops.Count -lt 2) { $fx_a = ''; $fx_b = '' } else { $fx_a = [string]$fx_ops[0]; $fx_b = [string]$fx_ops[1] }",
642
631
  "if ($fx_a -eq '' -or $fx_b -eq '') { [Console]::Error.WriteLine('diff: missing operand'); $script:fx_exit = 2 }",
643
632
  'elseif (-not (Test-Path -LiteralPath $fx_a)) { [Console]::Error.WriteLine("diff: " + $fx_a + ": No such file or directory"); $script:fx_exit = 2 }',
644
633
  'elseif (-not (Test-Path -LiteralPath $fx_b)) { [Console]::Error.WriteLine("diff: " + $fx_b + ": No such file or directory"); $script:fx_exit = 2 }',
@@ -1,6 +1,6 @@
1
1
  import { wordToString } from '../ast.js';
2
2
  import { parseWords, psErr, psStr } from '../registry.js';
3
- import { exprOfWord, literalOfWord, operandExpr } from '../translator.js';
3
+ import { argListExpr, exprOfWord, literalOfWord, operandExpr } from '../translator.js';
4
4
  /* ------------------------------------------------------------------ */
5
5
  /* Shared PS snippets */
6
6
  /* ------------------------------------------------------------------ */
@@ -94,7 +94,7 @@ const curl = (args) => {
94
94
  // refuses private/loopback URLs before the process is even started.
95
95
  return [
96
96
  PS_NETGUARD_FNS,
97
- "$fx_args = @(" + args.map(exprOfWord).join(', ') + ')',
97
+ '$fx_args = ' + argListExpr(args, exprOfWord),
98
98
  '$fx_bad = $false',
99
99
  "foreach ($fx_a in $fx_args) { if (fx-netguard 'curl' $fx_a) { $fx_bad = $true } }",
100
100
  'if ($fx_bad) { $script:fx_exit = 1 }',
@@ -212,11 +212,11 @@ function mapWgetArgs(args) {
212
212
  return { margs, sawOutput, urls };
213
213
  }
214
214
  const wget = (args) => {
215
- const orig = args.map(exprOfWord);
215
+ const orig = args.map((w) => exprOfWord(w));
216
216
  const mapped = mapWgetArgs(args);
217
217
  return [
218
218
  PS_NETGUARD_FNS,
219
- '$fx_args = @(' + orig.join(', ') + ')',
219
+ '$fx_args = ' + argListExpr(args, exprOfWord),
220
220
  '$fx_margs = @(' + mapped.margs.join(', ') + ')',
221
221
  '$fx_bad = $false',
222
222
  "foreach ($fx_a in $fx_args) { if (fx-netguard 'wget' $fx_a) { $fx_bad = $true } }",
@@ -456,7 +456,10 @@ const ifconfig = (args) => {
456
456
  /* ------------------------------------------------------------------ */
457
457
  /* nslookup / dig / host */
458
458
  /* ------------------------------------------------------------------ */
459
- const nslookup = (args) => nativeCall('nslookup.exe', args.map(exprOfWord).join(', '));
459
+ const nslookup = (args) => [
460
+ '$fx_args = ' + argListExpr(args, exprOfWord),
461
+ nativeCall('nslookup.exe', '$fx_args'),
462
+ ].join('\n');
460
463
  const dig = (args) => {
461
464
  const raw = args.map(wordToString);
462
465
  let short = false;
@@ -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 { exprOfWord, operandExpr, translateSimple, wrapTempEnv, encodeSetValExpr, escapeDq, translateCmdSub, normalizeLiteralPath, pathExpr, } from '../translator.js';
3
+ import { argListExpr, exprOfWord, operandExpr, translateSimple, wrapTempEnv, encodeSetValExpr, escapeDq, translateCmdSub, normalizeLiteralPath, pathExpr, } from '../translator.js';
4
4
  import { handlers as textIoHandlers } from './text-io.js';
5
5
  /* ------------------------------------------------------------------ */
6
6
  /* Shared TS helpers */
@@ -10,6 +10,7 @@ const NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
10
10
  function emitSetValPut(nameLit, valueExpr) {
11
11
  const n = nameLit.replace(/'/g, "''");
12
12
  return [
13
+ "fx-arrdrop '" + n + "'",
13
14
  '$fx_sv = @()',
14
15
  'foreach ($fx_pair in @($env:FAUXNIX_SETVALS -split [string][char]10)) {',
15
16
  ' $fx_eq = $fx_pair.IndexOf([char]61)',
@@ -22,6 +23,7 @@ function emitSetValPut(nameLit, valueExpr) {
22
23
  }
23
24
  function emitSetValDelRuntime(nameVar) {
24
25
  return [
26
+ 'fx-arrdrop ' + nameVar,
25
27
  '$fx_sv = @()',
26
28
  'foreach ($fx_pair in @($env:FAUXNIX_SETVALS -split [string][char]10)) {',
27
29
  ' $fx_eq = $fx_pair.IndexOf([char]61)',
@@ -62,9 +64,7 @@ function splitAssignWord(w) {
62
64
  }
63
65
  /** Text Words -> PS array expression. */
64
66
  function textArgs(words) {
65
- if (words.length === 0)
66
- return '@()';
67
- return '@(' + words.map(exprOfWord).join(', ') + ')';
67
+ return argListExpr(words, exprOfWord);
68
68
  }
69
69
  /** Drop dash-arguments, return operand words. */
70
70
  function stripFlags(args) {
@@ -128,10 +128,12 @@ const cd = (args) => {
128
128
  ].join('\n');
129
129
  }
130
130
  return [
131
- '$fx_d = ' + operandExpr(args[0]),
131
+ '$fx_ds = ' + argListExpr([args[0]]),
132
+ "if ($fx_ds.Count -ne 1) { [Console]::Error.WriteLine('bash: cd: too many arguments'); $script:fx_exit = 1 }",
133
+ 'else { $fx_d = [string]$fx_ds[0]',
132
134
  'if (-not (Test-Path -LiteralPath $fx_d)) { [Console]::Error.WriteLine("bash: cd: " + $fx_d + ": No such file or directory"); $script:fx_exit = 1 }',
133
135
  'elseif (-not (Test-Path -LiteralPath $fx_d -PathType Container)) { [Console]::Error.WriteLine("bash: cd: " + $fx_d + ": Not a directory"); $script:fx_exit = 1 }',
134
- 'else { try { Set-Location -LiteralPath $fx_d } catch { [Console]::Error.WriteLine("bash: cd: " + $fx_d + ": No such file or directory"); $script:fx_exit = 1 } }',
136
+ 'else { try { Set-Location -LiteralPath $fx_d } catch { [Console]::Error.WriteLine("bash: cd: " + $fx_d + ": No such file or directory"); $script:fx_exit = 1 } } }',
135
137
  ].join('\n');
136
138
  };
137
139
  const pwd = (args) => {
@@ -162,7 +164,7 @@ const exportCmd = (args) => {
162
164
  }
163
165
  return sets
164
166
  .map((s) => {
165
- const val = exprOfWord(s.value);
167
+ const val = exprOfWord(s.value, { preserveCmdSub: true });
166
168
  return ('$fx_exv = ' +
167
169
  val +
168
170
  '; $env:' +
@@ -886,6 +888,7 @@ const FX_TNK_FN = [
886
888
  ' $fx_ev = Get-ChildItem Env: | Where-Object { $_.Name -ceq $n } | Select-Object -First 1',
887
889
  ' $nm = if ($fx_ev) { $fx_ev.Name } else { $n }',
888
890
  " Set-Item -LiteralPath ('Env:' + $nm) -Value ([string][long]$v)",
891
+ ' fx-arrdrop $n',
889
892
  " $env:FAUXNIX_SETVARS = ((@($env:FAUXNIX_SETVARS -split ';' | Where-Object { $_ -ne '' -and $_ -cne $n }) + $n) -join ';')",
890
893
  " $env:FAUXNIX_UNSETVARS = (@($env:FAUXNIX_UNSETVARS -split ';' | Where-Object { $_ -ne '' -and $_ -cne $n }) -join ';')",
891
894
  ' $fx_sv = @()',
@@ -1341,6 +1344,9 @@ function kshExprOfWord(w) {
1341
1344
  if (tilde && expanded.length === 0)
1342
1345
  return '(fx-home)';
1343
1346
  if (!tilde && expanded.length === 1 && expanded[0].kind === 'Var') {
1347
+ if (expanded[0].index !== undefined) {
1348
+ return '(fx-subget ' + psStr(expanded[0].name) + ' ' + psStr(expanded[0].index) + ')';
1349
+ }
1344
1350
  return '(fx-envget ' + psStr(expanded[0].name) + ')';
1345
1351
  }
1346
1352
  const literal = !tilde && expanded.every((p) => p.kind === 'Text' || p.kind === 'SingleQuoted');
@@ -1362,10 +1368,14 @@ function kshExprOfWord(w) {
1362
1368
  emitPart(q);
1363
1369
  break;
1364
1370
  case 'Var':
1365
- out += '$(fx-envget ' + psStr(p.name) + ')';
1371
+ out +=
1372
+ p.index !== undefined
1373
+ ? '$(fx-subget ' + psStr(p.name) + ' ' + psStr(p.index) + ')'
1374
+ : '$(fx-envget ' + psStr(p.name) + ')';
1366
1375
  break;
1367
1376
  case 'CmdSub':
1368
- out += '$(' + translateCmdSub(p.cmd) + ')';
1377
+ // [[ ]] does not IFS-split, so keep the newline contract.
1378
+ out += '$(' + translateCmdSub(p.cmd, true) + ')';
1369
1379
  break;
1370
1380
  }
1371
1381
  };
@@ -1378,7 +1388,16 @@ const FX_ISSET_FN = [
1378
1388
  'function fx-isset($n) {',
1379
1389
  ' $n = [string]$n',
1380
1390
  " if ($n -eq '') { return $false }",
1381
- " if ($n -match '^([A-Za-z_][A-Za-z0-9_]*)\\[(0|@|\\*)\\]$') { $n = $Matches[1] }",
1391
+ " if ($n -match '^([A-Za-z_][A-Za-z0-9_]*)\\[([0-9]+|@|\\*)\\]$') {",
1392
+ ' $fx_ib = $Matches[1]; $fx_ix = [string]$Matches[2]',
1393
+ " if ($fx_ix -eq '@' -or $fx_ix -eq '*' -or $fx_ix -eq '0') { $n = $fx_ib }",
1394
+ ' else {',
1395
+ ' $fx_ia = @(fx-arrload $fx_ib)',
1396
+ ' $fx_ii = 0',
1397
+ ' if (-not [int]::TryParse($fx_ix, [ref]$fx_ii)) { return $false }',
1398
+ ' return ($fx_ii -ge 0 -and $fx_ii -lt $fx_ia.Count)',
1399
+ ' }',
1400
+ ' }',
1382
1401
  " elseif ($n -match '^[A-Za-z_][A-Za-z0-9_]*\\[') { return $false }",
1383
1402
  " if (@($env:FAUXNIX_UNSETVARS -split ';' | Where-Object { $_ -ceq $n }).Count -gt 0) { return $false }",
1384
1403
  " if (@($env:FAUXNIX_SETVARS -split ';' | Where-Object { $_ -ceq $n }).Count -gt 0) { return $true }",
@@ -1425,18 +1444,11 @@ const FX_RE_FN = [
1425
1444
  ' try {',
1426
1445
  ' $fx_rm = [regex]::Match([string]$a, (fx-posixre ([string]$b)), [Text.RegularExpressions.RegexOptions]::Singleline)',
1427
1446
  ' if ($fx_rm.Success) {',
1428
- ' $env:BASH_REMATCH = [string]$fx_rm.Value',
1429
- " $env:FAUXNIX_SETVARS = ((@($env:FAUXNIX_SETVARS -split ';' | Where-Object { $_ -ne '' -and $_ -cne 'BASH_REMATCH' }) + 'BASH_REMATCH') -join ';')",
1430
- " $env:FAUXNIX_UNSETVARS = (@($env:FAUXNIX_UNSETVARS -split ';' | Where-Object { $_ -ne '' -and $_ -cne 'BASH_REMATCH' }) -join ';')",
1431
- ' $fx_enc = ([string]$fx_rm.Value).Replace([string][char]92, ([string][char]92 + [string][char]92)).Replace([string][char]13, ([string][char]92 + [char]114)).Replace([string][char]10, ([string][char]92 + [char]110))',
1432
- " $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 'BASH_REMATCH') { $fx_sv += $fx_pair } }",
1433
- " $fx_sv += ('BASH_REMATCH' + [string][char]61 + $fx_enc); $env:FAUXNIX_SETVALS = ($fx_sv -join [string][char]10)",
1447
+ ' $fx_gs = @(); foreach ($fx_g in $fx_rm.Groups) { $fx_gs += ,[string]$fx_g.Value }',
1448
+ " fx-arrput 'BASH_REMATCH' $fx_gs",
1434
1449
  ' return $true',
1435
1450
  ' }',
1436
- " Remove-Item -LiteralPath 'Env:\\BASH_REMATCH' -ErrorAction SilentlyContinue",
1437
- " $env:FAUXNIX_SETVARS = (@($env:FAUXNIX_SETVARS -split ';' | Where-Object { $_ -ne '' -and $_ -cne 'BASH_REMATCH' }) -join ';')",
1438
- " $env:FAUXNIX_UNSETVARS = ((@($env:FAUXNIX_UNSETVARS -split ';' | Where-Object { $_ -ne '' -and $_ -cne 'BASH_REMATCH' }) + 'BASH_REMATCH') -join ';')",
1439
- " $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 'BASH_REMATCH') { $fx_sv += $fx_pair } }; $env:FAUXNIX_SETVALS = ($fx_sv -join [string][char]10)",
1451
+ " fx-arrclr 'BASH_REMATCH'",
1440
1452
  ' return $false',
1441
1453
  ' }',
1442
1454
  " catch { [Console]::Error.WriteLine('bash: [[: invalid regular expression'); $script:fx_exit = 2; return $false }",
@@ -3,7 +3,7 @@ import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { FauxnixParseError, wordToString } from '../ast.js';
5
5
  import { parseWords, psStr } from '../registry.js';
6
- import { exprOfWord, literalOfWord, operandExpr } from '../translator.js';
6
+ import { argListExpr, exprOfWord, literalOfWord, operandExpr } from '../translator.js';
7
7
  /* ------------------------------------------------------------------ */
8
8
  /* Shared PS snippets (same shape as files.ts) */
9
9
  /* ------------------------------------------------------------------ */
@@ -34,9 +34,7 @@ const PS_SPLITLINES_FN = [
34
34
  const STDIN_LINES = '@($input | ForEach-Object { [string]$_ })';
35
35
  /** Operand Words → PS array expression of string exprs. */
36
36
  function psArray(words, fn = operandExpr) {
37
- if (words.length === 0)
38
- return '@()';
39
- return '@(' + words.map(fn).join(', ') + ')';
37
+ return argListExpr(words, fn);
40
38
  }
41
39
  /** PS boolean literal. */
42
40
  function pb(v) {
@@ -1,6 +1,6 @@
1
1
  import { wordToString } from '../ast.js';
2
2
  import { lookup, parseWords, psStr } from '../registry.js';
3
- import { exprOfWord, operandExpr } from '../translator.js';
3
+ import { argListExpr, exprOfWord, operandExpr } from '../translator.js';
4
4
  /* ------------------------------------------------------------------ */
5
5
  /* Shared PS snippets (same shape as files.ts / text-filters.ts) */
6
6
  /* ------------------------------------------------------------------ */
@@ -80,6 +80,9 @@ const PS_UNESQ_FN = [
80
80
  const PS_WRITE_FN = [
81
81
  'function fx-write($s, $term) {',
82
82
  " if ($s -eq '') { return }",
83
+ // Inside quoted/assignment $(...) the collector wants one string object
84
+ // so interior newlines survive (PS would otherwise join lines with spaces).
85
+ ' if ($script:fx_csub) { $s; return }',
83
86
  ' if (-not $term) { $s; return }',
84
87
  ' if (-not $s.EndsWith([string][char]10)) { [Console]::Out.Write($s); return }',
85
88
  ' $t = $s.Substring(0, $s.Length - 1)',
@@ -110,9 +113,7 @@ function qErr(cmd, g, msg, lead = 'cannot open ') {
110
113
  }
111
114
  /** Operand Words → PS array expression of string exprs. */
112
115
  function psArray(words, fn = operandExpr) {
113
- if (words.length === 0)
114
- return '@()';
115
- return '@(' + words.map(fn).join(', ') + ')';
116
+ return argListExpr(words, fn);
116
117
  }
117
118
  /**
118
119
  * Collect file operands through fx-glob into `$fx_srcs`. A literal `-`
@@ -136,8 +137,8 @@ function psCollectFiles(operandWords, missErr, dirErr, stdinDefault = true) {
136
137
  const lit = w.every((p) => p.kind === 'Text' || p.kind === 'SingleQuoted')
137
138
  ? w.map((p) => p.text).join('')
138
139
  : null;
139
- out.push('$fx_d = ' + (lit !== null ? psStr(lit) : exprOfWord(w)));
140
- out.push('foreach ($fx_g in (fx-glob ' + operandExpr(w) + ')) {');
140
+ out.push('foreach ($fx_d in ' + argListExpr([w], lit !== null ? operandExpr : exprOfWord) + ') {');
141
+ out.push('foreach ($fx_g in (fx-glob $fx_d)) {');
141
142
  if (dirErr) {
142
143
  const dis = lit !== null && /[*?]/.test(lit) ? '$fx_g' : '$fx_d';
143
144
  out.push(' if (Test-Path -LiteralPath $fx_g -PathType Container) { ' +
@@ -153,7 +154,7 @@ function psCollectFiles(operandWords, missErr, dirErr, stdinDefault = true) {
153
154
  (lit !== null && /[*?]/.test(lit)
154
155
  ? '$fx_names += $fx_g'
155
156
  : '$fx_names += $fx_d'));
156
- out.push('}');
157
+ out.push('}', '}');
157
158
  }
158
159
  return out;
159
160
  }
@@ -915,16 +916,6 @@ const base64 = (args, ctx) => {
915
916
  const seq = (args, ctx) => {
916
917
  const { flags, values, operandWords } = parseWords(args, ['s']);
917
918
  const eq = flags.has('w');
918
- const nums = operandWords.map((w) => exprOfWord(w));
919
- if (nums.length === 0) {
920
- return psErrExpr(psStr('seq: missing operand'));
921
- }
922
- if (nums.length > 3) {
923
- return psErrExpr(psStr('seq: extra operand ') + ' + ' + nums[3]);
924
- }
925
- const first = nums.length >= 2 ? nums[0] : '1';
926
- const inc = nums.length === 3 ? nums[1] : '1';
927
- const last = nums.length === 3 ? nums[2] : nums[nums.length - 1];
928
919
  const sepExpr = values.has('-s') ? psStr(values.get('-s')) : '[string][char]10';
929
920
  return [
930
921
  PS_WRITE_FN,
@@ -937,9 +928,17 @@ const seq = (args, ctx) => {
937
928
  " if ([string]$s -match '^[+-]?[0-9]*\\.([0-9]+)') { return $Matches[1].Length }",
938
929
  ' return 0',
939
930
  '}',
940
- '$fx_a = [string](' + first + ')',
941
- '$fx_b = [string](' + inc + ')',
942
- '$fx_c = [string](' + last + ')',
931
+ '$fx_nums = ' + argListExpr(operandWords),
932
+ "if ($fx_nums.Count -eq 0) { " +
933
+ psErrExpr(psStr('seq: missing operand')) +
934
+ ' }',
935
+ "elseif ($fx_nums.Count -gt 3) { " +
936
+ psErrExpr(psStr('seq: extra operand ') + ' + $fx_nums[3]') +
937
+ ' }',
938
+ 'else {',
939
+ " $fx_a = [string]$(if ($fx_nums.Count -ge 2) { $fx_nums[0] } else { '1' })",
940
+ " $fx_b = [string]$(if ($fx_nums.Count -eq 3) { $fx_nums[1] } else { '1' })",
941
+ ' $fx_c = [string]$(if ($fx_nums.Count -eq 3) { $fx_nums[2] } else { $fx_nums[$fx_nums.Count - 1] })',
943
942
  '$fx_first = fx-tod $fx_a',
944
943
  '$fx_inc = fx-tod $fx_b',
945
944
  '$fx_last = fx-tod $fx_c',
@@ -968,6 +967,7 @@ const seq = (args, ctx) => {
968
967
  ' }',
969
968
  ' if ($fx_strs.Count -eq 0) { }',
970
969
  ' else { fx-write (($fx_strs -join (' + sepExpr + ')) + [string][char]10) $fx_term }',
970
+ ' }',
971
971
  '}',
972
972
  ].join('\n');
973
973
  };
@@ -1069,8 +1069,6 @@ const xargs = (args) => {
1069
1069
  if (firstLit !== null && lookup(firstLit) !== undefined) {
1070
1070
  return psErrExpr(psStr(XARGS_BUILTIN_MSG));
1071
1071
  }
1072
- const cmdExpr = exprOfWord(target[0]);
1073
- const baseArgs = psArray(target.slice(1), exprOfWord);
1074
1072
  const n = chunkN !== null && Number.isFinite(chunkN) && chunkN > 0 ? chunkN : 0;
1075
1073
  const replExpr = repl !== null ? psStr(repl) : null;
1076
1074
  const invoke = [
@@ -1137,8 +1135,8 @@ const xargs = (args) => {
1137
1135
  return [
1138
1136
  PS_SPLITLINES_FN,
1139
1137
  STDIN_INLINES,
1140
- '$fx_cmd = ' + cmdExpr,
1141
- '$fx_base = ' + baseArgs,
1138
+ '$fx_tg = ' + argListExpr(target, exprOfWord),
1139
+ "if ($fx_tg.Count -eq 0) { $fx_cmd = ''; $fx_base = @() } else { $fx_cmd = [string]$fx_tg[0]; $fx_base = $(if ($fx_tg.Count -gt 1) { @($fx_tg[1..($fx_tg.Count - 1)]) } else { @() }) }",
1142
1140
  "$fx_args = @($fx_in | Where-Object { $_ -ne '' })",
1143
1141
  ...dispatch,
1144
1142
  ].join('\n');
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,12 +1,35 @@
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';
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
+ };
10
33
  const TOOL_DESCRIPTION = `Execute a Linux/bash-style command on this Windows machine.
11
34
 
12
35
  Commands are deterministically translated to PowerShell and executed natively — no WSL or VM.
@@ -17,9 +40,11 @@ Unknown commands (git, node, npm, python, cargo...) are passed through and execu
17
40
  Not supported: heredocs, backticks, control flow (if/for/while), background jobs.
18
41
 
19
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.
20
- Exit codes follow bash conventions (0 ok, 1 fail, 2 usage/serious, 127 command not found, 124 timeout).`;
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.3.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
@@ -200,7 +200,11 @@ function readDollar(input, i) {
200
200
  if (end === -1)
201
201
  throw new FauxnixParseError('fauxnix: unclosed ${');
202
202
  const name = input.slice(j + 1, end);
203
- if (!isNameStart(name[0]) || !name.split('').every(isNameChar)) {
203
+ const sub = name.match(/^([A-Za-z_][A-Za-z0-9_]*)\[([0-9]+|@|\*)\]$/);
204
+ if (sub) {
205
+ return { part: { kind: 'Var', name: sub[1], index: sub[2] }, next: end + 1 };
206
+ }
207
+ if (!name || !isNameStart(name[0]) || !name.split('').every(isNameChar)) {
204
208
  // ${VAR:-default} etc. — unsupported, kept as raw text
205
209
  return { part: { kind: 'Text', text: input.slice(i, end + 1) }, next: end + 1 };
206
210
  }
@@ -1,7 +1,7 @@
1
1
  import { Assignment, CommandList, Redirect, SimpleCommand, Word } from './ast.js';
2
2
  import { PipelineCtx } from './registry.js';
3
3
  /** Map a bash $VAR name to a PowerShell expression (usable inside $(...)). */
4
- export declare function varExpr(name: string): string;
4
+ export declare function varExpr(name: string, index?: string): string;
5
5
  /** Escape text destined for the inside of a PS double-quoted string. */
6
6
  export declare function escapeDq(s: string): string;
7
7
  /** Normalize a literal POSIX-ish path to its Windows equivalent. */
@@ -17,7 +17,9 @@ export declare function pathExpr(s: string): string;
17
17
  * Literal words become single-quoted strings; dynamic ones become
18
18
  * double-quoted strings with $(...) interpolation.
19
19
  */
20
- export declare function exprOfWord(w: Word): string;
20
+ export declare function exprOfWord(w: Word, opts?: {
21
+ preserveCmdSub?: boolean;
22
+ }): string;
21
23
  /** Literal text of a word when it contains no interpolation, else null. */
22
24
  export declare function literalOfWord(w: Word): string | null;
23
25
  /**
@@ -25,8 +27,25 @@ export declare function literalOfWord(w: Word): string | null;
25
27
  * Literal paths get POSIX-ish normalization (/dev/null, /tmp, /d/...).
26
28
  */
27
29
  export declare function operandExpr(w: Word): string;
28
- /** Translate the inside of $(...) — pipelines only, no wrappers. */
29
- export declare function translateCmdSub(cmdText: string): string;
30
+ /**
31
+ * `${name[@]}` / `"pre${name[@]}post"` — one argv per element.
32
+ * Unquoted `${name[*]}` also splats (bash); quoted `"${name[*]}"` stays one join.
33
+ */
34
+ export declare function splatSpec(w: Word): {
35
+ name: string;
36
+ prefix: string;
37
+ suffix: string;
38
+ } | null;
39
+ /** PS expression of a string[]: `@` words splat, others stay one element. */
40
+ export declare function argListExpr(words: Word[], fn?: (w: Word) => string): string;
41
+ /**
42
+ * Translate the inside of $(...).
43
+ * `keepNl`: quoted words and assignments keep interior newlines (bash).
44
+ * Unquoted command words join non-empty lines with a space (IFS
45
+ * word-split approximation). Handlers often emit one string object, so
46
+ * a bare `$(…)` interpolation would keep those newlines.
47
+ */
48
+ export declare function translateCmdSub(cmdText: string, keepNl?: boolean): string;
30
49
  export declare function translateSimple(cmd: SimpleCommand, position: PipelineCtx['position'], hasStdin: boolean): string;
31
50
  /** PS expr: encode a string so SETVALS records can stay newline-delimited. */
32
51
  export declare function encodeSetValExpr(srcExpr: string): string;
@@ -5,7 +5,13 @@ import { lookup, psStr } from './registry.js';
5
5
  /* Variable mapping */
6
6
  /* ------------------------------------------------------------------ */
7
7
  /** Map a bash $VAR name to a PowerShell expression (usable inside $(...)). */
8
- export function varExpr(name) {
8
+ export function varExpr(name, index) {
9
+ // Indexed reads always go through fx-subget → fx-arrload → fx-scalar0 so
10
+ // ${PWD[0]} keeps the special mapping and ${bash_rematch[0]} stays
11
+ // case-exact (a `$env:name` fallback would alias BASH_REMATCH on Windows).
12
+ if (index !== undefined) {
13
+ return '(fx-subget ' + psStr(name) + ' ' + psStr(index) + ')';
14
+ }
9
15
  switch (name) {
10
16
  case 'HOME':
11
17
  return '$HOME';
@@ -85,7 +91,7 @@ export function pathExpr(s) {
85
91
  * Literal words become single-quoted strings; dynamic ones become
86
92
  * double-quoted strings with $(...) interpolation.
87
93
  */
88
- export function exprOfWord(w) {
94
+ export function exprOfWord(w, opts) {
89
95
  // tilde expansion (unquoted leading ~)
90
96
  const expanded = [];
91
97
  if (w.length > 0 && w[0].kind === 'Text' && w[0].text.startsWith('~')) {
@@ -100,7 +106,12 @@ export function exprOfWord(w) {
100
106
  }
101
107
  // single bare variable → bare expression
102
108
  if (expanded.length === 1 && expanded[0].kind === 'Var') {
103
- return varExpr(expanded[0].name);
109
+ return varExpr(expanded[0].name, expanded[0].index);
110
+ }
111
+ // Bare `$(...)` must not sit inside a PS expandable string: the
112
+ // substitution body contains `"` / `$_` that would break interpolation.
113
+ if (expanded.length === 1 && expanded[0].kind === 'CmdSub') {
114
+ return '$(' + translateCmdSub(expanded[0].cmd, opts?.preserveCmdSub === true) + ')';
104
115
  }
105
116
  const literal = expanded.every((p) => p.kind === 'Text' || p.kind === 'SingleQuoted');
106
117
  if (literal) {
@@ -109,7 +120,7 @@ export function exprOfWord(w) {
109
120
  }
110
121
  // dynamic — build a PS double-quoted string with interpolation
111
122
  let out = '"';
112
- const emitPart = (p) => {
123
+ const emitPart = (p, quoted) => {
113
124
  switch (p.kind) {
114
125
  case 'Text':
115
126
  out += escapeDq(p.text);
@@ -119,18 +130,18 @@ export function exprOfWord(w) {
119
130
  break;
120
131
  case 'DoubleQuoted':
121
132
  for (const q of p.parts)
122
- emitPart(q);
133
+ emitPart(q, true);
123
134
  break;
124
135
  case 'Var':
125
- out += '$(' + varExpr(p.name) + ')';
136
+ out += '$(' + varExpr(p.name, p.index) + ')';
126
137
  break;
127
138
  case 'CmdSub':
128
- out += '$(' + translateCmdSub(p.cmd) + ')';
139
+ out += '$(' + translateCmdSub(p.cmd, quoted || opts?.preserveCmdSub === true) + ')';
129
140
  break;
130
141
  }
131
142
  };
132
143
  for (const p of expanded)
133
- emitPart(p);
144
+ emitPart(p, false);
134
145
  out += '"';
135
146
  return out;
136
147
  }
@@ -153,17 +164,96 @@ export function operandExpr(w) {
153
164
  return pathExpr(normalizeLiteralPath(lit));
154
165
  return exprOfWord(w);
155
166
  }
167
+ /** Flatten quotes but remember whether a part sat inside `"..."`. */
168
+ function wordPartsForSplat(w) {
169
+ const out = [];
170
+ const walk = (parts, quoted) => {
171
+ for (const p of parts) {
172
+ if (p.kind === 'DoubleQuoted')
173
+ walk(p.parts, true);
174
+ else
175
+ out.push({ part: p, quoted });
176
+ }
177
+ };
178
+ walk(w, false);
179
+ return out;
180
+ }
181
+ /**
182
+ * `${name[@]}` / `"pre${name[@]}post"` — one argv per element.
183
+ * Unquoted `${name[*]}` also splats (bash); quoted `"${name[*]}"` stays one join.
184
+ */
185
+ export function splatSpec(w) {
186
+ const parts = wordPartsForSplat(w);
187
+ let name = null;
188
+ let prefix = '';
189
+ let suffix = '';
190
+ let seen = false;
191
+ for (const { part: p, quoted } of parts) {
192
+ const splat = p.kind === 'Var' && (p.index === '@' || (p.index === '*' && !quoted));
193
+ if (splat) {
194
+ if (seen)
195
+ return null;
196
+ seen = true;
197
+ name = p.name;
198
+ continue;
199
+ }
200
+ if (p.kind !== 'Text' && p.kind !== 'SingleQuoted')
201
+ return null;
202
+ if (seen)
203
+ suffix += p.text;
204
+ else
205
+ prefix += p.text;
206
+ }
207
+ return name ? { name, prefix, suffix } : null;
208
+ }
209
+ /** PS expression of a string[]: `@` words splat, others stay one element. */
210
+ export function argListExpr(words, fn = exprOfWord) {
211
+ if (words.length === 0)
212
+ return '@()';
213
+ return ('(' +
214
+ words
215
+ .map((w) => {
216
+ const s = splatSpec(w);
217
+ if (!s)
218
+ return '@(' + fn(w) + ')';
219
+ if (!s.prefix && !s.suffix)
220
+ return '@(fx-arrload ' + psStr(s.name) + ')';
221
+ return ('@($( $fx_sp = @(fx-arrload ' +
222
+ psStr(s.name) +
223
+ '); if ($fx_sp.Count -eq 0) { $fx_sp = @(' +
224
+ psStr(s.prefix + s.suffix) +
225
+ ') } else { $fx_sp[0] = ' +
226
+ psStr(s.prefix) +
227
+ ' + $fx_sp[0]; $fx_sp[$fx_sp.Count-1] = $fx_sp[$fx_sp.Count-1] + ' +
228
+ psStr(s.suffix) +
229
+ ' }; $fx_sp ))');
230
+ })
231
+ .join(' + ') +
232
+ ')');
233
+ }
156
234
  /* ------------------------------------------------------------------ */
157
235
  /* Command substitution */
158
236
  /* ------------------------------------------------------------------ */
159
- /** Translate the inside of $(...) — pipelines only, no wrappers. */
160
- export function translateCmdSub(cmdText) {
237
+ /**
238
+ * Translate the inside of $(...).
239
+ * `keepNl`: quoted words and assignments keep interior newlines (bash).
240
+ * Unquoted command words join non-empty lines with a space (IFS
241
+ * word-split approximation). Handlers often emit one string object, so
242
+ * a bare `$(…)` interpolation would keep those newlines.
243
+ */
244
+ export function translateCmdSub(cmdText, keepNl = false) {
161
245
  const list = parseCommand(cmdText);
162
246
  if (list.segments.length !== 1) {
163
247
  throw new FauxnixParseError('fauxnix: command substitution with ; && || is not supported yet');
164
248
  }
165
249
  const { defs, call } = translatePipelineBody(list.segments[0].pipeline);
166
- return defs ? defs + '\n' + call : call;
250
+ const inner = defs ? defs + '\n' + call : call;
251
+ const collected = '(fx-csub { ' + inner + ' })';
252
+ if (keepNl)
253
+ return collected;
254
+ return ('((' +
255
+ collected +
256
+ " -split [string][char]10 | Where-Object { $_ -ne '' }) -join ' ')");
167
257
  }
168
258
  /* ------------------------------------------------------------------ */
169
259
  /* Simple command translation */
@@ -182,8 +272,26 @@ export function translateSimple(cmd, position, hasStdin) {
182
272
  return exportHandler ? exportHandler(words, { position, hasStdin }) : '';
183
273
  }
184
274
  const nameLit = literalOfWord(cmd.name);
275
+ const nameSplat = splatSpec(cmd.name);
185
276
  let body;
186
- if (nameLit !== null) {
277
+ if (nameSplat) {
278
+ const invoke = '& $fx_cmd @fx_na';
279
+ body = [
280
+ '$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] = ' +
283
+ psStr(nameSplat.prefix) +
284
+ ' + $fx_cw[0]; $fx_cw[$fx_cw.Count-1] = $fx_cw[$fx_cw.Count-1] + ' +
285
+ 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');
293
+ }
294
+ else if (nameLit !== null) {
187
295
  const handler = lookup(nameLit);
188
296
  if (handler && !(nameLit === '[[' && !isUnquotedLiteral(cmd.name, '[['))) {
189
297
  body = handler(cmd.args, { position, hasStdin });
@@ -193,12 +301,11 @@ export function translateSimple(cmd, position, hasStdin) {
193
301
  // invoked with the call operator and an argv-style argument array —
194
302
  // no string re-parsing of user text.
195
303
  const nameExpr = psStr(nameLit);
196
- const argExprs = cmd.args.map((a) => exprOfWord(a));
197
- const args = argExprs.length ? ' @(' + argExprs.join(', ') + ')' : '';
198
- const call = '& ' + nameExpr + args;
304
+ const invoke = '& ' + nameExpr + ' @fx_na';
199
305
  body = [
306
+ '$fx_na = ' + argListExpr(cmd.args),
200
307
  // feed pipeline stdin into the native process when we are a non-first stage
201
- (hasStdin ? '($input | ' + call + ')' : call) + ' | ForEach-Object { [string]$_ }',
308
+ (hasStdin ? '($input | ' + invoke + ')' : invoke) + ' | ForEach-Object { [string]$_ }',
202
309
  'if ($LASTEXITCODE -gt 0) { $script:fx_exit = $LASTEXITCODE } elseif ($LASTEXITCODE -lt 0) { $script:fx_exit = 1 }',
203
310
  ].join('\n');
204
311
  }
@@ -206,11 +313,10 @@ export function translateSimple(cmd, position, hasStdin) {
206
313
  else {
207
314
  // dynamic command name — evaluate it
208
315
  const nameExpr = exprOfWord(cmd.name);
209
- const argExprs = cmd.args.map((a) => exprOfWord(a));
210
- const args = argExprs.length ? ' @(' + argExprs.join(', ') + ')' : '';
211
- const call = '& (' + nameExpr + ')' + args;
316
+ const invoke = '& (' + nameExpr + ') @fx_na';
212
317
  body = [
213
- (hasStdin ? '($input | ' + call + ')' : call) + ' | ForEach-Object { [string]$_ }',
318
+ '$fx_na = ' + argListExpr(cmd.args),
319
+ (hasStdin ? '($input | ' + invoke + ')' : invoke) + ' | ForEach-Object { [string]$_ }',
214
320
  'if ($LASTEXITCODE -gt 0) { $script:fx_exit = $LASTEXITCODE } elseif ($LASTEXITCODE -lt 0) { $script:fx_exit = 1 }',
215
321
  ].join('\n');
216
322
  }
@@ -288,11 +394,14 @@ export function wrapTempEnv(sets, body, extra) {
288
394
  }
289
395
  if (names.length === 0)
290
396
  return body;
397
+ const assigned = new Set(sets.map((s) => s.name));
291
398
  const id = tempEnvSeq++;
292
399
  const save = '$fx_es' + id;
400
+ const arrSave = '$fx_ar' + id;
293
401
  const keep = persistWords && persistWords.length > 0 ? '$fx_ek' + id : '';
294
402
  const lines = [
295
403
  save + ' = @{}',
404
+ arrSave + ' = @{}',
296
405
  '$fx_sv0' + id + ' = $env:FAUXNIX_SETVARS',
297
406
  '$fx_uv0' + id + ' = $env:FAUXNIX_UNSETVARS',
298
407
  '$fx_xv0' + id + ' = $env:FAUXNIX_SETVALS',
@@ -307,17 +416,19 @@ export function wrapTempEnv(sets, body, extra) {
307
416
  ') { [string](Get-Item -LiteralPath ' +
308
417
  p +
309
418
  ').Value } else { $null })');
419
+ lines.push(arrSave + '[' + psStr(n) + '] = (fx-arrpackget ' + psStr(n) + ')');
310
420
  }
311
421
  const valVars = [];
312
422
  for (let i = 0; i < sets.length; i++) {
313
423
  const vn = '$fx_ev' + id + '_' + i;
314
424
  valVars.push(vn);
315
- lines.push(vn + ' = ' + exprOfWord(sets[i].value));
425
+ lines.push(vn + ' = ' + exprOfWord(sets[i].value, { preserveCmdSub: true }));
316
426
  }
317
427
  lines.push('try {');
318
428
  for (const u of unsets) {
319
429
  const uq = u.replace(/'/g, "''");
320
430
  lines.push(' Remove-Item -LiteralPath ' + psStr('Env:\\' + u) + ' -ErrorAction SilentlyContinue');
431
+ lines.push(' fx-arrdrop ' + psStr(u));
321
432
  // `env -u NAME` must hide NAME from fx-envget / fx-isset for the
322
433
  // wrapped body. Removing Env:\NAME is not enough: an earlier
323
434
  // `export NAME=x` still lives in SETVARS/SETVALS, and special
@@ -338,6 +449,7 @@ export function wrapTempEnv(sets, body, extra) {
338
449
  const n = sets[i].name;
339
450
  const nq = n.replace(/'/g, "''");
340
451
  lines.push(' $env:' + n + ' = ' + valVars[i]);
452
+ lines.push(' fx-arrdrop ' + psStr(n));
341
453
  lines.push(" $env:FAUXNIX_SETVARS = ((@($env:FAUXNIX_SETVARS -split ';' | Where-Object { $_ -ne '' -and $_ -cne '" +
342
454
  nq +
343
455
  "' }) + '" +
@@ -415,8 +527,14 @@ export function wrapTempEnv(sets, body, extra) {
415
527
  psStr(n) +
416
528
  '] }');
417
529
  }
530
+ const restoreArr = 'fx-arrpackset ' + psStr(n) + ' ' + arrSave + '[' + psStr(n) + ']';
531
+ if (keep) {
532
+ lines.push(' if (-not $fx_skip) { ' + restoreArr + ' }');
533
+ }
534
+ else {
535
+ lines.push(' ' + restoreArr);
536
+ }
418
537
  }
419
- const assigned = new Set(sets.map((s) => s.name));
420
538
  for (const n of persistNames) {
421
539
  const nq = n.replace(/'/g, "''");
422
540
  const ep = psStr('Env:\\' + n);
@@ -562,6 +680,144 @@ export function wrapScript(body) {
562
680
  " if ($parts[$parts.Count - 1] -eq '') { $parts = $parts[0..($parts.Count - 2)] }",
563
681
  ' return $parts',
564
682
  '}',
683
+ 'function fx-csub([scriptblock]$b) {',
684
+ ' $fx_prevcs = $script:fx_csub',
685
+ ' $script:fx_csub = $true',
686
+ ' try { $fx_o = @(& $b | ForEach-Object { [string]$_ }) }',
687
+ ' finally { $script:fx_csub = $fx_prevcs }',
688
+ ' $fx_s = ($fx_o -join [string][char]10)',
689
+ ' while ($fx_s.Length -gt 0 -and $fx_s[$fx_s.Length - 1] -eq [char]10) {',
690
+ ' $fx_s = $fx_s.Substring(0, $fx_s.Length - 1)',
691
+ ' }',
692
+ ' return $fx_s',
693
+ '}',
694
+ 'function fx-svenc($s) {',
695
+ ' return ([string]$s).Replace([string][char]92, ([string][char]92 + [string][char]92)).Replace([string][char]13, ([string][char]92 + [char]114)).Replace([string][char]10, ([string][char]92 + [char]110))',
696
+ '}',
697
+ 'function fx-svdec($s) {',
698
+ ' $s = [string]$s',
699
+ ' $sb = New-Object System.Text.StringBuilder',
700
+ ' $i = 0',
701
+ ' while ($i -lt $s.Length) {',
702
+ ' $c = $s[$i]',
703
+ ' if ($c -eq [char]92 -and ($i + 1) -lt $s.Length) {',
704
+ ' $n2 = $s[$i + 1]',
705
+ ' if ($n2 -eq [char]110) { [void]$sb.Append([char]10); $i += 2; continue }',
706
+ ' if ($n2 -eq [char]114) { [void]$sb.Append([char]13); $i += 2; continue }',
707
+ ' if ($n2 -eq [char]92) { [void]$sb.Append([char]92); $i += 2; continue }',
708
+ ' }',
709
+ ' [void]$sb.Append($c)',
710
+ ' $i++',
711
+ ' }',
712
+ ' return [string]$sb',
713
+ '}',
714
+ 'function fx-arrload($n) {',
715
+ ' $n = [string]$n',
716
+ ' foreach ($fx_pair in @($env:FAUXNIX_ARRS -split [string][char]10)) {',
717
+ ' $fx_eq = $fx_pair.IndexOf([char]61)',
718
+ ' if ($fx_eq -lt 1) { continue }',
719
+ ' if ($fx_pair.Substring(0, $fx_eq) -cne $n) { continue }',
720
+ ' $out = @()',
721
+ ' foreach ($el in @($fx_pair.Substring($fx_eq + 1) -split [string][char]30)) { $out += ,(fx-svdec $el) }',
722
+ ' return $out',
723
+ ' }',
724
+ ' $s0 = fx-scalar0 $n',
725
+ ' if ($null -eq $s0) { return @() }',
726
+ ' return @([string]$s0)',
727
+ '}',
728
+ 'function fx-scalar0($n) {',
729
+ ' $n = [string]$n',
730
+ " if (@($env:FAUXNIX_UNSETVARS -split ';' | Where-Object { $_ -ceq $n }).Count -gt 0) { return $null }",
731
+ ' foreach ($fx_pair in @($env:FAUXNIX_SETVALS -split [string][char]10)) {',
732
+ ' $fx_eq = $fx_pair.IndexOf([char]61)',
733
+ ' if ($fx_eq -lt 1) { continue }',
734
+ ' if ($fx_pair.Substring(0, $fx_eq) -ceq $n) { return (fx-svdec $fx_pair.Substring($fx_eq + 1)) }',
735
+ ' }',
736
+ " if ($n -ceq 'HOME') { return [string]$HOME }",
737
+ " if ($n -ceq 'PWD') { return [string]$PWD.Path }",
738
+ " if ($n -ceq 'USER' -or $n -ceq 'LOGNAME') { return [string]$env:USERNAME }",
739
+ " if ($n -ceq 'PATH') { return [string]$env:PATH }",
740
+ " if ($n -ceq 'SHELL') { return 'powershell' }",
741
+ " if ($n -ceq 'TERM') { return 'xterm-256color' }",
742
+ " if ($n -ceq 'OLDPWD') { return $(if ($env:FAUXNIX_OLDPWD) { [string]$env:FAUXNIX_OLDPWD } else { $null }) }",
743
+ " if ($n -ceq 'HOSTNAME') { return [string]$env:COMPUTERNAME }",
744
+ ' $ev = Get-ChildItem Env: | Where-Object { $_.Name -ceq $n } | Select-Object -First 1',
745
+ ' if ($ev) { return [string]$ev.Value }',
746
+ ' return $null',
747
+ '}',
748
+ 'function fx-ifs1 {',
749
+ " $s = fx-scalar0 'IFS'",
750
+ " if ($null -eq $s) { return ' ' }",
751
+ " if ([string]$s -eq '') { return '' }",
752
+ ' return [string]$s[0]',
753
+ '}',
754
+ 'function fx-arrdrop($n) {',
755
+ ' $n = [string]$n',
756
+ ' $fx_sm = @()',
757
+ ' foreach ($fx_pair in @($env:FAUXNIX_ARRS -split [string][char]10)) {',
758
+ ' $fx_eq = $fx_pair.IndexOf([char]61)',
759
+ ' if ($fx_eq -lt 1) { continue }',
760
+ ' if ($fx_pair.Substring(0, $fx_eq) -cne $n) { $fx_sm += $fx_pair }',
761
+ ' }',
762
+ ' $env:FAUXNIX_ARRS = ($fx_sm -join [string][char]10)',
763
+ '}',
764
+ 'function fx-arrhas($n) {',
765
+ ' $n = [string]$n',
766
+ ' foreach ($fx_pair in @($env:FAUXNIX_ARRS -split [string][char]10)) {',
767
+ ' $fx_eq = $fx_pair.IndexOf([char]61)',
768
+ ' if ($fx_eq -lt 1) { continue }',
769
+ ' if ($fx_pair.Substring(0, $fx_eq) -ceq $n) { return $true }',
770
+ ' }',
771
+ ' return $false',
772
+ '}',
773
+ 'function fx-arrpackget($n) {',
774
+ ' $n = [string]$n',
775
+ ' foreach ($fx_pair in @($env:FAUXNIX_ARRS -split [string][char]10)) {',
776
+ ' $fx_eq = $fx_pair.IndexOf([char]61)',
777
+ ' if ($fx_eq -lt 1) { continue }',
778
+ ' if ($fx_pair.Substring(0, $fx_eq) -ceq $n) { return $fx_pair.Substring($fx_eq + 1) }',
779
+ ' }',
780
+ ' return $null',
781
+ '}',
782
+ 'function fx-arrpackset($n, $pay) {',
783
+ ' fx-arrdrop $n',
784
+ ' if ($null -eq $pay) { return }',
785
+ " $env:FAUXNIX_ARRS = ((@($env:FAUXNIX_ARRS -split [string][char]10 | Where-Object { $_ -ne '' }) + ([string]$n + [string][char]61 + [string]$pay)) -join [string][char]10)",
786
+ '}',
787
+ 'function fx-arrput($n, $vals) {',
788
+ ' $n = [string]$n',
789
+ ' $vals = @($vals)',
790
+ ' fx-arrdrop $n',
791
+ ' if ($vals.Count -eq 0) { } else {',
792
+ ' $encs = @(); foreach ($v in $vals) { $encs += (fx-svenc $v) }',
793
+ " $env:FAUXNIX_ARRS = ((@($env:FAUXNIX_ARRS -split [string][char]10 | Where-Object { $_ -ne '' }) + ($n + [string][char]61 + ($encs -join [string][char]30))) -join [string][char]10)",
794
+ ' }',
795
+ " $fx_0 = $(if ($vals.Count -gt 0) { [string]$vals[0] } else { '' })",
796
+ ' Set-Item -LiteralPath (\'Env:\\\' + $n) -Value $fx_0',
797
+ " $env:FAUXNIX_SETVARS = ((@($env:FAUXNIX_SETVARS -split ';' | Where-Object { $_ -ne '' -and $_ -cne $n }) + $n) -join ';')",
798
+ " $env:FAUXNIX_UNSETVARS = (@($env:FAUXNIX_UNSETVARS -split ';' | Where-Object { $_ -ne '' -and $_ -cne $n }) -join ';')",
799
+ ' $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 } }',
800
+ " $fx_sv += ($n + [string][char]61 + (fx-svenc $fx_0)); $env:FAUXNIX_SETVALS = ($fx_sv -join [string][char]10)",
801
+ '}',
802
+ 'function fx-arrclr($n) {',
803
+ ' $n = [string]$n',
804
+ ' fx-arrdrop $n',
805
+ " Remove-Item -LiteralPath ('Env:\\' + $n) -ErrorAction SilentlyContinue",
806
+ " $env:FAUXNIX_SETVARS = (@($env:FAUXNIX_SETVARS -split ';' | Where-Object { $_ -ne '' -and $_ -cne $n }) -join ';')",
807
+ " $env:FAUXNIX_UNSETVARS = ((@($env:FAUXNIX_UNSETVARS -split ';' | Where-Object { $_ -ne '' -and $_ -cne $n }) + $n) -join ';')",
808
+ ' $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 } }; $env:FAUXNIX_SETVALS = ($fx_sv -join [string][char]10)',
809
+ '}',
810
+ 'function fx-subget($n, $ix) {',
811
+ ' $arr = @(fx-arrload $n)',
812
+ ' $ix = [string]$ix',
813
+ // argv-level `@` is expanded by argListExpr; this is the scalar/quoted-* join.
814
+ " if ($ix -eq '*') { return ($arr -join (fx-ifs1)) }",
815
+ " if ($ix -eq '@') { return ($arr -join (fx-ifs1)) }",
816
+ ' $i = 0',
817
+ ' if (-not [int]::TryParse($ix, [ref]$i)) { return \'\' }',
818
+ " if ($i -lt 0 -or $i -ge $arr.Count) { return '' }",
819
+ ' return [string]$arr[$i]',
820
+ '}',
565
821
  'try {',
566
822
  ...body.split('\n').map((l) => ' ' + l),
567
823
  '} catch [System.Management.Automation.CommandNotFoundException] {',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fauxnix-cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.2",
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
  }