import * as fs from "node:fs/promises";
import * as os from "node:os";
import { getProjectDir } from "@oh-my-pi/pi-utils";
import { skillCapability } from "../capability/skill";
import type { SourceMeta } from "../capability/types";
import type { SkillsSettings } from "../config/settings";
import { type Skill as CapabilitySkill, loadCapability } from "../discovery";
import { compareSkillOrder, scanSkillsFromDir } from "../discovery/helpers";
import type { SkillPromptDetails } from "../session/messages";
import { expandTilde } from "../tools/path-utils";
export interface Skill {
name: string;
description: string;
filePath: string;
baseDir: string;
source: string;
* When `true`, the skill is loaded and reachable via `skill://<name>` and
* (when enabled) `/skill:<name>`, but is excluded from the rendered system
* prompt's `<skills>` listing.
*/
hide?: boolean;
_source?: SourceMeta;
}
export interface SkillWarning {
skillPath: string;
message: string;
}
export interface LoadSkillsResult {
skills: Skill[];
warnings: SkillWarning[];
}
let activeSkills: readonly Skill[] = [];
* Process-global snapshot of skills the active session loaded.
* Read by internal URL protocol handlers (skill://).
*/
export function getActiveSkills(): readonly Skill[] {
return activeSkills;
}
export function setActiveSkills(value: readonly Skill[]): void {
activeSkills = value;
}
export function resetActiveSkillsForTests(): void {
activeSkills = [];
}
export interface LoadSkillsFromDirOptions {
dir: string;
source: string;
}
export async function loadSkillsFromDir(options: LoadSkillsFromDirOptions): Promise<LoadSkillsResult> {
const [rawProviderId, rawLevel] = options.source.split(":", 2);
const providerId = rawProviderId || "custom";
const level: "user" | "project" = rawLevel === "project" ? "project" : "user";
const result = await scanSkillsFromDir(
{ cwd: getProjectDir(), home: os.homedir(), repoRoot: null },
{
dir: options.dir,
providerId,
level,
requireDescription: true,
},
);
return {
skills: result.items.map(capSkill => ({
name: capSkill.name,
description: typeof capSkill.frontmatter?.description === "string" ? capSkill.frontmatter.description : "",
filePath: capSkill.path,
baseDir: capSkill.path.replace(/[\\/]SKILL\.md$/, ""),
source: options.source,
hide: capSkill.frontmatter?.hide === true,
_source: capSkill._source,
})),
warnings: (result.warnings ?? []).map(message => ({ skillPath: options.dir, message })),
};
}
export interface LoadSkillsOptions extends SkillsSettings {
cwd?: string;
}
* Load skills from all configured locations.
* Returns skills and any validation warnings.
*/
export async function loadSkills(options: LoadSkillsOptions = {}): Promise<LoadSkillsResult> {
const {
cwd = getProjectDir(),
enabled = true,
enableCodexUser = true,
enableClaudeUser = true,
enableClaudeProject = true,
enablePiUser = true,
enablePiProject = true,
customDirectories = [],
ignoredSkills = [],
includeSkills = [],
disabledExtensions = [],
} = options;
if (!enabled) {
return { skills: [], warnings: [] };
}
const anyBuiltInSkillSourceEnabled =
enableCodexUser || enableClaudeUser || enableClaudeProject || enablePiUser || enablePiProject;
function isSourceEnabled(source: SourceMeta): boolean {
const { provider, level } = source;
if (provider === "codex" && level === "user") return enableCodexUser;
if (provider === "claude" && level === "user") return enableClaudeUser;
if (provider === "claude" && level === "project") return enableClaudeProject;
if (provider === "native" && level === "user") return enablePiUser;
if (provider === "native" && level === "project") return enablePiProject;
return anyBuiltInSkillSourceEnabled;
}
const result = await loadCapability<CapabilitySkill>(skillCapability.id, { cwd, disabledExtensions });
const skillMap = new Map<string, Skill>();
const realPathSet = new Set<string>();
const collisionWarnings: SkillWarning[] = [];
function matchesIncludePatterns(name: string): boolean {
if (includeSkills.length === 0) return true;
return includeSkills.some(pattern => new Bun.Glob(pattern).match(name));
}
function matchesIgnorePatterns(name: string): boolean {
if (ignoredSkills.length === 0) return false;
return ignoredSkills.some(pattern => new Bun.Glob(pattern).match(name));
}
const disabledSkillNames = new Set(
(disabledExtensions ?? []).filter(id => id.startsWith("skill:")).map(id => id.slice(6)),
);
const filteredSkills = result.items.filter(capSkill => {
if (disabledSkillNames.has(capSkill.name)) return false;
if (!isSourceEnabled(capSkill._source)) return false;
if (matchesIgnorePatterns(capSkill.name)) return false;
if (!matchesIncludePatterns(capSkill.name)) return false;
return true;
});
const realPaths = await Promise.all(
filteredSkills.map(async capSkill => {
try {
return await fs.realpath(capSkill.path);
} catch {
return capSkill.path;
}
}),
);
for (let i = 0; i < filteredSkills.length; i++) {
const capSkill = filteredSkills[i];
const resolvedPath = realPaths[i];
if (realPathSet.has(resolvedPath)) {
continue;
}
const existing = skillMap.get(capSkill.name);
if (existing) {
collisionWarnings.push({
skillPath: capSkill.path,
message: `name collision: "${capSkill.name}" already loaded from ${existing.filePath}, skipping this one`,
});
} else {
skillMap.set(capSkill.name, {
name: capSkill.name,
description: typeof capSkill.frontmatter?.description === "string" ? capSkill.frontmatter.description : "",
filePath: capSkill.path,
baseDir: capSkill.path.replace(/[\\/]SKILL\.md$/, ""),
source: `${capSkill._source.provider}:${capSkill.level}`,
hide: capSkill.frontmatter?.hide === true,
_source: capSkill._source,
});
realPathSet.add(resolvedPath);
}
}
const customDirectoryResults = await Promise.all(
customDirectories.map(async dir => {
const expandedDir = expandTilde(dir);
const scanResult = await scanSkillsFromDir(
{ cwd, home: os.homedir(), repoRoot: null },
{
dir: expandedDir,
providerId: "custom",
level: "user",
requireDescription: true,
},
);
return { expandedDir, scanResult };
}),
);
const allCustomSkills: Array<{ skill: Skill; path: string }> = [];
for (const { expandedDir, scanResult } of customDirectoryResults) {
for (const capSkill of scanResult.items) {
if (disabledSkillNames.has(capSkill.name)) continue;
if (matchesIgnorePatterns(capSkill.name)) continue;
if (!matchesIncludePatterns(capSkill.name)) continue;
allCustomSkills.push({
skill: {
name: capSkill.name,
description:
typeof capSkill.frontmatter?.description === "string" ? capSkill.frontmatter.description : "",
filePath: capSkill.path,
baseDir: capSkill.path.replace(/[\\/]SKILL\.md$/, ""),
source: "custom:user",
hide: capSkill.frontmatter?.hide === true,
_source: { ...capSkill._source, providerName: "Custom" },
},
path: capSkill.path,
});
}
collisionWarnings.push(...(scanResult.warnings ?? []).map(message => ({ skillPath: expandedDir, message })));
}
const customRealPaths = await Promise.all(
allCustomSkills.map(async ({ path }) => {
try {
return await fs.realpath(path);
} catch {
return path;
}
}),
);
for (let i = 0; i < allCustomSkills.length; i++) {
const { skill } = allCustomSkills[i];
const resolvedPath = customRealPaths[i];
if (realPathSet.has(resolvedPath)) continue;
const existing = skillMap.get(skill.name);
if (existing) {
collisionWarnings.push({
skillPath: skill.filePath,
message: `name collision: "${skill.name}" already loaded from ${existing.filePath}, skipping this one`,
});
} else {
skillMap.set(skill.name, skill);
realPathSet.add(resolvedPath);
}
}
const skills = Array.from(skillMap.values());
skills.sort((a, b) => compareSkillOrder(a.name, a.filePath, b.name, b.filePath));
return {
skills,
warnings: [...(result.warnings ?? []).map(w => ({ skillPath: "", message: w })), ...collisionWarnings],
};
}
export interface BuiltSkillPromptMessage {
message: string;
details: SkillPromptDetails;
}
export function getSkillSlashCommandName(skill: Pick<Skill, "name">): string {
return `skill:${skill.name}`;
}
export async function buildSkillPromptMessage(
skill: Pick<Skill, "name" | "filePath">,
args: string,
): Promise<BuiltSkillPromptMessage> {
const content = await Bun.file(skill.filePath).text();
const body = content.replace(/^---\n[\s\S]*?\n---\n/, "").trim();
const metaLines = [`Skill: ${skill.filePath}`];
const trimmedArgs = args.trim();
if (trimmedArgs) {
metaLines.push(`User: ${trimmedArgs}`);
}
const message = `${body}\n\n---\n\n${metaLines.join("\n")}`;
return {
message,
details: {
name: skill.name,
path: skill.filePath,
args: trimmedArgs || undefined,
lineCount: body ? body.split("\n").length : 0,
},
};
}