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

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

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

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

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

* Rewrite skills install flow with disk-truth model

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

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

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

* Stop symlinking claude skills, write real files

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

* Make Uninstall button visible (outline, not ghost)
This commit is contained in:
Mohamed Boudra
2026-05-07 18:41:13 +08:00
committed by GitHub
parent 4f0b264886
commit 92be6c0cba
19 changed files with 1180 additions and 439 deletions
@@ -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<SkillOp["kind"], number> = { add: 0, update: 1, delete: 2 };
const OP_KIND_LABEL: Record<SkillOp["kind"], string> = {
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 (
<SettingsSection title="Integrations" trailing={trailing}>
<View style={settingsStyles.card}>
@@ -130,30 +180,69 @@ export function IntegrationsSection() {
<Text style={settingsStyles.rowTitle}>Orchestration skills</Text>
</View>
<Text style={settingsStyles.rowHint}>
Teach your agents to orchestrate through the CLI
{skillsState === "drift"
? "Update available"
: "Teach your agents to orchestrate through the CLI"}
</Text>
</View>
{skillsStatus?.installed ? (
<View style={styles.installedLabel}>
<Check size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.mutedText}>Installed</Text>
</View>
) : (
<Button
variant="outline"
size="sm"
onPress={handleInstallSkills}
disabled={isInstallingSkills}
>
{isInstallingSkills ? "Installing..." : "Install"}
</Button>
)}
<SkillsActions
state={skillsState}
isWorking={isSkillsWorking}
onInstall={handleInstallSkills}
onUpdate={handleUpdateSkills}
onUninstall={handleUninstallSkills}
/>
</View>
</View>
</SettingsSection>
);
}
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 (
<View style={styles.actionsRow}>
<View style={styles.installedLabel}>
<Check size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.mutedText}>Installed</Text>
</View>
<Button variant="outline" size="sm" onPress={onUninstall} disabled={isWorking}>
Uninstall
</Button>
</View>
);
}
if (state === "drift") {
return (
<View style={styles.actionsRow}>
<Button variant="outline" size="sm" onPress={onUpdate} disabled={isWorking}>
{isWorking ? "Working..." : "Update"}
</Button>
<Button variant="outline" size="sm" onPress={onUninstall} disabled={isWorking}>
Uninstall
</Button>
</View>
);
}
return (
<Button variant="outline" size="sm" onPress={onInstall} disabled={isWorking}>
{isWorking ? "Installing..." : "Install"}
</Button>
);
}
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],
},
}));
@@ -220,10 +220,67 @@ export async function installCli(): Promise<InstallStatus> {
return parseInstallStatus(await invokeDesktopCommand("install_cli"));
}
export async function getSkillsInstallStatus(): Promise<InstallStatus> {
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<InstallStatus> {
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<SkillsStatus> {
return parseSkillsStatus(await invokeDesktopCommand("get_skills_status"));
}
export async function installSkills(): Promise<SkillsStatus> {
return parseSkillsStatus(await invokeDesktopCommand("install_skills"));
}
export async function updateSkills(): Promise<SkillsStatus> {
return parseSkillsStatus(await invokeDesktopCommand("update_skills"));
}
export async function uninstallSkills(): Promise<SkillsStatus> {
return parseSkillsStatus(await invokeDesktopCommand("uninstall_skills"));
}
@@ -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<void> = 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);
});
@@ -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<void>;
install: () => Promise<void>;
update: () => Promise<void>;
uninstall: () => Promise<void>;
}
export function useSkillsStatus(): SkillsStatusHookResult {
const queryClient = useQueryClient();
const reportError = useDesktopIpcErrorReporter();
const enabled = shouldUseDesktopDaemon();
const statusQuery = useQuery<InstallStatus, Error>({
queryKey: SKILLS_INSTALL_STATUS_QUERY_KEY,
queryFn: getSkillsInstallStatus,
const statusQuery = useQuery<SkillsStatus, Error>({
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<InstallStatus, Error>({
const setStatus = useCallback(
(next: SkillsStatus) => {
queryClient.setQueryData<SkillsStatus>(SKILLS_STATUS_QUERY_KEY, next);
},
[queryClient],
);
const installMutation = useMutation<SkillsStatus, Error>({
mutationFn: installSkills,
onError: (error) => {
reportError({
@@ -104,23 +121,65 @@ export function useSkillsInstall(): DesktopInstallHookResult {
logLabel: "[Integrations] Failed to install skills",
});
},
onSuccess: (nextStatus) => {
queryClient.setQueryData<InstallStatus>(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<SkillsStatus, Error>({
mutationFn: updateSkills,
onError: (error) => {
reportError({
error,
message: "Unable to update orchestration skills.",
logLabel: "[Integrations] Failed to update skills",
});
},
onSuccess: setStatus,
});
const uninstallMutation = useMutation<SkillsStatus, Error>({
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,
};
}