clud-bug 0.6.34 → 0.7.0-rc.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.
Files changed (95) hide show
  1. package/bin/clud-bug.js +10 -1353
  2. package/dist/cli/agents-md.d.ts +16 -0
  3. package/dist/cli/agents-md.d.ts.map +1 -0
  4. package/dist/cli/agents-md.js +226 -0
  5. package/dist/cli/agents-md.js.map +1 -0
  6. package/dist/cli/audit.d.ts +13 -0
  7. package/dist/cli/audit.d.ts.map +1 -0
  8. package/dist/cli/audit.js +90 -0
  9. package/dist/cli/audit.js.map +1 -0
  10. package/dist/cli/branch-protection.d.ts +57 -0
  11. package/dist/cli/branch-protection.d.ts.map +1 -0
  12. package/dist/cli/branch-protection.js +118 -0
  13. package/dist/cli/branch-protection.js.map +1 -0
  14. package/dist/cli/edit-workflow.d.ts +18 -0
  15. package/dist/cli/edit-workflow.d.ts.map +1 -0
  16. package/dist/cli/edit-workflow.js +43 -0
  17. package/dist/cli/edit-workflow.js.map +1 -0
  18. package/dist/cli/index.d.ts +8 -0
  19. package/dist/cli/index.d.ts.map +1 -0
  20. package/dist/cli/index.js +18 -0
  21. package/dist/cli/index.js.map +1 -0
  22. package/dist/cli/main.d.ts +3 -0
  23. package/dist/cli/main.d.ts.map +1 -0
  24. package/dist/cli/main.js +1336 -0
  25. package/dist/cli/main.js.map +1 -0
  26. package/dist/cli/skill-usage.d.ts +109 -0
  27. package/dist/cli/skill-usage.d.ts.map +1 -0
  28. package/dist/cli/skill-usage.js +380 -0
  29. package/dist/cli/skill-usage.js.map +1 -0
  30. package/dist/cli/skills.d.ts +56 -0
  31. package/dist/cli/skills.d.ts.map +1 -0
  32. package/dist/cli/skills.js +292 -0
  33. package/dist/cli/skills.js.map +1 -0
  34. package/dist/cli/update.d.ts +29 -0
  35. package/dist/cli/update.d.ts.map +1 -0
  36. package/dist/cli/update.js +186 -0
  37. package/dist/cli/update.js.map +1 -0
  38. package/dist/cli/usage.d.ts +142 -0
  39. package/dist/cli/usage.d.ts.map +1 -0
  40. package/dist/cli/usage.js +348 -0
  41. package/dist/cli/usage.js.map +1 -0
  42. package/dist/core/audit.d.ts +8 -0
  43. package/dist/core/audit.d.ts.map +1 -0
  44. package/dist/core/audit.js +47 -0
  45. package/dist/core/audit.js.map +1 -0
  46. package/dist/core/detect.d.ts +77 -0
  47. package/dist/core/detect.d.ts.map +1 -0
  48. package/dist/core/detect.js +262 -0
  49. package/dist/core/detect.js.map +1 -0
  50. package/dist/core/index.d.ts +8 -0
  51. package/dist/core/index.d.ts.map +1 -0
  52. package/dist/core/index.js +14 -0
  53. package/dist/core/index.js.map +1 -0
  54. package/dist/core/prompts.d.ts +9 -0
  55. package/dist/core/prompts.d.ts.map +1 -0
  56. package/dist/core/prompts.js +401 -0
  57. package/dist/core/prompts.js.map +1 -0
  58. package/dist/core/render-review.d.ts +6 -0
  59. package/dist/core/render-review.d.ts.map +1 -0
  60. package/dist/core/render-review.js +219 -0
  61. package/dist/core/render-review.js.map +1 -0
  62. package/dist/core/render.d.ts +13 -0
  63. package/dist/core/render.d.ts.map +1 -0
  64. package/dist/core/render.js +80 -0
  65. package/dist/core/render.js.map +1 -0
  66. package/dist/core/review-schema.d.ts +42 -0
  67. package/dist/core/review-schema.d.ts.map +1 -0
  68. package/dist/core/review-schema.js +156 -0
  69. package/dist/core/review-schema.js.map +1 -0
  70. package/dist/core/skills.d.ts +80 -0
  71. package/dist/core/skills.d.ts.map +1 -0
  72. package/dist/core/skills.js +510 -0
  73. package/dist/core/skills.js.map +1 -0
  74. package/package.json +27 -4
  75. package/{lib/agents-md.js → src/cli/agents-md.ts} +25 -14
  76. package/{lib/audit.js → src/cli/audit.ts} +37 -44
  77. package/{lib/branch-protection.js → src/cli/branch-protection.ts} +75 -11
  78. package/{lib/edit-workflow.js → src/cli/edit-workflow.ts} +32 -11
  79. package/src/cli/index.ts +101 -0
  80. package/src/cli/main.ts +1376 -0
  81. package/{lib/skill-usage.js → src/cli/skill-usage.ts} +168 -94
  82. package/src/cli/skills.ts +386 -0
  83. package/{lib/update.js → src/cli/update.ts} +68 -27
  84. package/{lib/usage.js → src/cli/usage.ts} +167 -76
  85. package/src/core/audit.ts +53 -0
  86. package/{lib/detect.js → src/core/detect.ts} +100 -47
  87. package/src/core/index.ts +70 -0
  88. package/{lib/prompts.js → src/core/prompts.ts} +16 -2
  89. package/{lib/render-review.js → src/core/render-review.ts} +57 -25
  90. package/{lib/render.js → src/core/render.ts} +36 -10
  91. package/{lib/review-schema.js → src/core/review-schema.ts} +68 -5
  92. package/{lib/skills.js → src/core/skills.ts} +172 -343
  93. package/templates/workflow-py.yml.tmpl +2 -2
  94. package/templates/workflow-ts.yml.tmpl +2 -2
  95. package/templates/workflow.yml.tmpl +17 -8
package/bin/clud-bug.js CHANGED
@@ -1,1357 +1,14 @@
1
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, spawn } 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, templateLanguage } from '../lib/render.js';
11
- import { reviewPrompt } from '../lib/prompts.js';
12
- import {
13
- SkillsClient, rankAndCap, writeSkills, writeSkill, loadBaseline,
14
- readManifest, writeManifest, removeSkill, listInstalled, diffManifest,
15
- } from '../lib/skills.js';
16
- import { computeAuditFileSet, renderAuditHeader } from '../lib/audit.js';
17
- import { runUpdate } from '../lib/update.js';
18
- import { getPendingWorkflowEdits, makeBranchName, git as gitCmd } from '../lib/edit-workflow.js';
19
- import { applyToRepo as applyAgentDocs } from '../lib/agents-md.js';
20
- import { detectRepo, detectDefaultBranch, getProtectionState, enableConversationResolution } from '../lib/branch-protection.js';
21
- import { computeReviewCost, costPerLOC, cacheHitRate, extractTokensFromLog, rollup, formatRollup } from '../lib/usage.js';
22
-
23
- const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
24
- const TEMPLATES = join(PKG_ROOT, 'templates');
25
- const BASELINE_DIR = join(TEMPLATES, 'skills', 'baseline');
26
-
27
- function parseArgs(argv) {
28
- const args = {
29
- _: [], offline: false, acceptAll: false, commit: false, help: false, version: false,
30
- since: null, changedIn: null, scopes: [], out: null,
31
- setProtection: true, quiet: false,
32
- // 0.0.M.1 (v0.6.13): `clud-bug usage` flags.
33
- repo: null, pr: null, limit: null, json: false,
34
- // 0.0.O (v0.6.22): `clud-bug render` reads its payload from stdin.
35
- stdin: false,
36
- // v0.6.30: cross-review aggregation read source for `usage --health`.
37
- // Defaults to true (artifact mode); `--no-artifacts` forces local
38
- // .clud-bug.json read (matches v0.6.28 behavior).
39
- artifacts: true,
40
- // v0.6.33: unified-install mirror — `clud-bug init --with-skdd` also
41
- // subprocesses to `pip install logmind && logmind init` so Node-first
42
- // users get the same one-command bootstrap as Python-first users
43
- // (logmind v0.6.8's --with-skdd is the symmetric counterpart).
44
- withSkdd: false,
45
- };
46
- for (let i = 0; i < argv.length; i++) {
47
- const a = argv[i];
48
- if (a === '--offline') args.offline = true;
49
- else if (a === '--accept-all' || a === '-y') args.acceptAll = true;
50
- else if (a === '--commit') args.commit = true;
51
- else if (a === '--help' || a === '-h') args.help = true;
52
- else if (a === '--version' || a === '-v') args.version = true;
53
- else if (a === '--quiet' || a === '-q') args.quiet = true;
54
- else if (a === '--since') args.since = argv[++i];
55
- else if (a === '--changed-in') args.changedIn = argv[++i];
56
- else if (a === '--scope') args.scopes.push(argv[++i]);
57
- else if (a === '--out') args.out = argv[++i];
58
- else if (a === '--no-set-protection') args.setProtection = false;
59
- else if (a === '--repo') args.repo = argv[++i];
60
- else if (a === '--pr') args.pr = Number(argv[++i]);
61
- else if (a === '--limit') args.limit = Number(argv[++i]);
62
- else if (a === '--json') args.json = true;
63
- else if (a === '--stdin') args.stdin = true;
64
- else if (a === '--health') args.health = true;
65
- else if (a === '--no-artifacts') args.artifacts = false;
66
- else if (a === '--with-skdd') args.withSkdd = true;
67
- else args._.push(a);
68
- }
69
- return args;
70
- }
71
-
72
- const HELP = `clud-bug 🐛 — a field guide to specimens crawling your code
73
-
74
- Usage:
75
- npx clud-bug <command> [options]
76
-
77
- Commands:
78
- init Open field season: survey the repo, pin baseline specimens, write the workflows.
79
- Pass \`--with-skdd\` to also install logmind in one go (requires Python + pip).
80
- list Show your collection (baseline / from skills.sh / custom).
81
- add <source/name> Pin one new specimen from skills.sh (e.g. vercel-labs/skills/next-best-practices).
82
- remove <slug> Unpin a clud-bug-managed specimen (refuses to touch your custom ones).
83
- refresh Re-survey, diff against your collection, prompt to update.
84
- audit Walk the whole habitat (or a recent slice) and prepare a report stub.
85
- Use --since / --changed-in / --scope to narrow.
86
- update Re-render workflows + refresh baseline specimens to the latest shipped
87
- templates. Custom and skills.sh-installed specimens left alone.
88
- edit-workflow Helper for editing .github/workflows/clud-bug-*.yml in an isolated
89
- PR (the action refuses to review PRs that modify its own workflow).
90
- usage Read recent clud-bug-review run JSON + normalize cost per LOC.
91
- Internal Q7-clud-bug enforcement dashboard. Reports cache hit
92
- rate, 30-day rolling \$/LOC trend, per-repo/per-model
93
- distributions, and outliers (> 2x org median).
94
- Use --pr / --repo / --since / --limit / --json to filter.
95
- usage --health Deterministic skill-health dashboard. Renders archive-
96
- candidate / stale / new / healthy status per skill, applying
97
- the v0.6.28 thresholds (citations==0 + loads>=5 → archive
98
- candidate; last cited >60d → stale; etc.). Read-only —
99
- humans decide what to prune.
100
- Read source (v0.6.30): by default, walks
101
- \`clud-bug-skill-usage-pr-*\` workflow artifacts uploaded
102
- by every clud-bug-review run and accumulates them into
103
- one org-level snapshot. Pass \`--repo owner/name\` to
104
- target a specific repo; otherwise infers from the local
105
- git remote. \`--no-artifacts\` falls back to reading the
106
- local \`.claude/skills/.clud-bug.json\` (v0.6.28 behavior).
107
- eval Run the golden-set regression gate against the rendered review
108
- prompt (must-contain / must-not-contain / byte-budget). Same as
109
- \`node --test test/prompts.eval.test.js\` but works from any cwd.
110
- update-skill-usage Update the .claude/skills/.clud-bug.json usage block from
111
- a structured-output JSON payload (the action's
112
- \`outputs.structured_output\`). Called as a workflow
113
- post-step alongside \`render\` (v0.6.29 / Component 4).
114
- Pipe the JSON to stdin. Idempotent + atomic write.
115
- Silent no-op on empty stdin (parity with \`render\`).
116
- render --stdin Render a structured-output JSON payload (the action's
117
- \`outputs.structured_output\`, piped via stdin) to the
118
- GitHub-markdown summary comment shape. Invoked by the
119
- workflow post-step; output is what \`gh pr comment\`
120
- receives. Empty stdin or non-object payload exits 2.
121
-
122
- Options:
123
- --offline Skip skills.sh; pin only the bundled baseline specimens.
124
- --accept-all,-y Accept the recommended specimens without prompting.
125
- --commit git add + commit the generated kit when done (init only).
126
- --quiet,-q Token-frugal mode for agent invocations. Suppresses
127
- progress chatter; emits exactly one final
128
- \`ok <key-value>\` summary line per command. Errors
129
- and warnings still print. Also honored via the
130
- CLUD_BUG_QUIET=1 env var.
131
- --no-set-protection Skip the prompt that offers to enable
132
- required_conversation_resolution on the default
133
- branch (init only). Use for repos that manage
134
- branch protection via ruleset or org policy.
135
- --repo <owner/name> Restrict \`usage\` to a single repo. Default: all repos
136
- with clud-bug-review.yml in the gh user's auth scope.
137
- --pr <N> Restrict \`usage\` to a single PR.
138
- --limit <N> Max reviews to fetch (default 50; the API caps).
139
- --json Emit JSON instead of human-readable output.
140
- Compatible with --quiet for pipeline consumption.
141
- --since <date> Audit only files changed in commits after <date> (git date string).
142
- --changed-in <dur> Audit only files changed in the past <dur>: 7d, 2w, 1mo, 1y. (audit only)
143
- --scope <glob> Limit audit to files matching <glob>; repeatable. (audit only)
144
- --out <path> Where to write the audit stub. Default: audits/YYYY-MM-DD.md
145
- --help,-h Show this help.
146
- --version,-v Show version.
147
- `;
148
-
149
- async function readPkgVersion() {
150
- const pkg = JSON.parse(await readFile(join(PKG_ROOT, 'package.json'), 'utf8'));
151
- return pkg.version;
152
- }
153
-
154
- async function main() {
155
- const args = parseArgs(process.argv.slice(2));
156
- if (args.help) { process.stdout.write(HELP); return; }
157
- if (args.version) { process.stdout.write((await readPkgVersion()) + '\n'); return; }
158
- if (args.quiet) setQuiet(true);
159
-
160
- const cmd = args._[0];
161
- switch (cmd) {
162
- case 'init': return runInit(args);
163
- case 'list': return runList(args);
164
- case 'add': return runAdd(args);
165
- case 'remove': return runRemove(args);
166
- case 'refresh': return runRefresh(args);
167
- case 'audit': return runAudit(args);
168
- case 'update': return runUpdateCmd(args);
169
- case 'edit-workflow': return runEditWorkflow(args);
170
- case 'usage': return runUsage(args);
171
- case 'eval': return runEval();
172
- case 'render': return runRender(args);
173
- case 'update-skill-usage': return runUpdateSkillUsage(args);
174
- default:
175
- process.stderr.write(`Unknown command: ${cmd || '(none)'}\n\n${HELP}`);
176
- process.exit(2);
177
- }
178
- }
179
-
180
- // 0.0.O (v0.6.22): render a structured-output JSON payload to the
181
- // GitHub-markdown summary comment shape. Called by the post-step
182
- // in the workflow templates: it reads the action's
183
- // `outputs.structured_output` (one bundled JSON string), pipes it
184
- // to stdin here, and we emit the rendered markdown on stdout for
185
- // the shell to pass to `gh pr comment --body`.
186
- //
187
- // Usage: `clud-bug render --stdin` (only input source supported).
188
- // Exit code: 0 on success, 2 on JSON parse error or non-object payload.
189
- async function runRender(args) {
190
- const { renderReview } = await import('../lib/render-review.js');
191
- if (!args.stdin) {
192
- process.stderr.write('clud-bug render: --stdin is required (the only supported input source).\n');
193
- process.exit(2);
194
- }
195
- let raw = '';
196
- for await (const chunk of process.stdin) raw += chunk;
197
- raw = raw.trim();
198
- if (!raw) {
199
- // Empty structured_output → post-step is supposed to skip the
200
- // render. Surface the situation rather than silently producing an
201
- // empty comment.
202
- process.stderr.write('clud-bug render: stdin was empty — nothing to render.\n');
203
- process.exit(2);
204
- }
205
- let payload;
206
- try {
207
- payload = JSON.parse(raw);
208
- } catch (e) {
209
- process.stderr.write(`clud-bug render: JSON parse failed: ${e.message}\n`);
210
- process.exit(2);
211
- }
212
- try {
213
- process.stdout.write(renderReview(payload));
214
- } catch (e) {
215
- process.stderr.write(`clud-bug render: ${e.message}\n`);
216
- process.exit(2);
217
- }
218
- }
219
-
220
- // v0.6.29 — Component 4. Pipe the action's structured_output through
221
- // the skill-usage data layer (v0.6.28) + write the merged result back
222
- // to .claude/skills/.clud-bug.json atomically.
223
- //
224
- // Workflow integration (post-step in workflow.yml.tmpl):
225
- //
226
- // echo "${{ steps.review.outputs.structured_output }}" \
227
- // | npx clud-bug@latest update-skill-usage --stdin
228
- //
229
- // Runs AFTER the render post-step. Silent no-op on empty stdin
230
- // (same contract as `render` — preserves the workflow's existing
231
- // "skip both if empty" branch). Idempotent: running on the same JSON
232
- // twice produces the same result.
233
- async function runUpdateSkillUsage(args) {
234
- const fs = await import('node:fs/promises');
235
- const path = await import('node:path');
236
- const {
237
- computeSkillUsageDelta,
238
- mergeSkillUsage,
239
- } = await import('../lib/skill-usage.js');
240
-
241
- if (!args.stdin) {
242
- process.stderr.write('clud-bug update-skill-usage: --stdin is required.\n');
243
- process.exit(2);
244
- }
245
-
246
- let raw = '';
247
- for await (const chunk of process.stdin) raw += chunk;
248
- raw = raw.trim();
249
- if (!raw) {
250
- // Empty structured_output → render is also skipped → nothing to
251
- // update. Match the render contract: exit 0 with a stderr note.
252
- process.stderr.write('clud-bug update-skill-usage: stdin empty — no usage update.\n');
253
- return;
254
- }
255
-
256
- let reviewJson;
257
- try {
258
- reviewJson = JSON.parse(raw);
259
- } catch (e) {
260
- process.stderr.write(`clud-bug update-skill-usage: invalid JSON: ${e.message}\n`);
261
- process.exit(2);
262
- }
263
- if (!reviewJson || typeof reviewJson !== 'object') {
264
- process.stderr.write('clud-bug update-skill-usage: payload must be a JSON object.\n');
265
- process.exit(2);
266
- }
267
-
268
- // Compute per-review delta. Empty delta is fine — just means no
269
- // skills loaded or cited (workflow-only PRs, e.g.).
270
- const delta = computeSkillUsageDelta(reviewJson);
271
- if (Object.keys(delta).length === 0) {
272
- process.stderr.write('clud-bug update-skill-usage: no skills in payload — nothing to record.\n');
273
- return;
274
- }
275
-
276
- // Read existing .clud-bug.json. The path is canonical:
277
- // .claude/skills/.clud-bug.json relative to cwd (the workflow runs
278
- // from the repo root).
279
- const jsonPath = path.resolve(process.cwd(), '.claude', 'skills', '.clud-bug.json');
280
- let parsed;
281
- try {
282
- const existingRaw = await fs.readFile(jsonPath, 'utf-8');
283
- parsed = JSON.parse(existingRaw);
284
- } catch (err) {
285
- if (err.code === 'ENOENT') {
286
- process.stderr.write(
287
- `clud-bug update-skill-usage: no .clud-bug.json at ${jsonPath} — skipping. ` +
288
- `Run \`npx clud-bug init\` first.\n`
289
- );
290
- return;
291
- }
292
- process.stderr.write(`clud-bug update-skill-usage: parse failed: ${err.message}\n`);
293
- process.exit(2);
294
- }
295
- if (!parsed || typeof parsed !== 'object') {
296
- process.stderr.write('clud-bug update-skill-usage: .clud-bug.json malformed.\n');
297
- process.exit(2);
298
- }
299
-
300
- const existingUsage = parsed.usage || {};
301
- const timestamp = new Date().toISOString();
302
- const mergedUsage = mergeSkillUsage(existingUsage, delta, timestamp);
303
- parsed.usage = mergedUsage;
304
-
305
- // Write back ATOMICALLY: temp file + rename. Guards against a
306
- // crashed write leaving the JSON half-written + unparseable on next
307
- // read (which would brick the entire skill catalog).
308
- const tmpPath = jsonPath + '.tmp';
309
- const serialized = JSON.stringify(parsed, null, 2) + '\n';
310
- await fs.writeFile(tmpPath, serialized, 'utf-8');
311
- await fs.rename(tmpPath, jsonPath);
312
-
313
- const skillCount = Object.keys(delta).length;
314
- ok(`update-skill-usage: merged ${skillCount} skill${skillCount === 1 ? '' : 's'} from review`);
315
- }
316
-
317
-
318
- // 0.0.E (v0.6.17): thin wrapper around the golden-set test file. Devs
319
- // who follow the README invoke `clud-bug eval` — this routes to the
320
- // same `node --test` runner CI uses, so dev and CI verdicts match.
321
- //
322
- // Dev-only: runs against the prompt bundled in PKG_ROOT (the cloned
323
- // clud-bug repo). `test/` is intentionally not in package.json `files`,
324
- // so invoking this from a globally installed copy will ENOENT. No args
325
- // supported yet — the README does not advertise any.
326
- async function runEval() {
327
- const result = spawnSync(
328
- 'node',
329
- ['--test', join(PKG_ROOT, 'test/prompts.eval.test.js')],
330
- { stdio: 'inherit' },
331
- );
332
- process.exit(result.status ?? 1);
333
- }
334
-
335
- async function runInit(args) {
336
- const cwd = process.cwd();
337
- log(`🐛 Field season opens in ${cwd}.`);
338
-
339
- log(' surveying habitat...');
340
- const signals = await detect(cwd);
341
- log(` primary language: ${signals.primaryLanguage || '(unknown)'}`);
342
- log(` search terms: ${signals.searchTerms.join(', ') || '(none)'}`);
343
-
344
- const baseline = await loadBaseline(BASELINE_DIR);
345
- const fromAgentSkills = baseline.filter((s) => s._source === 'agent-skills').length;
346
- const sourceLabel = baseline.length === 0
347
- ? ''
348
- : fromAgentSkills === baseline.length ? ' (from thrillmade/agent-skills)'
349
- : fromAgentSkills === 0 ? ' (bundled fallback)'
350
- : ` (${fromAgentSkills} from agent-skills, ${baseline.length - fromAgentSkills} bundled)`;
351
- log(` baseline kit: ${baseline.length} specimens${sourceLabel}`);
352
-
353
- let curated = [];
354
- let searched = [];
355
- if (args.offline) {
356
- log(' --offline: skipping skills.sh');
357
- } else {
358
- const client = new SkillsClient();
359
- try {
360
- log(' consulting skills.sh...');
361
- [curated, searched] = await Promise.all([
362
- client.curated().catch(err => { warn(`curated query failed: ${err.message}`); return []; }),
363
- client.search(signals.searchTerms).catch(err => { warn(`search failed: ${err.message}`); return []; }),
364
- ]);
365
- log(` curated: ${curated.length}, search hits: ${searched.length}`);
366
- } catch (err) {
367
- warn(`skills.sh unreachable (${err.message}); continuing with baseline only`);
368
- }
369
- }
370
-
371
- const recommended = rankAndCap(curated, searched, baseline);
372
- log('');
373
- log('Specimens to pin:');
374
- for (const s of recommended) {
375
- const tag = s.kind === 'baseline' ? '[baseline]' : `[${s.source}]`;
376
- log(` • ${s.name} ${tag}`);
377
- if (s.description && s.kind !== 'baseline') log(` ${s.description}`);
378
- }
379
- log('');
380
-
381
- let chosen = recommended;
382
- if (!args.acceptAll && recommended.some(s => s.kind !== 'baseline')) {
383
- chosen = await promptForSkills(recommended);
384
- }
385
-
386
- log(' pinning specimens to .claude/skills/...');
387
- const client = new SkillsClient();
388
- const written = await writeSkills(join(cwd, '.claude', 'skills'), chosen, client);
389
- log(` pinned ${written.length} specimens`);
390
-
391
- // Empty-skills warning: clud-bug shines when paired with project-specific
392
- // skills. Reviews that load only the three baselines are functional but
393
- // generic; flag this so users notice.
394
- const remoteCount = written.filter((w) => w.kind !== 'baseline').length;
395
- if (remoteCount === 0) {
396
- 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`.');
397
- }
398
-
399
- log(' drafting field kit...');
400
- const tmplName = pickTemplate(signals.languages);
401
- const tmplPath = join(TEMPLATES, tmplName);
402
- // REVIEW_SCHEMA + CCA_VERSION + CLUD_BUG_VERSION come from render.js DEFAULTS.
403
- const workflow = await renderFile(tmplPath, {
404
- REVIEW_PROMPT: reviewPrompt({
405
- projectDescription: buildDescriptionLine(signals),
406
- language: templateLanguage(tmplName),
407
- }),
408
- });
409
- const workflowPath = join(cwd, '.github', 'workflows', 'clud-bug-review.yml');
410
- await mkdir(dirname(workflowPath), { recursive: true });
411
- await writeFile(workflowPath, workflow);
412
- log(` wrote ${rel(cwd, workflowPath)}`);
413
-
414
- // Install the audit workflow alongside the per-PR review one.
415
- // Manual-trigger by default; users opt into the cron by uncommenting.
416
- // Routed through renderFile so {{CCA_VERSION}} substitution pins
417
- // claude-code-action consistently with the review workflow.
418
- const auditTmpl = await renderFile(join(TEMPLATES, 'audit.yml.tmpl'), {});
419
- const auditPath = join(cwd, '.github', 'workflows', 'clud-bug-audit.yml');
420
- await writeFile(auditPath, auditTmpl);
421
- log(` wrote ${rel(cwd, auditPath)}`);
422
-
423
- // Install the self-update workflow. Cron weekly Mondays 12:00 UTC; opens
424
- // a PR if a newer clud-bug version is published. Disable by deleting the
425
- // file or pinning via .claude/skills/.clud-bug.json.
426
- // Routed through renderFile for parity (no CCA ref today but future
427
- // tokens should propagate uniformly).
428
- const selfUpdateTmpl = await renderFile(join(TEMPLATES, 'self-update.yml.tmpl'), {});
429
- const selfUpdatePath = join(cwd, '.github', 'workflows', 'clud-bug-self-update.yml');
430
- await writeFile(selfUpdatePath, selfUpdateTmpl);
431
- log(` wrote ${rel(cwd, selfUpdatePath)}`);
432
-
433
- // Stamp the manifest. Sets strictMode: true ONLY on fresh installs —
434
- // a manifest that's never been touched by clud-bug init/update has no
435
- // lastUpdate field. Existing v0.3.x advisory installs (where strictMode
436
- // was never written and so == undefined) keep their advisory behavior
437
- // because lastUpdate IS set; the strictMode default only fires on truly
438
- // fresh inits. Users opt out by setting strictMode: false.
439
- const skillsDirPath = join(cwd, '.claude', 'skills');
440
- const manifest = await readManifest(skillsDirPath);
441
- const isFreshInstall = manifest.lastUpdate === undefined;
442
- manifest.lastUpdateVersion = await readPkgVersion();
443
- manifest.lastUpdate = new Date().toISOString();
444
- if (isFreshInstall && manifest.strictMode === undefined) {
445
- manifest.strictMode = true;
446
- }
447
- await writeManifest(skillsDirPath, manifest);
448
-
449
- // Tell other agents what's installed and how to coexist with the bot.
450
- // Idempotent — re-runs replace the prior block in place. AGENTS.md is the
451
- // canonical home (cross-tool); CLAUDE.md / GEMINI.md / Cursor / Windsurf
452
- // / Cline / Continue rules files get the same block appended IF they
453
- // already exist (we don't proliferate stubs the user didn't ask for).
454
- log(' briefing other agents (AGENTS.md / CLAUDE.md)...');
455
- // Pass `=== true` (not `!== false`) so the rendered block matches the
456
- // workflow's gate predicate exactly. A v0.3 advisory upgrade where
457
- // strictMode is undefined renders "off" — which is what the workflow
458
- // actually does on that manifest.
459
- const agentDocs = await applyAgentDocs(cwd, {
460
- version: manifest.lastUpdateVersion,
461
- strictMode: manifest.strictMode === true,
462
- });
463
- for (const p of agentDocs.created) log(` created ${p}`);
464
- for (const p of agentDocs.touched) log(` updated ${p}`);
465
-
466
- if (args.commit) {
467
- log(' committing...');
468
- const toAdd = [
469
- '.claude',
470
- '.github/workflows/clud-bug-review.yml',
471
- '.github/workflows/clud-bug-audit.yml',
472
- '.github/workflows/clud-bug-self-update.yml',
473
- ...agentDocs.created,
474
- ...agentDocs.touched,
475
- ];
476
- spawnSync('git', ['add', ...toAdd], { cwd, stdio: 'inherit' });
477
- spawnSync('git', ['commit', '-m', 'Add clud-bug 🐛 — a field guide to specimens crawling your code'], { cwd, stdio: 'inherit' });
478
- }
479
-
480
- // Offer to enable required_conversation_resolution on the default
481
- // branch. clud-bug auto-resolves its own review threads when fixes
482
- // land — without this setting, that doesn't gate merges. Skipped on
483
- // --no-set-protection for repos that manage protection via ruleset
484
- // or org policy.
485
- await runInitBranchProtection(args);
486
-
487
- log('');
488
- log('Field kit assembled. Next:');
489
- log(' 1. Set ANTHROPIC_API_KEY in your repo secrets:');
490
- log(' Settings → Secrets and variables → Actions → New repository secret');
491
- if (!args.commit) {
492
- log(' 2. git add .claude .github/workflows/clud-bug-*.yml && git commit && git push');
493
- log(' 3. Open a PR — the naturalist arrives within ~2 minutes.');
494
- } else {
495
- log(' 2. git push, then open a PR — the naturalist arrives within ~2 minutes.');
496
- }
497
- log('');
498
- log('Drop your own .claude/skills/<name>/SKILL.md files anytime — they get pinned automatically.');
499
- log('For a whole-repo walk: Actions tab → Clud Bug 🐛 Audit → Run workflow.');
500
- log('Self-update is on (weekly Mondays 12:00 UTC). Pin via "pinVersion" in .claude/skills/.clud-bug.json.');
501
- log('');
502
- log('Strict mode is ON by default (clud-bug-review fails the check on critical findings).');
503
- log(' • Add `clud-bug-review` to your branch protection required checks for full enforcement.');
504
- log(' • Opt out by setting "strictMode": false in .claude/skills/.clud-bug.json.');
505
-
506
- // v0.6.33 — opt-in unified install (mirror of logmind v0.6.8). When
507
- // --with-skdd is passed, subprocess to `pip install logmind` + `logmind init`
508
- // so Node-first users get the same one-command bootstrap as Python-first
509
- // users do via `logmind init --with-skdd`.
510
- // ANTI-LOOP: invoke `logmind init` (NOT `logmind init --with-skdd`).
511
- // Each opt-in flag only goes one level — no mutual recursion possible.
512
- if (args.withSkdd) {
513
- await installLogmindViaPip();
514
- }
515
-
516
- // Final agent-friendly summary line (always emitted, even with --quiet).
517
- const version = await readPkgVersion();
518
- ok(`initialized: .claude/skills/ ${chosen.length} specimens, workflow @v${version}`);
519
- }
520
-
521
- async function installLogmindViaPip() {
522
- // `spawn` is already imported at module top (line 5). No dynamic
523
- // re-import needed.
524
- //
525
- // Warnings use process.stderr.write directly (always emitted, even
526
- // under CLUD_BUG_QUIET=1) — recovery hints MUST surface to the user.
527
- // The standard `log()` is for progress chatter which quiet suppresses.
528
-
529
- // Find pip via fallback chain (pip → pip3 → python -m pip).
530
- const pipCmd = await findPipCommand();
531
- if (!pipCmd) {
532
- process.stderr.write(
533
- '\nWarning: --with-skdd requested but no `pip`/`pip3`/`python` found on PATH.\n' +
534
- ' Install Python 3.10+ (https://python.org), then run:\n' +
535
- ' pip install logmind && logmind init\n' +
536
- ' Or skip this flag if you only want clud-bug standalone.\n'
537
- );
538
- return;
539
- }
540
-
541
- log('');
542
- log(`→ --with-skdd: installing logmind (${pipCmd.join(' ')} install logmind)`);
543
-
544
- const installCode = await new Promise((resolve) => {
545
- const child = spawn(pipCmd[0], [...pipCmd.slice(1), 'install', 'logmind'], { stdio: 'inherit' });
546
- child.on('error', () => resolve(127));
547
- child.on('close', (code) => resolve(code ?? 1));
548
- });
549
- if (installCode !== 0) {
550
- process.stderr.write(
551
- `Warning: \`pip install logmind\` exited ${installCode}.\n` +
552
- ' clud-bug side succeeded; logmind install is incomplete.\n' +
553
- ' Inspect output above and re-run manually if needed.\n'
554
- );
555
- return;
556
- }
557
-
558
- log(`→ --with-skdd: running \`logmind init\` to scaffold the logmind side`);
559
- const initCode = await new Promise((resolve) => {
560
- const child = spawn('logmind', ['init'], { stdio: 'inherit' });
561
- child.on('error', () => resolve(127));
562
- child.on('close', (code) => resolve(code ?? 1));
563
- });
564
- if (initCode !== 0) {
565
- process.stderr.write(
566
- `Warning: \`logmind init\` exited ${initCode}. logmind install completed `
567
- + `but init scaffolding is incomplete. Re-run manually to finish.\n`
568
- );
569
- return;
570
- }
571
- log('✓ logmind installed via --with-skdd');
572
- }
573
-
574
- async function findPipCommand() {
575
- // Try pip → pip3 → python -m pip → python3 -m pip in order. First one
576
- // that responds to --version wins. Returns array form for spawn().
577
- // `spawn` is already imported at module top.
578
- const candidates = [
579
- ['pip'],
580
- ['pip3'],
581
- ['python', '-m', 'pip'],
582
- ['python3', '-m', 'pip'],
583
- ];
584
- for (const cmd of candidates) {
585
- const ok = await new Promise((resolve) => {
586
- const child = spawn(cmd[0], [...cmd.slice(1), '--version'], { stdio: ['ignore', 'ignore', 'ignore'] });
587
- child.on('error', () => resolve(false));
588
- child.on('close', (code) => resolve(code === 0));
589
- });
590
- if (ok) return cmd;
591
- }
592
- return null;
593
- }
594
-
595
- async function promptForSkills(recommended) {
596
- const rl = createInterface({ input, output });
597
- try {
598
- const answer = await rl.question('Install all of the above? [Y/n/select] ');
599
- const a = answer.trim().toLowerCase();
600
- if (a === '' || a === 'y' || a === 'yes') return recommended;
601
- if (a === 'n' || a === 'no') return recommended.filter(s => s.kind === 'baseline');
602
- if (a === 's' || a === 'select') {
603
- const chosen = [];
604
- for (const skill of recommended) {
605
- if (skill.kind === 'baseline') { chosen.push(skill); continue; }
606
- const ans = await rl.question(` install ${skill.name}? [Y/n] `);
607
- if (ans.trim().toLowerCase() !== 'n') chosen.push(skill);
608
- }
609
- return chosen;
610
- }
611
- return recommended;
612
- } finally {
613
- rl.close();
614
- }
615
- }
616
-
617
- // Branch-protection setup step at the end of `clud-bug init`.
618
- // Offers to enable required_conversation_resolution on the default
619
- // branch via gh API. Skipped cleanly when --no-set-protection is
620
- // passed. Failure modes (no admin perms, no base protection rule,
621
- // network error) all degrade to advisory log messages — they never
622
- // fail the init run.
623
- //
624
- // gh and prompt are injectable for tests (defaults to spawning real
625
- // gh + reading from real stdin).
626
- async function runInitBranchProtection(args, { gh, prompt } = {}) {
627
- if (!args.setProtection) {
628
- log('');
629
- log('🐛 Branch protection: skipped (--no-set-protection).');
630
- return;
631
- }
632
- log('');
633
- log('🐛 Branch protection');
634
-
635
- // Detect repo + default branch. If gh isn't installed or the local
636
- // dir isn't a github repo, treat as advisory and move on.
637
- let owner, repo, branch;
638
- try {
639
- ({ owner, repo } = await detectRepo({ gh }));
640
- branch = await detectDefaultBranch({ owner, repo, gh });
641
- } catch (err) {
642
- log(` Could not detect repo/branch (${err.message.split('\n')[0]}). Skipping.`);
643
- log(' You can enable it manually: gh api -X POST repos/<owner>/<repo>/branches/<default>/protection/required_conversation_resolution');
644
- return;
645
- }
646
-
647
- log(` Default branch: ${branch}`);
648
-
649
- // Inspect current state.
650
- const current = await getProtectionState({ owner, repo, branch, gh });
651
- if (current.state === 'enabled') {
652
- log(' required_conversation_resolution: already on — your repo is all set.');
653
- return;
654
- }
655
- if (current.state === 'forbidden') {
656
- log(' Could not read branch protection (no admin perms). Ask the repo owner to enable required_conversation_resolution, or re-run with --no-set-protection to silence this prompt.');
657
- return;
658
- }
659
- if (current.state === 'unknown') {
660
- log(` Could not read branch protection (${current.reason}). Skipping.`);
661
- return;
662
- }
663
-
664
- // Short-circuit on no-protection BEFORE prompting. The single-flag
665
- // POST endpoint requires a base protection rule on the branch — if
666
- // there's none, enableConversationResolution would just 404. Skip
667
- // the prompt and go straight to the actionable guidance (set up
668
- // basic protection first, then re-run).
669
- if (current.state === 'no-protection') {
670
- log(' required_conversation_resolution: not set (no base protection rule on this branch)');
671
- log(' Cannot enable yet: this branch has no base protection rule.');
672
- log(` Set one up first: Settings → Branches → Add rule for ${branch}`);
673
- log(' Then re-run clud-bug init (or toggle the setting in the GUI).');
674
- return;
675
- }
676
-
677
- // current.state is 'disabled'.
678
- log(' required_conversation_resolution: not set');
679
-
680
- // Decide whether to prompt.
681
- let shouldEnable;
682
- if (args.acceptAll) {
683
- // --accept-all is a real side-effect flag here: it flips a
684
- // merge-gating repo setting. Make that explicit in the log so
685
- // CI users running `clud-bug init --accept-all` see exactly
686
- // what's happening instead of silently noticing later.
687
- log(' --accept-all: will enable required_conversation_resolution. Pass --no-set-protection to skip.');
688
- shouldEnable = true;
689
- } else {
690
- const ask = prompt ?? (async (q) => {
691
- const rl = createInterface({ input, output });
692
- try { return await rl.question(q); } finally { rl.close(); }
693
- });
694
- log('');
695
- log(' Clud Bug auto-resolves its own review threads when fixes land.');
696
- log(' Without required_conversation_resolution, that doesn\'t actually gate merges.');
697
- const answer = await ask(` Enable required_conversation_resolution on ${branch}? [Y/n] `);
698
- shouldEnable = !['n', 'no'].includes(answer.trim().toLowerCase());
699
- }
700
-
701
- if (!shouldEnable) {
702
- log(' Skipped. Re-run with --accept-all or set it manually anytime.');
703
- return;
704
- }
705
-
706
- const result = await enableConversationResolution({ owner, repo, branch, gh });
707
- if (result.ok) {
708
- log(' ✓ Enabled required_conversation_resolution.');
709
- return;
710
- }
711
- if (result.state === 'no-protection') {
712
- log(' Cannot enable: this branch has no base protection rule. Set up basic branch protection first:');
713
- log(` Settings → Branches → Add rule for ${branch}`);
714
- log(' Then re-run clud-bug init (or just toggle the setting in the GUI).');
715
- return;
716
- }
717
- if (result.state === 'forbidden') {
718
- log(' Cannot enable: you do not have admin permissions on this repository.');
719
- log(' Ask the repo owner to enable it, or re-run with --no-set-protection to silence this prompt.');
720
- return;
721
- }
722
- log(` Cannot enable (${result.reason}). You can enable it manually anytime.`);
723
- }
724
-
725
- async function runList(_args) {
726
- const skillsDir = join(process.cwd(), '.claude', 'skills');
727
- const groups = await listInstalled(skillsDir);
728
- const total = groups.baseline.length + groups.remote.length + groups.custom.length;
729
- if (total === 0) {
730
- log('Empty collection. Run `clud-bug init` to open field season.');
731
- ok('list: 0 skills installed (run `clud-bug init` first)');
732
- return;
733
- }
734
- log(`🐛 ${total} specimen${total === 1 ? '' : 's'} pinned in .claude/skills/`);
735
- if (groups.baseline.length) {
736
- log('');
737
- log('Baseline (always pinned):');
738
- for (const s of groups.baseline) log(` • ${s.slug}`);
739
- }
740
- if (groups.remote.length) {
741
- log('');
742
- log('From skills.sh:');
743
- for (const s of groups.remote) log(` • ${s.slug} ${s.source ? `[${s.source}]` : ''}`);
744
- }
745
- if (groups.custom.length) {
746
- log('');
747
- log('Custom (your own — never auto-modified):');
748
- for (const s of groups.custom) {
749
- log(` • ${s.slug}${s.description ? ` — ${s.description}` : ''}`);
750
- }
751
- }
752
- ok(`list: ${total} skills (baseline=${groups.baseline.length}, remote=${groups.remote.length}, custom=${groups.custom.length})`);
753
- }
754
-
755
- async function runAdd(args) {
756
- const ref = args._[1];
757
- if (!ref || !ref.includes('/')) {
758
- process.stderr.write('Usage: clud-bug add <source/name> (e.g. vercel-labs/skills/next-best-practices)\n');
759
- process.exit(2);
760
- }
761
- // Last segment is the skill name; everything before is the source repo path.
762
- const lastSlash = ref.lastIndexOf('/');
763
- const source = ref.slice(0, lastSlash);
764
- const name = ref.slice(lastSlash + 1);
765
- const skillsDir = join(process.cwd(), '.claude', 'skills');
766
- log(` fetching ${source}/${name} from skills.sh...`);
767
- const client = new SkillsClient();
768
- const entry = await writeSkill(skillsDir, { source, name, kind: 'remote' }, client);
769
- const manifest = await readManifest(skillsDir);
770
- // Mutate in place so caller-set fields on the manifest (pinVersion,
771
- // lastUpdate, lastUpdateVersion) survive the add. Building a fresh
772
- // {version, installed} object would silently drop them.
773
- manifest.installed = [...manifest.installed.filter((e) => e.slug !== entry.slug), entry];
774
- await writeManifest(skillsDir, manifest);
775
- log(` ✓ pinned ${entry.slug} → .claude/skills/${entry.slug}/SKILL.md`);
776
- log(' Commit + push to apply on the next PR.');
777
- ok(`added: .claude/skills/${entry.slug}/SKILL.md`);
778
- }
779
-
780
- async function runRemove(args) {
781
- const slug = args._[1];
782
- if (!slug) {
783
- process.stderr.write('Usage: clud-bug remove <slug> (run `clud-bug list` to see installed slugs)\n');
784
- process.exit(2);
785
- }
786
- const skillsDir = join(process.cwd(), '.claude', 'skills');
787
- const entry = await removeSkill(skillsDir, slug);
788
- log(` ✓ unpinned ${entry.slug}${entry.kind === 'baseline' ? ' (baseline — returns on next init)' : ''}`);
789
- ok(`removed: ${entry.slug}${entry.kind === 'baseline' ? ' (baseline)' : ''}`);
790
- }
791
-
792
- async function runRefresh(args) {
793
- const cwd = process.cwd();
794
- const skillsDir = join(cwd, '.claude', 'skills');
795
- const manifest = await readManifest(skillsDir);
796
- if (manifest.installed.length === 0) {
797
- log('No clud-bug-managed specimens found. Run `clud-bug init` first.');
798
- ok('refreshed: 0 skills installed (run `clud-bug init` first)');
799
- return;
800
- }
801
-
802
- log(' re-surveying habitat...');
803
- const signals = await detect(cwd);
804
- log(` primary language: ${signals.primaryLanguage || '(unknown)'}`);
805
- log(` search terms: ${signals.searchTerms.join(', ') || '(none)'}`);
806
-
807
- const baseline = await loadBaseline(BASELINE_DIR);
808
- let curated = [];
809
- let searched = [];
810
- if (args.offline) {
811
- log(' --offline: skipping skills.sh — only baseline additions will be diffed; existing remote skills are preserved');
812
- } else {
813
- const client = new SkillsClient();
814
- let curatedErr, searchedErr;
815
- [curated, searched] = await Promise.all([
816
- client.curated().catch(err => { curatedErr = err; return []; }),
817
- client.search(signals.searchTerms).catch(err => { searchedErr = err; return []; }),
818
- ]);
819
- if (curatedErr || searchedErr) {
820
- const err = curatedErr || searchedErr;
821
- warn(`skills.sh unreachable (${err.message})`);
822
- warn('refusing to compute removals — an empty API response would look like "delete everything from skills.sh".');
823
- warn('Try again later, or run with --offline to install only baseline updates.');
824
- process.exit(1);
825
- }
826
- }
827
- const recommended = rankAndCap(curated, searched, baseline);
828
- const diff = diffManifest(manifest, recommended);
829
-
830
- // In --offline mode the recommendation set isn't authoritative (we only have
831
- // baseline locally), so any "missing from recommendations" entry is a false
832
- // positive. Suppress removals to avoid mass-deleting the user's remote skills.
833
- if (args.offline) diff.remove = [];
834
-
835
- log('');
836
- log(` add: ${diff.add.length}`);
837
- log(` remove: ${diff.remove.length} (custom skills untouched)`);
838
- log(` unchanged: ${diff.unchanged.length}`);
839
-
840
- if (diff.add.length === 0 && diff.remove.length === 0) {
841
- log('');
842
- log('Collection in sync with skills.sh — nothing to update.');
843
- ok(`refreshed: ${diff.unchanged.length} skills in sync, 0 changes`);
844
- return;
845
- }
846
-
847
- log('');
848
- for (const s of diff.add) log(` + ${s.name} [${s.source || s.kind}]`);
849
- for (const s of diff.remove) log(` - ${s.slug} [${s.source || s.kind}]`);
850
-
851
- if (!args.acceptAll) {
852
- const rl = createInterface({ input, output });
853
- const answer = await rl.question('\nApply these changes? [y/N] ');
854
- rl.close();
855
- if (answer.trim().toLowerCase() !== 'y') {
856
- log('Aborted. No files changed.');
857
- return;
858
- }
859
- }
860
-
861
- const client = new SkillsClient();
862
- if (diff.add.length) await writeSkills(skillsDir, diff.add, client);
863
- for (const entry of diff.remove) await removeSkill(skillsDir, entry.slug);
864
- log(' ✓ collection updated. Commit + push to apply on the next PR.');
865
- ok(`refreshed: +${diff.add.length} -${diff.remove.length} (${diff.unchanged.length} unchanged)`);
866
- }
867
-
868
- async function runEditWorkflow(_args) {
869
- const cwd = process.cwd();
870
-
871
- // Validate: must have pending changes, all scoped to clud-bug workflow files.
872
- let pending;
873
- try {
874
- pending = getPendingWorkflowEdits(cwd);
875
- } catch (err) {
876
- process.stderr.write(`clud-bug edit-workflow: ${err.message}\n`);
877
- process.exit(2);
878
- }
879
-
880
- if (pending.files.length === 0) {
881
- log('Nothing to commit. Edit your .github/workflows/clud-bug-*.yml file(s) first, then re-run.');
882
- ok('branch: (none — no pending workflow edits)');
883
- return;
884
- }
885
- if (!pending.allWorkflow) {
886
- process.stderr.write(`clud-bug edit-workflow: working tree contains non-workflow changes:\n`);
887
- for (const f of pending.nonWorkflow) process.stderr.write(` ${f}\n`);
888
- process.stderr.write(`\nThis command is for isolated workflow-only PRs. Stash or commit the\nnon-workflow changes elsewhere first, then re-run.\n`);
889
- process.exit(2);
890
- }
891
-
892
- log('🐛 Preparing an isolated PR for your workflow edit.');
893
- const branch = makeBranchName();
894
- log(` branch: ${branch} (rooted at origin/main)`);
895
- for (const f of pending.files) log(` • ${f}`);
896
-
897
- // Stash the pending workflow changes, branch from origin/main explicitly
898
- // (NOT from HEAD — if the user is on a feature branch with unrelated
899
- // commits, those would otherwise leak into the "isolated" PR), then
900
- // restore the changes onto the new branch and commit.
901
- gitCmd(cwd, ['stash', 'push', '--include-untracked', '-m', 'clud-bug edit-workflow']);
902
- try {
903
- gitCmd(cwd, ['fetch', 'origin', 'main', '--depth=1']);
904
- gitCmd(cwd, ['checkout', '-b', branch, 'origin/main']);
905
- } catch (err) {
906
- // Restore the user's stash before bubbling up.
907
- gitCmd(cwd, ['stash', 'pop'], { allowFail: true });
908
- throw err;
909
- }
910
- const popped = gitCmd(cwd, ['stash', 'pop'], { allowFail: true });
911
- if (!popped.ok) {
912
- 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`);
913
- process.exit(1);
914
- }
915
- gitCmd(cwd, ['add', ...pending.files]);
916
- gitCmd(cwd, ['commit', '-m', 'Edit clud-bug workflow']);
917
- gitCmd(cwd, ['push', '-u', 'origin', branch]);
918
-
919
- log('');
920
- log('Done. Open the PR:');
921
- 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."`);
922
- ok(`branch: ${branch} (${pending.files.length} file${pending.files.length === 1 ? '' : 's'})`);
923
- }
924
-
925
- async function runUpdateCmd(_args) {
926
- const cwd = process.cwd();
927
- const ourVersion = await readPkgVersion();
928
- log(`🐛 Refreshing the field kit (${ourVersion}).`);
929
-
930
- const result = await runUpdate({
931
- cwd,
932
- templatesDir: TEMPLATES,
933
- baselineDir: BASELINE_DIR,
934
- ourVersion,
935
- });
936
-
937
- if (result.missing === 'init') {
938
- log(' No clud-bug installation detected. Run `clud-bug init` first.');
939
- ok('updated: 0 changes (no clud-bug install detected)');
940
- return;
941
- }
942
-
943
- const skipped = result.skipped ?? [];
944
-
945
- if (result.changed.length === 0 && skipped.length === 0) {
946
- log(' Already current. Nothing to update.');
947
- ok(`updated: @v${ourVersion}, 0 changes`);
948
- return;
949
- }
950
-
951
- if (result.changed.length > 0) {
952
- log(` ✓ Updated ${result.changed.length} file${result.changed.length === 1 ? '' : 's'}:`);
953
- for (const c of result.changed) {
954
- const versionNote = c.from && c.to && c.from !== c.to ? ` (${c.label}, ${c.from} → ${c.to})` : ` (${c.label})`;
955
- log(` • ${rel(cwd, c.path)}${versionNote}`);
956
- }
957
- }
958
- if (result.unchanged.length > 0) {
959
- log(` ${result.unchanged.length} file${result.unchanged.length === 1 ? ' was' : 's were'} already current.`);
960
- }
961
- if (skipped.length > 0) {
962
- log('');
963
- log(` ! Skipped ${skipped.length} markerless file${skipped.length === 1 ? '' : 's'} (treated as user-customized):`);
964
- for (const s of skipped) log(` • ${rel(cwd, s.path)} — ${s.reason}`);
965
- }
966
- log('');
967
- log('Commit + push to apply the refreshed kit on the next PR.');
968
- ok(`updated: @v${ourVersion}, ${result.changed.length} changed, ${result.unchanged.length} unchanged${skipped.length ? `, ${skipped.length} skipped` : ''}`);
969
- }
970
-
971
- async function runAudit(args) {
972
- const cwd = process.cwd();
973
- const date = new Date().toISOString().slice(0, 10);
974
-
975
- let scopeLabel;
976
- if (args.since) scopeLabel = `commits since ${args.since}`;
977
- else if (args.changedIn) scopeLabel = `files changed in the past ${args.changedIn}`;
978
- else if (args.scopes.length) scopeLabel = `glob ${args.scopes.join(', ')}`;
979
- else scopeLabel = 'all tracked files';
980
-
981
- log(`🐛 Audit walk in ${cwd}.`);
982
- log(` scope: ${scopeLabel}`);
983
-
984
- let files;
985
- try {
986
- files = computeAuditFileSet({
987
- cwd,
988
- since: args.since,
989
- changedIn: args.changedIn,
990
- scopes: args.scopes,
991
- });
992
- } catch (err) {
993
- process.stderr.write(`clud-bug audit: ${err.message}\n`);
994
- process.exit(2);
995
- }
996
- log(` surveyed: ${files.length} file${files.length === 1 ? '' : 's'}`);
997
-
998
- if (files.length === 0) {
999
- log(' Nothing in scope. Try widening --scope or --changed-in.');
1000
- ok(`audit: 0 files in scope`);
1001
- return;
1002
- }
1003
-
1004
- const outPath = args.out || join(cwd, 'audits', `${date}.md`);
1005
- await mkdir(dirname(outPath), { recursive: true });
1006
- await writeFile(outPath, renderAuditHeader({ date, scopeLabel, files }));
1007
- log(` ✓ wrote stub: ${rel(cwd, outPath)}`);
1008
- log('');
1009
- log('Stub is empty findings — populated by the GitHub Action.');
1010
- log('Run locally without the workflow if you want — Clud Bug review needs the action runner + ANTHROPIC_API_KEY.');
1011
- ok(`audit: ${files.length} file${files.length === 1 ? '' : 's'} surveyed; stub at ${rel(cwd, outPath)}`);
1012
- }
1013
-
1014
- // 0.0.M.1 (v0.6.13): Q7-clud-bug $/LOC dashboard.
1015
- //
1016
- // Reads recent clud-bug-review run JSON via `gh run list` + per-job logs
1017
- // (which contain the SDK result messages with token counts + model),
1018
- // joins to `gh pr view --json additions,deletions` for the LOC denominator,
1019
- // and reports the rollup. Internal-only — not consumer-facing.
1020
- //
1021
- // Default scope: 30 days, all repos with clud-bug-review.yml in the gh
1022
- // user's auth scope. --repo / --pr / --since / --limit narrow.
1023
- async function runUsage(args) {
1024
- // v0.6.28 — `clud-bug usage --health`: deterministic skill-health
1025
- // dashboard. Reads `.claude/skills/.clud-bug.json` usage block,
1026
- // applies thresholds, renders read-only table. No automation acts
1027
- // on the output. Per the pragmatic SkDD pivot (2026-05-30).
1028
- if (args.health) {
1029
- return runUsageHealth(args);
1030
- }
1031
-
1032
- const limit = args.limit ?? 50;
1033
- const since = args.since ?? '30d';
1034
-
1035
- // Determine target repos. If --repo specified, just that one. Otherwise
1036
- // discover repos via the local gh user's auth scope (the org's repos we
1037
- // own clud-bug-review on).
1038
- const repos = args.repo
1039
- ? [args.repo]
1040
- : await discoverConsumingRepos();
1041
-
1042
- if (repos.length === 0) {
1043
- process.stderr.write(
1044
- 'clud-bug usage: no repos with clud-bug-review.yml found in your gh scope.\n' +
1045
- 'Pass --repo <owner/name> to point at a specific repo.\n'
1046
- );
1047
- process.exit(2);
1048
- }
1049
-
1050
- // Per-repo: list recent clud-bug-review runs + extract the per-run job
1051
- // logs + per-PR LOC counts. Filter to PR runs (drop schedule/dispatch).
1052
- // PR #104 fix: --pr filter must be applied AFTER resolvePrNumber
1053
- // (we don't have the PR # until then). prFilter on listRecentRuns was
1054
- // promised but never applied — bug caught by clud-bug self-review.
1055
- const reviews = [];
1056
- for (const repo of repos) {
1057
- const runs = await listRecentRuns(repo, limit, since, args.pr);
1058
- if (process.env.CLUD_BUG_DEBUG) process.stderr.write(`DBG: ${repo} runs=${runs.length}\n`);
1059
- for (const run of runs) {
1060
- const review = await fetchReviewRecord(repo, run);
1061
- if (process.env.CLUD_BUG_DEBUG) process.stderr.write(`DBG: ${run.databaseId} ${run.conclusion} → ${review ? 'OK' : 'NULL'}\n`);
1062
- if (!review) continue;
1063
- // --pr filter: drop reviews whose PR doesn't match.
1064
- if (args.pr != null && review.pr !== args.pr) continue;
1065
- reviews.push(review);
1066
- }
1067
- }
1068
-
1069
- if (reviews.length === 0) {
1070
- process.stderr.write(
1071
- `clud-bug usage: no clud-bug-review runs found in scope.\n` +
1072
- ` scope: ${repos.length} repo${repos.length === 1 ? '' : 's'}, last ${since}, limit ${limit}.\n`
1073
- );
1074
- process.exit(2);
1075
- }
1076
-
1077
- const summary = rollup(reviews);
1078
- process.stdout.write(formatRollup(summary, { json: args.json }));
1079
- if (!args.json) {
1080
- ok(`usage: ${reviews.length} review${reviews.length === 1 ? '' : 's'} across ${repos.length} repo${repos.length === 1 ? '' : 's'}`);
1081
- }
1082
- }
1083
-
1084
- // `gh repo list` won't filter by workflow file content, so we iterate
1085
- // repos the user has access to and probe for clud-bug-review.yml. We
1086
- // v0.6.28 — `clud-bug usage --health` implementation. Reads the local
1087
- // .claude/skills/.clud-bug.json usage block, applies deterministic
1088
- // thresholds, renders a read-only dashboard. No I/O beyond the JSON
1089
- // read.
1090
- //
1091
- // v0.6.30 — read accumulated usage from workflow artifacts (uploaded
1092
- // by v0.6.29's post-step). Defaults to artifact mode when --repo is
1093
- // passed OR an `owner/name` can be inferred from `git remote`. Falls
1094
- // back to the local-file path otherwise. The `--no-artifacts` flag
1095
- // forces the v0.6.28 local-only behavior (handy for tests + offline).
1096
- async function runUsageHealth(args) {
1097
- const { assessSkillHealth, formatHealthDashboard } = await import('../lib/skill-usage.js');
1098
-
1099
- // Decide read source. Priority: explicit --no-artifacts → local;
1100
- // explicit --repo OR inferred owner/repo → artifacts; else local.
1101
- const wantArtifacts = args.artifacts !== false;
1102
- let ownerRepo = null;
1103
- if (wantArtifacts) {
1104
- ownerRepo = args.repo || await inferOwnerRepoFromGit();
1105
- }
1106
-
1107
- let usage;
1108
- let source;
1109
- if (wantArtifacts && ownerRepo) {
1110
- const result = await loadUsageFromArtifacts(ownerRepo, args);
1111
- if (result) {
1112
- usage = result.usage;
1113
- source = `${result.artifactCount} artifact${result.artifactCount === 1 ? '' : 's'} from ${ownerRepo}`;
1114
- }
1115
- }
1116
-
1117
- // Fallback to local .clud-bug.json (v0.6.28 behavior).
1118
- if (usage == null) {
1119
- const localResult = await loadUsageFromLocalFile();
1120
- if (localResult == null) {
1121
- // Both paths failed. The local helper has already written its
1122
- // own stderr explanation; we just exit.
1123
- process.exit(1);
1124
- }
1125
- usage = localResult;
1126
- source = `local .clud-bug.json`;
1127
- }
1128
-
1129
- const rows = assessSkillHealth(usage, new Date());
1130
- process.stdout.write(formatHealthDashboard(rows) + '\n');
1131
-
1132
- // Exit code semantics: 0 (informational). The dashboard is read-only;
1133
- // archive-candidates being present is NOT a failure mode — humans
1134
- // decide. CI gates should NOT block on this.
1135
- ok(`skill health: ${rows.length} skill${rows.length === 1 ? '' : 's'} tracked (source: ${source})`);
1136
- }
1137
-
1138
- // Helpers split out from runUsageHealth so the two read paths are
1139
- // independently testable + composable in future commands.
1140
-
1141
- async function loadUsageFromArtifacts(ownerRepo, args) {
1142
- const { fetchUsageArtifacts, aggregateUsageStream } = await import('../lib/skill-usage.js');
1143
- const [owner, repo] = ownerRepo.split('/');
1144
- if (!owner || !repo) {
1145
- process.stderr.write(`clud-bug usage --health: --repo must be in owner/name form, got "${ownerRepo}".\n`);
1146
- return null;
1147
- }
1148
- const since = parseSinceArg(args.since);
1149
- let artifacts;
1150
- try {
1151
- artifacts = await fetchUsageArtifacts({ owner, repo, since });
1152
- } catch (err) {
1153
- process.stderr.write(`::notice::clud-bug usage --health: artifact fetch failed (${err.message}) — falling back to local .clud-bug.json\n`);
1154
- return null;
1155
- }
1156
- if (artifacts.length === 0) {
1157
- process.stderr.write(`::notice::clud-bug usage --health: no skill-usage artifacts found in ${ownerRepo} — falling back to local .clud-bug.json\n`);
1158
- return null;
1159
- }
1160
- return {
1161
- usage: aggregateUsageStream(artifacts),
1162
- artifactCount: artifacts.length,
1163
- };
1164
- }
1165
-
1166
- async function loadUsageFromLocalFile() {
1167
- const fs = await import('node:fs/promises');
1168
- const path = await import('node:path');
1169
- const jsonPath = path.resolve(process.cwd(), '.claude', 'skills', '.clud-bug.json');
1170
- try {
1171
- const raw = await fs.readFile(jsonPath, 'utf-8');
1172
- const parsed = JSON.parse(raw);
1173
- return (parsed && parsed.usage) ? parsed.usage : {};
1174
- } catch (err) {
1175
- if (err.code === 'ENOENT') {
1176
- process.stderr.write(
1177
- `clud-bug usage --health: no .claude/skills/.clud-bug.json found in ${process.cwd()}.\n` +
1178
- `Run \`npx clud-bug init\` first OR pass --repo owner/name to read from workflow artifacts.\n`
1179
- );
1180
- return null;
1181
- }
1182
- process.stderr.write(`clud-bug usage --health: failed to parse .clud-bug.json: ${err.message}\n`);
1183
- return null;
1184
- }
1185
- }
1186
-
1187
- async function inferOwnerRepoFromGit() {
1188
- // `gh repo view --json nameWithOwner` reads the current dir's git
1189
- // remote AND respects gh's config. Returns null on non-git dirs.
1190
- const result = await ghJson(['repo', 'view', '--json', 'nameWithOwner']);
1191
- return result && result.nameWithOwner ? result.nameWithOwner : null;
1192
- }
1193
-
1194
- function parseSinceArg(since) {
1195
- if (!since) return null;
1196
- if (since instanceof Date) return since;
1197
- const m = String(since).match(/^(\d+)([dwmy])$/);
1198
- if (!m) return null;
1199
- const n = Number(m[1]);
1200
- const unitMs = { d: 86400e3, w: 7 * 86400e3, m: 30 * 86400e3, y: 365 * 86400e3 }[m[2]];
1201
- return new Date(Date.now() - n * unitMs);
1202
- }
1203
-
1204
- // limit to 100 to avoid pagination explosions.
1205
- async function discoverConsumingRepos() {
1206
- const list = await ghJson(['repo', 'list', '--limit', '100', '--json', 'nameWithOwner']);
1207
- if (!Array.isArray(list)) return [];
1208
- const owners = list.map((e) => e.nameWithOwner);
1209
- const found = [];
1210
- for (const ownerRepo of owners) {
1211
- const probe = await gh(['api', `repos/${ownerRepo}/contents/.github/workflows/clud-bug-review.yml`, '-q', '.size']);
1212
- if (probe.code === 0 && probe.stdout.trim().length > 0) {
1213
- found.push(ownerRepo);
1214
- }
1215
- }
1216
- return found;
1217
- }
1218
-
1219
- // List recent clud-bug-review.yml runs in a repo. Filters to PR events
1220
- // (drops schedule, workflow_dispatch — those have no PR LOC denominator).
1221
- //
1222
- // IMPORTANT (Q7 measurement integrity, fixed during PR #104 review):
1223
- // We INCLUDE conclusion === 'failure' runs because Anthropic bills for
1224
- // tokens regardless of GitHub workflow conclusion. A run that hit the
1225
- // spend cap, errored mid-action, or failed strict-mode still incurred
1226
- // real API cost — silently excluding it would underreport spend and
1227
- // fool the Q7-clud-bug "gradient must point down" gate.
1228
- // extractTokensFromLog() returns ok:false on logs without usable token
1229
- // totals, which gracefully skips the cancelled/errored-too-early case
1230
- // without losing accountability for the partially-billed runs.
1231
- async function listRecentRuns(repo, limit, since, prFilter) {
1232
- const sinceDate = since.match(/^\d+[dwmy]$/) ? dateAgo(since) : null;
1233
- const args = [
1234
- 'run', 'list', '-R', repo,
1235
- '--workflow', 'clud-bug-review.yml',
1236
- '--limit', String(limit),
1237
- '--json', 'databaseId,headSha,createdAt,event,status,conclusion',
1238
- ];
1239
- if (sinceDate) args.push('--created', `>=${sinceDate}`);
1240
- const runs = await ghJson(args);
1241
- if (!Array.isArray(runs)) return [];
1242
- return runs
1243
- .filter((r) => r.event === 'pull_request' && (r.conclusion === 'success' || r.conclusion === 'failure'))
1244
- .map((r) => ({ ...r, repo }))
1245
- .slice(0, limit);
1246
- }
1247
-
1248
- async function fetchReviewRecord(repo, run) {
1249
- // Find the clud-bug-review JOB id within the run.
1250
- const jobs = await ghJson(['api', `repos/${repo}/actions/runs/${run.databaseId}/jobs`, '-q', '.jobs']);
1251
- if (!Array.isArray(jobs)) return null;
1252
- const job = jobs.find((j) => j.name === 'clud-bug-review');
1253
- if (!job) return null;
1254
-
1255
- // Fetch the job's log dump. May be large.
1256
- const logs = await gh(['api', `repos/${repo}/actions/jobs/${job.id}/logs`]);
1257
- if (logs.code !== 0) return null;
1258
-
1259
- // Extract tokens + model from the SDK result-message JSON in the log.
1260
- const extracted = extractTokensFromLog(logs.stdout);
1261
- if (!extracted.ok) return null;
1262
-
1263
- // Resolve the PR number from the run's pull_requests array or by SHA.
1264
- const prNumber = await resolvePrNumber(repo, run);
1265
- if (!prNumber) return null;
1266
-
1267
- // Pull LOC denominator from the PR.
1268
- const prMeta = await ghJson(['pr', 'view', String(prNumber), '-R', repo, '--json', 'additions,deletions,number']);
1269
- if (!prMeta || typeof prMeta.additions !== 'number') return null;
1270
-
1271
- const tokens = extracted.tokens;
1272
- const model = extracted.model;
1273
- const costInfo = computeReviewCost(tokens, model);
1274
- return {
1275
- repo,
1276
- pr: prNumber,
1277
- createdAt: run.createdAt,
1278
- model: costInfo.model, // normalized (PRICING key)
1279
- modelObserved: model, // raw value from log (may be versioned)
1280
- unknownModel: costInfo.unknownModel, // PR #104 fix: surface for dashboard warn
1281
- tokens,
1282
- additions: prMeta.additions,
1283
- deletions: prMeta.deletions,
1284
- cost: costInfo.total,
1285
- costPerLOC: costPerLOC(costInfo.total, prMeta.additions, prMeta.deletions),
1286
- cacheRate: cacheHitRate(tokens),
1287
- };
1288
- }
1289
-
1290
- async function resolvePrNumber(repo, run) {
1291
- // gh's run JSON sometimes carries a `pull_requests` array; if not (or
1292
- // if it's empty because the PR has been merged), look up via the
1293
- // commits/{sha}/pulls endpoint, which includes merged/closed PRs.
1294
- const detail = await ghJson(['api', `repos/${repo}/actions/runs/${run.databaseId}`, '-q', '.pull_requests']);
1295
- if (Array.isArray(detail) && detail[0]?.number) return detail[0].number;
1296
- // commits/{sha}/pulls returns PRs that contain the commit — works for
1297
- // open AND merged/closed PRs. The default `gh pr list -S <sha>` does
1298
- // not search closed PRs and silently returns empty for the merged
1299
- // case, which made every $/LOC lookup fail on historical PRs.
1300
- const pulls = await ghJson(['api', `repos/${repo}/commits/${run.headSha}/pulls`, '-q', '[.[].number]']);
1301
- if (Array.isArray(pulls) && pulls.length > 0) return pulls[0];
1302
- return null;
1303
- }
1304
-
1305
- function dateAgo(spec) {
1306
- // spec like "30d", "2w", "1m", "1y" → ISO date N units ago.
1307
- const m = spec.match(/^(\d+)([dwmy])$/);
1308
- if (!m) return null;
1309
- const n = Number(m[1]);
1310
- const unit = m[2];
1311
- const day = 24 * 60 * 60 * 1000;
1312
- const ms = n * (unit === 'd' ? day : unit === 'w' ? 7 * day : unit === 'm' ? 30 * day : 365 * day);
1313
- return new Date(Date.now() - ms).toISOString().slice(0, 10);
1314
- }
1315
-
1316
- // gh helpers (reuse pattern from lib/branch-protection.js so callers can
1317
- // stub `gh` in tests if they want — but for now spawn directly).
1318
- function gh(args) {
1319
- return new Promise((resolve) => {
1320
- const child = spawn('gh', args, { stdio: ['ignore', 'pipe', 'pipe'] });
1321
- let stdout = '';
1322
- let stderr = '';
1323
- child.stdout.on('data', (d) => { stdout += d; });
1324
- child.stderr.on('data', (d) => { stderr += d; });
1325
- child.on('error', () => resolve({ code: 1, stdout: '', stderr: 'gh not on PATH' }));
1326
- child.on('close', (code) => resolve({ code, stdout, stderr }));
1327
- });
1328
- }
1329
-
1330
- async function ghJson(args) {
1331
- const { code, stdout } = await gh(args);
1332
- if (code !== 0) return null;
1333
- try { return JSON.parse(stdout); } catch { return null; }
1334
- }
1335
-
1336
- function rel(from, to) {
1337
- return to.startsWith(from + '/') ? to.slice(from.length + 1) : to;
1338
- }
1339
-
1340
- // Quiet-mode mechanism (v0.6.7+):
1341
- // - Default: log() emits progress to stdout (today's behavior).
1342
- // - When CLUD_BUG_QUIET=1 OR --quiet/-q is passed: log() is suppressed.
1343
- // ok() ALWAYS emits its single-line summary so agents get positive
1344
- // confirmation with a chainable key-value (commit SHA, file count,
1345
- // branch name) regardless of quiet state.
1346
- // - warn() / die() emit unconditionally — quiet must not silence real
1347
- // problems.
1348
- let QUIET = process.env.CLUD_BUG_QUIET === '1';
1349
- function setQuiet(flag) { QUIET = !!flag; }
1350
- function log(msg) { if (!QUIET) process.stdout.write(msg + '\n'); }
1351
- function ok(msg) { process.stdout.write('ok ' + msg + '\n'); }
1352
- function warn(msg) { process.stderr.write(` ! ${msg}\n`); }
1353
-
1354
- main().catch(err => {
2
+ // v0.7.0+ entry shim delegates to the compiled CLI at dist/cli/main.js
3
+ // (re-exported through dist/cli/index.js). See plan §"Phase 2 execution
4
+ // detail" Risk R7 for the deferral rationale: the legacy bin file was
5
+ // 1359 LOC of dynamically-typed command dispatch; converting it to
6
+ // strict-mode TS would have cost 3-4h of type-annotation churn with
7
+ // little semantic gain. Shimming to dist/ keeps the entry surface a
8
+ // 3-liner while letting the dispatch live under src/ alongside everything
9
+ // else compiled by tsc.
10
+ import { main } from '../dist/cli/index.js';
11
+ main().catch((err) => {
1355
12
  process.stderr.write(`clud-bug: ${err.message}\n`);
1356
13
  process.exit(1);
1357
14
  });