From 70440f7591026bf3ef987e1ab7200d4ff62d6c4a Mon Sep 17 00:00:00 2001 From: Nico Bailon Date: Sun, 21 Dec 2025 11:48:40 -0800 Subject: [PATCH] feat(coding-agent): add configurable skills directories (#269) --- packages/coding-agent/docs/skills.md | 32 ++++++++++ .../coding-agent/src/core/agent-session.ts | 10 ++- .../coding-agent/src/core/settings-manager.ts | 20 ++++++ packages/coding-agent/src/core/skills.ts | 51 +++++++++------ .../coding-agent/src/core/system-prompt.ts | 13 ++-- packages/coding-agent/src/index.ts | 1 + packages/coding-agent/src/main.ts | 8 ++- .../src/modes/interactive/interactive-mode.ts | 27 ++++---- packages/coding-agent/test/skills.test.ts | 63 ++++++++++++++++++- 9 files changed, 186 insertions(+), 39 deletions(-) diff --git a/packages/coding-agent/docs/skills.md b/packages/coding-agent/docs/skills.md index de433888..4b9c56b0 100644 --- a/packages/coding-agent/docs/skills.md +++ b/packages/coding-agent/docs/skills.md @@ -145,6 +145,36 @@ Skills are discovered from these locations (later wins on name collision): 4. `~/.pi/agent/skills/**/SKILL.md` (Pi user, recursive) 5. `/.pi/skills/**/SKILL.md` (Pi project, recursive) +## Configuration + +Configure skill loading in `~/.pi/agent/settings.json`: + +```json +{ + "skills": { + "enabled": true, + "enableCodexUser": true, + "enableClaudeUser": true, + "enableClaudeProject": true, + "enablePiUser": true, + "enablePiProject": true, + "customDirectories": ["~/my-skills-repo"], + "ignoredSkills": ["deprecated-skill"] + } +} +``` + +| Setting | Default | Description | +|---------|---------|-------------| +| `enabled` | `true` | Master toggle for all skills | +| `enableCodexUser` | `true` | Load from `~/.codex/skills/` | +| `enableClaudeUser` | `true` | Load from `~/.claude/skills/` | +| `enableClaudeProject` | `true` | Load from `/.claude/skills/` | +| `enablePiUser` | `true` | Load from `~/.pi/agent/skills/` | +| `enablePiProject` | `true` | Load from `/.pi/skills/` | +| `customDirectories` | `[]` | Additional directories to scan (supports `~` expansion) | +| `ignoredSkills` | `[]` | Skill names to exclude | + ## How Skills Work 1. At startup, pi scans skill locations and extracts names + descriptions @@ -233,3 +263,5 @@ Settings (`~/.pi/agent/settings.json`): } } ``` + +Use the granular `enable*` flags to disable individual sources (e.g., `enableClaudeUser: false` to skip `~/.claude/skills`). diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 4c1bbda6..5000c441 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -25,7 +25,7 @@ import type { BranchEventResult, HookRunner, TurnEndEvent, TurnStartEvent } from import type { BashExecutionMessage } from "./messages.js"; import { getApiKeyForModel, getAvailableModels } from "./model-config.js"; import { loadSessionFromEntries, type SessionManager } from "./session-manager.js"; -import type { SettingsManager } from "./settings-manager.js"; +import type { SettingsManager, SkillsSettings } from "./settings-manager.js"; import { expandSlashCommand, type FileSlashCommand } from "./slash-commands.js"; /** Session-specific events that extend the core AgentEvent */ @@ -55,6 +55,7 @@ export interface AgentSessionConfig { hookRunner?: HookRunner | null; /** Custom tools for session lifecycle events */ customTools?: LoadedCustomTool[]; + skillsSettings?: Required; } /** Options for AgentSession.prompt() */ @@ -148,6 +149,8 @@ export class AgentSession { // Custom tools for session lifecycle private _customTools: LoadedCustomTool[] = []; + private _skillsSettings: Required | undefined; + constructor(config: AgentSessionConfig) { this.agent = config.agent; this.sessionManager = config.sessionManager; @@ -156,6 +159,7 @@ export class AgentSession { this._fileCommands = config.fileCommands ?? []; this._hookRunner = config.hookRunner ?? null; this._customTools = config.customTools ?? []; + this._skillsSettings = config.skillsSettings; } // ========================================================================= @@ -485,6 +489,10 @@ export class AgentSession { return this._queuedMessages; } + get skillsSettings(): Required | undefined { + return this._skillsSettings; + } + /** * Abort current operation and wait for agent to become idle. */ diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 46480bc6..d04a5156 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -16,6 +16,13 @@ export interface RetrySettings { export interface SkillsSettings { enabled?: boolean; // default: true + enableCodexUser?: boolean; // default: true + enableClaudeUser?: boolean; // default: true + enableClaudeProject?: boolean; // default: true + enablePiUser?: boolean; // default: true + enablePiProject?: boolean; // default: true + customDirectories?: string[]; // default: [] + ignoredSkills?: string[]; // default: [] } export interface TerminalSettings { @@ -253,6 +260,19 @@ export class SettingsManager { this.save(); } + getSkillsSettings(): Required { + return { + enabled: this.settings.skills?.enabled ?? true, + enableCodexUser: this.settings.skills?.enableCodexUser ?? true, + enableClaudeUser: this.settings.skills?.enableClaudeUser ?? true, + enableClaudeProject: this.settings.skills?.enableClaudeProject ?? true, + enablePiUser: this.settings.skills?.enablePiUser ?? true, + enablePiProject: this.settings.skills?.enablePiProject ?? true, + customDirectories: this.settings.skills?.customDirectories ?? [], + ignoredSkills: this.settings.skills?.ignoredSkills ?? [], + }; + } + getShowImages(): boolean { return this.settings.terminal?.showImages ?? true; } diff --git a/packages/coding-agent/src/core/skills.ts b/packages/coding-agent/src/core/skills.ts index 969a82ce..0e396012 100644 --- a/packages/coding-agent/src/core/skills.ts +++ b/packages/coding-agent/src/core/skills.ts @@ -2,6 +2,7 @@ import { existsSync, readdirSync, readFileSync } from "fs"; import { homedir } from "os"; import { basename, dirname, join, resolve } from "path"; import { CONFIG_DIR_NAME } from "../config.js"; +import type { SkillsSettings } from "./settings-manager.js"; /** * Standard frontmatter fields per Agent Skills spec. @@ -315,7 +316,17 @@ function escapeXml(str: string): string { * Load skills from all configured locations. * Returns skills and any validation warnings. */ -export function loadSkills(): LoadSkillsResult { +export function loadSkills(options: SkillsSettings = {}): LoadSkillsResult { + const { + enableCodexUser = true, + enableClaudeUser = true, + enableClaudeProject = true, + enablePiUser = true, + enablePiProject = true, + customDirectories = [], + ignoredSkills = [], + } = options; + const skillMap = new Map(); const allWarnings: SkillWarning[] = []; const collisionWarnings: SkillWarning[] = []; @@ -323,6 +334,9 @@ export function loadSkills(): LoadSkillsResult { function addSkills(result: LoadSkillsResult) { allWarnings.push(...result.warnings); for (const skill of result.skills) { + if (ignoredSkills.includes(skill.name)) { + continue; + } const existing = skillMap.get(skill.name); if (existing) { collisionWarnings.push({ @@ -335,23 +349,24 @@ export function loadSkills(): LoadSkillsResult { } } - // Codex: recursive - const codexUserDir = join(homedir(), ".codex", "skills"); - addSkills(loadSkillsFromDirInternal(codexUserDir, "codex-user", "recursive")); - - // Claude: single level only - const claudeUserDir = join(homedir(), ".claude", "skills"); - addSkills(loadSkillsFromDirInternal(claudeUserDir, "claude-user", "claude")); - - const claudeProjectDir = resolve(process.cwd(), ".claude", "skills"); - addSkills(loadSkillsFromDirInternal(claudeProjectDir, "claude-project", "claude")); - - // Pi: recursive - const globalSkillsDir = join(homedir(), CONFIG_DIR_NAME, "agent", "skills"); - addSkills(loadSkillsFromDirInternal(globalSkillsDir, "user", "recursive")); - - const projectSkillsDir = resolve(process.cwd(), CONFIG_DIR_NAME, "skills"); - addSkills(loadSkillsFromDirInternal(projectSkillsDir, "project", "recursive")); + if (enableCodexUser) { + addSkills(loadSkillsFromDirInternal(join(homedir(), ".codex", "skills"), "codex-user", "recursive")); + } + if (enableClaudeUser) { + addSkills(loadSkillsFromDirInternal(join(homedir(), ".claude", "skills"), "claude-user", "claude")); + } + if (enableClaudeProject) { + addSkills(loadSkillsFromDirInternal(resolve(process.cwd(), ".claude", "skills"), "claude-project", "claude")); + } + if (enablePiUser) { + addSkills(loadSkillsFromDirInternal(join(homedir(), CONFIG_DIR_NAME, "agent", "skills"), "user", "recursive")); + } + if (enablePiProject) { + addSkills(loadSkillsFromDirInternal(resolve(process.cwd(), CONFIG_DIR_NAME, "skills"), "project", "recursive")); + } + for (const customDir of customDirectories) { + addSkills(loadSkillsFromDirInternal(customDir.replace(/^~(?=$|[\\/])/, homedir()), "custom", "recursive")); + } return { skills: 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 4df25c41..4b47dd62 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 type { SkillsSettings } from "./settings-manager.js"; import { formatSkillsForPrompt, loadSkills } from "./skills.js"; import type { ToolName } from "./tools/index.js"; @@ -109,12 +110,12 @@ export interface BuildSystemPromptOptions { customPrompt?: string; selectedTools?: ToolName[]; appendSystemPrompt?: string; - skillsEnabled?: boolean; + skillsSettings?: SkillsSettings; } /** Build the system prompt with tools, guidelines, and context */ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): string { - const { customPrompt, selectedTools, appendSystemPrompt, skillsEnabled = true } = options; + const { customPrompt, selectedTools, appendSystemPrompt, skillsSettings } = options; const resolvedCustomPrompt = resolvePromptInput(customPrompt, "system prompt"); const resolvedAppendPrompt = resolvePromptInput(appendSystemPrompt, "append system prompt"); @@ -151,8 +152,8 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin // Append skills section (only if read tool is available) const customPromptHasRead = !selectedTools || selectedTools.includes("read"); - if (skillsEnabled && customPromptHasRead) { - const { skills } = loadSkills(); + if (skillsSettings?.enabled !== false && customPromptHasRead) { + const { skills } = loadSkills(skillsSettings ?? {}); prompt += formatSkillsForPrompt(skills); } @@ -257,8 +258,8 @@ Documentation: } // Append skills section (only if read tool is available) - if (skillsEnabled && hasRead) { - const { skills } = loadSkills(); + if (skillsSettings?.enabled !== false && hasRead) { + const { skills } = loadSkills(skillsSettings ?? {}); prompt += formatSkillsForPrompt(skills); } diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index fd0da6c1..7683c4a9 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -104,6 +104,7 @@ export { type RetrySettings, type Settings, SettingsManager, + type SkillsSettings, } from "./core/settings-manager.js"; // Skills export { diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 2400e4e4..aa7235ce 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -285,12 +285,15 @@ export async function main(args: string[]) { } // Build system prompt - const skillsEnabled = !parsed.noSkills && settingsManager.getSkillsEnabled(); + const skillsSettings = settingsManager.getSkillsSettings(); + if (parsed.noSkills) { + skillsSettings.enabled = false; + } const systemPrompt = buildSystemPrompt({ customPrompt: parsed.systemPrompt, selectedTools: parsed.tools, appendSystemPrompt: parsed.appendSystemPrompt, - skillsEnabled, + skillsSettings, }); // Handle session restoration @@ -440,6 +443,7 @@ export async function main(args: string[]) { fileCommands, hookRunner, customTools: loadedCustomTools, + skillsSettings, }); // Route to appropriate mode diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index a8a7fe59..8e2140a6 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -314,18 +314,23 @@ export class InteractiveMode { } // Show loaded skills - const { skills, warnings: skillWarnings } = loadSkills(); - if (skills.length > 0) { - const skillList = skills.map((s) => theme.fg("dim", ` ${s.filePath}`)).join("\n"); - this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded skills:\n") + skillList, 0, 0)); - this.chatContainer.addChild(new Spacer(1)); - } + const skillsSettings = this.session.skillsSettings; + if (skillsSettings?.enabled !== false) { + const { skills, warnings: skillWarnings } = loadSkills(skillsSettings ?? {}); + if (skills.length > 0) { + const skillList = skills.map((s) => theme.fg("dim", ` ${s.filePath}`)).join("\n"); + this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded skills:\n") + skillList, 0, 0)); + this.chatContainer.addChild(new Spacer(1)); + } - // Show skill warnings if any - if (skillWarnings.length > 0) { - const warningList = skillWarnings.map((w) => theme.fg("warning", ` ${w.skillPath}: ${w.message}`)).join("\n"); - this.chatContainer.addChild(new Text(theme.fg("warning", "Skill warnings:\n") + warningList, 0, 0)); - this.chatContainer.addChild(new Spacer(1)); + // Show skill warnings if any + if (skillWarnings.length > 0) { + const warningList = skillWarnings + .map((w) => theme.fg("warning", ` ${w.skillPath}: ${w.message}`)) + .join("\n"); + this.chatContainer.addChild(new Text(theme.fg("warning", "Skill warnings:\n") + warningList, 0, 0)); + this.chatContainer.addChild(new Spacer(1)); + } } // Show loaded custom tools diff --git a/packages/coding-agent/test/skills.test.ts b/packages/coding-agent/test/skills.test.ts index 3266a73f..9661f60c 100644 --- a/packages/coding-agent/test/skills.test.ts +++ b/packages/coding-agent/test/skills.test.ts @@ -1,6 +1,7 @@ +import { homedir } from "os"; import { join, resolve } from "path"; import { describe, expect, it } from "vitest"; -import { formatSkillsForPrompt, loadSkillsFromDir, type Skill } from "../src/core/skills.js"; +import { formatSkillsForPrompt, loadSkills, loadSkillsFromDir, type Skill } from "../src/core/skills.js"; const fixturesDir = resolve(__dirname, "fixtures/skills"); const collisionFixturesDir = resolve(__dirname, "fixtures/skills-collision"); @@ -230,6 +231,66 @@ describe("skills", () => { }); }); + describe("loadSkills with options", () => { + it("should load from customDirectories only when built-ins disabled", () => { + const { skills } = loadSkills({ + enableCodexUser: false, + enableClaudeUser: false, + enableClaudeProject: false, + enablePiUser: false, + enablePiProject: false, + customDirectories: [fixturesDir], + }); + expect(skills.length).toBeGreaterThan(0); + expect(skills.every((s) => s.source === "custom")).toBe(true); + }); + + it("should filter out ignoredSkills", () => { + const { skills } = loadSkills({ + enableCodexUser: false, + enableClaudeUser: false, + enableClaudeProject: false, + enablePiUser: false, + enablePiProject: false, + customDirectories: [join(fixturesDir, "valid-skill")], + ignoredSkills: ["valid-skill"], + }); + expect(skills).toHaveLength(0); + }); + + it("should expand ~ in customDirectories", () => { + const homeSkillsDir = join(homedir(), ".pi/agent/skills"); + const { skills: withTilde } = loadSkills({ + enableCodexUser: false, + enableClaudeUser: false, + enableClaudeProject: false, + enablePiUser: false, + enablePiProject: false, + customDirectories: ["~/.pi/agent/skills"], + }); + const { skills: withoutTilde } = loadSkills({ + enableCodexUser: false, + enableClaudeUser: false, + enableClaudeProject: false, + enablePiUser: false, + enablePiProject: false, + customDirectories: [homeSkillsDir], + }); + expect(withTilde.length).toBe(withoutTilde.length); + }); + + it("should return empty when all sources disabled and no custom dirs", () => { + const { skills } = loadSkills({ + enableCodexUser: false, + enableClaudeUser: false, + enableClaudeProject: false, + enablePiUser: false, + enablePiProject: false, + }); + expect(skills).toHaveLength(0); + }); + }); + describe("collision handling", () => { it("should detect name collisions and keep first skill", () => { // Load from first directory