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 <noreply@paperclip.ing>
This commit is contained in:
dotta
2026-03-18 14:21:50 -05:00
parent 58d7f59477
commit cfc53bf96b
19 changed files with 497 additions and 501 deletions

View File

@@ -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",
}],
});
});
});

View File

@@ -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<string> {
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<string>();
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.",
}));
});
});

View File

@@ -11,6 +11,13 @@ async function makeTempDir(prefix: string): Promise<string> {
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<string>();
@@ -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.",
}));
});
});

View File

@@ -16,6 +16,7 @@ export type {
AdapterEnvironmentTestContext,
AdapterSkillSyncMode,
AdapterSkillState,
AdapterSkillOrigin,
AdapterSkillEntry,
AdapterSkillSnapshot,
AdapterSkillContext,