poddrill 0.0.0-stage → 0.1.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/client.js ADDED
@@ -0,0 +1,1450 @@
1
+ import { spawn } from "node:child_process";
2
+ import { randomBytes } from "node:crypto";
3
+ import { cp, mkdir, readdir, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { basename, dirname, isAbsolute, join, normalize, relative } from "node:path";
6
+ import { asRecord, errorCode, pathExists } from "./disk.js";
7
+ import { embedWeightsPath } from "./embed.js";
8
+ import { readPackageIdentity } from "./package-identity.js";
9
+ const serverName = "poddrill";
10
+ const skillPublishPath = `.agents/skills/${serverName}`;
11
+ const skillNextPrefix = `${serverName}.next-`;
12
+ const skillPrevPrefix = `${serverName}.prev-`;
13
+ const skillMarkerSuffix = ".complete";
14
+ const requiredPublishedFiles = ["dist", skillPublishPath];
15
+ const GROK_STARTUP_TIMEOUT_SEC = 30;
16
+ const CONFIG_CAS_LIMIT = 5;
17
+ const clientIds = ["cursor", "grok_build", "grok_bot"];
18
+ const clientIdList = clientIds.join("|");
19
+ export const clientUsageBody = [
20
+ `client doctor --client <${clientIdList}> [--json]`,
21
+ `client plan --client <${clientIdList}> [--json]`,
22
+ `client plan --linked [--json]`,
23
+ `client apply --yes --client <${clientIdList}> [--json]`,
24
+ `client apply --yes --linked [--json]`,
25
+ ].join(" | ");
26
+ const clientUsage = `usage: poddrill ${clientUsageBody}`;
27
+ const workspaceCommandMessage = "poddrill client registers Cursor or Grok. A workspace is poddrill setup --provider <id> --queue-length <n>.";
28
+ export function workspaceSetupCollision(args) {
29
+ for (const flag of ["--client", "--linked", "--yes", "--dry-run"]) {
30
+ if (args.includes(flag)) {
31
+ return `poddrill setup writes a workspace. Register a client with: ${clientUsage}`;
32
+ }
33
+ }
34
+ return undefined;
35
+ }
36
+ export async function clientMain(args, env, moduleUrl) {
37
+ const parsed = parseClientArgs(args);
38
+ if (!parsed.ok) {
39
+ return { text: errorText(parsed.message), exitCode: 1 };
40
+ }
41
+ const home = absoluteHome(env);
42
+ if (home === undefined) {
43
+ return outcomeRendered({
44
+ kind: "refused",
45
+ reason: "home-missing",
46
+ message: "HOME must be an absolute path",
47
+ });
48
+ }
49
+ const identity = await readPackageIdentity(moduleUrl);
50
+ if (!publishLayoutOk(identity.files)) {
51
+ return outcomeRendered({
52
+ kind: "refused",
53
+ reason: "package-identity",
54
+ message: `package.json files must include ${requiredPublishedFiles.join(" and ")}`,
55
+ });
56
+ }
57
+ switch (parsed.command.op) {
58
+ case "doctor":
59
+ return outcomeRendered(await doctor(parsed.command.client, identity, home, env));
60
+ case "plan":
61
+ return outcomeRendered(await resolve(parsed.command.selection, identity, home));
62
+ case "apply":
63
+ return outcomeRendered(await applyResolved(await resolve(parsed.command.selection, identity, home), identity, home));
64
+ default: {
65
+ const leftover = parsed.command;
66
+ throw new Error(`unexpected command ${JSON.stringify(leftover)}`);
67
+ }
68
+ }
69
+ }
70
+ async function doctor(client, identity, home, env) {
71
+ const launcher = launcherFor(identity.spec);
72
+ const paths = pathsFor(home);
73
+ const skillText = await readOptionalUtf8(join(identity.root, skillPublishPath, "SKILL.md"));
74
+ const probes = await runtimeProbes(env, identity);
75
+ const link = await linkCheckFor(client, paths, launcher);
76
+ return {
77
+ kind: "doctor",
78
+ client,
79
+ launcher,
80
+ checks: [skillBundleCheck(skillText), probes[0], probes[1], probes[2], link],
81
+ };
82
+ }
83
+ async function resolve(selection, identity, home) {
84
+ const launcher = launcherFor(identity.spec);
85
+ const skillPath = join(identity.root, skillPublishPath, "SKILL.md");
86
+ const skillText = await readOptionalUtf8(skillPath);
87
+ if (skillText === undefined) {
88
+ return {
89
+ kind: "refused",
90
+ reason: "skill-bundle-missing",
91
+ path: skillPath,
92
+ message: `bundled skill is missing at ${skillPath}`,
93
+ };
94
+ }
95
+ if (!skillAgrees(skillText)) {
96
+ return {
97
+ kind: "refused",
98
+ reason: "skill-pin-mismatch",
99
+ path: skillPath,
100
+ message: "bundled skill must name client doctor, client plan, and client apply --yes, and must not include @latest or an unpinned npx launch",
101
+ };
102
+ }
103
+ const paths = pathsFor(home);
104
+ const effects = await effectsFor(selection, launcher, paths);
105
+ if (effects === undefined) {
106
+ return { kind: "nothing-linked", launcher };
107
+ }
108
+ const bundle = bundleDir(identity);
109
+ const view = await observeSkill(paths.skillDest, bundle);
110
+ return {
111
+ kind: "plan",
112
+ launcher,
113
+ skill: { dest: paths.skillDest, action: skillAction(view) },
114
+ effects,
115
+ };
116
+ }
117
+ async function applyResolved(resolved, identity, home) {
118
+ switch (resolved.kind) {
119
+ case "nothing-linked":
120
+ case "refused":
121
+ return resolved;
122
+ case "plan":
123
+ return commit(resolved, identity, home);
124
+ default: {
125
+ const leftover = resolved;
126
+ throw new Error(`unexpected resolution ${JSON.stringify(leftover)}`);
127
+ }
128
+ }
129
+ }
130
+ async function commit(plan, identity, home) {
131
+ const blocked = firstUnreadable(plan.effects);
132
+ if (blocked !== undefined) {
133
+ return blocked;
134
+ }
135
+ if (!(await staysInHome(home, plan.skill.dest))) {
136
+ return {
137
+ kind: "refused",
138
+ reason: "outside-home",
139
+ path: plan.skill.dest,
140
+ message: `refusing to write ${plan.skill.dest}`,
141
+ };
142
+ }
143
+ const skillError = await convergeSkill(plan.skill.dest, bundleDir(identity));
144
+ if (skillError !== undefined) {
145
+ return skillError;
146
+ }
147
+ const results = [];
148
+ for (const effect of plan.effects) {
149
+ const written = await writeEffect(effect, plan.launcher, home);
150
+ if ("kind" in written) {
151
+ return written;
152
+ }
153
+ results.push(written);
154
+ }
155
+ const verified = await verify(plan, identity);
156
+ if (verified !== undefined) {
157
+ return verified;
158
+ }
159
+ return {
160
+ kind: "applied",
161
+ launcher: plan.launcher,
162
+ skill: plan.skill.action,
163
+ results,
164
+ };
165
+ }
166
+ function parseClientArgs(args) {
167
+ const opText = args[0];
168
+ if (opText === undefined) {
169
+ return fail(clientUsage);
170
+ }
171
+ const op = parseOp(opText);
172
+ if (op === undefined) {
173
+ return fail(clientUsage);
174
+ }
175
+ let jsonCount = 0;
176
+ let yes = false;
177
+ let linked = false;
178
+ let client;
179
+ for (let index = 1; index < args.length; index += 1) {
180
+ const arg = args[index];
181
+ if (arg === undefined) {
182
+ continue;
183
+ }
184
+ if (arg === "--provider" || arg === "--queue-length") {
185
+ return fail(workspaceCommandMessage);
186
+ }
187
+ switch (arg) {
188
+ case "--json":
189
+ jsonCount += 1;
190
+ if (jsonCount > 1) {
191
+ return fail(clientUsage);
192
+ }
193
+ break;
194
+ case "--yes":
195
+ if (yes) {
196
+ return fail(clientUsage);
197
+ }
198
+ yes = true;
199
+ break;
200
+ case "--linked":
201
+ if (linked) {
202
+ return fail(clientUsage);
203
+ }
204
+ linked = true;
205
+ break;
206
+ case "--dry-run":
207
+ return fail("client plan is the preview. poddrill client has no --dry-run flag.");
208
+ case "--client": {
209
+ if (client !== undefined) {
210
+ return fail(clientUsage);
211
+ }
212
+ const value = args[index + 1];
213
+ if (value === undefined || value.startsWith("--")) {
214
+ return fail(clientUsage);
215
+ }
216
+ const parsed = parseClientId(value);
217
+ if (parsed === undefined) {
218
+ return fail(clientUsage);
219
+ }
220
+ client = parsed;
221
+ index += 1;
222
+ break;
223
+ }
224
+ default:
225
+ return fail(clientUsage);
226
+ }
227
+ }
228
+ if (yes && op !== "apply") {
229
+ return fail("client plan does not write. client apply --yes writes.");
230
+ }
231
+ if (!yes && op === "apply") {
232
+ return fail("client apply writes nothing without --yes. client plan previews.");
233
+ }
234
+ const command = commandFrom(op, client, linked);
235
+ if (command === undefined) {
236
+ return fail(clientUsage);
237
+ }
238
+ return { ok: true, command };
239
+ }
240
+ function commandFrom(op, client, linked) {
241
+ if (linked && client !== undefined) {
242
+ return undefined;
243
+ }
244
+ switch (op) {
245
+ case "doctor":
246
+ if (linked || client === undefined) {
247
+ return undefined;
248
+ }
249
+ return { op: "doctor", client };
250
+ case "plan":
251
+ if (linked) {
252
+ return { op: "plan", selection: { kind: "linked" } };
253
+ }
254
+ if (client === undefined) {
255
+ return undefined;
256
+ }
257
+ return { op: "plan", selection: { kind: "one", client } };
258
+ case "apply":
259
+ if (linked) {
260
+ return { op: "apply", selection: { kind: "linked" } };
261
+ }
262
+ if (client === undefined) {
263
+ return undefined;
264
+ }
265
+ return { op: "apply", selection: { kind: "one", client } };
266
+ default: {
267
+ const leftover = op;
268
+ throw new Error(`unexpected op ${String(leftover)}`);
269
+ }
270
+ }
271
+ }
272
+ function parseOp(value) {
273
+ switch (value) {
274
+ case "doctor":
275
+ case "plan":
276
+ case "apply":
277
+ return value;
278
+ default:
279
+ return undefined;
280
+ }
281
+ }
282
+ function parseClientId(value) {
283
+ for (const client of clientIds) {
284
+ if (client === value) {
285
+ return client;
286
+ }
287
+ }
288
+ return undefined;
289
+ }
290
+ function fail(message) {
291
+ return { ok: false, message };
292
+ }
293
+ function absoluteHome(env) {
294
+ const raw = env["HOME"];
295
+ const home = raw === undefined || raw === "" ? homedir() : raw;
296
+ if (!isAbsolute(home)) {
297
+ return undefined;
298
+ }
299
+ return home;
300
+ }
301
+ function publishLayoutOk(files) {
302
+ for (const required of requiredPublishedFiles) {
303
+ if (!files.includes(required)) {
304
+ return false;
305
+ }
306
+ }
307
+ return true;
308
+ }
309
+ function launcherFor(spec) {
310
+ return { command: "npx", args: ["-y", spec, "mcp"] };
311
+ }
312
+ function bundleDir(identity) {
313
+ return join(identity.root, skillPublishPath);
314
+ }
315
+ function grokBotSentence(launcher) {
316
+ const commandLine = [launcher.command, ...launcher.args].join(" ");
317
+ return `Add a custom server named ${serverName} on this computer, running ${commandLine}.`;
318
+ }
319
+ function skillAgrees(skillText) {
320
+ if (skillText.includes(`${serverName}@latest`) || skillText.includes(`npx -y ${serverName} `)) {
321
+ return false;
322
+ }
323
+ return (skillText.includes("client doctor") &&
324
+ skillText.includes("client plan") &&
325
+ skillText.includes("client apply --yes"));
326
+ }
327
+ function skillBundleCheck(text) {
328
+ if (text !== undefined && skillAgrees(text)) {
329
+ return { name: "skill-bundle", state: "ok" };
330
+ }
331
+ const detail = text === undefined
332
+ ? "bundled SKILL.md is missing"
333
+ : "bundled SKILL.md does not name the client commands";
334
+ return { name: "skill-bundle", state: "bad", detail };
335
+ }
336
+ function writingClients() {
337
+ const found = [];
338
+ for (const client of clientIds) {
339
+ switch (client) {
340
+ case "cursor":
341
+ case "grok_build":
342
+ found.push(client);
343
+ break;
344
+ case "grok_bot":
345
+ break;
346
+ default: {
347
+ const leftover = client;
348
+ throw new Error(`unexpected client ${String(leftover)}`);
349
+ }
350
+ }
351
+ }
352
+ return found;
353
+ }
354
+ function pathsFor(home) {
355
+ return {
356
+ skillDest: join(home, skillPublishPath),
357
+ cursorMcp: join(home, ".cursor", "mcp.json"),
358
+ grokConfig: join(home, ".grok", "config.toml"),
359
+ };
360
+ }
361
+ function configFormat(client) {
362
+ switch (client) {
363
+ case "cursor":
364
+ return "json";
365
+ case "grok_build":
366
+ return "toml";
367
+ default: {
368
+ const leftover = client;
369
+ throw new Error(`unexpected writer ${String(leftover)}`);
370
+ }
371
+ }
372
+ }
373
+ function skillAction(view) {
374
+ switch (view.live) {
375
+ case "match":
376
+ return "keep";
377
+ case "absent":
378
+ return view.completeNext.length > 0 ? "finish" : "install";
379
+ case "differ":
380
+ return "replace";
381
+ default: {
382
+ const leftover = view.live;
383
+ throw new Error(`unexpected live ${String(leftover)}`);
384
+ }
385
+ }
386
+ }
387
+ function nextSkillStep(view) {
388
+ const partial = view.partialNext[0];
389
+ if (partial !== undefined) {
390
+ return { op: "drop", path: partial };
391
+ }
392
+ const orphan = view.orphanMarkers[0];
393
+ if (orphan !== undefined) {
394
+ return { op: "drop", path: orphan };
395
+ }
396
+ switch (view.live) {
397
+ case "match": {
398
+ const extra = view.completeNext[0] ?? view.prev[0];
399
+ if (extra !== undefined) {
400
+ return { op: "drop", path: extra };
401
+ }
402
+ return { op: "done" };
403
+ }
404
+ case "absent": {
405
+ const ready = view.completeNext[0];
406
+ if (ready !== undefined) {
407
+ return { op: "promote", path: ready };
408
+ }
409
+ return { op: "stage" };
410
+ }
411
+ case "differ":
412
+ if (view.completeNext[0] === undefined) {
413
+ return { op: "stage" };
414
+ }
415
+ return { op: "aside" };
416
+ default: {
417
+ const leftover = view.live;
418
+ throw new Error(`unexpected live ${String(leftover)}`);
419
+ }
420
+ }
421
+ }
422
+ function skillStepBudget(view) {
423
+ const listed = view.partialNext.length +
424
+ view.completeNext.length +
425
+ view.prev.length +
426
+ view.orphanMarkers.length;
427
+ switch (view.live) {
428
+ case "match":
429
+ return listed;
430
+ case "absent":
431
+ return listed + (view.completeNext.length === 0 ? 1 : 0) + 2;
432
+ case "differ":
433
+ return listed + (view.completeNext.length === 0 ? 1 : 0) + 4;
434
+ default: {
435
+ const leftover = view.live;
436
+ throw new Error(`unexpected live ${String(leftover)}`);
437
+ }
438
+ }
439
+ }
440
+ function pathStaysInHome(home, candidate) {
441
+ const root = normalize(home);
442
+ const target = normalize(candidate);
443
+ const rootPrefix = root.endsWith("/") ? root : `${root}/`;
444
+ return target === root || target.startsWith(rootPrefix);
445
+ }
446
+ function markerPath(dir) {
447
+ return `${dir}${skillMarkerSuffix}`;
448
+ }
449
+ function firstUnreadable(effects) {
450
+ for (const effect of effects) {
451
+ const refusal = unreadable(effect);
452
+ if (refusal !== undefined) {
453
+ return refusal;
454
+ }
455
+ }
456
+ return undefined;
457
+ }
458
+ function unreadable(effect) {
459
+ switch (effect.client) {
460
+ case "grok_bot":
461
+ return undefined;
462
+ case "cursor":
463
+ case "grok_build":
464
+ if (effect.record.status !== "unreadable") {
465
+ return undefined;
466
+ }
467
+ return {
468
+ kind: "refused",
469
+ reason: "unreadable-config",
470
+ path: effect.path,
471
+ message: effect.record.detail,
472
+ };
473
+ default: {
474
+ const leftover = effect;
475
+ throw new Error(`unexpected effect ${JSON.stringify(leftover)}`);
476
+ }
477
+ }
478
+ }
479
+ function unchangedEffect(effect) {
480
+ switch (effect.client) {
481
+ case "cursor":
482
+ return { client: "cursor", path: effect.path, result: "unchanged" };
483
+ case "grok_build":
484
+ return {
485
+ client: "grok_build",
486
+ path: effect.path,
487
+ result: "unchanged",
488
+ startupTimeoutSec: effect.startupTimeoutSec,
489
+ };
490
+ default: {
491
+ const leftover = effect;
492
+ throw new Error(`unexpected effect ${JSON.stringify(leftover)}`);
493
+ }
494
+ }
495
+ }
496
+ async function effectsFor(selection, launcher, paths) {
497
+ switch (selection.kind) {
498
+ case "one":
499
+ return [await oneEffect(selection.client, launcher, paths)];
500
+ case "linked": {
501
+ const effects = [];
502
+ for (const client of writingClients()) {
503
+ const effect = await writingEffect(client, launcher, paths);
504
+ if (effect.record.status === "absent") {
505
+ continue;
506
+ }
507
+ effects.push(effect);
508
+ }
509
+ if (effects.length === 0) {
510
+ return undefined;
511
+ }
512
+ return effects;
513
+ }
514
+ default: {
515
+ const leftover = selection;
516
+ throw new Error(`unexpected selection ${JSON.stringify(leftover)}`);
517
+ }
518
+ }
519
+ }
520
+ async function oneEffect(client, launcher, paths) {
521
+ switch (client) {
522
+ case "cursor":
523
+ case "grok_build":
524
+ return writingEffect(client, launcher, paths);
525
+ case "grok_bot":
526
+ return { client: "grok_bot", sentence: grokBotSentence(launcher) };
527
+ default: {
528
+ const leftover = client;
529
+ throw new Error(`unexpected client ${String(leftover)}`);
530
+ }
531
+ }
532
+ }
533
+ async function writingEffect(client, launcher, paths) {
534
+ switch (client) {
535
+ case "cursor":
536
+ return {
537
+ client,
538
+ path: paths.cursorMcp,
539
+ record: await linkRecord(paths.cursorMcp, "json", launcher),
540
+ };
541
+ case "grok_build":
542
+ return {
543
+ client,
544
+ path: paths.grokConfig,
545
+ record: await linkRecord(paths.grokConfig, "toml", launcher),
546
+ startupTimeoutSec: GROK_STARTUP_TIMEOUT_SEC,
547
+ };
548
+ default: {
549
+ const leftover = client;
550
+ throw new Error(`unexpected writer ${String(leftover)}`);
551
+ }
552
+ }
553
+ }
554
+ async function linkCheckFor(client, paths, launcher) {
555
+ switch (client) {
556
+ case "grok_bot":
557
+ return { name: "link", client, sentence: grokBotSentence(launcher) };
558
+ case "cursor":
559
+ case "grok_build": {
560
+ const effect = await writingEffect(client, launcher, paths);
561
+ return { name: "link", client, record: effect.record };
562
+ }
563
+ default: {
564
+ const leftover = client;
565
+ throw new Error(`unexpected client ${String(leftover)}`);
566
+ }
567
+ }
568
+ }
569
+ async function convergeSkill(dest, bundle) {
570
+ let view = await observeSkill(dest, bundle);
571
+ const budget = skillStepBudget(view);
572
+ for (let step = 0; step < budget; step += 1) {
573
+ const next = nextSkillStep(view);
574
+ if (next.op === "done") {
575
+ return undefined;
576
+ }
577
+ await runSkillStep(dest, bundle, next);
578
+ view = await observeSkill(dest, bundle);
579
+ }
580
+ if (nextSkillStep(view).op === "done") {
581
+ return undefined;
582
+ }
583
+ return {
584
+ kind: "refused",
585
+ reason: "skill-mismatch",
586
+ message: `skill at ${dest} did not match the bundle`,
587
+ };
588
+ }
589
+ async function verify(plan, identity) {
590
+ const view = await observeSkill(plan.skill.dest, bundleDir(identity));
591
+ if (view.live !== "match" ||
592
+ view.partialNext.length > 0 ||
593
+ view.completeNext.length > 0 ||
594
+ view.prev.length > 0 ||
595
+ view.orphanMarkers.length > 0) {
596
+ return {
597
+ kind: "refused",
598
+ reason: "skill-mismatch",
599
+ message: `skill at ${plan.skill.dest} does not match the bundle`,
600
+ };
601
+ }
602
+ for (const effect of plan.effects) {
603
+ switch (effect.client) {
604
+ case "grok_bot":
605
+ break;
606
+ case "cursor":
607
+ case "grok_build": {
608
+ const record = await linkRecord(effect.path, configFormat(effect.client), plan.launcher);
609
+ if (record.status !== "match") {
610
+ return {
611
+ kind: "refused",
612
+ reason: "config-race",
613
+ message: `${effect.path} does not match the launcher`,
614
+ };
615
+ }
616
+ break;
617
+ }
618
+ default: {
619
+ const leftover = effect;
620
+ throw new Error(`unexpected effect ${JSON.stringify(leftover)}`);
621
+ }
622
+ }
623
+ }
624
+ return undefined;
625
+ }
626
+ async function writeEffect(effect, launcher, home) {
627
+ switch (effect.client) {
628
+ case "grok_bot":
629
+ return { client: "grok_bot", sentence: effect.sentence };
630
+ case "cursor":
631
+ case "grok_build": {
632
+ if (!(await staysInHome(home, effect.path))) {
633
+ return {
634
+ kind: "refused",
635
+ reason: "outside-home",
636
+ path: effect.path,
637
+ message: `refusing to write ${effect.path}`,
638
+ };
639
+ }
640
+ if (effect.record.status === "match") {
641
+ return unchangedEffect(effect);
642
+ }
643
+ const swapped = await compareAndSwap(effect.path, configFormat(effect.client), launcher);
644
+ if (typeof swapped !== "string") {
645
+ return swapped;
646
+ }
647
+ return swapped === "unchanged" ? unchangedEffect(effect) : writtenEffect(effect);
648
+ }
649
+ default: {
650
+ const leftover = effect;
651
+ throw new Error(`unexpected effect ${JSON.stringify(leftover)}`);
652
+ }
653
+ }
654
+ }
655
+ function writtenEffect(effect) {
656
+ switch (effect.client) {
657
+ case "cursor":
658
+ return { client: "cursor", path: effect.path, result: "written" };
659
+ case "grok_build":
660
+ return {
661
+ client: "grok_build",
662
+ path: effect.path,
663
+ result: "written",
664
+ startupTimeoutSec: effect.startupTimeoutSec,
665
+ };
666
+ default: {
667
+ const leftover = effect;
668
+ throw new Error(`unexpected effect ${JSON.stringify(leftover)}`);
669
+ }
670
+ }
671
+ }
672
+ function checkFailed(check) {
673
+ switch (check.name) {
674
+ case "skill-bundle":
675
+ case "ffmpeg":
676
+ case "cactus-needle":
677
+ case "ecapa":
678
+ return check.state === "bad";
679
+ case "link":
680
+ return linkFailed(check);
681
+ default: {
682
+ const leftover = check;
683
+ throw new Error(`unexpected check ${JSON.stringify(leftover)}`);
684
+ }
685
+ }
686
+ }
687
+ function linkFailed(check) {
688
+ switch (check.client) {
689
+ case "grok_bot":
690
+ return false;
691
+ case "cursor":
692
+ case "grok_build":
693
+ return check.record.status !== "match";
694
+ default: {
695
+ const leftover = check;
696
+ throw new Error(`unexpected link ${JSON.stringify(leftover)}`);
697
+ }
698
+ }
699
+ }
700
+ function exitCode(outcome) {
701
+ switch (outcome.kind) {
702
+ case "plan":
703
+ case "applied":
704
+ case "nothing-linked":
705
+ return 0;
706
+ case "refused":
707
+ return 1;
708
+ case "doctor":
709
+ return outcome.checks.some((check) => checkFailed(check)) ? 1 : 0;
710
+ default: {
711
+ const leftover = outcome;
712
+ throw new Error(`unexpected outcome ${JSON.stringify(leftover)}`);
713
+ }
714
+ }
715
+ }
716
+ function outcomeRendered(outcome) {
717
+ return { text: `${JSON.stringify(outcome)}\n`, exitCode: exitCode(outcome) };
718
+ }
719
+ function errorText(message) {
720
+ const body = { kind: "error", message };
721
+ return `${JSON.stringify(body)}\n`;
722
+ }
723
+ async function readOptionalUtf8(path) {
724
+ try {
725
+ return await readFile(path, "utf8");
726
+ }
727
+ catch (error) {
728
+ if (errorCode(error) === "ENOENT") {
729
+ return undefined;
730
+ }
731
+ throw error;
732
+ }
733
+ }
734
+ async function runtimeProbes(env, identity) {
735
+ const ffmpeg = await ffmpegCheck(env);
736
+ const needle = await needleCheck(env, identity.cactusNeedle);
737
+ const weights = embedWeightsPath(env);
738
+ const ecapa = (await pathExists(weights))
739
+ ? { name: "ecapa", state: "ok", path: weights }
740
+ : { name: "ecapa", state: "bad", path: weights };
741
+ return [ffmpeg, needle, ecapa];
742
+ }
743
+ async function ffmpegCheck(env) {
744
+ const result = await capture("ffmpeg", ["-version"], env);
745
+ if (result.code === 0) {
746
+ return { name: "ffmpeg", state: "ok" };
747
+ }
748
+ return { name: "ffmpeg", state: "bad", detail: "ffmpeg is not on PATH" };
749
+ }
750
+ async function needleCheck(env, expected) {
751
+ const result = await capture("python3", ["-c", "import needle\nprint(needle.__version__)\n"], env);
752
+ if (result.code !== 0) {
753
+ return { name: "cactus-needle", state: "bad", expected, found: undefined };
754
+ }
755
+ const found = result.stdout.trim();
756
+ if (found === expected) {
757
+ return { name: "cactus-needle", state: "ok", version: found };
758
+ }
759
+ return { name: "cactus-needle", state: "bad", expected, found };
760
+ }
761
+ function capture(command, args, env) {
762
+ return new Promise((resolve, reject) => {
763
+ const child = spawn(command, [...args], {
764
+ env: spawnEnv(env),
765
+ stdio: ["ignore", "pipe", "pipe"],
766
+ });
767
+ const out = [];
768
+ child.stdout.on("data", (chunk) => {
769
+ out.push(chunk);
770
+ });
771
+ child.on("error", (error) => {
772
+ if (errorCode(error) === "ENOENT") {
773
+ resolve({ code: null, stdout: "" });
774
+ return;
775
+ }
776
+ reject(error);
777
+ });
778
+ child.on("close", (code) => {
779
+ resolve({ code, stdout: Buffer.concat(out).toString("utf8") });
780
+ });
781
+ });
782
+ }
783
+ function spawnEnv(env) {
784
+ const copy = {};
785
+ for (const [key, value] of Object.entries(env)) {
786
+ if (value !== undefined) {
787
+ copy[key] = value;
788
+ }
789
+ }
790
+ return copy;
791
+ }
792
+ async function linkRecord(path, format, launcher) {
793
+ const text = await readOptionalUtf8(path);
794
+ switch (format) {
795
+ case "json":
796
+ return cursorRecord(text, launcher);
797
+ case "toml":
798
+ return grokRecord(text, launcher);
799
+ default: {
800
+ const leftover = format;
801
+ throw new Error(`unexpected format ${String(leftover)}`);
802
+ }
803
+ }
804
+ }
805
+ function cursorRecord(text, launcher) {
806
+ if (text === undefined || text.trim() === "") {
807
+ return { status: "absent" };
808
+ }
809
+ let parsed;
810
+ try {
811
+ parsed = JSON.parse(text);
812
+ }
813
+ catch {
814
+ return { status: "unreadable", detail: "mcp.json is not valid JSON" };
815
+ }
816
+ const root = asRecord(parsed);
817
+ if (root === undefined) {
818
+ return { status: "unreadable", detail: "mcp.json must be a JSON object" };
819
+ }
820
+ if (!Object.prototype.hasOwnProperty.call(root, "mcpServers")) {
821
+ return { status: "absent" };
822
+ }
823
+ const servers = asRecord(root["mcpServers"]);
824
+ if (servers === undefined) {
825
+ return { status: "unreadable", detail: "mcpServers is not an object" };
826
+ }
827
+ if (!Object.prototype.hasOwnProperty.call(servers, serverName)) {
828
+ return { status: "absent" };
829
+ }
830
+ const entry = servers[serverName];
831
+ if (cursorEntryMatches(entry, launcher)) {
832
+ return { status: "match" };
833
+ }
834
+ return { status: "differ", previous: previousFromCursor(entry) };
835
+ }
836
+ function cursorEntryMatches(value, launcher) {
837
+ const record = asRecord(value);
838
+ if (record === undefined) {
839
+ return false;
840
+ }
841
+ const keys = Object.keys(record);
842
+ if (keys.length !== 2 || !keys.includes("command") || !keys.includes("args")) {
843
+ return false;
844
+ }
845
+ if (record["command"] !== launcher.command) {
846
+ return false;
847
+ }
848
+ const args = stringList(record["args"]);
849
+ if (args === undefined) {
850
+ return false;
851
+ }
852
+ return sameStrings(args, launcher.args);
853
+ }
854
+ function previousFromCursor(value) {
855
+ const record = asRecord(value);
856
+ if (record === undefined) {
857
+ return { command: "", args: [] };
858
+ }
859
+ const command = typeof record["command"] === "string" ? record["command"] : "";
860
+ return { command, args: stringList(record["args"]) ?? [] };
861
+ }
862
+ function stringList(value) {
863
+ if (!Array.isArray(value)) {
864
+ return undefined;
865
+ }
866
+ const args = [];
867
+ for (const entry of value) {
868
+ if (typeof entry !== "string") {
869
+ return undefined;
870
+ }
871
+ args.push(entry);
872
+ }
873
+ return args;
874
+ }
875
+ function sameStrings(actual, expected) {
876
+ if (actual.length !== expected.length) {
877
+ return false;
878
+ }
879
+ for (let index = 0; index < expected.length; index += 1) {
880
+ if (actual[index] !== expected[index]) {
881
+ return false;
882
+ }
883
+ }
884
+ return true;
885
+ }
886
+ function grokRecord(text, launcher) {
887
+ if (text === undefined || text.trim() === "") {
888
+ return { status: "absent" };
889
+ }
890
+ const sections = scanTomlSections(text);
891
+ if (sections === undefined) {
892
+ return { status: "unreadable", detail: "config.toml headers cannot be scanned" };
893
+ }
894
+ const hits = sections.filter((section) => isPoddrillTable(section.name));
895
+ if (hits.length > 1) {
896
+ return { status: "unreadable", detail: "config.toml has more than one poddrill table" };
897
+ }
898
+ const hit = hits[0];
899
+ if (hit === undefined) {
900
+ return { status: "absent" };
901
+ }
902
+ const previous = previousFromToml(hit.body);
903
+ if (grokBodyMatches(hit.body, launcher, previous)) {
904
+ return { status: "match" };
905
+ }
906
+ return { status: "differ", previous };
907
+ }
908
+ function grokBodyMatches(body, launcher, previous) {
909
+ if (!("startupTimeoutSec" in previous)) {
910
+ return false;
911
+ }
912
+ const keys = assignmentKeys(body);
913
+ if (keys === undefined) {
914
+ return false;
915
+ }
916
+ if (keys.length !== 3 || !keys.includes("command") || !keys.includes("args") || !keys.includes("startup_timeout_sec")) {
917
+ return false;
918
+ }
919
+ return (previous.command === launcher.command &&
920
+ sameStrings(previous.args, launcher.args) &&
921
+ previous.startupTimeoutSec === GROK_STARTUP_TIMEOUT_SEC);
922
+ }
923
+ function previousFromToml(body) {
924
+ let command = "";
925
+ let args = [];
926
+ let startupTimeoutSec = null;
927
+ for (const line of body.split("\n")) {
928
+ const commandMatch = /^\s*command\s*=\s*"((?:\\.|[^"\\])*)"\s*(?:#.*)?$/.exec(line);
929
+ if (commandMatch !== null) {
930
+ command = unescapeToml(commandMatch[1] ?? "");
931
+ continue;
932
+ }
933
+ const argsMatch = /^\s*args\s*=\s*\[(.*)\]\s*(?:#.*)?$/.exec(line);
934
+ if (argsMatch !== null) {
935
+ const parsed = parseTomlStringArray(argsMatch[1] ?? "");
936
+ if (parsed !== undefined) {
937
+ args = parsed;
938
+ }
939
+ continue;
940
+ }
941
+ const timeoutMatch = /^\s*startup_timeout_sec\s*=\s*(\d+)\s*(?:#.*)?$/.exec(line);
942
+ if (timeoutMatch !== null && timeoutMatch[1] !== undefined) {
943
+ startupTimeoutSec = Number(timeoutMatch[1]);
944
+ }
945
+ }
946
+ return { command, args, startupTimeoutSec };
947
+ }
948
+ function assignmentKeys(body) {
949
+ const keys = [];
950
+ for (const line of body.split("\n")) {
951
+ const trimmed = line.trim();
952
+ if (trimmed === "" || trimmed.startsWith("#")) {
953
+ continue;
954
+ }
955
+ const match = /^([A-Za-z0-9_-]+)\s*=/.exec(trimmed);
956
+ const key = match?.[1];
957
+ if (key === undefined) {
958
+ return undefined;
959
+ }
960
+ keys.push(key);
961
+ }
962
+ return keys;
963
+ }
964
+ function parseTomlStringArray(inner) {
965
+ const values = [];
966
+ let index = 0;
967
+ while (index < inner.length) {
968
+ const ch = inner[index];
969
+ if (ch === undefined) {
970
+ break;
971
+ }
972
+ if (ch === " " || ch === "\t" || ch === "," || ch === "\n" || ch === "\r") {
973
+ index += 1;
974
+ continue;
975
+ }
976
+ if (ch !== "\"") {
977
+ return undefined;
978
+ }
979
+ index += 1;
980
+ let value = "";
981
+ let closed = false;
982
+ while (index < inner.length) {
983
+ const next = inner[index];
984
+ if (next === undefined) {
985
+ return undefined;
986
+ }
987
+ if (next === "\\") {
988
+ const escaped = inner[index + 1];
989
+ if (escaped === undefined) {
990
+ return undefined;
991
+ }
992
+ value += escaped === "n" ? "\n" : escaped;
993
+ index += 2;
994
+ continue;
995
+ }
996
+ if (next === "\"") {
997
+ index += 1;
998
+ closed = true;
999
+ values.push(value);
1000
+ break;
1001
+ }
1002
+ value += next;
1003
+ index += 1;
1004
+ }
1005
+ if (!closed) {
1006
+ return undefined;
1007
+ }
1008
+ }
1009
+ return values;
1010
+ }
1011
+ function unescapeToml(value) {
1012
+ return value.replaceAll(/\\(.)/g, (_match, char) => (char === "n" ? "\n" : char));
1013
+ }
1014
+ function isPoddrillTable(name) {
1015
+ const compact = name.replaceAll(" ", "").replaceAll("\t", "").replaceAll("\"", "").replaceAll("'", "");
1016
+ return compact === "mcp_servers.poddrill";
1017
+ }
1018
+ function scanTomlSections(text) {
1019
+ if (text.includes("\"\"\"") || text.includes("'''")) {
1020
+ return undefined;
1021
+ }
1022
+ const found = [];
1023
+ let mode = "top";
1024
+ let lineStart = 0;
1025
+ let index = 0;
1026
+ while (index < text.length) {
1027
+ const ch = text[index];
1028
+ if (ch === undefined) {
1029
+ break;
1030
+ }
1031
+ if (mode === "basic") {
1032
+ if (ch === "\\") {
1033
+ index += 2;
1034
+ continue;
1035
+ }
1036
+ if (ch === "\"") {
1037
+ mode = "top";
1038
+ }
1039
+ index += 1;
1040
+ continue;
1041
+ }
1042
+ if (mode === "literal") {
1043
+ if (ch === "'") {
1044
+ mode = "top";
1045
+ }
1046
+ index += 1;
1047
+ continue;
1048
+ }
1049
+ if (ch === "\n") {
1050
+ lineStart = index + 1;
1051
+ index += 1;
1052
+ continue;
1053
+ }
1054
+ if (ch === "#") {
1055
+ while (index < text.length && text[index] !== "\n") {
1056
+ index += 1;
1057
+ }
1058
+ continue;
1059
+ }
1060
+ if (ch === "\"") {
1061
+ mode = "basic";
1062
+ index += 1;
1063
+ continue;
1064
+ }
1065
+ if (ch === "'") {
1066
+ mode = "literal";
1067
+ index += 1;
1068
+ continue;
1069
+ }
1070
+ if (ch === "[" && onlySpace(text, lineStart, index)) {
1071
+ const header = readHeader(text, index);
1072
+ if (header === undefined) {
1073
+ return undefined;
1074
+ }
1075
+ const previous = found[found.length - 1];
1076
+ if (previous !== undefined) {
1077
+ found[found.length - 1] = {
1078
+ ...previous,
1079
+ bodyEnd: lineStart,
1080
+ body: text.slice(previous.bodyEnd, lineStart),
1081
+ };
1082
+ }
1083
+ found.push({
1084
+ headerStart: lineStart,
1085
+ bodyEnd: header.end,
1086
+ name: header.name,
1087
+ body: "",
1088
+ });
1089
+ index = header.end;
1090
+ continue;
1091
+ }
1092
+ index += 1;
1093
+ }
1094
+ if (mode !== "top") {
1095
+ return undefined;
1096
+ }
1097
+ const last = found[found.length - 1];
1098
+ if (last !== undefined) {
1099
+ found[found.length - 1] = { ...last, bodyEnd: text.length, body: text.slice(last.bodyEnd) };
1100
+ }
1101
+ return found;
1102
+ }
1103
+ function onlySpace(text, from, to) {
1104
+ for (let index = from; index < to; index += 1) {
1105
+ const ch = text[index];
1106
+ if (ch !== " " && ch !== "\t" && ch !== "\r") {
1107
+ return false;
1108
+ }
1109
+ }
1110
+ return true;
1111
+ }
1112
+ function readHeader(text, index) {
1113
+ const newline = text.indexOf("\n", index);
1114
+ const lineEnd = newline === -1 ? text.length : newline;
1115
+ const line = text.slice(index, lineEnd).replace(/\r$/, "");
1116
+ const match = /^\[\[?\s*([^\]]+?)\s*\]\]?\s*(?:#.*)?$/.exec(line);
1117
+ const name = match?.[1];
1118
+ if (name === undefined || name.includes("[")) {
1119
+ return undefined;
1120
+ }
1121
+ return { name, end: lineEnd };
1122
+ }
1123
+ async function compareAndSwap(path, format, launcher) {
1124
+ await deleteLeftoverTemps(path);
1125
+ for (let attempt = 0; attempt < CONFIG_CAS_LIMIT; attempt += 1) {
1126
+ const current = await readOptionalUtf8(path);
1127
+ const merged = mergeText(current, format, launcher);
1128
+ if (typeof merged !== "string") {
1129
+ return {
1130
+ kind: "refused",
1131
+ reason: "unreadable-config",
1132
+ path,
1133
+ message: merged.detail,
1134
+ };
1135
+ }
1136
+ if (current !== undefined && current === merged) {
1137
+ return "unchanged";
1138
+ }
1139
+ const temp = `${path}.${serverName}-${randomBytes(6).toString("hex")}.tmp`;
1140
+ await mkdir(dirname(path), { recursive: true });
1141
+ await writeFile(temp, merged);
1142
+ const again = await readOptionalUtf8(path);
1143
+ if (again === current) {
1144
+ await rename(temp, path);
1145
+ return "written";
1146
+ }
1147
+ await rm(temp, { force: true });
1148
+ }
1149
+ return {
1150
+ kind: "refused",
1151
+ reason: "config-race",
1152
+ message: `${path} changed while it was being written`,
1153
+ };
1154
+ }
1155
+ function mergeText(current, format, launcher) {
1156
+ switch (format) {
1157
+ case "json":
1158
+ return mergeCursor(current, launcher);
1159
+ case "toml":
1160
+ return mergeGrok(current, launcher);
1161
+ default: {
1162
+ const leftover = format;
1163
+ throw new Error(`unexpected format ${String(leftover)}`);
1164
+ }
1165
+ }
1166
+ }
1167
+ function mergeCursor(current, launcher) {
1168
+ const entry = { command: launcher.command, args: [...launcher.args] };
1169
+ if (current === undefined || current.trim() === "") {
1170
+ return `${JSON.stringify({ mcpServers: { [serverName]: entry } }, null, 2)}\n`;
1171
+ }
1172
+ let parsed;
1173
+ try {
1174
+ parsed = JSON.parse(current);
1175
+ }
1176
+ catch {
1177
+ return { detail: "mcp.json is not valid JSON" };
1178
+ }
1179
+ const root = asRecord(parsed);
1180
+ if (root === undefined) {
1181
+ return { detail: "mcp.json must be a JSON object" };
1182
+ }
1183
+ const serversValue = root["mcpServers"];
1184
+ if (serversValue !== undefined && asRecord(serversValue) === undefined) {
1185
+ return { detail: "mcpServers is not an object" };
1186
+ }
1187
+ const servers = asRecord(serversValue) ?? {};
1188
+ const next = { ...root, mcpServers: { ...servers, [serverName]: entry } };
1189
+ return `${JSON.stringify(next, null, 2)}\n`;
1190
+ }
1191
+ function mergeGrok(current, launcher) {
1192
+ const table = desiredGrokTable(launcher);
1193
+ if (current === undefined || current.trim() === "") {
1194
+ return table;
1195
+ }
1196
+ const sections = scanTomlSections(current);
1197
+ if (sections === undefined) {
1198
+ return { detail: "config.toml headers cannot be scanned" };
1199
+ }
1200
+ const hits = sections.filter((section) => isPoddrillTable(section.name));
1201
+ if (hits.length > 1) {
1202
+ return { detail: "config.toml has more than one poddrill table" };
1203
+ }
1204
+ const hit = hits[0];
1205
+ if (hit === undefined) {
1206
+ const prefix = current.endsWith("\n") ? current : `${current}\n`;
1207
+ return `${prefix}\n${table}`;
1208
+ }
1209
+ return `${current.slice(0, hit.headerStart)}${table}${current.slice(hit.bodyEnd)}`;
1210
+ }
1211
+ function desiredGrokTable(launcher) {
1212
+ const args = launcher.args.map((arg) => `"${escapeToml(arg)}"`).join(", ");
1213
+ return [
1214
+ "[mcp_servers.poddrill]",
1215
+ `command = "${escapeToml(launcher.command)}"`,
1216
+ `args = [${args}]`,
1217
+ `startup_timeout_sec = ${GROK_STARTUP_TIMEOUT_SEC}`,
1218
+ "",
1219
+ ].join("\n");
1220
+ }
1221
+ function escapeToml(value) {
1222
+ return value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"");
1223
+ }
1224
+ async function deleteLeftoverTemps(path) {
1225
+ const dir = dirname(path);
1226
+ const prefix = `${basename(path)}.${serverName}-`;
1227
+ let names;
1228
+ try {
1229
+ names = await readdir(dir);
1230
+ }
1231
+ catch (error) {
1232
+ if (errorCode(error) === "ENOENT") {
1233
+ return;
1234
+ }
1235
+ throw error;
1236
+ }
1237
+ for (const name of names) {
1238
+ if (name.startsWith(prefix) && name.endsWith(".tmp")) {
1239
+ await rm(join(dir, name), { force: true });
1240
+ }
1241
+ }
1242
+ }
1243
+ async function staysInHome(home, candidate) {
1244
+ if (!pathStaysInHome(home, candidate)) {
1245
+ return false;
1246
+ }
1247
+ let homeReal;
1248
+ try {
1249
+ homeReal = await realpath(home);
1250
+ }
1251
+ catch (error) {
1252
+ if (errorCode(error) !== "ENOENT") {
1253
+ throw error;
1254
+ }
1255
+ await mkdir(home, { recursive: true });
1256
+ homeReal = await realpath(home);
1257
+ }
1258
+ return pathStaysInHome(homeReal, await resolveFromExisting(candidate));
1259
+ }
1260
+ async function resolveFromExisting(candidate) {
1261
+ const pending = [];
1262
+ let current = candidate;
1263
+ for (;;) {
1264
+ try {
1265
+ const found = await realpath(current);
1266
+ const rest = [...pending].reverse();
1267
+ return rest.length === 0 ? found : join(found, ...rest);
1268
+ }
1269
+ catch (error) {
1270
+ if (errorCode(error) !== "ENOENT") {
1271
+ throw error;
1272
+ }
1273
+ const parent = dirname(current);
1274
+ if (parent === current) {
1275
+ return normalize(candidate);
1276
+ }
1277
+ pending.push(basename(current));
1278
+ current = parent;
1279
+ }
1280
+ }
1281
+ }
1282
+ async function observeSkill(dest, bundle) {
1283
+ const parent = dirname(dest);
1284
+ const liveName = basename(dest);
1285
+ let names;
1286
+ try {
1287
+ names = await readdir(parent);
1288
+ }
1289
+ catch (error) {
1290
+ if (errorCode(error) === "ENOENT") {
1291
+ return { live: "absent", partialNext: [], completeNext: [], prev: [], orphanMarkers: [] };
1292
+ }
1293
+ throw error;
1294
+ }
1295
+ const bundleFiles = await filesIn(bundle);
1296
+ const partialNext = [];
1297
+ const completeNext = [];
1298
+ const prev = [];
1299
+ const orphanMarkers = [];
1300
+ const nameSet = new Set(names);
1301
+ for (const name of names) {
1302
+ const full = join(parent, name);
1303
+ if (name === liveName) {
1304
+ continue;
1305
+ }
1306
+ if (isMarkerName(name)) {
1307
+ const stem = name.slice(0, -skillMarkerSuffix.length);
1308
+ if (!nameSet.has(stem) || !(await isDirectory(join(parent, stem)))) {
1309
+ orphanMarkers.push(full);
1310
+ }
1311
+ continue;
1312
+ }
1313
+ if (name.startsWith(skillNextPrefix)) {
1314
+ const marked = await pathExists(markerPath(full));
1315
+ const directory = await isDirectory(full);
1316
+ const files = directory ? await filesIn(full) : undefined;
1317
+ if (directory && marked && bundleFiles !== undefined && files !== undefined && sameFiles(files, bundleFiles)) {
1318
+ completeNext.push(full);
1319
+ }
1320
+ else {
1321
+ partialNext.push(full);
1322
+ }
1323
+ continue;
1324
+ }
1325
+ if (name.startsWith(skillPrevPrefix)) {
1326
+ prev.push(full);
1327
+ }
1328
+ }
1329
+ let live = "absent";
1330
+ if (nameSet.has(liveName)) {
1331
+ const files = (await isDirectory(dest)) ? await filesIn(dest) : undefined;
1332
+ live =
1333
+ files !== undefined && bundleFiles !== undefined && sameFiles(files, bundleFiles) ? "match" : "differ";
1334
+ }
1335
+ partialNext.sort();
1336
+ completeNext.sort();
1337
+ prev.sort();
1338
+ orphanMarkers.sort();
1339
+ return { live, partialNext, completeNext, prev, orphanMarkers };
1340
+ }
1341
+ function isMarkerName(name) {
1342
+ return name.startsWith(skillNextPrefix) && name.endsWith(skillMarkerSuffix);
1343
+ }
1344
+ async function isDirectory(path) {
1345
+ try {
1346
+ const info = await statSafe(path);
1347
+ return info?.isDirectory() ?? false;
1348
+ }
1349
+ catch (error) {
1350
+ if (errorCode(error) === "ENOENT") {
1351
+ return false;
1352
+ }
1353
+ throw error;
1354
+ }
1355
+ }
1356
+ async function statSafe(path) {
1357
+ try {
1358
+ return await stat(path);
1359
+ }
1360
+ catch (error) {
1361
+ if (errorCode(error) === "ENOENT") {
1362
+ return undefined;
1363
+ }
1364
+ throw error;
1365
+ }
1366
+ }
1367
+ async function filesIn(dir) {
1368
+ const info = await statSafe(dir);
1369
+ if (info === undefined) {
1370
+ return undefined;
1371
+ }
1372
+ if (!info.isDirectory()) {
1373
+ return new Map([["", await readFile(dir)]]);
1374
+ }
1375
+ const out = new Map();
1376
+ await walkFiles(dir, dir, out);
1377
+ return out;
1378
+ }
1379
+ async function walkFiles(root, dir, out) {
1380
+ const entries = await readdir(dir, { withFileTypes: true });
1381
+ for (const entry of entries) {
1382
+ const path = join(dir, entry.name);
1383
+ if (entry.isDirectory()) {
1384
+ await walkFiles(root, path, out);
1385
+ continue;
1386
+ }
1387
+ if (entry.isFile()) {
1388
+ out.set(relative(root, path), await readFile(path));
1389
+ }
1390
+ }
1391
+ }
1392
+ function sameFiles(left, right) {
1393
+ if (left.size !== right.size) {
1394
+ return false;
1395
+ }
1396
+ for (const [key, bytes] of left) {
1397
+ const other = right.get(key);
1398
+ if (other === undefined || !other.equals(bytes)) {
1399
+ return false;
1400
+ }
1401
+ }
1402
+ return true;
1403
+ }
1404
+ async function runSkillStep(dest, bundle, step) {
1405
+ switch (step.op) {
1406
+ case "done":
1407
+ return;
1408
+ case "drop":
1409
+ await rm(step.path, { recursive: true, force: true });
1410
+ if (!step.path.endsWith(skillMarkerSuffix)) {
1411
+ await rm(markerPath(step.path), { force: true });
1412
+ }
1413
+ return;
1414
+ case "stage": {
1415
+ const next = join(dirname(dest), `${skillNextPrefix}${randomBytes(8).toString("hex")}`);
1416
+ await cp(bundle, next, { recursive: true });
1417
+ await writeFile(markerPath(next), "");
1418
+ return;
1419
+ }
1420
+ case "promote":
1421
+ try {
1422
+ await rename(step.path, dest);
1423
+ }
1424
+ catch (error) {
1425
+ const code = errorCode(error);
1426
+ if (code === "EEXIST" || code === "ENOTEMPTY" || code === "ENOENT") {
1427
+ return;
1428
+ }
1429
+ throw error;
1430
+ }
1431
+ return;
1432
+ case "aside": {
1433
+ const prev = join(dirname(dest), `${skillPrevPrefix}${randomBytes(8).toString("hex")}`);
1434
+ try {
1435
+ await rename(dest, prev);
1436
+ }
1437
+ catch (error) {
1438
+ if (errorCode(error) === "ENOENT") {
1439
+ return;
1440
+ }
1441
+ throw error;
1442
+ }
1443
+ return;
1444
+ }
1445
+ default: {
1446
+ const leftover = step;
1447
+ throw new Error(`unexpected skill step ${JSON.stringify(leftover)}`);
1448
+ }
1449
+ }
1450
+ }