Namespace company skill identities

Persist canonical namespaced skill keys, split adapter runtime names from skill keys, and update portability/import flows to carry the canonical identity end-to-end.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta
2026-03-16 18:27:20 -05:00
parent bb46423969
commit 5890b318c4
39 changed files with 9902 additions and 309 deletions

View File

@@ -38,7 +38,8 @@ const PAPERCLIP_SKILL_ROOT_RELATIVE_CANDIDATES = [
];
export interface PaperclipSkillEntry {
name: string;
key: string;
runtimeName: string;
source: string;
required?: boolean;
requiredReason?: string | null;
@@ -306,7 +307,8 @@ export async function listPaperclipSkillEntries(
return entries
.filter((entry) => entry.isDirectory())
.map((entry) => ({
name: entry.name,
key: `paperclipai/paperclip/${entry.name}`,
runtimeName: entry.name,
source: path.join(root, entry.name),
required: true,
requiredReason: "Bundled Paperclip skills are always available for local adapters.",
@@ -321,11 +323,13 @@ function normalizeConfiguredPaperclipRuntimeSkills(value: unknown): PaperclipSki
const out: PaperclipSkillEntry[] = [];
for (const rawEntry of value) {
const entry = parseObject(rawEntry);
const name = asString(entry.name, "").trim();
const key = asString(entry.key, asString(entry.name, "")).trim();
const runtimeName = asString(entry.runtimeName, asString(entry.name, "")).trim();
const source = asString(entry.source, "").trim();
if (!name || !source) continue;
if (!key || !runtimeName || !source) continue;
out.push({
name,
key,
runtimeName,
source,
required: asBoolean(entry.required, false),
requiredReason:
@@ -349,13 +353,13 @@ export async function readPaperclipRuntimeSkillEntries(
export async function readPaperclipSkillMarkdown(
moduleDir: string,
skillName: string,
skillKey: string,
): Promise<string | null> {
const normalized = skillName.trim().toLowerCase();
const normalized = skillKey.trim().toLowerCase();
if (!normalized) return null;
const entries = await listPaperclipSkillEntries(moduleDir);
const match = entries.find((entry) => entry.name === normalized);
const match = entries.find((entry) => entry.key === normalized);
if (!match) return null;
try {
@@ -389,12 +393,12 @@ export function readPaperclipSkillSyncPreference(config: Record<string, unknown>
export function resolvePaperclipDesiredSkillNames(
config: Record<string, unknown>,
availableEntries: Array<{ name: string; required?: boolean }>,
availableEntries: Array<{ key: string; required?: boolean }>,
): string[] {
const preference = readPaperclipSkillSyncPreference(config);
const requiredSkills = availableEntries
.filter((entry) => entry.required)
.map((entry) => entry.name);
.map((entry) => entry.key);
if (!preference.explicit) {
return Array.from(new Set(requiredSkills));
}

View File

@@ -158,7 +158,8 @@ export type AdapterSkillState =
| "external";
export interface AdapterSkillEntry {
name: string;
key: string;
runtimeName: string | null;
desired: boolean;
managed: boolean;
required?: boolean;

View File

@@ -49,10 +49,10 @@ async function buildSkillsDir(config: Record<string, unknown>): Promise<string>
),
);
for (const entry of availableEntries) {
if (!desiredNames.has(entry.name)) continue;
if (!desiredNames.has(entry.key)) continue;
await fs.symlink(
entry.source,
path.join(target, entry.name),
path.join(target, entry.runtimeName),
);
}
return tmp;

View File

@@ -14,17 +14,18 @@ const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
async function buildClaudeSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const availableByName = new Map(availableEntries.map((entry) => [entry.name, entry]));
const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry]));
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
const desiredSet = new Set(desiredSkills);
const entries: AdapterSkillEntry[] = availableEntries.map((entry) => ({
name: entry.name,
desired: desiredSet.has(entry.name),
key: entry.key,
runtimeName: entry.runtimeName,
desired: desiredSet.has(entry.key),
managed: true,
state: desiredSet.has(entry.name) ? "configured" : "available",
state: desiredSet.has(entry.key) ? "configured" : "available",
sourcePath: entry.source,
targetPath: null,
detail: desiredSet.has(entry.name)
detail: desiredSet.has(entry.key)
? "Will be mounted into the ephemeral Claude skill directory on the next run."
: null,
required: Boolean(entry.required),
@@ -33,10 +34,11 @@ async function buildClaudeSkillSnapshot(config: Record<string, unknown>): Promis
const warnings: string[] = [];
for (const desiredSkill of desiredSkills) {
if (availableByName.has(desiredSkill)) continue;
if (availableByKey.has(desiredSkill)) continue;
warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`);
entries.push({
name: desiredSkill,
key: desiredSkill,
runtimeName: null,
desired: true,
managed: true,
state: "missing",
@@ -46,7 +48,7 @@ async function buildClaudeSkillSnapshot(config: Record<string, unknown>): Promis
});
}
entries.sort((left, right) => left.name.localeCompare(right.name));
entries.sort((left, right) => left.key.localeCompare(right.key));
return {
adapterType: "claude_local",
@@ -71,7 +73,7 @@ export async function syncClaudeSkills(
export function resolveClaudeDesiredSkillNames(
config: Record<string, unknown>,
availableEntries: Array<{ name: string; required?: boolean }>,
availableEntries: Array<{ key: string; required?: boolean }>,
) {
return resolvePaperclipDesiredSkillNames(config, availableEntries);
}

View File

@@ -99,7 +99,7 @@ async function isLikelyPaperclipRuntimeSkillSource(candidate: string, skillName:
type EnsureCodexSkillsInjectedOptions = {
skillsHome?: string;
skillsEntries?: Array<{ name: string; source: string }>;
skillsEntries?: Array<{ key: string; runtimeName: string; source: string }>;
desiredSkillNames?: string[];
linkSkill?: (source: string, target: string) => Promise<void>;
};
@@ -110,16 +110,16 @@ export async function ensureCodexSkillsInjected(
) {
const allSkillsEntries = options.skillsEntries ?? await readPaperclipRuntimeSkillEntries({}, __moduleDir);
const desiredSkillNames =
options.desiredSkillNames ?? allSkillsEntries.map((entry) => entry.name);
options.desiredSkillNames ?? allSkillsEntries.map((entry) => entry.key);
const desiredSet = new Set(desiredSkillNames);
const skillsEntries = allSkillsEntries.filter((entry) => desiredSet.has(entry.name));
const skillsEntries = allSkillsEntries.filter((entry) => desiredSet.has(entry.key));
if (skillsEntries.length === 0) return;
const skillsHome = options.skillsHome ?? path.join(resolveCodexHomeDir(process.env), "skills");
await fs.mkdir(skillsHome, { recursive: true });
const removedSkills = await removeMaintainerOnlySkillSymlinks(
skillsHome,
skillsEntries.map((entry) => entry.name),
skillsEntries.map((entry) => entry.runtimeName),
);
for (const skillName of removedSkills) {
await onLog(
@@ -129,7 +129,7 @@ export async function ensureCodexSkillsInjected(
}
const linkSkill = options.linkSkill;
for (const entry of skillsEntries) {
const target = path.join(skillsHome, entry.name);
const target = path.join(skillsHome, entry.runtimeName);
try {
const existing = await fs.lstat(target).catch(() => null);
@@ -141,7 +141,7 @@ export async function ensureCodexSkillsInjected(
if (
resolvedLinkedPath &&
resolvedLinkedPath !== entry.source &&
(await isLikelyPaperclipRuntimeSkillSource(resolvedLinkedPath, entry.name))
(await isLikelyPaperclipRuntimeSkillSource(resolvedLinkedPath, entry.runtimeName))
) {
await fs.unlink(target);
if (linkSkill) {
@@ -151,7 +151,7 @@ export async function ensureCodexSkillsInjected(
}
await onLog(
"stderr",
`[paperclip] Repaired Codex skill "${entry.name}" into ${skillsHome}\n`,
`[paperclip] Repaired Codex skill "${entry.key}" into ${skillsHome}\n`,
);
continue;
}
@@ -162,12 +162,12 @@ export async function ensureCodexSkillsInjected(
await onLog(
"stderr",
`[paperclip] ${result === "repaired" ? "Repaired" : "Injected"} Codex skill "${entry.name}" into ${skillsHome}\n`,
`[paperclip] ${result === "repaired" ? "Repaired" : "Injected"} Codex skill "${entry.key}" into ${skillsHome}\n`,
);
} catch (err) {
await onLog(
"stderr",
`[paperclip] Failed to inject Codex skill "${entry.name}" into ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
`[paperclip] Failed to inject Codex skill "${entry.key}" into ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
);
}
}

View File

@@ -53,7 +53,7 @@ async function readInstalledSkillTargets(skillsHome: string) {
async function buildCodexSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const availableByName = new Map(availableEntries.map((entry) => [entry.name, entry]));
const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry]));
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
const desiredSet = new Set(desiredSkills);
const skillsHome = resolveCodexSkillsHome(config);
@@ -62,8 +62,8 @@ async function buildCodexSkillSnapshot(config: Record<string, unknown>): Promise
const warnings: string[] = [];
for (const available of availableEntries) {
const installedEntry = installed.get(available.name) ?? null;
const desired = desiredSet.has(available.name);
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;
@@ -82,12 +82,13 @@ async function buildCodexSkillSnapshot(config: Record<string, unknown>): Promise
}
entries.push({
name: available.name,
key: available.key,
runtimeName: available.runtimeName,
desired,
managed,
state,
sourcePath: available.source,
targetPath: path.join(skillsHome, available.name),
targetPath: path.join(skillsHome, available.runtimeName),
detail,
required: Boolean(available.required),
requiredReason: available.requiredReason ?? null,
@@ -95,23 +96,25 @@ async function buildCodexSkillSnapshot(config: Record<string, unknown>): Promise
}
for (const desiredSkill of desiredSkills) {
if (availableByName.has(desiredSkill)) continue;
if (availableByKey.has(desiredSkill)) continue;
warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`);
entries.push({
name: desiredSkill,
key: desiredSkill,
runtimeName: null,
desired: true,
managed: true,
state: "missing",
sourcePath: null,
targetPath: path.join(skillsHome, desiredSkill),
targetPath: null,
detail: "Paperclip cannot find this skill in the local runtime skills directory.",
});
}
for (const [name, installedEntry] of installed.entries()) {
if (availableByName.has(name)) continue;
if (availableEntries.some((entry) => entry.runtimeName === name)) continue;
entries.push({
name,
key: name,
runtimeName: name,
desired: false,
managed: false,
state: "external",
@@ -121,7 +124,7 @@ async function buildCodexSkillSnapshot(config: Record<string, unknown>): Promise
});
}
entries.sort((left, right) => left.name.localeCompare(right.name));
entries.sort((left, right) => left.key.localeCompare(right.key));
return {
adapterType: "codex_local",
@@ -144,23 +147,23 @@ export async function syncCodexSkills(
const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir);
const desiredSet = new Set([
...desiredSkills,
...availableEntries.filter((entry) => entry.required).map((entry) => entry.name),
...availableEntries.filter((entry) => entry.required).map((entry) => entry.key),
]);
const skillsHome = resolveCodexSkillsHome(ctx.config);
await fs.mkdir(skillsHome, { recursive: true });
const installed = await readInstalledSkillTargets(skillsHome);
const availableByName = new Map(availableEntries.map((entry) => [entry.name, entry]));
const availableByRuntimeName = new Map(availableEntries.map((entry) => [entry.runtimeName, entry]));
for (const available of availableEntries) {
if (!desiredSet.has(available.name)) continue;
const target = path.join(skillsHome, available.name);
if (!desiredSet.has(available.key)) continue;
const target = path.join(skillsHome, available.runtimeName);
await ensurePaperclipSkillSymlink(available.source, target);
}
for (const [name, installedEntry] of installed.entries()) {
const available = availableByName.get(name);
const available = availableByRuntimeName.get(name);
if (!available) continue;
if (desiredSet.has(name)) continue;
if (desiredSet.has(available.key)) continue;
if (installedEntry.targetPath !== available.source) continue;
await fs.unlink(path.join(skillsHome, name)).catch(() => {});
}
@@ -170,7 +173,7 @@ export async function syncCodexSkills(
export function resolveCodexDesiredSkillNames(
config: Record<string, unknown>,
availableEntries: Array<{ name: string; required?: boolean }>,
availableEntries: Array<{ key: string; required?: boolean }>,
) {
return resolvePaperclipDesiredSkillNames(config, availableEntries);
}

View File

@@ -95,7 +95,7 @@ function cursorSkillsHome(): string {
type EnsureCursorSkillsInjectedOptions = {
skillsDir?: string | null;
skillsEntries?: Array<{ name: string; source: string }>;
skillsEntries?: Array<{ key: string; runtimeName: string; source: string }>;
skillsHome?: string;
linkSkill?: (source: string, target: string) => Promise<void>;
};
@@ -108,7 +108,11 @@ export async function ensureCursorSkillsInjected(
?? (options.skillsDir
? (await fs.readdir(options.skillsDir, { withFileTypes: true }))
.filter((entry) => entry.isDirectory())
.map((entry) => ({ name: entry.name, source: path.join(options.skillsDir!, entry.name) }))
.map((entry) => ({
key: entry.name,
runtimeName: entry.name,
source: path.join(options.skillsDir!, entry.name),
}))
: await readPaperclipRuntimeSkillEntries({}, __moduleDir));
if (skillsEntries.length === 0) return;
@@ -124,7 +128,7 @@ export async function ensureCursorSkillsInjected(
}
const removedSkills = await removeMaintainerOnlySkillSymlinks(
skillsHome,
skillsEntries.map((entry) => entry.name),
skillsEntries.map((entry) => entry.runtimeName),
);
for (const skillName of removedSkills) {
await onLog(
@@ -134,19 +138,19 @@ export async function ensureCursorSkillsInjected(
}
const linkSkill = options.linkSkill ?? ((source: string, target: string) => fs.symlink(source, target));
for (const entry of skillsEntries) {
const target = path.join(skillsHome, entry.name);
const target = path.join(skillsHome, entry.runtimeName);
try {
const result = await ensurePaperclipSkillSymlink(entry.source, target, linkSkill);
if (result === "skipped") continue;
await onLog(
"stderr",
`[paperclip] ${result === "repaired" ? "Repaired" : "Injected"} Cursor skill "${entry.name}" into ${skillsHome}\n`,
`[paperclip] ${result === "repaired" ? "Repaired" : "Injected"} Cursor skill "${entry.key}" into ${skillsHome}\n`,
);
} catch (err) {
await onLog(
"stderr",
`[paperclip] Failed to inject Cursor skill "${entry.name}" into ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
`[paperclip] Failed to inject Cursor skill "${entry.key}" into ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
);
}
}
@@ -183,7 +187,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const cursorSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const desiredCursorSkillNames = resolvePaperclipDesiredSkillNames(config, cursorSkillEntries);
await ensureCursorSkillsInjected(onLog, {
skillsEntries: cursorSkillEntries.filter((entry) => desiredCursorSkillNames.includes(entry.name)),
skillsEntries: cursorSkillEntries.filter((entry) => desiredCursorSkillNames.includes(entry.key)),
});
const envConfig = parseObject(config.env);

View File

@@ -53,7 +53,7 @@ async function readInstalledSkillTargets(skillsHome: string) {
async function buildCursorSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const availableByName = new Map(availableEntries.map((entry) => [entry.name, entry]));
const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry]));
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
const desiredSet = new Set(desiredSkills);
const skillsHome = resolveCursorSkillsHome(config);
@@ -62,8 +62,8 @@ async function buildCursorSkillSnapshot(config: Record<string, unknown>): Promis
const warnings: string[] = [];
for (const available of availableEntries) {
const installedEntry = installed.get(available.name) ?? null;
const desired = desiredSet.has(available.name);
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;
@@ -82,12 +82,13 @@ async function buildCursorSkillSnapshot(config: Record<string, unknown>): Promis
}
entries.push({
name: available.name,
key: available.key,
runtimeName: available.runtimeName,
desired,
managed,
state,
sourcePath: available.source,
targetPath: path.join(skillsHome, available.name),
targetPath: path.join(skillsHome, available.runtimeName),
detail,
required: Boolean(available.required),
requiredReason: available.requiredReason ?? null,
@@ -95,23 +96,25 @@ async function buildCursorSkillSnapshot(config: Record<string, unknown>): Promis
}
for (const desiredSkill of desiredSkills) {
if (availableByName.has(desiredSkill)) continue;
if (availableByKey.has(desiredSkill)) continue;
warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`);
entries.push({
name: desiredSkill,
key: desiredSkill,
runtimeName: null,
desired: true,
managed: true,
state: "missing",
sourcePath: null,
targetPath: path.join(skillsHome, desiredSkill),
targetPath: null,
detail: "Paperclip cannot find this skill in the local runtime skills directory.",
});
}
for (const [name, installedEntry] of installed.entries()) {
if (availableByName.has(name)) continue;
if (availableEntries.some((entry) => entry.runtimeName === name)) continue;
entries.push({
name,
key: name,
runtimeName: name,
desired: false,
managed: false,
state: "external",
@@ -121,7 +124,7 @@ async function buildCursorSkillSnapshot(config: Record<string, unknown>): Promis
});
}
entries.sort((left, right) => left.name.localeCompare(right.name));
entries.sort((left, right) => left.key.localeCompare(right.key));
return {
adapterType: "cursor",
@@ -144,23 +147,23 @@ export async function syncCursorSkills(
const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir);
const desiredSet = new Set([
...desiredSkills,
...availableEntries.filter((entry) => entry.required).map((entry) => entry.name),
...availableEntries.filter((entry) => entry.required).map((entry) => entry.key),
]);
const skillsHome = resolveCursorSkillsHome(ctx.config);
await fs.mkdir(skillsHome, { recursive: true });
const installed = await readInstalledSkillTargets(skillsHome);
const availableByName = new Map(availableEntries.map((entry) => [entry.name, entry]));
const availableByRuntimeName = new Map(availableEntries.map((entry) => [entry.runtimeName, entry]));
for (const available of availableEntries) {
if (!desiredSet.has(available.name)) continue;
const target = path.join(skillsHome, available.name);
if (!desiredSet.has(available.key)) continue;
const target = path.join(skillsHome, available.runtimeName);
await ensurePaperclipSkillSymlink(available.source, target);
}
for (const [name, installedEntry] of installed.entries()) {
const available = availableByName.get(name);
const available = availableByRuntimeName.get(name);
if (!available) continue;
if (desiredSet.has(name)) continue;
if (desiredSet.has(available.key)) continue;
if (installedEntry.targetPath !== available.source) continue;
await fs.unlink(path.join(skillsHome, name)).catch(() => {});
}
@@ -170,7 +173,7 @@ export async function syncCursorSkills(
export function resolveCursorDesiredSkillNames(
config: Record<string, unknown>,
availableEntries: Array<{ name: string; required?: boolean }>,
availableEntries: Array<{ key: string; required?: boolean }>,
) {
return resolvePaperclipDesiredSkillNames(config, availableEntries);
}

View File

@@ -85,11 +85,11 @@ function geminiSkillsHome(): string {
*/
async function ensureGeminiSkillsInjected(
onLog: AdapterExecutionContext["onLog"],
skillsEntries: Array<{ name: string; source: string }>,
skillsEntries: Array<{ key: string; runtimeName: string; source: string }>,
desiredSkillNames?: string[],
): Promise<void> {
const desiredSet = new Set(desiredSkillNames ?? skillsEntries.map((entry) => entry.name));
const selectedEntries = skillsEntries.filter((entry) => desiredSet.has(entry.name));
const desiredSet = new Set(desiredSkillNames ?? skillsEntries.map((entry) => entry.key));
const selectedEntries = skillsEntries.filter((entry) => desiredSet.has(entry.key));
if (selectedEntries.length === 0) return;
const skillsHome = geminiSkillsHome();
@@ -104,7 +104,7 @@ async function ensureGeminiSkillsInjected(
}
const removedSkills = await removeMaintainerOnlySkillSymlinks(
skillsHome,
selectedEntries.map((entry) => entry.name),
selectedEntries.map((entry) => entry.runtimeName),
);
for (const skillName of removedSkills) {
await onLog(
@@ -114,19 +114,19 @@ async function ensureGeminiSkillsInjected(
}
for (const entry of selectedEntries) {
const target = path.join(skillsHome, entry.name);
const target = path.join(skillsHome, entry.runtimeName);
try {
const result = await ensurePaperclipSkillSymlink(entry.source, target);
if (result === "skipped") continue;
await onLog(
"stderr",
`[paperclip] ${result === "repaired" ? "Repaired" : "Linked"} Gemini skill: ${entry.name}\n`,
`[paperclip] ${result === "repaired" ? "Repaired" : "Linked"} Gemini skill: ${entry.key}\n`,
);
} catch (err) {
await onLog(
"stderr",
`[paperclip] Failed to link Gemini skill "${entry.name}": ${err instanceof Error ? err.message : String(err)}\n`,
`[paperclip] Failed to link Gemini skill "${entry.key}": ${err instanceof Error ? err.message : String(err)}\n`,
);
}
}

View File

@@ -53,7 +53,7 @@ async function readInstalledSkillTargets(skillsHome: string) {
async function buildGeminiSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const availableByName = new Map(availableEntries.map((entry) => [entry.name, entry]));
const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry]));
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
const desiredSet = new Set(desiredSkills);
const skillsHome = resolveGeminiSkillsHome(config);
@@ -62,8 +62,8 @@ async function buildGeminiSkillSnapshot(config: Record<string, unknown>): Promis
const warnings: string[] = [];
for (const available of availableEntries) {
const installedEntry = installed.get(available.name) ?? null;
const desired = desiredSet.has(available.name);
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;
@@ -82,12 +82,13 @@ async function buildGeminiSkillSnapshot(config: Record<string, unknown>): Promis
}
entries.push({
name: available.name,
key: available.key,
runtimeName: available.runtimeName,
desired,
managed,
state,
sourcePath: available.source,
targetPath: path.join(skillsHome, available.name),
targetPath: path.join(skillsHome, available.runtimeName),
detail,
required: Boolean(available.required),
requiredReason: available.requiredReason ?? null,
@@ -95,23 +96,25 @@ async function buildGeminiSkillSnapshot(config: Record<string, unknown>): Promis
}
for (const desiredSkill of desiredSkills) {
if (availableByName.has(desiredSkill)) continue;
if (availableByKey.has(desiredSkill)) continue;
warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`);
entries.push({
name: desiredSkill,
key: desiredSkill,
runtimeName: null,
desired: true,
managed: true,
state: "missing",
sourcePath: null,
targetPath: path.join(skillsHome, desiredSkill),
targetPath: null,
detail: "Paperclip cannot find this skill in the local runtime skills directory.",
});
}
for (const [name, installedEntry] of installed.entries()) {
if (availableByName.has(name)) continue;
if (availableEntries.some((entry) => entry.runtimeName === name)) continue;
entries.push({
name,
key: name,
runtimeName: name,
desired: false,
managed: false,
state: "external",
@@ -121,7 +124,7 @@ async function buildGeminiSkillSnapshot(config: Record<string, unknown>): Promis
});
}
entries.sort((left, right) => left.name.localeCompare(right.name));
entries.sort((left, right) => left.key.localeCompare(right.key));
return {
adapterType: "gemini_local",
@@ -144,23 +147,23 @@ export async function syncGeminiSkills(
const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir);
const desiredSet = new Set([
...desiredSkills,
...availableEntries.filter((entry) => entry.required).map((entry) => entry.name),
...availableEntries.filter((entry) => entry.required).map((entry) => entry.key),
]);
const skillsHome = resolveGeminiSkillsHome(ctx.config);
await fs.mkdir(skillsHome, { recursive: true });
const installed = await readInstalledSkillTargets(skillsHome);
const availableByName = new Map(availableEntries.map((entry) => [entry.name, entry]));
const availableByRuntimeName = new Map(availableEntries.map((entry) => [entry.runtimeName, entry]));
for (const available of availableEntries) {
if (!desiredSet.has(available.name)) continue;
const target = path.join(skillsHome, available.name);
if (!desiredSet.has(available.key)) continue;
const target = path.join(skillsHome, available.runtimeName);
await ensurePaperclipSkillSymlink(available.source, target);
}
for (const [name, installedEntry] of installed.entries()) {
const available = availableByName.get(name);
const available = availableByRuntimeName.get(name);
if (!available) continue;
if (desiredSet.has(name)) continue;
if (desiredSet.has(available.key)) continue;
if (installedEntry.targetPath !== available.source) continue;
await fs.unlink(path.join(skillsHome, name)).catch(() => {});
}
@@ -170,7 +173,7 @@ export async function syncGeminiSkills(
export function resolveGeminiDesiredSkillNames(
config: Record<string, unknown>,
availableEntries: Array<{ name: string; required?: boolean }>,
availableEntries: Array<{ key: string; required?: boolean }>,
) {
return resolvePaperclipDesiredSkillNames(config, availableEntries);
}

View File

@@ -52,16 +52,16 @@ function claudeSkillsHome(): string {
async function ensureOpenCodeSkillsInjected(
onLog: AdapterExecutionContext["onLog"],
skillsEntries: Array<{ name: string; source: string }>,
skillsEntries: Array<{ key: string; runtimeName: string; source: string }>,
desiredSkillNames?: string[],
) {
const skillsHome = claudeSkillsHome();
await fs.mkdir(skillsHome, { recursive: true });
const desiredSet = new Set(desiredSkillNames ?? skillsEntries.map((entry) => entry.name));
const selectedEntries = skillsEntries.filter((entry) => desiredSet.has(entry.name));
const desiredSet = new Set(desiredSkillNames ?? skillsEntries.map((entry) => entry.key));
const selectedEntries = skillsEntries.filter((entry) => desiredSet.has(entry.key));
const removedSkills = await removeMaintainerOnlySkillSymlinks(
skillsHome,
selectedEntries.map((entry) => entry.name),
selectedEntries.map((entry) => entry.runtimeName),
);
for (const skillName of removedSkills) {
await onLog(
@@ -70,19 +70,19 @@ async function ensureOpenCodeSkillsInjected(
);
}
for (const entry of selectedEntries) {
const target = path.join(skillsHome, entry.name);
const target = path.join(skillsHome, entry.runtimeName);
try {
const result = await ensurePaperclipSkillSymlink(entry.source, target);
if (result === "skipped") continue;
await onLog(
"stderr",
`[paperclip] ${result === "repaired" ? "Repaired" : "Injected"} OpenCode skill "${entry.name}" into ${skillsHome}\n`,
`[paperclip] ${result === "repaired" ? "Repaired" : "Injected"} OpenCode skill "${entry.key}" into ${skillsHome}\n`,
);
} catch (err) {
await onLog(
"stderr",
`[paperclip] Failed to inject OpenCode skill "${entry.name}" into ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
`[paperclip] Failed to inject OpenCode skill "${entry.key}" into ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
);
}
}

View File

@@ -53,7 +53,7 @@ async function readInstalledSkillTargets(skillsHome: string) {
async function buildOpenCodeSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const availableByName = new Map(availableEntries.map((entry) => [entry.name, entry]));
const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry]));
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
const desiredSet = new Set(desiredSkills);
const skillsHome = resolveOpenCodeSkillsHome(config);
@@ -64,8 +64,8 @@ async function buildOpenCodeSkillSnapshot(config: Record<string, unknown>): Prom
];
for (const available of availableEntries) {
const installedEntry = installed.get(available.name) ?? null;
const desired = desiredSet.has(available.name);
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;
@@ -85,12 +85,13 @@ async function buildOpenCodeSkillSnapshot(config: Record<string, unknown>): Prom
}
entries.push({
name: available.name,
key: available.key,
runtimeName: available.runtimeName,
desired,
managed,
state,
sourcePath: available.source,
targetPath: path.join(skillsHome, available.name),
targetPath: path.join(skillsHome, available.runtimeName),
detail,
required: Boolean(available.required),
requiredReason: available.requiredReason ?? null,
@@ -98,23 +99,25 @@ async function buildOpenCodeSkillSnapshot(config: Record<string, unknown>): Prom
}
for (const desiredSkill of desiredSkills) {
if (availableByName.has(desiredSkill)) continue;
if (availableByKey.has(desiredSkill)) continue;
warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`);
entries.push({
name: desiredSkill,
key: desiredSkill,
runtimeName: null,
desired: true,
managed: true,
state: "missing",
sourcePath: null,
targetPath: path.join(skillsHome, desiredSkill),
targetPath: null,
detail: "Paperclip cannot find this skill in the local runtime skills directory.",
});
}
for (const [name, installedEntry] of installed.entries()) {
if (availableByName.has(name)) continue;
if (availableEntries.some((entry) => entry.runtimeName === name)) continue;
entries.push({
name,
key: name,
runtimeName: name,
desired: false,
managed: false,
state: "external",
@@ -124,7 +127,7 @@ async function buildOpenCodeSkillSnapshot(config: Record<string, unknown>): Prom
});
}
entries.sort((left, right) => left.name.localeCompare(right.name));
entries.sort((left, right) => left.key.localeCompare(right.key));
return {
adapterType: "opencode_local",
@@ -147,23 +150,23 @@ export async function syncOpenCodeSkills(
const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir);
const desiredSet = new Set([
...desiredSkills,
...availableEntries.filter((entry) => entry.required).map((entry) => entry.name),
...availableEntries.filter((entry) => entry.required).map((entry) => entry.key),
]);
const skillsHome = resolveOpenCodeSkillsHome(ctx.config);
await fs.mkdir(skillsHome, { recursive: true });
const installed = await readInstalledSkillTargets(skillsHome);
const availableByName = new Map(availableEntries.map((entry) => [entry.name, entry]));
const availableByRuntimeName = new Map(availableEntries.map((entry) => [entry.runtimeName, entry]));
for (const available of availableEntries) {
if (!desiredSet.has(available.name)) continue;
const target = path.join(skillsHome, available.name);
if (!desiredSet.has(available.key)) continue;
const target = path.join(skillsHome, available.runtimeName);
await ensurePaperclipSkillSymlink(available.source, target);
}
for (const [name, installedEntry] of installed.entries()) {
const available = availableByName.get(name);
const available = availableByRuntimeName.get(name);
if (!available) continue;
if (desiredSet.has(name)) continue;
if (desiredSet.has(available.key)) continue;
if (installedEntry.targetPath !== available.source) continue;
await fs.unlink(path.join(skillsHome, name)).catch(() => {});
}
@@ -173,7 +176,7 @@ export async function syncOpenCodeSkills(
export function resolveOpenCodeDesiredSkillNames(
config: Record<string, unknown>,
availableEntries: Array<{ name: string; required?: boolean }>,
availableEntries: Array<{ key: string; required?: boolean }>,
) {
return resolvePaperclipDesiredSkillNames(config, availableEntries);
}

View File

@@ -53,17 +53,17 @@ function parseModelId(model: string | null): string | null {
async function ensurePiSkillsInjected(
onLog: AdapterExecutionContext["onLog"],
skillsEntries: Array<{ name: string; source: string }>,
skillsEntries: Array<{ key: string; runtimeName: string; source: string }>,
desiredSkillNames?: string[],
) {
const desiredSet = new Set(desiredSkillNames ?? skillsEntries.map((entry) => entry.name));
const selectedEntries = skillsEntries.filter((entry) => desiredSet.has(entry.name));
const desiredSet = new Set(desiredSkillNames ?? skillsEntries.map((entry) => entry.key));
const selectedEntries = skillsEntries.filter((entry) => desiredSet.has(entry.key));
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,
selectedEntries.map((entry) => entry.name),
selectedEntries.map((entry) => entry.runtimeName),
);
for (const skillName of removedSkills) {
await onLog(
@@ -73,19 +73,19 @@ async function ensurePiSkillsInjected(
}
for (const entry of selectedEntries) {
const target = path.join(piSkillsHome, entry.name);
const target = path.join(piSkillsHome, entry.runtimeName);
try {
const result = await ensurePaperclipSkillSymlink(entry.source, target);
if (result === "skipped") continue;
await onLog(
"stderr",
`[paperclip] ${result === "repaired" ? "Repaired" : "Injected"} Pi skill "${entry.name}" into ${piSkillsHome}\n`,
`[paperclip] ${result === "repaired" ? "Repaired" : "Injected"} Pi skill "${entry.key}" into ${piSkillsHome}\n`,
);
} catch (err) {
await onLog(
"stderr",
`[paperclip] Failed to inject Pi skill "${entry.name}" into ${piSkillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
`[paperclip] Failed to inject Pi skill "${entry.key}" into ${piSkillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
);
}
}

View File

@@ -53,7 +53,7 @@ async function readInstalledSkillTargets(skillsHome: string) {
async function buildPiSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const availableByName = new Map(availableEntries.map((entry) => [entry.name, entry]));
const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry]));
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
const desiredSet = new Set(desiredSkills);
const skillsHome = resolvePiSkillsHome(config);
@@ -62,8 +62,8 @@ async function buildPiSkillSnapshot(config: Record<string, unknown>): Promise<Ad
const warnings: string[] = [];
for (const available of availableEntries) {
const installedEntry = installed.get(available.name) ?? null;
const desired = desiredSet.has(available.name);
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;
@@ -82,12 +82,13 @@ async function buildPiSkillSnapshot(config: Record<string, unknown>): Promise<Ad
}
entries.push({
name: available.name,
key: available.key,
runtimeName: available.runtimeName,
desired,
managed,
state,
sourcePath: available.source,
targetPath: path.join(skillsHome, available.name),
targetPath: path.join(skillsHome, available.runtimeName),
detail,
required: Boolean(available.required),
requiredReason: available.requiredReason ?? null,
@@ -95,23 +96,25 @@ async function buildPiSkillSnapshot(config: Record<string, unknown>): Promise<Ad
}
for (const desiredSkill of desiredSkills) {
if (availableByName.has(desiredSkill)) continue;
if (availableByKey.has(desiredSkill)) continue;
warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`);
entries.push({
name: desiredSkill,
key: desiredSkill,
runtimeName: null,
desired: true,
managed: true,
state: "missing",
sourcePath: null,
targetPath: path.join(skillsHome, desiredSkill),
targetPath: null,
detail: "Paperclip cannot find this skill in the local runtime skills directory.",
});
}
for (const [name, installedEntry] of installed.entries()) {
if (availableByName.has(name)) continue;
if (availableEntries.some((entry) => entry.runtimeName === name)) continue;
entries.push({
name,
key: name,
runtimeName: name,
desired: false,
managed: false,
state: "external",
@@ -121,7 +124,7 @@ async function buildPiSkillSnapshot(config: Record<string, unknown>): Promise<Ad
});
}
entries.sort((left, right) => left.name.localeCompare(right.name));
entries.sort((left, right) => left.key.localeCompare(right.key));
return {
adapterType: "pi_local",
@@ -144,23 +147,23 @@ export async function syncPiSkills(
const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir);
const desiredSet = new Set([
...desiredSkills,
...availableEntries.filter((entry) => entry.required).map((entry) => entry.name),
...availableEntries.filter((entry) => entry.required).map((entry) => entry.key),
]);
const skillsHome = resolvePiSkillsHome(ctx.config);
await fs.mkdir(skillsHome, { recursive: true });
const installed = await readInstalledSkillTargets(skillsHome);
const availableByName = new Map(availableEntries.map((entry) => [entry.name, entry]));
const availableByRuntimeName = new Map(availableEntries.map((entry) => [entry.runtimeName, entry]));
for (const available of availableEntries) {
if (!desiredSet.has(available.name)) continue;
const target = path.join(skillsHome, available.name);
if (!desiredSet.has(available.key)) continue;
const target = path.join(skillsHome, available.runtimeName);
await ensurePaperclipSkillSymlink(available.source, target);
}
for (const [name, installedEntry] of installed.entries()) {
const available = availableByName.get(name);
const available = availableByRuntimeName.get(name);
if (!available) continue;
if (desiredSet.has(name)) continue;
if (desiredSet.has(available.key)) continue;
if (installedEntry.targetPath !== available.source) continue;
await fs.unlink(path.join(skillsHome, name)).catch(() => {});
}
@@ -170,7 +173,7 @@ export async function syncPiSkills(
export function resolvePiDesiredSkillNames(
config: Record<string, unknown>,
availableEntries: Array<{ name: string; required?: boolean }>,
availableEntries: Array<{ key: string; required?: boolean }>,
) {
return resolvePaperclipDesiredSkillNames(config, availableEntries);
}

View File

@@ -0,0 +1,27 @@
ALTER TABLE "company_skills" ADD COLUMN "key" text;--> statement-breakpoint
UPDATE "company_skills"
SET "key" = CASE
WHEN COALESCE("metadata"->>'sourceKind', '') = 'paperclip_bundled' THEN 'paperclipai/paperclip/' || "slug"
WHEN (COALESCE("metadata"->>'sourceKind', '') = 'github' OR "source_type" = 'github')
AND COALESCE("metadata"->>'owner', '') <> ''
AND COALESCE("metadata"->>'repo', '') <> ''
THEN lower("metadata"->>'owner') || '/' || lower("metadata"->>'repo') || '/' || "slug"
WHEN COALESCE("metadata"->>'sourceKind', '') = 'managed_local' THEN 'company/' || "company_id"::text || '/' || "slug"
WHEN (COALESCE("metadata"->>'sourceKind', '') = 'url' OR "source_type" = 'url')
THEN 'url/'
|| COALESCE(
NULLIF(regexp_replace(lower(regexp_replace(COALESCE("source_locator", ''), '^https?://([^/]+).*$','\1')), '[^a-z0-9._-]+', '-', 'g'), ''),
'unknown'
)
|| '/'
|| substr(md5(COALESCE("source_locator", "slug")), 1, 10)
|| '/'
|| "slug"
WHEN "source_type" = 'local_path' AND COALESCE("source_locator", '') <> ''
THEN 'local/' || substr(md5("source_locator"), 1, 10) || '/' || "slug"
ELSE 'company/' || "company_id"::text || '/' || "slug"
END
WHERE "key" IS NULL;--> statement-breakpoint
ALTER TABLE "company_skills" ALTER COLUMN "key" SET NOT NULL;--> statement-breakpoint
DROP INDEX IF EXISTS "company_skills_company_slug_idx";--> statement-breakpoint
CREATE UNIQUE INDEX "company_skills_company_key_idx" ON "company_skills" USING btree ("company_id","key");

File diff suppressed because it is too large Load Diff

View File

@@ -246,6 +246,13 @@
"when": 1773697572188,
"tag": "0034_fat_dormammu",
"breakpoints": true
},
{
"idx": 35,
"version": "7",
"when": 1773703213570,
"tag": "0035_colorful_rhino",
"breakpoints": true
}
]
}

View File

@@ -14,6 +14,7 @@ export const companySkills = pgTable(
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
key: text("key").notNull(),
slug: text("slug").notNull(),
name: text("name").notNull(),
description: text("description"),
@@ -29,7 +30,7 @@ export const companySkills = pgTable(
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companySlugUniqueIdx: uniqueIndex("company_skills_company_slug_idx").on(table.companyId, table.slug),
companyKeyUniqueIdx: uniqueIndex("company_skills_company_key_idx").on(table.companyId, table.key),
companyNameIdx: index("company_skills_company_name_idx").on(table.companyId, table.name),
}),
);

View File

@@ -9,7 +9,8 @@ export type AgentSkillState =
| "external";
export interface AgentSkillEntry {
name: string;
key: string;
runtimeName: string | null;
desired: boolean;
managed: boolean;
required?: boolean;

View File

@@ -74,6 +74,7 @@ export interface CompanyPortabilityAgentManifestEntry {
}
export interface CompanyPortabilitySkillManifestEntry {
key: string;
slug: string;
name: string;
path: string;

View File

@@ -14,6 +14,7 @@ export interface CompanySkillFileInventoryEntry {
export interface CompanySkill {
id: string;
companyId: string;
key: string;
slug: string;
name: string;
description: string | null;
@@ -32,6 +33,7 @@ export interface CompanySkill {
export interface CompanySkillListItem {
id: string;
companyId: string;
key: string;
slug: string;
name: string;
description: string | null;

View File

@@ -16,7 +16,8 @@ export const agentSkillSyncModeSchema = z.enum([
]);
export const agentSkillEntrySchema = z.object({
name: z.string().min(1),
key: z.string().min(1),
runtimeName: z.string().min(1).nullable(),
desired: z.boolean(),
managed: z.boolean(),
required: z.boolean().optional(),

View File

@@ -46,6 +46,7 @@ export const portabilityAgentManifestEntrySchema = z.object({
});
export const portabilitySkillManifestEntrySchema = z.object({
key: z.string().min(1),
slug: z.string().min(1),
name: z.string().min(1),
path: z.string().min(1),

View File

@@ -13,6 +13,7 @@ export const companySkillFileInventoryEntrySchema = z.object({
export const companySkillSchema = z.object({
id: z.string().uuid(),
companyId: z.string().uuid(),
key: z.string().min(1),
slug: z.string().min(1),
name: z.string().min(1),
description: z.string().nullable(),