fauxnix-cli 0.6.0 → 0.7.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 CHANGED
@@ -193,11 +193,10 @@ fauxnix optimizes for the commands agents actually run. Documented deviations:
193
193
  word expansion precedes the temporary environment).
194
194
  - `yes` is capped at 65,536 lines — PS 5.1 pipelines cannot signal upstream producers to stop, so
195
195
  an unbounded `yes | head` would hang.
196
- - `tail -f`, `eval`, `alias`, heredocs, `while`/`until`/`case`, word-level
197
- `$((...))` arithmetic expansion
196
+ - `tail -f`, `eval`, `alias`, heredocs, `while`/`until`/`case`,
198
197
  and background `&` are rejected with actionable error messages instead of misbehaving.
199
- (`if/then/else/fi`, `for x in ...`, backtick substitution, `command -v`, pipeline `read`,
200
- and dotenv-style `source` are supported.)
198
+ (`if/then/elif/else/fi`, `for x in ...`, backtick substitution, `command -v`, pipeline `read`,
199
+ dotenv-style `source`, and word-level `$((...))` arithmetic expansion are supported.)
201
200
  - `command -v <builtin>` prints `/usr/bin/<name>` where bash prints the bare builtin name;
202
201
  exit codes and empty-result semantics match.
203
202
  - `chmod` maps only the read-only bit; exec bits are no-ops on Windows. `chown` is a silent no-op
@@ -231,6 +230,9 @@ Architecture map: `src/parser.ts` (bash subset → AST) · `src/translator.ts` (
231
230
  executor wrapper) · `src/executor.ts` (spawn, redirects, session persistence) ·
232
231
  `src/commands/*.ts` (per-command generators) · `src/mcp.ts` (MCP server) · `src/cli.ts`.
233
232
 
233
+ Roadmap: [docs/rfc-roadmap-to-1.0.md](docs/rfc-roadmap-to-1.0.md) — tracks, milestones,
234
+ and the RFC process for proposing waves.
235
+
234
236
  ## License
235
237
 
236
238
  MIT © 20000419
package/dist/ast.d.ts CHANGED
@@ -8,12 +8,12 @@
8
8
  * - quoting: 'literal' "interp $VAR $(cmd)"
9
9
  * - variables: $VAR ${VAR} ${VAR[n]} plus special cases ($HOME $USER $PATH ...)
10
10
  * - command substitution: $(...) and `...` (recursively translated)
11
+ * - arithmetic expansion: $((...)) (existing fx-arith engine)
11
12
  * - env assignment prefix: VAR=value cmd
12
13
  *
13
14
  * Explicitly unsupported (parser throws a helpful FauxnixError):
14
15
  * heredocs, subshells (...), background &, while/until/case,
15
- * word-level $((...)) arithmetic expansion, globs inside quotes,
16
- * process substitution <(...).
16
+ * globs inside quotes, process substitution <(...).
17
17
  */
