fauxnix-cli 0.1.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.
@@ -0,0 +1,318 @@
1
+ import { FauxnixParseError, } from './ast.js';
2
+ import { parseCommand } from './parser.js';
3
+ import { lookup, psStr } from './registry.js';
4
+ /* ------------------------------------------------------------------ */
5
+ /* Variable mapping */
6
+ /* ------------------------------------------------------------------ */
7
+ /** Map a bash $VAR name to a PowerShell expression (usable inside $(...)). */
8
+ export function varExpr(name) {
9
+ switch (name) {
10
+ case 'HOME':
11
+ return '$HOME';
12
+ case 'PWD':
13
+ return '$PWD.Path';
14
+ case 'USER':
15
+ case 'LOGNAME':
16
+ return '$env:USERNAME';
17
+ case 'PATH':
18
+ return '$env:PATH';
19
+ case 'SHELL':
20
+ return "'powershell'";
21
+ case 'TERM':
22
+ return "'xterm-256color'";
23
+ case 'OLDPWD':
24
+ return '$env:FAUXNIX_OLDPWD';
25
+ case '?':
26
+ return '[string]$fx_prev';
27
+ case '$':
28
+ return '[string]$PID';
29
+ case 'HOSTNAME':
30
+ return '$env:COMPUTERNAME';
31
+ default:
32
+ return '$env:' + name;
33
+ }
34
+ }
35
+ /* ------------------------------------------------------------------ */
36
+ /* Word → PowerShell expression */
37
+ /* ------------------------------------------------------------------ */
38
+ /** Escape text destined for the inside of a PS double-quoted string. */
39
+ function escDq(s) {
40
+ return s.replace(/`/g, '``').replace(/"/g, '\\"').replace(/\$/g, '`$');
41
+ }
42
+ /** Normalize a literal POSIX-ish path to its Windows equivalent. */
43
+ export function normalizeLiteralPath(s) {
44
+ if (s === '/dev/null')
45
+ return 'NUL';
46
+ if (s === '/tmp')
47
+ return '$env:TEMP';
48
+ if (s.startsWith('/tmp/')) {
49
+ const rest = s.slice(5).split('/').join('\\');
50
+ return '$env:TEMP' + '\\' + rest;
51
+ }
52
+ // Git-Bash drive mounts: /d/foo → d:\foo
53
+ const m = s.match(/^\/([a-zA-Z])\/(.*)$/);
54
+ if (m) {
55
+ const drive = m[1].toUpperCase();
56
+ const tail = m[2].split('/').join('\\');
57
+ return drive + ':\\' + tail;
58
+ }
59
+ const onlyDrive = s.match(/^\/([a-zA-Z])\/?$/);
60
+ if (onlyDrive)
61
+ return onlyDrive[1].toUpperCase() + ':\\';
62
+ return s;
63
+ }
64
+ /**
65
+ * Convert a normalized literal path (see normalizeLiteralPath) into a valid
66
+ * PowerShell string *expression*. Paths that normalize to `$env:TEMP...`
67
+ * must NOT go through single-quoting — the variable has to stay expandable.
68
+ */
69
+ export function pathExpr(s) {
70
+ const prefix = '$env:TEMP';
71
+ if (s === prefix)
72
+ return prefix;
73
+ if (s.startsWith(prefix + '\\')) {
74
+ return '(' + prefix + ' + ' + psStr(s.slice(prefix.length)) + ')';
75
+ }
76
+ return psStr(s);
77
+ }
78
+ /**
79
+ * Convert a Word to a PowerShell string expression.
80
+ * Literal words become single-quoted strings; dynamic ones become
81
+ * double-quoted strings with $(...) interpolation.
82
+ */
83
+ export function exprOfWord(w) {
84
+ // tilde expansion (unquoted leading ~)
85
+ const expanded = [];
86
+ if (w.length > 0 && w[0].kind === 'Text' && w[0].text.startsWith('~')) {
87
+ expanded.push({ kind: 'Var', name: 'HOME' });
88
+ const rest = w[0].text.slice(1);
89
+ if (rest)
90
+ expanded.push({ kind: 'Text', text: rest });
91
+ expanded.push(...w.slice(1));
92
+ }
93
+ else {
94
+ expanded.push(...w);
95
+ }
96
+ // single bare variable → bare expression
97
+ if (expanded.length === 1 && expanded[0].kind === 'Var') {
98
+ return varExpr(expanded[0].name);
99
+ }
100
+ const literal = expanded.every((p) => p.kind === 'Text' || p.kind === 'SingleQuoted');
101
+ if (literal) {
102
+ const text = expanded.map((p) => p.text).join('');
103
+ return pathExpr(normalizeLiteralPath(text));
104
+ }
105
+ // dynamic — build a PS double-quoted string with interpolation
106
+ let out = '"';
107
+ const emitPart = (p) => {
108
+ switch (p.kind) {
109
+ case 'Text':
110
+ out += escDq(p.text);
111
+ break;
112
+ case 'SingleQuoted':
113
+ out += escDq(p.text);
114
+ break;
115
+ case 'DoubleQuoted':
116
+ for (const q of p.parts)
117
+ emitPart(q);
118
+ break;
119
+ case 'Var':
120
+ out += '$(' + varExpr(p.name) + ')';
121
+ break;
122
+ case 'CmdSub':
123
+ out += '$(' + translateCmdSub(p.cmd) + ')';
124
+ break;
125
+ }
126
+ };
127
+ for (const p of expanded)
128
+ emitPart(p);
129
+ out += '"';
130
+ return out;
131
+ }
132
+ /** Literal text of a word when it contains no interpolation, else null. */
133
+ export function literalOfWord(w) {
134
+ if (!w.every((p) => p.kind === 'Text' || p.kind === 'SingleQuoted'))
135
+ return null;
136
+ const text = w.map((p) => p.text).join('');
137
+ if (w.length > 0 && w[0].kind === 'Text' && w[0].text.startsWith('~'))
138
+ return null; // needs $HOME
139
+ return text;
140
+ }
141
+ /**
142
+ * Argument expression for an operand (file path-ish).
143
+ * Literal paths get POSIX-ish normalization (/dev/null, /tmp, /d/...).
144
+ */
145
+ export function operandExpr(w) {
146
+ const lit = literalOfWord(w);
147
+ if (lit !== null)
148
+ return pathExpr(normalizeLiteralPath(lit));
149
+ return exprOfWord(w);
150
+ }
151
+ /* ------------------------------------------------------------------ */
152
+ /* Command substitution */
153
+ /* ------------------------------------------------------------------ */
154
+ /** Translate the inside of $(...) — pipelines only, no wrappers. */
155
+ export function translateCmdSub(cmdText) {
156
+ const list = parseCommand(cmdText);
157
+ if (list.segments.length !== 1) {
158
+ throw new FauxnixParseError('fauxnix: command substitution with ; && || is not supported yet');
159
+ }
160
+ const { defs, call } = translatePipelineBody(list.segments[0].pipeline);
161
+ return defs ? defs + '\n' + call : call;
162
+ }
163
+ /* ------------------------------------------------------------------ */
164
+ /* Simple command translation */
165
+ /* ------------------------------------------------------------------ */
166
+ export function translateSimple(cmd, position, hasStdin) {
167
+ const nameLit = literalOfWord(cmd.name);
168
+ let body;
169
+ if (nameLit !== null) {
170
+ const handler = lookup(nameLit);
171
+ if (handler) {
172
+ body = handler(cmd.args, { position, hasStdin });
173
+ }
174
+ else {
175
+ // passthrough: native command (git, node, npm, python, cargo, ...)
176
+ // invoked with the call operator and an argv-style argument array —
177
+ // no string re-parsing of user text.
178
+ const nameExpr = psStr(nameLit);
179
+ const argExprs = cmd.args.map((a) => exprOfWord(a));
180
+ const args = argExprs.length ? ' @(' + argExprs.join(', ') + ')' : '';
181
+ const call = '& ' + nameExpr + args;
182
+ body = [
183
+ // feed pipeline stdin into the native process when we are a non-first stage
184
+ (hasStdin ? '($input | ' + call + ')' : call) + ' | ForEach-Object { [string]$_ }',
185
+ 'if ($LASTEXITCODE -gt 0) { $script:fx_exit = $LASTEXITCODE } elseif ($LASTEXITCODE -lt 0) { $script:fx_exit = 1 }',
186
+ ].join('\n');
187
+ }
188
+ }
189
+ else {
190
+ // dynamic command name — evaluate it
191
+ const nameExpr = exprOfWord(cmd.name);
192
+ const argExprs = cmd.args.map((a) => exprOfWord(a));
193
+ const args = argExprs.length ? ' @(' + argExprs.join(', ') + ')' : '';
194
+ const call = '& (' + nameExpr + ')' + args;
195
+ body = [
196
+ (hasStdin ? '($input | ' + call + ')' : call) + ' | ForEach-Object { [string]$_ }',
197
+ 'if ($LASTEXITCODE -gt 0) { $script:fx_exit = $LASTEXITCODE } elseif ($LASTEXITCODE -lt 0) { $script:fx_exit = 1 }',
198
+ ].join('\n');
199
+ }
200
+ // `VAR=value cmd ...` prefix — set process env for the invocation.
201
+ if (cmd.assignments.length > 0) {
202
+ const sets = cmd.assignments
203
+ .map((a) => '$env:' + a.name + ' = ' + exprOfWord(a.value))
204
+ .join('; ');
205
+ body = sets + '\n' + body;
206
+ }
207
+ return body;
208
+ }
209
+ /** Unique suffix for generated stage functions (nested pipelines included). */
210
+ let stageSeq = 0;
211
+ /**
212
+ * Pipeline body. A lone command runs as a plain script-block expression;
213
+ * multi-command pipelines become generated functions chained with `|`
214
+ * (PS 5.1 forbids parenthesized expressions as non-first pipeline elements).
215
+ */
216
+ export function translatePipelineBody(p) {
217
+ const bodies = [];
218
+ for (let i = 0; i < p.commands.length; i++) {
219
+ const hasStdin = i > 0 || p.commands[i].redirects.some((r) => r.op === '<');
220
+ const position = i === 0 ? 'first' : i === p.commands.length - 1 ? 'last' : 'middle';
221
+ bodies.push(translateSimple(p.commands[i], position, hasStdin));
222
+ }
223
+ if (bodies.length === 1) {
224
+ return { defs: '', call: '(& {\n' + bodies[0] + '\n})' };
225
+ }
226
+ const names = [];
227
+ const defs = [];
228
+ for (let i = 0; i < bodies.length; i++) {
229
+ const name = '__fx_s' + stageSeq++;
230
+ names.push(name);
231
+ const indented = bodies[i]
232
+ .split('\n')
233
+ .map((l) => (l ? ' ' + l : l))
234
+ .join('\n');
235
+ defs.push('function ' + name + ' {\n' + indented + '\n}');
236
+ }
237
+ return { defs: defs.join('\n'), call: names.join(' | ') };
238
+ }
239
+ export function translateCommandList(list) {
240
+ const plans = [];
241
+ for (const seg of list.segments) {
242
+ const redirects = [];
243
+ for (const c of seg.pipeline.commands)
244
+ redirects.push(...c.redirects);
245
+ const { defs, call } = translatePipelineBody(seg.pipeline);
246
+ let body = defs ? defs + '\n' + call : call;
247
+ // `< file` redirects feed the pipeline via the FAUXNIX_STDIN_FILE channel
248
+ if (redirects.some((r) => r.op === '<')) {
249
+ // `& { ... }` (no parens) so the scriptblock can be a non-first
250
+ // pipeline element receiving the fed lines.
251
+ const pipeCall = call.startsWith('(& {') ? call.slice(1, -1) : call;
252
+ body =
253
+ (defs ? defs + '\n' : '') +
254
+ 'if ($env:FAUXNIX_STDIN_FILE) { fx-readlines $env:FAUXNIX_STDIN_FILE | ' +
255
+ pipeCall +
256
+ ' } else { ' +
257
+ call +
258
+ ' }';
259
+ }
260
+ plans.push({ op: seg.op, script: wrapScript(body), redirects });
261
+ }
262
+ return plans;
263
+ }
264
+ /**
265
+ * Wrap a pipeline body with the Fauxnix executor contract:
266
+ * UTF-8 everywhere, bash-style exit codes, cwd/env persistence channels.
267
+ */
268
+ export function wrapScript(body) {
269
+ const lines = [
270
+ '$ErrorActionPreference = "Continue"',
271
+ "$ProgressPreference = 'SilentlyContinue'",
272
+ '$fx_exit = 0',
273
+ '$fx_prev = 0',
274
+ 'if ($env:FAUXNIX_PREV_EXIT) { try { $fx_prev = [int]$env:FAUXNIX_PREV_EXIT } catch { $fx_prev = 0 } }',
275
+ 'try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {}',
276
+ '$OutputEncoding = [System.Text.Encoding]::UTF8',
277
+ 'try { chcp 65001 > $null } catch {}',
278
+ 'if ($env:FAUXNIX_CWD) { try { Set-Location -LiteralPath $env:FAUXNIX_CWD } catch {} }',
279
+ // capture AFTER the session cwd is applied — OLDPWD must refer to the
280
+ // shell's previous directory, not the host process' startup directory
281
+ '$fx_oldcwd = (Get-Location).ProviderPath',
282
+ // .NET APIs (ReadAllBytes & friends) resolve relative paths against the
283
+ // process working directory, NOT the PS location — keep them in sync.
284
+ 'try { [Environment]::CurrentDirectory = (Get-Location).ProviderPath } catch {}',
285
+ // byte-sniffing line reader for `< file` stdin redirects (UTF-8 → GBK)
286
+ 'function fx-readlines($p) {',
287
+ ' $b = [IO.File]::ReadAllBytes($p)',
288
+ ' $t = $null',
289
+ ' try { $t = (New-Object System.Text.UTF8Encoding($false, $true)).GetString($b) } catch {}',
290
+ " if ($null -eq $t) { try { $t = [System.Text.Encoding]::GetEncoding(936).GetString($b) } catch { $t = [System.Text.Encoding]::ASCII.GetString($b) } }",
291
+ ' $t = $t -replace "`r`n", "`n"',
292
+ ' $t = $t -replace "`r", "`n"',
293
+ ' $parts = @($t.Split("`n"))',
294
+ " if ($parts.Count -eq 1 -and $parts[0] -eq '') { return @() }",
295
+ " if ($parts[$parts.Count - 1] -eq '') { $parts = $parts[0..($parts.Count - 2)] }",
296
+ ' return $parts',
297
+ '}',
298
+ 'try {',
299
+ ...body.split('\n').map((l) => ' ' + l),
300
+ '} catch [System.Management.Automation.CommandNotFoundException] {',
301
+ " [Console]::Error.WriteLine('bash: ' + $_.Exception.TargetName + ': command not found')",
302
+ ' $script:fx_exit = 127',
303
+ '} catch {',
304
+ ' [Console]::Error.WriteLine(($_.Exception.Message).Split("`n")[0])',
305
+ ' $script:fx_exit = 1',
306
+ '}',
307
+ '# persist session cwd and environment for the next segment',
308
+ 'try { [IO.File]::WriteAllText($env:FAUXNIX_CWD_FILE, (Get-Location).Path) } catch {}',
309
+ 'if ((Get-Location).Path -ne $fx_oldcwd) { $env:FAUXNIX_OLDPWD = $fx_oldcwd }',
310
+ 'try {',
311
+ ' $envObj = @{}',
312
+ ' Get-ChildItem Env: | ForEach-Object { $envObj[$_.Name] = $_.Value }',
313
+ ' [IO.File]::WriteAllText($env:FAUXNIX_ENV_FILE, (ConvertTo-Json $envObj -Compress))',
314
+ '} catch {}',
315
+ 'exit $script:fx_exit',
316
+ ];
317
+ return lines.join('\n');
318
+ }
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "fauxnix-cli",
3
+ "version": "0.1.0",
4
+ "description": "Fauxnix — run Linux-style commands on Windows via deterministic PowerShell translation. No VM, no WSL. MCP server + CLI for AI agents.",
5
+ "type": "module",
6
+ "bin": {
7
+ "fauxnix": "dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "README.md",
12
+ "LICENSE"
13
+ ],
14
+ "scripts": {
15
+ "build": "tsc",
16
+ "test": "vitest run",
17
+ "test:watch": "vitest",
18
+ "typecheck": "tsc --noEmit",
19
+ "dev": "tsx src/index.ts"
20
+ },
21
+ "keywords": [
22
+ "bash",
23
+ "powershell",
24
+ "translate",
25
+ "translator",
26
+ "linux",
27
+ "windows",
28
+ "mcp",
29
+ "mcp-server",
30
+ "ai-agent",
31
+ "claude-code",
32
+ "codex",
33
+ "opencode",
34
+ "shell",
35
+ "gbk",
36
+ "utf8"
37
+ ],
38
+ "engines": {
39
+ "node": ">=18"
40
+ },
41
+ "license": "MIT",
42
+ "repository": {
43
+ "type": "git",
44
+ "url": "git+https://github.com/20000419/fauxnix.git"
45
+ },
46
+ "dependencies": {
47
+ "@modelcontextprotocol/sdk": "^1.12.0",
48
+ "iconv-lite": "^0.6.3",
49
+ "zod": "^3.24.0"
50
+ },
51
+ "devDependencies": {
52
+ "@types/node": "^20.14.0",
53
+ "tsx": "^4.19.0",
54
+ "typescript": "^5.5.0",
55
+ "vitest": "^2.1.0"
56
+ }
57
+ }