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/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;
@@ -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;
@@ -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, while/until/case, env -i/--ignore-environment, background jobs. if/then/elif/else/fi, for-in loops, and word-level \$((...)) arithmetic expansion are supported.
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
- const envKeys = Object.keys(session.env).sort();
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;