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.
This commit is contained in:
Mohamed Boudra
2026-04-04 13:21:32 +07:00
parent 1594e41602
commit e73c332e4f
13 changed files with 811 additions and 256 deletions
@@ -10,7 +10,6 @@ import {
Play, Play,
Pause, Pause,
RotateCw, RotateCw,
Terminal,
Copy, Copy,
FileText, FileText,
Smartphone, Smartphone,
@@ -24,7 +23,6 @@ import { openExternalUrl } from "@/utils/open-external-url";
import { isVersionMismatch } from "@/desktop/updates/desktop-updates"; import { isVersionMismatch } from "@/desktop/updates/desktop-updates";
import { import {
getCliDaemonStatus, getCliDaemonStatus,
getCliSymlinkInstructions,
getDesktopDaemonLogs, getDesktopDaemonLogs,
getDesktopDaemonPairing, getDesktopDaemonPairing,
getDesktopDaemonStatus, getDesktopDaemonStatus,
@@ -32,7 +30,6 @@ import {
shouldUseDesktopDaemon, shouldUseDesktopDaemon,
startDesktopDaemon, startDesktopDaemon,
stopDesktopDaemon, stopDesktopDaemon,
type CliSymlinkInstructions,
type DesktopDaemonLogs, type DesktopDaemonLogs,
type DesktopDaemonStatus, type DesktopDaemonStatus,
type DesktopPairingOffer, type DesktopPairingOffer,
@@ -52,17 +49,12 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
const [statusError, setStatusError] = useState<string | null>(null); const [statusError, setStatusError] = useState<string | null>(null);
const [isRestartingDaemon, setIsRestartingDaemon] = useState(false); const [isRestartingDaemon, setIsRestartingDaemon] = useState(false);
const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false); const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false);
const [isLoadingCliSymlinkInstructions, setIsLoadingCliSymlinkInstructions] = useState(false);
const [statusMessage, setStatusMessage] = useState<string | null>(null); const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [cliStatusMessage, setCliStatusMessage] = useState<string | null>(null);
const [daemonLogs, setDaemonLogs] = useState<DesktopDaemonLogs | null>(null); const [daemonLogs, setDaemonLogs] = useState<DesktopDaemonLogs | null>(null);
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false); const [isLogsModalOpen, setIsLogsModalOpen] = useState(false);
const [isPairingModalOpen, setIsPairingModalOpen] = useState(false); const [isPairingModalOpen, setIsPairingModalOpen] = useState(false);
const [isCliSymlinkModalOpen, setIsCliSymlinkModalOpen] = useState(false);
const [isLoadingPairing, setIsLoadingPairing] = useState(false); const [isLoadingPairing, setIsLoadingPairing] = useState(false);
const [pairingOffer, setPairingOffer] = useState<DesktopPairingOffer | null>(null); const [pairingOffer, setPairingOffer] = useState<DesktopPairingOffer | null>(null);
const [cliSymlinkInstructions, setCliSymlinkInstructions] =
useState<CliSymlinkInstructions | null>(null);
const [pairingStatusMessage, setPairingStatusMessage] = useState<string | null>(null); const [pairingStatusMessage, setPairingStatusMessage] = useState<string | null>(null);
const [cliStatusOutput, setCliStatusOutput] = useState<string | null>(null); const [cliStatusOutput, setCliStatusOutput] = useState<string | null>(null);
const [isCliStatusModalOpen, setIsCliStatusModalOpen] = useState(false); const [isCliStatusModalOpen, setIsCliStatusModalOpen] = useState(false);
@@ -223,40 +215,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
updateSettings, 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 handleCopyLogPath = useCallback(() => {
const logPath = daemonLogs?.logPath; const logPath = daemonLogs?.logPath;
if (!logPath) { if (!logPath) {
@@ -353,14 +311,14 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
return ( return (
<View style={settingsStyles.section}> <View style={settingsStyles.section}>
<View style={styles.sectionHeader}> <View style={settingsStyles.sectionHeader}>
<Text style={settingsStyles.sectionTitle}>Built-in daemon</Text> <Text style={settingsStyles.sectionHeaderTitle}>Built-in daemon</Text>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
leftIcon={<ArrowUpRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />} leftIcon={<ArrowUpRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />}
textStyle={styles.sectionLinkText} textStyle={settingsStyles.sectionHeaderLinkText}
style={styles.sectionLink} style={settingsStyles.sectionHeaderLink}
onPress={() => void openExternalUrl(ADVANCED_DAEMON_SETTINGS_URL)} onPress={() => void openExternalUrl(ADVANCED_DAEMON_SETTINGS_URL)}
accessibilityLabel="Open advanced daemon settings" accessibilityLabel="Open advanced daemon settings"
> >
@@ -368,10 +326,10 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
</Button> </Button>
</View> </View>
<View style={settingsStyles.card}> <View style={settingsStyles.card}>
<View style={styles.row}> <View style={settingsStyles.row}>
<View style={styles.rowContent}> <View style={settingsStyles.rowContent}>
<Text style={styles.rowTitle}>Status</Text> <Text style={settingsStyles.rowTitle}>Status</Text>
<Text style={styles.hintText}>Only the built-in desktop daemon is shown here.</Text> <Text style={settingsStyles.rowHint}>Only the built-in desktop daemon is shown here.</Text>
</View> </View>
<View style={styles.statusValueGroup}> <View style={styles.statusValueGroup}>
<Text style={styles.valueText}>{daemonStatusStateText}</Text> <Text style={styles.valueText}>{daemonStatusStateText}</Text>
@@ -380,10 +338,10 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
</View> </View>
{showLifecycleControls ? ( {showLifecycleControls ? (
<> <>
<View style={[styles.row, styles.rowBorder]}> <View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={styles.rowContent}> <View style={settingsStyles.rowContent}>
<Text style={styles.rowTitle}>Daemon management</Text> <Text style={settingsStyles.rowTitle}>Daemon management</Text>
<Text style={styles.hintText}> <Text style={settingsStyles.rowHint}>
{isDaemonManagementPaused {isDaemonManagementPaused
? "Paused. The built-in daemon stays stopped until you start it again." ? "Paused. The built-in daemon stays stopped until you start it again."
: "Enabled. Paseo can manage the built-in daemon from the desktop app."} : "Enabled. Paseo can manage the built-in daemon from the desktop app."}
@@ -411,10 +369,10 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
: "Pause"} : "Pause"}
</Button> </Button>
</View> </View>
<View style={[styles.row, styles.rowBorder]}> <View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={styles.rowContent}> <View style={settingsStyles.rowContent}>
<Text style={styles.rowTitle}>{daemonActionLabel}</Text> <Text style={settingsStyles.rowTitle}>{daemonActionLabel}</Text>
<Text style={styles.hintText}>{daemonActionMessage}</Text> <Text style={settingsStyles.rowHint}>{daemonActionMessage}</Text>
{statusMessage ? <Text style={styles.statusText}>{statusMessage}</Text> : null} {statusMessage ? <Text style={styles.statusText}>{statusMessage}</Text> : null}
</View> </View>
<Button <Button
@@ -433,26 +391,10 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
</View> </View>
</> </>
) : null} ) : null}
<View style={[styles.row, styles.rowBorder]}> <View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={styles.rowContent}> <View style={settingsStyles.rowContent}>
<Text style={styles.rowTitle}>Command line (CLI)</Text> <Text style={settingsStyles.rowTitle}>Log file</Text>
<Text style={styles.hintText}>Shows the command to add `paseo` to your terminal.</Text> <Text style={settingsStyles.rowHint}>{daemonLogs?.logPath ?? "Log path unavailable."}</Text>
{cliStatusMessage ? <Text style={styles.statusText}>{cliStatusMessage}</Text> : null}
</View>
<Button
variant="outline"
size="sm"
leftIcon={<Terminal size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={handleOpenCliSymlinkInstructions}
disabled={isLoadingCliSymlinkInstructions}
>
{isLoadingCliSymlinkInstructions ? "Loading..." : "Show instructions"}
</Button>
</View>
<View style={[styles.row, styles.rowBorder]}>
<View style={styles.rowContent}>
<Text style={styles.rowTitle}>Log file</Text>
<Text style={styles.hintText}>{daemonLogs?.logPath ?? "Log path unavailable."}</Text>
</View> </View>
<View style={styles.actionGroup}> <View style={styles.actionGroup}>
{daemonLogs?.logPath ? ( {daemonLogs?.logPath ? (
@@ -476,10 +418,10 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
</Button> </Button>
</View> </View>
</View> </View>
<View style={[styles.row, styles.rowBorder]}> <View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={styles.rowContent}> <View style={settingsStyles.rowContent}>
<Text style={styles.rowTitle}>Pair device</Text> <Text style={settingsStyles.rowTitle}>Pair device</Text>
<Text style={styles.hintText}>Connect your phone to this computer.</Text> <Text style={settingsStyles.rowHint}>Connect your phone to this computer.</Text>
</View> </View>
<Button <Button
variant="outline" variant="outline"
@@ -490,10 +432,10 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
Pair device Pair device
</Button> </Button>
</View> </View>
<View style={[styles.row, styles.rowBorder]}> <View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={styles.rowContent}> <View style={settingsStyles.rowContent}>
<Text style={styles.rowTitle}>Full status</Text> <Text style={settingsStyles.rowTitle}>Full status</Text>
<Text style={styles.hintText}> <Text style={settingsStyles.rowHint}>
Runs `paseo daemon status` and shows the output. Runs `paseo daemon status` and shows the output.
</Text> </Text>
</View> </View>
@@ -518,33 +460,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
</View> </View>
) : null} ) : null}
<AdaptiveModalSheet
visible={isCliSymlinkModalOpen}
onClose={() => setIsCliSymlinkModalOpen(false)}
title="Add paseo to your shell"
testID="managed-daemon-cli-symlink-dialog"
>
<View style={styles.modalBody}>
<Text style={styles.hintText}>
Paseo does not add the command for you. Run the command below in your terminal.
</Text>
{cliSymlinkInstructions?.detail ? (
<Text style={styles.hintText}>{cliSymlinkInstructions.detail}</Text>
) : null}
<Text style={styles.codeBlock} selectable>
{cliSymlinkInstructions?.commands ?? ""}
</Text>
<View style={styles.modalActions}>
<Button variant="outline" size="sm" onPress={() => setIsCliSymlinkModalOpen(false)}>
Close
</Button>
<Button size="sm" onPress={handleCopyCliSymlinkCommands}>
Copy commands
</Button>
</View>
</View>
</AdaptiveModalSheet>
<AdaptiveModalSheet <AdaptiveModalSheet
visible={isPairingModalOpen} visible={isPairingModalOpen}
onClose={() => setIsPairingModalOpen(false)} onClose={() => setIsPairingModalOpen(false)}
@@ -567,7 +482,7 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
snapPoints={["70%", "92%"]} snapPoints={["70%", "92%"]}
> >
<View style={styles.modalBody}> <View style={styles.modalBody}>
<Text style={styles.hintText}>{daemonLogs?.logPath ?? "Log path unavailable."}</Text> <Text style={settingsStyles.rowHint}>{daemonLogs?.logPath ?? "Log path unavailable."}</Text>
<Text style={styles.logOutput} selectable> <Text style={styles.logOutput} selectable>
{daemonLogs?.contents.length ? daemonLogs.contents : "(log file is empty)"} {daemonLogs?.contents.length ? daemonLogs.contents : "(log file is empty)"}
</Text> </Text>
@@ -652,7 +567,7 @@ function PairingOfferDialogContent(input: {
return ( return (
<View style={styles.pairingState}> <View style={styles.pairingState}>
<ActivityIndicator size="small" /> <ActivityIndicator size="small" />
<Text style={styles.hintText}>Loading pairing offer…</Text> <Text style={settingsStyles.rowHint}>Loading pairing offer…</Text>
</View> </View>
); );
} }
@@ -660,7 +575,7 @@ function PairingOfferDialogContent(input: {
if (statusMessage) { if (statusMessage) {
return ( return (
<View style={styles.modalBody}> <View style={styles.modalBody}>
<Text style={styles.hintText}>{statusMessage}</Text> <Text style={settingsStyles.rowHint}>{statusMessage}</Text>
</View> </View>
); );
} }
@@ -668,21 +583,21 @@ function PairingOfferDialogContent(input: {
if (!pairingOffer?.url) { if (!pairingOffer?.url) {
return ( return (
<View style={styles.modalBody}> <View style={styles.modalBody}>
<Text style={styles.hintText}>Pairing offer unavailable.</Text> <Text style={settingsStyles.rowHint}>Pairing offer unavailable.</Text>
</View> </View>
); );
} }
return ( return (
<View style={styles.modalBody}> <View style={styles.modalBody}>
<Text style={styles.hintText}> <Text style={settingsStyles.rowHint}>
Scan this QR code in Paseo, or copy the pairing link below. Scan this QR code in Paseo, or copy the pairing link below.
</Text> </Text>
<View style={styles.qrCard}> <View style={styles.qrCard}>
{qrDataUrl ? ( {qrDataUrl ? (
<Image source={{ uri: qrDataUrl }} style={styles.qrImage} resizeMode="contain" /> <Image source={{ uri: qrDataUrl }} style={styles.qrImage} resizeMode="contain" />
) : qrError ? ( ) : qrError ? (
<Text style={styles.hintText}>QR unavailable: {qrError}</Text> <Text style={settingsStyles.rowHint}>QR unavailable: {qrError}</Text>
) : ( ) : (
<ActivityIndicator size="small" /> <ActivityIndicator size="small" />
)} )}
@@ -703,37 +618,6 @@ function PairingOfferDialogContent(input: {
} }
const styles = StyleSheet.create((theme) => ({ 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: { actionGroup: {
flexDirection: "row", flexDirection: "row",
gap: theme.spacing[2], gap: theme.spacing[2],
@@ -744,10 +628,6 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "flex-end", alignItems: "flex-end",
gap: 2, gap: 2,
}, },
rowTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
},
valueText: { valueText: {
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
@@ -757,11 +637,6 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
}, },
hintText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
marginTop: 2,
},
statusText: { statusText: {
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
@@ -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<InstallStatus | null>(null);
const [skillsStatus, setSkillsStatus] = useState<InstallStatus | null>(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 (
<View style={settingsStyles.section}>
<View style={settingsStyles.sectionHeader}>
<Text style={settingsStyles.sectionHeaderTitle}>Integrations</Text>
<View style={styles.headerLinks}>
<Button
variant="ghost"
size="sm"
leftIcon={<ArrowUpRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />}
textStyle={settingsStyles.sectionHeaderLinkText}
style={settingsStyles.sectionHeaderLink}
onPress={() => void openExternalUrl(CLI_DOCS_URL)}
accessibilityLabel="Open CLI documentation"
>
CLI docs
</Button>
<Button
variant="ghost"
size="sm"
leftIcon={<ArrowUpRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />}
textStyle={settingsStyles.sectionHeaderLinkText}
style={settingsStyles.sectionHeaderLink}
onPress={() => void openExternalUrl(SKILLS_DOCS_URL)}
accessibilityLabel="Open skills documentation"
>
Skills docs
</Button>
</View>
</View>
<View style={settingsStyles.card}>
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<View style={styles.rowTitleRow}>
<Terminal size={theme.iconSize.md} color={theme.colors.foreground} />
<Text style={settingsStyles.rowTitle}>Command line</Text>
</View>
<Text style={settingsStyles.rowHint}>
Control and script agents from your terminal.
</Text>
</View>
{cliStatus?.installed ? (
<View style={styles.installedLabel}>
<Check size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.mutedText}>Installed</Text>
</View>
) : (
<Button
variant="outline"
size="sm"
onPress={handleInstallCli}
disabled={isInstallingCli}
>
{isInstallingCli ? "Installing..." : "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}>Orchestration skills</Text>
</View>
<Text style={settingsStyles.rowHint}>
Teach your agents to orchestrate through the CLI.
</Text>
</View>
{skillsStatus?.installed ? (
<View style={styles.installedLabel}>
<Check size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.mutedText}>Installed</Text>
</View>
) : (
<Button
variant="outline"
size="sm"
onPress={handleInstallSkills}
disabled={isInstallingSkills}
>
{isInstallingSkills ? "Installing..." : "Install"}
</Button>
)}
</View>
</View>
</View>
);
}
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,
},
}));
@@ -26,12 +26,6 @@ export type DesktopPairingOffer = {
qr: string | null; qr: string | null;
}; };
export type CliSymlinkInstructions = {
title: string;
detail: string;
commands: string;
};
export type LocalTransportTarget = { export type LocalTransportTarget = {
transportType: "socket" | "pipe"; transportType: "socket" | "pipe";
transportPath: string; 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 { export function shouldUseDesktopDaemon(): boolean {
return isElectronRuntime(); return isElectronRuntime();
} }
@@ -154,18 +137,6 @@ export async function getDesktopDaemonPairing(): Promise<DesktopPairingOffer> {
return parseDesktopPairingOffer(await invokeDesktopCommand("desktop_daemon_pairing")); 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<CliSymlinkInstructions> {
return parseCliSymlinkInstructions(await invokeDesktopCommand("cli_symlink_instructions"));
}
export async function getCliDaemonStatus(): Promise<string> { export async function getCliDaemonStatus(): Promise<string> {
const raw = await invokeDesktopCommand<unknown>("cli_daemon_status"); const raw = await invokeDesktopCommand<unknown>("cli_daemon_status");
if (typeof raw !== "string") { if (typeof raw !== "string") {
@@ -224,3 +195,34 @@ export async function sendLocalTransportMessage(input: {
export async function closeLocalTransportSession(sessionId: string): Promise<void> { export async function closeLocalTransportSession(sessionId: string): Promise<void> {
await invokeDesktopCommand("close_local_daemon_transport", { sessionId }); 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<InstallStatus> {
return parseInstallStatus(await invokeDesktopCommand("get_cli_install_status"));
}
export async function installCli(): Promise<InstallStatus> {
return parseInstallStatus(await invokeDesktopCommand("install_cli"));
}
export async function getSkillsInstallStatus(): Promise<InstallStatus> {
return parseInstallStatus(await invokeDesktopCommand("get_skills_install_status"));
}
export async function installSkills(): Promise<InstallStatus> {
return parseInstallStatus(await invokeDesktopCommand("install_skills"));
}
+38 -22
View File
@@ -20,6 +20,7 @@ import {
Stethoscope, Stethoscope,
Info, Info,
Shield, Shield,
Puzzle,
} from "lucide-react-native"; } from "lucide-react-native";
import { useAppSettings, type AppSettings } from "@/hooks/use-settings"; import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
import type { HostProfile, HostConnection } from "@/types/host-connection"; import type { HostProfile, HostConnection } from "@/types/host-connection";
@@ -50,6 +51,7 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet"; import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section"; import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section";
import { IntegrationsSection } from "@/desktop/components/integrations-section";
import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section"; import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section";
import { isElectronRuntime } from "@/desktop/host"; import { isElectronRuntime } from "@/desktop/host";
import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater"; import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater";
@@ -69,6 +71,7 @@ type SettingsSectionId =
| "hosts" | "hosts"
| "appearance" | "appearance"
| "shortcuts" | "shortcuts"
| "integrations"
| "diagnostics" | "diagnostics"
| "about" | "about"
| "permissions" | "permissions"
@@ -85,12 +88,13 @@ function getSettingsSections(context: { isDesktopApp: boolean }): SettingsSectio
{ id: "hosts", label: "Hosts", icon: Server }, { id: "hosts", label: "Hosts", icon: Server },
{ id: "appearance", label: "Appearance", icon: Palette }, { id: "appearance", label: "Appearance", icon: Palette },
{ id: "shortcuts", label: "Shortcuts", icon: Keyboard }, { id: "shortcuts", label: "Shortcuts", icon: Keyboard },
{ id: "permissions", label: "Permissions", icon: Shield },
]; ];
if (context.isDesktopApp) { if (context.isDesktopApp) {
sections.push( sections.push(
{ id: "integrations", label: "Integrations", icon: Puzzle },
{ id: "daemon", label: "Daemon", icon: Settings }, { id: "daemon", label: "Daemon", icon: Settings },
{ id: "permissions", label: "Permissions", icon: Shield },
); );
} }
@@ -523,6 +527,8 @@ function SettingsSectionContent({
return <DiagnosticsSection {...diagnosticsProps} />; return <DiagnosticsSection {...diagnosticsProps} />;
case "about": case "about":
return <AboutSection {...aboutProps} />; return <AboutSection {...aboutProps} />;
case "integrations":
return isDesktopApp ? <IntegrationsSection /> : null;
case "permissions": case "permissions":
return isDesktopApp ? <DesktopPermissionsSection /> : null; return isDesktopApp ? <DesktopPermissionsSection /> : null;
case "daemon": case "daemon":
@@ -573,31 +579,35 @@ function SettingsDesktopLayout({ sections, sectionContentProps }: SettingsLayout
{sections.map((section) => { {sections.map((section) => {
const isSelected = section.id === selectedSectionId; const isSelected = section.id === selectedSectionId;
const IconComponent = section.icon; const IconComponent = section.icon;
const showSeparator =
section.id === "integrations" || section.id === "diagnostics";
return ( return (
<Pressable <View key={section.id}>
key={section.id} {showSeparator ? <View style={desktopStyles.sidebarSeparator} /> : null}
style={[ <Pressable
desktopStyles.sidebarItem,
isSelected && { backgroundColor: theme.colors.surface2 },
]}
onPress={() => setSelectedSectionId(section.id)}
accessibilityRole="button"
accessibilityState={{ selected: isSelected }}
>
<IconComponent
size={theme.iconSize.md}
color={isSelected ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text
style={[ style={[
desktopStyles.sidebarLabel, desktopStyles.sidebarItem,
isSelected && { color: theme.colors.foreground }, isSelected && { backgroundColor: theme.colors.surface2 },
]} ]}
numberOfLines={1} onPress={() => setSelectedSectionId(section.id)}
accessibilityRole="button"
accessibilityState={{ selected: isSelected }}
> >
{section.label} <IconComponent
</Text> size={theme.iconSize.md}
</Pressable> color={isSelected ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text
style={[
desktopStyles.sidebarLabel,
isSelected && { color: theme.colors.foreground },
]}
numberOfLines={1}
>
{section.label}
</Text>
</Pressable>
</View>
); );
})} })}
</View> </View>
@@ -1896,6 +1906,12 @@ const desktopStyles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
}, },
sidebarSeparator: {
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
marginVertical: theme.spacing[2],
marginHorizontal: theme.spacing[3],
},
contentPane: { contentPane: {
flex: 1, flex: 1,
}, },
@@ -192,8 +192,8 @@ export function KeyboardShortcutsSection() {
return ( return (
<View style={settingsStyles.section}> <View style={settingsStyles.section}>
<View style={styles.sectionHeader}> <View style={settingsStyles.sectionHeader}>
<Text style={settingsStyles.sectionTitle}>Shortcuts</Text> <Text style={settingsStyles.sectionHeaderTitle}>Shortcuts</Text>
{hasOverrides && ( {hasOverrides && (
<Button variant="ghost" size="sm" onPress={() => void resetAll()}> <Button variant="ghost" size="sm" onPress={() => void resetAll()}>
Reset all Reset all
@@ -246,11 +246,6 @@ export function KeyboardShortcutsSection() {
} }
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
sectionHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
},
subsectionTitle: { subsectionTitle: {
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
+45
View File
@@ -4,6 +4,13 @@ export const settingsStyles = StyleSheet.create((theme) => ({
section: { section: {
marginBottom: theme.spacing[6], marginBottom: theme.spacing[6],
}, },
sectionHeader: {
alignItems: "center",
flexDirection: "row",
justifyContent: "space-between",
marginBottom: theme.spacing[3],
marginLeft: theme.spacing[1],
},
sectionTitle: { sectionTitle: {
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
@@ -11,6 +18,20 @@ export const settingsStyles = StyleSheet.create((theme) => ({
marginBottom: theme.spacing[3], marginBottom: theme.spacing[3],
marginLeft: theme.spacing[1], marginLeft: theme.spacing[1],
}, },
sectionHeaderTitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
},
sectionHeaderLink: {
alignItems: "center",
flexDirection: "row",
gap: theme.spacing[1],
},
sectionHeaderLinkText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
card: { card: {
backgroundColor: theme.colors.surface1, backgroundColor: theme.colors.surface1,
borderRadius: theme.borderRadius.lg, borderRadius: theme.borderRadius.lg,
@@ -18,4 +39,28 @@ export const settingsStyles = StyleSheet.create((theme) => ({
borderColor: theme.colors.border, borderColor: theme.colors.border,
overflow: "hidden", overflow: "hidden",
}, },
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],
},
rowTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
},
rowHint: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
marginTop: theme.spacing[1],
},
})); }));
+2
View File
@@ -10,6 +10,8 @@ files:
extraResources: extraResources:
- from: ../app/dist - from: ../app/dist
to: app-dist to: app-dist
- from: ../../skills
to: skills
publish: publish:
provider: github provider: github
owner: getpaseo owner: getpaseo
+10 -39
View File
@@ -12,6 +12,12 @@ import {
writeAttachmentBase64, writeAttachmentBase64,
} from "../features/attachments.js"; } from "../features/attachments.js";
import { checkForAppUpdate, downloadAndInstallUpdate } from "../features/auto-updater.js"; import { checkForAppUpdate, downloadAndInstallUpdate } from "../features/auto-updater.js";
import {
installCli,
getCliInstallStatus,
installSkills,
getSkillsInstallStatus,
} from "../integrations/integrations-manager.js";
import { import {
openLocalTransportSession, openLocalTransportSession,
sendLocalTransportMessage, sendLocalTransportMessage,
@@ -57,12 +63,6 @@ type DesktopPairingOffer = {
qr: string | null; qr: string | null;
}; };
type CliSymlinkInstructions = {
title: string;
detail: string;
commands: string;
};
type DesktopCommandHandler = (args?: Record<string, unknown>) => Promise<unknown> | unknown; type DesktopCommandHandler = (args?: Record<string, unknown>) => Promise<unknown> | unknown;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -422,38 +422,6 @@ function resolveCurrentUpdateVersion(): string {
return resolveDesktopAppVersion(); return resolveDesktopAppVersion();
} }
function getCliSymlinkInstructions(): CliSymlinkInstructions {
const electronExePath = app.getPath("exe");
const cliShimFilename = process.platform === "win32" ? "paseo.cmd" : "paseo";
if (process.platform === "darwin") {
const appBundle = electronExePath.replace(/\/Contents\/MacOS\/.+$/, "");
const cliPath = path.join(appBundle, "Contents", "Resources", "bin", cliShimFilename);
return {
title: "Add paseo to your shell",
detail: "Create a symlink to the bundled Paseo CLI shim.",
commands: `sudo ln -sf "${cliPath}" /usr/local/bin/paseo`,
};
}
if (process.platform === "win32") {
const cliPath = path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename);
return {
title: "Add paseo to your PATH",
detail: "Add the Paseo installation directory to your system PATH so paseo.cmd is available.",
commands: `setx PATH "%PATH%;${path.dirname(cliPath)}"`,
};
}
// Linux
const cliPath = path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename);
return {
title: "Add paseo to your shell",
detail: "Create a symlink to the bundled Paseo CLI shim.",
commands: `sudo ln -sf "${cliPath}" /usr/local/bin/paseo`,
};
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// IPC registration // IPC registration
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -466,7 +434,6 @@ export function createDaemonCommandHandlers(): Record<string, DesktopCommandHand
restart_desktop_daemon: () => restartDaemon(), restart_desktop_daemon: () => restartDaemon(),
desktop_daemon_logs: () => getDaemonLogs(), desktop_daemon_logs: () => getDaemonLogs(),
desktop_daemon_pairing: () => getDaemonPairing(), desktop_daemon_pairing: () => getDaemonPairing(),
cli_symlink_instructions: () => getCliSymlinkInstructions(),
cli_daemon_status: () => getCliDaemonStatus(), cli_daemon_status: () => getCliDaemonStatus(),
write_attachment_base64: (args) => writeAttachmentBase64(args ?? {}), write_attachment_base64: (args) => writeAttachmentBase64(args ?? {}),
copy_attachment_file: (args) => copyAttachmentFileToManagedStorage(args ?? {}), copy_attachment_file: (args) => copyAttachmentFileToManagedStorage(args ?? {}),
@@ -500,6 +467,10 @@ export function createDaemonCommandHandlers(): Record<string, DesktopCommandHand
}); });
}, },
get_local_daemon_version: () => getLocalDaemonVersion(), get_local_daemon_version: () => getLocalDaemonVersion(),
install_cli: () => installCli(),
get_cli_install_status: () => getCliInstallStatus(),
install_skills: () => installSkills(),
get_skills_install_status: () => getSkillsInstallStatus(),
}; };
} }
@@ -0,0 +1,275 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import os from "node:os";
import { app } from "electron";
import log from "electron-log/main";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface InstallStatus {
installed: boolean;
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const SKILL_NAMES = [
"paseo",
"paseo-loop",
"paseo-handoff",
"paseo-orchestrator",
"paseo-chat",
"paseo-committee",
];
// ---------------------------------------------------------------------------
// Filesystem helpers
// ---------------------------------------------------------------------------
async function pathOrSymlinkExists(p: string): Promise<boolean> {
try {
await fs.lstat(p);
return true;
} catch {
return false;
}
}
// ---------------------------------------------------------------------------
// Path helpers
// ---------------------------------------------------------------------------
function getLocalBinDir(): string {
return path.join(os.homedir(), ".local", "bin");
}
function getCliTargetPath(): string {
const filename = process.platform === "win32" ? "paseo.cmd" : "paseo";
return path.join(getLocalBinDir(), filename);
}
function getBundledCliShimPath(): string {
const cliShimFilename = process.platform === "win32" ? "paseo.cmd" : "paseo";
if (process.platform === "darwin") {
const electronExePath = app.getPath("exe");
const appBundle = electronExePath.replace(/\/Contents\/MacOS\/.+$/, "");
return path.join(appBundle, "Contents", "Resources", "bin", cliShimFilename);
}
if (process.platform === "win32") {
const electronExePath = app.getPath("exe");
return path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename);
}
// Linux
const electronExePath = app.getPath("exe");
return path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename);
}
function getBundledSkillsDir(): string {
if (app.isPackaged) {
return path.join(process.resourcesPath, "skills");
}
return path.join(__dirname, "..", "..", "..", "..", "skills");
}
function getAgentsSkillsDir(): string {
return path.join(os.homedir(), ".agents", "skills");
}
function getClaudeSkillsDir(): string {
return path.join(os.homedir(), ".claude", "skills");
}
function getCodexSkillsDir(): string {
return path.join(os.homedir(), ".codex", "skills");
}
// ---------------------------------------------------------------------------
// Shell PATH helpers
// ---------------------------------------------------------------------------
interface ShellRcInfo {
shell: string;
rcFile: string;
pathCheckPattern: RegExp;
exportLine: string;
}
function detectShellRcInfo(): ShellRcInfo | null {
if (process.platform === "win32") return null;
const shell = process.env.SHELL;
if (!shell) return null;
const shellName = path.basename(shell);
if (shellName === "zsh") {
return {
shell: "zsh",
rcFile: path.join(os.homedir(), ".zshrc"),
pathCheckPattern: /\.local\/bin/,
exportLine: 'export PATH="$HOME/.local/bin:$PATH"',
};
}
if (shellName === "bash") {
const rcFile =
process.platform === "darwin"
? path.join(os.homedir(), ".bash_profile")
: path.join(os.homedir(), ".bashrc");
return {
shell: "bash",
rcFile,
pathCheckPattern: /\.local\/bin/,
exportLine: 'export PATH="$HOME/.local/bin:$PATH"',
};
}
if (shellName === "fish") {
return {
shell: "fish",
rcFile: path.join(os.homedir(), ".config", "fish", "config.fish"),
pathCheckPattern: /\.local\/bin/,
exportLine: "fish_add_path $HOME/.local/bin",
};
}
return null;
}
function pathAlreadyContainsLocalBin(): boolean {
const pathEnv = process.env.PATH ?? "";
const localBin = path.join(os.homedir(), ".local", "bin");
return pathEnv.split(path.delimiter).some((p) => p === localBin || p === "~/.local/bin");
}
async function ensurePathInShellRc(): Promise<{ shellUpdated: boolean }> {
if (pathAlreadyContainsLocalBin()) {
return { shellUpdated: false };
}
const info = detectShellRcInfo();
if (!info) {
return { shellUpdated: false };
}
try {
const exists = await pathOrSymlinkExists(info.rcFile);
if (exists) {
const content = await fs.readFile(info.rcFile, "utf-8");
if (info.pathCheckPattern.test(content)) {
return { shellUpdated: false };
}
}
await fs.mkdir(path.dirname(info.rcFile), { recursive: true });
await fs.appendFile(info.rcFile, `\n# Added by Paseo\n${info.exportLine}\n`);
return { shellUpdated: true };
} catch (err) {
log.warn("[integrations] Failed to update shell rc file", { rcFile: info.rcFile, err });
return { shellUpdated: false };
}
}
// ---------------------------------------------------------------------------
// CLI Installation
// ---------------------------------------------------------------------------
export async function installCli(): Promise<InstallStatus> {
const targetPath = getCliTargetPath();
const shimPath = getBundledCliShimPath();
const binDir = getLocalBinDir();
await fs.mkdir(binDir, { recursive: true });
if (process.platform === "win32") {
if (await pathOrSymlinkExists(targetPath)) {
await fs.unlink(targetPath);
}
await fs.copyFile(shimPath, targetPath);
} else {
if (await pathOrSymlinkExists(targetPath)) {
await fs.unlink(targetPath);
}
await fs.symlink(shimPath, targetPath);
}
const { shellUpdated } = await ensurePathInShellRc();
if (shellUpdated) {
log.info("[integrations] Updated shell rc with ~/.local/bin PATH");
}
return getCliInstallStatus();
}
export async function getCliInstallStatus(): Promise<InstallStatus> {
const targetPath = getCliTargetPath();
return { installed: await pathOrSymlinkExists(targetPath) };
}
// ---------------------------------------------------------------------------
// Skills Installation
// ---------------------------------------------------------------------------
async function copySkillFile(sourceFile: string, destDir: string, skillName: string): Promise<void> {
const destSkillDir = path.join(destDir, skillName);
await fs.mkdir(destSkillDir, { recursive: true });
await fs.copyFile(sourceFile, path.join(destSkillDir, "SKILL.md"));
}
async function symlinkSkillDir(skillName: string, targetDir: string, linkParentDir: string): Promise<void> {
await fs.mkdir(linkParentDir, { recursive: true });
const target = path.join(targetDir, skillName);
const linkPath = path.join(linkParentDir, skillName);
if (await pathOrSymlinkExists(linkPath)) {
await fs.rm(linkPath, { recursive: true, force: true });
}
if (process.platform === "win32") {
try {
await fs.symlink(target, linkPath, "junction");
} catch {
await copySkillFile(path.join(target, "SKILL.md"), linkParentDir, skillName);
}
} else {
await fs.symlink(target, linkPath);
}
}
export async function installSkills(): Promise<InstallStatus> {
const sourceDir = getBundledSkillsDir();
const agentsDir = getAgentsSkillsDir();
const claudeDir = getClaudeSkillsDir();
const codexDir = getCodexSkillsDir();
log.info("[integrations] installSkills", { sourceDir, agentsDir, claudeDir, codexDir });
for (const skillName of SKILL_NAMES) {
const sourceFile = path.join(sourceDir, skillName, "SKILL.md");
await copySkillFile(sourceFile, agentsDir, skillName);
await symlinkSkillDir(skillName, agentsDir, claudeDir);
await copySkillFile(sourceFile, codexDir, skillName);
}
return getSkillsInstallStatus();
}
export async function getSkillsInstallStatus(): Promise<InstallStatus> {
const claudeDir = getClaudeSkillsDir();
for (const skillName of SKILL_NAMES) {
const skillFile = path.join(claudeDir, skillName, "SKILL.md");
try {
await fs.access(skillFile);
} catch {
return { installed: false };
}
}
return { installed: true };
}
+21
View File
@@ -25,6 +25,7 @@ import { Route as DocsVoiceRouteImport } from './routes/docs/voice'
import { Route as DocsUpdatesRouteImport } from './routes/docs/updates' import { Route as DocsUpdatesRouteImport } from './routes/docs/updates'
import { Route as DocsSecurityRouteImport } from './routes/docs/security' import { Route as DocsSecurityRouteImport } from './routes/docs/security'
import { Route as DocsConfigurationRouteImport } from './routes/docs/configuration' import { Route as DocsConfigurationRouteImport } from './routes/docs/configuration'
import { Route as DocsSkillsRouteImport } from './routes/docs/skills'
import { Route as DocsCliRouteImport } from './routes/docs/cli' import { Route as DocsCliRouteImport } from './routes/docs/cli'
import { Route as DocsBestPracticesRouteImport } from './routes/docs/best-practices' import { Route as DocsBestPracticesRouteImport } from './routes/docs/best-practices'
import { Route as BlogSplatRouteImport } from './routes/blog/$' import { Route as BlogSplatRouteImport } from './routes/blog/$'
@@ -109,6 +110,11 @@ const DocsConfigurationRoute = DocsConfigurationRouteImport.update({
path: '/configuration', path: '/configuration',
getParentRoute: () => DocsRoute, getParentRoute: () => DocsRoute,
} as any) } as any)
const DocsSkillsRoute = DocsSkillsRouteImport.update({
id: '/skills',
path: '/skills',
getParentRoute: () => DocsRoute,
} as any)
const DocsCliRoute = DocsCliRouteImport.update({ const DocsCliRoute = DocsCliRouteImport.update({
id: '/cli', id: '/cli',
path: '/cli', path: '/cli',
@@ -140,6 +146,7 @@ export interface FileRoutesByFullPath {
'/docs/cli': typeof DocsCliRoute '/docs/cli': typeof DocsCliRoute
'/docs/configuration': typeof DocsConfigurationRoute '/docs/configuration': typeof DocsConfigurationRoute
'/docs/security': typeof DocsSecurityRoute '/docs/security': typeof DocsSecurityRoute
'/docs/skills': typeof DocsSkillsRoute
'/docs/updates': typeof DocsUpdatesRoute '/docs/updates': typeof DocsUpdatesRoute
'/docs/voice': typeof DocsVoiceRoute '/docs/voice': typeof DocsVoiceRoute
'/docs/worktrees': typeof DocsWorktreesRoute '/docs/worktrees': typeof DocsWorktreesRoute
@@ -159,6 +166,7 @@ export interface FileRoutesByTo {
'/docs/cli': typeof DocsCliRoute '/docs/cli': typeof DocsCliRoute
'/docs/configuration': typeof DocsConfigurationRoute '/docs/configuration': typeof DocsConfigurationRoute
'/docs/security': typeof DocsSecurityRoute '/docs/security': typeof DocsSecurityRoute
'/docs/skills': typeof DocsSkillsRoute
'/docs/updates': typeof DocsUpdatesRoute '/docs/updates': typeof DocsUpdatesRoute
'/docs/voice': typeof DocsVoiceRoute '/docs/voice': typeof DocsVoiceRoute
'/docs/worktrees': typeof DocsWorktreesRoute '/docs/worktrees': typeof DocsWorktreesRoute
@@ -181,6 +189,7 @@ export interface FileRoutesById {
'/docs/cli': typeof DocsCliRoute '/docs/cli': typeof DocsCliRoute
'/docs/configuration': typeof DocsConfigurationRoute '/docs/configuration': typeof DocsConfigurationRoute
'/docs/security': typeof DocsSecurityRoute '/docs/security': typeof DocsSecurityRoute
'/docs/skills': typeof DocsSkillsRoute
'/docs/updates': typeof DocsUpdatesRoute '/docs/updates': typeof DocsUpdatesRoute
'/docs/voice': typeof DocsVoiceRoute '/docs/voice': typeof DocsVoiceRoute
'/docs/worktrees': typeof DocsWorktreesRoute '/docs/worktrees': typeof DocsWorktreesRoute
@@ -204,6 +213,7 @@ export interface FileRouteTypes {
| '/docs/cli' | '/docs/cli'
| '/docs/configuration' | '/docs/configuration'
| '/docs/security' | '/docs/security'
| '/docs/skills'
| '/docs/updates' | '/docs/updates'
| '/docs/voice' | '/docs/voice'
| '/docs/worktrees' | '/docs/worktrees'
@@ -223,6 +233,7 @@ export interface FileRouteTypes {
| '/docs/cli' | '/docs/cli'
| '/docs/configuration' | '/docs/configuration'
| '/docs/security' | '/docs/security'
| '/docs/skills'
| '/docs/updates' | '/docs/updates'
| '/docs/voice' | '/docs/voice'
| '/docs/worktrees' | '/docs/worktrees'
@@ -244,6 +255,7 @@ export interface FileRouteTypes {
| '/docs/cli' | '/docs/cli'
| '/docs/configuration' | '/docs/configuration'
| '/docs/security' | '/docs/security'
| '/docs/skills'
| '/docs/updates' | '/docs/updates'
| '/docs/voice' | '/docs/voice'
| '/docs/worktrees' | '/docs/worktrees'
@@ -377,6 +389,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DocsConfigurationRouteImport preLoaderRoute: typeof DocsConfigurationRouteImport
parentRoute: typeof DocsRoute parentRoute: typeof DocsRoute
} }
'/docs/skills': {
id: '/docs/skills'
path: '/skills'
fullPath: '/docs/skills'
preLoaderRoute: typeof DocsSkillsRouteImport
parentRoute: typeof DocsRoute
}
'/docs/cli': { '/docs/cli': {
id: '/docs/cli' id: '/docs/cli'
path: '/cli' path: '/cli'
@@ -418,6 +437,7 @@ interface DocsRouteChildren {
DocsCliRoute: typeof DocsCliRoute DocsCliRoute: typeof DocsCliRoute
DocsConfigurationRoute: typeof DocsConfigurationRoute DocsConfigurationRoute: typeof DocsConfigurationRoute
DocsSecurityRoute: typeof DocsSecurityRoute DocsSecurityRoute: typeof DocsSecurityRoute
DocsSkillsRoute: typeof DocsSkillsRoute
DocsUpdatesRoute: typeof DocsUpdatesRoute DocsUpdatesRoute: typeof DocsUpdatesRoute
DocsVoiceRoute: typeof DocsVoiceRoute DocsVoiceRoute: typeof DocsVoiceRoute
DocsWorktreesRoute: typeof DocsWorktreesRoute DocsWorktreesRoute: typeof DocsWorktreesRoute
@@ -429,6 +449,7 @@ const DocsRouteChildren: DocsRouteChildren = {
DocsCliRoute: DocsCliRoute, DocsCliRoute: DocsCliRoute,
DocsConfigurationRoute: DocsConfigurationRoute, DocsConfigurationRoute: DocsConfigurationRoute,
DocsSecurityRoute: DocsSecurityRoute, DocsSecurityRoute: DocsSecurityRoute,
DocsSkillsRoute: DocsSkillsRoute,
DocsUpdatesRoute: DocsUpdatesRoute, DocsUpdatesRoute: DocsUpdatesRoute,
DocsVoiceRoute: DocsVoiceRoute, DocsVoiceRoute: DocsVoiceRoute,
DocsWorktreesRoute: DocsWorktreesRoute, DocsWorktreesRoute: DocsWorktreesRoute,
+1
View File
@@ -11,6 +11,7 @@ const navigation = [
{ name: "Voice", href: "/docs/voice" }, { name: "Voice", href: "/docs/voice" },
{ name: "Git worktrees", href: "/docs/worktrees" }, { name: "Git worktrees", href: "/docs/worktrees" },
{ name: "CLI", href: "/docs/cli" }, { name: "CLI", href: "/docs/cli" },
{ name: "Skills", href: "/docs/skills" },
{ name: "Configuration", href: "/docs/configuration" }, { name: "Configuration", href: "/docs/configuration" },
{ name: "Security", href: "/docs/security" }, { name: "Security", href: "/docs/security" },
{ name: "Best practices", href: "/docs/best-practices" }, { name: "Best practices", href: "/docs/best-practices" },
+162
View File
@@ -0,0 +1,162 @@
import { createFileRoute } from "@tanstack/react-router";
import { pageMeta } from "~/meta";
export const Route = createFileRoute("/docs/skills")({
head: () => ({
meta: pageMeta(
"Orchestration Skills - Paseo Docs",
"Paseo orchestration skills: teach coding agents to spawn, coordinate, and manage other agents using slash commands.",
),
}),
component: Skills,
});
function Code({ children }: { children: React.ReactNode }) {
return (
<div className="bg-card border border-border rounded-lg p-4 font-mono text-sm overflow-x-auto">
{children}
</div>
);
}
function Skills() {
return (
<div className="space-y-10">
<div>
<h1 className="text-3xl font-medium font-title mb-4">Orchestration Skills</h1>
<p className="text-white/60 leading-relaxed">
Paseo ships orchestration skills that teach coding agents (Claude Code, Codex) how to use
the Paseo CLI to spawn, coordinate, and manage other agents. Skills are slash commands your
agent can invoke — they provide the prompts, context, and workflows so agents know how to
orchestrate without you writing boilerplate. Install them from the desktop app's
Integrations settings or via the CLI.
</p>
</div>
{/* Installation */}
<section className="space-y-4">
<h2 className="text-xl font-medium">Installation</h2>
<p className="text-white/60 leading-relaxed">Two ways to install:</p>
<ul className="text-white/60 space-y-2 list-disc list-inside">
<li>
<strong>Desktop app:</strong> Settings → Integrations → Install
</li>
<li>
<strong>Manual:</strong> <code className="font-mono">npx skills add getpaseo/paseo</code>{" "}
— this installs to <code className="font-mono">~/.agents/skills/</code> and sets up
symlinks for each agent.
</li>
</ul>
</section>
{/* /paseo */}
<section className="space-y-4">
<h2 className="text-xl font-medium">
<code className="font-mono">/paseo</code> — CLI Reference
</h2>
<p className="text-white/60 leading-relaxed">
The foundational skill. Loaded automatically by other skills. Contains the full Paseo CLI
command reference so agents know how to run commands.
</p>
<p className="text-white/60 leading-relaxed">
Not typically invoked directly by users — it's a reference that other skills depend on.
</p>
</section>
{/* /paseo-handoff */}
<section className="space-y-4">
<h2 className="text-xl font-medium">
<code className="font-mono">/paseo-handoff</code> — Task Handoff
</h2>
<p className="text-white/60 leading-relaxed">
Hands off your current task to another agent with full context. The receiving agent gets a
comprehensive prompt with: task description, relevant files, what's been tried, decisions
made, and acceptance criteria.
</p>
<p className="text-white/60 leading-relaxed">
Default provider is Codex. Can specify Claude (sonnet/opus). Supports{" "}
<code className="font-mono">--worktree</code> for isolated git branches.
</p>
<Code>
<pre className="text-white/80">{`/paseo-handoff hand off the auth fix to codex in a worktree
/paseo-handoff hand this to claude opus for review`}</pre>
</Code>
</section>
{/* /paseo-loop */}
<section className="space-y-4">
<h2 className="text-xl font-medium">
<code className="font-mono">/paseo-loop</code> — Iterative Loops
</h2>
<p className="text-white/60 leading-relaxed">
Runs an agent in a loop with automatic verification until an exit condition is met. Worker
runs, verifier checks, repeat until done or max iterations. Supports different providers for
worker vs verifier (e.g., Codex implements, Claude verifies).
</p>
<p className="text-white/60 leading-relaxed">
Stop conditions: <code className="font-mono">--max-iterations</code>,{" "}
<code className="font-mono">--max-time</code>, or verification passes.
</p>
<Code>
<pre className="text-white/80">{`/paseo-loop fix the failing tests, verify with npm test, max 5 iterations
/paseo-loop use codex to implement, claude sonnet to verify, loop until tests pass`}</pre>
</Code>
</section>
{/* /paseo-orchestrator */}
<section className="space-y-4">
<h2 className="text-xl font-medium">
<code className="font-mono">/paseo-orchestrator</code> — Team Orchestration
</h2>
<p className="text-white/60 leading-relaxed">
Builds and manages a team of agents coordinating through a shared chat room. You describe
the work, it sets up roles, launches agents, and coordinates through chat. Uses a heartbeat
schedule to check progress.
</p>
<p className="text-white/60 leading-relaxed">
Cross-provider: typically Codex for implementation, Claude for review.
</p>
<Code>
<pre className="text-white/80">{`/paseo-orchestrator spin up a team to implement the database migration, codex implements, claude reviews`}</pre>
</Code>
</section>
{/* /paseo-chat */}
<section className="space-y-4">
<h2 className="text-xl font-medium">
<code className="font-mono">/paseo-chat</code> — Chat Rooms
</h2>
<p className="text-white/60 leading-relaxed">
Use persistent chat rooms for asynchronous agent coordination. Create rooms, post messages,
read history, wait for replies. Supports @mentions for specific agents or @everyone.
</p>
<p className="text-white/60 leading-relaxed">
Typically used by the orchestrator skill, but can be used directly.
</p>
<Code>
<pre className="text-white/80">{`/paseo-chat create a room called "backend-refactor" for coordinating the API changes
/paseo-chat post to backend-refactor: "API endpoints are done, ready for review"`}</pre>
</Code>
</section>
{/* /paseo-committee */}
<section className="space-y-4">
<h2 className="text-xl font-medium">
<code className="font-mono">/paseo-committee</code> — Committee Planning
</h2>
<p className="text-white/60 leading-relaxed">
Forms a committee of two high-reasoning agents (Claude Opus + GPT 5.4) to analyze a problem
before implementing. Both agents reason in parallel, then plans are merged. Useful when
stuck, looping, or facing a hard architectural decision.
</p>
<p className="text-white/60 leading-relaxed">
Agents are prevented from editing code — they only produce a plan.
</p>
<Code>
<pre className="text-white/80">{`/paseo-committee why are the websocket connections dropping under load?
/paseo-committee plan the auth system migration`}</pre>
</Code>
</section>
</div>
);
}
+1
View File
@@ -20,6 +20,7 @@ const sitemapPages = [
"/docs/best-practices", "/docs/best-practices",
"/docs/cli", "/docs/cli",
"/docs/configuration", "/docs/configuration",
"/docs/skills",
"/docs/security", "/docs/security",
"/docs/updates", "/docs/updates",
"/docs/voice", "/docs/voice",