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