@danieljvdm/dev-kit 0.3.3 → 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.
@@ -188,8 +188,14 @@ const selectedNames = (
188
188
  return selected;
189
189
  };
190
190
 
191
- const summary = (description: string, fallback: string): string => {
192
- const text = description || fallback;
191
+ const displayValue = (value: string): string =>
192
+ [...value].map((character) => {
193
+ const code = character.charCodeAt(0);
194
+ return code <= 31 || (code >= 127 && code <= 159) ? " " : character;
195
+ }).join("").replace(/\s+/g, " ").trim();
196
+
197
+ const summary = (description: string, defaultDescription: string): string => {
198
+ const text = displayValue(description || defaultDescription);
193
199
  const firstSentence = text.match(/^.*?[.!?](?:\s|$)/)?.[0]?.trim() ?? text;
194
200
  return firstSentence.length > 96 ? `${firstSentence.slice(0, 93).trimEnd()}…` : firstSentence;
195
201
  };
@@ -219,15 +225,16 @@ export const addSkills = Effect.fn("addManagedSkills")(function* (
219
225
  options: ManagerOptions,
220
226
  ) {
221
227
  const current = yield* readManifest(options, true);
222
- const catalog = yield* loadSkillCatalog(yield* packageRoot());
223
- const known = new Set([...catalog.skills.map((skill) => skill.name), ...Object.keys(catalog.families)]);
228
+ const catalog = yield* loadSkillCatalog(yield* packageRoot(), current.projectDir);
229
+ const known = new Set([...catalog.skills.map((skill) => skill.selector), ...Object.keys(catalog.families)]);
224
230
  const unknown = names.filter((name) => !known.has(name));
225
231
  if (unknown.length > 0) {
226
232
  return yield* new SkillManagerError({
227
233
  message: `unknown skill${unknown.length === 1 ? "" : "s"}: ${unknown.join(", ")}. Try \`dev-kit search ${unknown[0]}\`.`,
228
234
  });
229
235
  }
230
- for (const source of catalog.lock?.sources ?? []) {
236
+ const sourceFamilies = catalog.lock?.sources ?? [];
237
+ for (const source of sourceFamilies) {
231
238
  if (!names.includes(source.id)) continue;
232
239
  yield* printStatus(
233
240
  "info",
@@ -255,7 +262,7 @@ export const removeSkills = Effect.fn("removeManagedSkills")(function* (
255
262
  options: ManagerOptions,
256
263
  ) {
257
264
  const current = yield* readManifest(options);
258
- const catalog = yield* loadSkillCatalog(yield* packageRoot());
265
+ const catalog = yield* loadSkillCatalog(yield* packageRoot(), current.projectDir);
259
266
  const before = selectedNames(
260
267
  current.manifest.include,
261
268
  current.manifest.exclude ?? [],
@@ -287,43 +294,63 @@ export const listSkills = Effect.fn("listManagedSkills")(function* (
287
294
  ) {
288
295
  const fs = yield* FileSystem.FileSystem;
289
296
  const paths = yield* resolvePaths(options);
290
- const catalog = yield* loadSkillCatalog(yield* packageRoot());
297
+ const catalog = yield* loadSkillCatalog(yield* packageRoot(), paths.projectDir);
291
298
  const manifest = (yield* fs.exists(paths.manifestPath))
292
299
  ? (yield* readManifest(options)).manifest
293
300
  : { include: [], exclude: [] };
294
301
  const selected = selectedNames(manifest.include, manifest.exclude ?? [], catalog.families);
295
302
  const query = options.query?.toLowerCase();
296
303
  const visible = catalog.skills.filter((skill) =>
297
- (options.all || selected.has(skill.name)) &&
298
- (!query || `${skill.name} ${skill.description} ${skill.source}`.toLowerCase().includes(query)),
304
+ (options.all || selected.has(skill.selector)) &&
305
+ (!query || `${skill.selector} ${skill.description} ${skill.source}`.toLowerCase().includes(query)),
306
+ );
307
+ const catalogSelectors = new Set(catalog.skills.map((skill) => skill.selector));
308
+ const unavailable = [...selected].filter((selector) =>
309
+ !catalogSelectors.has(selector) && (!query || selector.toLowerCase().includes(query))
299
310
  );
300
- if (visible.length === 0) {
311
+ if (visible.length === 0 && unavailable.length === 0) {
301
312
  yield* printStatus("info", query ? "No matching skills" : "No skills selected");
302
313
  if (!query && !options.all) yield* printDetail("Browse with: dev-kit list --all");
303
314
  return;
304
315
  }
305
316
  for (const skill of visible) {
306
- const marker = selected.has(skill.name) ? "✓" : " ";
317
+ const marker = selected.has(skill.selector) ? "✓" : " ";
307
318
  const origin = skill.bundled ? "built in" : skill.source;
308
- const provenance = skill.bundled ? "" : ` [${skill.source}]`;
309
- yield* printLine(`${marker} ${skill.name}${provenance} ${summary(skill.description, origin)}`);
319
+ const provenance = skill.package
320
+ ? ` [installed ${displayValue(skill.package.version)}]`
321
+ : skill.bundled ? "" : ` [${skill.source}]`;
322
+ yield* printLine(`${marker} ${skill.selector}${provenance} ${summary(skill.description, origin)}`);
323
+ }
324
+ for (const selector of unavailable) {
325
+ yield* printLine(`! ${selector} [unavailable] install or repair the selected direct dependency`);
310
326
  }
311
327
  yield* printLine();
312
- yield* printLine(`${selected.size} selected · ${catalog.skills.length} approved`);
328
+ yield* printLine(`${selected.size} selected · ${catalog.skills.length} available`);
313
329
  });
314
330
 
315
- export const showSkill = Effect.fn("showCatalogSkill")(function* (name: string) {
316
- const catalog = yield* loadSkillCatalog(yield* packageRoot());
317
- const skill = catalog.skills.find((candidate) => candidate.name === name);
331
+ export const showSkill = Effect.fn("showCatalogSkill")(function* (
332
+ name: string,
333
+ options: ManagerOptions,
334
+ ) {
335
+ const paths = yield* resolvePaths(options);
336
+ const catalog = yield* loadSkillCatalog(yield* packageRoot(), paths.projectDir);
337
+ const skill = catalog.skills.find((candidate) => candidate.selector === name);
318
338
  if (!skill) return yield* new SkillManagerError({ message: `unknown skill: ${name}` });
319
- yield* printLine(skill.name);
320
- if (skill.description) yield* printLine(skill.description);
339
+ yield* printLine(skill.selector);
340
+ if (skill.description) yield* printLine(displayValue(skill.description));
341
+ if (skill.package) {
342
+ yield* printLine(`Source: installed package`);
343
+ yield* printLine(`Package: ${skill.package.name}`);
344
+ yield* printLine(`Version: ${displayValue(skill.package.version)}`);
345
+ return;
346
+ }
321
347
  yield* printLine(`Source: ${skill.bundled ? "dev-kit (built in)" : skill.source}`);
322
348
  if (!skill.bundled) {
323
349
  const source = catalog.lock?.sources.find((candidate) => candidate.id === skill.source);
324
350
  if (source) {
325
351
  yield* printLine(`Repository: ${source.repository}`);
326
352
  yield* printLine(`Approved commit: ${source.resolved}`);
353
+ return;
327
354
  }
328
355
  }
329
356
  });
@@ -345,22 +372,22 @@ export const chooseSkillsToAdd = Effect.fn("chooseSkillsToAdd")(function* (
345
372
  return yield* new SkillManagerError({ message: "pass one or more skill names, or run this command in a terminal" });
346
373
  }
347
374
  const current = yield* readManifest(options, true);
348
- const catalog = yield* loadSkillCatalog(yield* packageRoot());
375
+ const catalog = yield* loadSkillCatalog(yield* packageRoot(), current.projectDir);
349
376
  const selected = selectedNames(
350
377
  current.manifest.include,
351
378
  current.manifest.exclude ?? [],
352
379
  catalog.families,
353
380
  );
354
- const available = catalog.skills.filter((skill) => !selected.has(skill.name));
381
+ const available = catalog.skills.filter((skill) => !selected.has(skill.selector));
355
382
  if (available.length === 0) {
356
- yield* printStatus("success", "All approved skills are selected");
383
+ yield* printStatus("success", "All available skills are selected");
357
384
  return;
358
385
  }
359
386
  const names = yield* Prompt.multiSelect({
360
387
  message: "Choose skills to add",
361
388
  choices: available.map((skill) => ({
362
- title: skill.name,
363
- value: skill.name,
389
+ title: skill.selector,
390
+ value: skill.selector,
364
391
  description: summary(skill.description, skill.source),
365
392
  })),
366
393
  min: 1,
@@ -375,7 +402,7 @@ export const chooseSkillsToRemove = Effect.fn("chooseSkillsToRemove")(function*
375
402
  return yield* new SkillManagerError({ message: "pass one or more skill names, or run this command in a terminal" });
376
403
  }
377
404
  const current = yield* readManifest(options);
378
- const catalog = yield* loadSkillCatalog(yield* packageRoot());
405
+ const catalog = yield* loadSkillCatalog(yield* packageRoot(), current.projectDir);
379
406
  const selected = selectedNames(
380
407
  current.manifest.include,
381
408
  current.manifest.exclude ?? [],
@@ -387,11 +414,20 @@ export const chooseSkillsToRemove = Effect.fn("chooseSkillsToRemove")(function*
387
414
  }
388
415
  const names = yield* Prompt.multiSelect({
389
416
  message: "Choose skills to remove",
390
- choices: catalog.skills.filter((skill) => selected.has(skill.name)).map((skill) => ({
391
- title: skill.name,
392
- value: skill.name,
393
- description: summary(skill.description, skill.source),
394
- })),
417
+ choices: [
418
+ ...catalog.skills.filter((skill) => selected.has(skill.selector)).map((skill) => ({
419
+ title: skill.selector,
420
+ value: skill.selector,
421
+ description: summary(skill.description, skill.source),
422
+ })),
423
+ ...[...selected]
424
+ .filter((selector) => !catalog.skills.some((skill) => skill.selector === selector))
425
+ .map((selector) => ({
426
+ title: selector,
427
+ value: selector,
428
+ description: "Selected but currently unavailable",
429
+ })),
430
+ ],
395
431
  min: 1,
396
432
  });
397
433
  yield* removeSkills(names, options);
@@ -0,0 +1,43 @@
1
+ import { isTypeScriptPackageName } from "./typescript-package-name.ts";
2
+
3
+ /** An immediate Agent Skill directory name. */
4
+ export const SKILL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
5
+
6
+ const PACKAGE_SKILL_SELECTOR_PATTERN = /^(?<package>[^#]+)#(?<skill>[^#]+)$/;
7
+
8
+ type StaticSkillSelector = {
9
+ readonly type: "static";
10
+ readonly name: string;
11
+ };
12
+
13
+ type PackageSkillSelector = {
14
+ readonly type: "package";
15
+ readonly package: string;
16
+ readonly skill: string;
17
+ };
18
+
19
+ type SkillSelector = StaticSkillSelector | PackageSkillSelector;
20
+
21
+ /**
22
+ * The JSON-schema-compatible pattern for static selectors and exact package
23
+ * selectors. Package names intentionally use the same rules as package-backed
24
+ * skill sources.
25
+ */
26
+ export const SKILL_SELECTOR_PATTERN =
27
+ /^(?:[a-z0-9]+(?:-[a-z0-9]+)*|(?:[a-z0-9][a-z0-9._-]*|@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*)#[a-z0-9]+(?:-[a-z0-9]+)*)$/;
28
+
29
+ export const isSkillName = (value: string): boolean => SKILL_NAME_PATTERN.test(value);
30
+
31
+ /** Parse an exact, canonical manifest skill selector. */
32
+ export const parseSkillSelector = (value: string): SkillSelector | undefined => {
33
+ if (isSkillName(value)) return { type: "static", name: value };
34
+
35
+ const match = PACKAGE_SKILL_SELECTOR_PATTERN.exec(value);
36
+ const packageName = match?.groups?.package;
37
+ const skill = match?.groups?.skill;
38
+ if (packageName === undefined || skill === undefined ||
39
+ !isTypeScriptPackageName(packageName) || !isSkillName(skill)) {
40
+ return undefined;
41
+ }
42
+ return { type: "package", package: packageName, skill };
43
+ };
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));