@danieljvdm/dev-kit 0.18.0 → 1.0.1

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 (45) hide show
  1. package/README.md +148 -659
  2. package/package.json +8 -62
  3. package/skill-sources.jsonc +1 -0
  4. package/skill-sources.lock.json +5 -1
  5. package/skills/dev-kit/SKILL.md +42 -212
  6. package/skills/dev-kit/agents/openai.yaml +2 -2
  7. package/skills/dev-kit/references/cloudflare-worker-api.md +37 -0
  8. package/skills/dev-kit/references/default-typescript-repository.md +43 -0
  9. package/skills/dev-kit/references/legacy-eject.md +46 -0
  10. package/skills/dev-kit/references/repository-setup.md +53 -0
  11. package/skills/dev-kit/references/skills.md +35 -0
  12. package/src/bin/dev-kit.ts +142 -127
  13. package/src/eject.ts +715 -0
  14. package/src/legacy-project.ts +67 -0
  15. package/src/oxfmt.ts +1 -4
  16. package/src/oxlint.ts +5 -10
  17. package/src/path-digest.ts +60 -0
  18. package/src/project-skills.ts +722 -0
  19. package/src/tool-metadata.ts +0 -2
  20. package/src/vendor.ts +0 -5
  21. package/src/vite-plus.ts +1 -3
  22. package/dev-kit.example.jsonc +0 -22
  23. package/schema/dev-kit.schema.json +0 -218
  24. package/schema/skill-sources.schema.json +0 -83
  25. package/scripts/sync-anti-slop-runtime.mjs +0 -19
  26. package/src/index.ts +0 -125
  27. package/src/manifest.ts +0 -224
  28. package/src/oxfmt.js +0 -23
  29. package/src/oxlint-plugin-anti-slop/runtime.d.ts +0 -22
  30. package/src/oxlint-plugin-effect.d.ts +0 -19
  31. package/src/oxlint-plugin-style.d.ts +0 -8
  32. package/src/oxlint.js +0 -113
  33. package/src/project-state.ts +0 -122
  34. package/src/scaffold.ts +0 -79
  35. package/src/skill-manager.ts +0 -527
  36. package/src/sync.ts +0 -1935
  37. package/src/tool-ignore-patterns.js +0 -9
  38. package/src/vite-plus-dependency.ts +0 -69
  39. package/src/vite-plus-hooks.ts +0 -175
  40. package/src/vite-plus-workflow.ts +0 -82
  41. package/src/vite-plus.js +0 -88
  42. package/src/worktrunk-config.ts +0 -88
  43. package/templates/AGENTS.md +0 -11
  44. package/templates/vite-plus/github-actions-check.yml +0 -51
  45. package/templates/worktrunk/wt.toml +0 -27
