claudenv 1.3.1 → 1.3.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.
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * Builds the `--append-system-prompt` payload for `claudenv loop`.
3
3
  *
4
- * Three composable fragments, joined with blank lines:
4
+ * Four composable fragments, joined with blank lines:
5
5
  *
6
6
  * 1. Autonomy=law directive from the goal (existing 1.2.x behavior)
7
7
  * 2. Vibe-decisions loop-mode marker so the skill skips pause-and-ask
8
- * 3. Memory briefing current INDEX.md contents, capped for cache stability
8
+ * 3. Dynamic-workflows loop-mode marker so the loop may fan out decomposable
9
+ * plan items via the Workflow tool (with a hard fan-out cap)
10
+ * 4. Memory briefing — current INDEX.md contents, capped for cache stability
9
11
  *
10
12
  * Cache discipline: the function is pure on its inputs (goal + file state at
11
13
  * call time). Loop.js calls it once per iteration but with stable inputs
@@ -23,6 +25,31 @@ immediately (Write tool to /memories/decisions/<date>-<slug>.md with the
23
25
  __VIBE_DECISION__ marker), continue with code. Do NOT pause for user
24
26
  confirmation. Do NOT ask "делать так?". The goal is law.`;
25
27
 
28
+ const WORKFLOW_LOOP_FRAGMENT = `## Dynamic-workflows mode (loop)
29
+
30
+ When a plan item genuinely splits into many INDEPENDENT sub-tasks (the same
31
+ operation over many files, several review dimensions, N independent attempts),
32
+ you MAY orchestrate them with the Workflow tool instead of working serially —
33
+ see the dynamic-workflows skill for patterns. Otherwise stay single-threaded:
34
+ one item, one focused change. Hard limits — fan-out costs ~15× the tokens and
35
+ this iteration runs under a per-iteration budget cap, so orchestrate only when
36
+ the width is real and keep the number of concurrent agents small (a handful,
37
+ not dozens). If the Workflow tool's background completion does not resolve
38
+ under this headless run, fall back to a few parallel Agent calls for the same
39
+ fan-out. Never pause to ask whether to orchestrate — the goal is law.`;
40
+
41
+ const HARNESS_LOOP_FRAGMENT = `## Harness mode (loop)
42
+
43
+ You can extend your own harness. If the current plan item would be done far
44
+ better with a capability you lack — a skill, connector, MCP, or browser — run
45
+ \`claudenv capabilities\` to see what you have, \`claudenv skills search "<need>"\`
46
+ to find it, and \`claudenv skills add <slug>\` to equip it. The goal is law: do
47
+ not pause to ask. HARD limit — only auto-install CURATED (★) skills and
48
+ known-safe bootstraps; a fetched SKILL.md is auto-loaded model-facing text, so
49
+ NEVER auto-install a live (non-curated) skill in this headless run — note the
50
+ gap and proceed with what you have. Secrets only in .env.local; connector
51
+ knowledge into the active workspace. See the harness skill for the full flow.`;
52
+
26
53
  const MAX_BRIEFING_CHARS = 4000;
27
54
 
28
55
  /**
@@ -44,6 +71,14 @@ export async function buildSystemPromptWithMemory(goal, opts = {}) {
44
71
  // Vibe-decisions loop mode is always on inside `claudenv loop`.
45
72
  parts.push(VIBE_LOOP_FRAGMENT);
46
73
 
74
+ // Dynamic-workflows loop mode — lets the loop fan out decomposable plan
75
+ // items via the Workflow tool, with a hard cap on fan-out width.
76
+ parts.push(WORKFLOW_LOOP_FRAGMENT);
77
+
78
+ // Harness loop mode — lets the loop self-equip missing capabilities
79
+ // (curated skills only) instead of doing tooling-shaped work by hand.
80
+ parts.push(HARNESS_LOOP_FRAGMENT);
81
+
47
82
  // Memory briefing — INDEX.md if present.
48
83
  if (opts.includeMemoryBriefing !== false) {
49
84
  try {
@@ -38,6 +38,25 @@ export function dirtyFlagPath() {
38
38
  return join(claudenvHome(), '.index-dirty');
39
39
  }
40
40
 
41
+ // --- Skills layer (~/.claude/skills/ — where Claude Code loads skills from) ---
42
+
43
+ /**
44
+ * Path to ~/.claude/ — where Claude Code reads global commands and skills.
45
+ * Distinct from claudenvHome() (~/.claudenv/), which holds memory/canon/workspaces.
46
+ */
47
+ export function claudeDir() {
48
+ return process.env.CLAUDE_HOME || join(homedir(), '.claude');
49
+ }
50
+
51
+ export function claudeSkillsDir() {
52
+ return join(claudeDir(), 'skills');
53
+ }
54
+
55
+ /** Cache of the parsed awesome-claude-skills registry. */
56
+ export function skillsRegistryCachePath() {
57
+ return join(claudenvHome(), 'skills-registry.json');
58
+ }
59
+
41
60
  export function projectDecisionsDir(cwd) {
42
61
  return join(cwd, '.claude', 'memories', 'decisions');
43
62
  }
