diff --git a/desktop/sidecars/claude-sidecar.ts b/desktop/sidecars/claude-sidecar.ts index db070432..cd0937f0 100644 --- a/desktop/sidecars/claude-sidecar.ts +++ b/desktop/sidecars/claude-sidecar.ts @@ -17,18 +17,21 @@ * launcher-only 参数。 */ +import { parseLauncherArgs, resolveSidecarInvocation } from './launcherRouting' + const rawArgs = process.argv.slice(2) -if (rawArgs.length === 0) { +const invocation = resolveSidecarInvocation(rawArgs) +if (!invocation.mode) { console.error('claude-sidecar: missing mode argument (expected "server", "cli" or "adapters")') process.exit(2) } -const mode = rawArgs[0]! -const restArgs = rawArgs.slice(1) +const mode = invocation.mode +const restArgs = invocation.restArgs if (mode === 'adapters') { await runAdapters(restArgs) } else { - const { appRoot, args } = parseLauncherArgs(restArgs) + const { appRoot, args } = parseLauncherArgs(restArgs, invocation.defaultAppRoot) process.env.CLAUDE_APP_ROOT = appRoot process.env.CALLER_DIR ||= process.cwd() @@ -133,24 +136,3 @@ async function runAdapters(rawArgs: string[]): Promise { // / grammY long-polling)持有 event loop,自然不会退出。这里不需要额外 // setInterval 兜底。两个 adapter 自己注册的 SIGINT handler 都会触发。 } - -function parseLauncherArgs(rawArgs: string[]): { appRoot: string; args: string[] } { - const nextArgs: string[] = [] - let appRoot: string | null = process.env.CLAUDE_APP_ROOT ?? null - - for (let index = 0; index < rawArgs.length; index++) { - const arg = rawArgs[index] - if (arg === '--app-root') { - appRoot = rawArgs[index + 1] ?? null - index += 1 - continue - } - nextArgs.push(arg!) - } - - if (!appRoot) { - throw new Error('Missing --app-root for claude-sidecar') - } - - return { appRoot, args: nextArgs } -} diff --git a/desktop/sidecars/launcherRouting.test.ts b/desktop/sidecars/launcherRouting.test.ts new file mode 100644 index 00000000..84d26637 --- /dev/null +++ b/desktop/sidecars/launcherRouting.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' + +import { parseLauncherArgs, resolveSidecarInvocation } from './launcherRouting' + +describe('resolveSidecarInvocation', () => { + it('keeps explicit sidecar modes unchanged', () => { + expect( + resolveSidecarInvocation( + ['server', '--host', '127.0.0.1'], + '/tmp/claude-sidecar', + ), + ).toEqual({ + mode: 'server', + restArgs: ['--host', '127.0.0.1'], + defaultAppRoot: null, + }) + }) + + it('defaults claude-haha invocations to cli mode', () => { + expect( + resolveSidecarInvocation( + ['plugin', 'install', 'demo'], + '/Users/demo/.local/bin/claude-haha', + ), + ).toEqual({ + mode: 'cli', + restArgs: ['plugin', 'install', 'demo'], + defaultAppRoot: '/Users/demo/.local/bin', + }) + }) +}) + +describe('parseLauncherArgs', () => { + it('falls back to the provided default app root', () => { + expect( + parseLauncherArgs(['plugin', 'install', 'demo'], '/Users/demo/.local/bin'), + ).toEqual({ + appRoot: '/Users/demo/.local/bin', + args: ['plugin', 'install', 'demo'], + }) + }) + + it('lets explicit app root override the default', () => { + expect( + parseLauncherArgs( + ['--app-root', '/tmp/app', 'plugin', 'install', 'demo'], + '/Users/demo/.local/bin', + ), + ).toEqual({ + appRoot: '/tmp/app', + args: ['plugin', 'install', 'demo'], + }) + }) +}) diff --git a/desktop/sidecars/launcherRouting.ts b/desktop/sidecars/launcherRouting.ts new file mode 100644 index 00000000..2af59c6a --- /dev/null +++ b/desktop/sidecars/launcherRouting.ts @@ -0,0 +1,64 @@ +import path from 'node:path' + +export type SidecarMode = 'server' | 'cli' | 'adapters' + +const EXPLICIT_MODES = new Set(['server', 'cli', 'adapters']) +const DESKTOP_CLI_NAMES = new Set(['claude-haha', 'claude-haha.exe']) + +export function resolveSidecarInvocation( + rawArgs: string[], + execPath: string = process.execPath, + envAppRoot: string | null = process.env.CLAUDE_APP_ROOT ?? null, +): { + mode: SidecarMode | null + restArgs: string[] + defaultAppRoot: string | null +} { + const explicitMode = rawArgs[0] + if (explicitMode && EXPLICIT_MODES.has(explicitMode as SidecarMode)) { + return { + mode: explicitMode as SidecarMode, + restArgs: rawArgs.slice(1), + defaultAppRoot: envAppRoot, + } + } + + const execName = path.basename(execPath).toLowerCase() + if (DESKTOP_CLI_NAMES.has(execName)) { + return { + mode: 'cli', + restArgs: rawArgs, + defaultAppRoot: envAppRoot ?? path.dirname(execPath), + } + } + + return { + mode: null, + restArgs: rawArgs, + defaultAppRoot: envAppRoot, + } +} + +export function parseLauncherArgs( + rawArgs: string[], + defaultAppRoot: string | null = process.env.CLAUDE_APP_ROOT ?? null, +): { appRoot: string; args: string[] } { + const nextArgs: string[] = [] + let appRoot: string | null = defaultAppRoot + + for (let index = 0; index < rawArgs.length; index++) { + const arg = rawArgs[index] + if (arg === '--app-root') { + appRoot = rawArgs[index + 1] ?? null + index += 1 + continue + } + nextArgs.push(arg!) + } + + if (!appRoot) { + throw new Error('Missing --app-root for claude-sidecar') + } + + return { appRoot, args: nextArgs } +} diff --git a/desktop/src/api/settings.ts b/desktop/src/api/settings.ts index b3c34d54..4255f943 100644 --- a/desktop/src/api/settings.ts +++ b/desktop/src/api/settings.ts @@ -1,6 +1,20 @@ import { api } from './client' import type { PermissionMode, UserSettings } from '../types/settings' +export type CliLauncherStatus = { + supported: boolean + command: string + installed: boolean + launcherPath: string + binDir: string + pathConfigured: boolean + pathInCurrentShell: boolean + availableInNewTerminals: boolean + needsTerminalRestart: boolean + configTarget: string | null + lastError: string | null +} + export const settingsApi = { getUser() { return api.get('/api/settings/user') @@ -17,4 +31,8 @@ export const settingsApi = { setPermissionMode(mode: PermissionMode) { return api.put<{ ok: true; mode: PermissionMode }>('/api/permissions/mode', { mode }) }, + + getCliLauncherStatus() { + return api.get('/api/settings/cli-launcher') + }, } diff --git a/desktop/src/components/settings/InstallCenter.test.tsx b/desktop/src/components/settings/InstallCenter.test.tsx new file mode 100644 index 00000000..692121f7 --- /dev/null +++ b/desktop/src/components/settings/InstallCenter.test.tsx @@ -0,0 +1,221 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' + +import type { SessionListItem } from '../../types/session' + +const sessionStoreState: { + sessions: SessionListItem[] + fetchSessions: ReturnType +} = { + sessions: [], + fetchSessions: vi.fn(), +} + +const chatStoreState: { + sessions: Record + connectToSession: ReturnType + disconnectSession: ReturnType + sendMessage: ReturnType + stopGeneration: ReturnType +} = { + sessions: {}, + connectToSession: vi.fn(), + disconnectSession: vi.fn(), + sendMessage: vi.fn(), + stopGeneration: vi.fn(), +} + +const pluginStoreState = { + fetchPlugins: vi.fn(), + reloadPlugins: vi.fn(), +} + +const skillStoreState = { + fetchSkills: vi.fn(), +} + +const mcpStoreState = { + fetchServers: vi.fn(), +} + +const uiStoreState = { + addToast: vi.fn(), + setPendingSettingsTab: vi.fn(), +} + +const { settingsApiState } = vi.hoisted(() => ({ + settingsApiState: { + getCliLauncherStatus: vi.fn(), + }, +})) + +vi.mock('../../stores/sessionStore', () => ({ + useSessionStore: (selector: (state: typeof sessionStoreState) => unknown) => + selector(sessionStoreState), +})) + +vi.mock('../../stores/chatStore', () => ({ + useChatStore: (selector: (state: typeof chatStoreState) => unknown) => + selector(chatStoreState), +})) + +vi.mock('../../stores/pluginStore', () => ({ + usePluginStore: (selector: (state: typeof pluginStoreState) => unknown) => + selector(pluginStoreState), +})) + +vi.mock('../../stores/skillStore', () => ({ + useSkillStore: (selector: (state: typeof skillStoreState) => unknown) => + selector(skillStoreState), +})) + +vi.mock('../../stores/mcpStore', () => ({ + useMcpStore: (selector: (state: typeof mcpStoreState) => unknown) => + selector(mcpStoreState), +})) + +vi.mock('../../stores/uiStore', () => ({ + useUIStore: (selector: (state: typeof uiStoreState) => unknown) => + selector(uiStoreState), +})) + +vi.mock('../../api/settings', () => ({ + settingsApi: settingsApiState, +})) + +vi.mock('../../i18n', () => { + const translations: Record = { + 'settings.install.eyebrow': 'AI 安装助手', + 'settings.install.title': '安装中心', + 'settings.install.description': '安装中心描述', + 'settings.install.targets.plugins': 'Plugins', + 'settings.install.targets.mcp': 'MCP', + 'settings.install.targets.skills': 'Skills', + 'settings.install.contextAuto': '默认上下文', + 'settings.install.composeTitle': '自然语言安装', + 'settings.install.composeHint': '安装提示', + 'settings.install.refresh': '刷新安装状态', + 'settings.install.newConversation': '新建安装会话', + 'settings.install.placeholder': '占位符', + 'settings.install.send': '发送安装请求', + 'settings.install.cliTitle': '内置 CLI 命令', + 'settings.install.cliDescription': 'CLI 描述', + 'settings.install.cliLoading': '正在检查内置 CLI launcher 状态…', + 'settings.install.cliReady': '当前终端已可直接使用', + 'settings.install.cliNeedsRestart': '已安装完成;请新开一个终端以加载 PATH 变更。', + 'settings.install.cliPathMissing': 'launcher 已安装,但 PATH 仍未完全就绪。', + 'settings.install.cliUnavailable': '内置 CLI launcher 还没有准备好。', + 'settings.install.cliLocation': 'CLI 路径', + 'settings.install.cliConfigTarget': 'PATH 集成目标:{target}', + 'settings.install.cliError': '内置 CLI 配置告警:{message}', + 'settings.install.contextDefault': '默认目录提示', + 'settings.install.contextUsing': '当前安装会话目录:{path}', + 'settings.install.contextTitle': '执行目录', + 'settings.install.contextHint': '执行目录提示', + 'settings.install.goPlugins': '查看插件', + 'settings.install.goMcp': '查看 MCP', + 'settings.install.goSkills': '查看技能', + 'settings.install.sessionTitle': '安装助手会话', + 'settings.install.sessionHint': '会话提示', + 'settings.install.sessionEmpty': '还没有安装会话', + 'settings.install.sessionEmptyHint': '空会话提示', + 'settings.install.clearConversation': '清除对话', + 'settings.install.clearConversationReady': '已清除当前安装对话;下一条请求会启动新的安装上下文。', + 'settings.install.newConversationReady': '已准备新的安装会话;下一条请求会启动新的安装上下文。', + } + + return { + useTranslation: () => ( + key: string, + params?: Record, + ) => { + let text = translations[key] ?? key + if (params) { + for (const [name, value] of Object.entries(params)) { + text = text.replace(new RegExp(`\\{${name}\\}`, 'g'), String(value)) + } + } + return text + }, + } +}) + +vi.mock('../chat/MessageList', () => ({ + MessageList: ({ sessionId }: { sessionId: string }) => ( +
session:{sessionId}
+ ), +})) + +vi.mock('../chat/ComputerUsePermissionModal', () => ({ + ComputerUsePermissionModal: () => null, +})) + +vi.mock('../shared/DirectoryPicker', () => ({ + DirectoryPicker: () =>
Directory picker
, +})) + +import { InstallCenter } from './InstallCenter' + +describe('InstallCenter', () => { + beforeEach(() => { + vi.clearAllMocks() + window.localStorage.clear() + + sessionStoreState.sessions = [ + { + id: 'installer-1', + title: '安装助手会话', + createdAt: '2026-04-23T00:00:00.000Z', + modifiedAt: '2026-04-23T00:00:00.000Z', + messageCount: 2, + projectPath: '', + workDir: '/Users/nanmi', + workDirExists: true, + }, + ] + sessionStoreState.fetchSessions = vi.fn() + + chatStoreState.sessions = { + 'installer-1': { + chatState: 'idle', + pendingComputerUsePermission: null, + }, + } + chatStoreState.connectToSession = vi.fn() + chatStoreState.disconnectSession = vi.fn() + chatStoreState.sendMessage = vi.fn() + chatStoreState.stopGeneration = vi.fn() + + pluginStoreState.fetchPlugins = vi.fn() + pluginStoreState.reloadPlugins = vi.fn() + skillStoreState.fetchSkills = vi.fn() + mcpStoreState.fetchServers = vi.fn() + uiStoreState.addToast = vi.fn() + uiStoreState.setPendingSettingsTab = vi.fn() + settingsApiState.getCliLauncherStatus = vi.fn().mockResolvedValue({ + supported: true, + command: 'claude-haha', + installed: true, + launcherPath: '/Users/nanmi/.local/bin/claude-haha', + binDir: '/Users/nanmi/.local/bin', + pathConfigured: true, + pathInCurrentShell: false, + availableInNewTerminals: true, + needsTerminalRestart: true, + configTarget: '/Users/nanmi/.zshrc', + lastError: null, + }) + + window.localStorage.setItem('cc-haha-installer-session-id', 'installer-1') + window.localStorage.setItem('cc-haha-installer-context-dir', '/Users/nanmi') + }) + + it('shows bundled cli launcher status', async () => { + render() + + expect(await screen.findByText('claude-haha')).toBeInTheDocument() + expect(screen.getByText('已安装完成;请新开一个终端以加载 PATH 变更。')).toBeInTheDocument() + expect(screen.getByTestId('message-list')).toHaveTextContent('session:installer-1') + }) +}) diff --git a/desktop/src/components/settings/InstallCenter.tsx b/desktop/src/components/settings/InstallCenter.tsx index 9a81aaa3..49cf97db 100644 --- a/desktop/src/components/settings/InstallCenter.tsx +++ b/desktop/src/components/settings/InstallCenter.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { sessionsApi } from '../../api/sessions' +import { settingsApi, type CliLauncherStatus } from '../../api/settings' import { useChatStore } from '../../stores/chatStore' import { useMcpStore } from '../../stores/mcpStore' import { usePluginStore } from '../../stores/pluginStore' @@ -65,6 +66,8 @@ export function InstallCenter() { const [contextDir, setContextDir] = useState(() => readStoredValue(INSTALLER_CONTEXT_KEY)) const [draft, setDraft] = useState('') const [isCreating, setIsCreating] = useState(false) + const [cliLauncherStatus, setCliLauncherStatus] = useState(null) + const [isCliLauncherLoading, setIsCliLauncherLoading] = useState(true) const createPromiseRef = useRef | null>(null) const previousChatStateRef = useRef<'idle' | 'thinking' | 'tool_executing' | 'streaming' | 'permission_pending'>('idle') @@ -92,6 +95,32 @@ export function InstallCenter() { writeStoredValue(INSTALLER_CONTEXT_KEY, contextDir.trim()) }, [contextDir]) + useEffect(() => { + let cancelled = false + + void (async () => { + setIsCliLauncherLoading(true) + try { + const status = await settingsApi.getCliLauncherStatus() + if (!cancelled) { + setCliLauncherStatus(status) + } + } catch { + if (!cancelled) { + setCliLauncherStatus(null) + } + } finally { + if (!cancelled) { + setIsCliLauncherLoading(false) + } + } + })() + + return () => { + cancelled = true + } + }, []) + useEffect(() => { if (!sessionId) return const previousState = previousChatStateRef.current @@ -178,11 +207,15 @@ export function InstallCenter() { const handleRefresh = async () => { const cwd = installerSession?.workDir || undefined - await Promise.all([ + const [launcherStatus] = await Promise.all([ + settingsApi.getCliLauncherStatus().catch(() => null), fetchPlugins(cwd), fetchSkills(cwd), fetchServers(cwd ? [cwd] : undefined, cwd), ]) + if (launcherStatus) { + setCliLauncherStatus(launcherStatus) + } addToast({ type: 'success', message: t('settings.install.refreshDone'), @@ -244,6 +277,38 @@ export function InstallCenter() { + {(isCliLauncherLoading || cliLauncherStatus?.supported) && ( +
+
+
+

+ {t('settings.install.cliTitle')} +

+

+ {t('settings.install.cliDescription')} +

+
+
+ {cliLauncherStatus?.command || 'claude-haha'} +
+
+ +
+ {isCliLauncherLoading ? ( +

+ {t('settings.install.cliLoading')} +

+ ) : cliLauncherStatus ? ( + + ) : ( +

+ {t('settings.install.cliUnavailable')} +

+ )} +
+
+ )} +
@@ -415,6 +480,60 @@ export function InstallCenter() { ) } +function CliLauncherStatusPanel({ status }: { status: CliLauncherStatus }) { + const t = useTranslation() + + let statusText = t('settings.install.cliUnavailable') + let statusClassName = 'border-[var(--color-danger)]/25 bg-[var(--color-danger)]/10 text-[var(--color-danger)]' + + if (status.installed && status.availableInNewTerminals) { + if (status.needsTerminalRestart) { + statusText = t('settings.install.cliNeedsRestart') + statusClassName = 'border-[var(--color-warning)]/25 bg-[var(--color-warning)]/10 text-[var(--color-warning)]' + } else { + statusText = t('settings.install.cliReady') + statusClassName = 'border-[var(--color-success)]/25 bg-[var(--color-success)]/10 text-[var(--color-success)]' + } + } else if (status.installed) { + statusText = t('settings.install.cliPathMissing') + statusClassName = 'border-[var(--color-warning)]/25 bg-[var(--color-warning)]/10 text-[var(--color-warning)]' + } + + return ( +
+
+ + {statusText} + + + {t('settings.install.cliLocation')} + + + {status.launcherPath} + +
+ + {status.configTarget && ( +

+ {t('settings.install.cliConfigTarget', { + target: status.configTarget, + })} +

+ )} + + {status.lastError && ( +

+ {t('settings.install.cliError', { + message: status.lastError, + })} +

+ )} +
+ ) +} + function SummaryPill({ label, icon, diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 027c8ce4..c6424e52 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -307,6 +307,16 @@ export const en = { 'settings.install.refresh': 'Refresh state', 'settings.install.newConversation': 'New install chat', 'settings.install.clearConversation': 'Clear chat', + 'settings.install.cliTitle': 'Bundled CLI command', + 'settings.install.cliDescription': 'The desktop app installs a terminal command named `claude-haha` so system shells can use the bundled CLI even when the official Claude CLI is not installed.', + 'settings.install.cliLoading': 'Checking bundled CLI launcher status…', + 'settings.install.cliReady': 'Ready in current terminals', + 'settings.install.cliNeedsRestart': 'Installed. Open a new terminal to pick up PATH changes.', + 'settings.install.cliPathMissing': 'Installed, but PATH still needs attention.', + 'settings.install.cliUnavailable': 'Bundled CLI launcher is not ready yet.', + 'settings.install.cliLocation': 'CLI path', + 'settings.install.cliConfigTarget': 'PATH integration target: {target}', + 'settings.install.cliError': 'Bundled CLI setup warning: {message}', 'settings.install.contextDefault': 'If no directory is selected, the installer session starts from your home directory.', 'settings.install.contextUsing': 'Installer session directory: {path}', 'settings.install.contextTitle': 'Execution directory', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 65ecc6e7..370d36a1 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -309,6 +309,16 @@ export const zh: Record = { 'settings.install.refresh': '刷新安装状态', 'settings.install.newConversation': '新建安装会话', 'settings.install.clearConversation': '清除对话', + 'settings.install.cliTitle': '内置 CLI 命令', + 'settings.install.cliDescription': '桌面端会把打包进去的 CLI 暴露成系统终端命令 `claude-haha`,即使用户没有安装官方 Claude CLI 也能直接调用。', + 'settings.install.cliLoading': '正在检查内置 CLI launcher 状态…', + 'settings.install.cliReady': '当前终端已可直接使用', + 'settings.install.cliNeedsRestart': '已安装完成;请新开一个终端以加载 PATH 变更。', + 'settings.install.cliPathMissing': 'launcher 已安装,但 PATH 仍未完全就绪。', + 'settings.install.cliUnavailable': '内置 CLI launcher 还没有准备好。', + 'settings.install.cliLocation': 'CLI 路径', + 'settings.install.cliConfigTarget': 'PATH 集成目标:{target}', + 'settings.install.cliError': '内置 CLI 配置告警:{message}', 'settings.install.contextDefault': '未指定目录时默认使用 Home 目录启动安装会话。', 'settings.install.contextUsing': '当前安装会话目录:{path}', 'settings.install.contextTitle': '执行目录', diff --git a/desktop/src/lib/installAssistantPrompt.ts b/desktop/src/lib/installAssistantPrompt.ts index 0d717711..1f372a0d 100644 --- a/desktop/src/lib/installAssistantPrompt.ts +++ b/desktop/src/lib/installAssistantPrompt.ts @@ -16,6 +16,8 @@ Execution rules: 8. Keep unrelated workspace files untouched. Command guidance: +- In Claude Code Haha Desktop installer sessions, the Bash shell already exposes a \`claude\` command wired to the bundled desktop CLI. Prefer that and do not spend time checking whether a separate global Claude CLI is installed. +- Outside the desktop app, the bundled CLI is exposed to system terminals as \`claude-haha\`. Inside installer sessions, continue to use the injected \`claude\` command. - Plugin install: run Bash with \`claude plugin install --scope \` - For a normal plugin installation request, do both: 1. \`claude plugin install --scope \` diff --git a/src/server/__tests__/desktop-cli-launcher.test.ts b/src/server/__tests__/desktop-cli-launcher.test.ts new file mode 100644 index 00000000..5be027b7 --- /dev/null +++ b/src/server/__tests__/desktop-cli-launcher.test.ts @@ -0,0 +1,100 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' + +import { ensureDesktopCliLauncherInstalled } from '../services/desktopCliLauncherService.js' + +const isWindows = process.platform === 'win32' +const unixOnly = isWindows ? it.skip : it + +const ORIGINAL_HOME = process.env.HOME +const ORIGINAL_USERPROFILE = process.env.USERPROFILE +const ORIGINAL_SHELL = process.env.SHELL +const ORIGINAL_PATH = process.env.PATH +const ORIGINAL_CLAUDE_CLI_PATH = process.env.CLAUDE_CLI_PATH + +describe('ensureDesktopCliLauncherInstalled', () => { + let tempHome = '' + let tempSourceDir = '' + + beforeEach(async () => { + tempHome = await mkdtemp(join(tmpdir(), 'desktop-cli-home-')) + tempSourceDir = await mkdtemp(join(tmpdir(), 'desktop-cli-source-')) + process.env.HOME = tempHome + process.env.USERPROFILE = tempHome + process.env.SHELL = '/bin/zsh' + process.env.PATH = '' + }) + + afterEach(async () => { + if (ORIGINAL_HOME === undefined) { + delete process.env.HOME + } else { + process.env.HOME = ORIGINAL_HOME + } + + if (ORIGINAL_USERPROFILE === undefined) { + delete process.env.USERPROFILE + } else { + process.env.USERPROFILE = ORIGINAL_USERPROFILE + } + + if (ORIGINAL_SHELL === undefined) { + delete process.env.SHELL + } else { + process.env.SHELL = ORIGINAL_SHELL + } + + if (ORIGINAL_PATH === undefined) { + delete process.env.PATH + } else { + process.env.PATH = ORIGINAL_PATH + } + + if (ORIGINAL_CLAUDE_CLI_PATH === undefined) { + delete process.env.CLAUDE_CLI_PATH + } else { + process.env.CLAUDE_CLI_PATH = ORIGINAL_CLAUDE_CLI_PATH + } + + await rm(tempHome, { recursive: true, force: true }) + await rm(tempSourceDir, { recursive: true, force: true }) + }) + + unixOnly('copies the bundled sidecar into the user bin dir and configures PATH', async () => { + const sourcePath = join(tempSourceDir, 'claude-sidecar') + await writeFile(sourcePath, '#!/bin/sh\necho desktop-sidecar\n', 'utf8') + await chmod(sourcePath, 0o755) + process.env.CLAUDE_CLI_PATH = sourcePath + + const status = await ensureDesktopCliLauncherInstalled() + const launcherPath = join(tempHome, '.local', 'bin', 'claude-haha') + const shellConfigPath = join(tempHome, '.zshrc') + + expect(status.supported).toBe(true) + expect(status.installed).toBe(true) + expect(status.command).toBe('claude-haha') + expect(status.launcherPath).toBe(launcherPath) + expect(status.availableInNewTerminals).toBe(true) + expect(status.needsTerminalRestart).toBe(true) + expect(status.configTarget).toBe(shellConfigPath) + + expect(await readFile(launcherPath, 'utf8')).toContain('desktop-sidecar') + expect(await readFile(shellConfigPath, 'utf8')).toContain( + 'export PATH="$HOME/.local/bin:$PATH"', + ) + }) + + it('reports unsupported status when the current launcher is not a bundled sidecar', async () => { + const sourcePath = join(tempSourceDir, 'claude') + await writeFile(sourcePath, '#!/bin/sh\necho plain-cli\n', 'utf8') + process.env.CLAUDE_CLI_PATH = sourcePath + + const status = await ensureDesktopCliLauncherInstalled() + + expect(status.supported).toBe(false) + expect(status.installed).toBe(false) + expect(status.command).toBe('claude-haha') + }) +}) diff --git a/src/server/__tests__/settings.test.ts b/src/server/__tests__/settings.test.ts index f1a521c3..f776ab58 100644 --- a/src/server/__tests__/settings.test.ts +++ b/src/server/__tests__/settings.test.ts @@ -16,11 +16,25 @@ import { ProviderService } from '../services/providerService.js' let tmpDir: string let originalConfigDir: string | undefined +let originalHome: string | undefined +let originalUserProfile: string | undefined +let originalShell: string | undefined +let originalPath: string | undefined +let originalCliPath: string | undefined async function setup() { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-test-')) originalConfigDir = process.env.CLAUDE_CONFIG_DIR + originalHome = process.env.HOME + originalUserProfile = process.env.USERPROFILE + originalShell = process.env.SHELL + originalPath = process.env.PATH + originalCliPath = process.env.CLAUDE_CLI_PATH process.env.CLAUDE_CONFIG_DIR = tmpDir + process.env.HOME = tmpDir + process.env.USERPROFILE = tmpDir + process.env.SHELL = '/bin/zsh' + process.env.PATH = '' } async function teardown() { @@ -29,6 +43,37 @@ async function teardown() { } else { delete process.env.CLAUDE_CONFIG_DIR } + + if (originalHome !== undefined) { + process.env.HOME = originalHome + } else { + delete process.env.HOME + } + + if (originalUserProfile !== undefined) { + process.env.USERPROFILE = originalUserProfile + } else { + delete process.env.USERPROFILE + } + + if (originalShell !== undefined) { + process.env.SHELL = originalShell + } else { + delete process.env.SHELL + } + + if (originalPath !== undefined) { + process.env.PATH = originalPath + } else { + delete process.env.PATH + } + + if (originalCliPath !== undefined) { + process.env.CLAUDE_CLI_PATH = originalCliPath + } else { + delete process.env.CLAUDE_CLI_PATH + } + await fs.rm(tmpDir, { recursive: true, force: true }) } @@ -203,6 +248,26 @@ describe('Settings API', () => { expect(body2.model).toBe('claude-opus-4-7') }) + it('GET /api/settings/cli-launcher should expose bundled launcher status', async () => { + if (process.platform === 'win32') return + + const sidecarPath = path.join(tmpDir, 'claude-sidecar') + await fs.writeFile(sidecarPath, '#!/bin/sh\necho desktop-sidecar\n', { + encoding: 'utf8', + mode: 0o755, + }) + process.env.CLAUDE_CLI_PATH = sidecarPath + + const { req, url, segments } = makeRequest('GET', '/api/settings/cli-launcher') + const res = await handleSettingsApi(req, url, segments) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.command).toBe('claude-haha') + expect(body.installed).toBe(true) + expect(body.availableInNewTerminals).toBe(true) + }) + it('GET /api/permissions/mode should return default mode', async () => { const { req, url, segments } = makeRequest('GET', '/api/permissions/mode') const res = await handleSettingsApi(req, url, segments) diff --git a/src/server/api/settings.ts b/src/server/api/settings.ts index 0463185d..26b90d97 100644 --- a/src/server/api/settings.ts +++ b/src/server/api/settings.ts @@ -12,6 +12,7 @@ import { SettingsService } from '../services/settingsService.js' import { ApiError, errorResponse } from '../middleware/errorHandler.js' +import { ensureDesktopCliLauncherInstalled } from '../services/desktopCliLauncherService.js' const settingsService = new SettingsService() @@ -47,6 +48,10 @@ export async function handleSettingsApi( case 'project': return await handleProjectSettings(req, url) + case 'cli-launcher': + if (method !== 'GET') throw methodNotAllowed(method) + return Response.json(await ensureDesktopCliLauncherInstalled()) + default: throw ApiError.notFound(`Unknown settings endpoint: ${sub}`) } diff --git a/src/server/index.ts b/src/server/index.ts index a81120de..784ccde9 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -14,6 +14,7 @@ import { cronScheduler } from './services/cronScheduler.js' import { handleProxyRequest } from './proxy/handler.js' import { ProviderService } from './services/providerService.js' import { handleHahaOAuthCallback } from './api/haha-oauth.js' +import { ensureDesktopCliLauncherInstalled } from './services/desktopCliLauncherService.js' function readArgValue(flag: string): string | undefined { const args = process.argv.slice(2) @@ -218,6 +219,13 @@ export function startServer(port = PORT, host = HOST) { // Start the cron scheduler to execute scheduled tasks cronScheduler.start() + void ensureDesktopCliLauncherInstalled().catch((error) => { + console.error( + '[desktop-cli-launcher] failed to install bundled launcher:', + error instanceof Error ? error.message : error, + ) + }) + console.log(`[Server] Claude Code API server running at http://${host}:${port}`) return server } diff --git a/src/server/services/conversationService.ts b/src/server/services/conversationService.ts index e3cd80aa..6874be2e 100644 --- a/src/server/services/conversationService.ts +++ b/src/server/services/conversationService.ts @@ -10,6 +10,10 @@ import * as fs from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' import { sessionService } from './sessionService.js' +import { + buildClaudeCliArgs, + resolveClaudeCliLauncher, +} from '../../utils/desktopBundledCli.js' type AttachmentRef = { type: 'file' | 'image' @@ -655,35 +659,13 @@ export class ConversationService { } } - private resolveBundledCliPath(): string | null { - // 桌面端 P0+P2 之后只有一个合并的 sidecar 二进制 —— `claude-sidecar`, - // 它通过第一个 positional 参数 (server / cli) 选模式。当前进程要么 - // 已经是这个 sidecar 自己(spawn 子 CLI 时复用同一个文件),要么是 - // 旧 dev 模式下走 bin/claude-haha。这里支持两种命名: - // - 桌面端 prod build:进程名 claude-sidecar* - // - 旧 server-only 二进制(向后兼容):claude-server* - const execPath = process.execPath - const execName = path.basename(execPath) - - if (execName.startsWith('claude-sidecar')) { - // 复用同一个二进制,调用 cli 模式 - return execPath - } - - if (execName.startsWith('claude-server')) { - const bundledCliPath = path.join( - path.dirname(execPath), - execName.replace(/^claude-server/, 'claude-cli'), - ) - return fs.existsSync(bundledCliPath) ? bundledCliPath : null - } - - return null - } - private resolveCliArgs(baseArgs: string[]): string[] { - const cliCommand = process.env.CLAUDE_CLI_PATH || this.resolveBundledCliPath() - if (!cliCommand) { + const launcher = resolveClaudeCliLauncher({ + cliPath: process.env.CLAUDE_CLI_PATH, + execPath: process.execPath, + }) + + if (!launcher) { if (process.platform === 'win32') { return [ process.execPath, @@ -694,35 +676,7 @@ export class ConversationService { return [path.resolve(import.meta.dir, '../../../bin/claude-haha'), ...baseArgs] } - if (/\.(?:[cm]?[jt]s|tsx?)$/i.test(cliCommand)) { - return ['bun', cliCommand, ...baseArgs] - } - - const cliBaseName = path.basename(cliCommand) - - // 合并 sidecar 模式:第一个参数必须是 'cli',后面跟 --app-root 透传 - if (cliBaseName.startsWith('claude-sidecar')) { - const args = ['cli', ...baseArgs] - if (process.env.CLAUDE_APP_ROOT) { - return [cliCommand, 'cli', '--app-root', process.env.CLAUDE_APP_ROOT, ...baseArgs] - } - return [cliCommand, ...args] - } - - // 旧两段式 sidecar:claude-cli 二进制需要 --app-root - if ( - process.env.CLAUDE_APP_ROOT && - cliBaseName.startsWith('claude-cli') - ) { - return [ - cliCommand, - '--app-root', - process.env.CLAUDE_APP_ROOT, - ...baseArgs, - ] - } - - return [cliCommand, ...baseArgs] + return buildClaudeCliArgs(launcher, baseArgs, process.env.CLAUDE_APP_ROOT) } private clearStaleLock(sessionId: string): boolean { diff --git a/src/server/services/desktopCliLauncherService.ts b/src/server/services/desktopCliLauncherService.ts new file mode 100644 index 00000000..8e1a9b99 --- /dev/null +++ b/src/server/services/desktopCliLauncherService.ts @@ -0,0 +1,476 @@ +import { createHash } from 'node:crypto' +import { createReadStream } from 'node:fs' +import { + chmod, + copyFile, + mkdir, + readFile, + rename, + stat, + unlink, + writeFile, +} from 'node:fs/promises' +import { homedir } from 'node:os' +import { delimiter, dirname, join, resolve } from 'node:path' + +import { resolveClaudeCliLauncher } from '../../utils/desktopBundledCli.js' +import { execFileNoThrow } from '../../utils/execFileNoThrow.js' +import { getShellConfigPaths } from '../../utils/shellConfig.js' +import { getUserBinDir } from '../../utils/xdg.js' + +const DESKTOP_CLI_NAME = 'claude-haha' +const PATH_BLOCK_START = '# >>> Claude Code Haha PATH >>>' +const PATH_BLOCK_END = '# <<< Claude Code Haha PATH <<<' +const WINDOWS_PATH_TARGET = 'Windows User PATH' +const WINDOWS_USER_BIN_EXPR = '%USERPROFILE%\\.local\\bin' + +export type DesktopCliLauncherStatus = { + supported: boolean + command: string + installed: boolean + launcherPath: string + binDir: string + pathConfigured: boolean + pathInCurrentShell: boolean + availableInNewTerminals: boolean + needsTerminalRestart: boolean + configTarget: string | null + lastError: string | null +} + +let inFlightEnsure: Promise | null = null + +export function getDesktopCliCommandName( + platform: NodeJS.Platform = process.platform, +) { + return platform === 'win32' ? `${DESKTOP_CLI_NAME}.exe` : DESKTOP_CLI_NAME +} + +export function resolveHomeDir(env: NodeJS.ProcessEnv = process.env) { + return env.HOME || env.USERPROFILE || homedir() +} + +export function isPathEntryPresent( + pathValue: string | undefined, + targetDir: string, + platform: NodeJS.Platform = process.platform, + homeDir: string = resolveHomeDir(), +) { + if (!pathValue) return false + + if (platform === 'win32') { + const normalizedTarget = normalizeWindowsPathEntry(targetDir, homeDir) + return pathValue + .split(';') + .map((entry) => normalizeWindowsPathEntry(entry, homeDir)) + .some((entry) => entry === normalizedTarget) + } + + const normalizedTarget = resolve(targetDir) + return pathValue + .split(delimiter) + .map((entry) => entry.trim()) + .filter(Boolean) + .some((entry) => { + try { + return resolve(entry) === normalizedTarget + } catch { + return false + } + }) +} + +export function upsertManagedPathBlock( + existingContent: string, + block: string, +): string { + const escapedStart = PATH_BLOCK_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const escapedEnd = PATH_BLOCK_END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const pattern = new RegExp(`${escapedStart}[\\s\\S]*?${escapedEnd}\\n?`, 'm') + const nextBlock = `${block.trimEnd()}\n` + + if (pattern.test(existingContent)) { + return existingContent.replace(pattern, nextBlock) + } + + const trimmed = existingContent.trimEnd() + if (!trimmed) { + return nextBlock + } + + return `${trimmed}\n\n${nextBlock}` +} + +export function buildManagedPathBlock( + shellType: 'zsh' | 'bash' | 'fish', + binDir: string, + homeDir: string = resolveHomeDir(), +) { + const defaultBinDir = join(homeDir, '.local', 'bin') + const pathExpr = resolve(binDir) === resolve(defaultBinDir) ? '$HOME/.local/bin' : binDir + + if (shellType === 'fish') { + return [ + PATH_BLOCK_START, + `if not contains "${pathExpr}" $PATH`, + ` set -gx PATH "${pathExpr}" $PATH`, + 'end', + PATH_BLOCK_END, + ].join('\n') + } + + return [ + PATH_BLOCK_START, + `export PATH="${pathExpr}:$PATH"`, + PATH_BLOCK_END, + ].join('\n') +} + +export async function ensureDesktopCliLauncherInstalled(): Promise { + if (inFlightEnsure) { + return inFlightEnsure + } + + const promise = ensureDesktopCliLauncherInstalledImpl() + inFlightEnsure = promise + + try { + return await promise + } finally { + if (inFlightEnsure === promise) { + inFlightEnsure = null + } + } +} + +async function ensureDesktopCliLauncherInstalledImpl(): Promise { + const homeDir = resolveHomeDir() + const binDir = getUserBinDir({ homedir: homeDir }) + const launcherPath = join(binDir, getDesktopCliCommandName()) + const sourcePath = resolveBundledSidecarSourcePath() + + if (!sourcePath) { + return buildStatus({ + supported: false, + launcherPath, + binDir, + command: DESKTOP_CLI_NAME, + installed: false, + pathConfigured: false, + pathInCurrentShell: isPathEntryPresent(process.env.PATH, binDir), + configTarget: null, + lastError: null, + }) + } + + let lastError: string | null = null + + try { + await syncLauncherBinary(sourcePath, launcherPath) + } catch (error) { + lastError = error instanceof Error ? error.message : String(error) + } + + const installed = await isUsableLauncher(launcherPath) + const currentPathReady = isPathEntryPresent(process.env.PATH, binDir) + + let pathConfigured = currentPathReady + let configTarget: string | null = null + + try { + if (process.platform === 'win32') { + const windowsResult = await ensureWindowsUserPathConfigured(binDir, homeDir) + pathConfigured = currentPathReady || windowsResult.pathConfigured + configTarget = windowsResult.configTarget + lastError ||= windowsResult.lastError + } else { + const unixResult = await ensureUnixShellPathConfigured(binDir, homeDir) + pathConfigured = currentPathReady || unixResult.pathConfigured + configTarget = unixResult.configTarget + lastError ||= unixResult.lastError + } + } catch (error) { + lastError ||= error instanceof Error ? error.message : String(error) + } + + return buildStatus({ + supported: true, + command: DESKTOP_CLI_NAME, + launcherPath, + binDir, + installed, + pathConfigured, + pathInCurrentShell: currentPathReady, + configTarget, + lastError, + }) +} + +function buildStatus( + input: Omit< + DesktopCliLauncherStatus, + 'availableInNewTerminals' | 'needsTerminalRestart' + >, +): DesktopCliLauncherStatus { + const availableInNewTerminals = + input.installed && (input.pathInCurrentShell || input.pathConfigured) + + return { + ...input, + availableInNewTerminals, + needsTerminalRestart: + availableInNewTerminals && !input.pathInCurrentShell, + } +} + +function resolveBundledSidecarSourcePath(): string | null { + const launcher = resolveClaudeCliLauncher({ + cliPath: process.env.CLAUDE_CLI_PATH, + execPath: process.execPath, + }) + + if (!launcher || launcher.kind !== 'sidecar') { + return null + } + + return launcher.command +} + +async function syncLauncherBinary(sourcePath: string, targetPath: string) { + await mkdir(dirname(targetPath), { recursive: true }) + + if (await filesMatch(sourcePath, targetPath)) { + return + } + + const tempPath = `${targetPath}.tmp.${process.pid}.${Date.now()}` + await copyFile(sourcePath, tempPath) + + if (process.platform !== 'win32') { + await chmod(tempPath, 0o755) + } + + try { + if (process.platform === 'win32') { + await replaceWindowsBinary(tempPath, targetPath) + } else { + await rename(tempPath, targetPath) + await chmod(targetPath, 0o755) + } + } finally { + await unlink(tempPath).catch(() => undefined) + } +} + +async function replaceWindowsBinary(tempPath: string, targetPath: string) { + try { + await unlink(targetPath) + } catch { + // noop + } + + try { + await rename(tempPath, targetPath) + return + } catch { + // The existing executable may still be in use. Rename it away and retry. + } + + const backupPath = `${targetPath}.old.${Date.now()}` + try { + await rename(targetPath, backupPath) + } catch { + // noop + } + + await rename(tempPath, targetPath) + await unlink(backupPath).catch(() => undefined) +} + +async function filesMatch(sourcePath: string, targetPath: string) { + try { + const [sourceStats, targetStats] = await Promise.all([ + stat(sourcePath), + stat(targetPath), + ]) + + if (!sourceStats.isFile() || !targetStats.isFile()) { + return false + } + + if (sourceStats.size !== targetStats.size) { + return false + } + + const [sourceHash, targetHash] = await Promise.all([ + hashFile(sourcePath), + hashFile(targetPath), + ]) + return sourceHash === targetHash + } catch { + return false + } +} + +async function hashFile(filePath: string) { + return await new Promise((resolvePromise, reject) => { + const hash = createHash('sha256') + const stream = createReadStream(filePath) + stream.on('data', (chunk) => hash.update(chunk)) + stream.on('error', reject) + stream.on('end', () => resolvePromise(hash.digest('hex'))) + }) +} + +async function isUsableLauncher(filePath: string) { + try { + const fileStats = await stat(filePath) + return fileStats.isFile() && fileStats.size > 0 + } catch { + return false + } +} + +async function ensureUnixShellPathConfigured( + binDir: string, + homeDir: string, +): Promise<{ + pathConfigured: boolean + configTarget: string | null + lastError: string | null +}> { + const shellType = resolveShellType() + const configPaths = getShellConfigPaths({ env: process.env, homedir: homeDir }) + const configTarget = + configPaths[shellType] ?? + (process.platform === 'darwin' ? configPaths.zsh : configPaths.bash) + + if (!configTarget) { + return { + pathConfigured: false, + configTarget: null, + lastError: 'Could not resolve a shell config file for PATH setup', + } + } + + const block = buildManagedPathBlock(shellType, binDir, homeDir) + const existingContent = await readFile(configTarget, 'utf8').catch(() => '') + const nextContent = upsertManagedPathBlock(existingContent, block) + + if (nextContent !== existingContent) { + await mkdir(dirname(configTarget), { recursive: true }) + await writeFile(configTarget, nextContent, 'utf8') + } + + return { + pathConfigured: true, + configTarget, + lastError: null, + } +} + +async function ensureWindowsUserPathConfigured( + binDir: string, + homeDir: string, +): Promise<{ + pathConfigured: boolean + configTarget: string + lastError: string | null +}> { + const userPath = await readWindowsUserPath() + if (isPathEntryPresent(userPath, binDir, 'win32', homeDir)) { + return { + pathConfigured: true, + configTarget: WINDOWS_PATH_TARGET, + lastError: null, + } + } + + const script = [ + `$bin = [Environment]::ExpandEnvironmentVariables('${WINDOWS_USER_BIN_EXPR}')`, + `$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')`, + `$segments = @()`, + `if ($userPath) { $segments = $userPath.Split(';') | Where-Object { $_ -and $_.Trim() -ne '' } }`, + `$normalized = $segments | ForEach-Object { [Environment]::ExpandEnvironmentVariables($_).TrimEnd('\\').ToLowerInvariant() }`, + `if (-not ($normalized -contains $bin.TrimEnd('\\').ToLowerInvariant())) {`, + ` $newPath = if ([string]::IsNullOrWhiteSpace($userPath)) { '${WINDOWS_USER_BIN_EXPR}' } else { '${WINDOWS_USER_BIN_EXPR};' + $userPath }`, + ` [Environment]::SetEnvironmentVariable('Path', $newPath, 'User')`, + ` $signature = @'`, + `using System;`, + `using System.Runtime.InteropServices;`, + `public static class NativeMethods {`, + ` [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Auto)]`, + ` public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint Msg, IntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out IntPtr lpdwResult);`, + `}`, + `'@`, + ` Add-Type -TypeDefinition $signature -ErrorAction SilentlyContinue | Out-Null`, + ` $HWND_BROADCAST = [IntPtr]0xffff`, + ` $WM_SETTINGCHANGE = 0x1A`, + ` $SMTO_ABORTIFHUNG = 0x2`, + ` $result = [IntPtr]::Zero`, + ` [void][NativeMethods]::SendMessageTimeout($HWND_BROADCAST, $WM_SETTINGCHANGE, [IntPtr]::Zero, 'Environment', $SMTO_ABORTIFHUNG, 5000, [ref]$result)`, + `}`, + ].join('\n') + + const result = await execFileNoThrow( + 'powershell.exe', + ['-NoProfile', '-NonInteractive', '-Command', script], + { useCwd: false }, + ) + + if (result.code !== 0) { + return { + pathConfigured: false, + configTarget: WINDOWS_PATH_TARGET, + lastError: + result.stderr.trim() || + result.stdout.trim() || + result.error || + 'Failed to update Windows user PATH', + } + } + + return { + pathConfigured: true, + configTarget: WINDOWS_PATH_TARGET, + lastError: null, + } +} + +function resolveShellType(): 'zsh' | 'bash' | 'fish' { + const shellPath = process.env.SHELL || '' + if (shellPath.includes('fish')) return 'fish' + if (shellPath.includes('bash')) return 'bash' + if (shellPath.includes('zsh')) return 'zsh' + return process.platform === 'darwin' ? 'zsh' : 'bash' +} + +async function readWindowsUserPath() { + const result = await execFileNoThrow( + 'powershell.exe', + [ + '-NoProfile', + '-NonInteractive', + '-Command', + `[Environment]::GetEnvironmentVariable('Path', 'User')`, + ], + { useCwd: false }, + ) + + if (result.code !== 0) { + return '' + } + + return result.stdout.trim() +} + +function normalizeWindowsPathEntry(entry: string, homeDir: string) { + return entry + .trim() + .replace(/^"+|"+$/g, '') + .replace(/%USERPROFILE%/gi, homeDir) + .replace(/%HOMEDRIVE%%HOMEPATH%/gi, homeDir) + .replace(/\//g, '\\') + .replace(/\\+$/, '') + .toLowerCase() +} diff --git a/src/utils/desktopBundledCli.ts b/src/utils/desktopBundledCli.ts new file mode 100644 index 00000000..0a462ac3 --- /dev/null +++ b/src/utils/desktopBundledCli.ts @@ -0,0 +1,93 @@ +import * as fs from 'node:fs' +import * as path from 'node:path' + +export type ClaudeCliLauncher = { + command: string + kind: 'script' | 'sidecar' | 'binary' + requiresAppRoot: boolean +} + +export function resolveBundledCliPathFromExecPath( + execPath: string = process.execPath, +): string | null { + const execName = path.basename(execPath) + + if (execName.startsWith('claude-sidecar')) { + return execPath + } + + if (execName.startsWith('claude-server')) { + const bundledCliPath = path.join( + path.dirname(execPath), + execName.replace(/^claude-server/, 'claude-cli'), + ) + return fs.existsSync(bundledCliPath) ? bundledCliPath : null + } + + return null +} + +export function resolveClaudeCliLauncher(options?: { + cliPath?: string | null + execPath?: string +}): ClaudeCliLauncher | null { + const command = + options?.cliPath || resolveBundledCliPathFromExecPath(options?.execPath) + + if (!command) { + return null + } + + if (/\.(?:[cm]?[jt]s|tsx?)$/i.test(command)) { + return { + command, + kind: 'script', + requiresAppRoot: false, + } + } + + const cliBaseName = path.basename(command) + if (cliBaseName.startsWith('claude-sidecar')) { + return { + command, + kind: 'sidecar', + requiresAppRoot: true, + } + } + + if (cliBaseName.startsWith('claude-cli')) { + return { + command, + kind: 'binary', + requiresAppRoot: true, + } + } + + return { + command, + kind: 'binary', + requiresAppRoot: false, + } +} + +export function buildClaudeCliArgs( + launcher: ClaudeCliLauncher, + baseArgs: string[], + appRoot: string | undefined = process.env.CLAUDE_APP_ROOT, +): string[] { + if (launcher.kind === 'script') { + return ['bun', launcher.command, ...baseArgs] + } + + if (launcher.kind === 'sidecar') { + return appRoot + ? [launcher.command, 'cli', '--app-root', appRoot, ...baseArgs] + : [launcher.command, 'cli', ...baseArgs] + } + + if (launcher.requiresAppRoot && appRoot) { + return [launcher.command, '--app-root', appRoot, ...baseArgs] + } + + return [launcher.command, ...baseArgs] +} diff --git a/src/utils/shell/bashProvider.test.ts b/src/utils/shell/bashProvider.test.ts new file mode 100644 index 00000000..8fed95ae --- /dev/null +++ b/src/utils/shell/bashProvider.test.ts @@ -0,0 +1,41 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { createBashShellProvider } from './bashProvider.js' + +const ORIGINAL_CLAUDE_CLI_PATH = process.env.CLAUDE_CLI_PATH +const ORIGINAL_CLAUDE_APP_ROOT = process.env.CLAUDE_APP_ROOT + +afterEach(() => { + if (ORIGINAL_CLAUDE_CLI_PATH === undefined) { + delete process.env.CLAUDE_CLI_PATH + } else { + process.env.CLAUDE_CLI_PATH = ORIGINAL_CLAUDE_CLI_PATH + } + + if (ORIGINAL_CLAUDE_APP_ROOT === undefined) { + delete process.env.CLAUDE_APP_ROOT + } else { + process.env.CLAUDE_APP_ROOT = ORIGINAL_CLAUDE_APP_ROOT + } +}) + +describe('createBashShellProvider', () => { + test('injects a bundled claude wrapper for desktop sidecars', async () => { + process.env.CLAUDE_CLI_PATH = '/tmp/claude-sidecar' + process.env.CLAUDE_APP_ROOT = '/tmp/claude-desktop-app' + + const provider = await createBashShellProvider('/bin/bash', { + skipSnapshot: true, + }) + + const { commandString } = await provider.buildExecCommand( + 'claude plugin install demo@claude-plugins-official --scope user', + { + id: 'wrapper-test', + useSandbox: false, + }, + ) + + expect(commandString).toContain('claude() {') + expect(commandString).toContain('/tmp/claude-sidecar cli --app-root "$CLAUDE_APP_ROOT" "$@"') + }) +}) diff --git a/src/utils/shell/bashProvider.ts b/src/utils/shell/bashProvider.ts index 2881711a..4db50e69 100644 --- a/src/utils/shell/bashProvider.ts +++ b/src/utils/shell/bashProvider.ts @@ -22,6 +22,7 @@ import { hasTmuxToolBeenUsed, } from '../tmuxSocket.js' import { windowsPathToPosixPath } from '../windowsPaths.js' +import { resolveClaudeCliLauncher } from '../desktopBundledCli.js' import type { ShellProvider } from './shellProvider.js' /** @@ -55,6 +56,34 @@ function getDisableExtglobCommand(shellPath: string): string | null { return null } +function buildBundledClaudeShellWrapper(): string | null { + const launcher = resolveClaudeCliLauncher({ + cliPath: process.env.CLAUDE_CLI_PATH, + execPath: process.execPath, + }) + + if (!launcher) { + return null + } + + const quotedCommand = quote([launcher.command]) + let invoke = '' + + if (launcher.kind === 'script') { + invoke = `command bun ${quotedCommand} "$@"` + } else if (launcher.kind === 'sidecar') { + invoke = launcher.requiresAppRoot + ? `if [ -z "$CLAUDE_APP_ROOT" ]; then echo "bundled claude wrapper requires CLAUDE_APP_ROOT" >&2; return 1; fi; ${quotedCommand} cli --app-root "$CLAUDE_APP_ROOT" "$@"` + : `${quotedCommand} cli "$@"` + } else if (launcher.requiresAppRoot) { + invoke = `if [ -n "$CLAUDE_APP_ROOT" ]; then ${quotedCommand} --app-root "$CLAUDE_APP_ROOT" "$@"; else ${quotedCommand} "$@"; fi` + } else { + invoke = `${quotedCommand} "$@"` + } + + return `claude() { ${invoke}; }` +} + export async function createBashShellProvider( shellPath: string, options?: { skipSnapshot?: boolean }, @@ -172,6 +201,11 @@ export async function createBashShellProvider( commandParts.push(sessionEnvScript) } + const bundledClaudeWrapper = buildBundledClaudeShellWrapper() + if (bundledClaudeWrapper) { + commandParts.push(bundledClaudeWrapper) + } + // Disable extended glob patterns for security (after sourcing user config to override) const disableExtglobCmd = getDisableExtglobCommand(shellPath) if (disableExtglobCmd) {