Repository Public
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