SPB Git

Repository Public

fsx-skills-scan.ts 6.9 KB 181 lines typescript

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
Files main
Menu

Theme