@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/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 { loadSkillCatalog, resolveSkillSources, type ResolvedSkillSource } from "./catalog.ts";
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: DesiredSkillOutput;
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: DesiredSkillOutput;
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<ManagedSkillOutput | OwnershipReceipt, "path">>,
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: Pick<ManagedSkillOutput, "resourceId" | "path" | "mode" | "kind" | "digest" | "catalog">) =>
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
- catalog: output.catalog,
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<ManagedSkillOutput | OwnershipReceipt>,
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<ManagedSkillOutput | OwnershipReceipt, "path">>,
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<string>,
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<DesiredSkillOutput> = [];
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<DesiredSkillOutput>,
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.name);
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 selectedSkills = yield* expandSelection(manifest.include, manifest.exclude, availableSkills, skillFamilies);
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
- "Fetching selected skills",
605
- resolveSkillSources(packageRoot, projectDir, selectedSkills, options.dryRun !== true),
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(({ resourceId, path: outputPath, skill, target, mode, kind, digest, catalog }) => ({
635
- resourceId,
636
- path: outputPath,
637
- skill,
638
- target,
639
- mode,
640
- kind,
641
- digest,
642
- ...(catalog ? { catalog } : {}),
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
- return `${marker} ${verb} ${action.desired.skill} ${action.desired.path}${adoption}`;
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));