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:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user