skillhub/skillhub-cli/src/core/installer.ts
chenbaowang 32395ee5c2 refactor(cli): use dynamic isUniversalForScope instead of static isUniversalAgent
- Add isUniversalForScope(agent, isGlobal) and getAgentTargetDir(agent, isGlobal) to agent-detector.ts
- An agent is 'universal' when its target install dir equals the canonical .agents/skills directory
- This fixes incorrect symlink skipping for agents like Codex/Cursor whose globalSkillsDir
  differs from .agents/skills (e.g. .codex/skills, .cursor/skills)
- Update installer.ts to use dynamic check via optional AgentInfo parameter
- Update install.ts to use getAgentTargetDir and pass agent info to installSkill
- Update uninstall.ts to use isUniversalForScope for correct path resolution
- Clean up unused isUniversalAgent import in list.ts
2026-04-20 19:48:58 +08:00

146 lines
4.3 KiB
TypeScript

import { mkdirSync, symlinkSync, copyFileSync, readdirSync, lstatSync, unlinkSync, existsSync } from "node:fs";
import { join, dirname, relative } from "node:path";
import { homedir, platform } from "node:os";
import { isUniversalForScope, type AgentInfo } from "./agent-detector.js";
export interface SkillInstallResult {
skillName: string;
agentKey: string;
path: string;
mode: "symlink" | "copy";
success: boolean;
error?: string;
}
const CANONICAL_SKILLS_DIR = ".agents/skills";
function getCanonicalBase(isGlobal: boolean, cwd: string): string {
const home = homedir();
return isGlobal ? join(home, CANONICAL_SKILLS_DIR) : join(cwd, CANONICAL_SKILLS_DIR);
}
function getAgentBaseDir(skillsDir: string, isGlobal: boolean, cwd: string): string {
const home = homedir();
if (isGlobal) {
return join(home, skillsDir);
}
return join(cwd, skillsDir);
}
function removePath(path: string): void {
try {
const stat = lstatSync(path);
if (stat.isSymbolicLink()) {
unlinkSync(path);
} else if (stat.isDirectory()) {
for (const entry of readdirSync(path)) {
removePath(join(path, entry));
}
if (platform() !== "win32") {
try { unlinkSync(path); } catch { }
}
} else {
unlinkSync(path);
}
} catch { }
}
function ensureDir(path: string): void {
if (!existsSync(path)) {
mkdirSync(path, { recursive: true });
}
}
function createSymlink(target: string, linkPath: string): boolean {
try {
if (target === linkPath) {
return true;
}
removePath(linkPath);
const linkDir = dirname(linkPath);
const resolvedLinkDir = linkDir.startsWith("~") ? join(homedir(), linkDir.slice(1)) : linkDir;
ensureDir(resolvedLinkDir);
const relativePath = relative(resolvedLinkDir, target);
const symlinkType = platform() === "win32" ? "junction" : "dir";
symlinkSync(relativePath, linkPath, symlinkType);
return true;
} catch {
return false;
}
}
export function installSkill(
skillDir: string,
skillName: string,
agentKey: string,
targetDir: string,
mode: "symlink" | "copy",
isGlobal: boolean,
agent?: AgentInfo,
): SkillInstallResult {
const cwd = process.cwd();
const canonicalBase = getCanonicalBase(isGlobal, cwd);
const canonicalDir = join(canonicalBase, skillName);
const agentBase = getAgentBaseDir(targetDir, isGlobal, cwd);
const agentDir = join(agentBase, skillName);
// Use dynamic scope-aware universal check if agent info is available,
// otherwise fall back to static targetDir check
const agentIsUniversal = agent
? isUniversalForScope(agent, isGlobal)
: targetDir === CANONICAL_SKILLS_DIR;
try {
if (mode === "copy") {
const copyDestDir = dirname(agentDir);
const resolvedCopyDestDir = copyDestDir.startsWith("~") ? join(homedir(), copyDestDir.slice(1)) : copyDestDir;
ensureDir(resolvedCopyDestDir);
removePath(agentDir);
mkdirSync(agentDir, { recursive: true });
copyDir(skillDir, agentDir);
return { skillName, agentKey, path: agentDir, mode, success: true };
}
ensureDir(dirname(canonicalDir));
removePath(canonicalDir);
mkdirSync(canonicalDir, { recursive: true });
copyDir(skillDir, canonicalDir);
if (isGlobal && agentIsUniversal) {
return { skillName, agentKey, path: canonicalDir, mode, success: true };
}
const symlinkCreated = createSymlink(canonicalDir, agentDir);
if (!symlinkCreated) {
const agentLinkDir = dirname(agentDir);
const resolvedAgentLinkDir = agentLinkDir.startsWith("~") ? join(homedir(), agentLinkDir.slice(1)) : agentLinkDir;
ensureDir(resolvedAgentLinkDir);
removePath(agentDir);
mkdirSync(agentDir, { recursive: true });
copyDir(skillDir, agentDir);
return { skillName, agentKey, path: agentDir, mode, success: true };
}
return { skillName, agentKey, path: agentDir, mode, success: true };
} catch (e: any) {
return { skillName, agentKey, path: agentDir, mode, success: false, error: e.message };
}
}
function copyDir(src: string, dest: string) {
mkdirSync(dest, { recursive: true });
for (const entry of readdirSync(src)) {
const srcPath = join(src, entry);
const destPath = join(dest, entry);
if (lstatSync(srcPath).isDirectory()) {
copyDir(srcPath, destPath);
} else {
copyFileSync(srcPath, destPath);
}
}
}