@snarkipus/opencode-beads 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/LICENSE +9 -0
  2. package/README.md +150 -0
  3. package/dist/init/artifacts/beads/SKILL.md +16 -0
  4. package/dist/init/artifacts/beads/references/DEPENDENCIES.md +5 -0
  5. package/dist/init/artifacts/beads/references/ISSUE_CREATION.md +5 -0
  6. package/dist/init/artifacts/beads/references/RESUMABILITY.md +5 -0
  7. package/dist/init/manifest.json +54 -0
  8. package/package.json +82 -0
  9. package/src/init-cli.ts +172 -0
  10. package/src/init.ts +700 -0
  11. package/src/plugin-core.ts +290 -0
  12. package/src/plugin.ts +150 -0
  13. package/src/prime.ts +88 -0
  14. package/src/skill-collision-policy.ts +133 -0
  15. package/src/vendor-adaptations.ts +146 -0
  16. package/src/vendor-provenance.ts +252 -0
  17. package/src/vendor.ts +297 -0
  18. package/vendor/agents/task-agent.md +61 -0
  19. package/vendor/commands/audit.md +28 -0
  20. package/vendor/commands/blocked.md +15 -0
  21. package/vendor/commands/close.md +18 -0
  22. package/vendor/commands/comments.md +30 -0
  23. package/vendor/commands/compact.md +31 -0
  24. package/vendor/commands/create.md +19 -0
  25. package/vendor/commands/decision.md +111 -0
  26. package/vendor/commands/delete.md +32 -0
  27. package/vendor/commands/dep.md +101 -0
  28. package/vendor/commands/epic.md +27 -0
  29. package/vendor/commands/export.md +25 -0
  30. package/vendor/commands/import.md +18 -0
  31. package/vendor/commands/init.md +18 -0
  32. package/vendor/commands/label.md +33 -0
  33. package/vendor/commands/list.md +64 -0
  34. package/vendor/commands/prime.md +10 -0
  35. package/vendor/commands/quickstart.md +13 -0
  36. package/vendor/commands/ready.md +15 -0
  37. package/vendor/commands/rename-prefix.md +24 -0
  38. package/vendor/commands/reopen.md +22 -0
  39. package/vendor/commands/restore.md +27 -0
  40. package/vendor/commands/search.md +103 -0
  41. package/vendor/commands/show.md +17 -0
  42. package/vendor/commands/stats.md +17 -0
  43. package/vendor/commands/sync.md +17 -0
  44. package/vendor/commands/template.md +337 -0
  45. package/vendor/commands/update.md +24 -0
  46. package/vendor/commands/version.md +26 -0
  47. package/vendor/commands/workflow.md +58 -0
  48. package/vendor/manifest.json +168 -0
