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.
@@ -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
- export async function loadBaseline(baselineDir) {
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: '(bundled baseline)',
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
- return { version: MANIFEST_VERSION, installed: [...byKey.values()] };
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.2.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."