SPB Git

Repository Public

skills.ts 10.7 KB 236 lines typescript

1/**2 * Skills IPC (runtime-fs): GET /skills, POST /skills/op.3 * Discovery is read-only (fsx-skills-scan.ts). Operations only move, delete or copy files:4 * skill code is never executed, sourced or imported.5 */6import { randomBytes } from 'node:crypto';7import { constants as fsc, promises as fsp } from 'node:fs';8import path from 'node:path';9import { z } from 'zod';10import type { SkillInfo } from '@cr/shared';11import type { RuntimeConfig } from './config.js';12import { RtError, type RuntimePlugin } from './plugins.js';13import { assertRootAllowed, resolveInsideEx } from './paths.js';14import { defaultClaudeEnv, tildify, type ClaudeEnv } from './fsx-claude-env.js';15import { DISABLED_DIR, KIND_DIR, scanScope, type SkillKind } from './fsx-skills-scan.js';16import { scanCodexSkills } from './codex.js';1718export const MAX_INSTALL_BYTES = 20 * 1024 * 1024;19const MAX_INSTALL_FILES = 2000;20const NAME = /^[A-Za-z0-9._-]+$/;2122export interface SkillsOptions {23  env?: ClaudeEnv;24}2526const Kind = z.enum(['skill', 'command', 'agent']);27const Scope = z.enum(['user', 'project']);28const toggle = { kind: Kind, name: z.string().min(1).max(200), scope: Scope.default('user'), root: z.string().optional() };29const OpBody = z.discriminatedUnion('op', [30  z.object({ op: z.literal('disable'), ...toggle }),31  z.object({ op: z.literal('enable'), ...toggle }),32  z.object({ op: z.literal('remove'), ...toggle, confirm: z.literal(true) }),33  z.object({ op: z.literal('install'), sourceDir: z.string().min(1), root: z.string().min(1), replace: z.boolean().optional() }),34]);35type ToggleBody = Extract<z.infer<typeof OpBody>, { op: 'disable' | 'enable' | 'remove' }>;36const ListQuery = z.object({ root: z.string().min(1).optional() });3738function validName(name: string): boolean {39  return NAME.test(name) && name !== '.' && name !== '..' && !name.startsWith('.');40}4142/** Path of an item relative to its config dir, e.g. `skills/foo`, `commands/git/commit.md`. */43export function itemRel(kind: SkillKind, name: string): string {44  const segments = kind === 'command' ? name.split(':') : [name];45  if (segments.length === 0 || segments.length > 5 || !segments.every(validName)) {46    throw new RtError(400, 'bad_name', 'name must match ^[A-Za-z0-9._-]+$');47  }48  const leaf = segments.join('/');49  return `${KIND_DIR[kind]}/${kind === 'skill' ? leaf : `${leaf}.md`}`;50}5152async function exists(p: string): Promise<boolean> {53  return fsp.lstat(p).then(54    () => true,55    () => false,56  );57}5859interface Locations {60  live: string;61  disabled: string;62}63/** Where items live per agent profile: user config dir and the project dot-directory. */64function bases(config: RuntimeConfig, env: ClaudeEnv): { user: string; projectDot: string } {65  return config.agent === 'codex' ? { user: env.codexHome, projectDot: '.agents' } : { user: env.configDir, projectDot: '.claude' };66}6768async function locate(config: RuntimeConfig, env: ClaudeEnv, body: ToggleBody): Promise<Locations> {69  // Codex has skills only: AGENTS.md instruction files are listed, never moved or deleted here.70  if (config.agent === 'codex' && body.kind !== 'skill') {71    throw new RtError(400, 'unsupported', 'only skills can be enabled, disabled or removed for Codex');72  }73  const rel = itemRel(body.kind, body.name);74  const base = bases(config, env);75  if (body.scope === 'user') {76    return { live: path.join(base.user, rel), disabled: path.join(base.user, DISABLED_DIR, rel) };77  }78  if (!body.root) throw new RtError(400, 'bad_request', 'root is required for project scope');79  // Project scope: every path is resolved inside the project root (no symlink can lead out).80  const opts = { forWrite: true, noFollowFinal: true } as const;81  const live = await resolveInsideEx(config, body.root, `${base.projectDot}/${rel}`, opts);82  const disabled = await resolveInsideEx(config, body.root, `${base.projectDot}/${DISABLED_DIR}/${rel}`, opts);83  return { live: live.abs, disabled: disabled.abs };84}8586async function move(from: string, to: string): Promise<void> {87  if (!(await exists(from))) throw new RtError(404, 'not_found', 'item does not exist');88  if (await exists(to)) throw new RtError(409, 'exists', 'an item with this name already exists at the destination');89  await fsp.mkdir(path.dirname(to), { recursive: true });90  await fsp.rename(from, to);91}9293interface SourceFile {94  rel: string;95  abs: string;96  mode: number;97  dir: boolean;98}99/** lstat-walk of the source; refuses symlinks / special files and enforces the size cap. */100async function inventory(src: string): Promise<SourceFile[]> {101  const out: SourceFile[] = [];102  let total = 0;103  const walk = async (rel: string): Promise<void> => {104    const dirents = await fsp.readdir(path.join(src, rel), { withFileTypes: true });105    for (const d of dirents) {106      const childRel = rel ? `${rel}/${d.name}` : d.name;107      const abs = path.join(src, childRel);108      const st = await fsp.lstat(abs);109      if (st.isSymbolicLink()) throw new RtError(400, 'symlink_in_source', 'the skill directory contains a symbolic link');110      if (st.isDirectory()) {111        if (d.name === '.git') continue;112        out.push({ rel: childRel, abs, mode: 0o755, dir: true });113        await walk(childRel);114      } else if (st.isFile()) {115        total += st.size;116        out.push({ rel: childRel, abs, mode: st.mode & 0o777, dir: false });117      } else {118        throw new RtError(400, 'special_file', 'the skill directory contains a special file');119      }120      if (total > MAX_INSTALL_BYTES || out.length > MAX_INSTALL_FILES) {121        throw new RtError(413, 'too_large', 'the skill directory is too large (20 MiB / 2000 entries max)');122      }123    }124  };125  await walk('');126  return out;127}128129async function install(config: RuntimeConfig, env: ClaudeEnv, body: Extract<z.infer<typeof OpBody>, { op: 'install' }>): Promise<{ ok: true; name: string }> {130  const src = await resolveInsideEx(config, body.root, body.sourceDir);131  if (!src.exists || !src.stats?.isDirectory()) throw new RtError(404, 'not_found', 'source directory does not exist');132  if (src.abs === src.rootReal) throw new RtError(400, 'bad_request', 'the project root itself cannot be installed as a skill');133  const name = path.basename(src.abs);134  if (!validName(name)) throw new RtError(400, 'bad_name', 'directory name must match ^[A-Za-z0-9._-]+$');135  const manifest = await fsp.lstat(path.join(src.abs, 'SKILL.md')).catch(() => null);136  if (manifest?.isSymbolicLink()) throw new RtError(400, 'symlink_in_source', 'the skill directory contains a symbolic link');137  if (!manifest?.isFile()) throw new RtError(400, 'not_a_skill', 'source directory has no SKILL.md');138  const files = await inventory(src.abs);139140  const skillsDir = path.join(bases(config, env).user, KIND_DIR.skill);141  const dest = path.join(skillsDir, name);142  const destExists = await exists(dest);143  if (destExists && !body.replace) throw new RtError(409, 'exists', 'a skill with this name is already installed');144  await fsp.mkdir(skillsDir, { recursive: true });145  const staging = path.join(skillsDir, `.cr-install-${randomBytes(6).toString('hex')}`);146  let copied = 0;147  try {148    await fsp.mkdir(staging);149    for (const f of files) {150      const target = path.join(staging, f.rel);151      if (f.dir) {152        await fsp.mkdir(target, { mode: 0o755 });153        continue;154      }155      // O_NOFOLLOW: a file swapped for a symlink after the inventory is refused, not followed.156      const fh = await fsp.open(f.abs, fsc.O_RDONLY | fsc.O_NOFOLLOW);157      try {158        const st = await fh.stat();159        if (!st.isFile()) throw new RtError(400, 'special_file', 'the skill directory changed during installation');160        copied += st.size;161        if (copied > MAX_INSTALL_BYTES) throw new RtError(413, 'too_large', 'the skill directory is too large');162        await fsp.writeFile(target, await fh.readFile(), { mode: f.mode, flag: 'wx' });163      } finally {164        await fh.close();165      }166    }167    if (destExists) {168      const old = path.join(skillsDir, `.cr-old-${randomBytes(6).toString('hex')}`);169      await fsp.rename(dest, old);170      try {171        await fsp.rename(staging, dest);172      } catch (e) {173        await fsp.rename(old, dest).catch(() => undefined);174        throw e;175      }176      await fsp.rm(old, { recursive: true, force: true });177    } else {178      await fsp.rename(staging, dest);179    }180  } catch (e) {181    await fsp.rm(staging, { recursive: true, force: true }).catch(() => undefined);182    if (e instanceof RtError) throw e;183    const code = (e as NodeJS.ErrnoException).code;184    if (code === 'ELOOP') throw new RtError(400, 'symlink_in_source', 'the skill directory contains a symbolic link');185    throw new RtError(500, 'install_failed', `installation failed${code ? ` (${code})` : ''}`);186  }187  return { ok: true, name };188}189190export function createSkillsPlugin(opts: SkillsOptions = {}): RuntimePlugin {191  return async (app, { config }) => {192    const env = opts.env ?? defaultClaudeEnv();193    const label = (abs: string): string => tildify(abs, env.home);194195    app.get('/skills', async (req): Promise<SkillInfo[]> => {196      const q = ListQuery.safeParse(req.query);197      if (!q.success) throw new RtError(400, 'bad_request', 'invalid query');198      if (config.agent === 'codex') {199        const root = q.data.root ? await assertRootAllowed(config, q.data.root) : null;200        return scanCodexSkills({ codexHome: env.codexHome, home: env.home, root, label });201      }202      const out = await scanScope({ base: env.configDir, source: 'user', followSymlinks: true, label });203      if (q.data.root) {204        const rootReal = await assertRootAllowed(config, q.data.root);205        const st = await fsp.lstat(path.join(rootReal, '.claude')).catch(() => null);206        // A `.claude` that is a symlink is not scanned: it could point outside the project.207        if (st?.isDirectory()) {208          out.push(...(await scanScope({ base: path.join(rootReal, '.claude'), source: 'project', followSymlinks: false, label })));209        }210      }211      return out;212    });213214    app.post('/skills/op', async (req) => {215      const parsed = OpBody.safeParse(req.body);216      if (!parsed.success) {217        const issue = parsed.error.issues[0];218        throw new RtError(400, 'bad_request', `invalid ${issue?.path.join('.') || 'body'}`);219      }220      const body = parsed.data;221      if (body.op === 'install') return install(config, env, body);222      const loc = await locate(config, env, body);223      if (body.op === 'disable') await move(loc.live, loc.disabled);224      else if (body.op === 'enable') await move(loc.disabled, loc.live);225      else {226        const target = (await exists(loc.live)) ? loc.live : (await exists(loc.disabled)) ? loc.disabled : null;227        if (!target) throw new RtError(404, 'not_found', 'item does not exist');228        await fsp.rm(target, { recursive: true, force: false });229      }230      return { ok: true, name: body.name };231    });232  };233}234235export default createSkillsPlugin();236
Files main
Menu

Theme