clud-bug 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 thrillmot
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,127 @@
1
+ # Clud Bug ๐Ÿ›
2
+ ### Crawling all over your code
3
+
4
+ > **[cludbug.dev](https://cludbug.dev)** ยท A field guide.
5
+
6
+ Claude PR review for any GitHub repo, with **project-aware skills** auto-discovered from [skills.sh](https://skills.sh) and a baseline of review-discipline skills bundled in.
7
+
8
+ One command to install. The first PR you open afterwards gets a real review comment back.
9
+
10
+ ## Quickstart
11
+
12
+ ```bash
13
+ cd your-repo
14
+ npx clud-bug init
15
+ git add .claude .github/workflows/clud-bug-review.yml
16
+ git commit -m "Add clud-bug PR review"
17
+ git push
18
+ ```
19
+
20
+ Then in your repo on GitHub:
21
+ **Settings โ†’ Secrets and variables โ†’ Actions โ†’ New repository secret** โ†’ set `ANTHROPIC_API_KEY`.
22
+
23
+ Open a PR. A review comment should appear within ~2 minutes.
24
+
25
+ ## What `clud-bug init` does
26
+
27
+ 1. **Detects** your stack (Node, Python, Go, Rust, Ruby โ€” reads `package.json`, `pyproject.toml`, `go.mod`, etc.).
28
+ 2. **Queries [skills.sh](https://skills.sh)** for review skills relevant to your dependencies (e.g. a Next.js project gets Next.js review skills).
29
+ 3. **Installs three baseline skills** that enforce review discipline regardless of stack:
30
+ - `critical-issues-only` โ€” flag bugs, security, perf only. Skip nits.
31
+ - `evidence-based-review` โ€” every claim must quote the line being criticized.
32
+ - `respect-existing-conventions` โ€” don't suggest fights with the codebase's patterns.
33
+ 4. **Writes** the chosen skills to `.claude/skills/<name>/SKILL.md` (Claude Code auto-loads them in the GitHub Action).
34
+ 5. **Generates** `.github/workflows/clud-bug-review.yml` with the project description filled in and the right permissions/tool allowlist for `gh pr comment` to actually work.
35
+
36
+ ## CLI options
37
+
38
+ ```
39
+ npx clud-bug init [options]
40
+
41
+ --offline Skip skills.sh; install only the bundled baseline skills.
42
+ --accept-all,-y Accept the recommended skill set without prompting.
43
+ --commit git add + commit the generated files when done.
44
+ --help,-h Show help.
45
+ ```
46
+
47
+ ## Managing skills
48
+
49
+ After `init`, four commands let you evolve the skill set without re-running the whole setup:
50
+
51
+ ```bash
52
+ clud-bug list # show what's installed
53
+ clud-bug add vercel-labs/skills/next-best-practices # install one from skills.sh
54
+ clud-bug remove next-best-practices # uninstall (refuses custom skills)
55
+ clud-bug refresh # re-query skills.sh, diff vs installed
56
+ ```
57
+
58
+ Skills are tracked in `.claude/skills/.clud-bug.json` (a small manifest). Anything in `.claude/skills/` that *isn't* in the manifest is treated as your custom work and never modified by `clud-bug` commands.
59
+
60
+ ## Adding your own skills
61
+
62
+ Drop any `.md` file into `.claude/skills/<your-skill>/SKILL.md` โ€” Claude Code auto-discovers it on the next PR. Same format as skills from skills.sh:
63
+
64
+ ```markdown
65
+ ---
66
+ name: my-team-rules
67
+ description: One-line description of what this skill teaches the reviewer.
68
+ ---
69
+
70
+ # My team rules
71
+
72
+ Rules go here. Be specific, cite examples, explain the why.
73
+ ```
74
+
75
+ This is how you encode your team's PR-review discipline (e.g. "always check for SQL injection in `db/queries/`", "API responses must include error codes from `lib/errors.ts`").
76
+
77
+ ## Why this works (and why the original `claude-code-action` install often doesn't)
78
+
79
+ `anthropics/claude-code-action@v1` is the underlying engine โ€” clud-bug just configures it correctly. Two things people commonly miss when wiring it themselves:
80
+
81
+ - **`gh pr comment` is disabled by default.** Without `--allowedTools` whitelisting it, Claude runs, thinks, and exits silently. clud-bug's generated workflow includes the right allowlist.
82
+ - **Skills are not auto-loaded from anywhere.** If you don't ship `.claude/skills/*` in your repo, Claude reviews with zero project context. clud-bug installs a curated set so the review is actually project-aware.
83
+
84
+ ## Fork PR caveat โš ๏ธ
85
+
86
+ GitHub does **not** pass repo secrets (including `ANTHROPIC_API_KEY`) to workflows triggered by PRs from forks. By default, `pull_request` workflows on fork PRs will run with no API key and produce no comment.
87
+
88
+ If you want clud-bug to review fork PRs too, you have two options:
89
+
90
+ 1. **Maintainer re-pushes the branch** to your repo as a non-fork branch, and the review runs.
91
+ 2. **Switch the trigger to `pull_request_target`** (advanced) โ€” this gives the workflow access to secrets but runs against the *base* ref, not the PR's code. To safely review the PR's actual code, follow [`anthropics/claude-code-action` security.md](https://github.com/anthropics/claude-code-action/blob/main/docs/security.md): check out the PR head into a **subdirectory** (not the workspace root) and pass it via `--add-dir`. Skipping this is a code-execution risk.
92
+
93
+ clud-bug's generated workflow uses `pull_request` by default. If you understand the trade-offs, edit the trigger yourself.
94
+
95
+ ## Verifying it works
96
+
97
+ After install:
98
+
99
+ 1. Confirm `ANTHROPIC_API_KEY` secret is set on the repo.
100
+ 2. Open a throwaway PR with an obvious bug (e.g. `const x = null; x.foo()`).
101
+ 3. Within ~2 min, Clud Bug should post a comment flagging it.
102
+ 4. If no comment: check the **Actions** tab logs. Look for `gh pr comment` invocations and any "Resource not accessible by integration" errors (usually a permissions issue or a fork PR).
103
+
104
+ ## Manual install (advanced)
105
+
106
+ If you don't want to use the CLI, you can install a generic workflow by hand:
107
+
108
+ ```bash
109
+ mkdir -p .github/workflows
110
+ curl -o .github/workflows/clud-bug-review.yml \
111
+ https://raw.githubusercontent.com/thrillmot/clud-bug/main/templates/workflow.yml.tmpl
112
+ # Edit {{PROJECT_DESCRIPTION}} and {{LANGUAGE_HINTS}} placeholders by hand.
113
+ ```
114
+
115
+ The CLI does this for you, plus skill curation.
116
+
117
+ ## Contributing
118
+
119
+ Pull requests welcome. If you're adding a new detector for a language ecosystem, put it in `lib/detect.js` and add a fixture-based test in `test/detect.test.js`.
120
+
121
+ ```bash
122
+ npm test # node:test, no runtime deps
123
+ ```
124
+
125
+ ## License
126
+
127
+ MIT.
@@ -0,0 +1,322 @@
1
+ #!/usr/bin/env node
2
+ import { mkdir, writeFile, readFile } from 'node:fs/promises';
3
+ import { join, dirname } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { spawnSync } from 'node:child_process';
6
+ import { createInterface } from 'node:readline/promises';
7
+ import { stdin as input, stdout as output } from 'node:process';
8
+
9
+ import { detect, buildDescriptionLine } from '../lib/detect.js';
10
+ import { renderFile, pickTemplate } from '../lib/render.js';
11
+ import {
12
+ SkillsClient, rankAndCap, writeSkills, writeSkill, loadBaseline,
13
+ readManifest, writeManifest, removeSkill, listInstalled, diffManifest,
14
+ } from '../lib/skills.js';
15
+
16
+ const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
17
+ const TEMPLATES = join(PKG_ROOT, 'templates');
18
+ const BASELINE_DIR = join(TEMPLATES, 'skills', 'baseline');
19
+
20
+ function parseArgs(argv) {
21
+ const args = { _: [], offline: false, acceptAll: false, commit: false, help: false, version: false };
22
+ for (const a of argv) {
23
+ if (a === '--offline') args.offline = true;
24
+ else if (a === '--accept-all' || a === '-y') args.acceptAll = true;
25
+ else if (a === '--commit') args.commit = true;
26
+ else if (a === '--help' || a === '-h') args.help = true;
27
+ else if (a === '--version' || a === '-v') args.version = true;
28
+ else args._.push(a);
29
+ }
30
+ return args;
31
+ }
32
+
33
+ const HELP = `clud-bug โ€” Claude PR review with project-aware skills
34
+
35
+ Usage:
36
+ npx clud-bug <command> [options]
37
+
38
+ Commands:
39
+ init First-time setup: detect repo, install skills, write workflow.
40
+ list Show currently installed skills (baseline / remote / custom).
41
+ add <source/name> Install one skill from skills.sh (e.g. vercel-labs/skills/next-best-practices).
42
+ remove <slug> Remove an installed skill (refuses if it's a custom skill).
43
+ refresh Re-query skills.sh and diff against installed; prompt to update.
44
+
45
+ Options:
46
+ --offline Skip skills.sh; only use bundled baseline skills (init/refresh).
47
+ --accept-all,-y Accept recommendations without prompting.
48
+ --commit git add + commit the generated files when done (init only).
49
+ --help,-h Show this help.
50
+ --version,-v Show version.
51
+ `;
52
+
53
+ async function readPkgVersion() {
54
+ const pkg = JSON.parse(await readFile(join(PKG_ROOT, 'package.json'), 'utf8'));
55
+ return pkg.version;
56
+ }
57
+
58
+ async function main() {
59
+ const args = parseArgs(process.argv.slice(2));
60
+ if (args.help) { process.stdout.write(HELP); return; }
61
+ if (args.version) { process.stdout.write((await readPkgVersion()) + '\n'); return; }
62
+
63
+ const cmd = args._[0];
64
+ switch (cmd) {
65
+ case 'init': return runInit(args);
66
+ case 'list': return runList(args);
67
+ case 'add': return runAdd(args);
68
+ case 'remove': return runRemove(args);
69
+ case 'refresh': return runRefresh(args);
70
+ default:
71
+ process.stderr.write(`Unknown command: ${cmd || '(none)'}\n\n${HELP}`);
72
+ process.exit(2);
73
+ }
74
+ }
75
+
76
+ async function runInit(args) {
77
+ const cwd = process.cwd();
78
+ log(`๐Ÿ› clud-bug init in ${cwd}`);
79
+
80
+ log(' detecting repo signals...');
81
+ const signals = await detect(cwd);
82
+ log(` primary language: ${signals.primaryLanguage || '(unknown)'}`);
83
+ log(` search terms: ${signals.searchTerms.join(', ') || '(none)'}`);
84
+
85
+ const baseline = await loadBaseline(BASELINE_DIR);
86
+ log(` baseline skills: ${baseline.length}`);
87
+
88
+ let curated = [];
89
+ let searched = [];
90
+ if (args.offline) {
91
+ log(' --offline: skipping skills.sh');
92
+ } else {
93
+ const client = new SkillsClient();
94
+ try {
95
+ log(' querying skills.sh...');
96
+ [curated, searched] = await Promise.all([
97
+ client.curated().catch(err => { warn(`curated query failed: ${err.message}`); return []; }),
98
+ client.search(signals.searchTerms).catch(err => { warn(`search failed: ${err.message}`); return []; }),
99
+ ]);
100
+ log(` curated: ${curated.length}, search hits: ${searched.length}`);
101
+ } catch (err) {
102
+ warn(`skills.sh unreachable (${err.message}); continuing with baseline only`);
103
+ }
104
+ }
105
+
106
+ const recommended = rankAndCap(curated, searched, baseline);
107
+ log('');
108
+ log('Recommended skills:');
109
+ for (const s of recommended) {
110
+ const tag = s.kind === 'baseline' ? '[baseline]' : `[${s.source}]`;
111
+ log(` โ€ข ${s.name} ${tag}`);
112
+ if (s.description && s.kind !== 'baseline') log(` ${s.description}`);
113
+ }
114
+ log('');
115
+
116
+ let chosen = recommended;
117
+ if (!args.acceptAll && recommended.some(s => s.kind !== 'baseline')) {
118
+ chosen = await promptForSkills(recommended);
119
+ }
120
+
121
+ log(' installing skills into .claude/skills/...');
122
+ const client = new SkillsClient();
123
+ const written = await writeSkills(join(cwd, '.claude', 'skills'), chosen, client);
124
+ log(` wrote ${written.length} skills`);
125
+
126
+ log(' rendering workflow...');
127
+ const tmplName = pickTemplate(signals.languages);
128
+ const tmplPath = join(TEMPLATES, tmplName);
129
+ const workflow = await renderFile(tmplPath, {
130
+ PROJECT_DESCRIPTION: buildDescriptionLine(signals),
131
+ LANGUAGE_HINTS: '',
132
+ });
133
+ const workflowPath = join(cwd, '.github', 'workflows', 'clud-bug-review.yml');
134
+ await mkdir(dirname(workflowPath), { recursive: true });
135
+ await writeFile(workflowPath, workflow);
136
+ log(` wrote ${rel(cwd, workflowPath)}`);
137
+
138
+ if (args.commit) {
139
+ log(' committing...');
140
+ spawnSync('git', ['add', '.claude', '.github/workflows/clud-bug-review.yml'], { cwd, stdio: 'inherit' });
141
+ spawnSync('git', ['commit', '-m', 'Add clud-bug Claude PR review'], { cwd, stdio: 'inherit' });
142
+ }
143
+
144
+ log('');
145
+ log('Done. Next steps:');
146
+ log(' 1. Set ANTHROPIC_API_KEY in your repo secrets:');
147
+ log(' Settings โ†’ Secrets and variables โ†’ Actions โ†’ New repository secret');
148
+ if (!args.commit) {
149
+ log(' 2. git add .claude .github/workflows/clud-bug-review.yml && git commit && git push');
150
+ log(' 3. Open a PR โ€” Clud Bug should comment within ~2 min.');
151
+ } else {
152
+ log(' 2. git push, then open a PR โ€” Clud Bug should comment within ~2 min.');
153
+ }
154
+ log('');
155
+ log('Drop your own .claude/skills/<name>/SKILL.md files anytime โ€” they\'re auto-loaded.');
156
+ }
157
+
158
+ async function promptForSkills(recommended) {
159
+ const rl = createInterface({ input, output });
160
+ try {
161
+ const answer = await rl.question('Install all of the above? [Y/n/select] ');
162
+ const a = answer.trim().toLowerCase();
163
+ if (a === '' || a === 'y' || a === 'yes') return recommended;
164
+ if (a === 'n' || a === 'no') return recommended.filter(s => s.kind === 'baseline');
165
+ if (a === 's' || a === 'select') {
166
+ const chosen = [];
167
+ for (const skill of recommended) {
168
+ if (skill.kind === 'baseline') { chosen.push(skill); continue; }
169
+ const ans = await rl.question(` install ${skill.name}? [Y/n] `);
170
+ if (ans.trim().toLowerCase() !== 'n') chosen.push(skill);
171
+ }
172
+ return chosen;
173
+ }
174
+ return recommended;
175
+ } finally {
176
+ rl.close();
177
+ }
178
+ }
179
+
180
+ async function runList(_args) {
181
+ const skillsDir = join(process.cwd(), '.claude', 'skills');
182
+ const groups = await listInstalled(skillsDir);
183
+ const total = groups.baseline.length + groups.remote.length + groups.custom.length;
184
+ if (total === 0) {
185
+ log('No skills installed yet. Run `clud-bug init` to get started.');
186
+ return;
187
+ }
188
+ log(`๐Ÿ› ${total} skill${total === 1 ? '' : 's'} in .claude/skills/`);
189
+ if (groups.baseline.length) {
190
+ log('');
191
+ log('Baseline (always installed):');
192
+ for (const s of groups.baseline) log(` โ€ข ${s.slug}`);
193
+ }
194
+ if (groups.remote.length) {
195
+ log('');
196
+ log('From skills.sh:');
197
+ for (const s of groups.remote) log(` โ€ข ${s.slug} ${s.source ? `[${s.source}]` : ''}`);
198
+ }
199
+ if (groups.custom.length) {
200
+ log('');
201
+ log('Custom (yours, never auto-modified):');
202
+ for (const s of groups.custom) {
203
+ log(` โ€ข ${s.slug}${s.description ? ` โ€” ${s.description}` : ''}`);
204
+ }
205
+ }
206
+ }
207
+
208
+ async function runAdd(args) {
209
+ const ref = args._[1];
210
+ if (!ref || !ref.includes('/')) {
211
+ process.stderr.write('Usage: clud-bug add <source/name> (e.g. vercel-labs/skills/next-best-practices)\n');
212
+ process.exit(2);
213
+ }
214
+ // Last segment is the skill name; everything before is the source repo path.
215
+ const lastSlash = ref.lastIndexOf('/');
216
+ const source = ref.slice(0, lastSlash);
217
+ const name = ref.slice(lastSlash + 1);
218
+ const skillsDir = join(process.cwd(), '.claude', 'skills');
219
+ log(` fetching ${source}/${name} from skills.sh...`);
220
+ const client = new SkillsClient();
221
+ const entry = await writeSkill(skillsDir, { source, name, kind: 'remote' }, client);
222
+ const manifest = await readManifest(skillsDir);
223
+ const merged = { version: 1, installed: [...manifest.installed.filter(e => e.slug !== entry.slug), entry] };
224
+ await writeManifest(skillsDir, merged);
225
+ log(` โœ“ installed ${entry.slug} โ†’ .claude/skills/${entry.slug}/SKILL.md`);
226
+ log(' Commit + push to apply on the next PR.');
227
+ }
228
+
229
+ async function runRemove(args) {
230
+ const slug = args._[1];
231
+ if (!slug) {
232
+ process.stderr.write('Usage: clud-bug remove <slug> (run `clud-bug list` to see installed slugs)\n');
233
+ process.exit(2);
234
+ }
235
+ const skillsDir = join(process.cwd(), '.claude', 'skills');
236
+ const entry = await removeSkill(skillsDir, slug);
237
+ log(` โœ“ removed ${entry.slug}${entry.kind === 'baseline' ? ' (baseline โ€” will return on next init)' : ''}`);
238
+ }
239
+
240
+ async function runRefresh(args) {
241
+ const cwd = process.cwd();
242
+ const skillsDir = join(cwd, '.claude', 'skills');
243
+ const manifest = await readManifest(skillsDir);
244
+ if (manifest.installed.length === 0) {
245
+ log('No clud-bug-managed skills found. Run `clud-bug init` first.');
246
+ return;
247
+ }
248
+
249
+ log(' detecting repo signals...');
250
+ const signals = await detect(cwd);
251
+ log(` primary language: ${signals.primaryLanguage || '(unknown)'}`);
252
+ log(` search terms: ${signals.searchTerms.join(', ') || '(none)'}`);
253
+
254
+ const baseline = await loadBaseline(BASELINE_DIR);
255
+ let curated = [];
256
+ let searched = [];
257
+ if (args.offline) {
258
+ log(' --offline: skipping skills.sh โ€” only baseline additions will be diffed; existing remote skills are preserved');
259
+ } else {
260
+ const client = new SkillsClient();
261
+ let curatedErr, searchedErr;
262
+ [curated, searched] = await Promise.all([
263
+ client.curated().catch(err => { curatedErr = err; return []; }),
264
+ client.search(signals.searchTerms).catch(err => { searchedErr = err; return []; }),
265
+ ]);
266
+ if (curatedErr || searchedErr) {
267
+ const err = curatedErr || searchedErr;
268
+ warn(`skills.sh unreachable (${err.message})`);
269
+ warn('refusing to compute removals โ€” an empty API response would look like "delete everything from skills.sh".');
270
+ warn('Try again later, or run with --offline to install only baseline updates.');
271
+ process.exit(1);
272
+ }
273
+ }
274
+ const recommended = rankAndCap(curated, searched, baseline);
275
+ const diff = diffManifest(manifest, recommended);
276
+
277
+ // In --offline mode the recommendation set isn't authoritative (we only have
278
+ // baseline locally), so any "missing from recommendations" entry is a false
279
+ // positive. Suppress removals to avoid mass-deleting the user's remote skills.
280
+ if (args.offline) diff.remove = [];
281
+
282
+ log('');
283
+ log(` add: ${diff.add.length}`);
284
+ log(` remove: ${diff.remove.length} (custom skills untouched)`);
285
+ log(` unchanged: ${diff.unchanged.length}`);
286
+
287
+ if (diff.add.length === 0 && diff.remove.length === 0) {
288
+ log('');
289
+ log('Nothing to update. Skills are in sync with skills.sh recommendations.');
290
+ return;
291
+ }
292
+
293
+ log('');
294
+ for (const s of diff.add) log(` + ${s.name} [${s.source || s.kind}]`);
295
+ for (const s of diff.remove) log(` - ${s.slug} [${s.source || s.kind}]`);
296
+
297
+ if (!args.acceptAll) {
298
+ const rl = createInterface({ input, output });
299
+ const answer = await rl.question('\nApply these changes? [y/N] ');
300
+ rl.close();
301
+ if (answer.trim().toLowerCase() !== 'y') {
302
+ log('Aborted. No files changed.');
303
+ return;
304
+ }
305
+ }
306
+
307
+ const client = new SkillsClient();
308
+ if (diff.add.length) await writeSkills(skillsDir, diff.add, client);
309
+ for (const entry of diff.remove) await removeSkill(skillsDir, entry.slug);
310
+ log(' โœ“ skills updated. Commit + push to apply on the next PR.');
311
+ }
312
+
313
+ function rel(from, to) {
314
+ return to.startsWith(from + '/') ? to.slice(from.length + 1) : to;
315
+ }
316
+ function log(msg) { process.stdout.write(msg + '\n'); }
317
+ function warn(msg) { process.stderr.write(` ! ${msg}\n`); }
318
+
319
+ main().catch(err => {
320
+ process.stderr.write(`clud-bug: ${err.message}\n`);
321
+ process.exit(1);
322
+ });
package/lib/detect.js ADDED
@@ -0,0 +1,231 @@
1
+ import { readFile, readdir, stat } from 'node:fs/promises';
2
+ import { join, extname } from 'node:path';
3
+
4
+ const EXT_TO_LANG = {
5
+ '.ts': 'typescript', '.tsx': 'typescript',
6
+ '.js': 'javascript', '.jsx': 'javascript', '.mjs': 'javascript', '.cjs': 'javascript',
7
+ '.py': 'python',
8
+ '.go': 'go',
9
+ '.rs': 'rust',
10
+ '.rb': 'ruby',
11
+ '.java': 'java', '.kt': 'kotlin',
12
+ '.swift': 'swift',
13
+ '.php': 'php',
14
+ '.cs': 'csharp',
15
+ '.c': 'c', '.h': 'c',
16
+ '.cpp': 'cpp', '.cc': 'cpp', '.hpp': 'cpp',
17
+ };
18
+
19
+ // Dependency name โ†’ search term hint passed to skills.sh.
20
+ // Only well-known frameworks; obscure packages get filtered out so the
21
+ // skills.sh query doesn't get drowned in noise.
22
+ const DEP_TO_TERM = {
23
+ 'next': 'nextjs', 'react': 'react', 'vue': 'vue', 'svelte': 'svelte',
24
+ '@angular/core': 'angular', 'solid-js': 'solid',
25
+ 'express': 'express', 'fastify': 'fastify', 'koa': 'koa', 'hono': 'hono',
26
+ 'prisma': 'prisma', '@prisma/client': 'prisma', 'drizzle-orm': 'drizzle',
27
+ 'mongoose': 'mongodb', 'mongodb': 'mongodb',
28
+ 'tailwindcss': 'tailwind',
29
+ 'vitest': 'vitest', 'jest': 'jest', 'playwright': 'playwright',
30
+ '@playwright/test': 'playwright',
31
+ 'typescript': 'typescript',
32
+ };
33
+
34
+ const PY_DEP_TO_TERM = {
35
+ 'django': 'django', 'flask': 'flask', 'fastapi': 'fastapi',
36
+ 'click': 'click', 'typer': 'typer',
37
+ 'pytest': 'pytest', 'sqlalchemy': 'sqlalchemy',
38
+ 'pydantic': 'pydantic', 'numpy': 'numpy', 'pandas': 'pandas',
39
+ };
40
+
41
+ async function fileExists(path) {
42
+ try {
43
+ await stat(path);
44
+ return true;
45
+ } catch {
46
+ return false;
47
+ }
48
+ }
49
+
50
+ async function readJsonSafe(path) {
51
+ try {
52
+ return JSON.parse(await readFile(path, 'utf8'));
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ async function readTextSafe(path) {
59
+ try {
60
+ return await readFile(path, 'utf8');
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ async function detectFromPackageJson(root) {
67
+ const pkg = await readJsonSafe(join(root, 'package.json'));
68
+ if (!pkg) return null;
69
+ const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
70
+ const terms = new Set();
71
+ for (const dep of Object.keys(deps)) {
72
+ if (DEP_TO_TERM[dep]) terms.add(DEP_TO_TERM[dep]);
73
+ }
74
+ return {
75
+ name: pkg.name,
76
+ description: pkg.description || null,
77
+ languages: ['javascript', ...(deps.typescript || pkg.devDependencies?.typescript ? ['typescript'] : [])],
78
+ terms: [...terms],
79
+ };
80
+ }
81
+
82
+ async function detectFromPyproject(root) {
83
+ const text = await readTextSafe(join(root, 'pyproject.toml'));
84
+ if (!text) return null;
85
+ const terms = new Set();
86
+ for (const [dep, term] of Object.entries(PY_DEP_TO_TERM)) {
87
+ // crude but adequate match โ€” full TOML parse would be overkill for the
88
+ // dependency-name lookup we actually need
89
+ if (new RegExp(`["']${dep}[><=~ "']`, 'i').test(text)) terms.add(term);
90
+ }
91
+ const nameMatch = text.match(/^\s*name\s*=\s*["']([^"']+)["']/m);
92
+ const descMatch = text.match(/^\s*description\s*=\s*["']([^"']+)["']/m);
93
+ return {
94
+ name: nameMatch?.[1] || null,
95
+ description: descMatch?.[1] || null,
96
+ languages: ['python'],
97
+ terms: [...terms],
98
+ };
99
+ }
100
+
101
+ async function detectFromRequirements(root) {
102
+ const text = await readTextSafe(join(root, 'requirements.txt'));
103
+ if (!text) return null;
104
+ const terms = new Set();
105
+ for (const line of text.split('\n')) {
106
+ const dep = line.split(/[<>=~ #]/)[0].trim().toLowerCase();
107
+ if (PY_DEP_TO_TERM[dep]) terms.add(PY_DEP_TO_TERM[dep]);
108
+ }
109
+ return { name: null, description: null, languages: ['python'], terms: [...terms] };
110
+ }
111
+
112
+ async function detectFromGoMod(root) {
113
+ const text = await readTextSafe(join(root, 'go.mod'));
114
+ if (!text) return null;
115
+ const moduleMatch = text.match(/^module\s+(\S+)/m);
116
+ return {
117
+ name: moduleMatch?.[1]?.split('/').pop() || null,
118
+ description: null,
119
+ languages: ['go'],
120
+ terms: [],
121
+ };
122
+ }
123
+
124
+ async function detectFromCargo(root) {
125
+ const text = await readTextSafe(join(root, 'Cargo.toml'));
126
+ if (!text) return null;
127
+ const nameMatch = text.match(/^\s*name\s*=\s*["']([^"']+)["']/m);
128
+ const descMatch = text.match(/^\s*description\s*=\s*["']([^"']+)["']/m);
129
+ return {
130
+ name: nameMatch?.[1] || null,
131
+ description: descMatch?.[1] || null,
132
+ languages: ['rust'],
133
+ terms: [],
134
+ };
135
+ }
136
+
137
+ async function detectFromGemfile(root) {
138
+ const text = await readTextSafe(join(root, 'Gemfile'));
139
+ if (!text) return null;
140
+ return { name: null, description: null, languages: ['ruby'], terms: [] };
141
+ }
142
+
143
+ async function fileHistogram(root) {
144
+ const counts = {};
145
+ async function walk(dir, depth) {
146
+ if (depth > 3) return;
147
+ let entries;
148
+ try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; }
149
+ for (const entry of entries) {
150
+ if (entry.name.startsWith('.') || entry.name === 'node_modules' ||
151
+ entry.name === 'dist' || entry.name === 'build' ||
152
+ entry.name === '__pycache__' || entry.name === 'target') continue;
153
+ const full = join(dir, entry.name);
154
+ if (entry.isDirectory()) {
155
+ await walk(full, depth + 1);
156
+ } else {
157
+ const lang = EXT_TO_LANG[extname(entry.name)];
158
+ if (lang) counts[lang] = (counts[lang] || 0) + 1;
159
+ }
160
+ }
161
+ }
162
+ await walk(root, 0);
163
+ return counts;
164
+ }
165
+
166
+ function firstParagraph(readme) {
167
+ if (!readme) return null;
168
+ const lines = readme.split('\n').slice(0, 200);
169
+ const paragraphs = lines.join('\n').split(/\n\s*\n/);
170
+ for (const p of paragraphs) {
171
+ const cleaned = p.replace(/^#+\s*/, '').replace(/[*_`]/g, '').trim();
172
+ if (cleaned.length > 40) return cleaned.slice(0, 500);
173
+ }
174
+ return null;
175
+ }
176
+
177
+ export async function detect(root) {
178
+ const detectors = [
179
+ detectFromPackageJson, detectFromPyproject, detectFromRequirements,
180
+ detectFromGoMod, detectFromCargo, detectFromGemfile,
181
+ ];
182
+ const results = (await Promise.all(detectors.map(d => d(root)))).filter(Boolean);
183
+ const histogram = await fileHistogram(root);
184
+ const readme = await readTextSafe(join(root, 'README.md'))
185
+ || await readTextSafe(join(root, 'README'));
186
+
187
+ const languages = new Set();
188
+ const terms = new Set();
189
+ let name = null;
190
+ let description = null;
191
+ for (const r of results) {
192
+ for (const lang of r.languages) languages.add(lang);
193
+ for (const term of r.terms) terms.add(term);
194
+ if (!name && r.name) name = r.name;
195
+ if (!description && r.description) description = r.description;
196
+ }
197
+ for (const lang of Object.keys(histogram)) languages.add(lang);
198
+ if (!description) description = firstParagraph(readme);
199
+
200
+ // Prefer the language with the most files when picking a primary
201
+ const sortedLangs = [...languages].sort((a, b) => (histogram[b] || 0) - (histogram[a] || 0));
202
+
203
+ return {
204
+ name,
205
+ description,
206
+ languages: sortedLangs,
207
+ histogram,
208
+ searchTerms: [...new Set([...terms, ...sortedLangs.slice(0, 2)])],
209
+ primaryLanguage: sortedLangs[0] || null,
210
+ };
211
+ }
212
+
213
+ export function buildDescriptionLine(signals) {
214
+ const parts = [];
215
+ if (signals.name) parts.push(`This project is "${signals.name}".`);
216
+ if (signals.description) {
217
+ const desc = signals.description.trim();
218
+ parts.push(/[.!?]$/.test(desc) ? desc : `${desc}.`);
219
+ }
220
+ if (signals.primaryLanguage) {
221
+ const frameworks = [...new Set(signals.searchTerms)].filter(t =>
222
+ !['typescript', 'javascript', 'python', 'go', 'rust', 'ruby'].includes(t));
223
+ const frameworkPart = frameworks.length ? ` using ${frameworks.join(', ')}` : '';
224
+ parts.push(`It's primarily ${signals.primaryLanguage}${frameworkPart}.`);
225
+ }
226
+ if (parts.length === 0) return 'Project context unavailable โ€” review on the merits of the diff alone.';
227
+ return parts.join(' ');
228
+ }
229
+
230
+ // Test seam: allow tests to inject the EXT_TO_LANG and DEP_TO_TERM tables
231
+ export const _internal = { EXT_TO_LANG, DEP_TO_TERM, PY_DEP_TO_TERM, fileHistogram, firstParagraph };
package/lib/render.js ADDED
@@ -0,0 +1,27 @@
1
+ import { readFile } from 'node:fs/promises';
2
+
3
+ const PLACEHOLDER_RE = /\{\{([A-Z_]+)\}\}/g;
4
+
5
+ export function render(template, vars) {
6
+ return template.replace(PLACEHOLDER_RE, (match, key) => {
7
+ if (!(key in vars)) {
8
+ throw new Error(`Missing template variable: ${key}`);
9
+ }
10
+ return vars[key];
11
+ });
12
+ }
13
+
14
+ export async function renderFile(path, vars) {
15
+ const tmpl = await readFile(path, 'utf8');
16
+ return render(tmpl, vars);
17
+ }
18
+
19
+ export function pickTemplate(languages) {
20
+ if (languages.includes('typescript') || languages.includes('javascript')) {
21
+ return 'workflow-ts.yml.tmpl';
22
+ }
23
+ if (languages.includes('python')) {
24
+ return 'workflow-py.yml.tmpl';
25
+ }
26
+ return 'workflow.yml.tmpl';
27
+ }
package/lib/skills.js ADDED
@@ -0,0 +1,243 @@
1
+ import { mkdir, writeFile, readdir, readFile, rm, stat } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+
4
+ const API_BASE = 'https://skills.sh/api/v1';
5
+ const MAX_SKILLS = 8;
6
+ const MANIFEST_FILE = '.clud-bug.json';
7
+ const MANIFEST_VERSION = 1;
8
+
9
+ export class SkillsClient {
10
+ constructor({ fetch = globalThis.fetch, base, userAgent = 'clud-bug' } = {}) {
11
+ this.fetch = fetch;
12
+ this.base = base ?? process.env.CLUD_BUG_SKILLS_SH_BASE ?? API_BASE;
13
+ this.userAgent = userAgent;
14
+ }
15
+
16
+ async #json(path) {
17
+ const res = await this.fetch(`${this.base}${path}`, {
18
+ headers: { 'User-Agent': this.userAgent, accept: 'application/json' },
19
+ });
20
+ if (!res.ok) {
21
+ throw new Error(`skills.sh ${path} โ†’ ${res.status}`);
22
+ }
23
+ return res.json();
24
+ }
25
+
26
+ async search(terms) {
27
+ const q = terms.filter(Boolean).join(' ').trim();
28
+ if (!q) return [];
29
+ const data = await this.#json(`/skills/search?q=${encodeURIComponent(q)}`);
30
+ return normalizeList(data);
31
+ }
32
+
33
+ async curated() {
34
+ const data = await this.#json('/skills/curated');
35
+ return normalizeList(data);
36
+ }
37
+
38
+ async getContent(source, name) {
39
+ const data = await this.#json(`/skills/${encodeURIComponent(source)}/${encodeURIComponent(name)}`);
40
+ // The API may return content as `body`, `content`, or under `files[0].content`.
41
+ // Try the documented shapes in order; fail loudly if none match so we know
42
+ // the API contract changed.
43
+ if (typeof data?.content === 'string') return data.content;
44
+ if (typeof data?.body === 'string') return data.body;
45
+ if (typeof data?.files?.[0]?.content === 'string') return data.files[0].content;
46
+ throw new Error(`skills.sh response for ${source}/${name} had no content field`);
47
+ }
48
+ }
49
+
50
+ function normalizeList(data) {
51
+ // Tolerate either { skills: [...] } or a bare array.
52
+ const list = Array.isArray(data) ? data : (data?.skills || data?.results || []);
53
+ return list.map(item => ({
54
+ source: item.source || item.repo || '',
55
+ name: item.name || item.slug || '',
56
+ description: item.description || item.summary || '',
57
+ installs: item.installs || item.installCount || 0,
58
+ })).filter(s => s.source && s.name);
59
+ }
60
+
61
+ // Deduplicates by source/name and caps at MAX_SKILLS, preferring curated then by install count.
62
+ export function rankAndCap(curated, searched, baseline, cap = MAX_SKILLS) {
63
+ const seen = new Set(baseline.map(b => `local:${b.name}`));
64
+ const out = [...baseline];
65
+ const remaining = cap - baseline.length;
66
+ if (remaining <= 0) return out.slice(0, cap);
67
+
68
+ const curatedSorted = [...curated].sort((a, b) => b.installs - a.installs);
69
+ const searchedSorted = [...searched].sort((a, b) => b.installs - a.installs);
70
+
71
+ for (const skill of [...curatedSorted, ...searchedSorted]) {
72
+ if (out.length >= cap) break;
73
+ const key = `${skill.source}/${skill.name}`;
74
+ if (seen.has(key)) continue;
75
+ seen.add(key);
76
+ out.push({ ...skill, kind: skill.kind || 'remote' });
77
+ }
78
+ return out;
79
+ }
80
+
81
+ export async function loadBaseline(baselineDir) {
82
+ const skills = [];
83
+ let entries;
84
+ try {
85
+ entries = await readdir(baselineDir, { withFileTypes: true });
86
+ } catch {
87
+ return skills;
88
+ }
89
+ for (const entry of entries) {
90
+ if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
91
+ const content = await readFile(join(baselineDir, entry.name), 'utf8');
92
+ skills.push({
93
+ source: 'clud-bug-baseline',
94
+ name: entry.name.replace(/\.md$/, ''),
95
+ description: '(bundled baseline)',
96
+ installs: 0,
97
+ kind: 'baseline',
98
+ content,
99
+ });
100
+ }
101
+ return skills;
102
+ }
103
+
104
+ export async function writeSkills(targetDir, skills, client) {
105
+ await mkdir(targetDir, { recursive: true });
106
+ const written = [];
107
+ for (const skill of skills) {
108
+ const entry = await writeSkill(targetDir, skill, client);
109
+ written.push(entry);
110
+ }
111
+ await writeManifest(targetDir, mergeManifest(await readManifest(targetDir), written));
112
+ return written;
113
+ }
114
+
115
+ export async function writeSkill(targetDir, skill, client) {
116
+ await mkdir(targetDir, { recursive: true });
117
+ const slug = sanitizeSlug(skill.name);
118
+ const skillDir = join(targetDir, slug);
119
+ await mkdir(skillDir, { recursive: true });
120
+ const content = skill.content ?? await client.getContent(skill.source, skill.name);
121
+ await writeFile(join(skillDir, 'SKILL.md'), content);
122
+ return {
123
+ slug,
124
+ name: skill.name,
125
+ source: skill.source,
126
+ kind: skill.kind || 'remote',
127
+ description: skill.description || '',
128
+ };
129
+ }
130
+
131
+ export async function readManifest(targetDir) {
132
+ try {
133
+ const text = await readFile(join(targetDir, MANIFEST_FILE), 'utf8');
134
+ const data = JSON.parse(text);
135
+ return {
136
+ version: data.version || MANIFEST_VERSION,
137
+ installed: Array.isArray(data.installed) ? data.installed : [],
138
+ };
139
+ } catch {
140
+ return { version: MANIFEST_VERSION, installed: [] };
141
+ }
142
+ }
143
+
144
+ export async function writeManifest(targetDir, manifest) {
145
+ await mkdir(targetDir, { recursive: true });
146
+ const out = {
147
+ version: manifest.version || MANIFEST_VERSION,
148
+ installed: manifest.installed || [],
149
+ };
150
+ await writeFile(join(targetDir, MANIFEST_FILE), JSON.stringify(out, null, 2) + '\n');
151
+ }
152
+
153
+ export function mergeManifest(existing, newEntries) {
154
+ const byKey = new Map();
155
+ for (const entry of existing.installed || []) {
156
+ byKey.set(entryKey(entry), entry);
157
+ }
158
+ for (const entry of newEntries) {
159
+ byKey.set(entryKey(entry), entry);
160
+ }
161
+ return { version: MANIFEST_VERSION, installed: [...byKey.values()] };
162
+ }
163
+
164
+ function entryKey(entry) {
165
+ // Baseline skills have no source; key by slug. Remote skills key by source/name.
166
+ return entry.kind === 'baseline' ? `baseline:${entry.slug}` : `${entry.source}/${entry.name || entry.slug}`;
167
+ }
168
+
169
+ export async function removeSkill(targetDir, slug) {
170
+ const manifest = await readManifest(targetDir);
171
+ const entry = manifest.installed.find(e => e.slug === slug);
172
+ if (!entry) {
173
+ 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}`);
174
+ }
175
+ await rm(join(targetDir, slug), { recursive: true, force: true });
176
+ manifest.installed = manifest.installed.filter(e => e.slug !== slug);
177
+ await writeManifest(targetDir, manifest);
178
+ return entry;
179
+ }
180
+
181
+ export async function listInstalled(targetDir) {
182
+ const manifest = await readManifest(targetDir);
183
+ const managedSlugs = new Set(manifest.installed.map(e => e.slug));
184
+ const groups = { baseline: [], remote: [], custom: [] };
185
+ for (const entry of manifest.installed) {
186
+ (groups[entry.kind === 'baseline' ? 'baseline' : 'remote']).push(entry);
187
+ }
188
+
189
+ let entries;
190
+ try {
191
+ entries = await readdir(targetDir, { withFileTypes: true });
192
+ } catch {
193
+ return groups;
194
+ }
195
+ for (const entry of entries) {
196
+ if (!entry.isDirectory()) continue;
197
+ if (managedSlugs.has(entry.name)) continue;
198
+ const skillFile = join(targetDir, entry.name, 'SKILL.md');
199
+ let description = '';
200
+ try {
201
+ const text = await readFile(skillFile, 'utf8');
202
+ const m = text.match(/^description:\s*(.+)$/m);
203
+ description = m?.[1]?.trim() || '';
204
+ } catch {
205
+ continue; // not a skill dir
206
+ }
207
+ groups.custom.push({ slug: entry.name, kind: 'custom', description });
208
+ }
209
+ return groups;
210
+ }
211
+
212
+ // Diff a current manifest against a freshly-recommended skill set.
213
+ // Returns { add: [], remove: [], unchanged: [] }. Custom skills are never affected.
214
+ export function diffManifest(manifest, recommended) {
215
+ const recByKey = new Map(recommended.map(s => [
216
+ s.kind === 'baseline' ? `baseline:${sanitizeSlug(s.name)}` : `${s.source}/${s.name}`,
217
+ s,
218
+ ]));
219
+ const installedByKey = new Map(manifest.installed.map(e => [entryKey(e), e]));
220
+
221
+ const add = [];
222
+ const remove = [];
223
+ const unchanged = [];
224
+
225
+ for (const [key, skill] of recByKey) {
226
+ if (installedByKey.has(key)) {
227
+ unchanged.push(skill);
228
+ } else {
229
+ add.push(skill);
230
+ }
231
+ }
232
+ for (const [key, entry] of installedByKey) {
233
+ if (entry.kind === 'baseline') continue; // baseline always stays
234
+ if (!recByKey.has(key)) remove.push(entry);
235
+ }
236
+ return { add, remove, unchanged };
237
+ }
238
+
239
+ function sanitizeSlug(name) {
240
+ return name.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '');
241
+ }
242
+
243
+ export const _internal = { normalizeList, sanitizeSlug, entryKey, MAX_SKILLS, API_BASE, MANIFEST_FILE };
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "clud-bug",
3
+ "version": "0.2.0",
4
+ "description": "Claude PR review with project-aware skills. CLI installs a working GitHub Actions workflow and curates skills from skills.sh.",
5
+ "homepage": "https://cludbug.dev",
6
+ "bugs": "https://github.com/thrillmot/clud-bug/issues",
7
+ "type": "module",
8
+ "license": "MIT",
9
+ "author": "thrillmot",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/thrillmot/clud-bug.git"
13
+ },
14
+ "keywords": [
15
+ "claude",
16
+ "claude-code",
17
+ "github-action",
18
+ "code-review",
19
+ "pull-request",
20
+ "ai",
21
+ "skills"
22
+ ],
23
+ "bin": {
24
+ "clud-bug": "./bin/clud-bug.js"
25
+ },
26
+ "files": [
27
+ "bin",
28
+ "lib",
29
+ "templates",
30
+ "README.md",
31
+ "LICENSE"
32
+ ],
33
+ "engines": {
34
+ "node": ">=20"
35
+ },
36
+ "scripts": {
37
+ "test": "node --test test/*.test.js",
38
+ "prepublishOnly": "npm test"
39
+ }
40
+ }
@@ -0,0 +1,28 @@
1
+ ---
2
+ name: critical-issues-only
3
+ description: PR review discipline - flag only correctness, security, and performance issues. Skip nits.
4
+ ---
5
+
6
+ # Critical issues only
7
+
8
+ When reviewing a pull request, only surface issues that fall into one of these buckets:
9
+
10
+ 1. **Correctness bugs** โ€” the code does the wrong thing, mishandles a case, or breaks an existing contract.
11
+ 2. **Security vulnerabilities** โ€” injection, auth bypass, secret exposure, unsafe deserialization, SSRF, etc.
12
+ 3. **Performance problems** โ€” algorithmic blowups, N+1 queries, memory leaks, blocking calls in hot paths.
13
+ 4. **Missing or broken test coverage** for new code paths that meaningfully change behavior.
14
+
15
+ ## Do not surface
16
+
17
+ - Style preferences, formatting, naming preferences.
18
+ - Architectural rewrites unless the existing approach is actively broken.
19
+ - "You could also..." suggestions that aren't bugs.
20
+ - Nitpicks the author can fix in 30 seconds and don't change behavior.
21
+
22
+ ## How to phrase findings
23
+
24
+ - One concrete issue per comment. No bundling unrelated nits.
25
+ - Quote the specific line or block being criticized.
26
+ - Say what's wrong, then what to do about it. Skip the throat-clearing.
27
+
28
+ If the diff has no issues in the four buckets above, post a single short comment confirming that โ€” don't invent problems.
@@ -0,0 +1,29 @@
1
+ ---
2
+ name: evidence-based-review
3
+ description: Every PR review claim must quote the specific code being criticized. No hand-waving.
4
+ ---
5
+
6
+ # Evidence-based review
7
+
8
+ Every claim in your review must be backed by evidence the PR author can verify in seconds.
9
+
10
+ ## Required for every finding
11
+
12
+ - **Quote the exact line or block** you're talking about (use a fenced code block or `gh pr` inline-comment anchor).
13
+ - **Cite the file and line range** so the author can navigate directly there.
14
+ - **State the failure mode concretely**: what input produces what wrong output, or what attacker action exploits what gap.
15
+
16
+ ## Banned phrasings
17
+
18
+ These are red flags that you're hand-waving instead of reviewing:
19
+
20
+ - "This might cause issues" โ†’ say *which* issue, with the input that triggers it.
21
+ - "Consider refactoring this" โ†’ either it has a bug, or it doesn't. Not your call to redesign their code.
22
+ - "This doesn't follow best practices" โ†’ cite *which* practice and why it matters here.
23
+ - "You should add tests" โ†’ name the specific code path that isn't covered.
24
+
25
+ ## Verifying your own claims
26
+
27
+ Before posting a comment, ask: if the author asked "show me", could I point at the exact line and exact failure case? If not, delete the comment.
28
+
29
+ A short, specific review of three real issues beats a long, vague review of fifteen maybes.
@@ -0,0 +1,27 @@
1
+ ---
2
+ name: respect-existing-conventions
3
+ description: Don't suggest changes that fight the codebase's established patterns. Match what's already there.
4
+ ---
5
+
6
+ # Respect existing conventions
7
+
8
+ A code review is not a redesign. The PR author is working within a codebase that has its own conventions, abstractions, and trade-offs that long predate this PR.
9
+
10
+ ## Before suggesting any change, check
11
+
12
+ - **Is this pattern already used elsewhere in the repo?** If yes, the author is following convention. Don't push them off it.
13
+ - **Did the team explicitly choose this approach?** Look at neighboring files, git log on related code, or comments. If the surrounding code already does it this way, that's a signal.
14
+ - **Would adopting your suggestion require changing 50 other files?** If yes, your suggestion belongs in a separate refactor PR or RFC, not this review.
15
+
16
+ ## Things that often *look* like problems but usually aren't
17
+
18
+ - Manual loops where a `.map()` would also work โ€” both are fine.
19
+ - Repeated three-line patterns that "could be a helper" โ€” three is below the threshold to justify abstraction.
20
+ - Type annotations the language can infer โ€” explicitness is a valid choice.
21
+ - Defensive null checks at module boundaries โ€” context-dependent.
22
+
23
+ ## When convention itself is the bug
24
+
25
+ If the existing pattern *is* the source of the bug, say so explicitly: "the convention used here propagates [specific bug]; this PR should break from it because [reason]." Don't quietly suggest a different approach without naming the trade-off.
26
+
27
+ The bar for "you should do it differently than the rest of the codebase" is high. Clear it before suggesting.
@@ -0,0 +1,71 @@
1
+ name: Clud Bug ๐Ÿ› Crawls Your Code
2
+
3
+ on:
4
+ pull_request:
5
+ types: [opened, synchronize]
6
+
7
+ jobs:
8
+ clud-bug-review:
9
+ runs-on: ubuntu-latest
10
+ permissions:
11
+ contents: read
12
+ pull-requests: write
13
+ id-token: write
14
+
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+
18
+ - uses: anthropics/claude-code-action@v1
19
+ env:
20
+ PR_NUMBER: ${{ github.event.pull_request.number }}
21
+ with:
22
+ anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
23
+ track_progress: true
24
+ claude_args: |
25
+ --allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr review:*),Bash(gh api graphql:*),Bash(gh api repos/:*)"
26
+ prompt: |
27
+ {{PROJECT_DESCRIPTION}}
28
+
29
+ Review this pull request for critical issues only. Focus on:
30
+ - Bugs, logic errors, or incorrect behaviour
31
+ - Security vulnerabilities
32
+ - Performance problems
33
+ - Incorrect exception handling (bare excepts, swallowed errors, wrong exception types)
34
+ - Missing type hints on new functions
35
+ - Incorrect use of Click (exit codes, error messages) if the project uses it
36
+ - Missing pytest coverage for new code
37
+ {{LANGUAGE_HINTS}}
38
+
39
+ Skip style suggestions, minor naming issues, or anything that
40
+ doesn't affect correctness, security, or performance.
41
+
42
+ Any project-specific skills loaded from .claude/skills/ should
43
+ shape your review โ€” defer to their guidance over generic advice.
44
+
45
+ When you finish, post your review as a single PR comment.
46
+ The comment body MUST start with this exact line so the
47
+ project's identity is visible (the bot account will say
48
+ claude[bot], but the comment header brands it as Clud Bug):
49
+
50
+ ## ๐Ÿ› Clud Bug review
51
+
52
+ Post it via:
53
+ gh pr comment "$PR_NUMBER" --body "<your review>"
54
+
55
+ If you previously reviewed this PR (you'll see prior claude[bot]
56
+ comments starting with "## ๐Ÿ› Clud Bug review"), resolve your
57
+ prior unresolved inline review threads where the flagged issue
58
+ is fixed in the current diff. List threads:
59
+
60
+ gh api graphql -f query='{ repository(owner: "${{ github.repository_owner }}", name: "${{ github.event.repository.name }}") { pullRequest(number: '"$PR_NUMBER"') { reviewThreads(first: 30) { nodes { id isResolved comments(first: 1) { nodes { body author { login } } } } } } } }'
61
+
62
+ For each unresolved thread you (claude[bot]) authored where the
63
+ issue is now addressed:
64
+
65
+ gh api graphql -f query='mutation { resolveReviewThread(input: {threadId: "<id>"}) { thread { isResolved } } }'
66
+
67
+ Only resolve threads where the fix is verifiable in the diff.
68
+ Leave unresolved any thread whose issue still stands.
69
+ For line-specific issues, use the github_inline_comment MCP tool
70
+ with confirmed: true.
71
+ If there are no critical issues, post a one-line comment saying so.
@@ -0,0 +1,72 @@
1
+ name: Clud Bug ๐Ÿ› Crawls Your Code
2
+
3
+ on:
4
+ pull_request:
5
+ types: [opened, synchronize]
6
+
7
+ jobs:
8
+ clud-bug-review:
9
+ runs-on: ubuntu-latest
10
+ permissions:
11
+ contents: read
12
+ pull-requests: write
13
+ id-token: write
14
+
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+
18
+ - uses: anthropics/claude-code-action@v1
19
+ env:
20
+ PR_NUMBER: ${{ github.event.pull_request.number }}
21
+ with:
22
+ anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
23
+ track_progress: true
24
+ claude_args: |
25
+ --allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr review:*),Bash(gh api graphql:*),Bash(gh api repos/:*)"
26
+ prompt: |
27
+ {{PROJECT_DESCRIPTION}}
28
+
29
+ Review this pull request for critical issues only. Focus on:
30
+ - Bugs, logic errors, or incorrect behaviour
31
+ - Security vulnerabilities
32
+ - Performance problems
33
+ - Broken or missing test coverage for new code
34
+ - TypeScript type safety issues (unsafe casts, missing types, incorrect generics)
35
+ - Incorrect ESM/CJS module usage
36
+ - Improper async/await or Promise handling (unhandled rejections, missing awaits)
37
+ - Incorrect use of common Node.js patterns
38
+ {{LANGUAGE_HINTS}}
39
+
40
+ Skip style suggestions, minor naming issues, or anything that
41
+ doesn't affect correctness, security, or performance.
42
+
43
+ Any project-specific skills loaded from .claude/skills/ should
44
+ shape your review โ€” defer to their guidance over generic advice.
45
+
46
+ When you finish, post your review as a single PR comment.
47
+ The comment body MUST start with this exact line so the
48
+ project's identity is visible (the bot account will say
49
+ claude[bot], but the comment header brands it as Clud Bug):
50
+
51
+ ## ๐Ÿ› Clud Bug review
52
+
53
+ Post it via:
54
+ gh pr comment "$PR_NUMBER" --body "<your review>"
55
+
56
+ If you previously reviewed this PR (you'll see prior claude[bot]
57
+ comments starting with "## ๐Ÿ› Clud Bug review"), resolve your
58
+ prior unresolved inline review threads where the flagged issue
59
+ is fixed in the current diff. List threads:
60
+
61
+ gh api graphql -f query='{ repository(owner: "${{ github.repository_owner }}", name: "${{ github.event.repository.name }}") { pullRequest(number: '"$PR_NUMBER"') { reviewThreads(first: 30) { nodes { id isResolved comments(first: 1) { nodes { body author { login } } } } } } } }'
62
+
63
+ For each unresolved thread you (claude[bot]) authored where the
64
+ issue is now addressed:
65
+
66
+ gh api graphql -f query='mutation { resolveReviewThread(input: {threadId: "<id>"}) { thread { isResolved } } }'
67
+
68
+ Only resolve threads where the fix is verifiable in the diff.
69
+ Leave unresolved any thread whose issue still stands.
70
+ For line-specific issues, use the github_inline_comment MCP tool
71
+ with confirmed: true.
72
+ If there are no critical issues, post a one-line comment saying so.
@@ -0,0 +1,68 @@
1
+ name: Clud Bug ๐Ÿ› Crawls Your Code
2
+
3
+ on:
4
+ pull_request:
5
+ types: [opened, synchronize]
6
+
7
+ jobs:
8
+ clud-bug-review:
9
+ runs-on: ubuntu-latest
10
+ permissions:
11
+ contents: read
12
+ pull-requests: write
13
+ id-token: write
14
+
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+
18
+ - uses: anthropics/claude-code-action@v1
19
+ env:
20
+ PR_NUMBER: ${{ github.event.pull_request.number }}
21
+ with:
22
+ anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
23
+ track_progress: true
24
+ claude_args: |
25
+ --allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr review:*),Bash(gh api graphql:*),Bash(gh api repos/:*)"
26
+ prompt: |
27
+ {{PROJECT_DESCRIPTION}}
28
+
29
+ Review this pull request for critical issues only. Focus on:
30
+ - Bugs, logic errors, or incorrect behaviour
31
+ - Security vulnerabilities
32
+ - Performance problems
33
+ - Broken or missing test coverage for new code
34
+ {{LANGUAGE_HINTS}}
35
+
36
+ Skip style suggestions, minor naming issues, or anything that
37
+ doesn't affect correctness, security, or performance.
38
+
39
+ Any project-specific skills loaded from .claude/skills/ should
40
+ shape your review โ€” defer to their guidance over generic advice.
41
+
42
+ When you finish, post your review as a single PR comment.
43
+ The comment body MUST start with this exact line so the
44
+ project's identity is visible (the bot account will say
45
+ claude[bot], but the comment header brands it as Clud Bug):
46
+
47
+ ## ๐Ÿ› Clud Bug review
48
+
49
+ Post it via:
50
+ gh pr comment "$PR_NUMBER" --body "<your review>"
51
+
52
+ If you previously reviewed this PR (you'll see prior claude[bot]
53
+ comments starting with "## ๐Ÿ› Clud Bug review"), resolve your
54
+ prior unresolved inline review threads where the flagged issue
55
+ is fixed in the current diff. List threads:
56
+
57
+ gh api graphql -f query='{ repository(owner: "${{ github.repository_owner }}", name: "${{ github.event.repository.name }}") { pullRequest(number: '"$PR_NUMBER"') { reviewThreads(first: 30) { nodes { id isResolved comments(first: 1) { nodes { body author { login } } } } } } } }'
58
+
59
+ For each unresolved thread you (claude[bot]) authored where the
60
+ issue is now addressed:
61
+
62
+ gh api graphql -f query='mutation { resolveReviewThread(input: {threadId: "<id>"}) { thread { isResolved } } }'
63
+
64
+ Only resolve threads where the fix is verifiable in the diff.
65
+ Leave unresolved any thread whose issue still stands.
66
+ For line-specific issues, use the github_inline_comment MCP tool
67
+ with confirmed: true.
68
+ If there are no critical issues, post a one-line comment saying so.