@bigknoxy/hashpilot 4.6.3

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.
Files changed (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +777 -0
  3. package/docs/ADAPTER-CONTRACT.md +1260 -0
  4. package/docs/ARCHITECTURE.md +846 -0
  5. package/docs/CLI-QUICKREF.md +827 -0
  6. package/docs/COMPETITIVE-ANALYSIS.md +307 -0
  7. package/docs/INSTALL.md +403 -0
  8. package/docs/INTEGRATION-CLAUDE.md +126 -0
  9. package/docs/INTEGRATION-MCP.md +196 -0
  10. package/docs/INTEGRATION-OPENCODE.md +136 -0
  11. package/docs/INTEGRATION-PI.md +195 -0
  12. package/package.json +77 -0
  13. package/scripts/build-site.sh +39 -0
  14. package/scripts/doctor.sh +218 -0
  15. package/scripts/gen-cli-quickref.ts +232 -0
  16. package/scripts/install-cli.sh +60 -0
  17. package/scripts/install.sh +466 -0
  18. package/scripts/roadmap-lint.ts +200 -0
  19. package/scripts/uninstall.sh +202 -0
  20. package/src/cli-node.cjs +51 -0
  21. package/src/cli.ts +209 -0
  22. package/src/commands/ast.ts +255 -0
  23. package/src/commands/diff.ts +98 -0
  24. package/src/commands/edit.ts +93 -0
  25. package/src/commands/hash.ts +64 -0
  26. package/src/commands/intent.ts +68 -0
  27. package/src/commands/maintenance.ts +191 -0
  28. package/src/commands/mcp.ts +28 -0
  29. package/src/commands/provenance.ts +111 -0
  30. package/src/commands/read.ts +117 -0
  31. package/src/commands/route.ts +42 -0
  32. package/src/commands/shared.ts +65 -0
  33. package/src/commands/telemetry.ts +126 -0
  34. package/src/commands/verify.ts +61 -0
  35. package/src/core/ast-edit.ts +2357 -0
  36. package/src/core/batch-edit.ts +185 -0
  37. package/src/core/config.ts +189 -0
  38. package/src/core/diff-engine.ts +474 -0
  39. package/src/core/doctor.ts +303 -0
  40. package/src/core/encoding.ts +116 -0
  41. package/src/core/envelope.ts +163 -0
  42. package/src/core/exit-codes.ts +198 -0
  43. package/src/core/format.ts +339 -0
  44. package/src/core/grep.ts +180 -0
  45. package/src/core/hash-edit.ts +416 -0
  46. package/src/core/index.ts +155 -0
  47. package/src/core/intent.ts +584 -0
  48. package/src/core/locking.ts +292 -0
  49. package/src/core/module-system.ts +142 -0
  50. package/src/core/operations.ts +557 -0
  51. package/src/core/output.ts +122 -0
  52. package/src/core/path-normalize.ts +61 -0
  53. package/src/core/paths.ts +326 -0
  54. package/src/core/plan-executor.ts +437 -0
  55. package/src/core/platform.ts +132 -0
  56. package/src/core/provenance.ts +214 -0
  57. package/src/core/read.ts +111 -0
  58. package/src/core/redact.ts +98 -0
  59. package/src/core/resolve-content.ts +12 -0
  60. package/src/core/router.ts +463 -0
  61. package/src/core/snapshot.ts +346 -0
  62. package/src/core/telemetry.ts +838 -0
  63. package/src/core/utils.ts +7 -0
  64. package/src/core/verify-baseline.ts +186 -0
  65. package/src/core/verify-scope.ts +282 -0
  66. package/src/core/verify.ts +753 -0
  67. package/src/mcp/server.ts +325 -0
  68. package/templates/claude-section.md +12 -0
  69. package/templates/opencode-agent.md +106 -0
  70. package/templates/opencode-skill.md +241 -0
  71. package/templates/pi-extension.ts +288 -0
  72. package/templates/pi-skill.md +123 -0
  73. package/tsconfig.json +19 -0
@@ -0,0 +1,437 @@
1
+ import { EditPlan, findSymbolDefinition, findReferences, generatePlan, parseIntent, StructuredIntent, resolveReferences, ReferenceReconciliation } from "./intent";
2
+ import { safeWrite } from "./paths";
3
+ import { insertParameter, insertCallArg, renameSymbol, detectLanguage } from "./ast-edit";
4
+ import { replaceHash } from "./hash-edit";
5
+ import { computeHash } from "./read";
6
+ import { verifyChanges, recordVerifyBaseline, VerifyResult } from "./verify";
7
+ import { recordEvent, ErrorCode } from "./telemetry";
8
+ import type { TelemetryEvent } from "./telemetry";
9
+ import { createChangeSet, buildProvenanceFields } from "./provenance";
10
+ import { readDecoded } from "./encoding";
11
+
12
+ // ── Result types ──────────────────────────────────────────────────────
13
+
14
+ export interface StepResult {
15
+ step: number;
16
+ file: string;
17
+ operation: string;
18
+ success: boolean;
19
+ message: string;
20
+ elapsed_ms: number;
21
+ }
22
+
23
+ export interface PlanResult {
24
+ success: boolean;
25
+ intent: StructuredIntent;
26
+ plan: EditPlan;
27
+ steps: StepResult[];
28
+ summary: {
29
+ totalSteps: number;
30
+ succeeded: number;
31
+ failed: number;
32
+ elapsed_ms: number;
33
+ };
34
+ verification?: VerifyResult;
35
+ reverted: boolean;
36
+ /** Why the rollback fired, when one did — letting a caller tell "the
37
+ * tests broke" (verification-failed) apart from "an edit never applied"
38
+ * (step-failed). Absent when no rollback ran. */
39
+ revertReason?: "verification-failed" | "step-failure";
40
+ /** Files in the rollback snapshot whose `safeWrite` threw; the tree is half-reverted. */
41
+ unrevertedFiles?: string[];
42
+ /** Set when the plan was refused outright; drives the process exit code. */
43
+ errorCode?: string;
44
+ /** Work the planner could not compute. Non-empty means the plan was partial. */
45
+ unresolved: EditPlan["unresolved"];
46
+ }
47
+
48
+ // ── Plan execution ────────────────────────────────────────────────────
49
+
50
+ export async function executePlan(
51
+ plan: EditPlan,
52
+ options: {
53
+ dryRun?: boolean;
54
+ verify?: boolean;
55
+ revertOnFailure?: boolean;
56
+ timeout?: number;
57
+ actor?: string;
58
+ taskId?: string;
59
+ reason?: string;
60
+ context?: string;
61
+ /** Proceed even though part of the intent could not be planned. */
62
+ yes?: boolean;
63
+ /** Test seam: substitute the verification runner. The CLI never sets this;
64
+ * it defaults to verifyChanges and exists so the rollback decision can be
65
+ * exercised deterministically without spawning real commands. */
66
+ verifyImpl?: typeof verifyChanges;
67
+ } = {}
68
+ ): Promise<PlanResult> {
69
+ const start = Date.now();
70
+ const dryRun = options.dryRun ?? false;
71
+ const doVerify = options.verify ?? true;
72
+ const doRevert = options.revertOnFailure ?? true;
73
+ const timeout = options.timeout ?? 30000;
74
+ const unresolved = plan.unresolved ?? [];
75
+
76
+ // A partial plan is refused rather than half-applied: applying only the
77
+ // signature edit and leaving every call site alone breaks the build, and the
78
+ // caller has no way to know it happened unless we stop and say so (#16).
79
+ if (unresolved.length > 0 && !options.yes) {
80
+ return {
81
+ success: false,
82
+ intent: plan.intent,
83
+ plan,
84
+ steps: [],
85
+ summary: { totalSteps: plan.steps.length, succeeded: 0, failed: 0, elapsed_ms: Date.now() - start },
86
+ reverted: false,
87
+ errorCode: "UNSUPPORTED_OPERATION",
88
+ unresolved,
89
+ };
90
+ }
91
+
92
+ const planActor = options.actor;
93
+ const planTaskId = options.taskId;
94
+ const planContext = options.context;
95
+ const planReason = options.reason ?? `${plan.intent.operation} on '${plan.intent.symbol}'`;
96
+ const changeSetId = createChangeSet();
97
+ const stepTotal = plan.steps.length;
98
+
99
+ // Snapshot all impacted files for rollback.
100
+ //
101
+ // A file we cannot read here has no snapshot, so it can never be rolled back.
102
+ // Swallowing that read failure re-creates the exact defect #17 closed on the
103
+ // write side: the revert loop iterates `originals`, so an unsnapshotted file
104
+ // is neither restored nor reported, and `reverted: true` goes out over a tree
105
+ // that still holds the edit. Record the miss now and fold it into
106
+ // `unrevertedFiles` if a rollback actually fires.
107
+ const originals = new Map<string, string>();
108
+ const unsnapshotted: string[] = [];
109
+ if (doRevert) {
110
+ for (const file of [...new Set(plan.steps.map((s) => s.file))]) {
111
+ try {
112
+ originals.set(file, (await readDecoded(file)).text);
113
+ } catch {
114
+ unsnapshotted.push(file);
115
+ }
116
+ }
117
+ }
118
+
119
+ // Record which tests already fail, while the tree is still pristine. This is
120
+ // the only moment a baseline can be taken honestly — after the first step the
121
+ // failures are no longer "pre-existing". Cached per commit SHA, so this is a
122
+ // no-op on every plan after the first at a given commit. Best-effort: a repo
123
+ // with no runner, no git, or a timing-out suite simply gets no baseline, and
124
+ // verification then counts every failure as it did before (#24).
125
+ if (doVerify && !dryRun) {
126
+ try {
127
+ await recordVerifyBaseline([...new Set(plan.steps.map((s) => s.file))], {
128
+ autoDetect: true,
129
+ timeout,
130
+ });
131
+ } catch {}
132
+ }
133
+
134
+ const results: StepResult[] = [];
135
+
136
+ // Execute steps in order (sequential is safer for dependent edits)
137
+ for (const step of plan.steps) {
138
+ const stepStart = Date.now();
139
+ let stepSuccess = false;
140
+ let stepMessage = "";
141
+ let stepNewSource: string | undefined;
142
+ let stepSource: string | undefined;
143
+
144
+ try {
145
+ stepSource = (await readDecoded(step.file)).text;
146
+ const source = stepSource;
147
+ let result: { success: boolean; message: string; newSource?: string };
148
+
149
+ switch (step.operation) {
150
+ case "insert-parameter":
151
+ result = insertParameter(source, step.file, step.params.symbolName, step.params.newParam);
152
+ break;
153
+
154
+ case "insert-call-arg":
155
+ result = insertCallArg(source, step.file, step.params.functionName, step.params.argValue);
156
+ break;
157
+
158
+ case "rename-symbol":
159
+ result = renameSymbol(source, step.file, step.params.oldName, step.params.newName);
160
+ break;
161
+
162
+ case "replace-hash": {
163
+ const srcHash = computeHash(source);
164
+ const hashResult = await replaceHash(step.file, srcHash, step.params.newContent!, { dryRun });
165
+ result = hashResult;
166
+ break;
167
+ }
168
+
169
+ case "diff": {
170
+ const { oldContent, newContent } = step.params;
171
+ if (!oldContent || !newContent) {
172
+ result = { success: false, message: "Diff requires oldContent and newContent" };
173
+ break;
174
+ }
175
+ const count = source.split(oldContent).length - 1;
176
+ if (count === 0) {
177
+ result = { success: false, message: `Content not found in ${step.file}` };
178
+ } else if (count > 1) {
179
+ result = { success: false, message: `Content appears ${count} times — disambiguate` };
180
+ } else {
181
+ const newSource = source.split(oldContent).join(newContent);
182
+ result = { success: true, message: `Replaced content`, newSource };
183
+ }
184
+ break;
185
+ }
186
+
187
+ default:
188
+ result = { success: false, message: `Unknown operation: ${step.operation}` };
189
+ }
190
+
191
+ stepSuccess = result.success;
192
+ stepMessage = result.message;
193
+ stepNewSource = result.newSource;
194
+
195
+ if (stepSuccess && stepNewSource && !dryRun) {
196
+ await safeWrite(step.file, stepNewSource);
197
+ }
198
+ } catch (err: any) {
199
+ stepSuccess = false;
200
+ stepMessage = `Error: ${err.message}`;
201
+ }
202
+
203
+ results.push({
204
+ step: step.order,
205
+ file: step.file,
206
+ operation: step.operation,
207
+ success: stepSuccess,
208
+ message: stepMessage,
209
+ elapsed_ms: Date.now() - stepStart,
210
+ });
211
+
212
+ const stepProvenance = buildProvenanceFields({
213
+ actor: planActor,
214
+ taskId: planTaskId,
215
+ changeSetId,
216
+ reason: step.description,
217
+ source: stepSource,
218
+ newSource: stepNewSource,
219
+ stepIndex: step.order,
220
+ stepTotal,
221
+ context: planContext,
222
+ filePath: step.file,
223
+ });
224
+
225
+ let stepRoute: TelemetryEvent["route"] = "ast";
226
+ if (step.operation === "diff") stepRoute = "diff";
227
+ else if (step.operation === "replace-hash") stepRoute = "hash";
228
+
229
+ recordEvent({
230
+ operation: step.operation,
231
+ route: stepRoute,
232
+ file: step.file,
233
+ language: detectLanguage(step.file) || undefined,
234
+ success: stepSuccess,
235
+ elapsed_ms: Date.now() - stepStart,
236
+ ...stepProvenance,
237
+ });
238
+ }
239
+
240
+ const succeeded = results.filter((r) => r.success).length;
241
+ const failed = results.length - succeeded;
242
+ const stepFailed = failed > 0;
243
+
244
+ // Run verification after all steps have applied, so the verify run sees the
245
+ // full in-memory state.
246
+ //
247
+ // Skipped when a step already failed. The tree is then half-applied, so the
248
+ // suite is being run over a state no one asked for and is about to be
249
+ // reverted — it costs a full test run to produce a failure that is a
250
+ // consequence of the step failure, not an independent finding. Worse, it used
251
+ // to overwrite the diagnosis: `errorCode` became VERIFY_FAILED (exit 4,
252
+ // "the edit applied but tests failed") when the edit had in fact never
253
+ // applied (exit 2).
254
+ let verification: VerifyResult | undefined;
255
+ if (doVerify && !dryRun && !stepFailed) {
256
+ const impactedFiles = [...new Set(plan.steps.map((s) => s.file))];
257
+ verification = await (options.verifyImpl ?? verifyChanges)(impactedFiles, {
258
+ autoDetect: true,
259
+ revertOnFailure: false, // executePlan owns the rollback path
260
+ useBaseline: true, // only tests this plan actually broke count (#24)
261
+ timeout,
262
+ });
263
+ }
264
+
265
+ const verifyFailed = verification?.overall === "fail";
266
+ // A timeout is not a failure and must not trigger a rollback: the suite never
267
+ // reached a verdict, so reverting would destroy work on no evidence (#24).
268
+ // It is still not a pass — the plan reports `success: false` with
269
+ // VERIFY_TIMEOUT so the caller decides what to do.
270
+ const verifyTimedOut = verification?.overall === "timeout";
271
+ // No check ran, so the plan is unverified. Like a timeout this is not a
272
+ // failure and must not roll back — but it is not a pass either, and reporting
273
+ // one would be the false green of #106 laundered through the planner.
274
+ const verifySkipped = verification?.overall === "skipped";
275
+ const allPassed = !stepFailed && !verifyFailed && !verifyTimedOut && !verifySkipped;
276
+
277
+ // Rollback on step failure OR verification failure.
278
+ //
279
+ // #10: verification was computed but its result was never read. The old loop
280
+ // condition `!allPassed` only tracked step failures, so a green step /
281
+ // red test suite reported `success: true` and kept the broken changes.
282
+ //
283
+ // #17: `catch {}` in the revert loop meant a half-reverted tree still set
284
+ // `reverted: true`. `unrevertedFiles` now names every snapshot file
285
+ // that could not be restored; `reverted` is only `true` when every file
286
+ // in the snapshot was written back.
287
+ const unrevertedFiles: string[] = [];
288
+ let reverted = false;
289
+ // #10 (B13): the rollback decision must also say *why* it fired, not just that
290
+ // it did. A step that failed is the root cause; "verification-failed" is the
291
+ // case where every step applied but a check (test/lint/typecheck) failed.
292
+ let revertReason: "verification-failed" | "step-failure" | undefined;
293
+ // Gated on (stepFailed || verifyFailed) && doRevert && !dryRun only — NOT on
294
+ // originals.size > 0. If the snapshot pass failed for every file, originals
295
+ // is empty and the restore loop below is a no-op, but the unsnapshotted/
296
+ // editedFiles accounting still must run: a step can succeed in writing a
297
+ // file whose blanket snapshot read failed (its own read-before-write
298
+ // succeeded independently), and that edit is exactly as unrevertable as one
299
+ // whose restore write threw (#160/B59). Skipping this whole block when
300
+ // originals is empty silently dropped that signal.
301
+ if ((stepFailed || verifyFailed) && doRevert && !dryRun) {
302
+ revertReason = stepFailed ? "step-failure" : "verification-failed";
303
+ for (const [file, original] of originals) {
304
+ try { await safeWrite(file, original); }
305
+ catch { unrevertedFiles.push(file); }
306
+ }
307
+ // A file that was edited but never snapshotted is just as unreverted as one
308
+ // whose restore write threw — there was nothing to write back. Only count
309
+ // it if a step actually changed it; an unreadable file that also failed its
310
+ // step left the tree untouched and needs no rollback.
311
+ const editedFiles = new Set(results.filter((r) => r.success).map((r) => r.file));
312
+ for (const file of unsnapshotted) {
313
+ if (editedFiles.has(file)) unrevertedFiles.push(file);
314
+ }
315
+ reverted = unrevertedFiles.length === 0;
316
+ }
317
+
318
+ const elapsed = Date.now() - start;
319
+
320
+ // Error-code precedence, most alarming first.
321
+ //
322
+ // An incomplete rollback outranks everything else: the tree is now in a state
323
+ // neither the caller nor the plan asked for. Reporting that as VERIFY_FAILED
324
+ // (exit 4) tells an agent "your edit applied, the tests didn't pass" — which
325
+ // reads as safe to retry, when in fact some files still hold edits that were
326
+ // supposed to be undone. ROLLBACK_INCOMPLETE maps to exit 5 (I/O), so it does
327
+ // not sit in the retryable band at all.
328
+ //
329
+ // VERIFY_FAILED has its own slot (code 4). A bare step failure carries no
330
+ // errorCode and the exit-code system maps `success: false` to code 2.
331
+ const errorCode: string | undefined =
332
+ unrevertedFiles.length > 0
333
+ ? ErrorCode.ROLLBACK_INCOMPLETE
334
+ : verifyTimedOut
335
+ ? ErrorCode.VERIFY_TIMEOUT
336
+ : verifyFailed
337
+ ? ErrorCode.VERIFY_FAILED
338
+ : verifySkipped
339
+ ? ErrorCode.VERIFY_NO_CHECKS
340
+ : undefined;
341
+
342
+ recordEvent({
343
+ operation: `intent-${plan.intent.operation}`,
344
+ route: "intent",
345
+ success: allPassed,
346
+ elapsed_ms: elapsed,
347
+ files_count: plan.steps.length,
348
+ changeSetId,
349
+ actor: planActor,
350
+ taskId: planTaskId,
351
+ reason: planReason,
352
+ context: planContext,
353
+ stepTotal: plan.steps.length,
354
+ errorCode,
355
+ });
356
+
357
+ return {
358
+ success: allPassed,
359
+ intent: plan.intent,
360
+ plan,
361
+ steps: results,
362
+ summary: {
363
+ totalSteps: plan.steps.length,
364
+ succeeded,
365
+ failed,
366
+ elapsed_ms: elapsed,
367
+ },
368
+ verification,
369
+ reverted,
370
+ revertReason,
371
+ unrevertedFiles: unrevertedFiles.length > 0 ? unrevertedFiles : undefined,
372
+ errorCode,
373
+ unresolved,
374
+ };
375
+ }
376
+
377
+ // ── Top-level API: intent → plan → execute ────────────────────────────
378
+
379
+ export interface IntentResult {
380
+ success: boolean;
381
+ plan: EditPlan;
382
+ execution: PlanResult;
383
+ /** Mirrors `execution.errorCode` so the CLI's exit-code mapping sees it. */
384
+ errorCode?: string;
385
+ }
386
+
387
+ /**
388
+ * The one-shot entry point for intent-based editing.
389
+ * Parses the intent, discovers references, generates a plan, and executes it.
390
+ */
391
+ export async function executeIntent(
392
+ rawIntent: string,
393
+ options: {
394
+ projectRoot?: string;
395
+ dryRun?: boolean;
396
+ verify?: boolean;
397
+ revertOnFailure?: boolean;
398
+ timeout?: number;
399
+ actor?: string;
400
+ taskId?: string;
401
+ reason?: string;
402
+ context?: string;
403
+ yes?: boolean;
404
+ } = {}
405
+ ): Promise<IntentResult> {
406
+ const intent = parseIntent(rawIntent);
407
+
408
+ // Auto-discover project root from hint file or cwd
409
+ const projectRoot = options.projectRoot || ".";
410
+ const definition = await findSymbolDefinition(intent.symbol, projectRoot, intent.file);
411
+ if (!definition) {
412
+ throw new Error(`Symbol '${intent.symbol}' not found in project at ${projectRoot}`);
413
+ }
414
+
415
+ const { references, unresolved: unresolvedRefs, reconciliation } = await resolveReferences(
416
+ intent.symbol,
417
+ projectRoot,
418
+ definition.file
419
+ );
420
+ const plan = generatePlan(intent, definition, references, reconciliation);
421
+ // Surface languages HashPilot cannot parse so the planner refuses rather than
422
+ // guessing (#15). Ambiguous bindings — a name that also binds more than once
423
+ // in a caller file — are reported via `reconciliation` and, when ambiguous > 0,
424
+ // block execution unless the caller opts in with --yes.
425
+ for (const u of unresolvedRefs) plan.unresolved.push(u);
426
+ if (reconciliation.ambiguous > 0 && !options.yes) {
427
+ plan.unresolved.push({
428
+ file: definition.file,
429
+ operation: "resolve-references",
430
+ reason: `${reconciliation.ambiguous} reference(s) of '${intent.symbol}' live in a file that also binds that name more than once — cannot tell which module's symbol to rename`,
431
+ resolution: `Disambiguate (e.g. scope the rename to one binding), or re-run with --yes to proceed anyway.`,
432
+ });
433
+ }
434
+ const execution = await executePlan(plan, options);
435
+
436
+ return { success: execution.success, plan, execution, errorCode: execution.errorCode };
437
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Cross-runtime platform abstraction.
3
+ * Provides file I/O, stdin, and process spawning that work on both Bun and Node.
4
+ */
5
+
6
+ import { fileURLToPath } from "node:url";
7
+ import { readFile, writeFile, spawn } from "node:fs/promises";
8
+ import { createReadStream, createWriteStream } from "node:fs";
9
+ import { stdin as nodeStdin, stdout as nodeStdout, stderr as nodeStderr } from "node:process";
10
+ import { pipeline } from "node:stream/promises";
11
+ import { Readable } from "node:stream";
12
+
13
+ /** Detect if we're running on Bun. */
14
+ export function isBun(): boolean {
15
+ return typeof Bun !== "undefined";
16
+ }
17
+
18
+ /** Detect if we're running on Node. */
19
+ export function isNode(): boolean {
20
+ return !isBun();
21
+ }
22
+
23
+ /** Read entire file as text. Works on both Bun and Node. */
24
+ export async function readFileText(path: string): Promise<string> {
25
+ if (isBun()) {
26
+ return await Bun.file(path).text();
27
+ }
28
+ return await readFile(path, "utf-8");
29
+ }
30
+
31
+ /** Read entire file as bytes. Works on both Bun and Node. */
32
+ export async function readFileBytes(path: string): Promise<Uint8Array> {
33
+ if (isBun()) {
34
+ return await Bun.file(path).bytes();
35
+ }
36
+ const buffer = await readFile(path);
37
+ return new Uint8Array(buffer);
38
+ }
39
+
40
+ /** Check if file exists. Works on both Bun and Node. */
41
+ export async function fileExists(path: string): Promise<boolean> {
42
+ if (isBun()) {
43
+ return await Bun.file(path).exists();
44
+ }
45
+ try {
46
+ await readFile(path);
47
+ return true;
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
52
+
53
+ /** Write file. Works on both Bun and Node. */
54
+ export async function writeFileText(path: string, content: string): Promise<void> {
55
+ if (isBun()) {
56
+ await Bun.write(path, content);
57
+ return;
58
+ }
59
+ await writeFile(path, content, "utf-8");
60
+ }
61
+
62
+ /** Read stdin as text. Works on both Bun and Node. */
63
+ export async function readStdinText(): Promise<string> {
64
+ if (isBun()) {
65
+ return await Bun.stdin.text();
66
+ }
67
+ return new Promise((resolve, reject) => {
68
+ let data = "";
69
+ nodeStdin.setEncoding("utf-8");
70
+ nodeStdin.on("data", (chunk) => { data += chunk; });
71
+ nodeStdin.on("end", () => resolve(data));
72
+ nodeStdin.on("error", reject);
73
+ nodeStdin.resume();
74
+ });
75
+ }
76
+
77
+ /** Stream stdin chunks. Works on both Bun and Node. */
78
+ export async function* streamStdin(): AsyncGenerator<Uint8Array, void, unknown> {
79
+ if (isBun()) {
80
+ for await (const chunk of Bun.stdin.stream()) {
81
+ yield chunk;
82
+ }
83
+ return;
84
+ }
85
+ for await (const chunk of Readable.toWeb(nodeStdin) as any) {
86
+ yield chunk;
87
+ }
88
+ }
89
+
90
+ /** Spawn a child process. Works on both Bun and Node. */
91
+ export async function spawnProcess(
92
+ command: string,
93
+ args: string[],
94
+ options: { cwd?: string; env?: Record<string, string>; stdout?: "pipe" | "inherit"; stderr?: "pipe" | "inherit" } = {}
95
+ ) {
96
+ if (isBun()) {
97
+ return Bun.spawn([command, ...args], {
98
+ cwd: options.cwd,
99
+ env: options.env,
100
+ stdout: options.stdout === "inherit" ? "inherit" : "pipe",
101
+ stderr: options.stderr === "inherit" ? "inherit" : "pipe",
102
+ });
103
+ }
104
+ const child = spawn(command, args, {
105
+ cwd: options.cwd,
106
+ env: { ...process.env, ...options.env },
107
+ stdio: [
108
+ "ignore",
109
+ options.stdout === "inherit" ? "inherit" : "pipe",
110
+ options.stderr === "inherit" ? "inherit" : "pipe",
111
+ ],
112
+ });
113
+ return child;
114
+ }
115
+
116
+ /** Get stdin as a readable stream (Node only, Bun uses Bun.stdin.stream()). */
117
+ export function getStdinStream(): Readable {
118
+ if (isBun()) {
119
+ throw new Error("Use streamStdin() on Bun");
120
+ }
121
+ return nodeStdin;
122
+ }
123
+
124
+ /** Get stdout as a writable stream. */
125
+ export function getStdoutStream(): NodeJS.WritableStream {
126
+ return nodeStdout;
127
+ }
128
+
129
+ /** Get stderr as a writable stream. */
130
+ export function getStderrStream(): NodeJS.WritableStream {
131
+ return nodeStderr;
132
+ }