From e73c332e4f0828f69026752f9447ce767eed2f6f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 4 Apr 2026 13:20:46 +0700 Subject: [PATCH] feat(desktop): add Integrations settings to install CLI and orchestration skills Add a new Integrations section in desktop settings that lets users install the Paseo CLI and orchestration skills directly from the app. CLI install symlinks the bundled shim to ~/.local/bin and updates the user's shell rc file if needed. Skills install copies SKILL.md files to ~/.agents/skills/, symlinks into ~/.claude/skills/ for Claude Code, and copies to ~/.codex/skills/ for Codex. Both use a single status check code path for install verification and on-load detection. Also adds a /docs/skills page documenting all six orchestration skills, extracts shared settings styles (section headers, rows) used across the daemon, integrations, and shortcuts sections, and reorders the settings sidebar to group integrations and daemon together. --- .../components/desktop-updates-section.tsx | 193 +++--------- .../components/integrations-section.tsx | 189 ++++++++++++ .../app/src/desktop/daemon/desktop-daemon.ts | 60 ++-- packages/app/src/screens/settings-screen.tsx | 60 ++-- .../settings/keyboard-shortcuts-section.tsx | 9 +- packages/app/src/styles/settings.ts | 45 +++ packages/desktop/electron-builder.yml | 2 + packages/desktop/src/daemon/daemon-manager.ts | 49 +--- .../src/integrations/integrations-manager.ts | 275 ++++++++++++++++++ packages/website/src/routeTree.gen.ts | 21 ++ packages/website/src/routes/docs.tsx | 1 + packages/website/src/routes/docs/skills.tsx | 162 +++++++++++ packages/website/vite.config.ts | 1 + 13 files changed, 811 insertions(+), 256 deletions(-) create mode 100644 packages/app/src/desktop/components/integrations-section.tsx create mode 100644 packages/desktop/src/integrations/integrations-manager.ts create mode 100644 packages/website/src/routes/docs/skills.tsx diff --git a/packages/app/src/desktop/components/desktop-updates-section.tsx b/packages/app/src/desktop/components/desktop-updates-section.tsx index c0678edcd..df0756eff 100644 --- a/packages/app/src/desktop/components/desktop-updates-section.tsx +++ b/packages/app/src/desktop/components/desktop-updates-section.tsx @@ -10,7 +10,6 @@ import { Play, Pause, RotateCw, - Terminal, Copy, FileText, Smartphone, @@ -24,7 +23,6 @@ import { openExternalUrl } from "@/utils/open-external-url"; import { isVersionMismatch } from "@/desktop/updates/desktop-updates"; import { getCliDaemonStatus, - getCliSymlinkInstructions, getDesktopDaemonLogs, getDesktopDaemonPairing, getDesktopDaemonStatus, @@ -32,7 +30,6 @@ import { shouldUseDesktopDaemon, startDesktopDaemon, stopDesktopDaemon, - type CliSymlinkInstructions, type DesktopDaemonLogs, type DesktopDaemonStatus, type DesktopPairingOffer, @@ -52,17 +49,12 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD const [statusError, setStatusError] = useState(null); const [isRestartingDaemon, setIsRestartingDaemon] = useState(false); const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false); - const [isLoadingCliSymlinkInstructions, setIsLoadingCliSymlinkInstructions] = useState(false); const [statusMessage, setStatusMessage] = useState(null); - const [cliStatusMessage, setCliStatusMessage] = useState(null); const [daemonLogs, setDaemonLogs] = useState(null); const [isLogsModalOpen, setIsLogsModalOpen] = useState(false); const [isPairingModalOpen, setIsPairingModalOpen] = useState(false); - const [isCliSymlinkModalOpen, setIsCliSymlinkModalOpen] = useState(false); const [isLoadingPairing, setIsLoadingPairing] = useState(false); const [pairingOffer, setPairingOffer] = useState(null); - const [cliSymlinkInstructions, setCliSymlinkInstructions] = - useState(null); const [pairingStatusMessage, setPairingStatusMessage] = useState(null); const [cliStatusOutput, setCliStatusOutput] = useState(null); const [isCliStatusModalOpen, setIsCliStatusModalOpen] = useState(false); @@ -223,40 +215,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD updateSettings, ]); - const handleOpenCliSymlinkInstructions = useCallback(() => { - if (!showSection || isLoadingCliSymlinkInstructions) { - return; - } - setIsLoadingCliSymlinkInstructions(true); - setCliStatusMessage(null); - void getCliSymlinkInstructions() - .then((instructions) => { - setCliSymlinkInstructions(instructions); - setIsCliSymlinkModalOpen(true); - }) - .catch((error) => { - const message = error instanceof Error ? error.message : String(error); - setCliStatusMessage(`Unable to load CLI symlink instructions: ${message}`); - }) - .finally(() => { - setIsLoadingCliSymlinkInstructions(false); - }); - }, [isLoadingCliSymlinkInstructions, showSection]); - - const handleCopyCliSymlinkCommands = useCallback(() => { - if (!cliSymlinkInstructions?.commands) { - return; - } - void Clipboard.setStringAsync(cliSymlinkInstructions.commands) - .then(() => { - Alert.alert("Copied", "CLI symlink commands copied."); - }) - .catch((error) => { - console.error("[Settings] Failed to copy CLI symlink commands", error); - Alert.alert("Error", "Unable to copy CLI symlink commands."); - }); - }, [cliSymlinkInstructions?.commands]); - const handleCopyLogPath = useCallback(() => { const logPath = daemonLogs?.logPath; if (!logPath) { @@ -353,14 +311,14 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD return ( - - Built-in daemon + + Built-in daemon - - - Status - Only the built-in desktop daemon is shown here. + + + Status + Only the built-in desktop daemon is shown here. {daemonStatusStateText} @@ -380,10 +338,10 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD {showLifecycleControls ? ( <> - - - Daemon management - + + + Daemon management + {isDaemonManagementPaused ? "Paused. The built-in daemon stays stopped until you start it again." : "Enabled. Paseo can manage the built-in daemon from the desktop app."} @@ -411,10 +369,10 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD : "Pause"} - - - {daemonActionLabel} - {daemonActionMessage} + + + {daemonActionLabel} + {daemonActionMessage} {statusMessage ? {statusMessage} : null} - - - - Log file - {daemonLogs?.logPath ?? "Log path unavailable."} + + + Log file + {daemonLogs?.logPath ?? "Log path unavailable."} {daemonLogs?.logPath ? ( @@ -476,10 +418,10 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD - - - Pair device - Connect your phone to this computer. + + + Pair device + Connect your phone to this computer. - - - - - setIsPairingModalOpen(false)} @@ -567,7 +482,7 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD snapPoints={["70%", "92%"]} > - {daemonLogs?.logPath ?? "Log path unavailable."} + {daemonLogs?.logPath ?? "Log path unavailable."} {daemonLogs?.contents.length ? daemonLogs.contents : "(log file is empty)"} @@ -652,7 +567,7 @@ function PairingOfferDialogContent(input: { return ( - Loading pairing offer… + Loading pairing offer… ); } @@ -660,7 +575,7 @@ function PairingOfferDialogContent(input: { if (statusMessage) { return ( - {statusMessage} + {statusMessage} ); } @@ -668,21 +583,21 @@ function PairingOfferDialogContent(input: { if (!pairingOffer?.url) { return ( - Pairing offer unavailable. + Pairing offer unavailable. ); } return ( - + Scan this QR code in Paseo, or copy the pairing link below. {qrDataUrl ? ( ) : qrError ? ( - QR unavailable: {qrError} + QR unavailable: {qrError} ) : ( )} @@ -703,37 +618,6 @@ function PairingOfferDialogContent(input: { } const styles = StyleSheet.create((theme) => ({ - sectionHeader: { - alignItems: "center", - flexDirection: "row", - justifyContent: "space-between", - marginBottom: theme.spacing[3], - marginLeft: theme.spacing[1], - }, - sectionLink: { - alignItems: "center", - flexDirection: "row", - gap: theme.spacing[1], - }, - sectionLinkText: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.xs, - }, - row: { - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - paddingVertical: theme.spacing[4], - paddingHorizontal: theme.spacing[4], - }, - rowBorder: { - borderTopWidth: 1, - borderTopColor: theme.colors.border, - }, - rowContent: { - flex: 1, - marginRight: theme.spacing[3], - }, actionGroup: { flexDirection: "row", gap: theme.spacing[2], @@ -744,10 +628,6 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "flex-end", gap: 2, }, - rowTitle: { - color: theme.colors.foreground, - fontSize: theme.fontSize.base, - }, valueText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, @@ -757,11 +637,6 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, }, - hintText: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.xs, - marginTop: 2, - }, statusText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, diff --git a/packages/app/src/desktop/components/integrations-section.tsx b/packages/app/src/desktop/components/integrations-section.tsx new file mode 100644 index 000000000..cfc1b8510 --- /dev/null +++ b/packages/app/src/desktop/components/integrations-section.tsx @@ -0,0 +1,189 @@ +import { useCallback, useState } from "react"; +import { Text, View } from "react-native"; +import { useFocusEffect } from "@react-navigation/native"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { ArrowUpRight, Terminal, Blocks, Check } from "lucide-react-native"; +import { settingsStyles } from "@/styles/settings"; +import { Button } from "@/components/ui/button"; +import { openExternalUrl } from "@/utils/open-external-url"; +import { + shouldUseDesktopDaemon, + getCliInstallStatus, + installCli, + getSkillsInstallStatus, + installSkills, + type InstallStatus, +} from "@/desktop/daemon/desktop-daemon"; + +const CLI_DOCS_URL = "https://paseo.sh/docs/cli"; +const SKILLS_DOCS_URL = "https://paseo.sh/docs/skills"; + +export function IntegrationsSection() { + const { theme } = useUnistyles(); + const showSection = shouldUseDesktopDaemon(); + + const [cliStatus, setCliStatus] = useState(null); + const [skillsStatus, setSkillsStatus] = useState(null); + const [isInstallingCli, setIsInstallingCli] = useState(false); + const [isInstallingSkills, setIsInstallingSkills] = useState(false); + + const loadStatus = useCallback(() => { + if (!showSection) return; + void getCliInstallStatus() + .then(setCliStatus) + .catch((error) => { + console.error("[Integrations] Failed to load CLI status", error); + }); + void getSkillsInstallStatus() + .then(setSkillsStatus) + .catch((error) => { + console.error("[Integrations] Failed to load skills status", error); + }); + }, [showSection]); + + useFocusEffect( + useCallback(() => { + if (!showSection) return undefined; + loadStatus(); + return undefined; + }, [loadStatus, showSection]), + ); + + const handleInstallCli = useCallback(() => { + if (isInstallingCli) return; + setIsInstallingCli(true); + void installCli() + .then(setCliStatus) + .catch((error) => { + console.error("[Integrations] Failed to install CLI", error); + }) + .finally(() => { + setIsInstallingCli(false); + }); + }, [isInstallingCli]); + + const handleInstallSkills = useCallback(() => { + if (isInstallingSkills) return; + setIsInstallingSkills(true); + void installSkills() + .then(setSkillsStatus) + .catch((error) => { + console.error("[Integrations] Failed to install skills", error); + }) + .finally(() => { + setIsInstallingSkills(false); + }); + }, [isInstallingSkills]); + + if (!showSection) { + return null; + } + + return ( + + + Integrations + + + + + + + + + + + Command line + + + Control and script agents from your terminal. + + + {cliStatus?.installed ? ( + + + Installed + + ) : ( + + )} + + + + + + Orchestration skills + + + Teach your agents to orchestrate through the CLI. + + + {skillsStatus?.installed ? ( + + + Installed + + ) : ( + + )} + + + + ); +} + +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.sm, + }, +})); diff --git a/packages/app/src/desktop/daemon/desktop-daemon.ts b/packages/app/src/desktop/daemon/desktop-daemon.ts index 5fcc61a59..9204dc7b3 100644 --- a/packages/app/src/desktop/daemon/desktop-daemon.ts +++ b/packages/app/src/desktop/daemon/desktop-daemon.ts @@ -26,12 +26,6 @@ export type DesktopPairingOffer = { qr: string | null; }; -export type CliSymlinkInstructions = { - title: string; - detail: string; - commands: string; -}; - export type LocalTransportTarget = { transportType: "socket" | "pipe"; transportPath: string; @@ -115,17 +109,6 @@ function parseDesktopPairingOffer(raw: unknown): DesktopPairingOffer { }; } -function parseCliSymlinkInstructionsInternal(raw: unknown): CliSymlinkInstructions | null { - if (!isRecord(raw)) { - return null; - } - return { - title: toStringOrNull(raw.title) ?? "", - detail: toStringOrNull(raw.detail) ?? "", - commands: toStringOrNull(raw.commands) ?? "", - }; -} - export function shouldUseDesktopDaemon(): boolean { return isElectronRuntime(); } @@ -154,18 +137,6 @@ export async function getDesktopDaemonPairing(): Promise { return parseDesktopPairingOffer(await invokeDesktopCommand("desktop_daemon_pairing")); } -export function parseCliSymlinkInstructions(raw: unknown): CliSymlinkInstructions { - const instructions = parseCliSymlinkInstructionsInternal(raw); - if (!instructions) { - throw new Error("Unexpected CLI symlink instructions response."); - } - return instructions; -} - -export async function getCliSymlinkInstructions(): Promise { - return parseCliSymlinkInstructions(await invokeDesktopCommand("cli_symlink_instructions")); -} - export async function getCliDaemonStatus(): Promise { const raw = await invokeDesktopCommand("cli_daemon_status"); if (typeof raw !== "string") { @@ -224,3 +195,34 @@ export async function sendLocalTransportMessage(input: { export async function closeLocalTransportSession(sessionId: string): Promise { await invokeDesktopCommand("close_local_daemon_transport", { sessionId }); } + +// --------------------------------------------------------------------------- +// Integrations +// --------------------------------------------------------------------------- + +export interface InstallStatus { + installed: boolean; +} + +function parseInstallStatus(raw: unknown): InstallStatus { + if (!isRecord(raw)) { + throw new Error("Unexpected install status response."); + } + return { installed: raw.installed === true }; +} + +export async function getCliInstallStatus(): Promise { + return parseInstallStatus(await invokeDesktopCommand("get_cli_install_status")); +} + +export async function installCli(): Promise { + return parseInstallStatus(await invokeDesktopCommand("install_cli")); +} + +export async function getSkillsInstallStatus(): Promise { + return parseInstallStatus(await invokeDesktopCommand("get_skills_install_status")); +} + +export async function installSkills(): Promise { + return parseInstallStatus(await invokeDesktopCommand("install_skills")); +} diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 5dc28a0b6..39e3611fd 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -20,6 +20,7 @@ import { Stethoscope, Info, Shield, + Puzzle, } from "lucide-react-native"; import { useAppSettings, type AppSettings } from "@/hooks/use-settings"; import type { HostProfile, HostConnection } from "@/types/host-connection"; @@ -50,6 +51,7 @@ import { } from "@/components/ui/dropdown-menu"; import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet"; import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section"; +import { IntegrationsSection } from "@/desktop/components/integrations-section"; import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section"; import { isElectronRuntime } from "@/desktop/host"; import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater"; @@ -69,6 +71,7 @@ type SettingsSectionId = | "hosts" | "appearance" | "shortcuts" + | "integrations" | "diagnostics" | "about" | "permissions" @@ -85,12 +88,13 @@ function getSettingsSections(context: { isDesktopApp: boolean }): SettingsSectio { id: "hosts", label: "Hosts", icon: Server }, { id: "appearance", label: "Appearance", icon: Palette }, { id: "shortcuts", label: "Shortcuts", icon: Keyboard }, + { id: "permissions", label: "Permissions", icon: Shield }, ]; if (context.isDesktopApp) { sections.push( + { id: "integrations", label: "Integrations", icon: Puzzle }, { id: "daemon", label: "Daemon", icon: Settings }, - { id: "permissions", label: "Permissions", icon: Shield }, ); } @@ -523,6 +527,8 @@ function SettingsSectionContent({ return ; case "about": return ; + case "integrations": + return isDesktopApp ? : null; case "permissions": return isDesktopApp ? : null; case "daemon": @@ -573,31 +579,35 @@ function SettingsDesktopLayout({ sections, sectionContentProps }: SettingsLayout {sections.map((section) => { const isSelected = section.id === selectedSectionId; const IconComponent = section.icon; + const showSeparator = + section.id === "integrations" || section.id === "diagnostics"; return ( - setSelectedSectionId(section.id)} - accessibilityRole="button" - accessibilityState={{ selected: isSelected }} - > - - + {showSeparator ? : null} + setSelectedSectionId(section.id)} + accessibilityRole="button" + accessibilityState={{ selected: isSelected }} > - {section.label} - - + + + {section.label} + + + ); })} @@ -1896,6 +1906,12 @@ const desktopStyles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, fontWeight: theme.fontWeight.normal, }, + sidebarSeparator: { + borderBottomWidth: 1, + borderBottomColor: theme.colors.border, + marginVertical: theme.spacing[2], + marginHorizontal: theme.spacing[3], + }, contentPane: { flex: 1, }, diff --git a/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx b/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx index 7f64c7e55..09b5b07ac 100644 --- a/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx +++ b/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx @@ -192,8 +192,8 @@ export function KeyboardShortcutsSection() { return ( - - Shortcuts + + Shortcuts {hasOverrides && (