import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' import { constants as fsConstants } from 'fs' import { mkdir, open } from 'fs/promises' import { dirname, isAbsolute, join, normalize, sep as pathSep } from 'path' import type { ToolUseContext } from '../Tool.js' import type { Command } from '../types/command.js' import { logForDebugging } from '../utils/debug.js' import { getBundledSkillsRoot } from '../utils/permissions/filesystem.js' import type { HooksSettings } from '../utils/settings/types.js' /** * Definition for a bundled skill that ships with the CLI. * These are registered programmatically at startup. */ export type BundledSkillDefinition = { name: string description: string aliases?: string[] whenToUse?: string argumentHint?: string allowedTools?: string[] model?: string disableModelInvocation?: boolean userInvocable?: boolean isEnabled?: () => boolean hooks?: HooksSettings context?: 'inline' | 'fork' agent?: string /** * Additional reference files to extract to disk on first invocation. * Keys are relative paths (forward slashes, no `..`), values are content. * When set, the skill prompt is prefixed with a "Base directory for this * skill: " line so the model can Read/Grep these files on demand — * same contract as disk-based skills. */ files?: Record getPromptForCommand: ( args: string, context: ToolUseContext, ) => Promise } // Internal registry for bundled skills const bundledSkills: Command[] = [] /** * Register a bundled skill that will be available to the model. * Call this at module initialization or in an init function. * * Bundled skills are compiled into the CLI binary and available to all users. * They follow the same pattern as registerPostSamplingHook() for internal features. */ export function registerBundledSkill(definition: BundledSkillDefinition): void { const { files } = definition let skillRoot: string | undefined let getPromptForCommand = definition.getPromptForCommand if (files && Object.keys(files).length > 0) { skillRoot = getBundledSkillExtractDir(definition.name) // Closure-local memoization: extract once per process. // Memoize the promise (not the result) so concurrent callers await // the same extraction instead of racing into separate writes. let extractionPromise: Promise | undefined const inner = definition.getPromptForCommand getPromptForCommand = async (args, ctx) => { extractionPromise ??= extractBundledSkillFiles(definition.name, files) const extractedDir = await extractionPromise const blocks = await inner(args, ctx) if (extractedDir === null) return blocks return prependBaseDir(blocks, extractedDir) } } const command: Command = { type: 'prompt', name: definition.name, description: definition.description, aliases: definition.aliases, hasUserSpecifiedDescription: true, allowedTools: definition.allowedTools ?? [], argumentHint: definition.argumentHint, whenToUse: definition.whenToUse, model: definition.model, disableModelInvocation: definition.disableModelInvocation ?? false, userInvocable: definition.userInvocable ?? true, contentLength: 0, // Not applicable for bundled skills source: 'bundled', loadedFrom: 'bundled', hooks: definition.hooks, skillRoot, context: definition.context, agent: definition.agent, isEnabled: definition.isEnabled, isHidden: !(definition.userInvocable ?? true), progressMessage: 'running', getPromptForCommand, } bundledSkills.push(command) } /** * Get all registered bundled skills. * Returns a copy to prevent external mutation. */ export function getBundledSkills(): Command[] { return [...bundledSkills] } /** * Clear bundled skills registry (for testing). */ export function clearBundledSkills(): void { bundledSkills.length = 0 } /** * Deterministic extraction directory for a bundled skill's reference files. */ export function getBundledSkillExtractDir(skillName: string): string { return join(getBundledSkillsRoot(), skillName) } /** * Extract a bundled skill's reference files to disk so the model can * Read/Grep them on demand. Called lazily on first skill invocation. * * Returns the directory written to, or null if write failed (skill * continues to work, just without the base-directory prefix). */ async function extractBundledSkillFiles( skillName: string, files: Record, ): Promise { const dir = getBundledSkillExtractDir(skillName) try { await writeSkillFiles(dir, files) return dir } catch (e) { logForDebugging( `Failed to extract bundled skill '${skillName}' to ${dir}: ${e instanceof Error ? e.message : String(e)}`, ) return null } } async function writeSkillFiles( dir: string, files: Record, ): Promise { // Group by parent dir so we mkdir each subtree once, then write. const byParent = new Map() for (const [relPath, content] of Object.entries(files)) { const target = resolveSkillFilePath(dir, relPath) const parent = dirname(target) const entry: [string, string] = [target, content] const group = byParent.get(parent) if (group) group.push(entry) else byParent.set(parent, [entry]) } await Promise.all( [...byParent].map(async ([parent, entries]) => { await mkdir(parent, { recursive: true, mode: 0o700 }) await Promise.all(entries.map(([p, c]) => safeWriteFile(p, c))) }), ) } // The per-process nonce in getBundledSkillsRoot() is the primary defense // against pre-created symlinks/dirs. Explicit 0o700/0o600 modes keep the // nonce subtree owner-only even on umask=0, so an attacker who learns the // nonce via inotify on the predictable parent still can't write into it. // O_NOFOLLOW|O_EXCL is belt-and-suspenders (O_NOFOLLOW only protects the // final component); we deliberately do NOT unlink+retry on EEXIST — unlink() // follows intermediate symlinks too. const O_NOFOLLOW = fsConstants.O_NOFOLLOW ?? 0 // On Windows, use string flags — numeric O_EXCL can produce EINVAL through libuv. const SAFE_WRITE_FLAGS = process.platform === 'win32' ? 'wx' : fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | O_NOFOLLOW async function safeWriteFile(p: string, content: string): Promise { const fh = await open(p, SAFE_WRITE_FLAGS, 0o600) try { await fh.writeFile(content, 'utf8') } finally { await fh.close() } } /** Normalize and validate a skill-relative path; throws on traversal. */ function resolveSkillFilePath(baseDir: string, relPath: string): string { const normalized = normalize(relPath) if ( isAbsolute(normalized) || normalized.split(pathSep).includes('..') || normalized.split('/').includes('..') ) { throw new Error(`bundled skill file path escapes skill dir: ${relPath}`) } return join(baseDir, normalized) } function prependBaseDir( blocks: ContentBlockParam[], baseDir: string, ): ContentBlockParam[] { const prefix = `Base directory for this skill: ${baseDir}\n\n` if (blocks.length > 0 && blocks[0]!.type === 'text') { return [ { type: 'text', text: prefix + blocks[0]!.text }, ...blocks.slice(1), ] } return [{ type: 'text', text: prefix }, ...blocks] }