fauxnix-cli 0.9.2 → 0.11.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 +54 -12
- package/dist/ast.d.ts +38 -3
- package/dist/ast.js +22 -2
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +25 -1
- package/dist/commands/archive.d.ts +2 -1
- package/dist/commands/archive.js +85 -3
- package/dist/commands/files.js +4 -2
- package/dist/commands/install-all.js +2 -1
- package/dist/commands/net.js +3 -4
- package/dist/commands/sysinfo.js +48 -12
- package/dist/commands/text-filters.d.ts +1 -0
- package/dist/commands/text-filters.js +96 -38
- package/dist/commands/text-io.js +89 -10
- package/dist/doctor.d.ts +20 -0
- package/dist/doctor.js +251 -0
- package/dist/errors.d.ts +4 -0
- package/dist/errors.js +18 -5
- package/dist/executor.js +92 -102
- package/dist/install.d.ts +15 -0
- package/dist/install.js +206 -0
- package/dist/mcp.d.ts +6 -0
- package/dist/mcp.js +34 -12
- package/dist/parser.js +422 -33
- package/dist/registry.d.ts +2 -0
- package/dist/registry.js +4 -1
- package/dist/translator.d.ts +29 -3
- package/dist/translator.js +320 -29
- package/package.json +1 -1
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
|
|
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
|
-
|
|
66
|
+
caller.stderr(msg);
|
|
67
|
+
return;
|
|
39
68
|
}
|
|
40
69
|
}
|
|
41
|
-
|
|
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
|
-
|
|
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,
|
|
370
|
-
|
|
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
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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
|
-
|
|
518
|
-
|
|
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;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type InstallOptions = {
|
|
2
|
+
home?: string;
|
|
3
|
+
/** Accepted for parity with collectDoctorReport; install writes user-level configs only. */
|
|
4
|
+
cwd?: string;
|
|
5
|
+
env?: NodeJS.ProcessEnv;
|
|
6
|
+
};
|
|
7
|
+
export type InstallReport = {
|
|
8
|
+
lines: string[];
|
|
9
|
+
ok: boolean;
|
|
10
|
+
};
|
|
11
|
+
export declare const INSTALL_FLAGS: readonly ["claude", "codex", "opencode", "kimi", "qwen"];
|
|
12
|
+
export type HarnessName = (typeof INSTALL_FLAGS)[number];
|
|
13
|
+
export declare function kimiConfigPath(home: string, env: NodeJS.ProcessEnv): string;
|
|
14
|
+
export declare function qwenConfigPath(home: string, env: NodeJS.ProcessEnv): string;
|
|
15
|
+
export declare function runInstall(argv: string[], opts?: InstallOptions): InstallReport;
|
package/dist/install.js
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { claudeUserConfigPath, codexConfigPath, hasCodexFauxnix, hasOpenCodeFauxnix, isServerMap, openCodeConfigPath, serverMapHasFauxnix, } from './doctor.js';
|
|
5
|
+
export const INSTALL_FLAGS = ['claude', 'codex', 'opencode', 'kimi', 'qwen'];
|
|
6
|
+
const STDIO = { command: 'fauxnix', args: ['mcp'] };
|
|
7
|
+
const OPENCODE_STDIO = { type: 'local', command: ['fauxnix', 'mcp'] };
|
|
8
|
+
export function kimiConfigPath(home, env) {
|
|
9
|
+
const root = env.KIMI_CODE_HOME?.trim() || join(home, '.kimi-code');
|
|
10
|
+
return join(root, 'mcp.json');
|
|
11
|
+
}
|
|
12
|
+
export function qwenConfigPath(home, env) {
|
|
13
|
+
return join(home, '.qwen', 'settings.json');
|
|
14
|
+
}
|
|
15
|
+
export function runInstall(argv, opts = {}) {
|
|
16
|
+
const parsed = parseHarnessFlags(argv);
|
|
17
|
+
if (parsed.help)
|
|
18
|
+
return { lines: installUsageLines(), ok: true };
|
|
19
|
+
if (parsed.error)
|
|
20
|
+
return { lines: [parsed.error, ...installUsageLines()], ok: false };
|
|
21
|
+
const ctx = {
|
|
22
|
+
home: opts.home ?? homedir(),
|
|
23
|
+
env: opts.env ?? process.env,
|
|
24
|
+
};
|
|
25
|
+
const lines = [];
|
|
26
|
+
let ok = true;
|
|
27
|
+
for (const name of parsed.harnesses) {
|
|
28
|
+
const one = installHarness(name, ctx);
|
|
29
|
+
lines.push(one.line);
|
|
30
|
+
if (!one.ok)
|
|
31
|
+
ok = false;
|
|
32
|
+
}
|
|
33
|
+
return { lines, ok };
|
|
34
|
+
}
|
|
35
|
+
function parseHarnessFlags(argv) {
|
|
36
|
+
if (argv.some((a) => a === '--help' || a === '-h'))
|
|
37
|
+
return { help: true, harnesses: [] };
|
|
38
|
+
if (argv.length === 0) {
|
|
39
|
+
return { error: 'select a harness: --claude --codex --opencode --kimi --qwen', harnesses: [] };
|
|
40
|
+
}
|
|
41
|
+
const harnesses = [];
|
|
42
|
+
const seen = new Set();
|
|
43
|
+
for (const a of argv) {
|
|
44
|
+
if (!a.startsWith('--') || a === '--') {
|
|
45
|
+
return { error: `unknown argument: ${a}`, harnesses: [] };
|
|
46
|
+
}
|
|
47
|
+
const name = a.slice(2);
|
|
48
|
+
if (!isHarness(name))
|
|
49
|
+
return { error: `unknown harness: ${a}`, harnesses: [] };
|
|
50
|
+
if (!seen.has(name)) {
|
|
51
|
+
seen.add(name);
|
|
52
|
+
harnesses.push(name);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return { harnesses };
|
|
56
|
+
}
|
|
57
|
+
function isHarness(s) {
|
|
58
|
+
return INSTALL_FLAGS.includes(s);
|
|
59
|
+
}
|
|
60
|
+
function installUsageLines() {
|
|
61
|
+
return ['Usage:', ' fauxnix install --claude|--codex|--opencode|--kimi|--qwen'];
|
|
62
|
+
}
|
|
63
|
+
function installHarness(name, ctx) {
|
|
64
|
+
switch (name) {
|
|
65
|
+
case 'claude':
|
|
66
|
+
return patchMcpServers(claudeUserConfigPath(ctx.home, ctx.env), 'claude');
|
|
67
|
+
case 'codex':
|
|
68
|
+
return patchCodex(codexConfigPath(ctx.home, ctx.env));
|
|
69
|
+
case 'opencode':
|
|
70
|
+
return patchOpenCode(openCodeConfigPath(ctx.home, ctx.env));
|
|
71
|
+
case 'kimi':
|
|
72
|
+
return patchMcpServers(kimiConfigPath(ctx.home, ctx.env), 'kimi');
|
|
73
|
+
case 'qwen':
|
|
74
|
+
return patchMcpServers(qwenConfigPath(ctx.home, ctx.env), 'qwen');
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function patchMcpServers(path, harness) {
|
|
78
|
+
const read = readJsonObject(path);
|
|
79
|
+
if (read.state === 'invalid') {
|
|
80
|
+
return { ok: false, line: `${harness}: ${path} is ${read.reason} — not modified` };
|
|
81
|
+
}
|
|
82
|
+
const existed = read.state !== 'missing';
|
|
83
|
+
const data = read.state === 'ok' ? read.data : {};
|
|
84
|
+
if (serverMapHasFauxnix(data.mcpServers)) {
|
|
85
|
+
return { ok: true, line: `${harness}: already configured (${path})` };
|
|
86
|
+
}
|
|
87
|
+
if (data.mcpServers != null && !isServerMap(data.mcpServers)) {
|
|
88
|
+
return { ok: false, line: `${harness}: ${path} mcpServers is not an object — not modified` };
|
|
89
|
+
}
|
|
90
|
+
if (!isServerMap(data.mcpServers))
|
|
91
|
+
data.mcpServers = {};
|
|
92
|
+
data.mcpServers.fauxnix = {
|
|
93
|
+
command: STDIO.command,
|
|
94
|
+
args: [...STDIO.args],
|
|
95
|
+
};
|
|
96
|
+
return writeJson(path, data, harness, existed, 'added mcpServers.fauxnix');
|
|
97
|
+
}
|
|
98
|
+
function patchOpenCode(path) {
|
|
99
|
+
const read = readJsonObject(path);
|
|
100
|
+
if (read.state === 'invalid') {
|
|
101
|
+
return { ok: false, line: `opencode: ${path} is ${read.reason} — not modified` };
|
|
102
|
+
}
|
|
103
|
+
const existed = read.state !== 'missing';
|
|
104
|
+
const data = read.state === 'ok' ? read.data : {};
|
|
105
|
+
if (hasOpenCodeFauxnix(data)) {
|
|
106
|
+
return { ok: true, line: `opencode: already configured (${path})` };
|
|
107
|
+
}
|
|
108
|
+
if (data.mcp != null && !isServerMap(data.mcp)) {
|
|
109
|
+
return { ok: false, line: `opencode: ${path} mcp is not an object — not modified` };
|
|
110
|
+
}
|
|
111
|
+
if (!isServerMap(data.mcp))
|
|
112
|
+
data.mcp = {};
|
|
113
|
+
const mcp = data.mcp;
|
|
114
|
+
const payload = { type: OPENCODE_STDIO.type, command: [...OPENCODE_STDIO.command] };
|
|
115
|
+
if (isServerMap(mcp.servers)) {
|
|
116
|
+
mcp.servers.fauxnix = payload;
|
|
117
|
+
return writeJson(path, data, 'opencode', existed, 'added mcp.servers.fauxnix');
|
|
118
|
+
}
|
|
119
|
+
mcp.fauxnix = payload;
|
|
120
|
+
return writeJson(path, data, 'opencode', existed, 'added mcp.fauxnix');
|
|
121
|
+
}
|
|
122
|
+
function patchCodex(path) {
|
|
123
|
+
const existed = existsSync(path);
|
|
124
|
+
if (!existed) {
|
|
125
|
+
const written = writeText(path, tomlTable('\n'));
|
|
126
|
+
if (!written.ok)
|
|
127
|
+
return { ok: false, line: `codex: failed to write ${path}: ${written.error}` };
|
|
128
|
+
return { ok: true, line: `codex: created ${path}` };
|
|
129
|
+
}
|
|
130
|
+
const text = readText(path);
|
|
131
|
+
if (text === undefined) {
|
|
132
|
+
return { ok: false, line: `codex: ${path} is unreadable — not modified` };
|
|
133
|
+
}
|
|
134
|
+
if (hasCodexFauxnix(stripBom(text))) {
|
|
135
|
+
return { ok: true, line: `codex: already configured (${path})` };
|
|
136
|
+
}
|
|
137
|
+
const nl = text.includes('\r\n') ? '\r\n' : '\n';
|
|
138
|
+
if (stripBom(text).trim() === '') {
|
|
139
|
+
const written = writeText(path, tomlTable(nl));
|
|
140
|
+
if (!written.ok)
|
|
141
|
+
return { ok: false, line: `codex: failed to write ${path}: ${written.error}` };
|
|
142
|
+
return { ok: true, line: `codex: patched ${path} (appended [mcp_servers.fauxnix])` };
|
|
143
|
+
}
|
|
144
|
+
let body = text;
|
|
145
|
+
if (!body.endsWith('\n'))
|
|
146
|
+
body += nl;
|
|
147
|
+
if (!body.endsWith(nl + nl))
|
|
148
|
+
body += nl;
|
|
149
|
+
const written = writeText(path, body + tomlTable(nl));
|
|
150
|
+
if (!written.ok)
|
|
151
|
+
return { ok: false, line: `codex: failed to write ${path}: ${written.error}` };
|
|
152
|
+
return { ok: true, line: `codex: patched ${path} (appended [mcp_servers.fauxnix])` };
|
|
153
|
+
}
|
|
154
|
+
function tomlTable(nl) {
|
|
155
|
+
return `[mcp_servers.fauxnix]${nl}command = "fauxnix"${nl}args = ["mcp"]${nl}`;
|
|
156
|
+
}
|
|
157
|
+
function writeJson(path, data, harness, existed, change) {
|
|
158
|
+
const written = writeText(path, JSON.stringify(data, null, 2) + '\n');
|
|
159
|
+
if (!written.ok) {
|
|
160
|
+
return { ok: false, line: `${harness}: failed to write ${path}: ${written.error}` };
|
|
161
|
+
}
|
|
162
|
+
if (existed)
|
|
163
|
+
return { ok: true, line: `${harness}: patched ${path} (${change})` };
|
|
164
|
+
return { ok: true, line: `${harness}: created ${path}` };
|
|
165
|
+
}
|
|
166
|
+
function writeText(path, contents) {
|
|
167
|
+
try {
|
|
168
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
169
|
+
writeFileSync(path, contents, 'utf8');
|
|
170
|
+
return { ok: true };
|
|
171
|
+
}
|
|
172
|
+
catch (e) {
|
|
173
|
+
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
function readJsonObject(path) {
|
|
177
|
+
if (!existsSync(path))
|
|
178
|
+
return { state: 'missing' };
|
|
179
|
+
const text = readText(path);
|
|
180
|
+
if (text === undefined)
|
|
181
|
+
return { state: 'invalid', reason: 'unreadable' };
|
|
182
|
+
const stripped = stripBom(text).trim();
|
|
183
|
+
if (stripped === '')
|
|
184
|
+
return { state: 'empty' };
|
|
185
|
+
try {
|
|
186
|
+
const data = JSON.parse(stripped);
|
|
187
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
188
|
+
return { state: 'invalid', reason: 'not a JSON object' };
|
|
189
|
+
}
|
|
190
|
+
return { state: 'ok', data: data };
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return { state: 'invalid', reason: 'not valid JSON' };
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
function stripBom(text) {
|
|
197
|
+
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
198
|
+
}
|
|
199
|
+
function readText(path) {
|
|
200
|
+
try {
|
|
201
|
+
return readFileSync(path, 'utf8');
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|
|
206
|
+
}
|
package/dist/mcp.d.ts
CHANGED
|
@@ -19,5 +19,11 @@ export declare function bashToolResult(r: ExecResult, sessionId: string, infra:
|
|
|
19
19
|
sessionId: string;
|
|
20
20
|
};
|
|
21
21
|
};
|
|
22
|
+
export declare function positionalCountFromEnv(env: Record<string, string>): number;
|
|
23
|
+
export declare function formatSessionStatus(session: {
|
|
24
|
+
cwd: string | null;
|
|
25
|
+
env: Record<string, string>;
|
|
26
|
+
id: string;
|
|
27
|
+
}): string;
|
|
22
28
|
export declare function startMcpServer(): Promise<void>;
|
|
23
29
|
export { translatePipelineBody };
|
package/dist/mcp.js
CHANGED
|
@@ -31,11 +31,10 @@ const TOOL_DESCRIPTION = `Execute a Linux/bash-style command on this Windows mac
|
|
|
31
31
|
Commands are deterministically translated to PowerShell and executed natively — no WSL or VM.
|
|
32
32
|
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.
|
|
33
33
|
|
|
34
|
-
Supported: pipes (|), && / || / ;, redirections (> >> 2> 2>&1 < /dev/null), variables ($VAR $HOME ~), command substitution $(...), and ${registeredNames().length}+ coreutils-style commands (${registeredNames().slice(0, 18).join(', ')}...).
|
|
34
|
+
Supported: pipes (|), && / || / ;, redirections (> >> 2> 2>&1 < /dev/null), variables ($VAR $HOME $1 $# "$@" ~), set -- / shift, array assignment A=(x y z), \${name[n]} \${#name[@]} \${name//pat/str} \${name:off:len}, command substitution $(...), and ${registeredNames().length}+ coreutils-style commands (${registeredNames().slice(0, 18).join(', ')}...).
|
|
35
35
|
Unknown commands (git, node, npm, python, cargo...) are passed through and executed natively with argv-style quoting.
|
|
36
|
-
Not supported: heredocs,
|
|
37
|
-
|
|
38
|
-
CWD, environment variables, export/unset and cd persist across calls within this session — a resident PowerShell 5.1 host is started when the MCP session begins (and after reset), so the first bash tool call is already warm.
|
|
36
|
+
Not supported: heredocs, env -i/--ignore-environment, background jobs. if/then/elif/else/fi, for-in loops, while/until, case ... esac, and word-level \$((...)) arithmetic expansion are supported.
|
|
37
|
+
CWD, environment variables, export/unset, cd, and positional parameters (set -- / $1 / "$@") persist across calls within this session — a resident PowerShell 5.1 host is started when the MCP session begins (and after reset), so the first bash tool call is already warm.
|
|
39
38
|
Exit codes follow bash conventions (0 ok, 1 fail, 2 usage/serious, 127 command not found, 124 timeout, 130 cancelled). The tool also returns structuredContent (schemaVersion 1) with stdout/stderr/exitCode/timedOut/cancelled/truncated/sessionId.
|
|
40
39
|
|
|
41
40
|
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.`;
|
|
@@ -70,7 +69,35 @@ export function bashToolResult(r, sessionId, infra) {
|
|
|
70
69
|
...(infra ? { isError: true } : {}),
|
|
71
70
|
};
|
|
72
71
|
}
|
|
72
|
+
/** Packed FAUXNIX_POS uses char-30 separators (same as array sidecar). */
|
|
73
|
+
const POS_SEP = '\x1e';
|
|
74
|
+
export function positionalCountFromEnv(env) {
|
|
75
|
+
let packed;
|
|
76
|
+
for (const [k, v] of Object.entries(env)) {
|
|
77
|
+
if (k.toUpperCase() === 'FAUXNIX_POS') {
|
|
78
|
+
packed = v;
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (packed == null || packed === '')
|
|
83
|
+
return 0;
|
|
84
|
+
return packed.split(POS_SEP).length;
|
|
85
|
+
}
|
|
86
|
+
export function formatSessionStatus(session) {
|
|
87
|
+
const envKeys = Object.keys(session.env).sort();
|
|
88
|
+
return ('cwd: ' +
|
|
89
|
+
(session.cwd ?? '(inherit from server start)') +
|
|
90
|
+
'\nenv keys: ' +
|
|
91
|
+
(envKeys.length ? envKeys.join(', ') : '(none tracked)') +
|
|
92
|
+
'\npositionals: ' +
|
|
93
|
+
positionalCountFromEnv(session.env) +
|
|
94
|
+
'\nsession: ' +
|
|
95
|
+
session.id +
|
|
96
|
+
'\ncommands registered: ' +
|
|
97
|
+
registeredNames().length);
|
|
98
|
+
}
|
|
73
99
|
export async function startMcpServer() {
|
|
100
|
+
process.env.FAUXNIX_ARG0 = TOOL_NAME;
|
|
74
101
|
const server = new McpServer({ name: 'fauxnix', version: packageVersion }, { capabilities: { tools: {} } });
|
|
75
102
|
const session = new FauxnixSession();
|
|
76
103
|
await session.prewarm();
|
|
@@ -117,22 +144,17 @@ export async function startMcpServer() {
|
|
|
117
144
|
return { content: [{ type: 'text', text: msg }], isError: true };
|
|
118
145
|
}
|
|
119
146
|
});
|
|
120
|
-
server.tool('fauxnix_session', 'Inspect or reset the persistent fauxnix shell session (current directory, environment, session id). Actions: "status" (default) or "reset".', {
|
|
147
|
+
server.tool('fauxnix_session', 'Inspect or reset the persistent fauxnix shell session (current directory, environment, positional count, session id). Actions: "status" (default) or "reset".', {
|
|
121
148
|
action: z
|
|
122
149
|
.enum(['status', 'reset'])
|
|
123
150
|
.default('status')
|
|
124
|
-
.describe('"status" shows the session state (cwd, tracked env keys); "reset" clears it back to a fresh shell'),
|
|
151
|
+
.describe('"status" shows the session state (cwd, tracked env keys, positional count); "reset" clears it back to a fresh shell'),
|
|
125
152
|
}, SESSION_ANNOTATIONS, async ({ action }) => {
|
|
126
153
|
if (action === 'reset') {
|
|
127
154
|
await session.reset();
|
|
128
155
|
return { content: [{ type: 'text', text: 'fauxnix: session reset' }] };
|
|
129
156
|
}
|
|
130
|
-
|
|
131
|
-
const text = 'cwd: ' + (session.cwd ?? '(inherit from server start)') +
|
|
132
|
-
'\nenv keys: ' + (envKeys.length ? envKeys.join(', ') : '(none tracked)') +
|
|
133
|
-
'\nsession: ' + session.id +
|
|
134
|
-
'\ncommands registered: ' + registeredNames().length;
|
|
135
|
-
return { content: [{ type: 'text', text }] };
|
|
157
|
+
return { content: [{ type: 'text', text: formatSessionStatus(session) }] };
|
|
136
158
|
});
|
|
137
159
|
const transport = new StdioServerTransport();
|
|
138
160
|
let shuttingDown = false;
|