@danieljvdm/dev-kit 0.4.0 → 0.5.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/README.md +99 -30
- package/dev-kit.example.jsonc +1 -0
- package/package.json +1 -1
- package/schema/dev-kit.schema.json +18 -4
- package/skills/dev-kit/SKILL.md +12 -2
- package/src/bin/dev-kit.ts +5 -5
- package/src/catalog.ts +72 -15
- package/src/index.ts +8 -0
- package/src/manifest.ts +16 -2
- package/src/package-skill-source.ts +272 -0
- package/src/project-state.ts +33 -8
- package/src/skill-manager.ts +66 -30
- package/src/skill-selector.ts +43 -0
- package/src/sync.ts +175 -38
package/src/sync.ts
CHANGED
|
@@ -3,7 +3,12 @@ import { Cause, Effect, FileSystem, Path, Schema, Stream } from "effect";
|
|
|
3
3
|
import { ChildProcess } from "effect/unstable/process";
|
|
4
4
|
|
|
5
5
|
import { DevKitManifestSchema, normalizeManifest } from "./manifest.ts";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
loadSkillCatalog,
|
|
8
|
+
resolveSkillSources,
|
|
9
|
+
type CatalogSkill,
|
|
10
|
+
type ResolvedSkillSource,
|
|
11
|
+
} from "./catalog.ts";
|
|
7
12
|
import { printDetail, printStatus, withSpinner } from "./cli-ui.ts";
|
|
8
13
|
import {
|
|
9
14
|
applyEffectSourcePlan,
|
|
@@ -21,11 +26,15 @@ import {
|
|
|
21
26
|
observePath,
|
|
22
27
|
type ObservedPath,
|
|
23
28
|
} from "./path-digest.ts";
|
|
29
|
+
import { resolvePackageSkillSelector } from "./package-skill-source.ts";
|
|
30
|
+
import { parseSkillSelector } from "./skill-selector.ts";
|
|
24
31
|
import {
|
|
25
32
|
AppliedStateSchema,
|
|
26
33
|
DevKitLockSchema,
|
|
27
34
|
type AppliedState,
|
|
28
35
|
type DevKitLock,
|
|
36
|
+
type ManagedInstructionOutput,
|
|
37
|
+
type ManagedOutput,
|
|
29
38
|
type ManagedSkillOutput,
|
|
30
39
|
type OwnershipReceipt,
|
|
31
40
|
} from "./project-state.ts";
|
|
@@ -67,10 +76,17 @@ type DesiredSkillOutput =
|
|
|
67
76
|
readonly linkTarget: string;
|
|
68
77
|
});
|
|
69
78
|
|
|
79
|
+
type DesiredInstructionOutput = ManagedInstructionOutput & {
|
|
80
|
+
readonly destination: string;
|
|
81
|
+
readonly linkTarget: string;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
type DesiredOutput = DesiredSkillOutput | DesiredInstructionOutput;
|
|
85
|
+
|
|
70
86
|
type SkillPlanAction =
|
|
71
87
|
| {
|
|
72
88
|
readonly action: "create" | "update";
|
|
73
|
-
readonly desired:
|
|
89
|
+
readonly desired: DesiredOutput;
|
|
74
90
|
readonly observed: ObservedPath;
|
|
75
91
|
}
|
|
76
92
|
| {
|
|
@@ -81,7 +97,7 @@ type SkillPlanAction =
|
|
|
81
97
|
}
|
|
82
98
|
| {
|
|
83
99
|
readonly action: "unchanged";
|
|
84
|
-
readonly desired:
|
|
100
|
+
readonly desired: DesiredOutput;
|
|
85
101
|
readonly observed: ObservedPath;
|
|
86
102
|
readonly adopted: boolean;
|
|
87
103
|
}
|
|
@@ -263,7 +279,7 @@ const expandSelection = (
|
|
|
263
279
|
for (const name of include) {
|
|
264
280
|
if (skillFamilies[name]) {
|
|
265
281
|
for (const skill of skillFamilies[name]) selected.add(skill);
|
|
266
|
-
} else if (availableSkills.includes(name)) {
|
|
282
|
+
} else if (availableSkills.includes(name) || parseSkillSelector(name)?.type === "package") {
|
|
267
283
|
selected.add(name);
|
|
268
284
|
} else {
|
|
269
285
|
return Effect.fail(new UnknownSkillOrFamilyError({ name, known }));
|
|
@@ -315,7 +331,7 @@ const pathsOverlap = (left: string, right: string): boolean =>
|
|
|
315
331
|
const validateReservedPaths = Effect.fn("validateReservedPaths")(function* (
|
|
316
332
|
projectDir: string,
|
|
317
333
|
reserved: ReadonlyArray<{ readonly label: string; readonly path: string }>,
|
|
318
|
-
outputs: ReadonlyArray<Pick<
|
|
334
|
+
outputs: ReadonlyArray<Pick<ManagedOutput | OwnershipReceipt, "path">>,
|
|
319
335
|
) {
|
|
320
336
|
const outputPaths = new Set<string>();
|
|
321
337
|
for (const output of outputs) {
|
|
@@ -342,19 +358,21 @@ const validateReservedPaths = Effect.fn("validateReservedPaths")(function* (
|
|
|
342
358
|
}
|
|
343
359
|
});
|
|
344
360
|
|
|
345
|
-
const outputIdentity = (output:
|
|
361
|
+
const outputIdentity = (output: ManagedOutput) =>
|
|
346
362
|
JSON.stringify({
|
|
347
363
|
resourceId: output.resourceId,
|
|
348
364
|
path: output.path,
|
|
349
365
|
mode: output.mode,
|
|
350
366
|
kind: output.kind,
|
|
351
367
|
digest: output.digest,
|
|
352
|
-
|
|
368
|
+
...("skill" in output
|
|
369
|
+
? { skill: output.skill, target: output.target, catalog: output.catalog }
|
|
370
|
+
: { sourcePath: output.sourcePath }),
|
|
353
371
|
});
|
|
354
372
|
|
|
355
373
|
const validateInventory = Effect.fn("validateManagedInventory")(function* (
|
|
356
374
|
projectDir: string,
|
|
357
|
-
outputs: ReadonlyArray<
|
|
375
|
+
outputs: ReadonlyArray<ManagedOutput | OwnershipReceipt>,
|
|
358
376
|
label: string,
|
|
359
377
|
) {
|
|
360
378
|
const ids = new Set<string>();
|
|
@@ -384,7 +402,7 @@ const validateInventory = Effect.fn("validateManagedInventory")(function* (
|
|
|
384
402
|
|
|
385
403
|
const validateCrossInventoryPaths = Effect.fn("validateCrossInventoryPaths")(function* (
|
|
386
404
|
projectDir: string,
|
|
387
|
-
outputs: ReadonlyArray<Pick<
|
|
405
|
+
outputs: ReadonlyArray<Pick<ManagedOutput | OwnershipReceipt, "path">>,
|
|
388
406
|
) {
|
|
389
407
|
const uniquePaths = new Set<string>();
|
|
390
408
|
for (const output of outputs) {
|
|
@@ -406,16 +424,49 @@ const validateCrossInventoryPaths = Effect.fn("validateCrossInventoryPaths")(fun
|
|
|
406
424
|
const buildDesiredOutputs = Effect.fn("buildDesiredSkillOutputs")(function* (
|
|
407
425
|
projectDir: string,
|
|
408
426
|
sourceBySkill: ReadonlyMap<string, ResolvedSkillSource>,
|
|
409
|
-
skills: ReadonlyArray<
|
|
427
|
+
skills: ReadonlyArray<CatalogSkill>,
|
|
428
|
+
setup: ReturnType<typeof normalizeManifest>["setup"],
|
|
410
429
|
targets: ReturnType<typeof normalizeManifest>["targets"],
|
|
411
430
|
) {
|
|
412
431
|
const path = yield* Path.Path;
|
|
413
|
-
const outputs: Array<
|
|
432
|
+
const outputs: Array<DesiredOutput> = [];
|
|
433
|
+
if (setup.claudeInstructions.enabled) {
|
|
434
|
+
const source = yield* resolveManagedPath(projectDir, "AGENTS.md");
|
|
435
|
+
const sourceObservation = yield* observePath(source.absolute);
|
|
436
|
+
if (sourceObservation.kind !== "file") {
|
|
437
|
+
return yield* new InvalidProjectStateError({
|
|
438
|
+
message: "Claude instructions source is not a regular file: AGENTS.md",
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
const managed = yield* resolveManagedPath(projectDir, "CLAUDE.md");
|
|
442
|
+
const linkTarget = path.relative(path.dirname(managed.absolute), source.absolute);
|
|
443
|
+
outputs.push({
|
|
444
|
+
resourceId: "setup:claude-instructions",
|
|
445
|
+
path: managed.relative,
|
|
446
|
+
sourcePath: source.relative,
|
|
447
|
+
mode: "symlink",
|
|
448
|
+
kind: "symlink",
|
|
449
|
+
digest: yield* digestSymlinkTarget(linkTarget),
|
|
450
|
+
destination: managed.absolute,
|
|
451
|
+
linkTarget,
|
|
452
|
+
});
|
|
453
|
+
}
|
|
414
454
|
const agentsTarget = targets.agents;
|
|
455
|
+
const duplicateOutput = skills.find((skill, index) =>
|
|
456
|
+
skills.findIndex((candidate) => candidate.name === skill.name) !== index
|
|
457
|
+
);
|
|
458
|
+
if (duplicateOutput !== undefined) {
|
|
459
|
+
const selectors = skills
|
|
460
|
+
.filter((skill) => skill.name === duplicateOutput.name)
|
|
461
|
+
.map((skill) => skill.selector);
|
|
462
|
+
return yield* new InvalidProjectStateError({
|
|
463
|
+
message: `selected skills would both install as ${duplicateOutput.name}: ${selectors.join(", ")}`,
|
|
464
|
+
});
|
|
465
|
+
}
|
|
415
466
|
for (const skill of skills) {
|
|
416
|
-
const resolvedSource = sourceBySkill.get(skill);
|
|
467
|
+
const resolvedSource = sourceBySkill.get(skill.selector);
|
|
417
468
|
if (resolvedSource === undefined) {
|
|
418
|
-
return yield* new InvalidProjectStateError({ message: `skill source is unavailable: ${skill}` });
|
|
469
|
+
return yield* new InvalidProjectStateError({ message: `skill source is unavailable: ${skill.selector}` });
|
|
419
470
|
}
|
|
420
471
|
const source = resolvedSource.path;
|
|
421
472
|
const sourceObservation = yield* observePath(source);
|
|
@@ -425,12 +476,12 @@ const buildDesiredOutputs = Effect.fn("buildDesiredSkillOutputs")(function* (
|
|
|
425
476
|
for (const targetName of ["agents", "claude", "opencode"] as const) {
|
|
426
477
|
const target = targets[targetName];
|
|
427
478
|
if (!target.enabled) continue;
|
|
428
|
-
const managed = yield* resolveManagedPath(projectDir, path.join(target.path, skill));
|
|
479
|
+
const managed = yield* resolveManagedPath(projectDir, path.join(target.path, skill.name));
|
|
429
480
|
if (target.mode === "copy") {
|
|
430
481
|
outputs.push({
|
|
431
|
-
resourceId: `skill:${skill}@${targetName}`,
|
|
482
|
+
resourceId: `skill:${skill.selector}@${targetName}`,
|
|
432
483
|
path: managed.relative,
|
|
433
|
-
skill,
|
|
484
|
+
skill: skill.name,
|
|
434
485
|
target: targetName,
|
|
435
486
|
mode: "copy",
|
|
436
487
|
kind: "directory",
|
|
@@ -443,14 +494,14 @@ const buildDesiredOutputs = Effect.fn("buildDesiredSkillOutputs")(function* (
|
|
|
443
494
|
}
|
|
444
495
|
const linkSource =
|
|
445
496
|
targetName === "agents" || !agentsTarget.enabled
|
|
446
|
-
? source
|
|
447
|
-
: (yield* resolveManagedPath(projectDir, path.join(agentsTarget.path, skill))).absolute;
|
|
497
|
+
? resolvedSource.linkPath ?? source
|
|
498
|
+
: (yield* resolveManagedPath(projectDir, path.join(agentsTarget.path, skill.name))).absolute;
|
|
448
499
|
const linkTarget = path.relative(path.dirname(managed.absolute), linkSource);
|
|
449
500
|
const linkDigest = yield* digestSymlinkTarget(linkTarget);
|
|
450
501
|
outputs.push({
|
|
451
|
-
resourceId: `skill:${skill}@${targetName}`,
|
|
502
|
+
resourceId: `skill:${skill.selector}@${targetName}`,
|
|
452
503
|
path: managed.relative,
|
|
453
|
-
skill,
|
|
504
|
+
skill: skill.name,
|
|
454
505
|
target: targetName,
|
|
455
506
|
mode: "symlink",
|
|
456
507
|
kind: "symlink",
|
|
@@ -471,7 +522,7 @@ const canonicalState = (state: AppliedState): string => `${JSON.stringify(state,
|
|
|
471
522
|
|
|
472
523
|
const planDesiredOutputs = Effect.fn("planDesiredSkillOutputs")(function* (
|
|
473
524
|
projectDir: string,
|
|
474
|
-
desired: ReadonlyArray<
|
|
525
|
+
desired: ReadonlyArray<DesiredOutput>,
|
|
475
526
|
currentLock: DevKitLock | undefined,
|
|
476
527
|
currentState: AppliedState | undefined,
|
|
477
528
|
nextLock: DevKitLock,
|
|
@@ -587,8 +638,8 @@ export const planProjectSkills = Effect.fn("planProjectSkills")(function* (optio
|
|
|
587
638
|
typescriptPackage: manifest.setup.effectTsgo.typescriptPackage,
|
|
588
639
|
})
|
|
589
640
|
: undefined;
|
|
590
|
-
const catalog = yield* loadSkillCatalog(packageRoot);
|
|
591
|
-
const availableSkills = catalog.skills.map((skill) => skill.
|
|
641
|
+
const catalog = yield* loadSkillCatalog(packageRoot, projectDir);
|
|
642
|
+
const availableSkills = catalog.skills.map((skill) => skill.selector);
|
|
592
643
|
const skillFamilies = { ...SKILL_FAMILIES, ...catalog.families };
|
|
593
644
|
for (const [family, familySkills] of Object.entries(skillFamilies)) {
|
|
594
645
|
if (availableSkills.includes(family)) {
|
|
@@ -599,12 +650,35 @@ export const planProjectSkills = Effect.fn("planProjectSkills")(function* (optio
|
|
|
599
650
|
return yield* new InvalidSkillCatalogError({ family, message: `family references missing skills: ${missing.join(", ")}` });
|
|
600
651
|
}
|
|
601
652
|
}
|
|
602
|
-
const
|
|
653
|
+
const selectedSelectors = yield* expandSelection(manifest.include, manifest.exclude, availableSkills, skillFamilies);
|
|
654
|
+
const catalogBySelector = new Map(catalog.skills.map((skill) => [skill.selector, skill]));
|
|
603
655
|
const sourceBySkill = yield* withSpinner(
|
|
604
|
-
"
|
|
605
|
-
resolveSkillSources(
|
|
656
|
+
"Resolving selected skills",
|
|
657
|
+
resolveSkillSources(
|
|
658
|
+
packageRoot,
|
|
659
|
+
projectDir,
|
|
660
|
+
catalog,
|
|
661
|
+
selectedSelectors,
|
|
662
|
+
options.dryRun !== true,
|
|
663
|
+
),
|
|
664
|
+
);
|
|
665
|
+
const selectedSkills: Array<CatalogSkill> = [];
|
|
666
|
+
for (const selector of selectedSelectors) {
|
|
667
|
+
const catalogSkill = catalogBySelector.get(selector);
|
|
668
|
+
if (catalogSkill === undefined) {
|
|
669
|
+
return yield* new InvalidProjectStateError({
|
|
670
|
+
message: `selected skill is unavailable: ${selector}`,
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
selectedSkills.push(catalogSkill);
|
|
674
|
+
}
|
|
675
|
+
const desired = yield* buildDesiredOutputs(
|
|
676
|
+
projectDir,
|
|
677
|
+
sourceBySkill,
|
|
678
|
+
selectedSkills,
|
|
679
|
+
manifest.setup,
|
|
680
|
+
manifest.targets,
|
|
606
681
|
);
|
|
607
|
-
const desired = yield* buildDesiredOutputs(projectDir, sourceBySkill, selectedSkills, manifest.targets);
|
|
608
682
|
const nextLock: DevKitLock = {
|
|
609
683
|
version: 1,
|
|
610
684
|
toolVersion: DEV_KIT_VERSION,
|
|
@@ -631,16 +705,27 @@ export const planProjectSkills = Effect.fn("planProjectSkills")(function* (optio
|
|
|
631
705
|
},
|
|
632
706
|
}),
|
|
633
707
|
},
|
|
634
|
-
outputs: desired.map((
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
708
|
+
outputs: desired.map((output): ManagedOutput =>
|
|
709
|
+
"skill" in output
|
|
710
|
+
? {
|
|
711
|
+
resourceId: output.resourceId,
|
|
712
|
+
path: output.path,
|
|
713
|
+
skill: output.skill,
|
|
714
|
+
target: output.target,
|
|
715
|
+
mode: output.mode,
|
|
716
|
+
kind: output.kind,
|
|
717
|
+
digest: output.digest,
|
|
718
|
+
...(output.catalog ? { catalog: output.catalog } : {}),
|
|
719
|
+
}
|
|
720
|
+
: {
|
|
721
|
+
resourceId: output.resourceId,
|
|
722
|
+
path: output.path,
|
|
723
|
+
sourcePath: output.sourcePath,
|
|
724
|
+
mode: output.mode,
|
|
725
|
+
kind: output.kind,
|
|
726
|
+
digest: output.digest,
|
|
727
|
+
},
|
|
728
|
+
),
|
|
644
729
|
};
|
|
645
730
|
const reservedPaths = [
|
|
646
731
|
{ label: "manifest", path: manifestManaged.relative },
|
|
@@ -692,7 +777,10 @@ const formatAction = (action: SkillPlanAction): string => {
|
|
|
692
777
|
const verb = action.desired.mode === "copy" ? "copy" : "link";
|
|
693
778
|
const adoption = action.action === "unchanged" && action.adopted ? " (adopt)" : "";
|
|
694
779
|
const marker = action.action === "create" ? "+" : action.action === "update" ? "~" : "=";
|
|
695
|
-
|
|
780
|
+
const source = "skill" in action.desired
|
|
781
|
+
? action.desired.skill
|
|
782
|
+
: action.desired.sourcePath;
|
|
783
|
+
return `${marker} ${verb} ${source} → ${action.desired.path}${adoption}`;
|
|
696
784
|
};
|
|
697
785
|
|
|
698
786
|
const operationalChangeCount = (plan: SkillPlan): number =>
|
|
@@ -734,6 +822,47 @@ const observationsEqual = (left: ObservedPath, right: ObservedPath): boolean =>
|
|
|
734
822
|
left.kind === right.kind &&
|
|
735
823
|
(left.kind === "missing" || (right.kind !== "missing" && left.digest === right.digest));
|
|
736
824
|
|
|
825
|
+
const findNestedSymbolicLink = Effect.fn("findNestedSkillSymbolicLink")(function* (
|
|
826
|
+
root: string,
|
|
827
|
+
) {
|
|
828
|
+
const fs = yield* FileSystem.FileSystem;
|
|
829
|
+
const path = yield* Path.Path;
|
|
830
|
+
const pending = [root];
|
|
831
|
+
while (pending.length > 0) {
|
|
832
|
+
const current = pending.pop();
|
|
833
|
+
if (current === undefined) continue;
|
|
834
|
+
if ((yield* observeSymbolicLink(current)).kind === "symlink") return current;
|
|
835
|
+
const info = yield* fs.stat(current);
|
|
836
|
+
if (info.type !== "Directory") continue;
|
|
837
|
+
for (const entry of yield* fs.readDirectory(current)) {
|
|
838
|
+
pending.push(path.join(current, entry));
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
return undefined;
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
const verifyPackageSkillSources = Effect.fn("verifyPackageSkillSources")(function* (
|
|
845
|
+
plan: SkillPlan,
|
|
846
|
+
) {
|
|
847
|
+
const verified = new Set<string>();
|
|
848
|
+
for (const action of plan.actions) {
|
|
849
|
+
if (action.action === "remove" || action.action === "conflict") continue;
|
|
850
|
+
if (!("skill" in action.desired)) continue;
|
|
851
|
+
const catalog = action.desired.catalog;
|
|
852
|
+
if (catalog === undefined || !("package" in catalog)) continue;
|
|
853
|
+
const selector = `${catalog.package}#${catalog.skill}`;
|
|
854
|
+
const key = `${selector}\0${catalog.version}\0${catalog.digest}`;
|
|
855
|
+
if (verified.has(key)) continue;
|
|
856
|
+
const resolved = yield* resolvePackageSkillSelector(plan.projectDir, selector);
|
|
857
|
+
const observation = yield* observePath(resolved.path);
|
|
858
|
+
if (resolved.path !== action.desired.source || resolved.version !== catalog.version ||
|
|
859
|
+
observation.kind !== "directory" || observation.digest !== catalog.digest) {
|
|
860
|
+
return yield* new ApplyRaceError({ path: action.desired.source });
|
|
861
|
+
}
|
|
862
|
+
verified.add(key);
|
|
863
|
+
}
|
|
864
|
+
});
|
|
865
|
+
|
|
737
866
|
const applyPlannedSkillChanges = Effect.fn("applyPlannedSkillChanges")(function* (plan: SkillPlan) {
|
|
738
867
|
const conflicts = plan.actions.filter((action) => action.action === "conflict");
|
|
739
868
|
if (conflicts.length > 0) {
|
|
@@ -770,6 +899,12 @@ const applyPlannedSkillChanges = Effect.fn("applyPlannedSkillChanges")(function*
|
|
|
770
899
|
yield* fs.makeDirectory(path.dirname(staged), { recursive: true });
|
|
771
900
|
if (action.desired.mode === "copy") {
|
|
772
901
|
yield* fs.copy(action.desired.source, staged, { overwrite: true });
|
|
902
|
+
const symbolicLink = yield* findNestedSymbolicLink(staged);
|
|
903
|
+
if (symbolicLink !== undefined) {
|
|
904
|
+
return yield* new InvalidProjectStateError({
|
|
905
|
+
message: `staged skill contains a symlink: ${action.desired.path}`,
|
|
906
|
+
});
|
|
907
|
+
}
|
|
773
908
|
} else {
|
|
774
909
|
yield* fs.symlink(action.desired.linkTarget, staged);
|
|
775
910
|
}
|
|
@@ -780,6 +915,8 @@ const applyPlannedSkillChanges = Effect.fn("applyPlannedSkillChanges")(function*
|
|
|
780
915
|
stagedByResource.set(action.desired.resourceId, staged);
|
|
781
916
|
}
|
|
782
917
|
|
|
918
|
+
yield* verifyPackageSkillSources(plan);
|
|
919
|
+
|
|
783
920
|
const stagedLock = path.join(tempDir, "next-lock.json");
|
|
784
921
|
const stagedState = path.join(tempDir, "next-state.json");
|
|
785
922
|
yield* fs.writeFileString(stagedLock, canonicalLock(plan.nextLock));
|