@danieljvdm/dev-kit 0.6.0 → 0.7.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 (49) hide show
  1. package/README.md +123 -56
  2. package/dev-kit.example.jsonc +7 -3
  3. package/package.json +19 -16
  4. package/schema/dev-kit.schema.json +38 -0
  5. package/skill-sources.jsonc +8 -12
  6. package/skill-sources.lock.json +3 -9
  7. package/skills/dev-kit/SKILL.md +52 -17
  8. package/skills/effect-ts/agents/openai.yaml +0 -1
  9. package/skills/effect-ts/references/audit-services.md +11 -11
  10. package/skills/effect-ts/references/guide-effect.md +56 -69
  11. package/skills/effect-ts/references/guide-error-handling.md +64 -73
  12. package/skills/effect-ts/references/guide-layers.md +187 -215
  13. package/skills/effect-ts/references/guide-observability.md +91 -116
  14. package/skills/effect-ts/references/guide-retries.md +32 -44
  15. package/skills/effect-ts/references/guide-schedule.md +26 -40
  16. package/skills/effect-ts/references/guide-schema.md +50 -57
  17. package/skills/effect-ts/references/guide-sql.md +47 -50
  18. package/skills/effect-ts/references/guide-testing.md +96 -98
  19. package/skills/effect-ts/references/guide-type-safety-and-boundaries.md +7 -7
  20. package/skills/effect-ts/references/version-and-source.md +0 -1
  21. package/src/bin/dev-kit.ts +61 -28
  22. package/src/catalog-manager.ts +86 -34
  23. package/src/catalog.ts +71 -33
  24. package/src/cli-ui.ts +20 -16
  25. package/src/effect-source.ts +49 -19
  26. package/src/effect-tsgo.ts +66 -35
  27. package/src/gitignore.ts +19 -6
  28. package/src/index.ts +6 -0
  29. package/src/manifest.ts +38 -3
  30. package/src/node-symbolic-link.ts +3 -0
  31. package/src/oxlint-plugin-effect.js +3 -0
  32. package/src/oxlint-plugin-style.d.ts +8 -0
  33. package/src/oxlint-plugin-style.js +8 -0
  34. package/src/oxlint.js +14 -0
  35. package/src/oxlint.ts +14 -0
  36. package/src/package-skill-source.ts +189 -52
  37. package/src/path-digest.ts +47 -13
  38. package/src/project-package.ts +44 -19
  39. package/src/project-process-lock.ts +19 -12
  40. package/src/project-state.ts +11 -0
  41. package/src/skill-manager.ts +134 -55
  42. package/src/skill-selector.ts +8 -2
  43. package/src/source-manifest.ts +2 -6
  44. package/src/sync.ts +417 -107
  45. package/src/vendor.ts +112 -42
  46. package/src/vite-plus-hooks.ts +174 -0
  47. package/src/vite-plus-quality.ts +49 -0
  48. package/templates/vite-plus/github-actions-check.yml +44 -0
  49. package/templates/vite-plus/vite.config.ts +22 -0
@@ -9,6 +9,7 @@ export type SymbolicLinkObservation =
9
9
  const isNotSymbolicLink = (error: PlatformError.PlatformError): boolean => {
10
10
  if (error.reason._tag !== "Unknown") return false;
11
11
  const cause = error.reason.cause;
12
+
12
13
  return cause instanceof Error && "code" in cause && cause.code === "EINVAL";
13
14
  };
14
15
 
@@ -16,6 +17,7 @@ export const observeSymbolicLink = Effect.fn("observeSymbolicLink")(function* (
16
17
  absolutePath: string,
17
18
  ) {
18
19
  const fs = yield* FileSystem.FileSystem;
20
+
19
21
  return yield* fs.readLink(absolutePath).pipe(
20
22
  Effect.map((target): SymbolicLinkObservation => ({ kind: "symlink", target })),
21
23
  Effect.catch((error) => {
@@ -25,6 +27,7 @@ export const observeSymbolicLink = Effect.fn("observeSymbolicLink")(function* (
25
27
  if (isNotSymbolicLink(error)) {
26
28
  return Effect.succeed<SymbolicLinkObservation>({ kind: "not-symlink" });
27
29
  }
30
+
28
31
  return Effect.fail(error);
29
32
  }),
30
33
  );
@@ -33,6 +33,7 @@ const noPromiseAtomMode = {
33
33
  const isMode =
34
34
  (key.type === "Identifier" && key.name === "mode") ||
35
35
  (key.type === "Literal" && key.value === "mode");
36
+
36
37
  if (isMode && value.type === "Literal" && value.value === "promise") {
37
38
  context.report({ node, messageId: "noPromiseAtomMode" });
38
39
  }
@@ -117,8 +118,10 @@ const noAsyncWorkflow = {
117
118
  parent?.type === "Property" &&
118
119
  ((parent.key.type === "Identifier" && parent.key.name === "try") ||
119
120
  (parent.key.type === "Literal" && parent.key.value === "try"));
121
+
120
122
  if (!isCapturedTryThunk) context.report({ node, messageId: "noAsyncWorkflow" });
121
123
  };
124
+
122
125
  return {
123
126
  ArrowFunctionExpression: check,
124
127
  FunctionDeclaration: check,
@@ -0,0 +1,8 @@
1
+ declare const styleOxlintPlugin: {
2
+ readonly meta: { readonly name: "dev-kit-style" };
3
+ readonly rules: {
4
+ readonly "padding-line-between-statements": unknown;
5
+ };
6
+ };
7
+
8
+ export default styleOxlintPlugin;
@@ -0,0 +1,8 @@
1
+ import stylisticPlugin from "@stylistic/eslint-plugin";
2
+
3
+ export default {
4
+ meta: { name: "dev-kit-style" },
5
+ rules: {
6
+ "padding-line-between-statements": stylisticPlugin.rules["padding-line-between-statements"],
7
+ },
8
+ };
package/src/oxlint.js CHANGED
@@ -13,6 +13,10 @@ export const recommendedOxlintConfig = {
13
13
  name: "effect",
14
14
  specifier: "@danieljvdm/dev-kit/oxlint-plugin-effect",
15
15
  },
16
+ {
17
+ name: "stylistic",
18
+ specifier: "@danieljvdm/dev-kit/oxlint-plugin-style",
19
+ },
16
20
  ],
17
21
  plugins: ["import", "react", "vitest"],
18
22
  rules: {
@@ -24,6 +28,16 @@ export const recommendedOxlintConfig = {
24
28
  "import/no-self-import": "error",
25
29
  "react/exhaustive-deps": "error",
26
30
  "react/rules-of-hooks": "error",
31
+ "stylistic/padding-line-between-statements": [
32
+ "error",
33
+ { blankLine: "always", prev: ["const", "let", "var"], next: "*" },
34
+ {
35
+ blankLine: "any",
36
+ prev: ["const", "let", "var"],
37
+ next: ["const", "let", "var"],
38
+ },
39
+ { blankLine: "always", prev: "*", next: "return" },
40
+ ],
27
41
  "typescript/consistent-type-imports": "error",
28
42
  "typescript/no-floating-promises": "off",
29
43
  "typescript/no-explicit-any": "error",
package/src/oxlint.ts CHANGED
@@ -16,6 +16,10 @@ export const recommendedOxlintConfig = {
16
16
  name: "effect",
17
17
  specifier: "@danieljvdm/dev-kit/oxlint-plugin-effect",
18
18
  },
19
+ {
20
+ name: "stylistic",
21
+ specifier: "@danieljvdm/dev-kit/oxlint-plugin-style",
22
+ },
19
23
  ],
20
24
  plugins: ["import", "react", "vitest"],
21
25
  rules: {
@@ -27,6 +31,16 @@ export const recommendedOxlintConfig = {
27
31
  "import/no-self-import": "error",
28
32
  "react/exhaustive-deps": "error",
29
33
  "react/rules-of-hooks": "error",
34
+ "stylistic/padding-line-between-statements": [
35
+ "error",
36
+ { blankLine: "always", prev: ["const", "let", "var"], next: "*" },
37
+ {
38
+ blankLine: "any",
39
+ prev: ["const", "let", "var"],
40
+ next: ["const", "let", "var"],
41
+ },
42
+ { blankLine: "always", prev: "*", next: "return" },
43
+ ],
30
44
  "typescript/consistent-type-imports": "error",
31
45
  "typescript/no-floating-promises": "off",
32
46
  "typescript/no-explicit-any": "error",
@@ -25,65 +25,88 @@ export type DiscoveredPackageSkill = {
25
25
  readonly linkPath: string;
26
26
  };
27
27
 
28
- const PackageMetadataSchema = Schema.fromJsonString(Schema.Struct({
29
- name: Schema.String,
30
- version: Schema.String,
31
- intent: Schema.optional(Schema.Unknown),
32
- repository: Schema.optional(Schema.Unknown),
33
- }));
28
+ const PackageMetadataSchema = Schema.fromJsonString(
29
+ Schema.Struct({
30
+ name: Schema.String,
31
+ version: Schema.String,
32
+ intent: Schema.optional(Schema.Unknown),
33
+ repository: Schema.optional(Schema.Unknown),
34
+ }),
35
+ );
34
36
 
35
37
  const nonEmptyString = (value: unknown): value is string =>
36
38
  typeof value === "string" && value.trim().length > 0;
37
39
 
38
40
  const hasIntentDiscoveryMetadata = (metadata: typeof PackageMetadataSchema.Type): boolean => {
39
41
  const intent = metadata.intent;
40
- if (typeof intent === "object" && intent !== null &&
41
- "version" in intent && intent.version === 1 &&
42
- "repo" in intent && nonEmptyString(intent.repo) &&
43
- "docs" in intent && nonEmptyString(intent.docs)) {
42
+
43
+ if (
44
+ typeof intent === "object" &&
45
+ intent !== null &&
46
+ "version" in intent &&
47
+ intent.version === 1 &&
48
+ "repo" in intent &&
49
+ nonEmptyString(intent.repo) &&
50
+ "docs" in intent &&
51
+ nonEmptyString(intent.docs)
52
+ ) {
44
53
  return true;
45
54
  }
46
55
  const repository = metadata.repository;
47
- return nonEmptyString(repository) ||
48
- (typeof repository === "object" && repository !== null &&
49
- "url" in repository && nonEmptyString(repository.url));
56
+
57
+ return (
58
+ nonEmptyString(repository) ||
59
+ (typeof repository === "object" &&
60
+ repository !== null &&
61
+ "url" in repository &&
62
+ nonEmptyString(repository.url))
63
+ );
50
64
  };
51
65
 
52
66
  const isSafePackageVersion = (value: string): boolean =>
53
- value.length > 0 && value.trim() === value && ![...value].some((character) => {
67
+ value.length > 0 &&
68
+ value.trim() === value &&
69
+ ![...value].some((character) => {
54
70
  const code = character.charCodeAt(0);
71
+
55
72
  return code <= 32 || (code >= 127 && code <= 159);
56
73
  });
57
74
 
58
75
  const isContained = (path: Path.Path, root: string, candidate: string): boolean => {
59
76
  const relative = path.relative(root, candidate);
77
+
60
78
  return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
61
79
  };
62
80
 
63
81
  const frontmatterScalar = (document: string, key: string): string | undefined => {
64
82
  const body = document.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)?.[1];
83
+
65
84
  if (body === undefined) return undefined;
66
85
  const lines = body.split(/\r?\n/);
67
86
  const index = lines.findIndex((line) => line.startsWith(`${key}:`));
87
+
68
88
  if (index < 0) return undefined;
69
89
  const raw = lines[index]?.slice(key.length + 1).trim() ?? "";
70
90
  const block = raw.match(/^([|>])(?:[1-9][+-]?|[+-][1-9]?)?$/)?.[1];
91
+
71
92
  if (block !== undefined) {
72
93
  const values: Array<string> = [];
94
+
73
95
  for (const line of lines.slice(index + 1)) {
74
96
  if (line.length > 0 && !/^\s/.test(line)) break;
75
97
  values.push(line.trim());
76
98
  }
77
99
  const value = block === "|" ? values.join("\n").trim() : values.join(" ").trim();
100
+
78
101
  return value.length > 0 ? value : undefined;
79
102
  }
80
103
  const quoted = raw.match(/^(['"])([\s\S]*?)\1(?:\s+#.*)?$/)?.[2];
81
104
  const value = (quoted ?? raw.replace(/\s+#.*$/, "")).trim();
105
+
82
106
  return value.length > 0 ? value : undefined;
83
107
  };
84
108
 
85
- const skillName = (document: string): string | undefined =>
86
- frontmatterScalar(document, "name");
109
+ const skillName = (document: string): string | undefined => frontmatterScalar(document, "name");
87
110
 
88
111
  const skillDescription = (document: string): string | undefined =>
89
112
  frontmatterScalar(document, "description");
@@ -92,19 +115,35 @@ const rejectNestedSymlinks = Effect.fn("rejectPackageSkillSymlinks")(function* (
92
115
  const fs = yield* FileSystem.FileSystem;
93
116
  const path = yield* Path.Path;
94
117
  const pending = [skillRoot];
118
+
95
119
  while (pending.length > 0) {
96
120
  const current = pending.pop();
121
+
97
122
  if (current === undefined) continue;
98
123
  if ((yield* observeSymbolicLink(current)).kind === "symlink") {
99
- return yield* new PackageSkillSourceError({ message: `package skill contains a symlink: ${current}` });
124
+ return yield* new PackageSkillSourceError({
125
+ message: `package skill contains a symlink: ${current}`,
126
+ });
100
127
  }
101
- const info = yield* fs.stat(current).pipe(
102
- Effect.mapError(() => new PackageSkillSourceError({ message: `could not inspect package skill: ${current}` })),
103
- );
128
+ const info = yield* fs
129
+ .stat(current)
130
+ .pipe(
131
+ Effect.mapError(
132
+ () =>
133
+ new PackageSkillSourceError({ message: `could not inspect package skill: ${current}` }),
134
+ ),
135
+ );
136
+
104
137
  if (info.type !== "Directory") continue;
105
- for (const entry of yield* fs.readDirectory(current).pipe(
106
- Effect.mapError(() => new PackageSkillSourceError({ message: `could not read package skill: ${current}` })),
107
- )) pending.push(path.join(current, entry));
138
+ for (const entry of yield* fs
139
+ .readDirectory(current)
140
+ .pipe(
141
+ Effect.mapError(
142
+ () =>
143
+ new PackageSkillSourceError({ message: `could not read package skill: ${current}` }),
144
+ ),
145
+ ))
146
+ pending.push(path.join(current, entry));
108
147
  }
109
148
  });
110
149
 
@@ -124,33 +163,85 @@ const loadInstalledPackageSkills = Effect.fn("loadInstalledPackageSkills")(funct
124
163
  const path = yield* Path.Path;
125
164
  const packageLink = path.join(projectDir, "node_modules", ...packageName.split("/"));
126
165
  const packageRoot = yield* fs.realPath(packageLink).pipe(
127
- Effect.mapError(() => new PackageSkillSourceError({ message: `package skill package is not installed: ${packageName}` })),
166
+ Effect.mapError(
167
+ () =>
168
+ new PackageSkillSourceError({
169
+ message: `package skill package is not installed: ${packageName}`,
170
+ }),
171
+ ),
128
172
  );
129
173
  const packageInfo = yield* fs.stat(packageRoot).pipe(
130
- Effect.mapError(() => new PackageSkillSourceError({ message: `could not inspect package skill package: ${packageName}` })),
174
+ Effect.mapError(
175
+ () =>
176
+ new PackageSkillSourceError({
177
+ message: `could not inspect package skill package: ${packageName}`,
178
+ }),
179
+ ),
131
180
  );
132
- if (packageInfo.type !== "Directory") return yield* new PackageSkillSourceError({ message: `package skill package is not a directory: ${packageName}` });
181
+
182
+ if (packageInfo.type !== "Directory")
183
+ return yield* new PackageSkillSourceError({
184
+ message: `package skill package is not a directory: ${packageName}`,
185
+ });
133
186
  const metadata = yield* fs.readFileString(path.join(packageRoot, "package.json")).pipe(
134
187
  Effect.flatMap(Schema.decodeUnknownEffect(PackageMetadataSchema)),
135
- Effect.mapError(() => new PackageSkillSourceError({ message: `invalid package.json for package skill package: ${packageName}` })),
188
+ Effect.mapError(
189
+ () =>
190
+ new PackageSkillSourceError({
191
+ message: `invalid package.json for package skill package: ${packageName}`,
192
+ }),
193
+ ),
136
194
  );
137
- if (metadata.name !== packageName) return yield* new PackageSkillSourceError({ message: `package.json name does not match package skill package: ${packageName}` });
138
- if (!isSafePackageVersion(metadata.version)) return yield* new PackageSkillSourceError({ message: `package.json has an invalid version for package skill package: ${packageName}` });
139
- if (!hasIntentDiscoveryMetadata(metadata)) return yield* new PackageSkillSourceError({ message: `package does not declare Intent-compatible discovery metadata: ${packageName}` });
195
+
196
+ if (metadata.name !== packageName)
197
+ return yield* new PackageSkillSourceError({
198
+ message: `package.json name does not match package skill package: ${packageName}`,
199
+ });
200
+ if (!isSafePackageVersion(metadata.version))
201
+ return yield* new PackageSkillSourceError({
202
+ message: `package.json has an invalid version for package skill package: ${packageName}`,
203
+ });
204
+ if (!hasIntentDiscoveryMetadata(metadata))
205
+ return yield* new PackageSkillSourceError({
206
+ message: `package does not declare Intent-compatible discovery metadata: ${packageName}`,
207
+ });
140
208
  const skillsPath = "skills";
141
209
  const skillsLink = path.join(packageLink, skillsPath);
142
- if ((yield* observeSymbolicLink(skillsLink)).kind === "symlink") return yield* new PackageSkillSourceError({ message: `package skills path is a symlink: ${packageName}/${skillsPath}` });
210
+
211
+ if ((yield* observeSymbolicLink(skillsLink)).kind === "symlink")
212
+ return yield* new PackageSkillSourceError({
213
+ message: `package skills path is a symlink: ${packageName}/${skillsPath}`,
214
+ });
143
215
  const skillsRoot = yield* fs.realPath(skillsLink).pipe(
144
- Effect.mapError(() => new PackageSkillSourceError({ message: `package skill package has no skills directory: ${packageName}` })),
216
+ Effect.mapError(
217
+ () =>
218
+ new PackageSkillSourceError({
219
+ message: `package skill package has no skills directory: ${packageName}`,
220
+ }),
221
+ ),
145
222
  );
146
- if (!isContained(path, packageRoot, skillsRoot)) return yield* new PackageSkillSourceError({ message: `package skills path resolves outside package root: ${packageName}/${skillsPath}` });
223
+
224
+ if (!isContained(path, packageRoot, skillsRoot))
225
+ return yield* new PackageSkillSourceError({
226
+ message: `package skills path resolves outside package root: ${packageName}/${skillsPath}`,
227
+ });
147
228
  const skillsInfo = yield* fs.stat(skillsRoot);
148
- if (skillsInfo.type !== "Directory") return yield* new PackageSkillSourceError({ message: `package skills path is not a directory: ${packageName}/${skillsPath}` });
229
+
230
+ if (skillsInfo.type !== "Directory")
231
+ return yield* new PackageSkillSourceError({
232
+ message: `package skills path is not a directory: ${packageName}/${skillsPath}`,
233
+ });
149
234
  const names = (yield* fs.readDirectory(skillsRoot).pipe(
150
- Effect.mapError(() => new PackageSkillSourceError({
151
- message: `package skill package has no readable skills directory: ${packageName}`,
152
- })),
153
- )).filter(isSkillName).sort();
235
+ Effect.mapError(
236
+ () =>
237
+ new PackageSkillSourceError({
238
+ message: `package skill package has no readable skills directory: ${packageName}`,
239
+ }),
240
+ ),
241
+ ))
242
+ .filter(isSkillName)
243
+ .sort();
244
+
154
245
  return {
155
246
  package: packageName,
156
247
  version: metadata.version,
@@ -166,36 +257,57 @@ const inspectPackageSkill = Effect.fn("inspectInstalledPackageSkill")(function*
166
257
  ) {
167
258
  const fs = yield* FileSystem.FileSystem;
168
259
  const path = yield* Path.Path;
260
+
169
261
  if (!isSkillName(name)) {
170
262
  return yield* new PackageSkillSourceError({ message: `invalid package skill name: ${name}` });
171
263
  }
172
264
  const selector = `${installed.package}#${name}`;
173
265
  const linkPath = path.join(installed.packageLink, "skills", name);
266
+
174
267
  if ((yield* observeSymbolicLink(linkPath)).kind === "symlink") {
175
- return yield* new PackageSkillSourceError({ message: `package skill contains a symlink: ${selector}` });
268
+ return yield* new PackageSkillSourceError({
269
+ message: `package skill contains a symlink: ${selector}`,
270
+ });
176
271
  }
177
- const skillRoot = yield* fs.realPath(linkPath).pipe(
178
- Effect.mapError(() => new PackageSkillSourceError({ message: `package skill does not exist: ${selector}` })),
179
- );
180
- if (!isContained(path, installed.skillsRoot, skillRoot) ||
181
- (yield* fs.stat(skillRoot)).type !== "Directory") {
182
- return yield* new PackageSkillSourceError({ message: `package skill is not a contained directory: ${selector}` });
272
+ const skillRoot = yield* fs
273
+ .realPath(linkPath)
274
+ .pipe(
275
+ Effect.mapError(
276
+ () => new PackageSkillSourceError({ message: `package skill does not exist: ${selector}` }),
277
+ ),
278
+ );
279
+
280
+ if (
281
+ !isContained(path, installed.skillsRoot, skillRoot) ||
282
+ (yield* fs.stat(skillRoot)).type !== "Directory"
283
+ ) {
284
+ return yield* new PackageSkillSourceError({
285
+ message: `package skill is not a contained directory: ${selector}`,
286
+ });
183
287
  }
184
288
  yield* rejectNestedSymlinks(skillRoot);
185
289
  const document = yield* fs.readFileString(path.join(skillRoot, "SKILL.md")).pipe(
186
- Effect.mapError(() => new PackageSkillSourceError({ message: `package skill is missing SKILL.md: ${selector}` })),
290
+ Effect.mapError(
291
+ () =>
292
+ new PackageSkillSourceError({
293
+ message: `package skill is missing SKILL.md: ${selector}`,
294
+ }),
295
+ ),
187
296
  );
297
+
188
298
  if (skillName(document) !== name) {
189
299
  return yield* new PackageSkillSourceError({
190
300
  message: `package skill SKILL.md name must match directory: ${selector}`,
191
301
  });
192
302
  }
193
303
  const description = skillDescription(document);
304
+
194
305
  if (description === undefined) {
195
306
  return yield* new PackageSkillSourceError({
196
307
  message: `package skill SKILL.md must declare a description: ${selector}`,
197
308
  });
198
309
  }
310
+
199
311
  return {
200
312
  selector,
201
313
  name,
@@ -208,41 +320,66 @@ const inspectPackageSkill = Effect.fn("inspectInstalledPackageSkill")(function*
208
320
  });
209
321
 
210
322
  /** Read direct project dependencies only; malformed packages are returned as diagnostics, never executed. */
211
- export const discoverPackageSkills = Effect.fn("discoverInstalledPackageSkills")(function* (projectDir: string) {
323
+ export const discoverPackageSkills = Effect.fn("discoverInstalledPackageSkills")(function* (
324
+ projectDir: string,
325
+ ) {
212
326
  const fs = yield* FileSystem.FileSystem;
213
327
  const path = yield* Path.Path;
214
328
  const candidates: Array<DiscoveredPackageSkill> = [];
215
329
  const diagnostics: Array<PackageSkillDiagnostic> = [];
330
+
216
331
  if (!(yield* fs.exists(path.join(projectDir, "package.json")))) {
217
332
  return { candidates, diagnostics };
218
333
  }
219
334
  for (const packageName of yield* readDirectDependencyNames(projectDir)) {
220
335
  if (!isTypeScriptPackageName(packageName)) {
221
- diagnostics.push({ package: packageName, message: `invalid direct dependency package name: ${packageName}` });
336
+ diagnostics.push({
337
+ package: packageName,
338
+ message: `invalid direct dependency package name: ${packageName}`,
339
+ });
222
340
  continue;
223
341
  }
224
342
  const skillsLink = path.join(projectDir, "node_modules", ...packageName.split("/"), "skills");
343
+
225
344
  if (!(yield* fs.exists(skillsLink))) continue;
226
345
  const installed = yield* Effect.result(loadInstalledPackageSkills(projectDir, packageName));
346
+
227
347
  if (Result.isFailure(installed)) {
228
348
  diagnostics.push({ package: packageName, message: installed.failure.message });
229
349
  continue;
230
350
  }
231
351
  for (const name of installed.success.names) {
232
352
  const inspected = yield* Effect.result(inspectPackageSkill(installed.success, name));
353
+
233
354
  if (Result.isSuccess(inspected)) candidates.push(inspected.success);
234
355
  else diagnostics.push({ package: packageName, message: inspected.failure.message });
235
356
  }
236
357
  }
237
- return { candidates: candidates.sort((left, right) => left.selector.localeCompare(right.selector)), diagnostics };
358
+
359
+ return {
360
+ candidates: candidates.sort((left, right) => left.selector.localeCompare(right.selector)),
361
+ diagnostics,
362
+ };
238
363
  });
239
364
 
240
365
  /** Resolve one explicitly selected package skill. Unlike browsing, every malformed or missing part is an error. */
241
- export const resolvePackageSkillSelector = Effect.fn("resolvePackageSkillSelector")(function* (projectDir: string, selector: string) {
366
+ export const resolvePackageSkillSelector = Effect.fn("resolvePackageSkillSelector")(function* (
367
+ projectDir: string,
368
+ selector: string,
369
+ ) {
242
370
  const parsed = parseSkillSelector(selector);
243
- if (parsed?.type !== "package") return yield* new PackageSkillSourceError({ message: `invalid package skill selector: ${selector}` });
371
+
372
+ if (parsed?.type !== "package")
373
+ return yield* new PackageSkillSourceError({
374
+ message: `invalid package skill selector: ${selector}`,
375
+ });
244
376
  const directDependencies = yield* readDirectDependencyNames(projectDir);
245
- if (!directDependencies.includes(parsed.package)) return yield* new PackageSkillSourceError({ message: `package skill package is not a direct dependency: ${parsed.package}` });
377
+
378
+ if (!directDependencies.includes(parsed.package))
379
+ return yield* new PackageSkillSourceError({
380
+ message: `package skill package is not a direct dependency: ${parsed.package}`,
381
+ });
382
+
246
383
  return yield* inspectPackageSkill(
247
384
  yield* loadInstalledPackageSkills(projectDir, parsed.package),
248
385
  parsed.skill,
@@ -2,9 +2,7 @@ import { Crypto, Effect, Encoding, FileSystem, Path, type PlatformError, Schema
2
2
 
3
3
  import { observeSymbolicLink } from "./node-symbolic-link.ts";
4
4
 
5
- export const DigestSchema = Schema.String.check(
6
- Schema.isPattern(/^sha256:[0-9a-f]{64}$/),
7
- );
5
+ export const DigestSchema = Schema.String.check(Schema.isPattern(/^sha256:[0-9a-f]{64}$/));
8
6
  export type Digest = typeof DigestSchema.Type;
9
7
 
10
8
  export type ObservedPath =
@@ -26,21 +24,30 @@ export class PathInspectionError extends Schema.TaggedErrorClass<PathInspectionE
26
24
 
27
25
  const textEncoder = new TextEncoder();
28
26
 
27
+ // Git preserves only the executable distinction for regular files. Canonicalizing
28
+ // the remaining bits keeps digests stable across checkout and copy umasks.
29
+ const canonicalFileMode = (mode: number): number => ((mode & 0o111) === 0 ? 0o644 : 0o755);
30
+ const rawFileMode = (mode: number): number => mode & 0o777;
31
+
29
32
  const frame = (value: string | Uint8Array): Uint8Array => {
30
33
  const bytes = typeof value === "string" ? textEncoder.encode(value) : value;
31
34
  const framed = new Uint8Array(4 + bytes.length);
35
+
32
36
  new DataView(framed.buffer).setUint32(0, bytes.length);
33
37
  framed.set(bytes, 4);
38
+
34
39
  return framed;
35
40
  };
36
41
 
37
42
  const concatenate = (chunks: ReadonlyArray<Uint8Array>): Uint8Array => {
38
43
  const combined = new Uint8Array(chunks.reduce((length, chunk) => length + chunk.length, 0));
39
44
  let offset = 0;
45
+
40
46
  for (const chunk of chunks) {
41
47
  combined.set(chunk, offset);
42
48
  offset += chunk.length;
43
49
  }
50
+
44
51
  return combined;
45
52
  };
46
53
 
@@ -48,10 +55,13 @@ const compareUtf8 = (left: string, right: string): number => {
48
55
  const leftBytes = textEncoder.encode(left);
49
56
  const rightBytes = textEncoder.encode(right);
50
57
  const sharedLength = Math.min(leftBytes.length, rightBytes.length);
58
+
51
59
  for (let index = 0; index < sharedLength; index += 1) {
52
60
  const difference = (leftBytes[index] ?? 0) - (rightBytes[index] ?? 0);
61
+
53
62
  if (difference !== 0) return difference;
54
63
  }
64
+
55
65
  return leftBytes.length - rightBytes.length;
56
66
  };
57
67
 
@@ -60,12 +70,18 @@ const digestFrames = Effect.fn("digestPathFrames")(function* (
60
70
  ) {
61
71
  const crypto = yield* Crypto.Crypto;
62
72
  const digest = yield* crypto.digest("SHA-256", concatenate(values.map(frame)));
73
+
63
74
  return `sha256:${Encoding.encodeHex(digest)}`;
64
75
  });
65
76
 
66
77
  const digestFileSystemPath = Effect.fn("digestFileSystemPath")(function* (
67
78
  absolutePath: string,
68
- ): Effect.fn.Return<ObservedPath, PlatformError.PlatformError | PathInspectionError, FileSystem.FileSystem | Path.Path | Crypto.Crypto> {
79
+ fileMode: (mode: number) => number,
80
+ ): Effect.fn.Return<
81
+ ObservedPath,
82
+ PlatformError.PlatformError | PathInspectionError,
83
+ FileSystem.FileSystem | Path.Path | Crypto.Crypto
84
+ > {
69
85
  const fs = yield* FileSystem.FileSystem;
70
86
  const path = yield* Path.Path;
71
87
  const symbolicLink = yield* observeSymbolicLink(absolutePath);
@@ -78,11 +94,14 @@ const digestFileSystemPath = Effect.fn("digestFileSystemPath")(function* (
78
94
  };
79
95
  }
80
96
 
81
- const info = yield* fs.stat(absolutePath).pipe(
82
- Effect.catch((error) =>
83
- error.reason._tag === "NotFound" ? Effect.void : Effect.fail(error),
84
- ),
85
- );
97
+ const info = yield* fs
98
+ .stat(absolutePath)
99
+ .pipe(
100
+ Effect.catch((error) =>
101
+ error.reason._tag === "NotFound" ? Effect.void : Effect.fail(error),
102
+ ),
103
+ );
104
+
86
105
  if (info === undefined) return { kind: "missing" };
87
106
 
88
107
  if (info.type === "File") {
@@ -90,7 +109,7 @@ const digestFileSystemPath = Effect.fn("digestFileSystemPath")(function* (
90
109
  kind: "file",
91
110
  digest: yield* digestFrames([
92
111
  "file-v1",
93
- String(info.mode & 0o777),
112
+ String(fileMode(info.mode)),
94
113
  yield* fs.readFile(absolutePath),
95
114
  ]),
96
115
  };
@@ -99,9 +118,11 @@ const digestFileSystemPath = Effect.fn("digestFileSystemPath")(function* (
99
118
  if (info.type === "Directory") {
100
119
  const entries = (yield* fs.readDirectory(absolutePath)).sort(compareUtf8);
101
120
  const frames: Array<string | Uint8Array> = ["directory-v1"];
121
+
102
122
  for (const entry of entries) {
103
123
  const childPath = path.join(absolutePath, entry);
104
- const child = yield* digestFileSystemPath(childPath);
124
+ const child = yield* digestFileSystemPath(childPath, fileMode);
125
+
105
126
  if (child.kind === "missing") {
106
127
  return yield* new PathInspectionError({
107
128
  path: childPath,
@@ -111,6 +132,7 @@ const digestFileSystemPath = Effect.fn("digestFileSystemPath")(function* (
111
132
  }
112
133
  frames.push(entry, child.kind, child.digest);
113
134
  }
135
+
114
136
  return { kind: "directory", digest: yield* digestFrames(frames) };
115
137
  }
116
138
 
@@ -122,7 +144,19 @@ const digestFileSystemPath = Effect.fn("digestFileSystemPath")(function* (
122
144
  });
123
145
 
124
146
  export const observePath = Effect.fn("observeManagedPath")(function* (absolutePath: string) {
125
- return yield* digestFileSystemPath(absolutePath).pipe(
147
+ return yield* digestFileSystemPath(absolutePath, canonicalFileMode).pipe(
148
+ Effect.mapError((cause) =>
149
+ cause instanceof PathInspectionError
150
+ ? cause
151
+ : new PathInspectionError({ path: absolutePath, operation: "inspect", cause }),
152
+ ),
153
+ );
154
+ });
155
+
156
+ export const observePathWithRawModes = Effect.fn("observeManagedPathWithRawModes")(function* (
157
+ absolutePath: string,
158
+ ) {
159
+ return yield* digestFileSystemPath(absolutePath, rawFileMode).pipe(
126
160
  Effect.mapError((cause) =>
127
161
  cause instanceof PathInspectionError
128
162
  ? cause
@@ -139,7 +173,7 @@ export const digestFileContent = Effect.fn("digestFileContent")(function* (
139
173
  value: string,
140
174
  mode = 0o644,
141
175
  ) {
142
- return yield* digestFrames(["file-v1", String(mode), value]);
176
+ return yield* digestFrames(["file-v1", String(canonicalFileMode(mode)), value]);
143
177
  });
144
178
 
145
179
  export const digestSymlinkTarget = Effect.fn("digestSymlinkTarget")(function* (target: string) {