Repository Public
1/**2 * Read-only discovery of skills / slash commands / agents below a Claude config directory3 * (`~/.claude` or `<project>/.claude`), including the disabled ones parked under `.disabled/`.4 * Nothing is ever executed; only the first bytes of each Markdown file are read.5 */6import { promises as fsp } from 'node:fs';7import path from 'node:path';8import type { SkillInfo } from '@cr/shared';910export type SkillKind = SkillInfo['kind'];11export const KIND_DIR: Record<SkillKind, string> = { skill: 'skills', command: 'commands', agent: 'agents' };12export const DISABLED_DIR = '.disabled';13const HEAD_BYTES = 16 * 1024;14const MAX_ITEMS_PER_DIR = 500;15const MAX_COMMAND_DEPTH = 4;1617export interface Frontmatter {18 fields: Record<string, string>;19 body: string;20}2122function unquote(v: string): string {23 const t = v.trim();24 if (t.length >= 2 && ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'")))) {25 const inner = t.slice(1, -1);26 return t.startsWith('"') ? inner.replace(/\\(["\\n])/g, (_m, c: string) => (c === 'n' ? ' ' : c)) : inner.replace(/''/g, "'");27 }28 return t.replace(/\s+#.*$/, '');29}3031/**32 * Tiny, safe subset of YAML frontmatter: top-level `key: value` scalars, quoted strings,33 * block scalars (`|`, `>`) and one level of nesting flattened as `parent.child`.34 * No anchors, tags, flow collections or evaluation of any kind.35 */36export function parseFrontmatter(text: string): Frontmatter {37 const src = text.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n');38 if (!src.startsWith('---\n')) return { fields: {}, body: src };39 const end = src.indexOf('\n---', 3);40 if (end < 0) return { fields: {}, body: src };41 const after = src.indexOf('\n', end + 1);42 const body = after < 0 ? '' : src.slice(after + 1);43 const fields: Record<string, string> = Object.create(null) as Record<string, string>;44 let parent: string | null = null;45 let block: { key: string; lines: string[] } | null = null;46 const flush = (): void => {47 if (block) fields[block.key] = block.lines.join(' ').replace(/\s+/g, ' ').trim();48 block = null;49 };50 for (const raw of src.slice(4, end).split('\n')) {51 const indented = /^\s+/.test(raw);52 if (block && (indented || raw.trim() === '')) {53 block.lines.push(raw.trim());54 continue;55 }56 flush();57 if (raw.trim() === '' || raw.trim().startsWith('#')) continue;58 const m = /^(\s*)([A-Za-z0-9_.-]+):(?:\s+(.*)|\s*)$/.exec(raw);59 if (!m) continue;60 const key = m[2] as string;61 const value = (m[3] ?? '').trim();62 if (!indented) parent = null;63 const full = indented && parent ? `${parent}.${key}` : key;64 if (indented && !parent) continue;65 if (value === '') {66 if (!indented) parent = key;67 continue;68 }69 if (/^[|>][+-]?$/.test(value)) {70 block = { key: full, lines: [] };71 continue;72 }73 fields[full] = unquote(value);74 }75 flush();76 return { fields, body };77}7879async function readHead(file: string): Promise<string | null> {80 let fh: fsp.FileHandle | null = null;81 try {82 fh = await fsp.open(file, 'r');83 const buf = Buffer.alloc(HEAD_BYTES);84 const { bytesRead } = await fh.read(buf, 0, HEAD_BYTES, 0);85 return buf.subarray(0, bytesRead).toString('utf8');86 } catch {87 return null;88 } finally {89 await fh?.close().catch(() => undefined);90 }91}9293function describe(fm: Frontmatter): { description: string; version: string | null } {94 let description = fm.fields['description'] ?? '';95 if (!description) {96 const first = fm.body.split('\n').map((l) => l.trim()).find((l) => l !== '' && !l.startsWith('#') && !l.startsWith('---'));97 description = first ?? '';98 }99 const version = fm.fields['version'] ?? fm.fields['metadata.version'] ?? null;100 return { description: description.slice(0, 500), version: version ? version.slice(0, 40) : null };101}102103export interface ScanScope {104 /** Config dir to scan (`~/.claude` or `<root>/.claude`). */105 base: string;106 source: 'user' | 'project';107 /** Follow symlinked entries (user scope only; project scope never leaves the root). */108 followSymlinks: boolean;109 label: (abs: string) => string;110}111112async function entryKind(abs: string, follow: boolean): Promise<'dir' | 'file' | null> {113 try {114 const st = follow ? await fsp.stat(abs) : await fsp.lstat(abs);115 return st.isDirectory() ? 'dir' : st.isFile() ? 'file' : null;116 } catch {117 return null;118 }119}120121async function names(dir: string): Promise<string[]> {122 try {123 return (await fsp.readdir(dir)).filter((n) => !n.startsWith('.')).sort().slice(0, MAX_ITEMS_PER_DIR);124 } catch {125 return [];126 }127}128129export async function scanKindDir(scope: ScanScope, kind: SkillKind, dir: string, enabled: boolean, out: SkillInfo[]): Promise<void> {130 const push = async (name: string, mdFile: string, location: string): Promise<void> => {131 const head = await readHead(mdFile);132 if (head === null) return;133 const { description, version } = describe(parseFrontmatter(head));134 out.push({ name, description, source: scope.source, kind, version, location: scope.label(location), enabled, updateAvailable: null });135 };136 if (kind === 'skill') {137 for (const n of await names(dir)) {138 const abs = path.join(dir, n);139 if ((await entryKind(abs, scope.followSymlinks)) !== 'dir') continue;140 const md = path.join(abs, 'SKILL.md');141 if ((await entryKind(md, scope.followSymlinks)) === 'file') await push(n, md, abs);142 }143 return;144 }145 if (kind === 'agent') {146 for (const n of await names(dir)) {147 const abs = path.join(dir, n);148 if (n.endsWith('.md') && (await entryKind(abs, scope.followSymlinks)) === 'file') await push(n.slice(0, -3), abs, abs);149 }150 return;151 }152 // commands: nested directories namespace the command (`git/commit.md` → `git:commit`).153 const walk = async (sub: string[], depth: number): Promise<void> => {154 const here = path.join(dir, ...sub);155 for (const n of await names(here)) {156 if (out.length > 5000) return;157 const abs = path.join(here, n);158 const k = await entryKind(abs, scope.followSymlinks);159 if (k === 'dir' && depth < MAX_COMMAND_DEPTH) await walk([...sub, n], depth + 1);160 else if (k === 'file' && n.endsWith('.md')) await push([...sub, n.slice(0, -3)].join(':'), abs, abs);161 }162 };163 await walk([], 0);164}165166/** All skills/commands/agents of one scope, enabled first then disabled. */167export async function scanScope(scope: ScanScope): Promise<SkillInfo[]> {168 const out: SkillInfo[] = [];169 for (const enabled of [true, false]) {170 const base = enabled ? scope.base : path.join(scope.base, DISABLED_DIR);171 // Without followSymlinks every directory on the way must be a real directory (lstat).172 if ((await entryKind(base, scope.followSymlinks)) !== 'dir') continue;173 for (const kind of ['skill', 'command', 'agent'] as const) {174 const dir = path.join(base, KIND_DIR[kind]);175 if ((await entryKind(dir, scope.followSymlinks)) !== 'dir') continue;176 await scanKindDir(scope, kind, dir, enabled, out);177 }178 }179 return out;180}181