@@ -0,0 +1,447 @@
1
+ /**
2
+ * skills-registry.js — discover and install Claude skills from the
3
+ * awesome-claude-skills registry + a curated bundled catalog.
4
+ *
5
+ * The registry (https://github.com/ComposioHQ/awesome-claude-skills) is a plain
6
+ * markdown README, not a machine index, and its links are HETEROGENEOUS. Install
7
+ * classes (verified against live raw.githubusercontent.com endpoints 2026-06-08):
8
+ *
9
+ * repo-path : github.com/<o>/<r>/(tree|blob)/<branch>/<path>
10
+ * → https://raw.githubusercontent.com/<o>/<r>/<branch>/<path>/SKILL.md (reliable)
11
+ * in-repo : a README-relative "./<slug>/" link
12
+ * → resolved against the awesome repo (reliable)
13
+ * repo-root : github.com/<o>/<r> with no path
14
+ * → SKILL.md location unknown; best-effort probe, else guide
15
+ * bootstrap : not a copyable SKILL.md — installed by a shell command (kimi-webbridge)
16
+ * guide : non-fetchable (vendor dashboard, Composio platform connector) → show the URL
17
+ *
18
+ * SECURITY. installSkill() writes ONLY under ~/.claude/skills/<slug>/SKILL.md, never
19
+ * overwrites without force, validates that the fetched body is a real SKILL.md
20
+ * (frontmatter + name, size cap, not an HTML page), and NEVER executes fetched
21
+ * content. A fetched SKILL.md is auto-loaded, model-facing instruction text, so it
22
+ * is a prompt-injection surface: only CURATED (bundled) entries are safe to
23
+ * auto-equip; LIVE entries must be confirmed by the user (see the harness skill).
24
+ */
25
+
26
+ import { readFile, writeFile, mkdir, readdir, stat } from 'node:fs/promises';
27
+ import { join } from 'node:path';
28
+ import {
29
+ claudeSkillsDir,
30
+ skillsRegistryCachePath,
31
+ parseFrontmatter,
32
+ } from './memory-paths.js';
33
+ import { BUNDLED_CATALOG, findBundled } from './bundled-catalog.js';
34
+
35
+ const AWESOME = { owner: 'ComposioHQ', repo: 'awesome-claude-skills', branch: 'master' };
36
+ const RAW = 'https://raw.githubusercontent.com';
37
+ const README_URL = `${RAW}/${AWESOME.owner}/${AWESOME.repo}/${AWESOME.branch}/README.md`;
38
+ const MAX_SKILL_BYTES = 256 * 1024;
39
+
40
+ // =============================================================
41
+ // Classification & resolution (pure)
42
+ // =============================================================
43
+
44
+ /** Classify a registry link into an install class from its URL shape alone. */
45
+ export function classifyUrl(url) {
46
+ const u = (url || '').trim();
47
+ if (!u) return 'guide';
48
+ // README-relative link → an in-repo skill folder of the awesome repo
49
+ if (!/^https?:\/\//i.test(u)) return 'in-repo';
50
+ const gh = /^https?:\/\/github\.com\/[^/]+\/[^/]+/i.test(u);
51
+ if (gh) {
52
+ return /\/(tree|blob)\/[^/]+\/.+/i.test(u) ? 'repo-path' : 'repo-root';
53
+ }
54
+ return 'guide';
55
+ }
56
+
57
+ /**
58
+ * Resolve an entry into something installable.
59
+ * Returns one of:
60
+ * { kind: 'bootstrap', command, url }
61
+ * { kind: 'fetch', class, candidates: [rawUrl...], url, uncertain? }
62
+ * { kind: 'guide', url, reason }
63
+ */
64
+ export function resolveSkillSource(entry) {
65
+ const url = (entry.url || '').trim();
66
+ // Strip query/fragment before constructing raw fetch URLs (display keeps `url`).
67
+ const u = url.replace(/[?#].*$/, '');
68
+ const cls = entry.install || classifyUrl(url);
69
+
70
+ if (cls === 'bootstrap' || entry.bootstrap) {
71
+ return { kind: 'bootstrap', command: entry.bootstrap, url };
72
+ }
73
+
74
+ if (cls === 'in-repo') {
75
+ const path = u.replace(/^\.?\/+/, '').replace(/\/+$/, '');
76
+ if (!path) return { kind: 'guide', url, reason: 'empty in-repo path' };
77
+ return {
78
+ kind: 'fetch',
79
+ class: 'in-repo',
80
+ url,
81
+ candidates: [`${RAW}/${AWESOME.owner}/${AWESOME.repo}/${AWESOME.branch}/${path}/SKILL.md`],
82
+ };
83
+ }
84
+
85
+ if (cls === 'repo-path') {
86
+ const m = /github\.com\/([^/]+)\/([^/]+)\/(?:tree|blob)\/([^/]+)\/(.+?)\/?$/i.exec(u);
87
+ if (m) {
88
+ const [, owner, repo, branch, path] = m;
89
+ return {
90
+ kind: 'fetch',
91
+ class: 'repo-path',
92
+ url,
93
+ candidates: [`${RAW}/${owner}/${repo}/${branch}/${path}/SKILL.md`],
94
+ };
95
+ }
96
+ }
97
+
98
+ if (cls === 'repo-root') {
99
+ const m = /github\.com\/([^/]+)\/([^/]+?)\/?$/i.exec(u);
100
+ if (m) {
101
+ const [, owner, repo] = m;
102
+ const candidates = [];
103
+ for (const b of ['main', 'master']) {
104
+ candidates.push(`${RAW}/${owner}/${repo}/${b}/SKILL.md`);
105
+ candidates.push(`${RAW}/${owner}/${repo}/${b}/skills/${repo}/SKILL.md`);
106
+ candidates.push(`${RAW}/${owner}/${repo}/${b}/${repo}/SKILL.md`);
107
+ }
108
+ return { kind: 'fetch', class: 'repo-root', url, candidates, uncertain: true };
109
+ }
110
+ }
111
+
112
+ return { kind: 'guide', url, reason: 'not a fetchable SKILL.md (vendor/platform link)' };
113
+ }
114
+
115
+ // Terminal path segments that are too generic to be a distinctive slug — for
116
+ // these we prefer the descriptive link text (e.g. ".../ai-skills/tree/main/skills").
117
+ const GENERIC_SEGMENTS = new Set([
118
+ 'skills', 'skill', 'src', 'main', 'master', 'plugin', 'plugins',
119
+ 'package', 'packages', 'tree', 'blob', 'docs', 'examples',
120
+ ]);
121
+
122
+ /** Derive a filesystem-safe slug from a registry entry's name/url. */
123
+ export function slugify(name, url) {
124
+ const cleaned = (url || '')
125
+ .replace(/[?#].*$/, '') // drop query/fragment
126
+ .replace(/\/(tree|blob)\/[^/]+\//i, '/') // drop the /tree|blob/<branch>/ segment
127
+ .replace(/\/+$/, '');
128
+ const last = /\/([^/]+)$/.exec(cleaned);
129
+ let base = (last ? last[1] : cleaned).toLowerCase();
130
+ base = base.replace(/\.git$/, '').replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
131
+ const nameSlug = (name || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
132
+ if (base && /^[a-z0-9]/.test(base) && !GENERIC_SEGMENTS.has(base)) return base;
133
+ return nameSlug || base || 'skill';
134
+ }
135
+
136
+ // =============================================================
137
+ // README parsing (pure)
138
+ // =============================================================
139
+
140
+ /**
141
+ * Parse the awesome-claude-skills README markdown into entries. Only the
142
+ * "## Skills" section is scanned; "### <category>" headings group entries;
143
+ * items look like `- [Name](url) - description *By [@x](...)*`.
144
+ */
145
+ export function parseRegistry(markdown) {
146
+ const entries = [];
147
+ let category = null;
148
+ let inSkills = false;
149
+
150
+ for (const raw of String(markdown || '').split('\n')) {
151
+ const line = raw.replace(/\s+$/, '');
152
+
153
+ const h2 = /^##\s+(.+)$/.exec(line);
154
+ if (h2) {
155
+ inSkills = /^skills\b/i.test(h2[1].trim());
156
+ category = null;
157
+ continue;
158
+ }
159
+ if (!inSkills) continue;
160
+
161
+ const h3 = /^###\s+(.+)$/.exec(line);
162
+ if (h3) {
163
+ category = h3[1].trim();
164
+ continue;
165
+ }
166
+
167
+ const item = /^\s*[-*]\s+\[([^\]]+)\]\(([^)]+)\)\s*[-–—:]*\s*(.*)$/.exec(line);
168
+ if (!item) continue;
169
+
170
+ const name = item[1].trim();
171
+ const url = item[2].trim();
172
+ let description = item[3].trim();
173
+ // strip a trailing "*By [@author](...)*" attribution
174
+ description = description.replace(/\*By\s+\[[^\]]*\]\([^)]*\)\*\s*$/i, '').trim();
175
+ description = description.replace(/\s*\*By\b[^*]*\*\s*$/i, '').trim();
176
+
177
+ if (!name || !url) continue;
178
+ entries.push({
179
+ name,
180
+ slug: slugify(name, url),
181
+ url,
182
+ description,
183
+ category,
184
+ install: classifyUrl(url),
185
+ curated: false,
186
+ });
187
+ }
188
+ return entries;
189
+ }
190
+
191
+ /** Rank catalog entries against a free-text query (curated entries get a nudge). */
192
+ export function searchCatalog(entries, query) {
193
+ if (!query || !query.trim()) {
194
+ return [...entries].sort((a, b) => Number(b.curated) - Number(a.curated));
195
+ }
196
+ const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
197
+ const scored = [];
198
+ for (const e of entries) {
199
+ const slug = (e.slug || '').toLowerCase();
200
+ const name = (e.name || '').toLowerCase();
201
+ const hay = `${name} ${slug} ${e.description || ''} ${e.category || ''}`.toLowerCase();
202
+ let score = 0;
203
+ let matchedAll = true;
204
+ for (const t of terms) {
205
+ if (!hay.includes(t)) {
206
+ matchedAll = false;
207
+ break;
208
+ }
209
+ if (slug === t) score += 6;
210
+ score += name.includes(t) ? 3 : 1;
211
+ }
212
+ if (!matchedAll) continue;
213
+ if (e.curated) score += 2;
214
+ scored.push({ e, score });
215
+ }
216
+ scored.sort((a, b) => b.score - a.score);
217
+ return scored.map((x) => x.e);
218
+ }
219
+
220
+ /** Merge curated (authoritative) + live entries; curated wins on slug collision. */
221
+ export function mergeCatalog(curated, live) {
222
+ const bySlug = new Map();
223
+ for (const e of live || []) bySlug.set(e.slug, e);
224
+ for (const e of curated || []) bySlug.set(e.slug, e); // curated overrides
225
+ return [...bySlug.values()];
226
+ }
227
+
228
+ // =============================================================
229
+ // Fetch / cache / install (effectful — fetch is injectable for tests)
230
+ // =============================================================
231
+
232
+ async function fetchText(url, fetchImpl) {
233
+ const f = fetchImpl || globalThis.fetch;
234
+ if (!f) throw new Error('no fetch available — Node >= 18 or pass fetchImpl');
235
+ let res;
236
+ try {
237
+ res = await f(url, { headers: { 'User-Agent': 'claudenv-skills' }, redirect: 'follow' });
238
+ } catch {
239
+ return null;
240
+ }
241
+ if (!res || !res.ok) return null;
242
+ return await res.text();
243
+ }
244
+
245
+ /** Validate that a fetched body really is a SKILL.md (cheap safety gate). */
246
+ export function validateSkillBody(text) {
247
+ if (!text || typeof text !== 'string') return { ok: false, reason: 'empty response' };
248
+ if (Buffer.byteLength(text, 'utf-8') > MAX_SKILL_BYTES) {
249
+ return { ok: false, reason: 'SKILL.md too large (>256KB) — fetch manually' };
250
+ }
251
+ if (/^\s*<(!doctype|html|head|body)\b/i.test(text)) {
252
+ return { ok: false, reason: 'response looks like an HTML page, not a SKILL.md' };
253
+ }
254
+ const fm = parseFrontmatter(text);
255
+ if (!fm || !fm.name) return { ok: false, reason: 'no SKILL.md frontmatter (missing name:)' };
256
+ return { ok: true, frontmatter: fm };
257
+ }
258
+
259
+ /** Fetch + parse the live registry README. Throws only if README itself is unreachable. */
260
+ export async function fetchRegistry(fetchImpl) {
261
+ const md = await fetchText(README_URL, fetchImpl);
262
+ if (!md) throw new Error(`could not fetch registry README (${README_URL})`);
263
+ return parseRegistry(md);
264
+ }
265
+
266
+ async function readCache() {
267
+ try {
268
+ return JSON.parse(await readFile(skillsRegistryCachePath(), 'utf-8'));
269
+ } catch {
270
+ return [];
271
+ }
272
+ }
273
+
274
+ export async function writeCache(entries) {
275
+ const p = skillsRegistryCachePath();
276
+ await mkdir(join(p, '..'), { recursive: true });
277
+ await writeFile(p, JSON.stringify(entries, null, 2), 'utf-8');
278
+ }
279
+
280
+ /** Fetch the live registry and persist it to the cache. Returns the entries. */
281
+ export async function refreshCatalog(fetchImpl) {
282
+ const entries = await fetchRegistry(fetchImpl);
283
+ await writeCache(entries);
284
+ return entries;
285
+ }
286
+
287
+ /**
288
+ * Synthesize a catalog entry from a bare URL (github tree/blob/root or a
289
+ * README-relative path), so `skills add <url>` works. Always non-curated.
290
+ */
291
+ export function makeEntryFromUrl(url) {
292
+ const u = (url || '').trim();
293
+ return {
294
+ name: u,
295
+ slug: slugify(u, u),
296
+ url: u,
297
+ description: '',
298
+ category: null,
299
+ install: classifyUrl(u),
300
+ curated: false,
301
+ };
302
+ }
303
+
304
+ /**
305
+ * Build the working catalog: bundled (curated) + live (cached, or refreshed).
306
+ * Offline-first — with no network and no cache you still get the curated set.
307
+ */
308
+ export async function loadCatalog({ fetchImpl, refresh = false } = {}) {
309
+ const curated = BUNDLED_CATALOG.map((e) => ({ ...e, curated: true }));
310
+ let live = [];
311
+ if (refresh) {
312
+ try {
313
+ live = await refreshCatalog(fetchImpl);
314
+ } catch {
315
+ live = await readCache();
316
+ }
317
+ } else {
318
+ live = await readCache();
319
+ }
320
+ return mergeCatalog(curated, live);
321
+ }
322
+
323
+ /** List skills currently installed under ~/.claude/skills/. */
324
+ export async function listInstalledSkills(claudeHome) {
325
+ const dir = claudeHome ? join(claudeHome, 'skills') : claudeSkillsDir();
326
+ let entries;
327
+ try {
328
+ entries = await readdir(dir, { withFileTypes: true });
329
+ } catch {
330
+ return [];
331
+ }
332
+ const out = [];
333
+ for (const ent of entries) {
334
+ if (!ent.isDirectory()) continue;
335
+ let frontmatter = null;
336
+ try {
337
+ frontmatter = parseFrontmatter(await readFile(join(dir, ent.name, 'SKILL.md'), 'utf-8'));
338
+ } catch {
339
+ /* directory without a SKILL.md — skip metadata */
340
+ }
341
+ out.push({
342
+ slug: ent.name,
343
+ name: (frontmatter && frontmatter.name) || ent.name,
344
+ description: (frontmatter && frontmatter.description) || '',
345
+ hasSkillMd: !!frontmatter,
346
+ });
347
+ }
348
+ return out;
349
+ }
350
+
351
+ /** Find a catalog entry by slug or name (case-insensitive). */
352
+ export function findEntry(catalog, nameOrSlug) {
353
+ if (!nameOrSlug) return null;
354
+ const q = String(nameOrSlug).toLowerCase();
355
+ return (
356
+ catalog.find((e) => e.slug.toLowerCase() === q) ||
357
+ catalog.find((e) => (e.name || '').toLowerCase() === q) ||
358
+ catalog.find((e) => e.slug.toLowerCase().includes(q)) ||
359
+ findBundled(nameOrSlug) ||
360
+ null
361
+ );
362
+ }
363
+
364
+ /**
365
+ * Install one skill. Returns a result describing what happened:
366
+ * { action: 'installed', slug, path, source, curated, frontmatter }
367
+ * { action: 'exists', slug, path }
368
+ * { action: 'needs-confirm', slug, url } — live (untrusted) entry, confirmLive not set
369
+ * { action: 'bootstrap', slug, command, url } — caller decides whether to run it
370
+ * { action: 'guide', slug, url, reason } — not file-copyable
371
+ * { action: 'invalid', slug, url, reason } — fetched body failed validation
372
+ *
373
+ * TRUST GATE (enforced in code, not just docs): a live (non-curated) entry is NOT
374
+ * fetched or written unless `confirmLive` is true. `claudenv loop` never passes it,
375
+ * so the loop is physically unable to install a live skill — only the curated
376
+ * allowlist can auto-equip. A fetched SKILL.md is auto-loaded model-facing text.
377
+ *
378
+ * Never throws on network/validation failures — degrades to 'guide'/'invalid'.
379
+ */
380
+ export async function installSkill(entry, { fetchImpl, claudeHome, force = false, confirmLive = false } = {}) {
381
+ const slug = entry.slug || slugify(entry.name, entry.url);
382
+ if (!/^[a-z0-9][a-z0-9._-]*$/i.test(slug)) {
383
+ throw new Error(`invalid skill slug "${slug}"`);
384
+ }
385
+
386
+ const src = resolveSkillSource(entry);
387
+ if (src.kind === 'bootstrap') {
388
+ return { action: 'bootstrap', slug, command: src.command, url: src.url };
389
+ }
390
+ if (src.kind === 'guide') {
391
+ return { action: 'guide', slug, url: src.url, reason: src.reason };
392
+ }
393
+
394
+ // Trust gate: never fetch/write an untrusted (non-curated) skill without
395
+ // explicit confirmation. This is the code-level enforcement of the boundary
396
+ // that the docs and the loop fragment describe.
397
+ if (entry.curated !== true && !confirmLive) {
398
+ return { action: 'needs-confirm', slug, url: src.url || entry.url };
399
+ }
400
+
401
+ const skillsHome = claudeHome ? join(claudeHome, 'skills') : claudeSkillsDir();
402
+ const destDir = join(skillsHome, slug);
403
+ const destFile = join(destDir, 'SKILL.md');
404
+
405
+ if (!force) {
406
+ try {
407
+ await stat(destFile);
408
+ return { action: 'exists', slug, path: destFile };
409
+ } catch {
410
+ /* not installed yet — proceed */
411
+ }
412
+ }
413
+
414
+ let body = null;
415
+ let usedUrl = null;
416
+ for (const candidate of src.candidates) {
417
+ body = await fetchText(candidate, fetchImpl);
418
+ if (body) {
419
+ usedUrl = candidate;
420
+ break;
421
+ }
422
+ }
423
+ if (!body) {
424
+ return {
425
+ action: 'guide',
426
+ slug,
427
+ url: src.url,
428
+ reason: 'no SKILL.md found at the expected location — open the link and copy it manually',
429
+ };
430
+ }
431
+
432
+ const valid = validateSkillBody(body);
433
+ if (!valid.ok) {
434
+ return { action: 'invalid', slug, url: usedUrl, reason: valid.reason };
435
+ }
436
+
437
+ await mkdir(destDir, { recursive: true });
438
+ await writeFile(destFile, body, 'utf-8');
439
+ return {
440
+ action: 'installed',
441
+ slug,
442
+ path: destFile,
443
+ source: usedUrl,
444
+ curated: !!entry.curated,
445
+ frontmatter: valid.frontmatter,
446
+ };
447
+ }