Expose the bundled desktop CLI outside the app

The desktop app already shipped a bundled sidecar, but only desktop-managed
sessions could see it. This change installs a `claude-haha` launcher into the
user bin directory, wires PATH setup so new terminals can resolve it, and keeps
desktop installer sessions aligned on the same bundled sidecar resolution path.
The desktop install surface now also reports whether the launcher is ready or
still waiting on a terminal restart.

Constraint: The worktree already contains unrelated icon, docs, and UI changes, so this commit stages only the bundled CLI launcher slice
Rejected: Tell users to install the official Claude CLI separately | it breaks the desktop out-of-box install story
Rejected: Keep the bundled CLI reachable only inside desktop-managed shells | system terminals would still be unable to call the packaged runtime
Rejected: Symlink directly into the app bundle instead of copying to user bin | moving or replacing the app bundle would leave a stale launcher behind
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Proxy-backed non-Anthropic providers still depend on the desktop server; do not assume this launcher makes every provider fully standalone
Tested: bun test src/server/__tests__/desktop-cli-launcher.test.ts src/server/__tests__/settings.test.ts; bun test src/server/__tests__/conversation-service.test.ts; bun test src/utils/shell/bashProvider.test.ts; cd desktop && bun x vitest run sidecars/launcherRouting.test.ts src/components/settings/InstallCenter.test.tsx; cd desktop && bun run lint; cd desktop && bun run build; cargo check --manifest-path desktop/src-tauri/Cargo.toml
Not-tested: Manual packaged desktop app install plus real Terminal/iTerm/PowerShell invocation on fresh macOS and Windows machines
This commit is contained in:
程序员阿江(Relakkes) 2026-04-23 12:12:49 +08:00
parent 90c3db1790
commit 5fea7033e3
18 changed files with 1339 additions and 83 deletions

View File

@ -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<void> {
// / 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 }
}

View File

@ -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'],
})
})
})

View File

@ -0,0 +1,64 @@
import path from 'node:path'
export type SidecarMode = 'server' | 'cli' | 'adapters'
const EXPLICIT_MODES = new Set<SidecarMode>(['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 }
}

View File

@ -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<UserSettings>('/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<CliLauncherStatus>('/api/settings/cli-launcher')
},
}

View File

@ -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<typeof vi.fn>
} = {
sessions: [],
fetchSessions: vi.fn(),
}
const chatStoreState: {
sessions: Record<string, { chatState: 'idle'; pendingComputerUsePermission: { request: unknown } | null }>
connectToSession: ReturnType<typeof vi.fn>
disconnectSession: ReturnType<typeof vi.fn>
sendMessage: ReturnType<typeof vi.fn>
stopGeneration: ReturnType<typeof vi.fn>
} = {
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<string, string> = {
'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<string, string | number>,
) => {
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 }) => (
<div data-testid="message-list">session:{sessionId}</div>
),
}))
vi.mock('../chat/ComputerUsePermissionModal', () => ({
ComputerUsePermissionModal: () => null,
}))
vi.mock('../shared/DirectoryPicker', () => ({
DirectoryPicker: () => <div>Directory picker</div>,
}))
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(<InstallCenter />)
expect(await screen.findByText('claude-haha')).toBeInTheDocument()
expect(screen.getByText('已安装完成;请新开一个终端以加载 PATH 变更。')).toBeInTheDocument()
expect(screen.getByTestId('message-list')).toHaveTextContent('session:installer-1')
})
})

View File

@ -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<CliLauncherStatus | null>(null)
const [isCliLauncherLoading, setIsCliLauncherLoading] = useState(true)
const createPromiseRef = useRef<Promise<string> | 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() {
</div>
</section>
{(isCliLauncherLoading || cliLauncherStatus?.supported) && (
<section className="mt-6 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-[var(--color-text-primary)]">
{t('settings.install.cliTitle')}
</h3>
<p className="text-xs text-[var(--color-text-tertiary)] mt-1">
{t('settings.install.cliDescription')}
</p>
</div>
<div className="rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-1 text-sm font-semibold text-[var(--color-text-primary)]">
{cliLauncherStatus?.command || 'claude-haha'}
</div>
</div>
<div className="mt-4 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-4">
{isCliLauncherLoading ? (
<p className="text-sm text-[var(--color-text-secondary)]">
{t('settings.install.cliLoading')}
</p>
) : cliLauncherStatus ? (
<CliLauncherStatusPanel status={cliLauncherStatus} />
) : (
<p className="text-sm text-[var(--color-danger)]">
{t('settings.install.cliUnavailable')}
</p>
)}
</div>
</section>
)}
<section className="mt-6 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
@ -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 (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-3">
<span
className={`inline-flex rounded-full border px-2.5 py-1 text-xs font-semibold ${statusClassName}`}
>
{statusText}
</span>
<span className="text-xs text-[var(--color-text-tertiary)]">
{t('settings.install.cliLocation')}
</span>
<span className="font-mono text-xs text-[var(--color-text-primary)] break-all">
{status.launcherPath}
</span>
</div>
{status.configTarget && (
<p className="text-xs text-[var(--color-text-tertiary)]">
{t('settings.install.cliConfigTarget', {
target: status.configTarget,
})}
</p>
)}
{status.lastError && (
<p className="text-xs text-[var(--color-danger)]">
{t('settings.install.cliError', {
message: status.lastError,
})}
</p>
)}
</div>
)
}
function SummaryPill({
label,
icon,

View File

@ -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',

View File

@ -309,6 +309,16 @@ export const zh: Record<TranslationKey, string> = {
'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': '执行目录',

View File

@ -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 <plugin-id> --scope <scope>\`
- For a normal plugin installation request, do both:
1. \`claude plugin install <plugin-id> --scope <scope>\`

View File

@ -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')
})
})

View File

@ -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)

View File

@ -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}`)
}

View File

@ -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
}

View File

@ -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]
}
// 旧两段式 sidecarclaude-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 {

View File

@ -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<DesktopCliLauncherStatus> | 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<DesktopCliLauncherStatus> {
if (inFlightEnsure) {
return inFlightEnsure
}
const promise = ensureDesktopCliLauncherInstalledImpl()
inFlightEnsure = promise
try {
return await promise
} finally {
if (inFlightEnsure === promise) {
inFlightEnsure = null
}
}
}
async function ensureDesktopCliLauncherInstalledImpl(): Promise<DesktopCliLauncherStatus> {
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<string>((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()
}

View File

@ -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]
}

View File

@ -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" "$@"')
})
})

View File

@ -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) {