diff --git a/desktop/src/api/computerUse.ts b/desktop/src/api/computerUse.ts index f0c50e27..eb527d46 100644 --- a/desktop/src/api/computerUse.ts +++ b/desktop/src/api/computerUse.ts @@ -7,6 +7,8 @@ export type ComputerUseStatus = { installed: boolean version: string | null path: string | null + source: 'custom' | 'system' | 'venv' | null + error: string | null } venv: { created: boolean @@ -53,6 +55,7 @@ export type ComputerUseConfig = { clipboardWrite: boolean systemKeyCombos: boolean } + pythonPath: string | null } export const computerUseApi = { diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 45920002..567d091e 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -592,6 +592,17 @@ export const en = { 'settings.computerUse.python': 'Python 3', 'settings.computerUse.pythonNotFound': 'Not installed. Please install Python 3 first.', 'settings.computerUse.pythonFound': 'Installed', + 'settings.computerUse.pythonCustomInvalid': 'Custom interpreter unavailable', + 'settings.computerUse.pythonPathLabel': 'Python Interpreter Path', + 'settings.computerUse.pythonPathPlaceholder': 'Auto-detect, or choose python.exe / python3', + 'settings.computerUse.pythonPathHint': 'Leave empty to auto-detect. Choose the Python executable from conda, pyenv, or another custom environment to prefer it for runtime setup.', + 'settings.computerUse.pythonPathBrowse': 'Browse', + 'settings.computerUse.pythonPathSave': 'Apply', + 'settings.computerUse.pythonPathAuto': 'Auto', + 'settings.computerUse.pythonPathSaved': 'Saved. Rechecks will prefer this interpreter.', + 'settings.computerUse.pythonPathSaveFailed': 'Save failed. Try again.', + 'settings.computerUse.pythonPathDialogTitle': 'Select Python Interpreter', + 'settings.computerUse.pythonPathDialogFailed': 'Could not open the file picker. Paste the path manually.', 'settings.computerUse.venv': 'Virtual Environment', 'settings.computerUse.venvReady': 'Ready', 'settings.computerUse.venvNotReady': 'Not created', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 801f69ae..1fdef644 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -594,6 +594,17 @@ export const zh: Record = { 'settings.computerUse.python': 'Python 3', 'settings.computerUse.pythonNotFound': '未安装,请先安装 Python 3。', 'settings.computerUse.pythonFound': '已安装', + 'settings.computerUse.pythonCustomInvalid': '自定义解释器不可用', + 'settings.computerUse.pythonPathLabel': 'Python 解释器路径', + 'settings.computerUse.pythonPathPlaceholder': '自动检测,或选择 python.exe / python3', + 'settings.computerUse.pythonPathHint': '留空则自动检测;选择 conda、pyenv 或自定义环境里的 Python 可执行文件后会优先使用它创建运行环境。', + 'settings.computerUse.pythonPathBrowse': '选择', + 'settings.computerUse.pythonPathSave': '应用', + 'settings.computerUse.pythonPathAuto': '自动', + 'settings.computerUse.pythonPathSaved': '已保存,重新检测会优先使用这个解释器。', + 'settings.computerUse.pythonPathSaveFailed': '保存失败,请稍后重试。', + 'settings.computerUse.pythonPathDialogTitle': '选择 Python 解释器', + 'settings.computerUse.pythonPathDialogFailed': '无法打开文件选择器,请手动粘贴路径。', 'settings.computerUse.venv': '虚拟环境', 'settings.computerUse.venvReady': '已就绪', 'settings.computerUse.venvNotReady': '未创建', diff --git a/desktop/src/pages/ComputerUseSettings.test.tsx b/desktop/src/pages/ComputerUseSettings.test.tsx index e2b05ade..80ef37b4 100644 --- a/desktop/src/pages/ComputerUseSettings.test.tsx +++ b/desktop/src/pages/ComputerUseSettings.test.tsx @@ -25,6 +25,8 @@ const readyStatus = { installed: true, version: '3.12.0', path: '/usr/bin/python3', + source: 'system', + error: null, }, venv: { created: false, @@ -48,6 +50,7 @@ const enabledConfig = { clipboardWrite: true, systemKeyCombos: true, }, + pythonPath: null, } function deferred() { @@ -104,6 +107,25 @@ describe('ComputerUseSettings', () => { }) }) + it('saves a custom Python interpreter path and rechecks status', async () => { + render() + + const input = await screen.findByLabelText('Python Interpreter Path') + + await act(async () => { + fireEvent.change(input, { + target: { value: ' C:\\Users\\me\\miniconda3\\envs\\cu\\python.exe ' }, + }) + fireEvent.click(screen.getByText('Apply')) + await Promise.resolve() + }) + + expect(computerUseApiMock.setAuthorizedApps).toHaveBeenCalledWith({ + pythonPath: 'C:\\Users\\me\\miniconda3\\envs\\cu\\python.exe', + }) + expect(computerUseApiMock.getStatus).toHaveBeenCalledTimes(2) + }) + it('keeps the user-selected enablement when a stale refresh resolves later', async () => { const staleRefresh = deferred() computerUseApiMock.getStatus.mockResolvedValue({ diff --git a/desktop/src/pages/ComputerUseSettings.tsx b/desktop/src/pages/ComputerUseSettings.tsx index 9cfd2556..35479778 100644 --- a/desktop/src/pages/ComputerUseSettings.tsx +++ b/desktop/src/pages/ComputerUseSettings.tsx @@ -61,6 +61,10 @@ export function ComputerUseSettings() { const [computerUseEnabled, setComputerUseEnabled] = useState(true) const [clipboardAccess, setClipboardAccess] = useState(true) const [systemKeys, setSystemKeys] = useState(true) + const [pythonPathDraft, setPythonPathDraft] = useState('') + const [pythonPathSaved, setPythonPathSaved] = useState('') + const [pythonPathSaving, setPythonPathSaving] = useState(false) + const [pythonPathMessage, setPythonPathMessage] = useState(null) const configMutationSeqRef = useRef(0) const fetchStatus = useCallback(async () => { @@ -84,6 +88,8 @@ export function ComputerUseSettings() { setAuthorizedBundleIds(new Set(configResult.authorizedApps.map(a => a.bundleId))) setClipboardAccess(configResult.grantFlags.clipboardRead) setSystemKeys(configResult.grantFlags.systemKeyCombos) + setPythonPathDraft(configResult.pythonPath ?? '') + setPythonPathSaved(configResult.pythonPath ?? '') }, []) const fetchConfig = useCallback(async () => { @@ -190,6 +196,42 @@ export function ComputerUseSettings() { }) } + const savePythonPath = async (value = pythonPathDraft) => { + configMutationSeqRef.current += 1 + const normalized = value.trim() + setPythonPathSaving(true) + setPythonPathMessage(null) + try { + await computerUseApi.setAuthorizedApps({ pythonPath: normalized || null }) + setPythonPathDraft(normalized) + setPythonPathSaved(normalized) + setPythonPathMessage(t('settings.computerUse.pythonPathSaved')) + await fetchStatus() + } catch { + setPythonPathMessage(t('settings.computerUse.pythonPathSaveFailed')) + } finally { + setPythonPathSaving(false) + } + } + + const choosePythonPath = async () => { + try { + const { open } = await import('@tauri-apps/plugin-dialog') + const selected = await open({ + multiple: false, + directory: false, + title: t('settings.computerUse.pythonPathDialogTitle'), + }) + const selectedPath = Array.isArray(selected) ? selected[0] : selected + if (typeof selectedPath === 'string' && selectedPath.trim()) { + setPythonPathDraft(selectedPath) + await savePythonPath(selectedPath) + } + } catch { + setPythonPathMessage(t('settings.computerUse.pythonPathDialogFailed')) + } + } + const allReady = status?.supported && status.python.installed && @@ -202,6 +244,12 @@ export function ComputerUseSettings() { const pythonDownloadUrl = status ? PYTHON_DOWNLOAD_URLS[status.platform] ?? 'https://www.python.org/downloads/' : 'https://www.python.org/downloads/' + const pythonPathDirty = pythonPathDraft.trim() !== pythonPathSaved + const pythonDetail = status?.python.installed + ? `${t('settings.computerUse.pythonFound')} — ${status.python.version} (${status.python.path})` + : status?.python.source === 'custom' + ? `${t('settings.computerUse.pythonCustomInvalid')} — ${status.python.path}${status.python.error ? `: ${status.python.error}` : ''}` + : t('settings.computerUse.pythonNotFound') // Filter apps by search query const filteredApps = useMemo(() => { @@ -273,11 +321,7 @@ export function ComputerUseSettings() { +
+ +
+ { + setPythonPathDraft(e.target.value) + setPythonPathMessage(null) + }} + placeholder={t('settings.computerUse.pythonPathPlaceholder')} + className="min-w-[220px] flex-1 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container)] px-3 py-2 font-mono text-xs text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:border-[var(--color-brand)] focus:outline-none" + /> + + + {pythonPathSaved && ( + + )} +
+

+ {pythonPathMessage ?? t('settings.computerUse.pythonPathHint')} +

+
+ {/* macOS Permissions — only shown on macOS (darwin) */} {envReady && status.platform === 'darwin' && ( <> diff --git a/src/server/__tests__/computer-use-api.test.ts b/src/server/__tests__/computer-use-api.test.ts index 12bbb5c6..528ca8a8 100644 --- a/src/server/__tests__/computer-use-api.test.ts +++ b/src/server/__tests__/computer-use-api.test.ts @@ -65,4 +65,21 @@ describe('Computer Use API authorized app config', () => { ) expect(JSON.parse(raw)).toMatchObject({ enabled: false }) }) + + it('persists and normalizes a custom Python interpreter path', async () => { + const pythonPath = ' C:\\Users\\me\\miniconda3\\envs\\cu\\python.exe ' + const putRes = await callAuthorizedApps('PUT', { pythonPath }) + expect(putRes.status).toBe(200) + + const getRes = await callAuthorizedApps('GET') + expect(await getRes.json()).toMatchObject({ + pythonPath: 'C:\\Users\\me\\miniconda3\\envs\\cu\\python.exe', + }) + + const resetRes = await callAuthorizedApps('PUT', { pythonPath: '' }) + expect(resetRes.status).toBe(200) + + const resetGetRes = await callAuthorizedApps('GET') + expect(await resetGetRes.json()).toMatchObject({ pythonPath: null }) + }) }) diff --git a/src/server/__tests__/computer-use-python.test.ts b/src/server/__tests__/computer-use-python.test.ts index 71564d2b..0665c09c 100644 --- a/src/server/__tests__/computer-use-python.test.ts +++ b/src/server/__tests__/computer-use-python.test.ts @@ -24,9 +24,62 @@ describe('detectPythonRuntime', () => { expect(result.command).toBe('python3') expect(result.prefixArgs).toEqual([]) expect(result.source).toBe('system') + expect(result.error).toBeNull() expect(calls).toEqual(['python3 --version', 'where python3']) }) + test('uses a custom Python path before PATH candidates', async () => { + const calls: string[] = [] + const customPython = 'C:\\Users\\me\\miniconda3\\envs\\cu\\python.exe' + const result = await detectPythonRuntime( + 'win32', + async (cmd, args) => { + calls.push(`${cmd} ${args.join(' ')}`.trim()) + if (cmd === customPython && args.join(' ') === '--version') { + return { ok: true, stdout: 'Python 3.11.9', stderr: '', code: 0 } + } + return { ok: false, stdout: '', stderr: '', code: 1 } + }, + undefined, + customPython, + ) + + expect(result.installed).toBe(true) + expect(result.version).toBe('3.11.9') + expect(result.path).toBe(customPython) + expect(result.command).toBe(customPython) + expect(result.prefixArgs).toEqual([]) + expect(result.source).toBe('custom') + expect(result.error).toBeNull() + expect(calls).toEqual([`${customPython} --version`]) + }) + + test('reports an invalid custom Python path without falling back to PATH', async () => { + const calls: string[] = [] + const result = await detectPythonRuntime( + 'win32', + async (cmd, args) => { + calls.push(`${cmd} ${args.join(' ')}`.trim()) + if (cmd === 'C:\\missing\\python.exe') { + return { ok: false, stdout: '', stderr: 'not found', code: 1 } + } + if (cmd === 'python') { + return { ok: true, stdout: 'Python 3.12.0', stderr: '', code: 0 } + } + return { ok: false, stdout: '', stderr: '', code: 1 } + }, + undefined, + 'C:\\missing\\python.exe', + ) + + expect(result.installed).toBe(false) + expect(result.path).toBe('C:\\missing\\python.exe') + expect(result.command).toBe('C:\\missing\\python.exe') + expect(result.source).toBe('custom') + expect(result.error).toBe('not found') + expect(calls).toEqual(['C:\\missing\\python.exe --version']) + }) + test('falls back to py -3 on Windows', async () => { const result = await detectPythonRuntime( 'win32', @@ -50,6 +103,7 @@ describe('detectPythonRuntime', () => { expect(result.command).toBe('py') expect(result.prefixArgs).toEqual(['-3']) expect(result.source).toBe('system') + expect(result.error).toBeNull() }) test('falls back to venv python when system python is not discoverable', async () => { @@ -71,5 +125,6 @@ describe('detectPythonRuntime', () => { expect(result.command).toBe(venvPython) expect(result.prefixArgs).toEqual([]) expect(result.source).toBe('venv') + expect(result.error).toBeNull() }) }) diff --git a/src/server/api/computer-use-python.ts b/src/server/api/computer-use-python.ts index 0c4587dc..6bda5bd4 100644 --- a/src/server/api/computer-use-python.ts +++ b/src/server/api/computer-use-python.ts @@ -25,7 +25,8 @@ export type PythonRuntimeResolution = { path: string | null command: string | null prefixArgs: string[] - source: 'system' | 'venv' | null + source: 'custom' | 'system' | 'venv' | null + error: string | null } function getPythonCandidates(platform: NodeJS.Platform): PythonCandidate[] { @@ -90,7 +91,34 @@ export async function detectPythonRuntime( platform: NodeJS.Platform, runCommand: CommandRunner, venvPythonPath?: string, + customPythonPath?: string | null, ): Promise { + const normalizedCustomPath = customPythonPath?.trim() + if (normalizedCustomPath) { + const customResult = await runCommand(normalizedCustomPath, ['--version']) + if (customResult.ok) { + return { + installed: true, + version: extractPythonVersion(`${customResult.stdout}\n${customResult.stderr}`), + path: normalizedCustomPath, + command: normalizedCustomPath, + prefixArgs: [], + source: 'custom', + error: null, + } + } + + return { + installed: false, + version: null, + path: normalizedCustomPath, + command: normalizedCustomPath, + prefixArgs: [], + source: 'custom', + error: customResult.stderr || customResult.stdout || `Failed to run ${normalizedCustomPath}`, + } + } + for (const candidate of getPythonCandidates(platform)) { const versionResult = await runCommand(candidate.command, [...candidate.prefixArgs, '--version']) if (!versionResult.ok) continue @@ -102,6 +130,7 @@ export async function detectPythonRuntime( command: candidate.command, prefixArgs: candidate.prefixArgs, source: 'system', + error: null, } } @@ -115,6 +144,7 @@ export async function detectPythonRuntime( command: venvPythonPath, prefixArgs: [], source: 'venv', + error: null, } } } @@ -126,6 +156,6 @@ export async function detectPythonRuntime( command: null, prefixArgs: [], source: null, + error: null, } } - diff --git a/src/server/api/computer-use.ts b/src/server/api/computer-use.ts index f2b31111..b59fd90b 100644 --- a/src/server/api/computer-use.ts +++ b/src/server/api/computer-use.ts @@ -18,6 +18,7 @@ import { detectPythonRuntime } from './computer-use-python.js' import { DEFAULT_DESKTOP_GRANT_FLAGS, loadStoredComputerUseConfig, + normalizePythonPath, saveStoredComputerUseConfig, } from '../../utils/computerUse/preauthorizedConfig.js' // Embed helper scripts at compile time so they're available in bundled mode @@ -122,6 +123,8 @@ type EnvStatus = { installed: boolean version: string | null path: string | null + source: 'custom' | 'system' | 'venv' | null + error: string | null } venv: { created: boolean @@ -146,8 +149,14 @@ async function checkStatus(): Promise { ? join(venvRoot, 'Scripts', 'python.exe') : join(venvRoot, 'bin', 'python3') const venvCreated = await pathExists(venvPython) + const config = await loadConfig() - const pythonRuntime = await detectPythonRuntime(platform, runCommand, venvCreated ? venvPython : undefined) + const pythonRuntime = await detectPythonRuntime( + platform, + runCommand, + venvCreated ? venvPython : undefined, + config.pythonPath, + ) // Check dependencies — use the state dir copy const reqPath = getRequirementsPath() @@ -193,6 +202,8 @@ async function checkStatus(): Promise { installed: pythonRuntime.installed, version: pythonRuntime.version, path: pythonRuntime.path, + source: pythonRuntime.source, + error: pythonRuntime.error, }, venv: { created: venvCreated, path: venvRoot }, dependencies: { installed: depsInstalled, requirementsFound: requirementsFound || true }, @@ -212,27 +223,33 @@ async function runSetup(): Promise { ? join(venvRoot, 'Scripts', 'python.exe') : join(venvRoot, 'bin', 'python3') const venvExists = await pathExists(venvPython) + const config = await loadConfig() // Step 1: Check python const pythonRuntime = await detectPythonRuntime( process.platform, runCommand, venvExists ? venvPython : undefined, + config.pythonPath, ) if (!pythonRuntime.installed) { steps.push({ name: 'python_check', ok: false, - message: 'Python 3 未安装,请先安装 Python 3', + message: pythonRuntime.source === 'custom' + ? `自定义 Python 路径不可用: ${pythonRuntime.error ?? pythonRuntime.path}` + : 'Python 3 未安装,请先安装 Python 3', }) return { success: false, steps } } steps.push({ name: 'python_check', ok: true, - message: pythonRuntime.source === 'venv' - ? `Python ${pythonRuntime.version}(使用现有虚拟环境)` - : `Python ${pythonRuntime.version}`, + message: pythonRuntime.source === 'custom' + ? `Python ${pythonRuntime.version}(使用自定义解释器)` + : pythonRuntime.source === 'venv' + ? `Python ${pythonRuntime.version}(使用现有虚拟环境)` + : `Python ${pythonRuntime.version}`, }) // Step 2: Extract runtime files to ~/.claude/.runtime/ @@ -356,6 +373,7 @@ type ComputerUseConfig = { clipboardWrite: boolean systemKeyCombos: boolean } + pythonPath: string | null } type RequestAccessBody = { @@ -367,6 +385,7 @@ const DEFAULT_CONFIG: ComputerUseConfig = { enabled: true, authorizedApps: [], grantFlags: DEFAULT_DESKTOP_GRANT_FLAGS, + pythonPath: null, } async function loadConfig(): Promise { @@ -439,6 +458,7 @@ export async function handleComputerUseApi( if (body.enabled !== undefined) config.enabled = body.enabled if (body.authorizedApps) config.authorizedApps = body.authorizedApps if (body.grantFlags) config.grantFlags = { ...config.grantFlags, ...body.grantFlags } + if ('pythonPath' in body) config.pythonPath = normalizePythonPath(body.pythonPath) await saveConfig(config) return Response.json({ ok: true }) } catch { diff --git a/src/utils/computerUse/preauthorizedConfig.test.ts b/src/utils/computerUse/preauthorizedConfig.test.ts index 6a3e6cf4..2b005e83 100644 --- a/src/utils/computerUse/preauthorizedConfig.test.ts +++ b/src/utils/computerUse/preauthorizedConfig.test.ts @@ -10,6 +10,7 @@ describe('resolveStoredComputerUseConfig', () => { enabled: true, authorizedApps: [], grantFlags: DEFAULT_DESKTOP_GRANT_FLAGS, + pythonPath: null, }) }) @@ -35,6 +36,20 @@ describe('resolveStoredComputerUseConfig', () => { clipboardWrite: true, systemKeyCombos: true, }, + pythonPath: null, + }) + }) + + test('normalizes a stored custom Python interpreter path', () => { + expect( + resolveStoredComputerUseConfig({ + pythonPath: ' C:\\Users\\me\\miniconda3\\envs\\cu\\python.exe ', + }), + ).toMatchObject({ + pythonPath: 'C:\\Users\\me\\miniconda3\\envs\\cu\\python.exe', + }) + expect(resolveStoredComputerUseConfig({ pythonPath: '' })).toMatchObject({ + pythonPath: null, }) }) }) diff --git a/src/utils/computerUse/preauthorizedConfig.ts b/src/utils/computerUse/preauthorizedConfig.ts index 884ca054..7071739b 100644 --- a/src/utils/computerUse/preauthorizedConfig.ts +++ b/src/utils/computerUse/preauthorizedConfig.ts @@ -13,6 +13,7 @@ export type StoredComputerUseConfig = { enabled?: boolean authorizedApps?: StoredAuthorizedApp[] grantFlags?: Partial + pythonPath?: string | null } export const DEFAULT_COMPUTER_USE_ENABLED = true @@ -37,6 +38,7 @@ export function resolveStoredComputerUseConfig( enabled: boolean authorizedApps: StoredAuthorizedApp[] grantFlags: CuGrantFlags + pythonPath: string | null } { return { enabled: config?.enabled ?? DEFAULT_COMPUTER_USE_ENABLED, @@ -45,9 +47,16 @@ export function resolveStoredComputerUseConfig( ...DEFAULT_DESKTOP_GRANT_FLAGS, ...(config?.grantFlags ?? {}), }, + pythonPath: normalizePythonPath(config?.pythonPath), } } +export function normalizePythonPath(value: unknown): string | null { + if (typeof value !== 'string') return null + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : null +} + export async function loadStoredComputerUseConfig(): Promise< ReturnType > { diff --git a/src/utils/computerUse/pythonBridge.ts b/src/utils/computerUse/pythonBridge.ts index fbae5078..8a51174b 100644 --- a/src/utils/computerUse/pythonBridge.ts +++ b/src/utils/computerUse/pythonBridge.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url' import { execFileNoThrow } from '../execFileNoThrow.js' import { logForDebugging } from '../debug.js' import { getClaudeConfigHomeDir } from '../envUtils.js' +import { loadStoredComputerUseConfig } from './preauthorizedConfig.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const projectRoot = path.resolve(__dirname, '../../..') @@ -60,6 +61,12 @@ async function runOrThrow(file: string, args: string[], label: string): Promise< return stdout } +async function getVenvCreationPythonCommand(): Promise { + const config = await loadStoredComputerUseConfig() + if (config.pythonPath) return config.pythonPath + return isWindows ? 'python' : 'python3' +} + /** * Ensure runtime source files exist in ~/.claude/.runtime/. * In dev mode, copies from the project's runtime/ directory on first run. @@ -92,7 +99,7 @@ export async function ensureBootstrapped(): Promise { if (!(await pathExists(pythonBinPath()))) { logForDebugging('creating runtime venv at %s', { level: 'debug' }) - const pythonCmd = isWindows ? 'python' : 'python3' + const pythonCmd = await getVenvCreationPythonCommand() await runOrThrow(pythonCmd, ['-m', 'venv', venvRoot], 'python venv creation') }