ddduck 0.1.2 → 0.3.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.
@@ -1,463 +0,0 @@
1
- /**
2
- * Installer for the bundled update-ddduck-specs agent skill, behind `ddduck
3
- * install skill`. Selects a host topology from the repository state (codex
4
- * .agents/, claude-code .claude/, or shared via symlink), plans create,
5
- * upgrade, or no-op against the canonical SKILL.md and the
6
- * .ddduck/agent-skills.lock.json lock, and applies the plan with atomic
7
- * writes plus rollback of everything touched on failure. Conflicting host
8
- * state or a locally modified canonical file refuses with a nextAction
9
- * naming the exact path to resolve.
10
- */
11
-
12
- import {
13
- lstatSync,
14
- mkdirSync,
15
- readFileSync,
16
- readdirSync,
17
- readlinkSync,
18
- renameSync,
19
- rmSync,
20
- symlinkSync,
21
- writeFileSync,
22
- } from "node:fs";
23
- import { createHash, randomUUID } from "node:crypto";
24
- import path from "node:path";
25
-
26
- const lockSchemaVersion = 1;
27
- const lockRelativePath = path.join(".ddduck", "agent-skills.lock.json");
28
-
29
- const defaultOperations = {
30
- lstatSync,
31
- mkdirSync,
32
- readFileSync,
33
- readdirSync,
34
- readlinkSync,
35
- renameSync,
36
- rmSync,
37
- symlinkSync,
38
- writeFileSync,
39
- };
40
-
41
- const codexSkillDirectory = path.join(".agents", "skills", "update-ddduck-specs");
42
- const claudeSkillDirectory = path.join(".claude", "skills", "update-ddduck-specs");
43
- const skillFileName = "SKILL.md";
44
-
45
- const hostSkillAdapters = {
46
- codex: {
47
- host: "codex",
48
- relativePath: codexSkillDirectory,
49
- lockEntry() {
50
- return { host: this.host, path: ".agents/skills/update-ddduck-specs" };
51
- },
52
- parentPaths(adapterPath) {
53
- return [path.dirname(path.dirname(adapterPath)), path.dirname(adapterPath)];
54
- },
55
- inspect({ adapterPath, operations }) {
56
- const state = pathState(adapterPath, operations);
57
- if (state.type === "absent") return "absent";
58
- return state.type === "directory" ? "valid" : "conflict";
59
- },
60
- materialize({ adapterPath, operations, touched }) {
61
- touched.push(adapterPath);
62
- operations.mkdirSync(adapterPath, { recursive: true });
63
- },
64
- },
65
- claudeDirectory: {
66
- host: "claude-code",
67
- relativePath: claudeSkillDirectory,
68
- lockEntry() {
69
- return { host: this.host, path: ".claude/skills/update-ddduck-specs" };
70
- },
71
- parentPaths(adapterPath) {
72
- return [path.dirname(path.dirname(adapterPath)), path.dirname(adapterPath)];
73
- },
74
- inspect({ adapterPath, operations }) {
75
- const state = pathState(adapterPath, operations);
76
- if (state.type === "absent") return "absent";
77
- return state.type === "directory" ? "valid" : "conflict";
78
- },
79
- materialize({ adapterPath, operations, touched }) {
80
- touched.push(adapterPath);
81
- operations.mkdirSync(adapterPath, { recursive: true });
82
- },
83
- },
84
- claudeSymlink: {
85
- host: "claude-code",
86
- relativePath: claudeSkillDirectory,
87
- target: "../../.agents/skills/update-ddduck-specs",
88
- lockEntry() {
89
- return { host: this.host, path: ".claude/skills/update-ddduck-specs", target: this.target };
90
- },
91
- parentPaths(adapterPath) {
92
- return [path.dirname(path.dirname(adapterPath)), path.dirname(adapterPath)];
93
- },
94
- inspect({ adapterPath, operations }) {
95
- const state = pathState(adapterPath, operations);
96
- if (state.type === "absent") return "absent";
97
- if (state.type !== "symlink" || operations.readlinkSync(adapterPath) !== this.target) return "conflict";
98
- return pathState(path.join(adapterPath, skillFileName), operations).type === "file" ? "valid" : "conflict";
99
- },
100
- materialize({ adapterPath, operations, touched }) {
101
- touched.push(path.dirname(adapterPath), adapterPath);
102
- operations.mkdirSync(path.dirname(adapterPath), { recursive: true });
103
- operations.symlinkSync(this.target, adapterPath);
104
- },
105
- },
106
- };
107
-
108
- const installTopologies = [
109
- {
110
- id: "codex",
111
- canonicalRelativePath: path.join(codexSkillDirectory, skillFileName),
112
- adapters: [hostSkillAdapters.codex],
113
- },
114
- {
115
- id: "claude-code",
116
- canonicalRelativePath: path.join(claudeSkillDirectory, skillFileName),
117
- adapters: [hostSkillAdapters.claudeDirectory],
118
- },
119
- {
120
- id: "shared",
121
- canonicalRelativePath: path.join(codexSkillDirectory, skillFileName),
122
- adapters: [hostSkillAdapters.codex, hostSkillAdapters.claudeSymlink],
123
- },
124
- ];
125
-
126
- /**
127
- * Install (or upgrade) the bundled skill into a repository: load, plan, apply.
128
- * @param {{repository: string, skillName: string, skillPath: string, packageVersion: string, operations?: object}} options - Repository root, skill identity, bundled asset path, and ddduck version for the lock.
129
- * @returns {{action: "create"|"upgrade"|"no-op", skillSha256: string, canonicalPath: string, lockPath: string}} The installation result.
130
- */
131
- export function installSkill(options) {
132
- const bundle = loadSkillBundle(options);
133
- const plan = planSkillInstall({ ...options, bundle });
134
- return applySkillInstall({ ...options, bundle, plan });
135
- }
136
-
137
- /**
138
- * Read the bundled skill asset and compute its sha256.
139
- * @param {{skillName: string, skillPath: string, operations?: object}} options - Skill name, SKILL.md path, and fs overrides for tests.
140
- * @returns {{name: string, bytes: Buffer, sha256: string}} The loaded bundle.
141
- */
142
- export function loadSkillBundle({ skillName, skillPath, operations = {} }) {
143
- const resolvedOperations = { ...defaultOperations, ...operations };
144
- if (pathState(skillPath, resolvedOperations).type !== "file") {
145
- throw new Error(`Missing bundled skill asset: ${skillPath}`);
146
- }
147
- const bytes = resolvedOperations.readFileSync(skillPath);
148
- return { name: skillName, bytes, sha256: sha256(bytes) };
149
- }
150
-
151
- /**
152
- * Inspect the repository's lock, canonical file, and host adapters and decide
153
- * the action: create, upgrade, or no-op — or throw on conflicting host state,
154
- * an incomplete lock, or a locally modified canonical skill.
155
- * @param {{repository: string, skillName: string, bundle: {sha256: string}, operations?: object}} options - Repository root, skill name, loaded bundle, and fs overrides.
156
- * @returns {{action: string, paths: object, topology: object, adaptersToMaterialize: object[], writeCanonical: boolean}} The install plan for applySkillInstall.
157
- */
158
- export function planSkillInstall({ repository, skillName, bundle, operations = {} }) {
159
- const resolvedOperations = { ...defaultOperations, ...operations };
160
- const root = path.resolve(repository);
161
- const lockPath = path.join(root, lockRelativePath);
162
- const lockState = readLock(lockPath, resolvedOperations);
163
- const topology = selectTopology({ root, lockState, operations: resolvedOperations });
164
- const paths = installationPaths(root, topology);
165
- assertDirectoryParents(paths, topology, resolvedOperations);
166
- const canonical = pathState(paths.canonical, resolvedOperations);
167
- const adapters = inspectHostAdapters(paths, topology, resolvedOperations);
168
- const conflictingAdapter = adapters.find(({ state }) => state === "conflict");
169
-
170
- if (lockState.type === "invalid") throw incompleteLock(paths.lock);
171
- if (conflictingAdapter) throw conflictingHostAdapter(conflictingAdapter, paths);
172
-
173
- if (lockState.type === "absent") {
174
- const canonicalDirectory = pathState(paths.canonicalDirectory, resolvedOperations);
175
- if (
176
- canonicalDirectory.type === "directory" &&
177
- canonical.type === "absent" &&
178
- resolvedOperations.readdirSync(paths.canonicalDirectory).length > 0
179
- ) {
180
- throw conflictingHostState(
181
- `Conflicting canonical skill directory: ${paths.canonicalDirectory}`,
182
- paths.canonicalDirectory,
183
- );
184
- }
185
- if (canonical.type === "absent") {
186
- return createPlan({ paths, adapters, writeCanonical: true });
187
- }
188
- if (canonical.type !== "file" || sha256(resolvedOperations.readFileSync(paths.canonical)) !== bundle.sha256) {
189
- throw conflictingHostState(`Conflicting canonical skill destination: ${paths.canonical}`, paths.canonical);
190
- }
191
- return createPlan({ paths, adapters, writeCanonical: false });
192
- }
193
-
194
- const lock = lockState.value;
195
- if (!isValidLock(lock, skillName, topology)) throw incompleteLock(paths.lock);
196
- if (canonical.type === "absent") return createPlan({ paths, adapters, writeCanonical: true });
197
- if (canonical.type !== "file") throw locallyModifiedCanonical(paths.canonical);
198
- if (sha256(resolvedOperations.readFileSync(paths.canonical)) !== lock.skillSha256) {
199
- throw locallyModifiedCanonical(paths.canonical);
200
- }
201
- if (adapters.some(({ state }) => state !== "valid")) throw incompleteLock(paths.lock);
202
-
203
- return {
204
- action: lock.skillSha256 === bundle.sha256 ? "no-op" : "upgrade",
205
- paths,
206
- topology,
207
- adaptersToMaterialize: [],
208
- writeCanonical: lock.skillSha256 !== bundle.sha256,
209
- };
210
- }
211
-
212
- /**
213
- * Execute an install plan: atomically write the canonical skill, materialize
214
- * host adapters, and write the lock; on failure roll back everything touched
215
- * and report any recovery failures in the thrown error.
216
- * @param {{packageVersion: string, bundle: {name: string, bytes: Buffer, sha256: string}, plan: object, operations?: object}} options - ddduck version for the lock, loaded bundle, plan from planSkillInstall, and fs overrides.
217
- * @returns {{action: string, skillSha256: string, canonicalPath: string, lockPath: string}} The installation result.
218
- */
219
- export function applySkillInstall({ packageVersion, bundle, plan, operations = {} }) {
220
- const resolvedOperations = { ...defaultOperations, ...operations };
221
- if (plan.action === "no-op") return installResult("no-op", bundle, plan);
222
-
223
- const touched = [];
224
- const originalCanonical =
225
- plan.writeCanonical && pathState(plan.paths.canonical, resolvedOperations).type === "file"
226
- ? resolvedOperations.readFileSync(plan.paths.canonical)
227
- : null;
228
- let canonicalWritten = false;
229
- const materializedAdapters = [];
230
-
231
- try {
232
- if (plan.writeCanonical) {
233
- atomicWrite(plan.paths.canonical, bundle.bytes, resolvedOperations, touched);
234
- canonicalWritten = true;
235
- }
236
- for (const adapter of plan.adaptersToMaterialize) {
237
- adapter.materialize({
238
- adapterPath: plan.paths.adapters[adapter.host],
239
- operations: resolvedOperations,
240
- touched,
241
- });
242
- materializedAdapters.push(adapter);
243
- }
244
-
245
- atomicWrite(
246
- plan.paths.lock,
247
- `${JSON.stringify(createLock({ packageVersion, bundle, topology: plan.topology }), null, 2)}\n`,
248
- resolvedOperations,
249
- touched,
250
- );
251
- return installResult(plan.action, bundle, plan);
252
- } catch (error) {
253
- const recoveryFailures = restoreAfterFailure({
254
- plan,
255
- originalCanonical,
256
- canonicalWritten,
257
- materializedAdapters,
258
- operations: resolvedOperations,
259
- touched,
260
- });
261
- const paths = [...new Set(touched)].join(", ") || "none";
262
- const recovery = recoveryFailures.length === 0 ? "" : ` Recovery failures: ${recoveryFailures.join("; ")}.`;
263
- throw new Error(`Skill installation failed after touching: ${paths}. ${error.message}.${recovery}`);
264
- }
265
- }
266
-
267
- function installResult(action, bundle, plan) {
268
- return {
269
- action,
270
- skillSha256: bundle.sha256,
271
- canonicalPath: toPosixPath(plan.topology.canonicalRelativePath),
272
- lockPath: toPosixPath(lockRelativePath),
273
- };
274
- }
275
-
276
- function createPlan({ paths, adapters, writeCanonical }) {
277
- return {
278
- action: "create",
279
- paths,
280
- topology: paths.topology,
281
- adaptersToMaterialize: adapters.filter(({ state }) => state === "absent").map(({ adapter }) => adapter),
282
- writeCanonical,
283
- };
284
- }
285
-
286
- function installationPaths(root, topology) {
287
- const canonical = path.join(root, topology.canonicalRelativePath);
288
- return {
289
- canonical,
290
- canonicalDirectory: path.dirname(canonical),
291
- topology,
292
- adapters: Object.fromEntries(
293
- topology.adapters.map((adapter) => [adapter.host, path.join(root, adapter.relativePath)]),
294
- ),
295
- lock: path.join(root, lockRelativePath),
296
- };
297
- }
298
-
299
- function inspectHostAdapters(paths, topology, operations) {
300
- return topology.adapters.map((adapter) => ({
301
- adapter,
302
- state: adapter.inspect({ adapterPath: paths.adapters[adapter.host], operations }),
303
- }));
304
- }
305
-
306
- function assertDirectoryParents(paths, topology, operations) {
307
- const directories = [
308
- ...topology.adapters.flatMap((adapter) => adapter.parentPaths(paths.adapters[adapter.host])),
309
- path.dirname(paths.lock),
310
- ];
311
- for (const directory of new Set(directories)) {
312
- const state = pathState(directory, operations);
313
- if (!["absent", "directory"].includes(state.type)) {
314
- throw new Error(`Conflicting skill installation path: ${directory}`);
315
- }
316
- }
317
- }
318
-
319
- function readLock(lockPath, operations) {
320
- const state = pathState(lockPath, operations);
321
- if (state.type === "absent") return { type: "absent" };
322
- if (state.type !== "file") return { type: "invalid" };
323
- try {
324
- return { type: "valid", value: JSON.parse(operations.readFileSync(lockPath, "utf8")) };
325
- } catch {
326
- return { type: "invalid" };
327
- }
328
- }
329
-
330
- function pathState(filePath, operations) {
331
- try {
332
- const stat = operations.lstatSync(filePath);
333
- if (stat.isFile()) return { type: "file" };
334
- if (stat.isDirectory()) return { type: "directory" };
335
- if (stat.isSymbolicLink()) return { type: "symlink" };
336
- return { type: "other" };
337
- } catch (error) {
338
- if (error.code === "ENOENT") return { type: "absent" };
339
- throw error;
340
- }
341
- }
342
-
343
- function selectTopology({ root, lockState, operations }) {
344
- if (lockState.type === "valid") {
345
- const topology = installTopologies.find(
346
- (candidate) => toPosixPath(candidate.canonicalRelativePath) === toPosixPath(lockState.value.canonicalPath),
347
- );
348
- return topology ?? installTopologies[0];
349
- }
350
-
351
- const agents = pathState(path.join(root, ".agents"), operations).type;
352
- const claude = pathState(path.join(root, ".claude"), operations).type;
353
- if (agents === "directory" && claude === "directory") return installTopologies.find(({ id }) => id === "shared");
354
- if (claude === "directory") return installTopologies.find(({ id }) => id === "claude-code");
355
- return installTopologies.find(({ id }) => id === "codex");
356
- }
357
-
358
- function isValidLock(lock, skillName, topology) {
359
- return (
360
- lock &&
361
- lock.schemaVersion === lockSchemaVersion &&
362
- lock.skill === skillName &&
363
- typeof lock.ddduckVersion === "string" &&
364
- lock.ddduckVersion.length > 0 &&
365
- toPosixPath(lock.canonicalPath) === toPosixPath(topology.canonicalRelativePath) &&
366
- /^[a-f0-9]{64}$/.test(lock.skillSha256) &&
367
- JSON.stringify(lock.adapters) === JSON.stringify(expectedAdapters(topology))
368
- );
369
- }
370
-
371
- function createLock({ packageVersion, bundle, topology }) {
372
- return {
373
- schemaVersion: lockSchemaVersion,
374
- skill: bundle.name,
375
- ddduckVersion: packageVersion,
376
- canonicalPath: toPosixPath(topology.canonicalRelativePath),
377
- skillSha256: bundle.sha256,
378
- adapters: expectedAdapters(topology),
379
- };
380
- }
381
-
382
- function expectedAdapters(topology) {
383
- return topology.adapters.map((adapter) => adapter.lockEntry());
384
- }
385
-
386
- function conflictingHostAdapter({ adapter }, paths) {
387
- const label = adapter.host === "claude-code" ? "Claude Code" : adapter.host;
388
- return conflictingHostState(
389
- `Conflicting ${label} adapter: ${paths.adapters[adapter.host]}`,
390
- paths.adapters[adapter.host],
391
- );
392
- }
393
-
394
- // Host-state conflicts are environment failures, not input failures: name the
395
- // pre-existing path the user must resolve instead of the usage hint.
396
- function conflictingHostState(message, conflictingPath) {
397
- const error = new Error(message);
398
- error.nextAction = `Move ${conflictingPath} aside or remove it, then re-run ddduck install skill update-ddduck-specs.`;
399
- return error;
400
- }
401
-
402
- function atomicWrite(destination, content, operations, touched) {
403
- const directory = path.dirname(destination);
404
- const temporary = path.join(directory, `.${path.basename(destination)}.${randomUUID()}.tmp`);
405
- touched.push(directory, temporary, destination);
406
- try {
407
- operations.mkdirSync(directory, { recursive: true });
408
- operations.writeFileSync(temporary, content);
409
- operations.renameSync(temporary, destination);
410
- } finally {
411
- if (pathState(temporary, operations).type !== "absent") operations.rmSync(temporary, { force: true });
412
- }
413
- }
414
-
415
- function restoreAfterFailure({ plan, originalCanonical, canonicalWritten, materializedAdapters, operations, touched }) {
416
- const failures = [];
417
- for (const adapter of materializedAdapters.toReversed()) {
418
- try {
419
- const adapterPath = plan.paths.adapters[adapter.host];
420
- touched.push(adapterPath);
421
- if (pathState(adapterPath, operations).type !== "absent") {
422
- operations.rmSync(adapterPath, { recursive: true, force: true });
423
- }
424
- } catch (error) {
425
- failures.push(`remove ${adapter.host} adapter: ${error.message}`);
426
- }
427
- }
428
- if (!canonicalWritten) return failures;
429
-
430
- try {
431
- if (originalCanonical) {
432
- atomicWrite(plan.paths.canonical, originalCanonical, operations, touched);
433
- } else {
434
- touched.push(plan.paths.canonical);
435
- operations.rmSync(plan.paths.canonical, { force: true });
436
- }
437
- } catch (error) {
438
- failures.push(`restore canonical skill: ${error.message}`);
439
- }
440
- return failures;
441
- }
442
-
443
- function incompleteLock(lockPath) {
444
- const error = new Error(`Incomplete or inconsistent skill lock: ${lockPath}`);
445
- // Deleting the lock is safe: the next install rebuilds it from the repository
446
- // state, and any canonical mismatch then surfaces as its own conflict.
447
- error.nextAction = `Delete ${lockPath}, then re-run ddduck install skill update-ddduck-specs to rebuild it.`;
448
- return error;
449
- }
450
-
451
- function locallyModifiedCanonical(canonicalPath) {
452
- const error = new Error(`Locally modified canonical skill: ${canonicalPath}`);
453
- error.nextAction = `Revert or remove ${canonicalPath}, then re-run ddduck install skill update-ddduck-specs.`;
454
- return error;
455
- }
456
-
457
- function sha256(bytes) {
458
- return createHash("sha256").update(bytes).digest("hex");
459
- }
460
-
461
- function toPosixPath(value) {
462
- return String(value).replaceAll("\\", "/");
463
- }