Manage orchestration skills per host (#3451)

* feat(skills): manage orchestration skills per host

Agent skills execute on the daemon host, so their bundle, selection, and filesystem convergence must share that owner. Preserve the existing install semantics while letting every capable client manage the selected host and migrating the legacy desktop preference once.

* fix(client): keep skill management internal

Keep host skill management on the low-level daemon client used by the app. Do not expose daemon settings or the desktop-only compatibility migration through the shared Paseo API handed to plugins.

* fix(e2e): declare skills fixture dependencies

Use Playwright's required destructuring form for the test-scoped daemon sandbox fixture so CI can collect the real-host skills suite.

* test(skills): use native managed paths

Assert fixed skill targets relative to the daemon user's real home with platform-native path joining so the ownership contract holds on Windows and POSIX hosts.
This commit is contained in:
Mohamed Boudra
2026-08-18 09:51:46 +02:00
committed by GitHub
parent eb3591cee8
commit 4108960709
71 changed files with 2225 additions and 2598 deletions
@@ -1,77 +0,0 @@
import { expect, test, type Page } from "../../app/e2e/support/fixtures";
import { gotoAppShell } from "../../app/e2e/support/helpers/app";
import {
readVerticalGap,
waitForSettledPosition,
} from "../../app/e2e/support/helpers/sheet-layout";
import {
openCompactSkillsIntegrations,
openSkillSelection,
openSkillsIntegrations,
startSkillsSandbox,
type SkillsSandbox,
} from "./support/skills-selection";
const COMPACT_VIEWPORT = { width: 390, height: 844 };
const DESKTOP_VIEWPORT = { width: 1280, height: 900 };
const sandboxes: SkillsSandbox[] = [];
test.afterEach(async () => {
for (const sandbox of sandboxes.splice(0)) {
await sandbox.cleanup();
}
});
async function openChooseSkillsOnPhone(page: Page): Promise<void> {
sandboxes.push(await startSkillsSandbox(page));
await gotoAppShell(page);
await openCompactSkillsIntegrations(page);
await openSkillSelection(page);
await waitForSettledPosition(page.getByTestId("skill-selection-sheet"));
}
async function openChooseSkillsOnDesktop(page: Page): Promise<void> {
sandboxes.push(await startSkillsSandbox(page));
await gotoAppShell(page);
await openSkillsIntegrations(page);
await openSkillSelection(page);
}
function allSkillsCard(page: Page) {
return page.getByTestId("skill-selection-all-card");
}
function skillListCard(page: Page) {
return page.getByTestId("skill-selection-list-card");
}
function bundledSkillsLabel(page: Page) {
return page.getByText("Bundled skills", { exact: true });
}
test.describe("Choose skills sheet on a compact form factor", () => {
test.use({ viewport: COMPACT_VIEWPORT });
test("gives the Bundled skills label more room above it than below", async ({ page }) => {
await openChooseSkillsOnPhone(page);
expect({
aboveLabel: await readVerticalGap(allSkillsCard(page), bundledSkillsLabel(page)),
belowLabel: await readVerticalGap(bundledSkillsLabel(page), skillListCard(page)),
}).toEqual({ aboveLabel: 16, belowLabel: 12 });
});
});
test.describe("Choose skills sheet on a desktop form factor", () => {
test.use({ viewport: DESKTOP_VIEWPORT });
test("gives the Bundled skills label more room above it than below", async ({ page }) => {
await openChooseSkillsOnDesktop(page);
expect({
aboveLabel: await readVerticalGap(allSkillsCard(page), bundledSkillsLabel(page)),
belowLabel: await readVerticalGap(bundledSkillsLabel(page), skillListCard(page)),
}).toEqual({ aboveLabel: 16, belowLabel: 12 });
});
});
@@ -1,333 +0,0 @@
import { test as base } from "../../app/e2e/support/fixtures";
import { expect } from "@playwright/test";
import { gotoAppShell } from "../../app/e2e/support/helpers/app";
import { installDesktopRuntime } from "./support/runtime";
import { getServerId } from "../../app/e2e/support/helpers/server-id";
import {
cancelSkillSelection,
chooseAllSkills,
chooseCustomSkills,
createSkillsSandbox,
expectAllSkillsSelected,
expectNoRemovalWarning,
expectRemovalWarning,
expectSavedSkillSelection,
expectSaveErrorKeepsSheetOpen,
expectSelectedSkills,
expectSkillChoices,
expectSkillFilePresent,
expectSkillPresentIn,
expectSkillSelectionOpen,
expectSkillsInstalled,
openSkillsIntegrations,
openSkillSelection,
saveSkillSelection,
toggleSkill,
serveRealSkillsCommands,
type SkillsSandbox,
type SkillsSandboxOptions,
} from "./support/skills-selection";
interface SkillsSandboxSetup extends SkillsSandboxOptions {
/** What the desktop confirm dialog answers when the removal warning fires. */
confirmRemoval?: boolean;
}
type StartSkills = (setup?: SkillsSandboxSetup) => Promise<SkillsSandbox>;
// Every skills command runs against the real desktop handlers over a temp bundle
// and temp user data, so the assertions cover persistence and convergence, not a
// browser stand-in for them.
const test = base.extend<{ startSkills: StartSkills }>({
startSkills: async ({ page }, provide) => {
const sandboxes: SkillsSandbox[] = [];
await provide(async (setup = {}) => {
const { confirmRemoval, ...options } = setup;
const sandbox = await createSkillsSandbox(options);
await installDesktopRuntime(page, {
serverId: getServerId(),
confirmShouldAccept: confirmRemoval ?? false,
});
await serveRealSkillsCommands(page, sandbox);
sandboxes.push(sandbox);
return sandbox;
});
for (const sandbox of sandboxes) {
await sandbox.cleanup();
}
},
});
test.describe("Choosing installed skills", () => {
test("installs only the skills selected in Settings", async ({ page, startSkills }) => {
const skills = await startSkills();
await gotoAppShell(page);
await openSkillsIntegrations(page);
await openSkillSelection(page);
await chooseCustomSkills(page, ["paseo", "paseo-loop"]);
await saveSkillSelection(page);
await expectSkillsInstalled(skills, ["paseo", "paseo-loop"]);
await expectSelectedSkills(page, ["paseo", "paseo-loop"]);
});
test("all skills includes every available skill", async ({ page, startSkills }) => {
const skills = await startSkills();
await gotoAppShell(page);
await openSkillsIntegrations(page);
await openSkillSelection(page);
await chooseCustomSkills(page, ["paseo"]);
await saveSkillSelection(page);
await openSkillSelection(page);
await chooseAllSkills(page);
await saveSkillSelection(page);
await expectSkillsInstalled(skills, skills.bundledSkills);
await expectAllSkillsSelected(page);
});
test("cancelling leaves the installed skills untouched", async ({ page, startSkills }) => {
const skills = await startSkills();
await gotoAppShell(page);
await openSkillsIntegrations(page);
await openSkillSelection(page);
await chooseCustomSkills(page, ["paseo"]);
await saveSkillSelection(page);
await openSkillSelection(page);
await toggleSkill(page, "paseo-loop");
await cancelSkillSelection(page);
await expectSkillsInstalled(skills, ["paseo"]);
await expectSelectedSkills(page, ["paseo"]);
});
test("a failed save keeps the sheet open with an error", async ({ page, startSkills }) => {
const skills = await startSkills({ blockAgentsDir: true });
await gotoAppShell(page);
await openSkillsIntegrations(page);
await openSkillSelection(page);
await chooseCustomSkills(page, ["paseo"]);
await saveSkillSelection(page);
await expectSaveErrorKeepsSheetOpen(page);
await expectSkillsInstalled(skills, []);
});
test("an empty selection does not offer an Install action that cannot install anything", async ({
page,
startSkills,
}) => {
await startSkills({ installed: { mode: "all" }, confirmRemoval: true });
await gotoAppShell(page);
await openSkillsIntegrations(page);
await openSkillSelection(page);
await chooseCustomSkills(page, []);
await saveSkillSelection(page);
// The remaining Install action belongs to the command-line integration.
await expect(page.getByRole("button", { name: "Install", exact: true })).toHaveCount(1);
await expect(page.getByRole("button", { name: "Choose skills", exact: true })).toBeVisible();
});
test("a selection containing only retired skills does not offer a no-op Install", async ({
page,
startSkills,
}) => {
await startSkills({ installed: { mode: "custom", skills: ["retired-skill"] } });
await gotoAppShell(page);
await openSkillsIntegrations(page);
// The remaining Install action belongs to the command-line integration.
await expect(page.getByRole("button", { name: "Install", exact: true })).toHaveCount(1);
await expect(page.getByRole("button", { name: "Choose skills", exact: true })).toBeVisible();
});
test("an empty bundled catalog does not offer a no-op Install", async ({ page, startSkills }) => {
await startSkills({ bundledSkills: [] });
await gotoAppShell(page);
await openSkillsIntegrations(page);
// The remaining Install action belongs to the command-line integration.
await expect(page.getByRole("button", { name: "Install", exact: true })).toHaveCount(1);
await expect(page.getByRole("button", { name: "Choose skills", exact: true })).toBeVisible();
});
test("cannot dismiss the sheet while Save is pending", async ({ page, startSkills }) => {
const skills = await startSkills({ holdSave: true });
await gotoAppShell(page);
await openSkillsIntegrations(page);
await openSkillSelection(page);
await chooseCustomSkills(page, ["paseo"]);
await saveSkillSelection(page);
await skills.waitForHeldSave();
await page.keyboard.press("Escape");
await expectSkillSelectionOpen(page);
skills.releaseHeldSave();
await expect(page.getByRole("switch", { name: "All skills", exact: true })).toHaveCount(0);
});
});
test.describe("Removing an installed skill", () => {
test("delete-only drift stays in Choose skills instead of Update", async ({
page,
startSkills,
}) => {
await startSkills({
installed: { mode: "custom", skills: ["paseo"] },
placeOnDisk: {
skill: "paseo-loop",
target: "claude",
files: { "SKILL.md": "# paseo-loop\n", "notes/mine.md": "my notes" },
},
});
await gotoAppShell(page);
await openSkillsIntegrations(page);
await expect(page.getByText("Update available", { exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Update", exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Choose skills", exact: true })).toBeVisible();
});
test("warns before deleting the skill folders", async ({ page, startSkills }) => {
const skills = await startSkills({ installed: { mode: "all" }, confirmRemoval: false });
await gotoAppShell(page);
await openSkillsIntegrations(page);
await openSkillSelection(page);
await chooseCustomSkills(page, ["paseo", "paseo-advisor"]);
await saveSkillSelection(page);
await expectRemovalWarning(page, ["paseo-loop"]);
await expectSkillSelectionOpen(page);
await expectSkillsInstalled(skills, skills.bundledSkills);
await expectSavedSkillSelection(skills, { mode: "all" });
});
test("confirming the warning removes the skill", async ({ page, startSkills }) => {
const skills = await startSkills({ installed: { mode: "all" }, confirmRemoval: true });
await gotoAppShell(page);
await openSkillsIntegrations(page);
await openSkillSelection(page);
await chooseCustomSkills(page, ["paseo", "paseo-advisor"]);
await saveSkillSelection(page);
await expectRemovalWarning(page, ["paseo-loop"]);
await expectSkillsInstalled(skills, ["paseo", "paseo-advisor"]);
await expectSelectedSkills(page, ["paseo", "paseo-advisor"]);
});
test("warns for a skill installed in only one agent directory", async ({ page, startSkills }) => {
const skills = await startSkills({
installed: { mode: "all" },
keepOnlyIn: { skill: "paseo-loop", target: "claude" },
confirmRemoval: false,
});
await gotoAppShell(page);
await openSkillsIntegrations(page);
await openSkillSelection(page);
await chooseCustomSkills(page, ["paseo", "paseo-advisor"]);
await saveSkillSelection(page);
await expectRemovalWarning(page, ["paseo-loop"]);
await expectSkillSelectionOpen(page);
await expectSkillPresentIn(skills, "claude", "paseo-loop");
await expectSavedSkillSelection(skills, { mode: "all" });
});
test("warns for a skill on disk that the saved selection never mentioned", async ({
page,
startSkills,
}) => {
// The committed preference excludes paseo-loop, but the directory is back —
// a manual reinstall or an external sync. Saving any edit deletes it.
const skills = await startSkills({
installed: { mode: "custom", skills: ["paseo"] },
placeOnDisk: {
skill: "paseo-loop",
target: "claude",
files: { "SKILL.md": "# paseo-loop\n", "notes/mine.md": "my notes" },
},
confirmRemoval: false,
});
await gotoAppShell(page);
await openSkillsIntegrations(page);
await openSkillSelection(page);
await toggleSkill(page, "paseo-advisor");
await saveSkillSelection(page);
await expectRemovalWarning(page, ["paseo-loop"]);
await expectSkillSelectionOpen(page);
await expectSkillFilePresent(skills, "claude", "paseo-loop", "notes/mine.md");
await expectSavedSkillSelection(skills, { mode: "custom", skills: ["paseo"] });
});
test("warns about a directory that appears after the sheet was opened", async ({
page,
startSkills,
}) => {
const skills = await startSkills({
installed: { mode: "custom", skills: ["paseo"] },
confirmRemoval: false,
});
await gotoAppShell(page);
await openSkillsIntegrations(page);
await openSkillSelection(page);
// Nothing to delete when the sheet opened; the host looks again at save time.
await skills.placeSkillOnDisk("paseo-loop", "claude");
await toggleSkill(page, "paseo-advisor");
await saveSkillSelection(page);
await expectRemovalWarning(page, ["paseo-loop"]);
await expectSkillSelectionOpen(page);
await expectSkillPresentIn(skills, "claude", "paseo-loop");
await expectSavedSkillSelection(skills, { mode: "custom", skills: ["paseo"] });
});
test("keeps draft choices when fresher status arrives while the sheet is open", async ({
page,
startSkills,
}) => {
const skills = await startSkills({ installed: { mode: "all" }, confirmRemoval: false });
await gotoAppShell(page);
await openSkillsIntegrations(page);
await openSkillSelection(page);
await chooseCustomSkills(page, ["paseo", "paseo-advisor"]);
// Another window commits a different selection. Saving picks that up, so the
// sheet is handed a selection it did not start from while an edit is open.
await skills.commitSelectionExternally({ mode: "custom", skills: ["paseo", "paseo-loop"] }, [
"paseo-advisor",
]);
await saveSkillSelection(page);
await expectRemovalWarning(page, ["paseo-loop"]);
await expectSkillChoices(page, ["paseo", "paseo-advisor"]);
});
test("adding a skill saves without a warning", async ({ page, startSkills }) => {
const skills = await startSkills({
installed: { mode: "custom", skills: ["paseo"] },
confirmRemoval: false,
});
await gotoAppShell(page);
await openSkillsIntegrations(page);
await openSkillSelection(page);
await toggleSkill(page, "paseo-loop");
await saveSkillSelection(page);
await expectSkillsInstalled(skills, ["paseo", "paseo-loop"]);
await expectNoRemovalWarning(page);
});
});
@@ -1,410 +0,0 @@
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { expect, type Page } from "@playwright/test";
import { buildOpenProjectRoute } from "../../../app/src/utils/host-routes";
import { openSettings } from "../../../app/e2e/support/helpers/app";
import { installDesktopRuntime } from "./runtime";
import { getServerId } from "../../../app/e2e/support/helpers/server-id";
import {
openCompactSettings,
openSettingsSection,
} from "../../../app/e2e/support/helpers/settings";
// The desktop skills module is the real command surface: the same handlers the
// Electron main process registers, running against a temp bundle and temp user
// data. Nothing about persistence or convergence is simulated in the browser.
import type { SkillSelection, SkillTargets } from "../../src/integrations/skills/operations.js";
import { createSkillSelectionStore } from "../../src/integrations/skills/selection-store.js";
import { createSkillsCommandHandlers } from "../../src/integrations/skills/skills-commands.js";
import { createSkillsController } from "../../src/integrations/skills/skills-controller.js";
const SKILL_COMMANDS = [
"get_skills_status",
"install_skills",
"update_skills",
"uninstall_skills",
"save_skills_selection",
] as const;
declare global {
interface Window {
__paseoSkillsInvoke?: (
command: string,
args: Record<string, unknown> | null,
) => Promise<unknown>;
}
}
export interface SkillsSandbox {
targets: SkillTargets;
userDataPath: string;
bundledSkills: string[];
/** Writes a managed skill directory the way an external tool would. */
placeSkillOnDisk: (skill: string, target: SkillTargetName) => Promise<void>;
/** Commits a selection through the real controller, as another window would. */
commitSelectionExternally: (
selection: SkillSelection,
confirmedRemovals?: readonly string[],
) => Promise<void>;
/** Waits until a held save reaches the desktop command boundary. */
waitForHeldSave: () => Promise<void>;
/** Releases a save held at the desktop command boundary. */
releaseHeldSave: () => void;
/** Test-only command-boundary gate used to keep the renderer in its pending state. */
holdSaveResponse?: () => Promise<void>;
cleanup: () => Promise<void>;
}
export interface SkillsSandboxOptions {
bundledSkills?: string[];
/**
* Puts a regular file where the `.agents` skills tree has to go, so the real
* convergence fails with ENOTDIR instead of a stubbed rejection.
*/
blockAgentsDir?: boolean;
/** Converge and commit this selection before the app loads, through the real controller. */
installed?: SkillSelection;
/**
* Leaves a skill installed in one agent home only, the way a partial install
* or a hand-deleted directory leaves it.
*/
keepOnlyIn?: { skill: string; target: SkillTargetName };
/**
* Writes a skill directory straight into one agent home after the selection is
* committed, the way a manual reinstall or an external sync leaves a directory
* the saved preference does not mention.
*/
placeOnDisk?: { skill: string; target: SkillTargetName; files: Record<string, string> };
/** Holds the save response so dismissal behavior can be asserted while pending. */
holdSave?: boolean;
}
export type SkillTargetName = "agents" | "claude" | "codex";
function targetDir(targets: SkillTargets, target: SkillTargetName): string {
if (target === "agents") return targets.agentsDir;
if (target === "claude") return targets.claudeDir;
return targets.codexDir;
}
export async function createSkillsSandbox(
options: SkillsSandboxOptions = {},
): Promise<SkillsSandbox> {
let releaseHeldSave: (() => void) | null = null;
let markSaveHeld: (() => void) | null = null;
const heldSave = new Promise<void>((resolve) => {
markSaveHeld = resolve;
});
const saveRelease = new Promise<void>((resolve) => {
releaseHeldSave = resolve;
});
const bundledSkills = options.bundledSkills ?? ["paseo", "paseo-advisor", "paseo-loop"];
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-e2e-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"),
};
for (const name of bundledSkills) {
await mkdir(path.join(targets.sourceDir, name), { recursive: true });
await writeFile(path.join(targets.sourceDir, name, "SKILL.md"), `# ${name}\n`, "utf8");
}
const userDataPath = path.join(root, "user-data");
if (options.installed) {
await createSkillsController({
resolveTargets: () => targets,
selectionStore: createSkillSelectionStore({ userDataPath }),
}).save(options.installed);
}
if (options.keepOnlyIn) {
const kept = targetDir(targets, options.keepOnlyIn.target);
for (const dir of [targets.agentsDir, targets.claudeDir, targets.codexDir]) {
if (dir === kept) continue;
await rm(path.join(dir, options.keepOnlyIn.skill), { recursive: true, force: true });
}
}
if (options.placeOnDisk) {
const dir = path.join(
targetDir(targets, options.placeOnDisk.target),
options.placeOnDisk.skill,
);
for (const [rel, contents] of Object.entries(options.placeOnDisk.files)) {
const file = path.join(dir, rel);
await mkdir(path.dirname(file), { recursive: true });
await writeFile(file, contents, "utf8");
}
}
if (options.blockAgentsDir) {
await mkdir(path.dirname(targets.agentsDir), { recursive: true });
await writeFile(targets.agentsDir, "not a directory", "utf8");
}
return {
targets,
userDataPath,
bundledSkills,
placeSkillOnDisk: async (skill, target) => {
const dir = path.join(targetDir(targets, target), skill);
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, "SKILL.md"), `# ${skill}\n`, "utf8");
},
commitSelectionExternally: async (selection, confirmedRemovals = []) => {
await createSkillsController({
resolveTargets: () => targets,
selectionStore: createSkillSelectionStore({ userDataPath }),
}).save({ ...selection, confirmedRemovals: [...confirmedRemovals] });
},
waitForHeldSave: () => heldSave,
releaseHeldSave: () => releaseHeldSave?.(),
holdSaveResponse: options.holdSave
? async () => {
markSaveHeld?.();
await saveRelease;
}
: undefined,
// The save path atomically renames skill directories. On Linux/overlayfs,
// recursive removal can briefly observe a repopulated directory and fail
// with ENOTEMPTY even after the command has completed. Node only retries
// that transient when maxRetries is set.
cleanup: () => rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }),
};
}
/** Reads the committed selection back through the same store the app writes. */
export async function expectSavedSkillSelection(
sandbox: SkillsSandbox,
expected: SkillSelection,
): Promise<void> {
const store = createSkillSelectionStore({ userDataPath: sandbox.userDataPath });
await expect.poll(() => store.get(), { timeout: 15_000 }).toEqual(expected);
}
/**
* Routes the skills desktop commands to the real handlers in Node, leaving every
* other command on the injected desktop bridge. Call after `installDesktopRuntime`
* and before navigation.
*/
export async function serveRealSkillsCommands(page: Page, sandbox: SkillsSandbox): Promise<void> {
const handlers = createSkillsCommandHandlers({
controller: createSkillsController({
resolveTargets: () => sandbox.targets,
selectionStore: createSkillSelectionStore({ userDataPath: sandbox.userDataPath }),
}),
});
await page.exposeFunction(
"__paseoSkillsInvoke",
async (command: string, args: Record<string, unknown> | null) => {
const handler = handlers[command];
if (!handler) throw new Error(`Unknown skills command: ${command}`);
const result = await handler(args ?? undefined);
if (command === "save_skills_selection") {
await sandbox.holdSaveResponse?.();
}
return result;
},
);
await page.addInitScript((commands: readonly string[]) => {
const bridge = (window as unknown as { paseoDesktop?: { invoke: unknown } }).paseoDesktop;
if (!bridge) throw new Error("Desktop bridge must be injected before the skills bridge.");
const skillCommands = new Set(commands);
const inner = bridge.invoke as (
command: string,
args?: Record<string, unknown>,
) => Promise<unknown>;
bridge.invoke = (command: string, args?: Record<string, unknown>) =>
skillCommands.has(command)
? window.__paseoSkillsInvoke!(command, args ?? null)
: inner(command, args);
}, SKILL_COMMANDS);
}
/** Real skills commands over a temp bundle, wired to this page's desktop bridge. */
export async function startSkillsSandbox(
page: Page,
options: SkillsSandboxOptions = {},
): Promise<SkillsSandbox> {
const sandbox = await createSkillsSandbox(options);
await installDesktopRuntime(page, { serverId: getServerId(), confirmShouldAccept: false });
await serveRealSkillsCommands(page, sandbox);
return sandbox;
}
export async function openSkillsIntegrations(page: Page): Promise<void> {
await openSettings(page);
await openSettingsSection(page, "integrations");
await expect(page.getByText("Orchestration skills", { exact: true })).toBeVisible();
}
/** Settings on a compact form factor stacks list and detail, so it needs the menu first. */
export async function openCompactSkillsIntegrations(page: Page): Promise<void> {
await openCompactSettings(page, buildOpenProjectRoute());
await openSettingsSection(page, "integrations");
await expect(page.getByText("Orchestration skills", { exact: true })).toBeVisible();
}
export async function openSkillSelection(page: Page): Promise<void> {
await page.getByRole("button", { name: "Choose skills", exact: true }).click();
await expectSkillSelectionOpen(page);
}
export async function expectSkillSelectionOpen(page: Page): Promise<void> {
await expect(page.getByRole("switch", { name: "All skills", exact: true })).toBeVisible();
}
export async function expectSkillSelectionClosed(page: Page): Promise<void> {
await expect(page.getByRole("switch", { name: "All skills", exact: true })).toHaveCount(0);
}
export async function chooseAllSkills(page: Page): Promise<void> {
const allSkills = page.getByRole("switch", { name: "All skills", exact: true });
await expect(allSkills).not.toBeChecked();
await allSkills.click();
await expect(allSkills).toBeChecked();
}
export async function chooseCustomSkills(page: Page, skills: string[]): Promise<void> {
const allSkills = page.getByRole("switch", { name: "All skills", exact: true });
await expect(allSkills).toBeChecked();
await allSkills.click();
await expect(allSkills).not.toBeChecked();
const keep = new Set(skills);
for (const name of await listSkillChoices(page)) {
if (keep.has(name)) continue;
await page.getByRole("checkbox", { name, exact: true }).click();
}
await expectSkillChoices(page, skills);
}
export async function toggleSkill(page: Page, name: string): Promise<void> {
await page.getByRole("checkbox", { name, exact: true }).click();
}
export async function saveSkillSelection(page: Page): Promise<void> {
await page.getByRole("button", { name: "Save", exact: true }).click();
}
export async function cancelSkillSelection(page: Page): Promise<void> {
await page.getByRole("button", { name: "Cancel", exact: true }).click();
await expectSkillSelectionClosed(page);
}
export async function listSkillChoices(page: Page): Promise<string[]> {
const rows = page.getByRole("checkbox");
const names = await rows.evaluateAll((elements) =>
elements.map((element) => element.getAttribute("aria-label") ?? ""),
);
return names.filter((name) => name.length > 0);
}
/** Asserts exactly which skills are checked in the open sheet. */
export async function expectSkillChoices(page: Page, skills: string[]): Promise<void> {
const expected = new Set(skills);
for (const name of await listSkillChoices(page)) {
const choice = page.getByRole("checkbox", { name, exact: true });
if (expected.has(name)) {
await expect(choice).toBeChecked();
continue;
}
await expect(choice).not.toBeChecked();
}
}
/** Reopens the saved selection and asserts what the user sees, then closes it. */
export async function expectSelectedSkills(page: Page, skills: string[]): Promise<void> {
await openSkillSelection(page);
await expect(page.getByRole("switch", { name: "All skills", exact: true })).not.toBeChecked();
await expectSkillChoices(page, skills);
await cancelSkillSelection(page);
}
export async function expectAllSkillsSelected(page: Page): Promise<void> {
await openSkillSelection(page);
const allSkills = page.getByRole("switch", { name: "All skills", exact: true });
await expect(allSkills).toBeChecked();
await expectSkillChoices(page, await listSkillChoices(page));
await cancelSkillSelection(page);
}
export async function expectSaveErrorKeepsSheetOpen(page: Page): Promise<void> {
await expect(page.getByText("Could not save your skill selection.", { exact: true })).toBeVisible(
{ timeout: 15_000 },
);
await expectSkillSelectionOpen(page);
}
/**
* The removal warning is the desktop confirm dialog, which has no DOM to query.
* The injected bridge records the call the same way the daemon-management tests
* assert their confirmation copy.
*/
export async function expectRemovalWarning(page: Page, skills: string[]): Promise<void> {
await page.waitForFunction(() => !!window.__capturedDialogCall, { timeout: 15_000 });
const call = await page.evaluate(() => window.__capturedDialogCall);
expect(call?.title).toBe("Remove deselected skills?");
for (const name of skills) {
expect(call?.message).toContain(name);
}
for (const dir of ["~/.agents", "~/.claude", "~/.codex"]) {
expect(call?.message).toContain(dir);
}
expect(call?.message).toContain("Anything you added inside those skill folders is deleted too.");
}
export async function expectNoRemovalWarning(page: Page): Promise<void> {
expect(await page.evaluate(() => window.__capturedDialogCall)).toBeUndefined();
}
export async function expectSkillPresentIn(
sandbox: SkillsSandbox,
target: SkillTargetName,
skill: string,
): Promise<void> {
await expect
.poll(() => readInstalledSkills(targetDir(sandbox.targets, target)), { timeout: 15_000 })
.toContain(skill);
}
export async function expectSkillFilePresent(
sandbox: SkillsSandbox,
target: SkillTargetName,
skill: string,
relativePath: string,
): Promise<void> {
const file = path.join(targetDir(sandbox.targets, target), skill, relativePath);
await expect
.poll(() => readFile(file, "utf8").catch(() => null), { timeout: 15_000 })
.not.toBeNull();
}
export async function expectSkillsInstalled(
sandbox: SkillsSandbox,
skills: string[],
): Promise<void> {
const dirs = [
sandbox.targets.agentsDir,
sandbox.targets.claudeDir,
sandbox.targets.codexDir,
] as const;
for (const dir of dirs) {
await expect
.poll(async () => readInstalledSkills(dir), { timeout: 15_000 })
.toEqual([...skills].sort());
}
}
async function readInstalledSkills(dir: string): Promise<string[]> {
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
return entries
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort();
}
-2
View File
@@ -29,8 +29,6 @@ asarUnpack:
extraResources:
- from: ../app/dist
to: app-dist
- from: ../../skills
to: skills
- from: assets/editor-targets
to: editor-target-icons
publish:
@@ -23,7 +23,6 @@ import {
getCliInstallStatus,
installCli,
} from "../integrations/cli-install/index.js";
import { createSkillsCommandHandlers, getSkillsController } from "../integrations/skills/index.js";
import {
openLocalTransportSession,
sendLocalTransportMessage,
@@ -39,6 +38,10 @@ import type { DesktopSettings } from "../settings/desktop-settings.js";
import { getDesktopSettingsStore } from "../settings/desktop-settings-electron.js";
import { isRunningUnderARM64Translation } from "../system/arm64-translation.js";
import { getDesktopAppLogs } from "../diagnostics/app-logs.js";
import {
deleteLegacySkillSelection,
readLegacySkillSelection,
} from "../integrations/legacy-skill-selection.js";
import { tailFile } from "../diagnostics/tail-file.js";
const DAEMON_LOG_FILENAME = "daemon.log";
@@ -569,7 +572,8 @@ export function createDaemonCommandHandlers(): Record<string, DesktopCommandHand
get_local_daemon_version: () => getLocalDaemonVersion(),
install_cli: () => installCli(),
get_cli_install_status: () => getCliInstallStatus(),
...createSkillsCommandHandlers({ controller: getSkillsController() }),
read_legacy_skill_selection: () => readLegacySkillSelection(),
delete_legacy_skill_selection: () => deleteLegacySkillSelection(),
};
}
@@ -89,6 +89,20 @@ describe("desktop packaging", () => {
expect(config).toContain("!node_modules/@getpaseo/server/dist/server/web-ui/**");
});
it("uses the server skill catalog without a duplicate desktop resource", () => {
const config = readFileSync(join(packageRoot, "electron-builder.yml"), "utf8");
const serverPackage = readFileSync(join(packageRoot, "..", "server", "package.json"), "utf8");
const runtimeTrace = readFileSync(
join(packageRoot, "..", "..", "scripts", "trace-daemon.mjs"),
"utf8",
);
expect(config).not.toContain("from: ../../skills");
expect(serverPackage).toContain("fs.rmSync('dist/server/skills',{recursive:true,force:true})");
expect(serverPackage).toContain("fs.cpSync('../../skills','dist/server/skills'");
expect(runtimeTrace).toContain('"packages/server/dist/server/skills/**"');
});
it("registers Paseo agent links with the operating system", () => {
const config = readFileSync(join(packageRoot, "electron-builder.yml"), "utf8");
@@ -36,24 +36,6 @@ describe("desktop startup", () => {
expect(calls).toEqual(["cli", "env", "gui"]);
});
it("starts skills auto-update after GUI startup", async () => {
const calls: string[] = [];
await runDesktopStartup({
hasPendingGuiLaunchRequest: false,
runCliPassthroughIfRequested: vi.fn(async () => {
calls.push("cli");
return false;
}),
inheritLoginShellEnv: vi.fn(() => calls.push("env")),
bootstrapGui: vi.fn(async () => {
calls.push("gui");
}),
autoUpdateInstalledSkills: vi.fn(() => calls.push("skills")),
});
expect(calls).toEqual(["cli", "env", "gui", "skills"]);
});
it("does not route open-project launches through CLI passthrough", async () => {
const runCliPassthroughIfRequested = vi.fn(async () => true);
const calls: string[] = [];
-2
View File
@@ -3,7 +3,6 @@ export interface DesktopStartupDependencies {
runCliPassthroughIfRequested: () => Promise<boolean>;
inheritLoginShellEnv: () => void;
bootstrapGui: () => Promise<void>;
autoUpdateInstalledSkills?: () => void;
}
export async function runDesktopStartup(deps: DesktopStartupDependencies): Promise<void> {
@@ -13,5 +12,4 @@ export async function runDesktopStartup(deps: DesktopStartupDependencies): Promi
deps.inheritLoginShellEnv();
await deps.bootstrapGui();
deps.autoUpdateInstalledSkills?.();
}
@@ -0,0 +1,37 @@
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
let userDataPath = "";
vi.mock("electron", () => ({ app: { getPath: () => userDataPath } }));
import { deleteLegacySkillSelection, readLegacySkillSelection } from "./legacy-skill-selection";
beforeEach(async () => {
userDataPath = await mkdtemp(path.join(os.tmpdir(), "paseo-legacy-skills-"));
});
describe("legacy desktop skill selection", () => {
it("reads and normalizes the legacy preference", async () => {
await writeFile(
path.join(userDataPath, "skill-selection.json"),
JSON.stringify({
version: 1,
selection: { mode: "custom", skills: ["paseo-loop", "paseo", "paseo"] },
}),
);
expect(await readLegacySkillSelection()).toEqual({
mode: "custom",
skills: ["paseo", "paseo-loop"],
});
});
it("deletes only when the caller confirms daemon persistence", async () => {
const file = path.join(userDataPath, "skill-selection.json");
await writeFile(file, JSON.stringify({ version: 1, selection: { mode: "all" } }));
await expect(readFile(file, "utf8")).resolves.toContain('"mode":"all"');
await deleteLegacySkillSelection();
await expect(readFile(file, "utf8")).rejects.toMatchObject({ code: "ENOENT" });
});
});
@@ -0,0 +1,50 @@
import { readFile, rm } from "node:fs/promises";
import path from "node:path";
import { app } from "electron";
type AgentSkillSelection = { mode: "all" } | { mode: "custom"; skills: string[] };
const LEGACY_FILENAME = "skill-selection.json";
function parseSelection(value: unknown): AgentSkillSelection | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return { mode: "all" };
const document = value as { selection?: unknown };
if (!document.selection || typeof document.selection !== "object") return { mode: "all" };
const selection = document.selection as { mode?: unknown; skills?: unknown };
if (selection.mode === "all") return { mode: "all" };
if (selection.mode !== "custom" || !Array.isArray(selection.skills)) return { mode: "all" };
return {
mode: "custom",
skills: [
...new Set(
selection.skills
.filter((name): name is string => typeof name === "string")
.map((name) => name.trim())
.filter(Boolean),
),
].sort(),
};
}
function legacyPath(): string {
return path.join(app.getPath("userData"), LEGACY_FILENAME);
}
// COMPAT(desktopSkillSelectionMigration): added in v0.4.0; remove after 2027-02-16.
export async function readLegacySkillSelection(): Promise<AgentSkillSelection | null> {
const raw = await readFile(legacyPath(), "utf8").catch((error: NodeJS.ErrnoException) => {
if (error.code === "ENOENT") return null;
throw error;
});
if (raw === null) return null;
try {
return parseSelection(JSON.parse(raw));
} catch {
return { mode: "all" };
}
}
// COMPAT(desktopSkillSelectionMigration): added in v0.4.0; remove after 2027-02-16.
export async function deleteLegacySkillSelection(): Promise<void> {
await rm(legacyPath(), { force: true });
}
@@ -1,49 +0,0 @@
import { app } from "electron";
import type { SkillsStatus, SkillTargets } from "./operations.js";
import {
getAgentsSkillsDir,
getBundledSkillsDir,
getClaudeSkillsDir,
getCodexSkillsDir,
} from "./paths.js";
import { createSkillSelectionStore } from "./selection-store.js";
import { createSkillsController, type SkillsController } from "./skills-controller.js";
export { createSkillsCommandHandlers } from "./skills-commands.js";
export { type SkillsController, type SkillsSnapshot } from "./skills-controller.js";
export type {
SkillOp,
SkillSelection,
SkillsState,
SkillsStatus,
SkillTargets,
} from "./operations.js";
let controller: SkillsController | null = null;
function getSkillTargets(): SkillTargets {
return {
sourceDir: getBundledSkillsDir(),
agentsDir: getAgentsSkillsDir(),
claudeDir: getClaudeSkillsDir(),
codexDir: getCodexSkillsDir(),
};
}
/**
* One controller per process. Startup convergence and the settings commands must
* share it, or they are two writers over the same directories again.
*/
export function getSkillsController(): SkillsController {
controller ??= createSkillsController({
resolveTargets: getSkillTargets,
selectionStore: createSkillSelectionStore({ userDataPath: app.getPath("userData") }),
});
return controller;
}
/** Startup convergence: keep whatever the user selected in sync with the bundle. */
export async function autoUpdateInstalledSkills(): Promise<SkillsStatus> {
return getSkillsController().autoUpdate();
}
@@ -1,620 +0,0 @@
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 {
autoUpdateInstalledSkills,
getSkillsStatus,
installSkills,
LEGACY_SKILL_NAMES,
type SkillSelection,
type SkillTargets,
uninstallSkills,
updateSkills,
} from "./operations";
const ALL_SKILLS: SkillSelection = { mode: "all" };
function only(...skills: string[]): SkillSelection {
return { mode: "custom", skills };
}
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 writeOnDiskSkillToAllTargets(
targets: SkillTargets,
name: string,
files: Record<string, string>,
): Promise<void> {
await Promise.all([
writeOnDiskSkill(targets.agentsDir, name, files),
writeOnDiskSkill(targets.claudeDir, name, files),
writeOnDiskSkill(targets.codexDir, 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);
}
async function installedIn(targets: SkillTargets, name: string): Promise<boolean[]> {
return Promise.all(
[targets.agentsDir, targets.claudeDir, targets.codexDir].map((dir) =>
pathExists(path.join(dir, name)),
),
);
}
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, ALL_SKILLS);
expect(status.state).toBe("not-installed");
expect(status.ops).toEqual([
{ kind: "add", name: "paseo" },
{ kind: "add", name: "paseo-loop" },
]);
});
it("reports every bundled skill as available", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeBundleSkill(sandbox.targets.sourceDir, "paseo-advisor", {
"SKILL.md": "advisor-v1",
});
const status = await getSkillsStatus(sandbox.targets, only("paseo"));
expect(status.available).toEqual(["paseo", "paseo-advisor", "paseo-loop"]);
});
it("reports a skill present in only one target as installed", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.claudeDir, "paseo", { "SKILL.md": "paseo-v1" });
const status = await getSkillsStatus(sandbox.targets, ALL_SKILLS);
// `add` means "missing from at least one target", so it cannot answer
// "is there a directory here to delete". `installed` answers that.
expect(status.installed).toEqual(["paseo"]);
expect(status.ops).toEqual([
{ kind: "add", name: "paseo" },
{ kind: "add", name: "paseo-loop" },
]);
});
it("reports legacy skill directories left on disk as installed", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-chat", { "SKILL.md": "chat-old" });
expect((await getSkillsStatus(sandbox.targets, ALL_SKILLS)).installed).toEqual(["paseo-chat"]);
});
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, ALL_SKILLS);
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 writeOnDiskSkillToAllTargets(sandbox.targets, "paseo", { "SKILL.md": "paseo-v1" });
await writeOnDiskSkillToAllTargets(sandbox.targets, "paseo-loop", { "SKILL.md": "loop-v1" });
const status = await getSkillsStatus(sandbox.targets, ALL_SKILLS);
expect(status).toEqual({
state: "up-to-date",
ops: [],
available: ["paseo", "paseo-loop"],
installed: ["paseo", "paseo-loop"],
});
});
it("ignores user-added files inside current managed skill dirs in every target", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkillToAllTargets(sandbox.targets, "paseo", { "SKILL.md": "paseo-v1" });
await writeOnDiskSkillToAllTargets(sandbox.targets, "paseo-loop", { "SKILL.md": "loop-v1" });
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", {
"SKILL.md": "paseo-v1",
"my-context.md": "user context",
});
await writeOnDiskSkill(sandbox.targets.claudeDir, "paseo", {
"SKILL.md": "paseo-v1",
"commands/local.md": "user command",
});
await writeOnDiskSkill(sandbox.targets.codexDir, "paseo", {
"SKILL.md": "paseo-v1",
"hooks/guard.sh": "user guard",
});
const status = await getSkillsStatus(sandbox.targets, ALL_SKILLS);
expect(status).toEqual({
state: "up-to-date",
ops: [],
available: ["paseo", "paseo-loop"],
installed: ["paseo", "paseo-loop"],
});
});
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.claudeDir, "paseo", { "SKILL.md": "paseo-v1" });
await writeOnDiskSkill(sandbox.targets.codexDir, "paseo", { "SKILL.md": "paseo-v1" });
await writeOnDiskSkillToAllTargets(sandbox.targets, "paseo-loop", { "SKILL.md": "loop-v1" });
const status = await getSkillsStatus(sandbox.targets, ALL_SKILLS);
expect(status.state).toBe("drift");
expect(status.ops).toEqual([{ kind: "update", name: "paseo" }]);
});
it("returns drift when a secondary agent target is stale", 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.claudeDir, "paseo", { "SKILL.md": "stale" });
await writeOnDiskSkill(sandbox.targets.claudeDir, "paseo-loop", { "SKILL.md": "loop-v1" });
await writeOnDiskSkill(sandbox.targets.codexDir, "paseo", { "SKILL.md": "paseo-v1" });
await writeOnDiskSkill(sandbox.targets.codexDir, "paseo-loop", { "SKILL.md": "loop-v1" });
const status = await getSkillsStatus(sandbox.targets, ALL_SKILLS);
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 writeOnDiskSkillToAllTargets(sandbox.targets, "paseo", { "SKILL.md": "paseo-v1" });
const status = await getSkillsStatus(sandbox.targets, ALL_SKILLS);
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 writeOnDiskSkillToAllTargets(sandbox.targets, "paseo", { "SKILL.md": "paseo-v1" });
await writeOnDiskSkillToAllTargets(sandbox.targets, "paseo-loop", { "SKILL.md": "loop-v1" });
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-chat", { "SKILL.md": "chat-old" });
const status = await getSkillsStatus(sandbox.targets, ALL_SKILLS);
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.claudeDir, "paseo", { "SKILL.md": "paseo-v1" });
await writeOnDiskSkill(sandbox.targets.codexDir, "paseo", { "SKILL.md": "paseo-v1" });
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-chat", { "SKILL.md": "chat-old" });
const status = await getSkillsStatus(sandbox.targets, ALL_SKILLS);
expect(status.state).toBe("drift");
expect(status.ops).toEqual([
{ kind: "update", name: "paseo" },
{ kind: "delete", name: "paseo-chat" },
{ kind: "add", name: "paseo-loop" },
]);
});
});
describe("custom skill selection", () => {
let sandbox: Sandbox;
beforeEach(async () => {
sandbox = await makeSandbox();
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeBundleSkill(sandbox.targets.sourceDir, "paseo-advisor", {
"SKILL.md": "advisor-v1",
});
});
afterEach(async () => {
await fs.rm(sandbox.root, { recursive: true, force: true });
});
it("installs only the selected skills", async () => {
const status = await installSkills(sandbox.targets, only("paseo", "paseo-loop"));
expect(status).toEqual({
state: "up-to-date",
ops: [],
available: ["paseo", "paseo-advisor", "paseo-loop"],
installed: ["paseo", "paseo-loop"],
});
expect(await installedIn(sandbox.targets, "paseo")).toEqual([true, true, true]);
expect(await installedIn(sandbox.targets, "paseo-loop")).toEqual([true, true, true]);
expect(await installedIn(sandbox.targets, "paseo-advisor")).toEqual([false, false, false]);
});
it("reports up-to-date while an unselected bundled skill is absent", async () => {
await installSkills(sandbox.targets, only("paseo"));
const status = await getSkillsStatus(sandbox.targets, only("paseo"));
expect(status).toEqual({
state: "up-to-date",
ops: [],
available: ["paseo", "paseo-advisor", "paseo-loop"],
installed: ["paseo"],
});
});
it("removes a previously installed skill once it leaves the selection", async () => {
await installSkills(sandbox.targets, ALL_SKILLS);
const status = await installSkills(sandbox.targets, only("paseo"));
expect(status.state).toBe("up-to-date");
expect(await installedIn(sandbox.targets, "paseo")).toEqual([true, true, true]);
expect(await installedIn(sandbox.targets, "paseo-loop")).toEqual([false, false, false]);
expect(await installedIn(sandbox.targets, "paseo-advisor")).toEqual([false, false, false]);
});
it("reports a delete op for a deselected skill before it is applied", async () => {
await installSkills(sandbox.targets, ALL_SKILLS);
const status = await getSkillsStatus(sandbox.targets, only("paseo", "paseo-advisor"));
expect(status.state).toBe("drift");
expect(status.ops).toEqual([{ kind: "delete", name: "paseo-loop" }]);
});
it("leaves unrelated user skills untouched", async () => {
await writeOnDiskSkill(sandbox.targets.agentsDir, "unslop", { "SKILL.md": "user-unslop" });
await installSkills(sandbox.targets, only("paseo"));
expect(
await fs.readFile(path.join(sandbox.targets.agentsDir, "unslop", "SKILL.md"), "utf-8"),
).toBe("user-unslop");
});
it("treats an empty custom selection as no managed skills installed", async () => {
await installSkills(sandbox.targets, ALL_SKILLS);
const status = await installSkills(sandbox.targets, only());
expect(status).toEqual({
state: "not-installed",
ops: [],
available: ["paseo", "paseo-advisor", "paseo-loop"],
installed: [],
});
expect(await installedIn(sandbox.targets, "paseo")).toEqual([false, false, false]);
expect(await installedIn(sandbox.targets, "paseo-loop")).toEqual([false, false, false]);
});
it("ignores selected names that the bundle does not ship", async () => {
const status = await installSkills(sandbox.targets, only("paseo", "not-a-skill"));
expect(status).toEqual({
state: "up-to-date",
ops: [],
available: ["paseo", "paseo-advisor", "paseo-loop"],
installed: ["paseo"],
});
expect(await installedIn(sandbox.targets, "not-a-skill")).toEqual([false, false, false]);
});
it("still deletes legacy skill names that are not selectable", async () => {
await writeOnDiskSkillToAllTargets(sandbox.targets, "paseo-orchestrator", {
"SKILL.md": "orchestrator-old",
});
await installSkills(sandbox.targets, only("paseo"));
expect(await installedIn(sandbox.targets, "paseo-orchestrator")).toEqual([false, false, false]);
});
});
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, ALL_SKILLS);
expect(status).toEqual({
state: "up-to-date",
ops: [],
available: ["paseo", "paseo-loop"],
installed: ["paseo", "paseo-loop"],
});
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("repairs missing and edited skills without deleting a legacy directory", 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, ALL_SKILLS);
expect(status).toEqual({
state: "drift",
ops: [{ kind: "delete", name: "paseo-chat" }],
available: ["paseo", "paseo-loop"],
installed: ["paseo", "paseo-chat", "paseo-loop"],
});
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(true);
}
});
it("defines updated as the state reached after preserving user files", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkillToAllTargets(sandbox.targets, "paseo", { "SKILL.md": "paseo-v1" });
await writeOnDiskSkillToAllTargets(sandbox.targets, "paseo-loop", { "SKILL.md": "loop-v1" });
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", {
"SKILL.md": "stale",
"hooks/guard.sh": "user guard",
});
await writeOnDiskSkill(sandbox.targets.claudeDir, "paseo", {
"SKILL.md": "paseo-v1",
"notes/local.md": "claude notes",
});
await writeOnDiskSkill(sandbox.targets.codexDir, "paseo", {
"SKILL.md": "paseo-v1",
"prompts/local.md": "codex prompt",
});
const status = await updateSkills(sandbox.targets, ALL_SKILLS);
expect(status.state).toBe("up-to-date");
expect(await getSkillsStatus(sandbox.targets, ALL_SKILLS)).toEqual({
state: "up-to-date",
ops: [],
available: ["paseo", "paseo-loop"],
installed: ["paseo", "paseo-loop"],
});
expect(
await fs.readFile(
path.join(sandbox.targets.agentsDir, "paseo", "hooks", "guard.sh"),
"utf-8",
),
).toBe("user guard");
expect(
await fs.readFile(
path.join(sandbox.targets.claudeDir, "paseo", "notes", "local.md"),
"utf-8",
),
).toBe("claude notes");
expect(
await fs.readFile(
path.join(sandbox.targets.codexDir, "paseo", "prompts", "local.md"),
"utf-8",
),
).toBe("codex prompt");
});
it("repairs secondary agent targets even when agents skills are current", 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.claudeDir, "paseo", { "SKILL.md": "paseo-v1" });
await writeOnDiskSkill(sandbox.targets.codexDir, "paseo", { "SKILL.md": "paseo-v1" });
await writeOnDiskSkill(sandbox.targets.codexDir, "paseo-loop", { "SKILL.md": "loop-v1" });
const status = await updateSkills(sandbox.targets, ALL_SKILLS);
expect(status.state).toBe("up-to-date");
expect(
await fs.readFile(path.join(sandbox.targets.claudeDir, "paseo-loop", "SKILL.md"), "utf-8"),
).toBe("loop-v1");
});
it("auto-updates drifted installed skills", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", {
"SKILL.md": "stale",
"hooks/guard.sh": "user guard",
});
const status = await autoUpdateInstalledSkills(sandbox.targets, ALL_SKILLS);
expect(status.state).toBe("up-to-date");
expect((await getSkillsStatus(sandbox.targets, ALL_SKILLS)).state).toBe("up-to-date");
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", "hooks", "guard.sh"),
"utf-8",
),
).toBe("user guard");
});
it("does not auto-install skills on a clean machine", async () => {
await writeCurrentBundle(sandbox.targets.sourceDir);
const status = await autoUpdateInstalledSkills(sandbox.targets, ALL_SKILLS);
expect(status).toEqual({
state: "not-installed",
ops: [
{ kind: "add", name: "paseo" },
{ kind: "add", name: "paseo-loop" },
],
available: ["paseo", "paseo-loop"],
installed: [],
});
expect(await installedIn(sandbox.targets, "paseo")).toEqual([false, false, 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, ALL_SKILLS);
const second = await installSkills(sandbox.targets, ALL_SKILLS);
expect(first.state).toBe("up-to-date");
expect(second.state).toBe("up-to-date");
});
});
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, ALL_SKILLS);
for (const name of ["unslop", "tdd", "devbox"]) {
await writeOnDiskSkill(sandbox.targets.agentsDir, name, { "SKILL.md": `user-${name}` });
}
const status = await uninstallSkills(sandbox.targets, ALL_SKILLS);
expect(status.state).toBe("not-installed");
for (const name of ["paseo", "paseo-loop", ...LEGACY_SKILL_NAMES]) {
expect(await installedIn(sandbox.targets, name)).toEqual([false, false, 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, ALL_SKILLS);
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, ALL_SKILLS);
expect(status.state).toBe("not-installed");
expect(await installedIn(sandbox.targets, "paseo-chat")).toEqual([false, false, false]);
});
});
@@ -1,251 +0,0 @@
import { createHash } from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
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 };
/** What the user asked to have installed. `all` follows the bundle as it grows. */
export type SkillSelection = { mode: "all" } | { mode: "custom"; skills: readonly string[] };
export interface SkillsStatus {
state: SkillsState;
ops: SkillOp[];
/** Every skill the bundle currently ships, sorted. The selectable catalog. */
available: string[];
/**
* Managed skills with a directory in at least one agent home, sorted. An `add`
* op means "missing from at least one target", so it cannot answer whether
* there is anything on disk to delete — this can.
*/
installed: string[];
}
export interface SkillTargets {
sourceDir: string;
agentsDir: string;
claudeDir: string;
codexDir: string;
}
// Names the bundle used to ship. They are never selectable, but every scan still
// covers them so an older install's copies get cleaned up.
export const LEGACY_SKILL_NAMES = [
"paseo-chat",
"paseo-epic",
"paseo-orchestrate",
"paseo-orchestrator",
] as const;
type SkillFiles = Map<string, string>;
type TargetSkills = Map<string, SkillFiles>;
/**
* The bundle directory is the catalog. Reading it instead of a hardcoded list is
* what makes `all` pick up skills added in a later release with no code change.
*/
async function listBundledSkills(sourceDir: string): Promise<string[]> {
const entries = await fs.readdir(sourceDir, { withFileTypes: true }).catch(() => []);
return entries
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort(compareStrings);
}
/** Every name Paseo owns on disk: what it ships now plus what it used to ship. */
function managedSkillNames(available: readonly string[]): string[] {
return [...new Set([...available, ...LEGACY_SKILL_NAMES])].sort(compareStrings);
}
/** The names a convergence may create, replace, or delete. */
export async function listManagedSkillNames(sourceDir: string): Promise<string[]> {
return managedSkillNames(await listBundledSkills(sourceDir));
}
function resolveDesiredSkills(
selection: SkillSelection,
available: readonly string[],
): Set<string> {
if (selection.mode === "all") return new Set(available);
const chosen = new Set(selection.skills);
return new Set(available.filter((name) => chosen.has(name)));
}
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, names: readonly string[]): Promise<TargetSkills> {
const out: TargetSkills = new Map();
for (const name of names) {
const files = await hashSkillDir(path.join(rootDir, name));
if (files !== null) out.set(name, files);
}
return out;
}
function diff(
bundle: TargetSkills,
disks: readonly TargetSkills[],
names: readonly string[],
desired: ReadonlySet<string>,
): SkillOp[] {
const ops: SkillOp[] = [];
for (const name of names) {
const b = desired.has(name) ? bundle.get(name) : undefined;
const targetFiles = disks.map((disk) => disk.get(name));
const installedTargets = targetFiles.filter(
(files): files is SkillFiles => files !== undefined,
);
if (b) {
const missingTargets = installedTargets.length < disks.length;
const changedTargets = installedTargets.some((files) => !bundleFilesMatch(b, files));
if (missingTargets) ops.push({ kind: "add", name });
else if (changedTargets) ops.push({ kind: "update", name });
} else if (installedTargets.length > 0) {
ops.push({ kind: "delete", name });
}
}
ops.sort((a, b) => compareStrings(a.name, b.name));
return ops;
}
function hasInstalledPaseoSkill(disks: readonly TargetSkills[]): boolean {
return disks.some((disk) => disk.size > 0);
}
function installedSkillNames(disks: readonly TargetSkills[], names: readonly string[]): string[] {
return names.filter((name) => disks.some((disk) => disk.has(name)));
}
function bundleFilesMatch(bundle: SkillFiles, disk: SkillFiles): boolean {
for (const [rel, sha] of bundle) {
if (disk.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,
selection: SkillSelection,
): Promise<SkillsStatus> {
const available = await listBundledSkills(targets.sourceDir);
const names = managedSkillNames(available);
const [bundle, agentsDisk, claudeDisk, codexDisk] = await Promise.all([
hashSkills(targets.sourceDir, available),
hashSkills(targets.agentsDir, names),
hashSkills(targets.claudeDir, names),
hashSkills(targets.codexDir, names),
]);
const disks = [agentsDisk, claudeDisk, codexDisk];
const ops = diff(bundle, disks, names, resolveDesiredSkills(selection, available));
const installed = installedSkillNames(disks, names);
if (!hasInstalledPaseoSkill(disks)) return { state: "not-installed", ops, available, installed };
if (ops.length === 0) return { state: "up-to-date", ops, available, installed };
return { state: "drift", ops, available, installed };
}
async function applySkills(
targets: SkillTargets,
selection: SkillSelection,
initialStatus?: SkillsStatus,
): Promise<SkillsStatus> {
const status = initialStatus ?? (await getSkillsStatus(targets, selection));
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, selection);
}
export async function installSkills(
targets: SkillTargets,
selection: SkillSelection,
/** Apply exactly this plan instead of rescanning, so a confirmed plan is the applied plan. */
plan?: SkillsStatus,
): Promise<SkillsStatus> {
return applySkills(targets, selection, plan);
}
export async function updateSkills(
targets: SkillTargets,
selection: SkillSelection,
): Promise<SkillsStatus> {
const status = await getSkillsStatus(targets, selection);
return applySkills(targets, selection, nonDestructivePlan(status));
}
function nonDestructivePlan(status: SkillsStatus): SkillsStatus {
return { ...status, ops: status.ops.filter((op) => op.kind !== "delete") };
}
export async function autoUpdateInstalledSkills(
targets: SkillTargets,
selection: SkillSelection,
): Promise<SkillsStatus> {
const status = await getSkillsStatus(targets, selection);
if (status.state !== "drift") return status;
// Automatic maintenance may repair selected skills, but removal is an
// interactive operation because managed directories can contain user files.
return applySkills(targets, selection, nonDestructivePlan(status));
}
export async function uninstallSkills(
targets: SkillTargets,
selection: SkillSelection,
): Promise<SkillsStatus> {
const available = await listBundledSkills(targets.sourceDir);
for (const name of managedSkillNames(available)) {
await removeSkill(name, {
agentsDir: targets.agentsDir,
claudeDir: targets.claudeDir,
codexDir: targets.codexDir,
});
}
return getSkillsStatus(targets, selection);
}
@@ -1,22 +0,0 @@
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");
}
@@ -1,150 +0,0 @@
import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { createSkillSelectionStore } from "./selection-store";
const directories = new Set<string>();
async function createTempUserDataDir(): Promise<string> {
const dir = await mkdtemp(path.join(os.tmpdir(), "paseo-skill-selection-"));
directories.add(dir);
return dir;
}
function selectionFilePath(userDataPath: string): string {
return path.join(userDataPath, "skill-selection.json");
}
async function seedSelectionFile(userDataPath: string, raw: string): Promise<void> {
await writeFile(selectionFilePath(userDataPath), raw, "utf8");
}
afterEach(async () => {
for (const dir of directories) {
await rm(dir, { recursive: true, force: true });
}
directories.clear();
});
describe("createSkillSelectionStore", () => {
it("reports all skills when nothing has ever been saved", async () => {
const userDataPath = await createTempUserDataDir();
const store = createSkillSelectionStore({ userDataPath });
expect(await store.get()).toEqual({ mode: "all" });
expect(await readdir(userDataPath)).toEqual([]);
});
it("round-trips a custom selection through a fresh store", async () => {
const userDataPath = await createTempUserDataDir();
const saved = await createSkillSelectionStore({ userDataPath }).set({
mode: "custom",
skills: ["paseo", "paseo-loop"],
});
const reloaded = await createSkillSelectionStore({ userDataPath }).get();
expect(saved).toEqual({ mode: "custom", skills: ["paseo", "paseo-loop"] });
expect(reloaded).toEqual({ mode: "custom", skills: ["paseo", "paseo-loop"] });
expect(await readdir(userDataPath)).toEqual(["skill-selection.json"]);
});
it("sorts, de-duplicates, and drops non-string names before persisting", async () => {
const userDataPath = await createTempUserDataDir();
const store = createSkillSelectionStore({ userDataPath });
const saved = await store.set({
mode: "custom",
skills: ["paseo-loop", "paseo", "paseo-loop", " ", 7, null],
});
expect(saved).toEqual({ mode: "custom", skills: ["paseo", "paseo-loop"] });
expect(JSON.parse(await readFile(selectionFilePath(userDataPath), "utf8"))).toEqual({
version: 1,
selection: { mode: "custom", skills: ["paseo", "paseo-loop"] },
});
});
it("keeps an empty custom selection as a deliberate choice", async () => {
const userDataPath = await createTempUserDataDir();
await createSkillSelectionStore({ userDataPath }).set({ mode: "custom", skills: [] });
expect(await createSkillSelectionStore({ userDataPath }).get()).toEqual({
mode: "custom",
skills: [],
});
});
it("switches back to all and forgets the previous custom names", async () => {
const userDataPath = await createTempUserDataDir();
const store = createSkillSelectionStore({ userDataPath });
await store.set({ mode: "custom", skills: ["paseo"] });
const saved = await store.set({ mode: "all" });
expect(saved).toEqual({ mode: "all" });
expect(await createSkillSelectionStore({ userDataPath }).get()).toEqual({ mode: "all" });
});
it("falls back to all when the saved file is not valid JSON", async () => {
const userDataPath = await createTempUserDataDir();
await seedSelectionFile(userDataPath, "{ not valid json");
expect(await createSkillSelectionStore({ userDataPath }).get()).toEqual({ mode: "all" });
});
it("falls back to all when the saved mode is unknown", async () => {
const userDataPath = await createTempUserDataDir();
await seedSelectionFile(
userDataPath,
JSON.stringify({ version: 1, selection: { mode: "some-future-mode", skills: ["paseo"] } }),
);
expect(await createSkillSelectionStore({ userDataPath }).get()).toEqual({ mode: "all" });
});
it("normalizes a custom selection that was hand-edited on disk", async () => {
const userDataPath = await createTempUserDataDir();
await seedSelectionFile(
userDataPath,
JSON.stringify({
version: 1,
selection: { mode: "custom", skills: ["paseo-loop", 3, "paseo", "paseo"] },
}),
);
expect(await createSkillSelectionStore({ userDataPath }).get()).toEqual({
mode: "custom",
skills: ["paseo", "paseo-loop"],
});
});
it("treats a custom selection with no usable skills array as an empty selection", async () => {
const userDataPath = await createTempUserDataDir();
await seedSelectionFile(
userDataPath,
JSON.stringify({ version: 1, selection: { mode: "custom" } }),
);
expect(await createSkillSelectionStore({ userDataPath }).get()).toEqual({
mode: "custom",
skills: [],
});
});
it("leaves no temp files behind when saves overlap", async () => {
const userDataPath = await createTempUserDataDir();
const store = createSkillSelectionStore({ userDataPath });
await Promise.all([
store.set({ mode: "custom", skills: ["paseo"] }),
store.set({ mode: "all" }),
store.set({ mode: "custom", skills: ["paseo-loop"] }),
]);
expect(await readdir(userDataPath)).toEqual(["skill-selection.json"]);
});
});
@@ -1,101 +0,0 @@
import { randomUUID } from "node:crypto";
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import path from "node:path";
import type { SkillSelection } from "./operations.js";
export interface SkillSelectionStore {
get(): Promise<SkillSelection>;
set(selection: unknown): Promise<SkillSelection>;
}
interface PersistedSkillSelectionDocument {
version: 1;
selection: SkillSelection;
}
const SKILL_SELECTION_FILENAME = "skill-selection.json";
// Installs that predate the picker have no file. They keep every bundled skill,
// which is what they had before, and what `all` keeps meaning as the bundle grows.
const DEFAULT_SKILL_SELECTION: SkillSelection = { mode: "all" };
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error;
}
export function coerceSkillNames(value: unknown): string[] {
if (!Array.isArray(value)) return [];
const names = value
.filter((entry): entry is string => typeof entry === "string")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
return [...new Set(names)].sort();
}
/**
* The one place an unvalidated selection becomes a `SkillSelection`. Whether a
* name is actually in the bundle is not decided here — the catalog belongs to
* the bundle directory, so `operations` resolves that against the live catalog.
*/
export function coerceSkillSelection(value: unknown): SkillSelection {
if (!isRecord(value)) return DEFAULT_SKILL_SELECTION;
if (value.mode === "all") return { mode: "all" };
if (value.mode === "custom") return { mode: "custom", skills: coerceSkillNames(value.skills) };
return DEFAULT_SKILL_SELECTION;
}
function coerceDocument(value: unknown): PersistedSkillSelectionDocument {
const selection = isRecord(value)
? coerceSkillSelection(value.selection)
: DEFAULT_SKILL_SELECTION;
return { version: 1, selection };
}
export function createSkillSelectionStore({
userDataPath,
}: {
userDataPath: string;
}): SkillSelectionStore {
const filePath = path.join(userDataPath, SKILL_SELECTION_FILENAME);
let persistQueue: Promise<void> = Promise.resolve();
async function persist(document: PersistedSkillSelectionDocument): Promise<void> {
const write = async () => {
await mkdir(userDataPath, { recursive: true });
const tempFilePath = `${filePath}.tmp.${process.pid}.${randomUUID()}`;
await writeFile(tempFilePath, `${JSON.stringify(document, null, 2)}\n`, "utf8");
await rename(tempFilePath, filePath);
};
const queued = persistQueue.then(write, write);
persistQueue = queued.catch(() => undefined);
await queued;
}
return {
async get(): Promise<SkillSelection> {
let raw: string;
try {
raw = await readFile(filePath, "utf8");
} catch (error) {
if (!isNodeError(error) || error.code !== "ENOENT") throw error;
return DEFAULT_SKILL_SELECTION;
}
try {
return coerceDocument(JSON.parse(raw)).selection;
} catch {
return DEFAULT_SKILL_SELECTION;
}
},
async set(selection: unknown): Promise<SkillSelection> {
const document = coerceDocument({ selection });
await persist(document);
return document.selection;
},
};
}
@@ -1,16 +0,0 @@
import type { DesktopCommandHandler } from "../../settings/desktop-settings-commands.js";
import type { SkillsController } from "./skills-controller.js";
export function createSkillsCommandHandlers({
controller,
}: {
controller: SkillsController;
}): Record<string, DesktopCommandHandler> {
return {
get_skills_status: () => controller.status(),
install_skills: () => controller.install(),
update_skills: () => controller.update(),
uninstall_skills: () => controller.uninstall(),
save_skills_selection: (args) => controller.save(args),
};
}
File diff suppressed because it is too large Load Diff
@@ -1,160 +0,0 @@
import {
autoUpdateInstalledSkills,
getSkillsStatus,
installSkills,
type SkillSelection,
type SkillsStatus,
type SkillTargets,
uninstallSkills,
updateSkills,
} from "./operations.js";
import {
coerceSkillNames,
coerceSkillSelection,
type SkillSelectionStore,
} from "./selection-store.js";
import {
beginSkillsTransaction,
recoverInterruptedSkillTransactions,
} from "./skills-transaction.js";
/** Everything the settings UI needs to render skills in one round trip. */
export interface SkillsSnapshot extends SkillsStatus {
selection: SkillSelection;
}
export interface SkillsSaveResult extends SkillsSnapshot {
/**
* Non-null when the save did nothing because these directories would be
* deleted and the request had not confirmed them. Retry with the names.
*/
confirmationRequired: { removals: string[] } | null;
}
export interface SkillsController {
status(): Promise<SkillsSnapshot>;
install(): Promise<SkillsSnapshot>;
update(): Promise<SkillsSnapshot>;
uninstall(): Promise<SkillsSnapshot>;
autoUpdate(): Promise<SkillsSnapshot>;
save(request: unknown): Promise<SkillsSaveResult>;
}
type Converge = (targets: SkillTargets, selection: SkillSelection) => Promise<SkillsStatus>;
const MAX_SAVE_ATTEMPTS = 3;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/**
* The single writer for installed skills. Startup convergence and every settings
* command run through one queue, so no operation applies a plan computed from a
* selection another one has already replaced, and no read sees a half-applied
* directory. Ordering between concurrent callers is not promised — consistency
* after both finish is.
*/
export function createSkillsController({
resolveTargets,
selectionStore,
}: {
// Resolved per operation, not at wiring time: the bundle path depends on how
// the app was packaged, which is not knowable when the controller is built.
resolveTargets: () => SkillTargets;
selectionStore: SkillSelectionStore;
}): SkillsController {
let queue: Promise<unknown> = Promise.resolve();
function serialize<T>(run: () => Promise<T>): Promise<T> {
const next = queue.then(run, run);
queue = next.then(
() => undefined,
() => undefined,
);
return next;
}
async function converge(apply: Converge): Promise<SkillsSnapshot> {
const selection = await selectionStore.get();
await recoverInterruptedSkillTransactions(resolveTargets(), selection);
return { ...(await apply(resolveTargets(), selection)), selection };
}
async function saveSelection(request: unknown): Promise<SkillsSaveResult> {
const targets = resolveTargets();
const next = coerceSkillSelection(request);
const previous = await selectionStore.get();
await recoverInterruptedSkillTransactions(targets, previous);
const confirmed = new Set(
coerceSkillNames(isRecord(request) ? request.confirmedRemovals : null),
);
// The plan comes from a scan taken here, inside the queue, and is the exact
// plan applied below. Deciding what will be deleted from an older snapshot
// leaves a window for a directory to appear and be deleted unannounced.
let plan = await getSkillsStatus(targets, next);
for (let attempt = 0; attempt < MAX_SAVE_ATTEMPTS; attempt += 1) {
const removals = plan.ops.filter((op) => op.kind === "delete").map((op) => op.name);
if (removals.some((name) => !confirmed.has(name))) {
return {
...(await getSkillsStatus(targets, previous)),
selection: previous,
confirmationRequired: { removals },
};
}
// Capture only paths this frozen plan can mutate. Capturing every managed
// path would make rollback delete a new external directory that appeared
// after the scan merely because it was absent from the snapshot.
const transaction = await beginSkillsTransaction(targets, previous, next, plan.ops);
let status: SkillsStatus;
try {
status = await installSkills(targets, next, {
...plan,
// beginSkillsTransaction atomically staged these directories. Running
// removeSkill afterward would delete a path another writer recreated.
ops: plan.ops.filter((op) => op.kind !== "delete"),
});
} catch (error) {
await transaction.rollback();
throw error;
}
// An empty custom selection converges to the intentional not-installed
// state; zero remaining operations is the invariant, not the label.
if (status.ops.length === 0) {
try {
await selectionStore.set(next);
} catch (error) {
await transaction.rollback();
throw error;
}
try {
await transaction.commit();
} catch {
// Selection and disk convergence are already durable. The owned
// manifest stays behind so the next serialized operation retries
// cleanup; reporting this save as failed would misstate its outcome.
}
return { ...status, selection: next, confirmationRequired: null };
}
// The frozen plan became incomplete while it was being applied. Put only
// its mutations back, rescan, and either request confirmation for a new
// deletion or retry harmless additions/updates from the fresh plan.
await transaction.rollback();
plan = await getSkillsStatus(targets, next);
}
throw new Error("Skills changed repeatedly while the selection was being saved");
}
return {
status: () => serialize(() => converge(getSkillsStatus)),
install: () => serialize(() => converge(updateSkills)),
update: () => serialize(() => converge(updateSkills)),
uninstall: () => serialize(() => converge(uninstallSkills)),
autoUpdate: () => serialize(() => converge(autoUpdateInstalledSkills)),
save: (selection) => serialize(() => saveSelection(selection)),
};
}
@@ -1,607 +0,0 @@
import { createHash, randomUUID } from "node:crypto";
import {
cp,
lstat,
mkdir,
readdir,
readFile,
readlink,
realpath,
rename,
rm,
stat,
writeFile,
} from "node:fs/promises";
import path from "node:path";
import {
listManagedSkillNames,
type SkillOp,
type SkillSelection,
type SkillTargets,
} from "./operations.js";
import { listFilesRecursive } from "./sync.js";
export interface SkillsTransaction {
/** Convergence stuck. Drop the staged copies. */
commit(): Promise<void>;
/** Put every captured directory back exactly as it was, then drop the staging. */
rollback(): Promise<void>;
}
/**
* Written before anything is touched and updated once capture finishes. A
* directory without a readable one of these is not ours and is never deleted —
* the prefix alone is a guess, and guessing here deletes someone's files.
*/
interface TransactionManifest {
owner: typeof MANIFEST_OWNER;
version: 1;
/** `capturing` means convergence never started, so there is nothing to undo. */
phase: "capturing" | "converging";
previousSelection: SkillSelection;
nextSelection: SkillSelection;
entries: CapturedDirectory[];
}
interface CapturedDirectory {
livePath: string;
kind: SkillOp["kind"];
/** Relative to the manifest directory, or an absolute same-filesystem delete stage. */
backupPath: string | null;
}
const MANIFEST_OWNER = "paseo-skills-transaction";
const MANIFEST_FILENAME = "transaction.json";
const TRANSACTION_PREFIX = ".paseo-skills-transaction-";
const RECOVERED_PREFIX = ".paseo-skills-recovered-";
const BACKUP_DIRNAME = "backup";
const MANAGED_FILES_MANIFEST = ".paseo-managed-files.json";
async function isDirectory(target: string): Promise<boolean> {
const info = await stat(target).catch(() => null);
return info?.isDirectory() === true;
}
function isSkillSelection(value: unknown): value is SkillSelection {
if (typeof value !== "object" || value === null) return false;
const selection = value as Partial<SkillSelection>;
if (selection.mode === "all") return true;
return (
selection.mode === "custom" &&
Array.isArray(selection.skills) &&
selection.skills.every((name) => typeof name === "string")
);
}
async function readManifest(transactionDir: string): Promise<TransactionManifest | null> {
const raw = await readFile(path.join(transactionDir, MANIFEST_FILENAME), "utf8").catch(
() => null,
);
if (raw === null) return null;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
if (typeof parsed !== "object" || parsed === null) return null;
const manifest = parsed as Partial<TransactionManifest>;
if (manifest.owner !== MANIFEST_OWNER || manifest.version !== 1) return null;
if (manifest.phase !== "capturing" && manifest.phase !== "converging") return null;
if (!isSkillSelection(manifest.previousSelection) || !isSkillSelection(manifest.nextSelection)) {
return null;
}
if (!Array.isArray(manifest.entries)) return null;
const validEntries = manifest.entries.every(
(entry) =>
typeof entry?.livePath === "string" &&
(entry.kind === "add" || entry.kind === "update" || entry.kind === "delete") &&
(entry.backupPath === null || typeof entry.backupPath === "string"),
);
if (!validEntries) return null;
const entries = manifest.entries as CapturedDirectory[];
return {
owner: MANIFEST_OWNER,
version: 1,
phase: manifest.phase,
previousSelection: manifest.previousSelection,
nextSelection: manifest.nextSelection,
entries,
};
}
async function writeManifest(transactionDir: string, manifest: TransactionManifest): Promise<void> {
await writeFile(
path.join(transactionDir, MANIFEST_FILENAME),
`${JSON.stringify(manifest, null, 2)}\n`,
"utf8",
);
}
function selectionsEqual(left: SkillSelection, right: SkillSelection): boolean {
if (left.mode !== right.mode) return false;
if (left.mode === "all" || right.mode === "all") return true;
return (
left.skills.length === right.skills.length &&
left.skills.every((name, index) => name === right.skills[index])
);
}
async function validateEntries(
targets: SkillTargets,
transactionDir: string,
entries: CapturedDirectory[],
): Promise<boolean> {
const names = new Set(await listManagedSkillNames(targets.sourceDir));
const roots = [targets.agentsDir, targets.claudeDir, targets.codexDir];
return entries.every((entry) => {
const rootIndex = roots.findIndex((root) => path.dirname(entry.livePath) === root);
if (rootIndex === -1) return false;
const name = path.basename(entry.livePath);
if (!names.has(name)) return false;
if (entry.backupPath === null) return true;
const expected = path.join(BACKUP_DIRNAME, String(rootIndex), name);
const sameFilesystemStage = path.join(roots[rootIndex]!, path.basename(transactionDir), name);
if (entry.kind === "delete" && entry.backupPath === sameFilesystemStage) return true;
return (
entry.backupPath === expected &&
path
.resolve(transactionDir, entry.backupPath)
.startsWith(`${path.resolve(transactionDir)}${path.sep}`)
);
});
}
function resolveBackupPath(transactionDir: string, backupPath: string | null): string | null {
if (backupPath === null) return null;
return path.isAbsolute(backupPath) ? backupPath : path.join(transactionDir, backupPath);
}
async function discardTransaction(
transactionDir: string,
entries: readonly CapturedDirectory[],
): Promise<void> {
const stagingDirs = new Set(
entries.flatMap((entry) =>
entry.backupPath !== null && path.isAbsolute(entry.backupPath)
? [path.dirname(entry.backupPath)]
: [],
),
);
for (const stagingDir of stagingDirs) {
await rm(stagingDir, { recursive: true, force: true });
}
await rm(transactionDir, { recursive: true, force: true });
}
async function listAllFiles(rootDir: string): Promise<string[]> {
const files: string[] = [];
async function walk(dir: string): Promise<void> {
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) await walk(full);
else if (entry.isFile()) files.push(path.relative(rootDir, full));
}
}
await walk(rootDir);
return files;
}
async function expectedSyncedFiles(sourceDir: string, name: string): Promise<Map<string, Buffer>> {
const skillDir = path.join(sourceDir, name);
const files = new Map<string, Buffer>();
const hashes: Record<string, string> = {};
for (const rel of await listFilesRecursive(skillDir)) {
const contents = await readFile(path.join(skillDir, rel));
files.set(rel, contents);
hashes[rel] = createHash("sha256").update(contents).digest("hex");
}
files.set(
MANAGED_FILES_MANIFEST,
Buffer.from(`${JSON.stringify({ version: 1, files: hashes }, null, 2)}\n`),
);
return files;
}
async function copyStagedEntry(source: string, target: string): Promise<void> {
const info = await lstat(source);
await mkdir(path.dirname(target), { recursive: true });
await cp(source, target, {
recursive: info.isDirectory(),
preserveTimestamps: true,
verbatimSymlinks: true,
});
}
async function mergeBackupDirectory(livePath: string, backupPath: string | null): Promise<boolean> {
if (backupPath === null) return false;
const liveInfo = await lstat(livePath).catch(() => null);
const destination = liveInfo?.isSymbolicLink() ? await realpath(livePath) : livePath;
await mkdir(destination, { recursive: true });
let hasConflict = false;
async function mergeMissing(sourceDir: string, destinationDir: string): Promise<void> {
for (const entry of await readdir(sourceDir, { withFileTypes: true })) {
const source = path.join(sourceDir, entry.name);
const target = path.join(destinationDir, entry.name);
const sourceInfo = await lstat(source);
const targetInfo = await lstat(target).catch(() => null);
if (targetInfo === null) {
await copyStagedEntry(source, target);
continue;
}
if (
sourceInfo.isDirectory() &&
!sourceInfo.isSymbolicLink() &&
targetInfo.isDirectory() &&
!targetInfo.isSymbolicLink()
) {
if ((sourceInfo.mode & 0o777) !== (targetInfo.mode & 0o777)) hasConflict = true;
await mergeMissing(source, target);
continue;
}
if (sourceInfo.isFile() && targetInfo.isFile()) {
const [sourceContents, targetContents] = await Promise.all([
readFile(source),
readFile(target),
]);
if (
!sourceContents.equals(targetContents) ||
(sourceInfo.mode & 0o777) !== (targetInfo.mode & 0o777)
) {
hasConflict = true;
}
continue;
}
if (sourceInfo.isSymbolicLink() && targetInfo.isSymbolicLink()) {
const [sourceTarget, targetTarget] = await Promise.all([
readlink(source),
readlink(target),
]);
if (sourceTarget !== targetTarget) hasConflict = true;
continue;
}
hasConflict = true;
}
}
await mergeMissing(backupPath, destination);
return hasConflict;
}
function recoveryBase(transactionDir: string, livePath: string, backupPath: string): string {
const transactionId = path.basename(transactionDir).replace(TRANSACTION_PREFIX, "");
// Update backups live inside the central transaction directory; delete stages
// live beside their target root. Keep quarantine on the backup's filesystem
// so preserving it remains an atomic rename across mounted agent homes.
const recoveryParent = backupPath.startsWith(`${transactionDir}${path.sep}`)
? path.dirname(transactionDir)
: path.dirname(livePath);
return path.join(
recoveryParent,
`${RECOVERED_PREFIX}${path.basename(livePath)}-${transactionId}`,
);
}
async function findQuarantinedEntry(
transactionDir: string,
livePath: string,
backupPath: string,
claimed: ReadonlySet<string>,
): Promise<string | null> {
const base = recoveryBase(transactionDir, livePath, backupPath);
const name = path.basename(base);
const matches = (await readdir(path.dirname(base)).catch(() => []))
.filter((entry) => entry === name || entry.startsWith(`${name}-`))
.map((entry) => path.join(path.dirname(base), entry))
.filter((entry) => !claimed.has(entry))
.sort();
return matches[0] ?? null;
}
async function quarantineStagedEntry(
transactionDir: string,
livePath: string,
backupPath: string,
): Promise<void> {
const base = recoveryBase(transactionDir, livePath, backupPath);
let destination = base;
for (let suffix = 1; await lstat(destination).catch(() => null); suffix += 1) {
destination = `${base}-${suffix}`;
}
await rename(backupPath, destination);
}
async function restoreDeletedDirectory(
transactionDir: string,
livePath: string,
backupPath: string | null,
): Promise<void> {
if (backupPath === null) return;
const liveInfo = await lstat(livePath).catch(() => null);
if (liveInfo === null) {
await mkdir(path.dirname(livePath), { recursive: true });
await rename(backupPath, livePath);
return;
}
const backupInfo = await lstat(backupPath);
if (backupInfo.isDirectory() && !backupInfo.isSymbolicLink() && (await isDirectory(livePath))) {
const hasConflict = await mergeBackupDirectory(livePath, backupPath);
if (hasConflict) await quarantineStagedEntry(transactionDir, livePath, backupPath);
return;
}
// A concurrent writer recreated the path with an incompatible type. Keep its
// entry live and move the staged original outside transaction cleanup so
// neither side is overwritten and later controller operations can proceed.
await quarantineStagedEntry(transactionDir, livePath, backupPath);
}
async function restoreStagedEntry(
liveRoot: string,
backupRoot: string | null,
rel: string,
): Promise<void> {
const live = path.join(liveRoot, rel);
const backup = backupRoot && path.join(backupRoot, rel);
await rm(live, { recursive: true, force: true });
if (backup === null || !(await lstat(backup).catch(() => null))) return;
await copyStagedEntry(backup, live);
}
async function undoSyncedDirectory(
transactionDir: string,
livePath: string,
backupPath: string | null,
expectedFiles: ReadonlyMap<string, Buffer>,
): Promise<void> {
for (const rel of await listAllFiles(livePath)) {
const target = path.join(livePath, rel);
const current = await readFile(target).catch(() => null);
const expected = expectedFiles.get(rel);
if (current === null || expected === undefined || !current.equals(expected)) continue;
await restoreStagedEntry(livePath, backupPath, rel);
}
// Sync can prune files that an older managed-files manifest owned. Restore
// missing staged entries with their type and metadata, but never overwrite a
// path another writer recreated.
const hasConflict = await mergeBackupDirectory(livePath, backupPath);
if (hasConflict && backupPath !== null) {
await quarantineStagedEntry(transactionDir, livePath, backupPath);
}
if ((await readdir(livePath).catch(() => [])).length === 0) {
await rm(livePath, { recursive: true, force: true });
}
}
async function restore(
targets: SkillTargets,
transactionDir: string,
manifest: TransactionManifest,
): Promise<void> {
// Nothing was converged yet, so the live tree is already the pre-save state.
if (manifest.phase === "capturing") return;
const expectedByName = new Map<string, Map<string, Buffer>>();
const claimedQuarantines = new Set<string>();
for (const entry of manifest.entries) {
const backup = resolveBackupPath(transactionDir, entry.backupPath);
if (backup !== null && !(await lstat(backup).catch(() => null))) {
// Atomic rename leaves either the live path or its stage. If neither is
// present, an external actor deleted it; there is nothing this
// transaction can restore. Other captured entries still need rollback.
if (entry.kind === "delete") continue;
const quarantined = await findQuarantinedEntry(
transactionDir,
entry.livePath,
backup,
claimedQuarantines,
);
if (quarantined !== null) {
claimedQuarantines.add(quarantined);
continue;
}
throw new Error(`Skills transaction backup is missing: ${backup}`);
}
if (entry.kind === "delete") {
await restoreDeletedDirectory(transactionDir, entry.livePath, backup);
continue;
}
const liveInfo = await lstat(entry.livePath).catch(() => null);
if (liveInfo !== null && !(await isDirectory(entry.livePath))) {
// Add/update rollback has the same conflict state as delete rollback. A
// recreated file or file symlink belongs to the external writer; preserve
// it, and preserve any captured original outside transaction cleanup.
if (backup !== null) {
await quarantineStagedEntry(transactionDir, entry.livePath, backup);
}
continue;
}
const backupInfo = backup && (await lstat(backup).catch(() => null));
if (backupInfo && !backupInfo.isDirectory()) {
if (liveInfo !== null && (await isDirectory(entry.livePath))) {
// The captured file was replaced by a directory after the transaction
// started. Preserve the external tree and quarantine the captured entry
// instead of recursively deleting the replacement during recovery.
await quarantineStagedEntry(transactionDir, entry.livePath, backup);
continue;
}
await restoreStagedEntry(entry.livePath, backup, ".");
continue;
}
const name = path.basename(entry.livePath);
let expected = expectedByName.get(name);
if (!expected) {
expected = await expectedSyncedFiles(targets.sourceDir, name);
expectedByName.set(name, expected);
}
await undoSyncedDirectory(transactionDir, entry.livePath, backup, expected);
}
}
function transactionParents(targets: SkillTargets): string[] {
return [
...new Set(
[targets.agentsDir, targets.claudeDir, targets.codexDir].map((root) => path.dirname(root)),
),
];
}
/**
* Finishes what an interrupted save started. A transaction directory only exists
* while a save is mid-flight, so finding one means the process died between
* mutating the directories and committing the selection — the staged copies are
* the only place the user's files still exist. Restore them, then drop the
* transaction. Anything without our manifest is left where it is.
*/
export async function recoverInterruptedSkillTransactions(
targets: SkillTargets,
committedSelection: SkillSelection,
): Promise<void> {
for (const parent of transactionParents(targets)) {
const entries = await readdir(parent).catch(() => []);
for (const entry of entries) {
if (!entry.startsWith(TRANSACTION_PREFIX)) continue;
const transactionDir = path.join(parent, entry);
const manifest = await readManifest(transactionDir);
if (!manifest) continue;
if (!(await validateEntries(targets, transactionDir, manifest.entries))) continue;
if (selectionsEqual(committedSelection, manifest.nextSelection)) {
await discardTransaction(transactionDir, manifest.entries);
continue;
}
if (!selectionsEqual(committedSelection, manifest.previousSelection)) {
throw new Error(
`Cannot safely recover interrupted skills transaction at ${transactionDir}`,
);
}
await restore(targets, transactionDir, manifest);
await discardTransaction(transactionDir, manifest.entries);
}
}
}
/**
* Captures the managed skill directories so a failed save can put the machine
* back exactly as it was — including files the user added inside them, which
* re-running an install cannot reconstruct.
*/
export async function beginSkillsTransaction(
targets: SkillTargets,
previousSelection: SkillSelection,
nextSelection: SkillSelection,
ops: readonly SkillOp[],
): Promise<SkillsTransaction> {
const roots = [targets.agentsDir, targets.claudeDir, targets.codexDir];
const seenRoots = new Set<string>();
const uniqueRoots: Array<{ root: string; rootIndex: number }> = [];
for (const [rootIndex, root] of roots.entries()) {
const identity = await realpath(root).catch(() => path.resolve(root));
if (seenRoots.has(identity)) continue;
seenRoots.add(identity);
uniqueRoots.push({ root, rootIndex });
}
// Staged next to the skills tree rather than inside it: same filesystem, so a
// restore is a local copy, and never a directory a skill scan walks into.
const transactionDir = path.join(path.dirname(roots[0]!), `${TRANSACTION_PREFIX}${randomUUID()}`);
const entries: CapturedDirectory[] = [];
async function discard(): Promise<void> {
await discardTransaction(transactionDir, entries);
}
await mkdir(transactionDir, { recursive: true });
await writeManifest(transactionDir, {
owner: MANIFEST_OWNER,
version: 1,
phase: "capturing",
previousSelection,
nextSelection,
entries: [],
});
try {
for (const { root, rootIndex } of uniqueRoots) {
for (const op of ops) {
const name = op.name;
const livePath = path.join(root, name);
const liveInfo = await lstat(livePath).catch(() => null);
if (liveInfo === null) {
entries.push({ livePath, kind: op.kind, backupPath: null });
continue;
}
const liveIsDirectory = await isDirectory(livePath);
const backupPath =
op.kind === "delete"
? path.join(root, path.basename(transactionDir), name)
: path.join(BACKUP_DIRNAME, String(rootIndex), name);
if (op.kind !== "delete") {
const captureSource =
liveInfo.isSymbolicLink() && liveIsDirectory ? await realpath(livePath) : livePath;
const captureInfo = await lstat(captureSource);
await cp(captureSource, resolveBackupPath(transactionDir, backupPath)!, {
recursive: captureInfo.isDirectory(),
preserveTimestamps: true,
verbatimSymlinks: true,
});
}
entries.push({ livePath, kind: op.kind, backupPath });
}
}
await writeManifest(transactionDir, {
owner: MANIFEST_OWNER,
version: 1,
phase: "converging",
previousSelection,
nextSelection,
entries,
});
} catch (error) {
// Nothing has been converged yet, so failing to stage means failing the save
// before it touches anything.
await discard();
throw error;
}
try {
// Each delete is staged inside its own skills root, so rename stays atomic
// even when the agent homes are symlinks or mounts on different filesystems.
// Any writer
// holding the old directory keeps writing into the staged inode; any writer
// recreating the old path remains live and is merged on rollback. The later
// convergence plan deliberately omits deletes.
for (const entry of entries) {
if (entry.kind !== "delete" || entry.backupPath === null) continue;
const backup = resolveBackupPath(transactionDir, entry.backupPath)!;
await mkdir(path.dirname(backup), { recursive: true });
await rename(entry.livePath, backup);
}
} catch (error) {
await restore(targets, transactionDir, {
owner: MANIFEST_OWNER,
version: 1,
phase: "converging",
previousSelection,
nextSelection,
entries,
});
await discard();
throw error;
}
return {
commit: discard,
async rollback(): Promise<void> {
await restore(targets, transactionDir, {
owner: MANIFEST_OWNER,
version: 1,
phase: "converging",
previousSelection,
nextSelection,
entries,
});
await discard();
},
};
}
@@ -1,374 +0,0 @@
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-committee", {
"SKILL.md": "committee content",
"references/roles.md": "roles content",
});
await syncSkills({
sourceDir: sandbox.sourceDir,
agentsDir: sandbox.agentsDir,
claudeDir: sandbox.claudeDir,
codexDir: sandbox.codexDir,
skillNames: ["paseo-committee"],
});
expect(
await fs.readFile(path.join(sandbox.agentsDir, "paseo-committee", "SKILL.md"), "utf-8"),
).toBe("committee content");
expect(
await fs.readFile(
path.join(sandbox.agentsDir, "paseo-committee", "references", "roles.md"),
"utf-8",
),
).toBe("roles content");
expect(
await fs.readFile(
path.join(sandbox.codexDir, "paseo-committee", "references", "roles.md"),
"utf-8",
),
).toBe("roles content");
const claudeSkillDir = path.join(sandbox.claudeDir, "paseo-committee");
expect((await fs.lstat(claudeSkillDir)).isDirectory()).toBe(true);
expect(await fs.readFile(path.join(claudeSkillDir, "SKILL.md"), "utf-8")).toBe(
"committee 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("removes stale files previously written to managed skill dirs", async () => {
await writeBundleSkill(sandbox.sourceDir, "paseo", {
"SKILL.md": "old",
"references/stale.md": "stale",
});
const onDiskSkill = path.join(sandbox.agentsDir, "paseo");
await syncSkills({
sourceDir: sandbox.sourceDir,
agentsDir: sandbox.agentsDir,
claudeDir: sandbox.claudeDir,
codexDir: sandbox.codexDir,
skillNames: ["paseo"],
});
await fs.rm(path.join(sandbox.sourceDir, "paseo"), { recursive: true, force: true });
await writeBundleSkill(sandbox.sourceDir, "paseo", { "SKILL.md": "new" });
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");
await expect(fs.access(path.join(onDiskSkill, "references", "stale.md"))).rejects.toThrow();
await expect(fs.access(path.join(onDiskSkill, "references"))).rejects.toThrow();
});
it("preserves user-added files in managed skill dirs", 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, "my-context.md"), "user context");
await fs.writeFile(path.join(onDiskSkill, "references", "notes.md"), "user notes");
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, "my-context.md"), "utf-8")).toBe(
"user context",
);
expect(await fs.readFile(path.join(onDiskSkill, "references", "notes.md"), "utf-8")).toBe(
"user notes",
);
});
it.skipIf(process.platform === "win32")(
"rejects managed paths that would write through a symlink",
async () => {
await writeBundleSkill(sandbox.sourceDir, "paseo", {
"SKILL.md": "new skill",
"references/guide.md": "new guide",
});
const onDiskSkill = path.join(sandbox.agentsDir, "paseo");
const externalReferences = path.join(sandbox.root, "external-references");
await fs.mkdir(onDiskSkill, { recursive: true });
await fs.mkdir(externalReferences, { recursive: true });
await fs.writeFile(path.join(onDiskSkill, "SKILL.md"), "old skill");
await fs.writeFile(path.join(externalReferences, "guide.md"), "external guide");
await fs.symlink(externalReferences, path.join(onDiskSkill, "references"), "dir");
await expect(
syncSkills({
sourceDir: sandbox.sourceDir,
agentsDir: sandbox.agentsDir,
claudeDir: sandbox.claudeDir,
codexDir: sandbox.codexDir,
skillNames: ["paseo"],
}),
).rejects.toThrow("Cannot sync through symbolic link");
expect(await fs.readFile(path.join(onDiskSkill, "SKILL.md"), "utf-8")).toBe("old skill");
expect(await fs.readFile(path.join(externalReferences, "guide.md"), "utf-8")).toBe(
"external guide",
);
expect((await fs.lstat(path.join(onDiskSkill, "references"))).isSymbolicLink()).toBe(true);
},
);
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();
});
});
@@ -1,222 +0,0 @@
import { createHash } from "node:crypto";
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;
}
const MANAGED_FILES_MANIFEST = ".paseo-managed-files.json";
interface ManagedFilesManifest {
version: 1;
files: Record<string, string>;
}
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);
const rel = path.relative(rootDir, full);
if (rel === MANAGED_FILES_MANIFEST) continue;
if (entry.isDirectory()) {
await walk(full);
} else if (entry.isFile()) {
out.push(rel);
}
}
}
await walk(rootDir);
return out;
}
async function readManagedFilesManifest(dstDir: string): Promise<ManagedFilesManifest | null> {
const raw = await fs
.readFile(path.join(dstDir, MANAGED_FILES_MANIFEST), "utf-8")
.catch(() => null);
if (!raw) return null;
const parsed = safeParseJson(raw) as Partial<ManagedFilesManifest> | null;
if (!parsed) return null;
if (parsed.version !== 1 || typeof parsed.files !== "object" || parsed.files === null)
return null;
return { version: 1, files: parsed.files as Record<string, string> };
}
function safeParseJson(raw: string): unknown {
try {
return JSON.parse(raw);
} catch {
return null;
}
}
async function hashFile(filePath: string): Promise<string> {
const buf = await fs.readFile(filePath);
return createHash("sha256").update(buf).digest("hex");
}
async function writeManifestIfChanged(
dstDir: string,
files: Record<string, string>,
): Promise<boolean> {
const manifest: ManagedFilesManifest = { version: 1, files };
const next = `${JSON.stringify(manifest, null, 2)}\n`;
const manifestPath = path.join(dstDir, MANAGED_FILES_MANIFEST);
const current = await fs.readFile(manifestPath, "utf-8").catch(() => null);
if (current === next) return false;
await fs.mkdir(dstDir, { recursive: true });
await fs.writeFile(manifestPath, next);
return true;
}
async function pruneEmptyParentDirs(rootDir: string, rels: readonly string[]): Promise<void> {
const dirs = new Set<string>();
for (const rel of rels) {
let dir = path.dirname(rel);
while (dir !== ".") {
dirs.add(dir);
dir = path.dirname(dir);
}
}
const deepestFirst = [...dirs].sort(
(a, b) => b.split(path.sep).length - a.split(path.sep).length,
);
for (const rel of deepestFirst) {
await fs.rmdir(path.join(rootDir, rel)).catch(() => {});
}
}
async function assertManagedPathsStayInsideSkill(rootDir: string, rels: readonly string[]) {
for (const rel of rels) {
const normalized = path.normalize(rel);
if (
path.isAbsolute(normalized) ||
normalized === ".." ||
normalized.startsWith(`..${path.sep}`)
) {
throw new Error(`Cannot sync managed path outside skill directory: ${rel}`);
}
let current = rootDir;
for (const segment of normalized.split(path.sep)) {
current = path.join(current, segment);
const info = await fs.lstat(current).catch((error: NodeJS.ErrnoException) => {
if (error.code === "ENOENT") return null;
throw error;
});
if (info === null) break;
if (info.isSymbolicLink()) {
throw new Error(`Cannot sync through symbolic link: ${current}`);
}
}
}
}
async function syncDirectoryFiles(srcDir: string, dstDir: string): Promise<number> {
const files = await listFilesRecursive(srcDir);
const srcFileSet = new Set(files);
const srcHashes: Record<string, string> = {};
for (const rel of files) {
srcHashes[rel] = await hashFile(path.join(srcDir, rel));
}
await assertManagedPathsStayInsideSkill(dstDir, [MANAGED_FILES_MANIFEST]);
const previousManifest = await readManagedFilesManifest(dstDir);
await assertManagedPathsStayInsideSkill(dstDir, [
MANAGED_FILES_MANIFEST,
...files,
...Object.keys(previousManifest?.files ?? {}),
]);
let changed = 0;
for (const rel of files) {
if (await writeFileIfChanged(path.join(srcDir, rel), path.join(dstDir, rel))) {
changed++;
}
}
const deletedRels: string[] = [];
for (const [rel, previousHash] of Object.entries(previousManifest?.files ?? {})) {
if (srcFileSet.has(rel)) continue;
const dstPath = path.join(dstDir, rel);
const currentHash = await hashFile(dstPath).catch(() => null);
if (currentHash !== previousHash) continue;
await fs.rm(dstPath, { force: true });
deletedRels.push(rel);
changed++;
}
await pruneEmptyParentDirs(dstDir, deletedRels);
if (await writeManifestIfChanged(dstDir, srcHashes)) 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 };
}
-6
View File
@@ -87,7 +87,6 @@ import {
stopDesktopManagedDaemonOnQuitIfNeeded,
} from "./daemon/quit-lifecycle.js";
import { runDesktopStartup } from "./desktop-startup.js";
import { autoUpdateInstalledSkills } from "./integrations/skills/index.js";
import { registerBrowserAutomationIpc } from "./features/browser-automation/ipc.js";
import { BrowserKeyboard } from "./features/browser-keyboard/index.js";
import { installAppUpdateOnQuit } from "./features/auto-updater.js";
@@ -1035,11 +1034,6 @@ void runDesktopStartup({
runCliPassthroughIfRequested,
inheritLoginShellEnv,
bootstrapGui: bootstrap,
autoUpdateInstalledSkills: () => {
void autoUpdateInstalledSkills().catch((error) => {
log.error("[skills] auto-update failed", error);
});
},
}).catch((error) => {
const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
process.stderr.write(`${message}\n`);