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:
@@ -4,12 +4,13 @@ import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type {
|
||||
AdapterSkillContext,
|
||||
AdapterSkillEntry,
|
||||
AdapterSkillSnapshot,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
buildPersistentSkillSnapshot,
|
||||
ensurePaperclipSkillSymlink,
|
||||
readPaperclipRuntimeSkillEntries,
|
||||
readInstalledSkillTargets,
|
||||
resolvePaperclipDesiredSkillNames,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
|
||||
@@ -29,114 +30,26 @@ function resolveOpenCodeSkillsHome(config: Record<string, unknown>) {
|
||||
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> {
|
||||
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||
const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry]));
|
||||
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||
const desiredSet = new Set(desiredSkills);
|
||||
const skillsHome = resolveOpenCodeSkillsHome(config);
|
||||
const installed = await readInstalledSkillTargets(skillsHome);
|
||||
const entries: AdapterSkillEntry[] = [];
|
||||
const warnings: string[] = [
|
||||
"OpenCode currently uses the shared Claude skills home (~/.claude/skills).",
|
||||
];
|
||||
|
||||
for (const available of availableEntries) {
|
||||
const installedEntry = installed.get(available.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 {
|
||||
return buildPersistentSkillSnapshot({
|
||||
adapterType: "opencode_local",
|
||||
supported: true,
|
||||
mode: "persistent",
|
||||
availableEntries,
|
||||
desiredSkills,
|
||||
entries,
|
||||
warnings,
|
||||
};
|
||||
installed,
|
||||
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> {
|
||||
|
||||
Reference in New Issue
Block a user