clud-bug 0.6.34 → 0.7.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/bin/clud-bug.js +10 -1353
  2. package/dist/cli/agents-md.d.ts +16 -0
  3. package/dist/cli/agents-md.d.ts.map +1 -0
  4. package/dist/cli/agents-md.js +226 -0
  5. package/dist/cli/agents-md.js.map +1 -0
  6. package/dist/cli/audit.d.ts +13 -0
  7. package/dist/cli/audit.d.ts.map +1 -0
  8. package/dist/cli/audit.js +90 -0
  9. package/dist/cli/audit.js.map +1 -0
  10. package/dist/cli/branch-protection.d.ts +57 -0
  11. package/dist/cli/branch-protection.d.ts.map +1 -0
  12. package/dist/cli/branch-protection.js +118 -0
  13. package/dist/cli/branch-protection.js.map +1 -0
  14. package/dist/cli/edit-workflow.d.ts +18 -0
  15. package/dist/cli/edit-workflow.d.ts.map +1 -0
  16. package/dist/cli/edit-workflow.js +43 -0
  17. package/dist/cli/edit-workflow.js.map +1 -0
  18. package/dist/cli/index.d.ts +8 -0
  19. package/dist/cli/index.d.ts.map +1 -0
  20. package/dist/cli/index.js +18 -0
  21. package/dist/cli/index.js.map +1 -0
  22. package/dist/cli/main.d.ts +3 -0
  23. package/dist/cli/main.d.ts.map +1 -0
  24. package/dist/cli/main.js +1336 -0
  25. package/dist/cli/main.js.map +1 -0
  26. package/dist/cli/skill-usage.d.ts +109 -0
  27. package/dist/cli/skill-usage.d.ts.map +1 -0
  28. package/dist/cli/skill-usage.js +380 -0
  29. package/dist/cli/skill-usage.js.map +1 -0
  30. package/dist/cli/skills.d.ts +56 -0
  31. package/dist/cli/skills.d.ts.map +1 -0
  32. package/dist/cli/skills.js +292 -0
  33. package/dist/cli/skills.js.map +1 -0
  34. package/dist/cli/update.d.ts +29 -0
  35. package/dist/cli/update.d.ts.map +1 -0
  36. package/dist/cli/update.js +186 -0
  37. package/dist/cli/update.js.map +1 -0
  38. package/dist/cli/usage.d.ts +142 -0
  39. package/dist/cli/usage.d.ts.map +1 -0
  40. package/dist/cli/usage.js +348 -0
  41. package/dist/cli/usage.js.map +1 -0
  42. package/dist/core/audit.d.ts +8 -0
  43. package/dist/core/audit.d.ts.map +1 -0
  44. package/dist/core/audit.js +47 -0
  45. package/dist/core/audit.js.map +1 -0
  46. package/dist/core/detect.d.ts +77 -0
  47. package/dist/core/detect.d.ts.map +1 -0
  48. package/dist/core/detect.js +262 -0
  49. package/dist/core/detect.js.map +1 -0
  50. package/dist/core/index.d.ts +11 -0
  51. package/dist/core/index.d.ts.map +1 -0
  52. package/dist/core/index.js +31 -0
  53. package/dist/core/index.js.map +1 -0
  54. package/dist/core/prompt-builder.d.ts +164 -0
  55. package/dist/core/prompt-builder.d.ts.map +1 -0
  56. package/dist/core/prompt-builder.js +419 -0
  57. package/dist/core/prompt-builder.js.map +1 -0
  58. package/dist/core/prompts.d.ts +9 -0
  59. package/dist/core/prompts.d.ts.map +1 -0
  60. package/dist/core/prompts.js +401 -0
  61. package/dist/core/prompts.js.map +1 -0
  62. package/dist/core/render-review.d.ts +6 -0
  63. package/dist/core/render-review.d.ts.map +1 -0
  64. package/dist/core/render-review.js +219 -0
  65. package/dist/core/render-review.js.map +1 -0
  66. package/dist/core/render.d.ts +13 -0
  67. package/dist/core/render.d.ts.map +1 -0
  68. package/dist/core/render.js +80 -0
  69. package/dist/core/render.js.map +1 -0
  70. package/dist/core/review-schema-zod.d.ts +240 -0
  71. package/dist/core/review-schema-zod.d.ts.map +1 -0
  72. package/dist/core/review-schema-zod.js +218 -0
  73. package/dist/core/review-schema-zod.js.map +1 -0
  74. package/dist/core/review-schema.d.ts +42 -0
  75. package/dist/core/review-schema.d.ts.map +1 -0
  76. package/dist/core/review-schema.js +156 -0
  77. package/dist/core/review-schema.js.map +1 -0
  78. package/dist/core/review-writeback.d.ts +139 -0
  79. package/dist/core/review-writeback.d.ts.map +1 -0
  80. package/dist/core/review-writeback.js +313 -0
  81. package/dist/core/review-writeback.js.map +1 -0
  82. package/dist/core/skills.d.ts +122 -0
  83. package/dist/core/skills.d.ts.map +1 -0
  84. package/dist/core/skills.js +636 -0
  85. package/dist/core/skills.js.map +1 -0
  86. package/package.json +30 -4
  87. package/{lib/agents-md.js → src/cli/agents-md.ts} +25 -14
  88. package/{lib/audit.js → src/cli/audit.ts} +37 -44
  89. package/{lib/branch-protection.js → src/cli/branch-protection.ts} +75 -11
  90. package/{lib/edit-workflow.js → src/cli/edit-workflow.ts} +32 -11
  91. package/src/cli/index.ts +101 -0
  92. package/src/cli/main.ts +1376 -0
  93. package/{lib/skill-usage.js → src/cli/skill-usage.ts} +168 -94
  94. package/src/cli/skills.ts +386 -0
  95. package/{lib/update.js → src/cli/update.ts} +68 -27
  96. package/{lib/usage.js → src/cli/usage.ts} +167 -76
  97. package/src/core/audit.ts +53 -0
  98. package/{lib/detect.js → src/core/detect.ts} +100 -47
  99. package/src/core/index.ts +155 -0
  100. package/src/core/prompt-builder.ts +561 -0
  101. package/{lib/prompts.js → src/core/prompts.ts} +16 -2
  102. package/{lib/render-review.js → src/core/render-review.ts} +57 -25
  103. package/{lib/render.js → src/core/render.ts} +36 -10
  104. package/src/core/review-schema-zod.ts +262 -0
  105. package/{lib/review-schema.js → src/core/review-schema.ts} +68 -5
  106. package/src/core/review-writeback.ts +446 -0
  107. package/{lib/skills.js → src/core/skills.ts} +339 -342
  108. package/templates/workflow-py.yml.tmpl +2 -2
  109. package/templates/workflow-ts.yml.tmpl +2 -2
  110. package/templates/workflow.yml.tmpl +17 -8
@@ -1,34 +1,86 @@
1
- import { mkdir, writeFile, readdir, readFile, rm, stat } from 'node:fs/promises';
2
- import { join } from 'node:path';
3
- import { homedir } from 'node:os';
4
- import { createHash } from 'node:crypto';
5
-
6
- const API_BASE = 'https://skills.sh/api/v1';
7
- const MAX_SKILLS = 8;
8
- const MANIFEST_FILE = '.clud-bug.json';
9
- const MANIFEST_VERSION = 1;
10
-
11
- // Canonical home for clud-bug's baseline skills.
12
- // PINNED TO A COMMIT SHA, NOT `main`. This re-couples the trust boundary
13
- // to clud-bug releases: a compromised commit on agent-skills@main cannot
14
- // silently land in users' Claude review skills mid-cycle. To roll new
15
- // skill content, bump BASELINE_SKILLS_REF below in the same clud-bug PR
16
- // that ships the corresponding bundled fallback update.
17
- // See thrillmade/agent-skills skills.sh `skills/<name>/SKILL.md` layout.
18
- const BASELINE_SKILLS_REF = '436963ed37cbd9c6a9b7a07e907d5a0a432fab59';
19
- const AGENT_SKILLS_BASE = process.env.CLUD_BUG_AGENT_SKILLS_BASE
20
- ?? `https://raw.githubusercontent.com/thrillmade/agent-skills/${BASELINE_SKILLS_REF}/skills`;
21
- const SKILL_FETCH_TIMEOUT_MS = 5000;
22
- const SKILL_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24h
1
+ // Pure skill helpers no FS, only the network via injectable fetch.
2
+ //
3
+ // Split from lib/skills.js during the v0.7.0 TS migration. The App
4
+ // (clud-bug-app) consumes these for review-time skill routing without
5
+ // pulling node:fs into the serverless bundle. CLI-only install/update
6
+ // helpers live in src/cli/skills.ts.
7
+ //
8
+ // The `_internal` debug-export pattern from lib/skills.js is removed
9
+ // here: every helper that needed test access has been promoted to a
10
+ // direct named export. Constants (`MAX_SKILLS`, `API_BASE`) and the
11
+ // shape normaliser (`normalizeList`) are now first-class core exports.
12
+
13
+ export const API_BASE = 'https://skills.sh/api/v1';
14
+ export const MAX_SKILLS = 8;
15
+
16
+ // Skill descriptors as returned by skills.sh after normaliseList()
17
+ // massages the API shape. The CLI install path adds further fields
18
+ // (`kind`, `content`) before passing to writeSkill — we type the
19
+ // result loosely with optional fields so a single shape works for
20
+ // both core (search results, ranking) and cli (write+manifest) sites.
21
+ export interface SkillDescriptor {
22
+ source: string;
23
+ name: string;
24
+ description: string;
25
+ installs: number;
26
+ kind?: string;
27
+ content?: string;
28
+ }
29
+
30
+ // Raw shape coming back from skills.sh. Tolerant: some endpoints return
31
+ // { skills: [...] }, some a bare array, some { results: [...] }, and
32
+ // individual items may use `source` or `repo`, `name` or `slug`.
33
+ type RawSkillItem = {
34
+ source?: string;
35
+ repo?: string;
36
+ name?: string;
37
+ slug?: string;
38
+ description?: string;
39
+ summary?: string;
40
+ installs?: number;
41
+ installCount?: number;
42
+ };
43
+
44
+ type RawSkillListResponse =
45
+ | RawSkillItem[]
46
+ | { skills?: RawSkillItem[]; results?: RawSkillItem[] }
47
+ | unknown;
48
+
49
+ export function normalizeList(data: RawSkillListResponse): SkillDescriptor[] {
50
+ // Tolerate either { skills: [...] } or a bare array.
51
+ const list: RawSkillItem[] = Array.isArray(data)
52
+ ? data
53
+ : ((data as { skills?: RawSkillItem[]; results?: RawSkillItem[] } | null)?.skills
54
+ || (data as { results?: RawSkillItem[] } | null)?.results
55
+ || []);
56
+ return list
57
+ .map((item) => ({
58
+ source: item.source || item.repo || '',
59
+ name: item.name || item.slug || '',
60
+ description: item.description || item.summary || '',
61
+ installs: item.installs || item.installCount || 0,
62
+ }))
63
+ .filter((s) => s.source && s.name);
64
+ }
65
+
66
+ interface SkillsClientOptions {
67
+ fetch?: typeof globalThis.fetch;
68
+ base?: string;
69
+ userAgent?: string;
70
+ }
23
71
 
