pi-skill-router 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/src/sync.ts ADDED
@@ -0,0 +1,869 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import type { Api, Model, UserMessage } from "@earendil-works/pi-ai";
3
+ import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
4
+ import { createHash, randomUUID } from "node:crypto";
5
+ import {
6
+ cpSync,
7
+ existsSync,
8
+ lstatSync,
9
+ mkdirSync,
10
+ readdirSync,
11
+ readFileSync,
12
+ realpathSync,
13
+ renameSync,
14
+ rmSync,
15
+ statSync,
16
+ writeFileSync,
17
+ } from "node:fs";
18
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
19
+ import { cacheDir, exposedDir, gatewayDir, leafSkillFile, legacyExposedDir } from "./paths";
20
+ import { isCollectionId, isSafeGitRef, isSlug, loadCollections, saveCollections, type RegistryCollection, type RegistryRoute } from "./registry";
21
+
22
+ export interface RepoSpec {
23
+ repo: string;
24
+ url: string;
25
+ ref?: string;
26
+ name: string;
27
+ cacheKey: string;
28
+ }
29
+
30
+ export interface UpstreamSkill {
31
+ name: string;
32
+ description: string;
33
+ path: string;
34
+ }
35
+
36
+ export interface WorkflowSummary {
37
+ skill: string;
38
+ summary: string;
39
+ }
40
+
41
+ export interface AddCollectionOptions {
42
+ repo: string;
43
+ id?: string;
44
+ gateway?: string;
45
+ description?: string;
46
+ skills: "all" | string[];
47
+ summaries?: WorkflowSummary[];
48
+ }
49
+
50
+ export interface AddCollectionResult {
51
+ id: string;
52
+ gateway: string;
53
+ skills: string[];
54
+ exposedDir: string;
55
+ }
56
+
57
+ export interface UpdateCollectionResult {
58
+ id: string;
59
+ kept: string[];
60
+ dropped: string[];
61
+ newUpstream: string[];
62
+ }
63
+
64
+ export interface SelectionUpdateResult {
65
+ id: string;
66
+ selected: string[];
67
+ }
68
+
69
+ function slugify(value: string): string {
70
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
71
+ }
72
+
73
+ function cacheKeyWithRef(base: string, ref: string | undefined): string {
74
+ if (!ref) return base;
75
+ const digest = createHash("sha1").update(ref).digest("hex").slice(0, 10);
76
+ return `${base}__ref-${digest}`;
77
+ }
78
+
79
+ export function parseRepoSpec(input: string): RepoSpec {
80
+ const trimmed = input.trim();
81
+ if (!trimmed) throw new Error("Repository is required");
82
+
83
+ const directLocal = resolve(trimmed);
84
+ const localRefMatch = trimmed.match(/^(.*?)[@#]([A-Za-z0-9_./-]+)$/);
85
+ const localCandidate = localRefMatch?.[1] ?? trimmed;
86
+ const local = existsSync(directLocal) ? directLocal : resolve(localCandidate);
87
+ const localRef = existsSync(directLocal) ? undefined : localRefMatch?.[2];
88
+ if (localRef && !isSafeGitRef(localRef)) throw new Error(`Unsupported git ref "${localRef}"`);
89
+ if (existsSync(local) && statSync(local).isDirectory()) {
90
+ const name = slugify(basename(local));
91
+ if (!name) throw new Error(`Cannot derive a collection name from ${trimmed}`);
92
+ const canonical = realpathSync(local);
93
+ const digest = createHash("sha1").update(canonical).digest("hex").slice(0, 10);
94
+ return { repo: canonical, url: canonical, ref: localRef, name, cacheKey: cacheKeyWithRef(`local__${name}__${digest}`, localRef) };
95
+ }
96
+
97
+ const match = trimmed.match(/^(?:https:\/\/github\.com\/)?([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?(?:[@#]([A-Za-z0-9_./-]+))?$/);
98
+ if (!match) {
99
+ throw new Error(`Unsupported repository spec "${trimmed}". Use owner/repo[@ref], a GitHub URL, or a local path.`);
100
+ }
101
+ const [, owner, name, ref] = match;
102
+ if (ref && !isSafeGitRef(ref)) throw new Error(`Unsupported git ref "${ref}"`);
103
+ return {
104
+ repo: `${owner}/${name}`,
105
+ url: `https://github.com/${owner}/${name}.git`,
106
+ ref,
107
+ name: slugify(name),
108
+ cacheKey: cacheKeyWithRef(`github.com__${owner}__${name}`, ref),
109
+ };
110
+ }
111
+
112
+ function git(args: string[], cwd?: string): string {
113
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
114
+ }
115
+
116
+ function ensureDirectoryNotSymlink(path: string, label: string): void {
117
+ try {
118
+ const stats = lstatSync(path);
119
+ if (stats.isSymbolicLink()) throw new Error(`Refusing symlinked managed ${label} directory: ${path}`);
120
+ if (!stats.isDirectory()) throw new Error(`Managed ${label} path is not a directory: ${path}`);
121
+ } catch (error) {
122
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
123
+ mkdirSync(path, { recursive: true });
124
+ return;
125
+ }
126
+ throw error;
127
+ }
128
+ }
129
+
130
+ function prepareManagedDirectories(root: string): void {
131
+ ensureDirectoryNotSymlink(root, "root");
132
+ ensureDirectoryNotSymlink(join(root, "cache"), "cache");
133
+ ensureDirectoryNotSymlink(join(root, "exposed"), "exposed");
134
+ ensureDirectoryNotSymlink(join(root, "exposed", "collections"), "collection exposure");
135
+ }
136
+
137
+ function copySkillFiles(source: string, destination: string): void {
138
+ mkdirSync(destination, { recursive: true });
139
+ for (const entry of readdirSync(source)) {
140
+ if (entry === "SKILL.md") continue;
141
+ const sourceEntry = join(source, entry);
142
+ const destinationEntry = join(destination, entry);
143
+ const stats = lstatSync(sourceEntry);
144
+ if (stats.isSymbolicLink()) throw new Error(`Refusing symlinked skill resource: ${sourceEntry}`);
145
+ if (stats.isDirectory()) copySkillFiles(sourceEntry, destinationEntry);
146
+ else cpSync(sourceEntry, destinationEntry);
147
+ }
148
+ }
149
+
150
+ function copyWrappedSkill(source: string, destination: string, skillName: string): void {
151
+ const sourceSkillFile = join(source, "SKILL.md");
152
+ if (!existsSync(sourceSkillFile) || lstatSync(sourceSkillFile).isSymbolicLink()) {
153
+ throw new Error(`Selected skill "${skillName}" has an unsafe SKILL.md`);
154
+ }
155
+ copySkillFiles(source, destination);
156
+ writeFileSync(join(destination, "SKILL.md"), wrapSkillContent(readFileSync(sourceSkillFile, "utf8"), skillName), "utf8");
157
+ }
158
+
159
+ function assertSafeCacheDirectory(destination: string): void {
160
+ if (!existsSync(destination)) return;
161
+ if (lstatSync(destination).isSymbolicLink()) {
162
+ throw new Error(`Refusing symlinked cache directory: ${destination}`);
163
+ }
164
+ const gitDir = join(destination, ".git");
165
+ if (!existsSync(gitDir)) return;
166
+ const gitStats = lstatSync(gitDir);
167
+ if (gitStats.isSymbolicLink() || !gitStats.isDirectory()) {
168
+ throw new Error(`Refusing non-directory or symlinked cache metadata: ${gitDir}`);
169
+ }
170
+ if (containsSymlink(gitDir)) {
171
+ throw new Error(`Refusing cache metadata containing symlinks: ${gitDir}`);
172
+ }
173
+ }
174
+
175
+ function cloneOrUpdate(spec: RepoSpec, destination: string, ref?: string): string {
176
+ assertSafeCacheDirectory(destination);
177
+ const gitDir = join(destination, ".git");
178
+ if (existsSync(gitDir)) {
179
+ const configuredOrigin = git(["remote", "get-url", "origin"], destination);
180
+ if (configuredOrigin !== spec.url) {
181
+ rmSync(destination, { recursive: true, force: true });
182
+ }
183
+ }
184
+
185
+ if (existsSync(join(destination, ".git"))) {
186
+ const target = ref ?? spec.ref ?? "HEAD";
187
+ if (target !== "HEAD" && !isSafeGitRef(target)) throw new Error(`Unsupported git ref "${target}"`);
188
+ git(["fetch", "--quiet", "--depth", "1", "origin", "--", target], destination);
189
+ git(["reset", "--quiet", "--hard", "FETCH_HEAD"], destination);
190
+ } else {
191
+ mkdirSync(dirname(destination), { recursive: true });
192
+ rmSync(destination, { recursive: true, force: true });
193
+ const branchArgs = spec.ref ? ["--branch", spec.ref] : [];
194
+ git(["clone", "--quiet", "--depth", "1", ...branchArgs, spec.url, destination]);
195
+ }
196
+ return git(["rev-parse", "--abbrev-ref", "HEAD"], destination);
197
+ }
198
+
199
+ /** Clone (or refresh) the collection cache and return the scanned upstream skills. */
200
+ export function fetchCollectionSkills(root: string, spec: RepoSpec): { cache: string; ref: string; skills: UpstreamSkill[] } {
201
+ return withLock(root, () => {
202
+ prepareManagedDirectories(root);
203
+ const cache = cacheDir(root, spec.cacheKey);
204
+ const resolvedRef = cloneOrUpdate(spec, cache, spec.ref);
205
+ return { cache, ref: spec.ref ?? resolvedRef, skills: scanSkills(cache) };
206
+ });
207
+ }
208
+
209
+ function frontmatterValue(frontmatter: string, key: string): string | undefined {
210
+ const match = frontmatter.match(new RegExp(`^${key}:\\s*(.+)$`, "m"));
211
+ return match?.[1]?.trim().replace(/^["']|["']$/g, "");
212
+ }
213
+
214
+ const FRONTMATTER_BLOCK = /^(---\r?\n)([\s\S]*?)(\r?\n---)(?=\r?\n|$)/;
215
+
216
+ function isIgnoredSkillDirectory(entry: string): boolean {
217
+ return entry === ".git" || entry === "node_modules" || entry === "test" || entry === "tests" || entry === "testdata" || entry === "fixtures";
218
+ }
219
+
220
+ export function scanSkills(repoDir: string, dir = repoDir): UpstreamSkill[] {
221
+ const skills: UpstreamSkill[] = [];
222
+ for (const entry of readdirSync(dir)) {
223
+ if (isIgnoredSkillDirectory(entry)) continue;
224
+ const full = join(dir, entry);
225
+ const stats = lstatSync(full);
226
+ if (stats.isSymbolicLink()) {
227
+ // Repositories such as marketingskills use symlinked metadata files
228
+ // (for example CLAUDE.md). They are outside the skill tree and do not
229
+ // need to be copied; directory symlinks remain rejected to avoid
230
+ // traversing outside the cloned repository.
231
+ let targetStats: ReturnType<typeof statSync>;
232
+ try {
233
+ targetStats = statSync(full);
234
+ } catch {
235
+ throw new Error(`Refusing broken symlink inside the repository: ${full.slice(repoDir.length + 1)}`);
236
+ }
237
+ if (targetStats.isDirectory()) {
238
+ throw new Error(`Refusing symlinked entry inside the repository: ${full.slice(repoDir.length + 1)}`);
239
+ }
240
+ continue;
241
+ }
242
+ if (!stats.isDirectory()) continue;
243
+ const skillFile = join(full, "SKILL.md");
244
+ try {
245
+ if (lstatSync(skillFile).isSymbolicLink()) {
246
+ throw new Error(`Refusing symlinked SKILL.md: ${full.slice(repoDir.length + 1)}`);
247
+ }
248
+ } catch (error) {
249
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
250
+ // This directory does not contain a SKILL.md file.
251
+ } else if (error instanceof Error && error.message.startsWith("Refusing symlinked")) {
252
+ throw error;
253
+ }
254
+ }
255
+ if (existsSync(skillFile)) {
256
+ const frontmatter = readFileSync(skillFile, "utf8").match(FRONTMATTER_BLOCK)?.[2];
257
+ const name = frontmatter && frontmatterValue(frontmatter, "name");
258
+ const description = frontmatter && frontmatterValue(frontmatter, "description");
259
+ if (name && description && isSlug(name)) {
260
+ const relative = full.slice(repoDir.length + 1).split(sep).join("/");
261
+ skills.push({ name, description, path: relative });
262
+ }
263
+ }
264
+ skills.push(...scanSkills(repoDir, full));
265
+ }
266
+ return skills.sort((a, b) => a.name.localeCompare(b.name));
267
+ }
268
+
269
+ function assertUniqueUpstreamNames(skills: UpstreamSkill[]): void {
270
+ const seen = new Map<string, string>();
271
+ for (const skill of skills) {
272
+ const existing = seen.get(skill.name);
273
+ if (existing) {
274
+ throw new Error(`Duplicate upstream skill name "${skill.name}" at ${existing} and ${skill.path}`);
275
+ }
276
+ seen.set(skill.name, skill.path);
277
+ }
278
+ }
279
+
280
+ const STOP_WORDS = new Set([
281
+ "and", "the", "for", "with", "from", "that", "this", "use", "when", "you",
282
+ "your", "can", "are", "all", "its", "into", "skill", "skills",
283
+ ]);
284
+
285
+ /** Derive routing terms from a skill name and optional description. */
286
+ export function deriveTerms(name: string, description = ""): string[] {
287
+ const tokens = name.split("-");
288
+ const terms = new Set<string>([tokens.join(" "), ...tokens]);
289
+ for (const token of tokens) {
290
+ if (token.length > 3 && token.endsWith("s") && !/(ss|is|us)$/.test(token)) {
291
+ terms.add(token.slice(0, -1));
292
+ }
293
+ if (token === "doc" || token === "docs") {
294
+ terms.add("doc");
295
+ terms.add("docs");
296
+ terms.add("document");
297
+ terms.add("documents");
298
+ }
299
+ }
300
+
301
+ if (description) {
302
+ const descWords = description.toLowerCase().match(/[a-z0-9]{3,}/g) ?? [];
303
+ for (const word of descWords) {
304
+ if (!STOP_WORDS.has(word)) terms.add(word);
305
+ }
306
+ }
307
+
308
+ return [...terms];
309
+ }
310
+
311
+ function wrapSkillContent(content: string, name: string): string {
312
+ const match = content.match(FRONTMATTER_BLOCK);
313
+ if (!match) throw new Error(`Skill ${name} has no frontmatter block`);
314
+ const eol = match[1].includes("\r") ? "\r\n" : "\n";
315
+ let frontmatter = match[2].replace(/^name:[^\r\n]*/m, `name: ${name}`);
316
+ if (/^disable-model-invocation:/m.test(frontmatter)) {
317
+ frontmatter = frontmatter.replace(/^disable-model-invocation:[^\r\n]*/m, "disable-model-invocation: true");
318
+ } else {
319
+ frontmatter += `${eol}disable-model-invocation: true`;
320
+ }
321
+ return content.replace(match[0], `${match[1]}${frontmatter}${match[3]}`);
322
+ }
323
+
324
+ function yamlScalar(value: string): string {
325
+ return JSON.stringify(value);
326
+ }
327
+
328
+ function fallbackWorkflowSummary(description: string): string {
329
+ const primary = description.match(/^.*?[.!?](?=\s|$)/)?.[0]?.trim() ?? description.trim();
330
+ const summary = primary
331
+ .replace(/^When the user wants to\s+/i, "")
332
+ .replace(/^When the user needs to\s+/i, "")
333
+ .replace(/^When the user asks to\s+/i, "")
334
+ .replace(/^Use when the user wants to\s+/i, "")
335
+ .replace(/^Write\s+/i, "Write ")
336
+ .replace(/\.$/, "");
337
+ if (!summary) return "Apply this workflow.";
338
+ return `${summary.charAt(0).toUpperCase()}${summary.slice(1)}.`;
339
+ }
340
+
341
+ function gatewayContent(root: string, collection: RegistryCollection, leaves: UpstreamSkill[]): string {
342
+ const gateway = gatewayDir(root, collection.id, collection.gateway);
343
+ const workflows = leaves.map((skill) => [
344
+ `- \`${skill.name}\` — ${collection.routes.find((route) => route.skill === skill.name)?.summary ?? fallbackWorkflowSummary(skill.description)}`,
345
+ ` Read \`${relative(gateway, leafSkillFile(root, collection.id, skill.name))}\` before applying this workflow.`,
346
+ ]).flat();
347
+
348
+ return [
349
+ "---",
350
+ `name: ${yamlScalar(collection.gateway)}`,
351
+ `description: ${yamlScalar(collection.description)}`,
352
+ "---",
353
+ "",
354
+ "## Available workflows",
355
+ "",
356
+ "Select the one workflow that best fits the user's request, then read its SKILL.md before proceeding.",
357
+ "",
358
+ ...workflows,
359
+ "",
360
+ "If no workflow clearly fits, ask the user to choose. Do not load unrelated workflows.",
361
+ "",
362
+ ].join("\n");
363
+ }
364
+
365
+ interface ExposedSwap {
366
+ exposed: string;
367
+ commit(): void;
368
+ rollback(): void;
369
+ }
370
+
371
+ function swapExposed(temporary: string, exposed: string): ExposedSwap {
372
+ const backup = `${exposed}.backup-${process.pid}-${randomUUID()}`;
373
+ const hadPrevious = existsSync(exposed);
374
+ if (hadPrevious) renameSync(exposed, backup);
375
+ try {
376
+ renameSync(temporary, exposed);
377
+ } catch (error) {
378
+ if (hadPrevious && existsSync(backup)) renameSync(backup, exposed);
379
+ throw error;
380
+ }
381
+ return {
382
+ exposed,
383
+ commit: () => {
384
+ try {
385
+ rmSync(backup, { recursive: true, force: true });
386
+ } catch {
387
+ // The committed exposed tree and registry remain valid if backup cleanup fails.
388
+ }
389
+ },
390
+ rollback: () => {
391
+ rmSync(exposed, { recursive: true, force: true });
392
+ if (hadPrevious && existsSync(backup)) renameSync(backup, exposed);
393
+ },
394
+ };
395
+ }
396
+
397
+ function materialize(root: string, collection: RegistryCollection, leaves: UpstreamSkill[], repoDir: string): ExposedSwap {
398
+ ensureDirectoryNotSymlink(root, "root");
399
+ ensureDirectoryNotSymlink(join(root, "exposed"), "exposed");
400
+ ensureDirectoryNotSymlink(join(root, "exposed", "collections"), "collection exposure");
401
+ const names = collection.routes.map((route) => route.skill);
402
+ if (new Set(names).size !== names.length) {
403
+ throw new Error(`Skill name collision in collection "${collection.id}": duplicate upstream skill names`);
404
+ }
405
+
406
+ const exposed = exposedDir(root, collection.id);
407
+ const legacy = legacyExposedDir(root, collection.id);
408
+ const temporary = join(root, "exposed", "collections", `.tmp-${collection.id}-${process.pid}`);
409
+ rmSync(temporary, { recursive: true, force: true });
410
+ mkdirSync(temporary, { recursive: true });
411
+ const temporaryReal = realpathSync(temporary);
412
+ try {
413
+ for (const route of collection.routes) {
414
+ const source = join(repoDir, route.path);
415
+ const skillFile = join(source, "SKILL.md");
416
+ if (!existsSync(skillFile)) {
417
+ throw new Error(`Selected skill "${route.skill}" no longer exists at ${route.path}`);
418
+ }
419
+ const destination = join(temporary, "leaves", route.skill);
420
+ copyWrappedSkill(source, destination, route.skill);
421
+ const destinationReal = realpathSync(destination);
422
+ if (!destinationReal.startsWith(temporaryReal + sep)) {
423
+ throw new Error(`Refusing materialization escaping the exposed directory: ${route.path}`);
424
+ }
425
+ }
426
+ const gateway = join(temporary, "gateway");
427
+ mkdirSync(gateway, { recursive: true });
428
+ writeFileSync(join(gateway, "SKILL.md"), gatewayContent(root, collection, leaves), "utf8");
429
+ const swap = swapExposed(temporary, exposed);
430
+ return {
431
+ exposed,
432
+ commit: () => {
433
+ swap.commit();
434
+ if (legacy !== join(root, "exposed", "collections")) {
435
+ rmSync(legacy, { recursive: true, force: true });
436
+ }
437
+ },
438
+ rollback: () => swap.rollback(),
439
+ };
440
+ } catch (error) {
441
+ rmSync(temporary, { recursive: true, force: true });
442
+ throw error;
443
+ }
444
+ }
445
+
446
+ function pause(ms: number): void {
447
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
448
+ }
449
+
450
+ function processIsAlive(pid: number): boolean {
451
+ try {
452
+ process.kill(pid, 0);
453
+ return true;
454
+ } catch {
455
+ return false;
456
+ }
457
+ }
458
+
459
+ function withLock<T>(root: string, action: () => T): T {
460
+ ensureDirectoryNotSymlink(root, "root");
461
+ const lock = join(root, ".lock");
462
+ const ownerPath = join(lock, "owner");
463
+ let acquired = false;
464
+ for (let attempt = 0; attempt < 200 && !acquired; attempt += 1) {
465
+ try {
466
+ mkdirSync(lock);
467
+ try {
468
+ writeFileSync(ownerPath, `${process.pid}\n`, "utf8");
469
+ } catch (error) {
470
+ rmSync(lock, { recursive: true, force: true });
471
+ throw error;
472
+ }
473
+ acquired = true;
474
+ } catch (error) {
475
+ if (acquired) throw error;
476
+ try {
477
+ const age = Date.now() - statSync(lock).mtimeMs;
478
+ let owner: number | undefined;
479
+ try {
480
+ owner = Number.parseInt(readFileSync(ownerPath, "utf8"), 10);
481
+ } catch {
482
+ // An ownerless lock may be left by a process killed during initialization.
483
+ }
484
+ if (Number.isInteger(owner)) {
485
+ if (processIsAlive(owner as number)) {
486
+ pause(50);
487
+ continue;
488
+ }
489
+ rmSync(lock, { recursive: true, force: true });
490
+ } else if (age > 1_000) {
491
+ rmSync(lock, { recursive: true, force: true });
492
+ }
493
+ } catch {
494
+ // lock vanished or is still being initialized
495
+ }
496
+ pause(50);
497
+ }
498
+ }
499
+ if (!acquired) throw new Error("Another skill-router operation is in progress; try again later");
500
+ try {
501
+ return action();
502
+ } finally {
503
+ rmSync(lock, { recursive: true, force: true });
504
+ }
505
+ }
506
+
507
+ function summaryPrompt(skills: UpstreamSkill[]): string {
508
+ return [
509
+ "Write a compact navigation summary for each external workflow below.",
510
+ "Return JSON only: an array of objects with exactly skill and summary string fields.",
511
+ "Each summary is one sentence, 8–18 words, imperative or declarative, task-oriented, and preserves the source meaning.",
512
+ "Do not use trigger wrappers such as 'When the user wants to'. Do not include quoted trigger phrases, cross-workflow references, file paths, Markdown, or extra keys.",
513
+ "Include each listed skill exactly once, without inventing workflows.",
514
+ "", "Workflows:",
515
+ ...skills.map((skill) => `- ${skill.name}: ${skill.description}`),
516
+ ].join("\n");
517
+ }
518
+
519
+ function responseText(response: { content: Array<{ type: string; text?: string }> }): string {
520
+ return response.content.filter((part) => part.type === "text").map((part) => part.text ?? "").join("\n").trim();
521
+ }
522
+
523
+ function validWorkflowSummary(value: unknown): value is WorkflowSummary {
524
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
525
+ const keys = Object.keys(value);
526
+ if (keys.length !== 2 || !keys.includes("skill") || !keys.includes("summary")) return false;
527
+ const { skill, summary } = value as WorkflowSummary;
528
+ if (typeof skill !== "string" || typeof summary !== "string") return false;
529
+ if (summary.includes("\n") || /[`*_#[\]<>]/.test(summary)) return false;
530
+ if (/^(when the user|use when|also use)/i.test(summary)) return false;
531
+ const words = summary.trim().split(/\s+/);
532
+ return words.length >= 8 && words.length <= 18 && /[.!?]$/.test(summary.trim());
533
+ }
534
+
535
+ function fallbackWorkflowSummaries(skills: UpstreamSkill[]): WorkflowSummary[] {
536
+ return skills.map((skill) => ({ skill: skill.name, summary: fallbackWorkflowSummary(skill.description) }));
537
+ }
538
+
539
+ export async function generateWorkflowSummaries(
540
+ registry: ModelRegistry,
541
+ model: Model<Api> | undefined,
542
+ skills: UpstreamSkill[],
543
+ signal?: AbortSignal,
544
+ ): Promise<WorkflowSummary[]> {
545
+ if (!model) return fallbackWorkflowSummaries(skills);
546
+ try {
547
+ const auth = await registry.getApiKeyAndHeaders(model);
548
+ if (!auth.ok) return fallbackWorkflowSummaries(skills);
549
+ const message: UserMessage = { role: "user", content: [{ type: "text", text: summaryPrompt(skills) }], timestamp: Date.now() };
550
+ const response = await registry.complete(model, { systemPrompt: "You create concise, faithful workflow navigation labels.", messages: [message] }, {
551
+ apiKey: auth.apiKey, headers: auth.headers, signal, maxTokens: Math.max(128, skills.length * 32), temperature: 0, cacheRetention: "none",
552
+ });
553
+ const parsed: unknown = JSON.parse(responseText(response));
554
+ if (!Array.isArray(parsed)) throw new Error("Expected an array");
555
+ if (!parsed.every(validWorkflowSummary)) throw new Error("Invalid workflow summary");
556
+ const summaries = new Map(parsed.map((entry) => [entry.skill, entry.summary.trim()]));
557
+ if (summaries.size !== skills.length || skills.some((skill) => !summaries.has(skill.name))) throw new Error("Incomplete workflow summaries");
558
+ return skills.map((skill) => ({ skill: skill.name, summary: summaries.get(skill.name)! }));
559
+ } catch {
560
+ return fallbackWorkflowSummaries(skills);
561
+ }
562
+ }
563
+
564
+ export function suggestCollectionDescription(skills: UpstreamSkill[]): string {
565
+ const capabilities = [...new Set(skills.map((skill) => skill.name.replaceAll("-", " ")))];
566
+ const list = capabilities.length < 2
567
+ ? capabilities[0]
568
+ : `${capabilities.slice(0, -1).join(", ")} and ${capabilities.at(-1)}`;
569
+ return list
570
+ ? `Expert workflows spanning ${list}.`
571
+ : "Expert workflows for the selected collection.";
572
+ }
573
+
574
+ export function defaultCollectionId(spec: RepoSpec): string {
575
+ return spec.url.startsWith("https://github.com/")
576
+ ? slugify(spec.repo.replace("/", "-"))
577
+ : spec.name;
578
+ }
579
+
580
+ function sharedSkillNamespace(skills: UpstreamSkill[]): string | undefined {
581
+ const [first] = skills;
582
+ if (!first) return undefined;
583
+ const namespace = first.name.split("-", 1)[0];
584
+ if (!namespace || !skills.every((skill) => skill.name.startsWith(`${namespace}-`))) return undefined;
585
+ return namespace;
586
+ }
587
+
588
+ function assertNameSpaceAvailable(
589
+ existing: RegistryCollection[],
590
+ candidate: { id: string; gateway: string; sourceUrl?: string; sourceRef?: string },
591
+ ): void {
592
+ if (existing.some((collection) => collection.id === candidate.id)) {
593
+ throw new Error(`Collection id "${candidate.id}" is already installed`);
594
+ }
595
+ if (existing.some((collection) => collection.source.url === candidate.sourceUrl && collection.source.ref === candidate.sourceRef)) {
596
+ throw new Error(`Source ${candidate.sourceUrl}@${candidate.sourceRef} is already installed`);
597
+ }
598
+ if (existing.some((collection) => collection.gateway === candidate.gateway)) {
599
+ throw new Error(`Gateway "${candidate.gateway}" is already used by another collection`);
600
+ }
601
+ }
602
+
603
+ export async function addCollection(root: string, options: AddCollectionOptions): Promise<AddCollectionResult> {
604
+ return withLock(root, () => addCollectionLocked(root, options));
605
+ }
606
+
607
+ function addCollectionLocked(root: string, options: AddCollectionOptions): AddCollectionResult {
608
+ prepareManagedDirectories(root);
609
+ const spec = parseRepoSpec(options.repo);
610
+ const id = options.id ?? defaultCollectionId(spec);
611
+ if (!isCollectionId(id)) throw new Error(`Invalid or reserved collection id "${id}"`);
612
+ const existing = loadCollections(root);
613
+
614
+ const cache = cacheDir(root, spec.cacheKey);
615
+ const resolvedRef = cloneOrUpdate(spec, cache, spec.ref);
616
+ const upstream = scanSkills(cache);
617
+ if (upstream.length === 0) throw new Error(`No skills (SKILL.md with name and description) found in ${spec.repo}`);
618
+ assertUniqueUpstreamNames(upstream);
619
+
620
+ const selected =
621
+ options.skills === "all"
622
+ ? upstream
623
+ : options.skills.map((name) => {
624
+ const found = upstream.find((skill) => skill.name === name);
625
+ if (!found) throw new Error(`Skill "${name}" not found. Available: ${upstream.map((skill) => skill.name).join(", ")}`);
626
+ return found;
627
+ });
628
+
629
+ const namespace = sharedSkillNamespace(selected);
630
+ const gateway = options.gateway
631
+ ?? (namespace && !existing.some((collection) => collection.gateway === namespace) ? namespace : id);
632
+ if (!isSlug(gateway)) throw new Error(`Invalid gateway name "${gateway}"`);
633
+
634
+ assertNameSpaceAvailable(existing, {
635
+ id,
636
+ gateway,
637
+ sourceUrl: spec.url,
638
+ sourceRef: spec.ref ?? resolvedRef,
639
+ });
640
+
641
+ const summaryBySkill = new Map(options.summaries?.map((summary) => [summary.skill, summary.summary]));
642
+ const collection: RegistryCollection = {
643
+ id,
644
+ gateway,
645
+ mode: "suggest",
646
+ enabled: true,
647
+ description: options.description ?? suggestCollectionDescription(selected),
648
+ source: { repo: spec.repo, url: spec.url, ref: spec.ref ?? resolvedRef, cacheKey: spec.cacheKey },
649
+ routes: selected.map<RegistryRoute>((skill) => ({
650
+ skill: skill.name,
651
+ path: skill.path,
652
+ terms: deriveTerms(skill.name, skill.description),
653
+ summary: summaryBySkill.get(skill.name) ?? fallbackWorkflowSummary(skill.description),
654
+ })),
655
+ };
656
+
657
+ const swap = materialize(root, collection, selected, cache);
658
+ try {
659
+ saveCollections(root, [...existing, collection]);
660
+ } catch (error) {
661
+ swap.rollback();
662
+ throw error;
663
+ }
664
+ swap.commit();
665
+ return { id, gateway, skills: selected.map((skill) => skill.name), exposedDir: swap.exposed };
666
+ }
667
+
668
+ function updateCollectionLocked(root: string, id: string): UpdateCollectionResult {
669
+ prepareManagedDirectories(root);
670
+ const collections = loadCollections(root);
671
+ const collection = collections.find((entry) => entry.id === id);
672
+ if (!collection) throw new Error(`Collection "${id}" is not installed`);
673
+
674
+ const cache = cacheDir(root, collection.source.cacheKey);
675
+ assertSafeCacheDirectory(cache);
676
+ cloneOrUpdate(
677
+ { repo: collection.source.repo, url: collection.source.url, ref: collection.source.ref, name: collection.id, cacheKey: collection.source.cacheKey },
678
+ cache,
679
+ collection.source.ref,
680
+ );
681
+ const upstream = scanSkills(cache);
682
+ assertUniqueUpstreamNames(upstream);
683
+ const upstreamByName = new Map(upstream.map((skill) => [skill.name, skill]));
684
+
685
+ const kept = collection.routes.flatMap((route) => {
686
+ const current = upstreamByName.get(route.skill);
687
+ return current ? [{ ...route, path: current.path }] : [];
688
+ });
689
+ const dropped = collection.routes.filter((route) => !upstreamByName.has(route.skill)).map((route) => route.skill);
690
+ const selected = new Set(collection.routes.map((route) => route.skill));
691
+
692
+ const updated: RegistryCollection = {
693
+ ...collection,
694
+ routes: kept.map((route) => ({
695
+ ...route,
696
+ summary: route.summary ?? fallbackWorkflowSummary(upstreamByName.get(route.skill)!.description),
697
+ })),
698
+ };
699
+ const swap = materialize(root, updated, upstream.filter((skill) => selected.has(skill.name)), cache);
700
+ try {
701
+ saveCollections(root, collections.map((entry) => (entry.id === id ? updated : entry)));
702
+ } catch (error) {
703
+ swap.rollback();
704
+ throw error;
705
+ }
706
+ swap.commit();
707
+ return { id, kept: kept.map((route) => route.skill), dropped, newUpstream: [...upstreamByName.keys()].filter((name) => !selected.has(name)) };
708
+ }
709
+
710
+ export async function updateCollection(root: string, id: string): Promise<UpdateCollectionResult> {
711
+ return withLock(root, () => updateCollectionLocked(root, id));
712
+ }
713
+
714
+ export async function updateCollectionSelection(root: string, id: string, selectedNames: string[]): Promise<SelectionUpdateResult> {
715
+ return withLock(root, () => {
716
+ prepareManagedDirectories(root);
717
+ const collections = loadCollections(root);
718
+ const collection = collections.find((entry) => entry.id === id);
719
+ if (!collection) throw new Error(`Collection "${id}" is not installed`);
720
+
721
+ const cache = cacheDir(root, collection.source.cacheKey);
722
+ assertSafeCacheDirectory(cache);
723
+ const upstream = scanSkills(cache);
724
+ assertUniqueUpstreamNames(upstream);
725
+ const upstreamByName = new Map(upstream.map((skill) => [skill.name, skill]));
726
+ const uniqueSelected = [...new Set(selectedNames)];
727
+ const missing = uniqueSelected.filter((name) => !upstreamByName.has(name));
728
+ if (missing.length > 0) throw new Error(`Skills not found upstream: ${missing.join(", ")}`);
729
+
730
+ const existingRoutes = new Map(collection.routes.map((route) => [route.skill, route]));
731
+ const routes = uniqueSelected.map<RegistryRoute>((name) => {
732
+ const skill = upstreamByName.get(name)!;
733
+ const existing = existingRoutes.get(name);
734
+ return {
735
+ skill: name,
736
+ path: skill.path,
737
+ terms: existing?.terms ?? deriveTerms(name, skill.description),
738
+ summary: existing?.summary ?? fallbackWorkflowSummary(skill.description),
739
+ };
740
+ });
741
+ const updated: RegistryCollection = { ...collection, routes };
742
+ assertNameSpaceAvailable(
743
+ collections.filter((entry) => entry.id !== id),
744
+ { id, gateway: collection.gateway },
745
+ );
746
+
747
+ const swap = materialize(root, updated, routes.map((route) => upstreamByName.get(route.skill)!), cache);
748
+ try {
749
+ saveCollections(root, collections.map((entry) => (entry.id === id ? updated : entry)));
750
+ } catch (error) {
751
+ swap.rollback();
752
+ throw error;
753
+ }
754
+ swap.commit();
755
+ return { id, selected: uniqueSelected };
756
+ });
757
+ }
758
+
759
+ export function collectionSkillNames(root: string, id: string): string[] {
760
+ const collection = loadCollections(root).find((entry) => entry.id === id);
761
+ if (!collection) throw new Error(`Collection "${id}" is not installed`);
762
+ return scanSkills(cacheDir(root, collection.source.cacheKey)).map((skill) => skill.name);
763
+ }
764
+
765
+ export function removeCollection(root: string, id: string): void {
766
+ withLock(root, () => {
767
+ prepareManagedDirectories(root);
768
+ const collections = loadCollections(root);
769
+ if (!collections.some((collection) => collection.id === id)) throw new Error(`Collection "${id}" is not installed`);
770
+
771
+ const exposed = exposedDir(root, id);
772
+ const backup = `${exposed}.remove-${process.pid}-${randomUUID()}`;
773
+ const hadExposed = existsSync(exposed);
774
+ if (hadExposed) renameSync(exposed, backup);
775
+ try {
776
+ saveCollections(root, collections.filter((entry) => entry.id !== id));
777
+ } catch (error) {
778
+ if (hadExposed && existsSync(backup)) renameSync(backup, exposed);
779
+ throw error;
780
+ }
781
+ const legacy = legacyExposedDir(root, id);
782
+ if (legacy !== join(root, "exposed", "collections")) {
783
+ rmSync(legacy, { recursive: true, force: true });
784
+ }
785
+ try {
786
+ rmSync(backup, { recursive: true, force: true });
787
+ } catch {
788
+ // Registry state is already committed; an orphaned backup is harmless and can be cleaned later.
789
+ }
790
+ });
791
+ }
792
+
793
+ export function updateCollectionDescription(root: string, id: string, description: string): RegistryCollection {
794
+ const normalized = description.trim();
795
+ if (!normalized) throw new Error("Collection capability summary is required");
796
+ return withLock(root, () => {
797
+ prepareManagedDirectories(root);
798
+ const collections = loadCollections(root);
799
+ const collection = collections.find((entry) => entry.id === id);
800
+ if (!collection) throw new Error(`Collection "${id}" is not installed`);
801
+
802
+ const cache = cacheDir(root, collection.source.cacheKey);
803
+ assertSafeCacheDirectory(cache);
804
+ const upstreamByName = new Map(scanSkills(cache).map((skill) => [skill.name, skill]));
805
+ const selected = collection.routes.map((route) => {
806
+ const skill = upstreamByName.get(route.skill);
807
+ if (!skill) throw new Error(`Selected skill "${route.skill}" no longer exists at ${route.path}`);
808
+ return skill;
809
+ });
810
+ const updated = { ...collection, description: normalized };
811
+ const swap = materialize(root, updated, selected, cache);
812
+ try {
813
+ saveCollections(root, collections.map((entry) => (entry.id === id ? updated : entry)));
814
+ } catch (error) {
815
+ swap.rollback();
816
+ throw error;
817
+ }
818
+ swap.commit();
819
+ return updated;
820
+ });
821
+ }
822
+
823
+ export function setCollectionEnabled(root: string, id: string, enabled: boolean): RegistryCollection {
824
+ return withLock(root, () => {
825
+ prepareManagedDirectories(root);
826
+ const collections = loadCollections(root);
827
+ const collection = collections.find((entry) => entry.id === id);
828
+ if (!collection) throw new Error(`Collection "${id}" is not installed`);
829
+ const updated = { ...collection, enabled };
830
+ saveCollections(root, collections.map((entry) => (entry.id === id ? updated : entry)));
831
+ return updated;
832
+ });
833
+ }
834
+
835
+ function containsSymlink(dir: string): boolean {
836
+ for (const entry of readdirSync(dir)) {
837
+ const full = join(dir, entry);
838
+ const stats = lstatSync(full);
839
+ if (stats.isSymbolicLink()) return true;
840
+ if (stats.isDirectory() && containsSymlink(full)) return true;
841
+ }
842
+ return false;
843
+ }
844
+
845
+ function isExistingDirectoryWithoutSymlink(path: string): boolean {
846
+ try {
847
+ const stats = lstatSync(path);
848
+ return stats.isDirectory() && !stats.isSymbolicLink();
849
+ } catch {
850
+ return false;
851
+ }
852
+ }
853
+
854
+ export function exposedSkillPaths(root: string): string[] {
855
+ const exposedRoot = join(root, "exposed");
856
+ if (!isExistingDirectoryWithoutSymlink(root) || !isExistingDirectoryWithoutSymlink(exposedRoot)) return [];
857
+ return loadCollections(root)
858
+ .filter((collection) => collection.enabled)
859
+ .map((collection) => gatewayDir(root, collection.id, collection.gateway))
860
+ .filter((path) => {
861
+ if (!isExistingDirectoryWithoutSymlink(path)) return false;
862
+ try {
863
+ if (!realpathSync(path).startsWith(realpathSync(exposedRoot) + sep)) return false;
864
+ return !containsSymlink(path);
865
+ } catch {
866
+ return false;
867
+ }
868
+ });
869
+ }