import { promises as fs } from "node:fs"; import path from "node:path"; import type { AdapterSkillEntry, AdapterSkillSnapshot } from "./types.js"; import type { AgentCompanySkillEntry, InstalledSkillTarget } from "./server-utils-skills.js"; interface PersistentSkillSnapshotOptions { adapterType: string; availableEntries: AgentCompanySkillEntry[]; desiredSkills: string[]; installed: Map; skillsHome: string; locationLabel?: string | null; installedDetail?: string | null; missingDetail: string; externalConflictDetail: string; externalDetail: string; warnings?: string[]; } function normalizePathSlashes(value: string): string { return value.replaceAll("\\", "/"); } 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 AgentCompany", readOnly: false, }; } return { origin: "company_managed", originLabel: "Managed by AgentCompany", readOnly: false, }; } function canonicalizeDesiredAgentCompanySkillReference( reference: string, availableEntries: Array<{ key: string; runtimeName?: string | null }>, ): string { const normalizedReference = reference.trim().toLowerCase(); if (!normalizedReference) return ""; const exactKey = availableEntries.find((entry) => entry.key.trim().toLowerCase() === normalizedReference); if (exactKey) return exactKey.key; const byRuntimeName = availableEntries.filter((entry) => typeof entry.runtimeName === "string" && entry.runtimeName.trim().toLowerCase() === normalizedReference, ); if (byRuntimeName.length === 1) return byRuntimeName[0]!.key; const slugMatches = availableEntries.filter((entry) => entry.key.trim().toLowerCase().split("/").pop() === normalizedReference, ); if (slugMatches.length === 1) return slugMatches[0]!.key; return normalizedReference; } /** * Build an AdapterSkillSnapshot for persistent-mode adapters (Gemini, Codex). * Compares available entries against installed state and desired skill list. */ 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 AgentCompany skills directory.`); entries.push({ key: desiredSkill, runtimeName: null, desired: true, managed: true, state: "missing", sourcePath: null, targetPath: null, detail: "AgentCompany 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, }; } /** * Read the desired skill sync preference from adapter config. */ export function readAgentCompanySkillSyncPreference(config: Record): { explicit: boolean; desiredSkills: string[]; } { const raw = config.agentCompanySkillSync; if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { return { explicit: false, desiredSkills: [] }; } const syncConfig = raw as Record; 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)), }; } /** * Resolve the canonical desired skill list from config and available entries. * Required skills are always included; explicit preferences are canonicalized. */ export function resolveAgentCompanyDesiredSkillNames( config: Record, availableEntries: Array<{ key: string; runtimeName?: string | null; required?: boolean }>, ): string[] { const preference = readAgentCompanySkillSyncPreference(config); const requiredSkills = availableEntries .filter((entry) => entry.required) .map((entry) => entry.key); if (!preference.explicit) { return Array.from(new Set(requiredSkills)); } const desiredSkills = preference.desiredSkills .map((reference) => canonicalizeDesiredAgentCompanySkillReference(reference, availableEntries)) .filter(Boolean); return Array.from(new Set([...requiredSkills, ...desiredSkills])); } /** * Create or repair a symlink from source to target skill directory. * Returns the action taken: "created", "repaired", or "skipped". */ export async function ensureAgentCompanySkillSymlink( source: string, target: string, linkSkill: (source: string, target: string) => Promise = (linkSource, linkTarget) => fs.symlink(linkSource, linkTarget), ): Promise<"created" | "repaired" | "skipped"> { const existing = await fs.lstat(target).catch(() => null); if (!existing) { await linkSkill(source, target); return "created"; } if (!existing.isSymbolicLink()) { return "skipped"; } const linkedPath = await fs.readlink(target).catch(() => null); if (!linkedPath) return "skipped"; const resolvedLinkedPath = path.resolve(path.dirname(target), linkedPath); if (resolvedLinkedPath === source) { return "skipped"; } const linkedPathExists = await fs.stat(resolvedLinkedPath).then(() => true).catch(() => false); if (linkedPathExists) { return "skipped"; } await fs.unlink(target); await linkSkill(source, target); return "repaired"; } /** * Remove symlinks in skillsHome that point to maintainer-only paths * (.agents/skills/) and are not in the allowed set. */ export async function removeMaintainerOnlySkillSymlinks( skillsHome: string, allowedSkillNames: Iterable, ): Promise { const allowed = new Set(Array.from(allowedSkillNames)); try { const entries = await fs.readdir(skillsHome, { withFileTypes: true }); const removed: string[] = []; for (const entry of entries) { if (allowed.has(entry.name)) continue; const target = path.join(skillsHome, entry.name); const existing = await fs.lstat(target).catch(() => null); if (!existing?.isSymbolicLink()) continue; const linkedPath = await fs.readlink(target).catch(() => null); if (!linkedPath) continue; const resolvedLinkedPath = path.isAbsolute(linkedPath) ? linkedPath : path.resolve(path.dirname(target), linkedPath); if ( !isMaintainerOnlySkillTarget(linkedPath) && !isMaintainerOnlySkillTarget(resolvedLinkedPath) ) { continue; } await fs.unlink(target); removed.push(entry.name); } return removed; } catch { return []; } }