18
18
  export interface CommandList {
19
19
  kind: 'CommandList';
@@ -89,6 +89,9 @@ export type WordPart = {
89
89
  } | {
90
90
  kind: 'CmdSub';
91
91
  cmd: string;
92
+ } | {
93
+ kind: 'Arith';
94
+ parts: WordPart[];
92
95
  };
93
96
  export declare function wordToString(w: Word): string;
94
97
  /** True when every part is unquoted Text and the concatenation equals `tok`. */
package/dist/ast.js CHANGED
@@ -8,12 +8,12 @@
8
8
  * - quoting: 'literal' "interp $VAR $(cmd)"
9
9
  * - variables: $VAR ${VAR} ${VAR[n]} plus special cases ($HOME $USER $PATH ...)
10
10
  * - command substitution: $(...) and `...` (recursively translated)
11
+ * - arithmetic expansion: $((...)) (existing fx-arith engine)
11
12
  * - env assignment prefix: VAR=value cmd
12
13
  *
13
14
  * Explicitly unsupported (parser throws a helpful FauxnixError):
14
15
  * heredocs, subshells (...), background &, while/until/case,
15
- * word-level $((...)) arithmetic expansion, globs inside quotes,
16
- * process substitution <(...).
16
+ * globs inside quotes, process substitution <(...).
17
17
  */
18
18
  export function wordToString(w) {
19
19
  return w.map(partToString).join('');
@@ -40,6 +40,8 @@ function partToString(p) {
40
40
  return p.index !== undefined ? `\${${p.name}[${p.index}]}` : `$${p.name}`;
41
41
  case 'CmdSub':
42
42
  return '$(' + p.cmd + ')';
43
+ case 'Arith':
44
+ return '$((' + p.parts.map(partToString).join('') + '))';
43
45
  }
44
46
  }
45
47
  /** Best-effort "raw literal" view: is this word free of interpolation? */
@@ -1,6 +1,6 @@
1
1
  import { FauxnixParseError, isUnquotedLiteral, wordToString } from '../ast.js';
2
2
  import { lookup, parseWords, psStr, registeredNames } from '../registry.js';
3
- import { argListExpr, exprOfWord, operandExpr, translateSimple, wrapTempEnv, encodeSetValExpr, escapeDq, translateCmdSub, normalizeLiteralPath, pathExpr, paramExpr, varExpr, } from '../translator.js';
3
+ import { argListExpr, exprOfWord, operandExpr, translateSimple, wrapTempEnv, encodeSetValExpr, escapeDq, translateCmdSub, normalizeLiteralPath, pathExpr, paramExpr, varExpr, arithExpr, setArithHelperPreamble, } from '../translator.js';
4
4
  import { handlers as textIoHandlers } from './text-io.js';
5
5
  /* ------------------------------------------------------------------ */
6
6
  /* Shared TS helpers */
@@ -1373,6 +1373,7 @@ const FX_ENVGET_FN = [
1373
1373
  ' return [string]$fx_ev.Value',
1374
1374
  '}',
1375
1375
  ].join('\n');
1376
+ setArithHelperPreamble(FX_TNK_FN + '\n' + FX_ENVGET_FN);
1376
1377
  /** Like exprOfWord, but $var is an exact-case env lookup (bash, not $env:). */
1377
1378
  function kshExprOfWord(w) {
1378
1379
  const expanded = [];
@@ -1392,6 +1393,9 @@ function kshExprOfWord(w) {
1392
1393
  }
1393
1394
  if (tilde && expanded.length === 0)
1394
1395
  return '(fx-home)';
1396
+ if (!tilde && expanded.length === 1 && expanded[0].kind === 'Arith') {
1397
+ return arithExpr(expanded[0].parts);
1398
+ }
1395
1399
  if (!tilde && expanded.length === 1 && expanded[0].kind === 'Var') {
1396
1400
  if (expanded[0].param) {
1397
1401
  return paramExpr(expanded[0].name, expanded[0].param.op, expanded[0].param.word);
@@ -1434,6 +1438,9 @@ function kshExprOfWord(w) {
1434
1438
  // [[ ]] does not IFS-split, so keep the newline contract.
1435
1439
  out += '$(' + translateCmdSub(p.cmd, true) + ')';
1436
1440
  break;
1441
+ case 'Arith':
1442
+ out += arithExpr(p.parts);
1443
+ break;
1437
1444
  }
1438
1445
  };
1439
1446
  for (const p of expanded)
@@ -25,6 +25,8 @@ export declare class FauxnixSession {
25
25
  private bindFiles;
26
26
  private syncFromDisk;
27
27
  private ensureHost;
28
+ /** Boot powershell.exe now so the first run() is not the 1.1s cold start. */
29
+ prewarm(): Promise<void>;
28
30
  dispose(): Promise<void>;
29
31
  /** env for the child powershell process. */
30
32
  childEnv(cwdOverride?: string, stdinFile?: string | null): NodeJS.ProcessEnv;
package/dist/executor.js CHANGED
@@ -195,6 +195,10 @@ export class FauxnixSession {
195
195
  }
196
196
  return this.host;
197
197
  }
198
+ /** Boot powershell.exe now so the first run() is not the 1.1s cold start. */
199
+ async prewarm() {
200
+ await this.ensureHost().ready();
201
+ }
198
202
  async dispose() {
199
203
  if (this.host) {
200
204
  await this.host.stop();
package/dist/mcp.js CHANGED
@@ -37,15 +37,16 @@ Output is formatted to look like GNU/Linux tooling (ls -l, ps aux, df -h ...), e
37
37
 
38
38
  Supported: pipes (|), && / || / ;, redirections (> >> 2> 2>&1 < /dev/null), variables ($VAR $HOME ~), command substitution $(...), and ${registeredNames().length}+ coreutils-style commands (${registeredNames().slice(0, 18).join(', ')}...).
39
39
  Unknown commands (git, node, npm, python, cargo...) are passed through and executed natively with argv-style quoting.
40
- Not supported: heredocs, while/until/case, word-level \$((...)) arithmetic expansion, background jobs. if/then/else/fi and for-in loops are supported.
40
+ Not supported: heredocs, while/until/case, background jobs. if/then/elif/else/fi, for-in loops, and word-level \$((...)) arithmetic expansion are supported.
41
41
 
42
- CWD, environment variables, export/unset and cd persist across calls within this session — a resident PowerShell 5.1 host is reused, so batching with ; or && is still nicer but many tiny calls no longer each pay a powershell.exe spawn.
42
+ 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
43
  Exit codes follow bash conventions (0 ok, 1 fail, 2 usage/serious, 127 command not found, 124 timeout).
44
44
 
45
45
  Platform requirement: the execution backend is native Windows PowerShell 5.1+. On hosts without PowerShell on PATH (e.g. Linux containers/sandboxes), the bash tool returns exit code 127 with an actionable error instead of running the command.`;
46
46
  export async function startMcpServer() {
47
47
  const server = new McpServer({ name: 'fauxnix', version: pkgVersion }, { capabilities: { tools: {} } });
48
48
  let session = new FauxnixSession();
49
+ await session.prewarm();
49
50
  server.tool(TOOL_NAME, TOOL_DESCRIPTION, {
50
51
  command: z.string().describe('The bash-style command line to run'),
51
52
  timeout_ms: z
@@ -95,6 +96,7 @@ export async function startMcpServer() {
95
96
  if (action === 'reset') {
96
97
  await session.dispose();
97
98
  session = new FauxnixSession();
99
+ await session.prewarm();
98
100
  return { content: [{ type: 'text', text: 'fauxnix: session reset' }] };
99
101
  }
100
102
  const envKeys = Object.keys(session.env).sort();
package/dist/parser.js CHANGED
@@ -217,7 +217,32 @@ function readBacktick(input, i) {
217
217
  }
218
218
  throw new FauxnixParseError('fauxnix: unclosed backtick');
219
219
  }
220
- /** Parse $VAR, ${VAR}, $(cmd substitution). Returns null when not a valid dollar construct. */
220
+ /** Expand `$…` constructs that appear inside `$((…))`. */
221
+ function readArithParts(input) {
222
+ const parts = [];
223
+ let buf = '';
224
+ let i = 0;
225
+ while (i < input.length) {
226
+ if (input[i] === '$') {
227
+ const v = readDollar(input, i);
228
+ if (v) {
229
+ if (buf) {
230
+ parts.push({ kind: 'Text', text: buf });
231
+ buf = '';
232
+ }
233
+ parts.push(v.part);
234
+ i = v.next;
235
+ continue;
236
+ }
237
+ }
238
+ buf += input[i];
239
+ i++;
240
+ }
241
+ if (buf)
242
+ parts.push({ kind: 'Text', text: buf });
243
+ return parts;
244
+ }
245
+ /** Parse $VAR, ${VAR}, $(cmd substitution), $((arith)). Returns null when not a valid dollar construct. */
221
246
  function readDollar(input, i) {
222
247
  const n = input.length;
223
248
  if (input[i] !== '$')
@@ -256,11 +281,41 @@ function readDollar(input, i) {
256
281
  }
257
282
  return { part: { kind: 'Var', name }, next: end + 1 };
258
283
  }
259
- // $((...)) is arithmetic expansion, not command substitution of a
260
- // parenthesized body. Today it was parsed as $( (expr) ) and became an
261
- // empty/confusing command. Reject loudly until word-level arith lands.
284
+ // $((...)) arithmetic expansion distinct from `$( (cmd) )` (space after
285
+ // the first paren is command substitution of a grouped body).
262
286
  if (input[j] === '(' && j + 1 < n && input[j + 1] === '(') {
263
- throw new FauxnixParseError('fauxnix: $((...)) arithmetic expansion is not supported; compute the value in the agent or compare with [[ $n -eq m ]]');
287
+ let depth = 0;
288
+ let k = j;
289
+ while (k < n) {
290
+ const c = input[k];
291
+ if (c === "'" || c === '"') {
292
+ const q = c;
293
+ k++;
294
+ while (k < n && input[k] !== q) {
295
+ if (input[k] === '\\')
296
+ k++;
297
+ k++;
298
+ }
299
+ k++;
300
+ continue;
301
+ }
302
+ if (c === '(')
303
+ depth++;
304
+ else if (c === ')') {
305
+ depth--;
306
+ if (depth === 0) {
307
+ k++;
308
+ break;
309
+ }
310
+ }
311
+ k++;
312
+ }
313
+ if (depth !== 0)
314
+ throw new FauxnixParseError('fauxnix: unclosed $(( ))');
315
+ return {
316
+ part: { kind: 'Arith', parts: readArithParts(input.slice(j + 2, k - 2)) },
317
+ next: k,
318
+ };
264
319
  }
265
320
  // $(cmd substitution) — captured with balanced parens; the translator
266
321
  // recursively translates this text before embedding it.
@@ -642,14 +697,24 @@ export function parseCommand(input) {
642
697
  }
643
698
  return { kind: 'CommandList', segments };
644
699
  };
645
- const parseIf = () => {
646
- expectKw('if');
700
+ const parseIf = (start = 'if') => {
701
+ expectKw(start);
647
702
  const test = parseListUntil(['then']);
648
703
  expectKw('then');
649
704
  const thenL = parseListUntil(['else', 'elif', 'fi']);
650
705
  let elseL;
651
706
  if (peekKw() === 'elif') {
652
- throw new FauxnixParseError('fauxnix: elif is not supported yet; use else + if');
707
+ // bash `elif` is `else` + nested `if`; the innermost clause eats `fi`.
708
+ elseL = {
709
+ kind: 'CommandList',
710
+ segments: [
711
+ {
712
+ op: ';',
713
+ pipeline: { kind: 'Pipeline', commands: [parseIf('elif')] },
714
+ },
715
+ ],
716
+ };
717
+ return { kind: 'If', test, then: thenL, else: elseL, redirects: [] };
653
718
  }
654
719
  if (peekKw() === 'else') {
655
720
  next();
package/dist/ps-host.d.ts CHANGED
@@ -37,6 +37,8 @@ export declare class PowerShellHost {
37
37
  private startLock;
38
38
  private invokeLock;
39
39
  constructor(hostFile: string, envFn: () => NodeJS.ProcessEnv);
40
+ /** Start the resident process and wait for the ready handshake (B1 prewarm). */
41
+ ready(): Promise<HostInvokeResult | null>;
40
42
  invoke(script: string, env: HostRequestEnv, timeoutMs: number): Promise<HostInvokeResult>;
41
43
  stop(): Promise<void>;
42
44
  private invokeSerial;
package/dist/ps-host.js CHANGED
@@ -46,6 +46,10 @@ export class PowerShellHost {
46
46
  this.hostFile = hostFile;
47
47
  this.envFn = envFn;
48
48
  }
49
+ /** Start the resident process and wait for the ready handshake (B1 prewarm). */
50
+ async ready() {
51
+ return this.ensureStarted();
52
+ }
49
53
  async invoke(script, env, timeoutMs) {
50
54
  const run = this.invokeLock.then(() => this.invokeSerial(script, env, timeoutMs));
51
55
  this.invokeLock = run.then(() => undefined, () => undefined);
@@ -1,4 +1,4 @@
1
- import { Assignment, CommandList, Redirect, SimpleCommand, IfCommand, ForCommand, Word } from './ast.js';
1
+ import { Assignment, CommandList, Redirect, SimpleCommand, IfCommand, ForCommand, Word, WordPart } from './ast.js';
2
2
  import { PipelineCtx } from './registry.js';
3
3
  /** `${name:-word}` and friends using case-exact fx-scalar0. */
4
4
  export declare function paramExpr(name: string, op: ':-' | ':=' | ':+' | ':?' | '-' | '+' | '?', word: string): string;
@@ -25,6 +25,10 @@ export declare function pathExpr(s: string): string;
25
25
  export declare function exprOfWord(w: Word, opts?: {
26
26
  preserveCmdSub?: boolean;
27
27
  }): string;
28
+ /** Registered by sysinfo so wrapScript can emit fx-arith without a circular import. */
29
+ export declare function setArithHelperPreamble(s: string): void;
30
+ /** PowerShell expression: evaluate `$((…))` via fx-arith; errors are loud, expansion empty. */
31
+ export declare function arithExpr(parts: WordPart[]): string;
28
32
  /** Literal text of a word when it contains no interpolation, else null. */
29
33
  export declare function literalOfWord(w: Word): string | null;
30
34
  /**
@@ -161,6 +161,9 @@ export function exprOfWord(w, opts) {
161
161
  if (expanded.length === 1 && expanded[0].kind === 'CmdSub') {
162
162
  return '$(' + translateCmdSub(expanded[0].cmd, opts?.preserveCmdSub === true) + ')';
163
163
  }
164
+ if (expanded.length === 1 && expanded[0].kind === 'Arith') {
165
+ return arithExpr(expanded[0].parts);
166
+ }
164
167
  const literal = expanded.every((p) => p.kind === 'Text' || p.kind === 'SingleQuoted');
165
168
  if (literal) {
166
169
  const text = expanded.map((p) => p.text).join('');
@@ -186,6 +189,9 @@ export function exprOfWord(w, opts) {
186
189
  case 'CmdSub':
187
190
  out += '$(' + translateCmdSub(p.cmd, quoted || opts?.preserveCmdSub === true) + ')';
188
191
  break;
192
+ case 'Arith':
193
+ out += arithExpr(p.parts);
194
+ break;
189
195
  }
190
196
  };
191
197
  for (const p of expanded)
@@ -193,6 +199,62 @@ export function exprOfWord(w, opts) {
193
199
  out += '"';
194
200
  return out;
195
201
  }
202
+ let arithHelperPreamble = '';
203
+ /** Registered by sysinfo so wrapScript can emit fx-arith without a circular import. */
204
+ export function setArithHelperPreamble(s) {
205
+ arithHelperPreamble = s;
206
+ }
207
+ function injectArithHelpers(body) {
208
+ if (!arithHelperPreamble)
209
+ return body;
210
+ if (!/\bfx-arith\b/.test(body))
211
+ return body;
212
+ if (/function\s+fx-arith\b/.test(body))
213
+ return body;
214
+ return arithHelperPreamble + '\n' + body;
215
+ }
216
+ /** PowerShell expression: evaluate `$((…))` via fx-arith; errors are loud, expansion empty. */
217
+ export function arithExpr(parts) {
218
+ const src = arithSourceExpr(parts);
219
+ return ('$(try { [string](fx-arith (' +
220
+ src +
221
+ ')) } catch { [Console]::Error.WriteLine((\'bash: \' + [string](' +
222
+ src +
223
+ ') + \': integer expression expected\')); $script:fx_exit = 1; \'\' })');
224
+ }
225
+ function arithSourceExpr(parts) {
226
+ if (parts.length === 0)
227
+ return "''";
228
+ if (parts.every((p) => p.kind === 'Text')) {
229
+ return psStr(parts.map((p) => p.text).join(''));
230
+ }
231
+ let out = '"';
232
+ const emit = (p) => {
233
+ switch (p.kind) {
234
+ case 'Text':
235
+ case 'SingleQuoted':
236
+ out += escapeDq(p.text);
237
+ break;
238
+ case 'DoubleQuoted':
239
+ for (const q of p.parts)
240
+ emit(q);
241
+ break;
242
+ case 'Var':
243
+ out += '$(' + varExpr(p.name, p.index, p.param, p.length === true) + ')';
244
+ break;
245
+ case 'CmdSub':
246
+ out += '$(' + translateCmdSub(p.cmd, true) + ')';
247
+ break;
248
+ case 'Arith':
249
+ out += arithExpr(p.parts);
250
+ break;
251
+ }
252
+ };
253
+ for (const p of parts)
254
+ emit(p);
255
+ out += '"';
256
+ return out;
257
+ }
196
258
  /** Literal text of a word when it contains no interpolation, else null. */
197
259
  export function literalOfWord(w) {
198
260
  if (!w.every((p) => p.kind === 'Text' || p.kind === 'SingleQuoted'))
@@ -895,6 +957,7 @@ function wrapBodyAndPersist(body, exitProcess) {
895
957
  */
896
958
  export function wrapScript(body, opts = {}) {
897
959
  const mode = opts.mode ?? 'spawn';
960
+ body = injectArithHelpers(body);
898
961
  const needed = mode === 'host' ? new Set() : wrapHelpersNeeded(body);
899
962
  const lines = mode === 'host'
900
963
  ? wrapCwdPreamble()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fauxnix-cli",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Fauxnix — run Linux-style commands on Windows via deterministic PowerShell translation. No VM, no WSL. MCP server + CLI for AI agents.",
5
5
  "type": "module",
6
6
  "bin": {