Add skills system with Claude Code compatibility (#171)

* Add skills system with Claude Code compatibility

* consolidate skills into single module, merge loaders, add <available_skills> XML tags

* add Codex CLI skills compatibility, skip hidden/symlinks
This commit is contained in:
Nico Bailon 2025-12-12 09:24:52 -08:00 committed by GitHub
parent 4d9a06b931
commit 09bca9672f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 376 additions and 11 deletions

View file

@ -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",

View file

@ -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. `<cwd>/.claude/skills/*/SKILL.md` (Claude Code project skills)
4. `~/.pi/agent/skills/**/*.md` (Pi user skills, recursive)
5. `<cwd>/.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`
- `<cwd>/.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
```

View file

@ -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 <level> Set thinking level: off, minimal, low, medium, high, xhigh
--hook <path> Load a hook file (can be used multiple times)
--no-skills Disable skills discovery and loading
--export <file> Export session file to HTML and exit
--help, -h Show this help
--version, -v Show version number

View file

@ -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();
}
}

View file

@ -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<string, Skill>();
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());
}

View file

@ -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<available_skills>",
"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("</available_skills>");
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()}`;

View file

@ -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;