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

@@ -40,6 +40,8 @@ const PAPERCLIP_SKILL_ROOT_RELATIVE_CANDIDATES = [
export interface PaperclipSkillEntry {
name: string;
source: string;
required?: boolean;
requiredReason?: string | null;
}
function normalizePathSlashes(value: string): string {
@@ -306,12 +308,45 @@ export async function listPaperclipSkillEntries(
.map((entry) => ({
name: entry.name,
source: path.join(root, entry.name),
required: true,
requiredReason: "Bundled Paperclip skills are always available for local adapters.",
}));
} catch {
return [];
}
}
function normalizeConfiguredPaperclipRuntimeSkills(value: unknown): PaperclipSkillEntry[] {
if (!Array.isArray(value)) return [];
const out: PaperclipSkillEntry[] = [];
for (const rawEntry of value) {
const entry = parseObject(rawEntry);
const name = asString(entry.name, "").trim();
const source = asString(entry.source, "").trim();
if (!name || !source) continue;
out.push({
name,
source,
required: asBoolean(entry.required, false),
requiredReason:
typeof entry.requiredReason === "string" && entry.requiredReason.trim().length > 0
? entry.requiredReason.trim()
: null,
});
}
return out;
}
export async function readPaperclipRuntimeSkillEntries(
config: Record<string, unknown>,
moduleDir: string,
additionalCandidates: string[] = [],
): Promise<PaperclipSkillEntry[]> {
const configuredEntries = normalizeConfiguredPaperclipRuntimeSkills(config.paperclipRuntimeSkills);
if (configuredEntries.length > 0) return configuredEntries;
return listPaperclipSkillEntries(moduleDir, additionalCandidates);
}
export async function readPaperclipSkillMarkdown(
moduleDir: string,
skillName: string,
@@ -352,6 +387,20 @@ export function readPaperclipSkillSyncPreference(config: Record<string, unknown>
};
}
export function resolvePaperclipDesiredSkillNames(
config: Record<string, unknown>,
availableEntries: Array<{ name: string; required?: boolean }>,
): string[] {
const preference = readPaperclipSkillSyncPreference(config);
const requiredSkills = availableEntries
.filter((entry) => entry.required)
.map((entry) => entry.name);
if (!preference.explicit) {
return Array.from(new Set(requiredSkills));
}
return Array.from(new Set([...requiredSkills, ...preference.desiredSkills]));
}
export function writePaperclipSkillSyncPreference(
config: Record<string, unknown>,
desiredSkills: string[],

View File

@@ -152,6 +152,8 @@ export interface AdapterSkillEntry {
name: string;
desired: boolean;
managed: boolean;
required?: boolean;
requiredReason?: string | null;
state: AdapterSkillState;
sourcePath?: string | null;
targetPath?: string | null;

View File

@@ -12,7 +12,7 @@ import {
parseObject,
parseJson,
buildPaperclipEnv,
listPaperclipSkillEntries,
readPaperclipRuntimeSkillEntries,
joinPromptSections,
redactEnvForLogs,
ensureAbsoluteDirectory,
@@ -41,11 +41,11 @@ async function buildSkillsDir(config: Record<string, unknown>): Promise<string>
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skills-"));
const target = path.join(tmp, ".claude", "skills");
await fs.mkdir(target, { recursive: true });
const availableEntries = await listPaperclipSkillEntries(__moduleDir);
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const desiredNames = new Set(
resolveClaudeDesiredSkillNames(
config,
availableEntries.map((entry) => entry.name),
availableEntries,
),
);
for (const entry of availableEntries) {

View File

@@ -6,24 +6,16 @@ import type {
AdapterSkillSnapshot,
} from "@paperclipai/adapter-utils";
import {
listPaperclipSkillEntries,
readPaperclipSkillSyncPreference,
readPaperclipRuntimeSkillEntries,
resolvePaperclipDesiredSkillNames,
} from "@paperclipai/adapter-utils/server-utils";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
function resolveDesiredSkillNames(config: Record<string, unknown>, availableSkillNames: string[]) {
const preference = readPaperclipSkillSyncPreference(config);
return preference.explicit ? preference.desiredSkills : availableSkillNames;
}
async function buildClaudeSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
const availableEntries = await listPaperclipSkillEntries(__moduleDir);
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const availableByName = new Map(availableEntries.map((entry) => [entry.name, entry]));
const desiredSkills = resolveDesiredSkillNames(
config,
availableEntries.map((entry) => entry.name),
);
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
const desiredSet = new Set(desiredSkills);
const entries: AdapterSkillEntry[] = availableEntries.map((entry) => ({
name: entry.name,
@@ -35,6 +27,8 @@ async function buildClaudeSkillSnapshot(config: Record<string, unknown>): Promis
detail: desiredSet.has(entry.name)
? "Will be mounted into the ephemeral Claude skill directory on the next run."
: null,
required: Boolean(entry.required),
requiredReason: entry.requiredReason ?? null,
}));
const warnings: string[] = [];
@@ -77,7 +71,7 @@ export async function syncClaudeSkills(
export function resolveClaudeDesiredSkillNames(
config: Record<string, unknown>,
availableSkillNames: string[],
availableEntries: Array<{ name: string; required?: boolean }>,
) {
return resolveDesiredSkillNames(config, availableSkillNames);
return resolvePaperclipDesiredSkillNames(config, availableEntries);
}

View File

@@ -14,7 +14,8 @@ import {
ensureCommandResolvable,
ensurePaperclipSkillSymlink,
ensurePathInEnv,
listPaperclipSkillEntries,
readPaperclipRuntimeSkillEntries,
resolvePaperclipDesiredSkillNames,
removeMaintainerOnlySkillSymlinks,
renderTemplate,
joinPromptSections,
@@ -92,7 +93,7 @@ async function isLikelyPaperclipRuntimeSkillSource(candidate: string, skillName:
type EnsureCodexSkillsInjectedOptions = {
skillsHome?: string;
skillsEntries?: Awaited<ReturnType<typeof listPaperclipSkillEntries>>;
skillsEntries?: Array<{ name: string; source: string }>;
desiredSkillNames?: string[];
linkSkill?: (source: string, target: string) => Promise<void>;
};
@@ -101,7 +102,7 @@ export async function ensureCodexSkillsInjected(
onLog: AdapterExecutionContext["onLog"],
options: EnsureCodexSkillsInjectedOptions = {},
) {
const allSkillsEntries = options.skillsEntries ?? await listPaperclipSkillEntries(__moduleDir);
const allSkillsEntries = options.skillsEntries ?? await readPaperclipRuntimeSkillEntries({}, __moduleDir);
const desiredSkillNames =
options.desiredSkillNames ?? allSkillsEntries.map((entry) => entry.name);
const desiredSet = new Set(desiredSkillNames);
@@ -220,10 +221,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
typeof envConfig.CODEX_HOME === "string" && envConfig.CODEX_HOME.trim().length > 0
? path.resolve(envConfig.CODEX_HOME.trim())
: null;
const desiredSkillNames = resolveCodexDesiredSkillNames(
config,
(await listPaperclipSkillEntries(__moduleDir)).map((entry) => entry.name),
);
const codexSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const desiredSkillNames = resolveCodexDesiredSkillNames(config, codexSkillEntries);
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
const preparedWorktreeCodexHome =
configuredCodexHome ? null : await prepareWorktreeCodexHome(process.env, onLog);
@@ -231,11 +230,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
await ensureCodexSkillsInjected(
onLog,
effectiveCodexHome
? {
? {
skillsHome: path.join(effectiveCodexHome, "skills"),
skillsEntries: codexSkillEntries,
desiredSkillNames,
}
: { desiredSkillNames },
: { skillsEntries: codexSkillEntries, desiredSkillNames },
);
const hasExplicitApiKey =
typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0;

View File

@@ -8,8 +8,8 @@ import type {
} from "@paperclipai/adapter-utils";
import {
ensurePaperclipSkillSymlink,
listPaperclipSkillEntries,
readPaperclipSkillSyncPreference,
readPaperclipRuntimeSkillEntries,
resolvePaperclipDesiredSkillNames,
} from "@paperclipai/adapter-utils/server-utils";
import { resolveCodexHomeDir } from "./codex-home.js";
@@ -29,11 +29,6 @@ function resolveCodexSkillsHome(config: Record<string, unknown>) {
return path.join(home, "skills");
}
function resolveDesiredSkillNames(config: Record<string, unknown>, 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<string, { targetPath: string | null; kind: "symlink" | "directory" | "file" }>();
@@ -57,12 +52,9 @@ async function readInstalledSkillTargets(skillsHome: string) {
}
async function buildCodexSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
const availableEntries = await listPaperclipSkillEntries(__moduleDir);
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const availableByName = new Map(availableEntries.map((entry) => [entry.name, entry]));
const desiredSkills = resolveDesiredSkillNames(
config,
availableEntries.map((entry) => entry.name),
);
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
const desiredSet = new Set(desiredSkills);
const skillsHome = resolveCodexSkillsHome(config);
const installed = await readInstalledSkillTargets(skillsHome);
@@ -97,6 +89,8 @@ async function buildCodexSkillSnapshot(config: Record<string, unknown>): Promise
sourcePath: available.source,
targetPath: path.join(skillsHome, available.name),
detail,
required: Boolean(available.required),
requiredReason: available.requiredReason ?? null,
});
}
@@ -147,8 +141,11 @@ export async function syncCodexSkills(
ctx: AdapterSkillContext,
desiredSkills: string[],
): Promise<AdapterSkillSnapshot> {
const availableEntries = await listPaperclipSkillEntries(__moduleDir);
const desiredSet = new Set(desiredSkills);
const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir);
const desiredSet = new Set([
...desiredSkills,
...availableEntries.filter((entry) => entry.required).map((entry) => entry.name),
]);
const skillsHome = resolveCodexSkillsHome(ctx.config);
await fs.mkdir(skillsHome, { recursive: true });
const installed = await readInstalledSkillTargets(skillsHome);
@@ -173,7 +170,7 @@ export async function syncCodexSkills(
export function resolveCodexDesiredSkillNames(
config: Record<string, unknown>,
availableSkillNames: string[],
availableEntries: Array<{ name: string; required?: boolean }>,
) {
return resolveDesiredSkillNames(config, availableSkillNames);
return resolvePaperclipDesiredSkillNames(config, availableEntries);
}

View File

@@ -14,8 +14,8 @@ import {
ensureCommandResolvable,
ensurePaperclipSkillSymlink,
ensurePathInEnv,
listPaperclipSkillEntries,
readPaperclipSkillSyncPreference,
readPaperclipRuntimeSkillEntries,
resolvePaperclipDesiredSkillNames,
removeMaintainerOnlySkillSymlinks,
renderTemplate,
joinPromptSections,
@@ -98,7 +98,7 @@ export async function ensureCursorSkillsInjected(
? (await fs.readdir(options.skillsDir, { withFileTypes: true }))
.filter((entry) => entry.isDirectory())
.map((entry) => ({ name: entry.name, source: path.join(options.skillsDir!, entry.name) }))
: await listPaperclipSkillEntries(__moduleDir));
: await readPaperclipRuntimeSkillEntries({}, __moduleDir));
if (skillsEntries.length === 0) return;
const skillsHome = options.skillsHome ?? cursorSkillsHome();
@@ -169,11 +169,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
const cursorSkillEntries = await listPaperclipSkillEntries(__moduleDir);
const cursorPreference = readPaperclipSkillSyncPreference(config);
const desiredCursorSkillNames = cursorPreference.explicit
? cursorPreference.desiredSkills
: cursorSkillEntries.map((entry) => entry.name);
const cursorSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const desiredCursorSkillNames = resolvePaperclipDesiredSkillNames(config, cursorSkillEntries);
await ensureCursorSkillsInjected(onLog, {
skillsEntries: cursorSkillEntries.filter((entry) => desiredCursorSkillNames.includes(entry.name)),
});

View File

@@ -9,8 +9,8 @@ import type {
} from "@paperclipai/adapter-utils";
import {
ensurePaperclipSkillSymlink,
listPaperclipSkillEntries,
readPaperclipSkillSyncPreference,
readPaperclipRuntimeSkillEntries,
resolvePaperclipDesiredSkillNames,
} from "@paperclipai/adapter-utils/server-utils";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
@@ -29,11 +29,6 @@ function resolveCursorSkillsHome(config: Record<string, unknown>) {
return path.join(home, ".cursor", "skills");
}
function resolveDesiredSkillNames(config: Record<string, unknown>, 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<string, { targetPath: string | null; kind: "symlink" | "directory" | "file" }>();
@@ -57,12 +52,9 @@ async function readInstalledSkillTargets(skillsHome: string) {
}
async function buildCursorSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
const availableEntries = await listPaperclipSkillEntries(__moduleDir);
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const availableByName = new Map(availableEntries.map((entry) => [entry.name, entry]));
const desiredSkills = resolveDesiredSkillNames(
config,
availableEntries.map((entry) => entry.name),
);
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
const desiredSet = new Set(desiredSkills);
const skillsHome = resolveCursorSkillsHome(config);
const installed = await readInstalledSkillTargets(skillsHome);
@@ -97,6 +89,8 @@ async function buildCursorSkillSnapshot(config: Record<string, unknown>): Promis
sourcePath: available.source,
targetPath: path.join(skillsHome, available.name),
detail,
required: Boolean(available.required),
requiredReason: available.requiredReason ?? null,
});
}
@@ -147,8 +141,11 @@ export async function syncCursorSkills(
ctx: AdapterSkillContext,
desiredSkills: string[],
): Promise<AdapterSkillSnapshot> {
const availableEntries = await listPaperclipSkillEntries(__moduleDir);
const desiredSet = new Set(desiredSkills);
const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir);
const desiredSet = new Set([
...desiredSkills,
...availableEntries.filter((entry) => entry.required).map((entry) => entry.name),
]);
const skillsHome = resolveCursorSkillsHome(ctx.config);
await fs.mkdir(skillsHome, { recursive: true });
const installed = await readInstalledSkillTargets(skillsHome);
@@ -173,7 +170,7 @@ export async function syncCursorSkills(
export function resolveCursorDesiredSkillNames(
config: Record<string, unknown>,
availableSkillNames: string[],
availableEntries: Array<{ name: string; required?: boolean }>,
) {
return resolveDesiredSkillNames(config, availableSkillNames);
return resolvePaperclipDesiredSkillNames(config, availableEntries);
}

View File

@@ -15,8 +15,8 @@ import {
ensurePaperclipSkillSymlink,
joinPromptSections,
ensurePathInEnv,
listPaperclipSkillEntries,
readPaperclipSkillSyncPreference,
readPaperclipRuntimeSkillEntries,
resolvePaperclipDesiredSkillNames,
removeMaintainerOnlySkillSymlinks,
parseObject,
redactEnvForLogs,
@@ -85,12 +85,12 @@ function geminiSkillsHome(): string {
*/
async function ensureGeminiSkillsInjected(
onLog: AdapterExecutionContext["onLog"],
skillsEntries: Array<{ name: string; source: string }>,
desiredSkillNames?: string[],
): Promise<void> {
const allSkillsEntries = await listPaperclipSkillEntries(__moduleDir);
const desiredSet = new Set(desiredSkillNames ?? allSkillsEntries.map((entry) => entry.name));
const skillsEntries = allSkillsEntries.filter((entry) => desiredSet.has(entry.name));
if (skillsEntries.length === 0) return;
const desiredSet = new Set(desiredSkillNames ?? skillsEntries.map((entry) => entry.name));
const selectedEntries = skillsEntries.filter((entry) => desiredSet.has(entry.name));
if (selectedEntries.length === 0) return;
const skillsHome = geminiSkillsHome();
try {
@@ -104,7 +104,7 @@ async function ensureGeminiSkillsInjected(
}
const removedSkills = await removeMaintainerOnlySkillSymlinks(
skillsHome,
skillsEntries.map((entry) => entry.name),
selectedEntries.map((entry) => entry.name),
);
for (const skillName of removedSkills) {
await onLog(
@@ -113,7 +113,7 @@ async function ensureGeminiSkillsInjected(
);
}
for (const entry of skillsEntries) {
for (const entry of selectedEntries) {
const target = path.join(skillsHome, entry.name);
try {
@@ -160,12 +160,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
const geminiSkillEntries = await listPaperclipSkillEntries(__moduleDir);
const geminiPreference = readPaperclipSkillSyncPreference(config);
const desiredGeminiSkillNames = geminiPreference.explicit
? geminiPreference.desiredSkills
: geminiSkillEntries.map((entry) => entry.name);
await ensureGeminiSkillsInjected(onLog, desiredGeminiSkillNames);
const geminiSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const desiredGeminiSkillNames = resolvePaperclipDesiredSkillNames(config, geminiSkillEntries);
await ensureGeminiSkillsInjected(onLog, geminiSkillEntries, desiredGeminiSkillNames);
const envConfig = parseObject(config.env);
const hasExplicitApiKey =

View File

@@ -9,8 +9,8 @@ import type {
} from "@paperclipai/adapter-utils";
import {
ensurePaperclipSkillSymlink,
listPaperclipSkillEntries,
readPaperclipSkillSyncPreference,
readPaperclipRuntimeSkillEntries,
resolvePaperclipDesiredSkillNames,
} from "@paperclipai/adapter-utils/server-utils";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
@@ -29,11 +29,6 @@ function resolveGeminiSkillsHome(config: Record<string, unknown>) {
return path.join(home, ".gemini", "skills");
}
function resolveDesiredSkillNames(config: Record<string, unknown>, 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<string, { targetPath: string | null; kind: "symlink" | "directory" | "file" }>();
@@ -57,12 +52,9 @@ async function readInstalledSkillTargets(skillsHome: string) {
}
async function buildGeminiSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
const availableEntries = await listPaperclipSkillEntries(__moduleDir);
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const availableByName = new Map(availableEntries.map((entry) => [entry.name, entry]));
const desiredSkills = resolveDesiredSkillNames(
config,
availableEntries.map((entry) => entry.name),
);
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
const desiredSet = new Set(desiredSkills);
const skillsHome = resolveGeminiSkillsHome(config);
const installed = await readInstalledSkillTargets(skillsHome);
@@ -97,6 +89,8 @@ async function buildGeminiSkillSnapshot(config: Record<string, unknown>): Promis
sourcePath: available.source,
targetPath: path.join(skillsHome, available.name),
detail,
required: Boolean(available.required),
requiredReason: available.requiredReason ?? null,
});
}
@@ -147,8 +141,11 @@ export async function syncGeminiSkills(
ctx: AdapterSkillContext,
desiredSkills: string[],
): Promise<AdapterSkillSnapshot> {
const availableEntries = await listPaperclipSkillEntries(__moduleDir);
const desiredSet = new Set(desiredSkills);
const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir);
const desiredSet = new Set([
...desiredSkills,
...availableEntries.filter((entry) => entry.required).map((entry) => entry.name),
]);
const skillsHome = resolveGeminiSkillsHome(ctx.config);
await fs.mkdir(skillsHome, { recursive: true });
const installed = await readInstalledSkillTargets(skillsHome);
@@ -173,7 +170,7 @@ export async function syncGeminiSkills(
export function resolveGeminiDesiredSkillNames(
config: Record<string, unknown>,
availableSkillNames: string[],
availableEntries: Array<{ name: string; required?: boolean }>,
) {
return resolveDesiredSkillNames(config, availableSkillNames);
return resolvePaperclipDesiredSkillNames(config, availableEntries);
}

View File

@@ -13,19 +13,18 @@ import {
redactEnvForLogs,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePaperclipSkillSymlink,
ensurePathInEnv,
renderTemplate,
runChildProcess,
readPaperclipSkillSyncPreference,
readPaperclipRuntimeSkillEntries,
resolvePaperclipDesiredSkillNames,
} from "@paperclipai/adapter-utils/server-utils";
import { isOpenCodeUnknownSessionError, parseOpenCodeJsonl } from "./parse.js";
import { ensureOpenCodeModelConfiguredAndAvailable } from "./models.js";
import { removeMaintainerOnlySkillSymlinks } from "@paperclipai/adapter-utils/server-utils";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
const PAPERCLIP_SKILLS_CANDIDATES = [
path.resolve(__moduleDir, "../../skills"),
path.resolve(__moduleDir, "../../../../../skills"),
];
function firstNonEmptyLine(text: string): string {
return (
@@ -47,38 +46,34 @@ function claudeSkillsHome(): string {
return path.join(os.homedir(), ".claude", "skills");
}
async function resolvePaperclipSkillsDir(): Promise<string | null> {
for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) {
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
if (isDir) return candidate;
}
return null;
}
async function ensureOpenCodeSkillsInjected(
onLog: AdapterExecutionContext["onLog"],
skillsEntries: Array<{ name: string; source: string }>,
desiredSkillNames?: string[],
) {
const skillsDir = await resolvePaperclipSkillsDir();
if (!skillsDir) return;
const skillsHome = claudeSkillsHome();
await fs.mkdir(skillsHome, { recursive: true });
const entries = await fs.readdir(skillsDir, { withFileTypes: true });
const desiredSet = new Set(desiredSkillNames ?? entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name));
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (!desiredSet.has(entry.name)) continue;
const source = path.join(skillsDir, entry.name);
const desiredSet = new Set(desiredSkillNames ?? skillsEntries.map((entry) => entry.name));
const selectedEntries = skillsEntries.filter((entry) => desiredSet.has(entry.name));
const removedSkills = await removeMaintainerOnlySkillSymlinks(
skillsHome,
selectedEntries.map((entry) => entry.name),
);
for (const skillName of removedSkills) {
await onLog(
"stderr",
`[paperclip] Removed maintainer-only OpenCode skill "${skillName}" from ${skillsHome}\n`,
);
}
for (const entry of selectedEntries) {
const target = path.join(skillsHome, entry.name);
const existing = await fs.lstat(target).catch(() => null);
if (existing) continue;
try {
await fs.symlink(source, target);
const result = await ensurePaperclipSkillSymlink(entry.source, target);
if (result === "skipped") continue;
await onLog(
"stderr",
`[paperclip] Injected OpenCode skill "${entry.name}" into ${skillsHome}\n`,
`[paperclip] ${result === "repaired" ? "Repaired" : "Injected"} OpenCode skill "${entry.name}" into ${skillsHome}\n`,
);
} catch (err) {
await onLog(
@@ -117,10 +112,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
const openCodePreference = readPaperclipSkillSyncPreference(config);
const openCodeSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const desiredOpenCodeSkillNames = resolvePaperclipDesiredSkillNames(config, openCodeSkillEntries);
await ensureOpenCodeSkillsInjected(
onLog,
openCodePreference.explicit ? openCodePreference.desiredSkills : undefined,
openCodeSkillEntries,
desiredOpenCodeSkillNames,
);
const envConfig = parseObject(config.env);

View File

@@ -9,8 +9,8 @@ import type {
} from "@paperclipai/adapter-utils";
import {
ensurePaperclipSkillSymlink,
listPaperclipSkillEntries,
readPaperclipSkillSyncPreference,
readPaperclipRuntimeSkillEntries,
resolvePaperclipDesiredSkillNames,
} from "@paperclipai/adapter-utils/server-utils";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
@@ -29,11 +29,6 @@ function resolveOpenCodeSkillsHome(config: Record<string, unknown>) {
return path.join(home, ".claude", "skills");
}
function resolveDesiredSkillNames(config: Record<string, unknown>, 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<string, { targetPath: string | null; kind: "symlink" | "directory" | "file" }>();
@@ -57,12 +52,9 @@ async function readInstalledSkillTargets(skillsHome: string) {
}
async function buildOpenCodeSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
const availableEntries = await listPaperclipSkillEntries(__moduleDir);
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const availableByName = new Map(availableEntries.map((entry) => [entry.name, entry]));
const desiredSkills = resolveDesiredSkillNames(
config,
availableEntries.map((entry) => entry.name),
);
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
const desiredSet = new Set(desiredSkills);
const skillsHome = resolveOpenCodeSkillsHome(config);
const installed = await readInstalledSkillTargets(skillsHome);
@@ -100,6 +92,8 @@ async function buildOpenCodeSkillSnapshot(config: Record<string, unknown>): Prom
sourcePath: available.source,
targetPath: path.join(skillsHome, available.name),
detail,
required: Boolean(available.required),
requiredReason: available.requiredReason ?? null,
});
}
@@ -150,8 +144,11 @@ export async function syncOpenCodeSkills(
ctx: AdapterSkillContext,
desiredSkills: string[],
): Promise<AdapterSkillSnapshot> {
const availableEntries = await listPaperclipSkillEntries(__moduleDir);
const desiredSet = new Set(desiredSkills);
const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir);
const desiredSet = new Set([
...desiredSkills,
...availableEntries.filter((entry) => entry.required).map((entry) => entry.name),
]);
const skillsHome = resolveOpenCodeSkillsHome(ctx.config);
await fs.mkdir(skillsHome, { recursive: true });
const installed = await readInstalledSkillTargets(skillsHome);
@@ -176,7 +173,7 @@ export async function syncOpenCodeSkills(
export function resolveOpenCodeDesiredSkillNames(
config: Record<string, unknown>,
availableSkillNames: string[],
availableEntries: Array<{ name: string; required?: boolean }>,
) {
return resolveDesiredSkillNames(config, availableSkillNames);
return resolvePaperclipDesiredSkillNames(config, availableEntries);
}

View File

@@ -15,8 +15,8 @@ import {
ensureCommandResolvable,
ensurePaperclipSkillSymlink,
ensurePathInEnv,
listPaperclipSkillEntries,
readPaperclipSkillSyncPreference,
readPaperclipRuntimeSkillEntries,
resolvePaperclipDesiredSkillNames,
removeMaintainerOnlySkillSymlinks,
renderTemplate,
runChildProcess,
@@ -53,18 +53,18 @@ function parseModelId(model: string | null): string | null {
async function ensurePiSkillsInjected(
onLog: AdapterExecutionContext["onLog"],
skillsEntries: Array<{ name: string; source: string }>,
desiredSkillNames?: string[],
) {
const allSkillsEntries = await listPaperclipSkillEntries(__moduleDir);
const desiredSet = new Set(desiredSkillNames ?? allSkillsEntries.map((entry) => entry.name));
const skillsEntries = allSkillsEntries.filter((entry) => desiredSet.has(entry.name));
if (skillsEntries.length === 0) return;
const desiredSet = new Set(desiredSkillNames ?? skillsEntries.map((entry) => entry.name));
const selectedEntries = skillsEntries.filter((entry) => desiredSet.has(entry.name));
if (selectedEntries.length === 0) return;
const piSkillsHome = path.join(os.homedir(), ".pi", "agent", "skills");
await fs.mkdir(piSkillsHome, { recursive: true });
const removedSkills = await removeMaintainerOnlySkillSymlinks(
piSkillsHome,
skillsEntries.map((entry) => entry.name),
selectedEntries.map((entry) => entry.name),
);
for (const skillName of removedSkills) {
await onLog(
@@ -73,7 +73,7 @@ async function ensurePiSkillsInjected(
);
}
for (const entry of skillsEntries) {
for (const entry of selectedEntries) {
const target = path.join(piSkillsHome, entry.name);
try {
@@ -139,12 +139,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
await ensureSessionsDir();
// Inject skills
const piSkillEntries = await listPaperclipSkillEntries(__moduleDir);
const piPreference = readPaperclipSkillSyncPreference(config);
const desiredPiSkillNames = piPreference.explicit
? piPreference.desiredSkills
: piSkillEntries.map((entry) => entry.name);
await ensurePiSkillsInjected(onLog, desiredPiSkillNames);
const piSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const desiredPiSkillNames = resolvePaperclipDesiredSkillNames(config, piSkillEntries);
await ensurePiSkillsInjected(onLog, piSkillEntries, desiredPiSkillNames);
// Build environment
const envConfig = parseObject(config.env);

View File

@@ -9,8 +9,8 @@ import type {
} from "@paperclipai/adapter-utils";
import {
ensurePaperclipSkillSymlink,
listPaperclipSkillEntries,
readPaperclipSkillSyncPreference,
readPaperclipRuntimeSkillEntries,
resolvePaperclipDesiredSkillNames,
} from "@paperclipai/adapter-utils/server-utils";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
@@ -29,11 +29,6 @@ function resolvePiSkillsHome(config: Record<string, unknown>) {
return path.join(home, ".pi", "agent", "skills");
}
function resolveDesiredSkillNames(config: Record<string, unknown>, 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<string, { targetPath: string | null; kind: "symlink" | "directory" | "file" }>();
@@ -57,12 +52,9 @@ async function readInstalledSkillTargets(skillsHome: string) {
}
async function buildPiSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
const availableEntries = await listPaperclipSkillEntries(__moduleDir);
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const availableByName = new Map(availableEntries.map((entry) => [entry.name, entry]));
const desiredSkills = resolveDesiredSkillNames(
config,
availableEntries.map((entry) => entry.name),
);
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
const desiredSet = new Set(desiredSkills);
const skillsHome = resolvePiSkillsHome(config);
const installed = await readInstalledSkillTargets(skillsHome);
@@ -97,6 +89,8 @@ async function buildPiSkillSnapshot(config: Record<string, unknown>): Promise<Ad
sourcePath: available.source,
targetPath: path.join(skillsHome, available.name),
detail,
required: Boolean(available.required),
requiredReason: available.requiredReason ?? null,
});
}
@@ -147,8 +141,11 @@ export async function syncPiSkills(
ctx: AdapterSkillContext,
desiredSkills: string[],
): Promise<AdapterSkillSnapshot> {
const availableEntries = await listPaperclipSkillEntries(__moduleDir);
const desiredSet = new Set(desiredSkills);
const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir);
const desiredSet = new Set([
...desiredSkills,
...availableEntries.filter((entry) => entry.required).map((entry) => entry.name),
]);
const skillsHome = resolvePiSkillsHome(ctx.config);
await fs.mkdir(skillsHome, { recursive: true });
const installed = await readInstalledSkillTargets(skillsHome);
@@ -173,7 +170,7 @@ export async function syncPiSkills(
export function resolvePiDesiredSkillNames(
config: Record<string, unknown>,
availableSkillNames: string[],
availableEntries: Array<{ name: string; required?: boolean }>,
) {
return resolveDesiredSkillNames(config, availableSkillNames);
return resolvePaperclipDesiredSkillNames(config, availableEntries);
}

View File

@@ -12,6 +12,8 @@ export interface AgentSkillEntry {
name: string;
desired: boolean;
managed: boolean;
required?: boolean;
requiredReason?: string | null;
state: AgentSkillState;
sourcePath?: string | null;
targetPath?: string | null;

View File

@@ -19,6 +19,8 @@ export const agentSkillEntrySchema = z.object({
name: z.string().min(1),
desired: z.boolean(),
managed: z.boolean(),
required: z.boolean().optional(),
requiredReason: z.string().nullable().optional(),
state: agentSkillStateSchema,
sourcePath: z.string().nullable().optional(),
targetPath: z.string().nullable().optional(),

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,

View File

@@ -30,6 +30,7 @@ import { formatCents, formatDate, relativeTime, formatTokens } from "../lib/util
import { cn } from "../lib/utils";
import { Button } from "@/components/ui/button";
import { Tabs } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import {
Popover,
PopoverContent,
@@ -1225,12 +1226,19 @@ function AgentSkillsTab({
return "Unknown";
}
}, [skillSnapshot?.mode]);
const unsupportedSkillMessage = useMemo(() => {
if (skillSnapshot?.mode !== "unsupported") return null;
if (agent.adapterType === "openclaw_gateway") {
return "Paperclip cannot manage OpenClaw skills here. Visit your OpenClaw instance to manage this agent's skills.";
}
return "Paperclip cannot manage skills for this adapter yet. Manage them in the adapter directly.";
}, [agent.adapterType, skillSnapshot?.mode]);
const hasUnsavedChanges = !arraysEqual(skillDraft, lastSavedSkills);
const saveStatusLabel = syncSkills.isPending
? "Saving changes..."
: hasUnsavedChanges
? "Saving soon..."
: "Changes save automatically";
: null;
return (
<div className="max-w-4xl space-y-5">
@@ -1239,12 +1247,14 @@ function AgentSkillsTab({
to="/skills"
className="text-sm font-medium text-foreground underline-offset-4 no-underline transition-colors hover:text-foreground/70 hover:underline"
>
View company library
View company skills library
</Link>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{syncSkills.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
<span>{saveStatusLabel}</span>
</div>
{saveStatusLabel ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{syncSkills.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
<span>{saveStatusLabel}</span>
</div>
) : null}
</div>
{skillSnapshot?.warnings.length ? (
@@ -1255,6 +1265,12 @@ function AgentSkillsTab({
</div>
) : null}
{unsupportedSkillMessage ? (
<div className="rounded-xl border border-border px-4 py-3 text-sm text-muted-foreground">
{unsupportedSkillMessage}
</div>
) : null}
{isLoading ? (
<PageSkeleton variant="list" />
) : (
@@ -1268,22 +1284,46 @@ function AgentSkillsTab({
(companySkills ?? []).map((skill) => {
const checked = skillDraft.includes(skill.slug);
const adapterEntry = adapterEntryByName.get(skill.slug);
const required = Boolean(adapterEntry?.required);
const disabled = required || skillSnapshot?.mode === "unsupported";
const checkbox = (
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(event) => {
const next = event.target.checked
? Array.from(new Set([...skillDraft, skill.slug]))
: skillDraft.filter((value) => value !== skill.slug);
setSkillDraft(next);
}}
className="mt-0.5 disabled:cursor-not-allowed disabled:opacity-60"
/>
);
return (
<label
key={skill.id}
className="flex items-start gap-3 border-b border-border px-3 py-3 text-sm last:border-b-0 hover:bg-accent/20"
>
<input
type="checkbox"
checked={checked}
onChange={(event) => {
const next = event.target.checked
? Array.from(new Set([...skillDraft, skill.slug]))
: skillDraft.filter((value) => value !== skill.slug);
setSkillDraft(next);
}}
className="mt-0.5"
/>
{required && adapterEntry?.requiredReason ? (
<Tooltip>
<TooltipTrigger asChild>
<span>{checkbox}</span>
</TooltipTrigger>
<TooltipContent side="top">{adapterEntry.requiredReason}</TooltipContent>
</Tooltip>
) : skillSnapshot?.mode === "unsupported" ? (
<Tooltip>
<TooltipTrigger asChild>
<span>{checkbox}</span>
</TooltipTrigger>
<TooltipContent side="top">
{unsupportedSkillMessage ?? "Manage skills in the adapter directly."}
</TooltipContent>
</Tooltip>
) : (
checkbox
)}
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3">
<span className="truncate font-medium">{skill.name}</span>