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

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