diff --git a/src/cli/update.ts b/src/cli/update.ts index 437201fd..05e46560 100644 --- a/src/cli/update.ts +++ b/src/cli/update.ts @@ -400,12 +400,12 @@ export async function update() { if (useLocalUpdate) { process.stderr.write('Try manually updating with:\n') process.stderr.write( - ` cd ~/.claude/local && npm update ${MACRO.PACKAGE_URL}\n`, + ` cd ~/.openclaude/local && npm update ${MACRO.PACKAGE_URL}\n`, ) } else { process.stderr.write('Try running with sudo or fix npm permissions\n') process.stderr.write( - 'Or consider using native installation with: claude install\n', + 'Or consider using native installation with: openclaude install\n', ) } await gracefulShutdown(1) @@ -415,11 +415,11 @@ export async function update() { if (useLocalUpdate) { process.stderr.write('Try manually updating with:\n') process.stderr.write( - ` cd ~/.claude/local && npm update ${MACRO.PACKAGE_URL}\n`, + ` cd ~/.openclaude/local && npm update ${MACRO.PACKAGE_URL}\n`, ) } else { process.stderr.write( - 'Or consider using native installation with: claude install\n', + 'Or consider using native installation with: openclaude install\n', ) } await gracefulShutdown(1) diff --git a/src/commands/install.tsx b/src/commands/install.tsx index ed787041..0ea7a84a 100644 --- a/src/commands/install.tsx +++ b/src/commands/install.tsx @@ -39,16 +39,16 @@ type InstallState = { message: string; warnings?: string[]; }; -function getInstallationPath(): string { +export function getInstallationPath(): string { const isWindows = env.platform === 'win32'; const homeDir = homedir(); if (isWindows) { // Convert to Windows-style path - const windowsPath = join(homeDir, '.local', 'bin', 'claude.exe'); + const windowsPath = join(homeDir, '.local', 'bin', 'openclaude.exe'); // Replace forward slashes with backslashes for Windows display return windowsPath.replace(/\//g, '\\'); } - return '~/.local/bin/claude'; + return '~/.local/bin/openclaude'; } function SetupNotes(t0) { const $ = _c(5); diff --git a/src/commands/sandbox-toggle/sandbox-toggle.tsx b/src/commands/sandbox-toggle/sandbox-toggle.tsx index dc70b194..f3bb7d3c 100644 --- a/src/commands/sandbox-toggle/sandbox-toggle.tsx +++ b/src/commands/sandbox-toggle/sandbox-toggle.tsx @@ -65,7 +65,7 @@ export async function call(onDone: (result?: string) => void, _context: unknown, // Get the local settings path and make it relative to cwd const localSettingsPath = getSettingsFilePathForSource('localSettings'); - const relativePath = localSettingsPath ? relative(getCwdState(), localSettingsPath) : '.claude/settings.local.json'; + const relativePath = localSettingsPath ? relative(getCwdState(), localSettingsPath) : '.openclaude/settings.local.json'; const message = color('success', themeName)(`Added "${cleanPattern}" to excluded commands in ${relativePath}`); onDone(message); return null; diff --git a/src/components/AutoUpdater.tsx b/src/components/AutoUpdater.tsx index dfbc81a6..4cf46357 100644 --- a/src/components/AutoUpdater.tsx +++ b/src/components/AutoUpdater.tsx @@ -188,9 +188,9 @@ export function AutoUpdater({ ✓ Update installed · Restart to apply } {(autoUpdaterResult?.status === 'install_failed' || autoUpdaterResult?.status === 'no_permissions') && - ✗ Auto-update failed · Try claude doctor or{' '} + ✗ Auto-update failed · Try openclaude doctor or{' '} - {hasLocalInstall ? `cd ~/.claude/local && npm update ${MACRO.PACKAGE_URL}` : `npm i -g ${MACRO.PACKAGE_URL}`} + {hasLocalInstall ? `cd ~/.openclaude/local && npm update ${MACRO.PACKAGE_URL}` : `npm i -g ${MACRO.PACKAGE_URL}`} } ; diff --git a/src/components/permissions/rules/AddPermissionRules.tsx b/src/components/permissions/rules/AddPermissionRules.tsx index c8dda7c7..c4788529 100644 --- a/src/components/permissions/rules/AddPermissionRules.tsx +++ b/src/components/permissions/rules/AddPermissionRules.tsx @@ -32,7 +32,7 @@ export function optionForPermissionSaveDestination(saveDestination: EditableSett case 'userSettings': return { label: 'User settings', - description: `Saved in at ~/.claude/settings.json`, + description: `Saved in ~/.openclaude/settings.json`, value: saveDestination }; } diff --git a/src/ink/termio/osc.test.ts b/src/ink/termio/osc.test.ts index 7f4f4917..d4cfc622 100644 --- a/src/ink/termio/osc.test.ts +++ b/src/ink/termio/osc.test.ts @@ -13,6 +13,7 @@ const execFileNoThrowMock = mock( mock.module('../../utils/execFileNoThrow.js', () => ({ execFileNoThrow: execFileNoThrowMock, + execFileNoThrowWithCwd: execFileNoThrowMock, })) mock.module('../../utils/tempfile.js', () => ({ diff --git a/src/services/api/codexShim.test.ts b/src/services/api/codexShim.test.ts index b21c8bca..7a0ba1f5 100644 --- a/src/services/api/codexShim.test.ts +++ b/src/services/api/codexShim.test.ts @@ -465,6 +465,37 @@ describe('Codex request translation', () => { ]) }) + test('strips leaked reasoning preamble from completed Codex text responses', () => { + const message = convertCodexResponseToAnthropicMessage( + { + id: 'resp_1', + model: 'gpt-5.4', + output: [ + { + type: 'message', + role: 'assistant', + content: [ + { + type: 'output_text', + text: + 'The user just said "hey" - a simple greeting. I should respond briefly and friendly.\n\nHey! How can I help you today?', + }, + ], + }, + ], + usage: { input_tokens: 12, output_tokens: 4 }, + }, + 'gpt-5.4', + ) + + expect(message.content).toEqual([ + { + type: 'text', + text: 'Hey! How can I help you today?', + }, + ]) + }) + test('translates Codex SSE text stream into Anthropic events', async () => { const responseText = [ 'event: response.output_item.added', @@ -495,4 +526,44 @@ describe('Codex request translation', () => { 'message_stop', ]) }) + + test('strips leaked reasoning preamble from Codex SSE text stream', async () => { + const responseText = [ + 'event: response.output_item.added', + 'data: {"type":"response.output_item.added","item":{"id":"msg_1","type":"message","status":"in_progress","content":[],"role":"assistant"},"output_index":0,"sequence_number":0}', + '', + 'event: response.content_part.added', + 'data: {"type":"response.content_part.added","content_index":0,"item_id":"msg_1","output_index":0,"part":{"type":"output_text","text":""},"sequence_number":1}', + '', + 'event: response.output_text.delta', + 'data: {"type":"response.output_text.delta","content_index":0,"delta":"The user just said \\"hey\\" - a simple greeting. I should respond briefly and friendly.\\n\\nHey! How can I help you today?","item_id":"msg_1","output_index":0,"sequence_number":2}', + '', + 'event: response.output_item.done', + 'data: {"type":"response.output_item.done","item":{"id":"msg_1","type":"message","status":"completed","content":[{"type":"output_text","text":"The user just said \\"hey\\" - a simple greeting. I should respond briefly and friendly.\\n\\nHey! How can I help you today?"}],"role":"assistant"},"output_index":0,"sequence_number":3}', + '', + 'event: response.completed', + 'data: {"type":"response.completed","response":{"id":"resp_1","status":"completed","model":"gpt-5.4","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"The user just said \\"hey\\" - a simple greeting. I should respond briefly and friendly.\\n\\nHey! How can I help you today?"}]}],"usage":{"input_tokens":2,"output_tokens":1}},"sequence_number":4}', + '', + ].join('\n') + + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(responseText)) + controller.close() + }, + }) + + const textDeltas: string[] = [] + for await (const event of codexStreamToAnthropic( + new Response(stream), + 'gpt-5.4', + )) { + const delta = (event as { delta?: { type?: string; text?: string } }).delta + if (delta?.type === 'text_delta' && typeof delta.text === 'string') { + textDeltas.push(delta.text) + } + } + + expect(textDeltas).toEqual(['Hey! How can I help you today?']) + }) }) diff --git a/src/services/api/codexShim.ts b/src/services/api/codexShim.ts index 27ec6f2a..4b7260e7 100644 --- a/src/services/api/codexShim.ts +++ b/src/services/api/codexShim.ts @@ -4,6 +4,11 @@ import type { ResolvedProviderRequest, } from './providerConfig.js' import { sanitizeSchemaForOpenAICompat } from './openaiSchemaSanitizer.js' +import { + looksLikeLeakedReasoningPrefix, + shouldBufferPotentialReasoningPrefix, + stripLeakedReasoningPreamble, +} from './reasoningLeakSanitizer.js' export interface AnthropicUsage { input_tokens: number @@ -678,17 +683,34 @@ export async function* codexStreamToAnthropic( { index: number; toolUseId: string } >() let activeTextBlockIndex: number | null = null + let activeTextBuffer = '' + let textBufferMode: 'none' | 'pending' | 'strip' = 'none' let nextContentBlockIndex = 0 let sawToolUse = false let finalResponse: Record | undefined const closeActiveTextBlock = async function* () { if (activeTextBlockIndex === null) return + if (textBufferMode !== 'none') { + const sanitized = stripLeakedReasoningPreamble(activeTextBuffer) + if (sanitized) { + yield { + type: 'content_block_delta', + index: activeTextBlockIndex, + delta: { + type: 'text_delta', + text: sanitized, + }, + } + } + } yield { type: 'content_block_stop', index: activeTextBlockIndex, } activeTextBlockIndex = null + activeTextBuffer = '' + textBufferMode = 'none' } const startTextBlockIfNeeded = async function* () { @@ -764,7 +786,36 @@ export async function* codexStreamToAnthropic( if (event.event === 'response.output_text.delta') { yield* startTextBlockIfNeeded() + activeTextBuffer += payload.delta ?? '' if (activeTextBlockIndex !== null) { + if ( + textBufferMode === 'strip' || + looksLikeLeakedReasoningPrefix(activeTextBuffer) + ) { + textBufferMode = 'strip' + continue + } + + if (textBufferMode === 'pending') { + if (shouldBufferPotentialReasoningPrefix(activeTextBuffer)) { + continue + } + yield { + type: 'content_block_delta', + index: activeTextBlockIndex, + delta: { + type: 'text_delta', + text: activeTextBuffer, + }, + } + textBufferMode = 'none' + continue + } + + if (shouldBufferPotentialReasoningPrefix(activeTextBuffer)) { + textBufferMode = 'pending' + continue + } yield { type: 'content_block_delta', index: activeTextBlockIndex, @@ -859,7 +910,7 @@ export function convertCodexResponseToAnthropicMessage( if (part?.type === 'output_text') { content.push({ type: 'text', - text: part.text ?? '', + text: stripLeakedReasoningPreamble(part.text ?? ''), }) } } diff --git a/src/services/api/openaiShim.test.ts b/src/services/api/openaiShim.test.ts index 8eab605d..db0c9c2e 100644 --- a/src/services/api/openaiShim.test.ts +++ b/src/services/api/openaiShim.test.ts @@ -1946,7 +1946,7 @@ test('coalesces consecutive assistant messages preserving tool_calls (issue #202 expect(assistantMsgs?.[0]?.tool_calls?.length).toBeGreaterThan(0) }) -test('non-streaming: reasoning_content emitted as thinking block, used as text when content is null', async () => { +test('non-streaming: reasoning_content emitted as thinking block only when content is null', async () => { globalThis.fetch = (async (_input, _init) => { return new Response( JSON.stringify({ @@ -1988,7 +1988,6 @@ test('non-streaming: reasoning_content emitted as thinking block, used as text w expect(result.content).toEqual([ { type: 'thinking', thinking: 'Let me think about this step by step.' }, - { type: 'text', text: 'Let me think about this step by step.' }, ]) }) @@ -2034,7 +2033,6 @@ test('non-streaming: empty string content does not fall through to reasoning_con expect(result.content).toEqual([ { type: 'thinking', thinking: 'Chain of thought here.' }, - { type: 'text', text: 'Chain of thought here.' }, ]) }) @@ -2084,6 +2082,46 @@ test('non-streaming: real content takes precedence over reasoning_content', asyn ]) }) +test('non-streaming: strips leaked reasoning preamble from assistant content', async () => { + globalThis.fetch = (async () => { + return new Response( + JSON.stringify({ + id: 'chatcmpl-1', + model: 'gpt-5-mini', + choices: [ + { + message: { + role: 'assistant', + content: + 'The user just said "hey" - a simple greeting. I should respond briefly and friendly.\n\nHey! How can I help you today?', + }, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 10, + completion_tokens: 20, + total_tokens: 30, + }, + }), + { headers: { 'Content-Type': 'application/json' } }, + ) + }) as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + const result = (await client.beta.messages.create({ + model: 'gpt-5-mini', + system: 'test system', + messages: [{ role: 'user', content: 'hey' }], + max_tokens: 64, + stream: false, + })) as { content: Array> } + + expect(result.content).toEqual([ + { type: 'text', text: 'Hey! How can I help you today?' }, + ]) +}) + test('streaming: thinking block closed before tool call', async () => { globalThis.fetch = (async (_input, _init) => { const chunks = makeStreamChunks([ @@ -2175,3 +2213,134 @@ test('streaming: thinking block closed before tool call', async () => { } expect(thinkingStart?.content_block?.type).toBe('thinking') }) + +test('streaming: strips leaked reasoning preamble from assistant content deltas', async () => { + globalThis.fetch = (async () => { + const chunks = makeStreamChunks([ + { + id: 'chatcmpl-1', + object: 'chat.completion.chunk', + model: 'gpt-5-mini', + choices: [ + { + index: 0, + delta: { + role: 'assistant', + content: + 'The user just said "hey" - a simple greeting. I should respond briefly and friendly.\n\nHey! How can I help you today?', + }, + finish_reason: null, + }, + ], + }, + { + id: 'chatcmpl-1', + object: 'chat.completion.chunk', + model: 'gpt-5-mini', + choices: [ + { + index: 0, + delta: {}, + finish_reason: 'stop', + }, + ], + }, + ]) + + return makeSseResponse(chunks) + }) as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + const result = await client.beta.messages + .create({ + model: 'gpt-5-mini', + system: 'test system', + messages: [{ role: 'user', content: 'hey' }], + max_tokens: 64, + stream: true, + }) + .withResponse() + + const textDeltas: string[] = [] + for await (const event of result.data) { + const delta = (event as { delta?: { type?: string; text?: string } }).delta + if (delta?.type === 'text_delta' && typeof delta.text === 'string') { + textDeltas.push(delta.text) + } + } + + expect(textDeltas).toEqual(['Hey! How can I help you today?']) +}) + +test('streaming: strips leaked reasoning preamble when split across multiple content chunks', async () => { + globalThis.fetch = (async () => { + const chunks = makeStreamChunks([ + { + id: 'chatcmpl-1', + object: 'chat.completion.chunk', + model: 'gpt-5-mini', + choices: [ + { + index: 0, + delta: { + role: 'assistant', + content: 'The user said "hey" - this is a simple greeting. ', + }, + finish_reason: null, + }, + ], + }, + { + id: 'chatcmpl-1', + object: 'chat.completion.chunk', + model: 'gpt-5-mini', + choices: [ + { + index: 0, + delta: { + content: + 'I should respond in a friendly, concise way.\n\nHey! How can I help you today?', + }, + finish_reason: null, + }, + ], + }, + { + id: 'chatcmpl-1', + object: 'chat.completion.chunk', + model: 'gpt-5-mini', + choices: [ + { + index: 0, + delta: {}, + finish_reason: 'stop', + }, + ], + }, + ]) + + return makeSseResponse(chunks) + }) as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + + const result = await client.beta.messages + .create({ + model: 'gpt-5-mini', + system: 'test system', + messages: [{ role: 'user', content: 'hey' }], + max_tokens: 64, + stream: true, + }) + .withResponse() + + const textDeltas: string[] = [] + for await (const event of result.data) { + const delta = (event as { delta?: { type?: string; text?: string } }).delta + if (delta?.type === 'text_delta' && typeof delta.text === 'string') { + textDeltas.push(delta.text) + } + } + + expect(textDeltas).toEqual(['Hey! How can I help you today?']) +}) diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index 32c85287..c72b8c24 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -26,6 +26,11 @@ import { isEnvTruthy } from '../../utils/envUtils.js' import { resolveGeminiCredential } from '../../utils/geminiAuth.js' import { hydrateGeminiAccessTokenFromSecureStorage } from '../../utils/geminiCredentials.js' import { hydrateGithubModelsTokenFromSecureStorage } from '../../utils/githubModelsCredentials.js' +import { + looksLikeLeakedReasoningPrefix, + shouldBufferPotentialReasoningPrefix, + stripLeakedReasoningPreamble, +} from './reasoningLeakSanitizer.js' import { codexStreamToAnthropic, collectCodexCompletedResponse, @@ -588,6 +593,8 @@ async function* openaiStreamToAnthropic( let hasEmittedContentStart = false let hasEmittedThinkingStart = false let hasClosedThinking = false + let activeTextBuffer = '' + let textBufferMode: 'none' | 'pending' | 'strip' = 'none' let lastStopReason: 'tool_use' | 'max_tokens' | 'end_turn' | null = null let hasEmittedFinalUsage = false let hasProcessedFinishReason = false @@ -618,6 +625,30 @@ async function* openaiStreamToAnthropic( const decoder = new TextDecoder() let buffer = '' + const closeActiveContentBlock = async function* () { + if (!hasEmittedContentStart) return + + if (textBufferMode !== 'none') { + const sanitized = stripLeakedReasoningPreamble(activeTextBuffer) + if (sanitized) { + yield { + type: 'content_block_delta', + index: contentBlockIndex, + delta: { type: 'text_delta', text: sanitized }, + } + } + } + + yield { + type: 'content_block_stop', + index: contentBlockIndex, + } + contentBlockIndex++ + hasEmittedContentStart = false + activeTextBuffer = '' + textBufferMode = 'none' + } + try { while (true) { const { done, value } = await reader.read() @@ -672,6 +703,7 @@ async function* openaiStreamToAnthropic( contentBlockIndex++ hasClosedThinking = true } + activeTextBuffer += delta.content if (!hasEmittedContentStart) { yield { type: 'content_block_start', @@ -680,6 +712,35 @@ async function* openaiStreamToAnthropic( } hasEmittedContentStart = true } + + if ( + textBufferMode === 'strip' || + looksLikeLeakedReasoningPrefix(activeTextBuffer) + ) { + textBufferMode = 'strip' + continue + } + + if (textBufferMode === 'pending') { + if (shouldBufferPotentialReasoningPrefix(activeTextBuffer)) { + continue + } + yield { + type: 'content_block_delta', + index: contentBlockIndex, + delta: { + type: 'text_delta', + text: activeTextBuffer, + }, + } + textBufferMode = 'none' + continue + } + + if (shouldBufferPotentialReasoningPrefix(activeTextBuffer)) { + textBufferMode = 'pending' + continue + } yield { type: 'content_block_delta', index: contentBlockIndex, @@ -698,12 +759,7 @@ async function* openaiStreamToAnthropic( hasClosedThinking = true } if (hasEmittedContentStart) { - yield { - type: 'content_block_stop', - index: contentBlockIndex, - } - contentBlockIndex++ - hasEmittedContentStart = false + yield* closeActiveContentBlock() } const toolBlockIndex = contentBlockIndex @@ -786,10 +842,7 @@ async function* openaiStreamToAnthropic( } // Close any open content blocks if (hasEmittedContentStart) { - yield { - type: 'content_block_stop', - index: contentBlockIndex, - } + yield* closeActiveContentBlock() } // Close active tool calls for (const [, tc] of activeToolCalls) { @@ -1383,9 +1436,9 @@ class OpenAIShimMessages { const choice = data.choices?.[0] const content: Array> = [] - // Some reasoning models (e.g. GLM-5) put their reply in reasoning_content - // while content stays null — emit reasoning as a thinking block, then - // fall back to it for visible text if content is empty. + // Some reasoning models (e.g. GLM-5) put their chain-of-thought in + // reasoning_content while content stays null. Preserve it as a thinking + // block, but do not surface it as visible assistant text. const reasoningText = choice?.message?.reasoning_content if (typeof reasoningText === 'string' && reasoningText) { content.push({ type: 'thinking', thinking: reasoningText }) @@ -1393,9 +1446,12 @@ class OpenAIShimMessages { const rawContent = choice?.message?.content !== '' && choice?.message?.content != null ? choice?.message?.content - : choice?.message?.reasoning_content + : null if (typeof rawContent === 'string' && rawContent) { - content.push({ type: 'text', text: rawContent }) + content.push({ + type: 'text', + text: stripLeakedReasoningPreamble(rawContent), + }) } else if (Array.isArray(rawContent) && rawContent.length > 0) { const parts: string[] = [] for (const part of rawContent) { @@ -1410,7 +1466,10 @@ class OpenAIShimMessages { } const joined = parts.join('\n') if (joined) { - content.push({ type: 'text', text: joined }) + content.push({ + type: 'text', + text: stripLeakedReasoningPreamble(joined), + }) } } diff --git a/src/services/api/reasoningLeakSanitizer.test.ts b/src/services/api/reasoningLeakSanitizer.test.ts new file mode 100644 index 00000000..e89e5a2e --- /dev/null +++ b/src/services/api/reasoningLeakSanitizer.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from 'bun:test' + +import { + looksLikeLeakedReasoningPrefix, + shouldBufferPotentialReasoningPrefix, + stripLeakedReasoningPreamble, +} from './reasoningLeakSanitizer.ts' + +describe('reasoning leak sanitizer', () => { + test('strips explicit internal reasoning preambles', () => { + const text = + 'The user just said "hey" - a simple greeting. I should respond briefly and friendly.\n\nHey! How can I help you today?' + + expect(looksLikeLeakedReasoningPrefix(text)).toBe(true) + expect(stripLeakedReasoningPreamble(text)).toBe( + 'Hey! How can I help you today?', + ) + }) + + test('does not strip normal user-facing advice that mentions "the user should"', () => { + const text = + 'The user should reset their password immediately.\n\nHere are the steps...' + + expect(looksLikeLeakedReasoningPrefix(text)).toBe(false) + expect(shouldBufferPotentialReasoningPrefix(text)).toBe(false) + expect(stripLeakedReasoningPreamble(text)).toBe(text) + }) + + test('does not strip legitimate first-person advice about responding to an incident', () => { + const text = + 'I need to respond to this security incident immediately. The system is compromised.\n\nHere are the remediation steps...' + + expect(looksLikeLeakedReasoningPrefix(text)).toBe(false) + expect(shouldBufferPotentialReasoningPrefix(text)).toBe(false) + expect(stripLeakedReasoningPreamble(text)).toBe(text) + }) + + test('does not strip legitimate first-person advice about answering a support ticket', () => { + const text = + 'I need to answer the support ticket before end of day. The customer is waiting.\n\nHere is the response I drafted...' + + expect(looksLikeLeakedReasoningPrefix(text)).toBe(false) + expect(shouldBufferPotentialReasoningPrefix(text)).toBe(false) + expect(stripLeakedReasoningPreamble(text)).toBe(text) + }) +}) diff --git a/src/services/api/reasoningLeakSanitizer.ts b/src/services/api/reasoningLeakSanitizer.ts new file mode 100644 index 00000000..00d02cd0 --- /dev/null +++ b/src/services/api/reasoningLeakSanitizer.ts @@ -0,0 +1,54 @@ +const EXPLICIT_REASONING_START_RE = + /^\s*(i should\b|i need to\b|let me think\b|the task\b|the request\b)/i + +const EXPLICIT_REASONING_META_RE = + /\b(user|request|question|prompt|message|task|greeting|small talk|briefly|friendly|concise)\b/i + +const USER_META_START_RE = + /^\s*the user\s+(just\s+)?(said|asked|is asking|wants|wanted|mentioned|seems|appears)\b/i + +const USER_REASONING_RE = + /^\s*the user\s+(just\s+)?(said|asked|is asking|wants|wanted|mentioned|seems|appears)\b[\s\S]*\b(i should|i need to|let me think|respond|reply|answer|greeting|small talk|briefly|friendly|concise)\b/i + +export function shouldBufferPotentialReasoningPrefix(text: string): boolean { + const normalized = text.trim() + if (!normalized) return false + + if (looksLikeLeakedReasoningPrefix(normalized)) { + return true + } + + const hasParagraphBoundary = /\n\s*\n/.test(normalized) + if (hasParagraphBoundary) { + return false + } + + return ( + EXPLICIT_REASONING_START_RE.test(normalized) || + USER_META_START_RE.test(normalized) + ) +} + +export function looksLikeLeakedReasoningPrefix(text: string): boolean { + const normalized = text.trim() + if (!normalized) return false + return ( + (EXPLICIT_REASONING_START_RE.test(normalized) && + EXPLICIT_REASONING_META_RE.test(normalized)) || + USER_REASONING_RE.test(normalized) + ) +} + +export function stripLeakedReasoningPreamble(text: string): string { + const normalized = text.replace(/\r\n/g, '\n') + const parts = normalized.split(/\n\s*\n/) + if (parts.length < 2) return text + + const first = parts[0]?.trim() ?? '' + if (!looksLikeLeakedReasoningPrefix(first)) { + return text + } + + const remainder = parts.slice(1).join('\n\n').trim() + return remainder || text +} diff --git a/src/utils/doctorDiagnostic.ts b/src/utils/doctorDiagnostic.ts index 065b20cb..450ced6c 100644 --- a/src/utils/doctorDiagnostic.ts +++ b/src/utils/doctorDiagnostic.ts @@ -11,10 +11,11 @@ import { type InstallMethod, } from './config.js' import { getCwd } from './cwd.js' -import { isEnvTruthy } from './envUtils.js' +import { getClaudeConfigHomeDir, isEnvTruthy } from './envUtils.js' import { execFileNoThrow } from './execFileNoThrow.js' import { getFsImplementation } from './fsOperations.js' import { + getDetectedLocalInstallDir, getShellType, isRunningFromLocalInstallation, localInstallationExists, @@ -43,6 +44,16 @@ import { import { jsonParse } from './slowOperations.js' import { which } from './which.js' +function getCliBinaryName(): string { + return MACRO.PACKAGE_URL === '@anthropic-ai/claude-code' + ? 'claude' + : 'openclaude' +} + +function getNativeDataDirName(): string { + return getCliBinaryName() +} + export type InstallationType = | 'npm-global' | 'npm-local' @@ -162,7 +173,7 @@ async function getInstallationPath(): Promise { } try { - const path = await which('claude') + const path = await which(getCliBinaryName()) if (path) { return path } @@ -172,8 +183,14 @@ async function getInstallationPath(): Promise { // If we can't find it, check common locations try { - await getFsImplementation().stat(join(homedir(), '.local/bin/claude')) - return join(homedir(), '.local/bin/claude') + const nativeBinaryPath = join( + homedir(), + '.local', + 'bin', + getCliBinaryName(), + ) + await getFsImplementation().stat(nativeBinaryPath) + return nativeBinaryPath } catch { // Not found } @@ -209,8 +226,8 @@ async function detectMultipleInstallations(): Promise< const installations: Array<{ type: string; path: string }> = [] // Check for local installation - const localPath = join(homedir(), '.claude', 'local') - if (await localInstallationExists()) { + const localPath = await getDetectedLocalInstallDir() + if (localPath) { installations.push({ type: 'npm-local', path: localPath }) } @@ -233,8 +250,8 @@ async function detectMultipleInstallations(): Promise< // Linux / macOS have prefix/bin/claude and prefix/lib/node_modules // Windows has prefix/claude and prefix/node_modules const globalBinPath = isWindows - ? join(npmPrefix, 'claude') - : join(npmPrefix, 'bin', 'claude') + ? join(npmPrefix, getCliBinaryName()) + : join(npmPrefix, 'bin', getCliBinaryName()) let globalBinExists = false try { @@ -289,7 +306,7 @@ async function detectMultipleInstallations(): Promise< // Check for native installation // Check common native installation paths - const nativeBinPath = join(homedir(), '.local', 'bin', 'claude') + const nativeBinPath = join(homedir(), '.local', 'bin', getCliBinaryName()) try { await fs.stat(nativeBinPath) installations.push({ type: 'native', path: nativeBinPath }) @@ -300,7 +317,12 @@ async function detectMultipleInstallations(): Promise< // Also check if config indicates native installation const config = getGlobalConfig() if (config.installMethod === 'native') { - const nativeDataPath = join(homedir(), '.local', 'share', 'claude') + const nativeDataPath = join( + homedir(), + '.local', + 'share', + getNativeDataDirName(), + ) try { await fs.stat(nativeDataPath) if (!installations.some(i => i.type === 'native')) { @@ -435,14 +457,14 @@ async function detectConfigurationIssues( if (type === 'npm-local' && config.installMethod !== 'local') { warnings.push({ issue: `Running from local installation but config install method is '${config.installMethod}'`, - fix: 'Consider using native installation: claude install', + fix: `Consider using native installation: ${getCliBinaryName()} install`, }) } if (type === 'native' && config.installMethod !== 'native') { warnings.push({ issue: `Running native installation but config install method is '${config.installMethod}'`, - fix: 'Run claude install to update configuration', + fix: `Run ${getCliBinaryName()} install to update configuration`, }) } } @@ -450,7 +472,7 @@ async function detectConfigurationIssues( if (type === 'npm-global' && (await localInstallationExists())) { warnings.push({ issue: 'Local installation exists but not being used', - fix: 'Consider using native installation: claude install', + fix: `Consider using native installation: ${getCliBinaryName()} install`, }) } @@ -460,7 +482,7 @@ async function detectConfigurationIssues( // Check if running local installation but it's not in PATH if (type === 'npm-local') { // Check if claude is already accessible via PATH - const whichResult = await which('claude') + const whichResult = await which(getCliBinaryName()) const claudeInPath = !!whichResult // Only show warning if claude is NOT in PATH AND no valid alias exists @@ -469,13 +491,13 @@ async function detectConfigurationIssues( // Alias exists but points to invalid target warnings.push({ issue: 'Local installation not accessible', - fix: `Alias exists but points to invalid target: ${existingAlias}. Update alias: alias claude="~/.claude/local/claude"`, + fix: `Alias exists but points to invalid target: ${existingAlias}. Update alias: alias ${getCliBinaryName()}="~/.openclaude/local/${getCliBinaryName()}"`, }) } else { // No alias exists and not in PATH warnings.push({ issue: 'Local installation not accessible', - fix: 'Create alias: alias claude="~/.claude/local/claude"', + fix: `Create alias: alias ${getCliBinaryName()}="~/.openclaude/local/${getCliBinaryName()}"`, }) } } @@ -580,7 +602,7 @@ export async function getDoctorDiagnostic(): Promise { if (!hasUpdatePermissions && !getAutoUpdaterDisabledReason()) { warnings.push({ issue: 'Insufficient permissions for auto-updates', - fix: 'Do one of: (1) Re-install node without sudo, or (2) Use `claude install` for native installation', + fix: `Do one of: (1) Re-install node without sudo, or (2) Use \`${getCliBinaryName()} install\` for native installation`, }) } } diff --git a/src/utils/envUtils.ts b/src/utils/envUtils.ts index fb700117..fe0e811b 100644 --- a/src/utils/envUtils.ts +++ b/src/utils/envUtils.ts @@ -3,23 +3,39 @@ import { existsSync } from 'fs' import { homedir } from 'os' import { join } from 'path' +export function resolveClaudeConfigHomeDir(options?: { + configDirEnv?: string + homeDir?: string + openClaudeExists?: boolean + legacyClaudeExists?: boolean +}): string { + if (options?.configDirEnv) { + return options.configDirEnv.normalize('NFC') + } + + const homeDir = options?.homeDir ?? homedir() + const openClaudeDir = join(homeDir, '.openclaude') + const legacyClaudeDir = join(homeDir, '.claude') + const openClaudeExists = + options?.openClaudeExists ?? existsSync(openClaudeDir) + const legacyClaudeExists = + options?.legacyClaudeExists ?? existsSync(legacyClaudeDir) + + // Preserve existing user config/install state until we ship an explicit + // migration. New installs (neither path exists) use ~/.openclaude. + if (!openClaudeExists && legacyClaudeExists) { + return legacyClaudeDir.normalize('NFC') + } + + return openClaudeDir.normalize('NFC') +} + // Memoized: 150+ callers, many on hot paths. Keyed off CLAUDE_CONFIG_DIR so // tests that change the env var get a fresh value without explicit cache.clear. export const getClaudeConfigHomeDir = memoize( - (): string => { - if (process.env.CLAUDE_CONFIG_DIR) { - return process.env.CLAUDE_CONFIG_DIR.normalize('NFC') - } - const newDefault = join(homedir(), '.openclaude') - // Migration compatibility: if ~/.openclaude doesn't exist yet but ~/.claude - // does, keep using ~/.claude so existing users don't lose their data on - // upgrade. New installs (neither dir exists) go straight to ~/.openclaude. - const legacyPath = join(homedir(), '.claude') - if (!existsSync(newDefault) && existsSync(legacyPath)) { - return legacyPath.normalize('NFC') - } - return newDefault.normalize('NFC') - }, + (): string => resolveClaudeConfigHomeDir({ + configDirEnv: process.env.CLAUDE_CONFIG_DIR, + }), () => process.env.CLAUDE_CONFIG_DIR, ) diff --git a/src/utils/execFileNoThrow.test.ts b/src/utils/execFileNoThrow.test.ts index 97512eea..5bc012f2 100644 --- a/src/utils/execFileNoThrow.test.ts +++ b/src/utils/execFileNoThrow.test.ts @@ -2,9 +2,13 @@ import { expect, test } from 'bun:test' import { mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { execFileNoThrowWithCwd } from './execFileNoThrow.js' + +async function importFreshExecFileNoThrowModule() { + return import(`./execFileNoThrow.ts?ts=${Date.now()}-${Math.random()}`) +} test('execFileNoThrowWithCwd rejects shell-like executable names', async () => { + const { execFileNoThrowWithCwd } = await importFreshExecFileNoThrowModule() const result = await execFileNoThrowWithCwd('openclaude && whoami', []) expect(result.code).toBe(1) @@ -12,6 +16,7 @@ test('execFileNoThrowWithCwd rejects shell-like executable names', async () => { }) test('execFileNoThrowWithCwd rejects cwd values with control characters', async () => { + const { execFileNoThrowWithCwd } = await importFreshExecFileNoThrowModule() const result = await execFileNoThrowWithCwd(process.execPath, ['--version'], { cwd: 'C:\\repo\nmalicious', }) @@ -21,6 +26,7 @@ test('execFileNoThrowWithCwd rejects cwd values with control characters', async }) test('execFileNoThrowWithCwd rejects arguments with control characters', async () => { + const { execFileNoThrowWithCwd } = await importFreshExecFileNoThrowModule() const result = await execFileNoThrowWithCwd(process.execPath, [ '--version\nmalicious', ]) @@ -30,6 +36,7 @@ test('execFileNoThrowWithCwd rejects arguments with control characters', async ( }) test('execFileNoThrowWithCwd rejects environment entries with control characters', async () => { + const { execFileNoThrowWithCwd } = await importFreshExecFileNoThrowModule() const result = await execFileNoThrowWithCwd(process.execPath, ['--version'], { env: { ...process.env, @@ -45,6 +52,7 @@ test('execFileNoThrowWithCwd preserves Windows .cmd compatibility', async () => if (process.platform !== 'win32') { return } + const { execFileNoThrowWithCwd } = await importFreshExecFileNoThrowModule() const dir = mkdtempSync(join(tmpdir(), 'openclaude-execfile-')) const file = join(dir, 'hello.cmd') diff --git a/src/utils/localInstaller.ts b/src/utils/localInstaller.ts index 2532076f..b2f4e678 100644 --- a/src/utils/localInstaller.ts +++ b/src/utils/localInstaller.ts @@ -3,6 +3,7 @@ */ import { access, chmod, writeFile } from 'fs/promises' +import { homedir } from 'os' import { join } from 'path' import { type ReleaseChannel, saveGlobalConfig } from './config.js' import { getClaudeConfigHomeDir } from './envUtils.js' @@ -19,16 +20,45 @@ import { jsonStringify } from './slowOperations.js' function getLocalInstallDir(): string { return join(getClaudeConfigHomeDir(), 'local') } + +function getLegacyLocalInstallDir(homeDir = homedir()): string { + return join(homeDir, '.claude', 'local') +} + +export function getCandidateLocalInstallDirs(options?: { + configHomeDir?: string + homeDir?: string +}): string[] { + const homeDir = options?.homeDir ?? homedir() + const configHomeDir = options?.configHomeDir ?? getClaudeConfigHomeDir() + return Array.from( + new Set([join(configHomeDir, 'local'), getLegacyLocalInstallDir(homeDir)]), + ) +} + +function getCandidateLocalBinaryPaths(localInstallDir: string): string[] { + return [ + join(localInstallDir, 'node_modules', '.bin', 'openclaude'), + join(localInstallDir, 'node_modules', '.bin', 'claude'), + ] +} + +export function isManagedLocalInstallationPath(execPath: string): boolean { + return ( + execPath.includes('/.openclaude/local/node_modules/') || + execPath.includes('/.claude/local/node_modules/') + ) +} + export function getLocalClaudePath(): string { - return join(getLocalInstallDir(), 'claude') + return join(getLocalInstallDir(), 'openclaude') } /** * Check if we're running from our managed local installation */ export function isRunningFromLocalInstallation(): boolean { - const execPath = process.argv[1] || '' - return execPath.includes('/.claude/local/node_modules/') + return isManagedLocalInstallationPath(process.argv[1] || '') } /** @@ -64,17 +94,17 @@ export async function ensureLocalPackageEnvironment(): Promise { await writeIfMissing( join(localInstallDir, 'package.json'), jsonStringify( - { name: 'claude-local', version: '0.0.1', private: true }, + { name: 'openclaude-local', version: '0.0.1', private: true }, null, 2, ), ) // Create the wrapper script if it doesn't exist - const wrapperPath = join(localInstallDir, 'claude') + const wrapperPath = getLocalClaudePath() const created = await writeIfMissing( wrapperPath, - `#!/bin/sh\nexec "${localInstallDir}/node_modules/.bin/claude" "$@"`, + `#!/bin/sh\nexec "${localInstallDir}/node_modules/.bin/openclaude" "$@"`, 0o755, ) if (created) { @@ -142,12 +172,31 @@ export async function installOrUpdateClaudePackage( * Pure existence probe — callers use this to choose update path / UI hints. */ export async function localInstallationExists(): Promise { - try { - await access(join(getLocalInstallDir(), 'node_modules', '.bin', 'claude')) - return true - } catch { - return false + for (const localInstallDir of getCandidateLocalInstallDirs()) { + for (const binaryPath of getCandidateLocalBinaryPaths(localInstallDir)) { + try { + await access(binaryPath) + return true + } catch { + // Try next candidate + } + } } + return false +} + +export async function getDetectedLocalInstallDir(): Promise { + for (const localInstallDir of getCandidateLocalInstallDirs()) { + for (const binaryPath of getCandidateLocalBinaryPaths(localInstallDir)) { + try { + await access(binaryPath) + return localInstallDir + } catch { + // Try next candidate + } + } + } + return null } /** diff --git a/src/utils/nativeInstaller/installer.ts b/src/utils/nativeInstaller/installer.ts index 3a3b770b..9f91d31c 100644 --- a/src/utils/nativeInstaller/installer.ts +++ b/src/utils/nativeInstaller/installer.ts @@ -41,7 +41,7 @@ import { logForDebugging } from '../debug.js' import { getCurrentInstallationType } from '../doctorDiagnostic.js' import { env } from '../env.js' import { envDynamic } from '../envDynamic.js' -import { isEnvTruthy } from '../envUtils.js' +import { getClaudeConfigHomeDir, isEnvTruthy } from '../envUtils.js' import { errorMessage, getErrnoCode, isENOENT, toError } from '../errors.js' import { execFileNoThrowWithCwd } from '../execFileNoThrow.js' import { getShellType } from '../localInstaller.js' @@ -1688,19 +1688,23 @@ export async function cleanupNpmInstallations(): Promise<{ } } - // Check for local installation at ~/.claude/local - const localInstallDir = join(homedir(), '.claude', 'local') + // Preserve compatibility with pre-migration installs under ~/.claude/local. + const localInstallDirs = Array.from( + new Set([join(getClaudeConfigHomeDir(), 'local'), join(homedir(), '.claude', 'local')]), + ) - try { - await rm(localInstallDir, { recursive: true }) - removed++ - logForDebugging(`Removed local installation at ${localInstallDir}`) - } catch (error) { - if (!isENOENT(error)) { - errors.push(`Failed to remove ${localInstallDir}: ${error}`) - logForDebugging(`Failed to remove local installation: ${error}`, { - level: 'error', - }) + for (const localInstallDir of localInstallDirs) { + try { + await rm(localInstallDir, { recursive: true }) + removed++ + logForDebugging(`Removed local installation at ${localInstallDir}`) + } catch (error) { + if (!isENOENT(error)) { + errors.push(`Failed to remove ${localInstallDir}: ${error}`) + logForDebugging(`Failed to remove local installation: ${error}`, { + level: 'error', + }) + } } } diff --git a/src/utils/openclaudeInstallSurfaces.test.ts b/src/utils/openclaudeInstallSurfaces.test.ts new file mode 100644 index 00000000..d5f20b6a --- /dev/null +++ b/src/utils/openclaudeInstallSurfaces.test.ts @@ -0,0 +1,75 @@ +import { afterEach, expect, mock, test } from 'bun:test' +import * as fsPromises from 'fs/promises' +import { homedir } from 'os' +import { join } from 'path' + +const originalEnv = { ...process.env } +const originalMacro = (globalThis as Record).MACRO + +afterEach(() => { + process.env = { ...originalEnv } + ;(globalThis as Record).MACRO = originalMacro + mock.restore() +}) + +async function importFreshInstallCommand() { + return import(`../commands/install.tsx?ts=${Date.now()}-${Math.random()}`) +} + +async function importFreshInstaller() { + return import(`./nativeInstaller/installer.ts?ts=${Date.now()}-${Math.random()}`) +} + +test('install command displays ~/.local/bin/openclaude on non-Windows', async () => { + mock.module('../utils/env.js', () => ({ + env: { platform: 'darwin' }, + })) + + const { getInstallationPath } = await importFreshInstallCommand() + + expect(getInstallationPath()).toBe('~/.local/bin/openclaude') +}) + +test('install command displays openclaude.exe path on Windows', async () => { + mock.module('../utils/env.js', () => ({ + env: { platform: 'win32' }, + })) + + const { getInstallationPath } = await importFreshInstallCommand() + + expect(getInstallationPath()).toBe( + join(homedir(), '.local', 'bin', 'openclaude.exe').replace(/\//g, '\\'), + ) +}) + +test('cleanupNpmInstallations removes both openclaude and legacy claude local install dirs', async () => { + const removedPaths: string[] = [] + ;(globalThis as Record).MACRO = { + PACKAGE_URL: '@gitlawb/openclaude', + } + + mock.module('fs/promises', () => ({ + ...fsPromises, + rm: async (path: string) => { + removedPaths.push(path) + }, + })) + + mock.module('./execFileNoThrow.js', () => ({ + execFileNoThrowWithCwd: async () => ({ + code: 1, + stderr: 'npm ERR! code E404', + }), + })) + + mock.module('./envUtils.js', () => ({ + getClaudeConfigHomeDir: () => join(homedir(), '.openclaude'), + isEnvTruthy: (value: string | undefined) => value === '1', + })) + + const { cleanupNpmInstallations } = await importFreshInstaller() + await cleanupNpmInstallations() + + expect(removedPaths).toContain(join(homedir(), '.openclaude', 'local')) + expect(removedPaths).toContain(join(homedir(), '.claude', 'local')) +}) diff --git a/src/utils/openclaudePaths.test.ts b/src/utils/openclaudePaths.test.ts new file mode 100644 index 00000000..a69db922 --- /dev/null +++ b/src/utils/openclaudePaths.test.ts @@ -0,0 +1,144 @@ +import { afterEach, describe, expect, mock, test } from 'bun:test' +import * as fsPromises from 'fs/promises' +import { homedir } from 'os' +import { join } from 'path' + +const originalEnv = { ...process.env } +const originalArgv = [...process.argv] + +async function importFreshEnvUtils() { + return import(`./envUtils.ts?ts=${Date.now()}-${Math.random()}`) +} + +async function importFreshSettings() { + return import(`./settings/settings.ts?ts=${Date.now()}-${Math.random()}`) +} + +async function importFreshLocalInstaller() { + return import(`./localInstaller.ts?ts=${Date.now()}-${Math.random()}`) +} + +afterEach(() => { + process.env = { ...originalEnv } + process.argv = [...originalArgv] + mock.restore() +}) + +describe('OpenClaude paths', () => { + test('defaults user config home to ~/.openclaude', async () => { + delete process.env.CLAUDE_CONFIG_DIR + const { resolveClaudeConfigHomeDir } = await importFreshEnvUtils() + + expect( + resolveClaudeConfigHomeDir({ + homeDir: homedir(), + openClaudeExists: true, + legacyClaudeExists: false, + }), + ).toBe(join(homedir(), '.openclaude')) + }) + + test('falls back to ~/.claude when legacy config exists and ~/.openclaude does not', async () => { + delete process.env.CLAUDE_CONFIG_DIR + const { resolveClaudeConfigHomeDir } = await importFreshEnvUtils() + + expect( + resolveClaudeConfigHomeDir({ + homeDir: homedir(), + openClaudeExists: false, + legacyClaudeExists: true, + }), + ).toBe(join(homedir(), '.claude')) + }) + + test('uses CLAUDE_CONFIG_DIR override when provided', async () => { + process.env.CLAUDE_CONFIG_DIR = '/tmp/custom-openclaude' + const { getClaudeConfigHomeDir, resolveClaudeConfigHomeDir } = + await importFreshEnvUtils() + + expect(getClaudeConfigHomeDir()).toBe('/tmp/custom-openclaude') + expect( + resolveClaudeConfigHomeDir({ + configDirEnv: '/tmp/custom-openclaude', + }), + ).toBe('/tmp/custom-openclaude') + }) + + test('project and local settings paths use .openclaude', async () => { + const { getRelativeSettingsFilePathForSource } = await importFreshSettings() + + expect(getRelativeSettingsFilePathForSource('projectSettings')).toBe( + '.openclaude/settings.json', + ) + expect(getRelativeSettingsFilePathForSource('localSettings')).toBe( + '.openclaude/settings.local.json', + ) + }) + + test('local installer uses openclaude wrapper path', async () => { + delete process.env.CLAUDE_CONFIG_DIR + const { getLocalClaudePath } = await importFreshLocalInstaller() + + expect(getLocalClaudePath()).toBe( + join(homedir(), '.openclaude', 'local', 'openclaude'), + ) + }) + + test('local installation detection matches .openclaude path', async () => { + const { isManagedLocalInstallationPath } = + await importFreshLocalInstaller() + + expect( + isManagedLocalInstallationPath( + `${join(homedir(), '.openclaude', 'local')}/node_modules/.bin/openclaude`, + ), + ).toBe(true) + }) + + test('local installation detection still matches legacy .claude path', async () => { + const { isManagedLocalInstallationPath } = + await importFreshLocalInstaller() + + expect( + isManagedLocalInstallationPath( + `${join(homedir(), '.claude', 'local')}/node_modules/.bin/openclaude`, + ), + ).toBe(true) + }) + + test('candidate local install dirs include both openclaude and legacy claude paths', async () => { + const { getCandidateLocalInstallDirs } = await importFreshLocalInstaller() + + expect( + getCandidateLocalInstallDirs({ + configHomeDir: join(homedir(), '.openclaude'), + homeDir: homedir(), + }), + ).toEqual([ + join(homedir(), '.openclaude', 'local'), + join(homedir(), '.claude', 'local'), + ]) + }) + + test('legacy local installs are detected when they still expose the claude binary', async () => { + mock.module('fs/promises', () => ({ + ...fsPromises, + access: async (path: string) => { + if ( + path === join(homedir(), '.claude', 'local', 'node_modules', '.bin', 'claude') + ) { + return + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }, + })) + + const { getDetectedLocalInstallDir, localInstallationExists } = + await importFreshLocalInstaller() + + expect(await localInstallationExists()).toBe(true) + expect(await getDetectedLocalInstallDir()).toBe( + join(homedir(), '.claude', 'local'), + ) + }) +}) diff --git a/src/utils/openclaudeUiSurfaces.test.ts b/src/utils/openclaudeUiSurfaces.test.ts new file mode 100644 index 00000000..0366dfd9 --- /dev/null +++ b/src/utils/openclaudeUiSurfaces.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from 'bun:test' +import { join } from 'path' + +import { optionForPermissionSaveDestination } from '../components/permissions/rules/AddPermissionRules.tsx' +import { isClaudeSettingsPath } from './permissions/filesystem.ts' +import { getValidationTip } from './settings/validationTips.ts' + +describe('OpenClaude settings path surfaces', () => { + test('isClaudeSettingsPath recognizes project .openclaude settings files', () => { + expect( + isClaudeSettingsPath( + join(process.cwd(), '.openclaude', 'settings.json'), + ), + ).toBe(true) + + expect( + isClaudeSettingsPath( + join(process.cwd(), '.openclaude', 'settings.local.json'), + ), + ).toBe(true) + }) + + test('permission save destinations point user settings to ~/.openclaude', () => { + expect(optionForPermissionSaveDestination('userSettings')).toEqual({ + label: 'User settings', + description: 'Saved in ~/.openclaude/settings.json', + value: 'userSettings', + }) + }) + + test('permission save destinations point project settings to .openclaude', () => { + expect(optionForPermissionSaveDestination('projectSettings')).toEqual({ + label: 'Project settings', + description: 'Checked in at .openclaude/settings.json', + value: 'projectSettings', + }) + + expect(optionForPermissionSaveDestination('localSettings')).toEqual({ + label: 'Project settings (local)', + description: 'Saved in .openclaude/settings.local.json', + value: 'localSettings', + }) + }) +}) + +describe('OpenClaude validation tips', () => { + test('permissions.defaultMode invalid value keeps suggestion but no Claude docs link', () => { + const tip = getValidationTip({ + path: 'permissions.defaultMode', + code: 'invalid_value', + enumValues: [ + 'acceptEdits', + 'bypassPermissions', + 'default', + 'dontAsk', + 'plan', + ], + }) + + expect(tip).toEqual({ + suggestion: + 'Valid modes: "acceptEdits" (ask before file changes), "plan" (analysis only), "bypassPermissions" (auto-accept all), or "default" (standard behavior)', + }) + }) +}) diff --git a/src/utils/permissions/filesystem.ts b/src/utils/permissions/filesystem.ts index 320b6235..db9d8ef0 100644 --- a/src/utils/permissions/filesystem.ts +++ b/src/utils/permissions/filesystem.ts @@ -76,6 +76,7 @@ export const DANGEROUS_DIRECTORIES = [ '.vscode', '.idea', '.claude', + '.openclaude', ] as const /** @@ -208,6 +209,8 @@ export function isClaudeSettingsPath(filePath: string): boolean { // Use platform separator so endsWith checks work on both Unix (/) and Windows (\) if ( + normalizedPath.endsWith(`${sep}.openclaude${sep}settings.json`) || + normalizedPath.endsWith(`${sep}.openclaude${sep}settings.local.json`) || normalizedPath.endsWith(`${sep}.claude${sep}settings.json`) || normalizedPath.endsWith(`${sep}.claude${sep}settings.local.json`) ) { @@ -233,11 +236,17 @@ function isClaudeConfigFilePath(filePath: string): boolean { const commandsDir = join(getOriginalCwd(), '.claude', 'commands') const agentsDir = join(getOriginalCwd(), '.claude', 'agents') const skillsDir = join(getOriginalCwd(), '.claude', 'skills') + const openCommandsDir = join(getOriginalCwd(), '.openclaude', 'commands') + const openAgentsDir = join(getOriginalCwd(), '.openclaude', 'agents') + const openSkillsDir = join(getOriginalCwd(), '.openclaude', 'skills') return ( pathInWorkingPath(filePath, commandsDir) || pathInWorkingPath(filePath, agentsDir) || - pathInWorkingPath(filePath, skillsDir) + pathInWorkingPath(filePath, skillsDir) || + pathInWorkingPath(filePath, openCommandsDir) || + pathInWorkingPath(filePath, openAgentsDir) || + pathInWorkingPath(filePath, openSkillsDir) ) } diff --git a/src/utils/settings/settings.ts b/src/utils/settings/settings.ts index 3bea04af..c7ddd1b8 100644 --- a/src/utils/settings/settings.ts +++ b/src/utils/settings/settings.ts @@ -300,9 +300,9 @@ export function getRelativeSettingsFilePathForSource( ): string { switch (source) { case 'projectSettings': - return join('.claude', 'settings.json') + return join('.openclaude', 'settings.json') case 'localSettings': - return join('.claude', 'settings.local.json') + return join('.openclaude', 'settings.local.json') } } diff --git a/src/utils/settings/validationTips.ts b/src/utils/settings/validationTips.ts index 716fbfb4..3eeff385 100644 --- a/src/utils/settings/validationTips.ts +++ b/src/utils/settings/validationTips.ts @@ -23,8 +23,6 @@ type TipMatcher = { tip: ValidationTip } -const DOCUMENTATION_BASE = 'https://code.claude.com/docs/en' - const TIP_MATCHERS: TipMatcher[] = [ { matches: (ctx): boolean => @@ -32,7 +30,6 @@ const TIP_MATCHERS: TipMatcher[] = [ tip: { suggestion: 'Valid modes: "acceptEdits" (ask before file changes), "plan" (analysis only), "bypassPermissions" (auto-accept all), or "default" (standard behavior)', - docLink: `${DOCUMENTATION_BASE}/iam#permission-modes`, }, }, { @@ -59,7 +56,6 @@ const TIP_MATCHERS: TipMatcher[] = [ tip: { suggestion: 'Environment variables must be strings. Wrap numbers and booleans in quotes. Example: "DEBUG": "true", "PORT": "3000"', - docLink: `${DOCUMENTATION_BASE}/settings#environment-variables`, }, }, { @@ -98,7 +94,6 @@ const TIP_MATCHERS: TipMatcher[] = [ tip: { suggestion: 'Check for typos or refer to the documentation for valid fields', - docLink: `${DOCUMENTATION_BASE}/settings`, }, }, { @@ -126,16 +121,11 @@ const TIP_MATCHERS: TipMatcher[] = [ tip: { suggestion: 'Must be an array of directory paths. Example: ["~/projects", "/tmp/workspace"]. You can also use --add-dir flag or /add-dir command', - docLink: `${DOCUMENTATION_BASE}/iam#working-directories`, }, }, ] -const PATH_DOC_LINKS: Record = { - permissions: `${DOCUMENTATION_BASE}/iam#configuring-permissions`, - env: `${DOCUMENTATION_BASE}/settings#environment-variables`, - hooks: `${DOCUMENTATION_BASE}/hooks`, -} +const PATH_DOC_LINKS: Record = {} export function getValidationTip(context: TipContext): ValidationTip | null { const matcher = TIP_MATCHERS.find(m => m.matches(context))