diff --git a/src/commands/auto-fix.test.ts b/src/commands/auto-fix.test.ts new file mode 100644 index 000000000..e70c57f0e --- /dev/null +++ b/src/commands/auto-fix.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from 'bun:test' +import autoFixCommand from './auto-fix.js' + +describe('/auto-fix command prompt', () => { + test('points project and local settings at canonical .openclaude paths', async () => { + expect(autoFixCommand.type).toBe('prompt') + if (autoFixCommand.type !== 'prompt') { + throw new Error('/auto-fix must be a prompt command') + } + + const blocks = await autoFixCommand.getPromptForCommand('', {} as never) + const text = blocks?.map(block => ('text' in block ? block.text : '')).join('\n') + + expect(text).toContain('.openclaude/settings.json') + expect(text).toContain('.openclaude/settings.local.json') + expect(text).not.toContain('.claude/settings.json') + expect(text).not.toContain('.claude/settings.local.json') + }) +}) diff --git a/src/commands/auto-fix.ts b/src/commands/auto-fix.ts index 41c53fd8e..61d6bd7a0 100644 --- a/src/commands/auto-fix.ts +++ b/src/commands/auto-fix.ts @@ -1,4 +1,5 @@ import type { Command } from '../types/command.js' +import { getRelativeSettingsFilePathForSource } from '../utils/settings/settings.js' const command: Command = { name: 'auto-fix', @@ -14,7 +15,7 @@ const command: Command = { type: 'text', text: 'The user wants to configure auto-fix settings. Auto-fix automatically runs lint and test commands after AI file edits, feeding errors back for self-repair.\n\n' + - 'Current settings location: `.claude/settings.json` or `.claude/settings.local.json`\n\n' + + `Current settings location: \`${getRelativeSettingsFilePathForSource('projectSettings')}\` or \`${getRelativeSettingsFilePathForSource('localSettings')}\`\n\n` + 'Example configuration:\n```json\n{\n "autoFix": {\n "enabled": true,\n "lint": "eslint . --fix",\n "test": "bun test",\n "maxRetries": 3,\n "timeout": 30000\n }\n}\n```\n\n' + 'Ask the user what lint and test commands they use, then help them set up the configuration.', }, diff --git a/src/commands/init.ts b/src/commands/init.ts index fd7a0ecb9..3ba5bfc3b 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -196,7 +196,7 @@ Check the environment and ask about each gap you find (use AskUserQuestion): For each hook preference (from the queue or the formatter fallback): - 1. Target file: default based on the Phase 1 instruction-file choice — project → \`.claude/settings.json\` (team-shared, committed); personal → \`.claude/settings.local.json\`. Only ask if the user chose "both" in Phase 1 or the preference is ambiguous. Ask once for all hooks, not per-hook. + 1. Target file: default based on the Phase 1 instruction-file choice — project → \`.openclaude/settings.json\` (team-shared, committed); personal → \`.openclaude/settings.local.json\`. Only ask if the user chose "both" in Phase 1 or the preference is ambiguous. Ask once for all hooks, not per-hook. 2. Pick the event and matcher from the preference: - "after every edit" → \`PostToolUse\` with matcher \`Write|Edit\` @@ -205,7 +205,7 @@ Check the environment and ask about each gap you find (use AskUserQuestion): - "before committing" (literal git-commit gate) → **not a hooks.json hook.** Matchers can't filter Bash by command content, so there's no way to target only \`git commit\`. Route this to a git pre-commit hook (\`.git/hooks/pre-commit\`, husky, pre-commit framework) instead — offer to write one. If the user actually means "before I review and commit Claude's output", that's \`Stop\` — probe to disambiguate. Probe if the preference is ambiguous. - 3. **Load the hook reference** (once per \`/init\` run, before the first hook): invoke the Skill tool with \`skill: 'update-config'\` and args starting with \`[hooks-only]\` followed by a one-line summary of what you're building — e.g., \`[hooks-only] Constructing a PostToolUse/Write|Edit format hook for .claude/settings.json using ruff\`. This loads the hooks schema and verification flow into context. Subsequent hooks reuse it — don't re-invoke. + 3. **Load the hook reference** (once per \`/init\` run, before the first hook): invoke the Skill tool with \`skill: 'update-config'\` and args starting with \`[hooks-only]\` followed by a one-line summary of what you're building — e.g., \`[hooks-only] Constructing a PostToolUse/Write|Edit format hook for .openclaude/settings.json using ruff\`. This loads the hooks schema and verification flow into context. Subsequent hooks reuse it — don't re-invoke. 4. Follow the skill's **"Constructing a Hook"** flow: dedup check → construct for THIS project → pipe-test raw → wrap → write JSON → \`jq -e\` validate → live-proof (for \`Pre|PostToolUse\` on triggerable matchers) → cleanup → handoff. Target file and event/matcher come from steps 1–2 above. diff --git a/src/commands/insights.ts b/src/commands/insights.ts index 5e1da413f..5caf77c61 100644 --- a/src/commands/insights.ts +++ b/src/commands/insights.ts @@ -1223,7 +1223,7 @@ Include 3 friction categories with 2 examples each.`, - Good for: repetitive workflows - /commit, /review, /test, /deploy, /pr, or complex multi-step workflows 3. **Hooks**: Shell commands that auto-run at specific lifecycle events. - - How to use: Add to \`.claude/settings.json\` under "hooks" key. + - How to use: Add to \`.openclaude/settings.json\` under "hooks" key. - Good for: auto-formatting code, running type checks, enforcing conventions 4. **Headless Mode**: Run Claude non-interactively from scripts and CI/CD. diff --git a/src/commands/onboard-github/onboard-github.tsx b/src/commands/onboard-github/onboard-github.tsx index 1f7937a46..523c4335d 100644 --- a/src/commands/onboard-github/onboard-github.tsx +++ b/src/commands/onboard-github/onboard-github.tsx @@ -203,7 +203,7 @@ function OnboardGithub(props: { if (!activated.ok) { setErrorMsg( `Token saved, but settings were not updated: ${activated.detail ?? 'unknown error'}. ` + - `Add env CLAUDE_CODE_USE_GITHUB=1 and OPENAI_MODEL to ~/.claude/settings.json manually.`, + `Add env CLAUDE_CODE_USE_GITHUB=1 and OPENAI_MODEL to ~/.openclaude/settings.json manually.`, ) setStep('error') return diff --git a/src/commands/plugin/ManagePlugins.tsx b/src/commands/plugin/ManagePlugins.tsx index a87f2ac0d..d1493f79b 100644 --- a/src/commands/plugin/ManagePlugins.tsx +++ b/src/commands/plugin/ManagePlugins.tsx @@ -1043,7 +1043,7 @@ export function ManagePlugins({ { if (isBuiltin) break; // guarded above; narrows pluginScope if (!isInstallableScope(pluginScope)) break; - // If the plugin is enabled in .claude/settings.json (shared with the + // If the plugin is enabled in .openclaude/settings.json (shared with the // team), divert to a confirmation dialog that offers to disable in // settings.local.json instead. Check the settings file directly — // `pluginScope` (from installed_plugins.json) can be 'user' even when @@ -1526,7 +1526,7 @@ export function ManagePlugins({ return; } clearAllCaches(); - setResult(`✓ Disabled ${selectedPlugin.plugin.name} in .claude/settings.local.json. Run /reload-plugins to apply.`); + setResult(`✓ Disabled ${selectedPlugin.plugin.name} in .openclaude/settings.local.json. Run /reload-plugins to apply.`); if (onManageComplete) void onManageComplete(); setParentViewState({ type: 'menu' @@ -1760,16 +1760,16 @@ export function ManagePlugins({ ; } - // Confirm-project-uninstall: warn about shared .claude/settings.json, + // Confirm-project-uninstall: warn about shared .openclaude/settings.json, // offer to disable in settings.local.json instead. if (viewState === 'confirm-project-uninstall' && selectedPlugin) { return - {selectedPlugin.plugin.name} is enabled in .claude/settings.json + {selectedPlugin.plugin.name} is enabled in .openclaude/settings.json (shared with your team) - Disable it just for you in .claude/settings.local.json? + Disable it just for you in .openclaude/settings.local.json? This has the same effect as uninstalling, without affecting other contributors. diff --git a/src/commands/statusline.tsx b/src/commands/statusline.tsx index 78e0131d6..6b29e5621 100644 --- a/src/commands/statusline.tsx +++ b/src/commands/statusline.tsx @@ -9,7 +9,7 @@ const statusline = { aliases: [], name: 'statusline', progressMessage: 'setting up statusLine', - allowedTools: [AGENT_TOOL_NAME, 'Read(~/**)', 'Edit(~/.claude/settings.json)'], + allowedTools: [AGENT_TOOL_NAME, 'Read(~/**)', 'Edit(~/.openclaude/settings.json)'], source: 'builtin', disableNonInteractive: true, async getPromptForCommand(args): Promise { diff --git a/src/components/ModelPicker.tsx b/src/components/ModelPicker.tsx index f70ddf4ba..a3122908e 100644 --- a/src/components/ModelPicker.tsx +++ b/src/components/ModelPicker.tsx @@ -35,8 +35,8 @@ export type Props = { /** * When true, skip writing effortLevel to userSettings on selection. * Used by the assistant installer wizard where the model choice is - * project-scoped (written to the assistant's .claude/settings.json via - * install.ts) and should not leak to the user's global ~/.claude/settings. + * project-scoped (written to the assistant's .openclaude/settings.json via + * install.ts) and should not leak to the user's global ~/.openclaude/settings.json. */ skipSettingsWrite?: boolean; optionsOverride?: ModelOption[]; diff --git a/src/components/TrustDialog/utils.test.ts b/src/components/TrustDialog/utils.test.ts new file mode 100644 index 000000000..8ef3c9b28 --- /dev/null +++ b/src/components/TrustDialog/utils.test.ts @@ -0,0 +1,292 @@ +import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test' +import { acquireSharedMutationLock, releaseSharedMutationLock } from '../../test/sharedMutationLock.js' +import { BASH_TOOL_NAME } from '../../tools/BashTool/toolName.js' +import type { PermissionRule } from '../../utils/permissions/PermissionRule.js' +import { getRelativeSettingsFilePathForSource as REAL_canonicalPath } from '../../utils/settings/settings.js' +import type { SettingsJson } from '../../utils/settings/types.js' + +// TrustDialog/utils.ts emits file-path strings shown to users in the trust +// dialog. These MUST come from getRelativeSettingsFilePathForSource — the +// single source of truth — not hardcoded literals. The previous code hardcoded +// '.claude/...' and drifted from the canonical '.openclaude/...'. +// +// This is a TRUE contract test with TWO independent assertions: +// 1. The real getRelativeSettingsFilePathForSource returns the EXPECTED +// literal '.openclaude/...' (catches a settings.ts regression). +// 2. utils.ts getters return the same value as the real function (catches +// a utils.ts hardcoding regression). +// The EXPECTED literal is hardcoded HERE and only here — if settings.ts drifts +// away from it, assertion (1) fails; if utils.ts stops using the function, +// assertion (2) fails. No self-reference. + +await acquireSharedMutationLock('components/TrustDialog/utils.test.ts') + +// The fork's canonical project settings paths. Hardcoded expected values — +// independent of the implementation under test. +const EXPECTED = { + projectSettings: '.openclaude/settings.json', + localSettings: '.openclaude/settings.local.json', +} as const + +// Contract assertion (1): the real source-of-truth function must return these. +// Wrapped in a named test so a settings.ts regression shows up as a clear +// test failure in CI rather than a module-load error. +test('getRelativeSettingsFilePathForSource returns the canonical .openclaude/ paths', () => { + expect(REAL_canonicalPath('projectSettings')).toBe(EXPECTED.projectSettings) + expect(REAL_canonicalPath('localSettings')).toBe(EXPECTED.localSettings) +}) + +const settingsState: { + projectSettings: SettingsJson | null + localSettings: SettingsJson | null +} = { + projectSettings: null, + localSettings: null, +} + +const permissionRulesState: { + projectSettings: PermissionRule[] + localSettings: PermissionRule[] +} = { + projectSettings: [], + localSettings: [], +} + +mock.module('../../utils/settings/settings.js', () => ({ + // Stateful — needs mocking to control test inputs. + getSettingsForSource: (source: 'projectSettings' | 'localSettings') => + settingsState[source], + // Pure — pass the REAL function through. The mock exists only because + // utils.ts imports both names from the same module; we must provide both. + getRelativeSettingsFilePathForSource: REAL_canonicalPath, +})) + +mock.module('../../utils/permissions/permissionsLoader.js', () => ({ + getPermissionRulesForSource: ( + source: 'projectSettings' | 'localSettings', + ): PermissionRule[] => permissionRulesState[source], +})) + +afterAll(() => { + try { + mock.restore() + } finally { + releaseSharedMutationLock() + } +}) + +beforeEach(() => { + settingsState.projectSettings = null + settingsState.localSettings = null + permissionRulesState.projectSettings = [] + permissionRulesState.localSettings = [] +}) + +async function freshUtils() { + const stamp = `${Date.now()}-${Math.random()}` + return import(`./utils.ts?ts=${stamp}`) +} + +const bashAllow = ( + source: PermissionRule['source'], + toolName: string = BASH_TOOL_NAME, +): PermissionRule => ({ + source, + ruleBehavior: 'allow', + ruleValue: { toolName }, +}) + +// Helper: build a SettingsJson with only the fields we care about. +const settings = (overrides: Partial): SettingsJson => + ({ ...overrides }) as unknown as SettingsJson + +describe('TrustDialog utils — canonical paths from source-of-truth', () => { + describe('getHooksSources', () => { + test('reports canonical paths when hooks present in both sources', async () => { + settingsState.projectSettings = settings({ + hooks: { + PreToolUse: [{ matcher: 'Read', hooks: [{ type: 'command', command: 'true' }] }], + }, + }) + settingsState.localSettings = settings({ + statusLine: { type: 'command', command: 'echo hi' }, + }) + + const { getHooksSources } = await freshUtils() + expect(getHooksSources().sort()).toEqual([ + EXPECTED.projectSettings, + EXPECTED.localSettings, + ]) + }) + + test('reports only project when local has no hooks', async () => { + settingsState.projectSettings = settings({ + hooks: { PostToolUse: [{ matcher: '.*', hooks: [{ type: 'command', command: 'true' }] }] }, + }) + settingsState.localSettings = settings({}) + + const { getHooksSources } = await freshUtils() + expect(getHooksSources()).toEqual([EXPECTED.projectSettings]) + }) + + test('empty hooks object does not count as having hooks', async () => { + settingsState.projectSettings = settings({ hooks: {} }) + const { getHooksSources } = await freshUtils() + expect(getHooksSources()).toEqual([]) + }) + + test('hooks with empty matcher arrays do not count', async () => { + settingsState.projectSettings = settings({ + hooks: { PreToolUse: [] }, + }) + const { getHooksSources } = await freshUtils() + expect(getHooksSources()).toEqual([]) + }) + + test('fileSuggestion alone counts as a hook source', async () => { + settingsState.projectSettings = settings({ + fileSuggestion: { enabled: true } as unknown as SettingsJson['fileSuggestion'], + }) + const { getHooksSources } = await freshUtils() + expect(getHooksSources()).toEqual([EXPECTED.projectSettings]) + }) + + test('disableAllHooks suppresses even when hooks are configured', async () => { + settingsState.projectSettings = settings({ + disableAllHooks: true, + hooks: { + PreToolUse: [{ matcher: 'Read', hooks: [{ type: 'command', command: 'true' }] }], + }, + statusLine: { type: 'command', command: 'echo hi' }, + }) + const { getHooksSources } = await freshUtils() + expect(getHooksSources()).toEqual([]) + }) + + test('null settings produce no sources', async () => { + const { getHooksSources } = await freshUtils() + expect(getHooksSources()).toEqual([]) + }) + + test('non-null settings with no hooks/statusLine/fileSuggestion produce no sources', async () => { + // Covers the `if (!settings.hooks) return false` branch — distinct from + // the null-settings branch above. Settings exist but have nothing that + // counts as a hook source. + settingsState.projectSettings = settings({ model: 'claude-3' }) + const { getHooksSources } = await freshUtils() + expect(getHooksSources()).toEqual([]) + }) + }) + + describe('getBashPermissionSources', () => { + test('reports canonical paths when bash allow rules in both sources', async () => { + permissionRulesState.projectSettings = [bashAllow('projectSettings')] + permissionRulesState.localSettings = [ + bashAllow('localSettings', `${BASH_TOOL_NAME}(npm test)`), + ] + + const { getBashPermissionSources } = await freshUtils() + expect(getBashPermissionSources().sort()).toEqual([ + EXPECTED.projectSettings, + EXPECTED.localSettings, + ]) + }) + + test('toolName prefix match (Bash(...)) counts as bash permission', async () => { + permissionRulesState.localSettings = [ + bashAllow('localSettings', `${BASH_TOOL_NAME}(rm -rf /)`), + ] + const { getBashPermissionSources } = await freshUtils() + expect(getBashPermissionSources()).toEqual([EXPECTED.localSettings]) + }) + + test('non-Bash tool allow rule does NOT count', async () => { + permissionRulesState.projectSettings = [bashAllow('projectSettings', 'Read')] + const { getBashPermissionSources } = await freshUtils() + expect(getBashPermissionSources()).toEqual([]) + }) + + test('deny behavior does NOT count even for Bash', async () => { + permissionRulesState.projectSettings = [ + { ...bashAllow('projectSettings'), ruleBehavior: 'deny' }, + ] + const { getBashPermissionSources } = await freshUtils() + expect(getBashPermissionSources()).toEqual([]) + }) + + test('empty rules produce no sources', async () => { + const { getBashPermissionSources } = await freshUtils() + expect(getBashPermissionSources()).toEqual([]) + }) + }) + + // Cover the remaining getters — each should emit canonical paths when its + // trigger field is present, and nothing otherwise. + describe('remaining getters — canonical path coverage', () => { + test('getOtelHeadersHelperSources', async () => { + const { getOtelHeadersHelperSources } = await freshUtils() + expect(getOtelHeadersHelperSources()).toEqual([]) + + settingsState.projectSettings = settings({ + otelHeadersHelper: 'cat /tmp/headers', + } as Partial) + expect(getOtelHeadersHelperSources()).toEqual([EXPECTED.projectSettings]) + }) + + test('getApiKeyHelperSources', async () => { + const { getApiKeyHelperSources } = await freshUtils() + expect(getApiKeyHelperSources()).toEqual([]) + + settingsState.localSettings = settings({ + apiKeyHelper: '/usr/local/bin/token-helper', + } as Partial) + expect(getApiKeyHelperSources()).toEqual([EXPECTED.localSettings]) + }) + + test('getAwsCommandsSources', async () => { + const { getAwsCommandsSources } = await freshUtils() + expect(getAwsCommandsSources()).toEqual([]) + + settingsState.projectSettings = settings({ + awsAuthRefresh: 'aws sso login', + } as Partial) + expect(getAwsCommandsSources()).toEqual([EXPECTED.projectSettings]) + }) + + test('getAwsCommandsSources — awsCredentialExport alone also triggers', async () => { + // hasAwsCommands is an OR of awsAuthRefresh || awsCredentialExport. + // Cover the second operand so a regression that drops it is caught. + settingsState.localSettings = settings({ + awsCredentialExport: 'export AWS_CREDENTIALS=$CREDS', + } as unknown as Partial) + const { getAwsCommandsSources } = await freshUtils() + expect(getAwsCommandsSources()).toEqual([EXPECTED.localSettings]) + }) + + test('getGcpCommandsSources', async () => { + const { getGcpCommandsSources } = await freshUtils() + expect(getGcpCommandsSources()).toEqual([]) + + settingsState.localSettings = settings({ + gcpAuthRefresh: 'gcloud auth print-access-token', + } as Partial) + expect(getGcpCommandsSources()).toEqual([EXPECTED.localSettings]) + }) + + test('getDangerousEnvVarsSources — dangerous var present', async () => { + const { getDangerousEnvVarsSources } = await freshUtils() + expect(getDangerousEnvVarsSources()).toEqual([]) + + // Most env vars are unsafe by design; pick one not in SAFE_ENV_VARS. + settingsState.projectSettings = settings({ env: { SUPER_SECRET_TOKEN: 'x' } }) + expect(getDangerousEnvVarsSources()).toEqual([EXPECTED.projectSettings]) + }) + + test('getDangerousEnvVarsSources — only safe vars produces nothing', async () => { + // AWS_REGION is in SAFE_ENV_VARS; must not trigger the warning. + settingsState.projectSettings = settings({ env: { AWS_REGION: 'us-east-1' } }) + const { getDangerousEnvVarsSources } = await freshUtils() + expect(getDangerousEnvVarsSources()).toEqual([]) + }) + }) +}) diff --git a/src/components/TrustDialog/utils.ts b/src/components/TrustDialog/utils.ts index 0be335a97..6b7e5881f 100644 --- a/src/components/TrustDialog/utils.ts +++ b/src/components/TrustDialog/utils.ts @@ -1,5 +1,8 @@ import type { PermissionRule } from 'src/utils/permissions/PermissionRule.js' -import { getSettingsForSource } from 'src/utils/settings/settings.js' +import { + getRelativeSettingsFilePathForSource, + getSettingsForSource, +} from 'src/utils/settings/settings.js' import type { SettingsJson } from 'src/utils/settings/types.js' import { BASH_TOOL_NAME } from '../../tools/BashTool/toolName.js' import { SAFE_ENV_VARS } from '../../utils/managedEnvConstants.js' @@ -31,12 +34,12 @@ export function getHooksSources(): string[] { const projectSettings = getSettingsForSource('projectSettings') if (hasHooks(projectSettings)) { - sources.push('.claude/settings.json') + sources.push(getRelativeSettingsFilePathForSource('projectSettings')) } const localSettings = getSettingsForSource('localSettings') if (hasHooks(localSettings)) { - sources.push('.claude/settings.local.json') + sources.push(getRelativeSettingsFilePathForSource('localSettings')) } return sources @@ -60,12 +63,12 @@ export function getBashPermissionSources(): string[] { const projectRules = getPermissionRulesForSource('projectSettings') if (hasBashPermission(projectRules)) { - sources.push('.claude/settings.json') + sources.push(getRelativeSettingsFilePathForSource('projectSettings')) } const localRules = getPermissionRulesForSource('localSettings') if (hasBashPermission(localRules)) { - sources.push('.claude/settings.local.json') + sources.push(getRelativeSettingsFilePathForSource('localSettings')) } return sources @@ -119,12 +122,12 @@ export function getOtelHeadersHelperSources(): string[] { const projectSettings = getSettingsForSource('projectSettings') if (hasOtelHeadersHelper(projectSettings)) { - sources.push('.claude/settings.json') + sources.push(getRelativeSettingsFilePathForSource('projectSettings')) } const localSettings = getSettingsForSource('localSettings') if (hasOtelHeadersHelper(localSettings)) { - sources.push('.claude/settings.local.json') + sources.push(getRelativeSettingsFilePathForSource('localSettings')) } return sources @@ -146,12 +149,12 @@ export function getApiKeyHelperSources(): string[] { const projectSettings = getSettingsForSource('projectSettings') if (hasApiKeyHelper(projectSettings)) { - sources.push('.claude/settings.json') + sources.push(getRelativeSettingsFilePathForSource('projectSettings')) } const localSettings = getSettingsForSource('localSettings') if (hasApiKeyHelper(localSettings)) { - sources.push('.claude/settings.local.json') + sources.push(getRelativeSettingsFilePathForSource('localSettings')) } return sources @@ -173,12 +176,12 @@ export function getAwsCommandsSources(): string[] { const projectSettings = getSettingsForSource('projectSettings') if (hasAwsCommands(projectSettings)) { - sources.push('.claude/settings.json') + sources.push(getRelativeSettingsFilePathForSource('projectSettings')) } const localSettings = getSettingsForSource('localSettings') if (hasAwsCommands(localSettings)) { - sources.push('.claude/settings.local.json') + sources.push(getRelativeSettingsFilePathForSource('localSettings')) } return sources @@ -200,12 +203,12 @@ export function getGcpCommandsSources(): string[] { const projectSettings = getSettingsForSource('projectSettings') if (hasGcpCommands(projectSettings)) { - sources.push('.claude/settings.json') + sources.push(getRelativeSettingsFilePathForSource('projectSettings')) } const localSettings = getSettingsForSource('localSettings') if (hasGcpCommands(localSettings)) { - sources.push('.claude/settings.local.json') + sources.push(getRelativeSettingsFilePathForSource('localSettings')) } return sources @@ -233,12 +236,12 @@ export function getDangerousEnvVarsSources(): string[] { const projectSettings = getSettingsForSource('projectSettings') if (hasDangerousEnvVars(projectSettings)) { - sources.push('.claude/settings.json') + sources.push(getRelativeSettingsFilePathForSource('projectSettings')) } const localSettings = getSettingsForSource('localSettings') if (hasDangerousEnvVars(localSettings)) { - sources.push('.claude/settings.local.json') + sources.push(getRelativeSettingsFilePathForSource('localSettings')) } return sources diff --git a/src/components/hooks/SelectEventMode.tsx b/src/components/hooks/SelectEventMode.tsx index b7dec5c7d..4427cff5d 100644 --- a/src/components/hooks/SelectEventMode.tsx +++ b/src/components/hooks/SelectEventMode.tsx @@ -46,7 +46,7 @@ export function SelectEventMode(t0) { const subtitle = `${totalHooksCount} ${t1} configured`; let t2; if ($[2] !== restrictedByPolicy) { - t2 = restrictedByPolicy && {figures.info} Hooks Restricted by PolicyOnly hooks from managed settings can run. User-defined hooks from ~/.claude/settings.json, .claude/settings.json, and .claude/settings.local.json are blocked.; + t2 = restrictedByPolicy && {figures.info} Hooks Restricted by PolicyOnly hooks from managed settings can run. User-defined hooks from ~/.openclaude/settings.json (user), .openclaude/settings.json (project), and .openclaude/settings.local.json (local) are blocked.; $[2] = restrictedByPolicy; $[3] = t2; } else { diff --git a/src/entrypoints/sdk/coreSchemas.ts b/src/entrypoints/sdk/coreSchemas.ts index 9465ad37e..e5f956fae 100644 --- a/src/entrypoints/sdk/coreSchemas.ts +++ b/src/entrypoints/sdk/coreSchemas.ts @@ -1215,9 +1215,9 @@ export const SettingSourceSchema = lazySchema(() => .enum(['user', 'project', 'local']) .describe( 'Source for loading filesystem-based settings. ' + - "'user' - Global user settings (~/.claude/settings.json). " + - "'project' - Project settings (.claude/settings.json). " + - "'local' - Local settings (.claude/settings.local.json).", + "'user' - Global user settings (~/.openclaude/settings.json). " + + "'project' - Project settings (.openclaude/settings.json). " + + "'local' - Local settings (.openclaude/settings.local.json).", ), ) diff --git a/src/entrypoints/sdk/coreTypes.generated.ts b/src/entrypoints/sdk/coreTypes.generated.ts index b6f075ac9..dbac00134 100644 --- a/src/entrypoints/sdk/coreTypes.generated.ts +++ b/src/entrypoints/sdk/coreTypes.generated.ts @@ -1538,7 +1538,7 @@ export type AgentDefinition = { permissionMode?: "default" | "acceptEdits" | "bypassPermissions" | "fullAccess" | "plan" | "dontAsk" } -/** Source for loading filesystem-based settings. 'user' - Global user settings (~/.claude/settings.json). 'project' - Project settings (.claude/settings.json). 'local' - Local settings (.claude/settings.local.json). */ +/** Source for loading filesystem-based settings. 'user' - Global user settings (~/.openclaude/settings.json). 'project' - Project settings (.openclaude/settings.json). 'local' - Local settings (.openclaude/settings.local.json). */ export type SettingSource = "user" | "project" | "local" /** Configuration for loading a plugin. */ diff --git a/src/screens/REPL.tsx b/src/screens/REPL.tsx index 1d7a8a8c8..d546acdf7 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -1713,7 +1713,7 @@ export function REPL({ if (wt.creationDurationMs < 15_000) return; worktreeTipShownRef.current = true; const secs = Math.round(wt.creationDurationMs / 1000); - setMessages(prev => [...prev, createSystemMessage(`Worktree creation took ${secs}s. For large repos, set \`worktree.sparsePaths\` in .claude/settings.json to check out only the directories you need — e.g. \`{"worktree": {"sparsePaths": ["src", "packages/foo"]}}\`.`, 'info')]); + setMessages(prev => [...prev, createSystemMessage(`Worktree creation took ${secs}s. For large repos, set \`worktree.sparsePaths\` in .openclaude/settings.json to check out only the directories you need — e.g. \`{"worktree": {"sparsePaths": ["src", "packages/foo"]}}\`.`, 'info')]); }, [setMessages]); // Hide spinner when the only in-progress tool is Sleep diff --git a/src/services/plugins/pluginOperations.ts b/src/services/plugins/pluginOperations.ts index 42631a977..4dd6e7665 100644 --- a/src/services/plugins/pluginOperations.ts +++ b/src/services/plugins/pluginOperations.ts @@ -116,7 +116,7 @@ export function getProjectPathForScope(scope: PluginScope): string | undefined { } /** - * Is this plugin enabled (value === true) in .claude/settings.json? + * Is this plugin enabled (value === true) in .openclaude/settings.json? * * Distinct from V2 installed_plugins.json scope: that file tracks where a * plugin was *installed from*, but the same plugin can also be enabled at @@ -482,12 +482,12 @@ export async function uninstallPluginOp( // Try to find where the plugin is actually installed to provide a helpful error const { scope: actualScope } = getPluginInstallationFromV2(pluginId) if (actualScope !== scope && installations && installations.length > 0) { - // Project scope is special: .claude/settings.json is shared with the team. + // Project scope is special: .openclaude/settings.json is shared with the team. // Point users at the local-override escape hatch instead of --scope project. if (actualScope === 'project') { return { success: false, - message: `Plugin "${plugin}" is enabled at project scope (.claude/settings.json, shared with your team). To disable just for you: claude plugin disable ${plugin} --scope local`, + message: `Plugin "${plugin}" is enabled at project scope (.openclaude/settings.json, shared with your team). To disable just for you: claude plugin disable ${plugin} --scope local`, } } return { @@ -668,7 +668,7 @@ export async function setPluginEnabledOp( // different scope, guide the user to the right --scope — UNLESS they're // writing to a higher-precedence scope to override a lower one // (e.g. `disable --scope local` to override a project-enabled plugin - // without touching the shared .claude/settings.json). + // without touching the shared .openclaude/settings.json). const SCOPE_PRECEDENCE: Record = { user: 0, project: 1, diff --git a/src/skills/bundled/updateConfig.test.ts b/src/skills/bundled/updateConfig.test.ts index 2a61e46db..3f579999c 100644 --- a/src/skills/bundled/updateConfig.test.ts +++ b/src/skills/bundled/updateConfig.test.ts @@ -24,4 +24,16 @@ test('update-config skill can generate its prompt without JSON Schema conversion expect((blocks[0] as { text: string }).text).toContain( '## Full Settings JSON Schema', ) + expect((blocks[0] as { text: string }).text).toContain( + '.openclaude/settings.json', + ) + expect((blocks[0] as { text: string }).text).toContain( + '.openclaude/settings.local.json', + ) + expect((blocks[0] as { text: string }).text).not.toContain( + '.claude/settings.json', + ) + expect((blocks[0] as { text: string }).text).not.toContain( + '.claude/settings.local.json', + ) }) diff --git a/src/skills/bundled/updateConfig.ts b/src/skills/bundled/updateConfig.ts index 1f2e6f6ff..509be3025 100644 --- a/src/skills/bundled/updateConfig.ts +++ b/src/skills/bundled/updateConfig.ts @@ -1,4 +1,5 @@ import { toJSONSchema } from 'zod/v4' +import { getRelativeSettingsFilePathForSource } from '../../utils/settings/settings.js' import { SettingsSchema } from '../../utils/settings/types.js' import { jsonStringify } from '../../utils/slowOperations.js' import { registerBundledSkill } from '../bundledSkills.js' @@ -12,15 +13,19 @@ function generateSettingsSchema(): string { return jsonStringify(jsonSchema, null, 2) } +const USER_SETTINGS_PATH = '~/.openclaude/settings.json' +const PROJECT_SETTINGS_PATH = getRelativeSettingsFilePathForSource('projectSettings') +const LOCAL_SETTINGS_PATH = getRelativeSettingsFilePathForSource('localSettings') + const SETTINGS_EXAMPLES_DOCS = `## Settings File Locations Choose the appropriate file based on scope: | File | Scope | Git | Use For | |------|-------|-----|---------| -| \`~/.claude/settings.json\` | Global | N/A | Personal preferences for all projects | -| \`.claude/settings.json\` | Project | Commit | Team-wide hooks, permissions, plugins | -| \`.claude/settings.local.json\` | Project | Gitignore | Personal overrides for this project | +| \`${USER_SETTINGS_PATH}\` | Global | N/A | Personal preferences for all projects | +| \`${PROJECT_SETTINGS_PATH}\` | Project | Commit | Team-wide hooks, permissions, plugins | +| \`${LOCAL_SETTINGS_PATH}\` | Project | Gitignore | Personal overrides for this project | Settings load in order: user → project → local (later overrides earlier). @@ -286,7 +291,7 @@ Given an event, matcher, target file, and desired behavior, follow this flow. Ea Check exit code AND side effect (file actually formatted, test actually ran). If it fails you get a real error — fix (wrong package manager? tool not installed? jq path wrong?) and retest. Once it works, wrap with \`2>/dev/null || true\` (unless the user wants a blocking check). -4. **Write the JSON.** Merge into the target file (schema shape in the "Hook Structure" section above). If this creates \`.claude/settings.local.json\` for the first time, add it to .gitignore — the Write tool doesn't auto-gitignore it. +4. **Write the JSON.** Merge into the target file (schema shape in the "Hook Structure" section above). If this creates \`${LOCAL_SETTINGS_PATH}\` for the first time, add it to .gitignore — the Write tool doesn't auto-gitignore it. 5. **Validate syntax + schema in one shot:** @@ -300,7 +305,7 @@ Given an event, matcher, target file, and desired behavior, follow this flow. Ea **Always clean up** — revert the violation, strip the sentinel prefix — whether the proof passed or failed. - **If proof fails but pipe-test passed and \`jq -e\` passed**: the settings watcher isn't watching \`.claude/\` — it only watches directories that had a settings file when this session started. The hook is written correctly. Tell the user to open \`/hooks\` once (reloads config) or restart — you can't do this yourself; \`/hooks\` is a user UI menu and opening it ends this turn. + **If proof fails but pipe-test passed and \`jq -e\` passed**: the settings watcher isn't watching \`.openclaude/\` — it only watches directories that had a settings file when this session started. The hook is written correctly. Tell the user to open \`/hooks\` once (reloads config) or restart — you can't do this yourself; \`/hooks\` is a user UI menu and opening it ends this turn. 7. **Handoff.** Tell the user the hook is live (or needs \`/hooks\`/restart per the watcher caveat). Point them at \`/hooks\` to review, edit, or disable it later. The UI only shows "Ran N hooks" if a hook errors or is slow — silent success is invisible by design. ` @@ -389,7 +394,7 @@ ${HOOK_VERIFICATION_FLOW} User: "Format my code after Claude writes it" 1. **Clarify**: Which formatter? (prettier, gofmt, etc.) -2. **Read**: \`.claude/settings.json\` (or create if missing) +2. **Read**: \`${PROJECT_SETTINGS_PATH}\` (or create if missing) 3. **Merge**: Add to existing hooks, don't replace 4. **Result**: \`\`\`json @@ -435,7 +440,7 @@ User: "Set DEBUG=true" ## Troubleshooting Hooks If a hook isn't running: -1. **Check the settings file** - Read ~/.claude/settings.json or .claude/settings.json +1. **Check the settings file** - Read ${USER_SETTINGS_PATH}, ${PROJECT_SETTINGS_PATH}, or ${LOCAL_SETTINGS_PATH} 2. **Verify JSON syntax** - Invalid JSON silently fails 3. **Check the matcher** - Does it match the tool name? (e.g., "Bash", "Write", "Edit") 4. **Check hook type** - Is it "command", "prompt", or "agent"? diff --git a/src/utils/doctorDiagnostic.settingsPath.test.ts b/src/utils/doctorDiagnostic.settingsPath.test.ts new file mode 100644 index 000000000..01721c76b --- /dev/null +++ b/src/utils/doctorDiagnostic.settingsPath.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { dirname, join } from 'path' +import { getOriginalCwd, setOriginalCwd } from '../bootstrap/state.js' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../test/sharedMutationLock.js' +import { detectStaleProjectSettingsPaths } from './doctorDiagnostic.js' + +let tempDir: string | null = null +let originalCwd: string | null = null + +function createProject(): string { + tempDir = mkdtempSync(join(tmpdir(), 'openclaude-settings-drift-')) + return tempDir +} + +function writeJson(path: string): void { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, '{}', 'utf8') +} + +beforeEach(async () => { + await acquireSharedMutationLock('utils/doctorDiagnostic.settingsPath.test.ts') + originalCwd = getOriginalCwd() +}) + +afterEach(() => { + try { + if (originalCwd) { + setOriginalCwd(originalCwd) + originalCwd = null + } + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }) + tempDir = null + } + } finally { + releaseSharedMutationLock() + } +}) + +describe('detectStaleProjectSettingsPaths', () => { + test('warns when legacy project settings exist without canonical settings', async () => { + const project = createProject() + writeJson(join(project, '.claude', 'settings.json')) + + const warning = await detectStaleProjectSettingsPaths(project) + + expect(warning).toEqual({ + issue: + 'Legacy project settings file .claude/settings.json found, but OpenClaude reads .openclaude/settings.json', + fix: + 'Move or copy .claude/settings.json to .openclaude/settings.json if you intended OpenClaude to use those project settings.', + }) + }) + + test('does not warn when the matching canonical project settings file exists', async () => { + const project = createProject() + writeJson(join(project, '.claude', 'settings.json')) + writeJson(join(project, '.openclaude', 'settings.json')) + + await expect(detectStaleProjectSettingsPaths(project)).resolves.toBeNull() + }) + + test('does not warn when no legacy project settings files exist', async () => { + const project = createProject() + + await expect(detectStaleProjectSettingsPaths(project)).resolves.toBeNull() + }) + + test('does not warn when only canonical settings files exist', async () => { + const project = createProject() + writeJson(join(project, '.openclaude', 'settings.json')) + writeJson(join(project, '.openclaude', 'settings.local.json')) + + await expect(detectStaleProjectSettingsPaths(project)).resolves.toBeNull() + }) + + test('warns independently for legacy local settings', async () => { + const project = createProject() + writeJson(join(project, '.claude', 'settings.local.json')) + + const warning = await detectStaleProjectSettingsPaths(project) + + expect(warning?.issue).toContain('.claude/settings.local.json') + expect(warning?.issue).toContain('.openclaude/settings.local.json') + }) + + test('warns about both legacy settings files when both canonical files are absent', async () => { + const project = createProject() + writeJson(join(project, '.claude', 'settings.json')) + writeJson(join(project, '.claude', 'settings.local.json')) + + const warning = await detectStaleProjectSettingsPaths(project) + + expect(warning?.issue).toContain('.claude/settings.json') + expect(warning?.issue).toContain('.claude/settings.local.json') + expect(warning?.issue).toContain('.openclaude/settings.json') + expect(warning?.issue).toContain('.openclaude/settings.local.json') + }) + + test('uses the settings resolver project root by default', async () => { + const project = createProject() + writeJson(join(project, '.claude', 'settings.json')) + setOriginalCwd(project) + + const warning = await detectStaleProjectSettingsPaths() + + expect(warning?.issue).toContain('.claude/settings.json') + expect(warning?.issue).toContain('.openclaude/settings.json') + }) +}) diff --git a/src/utils/doctorDiagnostic.ts b/src/utils/doctorDiagnostic.ts index 450ced6c2..34aabeeb8 100644 --- a/src/utils/doctorDiagnostic.ts +++ b/src/utils/doctorDiagnostic.ts @@ -35,6 +35,10 @@ import { getPlatform } from './platform.js' import { getRipgrepStatus } from './ripgrep.js' import { SandboxManager } from './sandbox/sandbox-adapter.js' import { getManagedFilePath } from './settings/managedPath.js' +import { + getRelativeSettingsFilePathForSource, + getSettingsRootPathForSource, +} from './settings/settings.js' import { CUSTOMIZATION_SURFACES } from './settings/types.js' import { findClaudeAlias, @@ -336,11 +340,60 @@ async function detectMultipleInstallations(): Promise< return installations } +async function pathExists(path: string): Promise { + try { + await getFsImplementation().stat(path) + return true + } catch { + return false + } +} + +export async function detectStaleProjectSettingsPaths( + cwd: string = getSettingsRootPathForSource('projectSettings'), +): Promise<{ issue: string; fix: string } | null> { + const pairs = [ + { + legacy: '.claude/settings.json', + canonical: getRelativeSettingsFilePathForSource('projectSettings'), + }, + { + legacy: '.claude/settings.local.json', + canonical: getRelativeSettingsFilePathForSource('localSettings'), + }, + ] + + const stale: Array<{ legacy: string; canonical: string }> = [] + for (const pair of pairs) { + const legacyExists = await pathExists(join(cwd, pair.legacy)) + if (!legacyExists) continue + const canonicalExists = await pathExists(join(cwd, pair.canonical)) + if (!canonicalExists) { + stale.push(pair) + } + } + + if (stale.length === 0) return null + + const legacyPaths = stale.map(pair => pair.legacy).join(', ') + const canonicalPaths = stale.map(pair => pair.canonical).join(', ') + + return { + issue: `Legacy project settings file${stale.length === 1 ? '' : 's'} ${legacyPaths} found, but OpenClaude reads ${canonicalPaths}`, + fix: `Move or copy ${legacyPaths} to ${canonicalPaths} if you intended OpenClaude to use those project settings.`, + } +} + async function detectConfigurationIssues( type: InstallationType, ): Promise> { const warnings: Array<{ issue: string; fix: string }> = [] + const staleProjectSettingsWarning = await detectStaleProjectSettingsPaths() + if (staleProjectSettingsWarning) { + warnings.push(staleProjectSettingsWarning) + } + // Managed-settings forwards-compat: the schema preprocess silently drops // unknown strictPluginOnlyCustomization surface names so one future enum // value doesn't null out the entire policy file (settings.ts:101). But diff --git a/src/utils/hooks/hooksSettings.test.ts b/src/utils/hooks/hooksSettings.test.ts new file mode 100644 index 000000000..be814fb42 --- /dev/null +++ b/src/utils/hooks/hooksSettings.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, test } from 'bun:test' +import { hookSourceDescriptionDisplayString } from './hooksSettings.js' + +describe('hookSourceDescriptionDisplayString', () => { + test('uses the canonical OpenClaude plugin path for plugin hooks', () => { + const description = hookSourceDescriptionDisplayString('pluginHook') + + expect(description).toBe( + 'Plugin hooks (~/.openclaude/plugins/*/hooks/hooks.json)', + ) + expect(description).not.toContain('~/.claude/') + }) +}) diff --git a/src/utils/hooks/hooksSettings.ts b/src/utils/hooks/hooksSettings.ts index b918ede76..31183b2c2 100644 --- a/src/utils/hooks/hooksSettings.ts +++ b/src/utils/hooks/hooksSettings.ts @@ -170,16 +170,16 @@ export function getHooksForEvent( export function hookSourceDescriptionDisplayString(source: HookSource): string { switch (source) { case 'userSettings': - return 'User settings (~/.claude/settings.json)' + return 'User settings (~/.openclaude/settings.json)' case 'projectSettings': - return 'Project settings (.claude/settings.json)' + return 'Project settings (.openclaude/settings.json)' case 'localSettings': - return 'Local settings (.claude/settings.local.json)' + return 'Local settings (.openclaude/settings.local.json)' case 'pluginHook': // TODO: Get the actual plugin hook file paths instead of using glob pattern // We should capture the specific plugin paths during hook registration and display them here - // e.g., "Plugin hooks (~/.claude/plugins/repos/source/example-plugin/example-plugin/hooks/hooks.json)" - return 'Plugin hooks (~/.claude/plugins/*/hooks/hooks.json)' + // e.g., "Plugin hooks (~/.openclaude/plugins/repos/source/example-plugin/example-plugin/hooks/hooks.json)" + return 'Plugin hooks (~/.openclaude/plugins/*/hooks/hooks.json)' case 'sessionHook': return 'Session hooks (in-memory, temporary)' case 'builtinHook': diff --git a/src/utils/plugins/installedPluginsManager.ts b/src/utils/plugins/installedPluginsManager.ts index f0bf98180..31636364e 100644 --- a/src/utils/plugins/installedPluginsManager.ts +++ b/src/utils/plugins/installedPluginsManager.ts @@ -6,7 +6,7 @@ * - Which plugins are installed globally * - Installation metadata (version, timestamps, paths) * - * The enabled/disabled state remains in .claude/settings.json for per-repo control. + * The enabled/disabled state remains in .openclaude/settings.json for per-repo control. * * Rationale: Installation is global (a plugin is either on disk or not), while * enabled/disabled state is per-repository (different projects may want different diff --git a/src/utils/plugins/schemas.ts b/src/utils/plugins/schemas.ts index b92fb80e9..36a160748 100644 --- a/src/utils/plugins/schemas.ts +++ b/src/utils/plugins/schemas.ts @@ -1496,9 +1496,9 @@ export const InstalledPluginsFileSchemaV1 = lazySchema(() => * * Plugins can be installed at different scopes: * - managed: Enterprise/system-wide (read-only, platform-specific paths) - * - user: User's global settings (~/.claude/settings.json) - * - project: Shared project settings ($project/.claude/settings.json) - * - local: Personal project overrides ($project/.claude/settings.local.json) + * - user: User's global settings (~/.openclaude/settings.json) + * - project: Shared project settings ($project/.openclaude/settings.json) + * - local: Personal project overrides ($project/.openclaude/settings.local.json) * * Note: 'flag' scope plugins (from --settings) are session-only and * are NOT persisted to installed_plugins.json. diff --git a/src/utils/sandbox/sandbox-adapter.test.ts b/src/utils/sandbox/sandbox-adapter.test.ts new file mode 100644 index 000000000..4995fcadb --- /dev/null +++ b/src/utils/sandbox/sandbox-adapter.test.ts @@ -0,0 +1,75 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { mkdtemp, rm } from 'fs/promises' +import { tmpdir } from 'os' +import { join, resolve } from 'path' +import { + getCwdState, + getOriginalCwd, + setCwdState, + setOriginalCwd, +} from '../../bootstrap/state.js' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../test/sharedMutationLock.js' +import { resetSettingsCache } from '../settings/settingsCache.js' +import type { SettingsJson } from '../settings/types.js' +import { convertToSandboxRuntimeConfig } from './sandbox-adapter.js' + +describe('convertToSandboxRuntimeConfig', () => { + let previousConfigDir: string | undefined + let previousOriginalCwd: string + let previousCwd: string + let tempRoot: string + let activeCwd: string + + beforeEach(async () => { + await acquireSharedMutationLock('utils/sandbox/sandbox-adapter.test.ts') + + previousConfigDir = process.env.CLAUDE_CONFIG_DIR + previousOriginalCwd = getOriginalCwd() + previousCwd = getCwdState() + + tempRoot = await mkdtemp(join(tmpdir(), 'openclaude-sandbox-adapter-')) + const originalCwd = join(tempRoot, 'original-project') + activeCwd = join(tempRoot, 'active-project') + + process.env.CLAUDE_CONFIG_DIR = join(tempRoot, 'config') + resetSettingsCache() + setOriginalCwd(originalCwd) + setCwdState(activeCwd) + }) + + afterEach(async () => { + try { + if (previousConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR + } else { + process.env.CLAUDE_CONFIG_DIR = previousConfigDir + } + setOriginalCwd(previousOriginalCwd) + setCwdState(previousCwd) + resetSettingsCache() + await rm(tempRoot, { recursive: true, force: true }) + } finally { + releaseSharedMutationLock() + } + }) + + test('denies canonical OpenClaude settings files in changed cwd', () => { + const config = convertToSandboxRuntimeConfig({} as SettingsJson) + + expect(config.filesystem.denyWrite).toContain( + resolve(activeCwd, '.openclaude', 'settings.json'), + ) + expect(config.filesystem.denyWrite).toContain( + resolve(activeCwd, '.openclaude', 'settings.local.json'), + ) + expect(config.filesystem.denyWrite).toContain( + resolve(activeCwd, '.claude', 'settings.json'), + ) + expect(config.filesystem.denyWrite).toContain( + resolve(activeCwd, '.claude', 'settings.local.json'), + ) + }) +}) diff --git a/src/utils/sandbox/sandbox-adapter.ts b/src/utils/sandbox/sandbox-adapter.ts index 60185faec..58d75ae75 100644 --- a/src/utils/sandbox/sandbox-adapter.ts +++ b/src/utils/sandbox/sandbox-adapter.ts @@ -37,6 +37,7 @@ import { SETTING_SOURCES, type SettingSource } from '../settings/constants.js' import { getManagedSettingsDropInDir } from '../settings/managedPath.js' import { getInitialSettings, + getRelativeSettingsFilePathForSource, getSettings_DEPRECATED, getSettingsFilePathForSource, getSettingsForSource, @@ -145,6 +146,15 @@ export function resolveSandboxFilesystemPath( return expandPath(pattern, getSettingsRootPathForSource(source)) } +function getCurrentCwdSettingsDenyWritePaths(cwd: string): string[] { + return [ + resolve(cwd, '.claude', 'settings.json'), + resolve(cwd, '.claude', 'settings.local.json'), + resolve(cwd, getRelativeSettingsFilePathForSource('projectSettings')), + resolve(cwd, getRelativeSettingsFilePathForSource('localSettings')), + ] +} + /** * Check if only managed sandbox domains should be used. * This is true when policySettings has sandbox.network.allowManagedDomainsOnly: true @@ -240,8 +250,7 @@ export function convertToSandboxRuntimeConfig( const cwd = getCwdState() const originalCwd = getOriginalCwd() if (cwd !== originalCwd) { - denyWrite.push(resolve(cwd, '.claude', 'settings.json')) - denyWrite.push(resolve(cwd, '.claude', 'settings.local.json')) + denyWrite.push(...getCurrentCwdSettingsDenyWritePaths(cwd)) } // Block writes to .claude/skills in both original and current working directories. diff --git a/src/utils/sessionStart.ts b/src/utils/sessionStart.ts index d87d51a64..ea5112a51 100644 --- a/src/utils/sessionStart.ts +++ b/src/utils/sessionStart.ts @@ -109,7 +109,7 @@ export async function processSessionStartHooks( errorMessage.includes('schema') ) { userGuidance = - 'This appears to be a configuration issue. Check your plugin settings in .claude/settings.json' + 'This appears to be a configuration issue. Check your plugin settings in .openclaude/settings.json' } else { userGuidance = 'Please fix the plugin configuration or remove problematic plugins from your settings.' diff --git a/src/utils/settings/changeDetector.test.ts b/src/utils/settings/changeDetector.test.ts index 62d1c7fd0..b1f390637 100644 --- a/src/utils/settings/changeDetector.test.ts +++ b/src/utils/settings/changeDetector.test.ts @@ -14,8 +14,8 @@ type SettingsChangeDetectorModule = typeof import('./changeDetector.js') & { const pathsBySource: Record = { userSettings: normalize('/tmp/openclaude/user/settings.json'), - projectSettings: normalize('/tmp/openclaude/project/.claude/settings.json'), - localSettings: normalize('/tmp/openclaude/project/.claude/settings.local.json'), + projectSettings: normalize('/tmp/openclaude/project/.openclaude/settings.json'), + localSettings: normalize('/tmp/openclaude/project/.openclaude/settings.local.json'), flagSettings: null, policySettings: normalize('/tmp/openclaude/managed/managed-settings.json'), } diff --git a/src/utils/settings/settings.ts b/src/utils/settings/settings.ts index d61612363..636fe4b1e 100644 --- a/src/utils/settings/settings.ts +++ b/src/utils/settings/settings.ts @@ -232,7 +232,7 @@ function parseSettingsFileUncached(path: string): { /** * Get the absolute path to the associated file root for a given settings source - * (e.g. for $PROJ_DIR/.claude/settings.json, returns $PROJ_DIR) + * (e.g. for $PROJ_DIR/.openclaude/settings.json, returns $PROJ_DIR) * @param source The source of the settings * @returns The root path of the settings file */ diff --git a/src/utils/settings/types.ts b/src/utils/settings/types.ts index 3bfec1045..564cdc233 100644 --- a/src/utils/settings/types.ts +++ b/src/utils/settings/types.ts @@ -218,7 +218,7 @@ export const DeniedMcpServerEntrySchema = lazySchema(() => * * ⚠️ BACKWARD COMPATIBILITY NOTICE ⚠️ * - * This schema defines the structure of user settings files (.claude/settings.json). + * This schema defines the structure of user settings files (~/.openclaude/settings.json). * We support backward-compatible changes! Here's how: * * ✅ ALLOWED CHANGES: @@ -619,7 +619,7 @@ export const SettingsSchema = lazySchema(() => }) .optional() .describe( - 'Additional marketplaces to make available for this repository. Typically used in repository .claude/settings.json to ensure team members have required plugin sources.', + 'Additional marketplaces to make available for this repository. Typically used in repository .openclaude/settings.json to ensure team members have required plugin sources.', ), // Enterprise strict list of allowed marketplace sources (policy settings only) // When set, ONLY these exact sources can be added. Check happens BEFORE download. @@ -1034,7 +1034,7 @@ export const SettingsSchema = lazySchema(() => .string() .optional() .describe( - 'Custom directory path for auto-memory storage. Supports ~/ prefix for home directory expansion. Ignored if set in projectSettings (checked-in .claude/settings.json) for security. When unset, defaults to ~/.claude/projects//memory/.', + 'Custom directory path for auto-memory storage. Supports ~/ prefix for home directory expansion. Ignored if set in projectSettings (checked-in .openclaude/settings.json) for security. When unset, defaults to ~/.openclaude/projects//memory/.', ), autoDreamEnabled: z .boolean() diff --git a/web/src/data/configuration.ts b/web/src/data/configuration.ts index 8c8545739..71f812825 100644 --- a/web/src/data/configuration.ts +++ b/web/src/data/configuration.ts @@ -8,17 +8,17 @@ export interface SettingsFile { export const settingsFiles: SettingsFile[] = [ { - path: '~/.claude/settings.json', + path: '~/.openclaude/settings.json', scope: 'user', notes: 'Global settings for every project on the machine.', }, { - path: '.claude/settings.json', + path: '.openclaude/settings.json', scope: 'project', notes: 'Shared project settings, committed to the repo.', }, { - path: '.claude/settings.local.json', + path: '.openclaude/settings.local.json', scope: 'local', notes: 'Per-machine overrides for one project; typically gitignored.', }, @@ -68,7 +68,7 @@ export const envVars: EnvVar[] = [ { name: 'MIMO_API_KEY', description: 'Xiaomi MiMo API key.' }, { name: 'OPENCODE_API_KEY', description: 'OpenCode Zen / Go gateway key.' }, { name: 'GITHUB_TOKEN', description: 'GitHub token for GitHub Models and PR workflows.' }, - { name: 'CLAUDE_CONFIG_DIR', description: 'Override the config directory (default ~/.claude).' }, + { name: 'CLAUDE_CONFIG_DIR', description: 'Override the config directory (default ~/.openclaude).' }, { name: 'HTTP_PROXY / HTTPS_PROXY', description: 'Route API traffic through a proxy.' }, { name: 'NODE_EXTRA_CA_CERTS', description: 'Extra CA certificates for corporate TLS interception.' }, { name: 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC', description: 'Disable non-essential network traffic.' },