Manage orchestration skills per host (#3451)

* feat(skills): manage orchestration skills per host

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

* fix(client): keep skill management internal

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

* fix(e2e): declare skills fixture dependencies

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

* test(skills): use native managed paths

Assert fixed skill targets relative to the daemon user's real home with platform-native path joining so the ownership contract holds on Windows and POSIX hosts.
This commit is contained in:
Mohamed Boudra
2026-08-18 09:51:46 +02:00
committed by GitHub
parent eb3591cee8
commit 4108960709
71 changed files with 2225 additions and 2598 deletions
@@ -1,179 +1,53 @@
import { useCallback, useMemo, useState } from "react";
import type { TFunction } from "i18next";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { useFocusEffect } from "@react-navigation/native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ArrowUpRight, Terminal, Blocks, Check, Settings2 } from "lucide-react-native";
import { settingsStyles } from "@/styles/settings";
import { SettingsSection } from "@/screens/settings/settings-section";
import { ArrowUpRight, Check, Terminal } from "lucide-react-native";
import { Button } from "@/components/ui/button";
import { useCliInstall } from "@/desktop/hooks/use-install-status";
import { SettingsSection } from "@/screens/settings/settings-section";
import { settingsStyles } from "@/styles/settings";
import { openExternalUrl } from "@/utils/open-external-url";
import { confirmDialog } from "@/utils/confirm-dialog";
import {
shouldUseDesktopDaemon,
type SkillOp,
type SkillsSnapshot,
} from "@/desktop/daemon/desktop-daemon";
import { SkillSelectionSheet } from "@/desktop/components/skill-selection-sheet";
import { useCliInstall, useSkillsStatus } from "@/desktop/hooks/use-install-status";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
const CLI_DOCS_URL = "https://paseo.sh/docs/cli";
const SKILLS_DOCS_URL = "https://paseo.sh/docs/skills";
const OP_KIND_ORDER: Record<SkillOp["kind"], number> = { add: 0, update: 1, delete: 2 };
const OP_KIND_LABEL_KEY: Record<SkillOp["kind"], string> = {
add: "settings.integrations.operations.add",
update: "settings.integrations.operations.update",
delete: "settings.integrations.operations.delete",
};
function formatUpdateMessage(ops: readonly SkillOp[], t: TFunction): 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) => `${t(OP_KIND_LABEL_KEY[op.kind])} ${op.name}`).join("\n");
}
export function IntegrationsSection() {
const { t } = useTranslation();
const { theme } = useUnistyles();
const showSection = shouldUseDesktopDaemon();
const {
status: cliStatus,
isInstalling: isInstallingCli,
install: installCli,
refresh: refreshCliStatus,
} = useCliInstall();
const {
status: skillsStatus,
isWorking: isSkillsWorking,
install: installSkills,
update: updateSkills,
uninstall: uninstallSkills,
saveSelection: saveSkillSelection,
refresh: refreshSkillsStatus,
} = useSkillsStatus();
const [isChoosingSkills, setIsChoosingSkills] = useState(false);
const skillMaintenanceOps = useMemo(
() => skillsStatus?.ops.filter((op) => op.kind !== "delete") ?? [],
[skillsStatus?.ops],
);
const { status, isInstalling, install, refresh } = useCliInstall();
useFocusEffect(
useCallback(() => {
if (!showSection) return undefined;
refreshCliStatus();
void refreshSkillsStatus();
if (showSection) refresh();
return undefined;
}, [refreshCliStatus, refreshSkillsStatus, showSection]),
}, [refresh, showSection]),
);
const handleInstallCli = useCallback(() => {
if (isInstallingCli) return;
installCli();
}, [installCli, isInstallingCli]);
const handleInstallSkills = useCallback(() => {
if (isSkillsWorking) return;
void installSkills();
}, [installSkills, isSkillsWorking]);
const handleUpdateSkills = useCallback(async () => {
if (isSkillsWorking) return;
const confirmed = await confirmDialog({
title: t("settings.integrations.skills.updateTitle"),
message:
skillMaintenanceOps.length > 0
? formatUpdateMessage(skillMaintenanceOps, t)
: t("settings.integrations.skills.updateFallback"),
confirmLabel: t("settings.integrations.actions.update"),
});
if (!confirmed) return;
await updateSkills();
}, [isSkillsWorking, skillMaintenanceOps, t, updateSkills]);
const handleUninstallSkills = useCallback(async () => {
if (isSkillsWorking) return;
const confirmed = await confirmDialog({
title: t("settings.integrations.skills.uninstallTitle"),
message: t("settings.integrations.skills.uninstallMessage"),
confirmLabel: t("settings.integrations.actions.uninstall"),
destructive: true,
});
if (!confirmed) return;
await uninstallSkills();
}, [isSkillsWorking, t, uninstallSkills]);
const handleOpenSkillSelection = useCallback(() => {
setIsChoosingSkills(true);
}, []);
const handleCloseSkillSelection = useCallback(() => {
setIsChoosingSkills(false);
}, []);
const handleOpenCliDocs = useCallback(() => {
void openExternalUrl(CLI_DOCS_URL);
}, []);
const handleOpenSkillsDocs = useCallback(() => {
void openExternalUrl(SKILLS_DOCS_URL);
}, []);
const arrowIcon = useMemo(
const docsIcon = useMemo(
() => <ArrowUpRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
[theme.iconSize.sm, theme.colors.foregroundMuted],
);
const chooseSkillsIcon = useMemo(
() => <Settings2 size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
[theme.iconSize.sm, theme.colors.foregroundMuted],
);
const handleOpenDocs = useCallback(() => {
void openExternalUrl(CLI_DOCS_URL);
}, []);
const trailing = useMemo(
() => (
<View style={styles.headerLinks}>
<Button
variant="ghost"
size="sm"
leftIcon={arrowIcon}
textStyle={settingsStyles.sectionHeaderLinkText}
style={settingsStyles.sectionHeaderLink}
onPress={handleOpenCliDocs}
accessibilityLabel={t("settings.integrations.docs.openCli")}
>
{t("settings.integrations.docs.cli")}
</Button>
<Button
variant="ghost"
size="sm"
leftIcon={arrowIcon}
textStyle={settingsStyles.sectionHeaderLinkText}
style={settingsStyles.sectionHeaderLink}
onPress={handleOpenSkillsDocs}
accessibilityLabel={t("settings.integrations.docs.openSkills")}
>
{t("settings.integrations.docs.skills")}
</Button>
</View>
<Button
variant="ghost"
size="sm"
leftIcon={docsIcon}
textStyle={settingsStyles.sectionHeaderLinkText}
style={settingsStyles.sectionHeaderLink}
onPress={handleOpenDocs}
accessibilityLabel={t("settings.integrations.docs.openCli")}
>
{t("settings.integrations.docs.cli")}
</Button>
),
[arrowIcon, handleOpenCliDocs, handleOpenSkillsDocs, t],
[docsIcon, handleOpenDocs, t],
);
if (!showSection) {
return null;
}
const skillsState =
skillsStatus?.state === "drift" && skillMaintenanceOps.length === 0
? "up-to-date"
: (skillsStatus?.state ?? null);
const hasSelectedSkills =
(skillsStatus?.selection.mode === "all" && skillsStatus.available.length > 0) ||
(skillsStatus?.selection.mode === "custom" &&
skillsStatus.selection.skills.some((name) => skillsStatus.available.includes(name)));
if (!showSection) return null;
return (
<SettingsSection title={t("settings.integrations.title")} trailing={trailing}>
<View style={settingsStyles.card}>
@@ -189,144 +63,26 @@ export function IntegrationsSection() {
{t("settings.integrations.commandLine.description")}
</Text>
</View>
{cliStatus?.installed ? (
{status?.installed ? (
<View style={styles.installedLabel}>
<Check size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.mutedText}>{t("settings.integrations.actions.installed")}</Text>
</View>
) : (
<Button
variant="outline"
size="sm"
onPress={handleInstallCli}
disabled={isInstallingCli}
>
{isInstallingCli
<Button variant="outline" size="sm" onPress={install} disabled={isInstalling}>
{isInstalling
? t("settings.integrations.actions.installing")
: t("settings.integrations.actions.install")}
</Button>
)}
</View>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<View style={styles.rowTitleRow}>
<Blocks size={theme.iconSize.md} color={theme.colors.foreground} />
<Text style={settingsStyles.rowTitle}>{t("settings.integrations.skills.title")}</Text>
</View>
<Text style={settingsStyles.rowHint}>
{skillsState === "drift"
? t("settings.integrations.skills.updateAvailable")
: t("settings.integrations.skills.description")}
</Text>
</View>
<View style={styles.actionsRow}>
<Button
variant="ghost"
size="sm"
leftIcon={chooseSkillsIcon}
onPress={handleOpenSkillSelection}
disabled={skillsStatus === null || isSkillsWorking}
accessibilityLabel={t("settings.integrations.skills.choose")}
/>
{hasSelectedSkills ? (
<SkillsActions
state={skillsState}
isWorking={isSkillsWorking}
onInstall={handleInstallSkills}
onUpdate={handleUpdateSkills}
onUninstall={handleUninstallSkills}
/>
) : null}
</View>
</View>
</View>
{skillsStatus ? (
<SkillSelectionSheet
visible={isChoosingSkills}
available={skillsStatus.available}
selection={skillsStatus.selection}
isSaving={isSkillsWorking}
onSave={saveSkillSelection}
onClose={handleCloseSkillSelection}
/>
) : null}
</SettingsSection>
);
}
interface SkillsActionsProps {
state: SkillsSnapshot["state"] | null;
isWorking: boolean;
onInstall: () => void;
onUpdate: () => void;
onUninstall: () => void;
}
function SkillsActions({ state, isWorking, onInstall, onUpdate, onUninstall }: SkillsActionsProps) {
const { t } = useTranslation();
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}>{t("settings.integrations.actions.installed")}</Text>
</View>
<Button variant="outline" size="sm" onPress={onUninstall} disabled={isWorking}>
{t("settings.integrations.actions.uninstall")}
</Button>
</View>
);
}
if (state === "drift") {
return (
<View style={styles.actionsRow}>
<Button variant="outline" size="sm" onPress={onUpdate} disabled={isWorking}>
{isWorking
? t("settings.integrations.actions.working")
: t("settings.integrations.actions.update")}
</Button>
<Button variant="outline" size="sm" onPress={onUninstall} disabled={isWorking}>
{t("settings.integrations.actions.uninstall")}
</Button>
</View>
);
}
return (
<Button variant="outline" size="sm" onPress={onInstall} disabled={isWorking}>
{isWorking
? t("settings.integrations.actions.installing")
: t("settings.integrations.actions.install")}
</Button>
);
}
const styles = StyleSheet.create((theme) => ({
headerLinks: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[0],
},
rowTitleRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
installedLabel: {
flexDirection: "row",
alignItems: "center",
gap: 4,
},
mutedText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
},
actionsRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
rowTitleRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] },
installedLabel: { flexDirection: "row", alignItems: "center", gap: 4 },
mutedText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base },
}));
@@ -1,288 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Check } from "lucide-react-native";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { SettingsSection } from "@/screens/settings/settings-section";
import { settingsStyles } from "@/styles/settings";
import type { Theme } from "@/styles/theme";
import { confirmDialog } from "@/utils/confirm-dialog";
import type { SkillSelection, SkillsSaveResult } from "@/desktop/daemon/desktop-daemon";
const ThemedCheck = withUnistyles(Check);
const checkedIconMapping = (theme: Theme) => ({ color: theme.colors.accentForeground });
interface SkillSelectionSheetProps {
visible: boolean;
available: readonly string[];
selection: SkillSelection;
isSaving: boolean;
onSave: (
selection: SkillSelection,
confirmedRemovals?: readonly string[],
) => Promise<SkillsSaveResult>;
onClose: () => void;
}
function isSkillSelected(draft: SkillSelection, name: string): boolean {
return draft.mode === "all" || draft.skills.includes(name);
}
/**
* Draft form state only. The saved selection, the convergence across agent skill
* directories, and the resulting status all belong to the desktop skills module.
*/
export function SkillSelectionSheet({
visible,
available,
selection,
isSaving,
onSave,
onClose,
}: SkillSelectionSheetProps) {
const { t } = useTranslation();
const [draft, setDraft] = useState<SkillSelection>(selection);
const [saveError, setSaveError] = useState<string | null>(null);
const wasVisible = useRef(visible);
// Seeded when the sheet opens, not whenever a background status refresh hands
// back a new selection object — that would throw away edits in progress.
useEffect(() => {
const opening = visible && !wasVisible.current;
wasVisible.current = visible;
if (!opening) return;
setDraft(selection);
setSaveError(null);
}, [selection, visible]);
const handleToggleAll = useCallback(
(nextAll: boolean) => {
// Leaving `all` keeps every currently installed skill checked, so turning
// the switch off is a starting point rather than a reset.
setDraft(nextAll ? { mode: "all" } : { mode: "custom", skills: [...available] });
},
[available],
);
const handleToggleSkill = useCallback((name: string) => {
setDraft((current) => {
if (current.mode === "all") return current;
const skills = current.skills.includes(name)
? current.skills.filter((skill) => skill !== name)
: [...current.skills, name].sort();
return { mode: "custom", skills };
});
}, []);
const handleSave = useCallback(() => {
void (async () => {
setSaveError(null);
try {
// The host refuses a save that would delete directories it was not told
// about, and answers with the names it found when it looked. Asking it
// is the only way the question and the deletion cannot drift apart.
const attempt = await onSave(draft);
if (attempt.confirmationRequired) {
const confirmed = await confirmDialog({
title: t("settings.integrations.skills.removeTitle"),
message: t("settings.integrations.skills.removeMessage", {
skills: attempt.confirmationRequired.removals.join(", "),
}),
confirmLabel: t("settings.integrations.actions.remove"),
destructive: true,
});
if (!confirmed) return;
const retry = await onSave(draft, attempt.confirmationRequired.removals);
// Something else changed the directories again while the dialog was
// up. Ask about the new list rather than deleting it unannounced.
if (retry.confirmationRequired) {
setSaveError(t("settings.integrations.skills.saveFailed"));
return;
}
}
onClose();
} catch {
setSaveError(t("settings.integrations.skills.saveFailed"));
}
})();
}, [draft, onClose, onSave, t]);
const handleDismiss = useCallback(() => {
if (isSaving) return;
onClose();
}, [isSaving, onClose]);
const header = useMemo<SheetHeader>(
() => ({ title: t("settings.integrations.skills.choose") }),
[t],
);
const footer = useMemo(
() => (
<View style={styles.footer}>
<Button
style={styles.footerButton}
variant="secondary"
onPress={handleDismiss}
disabled={isSaving}
>
{t("common.actions.cancel")}
</Button>
<Button
style={styles.footerButton}
variant="default"
onPress={handleSave}
loading={isSaving}
testID="skill-selection-save"
>
{isSaving
? t("settings.integrations.actions.saving")
: t("settings.integrations.actions.save")}
</Button>
</View>
),
[handleDismiss, handleSave, isSaving, t],
);
const allSkills = draft.mode === "all";
return (
<AdaptiveModalSheet
header={header}
visible={visible}
onClose={handleDismiss}
footer={footer}
testID="skill-selection-sheet"
>
<View style={settingsStyles.card} testID="skill-selection-all-card">
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>
{t("settings.integrations.skills.chooseAll")}
</Text>
<Text style={settingsStyles.rowHint}>
{t("settings.integrations.skills.chooseAllHint")}
</Text>
</View>
<Switch
value={allSkills}
onValueChange={handleToggleAll}
disabled={isSaving}
accessibilityLabel={t("settings.integrations.skills.chooseAll")}
testID="skill-selection-all"
/>
</View>
</View>
{/* The sheet body already spaces its blocks apart, so the section owns
only the gap between its label and the list the label names. */}
<SettingsSection title={t("settings.integrations.skills.chooseList")} flush>
<View style={settingsStyles.card} testID="skill-selection-list-card">
{available.length === 0 ? (
<View style={settingsStyles.row}>
<Text style={settingsStyles.rowHint}>
{t("settings.integrations.skills.chooseEmpty")}
</Text>
</View>
) : (
available.map((name, index) => (
<SkillCheckboxRow
key={name}
name={name}
checked={isSkillSelected(draft, name)}
disabled={allSkills || isSaving}
withBorder={index > 0}
onToggle={handleToggleSkill}
/>
))
)}
</View>
</SettingsSection>
{saveError ? <Text style={settingsStyles.rowError}>{saveError}</Text> : null}
</AdaptiveModalSheet>
);
}
interface SkillCheckboxRowProps {
name: string;
checked: boolean;
disabled: boolean;
withBorder: boolean;
onToggle: (name: string) => void;
}
function SkillCheckboxRow({
name,
checked,
disabled,
withBorder,
onToggle,
}: SkillCheckboxRowProps) {
const handlePress = useCallback(() => onToggle(name), [name, onToggle]);
const accessibilityState = useMemo(() => ({ checked, disabled }), [checked, disabled]);
const rowStyle = useMemo(
() => [
settingsStyles.row,
withBorder ? settingsStyles.rowBorder : null,
styles.checkboxRow,
disabled ? styles.disabledRow : null,
],
[disabled, withBorder],
);
const boxStyle = useMemo(
() => [styles.checkbox, checked ? styles.checkboxChecked : null],
[checked],
);
return (
<Pressable
style={rowStyle}
onPress={handlePress}
disabled={disabled}
accessibilityRole="checkbox"
accessibilityLabel={name}
accessibilityState={accessibilityState}
aria-checked={checked}
>
<View style={boxStyle}>
{checked ? <ThemedCheck size={14} uniProps={checkedIconMapping} /> : null}
</View>
<Text style={settingsStyles.rowTitle}>{name}</Text>
</Pressable>
);
}
const styles = StyleSheet.create((theme) => ({
footer: {
flex: 1,
flexDirection: "row",
gap: theme.spacing[3],
},
footerButton: {
flex: 1,
},
checkboxRow: {
justifyContent: "flex-start",
gap: theme.spacing[3],
},
disabledRow: {
opacity: theme.opacity[50],
},
checkbox: {
width: 22,
height: 22,
borderRadius: theme.borderRadius.sm,
borderWidth: 1,
borderColor: theme.colors.border,
alignItems: "center",
justifyContent: "center",
},
checkboxChecked: {
backgroundColor: theme.colors.accent,
borderColor: theme.colors.accent,
},
}));
@@ -1,12 +1,6 @@
import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
import { invokeDesktopCommand } from "@/desktop/electron/invoke";
import {
parseSkillsSaveResult,
parseSkillsSnapshot,
type SkillSelection,
type SkillsSaveResult,
type SkillsSnapshot,
} from "@/desktop/daemon/skills-snapshot";
import type { AgentSkillSelection } from "@getpaseo/protocol/messages";
export type DesktopDaemonState = "starting" | "running" | "stopped" | "errored";
export type DesktopDaemonStopReason =
@@ -232,38 +226,12 @@ export async function installCli(): Promise<InstallStatus> {
return parseInstallStatus(await invokeDesktopCommand("install_cli"));
}
export type {
SkillOp,
SkillSelection,
SkillsSaveResult,
SkillsSnapshot,
SkillsState,
} from "@/desktop/daemon/skills-snapshot";
export async function getSkillsSnapshot(): Promise<SkillsSnapshot> {
return parseSkillsSnapshot(await invokeDesktopCommand("get_skills_status"));
// COMPAT(desktopSkillSelectionMigration): added in v0.4.0; remove after 2027-02-16.
export function readLegacySkillSelection(): Promise<AgentSkillSelection | null> {
return invokeDesktopCommand("read_legacy_skill_selection") as Promise<AgentSkillSelection | null>;
}
export async function installSkills(): Promise<SkillsSnapshot> {
return parseSkillsSnapshot(await invokeDesktopCommand("install_skills"));
}
export async function updateSkills(): Promise<SkillsSnapshot> {
return parseSkillsSnapshot(await invokeDesktopCommand("update_skills"));
}
export async function uninstallSkills(): Promise<SkillsSnapshot> {
return parseSkillsSnapshot(await invokeDesktopCommand("uninstall_skills"));
}
export async function saveSkillsSelection(
selection: SkillSelection,
confirmedRemovals: readonly string[] = [],
): Promise<SkillsSaveResult> {
return parseSkillsSaveResult(
await invokeDesktopCommand("save_skills_selection", {
...selection,
confirmedRemovals: [...confirmedRemovals],
}),
);
// COMPAT(desktopSkillSelectionMigration): added in v0.4.0; remove after 2027-02-16.
export async function deleteLegacySkillSelection(): Promise<void> {
await invokeDesktopCommand("delete_legacy_skill_selection");
}
@@ -1,131 +0,0 @@
import { describe, expect, it } from "vitest";
import { parseSkillsSaveResult, parseSkillsSnapshot } from "./skills-snapshot";
describe("parseSkillsSnapshot", () => {
it("parses a full snapshot from the desktop command surface", () => {
expect(
parseSkillsSnapshot({
state: "drift",
ops: [
{ kind: "add", name: "paseo-loop" },
{ kind: "delete", name: "paseo-chat" },
],
available: ["paseo", "paseo-loop"],
installed: ["paseo"],
selection: { mode: "custom", skills: ["paseo", "paseo-loop"] },
}),
).toEqual({
state: "drift",
ops: [
{ kind: "add", name: "paseo-loop" },
{ kind: "delete", name: "paseo-chat" },
],
available: ["paseo", "paseo-loop"],
installed: ["paseo"],
selection: { mode: "custom", skills: ["paseo", "paseo-loop"] },
});
});
it("parses the installed skills the host reports", () => {
expect(
parseSkillsSnapshot({
state: "drift",
ops: [{ kind: "add", name: "paseo-loop" }],
available: ["paseo", "paseo-loop"],
installed: ["paseo-loop", 7],
selection: { mode: "all" },
}).installed,
).toEqual(["paseo-loop"]);
});
it("assumes the saved selection is installed when the host does not report it", () => {
// An older host has no `installed` field. Assuming the selection is on disk
// keeps the destructive confirmation firing rather than silently skipping it.
expect(
parseSkillsSnapshot({
state: "up-to-date",
ops: [],
available: ["paseo", "paseo-advisor", "paseo-loop"],
selection: { mode: "custom", skills: ["paseo", "paseo-loop"] },
}).installed,
).toEqual(["paseo", "paseo-loop"]);
});
it("assumes every bundled skill is installed for an all selection from an older host", () => {
expect(
parseSkillsSnapshot({
state: "up-to-date",
ops: [],
available: ["paseo", "paseo-loop"],
}).installed,
).toEqual(["paseo", "paseo-loop"]);
});
it("reads a snapshot with no saved selection as all skills", () => {
expect(parseSkillsSnapshot({ state: "up-to-date", ops: [], available: ["paseo"] })).toEqual({
state: "up-to-date",
ops: [],
available: ["paseo"],
installed: ["paseo"],
selection: { mode: "all" },
});
});
it("drops catalog and selection entries that are not skill names", () => {
expect(
parseSkillsSnapshot({
state: "up-to-date",
ops: [],
available: ["paseo", 7, null, "paseo-loop"],
selection: { mode: "custom", skills: ["paseo", 7] },
}),
).toEqual({
state: "up-to-date",
ops: [],
available: ["paseo", "paseo-loop"],
installed: ["paseo"],
selection: { mode: "custom", skills: ["paseo"] },
});
});
it("reads the removals the host wants confirmed", () => {
expect(
parseSkillsSaveResult({
state: "up-to-date",
ops: [],
available: ["paseo", "paseo-loop"],
installed: ["paseo", "paseo-loop"],
selection: { mode: "all" },
confirmationRequired: { removals: ["paseo-loop", 7] },
}).confirmationRequired,
).toEqual({ removals: ["paseo-loop"] });
});
it("treats a save with no confirmation request as applied", () => {
expect(
parseSkillsSaveResult({
state: "up-to-date",
ops: [],
available: ["paseo"],
installed: ["paseo"],
selection: { mode: "all" },
}).confirmationRequired,
).toBeNull();
});
it("rejects a response that is not an object", () => {
expect(() => parseSkillsSnapshot("nope")).toThrow("Unexpected skills status response.");
});
it("rejects an unknown install state", () => {
expect(() => parseSkillsSnapshot({ state: "half-installed", ops: [] })).toThrow(
"Unexpected skills status state: half-installed",
);
});
it("rejects an unknown pending operation kind", () => {
expect(() =>
parseSkillsSnapshot({ state: "drift", ops: [{ kind: "relocate", name: "paseo" }] }),
).toThrow("Unexpected skill op kind: relocate");
});
});
@@ -1,113 +0,0 @@
export type SkillsState = "not-installed" | "up-to-date" | "drift";
export type SkillOp =
| { kind: "add"; name: string }
| { kind: "update"; name: string }
| { kind: "delete"; name: string };
/** What the user asked to have installed. `all` follows the bundle as it grows. */
export type SkillSelection = { mode: "all" } | { mode: "custom"; skills: string[] };
export interface SkillsSnapshot {
state: SkillsState;
ops: SkillOp[];
/** Every skill the host currently bundles, sorted. The selectable catalog. */
available: string[];
/**
* Skills with a directory in at least one agent home. An `add` op only means
* "missing from at least one target", so it cannot stand in for this.
*/
installed: string[];
selection: SkillSelection;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
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 = typeof raw.name === "string" ? raw.name.trim() : "";
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 parseSkillNames(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.filter((entry): entry is string => typeof entry === "string");
}
function parseSkillSelection(value: unknown): SkillSelection {
// A host that predates the picker sends no selection; it manages every
// bundled skill, which is exactly what `all` means.
if (!isRecord(value)) return { mode: "all" };
if (value.mode === "custom") return { mode: "custom", skills: parseSkillNames(value.skills) };
return { mode: "all" };
}
function selectedSkills(selection: SkillSelection, available: readonly string[]): string[] {
if (selection.mode === "all") return [...available];
const chosen = new Set(selection.skills);
return available.filter((name) => chosen.has(name));
}
export interface SkillsSaveResult extends SkillsSnapshot {
/**
* Non-null when the host refused the save because these directories would be
* deleted. Confirm the names with the user and retry with them.
*/
confirmationRequired: { removals: string[] } | null;
}
export function parseSkillsSaveResult(raw: unknown): SkillsSaveResult {
const snapshot = parseSkillsSnapshot(raw);
const required =
isRecord(raw) && isRecord(raw.confirmationRequired)
? parseSkillNames(raw.confirmationRequired.removals)
: [];
return {
...snapshot,
confirmationRequired: required.length > 0 ? { removals: required } : null,
};
}
export function parseSkillsSnapshot(raw: unknown): SkillsSnapshot {
if (!isRecord(raw)) {
throw new Error("Unexpected skills status response.");
}
const available = parseSkillNames(raw.available);
const selection = parseSkillSelection(raw.selection);
return {
state: parseSkillsState(raw.state),
ops: Array.isArray(raw.ops) ? raw.ops.map(parseSkillOp) : [],
available,
// A host that predates this field cannot say what is on disk. Assume the
// saved selection is, so removing a skill still asks before deleting it.
installed: Array.isArray(raw.installed)
? parseSkillNames(raw.installed)
: selectedSkills(selection, available),
selection,
};
}
@@ -1,282 +0,0 @@
/**
* @vitest-environment jsdom
*/
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 { i18n } from "@/i18n/i18next";
import { useCliInstall, useSkillsStatus } from "./use-install-status";
const toast = vi.hoisted(() => ({
error: vi.fn(),
show: vi.fn(),
copied: vi.fn(),
}));
const desktopDaemon = vi.hoisted(() => ({
getCliInstallStatus: vi.fn(),
installCli: vi.fn(),
getSkillsSnapshot: vi.fn(),
installSkills: vi.fn(),
updateSkills: vi.fn(),
uninstallSkills: vi.fn(),
saveSkillsSelection: vi.fn(),
shouldUseDesktopDaemon: vi.fn(() => true),
}));
vi.mock("@/contexts/toast-context", () => ({
useToast: () => toast,
}));
vi.mock("@/desktop/daemon/desktop-daemon", () => desktopDaemon);
function createQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
}
function renderDesktopHook<TResult>(callback: () => TResult) {
const queryClient = createQueryClient();
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
return renderHook(callback, { wrapper });
}
describe("useCliInstall", () => {
beforeEach(() => {
vi.spyOn(console, "error").mockImplementation(() => {});
desktopDaemon.getCliInstallStatus.mockResolvedValue({ installed: true });
desktopDaemon.installCli.mockResolvedValue({ installed: true });
});
afterEach(() => {
void i18n.changeLanguage("en");
vi.restoreAllMocks();
vi.clearAllMocks();
});
it("loads CLI install status", async () => {
const { result } = renderDesktopHook(() => useCliInstall());
await waitFor(() => {
expect(result.current.status).toEqual({ installed: true });
});
expect(toast.error).not.toHaveBeenCalled();
});
it("toasts and exposes CLI install errors", async () => {
const error = new Error("Missing IPC handler");
desktopDaemon.getCliInstallStatus.mockResolvedValue({ installed: false });
desktopDaemon.installCli.mockRejectedValue(error);
const { result } = renderDesktopHook(() => useCliInstall());
await waitFor(() => {
expect(result.current.status).toEqual({ installed: false });
});
act(() => {
result.current.install();
});
await waitFor(() => {
expect(result.current.error).toBe(error);
});
expect(toast.error).toHaveBeenCalledWith("Unable to install the Paseo CLI.");
expect(console.error).toHaveBeenCalledWith("[Integrations] Failed to install CLI", error);
});
it("uses the active language for CLI install errors", async () => {
await i18n.changeLanguage("zh-CN");
const error = new Error("Missing IPC handler");
desktopDaemon.getCliInstallStatus.mockResolvedValue({ installed: false });
desktopDaemon.installCli.mockRejectedValue(error);
const { result } = renderDesktopHook(() => useCliInstall());
await waitFor(() => {
expect(result.current.status).toEqual({ installed: false });
});
act(() => {
result.current.install();
});
await waitFor(() => {
expect(result.current.error).toBe(error);
});
expect(toast.error).toHaveBeenCalledWith("无法安装 Paseo CLI。");
});
});
describe("useSkillsStatus", () => {
beforeEach(() => {
vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
void i18n.changeLanguage("en");
vi.restoreAllMocks();
vi.clearAllMocks();
});
it("loads the current skills status", async () => {
desktopDaemon.getSkillsSnapshot.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("install transitions a not-installed status to up-to-date and reflects the response directly", async () => {
desktopDaemon.getSkillsSnapshot.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?.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.getSkillsSnapshot.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.getSkillsSnapshot.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.getSkillsSnapshot.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(() => {
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.getSkillsSnapshot.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);
});
});
@@ -3,17 +3,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import {
getCliInstallStatus,
getSkillsSnapshot,
installCli,
installSkills,
saveSkillsSelection,
shouldUseDesktopDaemon,
type InstallStatus,
type SkillSelection,
type SkillsSaveResult,
type SkillsSnapshot,
uninstallSkills,
updateSkills,
} from "@/desktop/daemon/desktop-daemon";
import {
useDesktopIpcErrorReporter,
@@ -21,7 +13,6 @@ import {
} from "@/desktop/hooks/desktop-ipc-error";
const CLI_INSTALL_STATUS_QUERY_KEY = ["desktop", "integrations", "cli-install-status"] as const;
const SKILLS_STATUS_QUERY_KEY = ["desktop", "integrations", "skills-status"] as const;
interface DesktopInstallHookResult {
status: InstallStatus | null;
@@ -80,144 +71,3 @@ export function useCliInstall(): DesktopInstallHookResult {
refresh,
};
}
export interface SkillsStatusHookResult {
status: SkillsSnapshot | null;
isLoading: boolean;
isWorking: boolean;
error: Error | null;
refresh: () => Promise<void>;
install: () => Promise<void>;
update: () => Promise<void>;
uninstall: () => Promise<void>;
saveSelection: (
selection: SkillSelection,
confirmedRemovals?: readonly string[],
) => Promise<SkillsSaveResult>;
}
export function useSkillsStatus(): SkillsStatusHookResult {
const { t } = useTranslation();
const queryClient = useQueryClient();
const reportError = useDesktopIpcErrorReporter();
const enabled = shouldUseDesktopDaemon();
const statusQuery = useQuery<SkillsSnapshot, Error>({
queryKey: SKILLS_STATUS_QUERY_KEY,
queryFn: getSkillsSnapshot,
enabled,
retry: false,
});
const { data: status, error: statusError, isLoading, refetch } = statusQuery;
useDesktopIpcQueryErrorToast({
error: statusQuery.error,
message: t("desktop.integrations.skills.statusFailed"),
logLabel: "[Integrations] Failed to load skills status",
});
const setStatus = useCallback(
(next: SkillsSnapshot) => {
queryClient.setQueryData<SkillsSnapshot>(SKILLS_STATUS_QUERY_KEY, next);
},
[queryClient],
);
const installMutation = useMutation<SkillsSnapshot, Error>({
mutationFn: installSkills,
onError: (error) => {
reportError({
error,
message: t("desktop.integrations.skills.installFailed"),
logLabel: "[Integrations] Failed to install skills",
});
},
onSuccess: setStatus,
});
const updateMutation = useMutation<SkillsSnapshot, Error>({
mutationFn: updateSkills,
onError: (error) => {
reportError({
error,
message: t("desktop.integrations.skills.updateFailed"),
logLabel: "[Integrations] Failed to update skills",
});
},
onSuccess: setStatus,
});
const uninstallMutation = useMutation<SkillsSnapshot, Error>({
mutationFn: uninstallSkills,
onError: (error) => {
reportError({
error,
message: t("desktop.integrations.skills.uninstallFailed"),
logLabel: "[Integrations] Failed to uninstall skills",
});
},
onSuccess: setStatus,
});
const saveSelectionMutation = useMutation<
SkillsSaveResult,
Error,
{ selection: SkillSelection; confirmedRemovals: readonly string[] }
>({
mutationFn: ({ selection, confirmedRemovals }) =>
saveSkillsSelection(selection, confirmedRemovals),
onError: (error) => {
reportError({
error,
message: t("desktop.integrations.skills.saveSelectionFailed"),
logLabel: "[Integrations] Failed to save skills selection",
});
},
onSuccess: setStatus,
});
const isWorking =
installMutation.isPending ||
updateMutation.isPending ||
uninstallMutation.isPending ||
saveSelectionMutation.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]);
const saveSelection = useCallback(
async (selection: SkillSelection, confirmedRemovals: readonly string[] = []) =>
saveSelectionMutation.mutateAsync({ selection, confirmedRemovals }),
[saveSelectionMutation],
);
return {
status: status ?? null,
isLoading,
isWorking,
error:
statusError ??
installMutation.error ??
updateMutation.error ??
uninstallMutation.error ??
saveSelectionMutation.error ??
null,
refresh,
install,
update,
uninstall,
saveSelection,
};
}