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.
- package/bin/clud-bug.js +10 -1353
- package/dist/cli/agents-md.d.ts +16 -0
- package/dist/cli/agents-md.d.ts.map +1 -0
- package/dist/cli/agents-md.js +226 -0
- package/dist/cli/agents-md.js.map +1 -0
- package/dist/cli/audit.d.ts +13 -0
- package/dist/cli/audit.d.ts.map +1 -0
- package/dist/cli/audit.js +90 -0
- package/dist/cli/audit.js.map +1 -0
- package/dist/cli/branch-protection.d.ts +57 -0
- package/dist/cli/branch-protection.d.ts.map +1 -0
- package/dist/cli/branch-protection.js +118 -0
- package/dist/cli/branch-protection.js.map +1 -0
- package/dist/cli/edit-workflow.d.ts +18 -0
- package/dist/cli/edit-workflow.d.ts.map +1 -0
- package/dist/cli/edit-workflow.js +43 -0
- package/dist/cli/edit-workflow.js.map +1 -0
- package/dist/cli/index.d.ts +8 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +18 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/main.d.ts +3 -0
- package/dist/cli/main.d.ts.map +1 -0
- package/dist/cli/main.js +1336 -0
- package/dist/cli/main.js.map +1 -0
- package/dist/cli/skill-usage.d.ts +109 -0
- package/dist/cli/skill-usage.d.ts.map +1 -0
- package/dist/cli/skill-usage.js +380 -0
- package/dist/cli/skill-usage.js.map +1 -0
- package/dist/cli/skills.d.ts +56 -0
- package/dist/cli/skills.d.ts.map +1 -0
- package/dist/cli/skills.js +292 -0
- package/dist/cli/skills.js.map +1 -0
- package/dist/cli/update.d.ts +29 -0
- package/dist/cli/update.d.ts.map +1 -0
- package/dist/cli/update.js +186 -0
- package/dist/cli/update.js.map +1 -0
- package/dist/cli/usage.d.ts +142 -0
- package/dist/cli/usage.d.ts.map +1 -0
- package/dist/cli/usage.js +348 -0
- package/dist/cli/usage.js.map +1 -0
- package/dist/core/audit.d.ts +8 -0
- package/dist/core/audit.d.ts.map +1 -0
- package/dist/core/audit.js +47 -0
- package/dist/core/audit.js.map +1 -0
- package/dist/core/detect.d.ts +77 -0
- package/dist/core/detect.d.ts.map +1 -0
- package/dist/core/detect.js +262 -0
- package/dist/core/detect.js.map +1 -0
- package/dist/core/index.d.ts +11 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +31 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/prompt-builder.d.ts +164 -0
- package/dist/core/prompt-builder.d.ts.map +1 -0
- package/dist/core/prompt-builder.js +419 -0
- package/dist/core/prompt-builder.js.map +1 -0
- package/dist/core/prompts.d.ts +9 -0
- package/dist/core/prompts.d.ts.map +1 -0
- package/dist/core/prompts.js +401 -0
- package/dist/core/prompts.js.map +1 -0
- package/dist/core/render-review.d.ts +6 -0
- package/dist/core/render-review.d.ts.map +1 -0
- package/dist/core/render-review.js +219 -0
- package/dist/core/render-review.js.map +1 -0
- package/dist/core/render.d.ts +13 -0
- package/dist/core/render.d.ts.map +1 -0
- package/dist/core/render.js +80 -0
- package/dist/core/render.js.map +1 -0
- package/dist/core/review-schema-zod.d.ts +240 -0
- package/dist/core/review-schema-zod.d.ts.map +1 -0
- package/dist/core/review-schema-zod.js +218 -0
- package/dist/core/review-schema-zod.js.map +1 -0
- package/dist/core/review-schema.d.ts +42 -0
- package/dist/core/review-schema.d.ts.map +1 -0
- package/dist/core/review-schema.js +156 -0
- package/dist/core/review-schema.js.map +1 -0
- package/dist/core/review-writeback.d.ts +139 -0
- package/dist/core/review-writeback.d.ts.map +1 -0
- package/dist/core/review-writeback.js +313 -0
- package/dist/core/review-writeback.js.map +1 -0
- package/dist/core/skills.d.ts +122 -0
- package/dist/core/skills.d.ts.map +1 -0
- package/dist/core/skills.js +636 -0
- package/dist/core/skills.js.map +1 -0
- package/package.json +30 -4
- package/{lib/agents-md.js → src/cli/agents-md.ts} +25 -14
- package/{lib/audit.js → src/cli/audit.ts} +37 -44
- package/{lib/branch-protection.js → src/cli/branch-protection.ts} +75 -11
- package/{lib/edit-workflow.js → src/cli/edit-workflow.ts} +32 -11
- package/src/cli/index.ts +101 -0
- package/src/cli/main.ts +1376 -0
- package/{lib/skill-usage.js → src/cli/skill-usage.ts} +168 -94
- package/src/cli/skills.ts +386 -0
- package/{lib/update.js → src/cli/update.ts} +68 -27
- package/{lib/usage.js → src/cli/usage.ts} +167 -76
- package/src/core/audit.ts +53 -0
- package/{lib/detect.js → src/core/detect.ts} +100 -47
- package/src/core/index.ts +155 -0
- package/src/core/prompt-builder.ts +561 -0
- package/{lib/prompts.js → src/core/prompts.ts} +16 -2
- package/{lib/render-review.js → src/core/render-review.ts} +57 -25
- package/{lib/render.js → src/core/render.ts} +36 -10
- package/src/core/review-schema-zod.ts +262 -0
- package/{lib/review-schema.js → src/core/review-schema.ts} +68 -5
- package/src/core/review-writeback.ts +446 -0
- package/{lib/skills.js → src/core/skills.ts} +339 -342
- package/templates/workflow-py.yml.tmpl +2 -2
- package/templates/workflow-ts.yml.tmpl +2 -2
- package/templates/workflow.yml.tmpl +17 -8
|
@@ -1,34 +1,86 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
//
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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(
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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;
|
|
468
|
-
if (!Array.isArray(prPaths)) return true;
|
|
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(
|
|
501
|
-
|
|
502
|
-
|
|
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
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|