import { homedir } from "node:os"; import { FileSystem, Path } from "effect"; import type { PlatformError } from "effect/PlatformError"; import * as Effect from "effect/Effect"; import type { Owner } from "@executor-js/sdk/shared"; // --------------------------------------------------------------------------- // `executor skills` — list and pull Agent Skills from the connected server. // // Pure planning lives here, separated from the filesystem/network I/O that // drives it, so the marker-file safety rules (never touch a directory that // isn't ours) are unit-testable without a server or real disk writes. See // plans/agent-skills.md, "CLI (apps/cli)" for the contract, and // packages/core/api/src/skills/api.ts for the response shapes this mirrors. // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- // Types mirroring the skills HTTP API responses. // --------------------------------------------------------------------------- export interface SkillFileEntry { readonly path: string; readonly size: number; readonly digest: string; } export interface SkillFile extends SkillFileEntry { readonly content: string; } /** What `GET /skills` returns per skill: the manifest, no file contents. */ export interface SkillSummary { readonly owner: Owner; readonly name: string; readonly description: string; readonly files: readonly SkillFileEntry[]; readonly updatedAt: number; } /** What `GET /skills/:owner/:name` returns: the summary plus file contents. */ export interface SkillDetail extends Omit { readonly files: readonly SkillFile[]; } // --------------------------------------------------------------------------- // Default install locations // --------------------------------------------------------------------------- export const defaultSkillsDir = (): string => `${homedir()}/.agents/skills`; export const defaultClaudeSkillsDir = (): string => `${homedir()}/.claude/skills`; // --------------------------------------------------------------------------- // Owner shadowing: a `user` skill hides an `org` skill of the same name. // --------------------------------------------------------------------------- /** Resolve the set of skills every agent actually sees: at most one entry per * name, preferring the `user` (personal) copy over an `org` (workspace) one. */ export const resolveEffectiveSkills = ( skills: readonly S[], ): readonly S[] => { const byName = new Map(); for (const skill of skills) { const existing = byName.get(skill.name); if (!existing || (existing.owner === "org" && skill.owner === "user")) { byName.set(skill.name, skill); } } return Array.from(byName.values()); }; // --------------------------------------------------------------------------- // `list` — table formatting (pure). // --------------------------------------------------------------------------- export const formatSkillsTable = (skills: readonly SkillSummary[]): readonly string[] => { if (skills.length === 0) { return ["No skills found."]; } const header = { owner: "OWNER", name: "NAME", description: "DESCRIPTION", files: "FILES", updated: "UPDATED", }; const rows = skills.map((skill) => ({ owner: skill.owner, name: skill.name, description: skill.description, files: String(skill.files.length), updated: new Date(skill.updatedAt).toISOString(), })); const widthOf = (key: "owner" | "name" | "files"): number => Math.max(header[key].length, ...rows.map((row) => row[key].length)); const ownerWidth = widthOf("owner"); const nameWidth = widthOf("name"); const filesWidth = widthOf("files"); const line = (row: (typeof rows)[number] | typeof header): string => `${row.owner.padEnd(ownerWidth)} ${row.name.padEnd(nameWidth)} ${row.description} ${row.files.padEnd(filesWidth)} ${row.updated}`; return [line(header), ...rows.map(line)]; }; // --------------------------------------------------------------------------- // Marker file — `.executor-skill.json`, written into every skill directory // this CLI manages. Its presence (and matching `origin`) is the only thing // that authorizes `pull` to overwrite or delete a directory. // --------------------------------------------------------------------------- export const SKILL_MARKER_FILENAME = ".executor-skill.json"; export interface SkillMarker { readonly origin: string; readonly owner: Owner; readonly name: string; /** File path (relative to the skill root) -> digest, as last written. */ readonly digests: Readonly>; } const isOwner = (value: unknown): value is Owner => value === "org" || value === "user"; /** Parse a marker file's contents. Returns `undefined` for anything that * isn't a well-formed marker — including a directory some other tool made, * which must never be treated as ours. */ export const parseSkillMarker = (raw: string): SkillMarker | undefined => { let parsed: unknown; try { parsed = JSON.parse(raw); } catch { return undefined; } if (typeof parsed !== "object" || parsed === null) return undefined; const candidate = parsed as Record; if (typeof candidate.origin !== "string") return undefined; if (!isOwner(candidate.owner)) return undefined; if (typeof candidate.name !== "string") return undefined; if (typeof candidate.digests !== "object" || candidate.digests === null) return undefined; const digests: Record = {}; for (const [path, digest] of Object.entries(candidate.digests as Record)) { if (typeof digest !== "string") return undefined; digests[path] = digest; } return { origin: candidate.origin, owner: candidate.owner, name: candidate.name, digests }; }; export const serializeSkillMarker = (marker: SkillMarker): string => `${JSON.stringify(marker, null, 2)}\n`; export const skillMarkerFor = (input: { readonly origin: string; readonly skill: Pick; readonly files: readonly SkillFileEntry[]; }): SkillMarker => ({ origin: input.origin, owner: input.skill.owner, name: input.skill.name, digests: Object.fromEntries(input.files.map((file) => [file.path, file.digest])), }); // --------------------------------------------------------------------------- // `pull` — planning (pure). Decides add/update/unchanged/remove/skip per // skill directory without touching the network or filesystem. // --------------------------------------------------------------------------- export type SkillPullActionKind = "add" | "update" | "unchanged" | "remove" | "skip"; export interface SkillPullAction { readonly name: string; readonly kind: SkillPullActionKind; readonly reason?: string; } /** What `pull` found on disk for one directory entry under the target root. */ export interface ExistingSkillEntry { readonly name: string; /** `undefined` when there's no marker file, or it doesn't parse: an * unmanaged directory that `pull` must never overwrite or delete. */ readonly marker: SkillMarker | undefined; } const digestsEqual = ( a: Readonly>, b: Readonly>, ): boolean => { const aKeys = Object.keys(a); const bKeys = Object.keys(b); if (aKeys.length !== bKeys.length) return false; return aKeys.every((key) => a[key] === b[key]); }; /** Plan one target root's worth of `pull` actions. Call once per root (the * main `--dir` and, unless `--no-claude`, the `~/.claude/skills` mirror). */ export const planSkillsPull = (input: { readonly origin: string; /** Effective (already-shadowed) skills this owner scope can see. */ readonly skills: readonly SkillSummary[]; /** What's already on disk under the target root. */ readonly existing: readonly ExistingSkillEntry[]; }): readonly SkillPullAction[] => { const actions: SkillPullAction[] = []; const skillNames = new Set(input.skills.map((skill) => skill.name)); const existingByName = new Map(input.existing.map((entry) => [entry.name, entry])); for (const skill of input.skills) { const existing = existingByName.get(skill.name); const desiredDigests = Object.fromEntries(skill.files.map((file) => [file.path, file.digest])); if (!existing) { actions.push({ name: skill.name, kind: "add" }); continue; } if (!existing.marker) { actions.push({ name: skill.name, kind: "skip", reason: `${skill.name}/ exists without a ${SKILL_MARKER_FILENAME} marker`, }); continue; } if (existing.marker.origin !== input.origin) { actions.push({ name: skill.name, kind: "skip", reason: `${skill.name}/ is managed by a different server (${existing.marker.origin})`, }); continue; } actions.push({ name: skill.name, kind: digestsEqual(existing.marker.digests, desiredDigests) ? "unchanged" : "update", }); } for (const existing of input.existing) { if (skillNames.has(existing.name)) continue; if (!existing.marker) continue; // never touch a directory we don't own if (existing.marker.origin !== input.origin) continue; // owned by another server actions.push({ name: existing.name, kind: "remove", reason: "no longer exists upstream" }); } return actions; }; export interface SkillPullSummary { readonly added: number; readonly updated: number; readonly unchanged: number; readonly removed: number; readonly skipped: number; } export const summarizeSkillPullActions = ( actions: readonly SkillPullAction[], ): SkillPullSummary => ({ added: actions.filter((action) => action.kind === "add").length, updated: actions.filter((action) => action.kind === "update").length, unchanged: actions.filter((action) => action.kind === "unchanged").length, removed: actions.filter((action) => action.kind === "remove").length, skipped: actions.filter((action) => action.kind === "skip").length, }); export const formatSkillPullSummaryLine = (summary: SkillPullSummary): string => `${summary.added} added, ${summary.updated} updated, ${summary.removed} removed, ${summary.skipped} skipped` + (summary.unchanged > 0 ? ` (${summary.unchanged} unchanged)` : ""); // --------------------------------------------------------------------------- // Filesystem I/O — reading what's on disk and applying a plan. Kept small and // separate from the planning above so tests can exercise the pure logic // without a real (or even temp) filesystem; only the handful of tests that // need real directory semantics use a temp dir. // --------------------------------------------------------------------------- /** List the immediate subdirectories of `root` and read each one's marker, * if any. Returns `[]` when `root` doesn't exist yet. */ export const readExistingSkillEntries = ( root: string, ): Effect.Effect => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const rootExists = yield* fs.exists(root); if (!rootExists) return []; const entries = yield* fs.readDirectory(root); const results: ExistingSkillEntry[] = []; for (const name of entries) { const entryPath = path.join(root, name); const info = yield* fs.stat(entryPath); if (info.type !== "Directory") continue; const markerPath = path.join(entryPath, SKILL_MARKER_FILENAME); const hasMarker = yield* fs.exists(markerPath); if (!hasMarker) { results.push({ name, marker: undefined }); continue; } const raw = yield* fs.readFileString(markerPath).pipe(Effect.orElseSucceed(() => "")); results.push({ name, marker: parseSkillMarker(raw) }); } return results; }); /** Write one skill's files (and its marker) into `/`, creating * parent directories for nested file paths as needed. */ export const writeSkillDirectory = (input: { readonly root: string; readonly origin: string; readonly skill: SkillDetail; }): Effect.Effect => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const skillDir = path.join(input.root, input.skill.name); yield* fs.makeDirectory(skillDir, { recursive: true }); for (const file of input.skill.files) { const filePath = path.join(skillDir, file.path); const parent = path.dirname(filePath); if (parent !== skillDir) { yield* fs.makeDirectory(parent, { recursive: true }); } yield* fs.writeFileString(filePath, file.content); } const marker = skillMarkerFor({ origin: input.origin, skill: input.skill, files: input.skill.files, }); yield* fs.writeFileString( path.join(skillDir, SKILL_MARKER_FILENAME), serializeSkillMarker(marker), ); }); /** Remove `/` entirely. Only called for `"remove"` actions, which * `planSkillsPull` only ever produces for marker-bearing directories whose * marker origin matches the current server. */ export const removeSkillDirectory = ( root: string, name: string, ): Effect.Effect => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; yield* fs.remove(path.join(root, name), { recursive: true, force: true }); });