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.
Files changed (110) hide show
  1. package/bin/clud-bug.js +10 -1353
  2. package/dist/cli/agents-md.d.ts +16 -0
  3. package/dist/cli/agents-md.d.ts.map +1 -0
  4. package/dist/cli/agents-md.js +226 -0
  5. package/dist/cli/agents-md.js.map +1 -0
  6. package/dist/cli/audit.d.ts +13 -0
  7. package/dist/cli/audit.d.ts.map +1 -0
  8. package/dist/cli/audit.js +90 -0
  9. package/dist/cli/audit.js.map +1 -0
  10. package/dist/cli/branch-protection.d.ts +57 -0
  11. package/dist/cli/branch-protection.d.ts.map +1 -0
  12. package/dist/cli/branch-protection.js +118 -0
  13. package/dist/cli/branch-protection.js.map +1 -0
  14. package/dist/cli/edit-workflow.d.ts +18 -0
  15. package/dist/cli/edit-workflow.d.ts.map +1 -0
  16. package/dist/cli/edit-workflow.js +43 -0
  17. package/dist/cli/edit-workflow.js.map +1 -0
  18. package/dist/cli/index.d.ts +8 -0
  19. package/dist/cli/index.d.ts.map +1 -0
  20. package/dist/cli/index.js +18 -0
  21. package/dist/cli/index.js.map +1 -0
  22. package/dist/cli/main.d.ts +3 -0
  23. package/dist/cli/main.d.ts.map +1 -0
  24. package/dist/cli/main.js +1336 -0
  25. package/dist/cli/main.js.map +1 -0
  26. package/dist/cli/skill-usage.d.ts +109 -0
  27. package/dist/cli/skill-usage.d.ts.map +1 -0
  28. package/dist/cli/skill-usage.js +380 -0
  29. package/dist/cli/skill-usage.js.map +1 -0
  30. package/dist/cli/skills.d.ts +56 -0
  31. package/dist/cli/skills.d.ts.map +1 -0
  32. package/dist/cli/skills.js +292 -0
  33. package/dist/cli/skills.js.map +1 -0
  34. package/dist/cli/update.d.ts +29 -0
  35. package/dist/cli/update.d.ts.map +1 -0
  36. package/dist/cli/update.js +186 -0
  37. package/dist/cli/update.js.map +1 -0
  38. package/dist/cli/usage.d.ts +142 -0
  39. package/dist/cli/usage.d.ts.map +1 -0
  40. package/dist/cli/usage.js +348 -0
  41. package/dist/cli/usage.js.map +1 -0
  42. package/dist/core/audit.d.ts +8 -0
  43. package/dist/core/audit.d.ts.map +1 -0
  44. package/dist/core/audit.js +47 -0
  45. package/dist/core/audit.js.map +1 -0
  46. package/dist/core/detect.d.ts +77 -0
  47. package/dist/core/detect.d.ts.map +1 -0
  48. package/dist/core/detect.js +262 -0
  49. package/dist/core/detect.js.map +1 -0
  50. package/dist/core/index.d.ts +11 -0
  51. package/dist/core/index.d.ts.map +1 -0
  52. package/dist/core/index.js +31 -0
  53. package/dist/core/index.js.map +1 -0
  54. package/dist/core/prompt-builder.d.ts +164 -0
  55. package/dist/core/prompt-builder.d.ts.map +1 -0
  56. package/dist/core/prompt-builder.js +419 -0
  57. package/dist/core/prompt-builder.js.map +1 -0
  58. package/dist/core/prompts.d.ts +9 -0
  59. package/dist/core/prompts.d.ts.map +1 -0
  60. package/dist/core/prompts.js +401 -0
  61. package/dist/core/prompts.js.map +1 -0
  62. package/dist/core/render-review.d.ts +6 -0
  63. package/dist/core/render-review.d.ts.map +1 -0
  64. package/dist/core/render-review.js +219 -0
  65. package/dist/core/render-review.js.map +1 -0
  66. package/dist/core/render.d.ts +13 -0
  67. package/dist/core/render.d.ts.map +1 -0
  68. package/dist/core/render.js +80 -0
  69. package/dist/core/render.js.map +1 -0
  70. package/dist/core/review-schema-zod.d.ts +240 -0
  71. package/dist/core/review-schema-zod.d.ts.map +1 -0
  72. package/dist/core/review-schema-zod.js +218 -0
  73. package/dist/core/review-schema-zod.js.map +1 -0
  74. package/dist/core/review-schema.d.ts +42 -0
  75. package/dist/core/review-schema.d.ts.map +1 -0
  76. package/dist/core/review-schema.js +156 -0
  77. package/dist/core/review-schema.js.map +1 -0
  78. package/dist/core/review-writeback.d.ts +139 -0
  79. package/dist/core/review-writeback.d.ts.map +1 -0
  80. package/dist/core/review-writeback.js +313 -0
  81. package/dist/core/review-writeback.js.map +1 -0
  82. package/dist/core/skills.d.ts +122 -0
  83. package/dist/core/skills.d.ts.map +1 -0
  84. package/dist/core/skills.js +636 -0
  85. package/dist/core/skills.js.map +1 -0
  86. package/package.json +30 -4
  87. package/{lib/agents-md.js → src/cli/agents-md.ts} +25 -14
  88. package/{lib/audit.js → src/cli/audit.ts} +37 -44
  89. package/{lib/branch-protection.js → src/cli/branch-protection.ts} +75 -11
  90. package/{lib/edit-workflow.js → src/cli/edit-workflow.ts} +32 -11
  91. package/src/cli/index.ts +101 -0
  92. package/src/cli/main.ts +1376 -0
  93. package/{lib/skill-usage.js → src/cli/skill-usage.ts} +168 -94
  94. package/src/cli/skills.ts +386 -0
  95. package/{lib/update.js → src/cli/update.ts} +68 -27
  96. package/{lib/usage.js → src/cli/usage.ts} +167 -76
  97. package/src/core/audit.ts +53 -0
  98. package/{lib/detect.js → src/core/detect.ts} +100 -47
  99. package/src/core/index.ts +155 -0
  100. package/src/core/prompt-builder.ts +561 -0
  101. package/{lib/prompts.js → src/core/prompts.ts} +16 -2
  102. package/{lib/render-review.js → src/core/render-review.ts} +57 -25
  103. package/{lib/render.js → src/core/render.ts} +36 -10
  104. package/src/core/review-schema-zod.ts +262 -0
  105. package/{lib/review-schema.js → src/core/review-schema.ts} +68 -5
  106. package/src/core/review-writeback.ts +446 -0
  107. package/{lib/skills.js → src/core/skills.ts} +339 -342
  108. package/templates/workflow-py.yml.tmpl +2 -2
  109. package/templates/workflow-ts.yml.tmpl +2 -2
  110. package/templates/workflow.yml.tmpl +17 -8
