fauxnix-cli 0.7.0 → 0.8.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.
@@ -77,7 +77,7 @@ function textExpr(w) {
77
77
  return exprOfWord(w);
78
78
  }
79
79
  /** Collect EVERY value of a short option (-kN, -k N) — parseWords keeps only the last. */
80
- function collectShortValues(args, letter, longName) {
80
+ function collectShortValues(args, letter) {
81
81
  const out = [];
82
82
  let onlyOps = false;
83
83
  for (let i = 0; i < args.length; i++) {
@@ -88,9 +88,7 @@ function collectShortValues(args, letter, longName) {
88
88
  }
89
89
  if (onlyOps)
90
90
  continue;
91
- if (longName && t.startsWith(longName + '='))
92
- out.push(t.slice(longName.length + 1));
93
- else if (t === '-' + letter) {
91
+ if (t === '-' + letter) {
94
92
  if (i + 1 < args.length) {
95
93
  out.push(wordToString(args[i + 1]));
96
94
  i++;
@@ -102,6 +100,32 @@ function collectShortValues(args, letter, longName) {
102
100
  }
103
101
  return out;
104
102
  }
103
+ /** Collect repeated value-taking long options without mistaking short bundles for values. */
104
+ function collectLongValues(args, names) {
105
+ const out = [];
106
+ let onlyOps = false;
107
+ for (let i = 0; i < args.length; i++) {
108
+ const t = wordToString(args[i]);
109
+ if (t === '--') {
110
+ onlyOps = true;
111
+ continue;
112
+ }
113
+ if (onlyOps || !t.startsWith('--'))
114
+ continue;
115
+ const eq = t.indexOf('=');
116
+ const name = eq >= 0 ? t.slice(0, eq) : t;
117
+ if (!names.includes(name))
118
+ continue;
119
+ if (eq >= 0) {
120
+ out.push({ name, value: t.slice(eq + 1) });
121
+ }
122
+ else if (i + 1 < args.length) {
123
+ out.push({ name, value: wordToString(args[i + 1]) });
124
+ i++;
125
+ }
126
+ }
127
+ return out;
128
+ }
105
129
  /** Build the "collect file operands through fx-glob" PS prologue. */
106
130
  function psCollectSources(filesExpr, cmdErr, leafOnly) {
107
131
  const test = leafOnly
@@ -238,8 +262,19 @@ function ereToDotNet(re) {
238
262
  /* grep */
239
263
  /* ------------------------------------------------------------------ */
240
264
  const grep = (args) => {
241
- const includeGlobs = collectShortValues(args, '', '--include');
242
- const { flags, operandWords, values } = parseWords(args, ['A', 'B', 'C']);
265
+ const filterOptionNames = ['--include', '--exclude', '--exclude-dir'];
266
+ const filterOptions = collectLongValues(args, filterOptionNames);
267
+ const fileFilterOptions = filterOptions.filter((o) => o.name !== '--exclude-dir');
268
+ const excludeDirGlobs = filterOptions
269
+ .filter((o) => o.name === '--exclude-dir')
270
+ .map((o) => o.value.replace(/[\\/]+$/, ''));
271
+ const { flags, operandWords, values, missingValue } = parseWords(args, ['A', 'B', 'C'], filterOptionNames);
272
+ const missingFilterOption = missingValue.find((o) => filterOptionNames.includes(o));
273
+ if (missingFilterOption) {
274
+ return ('[Console]::Error.WriteLine(' +
275
+ psStr("grep: option '" + missingFilterOption + "' requires an argument") +
276
+ '); $script:fx_exit = 2');
277
+ }
243
278
  const ci = flags.has('i');
244
279
  const inv = flags.has('v');
245
280
  const num = flags.has('n');
@@ -320,23 +355,68 @@ const grep = (args) => {
320
355
  // --- source collection -------------------------------------------------
321
356
  if (fileWords.length > 0) {
322
357
  lines.push(PS_GLOB_FN);
323
- lines.push('$fx_inc = @(' + includeGlobs.map((g) => psStr(g)).join(', ') + ')', '$fx_srcs = @()', '$fx_err = $false', '$fx_recd = $false');
358
+ lines.push('$fx_fsel = @(' +
359
+ fileFilterOptions
360
+ .map((o) => '[pscustomobject]@{ Keep = ' +
361
+ pb(o.name === '--include') +
362
+ '; Glob = ' +
363
+ psStr(o.value) +
364
+ ' }')
365
+ .join(', ') +
366
+ ')', '$fx_excd = @(' + excludeDirGlobs.map((g) => psStr(g)).join(', ') + ')', '$fx_srcs = @()', '$fx_err = $false', '$fx_recd = $false');
367
+ lines.push('function fx-globmatch($fx_name, $fx_glob, $fx_suffix) {');
368
+ lines.push(" $fx_n = ([string]$fx_name).Replace('\\', '/')");
369
+ lines.push(" $fx_p = ([string]$fx_glob).Replace('\\', '/')");
370
+ lines.push(' if ($fx_n -like $fx_p) { return $true }');
371
+ lines.push(' if ($fx_suffix) {');
372
+ lines.push(" $fx_slash = $fx_n.IndexOf('/')");
373
+ lines.push(' while ($fx_slash -ge 0 -and $fx_slash + 1 -lt $fx_n.Length) {');
374
+ lines.push(' $fx_n = $fx_n.Substring($fx_slash + 1)');
375
+ lines.push(' if ($fx_n -like $fx_p) { return $true }');
376
+ lines.push(" $fx_slash = $fx_n.IndexOf('/')");
377
+ lines.push(' }');
378
+ lines.push(' }');
379
+ lines.push(' return $false');
380
+ lines.push('}');
381
+ lines.push('function fx-anyglob($fx_name, $fx_globs, $fx_suffix) {');
382
+ lines.push(' foreach ($fx_glob in $fx_globs) { if (fx-globmatch $fx_name $fx_glob $fx_suffix) { return $true } }');
383
+ lines.push(' return $false');
384
+ lines.push('}');
385
+ lines.push('function fx-filewanted($fx_path, $fx_suffix) {');
386
+ lines.push(' if ($fx_fsel.Count -eq 0) { return $true }');
387
+ lines.push(' $fx_name = if ($fx_suffix) { [string]$fx_path } else { [IO.Path]::GetFileName([string]$fx_path) }');
388
+ lines.push(' $fx_keep = -not [bool]$fx_fsel[0].Keep');
389
+ lines.push(' foreach ($fx_rule in $fx_fsel) { if (fx-globmatch $fx_name $fx_rule.Glob $fx_suffix) { $fx_keep = [bool]$fx_rule.Keep } }');
390
+ lines.push(' return $fx_keep');
391
+ lines.push('}');
392
+ if (rec) {
393
+ lines.push('function fx-walkfiles($fx_root) {');
394
+ lines.push(' $fx_dirs = New-Object System.Collections.Stack');
395
+ lines.push(' $fx_dirs.Push([string]$fx_root)');
396
+ lines.push(' while ($fx_dirs.Count -gt 0) {');
397
+ lines.push(' $fx_cur = [string]$fx_dirs.Pop()');
398
+ lines.push(' foreach ($fx_item in @(Get-ChildItem -LiteralPath $fx_cur -Force -ErrorAction SilentlyContinue)) {');
399
+ lines.push(' if ($fx_item.PSIsContainer) {');
400
+ lines.push(' if (($fx_item.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0 -and -not (fx-anyglob $fx_item.Name $fx_excd $false)) { $fx_dirs.Push($fx_item.FullName) }');
401
+ lines.push(' } elseif (fx-filewanted $fx_item.FullName $false) { $fx_item.FullName }');
402
+ lines.push(' }');
403
+ lines.push(' }');
404
+ lines.push('}');
405
+ }
324
406
  lines.push('foreach ($fx_o in ' + psArray(fileWords) + ') {');
325
407
  lines.push(' foreach ($fx_g in (fx-glob $fx_o)) {');
326
408
  lines.push(" if (-not (Test-Path -LiteralPath $fx_g)) { [Console]::Error.WriteLine('grep: ' + $fx_g + ': No such file or directory'); $fx_err = $true; continue }");
327
409
  lines.push(' if (Test-Path -LiteralPath $fx_g -PathType Container) {');
328
410
  if (rec) {
411
+ lines.push(' $fx_dir = Get-Item -LiteralPath $fx_g');
412
+ lines.push(' if (fx-anyglob $fx_g $fx_excd $true) { continue }');
329
413
  lines.push(' $fx_recd = $true');
330
- lines.push(' $fx_subs = @(Get-ChildItem -LiteralPath $fx_g -Recurse -Force -File -ErrorAction SilentlyContinue)');
331
- lines.push(' if ($fx_inc.Count -gt 0) {');
332
- lines.push(" $fx_subs = @($fx_subs | Where-Object { $fx_ok = $false; foreach ($fx_gi in $fx_inc) { if ($_.Name -like $fx_gi) { $fx_ok = $true; break } }; $fx_ok })");
333
- lines.push(' }');
334
- lines.push(' foreach ($fx_s in $fx_subs) { $fx_srcs += $fx_s.FullName }');
414
+ lines.push(' $fx_srcs += @(fx-walkfiles $fx_dir.FullName)');
335
415
  }
336
416
  else {
337
417
  lines.push(" [Console]::Error.WriteLine('grep: ' + $fx_g + ': Is a directory'); $fx_err = $true");
338
418
  }
339
- lines.push(' } else { $fx_srcs += $fx_g }');
419
+ lines.push(' } elseif (fx-filewanted $fx_g $true) { $fx_srcs += $fx_g }');
340
420
  lines.push(' }');
341
421
  lines.push('}');
342
422
  lines.push('$fx_pre = $false');
@@ -1,2 +1,3 @@
1
- import { Handler } from '../registry.js';
1
+ import { CommandSpec, Handler } from '../registry.js';
2
+ export declare const specs: CommandSpec[];
2
3
  export declare const handlers: Record<string, Handler>;
@@ -691,8 +691,8 @@ const wc = (args) => {
691
691
  /* tee */
692
692
  /* ------------------------------------------------------------------ */
693
693
  const tee = (args, ctx) => {
694
- const { flags, operandWords } = parseWords(args);
695
- const append = flags.has('a') || flags.has('append');
694
+ const { flags, longs, operandWords } = parseWords(args);
695
+ const append = flags.has('a') || longs.has('--append');
696
696
  return [
697
697
  PS_WRITE_FN,
698
698
  fxTermLine(ctx.position),
@@ -1142,6 +1142,18 @@ const xargs = (args) => {
1142
1142
  ].join('\n');
1143
1143
  };
1144
1144
  /* ------------------------------------------------------------------ */
1145
+ export const specs = [
1146
+ {
1147
+ names: ['tee'],
1148
+ options: [
1149
+ { short: 'a', long: '--append', support: 'implemented' },
1150
+ ],
1151
+ effects: ['read', 'write'],
1152
+ platform: 'windows-ps51',
1153
+ dispatch: 'translated',
1154
+ handler: tee,
1155
+ },
1156
+ ];
1145
1157
  export const handlers = {
1146
1158
  echo,
1147
1159
  printf,
@@ -1149,7 +1161,6 @@ export const handlers = {
1149
1161
  head,
1150
1162
  tail,
1151
1163
  wc,
1152
- tee,
1153
1164
  nl,
1154
1165
  tac,
1155
1166
  md5sum,
@@ -3,14 +3,22 @@ export interface ExecResult {
3
3
  stdout: string;
4
4
  stderr: string;
5
5
  exitCode: number;
6
+ timedOut: boolean;
7
+ cancelled: boolean;
8
+ truncated: boolean;
9
+ spawnError?: 'ENOENT' | 'START';
6
10
  }
7
11
  export interface ExecOptions {
8
12
  timeoutMs?: number;
9
13
  /** Extra environment layered over the session (used by MCP per-call cwd). */
10
14
  cwd?: string;
15
+ signal?: AbortSignal;
16
+ stdoutLimit?: number;
17
+ stderrLimit?: number;
11
18
  }
12
19
  /** Session persists cwd and env across segments, like a real shell. */
13
20
  export declare class FauxnixSession {
21
+ id: string;
14
22
  cwd: string | null;
15
23
  env: Record<string, string>;
16
24
  /** Exit code of the previous segment — powers bash's `$?`. */
@@ -20,14 +28,18 @@ export declare class FauxnixSession {
20
28
  private scriptFile;
21
29
  private hostFile;
22
30
  private host;
23
- private runLock;
31
+ private lifecycleLock;
24
32
  constructor();
25
33
  private bindFiles;
34
+ private withLock;
26
35
  private syncFromDisk;
27
36
  private ensureHost;
28
37
  /** Boot powershell.exe now so the first run() is not the 1.1s cold start. */
29
38
  prewarm(): Promise<void>;
30
39
  dispose(): Promise<void>;
40
+ /** Kill the host and re-prewarm the same session object (no second FauxnixSession). */
41
+ reset(): Promise<void>;
42
+ private disposeUnlocked;
31
43
  /** env for the child powershell process. */
32
44
  childEnv(cwdOverride?: string, stdinFile?: string | null): NodeJS.ProcessEnv;
33
45
  run(plans: SegmentPlan[], opts?: ExecOptions): Promise<ExecResult>;
package/dist/executor.js CHANGED
@@ -5,8 +5,13 @@ import path from 'node:path';
5
5
  import { normalizeLiteralPath, wrapScript } from './translator.js';
6
6
  import { decodeOutput, normalizeHostNewlines, resolveNativePref } from './encoding.js';
7
7
  import { normalizeStderr } from './errors.js';
8
- import { PowerShellHost, PS_MISSING_MESSAGE } from './ps-host.js';
8
+ import { DEFAULT_STDERR_LIMIT, DEFAULT_STDOUT_LIMIT, PowerShellHost, PS_MISSING_MESSAGE, } from './ps-host.js';
9
9
  const DEFAULT_TIMEOUT_MS = 120_000;
10
+ const DEFAULT_WINDOWS_PATHEXT = '.COM;.EXE;.BAT;.CMD';
11
+ function hasEnvKey(env, name) {
12
+ const normalized = name.toUpperCase();
13
+ return Object.keys(env).some((key) => key.toUpperCase() === normalized);
14
+ }
10
15
  /** Resolve /dev/null and POSIX-ish literal targets to real Windows paths. */
11
16
  function winTarget(target) {
12
17
  const p = normalizeLiteralPath(target);
@@ -148,6 +153,7 @@ function planRedirects(redirects) {
148
153
  }
149
154
  /** Session persists cwd and env across segments, like a real shell. */
150
155
  export class FauxnixSession {
156
+ id;
151
157
  cwd = null;
152
158
  env = {};
153
159
  /** Exit code of the previous segment — powers bash's `$?`. */
@@ -157,16 +163,23 @@ export class FauxnixSession {
157
163
  scriptFile;
158
164
  hostFile;
159
165
  host = null;
160
- runLock = Promise.resolve();
166
+ lifecycleLock = Promise.resolve();
161
167
  constructor() {
162
- this.bindFiles(randomUUID().slice(0, 8));
168
+ this.id = randomUUID().slice(0, 8);
169
+ this.bindFiles(this.id);
163
170
  }
164
171
  bindFiles(id) {
172
+ this.id = id;
165
173
  this.cwdFile = path.join(os.tmpdir(), 'fauxnix-' + id + '-cwd.txt');
166
174
  this.envFile = path.join(os.tmpdir(), 'fauxnix-' + id + '-env.json');
167
175
  this.scriptFile = path.join(os.tmpdir(), 'fauxnix-' + id + '-script.ps1');
168
176
  this.hostFile = path.join(os.tmpdir(), 'fauxnix-' + id + '-host.ps1');
169
177
  }
178
+ withLock(fn) {
179
+ const done = this.lifecycleLock.then(fn, fn);
180
+ this.lifecycleLock = done.then(() => undefined, () => undefined);
181
+ return done;
182
+ }
170
183
  syncFromDisk() {
171
184
  try {
172
185
  if (existsSync(this.cwdFile)) {
@@ -196,10 +209,22 @@ export class FauxnixSession {
196
209
  return this.host;
197
210
  }
198
211
  /** Boot powershell.exe now so the first run() is not the 1.1s cold start. */
199
- async prewarm() {
200
- await this.ensureHost().ready();
212
+ prewarm() {
213
+ return this.withLock(async () => {
214
+ await this.ensureHost().ready();
215
+ });
201
216
  }
202
- async dispose() {
217
+ dispose() {
218
+ return this.withLock(() => this.disposeUnlocked());
219
+ }
220
+ /** Kill the host and re-prewarm the same session object (no second FauxnixSession). */
221
+ reset() {
222
+ return this.withLock(async () => {
223
+ await this.disposeUnlocked();
224
+ await this.ensureHost().ready();
225
+ });
226
+ }
227
+ async disposeUnlocked() {
203
228
  if (this.host) {
204
229
  await this.host.stop();
205
230
  this.host = null;
@@ -224,6 +249,14 @@ export class FauxnixSession {
224
249
  else
225
250
  env[k] = v;
226
251
  }
252
+ // The MCP SDK's safe Windows stdio environment omits PATHEXT. Without it,
253
+ // PowerShell cannot resolve extensionless native commands such as `node`.
254
+ // Windows environment names are case-insensitive, so preserve any explicit
255
+ // spelling/value supplied by the caller and only restore the OS default
256
+ // when no variant is present at all.
257
+ if (process.platform === 'win32' && !hasEnvKey(env, 'PATHEXT')) {
258
+ env.PATHEXT = DEFAULT_WINDOWS_PATHEXT;
259
+ }
227
260
  env.FAUXNIX_CWD_FILE = this.cwdFile;
228
261
  env.FAUXNIX_ENV_FILE = this.envFile;
229
262
  if (stdinFile)
@@ -242,16 +275,22 @@ export class FauxnixSession {
242
275
  return env;
243
276
  }
244
277
  run(plans, opts = {}) {
245
- const done = this.runLock.then(() => runPlans(plans, this, opts, () => this.syncFromDisk(), () => this.ensureHost()));
246
- this.runLock = done.then(() => undefined, () => undefined);
247
- return done;
278
+ return this.withLock(() => runPlans(plans, this, opts, () => this.syncFromDisk(), () => this.ensureHost()));
248
279
  }
249
280
  }
250
281
  async function runPlans(plans, session, opts, afterSegment, ensureHost) {
251
282
  const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
283
+ const deadline = Date.now() + timeoutMs;
284
+ const stdoutLimit = opts.stdoutLimit ?? DEFAULT_STDOUT_LIMIT;
285
+ const stderrLimit = opts.stderrLimit ?? DEFAULT_STDERR_LIMIT;
286
+ const timeoutMessage = '\nbash: command timed out after ' + Math.round(timeoutMs / 1000) + 's';
252
287
  let stdout = '';
253
288
  let stderr = '';
254
289
  let exitCode = 0;
290
+ let timedOut = false;
291
+ let cancelled = false;
292
+ let truncated = false;
293
+ let spawnError;
255
294
  // bash list semantics: `a && b ; c` runs c regardless of a; `a && b && c`
256
295
  // skips b AND c when a fails. chainOk models the value of the current
257
296
  // &&/|| chain; `;` segments always run and restart the chain.
@@ -267,6 +306,19 @@ async function runPlans(plans, session, opts, afterSegment, ensureHost) {
267
306
  continue;
268
307
  if (plan.op === '||' && chainOk)
269
308
  continue;
309
+ if (opts.signal?.aborted) {
310
+ cancelled = true;
311
+ exitCode = 130;
312
+ session.prevExit = exitCode;
313
+ break;
314
+ }
315
+ if (Date.now() >= deadline) {
316
+ stderr += timeoutMessage;
317
+ exitCode = 124;
318
+ timedOut = true;
319
+ session.prevExit = exitCode;
320
+ break;
321
+ }
270
322
  const red = planRedirects(plan.redirects);
271
323
  red.stdinFile = red.stdinFile ? resolveTarget(red.stdinFile) : null;
272
324
  red.stdoutFile = red.stdoutFile ? resolveTarget(red.stdoutFile) : null;
@@ -339,19 +391,41 @@ async function runPlans(plans, session, opts, afterSegment, ensureHost) {
339
391
  chainOk = false;
340
392
  continue;
341
393
  }
394
+ const remainingMs = deadline - Date.now();
395
+ if (opts.signal?.aborted) {
396
+ cancelled = true;
397
+ exitCode = 130;
398
+ session.prevExit = exitCode;
399
+ break;
400
+ }
401
+ if (remainingMs <= 0) {
402
+ stderr += timeoutMessage;
403
+ exitCode = 124;
404
+ timedOut = true;
405
+ session.prevExit = exitCode;
406
+ break;
407
+ }
342
408
  const encoded = wrapScript(plan.body, { mode: 'host' });
343
409
  const inv = await ensureHost().invoke(encoded, {
344
410
  FAUXNIX_CWD: currentDir,
345
411
  FAUXNIX_PREV_EXIT: session.prevExit === null ? '' : String(session.prevExit),
346
412
  FAUXNIX_STDIN_FILE: red.stdinFile || '',
347
- }, timeoutMs);
348
- if (inv.spawnError === 'ENOENT') {
413
+ }, remainingMs, opts.signal);
414
+ if (inv.spawnError === 'ENOENT' || inv.spawnError === 'START') {
349
415
  stderr += inv.stderr.toString('utf8') || PS_MISSING_MESSAGE;
350
416
  exitCode = 127;
417
+ spawnError = inv.spawnError;
351
418
  session.prevExit = exitCode;
352
419
  chainOk = false;
353
420
  continue;
354
421
  }
422
+ if (inv.cancelled) {
423
+ cancelled = true;
424
+ exitCode = 130;
425
+ session.prevExit = exitCode;
426
+ chainOk = false;
427
+ break;
428
+ }
355
429
  afterSegment();
356
430
  const decodePref = resolveNativePref();
357
431
  // GNU line discipline: the PS host terminates Write-Output lines with
@@ -360,7 +434,7 @@ async function runPlans(plans, session, opts, afterSegment, ensureHost) {
360
434
  let segOut = normalizeHostNewlines(decodeOutput(inv.stdout, decodePref));
361
435
  let segErr = normalizeHostNewlines(normalizeStderr(decodeOutput(inv.stderr, decodePref)));
362
436
  if (inv.timedOut) {
363
- segErr += '\nbash: command timed out after ' + Math.round(timeoutMs / 1000) + 's';
437
+ segErr += timeoutMessage;
364
438
  }
365
439
  if (red.mergeStderr) {
366
440
  segOut += (segOut && !segOut.endsWith('\n') && segErr ? '\n' : '') + segErr;
@@ -402,7 +476,11 @@ async function runPlans(plans, session, opts, afterSegment, ensureHost) {
402
476
  }
403
477
  stdout += segOut;
404
478
  stderr += segErr;
405
- exitCode = inv.timedOut ? 124 : inv.exitCode;
479
+ if (inv.truncated)
480
+ truncated = true;
481
+ exitCode = inv.timedOut ? 124 : inv.cancelled ? 130 : inv.exitCode;
482
+ if (inv.timedOut)
483
+ timedOut = true;
406
484
  session.prevExit = exitCode;
407
485
  chainOk = exitCode === 0;
408
486
  // Only inherit cwd from a segment that actually ran and whose
@@ -410,10 +488,32 @@ async function runPlans(plans, session, opts, afterSegment, ensureHost) {
410
488
  // not move later relative redirects.
411
489
  if (redirectOk && session.cwd)
412
490
  currentDir = session.cwd;
491
+ if (inv.timedOut)
492
+ break;
413
493
  }
414
494
  finally {
415
495
  closePrepFds(prepFds);
416
496
  }
417
497
  }
418
- return { stdout, stderr, exitCode };
498
+ const clippedOut = clipUtf8(stdout, stdoutLimit);
499
+ const clippedErr = clipUtf8(stderr, stderrLimit);
500
+ if (clippedOut.truncated || clippedErr.truncated)
501
+ truncated = true;
502
+ return {
503
+ stdout: clippedOut.text,
504
+ stderr: clippedErr.text,
505
+ exitCode,
506
+ timedOut,
507
+ cancelled,
508
+ truncated,
509
+ spawnError,
510
+ };
511
+ }
512
+ function clipUtf8(text, limit) {
513
+ if (Buffer.byteLength(text, 'utf8') <= limit)
514
+ return { text, truncated: false };
515
+ let end = text.length;
516
+ while (end > 0 && Buffer.byteLength(text.slice(0, end), 'utf8') > limit)
517
+ end--;
518
+ return { text: text.slice(0, end), truncated: true };
419
519
  }
package/dist/mcp.d.ts CHANGED
@@ -1,4 +1,23 @@
1
+ import { ExecResult } from './executor.js';
1
2
  import { translatePipelineBody } from './translator.js';
2
3
  import './commands/install-all.js';
4
+ export declare function formatBashText(r: Pick<ExecResult, 'stdout' | 'stderr' | 'exitCode' | 'timedOut' | 'cancelled'>): string;
5
+ export declare function bashToolResult(r: ExecResult, sessionId: string, infra: boolean): {
6
+ isError?: true | undefined;
7
+ content: {
8
+ type: "text";
9
+ text: string;
10
+ }[];
11
+ structuredContent: {
12
+ schemaVersion: 1;
13
+ stdout: string;
14
+ stderr: string;
15
+ exitCode: number;
16
+ timedOut: boolean;
17
+ cancelled: boolean;
18
+ truncated: boolean;
19
+ sessionId: string;
20
+ };
21
+ };
3
22
  export declare function startMcpServer(): Promise<void>;
4
23
  export { translatePipelineBody };
package/dist/mcp.js CHANGED
@@ -1,16 +1,12 @@
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';
5
3
  import { z } from 'zod';
6
4
  import { FauxnixSession } from './executor.js';
7
5
  import { parseCommand } from './parser.js';
8
6
  import { translateCommandList, wrapScript, translatePipelineBody } from './translator.js';
9
7
  import { registeredNames } from './registry.js';
8
+ import { packageVersion } from './version.js';
10
9
  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;
14
10
  const TOOL_NAME = process.env.FAUXNIX_TOOL_NAME || 'bash';
15
11
  const EXEC_ANNOTATIONS = {
16
12
  readOnlyHint: false,
@@ -37,15 +33,46 @@ Output is formatted to look like GNU/Linux tooling (ls -l, ps aux, df -h ...), e
37
33
 
38
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(', ')}...).
39
35
  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/elif/else/fi, for-in loops, and word-level \$((...)) arithmetic expansion are supported.
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.
41
37
 
42
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.
43
- Exit codes follow bash conventions (0 ok, 1 fail, 2 usage/serious, 127 command not found, 124 timeout).
39
+ 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.
44
40
 
45
41
  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.`;
42
+ export function formatBashText(r) {
43
+ const parts = [];
44
+ if (r.stdout.length)
45
+ parts.push(r.stdout.replace(/\n$/, ''));
46
+ if (r.stderr.length)
47
+ parts.push(r.stderr.replace(/\n$/, ''));
48
+ if (r.cancelled)
49
+ parts.push('Cancelled');
50
+ else if (r.timedOut)
51
+ parts.push('Exit code: 124');
52
+ else if (r.exitCode !== 0)
53
+ parts.push('Exit code: ' + r.exitCode);
54
+ return parts.length ? parts.join('\n') : '(no output)';
55
+ }
56
+ export function bashToolResult(r, sessionId, infra) {
57
+ const structuredContent = {
58
+ schemaVersion: 1,
59
+ stdout: r.stdout,
60
+ stderr: r.stderr,
61
+ exitCode: r.exitCode,
62
+ timedOut: r.timedOut,
63
+ cancelled: r.cancelled,
64
+ truncated: r.truncated,
65
+ sessionId,
66
+ };
67
+ return {
68
+ content: [{ type: 'text', text: formatBashText(r) }],
69
+ structuredContent,
70
+ ...(infra ? { isError: true } : {}),
71
+ };
72
+ }
46
73
  export async function startMcpServer() {
47
- const server = new McpServer({ name: 'fauxnix', version: pkgVersion }, { capabilities: { tools: {} } });
48
- let session = new FauxnixSession();
74
+ const server = new McpServer({ name: 'fauxnix', version: packageVersion }, { capabilities: { tools: {} } });
75
+ const session = new FauxnixSession();
49
76
  await session.prewarm();
50
77
  server.tool(TOOL_NAME, TOOL_DESCRIPTION, {
51
78
  command: z.string().describe('The bash-style command line to run'),
@@ -56,23 +83,26 @@ export async function startMcpServer() {
56
83
  .max(600_000)
57
84
  .optional()
58
85
  .describe('Timeout in milliseconds (default 120000)'),
59
- }, EXEC_ANNOTATIONS, async ({ command, timeout_ms }) => {
86
+ }, EXEC_ANNOTATIONS, async ({ command, timeout_ms }, extra) => {
60
87
  try {
61
88
  const plans = translateCommandList(parseCommand(command));
62
- const result = await session.run(plans, { timeoutMs: timeout_ms });
63
- const parts = [];
64
- if (result.stdout.trim())
65
- parts.push(result.stdout.replace(/\n$/, ''));
66
- if (result.stderr.trim())
67
- parts.push(result.stderr.replace(/\n$/, ''));
68
- if (result.exitCode !== 0)
69
- parts.push('Exit code: ' + result.exitCode);
70
- const text = parts.length ? parts.join('\n') : '(no output)';
71
- return { content: [{ type: 'text', text }] };
89
+ const result = await session.run(plans, {
90
+ timeoutMs: timeout_ms,
91
+ signal: extra.signal,
92
+ });
93
+ const infra = result.spawnError === 'ENOENT' || result.spawnError === 'START';
94
+ return bashToolResult(result, session.id, infra);
72
95
  }
73
96
  catch (e) {
74
97
  const msg = e instanceof Error ? e.message : String(e);
75
- return { content: [{ type: 'text', text: msg }], isError: true };
98
+ return bashToolResult({
99
+ stdout: '',
100
+ stderr: msg,
101
+ exitCode: 2,
102
+ timedOut: false,
103
+ cancelled: false,
104
+ truncated: false,
105
+ }, session.id, true);
76
106
  }
77
107
  });
78
108
  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 }) => {
@@ -94,18 +124,45 @@ export async function startMcpServer() {
94
124
  .describe('"status" shows the session state (cwd, tracked env keys); "reset" clears it back to a fresh shell'),
95
125
  }, SESSION_ANNOTATIONS, async ({ action }) => {
96
126
  if (action === 'reset') {
97
- await session.dispose();
98
- session = new FauxnixSession();
99
- await session.prewarm();
127
+ await session.reset();
100
128
  return { content: [{ type: 'text', text: 'fauxnix: session reset' }] };
101
129
  }
102
130
  const envKeys = Object.keys(session.env).sort();
103
131
  const text = 'cwd: ' + (session.cwd ?? '(inherit from server start)') +
104
132
  '\nenv keys: ' + (envKeys.length ? envKeys.join(', ') : '(none tracked)') +
133
+ '\nsession: ' + session.id +
105
134
  '\ncommands registered: ' + registeredNames().length;
106
135
  return { content: [{ type: 'text', text }] };
107
136
  });
108
137
  const transport = new StdioServerTransport();
138
+ let shuttingDown = false;
139
+ const shutdown = async () => {
140
+ if (shuttingDown)
141
+ return;
142
+ shuttingDown = true;
143
+ await session.dispose();
144
+ try {
145
+ await server.close();
146
+ }
147
+ catch {
148
+ /* ignore */
149
+ }
150
+ };
151
+ process.stdin.on('end', () => {
152
+ void shutdown();
153
+ });
154
+ process.stdin.on('close', () => {
155
+ void shutdown();
156
+ });
157
+ process.on('SIGINT', () => {
158
+ void shutdown();
159
+ });
160
+ process.on('SIGTERM', () => {
161
+ void shutdown();
162
+ });
163
+ transport.onclose = () => {
164
+ void shutdown();
165
+ };
109
166
  await server.connect(transport);
110
167
  }
111
168
  // keep referenced for tree-shaking clarity