diff --git a/apps/cli/src/commands/add-skill.ts b/apps/cli/src/commands/add-skill.ts index dd486de0e..6339c16d8 100644 --- a/apps/cli/src/commands/add-skill.ts +++ b/apps/cli/src/commands/add-skill.ts @@ -3,6 +3,7 @@ import { lstatSync, mkdirSync, readlinkSync, + rmSync, symlinkSync, unlinkSync, writeFileSync, @@ -91,6 +92,14 @@ const downloadSkill = async (skillDir: string): Promise => { } }; +const getPathStats = (path: string) => { + try { + return lstatSync(path); + } catch { + return undefined; + } +}; + const selectAgents = async (agents: readonly SupportedAgent[], nonInteractive: boolean) => { if (nonInteractive) return [...agents]; @@ -114,18 +123,21 @@ const selectAgents = async (agents: readonly SupportedAgent[], nonInteractive: b return (response.agents ?? []) as SupportedAgent[]; }; -const ensureAgentSymlink = (projectRoot: string, agent: SupportedAgent): boolean | string => { +export const ensureAgentSymlink = (projectRoot: string, agent: SupportedAgent): boolean | string => { const skillSourceDir = join(projectRoot, AGENTS_SKILLS_DIR, SKILL_NAME); const agentSkillDir = join(projectRoot, toSkillDir(agent)); const symlinkPath = join(agentSkillDir, SKILL_NAME); const targetPath = relative(dirname(symlinkPath), skillSourceDir); try { - if (existsSync(symlinkPath)) { - const stats = lstatSync(symlinkPath); - if (!stats.isSymbolicLink()) return `${symlinkPath} exists and is not a symlink`; - if (readlinkSync(symlinkPath) === targetPath) return true; - unlinkSync(symlinkPath); + const stats = getPathStats(symlinkPath); + if (stats) { + if (stats.isSymbolicLink()) { + if (readlinkSync(symlinkPath) === targetPath) return true; + unlinkSync(symlinkPath); + } else { + rmSync(symlinkPath, { recursive: true, force: true }); + } } mkdirSync(dirname(symlinkPath), { recursive: true }); diff --git a/apps/cli/tests/add-skill.test.ts b/apps/cli/tests/add-skill.test.ts index db25f2311..0fcb521f8 100644 --- a/apps/cli/tests/add-skill.test.ts +++ b/apps/cli/tests/add-skill.test.ts @@ -1,8 +1,18 @@ -import { mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs"; +import { + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + readlinkSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { describe, expect, it, beforeEach, afterEach } from "vite-plus/test"; -import { extractTarEntries, readNullTerminated } from "../src/commands/add-skill"; +import { ensureAgentSymlink, extractTarEntries, readNullTerminated } from "../src/commands/add-skill"; const TAR_HEADER_SIZE = 512; @@ -108,3 +118,43 @@ describe("extractTarEntries", () => { expect(readdirSync(destDir)).toEqual([]); }); }); + +describe("ensureAgentSymlink", () => { + it("replaces an existing non-symlink expect directory with a symlink", () => { + const projectRoot = mkdtempSync(join(tmpdir(), "ensure-symlink-")); + + try { + mkdirSync(join(projectRoot, ".agents", "skills", "expect"), { recursive: true }); + mkdirSync(join(projectRoot, ".codex", "skills", "expect"), { recursive: true }); + writeFileSync(join(projectRoot, ".codex", "skills", "expect", "old-file.txt"), "legacy"); + + const result = ensureAgentSymlink(projectRoot, "codex"); + + expect(result).toBe(true); + const linkedPath = join(projectRoot, ".codex", "skills", "expect"); + expect(lstatSync(linkedPath).isSymbolicLink()).toBe(true); + expect(readlinkSync(linkedPath)).toBe("../../.agents/skills/expect"); + } finally { + rmSync(projectRoot, { recursive: true, force: true }); + } + }); + + it("replaces a broken symlink expect path with a valid symlink", () => { + const projectRoot = mkdtempSync(join(tmpdir(), "ensure-symlink-broken-")); + + try { + mkdirSync(join(projectRoot, ".agents", "skills", "expect"), { recursive: true }); + mkdirSync(join(projectRoot, ".codex", "skills"), { recursive: true }); + symlinkSync("../../../.agents/skills/does-not-exist", join(projectRoot, ".codex", "skills", "expect")); + + const result = ensureAgentSymlink(projectRoot, "codex"); + + expect(result).toBe(true); + const linkedPath = join(projectRoot, ".codex", "skills", "expect"); + expect(lstatSync(linkedPath).isSymbolicLink()).toBe(true); + expect(readlinkSync(linkedPath)).toBe("../../.agents/skills/expect"); + } finally { + rmSync(projectRoot, { recursive: true, force: true }); + } + }); +});