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
|
@@ -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 '
|
|
4
|
-
import { reviewPrompt } from '
|
|
5
|
-
import { detect, buildDescriptionLine } from '
|
|
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
|
|
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
|
|
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
|
|
122
|
-
manifest
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
}
|