bundledSkills.ts
1 import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' 2 import { constants as fsConstants } from 'fs' 3 import { mkdir, open } from 'fs/promises' 4 import { dirname, isAbsolute, join, normalize, sep as pathSep } from 'path' 5 import type { ToolUseContext } from '../Tool.js' 6 import type { Command } from '../types/command.js' 7 import { logForDebugging } from '../utils/debug.js' 8 import { getBundledSkillsRoot } from '../utils/permissions/filesystem.js' 9 import 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 */ 15 export 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 44 const 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 */ 53 export 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 */ 106 export function getBundledSkills(): Command[] { 107 return [...bundledSkills] 108 } 109 110 /** 111 * Clear bundled skills registry (for testing). 112 */ 113 export function clearBundledSkills(): void { 114 bundledSkills.length = 0 115 } 116 117 /** 118 * Deterministic extraction directory for a bundled skill's reference files. 119 */ 120 export 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 */ 131 async 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 147 async 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. 176 const O_NOFOLLOW = fsConstants.O_NOFOLLOW ?? 0 177 // On Windows, use string flags — numeric O_EXCL can produce EINVAL through libuv. 178 const 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 186 async 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. */ 196 function 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 208 function 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 }