package/src/sync.ts DELETED
@@ -1,1935 +0,0 @@
1
- import { Cause, Effect, FileSystem, Path, Schema, SchemaGetter, Stream } from "effect";
2
- import { ChildProcess } from "effect/unstable/process";
3
- import { parse as parseJsonc, printParseErrorCode, type ParseError } from "jsonc-parser";
4
-
5
- import {
6
- loadSkillCatalog,
7
- resolveSkillSources,
8
- type CatalogSkill,
9
- type ResolvedSkillSource,
10
- } from "./catalog.ts";
11
- import { printDetail, printStatus, withSpinner } from "./cli-ui.ts";
12
- import { applyEffectSourcePlan, planEffectSource, type EffectSourcePlan } from "./effect-source.ts";
13
- import {
14
- applyEffectTsgoPatchPlan,
15
- planEffectTsgoPatch,
16
- type EffectTsgoPatchPlan,
17
- } from "./effect-tsgo.ts";
18
- import { maybePruneGlobalCache } from "./global-cache.ts";
19
- import { DevKitManifestSchema, normalizeManifest } from "./manifest.ts";
20
- import { observeSymbolicLink } from "./node-symbolic-link.ts";
21
- import { resolvePackageSkillSelector } from "./package-skill-source.ts";
22
- import {
23
- digestFileContent,
24
- digestSymlinkTarget,
25
- digestText,
26
- observePath,
27
- type ObservedPath,
28
- } from "./path-digest.ts";
29
- import {
30
- detectPackageManager,
31
- PACKAGE_MANAGER_COMMANDS,
32
- readDirectDependencyNames,
33
- readWorkspaceDependencyNames,
34
- readProjectPackage,
35
- type PackageManagerName,
36
- } from "./project-package.ts";
37
- import { acquireProjectProcessLock, PROJECT_PROCESS_LOCK_PATH } from "./project-process-lock.ts";
38
- import {
39
- AppliedStateSchema,
40
- DevKitLockSchema,
41
- EffectSourceLockSchema,
42
- EffectTsgoLockSchema,
43
- ManagedOutputSchema,
44
- type AppliedState,
45
- type DevKitLock,
46
- type ManagedAgentInstructionsOutput,
47
- type ManagedClaudeInstructionsOutput,
48
- type ManagedOutput,
49
- type ManagedSkillOutput,
50
- type OwnershipReceipt,
51
- } from "./project-state.ts";
52
- import { applyScaffoldPlan, type ScaffoldPlan } from "./scaffold.ts";
53
- import { parseSkillSelector } from "./skill-selector.ts";
54
- import { DEV_KIT_VERSION } from "./tool-metadata.ts";
55
- import {
56
- applyVitePlusHooksPlan,
57
- planVitePlusHooks,
58
- type VitePlusHooksPlan,
59
- } from "./vite-plus-hooks.ts";
60
- import { planVitePlusWorkflow } from "./vite-plus-workflow.ts";
61
- import { planWorktrunkConfig } from "./worktrunk-config.ts";
62
-
63
- export type SyncOptions = {
64
- readonly manifestPath?: string;
65
- readonly projectDir?: string;
66
- readonly lockfilePath?: string;
67
- readonly statePath?: string;
68
- readonly dryRun?: boolean;
69
- readonly locked?: boolean;
70
- };
71
-
72
- type SkillCatalog = Readonly<Record<string, ReadonlyArray<string>>>;
73
-
74
- type ManagedPath = {
75
- readonly absolute: string;
76
- readonly relative: string;
77
- };
78
-
79
- type DesiredSkillOutput =
80
- | (Omit<ManagedSkillOutput, "mode" | "kind"> & {
81
- readonly mode: "copy";
82
- readonly kind: "directory";
83
- readonly source: string;
84
- readonly destination: string;
85
- })
86
- | (Omit<ManagedSkillOutput, "mode" | "kind"> & {
87
- readonly mode: "symlink";
88
- readonly kind: "symlink";
89
- readonly source: string;
90
- readonly destination: string;
91
- readonly linkTarget: string;
92
- });
93
-
94
- type DesiredAgentInstructionsOutput = ManagedAgentInstructionsOutput & {
95
- readonly content: string;
96
- readonly destination: string;
97
- };
98
-
99
- type DesiredClaudeInstructionsOutput = ManagedClaudeInstructionsOutput & {
100
- readonly destination: string;
101
- readonly linkTarget: string;
102
- };
103
-
104
- type DesiredOutput =
105
- | DesiredSkillOutput
106
- | DesiredAgentInstructionsOutput
107
- | DesiredClaudeInstructionsOutput;
108
-
109
- type SkillPlanAction =
110
- | {
111
- readonly action: "create" | "update";
112
- readonly desired: DesiredOutput;
113
- readonly observed: ObservedPath;
114
- readonly stagedContent?: string;
115
- }
116
- | {
117
- readonly action: "remove";
118
- readonly previous: OwnershipReceipt;
119
- readonly destination: string;
120
- readonly observed: ObservedPath;
121
- readonly stagedContent?: string;
122
- }
123
- | {
124
- readonly action: "unchanged";
125
- readonly desired: DesiredOutput;
126
- readonly observed: ObservedPath;
127
- readonly adopted: boolean;
128
- }
129
- | {
130
- readonly action: "conflict";
131
- readonly path: string;
132
- readonly reason: string;
133
- };
134
-
135
- export type SkillPlan = {
136
- readonly projectDir: string;
137
- readonly lockfilePath: string;
138
- readonly statePath: string;
139
- readonly actions: ReadonlyArray<SkillPlanAction>;
140
- readonly effectSource?: EffectSourcePlan;
141
- readonly effectTsgo?: EffectTsgoPatchPlan;
142
- readonly vitePlusHooks?: VitePlusHooksPlan;
143
- readonly vitePlusWorkflow?: ScaffoldPlan;
144
- readonly worktrunkConfig?: ScaffoldPlan;
145
- readonly nextLock: DevKitLock;
146
- readonly nextState: AppliedState;
147
- readonly metadataChanged: boolean;
148
- };
149
-
150
- class ManifestNotFoundError extends Schema.TaggedError<ManifestNotFoundError>()(
151
- "ManifestNotFoundError",
152
- { path: Schema.String },
153
- ) {
154
- override get message() {
155
- return `manifest not found: ${this.path}`;
156
- }
157
- }
158
-
159
- class StructuredFileError extends Schema.TaggedError<StructuredFileError>()("StructuredFileError", {
160
- path: Schema.String,
161
- message: Schema.String,
162
- }) {}
163
-
164
- class UnknownSkillOrFamilyError extends Schema.TaggedError<UnknownSkillOrFamilyError>()(
165
- "UnknownSkillOrFamilyError",
166
- { name: Schema.String, known: Schema.Array(Schema.String) },
167
- ) {
168
- override get message() {
169
- return `unknown skill or family "${this.name}". Known values: ${this.known.join(", ")}`;
170
- }
171
- }
172
-
173
- class InvalidSkillCatalogError extends Schema.TaggedError<InvalidSkillCatalogError>()(
174
- "InvalidSkillCatalogError",
175
- { family: Schema.String, message: Schema.String },
176
- ) {}
177
-
178
- class CommandError extends Schema.TaggedError<CommandError>()("CommandError", {
179
- command: Schema.String,
180
- exitCode: Schema.Int,
181
- output: Schema.String,
182
- }) {
183
- override get message() {
184
- return this.output.length > 0
185
- ? `${this.command} exited with code ${this.exitCode}: ${this.output}`
186
- : `${this.command} exited with code ${this.exitCode}`;
187
- }
188
- }
189
-
190
- class UnsafeManagedPathError extends Schema.TaggedError<UnsafeManagedPathError>()(
191
- "UnsafeManagedPathError",
192
- { path: Schema.String, reason: Schema.String },
193
- ) {
194
- override get message() {
195
- return `unsafe managed path "${this.path}": ${this.reason}`;
196
- }
197
- }
198
-
199
- class InvalidProjectStateError extends Schema.TaggedError<InvalidProjectStateError>()(
200
- "InvalidProjectStateError",
201
- { message: Schema.String },
202
- ) {}
203
-
204
- class LockedPlanMismatchError extends Schema.TaggedError<LockedPlanMismatchError>()(
205
- "LockedPlanMismatchError",
206
- { message: Schema.String },
207
- ) {}
208
-
209
- class PlanConflictError extends Schema.TaggedError<PlanConflictError>()("PlanConflictError", {
210
- conflicts: Schema.Array(Schema.String),
211
- }) {
212
- override get message() {
213
- const heading = `plan has ${this.conflicts.length} conflict${this.conflicts.length === 1 ? "" : "s"}`;
214
-
215
- return `${heading}:\n${this.conflicts.map((conflict) => ` ${conflict}`).join("\n")}`;
216
- }
217
- }
218
-
219
- class ApplyRaceError extends Schema.TaggedError<ApplyRaceError>()("ApplyRaceError", {
220
- path: Schema.String,
221
- }) {
222
- override get message() {
223
- return `managed path changed after planning: ${this.path}`;
224
- }
225
- }
226
-
227
- const fromJsonString = <S extends Schema.Constraint>(schema: S, space?: number) =>
228
- space === undefined
229
- ? Schema.fromJsonString(schema)
230
- : Schema.String.pipe(
231
- Schema.decodeTo(Schema.toCodecJson(schema), {
232
- decode: SchemaGetter.parseJson(),
233
- encode: SchemaGetter.stringifyJson({ space }),
234
- }),
235
- );
236
-
237
- const DevKitSetupSchema = Schema.Struct({
238
- effectSource: Schema.optional(EffectSourceLockSchema),
239
- effectTsgo: Schema.optional(EffectTsgoLockSchema),
240
- });
241
- const OutputOwnershipIdentitySchema = Schema.Union([
242
- Schema.Struct({
243
- resourceId: Schema.String,
244
- path: Schema.String,
245
- mode: Schema.Literals(["copy", "symlink"]),
246
- kind: Schema.Literals(["directory", "symlink"]),
247
- skill: Schema.String,
248
- target: Schema.Literals(["agents", "claude", "opencode"]),
249
- }),
250
- Schema.Struct({
251
- resourceId: Schema.String,
252
- path: Schema.String,
253
- mode: Schema.Literals(["copy", "symlink"]),
254
- kind: Schema.Literals(["file", "symlink"]),
255
- sourcePath: Schema.String,
256
- }),
257
- ]);
258
- const encodeAppliedStateJson = Schema.encodeSync(fromJsonString(AppliedStateSchema));
259
- const encodeDevKitLockJson = Schema.encodeSync(fromJsonString(DevKitLockSchema));
260
- const encodeDevKitLockPrettyJson = Schema.encodeSync(fromJsonString(DevKitLockSchema, 2));
261
- const encodeDevKitSetupJson = Schema.encodeSync(fromJsonString(DevKitSetupSchema));
262
- const encodeManifestJson = Schema.encodeSync(fromJsonString(DevKitManifestSchema));
263
- const encodeManagedOutputJson = Schema.encodeSync(fromJsonString(ManagedOutputSchema));
264
- const encodeOutputOwnershipIdentityJson = Schema.encodeSync(
265
- fromJsonString(OutputOwnershipIdentitySchema),
266
- );
267
- const encodePlanSnapshotJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
268
- const encodeAppliedStatePrettyJson = Schema.encodeSync(fromJsonString(AppliedStateSchema, 2));
269
-
270
- const SKILL_FAMILIES = {
271
- effect: [
272
- "effect-ts",
273
- "effect-architecture-audit",
274
- "build-effect-apis",
275
- "effect-atom-state",
276
- "build-effect-clis",
277
- ],
278
- } satisfies SkillCatalog;
279
-
280
- export const DEFAULT_MANIFEST = "dev-kit.jsonc";
281
- const DEFAULT_LOCKFILE = "dev-kit.lock.json";
282
- const DEFAULT_STATE = ".dev-kit/state.json";
283
- const AGENT_INSTRUCTIONS_TEMPLATE = "templates/AGENTS.md";
284
- const DEV_KIT_SKILL_PATH_PLACEHOLDER = "{{DEV_KIT_SKILL_PATH}}";
285
- const EFFECT_INSTRUCTIONS_PLACEHOLDER = "{{EFFECT_INSTRUCTIONS}}";
286
- const PROJECT_COMMAND_POLICY_PLACEHOLDER = "{{PROJECT_COMMAND_POLICY}}";
287
- const AGENT_INSTRUCTION_MARKERS = [
288
- { start: "<!-- DEV KIT START -->", end: "<!-- DEV KIT END -->" },
289
- // Legacy Dev Kit releases copied this upstream section into AGENTS.md. Keep
290
- // recognizing it so an owned section can be removed during migration.
291
- { start: "<!--VITE PLUS START-->", end: "<!--VITE PLUS END-->" },
292
- ] as const;
293
-
294
- type ManagedInstructionRange = {
295
- readonly start: number;
296
- readonly end: number;
297
- readonly content: string;
298
- };
299
-
300
- type ManagedInstructionInspection =
301
- | {
302
- readonly kind: "valid";
303
- readonly ranges: ReadonlyArray<ManagedInstructionRange>;
304
- readonly content?: string;
305
- }
306
- | { readonly kind: "invalid"; readonly reason: string };
307
-
308
- const findOccurrences = (content: string, marker: string): ReadonlyArray<number> => {
309
- const positions: Array<number> = [];
310
- let offset = 0;
311
-
312
- while (offset < content.length) {
313
- const position = content.indexOf(marker, offset);
314
-
315
- if (position === -1) break;
316
- positions.push(position);
317
- offset = position + marker.length;
318
- }
319
-
320
- return positions;
321
- };
322
-
323
- const inspectManagedInstructionSections = (content: string): ManagedInstructionInspection => {
324
- const ranges: Array<ManagedInstructionRange> = [];
325
-
326
- for (const markers of AGENT_INSTRUCTION_MARKERS) {
327
- const starts = findOccurrences(content, markers.start);
328
- const ends = findOccurrences(content, markers.end);
329
-
330
- if (starts.length === 0 && ends.length === 0) continue;
331
- if (
332
- starts.length !== 1 ||
333
- ends.length !== 1 ||
334
- starts[0] === undefined ||
335
- ends[0] === undefined
336
- ) {
337
- return {
338
- kind: "invalid",
339
- reason: `expected exactly one ${markers.start}/${markers.end} marker pair`,
340
- };
341
- }
342
- if (starts[0] >= ends[0]) {
343
- return { kind: "invalid", reason: `${markers.end} appears before ${markers.start}` };
344
- }
345
- const end = ends[0] + markers.end.length;
346
-
347
- ranges.push({ start: starts[0], end, content: content.slice(starts[0], end) });
348
- }
349
- ranges.sort((left, right) => left.start - right.start);
350
- for (let index = 1; index < ranges.length; index += 1) {
351
- const previous = ranges[index - 1];
352
- const current = ranges[index];
353
-
354
- if (previous !== undefined && current !== undefined && current.start < previous.end) {
355
- return { kind: "invalid", reason: "managed instruction marker pairs overlap" };
356
- }
357
- }
358
-
359
- if (ranges.length === 0) return { kind: "valid", ranges };
360
-
361
- return {
362
- kind: "valid",
363
- ranges,
364
- content: `${ranges.map((range) => range.content.trim()).join("\n\n")}\n`,
365
- };
366
- };
367
-
368
- const removeManagedInstructionSections = (
369
- content: string,
370
- ranges: ReadonlyArray<ManagedInstructionRange>,
371
- ): string => {
372
- const first = ranges[0];
373
- const last = ranges.at(-1);
374
- const hasOnlyManagedSeparators = ranges.every((range, index) => {
375
- const next = ranges[index + 1];
376
-
377
- return next === undefined || /^\s*$/.test(content.slice(range.end, next.start));
378
- });
379
-
380
- if (first?.start === 0 && last !== undefined && hasOnlyManagedSeparators) {
381
- let end = last.end;
382
-
383
- if (content.startsWith("\r\n", end)) end += 2;
384
- else if (content.startsWith("\n", end)) end += 1;
385
-
386
- return content.slice(end);
387
- }
388
- let result = content;
389
-
390
- for (const range of [...ranges].reverse()) {
391
- result = result.slice(0, range.start) + result.slice(range.end);
392
- }
393
-
394
- return result;
395
- };
396
-
397
- const prependManagedInstructionSections = (content: string, managed: string): string => {
398
- if (content.trim().length === 0) return managed;
399
-
400
- return `${managed}${content}`;
401
- };
402
-
403
- const reconcileManagedInstructionSections = (
404
- content: string,
405
- inspection: Extract<ManagedInstructionInspection, { readonly kind: "valid" }>,
406
- managed: string,
407
- ): string =>
408
- prependManagedInstructionSections(
409
- removeManagedInstructionSections(content, inspection.ranges),
410
- managed,
411
- );
412
-
413
- const renderVitePlusCommandPolicy = (
414
- scripts: Readonly<Record<string, string>>,
415
- managesQualityConfig: boolean,
416
- ): string => {
417
- const hasCheck = managesQualityConfig || scripts.check !== undefined;
418
- const hasTypecheck = managesQualityConfig || scripts.typecheck !== undefined;
419
-
420
- return [
421
- "## Project command policy",
422
- "",
423
- "Vite+ is the unified toolchain and command authority for this repository. It wraps Vite, Rolldown, Vitest, tsdown, Oxlint, Oxfmt, and Vite Task behind the `vp` CLI; Vite+ is distinct from Vite.",
424
- "",
425
- "Run `vp help` for available commands and `vp <command> --help` for command-specific options. Documentation is available locally in `node_modules/vite-plus/docs` and online at https://viteplus.dev/guide/.",
426
- "",
427
- "Use these repository commands:",
428
- "",
429
- "- Install dependencies: `vp install`.",
430
- ...(hasCheck ? ["- Full validation: `vp run check`."] : []),
431
- "- Static checks: `vp check`.",
432
- "- Format check: `vp fmt --check`; format fixes: `vp fmt`.",
433
- "- Lint only: `vp lint`; lint fixes: `vp lint --fix`.",
434
- "- Tests only: `vp test`.",
435
- ...(hasTypecheck ? ["- Typecheck only: `vp run typecheck`."] : []),
436
- "- Other repository tasks and package scripts: `vp run <task>`.",
437
- "- Toolchain or runtime troubleshooting: run `vp env doctor` and include its output when asking for help.",
438
- "",
439
- "Do not use `bun run`, `npm run`, `pnpm run`, or `yarn run` in this repository. Do not invoke underlying tools such as `tsc`, `vitest`, `oxlint`, or `oxfmt` directly; use the Vite+ entry points above.",
440
- ].join("\n");
441
- };
442
-
443
- const renderPackageScriptCommandPolicy = (
444
- manager: PackageManagerName | undefined,
445
- scripts: Readonly<Record<string, string>>,
446
- ): string => {
447
- const installer = manager === undefined ? undefined : PACKAGE_MANAGER_COMMANDS[manager];
448
- const entries = [
449
- ["check", "Full validation"],
450
- ["format:check", "Format check"],
451
- ["format", "Format"],
452
- ["lint", "Lint"],
453
- ["test", "Tests"],
454
- ["typecheck", "Typecheck"],
455
- ] as const;
456
- const commands = entries.flatMap(([script, label]) =>
457
- scripts[script] === undefined ? [] : [`- ${label}: \`bun run ${script}\`.`],
458
- );
459
- const knownScripts = new Set(entries.map(([script]) => script));
460
- const additionalCommands = Object.keys(scripts)
461
- .filter(
462
- (script) =>
463
- // SAFETY: `script` is only asserted to the set's string-literal union for a
464
- // runtime membership check; the assertion does not narrow any returned value.
465
- !knownScripts.has(script as (typeof entries)[number][0]) &&
466
- /^(?:check|validate|fmt|format|lint|test|type-?check)(?::|$)/.test(script),
467
- )
468
- .sort()
469
- .map((script) => `- Script \`${script}\`: \`bun run ${script}\`.`);
470
- const qualityCommands = [...commands, ...additionalCommands];
471
-
472
- return [
473
- "## Project command policy",
474
- "",
475
- "Bun is the package-script runner for this repository:",
476
- "",
477
- ...(installer === undefined
478
- ? []
479
- : [`- Install dependencies with ${installer.label}: \`${installer.install}\`.`]),
480
- ...qualityCommands,
481
- ...(qualityCommands.length === 0 ? ["- No root quality scripts are currently declared."] : []),
482
- "",
483
- "Run only declared scripts through `bun run <script>`. Do not use `npm run`, `pnpm run`, or `yarn run`, invent missing scripts, or invoke underlying tools such as `tsc`, `vitest`, `eslint`, or `prettier` directly. The Bun script-runner requirement does not choose the package manager used to install dependencies.",
484
- ].join("\n");
485
- };
486
-
487
- const resolvePackageRoot = Effect.fn("resolvePackageRoot")(function* () {
488
- const path = yield* Path.Path;
489
- const scriptPath = yield* path.fromFileUrl(new URL(import.meta.url));
490
-
491
- return path.resolve(path.dirname(scriptPath), "..");
492
- });
493
-
494
- const runCommand = Effect.fn("runCommand")(function* (
495
- cwd: string,
496
- command: string,
497
- args: ReadonlyArray<string>,
498
- ) {
499
- const formatted = [command, ...args].join(" ");
500
- const child = yield* ChildProcess.make(command, args, { cwd, stderr: "pipe", stdout: "pipe" });
501
- const [output, exitCode] = yield* Effect.all([
502
- Stream.mkString(Stream.decodeText(child.all)),
503
- child.exitCode,
504
- ]);
505
- const trimmed = output.trim();
506
-
507
- if (exitCode !== 0) {
508
- return yield* CommandError.make({ command: formatted, exitCode, output: trimmed });
509
- }
510
-
511
- return trimmed;
512
- });
513
-
514
- const resolveGitRoot = Effect.fn("resolveGitRoot")(function* (cwd: string) {
515
- return yield* runCommand(cwd, "git", ["rev-parse", "--show-toplevel"]);
516
- });
517
-
518
- const parseStructuredFile = Effect.fn("parseStructuredFile")(function* <A>(
519
- filePath: string,
520
- raw: string,
521
- schema: Schema.ConstraintDecoder<A>,
522
- options: { readonly rejectExcessProperties?: boolean } = {},
523
- ) {
524
- const errors: Array<ParseError> = [];
525
- const parsed = parseJsonc(raw, errors, { allowTrailingComma: true });
526
- const first = errors[0];
527
-
528
- if (first !== undefined) {
529
- return yield* StructuredFileError.make({
530
- path: filePath,
531
- message: `${printParseErrorCode(first.error)} at offset ${first.offset}`,
532
- });
533
- }
534
-
535
- return yield* Schema.decodeUnknownEffect(
536
- schema,
537
- options.rejectExcessProperties ? { onExcessProperty: "error" } : undefined,
538
- )(parsed).pipe(
539
- Effect.mapError((cause) =>
540
- StructuredFileError.make({ path: filePath, message: cause.message }),
541
- ),
542
- );
543
- });
544
-
545
- const readManifest = Effect.fn("readManifest")(function* (manifestPath: string) {
546
- const fs = yield* FileSystem.FileSystem;
547
-
548
- if (!(yield* fs.exists(manifestPath))) {
549
- return yield* ManifestNotFoundError.make({ path: manifestPath });
550
- }
551
- const raw = yield* fs.readFileString(manifestPath);
552
-
553
- return yield* parseStructuredFile(manifestPath, raw, DevKitManifestSchema, {
554
- rejectExcessProperties: true,
555
- });
556
- });
557
-
558
- const readOptionalStructuredFile = Effect.fn("readOptionalStructuredFile")(function* <A>(
559
- filePath: string,
560
- schema: Schema.ConstraintDecoder<A>,
561
- ) {
562
- const fs = yield* FileSystem.FileSystem;
563
-
564
- if (!(yield* fs.exists(filePath))) {
565
- return undefined;
566
- }
567
-
568
- return yield* parseStructuredFile(filePath, yield* fs.readFileString(filePath), schema);
569
- });
570
-
571
- const expandSelection = (
572
- include: ReadonlyArray<string>,
573
- exclude: ReadonlyArray<string>,
574
- availableSkills: ReadonlyArray<string>,
575
- skillFamilies: SkillCatalog,
576
- ) => {
577
- const known = [...new Set([...Object.keys(skillFamilies), ...availableSkills])].sort();
578
- const selected = new Set<string>();
579
-
580
- for (const name of include) {
581
- if (skillFamilies[name]) {
582
- for (const skill of skillFamilies[name]) selected.add(skill);
583
- } else if (availableSkills.includes(name) || parseSkillSelector(name)?.type === "package") {
584
- selected.add(name);
585
- } else {
586
- return Effect.fail(UnknownSkillOrFamilyError.make({ name, known }));
587
- }
588
- }
589
- for (const name of exclude) {
590
- const family = skillFamilies[name];
591
-
592
- if (family) for (const skill of family) selected.delete(skill);
593
- else selected.delete(name);
594
- }
595
-
596
- return Effect.succeed([...selected].sort());
597
- };
598
-
599
- const portablePath = (path: Path.Path, value: string): string =>
600
- path.sep === "/" ? value : value.split(path.sep).join("/");
601
-
602
- const resolveManagedPath = Effect.fn("resolveManagedPath")(function* (
603
- projectDir: string,
604
- candidate: string,
605
- ) {
606
- const path = yield* Path.Path;
607
-
608
- if (candidate.length === 0 || path.isAbsolute(candidate)) {
609
- return yield* UnsafeManagedPathError.make({
610
- path: candidate,
611
- reason: "must be a non-empty project-relative path",
612
- });
613
- }
614
- const absolute = path.resolve(projectDir, candidate);
615
- const relative = path.relative(projectDir, absolute);
616
-
617
- if (
618
- relative.length === 0 ||
619
- relative === ".." ||
620
- relative.startsWith(`..${path.sep}`) ||
621
- path.isAbsolute(relative)
622
- ) {
623
- return yield* UnsafeManagedPathError.make({
624
- path: candidate,
625
- reason: "resolves outside the project",
626
- });
627
- }
628
-
629
- const segments = relative.split(path.sep);
630
- let ancestor = projectDir;
631
-
632
- for (const segment of segments.slice(0, -1)) {
633
- ancestor = path.join(ancestor, segment);
634
- const target = yield* observeSymbolicLink(ancestor);
635
-
636
- if (target.kind === "symlink") {
637
- return yield* UnsafeManagedPathError.make({
638
- path: candidate,
639
- reason: `ancestor is a symlink: ${portablePath(path, path.relative(projectDir, ancestor))}`,
640
- });
641
- }
642
- }
643
-
644
- return { absolute, relative: portablePath(path, relative) } satisfies ManagedPath;
645
- });
646
-
647
- const pathsOverlap = (left: string, right: string): boolean =>
648
- left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`);
649
-
650
- const validateReservedPaths = Effect.fn("validateReservedPaths")(function* (
651
- projectDir: string,
652
- reserved: ReadonlyArray<{ readonly label: string; readonly path: string }>,
653
- outputs: ReadonlyArray<Pick<ManagedOutput | OwnershipReceipt, "path">>,
654
- ) {
655
- const outputPaths = new Set<string>();
656
-
657
- for (const output of outputs) {
658
- outputPaths.add((yield* resolveManagedPath(projectDir, output.path)).relative);
659
- }
660
-
661
- for (let index = 0; index < reserved.length; index += 1) {
662
- const current = reserved[index];
663
-
664
- if (current === undefined) continue;
665
- for (const other of reserved.slice(index + 1)) {
666
- if (pathsOverlap(current.path, other.path)) {
667
- return yield* InvalidProjectStateError.make({
668
- message: `${current.label} path ${current.path} overlaps ${other.label} path ${other.path}`,
669
- });
670
- }
671
- }
672
- for (const outputPath of outputPaths) {
673
- if (pathsOverlap(current.path, outputPath)) {
674
- return yield* InvalidProjectStateError.make({
675
- message: `${current.label} path ${current.path} overlaps managed output ${outputPath}`,
676
- });
677
- }
678
- }
679
- }
680
- });
681
-
682
- const outputIdentity = (output: ManagedOutput) => encodeManagedOutputJson(output);
683
-
684
- const outputOwnershipIdentity = (output: ManagedOutput) =>
685
- encodeOutputOwnershipIdentityJson(
686
- "skill" in output
687
- ? {
688
- resourceId: output.resourceId,
689
- path: output.path,
690
- mode: output.mode,
691
- kind: output.kind,
692
- skill: output.skill,
693
- target: output.target,
694
- }
695
- : {
696
- resourceId: output.resourceId,
697
- path: output.path,
698
- mode: output.mode,
699
- kind: output.kind,
700
- sourcePath: output.sourcePath,
701
- },
702
- );
703
-
704
- const validateInventory = Effect.fn("validateManagedInventory")(function* (
705
- projectDir: string,
706
- outputs: ReadonlyArray<ManagedOutput | OwnershipReceipt>,
707
- label: string,
708
- ) {
709
- const ids = new Set<string>();
710
- const paths = new Set<string>();
711
- const sortedPaths: Array<string> = [];
712
-
713
- for (const output of outputs) {
714
- if (ids.has(output.resourceId)) {
715
- return yield* InvalidProjectStateError.make({
716
- message: `${label} contains duplicate resource id ${output.resourceId}`,
717
- });
718
- }
719
- if (paths.has(output.path)) {
720
- return yield* InvalidProjectStateError.make({
721
- message: `${label} contains duplicate path ${output.path}`,
722
- });
723
- }
724
- ids.add(output.resourceId);
725
- paths.add(output.path);
726
- sortedPaths.push((yield* resolveManagedPath(projectDir, output.path)).relative);
727
- }
728
- sortedPaths.sort();
729
- for (let index = 1; index < sortedPaths.length; index += 1) {
730
- const previous = sortedPaths[index - 1];
731
- const current = sortedPaths[index];
732
-
733
- if (previous === undefined || current === undefined) continue;
734
- if (current.startsWith(`${previous}/`)) {
735
- return yield* InvalidProjectStateError.make({
736
- message: `${label} contains overlapping paths ${previous} and ${current}`,
737
- });
738
- }
739
- }
740
- });
741
-
742
- const validateCrossInventoryPaths = Effect.fn("validateCrossInventoryPaths")(function* (
743
- projectDir: string,
744
- outputs: ReadonlyArray<Pick<ManagedOutput | OwnershipReceipt, "path">>,
745
- ) {
746
- const uniquePaths = new Set<string>();
747
-
748
- for (const output of outputs) {
749
- uniquePaths.add((yield* resolveManagedPath(projectDir, output.path)).relative);
750
- }
751
- const sortedPaths = [...uniquePaths].sort();
752
-
753
- for (let index = 1; index < sortedPaths.length; index += 1) {
754
- const previous = sortedPaths[index - 1];
755
- const current = sortedPaths[index];
756
-
757
- if (previous === undefined || current === undefined) continue;
758
- if (current.startsWith(`${previous}/`)) {
759
- return yield* InvalidProjectStateError.make({
760
- message: `desired and previously owned paths overlap: ${previous} and ${current}`,
761
- });
762
- }
763
- }
764
- });
765
-
766
- const renderAgentInstructions = Effect.fn("renderAgentInstructions")(function* (
767
- packageRoot: string,
768
- projectDir: string,
769
- sourceBySkill: ReadonlyMap<string, ResolvedSkillSource>,
770
- usesRecommendedVitePlusTasks: boolean,
771
- targets: ReturnType<typeof normalizeManifest>["targets"],
772
- ) {
773
- const fs = yield* FileSystem.FileSystem;
774
- const path = yield* Path.Path;
775
- const templatePath = path.join(packageRoot, AGENT_INSTRUCTIONS_TEMPLATE);
776
-
777
- if ((yield* observePath(templatePath)).kind !== "file") {
778
- return yield* InvalidProjectStateError.make({
779
- message: `dev-kit agent instructions template is not a regular file: ${AGENT_INSTRUCTIONS_TEMPLATE}`,
780
- });
781
- }
782
- const template = yield* fs.readFileString(templatePath);
783
-
784
- if (!template.includes(DEV_KIT_SKILL_PATH_PLACEHOLDER)) {
785
- return yield* InvalidProjectStateError.make({
786
- message: `dev-kit agent instructions template is missing ${DEV_KIT_SKILL_PATH_PLACEHOLDER}`,
787
- });
788
- }
789
- if (!template.includes(EFFECT_INSTRUCTIONS_PLACEHOLDER)) {
790
- return yield* InvalidProjectStateError.make({
791
- message: `dev-kit agent instructions template is missing ${EFFECT_INSTRUCTIONS_PLACEHOLDER}`,
792
- });
793
- }
794
- if (!template.includes(PROJECT_COMMAND_POLICY_PLACEHOLDER)) {
795
- return yield* InvalidProjectStateError.make({
796
- message: `dev-kit agent instructions template is missing ${PROJECT_COMMAND_POLICY_PLACEHOLDER}`,
797
- });
798
- }
799
-
800
- const devKitSkill = sourceBySkill.get("dev-kit");
801
- const devKitTarget = (["agents", "claude", "opencode"] as const)
802
- .map((name) => targets[name])
803
- .find((target) => target.enabled);
804
- const devKitSkillPath =
805
- devKitSkill === undefined
806
- ? "node_modules/@danieljvdm/dev-kit/skills/dev-kit/SKILL.md"
807
- : devKitTarget !== undefined
808
- ? portablePath(path, path.join(devKitTarget.path, "dev-kit", "SKILL.md"))
809
- : portablePath(
810
- path,
811
- path.relative(
812
- projectDir,
813
- path.join(devKitSkill.linkPath ?? devKitSkill.path, "SKILL.md"),
814
- ),
815
- );
816
- const directDependencyNames = yield* readDirectDependencyNames(projectDir);
817
- const usesVitePlus = directDependencyNames.includes("vite-plus");
818
- const effectGuideInstructions =
819
- directDependencyNames.includes("effect") &&
820
- (yield* observePath(path.join(projectDir, "node_modules", "effect", "AGENTS.md"))).kind ===
821
- "file"
822
- ? `# Learning more about the Effect
823
-
824
- This repository uses the Effect Typescript library.
825
-
826
- Before writing any Effect code, first read \`node_modules/effect/AGENTS.md\`
827
- **completely**, and follow the links in the file when required.
828
-
829
- If you need to learn more about particular Effect apis and concepts that the
830
- guide doesn't cover, search through the source code in \`node_modules/effect/src\`.
831
-
832
- `
833
- : "";
834
- const atomBoundaryInstructions = (yield* readWorkspaceDependencyNames(projectDir)).includes(
835
- "@effect/atom-react",
836
- )
837
- ? `# Effect Atom client boundary
838
-
839
- This repository consumes APIs through Effect Atom clients (\`@effect/atom-react\`).
840
- Keep business logic in Effect: compose multi-step client workflows as atoms,
841
- declare cross-query invalidation as reactivity keys on mutations, and keep
842
- promise-mode dispatches at the React boundary logic-free — no \`.then\` chains
843
- in components or routes.
844
-
845
- `
846
- : "";
847
- const effectInstructions = `${effectGuideInstructions}${atomBoundaryInstructions}`;
848
- const projectPackage = yield* readProjectPackage(projectDir).pipe(
849
- Effect.catchTag("ProjectPackageError", (error) =>
850
- error.message.startsWith("package.json not found:") ? Effect.void : Effect.fail(error),
851
- ),
852
- );
853
- const manager = yield* detectPackageManager(projectDir, projectPackage?.packageManager);
854
- const commandPolicy = usesVitePlus
855
- ? renderVitePlusCommandPolicy(projectPackage?.scripts ?? {}, usesRecommendedVitePlusTasks)
856
- : renderPackageScriptCommandPolicy(manager, projectPackage?.scripts ?? {});
857
- const devKitInstructions = template
858
- .replaceAll(DEV_KIT_SKILL_PATH_PLACEHOLDER, devKitSkillPath)
859
- .replaceAll(EFFECT_INSTRUCTIONS_PLACEHOLDER, effectInstructions)
860
- .replaceAll(PROJECT_COMMAND_POLICY_PLACEHOLDER, commandPolicy)
861
- .trimEnd();
862
-
863
- return `${devKitInstructions}\n`;
864
- });
865
-
866
- const buildDesiredOutputs = Effect.fn("buildDesiredSkillOutputs")(function* (
867
- packageRoot: string,
868
- projectDir: string,
869
- sourceBySkill: ReadonlyMap<string, ResolvedSkillSource>,
870
- skills: ReadonlyArray<CatalogSkill>,
871
- setup: ReturnType<typeof normalizeManifest>["setup"],
872
- targets: ReturnType<typeof normalizeManifest>["targets"],
873
- ) {
874
- const path = yield* Path.Path;
875
- const outputs: Array<DesiredOutput> = [];
876
-
877
- if (setup.agentInstructions.enabled) {
878
- const managed = yield* resolveManagedPath(projectDir, "AGENTS.md");
879
- const content = yield* renderAgentInstructions(
880
- packageRoot,
881
- projectDir,
882
- sourceBySkill,
883
- setup.vitePlus.workflow.enabled,
884
- targets,
885
- );
886
-
887
- outputs.push({
888
- resourceId: "setup:agent-instructions",
889
- path: managed.relative,
890
- sourcePath: AGENT_INSTRUCTIONS_TEMPLATE,
891
- mode: "copy",
892
- kind: "file",
893
- digest: yield* digestFileContent(content),
894
- destination: managed.absolute,
895
- content,
896
- });
897
- }
898
- if (setup.claudeInstructions.enabled) {
899
- const source = yield* resolveManagedPath(projectDir, "AGENTS.md");
900
- const sourceObservation = setup.agentInstructions.enabled
901
- ? undefined
902
- : yield* observePath(source.absolute);
903
-
904
- if (!setup.agentInstructions.enabled && sourceObservation?.kind !== "file") {
905
- return yield* InvalidProjectStateError.make({
906
- message: "Claude instructions source is not a regular file: AGENTS.md",
907
- });
908
- }
909
- const managed = yield* resolveManagedPath(projectDir, "CLAUDE.md");
910
- const linkTarget = path.relative(path.dirname(managed.absolute), source.absolute);
911
-
912
- outputs.push({
913
- resourceId: "setup:claude-instructions",
914
- path: managed.relative,
915
- sourcePath: source.relative,
916
- mode: "symlink",
917
- kind: "symlink",
918
- digest: yield* digestSymlinkTarget(linkTarget),
919
- destination: managed.absolute,
920
- linkTarget,
921
- });
922
- }
923
- const agentsTarget = targets.agents;
924
- const duplicateOutput = skills.find(
925
- (skill, index) => skills.findIndex((candidate) => candidate.name === skill.name) !== index,
926
- );
927
-
928
- if (duplicateOutput !== undefined) {
929
- const selectors = skills
930
- .filter((skill) => skill.name === duplicateOutput.name)
931
- .map((skill) => skill.selector);
932
-
933
- return yield* InvalidProjectStateError.make({
934
- message: `selected skills would both install as ${duplicateOutput.name}: ${selectors.join(", ")}`,
935
- });
936
- }
937
- for (const skill of skills) {
938
- const resolvedSource = sourceBySkill.get(skill.selector);
939
-
940
- if (resolvedSource === undefined) {
941
- return yield* InvalidProjectStateError.make({
942
- message: `skill source is unavailable: ${skill.selector}`,
943
- });
944
- }
945
- const source = resolvedSource.path;
946
- const sourceObservation = yield* observePath(source);
947
-
948
- if (sourceObservation.kind !== "directory") {
949
- return yield* InvalidProjectStateError.make({
950
- message: `skill source is not a directory: ${source}`,
951
- });
952
- }
953
- for (const targetName of ["agents", "claude", "opencode"] as const) {
954
- const target = targets[targetName];
955
-
956
- if (!target.enabled) continue;
957
- const managed = yield* resolveManagedPath(projectDir, path.join(target.path, skill.name));
958
-
959
- if (target.mode === "copy") {
960
- const output: DesiredSkillOutput = {
961
- resourceId: `skill:${skill.selector}@${targetName}`,
962
- path: managed.relative,
963
- skill: skill.name,
964
- target: targetName,
965
- mode: "copy",
966
- kind: "directory",
967
- digest: sourceObservation.digest,
968
- source,
969
- destination: managed.absolute,
970
- };
971
-
972
- if (resolvedSource.catalog) Object.assign(output, { catalog: resolvedSource.catalog });
973
- outputs.push(output);
974
- continue;
975
- }
976
- const linkSource =
977
- targetName === "agents" || !agentsTarget.enabled
978
- ? (resolvedSource.linkPath ?? source)
979
- : (yield* resolveManagedPath(projectDir, path.join(agentsTarget.path, skill.name)))
980
- .absolute;
981
- const linkTarget = path.relative(path.dirname(managed.absolute), linkSource);
982
- const linkDigest = yield* digestSymlinkTarget(linkTarget);
983
-
984
- const output: DesiredSkillOutput = {
985
- resourceId: `skill:${skill.selector}@${targetName}`,
986
- path: managed.relative,
987
- skill: skill.name,
988
- target: targetName,
989
- mode: "symlink",
990
- kind: "symlink",
991
- digest: linkDigest,
992
- source,
993
- destination: managed.absolute,
994
- linkTarget,
995
- };
996
-
997
- if (resolvedSource.catalog) Object.assign(output, { catalog: resolvedSource.catalog });
998
- outputs.push(output);
999
- }
1000
- }
1001
- yield* validateInventory(projectDir, outputs, "desired outputs");
1002
-
1003
- return outputs.sort((left, right) => left.path.localeCompare(right.path));
1004
- });
1005
-
1006
- const canonicalLock = (lock: DevKitLock): string => `${encodeDevKitLockPrettyJson(lock)}\n`;
1007
- const canonicalState = (state: AppliedState): string => `${encodeAppliedStatePrettyJson(state)}\n`;
1008
-
1009
- const planDesiredOutputs = Effect.fn("planDesiredSkillOutputs")(function* (
1010
- projectDir: string,
1011
- desired: ReadonlyArray<DesiredOutput>,
1012
- currentLock: DevKitLock | undefined,
1013
- currentState: AppliedState | undefined,
1014
- nextLock: DevKitLock,
1015
- ) {
1016
- if (currentLock) yield* validateInventory(projectDir, currentLock.outputs, "dev-kit lock");
1017
- if (currentState) yield* validateInventory(projectDir, currentState.outputs, "applied state");
1018
- yield* validateCrossInventoryPaths(projectDir, [...desired, ...(currentState?.outputs ?? [])]);
1019
- const lockById = new Map(currentLock?.outputs.map((output) => [output.resourceId, output]) ?? []);
1020
- const receiptsById = new Map(
1021
- currentState?.outputs.map((output) => [output.resourceId, output]) ?? [],
1022
- );
1023
- const desiredKeys = new Set(desired.map((output) => `${output.resourceId}\0${output.path}`));
1024
- const actions: Array<SkillPlanAction> = [];
1025
-
1026
- for (const output of desired) {
1027
- const observed = yield* observePath(output.destination);
1028
- const receipt = receiptsById.get(output.resourceId);
1029
- const sameReceipt = receipt?.path === output.path ? receipt : undefined;
1030
- const locked = lockById.get(output.resourceId);
1031
- const matchingLockedOutput =
1032
- locked !== undefined &&
1033
- outputOwnershipIdentity(locked) === outputOwnershipIdentity(output) &&
1034
- observed.kind === locked.kind
1035
- ? locked
1036
- : undefined;
1037
- const lockedOwnsObserved =
1038
- matchingLockedOutput !== undefined &&
1039
- observed.kind !== "missing" &&
1040
- observed.digest === matchingLockedOutput.digest;
1041
-
1042
- if (output.resourceId === "setup:agent-instructions" && "content" in output) {
1043
- if (observed.kind === "missing") {
1044
- actions.push({
1045
- action: "create",
1046
- desired: output,
1047
- observed,
1048
- stagedContent: output.content,
1049
- });
1050
- continue;
1051
- }
1052
- if (observed.kind !== "file") {
1053
- actions.push({
1054
- action: "conflict",
1055
- path: output.path,
1056
- reason: "destination is not a regular file",
1057
- });
1058
- continue;
1059
- }
1060
- const existingContent = yield* FileSystem.FileSystem.pipe(
1061
- Effect.flatMap((fs) => fs.readFileString(output.destination)),
1062
- );
1063
- const inspection = inspectManagedInstructionSections(existingContent);
1064
-
1065
- if (inspection.kind === "invalid") {
1066
- actions.push({ action: "conflict", path: output.path, reason: inspection.reason });
1067
- continue;
1068
- }
1069
- const managedDigest =
1070
- inspection.content === undefined ? undefined : yield* digestFileContent(inspection.content);
1071
- const receiptOwnsManaged =
1072
- sameReceipt !== undefined &&
1073
- (managedDigest === sameReceipt.digest || observed.digest === sameReceipt.digest);
1074
- const lockOwnsManaged =
1075
- matchingLockedOutput !== undefined &&
1076
- (managedDigest === matchingLockedOutput.digest ||
1077
- observed.digest === matchingLockedOutput.digest);
1078
- const legacyOwnsWholeFile =
1079
- (sameReceipt !== undefined &&
1080
- observed.digest === sameReceipt.digest &&
1081
- managedDigest !== sameReceipt.digest) ||
1082
- (matchingLockedOutput !== undefined &&
1083
- lockedOwnsObserved &&
1084
- managedDigest !== matchingLockedOutput.digest);
1085
-
1086
- if (managedDigest === output.digest && !legacyOwnsWholeFile) {
1087
- if (sameReceipt !== undefined || lockOwnsManaged || lockedOwnsObserved) {
1088
- actions.push({
1089
- action: "unchanged",
1090
- desired: output,
1091
- observed,
1092
- adopted: sameReceipt === undefined,
1093
- });
1094
- } else {
1095
- actions.push({
1096
- action: "conflict",
1097
- path: output.path,
1098
- reason: "managed instruction sections exist but are not owned",
1099
- });
1100
- }
1101
- } else if (
1102
- managedDigest === undefined ||
1103
- receiptOwnsManaged ||
1104
- lockOwnsManaged ||
1105
- lockedOwnsObserved
1106
- ) {
1107
- actions.push({
1108
- action: "update",
1109
- desired: output,
1110
- observed,
1111
- stagedContent: legacyOwnsWholeFile
1112
- ? output.content
1113
- : reconcileManagedInstructionSections(existingContent, inspection, output.content),
1114
- });
1115
- } else {
1116
- actions.push({
1117
- action: "conflict",
1118
- path: output.path,
1119
- reason: "managed instruction sections exist but are not owned",
1120
- });
1121
- }
1122
- continue;
1123
- }
1124
-
1125
- if (observed.kind === "missing") {
1126
- actions.push({ action: "create", desired: output, observed });
1127
- } else if (observed.kind === output.kind && observed.digest === output.digest) {
1128
- if (sameReceipt || lockedOwnsObserved) {
1129
- actions.push({ action: "unchanged", desired: output, observed, adopted: !sameReceipt });
1130
- } else {
1131
- actions.push({
1132
- action: "conflict",
1133
- path: output.path,
1134
- reason: "destination exists but is not owned",
1135
- });
1136
- }
1137
- } else if (
1138
- (sameReceipt !== undefined &&
1139
- observed.kind === sameReceipt.kind &&
1140
- observed.digest === sameReceipt.digest) ||
1141
- lockedOwnsObserved
1142
- ) {
1143
- actions.push({ action: "update", desired: output, observed });
1144
- } else {
1145
- actions.push({
1146
- action: "conflict",
1147
- path: output.path,
1148
- reason: sameReceipt
1149
- ? "owned destination was modified"
1150
- : "destination exists but is not owned",
1151
- });
1152
- }
1153
- }
1154
-
1155
- for (const receipt of currentState?.outputs ?? []) {
1156
- if (desiredKeys.has(`${receipt.resourceId}\0${receipt.path}`)) continue;
1157
- const managed = yield* resolveManagedPath(projectDir, receipt.path);
1158
- const observed = yield* observePath(managed.absolute);
1159
-
1160
- if (observed.kind === "missing") continue;
1161
- if (receipt.resourceId === "setup:agent-instructions") {
1162
- if (observed.kind !== "file") {
1163
- actions.push({
1164
- action: "conflict",
1165
- path: receipt.path,
1166
- reason: "stale owned destination is not a regular file",
1167
- });
1168
- continue;
1169
- }
1170
- const existingContent = yield* FileSystem.FileSystem.pipe(
1171
- Effect.flatMap((fs) => fs.readFileString(managed.absolute)),
1172
- );
1173
- const inspection = inspectManagedInstructionSections(existingContent);
1174
-
1175
- if (inspection.kind === "invalid") {
1176
- actions.push({ action: "conflict", path: receipt.path, reason: inspection.reason });
1177
- continue;
1178
- }
1179
- if (inspection.content === undefined) continue;
1180
- const managedDigest = yield* digestFileContent(inspection.content);
1181
-
1182
- if (managedDigest === receipt.digest || observed.digest === receipt.digest) {
1183
- const remaining = removeManagedInstructionSections(existingContent, inspection.ranges);
1184
- const action: SkillPlanAction = {
1185
- action: "remove",
1186
- previous: receipt,
1187
- destination: managed.absolute,
1188
- observed,
1189
- };
1190
-
1191
- if (remaining.trim().length > 0) Object.assign(action, { stagedContent: remaining });
1192
- actions.push(action);
1193
- } else {
1194
- actions.push({
1195
- action: "conflict",
1196
- path: receipt.path,
1197
- reason: "stale owned managed instruction sections were modified",
1198
- });
1199
- }
1200
- continue;
1201
- }
1202
- if (observed.kind === receipt.kind && observed.digest === receipt.digest) {
1203
- actions.push({
1204
- action: "remove",
1205
- previous: receipt,
1206
- destination: managed.absolute,
1207
- observed,
1208
- });
1209
- } else {
1210
- actions.push({
1211
- action: "conflict",
1212
- path: receipt.path,
1213
- reason: "stale owned destination was modified",
1214
- });
1215
- }
1216
- }
1217
-
1218
- const nextState: AppliedState = {
1219
- version: 1,
1220
- appliedLockDigest: yield* digestText(canonicalLock(nextLock)),
1221
- outputs: desired.map(({ resourceId, path, mode, kind, digest }) => ({
1222
- resourceId,
1223
- path,
1224
- mode,
1225
- kind,
1226
- digest,
1227
- })),
1228
- };
1229
-
1230
- return {
1231
- actions: actions.sort((left, right) => {
1232
- const leftPath =
1233
- left.action === "remove"
1234
- ? left.previous.path
1235
- : left.action === "conflict"
1236
- ? left.path
1237
- : left.desired.path;
1238
- const rightPath =
1239
- right.action === "remove"
1240
- ? right.previous.path
1241
- : right.action === "conflict"
1242
- ? right.path
1243
- : right.desired.path;
1244
-
1245
- return leftPath.localeCompare(rightPath);
1246
- }),
1247
- nextState,
1248
- };
1249
- });
1250
-
1251
- const lockedPlanMatches = (current: DevKitLock, next: DevKitLock): boolean =>
1252
- current.toolVersion === next.toolVersion &&
1253
- current.manifestDigest === next.manifestDigest &&
1254
- encodeDevKitSetupJson(current.setup ?? {}) === encodeDevKitSetupJson(next.setup ?? {}) &&
1255
- current.outputs.length === next.outputs.length &&
1256
- current.outputs.every((output, index) => {
1257
- const nextOutput = next.outputs[index];
1258
-
1259
- return nextOutput !== undefined && outputIdentity(output) === outputIdentity(nextOutput);
1260
- });
1261
-
1262
- export const planProjectSkills = Effect.fn("planProjectSkills")(function* (options: SyncOptions) {
1263
- const path = yield* Path.Path;
1264
- const fs = yield* FileSystem.FileSystem;
1265
- const initialDir = path.resolve(options.projectDir ?? ".");
1266
- const discoveredRoot = yield* resolveGitRoot(initialDir).pipe(
1267
- Effect.catchTag("CommandError", (error) =>
1268
- error.output.includes("not a git repository")
1269
- ? Effect.succeed(initialDir)
1270
- : Effect.fail(error),
1271
- ),
1272
- );
1273
- const projectDir = yield* fs.realPath(discoveredRoot);
1274
- const manifestManaged = yield* resolveManagedPath(
1275
- projectDir,
1276
- options.manifestPath ?? DEFAULT_MANIFEST,
1277
- );
1278
- const lockManaged = yield* resolveManagedPath(
1279
- projectDir,
1280
- options.lockfilePath ?? DEFAULT_LOCKFILE,
1281
- );
1282
- const stateManaged = yield* resolveManagedPath(projectDir, options.statePath ?? DEFAULT_STATE);
1283
- const processLockManaged = yield* resolveManagedPath(projectDir, PROJECT_PROCESS_LOCK_PATH);
1284
- const packageRoot = yield* resolvePackageRoot();
1285
- const manifest = normalizeManifest(yield* readManifest(manifestManaged.absolute));
1286
-
1287
- const effectSource = manifest.setup.effectSource.enabled
1288
- ? yield* planEffectSource({
1289
- packageName: manifest.setup.effectSource.packageName,
1290
- path: manifest.setup.effectSource.path,
1291
- projectDir,
1292
- repository: manifest.setup.effectSource.repository,
1293
- })
1294
- : undefined;
1295
- const effectTsgo = manifest.setup.effectTsgo.enabled
1296
- ? yield* planEffectTsgoPatch({
1297
- force: manifest.setup.effectTsgo.force,
1298
- projectDir,
1299
- typescriptPackage: manifest.setup.effectTsgo.typescriptPackage,
1300
- })
1301
- : undefined;
1302
- const vitePlusHooks = manifest.setup.vitePlus.hooks.enabled
1303
- ? yield* planVitePlusHooks(projectDir)
1304
- : undefined;
1305
- const vitePlusWorkflow = manifest.setup.vitePlus.workflow.enabled
1306
- ? yield* planVitePlusWorkflow({
1307
- packageRoot,
1308
- projectDir,
1309
- effectTsgoEnabled: manifest.setup.effectTsgo.enabled,
1310
- typescriptPackage: manifest.setup.effectTsgo.typescriptPackage,
1311
- })
1312
- : undefined;
1313
- const worktrunkConfig = manifest.setup.worktrunk.config.enabled
1314
- ? yield* planWorktrunkConfig(packageRoot, projectDir)
1315
- : undefined;
1316
- const catalog = yield* loadSkillCatalog(packageRoot, projectDir);
1317
- const availableSkills = catalog.skills.map((skill) => skill.selector);
1318
- const skillFamilies = { ...SKILL_FAMILIES, ...catalog.families };
1319
-
1320
- for (const [family, familySkills] of Object.entries(skillFamilies)) {
1321
- if (availableSkills.includes(family)) {
1322
- return yield* InvalidSkillCatalogError.make({
1323
- family,
1324
- message: `family name conflicts with a skill name: ${family}`,
1325
- });
1326
- }
1327
- const missing = familySkills.filter((skill) => !availableSkills.includes(skill));
1328
-
1329
- if (missing.length > 0) {
1330
- return yield* InvalidSkillCatalogError.make({
1331
- family,
1332
- message: `family references missing skills: ${missing.join(", ")}`,
1333
- });
1334
- }
1335
- }
1336
- const selectedSelectors = yield* expandSelection(
1337
- manifest.include,
1338
- manifest.exclude,
1339
- availableSkills,
1340
- skillFamilies,
1341
- );
1342
- const catalogBySelector = new Map(catalog.skills.map((skill) => [skill.selector, skill]));
1343
- const sourceBySkill = yield* withSpinner(
1344
- "Resolving selected skills",
1345
- resolveSkillSources(
1346
- packageRoot,
1347
- projectDir,
1348
- catalog,
1349
- selectedSelectors,
1350
- options.dryRun !== true,
1351
- ),
1352
- );
1353
- const selectedSkills: Array<CatalogSkill> = [];
1354
-
1355
- for (const selector of selectedSelectors) {
1356
- const catalogSkill = catalogBySelector.get(selector);
1357
-
1358
- if (catalogSkill === undefined) {
1359
- return yield* InvalidProjectStateError.make({
1360
- message: `selected skill is unavailable: ${selector}`,
1361
- });
1362
- }
1363
- selectedSkills.push(catalogSkill);
1364
- }
1365
- const desired = yield* buildDesiredOutputs(
1366
- packageRoot,
1367
- projectDir,
1368
- sourceBySkill,
1369
- selectedSkills,
1370
- manifest.setup,
1371
- manifest.targets,
1372
- );
1373
- const nextSetup: NonNullable<DevKitLock["setup"]> = {};
1374
-
1375
- if (effectSource !== undefined) {
1376
- Object.assign(nextSetup, {
1377
- effectSource: {
1378
- packageName: effectSource.packageName,
1379
- packageVersion: effectSource.packageVersion,
1380
- path: effectSource.path,
1381
- repository: effectSource.repository,
1382
- tag: effectSource.tag,
1383
- },
1384
- });
1385
- }
1386
- if (effectTsgo !== undefined) {
1387
- Object.assign(nextSetup, {
1388
- effectTsgo: {
1389
- effectTsgoVersion: effectTsgo.effectTsgoVersion,
1390
- typescriptPackage: effectTsgo.typescriptPackage,
1391
- typescriptVersion: effectTsgo.typescriptVersion,
1392
- },
1393
- });
1394
- }
1395
- const nextLock: DevKitLock = {
1396
- version: 1,
1397
- toolVersion: DEV_KIT_VERSION,
1398
- manifestDigest: yield* digestText(encodeManifestJson(manifest)),
1399
- setup: nextSetup,
1400
- outputs: desired.map((output): ManagedOutput => {
1401
- if ("skill" in output) {
1402
- const managedOutput: ManagedSkillOutput = {
1403
- resourceId: output.resourceId,
1404
- path: output.path,
1405
- skill: output.skill,
1406
- target: output.target,
1407
- mode: output.mode,
1408
- kind: output.kind,
1409
- digest: output.digest,
1410
- };
1411
-
1412
- if (output.catalog) Object.assign(managedOutput, { catalog: output.catalog });
1413
-
1414
- return managedOutput;
1415
- }
1416
- if (output.resourceId === "setup:agent-instructions") {
1417
- return {
1418
- resourceId: output.resourceId,
1419
- path: output.path,
1420
- sourcePath: output.sourcePath,
1421
- mode: output.mode,
1422
- kind: output.kind,
1423
- digest: output.digest,
1424
- };
1425
- }
1426
-
1427
- return {
1428
- resourceId: output.resourceId,
1429
- path: output.path,
1430
- sourcePath: output.sourcePath,
1431
- mode: output.mode,
1432
- kind: output.kind,
1433
- digest: output.digest,
1434
- };
1435
- }),
1436
- };
1437
- const reservedPaths = [
1438
- { label: "manifest", path: manifestManaged.relative },
1439
- { label: "lockfile", path: lockManaged.relative },
1440
- { label: "state", path: stateManaged.relative },
1441
- { label: "process lock", path: processLockManaged.relative },
1442
- ...(effectSource === undefined
1443
- ? []
1444
- : [{ label: "Effect source checkout", path: effectSource.path }]),
1445
- ];
1446
-
1447
- yield* validateReservedPaths(projectDir, reservedPaths, desired);
1448
- const rawLock = yield* readOptionalStructuredFile(lockManaged.absolute, DevKitLockSchema);
1449
- const rawState = yield* readOptionalStructuredFile(stateManaged.absolute, AppliedStateSchema);
1450
- // Migration: dev-kit ≤0.14 owned the check workflow as a managed output. It
1451
- // is a scaffold now, so stale lock entries and receipts are dropped on read —
1452
- // releasing ownership to the repository instead of planning a removal.
1453
- const dropRetiredOutputs = <O extends { readonly resourceId: string }>(
1454
- outputs: ReadonlyArray<O>,
1455
- ) => outputs.filter((output) => output.resourceId !== "setup:vite-plus-github-actions");
1456
- const currentLock =
1457
- rawLock === undefined
1458
- ? undefined
1459
- : { ...rawLock, outputs: dropRetiredOutputs(rawLock.outputs) };
1460
- const currentState =
1461
- rawState === undefined
1462
- ? undefined
1463
- : { ...rawState, outputs: dropRetiredOutputs(rawState.outputs) };
1464
-
1465
- yield* validateReservedPaths(projectDir, reservedPaths, [
1466
- ...(currentLock?.outputs ?? []),
1467
- ...(currentState?.outputs ?? []),
1468
- ]);
1469
- if (options.locked) {
1470
- if (!currentLock) {
1471
- return yield* LockedPlanMismatchError.make({
1472
- message: "dev-kit.lock.json is required with --locked",
1473
- });
1474
- }
1475
- if (!lockedPlanMatches(currentLock, nextLock)) {
1476
- return yield* LockedPlanMismatchError.make({
1477
- message: "manifest or packaged skills differ from dev-kit.lock.json",
1478
- });
1479
- }
1480
- }
1481
- const planned = yield* planDesiredOutputs(
1482
- projectDir,
1483
- desired,
1484
- currentLock,
1485
- currentState,
1486
- nextLock,
1487
- );
1488
- const removesClaudeInstructionsSource = planned.actions.some(
1489
- (action) =>
1490
- action.action === "remove" &&
1491
- action.previous.resourceId === "setup:agent-instructions" &&
1492
- action.stagedContent === undefined,
1493
- );
1494
-
1495
- if (manifest.setup.claudeInstructions.enabled && removesClaudeInstructionsSource) {
1496
- return yield* InvalidProjectStateError.make({
1497
- message:
1498
- "cannot disable agentInstructions while claudeInstructions still links to an AGENTS.md that would be removed",
1499
- });
1500
- }
1501
-
1502
- const plan: SkillPlan = {
1503
- projectDir,
1504
- lockfilePath: lockManaged.absolute,
1505
- statePath: stateManaged.absolute,
1506
- actions: planned.actions,
1507
- nextLock,
1508
- nextState: planned.nextState,
1509
- metadataChanged:
1510
- currentLock === undefined ||
1511
- encodeDevKitLockJson(currentLock) !== encodeDevKitLockJson(nextLock) ||
1512
- currentState === undefined ||
1513
- encodeAppliedStateJson(currentState) !== encodeAppliedStateJson(planned.nextState),
1514
- };
1515
-
1516
- if (effectSource !== undefined) Object.assign(plan, { effectSource });
1517
- if (effectTsgo !== undefined) Object.assign(plan, { effectTsgo });
1518
- if (vitePlusHooks !== undefined) Object.assign(plan, { vitePlusHooks });
1519
- if (vitePlusWorkflow !== undefined) Object.assign(plan, { vitePlusWorkflow });
1520
- if (worktrunkConfig !== undefined) Object.assign(plan, { worktrunkConfig });
1521
-
1522
- return plan;
1523
- });
1524
-
1525
- const formatAction = (action: SkillPlanAction): string => {
1526
- if (action.action === "conflict") return `! ${action.path}: ${action.reason}`;
1527
- if (action.action === "remove")
1528
- return `− ${action.previous.resourceId} → ${action.previous.path}`;
1529
- const verb = action.desired.mode === "copy" ? "copy" : "link";
1530
- const adoption = action.action === "unchanged" && action.adopted ? " (adopt)" : "";
1531
- const marker = action.action === "create" ? "+" : action.action === "update" ? "~" : "=";
1532
- const source = "skill" in action.desired ? action.desired.skill : action.desired.sourcePath;
1533
-
1534
- return `${marker} ${verb} ${source} → ${action.desired.path}${adoption}`;
1535
- };
1536
-
1537
- const operationalChangeCount = (plan: SkillPlan): number =>
1538
- plan.actions.filter((action) => action.action !== "unchanged").length +
1539
- (plan.effectSource?.action === "sync" ? 1 : 0) +
1540
- (plan.effectTsgo !== undefined && !plan.effectTsgo.alreadyPatched ? 1 : 0) +
1541
- (plan.vitePlusHooks?.action === "configure" ? 1 : 0) +
1542
- (plan.vitePlusWorkflow?.action === "scaffold" ? 1 : 0) +
1543
- (plan.worktrunkConfig?.action === "scaffold" ? 1 : 0);
1544
-
1545
- const plannedChangeCount = (plan: SkillPlan): number => {
1546
- const operational = operationalChangeCount(plan);
1547
-
1548
- return operational === 0 && plan.metadataChanged ? 1 : operational;
1549
- };
1550
-
1551
- export const printSkillPlan = Effect.fn("printSkillPlan")(function* (plan: SkillPlan) {
1552
- const changes = plannedChangeCount(plan);
1553
-
1554
- if (changes === 0) {
1555
- yield* printStatus("success", "Already up to date");
1556
-
1557
- return;
1558
- }
1559
- yield* printStatus("plan", `${changes} change${changes === 1 ? "" : "s"} planned`);
1560
- for (const action of plan.actions) {
1561
- if (action.action !== "unchanged" || action.adopted) yield* printDetail(formatAction(action));
1562
- }
1563
- if (plan.effectSource?.action === "sync") {
1564
- yield* printDetail(`+ Effect source ${plan.effectSource.tag} → ${plan.effectSource.path}`);
1565
- }
1566
- if (plan.effectTsgo !== undefined && !plan.effectTsgo.alreadyPatched) {
1567
- yield* printDetail(
1568
- `+ TypeScript patch @effect/tsgo@${plan.effectTsgo.effectTsgoVersion} → ${plan.effectTsgo.typescriptPackage}@${plan.effectTsgo.typescriptVersion}`,
1569
- );
1570
- }
1571
- if (plan.vitePlusHooks?.action === "configure") {
1572
- yield* printDetail(`+ Vite+ hooks → ${plan.vitePlusHooks.hooksPath}`);
1573
- }
1574
- if (plan.vitePlusWorkflow?.action === "scaffold") {
1575
- yield* printDetail(`+ scaffold check workflow → ${plan.vitePlusWorkflow.path}`);
1576
- }
1577
- if (plan.worktrunkConfig?.action === "scaffold") {
1578
- yield* printDetail(`+ scaffold Worktrunk config → ${plan.worktrunkConfig.path}`);
1579
- }
1580
- if (operationalChangeCount(plan) === 0 && plan.metadataChanged) {
1581
- yield* printDetail("+ Dev kit metadata");
1582
- }
1583
- });
1584
-
1585
- const observationsEqual = (left: ObservedPath, right: ObservedPath): boolean =>
1586
- left.kind === right.kind &&
1587
- (left.kind === "missing" || (right.kind !== "missing" && left.digest === right.digest));
1588
-
1589
- const findNestedSymbolicLink = Effect.fn("findNestedSkillSymbolicLink")(function* (root: string) {
1590
- const fs = yield* FileSystem.FileSystem;
1591
- const path = yield* Path.Path;
1592
- const pending = [root];
1593
-
1594
- while (pending.length > 0) {
1595
- const current = pending.pop();
1596
-
1597
- if (current === undefined) continue;
1598
- if ((yield* observeSymbolicLink(current)).kind === "symlink") return current;
1599
- const info = yield* fs.stat(current);
1600
-
1601
- if (info.type !== "Directory") continue;
1602
- for (const entry of yield* fs.readDirectory(current)) {
1603
- pending.push(path.join(current, entry));
1604
- }
1605
- }
1606
-
1607
- return undefined;
1608
- });
1609
-
1610
- const verifyPackageSkillSources = Effect.fn("verifyPackageSkillSources")(function* (
1611
- plan: SkillPlan,
1612
- ) {
1613
- const verified = new Set<string>();
1614
-
1615
- for (const action of plan.actions) {
1616
- if (action.action === "remove" || action.action === "conflict") continue;
1617
- if (!("skill" in action.desired)) continue;
1618
- const catalog = action.desired.catalog;
1619
-
1620
- if (catalog === undefined || !("package" in catalog)) continue;
1621
- const selector = `${catalog.package}#${catalog.skill}`;
1622
- const key = `${selector}\0${catalog.version}\0${catalog.digest}`;
1623
-
1624
- if (verified.has(key)) continue;
1625
- const resolved = yield* resolvePackageSkillSelector(plan.projectDir, selector);
1626
- const observation = yield* observePath(resolved.path);
1627
-
1628
- if (
1629
- resolved.version !== catalog.version ||
1630
- observation.kind !== "directory" ||
1631
- observation.digest !== catalog.digest
1632
- ) {
1633
- return yield* ApplyRaceError.make({ path: action.desired.source });
1634
- }
1635
- verified.add(key);
1636
- }
1637
- });
1638
-
1639
- const applyPlannedSkillChanges = Effect.fn("applyPlannedSkillChanges")(function* (plan: SkillPlan) {
1640
- const conflicts = plan.actions.filter((action) => action.action === "conflict");
1641
-
1642
- if (conflicts.length > 0) {
1643
- return yield* PlanConflictError.make({
1644
- conflicts: conflicts.map((action) =>
1645
- action.action === "conflict" ? `${action.path}: ${action.reason}` : "",
1646
- ),
1647
- });
1648
- }
1649
-
1650
- const fs = yield* FileSystem.FileSystem;
1651
- const path = yield* Path.Path;
1652
- const mutating = plan.actions.filter(
1653
- (
1654
- action,
1655
- ): action is Extract<SkillPlanAction, { readonly action: "create" | "update" | "remove" }> =>
1656
- action.action === "create" || action.action === "update" || action.action === "remove",
1657
- );
1658
-
1659
- for (const action of mutating) {
1660
- const destination =
1661
- action.action === "remove" ? action.destination : action.desired.destination;
1662
-
1663
- if (!observationsEqual(yield* observePath(destination), action.observed)) {
1664
- return yield* ApplyRaceError.make({
1665
- path: action.action === "remove" ? action.previous.path : action.desired.path,
1666
- });
1667
- }
1668
- }
1669
-
1670
- if (mutating.length === 0 && !plan.metadataChanged) {
1671
- return;
1672
- }
1673
-
1674
- const tempDir = yield* fs.makeTempDirectoryScoped({
1675
- directory: plan.projectDir,
1676
- prefix: ".dev-kit-apply-",
1677
- });
1678
- const stageDir = path.join(tempDir, "stage");
1679
- const backupDir = path.join(tempDir, "backup");
1680
- const stagedByAction = new Map<(typeof mutating)[number], string>();
1681
- let stageIndex = 0;
1682
-
1683
- for (const action of mutating) {
1684
- if (action.action === "remove" && action.stagedContent === undefined) continue;
1685
- const staged = path.join(stageDir, String(stageIndex++));
1686
-
1687
- yield* fs.makeDirectory(path.dirname(staged), { recursive: true });
1688
- if (action.stagedContent !== undefined && action.observed.kind === "file") {
1689
- const destination =
1690
- action.action === "remove" ? action.destination : action.desired.destination;
1691
-
1692
- yield* fs.copy(destination, staged, { overwrite: true });
1693
- yield* fs.writeFileString(staged, action.stagedContent);
1694
- } else if (action.action === "remove") {
1695
- if (action.stagedContent === undefined) {
1696
- return yield* InvalidProjectStateError.make({
1697
- message: `missing staged content for ${action.previous.resourceId}`,
1698
- });
1699
- }
1700
- yield* fs.writeFileString(staged, action.stagedContent, { mode: 0o644 });
1701
- } else if (action.desired.mode === "copy") {
1702
- if (action.desired.kind === "file") {
1703
- yield* fs.writeFileString(staged, action.stagedContent ?? action.desired.content, {
1704
- mode: 0o644,
1705
- });
1706
- } else {
1707
- yield* fs.copy(action.desired.source, staged, { overwrite: true });
1708
- const symbolicLink = yield* findNestedSymbolicLink(staged);
1709
-
1710
- if (symbolicLink !== undefined) {
1711
- return yield* InvalidProjectStateError.make({
1712
- message: `staged skill contains a symlink: ${action.desired.path}`,
1713
- });
1714
- }
1715
- }
1716
- } else {
1717
- yield* fs.symlink(action.desired.linkTarget, staged);
1718
- }
1719
- if (action.action !== "remove") {
1720
- const observation = yield* observePath(staged);
1721
-
1722
- if (action.desired.resourceId === "setup:agent-instructions") {
1723
- const content = yield* fs.readFileString(staged);
1724
- const inspection = inspectManagedInstructionSections(content);
1725
- const digest =
1726
- inspection.kind === "valid" && inspection.content !== undefined
1727
- ? yield* digestFileContent(inspection.content)
1728
- : undefined;
1729
-
1730
- if (observation.kind !== "file" || digest !== action.desired.digest) {
1731
- return yield* InvalidProjectStateError.make({
1732
- message: `staged output digest mismatch for ${action.desired.path}`,
1733
- });
1734
- }
1735
- } else if (
1736
- observation.kind !== action.desired.kind ||
1737
- observation.digest !== action.desired.digest
1738
- ) {
1739
- return yield* InvalidProjectStateError.make({
1740
- message: `staged output digest mismatch for ${action.desired.path}`,
1741
- });
1742
- }
1743
- }
1744
- stagedByAction.set(action, staged);
1745
- }
1746
-
1747
- yield* verifyPackageSkillSources(plan);
1748
-
1749
- const stagedLock = path.join(tempDir, "next-lock.json");
1750
- const stagedState = path.join(tempDir, "next-state.json");
1751
-
1752
- yield* fs.writeFileString(stagedLock, canonicalLock(plan.nextLock));
1753
- yield* fs.writeFileString(stagedState, canonicalState(plan.nextState));
1754
-
1755
- type Replacement = {
1756
- readonly destination: string;
1757
- readonly backup: string;
1758
- readonly expected?: ObservedPath;
1759
- readonly path: string;
1760
- readonly staged?: string;
1761
- };
1762
- const replacements: Array<Replacement> = [];
1763
- let replacementIndex = 0;
1764
-
1765
- for (const action of mutating) {
1766
- const staged = stagedByAction.get(action);
1767
-
1768
- if (
1769
- (action.action !== "remove" || action.stagedContent !== undefined) &&
1770
- staged === undefined
1771
- ) {
1772
- return yield* InvalidProjectStateError.make({
1773
- message: `missing staged output for ${
1774
- action.action === "remove" ? action.previous.resourceId : action.desired.resourceId
1775
- }`,
1776
- });
1777
- }
1778
- const replacement: Replacement = {
1779
- destination: action.action === "remove" ? action.destination : action.desired.destination,
1780
- backup: path.join(backupDir, String(replacementIndex++)),
1781
- expected: action.observed,
1782
- path: action.action === "remove" ? action.previous.path : action.desired.path,
1783
- };
1784
-
1785
- if (staged !== undefined) Object.assign(replacement, { staged });
1786
- replacements.push(replacement);
1787
- }
1788
- replacements.push(
1789
- {
1790
- destination: plan.lockfilePath,
1791
- backup: path.join(backupDir, "lock"),
1792
- path: plan.lockfilePath,
1793
- staged: stagedLock,
1794
- },
1795
- {
1796
- destination: plan.statePath,
1797
- backup: path.join(backupDir, "state"),
1798
- path: plan.statePath,
1799
- staged: stagedState,
1800
- },
1801
- );
1802
-
1803
- const installed: Array<string> = [];
1804
- const backedUp: Array<Replacement> = [];
1805
- const rollback = Effect.gen(function* () {
1806
- for (const destination of [...installed].reverse()) {
1807
- yield* fs.remove(destination, { recursive: true, force: true });
1808
- }
1809
- for (const replacement of [...backedUp].reverse()) {
1810
- yield* fs.makeDirectory(path.dirname(replacement.destination), { recursive: true });
1811
- yield* fs.rename(replacement.backup, replacement.destination);
1812
- }
1813
- });
1814
-
1815
- const apply = Effect.gen(function* () {
1816
- for (const replacement of replacements) {
1817
- const observed = yield* observePath(replacement.destination);
1818
-
1819
- if (
1820
- replacement.expected !== undefined &&
1821
- !observationsEqual(observed, replacement.expected)
1822
- ) {
1823
- return yield* ApplyRaceError.make({ path: replacement.path });
1824
- }
1825
- if (observed.kind !== "missing") {
1826
- yield* fs.makeDirectory(path.dirname(replacement.backup), { recursive: true });
1827
- yield* fs.rename(replacement.destination, replacement.backup);
1828
- backedUp.push(replacement);
1829
- }
1830
- if (replacement.staged) {
1831
- yield* fs.makeDirectory(path.dirname(replacement.destination), { recursive: true });
1832
- yield* fs.rename(replacement.staged, replacement.destination);
1833
- installed.push(replacement.destination);
1834
- }
1835
- }
1836
- });
1837
-
1838
- yield* Effect.uninterruptible(
1839
- apply.pipe(
1840
- Effect.catchCause((applyCause) =>
1841
- rollback.pipe(
1842
- Effect.catchCause((rollbackCause) =>
1843
- Effect.failCause(Cause.combine(applyCause, rollbackCause)),
1844
- ),
1845
- Effect.andThen(Effect.failCause(applyCause)),
1846
- ),
1847
- ),
1848
- ),
1849
- );
1850
- });
1851
-
1852
- export const runProjectSkillPlan = Effect.fn("runProjectSkillPlan")(function* (
1853
- options: SyncOptions,
1854
- ) {
1855
- const plan = yield* planProjectSkills(options);
1856
-
1857
- if (options.dryRun) yield* printSkillPlan(plan);
1858
- const conflicts = plan.actions.filter((action) => action.action === "conflict");
1859
-
1860
- if (conflicts.length > 0) {
1861
- return yield* PlanConflictError.make({
1862
- conflicts: conflicts.map((action) =>
1863
- action.action === "conflict" ? `${action.path}: ${action.reason}` : "",
1864
- ),
1865
- });
1866
- }
1867
- if (options.dryRun) return;
1868
-
1869
- yield* acquireProjectProcessLock(plan.projectDir);
1870
- const replanned = yield* planProjectSkills(options);
1871
- const originalSignature = encodePlanSnapshotJson({
1872
- actions: plan.actions,
1873
- effectSource: plan.effectSource,
1874
- effectTsgo: plan.effectTsgo,
1875
- vitePlusHooks: plan.vitePlusHooks,
1876
- vitePlusWorkflow: plan.vitePlusWorkflow,
1877
- worktrunkConfig: plan.worktrunkConfig,
1878
- nextLock: plan.nextLock,
1879
- nextState: plan.nextState,
1880
- });
1881
- const nextSignature = encodePlanSnapshotJson({
1882
- actions: replanned.actions,
1883
- effectSource: replanned.effectSource,
1884
- effectTsgo: replanned.effectTsgo,
1885
- vitePlusHooks: replanned.vitePlusHooks,
1886
- vitePlusWorkflow: replanned.vitePlusWorkflow,
1887
- worktrunkConfig: replanned.worktrunkConfig,
1888
- nextLock: replanned.nextLock,
1889
- nextState: replanned.nextState,
1890
- });
1891
-
1892
- if (originalSignature !== nextSignature) {
1893
- return yield* ApplyRaceError.make({ path: "project state" });
1894
- }
1895
- const changes = plannedChangeCount(replanned);
1896
-
1897
- yield* withSpinner(
1898
- "Applying dev kit",
1899
- Effect.gen(function* () {
1900
- if (replanned.effectSource !== undefined) {
1901
- yield* applyEffectSourcePlan(replanned.effectSource);
1902
- }
1903
- if (replanned.effectTsgo !== undefined) {
1904
- yield* applyEffectTsgoPatchPlan(replanned.effectTsgo);
1905
- }
1906
- if (replanned.vitePlusHooks !== undefined) {
1907
- yield* applyVitePlusHooksPlan(replanned.vitePlusHooks);
1908
- }
1909
- if (replanned.vitePlusWorkflow !== undefined) {
1910
- yield* applyScaffoldPlan(replanned.vitePlusWorkflow);
1911
- }
1912
- if (replanned.worktrunkConfig !== undefined) {
1913
- yield* applyScaffoldPlan(replanned.worktrunkConfig);
1914
- }
1915
- yield* applyPlannedSkillChanges(replanned);
1916
- }),
1917
- );
1918
- const fs = yield* FileSystem.FileSystem;
1919
- const path = yield* Path.Path;
1920
-
1921
- // Catalog checkouts moved to the machine-global cache; drop the regenerable
1922
- // project-local copies left behind by earlier dev-kit versions.
1923
- yield* fs
1924
- .remove(path.join(plan.projectDir, ".dev-kit", "cache", "catalog"), {
1925
- force: true,
1926
- recursive: true,
1927
- })
1928
- .pipe(Effect.ignore);
1929
- yield* maybePruneGlobalCache().pipe(Effect.ignore);
1930
- yield* printStatus(
1931
- "success",
1932
- changes === 0 && !replanned.metadataChanged ? "Dev kit up to date" : "Dev kit ready",
1933
- changes > 0 ? `${changes} change${changes === 1 ? "" : "s"}` : undefined,
1934
- );
1935
- });