import { mkdir, cp, access, readdir, symlink, lstat, rm, readlink, readFile, writeFile, stat, chmod, realpath, } from 'fs/promises'; import { existsSync } from 'fs'; import { join, basename, normalize, resolve, sep, relative, dirname, extname } from 'path'; import { homedir, platform } from 'os'; import type { Skill, AgentType, RemoteSkill } from './types.ts'; import type { WellKnownSkill } from './providers/wellknown.ts'; import { agents, detectInstalledAgents, isUniversalAgent, getEveSubagents, EVE_SUBAGENTS_DIR, } from './agents.ts'; import { AGENTS_DIR, SKILLS_SUBDIR } from './constants.ts'; import { parseFrontmatter } from './frontmatter.ts'; import { stringify } from 'yaml'; import { parseSkillMd } from './skills.ts'; export type InstallMode = 'symlink' | 'copy'; interface InstallOptions { global?: boolean; cwd?: string; mode?: InstallMode; eveSubagent?: string; /** Create a missing agent-specific project root because the user selected this agent. */ createMissingAgentRoot?: boolean; } interface InstallResult { success: boolean; path: string; canonicalPath?: string; mode: InstallMode; symlinkFailed?: boolean; skipped?: boolean; skipReason?: 'missing-agent-project-directory'; error?: string; } /** * Sanitizes a filename/directory name to prevent path traversal attacks * and ensures it follows kebab-case convention * @param name - The name to sanitize * @returns Sanitized name safe for use in file paths */ export function sanitizeName(name: string): string { const sanitized = name .toLowerCase() // Replace any sequence of characters that are NOT lowercase letters (a-z), // digits (0-9), dots (.), or underscores (_) with a single hyphen. // This converts spaces, special chars, and path traversal attempts (../) into hyphens. .replace(/[^a-z0-9._]+/g, '-') // Remove leading/trailing dots and hyphens to prevent hidden files (.) and // ensure clean directory names. The pattern matches: // - ^[.\-]+ : one or more dots or hyphens at the start // - [.\-]+$ : one or more dots or hyphens at the end .replace(/^[.\-]+|[.\-]+$/g, ''); // Limit to 255 chars (common filesystem limit), fallback to 'unnamed-skill' if empty return sanitized.substring(0, 255) || 'unnamed-skill'; } /** * Validates that a path is within an expected base directory * @param basePath - The expected base directory * @param targetPath - The path to validate * @returns true if targetPath is within basePath */ function isPathSafe(basePath: string, targetPath: string): boolean { const normalizedBase = normalize(resolve(basePath)); const normalizedTarget = normalize(resolve(targetPath)); return normalizedTarget.startsWith(normalizedBase + sep) || normalizedTarget === normalizedBase; } function pathsOverlap(pathA: string, pathB: string): boolean { return isPathSafe(pathA, pathB) || isPathSafe(pathB, pathA); } function shouldSkipProjectAgentSymlink( agentType: AgentType, isGlobal: boolean, cwd: string, createMissingAgentRoot: boolean ): boolean { if ( isGlobal || isUniversalAgent(agentType) || createMissingAgentRoot || agents[agentType].createProjectSkillsDirByDefault ) { return false; } const agentRoot = agents[agentType].skillsDir.split('/')[0]!; return !existsSync(join(cwd, agentRoot)); } // Dirent.isDirectory() is false for symlinks; follow and verify the target is a directory. async function isDirEntryOrSymlinkToDir( entry: { isDirectory(): boolean; isSymbolicLink(): boolean }, entryPath: string ): Promise { if (entry.isDirectory()) return true; if (!entry.isSymbolicLink()) return false; try { return (await stat(entryPath)).isDirectory(); } catch { return false; } } export function getCanonicalSkillsDir(global: boolean, cwd?: string): string { const baseDir = global ? homedir() : cwd || process.cwd(); return join(baseDir, AGENTS_DIR, SKILLS_SUBDIR); } /** * Resolve the skills directory for a specific Eve subagent, relative to the * project root: `agent/subagents//skills`. The subagent name is * sanitized to prevent directory traversal when it originates from user input. */ export function getEveSubagentSkillsDir(subagent: string, cwd?: string): string { const baseDir = cwd || process.cwd(); return join(baseDir, EVE_SUBAGENTS_DIR, sanitizeName(subagent), 'skills'); } /** * Gets the base directory for an agent's skills, respecting universal agents. * Universal agents always use the canonical directory, which prevents * redundant symlinks and double-listing of skills. * * For Eve, an optional `eveSubagent` targets a subagent's skills directory * (`agent/subagents//skills`) instead of the root agent's `agent/skills`. */ export function getAgentBaseDir( agentType: AgentType, global: boolean, cwd?: string, eveSubagent?: string ): string { if (isUniversalAgent(agentType)) { return getCanonicalSkillsDir(global, cwd); } // Eve subagents are inherently project-scoped — they live under the project's // agent/ directory, so the `global` flag does not apply here. if (agentType === 'eve' && eveSubagent) { return getEveSubagentSkillsDir(eveSubagent, cwd); } const agent = agents[agentType]; const baseDir = global ? homedir() : cwd || process.cwd(); if (global) { if (agent.globalSkillsDir === undefined) { // This should be caught by callers checking support return join(baseDir, agent.skillsDir); } return agent.globalSkillsDir; } return join(baseDir, agent.skillsDir); } function resolveSymlinkTarget(linkPath: string, linkTarget: string): string { return resolve(dirname(linkPath), linkTarget); } /** * Cleans and recreates a directory for skill installation. * * This ensures: * 1. Renamed/deleted files from previous installs are removed * 2. Symlinks (including self-referential ones causing ELOOP) are handled * when canonical and agent paths resolve to the same location */ async function cleanAndCreateDirectory(path: string): Promise { try { await rm(path, { recursive: true, force: true }); } catch { // Ignore cleanup errors - mkdir will fail if there's a real problem } await mkdir(path, { recursive: true }); } /** * Resolve a path's parent directory through symlinks, keeping the final component. * This handles the case where a parent directory (e.g., ~/.claude/skills) is a symlink * to another location (e.g., ~/.agents/skills). In that case, computing relative paths * from the symlink path produces broken symlinks. * * Returns the real path of the parent + the original basename. * If realpath fails (parent doesn't exist), returns the original resolved path. */ async function resolveParentSymlinks(path: string): Promise { const resolved = resolve(path); const dir = dirname(resolved); const base = basename(resolved); try { const realDir = await realpath(dir); return join(realDir, base); } catch { return resolved; } } /** * Creates a symlink, handling cross-platform differences * Returns true if symlink was created, false if fallback to copy is needed */ async function createSymlink(target: string, linkPath: string): Promise { try { const resolvedTarget = resolve(target); const resolvedLinkPath = resolve(linkPath); // Use realpath to handle cases where parent directories are symlinked. // This prevents deleting the canonical directory if the agent directory // is a symlink to the canonical location. const [realTarget, realLinkPath] = await Promise.all([ realpath(resolvedTarget).catch(() => resolvedTarget), realpath(resolvedLinkPath).catch(() => resolvedLinkPath), ]); if (realTarget === realLinkPath) { return true; } // Also check with symlinks resolved in parent directories. // This handles cases where e.g. ~/.claude/skills is a symlink to ~/.agents/skills, // so ~/.claude/skills/ and ~/.agents/skills/ are physically the same. const realTargetWithParents = await resolveParentSymlinks(target); const realLinkPathWithParents = await resolveParentSymlinks(linkPath); if (realTargetWithParents === realLinkPathWithParents) { return true; } try { const stats = await lstat(linkPath); if (stats.isSymbolicLink()) { const existingTarget = await readlink(linkPath); if (resolveSymlinkTarget(linkPath, existingTarget) === resolvedTarget) { return true; } await rm(linkPath); } else { await rm(linkPath, { recursive: true }); } } catch (err: unknown) { // ELOOP = circular symlink, ENOENT = doesn't exist // For ELOOP, try to remove the broken symlink if (err && typeof err === 'object' && 'code' in err && err.code === 'ELOOP') { try { await rm(linkPath, { force: true }); } catch { // If we can't remove it, symlink creation will fail and trigger copy fallback } } // For ENOENT or other errors, continue to symlink creation } const linkDir = dirname(linkPath); await mkdir(linkDir, { recursive: true }); // Use the real (symlink-resolved) parent directory for computing the relative path. // This ensures the symlink target is correct even when the link's parent dir is a symlink. const realLinkDir = await resolveParentSymlinks(linkDir); const relativePath = relative(realLinkDir, target); const symlinkType = platform() === 'win32' ? 'junction' : undefined; const symlinkTarget = symlinkType === 'junction' ? resolvedTarget : relativePath; await symlink(symlinkTarget, linkPath, symlinkType); return true; } catch { return false; } } export async function installSkillForAgent( skill: Skill, agentType: AgentType, options: InstallOptions = {} ): Promise { const agent = agents[agentType]; const isGlobal = options.global ?? false; const cwd = options.cwd || process.cwd(); const eveSubagent = options.eveSubagent; // Check if agent supports global installation if (isGlobal && agent.globalSkillsDir === undefined) { return { success: false, path: '', mode: options.mode ?? 'symlink', error: `${agent.displayName} does not support global skill installation`, }; } // Sanitize skill name to prevent directory traversal const rawSkillName = skill.name || basename(skill.path); const skillName = sanitizeName(rawSkillName); const installMode = options.mode ?? 'symlink'; // Canonical location: .agents/skills/ const canonicalBase = agentType === 'eve' && installMode === 'symlink' ? getAgentBaseDir(agentType, isGlobal, cwd, eveSubagent) : getCanonicalSkillsDir(isGlobal, cwd); const canonicalDir = join(canonicalBase, skillName); // Agent-specific location (for symlink) const agentBase = getAgentBaseDir(agentType, isGlobal, cwd, eveSubagent); const agentDir = join(agentBase, skillName); // Validate paths if (!isPathSafe(canonicalBase, canonicalDir)) { return { success: false, path: agentDir, mode: installMode, error: 'Invalid skill name: potential path traversal detected', }; } if (!isPathSafe(agentBase, agentDir)) { return { success: false, path: agentDir, mode: installMode, error: 'Invalid skill name: potential path traversal detected', }; } try { // Never install onto (or inside) the source directory. This can happen with // agent-specific project directories like OpenClaw's "skills" when users run // `skills add ./skills --all`: the source `./skills/` and destination // `./skills/` are the same path. Cleaning the destination would delete // the user's source skill before we can link or copy it. if (pathsOverlap(skill.path, agentDir)) { return { success: true, path: agentDir, mode: installMode, skipped: true, }; } // For copy mode, skip canonical directory and copy directly to agent location if (installMode === 'copy') { await cleanAndCreateDirectory(agentDir); await copyDirectory(skill.path, agentDir, agentType); return { success: true, path: agentDir, mode: 'copy', }; } if (pathsOverlap(skill.path, canonicalDir)) { return { success: true, path: canonicalDir, canonicalPath: canonicalDir, mode: 'symlink', skipped: true, }; } // Symlink mode: copy to canonical location and symlink to agent location await cleanAndCreateDirectory(canonicalDir); await copyDirectory(skill.path, canonicalDir, agentType); // For universal agents with global install, the skill is already in the canonical // ~/.agents/skills directory. Skip creating a symlink to the agent-specific global dir // (e.g. ~/.copilot/skills) to avoid duplicates. if (isGlobal && isUniversalAgent(agentType)) { return { success: true, path: canonicalDir, canonicalPath: canonicalDir, mode: 'symlink', }; } // For project-level installs, skip creating symlinks for non-universal agents // whose config directory doesn't already exist in the project. This prevents // creating directories like .windsurf/, .kiro/, etc. when those agents aren't // actually used in this project. The skill is already available in .agents/skills/. if ( shouldSkipProjectAgentSymlink( agentType, isGlobal, cwd, options.createMissingAgentRoot ?? false ) ) { return { success: true, path: canonicalDir, canonicalPath: canonicalDir, mode: 'symlink', skipped: true, skipReason: 'missing-agent-project-directory', }; } const symlinkCreated = await createSymlink(canonicalDir, agentDir); if (!symlinkCreated) { // Symlink failed, fall back to copy await cleanAndCreateDirectory(agentDir); await copyDirectory(skill.path, agentDir, agentType); return { success: true, path: agentDir, canonicalPath: canonicalDir, mode: 'symlink', symlinkFailed: true, }; } return { success: true, path: agentDir, canonicalPath: canonicalDir, mode: 'symlink', }; } catch (error) { return { success: false, path: agentDir, mode: installMode, error: error instanceof Error ? error.message : 'Unknown error', }; } } const EXCLUDE_FILES = new Set(['metadata.json']); const EXCLUDE_DIRS = new Set(['.git', '__pycache__', '__pypackages__']); const isExcluded = (name: string, isDirectory: boolean = false): boolean => { if (EXCLUDE_FILES.has(name)) return true; if (isDirectory && EXCLUDE_DIRS.has(name)) return true; return false; }; function stripIgnoredEveFrontmatter(raw: string): string { const { data, content } = parseFrontmatter(raw); const eveData: Record = {}; if (typeof data.name === 'string') { eveData.name = data.name; } if (typeof data.description === 'string') { eveData.description = data.description; } if (typeof data.license === 'string') { eveData.license = data.license; } if (data.compatibility !== undefined) { eveData.compatibility = data.compatibility; } if (typeof data.version === 'string' || typeof data.version === 'number') { eveData.version = data.version; } if (data.metadata && typeof data.metadata === 'object' && !Array.isArray(data.metadata)) { eveData.metadata = data.metadata; } const keys = Object.keys(eveData); if (keys.length === 0) { return content.replace(/^\r?\n/u, ''); } // Real nested YAML, not inline JSON const frontmatter = stringify(eveData).trimEnd(); return `---\n${frontmatter}\n---\n${content.replace(/^\r?\n/u, '')}`; } async function copyDirectory(src: string, dest: string, agentType?: AgentType): Promise { await mkdir(dest, { recursive: true }); const entries = await readdir(src, { withFileTypes: true }); // Copy files and directories in parallel await Promise.all( entries .filter((entry) => !isExcluded(entry.name, entry.isDirectory())) .map(async (entry) => { const srcPath = join(src, entry.name); const destPath = join(dest, entry.name); if (entry.isDirectory()) { await copyDirectory(srcPath, destPath, agentType); } else { try { if (agentType === 'eve' && entry.name.toLowerCase() === 'skill.md') { await writeFile( destPath, stripIgnoredEveFrontmatter(await readFile(srcPath, 'utf-8')) ); return; } await cp(srcPath, destPath, { // If the file is a symlink to elsewhere in a remote skill, it may not // resolve correctly once it has been copied to the local location. // `dereference: true` tells Node to copy the file instead of copying // the symlink. `recursive: true` handles symlinks pointing to directories. dereference: true, recursive: true, }); const sourceStats = await stat(srcPath); await chmod(destPath, sourceStats.mode & 0o777); } catch (err: unknown) { // Skip broken symlinks (e.g., pointing to absolute paths on another machine) // instead of aborting the entire install. if ( err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'ENOENT' && entry.isSymbolicLink() ) { console.warn(`Skipping broken symlink: ${srcPath}`); } else { throw err; } } } }) ); } function isEvePackagedSkill(files: Array<{ path: string; contents?: string }>): boolean { return files.some((file) => basename(file.path).toLowerCase() === 'skill.md'); } function toEveFlatSkillFileName(installName: string): string { return `${sanitizeName(installName)}.md`; } function getEveFlatSkillMarkdown(files: Array<{ path: string; contents: string }>): string { const skillFile = files.find((file) => basename(file.path).toLowerCase() === 'skill.md'); if (skillFile) return stripIgnoredEveFrontmatter(skillFile.contents); const markdownFile = files.find((file) => extname(file.path).toLowerCase() === '.md'); return markdownFile ? stripIgnoredEveFrontmatter(markdownFile.contents) : ''; } export async function isSkillInstalled( skillName: string, agentType: AgentType, options: { global?: boolean; cwd?: string; eveSubagent?: string } = {} ): Promise { const agent = agents[agentType]; const sanitized = sanitizeName(skillName); // Agent doesn't support global installation if (options.global && agent.globalSkillsDir === undefined) { return false; } const targetBase = options.global ? agent.globalSkillsDir! : agentType === 'eve' && options.eveSubagent ? getEveSubagentSkillsDir(options.eveSubagent, options.cwd) : join(options.cwd || process.cwd(), agent.skillsDir); const skillDir = join(targetBase, sanitized); if (!isPathSafe(targetBase, skillDir)) { return false; } try { await access(skillDir); return true; } catch { return false; } } export function getInstallPath( skillName: string, agentType: AgentType, options: { global?: boolean; cwd?: string; eveSubagent?: string } = {} ): string { const agent = agents[agentType]; const cwd = options.cwd || process.cwd(); const sanitized = sanitizeName(skillName); const targetBase = getAgentBaseDir( agentType, options.global ?? false, options.cwd, options.eveSubagent ); const installPath = join(targetBase, sanitized); if (!isPathSafe(targetBase, installPath)) { throw new Error('Invalid skill name: potential path traversal detected'); } return installPath; } /** * Gets the canonical .agents/skills/ path */ export function getCanonicalPath( skillName: string, options: { global?: boolean; cwd?: string; agent?: AgentType; eveSubagent?: string } = {} ): string { const sanitized = sanitizeName(skillName); const canonicalBase = options.agent === 'eve' ? getAgentBaseDir('eve', options.global ?? false, options.cwd, options.eveSubagent) : getCanonicalSkillsDir(options.global ?? false, options.cwd); const canonicalPath = join(canonicalBase, sanitized); if (!isPathSafe(canonicalBase, canonicalPath)) { throw new Error('Invalid skill name: potential path traversal detected'); } return canonicalPath; } /** * Install a remote skill from any host provider. * The skill directory name is derived from the installName field. * Supports symlink mode (writes to canonical location and symlinks to agent dirs) * or copy mode (writes directly to each agent dir). */ export async function installRemoteSkillForAgent( skill: RemoteSkill, agentType: AgentType, options: { global?: boolean; cwd?: string; mode?: InstallMode; eveSubagent?: string } = {} ): Promise { const agent = agents[agentType]; const isGlobal = options.global ?? false; const cwd = options.cwd || process.cwd(); const installMode = options.mode ?? 'symlink'; const eveSubagent = options.eveSubagent; // Check if agent supports global installation if (isGlobal && agent.globalSkillsDir === undefined) { return { success: false, path: '', mode: installMode, error: `${agent.displayName} does not support global skill installation`, }; } // Use installName as the skill directory name const skillName = sanitizeName(skill.installName); // Canonical location: .agents/skills/ const canonicalBase = agentType === 'eve' && installMode === 'symlink' ? getAgentBaseDir(agentType, isGlobal, cwd, eveSubagent) : getCanonicalSkillsDir(isGlobal, cwd); const canonicalDir = join(canonicalBase, skillName); // Agent-specific location (for symlink) const agentBase = getAgentBaseDir(agentType, isGlobal, cwd, eveSubagent); const agentDir = join(agentBase, skillName); // Validate paths if (!isPathSafe(canonicalBase, canonicalDir)) { return { success: false, path: agentDir, mode: installMode, error: 'Invalid skill name: potential path traversal detected', }; } if (!isPathSafe(agentBase, agentDir)) { return { success: false, path: agentDir, mode: installMode, error: 'Invalid skill name: potential path traversal detected', }; } try { // For copy mode, write directly to agent location if (installMode === 'copy') { await cleanAndCreateDirectory(agentDir); const skillFileName = agentType === 'eve' ? toEveFlatSkillFileName(skill.installName) : 'SKILL.md'; const skillMdPath = join(agentDir, skillFileName); await writeFile( skillMdPath, agentType === 'eve' ? stripIgnoredEveFrontmatter(skill.content) : skill.content, 'utf-8' ); return { success: true, path: agentDir, mode: 'copy', }; } // Symlink mode: write to canonical location and symlink to agent location await cleanAndCreateDirectory(canonicalDir); const skillFileName = agentType === 'eve' ? toEveFlatSkillFileName(skill.installName) : 'SKILL.md'; const skillMdPath = join(canonicalDir, skillFileName); await writeFile( skillMdPath, agentType === 'eve' ? stripIgnoredEveFrontmatter(skill.content) : skill.content, 'utf-8' ); // For universal agents with global install, skip creating agent-specific symlink if (isGlobal && isUniversalAgent(agentType)) { return { success: true, path: canonicalDir, canonicalPath: canonicalDir, mode: 'symlink', }; } const symlinkCreated = await createSymlink(canonicalDir, agentDir); if (!symlinkCreated) { // Symlink failed, fall back to copy await cleanAndCreateDirectory(agentDir); const agentSkillFileName = agentType === 'eve' ? toEveFlatSkillFileName(skill.installName) : 'SKILL.md'; const agentSkillMdPath = join(agentDir, agentSkillFileName); await writeFile( agentSkillMdPath, agentType === 'eve' ? stripIgnoredEveFrontmatter(skill.content) : skill.content, 'utf-8' ); return { success: true, path: agentDir, canonicalPath: canonicalDir, mode: 'symlink', symlinkFailed: true, }; } return { success: true, path: agentDir, canonicalPath: canonicalDir, mode: 'symlink', }; } catch (error) { return { success: false, path: agentDir, mode: installMode, error: error instanceof Error ? error.message : 'Unknown error', }; } } /** * Install a well-known skill with multiple files. * The skill directory name is derived from the installName field. * All files from the skill's files map are written to the installation directory. * Supports symlink mode (writes to canonical location and symlinks to agent dirs) * or copy mode (writes directly to each agent dir). */ export async function installWellKnownSkillForAgent( skill: WellKnownSkill, agentType: AgentType, options: { global?: boolean; cwd?: string; mode?: InstallMode; eveSubagent?: string } = {} ): Promise { const agent = agents[agentType]; const isGlobal = options.global ?? false; const cwd = options.cwd || process.cwd(); const installMode = options.mode ?? 'symlink'; const eveSubagent = options.eveSubagent; // Check if agent supports global installation if (isGlobal && agent.globalSkillsDir === undefined) { return { success: false, path: '', mode: installMode, error: `${agent.displayName} does not support global skill installation`, }; } // Use installName as the skill directory name const skillName = sanitizeName(skill.installName); // Canonical location: .agents/skills/ const canonicalBase = agentType === 'eve' && installMode === 'symlink' ? getAgentBaseDir(agentType, isGlobal, cwd, eveSubagent) : getCanonicalSkillsDir(isGlobal, cwd); const canonicalDir = join(canonicalBase, skillName); // Agent-specific location (for symlink) const agentBase = getAgentBaseDir(agentType, isGlobal, cwd, eveSubagent); const agentDir = join(agentBase, skillName); // Validate paths if (!isPathSafe(canonicalBase, canonicalDir)) { return { success: false, path: agentDir, mode: installMode, error: 'Invalid skill name: potential path traversal detected', }; } if (!isPathSafe(agentBase, agentDir)) { return { success: false, path: agentDir, mode: installMode, error: 'Invalid skill name: potential path traversal detected', }; } /** * Write all skill files to a directory (assumes directory already exists) */ async function writeSkillFiles(targetDir: string): Promise { for (const [filePath, content] of skill.files) { // Validate file path doesn't escape the target directory const fullPath = join(targetDir, filePath); if (!isPathSafe(targetDir, fullPath)) { continue; // Skip files that would escape the directory } // Create parent directories if needed const parentDir = dirname(fullPath); if (parentDir !== targetDir) { await mkdir(parentDir, { recursive: true }); } await writeFile( fullPath, agentType === 'eve' && basename(filePath).toLowerCase() === 'skill.md' && typeof content === 'string' ? stripIgnoredEveFrontmatter(content) : content ); } } try { // For copy mode, write directly to agent location if (installMode === 'copy') { await cleanAndCreateDirectory(agentDir); await writeSkillFiles(agentDir); return { success: true, path: agentDir, mode: 'copy', }; } // Symlink mode: write to canonical location and symlink to agent location await cleanAndCreateDirectory(canonicalDir); await writeSkillFiles(canonicalDir); // For universal agents with global install, skip creating agent-specific symlink if (isGlobal && isUniversalAgent(agentType)) { return { success: true, path: canonicalDir, canonicalPath: canonicalDir, mode: 'symlink', }; } const symlinkCreated = await createSymlink(canonicalDir, agentDir); if (!symlinkCreated) { // Symlink failed, fall back to copy await cleanAndCreateDirectory(agentDir); await writeSkillFiles(agentDir); return { success: true, path: agentDir, canonicalPath: canonicalDir, mode: 'symlink', symlinkFailed: true, }; } return { success: true, path: agentDir, canonicalPath: canonicalDir, mode: 'symlink', }; } catch (error) { return { success: false, path: agentDir, mode: installMode, error: error instanceof Error ? error.message : 'Unknown error', }; } } /** * Install a blob-downloaded skill (fetched from skills.sh download API). * Similar to installWellKnownSkillForAgent but takes the snapshot file format * (array of { path, contents }) instead of a Map. */ export async function installBlobSkillForAgent( skill: { installName: string; files: Array<{ path: string; contents: string }> }, agentType: AgentType, options: InstallOptions = {} ): Promise { const agent = agents[agentType]; const isGlobal = options.global ?? false; const cwd = options.cwd || process.cwd(); const installMode = options.mode ?? 'symlink'; const eveSubagent = options.eveSubagent; if (isGlobal && agent.globalSkillsDir === undefined) { return { success: false, path: '', mode: installMode, error: `${agent.displayName} does not support global skill installation`, }; } const skillName = sanitizeName(skill.installName); const agentBase = getAgentBaseDir(agentType, isGlobal, cwd, eveSubagent); if (agentType === 'eve' && !isEvePackagedSkill(skill.files)) { const flatSkillPath = join(agentBase, toEveFlatSkillFileName(skill.installName)); if (!isPathSafe(agentBase, flatSkillPath)) { return { success: false, path: flatSkillPath, mode: installMode, error: 'Invalid skill name: potential path traversal detected', }; } try { await mkdir(agentBase, { recursive: true }); await rm(flatSkillPath, { recursive: true, force: true }); await writeFile(flatSkillPath, getEveFlatSkillMarkdown(skill.files), 'utf-8'); return { success: true, path: flatSkillPath, mode: 'copy' }; } catch (error) { return { success: false, path: flatSkillPath, mode: installMode, error: error instanceof Error ? error.message : 'Unknown error', }; } } const canonicalBase = agentType === 'eve' && installMode === 'symlink' ? getAgentBaseDir(agentType, isGlobal, cwd, eveSubagent) : getCanonicalSkillsDir(isGlobal, cwd); const canonicalDir = join(canonicalBase, skillName); const agentDir = join(agentBase, skillName); if (!isPathSafe(canonicalBase, canonicalDir)) { return { success: false, path: agentDir, mode: installMode, error: 'Invalid skill name: potential path traversal detected', }; } if (!isPathSafe(agentBase, agentDir)) { return { success: false, path: agentDir, mode: installMode, error: 'Invalid skill name: potential path traversal detected', }; } async function writeSkillFiles(targetDir: string): Promise { for (const file of skill.files) { const fullPath = join(targetDir, file.path); if (!isPathSafe(targetDir, fullPath)) continue; const parentDir = dirname(fullPath); if (parentDir !== targetDir) { await mkdir(parentDir, { recursive: true }); } await writeFile( fullPath, agentType === 'eve' && basename(file.path).toLowerCase() === 'skill.md' ? stripIgnoredEveFrontmatter(file.contents) : file.contents, 'utf-8' ); } } try { if (installMode === 'copy') { await cleanAndCreateDirectory(agentDir); await writeSkillFiles(agentDir); return { success: true, path: agentDir, mode: 'copy' }; } // Symlink mode await cleanAndCreateDirectory(canonicalDir); await writeSkillFiles(canonicalDir); if (isGlobal && isUniversalAgent(agentType)) { return { success: true, path: canonicalDir, canonicalPath: canonicalDir, mode: 'symlink', }; } // For project-level installs, skip creating symlinks for non-universal agents // whose config directory doesn't already exist in the project. Explicitly // selected agents and agents with a compatibility policy are exempt. if ( shouldSkipProjectAgentSymlink( agentType, isGlobal, cwd, options.createMissingAgentRoot ?? false ) ) { return { success: true, path: canonicalDir, canonicalPath: canonicalDir, mode: 'symlink', skipped: true, skipReason: 'missing-agent-project-directory', }; } const symlinkCreated = await createSymlink(canonicalDir, agentDir); if (!symlinkCreated) { await cleanAndCreateDirectory(agentDir); await writeSkillFiles(agentDir); return { success: true, path: agentDir, canonicalPath: canonicalDir, mode: 'symlink', symlinkFailed: true, }; } return { success: true, path: agentDir, canonicalPath: canonicalDir, mode: 'symlink', }; } catch (error) { return { success: false, path: agentDir, mode: installMode, error: error instanceof Error ? error.message : 'Unknown error', }; } } export interface InstalledSkill { name: string; description: string; path: string; canonicalPath: string; scope: 'project' | 'global'; agents: AgentType[]; } /** * Lists all installed skills from canonical locations * @param options - Options for listing skills * @returns Array of installed skills with metadata */ export async function listInstalledSkills( options: { global?: boolean; cwd?: string; agentFilter?: AgentType[]; } = {} ): Promise { const cwd = options.cwd || process.cwd(); // Use a Map to deduplicate skills by scope:name const skillsMap: Map = new Map(); const scopes: Array<{ global: boolean; path: string; agentType?: AgentType }> = []; // Detect which agents are actually installed const detectedAgents = await detectInstalledAgents(); const agentFilter = options.agentFilter; const agentsToCheck = agentFilter ? detectedAgents.filter((a) => agentFilter.includes(a)) : detectedAgents; // Determine which scopes to scan const scopeTypes: Array<{ global: boolean }> = []; if (options.global === undefined) { scopeTypes.push({ global: false }, { global: true }); } else { scopeTypes.push({ global: options.global }); } // Build list of directories to scan: canonical + each installed agent's directory // // Scanning workflow: // // detectInstalledAgents() // │ // ▼ // for each scope (project / global) // │ // ├──▶ scan canonical dir ──▶ .agents/skills, ~/.agents/skills // │ // ├──▶ scan each installed agent's dir ──▶ .cursor/skills, .claude/skills, ... // │ // ▼ // deduplicate by skill name // // Trade-off: More readdir() calls, but most non-existent dirs fail fast. // Skills in agent-specific dirs skip the expensive "check all agents" loop. // for (const { global: isGlobal } of scopeTypes) { // Add canonical directory scopes.push({ global: isGlobal, path: getCanonicalSkillsDir(isGlobal, cwd) }); // Add each installed agent's skills directory for (const agentType of agentsToCheck) { const agent = agents[agentType]; if (isGlobal && agent.globalSkillsDir === undefined) { continue; } const agentDir = isGlobal ? agent.globalSkillsDir! : join(cwd, agent.skillsDir); // Avoid duplicate paths if (!scopes.some((s) => s.path === agentDir && s.global === isGlobal)) { scopes.push({ global: isGlobal, path: agentDir, agentType }); } // Eve subagents keep their skills under agent/subagents//skills. // These are project-scoped, so only scan them for the project scope. if (agentType === 'eve' && !isGlobal) { for (const subagent of getEveSubagents(cwd)) { const subagentDir = getEveSubagentSkillsDir(subagent, cwd); if (!scopes.some((s) => s.path === subagentDir && s.global === isGlobal)) { scopes.push({ global: isGlobal, path: subagentDir, agentType }); } } } } // Also scan skill directories for agents NOT in agentsToCheck, in case // skills were installed with `--agent ` but the agent is no longer // detected (e.g. ~/.openclaw was removed). Only add dirs that actually // exist on disk to avoid unnecessary readdir errors. const allAgentTypes = Object.keys(agents) as AgentType[]; for (const agentType of allAgentTypes) { if (agentsToCheck.includes(agentType)) continue; const agent = agents[agentType]; if (isGlobal && agent.globalSkillsDir === undefined) continue; const agentDir = isGlobal ? agent.globalSkillsDir! : join(cwd, agent.skillsDir); if (scopes.some((s) => s.path === agentDir && s.global === isGlobal)) continue; if (existsSync(agentDir)) { scopes.push({ global: isGlobal, path: agentDir, agentType }); } } } for (const scope of scopes) { try { const entries = await readdir(scope.path, { withFileTypes: true }); for (const entry of entries) { const skillDir = join(scope.path, entry.name); if (!(await isDirEntryOrSymlinkToDir(entry, skillDir))) continue; const skillMdPath = join(skillDir, 'SKILL.md'); // Check if SKILL.md exists try { await stat(skillMdPath); } catch { // SKILL.md doesn't exist, skip this directory continue; } // Parse the skill const skill = await parseSkillMd(skillMdPath); if (!skill) { continue; } const scopeKey = scope.global ? 'global' : 'project'; const skillKey = `${scopeKey}:${skill.name}`; // If scanning an agent-specific directory, attribute directly to that agent if (scope.agentType) { if (skillsMap.has(skillKey)) { const existing = skillsMap.get(skillKey)!; if (!existing.agents.includes(scope.agentType)) { existing.agents.push(scope.agentType); } } else { skillsMap.set(skillKey, { name: skill.name, description: skill.description, path: skillDir, canonicalPath: skillDir, scope: scopeKey, agents: [scope.agentType], }); } continue; } // For canonical directory, check which agents have this skill const sanitizedSkillName = sanitizeName(skill.name); const installedAgents: AgentType[] = []; for (const agentType of agentsToCheck) { const agent = agents[agentType]; if (scope.global && agent.globalSkillsDir === undefined) { continue; } const agentBase = getAgentBaseDir(agentType, scope.global, cwd); let found = false; // Try exact directory name matches const possibleNames = Array.from( new Set([ entry.name, sanitizedSkillName, skill.name .toLowerCase() .replace(/\s+/g, '-') .replace(/[\/\\:\0]/g, ''), ]) ); for (const possibleName of possibleNames) { const agentSkillDir = join(agentBase, possibleName); if (!isPathSafe(agentBase, agentSkillDir)) continue; try { await access(agentSkillDir); found = true; break; } catch { // Try next name } } // Fallback: scan all directories and check SKILL.md files // Handles cases where directory names don't match (e.g., "git-review" vs "Git Review Before Commit") if (!found) { try { const agentEntries = await readdir(agentBase, { withFileTypes: true }); for (const agentEntry of agentEntries) { const candidateDir = join(agentBase, agentEntry.name); if (!(await isDirEntryOrSymlinkToDir(agentEntry, candidateDir))) continue; if (!isPathSafe(agentBase, candidateDir)) continue; try { const candidateSkillMd = join(candidateDir, 'SKILL.md'); await stat(candidateSkillMd); const candidateSkill = await parseSkillMd(candidateSkillMd); if (candidateSkill && candidateSkill.name === skill.name) { found = true; break; } } catch { // Not a valid skill directory } } } catch { // Agent base directory doesn't exist } } if (found) { installedAgents.push(agentType); } } if (skillsMap.has(skillKey)) { // Merge agents const existing = skillsMap.get(skillKey)!; for (const agent of installedAgents) { if (!existing.agents.includes(agent)) { existing.agents.push(agent); } } } else { skillsMap.set(skillKey, { name: skill.name, description: skill.description, path: skillDir, canonicalPath: skillDir, scope: scopeKey, agents: installedAgents, }); } } } catch { // Directory doesn't exist, skip } } return Array.from(skillsMap.values()); }