@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.
- package/README.md +138 -33
- package/dev-kit.example.jsonc +1 -0
- package/package.json +19 -1
- package/schema/dev-kit.schema.json +18 -4
- package/skills/dev-kit/SKILL.md +34 -11
- 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/oxfmt.js +18 -0
- package/src/oxfmt.ts +22 -0
- package/src/oxlint-plugin-effect.d.ts +17 -0
- package/src/oxlint-plugin-effect.js +166 -0
- package/src/oxlint.js +6 -0
- package/src/oxlint.ts +11 -4
- 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/skill-manager.ts
CHANGED
|
@@ -188,8 +188,14 @@ const selectedNames = (
|
|
|
188
188
|
return selected;
|
|
189
189
|
};
|
|
190
190
|
|
|
191
|
-
const
|
|
192
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
298
|
-
(!query || `${skill.
|
|
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.
|
|
317
|
+
const marker = selected.has(skill.selector) ? "✓" : " ";
|
|
307
318
|
const origin = skill.bundled ? "built in" : skill.source;
|
|
308
|
-
const provenance = skill.
|
|
309
|
-
|
|
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}
|
|
328
|
+
yield* printLine(`${selected.size} selected · ${catalog.skills.length} available`);
|
|
313
329
|
});
|
|
314
330
|
|
|
315
|
-
export const showSkill = Effect.fn("showCatalogSkill")(function* (
|
|
316
|
-
|
|
317
|
-
|
|
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.
|
|
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.
|
|
381
|
+
const available = catalog.skills.filter((skill) => !selected.has(skill.selector));
|
|
355
382
|
if (available.length === 0) {
|
|
356
|
-
yield* printStatus("success", "All
|
|
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.
|
|
363
|
-
value: skill.
|
|
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:
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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 {
|
|
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));
|