fauxnix-cli 0.9.3 → 0.12.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/errors.js CHANGED
@@ -43,7 +43,7 @@ function unescapeClixml(t) {
43
43
  .trim();
44
44
  }
45
45
  /**
46
- * When stderr is redirected, powershell.exe serializes error records as
46
+ * When stderr is redirected, PowerShell can serialize error records as
47
47
  * CLIXML (`#< CLIXML` + XML). Unwrap the real message lines and drop
48
48
  * progress records, so agents see plain bash-style text.
49
49
  */
@@ -76,8 +76,10 @@ export function normalizeStderr(stderr) {
76
76
  let m = line.match(/^The term '(.+?)' is not recognized/);
77
77
  if (m)
78
78
  return commandNotFound(m[1]);
79
- // zh-CN: 无法将"x"项识别为 cmdlet、函数、脚本文件或可运行程序的名称
80
- m = line.match(/^无法将["'”]?([^"'”]+)["'”]?项识别为/);
79
+ // zh-CN: & : 无法将“x”项识别为 cmdlet、函数、脚本文件或可运行程序的名称。
80
+ // Require the PowerShell-specific suffix so ordinary Chinese prose that
81
+ // happens to contain “无法将…项识别为” is not rewritten.
82
+ m = line.match(/^(?:\S+\s*:\s*)?无法将[“"'‘]?(.+?)[”"'’]?项识别为\s*cmdlet、函数、脚本文件或可运行程序的名称(?:。|$)/);
81
83
  if (m)
82
84
  return commandNotFound(m[1]);
83
85
  // "x : The term 'y' is not recognized ..." (with source prefix)
@@ -94,18 +96,26 @@ export function normalizeStderr(stderr) {
94
96
  ': No such file or directory');
95
97
  }
96
98
  // zh-CN: "Get-Content : 找不到路径“X”,因为该路径不存在。"
97
- m = line.match(/^(\S+)\s*:\s*找不到路径[“'"](.+?)[”'"],因为该路径不存在\.?$/);
99
+ m = line.match(/^(\S+)\s*:\s*找不到路径[“"'](.+?)[”"'],因为该路径不存在[。.]?$/);
98
100
  if (m) {
99
101
  return m[1].toLowerCase() + ': ' + m[2].replace(/\\/g, '/') + ': No such file or directory';
100
102
  }
101
103
  // "cat : Cannot find drive. A drive with the name 'z' does not exist."
102
104
  m = line.match(/^(\S+)\s*:\s*Cannot find drive\..*name '(.+?)'.*$/);
105
+ if (m)
106
+ return m[1].toLowerCase() + ': ' + m[2] + ': No such file or directory';
107
+ // zh-CN: "Get-Content : 找不到驱动器。名为“Z”的驱动器不存在。"
108
+ m = line.match(/^(\S+)\s*:\s*找不到驱动器[。.]\s*名为[“"'‘](.+?)[”"'’]的驱动器不存在[。.]?$/);
103
109
  if (m)
104
110
  return m[1].toLowerCase() + ': ' + m[2] + ': No such file or directory';
105
111
  // "rm : Cannot remove item ... Access is denied"
106
112
  m = line.match(/^(\S+)\s*:\s*(.*)Access to the path '(.+?)' is denied\.?$/);
107
113
  if (m)
108
114
  return m[1].toLowerCase() + ': cannot remove \'' + m[3] + '\': Permission denied';
115
+ // zh-CN: "rm : 对路径“C:\\protected”的访问被拒绝。"
116
+ m = line.match(/^(\S+)\s*:\s*对路径[“"'‘](.+?)[”"'’]的访问被拒绝[。.]?$/);
117
+ if (m)
118
+ return m[1].toLowerCase() + ': cannot remove \'' + m[2] + '\': Permission denied';
109
119
  // leftover PS not-recognized lines that the rewrites above did not catch
110
120
  if (/\.sh'?/.test(line) && /is not recognized/.test(line)) {
111
121
  return line + SH_SCRIPT_WINDOWS_HINT;
@@ -29,12 +29,15 @@ export declare class FauxnixSession {
29
29
  private hostFile;
30
30
  private host;
31
31
  private lifecycleLock;
32
+ /** True once `env` was loaded from the host's complete environment snapshot. */
33
+ private hasEnvSnapshot;
34
+ private readonly powerShell;
32
35
  constructor();
33
36
  private bindFiles;
34
37
  private withLock;
35
38
  private syncFromDisk;
36
39
  private ensureHost;
37
- /** Boot powershell.exe now so the first run() is not the 1.1s cold start. */
40
+ /** Boot the selected PowerShell now so the first run() is not a cold start. */
38
41
  prewarm(): Promise<void>;
39
42
  dispose(): Promise<void>;
40
43
  /** Kill the host and re-prewarm the same session object (no second FauxnixSession). */
package/dist/executor.js CHANGED
@@ -1,13 +1,35 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { promises as fs, readFileSync, existsSync, openSync, closeSync, writeSync } from 'node:fs';
2
+ import { promises as fs, readFileSync, existsSync, openSync, closeSync, readSync, rmSync, writeSync, } from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { normalizeLiteralPath, wrapScript } from './translator.js';
6
- import { decodeOutput, normalizeHostNewlines, resolveNativePref } from './encoding.js';
6
+ import { decodeOutput, resolveNativePref } from './encoding.js';
7
7
  import { normalizeStderr } from './errors.js';
8
- import { DEFAULT_STDERR_LIMIT, DEFAULT_STDOUT_LIMIT, PowerShellHost, PS_MISSING_MESSAGE, } from './ps-host.js';
8
+ import { DEFAULT_STDERR_LIMIT, DEFAULT_STDOUT_LIMIT, PowerShellHost, } from './ps-host.js';
9
+ import { resolvePowerShell } from './powershell.js';
9
10
  const DEFAULT_TIMEOUT_MS = 120_000;
10
11
  const DEFAULT_WINDOWS_PATHEXT = '.COM;.EXE;.BAT;.CMD';
12
+ const MAX_HOST_CAPTURE_LIMIT = 0x7fffffff;
13
+ const UTF8_BOUNDARY_SUFFIX = 4;
14
+ const MAX_CALLER_OUTPUT_LIMIT = MAX_HOST_CAPTURE_LIMIT - UTF8_BOUNDARY_SUFFIX;
15
+ function validateOutputLimit(name, value) {
16
+ if (!Number.isSafeInteger(value) || value < 0 || value > MAX_CALLER_OUTPUT_LIMIT) {
17
+ throw new RangeError('fauxnix: ' + name + ' must be an integer from 0 to ' + MAX_CALLER_OUTPUT_LIMIT);
18
+ }
19
+ return value;
20
+ }
21
+ /**
22
+ * Retain one complete codepoint beyond the logical caller budget so Node can
23
+ * detect truncation and clip at a UTF-8 boundary. The host itself stays
24
+ * bounded even when the caller has no remaining budget.
25
+ */
26
+ function rawHostCaptureLimit(logicalBytes) {
27
+ if (!Number.isFinite(logicalBytes)) {
28
+ return logicalBytes === Number.POSITIVE_INFINITY ? MAX_HOST_CAPTURE_LIMIT : 0;
29
+ }
30
+ const bytes = Math.max(0, Math.floor(logicalBytes));
31
+ return Math.min(MAX_HOST_CAPTURE_LIMIT, bytes + UTF8_BOUNDARY_SUFFIX);
32
+ }
11
33
  function hasEnvKey(env, name) {
12
34
  const normalized = name.toUpperCase();
13
35
  return Object.keys(env).some((key) => key.toUpperCase() === normalized);
@@ -201,8 +223,12 @@ export class FauxnixSession {
201
223
  hostFile;
202
224
  host = null;
203
225
  lifecycleLock = Promise.resolve();
226
+ /** True once `env` was loaded from the host's complete environment snapshot. */
227
+ hasEnvSnapshot = false;
228
+ powerShell;
204
229
  constructor() {
205
230
  this.id = randomUUID().slice(0, 8);
231
+ this.powerShell = resolvePowerShell();
206
232
  this.bindFiles(this.id);
207
233
  }
208
234
  bindFiles(id) {
@@ -231,8 +257,10 @@ export class FauxnixSession {
231
257
  try {
232
258
  if (existsSync(this.envFile)) {
233
259
  const raw = readFileSync(this.envFile, 'utf8');
234
- if (raw.trim())
260
+ if (raw.trim()) {
235
261
  this.env = JSON.parse(raw);
262
+ this.hasEnvSnapshot = true;
263
+ }
236
264
  }
237
265
  }
238
266
  catch {
@@ -241,11 +269,11 @@ export class FauxnixSession {
241
269
  }
242
270
  ensureHost() {
243
271
  if (!this.host) {
244
- this.host = new PowerShellHost(this.hostFile, () => this.childEnv());
272
+ this.host = new PowerShellHost(this.hostFile, () => this.childEnv(), this.powerShell);
245
273
  }
246
274
  return this.host;
247
275
  }
248
- /** Boot powershell.exe now so the first run() is not the 1.1s cold start. */
276
+ /** Boot the selected PowerShell now so the first run() is not a cold start. */
249
277
  prewarm() {
250
278
  return this.withLock(async () => {
251
279
  await this.ensureHost().ready();
@@ -268,6 +296,7 @@ export class FauxnixSession {
268
296
  }
269
297
  this.cwd = null;
270
298
  this.env = {};
299
+ this.hasEnvSnapshot = false;
271
300
  this.prevExit = null;
272
301
  await Promise.allSettled([
273
302
  fs.rm(this.cwdFile, { force: true }),
@@ -279,12 +308,19 @@ export class FauxnixSession {
279
308
  }
280
309
  /** env for the child powershell process. */
281
310
  childEnv(cwdOverride, stdinFile) {
282
- const env = { ...process.env };
283
- for (const [k, v] of Object.entries(this.env)) {
284
- if (v === undefined)
285
- delete env[k];
286
- else
287
- env[k] = v;
311
+ // Before the first completed request, `env` contains optional caller
312
+ // overrides and is layered over the process baseline. Afterwards it is a
313
+ // complete snapshot written by the resident host. Restore that snapshot
314
+ // verbatim on transparent host restarts: merging it with process.env would
315
+ // resurrect inherited variables that the shell successfully unset.
316
+ const env = this.hasEnvSnapshot ? { ...this.env } : { ...process.env };
317
+ if (!this.hasEnvSnapshot) {
318
+ for (const [k, v] of Object.entries(this.env)) {
319
+ if (v === undefined)
320
+ delete env[k];
321
+ else
322
+ env[k] = v;
323
+ }
288
324
  }
289
325
  // The MCP SDK's safe Windows stdio environment omits PATHEXT. Without it,
290
326
  // PowerShell cannot resolve extensionless native commands such as `node`.
@@ -318,16 +354,70 @@ export class FauxnixSession {
318
354
  async function runPlans(plans, session, opts, afterSegment, ensureHost) {
319
355
  const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
320
356
  const deadline = Date.now() + timeoutMs;
321
- const stdoutLimit = opts.stdoutLimit ?? DEFAULT_STDOUT_LIMIT;
322
- const stderrLimit = opts.stderrLimit ?? DEFAULT_STDERR_LIMIT;
357
+ const stdoutLimit = validateOutputLimit('stdoutLimit', opts.stdoutLimit ?? DEFAULT_STDOUT_LIMIT);
358
+ const stderrLimit = validateOutputLimit('stderrLimit', opts.stderrLimit ?? DEFAULT_STDERR_LIMIT);
323
359
  const timeoutMessage = '\nbash: command timed out after ' + Math.round(timeoutMs / 1000) + 's';
324
360
  let stdout = '';
325
361
  let stderr = '';
362
+ let stdoutBytes = 0;
363
+ let stderrBytes = 0;
364
+ let stdoutClosed = false;
365
+ let stderrClosed = false;
326
366
  let exitCode = 0;
327
367
  let timedOut = false;
328
368
  let cancelled = false;
329
369
  let truncated = false;
330
370
  let spawnError;
371
+ const appendCaller = (fd, data) => {
372
+ if (!data)
373
+ return;
374
+ if (fd === 1 ? stdoutClosed : stderrClosed)
375
+ return;
376
+ const used = fd === 1 ? stdoutBytes : stderrBytes;
377
+ const limit = fd === 1 ? stdoutLimit : stderrLimit;
378
+ const clipped = clipUtf8(data, Math.max(0, limit - used));
379
+ if (fd === 1) {
380
+ stdout += clipped.text;
381
+ stdoutBytes += Buffer.byteLength(clipped.text, 'utf8');
382
+ }
383
+ else {
384
+ stderr += clipped.text;
385
+ stderrBytes += Buffer.byteLength(clipped.text, 'utf8');
386
+ }
387
+ if (clipped.truncated) {
388
+ if (fd === 1)
389
+ stdoutClosed = true;
390
+ else
391
+ stderrClosed = true;
392
+ truncated = true;
393
+ }
394
+ };
395
+ const remainingFor = (dest) => {
396
+ if (dest.kind !== 'caller')
397
+ return 0;
398
+ if (dest.fd === 1 ? stdoutClosed : stderrClosed)
399
+ return 0;
400
+ const limit = dest.fd === 1 ? stdoutLimit : stderrLimit;
401
+ const used = dest.fd === 1 ? stdoutBytes : stderrBytes;
402
+ return Math.max(0, limit - used);
403
+ };
404
+ const closeCallerDest = (dest) => {
405
+ if (dest.kind !== 'caller')
406
+ return;
407
+ if (dest.fd === 1)
408
+ stdoutClosed = true;
409
+ else
410
+ stderrClosed = true;
411
+ };
412
+ const hostStream = (dest) => {
413
+ if (dest.kind === 'file')
414
+ return { mode: 'spool', limit: 0 };
415
+ if (dest.kind === 'nul')
416
+ return { mode: 'discard', limit: 0 };
417
+ if (dest.fd === 1 ? stdoutClosed : stderrClosed)
418
+ return { mode: 'discard', limit: 0 };
419
+ return { mode: 'capture', limit: rawHostCaptureLimit(remainingFor(dest)) };
420
+ };
331
421
  // bash list semantics: `a && b ; c` runs c regardless of a; `a && b && c`
332
422
  // skips b AND c when a fails. chainOk models the value of the current
333
423
  // &&/|| chain; `;` segments always run and restart the chain.
@@ -350,7 +440,7 @@ async function runPlans(plans, session, opts, afterSegment, ensureHost) {
350
440
  break;
351
441
  }
352
442
  if (Date.now() >= deadline) {
353
- stderr += timeoutMessage;
443
+ appendCaller(2, timeoutMessage);
354
444
  exitCode = 124;
355
445
  timedOut = true;
356
446
  session.prevExit = exitCode;
@@ -377,10 +467,10 @@ async function runPlans(plans, session, opts, afterSegment, ensureHost) {
377
467
  let prepStderr = { kind: 'caller', fd: 2 };
378
468
  const emitPrepError = (msg) => emitToPrepDest(prepStderr, msg, prepFds, {
379
469
  stdout: (s) => {
380
- stdout += s;
470
+ appendCaller(1, s);
381
471
  },
382
472
  stderr: (s) => {
383
- stderr += s;
473
+ appendCaller(2, s);
384
474
  },
385
475
  });
386
476
  for (const r of plan.redirects) {
@@ -424,7 +514,7 @@ async function runPlans(plans, session, opts, afterSegment, ensureHost) {
424
514
  break;
425
515
  }
426
516
  if (remainingMs <= 0) {
427
- stderr += timeoutMessage;
517
+ appendCaller(2, timeoutMessage);
428
518
  exitCode = 124;
429
519
  timedOut = true;
430
520
  session.prevExit = exitCode;
@@ -435,20 +525,23 @@ async function runPlans(plans, session, opts, afterSegment, ensureHost) {
435
525
  // stderr onto the caller's stdout before stdout is pointed at NUL, so
436
526
  // captured stderr is still returned; `>/dev/null 2>&1` points both at NUL.
437
527
  const applyDests = lastStageOutputDests(plan.outputRedirects, resolveTarget);
438
- // Response budgets cap what the CALLER receives streams redirected to
439
- // files must never be truncated by them (Codex-review P1: `printf … > f`
440
- // with a small stdoutLimit was writing a clipped file). The final
441
- // clipUtf8 below still enforces the returned-data budget.
442
- const fileRedirected = applyDests.stdout.kind === 'file' || applyDests.stderr.kind === 'file';
528
+ // Each source stream is bounded according to its final destination.
529
+ // A file keeps the complete stream, /dev/null retains nothing, and a
530
+ // caller stream receives only the budget left by earlier list segments.
531
+ const hostStdout = hostStream(applyDests.stdout);
532
+ const hostStderr = hostStream(applyDests.stderr);
443
533
  const inv = await ensureHost().invoke(encoded, {
444
534
  FAUXNIX_CWD: currentDir,
445
535
  FAUXNIX_PREV_EXIT: session.prevExit === null ? '' : String(session.prevExit),
446
536
  FAUXNIX_STDIN_FILE: red.stdinFile || '',
447
- }, remainingMs, opts.signal, fileRedirected
448
- ? { stdoutLimit: 0, stderrLimit: 0 }
449
- : { stdoutLimit, stderrLimit });
537
+ }, remainingMs, opts.signal, {
538
+ stdoutLimit: hostStdout.limit,
539
+ stderrLimit: hostStderr.limit,
540
+ stdoutMode: hostStdout.mode,
541
+ stderrMode: hostStderr.mode,
542
+ });
450
543
  if (inv.spawnError === 'ENOENT' || inv.spawnError === 'START') {
451
- stderr += inv.stderr.toString('utf8') || PS_MISSING_MESSAGE;
544
+ appendCaller(2, inv.stderr.toString('utf8') || 'fauxnix: failed to start the selected PowerShell host\n');
452
545
  exitCode = 127;
453
546
  spawnError = inv.spawnError;
454
547
  session.prevExit = exitCode;
@@ -464,11 +557,14 @@ async function runPlans(plans, session, opts, afterSegment, ensureHost) {
464
557
  }
465
558
  afterSegment();
466
559
  const decodePref = resolveNativePref();
467
- // GNU line discipline: the PS host terminates Write-Output lines with
468
- // CRLF. Exact writers (fx-write / printf / echo -n) must keep embedded
469
- // CR so `printf 'a\r\nb' > out` stays 4 bytes.
470
- const segOut = normalizeHostNewlines(decodeOutput(inv.stdout, decodePref));
471
- let segErr = normalizeHostNewlines(normalizeStderr(decodeOutput(inv.stderr, decodePref)));
560
+ // Captured protocol frames are always UTF-8. FAUXNIX_NATIVE_ENCODING is
561
+ // consumed at the native-process boundary inside fx-native; applying it
562
+ // again here double-decodes every translated/non-ASCII frame. Only bytes
563
+ // written directly to the host's OS stderr pipe retain a native encoding.
564
+ const segOut = decodeOutput(inv.stdout, 'utf8');
565
+ const framedErr = decodeOutput(inv.stderr, 'utf8');
566
+ const nativeErr = decodeOutput(inv.nativeStderr ?? Buffer.alloc(0), decodePref);
567
+ let segErr = normalizeStderr(framedErr + nativeErr);
472
568
  if (inv.timedOut) {
473
569
  segErr += timeoutMessage;
474
570
  }
@@ -488,27 +584,53 @@ async function runPlans(plans, session, opts, afterSegment, ensureHost) {
488
584
  }
489
585
  catch (e) {
490
586
  if (fromStdout) {
491
- stderr += 'bash: ' + dest.path + ': cannot create: ' + e.message + '\n';
587
+ appendCaller(2, 'bash: ' + dest.path + ': cannot create: ' + e.message + '\n');
492
588
  exitCode = 1;
493
589
  redirectOk = false;
494
- stdout += data;
590
+ appendCaller(1, data);
495
591
  }
496
592
  else {
497
- stderr += data;
593
+ appendCaller(2, data);
498
594
  }
499
595
  }
500
596
  return;
501
597
  }
502
- if (dest.fd === 1)
503
- stdout += data;
504
- else
505
- stderr += data;
598
+ appendCaller(dest.fd, data);
506
599
  };
507
- deliverCaptured(applyDests.stdout, segOut, true);
508
- deliverCaptured(applyDests.stderr, segErr, false);
600
+ const deliverSpool = (dest, spool, fromStdout) => {
601
+ if (!spool)
602
+ return;
603
+ if (dest.kind !== 'file') {
604
+ throw new Error('fauxnix: host returned a spool for a non-file destination');
605
+ }
606
+ try {
607
+ writeSpoolToPrepFd(prepFds, spool, dest.path);
608
+ }
609
+ catch (e) {
610
+ appendCaller(2, 'bash: ' + dest.path + ': cannot write: ' + e.message + '\n');
611
+ exitCode = 1;
612
+ redirectOk = false;
613
+ }
614
+ };
615
+ const spools = [inv.stdoutSpool, inv.stderrSpool, inv.nativeStderrSpool].filter((file) => !!file);
616
+ try {
617
+ deliverCaptured(applyDests.stdout, segOut, true);
618
+ if (inv.stdoutTruncated)
619
+ closeCallerDest(applyDests.stdout);
620
+ deliverCaptured(applyDests.stderr, segErr, false);
621
+ if (inv.stderrTruncated)
622
+ closeCallerDest(applyDests.stderr);
623
+ deliverSpool(applyDests.stdout, inv.stdoutSpool, true);
624
+ deliverSpool(applyDests.stderr, inv.stderrSpool, false);
625
+ deliverSpool(applyDests.stderr, inv.nativeStderrSpool, false);
626
+ }
627
+ finally {
628
+ for (const spool of spools)
629
+ rmSync(spool, { force: true });
630
+ }
509
631
  if (inv.truncated)
510
632
  truncated = true;
511
- exitCode = inv.timedOut ? 124 : inv.cancelled ? 130 : inv.exitCode;
633
+ exitCode = !redirectOk ? 1 : inv.timedOut ? 124 : inv.cancelled ? 130 : inv.exitCode;
512
634
  if (inv.timedOut)
513
635
  timedOut = true;
514
636
  session.prevExit = exitCode;
@@ -525,13 +647,9 @@ async function runPlans(plans, session, opts, afterSegment, ensureHost) {
525
647
  closePrepFds(prepFds);
526
648
  }
527
649
  }
528
- const clippedOut = clipUtf8(stdout, stdoutLimit);
529
- const clippedErr = clipUtf8(stderr, stderrLimit);
530
- if (clippedOut.truncated || clippedErr.truncated)
531
- truncated = true;
532
650
  return {
533
- stdout: clippedOut.text,
534
- stderr: clippedErr.text,
651
+ stdout,
652
+ stderr,
535
653
  exitCode,
536
654
  timedOut,
537
655
  cancelled,
@@ -542,8 +660,33 @@ async function runPlans(plans, session, opts, afterSegment, ensureHost) {
542
660
  function clipUtf8(text, limit) {
543
661
  if (Buffer.byteLength(text, 'utf8') <= limit)
544
662
  return { text, truncated: false };
545
- let end = text.length;
546
- while (end > 0 && Buffer.byteLength(text.slice(0, end), 'utf8') > limit)
547
- end--;
663
+ let used = 0;
664
+ let end = 0;
665
+ for (const codepoint of text) {
666
+ const size = Buffer.byteLength(codepoint, 'utf8');
667
+ if (used + size > limit)
668
+ break;
669
+ used += size;
670
+ end += codepoint.length;
671
+ }
548
672
  return { text: text.slice(0, end), truncated: true };
549
673
  }
674
+ /** Copy a host spool into an already-open redirect fd with bounded memory. */
675
+ function writeSpoolToPrepFd(fds, file, target) {
676
+ const outFd = fds.get(target);
677
+ if (outFd === undefined)
678
+ throw new Error('fauxnix: redirect fd missing for ' + target);
679
+ const inFd = openSync(file, 'r');
680
+ const input = Buffer.allocUnsafe(65_536);
681
+ try {
682
+ while (true) {
683
+ const n = readSync(inFd, input, 0, input.length, null);
684
+ if (n <= 0)
685
+ break;
686
+ writeAllSync(outFd, input.subarray(0, n));
687
+ }
688
+ }
689
+ finally {
690
+ closeSync(inFd);
691
+ }
692
+ }
@@ -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;