package/src/init.ts ADDED
@@ -0,0 +1,700 @@
1
+ import { createHash } from "node:crypto";
2
+ import * as fs from "node:fs/promises";
3
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
4
+ import {
5
+ beadsSkillLocations,
6
+ inspectBeadsSkillLocations,
7
+ type ManagedSkillState,
8
+ type SkillLocation,
9
+ } from "./skill-collision-policy";
10
+
11
+ export const OWNERSHIP_MANIFEST = ".opencode-beads-manifest.json";
12
+ const OWNER = "opencode-beads";
13
+ const PAYLOAD_PATHS = [
14
+ "SKILL.md",
15
+ "references/DEPENDENCIES.md",
16
+ "references/ISSUE_CREATION.md",
17
+ "references/RESUMABILITY.md",
18
+ ] as const;
19
+
20
+ export type InitCommand = "check" | "init" | "remove" | "update";
21
+ export type InitState = "conflicting" | "current" | "missing" | "modified" | "stale";
22
+ export type InitScope = "global" | "project";
23
+
24
+ interface ArtifactFile {
25
+ path: string;
26
+ bytes: number;
27
+ sha256: string;
28
+ }
29
+
30
+ interface ArtifactSource {
31
+ source: string;
32
+ sourceSha256: string;
33
+ target: string;
34
+ }
35
+
36
+ const ARTIFACT_SOURCES: ArtifactSource[] = [
37
+ {
38
+ source: "plugins/beads/skills/beads/SKILL.md",
39
+ sourceSha256: "01555fe65d19be401d820d9dec029cd048fb0791d433b4b575374477d6f1d827",
40
+ target: "SKILL.md",
41
+ },
42
+ {
43
+ source: "plugins/beads/skills/beads/resources/DEPENDENCIES.md",
44
+ sourceSha256: "9c3327611bfbdc47124736dd0cc928bfeff1c135d4ae79d4ea46cba1900df335",
45
+ target: "references/DEPENDENCIES.md",
46
+ },
47
+ {
48
+ source: "plugins/beads/skills/beads/resources/ISSUE_CREATION.md",
49
+ sourceSha256: "ff465ed1fb13fbb6c42b42ec15c1bd8fd677c4661237e4fc1675c179f7fca460",
50
+ target: "references/ISSUE_CREATION.md",
51
+ },
52
+ {
53
+ source: "plugins/beads/skills/beads/resources/RESUMABILITY.md",
54
+ sourceSha256: "8a7db4e967ace1b4f60dc85e3fb2d02f70749a18056fe90c61b2685bb172d7df",
55
+ target: "references/RESUMABILITY.md",
56
+ },
57
+ ];
58
+
59
+ interface ArtifactManifest {
60
+ schemaVersion: 1;
61
+ owner: typeof OWNER;
62
+ upstream: { repository: string; tag: string; commit: string };
63
+ adaptationRevision: number;
64
+ sources: ArtifactSource[];
65
+ files: ArtifactFile[];
66
+ }
67
+
68
+ export interface InitMutationFileSystem {
69
+ mkdir(path: string, options?: { recursive?: boolean }): Promise<string | undefined | void>;
70
+ mkdtemp(prefix: string): Promise<string>;
71
+ rmdir(path: string): Promise<void>;
72
+ writeFile(
73
+ path: string,
74
+ data: string | Uint8Array,
75
+ options?: { flag?: "w" | "wx" }
76
+ ): Promise<void>;
77
+ rename(oldPath: string, newPath: string): Promise<void>;
78
+ cp(
79
+ source: string,
80
+ destination: string,
81
+ options: { recursive: true; errorOnExist: true; force: false }
82
+ ): Promise<void>;
83
+ rm(path: string, options?: { recursive?: boolean; force?: boolean }): Promise<void>;
84
+ }
85
+
86
+ interface OwnershipManifest {
87
+ schemaVersion: 1;
88
+ owner: typeof OWNER;
89
+ packageVersion: string;
90
+ upstream: { tag: string; commit: string };
91
+ adaptationRevision: number;
92
+ scope: InitScope;
93
+ target: string;
94
+ files: Array<Pick<ArtifactFile, "path" | "sha256">>;
95
+ }
96
+
97
+ export interface InitInputs {
98
+ cwd: string;
99
+ worktree: string;
100
+ home: string;
101
+ opencodeConfigDirectory?: string;
102
+ packageRoot: string;
103
+ packageVersion: string;
104
+ scope?: InitScope;
105
+ dryRun?: boolean;
106
+ /** Test seam for mutation failures; reads always use the real filesystem. */
107
+ mutationFileSystem?: InitMutationFileSystem;
108
+ }
109
+
110
+ export type InitPlanOperation = {
111
+ action: "mkdir" | "remove" | "rmdir" | "write-manifest" | "write-payload";
112
+ path: string;
113
+ };
114
+
115
+ export interface InitResult {
116
+ command: InitCommand;
117
+ scope: InitScope;
118
+ target: string;
119
+ state: InitState;
120
+ ok: boolean;
121
+ changed: boolean;
122
+ dryRun: boolean;
123
+ plan: InitPlanOperation[];
124
+ collisions: string[];
125
+ error?: string;
126
+ }
127
+
128
+ function isRecord(value: unknown): value is Record<string, unknown> {
129
+ return typeof value === "object" && value !== null && !Array.isArray(value);
130
+ }
131
+
132
+ function exactKeys(value: Record<string, unknown>, keys: string[]): boolean {
133
+ return Object.keys(value).sort().join("\0") === [...keys].sort().join("\0");
134
+ }
135
+
136
+ function validRelativePath(path: string): boolean {
137
+ return path.length > 0 && !path.startsWith("/") && !path.split(/[\\/]/).includes("..");
138
+ }
139
+
140
+ function sha256(content: Uint8Array): string {
141
+ return createHash("sha256").update(content).digest("hex");
142
+ }
143
+
144
+ function isMissing(error: unknown): boolean {
145
+ return isRecord(error) && error.code === "ENOENT";
146
+ }
147
+
148
+ async function readJson(path: string): Promise<unknown> {
149
+ return JSON.parse(await fs.readFile(path, "utf8"));
150
+ }
151
+
152
+ function parseArtifactManifest(value: unknown): ArtifactManifest {
153
+ if (
154
+ !isRecord(value) ||
155
+ !exactKeys(value, ["schemaVersion", "owner", "upstream", "adaptationRevision", "sources", "files"]) ||
156
+ value.schemaVersion !== 1 ||
157
+ value.owner !== OWNER ||
158
+ typeof value.adaptationRevision !== "number" ||
159
+ !Number.isInteger(value.adaptationRevision) ||
160
+ !isRecord(value.upstream) ||
161
+ !exactKeys(value.upstream, ["repository", "tag", "commit"]) ||
162
+ typeof value.upstream.repository !== "string" ||
163
+ typeof value.upstream.tag !== "string" ||
164
+ typeof value.upstream.commit !== "string" ||
165
+ !Array.isArray(value.sources) ||
166
+ !Array.isArray(value.files)
167
+ ) {
168
+ throw new Error("invalid packaged manifest schema");
169
+ }
170
+ if (
171
+ value.upstream.repository !== "https://github.com/gastownhall/beads.git" ||
172
+ value.upstream.tag !== "v1.1.0" ||
173
+ value.upstream.commit !== "8e4e59d39f3459a43cf21a3236a13eca4dd874f7" ||
174
+ value.adaptationRevision !== 1
175
+ ) {
176
+ throw new Error("packaged manifest provenance is not the reviewed adaptation");
177
+ }
178
+
179
+ const sources = value.sources.map((source, index): ArtifactSource => {
180
+ if (
181
+ !isRecord(source) ||
182
+ !exactKeys(source, ["source", "sourceSha256", "target"]) ||
183
+ typeof source.source !== "string" ||
184
+ typeof source.sourceSha256 !== "string" ||
185
+ typeof source.target !== "string"
186
+ ) {
187
+ throw new Error(`invalid packaged manifest source ${index}`);
188
+ }
189
+ return {
190
+ source: source.source,
191
+ sourceSha256: source.sourceSha256,
192
+ target: source.target,
193
+ };
194
+ });
195
+ if (JSON.stringify(sources) !== JSON.stringify(ARTIFACT_SOURCES)) {
196
+ throw new Error("packaged manifest must contain the fixed sorted source mappings");
197
+ }
198
+
199
+ const files = value.files.map((file, index): ArtifactFile => {
200
+ if (
201
+ !isRecord(file) ||
202
+ !exactKeys(file, ["path", "bytes", "sha256"]) ||
203
+ typeof file.path !== "string" ||
204
+ !validRelativePath(file.path) ||
205
+ typeof file.bytes !== "number" ||
206
+ !Number.isInteger(file.bytes) ||
207
+ file.bytes < 0 ||
208
+ typeof file.sha256 !== "string" ||
209
+ !/^[a-f0-9]{64}$/.test(file.sha256)
210
+ ) {
211
+ throw new Error(`invalid packaged manifest file ${index}`);
212
+ }
213
+ return { path: file.path, bytes: file.bytes, sha256: file.sha256 };
214
+ });
215
+ const paths = files.map((file) => file.path);
216
+ if (
217
+ paths.join("\0") !== [...paths].sort().join("\0") ||
218
+ paths.join("\0") !== PAYLOAD_PATHS.join("\0")
219
+ ) {
220
+ throw new Error("packaged manifest must contain the fixed sorted payload inventory");
221
+ }
222
+ return {
223
+ schemaVersion: 1,
224
+ owner: OWNER,
225
+ upstream: {
226
+ repository: value.upstream.repository,
227
+ tag: value.upstream.tag,
228
+ commit: value.upstream.commit,
229
+ },
230
+ adaptationRevision: value.adaptationRevision,
231
+ sources,
232
+ files,
233
+ };
234
+ }
235
+
236
+ function parseOwnershipManifest(value: unknown): OwnershipManifest | undefined {
237
+ if (!isRecord(value) || value.owner !== OWNER) return undefined;
238
+ if (
239
+ !exactKeys(value, [
240
+ "schemaVersion",
241
+ "owner",
242
+ "packageVersion",
243
+ "upstream",
244
+ "adaptationRevision",
245
+ "scope",
246
+ "target",
247
+ "files",
248
+ ]) ||
249
+ value.schemaVersion !== 1 ||
250
+ typeof value.packageVersion !== "string" ||
251
+ value.packageVersion.length === 0 ||
252
+ !isRecord(value.upstream) ||
253
+ !exactKeys(value.upstream, ["tag", "commit"]) ||
254
+ typeof value.upstream.tag !== "string" ||
255
+ value.upstream.tag.length === 0 ||
256
+ typeof value.upstream.commit !== "string" ||
257
+ value.upstream.commit.length === 0 ||
258
+ typeof value.adaptationRevision !== "number" ||
259
+ !Number.isInteger(value.adaptationRevision) ||
260
+ value.adaptationRevision < 1 ||
261
+ (value.scope !== "project" && value.scope !== "global") ||
262
+ typeof value.target !== "string" ||
263
+ !Array.isArray(value.files)
264
+ ) {
265
+ return undefined;
266
+ }
267
+ const files: OwnershipManifest["files"] = [];
268
+ for (const file of value.files) {
269
+ if (
270
+ !isRecord(file) ||
271
+ !exactKeys(file, ["path", "sha256"]) ||
272
+ typeof file.path !== "string" ||
273
+ !validRelativePath(file.path) ||
274
+ file.path === OWNERSHIP_MANIFEST ||
275
+ typeof file.sha256 !== "string" ||
276
+ !/^[a-f0-9]{64}$/.test(file.sha256)
277
+ ) {
278
+ return undefined;
279
+ }
280
+ files.push({ path: file.path, sha256: file.sha256 });
281
+ }
282
+ const paths = files.map((file) => file.path);
283
+ if (new Set(paths).size !== paths.length || paths.join("\0") !== [...paths].sort().join("\0")) {
284
+ return undefined;
285
+ }
286
+ return {
287
+ schemaVersion: 1,
288
+ owner: OWNER,
289
+ packageVersion: value.packageVersion,
290
+ upstream: { tag: value.upstream.tag, commit: value.upstream.commit },
291
+ adaptationRevision: value.adaptationRevision,
292
+ scope: value.scope,
293
+ target: value.target,
294
+ files,
295
+ };
296
+ }
297
+
298
+ async function listTree(root: string): Promise<{ files: string[]; symlink: boolean }> {
299
+ const files: string[] = [];
300
+ let symlink = false;
301
+ async function visit(directory: string): Promise<void> {
302
+ const entries = await fs.readdir(join(root, directory), { withFileTypes: true });
303
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
304
+ const path = directory ? `${directory}/${entry.name}` : entry.name;
305
+ if (entry.isSymbolicLink()) {
306
+ symlink = true;
307
+ files.push(path);
308
+ } else if (entry.isDirectory()) {
309
+ await visit(path);
310
+ } else {
311
+ files.push(path);
312
+ }
313
+ }
314
+ }
315
+ await visit("");
316
+ return { files: files.sort(), symlink };
317
+ }
318
+
319
+ async function loadArtifacts(packageRoot: string): Promise<{
320
+ manifest: ArtifactManifest;
321
+ contents: Map<string, Uint8Array>;
322
+ }> {
323
+ const initRoot = join(packageRoot, "dist", "init");
324
+ let manifest: ArtifactManifest;
325
+ try {
326
+ manifest = parseArtifactManifest(await readJson(join(initRoot, "manifest.json")));
327
+ } catch (error) {
328
+ throw new Error(`Invalid packaged artifact manifest: ${error instanceof Error ? error.message : String(error)}`);
329
+ }
330
+ const contents = new Map<string, Uint8Array>();
331
+ for (const file of manifest.files) {
332
+ const path = join(initRoot, "artifacts", "beads", file.path);
333
+ const stats = await fs.lstat(path).catch(() => undefined);
334
+ if (!stats?.isFile() || stats.isSymbolicLink()) {
335
+ throw new Error(`Invalid packaged artifact ${file.path}: missing, symlinked, or not a file`);
336
+ }
337
+ const content = await fs.readFile(path);
338
+ if (content.byteLength !== file.bytes || sha256(content) !== file.sha256) {
339
+ throw new Error(`Invalid packaged artifact ${file.path}: size or checksum mismatch`);
340
+ }
341
+ contents.set(file.path, content);
342
+ }
343
+ const tree = await listTree(join(initRoot, "artifacts", "beads"));
344
+ if (tree.symlink || tree.files.join("\0") !== manifest.files.map((file) => file.path).join("\0")) {
345
+ throw new Error("Invalid packaged artifacts: inventory differs from manifest");
346
+ }
347
+ return { manifest, contents };
348
+ }
349
+
350
+ function expectedOwnership(
351
+ artifact: ArtifactManifest,
352
+ packageVersion: string,
353
+ scope: InitScope,
354
+ target: string
355
+ ): OwnershipManifest {
356
+ return {
357
+ schemaVersion: 1,
358
+ owner: OWNER,
359
+ packageVersion,
360
+ upstream: { tag: artifact.upstream.tag, commit: artifact.upstream.commit },
361
+ adaptationRevision: artifact.adaptationRevision,
362
+ scope,
363
+ target,
364
+ files: artifact.files.map(({ path, sha256: hash }) => ({ path, sha256: hash })),
365
+ };
366
+ }
367
+
368
+ async function inspectTarget(
369
+ target: string,
370
+ scope: InitScope,
371
+ expected: OwnershipManifest
372
+ ): Promise<{ state: InitState; ownership?: OwnershipManifest }> {
373
+ let stats;
374
+ try {
375
+ stats = await fs.lstat(target);
376
+ } catch (error) {
377
+ if (isMissing(error)) return { state: "missing" };
378
+ return { state: "conflicting" };
379
+ }
380
+ if (!stats.isDirectory() || stats.isSymbolicLink()) return { state: "conflicting" };
381
+
382
+ const manifestPath = join(target, OWNERSHIP_MANIFEST);
383
+ const manifestStats = await fs.lstat(manifestPath).catch(() => undefined);
384
+ if (!manifestStats?.isFile() || manifestStats.isSymbolicLink()) return { state: "conflicting" };
385
+ let ownership: OwnershipManifest | undefined;
386
+ try {
387
+ ownership = parseOwnershipManifest(await readJson(manifestPath));
388
+ } catch {
389
+ return { state: "conflicting" };
390
+ }
391
+ if (!ownership || resolve(ownership.target) !== target || ownership.scope !== scope) {
392
+ return { state: "conflicting" };
393
+ }
394
+
395
+ const tree = await listTree(target);
396
+ const expectedFiles = [...ownership.files.map((file) => file.path), OWNERSHIP_MANIFEST].sort();
397
+ if (tree.symlink || tree.files.join("\0") !== expectedFiles.join("\0")) {
398
+ return { state: "modified", ownership };
399
+ }
400
+ for (const file of ownership.files) {
401
+ const content = await fs.readFile(join(target, file.path)).catch(() => undefined);
402
+ if (!content || sha256(content) !== file.sha256) return { state: "modified", ownership };
403
+ }
404
+ const metadataMatches =
405
+ ownership.packageVersion === expected.packageVersion &&
406
+ ownership.upstream.tag === expected.upstream.tag &&
407
+ ownership.upstream.commit === expected.upstream.commit &&
408
+ ownership.adaptationRevision === expected.adaptationRevision &&
409
+ ownership.scope === expected.scope &&
410
+ ownership.target === expected.target &&
411
+ JSON.stringify(ownership.files) === JSON.stringify(expected.files);
412
+ return metadataMatches
413
+ ? { state: "current", ownership }
414
+ : { state: "stale", ownership };
415
+ }
416
+
417
+ function targetFor(
418
+ worktree: string,
419
+ opencodeConfigDirectory: string,
420
+ scope: InitScope
421
+ ): string {
422
+ return scope === "global"
423
+ ? resolve(opencodeConfigDirectory, "skills", "beads")
424
+ : resolve(worktree, ".opencode", "skills", "beads");
425
+ }
426
+
427
+ async function assertNoTargetSymlink(target: string, boundary: string): Promise<void> {
428
+ const parts = relative(resolve(boundary), target).split(sep).filter(Boolean);
429
+ let current = resolve(boundary);
430
+ for (const part of parts) {
431
+ current = join(current, part);
432
+ const stats = await fs.lstat(current).catch((error) => {
433
+ if (isMissing(error)) return undefined;
434
+ throw error;
435
+ });
436
+ if (stats?.isSymbolicLink()) throw new Error(`Refusing symlink in target path: ${current}`);
437
+ }
438
+ }
439
+
440
+ function refusal(command: InitCommand, state: InitState): string | undefined {
441
+ if (command === "check") return undefined;
442
+
443
+ const isManagedState = state === "current" || state === "stale";
444
+ if (command === "init") {
445
+ return state === "modified" || state === "conflicting"
446
+ ? `Refusing to initialize ${state} skill`
447
+ : undefined;
448
+ }
449
+ if (command === "update") {
450
+ return isManagedState ? undefined : `Refusing to update ${state} skill`;
451
+ }
452
+ return isManagedState ? undefined : `Refusing to remove ${state} skill`;
453
+ }
454
+
455
+ async function installPlan(target: string, old: OwnershipManifest | undefined, expected: OwnershipManifest): Promise<InitPlanOperation[]> {
456
+ const operations: InitPlanOperation[] = [];
457
+ const targetStats = await fs.lstat(target).catch(() => undefined);
458
+ const references = join(target, "references");
459
+ const referenceStats = await fs.lstat(references).catch(() => undefined);
460
+ if (!targetStats) operations.push({ action: "mkdir", path: target });
461
+ if (!referenceStats) operations.push({ action: "mkdir", path: references });
462
+ const expectedPaths = new Set(expected.files.map((file) => file.path));
463
+ for (const path of old?.files.map((file) => file.path).filter((path) => !expectedPaths.has(path)).sort() ?? []) {
464
+ operations.push({ action: "remove", path: join(target, path) });
465
+ }
466
+ const expectedDirectories = new Set(expected.files.map((file) => dirname(file.path)).filter((path) => path !== "."));
467
+ const obsoleteDirectories = new Set<string>();
468
+ for (const file of old?.files ?? []) {
469
+ for (let directory = dirname(file.path); directory !== "."; directory = dirname(directory)) {
470
+ if (!expectedDirectories.has(directory)) obsoleteDirectories.add(directory);
471
+ }
472
+ }
473
+ for (const directory of [...obsoleteDirectories].sort().reverse()) {
474
+ operations.push({ action: "rmdir", path: join(target, directory) });
475
+ }
476
+ for (const file of expected.files) operations.push({ action: "write-payload", path: join(target, file.path) });
477
+ operations.push({ action: "write-manifest", path: join(target, OWNERSHIP_MANIFEST) });
478
+ return operations;
479
+ }
480
+
481
+ function removePlan(target: string, ownership: OwnershipManifest): InitPlanOperation[] {
482
+ const directories = new Set<string>();
483
+ for (const file of ownership.files) {
484
+ for (let directory = dirname(file.path); directory !== "."; directory = dirname(directory)) {
485
+ directories.add(directory);
486
+ }
487
+ }
488
+ return [
489
+ ...ownership.files.map((file) => ({ action: "remove" as const, path: join(target, file.path) })),
490
+ { action: "remove", path: join(target, OWNERSHIP_MANIFEST) },
491
+ ...[...directories]
492
+ .sort((left, right) => right.split("/").length - left.split("/").length || left.localeCompare(right))
493
+ .map((directory) => ({ action: "rmdir" as const, path: join(target, directory) })),
494
+ { action: "rmdir", path: target },
495
+ ];
496
+ }
497
+
498
+ function transactionPrefix(target: string, kind: "backup" | "recovery" | "stage"): string {
499
+ return `.${basename(target)}.opencode-beads-${kind}-`;
500
+ }
501
+
502
+ async function staleTransactions(target: string): Promise<string[]> {
503
+ const parent = dirname(target);
504
+ const entries = await fs.readdir(parent).catch((error) => {
505
+ if (isMissing(error)) return [];
506
+ throw error;
507
+ });
508
+ const prefixes = (["backup", "recovery", "stage"] as const).map((kind) => transactionPrefix(target, kind));
509
+ return entries
510
+ .filter((entry) => prefixes.some((prefix) => entry.startsWith(prefix)))
511
+ .sort()
512
+ .map((entry) => join(parent, entry));
513
+ }
514
+
515
+ async function cleanup(mutationFs: InitMutationFileSystem, path: string): Promise<void> {
516
+ await mutationFs.rm(path, { recursive: true, force: true }).catch(() => undefined);
517
+ }
518
+
519
+ async function buildStage(
520
+ contents: Map<string, Uint8Array>,
521
+ ownership: OwnershipManifest,
522
+ target: string,
523
+ mutationFs: InitMutationFileSystem
524
+ ): Promise<string> {
525
+ await mutationFs.mkdir(dirname(target), { recursive: true });
526
+ const stage = await mutationFs.mkdtemp(join(dirname(target), transactionPrefix(target, "stage")));
527
+ try {
528
+ for (const file of ownership.files) {
529
+ const content = contents.get(file.path);
530
+ if (!content) throw new Error(`Missing planned payload for ${file.path}`);
531
+ await mutationFs.mkdir(dirname(join(stage, file.path)), { recursive: true });
532
+ await mutationFs.writeFile(join(stage, file.path), content, { flag: "wx" });
533
+ }
534
+ await mutationFs.writeFile(
535
+ join(stage, OWNERSHIP_MANIFEST),
536
+ `${JSON.stringify(ownership, null, 2)}\n`,
537
+ { flag: "wx" }
538
+ );
539
+ return stage;
540
+ } catch (error) {
541
+ await cleanup(mutationFs, stage);
542
+ throw error;
543
+ }
544
+ }
545
+
546
+ async function installTransaction(
547
+ target: string,
548
+ targetExists: boolean,
549
+ contents: Map<string, Uint8Array>,
550
+ ownership: OwnershipManifest,
551
+ mutationFs: InitMutationFileSystem
552
+ ): Promise<void> {
553
+ const stage = await buildStage(contents, ownership, target, mutationFs);
554
+ if (!targetExists) {
555
+ try {
556
+ await mutationFs.rename(stage, target);
557
+ } catch (error) {
558
+ await cleanup(mutationFs, stage);
559
+ throw error;
560
+ }
561
+ return;
562
+ }
563
+
564
+ let backup: string | undefined;
565
+ let recovery: string | undefined;
566
+ let moved = false;
567
+ try {
568
+ backup = await mutationFs.mkdtemp(join(dirname(target), transactionPrefix(target, "backup")));
569
+ recovery = await mutationFs.mkdtemp(join(dirname(target), transactionPrefix(target, "recovery")));
570
+ await mutationFs.rmdir(backup);
571
+ await mutationFs.rmdir(recovery);
572
+ await mutationFs.rename(target, backup);
573
+ moved = true;
574
+ await mutationFs.cp(backup, recovery, { recursive: true, errorOnExist: true, force: false });
575
+ await mutationFs.rename(stage, target);
576
+ try {
577
+ await mutationFs.rm(backup, { recursive: true });
578
+ } catch (error) {
579
+ await mutationFs.rename(target, stage);
580
+ await mutationFs.rename(recovery, target);
581
+ await cleanup(mutationFs, stage);
582
+ await cleanup(mutationFs, backup);
583
+ throw error;
584
+ }
585
+ await cleanup(mutationFs, recovery);
586
+ } catch (error) {
587
+ if (moved && !(await fs.exists(target))) {
588
+ const restore = backup && (await fs.exists(backup)) ? backup : recovery;
589
+ if (restore && (await fs.exists(restore))) await mutationFs.rename(restore, target);
590
+ }
591
+ await cleanup(mutationFs, stage);
592
+ if (backup) await cleanup(mutationFs, backup);
593
+ if (recovery) await cleanup(mutationFs, recovery);
594
+ throw error;
595
+ }
596
+ }
597
+
598
+ async function removeTransaction(target: string, mutationFs: InitMutationFileSystem): Promise<void> {
599
+ let backup: string | undefined;
600
+ let recovery: string | undefined;
601
+ let moved = false;
602
+ try {
603
+ backup = await mutationFs.mkdtemp(join(dirname(target), transactionPrefix(target, "backup")));
604
+ recovery = await mutationFs.mkdtemp(join(dirname(target), transactionPrefix(target, "recovery")));
605
+ await mutationFs.rmdir(backup);
606
+ await mutationFs.rmdir(recovery);
607
+ await mutationFs.rename(target, backup);
608
+ moved = true;
609
+ await mutationFs.cp(backup, recovery, { recursive: true, errorOnExist: true, force: false });
610
+ await mutationFs.rm(backup, { recursive: true });
611
+ await cleanup(mutationFs, recovery);
612
+ } catch (error) {
613
+ if (moved && !(await fs.exists(target))) {
614
+ const restore = recovery && (await fs.exists(recovery)) ? recovery : backup;
615
+ if (restore && (await fs.exists(restore))) await mutationFs.rename(restore, target);
616
+ }
617
+ if (backup) await cleanup(mutationFs, backup);
618
+ if (recovery) await cleanup(mutationFs, recovery);
619
+ throw error;
620
+ }
621
+ }
622
+
623
+ /** Validate, inspect, plan, and optionally execute one managed-skill lifecycle command. */
624
+ export async function runInitCommand(command: InitCommand, inputs: InitInputs): Promise<InitResult> {
625
+ const scope = inputs.scope ?? "project";
626
+ const cwd = resolve(inputs.cwd);
627
+ const worktree = resolve(inputs.worktree);
628
+ const home = resolve(inputs.home);
629
+ const opencodeConfigDirectory = resolve(
630
+ inputs.opencodeConfigDirectory ?? join(home, ".config", "opencode")
631
+ );
632
+ const target = targetFor(worktree, opencodeConfigDirectory, scope);
633
+ // This call validates cwd containment even when no collision locations exist.
634
+ beadsSkillLocations(cwd, worktree, home, opencodeConfigDirectory);
635
+ const { manifest: artifact, contents } = await loadArtifacts(resolve(inputs.packageRoot));
636
+ const expected = expectedOwnership(artifact, inputs.packageVersion, scope, target);
637
+ await assertNoTargetSymlink(
638
+ target,
639
+ scope === "global" ? dirname(opencodeConfigDirectory) : worktree
640
+ );
641
+ const transactions = await staleTransactions(target);
642
+ const targetInspection = await inspectTarget(target, scope, expected);
643
+
644
+ const inspections = await inspectBeadsSkillLocations(
645
+ cwd,
646
+ worktree,
647
+ home,
648
+ async (location: SkillLocation): Promise<ManagedSkillState | "differently-managed" | undefined> => {
649
+ const locationScope = location.scope;
650
+ const locationExpected = expectedOwnership(artifact, inputs.packageVersion, locationScope, resolve(location.path));
651
+ const inspection = await inspectTarget(resolve(location.path), locationScope, locationExpected);
652
+ if (inspection.state === "current" || inspection.state === "stale" || inspection.state === "modified") return inspection.state;
653
+ if (inspection.state === "conflicting") return "differently-managed";
654
+ return undefined;
655
+ },
656
+ opencodeConfigDirectory
657
+ );
658
+ const discoveryCollisions = inspections
659
+ .filter((inspection) => resolve(inspection.path) !== target && inspection.state !== "absent")
660
+ .map((inspection) => resolve(inspection.path));
661
+ const collisions = [...transactions, ...discoveryCollisions].sort();
662
+ const blockedByCollision =
663
+ transactions.length > 0 || (command !== "remove" && discoveryCollisions.length > 0);
664
+ const state: InitState = blockedByCollision ? "conflicting" : targetInspection.state;
665
+ const error = refusal(command, state);
666
+ const canMutate = error === undefined;
667
+ const shouldInstall =
668
+ canMutate &&
669
+ ((command === "init" && (state === "missing" || state === "stale")) ||
670
+ (command === "update" && state === "stale"));
671
+ const shouldRemove = canMutate && command === "remove";
672
+
673
+ let plan: InitPlanOperation[] = [];
674
+ if (shouldInstall) {
675
+ plan = await installPlan(target, targetInspection.ownership, expected);
676
+ } else if (shouldRemove && targetInspection.ownership) {
677
+ plan = removePlan(target, targetInspection.ownership);
678
+ }
679
+ const changed = plan.length > 0;
680
+ if (canMutate && changed && !inputs.dryRun) {
681
+ const mutationFs = inputs.mutationFileSystem ?? fs;
682
+ if (shouldInstall) {
683
+ await installTransaction(target, targetInspection.state !== "missing", contents, expected, mutationFs);
684
+ } else {
685
+ await removeTransaction(target, mutationFs);
686
+ }
687
+ }
688
+ return {
689
+ command,
690
+ scope,
691
+ target,
692
+ state,
693
+ ok: error === undefined,
694
+ changed,
695
+ dryRun: inputs.dryRun ?? false,
696
+ plan,
697
+ collisions,
698
+ ...(error ? { error } : {}),
699
+ };
700
+ }