Rhys SullivanAdd Agent Skillsa2bfc4d2 months agoHistory
354 lines · 13.4 KB · TypeScript
RawDownload
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<SkillSummary, "files"> {  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 = <S extends { readonly owner: Owner; readonly name: string }>(  skills: readonly S[],): readonly S[] => {  const byName = new Map<string, S>();  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<Record<string, string>>;} 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<string, unknown>;  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<string, string> = {};  for (const [path, digest] of Object.entries(candidate.digests as Record<string, unknown>)) {    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<SkillSummary, "owner" | "name">;  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<Record<string, string>>,  b: Readonly<Record<string, string>>,): 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<readonly ExistingSkillEntry[], PlatformError, FileSystem.FileSystem | Path.Path> =>  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 `<root>/<name>`, creating *  parent directories for nested file paths as needed. */export const writeSkillDirectory = (input: {  readonly root: string;  readonly origin: string;  readonly skill: SkillDetail;}): Effect.Effect<void, PlatformError, FileSystem.FileSystem | Path.Path> =>  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 `<root>/<name>` 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<void, PlatformError, FileSystem.FileSystem | Path.Path> =>  Effect.gen(function* () {    const fs = yield* FileSystem.FileSystem;    const path = yield* Path.Path;    yield* fs.remove(path.join(root, name), { recursive: true, force: true });  });