diff --git a/packages/app/src/desktop/components/integrations-section.tsx b/packages/app/src/desktop/components/integrations-section.tsx index c4b35a947..4fcddef99 100644 --- a/packages/app/src/desktop/components/integrations-section.tsx +++ b/packages/app/src/desktop/components/integrations-section.tsx @@ -7,12 +7,34 @@ import { settingsStyles } from "@/styles/settings"; import { SettingsSection } from "@/screens/settings/settings-section"; import { Button } from "@/components/ui/button"; import { openExternalUrl } from "@/utils/open-external-url"; -import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; -import { useCliInstall, useSkillsInstall } from "@/desktop/hooks/use-install-status"; +import { confirmDialog } from "@/utils/confirm-dialog"; +import { + shouldUseDesktopDaemon, + type SkillOp, + type SkillsStatus, +} from "@/desktop/daemon/desktop-daemon"; +import { useCliInstall, useSkillsStatus } from "@/desktop/hooks/use-install-status"; const CLI_DOCS_URL = "https://paseo.sh/docs/cli"; const SKILLS_DOCS_URL = "https://paseo.sh/docs/skills"; const ROW_WITH_BORDER_STYLE = [settingsStyles.row, settingsStyles.rowBorder]; +const UNINSTALL_MESSAGE = + "Removes all Paseo orchestration skills from ~/.agents, ~/.claude, ~/.codex."; + +const OP_KIND_ORDER: Record = { add: 0, update: 1, delete: 2 }; +const OP_KIND_LABEL: Record = { + add: "Add skill", + update: "Update skill", + delete: "Delete skill", +}; + +function formatUpdateMessage(ops: readonly SkillOp[]): string { + const sorted = [...ops].sort((a, b) => { + const kindOrder = OP_KIND_ORDER[a.kind] - OP_KIND_ORDER[b.kind]; + return kindOrder !== 0 ? kindOrder : a.name.localeCompare(b.name); + }); + return sorted.map((op) => `${OP_KIND_LABEL[op.kind]} ${op.name}`).join("\n"); +} export function IntegrationsSection() { const { theme } = useUnistyles(); @@ -25,16 +47,18 @@ export function IntegrationsSection() { } = useCliInstall(); const { status: skillsStatus, - isInstalling: isInstallingSkills, + isWorking: isSkillsWorking, install: installSkills, + update: updateSkills, + uninstall: uninstallSkills, refresh: refreshSkillsStatus, - } = useSkillsInstall(); + } = useSkillsStatus(); useFocusEffect( useCallback(() => { if (!showSection) return undefined; refreshCliStatus(); - refreshSkillsStatus(); + void refreshSkillsStatus(); return undefined; }, [refreshCliStatus, refreshSkillsStatus, showSection]), ); @@ -45,9 +69,33 @@ export function IntegrationsSection() { }, [installCli, isInstallingCli]); const handleInstallSkills = useCallback(() => { - if (isInstallingSkills) return; - installSkills(); - }, [installSkills, isInstallingSkills]); + if (isSkillsWorking) return; + void installSkills(); + }, [installSkills, isSkillsWorking]); + + const handleUpdateSkills = useCallback(async () => { + if (isSkillsWorking) return; + const ops = skillsStatus?.ops ?? []; + const confirmed = await confirmDialog({ + title: "Update Paseo skills?", + message: ops.length > 0 ? formatUpdateMessage(ops) : "Sync bundled skills to your machine.", + confirmLabel: "Update", + }); + if (!confirmed) return; + await updateSkills(); + }, [isSkillsWorking, skillsStatus, updateSkills]); + + const handleUninstallSkills = useCallback(async () => { + if (isSkillsWorking) return; + const confirmed = await confirmDialog({ + title: "Uninstall Paseo skills?", + message: UNINSTALL_MESSAGE, + confirmLabel: "Uninstall", + destructive: true, + }); + if (!confirmed) return; + await uninstallSkills(); + }, [isSkillsWorking, uninstallSkills]); const handleOpenCliDocs = useCallback(() => { void openExternalUrl(CLI_DOCS_URL); @@ -96,6 +144,8 @@ export function IntegrationsSection() { return null; } + const skillsState = skillsStatus?.state ?? null; + return ( @@ -130,30 +180,69 @@ export function IntegrationsSection() { Orchestration skills - Teach your agents to orchestrate through the CLI + {skillsState === "drift" + ? "Update available" + : "Teach your agents to orchestrate through the CLI"} - {skillsStatus?.installed ? ( - - - Installed - - ) : ( - - )} + ); } +interface SkillsActionsProps { + state: SkillsStatus["state"] | null; + isWorking: boolean; + onInstall: () => void; + onUpdate: () => void; + onUninstall: () => void; +} + +function SkillsActions({ state, isWorking, onInstall, onUpdate, onUninstall }: SkillsActionsProps) { + const { theme } = useUnistyles(); + + if (state === "up-to-date") { + return ( + + + + Installed + + + + ); + } + + if (state === "drift") { + return ( + + + + + ); + } + + return ( + + ); +} + const styles = StyleSheet.create((theme) => ({ headerLinks: { flexDirection: "row", @@ -174,4 +263,9 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, + actionsRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, })); diff --git a/packages/app/src/desktop/daemon/desktop-daemon.ts b/packages/app/src/desktop/daemon/desktop-daemon.ts index cf6bf859b..cabd81388 100644 --- a/packages/app/src/desktop/daemon/desktop-daemon.ts +++ b/packages/app/src/desktop/daemon/desktop-daemon.ts @@ -220,10 +220,67 @@ export async function installCli(): Promise { return parseInstallStatus(await invokeDesktopCommand("install_cli")); } -export async function getSkillsInstallStatus(): Promise { - return parseInstallStatus(await invokeDesktopCommand("get_skills_install_status")); +export type SkillsState = "not-installed" | "up-to-date" | "drift"; + +export type SkillOp = + | { kind: "add"; name: string } + | { kind: "update"; name: string } + | { kind: "delete"; name: string }; + +export interface SkillsStatus { + state: SkillsState; + ops: SkillOp[]; } -export async function installSkills(): Promise { - return parseInstallStatus(await invokeDesktopCommand("install_skills")); +function parseSkillsState(value: unknown): SkillsState { + switch (value) { + case "not-installed": + case "up-to-date": + case "drift": + return value; + default: + throw new Error(`Unexpected skills status state: ${String(value)}`); + } +} + +function parseSkillOp(raw: unknown): SkillOp { + if (!isRecord(raw)) { + throw new Error("Unexpected skill op response."); + } + const name = toStringOrNull(raw.name); + if (!name) throw new Error("Skill op missing name."); + switch (raw.kind) { + case "add": + return { kind: "add", name }; + case "update": + return { kind: "update", name }; + case "delete": + return { kind: "delete", name }; + default: + throw new Error(`Unexpected skill op kind: ${String(raw.kind)}`); + } +} + +function parseSkillsStatus(raw: unknown): SkillsStatus { + if (!isRecord(raw)) { + throw new Error("Unexpected skills status response."); + } + const ops = Array.isArray(raw.ops) ? raw.ops.map(parseSkillOp) : []; + return { state: parseSkillsState(raw.state), ops }; +} + +export async function getSkillsStatus(): Promise { + return parseSkillsStatus(await invokeDesktopCommand("get_skills_status")); +} + +export async function installSkills(): Promise { + return parseSkillsStatus(await invokeDesktopCommand("install_skills")); +} + +export async function updateSkills(): Promise { + return parseSkillsStatus(await invokeDesktopCommand("update_skills")); +} + +export async function uninstallSkills(): Promise { + return parseSkillsStatus(await invokeDesktopCommand("uninstall_skills")); } diff --git a/packages/app/src/desktop/hooks/use-install-status.test.tsx b/packages/app/src/desktop/hooks/use-install-status.test.tsx index edec5c6e2..da6c020c4 100644 --- a/packages/app/src/desktop/hooks/use-install-status.test.tsx +++ b/packages/app/src/desktop/hooks/use-install-status.test.tsx @@ -5,7 +5,7 @@ import React from "react"; import { act, renderHook, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { useCliInstall, useSkillsInstall } from "./use-install-status"; +import { useCliInstall, useSkillsStatus } from "./use-install-status"; const toast = vi.hoisted(() => ({ error: vi.fn(), @@ -15,9 +15,11 @@ const toast = vi.hoisted(() => ({ const desktopDaemon = vi.hoisted(() => ({ getCliInstallStatus: vi.fn(), - getSkillsInstallStatus: vi.fn(), installCli: vi.fn(), + getSkillsStatus: vi.fn(), installSkills: vi.fn(), + updateSkills: vi.fn(), + uninstallSkills: vi.fn(), shouldUseDesktopDaemon: vi.fn(() => true), })); @@ -89,11 +91,9 @@ describe("useCliInstall", () => { }); }); -describe("useSkillsInstall", () => { +describe("useSkillsStatus", () => { beforeEach(() => { vi.spyOn(console, "error").mockImplementation(() => {}); - desktopDaemon.getSkillsInstallStatus.mockResolvedValue({ installed: true }); - desktopDaemon.installSkills.mockResolvedValue({ installed: true }); }); afterEach(() => { @@ -101,34 +101,155 @@ describe("useSkillsInstall", () => { vi.clearAllMocks(); }); - it("loads skills install status", async () => { - const { result } = renderDesktopHook(() => useSkillsInstall()); - - await waitFor(() => { - expect(result.current.status).toEqual({ installed: true }); + it("loads the current skills status", async () => { + desktopDaemon.getSkillsStatus.mockResolvedValue({ + state: "up-to-date", + ops: [], }); + const { result } = renderDesktopHook(() => useSkillsStatus()); + + await waitFor(() => { + expect(result.current.status).toEqual({ state: "up-to-date", ops: [] }); + }); + expect(result.current.isWorking).toBe(false); expect(toast.error).not.toHaveBeenCalled(); }); - it("toasts and exposes skills install errors", async () => { - const error = new Error("Missing IPC handler"); - desktopDaemon.getSkillsInstallStatus.mockResolvedValue({ installed: false }); - desktopDaemon.installSkills.mockRejectedValue(error); - const { result } = renderDesktopHook(() => useSkillsInstall()); + it("install transitions a not-installed status to up-to-date and reflects the response directly", async () => { + desktopDaemon.getSkillsStatus.mockResolvedValue({ + state: "not-installed", + ops: [{ kind: "add", name: "paseo" }], + }); + desktopDaemon.installSkills.mockResolvedValue({ state: "up-to-date", ops: [] }); + + const { result } = renderDesktopHook(() => useSkillsStatus()); await waitFor(() => { - expect(result.current.status).toEqual({ installed: false }); + expect(result.current.status?.state).toBe("not-installed"); }); + await act(async () => { + await result.current.install(); + }); + + expect(desktopDaemon.installSkills).toHaveBeenCalledOnce(); + await waitFor(() => { + expect(result.current.status).toEqual({ state: "up-to-date", ops: [] }); + }); + }); + + it("update transitions drift to up-to-date", async () => { + desktopDaemon.getSkillsStatus.mockResolvedValue({ + state: "drift", + ops: [{ kind: "update", name: "paseo" }], + }); + desktopDaemon.updateSkills.mockResolvedValue({ state: "up-to-date", ops: [] }); + + const { result } = renderDesktopHook(() => useSkillsStatus()); + + await waitFor(() => { + expect(result.current.status?.state).toBe("drift"); + }); + + await act(async () => { + await result.current.update(); + }); + + expect(desktopDaemon.updateSkills).toHaveBeenCalledOnce(); + await waitFor(() => { + expect(result.current.status).toEqual({ state: "up-to-date", ops: [] }); + }); + }); + + it("uninstall transitions up-to-date back to not-installed", async () => { + desktopDaemon.getSkillsStatus.mockResolvedValue({ state: "up-to-date", ops: [] }); + desktopDaemon.uninstallSkills.mockResolvedValue({ + state: "not-installed", + ops: [{ kind: "add", name: "paseo" }], + }); + + const { result } = renderDesktopHook(() => useSkillsStatus()); + + await waitFor(() => { + expect(result.current.status?.state).toBe("up-to-date"); + }); + + await act(async () => { + await result.current.uninstall(); + }); + + expect(desktopDaemon.uninstallSkills).toHaveBeenCalledOnce(); + await waitFor(() => { + expect(result.current.status).toEqual({ + state: "not-installed", + ops: [{ kind: "add", name: "paseo" }], + }); + }); + }); + + it("isWorking flips while a mutation is in flight", async () => { + desktopDaemon.getSkillsStatus.mockResolvedValue({ + state: "not-installed", + ops: [{ kind: "add", name: "paseo" }], + }); + + let resolveInstall: ((value: unknown) => void) | null = null; + desktopDaemon.installSkills.mockImplementation( + () => + new Promise((resolve) => { + resolveInstall = resolve; + }), + ); + + const { result } = renderDesktopHook(() => useSkillsStatus()); + + await waitFor(() => { + expect(result.current.status?.state).toBe("not-installed"); + }); + expect(result.current.isWorking).toBe(false); + + let installPromise: Promise = Promise.resolve(); act(() => { - result.current.install(); + installPromise = result.current.install(); + }); + + await waitFor(() => { + expect(result.current.isWorking).toBe(true); + }); + + await act(async () => { + resolveInstall?.({ state: "up-to-date", ops: [] }); + await installPromise; + }); + + await waitFor(() => { + expect(result.current.isWorking).toBe(false); + }); + expect(result.current.status).toEqual({ state: "up-to-date", ops: [] }); + }); + + it("toasts and exposes errors when install fails", async () => { + const error = new Error("Missing IPC handler"); + desktopDaemon.getSkillsStatus.mockResolvedValue({ + state: "not-installed", + ops: [{ kind: "add", name: "paseo" }], + }); + desktopDaemon.installSkills.mockRejectedValue(error); + + const { result } = renderDesktopHook(() => useSkillsStatus()); + + await waitFor(() => { + expect(result.current.status?.state).toBe("not-installed"); + }); + + await act(async () => { + await result.current.install(); }); await waitFor(() => { expect(result.current.error).toBe(error); }); - expect(toast.error).toHaveBeenCalledWith("Unable to install orchestration skills."); expect(console.error).toHaveBeenCalledWith("[Integrations] Failed to install skills", error); }); diff --git a/packages/app/src/desktop/hooks/use-install-status.ts b/packages/app/src/desktop/hooks/use-install-status.ts index fb7a39974..97414c41c 100644 --- a/packages/app/src/desktop/hooks/use-install-status.ts +++ b/packages/app/src/desktop/hooks/use-install-status.ts @@ -2,11 +2,14 @@ import { useCallback } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { getCliInstallStatus, - getSkillsInstallStatus, + getSkillsStatus, installCli, installSkills, shouldUseDesktopDaemon, type InstallStatus, + type SkillsStatus, + uninstallSkills, + updateSkills, } from "@/desktop/daemon/desktop-daemon"; import { useDesktopIpcErrorReporter, @@ -14,11 +17,7 @@ import { } from "@/desktop/hooks/desktop-ipc-error"; const CLI_INSTALL_STATUS_QUERY_KEY = ["desktop", "integrations", "cli-install-status"] as const; -const SKILLS_INSTALL_STATUS_QUERY_KEY = [ - "desktop", - "integrations", - "skills-install-status", -] as const; +const SKILLS_STATUS_QUERY_KEY = ["desktop", "integrations", "skills-status"] as const; interface DesktopInstallHookResult { status: InstallStatus | null; @@ -77,25 +76,43 @@ export function useCliInstall(): DesktopInstallHookResult { }; } -export function useSkillsInstall(): DesktopInstallHookResult { +export interface SkillsStatusHookResult { + status: SkillsStatus | null; + isLoading: boolean; + isWorking: boolean; + error: Error | null; + refresh: () => Promise; + install: () => Promise; + update: () => Promise; + uninstall: () => Promise; +} + +export function useSkillsStatus(): SkillsStatusHookResult { const queryClient = useQueryClient(); const reportError = useDesktopIpcErrorReporter(); const enabled = shouldUseDesktopDaemon(); - const statusQuery = useQuery({ - queryKey: SKILLS_INSTALL_STATUS_QUERY_KEY, - queryFn: getSkillsInstallStatus, + const statusQuery = useQuery({ + queryKey: SKILLS_STATUS_QUERY_KEY, + queryFn: getSkillsStatus, enabled, retry: false, }); - const { data: installStatus, error: statusError, isLoading, refetch } = statusQuery; + const { data: status, error: statusError, isLoading, refetch } = statusQuery; useDesktopIpcQueryErrorToast({ error: statusQuery.error, - message: "Unable to check orchestration skills install status.", + message: "Unable to check orchestration skills status.", logLabel: "[Integrations] Failed to load skills status", }); - const installMutation = useMutation({ + const setStatus = useCallback( + (next: SkillsStatus) => { + queryClient.setQueryData(SKILLS_STATUS_QUERY_KEY, next); + }, + [queryClient], + ); + + const installMutation = useMutation({ mutationFn: installSkills, onError: (error) => { reportError({ @@ -104,23 +121,65 @@ export function useSkillsInstall(): DesktopInstallHookResult { logLabel: "[Integrations] Failed to install skills", }); }, - onSuccess: (nextStatus) => { - queryClient.setQueryData(SKILLS_INSTALL_STATUS_QUERY_KEY, nextStatus); - void queryClient.invalidateQueries({ queryKey: SKILLS_INSTALL_STATUS_QUERY_KEY }); - }, + onSuccess: setStatus, }); - const { error: installError, isPending: isInstalling, mutate: install } = installMutation; - const refresh = useCallback(() => { - void refetch(); + const updateMutation = useMutation({ + mutationFn: updateSkills, + onError: (error) => { + reportError({ + error, + message: "Unable to update orchestration skills.", + logLabel: "[Integrations] Failed to update skills", + }); + }, + onSuccess: setStatus, + }); + + const uninstallMutation = useMutation({ + mutationFn: uninstallSkills, + onError: (error) => { + reportError({ + error, + message: "Unable to uninstall orchestration skills.", + logLabel: "[Integrations] Failed to uninstall skills", + }); + }, + onSuccess: setStatus, + }); + + const isWorking = + installMutation.isPending || updateMutation.isPending || uninstallMutation.isPending; + + const refresh = useCallback(async () => { + await refetch(); }, [refetch]); + const install = useCallback(async () => { + await installMutation.mutateAsync().catch(() => undefined); + }, [installMutation]); + + const update = useCallback(async () => { + await updateMutation.mutateAsync().catch(() => undefined); + }, [updateMutation]); + + const uninstall = useCallback(async () => { + await uninstallMutation.mutateAsync().catch(() => undefined); + }, [uninstallMutation]); + return { - status: installStatus ?? null, + status: status ?? null, isLoading, - isInstalling, - error: statusError ?? installError ?? null, - install, + isWorking, + error: + statusError ?? + installMutation.error ?? + updateMutation.error ?? + uninstallMutation.error ?? + null, refresh, + install, + update, + uninstall, }; } diff --git a/packages/desktop/src/daemon/daemon-manager.ts b/packages/desktop/src/daemon/daemon-manager.ts index 27df78d2d..52c43ba9c 100644 --- a/packages/desktop/src/daemon/daemon-manager.ts +++ b/packages/desktop/src/daemon/daemon-manager.ts @@ -17,12 +17,13 @@ import { downloadAndInstallUpdate, type AppReleaseChannel, } from "../features/auto-updater.js"; +import { getCliInstallStatus, installCli } from "../integrations/cli-install/index.js"; import { - installCli, - getCliInstallStatus, + getSkillsStatus, installSkills, - getSkillsInstallStatus, -} from "../integrations/integrations-manager.js"; + uninstallSkills, + updateSkills, +} from "../integrations/skills/index.js"; import { openLocalTransportSession, sendLocalTransportMessage, @@ -531,8 +532,10 @@ export function createDaemonCommandHandlers(): Record getLocalDaemonVersion(), install_cli: () => installCli(), get_cli_install_status: () => getCliInstallStatus(), + get_skills_status: () => getSkillsStatus(), install_skills: () => installSkills(), - get_skills_install_status: () => getSkillsInstallStatus(), + update_skills: () => updateSkills(), + uninstall_skills: () => uninstallSkills(), }; } diff --git a/packages/desktop/src/integrations/cli-install/index.ts b/packages/desktop/src/integrations/cli-install/index.ts new file mode 100644 index 000000000..ad5423bc6 --- /dev/null +++ b/packages/desktop/src/integrations/cli-install/index.ts @@ -0,0 +1 @@ +export { getCliInstallStatus, installCli } from "./install.js"; diff --git a/packages/desktop/src/integrations/cli-install/install.ts b/packages/desktop/src/integrations/cli-install/install.ts new file mode 100644 index 000000000..cc113daea --- /dev/null +++ b/packages/desktop/src/integrations/cli-install/install.ts @@ -0,0 +1,71 @@ +import { promises as fs } from "node:fs"; +import { app } from "electron"; +import log from "electron-log/main"; +import { resolveCliInstallSourcePath } from "./path.js"; +import { getBundledCliShimPath, getCliTargetPath, getLocalBinDir } from "./paths.js"; +import { ensurePathInShellRc } from "./shell-rc.js"; + +interface InstallStatus { + installed: boolean; +} + +async function pathOrSymlinkExists(p: string): Promise { + try { + await fs.lstat(p); + return true; + } catch { + return false; + } +} + +export async function installCli(): Promise { + const targetPath = getCliTargetPath(); + const shimPath = getBundledCliShimPath(); + const installSourcePath = resolveCliInstallSourcePath({ + platform: process.platform, + isPackaged: app.isPackaged, + executablePath: app.getPath("exe"), + shimPath, + appImagePath: process.env.APPIMAGE, + }); + const binDir = getLocalBinDir(); + + await fs.mkdir(binDir, { recursive: true }); + + if (process.platform === "win32") { + if (await pathOrSymlinkExists(targetPath)) { + await fs.unlink(targetPath); + } + // Generate a thin .cmd trampoline that delegates to the bundled shim. + // Only the app install path is baked in — internal details (asar layout, + // entrypoint scripts) live in the bundled shim and update with the app. + const cmdContent = [ + "@echo off", + `set "BUNDLED_CLI=${shimPath}"`, + `if not exist "%BUNDLED_CLI%" (`, + ` echo Paseo CLI not found at %BUNDLED_CLI% — is Paseo installed? 1>&2`, + ` exit /b 1`, + `)`, + `call "%BUNDLED_CLI%" %*`, + `exit /b %errorlevel%`, + ].join("\r\n"); + await fs.writeFile(targetPath, cmdContent, "utf-8"); + } else { + if (await pathOrSymlinkExists(targetPath)) { + await fs.unlink(targetPath); + } + await fs.symlink(installSourcePath, targetPath); + } + + const { shellUpdated } = await ensurePathInShellRc(); + if (shellUpdated) { + log.info("[integrations] Updated shell rc with ~/.local/bin PATH"); + } + + return getCliInstallStatus(); +} + +export async function getCliInstallStatus(): Promise { + const targetPath = getCliTargetPath(); + return { installed: await pathOrSymlinkExists(targetPath) }; +} diff --git a/packages/desktop/src/integrations/cli-install-path.test.ts b/packages/desktop/src/integrations/cli-install/path.test.ts similarity index 96% rename from packages/desktop/src/integrations/cli-install-path.test.ts rename to packages/desktop/src/integrations/cli-install/path.test.ts index 5148e0015..e1b64a6c0 100644 --- a/packages/desktop/src/integrations/cli-install-path.test.ts +++ b/packages/desktop/src/integrations/cli-install/path.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { resolveCliInstallSourcePath } from "./cli-install-path"; +import { resolveCliInstallSourcePath } from "./path"; describe("cli-install-path", () => { it("uses the bundled shim for packaged macOS installs", () => { diff --git a/packages/desktop/src/integrations/cli-install-path.ts b/packages/desktop/src/integrations/cli-install/path.ts similarity index 100% rename from packages/desktop/src/integrations/cli-install-path.ts rename to packages/desktop/src/integrations/cli-install/path.ts diff --git a/packages/desktop/src/integrations/cli-install/paths.ts b/packages/desktop/src/integrations/cli-install/paths.ts new file mode 100644 index 000000000..312a58789 --- /dev/null +++ b/packages/desktop/src/integrations/cli-install/paths.ts @@ -0,0 +1,31 @@ +import path from "node:path"; +import os from "node:os"; +import { app } from "electron"; + +export function getLocalBinDir(): string { + return path.join(os.homedir(), ".local", "bin"); +} + +export function getCliTargetPath(): string { + const filename = process.platform === "win32" ? "paseo.cmd" : "paseo"; + return path.join(getLocalBinDir(), filename); +} + +export function getBundledCliShimPath(): string { + const cliShimFilename = process.platform === "win32" ? "paseo.cmd" : "paseo"; + + if (process.platform === "darwin") { + const electronExePath = app.getPath("exe"); + const appBundle = electronExePath.replace(/\/Contents\/MacOS\/.+$/, ""); + return path.join(appBundle, "Contents", "Resources", "bin", cliShimFilename); + } + + if (process.platform === "win32") { + const electronExePath = app.getPath("exe"); + return path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename); + } + + // Linux + const electronExePath = app.getPath("exe"); + return path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename); +} diff --git a/packages/desktop/src/integrations/cli-install/shell-rc.ts b/packages/desktop/src/integrations/cli-install/shell-rc.ts new file mode 100644 index 000000000..6bfa0e21e --- /dev/null +++ b/packages/desktop/src/integrations/cli-install/shell-rc.ts @@ -0,0 +1,97 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import log from "electron-log/main"; + +export interface ShellRcInfo { + shell: string; + rcFile: string; + pathCheckPattern: RegExp; + exportLine: string; +} + +async function pathOrSymlinkExists(p: string): Promise { + try { + await fs.lstat(p); + return true; + } catch { + return false; + } +} + +export function detectShellRcInfo(): ShellRcInfo | null { + if (process.platform === "win32") return null; + + const shell = process.env.SHELL; + if (!shell) return null; + + const shellName = path.basename(shell); + + if (shellName === "zsh") { + return { + shell: "zsh", + rcFile: path.join(os.homedir(), ".zshrc"), + pathCheckPattern: /\.local\/bin/, + exportLine: 'export PATH="$HOME/.local/bin:$PATH"', + }; + } + + if (shellName === "bash") { + const rcFile = + process.platform === "darwin" + ? path.join(os.homedir(), ".bash_profile") + : path.join(os.homedir(), ".bashrc"); + return { + shell: "bash", + rcFile, + pathCheckPattern: /\.local\/bin/, + exportLine: 'export PATH="$HOME/.local/bin:$PATH"', + }; + } + + if (shellName === "fish") { + return { + shell: "fish", + rcFile: path.join(os.homedir(), ".config", "fish", "config.fish"), + pathCheckPattern: /\.local\/bin/, + exportLine: "fish_add_path $HOME/.local/bin", + }; + } + + return null; +} + +export function pathAlreadyContainsLocalBin(): boolean { + const pathEnv = process.env.PATH ?? ""; + const localBin = path.join(os.homedir(), ".local", "bin"); + return pathEnv.split(path.delimiter).some((p) => p === localBin || p === "~/.local/bin"); +} + +export async function ensurePathInShellRc(): Promise<{ shellUpdated: boolean }> { + if (pathAlreadyContainsLocalBin()) { + return { shellUpdated: false }; + } + + const info = detectShellRcInfo(); + if (!info) { + return { shellUpdated: false }; + } + + try { + const exists = await pathOrSymlinkExists(info.rcFile); + if (exists) { + const content = await fs.readFile(info.rcFile, "utf-8"); + if (info.pathCheckPattern.test(content)) { + return { shellUpdated: false }; + } + } + + await fs.mkdir(path.dirname(info.rcFile), { recursive: true }); + await fs.appendFile(info.rcFile, `\n# Added by Paseo\n${info.exportLine}\n`); + + return { shellUpdated: true }; + } catch (err) { + log.warn("[integrations] Failed to update shell rc file", { rcFile: info.rcFile, err }); + return { shellUpdated: false }; + } +} diff --git a/packages/desktop/src/integrations/integrations-manager.ts b/packages/desktop/src/integrations/integrations-manager.ts deleted file mode 100644 index 1d7dd9dd7..000000000 --- a/packages/desktop/src/integrations/integrations-manager.ts +++ /dev/null @@ -1,318 +0,0 @@ -import { promises as fs } from "node:fs"; -import path from "node:path"; -import os from "node:os"; -import { app } from "electron"; -import log from "electron-log/main"; -import { resolveCliInstallSourcePath } from "./cli-install-path.js"; -import { syncSkills } from "./skill-sync.js"; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -interface InstallStatus { - installed: boolean; -} - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -const SKILL_NAMES = [ - "paseo", - "paseo-advisor", - "paseo-committee", - "paseo-epic", - "paseo-handoff", - "paseo-loop", - "paseo-orchestrate", -]; - -// --------------------------------------------------------------------------- -// Filesystem helpers -// --------------------------------------------------------------------------- - -async function pathOrSymlinkExists(p: string): Promise { - try { - await fs.lstat(p); - return true; - } catch { - return false; - } -} - -// --------------------------------------------------------------------------- -// Path helpers -// --------------------------------------------------------------------------- - -function getLocalBinDir(): string { - return path.join(os.homedir(), ".local", "bin"); -} - -function getCliTargetPath(): string { - const filename = process.platform === "win32" ? "paseo.cmd" : "paseo"; - return path.join(getLocalBinDir(), filename); -} - -function getBundledCliShimPath(): string { - const cliShimFilename = process.platform === "win32" ? "paseo.cmd" : "paseo"; - - if (process.platform === "darwin") { - const electronExePath = app.getPath("exe"); - const appBundle = electronExePath.replace(/\/Contents\/MacOS\/.+$/, ""); - return path.join(appBundle, "Contents", "Resources", "bin", cliShimFilename); - } - - if (process.platform === "win32") { - const electronExePath = app.getPath("exe"); - return path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename); - } - - // Linux - const electronExePath = app.getPath("exe"); - return path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename); -} - -function getBundledSkillsDir(): string { - if (app.isPackaged) { - return path.join(process.resourcesPath, "skills"); - } - return path.join(__dirname, "..", "..", "..", "..", "skills"); -} - -function getAgentsSkillsDir(): string { - return path.join(os.homedir(), ".agents", "skills"); -} - -function getClaudeSkillsDir(): string { - return path.join(os.homedir(), ".claude", "skills"); -} - -function getCodexSkillsDir(): string { - return path.join(os.homedir(), ".codex", "skills"); -} - -// --------------------------------------------------------------------------- -// Shell PATH helpers -// --------------------------------------------------------------------------- - -interface ShellRcInfo { - shell: string; - rcFile: string; - pathCheckPattern: RegExp; - exportLine: string; -} - -function detectShellRcInfo(): ShellRcInfo | null { - if (process.platform === "win32") return null; - - const shell = process.env.SHELL; - if (!shell) return null; - - const shellName = path.basename(shell); - - if (shellName === "zsh") { - return { - shell: "zsh", - rcFile: path.join(os.homedir(), ".zshrc"), - pathCheckPattern: /\.local\/bin/, - exportLine: 'export PATH="$HOME/.local/bin:$PATH"', - }; - } - - if (shellName === "bash") { - const rcFile = - process.platform === "darwin" - ? path.join(os.homedir(), ".bash_profile") - : path.join(os.homedir(), ".bashrc"); - return { - shell: "bash", - rcFile, - pathCheckPattern: /\.local\/bin/, - exportLine: 'export PATH="$HOME/.local/bin:$PATH"', - }; - } - - if (shellName === "fish") { - return { - shell: "fish", - rcFile: path.join(os.homedir(), ".config", "fish", "config.fish"), - pathCheckPattern: /\.local\/bin/, - exportLine: "fish_add_path $HOME/.local/bin", - }; - } - - return null; -} - -function pathAlreadyContainsLocalBin(): boolean { - const pathEnv = process.env.PATH ?? ""; - const localBin = path.join(os.homedir(), ".local", "bin"); - return pathEnv.split(path.delimiter).some((p) => p === localBin || p === "~/.local/bin"); -} - -async function ensurePathInShellRc(): Promise<{ shellUpdated: boolean }> { - if (pathAlreadyContainsLocalBin()) { - return { shellUpdated: false }; - } - - const info = detectShellRcInfo(); - if (!info) { - return { shellUpdated: false }; - } - - try { - const exists = await pathOrSymlinkExists(info.rcFile); - if (exists) { - const content = await fs.readFile(info.rcFile, "utf-8"); - if (info.pathCheckPattern.test(content)) { - return { shellUpdated: false }; - } - } - - await fs.mkdir(path.dirname(info.rcFile), { recursive: true }); - await fs.appendFile(info.rcFile, `\n# Added by Paseo\n${info.exportLine}\n`); - - return { shellUpdated: true }; - } catch (err) { - log.warn("[integrations] Failed to update shell rc file", { rcFile: info.rcFile, err }); - return { shellUpdated: false }; - } -} - -// --------------------------------------------------------------------------- -// CLI Installation -// --------------------------------------------------------------------------- - -export async function installCli(): Promise { - const targetPath = getCliTargetPath(); - const shimPath = getBundledCliShimPath(); - const installSourcePath = resolveCliInstallSourcePath({ - platform: process.platform, - isPackaged: app.isPackaged, - executablePath: app.getPath("exe"), - shimPath, - appImagePath: process.env.APPIMAGE, - }); - const binDir = getLocalBinDir(); - - await fs.mkdir(binDir, { recursive: true }); - - if (process.platform === "win32") { - if (await pathOrSymlinkExists(targetPath)) { - await fs.unlink(targetPath); - } - // Generate a thin .cmd trampoline that delegates to the bundled shim. - // Only the app install path is baked in — internal details (asar layout, - // entrypoint scripts) live in the bundled shim and update with the app. - const cmdContent = [ - "@echo off", - `set "BUNDLED_CLI=${shimPath}"`, - `if not exist "%BUNDLED_CLI%" (`, - ` echo Paseo CLI not found at %BUNDLED_CLI% — is Paseo installed? 1>&2`, - ` exit /b 1`, - `)`, - `call "%BUNDLED_CLI%" %*`, - `exit /b %errorlevel%`, - ].join("\r\n"); - await fs.writeFile(targetPath, cmdContent, "utf-8"); - } else { - if (await pathOrSymlinkExists(targetPath)) { - await fs.unlink(targetPath); - } - await fs.symlink(installSourcePath, targetPath); - } - - const { shellUpdated } = await ensurePathInShellRc(); - if (shellUpdated) { - log.info("[integrations] Updated shell rc with ~/.local/bin PATH"); - } - - return getCliInstallStatus(); -} - -export async function getCliInstallStatus(): Promise { - const targetPath = getCliTargetPath(); - return { installed: await pathOrSymlinkExists(targetPath) }; -} - -// --------------------------------------------------------------------------- -// Skills Installation -// --------------------------------------------------------------------------- - -function getSkillSyncTargets(): { - sourceDir: string; - agentsDir: string; - claudeDir: string; - codexDir: string; -} { - return { - sourceDir: getBundledSkillsDir(), - agentsDir: getAgentsSkillsDir(), - claudeDir: getClaudeSkillsDir(), - codexDir: getCodexSkillsDir(), - }; -} - -export async function installSkills(): Promise { - const targets = getSkillSyncTargets(); - log.info("[integrations] installSkills", targets); - - const result = await syncSkills({ - ...targets, - skillNames: SKILL_NAMES, - onSkillError: (skillName, error) => { - log.warn("[integrations] skill install failed", { skillName, error }); - }, - }); - - log.info("[integrations] installSkills done", result); - return getSkillsInstallStatus(); -} - -export async function autoUpdateSkillsIfInstalled(): Promise<{ - ran: boolean; - changedFiles: number; - processedSkills: number; -}> { - const targets = getSkillSyncTargets(); - const installedMarker = path.join(targets.agentsDir, "paseo", "SKILL.md"); - - try { - await fs.access(installedMarker); - } catch { - return { ran: false, changedFiles: 0, processedSkills: 0 }; - } - - try { - const result = await syncSkills({ - ...targets, - skillNames: SKILL_NAMES, - onSkillError: (skillName, error) => { - log.warn("[integrations] skill auto-update failed", { skillName, error }); - }, - }); - if (result.changedFiles > 0) { - log.info("[integrations] auto-updated paseo skills", result); - } else { - log.info("[integrations] paseo skills already up to date", result); - } - return { ran: true, ...result }; - } catch (error) { - log.warn("[integrations] auto-update skills aborted", { error }); - return { ran: false, changedFiles: 0, processedSkills: 0 }; - } -} - -export async function getSkillsInstallStatus(): Promise { - const claudeDir = getClaudeSkillsDir(); - const accessResults = await Promise.all( - SKILL_NAMES.map((skillName) => - fs - .access(path.join(claudeDir, skillName, "SKILL.md")) - .then(() => true) - .catch(() => false), - ), - ); - return { installed: accessResults.every(Boolean) }; -} diff --git a/packages/desktop/src/integrations/skills/index.ts b/packages/desktop/src/integrations/skills/index.ts new file mode 100644 index 000000000..5020b97f0 --- /dev/null +++ b/packages/desktop/src/integrations/skills/index.ts @@ -0,0 +1,10 @@ +export { + getSkillsStatus, + installSkills, + uninstallSkills, + updateSkills, + type SkillOp, + type SkillsState, + type SkillsStatus, + type SkillTargets, +} from "./operations.js"; diff --git a/packages/desktop/src/integrations/skills/operations.test.ts b/packages/desktop/src/integrations/skills/operations.test.ts new file mode 100644 index 000000000..ee8617492 --- /dev/null +++ b/packages/desktop/src/integrations/skills/operations.test.ts @@ -0,0 +1,302 @@ +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("electron", () => ({ + app: { + getPath: vi.fn(() => "/tmp/paseo-user-data"), + isPackaged: false, + }, +})); + +import { + getSkillsStatus, + installSkills, + PASEO_SKILL_NAMES, + type SkillTargets, + uninstallSkills, + updateSkills, +} from "./operations"; + +interface Sandbox { + root: string; + targets: SkillTargets; +} + +async function makeSandbox(): Promise { + 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): Promise { + 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, +): Promise { + await writeFiles(path.join(sourceDir, name), files); +} + +async function writeOnDiskSkill( + agentsDir: string, + name: string, + files: Record, +): Promise { + await writeFiles(path.join(agentsDir, name), files); +} + +async function writeCurrentBundle(sourceDir: string): Promise { + await writeBundleSkill(sourceDir, "paseo", { "SKILL.md": "paseo-v1" }); + await writeBundleSkill(sourceDir, "paseo-loop", { "SKILL.md": "loop-v1" }); +} + +async function pathExists(p: string): Promise { + return fs + .access(p) + .then(() => true) + .catch(() => false); +} + +describe("getSkillsStatus", () => { + let sandbox: Sandbox; + + beforeEach(async () => { + sandbox = await makeSandbox(); + }); + + afterEach(async () => { + await fs.rm(sandbox.root, { recursive: true, force: true }); + }); + + it("returns not-installed with add ops for every bundled skill when nothing is on disk", async () => { + await writeCurrentBundle(sandbox.targets.sourceDir); + + const status = await getSkillsStatus(sandbox.targets); + + expect(status.state).toBe("not-installed"); + expect(status.ops).toEqual([ + { kind: "add", name: "paseo" }, + { kind: "add", name: "paseo-loop" }, + ]); + }); + + it("returns not-installed when only user-personal skill dirs exist (the live bug)", async () => { + await writeCurrentBundle(sandbox.targets.sourceDir); + for (const name of ["unslop", "tdd", "devbox"]) { + await writeOnDiskSkill(sandbox.targets.agentsDir, name, { "SKILL.md": `user-${name}` }); + } + + const status = await getSkillsStatus(sandbox.targets); + + expect(status.state).toBe("not-installed"); + expect(status.ops).toEqual([ + { kind: "add", name: "paseo" }, + { kind: "add", name: "paseo-loop" }, + ]); + }); + + it("returns up-to-date when every bundled skill matches on disk", async () => { + await writeCurrentBundle(sandbox.targets.sourceDir); + await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "paseo-v1" }); + await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-loop", { "SKILL.md": "loop-v1" }); + + const status = await getSkillsStatus(sandbox.targets); + + expect(status).toEqual({ state: "up-to-date", ops: [] }); + }); + + it("returns drift with a single update op when one bundled file diverges", async () => { + await writeCurrentBundle(sandbox.targets.sourceDir); + await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "stale" }); + await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-loop", { "SKILL.md": "loop-v1" }); + + const status = await getSkillsStatus(sandbox.targets); + + expect(status.state).toBe("drift"); + expect(status.ops).toEqual([{ kind: "update", name: "paseo" }]); + }); + + it("returns drift with add ops for the bundled skills missing from disk", async () => { + await writeCurrentBundle(sandbox.targets.sourceDir); + await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "paseo-v1" }); + + const status = await getSkillsStatus(sandbox.targets); + + expect(status.state).toBe("drift"); + expect(status.ops).toEqual([{ kind: "add", name: "paseo-loop" }]); + }); + + it("returns drift with a delete op for a legacy skill name still on disk", async () => { + await writeCurrentBundle(sandbox.targets.sourceDir); + await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "paseo-v1" }); + await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-loop", { "SKILL.md": "loop-v1" }); + await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-chat", { "SKILL.md": "chat-old" }); + + const status = await getSkillsStatus(sandbox.targets); + + expect(status.state).toBe("drift"); + expect(status.ops).toEqual([{ kind: "delete", name: "paseo-chat" }]); + }); + + it("emits add + update + delete ops sorted by name when state is mixed", async () => { + await writeCurrentBundle(sandbox.targets.sourceDir); + await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "stale" }); + await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-chat", { "SKILL.md": "chat-old" }); + + const status = await getSkillsStatus(sandbox.targets); + + expect(status.state).toBe("drift"); + expect(status.ops).toEqual([ + { kind: "update", name: "paseo" }, + { kind: "delete", name: "paseo-chat" }, + { kind: "add", name: "paseo-loop" }, + ]); + }); +}); + +describe("installSkills / updateSkills", () => { + let sandbox: Sandbox; + + beforeEach(async () => { + sandbox = await makeSandbox(); + }); + + afterEach(async () => { + await fs.rm(sandbox.root, { recursive: true, force: true }); + }); + + it("installs from a clean machine, populates all three targets, and leaves user dirs alone", async () => { + await writeCurrentBundle(sandbox.targets.sourceDir); + await writeOnDiskSkill(sandbox.targets.agentsDir, "unslop", { "SKILL.md": "user-unslop" }); + + const status = await installSkills(sandbox.targets); + + expect(status).toEqual({ state: "up-to-date", ops: [] }); + for (const name of ["paseo", "paseo-loop"]) { + expect( + await fs.readFile(path.join(sandbox.targets.agentsDir, name, "SKILL.md"), "utf-8"), + ).toBe(name === "paseo" ? "paseo-v1" : "loop-v1"); + expect( + await fs.readFile(path.join(sandbox.targets.codexDir, name, "SKILL.md"), "utf-8"), + ).toBe(name === "paseo" ? "paseo-v1" : "loop-v1"); + expect(await pathExists(path.join(sandbox.targets.claudeDir, name))).toBe(true); + } + expect( + await fs.readFile(path.join(sandbox.targets.agentsDir, "unslop", "SKILL.md"), "utf-8"), + ).toBe("user-unslop"); + }); + + it("converges to up-to-date when state has missing + edited + legacy skills", async () => { + await writeCurrentBundle(sandbox.targets.sourceDir); + await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "stale" }); + await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-chat", { "SKILL.md": "chat-old" }); + await writeOnDiskSkill(sandbox.targets.claudeDir, "paseo-chat", { "SKILL.md": "chat-old" }); + await writeOnDiskSkill(sandbox.targets.codexDir, "paseo-chat", { "SKILL.md": "chat-old" }); + + const status = await updateSkills(sandbox.targets); + + expect(status).toEqual({ state: "up-to-date", ops: [] }); + expect( + await fs.readFile(path.join(sandbox.targets.agentsDir, "paseo", "SKILL.md"), "utf-8"), + ).toBe("paseo-v1"); + expect( + await fs.readFile(path.join(sandbox.targets.agentsDir, "paseo-loop", "SKILL.md"), "utf-8"), + ).toBe("loop-v1"); + for (const dir of [ + sandbox.targets.agentsDir, + sandbox.targets.claudeDir, + sandbox.targets.codexDir, + ]) { + expect(await pathExists(path.join(dir, "paseo-chat"))).toBe(false); + } + }); + + it("is idempotent — running install twice keeps state at up-to-date", async () => { + await writeCurrentBundle(sandbox.targets.sourceDir); + + const first = await installSkills(sandbox.targets); + const second = await installSkills(sandbox.targets); + + expect(first).toEqual({ state: "up-to-date", ops: [] }); + expect(second).toEqual({ state: "up-to-date", ops: [] }); + }); +}); + +describe("uninstallSkills", () => { + let sandbox: Sandbox; + + beforeEach(async () => { + sandbox = await makeSandbox(); + }); + + afterEach(async () => { + await fs.rm(sandbox.root, { recursive: true, force: true }); + }); + + it("removes every Paseo skill from all three targets and preserves user dirs", async () => { + await writeCurrentBundle(sandbox.targets.sourceDir); + await installSkills(sandbox.targets); + for (const name of ["unslop", "tdd", "devbox"]) { + await writeOnDiskSkill(sandbox.targets.agentsDir, name, { "SKILL.md": `user-${name}` }); + } + + const status = await uninstallSkills(sandbox.targets); + + expect(status.state).toBe("not-installed"); + for (const name of PASEO_SKILL_NAMES) { + expect(await pathExists(path.join(sandbox.targets.agentsDir, name))).toBe(false); + expect(await pathExists(path.join(sandbox.targets.claudeDir, name))).toBe(false); + expect(await pathExists(path.join(sandbox.targets.codexDir, name))).toBe(false); + } + for (const name of ["unslop", "tdd", "devbox"]) { + expect( + await fs.readFile(path.join(sandbox.targets.agentsDir, name, "SKILL.md"), "utf-8"), + ).toBe(`user-${name}`); + } + }); + + it("is a no-op when nothing is installed", async () => { + await writeCurrentBundle(sandbox.targets.sourceDir); + + const status = await uninstallSkills(sandbox.targets); + + expect(status.state).toBe("not-installed"); + }); + + it("cleans up legacy skill names that linger in agents, claude, and codex", async () => { + await writeCurrentBundle(sandbox.targets.sourceDir); + for (const dir of [ + sandbox.targets.agentsDir, + sandbox.targets.claudeDir, + sandbox.targets.codexDir, + ]) { + await writeOnDiskSkill(dir, "paseo-chat", { "SKILL.md": "chat-old" }); + } + + const status = await uninstallSkills(sandbox.targets); + + expect(status.state).toBe("not-installed"); + for (const dir of [ + sandbox.targets.agentsDir, + sandbox.targets.claudeDir, + sandbox.targets.codexDir, + ]) { + expect(await pathExists(path.join(dir, "paseo-chat"))).toBe(false); + } + }); +}); diff --git a/packages/desktop/src/integrations/skills/operations.ts b/packages/desktop/src/integrations/skills/operations.ts new file mode 100644 index 000000000..8acaa17b7 --- /dev/null +++ b/packages/desktop/src/integrations/skills/operations.ts @@ -0,0 +1,164 @@ +import { createHash } from "node:crypto"; +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { + getAgentsSkillsDir, + getBundledSkillsDir, + getClaudeSkillsDir, + getCodexSkillsDir, +} from "./paths.js"; +import { listFilesRecursive, removeSkill, syncSkills } from "./sync.js"; + +export type SkillsState = "not-installed" | "up-to-date" | "drift"; + +export type SkillOp = + | { kind: "add"; name: string } + | { kind: "update"; name: string } + | { kind: "delete"; name: string }; + +export interface SkillsStatus { + state: SkillsState; + ops: SkillOp[]; +} + +export interface SkillTargets { + sourceDir: string; + agentsDir: string; + claudeDir: string; + codexDir: string; +} + +export const PASEO_SKILL_NAMES = [ + "paseo", + "paseo-advisor", + "paseo-chat", + "paseo-committee", + "paseo-epic", + "paseo-handoff", + "paseo-loop", + "paseo-orchestrate", + "paseo-orchestrator", +] as const; + +type SkillFiles = Map; + +function resolveSkillTargets(): SkillTargets { + return { + sourceDir: getBundledSkillsDir(), + agentsDir: getAgentsSkillsDir(), + claudeDir: getClaudeSkillsDir(), + codexDir: getCodexSkillsDir(), + }; +} + +async function hashSkillDir(skillDir: string): Promise { + const stat = await fs.stat(skillDir).catch(() => null); + if (!stat?.isDirectory()) return null; + + const rels = await listFilesRecursive(skillDir); + const files: SkillFiles = new Map(); + for (const rel of rels) { + const buf = await fs.readFile(path.join(skillDir, rel)); + const sha = createHash("sha256").update(buf).digest("hex"); + files.set(toPosix(rel), sha); + } + return files; +} + +async function hashSkills(rootDir: string): Promise> { + const out = new Map(); + for (const name of PASEO_SKILL_NAMES) { + const files = await hashSkillDir(path.join(rootDir, name)); + if (files !== null) out.set(name, files); + } + return out; +} + +function diff(bundle: Map, disk: Map): SkillOp[] { + const ops: SkillOp[] = []; + for (const name of PASEO_SKILL_NAMES) { + const b = bundle.get(name); + const d = disk.get(name); + if (b && !d) ops.push({ kind: "add", name }); + else if (b && d && !filesEqual(b, d)) ops.push({ kind: "update", name }); + else if (!b && d) ops.push({ kind: "delete", name }); + } + ops.sort((a, b) => compareStrings(a.name, b.name)); + return ops; +} + +function filesEqual(a: SkillFiles, b: SkillFiles): boolean { + if (a.size !== b.size) return false; + for (const [rel, sha] of a) { + if (b.get(rel) !== sha) return false; + } + return true; +} + +function toPosix(p: string): string { + return p.split(path.sep).join("/"); +} + +function compareStrings(a: string, b: string): number { + if (a < b) return -1; + if (a > b) return 1; + return 0; +} + +export async function getSkillsStatus(targets?: SkillTargets): Promise { + const t = targets ?? resolveSkillTargets(); + const [bundle, disk] = await Promise.all([hashSkills(t.sourceDir), hashSkills(t.agentsDir)]); + const ops = diff(bundle, disk); + + if (disk.size === 0) return { state: "not-installed", ops }; + if (ops.length === 0) return { state: "up-to-date", ops }; + return { state: "drift", ops }; +} + +async function applySkills(targets: SkillTargets): Promise { + const status = await getSkillsStatus(targets); + + const writes = status.ops + .filter((op) => op.kind === "add" || op.kind === "update") + .map((op) => op.name); + if (writes.length > 0) { + await syncSkills({ + sourceDir: targets.sourceDir, + agentsDir: targets.agentsDir, + claudeDir: targets.claudeDir, + codexDir: targets.codexDir, + skillNames: writes, + }); + } + + for (const op of status.ops) { + if (op.kind !== "delete") continue; + await removeSkill(op.name, { + agentsDir: targets.agentsDir, + claudeDir: targets.claudeDir, + codexDir: targets.codexDir, + }); + } + + return getSkillsStatus(targets); +} + +export async function installSkills(targets?: SkillTargets): Promise { + return applySkills(targets ?? resolveSkillTargets()); +} + +export async function updateSkills(targets?: SkillTargets): Promise { + return applySkills(targets ?? resolveSkillTargets()); +} + +export async function uninstallSkills(targets?: SkillTargets): Promise { + const t = targets ?? resolveSkillTargets(); + for (const name of PASEO_SKILL_NAMES) { + await removeSkill(name, { + agentsDir: t.agentsDir, + claudeDir: t.claudeDir, + codexDir: t.codexDir, + }); + } + return getSkillsStatus(t); +} diff --git a/packages/desktop/src/integrations/skills/paths.ts b/packages/desktop/src/integrations/skills/paths.ts new file mode 100644 index 000000000..cc7423576 --- /dev/null +++ b/packages/desktop/src/integrations/skills/paths.ts @@ -0,0 +1,22 @@ +import path from "node:path"; +import os from "node:os"; +import { app } from "electron"; + +export function getBundledSkillsDir(): string { + if (app.isPackaged) { + return path.join(process.resourcesPath, "skills"); + } + return path.join(__dirname, "..", "..", "..", "..", "..", "skills"); +} + +export function getAgentsSkillsDir(): string { + return path.join(os.homedir(), ".agents", "skills"); +} + +export function getClaudeSkillsDir(): string { + return path.join(os.homedir(), ".claude", "skills"); +} + +export function getCodexSkillsDir(): string { + return path.join(os.homedir(), ".codex", "skills"); +} diff --git a/packages/desktop/src/integrations/skill-sync.test.ts b/packages/desktop/src/integrations/skills/sync.test.ts similarity index 82% rename from packages/desktop/src/integrations/skill-sync.test.ts rename to packages/desktop/src/integrations/skills/sync.test.ts index c354db6f3..52c7bbbcc 100644 --- a/packages/desktop/src/integrations/skill-sync.test.ts +++ b/packages/desktop/src/integrations/skills/sync.test.ts @@ -2,7 +2,7 @@ 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 { syncSkills } from "./skill-sync"; +import { removeSkill, syncSkills } from "./sync"; interface Sandbox { root: string; @@ -70,6 +70,11 @@ describe("syncSkills", () => { "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", @@ -107,10 +112,10 @@ describe("syncSkills", () => { ), ).toBe("roles content"); - const claudeLink = path.join(sandbox.claudeDir, "paseo-epic"); - const lstat = await fs.lstat(claudeLink); - expect(lstat.isSymbolicLink()).toBe(true); - expect(await fs.readFile(path.join(claudeLink, "references", "roles.md"), "utf-8")).toBe( + const claudeSkillDir = path.join(sandbox.claudeDir, "paseo-epic"); + expect((await fs.lstat(claudeSkillDir)).isDirectory()).toBe(true); + expect(await fs.readFile(path.join(claudeSkillDir, "SKILL.md"), "utf-8")).toBe("epic content"); + expect(await fs.readFile(path.join(claudeSkillDir, "references", "roles.md"), "utf-8")).toBe( "roles content", ); }); @@ -254,3 +259,46 @@ describe("syncSkills", () => { 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(); + }); +}); diff --git a/packages/desktop/src/integrations/skill-sync.ts b/packages/desktop/src/integrations/skills/sync.ts similarity index 63% rename from packages/desktop/src/integrations/skill-sync.ts rename to packages/desktop/src/integrations/skills/sync.ts index c61554abc..8004b3a79 100644 --- a/packages/desktop/src/integrations/skill-sync.ts +++ b/packages/desktop/src/integrations/skills/sync.ts @@ -7,7 +7,6 @@ export interface SkillSyncOptions { claudeDir: string; codexDir: string; skillNames: readonly string[]; - platform?: NodeJS.Platform; onSkillError?: (skillName: string, error: unknown) => void; } @@ -25,7 +24,7 @@ async function writeFileIfChanged(srcPath: string, dstPath: string): Promise { +export async function listFilesRecursive(rootDir: string): Promise { const out: string[] = []; async function walk(dir: string): Promise { const entries = await fs.readdir(dir, { withFileTypes: true }); @@ -53,38 +52,24 @@ async function syncDirectoryFiles(srcDir: string, dstDir: string): Promise { - await fs.mkdir(claudeDir, { recursive: true }); - const target = path.join(agentsDir, skillName); - const linkPath = path.join(claudeDir, skillName); +export interface RemoveSkillTargets { + agentsDir: string; + claudeDir: string; + codexDir: string; +} - // Always rebuild the link rather than diffing it. fs.rm with force: true is - // a no-op when nothing is there, and matches existing install behavior. - // On Windows, `fs.rm` does not follow junctions, so the agents-side content - // is preserved. - await fs.rm(linkPath, { recursive: true, force: true }); - - if (platform === "win32") { - try { - // Junctions don't require Developer Mode / admin like regular symlinks do. - await fs.symlink(target, linkPath, "junction"); - return 0; - } catch { - return await syncDirectoryFiles(target, linkPath); - } +export async function removeSkill(skillName: string, targets: RemoveSkillTargets): Promise { + 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 }); } - - await fs.symlink(target, linkPath); - return 0; } export async function syncSkills(options: SkillSyncOptions): Promise { - const platform = options.platform ?? process.platform; let changedFiles = 0; let processedSkills = 0; @@ -100,11 +85,9 @@ export async function syncSkills(options: SkillSyncOptions): Promise { registerNotificationHandlers(); registerOpenerHandlers(); - void autoUpdateSkillsIfInstalled().catch((error) => { - log.warn("[integrations] auto-update skills failed", error); - }); - await createMainWindow(); app.on("activate", async () => {