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
+238
View File
@@ -0,0 +1,238 @@
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { useFocusEffect } from "@react-navigation/native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { ArrowUpRight, Blocks, Check, Settings2 } from "lucide-react-native";
import type { AgentSkillOperation, AgentSkillsStatus } from "@getpaseo/protocol/messages";
import type { TFunction } from "i18next";
import { Button } from "@/components/ui/button";
import { SettingsSection } from "@/screens/settings/settings-section";
import { settingsStyles } from "@/styles/settings";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { confirmDialog } from "@/utils/confirm-dialog";
import { openExternalUrl } from "@/utils/open-external-url";
import { SkillSelectionSheet } from "./selection-sheet";
import { useAgentSkills } from "./use-agent-skills";
const OP_KIND_ORDER: Record<AgentSkillOperation["kind"], number> = {
add: 0,
update: 1,
delete: 2,
};
const ThemedBlocks = withUnistyles(Blocks);
const ThemedCheck = withUnistyles(Check);
const ThemedSettings = withUnistyles(Settings2);
const ThemedArrowUpRight = withUnistyles(ArrowUpRight);
const SKILLS_DOCS_URL = "https://paseo.sh/docs/skills";
const foregroundMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const mutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
function formatUpdateMessage(ops: readonly AgentSkillOperation[], t: TFunction): string {
return [...ops]
.sort((a, b) => OP_KIND_ORDER[a.kind] - OP_KIND_ORDER[b.kind] || a.name.localeCompare(b.name))
.map((op) => `${t(`settings.host.skills.operations.${op.kind}`)} ${op.name}`)
.join("\n");
}
export function AgentSkillsSection({ serverId }: { serverId: string }) {
const { t } = useTranslation();
const skills = useAgentSkills(serverId);
const refreshSkills = skills.refresh;
const skillsConnected = skills.connected;
const skillsSupported = skills.supported;
const [isChoosing, setIsChoosing] = useState(false);
useFocusEffect(
useCallback(() => {
if (skillsConnected && skillsSupported) void refreshSkills();
return undefined;
}, [refreshSkills, skillsConnected, skillsSupported]),
);
const maintenanceOps = useMemo(
() => skills.status?.ops.filter((op) => op.kind !== "delete") ?? [],
[skills.status?.ops],
);
const state =
skills.status?.state === "drift" && maintenanceOps.length === 0
? "up-to-date"
: (skills.status?.state ?? null);
const hasSelected =
(skills.status?.selection.mode === "all" && skills.status.available.length > 0) ||
(skills.status?.selection.mode === "custom" &&
skills.status.selection.skills.some((name) => skills.status?.available.includes(name)));
const handleReconcile = useCallback(async () => {
if (skills.isWorking) return;
const confirmed = await confirmDialog({
title: t("settings.host.skills.updateTitle"),
message:
maintenanceOps.length > 0
? formatUpdateMessage(maintenanceOps, t)
: t("settings.host.skills.updateFallback"),
confirmLabel: t("settings.host.skills.actions.update"),
});
if (confirmed) await skills.reconcile();
}, [maintenanceOps, skills, t]);
const handleUninstall = useCallback(async () => {
if (skills.isWorking) return;
const confirmed = await confirmDialog({
title: t("settings.host.skills.uninstallTitle"),
message: t("settings.host.skills.uninstallMessage"),
confirmLabel: t("settings.host.skills.actions.uninstall"),
destructive: true,
});
if (confirmed) await skills.uninstall();
}, [skills, t]);
const chooseIcon = useMemo(
() => <ThemedSettings size={ICON_SIZE.sm} uniProps={mutedMapping} />,
[],
);
const handleOpen = useCallback(() => setIsChoosing(true), []);
const handleClose = useCallback(() => setIsChoosing(false), []);
const handleOpenDocs = useCallback(() => {
void openExternalUrl(SKILLS_DOCS_URL);
}, []);
const docsIcon = useMemo(
() => <ThemedArrowUpRight size={ICON_SIZE.sm} uniProps={mutedMapping} />,
[],
);
const trailing = useMemo(
() => (
<Button
variant="ghost"
size="sm"
leftIcon={docsIcon}
textStyle={settingsStyles.sectionHeaderLinkText}
style={settingsStyles.sectionHeaderLink}
onPress={handleOpenDocs}
accessibilityLabel={t("settings.host.skills.openDocs")}
>
{t("settings.host.skills.docs")}
</Button>
),
[docsIcon, handleOpenDocs, t],
);
if (!skills.connected || !skills.supported) {
return (
<SettingsSection title={t("settings.host.skills.sectionTitle")} trailing={trailing}>
<View style={settingsStyles.card} testID="host-agent-skills-unavailable">
<View style={styles.emptyCard}>
<Text style={styles.mutedText}>
{skills.connected
? t("settings.host.skills.unsupported")
: t("settings.host.skills.unavailable")}
</Text>
</View>
</View>
</SettingsSection>
);
}
return (
<SettingsSection title={t("settings.host.skills.sectionTitle")} trailing={trailing}>
<View style={settingsStyles.card} testID="host-agent-skills-card">
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<View style={styles.rowTitleRow}>
<ThemedBlocks size={ICON_SIZE.md} uniProps={foregroundMapping} />
<Text style={settingsStyles.rowTitle}>{t("settings.host.skills.title")}</Text>
</View>
<Text style={settingsStyles.rowHint}>
{state === "drift"
? t("settings.host.skills.updateAvailable")
: t("settings.host.skills.description")}
</Text>
</View>
<View style={styles.actionsRow}>
<Button
variant="ghost"
size="sm"
leftIcon={chooseIcon}
onPress={handleOpen}
disabled={skills.status === null || skills.isWorking}
accessibilityLabel={t("settings.host.skills.choose")}
/>
{hasSelected ? (
<SkillsActions
state={state}
isWorking={skills.isWorking}
onInstall={skills.reconcile}
onReconcile={handleReconcile}
onUninstall={handleUninstall}
/>
) : null}
</View>
</View>
</View>
{skills.status ? (
<SkillSelectionSheet
visible={isChoosing}
available={skills.status.available}
selection={skills.status.selection}
isSaving={skills.isWorking}
onSave={skills.saveSelection}
onClose={handleClose}
/>
) : null}
</SettingsSection>
);
}
function SkillsActions({
state,
isWorking,
onReconcile,
onInstall,
onUninstall,
}: {
state: AgentSkillsStatus["state"] | null;
isWorking: boolean;
onReconcile: () => void;
onInstall: () => void;
onUninstall: () => void;
}) {
const { t } = useTranslation();
if (state === "up-to-date") {
return (
<View style={styles.actionsRow}>
<View style={styles.installedLabel}>
<ThemedCheck size={14} uniProps={mutedMapping} />
<Text style={styles.mutedText}>{t("settings.host.skills.actions.installed")}</Text>
</View>
<Button variant="outline" size="sm" onPress={onUninstall} disabled={isWorking}>
{t("settings.host.skills.actions.uninstall")}
</Button>
</View>
);
}
if (state === "drift") {
return (
<View style={styles.actionsRow}>
<Button variant="outline" size="sm" onPress={onReconcile} disabled={isWorking}>
{isWorking
? t("settings.host.skills.actions.working")
: t("settings.host.skills.actions.update")}
</Button>
<Button variant="outline" size="sm" onPress={onUninstall} disabled={isWorking}>
{t("settings.host.skills.actions.uninstall")}
</Button>
</View>
);
}
return (
<Button variant="outline" size="sm" onPress={onInstall} disabled={isWorking}>
{isWorking
? t("settings.host.skills.actions.installing")
: t("settings.host.skills.actions.install")}
</Button>
);
}
const styles = StyleSheet.create((theme) => ({
rowTitleRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] },
installedLabel: { flexDirection: "row", alignItems: "center", gap: 4 },
mutedText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm },
actionsRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] },
emptyCard: { padding: theme.spacing[4] },
}));
@@ -0,0 +1,46 @@
import { describe, expect, it, vi } from "vitest";
import { migrateLegacyAgentSkillsSelection } from "./legacy-migration";
describe("legacy agent skills migration", () => {
it("deletes the legacy file only after the daemon confirms persistence", async () => {
const selection = { mode: "custom" as const, skills: ["paseo"] };
const order: string[] = [];
const client = {
importLegacyAgentSkillsSelection: vi.fn(async () => {
order.push("persist");
}),
};
const remove = vi.fn(async () => {
order.push("delete");
});
await expect(
migrateLegacyAgentSkillsSelection(client, async () => selection, remove),
).resolves.toBe(true);
expect(client.importLegacyAgentSkillsSelection).toHaveBeenCalledWith(selection);
expect(order).toEqual(["persist", "delete"]);
});
it("retains the legacy file when daemon persistence fails", async () => {
const failure = new Error("offline");
const client = { importLegacyAgentSkillsSelection: vi.fn(async () => Promise.reject(failure)) };
const remove = vi.fn();
await expect(
migrateLegacyAgentSkillsSelection(client, async () => ({ mode: "all" }), remove),
).rejects.toBe(failure);
expect(remove).not.toHaveBeenCalled();
});
it("does nothing when no legacy file remains", async () => {
const client = { importLegacyAgentSkillsSelection: vi.fn() };
const remove = vi.fn();
await expect(migrateLegacyAgentSkillsSelection(client, async () => null, remove)).resolves.toBe(
false,
);
expect(client.importLegacyAgentSkillsSelection).not.toHaveBeenCalled();
expect(remove).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,50 @@
import { useEffect } from "react";
import type { AgentSkillSelection } from "@getpaseo/protocol/messages";
import { getDesktopDaemonStatus, shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import {
deleteLegacySkillSelection,
readLegacySkillSelection,
} from "@/desktop/daemon/desktop-daemon";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
interface LegacySelectionClient {
importLegacyAgentSkillsSelection(selection: AgentSkillSelection): Promise<unknown>;
}
export async function migrateLegacyAgentSkillsSelection(
client: LegacySelectionClient,
read = readLegacySkillSelection,
remove = deleteLegacySkillSelection,
): Promise<boolean> {
const selection = await read();
if (!selection) return false;
await client.importLegacyAgentSkillsSelection(selection);
await remove();
return true;
}
// COMPAT(desktopSkillSelectionMigration): added in v0.4.0; remove after 2027-02-16.
export function LegacyAgentSkillsMigration() {
const connectedManagedHosts = useSessionStore((state) =>
Object.entries(state.sessions)
.filter(([, session]) => session.serverInfo?.desktopManaged === true)
.map(([serverId]) => serverId)
.sort()
.join(","),
);
useEffect(() => {
if (!shouldUseDesktopDaemon() || !connectedManagedHosts) return;
async function migrate(): Promise<void> {
const status = await getDesktopDaemonStatus();
if (status.status !== "running" || !status.desktopManaged) return;
const client = getHostRuntimeStore().getSnapshot(status.serverId)?.client;
if (!client) return;
await migrateLegacyAgentSkillsSelection(client);
}
void migrate().catch((error) => {
console.error("[Agent skills] Legacy selection migration failed; will retry", error);
});
}, [connectedManagedHosts]);
return null;
}
@@ -10,7 +10,10 @@ 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";
import type {
AgentSkillSelection as SkillSelection,
AgentSkillsSaveResult as SkillsSaveResult,
} from "@getpaseo/protocol/messages";
const ThemedCheck = withUnistyles(Check);
const checkedIconMapping = (theme: Theme) => ({ color: theme.colors.accentForeground });
@@ -33,7 +36,7 @@ function isSkillSelected(draft: SkillSelection, name: string): boolean {
/**
* Draft form state only. The saved selection, the convergence across agent skill
* directories, and the resulting status all belong to the desktop skills module.
* directories, and the resulting status all belong to the selected host.
*/
export function SkillSelectionSheet({
visible,
@@ -87,11 +90,11 @@ export function SkillSelectionSheet({
const attempt = await onSave(draft);
if (attempt.confirmationRequired) {
const confirmed = await confirmDialog({
title: t("settings.integrations.skills.removeTitle"),
message: t("settings.integrations.skills.removeMessage", {
title: t("settings.host.skills.removeTitle"),
message: t("settings.host.skills.removeMessage", {
skills: attempt.confirmationRequired.removals.join(", "),
}),
confirmLabel: t("settings.integrations.actions.remove"),
confirmLabel: t("settings.host.skills.actions.remove"),
destructive: true,
});
if (!confirmed) return;
@@ -99,13 +102,13 @@ export function SkillSelectionSheet({
// 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"));
setSaveError(t("settings.host.skills.saveFailed"));
return;
}
}
onClose();
} catch {
setSaveError(t("settings.integrations.skills.saveFailed"));
setSaveError(t("settings.host.skills.saveFailed"));
}
})();
}, [draft, onClose, onSave, t]);
@@ -115,10 +118,7 @@ export function SkillSelectionSheet({
onClose();
}, [isSaving, onClose]);
const header = useMemo<SheetHeader>(
() => ({ title: t("settings.integrations.skills.choose") }),
[t],
);
const header = useMemo<SheetHeader>(() => ({ title: t("settings.host.skills.choose") }), [t]);
const footer = useMemo(
() => (
@@ -139,8 +139,8 @@ export function SkillSelectionSheet({
testID="skill-selection-save"
>
{isSaving
? t("settings.integrations.actions.saving")
: t("settings.integrations.actions.save")}
? t("settings.host.skills.actions.saving")
: t("settings.host.skills.actions.save")}
</Button>
</View>
),
@@ -160,18 +160,14 @@ export function SkillSelectionSheet({
<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>
<Text style={settingsStyles.rowTitle}>{t("settings.host.skills.chooseAll")}</Text>
<Text style={settingsStyles.rowHint}>{t("settings.host.skills.chooseAllHint")}</Text>
</View>
<Switch
value={allSkills}
onValueChange={handleToggleAll}
disabled={isSaving}
accessibilityLabel={t("settings.integrations.skills.chooseAll")}
accessibilityLabel={t("settings.host.skills.chooseAll")}
testID="skill-selection-all"
/>
</View>
@@ -179,13 +175,11 @@ export function SkillSelectionSheet({
{/* 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>
<SettingsSection title={t("settings.host.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>
<Text style={settingsStyles.rowHint}>{t("settings.host.skills.chooseEmpty")}</Text>
</View>
) : (
available.map((name, index) => (
@@ -0,0 +1,72 @@
/** @vitest-environment jsdom */
import React, { type ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useAgentSkills } from "./use-agent-skills";
const runtime = vi.hoisted(() => ({
connected: true,
supported: true,
clients: new Map<string, { getAgentSkillsStatus: ReturnType<typeof vi.fn> }>(),
}));
vi.mock("@/runtime/host-features", () => ({
useHostFeature: (_serverId: string, feature: string) =>
feature === "skillManagement" && runtime.supported,
}));
vi.mock("@/runtime/host-runtime", () => ({
useHostRuntimeClient: (serverId: string) => runtime.clients.get(serverId) ?? null,
useHostRuntimeIsConnected: () => runtime.connected,
}));
vi.mock("@/contexts/toast-context", () => ({
useToast: () => ({ error: vi.fn() }),
}));
function wrapper({ children }: { children: ReactNode }) {
return (
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
{children}
</QueryClientProvider>
);
}
describe("host agent skills", () => {
beforeEach(() => {
runtime.connected = true;
runtime.supported = true;
runtime.clients.clear();
});
it("reads status from the selected host client", async () => {
const local = { getAgentSkillsStatus: vi.fn() };
const remote = {
getAgentSkillsStatus: vi.fn(async () => ({
state: "not-installed",
ops: [],
available: ["paseo"],
installed: [],
selection: { mode: "all" },
})),
};
runtime.clients.set("local", local);
runtime.clients.set("remote", remote);
const { result } = renderHook(() => useAgentSkills("remote"), { wrapper });
await waitFor(() => expect(result.current.status?.available).toEqual(["paseo"]));
expect(remote.getAgentSkillsStatus).toHaveBeenCalledOnce();
expect(local.getAgentSkillsStatus).not.toHaveBeenCalled();
});
it("does not call an old host without the capability", async () => {
runtime.supported = false;
const client = { getAgentSkillsStatus: vi.fn() };
runtime.clients.set("old-host", client);
const { result } = renderHook(() => useAgentSkills("old-host"), { wrapper });
expect(result.current.supported).toBe(false);
expect(client.getAgentSkillsStatus).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,108 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import type {
AgentSkillSelection,
AgentSkillsSaveResult,
AgentSkillsStatus,
} from "@getpaseo/protocol/messages";
import { useToast } from "@/contexts/toast-context";
import { useFetchQuery } from "@/data/query";
import { useHostFeature } from "@/runtime/host-features";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
export function useAgentSkills(serverId: string) {
const { t } = useTranslation();
const toast = useToast();
const queryClient = useQueryClient();
const client = useHostRuntimeClient(serverId);
const connected = useHostRuntimeIsConnected(serverId);
const supported = useHostFeature(serverId, "skillManagement");
const queryKey = useMemo(() => ["host", serverId, "agent-skills"] as const, [serverId]);
const reportedQueryError = useRef<Error | null>(null);
const report = useCallback(
(message: string, error: Error) => {
console.error(`[Agent skills: ${serverId}]`, error);
toast.error(message);
},
[serverId, toast],
);
const query = useFetchQuery<AgentSkillsStatus, Error>({
queryKey,
queryFn: () => {
if (!client) throw new Error(t("settings.host.skills.unavailable"));
return client.getAgentSkillsStatus();
},
enabled: supported && client !== null,
retry: false,
dataShape: "value",
staleTimeMs: 0,
});
useEffect(() => {
if (!query.error || query.error === reportedQueryError.current) return;
reportedQueryError.current = query.error;
report(t("settings.host.skills.statusFailed"), query.error);
}, [query.error, report, t]);
const setStatus = useCallback(
(status: AgentSkillsStatus) => queryClient.setQueryData(queryKey, status),
[queryClient, queryKey],
);
const reconcile = useMutation({
mutationFn: async () => {
if (!client) throw new Error(t("settings.host.skills.unavailable"));
return client.reconcileAgentSkills();
},
onSuccess: setStatus,
onError: (error: Error) => report(t("settings.host.skills.updateFailed"), error),
});
const uninstall = useMutation({
mutationFn: async () => {
if (!client) throw new Error(t("settings.host.skills.unavailable"));
return client.uninstallAgentSkills();
},
onSuccess: setStatus,
onError: (error: Error) => report(t("settings.host.skills.uninstallFailed"), error),
});
const save = useMutation<
AgentSkillsSaveResult,
Error,
{ selection: AgentSkillSelection; confirmedRemovals: readonly string[] }
>({
mutationFn: async ({ selection, confirmedRemovals }) => {
if (!client) throw new Error(t("settings.host.skills.unavailable"));
return client.saveAgentSkillsSelection(selection, confirmedRemovals);
},
onSuccess: setStatus,
onError: (error) => report(t("settings.host.skills.saveSelectionFailed"), error),
});
const isWorking = reconcile.isPending || uninstall.isPending || save.isPending;
const refetch = query.refetch;
const reconcileAsync = reconcile.mutateAsync;
const uninstallAsync = uninstall.mutateAsync;
const saveAsync = save.mutateAsync;
const refresh = useCallback(async () => {
await refetch();
}, [refetch]);
const runReconcile = useCallback(async () => {
await reconcileAsync().catch(() => undefined);
}, [reconcileAsync]);
const runUninstall = useCallback(async () => {
await uninstallAsync().catch(() => undefined);
}, [uninstallAsync]);
const saveSelection = useCallback(
(selection: AgentSkillSelection, confirmedRemovals: readonly string[] = []) =>
saveAsync({ selection, confirmedRemovals }),
[saveAsync],
);
return {
connected,
supported,
status: query.data ?? null,
isLoading: query.isLoading,
isWorking,
refresh,
reconcile: runReconcile,
uninstall: runUninstall,
saveSelection,
};
}
+2
View File
@@ -72,6 +72,7 @@ import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-rout
import { ThemedStack } from "@/navigation/themed-stack";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { AgentNavigationListener } from "@/desktop/agent-navigation";
import { LegacyAgentSkillsMigration } from "@/agent-skills/legacy-migration";
import { legacyFavoriteProfileMigration } from "@/agent-profiles/migration";
import { listenToDesktopEvent } from "@/desktop/electron/events";
import { updateDesktopWindowControls } from "@/desktop/electron/window";
@@ -596,6 +597,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
<DownloadToast />
<RosettaCalloutSource />
<UpdateCalloutSource />
<LegacyAgentSkillsMigration />
<WorktreeSetupCalloutSource />
<CommandCenterRootActions />
<CommandCenterWorkspaceActions />
@@ -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,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,
};
}
-4
View File
@@ -258,7 +258,6 @@ describe("translation resources", () => {
expect(en.settings.appearance.fonts.interfaceFont).toBe("Interface font");
expect(en.settings.shortcuts.actions.rebind).toBe("Rebind");
expect(en.settings.integrations.commandLine.title).toBe("Command line");
expect(en.settings.integrations.skills.updateAvailable).toBe("Update available");
expect(en.settings.notifications.playSound).toBe("Play sound");
expect(en.settings.notifications.permission).toBe("Notification permission");
expect(en.settings.notifications.sentTitle).toBe("Test notification sent");
@@ -675,9 +674,6 @@ describe("translation resources", () => {
);
expect(en.desktop.daemon.loadFailed).toBe("Unable to load desktop daemon status.");
expect(en.desktop.integrations.cli.installFailed).toBe("Unable to install the Paseo CLI.");
expect(en.desktop.integrations.skills.installFailed).toBe(
"Unable to install orchestration skills.",
);
});
it("includes remaining utility chrome keys for the Batch 4T migration", () => {
+42 -39
View File
@@ -1,4 +1,4 @@
import type { TranslationResources } from "./en";
import { en, type TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const ar: TranslationResources = {
@@ -1298,13 +1298,6 @@ export const ar: TranslationResources = {
statusFailed: "غير قادر على التحقق من حالة تثبيت CLI.",
installFailed: "غير قادر على تثبيت PaseoCLI.",
},
skills: {
statusFailed: "غير قادر على التحقق من حالة مهارات التنسيق.",
installFailed: "غير قادر على تثبيت مهارات التنسيق.",
updateFailed: "غير قادر على تحديث مهارات التنسيق.",
uninstallFailed: "غير قادر على إلغاء تثبيت مهارات التنسيق.",
saveSelectionFailed: "غير قادر على حفظ اختيار مهارات التنسيق.",
},
},
},
rootError: {
@@ -2081,47 +2074,16 @@ export const ar: TranslationResources = {
title: "التكامل",
docs: {
cli: "مستندات CLI",
skills: "وثائق المهارات",
openCli: "افتح وثائق CLI",
openSkills: "فتح وثائق المهارات",
},
commandLine: {
title: "سطر الأوامر",
description: "وكلاء التحكم والبرنامج النصي من المحطة الطرفية الخاصة بك",
},
skills: {
title: "مهارات التنسيق",
description: "قم بتعليم عملائك كيفية التنسيق من خلال CLI",
updateAvailable: "التحديث متاح",
updateTitle: "تحديث مهارات Paseo ؟",
updateFallback: "مزامنة المهارات المجمعة لجهازك.",
uninstallTitle: "إلغاء تثبيت مهارات Paseo ؟",
uninstallMessage: "يزيل جميع مهارات تنسيق Paseo من ~/.agents ، ~/.claude ، ~/.codex.",
choose: "اختيار المهارات",
chooseAll: "كل المهارات",
chooseAllHint: "أبقِ جميع المهارات المرفقة مثبتة، بما فيها ما يُضاف لاحقًا.",
chooseList: "المهارات المرفقة",
chooseEmpty: "لا تتضمن هذه النسخة أي مهارات.",
removeTitle: "إزالة المهارات غير المحددة؟",
removeMessage:
"سيتم حذف {{skills}} من ~/.agents و~/.claude و~/.codex. كما يُحذف كل ما أضفته داخل مجلدات المهارات تلك.",
saveFailed: "تعذر حفظ اختيار المهارات.",
},
actions: {
install: "ثَبَّتَ",
installing: "جارٍ التثبيت...",
installed: "تم التثبيت",
update: "تحديث",
working: "عمل...",
remove: "إزالة",
uninstall: "إلغاء التثبيت",
save: "حفظ",
saving: "جارٍ الحفظ...",
},
operations: {
add: "أضف مهارة",
update: "تحديث المهارة",
delete: "حذف المهارة",
},
},
permissions: {
@@ -2191,6 +2153,47 @@ export const ar: TranslationResources = {
rowTitle: "إقران جهاز",
rowHint: "امسح رمز QR ضوئيًا أو انسخ رابطًا لتوصيل هاتفك بهذا المضيف",
},
skills: {
...en.settings.host.skills,
sectionTitle: "مهارات التنسيق",
title: "مهارات التنسيق",
description: "قم بتعليم عملائك كيفية التنسيق من خلال CLI",
updateAvailable: "التحديث متاح",
updateTitle: "تحديث مهارات Paseo ؟",
updateFallback: "مزامنة المهارات المجمعة لجهازك.",
uninstallTitle: "إلغاء تثبيت مهارات Paseo ؟",
uninstallMessage: "يزيل جميع مهارات تنسيق Paseo من ~/.agents ، ~/.claude ، ~/.codex.",
choose: "اختيار المهارات",
chooseAll: "كل المهارات",
chooseAllHint: "أبقِ جميع المهارات المرفقة مثبتة، بما فيها ما يُضاف لاحقًا.",
chooseList: "المهارات المرفقة",
chooseEmpty: "لا تتضمن هذه النسخة أي مهارات.",
removeTitle: "إزالة المهارات غير المحددة؟",
removeMessage:
"سيتم حذف {{skills}} من ~/.agents و~/.claude و~/.codex. كما يُحذف كل ما أضفته داخل مجلدات المهارات تلك.",
saveFailed: "تعذر حفظ اختيار المهارات.",
statusFailed: "غير قادر على التحقق من حالة مهارات التنسيق.",
updateFailed: "غير قادر على تحديث مهارات التنسيق.",
uninstallFailed: "غير قادر على إلغاء تثبيت مهارات التنسيق.",
saveSelectionFailed: "غير قادر على حفظ اختيار مهارات التنسيق.",
actions: {
install: "ثَبَّتَ",
installing: "جارٍ التثبيت...",
installed: "تم التثبيت",
update: "تحديث",
working: "عمل...",
remove: "إزالة",
uninstall: "إلغاء التثبيت",
save: "حفظ",
saving: "جارٍ الحفظ...",
cancel: "يلغي",
},
operations: {
add: "أضف مهارة",
update: "تحديث المهارة",
delete: "حذف المهارة",
},
},
orchestration: {
title: "التنسيق",
unavailable: "اتصل بهذا المضيف لإدارة التنسيق",
+45 -39
View File
@@ -1307,13 +1307,6 @@ export const en = {
statusFailed: "Unable to check CLI install status.",
installFailed: "Unable to install the Paseo CLI.",
},
skills: {
statusFailed: "Unable to check orchestration skills status.",
installFailed: "Unable to install orchestration skills.",
updateFailed: "Unable to update orchestration skills.",
uninstallFailed: "Unable to uninstall orchestration skills.",
saveSelectionFailed: "Unable to save the orchestration skills selection.",
},
},
},
rootError: {
@@ -2145,48 +2138,16 @@ export const en = {
title: "Integrations",
docs: {
cli: "CLI docs",
skills: "Skills docs",
openCli: "Open CLI documentation",
openSkills: "Open skills documentation",
},
commandLine: {
title: "Command line",
description: "Control and script agents from your terminal",
},
skills: {
title: "Orchestration skills",
description: "Teach your agents to orchestrate through the CLI",
updateAvailable: "Update available",
updateTitle: "Update Paseo skills?",
updateFallback: "Sync bundled skills to your machine.",
uninstallTitle: "Uninstall Paseo skills?",
uninstallMessage:
"Removes all Paseo orchestration skills from ~/.agents, ~/.claude, ~/.codex.",
choose: "Choose skills",
chooseAll: "All skills",
chooseAllHint: "Keep every bundled skill installed, including ones added later.",
chooseList: "Bundled skills",
chooseEmpty: "This build bundles no skills.",
removeTitle: "Remove deselected skills?",
removeMessage:
"{{skills}} will be deleted from ~/.agents, ~/.claude, and ~/.codex. Anything you added inside those skill folders is deleted too.",
saveFailed: "Could not save your skill selection.",
},
actions: {
install: "Install",
installing: "Installing...",
installed: "Installed",
update: "Update",
working: "Working...",
remove: "Remove",
uninstall: "Uninstall",
save: "Save",
saving: "Saving...",
},
operations: {
add: "Add skill",
update: "Update skill",
delete: "Delete skill",
},
},
permissions: {
@@ -2256,6 +2217,51 @@ export const en = {
rowTitle: "Pair a device",
rowHint: "Scan a QR code or copy a link to connect your phone to this host",
},
skills: {
sectionTitle: "Orchestration skills",
title: "Orchestration skills",
description: "Teach your agents to orchestrate through the CLI",
docs: "Skills",
openDocs: "Open skills documentation",
unavailable: "Connect to this host to manage orchestration skills",
unsupported: "Update this host to manage orchestration skills",
statusFailed: "Unable to check orchestration skills status.",
updateFailed: "Unable to update orchestration skills.",
uninstallFailed: "Unable to uninstall orchestration skills.",
saveSelectionFailed: "Unable to save the orchestration skills selection.",
updateAvailable: "Update available",
updateTitle: "Update Paseo skills?",
updateFallback: "Sync bundled skills to this host.",
uninstallTitle: "Uninstall Paseo skills?",
uninstallMessage:
"Removes all Paseo orchestration skills from ~/.agents, ~/.claude, ~/.codex on this host.",
choose: "Choose skills",
chooseAll: "All skills",
chooseAllHint: "Keep every bundled skill installed, including ones added later.",
chooseList: "Bundled skills",
chooseEmpty: "This host bundles no skills.",
removeTitle: "Remove deselected skills?",
removeMessage:
"{{skills}} will be deleted from ~/.agents, ~/.claude, and ~/.codex on this host. Anything you added inside those skill folders is deleted too.",
saveFailed: "Could not save your skill selection.",
actions: {
install: "Install",
installing: "Installing...",
installed: "Installed",
update: "Update",
working: "Working...",
remove: "Remove",
uninstall: "Uninstall",
save: "Save",
saving: "Saving...",
cancel: "Cancel",
},
operations: {
add: "Add skill",
update: "Update skill",
delete: "Delete skill",
},
},
orchestration: {
title: "Orchestration",
unavailable: "Connect to this host to manage orchestration",
+44 -41
View File
@@ -1,4 +1,4 @@
import type { TranslationResources } from "./en";
import { en, type TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const es: TranslationResources = {
@@ -1338,13 +1338,6 @@ export const es: TranslationResources = {
statusFailed: "No se puede verificar el estado de instalación deCLI.",
installFailed: "No se puede instalar elPaseoCLI.",
},
skills: {
statusFailed: "No se puede comprobar el estado de las habilidades de orquestación.",
installFailed: "No se pueden instalar habilidades de orquestación.",
updateFailed: "No se pueden actualizar las habilidades de orquestación.",
uninstallFailed: "No se pueden desinstalar las habilidades de orquestación.",
saveSelectionFailed: "No se puede guardar la selección de habilidades de orquestación.",
},
},
},
rootError: {
@@ -2129,49 +2122,16 @@ export const es: TranslationResources = {
title: "Integraciones",
docs: {
cli: "DocumentosCLI",
skills: "Documentos de habilidades",
openCli: "Abrir la documentación deCLI",
openSkills: "Documentación de habilidades abiertas",
},
commandLine: {
title: "línea de comando",
description: "Agentes de control y script desde tu terminal",
},
skills: {
title: "Habilidades de orquestación",
description: "Enseñe a sus agentes a orquestar a través delCLI",
updateAvailable: "Actualización disponible",
updateTitle: "¿Actualizar las habilidades dePaseo?",
updateFallback: "Sincronice las habilidades incluidas con su máquina.",
uninstallTitle: "¿Desinstalar las habilidadesPaseo?",
uninstallMessage:
"Elimina todas las habilidades de orquestaciónPaseode ~/.agents, ~/.claude, ~/.codex.",
choose: "Elegir habilidades",
chooseAll: "Todas las habilidades",
chooseAllHint:
"Mantén instaladas todas las habilidades incluidas, también las que se agreguen después.",
chooseList: "Habilidades incluidas",
chooseEmpty: "Esta versión no incluye habilidades.",
removeTitle: "¿Eliminar las habilidades deseleccionadas?",
removeMessage:
"{{skills}} se eliminarán de ~/.agents, ~/.claude y ~/.codex. También se elimina todo lo que hayas añadido dentro de esas carpetas de habilidades.",
saveFailed: "No se pudo guardar tu selección de habilidades.",
},
actions: {
install: "Instalar",
installing: "Instalando...",
installed: "Instalado",
update: "Actualizar",
working: "Laboral...",
remove: "Eliminar",
uninstall: "Desinstalar",
save: "Guardar",
saving: "Guardando...",
},
operations: {
add: "Agregar habilidad",
update: "Actualizar habilidad",
delete: "Eliminar habilidad",
},
},
permissions: {
@@ -2241,6 +2201,49 @@ export const es: TranslationResources = {
rowTitle: "Emparejar un dispositivo",
rowHint: "Escanee un códigoQRo copie un enlace para conectar su teléfono a este host",
},
skills: {
...en.settings.host.skills,
sectionTitle: "Habilidades de orquestación",
title: "Habilidades de orquestación",
description: "Enseñe a sus agentes a orquestar a través delCLI",
updateAvailable: "Actualización disponible",
updateTitle: "¿Actualizar las habilidades dePaseo?",
updateFallback: "Sincronice las habilidades incluidas con su máquina.",
uninstallTitle: "¿Desinstalar las habilidadesPaseo?",
uninstallMessage:
"Elimina todas las habilidades de orquestaciónPaseode ~/.agents, ~/.claude, ~/.codex.",
choose: "Elegir habilidades",
chooseAll: "Todas las habilidades",
chooseAllHint:
"Mantén instaladas todas las habilidades incluidas, también las que se agreguen después.",
chooseList: "Habilidades incluidas",
chooseEmpty: "Esta versión no incluye habilidades.",
removeTitle: "¿Eliminar las habilidades deseleccionadas?",
removeMessage:
"{{skills}} se eliminarán de ~/.agents, ~/.claude y ~/.codex. También se elimina todo lo que hayas añadido dentro de esas carpetas de habilidades.",
saveFailed: "No se pudo guardar tu selección de habilidades.",
statusFailed: "No se puede comprobar el estado de las habilidades de orquestación.",
updateFailed: "No se pueden actualizar las habilidades de orquestación.",
uninstallFailed: "No se pueden desinstalar las habilidades de orquestación.",
saveSelectionFailed: "No se puede guardar la selección de habilidades de orquestación.",
actions: {
install: "Instalar",
installing: "Instalando...",
installed: "Instalado",
update: "Actualizar",
working: "Laboral...",
remove: "Eliminar",
uninstall: "Desinstalar",
save: "Guardar",
saving: "Guardando...",
cancel: "Cancelar",
},
operations: {
add: "Agregar habilidad",
update: "Actualizar habilidad",
delete: "Eliminar habilidad",
},
},
orchestration: {
title: "Orquestación",
unavailable: "Conéctese a este host para administrar la orquestación",
+45 -42
View File
@@ -1,4 +1,4 @@
import type { TranslationResources } from "./en";
import { en, type TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const fr: TranslationResources = {
@@ -1340,14 +1340,6 @@ export const fr: TranslationResources = {
statusFailed: "Impossible de vérifier l'état de l'installation deCLI.",
installFailed: "Impossible d'installer lePaseoCLI.",
},
skills: {
statusFailed: "Impossible de vérifier l'état des compétences d'orchestration.",
installFailed: "Impossible d'installer les compétences d'orchestration.",
updateFailed: "Impossible de mettre à jour les compétences d'orchestration.",
uninstallFailed: "Impossible de désinstaller les compétences d'orchestration.",
saveSelectionFailed:
"Impossible d'enregistrer la sélection des compétences d'orchestration.",
},
},
},
rootError: {
@@ -2134,49 +2126,16 @@ export const fr: TranslationResources = {
title: "Intégrations",
docs: {
cli: "DocumentsCLI",
skills: "Documents de compétences",
openCli: "Ouvrir la documentationCLI",
openSkills: "Documentation des compétences ouvertes",
},
commandLine: {
title: "Ligne de commande",
description: "Agents de contrôle et de script depuis votre terminal",
},
skills: {
title: "Compétences en orchestration",
description: "Apprenez à vos agents à orchestrer via leCLI",
updateAvailable: "Mise à jour disponible",
updateTitle: "Mettre à jour les compétencesPaseo?",
updateFallback: "Synchronisez les compétences regroupées sur votre machine.",
uninstallTitle: "Désinstaller les compétencesPaseo?",
uninstallMessage:
"Supprime toutes les compétences d'orchestrationPaseode ~/.agents, ~/.claude, ~/.codex.",
choose: "Choisir les compétences",
chooseAll: "Toutes les compétences",
chooseAllHint:
"Gardez installées toutes les compétences fournies, y compris celles ajoutées plus tard.",
chooseList: "Compétences fournies",
chooseEmpty: "Cette version ne fournit aucune compétence.",
removeTitle: "Supprimer les compétences décochées ?",
removeMessage:
"{{skills}} seront supprimées de ~/.agents, ~/.claude et ~/.codex. Tout ce que vous avez ajouté dans ces dossiers de compétences est également supprimé.",
saveFailed: "Impossible d'enregistrer votre sélection de compétences.",
},
actions: {
install: "Installer",
installing: "Installation...",
installed: "Installé",
update: "Mise à jour",
working: "Fonctionnement...",
remove: "Supprimer",
uninstall: "Désinstaller",
save: "Enregistrer",
saving: "Enregistrement...",
},
operations: {
add: "Ajouter une compétence",
update: "Mettre à jour la compétence",
delete: "Supprimer la compétence",
},
},
permissions: {
@@ -2246,6 +2205,50 @@ export const fr: TranslationResources = {
rowTitle: "Associer un appareil",
rowHint: "Scannez un codeQRou copiez un lien pour connecter votre téléphone à cet hôte",
},
skills: {
...en.settings.host.skills,
sectionTitle: "Compétences en orchestration",
title: "Compétences en orchestration",
description: "Apprenez à vos agents à orchestrer via leCLI",
updateAvailable: "Mise à jour disponible",
updateTitle: "Mettre à jour les compétencesPaseo?",
updateFallback: "Synchronisez les compétences regroupées sur votre machine.",
uninstallTitle: "Désinstaller les compétencesPaseo?",
uninstallMessage:
"Supprime toutes les compétences d'orchestrationPaseode ~/.agents, ~/.claude, ~/.codex.",
choose: "Choisir les compétences",
chooseAll: "Toutes les compétences",
chooseAllHint:
"Gardez installées toutes les compétences fournies, y compris celles ajoutées plus tard.",
chooseList: "Compétences fournies",
chooseEmpty: "Cette version ne fournit aucune compétence.",
removeTitle: "Supprimer les compétences décochées ?",
removeMessage:
"{{skills}} seront supprimées de ~/.agents, ~/.claude et ~/.codex. Tout ce que vous avez ajouté dans ces dossiers de compétences est également supprimé.",
saveFailed: "Impossible d'enregistrer votre sélection de compétences.",
statusFailed: "Impossible de vérifier l'état des compétences d'orchestration.",
updateFailed: "Impossible de mettre à jour les compétences d'orchestration.",
uninstallFailed: "Impossible de désinstaller les compétences d'orchestration.",
saveSelectionFailed:
"Impossible d'enregistrer la sélection des compétences d'orchestration.",
actions: {
install: "Installer",
installing: "Installation...",
installed: "Installé",
update: "Mise à jour",
working: "Fonctionnement...",
remove: "Supprimer",
uninstall: "Désinstaller",
save: "Enregistrer",
saving: "Enregistrement...",
cancel: "Annuler",
},
operations: {
add: "Ajouter une compétence",
update: "Mettre à jour la compétence",
delete: "Supprimer la compétence",
},
},
orchestration: {
title: "Orchestration",
unavailable: "Connectez-vous à cet hôte pour gérer l'orchestration",
+44 -41
View File
@@ -1,4 +1,4 @@
import type { TranslationResources } from "./en";
import { en, type TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const ja: TranslationResources = {
@@ -1311,13 +1311,6 @@ export const ja: TranslationResources = {
statusFailed: "CLIのインストール状態を確認できません。",
installFailed: "Paseo CLIをインストールできません。",
},
skills: {
statusFailed: "オーケストレーションスキルのステータスを確認できません。",
installFailed: "オーケストレーションスキルをインストールできません。",
updateFailed: "オーケストレーションスキルを更新できません。",
uninstallFailed: "オーケストレーションスキルをアンインストールできません。",
saveSelectionFailed: "オーケストレーションスキルの選択を保存できません。",
},
},
},
rootError: {
@@ -2099,49 +2092,16 @@ export const ja: TranslationResources = {
title: "連携",
docs: {
cli: "CLIドキュメント",
skills: "スキルドキュメント",
openCli: "CLIドキュメントを開く",
openSkills: "スキルドキュメントを開く",
},
commandLine: {
title: "コマンドライン",
description: "ターミナルからエージェントを制御し、スクリプトで操作",
},
skills: {
title: "オーケストレーションスキル",
description: "エージェントがCLI経由でオーケストレーションできるようにします。",
updateAvailable: "更新が利用可能",
updateTitle: "Paseoスキルを更新しますか?",
updateFallback: "バンドルされたスキルをマシンに同期します。",
uninstallTitle: "Paseoスキルをアンインストールしますか?",
uninstallMessage:
"~/.agents、~/.claude、~/.codexからすべてのPaseoオーケストレーションスキルを削除します。",
choose: "スキルを選択",
chooseAll: "すべてのスキル",
chooseAllHint:
"後から追加されるものも含め、バンドルされたスキルをすべてインストールしたままにします。",
chooseList: "バンドルされたスキル",
chooseEmpty: "このビルドにはスキルが含まれていません。",
removeTitle: "選択を外したスキルを削除しますか?",
removeMessage:
"{{skills}} を ~/.agents、~/.claude、~/.codex から削除します。それらのスキルフォルダー内に追加したものもすべて削除されます。",
saveFailed: "スキルの選択を保存できませんでした。",
},
actions: {
install: "インストール",
installing: "インストール中...",
installed: "インストール済み",
update: "更新",
working: "処理中...",
remove: "削除",
uninstall: "アンインストール",
save: "保存",
saving: "保存中...",
},
operations: {
add: "スキルを追加",
update: "スキルを更新",
delete: "スキルを削除",
},
},
permissions: {
@@ -2211,6 +2171,49 @@ export const ja: TranslationResources = {
rowTitle: "デバイスをペアリング",
rowHint: "QRコードをスキャンするかリンクをコピーしてスマートフォンをこのホストに接続",
},
skills: {
...en.settings.host.skills,
sectionTitle: "オーケストレーションスキル",
title: "オーケストレーションスキル",
description: "エージェントがCLI経由でオーケストレーションできるようにします。",
updateAvailable: "更新が利用可能",
updateTitle: "Paseoスキルを更新しますか?",
updateFallback: "バンドルされたスキルをマシンに同期します。",
uninstallTitle: "Paseoスキルをアンインストールしますか?",
uninstallMessage:
"~/.agents、~/.claude、~/.codexからすべてのPaseoオーケストレーションスキルを削除します。",
choose: "スキルを選択",
chooseAll: "すべてのスキル",
chooseAllHint:
"後から追加されるものも含め、バンドルされたスキルをすべてインストールしたままにします。",
chooseList: "バンドルされたスキル",
chooseEmpty: "このビルドにはスキルが含まれていません。",
removeTitle: "選択を外したスキルを削除しますか?",
removeMessage:
"{{skills}} を ~/.agents、~/.claude、~/.codex から削除します。それらのスキルフォルダー内に追加したものもすべて削除されます。",
saveFailed: "スキルの選択を保存できませんでした。",
statusFailed: "オーケストレーションスキルのステータスを確認できません。",
updateFailed: "オーケストレーションスキルを更新できません。",
uninstallFailed: "オーケストレーションスキルをアンインストールできません。",
saveSelectionFailed: "オーケストレーションスキルの選択を保存できません。",
actions: {
install: "インストール",
installing: "インストール中...",
installed: "インストール済み",
update: "更新",
working: "処理中...",
remove: "削除",
uninstall: "アンインストール",
save: "保存",
saving: "保存中...",
cancel: "キャンセル",
},
operations: {
add: "スキルを追加",
update: "スキルを更新",
delete: "スキルを削除",
},
},
orchestration: {
title: "オーケストレーション",
unavailable: "オーケストレーションを管理するにはこのホストに接続してください",
+43 -40
View File
@@ -1,4 +1,4 @@
import type { TranslationResources } from "./en";
import { en, type TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const ko: TranslationResources = {
@@ -1307,13 +1307,6 @@ export const ko: TranslationResources = {
statusFailed: "CLI 설치 상태를 확인할 수 없습니다.",
installFailed: "Paseo CLI를 설치할 수 없습니다.",
},
skills: {
statusFailed: "오케스트레이션 스킬 상태를 확인할 수 없습니다.",
installFailed: "오케스트레이션 스킬을 설치할 수 없습니다.",
updateFailed: "오케스트레이션 스킬을 업데이트할 수 없습니다.",
uninstallFailed: "오케스트레이션 스킬을 제거할 수 없습니다.",
saveSelectionFailed: "오케스트레이션 스킬 선택을 저장하지 못했습니다.",
},
},
},
rootError: {
@@ -2092,48 +2085,16 @@ export const ko: TranslationResources = {
title: "통합",
docs: {
cli: "CLI 문서",
skills: "스킬 문서",
openCli: "CLI 문서 열기",
openSkills: "스킬 문서 열기",
},
commandLine: {
title: "명령줄",
description: "터미널에서 에이전트를 제어하고 스크립팅합니다",
},
skills: {
title: "오케스트레이션 스킬",
description: "CLI를 통해 에이전트가 오케스트레이션하도록 가르칩니다",
updateAvailable: "업데이트 사용 가능",
updateTitle: "Paseo 스킬을 업데이트할까요?",
updateFallback: "번들된 스킬을 내 컴퓨터에 동기화합니다.",
uninstallTitle: "Paseo 스킬을 제거할까요?",
uninstallMessage:
"~/.agents, ~/.claude, ~/.codex에서 모든 Paseo 오케스트레이션 스킬을 제거합니다.",
choose: "스킬 선택",
chooseAll: "모든 스킬",
chooseAllHint: "나중에 추가되는 항목을 포함하여 모든 번들 스킬을 설치된 상태로 유지합니다.",
chooseList: "번들 스킬",
chooseEmpty: "이 빌드에는 번들 스킬이 없습니다.",
removeTitle: "선택 해제한 스킬을 제거할까요?",
removeMessage:
"{{skills}}는 ~/.agents, ~/.claude 및 ~/.codex에서 삭제됩니다. 해당 스킬 폴더 안에 추가한 모든 항목도 삭제됩니다.",
saveFailed: "선택한 스킬을 저장하지 못했습니다.",
},
actions: {
install: "설치",
installing: "설치 중...",
installed: "설치됨",
update: "업데이트",
working: "작업 중...",
remove: "제거",
uninstall: "제거",
save: "저장",
saving: "저장 중...",
},
operations: {
add: "스킬 추가",
update: "스킬 업데이트",
delete: "스킬 삭제",
},
},
permissions: {
@@ -2203,6 +2164,48 @@ export const ko: TranslationResources = {
rowTitle: "기기 페어링",
rowHint: "QR 코드를 스캔하거나 링크를 복사하여 휴대폰을 이 호스트에 연결하세요",
},
skills: {
...en.settings.host.skills,
sectionTitle: "오케스트레이션 스킬",
title: "오케스트레이션 스킬",
description: "CLI를 통해 에이전트가 오케스트레이션하도록 가르칩니다",
updateAvailable: "업데이트 사용 가능",
updateTitle: "Paseo 스킬을 업데이트할까요?",
updateFallback: "번들된 스킬을 내 컴퓨터에 동기화합니다.",
uninstallTitle: "Paseo 스킬을 제거할까요?",
uninstallMessage:
"~/.agents, ~/.claude, ~/.codex에서 모든 Paseo 오케스트레이션 스킬을 제거합니다.",
choose: "스킬 선택",
chooseAll: "모든 스킬",
chooseAllHint: "나중에 추가되는 항목을 포함하여 모든 번들 스킬을 설치된 상태로 유지합니다.",
chooseList: "번들 스킬",
chooseEmpty: "이 빌드에는 번들 스킬이 없습니다.",
removeTitle: "선택 해제한 스킬을 제거할까요?",
removeMessage:
"{{skills}}는 ~/.agents, ~/.claude 및 ~/.codex에서 삭제됩니다. 해당 스킬 폴더 안에 추가한 모든 항목도 삭제됩니다.",
saveFailed: "선택한 스킬을 저장하지 못했습니다.",
statusFailed: "오케스트레이션 스킬 상태를 확인할 수 없습니다.",
updateFailed: "오케스트레이션 스킬을 업데이트할 수 없습니다.",
uninstallFailed: "오케스트레이션 스킬을 제거할 수 없습니다.",
saveSelectionFailed: "오케스트레이션 스킬 선택을 저장하지 못했습니다.",
actions: {
install: "설치",
installing: "설치 중...",
installed: "설치됨",
update: "업데이트",
working: "작업 중...",
remove: "제거",
uninstall: "제거",
save: "저장",
saving: "저장 중...",
cancel: "취소",
},
operations: {
add: "스킬 추가",
update: "스킬 업데이트",
delete: "스킬 삭제",
},
},
orchestration: {
title: "오케스트레이션",
unavailable: "오케스트레이션을 관리하려면 이 호스트에 연결하세요",
+44 -41
View File
@@ -1,4 +1,4 @@
import type { TranslationResources } from "./en";
import { en, type TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const ptBR: TranslationResources = {
@@ -1324,13 +1324,6 @@ export const ptBR: TranslationResources = {
statusFailed: "Não foi possível verificar o status de instalação da CLI.",
installFailed: "Não foi possível instalar a CLI do Paseo.",
},
skills: {
statusFailed: "Não foi possível verificar o status das skills de orquestração.",
installFailed: "Não foi possível instalar as skills de orquestração.",
updateFailed: "Não foi possível atualizar as skills de orquestração.",
uninstallFailed: "Não foi possível desinstalar as skills de orquestração.",
saveSelectionFailed: "Não foi possível salvar a seleção de skills de orquestração.",
},
},
},
rootError: {
@@ -2113,49 +2106,16 @@ export const ptBR: TranslationResources = {
title: "Integrações",
docs: {
cli: "Docs da CLI",
skills: "Docs das skills",
openCli: "Abrir documentação da CLI",
openSkills: "Abrir documentação das skills",
},
commandLine: {
title: "Linha de comando",
description: "Controle agentes e execute scripts pelo terminal",
},
skills: {
title: "Skills de orquestração",
description: "Ensine seus agentes a orquestrar pela CLI",
updateAvailable: "Atualização disponível",
updateTitle: "Atualizar Paseo skills?",
updateFallback: "Sincronize as skills incluídas com sua máquina.",
uninstallTitle: "Desinstalar Paseo skills?",
uninstallMessage:
"Remove todas as skills de orquestração do Paseo de ~/.agents, ~/.claude e ~/.codex.",
choose: "Escolher skills",
chooseAll: "Todas as skills",
chooseAllHint:
"Mantenha instaladas todas as skills incluídas, inclusive as adicionadas depois.",
chooseList: "Skills incluídas",
chooseEmpty: "Esta versão não inclui skills.",
removeTitle: "Remover as skills desmarcadas?",
removeMessage:
"{{skills}} serão excluídas de ~/.agents, ~/.claude e ~/.codex. Tudo o que você adicionou dentro dessas pastas de skills também é excluído.",
saveFailed: "Não foi possível salvar sua seleção de skills.",
},
actions: {
install: "Instalar",
installing: "Instalando...",
installed: "Instalado",
update: "Atualizar",
working: "Trabalhando...",
remove: "Remover",
uninstall: "Desinstalar",
save: "Salvar",
saving: "Salvando...",
},
operations: {
add: "Adicionar skill",
update: "Atualizar skill",
delete: "Excluir skill",
},
},
permissions: {
@@ -2225,6 +2185,49 @@ export const ptBR: TranslationResources = {
rowTitle: "Parear um dispositivo",
rowHint: "Escaneie um QR code ou copie um link para conectar seu celular a este host",
},
skills: {
...en.settings.host.skills,
sectionTitle: "Skills de orquestração",
title: "Skills de orquestração",
description: "Ensine seus agentes a orquestrar pela CLI",
updateAvailable: "Atualização disponível",
updateTitle: "Atualizar Paseo skills?",
updateFallback: "Sincronize as skills incluídas com sua máquina.",
uninstallTitle: "Desinstalar Paseo skills?",
uninstallMessage:
"Remove todas as skills de orquestração do Paseo de ~/.agents, ~/.claude e ~/.codex.",
choose: "Escolher skills",
chooseAll: "Todas as skills",
chooseAllHint:
"Mantenha instaladas todas as skills incluídas, inclusive as adicionadas depois.",
chooseList: "Skills incluídas",
chooseEmpty: "Esta versão não inclui skills.",
removeTitle: "Remover as skills desmarcadas?",
removeMessage:
"{{skills}} serão excluídas de ~/.agents, ~/.claude e ~/.codex. Tudo o que você adicionou dentro dessas pastas de skills também é excluído.",
saveFailed: "Não foi possível salvar sua seleção de skills.",
statusFailed: "Não foi possível verificar o status das skills de orquestração.",
updateFailed: "Não foi possível atualizar as skills de orquestração.",
uninstallFailed: "Não foi possível desinstalar as skills de orquestração.",
saveSelectionFailed: "Não foi possível salvar a seleção de skills de orquestração.",
actions: {
install: "Instalar",
installing: "Instalando...",
installed: "Instalado",
update: "Atualizar",
working: "Trabalhando...",
remove: "Remover",
uninstall: "Desinstalar",
save: "Salvar",
saving: "Salvando...",
cancel: "Cancelar",
},
operations: {
add: "Adicionar skill",
update: "Atualizar skill",
delete: "Excluir skill",
},
},
orchestration: {
title: "Orquestração",
unavailable: "Conecte-se a este host para gerenciar a orquestração",
+42 -39
View File
@@ -1,4 +1,4 @@
import type { TranslationResources } from "./en";
import { en, type TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const ru: TranslationResources = {
@@ -1327,13 +1327,6 @@ export const ru: TranslationResources = {
statusFailed: "Невозможно проверить статус установки CLI.",
installFailed: "Невозможно установить PaseoCLI.",
},
skills: {
statusFailed: "Невозможно проверить статус навыков оркестровки.",
installFailed: "Невозможно установить навыки оркестровки.",
updateFailed: "Невозможно обновить навыки оркестровки.",
uninstallFailed: "Невозможно удалить навыки оркестровки.",
saveSelectionFailed: "Не удалось сохранить выбор навыков оркестровки.",
},
},
},
rootError: {
@@ -2120,47 +2113,16 @@ export const ru: TranslationResources = {
title: "Интеграции",
docs: {
cli: "Документация CLI",
skills: "Документы по навыкам",
openCli: "Открыть документацию CLI",
openSkills: "Открытая документация по навыкам",
},
commandLine: {
title: "Командная строка",
description: "Агенты управления и сценариев с вашего терминала",
},
skills: {
title: "Навыки оркестровки",
description: "Научите своих агентов организовывать работу через CLI",
updateAvailable: "Доступно обновление",
updateTitle: "Обновить навыки Paseo?",
updateFallback: "Синхронизируйте связанные навыки с вашим компьютером.",
uninstallTitle: "Удалить навыки Paseo?",
uninstallMessage: "Удаляет все навыки оркестровки Paseo из ~/.agents, ~/.claude, ~/.codex.",
choose: "Выбрать навыки",
chooseAll: "Все навыки",
chooseAllHint: "Держите установленными все входящие навыки, включая добавленные позже.",
chooseList: "Входящие навыки",
chooseEmpty: "В этой сборке нет навыков.",
removeTitle: "Удалить снятые навыки?",
removeMessage:
"{{skills}} будут удалены из ~/.agents, ~/.claude и ~/.codex. Всё, что вы добавили внутри этих папок навыков, тоже удаляется.",
saveFailed: "Не удалось сохранить выбор навыков.",
},
actions: {
install: "Установить",
installing: "Установка...",
installed: "Установлено",
update: "Обновлять",
working: "Работающий...",
remove: "Удалить",
uninstall: "Удалить",
save: "Сохранить",
saving: "Сохранение...",
},
operations: {
add: "Добавить навык",
update: "Обновить навык",
delete: "Удалить навык",
},
},
permissions: {
@@ -2231,6 +2193,47 @@ export const ru: TranslationResources = {
rowHint:
"Отсканируйте код QR или скопируйте ссылку, чтобы подключить свой телефон к этому хосту.",
},
skills: {
...en.settings.host.skills,
sectionTitle: "Навыки оркестровки",
title: "Навыки оркестровки",
description: "Научите своих агентов организовывать работу через CLI",
updateAvailable: "Доступно обновление",
updateTitle: "Обновить навыки Paseo?",
updateFallback: "Синхронизируйте связанные навыки с вашим компьютером.",
uninstallTitle: "Удалить навыки Paseo?",
uninstallMessage: "Удаляет все навыки оркестровки Paseo из ~/.agents, ~/.claude, ~/.codex.",
choose: "Выбрать навыки",
chooseAll: "Все навыки",
chooseAllHint: "Держите установленными все входящие навыки, включая добавленные позже.",
chooseList: "Входящие навыки",
chooseEmpty: "В этой сборке нет навыков.",
removeTitle: "Удалить снятые навыки?",
removeMessage:
"{{skills}} будут удалены из ~/.agents, ~/.claude и ~/.codex. Всё, что вы добавили внутри этих папок навыков, тоже удаляется.",
saveFailed: "Не удалось сохранить выбор навыков.",
statusFailed: "Невозможно проверить статус навыков оркестровки.",
updateFailed: "Невозможно обновить навыки оркестровки.",
uninstallFailed: "Невозможно удалить навыки оркестровки.",
saveSelectionFailed: "Не удалось сохранить выбор навыков оркестровки.",
actions: {
install: "Установить",
installing: "Установка...",
installed: "Установлено",
update: "Обновлять",
working: "Работающий...",
remove: "Удалить",
uninstall: "Удалить",
save: "Сохранить",
saving: "Сохранение...",
cancel: "Отмена",
},
operations: {
add: "Добавить навык",
update: "Обновить навык",
delete: "Удалить навык",
},
},
orchestration: {
title: "оркестровка",
unavailable: "Подключитесь к этому хосту, чтобы управлять оркестрацией.",
+42 -39
View File
@@ -1,4 +1,4 @@
import type { TranslationResources } from "./en";
import { en, type TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const zhCN: TranslationResources = {
@@ -1282,13 +1282,6 @@ export const zhCN: TranslationResources = {
statusFailed: "无法检查 CLI 安装状态。",
installFailed: "无法安装 Paseo CLI。",
},
skills: {
statusFailed: "无法检查编排 skills 状态。",
installFailed: "无法安装编排 skills。",
updateFailed: "无法更新编排 skills。",
uninstallFailed: "无法卸载编排 skills。",
saveSelectionFailed: "无法保存编排 skills 的选择。",
},
},
},
rootError: {
@@ -2057,47 +2050,16 @@ export const zhCN: TranslationResources = {
title: "集成",
docs: {
cli: "CLI 文档",
skills: "Skills 文档",
openCli: "打开 CLI 文档",
openSkills: "打开 skills 文档",
},
commandLine: {
title: "命令行",
description: "从终端控制 Agent 并运行脚本",
},
skills: {
title: "编排 skills",
description: "教会 Agent 通过 CLI 编排任务",
updateAvailable: "有更新可用",
updateTitle: "更新 Paseo skills?",
updateFallback: "将内置 skills 同步到你的机器。",
uninstallTitle: "卸载 Paseo skills?",
uninstallMessage: "会从 ~/.agents、~/.claude、~/.codex 移除所有 Paseo 编排 skills。",
choose: "选择 skills",
chooseAll: "全部 skills",
chooseAllHint: "保持安装所有内置 skills,包括以后新增的。",
chooseList: "内置 skills",
chooseEmpty: "此版本未内置 skills。",
removeTitle: "移除取消勾选的 skills?",
removeMessage:
"将从 ~/.agents、~/.claude、~/.codex 中删除 {{skills}}。你在这些 skill 文件夹里添加的内容也会一并删除。",
saveFailed: "无法保存你的 skills 选择。",
},
actions: {
install: "安装",
installing: "正在安装...",
installed: "已安装",
update: "更新",
working: "处理中...",
remove: "移除",
uninstall: "卸载",
save: "保存",
saving: "正在保存...",
},
operations: {
add: "添加 skill",
update: "更新 skill",
delete: "删除 skill",
},
},
permissions: {
@@ -2167,6 +2129,47 @@ export const zhCN: TranslationResources = {
rowTitle: "配对设备",
rowHint: "扫描二维码或复制链接,将手机连接到这个 Host",
},
skills: {
...en.settings.host.skills,
sectionTitle: "编排 skills",
title: "编排 skills",
description: "教会 Agent 通过 CLI 编排任务",
updateAvailable: "有更新可用",
updateTitle: "更新 Paseo skills?",
updateFallback: "将内置 skills 同步到你的机器。",
uninstallTitle: "卸载 Paseo skills?",
uninstallMessage: "会从 ~/.agents、~/.claude、~/.codex 移除所有 Paseo 编排 skills。",
choose: "选择 skills",
chooseAll: "全部 skills",
chooseAllHint: "保持安装所有内置 skills,包括以后新增的。",
chooseList: "内置 skills",
chooseEmpty: "此版本未内置 skills。",
removeTitle: "移除取消勾选的 skills?",
removeMessage:
"将从 ~/.agents、~/.claude、~/.codex 中删除 {{skills}}。你在这些 skill 文件夹里添加的内容也会一并删除。",
saveFailed: "无法保存你的 skills 选择。",
statusFailed: "无法检查编排 skills 状态。",
updateFailed: "无法更新编排 skills。",
uninstallFailed: "无法卸载编排 skills。",
saveSelectionFailed: "无法保存编排 skills 的选择。",
actions: {
install: "安装",
installing: "正在安装...",
installed: "已安装",
update: "更新",
working: "处理中...",
remove: "移除",
uninstall: "卸载",
save: "保存",
saving: "正在保存...",
cancel: "取消",
},
operations: {
add: "添加 skill",
update: "更新 skill",
delete: "删除 skill",
},
},
orchestration: {
title: "编排",
unavailable: "连接到这个 Host 以管理编排",
@@ -22,6 +22,7 @@ import {
DEFAULT_TERMINAL_PROFILES,
} from "@getpaseo/protocol/terminal-profiles";
import { AgentProfilesSection } from "@/agent-profiles";
import { AgentSkillsSection } from "@/agent-skills";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { SettingsTextAreaCard } from "@/components/settings-textarea";
import { Alert as InlineAlert } from "@/components/ui/alert";
@@ -275,6 +276,7 @@ export function HostAgentsPage({ serverId }: { serverId: string }) {
<Text style={styles.emptyText}>{t("settings.host.agents.unavailable")}</Text>
</View>
)}
<AgentSkillsSection serverId={serverId} />
<AgentProfilesSection serverId={serverId} />
</View>
);