@danieljvdm/dev-kit 0.2.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.
Files changed (47) hide show
  1. package/README.md +290 -0
  2. package/bin/dev-kit.mjs +3 -0
  3. package/dev-kit.example.jsonc +13 -0
  4. package/package.json +69 -0
  5. package/schema/dev-kit.schema.json +128 -0
  6. package/schema/skill-sources.schema.json +83 -0
  7. package/skill-sources.jsonc +55 -0
  8. package/skill-sources.lock.json +136 -0
  9. package/skills/dev-kit/SKILL.md +145 -0
  10. package/skills/dev-kit/agents/openai.yaml +4 -0
  11. package/skills/effect-ts/SKILL.md +242 -0
  12. package/skills/effect-ts/UPSTREAM.md +28 -0
  13. package/skills/effect-ts/agents/openai.yaml +5 -0
  14. package/skills/effect-ts/references/audit-services.md +144 -0
  15. package/skills/effect-ts/references/features.md +525 -0
  16. package/skills/effect-ts/references/guide-cli.md +106 -0
  17. package/skills/effect-ts/references/guide-effect.md +453 -0
  18. package/skills/effect-ts/references/guide-error-handling.md +574 -0
  19. package/skills/effect-ts/references/guide-http-boundaries.md +55 -0
  20. package/skills/effect-ts/references/guide-layers.md +1017 -0
  21. package/skills/effect-ts/references/guide-observability.md +771 -0
  22. package/skills/effect-ts/references/guide-retries.md +446 -0
  23. package/skills/effect-ts/references/guide-schedule.md +357 -0
  24. package/skills/effect-ts/references/guide-schema.md +671 -0
  25. package/skills/effect-ts/references/guide-sql.md +539 -0
  26. package/skills/effect-ts/references/guide-testing.md +534 -0
  27. package/skills/effect-ts/references/guide-type-safety-and-boundaries.md +131 -0
  28. package/skills/effect-ts/references/version-and-source.md +87 -0
  29. package/src/bin/dev-kit.ts +372 -0
  30. package/src/catalog-manager.ts +345 -0
  31. package/src/catalog.ts +246 -0
  32. package/src/cli-ui.ts +110 -0
  33. package/src/effect-source.ts +325 -0
  34. package/src/effect-tsgo.ts +256 -0
  35. package/src/gitignore.ts +212 -0
  36. package/src/index.ts +98 -0
  37. package/src/manifest.ts +133 -0
  38. package/src/node-symbolic-link.ts +31 -0
  39. package/src/path-digest.ts +140 -0
  40. package/src/project-process-lock.ts +76 -0
  41. package/src/project-state.ts +67 -0
  42. package/src/skill-manager.ts +326 -0
  43. package/src/source-manifest.ts +51 -0
  44. package/src/sync.ts +900 -0
  45. package/src/tool-metadata.ts +3 -0
  46. package/src/typescript-package-name.ts +5 -0
  47. package/src/vendor.ts +848 -0
