From cad6425a4ac8287f2ebafe6c3bf3455d6e434dcc Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Fri, 26 Dec 2025 23:28:33 +0900 Subject: [PATCH] fix: honor CLAUDE_CONFIG_DIR environment variable (#261) Fixes #255 - Add getClaudeConfigDir() utility function that respects CLAUDE_CONFIG_DIR env var - Update all hardcoded ~/.claude paths to use the new utility - Add comprehensive tests for getClaudeConfigDir() - Maintain backward compatibility with default ~/.claude when env var is not set Files updated: - src/shared/claude-config-dir.ts (new utility) - src/shared/claude-config-dir.test.ts (tests) - src/hooks/claude-code-hooks/config.ts - src/hooks/claude-code-hooks/todo.ts - src/hooks/claude-code-hooks/transcript.ts - src/features/claude-code-command-loader/loader.ts - src/features/claude-code-agent-loader/loader.ts - src/features/claude-code-skill-loader/loader.ts - src/features/claude-code-mcp-loader/loader.ts - src/tools/session-manager/constants.ts - src/tools/slashcommand/tools.ts Co-authored-by: sisyphus-dev-ai --- .../claude-code-agent-loader/loader.ts | 4 +- .../claude-code-command-loader/loader.ts | 5 +- src/features/claude-code-mcp-loader/loader.ts | 6 +- .../claude-code-skill-loader/loader.ts | 4 +- src/hooks/claude-code-hooks/config.ts | 6 +- src/hooks/claude-code-hooks/todo.ts | 4 +- src/hooks/claude-code-hooks/transcript.ts | 9 +-- src/shared/claude-config-dir.test.ts | 60 +++++++++++++++++++ src/shared/claude-config-dir.ts | 11 ++++ src/shared/index.ts | 1 + src/tools/session-manager/constants.ts | 6 +- src/tools/slashcommand/tools.ts | 7 ++- 12 files changed, 97 insertions(+), 26 deletions(-) create mode 100644 src/shared/claude-config-dir.test.ts create mode 100644 src/shared/claude-config-dir.ts diff --git a/src/features/claude-code-agent-loader/loader.ts b/src/features/claude-code-agent-loader/loader.ts index 0bd26ba..0f6ef21 100644 --- a/src/features/claude-code-agent-loader/loader.ts +++ b/src/features/claude-code-agent-loader/loader.ts @@ -1,9 +1,9 @@ import { existsSync, readdirSync, readFileSync } from "fs" -import { homedir } from "os" import { join, basename } from "path" import type { AgentConfig } from "@opencode-ai/sdk" import { parseFrontmatter } from "../../shared/frontmatter" import { isMarkdownFile } from "../../shared/file-utils" +import { getClaudeConfigDir } from "../../shared" import type { AgentScope, AgentFrontmatter, LoadedAgent } from "./types" function parseToolsConfig(toolsStr?: string): Record | undefined { @@ -68,7 +68,7 @@ function loadAgentsFromDir(agentsDir: string, scope: AgentScope): LoadedAgent[] } export function loadUserAgents(): Record { - const userAgentsDir = join(homedir(), ".claude", "agents") + const userAgentsDir = join(getClaudeConfigDir(), "agents") const agents = loadAgentsFromDir(userAgentsDir, "user") const result: Record = {} diff --git a/src/features/claude-code-command-loader/loader.ts b/src/features/claude-code-command-loader/loader.ts index 3bcec78..82e0076 100644 --- a/src/features/claude-code-command-loader/loader.ts +++ b/src/features/claude-code-command-loader/loader.ts @@ -1,9 +1,9 @@ import { existsSync, readdirSync, readFileSync } from "fs" -import { homedir } from "os" import { join, basename } from "path" import { parseFrontmatter } from "../../shared/frontmatter" import { sanitizeModelField } from "../../shared/model-sanitizer" import { isMarkdownFile } from "../../shared/file-utils" +import { getClaudeConfigDir } from "../../shared" import type { CommandScope, CommandDefinition, CommandFrontmatter, LoadedCommand } from "./types" function loadCommandsFromDir(commandsDir: string, scope: CommandScope): LoadedCommand[] { @@ -68,7 +68,7 @@ function commandsToRecord(commands: LoadedCommand[]): Record { - const userCommandsDir = join(homedir(), ".claude", "commands") + const userCommandsDir = join(getClaudeConfigDir(), "commands") const commands = loadCommandsFromDir(userCommandsDir, "user") return commandsToRecord(commands) } @@ -80,6 +80,7 @@ export function loadProjectCommands(): Record { } export function loadOpencodeGlobalCommands(): Record { + const { homedir } = require("os") const opencodeCommandsDir = join(homedir(), ".config", "opencode", "command") const commands = loadCommandsFromDir(opencodeCommandsDir, "opencode") return commandsToRecord(commands) diff --git a/src/features/claude-code-mcp-loader/loader.ts b/src/features/claude-code-mcp-loader/loader.ts index d75cb57..8e33747 100644 --- a/src/features/claude-code-mcp-loader/loader.ts +++ b/src/features/claude-code-mcp-loader/loader.ts @@ -1,6 +1,6 @@ import { existsSync } from "fs" -import { homedir } from "os" import { join } from "path" +import { getClaudeConfigDir } from "../../shared" import type { ClaudeCodeMcpConfig, LoadedMcpServer, @@ -16,11 +16,11 @@ interface McpConfigPath { } function getMcpConfigPaths(): McpConfigPath[] { - const home = homedir() + const claudeConfigDir = getClaudeConfigDir() const cwd = process.cwd() return [ - { path: join(home, ".claude", ".mcp.json"), scope: "user" }, + { path: join(claudeConfigDir, ".mcp.json"), scope: "user" }, { path: join(cwd, ".mcp.json"), scope: "project" }, { path: join(cwd, ".claude", ".mcp.json"), scope: "local" }, ] diff --git a/src/features/claude-code-skill-loader/loader.ts b/src/features/claude-code-skill-loader/loader.ts index 4b43899..51e3bd6 100644 --- a/src/features/claude-code-skill-loader/loader.ts +++ b/src/features/claude-code-skill-loader/loader.ts @@ -1,9 +1,9 @@ import { existsSync, readdirSync, readFileSync } from "fs" -import { homedir } from "os" import { join } from "path" import { parseFrontmatter } from "../../shared/frontmatter" import { sanitizeModelField } from "../../shared/model-sanitizer" import { resolveSymlink } from "../../shared/file-utils" +import { getClaudeConfigDir } from "../../shared" import type { CommandDefinition } from "../claude-code-command-loader/types" import type { SkillScope, SkillMetadata, LoadedSkillAsCommand } from "./types" @@ -68,7 +68,7 @@ $ARGUMENTS } export function loadUserSkillsAsCommands(): Record { - const userSkillsDir = join(homedir(), ".claude", "skills") + const userSkillsDir = join(getClaudeConfigDir(), "skills") const skills = loadSkillsFromDir(userSkillsDir, "user") return skills.reduce((acc, skill) => { acc[skill.name] = skill.definition diff --git a/src/hooks/claude-code-hooks/config.ts b/src/hooks/claude-code-hooks/config.ts index a1f4d52..b155c48 100644 --- a/src/hooks/claude-code-hooks/config.ts +++ b/src/hooks/claude-code-hooks/config.ts @@ -1,6 +1,6 @@ -import { homedir } from "os" import { join } from "path" import { existsSync } from "fs" +import { getClaudeConfigDir } from "../../shared" import type { ClaudeHooksConfig, HookMatcher, HookCommand } from "./types" interface RawHookMatcher { @@ -44,9 +44,9 @@ function normalizeHooksConfig(raw: RawClaudeHooksConfig): ClaudeHooksConfig { } export function getClaudeSettingsPaths(customPath?: string): string[] { - const home = homedir() + const claudeConfigDir = getClaudeConfigDir() const paths = [ - join(home, ".claude", "settings.json"), + join(claudeConfigDir, "settings.json"), join(process.cwd(), ".claude", "settings.json"), join(process.cwd(), ".claude", "settings.local.json"), ] diff --git a/src/hooks/claude-code-hooks/todo.ts b/src/hooks/claude-code-hooks/todo.ts index ed53c2a..ed42c6c 100644 --- a/src/hooks/claude-code-hooks/todo.ts +++ b/src/hooks/claude-code-hooks/todo.ts @@ -1,9 +1,9 @@ import { join } from "path" import { mkdirSync, writeFileSync, readFileSync, existsSync, unlinkSync } from "fs" -import { homedir } from "os" +import { getClaudeConfigDir } from "../../shared" import type { TodoFile, TodoItem, ClaudeCodeTodoItem } from "./types" -const TODO_DIR = join(homedir(), ".claude", "todos") +const TODO_DIR = join(getClaudeConfigDir(), "todos") export function getTodoPath(sessionId: string): string { return join(TODO_DIR, `${sessionId}-agent-${sessionId}.json`) diff --git a/src/hooks/claude-code-hooks/transcript.ts b/src/hooks/claude-code-hooks/transcript.ts index 776a828..0cccd4e 100644 --- a/src/hooks/claude-code-hooks/transcript.ts +++ b/src/hooks/claude-code-hooks/transcript.ts @@ -1,15 +1,12 @@ -/** - * Transcript Manager - * Creates and manages Claude Code compatible transcript files - */ import { join } from "path" import { mkdirSync, appendFileSync, existsSync, writeFileSync, unlinkSync } from "fs" -import { homedir, tmpdir } from "os" +import { tmpdir } from "os" import { randomUUID } from "crypto" import type { TranscriptEntry } from "./types" import { transformToolName } from "../../shared/tool-name" +import { getClaudeConfigDir } from "../../shared" -const TRANSCRIPT_DIR = join(homedir(), ".claude", "transcripts") +const TRANSCRIPT_DIR = join(getClaudeConfigDir(), "transcripts") export function getTranscriptPath(sessionId: string): string { return join(TRANSCRIPT_DIR, `${sessionId}.jsonl`) diff --git a/src/shared/claude-config-dir.test.ts b/src/shared/claude-config-dir.test.ts new file mode 100644 index 0000000..4d44c42 --- /dev/null +++ b/src/shared/claude-config-dir.test.ts @@ -0,0 +1,60 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test" +import { homedir } from "node:os" +import { join } from "node:path" +import { getClaudeConfigDir } from "./claude-config-dir" + +describe("getClaudeConfigDir", () => { + let originalEnv: string | undefined + + beforeEach(() => { + originalEnv = process.env.CLAUDE_CONFIG_DIR + }) + + afterEach(() => { + if (originalEnv !== undefined) { + process.env.CLAUDE_CONFIG_DIR = originalEnv + } else { + delete process.env.CLAUDE_CONFIG_DIR + } + }) + + test("returns CLAUDE_CONFIG_DIR when env var is set", () => { + process.env.CLAUDE_CONFIG_DIR = "/custom/claude/path" + + const result = getClaudeConfigDir() + + expect(result).toBe("/custom/claude/path") + }) + + test("returns ~/.claude when env var is not set", () => { + delete process.env.CLAUDE_CONFIG_DIR + + const result = getClaudeConfigDir() + + expect(result).toBe(join(homedir(), ".claude")) + }) + + test("returns ~/.claude when env var is empty string", () => { + process.env.CLAUDE_CONFIG_DIR = "" + + const result = getClaudeConfigDir() + + expect(result).toBe(join(homedir(), ".claude")) + }) + + test("handles absolute paths with trailing slash", () => { + process.env.CLAUDE_CONFIG_DIR = "/custom/path/" + + const result = getClaudeConfigDir() + + expect(result).toBe("/custom/path/") + }) + + test("handles relative paths", () => { + process.env.CLAUDE_CONFIG_DIR = "./my-claude-config" + + const result = getClaudeConfigDir() + + expect(result).toBe("./my-claude-config") + }) +}) diff --git a/src/shared/claude-config-dir.ts b/src/shared/claude-config-dir.ts new file mode 100644 index 0000000..ee42230 --- /dev/null +++ b/src/shared/claude-config-dir.ts @@ -0,0 +1,11 @@ +import { homedir } from "node:os" +import { join } from "node:path" + +export function getClaudeConfigDir(): string { + const envConfigDir = process.env.CLAUDE_CONFIG_DIR + if (envConfigDir) { + return envConfigDir + } + + return join(homedir(), ".claude") +} diff --git a/src/shared/index.ts b/src/shared/index.ts index ae9d231..cd74d6c 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -13,3 +13,4 @@ export * from "./dynamic-truncator" export * from "./config-path" export * from "./data-path" export * from "./config-errors" +export * from "./claude-config-dir" diff --git a/src/tools/session-manager/constants.ts b/src/tools/session-manager/constants.ts index 0cce3f1..ff311ef 100644 --- a/src/tools/session-manager/constants.ts +++ b/src/tools/session-manager/constants.ts @@ -1,12 +1,12 @@ import { join } from "node:path" -import { homedir } from "node:os" import { getOpenCodeStorageDir } from "../../shared/data-path" +import { getClaudeConfigDir } from "../../shared" export const OPENCODE_STORAGE = getOpenCodeStorageDir() export const MESSAGE_STORAGE = join(OPENCODE_STORAGE, "message") export const PART_STORAGE = join(OPENCODE_STORAGE, "part") -export const TODO_DIR = join(homedir(), ".claude", "todos") -export const TRANSCRIPT_DIR = join(homedir(), ".claude", "transcripts") +export const TODO_DIR = join(getClaudeConfigDir(), "todos") +export const TRANSCRIPT_DIR = join(getClaudeConfigDir(), "transcripts") export const SESSION_LIST_DESCRIPTION = `List all OpenCode sessions with optional filtering. Returns a list of available session IDs with metadata including message count, date range, and agents used. diff --git a/src/tools/slashcommand/tools.ts b/src/tools/slashcommand/tools.ts index 3de840d..066f1f1 100644 --- a/src/tools/slashcommand/tools.ts +++ b/src/tools/slashcommand/tools.ts @@ -1,9 +1,9 @@ import { tool } from "@opencode-ai/plugin" import { existsSync, readdirSync, readFileSync } from "fs" -import { homedir } from "os" import { join, basename, dirname } from "path" import { parseFrontmatter, resolveCommandsInText, resolveFileReferencesInText, sanitizeModelField } from "../../shared" import { isMarkdownFile } from "../../shared/file-utils" +import { getClaudeConfigDir } from "../../shared" import type { CommandScope, CommandMetadata, CommandInfo } from "./types" function discoverCommandsFromDir(commandsDir: string, scope: CommandScope): CommandInfo[] { @@ -50,7 +50,8 @@ function discoverCommandsFromDir(commandsDir: string, scope: CommandScope): Comm } function discoverCommandsSync(): CommandInfo[] { - const userCommandsDir = join(homedir(), ".claude", "commands") + const { homedir } = require("os") + const userCommandsDir = join(getClaudeConfigDir(), "commands") const projectCommandsDir = join(process.cwd(), ".claude", "commands") const opencodeGlobalDir = join(homedir(), ".config", "opencode", "command") const opencodeProjectDir = join(process.cwd(), ".opencode", "command") @@ -145,7 +146,7 @@ Commands are loaded from (priority order, highest wins): - .opencode/command/ (opencode-project - OpenCode project-specific commands) - ./.claude/commands/ (project - Claude Code project-specific commands) - ~/.config/opencode/command/ (opencode - OpenCode global commands) -- ~/.claude/commands/ (user - Claude Code global commands) +- $CLAUDE_CONFIG_DIR/commands/ or ~/.claude/commands/ (user - Claude Code global commands) Each command is a markdown file with: - YAML frontmatter: description, argument-hint, model, agent, subtask (optional)