diff --git a/packages/adapter-claude-local/src/server/index.ts b/packages/adapter-claude-local/src/server/index.ts index bed2e92f..41e20205 100644 --- a/packages/adapter-claude-local/src/server/index.ts +++ b/packages/adapter-claude-local/src/server/index.ts @@ -6,6 +6,7 @@ export { isClaudeMaxTurnsResult, isClaudeUnknownSessionError, } from "./parse.js"; +export { listClaudeSkills, syncClaudeSkills } from "./skills.js"; import type { AdapterSessionCodec } from "@agentcompany/adapter-utils"; function readNonEmptyString(value: unknown): string | null { diff --git a/packages/adapter-claude-local/src/server/skills.ts b/packages/adapter-claude-local/src/server/skills.ts new file mode 100644 index 00000000..a86a5e94 --- /dev/null +++ b/packages/adapter-claude-local/src/server/skills.ts @@ -0,0 +1,131 @@ +// --------------------------------------------------------------------------- +// Claude adapter skill listing / sync -- ephemeral mode +// Skills are mounted into a temp dir via --add-dir at run time, not persisted. +// --------------------------------------------------------------------------- + +import path from "node:path"; +import type { + AdapterSkillContext, + AdapterSkillEntry, + AdapterSkillSnapshot, +} from "@agentcompany/adapter-utils"; +import { + parseObject, + asString, + readAgentCompanyRuntimeSkillEntries, + readInstalledSkillTargets, + resolveAgentCompanyDesiredSkillNames, +} from "@agentcompany/adapter-utils/server-utils"; + +function resolveClaudeSkillsHome(config: Record): string { + const env = parseObject(config.env); + const home = asString(env.HOME, asString(env.USERPROFILE, "")); + const base = home || (process.env.HOME ?? process.env.USERPROFILE ?? ""); + return path.join(base, ".claude", "skills"); +} + +async function buildClaudeSkillSnapshot( + config: Record, +): Promise { + const moduleDir = path.resolve(import.meta.dirname ?? __dirname); + const availableEntries = await readAgentCompanyRuntimeSkillEntries(config, moduleDir); + const desiredSkills = resolveAgentCompanyDesiredSkillNames(config, availableEntries); + const desiredSet = new Set(desiredSkills); + const entries: AdapterSkillEntry[] = []; + const warnings: string[] = []; + + for (const available of availableEntries) { + const desired = desiredSet.has(available.key); + entries.push({ + key: available.key, + runtimeName: available.runtimeName, + desired, + managed: true, + state: desired ? "configured" : "available", + sourcePath: available.source, + targetPath: null, + detail: desired + ? "Will be mounted into the ephemeral Claude skill directory on the next run." + : null, + required: Boolean(available.required), + requiredReason: available.requiredReason ?? null, + origin: available.required ? "paperclip_required" : "company_managed", + originLabel: available.required ? "Required by AgentCompany" : "Managed by AgentCompany", + readOnly: false, + }); + } + + // Scan installed Claude skills for external entries + const skillsHome = resolveClaudeSkillsHome(config); + const installed = await readInstalledSkillTargets(skillsHome); + 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 AgentCompany management.", + }); + } + + // Check for desired skills not in available + for (const desiredSkill of desiredSkills) { + if (availableEntries.some((entry) => entry.key === desiredSkill)) continue; + if (entries.some((entry) => entry.key === 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, + }); + } + + entries.sort((left, right) => left.key.localeCompare(right.key)); + + return { + adapterType: "claude_local", + supported: true, + mode: "ephemeral", + desiredSkills, + entries, + warnings, + }; +} + +/** + * List the current skill state for the Claude adapter. + * Ephemeral mode: skills are injected at run time via --add-dir, not persisted. + */ +export async function listClaudeSkills( + ctx: AdapterSkillContext, +): Promise { + return buildClaudeSkillSnapshot(ctx.config); +} + +/** + * Sync skills for the Claude adapter. + * Ephemeral mode: no on-disk sync needed; skills are injected at run time. + * Returns the current snapshot for informational purposes. + */ +export async function syncClaudeSkills( + ctx: AdapterSkillContext, + _desiredSkills: string[], +): Promise { + return buildClaudeSkillSnapshot(ctx.config); +} diff --git a/packages/adapter-codex-local/src/server/index.ts b/packages/adapter-codex-local/src/server/index.ts index b0adcc4f..da878bde 100644 --- a/packages/adapter-codex-local/src/server/index.ts +++ b/packages/adapter-codex-local/src/server/index.ts @@ -1,6 +1,7 @@ export { execute } from "./execute.js"; export { testEnvironment } from "./test.js"; export { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js"; +export { listCodexSkills, syncCodexSkills } from "./skills.js"; import type { AdapterSessionCodec } from "@agentcompany/adapter-utils"; function readNonEmptyString(value: unknown): string | null { diff --git a/packages/adapter-codex-local/src/server/skills.ts b/packages/adapter-codex-local/src/server/skills.ts new file mode 100644 index 00000000..43d4dd19 --- /dev/null +++ b/packages/adapter-codex-local/src/server/skills.ts @@ -0,0 +1,99 @@ +// --------------------------------------------------------------------------- +// Codex adapter skill listing / sync -- ephemeral mode +// Skills are linked into the workspace .agents/skills directory at run time. +// --------------------------------------------------------------------------- + +import path from "node:path"; +import type { + AdapterSkillContext, + AdapterSkillEntry, + AdapterSkillSnapshot, +} from "@agentcompany/adapter-utils"; +import { + readAgentCompanyRuntimeSkillEntries, + resolveAgentCompanyDesiredSkillNames, +} from "@agentcompany/adapter-utils/server-utils"; + +async function buildCodexSkillSnapshot( + config: Record, +): Promise { + const moduleDir = path.resolve(import.meta.dirname ?? __dirname); + const availableEntries = await readAgentCompanyRuntimeSkillEntries(config, moduleDir); + const desiredSkills = resolveAgentCompanyDesiredSkillNames(config, availableEntries); + const desiredSet = new Set(desiredSkills); + const entries: AdapterSkillEntry[] = []; + const warnings: string[] = []; + + for (const available of availableEntries) { + const desired = desiredSet.has(available.key); + entries.push({ + key: available.key, + runtimeName: available.runtimeName, + desired, + managed: true, + state: desired ? "configured" : "available", + sourcePath: available.source, + targetPath: null, + detail: desired + ? "Will be linked into the workspace .agents/skills directory on the next run." + : null, + required: Boolean(available.required), + requiredReason: available.requiredReason ?? null, + origin: available.required ? "paperclip_required" : "company_managed", + originLabel: available.required ? "Required by AgentCompany" : "Managed by AgentCompany", + readOnly: false, + }); + } + + // Check for desired skills not in available + for (const desiredSkill of desiredSkills) { + if (availableEntries.some((entry) => entry.key === 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, + }); + } + + entries.sort((left, right) => left.key.localeCompare(right.key)); + + return { + adapterType: "codex_local", + supported: true, + mode: "ephemeral", + desiredSkills, + entries, + warnings, + }; +} + +/** + * List the current skill state for the Codex adapter. + * Ephemeral mode: skills are linked into workspace at run time. + */ +export async function listCodexSkills( + ctx: AdapterSkillContext, +): Promise { + return buildCodexSkillSnapshot(ctx.config); +} + +/** + * Sync skills for the Codex adapter. + * Ephemeral mode: no on-disk sync needed; skills are linked at run time. + * Returns the current snapshot for informational purposes. + */ +export async function syncCodexSkills( + ctx: AdapterSkillContext, + _desiredSkills: string[], +): Promise { + return buildCodexSkillSnapshot(ctx.config); +} diff --git a/packages/adapter-gemini-local/src/server/index.ts b/packages/adapter-gemini-local/src/server/index.ts index 471aaf80..8ffe7d56 100644 --- a/packages/adapter-gemini-local/src/server/index.ts +++ b/packages/adapter-gemini-local/src/server/index.ts @@ -7,6 +7,7 @@ export { detectGeminiAuthRequired, isGeminiTurnLimitResult, } from "./parse.js"; +export { listGeminiSkills, syncGeminiSkills } from "./skills.js"; import type { AdapterSessionCodec } from "@agentcompany/adapter-utils"; function readNonEmptyString(value: unknown): string | null { diff --git a/packages/adapter-gemini-local/src/server/skills.ts b/packages/adapter-gemini-local/src/server/skills.ts new file mode 100644 index 00000000..415d8a8b --- /dev/null +++ b/packages/adapter-gemini-local/src/server/skills.ts @@ -0,0 +1,113 @@ +// --------------------------------------------------------------------------- +// Gemini adapter skill listing / sync -- persistent mode +// Skills are symlinked into ~/.gemini/skills/ and persist across runs. +// --------------------------------------------------------------------------- + +import { promises as fs } from "node:fs"; +import path from "node:path"; +import type { + AdapterSkillContext, + AdapterSkillSnapshot, +} from "@agentcompany/adapter-utils"; +import { + parseObject, + asString, + readAgentCompanyRuntimeSkillEntries, + readInstalledSkillTargets, + resolveAgentCompanyDesiredSkillNames, + buildPersistentSkillSnapshot, + ensureAgentCompanySkillSymlink, + removeMaintainerOnlySkillSymlinks, +} from "@agentcompany/adapter-utils/server-utils"; + +function resolveGeminiSkillsHome(config: Record): string { + const env = parseObject(config.env); + const home = asString(env.HOME, asString(env.USERPROFILE, "")); + const base = home || (process.env.HOME ?? process.env.USERPROFILE ?? ""); + return path.join(base, ".gemini", "skills"); +} + +async function buildGeminiSkillSnapshot( + config: Record, +): Promise { + const moduleDir = path.resolve(import.meta.dirname ?? __dirname); + const availableEntries = await readAgentCompanyRuntimeSkillEntries(config, moduleDir); + const desiredSkills = resolveAgentCompanyDesiredSkillNames(config, availableEntries); + const skillsHome = resolveGeminiSkillsHome(config); + const installed = await readInstalledSkillTargets(skillsHome); + + return buildPersistentSkillSnapshot({ + adapterType: "gemini_local", + availableEntries, + desiredSkills, + installed, + 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 AgentCompany management.", + }); +} + +/** + * List the current skill state for the Gemini adapter. + * Persistent mode: reports actual installed state in ~/.gemini/skills/. + */ +export async function listGeminiSkills( + ctx: AdapterSkillContext, +): Promise { + return buildGeminiSkillSnapshot(ctx.config); +} + +/** + * Sync skills for the Gemini adapter. + * Persistent mode: creates symlinks for desired skills, removes stale ones, + * then returns a fresh snapshot. + */ +export async function syncGeminiSkills( + ctx: AdapterSkillContext, + desiredSkills: string[], +): Promise { + const moduleDir = path.resolve(import.meta.dirname ?? __dirname); + const config = ctx.config; + const availableEntries = await readAgentCompanyRuntimeSkillEntries(config, moduleDir); + const skillsHome = resolveGeminiSkillsHome(config); + + // Ensure the skills home directory exists + await fs.mkdir(skillsHome, { recursive: true }); + + // Build a set of desired skill keys for quick lookup + const desiredSet = new Set(desiredSkills); + + // Symlink type for Windows compatibility + const symlinkType = process.platform === "win32" ? ("junction" as const) : undefined; + const linkSkill = async (source: string, target: string) => { + await fs.symlink(source, target, symlinkType); + }; + + // Create/repair symlinks for desired skills + const syncedRuntimeNames = new Set(); + for (const available of availableEntries) { + if (!desiredSet.has(available.key)) continue; + const target = path.join(skillsHome, available.runtimeName); + await ensureAgentCompanySkillSymlink(available.source, target, linkSkill); + syncedRuntimeNames.add(available.runtimeName); + } + + // Remove stale maintainer-only symlinks that are no longer desired + await removeMaintainerOnlySkillSymlinks(skillsHome, syncedRuntimeNames); + + // Return fresh snapshot reflecting the new state + const installed = await readInstalledSkillTargets(skillsHome); + return buildPersistentSkillSnapshot({ + adapterType: "gemini_local", + availableEntries, + desiredSkills, + installed, + 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 AgentCompany management.", + }); +}