clud-bug 0.2.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +88 -9
- package/bin/clud-bug.js +243 -38
- package/lib/audit.js +111 -0
- package/lib/edit-workflow.js +47 -0
- package/lib/skills.js +111 -3
- package/lib/update.js +99 -0
- package/package.json +2 -4
- package/templates/audit.yml.tmpl +134 -0
- package/templates/self-update.yml.tmpl +92 -0
- package/templates/workflow-py.yml.tmpl +89 -4
- package/templates/workflow-ts.yml.tmpl +89 -4
- package/templates/workflow.yml.tmpl +133 -4
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
// Validates that the working tree's pending changes are scoped to
|
|
4
|
+
// .github/workflows/clud-bug-*.yml. If yes, creates a branch, commits,
|
|
5
|
+
// pushes, and prints the gh command for opening a PR. Mixed changes are
|
|
6
|
+
// refused — the whole point is to keep workflow edits in an isolated PR
|
|
7
|
+
// so claude-code-action's workflow-self-mod guard doesn't bundle them
|
|
8
|
+
// with other reviewable work.
|
|
9
|
+
|
|
10
|
+
export function getPendingWorkflowEdits(cwd = process.cwd()) {
|
|
11
|
+
// --untracked-files=all so new files in new directories are reported as
|
|
12
|
+
// individual paths instead of being collapsed to the parent dir (default
|
|
13
|
+
// 'normal' mode would emit '.github/' for a brand-new clud-bug-review.yml).
|
|
14
|
+
const r = spawnSync('git', ['status', '--porcelain', '--untracked-files=all'], { cwd, encoding: 'utf8' });
|
|
15
|
+
if (r.status !== 0) {
|
|
16
|
+
throw new Error(`git status failed: ${r.stderr.trim()}`);
|
|
17
|
+
}
|
|
18
|
+
const lines = r.stdout.split('\n').filter(Boolean);
|
|
19
|
+
if (lines.length === 0) return { allWorkflow: true, files: [], nonWorkflow: [] };
|
|
20
|
+
|
|
21
|
+
const files = [];
|
|
22
|
+
const nonWorkflow = [];
|
|
23
|
+
for (const line of lines) {
|
|
24
|
+
// porcelain format: XY <space> <path> ; rename: XY old -> new
|
|
25
|
+
const path = line.slice(3).split(' -> ').pop().trim();
|
|
26
|
+
files.push(path);
|
|
27
|
+
if (!isWorkflowFile(path)) nonWorkflow.push(path);
|
|
28
|
+
}
|
|
29
|
+
return { allWorkflow: nonWorkflow.length === 0, files, nonWorkflow };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function isWorkflowFile(path) {
|
|
33
|
+
return /^\.github\/workflows\/clud-bug-.*\.ya?ml$/.test(path);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function makeBranchName(date = new Date()) {
|
|
37
|
+
const stamp = date.toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
38
|
+
return `clud-bug/edit-workflow-${stamp}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function git(cwd, args, opts = {}) {
|
|
42
|
+
const r = spawnSync('git', args, { cwd, encoding: 'utf8' });
|
|
43
|
+
if (r.status !== 0 && !opts.allowFail) {
|
|
44
|
+
throw new Error(`git ${args.join(' ')} failed (${r.status}): ${r.stderr.trim()}`);
|
|
45
|
+
}
|
|
46
|
+
return { ok: r.status === 0, stdout: r.stdout.trim(), stderr: r.stderr.trim() };
|
|
47
|
+
}
|
package/lib/skills.js
CHANGED
|
@@ -1,11 +1,26 @@
|
|
|
1
1
|
import { mkdir, writeFile, readdir, readFile, rm, stat } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
3
5
|
|
|
4
6
|
const API_BASE = 'https://skills.sh/api/v1';
|
|
5
7
|
const MAX_SKILLS = 8;
|
|
6
8
|
const MANIFEST_FILE = '.clud-bug.json';
|
|
7
9
|
const MANIFEST_VERSION = 1;
|
|
8
10
|
|
|
11
|
+
// Canonical home for clud-bug's baseline skills.
|
|
12
|
+
// PINNED TO A COMMIT SHA, NOT `main`. This re-couples the trust boundary
|
|
13
|
+
// to clud-bug releases: a compromised commit on agent-skills@main cannot
|
|
14
|
+
// silently land in users' Claude review skills mid-cycle. To roll new
|
|
15
|
+
// skill content, bump BASELINE_SKILLS_REF below in the same clud-bug PR
|
|
16
|
+
// that ships the corresponding bundled fallback update.
|
|
17
|
+
// See thrillmot/agent-skills — skills.sh `skills/<name>/SKILL.md` layout.
|
|
18
|
+
const BASELINE_SKILLS_REF = '977e439ec861860351239ed89dd56edcd48cbf6b';
|
|
19
|
+
const AGENT_SKILLS_BASE = process.env.CLUD_BUG_AGENT_SKILLS_BASE
|
|
20
|
+
?? `https://raw.githubusercontent.com/thrillmot/agent-skills/${BASELINE_SKILLS_REF}/skills`;
|
|
21
|
+
const SKILL_FETCH_TIMEOUT_MS = 5000;
|
|
22
|
+
const SKILL_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24h
|
|
23
|
+
|
|
9
24
|
export class SkillsClient {
|
|
10
25
|
constructor({ fetch = globalThis.fetch, base, userAgent = 'clud-bug' } = {}) {
|
|
11
26
|
this.fetch = fetch;
|
|
@@ -78,7 +93,34 @@ export function rankAndCap(curated, searched, baseline, cap = MAX_SKILLS) {
|
|
|
78
93
|
return out;
|
|
79
94
|
}
|
|
80
95
|
|
|
81
|
-
|
|
96
|
+
// Loads the baseline skills, preferring the pinned thrillmot/agent-skills
|
|
97
|
+
// commit and falling back to the bundled npm-package copy on any fetch failure.
|
|
98
|
+
// Returns the same shape as before, plus a `_source` of either 'agent-skills'
|
|
99
|
+
// or 'bundled' so the CLI can report which path was used.
|
|
100
|
+
//
|
|
101
|
+
// Options:
|
|
102
|
+
// - fetch — injectable for tests (defaults to globalThis.fetch)
|
|
103
|
+
// - cacheDir — where to cache fetched SKILL.md files (defaults to
|
|
104
|
+
// ~/.cache/clud-bug/skills/, skipped if null)
|
|
105
|
+
export async function loadBaseline(baselineDir, opts = {}) {
|
|
106
|
+
const fetchImpl = opts.fetch ?? globalThis.fetch;
|
|
107
|
+
const cacheDir = opts.cacheDir === null ? null
|
|
108
|
+
: (opts.cacheDir ?? join(homedir(), '.cache', 'clud-bug', 'skills'));
|
|
109
|
+
|
|
110
|
+
// First, enumerate the bundled baseline skills (source of truth for which
|
|
111
|
+
// names exist). Then fetch each in parallel — sequential awaits would
|
|
112
|
+
// stack timeouts (3 baselines × 5s = 15s before fallback when offline).
|
|
113
|
+
const bundled = await readBundled(baselineDir);
|
|
114
|
+
const remotes = await Promise.all(
|
|
115
|
+
bundled.map((s) => tryFetchSkill(s.name, fetchImpl, cacheDir)),
|
|
116
|
+
);
|
|
117
|
+
return bundled.map((skill, i) => remotes[i]
|
|
118
|
+
? { ...skill, content: remotes[i], _source: 'agent-skills' }
|
|
119
|
+
: { ...skill, _source: 'bundled' });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Reads the bundled baseline from the npm-package directory.
|
|
123
|
+
async function readBundled(baselineDir) {
|
|
82
124
|
const skills = [];
|
|
83
125
|
let entries;
|
|
84
126
|
try {
|
|
@@ -92,7 +134,7 @@ export async function loadBaseline(baselineDir) {
|
|
|
92
134
|
skills.push({
|
|
93
135
|
source: 'clud-bug-baseline',
|
|
94
136
|
name: entry.name.replace(/\.md$/, ''),
|
|
95
|
-
description: '(
|
|
137
|
+
description: '(baseline)',
|
|
96
138
|
installs: 0,
|
|
97
139
|
kind: 'baseline',
|
|
98
140
|
content,
|
|
@@ -101,6 +143,65 @@ export async function loadBaseline(baselineDir) {
|
|
|
101
143
|
return skills;
|
|
102
144
|
}
|
|
103
145
|
|
|
146
|
+
// Try to read from cache, then fall back to network. Returns the SKILL.md
|
|
147
|
+
// content string on success, null on any failure (caller falls back to bundled).
|
|
148
|
+
async function tryFetchSkill(name, fetchImpl, cacheDir) {
|
|
149
|
+
// Cache lookup first.
|
|
150
|
+
if (cacheDir) {
|
|
151
|
+
const cached = await readFromCache(cacheDir, name);
|
|
152
|
+
if (cached !== null) return cached;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Network fetch with timeout covering BOTH the connection AND the body
|
|
156
|
+
// read (clearTimeout in finally guarantees the timer doesn't keep the
|
|
157
|
+
// event loop alive for up to 5s past a failed CLI run).
|
|
158
|
+
const url = `${AGENT_SKILLS_BASE}/${encodeURIComponent(name)}/SKILL.md`;
|
|
159
|
+
const ctrl = new AbortController();
|
|
160
|
+
const timer = setTimeout(() => ctrl.abort(), SKILL_FETCH_TIMEOUT_MS);
|
|
161
|
+
try {
|
|
162
|
+
const res = await fetchImpl(url, { signal: ctrl.signal });
|
|
163
|
+
if (!res.ok) return null;
|
|
164
|
+
const content = await res.text();
|
|
165
|
+
if (!content || !content.trim()) return null;
|
|
166
|
+
if (cacheDir) await writeToCache(cacheDir, name, content);
|
|
167
|
+
return content;
|
|
168
|
+
} catch {
|
|
169
|
+
return null;
|
|
170
|
+
} finally {
|
|
171
|
+
clearTimeout(timer);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function readFromCache(cacheDir, name) {
|
|
176
|
+
const path = cachePath(cacheDir, name);
|
|
177
|
+
try {
|
|
178
|
+
const st = await stat(path);
|
|
179
|
+
if (Date.now() - st.mtimeMs > SKILL_CACHE_TTL_MS) return null;
|
|
180
|
+
return await readFile(path, 'utf8');
|
|
181
|
+
} catch {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function writeToCache(cacheDir, name, content) {
|
|
187
|
+
try {
|
|
188
|
+
await mkdir(cacheDir, { recursive: true });
|
|
189
|
+
await writeFile(cachePath(cacheDir, name), content);
|
|
190
|
+
} catch {
|
|
191
|
+
// Cache write failures are non-fatal — we already have the content.
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function cachePath(cacheDir, name) {
|
|
196
|
+
// Include AGENT_SKILLS_BASE in the hash so different upstream URLs (e.g.
|
|
197
|
+
// a fork via CLUD_BUG_AGENT_SKILLS_BASE, or a different pinned SHA after
|
|
198
|
+
// a clud-bug release) get different cache entries. Otherwise switching
|
|
199
|
+
// bases would silently return the previously-cached content from a
|
|
200
|
+
// different upstream — cross-base cache poisoning.
|
|
201
|
+
const hash = createHash('sha256').update(`${AGENT_SKILLS_BASE}\n${name}`).digest('hex').slice(0, 16);
|
|
202
|
+
return join(cacheDir, `${hash}.md`);
|
|
203
|
+
}
|
|
204
|
+
|
|
104
205
|
export async function writeSkills(targetDir, skills, client) {
|
|
105
206
|
await mkdir(targetDir, { recursive: true });
|
|
106
207
|
const written = [];
|
|
@@ -133,6 +234,7 @@ export async function readManifest(targetDir) {
|
|
|
133
234
|
const text = await readFile(join(targetDir, MANIFEST_FILE), 'utf8');
|
|
134
235
|
const data = JSON.parse(text);
|
|
135
236
|
return {
|
|
237
|
+
...data,
|
|
136
238
|
version: data.version || MANIFEST_VERSION,
|
|
137
239
|
installed: Array.isArray(data.installed) ? data.installed : [],
|
|
138
240
|
};
|
|
@@ -143,7 +245,10 @@ export async function readManifest(targetDir) {
|
|
|
143
245
|
|
|
144
246
|
export async function writeManifest(targetDir, manifest) {
|
|
145
247
|
await mkdir(targetDir, { recursive: true });
|
|
248
|
+
// Preserve any additional fields callers want to stamp (e.g. lastUpdate,
|
|
249
|
+
// lastUpdateVersion, pinVersion). Only `version` and `installed` are normalized.
|
|
146
250
|
const out = {
|
|
251
|
+
...manifest,
|
|
147
252
|
version: manifest.version || MANIFEST_VERSION,
|
|
148
253
|
installed: manifest.installed || [],
|
|
149
254
|
};
|
|
@@ -158,7 +263,10 @@ export function mergeManifest(existing, newEntries) {
|
|
|
158
263
|
for (const entry of newEntries) {
|
|
159
264
|
byKey.set(entryKey(entry), entry);
|
|
160
265
|
}
|
|
161
|
-
|
|
266
|
+
// Spread `existing` so caller-set fields (pinVersion, lastUpdate,
|
|
267
|
+
// lastUpdateVersion, etc.) survive merges performed by writeSkills /
|
|
268
|
+
// refresh / add. Only `installed` is rebuilt; everything else carries.
|
|
269
|
+
return { ...existing, version: MANIFEST_VERSION, installed: [...byKey.values()] };
|
|
162
270
|
}
|
|
163
271
|
|
|
164
272
|
function entryKey(entry) {
|
package/lib/update.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir, stat } from 'node:fs/promises';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
import { renderFile, pickTemplate } from './render.js';
|
|
4
|
+
import { detect, buildDescriptionLine } from './detect.js';
|
|
5
|
+
import { loadBaseline, readManifest, writeManifest } from './skills.js';
|
|
6
|
+
|
|
7
|
+
// Re-render the user's workflow + refresh baseline skills using the
|
|
8
|
+
// templates / baseline shipped with the currently-installed clud-bug.
|
|
9
|
+
//
|
|
10
|
+
// Honors three protections:
|
|
11
|
+
// - Custom skills (anything in .claude/skills/ not in the manifest) are
|
|
12
|
+
// never modified.
|
|
13
|
+
// - Remote skills (from skills.sh, kind: 'remote' in manifest) are left
|
|
14
|
+
// alone unless { refreshRemote: true }.
|
|
15
|
+
// - The audit workflow is also re-rendered if it's installed.
|
|
16
|
+
//
|
|
17
|
+
// Returns a diff summary with file paths and a short reason per file.
|
|
18
|
+
export async function runUpdate({
|
|
19
|
+
cwd,
|
|
20
|
+
templatesDir,
|
|
21
|
+
baselineDir,
|
|
22
|
+
ourVersion,
|
|
23
|
+
refreshRemote = false,
|
|
24
|
+
loadBaselineOpts, // forwarded to loadBaseline (e.g. for tests: { fetch, cacheDir: null })
|
|
25
|
+
} = {}) {
|
|
26
|
+
if (!cwd || !templatesDir || !baselineDir || !ourVersion) {
|
|
27
|
+
throw new Error('runUpdate requires cwd, templatesDir, baselineDir, ourVersion');
|
|
28
|
+
}
|
|
29
|
+
const skillsDir = join(cwd, '.claude', 'skills');
|
|
30
|
+
const manifest = await readManifest(skillsDir);
|
|
31
|
+
if (manifest.installed.length === 0 && !(await pathExists(join(cwd, '.github/workflows/clud-bug-review.yml')))) {
|
|
32
|
+
return { changed: [], unchanged: [], missing: 'init' };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const changed = [];
|
|
36
|
+
const unchanged = [];
|
|
37
|
+
|
|
38
|
+
// 1. Re-render review workflow with the latest template.
|
|
39
|
+
const signals = await detect(cwd);
|
|
40
|
+
const tmplName = pickTemplate(signals.languages);
|
|
41
|
+
const newReview = await renderFile(join(templatesDir, tmplName), {
|
|
42
|
+
PROJECT_DESCRIPTION: buildDescriptionLine(signals),
|
|
43
|
+
LANGUAGE_HINTS: '',
|
|
44
|
+
});
|
|
45
|
+
await maybeWrite(join(cwd, '.github/workflows/clud-bug-review.yml'), newReview, changed, unchanged, 'review workflow');
|
|
46
|
+
|
|
47
|
+
// 2. Re-render audit workflow if it's installed (init from v0.3+ ships it).
|
|
48
|
+
const auditPath = join(cwd, '.github/workflows/clud-bug-audit.yml');
|
|
49
|
+
if (await pathExists(auditPath)) {
|
|
50
|
+
const newAudit = await readFile(join(templatesDir, 'audit.yml.tmpl'), 'utf8');
|
|
51
|
+
await maybeWrite(auditPath, newAudit, changed, unchanged, 'audit workflow');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 3. Refresh baseline skills (always controlled by clud-bug).
|
|
55
|
+
const baseline = await loadBaseline(baselineDir, loadBaselineOpts);
|
|
56
|
+
for (const skill of baseline) {
|
|
57
|
+
const skillPath = join(skillsDir, sanitize(skill.name), 'SKILL.md');
|
|
58
|
+
await maybeWrite(skillPath, skill.content, changed, unchanged, `baseline ${skill.name}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 4. Optionally refresh remote skills (off by default).
|
|
62
|
+
// Custom skills are never touched.
|
|
63
|
+
// (Remote refresh is intentionally minimal here — `clud-bug refresh`
|
|
64
|
+
// already covers add/remove diffs against skills.sh.)
|
|
65
|
+
if (refreshRemote) {
|
|
66
|
+
// Placeholder for parity with the flag; full logic remains in
|
|
67
|
+
// `clud-bug refresh`. We just emit an advisory.
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 5. Stamp the manifest with the version that ran the update.
|
|
71
|
+
manifest.lastUpdate = new Date().toISOString();
|
|
72
|
+
manifest.lastUpdateVersion = ourVersion;
|
|
73
|
+
await writeManifest(skillsDir, manifest);
|
|
74
|
+
|
|
75
|
+
return { changed, unchanged, ourVersion };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function maybeWrite(path, contents, changed, unchanged, label) {
|
|
79
|
+
const prior = await readSafe(path);
|
|
80
|
+
if (prior === contents) {
|
|
81
|
+
unchanged.push({ path, label });
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
await mkdir(dirname(path), { recursive: true });
|
|
85
|
+
await writeFile(path, contents);
|
|
86
|
+
changed.push({ path, label });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function readSafe(path) {
|
|
90
|
+
try { return await readFile(path, 'utf8'); } catch { return null; }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function pathExists(path) {
|
|
94
|
+
try { await stat(path); return true; } catch { return false; }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function sanitize(name) {
|
|
98
|
+
return name.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
99
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clud-bug",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Claude PR review with project-aware skills. CLI installs a working GitHub Actions workflow and curates skills from skills.sh.",
|
|
5
5
|
"homepage": "https://cludbug.dev",
|
|
6
6
|
"bugs": "https://github.com/thrillmot/clud-bug/issues",
|
|
@@ -20,9 +20,7 @@
|
|
|
20
20
|
"ai",
|
|
21
21
|
"skills"
|
|
22
22
|
],
|
|
23
|
-
"bin":
|
|
24
|
-
"clud-bug": "./bin/clud-bug.js"
|
|
25
|
-
},
|
|
23
|
+
"bin": "bin/clud-bug.js",
|
|
26
24
|
"files": [
|
|
27
25
|
"bin",
|
|
28
26
|
"lib",
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
name: Clud Bug 🐛 Audit
|
|
2
|
+
|
|
3
|
+
# A scheduled / on-demand walk through the whole habitat (or a recent slice).
|
|
4
|
+
# Output lands as a PR titled "🐛 Clud Bug audit — YYYY-MM-DD" containing a
|
|
5
|
+
# single audits/<date>.md file with findings.
|
|
6
|
+
#
|
|
7
|
+
# Triggers:
|
|
8
|
+
# workflow_dispatch — manual button in the Actions tab. Default.
|
|
9
|
+
# schedule (commented) — uncomment for weekly Mondays 09:00 UTC.
|
|
10
|
+
|
|
11
|
+
on:
|
|
12
|
+
workflow_dispatch:
|
|
13
|
+
inputs:
|
|
14
|
+
changed_in:
|
|
15
|
+
description: "Audit files changed in (e.g. 7d, 2w, 1mo). Blank = full repo."
|
|
16
|
+
required: false
|
|
17
|
+
default: ""
|
|
18
|
+
scope:
|
|
19
|
+
description: "Glob to limit scope (e.g. src/**/*.ts). Blank = no scope filter."
|
|
20
|
+
required: false
|
|
21
|
+
default: ""
|
|
22
|
+
# Uncomment for a weekly Monday 09:00 UTC audit:
|
|
23
|
+
# schedule:
|
|
24
|
+
# - cron: '0 9 * * 1'
|
|
25
|
+
|
|
26
|
+
permissions:
|
|
27
|
+
contents: write
|
|
28
|
+
pull-requests: write
|
|
29
|
+
id-token: write
|
|
30
|
+
|
|
31
|
+
env:
|
|
32
|
+
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'
|
|
33
|
+
|
|
34
|
+
jobs:
|
|
35
|
+
audit:
|
|
36
|
+
runs-on: ubuntu-latest
|
|
37
|
+
steps:
|
|
38
|
+
- uses: actions/checkout@v5
|
|
39
|
+
with:
|
|
40
|
+
fetch-depth: 0 # full history needed for git --since
|
|
41
|
+
|
|
42
|
+
- uses: actions/setup-node@v5
|
|
43
|
+
with:
|
|
44
|
+
node-version: '20'
|
|
45
|
+
|
|
46
|
+
# Audit runs on workflow_dispatch / schedule — no PR context, so the
|
|
47
|
+
# fork-PR carve-out doesn't apply. Just fail loud if the secret is missing.
|
|
48
|
+
- name: Guard — require ANTHROPIC_API_KEY
|
|
49
|
+
env:
|
|
50
|
+
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
51
|
+
run: |
|
|
52
|
+
if [ -z "$ANTHROPIC_API_KEY" ]; then
|
|
53
|
+
echo "::error title=Clud Bug 🐛::ANTHROPIC_API_KEY secret is not set."
|
|
54
|
+
echo "::error::Set it: Settings → Secrets and variables → Actions → New repository secret."
|
|
55
|
+
exit 1
|
|
56
|
+
fi
|
|
57
|
+
|
|
58
|
+
- name: Compute audit scope and create branch
|
|
59
|
+
id: prep
|
|
60
|
+
env:
|
|
61
|
+
CHANGED_IN: ${{ inputs.changed_in }}
|
|
62
|
+
SCOPE: ${{ inputs.scope }}
|
|
63
|
+
run: |
|
|
64
|
+
DATE=$(date -u +%Y-%m-%d)
|
|
65
|
+
# Add a run-number suffix so same-day re-runs don't collide on the
|
|
66
|
+
# remote branch (push would otherwise be rejected as non-fast-forward).
|
|
67
|
+
BRANCH="clud-bug/audit-${DATE}-${{ github.run_number }}"
|
|
68
|
+
echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
|
|
69
|
+
echo "date=$DATE" >> "$GITHUB_OUTPUT"
|
|
70
|
+
|
|
71
|
+
# Use github-actions[bot]'s real identity (clud-bug isn't a registered
|
|
72
|
+
# GitHub App, so its name wouldn't resolve to a recognizable bot).
|
|
73
|
+
git config user.name "github-actions[bot]"
|
|
74
|
+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
75
|
+
git checkout -b "$BRANCH"
|
|
76
|
+
|
|
77
|
+
ARGS=()
|
|
78
|
+
[ -n "$CHANGED_IN" ] && ARGS+=("--changed-in" "$CHANGED_IN")
|
|
79
|
+
[ -n "$SCOPE" ] && ARGS+=("--scope" "$SCOPE")
|
|
80
|
+
|
|
81
|
+
npx -y clud-bug@latest audit "${ARGS[@]}"
|
|
82
|
+
|
|
83
|
+
if [ ! -f "audits/$DATE.md" ]; then
|
|
84
|
+
echo "::warning::clud-bug audit produced no stub file — nothing in scope."
|
|
85
|
+
echo "stub_written=false" >> "$GITHUB_OUTPUT"
|
|
86
|
+
exit 0
|
|
87
|
+
fi
|
|
88
|
+
|
|
89
|
+
git add audits/
|
|
90
|
+
git commit -m "🐛 Clud Bug audit — $DATE (stub)"
|
|
91
|
+
git push -u origin "$BRANCH"
|
|
92
|
+
echo "stub_written=true" >> "$GITHUB_OUTPUT"
|
|
93
|
+
|
|
94
|
+
- uses: anthropics/claude-code-action@v1
|
|
95
|
+
if: steps.prep.outputs.stub_written == 'true'
|
|
96
|
+
env:
|
|
97
|
+
AUDIT_DATE: ${{ steps.prep.outputs.date }}
|
|
98
|
+
AUDIT_BRANCH: ${{ steps.prep.outputs.branch }}
|
|
99
|
+
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'
|
|
100
|
+
with:
|
|
101
|
+
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
102
|
+
track_progress: true
|
|
103
|
+
claude_args: |
|
|
104
|
+
--allowedTools "Bash(gh pr create:*),Bash(gh pr edit:*),Bash(gh pr view:*),Bash(cat:*),Bash(head:*),Bash(tail:*),Bash(rg:*),Bash(grep:*),Bash(sed:*),Bash(git:*),Bash(ls:*),Bash(wc:*)"
|
|
105
|
+
prompt: |
|
|
106
|
+
You are Clud Bug 🐛 conducting a habitat audit. The branch
|
|
107
|
+
${{ steps.prep.outputs.branch }} contains a stub file at
|
|
108
|
+
audits/${{ steps.prep.outputs.date }}.md listing the file manifest
|
|
109
|
+
for this audit.
|
|
110
|
+
|
|
111
|
+
Procedure:
|
|
112
|
+
1. Read audits/${{ steps.prep.outputs.date }}.md to see the manifest.
|
|
113
|
+
2. Walk the manifest. For each file, look for critical issues only:
|
|
114
|
+
bugs / logic errors, security vulnerabilities (injection, secrets,
|
|
115
|
+
unsafe shell, etc.), performance defects, and broken or missing
|
|
116
|
+
test coverage on logic that needs it. Skip style nits.
|
|
117
|
+
3. Append a "## Findings" section to the audit file with one
|
|
118
|
+
entry per issue: file:line, the offending snippet, and the fix.
|
|
119
|
+
Group by severity. Cite skills from .claude/skills/ where relevant.
|
|
120
|
+
4. End the file with `Skills referenced: [...]`.
|
|
121
|
+
5. Commit your changes to the audit file:
|
|
122
|
+
git add audits/${{ steps.prep.outputs.date }}.md
|
|
123
|
+
git commit -m "🐛 Clud Bug audit — $AUDIT_DATE (findings)"
|
|
124
|
+
git push
|
|
125
|
+
6. Open a PR from this branch to the default branch:
|
|
126
|
+
gh pr create \
|
|
127
|
+
--title "🐛 Clud Bug audit — $AUDIT_DATE" \
|
|
128
|
+
--body "Findings from a habitat walk on $AUDIT_DATE. Review and act on each item; merge if you want the report committed, close otherwise."
|
|
129
|
+
|
|
130
|
+
Tone: concise field-naturalist voice — you are documenting specimens.
|
|
131
|
+
Don't perform the bit; let the precision speak.
|
|
132
|
+
|
|
133
|
+
If you find no critical issues, still open the PR with the audit
|
|
134
|
+
file noting "No critical issues observed." Maintainers will close it.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
name: Clud Bug 🐛 Self-Update
|
|
2
|
+
|
|
3
|
+
# Weekly check for a newer published clud-bug. If one exists, runs
|
|
4
|
+
# `clud-bug update` (which re-renders the workflow templates and refreshes
|
|
5
|
+
# the bundled baseline specimens, leaving custom and skills.sh-installed
|
|
6
|
+
# specimens alone), then opens a PR titled
|
|
7
|
+
# "🐛 Clud Bug self-update: vX.Y.Z → vA.B.C".
|
|
8
|
+
#
|
|
9
|
+
# To pin to a specific clud-bug version and stop self-updates, add a
|
|
10
|
+
# "pinVersion" field to .claude/skills/.clud-bug.json:
|
|
11
|
+
# { "pinVersion": "0.3.0", ... }
|
|
12
|
+
|
|
13
|
+
on:
|
|
14
|
+
workflow_dispatch:
|
|
15
|
+
schedule:
|
|
16
|
+
- cron: '0 12 * * 1' # Mondays 12:00 UTC
|
|
17
|
+
|
|
18
|
+
permissions:
|
|
19
|
+
contents: write
|
|
20
|
+
pull-requests: write
|
|
21
|
+
|
|
22
|
+
env:
|
|
23
|
+
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'
|
|
24
|
+
|
|
25
|
+
jobs:
|
|
26
|
+
check:
|
|
27
|
+
runs-on: ubuntu-latest
|
|
28
|
+
steps:
|
|
29
|
+
- uses: actions/checkout@v5
|
|
30
|
+
|
|
31
|
+
- uses: actions/setup-node@v5
|
|
32
|
+
with:
|
|
33
|
+
node-version: '20'
|
|
34
|
+
|
|
35
|
+
- name: Compare installed vs npm latest
|
|
36
|
+
id: compare
|
|
37
|
+
run: |
|
|
38
|
+
MANIFEST=".claude/skills/.clud-bug.json"
|
|
39
|
+
INSTALLED="(none)"
|
|
40
|
+
PIN=""
|
|
41
|
+
if [ -f "$MANIFEST" ]; then
|
|
42
|
+
INSTALLED=$(node -e "console.log(JSON.parse(require('fs').readFileSync('$MANIFEST','utf8')).lastUpdateVersion || '(unknown)')")
|
|
43
|
+
PIN=$(node -e "console.log(JSON.parse(require('fs').readFileSync('$MANIFEST','utf8')).pinVersion || '')")
|
|
44
|
+
fi
|
|
45
|
+
LATEST=$(npm view clud-bug version)
|
|
46
|
+
echo "installed=$INSTALLED" >> "$GITHUB_OUTPUT"
|
|
47
|
+
echo "latest=$LATEST" >> "$GITHUB_OUTPUT"
|
|
48
|
+
echo "pin=$PIN" >> "$GITHUB_OUTPUT"
|
|
49
|
+
echo "Installed: $INSTALLED Latest: $LATEST Pin: ${PIN:-(none)}"
|
|
50
|
+
|
|
51
|
+
if [ -n "$PIN" ]; then
|
|
52
|
+
echo "Pinned to $PIN — skipping update check."
|
|
53
|
+
echo "skip=true" >> "$GITHUB_OUTPUT"
|
|
54
|
+
exit 0
|
|
55
|
+
fi
|
|
56
|
+
if [ "$INSTALLED" = "$LATEST" ]; then
|
|
57
|
+
echo "Already on latest. Nothing to do."
|
|
58
|
+
echo "skip=true" >> "$GITHUB_OUTPUT"
|
|
59
|
+
exit 0
|
|
60
|
+
fi
|
|
61
|
+
echo "skip=false" >> "$GITHUB_OUTPUT"
|
|
62
|
+
|
|
63
|
+
- name: Run clud-bug update + open PR
|
|
64
|
+
if: steps.compare.outputs.skip == 'false'
|
|
65
|
+
env:
|
|
66
|
+
INSTALLED: ${{ steps.compare.outputs.installed }}
|
|
67
|
+
LATEST: ${{ steps.compare.outputs.latest }}
|
|
68
|
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
69
|
+
run: |
|
|
70
|
+
BRANCH="clud-bug/self-update-${LATEST}"
|
|
71
|
+
# Use github-actions[bot]'s real identity so commits resolve to a
|
|
72
|
+
# recognizable bot in the GitHub UI. (A "clud-bug[bot]" identity
|
|
73
|
+
# would only resolve if we registered an actual GitHub App for
|
|
74
|
+
# clud-bug — out of scope today.)
|
|
75
|
+
git config user.name "github-actions[bot]"
|
|
76
|
+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
77
|
+
git checkout -b "$BRANCH"
|
|
78
|
+
|
|
79
|
+
npx -y "clud-bug@${LATEST}" update
|
|
80
|
+
|
|
81
|
+
# Note: runUpdate always stamps lastUpdate / lastUpdateVersion in
|
|
82
|
+
# the manifest, so there will always be at least one diff in this
|
|
83
|
+
# branch. We rely on `git diff` simply to detect *real* file changes
|
|
84
|
+
# outside the manifest, and let the PR open either way.
|
|
85
|
+
git add -A
|
|
86
|
+
git commit -m "🐛 Clud Bug self-update: ${INSTALLED} → ${LATEST}"
|
|
87
|
+
git push -u origin "$BRANCH"
|
|
88
|
+
gh pr create \
|
|
89
|
+
--title "🐛 Clud Bug self-update: ${INSTALLED} → ${LATEST}" \
|
|
90
|
+
--body "Automated update from clud-bug ${INSTALLED} → ${LATEST}. Custom and skills.sh-installed specimens were left alone; only baseline specimens and the workflow templates were refreshed.
|
|
91
|
+
|
|
92
|
+
Review the diff. To stay on this version permanently, add \`\"pinVersion\": \"${INSTALLED}\"\` to \`.claude/skills/.clud-bug.json\` before merging."
|