From cfc53bf96b1760bddd256c920045649f8f66c917 Mon Sep 17 00:00:00 2001 From: dotta Date: Wed, 18 Mar 2026 14:21:50 -0500 Subject: [PATCH] Add unmanaged skill provenance to agent skills Expose adapter-discovered user-installed skills with provenance metadata, share persistent skill snapshot classification across local adapters, and render unmanaged skills as a read-only section in the agent skills UI. Co-Authored-By: Paperclip --- packages/adapter-utils/src/index.ts | 1 + packages/adapter-utils/src/server-utils.ts | 181 +++++++++++++++++- packages/adapter-utils/src/types.ts | 10 + .../claude-local/src/server/skills.ts | 42 ++++ .../adapters/codex-local/src/server/skills.ts | 110 ++--------- .../cursor-local/src/server/skills.ts | 110 ++--------- .../gemini-local/src/server/skills.ts | 110 ++--------- .../opencode-local/src/server/skills.ts | 117 ++--------- .../adapters/pi-local/src/server/skills.ts | 110 ++--------- packages/shared/src/index.ts | 1 + packages/shared/src/types/adapter-skills.ts | 10 + packages/shared/src/types/index.ts | 1 + .../shared/src/validators/adapter-skills.ts | 11 ++ .../__tests__/agent-skill-contract.test.ts | 49 +++++ .../__tests__/claude-local-skill-sync.test.ts | 51 ++++- .../__tests__/codex-local-skill-sync.test.ts | 38 ++++ server/src/adapters/types.ts | 1 + ui/src/lib/agent-skills-state.test.ts | 34 +++- ui/src/lib/agent-skills-state.ts | 11 ++ 19 files changed, 497 insertions(+), 501 deletions(-) create mode 100644 server/src/__tests__/agent-skill-contract.test.ts diff --git a/packages/adapter-utils/src/index.ts b/packages/adapter-utils/src/index.ts index aef48f97..943db253 100644 --- a/packages/adapter-utils/src/index.ts +++ b/packages/adapter-utils/src/index.ts @@ -14,6 +14,7 @@ export type { AdapterEnvironmentTestContext, AdapterSkillSyncMode, AdapterSkillState, + AdapterSkillOrigin, AdapterSkillEntry, AdapterSkillSnapshot, AdapterSkillContext, diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index ba35807a..f36b6a3b 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -1,6 +1,10 @@ import { spawn, type ChildProcess } from "node:child_process"; -import { constants as fsConstants, promises as fs } from "node:fs"; +import { constants as fsConstants, promises as fs, type Dirent } from "node:fs"; import path from "node:path"; +import type { + AdapterSkillEntry, + AdapterSkillSnapshot, +} from "./types.js"; export interface RunProcessResult { exitCode: number | null; @@ -45,6 +49,25 @@ export interface PaperclipSkillEntry { requiredReason?: string | null; } +export interface InstalledSkillTarget { + targetPath: string | null; + kind: "symlink" | "directory" | "file"; +} + +interface PersistentSkillSnapshotOptions { + adapterType: string; + availableEntries: PaperclipSkillEntry[]; + desiredSkills: string[]; + installed: Map; + skillsHome: string; + locationLabel?: string | null; + installedDetail?: string | null; + missingDetail: string; + externalConflictDetail: string; + externalDetail: string; + warnings?: string[]; +} + function normalizePathSlashes(value: string): string { return value.replaceAll("\\", "/"); } @@ -53,6 +76,49 @@ function isMaintainerOnlySkillTarget(candidate: string): boolean { return normalizePathSlashes(candidate).includes("/.agents/skills/"); } +function skillLocationLabel(value: string | null | undefined): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function buildManagedSkillOrigin(entry: { required?: boolean }): Pick< + AdapterSkillEntry, + "origin" | "originLabel" | "readOnly" +> { + if (entry.required) { + return { + origin: "paperclip_required", + originLabel: "Required by Paperclip", + readOnly: false, + }; + } + return { + origin: "company_managed", + originLabel: "Managed by Paperclip", + readOnly: false, + }; +} + +function resolveInstalledEntryTarget( + skillsHome: string, + entryName: string, + dirent: Dirent, + linkedPath: string | null, +): InstalledSkillTarget { + const fullPath = path.join(skillsHome, entryName); + if (dirent.isSymbolicLink()) { + return { + targetPath: linkedPath ? path.resolve(path.dirname(fullPath), linkedPath) : null, + kind: "symlink", + }; + } + if (dirent.isDirectory()) { + return { targetPath: fullPath, kind: "directory" }; + } + return { targetPath: fullPath, kind: "file" }; +} + export function parseObject(value: unknown): Record { if (typeof value !== "object" || value === null || Array.isArray(value)) { return {}; @@ -318,6 +384,119 @@ export async function listPaperclipSkillEntries( } } +export async function readInstalledSkillTargets(skillsHome: string): Promise> { + 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); + const linkedPath = entry.isSymbolicLink() ? await fs.readlink(fullPath).catch(() => null) : null; + out.set(entry.name, resolveInstalledEntryTarget(skillsHome, entry.name, entry, linkedPath)); + } + return out; +} + +export function buildPersistentSkillSnapshot( + options: PersistentSkillSnapshotOptions, +): AdapterSkillSnapshot { + const { + adapterType, + availableEntries, + desiredSkills, + installed, + skillsHome, + locationLabel, + installedDetail, + missingDetail, + externalConflictDetail, + externalDetail, + } = options; + const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry])); + const desiredSet = new Set(desiredSkills); + const entries: AdapterSkillEntry[] = []; + const warnings = [...(options.warnings ?? [])]; + + for (const available of availableEntries) { + const installedEntry = installed.get(available.runtimeName) ?? null; + const desired = desiredSet.has(available.key); + 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 = installedDetail ?? null; + } else if (installedEntry) { + state = "external"; + detail = desired ? externalConflictDetail : externalDetail; + } else if (desired) { + state = "missing"; + detail = missingDetail; + } + + entries.push({ + key: available.key, + runtimeName: available.runtimeName, + desired, + managed, + state, + sourcePath: available.source, + targetPath: path.join(skillsHome, available.runtimeName), + detail, + required: Boolean(available.required), + requiredReason: available.requiredReason ?? null, + ...buildManagedSkillOrigin(available), + }); + } + + for (const desiredSkill of desiredSkills) { + if (availableByKey.has(desiredSkill)) continue; + warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`); + entries.push({ + key: desiredSkill, + runtimeName: null, + desired: true, + managed: true, + state: "missing", + sourcePath: null, + targetPath: null, + detail: "Paperclip cannot find this skill in the local runtime skills directory.", + origin: "external_unknown", + originLabel: "External or unavailable", + readOnly: false, + }); + } + + for (const [name, installedEntry] of installed.entries()) { + if (availableEntries.some((entry) => entry.runtimeName === name)) continue; + entries.push({ + key: name, + runtimeName: name, + desired: false, + managed: false, + state: "external", + origin: "user_installed", + originLabel: "User-installed", + locationLabel: skillLocationLabel(locationLabel), + readOnly: true, + sourcePath: null, + targetPath: installedEntry.targetPath ?? path.join(skillsHome, name), + detail: externalDetail, + }); + } + + entries.sort((left, right) => left.key.localeCompare(right.key)); + + return { + adapterType, + supported: true, + mode: "persistent", + desiredSkills, + entries, + warnings, + }; +} + function normalizeConfiguredPaperclipRuntimeSkills(value: unknown): PaperclipSkillEntry[] { if (!Array.isArray(value)) return []; const out: PaperclipSkillEntry[] = []; diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index f3616f5a..142e8939 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -157,6 +157,12 @@ export type AdapterSkillState = | "stale" | "external"; +export type AdapterSkillOrigin = + | "company_managed" + | "paperclip_required" + | "user_installed" + | "external_unknown"; + export interface AdapterSkillEntry { key: string; runtimeName: string | null; @@ -165,6 +171,10 @@ export interface AdapterSkillEntry { required?: boolean; requiredReason?: string | null; state: AdapterSkillState; + origin?: AdapterSkillOrigin; + originLabel?: string | null; + locationLabel?: string | null; + readOnly?: boolean; sourcePath?: string | null; targetPath?: string | null; detail?: string | null; diff --git a/packages/adapters/claude-local/src/server/skills.ts b/packages/adapters/claude-local/src/server/skills.ts index b6921e0d..b88cc54e 100644 --- a/packages/adapters/claude-local/src/server/skills.ts +++ b/packages/adapters/claude-local/src/server/skills.ts @@ -1,3 +1,4 @@ +import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import type { @@ -7,22 +8,42 @@ import type { } from "@paperclipai/adapter-utils"; import { readPaperclipRuntimeSkillEntries, + readInstalledSkillTargets, resolvePaperclipDesiredSkillNames, } 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 resolveClaudeSkillsHome(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"); +} + async function buildClaudeSkillSnapshot(config: Record): Promise { const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry])); const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries); const desiredSet = new Set(desiredSkills); + const skillsHome = resolveClaudeSkillsHome(config); + const installed = await readInstalledSkillTargets(skillsHome); const entries: AdapterSkillEntry[] = availableEntries.map((entry) => ({ key: entry.key, runtimeName: entry.runtimeName, desired: desiredSet.has(entry.key), managed: true, state: desiredSet.has(entry.key) ? "configured" : "available", + origin: entry.required ? "paperclip_required" : "company_managed", + originLabel: entry.required ? "Required by Paperclip" : "Managed by Paperclip", + readOnly: false, sourcePath: entry.source, targetPath: null, detail: desiredSet.has(entry.key) @@ -42,12 +63,33 @@ async function buildClaudeSkillSnapshot(config: Record): Promis desired: true, managed: true, state: "missing", + origin: "external_unknown", + originLabel: "External or unavailable", + readOnly: false, sourcePath: undefined, targetPath: undefined, detail: "Paperclip cannot find this skill in the local runtime skills directory.", }); } + for (const [name, installedEntry] of installed.entries()) { + if (availableEntries.some((entry) => entry.runtimeName === name)) continue; + entries.push({ + key: name, + runtimeName: name, + desired: false, + managed: false, + state: "external", + origin: "user_installed", + originLabel: "User-installed", + locationLabel: "~/.claude/skills", + readOnly: true, + sourcePath: null, + targetPath: installedEntry.targetPath ?? path.join(skillsHome, name), + detail: "Installed outside Paperclip management in the Claude skills home.", + }); + } + entries.sort((left, right) => left.key.localeCompare(right.key)); return { diff --git a/packages/adapters/codex-local/src/server/skills.ts b/packages/adapters/codex-local/src/server/skills.ts index b9e04a96..4fde6a70 100644 --- a/packages/adapters/codex-local/src/server/skills.ts +++ b/packages/adapters/codex-local/src/server/skills.ts @@ -3,12 +3,13 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import type { AdapterSkillContext, - AdapterSkillEntry, AdapterSkillSnapshot, } from "@paperclipai/adapter-utils"; import { + buildPersistentSkillSnapshot, ensurePaperclipSkillSymlink, readPaperclipRuntimeSkillEntries, + readInstalledSkillTargets, resolvePaperclipDesiredSkillNames, } from "@paperclipai/adapter-utils/server-utils"; import { resolveCodexHomeDir } from "./codex-home.js"; @@ -29,111 +30,22 @@ function resolveCodexSkillsHome(config: Record) { return path.join(home, "skills"); } -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 buildCodexSkillSnapshot(config: Record): Promise { const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); - const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry])); const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries); - const desiredSet = new Set(desiredSkills); const skillsHome = resolveCodexSkillsHome(config); const installed = await readInstalledSkillTargets(skillsHome); - const entries: AdapterSkillEntry[] = []; - const warnings: string[] = []; - - for (const available of availableEntries) { - const installedEntry = installed.get(available.runtimeName) ?? null; - const desired = desiredSet.has(available.key); - 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 Codex skills home."; - } - - entries.push({ - key: available.key, - runtimeName: available.runtimeName, - desired, - managed, - state, - sourcePath: available.source, - targetPath: path.join(skillsHome, available.runtimeName), - detail, - required: Boolean(available.required), - requiredReason: available.requiredReason ?? null, - }); - } - - for (const desiredSkill of desiredSkills) { - if (availableByKey.has(desiredSkill)) continue; - warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`); - entries.push({ - key: desiredSkill, - runtimeName: null, - desired: true, - managed: true, - state: "missing", - sourcePath: null, - targetPath: null, - detail: "Paperclip cannot find this skill in the local runtime skills directory.", - }); - } - - for (const [name, installedEntry] of installed.entries()) { - if (availableEntries.some((entry) => entry.runtimeName === name)) continue; - entries.push({ - key: name, - runtimeName: 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.key.localeCompare(right.key)); - - return { + return buildPersistentSkillSnapshot({ adapterType: "codex_local", - supported: true, - mode: "persistent", + availableEntries, desiredSkills, - entries, - warnings, - }; + installed, + skillsHome, + locationLabel: "$CODEX_HOME/skills", + missingDetail: "Configured but not currently linked into the Codex skills home.", + externalConflictDetail: "Skill name is occupied by an external installation.", + externalDetail: "Installed outside Paperclip management.", + }); } export async function listCodexSkills(ctx: AdapterSkillContext): Promise { diff --git a/packages/adapters/cursor-local/src/server/skills.ts b/packages/adapters/cursor-local/src/server/skills.ts index f2cca0a7..7e43c333 100644 --- a/packages/adapters/cursor-local/src/server/skills.ts +++ b/packages/adapters/cursor-local/src/server/skills.ts @@ -4,12 +4,13 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import type { AdapterSkillContext, - AdapterSkillEntry, AdapterSkillSnapshot, } from "@paperclipai/adapter-utils"; import { + buildPersistentSkillSnapshot, ensurePaperclipSkillSymlink, readPaperclipRuntimeSkillEntries, + readInstalledSkillTargets, resolvePaperclipDesiredSkillNames, } from "@paperclipai/adapter-utils/server-utils"; @@ -29,111 +30,22 @@ function resolveCursorSkillsHome(config: Record) { return path.join(home, ".cursor", "skills"); } -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 readPaperclipRuntimeSkillEntries(config, __moduleDir); - const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry])); const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries); - 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.runtimeName) ?? null; - const desired = desiredSet.has(available.key); - 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({ - key: available.key, - runtimeName: available.runtimeName, - desired, - managed, - state, - sourcePath: available.source, - targetPath: path.join(skillsHome, available.runtimeName), - detail, - required: Boolean(available.required), - requiredReason: available.requiredReason ?? null, - }); - } - - for (const desiredSkill of desiredSkills) { - if (availableByKey.has(desiredSkill)) continue; - warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`); - entries.push({ - key: desiredSkill, - runtimeName: null, - desired: true, - managed: true, - state: "missing", - sourcePath: null, - targetPath: null, - detail: "Paperclip cannot find this skill in the local runtime skills directory.", - }); - } - - for (const [name, installedEntry] of installed.entries()) { - if (availableEntries.some((entry) => entry.runtimeName === name)) continue; - entries.push({ - key: name, - runtimeName: 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.key.localeCompare(right.key)); - - return { + return buildPersistentSkillSnapshot({ adapterType: "cursor", - supported: true, - mode: "persistent", + availableEntries, desiredSkills, - entries, - warnings, - }; + installed, + skillsHome, + locationLabel: "~/.cursor/skills", + missingDetail: "Configured but not currently linked into the Cursor skills home.", + externalConflictDetail: "Skill name is occupied by an external installation.", + externalDetail: "Installed outside Paperclip management.", + }); } export async function listCursorSkills(ctx: AdapterSkillContext): Promise { diff --git a/packages/adapters/gemini-local/src/server/skills.ts b/packages/adapters/gemini-local/src/server/skills.ts index a7cec2b8..51253b33 100644 --- a/packages/adapters/gemini-local/src/server/skills.ts +++ b/packages/adapters/gemini-local/src/server/skills.ts @@ -4,12 +4,13 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import type { AdapterSkillContext, - AdapterSkillEntry, AdapterSkillSnapshot, } from "@paperclipai/adapter-utils"; import { + buildPersistentSkillSnapshot, ensurePaperclipSkillSymlink, readPaperclipRuntimeSkillEntries, + readInstalledSkillTargets, resolvePaperclipDesiredSkillNames, } from "@paperclipai/adapter-utils/server-utils"; @@ -29,111 +30,22 @@ function resolveGeminiSkillsHome(config: Record) { return path.join(home, ".gemini", "skills"); } -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 readPaperclipRuntimeSkillEntries(config, __moduleDir); - const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry])); const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries); - 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.runtimeName) ?? null; - const desired = desiredSet.has(available.key); - 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({ - key: available.key, - runtimeName: available.runtimeName, - desired, - managed, - state, - sourcePath: available.source, - targetPath: path.join(skillsHome, available.runtimeName), - detail, - required: Boolean(available.required), - requiredReason: available.requiredReason ?? null, - }); - } - - for (const desiredSkill of desiredSkills) { - if (availableByKey.has(desiredSkill)) continue; - warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`); - entries.push({ - key: desiredSkill, - runtimeName: null, - desired: true, - managed: true, - state: "missing", - sourcePath: null, - targetPath: null, - detail: "Paperclip cannot find this skill in the local runtime skills directory.", - }); - } - - for (const [name, installedEntry] of installed.entries()) { - if (availableEntries.some((entry) => entry.runtimeName === name)) continue; - entries.push({ - key: name, - runtimeName: 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.key.localeCompare(right.key)); - - return { + return buildPersistentSkillSnapshot({ adapterType: "gemini_local", - supported: true, - mode: "persistent", + availableEntries, desiredSkills, - entries, - warnings, - }; + installed, + skillsHome, + locationLabel: "~/.gemini/skills", + missingDetail: "Configured but not currently linked into the Gemini skills home.", + externalConflictDetail: "Skill name is occupied by an external installation.", + externalDetail: "Installed outside Paperclip management.", + }); } export async function listGeminiSkills(ctx: AdapterSkillContext): Promise { diff --git a/packages/adapters/opencode-local/src/server/skills.ts b/packages/adapters/opencode-local/src/server/skills.ts index e2c1a01f..12ea6068 100644 --- a/packages/adapters/opencode-local/src/server/skills.ts +++ b/packages/adapters/opencode-local/src/server/skills.ts @@ -4,12 +4,13 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import type { AdapterSkillContext, - AdapterSkillEntry, AdapterSkillSnapshot, } from "@paperclipai/adapter-utils"; import { + buildPersistentSkillSnapshot, ensurePaperclipSkillSymlink, readPaperclipRuntimeSkillEntries, + readInstalledSkillTargets, resolvePaperclipDesiredSkillNames, } from "@paperclipai/adapter-utils/server-utils"; @@ -29,114 +30,26 @@ function resolveOpenCodeSkillsHome(config: Record) { return path.join(home, ".claude", "skills"); } -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 readPaperclipRuntimeSkillEntries(config, __moduleDir); - const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry])); const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries); - 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.runtimeName) ?? null; - const desired = desiredSet.has(available.key); - 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({ - key: available.key, - runtimeName: available.runtimeName, - desired, - managed, - state, - sourcePath: available.source, - targetPath: path.join(skillsHome, available.runtimeName), - detail, - required: Boolean(available.required), - requiredReason: available.requiredReason ?? null, - }); - } - - for (const desiredSkill of desiredSkills) { - if (availableByKey.has(desiredSkill)) continue; - warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`); - entries.push({ - key: desiredSkill, - runtimeName: null, - desired: true, - managed: true, - state: "missing", - sourcePath: null, - targetPath: null, - detail: "Paperclip cannot find this skill in the local runtime skills directory.", - }); - } - - for (const [name, installedEntry] of installed.entries()) { - if (availableEntries.some((entry) => entry.runtimeName === name)) continue; - entries.push({ - key: name, - runtimeName: 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.key.localeCompare(right.key)); - - return { + return buildPersistentSkillSnapshot({ adapterType: "opencode_local", - supported: true, - mode: "persistent", + availableEntries, desiredSkills, - entries, - warnings, - }; + installed, + skillsHome, + locationLabel: "~/.claude/skills", + installedDetail: "Installed in the shared Claude/OpenCode skills home.", + missingDetail: "Configured but not currently linked into the shared Claude/OpenCode skills home.", + externalConflictDetail: "Skill name is occupied by an external installation in the shared skills home.", + externalDetail: "Installed outside Paperclip management in the shared skills home.", + warnings: [ + "OpenCode currently uses the shared Claude skills home (~/.claude/skills).", + ], + }); } export async function listOpenCodeSkills(ctx: AdapterSkillContext): Promise { diff --git a/packages/adapters/pi-local/src/server/skills.ts b/packages/adapters/pi-local/src/server/skills.ts index f57c1060..8a13da9c 100644 --- a/packages/adapters/pi-local/src/server/skills.ts +++ b/packages/adapters/pi-local/src/server/skills.ts @@ -4,12 +4,13 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import type { AdapterSkillContext, - AdapterSkillEntry, AdapterSkillSnapshot, } from "@paperclipai/adapter-utils"; import { + buildPersistentSkillSnapshot, ensurePaperclipSkillSymlink, readPaperclipRuntimeSkillEntries, + readInstalledSkillTargets, resolvePaperclipDesiredSkillNames, } from "@paperclipai/adapter-utils/server-utils"; @@ -29,111 +30,22 @@ function resolvePiSkillsHome(config: Record) { return path.join(home, ".pi", "agent", "skills"); } -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 readPaperclipRuntimeSkillEntries(config, __moduleDir); - const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry])); const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries); - 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.runtimeName) ?? null; - const desired = desiredSet.has(available.key); - 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({ - key: available.key, - runtimeName: available.runtimeName, - desired, - managed, - state, - sourcePath: available.source, - targetPath: path.join(skillsHome, available.runtimeName), - detail, - required: Boolean(available.required), - requiredReason: available.requiredReason ?? null, - }); - } - - for (const desiredSkill of desiredSkills) { - if (availableByKey.has(desiredSkill)) continue; - warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`); - entries.push({ - key: desiredSkill, - runtimeName: null, - desired: true, - managed: true, - state: "missing", - sourcePath: null, - targetPath: null, - detail: "Paperclip cannot find this skill in the local runtime skills directory.", - }); - } - - for (const [name, installedEntry] of installed.entries()) { - if (availableEntries.some((entry) => entry.runtimeName === name)) continue; - entries.push({ - key: name, - runtimeName: 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.key.localeCompare(right.key)); - - return { + return buildPersistentSkillSnapshot({ adapterType: "pi_local", - supported: true, - mode: "persistent", + availableEntries, desiredSkills, - entries, - warnings, - }; + installed, + skillsHome, + locationLabel: "~/.pi/agent/skills", + missingDetail: "Configured but not currently linked into the Pi skills home.", + externalConflictDetail: "Skill name is occupied by an external installation.", + externalDetail: "Installed outside Paperclip management.", + }); } export async function listPiSkills(ctx: AdapterSkillContext): Promise { diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 3767883b..f353f5ea 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -141,6 +141,7 @@ export type { CompanySkillFileUpdateRequest, AgentSkillSyncMode, AgentSkillState, + AgentSkillOrigin, AgentSkillEntry, AgentSkillSnapshot, AgentSkillSyncRequest, diff --git a/packages/shared/src/types/adapter-skills.ts b/packages/shared/src/types/adapter-skills.ts index e5fcd60d..2699bef0 100644 --- a/packages/shared/src/types/adapter-skills.ts +++ b/packages/shared/src/types/adapter-skills.ts @@ -8,6 +8,12 @@ export type AgentSkillState = | "stale" | "external"; +export type AgentSkillOrigin = + | "company_managed" + | "paperclip_required" + | "user_installed" + | "external_unknown"; + export interface AgentSkillEntry { key: string; runtimeName: string | null; @@ -16,6 +22,10 @@ export interface AgentSkillEntry { required?: boolean; requiredReason?: string | null; state: AgentSkillState; + origin?: AgentSkillOrigin; + originLabel?: string | null; + locationLabel?: string | null; + readOnly?: boolean; sourcePath?: string | null; targetPath?: string | null; detail?: string | null; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index af9ef899..2080ffa6 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -24,6 +24,7 @@ export type { export type { AgentSkillSyncMode, AgentSkillState, + AgentSkillOrigin, AgentSkillEntry, AgentSkillSnapshot, AgentSkillSyncRequest, diff --git a/packages/shared/src/validators/adapter-skills.ts b/packages/shared/src/validators/adapter-skills.ts index fd81fcb8..84fa0f72 100644 --- a/packages/shared/src/validators/adapter-skills.ts +++ b/packages/shared/src/validators/adapter-skills.ts @@ -9,6 +9,13 @@ export const agentSkillStateSchema = z.enum([ "external", ]); +export const agentSkillOriginSchema = z.enum([ + "company_managed", + "paperclip_required", + "user_installed", + "external_unknown", +]); + export const agentSkillSyncModeSchema = z.enum([ "unsupported", "persistent", @@ -23,6 +30,10 @@ export const agentSkillEntrySchema = z.object({ required: z.boolean().optional(), requiredReason: z.string().nullable().optional(), state: agentSkillStateSchema, + origin: agentSkillOriginSchema.optional(), + originLabel: z.string().nullable().optional(), + locationLabel: z.string().nullable().optional(), + readOnly: z.boolean().optional(), sourcePath: z.string().nullable().optional(), targetPath: z.string().nullable().optional(), detail: z.string().nullable().optional(), diff --git a/server/src/__tests__/agent-skill-contract.test.ts b/server/src/__tests__/agent-skill-contract.test.ts new file mode 100644 index 00000000..57733806 --- /dev/null +++ b/server/src/__tests__/agent-skill-contract.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { + agentSkillEntrySchema, + agentSkillSnapshotSchema, +} from "@paperclipai/shared/validators/adapter-skills"; + +describe("agent skill contract", () => { + it("accepts optional provenance metadata on skill entries", () => { + expect(agentSkillEntrySchema.parse({ + key: "crack-python", + runtimeName: "crack-python", + desired: false, + managed: false, + state: "external", + origin: "user_installed", + originLabel: "User-installed", + locationLabel: "~/.claude/skills", + readOnly: true, + detail: "Installed outside Paperclip management.", + })).toMatchObject({ + origin: "user_installed", + locationLabel: "~/.claude/skills", + readOnly: true, + }); + }); + + it("remains backward compatible with snapshots that omit provenance metadata", () => { + expect(agentSkillSnapshotSchema.parse({ + adapterType: "claude_local", + supported: true, + mode: "ephemeral", + desiredSkills: [], + entries: [{ + key: "paperclipai/paperclip/paperclip", + runtimeName: "paperclip", + desired: true, + managed: true, + state: "configured", + }], + warnings: [], + })).toMatchObject({ + adapterType: "claude_local", + entries: [{ + key: "paperclipai/paperclip/paperclip", + state: "configured", + }], + }); + }); +}); diff --git a/server/src/__tests__/claude-local-skill-sync.test.ts b/server/src/__tests__/claude-local-skill-sync.test.ts index b8b761b2..7f47cba0 100644 --- a/server/src/__tests__/claude-local-skill-sync.test.ts +++ b/server/src/__tests__/claude-local-skill-sync.test.ts @@ -1,12 +1,32 @@ -import { describe, expect, it } from "vitest"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; import { listClaudeSkills, syncClaudeSkills, } from "@paperclipai/adapter-claude-local/server"; +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("claude local skill sync", () => { const paperclipKey = "paperclipai/paperclip/paperclip"; const createAgentKey = "paperclipai/paperclip/paperclip-create-agent"; + const cleanupDirs = new Set(); + + afterEach(async () => { + await Promise.all(Array.from(cleanupDirs).map((dir) => fs.rm(dir, { recursive: true, force: true }))); + cleanupDirs.clear(); + }); it("defaults to mounting all built-in Paperclip skills when no explicit selection exists", async () => { const snapshot = await listClaudeSkills({ @@ -58,4 +78,33 @@ describe("claude local skill sync", () => { expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured"); expect(snapshot.entries.find((entry) => entry.key === "paperclip")).toBeUndefined(); }); + + it("shows host-level user-installed Claude skills as read-only external entries", async () => { + const home = await makeTempDir("paperclip-claude-user-skills-"); + cleanupDirs.add(home); + await createSkillDir(path.join(home, ".claude", "skills"), "crack-python"); + + const snapshot = await listClaudeSkills({ + agentId: "agent-4", + companyId: "company-1", + adapterType: "claude_local", + config: { + env: { + HOME: home, + }, + }, + }); + + expect(snapshot.entries).toContainEqual(expect.objectContaining({ + key: "crack-python", + runtimeName: "crack-python", + state: "external", + managed: false, + origin: "user_installed", + originLabel: "User-installed", + locationLabel: "~/.claude/skills", + readOnly: true, + detail: "Installed outside Paperclip management in the Claude skills home.", + })); + }); }); diff --git a/server/src/__tests__/codex-local-skill-sync.test.ts b/server/src/__tests__/codex-local-skill-sync.test.ts index a78d37e7..0ea2b50c 100644 --- a/server/src/__tests__/codex-local-skill-sync.test.ts +++ b/server/src/__tests__/codex-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("codex local skill sync", () => { const paperclipKey = "paperclipai/paperclip/paperclip"; const cleanupDirs = new Set(); @@ -111,4 +118,35 @@ describe("codex local skill sync", () => { expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("missing"); expect(snapshot.entries.find((entry) => entry.key === "paperclip")).toBeUndefined(); }); + + it("reports unmanaged user-installed Codex skills with provenance metadata", async () => { + const codexHome = await makeTempDir("paperclip-codex-user-skills-"); + cleanupDirs.add(codexHome); + + const externalSkillDir = await createSkillDir(path.join(codexHome, "skills"), "crack-python"); + expect(externalSkillDir).toContain(path.join(codexHome, "skills")); + + const snapshot = await listCodexSkills({ + agentId: "agent-4", + companyId: "company-1", + adapterType: "codex_local", + config: { + env: { + CODEX_HOME: codexHome, + }, + }, + }); + + expect(snapshot.entries).toContainEqual(expect.objectContaining({ + key: "crack-python", + runtimeName: "crack-python", + state: "external", + managed: false, + origin: "user_installed", + originLabel: "User-installed", + locationLabel: "$CODEX_HOME/skills", + readOnly: true, + detail: "Installed outside Paperclip management.", + })); + }); }); diff --git a/server/src/adapters/types.ts b/server/src/adapters/types.ts index 8a0219ec..7df54741 100644 --- a/server/src/adapters/types.ts +++ b/server/src/adapters/types.ts @@ -16,6 +16,7 @@ export type { AdapterEnvironmentTestContext, AdapterSkillSyncMode, AdapterSkillState, + AdapterSkillOrigin, AdapterSkillEntry, AdapterSkillSnapshot, AdapterSkillContext, diff --git a/ui/src/lib/agent-skills-state.test.ts b/ui/src/lib/agent-skills-state.test.ts index 883ef860..c3f32579 100644 --- a/ui/src/lib/agent-skills-state.test.ts +++ b/ui/src/lib/agent-skills-state.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { applyAgentSkillSnapshot } from "./agent-skills-state"; +import { applyAgentSkillSnapshot, isReadOnlyUnmanagedSkillEntry } from "./agent-skills-state"; describe("applyAgentSkillSnapshot", () => { it("hydrates the initial snapshot without arming autosave", () => { @@ -55,4 +55,36 @@ describe("applyAgentSkillSnapshot", () => { shouldSkipAutosave: true, }); }); + + it("treats user-installed entries outside the company library as read-only unmanaged skills", () => { + expect(isReadOnlyUnmanagedSkillEntry({ + key: "crack-python", + runtimeName: "crack-python", + desired: false, + managed: false, + state: "external", + origin: "user_installed", + }, new Set(["paperclip"]))).toBe(true); + }); + + it("keeps company-library entries in the managed section even when the adapter reports an external conflict", () => { + expect(isReadOnlyUnmanagedSkillEntry({ + key: "paperclip", + runtimeName: "paperclip", + desired: true, + managed: false, + state: "external", + origin: "company_managed", + }, new Set(["paperclip"]))).toBe(false); + }); + + it("falls back to legacy snapshots that only mark unmanaged external entries", () => { + expect(isReadOnlyUnmanagedSkillEntry({ + key: "legacy-external", + runtimeName: "legacy-external", + desired: false, + managed: false, + state: "external", + }, new Set())).toBe(true); + }); }); diff --git a/ui/src/lib/agent-skills-state.ts b/ui/src/lib/agent-skills-state.ts index 640b3819..d5a697a6 100644 --- a/ui/src/lib/agent-skills-state.ts +++ b/ui/src/lib/agent-skills-state.ts @@ -1,3 +1,5 @@ +import type { AgentSkillEntry } from "@paperclipai/shared"; + export interface AgentSkillDraftState { draft: string[]; lastSaved: string[]; @@ -27,3 +29,12 @@ export function applyAgentSkillSnapshot( shouldSkipAutosave: shouldReplaceDraft, }; } + +export function isReadOnlyUnmanagedSkillEntry( + entry: AgentSkillEntry, + companySkillKeys: Set, +): boolean { + if (companySkillKeys.has(entry.key)) return false; + if (entry.origin === "user_installed" || entry.origin === "external_unknown") return true; + return entry.managed === false && entry.state === "external"; +}