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