fauxnix-cli 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -2
- package/dist/ast.d.ts +26 -4
- package/dist/ast.js +3 -3
- package/dist/commands/sysinfo.js +184 -9
- package/dist/executor.js +9 -1
- package/dist/mcp.js +45 -15
- package/dist/parser.js +166 -3
- package/dist/translator.d.ts +8 -8
- package/dist/translator.js +152 -16
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# fauxnix
|
|
2
2
|
|
|
3
|
+
[](https://github.com/20000419/fauxnix/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/fauxnix-cli)
|
|
5
|
+
[](https://www.npmjs.com/package/fauxnix-cli)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
[](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
|
+

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