mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 02:34:15 -05:00
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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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/<name>/SKILL.md or ~/.openclaude/skills/<name>/SKILL.md</Text></FullWidthRow>;
|
||||
t3 = <FullWidthRow><Text dimColor={true}>{getEmptySkillsMenuMessage()}</Text></FullWidthRow>;
|
||||
$[6] = t3;
|
||||
} else {
|
||||
t3 = $[6];
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')) }]
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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('')),
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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'),
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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).',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -38,9 +38,10 @@ const toc = [
|
||||
<h2 id="your-own">writing your own</h2>
|
||||
<p>
|
||||
Project skills live in <code>.claude/skills/<name>/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>
|
||||
|
||||
Reference in New Issue
Block a user