Add adapter skill sync for codex and claude
This commit is contained in:
@@ -12,6 +12,11 @@ export type {
|
||||
AdapterEnvironmentTestStatus,
|
||||
AdapterEnvironmentTestResult,
|
||||
AdapterEnvironmentTestContext,
|
||||
AdapterSkillSyncMode,
|
||||
AdapterSkillState,
|
||||
AdapterSkillEntry,
|
||||
AdapterSkillSnapshot,
|
||||
AdapterSkillContext,
|
||||
AdapterSessionCodec,
|
||||
AdapterModel,
|
||||
HireApprovedPayload,
|
||||
|
||||
@@ -330,6 +330,49 @@ export async function readPaperclipSkillMarkdown(
|
||||
}
|
||||
}
|
||||
|
||||
export function readPaperclipSkillSyncPreference(config: Record<string, unknown>): {
|
||||
explicit: boolean;
|
||||
desiredSkills: string[];
|
||||
} {
|
||||
const raw = config.paperclipSkillSync;
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
||||
return { explicit: false, desiredSkills: [] };
|
||||
}
|
||||
const syncConfig = raw as Record<string, unknown>;
|
||||
const desiredValues = syncConfig.desiredSkills;
|
||||
const desired = Array.isArray(desiredValues)
|
||||
? desiredValues
|
||||
.filter((value): value is string => typeof value === "string")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
return {
|
||||
explicit: Object.prototype.hasOwnProperty.call(raw, "desiredSkills"),
|
||||
desiredSkills: Array.from(new Set(desired)),
|
||||
};
|
||||
}
|
||||
|
||||
export function writePaperclipSkillSyncPreference(
|
||||
config: Record<string, unknown>,
|
||||
desiredSkills: string[],
|
||||
): Record<string, unknown> {
|
||||
const next = { ...config };
|
||||
const raw = next.paperclipSkillSync;
|
||||
const current =
|
||||
typeof raw === "object" && raw !== null && !Array.isArray(raw)
|
||||
? { ...(raw as Record<string, unknown>) }
|
||||
: {};
|
||||
current.desiredSkills = Array.from(
|
||||
new Set(
|
||||
desiredSkills
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
next.paperclipSkillSync = current;
|
||||
return next;
|
||||
}
|
||||
|
||||
export async function ensurePaperclipSkillSymlink(
|
||||
source: string,
|
||||
target: string,
|
||||
|
||||
@@ -138,6 +138,42 @@ export interface AdapterEnvironmentTestResult {
|
||||
testedAt: string;
|
||||
}
|
||||
|
||||
export type AdapterSkillSyncMode = "unsupported" | "persistent" | "ephemeral";
|
||||
|
||||
export type AdapterSkillState =
|
||||
| "available"
|
||||
| "configured"
|
||||
| "installed"
|
||||
| "missing"
|
||||
| "stale"
|
||||
| "external";
|
||||
|
||||
export interface AdapterSkillEntry {
|
||||
name: string;
|
||||
desired: boolean;
|
||||
managed: boolean;
|
||||
state: AdapterSkillState;
|
||||
sourcePath?: string | null;
|
||||
targetPath?: string | null;
|
||||
detail?: string | null;
|
||||
}
|
||||
|
||||
export interface AdapterSkillSnapshot {
|
||||
adapterType: string;
|
||||
supported: boolean;
|
||||
mode: AdapterSkillSyncMode;
|
||||
desiredSkills: string[];
|
||||
entries: AdapterSkillEntry[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface AdapterSkillContext {
|
||||
agentId: string;
|
||||
companyId: string;
|
||||
adapterType: string;
|
||||
config: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AdapterEnvironmentTestContext {
|
||||
companyId: string;
|
||||
adapterType: string;
|
||||
@@ -175,6 +211,8 @@ export interface ServerAdapterModule {
|
||||
type: string;
|
||||
execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult>;
|
||||
testEnvironment(ctx: AdapterEnvironmentTestContext): Promise<AdapterEnvironmentTestResult>;
|
||||
listSkills?: (ctx: AdapterSkillContext) => Promise<AdapterSkillSnapshot>;
|
||||
syncSkills?: (ctx: AdapterSkillContext, desiredSkills: string[]) => Promise<AdapterSkillSnapshot>;
|
||||
sessionCodec?: AdapterSessionCodec;
|
||||
supportsLocalAgentJwt?: boolean;
|
||||
models?: AdapterModel[];
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
parseObject,
|
||||
parseJson,
|
||||
buildPaperclipEnv,
|
||||
listPaperclipSkillEntries,
|
||||
joinPromptSections,
|
||||
redactEnvForLogs,
|
||||
ensureAbsoluteDirectory,
|
||||
@@ -27,40 +28,32 @@ import {
|
||||
isClaudeMaxTurnsResult,
|
||||
isClaudeUnknownSessionError,
|
||||
} from "./parse.js";
|
||||
import { resolveClaudeDesiredSkillNames } from "./skills.js";
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PAPERCLIP_SKILLS_CANDIDATES = [
|
||||
path.resolve(__moduleDir, "../../skills"), // published: <pkg>/dist/server/ -> <pkg>/skills/
|
||||
path.resolve(__moduleDir, "../../../../../skills"), // dev: src/server/ -> repo root/skills/
|
||||
];
|
||||
|
||||
async function resolvePaperclipSkillsDir(): Promise<string | null> {
|
||||
for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) {
|
||||
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
|
||||
if (isDir) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a tmpdir with `.claude/skills/` containing symlinks to skills from
|
||||
* the repo's `skills/` directory, so `--add-dir` makes Claude Code discover
|
||||
* them as proper registered skills.
|
||||
*/
|
||||
async function buildSkillsDir(): Promise<string> {
|
||||
async function buildSkillsDir(config: Record<string, unknown>): Promise<string> {
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skills-"));
|
||||
const target = path.join(tmp, ".claude", "skills");
|
||||
await fs.mkdir(target, { recursive: true });
|
||||
const skillsDir = await resolvePaperclipSkillsDir();
|
||||
if (!skillsDir) return tmp;
|
||||
const entries = await fs.readdir(skillsDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
await fs.symlink(
|
||||
path.join(skillsDir, entry.name),
|
||||
path.join(target, entry.name),
|
||||
);
|
||||
}
|
||||
const availableEntries = await listPaperclipSkillEntries(__moduleDir);
|
||||
const desiredNames = new Set(
|
||||
resolveClaudeDesiredSkillNames(
|
||||
config,
|
||||
availableEntries.map((entry) => entry.name),
|
||||
),
|
||||
);
|
||||
for (const entry of availableEntries) {
|
||||
if (!desiredNames.has(entry.name)) continue;
|
||||
await fs.symlink(
|
||||
entry.source,
|
||||
path.join(target, entry.name),
|
||||
);
|
||||
}
|
||||
return tmp;
|
||||
}
|
||||
@@ -337,7 +330,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
extraArgs,
|
||||
} = runtimeConfig;
|
||||
const billingType = resolveClaudeBillingType(env);
|
||||
const skillsDir = await buildSkillsDir();
|
||||
const skillsDir = await buildSkillsDir(config);
|
||||
|
||||
// When instructionsFilePath is configured, create a combined temp file that
|
||||
// includes both the file content and the path directive, so we only need
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { execute, runClaudeLogin } from "./execute.js";
|
||||
export { listClaudeSkills, syncClaudeSkills } from "./skills.js";
|
||||
export { testEnvironment } from "./test.js";
|
||||
export {
|
||||
parseClaudeStreamJson,
|
||||
|
||||
83
packages/adapters/claude-local/src/server/skills.ts
Normal file
83
packages/adapters/claude-local/src/server/skills.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type {
|
||||
AdapterSkillContext,
|
||||
AdapterSkillEntry,
|
||||
AdapterSkillSnapshot,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
listPaperclipSkillEntries,
|
||||
readPaperclipSkillSyncPreference,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function resolveDesiredSkillNames(config: Record<string, unknown>, availableSkillNames: string[]) {
|
||||
const preference = readPaperclipSkillSyncPreference(config);
|
||||
return preference.explicit ? preference.desiredSkills : availableSkillNames;
|
||||
}
|
||||
|
||||
async function buildClaudeSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
|
||||
const availableEntries = await listPaperclipSkillEntries(__moduleDir);
|
||||
const availableByName = new Map(availableEntries.map((entry) => [entry.name, entry]));
|
||||
const desiredSkills = resolveDesiredSkillNames(
|
||||
config,
|
||||
availableEntries.map((entry) => entry.name),
|
||||
);
|
||||
const desiredSet = new Set(desiredSkills);
|
||||
const entries: AdapterSkillEntry[] = availableEntries.map((entry) => ({
|
||||
name: entry.name,
|
||||
desired: desiredSet.has(entry.name),
|
||||
managed: true,
|
||||
state: desiredSet.has(entry.name) ? "configured" : "available",
|
||||
sourcePath: entry.source,
|
||||
targetPath: null,
|
||||
detail: desiredSet.has(entry.name)
|
||||
? "Will be mounted into the ephemeral Claude skill directory on the next run."
|
||||
: null,
|
||||
}));
|
||||
const warnings: string[] = [];
|
||||
|
||||
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: undefined,
|
||||
targetPath: undefined,
|
||||
detail: "Paperclip cannot find this skill in the local runtime skills directory.",
|
||||
});
|
||||
}
|
||||
|
||||
entries.sort((left, right) => left.name.localeCompare(right.name));
|
||||
|
||||
return {
|
||||
adapterType: "claude_local",
|
||||
supported: true,
|
||||
mode: "ephemeral",
|
||||
desiredSkills,
|
||||
entries,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listClaudeSkills(ctx: AdapterSkillContext): Promise<AdapterSkillSnapshot> {
|
||||
return buildClaudeSkillSnapshot(ctx.config);
|
||||
}
|
||||
|
||||
export async function syncClaudeSkills(
|
||||
ctx: AdapterSkillContext,
|
||||
_desiredSkills: string[],
|
||||
): Promise<AdapterSkillSnapshot> {
|
||||
return buildClaudeSkillSnapshot(ctx.config);
|
||||
}
|
||||
|
||||
export function resolveClaudeDesiredSkillNames(
|
||||
config: Record<string, unknown>,
|
||||
availableSkillNames: string[],
|
||||
) {
|
||||
return resolveDesiredSkillNames(config, availableSkillNames);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js";
|
||||
import { pathExists, prepareWorktreeCodexHome, resolveCodexHomeDir } from "./codex-home.js";
|
||||
import { resolveCodexDesiredSkillNames } from "./skills.js";
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CODEX_ROLLOUT_NOISE_RE =
|
||||
@@ -92,6 +93,7 @@ async function isLikelyPaperclipRuntimeSkillSource(candidate: string, skillName:
|
||||
type EnsureCodexSkillsInjectedOptions = {
|
||||
skillsHome?: string;
|
||||
skillsEntries?: Awaited<ReturnType<typeof listPaperclipSkillEntries>>;
|
||||
desiredSkillNames?: string[];
|
||||
linkSkill?: (source: string, target: string) => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -99,7 +101,11 @@ export async function ensureCodexSkillsInjected(
|
||||
onLog: AdapterExecutionContext["onLog"],
|
||||
options: EnsureCodexSkillsInjectedOptions = {},
|
||||
) {
|
||||
const skillsEntries = options.skillsEntries ?? await listPaperclipSkillEntries(__moduleDir);
|
||||
const allSkillsEntries = options.skillsEntries ?? await listPaperclipSkillEntries(__moduleDir);
|
||||
const desiredSkillNames =
|
||||
options.desiredSkillNames ?? allSkillsEntries.map((entry) => entry.name);
|
||||
const desiredSet = new Set(desiredSkillNames);
|
||||
const skillsEntries = allSkillsEntries.filter((entry) => desiredSet.has(entry.name));
|
||||
if (skillsEntries.length === 0) return;
|
||||
|
||||
const skillsHome = options.skillsHome ?? path.join(resolveCodexHomeDir(process.env), "skills");
|
||||
@@ -213,13 +219,22 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
typeof envConfig.CODEX_HOME === "string" && envConfig.CODEX_HOME.trim().length > 0
|
||||
? path.resolve(envConfig.CODEX_HOME.trim())
|
||||
: null;
|
||||
const desiredSkillNames = resolveCodexDesiredSkillNames(
|
||||
config,
|
||||
(await listPaperclipSkillEntries(__moduleDir)).map((entry) => entry.name),
|
||||
);
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
const preparedWorktreeCodexHome =
|
||||
configuredCodexHome ? null : await prepareWorktreeCodexHome(process.env, onLog);
|
||||
const effectiveCodexHome = configuredCodexHome ?? preparedWorktreeCodexHome;
|
||||
await ensureCodexSkillsInjected(
|
||||
onLog,
|
||||
effectiveCodexHome ? { skillsHome: path.join(effectiveCodexHome, "skills") } : {},
|
||||
effectiveCodexHome
|
||||
? {
|
||||
skillsHome: path.join(effectiveCodexHome, "skills"),
|
||||
desiredSkillNames,
|
||||
}
|
||||
: { desiredSkillNames },
|
||||
);
|
||||
const hasExplicitApiKey =
|
||||
typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { execute, ensureCodexSkillsInjected } from "./execute.js";
|
||||
export { listCodexSkills, syncCodexSkills } from "./skills.js";
|
||||
export { testEnvironment } from "./test.js";
|
||||
export { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js";
|
||||
import type { AdapterSessionCodec } from "@paperclipai/adapter-utils";
|
||||
|
||||
179
packages/adapters/codex-local/src/server/skills.ts
Normal file
179
packages/adapters/codex-local/src/server/skills.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import fs from "node:fs/promises";
|
||||
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";
|
||||
import { resolveCodexHomeDir } from "./codex-home.js";
|
||||
|
||||
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 resolveCodexSkillsHome(config: Record<string, unknown>) {
|
||||
const env =
|
||||
typeof config.env === "object" && config.env !== null && !Array.isArray(config.env)
|
||||
? (config.env as Record<string, unknown>)
|
||||
: {};
|
||||
const configuredCodexHome = asString(env.CODEX_HOME);
|
||||
const home = configuredCodexHome ? path.resolve(configuredCodexHome) : resolveCodexHomeDir(process.env);
|
||||
return path.join(home, "skills");
|
||||
}
|
||||
|
||||
function resolveDesiredSkillNames(config: Record<string, unknown>, availableSkillNames: string[]) {
|
||||
const preference = readPaperclipSkillSyncPreference(config);
|
||||
return preference.explicit ? preference.desiredSkills : availableSkillNames;
|
||||
}
|
||||
|
||||
async function readInstalledSkillTargets(skillsHome: string) {
|
||||
const entries = await fs.readdir(skillsHome, { withFileTypes: true }).catch(() => []);
|
||||
const out = new Map<string, { targetPath: string | null; kind: "symlink" | "directory" | "file" }>();
|
||||
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 buildCodexSkillSnapshot(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 = resolveCodexSkillsHome(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 Codex 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: "codex_local",
|
||||
supported: true,
|
||||
mode: "persistent",
|
||||
desiredSkills,
|
||||
entries,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listCodexSkills(ctx: AdapterSkillContext): Promise<AdapterSkillSnapshot> {
|
||||
return buildCodexSkillSnapshot(ctx.config);
|
||||
}
|
||||
|
||||
export async function syncCodexSkills(
|
||||
ctx: AdapterSkillContext,
|
||||
desiredSkills: string[],
|
||||
): Promise<AdapterSkillSnapshot> {
|
||||
const availableEntries = await listPaperclipSkillEntries(__moduleDir);
|
||||
const desiredSet = new Set(desiredSkills);
|
||||
const skillsHome = resolveCodexSkillsHome(ctx.config);
|
||||
await fs.mkdir(skillsHome, { recursive: true });
|
||||
const installed = await readInstalledSkillTargets(skillsHome);
|
||||
const availableByName = new Map(availableEntries.map((entry) => [entry.name, entry]));
|
||||
|
||||
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 buildCodexSkillSnapshot(ctx.config);
|
||||
}
|
||||
|
||||
export function resolveCodexDesiredSkillNames(
|
||||
config: Record<string, unknown>,
|
||||
availableSkillNames: string[],
|
||||
) {
|
||||
return resolveDesiredSkillNames(config, availableSkillNames);
|
||||
}
|
||||
@@ -65,6 +65,11 @@ export {
|
||||
|
||||
export type {
|
||||
Company,
|
||||
AgentSkillSyncMode,
|
||||
AgentSkillState,
|
||||
AgentSkillEntry,
|
||||
AgentSkillSnapshot,
|
||||
AgentSkillSyncRequest,
|
||||
Agent,
|
||||
AgentPermissions,
|
||||
AgentKeyCreated,
|
||||
@@ -136,6 +141,12 @@ export {
|
||||
updateCompanySchema,
|
||||
type CreateCompany,
|
||||
type UpdateCompany,
|
||||
agentSkillStateSchema,
|
||||
agentSkillSyncModeSchema,
|
||||
agentSkillEntrySchema,
|
||||
agentSkillSnapshotSchema,
|
||||
agentSkillSyncSchema,
|
||||
type AgentSkillSync,
|
||||
createAgentSchema,
|
||||
createAgentHireSchema,
|
||||
updateAgentSchema,
|
||||
|
||||
32
packages/shared/src/types/adapter-skills.ts
Normal file
32
packages/shared/src/types/adapter-skills.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export type AgentSkillSyncMode = "unsupported" | "persistent" | "ephemeral";
|
||||
|
||||
export type AgentSkillState =
|
||||
| "available"
|
||||
| "configured"
|
||||
| "installed"
|
||||
| "missing"
|
||||
| "stale"
|
||||
| "external";
|
||||
|
||||
export interface AgentSkillEntry {
|
||||
name: string;
|
||||
desired: boolean;
|
||||
managed: boolean;
|
||||
state: AgentSkillState;
|
||||
sourcePath?: string | null;
|
||||
targetPath?: string | null;
|
||||
detail?: string | null;
|
||||
}
|
||||
|
||||
export interface AgentSkillSnapshot {
|
||||
adapterType: string;
|
||||
supported: boolean;
|
||||
mode: AgentSkillSyncMode;
|
||||
desiredSkills: string[];
|
||||
entries: AgentSkillEntry[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface AgentSkillSyncRequest {
|
||||
desiredSkills: string[];
|
||||
}
|
||||
@@ -1,4 +1,11 @@
|
||||
export type { Company } from "./company.js";
|
||||
export type {
|
||||
AgentSkillSyncMode,
|
||||
AgentSkillState,
|
||||
AgentSkillEntry,
|
||||
AgentSkillSnapshot,
|
||||
AgentSkillSyncRequest,
|
||||
} from "./adapter-skills.js";
|
||||
export type {
|
||||
Agent,
|
||||
AgentPermissions,
|
||||
|
||||
41
packages/shared/src/validators/adapter-skills.ts
Normal file
41
packages/shared/src/validators/adapter-skills.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const agentSkillStateSchema = z.enum([
|
||||
"available",
|
||||
"configured",
|
||||
"installed",
|
||||
"missing",
|
||||
"stale",
|
||||
"external",
|
||||
]);
|
||||
|
||||
export const agentSkillSyncModeSchema = z.enum([
|
||||
"unsupported",
|
||||
"persistent",
|
||||
"ephemeral",
|
||||
]);
|
||||
|
||||
export const agentSkillEntrySchema = z.object({
|
||||
name: z.string().min(1),
|
||||
desired: z.boolean(),
|
||||
managed: z.boolean(),
|
||||
state: agentSkillStateSchema,
|
||||
sourcePath: z.string().nullable().optional(),
|
||||
targetPath: z.string().nullable().optional(),
|
||||
detail: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export const agentSkillSnapshotSchema = z.object({
|
||||
adapterType: z.string().min(1),
|
||||
supported: z.boolean(),
|
||||
mode: agentSkillSyncModeSchema,
|
||||
desiredSkills: z.array(z.string().min(1)),
|
||||
entries: z.array(agentSkillEntrySchema),
|
||||
warnings: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const agentSkillSyncSchema = z.object({
|
||||
desiredSkills: z.array(z.string().min(1)),
|
||||
});
|
||||
|
||||
export type AgentSkillSync = z.infer<typeof agentSkillSyncSchema>;
|
||||
@@ -4,6 +4,14 @@ export {
|
||||
type CreateCompany,
|
||||
type UpdateCompany,
|
||||
} from "./company.js";
|
||||
export {
|
||||
agentSkillStateSchema,
|
||||
agentSkillSyncModeSchema,
|
||||
agentSkillEntrySchema,
|
||||
agentSkillSnapshotSchema,
|
||||
agentSkillSyncSchema,
|
||||
type AgentSkillSync,
|
||||
} from "./adapter-skills.js";
|
||||
export {
|
||||
portabilityIncludeSchema,
|
||||
portabilitySecretRequirementSchema,
|
||||
|
||||
38
server/src/__tests__/claude-local-skill-sync.test.ts
Normal file
38
server/src/__tests__/claude-local-skill-sync.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
listClaudeSkills,
|
||||
syncClaudeSkills,
|
||||
} from "@paperclipai/adapter-claude-local/server";
|
||||
|
||||
describe("claude local skill sync", () => {
|
||||
it("defaults to mounting all built-in Paperclip skills when no explicit selection exists", async () => {
|
||||
const snapshot = await listClaudeSkills({
|
||||
agentId: "agent-1",
|
||||
companyId: "company-1",
|
||||
adapterType: "claude_local",
|
||||
config: {},
|
||||
});
|
||||
|
||||
expect(snapshot.mode).toBe("ephemeral");
|
||||
expect(snapshot.supported).toBe(true);
|
||||
expect(snapshot.desiredSkills).toContain("paperclip");
|
||||
expect(snapshot.entries.find((entry) => entry.name === "paperclip")?.state).toBe("configured");
|
||||
});
|
||||
|
||||
it("respects an explicit desired skill list without mutating a persistent home", async () => {
|
||||
const snapshot = await syncClaudeSkills({
|
||||
agentId: "agent-2",
|
||||
companyId: "company-1",
|
||||
adapterType: "claude_local",
|
||||
config: {
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: ["paperclip"],
|
||||
},
|
||||
},
|
||||
}, ["paperclip"]);
|
||||
|
||||
expect(snapshot.desiredSkills).toEqual(["paperclip"]);
|
||||
expect(snapshot.entries.find((entry) => entry.name === "paperclip")?.state).toBe("configured");
|
||||
expect(snapshot.entries.find((entry) => entry.name === "paperclip-create-agent")?.state).toBe("available");
|
||||
});
|
||||
});
|
||||
87
server/src/__tests__/codex-local-skill-sync.test.ts
Normal file
87
server/src/__tests__/codex-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 {
|
||||
listCodexSkills,
|
||||
syncCodexSkills,
|
||||
} from "@paperclipai/adapter-codex-local/server";
|
||||
|
||||
async function makeTempDir(prefix: string): Promise<string> {
|
||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
describe("codex 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 Codex skills home", async () => {
|
||||
const codexHome = await makeTempDir("paperclip-codex-skill-sync-");
|
||||
cleanupDirs.add(codexHome);
|
||||
|
||||
const ctx = {
|
||||
agentId: "agent-1",
|
||||
companyId: "company-1",
|
||||
adapterType: "codex_local",
|
||||
config: {
|
||||
env: {
|
||||
CODEX_HOME: codexHome,
|
||||
},
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: ["paperclip"],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const before = await listCodexSkills(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 syncCodexSkills(ctx, ["paperclip"]);
|
||||
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("installed");
|
||||
expect((await fs.lstat(path.join(codexHome, "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
it("removes stale managed Paperclip skills when the desired set is emptied", async () => {
|
||||
const codexHome = await makeTempDir("paperclip-codex-skill-prune-");
|
||||
cleanupDirs.add(codexHome);
|
||||
|
||||
const configuredCtx = {
|
||||
agentId: "agent-2",
|
||||
companyId: "company-1",
|
||||
adapterType: "codex_local",
|
||||
config: {
|
||||
env: {
|
||||
CODEX_HOME: codexHome,
|
||||
},
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: ["paperclip"],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
await syncCodexSkills(configuredCtx, ["paperclip"]);
|
||||
|
||||
const clearedCtx = {
|
||||
...configuredCtx,
|
||||
config: {
|
||||
env: {
|
||||
CODEX_HOME: codexHome,
|
||||
},
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: [],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const after = await syncCodexSkills(clearedCtx, []);
|
||||
expect(after.desiredSkills).toEqual([]);
|
||||
expect(after.entries.find((entry) => entry.name === "paperclip")?.state).toBe("available");
|
||||
await expect(fs.lstat(path.join(codexHome, "skills", "paperclip"))).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,16 @@
|
||||
import type { ServerAdapterModule } from "./types.js";
|
||||
import {
|
||||
execute as claudeExecute,
|
||||
listClaudeSkills,
|
||||
syncClaudeSkills,
|
||||
testEnvironment as claudeTestEnvironment,
|
||||
sessionCodec as claudeSessionCodec,
|
||||
} from "@paperclipai/adapter-claude-local/server";
|
||||
import { agentConfigurationDoc as claudeAgentConfigurationDoc, models as claudeModels } from "@paperclipai/adapter-claude-local";
|
||||
import {
|
||||
execute as codexExecute,
|
||||
listCodexSkills,
|
||||
syncCodexSkills,
|
||||
testEnvironment as codexTestEnvironment,
|
||||
sessionCodec as codexSessionCodec,
|
||||
} from "@paperclipai/adapter-codex-local/server";
|
||||
@@ -58,6 +62,8 @@ const claudeLocalAdapter: ServerAdapterModule = {
|
||||
type: "claude_local",
|
||||
execute: claudeExecute,
|
||||
testEnvironment: claudeTestEnvironment,
|
||||
listSkills: listClaudeSkills,
|
||||
syncSkills: syncClaudeSkills,
|
||||
sessionCodec: claudeSessionCodec,
|
||||
models: claudeModels,
|
||||
supportsLocalAgentJwt: true,
|
||||
@@ -68,6 +74,8 @@ const codexLocalAdapter: ServerAdapterModule = {
|
||||
type: "codex_local",
|
||||
execute: codexExecute,
|
||||
testEnvironment: codexTestEnvironment,
|
||||
listSkills: listCodexSkills,
|
||||
syncSkills: syncCodexSkills,
|
||||
sessionCodec: codexSessionCodec,
|
||||
models: codexModels,
|
||||
listModels: listCodexModels,
|
||||
|
||||
@@ -13,6 +13,11 @@ export type {
|
||||
AdapterEnvironmentTestStatus,
|
||||
AdapterEnvironmentTestResult,
|
||||
AdapterEnvironmentTestContext,
|
||||
AdapterSkillSyncMode,
|
||||
AdapterSkillState,
|
||||
AdapterSkillEntry,
|
||||
AdapterSkillSnapshot,
|
||||
AdapterSkillContext,
|
||||
AdapterSessionCodec,
|
||||
AdapterModel,
|
||||
ServerAdapterModule,
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Db } from "@paperclipai/db";
|
||||
import { agents as agentsTable, companies, heartbeatRuns } from "@paperclipai/db";
|
||||
import { and, desc, eq, inArray, not, sql } from "drizzle-orm";
|
||||
import {
|
||||
agentSkillSyncSchema,
|
||||
createAgentKeySchema,
|
||||
createAgentHireSchema,
|
||||
createAgentSchema,
|
||||
@@ -12,12 +13,17 @@ import {
|
||||
isUuidLike,
|
||||
resetAgentSessionSchema,
|
||||
testAdapterEnvironmentSchema,
|
||||
type AgentSkillSnapshot,
|
||||
type InstanceSchedulerHeartbeatAgent,
|
||||
updateAgentPermissionsSchema,
|
||||
updateAgentInstructionsPathSchema,
|
||||
wakeAgentSchema,
|
||||
updateAgentSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
readPaperclipSkillSyncPreference,
|
||||
writePaperclipSkillSyncPreference,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import {
|
||||
agentService,
|
||||
@@ -334,6 +340,20 @@ export function agentRoutes(db: Db) {
|
||||
return details;
|
||||
}
|
||||
|
||||
function buildUnsupportedSkillSnapshot(
|
||||
adapterType: string,
|
||||
desiredSkills: string[] = [],
|
||||
): AgentSkillSnapshot {
|
||||
return {
|
||||
adapterType,
|
||||
supported: false,
|
||||
mode: "unsupported",
|
||||
desiredSkills,
|
||||
entries: [],
|
||||
warnings: ["This adapter does not implement skill sync yet."],
|
||||
};
|
||||
}
|
||||
|
||||
function redactForRestrictedAgentView(agent: Awaited<ReturnType<typeof svc.getById>>) {
|
||||
if (!agent) return null;
|
||||
return {
|
||||
@@ -459,6 +479,119 @@ export function agentRoutes(db: Db) {
|
||||
},
|
||||
);
|
||||
|
||||
router.get("/agents/:id/skills", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const agent = await svc.getById(id);
|
||||
if (!agent) {
|
||||
res.status(404).json({ error: "Agent not found" });
|
||||
return;
|
||||
}
|
||||
await assertCanReadConfigurations(req, agent.companyId);
|
||||
|
||||
const adapter = findServerAdapter(agent.adapterType);
|
||||
if (!adapter?.listSkills) {
|
||||
const preference = readPaperclipSkillSyncPreference(
|
||||
agent.adapterConfig as Record<string, unknown>,
|
||||
);
|
||||
res.json(buildUnsupportedSkillSnapshot(agent.adapterType, preference.desiredSkills));
|
||||
return;
|
||||
}
|
||||
|
||||
const { config: runtimeConfig } = await secretsSvc.resolveAdapterConfigForRuntime(
|
||||
agent.companyId,
|
||||
agent.adapterConfig,
|
||||
);
|
||||
const snapshot = await adapter.listSkills({
|
||||
agentId: agent.id,
|
||||
companyId: agent.companyId,
|
||||
adapterType: agent.adapterType,
|
||||
config: runtimeConfig,
|
||||
});
|
||||
res.json(snapshot);
|
||||
});
|
||||
|
||||
router.post(
|
||||
"/agents/:id/skills/sync",
|
||||
validate(agentSkillSyncSchema),
|
||||
async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const agent = await svc.getById(id);
|
||||
if (!agent) {
|
||||
res.status(404).json({ error: "Agent not found" });
|
||||
return;
|
||||
}
|
||||
await assertCanUpdateAgent(req, agent);
|
||||
|
||||
const desiredSkills = Array.from(
|
||||
new Set(
|
||||
(req.body.desiredSkills as string[])
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
const nextAdapterConfig = writePaperclipSkillSyncPreference(
|
||||
agent.adapterConfig as Record<string, unknown>,
|
||||
desiredSkills,
|
||||
);
|
||||
const actor = getActorInfo(req);
|
||||
const updated = await svc.update(agent.id, {
|
||||
adapterConfig: nextAdapterConfig,
|
||||
}, {
|
||||
recordRevision: {
|
||||
createdByAgentId: actor.agentId,
|
||||
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
|
||||
source: "skill-sync",
|
||||
},
|
||||
});
|
||||
if (!updated) {
|
||||
res.status(404).json({ error: "Agent not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const adapter = findServerAdapter(updated.adapterType);
|
||||
const { config: runtimeConfig } = await secretsSvc.resolveAdapterConfigForRuntime(
|
||||
updated.companyId,
|
||||
updated.adapterConfig,
|
||||
);
|
||||
const snapshot = adapter?.syncSkills
|
||||
? await adapter.syncSkills({
|
||||
agentId: updated.id,
|
||||
companyId: updated.companyId,
|
||||
adapterType: updated.adapterType,
|
||||
config: runtimeConfig,
|
||||
}, desiredSkills)
|
||||
: adapter?.listSkills
|
||||
? await adapter.listSkills({
|
||||
agentId: updated.id,
|
||||
companyId: updated.companyId,
|
||||
adapterType: updated.adapterType,
|
||||
config: runtimeConfig,
|
||||
})
|
||||
: buildUnsupportedSkillSnapshot(updated.adapterType, desiredSkills);
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: updated.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
action: "agent.skills_synced",
|
||||
entityType: "agent",
|
||||
entityId: updated.id,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
details: {
|
||||
adapterType: updated.adapterType,
|
||||
desiredSkills,
|
||||
mode: snapshot.mode,
|
||||
supported: snapshot.supported,
|
||||
entryCount: snapshot.entries.length,
|
||||
warningCount: snapshot.warnings.length,
|
||||
},
|
||||
});
|
||||
|
||||
res.json(snapshot);
|
||||
},
|
||||
);
|
||||
|
||||
router.get("/companies/:companyId/agents", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
Agent,
|
||||
AgentSkillSnapshot,
|
||||
AdapterEnvironmentTestResult,
|
||||
AgentKeyCreated,
|
||||
AgentRuntimeState,
|
||||
@@ -107,6 +108,10 @@ export const agentsApi = {
|
||||
terminate: (id: string, companyId?: string) => api.post<Agent>(agentPath(id, companyId, "/terminate"), {}),
|
||||
remove: (id: string, companyId?: string) => api.delete<{ ok: true }>(agentPath(id, companyId)),
|
||||
listKeys: (id: string, companyId?: string) => api.get<AgentKey[]>(agentPath(id, companyId, "/keys")),
|
||||
skills: (id: string, companyId?: string) =>
|
||||
api.get<AgentSkillSnapshot>(agentPath(id, companyId, "/skills")),
|
||||
syncSkills: (id: string, desiredSkills: string[], companyId?: string) =>
|
||||
api.post<AgentSkillSnapshot>(agentPath(id, companyId, "/skills/sync"), { desiredSkills }),
|
||||
createKey: (id: string, name: string, companyId?: string) =>
|
||||
api.post<AgentKeyCreated>(agentPath(id, companyId, "/keys"), { name }),
|
||||
revokeKey: (agentId: string, keyId: string, companyId?: string) =>
|
||||
|
||||
@@ -9,6 +9,7 @@ export const queryKeys = {
|
||||
detail: (id: string) => ["agents", "detail", id] as const,
|
||||
runtimeState: (id: string) => ["agents", "runtime-state", id] as const,
|
||||
taskSessions: (id: string) => ["agents", "task-sessions", id] as const,
|
||||
skills: (id: string) => ["agents", "skills", id] as const,
|
||||
keys: (agentId: string) => ["agents", "keys", agentId] as const,
|
||||
configRevisions: (agentId: string) => ["agents", "config-revisions", agentId] as const,
|
||||
adapterModels: (companyId: string, adapterType: string) =>
|
||||
|
||||
@@ -1045,6 +1045,8 @@ function ConfigurationTab({
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [awaitingRefreshAfterSave, setAwaitingRefreshAfterSave] = useState(false);
|
||||
const [skillDraft, setSkillDraft] = useState<string[]>([]);
|
||||
const [skillDirty, setSkillDirty] = useState(false);
|
||||
const lastAgentRef = useRef(agent);
|
||||
|
||||
const { data: adapterModels } = useQuery({
|
||||
@@ -1056,6 +1058,12 @@ function ConfigurationTab({
|
||||
enabled: Boolean(companyId),
|
||||
});
|
||||
|
||||
const { data: skillSnapshot } = useQuery({
|
||||
queryKey: queryKeys.agents.skills(agent.id),
|
||||
queryFn: () => agentsApi.skills(agent.id, companyId),
|
||||
enabled: Boolean(companyId),
|
||||
});
|
||||
|
||||
const updateAgent = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) => agentsApi.update(agent.id, data, companyId),
|
||||
onMutate: () => {
|
||||
@@ -1071,6 +1079,17 @@ function ConfigurationTab({
|
||||
},
|
||||
});
|
||||
|
||||
const syncSkills = useMutation({
|
||||
mutationFn: (desiredSkills: string[]) => agentsApi.syncSkills(agent.id, desiredSkills, companyId),
|
||||
onSuccess: (snapshot) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.skills(agent.id) });
|
||||
setSkillDraft(snapshot.desiredSkills);
|
||||
setSkillDirty(false);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (awaitingRefreshAfterSave && agent !== lastAgentRef.current) {
|
||||
setAwaitingRefreshAfterSave(false);
|
||||
@@ -1078,6 +1097,12 @@ function ConfigurationTab({
|
||||
lastAgentRef.current = agent;
|
||||
}, [agent, awaitingRefreshAfterSave]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!skillSnapshot) return;
|
||||
setSkillDraft(skillSnapshot.desiredSkills);
|
||||
setSkillDirty(false);
|
||||
}, [skillSnapshot]);
|
||||
|
||||
const isConfigSaving = updateAgent.isPending || awaitingRefreshAfterSave;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1118,6 +1143,128 @@ function ConfigurationTab({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-3">Skills</h3>
|
||||
<div className="border border-border rounded-lg p-4 space-y-3">
|
||||
{!skillSnapshot ? (
|
||||
<p className="text-sm text-muted-foreground">Loading skill sync state…</p>
|
||||
) : !skillSnapshot.supported ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This adapter does not implement skill sync yet.
|
||||
</p>
|
||||
{skillSnapshot.warnings.map((warning) => (
|
||||
<p key={warning} className="text-xs text-muted-foreground">
|
||||
{warning}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{skillSnapshot.mode === "persistent"
|
||||
? "These skills are synced into the adapter's persistent skills home."
|
||||
: "These skills are mounted ephemerally for each Claude run."}
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
{skillSnapshot.entries
|
||||
.filter((entry) => entry.managed)
|
||||
.map((entry) => {
|
||||
const checked = skillDraft.includes(entry.name);
|
||||
return (
|
||||
<label
|
||||
key={entry.name}
|
||||
className="flex items-start gap-3 rounded-md border border-border/70 px-3 py-2"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => {
|
||||
const next = e.target.checked
|
||||
? Array.from(new Set([...skillDraft, entry.name]))
|
||||
: skillDraft.filter((value) => value !== entry.name);
|
||||
setSkillDraft(next);
|
||||
setSkillDirty(true);
|
||||
}}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<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>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{skillSnapshot.entries.some((entry) => entry.state === "external") && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
External skills
|
||||
</div>
|
||||
{skillSnapshot.entries
|
||||
.filter((entry) => entry.state === "external")
|
||||
.map((entry) => (
|
||||
<div key={entry.name} className="text-xs text-muted-foreground">
|
||||
{entry.name}
|
||||
{entry.detail ? ` - ${entry.detail}` : ""}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{skillSnapshot.warnings.length > 0 && (
|
||||
<div className="space-y-1 rounded-md border border-amber-300/60 bg-amber-50/60 px-3 py-2 text-xs text-amber-700">
|
||||
{skillSnapshot.warnings.map((warning) => (
|
||||
<div key={warning}>{warning}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{syncSkills.isError && (
|
||||
<p className="text-xs text-destructive">
|
||||
{syncSkills.error instanceof Error
|
||||
? syncSkills.error.message
|
||||
: "Failed to sync skills"}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => syncSkills.mutate(skillDraft)}
|
||||
disabled={syncSkills.isPending || !skillDirty}
|
||||
>
|
||||
{syncSkills.isPending ? "Syncing..." : "Sync skills"}
|
||||
</Button>
|
||||
{skillDirty && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSkillDraft(skillSnapshot.desiredSkills);
|
||||
setSkillDirty(false);
|
||||
}}
|
||||
disabled={syncSkills.isPending}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user