From 7675fd0856be8389ba4d9a027a7699d7da4a6bbf Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 15 Mar 2026 07:05:01 -0500 Subject: [PATCH] Fix runtime skill injection across adapters --- packages/adapter-utils/src/server-utils.ts | 49 ++++++++++++ packages/adapter-utils/src/types.ts | 2 + .../claude-local/src/server/execute.ts | 6 +- .../claude-local/src/server/skills.ts | 22 ++--- .../codex-local/src/server/execute.ts | 18 ++--- .../adapters/codex-local/src/server/skills.ts | 29 +++---- .../cursor-local/src/server/execute.ts | 13 ++- .../cursor-local/src/server/skills.ts | 29 +++---- .../gemini-local/src/server/execute.ts | 25 +++--- .../gemini-local/src/server/skills.ts | 29 +++---- .../opencode-local/src/server/execute.ts | 53 ++++++------ .../opencode-local/src/server/skills.ts | 29 +++---- .../adapters/pi-local/src/server/execute.ts | 25 +++--- .../adapters/pi-local/src/server/skills.ts | 29 +++---- packages/shared/src/types/adapter-skills.ts | 2 + .../shared/src/validators/adapter-skills.ts | 2 + .../__tests__/claude-local-skill-sync.test.ts | 5 +- .../__tests__/codex-local-skill-sync.test.ts | 11 +-- .../__tests__/cursor-local-execute.test.ts | 80 +++++++++++++++++++ .../__tests__/cursor-local-skill-sync.test.ts | 64 +++++++++++++-- .../__tests__/gemini-local-skill-sync.test.ts | 11 +-- .../opencode-local-skill-sync.test.ts | 11 +-- .../src/__tests__/pi-local-skill-sync.test.ts | 11 +-- server/src/routes/agents.ts | 30 +++++-- server/src/services/company-skills.ts | 60 +++++++++++++- server/src/services/heartbeat.ts | 9 ++- ui/src/pages/AgentDetail.tsx | 74 +++++++++++++---- 27 files changed, 506 insertions(+), 222 deletions(-) diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 2648e4a8..1afe4c3b 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -40,6 +40,8 @@ const PAPERCLIP_SKILL_ROOT_RELATIVE_CANDIDATES = [ export interface PaperclipSkillEntry { name: string; source: string; + required?: boolean; + requiredReason?: string | null; } function normalizePathSlashes(value: string): string { @@ -306,12 +308,45 @@ export async function listPaperclipSkillEntries( .map((entry) => ({ name: entry.name, source: path.join(root, entry.name), + required: true, + requiredReason: "Bundled Paperclip skills are always available for local adapters.", })); } catch { return []; } } +function normalizeConfiguredPaperclipRuntimeSkills(value: unknown): PaperclipSkillEntry[] { + if (!Array.isArray(value)) return []; + const out: PaperclipSkillEntry[] = []; + for (const rawEntry of value) { + const entry = parseObject(rawEntry); + const name = asString(entry.name, "").trim(); + const source = asString(entry.source, "").trim(); + if (!name || !source) continue; + out.push({ + name, + source, + required: asBoolean(entry.required, false), + requiredReason: + typeof entry.requiredReason === "string" && entry.requiredReason.trim().length > 0 + ? entry.requiredReason.trim() + : null, + }); + } + return out; +} + +export async function readPaperclipRuntimeSkillEntries( + config: Record, + moduleDir: string, + additionalCandidates: string[] = [], +): Promise { + const configuredEntries = normalizeConfiguredPaperclipRuntimeSkills(config.paperclipRuntimeSkills); + if (configuredEntries.length > 0) return configuredEntries; + return listPaperclipSkillEntries(moduleDir, additionalCandidates); +} + export async function readPaperclipSkillMarkdown( moduleDir: string, skillName: string, @@ -352,6 +387,20 @@ export function readPaperclipSkillSyncPreference(config: Record }; } +export function resolvePaperclipDesiredSkillNames( + config: Record, + availableEntries: Array<{ name: string; required?: boolean }>, +): string[] { + const preference = readPaperclipSkillSyncPreference(config); + const requiredSkills = availableEntries + .filter((entry) => entry.required) + .map((entry) => entry.name); + if (!preference.explicit) { + return Array.from(new Set(requiredSkills)); + } + return Array.from(new Set([...requiredSkills, ...preference.desiredSkills])); +} + export function writePaperclipSkillSyncPreference( config: Record, desiredSkills: string[], diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index 52afdb66..26195804 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -152,6 +152,8 @@ export interface AdapterSkillEntry { name: string; desired: boolean; managed: boolean; + required?: boolean; + requiredReason?: string | null; state: AdapterSkillState; sourcePath?: string | null; targetPath?: string | null; diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index 86745e7f..eb7d89e3 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -12,7 +12,7 @@ import { parseObject, parseJson, buildPaperclipEnv, - listPaperclipSkillEntries, + readPaperclipRuntimeSkillEntries, joinPromptSections, redactEnvForLogs, ensureAbsoluteDirectory, @@ -41,11 +41,11 @@ async function buildSkillsDir(config: Record): Promise const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skills-")); const target = path.join(tmp, ".claude", "skills"); await fs.mkdir(target, { recursive: true }); - const availableEntries = await listPaperclipSkillEntries(__moduleDir); + const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); const desiredNames = new Set( resolveClaudeDesiredSkillNames( config, - availableEntries.map((entry) => entry.name), + availableEntries, ), ); for (const entry of availableEntries) { diff --git a/packages/adapters/claude-local/src/server/skills.ts b/packages/adapters/claude-local/src/server/skills.ts index b2c02501..194ef324 100644 --- a/packages/adapters/claude-local/src/server/skills.ts +++ b/packages/adapters/claude-local/src/server/skills.ts @@ -6,24 +6,16 @@ import type { AdapterSkillSnapshot, } from "@paperclipai/adapter-utils"; import { - listPaperclipSkillEntries, - readPaperclipSkillSyncPreference, + readPaperclipRuntimeSkillEntries, + resolvePaperclipDesiredSkillNames, } from "@paperclipai/adapter-utils/server-utils"; const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); -function resolveDesiredSkillNames(config: Record, availableSkillNames: string[]) { - const preference = readPaperclipSkillSyncPreference(config); - return preference.explicit ? preference.desiredSkills : availableSkillNames; -} - async function buildClaudeSkillSnapshot(config: Record): Promise { - const availableEntries = await listPaperclipSkillEntries(__moduleDir); + const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); const availableByName = new Map(availableEntries.map((entry) => [entry.name, entry])); - const desiredSkills = resolveDesiredSkillNames( - config, - availableEntries.map((entry) => entry.name), - ); + const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries); const desiredSet = new Set(desiredSkills); const entries: AdapterSkillEntry[] = availableEntries.map((entry) => ({ name: entry.name, @@ -35,6 +27,8 @@ async function buildClaudeSkillSnapshot(config: Record): Promis detail: desiredSet.has(entry.name) ? "Will be mounted into the ephemeral Claude skill directory on the next run." : null, + required: Boolean(entry.required), + requiredReason: entry.requiredReason ?? null, })); const warnings: string[] = []; @@ -77,7 +71,7 @@ export async function syncClaudeSkills( export function resolveClaudeDesiredSkillNames( config: Record, - availableSkillNames: string[], + availableEntries: Array<{ name: string; required?: boolean }>, ) { - return resolveDesiredSkillNames(config, availableSkillNames); + return resolvePaperclipDesiredSkillNames(config, availableEntries); } diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index 452b8761..72972fa2 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -14,7 +14,8 @@ import { ensureCommandResolvable, ensurePaperclipSkillSymlink, ensurePathInEnv, - listPaperclipSkillEntries, + readPaperclipRuntimeSkillEntries, + resolvePaperclipDesiredSkillNames, removeMaintainerOnlySkillSymlinks, renderTemplate, joinPromptSections, @@ -92,7 +93,7 @@ async function isLikelyPaperclipRuntimeSkillSource(candidate: string, skillName: type EnsureCodexSkillsInjectedOptions = { skillsHome?: string; - skillsEntries?: Awaited>; + skillsEntries?: Array<{ name: string; source: string }>; desiredSkillNames?: string[]; linkSkill?: (source: string, target: string) => Promise; }; @@ -101,7 +102,7 @@ export async function ensureCodexSkillsInjected( onLog: AdapterExecutionContext["onLog"], options: EnsureCodexSkillsInjectedOptions = {}, ) { - const allSkillsEntries = options.skillsEntries ?? await listPaperclipSkillEntries(__moduleDir); + const allSkillsEntries = options.skillsEntries ?? await readPaperclipRuntimeSkillEntries({}, __moduleDir); const desiredSkillNames = options.desiredSkillNames ?? allSkillsEntries.map((entry) => entry.name); const desiredSet = new Set(desiredSkillNames); @@ -220,10 +221,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 ? path.resolve(envConfig.CODEX_HOME.trim()) : null; - const desiredSkillNames = resolveCodexDesiredSkillNames( - config, - (await listPaperclipSkillEntries(__moduleDir)).map((entry) => entry.name), - ); + const codexSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); + const desiredSkillNames = resolveCodexDesiredSkillNames(config, codexSkillEntries); await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); const preparedWorktreeCodexHome = configuredCodexHome ? null : await prepareWorktreeCodexHome(process.env, onLog); @@ -231,11 +230,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0; diff --git a/packages/adapters/codex-local/src/server/skills.ts b/packages/adapters/codex-local/src/server/skills.ts index 12e30347..b2fccab9 100644 --- a/packages/adapters/codex-local/src/server/skills.ts +++ b/packages/adapters/codex-local/src/server/skills.ts @@ -8,8 +8,8 @@ import type { } from "@paperclipai/adapter-utils"; import { ensurePaperclipSkillSymlink, - listPaperclipSkillEntries, - readPaperclipSkillSyncPreference, + readPaperclipRuntimeSkillEntries, + resolvePaperclipDesiredSkillNames, } from "@paperclipai/adapter-utils/server-utils"; import { resolveCodexHomeDir } from "./codex-home.js"; @@ -29,11 +29,6 @@ function resolveCodexSkillsHome(config: Record) { return path.join(home, "skills"); } -function resolveDesiredSkillNames(config: Record, availableSkillNames: string[]) { - const preference = readPaperclipSkillSyncPreference(config); - return preference.explicit ? preference.desiredSkills : availableSkillNames; -} - async function readInstalledSkillTargets(skillsHome: string) { const entries = await fs.readdir(skillsHome, { withFileTypes: true }).catch(() => []); const out = new Map(); @@ -57,12 +52,9 @@ async function readInstalledSkillTargets(skillsHome: string) { } async function buildCodexSkillSnapshot(config: Record): Promise { - const availableEntries = await listPaperclipSkillEntries(__moduleDir); + const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); const availableByName = new Map(availableEntries.map((entry) => [entry.name, entry])); - const desiredSkills = resolveDesiredSkillNames( - config, - availableEntries.map((entry) => entry.name), - ); + const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries); const desiredSet = new Set(desiredSkills); const skillsHome = resolveCodexSkillsHome(config); const installed = await readInstalledSkillTargets(skillsHome); @@ -97,6 +89,8 @@ async function buildCodexSkillSnapshot(config: Record): Promise sourcePath: available.source, targetPath: path.join(skillsHome, available.name), detail, + required: Boolean(available.required), + requiredReason: available.requiredReason ?? null, }); } @@ -147,8 +141,11 @@ export async function syncCodexSkills( ctx: AdapterSkillContext, desiredSkills: string[], ): Promise { - const availableEntries = await listPaperclipSkillEntries(__moduleDir); - const desiredSet = new Set(desiredSkills); + const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir); + const desiredSet = new Set([ + ...desiredSkills, + ...availableEntries.filter((entry) => entry.required).map((entry) => entry.name), + ]); const skillsHome = resolveCodexSkillsHome(ctx.config); await fs.mkdir(skillsHome, { recursive: true }); const installed = await readInstalledSkillTargets(skillsHome); @@ -173,7 +170,7 @@ export async function syncCodexSkills( export function resolveCodexDesiredSkillNames( config: Record, - availableSkillNames: string[], + availableEntries: Array<{ name: string; required?: boolean }>, ) { - return resolveDesiredSkillNames(config, availableSkillNames); + return resolvePaperclipDesiredSkillNames(config, availableEntries); } diff --git a/packages/adapters/cursor-local/src/server/execute.ts b/packages/adapters/cursor-local/src/server/execute.ts index 52fe73f8..daca09bc 100644 --- a/packages/adapters/cursor-local/src/server/execute.ts +++ b/packages/adapters/cursor-local/src/server/execute.ts @@ -14,8 +14,8 @@ import { ensureCommandResolvable, ensurePaperclipSkillSymlink, ensurePathInEnv, - listPaperclipSkillEntries, - readPaperclipSkillSyncPreference, + readPaperclipRuntimeSkillEntries, + resolvePaperclipDesiredSkillNames, removeMaintainerOnlySkillSymlinks, renderTemplate, joinPromptSections, @@ -98,7 +98,7 @@ export async function ensureCursorSkillsInjected( ? (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)); + : await readPaperclipRuntimeSkillEntries({}, __moduleDir)); if (skillsEntries.length === 0) return; const skillsHome = options.skillsHome ?? cursorSkillsHome(); @@ -169,11 +169,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise entry.name); + const cursorSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); + const desiredCursorSkillNames = resolvePaperclipDesiredSkillNames(config, cursorSkillEntries); await ensureCursorSkillsInjected(onLog, { skillsEntries: cursorSkillEntries.filter((entry) => desiredCursorSkillNames.includes(entry.name)), }); diff --git a/packages/adapters/cursor-local/src/server/skills.ts b/packages/adapters/cursor-local/src/server/skills.ts index c9d6d945..c05d1b20 100644 --- a/packages/adapters/cursor-local/src/server/skills.ts +++ b/packages/adapters/cursor-local/src/server/skills.ts @@ -9,8 +9,8 @@ import type { } from "@paperclipai/adapter-utils"; import { ensurePaperclipSkillSymlink, - listPaperclipSkillEntries, - readPaperclipSkillSyncPreference, + readPaperclipRuntimeSkillEntries, + resolvePaperclipDesiredSkillNames, } from "@paperclipai/adapter-utils/server-utils"; const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); @@ -29,11 +29,6 @@ function resolveCursorSkillsHome(config: Record) { return path.join(home, ".cursor", "skills"); } -function resolveDesiredSkillNames(config: Record, availableSkillNames: string[]) { - const preference = readPaperclipSkillSyncPreference(config); - return preference.explicit ? preference.desiredSkills : availableSkillNames; -} - async function readInstalledSkillTargets(skillsHome: string) { const entries = await fs.readdir(skillsHome, { withFileTypes: true }).catch(() => []); const out = new Map(); @@ -57,12 +52,9 @@ async function readInstalledSkillTargets(skillsHome: string) { } async function buildCursorSkillSnapshot(config: Record): Promise { - const availableEntries = await listPaperclipSkillEntries(__moduleDir); + const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); const availableByName = new Map(availableEntries.map((entry) => [entry.name, entry])); - const desiredSkills = resolveDesiredSkillNames( - config, - availableEntries.map((entry) => entry.name), - ); + const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries); const desiredSet = new Set(desiredSkills); const skillsHome = resolveCursorSkillsHome(config); const installed = await readInstalledSkillTargets(skillsHome); @@ -97,6 +89,8 @@ async function buildCursorSkillSnapshot(config: Record): Promis sourcePath: available.source, targetPath: path.join(skillsHome, available.name), detail, + required: Boolean(available.required), + requiredReason: available.requiredReason ?? null, }); } @@ -147,8 +141,11 @@ export async function syncCursorSkills( ctx: AdapterSkillContext, desiredSkills: string[], ): Promise { - const availableEntries = await listPaperclipSkillEntries(__moduleDir); - const desiredSet = new Set(desiredSkills); + const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir); + const desiredSet = new Set([ + ...desiredSkills, + ...availableEntries.filter((entry) => entry.required).map((entry) => entry.name), + ]); const skillsHome = resolveCursorSkillsHome(ctx.config); await fs.mkdir(skillsHome, { recursive: true }); const installed = await readInstalledSkillTargets(skillsHome); @@ -173,7 +170,7 @@ export async function syncCursorSkills( export function resolveCursorDesiredSkillNames( config: Record, - availableSkillNames: string[], + availableEntries: Array<{ name: string; required?: boolean }>, ) { - return resolveDesiredSkillNames(config, availableSkillNames); + return resolvePaperclipDesiredSkillNames(config, availableEntries); } diff --git a/packages/adapters/gemini-local/src/server/execute.ts b/packages/adapters/gemini-local/src/server/execute.ts index 4816d96c..c3397878 100644 --- a/packages/adapters/gemini-local/src/server/execute.ts +++ b/packages/adapters/gemini-local/src/server/execute.ts @@ -15,8 +15,8 @@ import { ensurePaperclipSkillSymlink, joinPromptSections, ensurePathInEnv, - listPaperclipSkillEntries, - readPaperclipSkillSyncPreference, + readPaperclipRuntimeSkillEntries, + resolvePaperclipDesiredSkillNames, removeMaintainerOnlySkillSymlinks, parseObject, redactEnvForLogs, @@ -85,12 +85,12 @@ function geminiSkillsHome(): string { */ async function ensureGeminiSkillsInjected( onLog: AdapterExecutionContext["onLog"], + skillsEntries: Array<{ name: string; source: string }>, desiredSkillNames?: string[], ): Promise { - const allSkillsEntries = await listPaperclipSkillEntries(__moduleDir); - const desiredSet = new Set(desiredSkillNames ?? allSkillsEntries.map((entry) => entry.name)); - const skillsEntries = allSkillsEntries.filter((entry) => desiredSet.has(entry.name)); - if (skillsEntries.length === 0) return; + const desiredSet = new Set(desiredSkillNames ?? skillsEntries.map((entry) => entry.name)); + const selectedEntries = skillsEntries.filter((entry) => desiredSet.has(entry.name)); + if (selectedEntries.length === 0) return; const skillsHome = geminiSkillsHome(); try { @@ -104,7 +104,7 @@ async function ensureGeminiSkillsInjected( } const removedSkills = await removeMaintainerOnlySkillSymlinks( skillsHome, - skillsEntries.map((entry) => entry.name), + selectedEntries.map((entry) => entry.name), ); for (const skillName of removedSkills) { await onLog( @@ -113,7 +113,7 @@ async function ensureGeminiSkillsInjected( ); } - for (const entry of skillsEntries) { + for (const entry of selectedEntries) { const target = path.join(skillsHome, entry.name); try { @@ -160,12 +160,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise entry.name); - await ensureGeminiSkillsInjected(onLog, desiredGeminiSkillNames); + const geminiSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); + const desiredGeminiSkillNames = resolvePaperclipDesiredSkillNames(config, geminiSkillEntries); + await ensureGeminiSkillsInjected(onLog, geminiSkillEntries, desiredGeminiSkillNames); const envConfig = parseObject(config.env); const hasExplicitApiKey = diff --git a/packages/adapters/gemini-local/src/server/skills.ts b/packages/adapters/gemini-local/src/server/skills.ts index 20a202e2..a2776d83 100644 --- a/packages/adapters/gemini-local/src/server/skills.ts +++ b/packages/adapters/gemini-local/src/server/skills.ts @@ -9,8 +9,8 @@ import type { } from "@paperclipai/adapter-utils"; import { ensurePaperclipSkillSymlink, - listPaperclipSkillEntries, - readPaperclipSkillSyncPreference, + readPaperclipRuntimeSkillEntries, + resolvePaperclipDesiredSkillNames, } from "@paperclipai/adapter-utils/server-utils"; const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); @@ -29,11 +29,6 @@ function resolveGeminiSkillsHome(config: Record) { return path.join(home, ".gemini", "skills"); } -function resolveDesiredSkillNames(config: Record, availableSkillNames: string[]) { - const preference = readPaperclipSkillSyncPreference(config); - return preference.explicit ? preference.desiredSkills : availableSkillNames; -} - async function readInstalledSkillTargets(skillsHome: string) { const entries = await fs.readdir(skillsHome, { withFileTypes: true }).catch(() => []); const out = new Map(); @@ -57,12 +52,9 @@ async function readInstalledSkillTargets(skillsHome: string) { } async function buildGeminiSkillSnapshot(config: Record): Promise { - const availableEntries = await listPaperclipSkillEntries(__moduleDir); + const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); const availableByName = new Map(availableEntries.map((entry) => [entry.name, entry])); - const desiredSkills = resolveDesiredSkillNames( - config, - availableEntries.map((entry) => entry.name), - ); + const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries); const desiredSet = new Set(desiredSkills); const skillsHome = resolveGeminiSkillsHome(config); const installed = await readInstalledSkillTargets(skillsHome); @@ -97,6 +89,8 @@ async function buildGeminiSkillSnapshot(config: Record): Promis sourcePath: available.source, targetPath: path.join(skillsHome, available.name), detail, + required: Boolean(available.required), + requiredReason: available.requiredReason ?? null, }); } @@ -147,8 +141,11 @@ export async function syncGeminiSkills( ctx: AdapterSkillContext, desiredSkills: string[], ): Promise { - const availableEntries = await listPaperclipSkillEntries(__moduleDir); - const desiredSet = new Set(desiredSkills); + const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir); + const desiredSet = new Set([ + ...desiredSkills, + ...availableEntries.filter((entry) => entry.required).map((entry) => entry.name), + ]); const skillsHome = resolveGeminiSkillsHome(ctx.config); await fs.mkdir(skillsHome, { recursive: true }); const installed = await readInstalledSkillTargets(skillsHome); @@ -173,7 +170,7 @@ export async function syncGeminiSkills( export function resolveGeminiDesiredSkillNames( config: Record, - availableSkillNames: string[], + availableEntries: Array<{ name: string; required?: boolean }>, ) { - return resolveDesiredSkillNames(config, availableSkillNames); + return resolvePaperclipDesiredSkillNames(config, availableEntries); } diff --git a/packages/adapters/opencode-local/src/server/execute.ts b/packages/adapters/opencode-local/src/server/execute.ts index d514a982..90e3c45a 100644 --- a/packages/adapters/opencode-local/src/server/execute.ts +++ b/packages/adapters/opencode-local/src/server/execute.ts @@ -13,19 +13,18 @@ import { redactEnvForLogs, ensureAbsoluteDirectory, ensureCommandResolvable, + ensurePaperclipSkillSymlink, ensurePathInEnv, renderTemplate, runChildProcess, - readPaperclipSkillSyncPreference, + readPaperclipRuntimeSkillEntries, + resolvePaperclipDesiredSkillNames, } from "@paperclipai/adapter-utils/server-utils"; import { isOpenCodeUnknownSessionError, parseOpenCodeJsonl } from "./parse.js"; import { ensureOpenCodeModelConfiguredAndAvailable } from "./models.js"; +import { removeMaintainerOnlySkillSymlinks } from "@paperclipai/adapter-utils/server-utils"; 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 ( @@ -47,38 +46,34 @@ function claudeSkillsHome(): string { return path.join(os.homedir(), ".claude", "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 ensureOpenCodeSkillsInjected( onLog: AdapterExecutionContext["onLog"], + skillsEntries: Array<{ name: string; source: string }>, desiredSkillNames?: string[], ) { - const skillsDir = await resolvePaperclipSkillsDir(); - if (!skillsDir) return; - const skillsHome = claudeSkillsHome(); await fs.mkdir(skillsHome, { recursive: true }); - const entries = await fs.readdir(skillsDir, { withFileTypes: true }); - const desiredSet = new Set(desiredSkillNames ?? entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name)); - for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (!desiredSet.has(entry.name)) continue; - const source = path.join(skillsDir, entry.name); + const desiredSet = new Set(desiredSkillNames ?? skillsEntries.map((entry) => entry.name)); + const selectedEntries = skillsEntries.filter((entry) => desiredSet.has(entry.name)); + const removedSkills = await removeMaintainerOnlySkillSymlinks( + skillsHome, + selectedEntries.map((entry) => entry.name), + ); + for (const skillName of removedSkills) { + await onLog( + "stderr", + `[paperclip] Removed maintainer-only OpenCode skill "${skillName}" from ${skillsHome}\n`, + ); + } + for (const entry of selectedEntries) { 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); + if (result === "skipped") continue; await onLog( "stderr", - `[paperclip] Injected OpenCode skill "${entry.name}" into ${skillsHome}\n`, + `[paperclip] ${result === "repaired" ? "Repaired" : "Injected"} OpenCode skill "${entry.name}" into ${skillsHome}\n`, ); } catch (err) { await onLog( @@ -117,10 +112,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise) { return path.join(home, ".claude", "skills"); } -function resolveDesiredSkillNames(config: Record, availableSkillNames: string[]) { - const preference = readPaperclipSkillSyncPreference(config); - return preference.explicit ? preference.desiredSkills : availableSkillNames; -} - async function readInstalledSkillTargets(skillsHome: string) { const entries = await fs.readdir(skillsHome, { withFileTypes: true }).catch(() => []); const out = new Map(); @@ -57,12 +52,9 @@ async function readInstalledSkillTargets(skillsHome: string) { } async function buildOpenCodeSkillSnapshot(config: Record): Promise { - const availableEntries = await listPaperclipSkillEntries(__moduleDir); + const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); const availableByName = new Map(availableEntries.map((entry) => [entry.name, entry])); - const desiredSkills = resolveDesiredSkillNames( - config, - availableEntries.map((entry) => entry.name), - ); + const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries); const desiredSet = new Set(desiredSkills); const skillsHome = resolveOpenCodeSkillsHome(config); const installed = await readInstalledSkillTargets(skillsHome); @@ -100,6 +92,8 @@ async function buildOpenCodeSkillSnapshot(config: Record): Prom sourcePath: available.source, targetPath: path.join(skillsHome, available.name), detail, + required: Boolean(available.required), + requiredReason: available.requiredReason ?? null, }); } @@ -150,8 +144,11 @@ export async function syncOpenCodeSkills( ctx: AdapterSkillContext, desiredSkills: string[], ): Promise { - const availableEntries = await listPaperclipSkillEntries(__moduleDir); - const desiredSet = new Set(desiredSkills); + const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir); + const desiredSet = new Set([ + ...desiredSkills, + ...availableEntries.filter((entry) => entry.required).map((entry) => entry.name), + ]); const skillsHome = resolveOpenCodeSkillsHome(ctx.config); await fs.mkdir(skillsHome, { recursive: true }); const installed = await readInstalledSkillTargets(skillsHome); @@ -176,7 +173,7 @@ export async function syncOpenCodeSkills( export function resolveOpenCodeDesiredSkillNames( config: Record, - availableSkillNames: string[], + availableEntries: Array<{ name: string; required?: boolean }>, ) { - return resolveDesiredSkillNames(config, availableSkillNames); + return resolvePaperclipDesiredSkillNames(config, availableEntries); } diff --git a/packages/adapters/pi-local/src/server/execute.ts b/packages/adapters/pi-local/src/server/execute.ts index 552d35d6..efc81222 100644 --- a/packages/adapters/pi-local/src/server/execute.ts +++ b/packages/adapters/pi-local/src/server/execute.ts @@ -15,8 +15,8 @@ import { ensureCommandResolvable, ensurePaperclipSkillSymlink, ensurePathInEnv, - listPaperclipSkillEntries, - readPaperclipSkillSyncPreference, + readPaperclipRuntimeSkillEntries, + resolvePaperclipDesiredSkillNames, removeMaintainerOnlySkillSymlinks, renderTemplate, runChildProcess, @@ -53,18 +53,18 @@ function parseModelId(model: string | null): string | null { async function ensurePiSkillsInjected( onLog: AdapterExecutionContext["onLog"], + skillsEntries: Array<{ name: string; source: string }>, desiredSkillNames?: string[], ) { - const allSkillsEntries = await listPaperclipSkillEntries(__moduleDir); - const desiredSet = new Set(desiredSkillNames ?? allSkillsEntries.map((entry) => entry.name)); - const skillsEntries = allSkillsEntries.filter((entry) => desiredSet.has(entry.name)); - if (skillsEntries.length === 0) return; + const desiredSet = new Set(desiredSkillNames ?? skillsEntries.map((entry) => entry.name)); + const selectedEntries = skillsEntries.filter((entry) => desiredSet.has(entry.name)); + if (selectedEntries.length === 0) return; const piSkillsHome = path.join(os.homedir(), ".pi", "agent", "skills"); await fs.mkdir(piSkillsHome, { recursive: true }); const removedSkills = await removeMaintainerOnlySkillSymlinks( piSkillsHome, - skillsEntries.map((entry) => entry.name), + selectedEntries.map((entry) => entry.name), ); for (const skillName of removedSkills) { await onLog( @@ -73,7 +73,7 @@ async function ensurePiSkillsInjected( ); } - for (const entry of skillsEntries) { + for (const entry of selectedEntries) { const target = path.join(piSkillsHome, entry.name); try { @@ -139,12 +139,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise entry.name); - await ensurePiSkillsInjected(onLog, desiredPiSkillNames); + const piSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); + const desiredPiSkillNames = resolvePaperclipDesiredSkillNames(config, piSkillEntries); + await ensurePiSkillsInjected(onLog, piSkillEntries, desiredPiSkillNames); // Build environment const envConfig = parseObject(config.env); diff --git a/packages/adapters/pi-local/src/server/skills.ts b/packages/adapters/pi-local/src/server/skills.ts index eff2753f..b0cb0fe3 100644 --- a/packages/adapters/pi-local/src/server/skills.ts +++ b/packages/adapters/pi-local/src/server/skills.ts @@ -9,8 +9,8 @@ import type { } from "@paperclipai/adapter-utils"; import { ensurePaperclipSkillSymlink, - listPaperclipSkillEntries, - readPaperclipSkillSyncPreference, + readPaperclipRuntimeSkillEntries, + resolvePaperclipDesiredSkillNames, } from "@paperclipai/adapter-utils/server-utils"; const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); @@ -29,11 +29,6 @@ function resolvePiSkillsHome(config: Record) { return path.join(home, ".pi", "agent", "skills"); } -function resolveDesiredSkillNames(config: Record, availableSkillNames: string[]) { - const preference = readPaperclipSkillSyncPreference(config); - return preference.explicit ? preference.desiredSkills : availableSkillNames; -} - async function readInstalledSkillTargets(skillsHome: string) { const entries = await fs.readdir(skillsHome, { withFileTypes: true }).catch(() => []); const out = new Map(); @@ -57,12 +52,9 @@ async function readInstalledSkillTargets(skillsHome: string) { } async function buildPiSkillSnapshot(config: Record): Promise { - const availableEntries = await listPaperclipSkillEntries(__moduleDir); + const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); const availableByName = new Map(availableEntries.map((entry) => [entry.name, entry])); - const desiredSkills = resolveDesiredSkillNames( - config, - availableEntries.map((entry) => entry.name), - ); + const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries); const desiredSet = new Set(desiredSkills); const skillsHome = resolvePiSkillsHome(config); const installed = await readInstalledSkillTargets(skillsHome); @@ -97,6 +89,8 @@ async function buildPiSkillSnapshot(config: Record): Promise { - const availableEntries = await listPaperclipSkillEntries(__moduleDir); - const desiredSet = new Set(desiredSkills); + const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir); + const desiredSet = new Set([ + ...desiredSkills, + ...availableEntries.filter((entry) => entry.required).map((entry) => entry.name), + ]); const skillsHome = resolvePiSkillsHome(ctx.config); await fs.mkdir(skillsHome, { recursive: true }); const installed = await readInstalledSkillTargets(skillsHome); @@ -173,7 +170,7 @@ export async function syncPiSkills( export function resolvePiDesiredSkillNames( config: Record, - availableSkillNames: string[], + availableEntries: Array<{ name: string; required?: boolean }>, ) { - return resolveDesiredSkillNames(config, availableSkillNames); + return resolvePaperclipDesiredSkillNames(config, availableEntries); } diff --git a/packages/shared/src/types/adapter-skills.ts b/packages/shared/src/types/adapter-skills.ts index 750ebaa4..c79483b5 100644 --- a/packages/shared/src/types/adapter-skills.ts +++ b/packages/shared/src/types/adapter-skills.ts @@ -12,6 +12,8 @@ export interface AgentSkillEntry { name: string; desired: boolean; managed: boolean; + required?: boolean; + requiredReason?: string | null; state: AgentSkillState; sourcePath?: string | null; targetPath?: string | null; diff --git a/packages/shared/src/validators/adapter-skills.ts b/packages/shared/src/validators/adapter-skills.ts index 07a71de5..212043ab 100644 --- a/packages/shared/src/validators/adapter-skills.ts +++ b/packages/shared/src/validators/adapter-skills.ts @@ -19,6 +19,8 @@ export const agentSkillEntrySchema = z.object({ name: z.string().min(1), desired: z.boolean(), managed: z.boolean(), + required: z.boolean().optional(), + requiredReason: z.string().nullable().optional(), state: agentSkillStateSchema, sourcePath: z.string().nullable().optional(), targetPath: z.string().nullable().optional(), diff --git a/server/src/__tests__/claude-local-skill-sync.test.ts b/server/src/__tests__/claude-local-skill-sync.test.ts index 5a6f13e2..4bc58f7d 100644 --- a/server/src/__tests__/claude-local-skill-sync.test.ts +++ b/server/src/__tests__/claude-local-skill-sync.test.ts @@ -16,6 +16,7 @@ describe("claude local skill sync", () => { expect(snapshot.mode).toBe("ephemeral"); expect(snapshot.supported).toBe(true); expect(snapshot.desiredSkills).toContain("paperclip"); + expect(snapshot.entries.find((entry) => entry.name === "paperclip")?.required).toBe(true); expect(snapshot.entries.find((entry) => entry.name === "paperclip")?.state).toBe("configured"); }); @@ -31,8 +32,8 @@ describe("claude local skill sync", () => { }, }, ["paperclip"]); - expect(snapshot.desiredSkills).toEqual(["paperclip"]); + expect(snapshot.desiredSkills).toContain("paperclip"); expect(snapshot.entries.find((entry) => entry.name === "paperclip")?.state).toBe("configured"); - expect(snapshot.entries.find((entry) => entry.name === "paperclip-create-agent")?.state).toBe("available"); + expect(snapshot.entries.find((entry) => entry.name === "paperclip-create-agent")?.state).toBe("configured"); }); }); diff --git a/server/src/__tests__/codex-local-skill-sync.test.ts b/server/src/__tests__/codex-local-skill-sync.test.ts index 79c1d895..13be991e 100644 --- a/server/src/__tests__/codex-local-skill-sync.test.ts +++ b/server/src/__tests__/codex-local-skill-sync.test.ts @@ -39,7 +39,8 @@ describe("codex local skill sync", () => { const before = await listCodexSkills(ctx); expect(before.mode).toBe("persistent"); - expect(before.desiredSkills).toEqual(["paperclip"]); + expect(before.desiredSkills).toContain("paperclip"); + expect(before.entries.find((entry) => entry.name === "paperclip")?.required).toBe(true); expect(before.entries.find((entry) => entry.name === "paperclip")?.state).toBe("missing"); const after = await syncCodexSkills(ctx, ["paperclip"]); @@ -47,7 +48,7 @@ describe("codex local skill sync", () => { expect((await fs.lstat(path.join(codexHome, "skills", "paperclip"))).isSymbolicLink()).toBe(true); }); - it("removes stale managed Paperclip skills when the desired set is emptied", async () => { + it("keeps required bundled Paperclip skills installed even when the desired set is emptied", async () => { const codexHome = await makeTempDir("paperclip-codex-skill-prune-"); cleanupDirs.add(codexHome); @@ -80,8 +81,8 @@ describe("codex local skill sync", () => { } as const; const after = await syncCodexSkills(clearedCtx, []); - expect(after.desiredSkills).toEqual([]); - expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("available"); - await expect(fs.lstat(path.join(codexHome, "skills", "paperclip"))).rejects.toThrow(); + expect(after.desiredSkills).toContain("paperclip"); + expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("installed"); + expect((await fs.lstat(path.join(codexHome, "skills", "paperclip"))).isSymbolicLink()).toBe(true); }); }); diff --git a/server/src/__tests__/cursor-local-execute.test.ts b/server/src/__tests__/cursor-local-execute.test.ts index 937315d0..97839897 100644 --- a/server/src/__tests__/cursor-local-execute.test.ts +++ b/server/src/__tests__/cursor-local-execute.test.ts @@ -46,6 +46,13 @@ type CapturePayload = { paperclipEnvKeys: string[]; }; +async function createSkillDir(root: string, name: string) { + const skillDir = path.join(root, name); + await fs.mkdir(skillDir, { recursive: true }); + await fs.writeFile(path.join(skillDir, "SKILL.md"), `---\nname: ${name}\n---\n`, "utf8"); + return skillDir; +} + describe("cursor execute", () => { it("injects paperclip env vars and prompt note by default", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-cursor-execute-")); @@ -179,4 +186,77 @@ describe("cursor execute", () => { await fs.rm(root, { recursive: true, force: true }); } }); + + it("injects company-library runtime skills into the Cursor skills home before execution", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-cursor-execute-runtime-skill-")); + const workspace = path.join(root, "workspace"); + const commandPath = path.join(root, "agent"); + const runtimeSkillsRoot = path.join(root, "runtime-skills"); + await fs.mkdir(workspace, { recursive: true }); + await writeFakeCursorCommand(commandPath); + + const paperclipDir = await createSkillDir(runtimeSkillsRoot, "paperclip"); + const asciiHeartDir = await createSkillDir(runtimeSkillsRoot, "ascii-heart"); + + const previousHome = process.env.HOME; + process.env.HOME = root; + + try { + const result = await execute({ + runId: "run-3", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Cursor Coder", + adapterType: "cursor", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + command: commandPath, + cwd: workspace, + model: "auto", + paperclipRuntimeSkills: [ + { + name: "paperclip", + source: paperclipDir, + required: true, + requiredReason: "Bundled Paperclip skills are always available for local adapters.", + }, + { + name: "ascii-heart", + source: asciiHeartDir, + }, + ], + paperclipSkillSync: { + desiredSkills: ["ascii-heart"], + }, + promptTemplate: "Follow the paperclip heartbeat.", + }, + context: {}, + authToken: "run-jwt-token", + onLog: async () => {}, + onMeta: async () => {}, + }); + + expect(result.exitCode).toBe(0); + expect(result.errorMessage).toBeNull(); + expect((await fs.lstat(path.join(root, ".cursor", "skills", "ascii-heart"))).isSymbolicLink()).toBe(true); + expect(await fs.realpath(path.join(root, ".cursor", "skills", "ascii-heart"))).toBe( + await fs.realpath(asciiHeartDir), + ); + } finally { + if (previousHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = previousHome; + } + await fs.rm(root, { recursive: true, force: true }); + } + }); }); diff --git a/server/src/__tests__/cursor-local-skill-sync.test.ts b/server/src/__tests__/cursor-local-skill-sync.test.ts index 18483d32..d26c99e4 100644 --- a/server/src/__tests__/cursor-local-skill-sync.test.ts +++ b/server/src/__tests__/cursor-local-skill-sync.test.ts @@ -11,6 +11,13 @@ async function makeTempDir(prefix: string): Promise { return fs.mkdtemp(path.join(os.tmpdir(), prefix)); } +async function createSkillDir(root: string, name: string) { + const skillDir = path.join(root, name); + await fs.mkdir(skillDir, { recursive: true }); + await fs.writeFile(path.join(skillDir, "SKILL.md"), `---\nname: ${name}\n---\n`, "utf8"); + return skillDir; +} + describe("cursor local skill sync", () => { const cleanupDirs = new Set(); @@ -39,7 +46,8 @@ describe("cursor local skill sync", () => { const before = await listCursorSkills(ctx); expect(before.mode).toBe("persistent"); - expect(before.desiredSkills).toEqual(["paperclip"]); + expect(before.desiredSkills).toContain("paperclip"); + expect(before.entries.find((entry) => entry.name === "paperclip")?.required).toBe(true); expect(before.entries.find((entry) => entry.name === "paperclip")?.state).toBe("missing"); const after = await syncCursorSkills(ctx, ["paperclip"]); @@ -47,7 +55,53 @@ describe("cursor local skill sync", () => { expect((await fs.lstat(path.join(home, ".cursor", "skills", "paperclip"))).isSymbolicLink()).toBe(true); }); - it("removes stale managed Paperclip skills when the desired set is emptied", async () => { + it("recognizes company-library runtime skills supplied outside the bundled Paperclip directory", async () => { + const home = await makeTempDir("paperclip-cursor-runtime-skills-home-"); + const runtimeSkills = await makeTempDir("paperclip-cursor-runtime-skills-src-"); + cleanupDirs.add(home); + cleanupDirs.add(runtimeSkills); + + const paperclipDir = await createSkillDir(runtimeSkills, "paperclip"); + const asciiHeartDir = await createSkillDir(runtimeSkills, "ascii-heart"); + + const ctx = { + agentId: "agent-3", + companyId: "company-1", + adapterType: "cursor", + config: { + env: { + HOME: home, + }, + paperclipRuntimeSkills: [ + { + name: "paperclip", + source: paperclipDir, + required: true, + requiredReason: "Bundled Paperclip skills are always available for local adapters.", + }, + { + name: "ascii-heart", + source: asciiHeartDir, + }, + ], + paperclipSkillSync: { + desiredSkills: ["ascii-heart"], + }, + }, + } as const; + + const before = await listCursorSkills(ctx); + expect(before.warnings).toEqual([]); + expect(before.desiredSkills).toEqual(["paperclip", "ascii-heart"]); + expect(before.entries.find((entry) => entry.name === "ascii-heart")?.state).toBe("missing"); + + const after = await syncCursorSkills(ctx, ["ascii-heart"]); + expect(after.warnings).toEqual([]); + expect(after.entries.find((entry) => entry.name === "ascii-heart")?.state).toBe("installed"); + expect((await fs.lstat(path.join(home, ".cursor", "skills", "ascii-heart"))).isSymbolicLink()).toBe(true); + }); + + it("keeps required bundled Paperclip skills installed even when the desired set is emptied", async () => { const home = await makeTempDir("paperclip-cursor-skill-prune-"); cleanupDirs.add(home); @@ -80,8 +134,8 @@ describe("cursor local skill sync", () => { } as const; const after = await syncCursorSkills(clearedCtx, []); - expect(after.desiredSkills).toEqual([]); - expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("available"); - await expect(fs.lstat(path.join(home, ".cursor", "skills", "paperclip"))).rejects.toThrow(); + expect(after.desiredSkills).toContain("paperclip"); + expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("installed"); + expect((await fs.lstat(path.join(home, ".cursor", "skills", "paperclip"))).isSymbolicLink()).toBe(true); }); }); diff --git a/server/src/__tests__/gemini-local-skill-sync.test.ts b/server/src/__tests__/gemini-local-skill-sync.test.ts index 75f933b5..8619973b 100644 --- a/server/src/__tests__/gemini-local-skill-sync.test.ts +++ b/server/src/__tests__/gemini-local-skill-sync.test.ts @@ -39,7 +39,8 @@ describe("gemini local skill sync", () => { const before = await listGeminiSkills(ctx); expect(before.mode).toBe("persistent"); - expect(before.desiredSkills).toEqual(["paperclip"]); + expect(before.desiredSkills).toContain("paperclip"); + expect(before.entries.find((entry) => entry.name === "paperclip")?.required).toBe(true); expect(before.entries.find((entry) => entry.name === "paperclip")?.state).toBe("missing"); const after = await syncGeminiSkills(ctx, ["paperclip"]); @@ -47,7 +48,7 @@ describe("gemini local skill sync", () => { expect((await fs.lstat(path.join(home, ".gemini", "skills", "paperclip"))).isSymbolicLink()).toBe(true); }); - it("removes stale managed Paperclip skills when the desired set is emptied", async () => { + it("keeps required bundled Paperclip skills installed even when the desired set is emptied", async () => { const home = await makeTempDir("paperclip-gemini-skill-prune-"); cleanupDirs.add(home); @@ -80,8 +81,8 @@ describe("gemini local skill sync", () => { } as const; const after = await syncGeminiSkills(clearedCtx, []); - expect(after.desiredSkills).toEqual([]); - expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("available"); - await expect(fs.lstat(path.join(home, ".gemini", "skills", "paperclip"))).rejects.toThrow(); + expect(after.desiredSkills).toContain("paperclip"); + expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("installed"); + expect((await fs.lstat(path.join(home, ".gemini", "skills", "paperclip"))).isSymbolicLink()).toBe(true); }); }); diff --git a/server/src/__tests__/opencode-local-skill-sync.test.ts b/server/src/__tests__/opencode-local-skill-sync.test.ts index 38f2f941..bf113064 100644 --- a/server/src/__tests__/opencode-local-skill-sync.test.ts +++ b/server/src/__tests__/opencode-local-skill-sync.test.ts @@ -40,7 +40,8 @@ describe("opencode local skill sync", () => { const before = await listOpenCodeSkills(ctx); expect(before.mode).toBe("persistent"); expect(before.warnings).toContain("OpenCode currently uses the shared Claude skills home (~/.claude/skills)."); - expect(before.desiredSkills).toEqual(["paperclip"]); + expect(before.desiredSkills).toContain("paperclip"); + expect(before.entries.find((entry) => entry.name === "paperclip")?.required).toBe(true); expect(before.entries.find((entry) => entry.name === "paperclip")?.state).toBe("missing"); const after = await syncOpenCodeSkills(ctx, ["paperclip"]); @@ -48,7 +49,7 @@ describe("opencode local skill sync", () => { expect((await fs.lstat(path.join(home, ".claude", "skills", "paperclip"))).isSymbolicLink()).toBe(true); }); - it("removes stale managed Paperclip skills when the desired set is emptied", async () => { + it("keeps required bundled Paperclip skills installed even when the desired set is emptied", async () => { const home = await makeTempDir("paperclip-opencode-skill-prune-"); cleanupDirs.add(home); @@ -81,8 +82,8 @@ describe("opencode local skill sync", () => { } as const; const after = await syncOpenCodeSkills(clearedCtx, []); - expect(after.desiredSkills).toEqual([]); - expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("available"); - await expect(fs.lstat(path.join(home, ".claude", "skills", "paperclip"))).rejects.toThrow(); + expect(after.desiredSkills).toContain("paperclip"); + expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("installed"); + expect((await fs.lstat(path.join(home, ".claude", "skills", "paperclip"))).isSymbolicLink()).toBe(true); }); }); diff --git a/server/src/__tests__/pi-local-skill-sync.test.ts b/server/src/__tests__/pi-local-skill-sync.test.ts index d2a00eda..77790c01 100644 --- a/server/src/__tests__/pi-local-skill-sync.test.ts +++ b/server/src/__tests__/pi-local-skill-sync.test.ts @@ -39,7 +39,8 @@ describe("pi local skill sync", () => { const before = await listPiSkills(ctx); expect(before.mode).toBe("persistent"); - expect(before.desiredSkills).toEqual(["paperclip"]); + expect(before.desiredSkills).toContain("paperclip"); + expect(before.entries.find((entry) => entry.name === "paperclip")?.required).toBe(true); expect(before.entries.find((entry) => entry.name === "paperclip")?.state).toBe("missing"); const after = await syncPiSkills(ctx, ["paperclip"]); @@ -47,7 +48,7 @@ describe("pi local skill sync", () => { expect((await fs.lstat(path.join(home, ".pi", "agent", "skills", "paperclip"))).isSymbolicLink()).toBe(true); }); - it("removes stale managed Paperclip skills when the desired set is emptied", async () => { + it("keeps required bundled Paperclip skills installed even when the desired set is emptied", async () => { const home = await makeTempDir("paperclip-pi-skill-prune-"); cleanupDirs.add(home); @@ -80,8 +81,8 @@ describe("pi local skill sync", () => { } as const; const after = await syncPiSkills(clearedCtx, []); - expect(after.desiredSkills).toEqual([]); - expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("available"); - await expect(fs.lstat(path.join(home, ".pi", "agent", "skills", "paperclip"))).rejects.toThrow(); + expect(after.desiredSkills).toContain("paperclip"); + expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("installed"); + expect((await fs.lstat(path.join(home, ".pi", "agent", "skills", "paperclip"))).isSymbolicLink()).toBe(true); }); }); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 9e429df6..a64ca042 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -29,6 +29,7 @@ import { agentService, accessService, approvalService, + companySkillService, heartbeatService, issueApprovalService, issueService, @@ -66,6 +67,7 @@ export function agentRoutes(db: Db) { const heartbeat = heartbeatService(db); const issueApprovalsSvc = issueApprovalService(db); const secretsSvc = secretService(db); + const companySkills = companySkillService(db); const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true"; function canCreateAgents(agent: { role: string; permissions: Record | null | undefined }) { @@ -354,6 +356,14 @@ export function agentRoutes(db: Db) { }; } + async function buildRuntimeSkillConfig(companyId: string, config: Record) { + const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(companyId); + return { + ...config, + paperclipRuntimeSkills: runtimeSkillEntries, + }; + } + function redactForRestrictedAgentView(agent: Awaited>) { if (!agent) return null; return { @@ -493,7 +503,9 @@ export function agentRoutes(db: Db) { const preference = readPaperclipSkillSyncPreference( agent.adapterConfig as Record, ); - res.json(buildUnsupportedSkillSnapshot(agent.adapterType, preference.desiredSkills)); + const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId); + const requiredSkills = runtimeSkillEntries.filter((entry) => entry.required).map((entry) => entry.name); + res.json(buildUnsupportedSkillSnapshot(agent.adapterType, Array.from(new Set([...requiredSkills, ...preference.desiredSkills])))); return; } @@ -501,11 +513,12 @@ export function agentRoutes(db: Db) { agent.companyId, agent.adapterConfig, ); + const runtimeSkillConfig = await buildRuntimeSkillConfig(agent.companyId, runtimeConfig); const snapshot = await adapter.listSkills({ agentId: agent.id, companyId: agent.companyId, adapterType: agent.adapterType, - config: runtimeConfig, + config: runtimeSkillConfig, }); res.json(snapshot); }); @@ -522,13 +535,16 @@ export function agentRoutes(db: Db) { } await assertCanUpdateAgent(req, agent); - const desiredSkills = Array.from( + const requestedSkills = Array.from( new Set( (req.body.desiredSkills as string[]) .map((value) => value.trim()) .filter(Boolean), ), ); + const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId); + const requiredSkills = runtimeSkillEntries.filter((entry) => entry.required).map((entry) => entry.name); + const desiredSkills = Array.from(new Set([...requiredSkills, ...requestedSkills])); const nextAdapterConfig = writePaperclipSkillSyncPreference( agent.adapterConfig as Record, desiredSkills, @@ -553,19 +569,23 @@ export function agentRoutes(db: Db) { updated.companyId, updated.adapterConfig, ); + const runtimeSkillConfig = { + ...runtimeConfig, + paperclipRuntimeSkills: runtimeSkillEntries, + }; const snapshot = adapter?.syncSkills ? await adapter.syncSkills({ agentId: updated.id, companyId: updated.companyId, adapterType: updated.adapterType, - config: runtimeConfig, + config: runtimeSkillConfig, }, desiredSkills) : adapter?.listSkills ? await adapter.listSkills({ agentId: updated.id, companyId: updated.companyId, adapterType: updated.adapterType, - config: runtimeConfig, + config: runtimeSkillConfig, }) : buildUnsupportedSkillSnapshot(updated.adapterType, desiredSkills); diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index 90895d7a..1ba24db7 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -4,6 +4,8 @@ import { fileURLToPath } from "node:url"; import { and, asc, eq } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { companySkills } from "@paperclipai/db"; +import { readPaperclipSkillSyncPreference } from "@paperclipai/adapter-utils/server-utils"; +import type { PaperclipSkillEntry } from "@paperclipai/adapter-utils/server-utils"; import type { CompanySkill, CompanySkillCreateRequest, @@ -20,7 +22,6 @@ import type { CompanySkillUsageAgent, } from "@paperclipai/shared"; import { normalizeAgentUrlKey } from "@paperclipai/shared"; -import { readPaperclipSkillSyncPreference } from "@paperclipai/adapter-utils/server-utils"; import { findServerAdapter } from "../adapters/index.js"; import { resolvePaperclipInstanceRoot } from "../home-paths.js"; import { notFound, unprocessable } from "../errors.js"; @@ -959,11 +960,15 @@ export function companySkillService(db: Db) { agent.companyId, agent.adapterConfig as Record, ); + const runtimeSkillEntries = await listRuntimeSkillEntries(agent.companyId); const snapshot = await adapter.listSkills({ agentId: agent.id, companyId: agent.companyId, adapterType: agent.adapterType, - config: runtimeConfig, + config: { + ...runtimeConfig, + paperclipRuntimeSkills: runtimeSkillEntries, + }, }); actualState = snapshot.entries.find((entry) => entry.name === slug)?.state ?? (snapshot.supported ? "missing" : "unsupported"); @@ -1219,6 +1224,56 @@ export function companySkillService(db: Db) { return skillDir; } + async function materializeRuntimeSkillFiles(companyId: string, skill: CompanySkill) { + const runtimeRoot = path.resolve(resolveManagedSkillsRoot(companyId), "__runtime__"); + const skillDir = path.resolve(runtimeRoot, skill.slug); + await fs.rm(skillDir, { recursive: true, force: true }); + await fs.mkdir(skillDir, { recursive: true }); + + for (const entry of skill.fileInventory) { + const detail = await readFile(companyId, skill.id, entry.path).catch(() => null); + if (!detail) continue; + const targetPath = path.resolve(skillDir, entry.path); + await fs.mkdir(path.dirname(targetPath), { recursive: true }); + await fs.writeFile(targetPath, detail.content, "utf8"); + } + + return skillDir; + } + + async function listRuntimeSkillEntries(companyId: string): Promise { + await ensureBundledSkills(companyId); + const rows = await db + .select() + .from(companySkills) + .where(eq(companySkills.companyId, companyId)) + .orderBy(asc(companySkills.name), asc(companySkills.slug)); + + const out: PaperclipSkillEntry[] = []; + for (const row of rows) { + const skill = toCompanySkill(row); + const sourceKind = asString(getSkillMeta(skill).sourceKind); + let source = normalizeSkillDirectory(skill); + if (!source) { + source = await materializeRuntimeSkillFiles(companyId, skill).catch(() => null); + } + if (!source) continue; + + const required = sourceKind === "paperclip_bundled"; + out.push({ + name: skill.slug, + source, + required, + requiredReason: required + ? "Bundled Paperclip skills are always available for local adapters." + : null, + }); + } + + out.sort((left, right) => left.name.localeCompare(right.name)); + return out; + } + async function importPackageFiles(companyId: string, files: Record): Promise { await ensureBundledSkills(companyId); const normalizedFiles = normalizePackageFileMap(files); @@ -1330,5 +1385,6 @@ export function companySkillService(db: Db) { importFromSource, importPackageFiles, installUpdate, + listRuntimeSkillEntries, }; } diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index e924359c..3f751e7a 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -22,6 +22,7 @@ import type { AdapterExecutionResult, AdapterInvocationMeta, AdapterSessionCodec import { createLocalAgentJwt } from "../agent-auth-jwt.js"; import { parseObject, asBoolean, asNumber, appendWithCap, MAX_EXCERPT_BYTES } from "../adapters/utils.js"; import { costService } from "./costs.js"; +import { companySkillService } from "./company-skills.js"; import { secretService } from "./secrets.js"; import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js"; import { summarizeHeartbeatRunResultJson } from "./heartbeat-run-summary.js"; @@ -555,6 +556,7 @@ function resolveNextSessionState(input: { export function heartbeatService(db: Db) { const runLogStore = getRunLogStore(); const secretsSvc = secretService(db); + const companySkills = companySkillService(db); const issuesSvc = issueService(db); const activeRunExecutions = new Set(); @@ -1463,6 +1465,11 @@ export function heartbeatService(db: Db) { agent.companyId, mergedConfig, ); + const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId); + const runtimeConfig = { + ...resolvedConfig, + paperclipRuntimeSkills: runtimeSkillEntries, + }; const issueRef = issueId ? await db .select({ @@ -1761,7 +1768,7 @@ export function heartbeatService(db: Db) { runId: run.id, agent, runtime: runtimeForAdapter, - config: resolvedConfig, + config: runtimeConfig, context, onLog, onMeta: onAdapterMeta, diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index d34eaead..ec94ea40 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -30,6 +30,7 @@ import { formatCents, formatDate, relativeTime, formatTokens } from "../lib/util import { cn } from "../lib/utils"; import { Button } from "@/components/ui/button"; import { Tabs } from "@/components/ui/tabs"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Popover, PopoverContent, @@ -1225,12 +1226,19 @@ function AgentSkillsTab({ return "Unknown"; } }, [skillSnapshot?.mode]); + const unsupportedSkillMessage = useMemo(() => { + if (skillSnapshot?.mode !== "unsupported") return null; + if (agent.adapterType === "openclaw_gateway") { + return "Paperclip cannot manage OpenClaw skills here. Visit your OpenClaw instance to manage this agent's skills."; + } + return "Paperclip cannot manage skills for this adapter yet. Manage them in the adapter directly."; + }, [agent.adapterType, skillSnapshot?.mode]); const hasUnsavedChanges = !arraysEqual(skillDraft, lastSavedSkills); const saveStatusLabel = syncSkills.isPending ? "Saving changes..." : hasUnsavedChanges ? "Saving soon..." - : "Changes save automatically"; + : null; return (
@@ -1239,12 +1247,14 @@ function AgentSkillsTab({ to="/skills" className="text-sm font-medium text-foreground underline-offset-4 no-underline transition-colors hover:text-foreground/70 hover:underline" > - View company library + View company skills library -
- {syncSkills.isPending ? : null} - {saveStatusLabel} -
+ {saveStatusLabel ? ( +
+ {syncSkills.isPending ? : null} + {saveStatusLabel} +
+ ) : null}
{skillSnapshot?.warnings.length ? ( @@ -1255,6 +1265,12 @@ function AgentSkillsTab({ ) : null} + {unsupportedSkillMessage ? ( +
+ {unsupportedSkillMessage} +
+ ) : null} + {isLoading ? ( ) : ( @@ -1268,22 +1284,46 @@ function AgentSkillsTab({ (companySkills ?? []).map((skill) => { const checked = skillDraft.includes(skill.slug); const adapterEntry = adapterEntryByName.get(skill.slug); + const required = Boolean(adapterEntry?.required); + const disabled = required || skillSnapshot?.mode === "unsupported"; + const checkbox = ( + { + const next = event.target.checked + ? Array.from(new Set([...skillDraft, skill.slug])) + : skillDraft.filter((value) => value !== skill.slug); + setSkillDraft(next); + }} + className="mt-0.5 disabled:cursor-not-allowed disabled:opacity-60" + /> + ); return (