From bac302626f8e547616576925e368ee5b9676bc93 Mon Sep 17 00:00:00 2001 From: Christoph Leiter Date: Mon, 13 Jul 2026 11:45:42 +0200 Subject: [PATCH 1/2] feat(app): give each permission mode a distinct icon (#1980) * feat(app): give each permission mode a distinct icon The mode selector reused shield glyphs across modes: Auto mode showed a question mark, Always Ask a checkmark, and the same icon appeared on several modes (ShieldCheck on both Always Ask and Plan, ShieldAlert on both Accept File Edits and Bypass). The result read as arbitrary rather than as a scale of autonomy. Assign every built-in provider mode a distinct icon from one shared vocabulary, applied consistently across Claude, Codex, Copilot, and OpenCode: - Shield guarded default (every action prompts) - ShieldCheck classifier/auto-reviewer vets prompts - ShieldPlus edit tools pre-approved, others still prompt - ShieldEllipsis plan / read-only deliberation - ShieldOff fully permissive, no prompts Register the new icons in the client MODE_ICONS map. Old clients downgrade unknown icon names to ShieldCheck through the existing customModeIcons compat gate, so the wire contract is unchanged. Co-Authored-By: Claude Opus 4.8 * feat(app): order permission modes by autonomy --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: Mohamed Boudra --- .../composer/agent-controls/mode-control.tsx | 14 ++++++- packages/protocol/src/provider-manifest.ts | 42 +++++++++---------- .../server/agent/providers/claude/agent.ts | 16 +++---- 3 files changed, 42 insertions(+), 30 deletions(-) diff --git a/packages/app/src/composer/agent-controls/mode-control.tsx b/packages/app/src/composer/agent-controls/mode-control.tsx index 6329cd46ed..e129b1e53e 100644 --- a/packages/app/src/composer/agent-controls/mode-control.tsx +++ b/packages/app/src/composer/agent-controls/mode-control.tsx @@ -12,7 +12,16 @@ import { Text, View, type PressableStateCallbackType } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useShallow } from "zustand/shallow"; import { useStoreWithEqualityFn } from "zustand/traditional"; -import { Bot, ShieldAlert, ShieldCheck, ShieldOff, ShieldQuestionMark } from "lucide-react-native"; +import { + Bot, + Shield, + ShieldAlert, + ShieldCheck, + ShieldEllipsis, + ShieldOff, + ShieldPlus, + ShieldQuestionMark, +} from "lucide-react-native"; import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; import { type SheetHeader } from "@/components/adaptive-modal-sheet"; import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; @@ -48,9 +57,12 @@ interface ModeIconProps { const MODE_ICONS: Record> = { Bot, + Shield, ShieldCheck, ShieldAlert, + ShieldEllipsis, ShieldOff, + ShieldPlus, ShieldQuestionMark, }; diff --git a/packages/protocol/src/provider-manifest.ts b/packages/protocol/src/provider-manifest.ts index 312e704559..35af5b1d9e 100644 --- a/packages/protocol/src/provider-manifest.ts +++ b/packages/protocol/src/provider-manifest.ts @@ -36,39 +36,39 @@ export interface AgentProviderDefinition { } const CLAUDE_MODES: AgentProviderModeDefinition[] = [ + { + id: "plan", + label: "Plan Mode", + description: "Analyze the codebase without executing tools or edits", + icon: "ShieldEllipsis", + colorTier: "planning", + }, { id: "default", label: "Always Ask", description: "Prompts for permission the first time a tool is used", - icon: "ShieldCheck", + icon: "Shield", colorTier: "safe", }, - { - id: "auto", - label: "Auto mode", - description: "Uses a model classifier to review permission prompts automatically", - icon: "ShieldQuestionMark", - colorTier: "moderate", - }, { id: "acceptEdits", label: "Accept File Edits", description: "Automatically approves edit-focused tools without prompting", - icon: "ShieldAlert", + icon: "ShieldPlus", colorTier: "moderate", }, { - id: "plan", - label: "Plan Mode", - description: "Analyze the codebase without executing tools or edits", + id: "auto", + label: "Auto mode", + description: "Uses a model classifier to review permission prompts automatically", icon: "ShieldCheck", - colorTier: "planning", + colorTier: "moderate", }, { id: "bypassPermissions", label: "Bypass", description: "Skip all permission prompts (use with caution)", - icon: "ShieldAlert", + icon: "ShieldOff", colorTier: "dangerous", isUnattended: true, }, @@ -79,7 +79,7 @@ const CODEX_MODES: AgentProviderModeDefinition[] = [ id: "auto", label: "Default Permissions", description: "Edit files and run commands with Codex's default approval flow.", - icon: "ShieldAlert", + icon: "Shield", colorTier: "moderate", }, { @@ -87,14 +87,14 @@ const CODEX_MODES: AgentProviderModeDefinition[] = [ label: "Auto-review", description: "Same workspace-write permissions as Default, but eligible `on-request` approvals are routed through the auto-reviewer subagent.", - icon: "ShieldQuestionMark", + icon: "ShieldCheck", colorTier: "moderate", }, { id: "full-access", label: "Full Access", description: "Edit files, run commands, and access the network without additional prompts.", - icon: "ShieldAlert", + icon: "ShieldOff", colorTier: "dangerous", isUnattended: true, }, @@ -105,14 +105,14 @@ const COPILOT_MODES: AgentProviderModeDefinition[] = [ id: "https://agentclientprotocol.com/protocol/session-modes#agent", label: "Agent", description: "Default agent mode for conversational interactions", - icon: "ShieldAlert", + icon: "Shield", colorTier: "moderate", }, { id: "https://agentclientprotocol.com/protocol/session-modes#plan", label: "Plan", description: "Plan mode for creating and executing multi-step plans", - icon: "ShieldCheck", + icon: "ShieldEllipsis", colorTier: "planning", }, { @@ -130,14 +130,14 @@ const OPENCODE_MODES: AgentProviderModeDefinition[] = [ id: "build", label: "Build", description: "Allows edits and tool execution for implementation work", - icon: "Bot", + icon: "Shield", colorTier: "moderate", }, { id: "plan", label: "Plan", description: "Read-only planning mode that avoids file edits", - icon: "Bot", + icon: "ShieldEllipsis", colorTier: "planning", }, ]; diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index a2dde63a63..60001add3b 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -280,25 +280,25 @@ const CLAUDE_CAPABILITIES: AgentCapabilityFlags = { }; const DEFAULT_MODES: AgentMode[] = [ + { + id: "plan", + label: "Plan Mode", + description: "Analyze the codebase without executing tools or edits", + }, { id: "default", label: "Always Ask", description: "Prompts for permission the first time a tool is used", }, - { - id: "auto", - label: "Auto mode", - description: "Uses a model classifier to review permission prompts automatically", - }, { id: "acceptEdits", label: "Accept File Edits", description: "Automatically approves edit-focused tools without prompting", }, { - id: "plan", - label: "Plan Mode", - description: "Analyze the codebase without executing tools or edits", + id: "auto", + label: "Auto mode", + description: "Uses a model classifier to review permission prompts automatically", }, { id: "bypassPermissions", From 218097b7ccf413d9daf1afe841a2fed84debeeed Mon Sep 17 00:00:00 2001 From: Matt Cowger Date: Mon, 13 Jul 2026 04:15:40 -0700 Subject: [PATCH 2/2] feat: clone GitHub repo into a workspace (#1331) * feat: clone GitHub repo into a workspace Add an end-to-end "clone a GitHub repo and register it as a Paseo workspace" flow: a new workspace.github.clone RPC, daemon handler, client method, CLI `paseo clone` command, and a GitHub-repo mode in the project picker modal. Gated behind the workspaceGithubClone server capability flag. - protocol: workspace.github.clone request/response schemas + feature flag - server: handleWorkspaceGithubCloneRequest, normalizeCloneRepository - client: DaemonClient.cloneGithubWorkspace - cli: `paseo clone --dir [--protocol https|ssh]` - app: GitHub-repo mode, clone-protocol picker, error surfacing Review fixes: - CLI clone now checks the workspaceGithubClone capability and fails fast with a clear "update the host" error instead of hanging for the full request timeout against an older daemon. - Replace the duplicated client-side URL-detection regex (app + CLI) with a shared isCompleteGitRemote() in @getpaseo/protocol/git-remote, backed by parseGitRemoteLocation so clients classify remotes identically to the daemon (fixes confusing errors for git://, ftp://, file:// inputs). - Add git-remote.test.ts covering the shared classifier. * Fix GitHub clone failure handling --------- Co-authored-by: Mohamed Boudra --- docs/development.md | 1 + .../src/components/project-picker-modal.tsx | 566 +++++++++++++++--- packages/app/src/hooks/open-project.ts | 72 ++- .../app/src/hooks/use-open-project.test.ts | 163 ++++- packages/app/src/hooks/use-open-project.ts | 55 +- packages/cli/src/cli.ts | 15 + packages/cli/src/commands/clone.ts | 96 +++ packages/cli/src/utils/client.ts | 19 + packages/client/src/daemon-client.ts | 22 + packages/protocol/src/git-remote.test.ts | 29 + packages/protocol/src/git-remote.ts | 13 + packages/protocol/src/messages.ts | 30 + .../protocol/src/messages.workspaces.test.ts | 61 ++ packages/server/src/server/session.ts | 157 ++++- .../server/src/server/websocket-server.ts | 2 + 15 files changed, 1223 insertions(+), 78 deletions(-) create mode 100644 packages/cli/src/commands/clone.ts create mode 100644 packages/protocol/src/git-remote.test.ts diff --git a/docs/development.md b/docs/development.md index 3d994ed28a..d435f11f02 100644 --- a/docs/development.md +++ b/docs/development.md @@ -338,6 +338,7 @@ npm run cli -- ls -a -g --json # Same, as JSON npm run cli -- inspect # Show detailed agent info npm run cli -- logs # View agent timeline npm run cli -- daemon status # Check daemon status +npm run cli -- clone owner/repo --dir ~/workspace # Clone GitHub repo and register workspace ``` Use `--host ` to point the CLI at a different daemon: diff --git a/packages/app/src/components/project-picker-modal.tsx b/packages/app/src/components/project-picker-modal.tsx index ddd1924703..9044875ad1 100644 --- a/packages/app/src/components/project-picker-modal.tsx +++ b/packages/app/src/components/project-picker-modal.tsx @@ -1,4 +1,13 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type Dispatch, + type RefObject, + type SetStateAction, +} from "react"; import { Modal, Pressable, @@ -12,18 +21,29 @@ import { } from "react-native"; import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; -import { Folder } from "lucide-react-native"; +import { Folder, Github } from "lucide-react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { getOpenProjectFailureReason, type OpenProjectFailureReason } from "@/hooks/open-project"; -import { useOpenProject } from "@/hooks/use-open-project"; +import { + getOpenProjectFailureReason, + type OpenProjectFailureReason, + type WorkspaceGithubCloneProtocol, +} from "@/hooks/open-project"; +import { useOpenGithubRepo, useOpenProject } from "@/hooks/use-open-project"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useProjectPickerStore } from "@/stores/project-picker-store"; import { useRecommendedProjectPaths } from "@/stores/session-store-hooks"; import { shortenPath } from "@/utils/shorten-path"; import { isNative } from "@/constants/platform"; import { ProjectPickerBrowseButton } from "./project-picker-browse-button"; +import { isCompleteGitRemote } from "@getpaseo/protocol/git-remote"; import { buildProjectPickerOptions, type ProjectPickerOption } from "./project-picker-options"; +type ProjectPickerMode = "local" | "github"; + +const DEFAULT_CLONE_TARGET_DIRECTORY = "~/workspace"; +const CLONE_REPO_ERROR_MESSAGE = "Unable to clone that GitHub repository."; +const CLONE_PROTOCOL_ERROR_MESSAGE = "Choose HTTPS or SSH for owner/repo repository names."; + interface PathRowProps { option: ProjectPickerOption; active: boolean; @@ -133,6 +153,255 @@ function ProjectPickerResults({ ); } +interface CloneProtocolButtonProps { + label: string; + active: boolean; + onPress: () => void; +} + +function CloneProtocolButton({ label, active, onPress }: CloneProtocolButtonProps) { + const { theme } = useUnistyles(); + const buttonStyle = useMemo( + () => [ + styles.protocolButton, + { + borderColor: theme.colors.border, + backgroundColor: active ? theme.colors.surface1 : "transparent", + }, + ], + [active, theme.colors.border, theme.colors.surface1], + ); + const textStyle = useMemo( + () => [ + styles.protocolButtonText, + { color: active ? theme.colors.foreground : theme.colors.foregroundMuted }, + ], + [active, theme.colors.foreground, theme.colors.foregroundMuted], + ); + return ( + + {label} + + ); +} + +interface GithubRepoFormProps { + repo: string; + cloneProtocol: WorkspaceGithubCloneProtocol | null; + targetDirectory: string; + needsCloneProtocol: boolean; + isSubmitting: boolean; + repoInputRef: RefObject; + onChangeRepo: (text: string) => void; + onChangeTargetDirectory: (text: string) => void; + onSelectHttps: () => void; + onSelectSsh: () => void; + onSubmit: () => void; +} + +function GithubRepoForm({ + repo, + cloneProtocol, + targetDirectory, + needsCloneProtocol, + isSubmitting, + repoInputRef, + onChangeRepo, + onChangeTargetDirectory, + onSelectHttps, + onSelectSsh, + onSubmit, +}: GithubRepoFormProps) { + const { theme } = useUnistyles(); + const inputStyle = useMemo( + () => [styles.input, { color: theme.colors.foreground }], + [theme.colors.foreground], + ); + const labelStyle = useMemo( + () => [styles.label, { color: theme.colors.foregroundMuted }], + [theme.colors.foregroundMuted], + ); + + return ( + + + GitHub repo + + + {needsCloneProtocol ? ( + + Clone protocol + + + + + + ) : null} + + Checkout directory + + + + ); +} + +interface ProjectPickerKeyboardNavigationInput { + open: boolean; + mode: ProjectPickerMode; + optionsLength: number; + onClose: () => void; + setActiveIndex: Dispatch>; +} + +function useProjectPickerKeyboardNavigation({ + open, + mode, + optionsLength, + onClose, + setActiveIndex, +}: ProjectPickerKeyboardNavigationInput) { + useEffect(() => { + if (!open || isNative) return; + + function handler(event: KeyboardEvent) { + const key = event.key; + if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Escape") return; + + if (key === "Escape") { + event.preventDefault(); + onClose(); + return; + } + + if (mode !== "local" || optionsLength === 0) return; + event.preventDefault(); + setActiveIndex((current) => { + const delta = key === "ArrowDown" ? 1 : -1; + const next = current + delta; + if (next < 0) return optionsLength - 1; + if (next >= optionsLength) return 0; + return next; + }); + } + + window.addEventListener("keydown", handler, true); + return () => window.removeEventListener("keydown", handler, true); + }, [mode, onClose, open, optionsLength, setActiveIndex]); +} + +interface GithubCloneModeInput { + client: ReturnType; + serverId: string | null; + openGithubRepo: ReturnType; + close: () => void; +} + +function useGithubCloneMode({ client, serverId, openGithubRepo, close }: GithubCloneModeInput) { + const [repo, setRepo] = useState(""); + const [cloneProtocol, setCloneProtocol] = useState(null); + const [targetDirectory, setTargetDirectory] = useState(DEFAULT_CLONE_TARGET_DIRECTORY); + const [cloneErrorText, setCloneErrorText] = useState(null); + const needsCloneProtocol = repo.trim().length > 0 && !isCompleteGitRemote(repo); + + const reset = useCallback(() => { + setRepo(""); + setCloneProtocol(null); + setTargetDirectory(DEFAULT_CLONE_TARGET_DIRECTORY); + setCloneErrorText(null); + }, []); + + const handleChangeRepo = useCallback((text: string) => { + setRepo(text); + if (isCompleteGitRemote(text)) { + setCloneProtocol(null); + } + setCloneErrorText(null); + }, []); + + const handleChangeTargetDirectory = useCallback((text: string) => { + setTargetDirectory(text); + setCloneErrorText(null); + }, []); + + const handleSetHttpsProtocol = useCallback(() => { + setCloneProtocol("https"); + setCloneErrorText(null); + }, []); + + const handleSetSshProtocol = useCallback(() => { + setCloneProtocol("ssh"); + setCloneErrorText(null); + }, []); + + const handleCloneRepo = useCallback(async () => { + const trimmedRepo = repo.trim(); + const trimmedTargetDirectory = targetDirectory.trim(); + if (!trimmedRepo || !trimmedTargetDirectory || !client || !serverId) return false; + const repoIsCompleteRemote = isCompleteGitRemote(trimmedRepo); + if (!repoIsCompleteRemote && !cloneProtocol) { + setCloneErrorText(CLONE_PROTOCOL_ERROR_MESSAGE); + return false; + } + + setCloneErrorText(null); + const didOpenProject = await openGithubRepo( + trimmedRepo, + trimmedTargetDirectory, + repoIsCompleteRemote ? undefined : (cloneProtocol ?? undefined), + ); + if (!didOpenProject) { + setCloneErrorText(CLONE_REPO_ERROR_MESSAGE); + return false; + } + close(); + return true; + }, [client, cloneProtocol, close, openGithubRepo, repo, serverId, targetDirectory]); + + return { + repo, + cloneProtocol, + targetDirectory, + cloneErrorText, + needsCloneProtocol, + reset, + handleChangeRepo, + handleChangeTargetDirectory, + handleSetHttpsProtocol, + handleSetSshProtocol, + handleCloneRepo, + }; +} + export function ProjectPickerModal() { const { theme } = useUnistyles(); const { t } = useTranslation(); @@ -146,12 +415,30 @@ export function ProjectPickerModal() { const recommendedPaths = useRecommendedProjectPaths(serverId); const inputRef = useRef(null); + const repoInputRef = useRef(null); + const [mode, setMode] = useState("local"); const [query, setQuery] = useState(""); const [debouncedQuery, setDebouncedQuery] = useState(""); const [activeIndex, setActiveIndex] = useState(0); const [isSubmitting, setIsSubmitting] = useState(false); const [openErrorReason, setOpenErrorReason] = useState(null); const openProject = useOpenProject(serverId); + const openGithubRepo = useOpenGithubRepo(serverId); + const supportsGithubClone = + client?.getLastServerInfoMessage()?.features?.workspaceGithubClone === true; + const { + repo, + cloneProtocol, + targetDirectory, + cloneErrorText, + needsCloneProtocol, + reset: resetGithubCloneMode, + handleChangeRepo, + handleChangeTargetDirectory, + handleSetHttpsProtocol, + handleSetSshProtocol, + handleCloneRepo: cloneGithubRepo, + } = useGithubCloneMode({ client, serverId, openGithubRepo, close }); const directorySuggestionsQuery = useQuery({ queryKey: ["project-picker-directory-suggestions", serverId, debouncedQuery], @@ -172,7 +459,7 @@ export function ProjectPickerModal() { [], }; }, - enabled: Boolean(client) && isConnected && open, + enabled: Boolean(client) && isConnected && open && mode === "local", staleTime: 15_000, retry: false, }); @@ -228,11 +515,24 @@ export function ProjectPickerModal() { [client, close, openProject, serverId], ); + const handleCloneRepo = useCallback(async () => { + setIsSubmitting(true); + try { + await cloneGithubRepo(); + } finally { + setIsSubmitting(false); + } + }, [cloneGithubRepo]); + const submitActiveOption = useCallback(() => { + if (mode === "github") { + void handleCloneRepo(); + return; + } const option = options[activeIndex]; if (!option) return; void handleSelectPath(option.path); - }, [activeIndex, handleSelectPath, options]); + }, [activeIndex, handleCloneRepo, handleSelectPath, mode, options]); const handleChangeQuery = useCallback((text: string) => { setQuery(text); @@ -244,16 +544,41 @@ export function ProjectPickerModal() { setOpenErrorReason("open_failed"); }, []); + const handleSetLocalMode = useCallback(() => { + setMode("local"); + setActiveIndex(0); + }, []); + + const handleSetGithubMode = useCallback(() => { + setMode("github"); + setActiveIndex(0); + setOpenErrorReason(null); + }, []); + useEffect(() => { if (open) { + setMode("local"); setQuery(""); setDebouncedQuery(""); + resetGithubCloneMode(); setActiveIndex(0); setOpenErrorReason(null); const id = setTimeout(() => inputRef.current?.focus(), 0); return () => clearTimeout(id); } - }, [open]); + }, [open, resetGithubCloneMode]); + + useEffect(() => { + if (!open) return; + const id = setTimeout(() => { + if (mode === "github") { + repoInputRef.current?.focus(); + return; + } + inputRef.current?.focus(); + }, 0); + return () => clearTimeout(id); + }, [mode, open]); // Debounce the query that drives the (potentially multi-second) directory // suggestions RPC so fast typing doesn't fire a filesystem scan per keystroke. @@ -263,41 +588,19 @@ export function ProjectPickerModal() { }, [query]); useEffect(() => { - if (!open) return; + if (!open || mode !== "local") return; if (activeIndex >= options.length) { setActiveIndex(options.length > 0 ? options.length - 1 : 0); } - }, [activeIndex, options.length, open]); + }, [activeIndex, mode, options.length, open]); - useEffect(() => { - if (!open || isNative) return; - - function handler(event: KeyboardEvent) { - const key = event.key; - if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Escape") return; - - if (key === "Escape") { - event.preventDefault(); - close(); - return; - } - - if (key === "ArrowDown" || key === "ArrowUp") { - if (options.length === 0) return; - event.preventDefault(); - setActiveIndex((current) => { - const delta = key === "ArrowDown" ? 1 : -1; - const next = current + delta; - if (next < 0) return options.length - 1; - if (next >= options.length) return 0; - return next; - }); - } - } - - window.addEventListener("keydown", handler, true); - return () => window.removeEventListener("keydown", handler, true); - }, [close, open, options.length]); + useProjectPickerKeyboardNavigation({ + open, + mode, + optionsLength: options.length, + onClose: close, + setActiveIndex, + }); const panelStyle = useMemo( () => [ @@ -325,6 +628,27 @@ export function ProjectPickerModal() { () => [styles.emptyText, { color: theme.colors.destructive }], [theme.colors.destructive], ); + const cloneErrorTextStyle = useMemo( + () => [styles.errorText, { color: theme.colors.destructive }], + [theme.colors.destructive], + ); + const modeButtonStyle = useCallback( + (buttonMode: ProjectPickerMode) => [ + styles.modeButton, + { + borderColor: theme.colors.border, + backgroundColor: mode === buttonMode ? theme.colors.surface1 : "transparent", + }, + ], + [mode, theme.colors.border, theme.colors.surface1], + ); + const modeButtonTextStyle = useCallback( + (buttonMode: ProjectPickerMode) => [ + styles.modeButtonText, + { color: mode === buttonMode ? theme.colors.foreground : theme.colors.foregroundMuted }, + ], + [mode, theme.colors.foreground, theme.colors.foregroundMuted], + ); if (!serverId) return null; @@ -335,40 +659,83 @@ export function ProjectPickerModal() { - - + + + + Local folder + + {supportsGithubClone ? ( + + + GitHub repo + + ) : null} + + + {mode === "local" ? ( + + + + + ) : ( + + )} - + {mode === "local" ? ( + + ) : ( + + {cloneErrorText ? {cloneErrorText} : null} + {isSubmitting ? Cloning repository... : null} + + )} @@ -396,12 +763,58 @@ const styles = StyleSheet.create((theme) => ({ ...theme.shadow.lg, }, header: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[3], paddingHorizontal: theme.spacing[4], paddingVertical: theme.spacing[3], borderBottomWidth: 1, + gap: theme.spacing[3], + }, + localInputRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[3], + }, + modeRow: { + flexDirection: "row", + gap: theme.spacing[2], + }, + modeButton: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + borderRadius: theme.borderRadius.md, + borderWidth: 1, + }, + modeButtonText: { + fontSize: theme.fontSize.sm, + fontWeight: "600", + }, + githubForm: { + gap: theme.spacing[3], + }, + fieldGroup: { + gap: theme.spacing[1], + }, + label: { + fontSize: theme.fontSize.xs, + fontWeight: "600", + textTransform: "uppercase", + letterSpacing: 0, + }, + protocolRow: { + flexDirection: "row", + gap: theme.spacing[2], + }, + protocolButton: { + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + borderRadius: theme.borderRadius.md, + borderWidth: 1, + }, + protocolButtonText: { + fontSize: theme.fontSize.sm, + fontWeight: "600", }, input: { flex: 1, @@ -446,4 +859,11 @@ const styles = StyleSheet.create((theme) => ({ paddingVertical: theme.spacing[4], fontSize: theme.fontSize.base, }, + errorText: { + paddingHorizontal: theme.spacing[4], + paddingTop: theme.spacing[3], + paddingBottom: theme.spacing[1], + fontSize: theme.fontSize.sm, + fontWeight: "600", + }, })); diff --git a/packages/app/src/hooks/open-project.ts b/packages/app/src/hooks/open-project.ts index d838341847..bff4432ba8 100644 --- a/packages/app/src/hooks/open-project.ts +++ b/packages/app/src/hooks/open-project.ts @@ -1,12 +1,18 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import type { ProjectAddResponse } from "@getpaseo/protocol/messages"; +import type { ProjectAddResponse, WorkspaceGithubCloneProtocol } from "@getpaseo/protocol/messages"; import { normalizeEmptyProjectDescriptor as normalizeProjectWithoutWorkspacesDescriptor, + normalizeWorkspaceDescriptor, type EmptyProjectDescriptor as ProjectWithoutWorkspacesDescriptor, + type WorkspaceDescriptor, } from "@/stores/session-store"; +import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store"; type OpenProjectPayload = ProjectAddResponse["payload"]; type OpenProjectErrorCode = NonNullable; +type WorkspaceOpenPayload = + | Awaited> + | Awaited>; export interface OpenProjectSuccess { ok: true; @@ -20,6 +26,7 @@ export interface OpenProjectFailure { export type OpenProjectResult = OpenProjectSuccess | OpenProjectFailure; export type OpenProjectFailureReason = "directory_not_found" | "open_failed"; +export type { WorkspaceGithubCloneProtocol }; export function getOpenProjectFailureReason( result: OpenProjectResult, @@ -45,6 +52,22 @@ export interface OpenProjectDirectlyInput { setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void; } +interface WorkspaceOpenCallbacks { + serverId: string; + isConnected: boolean; + mergeWorkspaces: (serverId: string, workspaces: Iterable) => void; + setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void; + openDraftTab: (workspaceKey: string) => string | null; + navigateToWorkspace: (serverId: string, workspaceId: string) => void; +} + +export interface OpenGithubRepoDirectlyInput extends WorkspaceOpenCallbacks { + repo: string; + targetDirectory: string; + cloneProtocol?: WorkspaceGithubCloneProtocol; + client: Pick | null; +} + export async function openProjectDirectly( input: OpenProjectDirectlyInput, ): Promise { @@ -78,3 +101,50 @@ export async function openProjectDirectly( input.setHasHydratedWorkspaces(normalizedServerId, true); return { ok: true }; } + +function finishWorkspaceOpen( + input: WorkspaceOpenCallbacks, + payload: WorkspaceOpenPayload, +): boolean { + const normalizedServerId = input.serverId.trim(); + if (!normalizedServerId || payload.error || !payload.workspace) { + return false; + } + + const workspace = normalizeWorkspaceDescriptor(payload.workspace); + const workspaceKey = buildWorkspaceTabPersistenceKey({ + serverId: normalizedServerId, + workspaceId: workspace.id, + }); + if (!workspaceKey) { + return false; + } + + input.mergeWorkspaces(normalizedServerId, [workspace]); + input.setHasHydratedWorkspaces(normalizedServerId, true); + input.openDraftTab(workspaceKey); + input.navigateToWorkspace(normalizedServerId, workspace.id); + return true; +} + +export async function openGithubRepoDirectly(input: OpenGithubRepoDirectlyInput): Promise { + const normalizedServerId = input.serverId.trim(); + const trimmedRepo = input.repo.trim(); + const trimmedTargetDirectory = input.targetDirectory.trim(); + if ( + !normalizedServerId || + !trimmedRepo || + !trimmedTargetDirectory || + !input.client || + !input.isConnected + ) { + return false; + } + + const payload = await input.client.cloneGithubWorkspace({ + repo: trimmedRepo, + targetDirectory: trimmedTargetDirectory, + ...(input.cloneProtocol ? { cloneProtocol: input.cloneProtocol } : {}), + }); + return finishWorkspaceOpen(input, payload); +} diff --git a/packages/app/src/hooks/use-open-project.test.ts b/packages/app/src/hooks/use-open-project.test.ts index 3f04e1a424..16d86c1315 100644 --- a/packages/app/src/hooks/use-open-project.test.ts +++ b/packages/app/src/hooks/use-open-project.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from "vitest"; -import { getOpenProjectFailureReason, openProjectDirectly } from "@/hooks/open-project"; -import type { EmptyProjectDescriptor as ProjectWithoutWorkspacesDescriptor } from "@/stores/session-store"; +import { + getOpenProjectFailureReason, + openGithubRepoDirectly, + openProjectDirectly, +} from "@/hooks/open-project"; +import type { + EmptyProjectDescriptor as ProjectWithoutWorkspacesDescriptor, + WorkspaceDescriptor, +} from "@/stores/session-store"; const SERVER_ID = "server-1"; const PROJECT_PATH = "/repo/project"; @@ -14,31 +21,113 @@ function buildProjectPayload() { }; } +function buildWorkspacePayload() { + return { + id: "1", + projectId: "1", + projectDisplayName: "project", + projectRootPath: PROJECT_PATH, + workspaceDirectory: PROJECT_PATH, + projectKind: "git" as const, + workspaceKind: "checkout" as const, + name: "project", + archivingAt: null, + status: "done" as const, + statusEnteredAt: null, + activityAt: null, + diffStat: null, + scripts: [], + }; +} + interface RecordedProject { serverId: string; project: ProjectWithoutWorkspacesDescriptor; } +interface RecordedMerge { + serverId: string; + workspaces: WorkspaceDescriptor[]; +} + interface RecordedHydrated { serverId: string; hydrated: boolean; } +interface RecordedOpenDraftTab { + workspaceKey: string; +} + +interface RecordedNavigate { + serverId: string; + workspaceId: string; +} + +interface RecordedClone { + repo: string; + targetDirectory: string; + cloneProtocol?: "https" | "ssh"; +} + function createFakeSession() { const projects: RecordedProject[] = []; + const merges: RecordedMerge[] = []; const hydrated: RecordedHydrated[] = []; return { projects, + merges, hydrated, addEmptyProject: (serverId: string, project: ProjectWithoutWorkspacesDescriptor) => { projects.push({ serverId, project }); }, + mergeWorkspaces: (serverId: string, workspaces: Iterable) => { + merges.push({ serverId, workspaces: Array.from(workspaces) }); + }, setHasHydratedWorkspaces: (serverId: string, value: boolean) => { hydrated.push({ serverId, hydrated: value }); }, }; } +function createFakeWorkspaceLayout() { + const openedTabs: RecordedOpenDraftTab[] = []; + return { + openedTabs, + openDraftTab: (workspaceKey: string) => { + openedTabs.push({ workspaceKey }); + return "tab-1"; + }, + }; +} + +function createFakeNavigator() { + const navigations: RecordedNavigate[] = []; + return { + navigations, + navigateToWorkspace: (serverId: string, workspaceId: string) => { + navigations.push({ serverId, workspaceId }); + }, + }; +} + +function createFakeGithubCloneClient(workspace: ReturnType) { + const clones: RecordedClone[] = []; + return { + clones, + cloneGithubWorkspace: async (input: RecordedClone) => { + clones.push(input); + return { + requestId: "request-3", + repo: "owner/project", + checkoutPath: PROJECT_PATH, + error: null, + workspace, + }; + }, + }; +} + describe("openProjectDirectly", () => { it("adds the project and marks workspaces hydrated without opening a workspace", async () => { const session = createFakeSession(); @@ -73,6 +162,7 @@ describe("openProjectDirectly", () => { }, }, ]); + expect(session.merges).toEqual([]); expect(session.hydrated).toEqual([{ serverId: SERVER_ID, hydrated: true }]); }); @@ -133,6 +223,75 @@ describe("openProjectDirectly", () => { }); }); +describe("openGithubRepoDirectly", () => { + it("opens a cloned GitHub workspace and seeds a draft tab", async () => { + const session = createFakeSession(); + const layout = createFakeWorkspaceLayout(); + const navigator = createFakeNavigator(); + const workspacePayload = buildWorkspacePayload(); + const github = createFakeGithubCloneClient(workspacePayload); + + const result = await openGithubRepoDirectly({ + serverId: SERVER_ID, + repo: "owner/project", + targetDirectory: "~/workspace", + cloneProtocol: "https", + isConnected: true, + client: github, + mergeWorkspaces: session.mergeWorkspaces, + setHasHydratedWorkspaces: session.setHasHydratedWorkspaces, + openDraftTab: layout.openDraftTab, + navigateToWorkspace: navigator.navigateToWorkspace, + }); + + expect(result).toBe(true); + expect(github.clones).toEqual([ + { + repo: "owner/project", + targetDirectory: "~/workspace", + cloneProtocol: "https", + }, + ]); + expect(session.merges).toHaveLength(1); + expect(session.merges[0]?.serverId).toBe(SERVER_ID); + expect(session.merges[0]?.workspaces[0]).toMatchObject({ + id: "1", + projectId: "1", + projectRootPath: PROJECT_PATH, + workspaceDirectory: PROJECT_PATH, + }); + expect(session.hydrated).toEqual([{ serverId: SERVER_ID, hydrated: true }]); + expect(layout.openedTabs).toEqual([{ workspaceKey: `${SERVER_ID}:1` }]); + expect(navigator.navigations).toEqual([{ serverId: SERVER_ID, workspaceId: "1" }]); + }); + + it("rejects a workspace without an identity before changing app state", async () => { + const session = createFakeSession(); + const layout = createFakeWorkspaceLayout(); + const navigator = createFakeNavigator(); + const github = createFakeGithubCloneClient({ ...buildWorkspacePayload(), id: " " }); + + const result = await openGithubRepoDirectly({ + serverId: SERVER_ID, + repo: "owner/project", + targetDirectory: "~/workspace", + cloneProtocol: "https", + isConnected: true, + client: github, + mergeWorkspaces: session.mergeWorkspaces, + setHasHydratedWorkspaces: session.setHasHydratedWorkspaces, + openDraftTab: layout.openDraftTab, + navigateToWorkspace: navigator.navigateToWorkspace, + }); + + expect(result).toBe(false); + expect(session.merges).toEqual([]); + expect(session.hydrated).toEqual([]); + expect(layout.openedTabs).toEqual([]); + expect(navigator.navigations).toEqual([]); + }); +}); + describe("getOpenProjectFailureReason", () => { it("keeps the known directory-not-found failure reason", () => { expect( diff --git a/packages/app/src/hooks/use-open-project.ts b/packages/app/src/hooks/use-open-project.ts index 7dabc03156..97347d38c9 100644 --- a/packages/app/src/hooks/use-open-project.ts +++ b/packages/app/src/hooks/use-open-project.ts @@ -1,7 +1,15 @@ import { useCallback } from "react"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useSessionStore } from "@/stores/session-store"; -import { openProjectDirectly, type OpenProjectResult } from "@/hooks/open-project"; +import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; +import { generateDraftId } from "@/stores/draft-keys"; +import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; +import { + openGithubRepoDirectly, + openProjectDirectly, + type OpenProjectResult, + type WorkspaceGithubCloneProtocol, +} from "@/hooks/open-project"; export function useOpenProject( serverId: string | null, @@ -40,3 +48,48 @@ export function useOpenProject( ], ); } + +export function useOpenGithubRepo( + serverId: string | null, +): ( + repo: string, + targetDirectory: string, + cloneProtocol?: WorkspaceGithubCloneProtocol, +) => Promise { + const normalizedServerId = serverId?.trim() ?? ""; + const client = useHostRuntimeClient(normalizedServerId); + const isConnected = useHostRuntimeIsConnected(normalizedServerId); + const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces); + const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces); + const openDraftTab = useCallback((workspaceKey: string) => { + return useWorkspaceLayoutStore.getState().openTabFocused(workspaceKey, { + kind: "draft", + draftId: generateDraftId(), + }); + }, []); + + return useCallback( + async (repo: string, targetDirectory: string, cloneProtocol?: WorkspaceGithubCloneProtocol) => { + return openGithubRepoDirectly({ + serverId: normalizedServerId, + repo, + targetDirectory, + ...(cloneProtocol ? { cloneProtocol } : {}), + isConnected, + client, + mergeWorkspaces, + setHasHydratedWorkspaces, + openDraftTab, + navigateToWorkspace, + }); + }, + [ + client, + isConnected, + mergeWorkspaces, + normalizedServerId, + openDraftTab, + setHasHydratedWorkspaces, + ], + ); +} diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index f89004dd51..9c98310ba0 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -25,6 +25,7 @@ import { addArchiveOptions, runArchiveCommand } from "./commands/agent/archive.j import { addAttachOptions, runAttachCommand } from "./commands/agent/attach.js"; import { addImportOptions, runImportCommand } from "./commands/agent/import.js"; import { withOutput } from "./output/index.js"; +import { runCloneCommand } from "./commands/clone.js"; import { onboardCommand } from "./commands/onboard.js"; import { addDaemonHostOption, @@ -66,6 +67,20 @@ export function createCli(): Command { withOutput(runImportCommand), ); + addJsonAndDaemonHostOptions( + program + .command("clone") + .description("Clone a GitHub repo and register it as a Paseo workspace") + .argument("", "GitHub repo in owner/repo format or a full git remote URL") + .requiredOption("--dir ", "Parent directory to clone into (for example: ~/workspace)"), + ) + .addOption( + new Option("--protocol ", "Protocol for owner/repo shorthand repositories").choices( + ["https", "ssh"], + ), + ) + .action(withOutput(runCloneCommand)); + addDaemonHostOption(addAttachOptions(program.command("attach"))).action(runAttachCommand); addDaemonHostOption(addLogsOptions(program.command("logs"))).action(runLogsCommand); diff --git a/packages/cli/src/commands/clone.ts b/packages/cli/src/commands/clone.ts new file mode 100644 index 0000000000..8935c18429 --- /dev/null +++ b/packages/cli/src/commands/clone.ts @@ -0,0 +1,96 @@ +import type { Command } from "commander"; +import { isCompleteGitRemote } from "@getpaseo/protocol/git-remote"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { buildDaemonConnectionCommandError, connectToDaemon } from "../utils/client.js"; +import type { CommandError, OutputSchema, SingleResult } from "../output/index.js"; +import type { CommandOptions } from "../output/with-output.js"; + +type CloneProtocol = "https" | "ssh"; + +interface CloneCommandOptions extends CommandOptions { + protocol?: CloneProtocol; +} + +export interface CloneResult { + repo: string; + checkoutPath: string; + workspaceId: string; + workspaceName: string; +} + +export const cloneSchema: OutputSchema = { + idField: "workspaceId", + columns: [ + { header: "REPO", field: "repo", width: 28 }, + { header: "WORKSPACE", field: "workspaceName", width: 28 }, + { header: "PATH", field: "checkoutPath", width: 56 }, + ], +}; + +function cmdError(code: string, message: string, details?: string): CommandError { + return details ? { code, message, details } : { code, message }; +} + +export async function runCloneCommand( + repo: string, + options: CloneCommandOptions, + _command: Command, +): Promise> { + const targetDirectory = typeof options.dir === "string" ? options.dir.trim() : ""; + if (!targetDirectory) { + throw cmdError("INVALID_ARGUMENT", "--dir is required"); + } + const repoIsCompleteRemote = isCompleteGitRemote(repo); + if (!repoIsCompleteRemote && !options.protocol) { + throw cmdError("INVALID_ARGUMENT", "--protocol is required for owner/repo repository names"); + } + + let client: DaemonClient; + try { + client = await connectToDaemon({ host: options.host }); + } catch (err) { + throw buildDaemonConnectionCommandError({ host: options.host, error: err }); + } + + if (client.getLastServerInfoMessage()?.features?.workspaceGithubClone !== true) { + await client.close().catch(() => {}); + throw cmdError( + "UNSUPPORTED_BY_HOST", + "This daemon does not support cloning GitHub repos.", + "Update the host to a newer Paseo version.", + ); + } + + try { + const response = await client.cloneGithubWorkspace({ + repo, + targetDirectory, + ...(repoIsCompleteRemote ? {} : { cloneProtocol: options.protocol }), + }); + if (response.error || !response.workspace || !response.checkoutPath) { + throw cmdError( + "CLONE_FAILED", + `Failed to clone GitHub repo: ${response.error ?? "no workspace returned"}`, + ); + } + + return { + type: "single", + data: { + repo: response.repo, + checkoutPath: response.checkoutPath, + workspaceId: response.workspace.id, + workspaceName: response.workspace.name, + }, + schema: cloneSchema, + }; + } catch (err) { + if (err && typeof err === "object" && "code" in err) { + throw err; + } + const message = err instanceof Error ? err.message : String(err); + throw cmdError("CLONE_FAILED", `Failed to clone GitHub repo: ${message}`); + } finally { + await client.close().catch(() => {}); + } +} diff --git a/packages/cli/src/utils/client.ts b/packages/cli/src/utils/client.ts index 5f936d91eb..697b1b0300 100644 --- a/packages/cli/src/utils/client.ts +++ b/packages/cli/src/utils/client.ts @@ -22,6 +22,12 @@ export interface ConnectOptions { timeout?: number; } +export interface DaemonConnectionCommandError { + code: "DAEMON_NOT_RUNNING"; + message: string; + details: string; +} + const DEFAULT_HOST = "localhost:6767"; const DEFAULT_TIMEOUT = 15000; const PID_FILENAME = "paseo.pid"; @@ -44,6 +50,19 @@ export function getDaemonHost(options?: ConnectOptions): string { return resolveDaemonHostCandidates(options)[0] ?? DEFAULT_HOST; } +export function buildDaemonConnectionCommandError(options: { + host?: string; + error: unknown; +}): DaemonConnectionCommandError { + const host = getDaemonHost({ host: options.host }); + const message = options.error instanceof Error ? options.error.message : String(options.error); + return { + code: "DAEMON_NOT_RUNNING", + message: `Cannot connect to daemon at ${host}: ${message}`, + details: "Start the daemon with: paseo daemon start", + }; +} + export function normalizeDaemonHost(raw: string): string | null { const trimmed = raw.trim(); if (!trimmed) { diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index a19be362f1..1f827a0b37 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -56,6 +56,8 @@ import type { ProjectIconResponse, ProjectAddResponse, OpenProjectResponseMessage, + WorkspaceGithubCloneProtocol, + WorkspaceGithubCloneResponse, ArchiveWorkspaceResponseMessage, WorkspaceSetupStatusResponseMessage, ListCommandsResponse, @@ -148,6 +150,8 @@ const perfNow: () => number = ? () => performance.now() : () => Date.now(); +const WORKSPACE_GITHUB_CLONE_TIMEOUT_MS = 5 * 60 * 1000; + interface ImportAgentInputBase { cwd?: string; labels?: Record; @@ -758,6 +762,7 @@ export interface RenameTerminalInput { } type OpenProjectPayload = OpenProjectResponseMessage["payload"]; type ProjectAddPayload = ProjectAddResponse["payload"]; +type WorkspaceGithubClonePayload = WorkspaceGithubCloneResponse["payload"]; type ArchiveWorkspacePayload = ArchiveWorkspaceResponseMessage["payload"]; type WorkspaceSetupStatusPayload = WorkspaceSetupStatusResponseMessage["payload"]; @@ -1988,6 +1993,23 @@ export class DaemonClient { }); } + async cloneGithubWorkspace( + input: { repo: string; targetDirectory: string; cloneProtocol?: WorkspaceGithubCloneProtocol }, + requestId?: string, + ): Promise { + const message = { + type: "workspace.github.clone.request", + repo: input.repo, + targetDirectory: input.targetDirectory, + ...(input.cloneProtocol ? { cloneProtocol: input.cloneProtocol } : {}), + } as const; + return this.sendNamespacedCorrelatedSessionRequest<"workspace.github.clone.response">({ + requestId, + message, + timeout: WORKSPACE_GITHUB_CLONE_TIMEOUT_MS, + }); + } + async startWorkspaceScript( workspaceId: string, scriptName: string, diff --git a/packages/protocol/src/git-remote.test.ts b/packages/protocol/src/git-remote.test.ts new file mode 100644 index 0000000000..c098d03405 --- /dev/null +++ b/packages/protocol/src/git-remote.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { isCompleteGitRemote, parseGitRemoteLocation } from "./git-remote.js"; + +describe("isCompleteGitRemote", () => { + it("treats supported URLs and scp-like addresses as complete remotes", () => { + expect(isCompleteGitRemote("https://github.com/owner/repo")).toBe(true); + expect(isCompleteGitRemote("http://internal/owner/repo.git")).toBe(true); + expect(isCompleteGitRemote("ssh://git@github.com/owner/repo")).toBe(true); + expect(isCompleteGitRemote("git@github.com:owner/repo.git")).toBe(true); + expect(isCompleteGitRemote(" https://github.com/owner/repo ")).toBe(true); + }); + + it("treats owner/repo shorthand as incomplete (needs a clone protocol)", () => { + expect(isCompleteGitRemote("owner/repo")).toBe(false); + expect(isCompleteGitRemote("owner/repo.git")).toBe(false); + expect(isCompleteGitRemote("")).toBe(false); + }); + + it("rejects schemes the daemon's parser does not accept, so clients agree with the server", () => { + // The old client-side regex matched any `scheme://`, classifying these as + // complete URLs while the daemon (parseGitRemoteLocation) rejected them — + // producing a confusing "use owner/repo format" error. The shared helper + // must classify them identically to the daemon. + for (const repo of ["git://github.com/owner/repo", "ftp://host/repo", "file:///tmp/repo"]) { + expect(isCompleteGitRemote(repo)).toBe(false); + expect(parseGitRemoteLocation(repo)).toBeNull(); + } + }); +}); diff --git a/packages/protocol/src/git-remote.ts b/packages/protocol/src/git-remote.ts index fae141b03c..b996c03db0 100644 --- a/packages/protocol/src/git-remote.ts +++ b/packages/protocol/src/git-remote.ts @@ -24,6 +24,19 @@ export function parseGitHubRemoteUrl(remoteUrl: string): GitHubRemoteIdentity | return parseGitHubRemoteIdentity(location.path); } +/** + * Whether `repo` is already a complete git remote (a URL or scp-like address) + * rather than `owner/repo` shorthand that still needs a clone protocol picked. + * + * Clients (app + CLI) and the daemon must agree on this classification: the + * daemon treats `parseGitRemoteLocation(repo) !== null` as "complete remote" + * and everything else as shorthand, so reuse the same parser here instead of a + * separate regex that would drift (e.g. accepting `git://` the parser rejects). + */ +export function isCompleteGitRemote(repo: string): boolean { + return parseGitRemoteLocation(repo) !== null; +} + export function parseGitRemoteLocation(remoteUrl: string): GitRemoteLocation | null { const trimmed = remoteUrl.trim(); if (!trimmed) return null; diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 74e7c5a3e5..ec05be1769 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1794,6 +1794,18 @@ export const ProjectAddRequestSchema = z.object({ requestId: z.string(), }); +// Smallest shorthand repo path is "a/b": owner, slash, repository. +const MIN_REPOSITORY_PATH_LENGTH = 3; +export const WorkspaceGithubCloneProtocolSchema = z.enum(["https", "ssh"]); + +export const WorkspaceGithubCloneRequestSchema = z.object({ + type: z.literal("workspace.github.clone.request"), + repo: z.string().trim().min(MIN_REPOSITORY_PATH_LENGTH), + cloneProtocol: WorkspaceGithubCloneProtocolSchema.optional(), + targetDirectory: z.string().trim().min(1), + requestId: z.string(), +}); + export const ArchiveWorkspaceRequestSchema = z.object({ type: z.literal("archive_workspace_request"), workspaceId: z.string(), @@ -2178,6 +2190,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ LegacyOpenInEditorRequestSchema, OpenProjectRequestSchema, ProjectAddRequestSchema, + WorkspaceGithubCloneRequestSchema, ArchiveWorkspaceRequestSchema, WorkspaceCreateRequestSchema, WorkspaceClearAttentionRequestSchema, @@ -2420,6 +2433,8 @@ export const ServerInfoStatusPayloadSchema = z providerSubagents: z.boolean().optional(), // COMPAT(workspacePinning): added in v0.1.107, remove gate after 2027-01-12. workspacePinning: z.boolean().optional(), + // COMPAT(workspaceGithubClone): added in v0.1.108, remove gate after 2027-01-13. + workspaceGithubClone: z.boolean().optional(), }) .optional(), }) @@ -2918,6 +2933,17 @@ export const ProjectAddResponseSchema = z.object({ }), }); +export const WorkspaceGithubCloneResponseSchema = z.object({ + type: z.literal("workspace.github.clone.response"), + payload: z.object({ + requestId: z.string(), + repo: z.string().trim().min(MIN_REPOSITORY_PATH_LENGTH), + checkoutPath: z.string().nullable(), + workspace: WorkspaceDescriptorPayloadSchema.nullable(), + error: z.string().nullable(), + }), +}); + export const StartWorkspaceScriptResponseMessageSchema = z.object({ type: z.literal("start_workspace_script_response"), payload: z.object({ @@ -4338,6 +4364,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ FetchWorkspacesResponseMessageSchema, ProjectAddResponseSchema, OpenProjectResponseMessageSchema, + WorkspaceGithubCloneResponseSchema, StartWorkspaceScriptResponseMessageSchema, LegacyListAvailableEditorsResponseMessageSchema, LegacyOpenInEditorResponseMessageSchema, @@ -4497,6 +4524,7 @@ export type FetchWorkspacesResponseMessage = z.infer; export type ScriptStatusUpdateMessage = z.infer; export type OpenProjectResponseMessage = z.infer; +export type WorkspaceGithubCloneResponse = z.infer; export type StartWorkspaceScriptResponseMessage = z.infer< typeof StartWorkspaceScriptResponseMessageSchema >; @@ -4745,6 +4773,8 @@ export type LegacyListAvailableEditorsRequest = z.infer< export type LegacyOpenInEditorRequest = z.infer; export type OpenProjectRequest = z.infer; export type ProjectAddRequest = z.infer; +export type WorkspaceGithubCloneRequest = z.infer; +export type WorkspaceGithubCloneProtocol = z.infer; export type ArchiveWorkspaceRequest = z.infer; export type WorkspaceClearAttentionRequest = z.infer; export type FileExplorerRequest = z.infer; diff --git a/packages/protocol/src/messages.workspaces.test.ts b/packages/protocol/src/messages.workspaces.test.ts index c7099db308..d47e46e3cd 100644 --- a/packages/protocol/src/messages.workspaces.test.ts +++ b/packages/protocol/src/messages.workspaces.test.ts @@ -286,6 +286,67 @@ describe("workspace message schemas", () => { expect(parsed.type).toBe("open_project_request"); }); + test("parses workspace GitHub clone request and response repo paths", () => { + const request = SessionInboundMessageSchema.parse({ + type: "workspace.github.clone.request", + repo: "a/b", + cloneProtocol: "https", + targetDirectory: "~/workspace", + requestId: "req-clone", + }); + const response = SessionOutboundMessageSchema.parse({ + type: "workspace.github.clone.response", + payload: { + requestId: "req-clone", + repo: "a/b", + checkoutPath: "/tmp/b", + workspace: null, + error: null, + }, + }); + + expect(request.type).toBe("workspace.github.clone.request"); + if (request.type !== "workspace.github.clone.request") { + throw new Error("expected workspace.github.clone.request"); + } + expect(request.cloneProtocol).toBe("https"); + expect(response.type).toBe("workspace.github.clone.response"); + }); + + test("rejects invalid workspace GitHub clone protocols", () => { + const request = SessionInboundMessageSchema.safeParse({ + type: "workspace.github.clone.request", + repo: "a/b", + cloneProtocol: "ftp", + targetDirectory: "~/workspace", + requestId: "req-clone", + }); + + expect(request.success).toBe(false); + }); + + test("rejects workspace GitHub clone repo paths shorter than owner slash repo", () => { + const request = SessionInboundMessageSchema.safeParse({ + type: "workspace.github.clone.request", + repo: "ab", + targetDirectory: "~/workspace", + requestId: "req-clone", + }); + const response = SessionOutboundMessageSchema.safeParse({ + type: "workspace.github.clone.response", + payload: { + requestId: "req-clone", + repo: "ab", + checkoutPath: null, + workspace: null, + error: "failed", + }, + }); + + expect(request.success).toBe(false); + expect(response.success).toBe(false); + }); + test("parses legacy editor RPC messages for compatibility", () => { const listRequest = SessionInboundMessageSchema.parse({ type: "list_available_editors_request", diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 1783dcb706..ac2bb1c3fe 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1,6 +1,6 @@ import equal from "fast-deep-equal"; import { v4 as uuidv4 } from "uuid"; -import { stat } from "node:fs/promises"; +import { lstat, mkdir, mkdtemp, rename, rm, stat } from "node:fs/promises"; import { basename, normalize, resolve, sep } from "path"; import { homedir } from "node:os"; import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities"; @@ -214,6 +214,7 @@ import { toWorktreeRequestError, toWorktreeWireError, } from "./worktree-errors.js"; +import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote"; import { type WorktreeConfig, createWorktree } from "../utils/worktree.js"; import { runGitCommand } from "../utils/run-git-command.js"; import { CreateAgentLifecycleDispatch } from "./agent/create-agent-lifecycle-dispatch.js"; @@ -1408,6 +1409,7 @@ export class Session { this.dispatchAgentConfigMessage(msg) ?? this.dispatchCheckoutMessage(msg) ?? this.dispatchWorkspaceAndProjectMessage(msg) ?? + this.dispatchWorkspaceFileMessage(msg) ?? this.dispatchProviderMessage(msg) ?? this.dispatchTerminalMessage(msg) ?? this.dispatchChatScheduleLoopMessage(msg) ?? @@ -1663,6 +1665,8 @@ export class Session { return this.handleOpenProjectRequest(msg); case "project.add.request": return this.handleProjectAddRequest(msg); + case "workspace.github.clone.request": + return this.handleWorkspaceGithubCloneRequest(msg); case "archive_workspace_request": return this.handleArchiveWorkspaceRequest(msg); case "project.remove.request": @@ -1675,6 +1679,13 @@ export class Session { return this.handleWorkspaceTitleSetRequest(msg.workspaceId, msg.title, msg.requestId); case "workspace.pin.set.request": return this.handleWorkspacePinSetRequest(msg.workspaceId, msg.pinned, msg.requestId); + default: + return undefined; + } + } + + private dispatchWorkspaceFileMessage(msg: SessionInboundMessage): Promise | undefined { + switch (msg.type) { case "file_explorer_request": return this.workspaceFilesSession.handleFileExplorerRequest(msg); case "project_icon_request": @@ -4773,6 +4784,99 @@ export class Session { } } + private async handleWorkspaceGithubCloneRequest( + request: Extract, + ): Promise { + let normalizedRepo = request.repo; + let checkoutPath: string | null = null; + try { + const repo = normalizeCloneRepository({ + repo: request.repo, + cloneProtocol: request.cloneProtocol, + }); + normalizedRepo = repo.displayName; + const targetParent = resolve(expandTilde(request.targetDirectory.trim())); + checkoutPath = resolve(targetParent, repo.name); + if (!this.isPathWithinRoot(targetParent, checkoutPath)) { + throw new Error("Resolved checkout path must stay inside the target directory"); + } + + await mkdir(targetParent, { recursive: true }); + try { + await lstat(checkoutPath); + throw new Error(`Checkout path already exists: ${checkoutPath}`); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + } + + const cloneStagingPath = await mkdtemp(resolve(targetParent, ".paseo-clone-")); + try { + await runGitCommand(["clone", repo.cloneUrl, cloneStagingPath], { + cwd: targetParent, + timeout: 5 * 60 * 1000, + maxOutputBytes: 1024 * 1024, + logger: this.sessionLogger, + }); + await rename(cloneStagingPath, checkoutPath); + } catch (error) { + await rm(cloneStagingPath, { recursive: true, force: true }).catch((cleanupError) => { + this.sessionLogger.warn( + { err: cleanupError, cloneStagingPath }, + "Failed to clean up partial GitHub clone", + ); + }); + throw error; + } + + const workspace = + await this.workspaceProvisioning.findOrCreateWorkspaceForDirectory(checkoutPath); + await this.syncWorkspaceGitObserverForWorkspace(workspace); + const descriptor = await this.describeWorkspaceRecord(workspace); + await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId); + void this.workspaceGitService + .getSnapshot(workspace.cwd, { + force: true, + includeGitHub: true, + reason: "open_project", + }) + .catch((error) => { + this.sessionLogger.warn( + { err: error, cwd: workspace.cwd }, + "Background snapshot refresh failed after workspace.github.clone", + ); + }); + + this.emit({ + type: "workspace.github.clone.response", + payload: { + requestId: request.requestId, + repo: repo.displayName, + checkoutPath, + workspace: descriptor, + error: null, + }, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to clone GitHub repo"; + this.sessionLogger.error( + { err: error, repo: request.repo, targetDirectory: request.targetDirectory }, + "Failed to clone GitHub workspace", + ); + this.emit({ + type: "workspace.github.clone.response", + payload: { + requestId: request.requestId, + repo: normalizedRepo, + checkoutPath, + workspace: null, + error: message, + }, + }); + } + } + // Named accessor: the workspace descriptor builder and the git-watch test both read a workspace's // scripts snapshot through here; the workspace-scripts module owns the payload assembly. private buildWorkspaceScriptPayloadSnapshot( @@ -5727,3 +5831,54 @@ export class Session { this.workspaceGitObserver.dispose(); } } + +interface CloneRepositoryInput { + name: string; + displayName: string; + cloneUrl: string; +} + +function normalizeCloneRepository(input: { + repo: string; + cloneProtocol?: "https" | "ssh"; +}): CloneRepositoryInput { + const trimmed = input.repo.trim(); + if (!trimmed) { + throw new Error("Repository is required"); + } + + const remote = parseGitRemoteLocation(trimmed); + if (remote) { + const segments = remote.path.split("/").filter(Boolean); + const name = segments.at(-1); + if (!name || !isValidGitHubRepoSegment(name)) { + throw new Error("Repository name contains invalid characters"); + } + return { name, displayName: remote.path, cloneUrl: trimmed }; + } + + const [owner, rawName, ...extra] = trimmed.split("/"); + if (!owner || !rawName || extra.length > 0) { + throw new Error("Repository must use owner/repo format or a git remote URL"); + } + const name = rawName.endsWith(".git") ? rawName.slice(0, -4) : rawName; + if (!isValidGitHubRepoSegment(owner) || !isValidGitHubRepoSegment(name)) { + throw new Error("Repository contains invalid characters"); + } + if (!input.cloneProtocol) { + throw new Error("Clone protocol is required for owner/repo repository names"); + } + const cloneUrl = + input.cloneProtocol === "ssh" + ? `git@github.com:${owner}/${name}.git` + : `https://github.com/${owner}/${name}.git`; + return { + name, + displayName: `${owner}/${name}`, + cloneUrl, + }; +} + +function isValidGitHubRepoSegment(value: string): boolean { + return /^[A-Za-z0-9._-]+$/u.test(value); +} diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 4061fa464e..05ce7e886b 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1264,6 +1264,8 @@ export class VoiceAssistantWebSocketServer { providerSubagents: true, // COMPAT(workspacePinning): added in v0.1.107, remove gate after 2027-01-12. workspacePinning: true, + // COMPAT(workspaceGithubClone): added in v0.1.108, remove gate after 2027-01-13. + workspaceGithubClone: true, }, }; }