package/src/sync.ts ADDED
@@ -0,0 +1,900 @@
1
+ import { parse as parseJsonc, printParseErrorCode, type ParseError } from "jsonc-parser";
2
+ import { Cause, Effect, FileSystem, Path, Schema, Stream } from "effect";
3
+ import { ChildProcess } from "effect/unstable/process";
4
+
5
+ import { DevKitManifestSchema, normalizeManifest } from "./manifest.ts";
6
+ import { loadSkillCatalog, resolveSkillSources, type ResolvedSkillSource } from "./catalog.ts";
7
+ import { printDetail, printStatus, withSpinner } from "./cli-ui.ts";
8
+ import {
9
+ applyEffectSourcePlan,
10
+ planEffectSource,
11
+ type EffectSourcePlan,
12
+ } from "./effect-source.ts";
13
+ import {
14
+ applyEffectTsgoPatchPlan,
15
+ planEffectTsgoPatch,
16
+ type EffectTsgoPatchPlan,
17
+ } from "./effect-tsgo.ts";
18
+ import {
19
+ digestSymlinkTarget,
20
+ digestText,
21
+ observePath,
22
+ type ObservedPath,
23
+ } from "./path-digest.ts";
24
+ import {
25
+ AppliedStateSchema,
26
+ DevKitLockSchema,
27
+ type AppliedState,
28
+ type DevKitLock,
29
+ type ManagedSkillOutput,
30
+ type OwnershipReceipt,
31
+ } from "./project-state.ts";
32
+ import {
33
+ acquireProjectProcessLock,
34
+ PROJECT_PROCESS_LOCK_PATH,
35
+ } from "./project-process-lock.ts";
36
+ import { observeSymbolicLink } from "./node-symbolic-link.ts";
37
+ import { DEV_KIT_VERSION } from "./tool-metadata.ts";
38
+
39
+ export type SyncOptions = {
40
+ readonly manifestPath?: string;
41
+ readonly projectDir?: string;
42
+ readonly lockfilePath?: string;
43
+ readonly statePath?: string;
44
+ readonly dryRun?: boolean;
45
+ readonly locked?: boolean;
46
+ };
47
+
48
+ type SkillCatalog = Readonly<Record<string, ReadonlyArray<string>>>;
49
+
50
+ type ManagedPath = {
51
+ readonly absolute: string;
52
+ readonly relative: string;
53
+ };
54
+
55
+ type DesiredSkillOutput =
56
+ | (Omit<ManagedSkillOutput, "mode" | "kind"> & {
57
+ readonly mode: "copy";
58
+ readonly kind: "directory";
59
+ readonly source: string;
60
+ readonly destination: string;
61
+ })
62
+ | (Omit<ManagedSkillOutput, "mode" | "kind"> & {
63
+ readonly mode: "symlink";
64
+ readonly kind: "symlink";
65
+ readonly source: string;
66
+ readonly destination: string;
67
+ readonly linkTarget: string;
68
+ });
69
+
70
+ type SkillPlanAction =
71
+ | {
72
+ readonly action: "create" | "update";
73
+ readonly desired: DesiredSkillOutput;
74
+ readonly observed: ObservedPath;
75
+ }
76
+ | {
77
+ readonly action: "remove";
78
+ readonly previous: OwnershipReceipt;
79
+ readonly destination: string;
80
+ readonly observed: ObservedPath;
81
+ }
82
+ | {
83
+ readonly action: "unchanged";
84
+ readonly desired: DesiredSkillOutput;
85
+ readonly observed: ObservedPath;
86
+ readonly adopted: boolean;
87
+ }
88
+ | {
89
+ readonly action: "conflict";
90
+ readonly path: string;
91
+ readonly reason: string;
92
+ };
93
+
94
+ export type SkillPlan = {
95
+ readonly projectDir: string;
96
+ readonly lockfilePath: string;
97
+ readonly statePath: string;
98
+ readonly actions: ReadonlyArray<SkillPlanAction>;
99
+ readonly effectSource?: EffectSourcePlan;
100
+ readonly effectTsgo?: EffectTsgoPatchPlan;
101
+ readonly nextLock: DevKitLock;
102
+ readonly nextState: AppliedState;
103
+ readonly metadataChanged: boolean;
104
+ };
105
+
106
+ class ManifestNotFoundError extends Schema.TaggedErrorClass<ManifestNotFoundError>()(
107
+ "ManifestNotFoundError",
108
+ { path: Schema.String },
109
+ ) {
110
+ override get message() {
111
+ return `manifest not found: ${this.path}`;
112
+ }
113
+ }
114
+
115
+ class StructuredFileError extends Schema.TaggedErrorClass<StructuredFileError>()(
116
+ "StructuredFileError",
117
+ { path: Schema.String, message: Schema.String },
118
+ ) {}
119
+
120
+ class UnknownSkillOrFamilyError extends Schema.TaggedErrorClass<UnknownSkillOrFamilyError>()(
121
+ "UnknownSkillOrFamilyError",
122
+ { name: Schema.String, known: Schema.Array(Schema.String) },
123
+ ) {
124
+ override get message() {
125
+ return `unknown skill or family "${this.name}". Known values: ${this.known.join(", ")}`;
126
+ }
127
+ }
128
+
129
+ class InvalidSkillCatalogError extends Schema.TaggedErrorClass<InvalidSkillCatalogError>()(
130
+ "InvalidSkillCatalogError",
131
+ { family: Schema.String, message: Schema.String },
132
+ ) {}
133
+
134
+ class CommandError extends Schema.TaggedErrorClass<CommandError>()("CommandError", {
135
+ command: Schema.String,
136
+ exitCode: Schema.Int,
137
+ output: Schema.String,
138
+ }) {
139
+ override get message() {
140
+ return this.output.length > 0
141
+ ? `${this.command} exited with code ${this.exitCode}: ${this.output}`
142
+ : `${this.command} exited with code ${this.exitCode}`;
143
+ }
144
+ }
145
+
146
+ class UnsafeManagedPathError extends Schema.TaggedErrorClass<UnsafeManagedPathError>()(
147
+ "UnsafeManagedPathError",
148
+ { path: Schema.String, reason: Schema.String },
149
+ ) {
150
+ override get message() {
151
+ return `unsafe managed path "${this.path}": ${this.reason}`;
152
+ }
153
+ }
154
+
155
+ class InvalidProjectStateError extends Schema.TaggedErrorClass<InvalidProjectStateError>()(
156
+ "InvalidProjectStateError",
157
+ { message: Schema.String },
158
+ ) {}
159
+
160
+ class LockedPlanMismatchError extends Schema.TaggedErrorClass<LockedPlanMismatchError>()(
161
+ "LockedPlanMismatchError",
162
+ { message: Schema.String },
163
+ ) {}
164
+
165
+ class PlanConflictError extends Schema.TaggedErrorClass<PlanConflictError>()(
166
+ "PlanConflictError",
167
+ { conflicts: Schema.Array(Schema.String) },
168
+ ) {
169
+ override get message() {
170
+ const heading = `plan has ${this.conflicts.length} conflict${this.conflicts.length === 1 ? "" : "s"}`;
171
+ return `${heading}:\n${this.conflicts.map((conflict) => ` ${conflict}`).join("\n")}`;
172
+ }
173
+ }
174
+
175
+ class ApplyRaceError extends Schema.TaggedErrorClass<ApplyRaceError>()("ApplyRaceError", {
176
+ path: Schema.String,
177
+ }) {
178
+ override get message() {
179
+ return `managed path changed after planning: ${this.path}`;
180
+ }
181
+ }
182
+
183
+ const SKILL_FAMILIES: SkillCatalog = { effect: ["effect-ts"] };
184
+ export const DEFAULT_MANIFEST = "dev-kit.jsonc";
185
+ const DEFAULT_LOCKFILE = "dev-kit.lock.json";
186
+ const DEFAULT_STATE = ".dev-kit/state.json";
187
+
188
+ const resolvePackageRoot = Effect.fn("resolvePackageRoot")(function* () {
189
+ const path = yield* Path.Path;
190
+ const scriptPath = yield* path.fromFileUrl(new URL(import.meta.url));
191
+ return path.resolve(path.dirname(scriptPath), "..");
192
+ });
193
+
194
+ const runCommand = Effect.fn("runCommand")(function* (
195
+ cwd: string,
196
+ command: string,
197
+ args: ReadonlyArray<string>,
198
+ ) {
199
+ const formatted = [command, ...args].join(" ");
200
+ const child = yield* ChildProcess.make(command, args, { cwd, stderr: "pipe", stdout: "pipe" });
201
+ const [output, exitCode] = yield* Effect.all([
202
+ Stream.mkString(Stream.decodeText(child.all)),
203
+ child.exitCode,
204
+ ]);
205
+ const trimmed = output.trim();
206
+ if (exitCode !== 0) {
207
+ return yield* new CommandError({ command: formatted, exitCode, output: trimmed });
208
+ }
209
+ return trimmed;
210
+ });
211
+
212
+ const resolveGitRoot = Effect.fn("resolveGitRoot")(function* (cwd: string) {
213
+ return yield* runCommand(cwd, "git", ["rev-parse", "--show-toplevel"]);
214
+ });
215
+
216
+ const parseStructuredFile = Effect.fn("parseStructuredFile")(function* <A>(
217
+ filePath: string,
218
+ raw: string,
219
+ schema: Schema.ConstraintDecoder<A>,
220
+ ) {
221
+ const errors: Array<ParseError> = [];
222
+ const parsed = parseJsonc(raw, errors, { allowTrailingComma: true });
223
+ const first = errors[0];
224
+ if (first !== undefined) {
225
+ return yield* new StructuredFileError({
226
+ path: filePath,
227
+ message: `${printParseErrorCode(first.error)} at offset ${first.offset}`,
228
+ });
229
+ }
230
+ return yield* Schema.decodeUnknownEffect(schema)(parsed).pipe(
231
+ Effect.mapError((cause) => new StructuredFileError({ path: filePath, message: cause.message })),
232
+ );
233
+ });
234
+
235
+ const readManifest = Effect.fn("readManifest")(function* (manifestPath: string) {
236
+ const fs = yield* FileSystem.FileSystem;
237
+ if (!(yield* fs.exists(manifestPath))) {
238
+ return yield* new ManifestNotFoundError({ path: manifestPath });
239
+ }
240
+ const raw = yield* fs.readFileString(manifestPath);
241
+ return yield* parseStructuredFile(manifestPath, raw, DevKitManifestSchema);
242
+ });
243
+
244
+ const readOptionalStructuredFile = Effect.fn("readOptionalStructuredFile")(function* <A>(
245
+ filePath: string,
246
+ schema: Schema.ConstraintDecoder<A>,
247
+ ) {
248
+ const fs = yield* FileSystem.FileSystem;
249
+ if (!(yield* fs.exists(filePath))) {
250
+ return undefined;
251
+ }
252
+ return yield* parseStructuredFile(filePath, yield* fs.readFileString(filePath), schema);
253
+ });
254
+
255
+ const expandSelection = (
256
+ include: ReadonlyArray<string>,
257
+ exclude: ReadonlyArray<string>,
258
+ availableSkills: ReadonlyArray<string>,
259
+ skillFamilies: SkillCatalog,
260
+ ) => {
261
+ const known = [...new Set([...Object.keys(skillFamilies), ...availableSkills])].sort();
262
+ const selected = new Set<string>();
263
+ for (const name of include) {
264
+ if (skillFamilies[name]) {
265
+ for (const skill of skillFamilies[name]) selected.add(skill);
266
+ } else if (availableSkills.includes(name)) {
267
+ selected.add(name);
268
+ } else {
269
+ return Effect.fail(new UnknownSkillOrFamilyError({ name, known }));
270
+ }
271
+ }
272
+ for (const name of exclude) {
273
+ const family = skillFamilies[name];
274
+ if (family) for (const skill of family) selected.delete(skill);
275
+ else selected.delete(name);
276
+ }
277
+ return Effect.succeed([...selected].sort());
278
+ };
279
+
280
+ const portablePath = (path: Path.Path, value: string): string =>
281
+ path.sep === "/" ? value : value.split(path.sep).join("/");
282
+
283
+ const resolveManagedPath = Effect.fn("resolveManagedPath")(function* (
284
+ projectDir: string,
285
+ candidate: string,
286
+ ) {
287
+ const fs = yield* FileSystem.FileSystem;
288
+ const path = yield* Path.Path;
289
+ if (candidate.length === 0 || path.isAbsolute(candidate)) {
290
+ return yield* new UnsafeManagedPathError({ path: candidate, reason: "must be a non-empty project-relative path" });
291
+ }
292
+ const absolute = path.resolve(projectDir, candidate);
293
+ const relative = path.relative(projectDir, absolute);
294
+ if (relative.length === 0 || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
295
+ return yield* new UnsafeManagedPathError({ path: candidate, reason: "resolves outside the project" });
296
+ }
297
+
298
+ const segments = relative.split(path.sep);
299
+ let ancestor = projectDir;
300
+ for (const segment of segments.slice(0, -1)) {
301
+ ancestor = path.join(ancestor, segment);
302
+ const target = yield* observeSymbolicLink(ancestor);
303
+ if (target.kind === "symlink") {
304
+ return yield* new UnsafeManagedPathError({
305
+ path: candidate,
306
+ reason: `ancestor is a symlink: ${portablePath(path, path.relative(projectDir, ancestor))}`,
307
+ });
308
+ }
309
+ }
310
+ return { absolute, relative: portablePath(path, relative) } satisfies ManagedPath;
311
+ });
312
+
313
+ const pathsOverlap = (left: string, right: string): boolean =>
314
+ left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`);
315
+
316
+ const validateReservedPaths = Effect.fn("validateReservedPaths")(function* (
317
+ projectDir: string,
318
+ reserved: ReadonlyArray<{ readonly label: string; readonly path: string }>,
319
+ outputs: ReadonlyArray<Pick<ManagedSkillOutput | OwnershipReceipt, "path">>,
320
+ ) {
321
+ const outputPaths = new Set<string>();
322
+ for (const output of outputs) {
323
+ outputPaths.add((yield* resolveManagedPath(projectDir, output.path)).relative);
324
+ }
325
+
326
+ for (let index = 0; index < reserved.length; index += 1) {
327
+ const current = reserved[index];
328
+ if (current === undefined) continue;
329
+ for (const other of reserved.slice(index + 1)) {
330
+ if (pathsOverlap(current.path, other.path)) {
331
+ return yield* new InvalidProjectStateError({
332
+ message: `${current.label} path ${current.path} overlaps ${other.label} path ${other.path}`,
333
+ });
334
+ }
335
+ }
336
+ for (const outputPath of outputPaths) {
337
+ if (pathsOverlap(current.path, outputPath)) {
338
+ return yield* new InvalidProjectStateError({
339
+ message: `${current.label} path ${current.path} overlaps managed output ${outputPath}`,
340
+ });
341
+ }
342
+ }
343
+ }
344
+ });
345
+
346
+ const outputIdentity = (output: Pick<ManagedSkillOutput, "resourceId" | "path" | "mode" | "kind" | "digest" | "catalog">) =>
347
+ JSON.stringify({
348
+ resourceId: output.resourceId,
349
+ path: output.path,
350
+ mode: output.mode,
351
+ kind: output.kind,
352
+ digest: output.digest,
353
+ catalog: output.catalog,
354
+ });
355
+
356
+ const validateInventory = Effect.fn("validateManagedInventory")(function* (
357
+ projectDir: string,
358
+ outputs: ReadonlyArray<ManagedSkillOutput | OwnershipReceipt>,
359
+ label: string,
360
+ ) {
361
+ const ids = new Set<string>();
362
+ const paths = new Set<string>();
363
+ const sortedPaths: Array<string> = [];
364
+ for (const output of outputs) {
365
+ if (ids.has(output.resourceId)) {
366
+ return yield* new InvalidProjectStateError({ message: `${label} contains duplicate resource id ${output.resourceId}` });
367
+ }
368
+ if (paths.has(output.path)) {
369
+ return yield* new InvalidProjectStateError({ message: `${label} contains duplicate path ${output.path}` });
370
+ }
371
+ ids.add(output.resourceId);
372
+ paths.add(output.path);
373
+ sortedPaths.push((yield* resolveManagedPath(projectDir, output.path)).relative);
374
+ }
375
+ sortedPaths.sort();
376
+ for (let index = 1; index < sortedPaths.length; index += 1) {
377
+ const previous = sortedPaths[index - 1];
378
+ const current = sortedPaths[index];
379
+ if (previous === undefined || current === undefined) continue;
380
+ if (current.startsWith(`${previous}/`)) {
381
+ return yield* new InvalidProjectStateError({ message: `${label} contains overlapping paths ${previous} and ${current}` });
382
+ }
383
+ }
384
+ });
385
+
386
+ const validateCrossInventoryPaths = Effect.fn("validateCrossInventoryPaths")(function* (
387
+ projectDir: string,
388
+ outputs: ReadonlyArray<Pick<ManagedSkillOutput | OwnershipReceipt, "path">>,
389
+ ) {
390
+ const uniquePaths = new Set<string>();
391
+ for (const output of outputs) {
392
+ uniquePaths.add((yield* resolveManagedPath(projectDir, output.path)).relative);
393
+ }
394
+ const sortedPaths = [...uniquePaths].sort();
395
+ for (let index = 1; index < sortedPaths.length; index += 1) {
396
+ const previous = sortedPaths[index - 1];
397
+ const current = sortedPaths[index];
398
+ if (previous === undefined || current === undefined) continue;
399
+ if (current.startsWith(`${previous}/`)) {
400
+ return yield* new InvalidProjectStateError({
401
+ message: `desired and previously owned paths overlap: ${previous} and ${current}`,
402
+ });
403
+ }
404
+ }
405
+ });
406
+
407
+ const buildDesiredOutputs = Effect.fn("buildDesiredSkillOutputs")(function* (
408
+ projectDir: string,
409
+ sourceBySkill: ReadonlyMap<string, ResolvedSkillSource>,
410
+ skills: ReadonlyArray<string>,
411
+ targets: ReturnType<typeof normalizeManifest>["targets"],
412
+ ) {
413
+ const path = yield* Path.Path;
414
+ const outputs: Array<DesiredSkillOutput> = [];
415
+ const agentsTarget = targets.agents;
416
+ for (const skill of skills) {
417
+ const resolvedSource = sourceBySkill.get(skill);
418
+ if (resolvedSource === undefined) {
419
+ return yield* new InvalidProjectStateError({ message: `skill source is unavailable: ${skill}` });
420
+ }
421
+ const source = resolvedSource.path;
422
+ const sourceObservation = yield* observePath(source);
423
+ if (sourceObservation.kind !== "directory") {
424
+ return yield* new InvalidProjectStateError({ message: `skill source is not a directory: ${source}` });
425
+ }
426
+ for (const targetName of ["agents", "claude", "opencode"] as const) {
427
+ const target = targets[targetName];
428
+ if (!target.enabled) continue;
429
+ const managed = yield* resolveManagedPath(projectDir, path.join(target.path, skill));
430
+ if (target.mode === "copy") {
431
+ outputs.push({
432
+ resourceId: `skill:${skill}@${targetName}`,
433
+ path: managed.relative,
434
+ skill,
435
+ target: targetName,
436
+ mode: "copy",
437
+ kind: "directory",
438
+ digest: sourceObservation.digest,
439
+ ...(resolvedSource.catalog ? { catalog: resolvedSource.catalog } : {}),
440
+ source,
441
+ destination: managed.absolute,
442
+ });
443
+ continue;
444
+ }
445
+ const linkSource =
446
+ targetName === "agents" || !agentsTarget.enabled
447
+ ? source
448
+ : (yield* resolveManagedPath(projectDir, path.join(agentsTarget.path, skill))).absolute;
449
+ const linkTarget = path.relative(path.dirname(managed.absolute), linkSource);
450
+ const linkDigest = yield* digestSymlinkTarget(linkTarget);
451
+ outputs.push({
452
+ resourceId: `skill:${skill}@${targetName}`,
453
+ path: managed.relative,
454
+ skill,
455
+ target: targetName,
456
+ mode: "symlink",
457
+ kind: "symlink",
458
+ digest: linkDigest,
459
+ ...(resolvedSource.catalog ? { catalog: resolvedSource.catalog } : {}),
460
+ source,
461
+ destination: managed.absolute,
462
+ linkTarget,
463
+ });
464
+ }
465
+ }
466
+ yield* validateInventory(projectDir, outputs, "desired outputs");
467
+ return outputs.sort((left, right) => left.path.localeCompare(right.path));
468
+ });
469
+
470
+ const canonicalLock = (lock: DevKitLock): string => `${JSON.stringify(lock, null, 2)}\n`;
471
+ const canonicalState = (state: AppliedState): string => `${JSON.stringify(state, null, 2)}\n`;
472
+
473
+ const planDesiredOutputs = Effect.fn("planDesiredSkillOutputs")(function* (
474
+ projectDir: string,
475
+ desired: ReadonlyArray<DesiredSkillOutput>,
476
+ currentLock: DevKitLock | undefined,
477
+ currentState: AppliedState | undefined,
478
+ nextLock: DevKitLock,
479
+ ) {
480
+ if (currentLock) yield* validateInventory(projectDir, currentLock.outputs, "dev-kit lock");
481
+ if (currentState) yield* validateInventory(projectDir, currentState.outputs, "applied state");
482
+ yield* validateCrossInventoryPaths(projectDir, [...desired, ...(currentState?.outputs ?? [])]);
483
+ const lockById = new Map(currentLock?.outputs.map((output) => [output.resourceId, output]) ?? []);
484
+ const receiptsById = new Map(currentState?.outputs.map((output) => [output.resourceId, output]) ?? []);
485
+ const desiredKeys = new Set(desired.map((output) => `${output.resourceId}\0${output.path}`));
486
+ const actions: Array<SkillPlanAction> = [];
487
+
488
+ for (const output of desired) {
489
+ const observed = yield* observePath(output.destination);
490
+ const receipt = receiptsById.get(output.resourceId);
491
+ const sameReceipt = receipt?.path === output.path ? receipt : undefined;
492
+ const locked = lockById.get(output.resourceId);
493
+ const adoptable = locked !== undefined && outputIdentity(locked) === outputIdentity(output);
494
+
495
+ if (observed.kind === "missing") {
496
+ actions.push({ action: "create", desired: output, observed });
497
+ } else if (observed.kind === output.kind && observed.digest === output.digest) {
498
+ if (sameReceipt || adoptable) {
499
+ actions.push({ action: "unchanged", desired: output, observed, adopted: !sameReceipt });
500
+ } else {
501
+ actions.push({ action: "conflict", path: output.path, reason: "destination exists but is not owned" });
502
+ }
503
+ } else if (
504
+ sameReceipt &&
505
+ observed.kind === sameReceipt.kind &&
506
+ observed.digest === sameReceipt.digest
507
+ ) {
508
+ actions.push({ action: "update", desired: output, observed });
509
+ } else {
510
+ actions.push({
511
+ action: "conflict",
512
+ path: output.path,
513
+ reason: sameReceipt ? "owned destination was modified" : "destination exists but is not owned",
514
+ });
515
+ }
516
+ }
517
+
518
+ for (const receipt of currentState?.outputs ?? []) {
519
+ if (desiredKeys.has(`${receipt.resourceId}\0${receipt.path}`)) continue;
520
+ const managed = yield* resolveManagedPath(projectDir, receipt.path);
521
+ const observed = yield* observePath(managed.absolute);
522
+ if (observed.kind === "missing") continue;
523
+ if (observed.kind === receipt.kind && observed.digest === receipt.digest) {
524
+ actions.push({ action: "remove", previous: receipt, destination: managed.absolute, observed });
525
+ } else {
526
+ actions.push({ action: "conflict", path: receipt.path, reason: "stale owned destination was modified" });
527
+ }
528
+ }
529
+
530
+ const nextState: AppliedState = {
531
+ version: 1,
532
+ appliedLockDigest: yield* digestText(canonicalLock(nextLock)),
533
+ outputs: desired.map(({ resourceId, path, mode, kind, digest }) => ({
534
+ resourceId,
535
+ path,
536
+ mode,
537
+ kind,
538
+ digest,
539
+ })),
540
+ };
541
+ return { actions: actions.sort((left, right) => {
542
+ const leftPath = left.action === "remove" ? left.previous.path : left.action === "conflict" ? left.path : left.desired.path;
543
+ const rightPath = right.action === "remove" ? right.previous.path : right.action === "conflict" ? right.path : right.desired.path;
544
+ return leftPath.localeCompare(rightPath);
545
+ }), nextState };
546
+ });
547
+
548
+ const lockedPlanMatches = (current: DevKitLock, next: DevKitLock): boolean =>
549
+ current.toolVersion === next.toolVersion &&
550
+ current.manifestDigest === next.manifestDigest &&
551
+ JSON.stringify(current.setup) === JSON.stringify(next.setup) &&
552
+ current.outputs.length === next.outputs.length &&
553
+ current.outputs.every((output, index) => {
554
+ const nextOutput = next.outputs[index];
555
+ return nextOutput !== undefined && outputIdentity(output) === outputIdentity(nextOutput);
556
+ });
557
+
558
+ export const planProjectSkills = Effect.fn("planProjectSkills")(function* (options: SyncOptions) {
559
+ const path = yield* Path.Path;
560
+ const fs = yield* FileSystem.FileSystem;
561
+ const initialDir = path.resolve(options.projectDir ?? ".");
562
+ const discoveredRoot = yield* resolveGitRoot(initialDir).pipe(
563
+ Effect.catchTag("CommandError", (error) =>
564
+ error.output.includes("not a git repository")
565
+ ? Effect.succeed(initialDir)
566
+ : Effect.fail(error),
567
+ ),
568
+ );
569
+ const projectDir = yield* fs.realPath(discoveredRoot);
570
+ const manifestManaged = yield* resolveManagedPath(projectDir, options.manifestPath ?? DEFAULT_MANIFEST);
571
+ const lockManaged = yield* resolveManagedPath(projectDir, options.lockfilePath ?? DEFAULT_LOCKFILE);
572
+ const stateManaged = yield* resolveManagedPath(projectDir, options.statePath ?? DEFAULT_STATE);
573
+ const processLockManaged = yield* resolveManagedPath(projectDir, PROJECT_PROCESS_LOCK_PATH);
574
+ const packageRoot = yield* resolvePackageRoot();
575
+ const manifest = normalizeManifest(yield* readManifest(manifestManaged.absolute));
576
+ const effectSource = manifest.setup.effectSource.enabled
577
+ ? yield* planEffectSource({
578
+ packageName: manifest.setup.effectSource.packageName,
579
+ path: manifest.setup.effectSource.path,
580
+ projectDir,
581
+ repository: manifest.setup.effectSource.repository,
582
+ })
583
+ : undefined;
584
+ const effectTsgo = manifest.setup.effectTsgo.enabled
585
+ ? yield* planEffectTsgoPatch({
586
+ force: manifest.setup.effectTsgo.force,
587
+ projectDir,
588
+ typescriptPackage: manifest.setup.effectTsgo.typescriptPackage,
589
+ })
590
+ : undefined;
591
+ const catalog = yield* loadSkillCatalog(packageRoot);
592
+ const availableSkills = catalog.skills.map((skill) => skill.name);
593
+ const skillFamilies = { ...SKILL_FAMILIES, ...catalog.families };
594
+ for (const [family, familySkills] of Object.entries(skillFamilies)) {
595
+ if (availableSkills.includes(family)) {
596
+ return yield* new InvalidSkillCatalogError({ family, message: `family name conflicts with a skill name: ${family}` });
597
+ }
598
+ const missing = familySkills.filter((skill) => !availableSkills.includes(skill));
599
+ if (missing.length > 0) {
600
+ return yield* new InvalidSkillCatalogError({ family, message: `family references missing skills: ${missing.join(", ")}` });
601
+ }
602
+ }
603
+ const selectedSkills = yield* expandSelection(manifest.include, manifest.exclude, availableSkills, skillFamilies);
604
+ const sourceBySkill = yield* withSpinner(
605
+ "Fetching selected skills",
606
+ resolveSkillSources(packageRoot, projectDir, selectedSkills, options.dryRun !== true),
607
+ );
608
+ const desired = yield* buildDesiredOutputs(projectDir, sourceBySkill, selectedSkills, manifest.targets);
609
+ const nextLock: DevKitLock = {
610
+ version: 1,
611
+ toolVersion: DEV_KIT_VERSION,
612
+ manifestDigest: yield* digestText(JSON.stringify(manifest)),
613
+ setup: {
614
+ ...(effectSource === undefined
615
+ ? {}
616
+ : {
617
+ effectSource: {
618
+ packageName: effectSource.packageName,
619
+ packageVersion: effectSource.packageVersion,
620
+ path: effectSource.path,
621
+ repository: effectSource.repository,
622
+ tag: effectSource.tag,
623
+ },
624
+ }),
625
+ ...(effectTsgo === undefined
626
+ ? {}
627
+ : {
628
+ effectTsgo: {
629
+ effectTsgoVersion: effectTsgo.effectTsgoVersion,
630
+ typescriptPackage: effectTsgo.typescriptPackage,
631
+ typescriptVersion: effectTsgo.typescriptVersion,
632
+ },
633
+ }),
634
+ },
635
+ outputs: desired.map(({ resourceId, path: outputPath, skill, target, mode, kind, digest, catalog }) => ({
636
+ resourceId,
637
+ path: outputPath,
638
+ skill,
639
+ target,
640
+ mode,
641
+ kind,
642
+ digest,
643
+ ...(catalog ? { catalog } : {}),
644
+ })),
645
+ };
646
+ const reservedPaths = [
647
+ { label: "manifest", path: manifestManaged.relative },
648
+ { label: "lockfile", path: lockManaged.relative },
649
+ { label: "state", path: stateManaged.relative },
650
+ { label: "process lock", path: processLockManaged.relative },
651
+ ...(effectSource === undefined
652
+ ? []
653
+ : [{ label: "Effect source checkout", path: effectSource.path }]),
654
+ ];
655
+ yield* validateReservedPaths(projectDir, reservedPaths, desired);
656
+ const currentLock = yield* readOptionalStructuredFile(lockManaged.absolute, DevKitLockSchema);
657
+ const currentState = yield* readOptionalStructuredFile(stateManaged.absolute, AppliedStateSchema);
658
+ yield* validateReservedPaths(
659
+ projectDir,
660
+ reservedPaths,
661
+ [
662
+ ...(currentLock?.outputs ?? []),
663
+ ...(currentState?.outputs ?? []),
664
+ ],
665
+ );
666
+ if (options.locked) {
667
+ if (!currentLock) {
668
+ return yield* new LockedPlanMismatchError({ message: "dev-kit.lock.json is required with --locked" });
669
+ }
670
+ if (!lockedPlanMatches(currentLock, nextLock)) {
671
+ return yield* new LockedPlanMismatchError({ message: "manifest or packaged skills differ from dev-kit.lock.json" });
672
+ }
673
+ }
674
+ const planned = yield* planDesiredOutputs(projectDir, desired, currentLock, currentState, nextLock);
675
+ return {
676
+ projectDir,
677
+ lockfilePath: lockManaged.absolute,
678
+ statePath: stateManaged.absolute,
679
+ actions: planned.actions,
680
+ ...(effectSource === undefined ? {} : { effectSource }),
681
+ ...(effectTsgo === undefined ? {} : { effectTsgo }),
682
+ nextLock,
683
+ nextState: planned.nextState,
684
+ metadataChanged:
685
+ JSON.stringify(currentLock) !== JSON.stringify(nextLock) ||
686
+ JSON.stringify(currentState) !== JSON.stringify(planned.nextState),
687
+ } satisfies SkillPlan;
688
+ });
689
+
690
+ const formatAction = (action: SkillPlanAction): string => {
691
+ if (action.action === "conflict") return `! ${action.path}: ${action.reason}`;
692
+ if (action.action === "remove") return `− ${action.previous.resourceId} → ${action.previous.path}`;
693
+ const verb = action.desired.mode === "copy" ? "copy" : "link";
694
+ const adoption = action.action === "unchanged" && action.adopted ? " (adopt)" : "";
695
+ const marker = action.action === "create" ? "+" : action.action === "update" ? "~" : "=";
696
+ return `${marker} ${verb} ${action.desired.skill} → ${action.desired.path}${adoption}`;
697
+ };
698
+
699
+ const operationalChangeCount = (plan: SkillPlan): number =>
700
+ plan.actions.filter((action) => action.action !== "unchanged").length +
701
+ (plan.effectSource?.action === "sync" ? 1 : 0) +
702
+ (plan.effectTsgo !== undefined && !plan.effectTsgo.alreadyPatched ? 1 : 0);
703
+
704
+ const plannedChangeCount = (plan: SkillPlan): number => {
705
+ const operational = operationalChangeCount(plan);
706
+ return operational === 0 && plan.metadataChanged ? 1 : operational;
707
+ };
708
+
709
+ export const printSkillPlan = Effect.fn("printSkillPlan")(function* (plan: SkillPlan) {
710
+ const changes = plannedChangeCount(plan);
711
+ if (changes === 0) {
712
+ yield* printStatus("success", "Already up to date");
713
+ return;
714
+ }
715
+ yield* printStatus("plan", `${changes} change${changes === 1 ? "" : "s"} planned`);
716
+ for (const action of plan.actions) {
717
+ if (action.action !== "unchanged") yield* printDetail(formatAction(action));
718
+ }
719
+ if (plan.effectSource?.action === "sync") {
720
+ yield* printDetail(
721
+ `+ Effect source ${plan.effectSource.tag} → ${plan.effectSource.path}`,
722
+ );
723
+ }
724
+ if (plan.effectTsgo !== undefined && !plan.effectTsgo.alreadyPatched) {
725
+ yield* printDetail(
726
+ `+ TypeScript patch @effect/tsgo@${plan.effectTsgo.effectTsgoVersion} → ${plan.effectTsgo.typescriptPackage}@${plan.effectTsgo.typescriptVersion}`,
727
+ );
728
+ }
729
+ if (operationalChangeCount(plan) === 0 && plan.metadataChanged) {
730
+ yield* printDetail("+ Dev kit metadata");
731
+ }
732
+ });
733
+
734
+ const observationsEqual = (left: ObservedPath, right: ObservedPath): boolean =>
735
+ left.kind === right.kind &&
736
+ (left.kind === "missing" || (right.kind !== "missing" && left.digest === right.digest));
737
+
738
+ const applyPlannedSkillChanges = Effect.fn("applyPlannedSkillChanges")(function* (plan: SkillPlan) {
739
+ const conflicts = plan.actions.filter((action) => action.action === "conflict");
740
+ if (conflicts.length > 0) {
741
+ return yield* new PlanConflictError({
742
+ conflicts: conflicts.map((action) => action.action === "conflict" ? `${action.path}: ${action.reason}` : ""),
743
+ });
744
+ }
745
+
746
+ const fs = yield* FileSystem.FileSystem;
747
+ const path = yield* Path.Path;
748
+ const mutating = plan.actions.filter(
749
+ (action): action is Exclude<SkillPlanAction, { readonly action: "unchanged" | "conflict" }> =>
750
+ action.action === "create" || action.action === "update" || action.action === "remove",
751
+ );
752
+ for (const action of mutating) {
753
+ const destination = action.action === "remove" ? action.destination : action.desired.destination;
754
+ if (!observationsEqual(yield* observePath(destination), action.observed)) {
755
+ return yield* new ApplyRaceError({ path: action.action === "remove" ? action.previous.path : action.desired.path });
756
+ }
757
+ }
758
+
759
+ if (mutating.length === 0 && !plan.metadataChanged) {
760
+ return;
761
+ }
762
+
763
+ const tempDir = yield* fs.makeTempDirectoryScoped({ directory: plan.projectDir, prefix: ".dev-kit-apply-" });
764
+ const stageDir = path.join(tempDir, "stage");
765
+ const backupDir = path.join(tempDir, "backup");
766
+ const stagedByResource = new Map<string, string>();
767
+ let stageIndex = 0;
768
+ for (const action of mutating) {
769
+ if (action.action === "remove") continue;
770
+ const staged = path.join(stageDir, String(stageIndex++));
771
+ yield* fs.makeDirectory(path.dirname(staged), { recursive: true });
772
+ if (action.desired.mode === "copy") {
773
+ yield* fs.copy(action.desired.source, staged, { overwrite: true });
774
+ } else {
775
+ yield* fs.symlink(action.desired.linkTarget, staged);
776
+ }
777
+ const observation = yield* observePath(staged);
778
+ if (observation.kind !== action.desired.kind || observation.digest !== action.desired.digest) {
779
+ return yield* new InvalidProjectStateError({ message: `staged output digest mismatch for ${action.desired.path}` });
780
+ }
781
+ stagedByResource.set(action.desired.resourceId, staged);
782
+ }
783
+
784
+ const stagedLock = path.join(tempDir, "next-lock.json");
785
+ const stagedState = path.join(tempDir, "next-state.json");
786
+ yield* fs.writeFileString(stagedLock, canonicalLock(plan.nextLock));
787
+ yield* fs.writeFileString(stagedState, canonicalState(plan.nextState));
788
+
789
+ type Replacement = { readonly destination: string; readonly backup: string; readonly staged?: string };
790
+ const replacements: Array<Replacement> = [];
791
+ let replacementIndex = 0;
792
+ for (const action of mutating) {
793
+ const staged = action.action === "remove"
794
+ ? undefined
795
+ : stagedByResource.get(action.desired.resourceId);
796
+ if (action.action !== "remove" && staged === undefined) {
797
+ return yield* new InvalidProjectStateError({
798
+ message: `missing staged output for ${action.desired.resourceId}`,
799
+ });
800
+ }
801
+ replacements.push({
802
+ destination: action.action === "remove" ? action.destination : action.desired.destination,
803
+ backup: path.join(backupDir, String(replacementIndex++)),
804
+ ...(staged === undefined ? {} : { staged }),
805
+ });
806
+ }
807
+ replacements.push(
808
+ { destination: plan.lockfilePath, backup: path.join(backupDir, "lock"), staged: stagedLock },
809
+ { destination: plan.statePath, backup: path.join(backupDir, "state"), staged: stagedState },
810
+ );
811
+
812
+ const installed: Array<string> = [];
813
+ const backedUp: Array<Replacement> = [];
814
+ const rollback = Effect.gen(function* () {
815
+ for (const destination of [...installed].reverse()) {
816
+ yield* fs.remove(destination, { recursive: true, force: true });
817
+ }
818
+ for (const replacement of [...backedUp].reverse()) {
819
+ yield* fs.makeDirectory(path.dirname(replacement.destination), { recursive: true });
820
+ yield* fs.rename(replacement.backup, replacement.destination);
821
+ }
822
+ });
823
+
824
+ const apply = Effect.gen(function* () {
825
+ for (const replacement of replacements) {
826
+ const observed = yield* observePath(replacement.destination);
827
+ if (observed.kind !== "missing") {
828
+ yield* fs.makeDirectory(path.dirname(replacement.backup), { recursive: true });
829
+ yield* fs.rename(replacement.destination, replacement.backup);
830
+ backedUp.push(replacement);
831
+ }
832
+ if (replacement.staged) {
833
+ yield* fs.makeDirectory(path.dirname(replacement.destination), { recursive: true });
834
+ yield* fs.rename(replacement.staged, replacement.destination);
835
+ installed.push(replacement.destination);
836
+ }
837
+ }
838
+ });
839
+
840
+ yield* Effect.uninterruptible(apply.pipe(
841
+ Effect.catchCause((applyCause) =>
842
+ rollback.pipe(
843
+ Effect.catchCause((rollbackCause) =>
844
+ Effect.failCause(Cause.combine(applyCause, rollbackCause)),
845
+ ),
846
+ Effect.andThen(Effect.failCause(applyCause)),
847
+ ),
848
+ ),
849
+ ));
850
+ });
851
+
852
+ export const runProjectSkillPlan = Effect.fn("runProjectSkillPlan")(function* (options: SyncOptions) {
853
+ const plan = yield* planProjectSkills(options);
854
+ if (options.dryRun) yield* printSkillPlan(plan);
855
+ const conflicts = plan.actions.filter((action) => action.action === "conflict");
856
+ if (conflicts.length > 0) {
857
+ return yield* new PlanConflictError({
858
+ conflicts: conflicts.map((action) => action.action === "conflict" ? `${action.path}: ${action.reason}` : ""),
859
+ });
860
+ }
861
+ if (options.dryRun) return;
862
+
863
+ yield* acquireProjectProcessLock(plan.projectDir);
864
+ const replanned = yield* planProjectSkills(options);
865
+ const originalSignature = JSON.stringify({
866
+ actions: plan.actions,
867
+ effectSource: plan.effectSource,
868
+ effectTsgo: plan.effectTsgo,
869
+ nextLock: plan.nextLock,
870
+ nextState: plan.nextState,
871
+ });
872
+ const nextSignature = JSON.stringify({
873
+ actions: replanned.actions,
874
+ effectSource: replanned.effectSource,
875
+ effectTsgo: replanned.effectTsgo,
876
+ nextLock: replanned.nextLock,
877
+ nextState: replanned.nextState,
878
+ });
879
+ if (originalSignature !== nextSignature) {
880
+ return yield* new ApplyRaceError({ path: "project state" });
881
+ }
882
+ const changes = plannedChangeCount(replanned);
883
+ yield* withSpinner(
884
+ "Applying dev kit",
885
+ Effect.gen(function* () {
886
+ if (replanned.effectSource !== undefined) {
887
+ yield* applyEffectSourcePlan(replanned.effectSource);
888
+ }
889
+ if (replanned.effectTsgo !== undefined) {
890
+ yield* applyEffectTsgoPatchPlan(replanned.effectTsgo);
891
+ }
892
+ yield* applyPlannedSkillChanges(replanned);
893
+ }),
894
+ );
895
+ yield* printStatus(
896
+ "success",
897
+ changes === 0 && !replanned.metadataChanged ? "Dev kit up to date" : "Dev kit ready",
898
+ changes > 0 ? `${changes} change${changes === 1 ? "" : "s"}` : undefined,
899
+ );
900
+ });