mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
90c3db1790
commit
5fea7033e3
@ -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 }
|
||||
}
|
||||
|
||||
54
desktop/sidecars/launcherRouting.test.ts
Normal file
54
desktop/sidecars/launcherRouting.test.ts
Normal 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'],
|
||||
})
|
||||
})
|
||||
})
|
||||
64
desktop/sidecars/launcherRouting.ts
Normal file
64
desktop/sidecars/launcherRouting.ts
Normal 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 }
|
||||
}
|
||||
@ -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')
|
||||
},
|
||||
}
|
||||
|
||||
221
desktop/src/components/settings/InstallCenter.test.tsx
Normal file
221
desktop/src/components/settings/InstallCenter.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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': '执行目录',
|
||||
|
||||
@ -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>\`
|
||||
|
||||
100
src/server/__tests__/desktop-cli-launcher.test.ts
Normal file
100
src/server/__tests__/desktop-cli-launcher.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
|
||||
@ -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}`)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
476
src/server/services/desktopCliLauncherService.ts
Normal file
476
src/server/services/desktopCliLauncherService.ts
Normal 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()
|
||||
}
|
||||
93
src/utils/desktopBundledCli.ts
Normal file
93
src/utils/desktopBundledCli.ts
Normal 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]
|
||||
}
|
||||
41
src/utils/shell/bashProvider.test.ts
Normal file
41
src/utils/shell/bashProvider.test.ts
Normal 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" "$@"')
|
||||
})
|
||||
})
|
||||
@ -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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user