Add unmanaged skill provenance to agent skills
Expose adapter-discovered user-installed skills with provenance metadata, share persistent skill snapshot classification across local adapters, and render unmanaged skills as a read-only section in the agent skills UI. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
58d7f59477
commit
cfc53bf96b
19 changed files with 497 additions and 501 deletions
|
|
@ -14,6 +14,7 @@ export type {
|
||||||
AdapterEnvironmentTestContext,
|
AdapterEnvironmentTestContext,
|
||||||
AdapterSkillSyncMode,
|
AdapterSkillSyncMode,
|
||||||
AdapterSkillState,
|
AdapterSkillState,
|
||||||
|
AdapterSkillOrigin,
|
||||||
AdapterSkillEntry,
|
AdapterSkillEntry,
|
||||||
AdapterSkillSnapshot,
|
AdapterSkillSnapshot,
|
||||||
AdapterSkillContext,
|
AdapterSkillContext,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
import { spawn, type ChildProcess } from "node:child_process";
|
import { spawn, type ChildProcess } from "node:child_process";
|
||||||
import { constants as fsConstants, promises as fs } from "node:fs";
|
import { constants as fsConstants, promises as fs, type Dirent } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import type {
|
||||||
|
AdapterSkillEntry,
|
||||||
|
AdapterSkillSnapshot,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
export interface RunProcessResult {
|
export interface RunProcessResult {
|
||||||
exitCode: number | null;
|
exitCode: number | null;
|
||||||
|
|
@ -45,6 +49,25 @@ export interface PaperclipSkillEntry {
|
||||||
requiredReason?: string | null;
|
requiredReason?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface InstalledSkillTarget {
|
||||||
|
targetPath: string | null;
|
||||||
|
kind: "symlink" | "directory" | "file";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PersistentSkillSnapshotOptions {
|
||||||
|
adapterType: string;
|
||||||
|
availableEntries: PaperclipSkillEntry[];
|
||||||
|
desiredSkills: string[];
|
||||||
|
installed: Map<string, InstalledSkillTarget>;
|
||||||
|
skillsHome: string;
|
||||||
|
locationLabel?: string | null;
|
||||||
|
installedDetail?: string | null;
|
||||||
|
missingDetail: string;
|
||||||
|
externalConflictDetail: string;
|
||||||
|
externalDetail: string;
|
||||||
|
warnings?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
function normalizePathSlashes(value: string): string {
|
function normalizePathSlashes(value: string): string {
|
||||||
return value.replaceAll("\\", "/");
|
return value.replaceAll("\\", "/");
|
||||||
}
|
}
|
||||||
|
|
@ -53,6 +76,49 @@ function isMaintainerOnlySkillTarget(candidate: string): boolean {
|
||||||
return normalizePathSlashes(candidate).includes("/.agents/skills/");
|
return normalizePathSlashes(candidate).includes("/.agents/skills/");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function skillLocationLabel(value: string | null | undefined): string | null {
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildManagedSkillOrigin(entry: { required?: boolean }): Pick<
|
||||||
|
AdapterSkillEntry,
|
||||||
|
"origin" | "originLabel" | "readOnly"
|
||||||
|
> {
|
||||||
|
if (entry.required) {
|
||||||
|
return {
|
||||||
|
origin: "paperclip_required",
|
||||||
|
originLabel: "Required by Paperclip",
|
||||||
|
readOnly: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
origin: "company_managed",
|
||||||
|
originLabel: "Managed by Paperclip",
|
||||||
|
readOnly: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveInstalledEntryTarget(
|
||||||
|
skillsHome: string,
|
||||||
|
entryName: string,
|
||||||
|
dirent: Dirent,
|
||||||
|
linkedPath: string | null,
|
||||||
|
): InstalledSkillTarget {
|
||||||
|
const fullPath = path.join(skillsHome, entryName);
|
||||||
|
if (dirent.isSymbolicLink()) {
|
||||||
|
return {
|
||||||
|
targetPath: linkedPath ? path.resolve(path.dirname(fullPath), linkedPath) : null,
|
||||||
|
kind: "symlink",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (dirent.isDirectory()) {
|
||||||
|
return { targetPath: fullPath, kind: "directory" };
|
||||||
|
}
|
||||||
|
return { targetPath: fullPath, kind: "file" };
|
||||||
|
}
|
||||||
|
|
||||||
export function parseObject(value: unknown): Record<string, unknown> {
|
export function parseObject(value: unknown): Record<string, unknown> {
|
||||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||||
return {};
|
return {};
|
||||||
|
|
@ -318,6 +384,119 @@ export async function listPaperclipSkillEntries(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function readInstalledSkillTargets(skillsHome: string): Promise<Map<string, InstalledSkillTarget>> {
|
||||||
|
const entries = await fs.readdir(skillsHome, { withFileTypes: true }).catch(() => []);
|
||||||
|
const out = new Map<string, InstalledSkillTarget>();
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = path.join(skillsHome, entry.name);
|
||||||
|
const linkedPath = entry.isSymbolicLink() ? await fs.readlink(fullPath).catch(() => null) : null;
|
||||||
|
out.set(entry.name, resolveInstalledEntryTarget(skillsHome, entry.name, entry, linkedPath));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPersistentSkillSnapshot(
|
||||||
|
options: PersistentSkillSnapshotOptions,
|
||||||
|
): AdapterSkillSnapshot {
|
||||||
|
const {
|
||||||
|
adapterType,
|
||||||
|
availableEntries,
|
||||||
|
desiredSkills,
|
||||||
|
installed,
|
||||||
|
skillsHome,
|
||||||
|
locationLabel,
|
||||||
|
installedDetail,
|
||||||
|
missingDetail,
|
||||||
|
externalConflictDetail,
|
||||||
|
externalDetail,
|
||||||
|
} = options;
|
||||||
|
const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry]));
|
||||||
|
const desiredSet = new Set(desiredSkills);
|
||||||
|
const entries: AdapterSkillEntry[] = [];
|
||||||
|
const warnings = [...(options.warnings ?? [])];
|
||||||
|
|
||||||
|
for (const available of availableEntries) {
|
||||||
|
const installedEntry = installed.get(available.runtimeName) ?? null;
|
||||||
|
const desired = desiredSet.has(available.key);
|
||||||
|
let state: AdapterSkillEntry["state"] = "available";
|
||||||
|
let managed = false;
|
||||||
|
let detail: string | null = null;
|
||||||
|
|
||||||
|
if (installedEntry?.targetPath === available.source) {
|
||||||
|
managed = true;
|
||||||
|
state = desired ? "installed" : "stale";
|
||||||
|
detail = installedDetail ?? null;
|
||||||
|
} else if (installedEntry) {
|
||||||
|
state = "external";
|
||||||
|
detail = desired ? externalConflictDetail : externalDetail;
|
||||||
|
} else if (desired) {
|
||||||
|
state = "missing";
|
||||||
|
detail = missingDetail;
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.push({
|
||||||
|
key: available.key,
|
||||||
|
runtimeName: available.runtimeName,
|
||||||
|
desired,
|
||||||
|
managed,
|
||||||
|
state,
|
||||||
|
sourcePath: available.source,
|
||||||
|
targetPath: path.join(skillsHome, available.runtimeName),
|
||||||
|
detail,
|
||||||
|
required: Boolean(available.required),
|
||||||
|
requiredReason: available.requiredReason ?? null,
|
||||||
|
...buildManagedSkillOrigin(available),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const desiredSkill of desiredSkills) {
|
||||||
|
if (availableByKey.has(desiredSkill)) continue;
|
||||||
|
warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`);
|
||||||
|
entries.push({
|
||||||
|
key: desiredSkill,
|
||||||
|
runtimeName: null,
|
||||||
|
desired: true,
|
||||||
|
managed: true,
|
||||||
|
state: "missing",
|
||||||
|
sourcePath: null,
|
||||||
|
targetPath: null,
|
||||||
|
detail: "Paperclip cannot find this skill in the local runtime skills directory.",
|
||||||
|
origin: "external_unknown",
|
||||||
|
originLabel: "External or unavailable",
|
||||||
|
readOnly: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, installedEntry] of installed.entries()) {
|
||||||
|
if (availableEntries.some((entry) => entry.runtimeName === name)) continue;
|
||||||
|
entries.push({
|
||||||
|
key: name,
|
||||||
|
runtimeName: name,
|
||||||
|
desired: false,
|
||||||
|
managed: false,
|
||||||
|
state: "external",
|
||||||
|
origin: "user_installed",
|
||||||
|
originLabel: "User-installed",
|
||||||
|
locationLabel: skillLocationLabel(locationLabel),
|
||||||
|
readOnly: true,
|
||||||
|
sourcePath: null,
|
||||||
|
targetPath: installedEntry.targetPath ?? path.join(skillsHome, name),
|
||||||
|
detail: externalDetail,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.sort((left, right) => left.key.localeCompare(right.key));
|
||||||
|
|
||||||
|
return {
|
||||||
|
adapterType,
|
||||||
|
supported: true,
|
||||||
|
mode: "persistent",
|
||||||
|
desiredSkills,
|
||||||
|
entries,
|
||||||
|
warnings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeConfiguredPaperclipRuntimeSkills(value: unknown): PaperclipSkillEntry[] {
|
function normalizeConfiguredPaperclipRuntimeSkills(value: unknown): PaperclipSkillEntry[] {
|
||||||
if (!Array.isArray(value)) return [];
|
if (!Array.isArray(value)) return [];
|
||||||
const out: PaperclipSkillEntry[] = [];
|
const out: PaperclipSkillEntry[] = [];
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,12 @@ export type AdapterSkillState =
|
||||||
| "stale"
|
| "stale"
|
||||||
| "external";
|
| "external";
|
||||||
|
|
||||||
|
export type AdapterSkillOrigin =
|
||||||
|
| "company_managed"
|
||||||
|
| "paperclip_required"
|
||||||
|
| "user_installed"
|
||||||
|
| "external_unknown";
|
||||||
|
|
||||||
export interface AdapterSkillEntry {
|
export interface AdapterSkillEntry {
|
||||||
key: string;
|
key: string;
|
||||||
runtimeName: string | null;
|
runtimeName: string | null;
|
||||||
|
|
@ -165,6 +171,10 @@ export interface AdapterSkillEntry {
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
requiredReason?: string | null;
|
requiredReason?: string | null;
|
||||||
state: AdapterSkillState;
|
state: AdapterSkillState;
|
||||||
|
origin?: AdapterSkillOrigin;
|
||||||
|
originLabel?: string | null;
|
||||||
|
locationLabel?: string | null;
|
||||||
|
readOnly?: boolean;
|
||||||
sourcePath?: string | null;
|
sourcePath?: string | null;
|
||||||
targetPath?: string | null;
|
targetPath?: string | null;
|
||||||
detail?: string | null;
|
detail?: string | null;
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -7,22 +8,42 @@ import type {
|
||||||
} from "@paperclipai/adapter-utils";
|
} from "@paperclipai/adapter-utils";
|
||||||
import {
|
import {
|
||||||
readPaperclipRuntimeSkillEntries,
|
readPaperclipRuntimeSkillEntries,
|
||||||
|
readInstalledSkillTargets,
|
||||||
resolvePaperclipDesiredSkillNames,
|
resolvePaperclipDesiredSkillNames,
|
||||||
} from "@paperclipai/adapter-utils/server-utils";
|
} from "@paperclipai/adapter-utils/server-utils";
|
||||||
|
|
||||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
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 resolveClaudeSkillsHome(config: Record<string, unknown>) {
|
||||||
|
const env =
|
||||||
|
typeof config.env === "object" && config.env !== null && !Array.isArray(config.env)
|
||||||
|
? (config.env as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
const configuredHome = asString(env.HOME);
|
||||||
|
const home = configuredHome ? path.resolve(configuredHome) : os.homedir();
|
||||||
|
return path.join(home, ".claude", "skills");
|
||||||
|
}
|
||||||
|
|
||||||
async function buildClaudeSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
|
async function buildClaudeSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
|
||||||
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||||
const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry]));
|
const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry]));
|
||||||
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
|
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||||
const desiredSet = new Set(desiredSkills);
|
const desiredSet = new Set(desiredSkills);
|
||||||
|
const skillsHome = resolveClaudeSkillsHome(config);
|
||||||
|
const installed = await readInstalledSkillTargets(skillsHome);
|
||||||
const entries: AdapterSkillEntry[] = availableEntries.map((entry) => ({
|
const entries: AdapterSkillEntry[] = availableEntries.map((entry) => ({
|
||||||
key: entry.key,
|
key: entry.key,
|
||||||
runtimeName: entry.runtimeName,
|
runtimeName: entry.runtimeName,
|
||||||
desired: desiredSet.has(entry.key),
|
desired: desiredSet.has(entry.key),
|
||||||
managed: true,
|
managed: true,
|
||||||
state: desiredSet.has(entry.key) ? "configured" : "available",
|
state: desiredSet.has(entry.key) ? "configured" : "available",
|
||||||
|
origin: entry.required ? "paperclip_required" : "company_managed",
|
||||||
|
originLabel: entry.required ? "Required by Paperclip" : "Managed by Paperclip",
|
||||||
|
readOnly: false,
|
||||||
sourcePath: entry.source,
|
sourcePath: entry.source,
|
||||||
targetPath: null,
|
targetPath: null,
|
||||||
detail: desiredSet.has(entry.key)
|
detail: desiredSet.has(entry.key)
|
||||||
|
|
@ -42,12 +63,33 @@ async function buildClaudeSkillSnapshot(config: Record<string, unknown>): Promis
|
||||||
desired: true,
|
desired: true,
|
||||||
managed: true,
|
managed: true,
|
||||||
state: "missing",
|
state: "missing",
|
||||||
|
origin: "external_unknown",
|
||||||
|
originLabel: "External or unavailable",
|
||||||
|
readOnly: false,
|
||||||
sourcePath: undefined,
|
sourcePath: undefined,
|
||||||
targetPath: undefined,
|
targetPath: undefined,
|
||||||
detail: "Paperclip cannot find this skill in the local runtime skills directory.",
|
detail: "Paperclip cannot find this skill in the local runtime skills directory.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const [name, installedEntry] of installed.entries()) {
|
||||||
|
if (availableEntries.some((entry) => entry.runtimeName === name)) continue;
|
||||||
|
entries.push({
|
||||||
|
key: name,
|
||||||
|
runtimeName: name,
|
||||||
|
desired: false,
|
||||||
|
managed: false,
|
||||||
|
state: "external",
|
||||||
|
origin: "user_installed",
|
||||||
|
originLabel: "User-installed",
|
||||||
|
locationLabel: "~/.claude/skills",
|
||||||
|
readOnly: true,
|
||||||
|
sourcePath: null,
|
||||||
|
targetPath: installedEntry.targetPath ?? path.join(skillsHome, name),
|
||||||
|
detail: "Installed outside Paperclip management in the Claude skills home.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
entries.sort((left, right) => left.key.localeCompare(right.key));
|
entries.sort((left, right) => left.key.localeCompare(right.key));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,13 @@ import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import type {
|
import type {
|
||||||
AdapterSkillContext,
|
AdapterSkillContext,
|
||||||
AdapterSkillEntry,
|
|
||||||
AdapterSkillSnapshot,
|
AdapterSkillSnapshot,
|
||||||
} from "@paperclipai/adapter-utils";
|
} from "@paperclipai/adapter-utils";
|
||||||
import {
|
import {
|
||||||
|
buildPersistentSkillSnapshot,
|
||||||
ensurePaperclipSkillSymlink,
|
ensurePaperclipSkillSymlink,
|
||||||
readPaperclipRuntimeSkillEntries,
|
readPaperclipRuntimeSkillEntries,
|
||||||
|
readInstalledSkillTargets,
|
||||||
resolvePaperclipDesiredSkillNames,
|
resolvePaperclipDesiredSkillNames,
|
||||||
} from "@paperclipai/adapter-utils/server-utils";
|
} from "@paperclipai/adapter-utils/server-utils";
|
||||||
import { resolveCodexHomeDir } from "./codex-home.js";
|
import { resolveCodexHomeDir } from "./codex-home.js";
|
||||||
|
|
@ -29,111 +30,22 @@ function resolveCodexSkillsHome(config: Record<string, unknown>) {
|
||||||
return path.join(home, "skills");
|
return path.join(home, "skills");
|
||||||
}
|
}
|
||||||
|
|
||||||
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> {
|
async function buildCodexSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
|
||||||
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||||
const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry]));
|
|
||||||
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
|
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||||
const desiredSet = new Set(desiredSkills);
|
|
||||||
const skillsHome = resolveCodexSkillsHome(config);
|
const skillsHome = resolveCodexSkillsHome(config);
|
||||||
const installed = await readInstalledSkillTargets(skillsHome);
|
const installed = await readInstalledSkillTargets(skillsHome);
|
||||||
const entries: AdapterSkillEntry[] = [];
|
return buildPersistentSkillSnapshot({
|
||||||
const warnings: string[] = [];
|
|
||||||
|
|
||||||
for (const available of availableEntries) {
|
|
||||||
const installedEntry = installed.get(available.runtimeName) ?? null;
|
|
||||||
const desired = desiredSet.has(available.key);
|
|
||||||
let state: AdapterSkillEntry["state"] = "available";
|
|
||||||
let managed = false;
|
|
||||||
let detail: string | null = null;
|
|
||||||
|
|
||||||
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({
|
|
||||||
key: available.key,
|
|
||||||
runtimeName: available.runtimeName,
|
|
||||||
desired,
|
|
||||||
managed,
|
|
||||||
state,
|
|
||||||
sourcePath: available.source,
|
|
||||||
targetPath: path.join(skillsHome, available.runtimeName),
|
|
||||||
detail,
|
|
||||||
required: Boolean(available.required),
|
|
||||||
requiredReason: available.requiredReason ?? null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const desiredSkill of desiredSkills) {
|
|
||||||
if (availableByKey.has(desiredSkill)) continue;
|
|
||||||
warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`);
|
|
||||||
entries.push({
|
|
||||||
key: desiredSkill,
|
|
||||||
runtimeName: null,
|
|
||||||
desired: true,
|
|
||||||
managed: true,
|
|
||||||
state: "missing",
|
|
||||||
sourcePath: null,
|
|
||||||
targetPath: null,
|
|
||||||
detail: "Paperclip cannot find this skill in the local runtime skills directory.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [name, installedEntry] of installed.entries()) {
|
|
||||||
if (availableEntries.some((entry) => entry.runtimeName === name)) continue;
|
|
||||||
entries.push({
|
|
||||||
key: name,
|
|
||||||
runtimeName: 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.key.localeCompare(right.key));
|
|
||||||
|
|
||||||
return {
|
|
||||||
adapterType: "codex_local",
|
adapterType: "codex_local",
|
||||||
supported: true,
|
availableEntries,
|
||||||
mode: "persistent",
|
|
||||||
desiredSkills,
|
desiredSkills,
|
||||||
entries,
|
installed,
|
||||||
warnings,
|
skillsHome,
|
||||||
};
|
locationLabel: "$CODEX_HOME/skills",
|
||||||
|
missingDetail: "Configured but not currently linked into the Codex skills home.",
|
||||||
|
externalConflictDetail: "Skill name is occupied by an external installation.",
|
||||||
|
externalDetail: "Installed outside Paperclip management.",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listCodexSkills(ctx: AdapterSkillContext): Promise<AdapterSkillSnapshot> {
|
export async function listCodexSkills(ctx: AdapterSkillContext): Promise<AdapterSkillSnapshot> {
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,13 @@ import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import type {
|
import type {
|
||||||
AdapterSkillContext,
|
AdapterSkillContext,
|
||||||
AdapterSkillEntry,
|
|
||||||
AdapterSkillSnapshot,
|
AdapterSkillSnapshot,
|
||||||
} from "@paperclipai/adapter-utils";
|
} from "@paperclipai/adapter-utils";
|
||||||
import {
|
import {
|
||||||
|
buildPersistentSkillSnapshot,
|
||||||
ensurePaperclipSkillSymlink,
|
ensurePaperclipSkillSymlink,
|
||||||
readPaperclipRuntimeSkillEntries,
|
readPaperclipRuntimeSkillEntries,
|
||||||
|
readInstalledSkillTargets,
|
||||||
resolvePaperclipDesiredSkillNames,
|
resolvePaperclipDesiredSkillNames,
|
||||||
} from "@paperclipai/adapter-utils/server-utils";
|
} from "@paperclipai/adapter-utils/server-utils";
|
||||||
|
|
||||||
|
|
@ -29,111 +30,22 @@ function resolveCursorSkillsHome(config: Record<string, unknown>) {
|
||||||
return path.join(home, ".cursor", "skills");
|
return path.join(home, ".cursor", "skills");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readInstalledSkillTargets(skillsHome: string) {
|
|
||||||
const entries = await fs.readdir(skillsHome, { withFileTypes: true }).catch(() => []);
|
|
||||||
const out = new Map<string, { targetPath: string | null; kind: "symlink" | "directory" | "file" }>();
|
|
||||||
for (const entry of entries) {
|
|
||||||
const fullPath = path.join(skillsHome, entry.name);
|
|
||||||
if (entry.isSymbolicLink()) {
|
|
||||||
const linkedPath = await fs.readlink(fullPath).catch(() => null);
|
|
||||||
out.set(entry.name, {
|
|
||||||
targetPath: linkedPath ? path.resolve(path.dirname(fullPath), linkedPath) : null,
|
|
||||||
kind: "symlink",
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
out.set(entry.name, { targetPath: fullPath, kind: "directory" });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
out.set(entry.name, { targetPath: fullPath, kind: "file" });
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function buildCursorSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
|
async function buildCursorSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
|
||||||
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||||
const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry]));
|
|
||||||
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
|
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||||
const desiredSet = new Set(desiredSkills);
|
|
||||||
const skillsHome = resolveCursorSkillsHome(config);
|
const skillsHome = resolveCursorSkillsHome(config);
|
||||||
const installed = await readInstalledSkillTargets(skillsHome);
|
const installed = await readInstalledSkillTargets(skillsHome);
|
||||||
const entries: AdapterSkillEntry[] = [];
|
return buildPersistentSkillSnapshot({
|
||||||
const warnings: string[] = [];
|
|
||||||
|
|
||||||
for (const available of availableEntries) {
|
|
||||||
const installedEntry = installed.get(available.runtimeName) ?? null;
|
|
||||||
const desired = desiredSet.has(available.key);
|
|
||||||
let state: AdapterSkillEntry["state"] = "available";
|
|
||||||
let managed = false;
|
|
||||||
let detail: string | null = null;
|
|
||||||
|
|
||||||
if (installedEntry?.targetPath === available.source) {
|
|
||||||
managed = true;
|
|
||||||
state = desired ? "installed" : "stale";
|
|
||||||
} else if (installedEntry) {
|
|
||||||
state = "external";
|
|
||||||
detail = desired
|
|
||||||
? "Skill name is occupied by an external installation."
|
|
||||||
: "Installed outside Paperclip management.";
|
|
||||||
} else if (desired) {
|
|
||||||
state = "missing";
|
|
||||||
detail = "Configured but not currently linked into the Cursor skills home.";
|
|
||||||
}
|
|
||||||
|
|
||||||
entries.push({
|
|
||||||
key: available.key,
|
|
||||||
runtimeName: available.runtimeName,
|
|
||||||
desired,
|
|
||||||
managed,
|
|
||||||
state,
|
|
||||||
sourcePath: available.source,
|
|
||||||
targetPath: path.join(skillsHome, available.runtimeName),
|
|
||||||
detail,
|
|
||||||
required: Boolean(available.required),
|
|
||||||
requiredReason: available.requiredReason ?? null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const desiredSkill of desiredSkills) {
|
|
||||||
if (availableByKey.has(desiredSkill)) continue;
|
|
||||||
warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`);
|
|
||||||
entries.push({
|
|
||||||
key: desiredSkill,
|
|
||||||
runtimeName: null,
|
|
||||||
desired: true,
|
|
||||||
managed: true,
|
|
||||||
state: "missing",
|
|
||||||
sourcePath: null,
|
|
||||||
targetPath: null,
|
|
||||||
detail: "Paperclip cannot find this skill in the local runtime skills directory.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [name, installedEntry] of installed.entries()) {
|
|
||||||
if (availableEntries.some((entry) => entry.runtimeName === name)) continue;
|
|
||||||
entries.push({
|
|
||||||
key: name,
|
|
||||||
runtimeName: 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.key.localeCompare(right.key));
|
|
||||||
|
|
||||||
return {
|
|
||||||
adapterType: "cursor",
|
adapterType: "cursor",
|
||||||
supported: true,
|
availableEntries,
|
||||||
mode: "persistent",
|
|
||||||
desiredSkills,
|
desiredSkills,
|
||||||
entries,
|
installed,
|
||||||
warnings,
|
skillsHome,
|
||||||
};
|
locationLabel: "~/.cursor/skills",
|
||||||
|
missingDetail: "Configured but not currently linked into the Cursor skills home.",
|
||||||
|
externalConflictDetail: "Skill name is occupied by an external installation.",
|
||||||
|
externalDetail: "Installed outside Paperclip management.",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listCursorSkills(ctx: AdapterSkillContext): Promise<AdapterSkillSnapshot> {
|
export async function listCursorSkills(ctx: AdapterSkillContext): Promise<AdapterSkillSnapshot> {
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,13 @@ import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import type {
|
import type {
|
||||||
AdapterSkillContext,
|
AdapterSkillContext,
|
||||||
AdapterSkillEntry,
|
|
||||||
AdapterSkillSnapshot,
|
AdapterSkillSnapshot,
|
||||||
} from "@paperclipai/adapter-utils";
|
} from "@paperclipai/adapter-utils";
|
||||||
import {
|
import {
|
||||||
|
buildPersistentSkillSnapshot,
|
||||||
ensurePaperclipSkillSymlink,
|
ensurePaperclipSkillSymlink,
|
||||||
readPaperclipRuntimeSkillEntries,
|
readPaperclipRuntimeSkillEntries,
|
||||||
|
readInstalledSkillTargets,
|
||||||
resolvePaperclipDesiredSkillNames,
|
resolvePaperclipDesiredSkillNames,
|
||||||
} from "@paperclipai/adapter-utils/server-utils";
|
} from "@paperclipai/adapter-utils/server-utils";
|
||||||
|
|
||||||
|
|
@ -29,111 +30,22 @@ function resolveGeminiSkillsHome(config: Record<string, unknown>) {
|
||||||
return path.join(home, ".gemini", "skills");
|
return path.join(home, ".gemini", "skills");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readInstalledSkillTargets(skillsHome: string) {
|
|
||||||
const entries = await fs.readdir(skillsHome, { withFileTypes: true }).catch(() => []);
|
|
||||||
const out = new Map<string, { targetPath: string | null; kind: "symlink" | "directory" | "file" }>();
|
|
||||||
for (const entry of entries) {
|
|
||||||
const fullPath = path.join(skillsHome, entry.name);
|
|
||||||
if (entry.isSymbolicLink()) {
|
|
||||||
const linkedPath = await fs.readlink(fullPath).catch(() => null);
|
|
||||||
out.set(entry.name, {
|
|
||||||
targetPath: linkedPath ? path.resolve(path.dirname(fullPath), linkedPath) : null,
|
|
||||||
kind: "symlink",
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
out.set(entry.name, { targetPath: fullPath, kind: "directory" });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
out.set(entry.name, { targetPath: fullPath, kind: "file" });
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function buildGeminiSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
|
async function buildGeminiSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
|
||||||
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||||
const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry]));
|
|
||||||
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
|
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||||
const desiredSet = new Set(desiredSkills);
|
|
||||||
const skillsHome = resolveGeminiSkillsHome(config);
|
const skillsHome = resolveGeminiSkillsHome(config);
|
||||||
const installed = await readInstalledSkillTargets(skillsHome);
|
const installed = await readInstalledSkillTargets(skillsHome);
|
||||||
const entries: AdapterSkillEntry[] = [];
|
return buildPersistentSkillSnapshot({
|
||||||
const warnings: string[] = [];
|
|
||||||
|
|
||||||
for (const available of availableEntries) {
|
|
||||||
const installedEntry = installed.get(available.runtimeName) ?? null;
|
|
||||||
const desired = desiredSet.has(available.key);
|
|
||||||
let state: AdapterSkillEntry["state"] = "available";
|
|
||||||
let managed = false;
|
|
||||||
let detail: string | null = null;
|
|
||||||
|
|
||||||
if (installedEntry?.targetPath === available.source) {
|
|
||||||
managed = true;
|
|
||||||
state = desired ? "installed" : "stale";
|
|
||||||
} else if (installedEntry) {
|
|
||||||
state = "external";
|
|
||||||
detail = desired
|
|
||||||
? "Skill name is occupied by an external installation."
|
|
||||||
: "Installed outside Paperclip management.";
|
|
||||||
} else if (desired) {
|
|
||||||
state = "missing";
|
|
||||||
detail = "Configured but not currently linked into the Gemini skills home.";
|
|
||||||
}
|
|
||||||
|
|
||||||
entries.push({
|
|
||||||
key: available.key,
|
|
||||||
runtimeName: available.runtimeName,
|
|
||||||
desired,
|
|
||||||
managed,
|
|
||||||
state,
|
|
||||||
sourcePath: available.source,
|
|
||||||
targetPath: path.join(skillsHome, available.runtimeName),
|
|
||||||
detail,
|
|
||||||
required: Boolean(available.required),
|
|
||||||
requiredReason: available.requiredReason ?? null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const desiredSkill of desiredSkills) {
|
|
||||||
if (availableByKey.has(desiredSkill)) continue;
|
|
||||||
warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`);
|
|
||||||
entries.push({
|
|
||||||
key: desiredSkill,
|
|
||||||
runtimeName: null,
|
|
||||||
desired: true,
|
|
||||||
managed: true,
|
|
||||||
state: "missing",
|
|
||||||
sourcePath: null,
|
|
||||||
targetPath: null,
|
|
||||||
detail: "Paperclip cannot find this skill in the local runtime skills directory.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [name, installedEntry] of installed.entries()) {
|
|
||||||
if (availableEntries.some((entry) => entry.runtimeName === name)) continue;
|
|
||||||
entries.push({
|
|
||||||
key: name,
|
|
||||||
runtimeName: 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.key.localeCompare(right.key));
|
|
||||||
|
|
||||||
return {
|
|
||||||
adapterType: "gemini_local",
|
adapterType: "gemini_local",
|
||||||
supported: true,
|
availableEntries,
|
||||||
mode: "persistent",
|
|
||||||
desiredSkills,
|
desiredSkills,
|
||||||
entries,
|
installed,
|
||||||
warnings,
|
skillsHome,
|
||||||
};
|
locationLabel: "~/.gemini/skills",
|
||||||
|
missingDetail: "Configured but not currently linked into the Gemini skills home.",
|
||||||
|
externalConflictDetail: "Skill name is occupied by an external installation.",
|
||||||
|
externalDetail: "Installed outside Paperclip management.",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listGeminiSkills(ctx: AdapterSkillContext): Promise<AdapterSkillSnapshot> {
|
export async function listGeminiSkills(ctx: AdapterSkillContext): Promise<AdapterSkillSnapshot> {
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,13 @@ import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import type {
|
import type {
|
||||||
AdapterSkillContext,
|
AdapterSkillContext,
|
||||||
AdapterSkillEntry,
|
|
||||||
AdapterSkillSnapshot,
|
AdapterSkillSnapshot,
|
||||||
} from "@paperclipai/adapter-utils";
|
} from "@paperclipai/adapter-utils";
|
||||||
import {
|
import {
|
||||||
|
buildPersistentSkillSnapshot,
|
||||||
ensurePaperclipSkillSymlink,
|
ensurePaperclipSkillSymlink,
|
||||||
readPaperclipRuntimeSkillEntries,
|
readPaperclipRuntimeSkillEntries,
|
||||||
|
readInstalledSkillTargets,
|
||||||
resolvePaperclipDesiredSkillNames,
|
resolvePaperclipDesiredSkillNames,
|
||||||
} from "@paperclipai/adapter-utils/server-utils";
|
} from "@paperclipai/adapter-utils/server-utils";
|
||||||
|
|
||||||
|
|
@ -29,114 +30,26 @@ function resolveOpenCodeSkillsHome(config: Record<string, unknown>) {
|
||||||
return path.join(home, ".claude", "skills");
|
return path.join(home, ".claude", "skills");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readInstalledSkillTargets(skillsHome: string) {
|
|
||||||
const entries = await fs.readdir(skillsHome, { withFileTypes: true }).catch(() => []);
|
|
||||||
const out = new Map<string, { targetPath: string | null; kind: "symlink" | "directory" | "file" }>();
|
|
||||||
for (const entry of entries) {
|
|
||||||
const fullPath = path.join(skillsHome, entry.name);
|
|
||||||
if (entry.isSymbolicLink()) {
|
|
||||||
const linkedPath = await fs.readlink(fullPath).catch(() => null);
|
|
||||||
out.set(entry.name, {
|
|
||||||
targetPath: linkedPath ? path.resolve(path.dirname(fullPath), linkedPath) : null,
|
|
||||||
kind: "symlink",
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
out.set(entry.name, { targetPath: fullPath, kind: "directory" });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
out.set(entry.name, { targetPath: fullPath, kind: "file" });
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function buildOpenCodeSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
|
async function buildOpenCodeSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
|
||||||
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||||
const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry]));
|
|
||||||
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
|
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||||
const desiredSet = new Set(desiredSkills);
|
|
||||||
const skillsHome = resolveOpenCodeSkillsHome(config);
|
const skillsHome = resolveOpenCodeSkillsHome(config);
|
||||||
const installed = await readInstalledSkillTargets(skillsHome);
|
const installed = await readInstalledSkillTargets(skillsHome);
|
||||||
const entries: AdapterSkillEntry[] = [];
|
return buildPersistentSkillSnapshot({
|
||||||
const warnings: string[] = [
|
|
||||||
"OpenCode currently uses the shared Claude skills home (~/.claude/skills).",
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const available of availableEntries) {
|
|
||||||
const installedEntry = installed.get(available.runtimeName) ?? null;
|
|
||||||
const desired = desiredSet.has(available.key);
|
|
||||||
let state: AdapterSkillEntry["state"] = "available";
|
|
||||||
let managed = false;
|
|
||||||
let detail: string | null = null;
|
|
||||||
|
|
||||||
if (installedEntry?.targetPath === available.source) {
|
|
||||||
managed = true;
|
|
||||||
state = desired ? "installed" : "stale";
|
|
||||||
detail = "Installed in the shared Claude/OpenCode skills home.";
|
|
||||||
} else if (installedEntry) {
|
|
||||||
state = "external";
|
|
||||||
detail = desired
|
|
||||||
? "Skill name is occupied by an external installation in the shared skills home."
|
|
||||||
: "Installed outside Paperclip management in the shared skills home.";
|
|
||||||
} else if (desired) {
|
|
||||||
state = "missing";
|
|
||||||
detail = "Configured but not currently linked into the shared Claude/OpenCode skills home.";
|
|
||||||
}
|
|
||||||
|
|
||||||
entries.push({
|
|
||||||
key: available.key,
|
|
||||||
runtimeName: available.runtimeName,
|
|
||||||
desired,
|
|
||||||
managed,
|
|
||||||
state,
|
|
||||||
sourcePath: available.source,
|
|
||||||
targetPath: path.join(skillsHome, available.runtimeName),
|
|
||||||
detail,
|
|
||||||
required: Boolean(available.required),
|
|
||||||
requiredReason: available.requiredReason ?? null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const desiredSkill of desiredSkills) {
|
|
||||||
if (availableByKey.has(desiredSkill)) continue;
|
|
||||||
warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`);
|
|
||||||
entries.push({
|
|
||||||
key: desiredSkill,
|
|
||||||
runtimeName: null,
|
|
||||||
desired: true,
|
|
||||||
managed: true,
|
|
||||||
state: "missing",
|
|
||||||
sourcePath: null,
|
|
||||||
targetPath: null,
|
|
||||||
detail: "Paperclip cannot find this skill in the local runtime skills directory.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [name, installedEntry] of installed.entries()) {
|
|
||||||
if (availableEntries.some((entry) => entry.runtimeName === name)) continue;
|
|
||||||
entries.push({
|
|
||||||
key: name,
|
|
||||||
runtimeName: name,
|
|
||||||
desired: false,
|
|
||||||
managed: false,
|
|
||||||
state: "external",
|
|
||||||
sourcePath: null,
|
|
||||||
targetPath: installedEntry.targetPath ?? path.join(skillsHome, name),
|
|
||||||
detail: "Installed outside Paperclip management in the shared skills home.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
entries.sort((left, right) => left.key.localeCompare(right.key));
|
|
||||||
|
|
||||||
return {
|
|
||||||
adapterType: "opencode_local",
|
adapterType: "opencode_local",
|
||||||
supported: true,
|
availableEntries,
|
||||||
mode: "persistent",
|
|
||||||
desiredSkills,
|
desiredSkills,
|
||||||
entries,
|
installed,
|
||||||
warnings,
|
skillsHome,
|
||||||
};
|
locationLabel: "~/.claude/skills",
|
||||||
|
installedDetail: "Installed in the shared Claude/OpenCode skills home.",
|
||||||
|
missingDetail: "Configured but not currently linked into the shared Claude/OpenCode skills home.",
|
||||||
|
externalConflictDetail: "Skill name is occupied by an external installation in the shared skills home.",
|
||||||
|
externalDetail: "Installed outside Paperclip management in the shared skills home.",
|
||||||
|
warnings: [
|
||||||
|
"OpenCode currently uses the shared Claude skills home (~/.claude/skills).",
|
||||||
|
],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listOpenCodeSkills(ctx: AdapterSkillContext): Promise<AdapterSkillSnapshot> {
|
export async function listOpenCodeSkills(ctx: AdapterSkillContext): Promise<AdapterSkillSnapshot> {
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,13 @@ import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import type {
|
import type {
|
||||||
AdapterSkillContext,
|
AdapterSkillContext,
|
||||||
AdapterSkillEntry,
|
|
||||||
AdapterSkillSnapshot,
|
AdapterSkillSnapshot,
|
||||||
} from "@paperclipai/adapter-utils";
|
} from "@paperclipai/adapter-utils";
|
||||||
import {
|
import {
|
||||||
|
buildPersistentSkillSnapshot,
|
||||||
ensurePaperclipSkillSymlink,
|
ensurePaperclipSkillSymlink,
|
||||||
readPaperclipRuntimeSkillEntries,
|
readPaperclipRuntimeSkillEntries,
|
||||||
|
readInstalledSkillTargets,
|
||||||
resolvePaperclipDesiredSkillNames,
|
resolvePaperclipDesiredSkillNames,
|
||||||
} from "@paperclipai/adapter-utils/server-utils";
|
} from "@paperclipai/adapter-utils/server-utils";
|
||||||
|
|
||||||
|
|
@ -29,111 +30,22 @@ function resolvePiSkillsHome(config: Record<string, unknown>) {
|
||||||
return path.join(home, ".pi", "agent", "skills");
|
return path.join(home, ".pi", "agent", "skills");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readInstalledSkillTargets(skillsHome: string) {
|
|
||||||
const entries = await fs.readdir(skillsHome, { withFileTypes: true }).catch(() => []);
|
|
||||||
const out = new Map<string, { targetPath: string | null; kind: "symlink" | "directory" | "file" }>();
|
|
||||||
for (const entry of entries) {
|
|
||||||
const fullPath = path.join(skillsHome, entry.name);
|
|
||||||
if (entry.isSymbolicLink()) {
|
|
||||||
const linkedPath = await fs.readlink(fullPath).catch(() => null);
|
|
||||||
out.set(entry.name, {
|
|
||||||
targetPath: linkedPath ? path.resolve(path.dirname(fullPath), linkedPath) : null,
|
|
||||||
kind: "symlink",
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
out.set(entry.name, { targetPath: fullPath, kind: "directory" });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
out.set(entry.name, { targetPath: fullPath, kind: "file" });
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function buildPiSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
|
async function buildPiSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
|
||||||
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||||
const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry]));
|
|
||||||
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
|
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||||
const desiredSet = new Set(desiredSkills);
|
|
||||||
const skillsHome = resolvePiSkillsHome(config);
|
const skillsHome = resolvePiSkillsHome(config);
|
||||||
const installed = await readInstalledSkillTargets(skillsHome);
|
const installed = await readInstalledSkillTargets(skillsHome);
|
||||||
const entries: AdapterSkillEntry[] = [];
|
return buildPersistentSkillSnapshot({
|
||||||
const warnings: string[] = [];
|
|
||||||
|
|
||||||
for (const available of availableEntries) {
|
|
||||||
const installedEntry = installed.get(available.runtimeName) ?? null;
|
|
||||||
const desired = desiredSet.has(available.key);
|
|
||||||
let state: AdapterSkillEntry["state"] = "available";
|
|
||||||
let managed = false;
|
|
||||||
let detail: string | null = null;
|
|
||||||
|
|
||||||
if (installedEntry?.targetPath === available.source) {
|
|
||||||
managed = true;
|
|
||||||
state = desired ? "installed" : "stale";
|
|
||||||
} else if (installedEntry) {
|
|
||||||
state = "external";
|
|
||||||
detail = desired
|
|
||||||
? "Skill name is occupied by an external installation."
|
|
||||||
: "Installed outside Paperclip management.";
|
|
||||||
} else if (desired) {
|
|
||||||
state = "missing";
|
|
||||||
detail = "Configured but not currently linked into the Pi skills home.";
|
|
||||||
}
|
|
||||||
|
|
||||||
entries.push({
|
|
||||||
key: available.key,
|
|
||||||
runtimeName: available.runtimeName,
|
|
||||||
desired,
|
|
||||||
managed,
|
|
||||||
state,
|
|
||||||
sourcePath: available.source,
|
|
||||||
targetPath: path.join(skillsHome, available.runtimeName),
|
|
||||||
detail,
|
|
||||||
required: Boolean(available.required),
|
|
||||||
requiredReason: available.requiredReason ?? null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const desiredSkill of desiredSkills) {
|
|
||||||
if (availableByKey.has(desiredSkill)) continue;
|
|
||||||
warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`);
|
|
||||||
entries.push({
|
|
||||||
key: desiredSkill,
|
|
||||||
runtimeName: null,
|
|
||||||
desired: true,
|
|
||||||
managed: true,
|
|
||||||
state: "missing",
|
|
||||||
sourcePath: null,
|
|
||||||
targetPath: null,
|
|
||||||
detail: "Paperclip cannot find this skill in the local runtime skills directory.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [name, installedEntry] of installed.entries()) {
|
|
||||||
if (availableEntries.some((entry) => entry.runtimeName === name)) continue;
|
|
||||||
entries.push({
|
|
||||||
key: name,
|
|
||||||
runtimeName: 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.key.localeCompare(right.key));
|
|
||||||
|
|
||||||
return {
|
|
||||||
adapterType: "pi_local",
|
adapterType: "pi_local",
|
||||||
supported: true,
|
availableEntries,
|
||||||
mode: "persistent",
|
|
||||||
desiredSkills,
|
desiredSkills,
|
||||||
entries,
|
installed,
|
||||||
warnings,
|
skillsHome,
|
||||||
};
|
locationLabel: "~/.pi/agent/skills",
|
||||||
|
missingDetail: "Configured but not currently linked into the Pi skills home.",
|
||||||
|
externalConflictDetail: "Skill name is occupied by an external installation.",
|
||||||
|
externalDetail: "Installed outside Paperclip management.",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listPiSkills(ctx: AdapterSkillContext): Promise<AdapterSkillSnapshot> {
|
export async function listPiSkills(ctx: AdapterSkillContext): Promise<AdapterSkillSnapshot> {
|
||||||
|
|
|
||||||
|
|
@ -141,6 +141,7 @@ export type {
|
||||||
CompanySkillFileUpdateRequest,
|
CompanySkillFileUpdateRequest,
|
||||||
AgentSkillSyncMode,
|
AgentSkillSyncMode,
|
||||||
AgentSkillState,
|
AgentSkillState,
|
||||||
|
AgentSkillOrigin,
|
||||||
AgentSkillEntry,
|
AgentSkillEntry,
|
||||||
AgentSkillSnapshot,
|
AgentSkillSnapshot,
|
||||||
AgentSkillSyncRequest,
|
AgentSkillSyncRequest,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,12 @@ export type AgentSkillState =
|
||||||
| "stale"
|
| "stale"
|
||||||
| "external";
|
| "external";
|
||||||
|
|
||||||
|
export type AgentSkillOrigin =
|
||||||
|
| "company_managed"
|
||||||
|
| "paperclip_required"
|
||||||
|
| "user_installed"
|
||||||
|
| "external_unknown";
|
||||||
|
|
||||||
export interface AgentSkillEntry {
|
export interface AgentSkillEntry {
|
||||||
key: string;
|
key: string;
|
||||||
runtimeName: string | null;
|
runtimeName: string | null;
|
||||||
|
|
@ -16,6 +22,10 @@ export interface AgentSkillEntry {
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
requiredReason?: string | null;
|
requiredReason?: string | null;
|
||||||
state: AgentSkillState;
|
state: AgentSkillState;
|
||||||
|
origin?: AgentSkillOrigin;
|
||||||
|
originLabel?: string | null;
|
||||||
|
locationLabel?: string | null;
|
||||||
|
readOnly?: boolean;
|
||||||
sourcePath?: string | null;
|
sourcePath?: string | null;
|
||||||
targetPath?: string | null;
|
targetPath?: string | null;
|
||||||
detail?: string | null;
|
detail?: string | null;
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ export type {
|
||||||
export type {
|
export type {
|
||||||
AgentSkillSyncMode,
|
AgentSkillSyncMode,
|
||||||
AgentSkillState,
|
AgentSkillState,
|
||||||
|
AgentSkillOrigin,
|
||||||
AgentSkillEntry,
|
AgentSkillEntry,
|
||||||
AgentSkillSnapshot,
|
AgentSkillSnapshot,
|
||||||
AgentSkillSyncRequest,
|
AgentSkillSyncRequest,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,13 @@ export const agentSkillStateSchema = z.enum([
|
||||||
"external",
|
"external",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
export const agentSkillOriginSchema = z.enum([
|
||||||
|
"company_managed",
|
||||||
|
"paperclip_required",
|
||||||
|
"user_installed",
|
||||||
|
"external_unknown",
|
||||||
|
]);
|
||||||
|
|
||||||
export const agentSkillSyncModeSchema = z.enum([
|
export const agentSkillSyncModeSchema = z.enum([
|
||||||
"unsupported",
|
"unsupported",
|
||||||
"persistent",
|
"persistent",
|
||||||
|
|
@ -23,6 +30,10 @@ export const agentSkillEntrySchema = z.object({
|
||||||
required: z.boolean().optional(),
|
required: z.boolean().optional(),
|
||||||
requiredReason: z.string().nullable().optional(),
|
requiredReason: z.string().nullable().optional(),
|
||||||
state: agentSkillStateSchema,
|
state: agentSkillStateSchema,
|
||||||
|
origin: agentSkillOriginSchema.optional(),
|
||||||
|
originLabel: z.string().nullable().optional(),
|
||||||
|
locationLabel: z.string().nullable().optional(),
|
||||||
|
readOnly: z.boolean().optional(),
|
||||||
sourcePath: z.string().nullable().optional(),
|
sourcePath: z.string().nullable().optional(),
|
||||||
targetPath: z.string().nullable().optional(),
|
targetPath: z.string().nullable().optional(),
|
||||||
detail: z.string().nullable().optional(),
|
detail: z.string().nullable().optional(),
|
||||||
|
|
|
||||||
49
server/src/__tests__/agent-skill-contract.test.ts
Normal file
49
server/src/__tests__/agent-skill-contract.test.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
agentSkillEntrySchema,
|
||||||
|
agentSkillSnapshotSchema,
|
||||||
|
} from "@paperclipai/shared/validators/adapter-skills";
|
||||||
|
|
||||||
|
describe("agent skill contract", () => {
|
||||||
|
it("accepts optional provenance metadata on skill entries", () => {
|
||||||
|
expect(agentSkillEntrySchema.parse({
|
||||||
|
key: "crack-python",
|
||||||
|
runtimeName: "crack-python",
|
||||||
|
desired: false,
|
||||||
|
managed: false,
|
||||||
|
state: "external",
|
||||||
|
origin: "user_installed",
|
||||||
|
originLabel: "User-installed",
|
||||||
|
locationLabel: "~/.claude/skills",
|
||||||
|
readOnly: true,
|
||||||
|
detail: "Installed outside Paperclip management.",
|
||||||
|
})).toMatchObject({
|
||||||
|
origin: "user_installed",
|
||||||
|
locationLabel: "~/.claude/skills",
|
||||||
|
readOnly: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("remains backward compatible with snapshots that omit provenance metadata", () => {
|
||||||
|
expect(agentSkillSnapshotSchema.parse({
|
||||||
|
adapterType: "claude_local",
|
||||||
|
supported: true,
|
||||||
|
mode: "ephemeral",
|
||||||
|
desiredSkills: [],
|
||||||
|
entries: [{
|
||||||
|
key: "paperclipai/paperclip/paperclip",
|
||||||
|
runtimeName: "paperclip",
|
||||||
|
desired: true,
|
||||||
|
managed: true,
|
||||||
|
state: "configured",
|
||||||
|
}],
|
||||||
|
warnings: [],
|
||||||
|
})).toMatchObject({
|
||||||
|
adapterType: "claude_local",
|
||||||
|
entries: [{
|
||||||
|
key: "paperclipai/paperclip/paperclip",
|
||||||
|
state: "configured",
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,12 +1,32 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
listClaudeSkills,
|
listClaudeSkills,
|
||||||
syncClaudeSkills,
|
syncClaudeSkills,
|
||||||
} from "@paperclipai/adapter-claude-local/server";
|
} from "@paperclipai/adapter-claude-local/server";
|
||||||
|
|
||||||
|
async function makeTempDir(prefix: string): Promise<string> {
|
||||||
|
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createSkillDir(root: string, name: string) {
|
||||||
|
const skillDir = path.join(root, name);
|
||||||
|
await fs.mkdir(skillDir, { recursive: true });
|
||||||
|
await fs.writeFile(path.join(skillDir, "SKILL.md"), `---\nname: ${name}\n---\n`, "utf8");
|
||||||
|
return skillDir;
|
||||||
|
}
|
||||||
|
|
||||||
describe("claude local skill sync", () => {
|
describe("claude local skill sync", () => {
|
||||||
const paperclipKey = "paperclipai/paperclip/paperclip";
|
const paperclipKey = "paperclipai/paperclip/paperclip";
|
||||||
const createAgentKey = "paperclipai/paperclip/paperclip-create-agent";
|
const createAgentKey = "paperclipai/paperclip/paperclip-create-agent";
|
||||||
|
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("defaults to mounting all built-in Paperclip skills when no explicit selection exists", async () => {
|
it("defaults to mounting all built-in Paperclip skills when no explicit selection exists", async () => {
|
||||||
const snapshot = await listClaudeSkills({
|
const snapshot = await listClaudeSkills({
|
||||||
|
|
@ -58,4 +78,33 @@ describe("claude local skill sync", () => {
|
||||||
expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured");
|
expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured");
|
||||||
expect(snapshot.entries.find((entry) => entry.key === "paperclip")).toBeUndefined();
|
expect(snapshot.entries.find((entry) => entry.key === "paperclip")).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows host-level user-installed Claude skills as read-only external entries", async () => {
|
||||||
|
const home = await makeTempDir("paperclip-claude-user-skills-");
|
||||||
|
cleanupDirs.add(home);
|
||||||
|
await createSkillDir(path.join(home, ".claude", "skills"), "crack-python");
|
||||||
|
|
||||||
|
const snapshot = await listClaudeSkills({
|
||||||
|
agentId: "agent-4",
|
||||||
|
companyId: "company-1",
|
||||||
|
adapterType: "claude_local",
|
||||||
|
config: {
|
||||||
|
env: {
|
||||||
|
HOME: home,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(snapshot.entries).toContainEqual(expect.objectContaining({
|
||||||
|
key: "crack-python",
|
||||||
|
runtimeName: "crack-python",
|
||||||
|
state: "external",
|
||||||
|
managed: false,
|
||||||
|
origin: "user_installed",
|
||||||
|
originLabel: "User-installed",
|
||||||
|
locationLabel: "~/.claude/skills",
|
||||||
|
readOnly: true,
|
||||||
|
detail: "Installed outside Paperclip management in the Claude skills home.",
|
||||||
|
}));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,13 @@ async function makeTempDir(prefix: string): Promise<string> {
|
||||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createSkillDir(root: string, name: string) {
|
||||||
|
const skillDir = path.join(root, name);
|
||||||
|
await fs.mkdir(skillDir, { recursive: true });
|
||||||
|
await fs.writeFile(path.join(skillDir, "SKILL.md"), `---\nname: ${name}\n---\n`, "utf8");
|
||||||
|
return skillDir;
|
||||||
|
}
|
||||||
|
|
||||||
describe("codex local skill sync", () => {
|
describe("codex local skill sync", () => {
|
||||||
const paperclipKey = "paperclipai/paperclip/paperclip";
|
const paperclipKey = "paperclipai/paperclip/paperclip";
|
||||||
const cleanupDirs = new Set<string>();
|
const cleanupDirs = new Set<string>();
|
||||||
|
|
@ -111,4 +118,35 @@ describe("codex local skill sync", () => {
|
||||||
expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("missing");
|
expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("missing");
|
||||||
expect(snapshot.entries.find((entry) => entry.key === "paperclip")).toBeUndefined();
|
expect(snapshot.entries.find((entry) => entry.key === "paperclip")).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reports unmanaged user-installed Codex skills with provenance metadata", async () => {
|
||||||
|
const codexHome = await makeTempDir("paperclip-codex-user-skills-");
|
||||||
|
cleanupDirs.add(codexHome);
|
||||||
|
|
||||||
|
const externalSkillDir = await createSkillDir(path.join(codexHome, "skills"), "crack-python");
|
||||||
|
expect(externalSkillDir).toContain(path.join(codexHome, "skills"));
|
||||||
|
|
||||||
|
const snapshot = await listCodexSkills({
|
||||||
|
agentId: "agent-4",
|
||||||
|
companyId: "company-1",
|
||||||
|
adapterType: "codex_local",
|
||||||
|
config: {
|
||||||
|
env: {
|
||||||
|
CODEX_HOME: codexHome,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(snapshot.entries).toContainEqual(expect.objectContaining({
|
||||||
|
key: "crack-python",
|
||||||
|
runtimeName: "crack-python",
|
||||||
|
state: "external",
|
||||||
|
managed: false,
|
||||||
|
origin: "user_installed",
|
||||||
|
originLabel: "User-installed",
|
||||||
|
locationLabel: "$CODEX_HOME/skills",
|
||||||
|
readOnly: true,
|
||||||
|
detail: "Installed outside Paperclip management.",
|
||||||
|
}));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ export type {
|
||||||
AdapterEnvironmentTestContext,
|
AdapterEnvironmentTestContext,
|
||||||
AdapterSkillSyncMode,
|
AdapterSkillSyncMode,
|
||||||
AdapterSkillState,
|
AdapterSkillState,
|
||||||
|
AdapterSkillOrigin,
|
||||||
AdapterSkillEntry,
|
AdapterSkillEntry,
|
||||||
AdapterSkillSnapshot,
|
AdapterSkillSnapshot,
|
||||||
AdapterSkillContext,
|
AdapterSkillContext,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { applyAgentSkillSnapshot } from "./agent-skills-state";
|
import { applyAgentSkillSnapshot, isReadOnlyUnmanagedSkillEntry } from "./agent-skills-state";
|
||||||
|
|
||||||
describe("applyAgentSkillSnapshot", () => {
|
describe("applyAgentSkillSnapshot", () => {
|
||||||
it("hydrates the initial snapshot without arming autosave", () => {
|
it("hydrates the initial snapshot without arming autosave", () => {
|
||||||
|
|
@ -55,4 +55,36 @@ describe("applyAgentSkillSnapshot", () => {
|
||||||
shouldSkipAutosave: true,
|
shouldSkipAutosave: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("treats user-installed entries outside the company library as read-only unmanaged skills", () => {
|
||||||
|
expect(isReadOnlyUnmanagedSkillEntry({
|
||||||
|
key: "crack-python",
|
||||||
|
runtimeName: "crack-python",
|
||||||
|
desired: false,
|
||||||
|
managed: false,
|
||||||
|
state: "external",
|
||||||
|
origin: "user_installed",
|
||||||
|
}, new Set(["paperclip"]))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps company-library entries in the managed section even when the adapter reports an external conflict", () => {
|
||||||
|
expect(isReadOnlyUnmanagedSkillEntry({
|
||||||
|
key: "paperclip",
|
||||||
|
runtimeName: "paperclip",
|
||||||
|
desired: true,
|
||||||
|
managed: false,
|
||||||
|
state: "external",
|
||||||
|
origin: "company_managed",
|
||||||
|
}, new Set(["paperclip"]))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to legacy snapshots that only mark unmanaged external entries", () => {
|
||||||
|
expect(isReadOnlyUnmanagedSkillEntry({
|
||||||
|
key: "legacy-external",
|
||||||
|
runtimeName: "legacy-external",
|
||||||
|
desired: false,
|
||||||
|
managed: false,
|
||||||
|
state: "external",
|
||||||
|
}, new Set())).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import type { AgentSkillEntry } from "@paperclipai/shared";
|
||||||
|
|
||||||
export interface AgentSkillDraftState {
|
export interface AgentSkillDraftState {
|
||||||
draft: string[];
|
draft: string[];
|
||||||
lastSaved: string[];
|
lastSaved: string[];
|
||||||
|
|
@ -27,3 +29,12 @@ export function applyAgentSkillSnapshot(
|
||||||
shouldSkipAutosave: shouldReplaceDraft,
|
shouldSkipAutosave: shouldReplaceDraft,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isReadOnlyUnmanagedSkillEntry(
|
||||||
|
entry: AgentSkillEntry,
|
||||||
|
companySkillKeys: Set<string>,
|
||||||
|
): boolean {
|
||||||
|
if (companySkillKeys.has(entry.key)) return false;
|
||||||
|
if (entry.origin === "user_installed" || entry.origin === "external_unknown") return true;
|
||||||
|
return entry.managed === false && entry.state === "external";
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue