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