clud-bug 0.2.0 โ 0.5.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/README.md +88 -9
- package/bin/clud-bug.js +243 -38
- package/lib/audit.js +111 -0
- package/lib/edit-workflow.js +47 -0
- package/lib/skills.js +111 -3
- package/lib/update.js +99 -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,21 @@ 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
|
-
|
|
110
|
+
const fromAgentSkills = baseline.filter((s) => s._source === 'agent-skills').length;
|
|
111
|
+
const sourceLabel = baseline.length === 0
|
|
112
|
+
? ''
|
|
113
|
+
: fromAgentSkills === baseline.length ? ' (from thrillmot/agent-skills)'
|
|
114
|
+
: fromAgentSkills === 0 ? ' (bundled fallback)'
|
|
115
|
+
: ` (${fromAgentSkills} from agent-skills, ${baseline.length - fromAgentSkills} bundled)`;
|
|
116
|
+
log(` baseline kit: ${baseline.length} specimens${sourceLabel}`);
|
|
87
117
|
|
|
88
118
|
let curated = [];
|
|
89
119
|
let searched = [];
|
|
@@ -92,7 +122,7 @@ async function runInit(args) {
|
|
|
92
122
|
} else {
|
|
93
123
|
const client = new SkillsClient();
|
|
94
124
|
try {
|
|
95
|
-
log('
|
|
125
|
+
log(' consulting skills.sh...');
|
|
96
126
|
[curated, searched] = await Promise.all([
|
|
97
127
|
client.curated().catch(err => { warn(`curated query failed: ${err.message}`); return []; }),
|
|
98
128
|
client.search(signals.searchTerms).catch(err => { warn(`search failed: ${err.message}`); return []; }),
|
|
@@ -105,7 +135,7 @@ async function runInit(args) {
|
|
|
105
135
|
|
|
106
136
|
const recommended = rankAndCap(curated, searched, baseline);
|
|
107
137
|
log('');
|
|
108
|
-
log('
|
|
138
|
+
log('Specimens to pin:');
|
|
109
139
|
for (const s of recommended) {
|
|
110
140
|
const tag = s.kind === 'baseline' ? '[baseline]' : `[${s.source}]`;
|
|
111
141
|
log(` โข ${s.name} ${tag}`);
|
|
@@ -118,12 +148,20 @@ async function runInit(args) {
|
|
|
118
148
|
chosen = await promptForSkills(recommended);
|
|
119
149
|
}
|
|
120
150
|
|
|
121
|
-
log('
|
|
151
|
+
log(' pinning specimens to .claude/skills/...');
|
|
122
152
|
const client = new SkillsClient();
|
|
123
153
|
const written = await writeSkills(join(cwd, '.claude', 'skills'), chosen, client);
|
|
124
|
-
log(`
|
|
154
|
+
log(` pinned ${written.length} specimens`);
|
|
155
|
+
|
|
156
|
+
// Empty-skills warning: clud-bug shines when paired with project-specific
|
|
157
|
+
// skills. Reviews that load only the three baselines are functional but
|
|
158
|
+
// generic; flag this so users notice.
|
|
159
|
+
const remoteCount = written.filter((w) => w.kind !== 'baseline').length;
|
|
160
|
+
if (remoteCount === 0) {
|
|
161
|
+
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`.');
|
|
162
|
+
}
|
|
125
163
|
|
|
126
|
-
log('
|
|
164
|
+
log(' drafting field kit...');
|
|
127
165
|
const tmplName = pickTemplate(signals.languages);
|
|
128
166
|
const tmplPath = join(TEMPLATES, tmplName);
|
|
129
167
|
const workflow = await renderFile(tmplPath, {
|
|
@@ -135,24 +173,61 @@ async function runInit(args) {
|
|
|
135
173
|
await writeFile(workflowPath, workflow);
|
|
136
174
|
log(` wrote ${rel(cwd, workflowPath)}`);
|
|
137
175
|
|
|
176
|
+
// Install the audit workflow alongside the per-PR review one.
|
|
177
|
+
// Manual-trigger by default; users opt into the cron by uncommenting.
|
|
178
|
+
const auditTmpl = await readFile(join(TEMPLATES, 'audit.yml.tmpl'), 'utf8');
|
|
179
|
+
const auditPath = join(cwd, '.github', 'workflows', 'clud-bug-audit.yml');
|
|
180
|
+
await writeFile(auditPath, auditTmpl);
|
|
181
|
+
log(` wrote ${rel(cwd, auditPath)}`);
|
|
182
|
+
|
|
183
|
+
// Install the self-update workflow. Cron weekly Mondays 12:00 UTC; opens
|
|
184
|
+
// a PR if a newer clud-bug version is published. Disable by deleting the
|
|
185
|
+
// file or pinning via .claude/skills/.clud-bug.json.
|
|
186
|
+
const selfUpdateTmpl = await readFile(join(TEMPLATES, 'self-update.yml.tmpl'), 'utf8');
|
|
187
|
+
const selfUpdatePath = join(cwd, '.github', 'workflows', 'clud-bug-self-update.yml');
|
|
188
|
+
await writeFile(selfUpdatePath, selfUpdateTmpl);
|
|
189
|
+
log(` wrote ${rel(cwd, selfUpdatePath)}`);
|
|
190
|
+
|
|
191
|
+
// Stamp the manifest. Sets strictMode: true ONLY on fresh installs โ
|
|
192
|
+
// a manifest that's never been touched by clud-bug init/update has no
|
|
193
|
+
// lastUpdate field. Existing v0.3.x advisory installs (where strictMode
|
|
194
|
+
// was never written and so == undefined) keep their advisory behavior
|
|
195
|
+
// because lastUpdate IS set; the strictMode default only fires on truly
|
|
196
|
+
// fresh inits. Users opt out by setting strictMode: false.
|
|
197
|
+
const skillsDirPath = join(cwd, '.claude', 'skills');
|
|
198
|
+
const manifest = await readManifest(skillsDirPath);
|
|
199
|
+
const isFreshInstall = manifest.lastUpdate === undefined;
|
|
200
|
+
manifest.lastUpdateVersion = await readPkgVersion();
|
|
201
|
+
manifest.lastUpdate = new Date().toISOString();
|
|
202
|
+
if (isFreshInstall && manifest.strictMode === undefined) {
|
|
203
|
+
manifest.strictMode = true;
|
|
204
|
+
}
|
|
205
|
+
await writeManifest(skillsDirPath, manifest);
|
|
206
|
+
|
|
138
207
|
if (args.commit) {
|
|
139
208
|
log(' committing...');
|
|
140
|
-
spawnSync('git', ['add', '.claude', '.github/workflows/clud-bug-review.yml'], { cwd, stdio: 'inherit' });
|
|
141
|
-
spawnSync('git', ['commit', '-m', 'Add clud-bug
|
|
209
|
+
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' });
|
|
210
|
+
spawnSync('git', ['commit', '-m', 'Add clud-bug ๐ โ a field guide to specimens crawling your code'], { cwd, stdio: 'inherit' });
|
|
142
211
|
}
|
|
143
212
|
|
|
144
213
|
log('');
|
|
145
|
-
log('
|
|
214
|
+
log('Field kit assembled. Next:');
|
|
146
215
|
log(' 1. Set ANTHROPIC_API_KEY in your repo secrets:');
|
|
147
216
|
log(' Settings โ Secrets and variables โ Actions โ New repository secret');
|
|
148
217
|
if (!args.commit) {
|
|
149
|
-
log(' 2. git add .claude .github/workflows/clud-bug
|
|
150
|
-
log(' 3. Open a PR โ
|
|
218
|
+
log(' 2. git add .claude .github/workflows/clud-bug-*.yml && git commit && git push');
|
|
219
|
+
log(' 3. Open a PR โ the naturalist arrives within ~2 minutes.');
|
|
151
220
|
} else {
|
|
152
|
-
log(' 2. git push, then open a PR โ
|
|
221
|
+
log(' 2. git push, then open a PR โ the naturalist arrives within ~2 minutes.');
|
|
153
222
|
}
|
|
154
223
|
log('');
|
|
155
|
-
log('Drop your own .claude/skills/<name>/SKILL.md files anytime โ they
|
|
224
|
+
log('Drop your own .claude/skills/<name>/SKILL.md files anytime โ they get pinned automatically.');
|
|
225
|
+
log('For a whole-repo walk: Actions tab โ Clud Bug ๐ Audit โ Run workflow.');
|
|
226
|
+
log('Self-update is on (weekly Mondays 12:00 UTC). Pin via "pinVersion" in .claude/skills/.clud-bug.json.');
|
|
227
|
+
log('');
|
|
228
|
+
log('Strict mode is ON by default (clud-bug-review fails the check on critical findings).');
|
|
229
|
+
log(' โข Add `clud-bug-review` to your branch protection required checks for full enforcement.');
|
|
230
|
+
log(' โข Opt out by setting "strictMode": false in .claude/skills/.clud-bug.json.');
|
|
156
231
|
}
|
|
157
232
|
|
|
158
233
|
async function promptForSkills(recommended) {
|
|
@@ -182,13 +257,13 @@ async function runList(_args) {
|
|
|
182
257
|
const groups = await listInstalled(skillsDir);
|
|
183
258
|
const total = groups.baseline.length + groups.remote.length + groups.custom.length;
|
|
184
259
|
if (total === 0) {
|
|
185
|
-
log('
|
|
260
|
+
log('Empty collection. Run `clud-bug init` to open field season.');
|
|
186
261
|
return;
|
|
187
262
|
}
|
|
188
|
-
log(`๐ ${total}
|
|
263
|
+
log(`๐ ${total} specimen${total === 1 ? '' : 's'} pinned in .claude/skills/`);
|
|
189
264
|
if (groups.baseline.length) {
|
|
190
265
|
log('');
|
|
191
|
-
log('Baseline (always
|
|
266
|
+
log('Baseline (always pinned):');
|
|
192
267
|
for (const s of groups.baseline) log(` โข ${s.slug}`);
|
|
193
268
|
}
|
|
194
269
|
if (groups.remote.length) {
|
|
@@ -198,7 +273,7 @@ async function runList(_args) {
|
|
|
198
273
|
}
|
|
199
274
|
if (groups.custom.length) {
|
|
200
275
|
log('');
|
|
201
|
-
log('Custom (
|
|
276
|
+
log('Custom (your own โ never auto-modified):');
|
|
202
277
|
for (const s of groups.custom) {
|
|
203
278
|
log(` โข ${s.slug}${s.description ? ` โ ${s.description}` : ''}`);
|
|
204
279
|
}
|
|
@@ -220,9 +295,12 @@ async function runAdd(args) {
|
|
|
220
295
|
const client = new SkillsClient();
|
|
221
296
|
const entry = await writeSkill(skillsDir, { source, name, kind: 'remote' }, client);
|
|
222
297
|
const manifest = await readManifest(skillsDir);
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
298
|
+
// Mutate in place so caller-set fields on the manifest (pinVersion,
|
|
299
|
+
// lastUpdate, lastUpdateVersion) survive the add. Building a fresh
|
|
300
|
+
// {version, installed} object would silently drop them.
|
|
301
|
+
manifest.installed = [...manifest.installed.filter((e) => e.slug !== entry.slug), entry];
|
|
302
|
+
await writeManifest(skillsDir, manifest);
|
|
303
|
+
log(` โ pinned ${entry.slug} โ .claude/skills/${entry.slug}/SKILL.md`);
|
|
226
304
|
log(' Commit + push to apply on the next PR.');
|
|
227
305
|
}
|
|
228
306
|
|
|
@@ -234,7 +312,7 @@ async function runRemove(args) {
|
|
|
234
312
|
}
|
|
235
313
|
const skillsDir = join(process.cwd(), '.claude', 'skills');
|
|
236
314
|
const entry = await removeSkill(skillsDir, slug);
|
|
237
|
-
log(` โ
|
|
315
|
+
log(` โ unpinned ${entry.slug}${entry.kind === 'baseline' ? ' (baseline โ returns on next init)' : ''}`);
|
|
238
316
|
}
|
|
239
317
|
|
|
240
318
|
async function runRefresh(args) {
|
|
@@ -242,11 +320,11 @@ async function runRefresh(args) {
|
|
|
242
320
|
const skillsDir = join(cwd, '.claude', 'skills');
|
|
243
321
|
const manifest = await readManifest(skillsDir);
|
|
244
322
|
if (manifest.installed.length === 0) {
|
|
245
|
-
log('No clud-bug-managed
|
|
323
|
+
log('No clud-bug-managed specimens found. Run `clud-bug init` first.');
|
|
246
324
|
return;
|
|
247
325
|
}
|
|
248
326
|
|
|
249
|
-
log('
|
|
327
|
+
log(' re-surveying habitat...');
|
|
250
328
|
const signals = await detect(cwd);
|
|
251
329
|
log(` primary language: ${signals.primaryLanguage || '(unknown)'}`);
|
|
252
330
|
log(` search terms: ${signals.searchTerms.join(', ') || '(none)'}`);
|
|
@@ -286,7 +364,7 @@ async function runRefresh(args) {
|
|
|
286
364
|
|
|
287
365
|
if (diff.add.length === 0 && diff.remove.length === 0) {
|
|
288
366
|
log('');
|
|
289
|
-
log('
|
|
367
|
+
log('Collection in sync with skills.sh โ nothing to update.');
|
|
290
368
|
return;
|
|
291
369
|
}
|
|
292
370
|
|
|
@@ -307,7 +385,134 @@ async function runRefresh(args) {
|
|
|
307
385
|
const client = new SkillsClient();
|
|
308
386
|
if (diff.add.length) await writeSkills(skillsDir, diff.add, client);
|
|
309
387
|
for (const entry of diff.remove) await removeSkill(skillsDir, entry.slug);
|
|
310
|
-
log(' โ
|
|
388
|
+
log(' โ collection updated. Commit + push to apply on the next PR.');
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async function runEditWorkflow(_args) {
|
|
392
|
+
const cwd = process.cwd();
|
|
393
|
+
|
|
394
|
+
// Validate: must have pending changes, all scoped to clud-bug workflow files.
|
|
395
|
+
let pending;
|
|
396
|
+
try {
|
|
397
|
+
pending = getPendingWorkflowEdits(cwd);
|
|
398
|
+
} catch (err) {
|
|
399
|
+
process.stderr.write(`clud-bug edit-workflow: ${err.message}\n`);
|
|
400
|
+
process.exit(2);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (pending.files.length === 0) {
|
|
404
|
+
log('Nothing to commit. Edit your .github/workflows/clud-bug-*.yml file(s) first, then re-run.');
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
if (!pending.allWorkflow) {
|
|
408
|
+
process.stderr.write(`clud-bug edit-workflow: working tree contains non-workflow changes:\n`);
|
|
409
|
+
for (const f of pending.nonWorkflow) process.stderr.write(` ${f}\n`);
|
|
410
|
+
process.stderr.write(`\nThis command is for isolated workflow-only PRs. Stash or commit the\nnon-workflow changes elsewhere first, then re-run.\n`);
|
|
411
|
+
process.exit(2);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
log('๐ Preparing an isolated PR for your workflow edit.');
|
|
415
|
+
const branch = makeBranchName();
|
|
416
|
+
log(` branch: ${branch} (rooted at origin/main)`);
|
|
417
|
+
for (const f of pending.files) log(` โข ${f}`);
|
|
418
|
+
|
|
419
|
+
// Stash the pending workflow changes, branch from origin/main explicitly
|
|
420
|
+
// (NOT from HEAD โ if the user is on a feature branch with unrelated
|
|
421
|
+
// commits, those would otherwise leak into the "isolated" PR), then
|
|
422
|
+
// restore the changes onto the new branch and commit.
|
|
423
|
+
gitCmd(cwd, ['stash', 'push', '--include-untracked', '-m', 'clud-bug edit-workflow']);
|
|
424
|
+
try {
|
|
425
|
+
gitCmd(cwd, ['fetch', 'origin', 'main', '--depth=1']);
|
|
426
|
+
gitCmd(cwd, ['checkout', '-b', branch, 'origin/main']);
|
|
427
|
+
} catch (err) {
|
|
428
|
+
// Restore the user's stash before bubbling up.
|
|
429
|
+
gitCmd(cwd, ['stash', 'pop'], { allowFail: true });
|
|
430
|
+
throw err;
|
|
431
|
+
}
|
|
432
|
+
const popped = gitCmd(cwd, ['stash', 'pop'], { allowFail: true });
|
|
433
|
+
if (!popped.ok) {
|
|
434
|
+
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`);
|
|
435
|
+
process.exit(1);
|
|
436
|
+
}
|
|
437
|
+
gitCmd(cwd, ['add', ...pending.files]);
|
|
438
|
+
gitCmd(cwd, ['commit', '-m', 'Edit clud-bug workflow']);
|
|
439
|
+
gitCmd(cwd, ['push', '-u', 'origin', branch]);
|
|
440
|
+
|
|
441
|
+
log('');
|
|
442
|
+
log('Done. Open the PR:');
|
|
443
|
+
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."`);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async function runUpdateCmd(_args) {
|
|
447
|
+
const cwd = process.cwd();
|
|
448
|
+
const ourVersion = await readPkgVersion();
|
|
449
|
+
log(`๐ Refreshing the field kit (${ourVersion}).`);
|
|
450
|
+
|
|
451
|
+
const result = await runUpdate({
|
|
452
|
+
cwd,
|
|
453
|
+
templatesDir: TEMPLATES,
|
|
454
|
+
baselineDir: BASELINE_DIR,
|
|
455
|
+
ourVersion,
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
if (result.missing === 'init') {
|
|
459
|
+
log(' No clud-bug installation detected. Run `clud-bug init` first.');
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (result.changed.length === 0) {
|
|
464
|
+
log(' Already current. Nothing to update.');
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
log(` โ Updated ${result.changed.length} file${result.changed.length === 1 ? '' : 's'}:`);
|
|
469
|
+
for (const c of result.changed) log(` โข ${rel(cwd, c.path)} (${c.label})`);
|
|
470
|
+
if (result.unchanged.length > 0) {
|
|
471
|
+
log(` ${result.unchanged.length} file${result.unchanged.length === 1 ? ' was' : 's were'} already current.`);
|
|
472
|
+
}
|
|
473
|
+
log('');
|
|
474
|
+
log('Commit + push to apply the refreshed kit on the next PR.');
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async function runAudit(args) {
|
|
478
|
+
const cwd = process.cwd();
|
|
479
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
480
|
+
|
|
481
|
+
let scopeLabel;
|
|
482
|
+
if (args.since) scopeLabel = `commits since ${args.since}`;
|
|
483
|
+
else if (args.changedIn) scopeLabel = `files changed in the past ${args.changedIn}`;
|
|
484
|
+
else if (args.scopes.length) scopeLabel = `glob ${args.scopes.join(', ')}`;
|
|
485
|
+
else scopeLabel = 'all tracked files';
|
|
486
|
+
|
|
487
|
+
log(`๐ Audit walk in ${cwd}.`);
|
|
488
|
+
log(` scope: ${scopeLabel}`);
|
|
489
|
+
|
|
490
|
+
let files;
|
|
491
|
+
try {
|
|
492
|
+
files = computeAuditFileSet({
|
|
493
|
+
cwd,
|
|
494
|
+
since: args.since,
|
|
495
|
+
changedIn: args.changedIn,
|
|
496
|
+
scopes: args.scopes,
|
|
497
|
+
});
|
|
498
|
+
} catch (err) {
|
|
499
|
+
process.stderr.write(`clud-bug audit: ${err.message}\n`);
|
|
500
|
+
process.exit(2);
|
|
501
|
+
}
|
|
502
|
+
log(` surveyed: ${files.length} file${files.length === 1 ? '' : 's'}`);
|
|
503
|
+
|
|
504
|
+
if (files.length === 0) {
|
|
505
|
+
log(' Nothing in scope. Try widening --scope or --changed-in.');
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const outPath = args.out || join(cwd, 'audits', `${date}.md`);
|
|
510
|
+
await mkdir(dirname(outPath), { recursive: true });
|
|
511
|
+
await writeFile(outPath, renderAuditHeader({ date, scopeLabel, files }));
|
|
512
|
+
log(` โ wrote stub: ${rel(cwd, outPath)}`);
|
|
513
|
+
log('');
|
|
514
|
+
log('Stub is empty findings โ populated by the GitHub Action.');
|
|
515
|
+
log('Run locally without the workflow if you want โ Clud Bug review needs the action runner + ANTHROPIC_API_KEY.');
|
|
311
516
|
}
|
|
312
517
|
|
|
313
518
|
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
|
+
}
|