fauxnix-cli 0.9.2 → 0.9.3

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
@@ -58,6 +58,14 @@ Linux command line — file ops, text processing, process management, archives,
58
58
  maps cleanly onto PowerShell + .NET. fauxnix implements that subset faithfully and *fails loudly
59
59
  and helpfully* on what it can't translate, so the agent never gets silently-wrong results.
60
60
 
61
+ Labs now train computer-use agents on fleets of real desktops. Reporting in 2026 (*The
62
+ Information*, widely repeated) has OpenAI buying tens of thousands of Mac mini / Mac Studio
63
+ boxes — no screen, no keyboard — to reinforcement-learn agents that click, edit, test, and
64
+ run bash workflows, and Anthropic renting Mac minis through AWS for the same class of work.
65
+ That scoring environment is macOS. Windows users should not have to install a guest Unix to
66
+ keep up: the agent keeps writing bash; fauxnix makes the Windows box answer like the box the
67
+ agent was trained on. See [`docs/rfc-computer-use-windows.md`](docs/rfc-computer-use-windows.md).
68
+
61
69
  ## Install
62
70
 
63
71
  ```bash
@@ -156,7 +164,7 @@ development:
156
164
 
157
165
  `cp` / `mv` / `rm` / `touch` / `du` / `ls` / `ll` / `mkdir` / `rmdir` / `mktemp` / `ln` /
158
166
  `readlink` / `realpath` / `basename` / `dirname` / `stat` / `file` / `df` / `chmod` / `chown` /
159
- `diff` / `tee` / `grep` / `head` carry a `CommandSpec`: unknown options fail with a GNU-style
167
+ `diff` / `tee` / `grep` / `head` / `echo` / `printf` / `cat` / `tail` / `wc` carry a `CommandSpec`: unknown options fail with a GNU-style
160
168
  usage error instead of being ignored (`find` stays unspec'd so predicates like `-name` still
161
169
  compile). Implemented GNU holes: `cp -n` / `mv -n` / `touch -c` / `tee --append` / `grep -m` /
162
170
  `head --lines` / `du --max-depth`. `fauxnix list --json` and `docs/command-specs.md` dump the
@@ -214,8 +214,9 @@ const tar = (args) => {
214
214
  ' if ($fx_c) { $fx_tar = $fx_c.Source } else { $fx_tar = $null }',
215
215
  '}',
216
216
  'if ($fx_tar) {',
217
- ' & $fx_tar @($fx_args) | ForEach-Object { [string]$_ }',
218
- ' if ($LASTEXITCODE -gt 0) { $script:fx_exit = $LASTEXITCODE }',
217
+ // fx-native captures stdout as pipeline strings and sets fx_exit.
218
+ // [object[]]@(...) keeps an empty argv from unwrapping to $null on PS 5.1.
219
+ ' fx-native $fx_tar ([object[]]@($fx_args))',
219
220
  '} else {',
220
221
  " [Console]::Error.WriteLine('tar: fauxnix: tar.exe not found (Windows 10+ ships bsdtar as tar.exe)')",
221
222
  ' $script:fx_exit = 1',
@@ -55,7 +55,7 @@ function psIsLink(it) {
55
55
  /* ls */
56
56
  /* ------------------------------------------------------------------ */
57
57
  const ls = (args) => {
58
- const { flags, longs, values, operandWords } = parseWords(args, [], ['--format']);
58
+ const { flags, longs, values, operandWords } = parseWords(args, [], ['--format', '--color']);
59
59
  const long = flags.has('l') || longs.has('--long') || values.get('--format') === 'long';
60
60
  const all = flags.has('a') || longs.has('--all');
61
61
  const almost = flags.has('A') || longs.has('--almost-all');
@@ -725,7 +725,7 @@ const find = (args) => {
725
725
  const preds = raw.slice(pathEnd);
726
726
  if (preds.includes('-exec') || preds.includes('-execdir')) {
727
727
  return ('[Console]::Error.WriteLine(' +
728
- psStr('find: -exec is not supported by fauxnix; pipe into the command instead (e.g. `find . -name "*.log" | xargs rm`)') +
728
+ psStr('find: -exec is not supported by fauxnix; use `find . -name "*.log" -delete` or grep -r instead') +
729
729
  '); $script:fx_exit = 1');
730
730
  }
731
731
  const plan = parseFindPreds(preds);
@@ -967,6 +967,8 @@ export const specs = [
967
967
  opt('S', undefined),
968
968
  opt('r', undefined),
969
969
  opt('R', '--recursive', 'unsupported', { reason: 'recursive listing' }),
970
+ // GNU optional WHEN; takesValue so --color=auto is valid. No ANSI.
971
+ opt(undefined, '--color', 'implemented', { takesValue: true }),
970
972
  ], ls),
971
973
  fileSpec(['mkdir'], ['write'], [opt('p', '--parents'), opt('v', '--verbose')], mkdir),
972
974
  fileSpec(['rmdir'], ['delete'], [], rmdir),
@@ -79,10 +79,9 @@ function synthWord(text) {
79
79
  }
80
80
  /** A native-exe invocation obeying the fauxnix contract (string lines + exit code). */
81
81
  function nativeCall(exe, argArray) {
82
- return [
83
- '& ' + psStr(exe) + ' @(' + argArray + ') | ForEach-Object { [string]$_ }',
84
- 'if ($LASTEXITCODE -gt 0) { $script:fx_exit = $LASTEXITCODE }',
85
- ].join('\n');
82
+ // fx-native captures stdout as pipeline strings and sets fx_exit.
83
+ // [object[]]@(...) keeps an empty argv from unwrapping to $null on PS 5.1.
84
+ return 'fx-native ' + psStr(exe) + ' ([object[]]@(' + argArray + '))';
86
85
  }
87
86
  /* ------------------------------------------------------------------ */
88
87
  /* curl */
@@ -583,48 +583,45 @@ const grep = (args) => {
583
583
  scan.push(' $fx_mleft--');
584
584
  if (onlyMatch && !inv) {
585
585
  if (fixed) {
586
+ // GNU -o: emit leftmost-longest matches in input order, not per-needle.
586
587
  if (ci)
587
588
  scan.push(' $lx = $fx_l.ToLower()');
588
589
  const hay = ci ? '$lx' : '$fx_l';
589
- const emitFixedHits = (needle, indent) => {
590
- scan.push(indent + '$p = ' + hay + '.IndexOf(' + needle + ')');
591
- scan.push(indent + 'while ($p -ge 0) {');
592
- scan.push(indent + ' $ok = $true');
593
- if (word) {
594
- scan.push(indent +
595
- " if ($p -gt 0) { $c = " +
596
- hay +
597
- "[$p - 1]; $ok = -not ([char]::IsLetterOrDigit($c) -or $c -eq '_') }");
598
- scan.push(indent +
599
- ' if ($ok) { $e = $p + ' +
600
- needle +
601
- '.Length; if ($e -lt ' +
602
- hay +
603
- '.Length) { $c = ' +
604
- hay +
605
- "[$e]; $ok = -not ([char]::IsLetterOrDigit($c) -or $c -eq '_') } }");
606
- scan.push(indent +
607
- ' if ($ok) { fx-emitline $fx_i (' +
608
- hay +
609
- '.Substring($p, ' +
610
- needle +
611
- '.Length)) }');
612
- }
613
- else {
614
- scan.push(indent + ' fx-emitline $fx_i (' + hay + '.Substring($p, ' + needle + '.Length))');
615
- }
616
- scan.push(indent + ' $p = ' + hay + '.IndexOf(' + needle + ', $p + 1)');
617
- scan.push(indent + '}');
618
- };
619
- if (multiFixed) {
620
- const arr = ci ? '$fx_needles_ll' : '$fx_needles';
621
- scan.push(' foreach ($fx_needle in ' + arr + ') {');
622
- emitFixedHits('$fx_needle', ' ');
623
- scan.push(' }');
590
+ const needleArr = multiFixed
591
+ ? ci
592
+ ? '$fx_needles_ll'
593
+ : '$fx_needles'
594
+ : '@(' + (ci ? '$fx_needle_ll' : '$fx_needle') + ')';
595
+ scan.push(' $fx_cands = New-Object System.Collections.Generic.List[object]');
596
+ scan.push(' foreach ($fx_needle in ' + needleArr + ') {');
597
+ scan.push(' if ($fx_needle.Length -lt 1) { continue }');
598
+ scan.push(' $p = ' + hay + '.IndexOf($fx_needle)');
599
+ scan.push(' while ($p -ge 0) {');
600
+ if (word) {
601
+ scan.push(' $ok = $true');
602
+ scan.push(" if ($p -gt 0) { $c = " +
603
+ hay +
604
+ "[$p - 1]; $ok = -not ([char]::IsLetterOrDigit($c) -or $c -eq '_') }");
605
+ scan.push(' if ($ok) { $e = $p + $fx_needle.Length; if ($e -lt ' +
606
+ hay +
607
+ '.Length) { $c = ' +
608
+ hay +
609
+ "[$e]; $ok = -not ([char]::IsLetterOrDigit($c) -or $c -eq '_') } }");
610
+ scan.push(' if ($ok) { [void]$fx_cands.Add([pscustomobject]@{ Start = $p; Len = $fx_needle.Length }) }');
624
611
  }
625
612
  else {
626
- emitFixedHits(ci ? '$fx_needle_ll' : '$fx_needle', ' ');
613
+ scan.push(' [void]$fx_cands.Add([pscustomobject]@{ Start = $p; Len = $fx_needle.Length })');
627
614
  }
615
+ scan.push(' $p = ' + hay + '.IndexOf($fx_needle, $p + 1)');
616
+ scan.push(' }');
617
+ scan.push(' }');
618
+ scan.push(' $fx_end = 0');
619
+ scan.push(' foreach ($fx_c in @($fx_cands | Sort-Object Start, @{ Expression = { $_.Len }; Descending = $true })) {');
620
+ scan.push(' if ($fx_c.Start -ge $fx_end) {');
621
+ scan.push(' fx-emitline $fx_i (' + hay + '.Substring($fx_c.Start, $fx_c.Len))');
622
+ scan.push(' $fx_end = $fx_c.Start + $fx_c.Len');
623
+ scan.push(' }');
624
+ scan.push(' }');
628
625
  }
629
626
  else {
630
627
  scan.push(' foreach ($fx_m in $fx_re.Matches($fx_l)) { fx-emitline $fx_i $fx_m.Value }');
@@ -1093,6 +1093,9 @@ const xargs = (args) => {
1093
1093
  noRunIfEmpty = true;
1094
1094
  else if (ch === 't')
1095
1095
  trace = true;
1096
+ else if (ch === '0') {
1097
+ return psErrExpr(psStr('xargs: -0 is not supported by fauxnix'));
1098
+ }
1096
1099
  else if (ch === 'n' || ch === 'I' || ch === 'L') {
1097
1100
  const restv = body.slice(c + 1);
1098
1101
  let val;
@@ -1140,12 +1143,12 @@ const xargs = (args) => {
1140
1143
  }
1141
1144
  const n = chunkN !== null && Number.isFinite(chunkN) && chunkN > 0 ? chunkN : 0;
1142
1145
  const replExpr = repl !== null ? psStr(repl) : null;
1146
+ // fx-native already records $script:fx_exit from ExitCode.
1143
1147
  const invoke = [
1144
1148
  ' if (' +
1145
1149
  pb(trace) +
1146
1150
  ") { [Console]::Error.WriteLine(((@($fx_cmd) + @($fx_argv)) -join ' ')) }",
1147
- ' & $fx_cmd @fx_argv',
1148
- ' if ($LASTEXITCODE -ne 0 -and $script:fx_exit -eq 0) { $script:fx_exit = $LASTEXITCODE }',
1151
+ ' fx-native $fx_cmd $fx_argv',
1149
1152
  ];
1150
1153
  let dispatch;
1151
1154
  const guard = ' if (' + pb(noRunIfEmpty) + ' -and $fx_args.Count -eq 0) { }' + '\n' + ' else {';
@@ -1154,7 +1157,7 @@ const xargs = (args) => {
1154
1157
  dispatch = [
1155
1158
  guard,
1156
1159
  ' foreach ($fx_l in $fx_args) {',
1157
- ' $fx_argv = @()',
1160
+ ' $fx_argv = [object[]]@()',
1158
1161
  ' $fx_hit = $false',
1159
1162
  ' foreach ($fx_a in $fx_base) {',
1160
1163
  ' if ($fx_a.Contains(' + replExpr + ')) {',
@@ -1175,7 +1178,7 @@ const xargs = (args) => {
1175
1178
  ' $fx_i = 0',
1176
1179
  ' $fx_ran = $false',
1177
1180
  ' while ($fx_i -lt $fx_args.Count) {',
1178
- ' $fx_argv = @($fx_base)',
1181
+ ' $fx_argv = [object[]]@($fx_base)',
1179
1182
  ' $fx_j = 0',
1180
1183
  ' while ($fx_j -lt ' + n + ' -and $fx_i -lt $fx_args.Count) {',
1181
1184
  ' $fx_argv += $fx_args[$fx_i]',
@@ -1185,10 +1188,8 @@ const xargs = (args) => {
1185
1188
  ...invoke,
1186
1189
  ' }',
1187
1190
  ' if (-not $fx_ran) {',
1188
- ' $fx_argv = @($fx_base)',
1189
- ' ' + invoke[0],
1190
- ' ' + invoke[1],
1191
- ' ' + invoke[2],
1191
+ ' $fx_argv = [object[]]@($fx_base)',
1192
+ ...invoke.map((line) => ' ' + line),
1192
1193
  ' }',
1193
1194
  ' }',
1194
1195
  ];
@@ -1196,17 +1197,27 @@ const xargs = (args) => {
1196
1197
  else {
1197
1198
  dispatch = [
1198
1199
  guard,
1199
- ' $fx_argv = @($fx_base) + @($fx_args)',
1200
+ ' $fx_argv = [object[]](@($fx_base) + @($fx_args))',
1200
1201
  ...invoke,
1201
1202
  ' }',
1202
1203
  ];
1203
1204
  }
1205
+ // Default GNU xargs splits on blanks; -I keeps whole lines as one item.
1206
+ const collectArgs = replExpr !== null
1207
+ ? "$fx_args = @($fx_in | Where-Object { $_ -ne '' })"
1208
+ : [
1209
+ '$fx_args = @()',
1210
+ 'foreach ($fx_l in $fx_in) {',
1211
+ " if ($fx_l -eq '') { continue }",
1212
+ " $fx_args += @($fx_l -split '[ \\t]+' | Where-Object { $_ -ne '' })",
1213
+ '}',
1214
+ ].join('\n');
1204
1215
  return [
1205
1216
  PS_SPLITLINES_FN,
1206
1217
  STDIN_INLINES,
1207
1218
  '$fx_tg = ' + argListExpr(target, exprOfWord),
1208
1219
  "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 { @() }) }",
1209
- "$fx_args = @($fx_in | Where-Object { $_ -ne '' })",
1220
+ collectArgs,
1210
1221
  ...dispatch,
1211
1222
  ].join('\n');
1212
1223
  };
@@ -1236,6 +1247,74 @@ export const specs = [
1236
1247
  dispatch: 'translated',
1237
1248
  handler: head,
1238
1249
  },
1250
+ {
1251
+ names: ['echo'],
1252
+ options: [
1253
+ { short: 'n', support: 'implemented' },
1254
+ { short: 'e', support: 'implemented' },
1255
+ { short: 'E', support: 'implemented' },
1256
+ ],
1257
+ effects: [],
1258
+ platform: 'windows-ps51',
1259
+ dispatch: 'translated',
1260
+ usageExit: 2,
1261
+ leadingOptions: true,
1262
+ handler: echo,
1263
+ },
1264
+ {
1265
+ names: ['printf'],
1266
+ options: [],
1267
+ effects: [],
1268
+ platform: 'windows-ps51',
1269
+ dispatch: 'translated',
1270
+ usageExit: 2,
1271
+ leadingOptions: true,
1272
+ handler: printf,
1273
+ },
1274
+ {
1275
+ names: ['cat'],
1276
+ options: [
1277
+ { short: 'n', support: 'implemented' },
1278
+ { short: 'b', support: 'implemented' },
1279
+ { short: 's', support: 'implemented' },
1280
+ { short: 'E', support: 'implemented' },
1281
+ { short: 'T', support: 'implemented' },
1282
+ { short: 'A', support: 'implemented' },
1283
+ ],
1284
+ effects: ['read'],
1285
+ platform: 'windows-ps51',
1286
+ dispatch: 'translated',
1287
+ handler: cat,
1288
+ },
1289
+ {
1290
+ names: ['tail'],
1291
+ options: [
1292
+ { short: 'n', long: '--lines', takesValue: true, support: 'implemented' },
1293
+ { short: 'c', long: '--bytes', takesValue: true, support: 'implemented' },
1294
+ { short: 'q', long: '--quiet', support: 'implemented' },
1295
+ { long: '--silent', support: 'implemented' },
1296
+ { short: 'v', long: '--verbose', support: 'implemented' },
1297
+ { short: 'f', support: 'unsupported', reason: 'no persistent tty' },
1298
+ { short: 'F', support: 'unsupported', reason: 'no persistent tty' },
1299
+ ],
1300
+ effects: ['read'],
1301
+ platform: 'windows-ps51',
1302
+ dispatch: 'translated',
1303
+ handler: tail,
1304
+ },
1305
+ {
1306
+ names: ['wc'],
1307
+ options: [
1308
+ { short: 'l', support: 'implemented' },
1309
+ { short: 'w', support: 'implemented' },
1310
+ { short: 'c', support: 'implemented' },
1311
+ { short: 'm', support: 'implemented' },
1312
+ ],
1313
+ effects: ['read'],
1314
+ platform: 'windows-ps51',
1315
+ dispatch: 'translated',
1316
+ handler: wc,
1317
+ },
1239
1318
  ];
1240
1319
  export const handlers = {
1241
1320
  echo,
package/dist/errors.d.ts CHANGED
@@ -2,4 +2,8 @@
2
2
  * Error normalization — make PowerShell failures look like bash failures
3
3
  * so agents can pattern-match on familiar Linux error styles.
4
4
  */
5
+ /** Missing `python3` on Windows (Mac/Linux agents emit this; do not alias). */
6
+ export declare const PYTHON3_WINDOWS_HINT = " (fauxnix: try `python` or `py` on Windows)";
7
+ /** `.sh` cannot be CreateProcess'd; fx-native never emits the PS not-recognized line. */
8
+ export declare const SH_SCRIPT_WINDOWS_HINT = " (fauxnix: .sh scripts cannot run natively on Windows)";
5
9
  export declare function normalizeStderr(stderr: string): string;
package/dist/errors.js CHANGED
@@ -2,6 +2,19 @@
2
2
  * Error normalization — make PowerShell failures look like bash failures
3
3
  * so agents can pattern-match on familiar Linux error styles.
4
4
  */
5
+ /** Missing `python3` on Windows (Mac/Linux agents emit this; do not alias). */
6
+ export const PYTHON3_WINDOWS_HINT = ' (fauxnix: try `python` or `py` on Windows)';
7
+ /** `.sh` cannot be CreateProcess'd; fx-native never emits the PS not-recognized line. */
8
+ export const SH_SCRIPT_WINDOWS_HINT = ' (fauxnix: .sh scripts cannot run natively on Windows)';
9
+ function commandNotFound(name) {
10
+ const msg = 'bash: ' + name + ': command not found';
11
+ const base = name.replace(/^.*[/\\]/, '');
12
+ if (/^python3(\.exe)?$/i.test(base))
13
+ return msg + PYTHON3_WINDOWS_HINT;
14
+ if (/\.sh$/i.test(name))
15
+ return msg + SH_SCRIPT_WINDOWS_HINT;
16
+ return msg;
17
+ }
5
18
  /** Lines produced by PowerShell error formatting that bash would never show. */
6
19
  const PS_NOISE = [
7
20
  /^\s*\+ CategoryInfo\s*:/,
@@ -62,15 +75,15 @@ export function normalizeStderr(stderr) {
62
75
  // "The term 'x' is not recognized as a name of a cmdlet, function, ..."
63
76
  let m = line.match(/^The term '(.+?)' is not recognized/);
64
77
  if (m)
65
- return 'bash: ' + m[1] + ': command not found';
78
+ return commandNotFound(m[1]);
66
79
  // zh-CN: 无法将"x"项识别为 cmdlet、函数、脚本文件或可运行程序的名称
67
80
  m = line.match(/^无法将["'”]?([^"'”]+)["'”]?项识别为/);
68
81
  if (m)
69
- return 'bash: ' + m[1] + ': command not found';
82
+ return commandNotFound(m[1]);
70
83
  // "x : The term 'y' is not recognized ..." (with source prefix)
71
84
  m = line.match(/^(\S+)\s*:\s*The term '(.+?)' is not recognized/);
72
85
  if (m)
73
- return 'bash: ' + m[2] + ': command not found';
86
+ return commandNotFound(m[2]);
74
87
  // "cat : Cannot find path 'D:\x' because it does not exist."
75
88
  m = line.match(/^(\S+)\s*:\s*Cannot find path '(.+?)' because it does not exist\.?$/);
76
89
  if (m) {
@@ -93,9 +106,9 @@ export function normalizeStderr(stderr) {
93
106
  m = line.match(/^(\S+)\s*:\s*(.*)Access to the path '(.+?)' is denied\.?$/);
94
107
  if (m)
95
108
  return m[1].toLowerCase() + ': cannot remove \'' + m[3] + '\': Permission denied';
96
- // helpful hint for bash scripts
109
+ // leftover PS not-recognized lines that the rewrites above did not catch
97
110
  if (/\.sh'?/.test(line) && /is not recognized/.test(line)) {
98
- return line + ' (fauxnix: .sh scripts cannot run natively on Windows)';
111
+ return line + SH_SCRIPT_WINDOWS_HINT;
99
112
  }
100
113
  return line;
101
114
  });
package/dist/executor.js CHANGED
@@ -26,7 +26,35 @@ function isNulPath(p) {
26
26
  const base = p.split(/[/\\]/).pop() ?? p;
27
27
  return /^NUL$/i.test(base);
28
28
  }
29
- function emitToPrepDest(dest, msg, fds, fallback) {
29
+ function applyRedirectDest(op, target, stdout, stderr) {
30
+ if (op === '2>&1')
31
+ return { stdout, stderr: stdout };
32
+ if (op === '1>&2')
33
+ return { stdout: stderr, stderr };
34
+ if (op === '<' || target === undefined)
35
+ return { stdout, stderr };
36
+ const dest = isNulPath(target) ? { kind: 'nul' } : { kind: 'file', path: target };
37
+ if (op === '>' || op === '>>')
38
+ return { stdout: dest, stderr };
39
+ if (op === '2>' || op === '2>>')
40
+ return { stdout, stderr: dest };
41
+ if (op === '&>' || op === '&>>')
42
+ return { stdout: dest, stderr: dest };
43
+ return { stdout, stderr };
44
+ }
45
+ /** Last-stage output fds only — captured stdout/stderr apply. */
46
+ function lastStageOutputDests(redirects, resolveTarget) {
47
+ let stdout = { kind: 'caller', fd: 1 };
48
+ let stderr = { kind: 'caller', fd: 2 };
49
+ for (const r of redirects) {
50
+ const target = r.op === '2>&1' || r.op === '1>&2' || r.op === '<'
51
+ ? undefined
52
+ : resolveTarget(winTarget(r.target));
53
+ ({ stdout, stderr } = applyRedirectDest(r.op, target, stdout, stderr));
54
+ }
55
+ return { stdout, stderr };
56
+ }
57
+ function emitToPrepDest(dest, msg, fds, caller) {
30
58
  if (dest.kind === 'nul')
31
59
  return;
32
60
  if (dest.kind === 'file') {
@@ -35,10 +63,14 @@ function emitToPrepDest(dest, msg, fds, fallback) {
35
63
  return;
36
64
  }
37
65
  catch {
38
- /* fall through to caller */
66
+ caller.stderr(msg);
67
+ return;
39
68
  }
40
69
  }
41
- fallback(msg);
70
+ if (dest.fd === 1)
71
+ caller.stdout(msg);
72
+ else
73
+ caller.stderr(msg);
42
74
  }
43
75
  function writeAllSync(fd, data) {
44
76
  let off = 0;
@@ -94,9 +126,6 @@ function planRedirects(redirects) {
94
126
  appendStdout: false,
95
127
  stderrFile: null,
96
128
  appendStderr: false,
97
- mergeStderr: false,
98
- devNull: false,
99
- swallowStderr: false,
100
129
  };
101
130
  for (const red of redirects) {
102
131
  const target = winTarget(red.target);
@@ -107,41 +136,30 @@ function planRedirects(redirects) {
107
136
  case '>':
108
137
  case '&>':
109
138
  if (isNulPath(target)) {
110
- r.devNull = true;
111
139
  r.stdoutFile = null;
112
- if (red.op === '&>') {
113
- r.swallowStderr = true;
140
+ if (red.op === '&>')
114
141
  r.stderrFile = null;
115
- }
116
142
  }
117
143
  else {
118
- r.devNull = false;
119
144
  r.stdoutFile = target;
120
145
  r.appendStdout = false;
121
- if (red.op === '&>') {
146
+ if (red.op === '&>')
122
147
  r.stderrFile = target;
123
- r.swallowStderr = false;
124
- }
125
148
  }
126
149
  break;
127
150
  case '>>':
128
151
  case '&>>':
129
152
  if (isNulPath(target)) {
130
- r.devNull = true;
131
153
  r.stdoutFile = null;
132
- if (red.op === '&>>') {
133
- r.swallowStderr = true;
154
+ if (red.op === '&>>')
134
155
  r.stderrFile = null;
135
- }
136
156
  }
137
157
  else {
138
- r.devNull = false;
139
158
  r.stdoutFile = target;
140
159
  r.appendStdout = true;
141
160
  if (red.op === '&>>') {
142
161
  r.stderrFile = target;
143
162
  r.appendStderr = true;
144
- r.swallowStderr = false;
145
163
  }
146
164
  }
147
165
  break;
@@ -149,31 +167,22 @@ function planRedirects(redirects) {
149
167
  if (isNulPath(target)) {
150
168
  // stderr only — must not undo a prior >/dev/null
151
169
  r.stderrFile = null;
152
- r.swallowStderr = true;
153
170
  }
154
171
  else {
155
172
  r.stderrFile = target;
156
173
  r.appendStderr = false;
157
- r.swallowStderr = false;
158
174
  }
159
175
  break;
160
176
  case '2>>':
161
177
  if (isNulPath(target)) {
162
178
  r.stderrFile = null;
163
- r.swallowStderr = true;
164
179
  }
165
180
  else {
166
181
  r.stderrFile = target;
167
182
  r.appendStderr = true;
168
- r.swallowStderr = false;
169
183
  }
170
184
  break;
171
- case '2>&1':
172
- r.mergeStderr = true;
173
- break;
174
- case '1>&2':
175
- r.mergeStderr = false;
176
- r.stdoutToStderr = true;
185
+ default:
177
186
  break;
178
187
  }
179
188
  }
@@ -364,18 +373,19 @@ async function runPlans(plans, session, opts, afterSegment, ensureHost) {
364
373
  let redirectPrepFailed = false;
365
374
  // Snapshot fd destinations as we walk. `2>&1` copies stdout *at that
366
375
  // moment*; a later `>file` must not drag stderr along (bash fd dup).
367
- let prepStdout = { kind: 'caller' };
368
- let prepStderr = { kind: 'caller' };
369
- const emitPrepError = (msg) => emitToPrepDest(prepStderr, msg, prepFds, (s) => {
370
- stderr += s;
376
+ let prepStdout = { kind: 'caller', fd: 1 };
377
+ let prepStderr = { kind: 'caller', fd: 2 };
378
+ const emitPrepError = (msg) => emitToPrepDest(prepStderr, msg, prepFds, {
379
+ stdout: (s) => {
380
+ stdout += s;
381
+ },
382
+ stderr: (s) => {
383
+ stderr += s;
384
+ },
371
385
  });
372
386
  for (const r of plan.redirects) {
373
- if (r.op === '2>&1') {
374
- prepStderr = prepStdout;
375
- continue;
376
- }
377
- if (r.op === '1>&2') {
378
- prepStdout = prepStderr;
387
+ if (r.op === '2>&1' || r.op === '1>&2') {
388
+ ({ stdout: prepStdout, stderr: prepStderr } = applyRedirectDest(r.op, undefined, prepStdout, prepStderr));
379
389
  continue;
380
390
  }
381
391
  const target = resolveTarget(winTarget(r.target));
@@ -389,33 +399,16 @@ async function runPlans(plans, session, opts, afterSegment, ensureHost) {
389
399
  }
390
400
  continue;
391
401
  }
392
- if (isNulPath(target)) {
393
- if (r.op === '>' || r.op === '>>')
394
- prepStdout = { kind: 'nul' };
395
- else if (r.op === '2>' || r.op === '2>>')
396
- prepStderr = { kind: 'nul' };
397
- else if (r.op === '&>' || r.op === '&>>') {
398
- prepStdout = { kind: 'nul' };
399
- prepStderr = { kind: 'nul' };
402
+ if (!isNulPath(target)) {
403
+ const append = r.op === '>>' || r.op === '2>>' || r.op === '&>>';
404
+ const fail = prepareRedirectFile(target, append, prepFds);
405
+ if (fail) {
406
+ emitPrepError('bash: ' + fail + '\n');
407
+ redirectPrepFailed = true;
408
+ break;
400
409
  }
401
- continue;
402
- }
403
- const append = r.op === '>>' || r.op === '2>>' || r.op === '&>>';
404
- const fail = prepareRedirectFile(target, append, prepFds);
405
- if (fail) {
406
- emitPrepError('bash: ' + fail + '\n');
407
- redirectPrepFailed = true;
408
- break;
409
- }
410
- const fileDest = { kind: 'file', path: target };
411
- if (r.op === '>' || r.op === '>>')
412
- prepStdout = fileDest;
413
- else if (r.op === '2>' || r.op === '2>>')
414
- prepStderr = fileDest;
415
- else if (r.op === '&>' || r.op === '&>>') {
416
- prepStdout = fileDest;
417
- prepStderr = fileDest;
418
410
  }
411
+ ({ stdout: prepStdout, stderr: prepStderr } = applyRedirectDest(r.op, target, prepStdout, prepStderr));
419
412
  }
420
413
  if (redirectPrepFailed) {
421
414
  exitCode = 1;
@@ -438,11 +431,15 @@ async function runPlans(plans, session, opts, afterSegment, ensureHost) {
438
431
  break;
439
432
  }
440
433
  const encoded = wrapScript(plan.body, { mode: 'host' });
434
+ // Last-stage fds last-win independently. `2>&1 >/dev/null` snapshots
435
+ // stderr onto the caller's stdout before stdout is pointed at NUL, so
436
+ // captured stderr is still returned; `>/dev/null 2>&1` points both at NUL.
437
+ const applyDests = lastStageOutputDests(plan.outputRedirects, resolveTarget);
441
438
  // Response budgets cap what the CALLER receives — streams redirected to
442
439
  // files must never be truncated by them (Codex-review P1: `printf … > f`
443
440
  // with a small stdoutLimit was writing a clipped file). The final
444
441
  // clipUtf8 below still enforces the returned-data budget.
445
- const fileRedirected = !!(red.stdoutFile || red.stderrFile);
442
+ const fileRedirected = applyDests.stdout.kind === 'file' || applyDests.stderr.kind === 'file';
446
443
  const inv = await ensureHost().invoke(encoded, {
447
444
  FAUXNIX_CWD: currentDir,
448
445
  FAUXNIX_PREV_EXIT: session.prevExit === null ? '' : String(session.prevExit),
@@ -470,52 +467,45 @@ async function runPlans(plans, session, opts, afterSegment, ensureHost) {
470
467
  // GNU line discipline: the PS host terminates Write-Output lines with
471
468
  // CRLF. Exact writers (fx-write / printf / echo -n) must keep embedded
472
469
  // CR so `printf 'a\r\nb' > out` stays 4 bytes.
473
- let segOut = normalizeHostNewlines(decodeOutput(inv.stdout, decodePref));
470
+ const segOut = normalizeHostNewlines(decodeOutput(inv.stdout, decodePref));
474
471
  let segErr = normalizeHostNewlines(normalizeStderr(decodeOutput(inv.stderr, decodePref)));
475
472
  if (inv.timedOut) {
476
473
  segErr += timeoutMessage;
477
474
  }
478
- if (red.mergeStderr) {
479
- segOut += (segOut && !segOut.endsWith('\n') && segErr ? '\n' : '') + segErr;
480
- segErr = '';
481
- }
482
- const stdoutToStderr = red.stdoutToStderr;
483
- if (stdoutToStderr) {
484
- segErr += segOut;
485
- segOut = '';
486
- }
487
- if (red.swallowStderr)
488
- segErr = '';
489
- if (red.devNull)
490
- segOut = '';
491
475
  // Write captured streams through the fds opened during preflight
492
476
  // (bash: the redirect refers to the open file, not the path). Reopening
493
477
  // the path would recreate a file the command just unlinked
494
478
  // (`rm out.txt > out.txt`).
495
479
  let redirectOk = true;
496
- if (red.stdoutFile) {
497
- try {
498
- writeToPrepFd(prepFds, red.stdoutFile, segOut);
499
- segOut = '';
500
- }
501
- catch (e) {
502
- segErr += 'bash: ' + red.stdoutFile + ': cannot create: ' + e.message + '\n';
503
- exitCode = 1;
504
- redirectOk = false;
505
- }
506
- }
507
- if (red.stderrFile) {
508
- try {
509
- const body = red.stderrFile === red.stdoutFile ? segOut + segErr : segErr;
510
- writeToPrepFd(prepFds, red.stderrFile, body);
511
- segErr = '';
512
- }
513
- catch {
514
- /* best effort */
480
+ const deliverCaptured = (dest, data, fromStdout) => {
481
+ if (!data)
482
+ return;
483
+ if (dest.kind === 'nul')
484
+ return;
485
+ if (dest.kind === 'file') {
486
+ try {
487
+ writeToPrepFd(prepFds, dest.path, data);
488
+ }
489
+ catch (e) {
490
+ if (fromStdout) {
491
+ stderr += 'bash: ' + dest.path + ': cannot create: ' + e.message + '\n';
492
+ exitCode = 1;
493
+ redirectOk = false;
494
+ stdout += data;
495
+ }
496
+ else {
497
+ stderr += data;
498
+ }
499
+ }
500
+ return;
515
501
  }
516
- }
517
- stdout += segOut;
518
- stderr += segErr;
502
+ if (dest.fd === 1)
503
+ stdout += data;
504
+ else
505
+ stderr += data;
506
+ };
507
+ deliverCaptured(applyDests.stdout, segOut, true);
508
+ deliverCaptured(applyDests.stderr, segErr, false);
519
509
  if (inv.truncated)
520
510
  truncated = true;
521
511
  exitCode = inv.timedOut ? 124 : inv.cancelled ? 130 : inv.exitCode;
@@ -99,6 +99,8 @@ export interface CommandSpec {
99
99
  dispatch?: 'translated' | 'native' | 'dynamic';
100
100
  /** GNU usage/syntax exit (grep uses 2; cp/mv/rm use 1). */
101
101
  usageExit?: number;
102
+ /** First non-option operand ends option scanning (echo/printf). */
103
+ leadingOptions?: boolean;
102
104
  handler: Handler;
103
105
  }
104
106
  /** Register a spec'd command. Unknown/unsupported options become GNU-style usage errors. */
package/dist/registry.js CHANGED
@@ -197,7 +197,8 @@ export function specsMarkdown() {
197
197
  for (const spec of registeredSpecs()) {
198
198
  lines.push('## `' + spec.names.join('` / `') + '`');
199
199
  lines.push('');
200
- lines.push('Effects: ' + spec.effects.map((e) => '`' + e + '`').join(', '));
200
+ lines.push('Effects: ' +
201
+ (spec.effects.length ? spec.effects.map((e) => '`' + e + '`').join(', ') : 'none'));
201
202
  lines.push('');
202
203
  if (!spec.options.length) {
203
204
  lines.push('No options declared.');
@@ -319,6 +320,8 @@ export function specOptionError(spec, args, cmdName) {
319
320
  i++;
320
321
  continue;
321
322
  }
323
+ if (spec.leadingOptions)
324
+ onlyOperands = true;
322
325
  i++;
323
326
  }
324
327
  return null;
@@ -1,6 +1,7 @@
1
1
  import { FauxnixParseError, isUnquotedLiteral, } from './ast.js';
2
2
  import { parseCommand } from './parser.js';
3
3
  import { lookup, psStr } from './registry.js';
4
+ import { PYTHON3_WINDOWS_HINT, SH_SCRIPT_WINDOWS_HINT } from './errors.js';
4
5
  /* ------------------------------------------------------------------ */
5
6
  /* Variable mapping */
6
7
  /* ------------------------------------------------------------------ */
@@ -1216,8 +1217,9 @@ export function wrapScript(body, opts = {}) {
1216
1217
  '}',
1217
1218
  ],
1218
1219
  'fx-winargv': [
1219
- 'function fx-winargv($argv) {',
1220
+ 'function fx-winargv($argv, $cmdmeta) {',
1220
1221
  // Empty [object[]] unwraps to $null on PS 5.1; @($null) is one empty arg.
1222
+ // $cmdmeta: also quote & | () <> ^ so cmd.exe /c does not split the tail.
1221
1223
  ' if ($null -eq $argv) { $argv = @() }',
1222
1224
  ' $parts = New-Object System.Collections.Generic.List[string]',
1223
1225
  ' foreach ($a in @($argv)) {',
@@ -1226,6 +1228,7 @@ export function wrapScript(body, opts = {}) {
1226
1228
  ' $need = $false',
1227
1229
  ' foreach ($ch in $s.ToCharArray()) {',
1228
1230
  " if ($ch -eq ' ' -or $ch -eq ([char]9) -or $ch -eq [char]34) { $need = $true; break }",
1231
+ " if ($cmdmeta -and ($ch -eq '&' -or $ch -eq '|' -or $ch -eq '(' -or $ch -eq ')' -or $ch -eq '<' -or $ch -eq '>' -or $ch -eq '^')) { $need = $true; break }",
1229
1232
  ' }',
1230
1233
  ' if (-not $need) { $parts.Add($s); continue }',
1231
1234
  ' $sb = New-Object System.Text.StringBuilder',
@@ -1259,7 +1262,14 @@ export function wrapScript(body, opts = {}) {
1259
1262
  // operator is only for names that are not executables.
1260
1263
  ' $cmd = Get-Command -Name $name -ErrorAction SilentlyContinue | Select-Object -First 1',
1261
1264
  ' if ($null -eq $cmd) {',
1262
- " [Console]::Error.WriteLine('bash: ' + $name + ': command not found')",
1265
+ " $fx_nf = 'bash: ' + $name + ': command not found'",
1266
+ " $fx_n = [string]$name",
1267
+ // Hint only — never alias python3→python (wrong interpreter).
1268
+ " if ($fx_n -eq 'python3' -or $fx_n -eq 'python3.exe') { $fx_nf += '" +
1269
+ PYTHON3_WINDOWS_HINT +
1270
+ "' }",
1271
+ " elseif ($fx_n -like '*.sh') { $fx_nf += '" + SH_SCRIPT_WINDOWS_HINT + "' }",
1272
+ ' [Console]::Error.WriteLine($fx_nf)',
1263
1273
  ' $script:fx_exit = 127',
1264
1274
  ' return',
1265
1275
  ' }',
@@ -1272,6 +1282,8 @@ export function wrapScript(body, opts = {}) {
1272
1282
  ' $psi = New-Object System.Diagnostics.ProcessStartInfo',
1273
1283
  ' $ext = [IO.Path]::GetExtension([string]$app.Source)',
1274
1284
  // CreateProcess cannot launch .cmd/.bat with UseShellExecute=false (npm.cmd).
1285
+ // /s strips one outer quote pair from the /c tail; CRT-quote cmd
1286
+ // metacharacters so `&`/`|`/`()` do not start a second command.
1275
1287
  " if ($ext -eq '.cmd' -or $ext -eq '.bat') {",
1276
1288
  ' $comspec = Get-Command -Name cmd -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1',
1277
1289
  ' if ($null -eq $comspec) {',
@@ -1280,8 +1292,10 @@ export function wrapScript(body, opts = {}) {
1280
1292
  ' return',
1281
1293
  ' }',
1282
1294
  ' $psi.FileName = $comspec.Source',
1283
- ' $fx_rest = fx-winargv $argv',
1284
- " if ($fx_rest.Length -gt 0) { $psi.Arguments = '/d /s /c ' + (fx-winargv $app.Source) + ' ' + $fx_rest } else { $psi.Arguments = '/d /s /c ' + (fx-winargv $app.Source) }",
1295
+ ' $fx_app = fx-winargv $app.Source $true',
1296
+ ' $fx_rest = fx-winargv $argv $true',
1297
+ " if ($fx_rest.Length -gt 0) { $fx_tail = $fx_app + ' ' + $fx_rest } else { $fx_tail = $fx_app }",
1298
+ ' $psi.Arguments = \'/d /s /c "\' + $fx_tail + \'"\'',
1285
1299
  ' } else {',
1286
1300
  ' $psi.FileName = $app.Source',
1287
1301
  ' $psi.Arguments = fx-winargv $argv',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fauxnix-cli",
3
- "version": "0.9.2",
3
+ "version": "0.9.3",
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": {