Add skill sync for remaining local adapters
This commit is contained in:
@@ -15,6 +15,7 @@ import {
|
||||
ensurePaperclipSkillSymlink,
|
||||
ensurePathInEnv,
|
||||
listPaperclipSkillEntries,
|
||||
readPaperclipSkillSyncPreference,
|
||||
removeMaintainerOnlySkillSymlinks,
|
||||
renderTemplate,
|
||||
joinPromptSections,
|
||||
@@ -167,7 +168,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
||||
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
await ensureCursorSkillsInjected(onLog);
|
||||
const cursorSkillEntries = await listPaperclipSkillEntries(__moduleDir);
|
||||
const cursorPreference = readPaperclipSkillSyncPreference(config);
|
||||
const desiredCursorSkillNames = cursorPreference.explicit
|
||||
? cursorPreference.desiredSkills
|
||||
: cursorSkillEntries.map((entry) => entry.name);
|
||||
await ensureCursorSkillsInjected(onLog, {
|
||||
skillsEntries: cursorSkillEntries.filter((entry) => desiredCursorSkillNames.includes(entry.name)),
|
||||
});
|
||||
|
||||
const envConfig = parseObject(config.env);
|
||||
const hasExplicitApiKey =
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { execute, ensureCursorSkillsInjected } from "./execute.js";
|
||||
export { listCursorSkills, syncCursorSkills } from "./skills.js";
|
||||
export { testEnvironment } from "./test.js";
|
||||
export { parseCursorJsonl, isCursorUnknownSessionError } from "./parse.js";
|
||||
import type { AdapterSessionCodec } from "@paperclipai/adapter-utils";
|
||||
|
||||
179
packages/adapters/cursor-local/src/server/skills.ts
Normal file
179
packages/adapters/cursor-local/src/server/skills.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type {
|
||||
AdapterSkillContext,
|
||||
AdapterSkillEntry,
|
||||
AdapterSkillSnapshot,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
ensurePaperclipSkillSymlink,
|
||||
listPaperclipSkillEntries,
|
||||
readPaperclipSkillSyncPreference,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function resolveCursorSkillsHome(config: Record<string, unknown>) {
|
||||
const env =
|
||||
typeof config.env === "object" && config.env !== null && !Array.isArray(config.env)
|
||||
? (config.env as Record<string, unknown>)
|
||||
: {};
|
||||
const configuredHome = asString(env.HOME);
|
||||
const home = configuredHome ? path.resolve(configuredHome) : os.homedir();
|
||||
return path.join(home, ".cursor", "skills");
|
||||
}
|
||||
|
||||
function resolveDesiredSkillNames(config: Record<string, unknown>, availableSkillNames: string[]) {
|
||||
const preference = readPaperclipSkillSyncPreference(config);
|
||||
return preference.explicit ? preference.desiredSkills : availableSkillNames;
|
||||
}
|
||||
|
||||
async function readInstalledSkillTargets(skillsHome: string) {
|
||||
const entries = await fs.readdir(skillsHome, { withFileTypes: true }).catch(() => []);
|
||||
const out = new Map<string, { targetPath: string | null; kind: "symlink" | "directory" | "file" }>();
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(skillsHome, entry.name);
|
||||
if (entry.isSymbolicLink()) {
|
||||
const linkedPath = await fs.readlink(fullPath).catch(() => null);
|
||||
out.set(entry.name, {
|
||||
targetPath: linkedPath ? path.resolve(path.dirname(fullPath), linkedPath) : null,
|
||||
kind: "symlink",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
out.set(entry.name, { targetPath: fullPath, kind: "directory" });
|
||||
continue;
|
||||
}
|
||||
out.set(entry.name, { targetPath: fullPath, kind: "file" });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function buildCursorSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
|
||||
const availableEntries = await listPaperclipSkillEntries(__moduleDir);
|
||||
const availableByName = new Map(availableEntries.map((entry) => [entry.name, entry]));
|
||||
const desiredSkills = resolveDesiredSkillNames(
|
||||
config,
|
||||
availableEntries.map((entry) => entry.name),
|
||||
);
|
||||
const desiredSet = new Set(desiredSkills);
|
||||
const skillsHome = resolveCursorSkillsHome(config);
|
||||
const installed = await readInstalledSkillTargets(skillsHome);
|
||||
const entries: AdapterSkillEntry[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
for (const available of availableEntries) {
|
||||
const installedEntry = installed.get(available.name) ?? null;
|
||||
const desired = desiredSet.has(available.name);
|
||||
let state: AdapterSkillEntry["state"] = "available";
|
||||
let managed = false;
|
||||
let detail: string | null = null;
|
||||
|
||||
if (installedEntry?.targetPath === available.source) {
|
||||
managed = true;
|
||||
state = desired ? "installed" : "stale";
|
||||
} else if (installedEntry) {
|
||||
state = "external";
|
||||
detail = desired
|
||||
? "Skill name is occupied by an external installation."
|
||||
: "Installed outside Paperclip management.";
|
||||
} else if (desired) {
|
||||
state = "missing";
|
||||
detail = "Configured but not currently linked into the Cursor skills home.";
|
||||
}
|
||||
|
||||
entries.push({
|
||||
name: available.name,
|
||||
desired,
|
||||
managed,
|
||||
state,
|
||||
sourcePath: available.source,
|
||||
targetPath: path.join(skillsHome, available.name),
|
||||
detail,
|
||||
});
|
||||
}
|
||||
|
||||
for (const desiredSkill of desiredSkills) {
|
||||
if (availableByName.has(desiredSkill)) continue;
|
||||
warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`);
|
||||
entries.push({
|
||||
name: desiredSkill,
|
||||
desired: true,
|
||||
managed: true,
|
||||
state: "missing",
|
||||
sourcePath: null,
|
||||
targetPath: path.join(skillsHome, desiredSkill),
|
||||
detail: "Paperclip cannot find this skill in the local runtime skills directory.",
|
||||
});
|
||||
}
|
||||
|
||||
for (const [name, installedEntry] of installed.entries()) {
|
||||
if (availableByName.has(name)) continue;
|
||||
entries.push({
|
||||
name,
|
||||
desired: false,
|
||||
managed: false,
|
||||
state: "external",
|
||||
sourcePath: null,
|
||||
targetPath: installedEntry.targetPath ?? path.join(skillsHome, name),
|
||||
detail: "Installed outside Paperclip management.",
|
||||
});
|
||||
}
|
||||
|
||||
entries.sort((left, right) => left.name.localeCompare(right.name));
|
||||
|
||||
return {
|
||||
adapterType: "cursor",
|
||||
supported: true,
|
||||
mode: "persistent",
|
||||
desiredSkills,
|
||||
entries,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listCursorSkills(ctx: AdapterSkillContext): Promise<AdapterSkillSnapshot> {
|
||||
return buildCursorSkillSnapshot(ctx.config);
|
||||
}
|
||||
|
||||
export async function syncCursorSkills(
|
||||
ctx: AdapterSkillContext,
|
||||
desiredSkills: string[],
|
||||
): Promise<AdapterSkillSnapshot> {
|
||||
const availableEntries = await listPaperclipSkillEntries(__moduleDir);
|
||||
const desiredSet = new Set(desiredSkills);
|
||||
const skillsHome = resolveCursorSkillsHome(ctx.config);
|
||||
await fs.mkdir(skillsHome, { recursive: true });
|
||||
const installed = await readInstalledSkillTargets(skillsHome);
|
||||
const availableByName = new Map(availableEntries.map((entry) => [entry.name, entry]));
|
||||
|
||||
for (const available of availableEntries) {
|
||||
if (!desiredSet.has(available.name)) continue;
|
||||
const target = path.join(skillsHome, available.name);
|
||||
await ensurePaperclipSkillSymlink(available.source, target);
|
||||
}
|
||||
|
||||
for (const [name, installedEntry] of installed.entries()) {
|
||||
const available = availableByName.get(name);
|
||||
if (!available) continue;
|
||||
if (desiredSet.has(name)) continue;
|
||||
if (installedEntry.targetPath !== available.source) continue;
|
||||
await fs.unlink(path.join(skillsHome, name)).catch(() => {});
|
||||
}
|
||||
|
||||
return buildCursorSkillSnapshot(ctx.config);
|
||||
}
|
||||
|
||||
export function resolveCursorDesiredSkillNames(
|
||||
config: Record<string, unknown>,
|
||||
availableSkillNames: string[],
|
||||
) {
|
||||
return resolveDesiredSkillNames(config, availableSkillNames);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
joinPromptSections,
|
||||
ensurePathInEnv,
|
||||
listPaperclipSkillEntries,
|
||||
readPaperclipSkillSyncPreference,
|
||||
removeMaintainerOnlySkillSymlinks,
|
||||
parseObject,
|
||||
redactEnvForLogs,
|
||||
@@ -84,8 +85,11 @@ function geminiSkillsHome(): string {
|
||||
*/
|
||||
async function ensureGeminiSkillsInjected(
|
||||
onLog: AdapterExecutionContext["onLog"],
|
||||
desiredSkillNames?: string[],
|
||||
): Promise<void> {
|
||||
const skillsEntries = await listPaperclipSkillEntries(__moduleDir);
|
||||
const allSkillsEntries = await listPaperclipSkillEntries(__moduleDir);
|
||||
const desiredSet = new Set(desiredSkillNames ?? allSkillsEntries.map((entry) => entry.name));
|
||||
const skillsEntries = allSkillsEntries.filter((entry) => desiredSet.has(entry.name));
|
||||
if (skillsEntries.length === 0) return;
|
||||
|
||||
const skillsHome = geminiSkillsHome();
|
||||
@@ -155,7 +159,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
||||
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
await ensureGeminiSkillsInjected(onLog);
|
||||
const geminiSkillEntries = await listPaperclipSkillEntries(__moduleDir);
|
||||
const geminiPreference = readPaperclipSkillSyncPreference(config);
|
||||
const desiredGeminiSkillNames = geminiPreference.explicit
|
||||
? geminiPreference.desiredSkills
|
||||
: geminiSkillEntries.map((entry) => entry.name);
|
||||
await ensureGeminiSkillsInjected(onLog, desiredGeminiSkillNames);
|
||||
|
||||
const envConfig = parseObject(config.env);
|
||||
const hasExplicitApiKey =
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { execute } from "./execute.js";
|
||||
export { listGeminiSkills, syncGeminiSkills } from "./skills.js";
|
||||
export { testEnvironment } from "./test.js";
|
||||
export {
|
||||
parseGeminiJsonl,
|
||||
|
||||
179
packages/adapters/gemini-local/src/server/skills.ts
Normal file
179
packages/adapters/gemini-local/src/server/skills.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type {
|
||||
AdapterSkillContext,
|
||||
AdapterSkillEntry,
|
||||
AdapterSkillSnapshot,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
ensurePaperclipSkillSymlink,
|
||||
listPaperclipSkillEntries,
|
||||
readPaperclipSkillSyncPreference,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function resolveGeminiSkillsHome(config: Record<string, unknown>) {
|
||||
const env =
|
||||
typeof config.env === "object" && config.env !== null && !Array.isArray(config.env)
|
||||
? (config.env as Record<string, unknown>)
|
||||
: {};
|
||||
const configuredHome = asString(env.HOME);
|
||||
const home = configuredHome ? path.resolve(configuredHome) : os.homedir();
|
||||
return path.join(home, ".gemini", "skills");
|
||||
}
|
||||
|
||||
function resolveDesiredSkillNames(config: Record<string, unknown>, availableSkillNames: string[]) {
|
||||
const preference = readPaperclipSkillSyncPreference(config);
|
||||
return preference.explicit ? preference.desiredSkills : availableSkillNames;
|
||||
}
|
||||
|
||||
async function readInstalledSkillTargets(skillsHome: string) {
|
||||
const entries = await fs.readdir(skillsHome, { withFileTypes: true }).catch(() => []);
|
||||
const out = new Map<string, { targetPath: string | null; kind: "symlink" | "directory" | "file" }>();
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(skillsHome, entry.name);
|
||||
if (entry.isSymbolicLink()) {
|
||||
const linkedPath = await fs.readlink(fullPath).catch(() => null);
|
||||
out.set(entry.name, {
|
||||
targetPath: linkedPath ? path.resolve(path.dirname(fullPath), linkedPath) : null,
|
||||
kind: "symlink",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
out.set(entry.name, { targetPath: fullPath, kind: "directory" });
|
||||
continue;
|
||||
}
|
||||
out.set(entry.name, { targetPath: fullPath, kind: "file" });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function buildGeminiSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
|
||||
const availableEntries = await listPaperclipSkillEntries(__moduleDir);
|
||||
const availableByName = new Map(availableEntries.map((entry) => [entry.name, entry]));
|
||||
const desiredSkills = resolveDesiredSkillNames(
|
||||
config,
|
||||
availableEntries.map((entry) => entry.name),
|
||||
);
|
||||
const desiredSet = new Set(desiredSkills);
|
||||
const skillsHome = resolveGeminiSkillsHome(config);
|
||||
const installed = await readInstalledSkillTargets(skillsHome);
|
||||
const entries: AdapterSkillEntry[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
for (const available of availableEntries) {
|
||||
const installedEntry = installed.get(available.name) ?? null;
|
||||
const desired = desiredSet.has(available.name);
|
||||
let state: AdapterSkillEntry["state"] = "available";
|
||||
let managed = false;
|
||||
let detail: string | null = null;
|
||||
|
||||
if (installedEntry?.targetPath === available.source) {
|
||||
managed = true;
|
||||
state = desired ? "installed" : "stale";
|
||||
} else if (installedEntry) {
|
||||
state = "external";
|
||||
detail = desired
|
||||
? "Skill name is occupied by an external installation."
|
||||
: "Installed outside Paperclip management.";
|
||||
} else if (desired) {
|
||||
state = "missing";
|
||||
detail = "Configured but not currently linked into the Gemini skills home.";
|
||||
}
|
||||
|
||||
entries.push({
|
||||
name: available.name,
|
||||
desired,
|
||||
managed,
|
||||
state,
|
||||
sourcePath: available.source,
|
||||
targetPath: path.join(skillsHome, available.name),
|
||||
detail,
|
||||
});
|
||||
}
|
||||
|
||||
for (const desiredSkill of desiredSkills) {
|
||||
if (availableByName.has(desiredSkill)) continue;
|
||||
warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`);
|
||||
entries.push({
|
||||
name: desiredSkill,
|
||||
desired: true,
|
||||
managed: true,
|
||||
state: "missing",
|
||||
sourcePath: null,
|
||||
targetPath: path.join(skillsHome, desiredSkill),
|
||||
detail: "Paperclip cannot find this skill in the local runtime skills directory.",
|
||||
});
|
||||
}
|
||||
|
||||
for (const [name, installedEntry] of installed.entries()) {
|
||||
if (availableByName.has(name)) continue;
|
||||
entries.push({
|
||||
name,
|
||||
desired: false,
|
||||
managed: false,
|
||||
state: "external",
|
||||
sourcePath: null,
|
||||
targetPath: installedEntry.targetPath ?? path.join(skillsHome, name),
|
||||
detail: "Installed outside Paperclip management.",
|
||||
});
|
||||
}
|
||||
|
||||
entries.sort((left, right) => left.name.localeCompare(right.name));
|
||||
|
||||
return {
|
||||
adapterType: "gemini_local",
|
||||
supported: true,
|
||||
mode: "persistent",
|
||||
desiredSkills,
|
||||
entries,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listGeminiSkills(ctx: AdapterSkillContext): Promise<AdapterSkillSnapshot> {
|
||||
return buildGeminiSkillSnapshot(ctx.config);
|
||||
}
|
||||
|
||||
export async function syncGeminiSkills(
|
||||
ctx: AdapterSkillContext,
|
||||
desiredSkills: string[],
|
||||
): Promise<AdapterSkillSnapshot> {
|
||||
const availableEntries = await listPaperclipSkillEntries(__moduleDir);
|
||||
const desiredSet = new Set(desiredSkills);
|
||||
const skillsHome = resolveGeminiSkillsHome(ctx.config);
|
||||
await fs.mkdir(skillsHome, { recursive: true });
|
||||
const installed = await readInstalledSkillTargets(skillsHome);
|
||||
const availableByName = new Map(availableEntries.map((entry) => [entry.name, entry]));
|
||||
|
||||
for (const available of availableEntries) {
|
||||
if (!desiredSet.has(available.name)) continue;
|
||||
const target = path.join(skillsHome, available.name);
|
||||
await ensurePaperclipSkillSymlink(available.source, target);
|
||||
}
|
||||
|
||||
for (const [name, installedEntry] of installed.entries()) {
|
||||
const available = availableByName.get(name);
|
||||
if (!available) continue;
|
||||
if (desiredSet.has(name)) continue;
|
||||
if (installedEntry.targetPath !== available.source) continue;
|
||||
await fs.unlink(path.join(skillsHome, name)).catch(() => {});
|
||||
}
|
||||
|
||||
return buildGeminiSkillSnapshot(ctx.config);
|
||||
}
|
||||
|
||||
export function resolveGeminiDesiredSkillNames(
|
||||
config: Record<string, unknown>,
|
||||
availableSkillNames: string[],
|
||||
) {
|
||||
return resolveDesiredSkillNames(config, availableSkillNames);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
ensurePathInEnv,
|
||||
renderTemplate,
|
||||
runChildProcess,
|
||||
readPaperclipSkillSyncPreference,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { isOpenCodeUnknownSessionError, parseOpenCodeJsonl } from "./parse.js";
|
||||
import { ensureOpenCodeModelConfiguredAndAvailable } from "./models.js";
|
||||
@@ -54,15 +55,20 @@ async function resolvePaperclipSkillsDir(): Promise<string | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function ensureOpenCodeSkillsInjected(onLog: AdapterExecutionContext["onLog"]) {
|
||||
async function ensureOpenCodeSkillsInjected(
|
||||
onLog: AdapterExecutionContext["onLog"],
|
||||
desiredSkillNames?: string[],
|
||||
) {
|
||||
const skillsDir = await resolvePaperclipSkillsDir();
|
||||
if (!skillsDir) return;
|
||||
|
||||
const skillsHome = claudeSkillsHome();
|
||||
await fs.mkdir(skillsHome, { recursive: true });
|
||||
const entries = await fs.readdir(skillsDir, { withFileTypes: true });
|
||||
const desiredSet = new Set(desiredSkillNames ?? entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name));
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (!desiredSet.has(entry.name)) continue;
|
||||
const source = path.join(skillsDir, entry.name);
|
||||
const target = path.join(skillsHome, entry.name);
|
||||
const existing = await fs.lstat(target).catch(() => null);
|
||||
@@ -110,7 +116,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
||||
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
await ensureOpenCodeSkillsInjected(onLog);
|
||||
const openCodePreference = readPaperclipSkillSyncPreference(config);
|
||||
await ensureOpenCodeSkillsInjected(
|
||||
onLog,
|
||||
openCodePreference.explicit ? openCodePreference.desiredSkills : undefined,
|
||||
);
|
||||
|
||||
const envConfig = parseObject(config.env);
|
||||
const hasExplicitApiKey =
|
||||
|
||||
@@ -61,6 +61,7 @@ export const sessionCodec: AdapterSessionCodec = {
|
||||
};
|
||||
|
||||
export { execute } from "./execute.js";
|
||||
export { listOpenCodeSkills, syncOpenCodeSkills } from "./skills.js";
|
||||
export { testEnvironment } from "./test.js";
|
||||
export {
|
||||
listOpenCodeModels,
|
||||
|
||||
182
packages/adapters/opencode-local/src/server/skills.ts
Normal file
182
packages/adapters/opencode-local/src/server/skills.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type {
|
||||
AdapterSkillContext,
|
||||
AdapterSkillEntry,
|
||||
AdapterSkillSnapshot,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
ensurePaperclipSkillSymlink,
|
||||
listPaperclipSkillEntries,
|
||||
readPaperclipSkillSyncPreference,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function resolveOpenCodeSkillsHome(config: Record<string, unknown>) {
|
||||
const env =
|
||||
typeof config.env === "object" && config.env !== null && !Array.isArray(config.env)
|
||||
? (config.env as Record<string, unknown>)
|
||||
: {};
|
||||
const configuredHome = asString(env.HOME);
|
||||
const home = configuredHome ? path.resolve(configuredHome) : os.homedir();
|
||||
return path.join(home, ".claude", "skills");
|
||||
}
|
||||
|
||||
function resolveDesiredSkillNames(config: Record<string, unknown>, availableSkillNames: string[]) {
|
||||
const preference = readPaperclipSkillSyncPreference(config);
|
||||
return preference.explicit ? preference.desiredSkills : availableSkillNames;
|
||||
}
|
||||
|
||||
async function readInstalledSkillTargets(skillsHome: string) {
|
||||
const entries = await fs.readdir(skillsHome, { withFileTypes: true }).catch(() => []);
|
||||
const out = new Map<string, { targetPath: string | null; kind: "symlink" | "directory" | "file" }>();
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(skillsHome, entry.name);
|
||||
if (entry.isSymbolicLink()) {
|
||||
const linkedPath = await fs.readlink(fullPath).catch(() => null);
|
||||
out.set(entry.name, {
|
||||
targetPath: linkedPath ? path.resolve(path.dirname(fullPath), linkedPath) : null,
|
||||
kind: "symlink",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
out.set(entry.name, { targetPath: fullPath, kind: "directory" });
|
||||
continue;
|
||||
}
|
||||
out.set(entry.name, { targetPath: fullPath, kind: "file" });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function buildOpenCodeSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
|
||||
const availableEntries = await listPaperclipSkillEntries(__moduleDir);
|
||||
const availableByName = new Map(availableEntries.map((entry) => [entry.name, entry]));
|
||||
const desiredSkills = resolveDesiredSkillNames(
|
||||
config,
|
||||
availableEntries.map((entry) => entry.name),
|
||||
);
|
||||
const desiredSet = new Set(desiredSkills);
|
||||
const skillsHome = resolveOpenCodeSkillsHome(config);
|
||||
const installed = await readInstalledSkillTargets(skillsHome);
|
||||
const entries: AdapterSkillEntry[] = [];
|
||||
const warnings: string[] = [
|
||||
"OpenCode currently uses the shared Claude skills home (~/.claude/skills).",
|
||||
];
|
||||
|
||||
for (const available of availableEntries) {
|
||||
const installedEntry = installed.get(available.name) ?? null;
|
||||
const desired = desiredSet.has(available.name);
|
||||
let state: AdapterSkillEntry["state"] = "available";
|
||||
let managed = false;
|
||||
let detail: string | null = null;
|
||||
|
||||
if (installedEntry?.targetPath === available.source) {
|
||||
managed = true;
|
||||
state = desired ? "installed" : "stale";
|
||||
detail = "Installed in the shared Claude/OpenCode skills home.";
|
||||
} else if (installedEntry) {
|
||||
state = "external";
|
||||
detail = desired
|
||||
? "Skill name is occupied by an external installation in the shared skills home."
|
||||
: "Installed outside Paperclip management in the shared skills home.";
|
||||
} else if (desired) {
|
||||
state = "missing";
|
||||
detail = "Configured but not currently linked into the shared Claude/OpenCode skills home.";
|
||||
}
|
||||
|
||||
entries.push({
|
||||
name: available.name,
|
||||
desired,
|
||||
managed,
|
||||
state,
|
||||
sourcePath: available.source,
|
||||
targetPath: path.join(skillsHome, available.name),
|
||||
detail,
|
||||
});
|
||||
}
|
||||
|
||||
for (const desiredSkill of desiredSkills) {
|
||||
if (availableByName.has(desiredSkill)) continue;
|
||||
warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`);
|
||||
entries.push({
|
||||
name: desiredSkill,
|
||||
desired: true,
|
||||
managed: true,
|
||||
state: "missing",
|
||||
sourcePath: null,
|
||||
targetPath: path.join(skillsHome, desiredSkill),
|
||||
detail: "Paperclip cannot find this skill in the local runtime skills directory.",
|
||||
});
|
||||
}
|
||||
|
||||
for (const [name, installedEntry] of installed.entries()) {
|
||||
if (availableByName.has(name)) continue;
|
||||
entries.push({
|
||||
name,
|
||||
desired: false,
|
||||
managed: false,
|
||||
state: "external",
|
||||
sourcePath: null,
|
||||
targetPath: installedEntry.targetPath ?? path.join(skillsHome, name),
|
||||
detail: "Installed outside Paperclip management in the shared skills home.",
|
||||
});
|
||||
}
|
||||
|
||||
entries.sort((left, right) => left.name.localeCompare(right.name));
|
||||
|
||||
return {
|
||||
adapterType: "opencode_local",
|
||||
supported: true,
|
||||
mode: "persistent",
|
||||
desiredSkills,
|
||||
entries,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listOpenCodeSkills(ctx: AdapterSkillContext): Promise<AdapterSkillSnapshot> {
|
||||
return buildOpenCodeSkillSnapshot(ctx.config);
|
||||
}
|
||||
|
||||
export async function syncOpenCodeSkills(
|
||||
ctx: AdapterSkillContext,
|
||||
desiredSkills: string[],
|
||||
): Promise<AdapterSkillSnapshot> {
|
||||
const availableEntries = await listPaperclipSkillEntries(__moduleDir);
|
||||
const desiredSet = new Set(desiredSkills);
|
||||
const skillsHome = resolveOpenCodeSkillsHome(ctx.config);
|
||||
await fs.mkdir(skillsHome, { recursive: true });
|
||||
const installed = await readInstalledSkillTargets(skillsHome);
|
||||
const availableByName = new Map(availableEntries.map((entry) => [entry.name, entry]));
|
||||
|
||||
for (const available of availableEntries) {
|
||||
if (!desiredSet.has(available.name)) continue;
|
||||
const target = path.join(skillsHome, available.name);
|
||||
await ensurePaperclipSkillSymlink(available.source, target);
|
||||
}
|
||||
|
||||
for (const [name, installedEntry] of installed.entries()) {
|
||||
const available = availableByName.get(name);
|
||||
if (!available) continue;
|
||||
if (desiredSet.has(name)) continue;
|
||||
if (installedEntry.targetPath !== available.source) continue;
|
||||
await fs.unlink(path.join(skillsHome, name)).catch(() => {});
|
||||
}
|
||||
|
||||
return buildOpenCodeSkillSnapshot(ctx.config);
|
||||
}
|
||||
|
||||
export function resolveOpenCodeDesiredSkillNames(
|
||||
config: Record<string, unknown>,
|
||||
availableSkillNames: string[],
|
||||
) {
|
||||
return resolveDesiredSkillNames(config, availableSkillNames);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
ensurePaperclipSkillSymlink,
|
||||
ensurePathInEnv,
|
||||
listPaperclipSkillEntries,
|
||||
readPaperclipSkillSyncPreference,
|
||||
removeMaintainerOnlySkillSymlinks,
|
||||
renderTemplate,
|
||||
runChildProcess,
|
||||
@@ -50,8 +51,13 @@ function parseModelId(model: string | null): string | null {
|
||||
return trimmed.slice(trimmed.indexOf("/") + 1).trim() || null;
|
||||
}
|
||||
|
||||
async function ensurePiSkillsInjected(onLog: AdapterExecutionContext["onLog"]) {
|
||||
const skillsEntries = await listPaperclipSkillEntries(__moduleDir);
|
||||
async function ensurePiSkillsInjected(
|
||||
onLog: AdapterExecutionContext["onLog"],
|
||||
desiredSkillNames?: string[],
|
||||
) {
|
||||
const allSkillsEntries = await listPaperclipSkillEntries(__moduleDir);
|
||||
const desiredSet = new Set(desiredSkillNames ?? allSkillsEntries.map((entry) => entry.name));
|
||||
const skillsEntries = allSkillsEntries.filter((entry) => desiredSet.has(entry.name));
|
||||
if (skillsEntries.length === 0) return;
|
||||
|
||||
const piSkillsHome = path.join(os.homedir(), ".pi", "agent", "skills");
|
||||
@@ -132,7 +138,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
await ensureSessionsDir();
|
||||
|
||||
// Inject skills
|
||||
await ensurePiSkillsInjected(onLog);
|
||||
const piSkillEntries = await listPaperclipSkillEntries(__moduleDir);
|
||||
const piPreference = readPaperclipSkillSyncPreference(config);
|
||||
const desiredPiSkillNames = piPreference.explicit
|
||||
? piPreference.desiredSkills
|
||||
: piSkillEntries.map((entry) => entry.name);
|
||||
await ensurePiSkillsInjected(onLog, desiredPiSkillNames);
|
||||
|
||||
// Build environment
|
||||
const envConfig = parseObject(config.env);
|
||||
|
||||
@@ -49,6 +49,7 @@ export const sessionCodec: AdapterSessionCodec = {
|
||||
};
|
||||
|
||||
export { execute } from "./execute.js";
|
||||
export { listPiSkills, syncPiSkills } from "./skills.js";
|
||||
export { testEnvironment } from "./test.js";
|
||||
export {
|
||||
listPiModels,
|
||||
|
||||
179
packages/adapters/pi-local/src/server/skills.ts
Normal file
179
packages/adapters/pi-local/src/server/skills.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type {
|
||||
AdapterSkillContext,
|
||||
AdapterSkillEntry,
|
||||
AdapterSkillSnapshot,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
ensurePaperclipSkillSymlink,
|
||||
listPaperclipSkillEntries,
|
||||
readPaperclipSkillSyncPreference,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function resolvePiSkillsHome(config: Record<string, unknown>) {
|
||||
const env =
|
||||
typeof config.env === "object" && config.env !== null && !Array.isArray(config.env)
|
||||
? (config.env as Record<string, unknown>)
|
||||
: {};
|
||||
const configuredHome = asString(env.HOME);
|
||||
const home = configuredHome ? path.resolve(configuredHome) : os.homedir();
|
||||
return path.join(home, ".pi", "agent", "skills");
|
||||
}
|
||||
|
||||
function resolveDesiredSkillNames(config: Record<string, unknown>, availableSkillNames: string[]) {
|
||||
const preference = readPaperclipSkillSyncPreference(config);
|
||||
return preference.explicit ? preference.desiredSkills : availableSkillNames;
|
||||
}
|
||||
|
||||
async function readInstalledSkillTargets(skillsHome: string) {
|
||||
const entries = await fs.readdir(skillsHome, { withFileTypes: true }).catch(() => []);
|
||||
const out = new Map<string, { targetPath: string | null; kind: "symlink" | "directory" | "file" }>();
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(skillsHome, entry.name);
|
||||
if (entry.isSymbolicLink()) {
|
||||
const linkedPath = await fs.readlink(fullPath).catch(() => null);
|
||||
out.set(entry.name, {
|
||||
targetPath: linkedPath ? path.resolve(path.dirname(fullPath), linkedPath) : null,
|
||||
kind: "symlink",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
out.set(entry.name, { targetPath: fullPath, kind: "directory" });
|
||||
continue;
|
||||
}
|
||||
out.set(entry.name, { targetPath: fullPath, kind: "file" });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function buildPiSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
|
||||
const availableEntries = await listPaperclipSkillEntries(__moduleDir);
|
||||
const availableByName = new Map(availableEntries.map((entry) => [entry.name, entry]));
|
||||
const desiredSkills = resolveDesiredSkillNames(
|
||||
config,
|
||||
availableEntries.map((entry) => entry.name),
|
||||
);
|
||||
const desiredSet = new Set(desiredSkills);
|
||||
const skillsHome = resolvePiSkillsHome(config);
|
||||
const installed = await readInstalledSkillTargets(skillsHome);
|
||||
const entries: AdapterSkillEntry[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
for (const available of availableEntries) {
|
||||
const installedEntry = installed.get(available.name) ?? null;
|
||||
const desired = desiredSet.has(available.name);
|
||||
let state: AdapterSkillEntry["state"] = "available";
|
||||
let managed = false;
|
||||
let detail: string | null = null;
|
||||
|
||||
if (installedEntry?.targetPath === available.source) {
|
||||
managed = true;
|
||||
state = desired ? "installed" : "stale";
|
||||
} else if (installedEntry) {
|
||||
state = "external";
|
||||
detail = desired
|
||||
? "Skill name is occupied by an external installation."
|
||||
: "Installed outside Paperclip management.";
|
||||
} else if (desired) {
|
||||
state = "missing";
|
||||
detail = "Configured but not currently linked into the Pi skills home.";
|
||||
}
|
||||
|
||||
entries.push({
|
||||
name: available.name,
|
||||
desired,
|
||||
managed,
|
||||
state,
|
||||
sourcePath: available.source,
|
||||
targetPath: path.join(skillsHome, available.name),
|
||||
detail,
|
||||
});
|
||||
}
|
||||
|
||||
for (const desiredSkill of desiredSkills) {
|
||||
if (availableByName.has(desiredSkill)) continue;
|
||||
warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`);
|
||||
entries.push({
|
||||
name: desiredSkill,
|
||||
desired: true,
|
||||
managed: true,
|
||||
state: "missing",
|
||||
sourcePath: null,
|
||||
targetPath: path.join(skillsHome, desiredSkill),
|
||||
detail: "Paperclip cannot find this skill in the local runtime skills directory.",
|
||||
});
|
||||
}
|
||||
|
||||
for (const [name, installedEntry] of installed.entries()) {
|
||||
if (availableByName.has(name)) continue;
|
||||
entries.push({
|
||||
name,
|
||||
desired: false,
|
||||
managed: false,
|
||||
state: "external",
|
||||
sourcePath: null,
|
||||
targetPath: installedEntry.targetPath ?? path.join(skillsHome, name),
|
||||
detail: "Installed outside Paperclip management.",
|
||||
});
|
||||
}
|
||||
|
||||
entries.sort((left, right) => left.name.localeCompare(right.name));
|
||||
|
||||
return {
|
||||
adapterType: "pi_local",
|
||||
supported: true,
|
||||
mode: "persistent",
|
||||
desiredSkills,
|
||||
entries,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listPiSkills(ctx: AdapterSkillContext): Promise<AdapterSkillSnapshot> {
|
||||
return buildPiSkillSnapshot(ctx.config);
|
||||
}
|
||||
|
||||
export async function syncPiSkills(
|
||||
ctx: AdapterSkillContext,
|
||||
desiredSkills: string[],
|
||||
): Promise<AdapterSkillSnapshot> {
|
||||
const availableEntries = await listPaperclipSkillEntries(__moduleDir);
|
||||
const desiredSet = new Set(desiredSkills);
|
||||
const skillsHome = resolvePiSkillsHome(ctx.config);
|
||||
await fs.mkdir(skillsHome, { recursive: true });
|
||||
const installed = await readInstalledSkillTargets(skillsHome);
|
||||
const availableByName = new Map(availableEntries.map((entry) => [entry.name, entry]));
|
||||
|
||||
for (const available of availableEntries) {
|
||||
if (!desiredSet.has(available.name)) continue;
|
||||
const target = path.join(skillsHome, available.name);
|
||||
await ensurePaperclipSkillSymlink(available.source, target);
|
||||
}
|
||||
|
||||
for (const [name, installedEntry] of installed.entries()) {
|
||||
const available = availableByName.get(name);
|
||||
if (!available) continue;
|
||||
if (desiredSet.has(name)) continue;
|
||||
if (installedEntry.targetPath !== available.source) continue;
|
||||
await fs.unlink(path.join(skillsHome, name)).catch(() => {});
|
||||
}
|
||||
|
||||
return buildPiSkillSnapshot(ctx.config);
|
||||
}
|
||||
|
||||
export function resolvePiDesiredSkillNames(
|
||||
config: Record<string, unknown>,
|
||||
availableSkillNames: string[],
|
||||
) {
|
||||
return resolveDesiredSkillNames(config, availableSkillNames);
|
||||
}
|
||||
87
server/src/__tests__/cursor-local-skill-sync.test.ts
Normal file
87
server/src/__tests__/cursor-local-skill-sync.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
listCursorSkills,
|
||||
syncCursorSkills,
|
||||
} from "@paperclipai/adapter-cursor-local/server";
|
||||
|
||||
async function makeTempDir(prefix: string): Promise<string> {
|
||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
describe("cursor local skill sync", () => {
|
||||
const cleanupDirs = new Set<string>();
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(Array.from(cleanupDirs).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||
cleanupDirs.clear();
|
||||
});
|
||||
|
||||
it("reports configured Paperclip skills and installs them into the Cursor skills home", async () => {
|
||||
const home = await makeTempDir("paperclip-cursor-skill-sync-");
|
||||
cleanupDirs.add(home);
|
||||
|
||||
const ctx = {
|
||||
agentId: "agent-1",
|
||||
companyId: "company-1",
|
||||
adapterType: "cursor",
|
||||
config: {
|
||||
env: {
|
||||
HOME: home,
|
||||
},
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: ["paperclip"],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const before = await listCursorSkills(ctx);
|
||||
expect(before.mode).toBe("persistent");
|
||||
expect(before.desiredSkills).toEqual(["paperclip"]);
|
||||
expect(before.entries.find((entry) => entry.name === "paperclip")?.state).toBe("missing");
|
||||
|
||||
const after = await syncCursorSkills(ctx, ["paperclip"]);
|
||||
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("installed");
|
||||
expect((await fs.lstat(path.join(home, ".cursor", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
it("removes stale managed Paperclip skills when the desired set is emptied", async () => {
|
||||
const home = await makeTempDir("paperclip-cursor-skill-prune-");
|
||||
cleanupDirs.add(home);
|
||||
|
||||
const configuredCtx = {
|
||||
agentId: "agent-2",
|
||||
companyId: "company-1",
|
||||
adapterType: "cursor",
|
||||
config: {
|
||||
env: {
|
||||
HOME: home,
|
||||
},
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: ["paperclip"],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
await syncCursorSkills(configuredCtx, ["paperclip"]);
|
||||
|
||||
const clearedCtx = {
|
||||
...configuredCtx,
|
||||
config: {
|
||||
env: {
|
||||
HOME: home,
|
||||
},
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: [],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const after = await syncCursorSkills(clearedCtx, []);
|
||||
expect(after.desiredSkills).toEqual([]);
|
||||
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("available");
|
||||
await expect(fs.lstat(path.join(home, ".cursor", "skills", "paperclip"))).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
87
server/src/__tests__/gemini-local-skill-sync.test.ts
Normal file
87
server/src/__tests__/gemini-local-skill-sync.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
listGeminiSkills,
|
||||
syncGeminiSkills,
|
||||
} from "@paperclipai/adapter-gemini-local/server";
|
||||
|
||||
async function makeTempDir(prefix: string): Promise<string> {
|
||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
describe("gemini local skill sync", () => {
|
||||
const cleanupDirs = new Set<string>();
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(Array.from(cleanupDirs).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||
cleanupDirs.clear();
|
||||
});
|
||||
|
||||
it("reports configured Paperclip skills and installs them into the Gemini skills home", async () => {
|
||||
const home = await makeTempDir("paperclip-gemini-skill-sync-");
|
||||
cleanupDirs.add(home);
|
||||
|
||||
const ctx = {
|
||||
agentId: "agent-1",
|
||||
companyId: "company-1",
|
||||
adapterType: "gemini_local",
|
||||
config: {
|
||||
env: {
|
||||
HOME: home,
|
||||
},
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: ["paperclip"],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const before = await listGeminiSkills(ctx);
|
||||
expect(before.mode).toBe("persistent");
|
||||
expect(before.desiredSkills).toEqual(["paperclip"]);
|
||||
expect(before.entries.find((entry) => entry.name === "paperclip")?.state).toBe("missing");
|
||||
|
||||
const after = await syncGeminiSkills(ctx, ["paperclip"]);
|
||||
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("installed");
|
||||
expect((await fs.lstat(path.join(home, ".gemini", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
it("removes stale managed Paperclip skills when the desired set is emptied", async () => {
|
||||
const home = await makeTempDir("paperclip-gemini-skill-prune-");
|
||||
cleanupDirs.add(home);
|
||||
|
||||
const configuredCtx = {
|
||||
agentId: "agent-2",
|
||||
companyId: "company-1",
|
||||
adapterType: "gemini_local",
|
||||
config: {
|
||||
env: {
|
||||
HOME: home,
|
||||
},
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: ["paperclip"],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
await syncGeminiSkills(configuredCtx, ["paperclip"]);
|
||||
|
||||
const clearedCtx = {
|
||||
...configuredCtx,
|
||||
config: {
|
||||
env: {
|
||||
HOME: home,
|
||||
},
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: [],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const after = await syncGeminiSkills(clearedCtx, []);
|
||||
expect(after.desiredSkills).toEqual([]);
|
||||
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("available");
|
||||
await expect(fs.lstat(path.join(home, ".gemini", "skills", "paperclip"))).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
88
server/src/__tests__/opencode-local-skill-sync.test.ts
Normal file
88
server/src/__tests__/opencode-local-skill-sync.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
listOpenCodeSkills,
|
||||
syncOpenCodeSkills,
|
||||
} from "@paperclipai/adapter-opencode-local/server";
|
||||
|
||||
async function makeTempDir(prefix: string): Promise<string> {
|
||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
describe("opencode local skill sync", () => {
|
||||
const cleanupDirs = new Set<string>();
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(Array.from(cleanupDirs).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||
cleanupDirs.clear();
|
||||
});
|
||||
|
||||
it("reports configured Paperclip skills and installs them into the shared Claude/OpenCode skills home", async () => {
|
||||
const home = await makeTempDir("paperclip-opencode-skill-sync-");
|
||||
cleanupDirs.add(home);
|
||||
|
||||
const ctx = {
|
||||
agentId: "agent-1",
|
||||
companyId: "company-1",
|
||||
adapterType: "opencode_local",
|
||||
config: {
|
||||
env: {
|
||||
HOME: home,
|
||||
},
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: ["paperclip"],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const before = await listOpenCodeSkills(ctx);
|
||||
expect(before.mode).toBe("persistent");
|
||||
expect(before.warnings).toContain("OpenCode currently uses the shared Claude skills home (~/.claude/skills).");
|
||||
expect(before.desiredSkills).toEqual(["paperclip"]);
|
||||
expect(before.entries.find((entry) => entry.name === "paperclip")?.state).toBe("missing");
|
||||
|
||||
const after = await syncOpenCodeSkills(ctx, ["paperclip"]);
|
||||
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("installed");
|
||||
expect((await fs.lstat(path.join(home, ".claude", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
it("removes stale managed Paperclip skills when the desired set is emptied", async () => {
|
||||
const home = await makeTempDir("paperclip-opencode-skill-prune-");
|
||||
cleanupDirs.add(home);
|
||||
|
||||
const configuredCtx = {
|
||||
agentId: "agent-2",
|
||||
companyId: "company-1",
|
||||
adapterType: "opencode_local",
|
||||
config: {
|
||||
env: {
|
||||
HOME: home,
|
||||
},
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: ["paperclip"],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
await syncOpenCodeSkills(configuredCtx, ["paperclip"]);
|
||||
|
||||
const clearedCtx = {
|
||||
...configuredCtx,
|
||||
config: {
|
||||
env: {
|
||||
HOME: home,
|
||||
},
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: [],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const after = await syncOpenCodeSkills(clearedCtx, []);
|
||||
expect(after.desiredSkills).toEqual([]);
|
||||
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("available");
|
||||
await expect(fs.lstat(path.join(home, ".claude", "skills", "paperclip"))).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
87
server/src/__tests__/pi-local-skill-sync.test.ts
Normal file
87
server/src/__tests__/pi-local-skill-sync.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
listPiSkills,
|
||||
syncPiSkills,
|
||||
} from "@paperclipai/adapter-pi-local/server";
|
||||
|
||||
async function makeTempDir(prefix: string): Promise<string> {
|
||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
describe("pi local skill sync", () => {
|
||||
const cleanupDirs = new Set<string>();
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(Array.from(cleanupDirs).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||
cleanupDirs.clear();
|
||||
});
|
||||
|
||||
it("reports configured Paperclip skills and installs them into the Pi skills home", async () => {
|
||||
const home = await makeTempDir("paperclip-pi-skill-sync-");
|
||||
cleanupDirs.add(home);
|
||||
|
||||
const ctx = {
|
||||
agentId: "agent-1",
|
||||
companyId: "company-1",
|
||||
adapterType: "pi_local",
|
||||
config: {
|
||||
env: {
|
||||
HOME: home,
|
||||
},
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: ["paperclip"],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const before = await listPiSkills(ctx);
|
||||
expect(before.mode).toBe("persistent");
|
||||
expect(before.desiredSkills).toEqual(["paperclip"]);
|
||||
expect(before.entries.find((entry) => entry.name === "paperclip")?.state).toBe("missing");
|
||||
|
||||
const after = await syncPiSkills(ctx, ["paperclip"]);
|
||||
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("installed");
|
||||
expect((await fs.lstat(path.join(home, ".pi", "agent", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
it("removes stale managed Paperclip skills when the desired set is emptied", async () => {
|
||||
const home = await makeTempDir("paperclip-pi-skill-prune-");
|
||||
cleanupDirs.add(home);
|
||||
|
||||
const configuredCtx = {
|
||||
agentId: "agent-2",
|
||||
companyId: "company-1",
|
||||
adapterType: "pi_local",
|
||||
config: {
|
||||
env: {
|
||||
HOME: home,
|
||||
},
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: ["paperclip"],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
await syncPiSkills(configuredCtx, ["paperclip"]);
|
||||
|
||||
const clearedCtx = {
|
||||
...configuredCtx,
|
||||
config: {
|
||||
env: {
|
||||
HOME: home,
|
||||
},
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: [],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const after = await syncPiSkills(clearedCtx, []);
|
||||
expect(after.desiredSkills).toEqual([]);
|
||||
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("available");
|
||||
await expect(fs.lstat(path.join(home, ".pi", "agent", "skills", "paperclip"))).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -17,18 +17,24 @@ import {
|
||||
import { agentConfigurationDoc as codexAgentConfigurationDoc, models as codexModels } from "@paperclipai/adapter-codex-local";
|
||||
import {
|
||||
execute as cursorExecute,
|
||||
listCursorSkills,
|
||||
syncCursorSkills,
|
||||
testEnvironment as cursorTestEnvironment,
|
||||
sessionCodec as cursorSessionCodec,
|
||||
} from "@paperclipai/adapter-cursor-local/server";
|
||||
import { agentConfigurationDoc as cursorAgentConfigurationDoc, models as cursorModels } from "@paperclipai/adapter-cursor-local";
|
||||
import {
|
||||
execute as geminiExecute,
|
||||
listGeminiSkills,
|
||||
syncGeminiSkills,
|
||||
testEnvironment as geminiTestEnvironment,
|
||||
sessionCodec as geminiSessionCodec,
|
||||
} from "@paperclipai/adapter-gemini-local/server";
|
||||
import { agentConfigurationDoc as geminiAgentConfigurationDoc, models as geminiModels } from "@paperclipai/adapter-gemini-local";
|
||||
import {
|
||||
execute as openCodeExecute,
|
||||
listOpenCodeSkills,
|
||||
syncOpenCodeSkills,
|
||||
testEnvironment as openCodeTestEnvironment,
|
||||
sessionCodec as openCodeSessionCodec,
|
||||
listOpenCodeModels,
|
||||
@@ -48,6 +54,8 @@ import { listCodexModels } from "./codex-models.js";
|
||||
import { listCursorModels } from "./cursor-models.js";
|
||||
import {
|
||||
execute as piExecute,
|
||||
listPiSkills,
|
||||
syncPiSkills,
|
||||
testEnvironment as piTestEnvironment,
|
||||
sessionCodec as piSessionCodec,
|
||||
listPiModels,
|
||||
@@ -87,6 +95,8 @@ const cursorLocalAdapter: ServerAdapterModule = {
|
||||
type: "cursor",
|
||||
execute: cursorExecute,
|
||||
testEnvironment: cursorTestEnvironment,
|
||||
listSkills: listCursorSkills,
|
||||
syncSkills: syncCursorSkills,
|
||||
sessionCodec: cursorSessionCodec,
|
||||
models: cursorModels,
|
||||
listModels: listCursorModels,
|
||||
@@ -98,6 +108,8 @@ const geminiLocalAdapter: ServerAdapterModule = {
|
||||
type: "gemini_local",
|
||||
execute: geminiExecute,
|
||||
testEnvironment: geminiTestEnvironment,
|
||||
listSkills: listGeminiSkills,
|
||||
syncSkills: syncGeminiSkills,
|
||||
sessionCodec: geminiSessionCodec,
|
||||
models: geminiModels,
|
||||
supportsLocalAgentJwt: true,
|
||||
@@ -117,6 +129,8 @@ const openCodeLocalAdapter: ServerAdapterModule = {
|
||||
type: "opencode_local",
|
||||
execute: openCodeExecute,
|
||||
testEnvironment: openCodeTestEnvironment,
|
||||
listSkills: listOpenCodeSkills,
|
||||
syncSkills: syncOpenCodeSkills,
|
||||
sessionCodec: openCodeSessionCodec,
|
||||
models: [],
|
||||
listModels: listOpenCodeModels,
|
||||
@@ -128,6 +142,8 @@ const piLocalAdapter: ServerAdapterModule = {
|
||||
type: "pi_local",
|
||||
execute: piExecute,
|
||||
testEnvironment: piTestEnvironment,
|
||||
listSkills: listPiSkills,
|
||||
syncSkills: syncPiSkills,
|
||||
sessionCodec: piSessionCodec,
|
||||
models: [],
|
||||
listModels: listPiModels,
|
||||
|
||||
@@ -125,6 +125,12 @@ const sourceLabels: Record<string, string> = {
|
||||
const LIVE_SCROLL_BOTTOM_TOLERANCE_PX = 32;
|
||||
type ScrollContainer = Window | HTMLElement;
|
||||
|
||||
function arraysEqual(a: string[], b: string[]): boolean {
|
||||
if (a === b) return true;
|
||||
if (a.length !== b.length) return false;
|
||||
return a.every((value, index) => value === b[index]);
|
||||
}
|
||||
|
||||
function isWindowContainer(container: ScrollContainer): container is Window {
|
||||
return container === window;
|
||||
}
|
||||
@@ -1144,7 +1150,8 @@ function AgentSkillsTab({
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [skillDraft, setSkillDraft] = useState<string[]>([]);
|
||||
const [skillDirty, setSkillDirty] = useState(false);
|
||||
const [lastSavedSkills, setLastSavedSkills] = useState<string[]>([]);
|
||||
const lastSavedSkillsRef = useRef<string[]>([]);
|
||||
|
||||
const { data: skillSnapshot, isLoading } = useQuery({
|
||||
queryKey: queryKeys.agents.skills(agent.id),
|
||||
@@ -1158,28 +1165,40 @@ function AgentSkillsTab({
|
||||
enabled: Boolean(companyId),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!skillSnapshot) return;
|
||||
setSkillDraft(skillSnapshot.desiredSkills);
|
||||
setSkillDirty(false);
|
||||
}, [skillSnapshot]);
|
||||
|
||||
const syncSkills = useMutation({
|
||||
mutationFn: (desiredSkills: string[]) => agentsApi.syncSkills(agent.id, desiredSkills, companyId),
|
||||
onSuccess: async (snapshot) => {
|
||||
queryClient.setQueryData(queryKeys.agents.skills(agent.id), snapshot);
|
||||
lastSavedSkillsRef.current = snapshot.desiredSkills;
|
||||
setLastSavedSkills(snapshot.desiredSkills);
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.skills(agent.id) }),
|
||||
companyId
|
||||
? queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.list(companyId) })
|
||||
: Promise.resolve(),
|
||||
]);
|
||||
setSkillDraft(snapshot.desiredSkills);
|
||||
setSkillDirty(false);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!skillSnapshot) return;
|
||||
setSkillDraft((current) =>
|
||||
arraysEqual(current, lastSavedSkillsRef.current) ? skillSnapshot.desiredSkills : current,
|
||||
);
|
||||
lastSavedSkillsRef.current = skillSnapshot.desiredSkills;
|
||||
setLastSavedSkills(skillSnapshot.desiredSkills);
|
||||
}, [skillSnapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!skillSnapshot) return;
|
||||
if (syncSkills.isPending) return;
|
||||
if (arraysEqual(skillDraft, lastSavedSkills)) return;
|
||||
|
||||
const timeout = window.setTimeout(() => {
|
||||
syncSkills.mutate(skillDraft);
|
||||
}, 250);
|
||||
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [lastSavedSkills, skillDraft, skillSnapshot, syncSkills.isPending, syncSkills.mutate]);
|
||||
|
||||
const companySkillBySlug = useMemo(
|
||||
() => new Map((companySkills ?? []).map((skill) => [skill.slug, skill])),
|
||||
[companySkills],
|
||||
@@ -1192,258 +1211,135 @@ function AgentSkillsTab({
|
||||
() => skillDraft.filter((slug) => !companySkillBySlug.has(slug)),
|
||||
[companySkillBySlug, skillDraft],
|
||||
);
|
||||
const externalEntries = (skillSnapshot?.entries ?? []).filter((entry) => entry.state === "external");
|
||||
|
||||
const modeCopy = useMemo(() => {
|
||||
if (!skillSnapshot) return "Loading skill state...";
|
||||
if (!skillSnapshot.supported) {
|
||||
return "This adapter does not implement direct skill sync yet. Paperclip can still store the desired skill set for this agent.";
|
||||
const skillApplicationLabel = useMemo(() => {
|
||||
switch (skillSnapshot?.mode) {
|
||||
case "persistent":
|
||||
return "Kept in the workspace";
|
||||
case "ephemeral":
|
||||
return "Applied when the agent runs";
|
||||
case "unsupported":
|
||||
return "Tracked only";
|
||||
default:
|
||||
return "Unknown";
|
||||
}
|
||||
if (skillSnapshot.mode === "persistent") {
|
||||
return "Selected skills are synchronized into the adapter's persistent skills home.";
|
||||
}
|
||||
if (skillSnapshot.mode === "ephemeral") {
|
||||
return "Selected skills are mounted for each run instead of being installed globally.";
|
||||
}
|
||||
return "This adapter reports skill state but does not define a persistent install model.";
|
||||
}, [skillSnapshot]);
|
||||
|
||||
const primaryActionLabel = !skillSnapshot || skillSnapshot.supported
|
||||
? "Sync skills"
|
||||
: "Save desired skills";
|
||||
}, [skillSnapshot?.mode]);
|
||||
const hasUnsavedChanges = !arraysEqual(skillDraft, lastSavedSkills);
|
||||
const saveStatusLabel = syncSkills.isPending
|
||||
? "Saving changes..."
|
||||
: hasUnsavedChanges
|
||||
? "Saving soon..."
|
||||
: "Changes save automatically";
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl space-y-6">
|
||||
<section className="overflow-hidden rounded-2xl border border-border bg-card">
|
||||
<div className="border-b border-border bg-card px-5 py-5">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="max-w-2xl">
|
||||
<div className="mb-2 inline-flex items-center gap-2 rounded-full border border-border/70 bg-background/70 px-3 py-1 text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Skills
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold tracking-tight">Attach reusable skills to {agent.name}.</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{modeCopy}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Link
|
||||
to="/skills"
|
||||
className="inline-flex items-center gap-1 rounded-md border border-border px-3 py-2 text-sm font-medium text-foreground no-underline transition-colors hover:bg-accent/40"
|
||||
>
|
||||
Open company library
|
||||
<ArrowLeft className="h-3.5 w-3.5 rotate-180" />
|
||||
</Link>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => queryClient.invalidateQueries({ queryKey: queryKeys.agents.skills(agent.id) })}
|
||||
disabled={isLoading}
|
||||
variant="outline"
|
||||
>
|
||||
Refresh state
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-w-4xl space-y-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Link
|
||||
to="/skills"
|
||||
className="text-sm font-medium text-foreground underline-offset-4 no-underline transition-colors hover:text-foreground/70 hover:underline"
|
||||
>
|
||||
View company library
|
||||
</Link>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{syncSkills.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
|
||||
<span>{saveStatusLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 px-5 py-5">
|
||||
{skillSnapshot?.warnings.length ? (
|
||||
<div className="space-y-1 rounded-xl border border-amber-300/60 bg-amber-50/60 px-4 py-3 text-sm text-amber-800 dark:border-amber-500/30 dark:bg-amber-950/20 dark:text-amber-200">
|
||||
{skillSnapshot.warnings.map((warning) => (
|
||||
<div key={warning}>{warning}</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{skillSnapshot?.warnings.length ? (
|
||||
<div className="space-y-1 rounded-xl border border-amber-300/60 bg-amber-50/60 px-4 py-3 text-sm text-amber-800 dark:border-amber-500/30 dark:bg-amber-950/20 dark:text-amber-200">
|
||||
{skillSnapshot.warnings.map((warning) => (
|
||||
<div key={warning}>{warning}</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isLoading ? (
|
||||
<PageSkeleton variant="list" />
|
||||
) : (
|
||||
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_20rem]">
|
||||
<div className="space-y-4">
|
||||
<section className="rounded-xl border border-border/70 bg-background px-4 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium">Company skills</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Attach skills from the company library by shortname.
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-border/70 px-2 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
{(companySkills ?? []).length} available
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{(companySkills ?? []).length === 0 ? (
|
||||
<div className="mt-4 rounded-lg border border-dashed border-border px-4 py-6 text-sm text-muted-foreground">
|
||||
Import skills into the company library first, then attach them here.
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 space-y-2">
|
||||
{(companySkills ?? []).map((skill) => {
|
||||
const checked = skillDraft.includes(skill.slug);
|
||||
const adapterEntry = adapterEntryByName.get(skill.slug);
|
||||
return (
|
||||
<label
|
||||
key={skill.id}
|
||||
className="flex items-start gap-3 rounded-xl border border-border/70 px-3 py-3 transition-colors hover:bg-accent/20"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(event) => {
|
||||
const next = event.target.checked
|
||||
? Array.from(new Set([...skillDraft, skill.slug]))
|
||||
: skillDraft.filter((value) => value !== skill.slug);
|
||||
setSkillDraft(next);
|
||||
setSkillDirty(true);
|
||||
}}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{skill.name}</span>
|
||||
<span className="rounded-full border border-border/70 px-2 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
{skill.slug}
|
||||
</span>
|
||||
</div>
|
||||
{skill.description && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{skill.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{adapterEntry?.state && (
|
||||
<span className="rounded-full border border-border px-2 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
{adapterEntry.state}
|
||||
</span>
|
||||
)}
|
||||
<Link
|
||||
to={`/skills/${skill.id}`}
|
||||
className="text-xs text-muted-foreground no-underline hover:text-foreground"
|
||||
>
|
||||
View skill
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{desiredOnlyMissingSkills.length > 0 && (
|
||||
<section className="rounded-xl border border-amber-300/60 bg-amber-50/60 px-4 py-4 dark:border-amber-500/30 dark:bg-amber-950/20">
|
||||
<h4 className="text-sm font-medium text-amber-900 dark:text-amber-100">
|
||||
Desired skills not found in the company library
|
||||
</h4>
|
||||
<div className="mt-3 space-y-2">
|
||||
{desiredOnlyMissingSkills.map((skillName) => {
|
||||
const adapterEntry = adapterEntryByName.get(skillName);
|
||||
return (
|
||||
<div key={skillName} className="flex items-center justify-between gap-3 rounded-lg border border-amber-300/50 bg-background/70 px-3 py-2 dark:border-amber-500/20">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{skillName}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
This skill is still requested for the agent, but it is not tracked in the company library.
|
||||
</div>
|
||||
</div>
|
||||
{adapterEntry?.state && (
|
||||
<span className="rounded-full border border-border px-2 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
{adapterEntry.state}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{isLoading ? (
|
||||
<PageSkeleton variant="list" />
|
||||
) : (
|
||||
<>
|
||||
<section className="border-y border-border">
|
||||
{(companySkills ?? []).length === 0 ? (
|
||||
<div className="px-3 py-6 text-sm text-muted-foreground">
|
||||
Import skills into the company library first, then attach them here.
|
||||
</div>
|
||||
) : (
|
||||
(companySkills ?? []).map((skill) => {
|
||||
const checked = skillDraft.includes(skill.slug);
|
||||
const adapterEntry = adapterEntryByName.get(skill.slug);
|
||||
return (
|
||||
<label
|
||||
key={skill.id}
|
||||
className="flex items-start gap-3 border-b border-border px-3 py-3 text-sm last:border-b-0 hover:bg-accent/20"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(event) => {
|
||||
const next = event.target.checked
|
||||
? Array.from(new Set([...skillDraft, skill.slug]))
|
||||
: skillDraft.filter((value) => value !== skill.slug);
|
||||
setSkillDraft(next);
|
||||
}}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="truncate font-medium">{skill.name}</span>
|
||||
<Link
|
||||
to={`/skills/${skill.id}`}
|
||||
className="shrink-0 text-xs text-muted-foreground no-underline hover:text-foreground"
|
||||
>
|
||||
View
|
||||
</Link>
|
||||
</div>
|
||||
{skill.description && (
|
||||
<MarkdownBody className="mt-1 text-xs text-muted-foreground prose-p:my-1 prose-ul:my-1 prose-ol:my-1 prose-li:my-0 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
|
||||
{skill.description}
|
||||
</MarkdownBody>
|
||||
)}
|
||||
{adapterEntry?.detail && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{adapterEntry.detail}</p>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="space-y-4">
|
||||
<section className="rounded-xl border border-border/70 bg-background px-4 py-4">
|
||||
<h4 className="text-sm font-medium">Adapter state</h4>
|
||||
<div className="mt-3 grid gap-2 text-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground">Adapter</span>
|
||||
<span className="font-medium">{agent.adapterType}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground">Sync mode</span>
|
||||
<span className="rounded-full border border-border px-2 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
{skillSnapshot?.mode ?? "unsupported"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground">Desired skills</span>
|
||||
<span>{skillDraft.length}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground">External skills</span>
|
||||
<span>{externalEntries.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => syncSkills.mutate(skillDraft)}
|
||||
disabled={syncSkills.isPending || !skillDirty}
|
||||
>
|
||||
{syncSkills.isPending ? "Saving..." : primaryActionLabel}
|
||||
</Button>
|
||||
{skillDirty && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSkillDraft(skillSnapshot?.desiredSkills ?? []);
|
||||
setSkillDirty(false);
|
||||
}}
|
||||
disabled={syncSkills.isPending}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{syncSkills.isError && (
|
||||
<p className="mt-3 text-xs text-destructive">
|
||||
{syncSkills.error instanceof Error ? syncSkills.error.message : "Failed to update skills"}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-border/70 bg-background px-4 py-4">
|
||||
<h4 className="text-sm font-medium">External skills</h4>
|
||||
{externalEntries.length === 0 ? (
|
||||
<p className="mt-3 text-sm text-muted-foreground">
|
||||
No external skills were discovered by the adapter.
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-3 space-y-2">
|
||||
{externalEntries.map((entry) => (
|
||||
<div key={entry.name} className="rounded-lg border border-border/70 px-3 py-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium">{entry.name}</span>
|
||||
<span className="rounded-full border border-border px-2 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
{entry.state}
|
||||
</span>
|
||||
</div>
|
||||
{entry.detail && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{entry.detail}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
{desiredOnlyMissingSkills.length > 0 && (
|
||||
<div className="rounded-xl border border-amber-300/60 bg-amber-50/60 px-4 py-3 text-sm text-amber-800 dark:border-amber-500/30 dark:bg-amber-950/20 dark:text-amber-200">
|
||||
<div className="font-medium">Requested skills missing from the company library</div>
|
||||
<div className="mt-1 text-xs">
|
||||
{desiredOnlyMissingSkills.join(", ")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="border-t border-border pt-4">
|
||||
<div className="grid gap-2 text-sm sm:grid-cols-2">
|
||||
<div className="flex items-center justify-between gap-3 border-b border-border/60 py-2">
|
||||
<span className="text-muted-foreground">Adapter</span>
|
||||
<span className="font-medium">{adapterLabels[agent.adapterType] ?? agent.adapterType}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 border-b border-border/60 py-2">
|
||||
<span className="text-muted-foreground">Skills applied</span>
|
||||
<span>{skillApplicationLabel}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 border-b border-border/60 py-2">
|
||||
<span className="text-muted-foreground">Selected skills</span>
|
||||
<span>{skillDraft.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{syncSkills.isError && (
|
||||
<p className="mt-3 text-xs text-destructive">
|
||||
{syncSkills.error instanceof Error ? syncSkills.error.message : "Failed to update skills"}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user