@danieljvdm/dev-kit 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +290 -0
- package/bin/dev-kit.mjs +3 -0
- package/dev-kit.example.jsonc +13 -0
- package/package.json +69 -0
- package/schema/dev-kit.schema.json +128 -0
- package/schema/skill-sources.schema.json +83 -0
- package/skill-sources.jsonc +55 -0
- package/skill-sources.lock.json +136 -0
- package/skills/dev-kit/SKILL.md +145 -0
- package/skills/dev-kit/agents/openai.yaml +4 -0
- package/skills/effect-ts/SKILL.md +242 -0
- package/skills/effect-ts/UPSTREAM.md +28 -0
- package/skills/effect-ts/agents/openai.yaml +5 -0
- package/skills/effect-ts/references/audit-services.md +144 -0
- package/skills/effect-ts/references/features.md +525 -0
- package/skills/effect-ts/references/guide-cli.md +106 -0
- package/skills/effect-ts/references/guide-effect.md +453 -0
- package/skills/effect-ts/references/guide-error-handling.md +574 -0
- package/skills/effect-ts/references/guide-http-boundaries.md +55 -0
- package/skills/effect-ts/references/guide-layers.md +1017 -0
- package/skills/effect-ts/references/guide-observability.md +771 -0
- package/skills/effect-ts/references/guide-retries.md +446 -0
- package/skills/effect-ts/references/guide-schedule.md +357 -0
- package/skills/effect-ts/references/guide-schema.md +671 -0
- package/skills/effect-ts/references/guide-sql.md +539 -0
- package/skills/effect-ts/references/guide-testing.md +534 -0
- package/skills/effect-ts/references/guide-type-safety-and-boundaries.md +131 -0
- package/skills/effect-ts/references/version-and-source.md +87 -0
- package/src/bin/dev-kit.ts +372 -0
- package/src/catalog-manager.ts +345 -0
- package/src/catalog.ts +246 -0
- package/src/cli-ui.ts +110 -0
- package/src/effect-source.ts +325 -0
- package/src/effect-tsgo.ts +256 -0
- package/src/gitignore.ts +212 -0
- package/src/index.ts +98 -0
- package/src/manifest.ts +133 -0
- package/src/node-symbolic-link.ts +31 -0
- package/src/path-digest.ts +140 -0
- package/src/project-process-lock.ts +76 -0
- package/src/project-state.ts +67 -0
- package/src/skill-manager.ts +326 -0
- package/src/source-manifest.ts +51 -0
- package/src/sync.ts +900 -0
- package/src/tool-metadata.ts +3 -0
- package/src/typescript-package-name.ts +5 -0
- package/src/vendor.ts +848 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import { applyEdits, modify, parse as parseJsonc, type ParseError } from "jsonc-parser";
|
|
2
|
+
import { Effect, FileSystem, Path, Schema } from "effect";
|
|
3
|
+
import { Prompt } from "effect/unstable/cli";
|
|
4
|
+
|
|
5
|
+
import { isInteractiveTerminal, printLine, printStatus } from "./cli-ui.ts";
|
|
6
|
+
import {
|
|
7
|
+
SkillSourcesLockSchema,
|
|
8
|
+
SkillSourcesManifestSchema,
|
|
9
|
+
type ExternalSkillSource,
|
|
10
|
+
type SkillSourcesLock,
|
|
11
|
+
type SkillSourcesManifest,
|
|
12
|
+
} from "./source-manifest.ts";
|
|
13
|
+
import {
|
|
14
|
+
inspectCatalogRepository,
|
|
15
|
+
refreshSkillCatalog,
|
|
16
|
+
type CatalogInspection,
|
|
17
|
+
} from "./vendor.ts";
|
|
18
|
+
|
|
19
|
+
type CatalogPaths = {
|
|
20
|
+
readonly repoDir: string;
|
|
21
|
+
readonly sourcesPath: string;
|
|
22
|
+
readonly lockfilePath: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type CatalogCommandOptions = {
|
|
26
|
+
readonly repoDir?: string;
|
|
27
|
+
readonly sourcesPath?: string;
|
|
28
|
+
readonly lockfilePath?: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type CatalogAddOptions = CatalogCommandOptions & {
|
|
32
|
+
readonly repository: string;
|
|
33
|
+
readonly id?: string;
|
|
34
|
+
readonly ref?: string;
|
|
35
|
+
readonly skillsPath?: string;
|
|
36
|
+
readonly skills?: ReadonlyArray<string>;
|
|
37
|
+
readonly all?: boolean;
|
|
38
|
+
readonly licensePath?: string;
|
|
39
|
+
readonly stripFrontmatter?: ReadonlyArray<string>;
|
|
40
|
+
readonly dryRun?: boolean;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
class CatalogManagerError extends Schema.TaggedErrorClass<CatalogManagerError>()(
|
|
44
|
+
"CatalogManagerError",
|
|
45
|
+
{ message: Schema.String },
|
|
46
|
+
) {}
|
|
47
|
+
|
|
48
|
+
const formattingOptions = { insertSpaces: true, tabSize: 2 } as const;
|
|
49
|
+
|
|
50
|
+
const resolvePaths = Effect.fn("resolveCatalogManagerPaths")(function* (
|
|
51
|
+
options: CatalogCommandOptions,
|
|
52
|
+
) {
|
|
53
|
+
const path = yield* Path.Path;
|
|
54
|
+
const repoDir = path.resolve(options.repoDir ?? ".");
|
|
55
|
+
return {
|
|
56
|
+
repoDir,
|
|
57
|
+
sourcesPath: path.resolve(repoDir, options.sourcesPath ?? "skill-sources.jsonc"),
|
|
58
|
+
lockfilePath: path.resolve(repoDir, options.lockfilePath ?? "skill-sources.lock.json"),
|
|
59
|
+
} satisfies CatalogPaths;
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const readJsonc = Effect.fn("readCatalogManagerJsonc")(function* <A>(
|
|
63
|
+
filePath: string,
|
|
64
|
+
schema: Schema.ConstraintDecoder<A>,
|
|
65
|
+
) {
|
|
66
|
+
const fs = yield* FileSystem.FileSystem;
|
|
67
|
+
if (!(yield* fs.exists(filePath))) {
|
|
68
|
+
return yield* new CatalogManagerError({ message: `file not found: ${filePath}` });
|
|
69
|
+
}
|
|
70
|
+
const raw = yield* fs.readFileString(filePath);
|
|
71
|
+
const errors: Array<ParseError> = [];
|
|
72
|
+
const parsed = parseJsonc(raw, errors, { allowTrailingComma: true });
|
|
73
|
+
if (errors.length > 0) {
|
|
74
|
+
return yield* new CatalogManagerError({ message: `could not parse ${filePath}` });
|
|
75
|
+
}
|
|
76
|
+
const value = yield* Schema.decodeUnknownEffect(schema)(parsed).pipe(
|
|
77
|
+
Effect.mapError((error) => new CatalogManagerError({ message: error.message })),
|
|
78
|
+
);
|
|
79
|
+
return { raw, value };
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const readState = Effect.fn("readCatalogManagerState")(function* (
|
|
83
|
+
options: CatalogCommandOptions,
|
|
84
|
+
) {
|
|
85
|
+
const fs = yield* FileSystem.FileSystem;
|
|
86
|
+
const paths = yield* resolvePaths(options);
|
|
87
|
+
const sources = yield* readJsonc(paths.sourcesPath, SkillSourcesManifestSchema);
|
|
88
|
+
const lock = (yield* fs.exists(paths.lockfilePath))
|
|
89
|
+
? yield* readJsonc(paths.lockfilePath, SkillSourcesLockSchema)
|
|
90
|
+
: undefined;
|
|
91
|
+
return { ...paths, sources, lock };
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const compactDescription = (description: string): string => {
|
|
95
|
+
const first = description.match(/^.*?[.!?](?:\s|$)/)?.[0]?.trim() ?? description;
|
|
96
|
+
return first.length > 90 ? `${first.slice(0, 87).trimEnd()}…` : first;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const selectSkills = Effect.fn("selectCatalogSkills")(function* (
|
|
100
|
+
inspection: CatalogInspection,
|
|
101
|
+
requested: ReadonlyArray<string>,
|
|
102
|
+
all: boolean,
|
|
103
|
+
) {
|
|
104
|
+
const available = new Set(inspection.skills.map((skill) => skill.name));
|
|
105
|
+
const unknown = requested.filter((skill) => !available.has(skill));
|
|
106
|
+
if (unknown.length > 0) {
|
|
107
|
+
return yield* new CatalogManagerError({
|
|
108
|
+
message: `repository does not contain: ${unknown.join(", ")}`,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
if (all) {
|
|
112
|
+
const selected = inspection.skills.map((skill) => skill.name);
|
|
113
|
+
return { include: selected, selected };
|
|
114
|
+
}
|
|
115
|
+
if (requested.length > 0) return { include: [...new Set(requested)], selected: [...new Set(requested)] };
|
|
116
|
+
if (!(yield* isInteractiveTerminal)) {
|
|
117
|
+
return yield* new CatalogManagerError({
|
|
118
|
+
message: "choose skills with --skill <name>, or pass --all",
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
const selected = yield* Prompt.multiSelect({
|
|
122
|
+
message: `Approve skills from ${inspection.id}`,
|
|
123
|
+
choices: inspection.skills.map((skill) => ({
|
|
124
|
+
title: skill.name,
|
|
125
|
+
value: skill.name,
|
|
126
|
+
...(skill.description ? { description: compactDescription(skill.description) } : {}),
|
|
127
|
+
})),
|
|
128
|
+
min: 1,
|
|
129
|
+
});
|
|
130
|
+
return { include: selected, selected };
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const refreshWithRollback = Effect.fn("refreshCatalogWithRollback")(function* (
|
|
134
|
+
paths: CatalogPaths,
|
|
135
|
+
previousSources: string,
|
|
136
|
+
previousLock: string | undefined,
|
|
137
|
+
updateSourceIds: ReadonlyArray<string>,
|
|
138
|
+
pinSourceIds: ReadonlyArray<string>,
|
|
139
|
+
) {
|
|
140
|
+
const fs = yield* FileSystem.FileSystem;
|
|
141
|
+
yield* refreshSkillCatalog({
|
|
142
|
+
repoDir: paths.repoDir,
|
|
143
|
+
sourcesPath: paths.sourcesPath,
|
|
144
|
+
lockfilePath: paths.lockfilePath,
|
|
145
|
+
updateSourceIds,
|
|
146
|
+
pinSourceIds,
|
|
147
|
+
}).pipe(
|
|
148
|
+
Effect.catchCause((cause) =>
|
|
149
|
+
Effect.gen(function* () {
|
|
150
|
+
yield* fs.writeFileString(paths.sourcesPath, previousSources);
|
|
151
|
+
if (previousLock === undefined) {
|
|
152
|
+
yield* fs.remove(paths.lockfilePath, { force: true });
|
|
153
|
+
} else {
|
|
154
|
+
yield* fs.writeFileString(paths.lockfilePath, previousLock);
|
|
155
|
+
}
|
|
156
|
+
return yield* Effect.failCause(cause);
|
|
157
|
+
}),
|
|
158
|
+
),
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const writeAndRefresh = Effect.fn("writeAndRefreshCatalog")(function* (
|
|
163
|
+
state: {
|
|
164
|
+
readonly repoDir: string;
|
|
165
|
+
readonly sourcesPath: string;
|
|
166
|
+
readonly lockfilePath: string;
|
|
167
|
+
readonly sources: { readonly raw: string; readonly value: SkillSourcesManifest };
|
|
168
|
+
readonly lock: { readonly raw: string; readonly value: SkillSourcesLock } | undefined;
|
|
169
|
+
},
|
|
170
|
+
nextSources: string,
|
|
171
|
+
updateSourceIds: ReadonlyArray<string>,
|
|
172
|
+
pinSourceIds: ReadonlyArray<string> = [],
|
|
173
|
+
) {
|
|
174
|
+
const fs = yield* FileSystem.FileSystem;
|
|
175
|
+
if (nextSources === state.sources.raw) {
|
|
176
|
+
yield* printStatus("info", "Catalog already contains that selection");
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const previousLock = state.lock?.raw;
|
|
180
|
+
yield* fs.writeFileString(state.sourcesPath, nextSources);
|
|
181
|
+
yield* refreshWithRollback(state, state.sources.raw, previousLock, updateSourceIds, pinSourceIds);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
export const addCatalogSource = Effect.fn("addCatalogSource")(function* (
|
|
185
|
+
options: CatalogAddOptions,
|
|
186
|
+
) {
|
|
187
|
+
if (options.all && (options.skills?.length ?? 0) > 0) {
|
|
188
|
+
return yield* new CatalogManagerError({ message: "use either --all or --skill, not both" });
|
|
189
|
+
}
|
|
190
|
+
const state = yield* readState(options);
|
|
191
|
+
const inspection = yield* inspectCatalogRepository({
|
|
192
|
+
repository: options.repository,
|
|
193
|
+
repoDir: state.repoDir,
|
|
194
|
+
...(options.id ? { id: options.id } : {}),
|
|
195
|
+
...(options.ref ? { ref: options.ref } : {}),
|
|
196
|
+
...(options.skillsPath ? { skillsPath: options.skillsPath } : {}),
|
|
197
|
+
});
|
|
198
|
+
const selection = yield* selectSkills(
|
|
199
|
+
inspection,
|
|
200
|
+
options.skills ?? [],
|
|
201
|
+
options.all ?? false,
|
|
202
|
+
);
|
|
203
|
+
const sources = state.sources.value.sources;
|
|
204
|
+
const byId = sources.findIndex((source) => source.id === inspection.id);
|
|
205
|
+
const byRepository = sources.findIndex((source) => source.repository === inspection.repository);
|
|
206
|
+
if (byId >= 0 && sources[byId]?.repository !== inspection.repository) {
|
|
207
|
+
return yield* new CatalogManagerError({
|
|
208
|
+
message: `source id ${inspection.id} is already used by ${sources[byId]?.repository}`,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
if (byRepository >= 0 && sources[byRepository]?.id !== inspection.id) {
|
|
212
|
+
return yield* new CatalogManagerError({
|
|
213
|
+
message: `repository is already cataloged as ${sources[byRepository]?.id}`,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
const existingIndex = byId >= 0 ? byId : byRepository;
|
|
217
|
+
let next = state.sources.raw;
|
|
218
|
+
if (existingIndex >= 0) {
|
|
219
|
+
const existing = sources[existingIndex]!;
|
|
220
|
+
const approved = state.lock?.value.sources.find((source) => source.id === existing.id)?.skills ?? [];
|
|
221
|
+
const currentInclude = existing.include.includes("*") ? approved : existing.include;
|
|
222
|
+
const include = [...new Set([...currentInclude, ...selection.include])].sort();
|
|
223
|
+
const exclude = (existing.exclude ?? []).filter((skill) => !selection.selected.includes(skill));
|
|
224
|
+
next = applyEdits(next, modify(next, ["sources", existingIndex, "include"], include, { formattingOptions }));
|
|
225
|
+
if (exclude.length > 0 || existing.exclude !== undefined) {
|
|
226
|
+
next = applyEdits(next, modify(next, ["sources", existingIndex, "exclude"], exclude, { formattingOptions }));
|
|
227
|
+
}
|
|
228
|
+
if (existing.ref === "HEAD" && inspection.ref !== "HEAD") {
|
|
229
|
+
next = applyEdits(next, modify(next, ["sources", existingIndex, "ref"], inspection.ref, { formattingOptions }));
|
|
230
|
+
}
|
|
231
|
+
} else {
|
|
232
|
+
const source: ExternalSkillSource = {
|
|
233
|
+
id: inspection.id,
|
|
234
|
+
repository: inspection.repository,
|
|
235
|
+
ref: inspection.ref,
|
|
236
|
+
skillsPath: inspection.skillsPath,
|
|
237
|
+
include: selection.include,
|
|
238
|
+
...(options.licensePath
|
|
239
|
+
? { licensePath: options.licensePath }
|
|
240
|
+
: inspection.licensePath
|
|
241
|
+
? { licensePath: inspection.licensePath }
|
|
242
|
+
: {}),
|
|
243
|
+
...(options.stripFrontmatter?.length
|
|
244
|
+
? { stripFrontmatter: [...new Set(options.stripFrontmatter)] }
|
|
245
|
+
: {}),
|
|
246
|
+
};
|
|
247
|
+
next = applyEdits(next, modify(next, ["sources", sources.length], source, {
|
|
248
|
+
formattingOptions,
|
|
249
|
+
isArrayInsertion: true,
|
|
250
|
+
}));
|
|
251
|
+
}
|
|
252
|
+
if (options.dryRun) {
|
|
253
|
+
yield* printStatus(
|
|
254
|
+
"plan",
|
|
255
|
+
existingIndex >= 0 ? "Would update catalog source" : "Would add catalog source",
|
|
256
|
+
`${inspection.id} · ${selection.selected.length} skill${selection.selected.length === 1 ? "" : "s"}`,
|
|
257
|
+
);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
yield* writeAndRefresh(state, next, [inspection.id]);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
export const removeCatalogEntry = Effect.fn("removeCatalogEntry")(function* (
|
|
264
|
+
name: string,
|
|
265
|
+
options: CatalogCommandOptions & { readonly dryRun?: boolean; readonly yes?: boolean },
|
|
266
|
+
) {
|
|
267
|
+
const state = yield* readState(options);
|
|
268
|
+
const sources = state.sources.value.sources;
|
|
269
|
+
const sourceIndex = sources.findIndex((source) => source.id === name);
|
|
270
|
+
let next = state.sources.raw;
|
|
271
|
+
let label: string;
|
|
272
|
+
let pinSourceIds: ReadonlyArray<string> = [];
|
|
273
|
+
if (sourceIndex >= 0) {
|
|
274
|
+
next = applyEdits(next, modify(next, ["sources", sourceIndex], undefined, { formattingOptions }));
|
|
275
|
+
label = `source ${name}`;
|
|
276
|
+
} else {
|
|
277
|
+
const owner = state.lock?.value.sources.find((source) => source.skills.includes(name));
|
|
278
|
+
if (!owner) return yield* new CatalogManagerError({ message: `catalog entry not found: ${name}` });
|
|
279
|
+
const index = sources.findIndex((source) => source.id === owner.id);
|
|
280
|
+
const source = sources[index];
|
|
281
|
+
if (!source) return yield* new CatalogManagerError({ message: `source not found: ${owner.id}` });
|
|
282
|
+
if (source.include.includes("*")) {
|
|
283
|
+
const exclude = [...new Set([...(source.exclude ?? []), name])];
|
|
284
|
+
next = applyEdits(next, modify(next, ["sources", index, "exclude"], exclude, { formattingOptions }));
|
|
285
|
+
} else {
|
|
286
|
+
const include = source.include.filter((skill) => skill !== name);
|
|
287
|
+
next = include.length === 0
|
|
288
|
+
? applyEdits(next, modify(next, ["sources", index], undefined, { formattingOptions }))
|
|
289
|
+
: applyEdits(next, modify(next, ["sources", index, "include"], include, { formattingOptions }));
|
|
290
|
+
}
|
|
291
|
+
label = `skill ${name}`;
|
|
292
|
+
pinSourceIds = [owner.id];
|
|
293
|
+
}
|
|
294
|
+
if (options.dryRun) {
|
|
295
|
+
yield* printStatus("plan", "Would remove catalog entry", label);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
if (!options.yes) {
|
|
299
|
+
if (!(yield* isInteractiveTerminal)) {
|
|
300
|
+
return yield* new CatalogManagerError({
|
|
301
|
+
message: "catalog removal requires --yes outside a terminal",
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
const confirmed = yield* Prompt.confirm({
|
|
305
|
+
message: `Remove ${label} from the approved catalog?`,
|
|
306
|
+
initial: false,
|
|
307
|
+
});
|
|
308
|
+
if (!confirmed) {
|
|
309
|
+
yield* printStatus("info", "Cancelled");
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
yield* writeAndRefresh(state, next, [], pinSourceIds);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
export const listCatalogSources = Effect.fn("listCatalogSources")(function* (
|
|
317
|
+
options: CatalogCommandOptions,
|
|
318
|
+
) {
|
|
319
|
+
const state = yield* readState(options);
|
|
320
|
+
if (state.sources.value.sources.length === 0) {
|
|
321
|
+
yield* printStatus("info", "Catalog has no external sources");
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
for (const source of state.sources.value.sources) {
|
|
325
|
+
const locked = state.lock?.value.sources.find((candidate) => candidate.id === source.id);
|
|
326
|
+
yield* printLine(`${source.id} ${source.repository}`);
|
|
327
|
+
yield* printLine(` ${locked?.skills.length ?? 0} skills · ${locked?.resolved.slice(0, 12) ?? "not refreshed"}`);
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
export const showCatalogSource = Effect.fn("showCatalogSource")(function* (
|
|
332
|
+
id: string,
|
|
333
|
+
options: CatalogCommandOptions,
|
|
334
|
+
) {
|
|
335
|
+
const state = yield* readState(options);
|
|
336
|
+
const source = state.sources.value.sources.find((candidate) => candidate.id === id);
|
|
337
|
+
if (!source) return yield* new CatalogManagerError({ message: `catalog source not found: ${id}` });
|
|
338
|
+
const locked = state.lock?.value.sources.find((candidate) => candidate.id === id);
|
|
339
|
+
yield* printLine(source.id);
|
|
340
|
+
yield* printLine(`Repository: ${source.repository}`);
|
|
341
|
+
yield* printLine(`Tracking: ${source.ref}`);
|
|
342
|
+
if (locked) yield* printLine(`Approved commit: ${locked.resolved}`);
|
|
343
|
+
yield* printLine(`Skills: ${locked?.skills.join(", ") ?? "run catalog refresh"}`);
|
|
344
|
+
if (source.exclude?.length) yield* printLine(`Excluded: ${source.exclude.join(", ")}`);
|
|
345
|
+
});
|
package/src/catalog.ts
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { parse as parseJsonc, type ParseError } from "jsonc-parser";
|
|
2
|
+
import { Effect, FileSystem, Path, Schema, Stream } from "effect";
|
|
3
|
+
import { ChildProcess } from "effect/unstable/process";
|
|
4
|
+
|
|
5
|
+
import { observePath } from "./path-digest.ts";
|
|
6
|
+
import {
|
|
7
|
+
SkillSourcesLockSchema,
|
|
8
|
+
type LockedSkillSource,
|
|
9
|
+
type SkillSourcesLock,
|
|
10
|
+
} from "./source-manifest.ts";
|
|
11
|
+
|
|
12
|
+
export type CatalogSkill = {
|
|
13
|
+
readonly name: string;
|
|
14
|
+
readonly description: string;
|
|
15
|
+
readonly source: string;
|
|
16
|
+
readonly bundled: boolean;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type SkillCatalog = {
|
|
20
|
+
readonly skills: ReadonlyArray<CatalogSkill>;
|
|
21
|
+
readonly families: Readonly<Record<string, ReadonlyArray<string>>>;
|
|
22
|
+
readonly lock?: SkillSourcesLock;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type ResolvedSkillSource = {
|
|
26
|
+
readonly path: string;
|
|
27
|
+
readonly catalog?: {
|
|
28
|
+
readonly source: string;
|
|
29
|
+
readonly repository: string;
|
|
30
|
+
readonly resolved: string;
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
class CatalogError extends Schema.TaggedErrorClass<CatalogError>()("CatalogError", {
|
|
35
|
+
message: Schema.String,
|
|
36
|
+
}) {}
|
|
37
|
+
|
|
38
|
+
const runGit = Effect.fn("runCatalogGit")(function* (
|
|
39
|
+
cwd: string,
|
|
40
|
+
args: ReadonlyArray<string>,
|
|
41
|
+
) {
|
|
42
|
+
const child = yield* ChildProcess.make("git", args, {
|
|
43
|
+
cwd,
|
|
44
|
+
stderr: "pipe",
|
|
45
|
+
stdout: "pipe",
|
|
46
|
+
});
|
|
47
|
+
const [output, exitCode] = yield* Effect.all([
|
|
48
|
+
Stream.mkString(Stream.decodeText(child.all)),
|
|
49
|
+
child.exitCode,
|
|
50
|
+
]);
|
|
51
|
+
if (exitCode !== 0) {
|
|
52
|
+
return yield* new CatalogError({
|
|
53
|
+
message: `git ${args.join(" ")} failed: ${output.trim()}`,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return output.trim();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const readCatalogLock = Effect.fn("readCatalogLock")(function* (packageRoot: string) {
|
|
60
|
+
const fs = yield* FileSystem.FileSystem;
|
|
61
|
+
const path = yield* Path.Path;
|
|
62
|
+
const lockPath = path.join(packageRoot, "skill-sources.lock.json");
|
|
63
|
+
if (!(yield* fs.exists(lockPath))) return undefined;
|
|
64
|
+
const raw = yield* fs.readFileString(lockPath);
|
|
65
|
+
const errors: Array<ParseError> = [];
|
|
66
|
+
const value = parseJsonc(raw, errors, { allowTrailingComma: true });
|
|
67
|
+
if (errors.length > 0) {
|
|
68
|
+
return yield* new CatalogError({ message: `invalid skill catalog lock: ${lockPath}` });
|
|
69
|
+
}
|
|
70
|
+
return yield* Schema.decodeUnknownEffect(SkillSourcesLockSchema)(value).pipe(
|
|
71
|
+
Effect.mapError((error) => new CatalogError({ message: error.message })),
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const readDescription = Effect.fn("readSkillDescription")(function* (skillPath: string) {
|
|
76
|
+
const fs = yield* FileSystem.FileSystem;
|
|
77
|
+
const path = yield* Path.Path;
|
|
78
|
+
const text = yield* fs.readFileString(path.join(skillPath, "SKILL.md"));
|
|
79
|
+
return text
|
|
80
|
+
.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1]
|
|
81
|
+
?.split(/\r?\n/)
|
|
82
|
+
.find((line) => line.startsWith("description:"))
|
|
83
|
+
?.slice("description:".length)
|
|
84
|
+
.trim()
|
|
85
|
+
.replace(/^(['"])(.*)\1$/, "$2") ?? "";
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
export const loadSkillCatalog = Effect.fn("loadSkillCatalog")(function* (
|
|
89
|
+
packageRoot: string,
|
|
90
|
+
) {
|
|
91
|
+
const fs = yield* FileSystem.FileSystem;
|
|
92
|
+
const path = yield* Path.Path;
|
|
93
|
+
const skillsDir = path.join(packageRoot, "skills");
|
|
94
|
+
const skills: Array<CatalogSkill> = [];
|
|
95
|
+
if (yield* fs.exists(skillsDir)) {
|
|
96
|
+
for (const name of (yield* fs.readDirectory(skillsDir)).sort()) {
|
|
97
|
+
const skillPath = path.join(skillsDir, name);
|
|
98
|
+
if ((yield* fs.exists(path.join(skillPath, "SKILL.md")))) {
|
|
99
|
+
skills.push({
|
|
100
|
+
name,
|
|
101
|
+
description: yield* readDescription(skillPath),
|
|
102
|
+
source: "built-in",
|
|
103
|
+
bundled: true,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const lock = yield* readCatalogLock(packageRoot);
|
|
109
|
+
for (const source of lock?.sources ?? []) {
|
|
110
|
+
for (const name of source.skills) {
|
|
111
|
+
skills.push({
|
|
112
|
+
name,
|
|
113
|
+
description: source.descriptions?.[name] ?? "",
|
|
114
|
+
source: source.id,
|
|
115
|
+
bundled: false,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const duplicates = skills.filter(
|
|
120
|
+
(skill, index) => skills.findIndex((candidate) => candidate.name === skill.name) !== index,
|
|
121
|
+
);
|
|
122
|
+
if (duplicates.length > 0) {
|
|
123
|
+
return yield* new CatalogError({
|
|
124
|
+
message: `duplicate catalog skill: ${duplicates[0]?.name ?? "unknown"}`,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
const families: Readonly<Record<string, ReadonlyArray<string>>> = {
|
|
128
|
+
effect: ["effect-ts"],
|
|
129
|
+
...Object.fromEntries(
|
|
130
|
+
(lock?.sources ?? []).map((source) => [source.id, source.skills]),
|
|
131
|
+
),
|
|
132
|
+
};
|
|
133
|
+
return {
|
|
134
|
+
skills: skills.sort((left, right) => left.name.localeCompare(right.name)),
|
|
135
|
+
families,
|
|
136
|
+
...(lock ? { lock } : {}),
|
|
137
|
+
} satisfies SkillCatalog;
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const stripFrontmatterKeys = (text: string, keys: ReadonlyArray<string>): string => {
|
|
141
|
+
if (keys.length === 0) return text;
|
|
142
|
+
const frontmatter = text.match(/^---\r?\n([\s\S]*?)\r?\n---(\r?\n|$)/);
|
|
143
|
+
if (!frontmatter?.[1]) return text;
|
|
144
|
+
const stripped = new Set(keys);
|
|
145
|
+
let skipping = false;
|
|
146
|
+
const lines = frontmatter[1].split(/\r?\n/).filter((line) => {
|
|
147
|
+
const key = line.match(/^([A-Za-z0-9_-]+):/)?.[1];
|
|
148
|
+
if (key) skipping = stripped.has(key);
|
|
149
|
+
return !skipping;
|
|
150
|
+
});
|
|
151
|
+
return `---\n${lines.join("\n")}\n---${frontmatter[2]}${text.slice(frontmatter[0].length)}`;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const materializeSource = Effect.fn("materializeCatalogSource")(function* (
|
|
155
|
+
projectDir: string,
|
|
156
|
+
source: LockedSkillSource,
|
|
157
|
+
selected: ReadonlyArray<string>,
|
|
158
|
+
cache: boolean,
|
|
159
|
+
) {
|
|
160
|
+
const fs = yield* FileSystem.FileSystem;
|
|
161
|
+
const path = yield* Path.Path;
|
|
162
|
+
const root = cache
|
|
163
|
+
? path.join(projectDir, ".dev-kit", "cache", "catalog", source.id, source.resolved)
|
|
164
|
+
: path.join(
|
|
165
|
+
yield* fs.makeTempDirectoryScoped({ prefix: "dev-kit-catalog-plan-" }),
|
|
166
|
+
source.id,
|
|
167
|
+
source.resolved,
|
|
168
|
+
);
|
|
169
|
+
const checkout = path.join(root, "checkout");
|
|
170
|
+
const ready = path.join(root, ".ready");
|
|
171
|
+
if (!(yield* fs.exists(ready))) {
|
|
172
|
+
yield* fs.remove(root, { force: true, recursive: true });
|
|
173
|
+
yield* fs.makeDirectory(checkout, { recursive: true });
|
|
174
|
+
yield* runGit(checkout, ["init", "--quiet"]);
|
|
175
|
+
yield* runGit(checkout, ["remote", "add", "origin", source.repository]);
|
|
176
|
+
yield* runGit(checkout, ["fetch", "--quiet", "--depth", "1", "origin", source.resolved]);
|
|
177
|
+
yield* runGit(checkout, ["checkout", "--quiet", "--detach", "FETCH_HEAD"]);
|
|
178
|
+
const actual = yield* runGit(checkout, ["rev-parse", "HEAD"]);
|
|
179
|
+
if (actual !== source.resolved) {
|
|
180
|
+
return yield* new CatalogError({ message: `source ${source.id} resolved to ${actual}, expected ${source.resolved}` });
|
|
181
|
+
}
|
|
182
|
+
const symlinks = yield* runGit(checkout, ["ls-files", "--stage", "--", source.skillsPath]);
|
|
183
|
+
if (symlinks.split(/\r?\n/).some((line) => line.startsWith("120000 "))) {
|
|
184
|
+
return yield* new CatalogError({ message: `source ${source.id} contains symlinks; refusing to install it` });
|
|
185
|
+
}
|
|
186
|
+
for (const skill of source.skills) {
|
|
187
|
+
const from = path.join(checkout, source.skillsPath, skill);
|
|
188
|
+
const to = path.join(root, "skills", skill);
|
|
189
|
+
const observation = yield* observePath(from);
|
|
190
|
+
if (observation.kind !== "directory") {
|
|
191
|
+
return yield* new CatalogError({ message: `source ${source.id} is missing skill ${skill}` });
|
|
192
|
+
}
|
|
193
|
+
yield* fs.copy(from, to, { overwrite: true });
|
|
194
|
+
if (source.stripFrontmatter?.length) {
|
|
195
|
+
const document = path.join(to, "SKILL.md");
|
|
196
|
+
yield* fs.writeFileString(
|
|
197
|
+
document,
|
|
198
|
+
stripFrontmatterKeys(yield* fs.readFileString(document), source.stripFrontmatter),
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
yield* fs.writeFileString(ready, `${source.resolved}\n`);
|
|
203
|
+
}
|
|
204
|
+
for (const skill of selected) {
|
|
205
|
+
const observation = yield* observePath(path.join(root, "skills", skill));
|
|
206
|
+
const approvedDigest = source.digests?.[skill];
|
|
207
|
+
if (approvedDigest !== undefined &&
|
|
208
|
+
(observation.kind !== "directory" || observation.digest !== approvedDigest)) {
|
|
209
|
+
return yield* new CatalogError({
|
|
210
|
+
message: `cached skill ${skill} does not match the approved catalog; remove ${root} and retry`,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return new Map(selected.map((skill) => [skill, {
|
|
215
|
+
path: path.join(root, "skills", skill),
|
|
216
|
+
catalog: {
|
|
217
|
+
source: source.id,
|
|
218
|
+
repository: source.repository,
|
|
219
|
+
resolved: source.resolved,
|
|
220
|
+
},
|
|
221
|
+
} satisfies ResolvedSkillSource]));
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
export const resolveSkillSources = Effect.fn("resolveSkillSources")(function* (
|
|
225
|
+
packageRoot: string,
|
|
226
|
+
projectDir: string,
|
|
227
|
+
selected: ReadonlyArray<string>,
|
|
228
|
+
cache = true,
|
|
229
|
+
) {
|
|
230
|
+
const path = yield* Path.Path;
|
|
231
|
+
const catalog = yield* loadSkillCatalog(packageRoot);
|
|
232
|
+
const sources = new Map<string, ResolvedSkillSource>();
|
|
233
|
+
for (const skill of catalog.skills.filter((skill) => skill.bundled)) {
|
|
234
|
+
if (selected.includes(skill.name)) {
|
|
235
|
+
sources.set(skill.name, { path: path.join(packageRoot, "skills", skill.name) });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
for (const source of catalog.lock?.sources ?? []) {
|
|
239
|
+
const wanted = source.skills.filter((skill) => selected.includes(skill));
|
|
240
|
+
if (wanted.length === 0) continue;
|
|
241
|
+
for (const [name, sourcePath] of yield* materializeSource(projectDir, source, wanted, cache)) {
|
|
242
|
+
sources.set(name, sourcePath);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return sources;
|
|
246
|
+
});
|
package/src/cli-ui.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { Config, Console, Effect, Option, Terminal } from "effect";
|
|
2
|
+
|
|
3
|
+
const ANSI = {
|
|
4
|
+
clearLine: "\r\u001b[2K",
|
|
5
|
+
cyan: "\u001b[36m",
|
|
6
|
+
dim: "\u001b[2m",
|
|
7
|
+
green: "\u001b[32m",
|
|
8
|
+
red: "\u001b[31m",
|
|
9
|
+
reset: "\u001b[0m",
|
|
10
|
+
yellow: "\u001b[33m",
|
|
11
|
+
} as const;
|
|
12
|
+
|
|
13
|
+
type StatusKind = "success" | "info" | "plan" | "error";
|
|
14
|
+
|
|
15
|
+
const statusAppearance: Readonly<Record<StatusKind, readonly [string, string]>> = {
|
|
16
|
+
success: ["✓", ANSI.green],
|
|
17
|
+
info: ["•", ANSI.cyan],
|
|
18
|
+
plan: ["→", ANSI.yellow],
|
|
19
|
+
error: ["✗", ANSI.red],
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const terminalCapabilities = Effect.fn("terminalCapabilities")(function* () {
|
|
23
|
+
const terminal = yield* Terminal.Terminal;
|
|
24
|
+
const columns = yield* terminal.columns;
|
|
25
|
+
const noColor = yield* Config.string("NO_COLOR").pipe(Config.option, Effect.orDie);
|
|
26
|
+
const term = yield* Config.string("TERM").pipe(Config.withDefault(""), Effect.orDie);
|
|
27
|
+
const ci = yield* Config.string("CI").pipe(Config.withDefault(""), Effect.orDie);
|
|
28
|
+
const interactive = columns > 0 && term !== "dumb" && ci !== "true" && ci !== "1";
|
|
29
|
+
return {
|
|
30
|
+
color: interactive && Option.isNone(noColor),
|
|
31
|
+
interactive,
|
|
32
|
+
terminal,
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export const isInteractiveTerminal = terminalCapabilities().pipe(
|
|
37
|
+
Effect.map((capabilities) => capabilities.interactive),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const formatDetail = (detail: string | undefined, color: boolean): string =>
|
|
41
|
+
detail === undefined || detail.length === 0
|
|
42
|
+
? ""
|
|
43
|
+
: color
|
|
44
|
+
? ` ${ANSI.dim}${detail}${ANSI.reset}`
|
|
45
|
+
: ` ${detail}`;
|
|
46
|
+
|
|
47
|
+
export const printStatus = Effect.fn("printCliStatus")(function* (
|
|
48
|
+
kind: StatusKind,
|
|
49
|
+
label: string,
|
|
50
|
+
detail?: string,
|
|
51
|
+
) {
|
|
52
|
+
const capabilities = yield* terminalCapabilities();
|
|
53
|
+
const [symbol, color] = statusAppearance[kind];
|
|
54
|
+
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);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
export const printDetail = Effect.fn("printCliDetail")(function* (text: string) {
|
|
61
|
+
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);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
export const printLine = Effect.fn("printCliLine")(function* (text = "") {
|
|
70
|
+
const capabilities = yield* terminalCapabilities();
|
|
71
|
+
yield* capabilities.terminal.display(`${text}\n`).pipe(Effect.orDie);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
export const withSpinner = <A, E, R>(
|
|
75
|
+
label: string,
|
|
76
|
+
effect: Effect.Effect<A, E, R>,
|
|
77
|
+
): Effect.Effect<A, E, R | Terminal.Terminal> =>
|
|
78
|
+
Effect.gen(function* () {
|
|
79
|
+
const capabilities = yield* terminalCapabilities();
|
|
80
|
+
if (!capabilities.interactive) return yield* effect;
|
|
81
|
+
|
|
82
|
+
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
83
|
+
let index = 0;
|
|
84
|
+
const animate = Effect.forever(
|
|
85
|
+
Effect.suspend(() => {
|
|
86
|
+
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")));
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
const fiber = yield* Effect.forkChild(animate);
|
|
96
|
+
return yield* effect.pipe(
|
|
97
|
+
Effect.ensuring(
|
|
98
|
+
Effect.sync(() => fiber.interruptUnsafe()).pipe(
|
|
99
|
+
Effect.andThen(capabilities.terminal.display(ANSI.clearLine).pipe(Effect.orDie)),
|
|
100
|
+
),
|
|
101
|
+
),
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
export const printError = Effect.fn("printCliError")(function* (message: string) {
|
|
106
|
+
const capabilities = yield* terminalCapabilities();
|
|
107
|
+
const [symbol, color] = statusAppearance.error;
|
|
108
|
+
const prefix = capabilities.color ? `${color}${symbol}${ANSI.reset}` : symbol;
|
|
109
|
+
yield* Console.error(`${prefix} ${message}`);
|
|
110
|
+
});
|