dsh-skill-compat-plus 0.0.0-stage → 0.1.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/lib/index.js ADDED
@@ -0,0 +1,761 @@
1
+ // src/config.ts
2
+ var CONFIG_KEYS = [
3
+ "providerName",
4
+ "sources",
5
+ "extraDirs",
6
+ "watch",
7
+ "watchUsePolling",
8
+ "watchStabilityThresholdMs",
9
+ "watchPollIntervalMs",
10
+ "watchMaxProjects",
11
+ "slugifyNames",
12
+ "synthesizeDescription"
13
+ ];
14
+ var SOURCE_KEYS = ["enabled", "project", "root", "rank"];
15
+ var SOURCE_IDS = ["claude", "codex", "zcode"];
16
+ function isPlainObject(value) {
17
+ return typeof value === "object" && value !== null && !Array.isArray(value);
18
+ }
19
+ function booleanField(source, key) {
20
+ const value = source[key];
21
+ if (value === void 0) return void 0;
22
+ if (typeof value !== "boolean") throw new Error(`config field "${key}" must be a boolean, got ${typeof value}`);
23
+ return value;
24
+ }
25
+ function positiveNumberField(source, key) {
26
+ const value = source[key];
27
+ if (value === void 0) return void 0;
28
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
29
+ throw new Error(`config field "${key}" must be a positive finite number`);
30
+ }
31
+ return value;
32
+ }
33
+ function stringField(source, key) {
34
+ const value = source[key];
35
+ if (value === void 0) return void 0;
36
+ if (typeof value !== "string" || value.trim().length === 0) {
37
+ throw new Error(`config field "${key}" must be a non-empty string`);
38
+ }
39
+ return value;
40
+ }
41
+ function resolveSourceOption(id, option) {
42
+ const settings = { enabled: true, project: true };
43
+ if (option === void 0 || typeof option === "boolean") {
44
+ settings.enabled = option !== false;
45
+ return settings;
46
+ }
47
+ if (!isPlainObject(option)) {
48
+ throw new Error(`config sources.${id} must be a boolean or an object`);
49
+ }
50
+ for (const key of Object.keys(option)) {
51
+ if (!SOURCE_KEYS.includes(key)) {
52
+ throw new Error(`unknown config sources.${id} key "${key}" (expected ${SOURCE_KEYS.join(", ")})`);
53
+ }
54
+ }
55
+ const enabled = booleanField(option, "enabled");
56
+ const project = booleanField(option, "project");
57
+ const root = stringField(option, "root");
58
+ const rank = option.rank;
59
+ if (rank !== void 0 && (typeof rank !== "number" || !Number.isFinite(rank))) {
60
+ throw new Error("config sources rank must be a finite number");
61
+ }
62
+ return {
63
+ enabled: enabled ?? true,
64
+ project: project ?? true,
65
+ root,
66
+ rank
67
+ };
68
+ }
69
+ function resolveExtraDirs(value) {
70
+ if (value === void 0) return [];
71
+ if (!Array.isArray(value)) throw new Error("config extraDirs must be an array");
72
+ const dirs = [];
73
+ for (const raw of value) {
74
+ if (!isPlainObject(raw)) throw new Error("config extraDirs entries must be objects like { path, label?, rank? }");
75
+ for (const key of Object.keys(raw)) {
76
+ if (!["path", "label", "rank"].includes(key)) {
77
+ throw new Error(`unknown config extraDirs entry key "${key}" (expected path, label, rank)`);
78
+ }
79
+ }
80
+ const path = stringField(raw, "path");
81
+ if (path === void 0) throw new Error('config extraDirs entry requires a non-empty "path"');
82
+ const label = stringField(raw, "label") ?? "custom";
83
+ const rank = raw.rank;
84
+ if (rank !== void 0 && (typeof rank !== "number" || !Number.isFinite(rank))) {
85
+ throw new Error("config extraDirs entry rank must be a finite number");
86
+ }
87
+ dirs.push({ path, label, rank: rank ?? 300 });
88
+ }
89
+ return dirs;
90
+ }
91
+ function resolveConfig(input) {
92
+ if (input !== void 0 && !isPlainObject(input)) {
93
+ throw new Error("plugin config must be an object");
94
+ }
95
+ const source = input ?? {};
96
+ for (const key of Object.keys(source)) {
97
+ if (!CONFIG_KEYS.includes(key)) {
98
+ throw new Error(`unknown plugin config key "${key}" (expected ${CONFIG_KEYS.join(", ")})`);
99
+ }
100
+ }
101
+ const providerName = stringField(source, "providerName") ?? "skill-compat-plus";
102
+ if (providerName === "runtime") {
103
+ throw new Error('config providerName "runtime" is reserved by the dsh skill registry');
104
+ }
105
+ const rawSources = source.sources;
106
+ if (rawSources !== void 0 && !isPlainObject(rawSources)) {
107
+ throw new Error("config sources must be an object with keys claude, codex, zcode");
108
+ }
109
+ for (const key of Object.keys(rawSources ?? {})) {
110
+ if (!SOURCE_IDS.includes(key)) {
111
+ throw new Error(`unknown config sources key "${key}" (expected ${SOURCE_IDS.join(", ")})`);
112
+ }
113
+ }
114
+ const sources = {
115
+ claude: resolveSourceOption("claude", rawSources?.claude),
116
+ codex: resolveSourceOption("codex", rawSources?.codex),
117
+ zcode: resolveSourceOption("zcode", rawSources?.zcode)
118
+ };
119
+ const watch2 = booleanField(source, "watch") ?? true;
120
+ const watchUsePolling = booleanField(source, "watchUsePolling") ?? false;
121
+ const watchStabilityThresholdMs = positiveNumberField(source, "watchStabilityThresholdMs") ?? 200;
122
+ const watchPollIntervalMs = positiveNumberField(source, "watchPollIntervalMs") ?? 100;
123
+ const watchMaxProjects = positiveNumberField(source, "watchMaxProjects") ?? 128;
124
+ const slugifyNames = booleanField(source, "slugifyNames") ?? true;
125
+ const synthesizeDescription2 = booleanField(source, "synthesizeDescription") ?? true;
126
+ const extraDirs = resolveExtraDirs(source.extraDirs);
127
+ const anySourceEnabled = sources.claude.enabled || sources.codex.enabled || sources.zcode.enabled || extraDirs.length > 0;
128
+ return Object.freeze({
129
+ providerName,
130
+ sources,
131
+ extraDirs: Object.freeze(extraDirs),
132
+ watch: watch2,
133
+ watchUsePolling,
134
+ watchStabilityThresholdMs,
135
+ watchPollIntervalMs,
136
+ watchMaxProjects,
137
+ slugifyNames,
138
+ synthesizeDescription: synthesizeDescription2,
139
+ anySourceEnabled
140
+ });
141
+ }
142
+
143
+ // src/provider.ts
144
+ import { readFile as readFile3 } from "node:fs/promises";
145
+ import { basename as basename2 } from "node:path";
146
+
147
+ // src/frontmatter.ts
148
+ import { readFile } from "node:fs/promises";
149
+ import { join } from "node:path";
150
+ import { parse as parseYaml } from "yaml";
151
+ var SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
152
+ function isSkillName(name2) {
153
+ return SKILL_NAME.test(name2);
154
+ }
155
+ function splitFrontmatter(raw) {
156
+ const firstLineEnd = raw.indexOf("\n");
157
+ if (firstLineEnd < 0) return void 0;
158
+ const firstLine = raw.slice(0, firstLineEnd).replace(/\r$/, "");
159
+ if (firstLine !== "---") return void 0;
160
+ const start = firstLineEnd + 1;
161
+ let lineStart = start;
162
+ while (lineStart <= raw.length) {
163
+ const nextNewline = raw.indexOf("\n", lineStart);
164
+ const lineEnd = nextNewline < 0 ? raw.length : nextNewline;
165
+ const line = raw.slice(lineStart, lineEnd).replace(/\r$/, "");
166
+ if (line === "---") {
167
+ const yamlText = raw.slice(start, lineStart);
168
+ let parsed;
169
+ try {
170
+ parsed = parseYaml(yamlText);
171
+ } catch {
172
+ return void 0;
173
+ }
174
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
175
+ return { data: parsed, body: raw.slice(nextNewline < 0 ? raw.length : nextNewline + 1) };
176
+ }
177
+ if (nextNewline < 0) return void 0;
178
+ lineStart = nextNewline + 1;
179
+ }
180
+ return void 0;
181
+ }
182
+ var LEGACY_INVOCATION_KEYS = ["disableModelInvocation", "modelInvocable", "userInvocable"];
183
+ function lenientBoolean(value) {
184
+ if (typeof value === "boolean") return value;
185
+ if (typeof value === "number") {
186
+ if (value === 1) return true;
187
+ if (value === 0) return false;
188
+ return void 0;
189
+ }
190
+ if (typeof value === "string") {
191
+ const normalized = value.trim().toLowerCase();
192
+ if (["true", "yes", "on", "1"].includes(normalized)) return true;
193
+ if (["false", "no", "off", "0"].includes(normalized)) return false;
194
+ }
195
+ return void 0;
196
+ }
197
+ function slugifySkillName(input) {
198
+ return input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
199
+ }
200
+ function firstMeaningfulText(text) {
201
+ for (const line of text.split(/\r?\n/)) {
202
+ const stripped = line.replace(/^\s*#+\s*/, "").trim();
203
+ if (stripped.length > 0) return stripped;
204
+ }
205
+ return "";
206
+ }
207
+ function synthesizeDescription(body) {
208
+ const text = firstMeaningfulText(body);
209
+ if (text.length <= 200) return text;
210
+ const slice = text.slice(0, 200);
211
+ const lastSpace = slice.lastIndexOf(" ");
212
+ return (lastSpace >= 160 ? slice.slice(0, lastSpace) : slice) + "\u2026";
213
+ }
214
+ function stringField2(data, key) {
215
+ const value = data[key];
216
+ return typeof value === "string" && value.length > 0 ? value : void 0;
217
+ }
218
+ function mapForeignSkill(input) {
219
+ const { parsed, filePath } = input;
220
+ const data = parsed.data;
221
+ for (const key of LEGACY_INVOCATION_KEYS) {
222
+ if (key in data) {
223
+ return {
224
+ ok: false,
225
+ reason: `frontmatter field "${key}" is unsupported; use "disable-model-invocation"`
226
+ };
227
+ }
228
+ }
229
+ const rawName = stringField2(data, "name") ?? input.entryName;
230
+ let name2 = rawName;
231
+ if (!isSkillName(name2)) {
232
+ if (!input.slugify) {
233
+ return { ok: false, reason: `invalid skill name "${rawName}" and slugifyNames is disabled` };
234
+ }
235
+ const slug = slugifySkillName(rawName);
236
+ if (!isSkillName(slug)) {
237
+ return { ok: false, reason: `invalid skill name "${rawName}" cannot be converted to a dsh kebab-case name` };
238
+ }
239
+ name2 = slug;
240
+ }
241
+ let description = stringField2(data, "description");
242
+ if (description === void 0) {
243
+ if (!input.synthesize) {
244
+ return { ok: false, reason: "frontmatter requires a description and synthesizeDescription is disabled" };
245
+ }
246
+ description = synthesizeDescription(parsed.body);
247
+ if (description.length === 0) {
248
+ return { ok: false, reason: "frontmatter requires a description and the body is empty" };
249
+ }
250
+ }
251
+ const whenToUse = stringField2(data, "whenToUse") ?? stringField2(data, "when_to_use");
252
+ const disableModelInvocation = lenientBoolean(data["disable-model-invocation"]);
253
+ if (data["disable-model-invocation"] !== void 0 && disableModelInvocation === void 0) {
254
+ return { ok: false, reason: 'frontmatter field "disable-model-invocation" must be a boolean' };
255
+ }
256
+ const userInvocableRaw = data["user-invocable"];
257
+ let userInvocable = true;
258
+ if (userInvocableRaw !== void 0) {
259
+ const parsed2 = lenientBoolean(userInvocableRaw);
260
+ if (parsed2 === void 0) {
261
+ return { ok: false, reason: 'frontmatter field "user-invocable" must be a boolean' };
262
+ }
263
+ userInvocable = parsed2;
264
+ }
265
+ const modelInvocable = disableModelInvocation !== true && input.allowImplicitInvocation !== false;
266
+ return {
267
+ ok: true,
268
+ skill: {
269
+ name: name2,
270
+ description,
271
+ whenToUse,
272
+ invocation: { modelInvocable, userInvocable },
273
+ // 原始 frontmatter 全量保真:allowed-tools/model/hooks 等字段在 dsh
274
+ // 没有对应物,保留在 metadata 里可追溯,不做静默丢弃。
275
+ metadata: Object.freeze({ ...data }),
276
+ path: filePath,
277
+ directory: input.directory,
278
+ content: parsed.body.trim()
279
+ }
280
+ };
281
+ }
282
+ async function readCodexOpenAiPolicy(directory) {
283
+ let raw;
284
+ try {
285
+ raw = await readFile(join(directory, "agents", "openai.yaml"), { encoding: "utf8" });
286
+ } catch {
287
+ return void 0;
288
+ }
289
+ let parsed;
290
+ try {
291
+ parsed = parseYaml(raw);
292
+ } catch {
293
+ return void 0;
294
+ }
295
+ if (typeof parsed !== "object" || parsed === null) return void 0;
296
+ const policy = parsed["policy"];
297
+ if (typeof policy !== "object" || policy === null) return void 0;
298
+ return lenientBoolean(policy["allow_implicit_invocation"]);
299
+ }
300
+
301
+ // src/roots.ts
302
+ import { access } from "node:fs/promises";
303
+ import { homedir } from "node:os";
304
+ import { dirname, join as join2, resolve } from "node:path";
305
+ var DEFAULT_RANKS = {
306
+ claudeProject: 210,
307
+ zcodeProject: 220,
308
+ extra: 300,
309
+ claudeUser: 450,
310
+ codexUser: 460,
311
+ zcodeUser: 470
312
+ };
313
+ async function pathExists(path) {
314
+ try {
315
+ await access(path);
316
+ return true;
317
+ } catch {
318
+ return false;
319
+ }
320
+ }
321
+ async function findProjectRoot(cwd) {
322
+ let current = resolve(cwd);
323
+ for (; ; ) {
324
+ if (await pathExists(join2(current, ".git"))) return current;
325
+ const parent = dirname(current);
326
+ if (parent === current) return resolve(cwd);
327
+ current = parent;
328
+ }
329
+ }
330
+ async function resolveRoots(config, cwd) {
331
+ const roots = [];
332
+ const home = homedir();
333
+ if (cwd !== void 0) {
334
+ const projectRoot = await findProjectRoot(cwd);
335
+ if (config.sources.claude.enabled && config.sources.claude.project) {
336
+ roots.push({
337
+ path: join2(projectRoot, ".claude", "skills"),
338
+ source: "claude-code",
339
+ rank: DEFAULT_RANKS.claudeProject,
340
+ kind: "project"
341
+ });
342
+ }
343
+ if (config.sources.zcode.enabled && config.sources.zcode.project) {
344
+ roots.push({
345
+ path: join2(projectRoot, ".zcode", "skills"),
346
+ source: "zcode",
347
+ rank: DEFAULT_RANKS.zcodeProject,
348
+ kind: "project"
349
+ });
350
+ }
351
+ }
352
+ for (const dir of config.extraDirs) {
353
+ roots.push({ path: resolve(dir.path), source: dir.label, rank: dir.rank, kind: "extra" });
354
+ }
355
+ const claude = config.sources.claude;
356
+ if (claude.enabled) {
357
+ roots.push({
358
+ path: resolve(claude.root ?? join2(home, ".claude", "skills")),
359
+ source: "claude-code",
360
+ rank: claude.rank ?? DEFAULT_RANKS.claudeUser,
361
+ kind: "user"
362
+ });
363
+ }
364
+ const codex = config.sources.codex;
365
+ if (codex.enabled) {
366
+ roots.push({
367
+ path: resolve(codex.root ?? join2(home, ".codex", "skills")),
368
+ source: "codex",
369
+ rank: codex.rank ?? DEFAULT_RANKS.codexUser,
370
+ kind: "user"
371
+ });
372
+ }
373
+ const zcode = config.sources.zcode;
374
+ if (zcode.enabled) {
375
+ roots.push({
376
+ path: resolve(zcode.root ?? join2(home, ".zcode", "skills")),
377
+ source: "zcode",
378
+ rank: zcode.rank ?? DEFAULT_RANKS.zcodeUser,
379
+ kind: "user"
380
+ });
381
+ }
382
+ roots.sort((left, right) => left.rank - right.rank || left.path.localeCompare(right.path));
383
+ const seen = /* @__PURE__ */ new Set();
384
+ const unique = [];
385
+ for (const root of roots) {
386
+ if (seen.has(root.path)) continue;
387
+ seen.add(root.path);
388
+ unique.push(root);
389
+ }
390
+ return unique;
391
+ }
392
+
393
+ // src/scan.ts
394
+ import { readdir, readFile as readFile2 } from "node:fs/promises";
395
+ import { basename, join as join3 } from "node:path";
396
+ var CLAUDE_RESERVED_ENTRY = /^(synced|anthropic-skills)/;
397
+ function errorMessage(error) {
398
+ return error instanceof Error ? error.message : String(error);
399
+ }
400
+ async function readSkillText(path) {
401
+ try {
402
+ return await readFile2(path, { encoding: "utf8" });
403
+ } catch (error) {
404
+ if (error.code === "ENOENT") return void 0;
405
+ throw error;
406
+ }
407
+ }
408
+ async function scanRoot(root, options) {
409
+ let dirents;
410
+ try {
411
+ dirents = await readdir(root.path, { withFileTypes: true });
412
+ } catch (error) {
413
+ const code = error.code;
414
+ if (code === "ENOENT" || code === "ENOTDIR") {
415
+ return { root, skills: [], absent: true, issues: [] };
416
+ }
417
+ return { root, skills: [], absent: false, issues: [`${root.source}(${root.path}): failed to list directory: ${errorMessage(error)}`] };
418
+ }
419
+ const entries = [...dirents].sort((left, right) => left.name.localeCompare(right.name));
420
+ const skills = [];
421
+ const issues = [];
422
+ const seen = /* @__PURE__ */ new Map();
423
+ for (const entry of entries) {
424
+ if (entry.name.startsWith(".")) continue;
425
+ if (root.source === "claude-code" && CLAUDE_RESERVED_ENTRY.test(entry.name)) continue;
426
+ const isDirectory = entry.isDirectory();
427
+ const isFlatSkill = entry.isFile() && entry.name.endsWith(".md");
428
+ if (!isDirectory && !isFlatSkill) continue;
429
+ const entryPath = isDirectory ? join3(root.path, entry.name, "SKILL.md") : join3(root.path, entry.name);
430
+ const entryName = isDirectory ? entry.name : entry.name.slice(0, -3);
431
+ const directory = isDirectory ? join3(root.path, entry.name) : root.path;
432
+ let raw;
433
+ try {
434
+ raw = await readSkillText(entryPath);
435
+ } catch (error) {
436
+ issues.push(`${root.source}(${root.path}): failed to read ${entryPath}: ${errorMessage(error)}`);
437
+ continue;
438
+ }
439
+ if (raw === void 0) continue;
440
+ const parsed = splitFrontmatter(raw);
441
+ if (parsed === void 0) {
442
+ issues.push(`${entryPath} ignored: invalid or missing YAML frontmatter`);
443
+ continue;
444
+ }
445
+ const allowImplicit = isDirectory ? await readCodexOpenAiPolicy(directory) : void 0;
446
+ const mapped = mapForeignSkill({
447
+ filePath: entryPath,
448
+ directory,
449
+ entryName,
450
+ parsed,
451
+ slugify: options.slugify,
452
+ synthesize: options.synthesize,
453
+ allowImplicitInvocation: allowImplicit
454
+ });
455
+ if (!mapped.ok) {
456
+ issues.push(`${entryPath} ignored: ${mapped.reason}`);
457
+ continue;
458
+ }
459
+ const previous = seen.get(mapped.skill.name);
460
+ if (previous !== void 0) {
461
+ issues.push(
462
+ `${entryPath} ignored: duplicate skill name "${mapped.skill.name}" (already provided by ${previous})`
463
+ );
464
+ continue;
465
+ }
466
+ seen.set(mapped.skill.name, entryPath);
467
+ skills.push(mapped.skill);
468
+ }
469
+ return { root, skills, absent: false, issues };
470
+ }
471
+ function entryNameFor(directory, path) {
472
+ return basename(path) === "SKILL.md" ? basename(directory) : basename(path).replace(/\.md$/, "");
473
+ }
474
+
475
+ // src/watch.ts
476
+ import { stat } from "node:fs/promises";
477
+ import { watchFile, unwatchFile } from "node:fs";
478
+ import * as chokidar from "chokidar";
479
+ var PRESENCE_POLL_MS = 2500;
480
+ var DEBOUNCE_MS = 150;
481
+ var CompatWatchController = class {
482
+ constructor(options) {
483
+ this.options = options;
484
+ }
485
+ options;
486
+ states = /* @__PURE__ */ new Map();
487
+ debounceTimer;
488
+ disposed = false;
489
+ /** 注册要监视的根(幂等;list() 每轮把解析出的 roots 传进来)。 */
490
+ observe(entries) {
491
+ if (this.disposed) return;
492
+ for (const entry of entries) {
493
+ if (this.states.has(entry.path)) continue;
494
+ const state = { kind: entry.kind, chokidarWatcher: void 0, present: false };
495
+ this.states.set(entry.path, state);
496
+ watchFile(entry.path, { interval: PRESENCE_POLL_MS, persistent: false }, () => {
497
+ void this.refresh(entry.path, state);
498
+ });
499
+ void this.refresh(entry.path, state);
500
+ }
501
+ this.evictProjectOverflow();
502
+ }
503
+ /** 项目根数量超限时逐出最旧的项目根(Map 保持插入序)。 */
504
+ evictProjectOverflow() {
505
+ let projectCount = 0;
506
+ for (const state of this.states.values()) {
507
+ if (state.kind === "project") projectCount += 1;
508
+ }
509
+ for (const [path, state] of this.states) {
510
+ if (projectCount <= this.options.maxProjects) break;
511
+ if (state.kind !== "project") continue;
512
+ this.closeChokidar(state);
513
+ unwatchFile(path);
514
+ this.states.delete(path);
515
+ projectCount -= 1;
516
+ this.options.logger.warn(`skill-compat-plus: project watch limit (${this.options.maxProjects}) reached, dropped watch for ${path}`);
517
+ }
518
+ }
519
+ /** 存在性判定以 stat() 为准;watchFile 只充当变化信号。 */
520
+ async refresh(path, state) {
521
+ if (this.disposed) return;
522
+ let info;
523
+ try {
524
+ info = await stat(path);
525
+ } catch {
526
+ info = void 0;
527
+ }
528
+ const exists = info !== void 0 && info.isDirectory();
529
+ if (exists === state.present) return;
530
+ state.present = exists;
531
+ if (exists) {
532
+ this.openChokidar(path, state);
533
+ } else {
534
+ this.closeChokidar(state);
535
+ }
536
+ this.queueInvalidate();
537
+ }
538
+ openChokidar(path, state) {
539
+ if (state.chokidarWatcher !== void 0) return;
540
+ try {
541
+ const watcher = chokidar.watch(path, {
542
+ persistent: true,
543
+ ignoreInitial: true,
544
+ depth: 1,
545
+ atomic: true,
546
+ usePolling: this.options.usePolling,
547
+ interval: this.options.pollIntervalMs,
548
+ awaitWriteFinish: {
549
+ stabilityThreshold: this.options.stabilityThresholdMs,
550
+ pollInterval: this.options.pollIntervalMs
551
+ }
552
+ });
553
+ watcher.on("all", () => this.queueInvalidate());
554
+ watcher.on("error", (error) => {
555
+ this.options.logger.warn(
556
+ `skill-compat-plus: watcher error for ${path}: ${error instanceof Error ? error.message : String(error)}`
557
+ );
558
+ this.closeChokidar(state);
559
+ });
560
+ state.chokidarWatcher = watcher;
561
+ } catch (error) {
562
+ this.options.logger.warn(
563
+ `skill-compat-plus: failed to watch ${path}: ${error instanceof Error ? error.message : String(error)}`
564
+ );
565
+ }
566
+ }
567
+ closeChokidar(state) {
568
+ const watcher = state.chokidarWatcher;
569
+ state.chokidarWatcher = void 0;
570
+ if (watcher !== void 0) void watcher.close().catch(() => {
571
+ });
572
+ }
573
+ queueInvalidate() {
574
+ if (this.disposed || this.debounceTimer !== void 0) return;
575
+ this.debounceTimer = setTimeout(() => {
576
+ this.debounceTimer = void 0;
577
+ this.options.invalidate();
578
+ }, DEBOUNCE_MS);
579
+ this.debounceTimer.unref?.();
580
+ }
581
+ async dispose() {
582
+ this.disposed = true;
583
+ if (this.debounceTimer !== void 0) {
584
+ clearTimeout(this.debounceTimer);
585
+ this.debounceTimer = void 0;
586
+ }
587
+ for (const [path, state] of this.states) {
588
+ this.closeChokidar(state);
589
+ unwatchFile(path);
590
+ this.states.delete(path);
591
+ }
592
+ }
593
+ };
594
+
595
+ // src/provider.ts
596
+ function errorMessage2(error) {
597
+ return error instanceof Error ? error.message : String(error);
598
+ }
599
+ var CompatSkillProvider = class {
600
+ constructor(config, logger) {
601
+ this.config = config;
602
+ this.logger = logger;
603
+ this.name = config.providerName;
604
+ }
605
+ config;
606
+ logger;
607
+ name;
608
+ control;
609
+ watcher;
610
+ lastIssueSignature = "";
611
+ disposed = false;
612
+ /** 由 registerProvider 工厂调用;只能成功一次。 */
613
+ attach(control) {
614
+ if (this.control !== void 0) return;
615
+ this.control = control;
616
+ if (this.config.watch) {
617
+ this.watcher = new CompatWatchController({
618
+ invalidate: () => {
619
+ if (!this.disposed) this.control?.invalidate();
620
+ },
621
+ logger: this.logger,
622
+ usePolling: this.config.watchUsePolling,
623
+ stabilityThresholdMs: this.config.watchStabilityThresholdMs,
624
+ pollIntervalMs: this.config.watchPollIntervalMs,
625
+ maxProjects: this.config.watchMaxProjects
626
+ });
627
+ }
628
+ }
629
+ async list(options) {
630
+ options.signal?.throwIfAborted();
631
+ if (this.disposed) return [];
632
+ const roots = await resolveRoots(this.config, options.cwd);
633
+ const results = await Promise.all(
634
+ roots.map(async (root) => {
635
+ try {
636
+ const result = await scanRoot(root, {
637
+ slugify: this.config.slugifyNames,
638
+ synthesize: this.config.synthesizeDescription
639
+ });
640
+ return { root, skills: [...result.skills], issues: [...result.issues] };
641
+ } catch (error) {
642
+ return { root, skills: [], issues: [`${root.source}(${root.path}): scan failed: ${errorMessage2(error)}`] };
643
+ }
644
+ })
645
+ );
646
+ const candidates = [];
647
+ const issues = [];
648
+ for (const result of results) {
649
+ for (const issue of result.issues) {
650
+ issues.push(`${result.root.source}(${result.root.path}): ${issue}`);
651
+ }
652
+ for (const skill of result.skills) {
653
+ candidates.push(this.toCandidate(skill, result.root));
654
+ }
655
+ }
656
+ const signature = issues.join("\n");
657
+ if (signature !== this.lastIssueSignature) {
658
+ for (const issue of issues) this.logger.warn(issue);
659
+ this.lastIssueSignature = signature;
660
+ }
661
+ this.watcher?.observe(roots.map((root) => ({ path: root.path, kind: root.kind })));
662
+ return candidates;
663
+ }
664
+ async get(candidate, options) {
665
+ options.signal?.throwIfAborted();
666
+ if (this.disposed) return void 0;
667
+ const locator = candidate.locator;
668
+ if (typeof locator?.path !== "string" || typeof locator?.directory !== "string") return void 0;
669
+ let raw;
670
+ try {
671
+ raw = await readFile3(locator.path, { encoding: "utf8", signal: options.signal });
672
+ } catch (error) {
673
+ if (error.code === "ENOENT") return void 0;
674
+ throw error;
675
+ }
676
+ const parsed = splitFrontmatter(raw);
677
+ if (parsed === void 0) return void 0;
678
+ const allowImplicit = basename2(locator.path) === "SKILL.md" ? await readCodexOpenAiPolicy(locator.directory) : void 0;
679
+ const mapped = mapForeignSkill({
680
+ filePath: locator.path,
681
+ directory: locator.directory,
682
+ entryName: entryNameFor(locator.directory, locator.path),
683
+ parsed,
684
+ slugify: this.config.slugifyNames,
685
+ synthesize: this.config.synthesizeDescription,
686
+ allowImplicitInvocation: allowImplicit
687
+ });
688
+ if (!mapped.ok || mapped.skill.name !== candidate.name) return void 0;
689
+ const skill = mapped.skill;
690
+ return {
691
+ name: skill.name,
692
+ description: skill.description,
693
+ whenToUse: skill.whenToUse,
694
+ invocation: skill.invocation,
695
+ source: candidate.source,
696
+ provider: this.name,
697
+ resourceBase: { kind: "directory", path: skill.directory },
698
+ path: skill.path,
699
+ metadata: skill.metadata,
700
+ content: skill.content
701
+ };
702
+ }
703
+ toCandidate(skill, root) {
704
+ return {
705
+ name: skill.name,
706
+ description: skill.description,
707
+ whenToUse: skill.whenToUse,
708
+ invocation: skill.invocation,
709
+ source: root.source,
710
+ provider: this.name,
711
+ resourceBase: { kind: "directory", path: skill.directory },
712
+ path: skill.path,
713
+ metadata: skill.metadata,
714
+ rank: root.rank,
715
+ locator: { path: skill.path, directory: skill.directory }
716
+ };
717
+ }
718
+ async dispose() {
719
+ this.disposed = true;
720
+ await this.watcher?.dispose();
721
+ }
722
+ };
723
+
724
+ // src/index.ts
725
+ var name = "skill-compat-plus";
726
+ var inject = ["skills"];
727
+ function apply(ctx, config) {
728
+ let resolved;
729
+ try {
730
+ resolved = resolveConfig(config);
731
+ } catch (error) {
732
+ ctx.logger.warn(`skill-compat-plus disabled: invalid config: ${error instanceof Error ? error.message : String(error)}`);
733
+ return;
734
+ }
735
+ if (!resolved.anySourceEnabled) {
736
+ ctx.logger.warn("skill-compat-plus disabled: every source is turned off and extraDirs is empty");
737
+ return;
738
+ }
739
+ const provider = new CompatSkillProvider(resolved, ctx.logger);
740
+ try {
741
+ ctx.skills.registerProvider((control) => {
742
+ provider.attach(control);
743
+ return provider;
744
+ });
745
+ } catch (error) {
746
+ ctx.logger.warn(
747
+ `skill-compat-plus not registered: ${error instanceof Error ? error.message : String(error)}`
748
+ );
749
+ return;
750
+ }
751
+ ctx.effect(function* () {
752
+ yield async () => {
753
+ await provider.dispose();
754
+ };
755
+ }, "skill-compat-plus watch lifecycle");
756
+ }
757
+ export {
758
+ apply,
759
+ inject,
760
+ name
761
+ };