@@ -0,0 +1,386 @@
1
+ // CLI skill helpers — install/update commands that touch the filesystem.
2
+ //
3
+ // Split from lib/skills.js during the v0.7.0 TS migration. Pure helpers
4
+ // (SkillsClient, rankAndCap, partition/extract/select functions) live in
5
+ // src/core/skills.ts so the App can consume them without dragging
6
+ // node:fs into a serverless bundle.
7
+ //
8
+ // `_internal` debug-export removed: `sanitizeSlug`, `entryKey`,
9
+ // `MANIFEST_FILE` (the CLI-side pieces previously hidden under
10
+ // `_internal.X`) are now first-class named exports of this module.
11
+
12
+ import { mkdir, writeFile, readdir, readFile, rm, stat } from 'node:fs/promises';
13
+ import { join } from 'node:path';
14
+ import { homedir } from 'node:os';
15
+ import { createHash } from 'node:crypto';
16
+
17
+ import { SkillsClient, type RankableSkill, type SkillDescriptor } from '../core/skills.js';
18
+
19
+ export const MANIFEST_FILE = '.clud-bug.json';
20
+ export const MANIFEST_VERSION = 1;
21
+
22
+ // Canonical home for clud-bug's baseline skills.
23
+ // PINNED TO A COMMIT SHA, NOT `main`. This re-couples the trust boundary
24
+ // to clud-bug releases: a compromised commit on agent-skills@main cannot
25
+ // silently land in users' Claude review skills mid-cycle. To roll new
26
+ // skill content, bump BASELINE_SKILLS_REF below in the same clud-bug PR
27
+ // that ships the corresponding bundled fallback update.
28
+ // See thrillmade/agent-skills — skills.sh `skills/<name>/SKILL.md` layout.
29
+ const BASELINE_SKILLS_REF = '436963ed37cbd9c6a9b7a07e907d5a0a432fab59';
30
+ const AGENT_SKILLS_BASE =
31
+ process.env['CLUD_BUG_AGENT_SKILLS_BASE'] ??
32
+ `https://raw.githubusercontent.com/thrillmade/agent-skills/${BASELINE_SKILLS_REF}/skills`;
33
+ const SKILL_FETCH_TIMEOUT_MS = 5000;
34
+ const SKILL_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24h
35
+
36
+ export function sanitizeSlug(name: string): string {
37
+ return name.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '');
38
+ }
39
+
40
+ export function entryKey(entry: ManifestEntry): string {
41
+ // Baseline skills have no source; key by slug. Remote skills key by source/name.
42
+ return entry.kind === 'baseline'
43
+ ? `baseline:${entry.slug}`
44
+ : `${entry.source}/${entry.name || entry.slug}`;
45
+ }
46
+
47
+ // A baseline skill, as enumerated from the bundled npm-package directory.
48
+ // `content` is the raw SKILL.md text; `_source` is populated by loadBaseline
49
+ // to tell the CLI which provenance won (cached/remote agent-skills vs
50
+ // shipped bundled). Other consumers can ignore `_source`.
51
+ export interface BaselineSkill {
52
+ source: string;
53
+ name: string;
54
+ description: string;
55
+ installs: number;
56
+ kind: string;
57
+ content: string;
58
+ _source?: 'agent-skills' | 'bundled';
59
+ }
60
+
61
+ export interface LoadBaselineOptions {
62
+ fetch?: typeof globalThis.fetch | undefined;
63
+ // `cacheDir: null` disables the on-disk cache; `undefined` uses the
64
+ // default at ~/.cache/clud-bug/skills. exactOptionalPropertyTypes
65
+ // requires the explicit `| undefined`.
66
+ cacheDir?: string | null | undefined;
67
+ }
68
+
69
+ // Loads the baseline skills, preferring the pinned thrillmade/agent-skills
70
+ // commit and falling back to the bundled npm-package copy on any fetch failure.
71
+ // Returns the same shape as before, plus a `_source` of either 'agent-skills'
72
+ // or 'bundled' so the CLI can report which path was used.
73
+ //
74
+ // Options:
75
+ // - fetch — injectable for tests (defaults to globalThis.fetch)
76
+ // - cacheDir — where to cache fetched SKILL.md files (defaults to
77
+ // ~/.cache/clud-bug/skills/, skipped if null)
78
+ export async function loadBaseline(
79
+ baselineDir: string,
80
+ opts: LoadBaselineOptions = {},
81
+ ): Promise<BaselineSkill[]> {
82
+ const fetchImpl = opts.fetch ?? globalThis.fetch;
83
+ const cacheDir =
84
+ opts.cacheDir === null ? null : (opts.cacheDir ?? join(homedir(), '.cache', 'clud-bug', 'skills'));
85
+
86
+ // First, enumerate the bundled baseline skills (source of truth for which
87
+ // names exist). Then fetch each in parallel — sequential awaits would
88
+ // stack timeouts (3 baselines × 5s = 15s before fallback when offline).
89
+ const bundled = await readBundled(baselineDir);
90
+ const remotes = await Promise.all(
91
+ bundled.map((s) => tryFetchSkill(s.name, fetchImpl, cacheDir)),
92
+ );
93
+ return bundled.map((skill, i) => {
94
+ const remote = remotes[i];
95
+ return remote
96
+ ? { ...skill, content: remote, _source: 'agent-skills' as const }
97
+ : { ...skill, _source: 'bundled' as const };
98
+ });
99
+ }
100
+
101
+ // Reads the bundled baseline from the npm-package directory.
102
+ async function readBundled(baselineDir: string): Promise<BaselineSkill[]> {
103
+ const skills: BaselineSkill[] = [];
104
+ let entries;
105
+ try {
106
+ entries = await readdir(baselineDir, { withFileTypes: true });
107
+ } catch {
108
+ return skills;
109
+ }
110
+ for (const entry of entries) {
111
+ if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
112
+ const content = await readFile(join(baselineDir, entry.name), 'utf8');
113
+ skills.push({
114
+ source: 'clud-bug-baseline',
115
+ name: entry.name.replace(/\.md$/, ''),
116
+ description: '(baseline)',
117
+ installs: 0,
118
+ kind: 'baseline',
119
+ content,
120
+ });
121
+ }
122
+ return skills;
123
+ }
124
+
125
+ // Try to read from cache, then fall back to network. Returns the SKILL.md
126
+ // content string on success, null on any failure (caller falls back to bundled).
127
+ async function tryFetchSkill(
128
+ name: string,
129
+ fetchImpl: typeof globalThis.fetch,
130
+ cacheDir: string | null,
131
+ ): Promise<string | null> {
132
+ // Cache lookup first.
133
+ if (cacheDir) {
134
+ const cached = await readFromCache(cacheDir, name);
135
+ if (cached !== null) return cached;
136
+ }
137
+
138
+ // Network fetch with timeout covering BOTH the connection AND the body
139
+ // read (clearTimeout in finally guarantees the timer doesn't keep the
140
+ // event loop alive for up to 5s past a failed CLI run).
141
+ const url = `${AGENT_SKILLS_BASE}/${encodeURIComponent(name)}/SKILL.md`;
142
+ const ctrl = new AbortController();
143
+ const timer = setTimeout(() => ctrl.abort(), SKILL_FETCH_TIMEOUT_MS);
144
+ try {
145
+ const res = await fetchImpl(url, { signal: ctrl.signal });
146
+ if (!res.ok) return null;
147
+ const content = await res.text();
148
+ if (!content || !content.trim()) return null;
149
+ if (cacheDir) await writeToCache(cacheDir, name, content);
150
+ return content;
151
+ } catch {
152
+ return null;
153
+ } finally {
154
+ clearTimeout(timer);
155
+ }
156
+ }
157
+
158
+ async function readFromCache(cacheDir: string, name: string): Promise<string | null> {
159
+ const path = cachePath(cacheDir, name);
160
+ try {
161
+ const st = await stat(path);
162
+ if (Date.now() - st.mtimeMs > SKILL_CACHE_TTL_MS) return null;
163
+ return await readFile(path, 'utf8');
164
+ } catch {
165
+ return null;
166
+ }
167
+ }
168
+
169
+ async function writeToCache(cacheDir: string, name: string, content: string): Promise<void> {
170
+ try {
171
+ await mkdir(cacheDir, { recursive: true });
172
+ await writeFile(cachePath(cacheDir, name), content);
173
+ } catch {
174
+ // Cache write failures are non-fatal — we already have the content.
175
+ }
176
+ }
177
+
178
+ function cachePath(cacheDir: string, name: string): string {
179
+ // Include AGENT_SKILLS_BASE in the hash so different upstream URLs (e.g.
180
+ // a fork via CLUD_BUG_AGENT_SKILLS_BASE, or a different pinned SHA after
181
+ // a clud-bug release) get different cache entries. Otherwise switching
182
+ // bases would silently return the previously-cached content from a
183
+ // different upstream — cross-base cache poisoning.
184
+ const hash = createHash('sha256')
185
+ .update(`${AGENT_SKILLS_BASE}\n${name}`)
186
+ .digest('hex')
187
+ .slice(0, 16);
188
+ return join(cacheDir, `${hash}.md`);
189
+ }
190
+
191
+ // A skill ready for write: must have a name and source (or a baseline-style
192
+ // `kind: 'baseline'`). Either pre-bundled `content`, or omit and let
193
+ // SkillsClient.getContent fetch from skills.sh. The `content` field is
194
+ // inherited from RankableSkill / SkillDescriptor; no redeclaration here
195
+ // (exactOptionalPropertyTypes treats `content?: string` and
196
+ // `content?: string | undefined` as incompatible).
197
+ export type WritableSkill = RankableSkill;
198
+
199
+ // One row of the per-repo manifest at .claude/skills/.clud-bug.json.
200
+ export interface ManifestEntry {
201
+ slug: string;
202
+ name: string;
203
+ source: string;
204
+ kind: string;
205
+ description: string;
206
+ }
207
+
208
+ export interface Manifest {
209
+ version: number;
210
+ installed: ManifestEntry[];
211
+ // Caller-set fields (pinVersion, lastUpdate, lastUpdateVersion) survive
212
+ // merges via spread; type as an open record to keep extensibility.
213
+ [key: string]: unknown;
214
+ }
215
+
216
+ export async function writeSkills(
217
+ targetDir: string,
218
+ skills: WritableSkill[],
219
+ client: SkillsClient,
220
+ ): Promise<ManifestEntry[]> {
221
+ await mkdir(targetDir, { recursive: true });
222
+ const written: ManifestEntry[] = [];
223
+ for (const skill of skills) {
224
+ const entry = await writeSkill(targetDir, skill, client);
225
+ written.push(entry);
226
+ }
227
+ await writeManifest(targetDir, mergeManifest(await readManifest(targetDir), written));
228
+ return written;
229
+ }
230
+
231
+ export async function writeSkill(
232
+ targetDir: string,
233
+ skill: WritableSkill,
234
+ client: SkillsClient,
235
+ ): Promise<ManifestEntry> {
236
+ await mkdir(targetDir, { recursive: true });
237
+ const slug = sanitizeSlug(skill.name);
238
+ const skillDir = join(targetDir, slug);
239
+ await mkdir(skillDir, { recursive: true });
240
+ const content = skill.content ?? (await client.getContent(skill.source, skill.name));
241
+ await writeFile(join(skillDir, 'SKILL.md'), content);
242
+ return {
243
+ slug,
244
+ name: skill.name,
245
+ source: skill.source,
246
+ kind: skill.kind || 'remote',
247
+ description: skill.description || '',
248
+ };
249
+ }
250
+
251
+ export async function readManifest(targetDir: string): Promise<Manifest> {
252
+ try {
253
+ const text = await readFile(join(targetDir, MANIFEST_FILE), 'utf8');
254
+ const data = JSON.parse(text) as Partial<Manifest> & Record<string, unknown>;
255
+ return {
256
+ ...data,
257
+ version: data.version || MANIFEST_VERSION,
258
+ installed: Array.isArray(data.installed) ? data.installed : [],
259
+ };
260
+ } catch {
261
+ return { version: MANIFEST_VERSION, installed: [] };
262
+ }
263
+ }
264
+
265
+ export async function writeManifest(targetDir: string, manifest: Manifest): Promise<void> {
266
+ await mkdir(targetDir, { recursive: true });
267
+ // Preserve any additional fields callers want to stamp (e.g. lastUpdate,
268
+ // lastUpdateVersion, pinVersion). Only `version` and `installed` are normalized.
269
+ const out: Manifest = {
270
+ ...manifest,
271
+ version: manifest.version || MANIFEST_VERSION,
272
+ installed: manifest.installed || [],
273
+ };
274
+ await writeFile(join(targetDir, MANIFEST_FILE), JSON.stringify(out, null, 2) + '\n');
275
+ }
276
+
277
+ export function mergeManifest(existing: Manifest, newEntries: ManifestEntry[]): Manifest {
278
+ const byKey = new Map<string, ManifestEntry>();
279
+ for (const entry of existing.installed || []) {
280
+ byKey.set(entryKey(entry), entry);
281
+ }
282
+ for (const entry of newEntries) {
283
+ byKey.set(entryKey(entry), entry);
284
+ }
285
+ // Spread `existing` so caller-set fields (pinVersion, lastUpdate,
286
+ // lastUpdateVersion, etc.) survive merges performed by writeSkills /
287
+ // refresh / add. Only `installed` is rebuilt; everything else carries.
288
+ return { ...existing, version: MANIFEST_VERSION, installed: [...byKey.values()] };
289
+ }
290
+
291
+ export async function removeSkill(targetDir: string, slug: string): Promise<ManifestEntry> {
292
+ const manifest = await readManifest(targetDir);
293
+ const entry = manifest.installed.find((e) => e.slug === slug);
294
+ if (!entry) {
295
+ throw new Error(
296
+ `'${slug}' is not in the clud-bug manifest. If it's a custom skill, delete it manually with: rm -rf .claude/skills/${slug}`,
297
+ );
298
+ }
299
+ await rm(join(targetDir, slug), { recursive: true, force: true });
300
+ manifest.installed = manifest.installed.filter((e) => e.slug !== slug);
301
+ await writeManifest(targetDir, manifest);
302
+ return entry;
303
+ }
304
+
305
+ export interface InstalledGroups {
306
+ baseline: ManifestEntry[];
307
+ remote: ManifestEntry[];
308
+ custom: Array<{ slug: string; kind: 'custom'; description: string }>;
309
+ }
310
+
311
+ export async function listInstalled(targetDir: string): Promise<InstalledGroups> {
312
+ const manifest = await readManifest(targetDir);
313
+ const managedSlugs = new Set(manifest.installed.map((e) => e.slug));
314
+ const groups: InstalledGroups = { baseline: [], remote: [], custom: [] };
315
+ for (const entry of manifest.installed) {
316
+ if (entry.kind === 'baseline') {
317
+ groups.baseline.push(entry);
318
+ } else {
319
+ groups.remote.push(entry);
320
+ }
321
+ }
322
+
323
+ let entries;
324
+ try {
325
+ entries = await readdir(targetDir, { withFileTypes: true });
326
+ } catch {
327
+ return groups;
328
+ }
329
+ for (const entry of entries) {
330
+ if (!entry.isDirectory()) continue;
331
+ if (managedSlugs.has(entry.name)) continue;
332
+ const skillFile = join(targetDir, entry.name, 'SKILL.md');
333
+ let description = '';
334
+ try {
335
+ const text = await readFile(skillFile, 'utf8');
336
+ const m = text.match(/^description:\s*(.+)$/m);
337
+ description = m?.[1]?.trim() || '';
338
+ } catch {
339
+ continue; // not a skill dir
340
+ }
341
+ groups.custom.push({ slug: entry.name, kind: 'custom', description });
342
+ }
343
+ return groups;
344
+ }
345
+
346
+ export interface ManifestDiff {
347
+ add: RankableSkill[];
348
+ remove: ManifestEntry[];
349
+ unchanged: RankableSkill[];
350
+ }
351
+
352
+ // Diff a current manifest against a freshly-recommended skill set.
353
+ // Returns { add: [], remove: [], unchanged: [] }. Custom skills are never affected.
354
+ export function diffManifest(manifest: Manifest, recommended: RankableSkill[]): ManifestDiff {
355
+ const recByKey = new Map<string, RankableSkill>(
356
+ recommended.map((s) => [
357
+ s.kind === 'baseline' ? `baseline:${sanitizeSlug(s.name)}` : `${s.source}/${s.name}`,
358
+ s,
359
+ ]),
360
+ );
361
+ const installedByKey = new Map<string, ManifestEntry>(
362
+ manifest.installed.map((e) => [entryKey(e), e]),
363
+ );
364
+
365
+ const add: RankableSkill[] = [];
366
+ const remove: ManifestEntry[] = [];
367
+ const unchanged: RankableSkill[] = [];
368
+
369
+ for (const [key, skill] of recByKey) {
370
+ if (installedByKey.has(key)) {
371
+ unchanged.push(skill);
372
+ } else {
373
+ add.push(skill);
374
+ }
375
+ }
376
+ for (const [key, entry] of installedByKey) {
377
+ if (entry.kind === 'baseline') continue; // baseline always stays
378
+ if (!recByKey.has(key)) remove.push(entry);
379
+ }
380
+ return { add, remove, unchanged };
381
+ }
382
+
383
+ // Re-export for callers that previously imported SkillDescriptor through
384
+ // lib/skills.js (the type lived alongside the value exports). This keeps
385
+ // the v0.6.x consumer ergonomics intact during the migration.
386
+ export type { SkillDescriptor };
@@ -1,9 +1,9 @@
1
1
  import { readFile, writeFile, mkdir, stat, rm } from 'node:fs/promises';
