feat(coding-agent): add configurable skills directories (#269)

This commit is contained in:
Nico Bailon 2025-12-21 11:48:40 -08:00 committed by GitHub
parent ace3563f0e
commit 70440f7591
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 186 additions and 39 deletions

View file

@ -145,6 +145,36 @@ Skills are discovered from these locations (later wins on name collision):
4. `~/.pi/agent/skills/**/SKILL.md` (Pi user, recursive) 4. `~/.pi/agent/skills/**/SKILL.md` (Pi user, recursive)
5. `<cwd>/.pi/skills/**/SKILL.md` (Pi project, recursive) 5. `<cwd>/.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 `<cwd>/.claude/skills/` |
| `enablePiUser` | `true` | Load from `~/.pi/agent/skills/` |
| `enablePiProject` | `true` | Load from `<cwd>/.pi/skills/` |
| `customDirectories` | `[]` | Additional directories to scan (supports `~` expansion) |
| `ignoredSkills` | `[]` | Skill names to exclude |
## How Skills Work ## How Skills Work
1. At startup, pi scans skill locations and extracts names + descriptions 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`).

View file

@ -25,7 +25,7 @@ import type { BranchEventResult, HookRunner, TurnEndEvent, TurnStartEvent } from
import type { BashExecutionMessage } from "./messages.js"; import type { BashExecutionMessage } from "./messages.js";
import { getApiKeyForModel, getAvailableModels } from "./model-config.js"; import { getApiKeyForModel, getAvailableModels } from "./model-config.js";
import { loadSessionFromEntries, type SessionManager } from "./session-manager.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"; import { expandSlashCommand, type FileSlashCommand } from "./slash-commands.js";
/** Session-specific events that extend the core AgentEvent */ /** Session-specific events that extend the core AgentEvent */
@ -55,6 +55,7 @@ export interface AgentSessionConfig {
hookRunner?: HookRunner | null; hookRunner?: HookRunner | null;
/** Custom tools for session lifecycle events */ /** Custom tools for session lifecycle events */
customTools?: LoadedCustomTool[]; customTools?: LoadedCustomTool[];
skillsSettings?: Required<SkillsSettings>;
} }
/** Options for AgentSession.prompt() */ /** Options for AgentSession.prompt() */
@ -148,6 +149,8 @@ export class AgentSession {
// Custom tools for session lifecycle // Custom tools for session lifecycle
private _customTools: LoadedCustomTool[] = []; private _customTools: LoadedCustomTool[] = [];
private _skillsSettings: Required<SkillsSettings> | undefined;
constructor(config: AgentSessionConfig) { constructor(config: AgentSessionConfig) {
this.agent = config.agent; this.agent = config.agent;
this.sessionManager = config.sessionManager; this.sessionManager = config.sessionManager;
@ -156,6 +159,7 @@ export class AgentSession {
this._fileCommands = config.fileCommands ?? []; this._fileCommands = config.fileCommands ?? [];
this._hookRunner = config.hookRunner ?? null; this._hookRunner = config.hookRunner ?? null;
this._customTools = config.customTools ?? []; this._customTools = config.customTools ?? [];
this._skillsSettings = config.skillsSettings;
} }
// ========================================================================= // =========================================================================
@ -485,6 +489,10 @@ export class AgentSession {
return this._queuedMessages; return this._queuedMessages;
} }
get skillsSettings(): Required<SkillsSettings> | undefined {
return this._skillsSettings;
}
/** /**
* Abort current operation and wait for agent to become idle. * Abort current operation and wait for agent to become idle.
*/ */

View file

@ -16,6 +16,13 @@ export interface RetrySettings {
export interface SkillsSettings { export interface SkillsSettings {
enabled?: boolean; // default: true 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 { export interface TerminalSettings {
@ -253,6 +260,19 @@ export class SettingsManager {
this.save(); this.save();
} }
getSkillsSettings(): Required<SkillsSettings> {
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 { getShowImages(): boolean {
return this.settings.terminal?.showImages ?? true; return this.settings.terminal?.showImages ?? true;
} }

View file

@ -2,6 +2,7 @@ import { existsSync, readdirSync, readFileSync } from "fs";
import { homedir } from "os"; import { homedir } from "os";
import { basename, dirname, join, resolve } from "path"; import { basename, dirname, join, resolve } from "path";
import { CONFIG_DIR_NAME } from "../config.js"; import { CONFIG_DIR_NAME } from "../config.js";
import type { SkillsSettings } from "./settings-manager.js";
/** /**
* Standard frontmatter fields per Agent Skills spec. * Standard frontmatter fields per Agent Skills spec.
@ -315,7 +316,17 @@ function escapeXml(str: string): string {
* Load skills from all configured locations. * Load skills from all configured locations.
* Returns skills and any validation warnings. * 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<string, Skill>(); const skillMap = new Map<string, Skill>();
const allWarnings: SkillWarning[] = []; const allWarnings: SkillWarning[] = [];
const collisionWarnings: SkillWarning[] = []; const collisionWarnings: SkillWarning[] = [];
@ -323,6 +334,9 @@ export function loadSkills(): LoadSkillsResult {
function addSkills(result: LoadSkillsResult) { function addSkills(result: LoadSkillsResult) {
allWarnings.push(...result.warnings); allWarnings.push(...result.warnings);
for (const skill of result.skills) { for (const skill of result.skills) {
if (ignoredSkills.includes(skill.name)) {
continue;
}
const existing = skillMap.get(skill.name); const existing = skillMap.get(skill.name);
if (existing) { if (existing) {
collisionWarnings.push({ collisionWarnings.push({
@ -335,23 +349,24 @@ export function loadSkills(): LoadSkillsResult {
} }
} }
// Codex: recursive if (enableCodexUser) {
const codexUserDir = join(homedir(), ".codex", "skills"); addSkills(loadSkillsFromDirInternal(join(homedir(), ".codex", "skills"), "codex-user", "recursive"));
addSkills(loadSkillsFromDirInternal(codexUserDir, "codex-user", "recursive")); }
if (enableClaudeUser) {
// Claude: single level only addSkills(loadSkillsFromDirInternal(join(homedir(), ".claude", "skills"), "claude-user", "claude"));
const claudeUserDir = join(homedir(), ".claude", "skills"); }
addSkills(loadSkillsFromDirInternal(claudeUserDir, "claude-user", "claude")); if (enableClaudeProject) {
addSkills(loadSkillsFromDirInternal(resolve(process.cwd(), ".claude", "skills"), "claude-project", "claude"));
const claudeProjectDir = resolve(process.cwd(), ".claude", "skills"); }
addSkills(loadSkillsFromDirInternal(claudeProjectDir, "claude-project", "claude")); if (enablePiUser) {
addSkills(loadSkillsFromDirInternal(join(homedir(), CONFIG_DIR_NAME, "agent", "skills"), "user", "recursive"));
// Pi: recursive }
const globalSkillsDir = join(homedir(), CONFIG_DIR_NAME, "agent", "skills"); if (enablePiProject) {
addSkills(loadSkillsFromDirInternal(globalSkillsDir, "user", "recursive")); addSkills(loadSkillsFromDirInternal(resolve(process.cwd(), CONFIG_DIR_NAME, "skills"), "project", "recursive"));
}
const projectSkillsDir = resolve(process.cwd(), CONFIG_DIR_NAME, "skills"); for (const customDir of customDirectories) {
addSkills(loadSkillsFromDirInternal(projectSkillsDir, "project", "recursive")); addSkills(loadSkillsFromDirInternal(customDir.replace(/^~(?=$|[\\/])/, homedir()), "custom", "recursive"));
}
return { return {
skills: Array.from(skillMap.values()), skills: Array.from(skillMap.values()),

View file

@ -6,6 +6,7 @@ import chalk from "chalk";
import { existsSync, readFileSync } from "fs"; import { existsSync, readFileSync } from "fs";
import { join, resolve } from "path"; import { join, resolve } from "path";
import { getAgentDir, getDocsPath, getReadmePath } from "../config.js"; import { getAgentDir, getDocsPath, getReadmePath } from "../config.js";
import type { SkillsSettings } from "./settings-manager.js";
import { formatSkillsForPrompt, loadSkills } from "./skills.js"; import { formatSkillsForPrompt, loadSkills } from "./skills.js";
import type { ToolName } from "./tools/index.js"; import type { ToolName } from "./tools/index.js";
@ -109,12 +110,12 @@ export interface BuildSystemPromptOptions {
customPrompt?: string; customPrompt?: string;
selectedTools?: ToolName[]; selectedTools?: ToolName[];
appendSystemPrompt?: string; appendSystemPrompt?: string;
skillsEnabled?: boolean; skillsSettings?: SkillsSettings;
} }
/** Build the system prompt with tools, guidelines, and context */ /** Build the system prompt with tools, guidelines, and context */
export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): string { 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 resolvedCustomPrompt = resolvePromptInput(customPrompt, "system prompt");
const resolvedAppendPrompt = resolvePromptInput(appendSystemPrompt, "append 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) // Append skills section (only if read tool is available)
const customPromptHasRead = !selectedTools || selectedTools.includes("read"); const customPromptHasRead = !selectedTools || selectedTools.includes("read");
if (skillsEnabled && customPromptHasRead) { if (skillsSettings?.enabled !== false && customPromptHasRead) {
const { skills } = loadSkills(); const { skills } = loadSkills(skillsSettings ?? {});
prompt += formatSkillsForPrompt(skills); prompt += formatSkillsForPrompt(skills);
} }
@ -257,8 +258,8 @@ Documentation:
} }
// Append skills section (only if read tool is available) // Append skills section (only if read tool is available)
if (skillsEnabled && hasRead) { if (skillsSettings?.enabled !== false && hasRead) {
const { skills } = loadSkills(); const { skills } = loadSkills(skillsSettings ?? {});
prompt += formatSkillsForPrompt(skills); prompt += formatSkillsForPrompt(skills);
} }

View file

@ -104,6 +104,7 @@ export {
type RetrySettings, type RetrySettings,
type Settings, type Settings,
SettingsManager, SettingsManager,
type SkillsSettings,
} from "./core/settings-manager.js"; } from "./core/settings-manager.js";
// Skills // Skills
export { export {

View file

@ -285,12 +285,15 @@ export async function main(args: string[]) {
} }
// Build system prompt // Build system prompt
const skillsEnabled = !parsed.noSkills && settingsManager.getSkillsEnabled(); const skillsSettings = settingsManager.getSkillsSettings();
if (parsed.noSkills) {
skillsSettings.enabled = false;
}
const systemPrompt = buildSystemPrompt({ const systemPrompt = buildSystemPrompt({
customPrompt: parsed.systemPrompt, customPrompt: parsed.systemPrompt,
selectedTools: parsed.tools, selectedTools: parsed.tools,
appendSystemPrompt: parsed.appendSystemPrompt, appendSystemPrompt: parsed.appendSystemPrompt,
skillsEnabled, skillsSettings,
}); });
// Handle session restoration // Handle session restoration
@ -440,6 +443,7 @@ export async function main(args: string[]) {
fileCommands, fileCommands,
hookRunner, hookRunner,
customTools: loadedCustomTools, customTools: loadedCustomTools,
skillsSettings,
}); });
// Route to appropriate mode // Route to appropriate mode

View file

@ -314,18 +314,23 @@ export class InteractiveMode {
} }
// Show loaded skills // Show loaded skills
const { skills, warnings: skillWarnings } = loadSkills(); const skillsSettings = this.session.skillsSettings;
if (skills.length > 0) { if (skillsSettings?.enabled !== false) {
const skillList = skills.map((s) => theme.fg("dim", ` ${s.filePath}`)).join("\n"); const { skills, warnings: skillWarnings } = loadSkills(skillsSettings ?? {});
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded skills:\n") + skillList, 0, 0)); if (skills.length > 0) {
this.chatContainer.addChild(new Spacer(1)); 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 // Show skill warnings if any
if (skillWarnings.length > 0) { if (skillWarnings.length > 0) {
const warningList = skillWarnings.map((w) => theme.fg("warning", ` ${w.skillPath}: ${w.message}`)).join("\n"); const warningList = skillWarnings
this.chatContainer.addChild(new Text(theme.fg("warning", "Skill warnings:\n") + warningList, 0, 0)); .map((w) => theme.fg("warning", ` ${w.skillPath}: ${w.message}`))
this.chatContainer.addChild(new Spacer(1)); .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 // Show loaded custom tools

View file

@ -1,6 +1,7 @@
import { homedir } from "os";
import { join, resolve } from "path"; import { join, resolve } from "path";
import { describe, expect, it } from "vitest"; 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 fixturesDir = resolve(__dirname, "fixtures/skills");
const collisionFixturesDir = resolve(__dirname, "fixtures/skills-collision"); 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", () => { describe("collision handling", () => {
it("should detect name collisions and keep first skill", () => { it("should detect name collisions and keep first skill", () => {
// Load from first directory // Load from first directory