@danieljvdm/dev-kit 0.4.0 → 0.5.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.
@@ -0,0 +1,272 @@
1
+ import { Effect, FileSystem, Path, Result, Schema } from "effect";
2
+
3
+ import { observeSymbolicLink } from "./node-symbolic-link.ts";
4
+ import { isSkillName, parseSkillSelector } from "./skill-selector.ts";
5
+ import { isTypeScriptPackageName } from "./typescript-package-name.ts";
6
+
7
+ export class PackageSkillSourceError extends Schema.TaggedErrorClass<PackageSkillSourceError>()(
8
+ "PackageSkillSourceError",
9
+ { message: Schema.String },
10
+ ) {}
11
+
12
+ export type PackageSkillDiagnostic = {
13
+ readonly package: string;
14
+ readonly message: string;
15
+ };
16
+
17
+ export type DiscoveredPackageSkill = {
18
+ readonly selector: string;
19
+ readonly name: string;
20
+ readonly description: string;
21
+ readonly package: string;
22
+ readonly version: string;
23
+ readonly path: string;
24
+ readonly linkPath: string;
25
+ };
26
+
27
+ const ProjectPackageSchema = Schema.fromJsonString(Schema.Struct({
28
+ dependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)),
29
+ devDependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)),
30
+ optionalDependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)),
31
+ peerDependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)),
32
+ }));
33
+
34
+ const PackageMetadataSchema = Schema.fromJsonString(Schema.Struct({
35
+ name: Schema.String,
36
+ version: Schema.String,
37
+ intent: Schema.optional(Schema.Unknown),
38
+ repository: Schema.optional(Schema.Unknown),
39
+ }));
40
+
41
+ const nonEmptyString = (value: unknown): value is string =>
42
+ typeof value === "string" && value.trim().length > 0;
43
+
44
+ const hasIntentDiscoveryMetadata = (metadata: typeof PackageMetadataSchema.Type): boolean => {
45
+ const intent = metadata.intent;
46
+ if (typeof intent === "object" && intent !== null &&
47
+ "version" in intent && intent.version === 1 &&
48
+ "repo" in intent && nonEmptyString(intent.repo) &&
49
+ "docs" in intent && nonEmptyString(intent.docs)) {
50
+ return true;
51
+ }
52
+ const repository = metadata.repository;
53
+ return nonEmptyString(repository) ||
54
+ (typeof repository === "object" && repository !== null &&
55
+ "url" in repository && nonEmptyString(repository.url));
56
+ };
57
+
58
+ const isSafePackageVersion = (value: string): boolean =>
59
+ value.length > 0 && value.trim() === value && ![...value].some((character) => {
60
+ const code = character.charCodeAt(0);
61
+ return code <= 32 || (code >= 127 && code <= 159);
62
+ });
63
+
64
+ const isContained = (path: Path.Path, root: string, candidate: string): boolean => {
65
+ const relative = path.relative(root, candidate);
66
+ return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
67
+ };
68
+
69
+ const frontmatterScalar = (document: string, key: string): string | undefined => {
70
+ const body = document.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)?.[1];
71
+ if (body === undefined) return undefined;
72
+ const lines = body.split(/\r?\n/);
73
+ const index = lines.findIndex((line) => line.startsWith(`${key}:`));
74
+ if (index < 0) return undefined;
75
+ const raw = lines[index]?.slice(key.length + 1).trim() ?? "";
76
+ const block = raw.match(/^([|>])(?:[1-9][+-]?|[+-][1-9]?)?$/)?.[1];
77
+ if (block !== undefined) {
78
+ const values: Array<string> = [];
79
+ for (const line of lines.slice(index + 1)) {
80
+ if (line.length > 0 && !/^\s/.test(line)) break;
81
+ values.push(line.trim());
82
+ }
83
+ const value = block === "|" ? values.join("\n").trim() : values.join(" ").trim();
84
+ return value.length > 0 ? value : undefined;
85
+ }
86
+ const quoted = raw.match(/^(['"])([\s\S]*?)\1(?:\s+#.*)?$/)?.[2];
87
+ const value = (quoted ?? raw.replace(/\s+#.*$/, "")).trim();
88
+ return value.length > 0 ? value : undefined;
89
+ };
90
+
91
+ const skillName = (document: string): string | undefined =>
92
+ frontmatterScalar(document, "name");
93
+
94
+ const skillDescription = (document: string): string | undefined =>
95
+ frontmatterScalar(document, "description");
96
+
97
+ const rejectNestedSymlinks = Effect.fn("rejectPackageSkillSymlinks")(function* (skillRoot: string) {
98
+ const fs = yield* FileSystem.FileSystem;
99
+ const path = yield* Path.Path;
100
+ const pending = [skillRoot];
101
+ while (pending.length > 0) {
102
+ const current = pending.pop();
103
+ if (current === undefined) continue;
104
+ if ((yield* observeSymbolicLink(current)).kind === "symlink") {
105
+ return yield* new PackageSkillSourceError({ message: `package skill contains a symlink: ${current}` });
106
+ }
107
+ const info = yield* fs.stat(current).pipe(
108
+ Effect.mapError(() => new PackageSkillSourceError({ message: `could not inspect package skill: ${current}` })),
109
+ );
110
+ if (info.type !== "Directory") continue;
111
+ for (const entry of yield* fs.readDirectory(current).pipe(
112
+ Effect.mapError(() => new PackageSkillSourceError({ message: `could not read package skill: ${current}` })),
113
+ )) pending.push(path.join(current, entry));
114
+ }
115
+ });
116
+
117
+ const readDirectDependencyNames = Effect.fn("readDirectPackageSkillDependencyNames")(function* (projectDir: string) {
118
+ const fs = yield* FileSystem.FileSystem;
119
+ const path = yield* Path.Path;
120
+ const manifestPath = path.join(projectDir, "package.json");
121
+ const manifest = yield* fs.readFileString(manifestPath).pipe(
122
+ Effect.flatMap(Schema.decodeUnknownEffect(ProjectPackageSchema)),
123
+ Effect.mapError(() => new PackageSkillSourceError({ message: `invalid project package.json: ${manifestPath}` })),
124
+ );
125
+ return [...new Set([
126
+ ...Object.keys(manifest.dependencies ?? {}),
127
+ ...Object.keys(manifest.devDependencies ?? {}),
128
+ ...Object.keys(manifest.optionalDependencies ?? {}),
129
+ ...Object.keys(manifest.peerDependencies ?? {}),
130
+ ])].sort();
131
+ });
132
+
133
+ type InstalledPackageSkills = {
134
+ readonly package: string;
135
+ readonly version: string;
136
+ readonly packageLink: string;
137
+ readonly skillsRoot: string;
138
+ readonly names: ReadonlyArray<string>;
139
+ };
140
+
141
+ const loadInstalledPackageSkills = Effect.fn("loadInstalledPackageSkills")(function* (
142
+ projectDir: string,
143
+ packageName: string,
144
+ ) {
145
+ const fs = yield* FileSystem.FileSystem;
146
+ const path = yield* Path.Path;
147
+ const packageLink = path.join(projectDir, "node_modules", ...packageName.split("/"));
148
+ const packageRoot = yield* fs.realPath(packageLink).pipe(
149
+ Effect.mapError(() => new PackageSkillSourceError({ message: `package skill package is not installed: ${packageName}` })),
150
+ );
151
+ const packageInfo = yield* fs.stat(packageRoot).pipe(
152
+ Effect.mapError(() => new PackageSkillSourceError({ message: `could not inspect package skill package: ${packageName}` })),
153
+ );
154
+ if (packageInfo.type !== "Directory") return yield* new PackageSkillSourceError({ message: `package skill package is not a directory: ${packageName}` });
155
+ const metadata = yield* fs.readFileString(path.join(packageRoot, "package.json")).pipe(
156
+ Effect.flatMap(Schema.decodeUnknownEffect(PackageMetadataSchema)),
157
+ Effect.mapError(() => new PackageSkillSourceError({ message: `invalid package.json for package skill package: ${packageName}` })),
158
+ );
159
+ if (metadata.name !== packageName) return yield* new PackageSkillSourceError({ message: `package.json name does not match package skill package: ${packageName}` });
160
+ if (!isSafePackageVersion(metadata.version)) return yield* new PackageSkillSourceError({ message: `package.json has an invalid version for package skill package: ${packageName}` });
161
+ if (!hasIntentDiscoveryMetadata(metadata)) return yield* new PackageSkillSourceError({ message: `package does not declare Intent-compatible discovery metadata: ${packageName}` });
162
+ const skillsPath = "skills";
163
+ const skillsLink = path.join(packageLink, skillsPath);
164
+ if ((yield* observeSymbolicLink(skillsLink)).kind === "symlink") return yield* new PackageSkillSourceError({ message: `package skills path is a symlink: ${packageName}/${skillsPath}` });
165
+ const skillsRoot = yield* fs.realPath(skillsLink).pipe(
166
+ Effect.mapError(() => new PackageSkillSourceError({ message: `package skill package has no skills directory: ${packageName}` })),
167
+ );
168
+ if (!isContained(path, packageRoot, skillsRoot)) return yield* new PackageSkillSourceError({ message: `package skills path resolves outside package root: ${packageName}/${skillsPath}` });
169
+ const skillsInfo = yield* fs.stat(skillsRoot);
170
+ if (skillsInfo.type !== "Directory") return yield* new PackageSkillSourceError({ message: `package skills path is not a directory: ${packageName}/${skillsPath}` });
171
+ const names = (yield* fs.readDirectory(skillsRoot).pipe(
172
+ Effect.mapError(() => new PackageSkillSourceError({
173
+ message: `package skill package has no readable skills directory: ${packageName}`,
174
+ })),
175
+ )).filter(isSkillName).sort();
176
+ return {
177
+ package: packageName,
178
+ version: metadata.version,
179
+ packageLink,
180
+ skillsRoot,
181
+ names,
182
+ } satisfies InstalledPackageSkills;
183
+ });
184
+
185
+ const inspectPackageSkill = Effect.fn("inspectInstalledPackageSkill")(function* (
186
+ installed: InstalledPackageSkills,
187
+ name: string,
188
+ ) {
189
+ const fs = yield* FileSystem.FileSystem;
190
+ const path = yield* Path.Path;
191
+ if (!isSkillName(name)) {
192
+ return yield* new PackageSkillSourceError({ message: `invalid package skill name: ${name}` });
193
+ }
194
+ const selector = `${installed.package}#${name}`;
195
+ const linkPath = path.join(installed.packageLink, "skills", name);
196
+ if ((yield* observeSymbolicLink(linkPath)).kind === "symlink") {
197
+ return yield* new PackageSkillSourceError({ message: `package skill contains a symlink: ${selector}` });
198
+ }
199
+ const skillRoot = yield* fs.realPath(linkPath).pipe(
200
+ Effect.mapError(() => new PackageSkillSourceError({ message: `package skill does not exist: ${selector}` })),
201
+ );
202
+ if (!isContained(path, installed.skillsRoot, skillRoot) ||
203
+ (yield* fs.stat(skillRoot)).type !== "Directory") {
204
+ return yield* new PackageSkillSourceError({ message: `package skill is not a contained directory: ${selector}` });
205
+ }
206
+ yield* rejectNestedSymlinks(skillRoot);
207
+ const document = yield* fs.readFileString(path.join(skillRoot, "SKILL.md")).pipe(
208
+ Effect.mapError(() => new PackageSkillSourceError({ message: `package skill is missing SKILL.md: ${selector}` })),
209
+ );
210
+ if (skillName(document) !== name) {
211
+ return yield* new PackageSkillSourceError({
212
+ message: `package skill SKILL.md name must match directory: ${selector}`,
213
+ });
214
+ }
215
+ const description = skillDescription(document);
216
+ if (description === undefined) {
217
+ return yield* new PackageSkillSourceError({
218
+ message: `package skill SKILL.md must declare a description: ${selector}`,
219
+ });
220
+ }
221
+ return {
222
+ selector,
223
+ name,
224
+ description,
225
+ package: installed.package,
226
+ version: installed.version,
227
+ path: skillRoot,
228
+ linkPath,
229
+ } satisfies DiscoveredPackageSkill;
230
+ });
231
+
232
+ /** Read direct project dependencies only; malformed packages are returned as diagnostics, never executed. */
233
+ export const discoverPackageSkills = Effect.fn("discoverInstalledPackageSkills")(function* (projectDir: string) {
234
+ const fs = yield* FileSystem.FileSystem;
235
+ const path = yield* Path.Path;
236
+ const candidates: Array<DiscoveredPackageSkill> = [];
237
+ const diagnostics: Array<PackageSkillDiagnostic> = [];
238
+ if (!(yield* fs.exists(path.join(projectDir, "package.json")))) {
239
+ return { candidates, diagnostics };
240
+ }
241
+ for (const packageName of yield* readDirectDependencyNames(projectDir)) {
242
+ if (!isTypeScriptPackageName(packageName)) {
243
+ diagnostics.push({ package: packageName, message: `invalid direct dependency package name: ${packageName}` });
244
+ continue;
245
+ }
246
+ const skillsLink = path.join(projectDir, "node_modules", ...packageName.split("/"), "skills");
247
+ if (!(yield* fs.exists(skillsLink))) continue;
248
+ const installed = yield* Effect.result(loadInstalledPackageSkills(projectDir, packageName));
249
+ if (Result.isFailure(installed)) {
250
+ diagnostics.push({ package: packageName, message: installed.failure.message });
251
+ continue;
252
+ }
253
+ for (const name of installed.success.names) {
254
+ const inspected = yield* Effect.result(inspectPackageSkill(installed.success, name));
255
+ if (Result.isSuccess(inspected)) candidates.push(inspected.success);
256
+ else diagnostics.push({ package: packageName, message: inspected.failure.message });
257
+ }
258
+ }
259
+ return { candidates: candidates.sort((left, right) => left.selector.localeCompare(right.selector)), diagnostics };
260
+ });
261
+
262
+ /** Resolve one explicitly selected package skill. Unlike browsing, every malformed or missing part is an error. */
263
+ export const resolvePackageSkillSelector = Effect.fn("resolvePackageSkillSelector")(function* (projectDir: string, selector: string) {
264
+ const parsed = parseSkillSelector(selector);
265
+ if (parsed?.type !== "package") return yield* new PackageSkillSourceError({ message: `invalid package skill selector: ${selector}` });
266
+ const directDependencies = yield* readDirectDependencyNames(projectDir);
267
+ if (!directDependencies.includes(parsed.package)) return yield* new PackageSkillSourceError({ message: `package skill package is not a direct dependency: ${parsed.package}` });
268
+ return yield* inspectPackageSkill(
269
+ yield* loadInstalledPackageSkills(projectDir, parsed.package),
270
+ parsed.skill,
271
+ );
272
+ });
@@ -2,6 +2,21 @@ import { Schema } from "effect";
2
2
 
3
3
  import { DigestSchema } from "./path-digest.ts";
4
4
 
5
+ export const CatalogProvenanceSchema = Schema.Union([
6
+ Schema.Struct({
7
+ source: Schema.String,
8
+ repository: Schema.String,
9
+ resolved: Schema.String,
10
+ }),
11
+ Schema.Struct({
12
+ package: Schema.String,
13
+ version: Schema.String,
14
+ skill: Schema.String,
15
+ digest: DigestSchema,
16
+ }),
17
+ ]);
18
+ export type CatalogProvenance = typeof CatalogProvenanceSchema.Type;
19
+
5
20
  export const ManagedSkillOutputSchema = Schema.Struct({
6
21
  resourceId: Schema.String,
7
22
  path: Schema.String,
@@ -10,16 +25,26 @@ export const ManagedSkillOutputSchema = Schema.Struct({
10
25
  mode: Schema.Literals(["copy", "symlink"]),
11
26
  kind: Schema.Literals(["directory", "symlink"]),
12
27
  digest: DigestSchema,
13
- catalog: Schema.optional(
14
- Schema.Struct({
15
- source: Schema.String,
16
- repository: Schema.String,
17
- resolved: Schema.String,
18
- }),
19
- ),
28
+ catalog: Schema.optional(CatalogProvenanceSchema),
20
29
  });
21
30
  export type ManagedSkillOutput = typeof ManagedSkillOutputSchema.Type;
22
31
 
32
+ export const ManagedInstructionOutputSchema = Schema.Struct({
33
+ resourceId: Schema.Literal("setup:claude-instructions"),
34
+ path: Schema.String,
35
+ sourcePath: Schema.String,
36
+ mode: Schema.Literal("symlink"),
37
+ kind: Schema.Literal("symlink"),
38
+ digest: DigestSchema,
39
+ });
40
+ export type ManagedInstructionOutput = typeof ManagedInstructionOutputSchema.Type;
41
+
42
+ export const ManagedOutputSchema = Schema.Union([
43
+ ManagedSkillOutputSchema,
44
+ ManagedInstructionOutputSchema,
45
+ ]);
46
+ export type ManagedOutput = typeof ManagedOutputSchema.Type;
47
+
23
48
  export const EffectTsgoLockSchema = Schema.Struct({
24
49
  effectTsgoVersion: Schema.String,
25
50
  typescriptPackage: Schema.String,
@@ -46,7 +71,7 @@ export const DevKitLockSchema = Schema.Struct({
46
71
  effectTsgo: Schema.optional(EffectTsgoLockSchema),
47
72
  }),
48
73
  ),
49
- outputs: Schema.Array(ManagedSkillOutputSchema),
74
+ outputs: Schema.Array(ManagedOutputSchema),
50
75
  });
51
76
  export type DevKitLock = typeof DevKitLockSchema.Type;
52
77
 
@@ -188,8 +188,14 @@ const selectedNames = (
188
188
  return selected;
189
189
  };
190
190
 
191
- const summary = (description: string, fallback: string): string => {
192
- const text = description || fallback;
191
+ const displayValue = (value: string): string =>
192
+ [...value].map((character) => {
193
+ const code = character.charCodeAt(0);
194
+ return code <= 31 || (code >= 127 && code <= 159) ? " " : character;
195
+ }).join("").replace(/\s+/g, " ").trim();
196
+
197
+ const summary = (description: string, defaultDescription: string): string => {
198
+ const text = displayValue(description || defaultDescription);
193
199
  const firstSentence = text.match(/^.*?[.!?](?:\s|$)/)?.[0]?.trim() ?? text;
194
200
  return firstSentence.length > 96 ? `${firstSentence.slice(0, 93).trimEnd()}…` : firstSentence;
195
201
  };
@@ -219,15 +225,16 @@ export const addSkills = Effect.fn("addManagedSkills")(function* (
219
225
  options: ManagerOptions,
220
226
  ) {
221
227
  const current = yield* readManifest(options, true);
222
- const catalog = yield* loadSkillCatalog(yield* packageRoot());
223
- const known = new Set([...catalog.skills.map((skill) => skill.name), ...Object.keys(catalog.families)]);
228
+ const catalog = yield* loadSkillCatalog(yield* packageRoot(), current.projectDir);
229
+ const known = new Set([...catalog.skills.map((skill) => skill.selector), ...Object.keys(catalog.families)]);
224
230
  const unknown = names.filter((name) => !known.has(name));
225
231
  if (unknown.length > 0) {
226
232
  return yield* new SkillManagerError({
227
233
  message: `unknown skill${unknown.length === 1 ? "" : "s"}: ${unknown.join(", ")}. Try \`dev-kit search ${unknown[0]}\`.`,
228
234
  });
229
235
  }
230
- for (const source of catalog.lock?.sources ?? []) {
236
+ const sourceFamilies = catalog.lock?.sources ?? [];
237
+ for (const source of sourceFamilies) {
231
238
  if (!names.includes(source.id)) continue;
232
239
  yield* printStatus(
233
240
  "info",
@@ -255,7 +262,7 @@ export const removeSkills = Effect.fn("removeManagedSkills")(function* (
255
262
  options: ManagerOptions,
256
263
  ) {
257
264
  const current = yield* readManifest(options);
258
- const catalog = yield* loadSkillCatalog(yield* packageRoot());
265
+ const catalog = yield* loadSkillCatalog(yield* packageRoot(), current.projectDir);
259
266
  const before = selectedNames(
260
267
  current.manifest.include,
261
268
  current.manifest.exclude ?? [],
@@ -287,43 +294,63 @@ export const listSkills = Effect.fn("listManagedSkills")(function* (
287
294
  ) {
288
295
  const fs = yield* FileSystem.FileSystem;
289
296
  const paths = yield* resolvePaths(options);
290
- const catalog = yield* loadSkillCatalog(yield* packageRoot());
297
+ const catalog = yield* loadSkillCatalog(yield* packageRoot(), paths.projectDir);
291
298
  const manifest = (yield* fs.exists(paths.manifestPath))
292
299
  ? (yield* readManifest(options)).manifest
293
300
  : { include: [], exclude: [] };
294
301
  const selected = selectedNames(manifest.include, manifest.exclude ?? [], catalog.families);
295
302
  const query = options.query?.toLowerCase();
296
303
  const visible = catalog.skills.filter((skill) =>
297
- (options.all || selected.has(skill.name)) &&
298
- (!query || `${skill.name} ${skill.description} ${skill.source}`.toLowerCase().includes(query)),
304
+ (options.all || selected.has(skill.selector)) &&
305
+ (!query || `${skill.selector} ${skill.description} ${skill.source}`.toLowerCase().includes(query)),
306
+ );
307
+ const catalogSelectors = new Set(catalog.skills.map((skill) => skill.selector));
308
+ const unavailable = [...selected].filter((selector) =>
309
+ !catalogSelectors.has(selector) && (!query || selector.toLowerCase().includes(query))
299
310
  );
300
- if (visible.length === 0) {
311
+ if (visible.length === 0 && unavailable.length === 0) {
301
312
  yield* printStatus("info", query ? "No matching skills" : "No skills selected");
302
313
  if (!query && !options.all) yield* printDetail("Browse with: dev-kit list --all");
303
314
  return;
304
315
  }
305
316
  for (const skill of visible) {
306
- const marker = selected.has(skill.name) ? "✓" : " ";
317
+ const marker = selected.has(skill.selector) ? "✓" : " ";
307
318
  const origin = skill.bundled ? "built in" : skill.source;
308
- const provenance = skill.bundled ? "" : ` [${skill.source}]`;
309
- yield* printLine(`${marker} ${skill.name}${provenance} ${summary(skill.description, origin)}`);
319
+ const provenance = skill.package
320
+ ? ` [installed ${displayValue(skill.package.version)}]`
321
+ : skill.bundled ? "" : ` [${skill.source}]`;
322
+ yield* printLine(`${marker} ${skill.selector}${provenance} ${summary(skill.description, origin)}`);
323
+ }
324
+ for (const selector of unavailable) {
325
+ yield* printLine(`! ${selector} [unavailable] install or repair the selected direct dependency`);
310
326
  }
311
327
  yield* printLine();
312
- yield* printLine(`${selected.size} selected · ${catalog.skills.length} approved`);
328
+ yield* printLine(`${selected.size} selected · ${catalog.skills.length} available`);
313
329
  });
314
330
 
315
- export const showSkill = Effect.fn("showCatalogSkill")(function* (name: string) {
316
- const catalog = yield* loadSkillCatalog(yield* packageRoot());
317
- const skill = catalog.skills.find((candidate) => candidate.name === name);
331
+ export const showSkill = Effect.fn("showCatalogSkill")(function* (
332
+ name: string,
333
+ options: ManagerOptions,
334
+ ) {
335
+ const paths = yield* resolvePaths(options);
336
+ const catalog = yield* loadSkillCatalog(yield* packageRoot(), paths.projectDir);
337
+ const skill = catalog.skills.find((candidate) => candidate.selector === name);
318
338
  if (!skill) return yield* new SkillManagerError({ message: `unknown skill: ${name}` });
319
- yield* printLine(skill.name);
320
- if (skill.description) yield* printLine(skill.description);
339
+ yield* printLine(skill.selector);
340
+ if (skill.description) yield* printLine(displayValue(skill.description));
341
+ if (skill.package) {
342
+ yield* printLine(`Source: installed package`);
343
+ yield* printLine(`Package: ${skill.package.name}`);
344
+ yield* printLine(`Version: ${displayValue(skill.package.version)}`);
345
+ return;
346
+ }
321
347
  yield* printLine(`Source: ${skill.bundled ? "dev-kit (built in)" : skill.source}`);
322
348
  if (!skill.bundled) {
323
349
  const source = catalog.lock?.sources.find((candidate) => candidate.id === skill.source);
324
350
  if (source) {
325
351
  yield* printLine(`Repository: ${source.repository}`);
326
352
  yield* printLine(`Approved commit: ${source.resolved}`);
353
+ return;
327
354
  }
328
355
  }
329
356
  });
@@ -345,22 +372,22 @@ export const chooseSkillsToAdd = Effect.fn("chooseSkillsToAdd")(function* (
345
372
  return yield* new SkillManagerError({ message: "pass one or more skill names, or run this command in a terminal" });
346
373
  }
347
374
  const current = yield* readManifest(options, true);
348
- const catalog = yield* loadSkillCatalog(yield* packageRoot());
375
+ const catalog = yield* loadSkillCatalog(yield* packageRoot(), current.projectDir);
349
376
  const selected = selectedNames(
350
377
  current.manifest.include,
351
378
  current.manifest.exclude ?? [],
352
379
  catalog.families,
353
380
  );
354
- const available = catalog.skills.filter((skill) => !selected.has(skill.name));
381
+ const available = catalog.skills.filter((skill) => !selected.has(skill.selector));
355
382
  if (available.length === 0) {
356
- yield* printStatus("success", "All approved skills are selected");
383
+ yield* printStatus("success", "All available skills are selected");
357
384
  return;
358
385
  }
359
386
  const names = yield* Prompt.multiSelect({
360
387
  message: "Choose skills to add",
361
388
  choices: available.map((skill) => ({
362
- title: skill.name,
363
- value: skill.name,
389
+ title: skill.selector,
390
+ value: skill.selector,
364
391
  description: summary(skill.description, skill.source),
365
392
  })),
366
393
  min: 1,
@@ -375,7 +402,7 @@ export const chooseSkillsToRemove = Effect.fn("chooseSkillsToRemove")(function*
375
402
  return yield* new SkillManagerError({ message: "pass one or more skill names, or run this command in a terminal" });
376
403
  }
377
404
  const current = yield* readManifest(options);
378
- const catalog = yield* loadSkillCatalog(yield* packageRoot());
405
+ const catalog = yield* loadSkillCatalog(yield* packageRoot(), current.projectDir);
379
406
  const selected = selectedNames(
380
407
  current.manifest.include,
381
408
  current.manifest.exclude ?? [],
@@ -387,11 +414,20 @@ export const chooseSkillsToRemove = Effect.fn("chooseSkillsToRemove")(function*
387
414
  }
388
415
  const names = yield* Prompt.multiSelect({
389
416
  message: "Choose skills to remove",
390
- choices: catalog.skills.filter((skill) => selected.has(skill.name)).map((skill) => ({
391
- title: skill.name,
392
- value: skill.name,
393
- description: summary(skill.description, skill.source),
394
- })),
417
+ choices: [
418
+ ...catalog.skills.filter((skill) => selected.has(skill.selector)).map((skill) => ({
419
+ title: skill.selector,
420
+ value: skill.selector,
421
+ description: summary(skill.description, skill.source),
422
+ })),
423
+ ...[...selected]
424
+ .filter((selector) => !catalog.skills.some((skill) => skill.selector === selector))
425
+ .map((selector) => ({
426
+ title: selector,
427
+ value: selector,
428
+ description: "Selected but currently unavailable",
429
+ })),
430
+ ],
395
431
  min: 1,
396
432
  });
397
433
  yield* removeSkills(names, options);
@@ -0,0 +1,43 @@
1
+ import { isTypeScriptPackageName } from "./typescript-package-name.ts";
2
+
3
+ /** An immediate Agent Skill directory name. */
4
+ export const SKILL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
5
+
6
+ const PACKAGE_SKILL_SELECTOR_PATTERN = /^(?<package>[^#]+)#(?<skill>[^#]+)$/;
7
+
8
+ type StaticSkillSelector = {
9
+ readonly type: "static";
10
+ readonly name: string;
11
+ };
12
+
13
+ type PackageSkillSelector = {
14
+ readonly type: "package";
15
+ readonly package: string;
16
+ readonly skill: string;
17
+ };
18
+
19
+ type SkillSelector = StaticSkillSelector | PackageSkillSelector;
20
+
21
+ /**
22
+ * The JSON-schema-compatible pattern for static selectors and exact package
23
+ * selectors. Package names intentionally use the same rules as package-backed
24
+ * skill sources.
25
+ */
26
+ export const SKILL_SELECTOR_PATTERN =
27
+ /^(?:[a-z0-9]+(?:-[a-z0-9]+)*|(?:[a-z0-9][a-z0-9._-]*|@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*)#[a-z0-9]+(?:-[a-z0-9]+)*)$/;
28
+
29
+ export const isSkillName = (value: string): boolean => SKILL_NAME_PATTERN.test(value);
30
+
31
+ /** Parse an exact, canonical manifest skill selector. */
32
+ export const parseSkillSelector = (value: string): SkillSelector | undefined => {
33
+ if (isSkillName(value)) return { type: "static", name: value };
34
+
35
+ const match = PACKAGE_SKILL_SELECTOR_PATTERN.exec(value);
36
+ const packageName = match?.groups?.package;
37
+ const skill = match?.groups?.skill;
38
+ if (packageName === undefined || skill === undefined ||
39
+ !isTypeScriptPackageName(packageName) || !isSkillName(skill)) {
40
+ return undefined;
41
+ }
42
+ return { type: "package", package: packageName, skill };
43
+ };