Replace skills auto-sync with explicit install/update/uninstall (#797)

* Replace skills auto-sync with explicit install/update/uninstall

Skills no longer sync silently on every desktop launch. Settings now
shows Install (fresh), Update (with a confirm dialog listing per-skill
add/update/delete ops), or Uninstall depending on what's on disk.

A manifest at ~/.agents/skills/.paseo-manifest.json tracks Paseo-owned
skills so uninstall removes only what we wrote, never user content.
Existing users migrate transparently — first read synthesises a
manifest from on-disk hashes, so an unchanged install flips silently
to up-to-date.

Also restructures the integrations layer: skills/ and cli-install/
each own their files behind a designed index.ts. The shared
integrations-manager.ts is gone.

* Rewrite skills install flow with disk-truth model

Drops the manifest sidecar, migration logic, and the
agentsDirHasSkillContent heuristic that misread user-authored skills
as Paseo content. Replaces them with a hardcoded PASEO_SKILL_NAMES
list (current bundle + paseo-chat for retired-skill cleanup); the
disk is the source of truth for what's installed.

Renames the SkillsState "fresh" → "not-installed" everywhere
(server, IPC parser, hook test mocks). Install and update share an
applySkills body. Errors propagate instead of being swallowed.

Fixes the case where Uninstall left state at "drift" because the
user had personal skills under ~/.agents/skills/.

* Stop symlinking claude skills, write real files

If the user has ~/.claude/skills symlinked to ~/.agents/skills, the
per-skill symlink path resolved through the parent symlink and fs.rm'd
the agents-side files we'd just written. Mirror real files to claude
the same way as codex; agents stays the canonical source-of-truth dir.

* Make Uninstall button visible (outline, not ghost)
This commit is contained in:
Mohamed Boudra
2026-05-07 18:41:13 +08:00
committed by GitHub
parent 4f0b264886
commit 92be6c0cba
19 changed files with 1180 additions and 439 deletions
@@ -0,0 +1,10 @@
export {
getSkillsStatus,
installSkills,
uninstallSkills,
updateSkills,
type SkillOp,
type SkillsState,
type SkillsStatus,
type SkillTargets,
} from "./operations.js";
@@ -0,0 +1,302 @@
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("electron", () => ({
app: {
getPath: vi.fn(() => "/tmp/paseo-user-data"),
isPackaged: false,
},
}));
import {
getSkillsStatus,
installSkills,
PASEO_SKILL_NAMES,
type SkillTargets,
uninstallSkills,
updateSkills,
} from "./operations";
interface Sandbox {
root: string;
targets: SkillTargets;
}
async function makeSandbox(): Promise<Sandbox> {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-skills-"));
const targets: SkillTargets = {
sourceDir: path.join(root, "bundle"),
agentsDir: path.join(root, "home", ".agents", "skills"),
claudeDir: path.join(root, "home", ".claude", "skills"),
codexDir: path.join(root, "home", ".codex", "skills"),
};
await fs.mkdir(targets.sourceDir, { recursive: true });
return { root, targets };
}
async function writeFiles(rootDir: string, files: Record<string, string>): Promise<void> {
for (const [rel, content] of Object.entries(files)) {
const full = path.join(rootDir, rel);
await fs.mkdir(path.dirname(full), { recursive: true });
await fs.writeFile(full, content);
}
}
async function writeBundleSkill(
sourceDir: string,
name: string,
files: Record<string, string>,
): Promise<void> {
await writeFiles(path.join(sourceDir, name), files);
}
async function writeOnDiskSkill(
agentsDir: string,
name: string,
files: Record<string, string>,
): Promise<void> {
await writeFiles(path.join(agentsDir, name), files);
}
async function writeCurrentBundle(sourceDir: string): Promise<void> {
await writeBundleSkill(sourceDir, "paseo", { "SKILL.md": "paseo-v1" });
await writeBundleSkill(sourceDir, "paseo-loop", { "SKILL.md": "loop-v1" });
}
async function pathExists(p: string): Promise<boolean> {
return fs
.access(p)
.then(() => true)
.catch(() => false);
}
describe("getSkillsStatus", () => {
let sandbox: Sandbox;
beforeEach(async () => {
sandbox = await makeSandbox();
});
afterEach(async () => {
await fs.rm(sandbox.root, { recursive: true, force: true });
});
it("returns not-installed with add ops for every bundled skill when nothing is on disk", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
const status = await getSkillsStatus(sandbox.targets);
expect(status.state).toBe("not-installed");
expect(status.ops).toEqual([
{ kind: "add", name: "paseo" },
{ kind: "add", name: "paseo-loop" },
]);
});
it("returns not-installed when only user-personal skill dirs exist (the live bug)", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
for (const name of ["unslop", "tdd", "devbox"]) {
await writeOnDiskSkill(sandbox.targets.agentsDir, name, { "SKILL.md": `user-${name}` });
}
const status = await getSkillsStatus(sandbox.targets);
expect(status.state).toBe("not-installed");
expect(status.ops).toEqual([
{ kind: "add", name: "paseo" },
{ kind: "add", name: "paseo-loop" },
]);
});
it("returns up-to-date when every bundled skill matches on disk", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "paseo-v1" });
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-loop", { "SKILL.md": "loop-v1" });
const status = await getSkillsStatus(sandbox.targets);
expect(status).toEqual({ state: "up-to-date", ops: [] });
});
it("returns drift with a single update op when one bundled file diverges", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "stale" });
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-loop", { "SKILL.md": "loop-v1" });
const status = await getSkillsStatus(sandbox.targets);
expect(status.state).toBe("drift");
expect(status.ops).toEqual([{ kind: "update", name: "paseo" }]);
});
it("returns drift with add ops for the bundled skills missing from disk", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "paseo-v1" });
const status = await getSkillsStatus(sandbox.targets);
expect(status.state).toBe("drift");
expect(status.ops).toEqual([{ kind: "add", name: "paseo-loop" }]);
});
it("returns drift with a delete op for a legacy skill name still on disk", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "paseo-v1" });
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-loop", { "SKILL.md": "loop-v1" });
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-chat", { "SKILL.md": "chat-old" });
const status = await getSkillsStatus(sandbox.targets);
expect(status.state).toBe("drift");
expect(status.ops).toEqual([{ kind: "delete", name: "paseo-chat" }]);
});
it("emits add + update + delete ops sorted by name when state is mixed", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "stale" });
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-chat", { "SKILL.md": "chat-old" });
const status = await getSkillsStatus(sandbox.targets);
expect(status.state).toBe("drift");
expect(status.ops).toEqual([
{ kind: "update", name: "paseo" },
{ kind: "delete", name: "paseo-chat" },
{ kind: "add", name: "paseo-loop" },
]);
});
});
describe("installSkills / updateSkills", () => {
let sandbox: Sandbox;
beforeEach(async () => {
sandbox = await makeSandbox();
});
afterEach(async () => {
await fs.rm(sandbox.root, { recursive: true, force: true });
});
it("installs from a clean machine, populates all three targets, and leaves user dirs alone", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.agentsDir, "unslop", { "SKILL.md": "user-unslop" });
const status = await installSkills(sandbox.targets);
expect(status).toEqual({ state: "up-to-date", ops: [] });
for (const name of ["paseo", "paseo-loop"]) {
expect(
await fs.readFile(path.join(sandbox.targets.agentsDir, name, "SKILL.md"), "utf-8"),
).toBe(name === "paseo" ? "paseo-v1" : "loop-v1");
expect(
await fs.readFile(path.join(sandbox.targets.codexDir, name, "SKILL.md"), "utf-8"),
).toBe(name === "paseo" ? "paseo-v1" : "loop-v1");
expect(await pathExists(path.join(sandbox.targets.claudeDir, name))).toBe(true);
}
expect(
await fs.readFile(path.join(sandbox.targets.agentsDir, "unslop", "SKILL.md"), "utf-8"),
).toBe("user-unslop");
});
it("converges to up-to-date when state has missing + edited + legacy skills", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "stale" });
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-chat", { "SKILL.md": "chat-old" });
await writeOnDiskSkill(sandbox.targets.claudeDir, "paseo-chat", { "SKILL.md": "chat-old" });
await writeOnDiskSkill(sandbox.targets.codexDir, "paseo-chat", { "SKILL.md": "chat-old" });
const status = await updateSkills(sandbox.targets);
expect(status).toEqual({ state: "up-to-date", ops: [] });
expect(
await fs.readFile(path.join(sandbox.targets.agentsDir, "paseo", "SKILL.md"), "utf-8"),
).toBe("paseo-v1");
expect(
await fs.readFile(path.join(sandbox.targets.agentsDir, "paseo-loop", "SKILL.md"), "utf-8"),
).toBe("loop-v1");
for (const dir of [
sandbox.targets.agentsDir,
sandbox.targets.claudeDir,
sandbox.targets.codexDir,
]) {
expect(await pathExists(path.join(dir, "paseo-chat"))).toBe(false);
}
});
it("is idempotent — running install twice keeps state at up-to-date", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
const first = await installSkills(sandbox.targets);
const second = await installSkills(sandbox.targets);
expect(first).toEqual({ state: "up-to-date", ops: [] });
expect(second).toEqual({ state: "up-to-date", ops: [] });
});
});
describe("uninstallSkills", () => {
let sandbox: Sandbox;
beforeEach(async () => {
sandbox = await makeSandbox();
});
afterEach(async () => {
await fs.rm(sandbox.root, { recursive: true, force: true });
});
it("removes every Paseo skill from all three targets and preserves user dirs", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await installSkills(sandbox.targets);
for (const name of ["unslop", "tdd", "devbox"]) {
await writeOnDiskSkill(sandbox.targets.agentsDir, name, { "SKILL.md": `user-${name}` });
}
const status = await uninstallSkills(sandbox.targets);
expect(status.state).toBe("not-installed");
for (const name of PASEO_SKILL_NAMES) {
expect(await pathExists(path.join(sandbox.targets.agentsDir, name))).toBe(false);
expect(await pathExists(path.join(sandbox.targets.claudeDir, name))).toBe(false);
expect(await pathExists(path.join(sandbox.targets.codexDir, name))).toBe(false);
}
for (const name of ["unslop", "tdd", "devbox"]) {
expect(
await fs.readFile(path.join(sandbox.targets.agentsDir, name, "SKILL.md"), "utf-8"),
).toBe(`user-${name}`);
}
});
it("is a no-op when nothing is installed", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
const status = await uninstallSkills(sandbox.targets);
expect(status.state).toBe("not-installed");
});
it("cleans up legacy skill names that linger in agents, claude, and codex", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
for (const dir of [
sandbox.targets.agentsDir,
sandbox.targets.claudeDir,
sandbox.targets.codexDir,
]) {
await writeOnDiskSkill(dir, "paseo-chat", { "SKILL.md": "chat-old" });
}
const status = await uninstallSkills(sandbox.targets);
expect(status.state).toBe("not-installed");
for (const dir of [
sandbox.targets.agentsDir,
sandbox.targets.claudeDir,
sandbox.targets.codexDir,
]) {
expect(await pathExists(path.join(dir, "paseo-chat"))).toBe(false);
}
});
});
@@ -0,0 +1,164 @@
import { createHash } from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
import {
getAgentsSkillsDir,
getBundledSkillsDir,
getClaudeSkillsDir,
getCodexSkillsDir,
} from "./paths.js";
import { listFilesRecursive, removeSkill, syncSkills } from "./sync.js";
export type SkillsState = "not-installed" | "up-to-date" | "drift";
export type SkillOp =
| { kind: "add"; name: string }
| { kind: "update"; name: string }
| { kind: "delete"; name: string };
export interface SkillsStatus {
state: SkillsState;
ops: SkillOp[];
}
export interface SkillTargets {
sourceDir: string;
agentsDir: string;
claudeDir: string;
codexDir: string;
}
export const PASEO_SKILL_NAMES = [
"paseo",
"paseo-advisor",
"paseo-chat",
"paseo-committee",
"paseo-epic",
"paseo-handoff",
"paseo-loop",
"paseo-orchestrate",
"paseo-orchestrator",
] as const;
type SkillFiles = Map<string, string>;
function resolveSkillTargets(): SkillTargets {
return {
sourceDir: getBundledSkillsDir(),
agentsDir: getAgentsSkillsDir(),
claudeDir: getClaudeSkillsDir(),
codexDir: getCodexSkillsDir(),
};
}
async function hashSkillDir(skillDir: string): Promise<SkillFiles | null> {
const stat = await fs.stat(skillDir).catch(() => null);
if (!stat?.isDirectory()) return null;
const rels = await listFilesRecursive(skillDir);
const files: SkillFiles = new Map();
for (const rel of rels) {
const buf = await fs.readFile(path.join(skillDir, rel));
const sha = createHash("sha256").update(buf).digest("hex");
files.set(toPosix(rel), sha);
}
return files;
}
async function hashSkills(rootDir: string): Promise<Map<string, SkillFiles>> {
const out = new Map<string, SkillFiles>();
for (const name of PASEO_SKILL_NAMES) {
const files = await hashSkillDir(path.join(rootDir, name));
if (files !== null) out.set(name, files);
}
return out;
}
function diff(bundle: Map<string, SkillFiles>, disk: Map<string, SkillFiles>): SkillOp[] {
const ops: SkillOp[] = [];
for (const name of PASEO_SKILL_NAMES) {
const b = bundle.get(name);
const d = disk.get(name);
if (b && !d) ops.push({ kind: "add", name });
else if (b && d && !filesEqual(b, d)) ops.push({ kind: "update", name });
else if (!b && d) ops.push({ kind: "delete", name });
}
ops.sort((a, b) => compareStrings(a.name, b.name));
return ops;
}
function filesEqual(a: SkillFiles, b: SkillFiles): boolean {
if (a.size !== b.size) return false;
for (const [rel, sha] of a) {
if (b.get(rel) !== sha) return false;
}
return true;
}
function toPosix(p: string): string {
return p.split(path.sep).join("/");
}
function compareStrings(a: string, b: string): number {
if (a < b) return -1;
if (a > b) return 1;
return 0;
}
export async function getSkillsStatus(targets?: SkillTargets): Promise<SkillsStatus> {
const t = targets ?? resolveSkillTargets();
const [bundle, disk] = await Promise.all([hashSkills(t.sourceDir), hashSkills(t.agentsDir)]);
const ops = diff(bundle, disk);
if (disk.size === 0) return { state: "not-installed", ops };
if (ops.length === 0) return { state: "up-to-date", ops };
return { state: "drift", ops };
}
async function applySkills(targets: SkillTargets): Promise<SkillsStatus> {
const status = await getSkillsStatus(targets);
const writes = status.ops
.filter((op) => op.kind === "add" || op.kind === "update")
.map((op) => op.name);
if (writes.length > 0) {
await syncSkills({
sourceDir: targets.sourceDir,
agentsDir: targets.agentsDir,
claudeDir: targets.claudeDir,
codexDir: targets.codexDir,
skillNames: writes,
});
}
for (const op of status.ops) {
if (op.kind !== "delete") continue;
await removeSkill(op.name, {
agentsDir: targets.agentsDir,
claudeDir: targets.claudeDir,
codexDir: targets.codexDir,
});
}
return getSkillsStatus(targets);
}
export async function installSkills(targets?: SkillTargets): Promise<SkillsStatus> {
return applySkills(targets ?? resolveSkillTargets());
}
export async function updateSkills(targets?: SkillTargets): Promise<SkillsStatus> {
return applySkills(targets ?? resolveSkillTargets());
}
export async function uninstallSkills(targets?: SkillTargets): Promise<SkillsStatus> {
const t = targets ?? resolveSkillTargets();
for (const name of PASEO_SKILL_NAMES) {
await removeSkill(name, {
agentsDir: t.agentsDir,
claudeDir: t.claudeDir,
codexDir: t.codexDir,
});
}
return getSkillsStatus(t);
}
@@ -0,0 +1,22 @@
import path from "node:path";
import os from "node:os";
import { app } from "electron";
export function getBundledSkillsDir(): string {
if (app.isPackaged) {
return path.join(process.resourcesPath, "skills");
}
return path.join(__dirname, "..", "..", "..", "..", "..", "skills");
}
export function getAgentsSkillsDir(): string {
return path.join(os.homedir(), ".agents", "skills");
}
export function getClaudeSkillsDir(): string {
return path.join(os.homedir(), ".claude", "skills");
}
export function getCodexSkillsDir(): string {
return path.join(os.homedir(), ".codex", "skills");
}
@@ -0,0 +1,304 @@
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { removeSkill, syncSkills } from "./sync";
interface Sandbox {
root: string;
sourceDir: string;
agentsDir: string;
claudeDir: string;
codexDir: string;
}
async function makeSandbox(): Promise<Sandbox> {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-skill-sync-"));
const sourceDir = path.join(root, "bundle");
const agentsDir = path.join(root, "home", ".agents", "skills");
const claudeDir = path.join(root, "home", ".claude", "skills");
const codexDir = path.join(root, "home", ".codex", "skills");
await fs.mkdir(sourceDir, { recursive: true });
return { root, sourceDir, agentsDir, claudeDir, codexDir };
}
async function writeBundleSkill(
sourceDir: string,
name: string,
files: Record<string, string>,
): Promise<void> {
const skillDir = path.join(sourceDir, name);
await fs.mkdir(skillDir, { recursive: true });
for (const [rel, content] of Object.entries(files)) {
const full = path.join(skillDir, rel);
await fs.mkdir(path.dirname(full), { recursive: true });
await fs.writeFile(full, content);
}
}
describe("syncSkills", () => {
let sandbox: Sandbox;
beforeEach(async () => {
sandbox = await makeSandbox();
});
afterEach(async () => {
await fs.rm(sandbox.root, { recursive: true, force: true });
});
it("overwrites on-disk skill content when the bundle differs", async () => {
await writeBundleSkill(sandbox.sourceDir, "paseo", {
"SKILL.md": "new paseo content",
});
const onDiskSkill = path.join(sandbox.agentsDir, "paseo");
await fs.mkdir(onDiskSkill, { recursive: true });
await fs.writeFile(path.join(onDiskSkill, "SKILL.md"), "old paseo content");
const result = await syncSkills({
sourceDir: sandbox.sourceDir,
agentsDir: sandbox.agentsDir,
claudeDir: sandbox.claudeDir,
codexDir: sandbox.codexDir,
skillNames: ["paseo"],
});
expect(result.processedSkills).toBe(1);
expect(result.changedFiles).toBeGreaterThan(0);
const agentsContent = await fs.readFile(
path.join(sandbox.agentsDir, "paseo", "SKILL.md"),
"utf-8",
);
expect(agentsContent).toBe("new paseo content");
const claudeContent = await fs.readFile(
path.join(sandbox.claudeDir, "paseo", "SKILL.md"),
"utf-8",
);
expect(claudeContent).toBe("new paseo content");
const codexContent = await fs.readFile(
path.join(sandbox.codexDir, "paseo", "SKILL.md"),
"utf-8",
);
expect(codexContent).toBe("new paseo content");
});
it("installs new bundled skills, including references/, when not present on disk", async () => {
await writeBundleSkill(sandbox.sourceDir, "paseo-epic", {
"SKILL.md": "epic content",
"references/roles.md": "roles content",
});
await syncSkills({
sourceDir: sandbox.sourceDir,
agentsDir: sandbox.agentsDir,
claudeDir: sandbox.claudeDir,
codexDir: sandbox.codexDir,
skillNames: ["paseo-epic"],
});
expect(await fs.readFile(path.join(sandbox.agentsDir, "paseo-epic", "SKILL.md"), "utf-8")).toBe(
"epic content",
);
expect(
await fs.readFile(
path.join(sandbox.agentsDir, "paseo-epic", "references", "roles.md"),
"utf-8",
),
).toBe("roles content");
expect(
await fs.readFile(
path.join(sandbox.codexDir, "paseo-epic", "references", "roles.md"),
"utf-8",
),
).toBe("roles content");
const claudeSkillDir = path.join(sandbox.claudeDir, "paseo-epic");
expect((await fs.lstat(claudeSkillDir)).isDirectory()).toBe(true);
expect(await fs.readFile(path.join(claudeSkillDir, "SKILL.md"), "utf-8")).toBe("epic content");
expect(await fs.readFile(path.join(claudeSkillDir, "references", "roles.md"), "utf-8")).toBe(
"roles content",
);
});
it("leaves on-disk skills not in the bundle untouched", async () => {
await writeBundleSkill(sandbox.sourceDir, "paseo", { "SKILL.md": "new" });
const customSkill = path.join(sandbox.agentsDir, "user-custom-skill");
await fs.mkdir(customSkill, { recursive: true });
await fs.writeFile(path.join(customSkill, "SKILL.md"), "user content");
await syncSkills({
sourceDir: sandbox.sourceDir,
agentsDir: sandbox.agentsDir,
claudeDir: sandbox.claudeDir,
codexDir: sandbox.codexDir,
skillNames: ["paseo"],
});
expect(await fs.readFile(path.join(customSkill, "SKILL.md"), "utf-8")).toBe("user content");
});
it("does not delete files on disk that are no longer in the bundle", async () => {
await writeBundleSkill(sandbox.sourceDir, "paseo", { "SKILL.md": "new" });
const onDiskSkill = path.join(sandbox.agentsDir, "paseo");
await fs.mkdir(path.join(onDiskSkill, "references"), { recursive: true });
await fs.writeFile(path.join(onDiskSkill, "SKILL.md"), "old");
await fs.writeFile(path.join(onDiskSkill, "references", "stale.md"), "stale");
await syncSkills({
sourceDir: sandbox.sourceDir,
agentsDir: sandbox.agentsDir,
claudeDir: sandbox.claudeDir,
codexDir: sandbox.codexDir,
skillNames: ["paseo"],
});
expect(await fs.readFile(path.join(onDiskSkill, "SKILL.md"), "utf-8")).toBe("new");
expect(await fs.readFile(path.join(onDiskSkill, "references", "stale.md"), "utf-8")).toBe(
"stale",
);
});
it("reports zero changed files on a no-op resync", async () => {
await writeBundleSkill(sandbox.sourceDir, "paseo", {
"SKILL.md": "content",
"references/extra.md": "ref",
});
const first = await syncSkills({
sourceDir: sandbox.sourceDir,
agentsDir: sandbox.agentsDir,
claudeDir: sandbox.claudeDir,
codexDir: sandbox.codexDir,
skillNames: ["paseo"],
});
expect(first.changedFiles).toBeGreaterThan(0);
const second = await syncSkills({
sourceDir: sandbox.sourceDir,
agentsDir: sandbox.agentsDir,
claudeDir: sandbox.claudeDir,
codexDir: sandbox.codexDir,
skillNames: ["paseo"],
});
expect(second.changedFiles).toBe(0);
});
it("skips skills listed in skillNames that are missing from the bundle without raising", async () => {
await writeBundleSkill(sandbox.sourceDir, "paseo", { "SKILL.md": "content" });
// "paseo-removed" is in skillNames but not in the bundle on disk.
const errors: Array<{ name: string; error: unknown }> = [];
const result = await syncSkills({
sourceDir: sandbox.sourceDir,
agentsDir: sandbox.agentsDir,
claudeDir: sandbox.claudeDir,
codexDir: sandbox.codexDir,
skillNames: ["paseo", "paseo-removed"],
onSkillError: (name, error) => errors.push({ name, error }),
});
expect(errors).toEqual([]);
expect(result.processedSkills).toBe(1);
expect(await fs.readFile(path.join(sandbox.agentsDir, "paseo", "SKILL.md"), "utf-8")).toBe(
"content",
);
await expect(fs.access(path.join(sandbox.agentsDir, "paseo-removed"))).rejects.toThrow();
});
it("leaves on-disk skill content alone when the skill has been removed from the bundle", async () => {
await writeBundleSkill(sandbox.sourceDir, "paseo", { "SKILL.md": "current" });
const deprecatedDir = path.join(sandbox.agentsDir, "paseo-deprecated");
await fs.mkdir(deprecatedDir, { recursive: true });
await fs.writeFile(path.join(deprecatedDir, "SKILL.md"), "old content");
await syncSkills({
sourceDir: sandbox.sourceDir,
agentsDir: sandbox.agentsDir,
claudeDir: sandbox.claudeDir,
codexDir: sandbox.codexDir,
skillNames: ["paseo", "paseo-deprecated"],
});
expect(await fs.readFile(path.join(deprecatedDir, "SKILL.md"), "utf-8")).toBe("old content");
});
it("does not crash when the source bundle directory is missing", async () => {
const missingSourceDir = path.join(sandbox.root, "no-bundle-here");
const errors: string[] = [];
const result = await syncSkills({
sourceDir: missingSourceDir,
agentsDir: sandbox.agentsDir,
claudeDir: sandbox.claudeDir,
codexDir: sandbox.codexDir,
skillNames: ["paseo"],
onSkillError: (skillName) => errors.push(skillName),
});
expect(errors).toEqual([]);
expect(result).toEqual({ changedFiles: 0, processedSkills: 0 });
});
it("reports per-skill errors via onSkillError without throwing", async () => {
await writeBundleSkill(sandbox.sourceDir, "paseo", { "SKILL.md": "content" });
// Make agents skill path a file (not a directory) to force a write error
await fs.mkdir(sandbox.agentsDir, { recursive: true });
await fs.writeFile(path.join(sandbox.agentsDir, "paseo"), "blocking file");
const errors: string[] = [];
const result = await syncSkills({
sourceDir: sandbox.sourceDir,
agentsDir: sandbox.agentsDir,
claudeDir: sandbox.claudeDir,
codexDir: sandbox.codexDir,
skillNames: ["paseo"],
onSkillError: (skillName) => errors.push(skillName),
});
expect(errors).toContain("paseo");
expect(result.processedSkills).toBe(0);
});
});
describe("removeSkill", () => {
let sandbox: Sandbox;
beforeEach(async () => {
sandbox = await makeSandbox();
});
afterEach(async () => {
await fs.rm(sandbox.root, { recursive: true, force: true });
});
it("removes the skill from all three targets when present", async () => {
await writeBundleSkill(sandbox.sourceDir, "paseo", { "SKILL.md": "content" });
await syncSkills({
sourceDir: sandbox.sourceDir,
agentsDir: sandbox.agentsDir,
claudeDir: sandbox.claudeDir,
codexDir: sandbox.codexDir,
skillNames: ["paseo"],
});
await removeSkill("paseo", {
agentsDir: sandbox.agentsDir,
claudeDir: sandbox.claudeDir,
codexDir: sandbox.codexDir,
});
await expect(fs.access(path.join(sandbox.agentsDir, "paseo"))).rejects.toThrow();
await expect(fs.access(path.join(sandbox.claudeDir, "paseo"))).rejects.toThrow();
await expect(fs.access(path.join(sandbox.codexDir, "paseo"))).rejects.toThrow();
});
it("does not throw when targets are missing", async () => {
await expect(
removeSkill("does-not-exist", {
agentsDir: sandbox.agentsDir,
claudeDir: sandbox.claudeDir,
codexDir: sandbox.codexDir,
}),
).resolves.toBeUndefined();
});
});
@@ -0,0 +1,106 @@
import { promises as fs } from "node:fs";
import path from "node:path";
export interface SkillSyncOptions {
sourceDir: string;
agentsDir: string;
claudeDir: string;
codexDir: string;
skillNames: readonly string[];
onSkillError?: (skillName: string, error: unknown) => void;
}
export interface SkillSyncResult {
changedFiles: number;
processedSkills: number;
}
async function writeFileIfChanged(srcPath: string, dstPath: string): Promise<boolean> {
const src = await fs.readFile(srcPath);
const dst = await fs.readFile(dstPath).catch(() => null);
if (dst && src.equals(dst)) return false;
await fs.mkdir(path.dirname(dstPath), { recursive: true });
await fs.writeFile(dstPath, src);
return true;
}
export async function listFilesRecursive(rootDir: string): Promise<string[]> {
const out: string[] = [];
async function walk(dir: string): Promise<void> {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
await walk(full);
} else if (entry.isFile()) {
out.push(path.relative(rootDir, full));
}
}
}
await walk(rootDir);
return out;
}
async function syncDirectoryFiles(srcDir: string, dstDir: string): Promise<number> {
const files = await listFilesRecursive(srcDir);
let changed = 0;
for (const rel of files) {
if (await writeFileIfChanged(path.join(srcDir, rel), path.join(dstDir, rel))) {
changed++;
}
}
return changed;
}
export interface RemoveSkillTargets {
agentsDir: string;
claudeDir: string;
codexDir: string;
}
export async function removeSkill(skillName: string, targets: RemoveSkillTargets): Promise<void> {
const paths = [
path.join(targets.agentsDir, skillName),
path.join(targets.claudeDir, skillName),
path.join(targets.codexDir, skillName),
];
for (const p of paths) {
await fs.rm(p, { recursive: true, force: true });
}
}
export async function syncSkills(options: SkillSyncOptions): Promise<SkillSyncResult> {
let changedFiles = 0;
let processedSkills = 0;
for (const skillName of options.skillNames) {
const bundleSkillDir = path.join(options.sourceDir, skillName);
const bundleStat = await fs.stat(bundleSkillDir).catch(() => null);
if (!bundleStat?.isDirectory()) continue;
try {
changedFiles += await syncDirectoryFiles(
bundleSkillDir,
path.join(options.agentsDir, skillName),
);
changedFiles += await syncDirectoryFiles(
bundleSkillDir,
path.join(options.claudeDir, skillName),
);
changedFiles += await syncDirectoryFiles(
bundleSkillDir,
path.join(options.codexDir, skillName),
);
processedSkills++;
} catch (error) {
if (!options.onSkillError) throw error;
options.onSkillError(skillName, error);
}
}
return { changedFiles, processedSkills };
}