feat(config): add OPENCLAUDE_CONFIG_DIR override (#1683)

* feat(config): add OPENCLAUDE_CONFIG_DIR env var as preferred alias for CLAUDE_CONFIG_DIR (#454)

The legacy CLAUDE_CONFIG_DIR name was the only way to point openclaude
at a non-default config home, which leaked Anthropic branding for a
fork that has otherwise rebranded to OpenClaude. Add OPENCLAUDE_CONFIG_DIR
as the preferred name. CLAUDE_CONFIG_DIR continues to work for
backward compatibility; when both are set with different values,
OPENCLAUDE_CONFIG_DIR wins and a one-time warning is logged.

- src/utils/envUtils.ts: introduce resolveConfigDirEnv() that picks
  OPENCLAUDE_CONFIG_DIR over CLAUDE_CONFIG_DIR and emits a conflict
  warning. Memoize cache key now tracks both env vars so changing
  either invalidates the cached result.
- src/utils/env.ts: getGlobalClaudeFile() previously read
  CLAUDE_CONFIG_DIR directly, missing the new alias. Route through
  resolveConfigDirEnv() so the global config file path follows the
  same precedence.
- src/utils/secureStorage/macOsKeychainHelpers.ts: the "is default
  dir" check used by keychain service-name scoping now considers
  both env vars.
- src/utils/swarm/spawnUtils.ts: forward OPENCLAUDE_CONFIG_DIR to
  teammate processes alongside the legacy var.
- src/utils/openclaudePaths.test.ts: +6 unit tests covering the new
  alias, fallthrough, conflict warning, and resolveConfigDirEnv()
  in isolation.
- .env.example: document both env vars and the precedence rule.

Verified locally on Linux: with only OPENCLAUDE_CONFIG_DIR set, with
only CLAUDE_CONFIG_DIR set (legacy still works), with both set
matching (silent), with both set conflicting (warn once + OPENCLAUDE
wins), with neither set (default ~/.openclaude). Memo cache
invalidates across 4 sequential env transitions. Built dist/cli.mjs
honors the new var and emits the conflict warning to the user.

* Fix config-dir warning and docs review findings

Only mark the config-dir conflict warning as emitted when a warning callback actually receives it, add coverage for warn-once and silent callers, and update web configuration docs for OPENCLAUDE_CONFIG_DIR precedence.

# Conflicts:
#	web/src/data/configuration.ts

* Align configuration docs with openclaude paths

Update the configuration page settings-file table to point default users at .openclaude settings and keybindings paths, matching the new config home behavior.

* Align keybindings docs with openclaude config home

Update the keybindings page, keybindings docs data, and skill index to point default users at ~/.openclaude/keybindings.json.

* Align skill and hook labels with openclaude paths

Update bundled config/keybindings skill prompts, public skills docs, hook/trust labels, and the user memory selector to use the active OpenClaude config home paths.

# Conflicts:
#	src/components/TrustDialog/utils.ts
#	src/components/hooks/SelectEventMode.tsx
#	src/skills/bundled/updateConfig.ts
#	src/utils/hooks/hooksSettings.ts

* Resolve config-home paths dynamically in skill prompts

Use runtime settings/keybindings path helpers for bundled skill prompts and the restricted-hooks banner so custom OPENCLAUDE_CONFIG_DIR values are reflected in user-facing guidance.

* Update active command prompts for openclaude paths

Point statusline, setup/onboarding prompts, plugin messages, and the external user-memory warning at the active OpenClaude settings and memory paths.

# Conflicts:
#	src/commands/auto-fix.ts
#	src/commands/onboard-github/onboard-github.tsx
#	src/commands/plugin/ManagePlugins.tsx
#	src/commands/statusline.tsx

* Fix remaining config path review findings

* Cover dynamic config paths in UI and storage tests

* Fix config path smoke failures after rebase

* Fix remaining config path review findings

---------

Co-authored-by: gnanam1990 <gnanasekaran.sekareee@gmail.com>
This commit is contained in:
JATMN
2026-06-18 08:57:22 +08:00
committed by GitHub
co-authored by gnanam1990
parent e5cb589031
commit 2aad6fc93e
34 changed files with 559 additions and 97 deletions
+13
View File
@@ -505,3 +505,16 @@ ANTHROPIC_API_KEY=sk-ant-your-key-here
# WEB_CUSTOM_ALLOW_HTTP=false — set "true" to allow http:// URLs
# WEB_CUSTOM_ALLOW_PRIVATE=false — set "true" to target localhost/private IPs
# (needed for self-hosted SearXNG)
# ── Config directory override ───────────────────────────────────────
#
# By default openclaude stores per-user state under ~/.openclaude
# (and falls back to ~/.claude for installs that pre-date the rename).
# Set this to point openclaude at a different directory — useful for
# isolating profiles or sharing config across machines.
#
# OPENCLAUDE_CONFIG_DIR=/path/to/dir — preferred name
# CLAUDE_CONFIG_DIR=/path/to/dir — legacy alias (still works)
#
# When both are set with different values, OPENCLAUDE_CONFIG_DIR wins
# and a warning is logged once per process.
+3 -2
View File
@@ -124,8 +124,9 @@ Background sessions are local child processes. OpenClaude does not start a daemo
or network service, and permission/provider/model/settings flags are passed to
the child process the same way they are for a foreground `--print` run. Session
metadata and logs are stored under the resolved OpenClaude config directory,
usually `~/.openclaude/bg-sessions/`; `CLAUDE_CONFIG_DIR` can point OpenClaude
somewhere else. Session names can be reused after older sessions reach a
usually `~/.openclaude/bg-sessions/`; `OPENCLAUDE_CONFIG_DIR` can point
OpenClaude somewhere else, with `CLAUDE_CONFIG_DIR` still supported as the
legacy fallback. Session names can be reused after older sessions reach a
terminal state; use the session ID to inspect older logs with the same name.
`openclaude attach <id-or-name>` currently reports the matching session and
+3 -1
View File
@@ -10,12 +10,14 @@ const command: Command = {
contentLength: 0,
source: 'builtin',
async getPromptForCommand() {
const projectSettingsPath = getRelativeSettingsFilePathForSource('projectSettings')
const localSettingsPath = getRelativeSettingsFilePathForSource('localSettings')
return [
{
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: \`${getRelativeSettingsFilePathForSource('projectSettings')}\` or \`${getRelativeSettingsFilePathForSource('localSettings')}\`\n\n` +
`Current settings location: \`${projectSettingsPath}\` or \`${localSettingsPath}\`\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.',
},
+1 -1
View File
@@ -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 \`.openclaude/settings.json\` under "hooks" key.
- How to use: Add to your settings file 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.
+13 -3
View File
@@ -17,7 +17,12 @@ import {
readGithubModelsToken,
saveGithubModelsToken,
} from '../../utils/githubModelsCredentials.js'
import { getSettingsForSource, updateSettingsForSource } from '../../utils/settings/settings.js'
import { getDisplayPath } from '../../utils/file.js'
import {
getSettingsFilePathForSource,
getSettingsForSource,
updateSettingsForSource,
} from '../../utils/settings/settings.js'
const DEFAULT_MODEL = 'github:copilot'
const FORCE_RELOGIN_ARGS = new Set([
@@ -51,6 +56,11 @@ const PROVIDER_SPECIFIC_KEYS = new Set([
'GEMINI_AUTH_MODE',
])
function getUserSettingsDisplayPath(): string {
const userSettingsPath = getSettingsFilePathForSource('userSettings')
return userSettingsPath ? getDisplayPath(userSettingsPath) : 'user settings'
}
export function shouldForceGithubRelogin(args?: string): boolean {
const normalized = (args ?? '').trim().toLowerCase()
if (!normalized) {
@@ -370,7 +380,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 ~/.openclaude/settings.json manually.`,
`Add env CLAUDE_CODE_USE_GITHUB=1 and OPENAI_MODEL=${DEFAULT_MODEL} to ${getUserSettingsDisplayPath()} manually.`,
)
setStep('error')
return
@@ -621,7 +631,7 @@ export const call: LocalJSXCommandCall = async (onDone, context, args) => {
if (!activated.ok) {
onDone(
`GitHub token detected, but settings activation failed: ${activated.detail ?? 'unknown error'}. ` +
'Set CLAUDE_CODE_USE_GITHUB=1 and OPENAI_MODEL=github:copilot in user settings manually.',
`Set CLAUDE_CODE_USE_GITHUB=1 and OPENAI_MODEL=github:copilot in ${getUserSettingsDisplayPath()} manually.`,
{ display: 'system' },
)
return null
+9 -6
View File
@@ -41,7 +41,7 @@ import { loadAllPlugins } from '../../utils/plugins/pluginLoader.js';
import { loadPluginOptions, type PluginOptionSchema, savePluginOptions } from '../../utils/plugins/pluginOptionsStorage.js';
import { isPluginBlockedByPolicy } from '../../utils/plugins/pluginPolicy.js';
import { getPluginEditableScopes } from '../../utils/plugins/pluginStartupCheck.js';
import { getSettings_DEPRECATED, getSettingsForSource, updateSettingsForSource } from '../../utils/settings/settings.js';
import { getRelativeSettingsFilePathForSource, getSettings_DEPRECATED, getSettingsForSource, updateSettingsForSource } from '../../utils/settings/settings.js';
import { jsonParse } from '../../utils/slowOperations.js';
import { plural } from '../../utils/stringUtils.js';
import { formatErrorMessage, getErrorGuidance } from './PluginErrors.js';
@@ -60,6 +60,9 @@ type Props = {
targetMarketplace?: string;
action?: 'enable' | 'disable' | 'uninstall';
};
const projectSettingsDisplayPath = getRelativeSettingsFilePathForSource('projectSettings');
const localSettingsDisplayPath = getRelativeSettingsFilePathForSource('localSettings');
type FlaggedPluginInfo = {
id: string;
name: string;
@@ -1043,7 +1046,7 @@ export function ManagePlugins({
{
if (isBuiltin) break; // guarded above; narrows pluginScope
if (!isInstallableScope(pluginScope)) break;
// If the plugin is enabled in .openclaude/settings.json (shared with the
// If the plugin is enabled in project settings (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 +1529,7 @@ export function ManagePlugins({
return;
}
clearAllCaches();
setResult(`✓ Disabled ${selectedPlugin.plugin.name} in .openclaude/settings.local.json. Run /reload-plugins to apply.`);
setResult(`✓ Disabled ${selectedPlugin.plugin.name} in ${localSettingsDisplayPath}. Run /reload-plugins to apply.`);
if (onManageComplete) void onManageComplete();
setParentViewState({
type: 'menu'
@@ -1760,16 +1763,16 @@ export function ManagePlugins({
</Box>;
}
// Confirm-project-uninstall: warn about shared .openclaude/settings.json,
// Confirm-project-uninstall: warn about shared project settings,
// offer to disable in settings.local.json instead.
if (viewState === 'confirm-project-uninstall' && selectedPlugin) {
return <Box flexDirection="column">
<Text bold color="warning">
{selectedPlugin.plugin.name} is enabled in .openclaude/settings.json
{selectedPlugin.plugin.name} is enabled in {projectSettingsDisplayPath}
(shared with your team)
</Text>
<Box marginTop={1} flexDirection="column">
<Text>Disable it just for you in .openclaude/settings.local.json?</Text>
<Text>Disable it just for you in {localSettingsDisplayPath}?</Text>
<Text dimColor>
This has the same effect as uninstalling, without affecting other
contributors.
+9 -1
View File
@@ -1,6 +1,12 @@
import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/index.mjs';
import type { Command } from '../commands.js';
import { AGENT_TOOL_NAME } from '../tools/AgentTool/constants.js';
import { getSettingsFilePathForSource } from '../utils/settings/settings.js';
function getUserSettingsPath(): string {
return getSettingsFilePathForSource('userSettings') ?? '~/.openclaude/settings.json';
}
const statusline = {
type: 'prompt',
description: "Set up OpenClaude's status line UI",
@@ -9,7 +15,9 @@ const statusline = {
aliases: [],
name: 'statusline',
progressMessage: 'setting up statusLine',
allowedTools: [AGENT_TOOL_NAME, 'Read(~/**)', 'Edit(~/.openclaude/settings.json)'],
get allowedTools() {
return [AGENT_TOOL_NAME, 'Read(~/**)', `Edit(${getUserSettingsPath()})`];
},
source: 'builtin',
disableNonInteractive: true,
async getPromptForCommand(args): Promise<ContentBlockParam[]> {
@@ -1,9 +1,12 @@
import { c as _c } from "react-compiler-runtime";
import { join } from 'path';
import React from 'react';
import { logEvent } from 'src/services/analytics/index.js';
import { Box, Link, Text } from '../ink.js';
import type { ExternalClaudeMdInclude } from '../utils/claudemd.js';
import { saveCurrentProjectConfig } from '../utils/config.js';
import { getClaudeConfigHomeDir } from '../utils/envUtils.js';
import { getDisplayPath } from '../utils/file.js';
import { Select } from './CustomSelect/index.js';
import { Dialog } from './design-system/Dialog.js';
type Props = {
@@ -24,6 +27,9 @@ function declineProject(current: any) {
function declineUser(current: any) {
return { ...current, hasClaudeMdExternalIncludesApprovedForUser: false, hasClaudeMdExternalIncludesWarningShownForUser: true };
}
function getUserClaudeMdDisplayPath(): string {
return getDisplayPath(join(getClaudeConfigHomeDir(), 'CLAUDE.md'));
}
export function ClaudeMdExternalIncludesDialog(t0: Props) {
const $ = _c(18);
const { onDone, isStandaloneDialog, externalIncludes, scope } = t0;
@@ -43,7 +49,7 @@ export function ClaudeMdExternalIncludesDialog(t0: Props) {
? "Allow user CLAUDE.md file imports?"
: "Allow external CLAUDE.md file imports?";
const description = scope === 'User'
? <Text>Your user CLAUDE.md (~/.claude/CLAUDE.md) imports files outside the current working directory.</Text>
? <Text>Your user CLAUDE.md ({getUserClaudeMdDisplayPath()}) imports files outside the current working directory.</Text>
: <Text>This project's CLAUDE.md imports files outside the current working directory. Never allow this for third-party repositories.</Text>;
return (
<Dialog title={title} color="warning" onCancel={handleEscape} hideBorder={!isStandaloneDialog} hideInputGuide={!isStandaloneDialog}>
+19 -14
View File
@@ -8,6 +8,11 @@ import { BASH_TOOL_NAME } from '../../tools/BashTool/toolName.js'
import { SAFE_ENV_VARS } from '../../utils/managedEnvConstants.js'
import { getPermissionRulesForSource } from '../../utils/permissions/permissionsLoader.js'
const projectSettingsPath = () =>
getRelativeSettingsFilePathForSource('projectSettings')
const localSettingsPath = () =>
getRelativeSettingsFilePathForSource('localSettings')
function hasHooks(settings: SettingsJson | null): boolean {
if (settings === null || settings.disableAllHooks) {
return false
@@ -34,12 +39,12 @@ export function getHooksSources(): string[] {
const projectSettings = getSettingsForSource('projectSettings')
if (hasHooks(projectSettings)) {
sources.push(getRelativeSettingsFilePathForSource('projectSettings'))
sources.push(projectSettingsPath())
}
const localSettings = getSettingsForSource('localSettings')
if (hasHooks(localSettings)) {
sources.push(getRelativeSettingsFilePathForSource('localSettings'))
sources.push(localSettingsPath())
}
return sources
@@ -63,12 +68,12 @@ export function getBashPermissionSources(): string[] {
const projectRules = getPermissionRulesForSource('projectSettings')
if (hasBashPermission(projectRules)) {
sources.push(getRelativeSettingsFilePathForSource('projectSettings'))
sources.push(projectSettingsPath())
}
const localRules = getPermissionRulesForSource('localSettings')
if (hasBashPermission(localRules)) {
sources.push(getRelativeSettingsFilePathForSource('localSettings'))
sources.push(localSettingsPath())
}
return sources
@@ -122,12 +127,12 @@ export function getOtelHeadersHelperSources(): string[] {
const projectSettings = getSettingsForSource('projectSettings')
if (hasOtelHeadersHelper(projectSettings)) {
sources.push(getRelativeSettingsFilePathForSource('projectSettings'))
sources.push(projectSettingsPath())
}
const localSettings = getSettingsForSource('localSettings')
if (hasOtelHeadersHelper(localSettings)) {
sources.push(getRelativeSettingsFilePathForSource('localSettings'))
sources.push(localSettingsPath())
}
return sources
@@ -149,12 +154,12 @@ export function getApiKeyHelperSources(): string[] {
const projectSettings = getSettingsForSource('projectSettings')
if (hasApiKeyHelper(projectSettings)) {
sources.push(getRelativeSettingsFilePathForSource('projectSettings'))
sources.push(projectSettingsPath())
}
const localSettings = getSettingsForSource('localSettings')
if (hasApiKeyHelper(localSettings)) {
sources.push(getRelativeSettingsFilePathForSource('localSettings'))
sources.push(localSettingsPath())
}
return sources
@@ -176,12 +181,12 @@ export function getAwsCommandsSources(): string[] {
const projectSettings = getSettingsForSource('projectSettings')
if (hasAwsCommands(projectSettings)) {
sources.push(getRelativeSettingsFilePathForSource('projectSettings'))
sources.push(projectSettingsPath())
}
const localSettings = getSettingsForSource('localSettings')
if (hasAwsCommands(localSettings)) {
sources.push(getRelativeSettingsFilePathForSource('localSettings'))
sources.push(localSettingsPath())
}
return sources
@@ -203,12 +208,12 @@ export function getGcpCommandsSources(): string[] {
const projectSettings = getSettingsForSource('projectSettings')
if (hasGcpCommands(projectSettings)) {
sources.push(getRelativeSettingsFilePathForSource('projectSettings'))
sources.push(projectSettingsPath())
}
const localSettings = getSettingsForSource('localSettings')
if (hasGcpCommands(localSettings)) {
sources.push(getRelativeSettingsFilePathForSource('localSettings'))
sources.push(localSettingsPath())
}
return sources
@@ -236,12 +241,12 @@ export function getDangerousEnvVarsSources(): string[] {
const projectSettings = getSettingsForSource('projectSettings')
if (hasDangerousEnvVars(projectSettings)) {
sources.push(getRelativeSettingsFilePathForSource('projectSettings'))
sources.push(projectSettingsPath())
}
const localSettings = getSettingsForSource('localSettings')
if (hasDangerousEnvVars(localSettings)) {
sources.push(getRelativeSettingsFilePathForSource('localSettings'))
sources.push(localSettingsPath())
}
return sources
+11 -1
View File
@@ -14,6 +14,11 @@ import { PRODUCT_DISPLAY_NAME } from '../../constants/product.js';
import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js';
import type { HookEventMetadata } from 'src/utils/hooks/hooksConfigManager.js';
import { Box, Link, Text } from '../../ink.js';
import { getDisplayPath } from '../../utils/file.js';
import {
getRelativeSettingsFilePathForSource,
getSettingsFilePathForSource
} from '../../utils/settings/settings.js';
import { plural } from '../../utils/stringUtils.js';
import { Select } from '../CustomSelect/select.js';
import { Dialog } from '../design-system/Dialog.js';
@@ -25,6 +30,11 @@ type Props = {
onSelectEvent: (event: HookEvent) => void;
onCancel: () => void;
};
function getBlockedHooksSettingsDisplayText(): string {
const userSettingsPath = getSettingsFilePathForSource('userSettings');
const userSettingsDisplayPath = userSettingsPath ? getDisplayPath(userSettingsPath) : 'settings.json';
return `Only hooks from managed settings can run. User-defined hooks from ${userSettingsDisplayPath}, ${getRelativeSettingsFilePathForSource('projectSettings')}, and ${getRelativeSettingsFilePathForSource('localSettings')} are blocked.`;
}
export function SelectEventMode(t0) {
const $ = _c(23);
const {
@@ -46,7 +56,7 @@ export function SelectEventMode(t0) {
const subtitle = `${totalHooksCount} ${t1} configured`;
let t2;
if ($[2] !== restrictedByPolicy) {
t2 = restrictedByPolicy && <Box flexDirection="column"><Text color="suggestion">{figures.info} Hooks Restricted by Policy</Text><Text dimColor={true}>Only 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.</Text></Box>;
t2 = restrictedByPolicy && <Box flexDirection="column"><Text color="suggestion">{figures.info} Hooks Restricted by Policy</Text><Text dimColor={true}>{getBlockedHooksSettingsDisplayText()}</Text></Box>;
$[2] = restrictedByPolicy;
$[3] = t2;
} else {
+1 -1
View File
@@ -90,7 +90,7 @@ export function MemoryFileSelector(t0) {
let description;
const isGit = projectIsInGitRepo(originalCwd);
if (file.type === "User" && !file.isNested) {
description = "Saved in ~/.claude/CLAUDE.md";
description = `Saved in ${getDisplayPath(userMemoryPath)}`;
} else {
if (file.type === "Project" && !file.isNested && file.path === projectMemoryPath) {
description = `${isGit ? "Checked in at" : "Saved in"} ./${projectMemoryFileName}`;
@@ -11,6 +11,7 @@ import { detectUnreachableRules, type UnreachableRule } from '../../../utils/per
import { SandboxManager } from '../../../utils/sandbox/sandbox-adapter.js';
import { type EditableSettingSource, SOURCES } from '../../../utils/settings/constants.js';
import { getRelativeSettingsFilePathForSource } from '../../../utils/settings/settings.js';
import { getUserSettingsDisplayPath } from '../../../utils/openclaudeDisplayPaths.js';
import { plural } from '../../../utils/stringUtils.js';
import type { OptionWithDescription } from '../../CustomSelect/select.js';
import { Dialog } from '../../design-system/Dialog.js';
@@ -32,7 +33,7 @@ export function optionForPermissionSaveDestination(saveDestination: EditableSett
case 'userSettings':
return {
label: 'User settings',
description: `Saved in ~/.openclaude/settings.json`,
description: `Saved in ${getUserSettingsDisplayPath()}`,
value: saveDestination
};
}
+5 -1
View File
@@ -7,6 +7,7 @@ import { Box, Text } from '../../ink.js';
import { estimateSkillFrontmatterTokens, getSkillsPath } from '../../skills/loadSkillsDir.js';
import { getDisplayPath } from '../../utils/file.js';
import { formatTokens } from '../../utils/format.js';
import { getUserSkillExampleDisplayPath } from '../../utils/openclaudeDisplayPaths.js';
import { getSettingSourceName, type SettingSource } from '../../utils/settings/constants.js';
import { plural } from '../../utils/stringUtils.js';
import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js';
@@ -49,6 +50,9 @@ function getSkillListLabel(skill: SkillCommand): string {
const leafName = skill.name.split(':').pop() ?? skill.name;
return leafName === skill.name ? skill.name : `${skill.name} - ${leafName}`;
}
export function getEmptySkillsMenuMessage(): string {
return `Create skills in .claude/skills/<name>/SKILL.md or ${getUserSkillExampleDisplayPath()}`;
}
export function SkillsMenu(t0) {
const $ = _c(35);
const {
@@ -106,7 +110,7 @@ export function SkillsMenu(t0) {
if (skills.length === 0) {
let t3;
if ($[6] === Symbol.for("react.memo_cache_sentinel")) {
t3 = <FullWidthRow><Text dimColor={true}>Create skills in .claude/skills/&lt;name&gt;/SKILL.md or ~/.openclaude/skills/&lt;name&gt;/SKILL.md</Text></FullWidthRow>;
t3 = <FullWidthRow><Text dimColor={true}>{getEmptySkillsMenuMessage()}</Text></FullWidthRow>;
$[6] = t3;
} else {
t3 = $[6];
+6 -2
View File
@@ -22,6 +22,7 @@ import {
import { env } from '../../utils/env.js'
import { cacheKeys } from '../../utils/fileStateCache.js'
import { getWorktreeCount } from '../../utils/git.js'
import { getUserSkillExampleDisplayPath } from '../../utils/openclaudeDisplayPaths.js'
import {
detectRunningIDEsCached,
getSortedIdeLockfiles,
@@ -57,6 +58,10 @@ import { sponsoredTips } from './sponsoredTips.js'
import { getSessionsSinceLastShown } from './tipHistory.js'
import type { Tip, TipContext } from './types.js'
export function getCustomCommandsTipContent(): string {
return `Create skills at .claude/skills/<name>/SKILL.md in your project or ${getUserSkillExampleDisplayPath()} for skills that work in any project`
}
let _isOfficialMarketplaceInstalledCache: boolean | undefined
async function isOfficialMarketplaceInstalled(): Promise<boolean> {
if (_isOfficialMarketplaceInstalledCache !== undefined) {
@@ -390,8 +395,7 @@ const externalTips: Tip[] = [
},
{
id: 'custom-commands',
content: async () =>
'Create skills at .claude/skills/<name>/SKILL.md in your project or ~/.openclaude/skills/<name>/SKILL.md for skills that work in any project',
content: async () => getCustomCommandsTipContent(),
cooldownSessions: 15,
async isRelevant() {
const config = getGlobalConfig()
+21 -7
View File
@@ -1,5 +1,8 @@
import { DEFAULT_BINDINGS } from '../../keybindings/defaultBindings.js'
import { isKeybindingCustomizationEnabled } from '../../keybindings/loadUserBindings.js'
import {
getKeybindingsPath,
isKeybindingCustomizationEnabled,
} from '../../keybindings/loadUserBindings.js'
import {
MACOS_RESERVED,
NON_REBINDABLE,
@@ -12,8 +15,19 @@ import {
KEYBINDING_CONTEXTS,
} from '../../keybindings/schema.js'
import { jsonStringify } from '../../utils/slowOperations.js'
import { getDisplayPath } from '../../utils/file.js'
import { registerBundledSkill } from '../bundledSkills.js'
const KEYBINDINGS_PATH_TOKEN = '{{KEYBINDINGS_PATH}}'
function getKeybindingsDisplayPath(): string {
return getDisplayPath(getKeybindingsPath())
}
function withKeybindingsPath(text: string): string {
return text.replaceAll(KEYBINDINGS_PATH_TOKEN, getKeybindingsDisplayPath())
}
/**
* Build a markdown table of all contexts.
*/
@@ -149,11 +163,11 @@ const CHORD_EXAMPLE: KeybindingsSchemaType['bindings'][number] = {
const SECTION_INTRO = [
'# Keybindings Skill',
'',
'Create or modify `~/.claude/keybindings.json` to customize keyboard shortcuts.',
`Create or modify \`${KEYBINDINGS_PATH_TOKEN}\` to customize keyboard shortcuts.`,
'',
'## CRITICAL: Read Before Write',
'',
'**Always read `~/.claude/keybindings.json` first** (it may not exist yet). Merge changes with existing bindings — never replace the entire file.',
`**Always read \`${KEYBINDINGS_PATH_TOKEN}\` first** (it may not exist yet). Merge changes with existing bindings — never replace the entire file.`,
'',
'- Use **Edit** tool for modifications to existing files',
'- Use **Write** tool only if the file does not exist yet',
@@ -231,7 +245,7 @@ const SECTION_BEHAVIORAL_RULES = [
const SECTION_DOCTOR = [
'## Validation with /doctor',
'',
'The `/doctor` command includes a "Keybinding Configuration Issues" section that validates `~/.claude/keybindings.json`.',
`The \`/doctor\` command includes a "Keybinding Configuration Issues" section that validates \`${KEYBINDINGS_PATH_TOKEN}\`.`,
'',
'### Common Issues and Fixes',
'',
@@ -280,7 +294,7 @@ const SECTION_DOCTOR = [
'',
'```',
'Keybinding Configuration Issues',
'Location: ~/.claude/keybindings.json',
`Location: ${KEYBINDINGS_PATH_TOKEN}`,
' └ [Error] Unknown context "chat"',
' → Valid contexts: Global, Chat, Autocomplete, ...',
' └ [Warning] "ctrl+c" may not work: Terminal interrupt (SIGINT)',
@@ -293,7 +307,7 @@ export function registerKeybindingsSkill(): void {
registerBundledSkill({
name: 'keybindings-help',
description:
'Use when the user wants to customize keyboard shortcuts, rebind keys, add chord bindings, or modify ~/.claude/keybindings.json. Examples: "rebind ctrl+s", "add a chord shortcut", "change the submit key", "customize keybindings".',
`Use when the user wants to customize keyboard shortcuts, rebind keys, add chord bindings, or modify ${getKeybindingsDisplayPath()}. Examples: "rebind ctrl+s", "add a chord shortcut", "change the submit key", "customize keybindings".`,
allowedTools: ['Read'],
userInvocable: false,
isEnabled: isKeybindingCustomizationEnabled,
@@ -321,7 +335,7 @@ export function registerKeybindingsSkill(): void {
sections.push(`## User Request\n\n${args}`)
}
return [{ type: 'text', text: sections.join('\n\n') }]
return [{ type: 'text', text: withKeybindingsPath(sections.join('\n\n')) }]
},
})
}
+29 -16
View File
@@ -1,9 +1,26 @@
import { toJSONSchema } from 'zod/v4'
import { getRelativeSettingsFilePathForSource } from '../../utils/settings/settings.js'
import { join } from 'path'
import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
import { getDisplayPath } from '../../utils/file.js'
import { SettingsSchema } from '../../utils/settings/types.js'
import { jsonStringify } from '../../utils/slowOperations.js'
import { registerBundledSkill } from '../bundledSkills.js'
const USER_CONFIG_HOME_TOKEN = '{{USER_CONFIG_HOME}}'
const USER_SETTINGS_PATH_TOKEN = '{{USER_SETTINGS_PATH}}'
const USER_BASH_LOG_PATH_TOKEN = '{{USER_BASH_LOG_PATH}}'
function getUserConfigFileDisplayPath(fileName: string): string {
return getDisplayPath(join(getClaudeConfigHomeDir(), fileName))
}
function withConfigPaths(text: string): string {
return text
.replaceAll(USER_CONFIG_HOME_TOKEN, getDisplayPath(getClaudeConfigHomeDir()))
.replaceAll(USER_SETTINGS_PATH_TOKEN, getUserConfigFileDisplayPath('settings.json'))
.replaceAll(USER_BASH_LOG_PATH_TOKEN, getUserConfigFileDisplayPath('bash-log.txt'))
}
/**
* Generate JSON Schema from the settings Zod schema.
* This keeps the skill prompt in sync with the actual types.
@@ -13,19 +30,15 @@ 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 |
|------|-------|-----|---------|
| \`${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 |
| \`${USER_SETTINGS_PATH_TOKEN}\` | Global | N/A | Personal preferences for all projects |
| \`.openclaude/settings.json\` | Project | Commit | Team-wide hooks, permissions, plugins |
| \`.openclaude/settings.local.json\` | Project | Gitignore | Personal overrides for this project |
Settings load in order: user → project → local (later overrides earlier).
@@ -35,7 +48,7 @@ Settings load in order: user → project → local (later overrides earlier).
\`\`\`json
{
"permissions": {
"allow": ["Bash(npm:*)", "Edit(.claude)", "Read"],
"allow": ["Bash(npm:*)", "Edit(.openclaude)", "Read"],
"deny": ["Bash(rm -rf:*)"],
"ask": ["Write(/etc/*)"],
"defaultMode": "default" | "plan" | "acceptEdits" | "dontAsk",
@@ -241,7 +254,7 @@ Hooks can return JSON to control behavior:
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": "jq -r '.tool_input.command' >> ~/.claude/bash-log.txt"
"command": "jq -r '.tool_input.command' >> ${USER_BASH_LOG_PATH_TOKEN}"
}]
}]
}
@@ -291,7 +304,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 \`${LOCAL_SETTINGS_PATH}\` 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 \`.openclaude/settings.local.json\` for the first time, add it to .gitignore — the Write tool doesn't auto-gitignore it.
5. **Validate syntax + schema in one shot:**
@@ -374,7 +387,7 @@ When adding to permission arrays or hook arrays, **merge with existing**, don't
"permissions": {
"allow": [
"Bash(git:*)", // existing
"Edit(.claude)", // existing
"Edit(.openclaude)", // existing
"Bash(npm:*)" // new
]
}
@@ -394,7 +407,7 @@ ${HOOK_VERIFICATION_FLOW}
User: "Format my code after Claude writes it"
1. **Clarify**: Which formatter? (prettier, gofmt, etc.)
2. **Read**: \`${PROJECT_SETTINGS_PATH}\` (or create if missing)
2. **Read**: \`.openclaude/settings.json\` (or create if missing)
3. **Merge**: Add to existing hooks, don't replace
4. **Result**:
\`\`\`json
@@ -440,7 +453,7 @@ User: "Set DEBUG=true"
## Troubleshooting Hooks
If a hook isn't running:
1. **Check the settings file** - Read ${USER_SETTINGS_PATH}, ${PROJECT_SETTINGS_PATH}, or ${LOCAL_SETTINGS_PATH}
1. **Check the settings file** - Read ${USER_SETTINGS_PATH_TOKEN} or .openclaude/settings.json
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"?
@@ -459,7 +472,7 @@ export function registerUpdateConfigSkill(): void {
async getPromptForCommand(args) {
if (args.startsWith('[hooks-only]')) {
const req = args.slice('[hooks-only]'.length).trim()
let prompt = HOOKS_DOCS + '\n\n' + HOOK_VERIFICATION_FLOW
let prompt = withConfigPaths(HOOKS_DOCS + '\n\n' + HOOK_VERIFICATION_FLOW)
if (req) {
prompt += `\n\n## Task\n\n${req}`
}
@@ -469,7 +482,7 @@ export function registerUpdateConfigSkill(): void {
// Generate schema dynamically to stay in sync with types
const jsonSchema = generateSettingsSchema()
let prompt = UPDATE_CONFIG_PROMPT
let prompt = withConfigPaths(UPDATE_CONFIG_PROMPT)
prompt += `\n\n## Full Settings JSON Schema\n\n\`\`\`json\n${jsonSchema}\n\`\`\``
if (args) {
+36
View File
@@ -8,6 +8,7 @@ import {
} from '../test/sharedMutationLock.js'
const originalEnv = {
OPENCLAUDE_CONFIG_DIR: process.env.OPENCLAUDE_CONFIG_DIR,
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
CLAUDE_CODE_CUSTOM_OAUTH_URL: process.env.CLAUDE_CODE_CUSTOM_OAUTH_URL,
USER_TYPE: process.env.USER_TYPE,
@@ -18,6 +19,7 @@ let tempDir: string
beforeEach(async () => {
await acquireSharedMutationLock('env.test.ts')
tempDir = mkdtempSync(join(tmpdir(), 'openclaude-env-test-'))
delete process.env.OPENCLAUDE_CONFIG_DIR
process.env.CLAUDE_CONFIG_DIR = tempDir
delete process.env.CLAUDE_CODE_CUSTOM_OAUTH_URL
delete process.env.USER_TYPE
@@ -26,6 +28,11 @@ beforeEach(async () => {
afterEach(() => {
try {
rmSync(tempDir, { recursive: true, force: true })
if (originalEnv.OPENCLAUDE_CONFIG_DIR === undefined) {
delete process.env.OPENCLAUDE_CONFIG_DIR
} else {
process.env.OPENCLAUDE_CONFIG_DIR = originalEnv.OPENCLAUDE_CONFIG_DIR
}
if (originalEnv.CLAUDE_CONFIG_DIR === undefined) {
delete process.env.CLAUDE_CONFIG_DIR
} else {
@@ -70,6 +77,35 @@ test('getGlobalClaudeFile: migrated user uses .openclaude.json when both files e
expect(getGlobalClaudeFile()).toBe(join(tempDir, '.openclaude.json'))
})
test('getGlobalClaudeFile: OPENCLAUDE_CONFIG_DIR uses preferred config dir', async () => {
const preferredDir = mkdtempSync(join(tmpdir(), 'openclaude-preferred-env-test-'))
try {
process.env.OPENCLAUDE_CONFIG_DIR = preferredDir
process.env.CLAUDE_CONFIG_DIR = tempDir
const { getGlobalClaudeFile } = await importFreshEnvModule()
expect(getGlobalClaudeFile()).toBe(join(preferredDir, '.openclaude.json'))
} finally {
rmSync(preferredDir, { recursive: true, force: true })
}
})
test('getGlobalClaudeFile: OPENCLAUDE_CONFIG_DIR keeps .claude.json fallback when only legacy file exists', async () => {
const preferredDir = mkdtempSync(join(tmpdir(), 'openclaude-preferred-env-test-'))
try {
process.env.OPENCLAUDE_CONFIG_DIR = preferredDir
process.env.CLAUDE_CONFIG_DIR = tempDir
writeFileSync(join(preferredDir, '.claude.json'), '{}')
const { getGlobalClaudeFile } = await importFreshEnvModule()
expect(getGlobalClaudeFile()).toBe(join(preferredDir, '.claude.json'))
} finally {
rmSync(preferredDir, { recursive: true, force: true })
}
})
test('resolveGlobalClaudeFile: failed default migration keeps legacy file when new file is missing', async () => {
writeFileSync(join(tempDir, '.claude.json'), '{}')
const { resolveGlobalClaudeFile } = await importFreshEnvModule()
+10 -5
View File
@@ -8,6 +8,7 @@ import {
getClaudeConfigHomeDir,
isEnvTruthy,
migrateLegacyClaudeConfigHome,
resolveConfigDirEnv,
} from './envUtils.js'
import { findExecutable } from './findExecutable.js'
import { getFsImplementation } from './fsOperations.js'
@@ -50,8 +51,12 @@ export const getGlobalClaudeFile = memoize((): string => {
}
const oauthSuffix = fileSuffixForOauthConfig()
const configDir = process.env.CLAUDE_CONFIG_DIR || homedir()
const hasExplicitConfigDir = Boolean(process.env.CLAUDE_CONFIG_DIR)
const configDirEnv = resolveConfigDirEnv({
openClaudeConfigDir: process.env.OPENCLAUDE_CONFIG_DIR,
legacyConfigDir: process.env.CLAUDE_CONFIG_DIR,
})
const configDir = configDirEnv || homedir()
const hasExplicitConfigDir = Boolean(configDirEnv)
let migrationSucceeded = true
if (!hasExplicitConfigDir) {
@@ -59,10 +64,10 @@ export const getGlobalClaudeFile = memoize((): string => {
}
// Default installs hard-cut to .openclaude.json after the migration above.
// Explicit CLAUDE_CONFIG_DIR users keep the legacy filename fallback because
// that env var is the opt-out for automatic migration.
// Explicit config-dir users keep the legacy filename fallback because
// either env var is an opt-out for automatic migration.
return resolveGlobalClaudeFile({
configDirEnv: process.env.CLAUDE_CONFIG_DIR,
configDirEnv,
homeDir: configDir,
oauthSuffix,
migrationSucceeded,
+47 -4
View File
@@ -142,6 +142,40 @@ export function migrateLegacyClaudeConfigHome(options?: {
}
}
/**
* Resolves the override env value for the config home directory.
* `OPENCLAUDE_CONFIG_DIR` is preferred — `CLAUDE_CONFIG_DIR` is the legacy
* Anthropic name kept working for backward compatibility. When both are set
* and disagree, `OPENCLAUDE_CONFIG_DIR` wins and we warn once so the user
* can clean up. Exported for tests.
*/
let warnedAboutConflictingConfigDirEnvs = false
export function resolveConfigDirEnv(options?: {
openClaudeConfigDir?: string
legacyConfigDir?: string
warn?: (message: string) => void
}): string | undefined {
const open = options?.openClaudeConfigDir
const legacy = options?.legacyConfigDir
if (open && legacy && open !== legacy && !warnedAboutConflictingConfigDirEnvs) {
const message = `Both OPENCLAUDE_CONFIG_DIR and CLAUDE_CONFIG_DIR are set to different values. Using OPENCLAUDE_CONFIG_DIR=${open}; ignoring CLAUDE_CONFIG_DIR=${legacy}.`
if (options?.warn) {
warnedAboutConflictingConfigDirEnvs = true
options.warn(message)
}
}
return open || legacy || undefined
}
/**
* Test-only escape hatch — resets the once-per-process conflict warning so
* unit tests can re-trigger it.
*/
export function __resetConfigDirEnvWarningForTesting(): void {
warnedAboutConflictingConfigDirEnvs = false
}
export function resolveClaudeConfigHomeDir(options?: {
configDirEnv?: string
homeDir?: string
@@ -164,15 +198,23 @@ export function setClaudeConfigHomeDirForTesting(
claudeConfigHomeDirOverride = configDir?.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.
// Memoized: 150+ callers, many on hot paths. Keyed off both override env
// vars so tests that change either get a fresh value without explicit
// cache.clear.
export const getClaudeConfigHomeDir = memoize(
(): string => {
if (claudeConfigHomeDirOverride) {
return claudeConfigHomeDirOverride
}
const configDirEnv = process.env.CLAUDE_CONFIG_DIR
const configDirEnv = resolveConfigDirEnv({
openClaudeConfigDir: process.env.OPENCLAUDE_CONFIG_DIR,
legacyConfigDir: process.env.CLAUDE_CONFIG_DIR,
warn: message => {
// eslint-disable-next-line no-console
console.warn(`[openclaude] ${message}`)
},
})
const homeDir = homedir()
const migrationSucceeded = migrateLegacyClaudeConfigHome({
configDirEnv,
@@ -195,7 +237,8 @@ export const getClaudeConfigHomeDir = memoize(
homeDir,
})
},
() => `${claudeConfigHomeDirOverride ?? ''}\0${process.env.CLAUDE_CONFIG_DIR ?? ''}`,
() =>
`${claudeConfigHomeDirOverride ?? ''}\0${process.env.OPENCLAUDE_CONFIG_DIR ?? ''}\0${process.env.CLAUDE_CONFIG_DIR ?? ''}`,
)
export function getTeamsDir(): string {
+1
View File
@@ -4,6 +4,7 @@ import { validateEnvVars } from './envValidation.js'
const optionalEnvVars = [
'ANTHROPIC_API_KEY',
'ANTHROPIC_AUTH_TOKEN',
'OPENCLAUDE_CONFIG_DIR',
'CLAUDE_CONFIG_DIR',
'NODE_EXTRA_CA_CERTS',
] as const
+1
View File
@@ -50,6 +50,7 @@ const optionalNonEmptyString = z.preprocess(
const EnvSchema = z.object({
ANTHROPIC_API_KEY: optionalNonEmptyString,
ANTHROPIC_AUTH_TOKEN: optionalNonEmptyString,
OPENCLAUDE_CONFIG_DIR: optionalNonEmptyString,
CLAUDE_CONFIG_DIR: optionalNonEmptyString,
HTTP_PROXY: z.string().url().optional().or(z.literal('')),
HTTPS_PROXY: z.string().url().optional().or(z.literal('')),
+17 -7
View File
@@ -1,13 +1,15 @@
import { resolve } from 'path'
import { join, resolve } from 'path'
import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js'
import { getSessionId } from '../../bootstrap/state.js'
import type { AppState } from '../../state/AppState.js'
import { getClaudeConfigHomeDir } from '../envUtils.js'
import type { EditableSettingSource } from '../settings/constants.js'
import { SOURCES } from '../settings/constants.js'
import {
getSettingsFilePathForSource,
getSettingsForSource,
} from '../settings/settings.js'
import { getDisplayPath } from '../file.js'
import type { HookCommand, HookMatcher } from '../settings/types.js'
import { DEFAULT_HOOK_SHELL } from '../shell/shellProvider.js'
import { getSessionHooks } from './sessionHooks.js'
@@ -108,7 +110,7 @@ export function getAllHooks(appState: AppState): IndividualHookConfig[] {
// Track which settings files we've already processed to avoid duplicates
// (e.g., when running from home directory, userSettings and projectSettings
// both resolve to ~/.claude/settings.json)
// both resolve to the same settings.json)
const seenFiles = new Set<string>()
for (const source of sources) {
@@ -168,18 +170,26 @@ export function getHooksForEvent(
}
export function hookSourceDescriptionDisplayString(source: HookSource): string {
const settingsPath = (settingsSource: EditableSettingSource) => {
const filePath = getSettingsFilePathForSource(settingsSource)
return filePath ? getDisplayPath(filePath) : 'settings.json'
}
const pluginHooksPath = getDisplayPath(
join(getClaudeConfigHomeDir(), 'plugins', '*', 'hooks', 'hooks.json'),
)
switch (source) {
case 'userSettings':
return 'User settings (~/.openclaude/settings.json)'
return `User settings (${settingsPath('userSettings')})`
case 'projectSettings':
return 'Project settings (.openclaude/settings.json)'
return `Project settings (${settingsPath('projectSettings')})`
case 'localSettings':
return 'Local settings (.openclaude/settings.local.json)'
return `Local settings (${settingsPath('localSettings')})`
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 (~/.openclaude/plugins/repos/source/example-plugin/example-plugin/hooks/hooks.json)"
return 'Plugin hooks (~/.openclaude/plugins/*/hooks/hooks.json)'
// e.g., "Plugin hooks (<config-home>/plugins/repos/source/example-plugin/example-plugin/hooks/hooks.json)"
return `Plugin hooks (${pluginHooksPath})`
case 'sessionHook':
return 'Session hooks (in-memory, temporary)'
case 'builtinHook':
+34
View File
@@ -0,0 +1,34 @@
import { homedir } from 'os'
import { join } from 'path'
import {
getClaudeConfigHomeDir,
resolveClaudeConfigHomeDir,
resolveConfigDirEnv,
} from './envUtils.js'
import { getDisplayPath } from './file.js'
function getUserConfigHomeForDisplay(): string {
const configDirEnv = resolveConfigDirEnv({
openClaudeConfigDir: process.env.OPENCLAUDE_CONFIG_DIR,
legacyConfigDir: process.env.CLAUDE_CONFIG_DIR,
})
if (configDirEnv) {
return resolveClaudeConfigHomeDir({
configDirEnv,
homeDir: homedir(),
})
}
return getClaudeConfigHomeDir()
}
export function getUserSettingsDisplayPath(): string {
return getDisplayPath(join(getUserConfigHomeForDisplay(), 'settings.json'))
}
export function getUserSkillExampleDisplayPath(): string {
return getDisplayPath(
join(getUserConfigHomeForDisplay(), 'skills', '<name>', 'SKILL.md'),
)
}
+145 -1
View File
@@ -44,6 +44,7 @@ afterEach(() => {
describe('OpenClaude paths', () => {
test('defaults user config home to ~/.openclaude', async () => {
await acquireEnvMutex()
delete process.env.OPENCLAUDE_CONFIG_DIR
delete process.env.CLAUDE_CONFIG_DIR
const { resolveClaudeConfigHomeDir } = await importFreshEnvUtils()
@@ -56,6 +57,7 @@ describe('OpenClaude paths', () => {
test('hard-cuts user config home to ~/.openclaude by default', async () => {
await acquireEnvMutex()
delete process.env.OPENCLAUDE_CONFIG_DIR
delete process.env.CLAUDE_CONFIG_DIR
const { resolveClaudeConfigHomeDir } = await importFreshEnvUtils()
@@ -201,6 +203,7 @@ describe('OpenClaude paths', () => {
homedir: () => tempHome,
tmpdir,
}))
delete process.env.OPENCLAUDE_CONFIG_DIR
delete process.env.CLAUDE_CONFIG_DIR
const { getClaudeConfigHomeDir } = await importFreshEnvUtils()
@@ -213,6 +216,7 @@ describe('OpenClaude paths', () => {
test('default plans directory uses ~/.openclaude/plans', async () => {
await acquireEnvMutex()
delete process.env.OPENCLAUDE_CONFIG_DIR
delete process.env.CLAUDE_CONFIG_DIR
const { getDefaultPlansDirectory } = await importFreshPlans()
@@ -230,6 +234,28 @@ describe('OpenClaude paths', () => {
).toBe(join('/tmp/custom-openclaude', 'plans'))
})
test('default plans directory respects OPENCLAUDE_CONFIG_DIR', async () => {
await acquireEnvMutex()
process.env.OPENCLAUDE_CONFIG_DIR = '/tmp/preferred-openclaude'
delete process.env.CLAUDE_CONFIG_DIR
const { getDefaultPlansDirectory } = await importFreshPlans()
expect(getDefaultPlansDirectory()).toBe(
join('/tmp/preferred-openclaude', 'plans'),
)
})
test('OPENCLAUDE_CONFIG_DIR wins for default plans directory', async () => {
await acquireEnvMutex()
process.env.OPENCLAUDE_CONFIG_DIR = '/tmp/preferred-openclaude'
process.env.CLAUDE_CONFIG_DIR = '/tmp/legacy-openclaude'
const { getDefaultPlansDirectory } = await importFreshPlans()
expect(getDefaultPlansDirectory()).toBe(
join('/tmp/preferred-openclaude', 'plans'),
)
})
test('default plans directory normalizes generated path to NFC', async () => {
await acquireEnvMutex()
const { getDefaultPlansDirectory } = await importFreshPlans()
@@ -248,8 +274,9 @@ describe('OpenClaude paths', () => {
).toBe(join('/tmp/caf\u00e9-openclaude', 'plans'))
})
test('uses CLAUDE_CONFIG_DIR override when provided', async () => {
test('uses CLAUDE_CONFIG_DIR override when provided (legacy)', async () => {
await acquireEnvMutex()
delete process.env.OPENCLAUDE_CONFIG_DIR
process.env.CLAUDE_CONFIG_DIR = '/tmp/custom-openclaude'
const { getClaudeConfigHomeDir, resolveClaudeConfigHomeDir } =
await importFreshEnvUtils()
@@ -262,6 +289,123 @@ describe('OpenClaude paths', () => {
).toBe('/tmp/custom-openclaude')
})
test('OPENCLAUDE_CONFIG_DIR overrides the default (issue #454)', async () => {
await acquireEnvMutex()
delete process.env.CLAUDE_CONFIG_DIR
process.env.OPENCLAUDE_CONFIG_DIR = '/tmp/oc-config-only'
const { getClaudeConfigHomeDir } = await importFreshEnvUtils()
expect(getClaudeConfigHomeDir()).toBe('/tmp/oc-config-only')
})
test('OPENCLAUDE_CONFIG_DIR wins when both env vars are set with different values', async () => {
await acquireEnvMutex()
process.env.OPENCLAUDE_CONFIG_DIR = '/tmp/oc-wins'
process.env.CLAUDE_CONFIG_DIR = '/tmp/legacy-loses'
const { getClaudeConfigHomeDir } = await importFreshEnvUtils()
expect(getClaudeConfigHomeDir()).toBe('/tmp/oc-wins')
})
test('CLAUDE_CONFIG_DIR is still honored when OPENCLAUDE_CONFIG_DIR is unset', async () => {
await acquireEnvMutex()
delete process.env.OPENCLAUDE_CONFIG_DIR
process.env.CLAUDE_CONFIG_DIR = '/tmp/legacy-only'
const { getClaudeConfigHomeDir } = await importFreshEnvUtils()
expect(getClaudeConfigHomeDir()).toBe('/tmp/legacy-only')
})
test('empty OPENCLAUDE_CONFIG_DIR falls through to CLAUDE_CONFIG_DIR', async () => {
await acquireEnvMutex()
process.env.OPENCLAUDE_CONFIG_DIR = ''
process.env.CLAUDE_CONFIG_DIR = '/tmp/legacy-fallback'
const { getClaudeConfigHomeDir } = await importFreshEnvUtils()
expect(getClaudeConfigHomeDir()).toBe('/tmp/legacy-fallback')
})
test('resolveConfigDirEnv prefers OPENCLAUDE over CLAUDE and warns on conflict', async () => {
await acquireEnvMutex()
const { resolveConfigDirEnv, __resetConfigDirEnvWarningForTesting } =
await importFreshEnvUtils()
__resetConfigDirEnvWarningForTesting()
const warnings: string[] = []
const result = resolveConfigDirEnv({
openClaudeConfigDir: '/a',
legacyConfigDir: '/b',
warn: m => warnings.push(m),
})
expect(result).toBe('/a')
expect(warnings.length).toBe(1)
expect(warnings[0]).toContain('OPENCLAUDE_CONFIG_DIR=/a')
expect(warnings[0]).toContain('CLAUDE_CONFIG_DIR=/b')
resolveConfigDirEnv({
openClaudeConfigDir: '/x',
legacyConfigDir: '/y',
warn: m => warnings.push(m),
})
expect(warnings.length).toBe(1)
})
test('resolveConfigDirEnv silent callers do not consume the conflict warning', async () => {
await acquireEnvMutex()
const { resolveConfigDirEnv, __resetConfigDirEnvWarningForTesting } =
await importFreshEnvUtils()
__resetConfigDirEnvWarningForTesting()
expect(
resolveConfigDirEnv({
openClaudeConfigDir: '/silent-open',
legacyConfigDir: '/silent-legacy',
}),
).toBe('/silent-open')
const warnings: string[] = []
expect(
resolveConfigDirEnv({
openClaudeConfigDir: '/warn-open',
legacyConfigDir: '/warn-legacy',
warn: m => warnings.push(m),
}),
).toBe('/warn-open')
expect(warnings.length).toBe(1)
expect(warnings[0]).toContain('OPENCLAUDE_CONFIG_DIR=/warn-open')
expect(warnings[0]).toContain('CLAUDE_CONFIG_DIR=/warn-legacy')
})
test('resolveConfigDirEnv does not warn when both env vars agree', async () => {
await acquireEnvMutex()
const { resolveConfigDirEnv, __resetConfigDirEnvWarningForTesting } =
await importFreshEnvUtils()
__resetConfigDirEnvWarningForTesting()
const warnings: string[] = []
const result = resolveConfigDirEnv({
openClaudeConfigDir: '/same',
legacyConfigDir: '/same',
warn: m => warnings.push(m),
})
expect(result).toBe('/same')
expect(warnings).toEqual([])
})
test('resolveConfigDirEnv returns undefined when neither env var is set', async () => {
await acquireEnvMutex()
const { resolveConfigDirEnv } = await importFreshEnvUtils()
expect(
resolveConfigDirEnv({
openClaudeConfigDir: undefined,
legacyConfigDir: undefined,
}),
).toBeUndefined()
})
test('project and local settings paths use .openclaude', async () => {
await acquireEnvMutex()
const { getRelativeSettingsFilePathForSource } = await importFreshSettings()
+43 -5
View File
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
import { homedir } from 'os'
import { join } from 'path'
import {
@@ -7,7 +7,7 @@ import {
} from '../test/sharedMutationLock.js'
import { isInGlobalClaudeFolder } from '../components/permissions/FilePermissionDialog/permissionOptions.tsx'
import { optionForPermissionSaveDestination } from '../components/permissions/rules/AddPermissionRules.tsx'
import { getDisplayPath } from './file.ts'
import { getDefaultPermissionModeOptions } from './permissions/defaultPermissionModeOptions.ts'
import {
getClaudeSkillScope,
@@ -16,9 +16,13 @@ import {
import { getValidationTip } from './settings/validationTips.ts'
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR
const originalOpenClaudeConfigDir = process.env.OPENCLAUDE_CONFIG_DIR
beforeEach(async () => {
await acquireSharedMutationLock('openclaudeUiSurfaces.test.ts')
mock.restore()
delete process.env.CLAUDE_CONFIG_DIR
delete process.env.OPENCLAUDE_CONFIG_DIR
})
afterEach(() => {
@@ -28,6 +32,11 @@ afterEach(() => {
} else {
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
}
if (originalOpenClaudeConfigDir === undefined) {
delete process.env.OPENCLAUDE_CONFIG_DIR
} else {
process.env.OPENCLAUDE_CONFIG_DIR = originalOpenClaudeConfigDir
}
} finally {
releaseSharedMutationLock()
}
@@ -48,15 +57,44 @@ describe('OpenClaude settings path surfaces', () => {
).toBe(true)
})
test('permission save destinations point user settings to ~/.openclaude', () => {
test('permission save destinations point user settings to configured OPENCLAUDE_CONFIG_DIR', async () => {
const customConfigDir = join(homedir(), 'custom-openclaude')
process.env.OPENCLAUDE_CONFIG_DIR = customConfigDir
delete process.env.CLAUDE_CONFIG_DIR
const { optionForPermissionSaveDestination } = await import(
'../components/permissions/rules/AddPermissionRules.tsx'
)
expect(optionForPermissionSaveDestination('userSettings')).toEqual({
label: 'User settings',
description: 'Saved in ~/.openclaude/settings.json',
description: `Saved in ${getDisplayPath(join(customConfigDir, 'settings.json'))}`,
value: 'userSettings',
})
})
test('permission save destinations point project settings to .openclaude', () => {
test('skills help surfaces point user skills to configured OPENCLAUDE_CONFIG_DIR', async () => {
const customConfigDir = join(homedir(), 'custom-openclaude')
process.env.OPENCLAUDE_CONFIG_DIR = customConfigDir
delete process.env.CLAUDE_CONFIG_DIR
const { getEmptySkillsMenuMessage } = await import(
'../components/skills/SkillsMenu.tsx'
)
const { getCustomCommandsTipContent } = await import(
'../services/tips/tipRegistry.ts'
)
const customSkillPath = getDisplayPath(
join(customConfigDir, 'skills', '<name>', 'SKILL.md'),
)
expect(getEmptySkillsMenuMessage()).toContain(customSkillPath)
expect(getCustomCommandsTipContent()).toContain(customSkillPath)
})
test('permission save destinations point project settings to .openclaude', async () => {
const { optionForPermissionSaveDestination } = await import(
'../components/permissions/rules/AddPermissionRules.tsx'
)
expect(optionForPermissionSaveDestination('projectSettings')).toEqual({
label: 'Project settings',
description: 'Checked in at .openclaude/settings.json',
+5 -1
View File
@@ -19,13 +19,17 @@ import { isENOENT } from './errors.js'
import { getEnvironmentKind } from './filePersistence/outputsScanner.js'
import { getFsImplementation } from './fsOperations.js'
import { logError } from './log.js'
import { resolveConfigDirEnv } from './envUtils.js'
import { getInitialSettings } from './settings/settings.js'
import { generateWordSlug } from './words.js'
const MAX_SLUG_RETRIES = 10
export function getDefaultPlansDirectory({
configDirEnv = process.env.CLAUDE_CONFIG_DIR,
configDirEnv = resolveConfigDirEnv({
openClaudeConfigDir: process.env.OPENCLAUDE_CONFIG_DIR,
legacyConfigDir: process.env.CLAUDE_CONFIG_DIR,
}),
homeDir = homedir(),
}: {
configDirEnv?: string
@@ -34,7 +34,8 @@ export function getSecureStorageServiceName(
serviceSuffix: string = '',
): string {
const configDir = getClaudeConfigHomeDir()
const isDefaultDir = !process.env.CLAUDE_CONFIG_DIR
const isDefaultDir =
!process.env.OPENCLAUDE_CONFIG_DIR && !process.env.CLAUDE_CONFIG_DIR
// Use a hash of the config dir path to create a unique but stable suffix
// Only add suffix for non-default directories to maintain backwards compatibility
@@ -80,12 +80,14 @@ describe("Secure Storage Platform Implementations", () => {
beforeAll(async () => {
await acquireSharedMutationLock("platformStorage.test.ts");
mock.restore();
mock.module("execa", () => ({
...realExeca,
execaSync: mockExecaSync,
}));
({ linuxSecretStorage } = await import("./linuxSecretStorage.js"));
({ windowsCredentialStorage } = await import("./windowsCredentialStorage.js"));
const moduleSuffix = `?platformStorageTest=${Date.now()}-${Math.random()}`;
({ linuxSecretStorage } = await import(`./linuxSecretStorage.js${moduleSuffix}`));
({ windowsCredentialStorage } = await import(`./windowsCredentialStorage.js${moduleSuffix}`));
});
beforeEach(() => {
@@ -120,6 +122,8 @@ describe("Secure Storage Platform Implementations", () => {
describe("Config-Dir Isolation", () => {
test("service name changes with CLAUDE_CONFIG_DIR", () => {
delete process.env.OPENCLAUDE_CONFIG_DIR;
delete process.env.CLAUDE_CONFIG_DIR;
const defaultName = getSecureStorageServiceName(CREDENTIALS_SERVICE_SUFFIX);
process.env.CLAUDE_CONFIG_DIR = "/tmp/other-config";
@@ -130,7 +134,23 @@ describe("Secure Storage Platform Implementations", () => {
expect(otherName).toContain(CREDENTIALS_SERVICE_SUFFIX);
});
test("service name changes with OPENCLAUDE_CONFIG_DIR", () => {
delete process.env.OPENCLAUDE_CONFIG_DIR;
delete process.env.CLAUDE_CONFIG_DIR;
const defaultName = getSecureStorageServiceName(CREDENTIALS_SERVICE_SUFFIX);
process.env.OPENCLAUDE_CONFIG_DIR = "/tmp/preferred-config";
delete process.env.CLAUDE_CONFIG_DIR;
const preferredName = getSecureStorageServiceName(CREDENTIALS_SERVICE_SUFFIX);
expect(preferredName).not.toBe(defaultName);
expect(preferredName).toContain("Claude Code");
expect(preferredName).toContain(CREDENTIALS_SERVICE_SUFFIX);
});
test("Linux storage uses scoped service name", () => {
delete process.env.OPENCLAUDE_CONFIG_DIR;
delete process.env.CLAUDE_CONFIG_DIR;
process.env.CLAUDE_CONFIG_DIR = "/tmp/linux-scoped";
const expectedName = getSecureStorageServiceName(CREDENTIALS_SERVICE_SUFFIX);
@@ -140,7 +160,20 @@ describe("Secure Storage Platform Implementations", () => {
expect(args).toContain(expectedName);
});
test("Linux storage uses OPENCLAUDE_CONFIG_DIR scoped service name", () => {
process.env.OPENCLAUDE_CONFIG_DIR = "/tmp/linux-preferred-scoped";
delete process.env.CLAUDE_CONFIG_DIR;
const expectedName = getSecureStorageServiceName(CREDENTIALS_SERVICE_SUFFIX);
linuxSecretStorage.update(testData);
const args = getSecretToolArgs();
expect(args).toContain(expectedName);
});
test("Windows storage uses scoped resource name", () => {
delete process.env.OPENCLAUDE_CONFIG_DIR;
delete process.env.CLAUDE_CONFIG_DIR;
process.env.CLAUDE_CONFIG_DIR = "/tmp/win-scoped";
const expectedName = getSecureStorageServiceName(CREDENTIALS_SERVICE_SUFFIX);
@@ -151,6 +184,19 @@ describe("Secure Storage Platform Implementations", () => {
expect(script).toContain("ProtectedData");
expect(getCommandInput()).toContain("secret-token");
});
test("Windows storage uses OPENCLAUDE_CONFIG_DIR scoped resource name", () => {
process.env.OPENCLAUDE_CONFIG_DIR = "/tmp/win-preferred-scoped";
delete process.env.CLAUDE_CONFIG_DIR;
const expectedName = getSecureStorageServiceName(CREDENTIALS_SERVICE_SUFFIX);
windowsCredentialStorage.update(testData);
const script = getPowerShellScript();
expect(script).toContain(expectedName);
expect(script).toContain("ProtectedData");
expect(getCommandInput()).toContain("secret-token");
});
});
describe("Windows PowerShell Escaping", () => {
+2 -1
View File
@@ -119,7 +119,8 @@ const TEAMMATE_ENV_VARS = [
'MISTRAL_BASE_URL',
// Custom API endpoint
'ANTHROPIC_BASE_URL',
// Config directory override
// Config directory override (preferred name + legacy alias)
'OPENCLAUDE_CONFIG_DIR',
'CLAUDE_CONFIG_DIR',
// CCR marker — teammates need this for CCR-aware code paths. Auth finds
// its own way via /home/claude/.claude/remote/.oauth_token regardless;
+5 -4
View File
@@ -10,7 +10,7 @@ export const settingsFiles: SettingsFile[] = [
{
path: '~/.openclaude/settings.json',
scope: 'user',
notes: 'Global settings for every project on the machine.',
notes: 'Default global settings path for every project on the machine; OPENCLAUDE_CONFIG_DIR moves this under the configured config home.',
},
{
path: '.openclaude/settings.json',
@@ -23,9 +23,9 @@ export const settingsFiles: SettingsFile[] = [
notes: 'Per-machine overrides for one project; typically gitignored.',
},
{
path: '~/.claude/keybindings.json',
path: '~/.openclaude/keybindings.json',
scope: 'user',
notes: 'Keyboard shortcut overrides — see the keybindings page.',
notes: 'Default keyboard shortcut overrides path; OPENCLAUDE_CONFIG_DIR moves this under the configured config home.',
},
{
path: 'CLAUDE.md / .claude/CLAUDE.md',
@@ -68,7 +68,8 @@ 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 ~/.openclaude).' },
{ name: 'OPENCLAUDE_CONFIG_DIR', description: 'Preferred config directory override. Defaults to ~/.openclaude when unset.' },
{ name: 'CLAUDE_CONFIG_DIR', description: 'Legacy config directory override. Used only when OPENCLAUDE_CONFIG_DIR is unset.' },
{ 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.' },
+1 -1
View File
@@ -1,5 +1,5 @@
// Seeded from src/keybindings/defaultBindings.ts. User overrides live in
// ~/.claude/keybindings.json (open it with /keybindings).
// ~/.openclaude/keybindings.json (open it with /keybindings).
export interface Keybinding {
keys: string
+1 -1
View File
@@ -40,6 +40,6 @@ export const skills: Skill[] = [
name: 'keybindings-help',
invocation: '/keybindings-help',
description:
'Customize keyboard shortcuts: rebind keys, add chord bindings, or modify ~/.claude/keybindings.json.',
'Customize keyboard shortcuts: rebind keys, add chord bindings, or modify your keybindings file (default: ~/.openclaude/keybindings.json; override via OPENCLAUDE_CONFIG_DIR).',
},
]
+4 -2
View File
@@ -10,7 +10,7 @@ const toc = [
<DocsLayout
title="openclaude keybindings — defaults & customization"
description="Default keyboard shortcuts in the openclaude TUI and how to customize them with ~/.claude/keybindings.json and the /keybindings command."
description="Default keyboard shortcuts in the openclaude TUI and how to customize them with the keybindings file (default: ~/.openclaude/keybindings.json; override via OPENCLAUDE_CONFIG_DIR) and the /keybindings command."
heading="keybindings"
lede="The defaults match familiar readline/terminal muscle memory — and everything is rebindable."
toc={toc}
@@ -44,7 +44,9 @@ const toc = [
<h2 id="customizing">customizing</h2>
<p>
Run <code>/keybindings</code> to open (or create) <code>~/.claude/keybindings.json</code>.
Run <code>/keybindings</code> to open (or create) your keybindings file
(default: <code>~/.openclaude/keybindings.json</code>; override via
<code>OPENCLAUDE_CONFIG_DIR</code>).
User bindings are loaded on top of the defaults, so you only need to declare what you
change. Chord bindings like <code>ctrl+x ctrl+e</code> are supported.
</p>
+5 -4
View File
@@ -38,9 +38,10 @@ const toc = [
<h2 id="your-own">writing your own</h2>
<p>
Project skills live in <code>.claude/skills/&lt;name&gt;/SKILL.md</code>; user-level
skills in <code>~/.claude/skills/</code>. Each SKILL.md has frontmatter
(<code>name</code>, <code>description</code>) followed by the instructions the agent
loads when the skill is invoked. Plugins can ship skills too — manage them with
<code>/plugin</code>.
skills default to <code>~/.openclaude/skills/</code> and follow
<code>OPENCLAUDE_CONFIG_DIR</code> when it is set. Each SKILL.md has frontmatter
(<code>name</code>, <code>description</code>) followed by the instructions the
agent loads when the skill is invoked. Plugins can ship skills too — manage
them with <code>/plugin</code>.
</p>
</DocsLayout>