import { discoverFilesystemSkills, readSkillContent, resolveSkillPath, type PromptWithModel } from "./prompt-loader.js"; import type { SkillLoadedDetails } from "./skill-loaded-renderer.js"; export interface RuntimeSkillCommand { name: string; source?: string; sourceInfo?: { path?: string }; } export interface PendingSkillMessage { customType: "skill-loaded"; content: string; display: true; details: SkillLoadedDetails; } export type LoadedPromptSkill = { skillName: string; skillContent: string; skillPath: string; }; type SkillResolution = | { kind: "none" } | { kind: "ready"; skills: LoadedPromptSkill[] } | { kind: "error"; error: string }; type ExpandedSkill = { skillName: string; skillPath?: string }; export function buildSkillLoadedMessage(skills: LoadedPromptSkill[]): PendingSkillMessage { return { customType: "skill-loaded", content: skills.map((skill) => `\n${skill.skillContent}\n`).join("\n\n"), display: true, details: { skills }, }; } function normalizeSkillName(skillName: string): string { return skillName.startsWith("skill:") ? skillName.slice("skill:".length) : skillName; } function isSafeXmlSkillName(skillName: string): boolean { return /^[A-Za-z0-9._-]+$/.test(skillName); } function lexicalCompare(a: string, b: string): number { if (a < b) return -1; if (a > b) return 1; return 0; } function isWildcardSelector(skillName: string): boolean { return skillName.includes("*"); } function isValidSuffixWildcardSelector(skillName: string): boolean { const firstStar = skillName.indexOf("*"); return firstStar > 0 && firstStar === skillName.length - 1 && skillName.indexOf("*", firstStar + 1) === -1 && isSafeXmlSkillName(skillName.slice(0, -1)); } function invalidWildcardError(skillName: string): string { return `Invalid skill wildcard "${skillName}": only non-empty suffix "*" prefix matching is supported.`; } function isPathResolvableSkillName(skillName: string): boolean { return skillName !== "." && skillName !== ".."; } function getSourceInfo(command: RuntimeSkillCommand): { path?: string } | undefined { return "sourceInfo" in command ? command.sourceInfo : undefined; } function resolveRegisteredSkillPath(skillName: string, commands: RuntimeSkillCommand[]): string | undefined { const normalizedSkillName = normalizeSkillName(skillName); if (!normalizedSkillName) return undefined; const candidates = new Set([normalizedSkillName, `skill:${normalizedSkillName}`]); for (const command of commands) { if (command.source !== "skill") continue; const sourceInfo = getSourceInfo(command); if (!sourceInfo?.path) continue; if (!candidates.has(command.name)) continue; return sourceInfo.path; } return undefined; } function discoverRegisteredWildcardMatches(prefix: string, commands: RuntimeSkillCommand[]): Array<{ skillName: string; skillPath: string }> { const matches = new Map(); for (const command of commands) { if (command.source !== "skill") continue; const sourceInfo = getSourceInfo(command); if (!sourceInfo?.path) continue; const normalizedSkillName = normalizeSkillName(command.name); if (!isSafeXmlSkillName(normalizedSkillName)) continue; if (!normalizedSkillName.startsWith(prefix)) continue; if (!matches.has(normalizedSkillName)) matches.set(normalizedSkillName, sourceInfo.path); } return Array.from(matches, ([skillName, skillPath]) => ({ skillName, skillPath })) .sort((a, b) => lexicalCompare(a.skillName, b.skillName)); } function expandWildcardSelector(selector: string, cwd: string, commands: RuntimeSkillCommand[]): SkillResolution | { kind: "matches"; skills: ExpandedSkill[] } { if (!isValidSuffixWildcardSelector(selector)) { return { kind: "error", error: invalidWildcardError(selector) }; } const prefix = selector.slice(0, -1); const matches: ExpandedSkill[] = []; const seen = new Set(); for (const skill of discoverRegisteredWildcardMatches(prefix, commands)) { if (seen.has(skill.skillName)) continue; seen.add(skill.skillName); matches.push(skill); } for (const skill of discoverFilesystemSkills(cwd)) { if (!skill.skillName.startsWith(prefix)) continue; if (seen.has(skill.skillName)) continue; seen.add(skill.skillName); matches.push(skill); } if (matches.length === 0) { return { kind: "error", error: `No skills matched "${selector}"` }; } return { kind: "matches", skills: matches }; } function expandRequestedSkillNames(skillNames: string[], cwd: string, commands: RuntimeSkillCommand[]): SkillResolution | { kind: "expanded"; skills: ExpandedSkill[] } { const expanded: ExpandedSkill[] = []; const seen = new Set(); for (const skillName of skillNames) { const normalizedSkillName = normalizeSkillName(skillName); if (!normalizedSkillName) { return { kind: "error", error: `Skill "${skillName}" not found` }; } if (isWildcardSelector(normalizedSkillName)) { const wildcard = expandWildcardSelector(normalizedSkillName, cwd, commands); if (wildcard.kind !== "matches") return wildcard; for (const matchedSkill of wildcard.skills) { if (seen.has(matchedSkill.skillName)) continue; seen.add(matchedSkill.skillName); expanded.push(matchedSkill); } continue; } if (!isSafeXmlSkillName(normalizedSkillName)) { return { kind: "error", error: `Skill "${skillName}" has invalid name "${normalizedSkillName}"` }; } if (seen.has(normalizedSkillName)) continue; seen.add(normalizedSkillName); expanded.push({ skillName: normalizedSkillName }); } return { kind: "expanded", skills: expanded }; } export function resolvePromptSkills(skillNames: string[], cwd: string, commands: RuntimeSkillCommand[]): SkillResolution { if (skillNames.length === 0) { return { kind: "none" }; } const expandedSkillNames = expandRequestedSkillNames(skillNames, cwd, commands); if (expandedSkillNames.kind !== "expanded") return expandedSkillNames; const loadedSkills: LoadedPromptSkill[] = []; for (const skill of expandedSkillNames.skills) { const skillPath = skill.skillPath ?? resolveRegisteredSkillPath(skill.skillName, commands) ?? (isPathResolvableSkillName(skill.skillName) ? resolveSkillPath(skill.skillName, cwd) : undefined); if (!skillPath) { return { kind: "error", error: `Skill "${skill.skillName}" not found` }; } try { const skillContent = readSkillContent(skillPath); loadedSkills.push({ skillName: skill.skillName, skillContent, skillPath }); } catch (error) { return { kind: "error", error: `Failed to read skill "${skill.skillName}": ${error instanceof Error ? error.message : String(error)}`, }; } } return { kind: "ready", skills: loadedSkills }; } export function getRequestedSkills(prompt: Pick): string[] { return prompt.skills ?? (prompt.skill ? [prompt.skill] : []); }