Fix/openclaude diagnostics settings (#483)
* fix: use openclaude paths in diagnostics and settings * fix: strip leaked reasoning from assistant output * fix: preserve legacy claude config compatibility * fix: tighten path and reasoning compatibility * fix: buffer streamed reasoning leak preambles * test: cover openclaude migration and reasoning fixes * test: isolate execFileNoThrow from cross-file mocks
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -188,9 +188,9 @@ export function AutoUpdater({
|
||||
✓ Update installed · Restart to apply
|
||||
</Text>}
|
||||
{(autoUpdaterResult?.status === 'install_failed' || autoUpdaterResult?.status === 'no_permissions') && <Text color="error" wrap="truncate">
|
||||
✗ Auto-update failed · Try <Text bold>claude doctor</Text> or{' '}
|
||||
✗ Auto-update failed · Try <Text bold>openclaude doctor</Text> or{' '}
|
||||
<Text bold>
|
||||
{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}`}
|
||||
</Text>
|
||||
</Text>}
|
||||
</Box>;
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ const execFileNoThrowMock = mock(
|
||||
|
||||
mock.module('../../utils/execFileNoThrow.js', () => ({
|
||||
execFileNoThrow: execFileNoThrowMock,
|
||||
execFileNoThrowWithCwd: execFileNoThrowMock,
|
||||
}))
|
||||
|
||||
mock.module('../../utils/tempfile.js', () => ({
|
||||
|
||||
@@ -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?'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string, any> | 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 ?? ''),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Record<string, unknown>> }
|
||||
|
||||
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?'])
|
||||
})
|
||||
|
||||
@@ -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<Record<string, unknown>> = []
|
||||
|
||||
// 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),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
46
src/services/api/reasoningLeakSanitizer.test.ts
Normal file
46
src/services/api/reasoningLeakSanitizer.test.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
54
src/services/api/reasoningLeakSanitizer.ts
Normal file
54
src/services/api/reasoningLeakSanitizer.ts
Normal file
@@ -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
|
||||
}
|
||||
@@ -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<string> {
|
||||
}
|
||||
|
||||
try {
|
||||
const path = await which('claude')
|
||||
const path = await which(getCliBinaryName())
|
||||
if (path) {
|
||||
return path
|
||||
}
|
||||
@@ -172,8 +183,14 @@ async function getInstallationPath(): Promise<string> {
|
||||
|
||||
// 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<DiagnosticInfo> {
|
||||
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`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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<boolean> {
|
||||
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<boolean> {
|
||||
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<string | null> {
|
||||
for (const localInstallDir of getCandidateLocalInstallDirs()) {
|
||||
for (const binaryPath of getCandidateLocalBinaryPaths(localInstallDir)) {
|
||||
try {
|
||||
await access(binaryPath)
|
||||
return localInstallDir
|
||||
} catch {
|
||||
// Try next candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
75
src/utils/openclaudeInstallSurfaces.test.ts
Normal file
75
src/utils/openclaudeInstallSurfaces.test.ts
Normal file
@@ -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<string, unknown>).MACRO
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv }
|
||||
;(globalThis as Record<string, unknown>).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<string, unknown>).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'))
|
||||
})
|
||||
144
src/utils/openclaudePaths.test.ts
Normal file
144
src/utils/openclaudePaths.test.ts
Normal file
@@ -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'),
|
||||
)
|
||||
})
|
||||
})
|
||||
65
src/utils/openclaudeUiSurfaces.test.ts
Normal file
65
src/utils/openclaudeUiSurfaces.test.ts
Normal file
@@ -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)',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, string> = {
|
||||
permissions: `${DOCUMENTATION_BASE}/iam#configuring-permissions`,
|
||||
env: `${DOCUMENTATION_BASE}/settings#environment-variables`,
|
||||
hooks: `${DOCUMENTATION_BASE}/hooks`,
|
||||
}
|
||||
const PATH_DOC_LINKS: Record<string, string> = {}
|
||||
|
||||
export function getValidationTip(context: TipContext): ValidationTip | null {
|
||||
const matcher = TIP_MATCHERS.find(m => m.matches(context))
|
||||
|
||||
Reference in New Issue
Block a user