singular-skill 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +15 -0
- package/.claude-plugin/plugin.json +10 -0
- package/LICENSE +21 -0
- package/README.md +399 -2
- package/bin/on-expand.js +277 -0
- package/bin/on-prompt.js +672 -0
- package/bin/on-subagent.js +127 -0
- package/bin/singular-cli.js +52 -0
- package/bin/singular.js +114 -0
- package/config.example.json +23 -0
- package/docs/security.md +110 -0
- package/docs/spec.md +209 -0
- package/docs/testing.md +298 -0
- package/hooks/hooks.json +38 -0
- package/lib/cache.js +93 -0
- package/lib/cli-core.js +862 -0
- package/lib/commands/add.js +339 -0
- package/lib/commands/find.js +195 -0
- package/lib/commands/mode.js +93 -0
- package/lib/commands/status.js +128 -0
- package/lib/config.js +407 -0
- package/lib/context.js +184 -0
- package/lib/git.js +173 -0
- package/lib/inspect.js +183 -0
- package/lib/log.js +67 -0
- package/lib/refs.js +101 -0
- package/lib/registry.js +1152 -0
- package/lib/score.js +416 -0
- package/lib/skill-index.js +684 -0
- package/lib/stack.js +401 -0
- package/package.json +50 -4
- package/skills/singular/SKILL.md +24 -0
- package/skills/singular-find/SKILL.md +18 -0
|
@@ -0,0 +1,684 @@
|
|
|
1
|
+
// lib/skill-index.js — enumerate every skill Claude Code would load on this machine.
|
|
2
|
+
//
|
|
3
|
+
// Zero dependencies. Sync fs on purpose: ~150 skills index in single-digit ms and the
|
|
4
|
+
// per-prompt hook needs determinism more than concurrency.
|
|
5
|
+
//
|
|
6
|
+
// Where skills live (verified on Claude Code 2.1.282, see docs/spec.md + spike facts):
|
|
7
|
+
// <cfg>/skills/<name>/SKILL.md user skill, bare name
|
|
8
|
+
// <cfg>/skills/<sub>/<name>/SKILL.md nested user skill, invocable as <sub>:<name>
|
|
9
|
+
// <cfg>/skills/synced/<bucket>/<name>/SKILL.md claude.ai-synced skill -> anthropic-skills:<name>
|
|
10
|
+
// <cfg>/skills/<dir>/.claude-plugin/plugin.json skills-dir plugin (<dir>@skills-dir)
|
|
11
|
+
// <cwd..repo root>/.claude/skills/<name>/SKILL.md project skill, bare name
|
|
12
|
+
// <cfg>/plugins/installed_plugins.json -> plugins[id][0].installPath (marketplace plugins)
|
|
13
|
+
// <cfg>/plugins/synced/<bucket>/<name>[~g<N>]/ claude.ai-synced plugins (<name>@synced); the
|
|
14
|
+
// generation suffix is optional (manifest.json lists
|
|
15
|
+
// `data` and `cowork-plugin-management` without one,
|
|
16
|
+
// 2.1.293) and the highest generation wins
|
|
17
|
+
// <installPath>/skills/<name>/SKILL.md plugin skill -> <plugin>:<dir basename>
|
|
18
|
+
// <installPath>/commands/<name>.md plugin command, also exposed as <plugin>:<name>
|
|
19
|
+
// plugin.json "skills" entries (string|array) extra skill dirs or containers of skill dirs
|
|
20
|
+
// where <cfg> = <home>/.claude (or $CLAUDE_CONFIG_DIR when no explicit home is given).
|
|
21
|
+
//
|
|
22
|
+
// Never scanned wholesale: <cfg>/plugins/cache (every past version is kept side by side) and
|
|
23
|
+
// <cfg>/plugins/marketplaces (full clones of marketplaces, mostly non-installed plugins).
|
|
24
|
+
// cache/ is only consulted as a fallback when installed_plugins.json is missing.
|
|
25
|
+
// Skipped everywhere: dot-dirs (.trash, .in_use, .git, ...), node_modules, temp_git_*, and plugin
|
|
26
|
+
// skills / commands whose name starts with `_` (the convention for internal partials such as
|
|
27
|
+
// vercel/commands/_conventions.md, which Claude Code does not list).
|
|
28
|
+
|
|
29
|
+
import fs from 'node:fs';
|
|
30
|
+
import os from 'node:os';
|
|
31
|
+
import path from 'node:path';
|
|
32
|
+
|
|
33
|
+
export const INDEX_TTL_MS = 60_000;
|
|
34
|
+
|
|
35
|
+
/** @typedef {{
|
|
36
|
+
* name: string, // bare skill name: directory basename (or command file basename)
|
|
37
|
+
* description: string, // frontmatter description ('' when absent)
|
|
38
|
+
* path: string, // absolute path of SKILL.md / command .md
|
|
39
|
+
* scope: 'user'|'project'|'plugin'|'synced',
|
|
40
|
+
* pluginName?: string, // plugin/synced only: namespace prefix used by the Skill tool
|
|
41
|
+
* pluginId?: string, // plugin only: '<name>@<marketplace>' | '<name>@skills-dir' | '<name>@synced'
|
|
42
|
+
* invocableName: string, // exactly what the Skill tool / slash command expects
|
|
43
|
+
* kind: 'skill'|'command',
|
|
44
|
+
* frontmatterName?: string, // frontmatter `name` when present
|
|
45
|
+
* aliases: string[], // alternative names worth matching on (frontmatter name when it differs)
|
|
46
|
+
* whenToUse?: string, // frontmatter `when_to_use` when present
|
|
47
|
+
* userInvocable: boolean, // false when frontmatter `user-invocable: false`
|
|
48
|
+
* modelInvocable: boolean, // false when frontmatter `disable-model-invocation: true`
|
|
49
|
+
* }} Skill */
|
|
50
|
+
|
|
51
|
+
const SKIP_NAME_RE = /^(\.|node_modules$|temp_git_)/;
|
|
52
|
+
// `<name>` or `<name>~g<N>`; an unsuffixed directory counts as generation 0 so `name~g2` still beats `name`.
|
|
53
|
+
const SYNCED_PLUGIN_RE = /^(.+?)(?:~g(\d+))?$/;
|
|
54
|
+
|
|
55
|
+
const cache = new Map(); // key -> { ts, sig, skills }
|
|
56
|
+
|
|
57
|
+
/** Drop every cached index (tests, or after a known on-disk change). */
|
|
58
|
+
export function clearIndexCache() {
|
|
59
|
+
cache.clear();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Build (or return the cached) index of installed skills.
|
|
64
|
+
* @param {{cwd?: string, home?: string, configDir?: string, ttlMs?: number, includeCommands?: boolean}} [opts]
|
|
65
|
+
* home - HOME root; skills are read from <home>/.claude. Tests pass a temp dir.
|
|
66
|
+
* configDir - overrides <home>/.claude (defaults to $CLAUDE_CONFIG_DIR only when home is not given).
|
|
67
|
+
* ttlMs - in-process cache lifetime (default 60 s, 0 disables).
|
|
68
|
+
* includeCommands - also index plugin commands/*.md (default true; they are Skill-tool invocable).
|
|
69
|
+
* @returns {Promise<Skill[]>} a fresh array (never throws; unreadable parts are skipped).
|
|
70
|
+
*/
|
|
71
|
+
export async function buildIndex(opts = {}) {
|
|
72
|
+
const home = opts.home ?? process.env.HOME ?? os.homedir();
|
|
73
|
+
const cwd = path.resolve(opts.cwd ?? process.cwd());
|
|
74
|
+
const configDir = opts.configDir
|
|
75
|
+
?? (opts.home == null && process.env.CLAUDE_CONFIG_DIR ? process.env.CLAUDE_CONFIG_DIR : path.join(home, '.claude'));
|
|
76
|
+
const ttlMs = opts.ttlMs ?? INDEX_TTL_MS;
|
|
77
|
+
const includeCommands = opts.includeCommands !== false;
|
|
78
|
+
const now = Date.now();
|
|
79
|
+
|
|
80
|
+
let projectDirs = [cwd];
|
|
81
|
+
let sig = '';
|
|
82
|
+
try {
|
|
83
|
+
projectDirs = findProjectDirs(cwd);
|
|
84
|
+
sig = computeSignature(configDir, projectDirs);
|
|
85
|
+
} catch { /* signature is best-effort */ }
|
|
86
|
+
|
|
87
|
+
const key = `${configDir}\0${cwd}\0${includeCommands ? 1 : 0}`;
|
|
88
|
+
const hit = cache.get(key);
|
|
89
|
+
if (hit && ttlMs > 0 && now - hit.ts < ttlMs && hit.sig === sig) return hit.skills.slice();
|
|
90
|
+
|
|
91
|
+
let skills = [];
|
|
92
|
+
try {
|
|
93
|
+
skills = scan({ configDir, home, projectDirs, includeCommands });
|
|
94
|
+
} catch { /* never throw from the hook path */ }
|
|
95
|
+
cache.set(key, { ts: now, sig, skills });
|
|
96
|
+
return skills.slice();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Enumeration
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
function scan({ configDir, home, projectDirs, includeCommands }) {
|
|
104
|
+
const settings = readJson(path.join(configDir, 'settings.json'));
|
|
105
|
+
const enabledPlugins = settings && settings.enabledPlugins && typeof settings.enabledPlugins === 'object'
|
|
106
|
+
? settings.enabledPlugins : {};
|
|
107
|
+
const isEnabled = (id) => enabledPlugins[id] !== false;
|
|
108
|
+
|
|
109
|
+
const pluginRoots = []; // { root, pluginName, pluginId, pluginJson? }
|
|
110
|
+
const userSkills = [];
|
|
111
|
+
const projectSkills = [];
|
|
112
|
+
const out = [];
|
|
113
|
+
|
|
114
|
+
// 1. <cfg>/skills: user skills, synced skills, skills-dir plugins.
|
|
115
|
+
try {
|
|
116
|
+
scanSkillsRoot(path.join(configDir, 'skills'), 'user', userSkills, pluginRoots, isEnabled, out);
|
|
117
|
+
} catch { /* skip */ }
|
|
118
|
+
|
|
119
|
+
// 2. Project skills, nearest directory first, up to the repository root.
|
|
120
|
+
for (const dir of projectDirs) {
|
|
121
|
+
try {
|
|
122
|
+
scanSkillsRoot(path.join(dir, '.claude', 'skills'), 'project', projectSkills, pluginRoots, isEnabled, null);
|
|
123
|
+
} catch { /* skip */ }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 3. Marketplace plugins from installed_plugins.json (fallback: newest cached version).
|
|
127
|
+
const installed = readJson(path.join(configDir, 'plugins', 'installed_plugins.json'));
|
|
128
|
+
if (installed && installed.plugins && typeof installed.plugins === 'object') {
|
|
129
|
+
for (const [id, value] of Object.entries(installed.plugins)) {
|
|
130
|
+
if (!isEnabled(id)) continue;
|
|
131
|
+
const entry = Array.isArray(value) ? value[0] : value;
|
|
132
|
+
if (!entry || typeof entry.installPath !== 'string' || !entry.installPath) continue;
|
|
133
|
+
const root = expandHome(entry.installPath, home);
|
|
134
|
+
pluginRoots.push({ root, pluginName: id.split('@')[0] || id, pluginId: id });
|
|
135
|
+
}
|
|
136
|
+
} else {
|
|
137
|
+
scanCacheFallback(path.join(configDir, 'plugins', 'cache'), pluginRoots, isEnabled);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// 4. claude.ai-synced plugins: <cfg>/plugins/synced/<bucket>/<name>[~g<N>]/ (highest generation wins;
|
|
141
|
+
// only directories that look like a plugin — skills/ or .claude-plugin/ — count).
|
|
142
|
+
try {
|
|
143
|
+
const syncedRoot = path.join(configDir, 'plugins', 'synced');
|
|
144
|
+
for (const bucket of listDirs(syncedRoot)) {
|
|
145
|
+
const best = new Map();
|
|
146
|
+
for (const ent of listDirs(bucket.path)) {
|
|
147
|
+
const m = SYNCED_PLUGIN_RE.exec(ent.name);
|
|
148
|
+
if (!m || !m[1]) continue;
|
|
149
|
+
if (!isDir(path.join(ent.path, 'skills')) && !isDir(path.join(ent.path, '.claude-plugin'))) continue;
|
|
150
|
+
const gen = m[2] ? Number(m[2]) : 0;
|
|
151
|
+
const prev = best.get(m[1]);
|
|
152
|
+
if (!prev || gen > prev.gen) best.set(m[1], { gen, root: ent.path });
|
|
153
|
+
}
|
|
154
|
+
for (const [name, { root }] of best) {
|
|
155
|
+
const pluginId = `${name}@synced`;
|
|
156
|
+
if (isEnabled(pluginId)) pluginRoots.push({ root, pluginName: name, pluginId });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
} catch { /* skip */ }
|
|
160
|
+
|
|
161
|
+
// 5. Dedupe bare names: personal (user) beats project (docs: "personal over project").
|
|
162
|
+
const seenBare = new Set();
|
|
163
|
+
for (const s of userSkills) {
|
|
164
|
+
if (seenBare.has(s.invocableName)) continue;
|
|
165
|
+
seenBare.add(s.invocableName);
|
|
166
|
+
out.push(s);
|
|
167
|
+
}
|
|
168
|
+
for (const s of projectSkills) {
|
|
169
|
+
if (seenBare.has(s.invocableName)) continue;
|
|
170
|
+
seenBare.add(s.invocableName);
|
|
171
|
+
out.push(s);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// 6. Plugin skills are unique by namespaced name; first plugin root wins.
|
|
175
|
+
const seenNamespaced = new Set();
|
|
176
|
+
for (const p of pluginRoots) {
|
|
177
|
+
try {
|
|
178
|
+
scanPluginRoot(p, out, seenNamespaced, includeCommands);
|
|
179
|
+
} catch { /* skip this plugin */ }
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Scan a skills root (<cfg>/skills or <project>/.claude/skills).
|
|
186
|
+
* - bare skills go to `bareOut` (deduped later)
|
|
187
|
+
* - skills-dir plugins go to `pluginRoots`
|
|
188
|
+
* - synced skills (only under <cfg>/skills/synced) go straight to `syncedOut`
|
|
189
|
+
*/
|
|
190
|
+
function scanSkillsRoot(root, scope, bareOut, pluginRoots, isEnabled, syncedOut) {
|
|
191
|
+
for (const ent of listDirs(root)) {
|
|
192
|
+
if (ent.name === 'synced' && syncedOut) {
|
|
193
|
+
scanSyncedSkills(ent.path, syncedOut);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const pluginJson = readJson(path.join(ent.path, '.claude-plugin', 'plugin.json'));
|
|
197
|
+
if (pluginJson) {
|
|
198
|
+
const pluginName = typeof pluginJson.name === 'string' && pluginJson.name ? pluginJson.name : ent.name;
|
|
199
|
+
const pluginId = `${pluginName}@skills-dir`;
|
|
200
|
+
if (isEnabled(pluginId)) pluginRoots.push({ root: ent.path, pluginName, pluginId, pluginJson });
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
const skillFile = path.join(ent.path, 'SKILL.md');
|
|
204
|
+
if (isFile(skillFile)) {
|
|
205
|
+
const s = makeSkill({ file: skillFile, dirName: ent.name, scope, invocableName: ent.name });
|
|
206
|
+
if (s) bareOut.push(s);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
// One nested level: <root>/<sub>/<name>/SKILL.md is invocable as <sub>:<name>.
|
|
210
|
+
for (const sub of listDirs(ent.path)) {
|
|
211
|
+
const nestedFile = path.join(sub.path, 'SKILL.md');
|
|
212
|
+
if (!isFile(nestedFile)) continue;
|
|
213
|
+
const s = makeSkill({ file: nestedFile, dirName: sub.name, scope, invocableName: `${ent.name}:${sub.name}` });
|
|
214
|
+
if (s) bareOut.push(s);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** <cfg>/skills/synced/<bucket>/<name>/SKILL.md -> scope 'synced', invocable as anthropic-skills:<name>. */
|
|
220
|
+
function scanSyncedSkills(syncedDir, out) {
|
|
221
|
+
for (const bucket of listDirs(syncedDir)) {
|
|
222
|
+
const manifest = readJson(path.join(bucket.path, 'manifest.json'));
|
|
223
|
+
const sourceByName = new Map();
|
|
224
|
+
if (manifest && Array.isArray(manifest.skills)) {
|
|
225
|
+
for (const m of manifest.skills) {
|
|
226
|
+
if (m && typeof m.name === 'string') sourceByName.set(m.name, m.source);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
for (const ent of listDirs(bucket.path)) {
|
|
230
|
+
const file = path.join(ent.path, 'SKILL.md');
|
|
231
|
+
if (!isFile(file)) continue;
|
|
232
|
+
// Binary constants on 2.1.282: "anthropic-skills" normally, "claude-ai" when the source is the user's account.
|
|
233
|
+
const pluginName = sourceByName.get(ent.name) === 'account' ? 'claude-ai' : 'anthropic-skills';
|
|
234
|
+
const s = makeSkill({
|
|
235
|
+
file, dirName: ent.name, scope: 'synced', pluginName,
|
|
236
|
+
invocableName: `${pluginName}:${ent.name}`,
|
|
237
|
+
});
|
|
238
|
+
if (s) out.push(s);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Fallback when installed_plugins.json is missing: newest version dir of each cached plugin. */
|
|
244
|
+
function scanCacheFallback(cacheRoot, pluginRoots, isEnabled) {
|
|
245
|
+
for (const mk of listDirs(cacheRoot)) {
|
|
246
|
+
for (const plugin of listDirs(mk.path)) {
|
|
247
|
+
let best = null;
|
|
248
|
+
for (const ver of listDirs(plugin.path)) {
|
|
249
|
+
const st = statSafe(ver.path);
|
|
250
|
+
if (!st) continue;
|
|
251
|
+
if (!best || st.mtimeMs > best.mtimeMs) best = { root: ver.path, mtimeMs: st.mtimeMs };
|
|
252
|
+
}
|
|
253
|
+
if (!best) continue;
|
|
254
|
+
const pluginId = `${plugin.name}@${mk.name}`;
|
|
255
|
+
if (isEnabled(pluginId)) pluginRoots.push({ root: best.root, pluginName: plugin.name, pluginId });
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Index one plugin root: skills/<name>/SKILL.md (depth 1), plugin.json `skills`, commands/*.md. */
|
|
261
|
+
function scanPluginRoot(p, out, seenNamespaced, includeCommands) {
|
|
262
|
+
const { root, pluginName, pluginId } = p;
|
|
263
|
+
const pluginJson = p.pluginJson ?? readJson(path.join(root, '.claude-plugin', 'plugin.json'));
|
|
264
|
+
const prefix = `${pluginName}:`;
|
|
265
|
+
|
|
266
|
+
const add = (file, baseName, kind) => {
|
|
267
|
+
const bare = baseName.startsWith(prefix) ? baseName.slice(prefix.length) : baseName;
|
|
268
|
+
if (!bare || bare.startsWith('_')) return; // `_conventions.md`: internal partial, not routable
|
|
269
|
+
const invocableName = prefix + bare;
|
|
270
|
+
if (seenNamespaced.has(invocableName)) return;
|
|
271
|
+
const s = makeSkill({ file, dirName: bare, scope: 'plugin', pluginName, pluginId, invocableName, kind });
|
|
272
|
+
if (!s || (s.frontmatterName && s.frontmatterName.startsWith('_'))) return;
|
|
273
|
+
seenNamespaced.add(invocableName);
|
|
274
|
+
out.push(s);
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
// Default skills directory: exactly one level, never recurse into a skill dir.
|
|
278
|
+
for (const ent of listDirs(path.join(root, 'skills'))) {
|
|
279
|
+
const file = path.join(ent.path, 'SKILL.md');
|
|
280
|
+
if (isFile(file)) add(file, ent.name, 'skill');
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// plugin.json "skills": string|array of dirs; each is either a skill dir or a container of skill dirs.
|
|
284
|
+
for (const entry of toArray(pluginJson && pluginJson.skills)) {
|
|
285
|
+
if (typeof entry !== 'string' || !entry) continue;
|
|
286
|
+
const abs = path.resolve(root, entry);
|
|
287
|
+
if (!isWithin(root, abs)) continue;
|
|
288
|
+
const direct = path.join(abs, 'SKILL.md');
|
|
289
|
+
if (isFile(direct)) {
|
|
290
|
+
add(direct, path.basename(abs), 'skill');
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
for (const ent of listDirs(abs)) {
|
|
294
|
+
const file = path.join(ent.path, 'SKILL.md');
|
|
295
|
+
if (isFile(file)) add(file, ent.name, 'skill');
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (!includeCommands) return;
|
|
300
|
+
|
|
301
|
+
// commands/*.md are exposed through the Skill tool as <plugin>:<file sans .md>.
|
|
302
|
+
const commandDirs = [path.join(root, 'commands')];
|
|
303
|
+
for (const entry of toArray(pluginJson && pluginJson.commands)) {
|
|
304
|
+
if (typeof entry !== 'string' || !entry) continue;
|
|
305
|
+
const abs = path.resolve(root, entry);
|
|
306
|
+
if (!isWithin(root, abs)) continue;
|
|
307
|
+
if (abs.endsWith('.md') && isFile(abs)) add(abs, path.basename(abs, '.md'), 'command');
|
|
308
|
+
else commandDirs.push(abs);
|
|
309
|
+
}
|
|
310
|
+
for (const dir of commandDirs) {
|
|
311
|
+
for (const ent of listFiles(dir)) {
|
|
312
|
+
if (!ent.name.endsWith('.md')) continue;
|
|
313
|
+
add(ent.path, ent.name.slice(0, -3), 'command');
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
// Skill construction
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
function makeSkill({ file, dirName, scope, pluginName, pluginId, invocableName, kind = 'skill' }) {
|
|
323
|
+
const text = readHead(file);
|
|
324
|
+
if (text == null) return null;
|
|
325
|
+
const fm = parseFrontmatter(text);
|
|
326
|
+
const frontmatterName = fm.name || undefined;
|
|
327
|
+
const aliases = frontmatterName && frontmatterName !== dirName ? [frontmatterName] : [];
|
|
328
|
+
const description = fm.description || firstBodyLine(fm.body);
|
|
329
|
+
const skill = {
|
|
330
|
+
name: dirName,
|
|
331
|
+
description,
|
|
332
|
+
path: file,
|
|
333
|
+
scope,
|
|
334
|
+
};
|
|
335
|
+
if (pluginName) skill.pluginName = pluginName;
|
|
336
|
+
if (pluginId) skill.pluginId = pluginId;
|
|
337
|
+
skill.invocableName = invocableName;
|
|
338
|
+
skill.kind = kind;
|
|
339
|
+
if (frontmatterName) skill.frontmatterName = frontmatterName;
|
|
340
|
+
skill.aliases = aliases;
|
|
341
|
+
const whenToUse = fm.fields['when_to_use'];
|
|
342
|
+
if (typeof whenToUse === 'string' && whenToUse) skill.whenToUse = whenToUse;
|
|
343
|
+
skill.userInvocable = !isFalse(fm.fields['user-invocable']);
|
|
344
|
+
skill.modelInvocable = !isTrue(fm.fields['disable-model-invocation']);
|
|
345
|
+
return skill;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function firstBodyLine(body) {
|
|
349
|
+
if (!body) return '';
|
|
350
|
+
let inFence = false;
|
|
351
|
+
for (const raw of body.split('\n')) {
|
|
352
|
+
const line = raw.trim();
|
|
353
|
+
if (line.startsWith('```') || line.startsWith('~~~')) { inFence = !inFence; continue; }
|
|
354
|
+
if (inFence) continue;
|
|
355
|
+
if (!line || line.startsWith('#') || line.startsWith('<')) continue;
|
|
356
|
+
const clean = line.replace(/[*_`>]/g, '').trim();
|
|
357
|
+
if (clean) return clean.length > 200 ? clean.slice(0, 200) : clean;
|
|
358
|
+
}
|
|
359
|
+
return '';
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function isTrue(v) { return typeof v === 'string' && /^(true|yes|on)$/i.test(v.trim()); }
|
|
363
|
+
function isFalse(v) { return typeof v === 'string' && /^(false|no|off)$/i.test(v.trim()); }
|
|
364
|
+
|
|
365
|
+
// ---------------------------------------------------------------------------
|
|
366
|
+
// Frontmatter (minimal YAML: top-level scalars only, nested blocks kept raw)
|
|
367
|
+
// ---------------------------------------------------------------------------
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Parse the YAML frontmatter block of a SKILL.md / command file.
|
|
371
|
+
* Handles: BOM, CRLF, `# comments`, plain / "double" / 'single' quoted scalars (multi-line too),
|
|
372
|
+
* `>` folded and `|` literal block scalars (with chomping indicators), nested maps/lists (kept as
|
|
373
|
+
* raw text under `fields[key]` so callers can still detect keys such as `hooks`).
|
|
374
|
+
* Only top-level keys are extracted. Never throws.
|
|
375
|
+
* @returns {{name: string|null, description: string|null, fields: Record<string,string>, hasFrontmatter: boolean, body: string}}
|
|
376
|
+
*/
|
|
377
|
+
export function parseFrontmatter(text) {
|
|
378
|
+
const out = { name: null, description: null, fields: {}, hasFrontmatter: false, body: '' };
|
|
379
|
+
if (typeof text !== 'string') return out;
|
|
380
|
+
const src = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
381
|
+
const lines = src.split(/\r?\n/);
|
|
382
|
+
if (lines.length === 0 || lines[0].trim() !== '---') {
|
|
383
|
+
out.body = src;
|
|
384
|
+
return out;
|
|
385
|
+
}
|
|
386
|
+
let end = -1;
|
|
387
|
+
for (let i = 1; i < lines.length; i++) {
|
|
388
|
+
const t = lines[i].trim();
|
|
389
|
+
if (t === '---' || t === '...') { end = i; break; }
|
|
390
|
+
}
|
|
391
|
+
if (end === -1) {
|
|
392
|
+
out.body = src;
|
|
393
|
+
return out;
|
|
394
|
+
}
|
|
395
|
+
out.hasFrontmatter = true;
|
|
396
|
+
out.body = lines.slice(end + 1).join('\n');
|
|
397
|
+
const fm = lines.slice(1, end);
|
|
398
|
+
|
|
399
|
+
let i = 0;
|
|
400
|
+
while (i < fm.length) {
|
|
401
|
+
const line = fm[i];
|
|
402
|
+
if (!line.trim() || /^\s*#/.test(line)) { i++; continue; }
|
|
403
|
+
const m = /^([A-Za-z0-9_][A-Za-z0-9_.\-]*)\s*:(?:\s+(.*))?$/.exec(line);
|
|
404
|
+
if (!m) { i++; continue; } // indented stray line or not a top-level mapping entry
|
|
405
|
+
const key = m[1];
|
|
406
|
+
const rest = (m[2] ?? '').trim();
|
|
407
|
+
let value;
|
|
408
|
+
let next = i + 1;
|
|
409
|
+
|
|
410
|
+
if (rest === '') {
|
|
411
|
+
const block = collectIndented(fm, next);
|
|
412
|
+
next = block.next;
|
|
413
|
+
if (block.lines.length === 0) value = '';
|
|
414
|
+
else if (looksStructured(block.lines)) value = dedent(block.lines).join('\n');
|
|
415
|
+
else value = foldPlain(dedent(block.lines));
|
|
416
|
+
} else if (/^[>|]/.test(rest)) {
|
|
417
|
+
const indicator = rest[0];
|
|
418
|
+
const keep = /\+/.test(rest);
|
|
419
|
+
const strip = /-/.test(rest.slice(0, 3));
|
|
420
|
+
const block = collectIndented(fm, next);
|
|
421
|
+
next = block.next;
|
|
422
|
+
const body = dedent(block.lines);
|
|
423
|
+
value = indicator === '>' ? foldBlock(body) : body.join('\n');
|
|
424
|
+
value = strip ? value.replace(/\n+$/, '') : keep ? value : value.replace(/\n+$/, '');
|
|
425
|
+
} else if (rest[0] === '"') {
|
|
426
|
+
const q = readQuoted(fm, i, line.indexOf('"'), '"');
|
|
427
|
+
value = unescapeDouble(q.raw);
|
|
428
|
+
next = q.next;
|
|
429
|
+
} else if (rest[0] === "'") {
|
|
430
|
+
const q = readQuoted(fm, i, line.indexOf("'"), "'");
|
|
431
|
+
value = q.raw.replace(/''/g, "'");
|
|
432
|
+
next = q.next;
|
|
433
|
+
} else {
|
|
434
|
+
value = stripComment(rest);
|
|
435
|
+
const block = collectIndented(fm, next, true);
|
|
436
|
+
if (block.lines.length) {
|
|
437
|
+
value = foldPlain([value, ...dedent(block.lines)]);
|
|
438
|
+
next = block.next;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
out.fields[key] = value;
|
|
442
|
+
i = next;
|
|
443
|
+
}
|
|
444
|
+
if (typeof out.fields.name === 'string' && out.fields.name.trim()) out.name = out.fields.name.trim();
|
|
445
|
+
if (typeof out.fields.description === 'string' && out.fields.description.trim()) {
|
|
446
|
+
out.description = out.fields.description.trim();
|
|
447
|
+
}
|
|
448
|
+
return out;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Collect the run of indented (or blank) lines starting at `from`; trailing blanks are dropped. */
|
|
452
|
+
function collectIndented(fm, from, stopAtBlank = false) {
|
|
453
|
+
const lines = [];
|
|
454
|
+
let j = from;
|
|
455
|
+
while (j < fm.length) {
|
|
456
|
+
const l = fm[j];
|
|
457
|
+
if (l.trim() === '') {
|
|
458
|
+
if (stopAtBlank) break;
|
|
459
|
+
lines.push('');
|
|
460
|
+
j++;
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
if (!/^\s/.test(l)) break;
|
|
464
|
+
lines.push(l);
|
|
465
|
+
j++;
|
|
466
|
+
}
|
|
467
|
+
while (lines.length && lines[lines.length - 1] === '') { lines.pop(); j--; }
|
|
468
|
+
return { lines, next: j };
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function looksStructured(lines) {
|
|
472
|
+
const first = lines.find((l) => l.trim() !== '') ?? '';
|
|
473
|
+
return /^\s*-(\s|$)/.test(first) || /^\s*[^\s:#][^:]*:(\s|$)/.test(first);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function dedent(lines) {
|
|
477
|
+
let min = Infinity;
|
|
478
|
+
for (const l of lines) {
|
|
479
|
+
if (l.trim() === '') continue;
|
|
480
|
+
const n = /^\s*/.exec(l)[0].length;
|
|
481
|
+
if (n < min) min = n;
|
|
482
|
+
}
|
|
483
|
+
if (!Number.isFinite(min)) min = 0;
|
|
484
|
+
return lines.map((l) => (l.trim() === '' ? '' : l.slice(min)));
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** YAML folded block: lines joined by spaces, blank lines become newlines, more-indented lines kept. */
|
|
488
|
+
function foldBlock(lines) {
|
|
489
|
+
let outText = '';
|
|
490
|
+
let prevBlank = true;
|
|
491
|
+
let prevIndented = false;
|
|
492
|
+
for (const l of lines) {
|
|
493
|
+
if (l === '') { outText += '\n'; prevBlank = true; continue; }
|
|
494
|
+
const indented = /^\s/.test(l);
|
|
495
|
+
if (!prevBlank && !indented && !prevIndented) outText += ' ';
|
|
496
|
+
else if (!prevBlank && (indented || prevIndented)) outText += '\n';
|
|
497
|
+
outText += l;
|
|
498
|
+
prevBlank = false;
|
|
499
|
+
prevIndented = indented;
|
|
500
|
+
}
|
|
501
|
+
return outText.trim();
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** Plain multi-line scalar: continuation lines fold with single spaces. */
|
|
505
|
+
function foldPlain(lines) {
|
|
506
|
+
return lines.map((l) => stripComment(l.trim())).filter(Boolean).join(' ').trim();
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function stripComment(s) {
|
|
510
|
+
return s.replace(/\s+#.*$/, '').trim();
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** Read a quoted scalar that may span lines; returns the raw inner text (escapes intact). */
|
|
514
|
+
function readQuoted(fm, startLine, startCol, quote) {
|
|
515
|
+
let j = startLine;
|
|
516
|
+
let buf = fm[j].slice(startCol + 1);
|
|
517
|
+
let closeAt = findClose(buf, quote, 0);
|
|
518
|
+
while (closeAt === -1 && j + 1 < fm.length) {
|
|
519
|
+
j++;
|
|
520
|
+
const cont = fm[j].trim();
|
|
521
|
+
buf += cont === '' ? '\n' : (buf.endsWith('\n') ? '' : ' ') + cont;
|
|
522
|
+
closeAt = findClose(buf, quote, 0);
|
|
523
|
+
}
|
|
524
|
+
if (closeAt === -1) return { raw: buf.trim(), next: j + 1 };
|
|
525
|
+
return { raw: buf.slice(0, closeAt), next: j + 1 };
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function findClose(s, quote, from) {
|
|
529
|
+
for (let k = from; k < s.length; k++) {
|
|
530
|
+
const c = s[k];
|
|
531
|
+
if (quote === '"' && c === '\\') { k++; continue; }
|
|
532
|
+
if (c === quote) {
|
|
533
|
+
if (quote === "'" && s[k + 1] === "'") { k++; continue; }
|
|
534
|
+
return k;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
return -1;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function unescapeDouble(s) {
|
|
541
|
+
return s.replace(/\\(u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|.)/g, (_, e) => {
|
|
542
|
+
switch (e[0]) {
|
|
543
|
+
case 'n': return '\n';
|
|
544
|
+
case 't': return '\t';
|
|
545
|
+
case 'r': return '\r';
|
|
546
|
+
case '"': return '"';
|
|
547
|
+
case '\\': return '\\';
|
|
548
|
+
case '/': return '/';
|
|
549
|
+
case '0': return '\0';
|
|
550
|
+
case ' ': return ' ';
|
|
551
|
+
case 'u': return String.fromCharCode(parseInt(e.slice(1), 16));
|
|
552
|
+
case 'x': return String.fromCharCode(parseInt(e.slice(1), 16));
|
|
553
|
+
default: return e;
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// ---------------------------------------------------------------------------
|
|
559
|
+
// Cache signature and project discovery
|
|
560
|
+
// ---------------------------------------------------------------------------
|
|
561
|
+
|
|
562
|
+
/** cwd and its parents up to (and including) the first directory that contains .git; else just cwd. */
|
|
563
|
+
export function findProjectDirs(cwd) {
|
|
564
|
+
const dirs = [];
|
|
565
|
+
let d = path.resolve(cwd);
|
|
566
|
+
for (let i = 0; i < 64; i++) {
|
|
567
|
+
dirs.push(d);
|
|
568
|
+
if (exists(path.join(d, '.git'))) return dirs;
|
|
569
|
+
const parent = path.dirname(d);
|
|
570
|
+
if (parent === d) break;
|
|
571
|
+
d = parent;
|
|
572
|
+
}
|
|
573
|
+
return [path.resolve(cwd)];
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function computeSignature(configDir, projectDirs) {
|
|
577
|
+
const parts = [];
|
|
578
|
+
const push = (p) => { const st = statSafe(p); parts.push(st ? Math.floor(st.mtimeMs) : 0); };
|
|
579
|
+
const skillsDir = path.join(configDir, 'skills');
|
|
580
|
+
push(skillsDir);
|
|
581
|
+
for (const b of listDirs(path.join(skillsDir, 'synced'))) push(b.path);
|
|
582
|
+
push(path.join(configDir, 'plugins', 'installed_plugins.json'));
|
|
583
|
+
for (const b of listDirs(path.join(configDir, 'plugins', 'synced'))) push(b.path);
|
|
584
|
+
push(path.join(configDir, 'settings.json'));
|
|
585
|
+
for (const d of projectDirs) push(path.join(d, '.claude', 'skills'));
|
|
586
|
+
return parts.join(',');
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// ---------------------------------------------------------------------------
|
|
590
|
+
// fs helpers (all swallow errors)
|
|
591
|
+
// ---------------------------------------------------------------------------
|
|
592
|
+
|
|
593
|
+
function listDirs(dir) {
|
|
594
|
+
const out = [];
|
|
595
|
+
let ents;
|
|
596
|
+
try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return out; }
|
|
597
|
+
for (const ent of ents) {
|
|
598
|
+
if (SKIP_NAME_RE.test(ent.name)) continue;
|
|
599
|
+
const full = path.join(dir, ent.name);
|
|
600
|
+
if (ent.isDirectory()) out.push({ name: ent.name, path: full });
|
|
601
|
+
else if (ent.isSymbolicLink()) {
|
|
602
|
+
const st = statSafe(full);
|
|
603
|
+
if (st && st.isDirectory()) out.push({ name: ent.name, path: full });
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
return out;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function listFiles(dir) {
|
|
610
|
+
const out = [];
|
|
611
|
+
let ents;
|
|
612
|
+
try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return out; }
|
|
613
|
+
for (const ent of ents) {
|
|
614
|
+
if (ent.name.startsWith('.')) continue;
|
|
615
|
+
const full = path.join(dir, ent.name);
|
|
616
|
+
if (ent.isFile()) out.push({ name: ent.name, path: full });
|
|
617
|
+
else if (ent.isSymbolicLink()) {
|
|
618
|
+
const st = statSafe(full);
|
|
619
|
+
if (st && st.isFile()) out.push({ name: ent.name, path: full });
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
return out;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function statSafe(p) {
|
|
626
|
+
try { return fs.statSync(p); } catch { return null; }
|
|
627
|
+
}
|
|
628
|
+
function exists(p) { return statSafe(p) !== null; }
|
|
629
|
+
function isFile(p) { const st = statSafe(p); return !!st && st.isFile(); }
|
|
630
|
+
function isDir(p) { const st = statSafe(p); return !!st && st.isDirectory(); }
|
|
631
|
+
|
|
632
|
+
function readText(p) {
|
|
633
|
+
try { return fs.readFileSync(p, 'utf8'); } catch { return null; }
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const HEAD_BYTES = 16 * 1024;
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Read enough of a skill file to parse its frontmatter (and the first body line): the first 16 KB,
|
|
640
|
+
* or the whole file when it is small or its frontmatter has not closed within that window.
|
|
641
|
+
* Large SKILL.md bodies (vercel ships 48 KB ones) are otherwise the dominant indexing cost.
|
|
642
|
+
*/
|
|
643
|
+
function readHead(p) {
|
|
644
|
+
let fd;
|
|
645
|
+
try {
|
|
646
|
+
fd = fs.openSync(p, 'r');
|
|
647
|
+
const buf = Buffer.allocUnsafe(HEAD_BYTES);
|
|
648
|
+
const n = fs.readSync(fd, buf, 0, HEAD_BYTES, 0);
|
|
649
|
+
const text = buf.toString('utf8', 0, n);
|
|
650
|
+
if (n < HEAD_BYTES) return text; // whole file
|
|
651
|
+
const start = text.charCodeAt(0) === 0xfeff ? 1 : 0;
|
|
652
|
+
if (!text.startsWith('---', start)) return text; // no frontmatter; head is enough
|
|
653
|
+
if (text.indexOf('\n---', start + 3) !== -1 || text.indexOf('\n...', start + 3) !== -1) return text;
|
|
654
|
+
} catch {
|
|
655
|
+
return null;
|
|
656
|
+
} finally {
|
|
657
|
+
if (fd !== undefined) { try { fs.closeSync(fd); } catch { /* ignore */ } }
|
|
658
|
+
}
|
|
659
|
+
return readText(p);
|
|
660
|
+
}
|
|
661
|
+
function readJson(p) {
|
|
662
|
+
const t = readText(p);
|
|
663
|
+
if (t == null) return null;
|
|
664
|
+
try {
|
|
665
|
+
const v = JSON.parse(t);
|
|
666
|
+
return v && typeof v === 'object' ? v : null;
|
|
667
|
+
} catch { return null; }
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function toArray(v) {
|
|
671
|
+
if (v == null) return [];
|
|
672
|
+
return Array.isArray(v) ? v : [v];
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function isWithin(root, target) {
|
|
676
|
+
const rel = path.relative(root, target);
|
|
677
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function expandHome(p, home) {
|
|
681
|
+
if (p === '~') return home;
|
|
682
|
+
if (p.startsWith('~/')) return path.join(home, p.slice(2));
|
|
683
|
+
return p;
|
|
684
|
+
}
|