* Replace skills auto-sync with explicit install/update/uninstall Skills no longer sync silently on every desktop launch. Settings now shows Install (fresh), Update (with a confirm dialog listing per-skill add/update/delete ops), or Uninstall depending on what's on disk. A manifest at ~/.agents/skills/.paseo-manifest.json tracks Paseo-owned skills so uninstall removes only what we wrote, never user content. Existing users migrate transparently — first read synthesises a manifest from on-disk hashes, so an unchanged install flips silently to up-to-date. Also restructures the integrations layer: skills/ and cli-install/ each own their files behind a designed index.ts. The shared integrations-manager.ts is gone. * Rewrite skills install flow with disk-truth model Drops the manifest sidecar, migration logic, and the agentsDirHasSkillContent heuristic that misread user-authored skills as Paseo content. Replaces them with a hardcoded PASEO_SKILL_NAMES list (current bundle + paseo-chat for retired-skill cleanup); the disk is the source of truth for what's installed. Renames the SkillsState "fresh" → "not-installed" everywhere (server, IPC parser, hook test mocks). Install and update share an applySkills body. Errors propagate instead of being swallowed. Fixes the case where Uninstall left state at "drift" because the user had personal skills under ~/.agents/skills/. * Stop symlinking claude skills, write real files If the user has ~/.claude/skills symlinked to ~/.agents/skills, the per-skill symlink path resolved through the parent symlink and fs.rm'd the agents-side files we'd just written. Mirror real files to claude the same way as codex; agents stays the canonical source-of-truth dir. * Make Uninstall button visible (outline, not ghost)
272 lines
8.4 KiB
TypeScript
272 lines
8.4 KiB
TypeScript
import { useCallback, useMemo } 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 { SettingsSection } from "@/screens/settings/settings-section";
|
|
import { Button } from "@/components/ui/button";
|
|
import { openExternalUrl } from "@/utils/open-external-url";
|
|
import { confirmDialog } from "@/utils/confirm-dialog";
|
|
import {
|
|
shouldUseDesktopDaemon,
|
|
type SkillOp,
|
|
type SkillsStatus,
|
|
} from "@/desktop/daemon/desktop-daemon";
|
|
import { useCliInstall, useSkillsStatus } from "@/desktop/hooks/use-install-status";
|
|
|
|
const CLI_DOCS_URL = "https://paseo.sh/docs/cli";
|
|
const SKILLS_DOCS_URL = "https://paseo.sh/docs/skills";
|
|
const ROW_WITH_BORDER_STYLE = [settingsStyles.row, settingsStyles.rowBorder];
|
|
const UNINSTALL_MESSAGE =
|
|
"Removes all Paseo orchestration skills from ~/.agents, ~/.claude, ~/.codex.";
|
|
|
|
const OP_KIND_ORDER: Record<SkillOp["kind"], number> = { add: 0, update: 1, delete: 2 };
|
|
const OP_KIND_LABEL: Record<SkillOp["kind"], string> = {
|
|
add: "Add skill",
|
|
update: "Update skill",
|
|
delete: "Delete skill",
|
|
};
|
|
|
|
function formatUpdateMessage(ops: readonly SkillOp[]): string {
|
|
const sorted = [...ops].sort((a, b) => {
|
|
const kindOrder = OP_KIND_ORDER[a.kind] - OP_KIND_ORDER[b.kind];
|
|
return kindOrder !== 0 ? kindOrder : a.name.localeCompare(b.name);
|
|
});
|
|
return sorted.map((op) => `${OP_KIND_LABEL[op.kind]} ${op.name}`).join("\n");
|
|
}
|
|
|
|
export function IntegrationsSection() {
|
|
const { theme } = useUnistyles();
|
|
const showSection = shouldUseDesktopDaemon();
|
|
const {
|
|
status: cliStatus,
|
|
isInstalling: isInstallingCli,
|
|
install: installCli,
|
|
refresh: refreshCliStatus,
|
|
} = useCliInstall();
|
|
const {
|
|
status: skillsStatus,
|
|
isWorking: isSkillsWorking,
|
|
install: installSkills,
|
|
update: updateSkills,
|
|
uninstall: uninstallSkills,
|
|
refresh: refreshSkillsStatus,
|
|
} = useSkillsStatus();
|
|
|
|
useFocusEffect(
|
|
useCallback(() => {
|
|
if (!showSection) return undefined;
|
|
refreshCliStatus();
|
|
void refreshSkillsStatus();
|
|
return undefined;
|
|
}, [refreshCliStatus, refreshSkillsStatus, showSection]),
|
|
);
|
|
|
|
const handleInstallCli = useCallback(() => {
|
|
if (isInstallingCli) return;
|
|
installCli();
|
|
}, [installCli, isInstallingCli]);
|
|
|
|
const handleInstallSkills = useCallback(() => {
|
|
if (isSkillsWorking) return;
|
|
void installSkills();
|
|
}, [installSkills, isSkillsWorking]);
|
|
|
|
const handleUpdateSkills = useCallback(async () => {
|
|
if (isSkillsWorking) return;
|
|
const ops = skillsStatus?.ops ?? [];
|
|
const confirmed = await confirmDialog({
|
|
title: "Update Paseo skills?",
|
|
message: ops.length > 0 ? formatUpdateMessage(ops) : "Sync bundled skills to your machine.",
|
|
confirmLabel: "Update",
|
|
});
|
|
if (!confirmed) return;
|
|
await updateSkills();
|
|
}, [isSkillsWorking, skillsStatus, updateSkills]);
|
|
|
|
const handleUninstallSkills = useCallback(async () => {
|
|
if (isSkillsWorking) return;
|
|
const confirmed = await confirmDialog({
|
|
title: "Uninstall Paseo skills?",
|
|
message: UNINSTALL_MESSAGE,
|
|
confirmLabel: "Uninstall",
|
|
destructive: true,
|
|
});
|
|
if (!confirmed) return;
|
|
await uninstallSkills();
|
|
}, [isSkillsWorking, uninstallSkills]);
|
|
|
|
const handleOpenCliDocs = useCallback(() => {
|
|
void openExternalUrl(CLI_DOCS_URL);
|
|
}, []);
|
|
|
|
const handleOpenSkillsDocs = useCallback(() => {
|
|
void openExternalUrl(SKILLS_DOCS_URL);
|
|
}, []);
|
|
|
|
const arrowIcon = useMemo(
|
|
() => <ArrowUpRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
|
|
[theme.iconSize.sm, theme.colors.foregroundMuted],
|
|
);
|
|
|
|
const trailing = useMemo(
|
|
() => (
|
|
<View style={styles.headerLinks}>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
leftIcon={arrowIcon}
|
|
textStyle={settingsStyles.sectionHeaderLinkText}
|
|
style={settingsStyles.sectionHeaderLink}
|
|
onPress={handleOpenCliDocs}
|
|
accessibilityLabel="Open CLI documentation"
|
|
>
|
|
CLI docs
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
leftIcon={arrowIcon}
|
|
textStyle={settingsStyles.sectionHeaderLinkText}
|
|
style={settingsStyles.sectionHeaderLink}
|
|
onPress={handleOpenSkillsDocs}
|
|
accessibilityLabel="Open skills documentation"
|
|
>
|
|
Skills docs
|
|
</Button>
|
|
</View>
|
|
),
|
|
[arrowIcon, handleOpenCliDocs, handleOpenSkillsDocs],
|
|
);
|
|
|
|
if (!showSection) {
|
|
return null;
|
|
}
|
|
|
|
const skillsState = skillsStatus?.state ?? null;
|
|
|
|
return (
|
|
<SettingsSection title="Integrations" trailing={trailing}>
|
|
<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={ROW_WITH_BORDER_STYLE}>
|
|
<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}>
|
|
{skillsState === "drift"
|
|
? "Update available"
|
|
: "Teach your agents to orchestrate through the CLI"}
|
|
</Text>
|
|
</View>
|
|
<SkillsActions
|
|
state={skillsState}
|
|
isWorking={isSkillsWorking}
|
|
onInstall={handleInstallSkills}
|
|
onUpdate={handleUpdateSkills}
|
|
onUninstall={handleUninstallSkills}
|
|
/>
|
|
</View>
|
|
</View>
|
|
</SettingsSection>
|
|
);
|
|
}
|
|
|
|
interface SkillsActionsProps {
|
|
state: SkillsStatus["state"] | null;
|
|
isWorking: boolean;
|
|
onInstall: () => void;
|
|
onUpdate: () => void;
|
|
onUninstall: () => void;
|
|
}
|
|
|
|
function SkillsActions({ state, isWorking, onInstall, onUpdate, onUninstall }: SkillsActionsProps) {
|
|
const { theme } = useUnistyles();
|
|
|
|
if (state === "up-to-date") {
|
|
return (
|
|
<View style={styles.actionsRow}>
|
|
<View style={styles.installedLabel}>
|
|
<Check size={14} color={theme.colors.foregroundMuted} />
|
|
<Text style={styles.mutedText}>Installed</Text>
|
|
</View>
|
|
<Button variant="outline" size="sm" onPress={onUninstall} disabled={isWorking}>
|
|
Uninstall
|
|
</Button>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
if (state === "drift") {
|
|
return (
|
|
<View style={styles.actionsRow}>
|
|
<Button variant="outline" size="sm" onPress={onUpdate} disabled={isWorking}>
|
|
{isWorking ? "Working..." : "Update"}
|
|
</Button>
|
|
<Button variant="outline" size="sm" onPress={onUninstall} disabled={isWorking}>
|
|
Uninstall
|
|
</Button>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Button variant="outline" size="sm" onPress={onInstall} disabled={isWorking}>
|
|
{isWorking ? "Installing..." : "Install"}
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create((theme) => ({
|
|
headerLinks: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[0],
|
|
},
|
|
rowTitleRow: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[2],
|
|
},
|
|
installedLabel: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 4,
|
|
},
|
|
mutedText: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.sm,
|
|
},
|
|
actionsRow: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[2],
|
|
},
|
|
}));
|