clud-bug 0.4.1 → 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/bin/clud-bug.js CHANGED
@@ -107,7 +107,13 @@ async function runInit(args) {
107
107
  log(` search terms: ${signals.searchTerms.join(', ') || '(none)'}`);
108
108
 
109
109
  const baseline = await loadBaseline(BASELINE_DIR);
110
- log(` baseline kit: ${baseline.length} specimens`);
110
+ const fromAgentSkills = baseline.filter((s) => s._source === 'agent-skills').length;
111
+ const sourceLabel = baseline.length === 0
112
+ ? ''
113
+ : fromAgentSkills === baseline.length ? ' (from thrillmot/agent-skills)'
114
+ : fromAgentSkills === 0 ? ' (bundled fallback)'
115
+ : ` (${fromAgentSkills} from agent-skills, ${baseline.length - fromAgentSkills} bundled)`;
116
+ log(` baseline kit: ${baseline.length} specimens${sourceLabel}`);
111
117
 
112
118
  let curated = [];
113
119
  let searched = [];
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 = [];
package/lib/update.js CHANGED
@@ -21,6 +21,7 @@ export async function runUpdate({
21
21
  baselineDir,
22
22
  ourVersion,
23
23
  refreshRemote = false,
24
+ loadBaselineOpts, // forwarded to loadBaseline (e.g. for tests: { fetch, cacheDir: null })
24
25
  } = {}) {
25
26
  if (!cwd || !templatesDir || !baselineDir || !ourVersion) {
26
27
  throw new Error('runUpdate requires cwd, templatesDir, baselineDir, ourVersion');
@@ -51,7 +52,7 @@ export async function runUpdate({
51
52
  }
52
53
 
53
54
  // 3. Refresh baseline skills (always controlled by clud-bug).
54
- const baseline = await loadBaseline(baselineDir);
55
+ const baseline = await loadBaseline(baselineDir, loadBaselineOpts);
55
56
  for (const skill of baseline) {
56
57
  const skillPath = join(skillsDir, sanitize(skill.name), 'SKILL.md');
57
58
  await maybeWrite(skillPath, skill.content, changed, unchanged, `baseline ${skill.name}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clud-bug",
3
- "version": "0.4.1",
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",