From 72e26e0e14d29a4c42fd9f5e484e9fcd8b98417b Mon Sep 17 00:00:00 2001 From: RJ Burnham Date: Sat, 18 Jul 2026 09:23:34 -0300 Subject: [PATCH] fix: honor CLAUDE_CONFIG_DIR everywhere Claude Code's config/data dir is read CloudCLI hardcoded ~/.claude across every read/write of Claude Code's own config and data directory (projects, sessions, settings.json, .claude.json, .credentials.json, commands, external-projects, MCP config, skills, and the sessions watcher's root path). Claude Code CLI itself honors CLAUDE_CONFIG_DIR to relocate this directory (e.g. for running multiple accounts/profiles), but CloudCLI silently ignored it and always fell back to the default location. Adds a single shared helper, getClaudeConfigDir(), that resolves CLAUDE_CONFIG_DIR when set (falling back to ~/.claude otherwise), and updates every Claude-specific call site to use it. Also surfaces CLAUDE_CONFIG_DIR in `cloudcli status` output for visibility. Cursor/Codex/OpenCode paths and CloudCLI's own app-level state (~/.cloudcli, ~/.claude-code-ui) are untouched - this only affects paths that are genuinely Claude Code's own. Verified: `cloudcli status` now reports the correct projects folder under CLAUDE_CONFIG_DIR, and a live server run against a populated alt profile correctly indexed all of its sessions instead of the default ~/.claude. --- eslint.config.js | 1 + server/claude-sdk.js | 4 ++-- server/cli.js | 4 +++- server/index.js | 6 ++++-- .../list/claude/claude-auth.provider.ts | 6 +++--- .../list/claude/claude-mcp.provider.ts | 6 +++--- .../claude-session-synchronizer.provider.ts | 4 ++-- .../list/claude/claude-skills.provider.ts | 4 ++-- .../services/sessions-watcher.service.ts | 3 ++- server/routes/agent.js | 6 +++--- server/routes/commands.js | 9 ++++---- server/shared/claude-config-dir.ts | 21 +++++++++++++++++++ server/utils/mcp-detector.js | 8 +++---- 13 files changed, 54 insertions(+), 28 deletions(-) create mode 100644 server/shared/claude-config-dir.ts diff --git a/eslint.config.js b/eslint.config.js index df10d25e8e..c04016f7d5 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -161,6 +161,7 @@ export default tseslint.config( "server/shared/utils.{js,ts}", "server/shared/frontmatter.ts", "server/shared/claude-cli-path.ts", + "server/shared/claude-config-dir.ts", "server/shared/image-attachments.ts", ], // classify shared utility files so modules can depend on them explicitly mode: "file", diff --git a/server/claude-sdk.js b/server/claude-sdk.js index 6e5bb5cee5..acbd31283b 100644 --- a/server/claude-sdk.js +++ b/server/claude-sdk.js @@ -14,7 +14,6 @@ import crypto from 'crypto'; import { promises as fs } from 'fs'; -import os from 'os'; import path from 'path'; import { query } from '@anthropic-ai/claude-agent-sdk'; @@ -23,6 +22,7 @@ import { buildClaudeUserContent, normalizeImageDescriptors } from './shared/imag import { CLAUDE_FALLBACK_MODELS } from './modules/providers/list/claude/claude-models.provider.js'; import { providerModelsService } from './modules/providers/services/provider-models.service.js'; import { resolveClaudeCodeExecutablePath } from './shared/claude-cli-path.js'; +import { getClaudeConfigDir } from './shared/claude-config-dir.js'; import { createNotificationEvent, notifyRunFailed, @@ -400,7 +400,7 @@ async function buildPromptPayload(command, images, cwd) { */ async function loadMcpConfig(cwd) { try { - const claudeConfigPath = path.join(os.homedir(), '.claude.json'); + const claudeConfigPath = path.join(getClaudeConfigDir(), '.claude.json'); // Check if config file exists try { diff --git a/server/cli.js b/server/cli.js index 08d3af48a5..b183acb18c 100755 --- a/server/cli.js +++ b/server/cli.js @@ -18,6 +18,7 @@ import fs from 'fs'; import path from 'path'; import os from 'os'; import { findAppRoot, getModuleDir } from './utils/runtime-paths.js'; +import { getClaudeConfigDir } from './shared/claude-config-dir.js'; const __dirname = getModuleDir(import.meta.url); // The CLI is compiled into dist-server/server, but it still needs to read the top-level @@ -120,9 +121,10 @@ function showStatus() { console.log(` DATABASE_PATH: ${c.dim(process.env.DATABASE_PATH || '(using default location)')}`); console.log(` CLAUDE_CLI_PATH: ${c.dim(process.env.CLAUDE_CLI_PATH || 'claude (default)')}`); console.log(` CONTEXT_WINDOW: ${c.dim(process.env.CONTEXT_WINDOW || '160000 (default)')}`); + console.log(` CLAUDE_CONFIG_DIR: ${c.dim(process.env.CLAUDE_CONFIG_DIR || '(using default ~/.claude)')}`); // Claude projects folder - const claudeProjectsPath = path.join(os.homedir(), '.claude', 'projects'); + const claudeProjectsPath = path.join(getClaudeConfigDir(), 'projects'); const projectsExists = fs.existsSync(claudeProjectsPath); console.log(`\n${c.info('[INFO]')} Claude Projects Folder:`); console.log(` ${c.dim(claudeProjectsPath)}`); diff --git a/server/index.js b/server/index.js index 0832c4905b..c0a1875450 100755 --- a/server/index.js +++ b/server/index.js @@ -15,6 +15,7 @@ import mime from 'mime-types'; import Database from 'better-sqlite3'; import { AppError, WORKSPACES_ROOT, getOpenCodeDatabasePath, validateWorkspacePath } from '@/shared/utils.js'; +import { getClaudeConfigDir } from '@/shared/claude-config-dir.js'; import { closeSessionsWatcher, initializeSessionsWatcher } from '@/modules/providers/index.js'; import { createWebSocketServer } from '@/modules/websocket/index.js'; @@ -1256,10 +1257,11 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate } // Construct the JSONL file path - // Claude stores session files in ~/.claude/projects/[encoded-project-path]/[session-id].jsonl + // Claude stores session files in $CLAUDE_CONFIG_DIR/projects/[encoded-project-path]/[session-id].jsonl + // (or ~/.claude/projects/... when CLAUDE_CONFIG_DIR is unset) // The encoding replaces any non-alphanumeric character (except -) with - const encodedPath = projectPath.replace(/[^a-zA-Z0-9-]/g, '-'); - const projectDir = path.join(homeDir, '.claude', 'projects', encodedPath); + const projectDir = path.join(getClaudeConfigDir(), 'projects', encodedPath); // Prefer the indexed transcript path (already produced by the trusted // session synchronizer); fall back to the conventional location diff --git a/server/modules/providers/list/claude/claude-auth.provider.ts b/server/modules/providers/list/claude/claude-auth.provider.ts index 6caa65800c..b22fa62e7f 100644 --- a/server/modules/providers/list/claude/claude-auth.provider.ts +++ b/server/modules/providers/list/claude/claude-auth.provider.ts @@ -1,10 +1,10 @@ import { readFile } from 'node:fs/promises'; -import os from 'node:os'; import path from 'node:path'; import spawn from 'cross-spawn'; import { resolveClaudeCodeExecutablePath } from '@/shared/claude-cli-path.js'; +import { getClaudeConfigDir } from '@/shared/claude-config-dir.js'; import type { IProviderAuth } from '@/shared/interfaces.js'; import type { ProviderAuthStatus } from '@/shared/types.js'; import { readObjectRecord, readOptionalString } from '@/shared/utils.js'; @@ -68,7 +68,7 @@ export class ClaudeProviderAuth implements IProviderAuth { */ private async loadSettingsEnv(): Promise> { try { - const settingsPath = path.join(os.homedir(), '.claude', 'settings.json'); + const settingsPath = path.join(getClaudeConfigDir(), 'settings.json'); const content = await readFile(settingsPath, 'utf8'); const settings = readObjectRecord(JSON.parse(content)); return readObjectRecord(settings?.env) ?? {}; @@ -101,7 +101,7 @@ export class ClaudeProviderAuth implements IProviderAuth { } try { - const credPath = path.join(os.homedir(), '.claude', '.credentials.json'); + const credPath = path.join(getClaudeConfigDir(), '.credentials.json'); const content = await readFile(credPath, 'utf8'); const creds = readObjectRecord(JSON.parse(content)) ?? {}; const oauth = readObjectRecord(creds.claudeAiOauth); diff --git a/server/modules/providers/list/claude/claude-mcp.provider.ts b/server/modules/providers/list/claude/claude-mcp.provider.ts index fb4b4ac581..c8a76060ed 100644 --- a/server/modules/providers/list/claude/claude-mcp.provider.ts +++ b/server/modules/providers/list/claude/claude-mcp.provider.ts @@ -1,7 +1,7 @@ -import os from 'node:os'; import path from 'node:path'; import { McpProvider } from '@/modules/providers/shared/mcp/mcp.provider.js'; +import { getClaudeConfigDir } from '@/shared/claude-config-dir.js'; import type { McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js'; import { AppError, @@ -25,7 +25,7 @@ export class ClaudeMcpProvider extends McpProvider { return readObjectRecord(config.mcpServers) ?? {}; } - const filePath = path.join(os.homedir(), '.claude.json'); + const filePath = path.join(getClaudeConfigDir(), '.claude.json'); const config = await readJsonConfig(filePath); if (scope === 'user') { return readObjectRecord(config.mcpServers) ?? {}; @@ -49,7 +49,7 @@ export class ClaudeMcpProvider extends McpProvider { return; } - const filePath = path.join(os.homedir(), '.claude.json'); + const filePath = path.join(getClaudeConfigDir(), '.claude.json'); const config = await readJsonConfig(filePath); if (scope === 'user') { config.mcpServers = servers; diff --git a/server/modules/providers/list/claude/claude-session-synchronizer.provider.ts b/server/modules/providers/list/claude/claude-session-synchronizer.provider.ts index 9320a2fe02..e859c05102 100644 --- a/server/modules/providers/list/claude/claude-session-synchronizer.provider.ts +++ b/server/modules/providers/list/claude/claude-session-synchronizer.provider.ts @@ -1,4 +1,3 @@ -import os from 'node:os'; import path from 'node:path'; import { readFile } from 'node:fs/promises'; @@ -10,6 +9,7 @@ import { normalizeSessionName, readFileTimestamps, } from '@/shared/utils.js'; +import { getClaudeConfigDir } from '@/shared/claude-config-dir.js'; import type { IProviderSessionSynchronizer } from '@/shared/interfaces.js'; type ParsedSession = { @@ -23,7 +23,7 @@ type ParsedSession = { */ export class ClaudeSessionSynchronizer implements IProviderSessionSynchronizer { private readonly provider = 'claude' as const; - private readonly claudeHome = path.join(os.homedir(), '.claude'); + private readonly claudeHome = getClaudeConfigDir(); /** * Returns true when a JSONL file is a subagent transcript rather than a diff --git a/server/modules/providers/list/claude/claude-skills.provider.ts b/server/modules/providers/list/claude/claude-skills.provider.ts index 5462b6b99f..29120d0952 100644 --- a/server/modules/providers/list/claude/claude-skills.provider.ts +++ b/server/modules/providers/list/claude/claude-skills.provider.ts @@ -1,9 +1,9 @@ import { readFile, readdir, stat } from 'node:fs/promises'; -import os from 'node:os'; import path from 'node:path'; import { SkillsProvider } from '@/modules/providers/shared/skills/skills.provider.js'; import { parseFrontMatter } from '@/shared/frontmatter.js'; +import { getClaudeConfigDir } from '@/shared/claude-config-dir.js'; import type { ProviderSkill, ProviderSkillListOptions, @@ -17,7 +17,7 @@ import { readProviderSkillMarkdownDefinition, } from '@/shared/utils.js'; -const getClaudeHomePath = (): string => path.join(os.homedir(), '.claude'); +const getClaudeHomePath = (): string => getClaudeConfigDir(); const getClaudePluginName = (pluginId: string): string | null => { const normalizedPluginId = pluginId.trim(); diff --git a/server/modules/providers/services/sessions-watcher.service.ts b/server/modules/providers/services/sessions-watcher.service.ts index 96f13e2ab4..3b693672b8 100644 --- a/server/modules/providers/services/sessions-watcher.service.ts +++ b/server/modules/providers/services/sessions-watcher.service.ts @@ -9,13 +9,14 @@ import { sessionSynchronizerService } from '@/modules/providers/services/session import { WS_OPEN_STATE, connectedClients } from '@/modules/websocket/index.js'; import type { LLMProvider } from '@/shared/types.js'; import { generateDisplayName } from '@/modules/projects/index.js'; +import { getClaudeConfigDir } from '@/shared/claude-config-dir.js'; type WatcherEventType = 'add' | 'change'; const PROVIDER_WATCH_PATHS: Array<{ provider: LLMProvider; rootPath: string }> = [ { provider: 'claude', - rootPath: path.join(os.homedir(), '.claude', 'projects'), + rootPath: path.join(getClaudeConfigDir(), 'projects'), }, { provider: 'cursor', diff --git a/server/routes/agent.js b/server/routes/agent.js index 1ae605b69e..e9d15cb908 100644 --- a/server/routes/agent.js +++ b/server/routes/agent.js @@ -2,7 +2,6 @@ import express from 'express'; // cross-spawn: drop-in spawn with Windows .cmd/PATHEXT resolution. import spawn from 'cross-spawn'; import path from 'path'; -import os from 'os'; import { promises as fs } from 'fs'; import crypto from 'crypto'; import { userDb, apiKeysDb, githubTokensDb, projectsDb } from '../modules/database/index.js'; @@ -14,6 +13,7 @@ import { Octokit } from '@octokit/rest'; import { providerModelsService } from '../modules/providers/services/provider-models.service.js'; import { IS_PLATFORM } from '../constants/config.js'; import { normalizeProjectPath } from '../shared/utils.js'; +import { getClaudeConfigDir } from '../shared/claude-config-dir.js'; const router = express.Router(); @@ -434,7 +434,7 @@ async function cleanupProject(projectPath, sessionId = null) { // Also clean up the Claude session directory if sessionId provided if (sessionId) { try { - const sessionPath = path.join(os.homedir(), '.claude', 'sessions', sessionId); + const sessionPath = path.join(getClaudeConfigDir(), 'sessions', sessionId); console.log('๐Ÿงน Cleaning up session directory:', sessionPath); await fs.rm(sessionPath, { recursive: true, force: true }); console.log('โœ… Session directory cleaned up'); @@ -895,7 +895,7 @@ router.post('/', validateExternalApiKey, async (req, res) => { } else { // Generate a unique path for cloning const repoHash = crypto.createHash('md5').update(githubUrl + Date.now()).digest('hex'); - targetPath = path.join(os.homedir(), '.claude', 'external-projects', repoHash); + targetPath = path.join(getClaudeConfigDir(), 'external-projects', repoHash); } finalProjectPath = await cloneGitHubRepo(githubUrl.trim(), tokenToUse, targetPath); diff --git a/server/routes/commands.js b/server/routes/commands.js index 722fd545bc..9239a0a8b2 100644 --- a/server/routes/commands.js +++ b/server/routes/commands.js @@ -1,5 +1,4 @@ import { promises as fs } from "fs"; -import os from "os"; import path from "path"; import express from "express"; @@ -7,6 +6,7 @@ import express from "express"; import { providerModelsService } from "../modules/providers/services/provider-models.service.js"; import { parseFrontMatter } from "../shared/frontmatter.js"; import { findAppRoot, getModuleDir } from "../utils/runtime-paths.js"; +import { getClaudeConfigDir } from "../shared/claude-config-dir.js"; const __dirname = getModuleDir(import.meta.url); // This route reads the top-level package.json for the status command, so it needs the real @@ -452,9 +452,8 @@ router.post("/list", async (req, res) => { allCommands.push(...projectCommands); } - // Scan user-level commands (~/.claude/commands/) - const homeDir = os.homedir(); - const userCommandsDir = path.join(homeDir, ".claude", "commands"); + // Scan user-level commands (~/.claude/commands/, or CLAUDE_CONFIG_DIR/commands/ if set) + const userCommandsDir = path.join(getClaudeConfigDir(), "commands"); const userCommands = await scanCommandsDirectory( userCommandsDir, userCommandsDir, @@ -534,7 +533,7 @@ router.post("/execute", async (req, res) => { { const resolvedPath = path.resolve(commandPath); const userBase = path.resolve( - path.join(os.homedir(), ".claude", "commands"), + path.join(getClaudeConfigDir(), "commands"), ); const projectBase = context?.projectPath ? path.resolve(path.join(context.projectPath, ".claude", "commands")) diff --git a/server/shared/claude-config-dir.ts b/server/shared/claude-config-dir.ts new file mode 100644 index 0000000000..fcf287037d --- /dev/null +++ b/server/shared/claude-config-dir.ts @@ -0,0 +1,21 @@ +import os from 'node:os'; +import path from 'node:path'; + +/** + * Resolves Claude Code's own config/data directory (the directory that + * holds `projects/`, `settings.json`, `.claude.json`, `.credentials.json`, + * `sessions/`, `commands/`, etc). + * + * Claude Code itself honors `CLAUDE_CONFIG_DIR` to relocate this directory + * away from the default `~/.claude` โ€” commonly used to run multiple Claude + * accounts/profiles side by side. CloudCLI needs to resolve the same + * directory so it reads/writes the profile the user actually has active, + * instead of always falling back to `~/.claude`. + */ +export function getClaudeConfigDir(): string { + const override = process.env.CLAUDE_CONFIG_DIR; + if (override && override.trim().length > 0) { + return path.resolve(override.trim()); + } + return path.join(os.homedir(), '.claude'); +} diff --git a/server/utils/mcp-detector.js b/server/utils/mcp-detector.js index 0d9241ae1f..3b4cff8ffd 100644 --- a/server/utils/mcp-detector.js +++ b/server/utils/mcp-detector.js @@ -8,7 +8,7 @@ import { promises as fsPromises } from 'fs'; import path from 'path'; -import os from 'os'; +import { getClaudeConfigDir } from '../shared/claude-config-dir.js'; /** * Check if task-master-ai MCP server is configured @@ -18,10 +18,10 @@ import os from 'os'; export async function detectTaskMasterMCPServer() { try { // Read Claude configuration files directly (same logic as mcp.js) - const homeDir = os.homedir(); + const claudeConfigDir = getClaudeConfigDir(); const configPaths = [ - path.join(homeDir, '.claude.json'), - path.join(homeDir, '.claude', 'settings.json') + path.join(claudeConfigDir, '.claude.json'), + path.join(claudeConfigDir, 'settings.json') ]; let configData = null;