From 09bca9672fcfa9b94d2ac397e3685e2ab25b09c3 Mon Sep 17 00:00:00 2001 From: Nico Bailon Date: Fri, 12 Dec 2025 09:24:52 -0800 Subject: [PATCH] Add skills system with Claude Code compatibility (#171) * Add skills system with Claude Code compatibility * consolidate skills into single module, merge loaders, add XML tags * add Codex CLI skills compatibility, skip hidden/symlinks --- packages/ai/src/models.generated.ts | 10 +- packages/coding-agent/docs/skills.md | 98 +++++++++ packages/coding-agent/src/cli/args.ts | 4 + .../coding-agent/src/core/settings-manager.ts | 17 ++ packages/coding-agent/src/core/skills.ts | 199 ++++++++++++++++++ .../coding-agent/src/core/system-prompt.ts | 51 ++++- packages/coding-agent/src/main.ts | 8 +- 7 files changed, 376 insertions(+), 11 deletions(-) create mode 100644 packages/coding-agent/docs/skills.md create mode 100644 packages/coding-agent/src/core/skills.ts diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 85f8ad9b..cfddead5 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -2773,7 +2773,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 163840, - maxTokens: 65536, + maxTokens: 163840, } satisfies Model<"openai-completions">, "prime-intellect/intellect-3": { id: "prime-intellect/intellect-3", @@ -3260,13 +3260,13 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.14, - output: 1, + input: 0.15, + output: 0.6, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131072, - maxTokens: 131072, + contextWindow: 262144, + maxTokens: 4096, } satisfies Model<"openai-completions">, "openai/gpt-5-pro": { id: "openai/gpt-5-pro", diff --git a/packages/coding-agent/docs/skills.md b/packages/coding-agent/docs/skills.md new file mode 100644 index 00000000..91ba9f79 --- /dev/null +++ b/packages/coding-agent/docs/skills.md @@ -0,0 +1,98 @@ +# Skills + +Skills are instruction files that the agent loads on-demand for specific tasks. + +## Skill Locations + +Skills are discovered from these locations (in order of priority, later wins on name collision): + +1. `~/.codex/skills/**/SKILL.md` (Codex CLI user skills, recursive) +2. `~/.claude/skills/*/SKILL.md` (Claude Code user skills) +3. `/.claude/skills/*/SKILL.md` (Claude Code project skills) +4. `~/.pi/agent/skills/**/*.md` (Pi user skills, recursive) +5. `/.pi/skills/**/*.md` (Pi project skills, recursive) + +Skill names and descriptions are listed in the system prompt. When a task matches a skill's description, the agent uses the `read` tool to load it. + +## Creating Skills + +A skill is a markdown file with YAML frontmatter containing a `description` field: + +```markdown +--- +description: Extract text and tables from PDF files +--- + +# PDF Processing Instructions + +1. Use `pdftotext` to extract plain text +2. For tables, use `tabula-py` or similar +3. Always verify extraction quality + +Scripts are in: {baseDir}/scripts/ +``` + +### Frontmatter Fields + +| Field | Required | Description | +|-------|----------|-------------| +| `description` | Yes | Short description for skill selection | +| `name` | No | Override skill name (defaults to filename or directory name) | + +The parser only supports single-line `key: value` syntax. Multiline YAML blocks are not supported. + +### Variables + +`{baseDir}` is replaced with the directory containing the skill file. Use it to reference bundled scripts or resources. + +### Subdirectories (Pi Skills) + +Pi skills in subdirectories use colon-separated names: +- `~/.pi/agent/skills/db/migrate.md` → `db:migrate` +- `/.pi/skills/aws/s3/upload.md` → `aws:s3:upload` + +## Claude Code Compatibility + +Pi reads Claude Code skills from `~/.claude/skills/*/SKILL.md`. The `allowed-tools` and `model` frontmatter fields are ignored since Pi cannot enforce them. + +## Codex CLI Compatibility + +Pi reads Codex CLI skills from `~/.codex/skills/`. Unlike Claude Code skills (one level deep), Codex skills are scanned recursively, matching Codex CLI's behavior. Hidden files/directories (starting with `.`) and symlinks are skipped. + +## Disabling Skills + +CLI flag: +```bash +pi --no-skills +``` + +Or in `~/.pi/agent/settings.json`: +```json +{ + "skills": { + "enabled": false + } +} +``` + +## Example + +```markdown +--- +description: Perform code review with security and performance analysis +--- + +# Code Review + +Analyze: + +## Security +- Input validation +- SQL injection +- XSS vulnerabilities + +## Performance +- Algorithm complexity +- Memory usage +- Query efficiency +``` diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 448a183d..4f7de220 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -28,6 +28,7 @@ export interface Args { hooks?: string[]; print?: boolean; export?: string; + noSkills?: boolean; messages: string[]; fileArgs: string[]; } @@ -107,6 +108,8 @@ export function parseArgs(args: string[]): Args { } else if (arg === "--hook" && i + 1 < args.length) { result.hooks = result.hooks ?? []; result.hooks.push(args[++i]); + } else if (arg === "--no-skills") { + result.noSkills = true; } else if (arg.startsWith("@")) { result.fileArgs.push(arg.slice(1)); // Remove @ prefix } else if (!arg.startsWith("-")) { @@ -140,6 +143,7 @@ ${chalk.bold("Options:")} Available: read, bash, edit, write, grep, find, ls --thinking Set thinking level: off, minimal, low, medium, high, xhigh --hook Load a hook file (can be used multiple times) + --no-skills Disable skills discovery and loading --export Export session file to HTML and exit --help, -h Show this help --version, -v Show version number diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 129f9c5f..a7884f6a 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -14,6 +14,10 @@ export interface RetrySettings { baseDelayMs?: number; // default: 2000 (exponential backoff: 2s, 4s, 8s) } +export interface SkillsSettings { + enabled?: boolean; // default: true +} + export interface Settings { lastChangelogVersion?: string; defaultProvider?: string; @@ -28,6 +32,7 @@ export interface Settings { collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full) hooks?: string[]; // Array of hook file paths hookTimeout?: number; // Timeout for hook execution in ms (default: 30000) + skills?: SkillsSettings; } export class SettingsManager { @@ -220,4 +225,16 @@ export class SettingsManager { this.settings.hookTimeout = timeout; this.save(); } + + getSkillsEnabled(): boolean { + return this.settings.skills?.enabled ?? true; + } + + setSkillsEnabled(enabled: boolean): void { + if (!this.settings.skills) { + this.settings.skills = {}; + } + this.settings.skills.enabled = enabled; + this.save(); + } } diff --git a/packages/coding-agent/src/core/skills.ts b/packages/coding-agent/src/core/skills.ts new file mode 100644 index 00000000..de1cf68e --- /dev/null +++ b/packages/coding-agent/src/core/skills.ts @@ -0,0 +1,199 @@ +import { existsSync, readdirSync, readFileSync } from "fs"; +import { homedir } from "os"; +import { basename, dirname, join, resolve } from "path"; +import { CONFIG_DIR_NAME } from "../config.js"; + +export interface SkillFrontmatter { + name?: string; + description: string; +} + +export type SkillSource = "user" | "project" | "claude-user" | "claude-project" | "codex-user"; + +export interface Skill { + name: string; + description: string; + filePath: string; + baseDir: string; + source: SkillSource; +} + +type SkillFormat = "pi" | "claude" | "codex"; + +function stripQuotes(value: string): string { + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + return value.slice(1, -1); + } + return value; +} + +function parseFrontmatter(content: string): { frontmatter: SkillFrontmatter; body: string } { + const frontmatter: SkillFrontmatter = { description: "" }; + + const normalizedContent = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + + if (!normalizedContent.startsWith("---")) { + return { frontmatter, body: normalizedContent }; + } + + const endIndex = normalizedContent.indexOf("\n---", 3); + if (endIndex === -1) { + return { frontmatter, body: normalizedContent }; + } + + const frontmatterBlock = normalizedContent.slice(4, endIndex); + const body = normalizedContent.slice(endIndex + 4).trim(); + + for (const line of frontmatterBlock.split("\n")) { + const match = line.match(/^(\w+):\s*(.*)$/); + if (match) { + const key = match[1]; + const value = stripQuotes(match[2].trim()); + if (key === "name") { + frontmatter.name = value; + } else if (key === "description") { + frontmatter.description = value; + } + } + } + + return { frontmatter, body }; +} + +function loadSkillsFromDir(dir: string, source: SkillSource, format: SkillFormat, subdir: string = ""): Skill[] { + const skills: Skill[] = []; + + if (!existsSync(dir)) { + return skills; + } + + try { + const entries = readdirSync(dir, { withFileTypes: true }); + + for (const entry of entries) { + if (entry.name.startsWith(".")) { + continue; + } + + if (entry.isSymbolicLink()) { + continue; + } + + const fullPath = join(dir, entry.name); + + if (format === "pi") { + if (entry.isDirectory()) { + const newSubdir = subdir ? `${subdir}:${entry.name}` : entry.name; + skills.push(...loadSkillsFromDir(fullPath, source, format, newSubdir)); + } else if (entry.isFile() && entry.name.endsWith(".md")) { + try { + const rawContent = readFileSync(fullPath, "utf-8"); + const { frontmatter } = parseFrontmatter(rawContent); + + if (!frontmatter.description) { + continue; + } + + const nameFromFile = entry.name.slice(0, -3); + const name = frontmatter.name || (subdir ? `${subdir}:${nameFromFile}` : nameFromFile); + + skills.push({ + name, + description: frontmatter.description, + filePath: fullPath, + baseDir: dirname(fullPath), + source, + }); + } catch {} + } + } else if (format === "claude") { + if (!entry.isDirectory()) { + continue; + } + + const skillDir = fullPath; + const skillFile = join(skillDir, "SKILL.md"); + + if (!existsSync(skillFile)) { + continue; + } + + try { + const rawContent = readFileSync(skillFile, "utf-8"); + const { frontmatter } = parseFrontmatter(rawContent); + + if (!frontmatter.description) { + continue; + } + + const name = frontmatter.name || entry.name; + + skills.push({ + name, + description: frontmatter.description, + filePath: skillFile, + baseDir: skillDir, + source, + }); + } catch {} + } else if (format === "codex") { + if (entry.isDirectory()) { + skills.push(...loadSkillsFromDir(fullPath, source, format)); + } else if (entry.isFile() && entry.name === "SKILL.md") { + try { + const rawContent = readFileSync(fullPath, "utf-8"); + const { frontmatter } = parseFrontmatter(rawContent); + + if (!frontmatter.description) { + continue; + } + + const skillDir = dirname(fullPath); + const name = frontmatter.name || basename(skillDir); + + skills.push({ + name, + description: frontmatter.description, + filePath: fullPath, + baseDir: skillDir, + source, + }); + } catch {} + } + } + } + } catch {} + + return skills; +} + +export function loadSkills(): Skill[] { + const skillMap = new Map(); + + const codexUserDir = join(homedir(), ".codex", "skills"); + for (const skill of loadSkillsFromDir(codexUserDir, "codex-user", "codex")) { + skillMap.set(skill.name, skill); + } + + const claudeUserDir = join(homedir(), ".claude", "skills"); + for (const skill of loadSkillsFromDir(claudeUserDir, "claude-user", "claude")) { + skillMap.set(skill.name, skill); + } + + const claudeProjectDir = resolve(process.cwd(), ".claude", "skills"); + for (const skill of loadSkillsFromDir(claudeProjectDir, "claude-project", "claude")) { + skillMap.set(skill.name, skill); + } + + const globalSkillsDir = join(homedir(), CONFIG_DIR_NAME, "agent", "skills"); + for (const skill of loadSkillsFromDir(globalSkillsDir, "user", "pi")) { + skillMap.set(skill.name, skill); + } + + const projectSkillsDir = resolve(process.cwd(), CONFIG_DIR_NAME, "skills"); + for (const skill of loadSkillsFromDir(projectSkillsDir, "project", "pi")) { + skillMap.set(skill.name, skill); + } + + return Array.from(skillMap.values()); +} diff --git a/packages/coding-agent/src/core/system-prompt.ts b/packages/coding-agent/src/core/system-prompt.ts index 305f4912..b0cc6bb7 100644 --- a/packages/coding-agent/src/core/system-prompt.ts +++ b/packages/coding-agent/src/core/system-prompt.ts @@ -6,6 +6,7 @@ import chalk from "chalk"; import { existsSync, readFileSync } from "fs"; import { join, resolve } from "path"; import { getAgentDir, getDocsPath, getReadmePath } from "../config.js"; +import { loadSkills, type Skill } from "./skills.js"; import type { ToolName } from "./tools/index.js"; /** Tool descriptions for system prompt */ @@ -101,12 +102,39 @@ export function loadProjectContextFiles(): Array<{ path: string; content: string return contextFiles; } +function buildSkillsSection(skills: Skill[]): string { + if (skills.length === 0) { + return ""; + } + + const lines = [ + "\n\n", + "The following skills provide specialized instructions for specific tasks.", + "Use the read tool to load a skill's file when the task matches its description.", + "Skills may contain {baseDir} placeholders - replace them with the skill's base directory path.\n", + ]; + + for (const skill of skills) { + lines.push(`- ${skill.name}: ${skill.description}`); + lines.push(` File: ${skill.filePath}`); + lines.push(` Base directory: ${skill.baseDir}`); + } + + lines.push(""); + + return lines.join("\n"); +} + +export interface BuildSystemPromptOptions { + customPrompt?: string; + selectedTools?: ToolName[]; + appendSystemPrompt?: string; + skillsEnabled?: boolean; +} + /** Build the system prompt with tools, guidelines, and context */ -export function buildSystemPrompt( - customPrompt?: string, - selectedTools?: ToolName[], - appendSystemPrompt?: string, -): string { +export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): string { + const { customPrompt, selectedTools, appendSystemPrompt, skillsEnabled = true } = options; const resolvedCustomPrompt = resolvePromptInput(customPrompt, "system prompt"); const resolvedAppendPrompt = resolvePromptInput(appendSystemPrompt, "append system prompt"); @@ -141,6 +169,13 @@ export function buildSystemPrompt( } } + // Append skills section (only if read tool is available) + const customPromptHasRead = !selectedTools || selectedTools.includes("read"); + if (skillsEnabled && customPromptHasRead) { + const skills = loadSkills(); + prompt += buildSkillsSection(skills); + } + // Add date/time and working directory last prompt += `\nCurrent date and time: ${dateTime}`; prompt += `\nCurrent working directory: ${process.cwd()}`; @@ -241,6 +276,12 @@ Documentation: } } + // Append skills section (only if read tool is available) + if (skillsEnabled && hasRead) { + const skills = loadSkills(); + prompt += buildSkillsSection(skills); + } + // Add date/time and working directory last prompt += `\nCurrent date and time: ${dateTime}`; prompt += `\nCurrent working directory: ${process.cwd()}`; diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 635a98fb..4aab1fcc 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -241,7 +241,13 @@ export async function main(args: string[]) { } // Build system prompt - const systemPrompt = buildSystemPrompt(parsed.systemPrompt, parsed.tools, parsed.appendSystemPrompt); + const skillsEnabled = !parsed.noSkills && settingsManager.getSkillsEnabled(); + const systemPrompt = buildSystemPrompt({ + customPrompt: parsed.systemPrompt, + selectedTools: parsed.tools, + appendSystemPrompt: parsed.appendSystemPrompt, + skillsEnabled, + }); // Handle session restoration let modelFallbackMessage: string | null = null;