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:
dotta
2026-03-18 14:21:50 -05:00
parent 58d7f59477
commit cfc53bf96b
19 changed files with 497 additions and 501 deletions

View File

@@ -1,6 +1,10 @@
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 type {
AdapterSkillEntry,
AdapterSkillSnapshot,
} from "./types.js";
export interface RunProcessResult {
exitCode: number | null;
@@ -45,6 +49,25 @@ export interface PaperclipSkillEntry {
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 {
return value.replaceAll("\\", "/");
}
@@ -53,6 +76,49 @@ function isMaintainerOnlySkillTarget(candidate: string): boolean {
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> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
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[] {
if (!Array.isArray(value)) return [];
const out: PaperclipSkillEntry[] = [];