@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
@@ -1,6 +1,6 @@
1
- import { applyEdits, modify, parse as parseJsonc, type ParseError } from "jsonc-parser";
2
1
  import { Effect, FileSystem, Path, Schema } from "effect";
3
2
  import { Prompt } from "effect/unstable/cli";
3
+ import { applyEdits, modify, parse as parseJsonc, type ParseError } from "jsonc-parser";
4
4
 
5
5
  import { isInteractiveTerminal, printLine, printStatus } from "./cli-ui.ts";
6
6
  import {
@@ -10,11 +10,7 @@ import {
10
10
  type SkillSourcesLock,
11
11
  type SkillSourcesManifest,
12
12
  } from "./source-manifest.ts";
13
- import {
14
- inspectCatalogRepository,
15
- refreshSkillCatalog,
16
- type CatalogInspection,
17
- } from "./vendor.ts";
13
+ import { inspectCatalogRepository, refreshSkillCatalog, type CatalogInspection } from "./vendor.ts";
18
14
 
19
15
  type CatalogPaths = {
20
16
  readonly repoDir: string;
@@ -52,6 +48,7 @@ const resolvePaths = Effect.fn("resolveCatalogManagerPaths")(function* (
52
48
  ) {
53
49
  const path = yield* Path.Path;
54
50
  const repoDir = path.resolve(options.repoDir ?? ".");
51
+
55
52
  return {
56
53
  repoDir,
57
54
  sourcesPath: path.resolve(repoDir, options.sourcesPath ?? "skill-sources.jsonc"),
@@ -64,35 +61,38 @@ const readJsonc = Effect.fn("readCatalogManagerJsonc")(function* <A>(
64
61
  schema: Schema.ConstraintDecoder<A>,
65
62
  ) {
66
63
  const fs = yield* FileSystem.FileSystem;
64
+
67
65
  if (!(yield* fs.exists(filePath))) {
68
66
  return yield* new CatalogManagerError({ message: `file not found: ${filePath}` });
69
67
  }
70
68
  const raw = yield* fs.readFileString(filePath);
71
69
  const errors: Array<ParseError> = [];
72
70
  const parsed = parseJsonc(raw, errors, { allowTrailingComma: true });
71
+
73
72
  if (errors.length > 0) {
74
73
  return yield* new CatalogManagerError({ message: `could not parse ${filePath}` });
75
74
  }
76
75
  const value = yield* Schema.decodeUnknownEffect(schema)(parsed).pipe(
77
76
  Effect.mapError((error) => new CatalogManagerError({ message: error.message })),
78
77
  );
78
+
79
79
  return { raw, value };
80
80
  });
81
81
 
82
- const readState = Effect.fn("readCatalogManagerState")(function* (
83
- options: CatalogCommandOptions,
84
- ) {
82
+ const readState = Effect.fn("readCatalogManagerState")(function* (options: CatalogCommandOptions) {
85
83
  const fs = yield* FileSystem.FileSystem;
86
84
  const paths = yield* resolvePaths(options);
87
85
  const sources = yield* readJsonc(paths.sourcesPath, SkillSourcesManifestSchema);
88
86
  const lock = (yield* fs.exists(paths.lockfilePath))
89
87
  ? yield* readJsonc(paths.lockfilePath, SkillSourcesLockSchema)
90
88
  : undefined;
89
+
91
90
  return { ...paths, sources, lock };
92
91
  });
93
92
 
94
93
  const compactDescription = (description: string): string => {
95
94
  const first = description.match(/^.*?[.!?](?:\s|$)/)?.[0]?.trim() ?? description;
95
+
96
96
  return first.length > 90 ? `${first.slice(0, 87).trimEnd()}…` : first;
97
97
  };
98
98
 
@@ -103,6 +103,7 @@ const selectSkills = Effect.fn("selectCatalogSkills")(function* (
103
103
  ) {
104
104
  const available = new Set(inspection.skills.map((skill) => skill.name));
105
105
  const unknown = requested.filter((skill) => !available.has(skill));
106
+
106
107
  if (unknown.length > 0) {
107
108
  return yield* new CatalogManagerError({
108
109
  message: `repository does not contain: ${unknown.join(", ")}`,
@@ -110,9 +111,11 @@ const selectSkills = Effect.fn("selectCatalogSkills")(function* (
110
111
  }
111
112
  if (all) {
112
113
  const selected = inspection.skills.map((skill) => skill.name);
114
+
113
115
  return { include: selected, selected };
114
116
  }
115
- if (requested.length > 0) return { include: [...new Set(requested)], selected: [...new Set(requested)] };
117
+ if (requested.length > 0)
118
+ return { include: [...new Set(requested)], selected: [...new Set(requested)] };
116
119
  if (!(yield* isInteractiveTerminal)) {
117
120
  return yield* new CatalogManagerError({
118
121
  message: "choose skills with --skill <name>, or pass --all",
@@ -127,6 +130,7 @@ const selectSkills = Effect.fn("selectCatalogSkills")(function* (
127
130
  })),
128
131
  min: 1,
129
132
  });
133
+
130
134
  return { include: selected, selected };
131
135
  });
132
136
 
@@ -138,6 +142,7 @@ const refreshWithRollback = Effect.fn("refreshCatalogWithRollback")(function* (
138
142
  pinSourceIds: ReadonlyArray<string>,
139
143
  ) {
140
144
  const fs = yield* FileSystem.FileSystem;
145
+
141
146
  yield* refreshSkillCatalog({
142
147
  repoDir: paths.repoDir,
143
148
  sourcesPath: paths.sourcesPath,
@@ -153,6 +158,7 @@ const refreshWithRollback = Effect.fn("refreshCatalogWithRollback")(function* (
153
158
  } else {
154
159
  yield* fs.writeFileString(paths.lockfilePath, previousLock);
155
160
  }
161
+
156
162
  return yield* Effect.failCause(cause);
157
163
  }),
158
164
  ),
@@ -172,11 +178,14 @@ const writeAndRefresh = Effect.fn("writeAndRefreshCatalog")(function* (
172
178
  pinSourceIds: ReadonlyArray<string> = [],
173
179
  ) {
174
180
  const fs = yield* FileSystem.FileSystem;
181
+
175
182
  if (nextSources === state.sources.raw) {
176
183
  yield* printStatus("info", "Catalog already contains that selection");
184
+
177
185
  return;
178
186
  }
179
187
  const previousLock = state.lock?.raw;
188
+
180
189
  yield* fs.writeFileString(state.sourcesPath, nextSources);
181
190
  yield* refreshWithRollback(state, state.sources.raw, previousLock, updateSourceIds, pinSourceIds);
182
191
  });
@@ -195,14 +204,11 @@ export const addCatalogSource = Effect.fn("addCatalogSource")(function* (
195
204
  ...(options.ref ? { ref: options.ref } : {}),
196
205
  ...(options.skillsPath ? { skillsPath: options.skillsPath } : {}),
197
206
  });
198
- const selection = yield* selectSkills(
199
- inspection,
200
- options.skills ?? [],
201
- options.all ?? false,
202
- );
207
+ const selection = yield* selectSkills(inspection, options.skills ?? [], options.all ?? false);
203
208
  const sources = state.sources.value.sources;
204
209
  const byId = sources.findIndex((source) => source.id === inspection.id);
205
210
  const byRepository = sources.findIndex((source) => source.repository === inspection.repository);
211
+
206
212
  if (byId >= 0 && sources[byId]?.repository !== inspection.repository) {
207
213
  return yield* new CatalogManagerError({
208
214
  message: `source id ${inspection.id} is already used by ${sources[byId]?.repository}`,
@@ -215,21 +221,34 @@ export const addCatalogSource = Effect.fn("addCatalogSource")(function* (
215
221
  }
216
222
  const existingIndex = byId >= 0 ? byId : byRepository;
217
223
  let next = state.sources.raw;
224
+
218
225
  if (existingIndex >= 0) {
219
226
  const existing = sources[existingIndex];
227
+
220
228
  if (existing === undefined) {
221
229
  return yield* new CatalogManagerError({ message: "catalog source index is out of bounds" });
222
230
  }
223
- const approved = state.lock?.value.sources.find((source) => source.id === existing.id)?.skills ?? [];
231
+ const approved =
232
+ state.lock?.value.sources.find((source) => source.id === existing.id)?.skills ?? [];
224
233
  const currentInclude = existing.include.includes("*") ? approved : existing.include;
225
234
  const include = [...new Set([...currentInclude, ...selection.include])].sort();
226
235
  const exclude = (existing.exclude ?? []).filter((skill) => !selection.selected.includes(skill));
227
- next = applyEdits(next, modify(next, ["sources", existingIndex, "include"], include, { formattingOptions }));
236
+
237
+ next = applyEdits(
238
+ next,
239
+ modify(next, ["sources", existingIndex, "include"], include, { formattingOptions }),
240
+ );
228
241
  if (exclude.length > 0 || existing.exclude !== undefined) {
229
- next = applyEdits(next, modify(next, ["sources", existingIndex, "exclude"], exclude, { formattingOptions }));
242
+ next = applyEdits(
243
+ next,
244
+ modify(next, ["sources", existingIndex, "exclude"], exclude, { formattingOptions }),
245
+ );
230
246
  }
231
247
  if (existing.ref === "HEAD" && inspection.ref !== "HEAD") {
232
- next = applyEdits(next, modify(next, ["sources", existingIndex, "ref"], inspection.ref, { formattingOptions }));
248
+ next = applyEdits(
249
+ next,
250
+ modify(next, ["sources", existingIndex, "ref"], inspection.ref, { formattingOptions }),
251
+ );
233
252
  }
234
253
  } else {
235
254
  const source: ExternalSkillSource = {
@@ -241,16 +260,20 @@ export const addCatalogSource = Effect.fn("addCatalogSource")(function* (
241
260
  ...(options.licensePath
242
261
  ? { licensePath: options.licensePath }
243
262
  : inspection.licensePath
244
- ? { licensePath: inspection.licensePath }
245
- : {}),
263
+ ? { licensePath: inspection.licensePath }
264
+ : {}),
246
265
  ...(options.stripFrontmatter?.length
247
266
  ? { stripFrontmatter: [...new Set(options.stripFrontmatter)] }
248
267
  : {}),
249
268
  };
250
- next = applyEdits(next, modify(next, ["sources", sources.length], source, {
251
- formattingOptions,
252
- isArrayInsertion: true,
253
- }));
269
+
270
+ next = applyEdits(
271
+ next,
272
+ modify(next, ["sources", sources.length], source, {
273
+ formattingOptions,
274
+ isArrayInsertion: true,
275
+ }),
276
+ );
254
277
  }
255
278
  if (options.dryRun) {
256
279
  yield* printStatus(
@@ -258,6 +281,7 @@ export const addCatalogSource = Effect.fn("addCatalogSource")(function* (
258
281
  existingIndex >= 0 ? "Would update catalog source" : "Would add catalog source",
259
282
  `${inspection.id} · ${selection.selected.length} skill${selection.selected.length === 1 ? "" : "s"}`,
260
283
  );
284
+
261
285
  return;
262
286
  }
263
287
  yield* writeAndRefresh(state, next, [inspection.id]);
@@ -273,29 +297,47 @@ export const removeCatalogEntry = Effect.fn("removeCatalogEntry")(function* (
273
297
  let next = state.sources.raw;
274
298
  let label: string;
275
299
  let pinSourceIds: ReadonlyArray<string> = [];
300
+
276
301
  if (sourceIndex >= 0) {
277
- next = applyEdits(next, modify(next, ["sources", sourceIndex], undefined, { formattingOptions }));
302
+ next = applyEdits(
303
+ next,
304
+ modify(next, ["sources", sourceIndex], undefined, { formattingOptions }),
305
+ );
278
306
  label = `source ${name}`;
279
307
  } else {
280
308
  const owner = state.lock?.value.sources.find((source) => source.skills.includes(name));
281
- if (!owner) return yield* new CatalogManagerError({ message: `catalog entry not found: ${name}` });
309
+
310
+ if (!owner)
311
+ return yield* new CatalogManagerError({ message: `catalog entry not found: ${name}` });
282
312
  const index = sources.findIndex((source) => source.id === owner.id);
283
313
  const source = sources[index];
284
- if (!source) return yield* new CatalogManagerError({ message: `source not found: ${owner.id}` });
314
+
315
+ if (!source)
316
+ return yield* new CatalogManagerError({ message: `source not found: ${owner.id}` });
285
317
  if (source.include.includes("*")) {
286
318
  const exclude = [...new Set([...(source.exclude ?? []), name])];
287
- next = applyEdits(next, modify(next, ["sources", index, "exclude"], exclude, { formattingOptions }));
319
+
320
+ next = applyEdits(
321
+ next,
322
+ modify(next, ["sources", index, "exclude"], exclude, { formattingOptions }),
323
+ );
288
324
  } else {
289
325
  const include = source.include.filter((skill) => skill !== name);
290
- next = include.length === 0
291
- ? applyEdits(next, modify(next, ["sources", index], undefined, { formattingOptions }))
292
- : applyEdits(next, modify(next, ["sources", index, "include"], include, { formattingOptions }));
326
+
327
+ next =
328
+ include.length === 0
329
+ ? applyEdits(next, modify(next, ["sources", index], undefined, { formattingOptions }))
330
+ : applyEdits(
331
+ next,
332
+ modify(next, ["sources", index, "include"], include, { formattingOptions }),
333
+ );
293
334
  }
294
335
  label = `skill ${name}`;
295
336
  pinSourceIds = [owner.id];
296
337
  }
297
338
  if (options.dryRun) {
298
339
  yield* printStatus("plan", "Would remove catalog entry", label);
340
+
299
341
  return;
300
342
  }
301
343
  if (!options.yes) {
@@ -308,8 +350,10 @@ export const removeCatalogEntry = Effect.fn("removeCatalogEntry")(function* (
308
350
  message: `Remove ${label} from the approved catalog?`,
309
351
  initial: false,
310
352
  });
353
+
311
354
  if (!confirmed) {
312
355
  yield* printStatus("info", "Cancelled");
356
+
313
357
  return;
314
358
  }
315
359
  }
@@ -320,14 +364,19 @@ export const listCatalogSources = Effect.fn("listCatalogSources")(function* (
320
364
  options: CatalogCommandOptions,
321
365
  ) {
322
366
  const state = yield* readState(options);
367
+
323
368
  if (state.sources.value.sources.length === 0) {
324
369
  yield* printStatus("info", "Catalog has no external sources");
370
+
325
371
  return;
326
372
  }
327
373
  for (const source of state.sources.value.sources) {
328
374
  const locked = state.lock?.value.sources.find((candidate) => candidate.id === source.id);
375
+
329
376
  yield* printLine(`${source.id} ${source.repository}`);
330
- yield* printLine(` ${locked?.skills.length ?? 0} skills · ${locked?.resolved.slice(0, 12) ?? "not refreshed"}`);
377
+ yield* printLine(
378
+ ` ${locked?.skills.length ?? 0} skills · ${locked?.resolved.slice(0, 12) ?? "not refreshed"}`,
379
+ );
331
380
  }
332
381
  });
333
382
 
@@ -337,8 +386,11 @@ export const showCatalogSource = Effect.fn("showCatalogSource")(function* (
337
386
  ) {
338
387
  const state = yield* readState(options);
339
388
  const source = state.sources.value.sources.find((candidate) => candidate.id === id);
340
- if (!source) return yield* new CatalogManagerError({ message: `catalog source not found: ${id}` });
389
+
390
+ if (!source)
391
+ return yield* new CatalogManagerError({ message: `catalog source not found: ${id}` });
341
392
  const locked = state.lock?.value.sources.find((candidate) => candidate.id === id);
393
+
342
394
  yield* printLine(source.id);
343
395
  yield* printLine(`Repository: ${source.repository}`);
344
396
  yield* printLine(`Tracking: ${source.ref}`);
package/src/catalog.ts CHANGED
@@ -1,12 +1,9 @@
1
- import { parse as parseJsonc, type ParseError } from "jsonc-parser";
2
1
  import { Effect, FileSystem, Path, Schema, Stream } from "effect";
3
2
  import { ChildProcess } from "effect/unstable/process";
3
+ import { parse as parseJsonc, type ParseError } from "jsonc-parser";
4
4
 
5
+ import { discoverPackageSkills, resolvePackageSkillSelector } from "./package-skill-source.ts";
5
6
  import { observePath, type Digest } from "./path-digest.ts";
6
- import {
7
- discoverPackageSkills,
8
- resolvePackageSkillSelector,
9
- } from "./package-skill-source.ts";
10
7
  import {
11
8
  SkillSourcesLockSchema,
12
9
  type LockedSkillSource,
@@ -52,10 +49,7 @@ class CatalogError extends Schema.TaggedErrorClass<CatalogError>()("CatalogError
52
49
  message: Schema.String,
53
50
  }) {}
54
51
 
55
- const runGit = Effect.fn("runCatalogGit")(function* (
56
- cwd: string,
57
- args: ReadonlyArray<string>,
58
- ) {
52
+ const runGit = Effect.fn("runCatalogGit")(function* (cwd: string, args: ReadonlyArray<string>) {
59
53
  const child = yield* ChildProcess.make("git", args, {
60
54
  cwd,
61
55
  stderr: "pipe",
@@ -65,11 +59,13 @@ const runGit = Effect.fn("runCatalogGit")(function* (
65
59
  Stream.mkString(Stream.decodeText(child.all)),
66
60
  child.exitCode,
67
61
  ]);
62
+
68
63
  if (exitCode !== 0) {
69
64
  return yield* new CatalogError({
70
65
  message: `git ${args.join(" ")} failed: ${output.trim()}`,
71
66
  });
72
67
  }
68
+
73
69
  return output.trim();
74
70
  });
75
71
 
@@ -77,13 +73,16 @@ const readCatalogLock = Effect.fn("readCatalogLock")(function* (packageRoot: str
77
73
  const fs = yield* FileSystem.FileSystem;
78
74
  const path = yield* Path.Path;
79
75
  const lockPath = path.join(packageRoot, "skill-sources.lock.json");
76
+
80
77
  if (!(yield* fs.exists(lockPath))) return undefined;
81
78
  const raw = yield* fs.readFileString(lockPath);
82
79
  const errors: Array<ParseError> = [];
83
80
  const value = parseJsonc(raw, errors, { allowTrailingComma: true });
81
+
84
82
  if (errors.length > 0) {
85
83
  return yield* new CatalogError({ message: `invalid skill catalog lock: ${lockPath}` });
86
84
  }
85
+
87
86
  return yield* Schema.decodeUnknownEffect(SkillSourcesLockSchema)(value).pipe(
88
87
  Effect.mapError((error) => new CatalogError({ message: error.message })),
89
88
  );
@@ -93,13 +92,16 @@ const readDescription = Effect.fn("readSkillDescription")(function* (skillPath:
93
92
  const fs = yield* FileSystem.FileSystem;
94
93
  const path = yield* Path.Path;
95
94
  const text = yield* fs.readFileString(path.join(skillPath, "SKILL.md"));
96
- return text
97
- .match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1]
98
- ?.split(/\r?\n/)
99
- .find((line) => line.startsWith("description:"))
100
- ?.slice("description:".length)
101
- .trim()
102
- .replace(/^(['"])(.*)\1$/, "$2") ?? "";
95
+
96
+ return (
97
+ text
98
+ .match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1]
99
+ ?.split(/\r?\n/)
100
+ .find((line) => line.startsWith("description:"))
101
+ ?.slice("description:".length)
102
+ .trim()
103
+ .replace(/^(['"])(.*)\1$/, "$2") ?? ""
104
+ );
103
105
  });
104
106
 
105
107
  export const loadSkillCatalog = Effect.fn("loadSkillCatalog")(function* (
@@ -110,10 +112,12 @@ export const loadSkillCatalog = Effect.fn("loadSkillCatalog")(function* (
110
112
  const path = yield* Path.Path;
111
113
  const skillsDir = path.join(packageRoot, "skills");
112
114
  const skills: Array<CatalogSkill> = [];
115
+
113
116
  if (yield* fs.exists(skillsDir)) {
114
117
  for (const name of (yield* fs.readDirectory(skillsDir)).sort()) {
115
118
  const skillPath = path.join(skillsDir, name);
116
- if ((yield* fs.exists(path.join(skillPath, "SKILL.md")))) {
119
+
120
+ if (yield* fs.exists(path.join(skillPath, "SKILL.md"))) {
117
121
  skills.push({
118
122
  name,
119
123
  selector: name,
@@ -125,6 +129,7 @@ export const loadSkillCatalog = Effect.fn("loadSkillCatalog")(function* (
125
129
  }
126
130
  }
127
131
  const lock = yield* readCatalogLock(packageRoot);
132
+
128
133
  for (const source of lock?.sources ?? []) {
129
134
  for (const name of source.skills) {
130
135
  skills.push({
@@ -137,6 +142,7 @@ export const loadSkillCatalog = Effect.fn("loadSkillCatalog")(function* (
137
142
  }
138
143
  }
139
144
  const discovery = yield* discoverPackageSkills(projectDir);
145
+
140
146
  for (const candidate of discovery.candidates) {
141
147
  skills.push({
142
148
  name: candidate.name,
@@ -148,19 +154,22 @@ export const loadSkillCatalog = Effect.fn("loadSkillCatalog")(function* (
148
154
  });
149
155
  }
150
156
  const duplicates = skills.filter(
151
- (skill, index) => skills.findIndex((candidate) => candidate.selector === skill.selector) !== index,
157
+ (skill, index) =>
158
+ skills.findIndex((candidate) => candidate.selector === skill.selector) !== index,
152
159
  );
160
+
153
161
  if (duplicates.length > 0) {
154
162
  return yield* new CatalogError({
155
163
  message: `duplicate catalog skill selector: ${duplicates[0]?.selector ?? "unknown"}`,
156
164
  });
157
165
  }
158
- const externalFamilies = (lock?.sources ?? []).map((source) =>
159
- [source.id, source.skills] as const
166
+ const externalFamilies = (lock?.sources ?? []).map(
167
+ (source) => [source.id, source.skills] as const,
160
168
  );
161
169
  const duplicateFamily = externalFamilies.find(
162
170
  ([id], index) => externalFamilies.findIndex(([candidate]) => candidate === id) !== index,
163
171
  );
172
+
164
173
  if (duplicateFamily !== undefined) {
165
174
  return yield* new CatalogError({
166
175
  message: `duplicate catalog family: ${duplicateFamily[0]}`,
@@ -170,6 +179,7 @@ export const loadSkillCatalog = Effect.fn("loadSkillCatalog")(function* (
170
179
  effect: ["effect-ts", "effect-atom-data-fetching"],
171
180
  ...Object.fromEntries(externalFamilies),
172
181
  };
182
+
173
183
  return {
174
184
  skills: skills.sort((left, right) => left.selector.localeCompare(right.selector)),
175
185
  families,
@@ -180,14 +190,18 @@ export const loadSkillCatalog = Effect.fn("loadSkillCatalog")(function* (
180
190
  const stripFrontmatterKeys = (text: string, keys: ReadonlyArray<string>): string => {
181
191
  if (keys.length === 0) return text;
182
192
  const frontmatter = text.match(/^---\r?\n([\s\S]*?)\r?\n---(\r?\n|$)/);
193
+
183
194
  if (!frontmatter?.[1]) return text;
184
195
  const stripped = new Set(keys);
185
196
  let skipping = false;
186
197
  const lines = frontmatter[1].split(/\r?\n/).filter((line) => {
187
198
  const key = line.match(/^([A-Za-z0-9_-]+):/)?.[1];
199
+
188
200
  if (key) skipping = stripped.has(key);
201
+
189
202
  return !skipping;
190
203
  });
204
+
191
205
  return `---\n${lines.join("\n")}\n---${frontmatter[2]}${text.slice(frontmatter[0].length)}`;
192
206
  };
193
207
 
@@ -208,6 +222,7 @@ const materializeSource = Effect.fn("materializeCatalogSource")(function* (
208
222
  );
209
223
  const checkout = path.join(root, "checkout");
210
224
  const ready = path.join(root, ".ready");
225
+
211
226
  if (!(yield* fs.exists(ready))) {
212
227
  yield* fs.remove(root, { force: true, recursive: true });
213
228
  yield* fs.makeDirectory(checkout, { recursive: true });
@@ -216,23 +231,33 @@ const materializeSource = Effect.fn("materializeCatalogSource")(function* (
216
231
  yield* runGit(checkout, ["fetch", "--quiet", "--depth", "1", "origin", source.resolved]);
217
232
  yield* runGit(checkout, ["checkout", "--quiet", "--detach", "FETCH_HEAD"]);
218
233
  const actual = yield* runGit(checkout, ["rev-parse", "HEAD"]);
234
+
219
235
  if (actual !== source.resolved) {
220
- return yield* new CatalogError({ message: `source ${source.id} resolved to ${actual}, expected ${source.resolved}` });
236
+ return yield* new CatalogError({
237
+ message: `source ${source.id} resolved to ${actual}, expected ${source.resolved}`,
238
+ });
221
239
  }
222
240
  const symlinks = yield* runGit(checkout, ["ls-files", "--stage", "--", source.skillsPath]);
241
+
223
242
  if (symlinks.split(/\r?\n/).some((line) => line.startsWith("120000 "))) {
224
- return yield* new CatalogError({ message: `source ${source.id} contains symlinks; refusing to install it` });
243
+ return yield* new CatalogError({
244
+ message: `source ${source.id} contains symlinks; refusing to install it`,
245
+ });
225
246
  }
226
247
  for (const skill of source.skills) {
227
248
  const from = path.join(checkout, source.skillsPath, skill);
228
249
  const to = path.join(root, "skills", skill);
229
250
  const observation = yield* observePath(from);
251
+
230
252
  if (observation.kind !== "directory") {
231
- return yield* new CatalogError({ message: `source ${source.id} is missing skill ${skill}` });
253
+ return yield* new CatalogError({
254
+ message: `source ${source.id} is missing skill ${skill}`,
255
+ });
232
256
  }
233
257
  yield* fs.copy(from, to, { overwrite: true });
234
258
  if (source.stripFrontmatter?.length) {
235
259
  const document = path.join(to, "SKILL.md");
260
+
236
261
  yield* fs.writeFileString(
237
262
  document,
238
263
  stripFrontmatterKeys(yield* fs.readFileString(document), source.stripFrontmatter),
@@ -244,21 +269,30 @@ const materializeSource = Effect.fn("materializeCatalogSource")(function* (
244
269
  for (const skill of selected) {
245
270
  const observation = yield* observePath(path.join(root, "skills", skill));
246
271
  const approvedDigest = source.digests?.[skill];
247
- if (approvedDigest !== undefined &&
248
- (observation.kind !== "directory" || observation.digest !== approvedDigest)) {
272
+
273
+ if (
274
+ approvedDigest !== undefined &&
275
+ (observation.kind !== "directory" || observation.digest !== approvedDigest)
276
+ ) {
249
277
  return yield* new CatalogError({
250
278
  message: `cached skill ${skill} does not match the approved catalog; remove ${root} and retry`,
251
279
  });
252
280
  }
253
281
  }
254
- return new Map(selected.map((skill) => [skill, {
255
- path: path.join(root, "skills", skill),
256
- catalog: {
257
- source: source.id,
258
- repository: source.repository,
259
- resolved: source.resolved,
260
- },
261
- } satisfies ResolvedSkillSource]));
282
+
283
+ return new Map(
284
+ selected.map((skill) => [
285
+ skill,
286
+ {
287
+ path: path.join(root, "skills", skill),
288
+ catalog: {
289
+ source: source.id,
290
+ repository: source.repository,
291
+ resolved: source.resolved,
292
+ },
293
+ } satisfies ResolvedSkillSource,
294
+ ]),
295
+ );
262
296
  });
263
297
 
264
298
  export const resolveSkillSources = Effect.fn("resolveSkillSources")(function* (
@@ -270,6 +304,7 @@ export const resolveSkillSources = Effect.fn("resolveSkillSources")(function* (
270
304
  ) {
271
305
  const path = yield* Path.Path;
272
306
  const sources = new Map<string, ResolvedSkillSource>();
307
+
273
308
  for (const skill of catalog.skills.filter((skill) => skill.bundled)) {
274
309
  if (selected.includes(skill.selector)) {
275
310
  sources.set(skill.selector, { path: path.join(packageRoot, "skills", skill.name) });
@@ -277,6 +312,7 @@ export const resolveSkillSources = Effect.fn("resolveSkillSources")(function* (
277
312
  }
278
313
  for (const source of catalog.lock?.sources ?? []) {
279
314
  const wanted = source.skills.filter((skill) => selected.includes(skill));
315
+
280
316
  if (wanted.length === 0) continue;
281
317
  for (const [name, sourcePath] of yield* materializeSource(projectDir, source, wanted, cache)) {
282
318
  sources.set(name, sourcePath);
@@ -285,6 +321,7 @@ export const resolveSkillSources = Effect.fn("resolveSkillSources")(function* (
285
321
  for (const selector of selected.filter((value) => value.includes("#"))) {
286
322
  const resolved = yield* resolvePackageSkillSelector(projectDir, selector);
287
323
  const observation = yield* observePath(resolved.path);
324
+
288
325
  if (observation.kind !== "directory") {
289
326
  return yield* new CatalogError({ message: `package skill is missing: ${selector}` });
290
327
  }
@@ -299,5 +336,6 @@ export const resolveSkillSources = Effect.fn("resolveSkillSources")(function* (
299
336
  },
300
337
  });
301
338
  }
339
+
302
340
  return sources;
303
341
  });
package/src/cli-ui.ts CHANGED
@@ -26,6 +26,7 @@ const terminalCapabilities = Effect.fn("terminalCapabilities")(function* () {
26
26
  const term = yield* Config.string("TERM").pipe(Config.withDefault(""), Effect.orDie);
27
27
  const ci = yield* Config.string("CI").pipe(Config.withDefault(""), Effect.orDie);
28
28
  const interactive = columns > 0 && term !== "dumb" && ci !== "true" && ci !== "1";
29
+
29
30
  return {
30
31
  color: interactive && Option.isNone(noColor),
31
32
  interactive,
@@ -41,8 +42,8 @@ const formatDetail = (detail: string | undefined, color: boolean): string =>
41
42
  detail === undefined || detail.length === 0
42
43
  ? ""
43
44
  : color
44
- ? ` ${ANSI.dim}${detail}${ANSI.reset}`
45
- : ` ${detail}`;
45
+ ? ` ${ANSI.dim}${detail}${ANSI.reset}`
46
+ : ` ${detail}`;
46
47
 
47
48
  export const printStatus = Effect.fn("printCliStatus")(function* (
48
49
  kind: StatusKind,
@@ -52,22 +53,23 @@ export const printStatus = Effect.fn("printCliStatus")(function* (
52
53
  const capabilities = yield* terminalCapabilities();
53
54
  const [symbol, color] = statusAppearance[kind];
54
55
  const prefix = capabilities.color ? `${color}${symbol}${ANSI.reset}` : symbol;
55
- yield* capabilities.terminal.display(
56
- `${prefix} ${label}${formatDetail(detail, capabilities.color)}\n`,
57
- ).pipe(Effect.orDie);
56
+
57
+ yield* capabilities.terminal
58
+ .display(`${prefix} ${label}${formatDetail(detail, capabilities.color)}\n`)
59
+ .pipe(Effect.orDie);
58
60
  });
59
61
 
60
62
  export const printDetail = Effect.fn("printCliDetail")(function* (text: string) {
61
63
  const capabilities = yield* terminalCapabilities();
62
- yield* capabilities.terminal.display(
63
- capabilities.color
64
- ? ` ${ANSI.dim}${text}${ANSI.reset}\n`
65
- : ` ${text}\n`,
66
- ).pipe(Effect.orDie);
64
+
65
+ yield* capabilities.terminal
66
+ .display(capabilities.color ? ` ${ANSI.dim}${text}${ANSI.reset}\n` : ` ${text}\n`)
67
+ .pipe(Effect.orDie);
67
68
  });
68
69
 
69
70
  export const printLine = Effect.fn("printCliLine")(function* (text = "") {
70
71
  const capabilities = yield* terminalCapabilities();
72
+
71
73
  yield* capabilities.terminal.display(`${text}\n`).pipe(Effect.orDie);
72
74
  });
73
75
 
@@ -77,6 +79,7 @@ export const withSpinner = <A, E, R>(
77
79
  ): Effect.Effect<A, E, R | Terminal.Terminal> =>
78
80
  Effect.gen(function* () {
79
81
  const capabilities = yield* terminalCapabilities();
82
+
80
83
  if (!capabilities.interactive) return yield* effect;
81
84
 
82
85
  const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
@@ -84,15 +87,15 @@ export const withSpinner = <A, E, R>(
84
87
  const animate = Effect.forever(
85
88
  Effect.suspend(() => {
86
89
  const frame = frames[index++ % frames.length];
87
- const symbol = capabilities.color
88
- ? `${ANSI.cyan}${frame}${ANSI.reset}`
89
- : frame;
90
- return capabilities.terminal.display(
91
- `${ANSI.clearLine}${symbol} ${label}`,
92
- ).pipe(Effect.orDie, Effect.andThen(Effect.sleep("80 millis")));
90
+ const symbol = capabilities.color ? `${ANSI.cyan}${frame}${ANSI.reset}` : frame;
91
+
92
+ return capabilities.terminal
93
+ .display(`${ANSI.clearLine}${symbol} ${label}`)
94
+ .pipe(Effect.orDie, Effect.andThen(Effect.sleep("80 millis")));
93
95
  }),
94
96
  );
95
97
  const fiber = yield* Effect.forkChild(animate);
98
+
96
99
  return yield* effect.pipe(
97
100
  Effect.ensuring(
98
101
  Effect.sync(() => fiber.interruptUnsafe()).pipe(
@@ -106,5 +109,6 @@ export const printError = Effect.fn("printCliError")(function* (message: string)
106
109
  const capabilities = yield* terminalCapabilities();
107
110
  const [symbol, color] = statusAppearance.error;
108
111
  const prefix = capabilities.color ? `${color}${symbol}${ANSI.reset}` : symbol;
112
+
109
113
  yield* Console.error(`${prefix} ${message}`);
110
114
  });