2
2
  import { join, dirname } from 'node:path';
3
- import { renderFile, pickTemplate, templateLanguage } from './render.js';
4
- import { reviewPrompt } from './prompts.js';
5
- import { detect, buildDescriptionLine } from './detect.js';
6
- import { loadBaseline, readManifest, writeManifest } from './skills.js';
3
+ import { renderFile, pickTemplate, templateLanguage } from '../core/render.js';
4
+ import { reviewPrompt } from '../core/prompts.js';
5
+ import { detect, buildDescriptionLine } from '../core/detect.js';
6
+ import { loadBaseline, readManifest, writeManifest, type LoadBaselineOptions } from './skills.js';
7
7
  import { applyToRepo as applyAgentDocs } from './agents-md.js';
8
8
 
9
9
  // Re-render the user's workflow + refresh baseline skills using the
@@ -19,16 +19,41 @@ import { applyToRepo as applyAgentDocs } from './agents-md.js';
19
19
  // are treated as user-customized and left alone — the user gets a
20
20
  // printed warning + the documented "delete + clud-bug init" recovery
21
21
  // path. Mirrors logmind v0.2.1's refresh-mode pattern.
22
- //
22
+
23
+ export interface RunUpdateOptions {
24
+ cwd: string;
25
+ templatesDir: string;
26
+ baselineDir: string;
27
+ ourVersion: string;
28
+ refreshRemote?: boolean | undefined;
29
+ // forwarded to loadBaseline (e.g. for tests: { fetch, cacheDir: null })
30
+ loadBaselineOpts?: LoadBaselineOptions | undefined;
31
+ }
32
+
33
+ export interface UpdateChangeRecord {
34
+ path: string;
35
+ label: string;
36
+ from?: string | undefined;
37
+ to?: string | undefined;
38
+ }
39
+
40
+ export interface UpdateSkippedRecord {
41
+ path: string;
42
+ label: string;
43
+ reason: string;
44
+ }
45
+
46
+ export interface RunUpdateResult {
47
+ changed: UpdateChangeRecord[];
48
+ unchanged: UpdateChangeRecord[];
49
+ skipped?: UpdateSkippedRecord[];
50
+ ourVersion?: string;
51
+ missing?: 'init';
52
+ }
53
+
23
54
  // Returns { changed, unchanged, skipped, ourVersion }.
