diff --git a/packages/adapters/cursor-local/src/server/execute.ts b/packages/adapters/cursor-local/src/server/execute.ts index 9caf606b..22f995a7 100644 --- a/packages/adapters/cursor-local/src/server/execute.ts +++ b/packages/adapters/cursor-local/src/server/execute.ts @@ -15,6 +15,7 @@ import { ensurePaperclipSkillSymlink, ensurePathInEnv, listPaperclipSkillEntries, + readPaperclipSkillSyncPreference, removeMaintainerOnlySkillSymlinks, renderTemplate, joinPromptSections, @@ -167,7 +168,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise entry.name); + await ensureCursorSkillsInjected(onLog, { + skillsEntries: cursorSkillEntries.filter((entry) => desiredCursorSkillNames.includes(entry.name)), + }); const envConfig = parseObject(config.env); const hasExplicitApiKey = diff --git a/packages/adapters/cursor-local/src/server/index.ts b/packages/adapters/cursor-local/src/server/index.ts index 4e585b17..5605b0b7 100644 --- a/packages/adapters/cursor-local/src/server/index.ts +++ b/packages/adapters/cursor-local/src/server/index.ts @@ -1,4 +1,5 @@ export { execute, ensureCursorSkillsInjected } from "./execute.js"; +export { listCursorSkills, syncCursorSkills } from "./skills.js"; export { testEnvironment } from "./test.js"; export { parseCursorJsonl, isCursorUnknownSessionError } from "./parse.js"; import type { AdapterSessionCodec } from "@paperclipai/adapter-utils"; diff --git a/packages/adapters/cursor-local/src/server/skills.ts b/packages/adapters/cursor-local/src/server/skills.ts new file mode 100644 index 00000000..c9d6d945 --- /dev/null +++ b/packages/adapters/cursor-local/src/server/skills.ts @@ -0,0 +1,179 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { + AdapterSkillContext, + AdapterSkillEntry, + AdapterSkillSnapshot, +} from "@paperclipai/adapter-utils"; +import { + ensurePaperclipSkillSymlink, + listPaperclipSkillEntries, + readPaperclipSkillSyncPreference, +} from "@paperclipai/adapter-utils/server-utils"; + +const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); + +function asString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function resolveCursorSkillsHome(config: Record) { + const env = + typeof config.env === "object" && config.env !== null && !Array.isArray(config.env) + ? (config.env as Record) + : {}; + const configuredHome = asString(env.HOME); + const home = configuredHome ? path.resolve(configuredHome) : os.homedir(); + 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(); + for (const entry of entries) { + const fullPath = path.join(skillsHome, entry.name); + if (entry.isSymbolicLink()) { + const linkedPath = await fs.readlink(fullPath).catch(() => null); + out.set(entry.name, { + targetPath: linkedPath ? path.resolve(path.dirname(fullPath), linkedPath) : null, + kind: "symlink", + }); + continue; + } + if (entry.isDirectory()) { + out.set(entry.name, { targetPath: fullPath, kind: "directory" }); + continue; + } + out.set(entry.name, { targetPath: fullPath, kind: "file" }); + } + return out; +} + +async function buildCursorSkillSnapshot(config: Record): Promise { + const availableEntries = await listPaperclipSkillEntries(__moduleDir); + const availableByName = new Map(availableEntries.map((entry) => [entry.name, entry])); + const desiredSkills = resolveDesiredSkillNames( + config, + availableEntries.map((entry) => entry.name), + ); + const desiredSet = new Set(desiredSkills); + const skillsHome = resolveCursorSkillsHome(config); + const installed = await readInstalledSkillTargets(skillsHome); + const entries: AdapterSkillEntry[] = []; + const warnings: string[] = []; + + for (const available of availableEntries) { + const installedEntry = installed.get(available.name) ?? null; + const desired = desiredSet.has(available.name); + let state: AdapterSkillEntry["state"] = "available"; + let managed = false; + let detail: string | null = null; + + if (installedEntry?.targetPath === available.source) { + managed = true; + state = desired ? "installed" : "stale"; + } else if (installedEntry) { + state = "external"; + detail = desired + ? "Skill name is occupied by an external installation." + : "Installed outside Paperclip management."; + } else if (desired) { + state = "missing"; + detail = "Configured but not currently linked into the Cursor skills home."; + } + + entries.push({ + name: available.name, + desired, + managed, + state, + sourcePath: available.source, + targetPath: path.join(skillsHome, available.name), + detail, + }); + } + + for (const desiredSkill of desiredSkills) { + if (availableByName.has(desiredSkill)) continue; + warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`); + entries.push({ + name: desiredSkill, + desired: true, + managed: true, + state: "missing", + sourcePath: null, + targetPath: path.join(skillsHome, desiredSkill), + detail: "Paperclip cannot find this skill in the local runtime skills directory.", + }); + } + + for (const [name, installedEntry] of installed.entries()) { + if (availableByName.has(name)) continue; + entries.push({ + name, + desired: false, + managed: false, + state: "external", + sourcePath: null, + targetPath: installedEntry.targetPath ?? path.join(skillsHome, name), + detail: "Installed outside Paperclip management.", + }); + } + + entries.sort((left, right) => left.name.localeCompare(right.name)); + + return { + adapterType: "cursor", + supported: true, + mode: "persistent", + desiredSkills, + entries, + warnings, + }; +} + +export async function listCursorSkills(ctx: AdapterSkillContext): Promise { + return buildCursorSkillSnapshot(ctx.config); +} + +export async function syncCursorSkills( + ctx: AdapterSkillContext, + desiredSkills: string[], +): Promise { + const availableEntries = await listPaperclipSkillEntries(__moduleDir); + const desiredSet = new Set(desiredSkills); + const skillsHome = resolveCursorSkillsHome(ctx.config); + await fs.mkdir(skillsHome, { recursive: true }); + const installed = await readInstalledSkillTargets(skillsHome); + const availableByName = new Map(availableEntries.map((entry) => [entry.name, entry])); + + for (const available of availableEntries) { + if (!desiredSet.has(available.name)) continue; + const target = path.join(skillsHome, available.name); + await ensurePaperclipSkillSymlink(available.source, target); + } + + for (const [name, installedEntry] of installed.entries()) { + const available = availableByName.get(name); + if (!available) continue; + if (desiredSet.has(name)) continue; + if (installedEntry.targetPath !== available.source) continue; + await fs.unlink(path.join(skillsHome, name)).catch(() => {}); + } + + return buildCursorSkillSnapshot(ctx.config); +} + +export function resolveCursorDesiredSkillNames( + config: Record, + availableSkillNames: string[], +) { + return resolveDesiredSkillNames(config, availableSkillNames); +} diff --git a/packages/adapters/gemini-local/src/server/execute.ts b/packages/adapters/gemini-local/src/server/execute.ts index fc293066..26e5a7cc 100644 --- a/packages/adapters/gemini-local/src/server/execute.ts +++ b/packages/adapters/gemini-local/src/server/execute.ts @@ -16,6 +16,7 @@ import { joinPromptSections, ensurePathInEnv, listPaperclipSkillEntries, + readPaperclipSkillSyncPreference, removeMaintainerOnlySkillSymlinks, parseObject, redactEnvForLogs, @@ -84,8 +85,11 @@ function geminiSkillsHome(): string { */ async function ensureGeminiSkillsInjected( onLog: AdapterExecutionContext["onLog"], + desiredSkillNames?: string[], ): Promise { - const skillsEntries = await listPaperclipSkillEntries(__moduleDir); + 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 skillsHome = geminiSkillsHome(); @@ -155,7 +159,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise entry.name); + await ensureGeminiSkillsInjected(onLog, desiredGeminiSkillNames); const envConfig = parseObject(config.env); const hasExplicitApiKey = diff --git a/packages/adapters/gemini-local/src/server/index.ts b/packages/adapters/gemini-local/src/server/index.ts index 1d35a2bf..e3f9dec3 100644 --- a/packages/adapters/gemini-local/src/server/index.ts +++ b/packages/adapters/gemini-local/src/server/index.ts @@ -1,4 +1,5 @@ export { execute } from "./execute.js"; +export { listGeminiSkills, syncGeminiSkills } from "./skills.js"; export { testEnvironment } from "./test.js"; export { parseGeminiJsonl, diff --git a/packages/adapters/gemini-local/src/server/skills.ts b/packages/adapters/gemini-local/src/server/skills.ts new file mode 100644 index 00000000..20a202e2 --- /dev/null +++ b/packages/adapters/gemini-local/src/server/skills.ts @@ -0,0 +1,179 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { + AdapterSkillContext, + AdapterSkillEntry, + AdapterSkillSnapshot, +} from "@paperclipai/adapter-utils"; +import { + ensurePaperclipSkillSymlink, + listPaperclipSkillEntries, + readPaperclipSkillSyncPreference, +} from "@paperclipai/adapter-utils/server-utils"; + +const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); + +function asString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function resolveGeminiSkillsHome(config: Record) { + const env = + typeof config.env === "object" && config.env !== null && !Array.isArray(config.env) + ? (config.env as Record) + : {}; + const configuredHome = asString(env.HOME); + const home = configuredHome ? path.resolve(configuredHome) : os.homedir(); + 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(); + for (const entry of entries) { + const fullPath = path.join(skillsHome, entry.name); + if (entry.isSymbolicLink()) { + const linkedPath = await fs.readlink(fullPath).catch(() => null); + out.set(entry.name, { + targetPath: linkedPath ? path.resolve(path.dirname(fullPath), linkedPath) : null, + kind: "symlink", + }); + continue; + } + if (entry.isDirectory()) { + out.set(entry.name, { targetPath: fullPath, kind: "directory" }); + continue; + } + out.set(entry.name, { targetPath: fullPath, kind: "file" }); + } + return out; +} + +async function buildGeminiSkillSnapshot(config: Record): Promise { + const availableEntries = await listPaperclipSkillEntries(__moduleDir); + const availableByName = new Map(availableEntries.map((entry) => [entry.name, entry])); + const desiredSkills = resolveDesiredSkillNames( + config, + availableEntries.map((entry) => entry.name), + ); + const desiredSet = new Set(desiredSkills); + const skillsHome = resolveGeminiSkillsHome(config); + const installed = await readInstalledSkillTargets(skillsHome); + const entries: AdapterSkillEntry[] = []; + const warnings: string[] = []; + + for (const available of availableEntries) { + const installedEntry = installed.get(available.name) ?? null; + const desired = desiredSet.has(available.name); + let state: AdapterSkillEntry["state"] = "available"; + let managed = false; + let detail: string | null = null; + + if (installedEntry?.targetPath === available.source) { + managed = true; + state = desired ? "installed" : "stale"; + } else if (installedEntry) { + state = "external"; + detail = desired + ? "Skill name is occupied by an external installation." + : "Installed outside Paperclip management."; + } else if (desired) { + state = "missing"; + detail = "Configured but not currently linked into the Gemini skills home."; + } + + entries.push({ + name: available.name, + desired, + managed, + state, + sourcePath: available.source, + targetPath: path.join(skillsHome, available.name), + detail, + }); + } + + for (const desiredSkill of desiredSkills) { + if (availableByName.has(desiredSkill)) continue; + warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`); + entries.push({ + name: desiredSkill, + desired: true, + managed: true, + state: "missing", + sourcePath: null, + targetPath: path.join(skillsHome, desiredSkill), + detail: "Paperclip cannot find this skill in the local runtime skills directory.", + }); + } + + for (const [name, installedEntry] of installed.entries()) { + if (availableByName.has(name)) continue; + entries.push({ + name, + desired: false, + managed: false, + state: "external", + sourcePath: null, + targetPath: installedEntry.targetPath ?? path.join(skillsHome, name), + detail: "Installed outside Paperclip management.", + }); + } + + entries.sort((left, right) => left.name.localeCompare(right.name)); + + return { + adapterType: "gemini_local", + supported: true, + mode: "persistent", + desiredSkills, + entries, + warnings, + }; +} + +export async function listGeminiSkills(ctx: AdapterSkillContext): Promise { + return buildGeminiSkillSnapshot(ctx.config); +} + +export async function syncGeminiSkills( + ctx: AdapterSkillContext, + desiredSkills: string[], +): Promise { + const availableEntries = await listPaperclipSkillEntries(__moduleDir); + const desiredSet = new Set(desiredSkills); + const skillsHome = resolveGeminiSkillsHome(ctx.config); + await fs.mkdir(skillsHome, { recursive: true }); + const installed = await readInstalledSkillTargets(skillsHome); + const availableByName = new Map(availableEntries.map((entry) => [entry.name, entry])); + + for (const available of availableEntries) { + if (!desiredSet.has(available.name)) continue; + const target = path.join(skillsHome, available.name); + await ensurePaperclipSkillSymlink(available.source, target); + } + + for (const [name, installedEntry] of installed.entries()) { + const available = availableByName.get(name); + if (!available) continue; + if (desiredSet.has(name)) continue; + if (installedEntry.targetPath !== available.source) continue; + await fs.unlink(path.join(skillsHome, name)).catch(() => {}); + } + + return buildGeminiSkillSnapshot(ctx.config); +} + +export function resolveGeminiDesiredSkillNames( + config: Record, + availableSkillNames: string[], +) { + return resolveDesiredSkillNames(config, availableSkillNames); +} diff --git a/packages/adapters/opencode-local/src/server/execute.ts b/packages/adapters/opencode-local/src/server/execute.ts index 06b5a99f..90bdf181 100644 --- a/packages/adapters/opencode-local/src/server/execute.ts +++ b/packages/adapters/opencode-local/src/server/execute.ts @@ -16,6 +16,7 @@ import { ensurePathInEnv, renderTemplate, runChildProcess, + readPaperclipSkillSyncPreference, } from "@paperclipai/adapter-utils/server-utils"; import { isOpenCodeUnknownSessionError, parseOpenCodeJsonl } from "./parse.js"; import { ensureOpenCodeModelConfiguredAndAvailable } from "./models.js"; @@ -54,15 +55,20 @@ async function resolvePaperclipSkillsDir(): Promise { return null; } -async function ensureOpenCodeSkillsInjected(onLog: AdapterExecutionContext["onLog"]) { +async function ensureOpenCodeSkillsInjected( + onLog: AdapterExecutionContext["onLog"], + 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 target = path.join(skillsHome, entry.name); const existing = await fs.lstat(target).catch(() => null); @@ -110,7 +116,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 ? value.trim() : null; +} + +function resolveOpenCodeSkillsHome(config: Record) { + const env = + typeof config.env === "object" && config.env !== null && !Array.isArray(config.env) + ? (config.env as Record) + : {}; + const configuredHome = asString(env.HOME); + const home = configuredHome ? path.resolve(configuredHome) : os.homedir(); + 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(); + for (const entry of entries) { + const fullPath = path.join(skillsHome, entry.name); + if (entry.isSymbolicLink()) { + const linkedPath = await fs.readlink(fullPath).catch(() => null); + out.set(entry.name, { + targetPath: linkedPath ? path.resolve(path.dirname(fullPath), linkedPath) : null, + kind: "symlink", + }); + continue; + } + if (entry.isDirectory()) { + out.set(entry.name, { targetPath: fullPath, kind: "directory" }); + continue; + } + out.set(entry.name, { targetPath: fullPath, kind: "file" }); + } + return out; +} + +async function buildOpenCodeSkillSnapshot(config: Record): Promise { + const availableEntries = await listPaperclipSkillEntries(__moduleDir); + const availableByName = new Map(availableEntries.map((entry) => [entry.name, entry])); + const desiredSkills = resolveDesiredSkillNames( + config, + availableEntries.map((entry) => entry.name), + ); + const desiredSet = new Set(desiredSkills); + const skillsHome = resolveOpenCodeSkillsHome(config); + const installed = await readInstalledSkillTargets(skillsHome); + const entries: AdapterSkillEntry[] = []; + const warnings: string[] = [ + "OpenCode currently uses the shared Claude skills home (~/.claude/skills).", + ]; + + for (const available of availableEntries) { + const installedEntry = installed.get(available.name) ?? null; + const desired = desiredSet.has(available.name); + let state: AdapterSkillEntry["state"] = "available"; + let managed = false; + let detail: string | null = null; + + if (installedEntry?.targetPath === available.source) { + managed = true; + state = desired ? "installed" : "stale"; + detail = "Installed in the shared Claude/OpenCode skills home."; + } else if (installedEntry) { + state = "external"; + detail = desired + ? "Skill name is occupied by an external installation in the shared skills home." + : "Installed outside Paperclip management in the shared skills home."; + } else if (desired) { + state = "missing"; + detail = "Configured but not currently linked into the shared Claude/OpenCode skills home."; + } + + entries.push({ + name: available.name, + desired, + managed, + state, + sourcePath: available.source, + targetPath: path.join(skillsHome, available.name), + detail, + }); + } + + for (const desiredSkill of desiredSkills) { + if (availableByName.has(desiredSkill)) continue; + warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`); + entries.push({ + name: desiredSkill, + desired: true, + managed: true, + state: "missing", + sourcePath: null, + targetPath: path.join(skillsHome, desiredSkill), + detail: "Paperclip cannot find this skill in the local runtime skills directory.", + }); + } + + for (const [name, installedEntry] of installed.entries()) { + if (availableByName.has(name)) continue; + entries.push({ + name, + desired: false, + managed: false, + state: "external", + sourcePath: null, + targetPath: installedEntry.targetPath ?? path.join(skillsHome, name), + detail: "Installed outside Paperclip management in the shared skills home.", + }); + } + + entries.sort((left, right) => left.name.localeCompare(right.name)); + + return { + adapterType: "opencode_local", + supported: true, + mode: "persistent", + desiredSkills, + entries, + warnings, + }; +} + +export async function listOpenCodeSkills(ctx: AdapterSkillContext): Promise { + return buildOpenCodeSkillSnapshot(ctx.config); +} + +export async function syncOpenCodeSkills( + ctx: AdapterSkillContext, + desiredSkills: string[], +): Promise { + const availableEntries = await listPaperclipSkillEntries(__moduleDir); + const desiredSet = new Set(desiredSkills); + const skillsHome = resolveOpenCodeSkillsHome(ctx.config); + await fs.mkdir(skillsHome, { recursive: true }); + const installed = await readInstalledSkillTargets(skillsHome); + const availableByName = new Map(availableEntries.map((entry) => [entry.name, entry])); + + for (const available of availableEntries) { + if (!desiredSet.has(available.name)) continue; + const target = path.join(skillsHome, available.name); + await ensurePaperclipSkillSymlink(available.source, target); + } + + for (const [name, installedEntry] of installed.entries()) { + const available = availableByName.get(name); + if (!available) continue; + if (desiredSet.has(name)) continue; + if (installedEntry.targetPath !== available.source) continue; + await fs.unlink(path.join(skillsHome, name)).catch(() => {}); + } + + return buildOpenCodeSkillSnapshot(ctx.config); +} + +export function resolveOpenCodeDesiredSkillNames( + config: Record, + availableSkillNames: string[], +) { + return resolveDesiredSkillNames(config, availableSkillNames); +} diff --git a/packages/adapters/pi-local/src/server/execute.ts b/packages/adapters/pi-local/src/server/execute.ts index 810ec1ce..ee7778e0 100644 --- a/packages/adapters/pi-local/src/server/execute.ts +++ b/packages/adapters/pi-local/src/server/execute.ts @@ -16,6 +16,7 @@ import { ensurePaperclipSkillSymlink, ensurePathInEnv, listPaperclipSkillEntries, + readPaperclipSkillSyncPreference, removeMaintainerOnlySkillSymlinks, renderTemplate, runChildProcess, @@ -50,8 +51,13 @@ function parseModelId(model: string | null): string | null { return trimmed.slice(trimmed.indexOf("/") + 1).trim() || null; } -async function ensurePiSkillsInjected(onLog: AdapterExecutionContext["onLog"]) { - const skillsEntries = await listPaperclipSkillEntries(__moduleDir); +async function ensurePiSkillsInjected( + onLog: AdapterExecutionContext["onLog"], + 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 piSkillsHome = path.join(os.homedir(), ".pi", "agent", "skills"); @@ -132,7 +138,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise entry.name); + await ensurePiSkillsInjected(onLog, desiredPiSkillNames); // Build environment const envConfig = parseObject(config.env); diff --git a/packages/adapters/pi-local/src/server/index.ts b/packages/adapters/pi-local/src/server/index.ts index a18d5264..9ad14d6f 100644 --- a/packages/adapters/pi-local/src/server/index.ts +++ b/packages/adapters/pi-local/src/server/index.ts @@ -49,6 +49,7 @@ export const sessionCodec: AdapterSessionCodec = { }; export { execute } from "./execute.js"; +export { listPiSkills, syncPiSkills } from "./skills.js"; export { testEnvironment } from "./test.js"; export { listPiModels, diff --git a/packages/adapters/pi-local/src/server/skills.ts b/packages/adapters/pi-local/src/server/skills.ts new file mode 100644 index 00000000..eff2753f --- /dev/null +++ b/packages/adapters/pi-local/src/server/skills.ts @@ -0,0 +1,179 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { + AdapterSkillContext, + AdapterSkillEntry, + AdapterSkillSnapshot, +} from "@paperclipai/adapter-utils"; +import { + ensurePaperclipSkillSymlink, + listPaperclipSkillEntries, + readPaperclipSkillSyncPreference, +} from "@paperclipai/adapter-utils/server-utils"; + +const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); + +function asString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function resolvePiSkillsHome(config: Record) { + const env = + typeof config.env === "object" && config.env !== null && !Array.isArray(config.env) + ? (config.env as Record) + : {}; + const configuredHome = asString(env.HOME); + const home = configuredHome ? path.resolve(configuredHome) : os.homedir(); + 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(); + for (const entry of entries) { + const fullPath = path.join(skillsHome, entry.name); + if (entry.isSymbolicLink()) { + const linkedPath = await fs.readlink(fullPath).catch(() => null); + out.set(entry.name, { + targetPath: linkedPath ? path.resolve(path.dirname(fullPath), linkedPath) : null, + kind: "symlink", + }); + continue; + } + if (entry.isDirectory()) { + out.set(entry.name, { targetPath: fullPath, kind: "directory" }); + continue; + } + out.set(entry.name, { targetPath: fullPath, kind: "file" }); + } + return out; +} + +async function buildPiSkillSnapshot(config: Record): Promise { + const availableEntries = await listPaperclipSkillEntries(__moduleDir); + const availableByName = new Map(availableEntries.map((entry) => [entry.name, entry])); + const desiredSkills = resolveDesiredSkillNames( + config, + availableEntries.map((entry) => entry.name), + ); + const desiredSet = new Set(desiredSkills); + const skillsHome = resolvePiSkillsHome(config); + const installed = await readInstalledSkillTargets(skillsHome); + const entries: AdapterSkillEntry[] = []; + const warnings: string[] = []; + + for (const available of availableEntries) { + const installedEntry = installed.get(available.name) ?? null; + const desired = desiredSet.has(available.name); + let state: AdapterSkillEntry["state"] = "available"; + let managed = false; + let detail: string | null = null; + + if (installedEntry?.targetPath === available.source) { + managed = true; + state = desired ? "installed" : "stale"; + } else if (installedEntry) { + state = "external"; + detail = desired + ? "Skill name is occupied by an external installation." + : "Installed outside Paperclip management."; + } else if (desired) { + state = "missing"; + detail = "Configured but not currently linked into the Pi skills home."; + } + + entries.push({ + name: available.name, + desired, + managed, + state, + sourcePath: available.source, + targetPath: path.join(skillsHome, available.name), + detail, + }); + } + + for (const desiredSkill of desiredSkills) { + if (availableByName.has(desiredSkill)) continue; + warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`); + entries.push({ + name: desiredSkill, + desired: true, + managed: true, + state: "missing", + sourcePath: null, + targetPath: path.join(skillsHome, desiredSkill), + detail: "Paperclip cannot find this skill in the local runtime skills directory.", + }); + } + + for (const [name, installedEntry] of installed.entries()) { + if (availableByName.has(name)) continue; + entries.push({ + name, + desired: false, + managed: false, + state: "external", + sourcePath: null, + targetPath: installedEntry.targetPath ?? path.join(skillsHome, name), + detail: "Installed outside Paperclip management.", + }); + } + + entries.sort((left, right) => left.name.localeCompare(right.name)); + + return { + adapterType: "pi_local", + supported: true, + mode: "persistent", + desiredSkills, + entries, + warnings, + }; +} + +export async function listPiSkills(ctx: AdapterSkillContext): Promise { + return buildPiSkillSnapshot(ctx.config); +} + +export async function syncPiSkills( + ctx: AdapterSkillContext, + desiredSkills: string[], +): Promise { + const availableEntries = await listPaperclipSkillEntries(__moduleDir); + const desiredSet = new Set(desiredSkills); + const skillsHome = resolvePiSkillsHome(ctx.config); + await fs.mkdir(skillsHome, { recursive: true }); + const installed = await readInstalledSkillTargets(skillsHome); + const availableByName = new Map(availableEntries.map((entry) => [entry.name, entry])); + + for (const available of availableEntries) { + if (!desiredSet.has(available.name)) continue; + const target = path.join(skillsHome, available.name); + await ensurePaperclipSkillSymlink(available.source, target); + } + + for (const [name, installedEntry] of installed.entries()) { + const available = availableByName.get(name); + if (!available) continue; + if (desiredSet.has(name)) continue; + if (installedEntry.targetPath !== available.source) continue; + await fs.unlink(path.join(skillsHome, name)).catch(() => {}); + } + + return buildPiSkillSnapshot(ctx.config); +} + +export function resolvePiDesiredSkillNames( + config: Record, + availableSkillNames: string[], +) { + return resolveDesiredSkillNames(config, availableSkillNames); +} diff --git a/server/src/__tests__/cursor-local-skill-sync.test.ts b/server/src/__tests__/cursor-local-skill-sync.test.ts new file mode 100644 index 00000000..18483d32 --- /dev/null +++ b/server/src/__tests__/cursor-local-skill-sync.test.ts @@ -0,0 +1,87 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + listCursorSkills, + syncCursorSkills, +} from "@paperclipai/adapter-cursor-local/server"; + +async function makeTempDir(prefix: string): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), prefix)); +} + +describe("cursor local skill sync", () => { + const cleanupDirs = new Set(); + + afterEach(async () => { + await Promise.all(Array.from(cleanupDirs).map((dir) => fs.rm(dir, { recursive: true, force: true }))); + cleanupDirs.clear(); + }); + + it("reports configured Paperclip skills and installs them into the Cursor skills home", async () => { + const home = await makeTempDir("paperclip-cursor-skill-sync-"); + cleanupDirs.add(home); + + const ctx = { + agentId: "agent-1", + companyId: "company-1", + adapterType: "cursor", + config: { + env: { + HOME: home, + }, + paperclipSkillSync: { + desiredSkills: ["paperclip"], + }, + }, + } as const; + + const before = await listCursorSkills(ctx); + expect(before.mode).toBe("persistent"); + expect(before.desiredSkills).toEqual(["paperclip"]); + expect(before.entries.find((entry) => entry.name === "paperclip")?.state).toBe("missing"); + + const after = await syncCursorSkills(ctx, ["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); + }); + + it("removes stale managed Paperclip skills when the desired set is emptied", async () => { + const home = await makeTempDir("paperclip-cursor-skill-prune-"); + cleanupDirs.add(home); + + const configuredCtx = { + agentId: "agent-2", + companyId: "company-1", + adapterType: "cursor", + config: { + env: { + HOME: home, + }, + paperclipSkillSync: { + desiredSkills: ["paperclip"], + }, + }, + } as const; + + await syncCursorSkills(configuredCtx, ["paperclip"]); + + const clearedCtx = { + ...configuredCtx, + config: { + env: { + HOME: home, + }, + paperclipSkillSync: { + desiredSkills: [], + }, + }, + } 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(); + }); +}); diff --git a/server/src/__tests__/gemini-local-skill-sync.test.ts b/server/src/__tests__/gemini-local-skill-sync.test.ts new file mode 100644 index 00000000..75f933b5 --- /dev/null +++ b/server/src/__tests__/gemini-local-skill-sync.test.ts @@ -0,0 +1,87 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + listGeminiSkills, + syncGeminiSkills, +} from "@paperclipai/adapter-gemini-local/server"; + +async function makeTempDir(prefix: string): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), prefix)); +} + +describe("gemini local skill sync", () => { + const cleanupDirs = new Set(); + + afterEach(async () => { + await Promise.all(Array.from(cleanupDirs).map((dir) => fs.rm(dir, { recursive: true, force: true }))); + cleanupDirs.clear(); + }); + + it("reports configured Paperclip skills and installs them into the Gemini skills home", async () => { + const home = await makeTempDir("paperclip-gemini-skill-sync-"); + cleanupDirs.add(home); + + const ctx = { + agentId: "agent-1", + companyId: "company-1", + adapterType: "gemini_local", + config: { + env: { + HOME: home, + }, + paperclipSkillSync: { + desiredSkills: ["paperclip"], + }, + }, + } as const; + + const before = await listGeminiSkills(ctx); + expect(before.mode).toBe("persistent"); + expect(before.desiredSkills).toEqual(["paperclip"]); + expect(before.entries.find((entry) => entry.name === "paperclip")?.state).toBe("missing"); + + const after = await syncGeminiSkills(ctx, ["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); + }); + + it("removes stale managed Paperclip skills when the desired set is emptied", async () => { + const home = await makeTempDir("paperclip-gemini-skill-prune-"); + cleanupDirs.add(home); + + const configuredCtx = { + agentId: "agent-2", + companyId: "company-1", + adapterType: "gemini_local", + config: { + env: { + HOME: home, + }, + paperclipSkillSync: { + desiredSkills: ["paperclip"], + }, + }, + } as const; + + await syncGeminiSkills(configuredCtx, ["paperclip"]); + + const clearedCtx = { + ...configuredCtx, + config: { + env: { + HOME: home, + }, + paperclipSkillSync: { + desiredSkills: [], + }, + }, + } 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(); + }); +}); diff --git a/server/src/__tests__/opencode-local-skill-sync.test.ts b/server/src/__tests__/opencode-local-skill-sync.test.ts new file mode 100644 index 00000000..38f2f941 --- /dev/null +++ b/server/src/__tests__/opencode-local-skill-sync.test.ts @@ -0,0 +1,88 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + listOpenCodeSkills, + syncOpenCodeSkills, +} from "@paperclipai/adapter-opencode-local/server"; + +async function makeTempDir(prefix: string): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), prefix)); +} + +describe("opencode local skill sync", () => { + const cleanupDirs = new Set(); + + afterEach(async () => { + await Promise.all(Array.from(cleanupDirs).map((dir) => fs.rm(dir, { recursive: true, force: true }))); + cleanupDirs.clear(); + }); + + it("reports configured Paperclip skills and installs them into the shared Claude/OpenCode skills home", async () => { + const home = await makeTempDir("paperclip-opencode-skill-sync-"); + cleanupDirs.add(home); + + const ctx = { + agentId: "agent-1", + companyId: "company-1", + adapterType: "opencode_local", + config: { + env: { + HOME: home, + }, + paperclipSkillSync: { + desiredSkills: ["paperclip"], + }, + }, + } as const; + + 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.entries.find((entry) => entry.name === "paperclip")?.state).toBe("missing"); + + const after = await syncOpenCodeSkills(ctx, ["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); + }); + + it("removes stale managed Paperclip skills when the desired set is emptied", async () => { + const home = await makeTempDir("paperclip-opencode-skill-prune-"); + cleanupDirs.add(home); + + const configuredCtx = { + agentId: "agent-2", + companyId: "company-1", + adapterType: "opencode_local", + config: { + env: { + HOME: home, + }, + paperclipSkillSync: { + desiredSkills: ["paperclip"], + }, + }, + } as const; + + await syncOpenCodeSkills(configuredCtx, ["paperclip"]); + + const clearedCtx = { + ...configuredCtx, + config: { + env: { + HOME: home, + }, + paperclipSkillSync: { + desiredSkills: [], + }, + }, + } 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(); + }); +}); diff --git a/server/src/__tests__/pi-local-skill-sync.test.ts b/server/src/__tests__/pi-local-skill-sync.test.ts new file mode 100644 index 00000000..d2a00eda --- /dev/null +++ b/server/src/__tests__/pi-local-skill-sync.test.ts @@ -0,0 +1,87 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + listPiSkills, + syncPiSkills, +} from "@paperclipai/adapter-pi-local/server"; + +async function makeTempDir(prefix: string): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), prefix)); +} + +describe("pi local skill sync", () => { + const cleanupDirs = new Set(); + + afterEach(async () => { + await Promise.all(Array.from(cleanupDirs).map((dir) => fs.rm(dir, { recursive: true, force: true }))); + cleanupDirs.clear(); + }); + + it("reports configured Paperclip skills and installs them into the Pi skills home", async () => { + const home = await makeTempDir("paperclip-pi-skill-sync-"); + cleanupDirs.add(home); + + const ctx = { + agentId: "agent-1", + companyId: "company-1", + adapterType: "pi_local", + config: { + env: { + HOME: home, + }, + paperclipSkillSync: { + desiredSkills: ["paperclip"], + }, + }, + } as const; + + const before = await listPiSkills(ctx); + expect(before.mode).toBe("persistent"); + expect(before.desiredSkills).toEqual(["paperclip"]); + expect(before.entries.find((entry) => entry.name === "paperclip")?.state).toBe("missing"); + + const after = await syncPiSkills(ctx, ["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); + }); + + it("removes stale managed Paperclip skills when the desired set is emptied", async () => { + const home = await makeTempDir("paperclip-pi-skill-prune-"); + cleanupDirs.add(home); + + const configuredCtx = { + agentId: "agent-2", + companyId: "company-1", + adapterType: "pi_local", + config: { + env: { + HOME: home, + }, + paperclipSkillSync: { + desiredSkills: ["paperclip"], + }, + }, + } as const; + + await syncPiSkills(configuredCtx, ["paperclip"]); + + const clearedCtx = { + ...configuredCtx, + config: { + env: { + HOME: home, + }, + paperclipSkillSync: { + desiredSkills: [], + }, + }, + } 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(); + }); +}); diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index d5914b4d..d350f885 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -17,18 +17,24 @@ import { import { agentConfigurationDoc as codexAgentConfigurationDoc, models as codexModels } from "@paperclipai/adapter-codex-local"; import { execute as cursorExecute, + listCursorSkills, + syncCursorSkills, testEnvironment as cursorTestEnvironment, sessionCodec as cursorSessionCodec, } from "@paperclipai/adapter-cursor-local/server"; import { agentConfigurationDoc as cursorAgentConfigurationDoc, models as cursorModels } from "@paperclipai/adapter-cursor-local"; import { execute as geminiExecute, + listGeminiSkills, + syncGeminiSkills, testEnvironment as geminiTestEnvironment, sessionCodec as geminiSessionCodec, } from "@paperclipai/adapter-gemini-local/server"; import { agentConfigurationDoc as geminiAgentConfigurationDoc, models as geminiModels } from "@paperclipai/adapter-gemini-local"; import { execute as openCodeExecute, + listOpenCodeSkills, + syncOpenCodeSkills, testEnvironment as openCodeTestEnvironment, sessionCodec as openCodeSessionCodec, listOpenCodeModels, @@ -48,6 +54,8 @@ import { listCodexModels } from "./codex-models.js"; import { listCursorModels } from "./cursor-models.js"; import { execute as piExecute, + listPiSkills, + syncPiSkills, testEnvironment as piTestEnvironment, sessionCodec as piSessionCodec, listPiModels, @@ -87,6 +95,8 @@ const cursorLocalAdapter: ServerAdapterModule = { type: "cursor", execute: cursorExecute, testEnvironment: cursorTestEnvironment, + listSkills: listCursorSkills, + syncSkills: syncCursorSkills, sessionCodec: cursorSessionCodec, models: cursorModels, listModels: listCursorModels, @@ -98,6 +108,8 @@ const geminiLocalAdapter: ServerAdapterModule = { type: "gemini_local", execute: geminiExecute, testEnvironment: geminiTestEnvironment, + listSkills: listGeminiSkills, + syncSkills: syncGeminiSkills, sessionCodec: geminiSessionCodec, models: geminiModels, supportsLocalAgentJwt: true, @@ -117,6 +129,8 @@ const openCodeLocalAdapter: ServerAdapterModule = { type: "opencode_local", execute: openCodeExecute, testEnvironment: openCodeTestEnvironment, + listSkills: listOpenCodeSkills, + syncSkills: syncOpenCodeSkills, sessionCodec: openCodeSessionCodec, models: [], listModels: listOpenCodeModels, @@ -128,6 +142,8 @@ const piLocalAdapter: ServerAdapterModule = { type: "pi_local", execute: piExecute, testEnvironment: piTestEnvironment, + listSkills: listPiSkills, + syncSkills: syncPiSkills, sessionCodec: piSessionCodec, models: [], listModels: listPiModels, diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 593b7656..7dbc4dd4 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -125,6 +125,12 @@ const sourceLabels: Record = { const LIVE_SCROLL_BOTTOM_TOLERANCE_PX = 32; type ScrollContainer = Window | HTMLElement; +function arraysEqual(a: string[], b: string[]): boolean { + if (a === b) return true; + if (a.length !== b.length) return false; + return a.every((value, index) => value === b[index]); +} + function isWindowContainer(container: ScrollContainer): container is Window { return container === window; } @@ -1144,7 +1150,8 @@ function AgentSkillsTab({ }) { const queryClient = useQueryClient(); const [skillDraft, setSkillDraft] = useState([]); - const [skillDirty, setSkillDirty] = useState(false); + const [lastSavedSkills, setLastSavedSkills] = useState([]); + const lastSavedSkillsRef = useRef([]); const { data: skillSnapshot, isLoading } = useQuery({ queryKey: queryKeys.agents.skills(agent.id), @@ -1158,28 +1165,40 @@ function AgentSkillsTab({ enabled: Boolean(companyId), }); - useEffect(() => { - if (!skillSnapshot) return; - setSkillDraft(skillSnapshot.desiredSkills); - setSkillDirty(false); - }, [skillSnapshot]); - const syncSkills = useMutation({ mutationFn: (desiredSkills: string[]) => agentsApi.syncSkills(agent.id, desiredSkills, companyId), onSuccess: async (snapshot) => { + queryClient.setQueryData(queryKeys.agents.skills(agent.id), snapshot); + lastSavedSkillsRef.current = snapshot.desiredSkills; + setLastSavedSkills(snapshot.desiredSkills); await Promise.all([ queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) }), queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) }), - queryClient.invalidateQueries({ queryKey: queryKeys.agents.skills(agent.id) }), - companyId - ? queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.list(companyId) }) - : Promise.resolve(), ]); - setSkillDraft(snapshot.desiredSkills); - setSkillDirty(false); }, }); + useEffect(() => { + if (!skillSnapshot) return; + setSkillDraft((current) => + arraysEqual(current, lastSavedSkillsRef.current) ? skillSnapshot.desiredSkills : current, + ); + lastSavedSkillsRef.current = skillSnapshot.desiredSkills; + setLastSavedSkills(skillSnapshot.desiredSkills); + }, [skillSnapshot]); + + useEffect(() => { + if (!skillSnapshot) return; + if (syncSkills.isPending) return; + if (arraysEqual(skillDraft, lastSavedSkills)) return; + + const timeout = window.setTimeout(() => { + syncSkills.mutate(skillDraft); + }, 250); + + return () => window.clearTimeout(timeout); + }, [lastSavedSkills, skillDraft, skillSnapshot, syncSkills.isPending, syncSkills.mutate]); + const companySkillBySlug = useMemo( () => new Map((companySkills ?? []).map((skill) => [skill.slug, skill])), [companySkills], @@ -1192,258 +1211,135 @@ function AgentSkillsTab({ () => skillDraft.filter((slug) => !companySkillBySlug.has(slug)), [companySkillBySlug, skillDraft], ); - const externalEntries = (skillSnapshot?.entries ?? []).filter((entry) => entry.state === "external"); - - const modeCopy = useMemo(() => { - if (!skillSnapshot) return "Loading skill state..."; - if (!skillSnapshot.supported) { - return "This adapter does not implement direct skill sync yet. Paperclip can still store the desired skill set for this agent."; + const skillApplicationLabel = useMemo(() => { + switch (skillSnapshot?.mode) { + case "persistent": + return "Kept in the workspace"; + case "ephemeral": + return "Applied when the agent runs"; + case "unsupported": + return "Tracked only"; + default: + return "Unknown"; } - if (skillSnapshot.mode === "persistent") { - return "Selected skills are synchronized into the adapter's persistent skills home."; - } - if (skillSnapshot.mode === "ephemeral") { - return "Selected skills are mounted for each run instead of being installed globally."; - } - return "This adapter reports skill state but does not define a persistent install model."; - }, [skillSnapshot]); - - const primaryActionLabel = !skillSnapshot || skillSnapshot.supported - ? "Sync skills" - : "Save desired skills"; + }, [skillSnapshot?.mode]); + const hasUnsavedChanges = !arraysEqual(skillDraft, lastSavedSkills); + const saveStatusLabel = syncSkills.isPending + ? "Saving changes..." + : hasUnsavedChanges + ? "Saving soon..." + : "Changes save automatically"; return ( -
-
-
-
-
-
- Skills -
-

Attach reusable skills to {agent.name}.

-

{modeCopy}

-
-
- - Open company library - - - -
-
+
+
+ + View company library + +
+ {syncSkills.isPending ? : null} + {saveStatusLabel}
+
-
- {skillSnapshot?.warnings.length ? ( -
- {skillSnapshot.warnings.map((warning) => ( -
{warning}
- ))} -
- ) : null} + {skillSnapshot?.warnings.length ? ( +
+ {skillSnapshot.warnings.map((warning) => ( +
{warning}
+ ))} +
+ ) : null} - {isLoading ? ( - - ) : ( -
-
-
-
-
-

Company skills

-

- Attach skills from the company library by shortname. -

-
- - {(companySkills ?? []).length} available - -
- - {(companySkills ?? []).length === 0 ? ( -
- Import skills into the company library first, then attach them here. -
- ) : ( -
- {(companySkills ?? []).map((skill) => { - const checked = skillDraft.includes(skill.slug); - const adapterEntry = adapterEntryByName.get(skill.slug); - return ( - - ); - })} -
- )} -
- - {desiredOnlyMissingSkills.length > 0 && ( -
-

- Desired skills not found in the company library -

-
- {desiredOnlyMissingSkills.map((skillName) => { - const adapterEntry = adapterEntryByName.get(skillName); - return ( -
-
-
{skillName}
-
- This skill is still requested for the agent, but it is not tracked in the company library. -
-
- {adapterEntry?.state && ( - - {adapterEntry.state} - - )} -
- ); - })} -
-
- )} + {isLoading ? ( + + ) : ( + <> +
+ {(companySkills ?? []).length === 0 ? ( +
+ Import skills into the company library first, then attach them here.
+ ) : ( + (companySkills ?? []).map((skill) => { + const checked = skillDraft.includes(skill.slug); + const adapterEntry = adapterEntryByName.get(skill.slug); + return ( + + ); + }) + )} +
-
-
-

Adapter state

-
-
- Adapter - {agent.adapterType} -
-
- Sync mode - - {skillSnapshot?.mode ?? "unsupported"} - -
-
- Desired skills - {skillDraft.length} -
-
- External skills - {externalEntries.length} -
-
- -
- - {skillDirty && ( - - )} -
- - {syncSkills.isError && ( -

- {syncSkills.error instanceof Error ? syncSkills.error.message : "Failed to update skills"} -

- )} -
- -
-

External skills

- {externalEntries.length === 0 ? ( -

- No external skills were discovered by the adapter. -

- ) : ( -
- {externalEntries.map((entry) => ( -
-
- {entry.name} - - {entry.state} - -
- {entry.detail && ( -

{entry.detail}

- )} -
- ))} -
- )} -
+ {desiredOnlyMissingSkills.length > 0 && ( +
+
Requested skills missing from the company library
+
+ {desiredOnlyMissingSkills.join(", ")}
)} -
-
+ +
+
+
+ Adapter + {adapterLabels[agent.adapterType] ?? agent.adapterType} +
+
+ Skills applied + {skillApplicationLabel} +
+
+ Selected skills + {skillDraft.length} +
+
+ + {syncSkills.isError && ( +

+ {syncSkills.error instanceof Error ? syncSkills.error.message : "Failed to update skills"} +

+ )} +
+ + )}
); }