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:
parent
bb46423969
commit
5890b318c4
39 changed files with 9902 additions and 309 deletions
|
|
@ -38,7 +38,8 @@ const PAPERCLIP_SKILL_ROOT_RELATIVE_CANDIDATES = [
|
||||||
];
|
];
|
||||||
|
|
||||||
export interface PaperclipSkillEntry {
|
export interface PaperclipSkillEntry {
|
||||||
name: string;
|
key: string;
|
||||||
|
runtimeName: string;
|
||||||
source: string;
|
source: string;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
requiredReason?: string | null;
|
requiredReason?: string | null;
|
||||||
|
|
@ -306,7 +307,8 @@ export async function listPaperclipSkillEntries(
|
||||||
return entries
|
return entries
|
||||||
.filter((entry) => entry.isDirectory())
|
.filter((entry) => entry.isDirectory())
|
||||||
.map((entry) => ({
|
.map((entry) => ({
|
||||||
name: entry.name,
|
key: `paperclipai/paperclip/${entry.name}`,
|
||||||
|
runtimeName: entry.name,
|
||||||
source: path.join(root, entry.name),
|
source: path.join(root, entry.name),
|
||||||
required: true,
|
required: true,
|
||||||
requiredReason: "Bundled Paperclip skills are always available for local adapters.",
|
requiredReason: "Bundled Paperclip skills are always available for local adapters.",
|
||||||
|
|
@ -321,11 +323,13 @@ function normalizeConfiguredPaperclipRuntimeSkills(value: unknown): PaperclipSki
|
||||||
const out: PaperclipSkillEntry[] = [];
|
const out: PaperclipSkillEntry[] = [];
|
||||||
for (const rawEntry of value) {
|
for (const rawEntry of value) {
|
||||||
const entry = parseObject(rawEntry);
|
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();
|
const source = asString(entry.source, "").trim();
|
||||||
if (!name || !source) continue;
|
if (!key || !runtimeName || !source) continue;
|
||||||
out.push({
|
out.push({
|
||||||
name,
|
key,
|
||||||
|
runtimeName,
|
||||||
source,
|
source,
|
||||||
required: asBoolean(entry.required, false),
|
required: asBoolean(entry.required, false),
|
||||||
requiredReason:
|
requiredReason:
|
||||||
|
|
@ -349,13 +353,13 @@ export async function readPaperclipRuntimeSkillEntries(
|
||||||
|
|
||||||
export async function readPaperclipSkillMarkdown(
|
export async function readPaperclipSkillMarkdown(
|
||||||
moduleDir: string,
|
moduleDir: string,
|
||||||
skillName: string,
|
skillKey: string,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const normalized = skillName.trim().toLowerCase();
|
const normalized = skillKey.trim().toLowerCase();
|
||||||
if (!normalized) return null;
|
if (!normalized) return null;
|
||||||
|
|
||||||
const entries = await listPaperclipSkillEntries(moduleDir);
|
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;
|
if (!match) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -389,12 +393,12 @@ export function readPaperclipSkillSyncPreference(config: Record<string, unknown>
|
||||||
|
|
||||||
export function resolvePaperclipDesiredSkillNames(
|
export function resolvePaperclipDesiredSkillNames(
|
||||||
config: Record<string, unknown>,
|
config: Record<string, unknown>,
|
||||||
availableEntries: Array<{ name: string; required?: boolean }>,
|
availableEntries: Array<{ key: string; required?: boolean }>,
|
||||||
): string[] {
|
): string[] {
|
||||||
const preference = readPaperclipSkillSyncPreference(config);
|
const preference = readPaperclipSkillSyncPreference(config);
|
||||||
const requiredSkills = availableEntries
|
const requiredSkills = availableEntries
|
||||||
.filter((entry) => entry.required)
|
.filter((entry) => entry.required)
|
||||||
.map((entry) => entry.name);
|
.map((entry) => entry.key);
|
||||||
if (!preference.explicit) {
|
if (!preference.explicit) {
|
||||||
return Array.from(new Set(requiredSkills));
|
return Array.from(new Set(requiredSkills));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -158,7 +158,8 @@ export type AdapterSkillState =
|
||||||
| "external";
|
| "external";
|
||||||
|
|
||||||
export interface AdapterSkillEntry {
|
export interface AdapterSkillEntry {
|
||||||
name: string;
|
key: string;
|
||||||
|
runtimeName: string | null;
|
||||||
desired: boolean;
|
desired: boolean;
|
||||||
managed: boolean;
|
managed: boolean;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
|
|
|
||||||
|
|
@ -49,10 +49,10 @@ async function buildSkillsDir(config: Record<string, unknown>): Promise<string>
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
for (const entry of availableEntries) {
|
for (const entry of availableEntries) {
|
||||||
if (!desiredNames.has(entry.name)) continue;
|
if (!desiredNames.has(entry.key)) continue;
|
||||||
await fs.symlink(
|
await fs.symlink(
|
||||||
entry.source,
|
entry.source,
|
||||||
path.join(target, entry.name),
|
path.join(target, entry.runtimeName),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return tmp;
|
return tmp;
|
||||||
|
|
|
||||||
|
|
@ -14,17 +14,18 @@ const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
async function buildClaudeSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
|
async function buildClaudeSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
|
||||||
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
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 desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||||
const desiredSet = new Set(desiredSkills);
|
const desiredSet = new Set(desiredSkills);
|
||||||
const entries: AdapterSkillEntry[] = availableEntries.map((entry) => ({
|
const entries: AdapterSkillEntry[] = availableEntries.map((entry) => ({
|
||||||
name: entry.name,
|
key: entry.key,
|
||||||
desired: desiredSet.has(entry.name),
|
runtimeName: entry.runtimeName,
|
||||||
|
desired: desiredSet.has(entry.key),
|
||||||
managed: true,
|
managed: true,
|
||||||
state: desiredSet.has(entry.name) ? "configured" : "available",
|
state: desiredSet.has(entry.key) ? "configured" : "available",
|
||||||
sourcePath: entry.source,
|
sourcePath: entry.source,
|
||||||
targetPath: null,
|
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."
|
? "Will be mounted into the ephemeral Claude skill directory on the next run."
|
||||||
: null,
|
: null,
|
||||||
required: Boolean(entry.required),
|
required: Boolean(entry.required),
|
||||||
|
|
@ -33,10 +34,11 @@ async function buildClaudeSkillSnapshot(config: Record<string, unknown>): Promis
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
|
|
||||||
for (const desiredSkill of desiredSkills) {
|
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.`);
|
warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`);
|
||||||
entries.push({
|
entries.push({
|
||||||
name: desiredSkill,
|
key: desiredSkill,
|
||||||
|
runtimeName: null,
|
||||||
desired: true,
|
desired: true,
|
||||||
managed: true,
|
managed: true,
|
||||||
state: "missing",
|
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 {
|
return {
|
||||||
adapterType: "claude_local",
|
adapterType: "claude_local",
|
||||||
|
|
@ -71,7 +73,7 @@ export async function syncClaudeSkills(
|
||||||
|
|
||||||
export function resolveClaudeDesiredSkillNames(
|
export function resolveClaudeDesiredSkillNames(
|
||||||
config: Record<string, unknown>,
|
config: Record<string, unknown>,
|
||||||
availableEntries: Array<{ name: string; required?: boolean }>,
|
availableEntries: Array<{ key: string; required?: boolean }>,
|
||||||
) {
|
) {
|
||||||
return resolvePaperclipDesiredSkillNames(config, availableEntries);
|
return resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,7 @@ async function isLikelyPaperclipRuntimeSkillSource(candidate: string, skillName:
|
||||||
|
|
||||||
type EnsureCodexSkillsInjectedOptions = {
|
type EnsureCodexSkillsInjectedOptions = {
|
||||||
skillsHome?: string;
|
skillsHome?: string;
|
||||||
skillsEntries?: Array<{ name: string; source: string }>;
|
skillsEntries?: Array<{ key: string; runtimeName: string; source: string }>;
|
||||||
desiredSkillNames?: string[];
|
desiredSkillNames?: string[];
|
||||||
linkSkill?: (source: string, target: string) => Promise<void>;
|
linkSkill?: (source: string, target: string) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
@ -110,16 +110,16 @@ export async function ensureCodexSkillsInjected(
|
||||||
) {
|
) {
|
||||||
const allSkillsEntries = options.skillsEntries ?? await readPaperclipRuntimeSkillEntries({}, __moduleDir);
|
const allSkillsEntries = options.skillsEntries ?? await readPaperclipRuntimeSkillEntries({}, __moduleDir);
|
||||||
const desiredSkillNames =
|
const desiredSkillNames =
|
||||||
options.desiredSkillNames ?? allSkillsEntries.map((entry) => entry.name);
|
options.desiredSkillNames ?? allSkillsEntries.map((entry) => entry.key);
|
||||||
const desiredSet = new Set(desiredSkillNames);
|
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;
|
if (skillsEntries.length === 0) return;
|
||||||
|
|
||||||
const skillsHome = options.skillsHome ?? path.join(resolveCodexHomeDir(process.env), "skills");
|
const skillsHome = options.skillsHome ?? path.join(resolveCodexHomeDir(process.env), "skills");
|
||||||
await fs.mkdir(skillsHome, { recursive: true });
|
await fs.mkdir(skillsHome, { recursive: true });
|
||||||
const removedSkills = await removeMaintainerOnlySkillSymlinks(
|
const removedSkills = await removeMaintainerOnlySkillSymlinks(
|
||||||
skillsHome,
|
skillsHome,
|
||||||
skillsEntries.map((entry) => entry.name),
|
skillsEntries.map((entry) => entry.runtimeName),
|
||||||
);
|
);
|
||||||
for (const skillName of removedSkills) {
|
for (const skillName of removedSkills) {
|
||||||
await onLog(
|
await onLog(
|
||||||
|
|
@ -129,7 +129,7 @@ export async function ensureCodexSkillsInjected(
|
||||||
}
|
}
|
||||||
const linkSkill = options.linkSkill;
|
const linkSkill = options.linkSkill;
|
||||||
for (const entry of skillsEntries) {
|
for (const entry of skillsEntries) {
|
||||||
const target = path.join(skillsHome, entry.name);
|
const target = path.join(skillsHome, entry.runtimeName);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const existing = await fs.lstat(target).catch(() => null);
|
const existing = await fs.lstat(target).catch(() => null);
|
||||||
|
|
@ -141,7 +141,7 @@ export async function ensureCodexSkillsInjected(
|
||||||
if (
|
if (
|
||||||
resolvedLinkedPath &&
|
resolvedLinkedPath &&
|
||||||
resolvedLinkedPath !== entry.source &&
|
resolvedLinkedPath !== entry.source &&
|
||||||
(await isLikelyPaperclipRuntimeSkillSource(resolvedLinkedPath, entry.name))
|
(await isLikelyPaperclipRuntimeSkillSource(resolvedLinkedPath, entry.runtimeName))
|
||||||
) {
|
) {
|
||||||
await fs.unlink(target);
|
await fs.unlink(target);
|
||||||
if (linkSkill) {
|
if (linkSkill) {
|
||||||
|
|
@ -151,7 +151,7 @@ export async function ensureCodexSkillsInjected(
|
||||||
}
|
}
|
||||||
await onLog(
|
await onLog(
|
||||||
"stderr",
|
"stderr",
|
||||||
`[paperclip] Repaired Codex skill "${entry.name}" into ${skillsHome}\n`,
|
`[paperclip] Repaired Codex skill "${entry.key}" into ${skillsHome}\n`,
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -162,12 +162,12 @@ export async function ensureCodexSkillsInjected(
|
||||||
|
|
||||||
await onLog(
|
await onLog(
|
||||||
"stderr",
|
"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) {
|
} catch (err) {
|
||||||
await onLog(
|
await onLog(
|
||||||
"stderr",
|
"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`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ async function readInstalledSkillTargets(skillsHome: string) {
|
||||||
|
|
||||||
async function buildCodexSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
|
async function buildCodexSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
|
||||||
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
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 desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||||
const desiredSet = new Set(desiredSkills);
|
const desiredSet = new Set(desiredSkills);
|
||||||
const skillsHome = resolveCodexSkillsHome(config);
|
const skillsHome = resolveCodexSkillsHome(config);
|
||||||
|
|
@ -62,8 +62,8 @@ async function buildCodexSkillSnapshot(config: Record<string, unknown>): Promise
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
|
|
||||||
for (const available of availableEntries) {
|
for (const available of availableEntries) {
|
||||||
const installedEntry = installed.get(available.name) ?? null;
|
const installedEntry = installed.get(available.runtimeName) ?? null;
|
||||||
const desired = desiredSet.has(available.name);
|
const desired = desiredSet.has(available.key);
|
||||||
let state: AdapterSkillEntry["state"] = "available";
|
let state: AdapterSkillEntry["state"] = "available";
|
||||||
let managed = false;
|
let managed = false;
|
||||||
let detail: string | null = null;
|
let detail: string | null = null;
|
||||||
|
|
@ -82,12 +82,13 @@ async function buildCodexSkillSnapshot(config: Record<string, unknown>): Promise
|
||||||
}
|
}
|
||||||
|
|
||||||
entries.push({
|
entries.push({
|
||||||
name: available.name,
|
key: available.key,
|
||||||
|
runtimeName: available.runtimeName,
|
||||||
desired,
|
desired,
|
||||||
managed,
|
managed,
|
||||||
state,
|
state,
|
||||||
sourcePath: available.source,
|
sourcePath: available.source,
|
||||||
targetPath: path.join(skillsHome, available.name),
|
targetPath: path.join(skillsHome, available.runtimeName),
|
||||||
detail,
|
detail,
|
||||||
required: Boolean(available.required),
|
required: Boolean(available.required),
|
||||||
requiredReason: available.requiredReason ?? null,
|
requiredReason: available.requiredReason ?? null,
|
||||||
|
|
@ -95,23 +96,25 @@ async function buildCodexSkillSnapshot(config: Record<string, unknown>): Promise
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const desiredSkill of desiredSkills) {
|
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.`);
|
warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`);
|
||||||
entries.push({
|
entries.push({
|
||||||
name: desiredSkill,
|
key: desiredSkill,
|
||||||
|
runtimeName: null,
|
||||||
desired: true,
|
desired: true,
|
||||||
managed: true,
|
managed: true,
|
||||||
state: "missing",
|
state: "missing",
|
||||||
sourcePath: null,
|
sourcePath: null,
|
||||||
targetPath: path.join(skillsHome, desiredSkill),
|
targetPath: null,
|
||||||
detail: "Paperclip cannot find this skill in the local runtime skills directory.",
|
detail: "Paperclip cannot find this skill in the local runtime skills directory.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [name, installedEntry] of installed.entries()) {
|
for (const [name, installedEntry] of installed.entries()) {
|
||||||
if (availableByName.has(name)) continue;
|
if (availableEntries.some((entry) => entry.runtimeName === name)) continue;
|
||||||
entries.push({
|
entries.push({
|
||||||
name,
|
key: name,
|
||||||
|
runtimeName: name,
|
||||||
desired: false,
|
desired: false,
|
||||||
managed: false,
|
managed: false,
|
||||||
state: "external",
|
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 {
|
return {
|
||||||
adapterType: "codex_local",
|
adapterType: "codex_local",
|
||||||
|
|
@ -144,23 +147,23 @@ export async function syncCodexSkills(
|
||||||
const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir);
|
const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir);
|
||||||
const desiredSet = new Set([
|
const desiredSet = new Set([
|
||||||
...desiredSkills,
|
...desiredSkills,
|
||||||
...availableEntries.filter((entry) => entry.required).map((entry) => entry.name),
|
...availableEntries.filter((entry) => entry.required).map((entry) => entry.key),
|
||||||
]);
|
]);
|
||||||
const skillsHome = resolveCodexSkillsHome(ctx.config);
|
const skillsHome = resolveCodexSkillsHome(ctx.config);
|
||||||
await fs.mkdir(skillsHome, { recursive: true });
|
await fs.mkdir(skillsHome, { recursive: true });
|
||||||
const installed = await readInstalledSkillTargets(skillsHome);
|
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) {
|
for (const available of availableEntries) {
|
||||||
if (!desiredSet.has(available.name)) continue;
|
if (!desiredSet.has(available.key)) continue;
|
||||||
const target = path.join(skillsHome, available.name);
|
const target = path.join(skillsHome, available.runtimeName);
|
||||||
await ensurePaperclipSkillSymlink(available.source, target);
|
await ensurePaperclipSkillSymlink(available.source, target);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [name, installedEntry] of installed.entries()) {
|
for (const [name, installedEntry] of installed.entries()) {
|
||||||
const available = availableByName.get(name);
|
const available = availableByRuntimeName.get(name);
|
||||||
if (!available) continue;
|
if (!available) continue;
|
||||||
if (desiredSet.has(name)) continue;
|
if (desiredSet.has(available.key)) continue;
|
||||||
if (installedEntry.targetPath !== available.source) continue;
|
if (installedEntry.targetPath !== available.source) continue;
|
||||||
await fs.unlink(path.join(skillsHome, name)).catch(() => {});
|
await fs.unlink(path.join(skillsHome, name)).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
@ -170,7 +173,7 @@ export async function syncCodexSkills(
|
||||||
|
|
||||||
export function resolveCodexDesiredSkillNames(
|
export function resolveCodexDesiredSkillNames(
|
||||||
config: Record<string, unknown>,
|
config: Record<string, unknown>,
|
||||||
availableEntries: Array<{ name: string; required?: boolean }>,
|
availableEntries: Array<{ key: string; required?: boolean }>,
|
||||||
) {
|
) {
|
||||||
return resolvePaperclipDesiredSkillNames(config, availableEntries);
|
return resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -95,7 +95,7 @@ function cursorSkillsHome(): string {
|
||||||
|
|
||||||
type EnsureCursorSkillsInjectedOptions = {
|
type EnsureCursorSkillsInjectedOptions = {
|
||||||
skillsDir?: string | null;
|
skillsDir?: string | null;
|
||||||
skillsEntries?: Array<{ name: string; source: string }>;
|
skillsEntries?: Array<{ key: string; runtimeName: string; source: string }>;
|
||||||
skillsHome?: string;
|
skillsHome?: string;
|
||||||
linkSkill?: (source: string, target: string) => Promise<void>;
|
linkSkill?: (source: string, target: string) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
@ -108,7 +108,11 @@ export async function ensureCursorSkillsInjected(
|
||||||
?? (options.skillsDir
|
?? (options.skillsDir
|
||||||
? (await fs.readdir(options.skillsDir, { withFileTypes: true }))
|
? (await fs.readdir(options.skillsDir, { withFileTypes: true }))
|
||||||
.filter((entry) => entry.isDirectory())
|
.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));
|
: await readPaperclipRuntimeSkillEntries({}, __moduleDir));
|
||||||
if (skillsEntries.length === 0) return;
|
if (skillsEntries.length === 0) return;
|
||||||
|
|
||||||
|
|
@ -124,7 +128,7 @@ export async function ensureCursorSkillsInjected(
|
||||||
}
|
}
|
||||||
const removedSkills = await removeMaintainerOnlySkillSymlinks(
|
const removedSkills = await removeMaintainerOnlySkillSymlinks(
|
||||||
skillsHome,
|
skillsHome,
|
||||||
skillsEntries.map((entry) => entry.name),
|
skillsEntries.map((entry) => entry.runtimeName),
|
||||||
);
|
);
|
||||||
for (const skillName of removedSkills) {
|
for (const skillName of removedSkills) {
|
||||||
await onLog(
|
await onLog(
|
||||||
|
|
@ -134,19 +138,19 @@ export async function ensureCursorSkillsInjected(
|
||||||
}
|
}
|
||||||
const linkSkill = options.linkSkill ?? ((source: string, target: string) => fs.symlink(source, target));
|
const linkSkill = options.linkSkill ?? ((source: string, target: string) => fs.symlink(source, target));
|
||||||
for (const entry of skillsEntries) {
|
for (const entry of skillsEntries) {
|
||||||
const target = path.join(skillsHome, entry.name);
|
const target = path.join(skillsHome, entry.runtimeName);
|
||||||
try {
|
try {
|
||||||
const result = await ensurePaperclipSkillSymlink(entry.source, target, linkSkill);
|
const result = await ensurePaperclipSkillSymlink(entry.source, target, linkSkill);
|
||||||
if (result === "skipped") continue;
|
if (result === "skipped") continue;
|
||||||
|
|
||||||
await onLog(
|
await onLog(
|
||||||
"stderr",
|
"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) {
|
} catch (err) {
|
||||||
await onLog(
|
await onLog(
|
||||||
"stderr",
|
"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 cursorSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||||
const desiredCursorSkillNames = resolvePaperclipDesiredSkillNames(config, cursorSkillEntries);
|
const desiredCursorSkillNames = resolvePaperclipDesiredSkillNames(config, cursorSkillEntries);
|
||||||
await ensureCursorSkillsInjected(onLog, {
|
await ensureCursorSkillsInjected(onLog, {
|
||||||
skillsEntries: cursorSkillEntries.filter((entry) => desiredCursorSkillNames.includes(entry.name)),
|
skillsEntries: cursorSkillEntries.filter((entry) => desiredCursorSkillNames.includes(entry.key)),
|
||||||
});
|
});
|
||||||
|
|
||||||
const envConfig = parseObject(config.env);
|
const envConfig = parseObject(config.env);
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ async function readInstalledSkillTargets(skillsHome: string) {
|
||||||
|
|
||||||
async function buildCursorSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
|
async function buildCursorSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
|
||||||
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
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 desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||||
const desiredSet = new Set(desiredSkills);
|
const desiredSet = new Set(desiredSkills);
|
||||||
const skillsHome = resolveCursorSkillsHome(config);
|
const skillsHome = resolveCursorSkillsHome(config);
|
||||||
|
|
@ -62,8 +62,8 @@ async function buildCursorSkillSnapshot(config: Record<string, unknown>): Promis
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
|
|
||||||
for (const available of availableEntries) {
|
for (const available of availableEntries) {
|
||||||
const installedEntry = installed.get(available.name) ?? null;
|
const installedEntry = installed.get(available.runtimeName) ?? null;
|
||||||
const desired = desiredSet.has(available.name);
|
const desired = desiredSet.has(available.key);
|
||||||
let state: AdapterSkillEntry["state"] = "available";
|
let state: AdapterSkillEntry["state"] = "available";
|
||||||
let managed = false;
|
let managed = false;
|
||||||
let detail: string | null = null;
|
let detail: string | null = null;
|
||||||
|
|
@ -82,12 +82,13 @@ async function buildCursorSkillSnapshot(config: Record<string, unknown>): Promis
|
||||||
}
|
}
|
||||||
|
|
||||||
entries.push({
|
entries.push({
|
||||||
name: available.name,
|
key: available.key,
|
||||||
|
runtimeName: available.runtimeName,
|
||||||
desired,
|
desired,
|
||||||
managed,
|
managed,
|
||||||
state,
|
state,
|
||||||
sourcePath: available.source,
|
sourcePath: available.source,
|
||||||
targetPath: path.join(skillsHome, available.name),
|
targetPath: path.join(skillsHome, available.runtimeName),
|
||||||
detail,
|
detail,
|
||||||
required: Boolean(available.required),
|
required: Boolean(available.required),
|
||||||
requiredReason: available.requiredReason ?? null,
|
requiredReason: available.requiredReason ?? null,
|
||||||
|
|
@ -95,23 +96,25 @@ async function buildCursorSkillSnapshot(config: Record<string, unknown>): Promis
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const desiredSkill of desiredSkills) {
|
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.`);
|
warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`);
|
||||||
entries.push({
|
entries.push({
|
||||||
name: desiredSkill,
|
key: desiredSkill,
|
||||||
|
runtimeName: null,
|
||||||
desired: true,
|
desired: true,
|
||||||
managed: true,
|
managed: true,
|
||||||
state: "missing",
|
state: "missing",
|
||||||
sourcePath: null,
|
sourcePath: null,
|
||||||
targetPath: path.join(skillsHome, desiredSkill),
|
targetPath: null,
|
||||||
detail: "Paperclip cannot find this skill in the local runtime skills directory.",
|
detail: "Paperclip cannot find this skill in the local runtime skills directory.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [name, installedEntry] of installed.entries()) {
|
for (const [name, installedEntry] of installed.entries()) {
|
||||||
if (availableByName.has(name)) continue;
|
if (availableEntries.some((entry) => entry.runtimeName === name)) continue;
|
||||||
entries.push({
|
entries.push({
|
||||||
name,
|
key: name,
|
||||||
|
runtimeName: name,
|
||||||
desired: false,
|
desired: false,
|
||||||
managed: false,
|
managed: false,
|
||||||
state: "external",
|
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 {
|
return {
|
||||||
adapterType: "cursor",
|
adapterType: "cursor",
|
||||||
|
|
@ -144,23 +147,23 @@ export async function syncCursorSkills(
|
||||||
const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir);
|
const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir);
|
||||||
const desiredSet = new Set([
|
const desiredSet = new Set([
|
||||||
...desiredSkills,
|
...desiredSkills,
|
||||||
...availableEntries.filter((entry) => entry.required).map((entry) => entry.name),
|
...availableEntries.filter((entry) => entry.required).map((entry) => entry.key),
|
||||||
]);
|
]);
|
||||||
const skillsHome = resolveCursorSkillsHome(ctx.config);
|
const skillsHome = resolveCursorSkillsHome(ctx.config);
|
||||||
await fs.mkdir(skillsHome, { recursive: true });
|
await fs.mkdir(skillsHome, { recursive: true });
|
||||||
const installed = await readInstalledSkillTargets(skillsHome);
|
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) {
|
for (const available of availableEntries) {
|
||||||
if (!desiredSet.has(available.name)) continue;
|
if (!desiredSet.has(available.key)) continue;
|
||||||
const target = path.join(skillsHome, available.name);
|
const target = path.join(skillsHome, available.runtimeName);
|
||||||
await ensurePaperclipSkillSymlink(available.source, target);
|
await ensurePaperclipSkillSymlink(available.source, target);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [name, installedEntry] of installed.entries()) {
|
for (const [name, installedEntry] of installed.entries()) {
|
||||||
const available = availableByName.get(name);
|
const available = availableByRuntimeName.get(name);
|
||||||
if (!available) continue;
|
if (!available) continue;
|
||||||
if (desiredSet.has(name)) continue;
|
if (desiredSet.has(available.key)) continue;
|
||||||
if (installedEntry.targetPath !== available.source) continue;
|
if (installedEntry.targetPath !== available.source) continue;
|
||||||
await fs.unlink(path.join(skillsHome, name)).catch(() => {});
|
await fs.unlink(path.join(skillsHome, name)).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
@ -170,7 +173,7 @@ export async function syncCursorSkills(
|
||||||
|
|
||||||
export function resolveCursorDesiredSkillNames(
|
export function resolveCursorDesiredSkillNames(
|
||||||
config: Record<string, unknown>,
|
config: Record<string, unknown>,
|
||||||
availableEntries: Array<{ name: string; required?: boolean }>,
|
availableEntries: Array<{ key: string; required?: boolean }>,
|
||||||
) {
|
) {
|
||||||
return resolvePaperclipDesiredSkillNames(config, availableEntries);
|
return resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -85,11 +85,11 @@ function geminiSkillsHome(): string {
|
||||||
*/
|
*/
|
||||||
async function ensureGeminiSkillsInjected(
|
async function ensureGeminiSkillsInjected(
|
||||||
onLog: AdapterExecutionContext["onLog"],
|
onLog: AdapterExecutionContext["onLog"],
|
||||||
skillsEntries: Array<{ name: string; source: string }>,
|
skillsEntries: Array<{ key: string; runtimeName: string; source: string }>,
|
||||||
desiredSkillNames?: string[],
|
desiredSkillNames?: string[],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const desiredSet = new Set(desiredSkillNames ?? skillsEntries.map((entry) => entry.name));
|
const desiredSet = new Set(desiredSkillNames ?? skillsEntries.map((entry) => entry.key));
|
||||||
const selectedEntries = skillsEntries.filter((entry) => desiredSet.has(entry.name));
|
const selectedEntries = skillsEntries.filter((entry) => desiredSet.has(entry.key));
|
||||||
if (selectedEntries.length === 0) return;
|
if (selectedEntries.length === 0) return;
|
||||||
|
|
||||||
const skillsHome = geminiSkillsHome();
|
const skillsHome = geminiSkillsHome();
|
||||||
|
|
@ -104,7 +104,7 @@ async function ensureGeminiSkillsInjected(
|
||||||
}
|
}
|
||||||
const removedSkills = await removeMaintainerOnlySkillSymlinks(
|
const removedSkills = await removeMaintainerOnlySkillSymlinks(
|
||||||
skillsHome,
|
skillsHome,
|
||||||
selectedEntries.map((entry) => entry.name),
|
selectedEntries.map((entry) => entry.runtimeName),
|
||||||
);
|
);
|
||||||
for (const skillName of removedSkills) {
|
for (const skillName of removedSkills) {
|
||||||
await onLog(
|
await onLog(
|
||||||
|
|
@ -114,19 +114,19 @@ async function ensureGeminiSkillsInjected(
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const entry of selectedEntries) {
|
for (const entry of selectedEntries) {
|
||||||
const target = path.join(skillsHome, entry.name);
|
const target = path.join(skillsHome, entry.runtimeName);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await ensurePaperclipSkillSymlink(entry.source, target);
|
const result = await ensurePaperclipSkillSymlink(entry.source, target);
|
||||||
if (result === "skipped") continue;
|
if (result === "skipped") continue;
|
||||||
await onLog(
|
await onLog(
|
||||||
"stderr",
|
"stderr",
|
||||||
`[paperclip] ${result === "repaired" ? "Repaired" : "Linked"} Gemini skill: ${entry.name}\n`,
|
`[paperclip] ${result === "repaired" ? "Repaired" : "Linked"} Gemini skill: ${entry.key}\n`,
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await onLog(
|
await onLog(
|
||||||
"stderr",
|
"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`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ async function readInstalledSkillTargets(skillsHome: string) {
|
||||||
|
|
||||||
async function buildGeminiSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
|
async function buildGeminiSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
|
||||||
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
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 desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||||
const desiredSet = new Set(desiredSkills);
|
const desiredSet = new Set(desiredSkills);
|
||||||
const skillsHome = resolveGeminiSkillsHome(config);
|
const skillsHome = resolveGeminiSkillsHome(config);
|
||||||
|
|
@ -62,8 +62,8 @@ async function buildGeminiSkillSnapshot(config: Record<string, unknown>): Promis
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
|
|
||||||
for (const available of availableEntries) {
|
for (const available of availableEntries) {
|
||||||
const installedEntry = installed.get(available.name) ?? null;
|
const installedEntry = installed.get(available.runtimeName) ?? null;
|
||||||
const desired = desiredSet.has(available.name);
|
const desired = desiredSet.has(available.key);
|
||||||
let state: AdapterSkillEntry["state"] = "available";
|
let state: AdapterSkillEntry["state"] = "available";
|
||||||
let managed = false;
|
let managed = false;
|
||||||
let detail: string | null = null;
|
let detail: string | null = null;
|
||||||
|
|
@ -82,12 +82,13 @@ async function buildGeminiSkillSnapshot(config: Record<string, unknown>): Promis
|
||||||
}
|
}
|
||||||
|
|
||||||
entries.push({
|
entries.push({
|
||||||
name: available.name,
|
key: available.key,
|
||||||
|
runtimeName: available.runtimeName,
|
||||||
desired,
|
desired,
|
||||||
managed,
|
managed,
|
||||||
state,
|
state,
|
||||||
sourcePath: available.source,
|
sourcePath: available.source,
|
||||||
targetPath: path.join(skillsHome, available.name),
|
targetPath: path.join(skillsHome, available.runtimeName),
|
||||||
detail,
|
detail,
|
||||||
required: Boolean(available.required),
|
required: Boolean(available.required),
|
||||||
requiredReason: available.requiredReason ?? null,
|
requiredReason: available.requiredReason ?? null,
|
||||||
|
|
@ -95,23 +96,25 @@ async function buildGeminiSkillSnapshot(config: Record<string, unknown>): Promis
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const desiredSkill of desiredSkills) {
|
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.`);
|
warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`);
|
||||||
entries.push({
|
entries.push({
|
||||||
name: desiredSkill,
|
key: desiredSkill,
|
||||||
|
runtimeName: null,
|
||||||
desired: true,
|
desired: true,
|
||||||
managed: true,
|
managed: true,
|
||||||
state: "missing",
|
state: "missing",
|
||||||
sourcePath: null,
|
sourcePath: null,
|
||||||
targetPath: path.join(skillsHome, desiredSkill),
|
targetPath: null,
|
||||||
detail: "Paperclip cannot find this skill in the local runtime skills directory.",
|
detail: "Paperclip cannot find this skill in the local runtime skills directory.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [name, installedEntry] of installed.entries()) {
|
for (const [name, installedEntry] of installed.entries()) {
|
||||||
if (availableByName.has(name)) continue;
|
if (availableEntries.some((entry) => entry.runtimeName === name)) continue;
|
||||||
entries.push({
|
entries.push({
|
||||||
name,
|
key: name,
|
||||||
|
runtimeName: name,
|
||||||
desired: false,
|
desired: false,
|
||||||
managed: false,
|
managed: false,
|
||||||
state: "external",
|
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 {
|
return {
|
||||||
adapterType: "gemini_local",
|
adapterType: "gemini_local",
|
||||||
|
|
@ -144,23 +147,23 @@ export async function syncGeminiSkills(
|
||||||
const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir);
|
const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir);
|
||||||
const desiredSet = new Set([
|
const desiredSet = new Set([
|
||||||
...desiredSkills,
|
...desiredSkills,
|
||||||
...availableEntries.filter((entry) => entry.required).map((entry) => entry.name),
|
...availableEntries.filter((entry) => entry.required).map((entry) => entry.key),
|
||||||
]);
|
]);
|
||||||
const skillsHome = resolveGeminiSkillsHome(ctx.config);
|
const skillsHome = resolveGeminiSkillsHome(ctx.config);
|
||||||
await fs.mkdir(skillsHome, { recursive: true });
|
await fs.mkdir(skillsHome, { recursive: true });
|
||||||
const installed = await readInstalledSkillTargets(skillsHome);
|
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) {
|
for (const available of availableEntries) {
|
||||||
if (!desiredSet.has(available.name)) continue;
|
if (!desiredSet.has(available.key)) continue;
|
||||||
const target = path.join(skillsHome, available.name);
|
const target = path.join(skillsHome, available.runtimeName);
|
||||||
await ensurePaperclipSkillSymlink(available.source, target);
|
await ensurePaperclipSkillSymlink(available.source, target);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [name, installedEntry] of installed.entries()) {
|
for (const [name, installedEntry] of installed.entries()) {
|
||||||
const available = availableByName.get(name);
|
const available = availableByRuntimeName.get(name);
|
||||||
if (!available) continue;
|
if (!available) continue;
|
||||||
if (desiredSet.has(name)) continue;
|
if (desiredSet.has(available.key)) continue;
|
||||||
if (installedEntry.targetPath !== available.source) continue;
|
if (installedEntry.targetPath !== available.source) continue;
|
||||||
await fs.unlink(path.join(skillsHome, name)).catch(() => {});
|
await fs.unlink(path.join(skillsHome, name)).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
@ -170,7 +173,7 @@ export async function syncGeminiSkills(
|
||||||
|
|
||||||
export function resolveGeminiDesiredSkillNames(
|
export function resolveGeminiDesiredSkillNames(
|
||||||
config: Record<string, unknown>,
|
config: Record<string, unknown>,
|
||||||
availableEntries: Array<{ name: string; required?: boolean }>,
|
availableEntries: Array<{ key: string; required?: boolean }>,
|
||||||
) {
|
) {
|
||||||
return resolvePaperclipDesiredSkillNames(config, availableEntries);
|
return resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,16 +52,16 @@ function claudeSkillsHome(): string {
|
||||||
|
|
||||||
async function ensureOpenCodeSkillsInjected(
|
async function ensureOpenCodeSkillsInjected(
|
||||||
onLog: AdapterExecutionContext["onLog"],
|
onLog: AdapterExecutionContext["onLog"],
|
||||||
skillsEntries: Array<{ name: string; source: string }>,
|
skillsEntries: Array<{ key: string; runtimeName: string; source: string }>,
|
||||||
desiredSkillNames?: string[],
|
desiredSkillNames?: string[],
|
||||||
) {
|
) {
|
||||||
const skillsHome = claudeSkillsHome();
|
const skillsHome = claudeSkillsHome();
|
||||||
await fs.mkdir(skillsHome, { recursive: true });
|
await fs.mkdir(skillsHome, { recursive: true });
|
||||||
const desiredSet = new Set(desiredSkillNames ?? skillsEntries.map((entry) => entry.name));
|
const desiredSet = new Set(desiredSkillNames ?? skillsEntries.map((entry) => entry.key));
|
||||||
const selectedEntries = skillsEntries.filter((entry) => desiredSet.has(entry.name));
|
const selectedEntries = skillsEntries.filter((entry) => desiredSet.has(entry.key));
|
||||||
const removedSkills = await removeMaintainerOnlySkillSymlinks(
|
const removedSkills = await removeMaintainerOnlySkillSymlinks(
|
||||||
skillsHome,
|
skillsHome,
|
||||||
selectedEntries.map((entry) => entry.name),
|
selectedEntries.map((entry) => entry.runtimeName),
|
||||||
);
|
);
|
||||||
for (const skillName of removedSkills) {
|
for (const skillName of removedSkills) {
|
||||||
await onLog(
|
await onLog(
|
||||||
|
|
@ -70,19 +70,19 @@ async function ensureOpenCodeSkillsInjected(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
for (const entry of selectedEntries) {
|
for (const entry of selectedEntries) {
|
||||||
const target = path.join(skillsHome, entry.name);
|
const target = path.join(skillsHome, entry.runtimeName);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await ensurePaperclipSkillSymlink(entry.source, target);
|
const result = await ensurePaperclipSkillSymlink(entry.source, target);
|
||||||
if (result === "skipped") continue;
|
if (result === "skipped") continue;
|
||||||
await onLog(
|
await onLog(
|
||||||
"stderr",
|
"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) {
|
} catch (err) {
|
||||||
await onLog(
|
await onLog(
|
||||||
"stderr",
|
"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`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ async function readInstalledSkillTargets(skillsHome: string) {
|
||||||
|
|
||||||
async function buildOpenCodeSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
|
async function buildOpenCodeSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
|
||||||
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
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 desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||||
const desiredSet = new Set(desiredSkills);
|
const desiredSet = new Set(desiredSkills);
|
||||||
const skillsHome = resolveOpenCodeSkillsHome(config);
|
const skillsHome = resolveOpenCodeSkillsHome(config);
|
||||||
|
|
@ -64,8 +64,8 @@ async function buildOpenCodeSkillSnapshot(config: Record<string, unknown>): Prom
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const available of availableEntries) {
|
for (const available of availableEntries) {
|
||||||
const installedEntry = installed.get(available.name) ?? null;
|
const installedEntry = installed.get(available.runtimeName) ?? null;
|
||||||
const desired = desiredSet.has(available.name);
|
const desired = desiredSet.has(available.key);
|
||||||
let state: AdapterSkillEntry["state"] = "available";
|
let state: AdapterSkillEntry["state"] = "available";
|
||||||
let managed = false;
|
let managed = false;
|
||||||
let detail: string | null = null;
|
let detail: string | null = null;
|
||||||
|
|
@ -85,12 +85,13 @@ async function buildOpenCodeSkillSnapshot(config: Record<string, unknown>): Prom
|
||||||
}
|
}
|
||||||
|
|
||||||
entries.push({
|
entries.push({
|
||||||
name: available.name,
|
key: available.key,
|
||||||
|
runtimeName: available.runtimeName,
|
||||||
desired,
|
desired,
|
||||||
managed,
|
managed,
|
||||||
state,
|
state,
|
||||||
sourcePath: available.source,
|
sourcePath: available.source,
|
||||||
targetPath: path.join(skillsHome, available.name),
|
targetPath: path.join(skillsHome, available.runtimeName),
|
||||||
detail,
|
detail,
|
||||||
required: Boolean(available.required),
|
required: Boolean(available.required),
|
||||||
requiredReason: available.requiredReason ?? null,
|
requiredReason: available.requiredReason ?? null,
|
||||||
|
|
@ -98,23 +99,25 @@ async function buildOpenCodeSkillSnapshot(config: Record<string, unknown>): Prom
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const desiredSkill of desiredSkills) {
|
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.`);
|
warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`);
|
||||||
entries.push({
|
entries.push({
|
||||||
name: desiredSkill,
|
key: desiredSkill,
|
||||||
|
runtimeName: null,
|
||||||
desired: true,
|
desired: true,
|
||||||
managed: true,
|
managed: true,
|
||||||
state: "missing",
|
state: "missing",
|
||||||
sourcePath: null,
|
sourcePath: null,
|
||||||
targetPath: path.join(skillsHome, desiredSkill),
|
targetPath: null,
|
||||||
detail: "Paperclip cannot find this skill in the local runtime skills directory.",
|
detail: "Paperclip cannot find this skill in the local runtime skills directory.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [name, installedEntry] of installed.entries()) {
|
for (const [name, installedEntry] of installed.entries()) {
|
||||||
if (availableByName.has(name)) continue;
|
if (availableEntries.some((entry) => entry.runtimeName === name)) continue;
|
||||||
entries.push({
|
entries.push({
|
||||||
name,
|
key: name,
|
||||||
|
runtimeName: name,
|
||||||
desired: false,
|
desired: false,
|
||||||
managed: false,
|
managed: false,
|
||||||
state: "external",
|
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 {
|
return {
|
||||||
adapterType: "opencode_local",
|
adapterType: "opencode_local",
|
||||||
|
|
@ -147,23 +150,23 @@ export async function syncOpenCodeSkills(
|
||||||
const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir);
|
const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir);
|
||||||
const desiredSet = new Set([
|
const desiredSet = new Set([
|
||||||
...desiredSkills,
|
...desiredSkills,
|
||||||
...availableEntries.filter((entry) => entry.required).map((entry) => entry.name),
|
...availableEntries.filter((entry) => entry.required).map((entry) => entry.key),
|
||||||
]);
|
]);
|
||||||
const skillsHome = resolveOpenCodeSkillsHome(ctx.config);
|
const skillsHome = resolveOpenCodeSkillsHome(ctx.config);
|
||||||
await fs.mkdir(skillsHome, { recursive: true });
|
await fs.mkdir(skillsHome, { recursive: true });
|
||||||
const installed = await readInstalledSkillTargets(skillsHome);
|
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) {
|
for (const available of availableEntries) {
|
||||||
if (!desiredSet.has(available.name)) continue;
|
if (!desiredSet.has(available.key)) continue;
|
||||||
const target = path.join(skillsHome, available.name);
|
const target = path.join(skillsHome, available.runtimeName);
|
||||||
await ensurePaperclipSkillSymlink(available.source, target);
|
await ensurePaperclipSkillSymlink(available.source, target);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [name, installedEntry] of installed.entries()) {
|
for (const [name, installedEntry] of installed.entries()) {
|
||||||
const available = availableByName.get(name);
|
const available = availableByRuntimeName.get(name);
|
||||||
if (!available) continue;
|
if (!available) continue;
|
||||||
if (desiredSet.has(name)) continue;
|
if (desiredSet.has(available.key)) continue;
|
||||||
if (installedEntry.targetPath !== available.source) continue;
|
if (installedEntry.targetPath !== available.source) continue;
|
||||||
await fs.unlink(path.join(skillsHome, name)).catch(() => {});
|
await fs.unlink(path.join(skillsHome, name)).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
@ -173,7 +176,7 @@ export async function syncOpenCodeSkills(
|
||||||
|
|
||||||
export function resolveOpenCodeDesiredSkillNames(
|
export function resolveOpenCodeDesiredSkillNames(
|
||||||
config: Record<string, unknown>,
|
config: Record<string, unknown>,
|
||||||
availableEntries: Array<{ name: string; required?: boolean }>,
|
availableEntries: Array<{ key: string; required?: boolean }>,
|
||||||
) {
|
) {
|
||||||
return resolvePaperclipDesiredSkillNames(config, availableEntries);
|
return resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,17 +53,17 @@ function parseModelId(model: string | null): string | null {
|
||||||
|
|
||||||
async function ensurePiSkillsInjected(
|
async function ensurePiSkillsInjected(
|
||||||
onLog: AdapterExecutionContext["onLog"],
|
onLog: AdapterExecutionContext["onLog"],
|
||||||
skillsEntries: Array<{ name: string; source: string }>,
|
skillsEntries: Array<{ key: string; runtimeName: string; source: string }>,
|
||||||
desiredSkillNames?: string[],
|
desiredSkillNames?: string[],
|
||||||
) {
|
) {
|
||||||
const desiredSet = new Set(desiredSkillNames ?? skillsEntries.map((entry) => entry.name));
|
const desiredSet = new Set(desiredSkillNames ?? skillsEntries.map((entry) => entry.key));
|
||||||
const selectedEntries = skillsEntries.filter((entry) => desiredSet.has(entry.name));
|
const selectedEntries = skillsEntries.filter((entry) => desiredSet.has(entry.key));
|
||||||
if (selectedEntries.length === 0) return;
|
if (selectedEntries.length === 0) return;
|
||||||
const piSkillsHome = path.join(os.homedir(), ".pi", "agent", "skills");
|
const piSkillsHome = path.join(os.homedir(), ".pi", "agent", "skills");
|
||||||
await fs.mkdir(piSkillsHome, { recursive: true });
|
await fs.mkdir(piSkillsHome, { recursive: true });
|
||||||
const removedSkills = await removeMaintainerOnlySkillSymlinks(
|
const removedSkills = await removeMaintainerOnlySkillSymlinks(
|
||||||
piSkillsHome,
|
piSkillsHome,
|
||||||
selectedEntries.map((entry) => entry.name),
|
selectedEntries.map((entry) => entry.runtimeName),
|
||||||
);
|
);
|
||||||
for (const skillName of removedSkills) {
|
for (const skillName of removedSkills) {
|
||||||
await onLog(
|
await onLog(
|
||||||
|
|
@ -73,19 +73,19 @@ async function ensurePiSkillsInjected(
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const entry of selectedEntries) {
|
for (const entry of selectedEntries) {
|
||||||
const target = path.join(piSkillsHome, entry.name);
|
const target = path.join(piSkillsHome, entry.runtimeName);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await ensurePaperclipSkillSymlink(entry.source, target);
|
const result = await ensurePaperclipSkillSymlink(entry.source, target);
|
||||||
if (result === "skipped") continue;
|
if (result === "skipped") continue;
|
||||||
await onLog(
|
await onLog(
|
||||||
"stderr",
|
"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) {
|
} catch (err) {
|
||||||
await onLog(
|
await onLog(
|
||||||
"stderr",
|
"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`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ async function readInstalledSkillTargets(skillsHome: string) {
|
||||||
|
|
||||||
async function buildPiSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
|
async function buildPiSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
|
||||||
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
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 desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||||
const desiredSet = new Set(desiredSkills);
|
const desiredSet = new Set(desiredSkills);
|
||||||
const skillsHome = resolvePiSkillsHome(config);
|
const skillsHome = resolvePiSkillsHome(config);
|
||||||
|
|
@ -62,8 +62,8 @@ async function buildPiSkillSnapshot(config: Record<string, unknown>): Promise<Ad
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
|
|
||||||
for (const available of availableEntries) {
|
for (const available of availableEntries) {
|
||||||
const installedEntry = installed.get(available.name) ?? null;
|
const installedEntry = installed.get(available.runtimeName) ?? null;
|
||||||
const desired = desiredSet.has(available.name);
|
const desired = desiredSet.has(available.key);
|
||||||
let state: AdapterSkillEntry["state"] = "available";
|
let state: AdapterSkillEntry["state"] = "available";
|
||||||
let managed = false;
|
let managed = false;
|
||||||
let detail: string | null = null;
|
let detail: string | null = null;
|
||||||
|
|
@ -82,12 +82,13 @@ async function buildPiSkillSnapshot(config: Record<string, unknown>): Promise<Ad
|
||||||
}
|
}
|
||||||
|
|
||||||
entries.push({
|
entries.push({
|
||||||
name: available.name,
|
key: available.key,
|
||||||
|
runtimeName: available.runtimeName,
|
||||||
desired,
|
desired,
|
||||||
managed,
|
managed,
|
||||||
state,
|
state,
|
||||||
sourcePath: available.source,
|
sourcePath: available.source,
|
||||||
targetPath: path.join(skillsHome, available.name),
|
targetPath: path.join(skillsHome, available.runtimeName),
|
||||||
detail,
|
detail,
|
||||||
required: Boolean(available.required),
|
required: Boolean(available.required),
|
||||||
requiredReason: available.requiredReason ?? null,
|
requiredReason: available.requiredReason ?? null,
|
||||||
|
|
@ -95,23 +96,25 @@ async function buildPiSkillSnapshot(config: Record<string, unknown>): Promise<Ad
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const desiredSkill of desiredSkills) {
|
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.`);
|
warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`);
|
||||||
entries.push({
|
entries.push({
|
||||||
name: desiredSkill,
|
key: desiredSkill,
|
||||||
|
runtimeName: null,
|
||||||
desired: true,
|
desired: true,
|
||||||
managed: true,
|
managed: true,
|
||||||
state: "missing",
|
state: "missing",
|
||||||
sourcePath: null,
|
sourcePath: null,
|
||||||
targetPath: path.join(skillsHome, desiredSkill),
|
targetPath: null,
|
||||||
detail: "Paperclip cannot find this skill in the local runtime skills directory.",
|
detail: "Paperclip cannot find this skill in the local runtime skills directory.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [name, installedEntry] of installed.entries()) {
|
for (const [name, installedEntry] of installed.entries()) {
|
||||||
if (availableByName.has(name)) continue;
|
if (availableEntries.some((entry) => entry.runtimeName === name)) continue;
|
||||||
entries.push({
|
entries.push({
|
||||||
name,
|
key: name,
|
||||||
|
runtimeName: name,
|
||||||
desired: false,
|
desired: false,
|
||||||
managed: false,
|
managed: false,
|
||||||
state: "external",
|
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 {
|
return {
|
||||||
adapterType: "pi_local",
|
adapterType: "pi_local",
|
||||||
|
|
@ -144,23 +147,23 @@ export async function syncPiSkills(
|
||||||
const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir);
|
const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir);
|
||||||
const desiredSet = new Set([
|
const desiredSet = new Set([
|
||||||
...desiredSkills,
|
...desiredSkills,
|
||||||
...availableEntries.filter((entry) => entry.required).map((entry) => entry.name),
|
...availableEntries.filter((entry) => entry.required).map((entry) => entry.key),
|
||||||
]);
|
]);
|
||||||
const skillsHome = resolvePiSkillsHome(ctx.config);
|
const skillsHome = resolvePiSkillsHome(ctx.config);
|
||||||
await fs.mkdir(skillsHome, { recursive: true });
|
await fs.mkdir(skillsHome, { recursive: true });
|
||||||
const installed = await readInstalledSkillTargets(skillsHome);
|
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) {
|
for (const available of availableEntries) {
|
||||||
if (!desiredSet.has(available.name)) continue;
|
if (!desiredSet.has(available.key)) continue;
|
||||||
const target = path.join(skillsHome, available.name);
|
const target = path.join(skillsHome, available.runtimeName);
|
||||||
await ensurePaperclipSkillSymlink(available.source, target);
|
await ensurePaperclipSkillSymlink(available.source, target);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [name, installedEntry] of installed.entries()) {
|
for (const [name, installedEntry] of installed.entries()) {
|
||||||
const available = availableByName.get(name);
|
const available = availableByRuntimeName.get(name);
|
||||||
if (!available) continue;
|
if (!available) continue;
|
||||||
if (desiredSet.has(name)) continue;
|
if (desiredSet.has(available.key)) continue;
|
||||||
if (installedEntry.targetPath !== available.source) continue;
|
if (installedEntry.targetPath !== available.source) continue;
|
||||||
await fs.unlink(path.join(skillsHome, name)).catch(() => {});
|
await fs.unlink(path.join(skillsHome, name)).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
@ -170,7 +173,7 @@ export async function syncPiSkills(
|
||||||
|
|
||||||
export function resolvePiDesiredSkillNames(
|
export function resolvePiDesiredSkillNames(
|
||||||
config: Record<string, unknown>,
|
config: Record<string, unknown>,
|
||||||
availableEntries: Array<{ name: string; required?: boolean }>,
|
availableEntries: Array<{ key: string; required?: boolean }>,
|
||||||
) {
|
) {
|
||||||
return resolvePaperclipDesiredSkillNames(config, availableEntries);
|
return resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
27
packages/db/src/migrations/0035_colorful_rhino.sql
Normal file
27
packages/db/src/migrations/0035_colorful_rhino.sql
Normal 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");
|
||||||
9212
packages/db/src/migrations/meta/0035_snapshot.json
Normal file
9212
packages/db/src/migrations/meta/0035_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -246,6 +246,13 @@
|
||||||
"when": 1773697572188,
|
"when": 1773697572188,
|
||||||
"tag": "0034_fat_dormammu",
|
"tag": "0034_fat_dormammu",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 35,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1773703213570,
|
||||||
|
"tag": "0035_colorful_rhino",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -14,6 +14,7 @@ export const companySkills = pgTable(
|
||||||
{
|
{
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||||
|
key: text("key").notNull(),
|
||||||
slug: text("slug").notNull(),
|
slug: text("slug").notNull(),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
|
|
@ -29,7 +30,7 @@ export const companySkills = pgTable(
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
},
|
},
|
||||||
(table) => ({
|
(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),
|
companyNameIdx: index("company_skills_company_name_idx").on(table.companyId, table.name),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,8 @@ export type AgentSkillState =
|
||||||
| "external";
|
| "external";
|
||||||
|
|
||||||
export interface AgentSkillEntry {
|
export interface AgentSkillEntry {
|
||||||
name: string;
|
key: string;
|
||||||
|
runtimeName: string | null;
|
||||||
desired: boolean;
|
desired: boolean;
|
||||||
managed: boolean;
|
managed: boolean;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,7 @@ export interface CompanyPortabilityAgentManifestEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CompanyPortabilitySkillManifestEntry {
|
export interface CompanyPortabilitySkillManifestEntry {
|
||||||
|
key: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
name: string;
|
name: string;
|
||||||
path: string;
|
path: string;
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ export interface CompanySkillFileInventoryEntry {
|
||||||
export interface CompanySkill {
|
export interface CompanySkill {
|
||||||
id: string;
|
id: string;
|
||||||
companyId: string;
|
companyId: string;
|
||||||
|
key: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
|
@ -32,6 +33,7 @@ export interface CompanySkill {
|
||||||
export interface CompanySkillListItem {
|
export interface CompanySkillListItem {
|
||||||
id: string;
|
id: string;
|
||||||
companyId: string;
|
companyId: string;
|
||||||
|
key: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,8 @@ export const agentSkillSyncModeSchema = z.enum([
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const agentSkillEntrySchema = z.object({
|
export const agentSkillEntrySchema = z.object({
|
||||||
name: z.string().min(1),
|
key: z.string().min(1),
|
||||||
|
runtimeName: z.string().min(1).nullable(),
|
||||||
desired: z.boolean(),
|
desired: z.boolean(),
|
||||||
managed: z.boolean(),
|
managed: z.boolean(),
|
||||||
required: z.boolean().optional(),
|
required: z.boolean().optional(),
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ export const portabilityAgentManifestEntrySchema = z.object({
|
||||||
});
|
});
|
||||||
|
|
||||||
export const portabilitySkillManifestEntrySchema = z.object({
|
export const portabilitySkillManifestEntrySchema = z.object({
|
||||||
|
key: z.string().min(1),
|
||||||
slug: z.string().min(1),
|
slug: z.string().min(1),
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
path: z.string().min(1),
|
path: z.string().min(1),
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ export const companySkillFileInventoryEntrySchema = z.object({
|
||||||
export const companySkillSchema = z.object({
|
export const companySkillSchema = z.object({
|
||||||
id: z.string().uuid(),
|
id: z.string().uuid(),
|
||||||
companyId: z.string().uuid(),
|
companyId: z.string().uuid(),
|
||||||
|
key: z.string().min(1),
|
||||||
slug: z.string().min(1),
|
slug: z.string().min(1),
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
description: z.string().nullable(),
|
description: z.string().nullable(),
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,9 @@ import {
|
||||||
} from "@paperclipai/adapter-claude-local/server";
|
} from "@paperclipai/adapter-claude-local/server";
|
||||||
|
|
||||||
describe("claude local skill sync", () => {
|
describe("claude local skill sync", () => {
|
||||||
|
const paperclipKey = "paperclipai/paperclip/paperclip";
|
||||||
|
const createAgentKey = "paperclipai/paperclip/paperclip-create-agent";
|
||||||
|
|
||||||
it("defaults to mounting all built-in Paperclip skills when no explicit selection exists", async () => {
|
it("defaults to mounting all built-in Paperclip skills when no explicit selection exists", async () => {
|
||||||
const snapshot = await listClaudeSkills({
|
const snapshot = await listClaudeSkills({
|
||||||
agentId: "agent-1",
|
agentId: "agent-1",
|
||||||
|
|
@ -15,9 +18,9 @@ describe("claude local skill sync", () => {
|
||||||
|
|
||||||
expect(snapshot.mode).toBe("ephemeral");
|
expect(snapshot.mode).toBe("ephemeral");
|
||||||
expect(snapshot.supported).toBe(true);
|
expect(snapshot.supported).toBe(true);
|
||||||
expect(snapshot.desiredSkills).toContain("paperclip");
|
expect(snapshot.desiredSkills).toContain(paperclipKey);
|
||||||
expect(snapshot.entries.find((entry) => entry.name === "paperclip")?.required).toBe(true);
|
expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.required).toBe(true);
|
||||||
expect(snapshot.entries.find((entry) => entry.name === "paperclip")?.state).toBe("configured");
|
expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("respects an explicit desired skill list without mutating a persistent home", async () => {
|
it("respects an explicit desired skill list without mutating a persistent home", async () => {
|
||||||
|
|
@ -27,13 +30,13 @@ describe("claude local skill sync", () => {
|
||||||
adapterType: "claude_local",
|
adapterType: "claude_local",
|
||||||
config: {
|
config: {
|
||||||
paperclipSkillSync: {
|
paperclipSkillSync: {
|
||||||
desiredSkills: ["paperclip"],
|
desiredSkills: [paperclipKey],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, ["paperclip"]);
|
}, [paperclipKey]);
|
||||||
|
|
||||||
expect(snapshot.desiredSkills).toContain("paperclip");
|
expect(snapshot.desiredSkills).toContain(paperclipKey);
|
||||||
expect(snapshot.entries.find((entry) => entry.name === "paperclip")?.state).toBe("configured");
|
expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured");
|
||||||
expect(snapshot.entries.find((entry) => entry.name === "paperclip-create-agent")?.state).toBe("configured");
|
expect(snapshot.entries.find((entry) => entry.key === createAgentKey)?.state).toBe("configured");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ async function createCustomSkill(root: string, skillName: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("codex local adapter skill injection", () => {
|
describe("codex local adapter skill injection", () => {
|
||||||
|
const paperclipKey = "paperclipai/paperclip/paperclip";
|
||||||
const cleanupDirs = new Set<string>();
|
const cleanupDirs = new Set<string>();
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
|
|
@ -57,14 +58,18 @@ describe("codex local adapter skill injection", () => {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
skillsHome,
|
skillsHome,
|
||||||
skillsEntries: [{ name: "paperclip", source: path.join(currentRepo, "skills", "paperclip") }],
|
skillsEntries: [{
|
||||||
|
key: paperclipKey,
|
||||||
|
runtimeName: "paperclip",
|
||||||
|
source: path.join(currentRepo, "skills", "paperclip"),
|
||||||
|
}],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(await fs.realpath(path.join(skillsHome, "paperclip"))).toBe(
|
expect(await fs.realpath(path.join(skillsHome, "paperclip"))).toBe(
|
||||||
await fs.realpath(path.join(currentRepo, "skills", "paperclip")),
|
await fs.realpath(path.join(currentRepo, "skills", "paperclip")),
|
||||||
);
|
);
|
||||||
expect(logs.some((line) => line.includes('Repaired Codex skill "paperclip"'))).toBe(true);
|
expect(logs.some((line) => line.includes("Repaired Codex skill"))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves a custom Codex skill symlink outside Paperclip repo checkouts", async () => {
|
it("preserves a custom Codex skill symlink outside Paperclip repo checkouts", async () => {
|
||||||
|
|
@ -81,7 +86,11 @@ describe("codex local adapter skill injection", () => {
|
||||||
|
|
||||||
await ensureCodexSkillsInjected(async () => {}, {
|
await ensureCodexSkillsInjected(async () => {}, {
|
||||||
skillsHome,
|
skillsHome,
|
||||||
skillsEntries: [{ name: "paperclip", source: path.join(currentRepo, "skills", "paperclip") }],
|
skillsEntries: [{
|
||||||
|
key: paperclipKey,
|
||||||
|
runtimeName: "paperclip",
|
||||||
|
source: path.join(currentRepo, "skills", "paperclip"),
|
||||||
|
}],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(await fs.realpath(path.join(skillsHome, "paperclip"))).toBe(
|
expect(await fs.realpath(path.join(skillsHome, "paperclip"))).toBe(
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ async function makeTempDir(prefix: string): Promise<string> {
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("codex local skill sync", () => {
|
describe("codex local skill sync", () => {
|
||||||
|
const paperclipKey = "paperclipai/paperclip/paperclip";
|
||||||
const cleanupDirs = new Set<string>();
|
const cleanupDirs = new Set<string>();
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
|
|
@ -32,19 +33,19 @@ describe("codex local skill sync", () => {
|
||||||
CODEX_HOME: codexHome,
|
CODEX_HOME: codexHome,
|
||||||
},
|
},
|
||||||
paperclipSkillSync: {
|
paperclipSkillSync: {
|
||||||
desiredSkills: ["paperclip"],
|
desiredSkills: [paperclipKey],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const before = await listCodexSkills(ctx);
|
const before = await listCodexSkills(ctx);
|
||||||
expect(before.mode).toBe("persistent");
|
expect(before.mode).toBe("persistent");
|
||||||
expect(before.desiredSkills).toContain("paperclip");
|
expect(before.desiredSkills).toContain(paperclipKey);
|
||||||
expect(before.entries.find((entry) => entry.name === "paperclip")?.required).toBe(true);
|
expect(before.entries.find((entry) => entry.key === paperclipKey)?.required).toBe(true);
|
||||||
expect(before.entries.find((entry) => entry.name === "paperclip")?.state).toBe("missing");
|
expect(before.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("missing");
|
||||||
|
|
||||||
const after = await syncCodexSkills(ctx, ["paperclip"]);
|
const after = await syncCodexSkills(ctx, [paperclipKey]);
|
||||||
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("installed");
|
expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed");
|
||||||
expect((await fs.lstat(path.join(codexHome, "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
expect((await fs.lstat(path.join(codexHome, "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -61,12 +62,12 @@ describe("codex local skill sync", () => {
|
||||||
CODEX_HOME: codexHome,
|
CODEX_HOME: codexHome,
|
||||||
},
|
},
|
||||||
paperclipSkillSync: {
|
paperclipSkillSync: {
|
||||||
desiredSkills: ["paperclip"],
|
desiredSkills: [paperclipKey],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
await syncCodexSkills(configuredCtx, ["paperclip"]);
|
await syncCodexSkills(configuredCtx, [paperclipKey]);
|
||||||
|
|
||||||
const clearedCtx = {
|
const clearedCtx = {
|
||||||
...configuredCtx,
|
...configuredCtx,
|
||||||
|
|
@ -81,8 +82,8 @@ describe("codex local skill sync", () => {
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const after = await syncCodexSkills(clearedCtx, []);
|
const after = await syncCodexSkills(clearedCtx, []);
|
||||||
expect(after.desiredSkills).toContain("paperclip");
|
expect(after.desiredSkills).toContain(paperclipKey);
|
||||||
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("installed");
|
expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed");
|
||||||
expect((await fs.lstat(path.join(codexHome, "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
expect((await fs.lstat(path.join(codexHome, "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,9 @@ vi.mock("../services/company-skills.js", () => ({
|
||||||
const { companyPortabilityService } = await import("../services/company-portability.js");
|
const { companyPortabilityService } = await import("../services/company-portability.js");
|
||||||
|
|
||||||
describe("company portability", () => {
|
describe("company portability", () => {
|
||||||
|
const paperclipKey = "paperclipai/paperclip/paperclip";
|
||||||
|
const companyPlaybookKey = "company/company-1/company-playbook";
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
companySvc.getById.mockResolvedValue({
|
companySvc.getById.mockResolvedValue({
|
||||||
|
|
@ -86,7 +89,7 @@ describe("company portability", () => {
|
||||||
adapterConfig: {
|
adapterConfig: {
|
||||||
promptTemplate: "You are ClaudeCoder.",
|
promptTemplate: "You are ClaudeCoder.",
|
||||||
paperclipSkillSync: {
|
paperclipSkillSync: {
|
||||||
desiredSkills: ["paperclip"],
|
desiredSkills: [paperclipKey],
|
||||||
},
|
},
|
||||||
instructionsFilePath: "/tmp/ignored.md",
|
instructionsFilePath: "/tmp/ignored.md",
|
||||||
cwd: "/tmp/ignored",
|
cwd: "/tmp/ignored",
|
||||||
|
|
@ -153,6 +156,7 @@ describe("company portability", () => {
|
||||||
{
|
{
|
||||||
id: "skill-1",
|
id: "skill-1",
|
||||||
companyId: "company-1",
|
companyId: "company-1",
|
||||||
|
key: paperclipKey,
|
||||||
slug: "paperclip",
|
slug: "paperclip",
|
||||||
name: "paperclip",
|
name: "paperclip",
|
||||||
description: "Paperclip coordination skill",
|
description: "Paperclip coordination skill",
|
||||||
|
|
@ -178,6 +182,7 @@ describe("company portability", () => {
|
||||||
{
|
{
|
||||||
id: "skill-2",
|
id: "skill-2",
|
||||||
companyId: "company-1",
|
companyId: "company-1",
|
||||||
|
key: companyPlaybookKey,
|
||||||
slug: "company-playbook",
|
slug: "company-playbook",
|
||||||
name: "company-playbook",
|
name: "company-playbook",
|
||||||
description: "Internal company skill",
|
description: "Internal company skill",
|
||||||
|
|
@ -244,13 +249,13 @@ describe("company portability", () => {
|
||||||
expect(exported.files["COMPANY.md"]).toContain('schema: "agentcompanies/v1"');
|
expect(exported.files["COMPANY.md"]).toContain('schema: "agentcompanies/v1"');
|
||||||
expect(exported.files["agents/claudecoder/AGENTS.md"]).toContain("You are ClaudeCoder.");
|
expect(exported.files["agents/claudecoder/AGENTS.md"]).toContain("You are ClaudeCoder.");
|
||||||
expect(exported.files["agents/claudecoder/AGENTS.md"]).toContain("skills:");
|
expect(exported.files["agents/claudecoder/AGENTS.md"]).toContain("skills:");
|
||||||
expect(exported.files["agents/claudecoder/AGENTS.md"]).toContain('- "paperclip"');
|
expect(exported.files["agents/claudecoder/AGENTS.md"]).toContain(`- "${paperclipKey}"`);
|
||||||
expect(exported.files["agents/cmo/AGENTS.md"]).not.toContain("skills:");
|
expect(exported.files["agents/cmo/AGENTS.md"]).not.toContain("skills:");
|
||||||
expect(exported.files["skills/paperclip/SKILL.md"]).toContain("metadata:");
|
expect(exported.files[`skills/${paperclipKey}/SKILL.md`]).toContain("metadata:");
|
||||||
expect(exported.files["skills/paperclip/SKILL.md"]).toContain('kind: "github-dir"');
|
expect(exported.files[`skills/${paperclipKey}/SKILL.md`]).toContain('kind: "github-dir"');
|
||||||
expect(exported.files["skills/paperclip/references/api.md"]).toBeUndefined();
|
expect(exported.files[`skills/${paperclipKey}/references/api.md`]).toBeUndefined();
|
||||||
expect(exported.files["skills/company-playbook/SKILL.md"]).toContain("# Company Playbook");
|
expect(exported.files[`skills/${companyPlaybookKey}/SKILL.md`]).toContain("# Company Playbook");
|
||||||
expect(exported.files["skills/company-playbook/references/checklist.md"]).toContain("# Checklist");
|
expect(exported.files[`skills/${companyPlaybookKey}/references/checklist.md`]).toContain("# Checklist");
|
||||||
|
|
||||||
const extension = exported.files[".paperclip.yaml"];
|
const extension = exported.files[".paperclip.yaml"];
|
||||||
expect(extension).toContain('schema: "paperclip/v1"');
|
expect(extension).toContain('schema: "paperclip/v1"');
|
||||||
|
|
@ -284,9 +289,9 @@ describe("company portability", () => {
|
||||||
expandReferencedSkills: true,
|
expandReferencedSkills: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(exported.files["skills/paperclip/SKILL.md"]).toContain("# Paperclip");
|
expect(exported.files[`skills/${paperclipKey}/SKILL.md`]).toContain("# Paperclip");
|
||||||
expect(exported.files["skills/paperclip/SKILL.md"]).toContain("metadata:");
|
expect(exported.files[`skills/${paperclipKey}/SKILL.md`]).toContain("metadata:");
|
||||||
expect(exported.files["skills/paperclip/references/api.md"]).toContain("# API");
|
expect(exported.files[`skills/${paperclipKey}/references/api.md`]).toContain("# API");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reads env inputs back from .paperclip.yaml during preview import", async () => {
|
it("reads env inputs back from .paperclip.yaml during preview import", async () => {
|
||||||
|
|
@ -392,7 +397,7 @@ describe("company portability", () => {
|
||||||
expect(agentSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({
|
expect(agentSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({
|
||||||
adapterConfig: expect.objectContaining({
|
adapterConfig: expect.objectContaining({
|
||||||
paperclipSkillSync: {
|
paperclipSkillSync: {
|
||||||
desiredSkills: ["paperclip"],
|
desiredSkills: [paperclipKey],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ async function createSkillDir(root: string, name: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("cursor local skill sync", () => {
|
describe("cursor local skill sync", () => {
|
||||||
|
const paperclipKey = "paperclipai/paperclip/paperclip";
|
||||||
const cleanupDirs = new Set<string>();
|
const cleanupDirs = new Set<string>();
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
|
|
@ -39,19 +40,19 @@ describe("cursor local skill sync", () => {
|
||||||
HOME: home,
|
HOME: home,
|
||||||
},
|
},
|
||||||
paperclipSkillSync: {
|
paperclipSkillSync: {
|
||||||
desiredSkills: ["paperclip"],
|
desiredSkills: [paperclipKey],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const before = await listCursorSkills(ctx);
|
const before = await listCursorSkills(ctx);
|
||||||
expect(before.mode).toBe("persistent");
|
expect(before.mode).toBe("persistent");
|
||||||
expect(before.desiredSkills).toContain("paperclip");
|
expect(before.desiredSkills).toContain(paperclipKey);
|
||||||
expect(before.entries.find((entry) => entry.name === "paperclip")?.required).toBe(true);
|
expect(before.entries.find((entry) => entry.key === paperclipKey)?.required).toBe(true);
|
||||||
expect(before.entries.find((entry) => entry.name === "paperclip")?.state).toBe("missing");
|
expect(before.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("missing");
|
||||||
|
|
||||||
const after = await syncCursorSkills(ctx, ["paperclip"]);
|
const after = await syncCursorSkills(ctx, [paperclipKey]);
|
||||||
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("installed");
|
expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed");
|
||||||
expect((await fs.lstat(path.join(home, ".cursor", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
expect((await fs.lstat(path.join(home, ".cursor", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -74,13 +75,15 @@ describe("cursor local skill sync", () => {
|
||||||
},
|
},
|
||||||
paperclipRuntimeSkills: [
|
paperclipRuntimeSkills: [
|
||||||
{
|
{
|
||||||
name: "paperclip",
|
key: "paperclip",
|
||||||
|
runtimeName: "paperclip",
|
||||||
source: paperclipDir,
|
source: paperclipDir,
|
||||||
required: true,
|
required: true,
|
||||||
requiredReason: "Bundled Paperclip skills are always available for local adapters.",
|
requiredReason: "Bundled Paperclip skills are always available for local adapters.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "ascii-heart",
|
key: "ascii-heart",
|
||||||
|
runtimeName: "ascii-heart",
|
||||||
source: asciiHeartDir,
|
source: asciiHeartDir,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
@ -93,11 +96,11 @@ describe("cursor local skill sync", () => {
|
||||||
const before = await listCursorSkills(ctx);
|
const before = await listCursorSkills(ctx);
|
||||||
expect(before.warnings).toEqual([]);
|
expect(before.warnings).toEqual([]);
|
||||||
expect(before.desiredSkills).toEqual(["paperclip", "ascii-heart"]);
|
expect(before.desiredSkills).toEqual(["paperclip", "ascii-heart"]);
|
||||||
expect(before.entries.find((entry) => entry.name === "ascii-heart")?.state).toBe("missing");
|
expect(before.entries.find((entry) => entry.key === "ascii-heart")?.state).toBe("missing");
|
||||||
|
|
||||||
const after = await syncCursorSkills(ctx, ["ascii-heart"]);
|
const after = await syncCursorSkills(ctx, ["ascii-heart"]);
|
||||||
expect(after.warnings).toEqual([]);
|
expect(after.warnings).toEqual([]);
|
||||||
expect(after.entries.find((entry) => entry.name === "ascii-heart")?.state).toBe("installed");
|
expect(after.entries.find((entry) => entry.key === "ascii-heart")?.state).toBe("installed");
|
||||||
expect((await fs.lstat(path.join(home, ".cursor", "skills", "ascii-heart"))).isSymbolicLink()).toBe(true);
|
expect((await fs.lstat(path.join(home, ".cursor", "skills", "ascii-heart"))).isSymbolicLink()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -114,12 +117,12 @@ describe("cursor local skill sync", () => {
|
||||||
HOME: home,
|
HOME: home,
|
||||||
},
|
},
|
||||||
paperclipSkillSync: {
|
paperclipSkillSync: {
|
||||||
desiredSkills: ["paperclip"],
|
desiredSkills: [paperclipKey],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
await syncCursorSkills(configuredCtx, ["paperclip"]);
|
await syncCursorSkills(configuredCtx, [paperclipKey]);
|
||||||
|
|
||||||
const clearedCtx = {
|
const clearedCtx = {
|
||||||
...configuredCtx,
|
...configuredCtx,
|
||||||
|
|
@ -134,8 +137,8 @@ describe("cursor local skill sync", () => {
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const after = await syncCursorSkills(clearedCtx, []);
|
const after = await syncCursorSkills(clearedCtx, []);
|
||||||
expect(after.desiredSkills).toContain("paperclip");
|
expect(after.desiredSkills).toContain(paperclipKey);
|
||||||
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("installed");
|
expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed");
|
||||||
expect((await fs.lstat(path.join(home, ".cursor", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
expect((await fs.lstat(path.join(home, ".cursor", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ async function makeTempDir(prefix: string): Promise<string> {
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("gemini local skill sync", () => {
|
describe("gemini local skill sync", () => {
|
||||||
|
const paperclipKey = "paperclipai/paperclip/paperclip";
|
||||||
const cleanupDirs = new Set<string>();
|
const cleanupDirs = new Set<string>();
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
|
|
@ -32,19 +33,19 @@ describe("gemini local skill sync", () => {
|
||||||
HOME: home,
|
HOME: home,
|
||||||
},
|
},
|
||||||
paperclipSkillSync: {
|
paperclipSkillSync: {
|
||||||
desiredSkills: ["paperclip"],
|
desiredSkills: [paperclipKey],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const before = await listGeminiSkills(ctx);
|
const before = await listGeminiSkills(ctx);
|
||||||
expect(before.mode).toBe("persistent");
|
expect(before.mode).toBe("persistent");
|
||||||
expect(before.desiredSkills).toContain("paperclip");
|
expect(before.desiredSkills).toContain(paperclipKey);
|
||||||
expect(before.entries.find((entry) => entry.name === "paperclip")?.required).toBe(true);
|
expect(before.entries.find((entry) => entry.key === paperclipKey)?.required).toBe(true);
|
||||||
expect(before.entries.find((entry) => entry.name === "paperclip")?.state).toBe("missing");
|
expect(before.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("missing");
|
||||||
|
|
||||||
const after = await syncGeminiSkills(ctx, ["paperclip"]);
|
const after = await syncGeminiSkills(ctx, [paperclipKey]);
|
||||||
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("installed");
|
expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed");
|
||||||
expect((await fs.lstat(path.join(home, ".gemini", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
expect((await fs.lstat(path.join(home, ".gemini", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -61,12 +62,12 @@ describe("gemini local skill sync", () => {
|
||||||
HOME: home,
|
HOME: home,
|
||||||
},
|
},
|
||||||
paperclipSkillSync: {
|
paperclipSkillSync: {
|
||||||
desiredSkills: ["paperclip"],
|
desiredSkills: [paperclipKey],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
await syncGeminiSkills(configuredCtx, ["paperclip"]);
|
await syncGeminiSkills(configuredCtx, [paperclipKey]);
|
||||||
|
|
||||||
const clearedCtx = {
|
const clearedCtx = {
|
||||||
...configuredCtx,
|
...configuredCtx,
|
||||||
|
|
@ -81,8 +82,8 @@ describe("gemini local skill sync", () => {
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const after = await syncGeminiSkills(clearedCtx, []);
|
const after = await syncGeminiSkills(clearedCtx, []);
|
||||||
expect(after.desiredSkills).toContain("paperclip");
|
expect(after.desiredSkills).toContain(paperclipKey);
|
||||||
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("installed");
|
expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed");
|
||||||
expect((await fs.lstat(path.join(home, ".gemini", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
expect((await fs.lstat(path.join(home, ".gemini", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ async function makeTempDir(prefix: string): Promise<string> {
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("opencode local skill sync", () => {
|
describe("opencode local skill sync", () => {
|
||||||
|
const paperclipKey = "paperclipai/paperclip/paperclip";
|
||||||
const cleanupDirs = new Set<string>();
|
const cleanupDirs = new Set<string>();
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
|
|
@ -32,7 +33,7 @@ describe("opencode local skill sync", () => {
|
||||||
HOME: home,
|
HOME: home,
|
||||||
},
|
},
|
||||||
paperclipSkillSync: {
|
paperclipSkillSync: {
|
||||||
desiredSkills: ["paperclip"],
|
desiredSkills: [paperclipKey],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
@ -40,12 +41,12 @@ describe("opencode local skill sync", () => {
|
||||||
const before = await listOpenCodeSkills(ctx);
|
const before = await listOpenCodeSkills(ctx);
|
||||||
expect(before.mode).toBe("persistent");
|
expect(before.mode).toBe("persistent");
|
||||||
expect(before.warnings).toContain("OpenCode currently uses the shared Claude skills home (~/.claude/skills).");
|
expect(before.warnings).toContain("OpenCode currently uses the shared Claude skills home (~/.claude/skills).");
|
||||||
expect(before.desiredSkills).toContain("paperclip");
|
expect(before.desiredSkills).toContain(paperclipKey);
|
||||||
expect(before.entries.find((entry) => entry.name === "paperclip")?.required).toBe(true);
|
expect(before.entries.find((entry) => entry.key === paperclipKey)?.required).toBe(true);
|
||||||
expect(before.entries.find((entry) => entry.name === "paperclip")?.state).toBe("missing");
|
expect(before.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("missing");
|
||||||
|
|
||||||
const after = await syncOpenCodeSkills(ctx, ["paperclip"]);
|
const after = await syncOpenCodeSkills(ctx, [paperclipKey]);
|
||||||
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("installed");
|
expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed");
|
||||||
expect((await fs.lstat(path.join(home, ".claude", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
expect((await fs.lstat(path.join(home, ".claude", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -62,12 +63,12 @@ describe("opencode local skill sync", () => {
|
||||||
HOME: home,
|
HOME: home,
|
||||||
},
|
},
|
||||||
paperclipSkillSync: {
|
paperclipSkillSync: {
|
||||||
desiredSkills: ["paperclip"],
|
desiredSkills: [paperclipKey],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
await syncOpenCodeSkills(configuredCtx, ["paperclip"]);
|
await syncOpenCodeSkills(configuredCtx, [paperclipKey]);
|
||||||
|
|
||||||
const clearedCtx = {
|
const clearedCtx = {
|
||||||
...configuredCtx,
|
...configuredCtx,
|
||||||
|
|
@ -82,8 +83,8 @@ describe("opencode local skill sync", () => {
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const after = await syncOpenCodeSkills(clearedCtx, []);
|
const after = await syncOpenCodeSkills(clearedCtx, []);
|
||||||
expect(after.desiredSkills).toContain("paperclip");
|
expect(after.desiredSkills).toContain(paperclipKey);
|
||||||
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("installed");
|
expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed");
|
||||||
expect((await fs.lstat(path.join(home, ".claude", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
expect((await fs.lstat(path.join(home, ".claude", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,8 @@ describe("paperclip skill utils", () => {
|
||||||
|
|
||||||
const entries = await listPaperclipSkillEntries(moduleDir);
|
const entries = await listPaperclipSkillEntries(moduleDir);
|
||||||
|
|
||||||
expect(entries.map((entry) => entry.name)).toEqual(["paperclip"]);
|
expect(entries.map((entry) => entry.key)).toEqual(["paperclipai/paperclip/paperclip"]);
|
||||||
|
expect(entries.map((entry) => entry.runtimeName)).toEqual(["paperclip"]);
|
||||||
expect(entries[0]?.source).toBe(path.join(root, "skills", "paperclip"));
|
expect(entries[0]?.source).toBe(path.join(root, "skills", "paperclip"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ async function makeTempDir(prefix: string): Promise<string> {
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("pi local skill sync", () => {
|
describe("pi local skill sync", () => {
|
||||||
|
const paperclipKey = "paperclipai/paperclip/paperclip";
|
||||||
const cleanupDirs = new Set<string>();
|
const cleanupDirs = new Set<string>();
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
|
|
@ -32,19 +33,19 @@ describe("pi local skill sync", () => {
|
||||||
HOME: home,
|
HOME: home,
|
||||||
},
|
},
|
||||||
paperclipSkillSync: {
|
paperclipSkillSync: {
|
||||||
desiredSkills: ["paperclip"],
|
desiredSkills: [paperclipKey],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const before = await listPiSkills(ctx);
|
const before = await listPiSkills(ctx);
|
||||||
expect(before.mode).toBe("persistent");
|
expect(before.mode).toBe("persistent");
|
||||||
expect(before.desiredSkills).toContain("paperclip");
|
expect(before.desiredSkills).toContain(paperclipKey);
|
||||||
expect(before.entries.find((entry) => entry.name === "paperclip")?.required).toBe(true);
|
expect(before.entries.find((entry) => entry.key === paperclipKey)?.required).toBe(true);
|
||||||
expect(before.entries.find((entry) => entry.name === "paperclip")?.state).toBe("missing");
|
expect(before.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("missing");
|
||||||
|
|
||||||
const after = await syncPiSkills(ctx, ["paperclip"]);
|
const after = await syncPiSkills(ctx, [paperclipKey]);
|
||||||
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("installed");
|
expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed");
|
||||||
expect((await fs.lstat(path.join(home, ".pi", "agent", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
expect((await fs.lstat(path.join(home, ".pi", "agent", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -61,12 +62,12 @@ describe("pi local skill sync", () => {
|
||||||
HOME: home,
|
HOME: home,
|
||||||
},
|
},
|
||||||
paperclipSkillSync: {
|
paperclipSkillSync: {
|
||||||
desiredSkills: ["paperclip"],
|
desiredSkills: [paperclipKey],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
await syncPiSkills(configuredCtx, ["paperclip"]);
|
await syncPiSkills(configuredCtx, [paperclipKey]);
|
||||||
|
|
||||||
const clearedCtx = {
|
const clearedCtx = {
|
||||||
...configuredCtx,
|
...configuredCtx,
|
||||||
|
|
@ -81,8 +82,8 @@ describe("pi local skill sync", () => {
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const after = await syncPiSkills(clearedCtx, []);
|
const after = await syncPiSkills(clearedCtx, []);
|
||||||
expect(after.desiredSkills).toContain("paperclip");
|
expect(after.desiredSkills).toContain(paperclipKey);
|
||||||
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("installed");
|
expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed");
|
||||||
expect((await fs.lstat(path.join(home, ".pi", "agent", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
expect((await fs.lstat(path.join(home, ".pi", "agent", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -506,7 +506,7 @@ export function agentRoutes(db: Db) {
|
||||||
agent.adapterConfig as Record<string, unknown>,
|
agent.adapterConfig as Record<string, unknown>,
|
||||||
);
|
);
|
||||||
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId);
|
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId);
|
||||||
const requiredSkills = runtimeSkillEntries.filter((entry) => entry.required).map((entry) => entry.name);
|
const requiredSkills = runtimeSkillEntries.filter((entry) => entry.required).map((entry) => entry.key);
|
||||||
res.json(buildUnsupportedSkillSnapshot(agent.adapterType, Array.from(new Set([...requiredSkills, ...preference.desiredSkills]))));
|
res.json(buildUnsupportedSkillSnapshot(agent.adapterType, Array.from(new Set([...requiredSkills, ...preference.desiredSkills]))));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -545,7 +545,7 @@ export function agentRoutes(db: Db) {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId);
|
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId);
|
||||||
const requiredSkills = runtimeSkillEntries.filter((entry) => entry.required).map((entry) => entry.name);
|
const requiredSkills = runtimeSkillEntries.filter((entry) => entry.required).map((entry) => entry.key);
|
||||||
const desiredSkills = Array.from(new Set([...requiredSkills, ...requestedSkills]));
|
const desiredSkills = Array.from(new Set([...requiredSkills, ...requestedSkills]));
|
||||||
const nextAdapterConfig = writePaperclipSkillSyncPreference(
|
const nextAdapterConfig = writePaperclipSkillSyncPreference(
|
||||||
agent.adapterConfig as Record<string, unknown>,
|
agent.adapterConfig as Record<string, unknown>,
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,67 @@ const DEFAULT_COLLISION_STRATEGY: CompanyPortabilityCollisionStrategy = "rename"
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
let bundledSkillsCommitPromise: Promise<string | null> | null = null;
|
let bundledSkillsCommitPromise: Promise<string | null> | null = null;
|
||||||
|
|
||||||
|
function normalizeSkillSlug(value: string | null | undefined) {
|
||||||
|
return value ? normalizeAgentUrlKey(value) ?? null : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSkillKey(value: string | null | undefined) {
|
||||||
|
if (!value) return null;
|
||||||
|
const segments = value
|
||||||
|
.split("/")
|
||||||
|
.map((segment) => normalizeSkillSlug(segment))
|
||||||
|
.filter((segment): segment is string => Boolean(segment));
|
||||||
|
return segments.length > 0 ? segments.join("/") : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSkillKey(frontmatter: Record<string, unknown>) {
|
||||||
|
const metadata = isPlainRecord(frontmatter.metadata) ? frontmatter.metadata : null;
|
||||||
|
const paperclip = isPlainRecord(metadata?.paperclip) ? metadata?.paperclip as Record<string, unknown> : null;
|
||||||
|
return normalizeSkillKey(
|
||||||
|
asString(frontmatter.key)
|
||||||
|
?? asString(frontmatter.skillKey)
|
||||||
|
?? asString(metadata?.skillKey)
|
||||||
|
?? asString(metadata?.canonicalKey)
|
||||||
|
?? asString(metadata?.paperclipSkillKey)
|
||||||
|
?? asString(paperclip?.skillKey)
|
||||||
|
?? asString(paperclip?.key),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveManifestSkillKey(
|
||||||
|
frontmatter: Record<string, unknown>,
|
||||||
|
fallbackSlug: string,
|
||||||
|
metadata: Record<string, unknown> | null,
|
||||||
|
sourceType: string,
|
||||||
|
sourceLocator: string | null,
|
||||||
|
) {
|
||||||
|
const explicit = readSkillKey(frontmatter);
|
||||||
|
if (explicit) return explicit;
|
||||||
|
const slug = normalizeSkillSlug(asString(frontmatter.slug) ?? fallbackSlug) ?? "skill";
|
||||||
|
const sourceKind = asString(metadata?.sourceKind);
|
||||||
|
const owner = normalizeSkillSlug(asString(metadata?.owner));
|
||||||
|
const repo = normalizeSkillSlug(asString(metadata?.repo));
|
||||||
|
if ((sourceType === "github" || sourceKind === "github") && owner && repo) {
|
||||||
|
return `${owner}/${repo}/${slug}`;
|
||||||
|
}
|
||||||
|
if (sourceKind === "paperclip_bundled") {
|
||||||
|
return `paperclipai/paperclip/${slug}`;
|
||||||
|
}
|
||||||
|
if (sourceType === "url" || sourceKind === "url") {
|
||||||
|
try {
|
||||||
|
const host = normalizeSkillSlug(sourceLocator ? new URL(sourceLocator).host : null) ?? "url";
|
||||||
|
return `url/${host}/${slug}`;
|
||||||
|
} catch {
|
||||||
|
return `url/unknown/${slug}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
function skillPackageDir(key: string) {
|
||||||
|
return `skills/${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
function isSensitiveEnvKey(key: string) {
|
function isSensitiveEnvKey(key: string) {
|
||||||
const normalized = key.trim().toLowerCase();
|
const normalized = key.trim().toLowerCase();
|
||||||
return (
|
return (
|
||||||
|
|
@ -748,6 +809,8 @@ function shouldReferenceSkillOnExport(skill: CompanySkill, expandReferencedSkill
|
||||||
async function buildReferencedSkillMarkdown(skill: CompanySkill) {
|
async function buildReferencedSkillMarkdown(skill: CompanySkill) {
|
||||||
const sourceEntry = await buildSkillSourceEntry(skill);
|
const sourceEntry = await buildSkillSourceEntry(skill);
|
||||||
const frontmatter: Record<string, unknown> = {
|
const frontmatter: Record<string, unknown> = {
|
||||||
|
key: skill.key,
|
||||||
|
slug: skill.slug,
|
||||||
name: skill.name,
|
name: skill.name,
|
||||||
description: skill.description ?? null,
|
description: skill.description ?? null,
|
||||||
};
|
};
|
||||||
|
|
@ -761,7 +824,6 @@ async function buildReferencedSkillMarkdown(skill: CompanySkill) {
|
||||||
|
|
||||||
async function withSkillSourceMetadata(skill: CompanySkill, markdown: string) {
|
async function withSkillSourceMetadata(skill: CompanySkill, markdown: string) {
|
||||||
const sourceEntry = await buildSkillSourceEntry(skill);
|
const sourceEntry = await buildSkillSourceEntry(skill);
|
||||||
if (!sourceEntry) return markdown;
|
|
||||||
const parsed = parseFrontmatterMarkdown(markdown);
|
const parsed = parseFrontmatterMarkdown(markdown);
|
||||||
const metadata = isPlainRecord(parsed.frontmatter.metadata)
|
const metadata = isPlainRecord(parsed.frontmatter.metadata)
|
||||||
? { ...parsed.frontmatter.metadata }
|
? { ...parsed.frontmatter.metadata }
|
||||||
|
|
@ -769,9 +831,20 @@ async function withSkillSourceMetadata(skill: CompanySkill, markdown: string) {
|
||||||
const existingSources = Array.isArray(metadata.sources)
|
const existingSources = Array.isArray(metadata.sources)
|
||||||
? metadata.sources.filter((entry) => isPlainRecord(entry))
|
? metadata.sources.filter((entry) => isPlainRecord(entry))
|
||||||
: [];
|
: [];
|
||||||
metadata.sources = [...existingSources, sourceEntry];
|
if (sourceEntry) {
|
||||||
|
metadata.sources = [...existingSources, sourceEntry];
|
||||||
|
}
|
||||||
|
metadata.skillKey = skill.key;
|
||||||
|
metadata.paperclipSkillKey = skill.key;
|
||||||
|
metadata.paperclip = {
|
||||||
|
...(isPlainRecord(metadata.paperclip) ? metadata.paperclip : {}),
|
||||||
|
skillKey: skill.key,
|
||||||
|
slug: skill.slug,
|
||||||
|
};
|
||||||
const frontmatter = {
|
const frontmatter = {
|
||||||
...parsed.frontmatter,
|
...parsed.frontmatter,
|
||||||
|
key: skill.key,
|
||||||
|
slug: skill.slug,
|
||||||
metadata,
|
metadata,
|
||||||
};
|
};
|
||||||
return buildMarkdown(frontmatter, parsed.body);
|
return buildMarkdown(frontmatter, parsed.body);
|
||||||
|
|
@ -1043,7 +1116,7 @@ function readAgentSkillRefs(frontmatter: Record<string, unknown>) {
|
||||||
return Array.from(new Set(
|
return Array.from(new Set(
|
||||||
skills
|
skills
|
||||||
.filter((entry): entry is string => typeof entry === "string")
|
.filter((entry): entry is string => typeof entry === "string")
|
||||||
.map((entry) => normalizeAgentUrlKey(entry) ?? entry.trim())
|
.map((entry) => normalizeSkillKey(entry) ?? entry.trim())
|
||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
@ -1256,8 +1329,10 @@ function buildManifestFromPackageFiles(
|
||||||
sourceKind: "catalog",
|
sourceKind: "catalog",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
const key = deriveManifestSkillKey(frontmatter, slug, normalizedMetadata, sourceType, sourceLocator);
|
||||||
|
|
||||||
manifest.skills.push({
|
manifest.skills.push({
|
||||||
|
key,
|
||||||
slug,
|
slug,
|
||||||
name: asString(frontmatter.name) ?? slug,
|
name: asString(frontmatter.name) ?? slug,
|
||||||
path: skillPath,
|
path: skillPath,
|
||||||
|
|
@ -1688,15 +1763,16 @@ export function companyPortabilityService(db: Db) {
|
||||||
const paperclipTasksOut: Record<string, Record<string, unknown>> = {};
|
const paperclipTasksOut: Record<string, Record<string, unknown>> = {};
|
||||||
|
|
||||||
for (const skill of companySkillRows) {
|
for (const skill of companySkillRows) {
|
||||||
|
const packageDir = skillPackageDir(skill.key);
|
||||||
if (shouldReferenceSkillOnExport(skill, Boolean(input.expandReferencedSkills))) {
|
if (shouldReferenceSkillOnExport(skill, Boolean(input.expandReferencedSkills))) {
|
||||||
files[`skills/${skill.slug}/SKILL.md`] = await buildReferencedSkillMarkdown(skill);
|
files[`${packageDir}/SKILL.md`] = await buildReferencedSkillMarkdown(skill);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const inventoryEntry of skill.fileInventory) {
|
for (const inventoryEntry of skill.fileInventory) {
|
||||||
const fileDetail = await companySkills.readFile(companyId, skill.id, inventoryEntry.path).catch(() => null);
|
const fileDetail = await companySkills.readFile(companyId, skill.id, inventoryEntry.path).catch(() => null);
|
||||||
if (!fileDetail) continue;
|
if (!fileDetail) continue;
|
||||||
const filePath = `skills/${skill.slug}/${inventoryEntry.path}`;
|
const filePath = `${packageDir}/${inventoryEntry.path}`;
|
||||||
files[filePath] = inventoryEntry.path === "SKILL.md"
|
files[filePath] = inventoryEntry.path === "SKILL.md"
|
||||||
? await withSkillSourceMetadata(skill, fileDetail.content)
|
? await withSkillSourceMetadata(skill, fileDetail.content)
|
||||||
: fileDetail.content;
|
: fileDetail.content;
|
||||||
|
|
@ -1908,7 +1984,13 @@ export function companyPortabilityService(db: Db) {
|
||||||
warnings.push("No agents selected for import.");
|
warnings.push("No agents selected for import.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const availableSkillSlugs = new Set(source.manifest.skills.map((skill) => skill.slug));
|
const availableSkillKeys = new Set(source.manifest.skills.map((skill) => skill.key));
|
||||||
|
const availableSkillSlugs = new Map<string, CompanyPortabilitySkillManifestEntry[]>();
|
||||||
|
for (const skill of source.manifest.skills) {
|
||||||
|
const existing = availableSkillSlugs.get(skill.slug) ?? [];
|
||||||
|
existing.push(skill);
|
||||||
|
availableSkillSlugs.set(skill.slug, existing);
|
||||||
|
}
|
||||||
|
|
||||||
for (const agent of selectedAgents) {
|
for (const agent of selectedAgents) {
|
||||||
const filePath = ensureMarkdownPath(agent.path);
|
const filePath = ensureMarkdownPath(agent.path);
|
||||||
|
|
@ -1921,9 +2003,10 @@ export function companyPortabilityService(db: Db) {
|
||||||
if (parsed.frontmatter.kind && parsed.frontmatter.kind !== "agent") {
|
if (parsed.frontmatter.kind && parsed.frontmatter.kind !== "agent") {
|
||||||
warnings.push(`Agent markdown ${filePath} does not declare kind: agent in frontmatter.`);
|
warnings.push(`Agent markdown ${filePath} does not declare kind: agent in frontmatter.`);
|
||||||
}
|
}
|
||||||
for (const skillSlug of agent.skills) {
|
for (const skillRef of agent.skills) {
|
||||||
if (!availableSkillSlugs.has(skillSlug)) {
|
const slugMatches = availableSkillSlugs.get(skillRef) ?? [];
|
||||||
warnings.push(`Agent ${agent.slug} references skill ${skillSlug}, but that skill is not present in the package.`);
|
if (!availableSkillKeys.has(skillRef) && slugMatches.length !== 1) {
|
||||||
|
warnings.push(`Agent ${agent.slug} references skill ${skillRef}, but that skill is not present in the package.`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
import { promises as fs } from "node:fs";
|
import { promises as fs } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
@ -31,6 +32,7 @@ import { secretService } from "./secrets.js";
|
||||||
type CompanySkillRow = typeof companySkills.$inferSelect;
|
type CompanySkillRow = typeof companySkills.$inferSelect;
|
||||||
|
|
||||||
type ImportedSkill = {
|
type ImportedSkill = {
|
||||||
|
key: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
|
@ -52,6 +54,7 @@ type ParsedSkillImportSource = {
|
||||||
};
|
};
|
||||||
|
|
||||||
type SkillSourceMeta = {
|
type SkillSourceMeta = {
|
||||||
|
skillKey?: string;
|
||||||
sourceKind?: string;
|
sourceKind?: string;
|
||||||
owner?: string;
|
owner?: string;
|
||||||
repo?: string;
|
repo?: string;
|
||||||
|
|
@ -97,6 +100,86 @@ function normalizeSkillSlug(value: string | null | undefined) {
|
||||||
return value ? normalizeAgentUrlKey(value) ?? null : null;
|
return value ? normalizeAgentUrlKey(value) ?? null : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeSkillKey(value: string | null | undefined) {
|
||||||
|
if (!value) return null;
|
||||||
|
const segments = value
|
||||||
|
.split("/")
|
||||||
|
.map((segment) => normalizeSkillSlug(segment))
|
||||||
|
.filter((segment): segment is string => Boolean(segment));
|
||||||
|
return segments.length > 0 ? segments.join("/") : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashSkillValue(value: string) {
|
||||||
|
return createHash("sha256").update(value).digest("hex").slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSkillRuntimeName(key: string, slug: string) {
|
||||||
|
if (key.startsWith("paperclipai/paperclip/")) return slug;
|
||||||
|
return `${slug}--${hashSkillValue(key)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCanonicalSkillKey(frontmatter: Record<string, unknown>, metadata: Record<string, unknown> | null) {
|
||||||
|
const direct = normalizeSkillKey(
|
||||||
|
asString(frontmatter.key)
|
||||||
|
?? asString(frontmatter.skillKey)
|
||||||
|
?? asString(metadata?.skillKey)
|
||||||
|
?? asString(metadata?.canonicalKey)
|
||||||
|
?? asString(metadata?.paperclipSkillKey),
|
||||||
|
);
|
||||||
|
if (direct) return direct;
|
||||||
|
const paperclip = isPlainRecord(metadata?.paperclip) ? metadata?.paperclip as Record<string, unknown> : null;
|
||||||
|
return normalizeSkillKey(
|
||||||
|
asString(paperclip?.skillKey)
|
||||||
|
?? asString(paperclip?.key),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveCanonicalSkillKey(
|
||||||
|
companyId: string,
|
||||||
|
input: Pick<ImportedSkill, "slug" | "sourceType" | "sourceLocator" | "metadata">,
|
||||||
|
) {
|
||||||
|
const slug = normalizeSkillSlug(input.slug) ?? "skill";
|
||||||
|
const metadata = isPlainRecord(input.metadata) ? input.metadata : null;
|
||||||
|
const explicitKey = readCanonicalSkillKey({}, metadata);
|
||||||
|
if (explicitKey) return explicitKey;
|
||||||
|
|
||||||
|
const sourceKind = asString(metadata?.sourceKind);
|
||||||
|
if (sourceKind === "paperclip_bundled") {
|
||||||
|
return `paperclipai/paperclip/${slug}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const owner = normalizeSkillSlug(asString(metadata?.owner));
|
||||||
|
const repo = normalizeSkillSlug(asString(metadata?.repo));
|
||||||
|
if ((input.sourceType === "github" || sourceKind === "github") && owner && repo) {
|
||||||
|
return `${owner}/${repo}/${slug}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.sourceType === "url" || sourceKind === "url") {
|
||||||
|
const locator = asString(input.sourceLocator);
|
||||||
|
if (locator) {
|
||||||
|
try {
|
||||||
|
const url = new URL(locator);
|
||||||
|
const host = normalizeSkillSlug(url.host) ?? "url";
|
||||||
|
return `url/${host}/${hashSkillValue(locator)}/${slug}`;
|
||||||
|
} catch {
|
||||||
|
return `url/unknown/${hashSkillValue(locator)}/${slug}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.sourceType === "local_path") {
|
||||||
|
if (sourceKind === "managed_local") {
|
||||||
|
return `company/${companyId}/${slug}`;
|
||||||
|
}
|
||||||
|
const locator = asString(input.sourceLocator);
|
||||||
|
if (locator) {
|
||||||
|
return `local/${hashSkillValue(path.resolve(locator))}/${slug}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `company/${companyId}/${slug}`;
|
||||||
|
}
|
||||||
|
|
||||||
function classifyInventoryKind(relativePath: string): CompanySkillFileInventoryEntry["kind"] {
|
function classifyInventoryKind(relativePath: string): CompanySkillFileInventoryEntry["kind"] {
|
||||||
const normalized = normalizePortablePath(relativePath).toLowerCase();
|
const normalized = normalizePortablePath(relativePath).toLowerCase();
|
||||||
if (normalized.endsWith("/skill.md") || normalized === "skill.md") return "skill";
|
if (normalized.endsWith("/skill.md") || normalized === "skill.md") return "skill";
|
||||||
|
|
@ -417,7 +500,10 @@ function matchesRequestedSkill(relativeSkillPath: string, requestedSkillSlug: st
|
||||||
}
|
}
|
||||||
|
|
||||||
function deriveImportedSkillSlug(frontmatter: Record<string, unknown>, fallback: string) {
|
function deriveImportedSkillSlug(frontmatter: Record<string, unknown>, fallback: string) {
|
||||||
return normalizeSkillSlug(asString(frontmatter.name)) ?? normalizeAgentUrlKey(fallback) ?? "skill";
|
return normalizeSkillSlug(asString(frontmatter.slug))
|
||||||
|
?? normalizeSkillSlug(asString(frontmatter.name))
|
||||||
|
?? normalizeAgentUrlKey(fallback)
|
||||||
|
?? "skill";
|
||||||
}
|
}
|
||||||
|
|
||||||
function deriveImportedSkillSource(
|
function deriveImportedSkillSource(
|
||||||
|
|
@ -425,6 +511,7 @@ function deriveImportedSkillSource(
|
||||||
fallbackSlug: string,
|
fallbackSlug: string,
|
||||||
): Pick<ImportedSkill, "sourceType" | "sourceLocator" | "sourceRef" | "metadata"> {
|
): Pick<ImportedSkill, "sourceType" | "sourceLocator" | "sourceRef" | "metadata"> {
|
||||||
const metadata = isPlainRecord(frontmatter.metadata) ? frontmatter.metadata : null;
|
const metadata = isPlainRecord(frontmatter.metadata) ? frontmatter.metadata : null;
|
||||||
|
const canonicalKey = readCanonicalSkillKey(frontmatter, metadata);
|
||||||
const rawSources = metadata && Array.isArray(metadata.sources) ? metadata.sources : [];
|
const rawSources = metadata && Array.isArray(metadata.sources) ? metadata.sources : [];
|
||||||
const sourceEntry = rawSources.find((entry) => isPlainRecord(entry)) as Record<string, unknown> | undefined;
|
const sourceEntry = rawSources.find((entry) => isPlainRecord(entry)) as Record<string, unknown> | undefined;
|
||||||
const kind = asString(sourceEntry?.kind);
|
const kind = asString(sourceEntry?.kind);
|
||||||
|
|
@ -445,6 +532,7 @@ function deriveImportedSkillSource(
|
||||||
sourceLocator: url,
|
sourceLocator: url,
|
||||||
sourceRef: commit,
|
sourceRef: commit,
|
||||||
metadata: {
|
metadata: {
|
||||||
|
...(canonicalKey ? { skillKey: canonicalKey } : {}),
|
||||||
sourceKind: "github",
|
sourceKind: "github",
|
||||||
owner,
|
owner,
|
||||||
repo: repoName,
|
repo: repoName,
|
||||||
|
|
@ -464,6 +552,7 @@ function deriveImportedSkillSource(
|
||||||
sourceLocator: url,
|
sourceLocator: url,
|
||||||
sourceRef: null,
|
sourceRef: null,
|
||||||
metadata: {
|
metadata: {
|
||||||
|
...(canonicalKey ? { skillKey: canonicalKey } : {}),
|
||||||
sourceKind: "url",
|
sourceKind: "url",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -475,12 +564,13 @@ function deriveImportedSkillSource(
|
||||||
sourceLocator: null,
|
sourceLocator: null,
|
||||||
sourceRef: null,
|
sourceRef: null,
|
||||||
metadata: {
|
metadata: {
|
||||||
|
...(canonicalKey ? { skillKey: canonicalKey } : {}),
|
||||||
sourceKind: "catalog",
|
sourceKind: "catalog",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function readInlineSkillImports(files: Record<string, string>): ImportedSkill[] {
|
function readInlineSkillImports(companyId: string, files: Record<string, string>): ImportedSkill[] {
|
||||||
const normalizedFiles = normalizePackageFileMap(files);
|
const normalizedFiles = normalizePackageFileMap(files);
|
||||||
const skillPaths = Object.keys(normalizedFiles).filter(
|
const skillPaths = Object.keys(normalizedFiles).filter(
|
||||||
(entry) => path.posix.basename(entry).toLowerCase() === "skill.md",
|
(entry) => path.posix.basename(entry).toLowerCase() === "skill.md",
|
||||||
|
|
@ -507,6 +597,7 @@ function readInlineSkillImports(files: Record<string, string>): ImportedSkill[]
|
||||||
.sort((left, right) => left.path.localeCompare(right.path));
|
.sort((left, right) => left.path.localeCompare(right.path));
|
||||||
|
|
||||||
imports.push({
|
imports.push({
|
||||||
|
key: "",
|
||||||
slug,
|
slug,
|
||||||
name: asString(parsed.frontmatter.name) ?? slug,
|
name: asString(parsed.frontmatter.name) ?? slug,
|
||||||
description: asString(parsed.frontmatter.description),
|
description: asString(parsed.frontmatter.description),
|
||||||
|
|
@ -520,6 +611,7 @@ function readInlineSkillImports(files: Record<string, string>): ImportedSkill[]
|
||||||
fileInventory: inventory,
|
fileInventory: inventory,
|
||||||
metadata: source.metadata,
|
metadata: source.metadata,
|
||||||
});
|
});
|
||||||
|
imports[imports.length - 1]!.key = deriveCanonicalSkillKey(companyId, imports[imports.length - 1]!);
|
||||||
}
|
}
|
||||||
|
|
||||||
return imports;
|
return imports;
|
||||||
|
|
@ -539,7 +631,7 @@ async function walkLocalFiles(root: string, current: string, out: string[]) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readLocalSkillImports(sourcePath: string): Promise<ImportedSkill[]> {
|
async function readLocalSkillImports(companyId: string, sourcePath: string): Promise<ImportedSkill[]> {
|
||||||
const resolvedPath = path.resolve(sourcePath);
|
const resolvedPath = path.resolve(sourcePath);
|
||||||
const stat = await fs.stat(resolvedPath).catch(() => null);
|
const stat = await fs.stat(resolvedPath).catch(() => null);
|
||||||
if (!stat) {
|
if (!stat) {
|
||||||
|
|
@ -550,10 +642,24 @@ async function readLocalSkillImports(sourcePath: string): Promise<ImportedSkill[
|
||||||
const markdown = await fs.readFile(resolvedPath, "utf8");
|
const markdown = await fs.readFile(resolvedPath, "utf8");
|
||||||
const parsed = parseFrontmatterMarkdown(markdown);
|
const parsed = parseFrontmatterMarkdown(markdown);
|
||||||
const slug = deriveImportedSkillSlug(parsed.frontmatter, path.basename(path.dirname(resolvedPath)));
|
const slug = deriveImportedSkillSlug(parsed.frontmatter, path.basename(path.dirname(resolvedPath)));
|
||||||
|
const skillKey = readCanonicalSkillKey(
|
||||||
|
parsed.frontmatter,
|
||||||
|
isPlainRecord(parsed.frontmatter.metadata) ? parsed.frontmatter.metadata : null,
|
||||||
|
);
|
||||||
|
const metadata = {
|
||||||
|
...(skillKey ? { skillKey } : {}),
|
||||||
|
sourceKind: "local_path",
|
||||||
|
};
|
||||||
const inventory: CompanySkillFileInventoryEntry[] = [
|
const inventory: CompanySkillFileInventoryEntry[] = [
|
||||||
{ path: "SKILL.md", kind: "skill" },
|
{ path: "SKILL.md", kind: "skill" },
|
||||||
];
|
];
|
||||||
return [{
|
return [{
|
||||||
|
key: deriveCanonicalSkillKey(companyId, {
|
||||||
|
slug,
|
||||||
|
sourceType: "local_path",
|
||||||
|
sourceLocator: path.dirname(resolvedPath),
|
||||||
|
metadata,
|
||||||
|
}),
|
||||||
slug,
|
slug,
|
||||||
name: asString(parsed.frontmatter.name) ?? slug,
|
name: asString(parsed.frontmatter.name) ?? slug,
|
||||||
description: asString(parsed.frontmatter.description),
|
description: asString(parsed.frontmatter.description),
|
||||||
|
|
@ -565,7 +671,7 @@ async function readLocalSkillImports(sourcePath: string): Promise<ImportedSkill[
|
||||||
trustLevel: deriveTrustLevel(inventory),
|
trustLevel: deriveTrustLevel(inventory),
|
||||||
compatibility: "compatible",
|
compatibility: "compatible",
|
||||||
fileInventory: inventory,
|
fileInventory: inventory,
|
||||||
metadata: { sourceKind: "local_path" },
|
metadata,
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -583,6 +689,14 @@ async function readLocalSkillImports(sourcePath: string): Promise<ImportedSkill[
|
||||||
const markdown = await fs.readFile(path.join(root, skillPath), "utf8");
|
const markdown = await fs.readFile(path.join(root, skillPath), "utf8");
|
||||||
const parsed = parseFrontmatterMarkdown(markdown);
|
const parsed = parseFrontmatterMarkdown(markdown);
|
||||||
const slug = deriveImportedSkillSlug(parsed.frontmatter, path.posix.basename(skillDir));
|
const slug = deriveImportedSkillSlug(parsed.frontmatter, path.posix.basename(skillDir));
|
||||||
|
const skillKey = readCanonicalSkillKey(
|
||||||
|
parsed.frontmatter,
|
||||||
|
isPlainRecord(parsed.frontmatter.metadata) ? parsed.frontmatter.metadata : null,
|
||||||
|
);
|
||||||
|
const metadata = {
|
||||||
|
...(skillKey ? { skillKey } : {}),
|
||||||
|
sourceKind: "local_path",
|
||||||
|
};
|
||||||
const inventory = allFiles
|
const inventory = allFiles
|
||||||
.filter((entry) => entry === skillPath || entry.startsWith(`${skillDir}/`))
|
.filter((entry) => entry === skillPath || entry.startsWith(`${skillDir}/`))
|
||||||
.map((entry) => {
|
.map((entry) => {
|
||||||
|
|
@ -594,6 +708,12 @@ async function readLocalSkillImports(sourcePath: string): Promise<ImportedSkill[
|
||||||
})
|
})
|
||||||
.sort((left, right) => left.path.localeCompare(right.path));
|
.sort((left, right) => left.path.localeCompare(right.path));
|
||||||
imports.push({
|
imports.push({
|
||||||
|
key: deriveCanonicalSkillKey(companyId, {
|
||||||
|
slug,
|
||||||
|
sourceType: "local_path",
|
||||||
|
sourceLocator: path.join(root, skillDir),
|
||||||
|
metadata,
|
||||||
|
}),
|
||||||
slug,
|
slug,
|
||||||
name: asString(parsed.frontmatter.name) ?? slug,
|
name: asString(parsed.frontmatter.name) ?? slug,
|
||||||
description: asString(parsed.frontmatter.description),
|
description: asString(parsed.frontmatter.description),
|
||||||
|
|
@ -605,7 +725,7 @@ async function readLocalSkillImports(sourcePath: string): Promise<ImportedSkill[
|
||||||
trustLevel: deriveTrustLevel(inventory),
|
trustLevel: deriveTrustLevel(inventory),
|
||||||
compatibility: "compatible",
|
compatibility: "compatible",
|
||||||
fileInventory: inventory,
|
fileInventory: inventory,
|
||||||
metadata: { sourceKind: "local_path" },
|
metadata,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -613,6 +733,7 @@ async function readLocalSkillImports(sourcePath: string): Promise<ImportedSkill[
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readUrlSkillImports(
|
async function readUrlSkillImports(
|
||||||
|
companyId: string,
|
||||||
sourceUrl: string,
|
sourceUrl: string,
|
||||||
requestedSkillSlug: string | null = null,
|
requestedSkillSlug: string | null = null,
|
||||||
): Promise<{ skills: ImportedSkill[]; warnings: string[] }> {
|
): Promise<{ skills: ImportedSkill[]; warnings: string[] }> {
|
||||||
|
|
@ -654,9 +775,22 @@ async function readUrlSkillImports(
|
||||||
const parsedMarkdown = parseFrontmatterMarkdown(markdown);
|
const parsedMarkdown = parseFrontmatterMarkdown(markdown);
|
||||||
const skillDir = path.posix.dirname(relativeSkillPath);
|
const skillDir = path.posix.dirname(relativeSkillPath);
|
||||||
const slug = deriveImportedSkillSlug(parsedMarkdown.frontmatter, path.posix.basename(skillDir));
|
const slug = deriveImportedSkillSlug(parsedMarkdown.frontmatter, path.posix.basename(skillDir));
|
||||||
|
const skillKey = readCanonicalSkillKey(
|
||||||
|
parsedMarkdown.frontmatter,
|
||||||
|
isPlainRecord(parsedMarkdown.frontmatter.metadata) ? parsedMarkdown.frontmatter.metadata : null,
|
||||||
|
);
|
||||||
if (requestedSkillSlug && !matchesRequestedSkill(relativeSkillPath, requestedSkillSlug) && slug !== requestedSkillSlug) {
|
if (requestedSkillSlug && !matchesRequestedSkill(relativeSkillPath, requestedSkillSlug) && slug !== requestedSkillSlug) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
const metadata = {
|
||||||
|
...(skillKey ? { skillKey } : {}),
|
||||||
|
sourceKind: "github",
|
||||||
|
owner: parsed.owner,
|
||||||
|
repo: parsed.repo,
|
||||||
|
ref: ref,
|
||||||
|
trackingRef,
|
||||||
|
repoSkillDir: basePrefix ? `${basePrefix}${skillDir}` : skillDir,
|
||||||
|
};
|
||||||
const inventory = filteredPaths
|
const inventory = filteredPaths
|
||||||
.filter((entry) => entry === relativeSkillPath || entry.startsWith(`${skillDir}/`))
|
.filter((entry) => entry === relativeSkillPath || entry.startsWith(`${skillDir}/`))
|
||||||
.map((entry) => ({
|
.map((entry) => ({
|
||||||
|
|
@ -665,6 +799,12 @@ async function readUrlSkillImports(
|
||||||
}))
|
}))
|
||||||
.sort((left, right) => left.path.localeCompare(right.path));
|
.sort((left, right) => left.path.localeCompare(right.path));
|
||||||
skills.push({
|
skills.push({
|
||||||
|
key: deriveCanonicalSkillKey(companyId, {
|
||||||
|
slug,
|
||||||
|
sourceType: "github",
|
||||||
|
sourceLocator: sourceUrl,
|
||||||
|
metadata,
|
||||||
|
}),
|
||||||
slug,
|
slug,
|
||||||
name: asString(parsedMarkdown.frontmatter.name) ?? slug,
|
name: asString(parsedMarkdown.frontmatter.name) ?? slug,
|
||||||
description: asString(parsedMarkdown.frontmatter.description),
|
description: asString(parsedMarkdown.frontmatter.description),
|
||||||
|
|
@ -675,14 +815,7 @@ async function readUrlSkillImports(
|
||||||
trustLevel: deriveTrustLevel(inventory),
|
trustLevel: deriveTrustLevel(inventory),
|
||||||
compatibility: "compatible",
|
compatibility: "compatible",
|
||||||
fileInventory: inventory,
|
fileInventory: inventory,
|
||||||
metadata: {
|
metadata,
|
||||||
sourceKind: "github",
|
|
||||||
owner: parsed.owner,
|
|
||||||
repo: parsed.repo,
|
|
||||||
ref: ref,
|
|
||||||
trackingRef,
|
|
||||||
repoSkillDir: basePrefix ? `${basePrefix}${skillDir}` : skillDir,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (skills.length === 0) {
|
if (skills.length === 0) {
|
||||||
|
|
@ -701,9 +834,23 @@ async function readUrlSkillImports(
|
||||||
const urlObj = new URL(url);
|
const urlObj = new URL(url);
|
||||||
const fileName = path.posix.basename(urlObj.pathname);
|
const fileName = path.posix.basename(urlObj.pathname);
|
||||||
const slug = deriveImportedSkillSlug(parsedMarkdown.frontmatter, fileName.replace(/\.md$/i, ""));
|
const slug = deriveImportedSkillSlug(parsedMarkdown.frontmatter, fileName.replace(/\.md$/i, ""));
|
||||||
|
const skillKey = readCanonicalSkillKey(
|
||||||
|
parsedMarkdown.frontmatter,
|
||||||
|
isPlainRecord(parsedMarkdown.frontmatter.metadata) ? parsedMarkdown.frontmatter.metadata : null,
|
||||||
|
);
|
||||||
|
const metadata = {
|
||||||
|
...(skillKey ? { skillKey } : {}),
|
||||||
|
sourceKind: "url",
|
||||||
|
};
|
||||||
const inventory: CompanySkillFileInventoryEntry[] = [{ path: "SKILL.md", kind: "skill" }];
|
const inventory: CompanySkillFileInventoryEntry[] = [{ path: "SKILL.md", kind: "skill" }];
|
||||||
return {
|
return {
|
||||||
skills: [{
|
skills: [{
|
||||||
|
key: deriveCanonicalSkillKey(companyId, {
|
||||||
|
slug,
|
||||||
|
sourceType: "url",
|
||||||
|
sourceLocator: url,
|
||||||
|
metadata,
|
||||||
|
}),
|
||||||
slug,
|
slug,
|
||||||
name: asString(parsedMarkdown.frontmatter.name) ?? slug,
|
name: asString(parsedMarkdown.frontmatter.name) ?? slug,
|
||||||
description: asString(parsedMarkdown.frontmatter.description),
|
description: asString(parsedMarkdown.frontmatter.description),
|
||||||
|
|
@ -714,9 +861,7 @@ async function readUrlSkillImports(
|
||||||
trustLevel: deriveTrustLevel(inventory),
|
trustLevel: deriveTrustLevel(inventory),
|
||||||
compatibility: "compatible",
|
compatibility: "compatible",
|
||||||
fileInventory: inventory,
|
fileInventory: inventory,
|
||||||
metadata: {
|
metadata,
|
||||||
sourceKind: "url",
|
|
||||||
},
|
|
||||||
}],
|
}],
|
||||||
warnings,
|
warnings,
|
||||||
};
|
};
|
||||||
|
|
@ -760,6 +905,34 @@ function getSkillMeta(skill: CompanySkill): SkillSourceMeta {
|
||||||
return isPlainRecord(skill.metadata) ? skill.metadata as SkillSourceMeta : {};
|
return isPlainRecord(skill.metadata) ? skill.metadata as SkillSourceMeta : {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveSkillReference(
|
||||||
|
skills: CompanySkill[],
|
||||||
|
reference: string,
|
||||||
|
): CompanySkill | null {
|
||||||
|
const normalizedReference = normalizeSkillKey(reference) ?? normalizeSkillSlug(reference);
|
||||||
|
if (!normalizedReference) return null;
|
||||||
|
|
||||||
|
const byKey = skills.find((skill) => skill.key === normalizedReference);
|
||||||
|
if (byKey) return byKey;
|
||||||
|
|
||||||
|
const bySlug = skills.filter((skill) => skill.slug === normalizedReference);
|
||||||
|
if (bySlug.length === 1) return bySlug[0] ?? null;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDesiredSkillKeys(
|
||||||
|
skills: CompanySkill[],
|
||||||
|
config: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
const preference = readPaperclipSkillSyncPreference(config);
|
||||||
|
return Array.from(new Set(
|
||||||
|
preference.desiredSkills
|
||||||
|
.map((reference) => resolveSkillReference(skills, reference)?.key ?? normalizeSkillKey(reference))
|
||||||
|
.filter((value): value is string => Boolean(value)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeSkillDirectory(skill: CompanySkill) {
|
function normalizeSkillDirectory(skill: CompanySkill) {
|
||||||
if ((skill.sourceType !== "local_path" && skill.sourceType !== "catalog") || !skill.sourceLocator) return null;
|
if ((skill.sourceType !== "local_path" && skill.sourceType !== "catalog") || !skill.sourceLocator) return null;
|
||||||
const resolved = path.resolve(skill.sourceLocator);
|
const resolved = path.resolve(skill.sourceLocator);
|
||||||
|
|
@ -886,6 +1059,7 @@ function toCompanySkillListItem(skill: CompanySkill, attachedAgentCount: number)
|
||||||
return {
|
return {
|
||||||
id: skill.id,
|
id: skill.id,
|
||||||
companyId: skill.companyId,
|
companyId: skill.companyId,
|
||||||
|
key: skill.key,
|
||||||
slug: skill.slug,
|
slug: skill.slug,
|
||||||
name: skill.name,
|
name: skill.name,
|
||||||
description: skill.description,
|
description: skill.description,
|
||||||
|
|
@ -913,9 +1087,16 @@ export function companySkillService(db: Db) {
|
||||||
for (const skillsRoot of resolveBundledSkillsRoot()) {
|
for (const skillsRoot of resolveBundledSkillsRoot()) {
|
||||||
const stats = await fs.stat(skillsRoot).catch(() => null);
|
const stats = await fs.stat(skillsRoot).catch(() => null);
|
||||||
if (!stats?.isDirectory()) continue;
|
if (!stats?.isDirectory()) continue;
|
||||||
const bundledSkills = await readLocalSkillImports(skillsRoot)
|
const bundledSkills = await readLocalSkillImports(companyId, skillsRoot)
|
||||||
.then((skills) => skills.map((skill) => ({
|
.then((skills) => skills.map((skill) => ({
|
||||||
...skill,
|
...skill,
|
||||||
|
key: deriveCanonicalSkillKey(companyId, {
|
||||||
|
...skill,
|
||||||
|
metadata: {
|
||||||
|
...(skill.metadata ?? {}),
|
||||||
|
sourceKind: "paperclip_bundled",
|
||||||
|
},
|
||||||
|
}),
|
||||||
metadata: {
|
metadata: {
|
||||||
...(skill.metadata ?? {}),
|
...(skill.metadata ?? {}),
|
||||||
sourceKind: "paperclip_bundled",
|
sourceKind: "paperclip_bundled",
|
||||||
|
|
@ -933,8 +1114,8 @@ export function companySkillService(db: Db) {
|
||||||
const agentRows = await agents.list(companyId);
|
const agentRows = await agents.list(companyId);
|
||||||
return rows.map((skill) => {
|
return rows.map((skill) => {
|
||||||
const attachedAgentCount = agentRows.filter((agent) => {
|
const attachedAgentCount = agentRows.filter((agent) => {
|
||||||
const preference = readPaperclipSkillSyncPreference(agent.adapterConfig as Record<string, unknown>);
|
const desiredSkills = resolveDesiredSkillKeys(rows, agent.adapterConfig as Record<string, unknown>);
|
||||||
return preference.desiredSkills.includes(skill.slug);
|
return desiredSkills.includes(skill.key);
|
||||||
}).length;
|
}).length;
|
||||||
return toCompanySkillListItem(skill, attachedAgentCount);
|
return toCompanySkillListItem(skill, attachedAgentCount);
|
||||||
});
|
});
|
||||||
|
|
@ -946,7 +1127,7 @@ export function companySkillService(db: Db) {
|
||||||
.select()
|
.select()
|
||||||
.from(companySkills)
|
.from(companySkills)
|
||||||
.where(eq(companySkills.companyId, companyId))
|
.where(eq(companySkills.companyId, companyId))
|
||||||
.orderBy(asc(companySkills.name), asc(companySkills.slug));
|
.orderBy(asc(companySkills.name), asc(companySkills.key));
|
||||||
return rows.map((row) => toCompanySkill(row));
|
return rows.map((row) => toCompanySkill(row));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -959,20 +1140,21 @@ export function companySkillService(db: Db) {
|
||||||
return row ? toCompanySkill(row) : null;
|
return row ? toCompanySkill(row) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getBySlug(companyId: string, slug: string) {
|
async function getByKey(companyId: string, key: string) {
|
||||||
const row = await db
|
const row = await db
|
||||||
.select()
|
.select()
|
||||||
.from(companySkills)
|
.from(companySkills)
|
||||||
.where(and(eq(companySkills.companyId, companyId), eq(companySkills.slug, slug)))
|
.where(and(eq(companySkills.companyId, companyId), eq(companySkills.key, key)))
|
||||||
.then((rows) => rows[0] ?? null);
|
.then((rows) => rows[0] ?? null);
|
||||||
return row ? toCompanySkill(row) : null;
|
return row ? toCompanySkill(row) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function usage(companyId: string, slug: string): Promise<CompanySkillUsageAgent[]> {
|
async function usage(companyId: string, key: string): Promise<CompanySkillUsageAgent[]> {
|
||||||
|
const skills = await listFull(companyId);
|
||||||
const agentRows = await agents.list(companyId);
|
const agentRows = await agents.list(companyId);
|
||||||
const desiredAgents = agentRows.filter((agent) => {
|
const desiredAgents = agentRows.filter((agent) => {
|
||||||
const preference = readPaperclipSkillSyncPreference(agent.adapterConfig as Record<string, unknown>);
|
const desiredSkills = resolveDesiredSkillKeys(skills, agent.adapterConfig as Record<string, unknown>);
|
||||||
return preference.desiredSkills.includes(slug);
|
return desiredSkills.includes(key);
|
||||||
});
|
});
|
||||||
|
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
|
|
@ -998,7 +1180,7 @@ export function companySkillService(db: Db) {
|
||||||
paperclipRuntimeSkills: runtimeSkillEntries,
|
paperclipRuntimeSkills: runtimeSkillEntries,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
actualState = snapshot.entries.find((entry) => entry.name === slug)?.state
|
actualState = snapshot.entries.find((entry) => entry.key === key)?.state
|
||||||
?? (snapshot.supported ? "missing" : "unsupported");
|
?? (snapshot.supported ? "missing" : "unsupported");
|
||||||
} catch {
|
} catch {
|
||||||
actualState = "unknown";
|
actualState = "unknown";
|
||||||
|
|
@ -1021,7 +1203,7 @@ export function companySkillService(db: Db) {
|
||||||
await ensureBundledSkills(companyId);
|
await ensureBundledSkills(companyId);
|
||||||
const skill = await getById(id);
|
const skill = await getById(id);
|
||||||
if (!skill || skill.companyId !== companyId) return null;
|
if (!skill || skill.companyId !== companyId) return null;
|
||||||
const usedByAgents = await usage(companyId, skill.slug);
|
const usedByAgents = await usage(companyId, skill.key);
|
||||||
return enrichSkill(skill, usedByAgents.length, usedByAgents);
|
return enrichSkill(skill, usedByAgents.length, usedByAgents);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1147,6 +1329,7 @@ export function companySkillService(db: Db) {
|
||||||
|
|
||||||
const parsed = parseFrontmatterMarkdown(markdown);
|
const parsed = parseFrontmatterMarkdown(markdown);
|
||||||
const imported = await upsertImportedSkills(companyId, [{
|
const imported = await upsertImportedSkills(companyId, [{
|
||||||
|
key: `company/${companyId}/${slug}`,
|
||||||
slug,
|
slug,
|
||||||
name: asString(parsed.frontmatter.name) ?? input.name,
|
name: asString(parsed.frontmatter.name) ?? input.name,
|
||||||
description: asString(parsed.frontmatter.description) ?? input.description?.trim() ?? null,
|
description: asString(parsed.frontmatter.description) ?? input.description?.trim() ?? null,
|
||||||
|
|
@ -1216,10 +1399,10 @@ export function companySkillService(db: Db) {
|
||||||
throw unprocessable("Skill source locator is missing.");
|
throw unprocessable("Skill source locator is missing.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await readUrlSkillImports(skill.sourceLocator, skill.slug);
|
const result = await readUrlSkillImports(companyId, skill.sourceLocator, skill.slug);
|
||||||
const matching = result.skills.find((entry) => entry.slug === skill.slug) ?? result.skills[0] ?? null;
|
const matching = result.skills.find((entry) => entry.key === skill.key) ?? result.skills[0] ?? null;
|
||||||
if (!matching) {
|
if (!matching) {
|
||||||
throw unprocessable(`Skill ${skill.slug} could not be re-imported from its source.`);
|
throw unprocessable(`Skill ${skill.key} could not be re-imported from its source.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const imported = await upsertImportedSkills(companyId, [matching]);
|
const imported = await upsertImportedSkills(companyId, [matching]);
|
||||||
|
|
@ -1234,7 +1417,7 @@ export function companySkillService(db: Db) {
|
||||||
const packageDir = skill.packageDir ? normalizePortablePath(skill.packageDir) : null;
|
const packageDir = skill.packageDir ? normalizePortablePath(skill.packageDir) : null;
|
||||||
if (!packageDir) return null;
|
if (!packageDir) return null;
|
||||||
const catalogRoot = path.resolve(resolveManagedSkillsRoot(companyId), "__catalog__");
|
const catalogRoot = path.resolve(resolveManagedSkillsRoot(companyId), "__catalog__");
|
||||||
const skillDir = path.resolve(catalogRoot, skill.slug);
|
const skillDir = path.resolve(catalogRoot, buildSkillRuntimeName(skill.key, skill.slug));
|
||||||
await fs.rm(skillDir, { recursive: true, force: true });
|
await fs.rm(skillDir, { recursive: true, force: true });
|
||||||
await fs.mkdir(skillDir, { recursive: true });
|
await fs.mkdir(skillDir, { recursive: true });
|
||||||
|
|
||||||
|
|
@ -1254,7 +1437,7 @@ export function companySkillService(db: Db) {
|
||||||
|
|
||||||
async function materializeRuntimeSkillFiles(companyId: string, skill: CompanySkill) {
|
async function materializeRuntimeSkillFiles(companyId: string, skill: CompanySkill) {
|
||||||
const runtimeRoot = path.resolve(resolveManagedSkillsRoot(companyId), "__runtime__");
|
const runtimeRoot = path.resolve(resolveManagedSkillsRoot(companyId), "__runtime__");
|
||||||
const skillDir = path.resolve(runtimeRoot, skill.slug);
|
const skillDir = path.resolve(runtimeRoot, buildSkillRuntimeName(skill.key, skill.slug));
|
||||||
await fs.rm(skillDir, { recursive: true, force: true });
|
await fs.rm(skillDir, { recursive: true, force: true });
|
||||||
await fs.mkdir(skillDir, { recursive: true });
|
await fs.mkdir(skillDir, { recursive: true });
|
||||||
|
|
||||||
|
|
@ -1275,7 +1458,7 @@ export function companySkillService(db: Db) {
|
||||||
.select()
|
.select()
|
||||||
.from(companySkills)
|
.from(companySkills)
|
||||||
.where(eq(companySkills.companyId, companyId))
|
.where(eq(companySkills.companyId, companyId))
|
||||||
.orderBy(asc(companySkills.name), asc(companySkills.slug));
|
.orderBy(asc(companySkills.name), asc(companySkills.key));
|
||||||
|
|
||||||
const out: PaperclipSkillEntry[] = [];
|
const out: PaperclipSkillEntry[] = [];
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
|
|
@ -1289,7 +1472,8 @@ export function companySkillService(db: Db) {
|
||||||
|
|
||||||
const required = sourceKind === "paperclip_bundled";
|
const required = sourceKind === "paperclip_bundled";
|
||||||
out.push({
|
out.push({
|
||||||
name: skill.slug,
|
key: skill.key,
|
||||||
|
runtimeName: buildSkillRuntimeName(skill.key, skill.slug),
|
||||||
source,
|
source,
|
||||||
required,
|
required,
|
||||||
requiredReason: required
|
requiredReason: required
|
||||||
|
|
@ -1298,14 +1482,14 @@ export function companySkillService(db: Db) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
out.sort((left, right) => left.name.localeCompare(right.name));
|
out.sort((left, right) => left.key.localeCompare(right.key));
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function importPackageFiles(companyId: string, files: Record<string, string>): Promise<CompanySkill[]> {
|
async function importPackageFiles(companyId: string, files: Record<string, string>): Promise<CompanySkill[]> {
|
||||||
await ensureBundledSkills(companyId);
|
await ensureBundledSkills(companyId);
|
||||||
const normalizedFiles = normalizePackageFileMap(files);
|
const normalizedFiles = normalizePackageFileMap(files);
|
||||||
const importedSkills = readInlineSkillImports(normalizedFiles);
|
const importedSkills = readInlineSkillImports(companyId, normalizedFiles);
|
||||||
if (importedSkills.length === 0) return [];
|
if (importedSkills.length === 0) return [];
|
||||||
|
|
||||||
for (const skill of importedSkills) {
|
for (const skill of importedSkills) {
|
||||||
|
|
@ -1322,7 +1506,7 @@ export function companySkillService(db: Db) {
|
||||||
async function upsertImportedSkills(companyId: string, imported: ImportedSkill[]): Promise<CompanySkill[]> {
|
async function upsertImportedSkills(companyId: string, imported: ImportedSkill[]): Promise<CompanySkill[]> {
|
||||||
const out: CompanySkill[] = [];
|
const out: CompanySkill[] = [];
|
||||||
for (const skill of imported) {
|
for (const skill of imported) {
|
||||||
const existing = await getBySlug(companyId, skill.slug);
|
const existing = await getByKey(companyId, skill.key);
|
||||||
const existingMeta = existing ? getSkillMeta(existing) : {};
|
const existingMeta = existing ? getSkillMeta(existing) : {};
|
||||||
const incomingMeta = skill.metadata && isPlainRecord(skill.metadata) ? skill.metadata : {};
|
const incomingMeta = skill.metadata && isPlainRecord(skill.metadata) ? skill.metadata : {};
|
||||||
const incomingOwner = asString(incomingMeta.owner);
|
const incomingOwner = asString(incomingMeta.owner);
|
||||||
|
|
@ -1339,8 +1523,13 @@ export function companySkillService(db: Db) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const metadata = {
|
||||||
|
...(skill.metadata ?? {}),
|
||||||
|
skillKey: skill.key,
|
||||||
|
};
|
||||||
const values = {
|
const values = {
|
||||||
companyId,
|
companyId,
|
||||||
|
key: skill.key,
|
||||||
slug: skill.slug,
|
slug: skill.slug,
|
||||||
name: skill.name,
|
name: skill.name,
|
||||||
description: skill.description,
|
description: skill.description,
|
||||||
|
|
@ -1351,7 +1540,7 @@ export function companySkillService(db: Db) {
|
||||||
trustLevel: skill.trustLevel,
|
trustLevel: skill.trustLevel,
|
||||||
compatibility: skill.compatibility,
|
compatibility: skill.compatibility,
|
||||||
fileInventory: serializeFileInventory(skill.fileInventory),
|
fileInventory: serializeFileInventory(skill.fileInventory),
|
||||||
metadata: skill.metadata,
|
metadata,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
const row = existing
|
const row = existing
|
||||||
|
|
@ -1378,11 +1567,11 @@ export function companySkillService(db: Db) {
|
||||||
const local = !/^https?:\/\//i.test(parsed.resolvedSource);
|
const local = !/^https?:\/\//i.test(parsed.resolvedSource);
|
||||||
const { skills, warnings } = local
|
const { skills, warnings } = local
|
||||||
? {
|
? {
|
||||||
skills: (await readLocalSkillImports(parsed.resolvedSource))
|
skills: (await readLocalSkillImports(companyId, parsed.resolvedSource))
|
||||||
.filter((skill) => !parsed.requestedSkillSlug || skill.slug === parsed.requestedSkillSlug),
|
.filter((skill) => !parsed.requestedSkillSlug || skill.slug === parsed.requestedSkillSlug),
|
||||||
warnings: parsed.warnings,
|
warnings: parsed.warnings,
|
||||||
}
|
}
|
||||||
: await readUrlSkillImports(parsed.resolvedSource, parsed.requestedSkillSlug)
|
: await readUrlSkillImports(companyId, parsed.resolvedSource, parsed.requestedSkillSlug)
|
||||||
.then((result) => ({
|
.then((result) => ({
|
||||||
skills: result.skills,
|
skills: result.skills,
|
||||||
warnings: [...parsed.warnings, ...result.warnings],
|
warnings: [...parsed.warnings, ...result.warnings],
|
||||||
|
|
@ -1405,7 +1594,7 @@ export function companySkillService(db: Db) {
|
||||||
list,
|
list,
|
||||||
listFull,
|
listFull,
|
||||||
getById,
|
getById,
|
||||||
getBySlug,
|
getByKey,
|
||||||
detail,
|
detail,
|
||||||
updateStatus,
|
updateStatus,
|
||||||
readFile,
|
readFile,
|
||||||
|
|
|
||||||
|
|
@ -1233,7 +1233,7 @@ function AgentSkillsTab({
|
||||||
}) {
|
}) {
|
||||||
type SkillRow = {
|
type SkillRow = {
|
||||||
id: string;
|
id: string;
|
||||||
slug: string;
|
key: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
detail: string | null;
|
detail: string | null;
|
||||||
|
|
@ -1316,50 +1316,50 @@ function AgentSkillsTab({
|
||||||
return () => window.clearTimeout(timeout);
|
return () => window.clearTimeout(timeout);
|
||||||
}, [skillDraft, skillSnapshot, syncSkills.isPending, syncSkills.mutate]);
|
}, [skillDraft, skillSnapshot, syncSkills.isPending, syncSkills.mutate]);
|
||||||
|
|
||||||
const companySkillBySlug = useMemo(
|
const companySkillByKey = useMemo(
|
||||||
() => new Map((companySkills ?? []).map((skill) => [skill.slug, skill])),
|
() => new Map((companySkills ?? []).map((skill) => [skill.key, skill])),
|
||||||
[companySkills],
|
[companySkills],
|
||||||
);
|
);
|
||||||
const adapterEntryByName = useMemo(
|
const adapterEntryByKey = useMemo(
|
||||||
() => new Map((skillSnapshot?.entries ?? []).map((entry) => [entry.name, entry])),
|
() => new Map((skillSnapshot?.entries ?? []).map((entry) => [entry.key, entry])),
|
||||||
[skillSnapshot],
|
[skillSnapshot],
|
||||||
);
|
);
|
||||||
const optionalSkillRows = useMemo<SkillRow[]>(
|
const optionalSkillRows = useMemo<SkillRow[]>(
|
||||||
() =>
|
() =>
|
||||||
(companySkills ?? [])
|
(companySkills ?? [])
|
||||||
.filter((skill) => !adapterEntryByName.get(skill.slug)?.required)
|
.filter((skill) => !adapterEntryByKey.get(skill.key)?.required)
|
||||||
.map((skill) => ({
|
.map((skill) => ({
|
||||||
id: skill.id,
|
id: skill.id,
|
||||||
slug: skill.slug,
|
key: skill.key,
|
||||||
name: skill.name,
|
name: skill.name,
|
||||||
description: skill.description,
|
description: skill.description,
|
||||||
detail: adapterEntryByName.get(skill.slug)?.detail ?? null,
|
detail: adapterEntryByKey.get(skill.key)?.detail ?? null,
|
||||||
linkTo: `/skills/${skill.id}`,
|
linkTo: `/skills/${skill.id}`,
|
||||||
adapterEntry: adapterEntryByName.get(skill.slug) ?? null,
|
adapterEntry: adapterEntryByKey.get(skill.key) ?? null,
|
||||||
})),
|
})),
|
||||||
[adapterEntryByName, companySkills],
|
[adapterEntryByKey, companySkills],
|
||||||
);
|
);
|
||||||
const requiredSkillRows = useMemo<SkillRow[]>(
|
const requiredSkillRows = useMemo<SkillRow[]>(
|
||||||
() =>
|
() =>
|
||||||
(skillSnapshot?.entries ?? [])
|
(skillSnapshot?.entries ?? [])
|
||||||
.filter((entry) => entry.required)
|
.filter((entry) => entry.required)
|
||||||
.map((entry) => {
|
.map((entry) => {
|
||||||
const companySkill = companySkillBySlug.get(entry.name);
|
const companySkill = companySkillByKey.get(entry.key);
|
||||||
return {
|
return {
|
||||||
id: companySkill?.id ?? `required:${entry.name}`,
|
id: companySkill?.id ?? `required:${entry.key}`,
|
||||||
slug: entry.name,
|
key: entry.key,
|
||||||
name: companySkill?.name ?? entry.name,
|
name: companySkill?.name ?? entry.key,
|
||||||
description: companySkill?.description ?? null,
|
description: companySkill?.description ?? null,
|
||||||
detail: entry.detail ?? null,
|
detail: entry.detail ?? null,
|
||||||
linkTo: companySkill ? `/skills/${companySkill.id}` : null,
|
linkTo: companySkill ? `/skills/${companySkill.id}` : null,
|
||||||
adapterEntry: entry,
|
adapterEntry: entry,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
[companySkillBySlug, skillSnapshot],
|
[companySkillByKey, skillSnapshot],
|
||||||
);
|
);
|
||||||
const desiredOnlyMissingSkills = useMemo(
|
const desiredOnlyMissingSkills = useMemo(
|
||||||
() => skillDraft.filter((slug) => !companySkillBySlug.has(slug)),
|
() => skillDraft.filter((key) => !companySkillByKey.has(key)),
|
||||||
[companySkillBySlug, skillDraft],
|
[companySkillByKey, skillDraft],
|
||||||
);
|
);
|
||||||
const skillApplicationLabel = useMemo(() => {
|
const skillApplicationLabel = useMemo(() => {
|
||||||
switch (skillSnapshot?.mode) {
|
switch (skillSnapshot?.mode) {
|
||||||
|
|
@ -1424,9 +1424,9 @@ function AgentSkillsTab({
|
||||||
<>
|
<>
|
||||||
{(() => {
|
{(() => {
|
||||||
const renderSkillRow = (skill: SkillRow) => {
|
const renderSkillRow = (skill: SkillRow) => {
|
||||||
const adapterEntry = skill.adapterEntry ?? adapterEntryByName.get(skill.slug);
|
const adapterEntry = skill.adapterEntry ?? adapterEntryByKey.get(skill.key);
|
||||||
const required = Boolean(adapterEntry?.required);
|
const required = Boolean(adapterEntry?.required);
|
||||||
const checked = required || skillDraft.includes(skill.slug);
|
const checked = required || skillDraft.includes(skill.key);
|
||||||
const disabled = required || skillSnapshot?.mode === "unsupported";
|
const disabled = required || skillSnapshot?.mode === "unsupported";
|
||||||
const checkbox = (
|
const checkbox = (
|
||||||
<input
|
<input
|
||||||
|
|
@ -1435,8 +1435,8 @@ function AgentSkillsTab({
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const next = event.target.checked
|
const next = event.target.checked
|
||||||
? Array.from(new Set([...skillDraft, skill.slug]))
|
? Array.from(new Set([...skillDraft, skill.key]))
|
||||||
: skillDraft.filter((value) => value !== skill.slug);
|
: skillDraft.filter((value) => value !== skill.key);
|
||||||
setSkillDraft(next);
|
setSkillDraft(next);
|
||||||
}}
|
}}
|
||||||
className="mt-0.5 disabled:cursor-not-allowed disabled:opacity-60"
|
className="mt-0.5 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
|
@ -1468,7 +1468,10 @@ function AgentSkillsTab({
|
||||||
)}
|
)}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<span className="truncate font-medium">{skill.name}</span>
|
<div className="min-w-0">
|
||||||
|
<span className="truncate font-medium">{skill.name}</span>
|
||||||
|
<div className="truncate font-mono text-[11px] text-muted-foreground">{skill.key}</div>
|
||||||
|
</div>
|
||||||
{skill.linkTo ? (
|
{skill.linkTo ? (
|
||||||
<Link
|
<Link
|
||||||
to={skill.linkTo}
|
to={skill.linkTo}
|
||||||
|
|
|
||||||
|
|
@ -540,17 +540,23 @@ export function CompanyExport() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSkillClick(skillSlug: string) {
|
function handleSkillClick(skillKey: string) {
|
||||||
if (!exportData) return;
|
if (!exportData) return;
|
||||||
// Find the SKILL.md file for this skill slug
|
const manifestSkill = exportData.manifest.skills.find(
|
||||||
const skillPath = `skills/${skillSlug}/SKILL.md`;
|
(skill) => skill.key === skillKey || skill.slug === skillKey,
|
||||||
|
);
|
||||||
|
const skillPath = manifestSkill?.path ?? `skills/${skillKey}/SKILL.md`;
|
||||||
if (!(skillPath in exportData.files)) return;
|
if (!(skillPath in exportData.files)) return;
|
||||||
// Select the file and expand parent dirs
|
|
||||||
setSelectedFile(skillPath);
|
setSelectedFile(skillPath);
|
||||||
setExpandedDirs((prev) => {
|
setExpandedDirs((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
next.add("skills");
|
next.add("skills");
|
||||||
next.add(`skills/${skillSlug}`);
|
const parts = skillPath.split("/").slice(0, -1);
|
||||||
|
let current = "";
|
||||||
|
for (const part of parts) {
|
||||||
|
current = current ? `${current}/${part}` : part;
|
||||||
|
next.add(current);
|
||||||
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -389,7 +389,7 @@ function SkillList({
|
||||||
onSelectPath: (skillId: string, path: string) => void;
|
onSelectPath: (skillId: string, path: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const filteredSkills = skills.filter((skill) => {
|
const filteredSkills = skills.filter((skill) => {
|
||||||
const haystack = `${skill.name} ${skill.slug} ${skill.sourceLabel ?? ""}`.toLowerCase();
|
const haystack = `${skill.name} ${skill.key} ${skill.slug} ${skill.sourceLabel ?? ""}`.toLowerCase();
|
||||||
return haystack.includes(skillFilter.toLowerCase());
|
return haystack.includes(skillFilter.toLowerCase());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -435,6 +435,9 @@ function SkillList({
|
||||||
<span className="block min-w-0 overflow-hidden text-[13px] font-medium leading-5 [display:-webkit-box] [-webkit-box-orient:vertical] [-webkit-line-clamp:3]">
|
<span className="block min-w-0 overflow-hidden text-[13px] font-medium leading-5 [display:-webkit-box] [-webkit-box-orient:vertical] [-webkit-line-clamp:3]">
|
||||||
{skill.name}
|
{skill.name}
|
||||||
</span>
|
</span>
|
||||||
|
<span className="truncate font-mono text-[11px] text-muted-foreground">
|
||||||
|
{skill.key}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
|
|
@ -596,6 +599,10 @@ function SkillPane({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Key</span>
|
||||||
|
<span className="font-mono text-xs">{detail.key}</span>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Mode</span>
|
<span className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Mode</span>
|
||||||
<span>{detail.editable ? "Editable" : "Read only"}</span>
|
<span>{detail.editable ? "Editable" : "Read only"}</span>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue