From 5701a4f2e13681b0119689de9590f0d5ffe628ff Mon Sep 17 00:00:00 2001 From: Alan Vo <101240385+ALANDVO@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:14:19 +0000 Subject: [PATCH] fix: respect CLAUDE_CONFIG_DIR with WSL Windows-path conversion (cherry picked from commit aaacbc75924367887bcbe57721940acad1c59661) --- CHANGELOG.md | 1 + commands/auto-update.md | 2 +- commands/instinct-status.md | 2 +- commands/sessions.md | 12 +-- commands/skill-health.md | 6 +- commands/skill-host-compat.md | 4 +- hooks/codex-hooks.json | 2 +- hooks/hooks.json | 54 +++++----- hooks/hooks.metadata.json | 54 +++++----- scripts/lib/install-executor.js | 3 +- scripts/lib/install-targets/claude-home.js | 24 +++++ scripts/lib/install-targets/helpers.js | 49 +++++++++ scripts/lib/install/inventory.js | 6 +- scripts/lib/install/runtime.js | 1 + scripts/lib/resolve-ecc-root.js | 24 ++++- tests/lib/command-plugin-root.test.js | 39 ++++++++ tests/lib/install-targets.test.js | 109 +++++++++++++++++++++ tests/lib/resolve-ecc-root.test.js | 44 +++++++++ tests/scripts/install-apply.test.js | 45 +++++++++ 19 files changed, 409 insertions(+), 72 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a67bdf9b..764a58e63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ - GateGuard gates NotebookEdit as a first-touch edit of its `notebook_path`; notebooks were never gated before. - GateGuard prior-search credit no longer counts a search that never saw the target. File-type filters (`rg -t`/`-T`/`--type`/`--type-not`, `fd -e`/`--extension`, the Grep tool's `type`), the Glob tool's pattern, shell glob operands, and git pathspecs now filter like include globs, so `rg -t py tokenizer src` or `Glob **/tokenizer.py` no longer credits `tokenizer.js`. A search confined to the target's own file name (`Glob **/tokenizer.js`, `find -name tokenizer.js`, Grep `glob: 'tokenizer.js'`) is a lookup and never credits it. PowerShell searches keep backslashes as path separators. - GateGuard no longer passes an edit as comment-only when its text contains U+2028, U+2029, or U+0085, which end a JavaScript or C# line comment. +- Claude Code home installs (`ecc install --target claude` and `install.sh`) now install under `CLAUDE_CONFIG_DIR` when it is set, matching the directory Claude Code reads its user-level config from. Previously ECC wrote to `~/.claude` regardless, so when `CLAUDE_CONFIG_DIR` redirected the config root the install never loaded ("installs, but nothing loads"); the settings.json, plugin.json, hook commands, bundle and install state were all stranded in `~/.claude` while Claude Code read an empty `CLAUDE_CONFIG_DIR`. The claude-home adapter now resolves its root the same way the runtime does (`CLAUDE_CONFIG_DIR || ~/.claude`), consistent with the opencode adapter and `resolveClaudePaths`. ## 2.2.3 - 2026-10-01 diff --git a/commands/auto-update.md b/commands/auto-update.md index 54524f551..41e0d43e1 100644 --- a/commands/auto-update.md +++ b/commands/auto-update.md @@ -11,7 +11,7 @@ Update ECC from its upstream repo and regenerate the current context's managed i ```bash # Preview the update without mutating anything -ECC_ROOT="$(node -e "var r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=(process.env.CLAUDE_CONFIG_DIR||'').trim()||p.join(o.homedir(),'.claude');if(d==='~')d=o.homedir();else if(d.charAt(0)==='~'&&(d.charAt(1)==='/'||d.charCodeAt(1)===92))d=p.join(o.homedir(),d.slice(2));d=p.resolve(d);function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot({probe:p.join('scripts','auto-update.js')})}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i { diff --git a/scripts/lib/install-targets/helpers.js b/scripts/lib/install-targets/helpers.js index 3ff268a46..f099b1239 100644 --- a/scripts/lib/install-targets/helpers.js +++ b/scripts/lib/install-targets/helpers.js @@ -7,6 +7,51 @@ const { } = require('../install/claude-settings'); const { METADATA_FILENAME } = require('../hooks-config'); +// Memoized WSL detection (avoids repeated /proc/version reads) +let _isWSL = null; +function isWSL() { + if (_isWSL !== null) return _isWSL; + if (process.platform !== 'linux') { + _isWSL = false; + return false; + } + try { + _isWSL = fs.readFileSync('/proc/version', 'utf8').toLowerCase().includes('microsoft'); + } catch { + _isWSL = false; + } + return _isWSL; +} + +// Reset WSL detection memoization (for testing only) +function resetWSLDetection() { + _isWSL = null; +} + +// Set WSL detection for testing (for testing only) +function setWSLForTesting(value) { + _isWSL = value; +} + +// Windows path pattern: drive letter followed by :\ or :/ +const WINDOWS_PATH_PATTERN = /^[A-Za-z]:[\\/]/; + +/** + * Convert a Windows path to a WSL path. + * Example: "C:\Users\name" -> "/mnt/c/Users/name" + * Example: "D:/Projects" -> "/mnt/d/Projects" + * Returns the original path if it's not a Windows path or not on WSL. + */ +function convertWindowsPathToWSL(windowsPath) { + if (!isWSL()) return windowsPath; + if (typeof windowsPath !== 'string') return windowsPath; + const trimmed = windowsPath.trim(); + if (!WINDOWS_PATH_PATTERN.test(trimmed)) return trimmed; + const driveLetter = trimmed[0].toLowerCase(); + const rest = trimmed.slice(2).replace(/\\/g, '/'); + return `/mnt/${driveLetter}${rest}`; +} + const PLATFORM_SOURCE_PATH_OWNERS = Object.freeze({ '.claude-plugin': 'claude', '.codex': 'codex', @@ -441,4 +486,8 @@ module.exports = { isForeignPlatformPath, normalizeRelativePath, planClaudeHooksOperations, + isWSL, + resetWSLDetection, + setWSLForTesting, + convertWindowsPathToWSL, }; diff --git a/scripts/lib/install/inventory.js b/scripts/lib/install/inventory.js index dc861501d..8718fb70e 100644 --- a/scripts/lib/install/inventory.js +++ b/scripts/lib/install/inventory.js @@ -4,6 +4,7 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); const { isWithinRoot, realpathNearestExisting } = require('../path-safety'); +const { convertWindowsPathToWSL } = require('../install-targets/helpers'); const CURRENT_PLUGIN_ID = 'ecc@ecc'; // Legacy plugin ids, kept so existing installs are still recognized. @@ -17,9 +18,12 @@ function resolveClaudePaths(options = {}) { || process.env.HOME || process.env.USERPROFILE || os.homedir(); - const configDir = options.configDir + const rawConfigDir = options.configDir || process.env.CLAUDE_CONFIG_DIR || path.join(homeDir, '.claude'); + // On WSL, convert Windows paths (e.g., C:\Users\name\.claude) to WSL paths + // (/mnt/c/Users/name/.claude) so the resolver finds the correct location. + const configDir = convertWindowsPathToWSL(rawConfigDir); const projectRoot = options.projectRoot || process.cwd(); return { diff --git a/scripts/lib/install/runtime.js b/scripts/lib/install/runtime.js index eabb930b2..4d9e256c9 100644 --- a/scripts/lib/install/runtime.js +++ b/scripts/lib/install/runtime.js @@ -53,6 +53,7 @@ function createRawInstallPlan(request, options = {}) { languages: request.languages, projectRoot: options.projectRoot, homeDir: options.homeDir, + env: resolveInvocationEnvironment(options), claudeRulesDir: options.claudeRulesDir, sourceRoot: options.sourceRoot, }); diff --git a/scripts/lib/resolve-ecc-root.js b/scripts/lib/resolve-ecc-root.js index b6f6eaf25..4002593bc 100644 --- a/scripts/lib/resolve-ecc-root.js +++ b/scripts/lib/resolve-ecc-root.js @@ -4,6 +4,23 @@ const fs = require('fs'); const path = require('path'); const os = require('os'); +// Lazily loaded so this module can be required from stripped-down directories +// (e.g., temp dirs in tests that copy only resolve-ecc-root.js + utils.js). +// Falls back to identity on failure, which matches non-WSL behavior. +let _convertWindowsPathToWSL = null; +function convertWindowsPathToWSL(windowsPath) { + if (_convertWindowsPathToWSL === null) { + try { + _convertWindowsPathToWSL = require('./install-targets/helpers').convertWindowsPathToWSL; + } catch (_) { + // Module unavailable from this location; non-WSL behavior returns + // the input unchanged, so the fallback is safe. + _convertWindowsPathToWSL = (p) => p; + } + } + return _convertWindowsPathToWSL(windowsPath); +} + const CURRENT_PLUGIN_SLUG = 'ecc'; // Legacy install id, kept for existing installs. const LEGACY_PLUGIN_SLUG = 'everything-claude-code'; @@ -66,7 +83,10 @@ function resolveEccRoot(options = {}) { const configuredDir = options.configDir !== undefined ? options.configDir : process.env.CLAUDE_CONFIG_DIR; - const rawDir = typeof configuredDir === 'string' ? configuredDir.trim() : ''; + // On WSL, convert Windows paths (e.g., C:\Users\name\.claude) to WSL paths + // (/mnt/c/Users/name/.claude) so the resolver finds the correct location. + const wslConfiguredDir = convertWindowsPathToWSL(configuredDir); + const rawDir = typeof wslConfiguredDir === 'string' ? wslConfiguredDir.trim() : ''; const expandedDir = rawDir === '~' ? homeDir : (rawDir.startsWith('~/') || rawDir.startsWith('~\\')) @@ -154,7 +174,7 @@ function normalizePluginRootForPlatform(rootDir, platform = process.platform) { * const _r = ; * const sm = require(_r + '/scripts/lib/session-manager'); */ -const INLINE_RESOLVE = `(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=(process.env.CLAUDE_CONFIG_DIR||'').trim()||p.join(o.homedir(),'.claude');if(d==='~')d=o.homedir();else if(d.charAt(0)==='~'&&(d.charAt(1)==='/'||d.charCodeAt(1)===92))d=p.join(o.homedir(),d.slice(2));d=p.resolve(d);function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i { + const checked = countHooksWithWslConversion(path.join(__dirname, '..', '..', 'hooks', 'hooks.json')); + assert.ok(checked > 0, 'Expected at least one hooks.json resolver with CLAUDE_CONFIG_DIR'); +}); + +test('codex-hooks.json inline resolver includes WSL path conversion for CLAUDE_CONFIG_DIR', () => { + const checked = countHooksWithWslConversion(path.join(__dirname, '..', '..', 'hooks', 'codex-hooks.json')); + assert.strictEqual(checked, 1, 'Expected 1 codex-hooks.json resolver with CLAUDE_CONFIG_DIR'); +}); + console.log(`Passed: ${passed}`); console.log(`Failed: ${failed}`); diff --git a/tests/lib/install-targets.test.js b/tests/lib/install-targets.test.js index 68cedc557..a9ec9a95c 100644 --- a/tests/lib/install-targets.test.js +++ b/tests/lib/install-targets.test.js @@ -705,6 +705,61 @@ function runTests() { ); })) passed++; else failed++; + if (test('claude adapter honors CLAUDE_CONFIG_DIR over the default home directory', () => { + const adapter = getInstallTargetAdapter('claude'); + const homeDir = '/Users/example'; + const claudeConfigDir = '/private/claude-profile'; + + assert.strictEqual( + adapter.resolveRoot({ homeDir, env: { CLAUDE_CONFIG_DIR: claudeConfigDir } }), + path.resolve(claudeConfigDir) + ); + assert.strictEqual( + adapter.getInstallStatePath({ homeDir, env: { CLAUDE_CONFIG_DIR: claudeConfigDir } }), + path.join(path.resolve(claudeConfigDir), 'ecc', 'install-state.json') + ); + })) passed++; else failed++; + + if (test('claude adapter falls back to home/.claude when CLAUDE_CONFIG_DIR is unset or blank', () => { + const adapter = getInstallTargetAdapter('claude'); + const homeDir = '/Users/example'; + + assert.strictEqual( + adapter.resolveRoot({ homeDir, env: {} }), + path.join(homeDir, '.claude') + ); + assert.strictEqual( + adapter.resolveRoot({ homeDir, env: { CLAUDE_CONFIG_DIR: ' ' } }), + path.join(homeDir, '.claude') + ); + })) passed++; else failed++; + + if (test('plans claude install under CLAUDE_CONFIG_DIR, keeping operations in the redirected root', () => { + const repoRoot = path.join(__dirname, '..', '..'); + const homeDir = '/Users/example'; + const claudeConfigDir = '/private/claude-profile'; + + const plan = planInstallTargetScaffold({ + target: 'claude', + repoRoot, + homeDir, + env: { CLAUDE_CONFIG_DIR: claudeConfigDir }, + modules: [ + { id: 'rules-core', paths: ['rules'] }, + { id: 'docs-core', paths: ['docs'] }, + ], + }); + + assert.strictEqual(plan.targetRoot, path.resolve(claudeConfigDir)); + assert.ok( + plan.operations.every(operation => ( + operation.destinationPath === plan.targetRoot + || operation.destinationPath.startsWith(`${plan.targetRoot}${path.sep}`) + )), + 'Should keep every managed operation inside the CLAUDE_CONFIG_DIR root' + ); + })) passed++; else failed++; + if (test('invocation environments are immutable snapshots', () => { const source = { OPENCODE_CONFIG_DIR: '/custom/opencode' }; const selected = resolveInvocationEnvironment({ env: source }); @@ -1495,6 +1550,60 @@ function runTests() { } })) passed++; else failed++; + if (test('convertWindowsPathToWSL converts Windows paths on WSL', () => { + const { convertWindowsPathToWSL, resetWSLDetection, setWSLForTesting } = require('../../scripts/lib/install-targets/helpers'); + setWSLForTesting(true); + try { + assert.strictEqual(convertWindowsPathToWSL('C:\\Users\\test\\.claude'), '/mnt/c/Users/test/.claude'); + assert.strictEqual(convertWindowsPathToWSL('D:/Projects'), '/mnt/d/Projects'); + assert.strictEqual(convertWindowsPathToWSL('C:\\Path\\With\\Backslashes'), '/mnt/c/Path/With/Backslashes'); + } finally { + resetWSLDetection(); + } + })) passed++; else failed++; + + if (test('convertWindowsPathToWSL returns original path when not on WSL', () => { + const { convertWindowsPathToWSL, resetWSLDetection, setWSLForTesting } = require('../../scripts/lib/install-targets/helpers'); + setWSLForTesting(false); + try { + assert.strictEqual(convertWindowsPathToWSL('C:\\Users\\test\\.claude'), 'C:\\Users\\test\\.claude'); + assert.strictEqual(convertWindowsPathToWSL('/home/user/.claude'), '/home/user/.claude'); + } finally { + resetWSLDetection(); + } + })) passed++; else failed++; + + if (test('convertWindowsPathToWSL handles non-string input', () => { + const { convertWindowsPathToWSL, resetWSLDetection, setWSLForTesting } = require('../../scripts/lib/install-targets/helpers'); + setWSLForTesting(true); + try { + assert.strictEqual(convertWindowsPathToWSL(null), null); + assert.strictEqual(convertWindowsPathToWSL(undefined), undefined); + assert.strictEqual(convertWindowsPathToWSL(123), 123); + } finally { + resetWSLDetection(); + } + })) passed++; else failed++; + + if (test('claude adapter converts CLAUDE_CONFIG_DIR Windows path to WSL path on WSL', () => { + const adapter = getInstallTargetAdapter('claude'); + const homeDir = '/home/user'; + const windowsConfigDir = 'C:\\Users\\test\\.claude'; + const expectedWslPath = '/mnt/c/Users/test/.claude'; + + // Mock WSL detection + const { setWSLForTesting } = require('../../scripts/lib/install-targets/helpers'); + setWSLForTesting(true); + try { + const resolved = adapter.resolveRoot({ homeDir, env: { CLAUDE_CONFIG_DIR: windowsConfigDir } }); + assert.strictEqual(resolved, path.resolve(expectedWslPath)); + const statePath = adapter.getInstallStatePath({ homeDir, env: { CLAUDE_CONFIG_DIR: windowsConfigDir } }); + assert.strictEqual(statePath, path.join(path.resolve(expectedWslPath), 'ecc', 'install-state.json')); + } finally { + setWSLForTesting(null); + } + })) passed++; else failed++; + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); process.exit(failed > 0 ? 1 : 0); } diff --git a/tests/lib/resolve-ecc-root.test.js b/tests/lib/resolve-ecc-root.test.js index f532eba98..f2b1e1482 100644 --- a/tests/lib/resolve-ecc-root.test.js +++ b/tests/lib/resolve-ecc-root.test.js @@ -534,6 +534,50 @@ module.exports = { resolveEccRoot() { assert.strictEqual(process.env.HOME, ${JSO } })) passed++; else failed++; + // ─── WSL Path Conversion ─── + + if (test('resolveEccRoot converts Windows CLAUDE_CONFIG_DIR to WSL path on WSL', () => { + const { setWSLForTesting, resetWSLDetection } = require('../../scripts/lib/install-targets/helpers'); + setWSLForTesting(true); + try { + const homeDir = createTempDir(); + try { + const result = resolveEccRoot({ + homeDir, + configDir: 'C:\\Users\\test\\.claude' + }); + // On WSL, 'C:\\Users\\test\\.claude' is converted to '/mnt/c/Users/test/.claude'. + // No install exists at the converted path, so resolveEccRoot falls back to it. + assert.strictEqual(result, '/mnt/c/Users/test/.claude'); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + } + } finally { + resetWSLDetection(); + } + })) passed++; else failed++; + + if (test('resolveEccRoot returns original path when not on WSL', () => { + const { setWSLForTesting, resetWSLDetection } = require('../../scripts/lib/install-targets/helpers'); + setWSLForTesting(false); + try { + const homeDir = createTempDir(); + try { + const result = resolveEccRoot({ + homeDir, + configDir: 'C:\\Users\\test\\.claude' + }); + // Not on WSL: Windows path is not converted. On Linux, path.resolve treats + // it as a relative path, resolving against the current working directory. + assert.strictEqual(result, path.resolve('C:\\Users\\test\\.claude')); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + } + } finally { + resetWSLDetection(); + } + })) passed++; else failed++; + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); process.exit(failed > 0 ? 1 : 0); } diff --git a/tests/scripts/install-apply.test.js b/tests/scripts/install-apply.test.js index 36db330b8..84f1569e8 100644 --- a/tests/scripts/install-apply.test.js +++ b/tests/scripts/install-apply.test.js @@ -1692,6 +1692,51 @@ function runTests() { cleanup(projectDir); } })) passed++; else failed++; + if (test('legacy install respects CLAUDE_CONFIG_DIR environment variable', () => { + const homeDir = createTempDir('install-apply-legacy-claude-config-'); + const projectDir = createTempDir('install-apply-legacy-claude-config-project-'); + const customClaudeDir = path.join(homeDir, 'custom-claude-config'); + + try { + const env = { CLAUDE_CONFIG_DIR: customClaudeDir }; + // Use legacy mode (--target claude without --profile), provide a language + const result = run(['--target', 'claude', 'typescript', '--no-hooks'], { cwd: projectDir, homeDir, env }); + assert.strictEqual(result.code, 0, result.stderr); + + // Verify install landed in CLAUDE_CONFIG_DIR, not ~/.claude + assert.ok(fs.existsSync(path.join(customClaudeDir, 'rules', 'ecc', 'common', 'coding-style.md')), + 'Expected rules in CLAUDE_CONFIG_DIR'); + assert.ok(fs.existsSync(path.join(customClaudeDir, 'ecc', 'install-state.json')), + 'Expected install-state in CLAUDE_CONFIG_DIR'); + assert.ok(!fs.existsSync(path.join(homeDir, '.claude', 'rules', 'ecc')), + 'Should not install to default ~/.claude when CLAUDE_CONFIG_DIR is set'); + + const state = readJson(path.join(customClaudeDir, 'ecc', 'install-state.json')); + assert.strictEqual(fs.realpathSync(state.target.root), fs.realpathSync(customClaudeDir)); + } finally { + cleanup(homeDir); + cleanup(projectDir); + } + })) passed++; else failed++; + + if (test('legacy install passes CLAUDE_CONFIG_DIR through to adapter', () => { + const homeDir = createTempDir('install-apply-legacy-env-'); + const projectDir = createTempDir('install-apply-legacy-env-project-'); + const customClaudeDir = path.join(homeDir, 'custom-claude-config-env'); + + try { + const env = { CLAUDE_CONFIG_DIR: customClaudeDir }; + const result = run(['--target', 'claude', 'typescript', '--no-hooks'], { cwd: projectDir, homeDir, env }); + assert.strictEqual(result.code, 0, result.stderr); + + // Verify install landed in CLAUDE_CONFIG_DIR + const state = readJson(path.join(customClaudeDir, 'ecc', 'install-state.json')); + assert.strictEqual(fs.realpathSync(state.target.root), fs.realpathSync(customClaudeDir)); + } finally { + cleanup(homeDir); + cleanup(projectDir); + } + })) passed++; else failed++; console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); process.exit(failed > 0 ? 1 : 0);