Add adapter skill sync for codex and claude
This commit is contained in:
parent
271c2b9018
commit
56a34a8f8a
22 changed files with 907 additions and 26 deletions
|
|
@ -12,6 +12,11 @@ export type {
|
||||||
AdapterEnvironmentTestStatus,
|
AdapterEnvironmentTestStatus,
|
||||||
AdapterEnvironmentTestResult,
|
AdapterEnvironmentTestResult,
|
||||||
AdapterEnvironmentTestContext,
|
AdapterEnvironmentTestContext,
|
||||||
|
AdapterSkillSyncMode,
|
||||||
|
AdapterSkillState,
|
||||||
|
AdapterSkillEntry,
|
||||||
|
AdapterSkillSnapshot,
|
||||||
|
AdapterSkillContext,
|
||||||
AdapterSessionCodec,
|
AdapterSessionCodec,
|
||||||
AdapterModel,
|
AdapterModel,
|
||||||
HireApprovedPayload,
|
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(
|
export async function ensurePaperclipSkillSymlink(
|
||||||
source: string,
|
source: string,
|
||||||
target: string,
|
target: string,
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,42 @@ export interface AdapterEnvironmentTestResult {
|
||||||
testedAt: string;
|
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 {
|
export interface AdapterEnvironmentTestContext {
|
||||||
companyId: string;
|
companyId: string;
|
||||||
adapterType: string;
|
adapterType: string;
|
||||||
|
|
@ -175,6 +211,8 @@ export interface ServerAdapterModule {
|
||||||
type: string;
|
type: string;
|
||||||
execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult>;
|
execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult>;
|
||||||
testEnvironment(ctx: AdapterEnvironmentTestContext): Promise<AdapterEnvironmentTestResult>;
|
testEnvironment(ctx: AdapterEnvironmentTestContext): Promise<AdapterEnvironmentTestResult>;
|
||||||
|
listSkills?: (ctx: AdapterSkillContext) => Promise<AdapterSkillSnapshot>;
|
||||||
|
syncSkills?: (ctx: AdapterSkillContext, desiredSkills: string[]) => Promise<AdapterSkillSnapshot>;
|
||||||
sessionCodec?: AdapterSessionCodec;
|
sessionCodec?: AdapterSessionCodec;
|
||||||
supportsLocalAgentJwt?: boolean;
|
supportsLocalAgentJwt?: boolean;
|
||||||
models?: AdapterModel[];
|
models?: AdapterModel[];
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
parseObject,
|
parseObject,
|
||||||
parseJson,
|
parseJson,
|
||||||
buildPaperclipEnv,
|
buildPaperclipEnv,
|
||||||
|
listPaperclipSkillEntries,
|
||||||
joinPromptSections,
|
joinPromptSections,
|
||||||
redactEnvForLogs,
|
redactEnvForLogs,
|
||||||
ensureAbsoluteDirectory,
|
ensureAbsoluteDirectory,
|
||||||
|
|
@ -27,40 +28,32 @@ import {
|
||||||
isClaudeMaxTurnsResult,
|
isClaudeMaxTurnsResult,
|
||||||
isClaudeUnknownSessionError,
|
isClaudeUnknownSessionError,
|
||||||
} from "./parse.js";
|
} from "./parse.js";
|
||||||
|
import { resolveClaudeDesiredSkillNames } from "./skills.js";
|
||||||
|
|
||||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
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
|
* Create a tmpdir with `.claude/skills/` containing symlinks to skills from
|
||||||
* the repo's `skills/` directory, so `--add-dir` makes Claude Code discover
|
* the repo's `skills/` directory, so `--add-dir` makes Claude Code discover
|
||||||
* them as proper registered skills.
|
* 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 tmp = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skills-"));
|
||||||
const target = path.join(tmp, ".claude", "skills");
|
const target = path.join(tmp, ".claude", "skills");
|
||||||
await fs.mkdir(target, { recursive: true });
|
await fs.mkdir(target, { recursive: true });
|
||||||
const skillsDir = await resolvePaperclipSkillsDir();
|
const availableEntries = await listPaperclipSkillEntries(__moduleDir);
|
||||||
if (!skillsDir) return tmp;
|
const desiredNames = new Set(
|
||||||
const entries = await fs.readdir(skillsDir, { withFileTypes: true });
|
resolveClaudeDesiredSkillNames(
|
||||||
for (const entry of entries) {
|
config,
|
||||||
if (entry.isDirectory()) {
|
availableEntries.map((entry) => entry.name),
|
||||||
await fs.symlink(
|
),
|
||||||
path.join(skillsDir, entry.name),
|
);
|
||||||
path.join(target, 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;
|
return tmp;
|
||||||
}
|
}
|
||||||
|
|
@ -337,7 +330,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
extraArgs,
|
extraArgs,
|
||||||
} = runtimeConfig;
|
} = runtimeConfig;
|
||||||
const billingType = resolveClaudeBillingType(env);
|
const billingType = resolveClaudeBillingType(env);
|
||||||
const skillsDir = await buildSkillsDir();
|
const skillsDir = await buildSkillsDir(config);
|
||||||
|
|
||||||
// When instructionsFilePath is configured, create a combined temp file that
|
// When instructionsFilePath is configured, create a combined temp file that
|
||||||
// includes both the file content and the path directive, so we only need
|
// includes both the file content and the path directive, so we only need
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
export { execute, runClaudeLogin } from "./execute.js";
|
export { execute, runClaudeLogin } from "./execute.js";
|
||||||
|
export { listClaudeSkills, syncClaudeSkills } from "./skills.js";
|
||||||
export { testEnvironment } from "./test.js";
|
export { testEnvironment } from "./test.js";
|
||||||
export {
|
export {
|
||||||
parseClaudeStreamJson,
|
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";
|
} from "@paperclipai/adapter-utils/server-utils";
|
||||||
import { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js";
|
import { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js";
|
||||||
import { pathExists, prepareWorktreeCodexHome, resolveCodexHomeDir } from "./codex-home.js";
|
import { pathExists, prepareWorktreeCodexHome, resolveCodexHomeDir } from "./codex-home.js";
|
||||||
|
import { resolveCodexDesiredSkillNames } from "./skills.js";
|
||||||
|
|
||||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const CODEX_ROLLOUT_NOISE_RE =
|
const CODEX_ROLLOUT_NOISE_RE =
|
||||||
|
|
@ -92,6 +93,7 @@ async function isLikelyPaperclipRuntimeSkillSource(candidate: string, skillName:
|
||||||
type EnsureCodexSkillsInjectedOptions = {
|
type EnsureCodexSkillsInjectedOptions = {
|
||||||
skillsHome?: string;
|
skillsHome?: string;
|
||||||
skillsEntries?: Awaited<ReturnType<typeof listPaperclipSkillEntries>>;
|
skillsEntries?: Awaited<ReturnType<typeof listPaperclipSkillEntries>>;
|
||||||
|
desiredSkillNames?: string[];
|
||||||
linkSkill?: (source: string, target: string) => Promise<void>;
|
linkSkill?: (source: string, target: string) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -99,7 +101,11 @@ export async function ensureCodexSkillsInjected(
|
||||||
onLog: AdapterExecutionContext["onLog"],
|
onLog: AdapterExecutionContext["onLog"],
|
||||||
options: EnsureCodexSkillsInjectedOptions = {},
|
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;
|
if (skillsEntries.length === 0) return;
|
||||||
|
|
||||||
const skillsHome = options.skillsHome ?? path.join(resolveCodexHomeDir(process.env), "skills");
|
const skillsHome = options.skillsHome ?? path.join(resolveCodexHomeDir(process.env), "skills");
|
||||||
|
|
@ -213,13 +219,22 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
typeof envConfig.CODEX_HOME === "string" && envConfig.CODEX_HOME.trim().length > 0
|
typeof envConfig.CODEX_HOME === "string" && envConfig.CODEX_HOME.trim().length > 0
|
||||||
? path.resolve(envConfig.CODEX_HOME.trim())
|
? path.resolve(envConfig.CODEX_HOME.trim())
|
||||||
: null;
|
: null;
|
||||||
|
const desiredSkillNames = resolveCodexDesiredSkillNames(
|
||||||
|
config,
|
||||||
|
(await listPaperclipSkillEntries(__moduleDir)).map((entry) => entry.name),
|
||||||
|
);
|
||||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||||
const preparedWorktreeCodexHome =
|
const preparedWorktreeCodexHome =
|
||||||
configuredCodexHome ? null : await prepareWorktreeCodexHome(process.env, onLog);
|
configuredCodexHome ? null : await prepareWorktreeCodexHome(process.env, onLog);
|
||||||
const effectiveCodexHome = configuredCodexHome ?? preparedWorktreeCodexHome;
|
const effectiveCodexHome = configuredCodexHome ?? preparedWorktreeCodexHome;
|
||||||
await ensureCodexSkillsInjected(
|
await ensureCodexSkillsInjected(
|
||||||
onLog,
|
onLog,
|
||||||
effectiveCodexHome ? { skillsHome: path.join(effectiveCodexHome, "skills") } : {},
|
effectiveCodexHome
|
||||||
|
? {
|
||||||
|
skillsHome: path.join(effectiveCodexHome, "skills"),
|
||||||
|
desiredSkillNames,
|
||||||
|
}
|
||||||
|
: { desiredSkillNames },
|
||||||
);
|
);
|
||||||
const hasExplicitApiKey =
|
const hasExplicitApiKey =
|
||||||
typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0;
|
typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
export { execute, ensureCodexSkillsInjected } from "./execute.js";
|
export { execute, ensureCodexSkillsInjected } from "./execute.js";
|
||||||
|
export { listCodexSkills, syncCodexSkills } from "./skills.js";
|
||||||
export { testEnvironment } from "./test.js";
|
export { testEnvironment } from "./test.js";
|
||||||
export { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js";
|
export { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js";
|
||||||
import type { AdapterSessionCodec } from "@paperclipai/adapter-utils";
|
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 {
|
export type {
|
||||||
Company,
|
Company,
|
||||||
|
AgentSkillSyncMode,
|
||||||
|
AgentSkillState,
|
||||||
|
AgentSkillEntry,
|
||||||
|
AgentSkillSnapshot,
|
||||||
|
AgentSkillSyncRequest,
|
||||||
Agent,
|
Agent,
|
||||||
AgentPermissions,
|
AgentPermissions,
|
||||||
AgentKeyCreated,
|
AgentKeyCreated,
|
||||||
|
|
@ -136,6 +141,12 @@ export {
|
||||||
updateCompanySchema,
|
updateCompanySchema,
|
||||||
type CreateCompany,
|
type CreateCompany,
|
||||||
type UpdateCompany,
|
type UpdateCompany,
|
||||||
|
agentSkillStateSchema,
|
||||||
|
agentSkillSyncModeSchema,
|
||||||
|
agentSkillEntrySchema,
|
||||||
|
agentSkillSnapshotSchema,
|
||||||
|
agentSkillSyncSchema,
|
||||||
|
type AgentSkillSync,
|
||||||
createAgentSchema,
|
createAgentSchema,
|
||||||
createAgentHireSchema,
|
createAgentHireSchema,
|
||||||
updateAgentSchema,
|
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 { Company } from "./company.js";
|
||||||
|
export type {
|
||||||
|
AgentSkillSyncMode,
|
||||||
|
AgentSkillState,
|
||||||
|
AgentSkillEntry,
|
||||||
|
AgentSkillSnapshot,
|
||||||
|
AgentSkillSyncRequest,
|
||||||
|
} from "./adapter-skills.js";
|
||||||
export type {
|
export type {
|
||||||
Agent,
|
Agent,
|
||||||
AgentPermissions,
|
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 CreateCompany,
|
||||||
type UpdateCompany,
|
type UpdateCompany,
|
||||||
} from "./company.js";
|
} from "./company.js";
|
||||||
|
export {
|
||||||
|
agentSkillStateSchema,
|
||||||
|
agentSkillSyncModeSchema,
|
||||||
|
agentSkillEntrySchema,
|
||||||
|
agentSkillSnapshotSchema,
|
||||||
|
agentSkillSyncSchema,
|
||||||
|
type AgentSkillSync,
|
||||||
|
} from "./adapter-skills.js";
|
||||||
export {
|
export {
|
||||||
portabilityIncludeSchema,
|
portabilityIncludeSchema,
|
||||||
portabilitySecretRequirementSchema,
|
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 type { ServerAdapterModule } from "./types.js";
|
||||||
import {
|
import {
|
||||||
execute as claudeExecute,
|
execute as claudeExecute,
|
||||||
|
listClaudeSkills,
|
||||||
|
syncClaudeSkills,
|
||||||
testEnvironment as claudeTestEnvironment,
|
testEnvironment as claudeTestEnvironment,
|
||||||
sessionCodec as claudeSessionCodec,
|
sessionCodec as claudeSessionCodec,
|
||||||
} from "@paperclipai/adapter-claude-local/server";
|
} from "@paperclipai/adapter-claude-local/server";
|
||||||
import { agentConfigurationDoc as claudeAgentConfigurationDoc, models as claudeModels } from "@paperclipai/adapter-claude-local";
|
import { agentConfigurationDoc as claudeAgentConfigurationDoc, models as claudeModels } from "@paperclipai/adapter-claude-local";
|
||||||
import {
|
import {
|
||||||
execute as codexExecute,
|
execute as codexExecute,
|
||||||
|
listCodexSkills,
|
||||||
|
syncCodexSkills,
|
||||||
testEnvironment as codexTestEnvironment,
|
testEnvironment as codexTestEnvironment,
|
||||||
sessionCodec as codexSessionCodec,
|
sessionCodec as codexSessionCodec,
|
||||||
} from "@paperclipai/adapter-codex-local/server";
|
} from "@paperclipai/adapter-codex-local/server";
|
||||||
|
|
@ -58,6 +62,8 @@ const claudeLocalAdapter: ServerAdapterModule = {
|
||||||
type: "claude_local",
|
type: "claude_local",
|
||||||
execute: claudeExecute,
|
execute: claudeExecute,
|
||||||
testEnvironment: claudeTestEnvironment,
|
testEnvironment: claudeTestEnvironment,
|
||||||
|
listSkills: listClaudeSkills,
|
||||||
|
syncSkills: syncClaudeSkills,
|
||||||
sessionCodec: claudeSessionCodec,
|
sessionCodec: claudeSessionCodec,
|
||||||
models: claudeModels,
|
models: claudeModels,
|
||||||
supportsLocalAgentJwt: true,
|
supportsLocalAgentJwt: true,
|
||||||
|
|
@ -68,6 +74,8 @@ const codexLocalAdapter: ServerAdapterModule = {
|
||||||
type: "codex_local",
|
type: "codex_local",
|
||||||
execute: codexExecute,
|
execute: codexExecute,
|
||||||
testEnvironment: codexTestEnvironment,
|
testEnvironment: codexTestEnvironment,
|
||||||
|
listSkills: listCodexSkills,
|
||||||
|
syncSkills: syncCodexSkills,
|
||||||
sessionCodec: codexSessionCodec,
|
sessionCodec: codexSessionCodec,
|
||||||
models: codexModels,
|
models: codexModels,
|
||||||
listModels: listCodexModels,
|
listModels: listCodexModels,
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,11 @@ export type {
|
||||||
AdapterEnvironmentTestStatus,
|
AdapterEnvironmentTestStatus,
|
||||||
AdapterEnvironmentTestResult,
|
AdapterEnvironmentTestResult,
|
||||||
AdapterEnvironmentTestContext,
|
AdapterEnvironmentTestContext,
|
||||||
|
AdapterSkillSyncMode,
|
||||||
|
AdapterSkillState,
|
||||||
|
AdapterSkillEntry,
|
||||||
|
AdapterSkillSnapshot,
|
||||||
|
AdapterSkillContext,
|
||||||
AdapterSessionCodec,
|
AdapterSessionCodec,
|
||||||
AdapterModel,
|
AdapterModel,
|
||||||
ServerAdapterModule,
|
ServerAdapterModule,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import type { Db } from "@paperclipai/db";
|
||||||
import { agents as agentsTable, companies, heartbeatRuns } from "@paperclipai/db";
|
import { agents as agentsTable, companies, heartbeatRuns } from "@paperclipai/db";
|
||||||
import { and, desc, eq, inArray, not, sql } from "drizzle-orm";
|
import { and, desc, eq, inArray, not, sql } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
|
agentSkillSyncSchema,
|
||||||
createAgentKeySchema,
|
createAgentKeySchema,
|
||||||
createAgentHireSchema,
|
createAgentHireSchema,
|
||||||
createAgentSchema,
|
createAgentSchema,
|
||||||
|
|
@ -12,12 +13,17 @@ import {
|
||||||
isUuidLike,
|
isUuidLike,
|
||||||
resetAgentSessionSchema,
|
resetAgentSessionSchema,
|
||||||
testAdapterEnvironmentSchema,
|
testAdapterEnvironmentSchema,
|
||||||
|
type AgentSkillSnapshot,
|
||||||
type InstanceSchedulerHeartbeatAgent,
|
type InstanceSchedulerHeartbeatAgent,
|
||||||
updateAgentPermissionsSchema,
|
updateAgentPermissionsSchema,
|
||||||
updateAgentInstructionsPathSchema,
|
updateAgentInstructionsPathSchema,
|
||||||
wakeAgentSchema,
|
wakeAgentSchema,
|
||||||
updateAgentSchema,
|
updateAgentSchema,
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
|
import {
|
||||||
|
readPaperclipSkillSyncPreference,
|
||||||
|
writePaperclipSkillSyncPreference,
|
||||||
|
} from "@paperclipai/adapter-utils/server-utils";
|
||||||
import { validate } from "../middleware/validate.js";
|
import { validate } from "../middleware/validate.js";
|
||||||
import {
|
import {
|
||||||
agentService,
|
agentService,
|
||||||
|
|
@ -334,6 +340,20 @@ export function agentRoutes(db: Db) {
|
||||||
return details;
|
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>>) {
|
function redactForRestrictedAgentView(agent: Awaited<ReturnType<typeof svc.getById>>) {
|
||||||
if (!agent) return null;
|
if (!agent) return null;
|
||||||
return {
|
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) => {
|
router.get("/companies/:companyId/agents", async (req, res) => {
|
||||||
const companyId = req.params.companyId as string;
|
const companyId = req.params.companyId as string;
|
||||||
assertCompanyAccess(req, companyId);
|
assertCompanyAccess(req, companyId);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type {
|
import type {
|
||||||
Agent,
|
Agent,
|
||||||
|
AgentSkillSnapshot,
|
||||||
AdapterEnvironmentTestResult,
|
AdapterEnvironmentTestResult,
|
||||||
AgentKeyCreated,
|
AgentKeyCreated,
|
||||||
AgentRuntimeState,
|
AgentRuntimeState,
|
||||||
|
|
@ -107,6 +108,10 @@ export const agentsApi = {
|
||||||
terminate: (id: string, companyId?: string) => api.post<Agent>(agentPath(id, companyId, "/terminate"), {}),
|
terminate: (id: string, companyId?: string) => api.post<Agent>(agentPath(id, companyId, "/terminate"), {}),
|
||||||
remove: (id: string, companyId?: string) => api.delete<{ ok: true }>(agentPath(id, companyId)),
|
remove: (id: string, companyId?: string) => api.delete<{ ok: true }>(agentPath(id, companyId)),
|
||||||
listKeys: (id: string, companyId?: string) => api.get<AgentKey[]>(agentPath(id, companyId, "/keys")),
|
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) =>
|
createKey: (id: string, name: string, companyId?: string) =>
|
||||||
api.post<AgentKeyCreated>(agentPath(id, companyId, "/keys"), { name }),
|
api.post<AgentKeyCreated>(agentPath(id, companyId, "/keys"), { name }),
|
||||||
revokeKey: (agentId: string, keyId: string, companyId?: string) =>
|
revokeKey: (agentId: string, keyId: string, companyId?: string) =>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ export const queryKeys = {
|
||||||
detail: (id: string) => ["agents", "detail", id] as const,
|
detail: (id: string) => ["agents", "detail", id] as const,
|
||||||
runtimeState: (id: string) => ["agents", "runtime-state", id] as const,
|
runtimeState: (id: string) => ["agents", "runtime-state", id] as const,
|
||||||
taskSessions: (id: string) => ["agents", "task-sessions", 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,
|
keys: (agentId: string) => ["agents", "keys", agentId] as const,
|
||||||
configRevisions: (agentId: string) => ["agents", "config-revisions", agentId] as const,
|
configRevisions: (agentId: string) => ["agents", "config-revisions", agentId] as const,
|
||||||
adapterModels: (companyId: string, adapterType: string) =>
|
adapterModels: (companyId: string, adapterType: string) =>
|
||||||
|
|
|
||||||
|
|
@ -1045,6 +1045,8 @@ function ConfigurationTab({
|
||||||
}) {
|
}) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [awaitingRefreshAfterSave, setAwaitingRefreshAfterSave] = useState(false);
|
const [awaitingRefreshAfterSave, setAwaitingRefreshAfterSave] = useState(false);
|
||||||
|
const [skillDraft, setSkillDraft] = useState<string[]>([]);
|
||||||
|
const [skillDirty, setSkillDirty] = useState(false);
|
||||||
const lastAgentRef = useRef(agent);
|
const lastAgentRef = useRef(agent);
|
||||||
|
|
||||||
const { data: adapterModels } = useQuery({
|
const { data: adapterModels } = useQuery({
|
||||||
|
|
@ -1056,6 +1058,12 @@ function ConfigurationTab({
|
||||||
enabled: Boolean(companyId),
|
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({
|
const updateAgent = useMutation({
|
||||||
mutationFn: (data: Record<string, unknown>) => agentsApi.update(agent.id, data, companyId),
|
mutationFn: (data: Record<string, unknown>) => agentsApi.update(agent.id, data, companyId),
|
||||||
onMutate: () => {
|
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(() => {
|
useEffect(() => {
|
||||||
if (awaitingRefreshAfterSave && agent !== lastAgentRef.current) {
|
if (awaitingRefreshAfterSave && agent !== lastAgentRef.current) {
|
||||||
setAwaitingRefreshAfterSave(false);
|
setAwaitingRefreshAfterSave(false);
|
||||||
|
|
@ -1078,6 +1097,12 @@ function ConfigurationTab({
|
||||||
lastAgentRef.current = agent;
|
lastAgentRef.current = agent;
|
||||||
}, [agent, awaitingRefreshAfterSave]);
|
}, [agent, awaitingRefreshAfterSave]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!skillSnapshot) return;
|
||||||
|
setSkillDraft(skillSnapshot.desiredSkills);
|
||||||
|
setSkillDirty(false);
|
||||||
|
}, [skillSnapshot]);
|
||||||
|
|
||||||
const isConfigSaving = updateAgent.isPending || awaitingRefreshAfterSave;
|
const isConfigSaving = updateAgent.isPending || awaitingRefreshAfterSave;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -1118,6 +1143,128 @@ function ConfigurationTab({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue