source dump of claude code
at main 220 lines 7.5 kB view raw
1import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' 2import { constants as fsConstants } from 'fs' 3import { mkdir, open } from 'fs/promises' 4import { dirname, isAbsolute, join, normalize, sep as pathSep } from 'path' 5import type { ToolUseContext } from '../Tool.js' 6import type { Command } from '../types/command.js' 7import { logForDebugging } from '../utils/debug.js' 8import { getBundledSkillsRoot } from '../utils/permissions/filesystem.js' 9import type { HooksSettings } from '../utils/settings/types.js' 10 11/** 12 * Definition for a bundled skill that ships with the CLI. 13 * These are registered programmatically at startup. 14 */ 15export type BundledSkillDefinition = { 16 name: string 17 description: string 18 aliases?: string[] 19 whenToUse?: string 20 argumentHint?: string 21 allowedTools?: string[] 22 model?: string 23 disableModelInvocation?: boolean 24 userInvocable?: boolean 25 isEnabled?: () => boolean 26 hooks?: HooksSettings 27 context?: 'inline' | 'fork' 28 agent?: string 29 /** 30 * Additional reference files to extract to disk on first invocation. 31 * Keys are relative paths (forward slashes, no `..`), values are content. 32 * When set, the skill prompt is prefixed with a "Base directory for this 33 * skill: <dir>" line so the model can Read/Grep these files on demand — 34 * same contract as disk-based skills. 35 */ 36 files?: Record<string, string> 37 getPromptForCommand: ( 38 args: string, 39 context: ToolUseContext, 40 ) => Promise<ContentBlockParam[]> 41} 42 43// Internal registry for bundled skills 44const bundledSkills: Command[] = [] 45 46/** 47 * Register a bundled skill that will be available to the model. 48 * Call this at module initialization or in an init function. 49 * 50 * Bundled skills are compiled into the CLI binary and available to all users. 51 * They follow the same pattern as registerPostSamplingHook() for internal features. 52 */ 53export function registerBundledSkill(definition: BundledSkillDefinition): void { 54 const { files } = definition 55 56 let skillRoot: string | undefined 57 let getPromptForCommand = definition.getPromptForCommand 58 59 if (files && Object.keys(files).length > 0) { 60 skillRoot = getBundledSkillExtractDir(definition.name) 61 // Closure-local memoization: extract once per process. 62 // Memoize the promise (not the result) so concurrent callers await 63 // the same extraction instead of racing into separate writes. 64 let extractionPromise: Promise<string | null> | undefined 65 const inner = definition.getPromptForCommand 66 getPromptForCommand = async (args, ctx) => { 67 extractionPromise ??= extractBundledSkillFiles(definition.name, files) 68 const extractedDir = await extractionPromise 69 const blocks = await inner(args, ctx) 70 if (extractedDir === null) return blocks 71 return prependBaseDir(blocks, extractedDir) 72 } 73 } 74 75 const command: Command = { 76 type: 'prompt', 77 name: definition.name, 78 description: definition.description, 79 aliases: definition.aliases, 80 hasUserSpecifiedDescription: true, 81 allowedTools: definition.allowedTools ?? [], 82 argumentHint: definition.argumentHint, 83 whenToUse: definition.whenToUse, 84 model: definition.model, 85 disableModelInvocation: definition.disableModelInvocation ?? false, 86 userInvocable: definition.userInvocable ?? true, 87 contentLength: 0, // Not applicable for bundled skills 88 source: 'bundled', 89 loadedFrom: 'bundled', 90 hooks: definition.hooks, 91 skillRoot, 92 context: definition.context, 93 agent: definition.agent, 94 isEnabled: definition.isEnabled, 95 isHidden: !(definition.userInvocable ?? true), 96 progressMessage: 'running', 97 getPromptForCommand, 98 } 99 bundledSkills.push(command) 100} 101 102/** 103 * Get all registered bundled skills. 104 * Returns a copy to prevent external mutation. 105 */ 106export function getBundledSkills(): Command[] { 107 return [...bundledSkills] 108} 109 110/** 111 * Clear bundled skills registry (for testing). 112 */ 113export function clearBundledSkills(): void { 114 bundledSkills.length = 0 115} 116 117/** 118 * Deterministic extraction directory for a bundled skill's reference files. 119 */ 120export function getBundledSkillExtractDir(skillName: string): string { 121 return join(getBundledSkillsRoot(), skillName) 122} 123 124/** 125 * Extract a bundled skill's reference files to disk so the model can 126 * Read/Grep them on demand. Called lazily on first skill invocation. 127 * 128 * Returns the directory written to, or null if write failed (skill 129 * continues to work, just without the base-directory prefix). 130 */ 131async function extractBundledSkillFiles( 132 skillName: string, 133 files: Record<string, string>, 134): Promise<string | null> { 135 const dir = getBundledSkillExtractDir(skillName) 136 try { 137 await writeSkillFiles(dir, files) 138 return dir 139 } catch (e) { 140 logForDebugging( 141 `Failed to extract bundled skill '${skillName}' to ${dir}: ${e instanceof Error ? e.message : String(e)}`, 142 ) 143 return null 144 } 145} 146 147async function writeSkillFiles( 148 dir: string, 149 files: Record<string, string>, 150): Promise<void> { 151 // Group by parent dir so we mkdir each subtree once, then write. 152 const byParent = new Map<string, [string, string][]>() 153 for (const [relPath, content] of Object.entries(files)) { 154 const target = resolveSkillFilePath(dir, relPath) 155 const parent = dirname(target) 156 const entry: [string, string] = [target, content] 157 const group = byParent.get(parent) 158 if (group) group.push(entry) 159 else byParent.set(parent, [entry]) 160 } 161 await Promise.all( 162 [...byParent].map(async ([parent, entries]) => { 163 await mkdir(parent, { recursive: true, mode: 0o700 }) 164 await Promise.all(entries.map(([p, c]) => safeWriteFile(p, c))) 165 }), 166 ) 167} 168 169// The per-process nonce in getBundledSkillsRoot() is the primary defense 170// against pre-created symlinks/dirs. Explicit 0o700/0o600 modes keep the 171// nonce subtree owner-only even on umask=0, so an attacker who learns the 172// nonce via inotify on the predictable parent still can't write into it. 173// O_NOFOLLOW|O_EXCL is belt-and-suspenders (O_NOFOLLOW only protects the 174// final component); we deliberately do NOT unlink+retry on EEXIST — unlink() 175// follows intermediate symlinks too. 176const O_NOFOLLOW = fsConstants.O_NOFOLLOW ?? 0 177// On Windows, use string flags — numeric O_EXCL can produce EINVAL through libuv. 178const SAFE_WRITE_FLAGS = 179 process.platform === 'win32' 180 ? 'wx' 181 : fsConstants.O_WRONLY | 182 fsConstants.O_CREAT | 183 fsConstants.O_EXCL | 184 O_NOFOLLOW 185 186async function safeWriteFile(p: string, content: string): Promise<void> { 187 const fh = await open(p, SAFE_WRITE_FLAGS, 0o600) 188 try { 189 await fh.writeFile(content, 'utf8') 190 } finally { 191 await fh.close() 192 } 193} 194 195/** Normalize and validate a skill-relative path; throws on traversal. */ 196function resolveSkillFilePath(baseDir: string, relPath: string): string { 197 const normalized = normalize(relPath) 198 if ( 199 isAbsolute(normalized) || 200 normalized.split(pathSep).includes('..') || 201 normalized.split('/').includes('..') 202 ) { 203 throw new Error(`bundled skill file path escapes skill dir: ${relPath}`) 204 } 205 return join(baseDir, normalized) 206} 207 208function prependBaseDir( 209 blocks: ContentBlockParam[], 210 baseDir: string, 211): ContentBlockParam[] { 212 const prefix = `Base directory for this skill: ${baseDir}\n\n` 213 if (blocks.length > 0 && blocks[0]!.type === 'text') { 214 return [ 215 { type: 'text', text: prefix + blocks[0]!.text }, 216 ...blocks.slice(1), 217 ] 218 } 219 return [{ type: 'text', text: prefix }, ...blocks] 220}