diff --git a/cli/src/commands/client/agent.ts b/cli/src/commands/client/agent.ts index 36eb04e6..2c294628 100644 --- a/cli/src/commands/client/agent.ts +++ b/cli/src/commands/client/agent.ts @@ -1,5 +1,9 @@ import { Command } from "commander"; import type { Agent } from "@paperclipai/shared"; +import { + removeMaintainerOnlySkillSymlinks, + resolvePaperclipSkillsDir, +} from "@paperclipai/adapter-utils/server-utils"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -34,15 +38,12 @@ interface SkillsInstallSummary { tool: "codex" | "claude"; target: string; linked: string[]; + removed: string[]; skipped: string[]; failed: Array<{ name: string; error: string }>; } const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); -const PAPERCLIP_SKILLS_CANDIDATES = [ - path.resolve(__moduleDir, "../../../../../skills"), // dev: cli/src/commands/client -> repo root/skills - path.resolve(process.cwd(), "skills"), -]; function codexSkillsHome(): string { const fromEnv = process.env.CODEX_HOME?.trim(); @@ -56,14 +57,6 @@ function claudeSkillsHome(): string { return path.join(base, "skills"); } -async function resolvePaperclipSkillsDir(): Promise { - for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) { - const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false); - if (isDir) return candidate; - } - return null; -} - async function installSkillsForTarget( sourceSkillsDir: string, targetSkillsDir: string, @@ -73,20 +66,65 @@ async function installSkillsForTarget( tool, target: targetSkillsDir, linked: [], + removed: [], skipped: [], failed: [], }; await fs.mkdir(targetSkillsDir, { recursive: true }); const entries = await fs.readdir(sourceSkillsDir, { withFileTypes: true }); + summary.removed = await removeMaintainerOnlySkillSymlinks( + targetSkillsDir, + entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name), + ); for (const entry of entries) { if (!entry.isDirectory()) continue; const source = path.join(sourceSkillsDir, entry.name); const target = path.join(targetSkillsDir, entry.name); const existing = await fs.lstat(target).catch(() => null); if (existing) { - summary.skipped.push(entry.name); - continue; + if (existing.isSymbolicLink()) { + let linkedPath: string | null = null; + try { + linkedPath = await fs.readlink(target); + } catch (err) { + await fs.unlink(target); + try { + await fs.symlink(source, target); + summary.linked.push(entry.name); + continue; + } catch (linkErr) { + summary.failed.push({ + name: entry.name, + error: + err instanceof Error && linkErr instanceof Error + ? `${err.message}; then ${linkErr.message}` + : err instanceof Error + ? err.message + : `Failed to recover broken symlink: ${String(err)}`, + }); + continue; + } + } + + const resolvedLinkedPath = path.isAbsolute(linkedPath) + ? linkedPath + : path.resolve(path.dirname(target), linkedPath); + const linkedTargetExists = await fs + .stat(resolvedLinkedPath) + .then(() => true) + .catch(() => false); + + if (!linkedTargetExists) { + await fs.unlink(target); + } else { + summary.skipped.push(entry.name); + continue; + } + } else { + summary.skipped.push(entry.name); + continue; + } } try { @@ -210,7 +248,7 @@ export function registerAgentCommands(program: Command): void { const installSummaries: SkillsInstallSummary[] = []; if (opts.installSkills !== false) { - const skillsDir = await resolvePaperclipSkillsDir(); + const skillsDir = await resolvePaperclipSkillsDir(__moduleDir, [path.resolve(process.cwd(), "skills")]); if (!skillsDir) { throw new Error( "Could not locate local Paperclip skills directory. Expected ./skills in the repo checkout.", @@ -258,7 +296,7 @@ export function registerAgentCommands(program: Command): void { if (installSummaries.length > 0) { for (const summary of installSummaries) { console.log( - `${summary.tool}: linked=${summary.linked.length} skipped=${summary.skipped.length} failed=${summary.failed.length} target=${summary.target}`, + `${summary.tool}: linked=${summary.linked.length} removed=${summary.removed.length} skipped=${summary.skipped.length} failed=${summary.failed.length} target=${summary.target}`, ); for (const failed of summary.failed) { console.log(` failed ${failed.name}: ${failed.error}`); diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 2b9de31f..30f0c9bd 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -32,6 +32,23 @@ export const runningProcesses = new Map(); export const MAX_CAPTURE_BYTES = 4 * 1024 * 1024; export const MAX_EXCERPT_BYTES = 32 * 1024; const SENSITIVE_ENV_KEY = /(key|token|secret|password|passwd|authorization|cookie)/i; +const PAPERCLIP_SKILL_ROOT_RELATIVE_CANDIDATES = [ + "../../skills", + "../../../../../skills", +]; + +export interface PaperclipSkillEntry { + name: string; + source: string; +} + +function normalizePathSlashes(value: string): string { + return value.replaceAll("\\", "/"); +} + +function isMaintainerOnlySkillTarget(candidate: string): boolean { + return normalizePathSlashes(candidate).includes("/.agents/skills/"); +} export function parseObject(value: unknown): Record { if (typeof value !== "object" || value === null || Array.isArray(value)) { @@ -245,6 +262,136 @@ export async function ensureAbsoluteDirectory( } } +export async function resolvePaperclipSkillsDir( + moduleDir: string, + additionalCandidates: string[] = [], +): Promise { + const candidates = [ + ...PAPERCLIP_SKILL_ROOT_RELATIVE_CANDIDATES.map((relativePath) => path.resolve(moduleDir, relativePath)), + ...additionalCandidates.map((candidate) => path.resolve(candidate)), + ]; + const seenRoots = new Set(); + + for (const root of candidates) { + if (seenRoots.has(root)) continue; + seenRoots.add(root); + const isDirectory = await fs.stat(root).then((stats) => stats.isDirectory()).catch(() => false); + if (isDirectory) return root; + } + + return null; +} + +export async function listPaperclipSkillEntries( + moduleDir: string, + additionalCandidates: string[] = [], +): Promise { + const root = await resolvePaperclipSkillsDir(moduleDir, additionalCandidates); + if (!root) return []; + + try { + const entries = await fs.readdir(root, { withFileTypes: true }); + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => ({ + name: entry.name, + source: path.join(root, entry.name), + })); + } catch { + return []; + } +} + +export async function readPaperclipSkillMarkdown( + moduleDir: string, + skillName: string, +): Promise { + const normalized = skillName.trim().toLowerCase(); + if (!normalized) return null; + + const entries = await listPaperclipSkillEntries(moduleDir); + const match = entries.find((entry) => entry.name === normalized); + if (!match) return null; + + try { + return await fs.readFile(path.join(match.source, "SKILL.md"), "utf8"); + } catch { + return null; + } +} + +export async function ensurePaperclipSkillSymlink( + source: string, + target: string, + linkSkill: (source: string, target: string) => Promise = (linkSource, linkTarget) => + fs.symlink(linkSource, linkTarget), +): Promise<"created" | "repaired" | "skipped"> { + const existing = await fs.lstat(target).catch(() => null); + if (!existing) { + await linkSkill(source, target); + return "created"; + } + + if (!existing.isSymbolicLink()) { + return "skipped"; + } + + const linkedPath = await fs.readlink(target).catch(() => null); + if (!linkedPath) return "skipped"; + + const resolvedLinkedPath = path.resolve(path.dirname(target), linkedPath); + if (resolvedLinkedPath === source) { + return "skipped"; + } + + const linkedPathExists = await fs.stat(resolvedLinkedPath).then(() => true).catch(() => false); + if (linkedPathExists) { + return "skipped"; + } + + await fs.unlink(target); + await linkSkill(source, target); + return "repaired"; +} + +export async function removeMaintainerOnlySkillSymlinks( + skillsHome: string, + allowedSkillNames: Iterable, +): Promise { + const allowed = new Set(Array.from(allowedSkillNames)); + try { + const entries = await fs.readdir(skillsHome, { withFileTypes: true }); + const removed: string[] = []; + for (const entry of entries) { + if (allowed.has(entry.name)) continue; + + const target = path.join(skillsHome, entry.name); + const existing = await fs.lstat(target).catch(() => null); + if (!existing?.isSymbolicLink()) continue; + + const linkedPath = await fs.readlink(target).catch(() => null); + if (!linkedPath) continue; + + const resolvedLinkedPath = path.isAbsolute(linkedPath) + ? linkedPath + : path.resolve(path.dirname(target), linkedPath); + if ( + !isMaintainerOnlySkillTarget(linkedPath) && + !isMaintainerOnlySkillTarget(resolvedLinkedPath) + ) { + continue; + } + + await fs.unlink(target); + removed.push(entry.name); + } + + return removed; + } catch { + return []; + } +} + export async function ensureCommandResolvable(command: string, cwd: string, env: NodeJS.ProcessEnv) { const resolved = await resolveCommandPath(command, cwd, env); if (resolved) return; diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index 3dec4ff7..c51dc8a1 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -13,17 +13,16 @@ import { redactEnvForLogs, ensureAbsoluteDirectory, ensureCommandResolvable, + ensurePaperclipSkillSymlink, ensurePathInEnv, + listPaperclipSkillEntries, + removeMaintainerOnlySkillSymlinks, renderTemplate, runChildProcess, } from "@paperclipai/adapter-utils/server-utils"; import { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js"; const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); -const PAPERCLIP_SKILLS_CANDIDATES = [ - path.resolve(__moduleDir, "../../skills"), // published: /dist/server/ -> /skills/ - path.resolve(__moduleDir, "../../../../../skills"), // dev: src/server/ -> repo root/skills/ -]; const CODEX_ROLLOUT_NOISE_RE = /^\d{4}-\d{2}-\d{2}T[^\s]+\s+ERROR\s+codex_core::rollout::list:\s+state db missing rollout path for thread\s+[a-z0-9-]+$/i; @@ -67,33 +66,42 @@ function codexHomeDir(): string { return path.join(os.homedir(), ".codex"); } -async function resolvePaperclipSkillsDir(): Promise { - for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) { - const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false); - if (isDir) return candidate; - } - return null; -} +type EnsureCodexSkillsInjectedOptions = { + skillsHome?: string; + skillsEntries?: Awaited>; + linkSkill?: (source: string, target: string) => Promise; +}; -async function ensureCodexSkillsInjected(onLog: AdapterExecutionContext["onLog"]) { - const skillsDir = await resolvePaperclipSkillsDir(); - if (!skillsDir) return; +export async function ensureCodexSkillsInjected( + onLog: AdapterExecutionContext["onLog"], + options: EnsureCodexSkillsInjectedOptions = {}, +) { + const skillsEntries = options.skillsEntries ?? await listPaperclipSkillEntries(__moduleDir); + if (skillsEntries.length === 0) return; - const skillsHome = path.join(codexHomeDir(), "skills"); + const skillsHome = options.skillsHome ?? path.join(codexHomeDir(), "skills"); await fs.mkdir(skillsHome, { recursive: true }); - const entries = await fs.readdir(skillsDir, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const source = path.join(skillsDir, entry.name); + const removedSkills = await removeMaintainerOnlySkillSymlinks( + skillsHome, + skillsEntries.map((entry) => entry.name), + ); + for (const skillName of removedSkills) { + await onLog( + "stderr", + `[paperclip] Removed maintainer-only Codex skill "${skillName}" from ${skillsHome}\n`, + ); + } + const linkSkill = options.linkSkill; + for (const entry of skillsEntries) { const target = path.join(skillsHome, entry.name); - const existing = await fs.lstat(target).catch(() => null); - if (existing) continue; try { - await fs.symlink(source, target); + const result = await ensurePaperclipSkillSymlink(entry.source, target, linkSkill); + if (result === "skipped") continue; + await onLog( "stderr", - `[paperclip] Injected Codex skill "${entry.name}" into ${skillsHome}\n`, + `[paperclip] ${result === "repaired" ? "Repaired" : "Injected"} Codex skill "${entry.name}" into ${skillsHome}\n`, ); } catch (err) { await onLog( diff --git a/packages/adapters/codex-local/src/server/index.ts b/packages/adapters/codex-local/src/server/index.ts index 1b8dad75..04c1e368 100644 --- a/packages/adapters/codex-local/src/server/index.ts +++ b/packages/adapters/codex-local/src/server/index.ts @@ -1,4 +1,4 @@ -export { execute } from "./execute.js"; +export { execute, ensureCodexSkillsInjected } from "./execute.js"; export { testEnvironment } from "./test.js"; export { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js"; import type { AdapterSessionCodec } from "@paperclipai/adapter-utils"; diff --git a/packages/adapters/cursor-local/src/server/execute.ts b/packages/adapters/cursor-local/src/server/execute.ts index 162ed5c6..043c3ef1 100644 --- a/packages/adapters/cursor-local/src/server/execute.ts +++ b/packages/adapters/cursor-local/src/server/execute.ts @@ -1,5 +1,4 @@ import fs from "node:fs/promises"; -import type { Dirent } from "node:fs"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -13,7 +12,10 @@ import { redactEnvForLogs, ensureAbsoluteDirectory, ensureCommandResolvable, + ensurePaperclipSkillSymlink, ensurePathInEnv, + listPaperclipSkillEntries, + removeMaintainerOnlySkillSymlinks, renderTemplate, runChildProcess, } from "@paperclipai/adapter-utils/server-utils"; @@ -23,10 +25,6 @@ import { normalizeCursorStreamLine } from "../shared/stream.js"; import { hasCursorTrustBypassArg } from "../shared/trust.js"; const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); -const PAPERCLIP_SKILLS_CANDIDATES = [ - path.resolve(__moduleDir, "../../skills"), - path.resolve(__moduleDir, "../../../../../skills"), -]; function firstNonEmptyLine(text: string): string { return ( @@ -82,16 +80,9 @@ function cursorSkillsHome(): string { return path.join(os.homedir(), ".cursor", "skills"); } -async function resolvePaperclipSkillsDir(): Promise { - for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) { - const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false); - if (isDir) return candidate; - } - return null; -} - type EnsureCursorSkillsInjectedOptions = { skillsDir?: string | null; + skillsEntries?: Array<{ name: string; source: string }>; skillsHome?: string; linkSkill?: (source: string, target: string) => Promise; }; @@ -100,8 +91,13 @@ export async function ensureCursorSkillsInjected( onLog: AdapterExecutionContext["onLog"], options: EnsureCursorSkillsInjectedOptions = {}, ) { - const skillsDir = options.skillsDir ?? await resolvePaperclipSkillsDir(); - if (!skillsDir) return; + const skillsEntries = options.skillsEntries + ?? (options.skillsDir + ? (await fs.readdir(options.skillsDir, { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .map((entry) => ({ name: entry.name, source: path.join(options.skillsDir!, entry.name) })) + : await listPaperclipSkillEntries(__moduleDir)); + if (skillsEntries.length === 0) return; const skillsHome = options.skillsHome ?? cursorSkillsHome(); try { @@ -113,31 +109,26 @@ export async function ensureCursorSkillsInjected( ); return; } - - let entries: Dirent[]; - try { - entries = await fs.readdir(skillsDir, { withFileTypes: true }); - } catch (err) { + const removedSkills = await removeMaintainerOnlySkillSymlinks( + skillsHome, + skillsEntries.map((entry) => entry.name), + ); + for (const skillName of removedSkills) { await onLog( "stderr", - `[paperclip] Failed to read Paperclip skills from ${skillsDir}: ${err instanceof Error ? err.message : String(err)}\n`, + `[paperclip] Removed maintainer-only Cursor skill "${skillName}" from ${skillsHome}\n`, ); - return; } - const linkSkill = options.linkSkill ?? ((source: string, target: string) => fs.symlink(source, target)); - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const source = path.join(skillsDir, entry.name); + for (const entry of skillsEntries) { const target = path.join(skillsHome, entry.name); - const existing = await fs.lstat(target).catch(() => null); - if (existing) continue; - try { - await linkSkill(source, target); + const result = await ensurePaperclipSkillSymlink(entry.source, target, linkSkill); + if (result === "skipped") continue; + await onLog( "stderr", - `[paperclip] Injected Cursor skill "${entry.name}" into ${skillsHome}\n`, + `[paperclip] ${result === "repaired" ? "Repaired" : "Injected"} Cursor skill "${entry.name}" into ${skillsHome}\n`, ); } catch (err) { await onLog( diff --git a/packages/adapters/gemini-local/src/server/execute.ts b/packages/adapters/gemini-local/src/server/execute.ts index 4ffb51e3..2408b425 100644 --- a/packages/adapters/gemini-local/src/server/execute.ts +++ b/packages/adapters/gemini-local/src/server/execute.ts @@ -12,7 +12,10 @@ import { buildPaperclipEnv, ensureAbsoluteDirectory, ensureCommandResolvable, + ensurePaperclipSkillSymlink, ensurePathInEnv, + listPaperclipSkillEntries, + removeMaintainerOnlySkillSymlinks, parseObject, redactEnvForLogs, renderTemplate, @@ -29,10 +32,6 @@ import { import { firstNonEmptyLine } from "./utils.js"; const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); -const PAPERCLIP_SKILLS_CANDIDATES = [ - path.resolve(__moduleDir, "../../skills"), - path.resolve(__moduleDir, "../../../../../skills"), -]; function hasNonEmptyEnvValue(env: Record, key: string): boolean { const raw = env[key]; @@ -73,14 +72,6 @@ function renderApiAccessNote(env: Record): string { ].join("\n"); } -async function resolvePaperclipSkillsDir(): Promise { - for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) { - const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false); - if (isDir) return candidate; - } - return null; -} - function geminiSkillsHome(): string { return path.join(os.homedir(), ".gemini", "skills"); } @@ -93,8 +84,8 @@ function geminiSkillsHome(): string { async function ensureGeminiSkillsInjected( onLog: AdapterExecutionContext["onLog"], ): Promise { - const skillsDir = await resolvePaperclipSkillsDir(); - if (!skillsDir) return; + const skillsEntries = await listPaperclipSkillEntries(__moduleDir); + if (skillsEntries.length === 0) return; const skillsHome = geminiSkillsHome(); try { @@ -106,28 +97,27 @@ async function ensureGeminiSkillsInjected( ); return; } - - let entries: Dirent[]; - try { - entries = await fs.readdir(skillsDir, { withFileTypes: true }); - } catch (err) { + const removedSkills = await removeMaintainerOnlySkillSymlinks( + skillsHome, + skillsEntries.map((entry) => entry.name), + ); + for (const skillName of removedSkills) { await onLog( "stderr", - `[paperclip] Failed to read Paperclip skills from ${skillsDir}: ${err instanceof Error ? err.message : String(err)}\n`, + `[paperclip] Removed maintainer-only Gemini skill "${skillName}" from ${skillsHome}\n`, ); - return; } - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const source = path.join(skillsDir, entry.name); + for (const entry of skillsEntries) { const target = path.join(skillsHome, entry.name); - const existing = await fs.lstat(target).catch(() => null); - if (existing) continue; try { - await fs.symlink(source, target); - await onLog("stderr", `[paperclip] Linked Gemini skill: ${entry.name}\n`); + const result = await ensurePaperclipSkillSymlink(entry.source, target); + if (result === "skipped") continue; + await onLog( + "stderr", + `[paperclip] ${result === "repaired" ? "Repaired" : "Linked"} Gemini skill: ${entry.name}\n`, + ); } catch (err) { await onLog( "stderr", diff --git a/packages/adapters/pi-local/src/server/execute.ts b/packages/adapters/pi-local/src/server/execute.ts index 23cad28b..dfb1453b 100644 --- a/packages/adapters/pi-local/src/server/execute.ts +++ b/packages/adapters/pi-local/src/server/execute.ts @@ -12,7 +12,10 @@ import { redactEnvForLogs, ensureAbsoluteDirectory, ensureCommandResolvable, + ensurePaperclipSkillSymlink, ensurePathInEnv, + listPaperclipSkillEntries, + removeMaintainerOnlySkillSymlinks, renderTemplate, runChildProcess, } from "@paperclipai/adapter-utils/server-utils"; @@ -20,10 +23,6 @@ import { isPiUnknownSessionError, parsePiJsonl } from "./parse.js"; import { ensurePiModelConfiguredAndAvailable } from "./models.js"; const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); -const PAPERCLIP_SKILLS_CANDIDATES = [ - path.resolve(__moduleDir, "../../skills"), - path.resolve(__moduleDir, "../../../../../skills"), -]; const PAPERCLIP_SESSIONS_DIR = path.join(os.homedir(), ".pi", "paperclips"); @@ -50,34 +49,32 @@ function parseModelId(model: string | null): string | null { return trimmed.slice(trimmed.indexOf("/") + 1).trim() || null; } -async function resolvePaperclipSkillsDir(): Promise { - for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) { - const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false); - if (isDir) return candidate; - } - return null; -} - async function ensurePiSkillsInjected(onLog: AdapterExecutionContext["onLog"]) { - const skillsDir = await resolvePaperclipSkillsDir(); - if (!skillsDir) return; + const skillsEntries = await listPaperclipSkillEntries(__moduleDir); + if (skillsEntries.length === 0) return; const piSkillsHome = path.join(os.homedir(), ".pi", "agent", "skills"); await fs.mkdir(piSkillsHome, { recursive: true }); - - const entries = await fs.readdir(skillsDir, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const source = path.join(skillsDir, entry.name); + const removedSkills = await removeMaintainerOnlySkillSymlinks( + piSkillsHome, + skillsEntries.map((entry) => entry.name), + ); + for (const skillName of removedSkills) { + await onLog( + "stderr", + `[paperclip] Removed maintainer-only Pi skill "${skillName}" from ${piSkillsHome}\n`, + ); + } + + for (const entry of skillsEntries) { const target = path.join(piSkillsHome, entry.name); - const existing = await fs.lstat(target).catch(() => null); - if (existing) continue; try { - await fs.symlink(source, target); + const result = await ensurePaperclipSkillSymlink(entry.source, target); + if (result === "skipped") continue; await onLog( "stderr", - `[paperclip] Injected Pi skill "${entry.name}" into ${piSkillsHome}\n`, + `[paperclip] ${result === "repaired" ? "Repaired" : "Injected"} Pi skill "${entry.name}" into ${piSkillsHome}\n`, ); } catch (err) { await onLog( diff --git a/server/src/__tests__/paperclip-skill-utils.test.ts b/server/src/__tests__/paperclip-skill-utils.test.ts new file mode 100644 index 00000000..4344dc17 --- /dev/null +++ b/server/src/__tests__/paperclip-skill-utils.test.ts @@ -0,0 +1,61 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + listPaperclipSkillEntries, + removeMaintainerOnlySkillSymlinks, +} from "@paperclipai/adapter-utils/server-utils"; + +async function makeTempDir(prefix: string): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), prefix)); +} + +describe("paperclip skill utils", () => { + const cleanupDirs = new Set(); + + afterEach(async () => { + await Promise.all(Array.from(cleanupDirs).map((dir) => fs.rm(dir, { recursive: true, force: true }))); + cleanupDirs.clear(); + }); + + it("lists runtime skills from ./skills without pulling in .agents/skills", async () => { + const root = await makeTempDir("paperclip-skill-roots-"); + cleanupDirs.add(root); + + const moduleDir = path.join(root, "a", "b", "c", "d", "e"); + await fs.mkdir(moduleDir, { recursive: true }); + await fs.mkdir(path.join(root, "skills", "paperclip"), { recursive: true }); + await fs.mkdir(path.join(root, ".agents", "skills", "release"), { recursive: true }); + + const entries = await listPaperclipSkillEntries(moduleDir); + + expect(entries.map((entry) => entry.name)).toEqual(["paperclip"]); + expect(entries[0]?.source).toBe(path.join(root, "skills", "paperclip")); + }); + + it("removes stale maintainer-only symlinks from a shared skills home", async () => { + const root = await makeTempDir("paperclip-skill-cleanup-"); + cleanupDirs.add(root); + + const skillsHome = path.join(root, "skills-home"); + const runtimeSkill = path.join(root, "skills", "paperclip"); + const customSkill = path.join(root, "custom", "release-notes"); + const staleMaintainerSkill = path.join(root, ".agents", "skills", "release"); + + await fs.mkdir(skillsHome, { recursive: true }); + await fs.mkdir(runtimeSkill, { recursive: true }); + await fs.mkdir(customSkill, { recursive: true }); + + await fs.symlink(runtimeSkill, path.join(skillsHome, "paperclip")); + await fs.symlink(customSkill, path.join(skillsHome, "release-notes")); + await fs.symlink(staleMaintainerSkill, path.join(skillsHome, "release")); + + const removed = await removeMaintainerOnlySkillSymlinks(skillsHome, ["paperclip"]); + + expect(removed).toEqual(["release"]); + await expect(fs.lstat(path.join(skillsHome, "release"))).rejects.toThrow(); + expect((await fs.lstat(path.join(skillsHome, "paperclip"))).isSymbolicLink()).toBe(true); + expect((await fs.lstat(path.join(skillsHome, "release-notes"))).isSymbolicLink()).toBe(true); + }); +});