Fix runtime skill injection across adapters

This commit is contained in:
Dotta
2026-03-15 07:05:01 -05:00
parent 82f253c310
commit 7675fd0856
27 changed files with 506 additions and 222 deletions

View File

@@ -16,6 +16,7 @@ describe("claude local skill sync", () => {
expect(snapshot.mode).toBe("ephemeral");
expect(snapshot.supported).toBe(true);
expect(snapshot.desiredSkills).toContain("paperclip");
expect(snapshot.entries.find((entry) => entry.name === "paperclip")?.required).toBe(true);
expect(snapshot.entries.find((entry) => entry.name === "paperclip")?.state).toBe("configured");
});
@@ -31,8 +32,8 @@ describe("claude local skill sync", () => {
},
}, ["paperclip"]);
expect(snapshot.desiredSkills).toEqual(["paperclip"]);
expect(snapshot.desiredSkills).toContain("paperclip");
expect(snapshot.entries.find((entry) => entry.name === "paperclip")?.state).toBe("configured");
expect(snapshot.entries.find((entry) => entry.name === "paperclip-create-agent")?.state).toBe("available");
expect(snapshot.entries.find((entry) => entry.name === "paperclip-create-agent")?.state).toBe("configured");
});
});

View File

@@ -39,7 +39,8 @@ describe("codex local skill sync", () => {
const before = await listCodexSkills(ctx);
expect(before.mode).toBe("persistent");
expect(before.desiredSkills).toEqual(["paperclip"]);
expect(before.desiredSkills).toContain("paperclip");
expect(before.entries.find((entry) => entry.name === "paperclip")?.required).toBe(true);
expect(before.entries.find((entry) => entry.name === "paperclip")?.state).toBe("missing");
const after = await syncCodexSkills(ctx, ["paperclip"]);
@@ -47,7 +48,7 @@ describe("codex local skill sync", () => {
expect((await fs.lstat(path.join(codexHome, "skills", "paperclip"))).isSymbolicLink()).toBe(true);
});
it("removes stale managed Paperclip skills when the desired set is emptied", async () => {
it("keeps required bundled Paperclip skills installed even when the desired set is emptied", async () => {
const codexHome = await makeTempDir("paperclip-codex-skill-prune-");
cleanupDirs.add(codexHome);
@@ -80,8 +81,8 @@ describe("codex local skill sync", () => {
} as const;
const after = await syncCodexSkills(clearedCtx, []);
expect(after.desiredSkills).toEqual([]);
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("available");
await expect(fs.lstat(path.join(codexHome, "skills", "paperclip"))).rejects.toThrow();
expect(after.desiredSkills).toContain("paperclip");
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("installed");
expect((await fs.lstat(path.join(codexHome, "skills", "paperclip"))).isSymbolicLink()).toBe(true);
});
});

View File

@@ -46,6 +46,13 @@ type CapturePayload = {
paperclipEnvKeys: string[];
};
async function createSkillDir(root: string, name: string) {
const skillDir = path.join(root, name);
await fs.mkdir(skillDir, { recursive: true });
await fs.writeFile(path.join(skillDir, "SKILL.md"), `---\nname: ${name}\n---\n`, "utf8");
return skillDir;
}
describe("cursor execute", () => {
it("injects paperclip env vars and prompt note by default", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-cursor-execute-"));
@@ -179,4 +186,77 @@ describe("cursor execute", () => {
await fs.rm(root, { recursive: true, force: true });
}
});
it("injects company-library runtime skills into the Cursor skills home before execution", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-cursor-execute-runtime-skill-"));
const workspace = path.join(root, "workspace");
const commandPath = path.join(root, "agent");
const runtimeSkillsRoot = path.join(root, "runtime-skills");
await fs.mkdir(workspace, { recursive: true });
await writeFakeCursorCommand(commandPath);
const paperclipDir = await createSkillDir(runtimeSkillsRoot, "paperclip");
const asciiHeartDir = await createSkillDir(runtimeSkillsRoot, "ascii-heart");
const previousHome = process.env.HOME;
process.env.HOME = root;
try {
const result = await execute({
runId: "run-3",
agent: {
id: "agent-1",
companyId: "company-1",
name: "Cursor Coder",
adapterType: "cursor",
adapterConfig: {},
},
runtime: {
sessionId: null,
sessionParams: null,
sessionDisplayId: null,
taskKey: null,
},
config: {
command: commandPath,
cwd: workspace,
model: "auto",
paperclipRuntimeSkills: [
{
name: "paperclip",
source: paperclipDir,
required: true,
requiredReason: "Bundled Paperclip skills are always available for local adapters.",
},
{
name: "ascii-heart",
source: asciiHeartDir,
},
],
paperclipSkillSync: {
desiredSkills: ["ascii-heart"],
},
promptTemplate: "Follow the paperclip heartbeat.",
},
context: {},
authToken: "run-jwt-token",
onLog: async () => {},
onMeta: async () => {},
});
expect(result.exitCode).toBe(0);
expect(result.errorMessage).toBeNull();
expect((await fs.lstat(path.join(root, ".cursor", "skills", "ascii-heart"))).isSymbolicLink()).toBe(true);
expect(await fs.realpath(path.join(root, ".cursor", "skills", "ascii-heart"))).toBe(
await fs.realpath(asciiHeartDir),
);
} finally {
if (previousHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = previousHome;
}
await fs.rm(root, { recursive: true, force: true });
}
});
});

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("cursor local skill sync", () => {
const cleanupDirs = new Set<string>();
@@ -39,7 +46,8 @@ describe("cursor local skill sync", () => {
const before = await listCursorSkills(ctx);
expect(before.mode).toBe("persistent");
expect(before.desiredSkills).toEqual(["paperclip"]);
expect(before.desiredSkills).toContain("paperclip");
expect(before.entries.find((entry) => entry.name === "paperclip")?.required).toBe(true);
expect(before.entries.find((entry) => entry.name === "paperclip")?.state).toBe("missing");
const after = await syncCursorSkills(ctx, ["paperclip"]);
@@ -47,7 +55,53 @@ describe("cursor local skill sync", () => {
expect((await fs.lstat(path.join(home, ".cursor", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
});
it("removes stale managed Paperclip skills when the desired set is emptied", async () => {
it("recognizes company-library runtime skills supplied outside the bundled Paperclip directory", async () => {
const home = await makeTempDir("paperclip-cursor-runtime-skills-home-");
const runtimeSkills = await makeTempDir("paperclip-cursor-runtime-skills-src-");
cleanupDirs.add(home);
cleanupDirs.add(runtimeSkills);
const paperclipDir = await createSkillDir(runtimeSkills, "paperclip");
const asciiHeartDir = await createSkillDir(runtimeSkills, "ascii-heart");
const ctx = {
agentId: "agent-3",
companyId: "company-1",
adapterType: "cursor",
config: {
env: {
HOME: home,
},
paperclipRuntimeSkills: [
{
name: "paperclip",
source: paperclipDir,
required: true,
requiredReason: "Bundled Paperclip skills are always available for local adapters.",
},
{
name: "ascii-heart",
source: asciiHeartDir,
},
],
paperclipSkillSync: {
desiredSkills: ["ascii-heart"],
},
},
} as const;
const before = await listCursorSkills(ctx);
expect(before.warnings).toEqual([]);
expect(before.desiredSkills).toEqual(["paperclip", "ascii-heart"]);
expect(before.entries.find((entry) => entry.name === "ascii-heart")?.state).toBe("missing");
const after = await syncCursorSkills(ctx, ["ascii-heart"]);
expect(after.warnings).toEqual([]);
expect(after.entries.find((entry) => entry.name === "ascii-heart")?.state).toBe("installed");
expect((await fs.lstat(path.join(home, ".cursor", "skills", "ascii-heart"))).isSymbolicLink()).toBe(true);
});
it("keeps required bundled Paperclip skills installed even when the desired set is emptied", async () => {
const home = await makeTempDir("paperclip-cursor-skill-prune-");
cleanupDirs.add(home);
@@ -80,8 +134,8 @@ describe("cursor local skill sync", () => {
} as const;
const after = await syncCursorSkills(clearedCtx, []);
expect(after.desiredSkills).toEqual([]);
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("available");
await expect(fs.lstat(path.join(home, ".cursor", "skills", "paperclip"))).rejects.toThrow();
expect(after.desiredSkills).toContain("paperclip");
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("installed");
expect((await fs.lstat(path.join(home, ".cursor", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
});
});

View File

@@ -39,7 +39,8 @@ describe("gemini local skill sync", () => {
const before = await listGeminiSkills(ctx);
expect(before.mode).toBe("persistent");
expect(before.desiredSkills).toEqual(["paperclip"]);
expect(before.desiredSkills).toContain("paperclip");
expect(before.entries.find((entry) => entry.name === "paperclip")?.required).toBe(true);
expect(before.entries.find((entry) => entry.name === "paperclip")?.state).toBe("missing");
const after = await syncGeminiSkills(ctx, ["paperclip"]);
@@ -47,7 +48,7 @@ describe("gemini local skill sync", () => {
expect((await fs.lstat(path.join(home, ".gemini", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
});
it("removes stale managed Paperclip skills when the desired set is emptied", async () => {
it("keeps required bundled Paperclip skills installed even when the desired set is emptied", async () => {
const home = await makeTempDir("paperclip-gemini-skill-prune-");
cleanupDirs.add(home);
@@ -80,8 +81,8 @@ describe("gemini local skill sync", () => {
} as const;
const after = await syncGeminiSkills(clearedCtx, []);
expect(after.desiredSkills).toEqual([]);
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("available");
await expect(fs.lstat(path.join(home, ".gemini", "skills", "paperclip"))).rejects.toThrow();
expect(after.desiredSkills).toContain("paperclip");
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("installed");
expect((await fs.lstat(path.join(home, ".gemini", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
});
});

View File

@@ -40,7 +40,8 @@ describe("opencode local skill sync", () => {
const before = await listOpenCodeSkills(ctx);
expect(before.mode).toBe("persistent");
expect(before.warnings).toContain("OpenCode currently uses the shared Claude skills home (~/.claude/skills).");
expect(before.desiredSkills).toEqual(["paperclip"]);
expect(before.desiredSkills).toContain("paperclip");
expect(before.entries.find((entry) => entry.name === "paperclip")?.required).toBe(true);
expect(before.entries.find((entry) => entry.name === "paperclip")?.state).toBe("missing");
const after = await syncOpenCodeSkills(ctx, ["paperclip"]);
@@ -48,7 +49,7 @@ describe("opencode local skill sync", () => {
expect((await fs.lstat(path.join(home, ".claude", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
});
it("removes stale managed Paperclip skills when the desired set is emptied", async () => {
it("keeps required bundled Paperclip skills installed even when the desired set is emptied", async () => {
const home = await makeTempDir("paperclip-opencode-skill-prune-");
cleanupDirs.add(home);
@@ -81,8 +82,8 @@ describe("opencode local skill sync", () => {
} as const;
const after = await syncOpenCodeSkills(clearedCtx, []);
expect(after.desiredSkills).toEqual([]);
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("available");
await expect(fs.lstat(path.join(home, ".claude", "skills", "paperclip"))).rejects.toThrow();
expect(after.desiredSkills).toContain("paperclip");
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("installed");
expect((await fs.lstat(path.join(home, ".claude", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
});
});

View File

@@ -39,7 +39,8 @@ describe("pi local skill sync", () => {
const before = await listPiSkills(ctx);
expect(before.mode).toBe("persistent");
expect(before.desiredSkills).toEqual(["paperclip"]);
expect(before.desiredSkills).toContain("paperclip");
expect(before.entries.find((entry) => entry.name === "paperclip")?.required).toBe(true);
expect(before.entries.find((entry) => entry.name === "paperclip")?.state).toBe("missing");
const after = await syncPiSkills(ctx, ["paperclip"]);
@@ -47,7 +48,7 @@ describe("pi local skill sync", () => {
expect((await fs.lstat(path.join(home, ".pi", "agent", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
});
it("removes stale managed Paperclip skills when the desired set is emptied", async () => {
it("keeps required bundled Paperclip skills installed even when the desired set is emptied", async () => {
const home = await makeTempDir("paperclip-pi-skill-prune-");
cleanupDirs.add(home);
@@ -80,8 +81,8 @@ describe("pi local skill sync", () => {
} as const;
const after = await syncPiSkills(clearedCtx, []);
expect(after.desiredSkills).toEqual([]);
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("available");
await expect(fs.lstat(path.join(home, ".pi", "agent", "skills", "paperclip"))).rejects.toThrow();
expect(after.desiredSkills).toContain("paperclip");
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("installed");
expect((await fs.lstat(path.join(home, ".pi", "agent", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
});
});

View File

@@ -29,6 +29,7 @@ import {
agentService,
accessService,
approvalService,
companySkillService,
heartbeatService,
issueApprovalService,
issueService,
@@ -66,6 +67,7 @@ export function agentRoutes(db: Db) {
const heartbeat = heartbeatService(db);
const issueApprovalsSvc = issueApprovalService(db);
const secretsSvc = secretService(db);
const companySkills = companySkillService(db);
const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true";
function canCreateAgents(agent: { role: string; permissions: Record<string, unknown> | null | undefined }) {
@@ -354,6 +356,14 @@ export function agentRoutes(db: Db) {
};
}
async function buildRuntimeSkillConfig(companyId: string, config: Record<string, unknown>) {
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(companyId);
return {
...config,
paperclipRuntimeSkills: runtimeSkillEntries,
};
}
function redactForRestrictedAgentView(agent: Awaited<ReturnType<typeof svc.getById>>) {
if (!agent) return null;
return {
@@ -493,7 +503,9 @@ export function agentRoutes(db: Db) {
const preference = readPaperclipSkillSyncPreference(
agent.adapterConfig as Record<string, unknown>,
);
res.json(buildUnsupportedSkillSnapshot(agent.adapterType, preference.desiredSkills));
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId);
const requiredSkills = runtimeSkillEntries.filter((entry) => entry.required).map((entry) => entry.name);
res.json(buildUnsupportedSkillSnapshot(agent.adapterType, Array.from(new Set([...requiredSkills, ...preference.desiredSkills]))));
return;
}
@@ -501,11 +513,12 @@ export function agentRoutes(db: Db) {
agent.companyId,
agent.adapterConfig,
);
const runtimeSkillConfig = await buildRuntimeSkillConfig(agent.companyId, runtimeConfig);
const snapshot = await adapter.listSkills({
agentId: agent.id,
companyId: agent.companyId,
adapterType: agent.adapterType,
config: runtimeConfig,
config: runtimeSkillConfig,
});
res.json(snapshot);
});
@@ -522,13 +535,16 @@ export function agentRoutes(db: Db) {
}
await assertCanUpdateAgent(req, agent);
const desiredSkills = Array.from(
const requestedSkills = Array.from(
new Set(
(req.body.desiredSkills as string[])
.map((value) => value.trim())
.filter(Boolean),
),
);
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId);
const requiredSkills = runtimeSkillEntries.filter((entry) => entry.required).map((entry) => entry.name);
const desiredSkills = Array.from(new Set([...requiredSkills, ...requestedSkills]));
const nextAdapterConfig = writePaperclipSkillSyncPreference(
agent.adapterConfig as Record<string, unknown>,
desiredSkills,
@@ -553,19 +569,23 @@ export function agentRoutes(db: Db) {
updated.companyId,
updated.adapterConfig,
);
const runtimeSkillConfig = {
...runtimeConfig,
paperclipRuntimeSkills: runtimeSkillEntries,
};
const snapshot = adapter?.syncSkills
? await adapter.syncSkills({
agentId: updated.id,
companyId: updated.companyId,
adapterType: updated.adapterType,
config: runtimeConfig,
config: runtimeSkillConfig,
}, desiredSkills)
: adapter?.listSkills
? await adapter.listSkills({
agentId: updated.id,
companyId: updated.companyId,
adapterType: updated.adapterType,
config: runtimeConfig,
config: runtimeSkillConfig,
})
: buildUnsupportedSkillSnapshot(updated.adapterType, desiredSkills);

View File

@@ -4,6 +4,8 @@ import { fileURLToPath } from "node:url";
import { and, asc, eq } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { companySkills } from "@paperclipai/db";
import { readPaperclipSkillSyncPreference } from "@paperclipai/adapter-utils/server-utils";
import type { PaperclipSkillEntry } from "@paperclipai/adapter-utils/server-utils";
import type {
CompanySkill,
CompanySkillCreateRequest,
@@ -20,7 +22,6 @@ import type {
CompanySkillUsageAgent,
} from "@paperclipai/shared";
import { normalizeAgentUrlKey } from "@paperclipai/shared";
import { readPaperclipSkillSyncPreference } from "@paperclipai/adapter-utils/server-utils";
import { findServerAdapter } from "../adapters/index.js";
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
import { notFound, unprocessable } from "../errors.js";
@@ -959,11 +960,15 @@ export function companySkillService(db: Db) {
agent.companyId,
agent.adapterConfig as Record<string, unknown>,
);
const runtimeSkillEntries = await listRuntimeSkillEntries(agent.companyId);
const snapshot = await adapter.listSkills({
agentId: agent.id,
companyId: agent.companyId,
adapterType: agent.adapterType,
config: runtimeConfig,
config: {
...runtimeConfig,
paperclipRuntimeSkills: runtimeSkillEntries,
},
});
actualState = snapshot.entries.find((entry) => entry.name === slug)?.state
?? (snapshot.supported ? "missing" : "unsupported");
@@ -1219,6 +1224,56 @@ export function companySkillService(db: Db) {
return skillDir;
}
async function materializeRuntimeSkillFiles(companyId: string, skill: CompanySkill) {
const runtimeRoot = path.resolve(resolveManagedSkillsRoot(companyId), "__runtime__");
const skillDir = path.resolve(runtimeRoot, skill.slug);
await fs.rm(skillDir, { recursive: true, force: true });
await fs.mkdir(skillDir, { recursive: true });
for (const entry of skill.fileInventory) {
const detail = await readFile(companyId, skill.id, entry.path).catch(() => null);
if (!detail) continue;
const targetPath = path.resolve(skillDir, entry.path);
await fs.mkdir(path.dirname(targetPath), { recursive: true });
await fs.writeFile(targetPath, detail.content, "utf8");
}
return skillDir;
}
async function listRuntimeSkillEntries(companyId: string): Promise<PaperclipSkillEntry[]> {
await ensureBundledSkills(companyId);
const rows = await db
.select()
.from(companySkills)
.where(eq(companySkills.companyId, companyId))
.orderBy(asc(companySkills.name), asc(companySkills.slug));
const out: PaperclipSkillEntry[] = [];
for (const row of rows) {
const skill = toCompanySkill(row);
const sourceKind = asString(getSkillMeta(skill).sourceKind);
let source = normalizeSkillDirectory(skill);
if (!source) {
source = await materializeRuntimeSkillFiles(companyId, skill).catch(() => null);
}
if (!source) continue;
const required = sourceKind === "paperclip_bundled";
out.push({
name: skill.slug,
source,
required,
requiredReason: required
? "Bundled Paperclip skills are always available for local adapters."
: null,
});
}
out.sort((left, right) => left.name.localeCompare(right.name));
return out;
}
async function importPackageFiles(companyId: string, files: Record<string, string>): Promise<CompanySkill[]> {
await ensureBundledSkills(companyId);
const normalizedFiles = normalizePackageFileMap(files);
@@ -1330,5 +1385,6 @@ export function companySkillService(db: Db) {
importFromSource,
importPackageFiles,
installUpdate,
listRuntimeSkillEntries,
};
}

View File

@@ -22,6 +22,7 @@ import type { AdapterExecutionResult, AdapterInvocationMeta, AdapterSessionCodec
import { createLocalAgentJwt } from "../agent-auth-jwt.js";
import { parseObject, asBoolean, asNumber, appendWithCap, MAX_EXCERPT_BYTES } from "../adapters/utils.js";
import { costService } from "./costs.js";
import { companySkillService } from "./company-skills.js";
import { secretService } from "./secrets.js";
import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js";
import { summarizeHeartbeatRunResultJson } from "./heartbeat-run-summary.js";
@@ -555,6 +556,7 @@ function resolveNextSessionState(input: {
export function heartbeatService(db: Db) {
const runLogStore = getRunLogStore();
const secretsSvc = secretService(db);
const companySkills = companySkillService(db);
const issuesSvc = issueService(db);
const activeRunExecutions = new Set<string>();
@@ -1463,6 +1465,11 @@ export function heartbeatService(db: Db) {
agent.companyId,
mergedConfig,
);
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId);
const runtimeConfig = {
...resolvedConfig,
paperclipRuntimeSkills: runtimeSkillEntries,
};
const issueRef = issueId
? await db
.select({
@@ -1761,7 +1768,7 @@ export function heartbeatService(db: Db) {
runId: run.id,
agent,
runtime: runtimeForAdapter,
config: resolvedConfig,
config: runtimeConfig,
context,
onLog,
onMeta: onAdapterMeta,