24
- export async function runUpdate({
25
- cwd,
26
- templatesDir,
27
- baselineDir,
28
- ourVersion,
29
- refreshRemote = false,
30
- loadBaselineOpts, // forwarded to loadBaseline (e.g. for tests: { fetch, cacheDir: null })
31
- } = {}) {
55
+ export async function runUpdate(opts: RunUpdateOptions): Promise<RunUpdateResult> {
56
+ const { cwd, templatesDir, baselineDir, ourVersion, refreshRemote = false, loadBaselineOpts } = opts;
32
57
  if (!cwd || !templatesDir || !baselineDir || !ourVersion) {
33
58
  throw new Error('runUpdate requires cwd, templatesDir, baselineDir, ourVersion');
34
59
  }
@@ -38,9 +63,9 @@ export async function runUpdate({
38
63
  return { changed: [], unchanged: [], missing: 'init' };
39
64
  }
40
65
 
41
- const changed = [];
42
- const unchanged = [];
43
- const skipped = [];
66
+ const changed: UpdateChangeRecord[] = [];
67
+ const unchanged: UpdateChangeRecord[] = [];
68
+ const skipped: UpdateSkippedRecord[] = [];
44
69
 
45
70
  // 1. Re-render review workflow with the latest template.
46
71
  const signals = await detect(cwd);
@@ -77,7 +102,8 @@ export async function runUpdate({
77
102
  // existing .claude/skills/<slug>/ dir is removed if present, so a repo
78
103
  // that opts out of a baseline doesn't end up regenerating it on every
79
104
  // update (the original symptom this field exists to fix).
80
- const excluded = new Set(Array.isArray(manifest.excludedBaselines) ? manifest.excludedBaselines : []);
105
+ const excludedRaw = manifest['excludedBaselines'];
106
+ const excluded = new Set<string>(Array.isArray(excludedRaw) ? (excludedRaw as string[]) : []);
81
107
  const baseline = await loadBaseline(baselineDir, loadBaselineOpts);
82
108
  for (const skill of baseline) {
83
109
  const slug = sanitize(skill.name);
@@ -112,20 +138,26 @@ export async function runUpdate({
112
138
  // default-on behavior of fresh v0.4+ installs.
113
139
  const agentDocs = await applyAgentDocs(cwd, {
114
140
  version: ourVersion,
115
- strictMode: manifest.strictMode === true,
141
+ strictMode: manifest['strictMode'] === true,
116
142
  });
117
143
  for (const p of agentDocs.created) changed.push({ path: join(cwd, p), label: `agent docs: created ${p}` });
118
144
  for (const p of agentDocs.touched) changed.push({ path: join(cwd, p), label: `agent docs: ${p}` });
119
145
 
120
146
  // 6. Stamp the manifest with the version that ran the update.
121
- manifest.lastUpdate = new Date().toISOString();
122
- manifest.lastUpdateVersion = ourVersion;
147
+ manifest['lastUpdate'] = new Date().toISOString();
148
+ manifest['lastUpdateVersion'] = ourVersion;
123
149
  await writeManifest(skillsDir, manifest);
124
150
 
125
151
  return { changed, unchanged, skipped, ourVersion };
126
152
  }
127
153
 
128
- async function maybeWrite(path, contents, changed, unchanged, label) {
154
+ async function maybeWrite(
155
+ path: string,
156
+ contents: string,
157
+ changed: UpdateChangeRecord[],
158
+ unchanged: UpdateChangeRecord[],
159
+ label: string,
160
+ ): Promise<void> {
129
161
  const prior = await readSafe(path);
130
162
  if (prior === contents) {
131
163
  unchanged.push({ path, label });
@@ -140,7 +172,14 @@ async function maybeWrite(path, contents, changed, unchanged, label) {
140
172
  // on line 1). If the installed file lacks that marker, treat it as
141
173
  // user-customized and leave it alone — recovery path is delete + `clud-bug init`.
142
174
  // Mirrors logmind v0.2.1's refresh-mode contract.
143
- async function maybeRefreshVersioned(path, contents, changed, unchanged, skipped, label) {
175
+ async function maybeRefreshVersioned(
176
+ path: string,
177
+ contents: string,
178
+ changed: UpdateChangeRecord[],
179
+ unchanged: UpdateChangeRecord[],
180
+ skipped: UpdateSkippedRecord[],
181
+ label: string,
182
+ ): Promise<void> {
144
183
  const tmplVersion = extractTemplateVersion(contents);
145
184
  if (!tmplVersion) {
146
185
  // Defensive: every versioned template is supposed to carry a marker.
@@ -184,21 +223,23 @@ async function maybeRefreshVersioned(path, contents, changed, unchanged, skipped
184
223
  // Anchoring near the top means a stray `# clud-bug-template-version:` lower
185
224
  // in the file (in a comment inside a heredoc, say) can't be mistaken for the
186
225
  // authoritative marker. Returns null if not present.
187
- function extractTemplateVersion(text) {
226
+ function extractTemplateVersion(text: string | null | undefined): string | null {
188
227
  if (!text) return null;
189
228
  const head = text.split('\n', 5).join('\n');
190
229
  const m = head.match(/^# clud-bug-template-version:\s*(\S+)/m);
191
- return m ? m[1] : null;
230
+ // m[1] is `string | undefined` under noUncheckedIndexedAccess; coalesce
231
+ // to null to keep the return type tight.
232
+ return m ? (m[1] ?? null) : null;
192
233
  }
193
234
 
194
- async function readSafe(path) {
235
+ async function readSafe(path: string): Promise<string | null> {
195
236
  try { return await readFile(path, 'utf8'); } catch { return null; }
196
237
  }
197
238
 
198
- async function pathExists(path) {
239
+ async function pathExists(path: string): Promise<boolean> {
199
240
  try { await stat(path); return true; } catch { return false; }
200
241
  }
201
242
 
202
- function sanitize(name) {
243
+ function sanitize(name: string): string {
203
244
  return name.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '');
204
245
  }