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.
- package/README.md +80 -2
- package/bin/cli.js +194 -0
- package/package.json +1 -1
- package/scaffold/.claude/skills/dynamic-workflows/SKILL.md +105 -0
- package/scaffold/global/.claude/commands/claudenv.md +3 -1
- package/scaffold/global/.claude/commands/harness.md +29 -0
- package/scaffold/global/.claude/skills/claudenv/scaffold/.claude/skills/dynamic-workflows/SKILL.md +105 -0
- package/scaffold/global/.claude/skills/harness/SKILL.md +148 -0
- package/src/bundled-catalog.js +165 -0
- package/src/capabilities.js +164 -0
- package/src/doctor.js +31 -0
- package/src/installer.js +2 -0
- package/src/kimi.js +43 -0
- package/src/loop.js +27 -1
- package/src/memory-context.js +37 -2
- package/src/memory-paths.js +19 -0
- package/src/skills-registry.js +447 -0
package/src/memory-context.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Builds the `--append-system-prompt` payload for `claudenv loop`.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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.
|
|
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 {
|
package/src/memory-paths.js
CHANGED
|
@@ -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
|
+
}
|