clud-bug 0.2.0 โ 0.4.1
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/README.md +88 -9
- package/bin/clud-bug.js +237 -38
- package/lib/audit.js +111 -0
- package/lib/edit-workflow.js +47 -0
- package/lib/skills.js +8 -1
- package/lib/update.js +98 -0
- package/package.json +2 -4
- package/templates/audit.yml.tmpl +134 -0
- package/templates/self-update.yml.tmpl +92 -0
- package/templates/workflow-py.yml.tmpl +89 -4
- package/templates/workflow-ts.yml.tmpl +89 -4
- package/templates/workflow.yml.tmpl +133 -4
package/README.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# Clud Bug ๐
|
|
2
|
-
###
|
|
2
|
+
### A field guide to specimens crawling your code
|
|
3
3
|
|
|
4
|
-
> **[cludbug.dev](https://cludbug.dev)** ยท
|
|
4
|
+
> **[cludbug.dev](https://cludbug.dev)** ยท live field journal.
|
|
5
5
|
|
|
6
|
-
Claude PR
|
|
6
|
+
Clud Bug is a Claude PR-review naturalist for your GitHub repo. It pins **project-aware skills** auto-discovered from [skills.sh](https://skills.sh) and ships a baseline kit of review discipline so reviews stay focused on what matters: bugs, security, performance, and missing tests.
|
|
7
7
|
|
|
8
|
-
One command to install. The first PR you open afterwards gets a real review comment back.
|
|
8
|
+
One command to install. The first PR you open afterwards gets a real review comment back โ typically within two minutes.
|
|
9
9
|
|
|
10
10
|
## Quickstart
|
|
11
11
|
|
|
@@ -24,14 +24,16 @@ Open a PR. A review comment should appear within ~2 minutes.
|
|
|
24
24
|
|
|
25
25
|
## What `clud-bug init` does
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
The naturalist arrives at your repo, surveys the habitat, and assembles a field kit:
|
|
28
|
+
|
|
29
|
+
1. **Surveys habitat.** Reads `package.json`, `pyproject.toml`, `go.mod`, `Cargo.toml`, etc., to learn what your stack is.
|
|
30
|
+
2. **Consults [skills.sh](https://skills.sh).** Pulls review skills relevant to your dependencies (e.g. a Next.js project gets Next.js review specimens).
|
|
31
|
+
3. **Pins three baseline specimens** that enforce review discipline regardless of stack:
|
|
30
32
|
- `critical-issues-only` โ flag bugs, security, perf only. Skip nits.
|
|
31
33
|
- `evidence-based-review` โ every claim must quote the line being criticized.
|
|
32
34
|
- `respect-existing-conventions` โ don't suggest fights with the codebase's patterns.
|
|
33
|
-
4. **Writes** the chosen
|
|
34
|
-
5. **
|
|
35
|
+
4. **Writes** the chosen specimens to `.claude/skills/<name>/SKILL.md` (Claude Code auto-loads them in the GitHub Action).
|
|
36
|
+
5. **Drafts the field kit** at `.github/workflows/clud-bug-review.yml` with your project description filled in and the right permissions/tool allowlist for `gh pr comment` to actually post.
|
|
35
37
|
|
|
36
38
|
## CLI options
|
|
37
39
|
|
|
@@ -44,6 +46,70 @@ npx clud-bug init [options]
|
|
|
44
46
|
--help,-h Show help.
|
|
45
47
|
```
|
|
46
48
|
|
|
49
|
+
## Staying up to date
|
|
50
|
+
|
|
51
|
+
`clud-bug init` ships a third workflow: `clud-bug-self-update.yml`. Once a week (Mondays 12:00 UTC), it checks npm for a newer `clud-bug` version. If one exists, it runs `clud-bug update` and opens a PR titled `๐ Clud Bug self-update: vX.Y.Z โ vA.B.C`. Custom and skills.sh-installed specimens are never touched โ only baseline specimens and the workflow templates get refreshed.
|
|
52
|
+
|
|
53
|
+
You can also run the update manually:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
clud-bug update
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
To pin a specific version and stop receiving update PRs, add `pinVersion` to `.claude/skills/.clud-bug.json`:
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
{ "pinVersion": "0.3.0", ... }
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Auditing the whole repo
|
|
66
|
+
|
|
67
|
+
PR reviews catch issues entering. Audits catch issues that already crossed the line.
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
clud-bug audit # walk every tracked file
|
|
71
|
+
clud-bug audit --changed-in 7d # only files touched in the last 7 days
|
|
72
|
+
clud-bug audit --since 2026-01-01 # only files touched since a date
|
|
73
|
+
clud-bug audit --scope 'src/**/*.ts' # narrow by glob (repeatable)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The CLI prepares an `audits/YYYY-MM-DD.md` stub. For findings, `clud-bug init` also installed `.github/workflows/clud-bug-audit.yml` โ go to **Actions โ Clud Bug ๐ Audit โ Run workflow**. Clud Bug walks the manifest, appends findings to the same file, opens a PR you can read, act on, then merge or close.
|
|
77
|
+
|
|
78
|
+
The workflow ships with `workflow_dispatch` only (manual). The cron is in the file, commented โ uncomment for weekly audits.
|
|
79
|
+
|
|
80
|
+
## Strict mode (default since v0.4.0)
|
|
81
|
+
|
|
82
|
+
Clud Bug runs in **strict mode by default** for new installs. The workflow check fails when Clud Bug flags a critical issue (bug, security, performance, missing test coverage) โ green means clean, red means the bot found something to address. Add `clud-bug-review` to your branch protection's required status checks and merging is blocked until findings are addressed.
|
|
83
|
+
|
|
84
|
+
`clud-bug init` writes `{ "strictMode": true }` to `.claude/skills/.clud-bug.json`. To opt out into advisory mode (the bot still reviews; the check stays green regardless of findings), set `strictMode: false`:
|
|
85
|
+
|
|
86
|
+
```json
|
|
87
|
+
{ "strictMode": false, ... }
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The toggle takes effect on PRs opened *after* the new value lands on the base branch (the gate reads the manifest from the base ref so PRs can't disable strict on themselves).
|
|
91
|
+
|
|
92
|
+
**Existing installs upgrading to v0.4.0:** the new default only fires on fresh installs (manifests that have never been touched by `init` or `update`). Existing repos โ including v0.3.x advisory installs that never set `strictMode` โ keep their prior behavior on re-init. To enable strict mode in an existing repo, add `"strictMode": true` to `.claude/skills/.clud-bug.json` manually.
|
|
93
|
+
|
|
94
|
+
## Bot-authored PRs (Dependabot, Renovate, fork PRs)
|
|
95
|
+
|
|
96
|
+
GitHub deliberately doesn't pass repository secrets to workflows triggered by bot-authored PRs (`dependabot[bot]`, `renovate[bot]`) or PRs from forks. The action can't authenticate against Anthropic, so Clud Bug can't review.
|
|
97
|
+
|
|
98
|
+
Rather than failing red (wrong signal), the workflow detects this case, posts a one-line advisory comment to the PR explaining the skip, and exits 0. The check stays green; the comment makes the skip visible. Reviews are your responsibility on those PRs.
|
|
99
|
+
|
|
100
|
+
To enable real reviews on Dependabot PRs, [add ANTHROPIC_API_KEY to Dependabot's secret scope](https://docs.github.com/en/code-security/dependabot/working-with-dependabot/configuring-access-to-private-registries-for-dependabot).
|
|
101
|
+
|
|
102
|
+
## How skills shape reviews
|
|
103
|
+
|
|
104
|
+
Skills aren't background reading material for the bot โ they're rules with authority. The workflow prompt now requires Clud Bug to:
|
|
105
|
+
|
|
106
|
+
1. **Cite the skill by name** when applying its guidance: e.g. `[evidence-based-review]: this claim isn't anchored to a line`.
|
|
107
|
+
2. **End every review with a footer** listing which skills shaped the findings: `Skills referenced: [critical-issues-only, next-best-practices, my-team-rules]`.
|
|
108
|
+
|
|
109
|
+
The footer is your audit trail. If a review's footer is `[none]`, either the bot found nothing relevant in your installed skills (and should explain why), or your skill set isn't covering the kinds of changes you actually ship โ a signal to add or write new specimens.
|
|
110
|
+
|
|
111
|
+
`clud-bug init` warns when it would install only baseline specimens. Pair with at least one project-aware skill from skills.sh, or your own โ that's where the wedge over stock Claude review comes from.
|
|
112
|
+
|
|
47
113
|
## Managing skills
|
|
48
114
|
|
|
49
115
|
After `init`, four commands let you evolve the skill set without re-running the whole setup:
|
|
@@ -92,6 +158,19 @@ If you want clud-bug to review fork PRs too, you have two options:
|
|
|
92
158
|
|
|
93
159
|
clud-bug's generated workflow uses `pull_request` by default. If you understand the trade-offs, edit the trigger yourself.
|
|
94
160
|
|
|
161
|
+
## When you edit the workflow
|
|
162
|
+
|
|
163
|
+
clud-bug uses [`anthropics/claude-code-action`](https://github.com/anthropics/claude-code-action), which **refuses to run when the PR being reviewed modifies the action's own workflow file**. That's a security guard against PRs that try to neuter the reviewer or exfiltrate secrets via prompt injection. If you edit `.github/workflows/clud-bug-review.yml` (or any clud-bug workflow), expect this check to fail with a 401 โ `App token exchange failed: Workflow validation failed`. That's the documented behavior, not a bug. Merge the workflow change in isolation, and subsequent PRs work normally.
|
|
164
|
+
|
|
165
|
+
To make this easier, `clud-bug edit-workflow` packages the workflow change into a clean PR for you:
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
# Edit .github/workflows/clud-bug-*.yml as you like, then:
|
|
169
|
+
clud-bug edit-workflow
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
The command refuses to run if your working tree has any non-workflow changes โ keeping the PR scoped to just the workflow edit.
|
|
173
|
+
|
|
95
174
|
## Verifying it works
|
|
96
175
|
|
|
97
176
|
After install:
|
package/bin/clud-bug.js
CHANGED
|
@@ -12,40 +12,61 @@ import {
|
|
|
12
12
|
SkillsClient, rankAndCap, writeSkills, writeSkill, loadBaseline,
|
|
13
13
|
readManifest, writeManifest, removeSkill, listInstalled, diffManifest,
|
|
14
14
|
} from '../lib/skills.js';
|
|
15
|
+
import { computeAuditFileSet, renderAuditHeader } from '../lib/audit.js';
|
|
16
|
+
import { runUpdate } from '../lib/update.js';
|
|
17
|
+
import { getPendingWorkflowEdits, makeBranchName, git as gitCmd } from '../lib/edit-workflow.js';
|
|
15
18
|
|
|
16
19
|
const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
17
20
|
const TEMPLATES = join(PKG_ROOT, 'templates');
|
|
18
21
|
const BASELINE_DIR = join(TEMPLATES, 'skills', 'baseline');
|
|
19
22
|
|
|
20
23
|
function parseArgs(argv) {
|
|
21
|
-
const args = {
|
|
22
|
-
|
|
24
|
+
const args = {
|
|
25
|
+
_: [], offline: false, acceptAll: false, commit: false, help: false, version: false,
|
|
26
|
+
since: null, changedIn: null, scopes: [], out: null,
|
|
27
|
+
};
|
|
28
|
+
for (let i = 0; i < argv.length; i++) {
|
|
29
|
+
const a = argv[i];
|
|
23
30
|
if (a === '--offline') args.offline = true;
|
|
24
31
|
else if (a === '--accept-all' || a === '-y') args.acceptAll = true;
|
|
25
32
|
else if (a === '--commit') args.commit = true;
|
|
26
33
|
else if (a === '--help' || a === '-h') args.help = true;
|
|
27
34
|
else if (a === '--version' || a === '-v') args.version = true;
|
|
35
|
+
else if (a === '--since') args.since = argv[++i];
|
|
36
|
+
else if (a === '--changed-in') args.changedIn = argv[++i];
|
|
37
|
+
else if (a === '--scope') args.scopes.push(argv[++i]);
|
|
38
|
+
else if (a === '--out') args.out = argv[++i];
|
|
28
39
|
else args._.push(a);
|
|
29
40
|
}
|
|
30
41
|
return args;
|
|
31
42
|
}
|
|
32
43
|
|
|
33
|
-
const HELP = `clud-bug โ
|
|
44
|
+
const HELP = `clud-bug ๐ โ a field guide to specimens crawling your code
|
|
34
45
|
|
|
35
46
|
Usage:
|
|
36
47
|
npx clud-bug <command> [options]
|
|
37
48
|
|
|
38
49
|
Commands:
|
|
39
|
-
init
|
|
40
|
-
list Show
|
|
41
|
-
add <source/name>
|
|
42
|
-
remove <slug>
|
|
43
|
-
refresh Re-
|
|
50
|
+
init Open field season: survey the repo, pin baseline specimens, write the workflows.
|
|
51
|
+
list Show your collection (baseline / from skills.sh / custom).
|
|
52
|
+
add <source/name> Pin one new specimen from skills.sh (e.g. vercel-labs/skills/next-best-practices).
|
|
53
|
+
remove <slug> Unpin a clud-bug-managed specimen (refuses to touch your custom ones).
|
|
54
|
+
refresh Re-survey, diff against your collection, prompt to update.
|
|
55
|
+
audit Walk the whole habitat (or a recent slice) and prepare a report stub.
|
|
56
|
+
Use --since / --changed-in / --scope to narrow.
|
|
57
|
+
update Re-render workflows + refresh baseline specimens to the latest shipped
|
|
58
|
+
templates. Custom and skills.sh-installed specimens left alone.
|
|
59
|
+
edit-workflow Helper for editing .github/workflows/clud-bug-*.yml in an isolated
|
|
60
|
+
PR (the action refuses to review PRs that modify its own workflow).
|
|
44
61
|
|
|
45
62
|
Options:
|
|
46
|
-
--offline Skip skills.sh; only
|
|
47
|
-
--accept-all,-y Accept
|
|
48
|
-
--commit git add + commit the generated
|
|
63
|
+
--offline Skip skills.sh; pin only the bundled baseline specimens.
|
|
64
|
+
--accept-all,-y Accept the recommended specimens without prompting.
|
|
65
|
+
--commit git add + commit the generated kit when done (init only).
|
|
66
|
+
--since <date> Audit only files changed in commits after <date> (git date string).
|
|
67
|
+
--changed-in <dur> Audit only files changed in the past <dur>: 7d, 2w, 1mo, 1y. (audit only)
|
|
68
|
+
--scope <glob> Limit audit to files matching <glob>; repeatable. (audit only)
|
|
69
|
+
--out <path> Where to write the audit stub. Default: audits/YYYY-MM-DD.md
|
|
49
70
|
--help,-h Show this help.
|
|
50
71
|
--version,-v Show version.
|
|
51
72
|
`;
|
|
@@ -67,6 +88,9 @@ async function main() {
|
|
|
67
88
|
case 'add': return runAdd(args);
|
|
68
89
|
case 'remove': return runRemove(args);
|
|
69
90
|
case 'refresh': return runRefresh(args);
|
|
91
|
+
case 'audit': return runAudit(args);
|
|
92
|
+
case 'update': return runUpdateCmd(args);
|
|
93
|
+
case 'edit-workflow': return runEditWorkflow(args);
|
|
70
94
|
default:
|
|
71
95
|
process.stderr.write(`Unknown command: ${cmd || '(none)'}\n\n${HELP}`);
|
|
72
96
|
process.exit(2);
|
|
@@ -75,15 +99,15 @@ async function main() {
|
|
|
75
99
|
|
|
76
100
|
async function runInit(args) {
|
|
77
101
|
const cwd = process.cwd();
|
|
78
|
-
log(`๐
|
|
102
|
+
log(`๐ Field season opens in ${cwd}.`);
|
|
79
103
|
|
|
80
|
-
log('
|
|
104
|
+
log(' surveying habitat...');
|
|
81
105
|
const signals = await detect(cwd);
|
|
82
106
|
log(` primary language: ${signals.primaryLanguage || '(unknown)'}`);
|
|
83
107
|
log(` search terms: ${signals.searchTerms.join(', ') || '(none)'}`);
|
|
84
108
|
|
|
85
109
|
const baseline = await loadBaseline(BASELINE_DIR);
|
|
86
|
-
log(` baseline
|
|
110
|
+
log(` baseline kit: ${baseline.length} specimens`);
|
|
87
111
|
|
|
88
112
|
let curated = [];
|
|
89
113
|
let searched = [];
|
|
@@ -92,7 +116,7 @@ async function runInit(args) {
|
|
|
92
116
|
} else {
|
|
93
117
|
const client = new SkillsClient();
|
|
94
118
|
try {
|
|
95
|
-
log('
|
|
119
|
+
log(' consulting skills.sh...');
|
|
96
120
|
[curated, searched] = await Promise.all([
|
|
97
121
|
client.curated().catch(err => { warn(`curated query failed: ${err.message}`); return []; }),
|
|
98
122
|
client.search(signals.searchTerms).catch(err => { warn(`search failed: ${err.message}`); return []; }),
|
|
@@ -105,7 +129,7 @@ async function runInit(args) {
|
|
|
105
129
|
|
|
106
130
|
const recommended = rankAndCap(curated, searched, baseline);
|
|
107
131
|
log('');
|
|
108
|
-
log('
|
|
132
|
+
log('Specimens to pin:');
|
|
109
133
|
for (const s of recommended) {
|
|
110
134
|
const tag = s.kind === 'baseline' ? '[baseline]' : `[${s.source}]`;
|
|
111
135
|
log(` โข ${s.name} ${tag}`);
|
|
@@ -118,12 +142,20 @@ async function runInit(args) {
|
|
|
118
142
|
chosen = await promptForSkills(recommended);
|
|
119
143
|
}
|
|
120
144
|
|
|
121
|
-
log('
|
|
145
|
+
log(' pinning specimens to .claude/skills/...');
|
|
122
146
|
const client = new SkillsClient();
|
|
123
147
|
const written = await writeSkills(join(cwd, '.claude', 'skills'), chosen, client);
|
|
124
|
-
log(`
|
|
148
|
+
log(` pinned ${written.length} specimens`);
|
|
149
|
+
|
|
150
|
+
// Empty-skills warning: clud-bug shines when paired with project-specific
|
|
151
|
+
// skills. Reviews that load only the three baselines are functional but
|
|
152
|
+
// generic; flag this so users notice.
|
|
153
|
+
const remoteCount = written.filter((w) => w.kind !== 'baseline').length;
|
|
154
|
+
if (remoteCount === 0) {
|
|
155
|
+
warn('Only baseline specimens pinned. Add project-specific skills via `clud-bug add vercel-labs/skills/<name>` or drop your own `.claude/skills/<name>/SKILL.md`.');
|
|
156
|
+
}
|
|
125
157
|
|
|
126
|
-
log('
|
|
158
|
+
log(' drafting field kit...');
|
|
127
159
|
const tmplName = pickTemplate(signals.languages);
|
|
128
160
|
const tmplPath = join(TEMPLATES, tmplName);
|
|
129
161
|
const workflow = await renderFile(tmplPath, {
|
|
@@ -135,24 +167,61 @@ async function runInit(args) {
|
|
|
135
167
|
await writeFile(workflowPath, workflow);
|
|
136
168
|
log(` wrote ${rel(cwd, workflowPath)}`);
|
|
137
169
|
|
|
170
|
+
// Install the audit workflow alongside the per-PR review one.
|
|
171
|
+
// Manual-trigger by default; users opt into the cron by uncommenting.
|
|
172
|
+
const auditTmpl = await readFile(join(TEMPLATES, 'audit.yml.tmpl'), 'utf8');
|
|
173
|
+
const auditPath = join(cwd, '.github', 'workflows', 'clud-bug-audit.yml');
|
|
174
|
+
await writeFile(auditPath, auditTmpl);
|
|
175
|
+
log(` wrote ${rel(cwd, auditPath)}`);
|
|
176
|
+
|
|
177
|
+
// Install the self-update workflow. Cron weekly Mondays 12:00 UTC; opens
|
|
178
|
+
// a PR if a newer clud-bug version is published. Disable by deleting the
|
|
179
|
+
// file or pinning via .claude/skills/.clud-bug.json.
|
|
180
|
+
const selfUpdateTmpl = await readFile(join(TEMPLATES, 'self-update.yml.tmpl'), 'utf8');
|
|
181
|
+
const selfUpdatePath = join(cwd, '.github', 'workflows', 'clud-bug-self-update.yml');
|
|
182
|
+
await writeFile(selfUpdatePath, selfUpdateTmpl);
|
|
183
|
+
log(` wrote ${rel(cwd, selfUpdatePath)}`);
|
|
184
|
+
|
|
185
|
+
// Stamp the manifest. Sets strictMode: true ONLY on fresh installs โ
|
|
186
|
+
// a manifest that's never been touched by clud-bug init/update has no
|
|
187
|
+
// lastUpdate field. Existing v0.3.x advisory installs (where strictMode
|
|
188
|
+
// was never written and so == undefined) keep their advisory behavior
|
|
189
|
+
// because lastUpdate IS set; the strictMode default only fires on truly
|
|
190
|
+
// fresh inits. Users opt out by setting strictMode: false.
|
|
191
|
+
const skillsDirPath = join(cwd, '.claude', 'skills');
|
|
192
|
+
const manifest = await readManifest(skillsDirPath);
|
|
193
|
+
const isFreshInstall = manifest.lastUpdate === undefined;
|
|
194
|
+
manifest.lastUpdateVersion = await readPkgVersion();
|
|
195
|
+
manifest.lastUpdate = new Date().toISOString();
|
|
196
|
+
if (isFreshInstall && manifest.strictMode === undefined) {
|
|
197
|
+
manifest.strictMode = true;
|
|
198
|
+
}
|
|
199
|
+
await writeManifest(skillsDirPath, manifest);
|
|
200
|
+
|
|
138
201
|
if (args.commit) {
|
|
139
202
|
log(' committing...');
|
|
140
|
-
spawnSync('git', ['add', '.claude', '.github/workflows/clud-bug-review.yml'], { cwd, stdio: 'inherit' });
|
|
141
|
-
spawnSync('git', ['commit', '-m', 'Add clud-bug
|
|
203
|
+
spawnSync('git', ['add', '.claude', '.github/workflows/clud-bug-review.yml', '.github/workflows/clud-bug-audit.yml', '.github/workflows/clud-bug-self-update.yml'], { cwd, stdio: 'inherit' });
|
|
204
|
+
spawnSync('git', ['commit', '-m', 'Add clud-bug ๐ โ a field guide to specimens crawling your code'], { cwd, stdio: 'inherit' });
|
|
142
205
|
}
|
|
143
206
|
|
|
144
207
|
log('');
|
|
145
|
-
log('
|
|
208
|
+
log('Field kit assembled. Next:');
|
|
146
209
|
log(' 1. Set ANTHROPIC_API_KEY in your repo secrets:');
|
|
147
210
|
log(' Settings โ Secrets and variables โ Actions โ New repository secret');
|
|
148
211
|
if (!args.commit) {
|
|
149
|
-
log(' 2. git add .claude .github/workflows/clud-bug
|
|
150
|
-
log(' 3. Open a PR โ
|
|
212
|
+
log(' 2. git add .claude .github/workflows/clud-bug-*.yml && git commit && git push');
|
|
213
|
+
log(' 3. Open a PR โ the naturalist arrives within ~2 minutes.');
|
|
151
214
|
} else {
|
|
152
|
-
log(' 2. git push, then open a PR โ
|
|
215
|
+
log(' 2. git push, then open a PR โ the naturalist arrives within ~2 minutes.');
|
|
153
216
|
}
|
|
154
217
|
log('');
|
|
155
|
-
log('Drop your own .claude/skills/<name>/SKILL.md files anytime โ they
|
|
218
|
+
log('Drop your own .claude/skills/<name>/SKILL.md files anytime โ they get pinned automatically.');
|
|
219
|
+
log('For a whole-repo walk: Actions tab โ Clud Bug ๐ Audit โ Run workflow.');
|
|
220
|
+
log('Self-update is on (weekly Mondays 12:00 UTC). Pin via "pinVersion" in .claude/skills/.clud-bug.json.');
|
|
221
|
+
log('');
|
|
222
|
+
log('Strict mode is ON by default (clud-bug-review fails the check on critical findings).');
|
|
223
|
+
log(' โข Add `clud-bug-review` to your branch protection required checks for full enforcement.');
|
|
224
|
+
log(' โข Opt out by setting "strictMode": false in .claude/skills/.clud-bug.json.');
|
|
156
225
|
}
|
|
157
226
|
|
|
158
227
|
async function promptForSkills(recommended) {
|
|
@@ -182,13 +251,13 @@ async function runList(_args) {
|
|
|
182
251
|
const groups = await listInstalled(skillsDir);
|
|
183
252
|
const total = groups.baseline.length + groups.remote.length + groups.custom.length;
|
|
184
253
|
if (total === 0) {
|
|
185
|
-
log('
|
|
254
|
+
log('Empty collection. Run `clud-bug init` to open field season.');
|
|
186
255
|
return;
|
|
187
256
|
}
|
|
188
|
-
log(`๐ ${total}
|
|
257
|
+
log(`๐ ${total} specimen${total === 1 ? '' : 's'} pinned in .claude/skills/`);
|
|
189
258
|
if (groups.baseline.length) {
|
|
190
259
|
log('');
|
|
191
|
-
log('Baseline (always
|
|
260
|
+
log('Baseline (always pinned):');
|
|
192
261
|
for (const s of groups.baseline) log(` โข ${s.slug}`);
|
|
193
262
|
}
|
|
194
263
|
if (groups.remote.length) {
|
|
@@ -198,7 +267,7 @@ async function runList(_args) {
|
|
|
198
267
|
}
|
|
199
268
|
if (groups.custom.length) {
|
|
200
269
|
log('');
|
|
201
|
-
log('Custom (
|
|
270
|
+
log('Custom (your own โ never auto-modified):');
|
|
202
271
|
for (const s of groups.custom) {
|
|
203
272
|
log(` โข ${s.slug}${s.description ? ` โ ${s.description}` : ''}`);
|
|
204
273
|
}
|
|
@@ -220,9 +289,12 @@ async function runAdd(args) {
|
|
|
220
289
|
const client = new SkillsClient();
|
|
221
290
|
const entry = await writeSkill(skillsDir, { source, name, kind: 'remote' }, client);
|
|
222
291
|
const manifest = await readManifest(skillsDir);
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
292
|
+
// Mutate in place so caller-set fields on the manifest (pinVersion,
|
|
293
|
+
// lastUpdate, lastUpdateVersion) survive the add. Building a fresh
|
|
294
|
+
// {version, installed} object would silently drop them.
|
|
295
|
+
manifest.installed = [...manifest.installed.filter((e) => e.slug !== entry.slug), entry];
|
|
296
|
+
await writeManifest(skillsDir, manifest);
|
|
297
|
+
log(` โ pinned ${entry.slug} โ .claude/skills/${entry.slug}/SKILL.md`);
|
|
226
298
|
log(' Commit + push to apply on the next PR.');
|
|
227
299
|
}
|
|
228
300
|
|
|
@@ -234,7 +306,7 @@ async function runRemove(args) {
|
|
|
234
306
|
}
|
|
235
307
|
const skillsDir = join(process.cwd(), '.claude', 'skills');
|
|
236
308
|
const entry = await removeSkill(skillsDir, slug);
|
|
237
|
-
log(` โ
|
|
309
|
+
log(` โ unpinned ${entry.slug}${entry.kind === 'baseline' ? ' (baseline โ returns on next init)' : ''}`);
|
|
238
310
|
}
|
|
239
311
|
|
|
240
312
|
async function runRefresh(args) {
|
|
@@ -242,11 +314,11 @@ async function runRefresh(args) {
|
|
|
242
314
|
const skillsDir = join(cwd, '.claude', 'skills');
|
|
243
315
|
const manifest = await readManifest(skillsDir);
|
|
244
316
|
if (manifest.installed.length === 0) {
|
|
245
|
-
log('No clud-bug-managed
|
|
317
|
+
log('No clud-bug-managed specimens found. Run `clud-bug init` first.');
|
|
246
318
|
return;
|
|
247
319
|
}
|
|
248
320
|
|
|
249
|
-
log('
|
|
321
|
+
log(' re-surveying habitat...');
|
|
250
322
|
const signals = await detect(cwd);
|
|
251
323
|
log(` primary language: ${signals.primaryLanguage || '(unknown)'}`);
|
|
252
324
|
log(` search terms: ${signals.searchTerms.join(', ') || '(none)'}`);
|
|
@@ -286,7 +358,7 @@ async function runRefresh(args) {
|
|
|
286
358
|
|
|
287
359
|
if (diff.add.length === 0 && diff.remove.length === 0) {
|
|
288
360
|
log('');
|
|
289
|
-
log('
|
|
361
|
+
log('Collection in sync with skills.sh โ nothing to update.');
|
|
290
362
|
return;
|
|
291
363
|
}
|
|
292
364
|
|
|
@@ -307,7 +379,134 @@ async function runRefresh(args) {
|
|
|
307
379
|
const client = new SkillsClient();
|
|
308
380
|
if (diff.add.length) await writeSkills(skillsDir, diff.add, client);
|
|
309
381
|
for (const entry of diff.remove) await removeSkill(skillsDir, entry.slug);
|
|
310
|
-
log(' โ
|
|
382
|
+
log(' โ collection updated. Commit + push to apply on the next PR.');
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async function runEditWorkflow(_args) {
|
|
386
|
+
const cwd = process.cwd();
|
|
387
|
+
|
|
388
|
+
// Validate: must have pending changes, all scoped to clud-bug workflow files.
|
|
389
|
+
let pending;
|
|
390
|
+
try {
|
|
391
|
+
pending = getPendingWorkflowEdits(cwd);
|
|
392
|
+
} catch (err) {
|
|
393
|
+
process.stderr.write(`clud-bug edit-workflow: ${err.message}\n`);
|
|
394
|
+
process.exit(2);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (pending.files.length === 0) {
|
|
398
|
+
log('Nothing to commit. Edit your .github/workflows/clud-bug-*.yml file(s) first, then re-run.');
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
if (!pending.allWorkflow) {
|
|
402
|
+
process.stderr.write(`clud-bug edit-workflow: working tree contains non-workflow changes:\n`);
|
|
403
|
+
for (const f of pending.nonWorkflow) process.stderr.write(` ${f}\n`);
|
|
404
|
+
process.stderr.write(`\nThis command is for isolated workflow-only PRs. Stash or commit the\nnon-workflow changes elsewhere first, then re-run.\n`);
|
|
405
|
+
process.exit(2);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
log('๐ Preparing an isolated PR for your workflow edit.');
|
|
409
|
+
const branch = makeBranchName();
|
|
410
|
+
log(` branch: ${branch} (rooted at origin/main)`);
|
|
411
|
+
for (const f of pending.files) log(` โข ${f}`);
|
|
412
|
+
|
|
413
|
+
// Stash the pending workflow changes, branch from origin/main explicitly
|
|
414
|
+
// (NOT from HEAD โ if the user is on a feature branch with unrelated
|
|
415
|
+
// commits, those would otherwise leak into the "isolated" PR), then
|
|
416
|
+
// restore the changes onto the new branch and commit.
|
|
417
|
+
gitCmd(cwd, ['stash', 'push', '--include-untracked', '-m', 'clud-bug edit-workflow']);
|
|
418
|
+
try {
|
|
419
|
+
gitCmd(cwd, ['fetch', 'origin', 'main', '--depth=1']);
|
|
420
|
+
gitCmd(cwd, ['checkout', '-b', branch, 'origin/main']);
|
|
421
|
+
} catch (err) {
|
|
422
|
+
// Restore the user's stash before bubbling up.
|
|
423
|
+
gitCmd(cwd, ['stash', 'pop'], { allowFail: true });
|
|
424
|
+
throw err;
|
|
425
|
+
}
|
|
426
|
+
const popped = gitCmd(cwd, ['stash', 'pop'], { allowFail: true });
|
|
427
|
+
if (!popped.ok) {
|
|
428
|
+
process.stderr.write(`clud-bug edit-workflow: stash pop conflicted on origin/main โ your edits are still in 'git stash'. Resolve manually:\n git stash pop\n`);
|
|
429
|
+
process.exit(1);
|
|
430
|
+
}
|
|
431
|
+
gitCmd(cwd, ['add', ...pending.files]);
|
|
432
|
+
gitCmd(cwd, ['commit', '-m', 'Edit clud-bug workflow']);
|
|
433
|
+
gitCmd(cwd, ['push', '-u', 'origin', branch]);
|
|
434
|
+
|
|
435
|
+
log('');
|
|
436
|
+
log('Done. Open the PR:');
|
|
437
|
+
log(` gh pr create --title "Edit clud-bug workflow" --body "Workflow tweak. The clud-bug-review check on this PR will fail with a 401 (Anthropic's self-protection against PRs that modify the reviewer's own workflow); merge once and subsequent PRs work normally."`);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async function runUpdateCmd(_args) {
|
|
441
|
+
const cwd = process.cwd();
|
|
442
|
+
const ourVersion = await readPkgVersion();
|
|
443
|
+
log(`๐ Refreshing the field kit (${ourVersion}).`);
|
|
444
|
+
|
|
445
|
+
const result = await runUpdate({
|
|
446
|
+
cwd,
|
|
447
|
+
templatesDir: TEMPLATES,
|
|
448
|
+
baselineDir: BASELINE_DIR,
|
|
449
|
+
ourVersion,
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
if (result.missing === 'init') {
|
|
453
|
+
log(' No clud-bug installation detected. Run `clud-bug init` first.');
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (result.changed.length === 0) {
|
|
458
|
+
log(' Already current. Nothing to update.');
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
log(` โ Updated ${result.changed.length} file${result.changed.length === 1 ? '' : 's'}:`);
|
|
463
|
+
for (const c of result.changed) log(` โข ${rel(cwd, c.path)} (${c.label})`);
|
|
464
|
+
if (result.unchanged.length > 0) {
|
|
465
|
+
log(` ${result.unchanged.length} file${result.unchanged.length === 1 ? ' was' : 's were'} already current.`);
|
|
466
|
+
}
|
|
467
|
+
log('');
|
|
468
|
+
log('Commit + push to apply the refreshed kit on the next PR.');
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async function runAudit(args) {
|
|
472
|
+
const cwd = process.cwd();
|
|
473
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
474
|
+
|
|
475
|
+
let scopeLabel;
|
|
476
|
+
if (args.since) scopeLabel = `commits since ${args.since}`;
|
|
477
|
+
else if (args.changedIn) scopeLabel = `files changed in the past ${args.changedIn}`;
|
|
478
|
+
else if (args.scopes.length) scopeLabel = `glob ${args.scopes.join(', ')}`;
|
|
479
|
+
else scopeLabel = 'all tracked files';
|
|
480
|
+
|
|
481
|
+
log(`๐ Audit walk in ${cwd}.`);
|
|
482
|
+
log(` scope: ${scopeLabel}`);
|
|
483
|
+
|
|
484
|
+
let files;
|
|
485
|
+
try {
|
|
486
|
+
files = computeAuditFileSet({
|
|
487
|
+
cwd,
|
|
488
|
+
since: args.since,
|
|
489
|
+
changedIn: args.changedIn,
|
|
490
|
+
scopes: args.scopes,
|
|
491
|
+
});
|
|
492
|
+
} catch (err) {
|
|
493
|
+
process.stderr.write(`clud-bug audit: ${err.message}\n`);
|
|
494
|
+
process.exit(2);
|
|
495
|
+
}
|
|
496
|
+
log(` surveyed: ${files.length} file${files.length === 1 ? '' : 's'}`);
|
|
497
|
+
|
|
498
|
+
if (files.length === 0) {
|
|
499
|
+
log(' Nothing in scope. Try widening --scope or --changed-in.');
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const outPath = args.out || join(cwd, 'audits', `${date}.md`);
|
|
504
|
+
await mkdir(dirname(outPath), { recursive: true });
|
|
505
|
+
await writeFile(outPath, renderAuditHeader({ date, scopeLabel, files }));
|
|
506
|
+
log(` โ wrote stub: ${rel(cwd, outPath)}`);
|
|
507
|
+
log('');
|
|
508
|
+
log('Stub is empty findings โ populated by the GitHub Action.');
|
|
509
|
+
log('Run locally without the workflow if you want โ Clud Bug review needs the action runner + ANTHROPIC_API_KEY.');
|
|
311
510
|
}
|
|
312
511
|
|
|
313
512
|
function rel(from, to) {
|
package/lib/audit.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
// Convert a duration like "7d", "2w", "1mo", "3mo", "1y" to a git --since arg.
|
|
4
|
+
// Returns null if the input is empty/undefined; throws on malformed input.
|
|
5
|
+
export function durationToGitSince(input) {
|
|
6
|
+
if (!input) return null;
|
|
7
|
+
const m = String(input).trim().match(/^(\d+)\s*(d|w|mo|m|y)$/i);
|
|
8
|
+
if (!m) {
|
|
9
|
+
throw new Error(`Unrecognized duration "${input}". Examples: 7d, 2w, 1mo, 1y.`);
|
|
10
|
+
}
|
|
11
|
+
const n = Number(m[1]);
|
|
12
|
+
const unit = m[2].toLowerCase();
|
|
13
|
+
const map = { d: 'day', w: 'week', mo: 'month', m: 'month', y: 'year' };
|
|
14
|
+
return `${n} ${map[unit]}${n === 1 ? '' : 's'} ago`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Run a git command, return stdout lines split by \n. Throws on non-zero exit
|
|
18
|
+
// unless { allowFail: true }, in which case returns [].
|
|
19
|
+
export function gitLines(args, opts = {}) {
|
|
20
|
+
const r = spawnSync('git', args, { encoding: 'utf8', cwd: opts.cwd || process.cwd() });
|
|
21
|
+
if (r.status !== 0) {
|
|
22
|
+
if (opts.allowFail) return [];
|
|
23
|
+
throw new Error(`git ${args.join(' ')} failed (${r.status}): ${r.stderr.trim()}`);
|
|
24
|
+
}
|
|
25
|
+
return r.stdout.split('\n').filter(Boolean);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Returns the file set the audit should consider, in repo-relative paths.
|
|
29
|
+
// Filters: optional --since (git date), optional --changed-in (duration string),
|
|
30
|
+
// optional --scope globs (one or more, repeatable).
|
|
31
|
+
export function computeAuditFileSet({ since, changedIn, scopes = [], cwd } = {}) {
|
|
32
|
+
const sinceArg = since || (changedIn ? durationToGitSince(changedIn) : null);
|
|
33
|
+
|
|
34
|
+
let files;
|
|
35
|
+
if (sinceArg) {
|
|
36
|
+
// Files touched in any commit within the window.
|
|
37
|
+
files = [...new Set(gitLines(['log', `--since=${sinceArg}`, '--name-only', '--pretty=format:'], { cwd }))];
|
|
38
|
+
// --diff-filter at the log level only excludes the delete commit; a file
|
|
39
|
+
// that was modified (and emitted by --name-only) and *later* deleted will
|
|
40
|
+
// still appear here. Intersect with the current tracked-file set so the
|
|
41
|
+
// manifest only contains paths we can actually read.
|
|
42
|
+
const tracked = new Set(gitLines(['ls-files'], { cwd }));
|
|
43
|
+
files = files.filter((f) => tracked.has(f));
|
|
44
|
+
} else {
|
|
45
|
+
files = gitLines(['ls-files'], { cwd });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (scopes.length) {
|
|
49
|
+
const matchers = scopes.map(globToRegex);
|
|
50
|
+
files = files.filter((f) => matchers.some((rx) => rx.test(f)));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Skip vendor / build artifacts that bloat audits without adding signal.
|
|
54
|
+
const skip = /(^|\/)(node_modules|dist|build|out|\.next|\.vercel|coverage|target|__pycache__)\//;
|
|
55
|
+
return files.filter((f) => !skip.test(f)).sort();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Minimal glob โ RegExp. Supports **, *, ?. Anchors at both ends so that
|
|
59
|
+
// 'src/**/*.ts' matches 'src/lib/foo.ts' but not 'app/src/lib/foo.ts'.
|
|
60
|
+
function globToRegex(glob) {
|
|
61
|
+
let rx = '';
|
|
62
|
+
let i = 0;
|
|
63
|
+
while (i < glob.length) {
|
|
64
|
+
const ch = glob[i];
|
|
65
|
+
if (ch === '*' && glob[i + 1] === '*') {
|
|
66
|
+
// ** = any depth (including zero) of path segments
|
|
67
|
+
rx += '.*';
|
|
68
|
+
i += 2;
|
|
69
|
+
// consume an optional trailing slash so 'src/**/*.ts' works cleanly
|
|
70
|
+
if (glob[i] === '/') i++;
|
|
71
|
+
} else if (ch === '*') {
|
|
72
|
+
rx += '[^/]*';
|
|
73
|
+
i++;
|
|
74
|
+
} else if (ch === '?') {
|
|
75
|
+
rx += '[^/]';
|
|
76
|
+
i++;
|
|
77
|
+
} else if (/[.+^$|()\[\]{}\\]/.test(ch)) {
|
|
78
|
+
rx += '\\' + ch;
|
|
79
|
+
i++;
|
|
80
|
+
} else {
|
|
81
|
+
rx += ch;
|
|
82
|
+
i++;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return new RegExp(`^${rx}$`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Render the audit report's initial markdown body. The Action's Claude run
|
|
89
|
+
// will append findings under a "## Findings" section after this header.
|
|
90
|
+
export function renderAuditHeader({ date, scopeLabel, files }) {
|
|
91
|
+
const head = `# ๐ Clud Bug audit โ ${date}
|
|
92
|
+
|
|
93
|
+
A scheduled walk through the habitat. Scope: ${scopeLabel}.
|
|
94
|
+
Files surveyed: **${files.length}**.
|
|
95
|
+
|
|
96
|
+
<details>
|
|
97
|
+
<summary>File manifest (${files.length})</summary>
|
|
98
|
+
|
|
99
|
+
\`\`\`
|
|
100
|
+
${files.join('\n')}
|
|
101
|
+
\`\`\`
|
|
102
|
+
|
|
103
|
+
</details>
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
## Findings
|
|
108
|
+
|
|
109
|
+
`;
|
|
110
|
+
return head;
|
|
111
|
+
}
|