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:
@@ -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<string | null>(null);
|
||||
const [isRestartingDaemon, setIsRestartingDaemon] = useState(false);
|
||||
const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false);
|
||||
const [isLoadingCliSymlinkInstructions, setIsLoadingCliSymlinkInstructions] = useState(false);
|
||||
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
||||
const [cliStatusMessage, setCliStatusMessage] = useState<string | null>(null);
|
||||
const [daemonLogs, setDaemonLogs] = useState<DesktopDaemonLogs | null>(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<DesktopPairingOffer | null>(null);
|
||||
const [cliSymlinkInstructions, setCliSymlinkInstructions] =
|
||||
useState<CliSymlinkInstructions | null>(null);
|
||||
const [pairingStatusMessage, setPairingStatusMessage] = useState<string | null>(null);
|
||||
const [cliStatusOutput, setCliStatusOutput] = useState<string | null>(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 (
|
||||
<View style={settingsStyles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={settingsStyles.sectionTitle}>Built-in daemon</Text>
|
||||
<View style={settingsStyles.sectionHeader}>
|
||||
<Text style={settingsStyles.sectionHeaderTitle}>Built-in daemon</Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={<ArrowUpRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />}
|
||||
textStyle={styles.sectionLinkText}
|
||||
style={styles.sectionLink}
|
||||
textStyle={settingsStyles.sectionHeaderLinkText}
|
||||
style={settingsStyles.sectionHeaderLink}
|
||||
onPress={() => void openExternalUrl(ADVANCED_DAEMON_SETTINGS_URL)}
|
||||
accessibilityLabel="Open advanced daemon settings"
|
||||
>
|
||||
@@ -368,10 +326,10 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
||||
</Button>
|
||||
</View>
|
||||
<View style={settingsStyles.card}>
|
||||
<View style={styles.row}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowTitle}>Status</Text>
|
||||
<Text style={styles.hintText}>Only the built-in desktop daemon is shown here.</Text>
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Status</Text>
|
||||
<Text style={settingsStyles.rowHint}>Only the built-in desktop daemon is shown here.</Text>
|
||||
</View>
|
||||
<View style={styles.statusValueGroup}>
|
||||
<Text style={styles.valueText}>{daemonStatusStateText}</Text>
|
||||
@@ -380,10 +338,10 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
||||
</View>
|
||||
{showLifecycleControls ? (
|
||||
<>
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowTitle}>Daemon management</Text>
|
||||
<Text style={styles.hintText}>
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Daemon management</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{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"}
|
||||
</Button>
|
||||
</View>
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowTitle}>{daemonActionLabel}</Text>
|
||||
<Text style={styles.hintText}>{daemonActionMessage}</Text>
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>{daemonActionLabel}</Text>
|
||||
<Text style={settingsStyles.rowHint}>{daemonActionMessage}</Text>
|
||||
{statusMessage ? <Text style={styles.statusText}>{statusMessage}</Text> : null}
|
||||
</View>
|
||||
<Button
|
||||
@@ -433,26 +391,10 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
||||
</View>
|
||||
</>
|
||||
) : null}
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowTitle}>Command line (CLI)</Text>
|
||||
<Text style={styles.hintText}>Shows the command to add `paseo` to your terminal.</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 style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Log file</Text>
|
||||
<Text style={settingsStyles.rowHint}>{daemonLogs?.logPath ?? "Log path unavailable."}</Text>
|
||||
</View>
|
||||
<View style={styles.actionGroup}>
|
||||
{daemonLogs?.logPath ? (
|
||||
@@ -476,10 +418,10 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowTitle}>Pair device</Text>
|
||||
<Text style={styles.hintText}>Connect your phone to this computer.</Text>
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Pair device</Text>
|
||||
<Text style={settingsStyles.rowHint}>Connect your phone to this computer.</Text>
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -490,10 +432,10 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
||||
Pair device
|
||||
</Button>
|
||||
</View>
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowTitle}>Full status</Text>
|
||||
<Text style={styles.hintText}>
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Full status</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
Runs `paseo daemon status` and shows the output.
|
||||
</Text>
|
||||
</View>
|
||||
@@ -518,33 +460,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
||||
</View>
|
||||
) : 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
|
||||
visible={isPairingModalOpen}
|
||||
onClose={() => setIsPairingModalOpen(false)}
|
||||
@@ -567,7 +482,7 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
||||
snapPoints={["70%", "92%"]}
|
||||
>
|
||||
<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>
|
||||
{daemonLogs?.contents.length ? daemonLogs.contents : "(log file is empty)"}
|
||||
</Text>
|
||||
@@ -652,7 +567,7 @@ function PairingOfferDialogContent(input: {
|
||||
return (
|
||||
<View style={styles.pairingState}>
|
||||
<ActivityIndicator size="small" />
|
||||
<Text style={styles.hintText}>Loading pairing offer…</Text>
|
||||
<Text style={settingsStyles.rowHint}>Loading pairing offer…</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -660,7 +575,7 @@ function PairingOfferDialogContent(input: {
|
||||
if (statusMessage) {
|
||||
return (
|
||||
<View style={styles.modalBody}>
|
||||
<Text style={styles.hintText}>{statusMessage}</Text>
|
||||
<Text style={settingsStyles.rowHint}>{statusMessage}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -668,21 +583,21 @@ function PairingOfferDialogContent(input: {
|
||||
if (!pairingOffer?.url) {
|
||||
return (
|
||||
<View style={styles.modalBody}>
|
||||
<Text style={styles.hintText}>Pairing offer unavailable.</Text>
|
||||
<Text style={settingsStyles.rowHint}>Pairing offer unavailable.</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.modalBody}>
|
||||
<Text style={styles.hintText}>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
Scan this QR code in Paseo, or copy the pairing link below.
|
||||
</Text>
|
||||
<View style={styles.qrCard}>
|
||||
{qrDataUrl ? (
|
||||
<Image source={{ uri: qrDataUrl }} style={styles.qrImage} resizeMode="contain" />
|
||||
) : qrError ? (
|
||||
<Text style={styles.hintText}>QR unavailable: {qrError}</Text>
|
||||
<Text style={settingsStyles.rowHint}>QR unavailable: {qrError}</Text>
|
||||
) : (
|
||||
<ActivityIndicator size="small" />
|
||||
)}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
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<DesktopPairingOffer> {
|
||||
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> {
|
||||
const raw = await invokeDesktopCommand<unknown>("cli_daemon_status");
|
||||
if (typeof raw !== "string") {
|
||||
@@ -224,3 +195,34 @@ export async function sendLocalTransportMessage(input: {
|
||||
export async function closeLocalTransportSession(sessionId: string): Promise<void> {
|
||||
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"));
|
||||
}
|
||||
|
||||
@@ -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 <DiagnosticsSection {...diagnosticsProps} />;
|
||||
case "about":
|
||||
return <AboutSection {...aboutProps} />;
|
||||
case "integrations":
|
||||
return isDesktopApp ? <IntegrationsSection /> : null;
|
||||
case "permissions":
|
||||
return isDesktopApp ? <DesktopPermissionsSection /> : 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 (
|
||||
<Pressable
|
||||
key={section.id}
|
||||
style={[
|
||||
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
|
||||
<View key={section.id}>
|
||||
{showSeparator ? <View style={desktopStyles.sidebarSeparator} /> : null}
|
||||
<Pressable
|
||||
style={[
|
||||
desktopStyles.sidebarLabel,
|
||||
isSelected && { color: theme.colors.foreground },
|
||||
desktopStyles.sidebarItem,
|
||||
isSelected && { backgroundColor: theme.colors.surface2 },
|
||||
]}
|
||||
numberOfLines={1}
|
||||
onPress={() => setSelectedSectionId(section.id)}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ selected: isSelected }}
|
||||
>
|
||||
{section.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<IconComponent
|
||||
size={theme.iconSize.md}
|
||||
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>
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -192,8 +192,8 @@ export function KeyboardShortcutsSection() {
|
||||
|
||||
return (
|
||||
<View style={settingsStyles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={settingsStyles.sectionTitle}>Shortcuts</Text>
|
||||
<View style={settingsStyles.sectionHeader}>
|
||||
<Text style={settingsStyles.sectionHeaderTitle}>Shortcuts</Text>
|
||||
{hasOverrides && (
|
||||
<Button variant="ghost" size="sm" onPress={() => void resetAll()}>
|
||||
Reset all
|
||||
@@ -246,11 +246,6 @@ export function KeyboardShortcutsSection() {
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
sectionHeader: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
},
|
||||
subsectionTitle: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
|
||||
@@ -4,6 +4,13 @@ export const settingsStyles = StyleSheet.create((theme) => ({
|
||||
section: {
|
||||
marginBottom: theme.spacing[6],
|
||||
},
|
||||
sectionHeader: {
|
||||
alignItems: "center",
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: theme.spacing[3],
|
||||
marginLeft: theme.spacing[1],
|
||||
},
|
||||
sectionTitle: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
@@ -11,6 +18,20 @@ export const settingsStyles = StyleSheet.create((theme) => ({
|
||||
marginBottom: theme.spacing[3],
|
||||
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: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
@@ -18,4 +39,28 @@ export const settingsStyles = StyleSheet.create((theme) => ({
|
||||
borderColor: theme.colors.border,
|
||||
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],
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -10,6 +10,8 @@ files:
|
||||
extraResources:
|
||||
- from: ../app/dist
|
||||
to: app-dist
|
||||
- from: ../../skills
|
||||
to: skills
|
||||
publish:
|
||||
provider: github
|
||||
owner: getpaseo
|
||||
|
||||
@@ -12,6 +12,12 @@ import {
|
||||
writeAttachmentBase64,
|
||||
} from "../features/attachments.js";
|
||||
import { checkForAppUpdate, downloadAndInstallUpdate } from "../features/auto-updater.js";
|
||||
import {
|
||||
installCli,
|
||||
getCliInstallStatus,
|
||||
installSkills,
|
||||
getSkillsInstallStatus,
|
||||
} from "../integrations/integrations-manager.js";
|
||||
import {
|
||||
openLocalTransportSession,
|
||||
sendLocalTransportMessage,
|
||||
@@ -57,12 +63,6 @@ type DesktopPairingOffer = {
|
||||
qr: string | null;
|
||||
};
|
||||
|
||||
type CliSymlinkInstructions = {
|
||||
title: string;
|
||||
detail: string;
|
||||
commands: string;
|
||||
};
|
||||
|
||||
type DesktopCommandHandler = (args?: Record<string, unknown>) => Promise<unknown> | unknown;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -422,38 +422,6 @@ function resolveCurrentUpdateVersion(): string {
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -466,7 +434,6 @@ export function createDaemonCommandHandlers(): Record<string, DesktopCommandHand
|
||||
restart_desktop_daemon: () => restartDaemon(),
|
||||
desktop_daemon_logs: () => getDaemonLogs(),
|
||||
desktop_daemon_pairing: () => getDaemonPairing(),
|
||||
cli_symlink_instructions: () => getCliSymlinkInstructions(),
|
||||
cli_daemon_status: () => getCliDaemonStatus(),
|
||||
write_attachment_base64: (args) => writeAttachmentBase64(args ?? {}),
|
||||
copy_attachment_file: (args) => copyAttachmentFileToManagedStorage(args ?? {}),
|
||||
@@ -500,6 +467,10 @@ export function createDaemonCommandHandlers(): Record<string, DesktopCommandHand
|
||||
});
|
||||
},
|
||||
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 };
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import { Route as DocsVoiceRouteImport } from './routes/docs/voice'
|
||||
import { Route as DocsUpdatesRouteImport } from './routes/docs/updates'
|
||||
import { Route as DocsSecurityRouteImport } from './routes/docs/security'
|
||||
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 DocsBestPracticesRouteImport } from './routes/docs/best-practices'
|
||||
import { Route as BlogSplatRouteImport } from './routes/blog/$'
|
||||
@@ -109,6 +110,11 @@ const DocsConfigurationRoute = DocsConfigurationRouteImport.update({
|
||||
path: '/configuration',
|
||||
getParentRoute: () => DocsRoute,
|
||||
} as any)
|
||||
const DocsSkillsRoute = DocsSkillsRouteImport.update({
|
||||
id: '/skills',
|
||||
path: '/skills',
|
||||
getParentRoute: () => DocsRoute,
|
||||
} as any)
|
||||
const DocsCliRoute = DocsCliRouteImport.update({
|
||||
id: '/cli',
|
||||
path: '/cli',
|
||||
@@ -140,6 +146,7 @@ export interface FileRoutesByFullPath {
|
||||
'/docs/cli': typeof DocsCliRoute
|
||||
'/docs/configuration': typeof DocsConfigurationRoute
|
||||
'/docs/security': typeof DocsSecurityRoute
|
||||
'/docs/skills': typeof DocsSkillsRoute
|
||||
'/docs/updates': typeof DocsUpdatesRoute
|
||||
'/docs/voice': typeof DocsVoiceRoute
|
||||
'/docs/worktrees': typeof DocsWorktreesRoute
|
||||
@@ -159,6 +166,7 @@ export interface FileRoutesByTo {
|
||||
'/docs/cli': typeof DocsCliRoute
|
||||
'/docs/configuration': typeof DocsConfigurationRoute
|
||||
'/docs/security': typeof DocsSecurityRoute
|
||||
'/docs/skills': typeof DocsSkillsRoute
|
||||
'/docs/updates': typeof DocsUpdatesRoute
|
||||
'/docs/voice': typeof DocsVoiceRoute
|
||||
'/docs/worktrees': typeof DocsWorktreesRoute
|
||||
@@ -181,6 +189,7 @@ export interface FileRoutesById {
|
||||
'/docs/cli': typeof DocsCliRoute
|
||||
'/docs/configuration': typeof DocsConfigurationRoute
|
||||
'/docs/security': typeof DocsSecurityRoute
|
||||
'/docs/skills': typeof DocsSkillsRoute
|
||||
'/docs/updates': typeof DocsUpdatesRoute
|
||||
'/docs/voice': typeof DocsVoiceRoute
|
||||
'/docs/worktrees': typeof DocsWorktreesRoute
|
||||
@@ -204,6 +213,7 @@ export interface FileRouteTypes {
|
||||
| '/docs/cli'
|
||||
| '/docs/configuration'
|
||||
| '/docs/security'
|
||||
| '/docs/skills'
|
||||
| '/docs/updates'
|
||||
| '/docs/voice'
|
||||
| '/docs/worktrees'
|
||||
@@ -223,6 +233,7 @@ export interface FileRouteTypes {
|
||||
| '/docs/cli'
|
||||
| '/docs/configuration'
|
||||
| '/docs/security'
|
||||
| '/docs/skills'
|
||||
| '/docs/updates'
|
||||
| '/docs/voice'
|
||||
| '/docs/worktrees'
|
||||
@@ -244,6 +255,7 @@ export interface FileRouteTypes {
|
||||
| '/docs/cli'
|
||||
| '/docs/configuration'
|
||||
| '/docs/security'
|
||||
| '/docs/skills'
|
||||
| '/docs/updates'
|
||||
| '/docs/voice'
|
||||
| '/docs/worktrees'
|
||||
@@ -377,6 +389,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof DocsConfigurationRouteImport
|
||||
parentRoute: typeof DocsRoute
|
||||
}
|
||||
'/docs/skills': {
|
||||
id: '/docs/skills'
|
||||
path: '/skills'
|
||||
fullPath: '/docs/skills'
|
||||
preLoaderRoute: typeof DocsSkillsRouteImport
|
||||
parentRoute: typeof DocsRoute
|
||||
}
|
||||
'/docs/cli': {
|
||||
id: '/docs/cli'
|
||||
path: '/cli'
|
||||
@@ -418,6 +437,7 @@ interface DocsRouteChildren {
|
||||
DocsCliRoute: typeof DocsCliRoute
|
||||
DocsConfigurationRoute: typeof DocsConfigurationRoute
|
||||
DocsSecurityRoute: typeof DocsSecurityRoute
|
||||
DocsSkillsRoute: typeof DocsSkillsRoute
|
||||
DocsUpdatesRoute: typeof DocsUpdatesRoute
|
||||
DocsVoiceRoute: typeof DocsVoiceRoute
|
||||
DocsWorktreesRoute: typeof DocsWorktreesRoute
|
||||
@@ -429,6 +449,7 @@ const DocsRouteChildren: DocsRouteChildren = {
|
||||
DocsCliRoute: DocsCliRoute,
|
||||
DocsConfigurationRoute: DocsConfigurationRoute,
|
||||
DocsSecurityRoute: DocsSecurityRoute,
|
||||
DocsSkillsRoute: DocsSkillsRoute,
|
||||
DocsUpdatesRoute: DocsUpdatesRoute,
|
||||
DocsVoiceRoute: DocsVoiceRoute,
|
||||
DocsWorktreesRoute: DocsWorktreesRoute,
|
||||
|
||||
@@ -11,6 +11,7 @@ const navigation = [
|
||||
{ name: "Voice", href: "/docs/voice" },
|
||||
{ name: "Git worktrees", href: "/docs/worktrees" },
|
||||
{ name: "CLI", href: "/docs/cli" },
|
||||
{ name: "Skills", href: "/docs/skills" },
|
||||
{ name: "Configuration", href: "/docs/configuration" },
|
||||
{ name: "Security", href: "/docs/security" },
|
||||
{ name: "Best practices", href: "/docs/best-practices" },
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ const sitemapPages = [
|
||||
"/docs/best-practices",
|
||||
"/docs/cli",
|
||||
"/docs/configuration",
|
||||
"/docs/skills",
|
||||
"/docs/security",
|
||||
"/docs/updates",
|
||||
"/docs/voice",
|
||||
|
||||
Reference in New Issue
Block a user