diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 2b9de31f..15f0ada8 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -32,6 +32,17 @@ 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 = [ + "../../.agents/skills", + "../../skills", + "../../../../../.agents/skills", + "../../../../../skills", +]; + +export interface PaperclipSkillEntry { + name: string; + source: string; +} export function parseObject(value: unknown): Record { if (typeof value !== "object" || value === null || Array.isArray(value)) { @@ -245,6 +256,90 @@ export async function ensureAbsoluteDirectory( } } +export async function listPaperclipSkillEntries(moduleDir: string): Promise { + const entriesByName = new Map(); + const seenRoots = new Set(); + + for (const relativePath of PAPERCLIP_SKILL_ROOT_RELATIVE_CANDIDATES) { + const root = path.resolve(moduleDir, relativePath); + if (seenRoots.has(root)) continue; + seenRoots.add(root); + + const isDirectory = await fs.stat(root).then((stats) => stats.isDirectory()).catch(() => false); + if (!isDirectory) continue; + + let entries: Awaited>; + try { + entries = await fs.readdir(root, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (entriesByName.has(entry.name)) continue; + entriesByName.set(entry.name, { + name: entry.name, + source: path.join(root, entry.name), + }); + } + } + + return Array.from(entriesByName.values()); +} + +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 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..3afbf09e 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -13,17 +13,15 @@ import { redactEnvForLogs, ensureAbsoluteDirectory, ensureCommandResolvable, + ensurePaperclipSkillSymlink, ensurePathInEnv, + listPaperclipSkillEntries, 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 +65,32 @@ 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 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..ae068c1c 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,9 @@ import { redactEnvForLogs, ensureAbsoluteDirectory, ensureCommandResolvable, + ensurePaperclipSkillSymlink, ensurePathInEnv, + listPaperclipSkillEntries, renderTemplate, runChildProcess, } from "@paperclipai/adapter-utils/server-utils"; @@ -23,10 +24,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 +79,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 +90,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 +108,16 @@ export async function ensureCursorSkillsInjected( ); return; } - - let entries: Dirent[]; - try { - entries = await fs.readdir(skillsDir, { withFileTypes: true }); - } catch (err) { - await onLog( - "stderr", - `[paperclip] Failed to read Paperclip skills from ${skillsDir}: ${err instanceof Error ? err.message : String(err)}\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..fa27bffa 100644 --- a/packages/adapters/gemini-local/src/server/execute.ts +++ b/packages/adapters/gemini-local/src/server/execute.ts @@ -12,7 +12,9 @@ import { buildPaperclipEnv, ensureAbsoluteDirectory, ensureCommandResolvable, + ensurePaperclipSkillSymlink, ensurePathInEnv, + listPaperclipSkillEntries, parseObject, redactEnvForLogs, renderTemplate, @@ -29,10 +31,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 +71,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 +83,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 { @@ -107,27 +97,16 @@ async function ensureGeminiSkillsInjected( return; } - let entries: Dirent[]; - try { - entries = await fs.readdir(skillsDir, { withFileTypes: true }); - } catch (err) { - await onLog( - "stderr", - `[paperclip] Failed to read Paperclip skills from ${skillsDir}: ${err instanceof Error ? err.message : String(err)}\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..6fabe32d 100644 --- a/packages/adapters/pi-local/src/server/execute.ts +++ b/packages/adapters/pi-local/src/server/execute.ts @@ -12,7 +12,9 @@ import { redactEnvForLogs, ensureAbsoluteDirectory, ensureCommandResolvable, + ensurePaperclipSkillSymlink, ensurePathInEnv, + listPaperclipSkillEntries, renderTemplate, runChildProcess, } from "@paperclipai/adapter-utils/server-utils"; @@ -20,10 +22,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 +48,22 @@ 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); + + 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(