/ skills / bundledSkills.ts
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  }