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:
Kevin Codex
2026-04-09 20:42:51 +08:00
committed by GitHub
parent 32fbd0c7b4
commit 42b121bd0d
23 changed files with 934 additions and 101 deletions

View File

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

View File

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

View File

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

View File

@@ -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 &middot; Try <Text bold>claude doctor</Text> or{' '}
Auto-update failed &middot; 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>;

View File

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

View File

@@ -13,6 +13,7 @@ const execFileNoThrowMock = mock(
mock.module('../../utils/execFileNoThrow.js', () => ({
execFileNoThrow: execFileNoThrowMock,
execFileNoThrowWithCwd: execFileNoThrowMock,
}))
mock.module('../../utils/tempfile.js', () => ({

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

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

View File

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

View File

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