24
72
  export class SkillsClient {
25
- constructor({ fetch = globalThis.fetch, base, userAgent = 'clud-bug' } = {}) {
73
+ private readonly fetch: typeof globalThis.fetch;
74
+ private readonly base: string;
75
+ private readonly userAgent: string;
76
+
77
+ constructor({ fetch = globalThis.fetch, base, userAgent = 'clud-bug' }: SkillsClientOptions = {}) {
26
78
  this.fetch = fetch;
27
- this.base = base ?? process.env.CLUD_BUG_SKILLS_SH_BASE ?? API_BASE;
79
+ this.base = base ?? process.env['CLUD_BUG_SKILLS_SH_BASE'] ?? API_BASE;
28
80
  this.userAgent = userAgent;
29
81
  }
30
82
 
31
- async #json(path) {
83
+ async #json(path: string): Promise<unknown> {
32
84
  const res = await this.fetch(`${this.base}${path}`, {
33
85
  headers: { 'User-Agent': this.userAgent, accept: 'application/json' },
34
86
  });
@@ -38,45 +90,48 @@ export class SkillsClient {
38
90
  return res.json();
39
91
  }
40
92
 
41
- async search(terms) {
93
+ async search(terms: string[]): Promise<SkillDescriptor[]> {
42
94
  const q = terms.filter(Boolean).join(' ').trim();
43
95
  if (!q) return [];
44
96
  const data = await this.#json(`/skills/search?q=${encodeURIComponent(q)}`);
45
97
  return normalizeList(data);
46
98
  }
47
99
 
48
- async curated() {
100
+ async curated(): Promise<SkillDescriptor[]> {
49
101
  const data = await this.#json('/skills/curated');
50
102
  return normalizeList(data);
51
103
  }
52
104
 
53
- async getContent(source, name) {
54
- const data = await this.#json(`/skills/${encodeURIComponent(source)}/${encodeURIComponent(name)}`);
105
+ async getContent(source: string, name: string): Promise<string> {
106
+ const data = (await this.#json(
107
+ `/skills/${encodeURIComponent(source)}/${encodeURIComponent(name)}`,
108
+ )) as { content?: unknown; body?: unknown; files?: Array<{ content?: unknown }> } | null;
55
109
  // The API may return content as `body`, `content`, or under `files[0].content`.
56
110
  // Try the documented shapes in order; fail loudly if none match so we know
57
111
  // the API contract changed.
58
112
  if (typeof data?.content === 'string') return data.content;
59
113
  if (typeof data?.body === 'string') return data.body;
60
- if (typeof data?.files?.[0]?.content === 'string') return data.files[0].content;
114
+ const first = data?.files?.[0]?.content;
115
+ if (typeof first === 'string') return first;
61
116
  throw new Error(`skills.sh response for ${source}/${name} had no content field`);
62
117
  }
63
118
  }
64
119
 
65
- function normalizeList(data) {
66
- // Tolerate either { skills: [...] } or a bare array.
67
- const list = Array.isArray(data) ? data : (data?.skills || data?.results || []);
68
- return list.map(item => ({
69
- source: item.source || item.repo || '',
70
- name: item.name || item.slug || '',
71
- description: item.description || item.summary || '',
72
- installs: item.installs || item.installCount || 0,
73
- })).filter(s => s.source && s.name);
120
+ // Used by rankAndCap input. Allows mixing baseline skills (which carry a
121
+ // `kind: 'baseline'`) with remote ones. Matches the cli-side shape too.
122
+ export interface RankableSkill extends SkillDescriptor {
123
+ kind?: string;
74
124
  }
75
125
 
76
126
  // Deduplicates by source/name and caps at MAX_SKILLS, preferring curated then by install count.
77
- export function rankAndCap(curated, searched, baseline, cap = MAX_SKILLS) {
78
- const seen = new Set(baseline.map(b => `local:${b.name}`));
79
- const out = [...baseline];
127
+ export function rankAndCap(
128
+ curated: RankableSkill[],
129
+ searched: RankableSkill[],
130
+ baseline: RankableSkill[],
131
+ cap: number = MAX_SKILLS,
132
+ ): RankableSkill[] {
133
+ const seen = new Set(baseline.map((b) => `local:${b.name}`));
134
+ const out: RankableSkill[] = [...baseline];
80
135
  const remaining = cap - baseline.length;
81
136
  if (remaining <= 0) return out.slice(0, cap);
82
137
 
@@ -93,261 +148,6 @@ export function rankAndCap(curated, searched, baseline, cap = MAX_SKILLS) {
93
148
  return out;
94
149
  }
95
150
 
96
- // Loads the baseline skills, preferring the pinned thrillmade/agent-skills
97
- // commit and falling back to the bundled npm-package copy on any fetch failure.
98
- // Returns the same shape as before, plus a `_source` of either 'agent-skills'
99
- // or 'bundled' so the CLI can report which path was used.
100
- //
101
- // Options:
102
- // - fetch — injectable for tests (defaults to globalThis.fetch)
103
- // - cacheDir — where to cache fetched SKILL.md files (defaults to
104
- // ~/.cache/clud-bug/skills/, skipped if null)
105
- export async function loadBaseline(baselineDir, opts = {}) {
106
- const fetchImpl = opts.fetch ?? globalThis.fetch;
107
- const cacheDir = opts.cacheDir === null ? null
108
- : (opts.cacheDir ?? join(homedir(), '.cache', 'clud-bug', 'skills'));
109
-
110
- // First, enumerate the bundled baseline skills (source of truth for which
111
- // names exist). Then fetch each in parallel — sequential awaits would
112
- // stack timeouts (3 baselines × 5s = 15s before fallback when offline).
113
- const bundled = await readBundled(baselineDir);
114
- const remotes = await Promise.all(
115
- bundled.map((s) => tryFetchSkill(s.name, fetchImpl, cacheDir)),
116
- );
117
- return bundled.map((skill, i) => remotes[i]
118
- ? { ...skill, content: remotes[i], _source: 'agent-skills' }
119
- : { ...skill, _source: 'bundled' });
120
- }
121
-
122
- // Reads the bundled baseline from the npm-package directory.
123
- async function readBundled(baselineDir) {
124
- const skills = [];
125
- let entries;
126
- try {
127
- entries = await readdir(baselineDir, { withFileTypes: true });
128
- } catch {
129
- return skills;
130
- }
131
- for (const entry of entries) {
132
- if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
133
- const content = await readFile(join(baselineDir, entry.name), 'utf8');
134
- skills.push({
135
- source: 'clud-bug-baseline',
136
- name: entry.name.replace(/\.md$/, ''),
137
- description: '(baseline)',
138
- installs: 0,
139
- kind: 'baseline',
140
- content,
141
- });
142
- }
143
- return skills;
144
- }
145
-
146
- // Try to read from cache, then fall back to network. Returns the SKILL.md
147
- // content string on success, null on any failure (caller falls back to bundled).
148
- async function tryFetchSkill(name, fetchImpl, cacheDir) {
149
- // Cache lookup first.
150
- if (cacheDir) {
151
- const cached = await readFromCache(cacheDir, name);
152
- if (cached !== null) return cached;
153
- }
154
-
155
- // Network fetch with timeout covering BOTH the connection AND the body
156
- // read (clearTimeout in finally guarantees the timer doesn't keep the
157
- // event loop alive for up to 5s past a failed CLI run).
158
- const url = `${AGENT_SKILLS_BASE}/${encodeURIComponent(name)}/SKILL.md`;
159
- const ctrl = new AbortController();
160
- const timer = setTimeout(() => ctrl.abort(), SKILL_FETCH_TIMEOUT_MS);
161
- try {
162
- const res = await fetchImpl(url, { signal: ctrl.signal });
163
- if (!res.ok) return null;
164
- const content = await res.text();
165
- if (!content || !content.trim()) return null;
166
- if (cacheDir) await writeToCache(cacheDir, name, content);
167
- return content;
168
- } catch {
169
- return null;
170
- } finally {
171
- clearTimeout(timer);
172
- }
173
- }
174
-
175
- async function readFromCache(cacheDir, name) {
176
- const path = cachePath(cacheDir, name);
177
- try {
178
- const st = await stat(path);
179
- if (Date.now() - st.mtimeMs > SKILL_CACHE_TTL_MS) return null;
180
- return await readFile(path, 'utf8');
181
- } catch {
182
- return null;
183
- }
184
- }
185
-
186
- async function writeToCache(cacheDir, name, content) {
187
- try {
188
- await mkdir(cacheDir, { recursive: true });
189
- await writeFile(cachePath(cacheDir, name), content);
190
- } catch {
191
- // Cache write failures are non-fatal — we already have the content.
192
- }
193
- }
194
-
195
- function cachePath(cacheDir, name) {
196
- // Include AGENT_SKILLS_BASE in the hash so different upstream URLs (e.g.
197
- // a fork via CLUD_BUG_AGENT_SKILLS_BASE, or a different pinned SHA after
198
- // a clud-bug release) get different cache entries. Otherwise switching
199
- // bases would silently return the previously-cached content from a
200
- // different upstream — cross-base cache poisoning.
201
- const hash = createHash('sha256').update(`${AGENT_SKILLS_BASE}\n${name}`).digest('hex').slice(0, 16);
202
- return join(cacheDir, `${hash}.md`);
203
- }
204
-
205
- export async function writeSkills(targetDir, skills, client) {
206
- await mkdir(targetDir, { recursive: true });
207
- const written = [];
208
- for (const skill of skills) {
209
- const entry = await writeSkill(targetDir, skill, client);
210
- written.push(entry);
211
- }
212
- await writeManifest(targetDir, mergeManifest(await readManifest(targetDir), written));
213
- return written;
214
- }
215
-
216
- export async function writeSkill(targetDir, skill, client) {
217
- await mkdir(targetDir, { recursive: true });
218
- const slug = sanitizeSlug(skill.name);
219
- const skillDir = join(targetDir, slug);
220
- await mkdir(skillDir, { recursive: true });
221
- const content = skill.content ?? await client.getContent(skill.source, skill.name);
222
- await writeFile(join(skillDir, 'SKILL.md'), content);
223
- return {
224
- slug,
225
- name: skill.name,
226
- source: skill.source,
227
- kind: skill.kind || 'remote',
228
- description: skill.description || '',
229
- };
230
- }
231
-
232
- export async function readManifest(targetDir) {
233
- try {
234
- const text = await readFile(join(targetDir, MANIFEST_FILE), 'utf8');
235
- const data = JSON.parse(text);
236
- return {
237
- ...data,
238
- version: data.version || MANIFEST_VERSION,
239
- installed: Array.isArray(data.installed) ? data.installed : [],
240
- };
241
- } catch {
242
- return { version: MANIFEST_VERSION, installed: [] };
243
- }
244
- }
245
-
246
- export async function writeManifest(targetDir, manifest) {
247
- await mkdir(targetDir, { recursive: true });
248
- // Preserve any additional fields callers want to stamp (e.g. lastUpdate,
249
- // lastUpdateVersion, pinVersion). Only `version` and `installed` are normalized.
250
- const out = {
251
- ...manifest,
252
- version: manifest.version || MANIFEST_VERSION,
253
- installed: manifest.installed || [],
254
- };
255
- await writeFile(join(targetDir, MANIFEST_FILE), JSON.stringify(out, null, 2) + '\n');
256
- }
257
-
258
- export function mergeManifest(existing, newEntries) {
259
- const byKey = new Map();
260
- for (const entry of existing.installed || []) {
261
- byKey.set(entryKey(entry), entry);
262
- }
263
- for (const entry of newEntries) {
264
- byKey.set(entryKey(entry), entry);
265
- }
266
- // Spread `existing` so caller-set fields (pinVersion, lastUpdate,
267
- // lastUpdateVersion, etc.) survive merges performed by writeSkills /
268
- // refresh / add. Only `installed` is rebuilt; everything else carries.
269
- return { ...existing, version: MANIFEST_VERSION, installed: [...byKey.values()] };
270
- }
271
-
272
- function entryKey(entry) {
273
- // Baseline skills have no source; key by slug. Remote skills key by source/name.
274
- return entry.kind === 'baseline' ? `baseline:${entry.slug}` : `${entry.source}/${entry.name || entry.slug}`;
275
- }
276
-
277
- export async function removeSkill(targetDir, slug) {
278
- const manifest = await readManifest(targetDir);
279
- const entry = manifest.installed.find(e => e.slug === slug);
280
- if (!entry) {
281
- throw new Error(`'${slug}' is not in the clud-bug manifest. If it's a custom skill, delete it manually with: rm -rf .claude/skills/${slug}`);
282
- }
283
- await rm(join(targetDir, slug), { recursive: true, force: true });
284
- manifest.installed = manifest.installed.filter(e => e.slug !== slug);
285
- await writeManifest(targetDir, manifest);
286
- return entry;
287
- }
288
-
289
- export async function listInstalled(targetDir) {
290
- const manifest = await readManifest(targetDir);
291
- const managedSlugs = new Set(manifest.installed.map(e => e.slug));
292
- const groups = { baseline: [], remote: [], custom: [] };
293
- for (const entry of manifest.installed) {
294
- (groups[entry.kind === 'baseline' ? 'baseline' : 'remote']).push(entry);
295
- }
296
-
297
- let entries;
298
- try {
299
- entries = await readdir(targetDir, { withFileTypes: true });
300
- } catch {
301
- return groups;
302
- }
303
- for (const entry of entries) {
304
- if (!entry.isDirectory()) continue;
305
- if (managedSlugs.has(entry.name)) continue;
306
- const skillFile = join(targetDir, entry.name, 'SKILL.md');
307
- let description = '';
308
- try {
309
- const text = await readFile(skillFile, 'utf8');
310
- const m = text.match(/^description:\s*(.+)$/m);
311
- description = m?.[1]?.trim() || '';
312
- } catch {
313
- continue; // not a skill dir
314
- }
315
- groups.custom.push({ slug: entry.name, kind: 'custom', description });
316
- }
317
- return groups;
318
- }
319
-
320
- // Diff a current manifest against a freshly-recommended skill set.
321
- // Returns { add: [], remove: [], unchanged: [] }. Custom skills are never affected.
322
- export function diffManifest(manifest, recommended) {
323
- const recByKey = new Map(recommended.map(s => [
324
- s.kind === 'baseline' ? `baseline:${sanitizeSlug(s.name)}` : `${s.source}/${s.name}`,
325
- s,
326
- ]));
327
- const installedByKey = new Map(manifest.installed.map(e => [entryKey(e), e]));
328
-
329
- const add = [];
330
- const remove = [];
331
- const unchanged = [];
332
-
333
- for (const [key, skill] of recByKey) {
334
- if (installedByKey.has(key)) {
335
- unchanged.push(skill);
336
- } else {
337
- add.push(skill);
338
- }
339
- }
340
- for (const [key, entry] of installedByKey) {
341
- if (entry.kind === 'baseline') continue; // baseline always stays
342
- if (!recByKey.has(key)) remove.push(entry);
343
- }
344
- return { add, remove, unchanged };
345
- }
346
-
347
- function sanitizeSlug(name) {
348
- return name.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '');
349
- }
350
-
351
151
  // Extract the `review_mode` field from a SKILL.md's frontmatter.
352
152
  //
353
153
  // Contract (from the v0.6 plan, option D-unified):
@@ -367,22 +167,27 @@ function sanitizeSlug(name) {
367
167
  // The CLI runtime (v0.5.9) honors this via prompt restructuring inside a
368
168
  // single claude-code-action call. The v0.6 GitHub App will use the same
369
169
  // field to route to literal parallel API calls. Single source of truth.
370
- export function readReviewMode(skillContent) {
170
+ export function readReviewMode(skillContent: unknown): 'shared' | 'dedicated' {
371
171
  if (typeof skillContent !== 'string') return 'shared';
372
172
  // Scope to the YAML frontmatter block (between the first two `---` lines).
373
173
  // A `review_mode:` line in the body is documentation, not configuration.
374
174
  const fm = skillContent.match(/^---\n([\s\S]*?)\n---/);
375
175
  if (!fm) return 'shared';
376
- const m = fm[1].match(/^review_mode:\s*(\S+)\s*$/m);
176
+ const m = (fm[1] as string).match(/^review_mode:\s*(\S+)\s*$/m);
377
177
  if (!m) return 'shared';
378
178
  // Strip optional YAML string-quotes — `review_mode: "dedicated"` and
379
179
  // `review_mode: 'dedicated'` are both valid YAML, but the (\S+) capture
380
180
  // grabs the quotes too. Without this, quoted forms silently fell back
381
181
  // to `shared` even though the author clearly meant dedicated.
382
- const value = m[1].toLowerCase().replace(/^["']|["']$/g, '');
182
+ const value = (m[1] as string).toLowerCase().replace(/^["']|["']$/g, '');
383
183
  return value === 'dedicated' ? 'dedicated' : 'shared';
384
184
  }
385
185
 
186
+ export interface AppliesToRule {
187
+ paths: string[];
188
+ extensions: string[];
189
+ }
190
+
386
191
  // 0.0.K (v0.6.21): parse the optional `applies_to:` frontmatter block.
387
192
  //
388
193
  // Schema:
@@ -400,11 +205,11 @@ export function readReviewMode(skillContent) {
400
205
  // Hand-rolled YAML parser scoped to this exact shape. The frontmatter
401
206
  // is otherwise opaque (review_mode is parsed elsewhere with a similar
402
207
  // single-key regex), so pulling in a YAML dep would be overkill.
403
- export function readAppliesTo(skillContent) {
208
+ export function readAppliesTo(skillContent: unknown): AppliesToRule | null {
404
209
  if (typeof skillContent !== 'string') return null;
405
210
  const fm = skillContent.match(/^---\n([\s\S]*?)\n---/);
406
211
  if (!fm) return null;
407
- const block = fm[1];
212
+ const block = fm[1] as string;
408
213
  // Anchor on `applies_to:` at start of line (the body of a SKILL.md
409
214
  // could mention the term in prose; only the frontmatter key fires).
410
215
  const head = block.match(/^applies_to:\s*$/m);
@@ -412,7 +217,9 @@ export function readAppliesTo(skillContent) {
412
217
  // Slice from after the `applies_to:` line; the block ends at the
413
218
  // next top-level key (a line starting with a word character + `:`)
414
219
  // OR end-of-block.
415
- const startIdx = head.index + head[0].length;
220
+ // `head.index` is defined here because String.prototype.match returns
221
+ // a RegExpMatchArray with `index` set when the regex is non-global.
222
+ const startIdx = (head.index as number) + head[0].length;
416
223
  const rest = block.slice(startIdx);
417
224
  const stop = rest.search(/^\w[\w-]*:/m);
418
225
  const scoped = stop === -1 ? rest : rest.slice(0, stop);
@@ -425,10 +232,10 @@ export function readAppliesTo(skillContent) {
425
232
  // Parse a YAML list under `<key>:`, handling both the inline-array
426
233
  // form (`extensions: [".tsx", ".jsx"]`) and the block form
427
234
  // (`paths:` followed by ` - "src/ui/**"` lines).
428
- function parseYamlList(block, key) {
235
+ function parseYamlList(block: string, key: string): string[] {
429
236
  const inline = block.match(new RegExp(`^\\s{2}${key}:\\s*\\[(.*?)\\]\\s*$`, 'm'));
430
237
  if (inline) {
431
- return inline[1]
238
+ return (inline[1] as string)
432
239
  .split(',')
433
240
  .map((s) => s.trim().replace(/^["']|["']$/g, ''))
434
241
  .filter(Boolean);
@@ -436,12 +243,12 @@ function parseYamlList(block, key) {
436
243
  const headerRe = new RegExp(`^\\s{2}${key}:\\s*$`, 'm');
437
244
  const head = block.match(headerRe);
438
245
  if (!head) return [];
439
- const after = block.slice(head.index + head[0].length);
440
- const items = [];
246
+ const after = block.slice((head.index as number) + head[0].length);
247
+ const items: string[] = [];
441
248
  for (const line of after.split('\n')) {
442
249
  const item = line.match(/^\s{4,}-\s*(.+?)\s*$/);
443
250
  if (item) {
444
- items.push(item[1].replace(/^["']|["']$/g, ''));
251
+ items.push((item[1] as string).replace(/^["']|["']$/g, ''));
445
252
  continue;
446
253
  }
447
254
  // Anything that isn't a list item (or blank) ends the list.
@@ -462,10 +269,10 @@ function parseYamlList(block, key) {
462
269
  // Skill `paths` use the minimal glob set logmind already uses
463
270
  // (`*` matches non-slash, `**` matches across slashes, `?` single
464
271
  // char). Anything fancier would need a real glob lib.
465
- export function appliesToPr(skillContent, prPaths) {
272
+ export function appliesToPr(skillContent: unknown, prPaths: unknown): boolean {
466
273
  const rule = readAppliesTo(skillContent);
467
- if (rule === null) return true; // back-compat: no rule → applies
468
- if (!Array.isArray(prPaths)) return true; // be permissive on bad input
274
+ if (rule === null) return true; // back-compat: no rule → applies
275
+ if (!Array.isArray(prPaths)) return true; // be permissive on bad input
469
276
  for (const path of prPaths) {
470
277
  if (typeof path !== 'string') continue;
471
278
  for (const ext of rule.extensions) {
@@ -480,7 +287,7 @@ export function appliesToPr(skillContent, prPaths) {
480
287
 
481
288
  // Minimal glob → regex: `**` → `.*`, `*` → `[^/]*`, `?` → `.`,
482
289
  // everything else escaped. Anchored full-string match.
483
- function globMatch(glob, path) {
290
+ function globMatch(glob: string, path: string): boolean {
484
291
  const escaped = glob
485
292
  .replace(/([.+^${}()|[\]\\])/g, '\\$1')
486
293
  .replace(/\*\*/g, '__DOUBLESTAR__')
@@ -490,6 +297,13 @@ function globMatch(glob, path) {
490
297
  return new RegExp(`^${escaped}$`).test(path);
491
298
  }
492
299
 
300
+ // Loose shape of a "skill" object as it flows through the runtime. Caller
301
+ // may attach `content` (the SKILL.md text) for partitioning decisions.
302
+ export interface SkillWithOptionalContent {
303
+ content?: unknown;
304
+ [key: string]: unknown;
305
+ }
306
+
493
307
  // Partition a set of loaded skills into {shared, dedicated} buckets per
494
308
  // each skill's review_mode frontmatter. Expects skills with a `content`
495
309
  // field (SKILL.md text). Skills without content default to `shared`.
@@ -497,9 +311,11 @@ function globMatch(glob, path) {
497
311
  // Shape: input is the same skill objects produced by loadBaseline /
498
312
  // writeSkills / listInstalled. Output is two arrays of the same shape;
499
313
  // caller decides what to do with each bucket.
500
- export function partitionByReviewMode(skills) {
501
- const shared = [];
502
- const dedicated = [];
314
+ export function partitionByReviewMode<T extends SkillWithOptionalContent>(
315
+ skills: T[],
316
+ ): { shared: T[]; dedicated: T[] } {
317
+ const shared: T[] = [];
318
+ const dedicated: T[] = [];
503
319
  for (const skill of skills) {
504
320
  const mode = readReviewMode(skill?.content);
505
321
  (mode === 'dedicated' ? dedicated : shared).push(skill);
@@ -521,7 +337,7 @@ export function partitionByReviewMode(skills) {
521
337
  //
522
338
  // The brackets in the line prefix anchor the match so a partial-name collision
523
339
  // (e.g. `brand-voice` finding `brand-voice-review`) is impossible.
524
- export function extractPerSkillLine(comment, skillName) {
340
+ export function extractPerSkillLine(comment: unknown, skillName: unknown): string | null {
525
341
  if (typeof comment !== 'string' || !comment) return null;
526
342
  if (typeof skillName !== 'string' || !skillName) return null;
527
343
  // Escape regex metacharacters in the skill name. A skill name with a `.` or
@@ -532,7 +348,16 @@ export function extractPerSkillLine(comment, skillName) {
532
348
  // dash. The OUTCOME is everything from after `]:` to end-of-line.
533
349
  const re = new RegExp(`^\\s*-\\s*\\[${escaped}\\]:\\s*(.+?)\\s*$`, 'm');
534
350
  const m = comment.match(re);
535
- return m ? m[1] : null;
351
+ return m ? (m[1] as string) : null;
352
+ }
353
+
354
+ // Minimal shape of a PR comment (subset of GitHub's REST schema) used by
355
+ // selectReviewHeader / selectReviewBody. `body` and `user.login` are
356
+ // the only fields we actually read; `created_at` is used for sorting.
357
+ export interface PrComment {
358
+ body?: unknown;
359
+ user?: { login?: unknown };
360
+ created_at?: unknown;
536
361
  }
537
362
 
538
363
  // Find the latest clud-bug review header line from a list of PR comments.
@@ -561,7 +386,7 @@ export function extractPerSkillLine(comment, skillName) {
561
386
  // quoted sentinels in body text" property: a comment that mentions the
562
387
  // strict-mode header in prose (inline-code, blockquote) won't match
563
388
  // because the quoted version isn't at start-of-line.
564
- export function selectReviewHeader(comments, botLogin) {
389
+ export function selectReviewHeader(comments: unknown, botLogin: unknown): string | null {
565
390
  if (!Array.isArray(comments)) return null;
566
391
  if (typeof botLogin !== 'string' || !botLogin) return null;
567
392
  // Sort newest-first by created_at. The composite passes the result of
@@ -572,9 +397,9 @@ export function selectReviewHeader(comments, botLogin) {
572
397
  // the newer "— clean" follow-up, so fix-push reviews that resolved
573
398
  // critical findings still failed the gate. Explicit sort here makes
574
399
  // selection deterministic regardless of upstream API quirks.
575
- const sorted = [...comments].sort((a, b) => {
576
- const ta = a?.created_at ? Date.parse(a.created_at) : 0;
577
- const tb = b?.created_at ? Date.parse(b.created_at) : 0;
400
+ const sorted: PrComment[] = [...(comments as PrComment[])].sort((a, b) => {
401
+ const ta = typeof a?.created_at === 'string' ? Date.parse(a.created_at) : 0;
402
+ const tb = typeof b?.created_at === 'string' ? Date.parse(b.created_at) : 0;
578
403
  return tb - ta; // newest first
579
404
  });
580
405
  for (const c of sorted) {
@@ -591,7 +416,7 @@ export function selectReviewHeader(comments, botLogin) {
591
416
  // Pull the FIRST line of `body` that starts with the H2 sentinel.
592
417
  // Exported separately so callers can extract a header from a known body
593
418
  // without re-running the comment filter (useful in tests + the v0.6 App).
594
- export function extractFirstReviewHeaderLine(body) {
419
+ export function extractFirstReviewHeaderLine(body: unknown): string | null {
595
420
  if (typeof body !== 'string') return null;
596
421
  const m = body.match(/^## 🐛 Clud Bug review[^\n]*/m);
597
422
  return m ? m[0] : null;
@@ -613,16 +438,16 @@ export function extractFirstReviewHeaderLine(body) {
613
438
  // of the composite still used the broken `.body | startswith(...)` jq
614
439
  // filter even after step 1 was refactored, leaving per-skill check-runs
615
440
  // silently disabled on every install with strictSkills since v0.5.10.
616
- export function selectReviewBody(comments, botLogin) {
441
+ export function selectReviewBody(comments: unknown, botLogin: unknown): string | null {
617
442
  if (!Array.isArray(comments)) return null;
618
443
  if (typeof botLogin !== 'string' || !botLogin) return null;
619
444
  // Same explicit newest-first sort as selectReviewHeader — gh api
620
445
  // ignores direction=desc on issue-comments and returns ascending,
621
446
  // so without this BB.3 was parsing per-skill outcomes from the
622
447
  // OLDEST review comment, not the latest. See selectReviewHeader.
623
- const sorted = [...comments].sort((a, b) => {
624
- const ta = a?.created_at ? Date.parse(a.created_at) : 0;
625
- const tb = b?.created_at ? Date.parse(b.created_at) : 0;
448
+ const sorted: PrComment[] = [...(comments as PrComment[])].sort((a, b) => {
449
+ const ta = typeof a?.created_at === 'string' ? Date.parse(a.created_at) : 0;
450
+ const tb = typeof b?.created_at === 'string' ? Date.parse(b.created_at) : 0;
626
451
  return tb - ta;
627
452
  });
628
453
  for (const c of sorted) {
@@ -635,15 +460,12 @@ export function selectReviewBody(comments, botLogin) {
635
460
  return null;
636
461
  }
637
462
 
638
- // Decide whether a review-header line is the strict-mode "critical findings"
639
- // verdict that should fail the gate. Mirrors the v0.5.x bash predicate
640
- // `grep -q "Clud Bug review — critical findings"`.
641
- //
642
- // Returns false for null/non-string input so a "no header found" path
643
- // (selectReviewHeader returning null) safely falls through to the gate
644
- // passing — which is the right posture: if the bot didn't post a review
645
- // with the strict-mode header, there's nothing for the gate to fail on.
646
- // "Loud failure for missing manifest" is handled upstream in the composite.
463
+ export interface ReviewStatsHeader {
464
+ important: number;
465
+ nit: number;
466
+ preExisting: number;
467
+ }
468
+
647
469
  // Extract the v0.6.5+ stats header line "Found: N 🔴 / N 🟡 / N 🟣"
648
470
  // from a review comment body. Returns {important, nit, preExisting} when
649
471
  // found, null otherwise. The header lets agents reading the comment on a
@@ -655,19 +477,28 @@ export function selectReviewBody(comments, botLogin) {
655
477
  // and tolerates 1+ digits for each count. Severity emoji are matched
656
478
  // literally — a future bot revision that changes the emoji would break
657
479
  // this parser loudly, which is the intended behavior (catches drift).
658
- export function extractStatsHeader(comment) {
480
+ export function extractStatsHeader(comment: unknown): ReviewStatsHeader | null {
659
481
  if (typeof comment !== 'string' || !comment) return null;
660
482
  const re = /Found:\s*(\d+)\s*🔴\s*\/\s*(\d+)\s*🟡\s*\/\s*(\d+)\s*🟣/u;
661
483
  const m = comment.match(re);
662
484
  if (!m) return null;
663
485
  return {
664
- important: parseInt(m[1], 10),
665
- nit: parseInt(m[2], 10),
666
- preExisting: parseInt(m[3], 10),
486
+ important: parseInt(m[1] as string, 10),
487
+ nit: parseInt(m[2] as string, 10),
488
+ preExisting: parseInt(m[3] as string, 10),
667
489
  };
668
490
  }
669
491
 
670
- export function isCriticalReviewHeader(headerLine) {
492
+ // Decide whether a review-header line is the strict-mode "critical findings"
493
+ // verdict that should fail the gate. Mirrors the v0.5.x bash predicate
494
+ // `grep -q "Clud Bug review — critical findings"`.
495
+ //
496
+ // Returns false for null/non-string input so a "no header found" path
497
+ // (selectReviewHeader returning null) safely falls through to the gate
498
+ // passing — which is the right posture: if the bot didn't post a review
499
+ // with the strict-mode header, there's nothing for the gate to fail on.
500
+ // "Loud failure for missing manifest" is handled upstream in the composite.
501
+ export function isCriticalReviewHeader(headerLine: unknown): boolean {
671
502
  if (typeof headerLine !== 'string') return false;
672
503
  return /Clud Bug review — critical findings/.test(headerLine);
673
504
  }
@@ -699,7 +530,7 @@ export function isCriticalReviewHeader(headerLine) {
699
530
  // findings" / "100 findings" don't substring-match to success — the exact
700
531
  // bug that v0.5.10's first revision had, caught by clud-bug-review + claude-
701
532
  // review on PR #57.
702
- export function classifyPerSkillOutcome(outcomeLine) {
533
+ export function classifyPerSkillOutcome(outcomeLine: unknown): 'failure' | 'success' {
703
534
  if (outcomeLine == null) return 'failure';
704
535
  const text = String(outcomeLine);
705
536
 
@@ -746,4 +577,170 @@ export function classifyPerSkillOutcome(outcomeLine) {
746
577
  return 'failure';
747
578
  }
748
579
 
749
- export const _internal = { normalizeList, sanitizeSlug, entryKey, MAX_SKILLS, API_BASE, MANIFEST_FILE };
580
+ // ---------------------------------------------------------------------------
581
+ // SKILL.md frontmatter parser (ported from clud-bug-app/lib/skills-loader.ts).
582
+ //
583
+ // The App's `loadSkillsFromBaseRef` Octokit fetcher stays App-side (depends
584
+ // on Octokit). The PURE parser belongs in core so both the App and any
585
+ // future CLI runtime that wants to read SKILL.md frontmatter without an
586
+ // Octokit dependency can use it. SPEC §1.10 is the frontmatter contract.
587
+ // ---------------------------------------------------------------------------
588
+
589
+ /** Source provenance for an installed skill (see SPEC §1.10). */
590
+ export type SkillSource =
591
+ | 'manual'
592
+ | 'logmind-derived'
593
+ | 'skills-sh'
594
+ | 'clud-bug-baseline';
595
+
596
+ export type SkillReviewMode = 'shared' | 'dedicated';
597
+
598
+ /**
599
+ * Parsed SKILL.md frontmatter. The App uses this shape (see
600
+ * `clud-bug-app/lib/skills-loader.ts:40`); the prompt builder's
601
+ * `PromptSkillFrontmatter` is a narrower subset (name + description +
602
+ * applies_to) so we keep the full shape here.
603
+ */
604
+ export interface SkillFrontmatter {
605
+ name: string;
606
+ description: string;
607
+ source: SkillSource | string; // string fallback for forward-compat
608
+ review_mode: SkillReviewMode;
609
+ applies_to?: {
610
+ paths?: string[];
611
+ extensions?: string[];
612
+ };
613
+ }
614
+
615
+ /**
616
+ * Minimal YAML frontmatter parser. Handles:
617
+ * - `key: value` (scalar)
618
+ * - `key: [a, b, c]` (inline list)
619
+ * - `key:\n subkey: value` (one-level nesting — applies_to)
620
+ *
621
+ * Throws on malformed input; the App's `loadSkillsFromBaseRef` catches
622
+ * and skips the skill (a bad SKILL.md doesn't take down the whole review).
623
+ *
624
+ * Deliberately NOT a general-purpose YAML parser — SPEC §1.10 fixes the
625
+ * frontmatter schema to a handful of fields. If the schema grows beyond
626
+ * what this hand-rolled parser handles, swap to `js-yaml` — the boundary
627
+ * is this function.
628
+ */
629
+ export function parseFrontmatter(raw: string): SkillFrontmatter {
630
+ // Frontmatter MUST be the literal `---\n...\n---\n` at the file head.
631
+ // We tolerate a leading BOM and trailing whitespace.
632
+ const trimmed = raw.replace(/^/, '');
633
+ const match = trimmed.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
634
+ if (!match) {
635
+ throw new Error('missing YAML frontmatter');
636
+ }
637
+ const body = match[1] ?? '';
638
+ const lines = body.split(/\r?\n/);
639
+
640
+ const out: Record<string, unknown> = {};
641
+ let currentNested: string | null = null;
642
+
643
+ for (const line of lines) {
644
+ if (!line.trim()) continue;
645
+ // Comment line; YAML allows '#' as a comment marker at column 0.
646
+ if (line.trim().startsWith('#')) continue;
647
+
648
+ // Nested-block lines start with whitespace (e.g. " paths: [...]").
649
+ const isIndented = /^\s/.test(line);
650
+ if (isIndented && currentNested) {
651
+ const nested = out[currentNested] as Record<string, unknown> | undefined;
652
+ if (!nested) continue;
653
+ const trimmedLine = line.trim();
654
+ const colon = trimmedLine.indexOf(':');
655
+ if (colon === -1) continue;
656
+ const key = trimmedLine.slice(0, colon).trim();
657
+ const value = trimmedLine.slice(colon + 1).trim();
658
+ nested[key] = parseScalarOrList(value);
659
+ continue;
660
+ }
661
+
662
+ // Top-level key.
663
+ currentNested = null;
664
+ const colon = line.indexOf(':');
665
+ if (colon === -1) {
666
+ throw new Error(`malformed frontmatter line: ${line}`);
667
+ }
668
+ const key = line.slice(0, colon).trim();
669
+ const value = line.slice(colon + 1).trim();
670
+
671
+ if (value === '') {
672
+ // Block — next indented lines populate this key as a nested map.
673
+ out[key] = {};
674
+ currentNested = key;
675
+ continue;
676
+ }
677
+ out[key] = parseScalarOrList(value);
678
+ }
679
+
680
+ // Validate the SPEC-required fields are present and apply documented
681
+ // defaults for optional ones.
682
+ const name = String(out['name'] ?? '').trim();
683
+ if (!name) throw new Error('frontmatter.name is required');
684
+ if (!/^[a-z][a-z0-9-]{0,62}$/.test(name)) {
685
+ throw new Error(`frontmatter.name is not a valid kebab-case slug: ${name}`);
686
+ }
687
+ const description = String(out['description'] ?? '').trim();
688
+ if (!description) throw new Error('frontmatter.description is required');
689
+
690
+ const source = String(out['source'] ?? 'manual').trim();
691
+ const reviewMode: SkillReviewMode =
692
+ out['review_mode'] === 'dedicated' ? 'dedicated' : 'shared';
693
+
694
+ const appliesToRaw = out['applies_to'] as
695
+ | { paths?: unknown; extensions?: unknown }
696
+ | undefined;
697
+ let appliesTo: SkillFrontmatter['applies_to'] | undefined;
698
+ if (appliesToRaw) {
699
+ const paths = Array.isArray(appliesToRaw.paths)
700
+ ? appliesToRaw.paths.map(String)
701
+ : undefined;
702
+ const extensions = Array.isArray(appliesToRaw.extensions)
703
+ ? appliesToRaw.extensions.map(String)
704
+ : undefined;
705
+ appliesTo = {
706
+ ...(paths !== undefined ? { paths } : {}),
707
+ ...(extensions !== undefined ? { extensions } : {}),
708
+ };
709
+ }
710
+
711
+ return {
712
+ name,
713
+ description,
714
+ source,
715
+ review_mode: reviewMode,
716
+ ...(appliesTo !== undefined ? { applies_to: appliesTo } : {}),
717
+ };
718
+ }
719
+
720
+ function parseScalarOrList(value: string): unknown {
721
+ if (value.startsWith('[') && value.endsWith(']')) {
722
+ // Inline list: [a, "b", 'c'] → ['a', 'b', 'c']
723
+ const inner = value.slice(1, -1).trim();
724
+ if (!inner) return [];
725
+ return inner
726
+ .split(',')
727
+ .map((s) => s.trim().replace(/^['"]|['"]$/g, ''))
728
+ .filter(Boolean);
729
+ }
730
+ // Strip surrounding quotes; YAML allows both ' and ".
731
+ return value.replace(/^['"]|['"]$/g, '');
732
+ }
733
+
734
+ /**
735
+ * Strip the leading `---\n...\n---\n` from a SKILL.md file. Returns the
736
+ * markdown body (the part the LLM actually reads).
737
+ *
738
+ * Ported from `clud-bug-app/lib/skills-loader.ts:269` so callers don't have
739
+ * to re-implement the regex.
740
+ */
741
+ export function stripFrontmatter(raw: string): string {
742
+ const trimmed = raw.replace(/^/, '');
743
+ const match = trimmed.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/);
744
+ if (!match) return trimmed;
745
+ return trimmed.slice(match[0].length);
746
+ }