From 3fad091a9bc643af55ecc019e18d01d7f1270e71 Mon Sep 17 00:00:00 2001 From: shellRaining Date: Wed, 13 May 2026 14:51:31 +0800 Subject: [PATCH] feat: respect CLAUDE_CONFIG_DIR and CODEX_HOME environment variables Previously all configuration paths were hardcoded to ~/.claude and ~/.codex. Users who set CLAUDE_CONFIG_DIR (e.g. to ~/.config/claude) or CODEX_HOME (e.g. to ~/.config/codex) would find hooks, settings, and session files written to the wrong location. Introduce ConfigPaths as a single source of truth that checks the environment variables first and falls back to the default paths. --- ClaudeIsland/Core/ConfigPaths.swift | 52 +++++++++++++++++++ ClaudeIsland/Core/DebugLogger.swift | 4 +- ClaudeIsland/Core/LogStreamer.swift | 2 +- ClaudeIsland/Models/SessionState.swift | 2 +- .../Services/Hooks/CodexHookInstaller.swift | 20 +++---- .../Services/Hooks/HookHealthCheck.swift | 6 +-- .../Services/Hooks/HookInstaller.swift | 28 ++++------ .../Services/Session/AgentFileWatcher.swift | 2 +- .../Services/Session/BuddyReader.swift | 6 +-- .../Services/Session/CodexUsage.swift | 3 +- .../Services/Session/ConversationParser.swift | 12 ++--- .../Session/JSONLInterruptWatcher.swift | 2 +- .../Services/Sync/CapabilityScanner.swift | 4 +- .../Services/Sync/TerminalWriter.swift | 3 +- .../UI/Views/HookDiagnosticsView.swift | 6 +-- 15 files changed, 91 insertions(+), 61 deletions(-) create mode 100644 ClaudeIsland/Core/ConfigPaths.swift diff --git a/ClaudeIsland/Core/ConfigPaths.swift b/ClaudeIsland/Core/ConfigPaths.swift new file mode 100644 index 000000000..b62e2c7a7 --- /dev/null +++ b/ClaudeIsland/Core/ConfigPaths.swift @@ -0,0 +1,52 @@ +// +// ConfigPaths.swift +// ClaudeIsland +// +// Centralises discovery of Claude Code and Codex configuration directories. +// Respects the CLAUDE_CONFIG_DIR and CODEX_HOME environment variables so that +// users who customise their config location are not forced back to the default. +// + +import Foundation + +enum ConfigPaths { + /// Root directory for Claude Code configuration. + /// Honours `CLAUDE_CONFIG_DIR` when set, otherwise falls back to `~/.claude`. + static var claudeDir: URL { + if let custom = ProcessInfo.processInfo.environment["CLAUDE_CONFIG_DIR"] { + return URL(fileURLWithPath: (custom as NSString).expandingTildeInPath) + } + return FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".claude") + } + + /// Root directory for Codex configuration. + /// Honours `CODEX_HOME` when set, otherwise falls back to `~/.codex`. + static var codexDir: URL { + if let custom = ProcessInfo.processInfo.environment["CODEX_HOME"] { + return URL(fileURLWithPath: (custom as NSString).expandingTildeInPath) + } + return FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".codex") + } + + // MARK: - Convenience paths + + static var claudeHooksDir: URL { claudeDir.appendingPathComponent("hooks") } + static var claudeSettings: URL { claudeDir.appendingPathComponent("settings.json") } + static var claudeProjectsDir: URL { claudeDir.appendingPathComponent("projects") } + static var claudeSessionsDir: URL { claudeDir.appendingPathComponent("sessions") } + static var claudeBuddyFile: URL { URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent(".claude.json") } + static var claudeLogFile: URL { claudeDir.appendingPathComponent(".codeisland.log") } + static var claudeSaltCache: URL { claudeDir.appendingPathComponent(".codeisland-salt") } + static var claudeBonesCache: URL { claudeDir.appendingPathComponent(".codeisland-bones.json") } + static var hookScript: URL { claudeHooksDir.appendingPathComponent("codeisland-state.py") } + + static var codexConfig: URL { codexDir.appendingPathComponent("config.toml") } + static var codexHooks: URL { codexDir.appendingPathComponent("hooks.json") } + + /// Expand a path that may contain `~` using the current home directory. + static func expandingTilde(_ path: String) -> String { + (path as NSString).expandingTildeInPath + } +} diff --git a/ClaudeIsland/Core/DebugLogger.swift b/ClaudeIsland/Core/DebugLogger.swift index 3ddd8345f..9f4830ac1 100644 --- a/ClaudeIsland/Core/DebugLogger.swift +++ b/ClaudeIsland/Core/DebugLogger.swift @@ -3,14 +3,12 @@ // CodeIsland // // Lightweight debug logging to file for runtime diagnostics. -// Logs are written to ~/.claude/.codeisland.log -// Tail the log: tail -f ~/.claude/.codeisland.log // import Foundation enum DebugLogger: Sendable { - private static let logPath = NSHomeDirectory() + "/.claude/.codeisland.log" + private static let logPath = ConfigPaths.claudeLogFile.path private nonisolated(unsafe) static let dateFormatter: DateFormatter = { let f = DateFormatter() f.dateFormat = "HH:mm:ss.SSS" diff --git a/ClaudeIsland/Core/LogStreamer.swift b/ClaudeIsland/Core/LogStreamer.swift index 69cb66cfa..9dc52c6a1 100644 --- a/ClaudeIsland/Core/LogStreamer.swift +++ b/ClaudeIsland/Core/LogStreamer.swift @@ -24,7 +24,7 @@ final class LogStreamer: ObservableObject { @Published private(set) var lines: [String] = [] - private let logPath = NSHomeDirectory() + "/.claude/.codeisland.log" + private let logPath = ConfigPaths.claudeLogFile.path private var fileHandle: FileHandle? private var source: DispatchSourceFileSystemObject? private var readOffset: UInt64 = 0 diff --git a/ClaudeIsland/Models/SessionState.swift b/ClaudeIsland/Models/SessionState.swift index d33e2499a..2608e295d 100644 --- a/ClaudeIsland/Models/SessionState.swift +++ b/ClaudeIsland/Models/SessionState.swift @@ -283,7 +283,7 @@ struct SessionState: Equatable, Identifiable, Sendable { private static func directParseFirstMessage(sessionId: String, cwd: String) -> String? { // Try exact cwd first, then walk up parent directories var searchCwd = cwd - let projectsDir = NSHomeDirectory() + "/.claude/projects" + let projectsDir = ConfigPaths.claudeProjectsDir.path while !searchCwd.isEmpty && searchCwd != "/" { let projectDir = searchCwd.replacingOccurrences(of: "/", with: "-").replacingOccurrences(of: ".", with: "-") diff --git a/ClaudeIsland/Services/Hooks/CodexHookInstaller.swift b/ClaudeIsland/Services/Hooks/CodexHookInstaller.swift index c984f4c1f..95e466651 100644 --- a/ClaudeIsland/Services/Hooks/CodexHookInstaller.swift +++ b/ClaudeIsland/Services/Hooks/CodexHookInstaller.swift @@ -61,16 +61,14 @@ enum CodexHookInstaller { /// Install Codex hooks on app launch. static func installIfNeeded() { - let codexDir = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".codex") + let codexDir = ConfigPaths.codexDir try? FileManager.default.createDirectory(at: codexDir, withIntermediateDirectories: true) - let scriptPath = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".claude/hooks/codeisland-state.py").path + let scriptPath = ConfigPaths.hookScript.path let command = hookCommand(for: scriptPath) - let configURL = codexDir.appendingPathComponent("config.toml") - let hooksURL = codexDir.appendingPathComponent("hooks.json") + let configURL = ConfigPaths.codexConfig + let hooksURL = ConfigPaths.codexHooks let manifestURL = codexDir.appendingPathComponent(CodexHookInstallerManifest.fileName) let legacyManifestURL = codexDir.appendingPathComponent(CodexHookInstallerManifest.legacyFileName) @@ -103,8 +101,7 @@ enum CodexHookInstaller { /// Check if Codex hooks are currently installed. static func isInstalled() -> Bool { - let hooksURL = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".codex/hooks.json") + let hooksURL = ConfigPaths.codexHooks guard let data = try? Data(contentsOf: hooksURL), let json = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], let hooksObj = json["hooks"] as? [String: Any] else { return false } @@ -126,10 +123,9 @@ enum CodexHookInstaller { /// Uninstall Codex hooks and optionally revert the feature flag. static func uninstall() { - let codexDir = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".codex") - let configURL = codexDir.appendingPathComponent("config.toml") - let hooksURL = codexDir.appendingPathComponent("hooks.json") + let codexDir = ConfigPaths.codexDir + let configURL = ConfigPaths.codexConfig + let hooksURL = ConfigPaths.codexHooks let primaryManifestURL = codexDir.appendingPathComponent(CodexHookInstallerManifest.fileName) let legacyManifestURL = codexDir.appendingPathComponent(CodexHookInstallerManifest.legacyFileName) let manifestURL = resolvedManifestURL(in: codexDir) diff --git a/ClaudeIsland/Services/Hooks/HookHealthCheck.swift b/ClaudeIsland/Services/Hooks/HookHealthCheck.swift index 28faab5a4..8d35771fc 100644 --- a/ClaudeIsland/Services/Hooks/HookHealthCheck.swift +++ b/ClaudeIsland/Services/Hooks/HookHealthCheck.swift @@ -83,8 +83,7 @@ enum HookHealthCheck { /// Check Claude Code hook health. static func checkClaude( - claudeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".claude"), + claudeDirectory: URL = ConfigPaths.claudeDir, fileManager: FileManager = .default ) -> HookHealthReport { var issues: [HookHealthReport.Issue] = [] @@ -133,8 +132,7 @@ enum HookHealthCheck { /// Check Codex hook health. static func checkCodex( - codexDirectory: URL = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".codex"), + codexDirectory: URL = ConfigPaths.codexDir, fileManager: FileManager = .default ) -> HookHealthReport { var issues: [HookHealthReport.Issue] = [] diff --git a/ClaudeIsland/Services/Hooks/HookInstaller.swift b/ClaudeIsland/Services/Hooks/HookInstaller.swift index 6855fd55c..de71bbcdd 100644 --- a/ClaudeIsland/Services/Hooks/HookInstaller.swift +++ b/ClaudeIsland/Services/Hooks/HookInstaller.swift @@ -13,11 +13,9 @@ struct HookInstaller { static func installIfNeeded() { cleanupLegacyHooks() - let claudeDir = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".claude") - let hooksDir = claudeDir.appendingPathComponent("hooks") - let pythonScript = hooksDir.appendingPathComponent("codeisland-state.py") - let settings = claudeDir.appendingPathComponent("settings.json") + let hooksDir = ConfigPaths.claudeHooksDir + let pythonScript = ConfigPaths.hookScript + let settings = ConfigPaths.claudeSettings try? FileManager.default.createDirectory( at: hooksDir, @@ -44,7 +42,7 @@ struct HookInstaller { } let python = detectPython() - let command = "\(python) ~/.claude/hooks/codeisland-state.py" + let command = "\(python) \(ConfigPaths.hookScript.path)" let hookEntry: [[String: Any]] = [["type": "command", "command": command]] let hookEntryWithTimeout: [[String: Any]] = [["type": "command", "command": command, "timeout": 86400]] let withMatcher: [[String: Any]] = [["matcher": "*", "hooks": hookEntry]] @@ -102,9 +100,7 @@ struct HookInstaller { /// Check if hooks are currently installed static func isInstalled() -> Bool { - let claudeDir = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".claude") - let settings = claudeDir.appendingPathComponent("settings.json") + let settings = ConfigPaths.claudeSettings guard let data = try? Data(contentsOf: settings), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], @@ -131,11 +127,9 @@ struct HookInstaller { /// Uninstall hooks from settings.json and remove script static func uninstall() { - let claudeDir = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".claude") - let hooksDir = claudeDir.appendingPathComponent("hooks") - let pythonScript = hooksDir.appendingPathComponent("codeisland-state.py") - let settings = claudeDir.appendingPathComponent("settings.json") + let hooksDir = ConfigPaths.claudeHooksDir + let pythonScript = ConfigPaths.hookScript + let settings = ConfigPaths.claudeSettings try? FileManager.default.removeItem(at: pythonScript) @@ -186,10 +180,8 @@ struct HookInstaller { /// Strip hook entries from older app versions and delete their leftover /// scripts. Idempotent — safe to run every launch. static func cleanupLegacyHooks() { - let claudeDir = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".claude") - let hooksDir = claudeDir.appendingPathComponent("hooks") - let settings = claudeDir.appendingPathComponent("settings.json") + let hooksDir = ConfigPaths.claudeHooksDir + let settings = ConfigPaths.claudeSettings // 1. Delete legacy script files on disk (no-op if missing). for name in legacyHookScripts { diff --git a/ClaudeIsland/Services/Session/AgentFileWatcher.swift b/ClaudeIsland/Services/Session/AgentFileWatcher.swift index c19e0b5fb..97f912604 100644 --- a/ClaudeIsland/Services/Session/AgentFileWatcher.swift +++ b/ClaudeIsland/Services/Session/AgentFileWatcher.swift @@ -42,7 +42,7 @@ class AgentFileWatcher { let projectDir = cwd.replacingOccurrences(of: "/", with: "-") .replacingOccurrences(of: ".", with: "-") - self.filePath = NSHomeDirectory() + "/.claude/projects/" + projectDir + "/agent-" + agentId + ".jsonl" + self.filePath = ConfigPaths.claudeProjectsDir.path + "/" + projectDir + "/agent-" + agentId + ".jsonl" } /// Start watching the agent file diff --git a/ClaudeIsland/Services/Session/BuddyReader.swift b/ClaudeIsland/Services/Session/BuddyReader.swift index 8f8b34849..beef378fe 100644 --- a/ClaudeIsland/Services/Session/BuddyReader.swift +++ b/ClaudeIsland/Services/Session/BuddyReader.swift @@ -102,7 +102,7 @@ class BuddyReader: ObservableObject { } func reload() { - let configPath = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".claude.json") + let configPath = ConfigPaths.claudeBuddyFile guard let data = try? Data(contentsOf: configPath), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let companion = json["companion"] as? [String: Any], @@ -171,7 +171,7 @@ class BuddyReader: ObservableObject { let home = FileManager.default.homeDirectoryForCurrentUser.path // 1. Check cached salt file (written by any-buddy or CodeIsland setup) - let cachePath = "\(home)/.claude/.codeisland-salt" + let cachePath = ConfigPaths.claudeSaltCache.path if let cached = try? String(contentsOfFile: cachePath, encoding: .utf8), cached.count == originalSalt.count, cached.allSatisfy({ $0.isASCII && !$0.isNewline }) { @@ -225,7 +225,7 @@ class BuddyReader: ObservableObject { // MARK: - Cached Bones - private static let bonesCachePath = FileManager.default.homeDirectoryForCurrentUser.path + "/.claude/.codeisland-bones.json" + private static let bonesCachePath = ConfigPaths.claudeBonesCache.path private static func readCachedBones() -> Bones? { guard let data = try? Data(contentsOf: URL(fileURLWithPath: bonesCachePath)), diff --git a/ClaudeIsland/Services/Session/CodexUsage.swift b/ClaudeIsland/Services/Session/CodexUsage.swift index 5eb08f004..d881af0c7 100644 --- a/ClaudeIsland/Services/Session/CodexUsage.swift +++ b/ClaudeIsland/Services/Session/CodexUsage.swift @@ -74,8 +74,7 @@ class CodexUsageMonitor: ObservableObject { // MARK: - Usage Loader enum CodexUsageLoader { - static let defaultRootURL: URL = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".codex/sessions", isDirectory: true) + static let defaultRootURL: URL = ConfigPaths.codexDir.appendingPathComponent("sessions", isDirectory: true) private struct Candidate { var fileURL: URL diff --git a/ClaudeIsland/Services/Session/ConversationParser.swift b/ClaudeIsland/Services/Session/ConversationParser.swift index 2900bd66d..f48cfbd3b 100644 --- a/ClaudeIsland/Services/Session/ConversationParser.swift +++ b/ClaudeIsland/Services/Session/ConversationParser.swift @@ -529,23 +529,21 @@ actor ConversationParser { /// Build session file path, walking up parent directories if needed private static func sessionFilePath(sessionId: String, cwd: String) -> String { - let home = NSHomeDirectory() + let projectsDir = ConfigPaths.claudeProjectsDir.path let fm = FileManager.default var dir = cwd - // Try cwd and each parent directory until we find the JSONL while dir.count > 1 { let projectDir = dir.replacingOccurrences(of: "/", with: "-").replacingOccurrences(of: ".", with: "-") - let path = home + "/.claude/projects/" + projectDir + "/" + sessionId + ".jsonl" + let path = projectsDir + "/" + projectDir + "/" + sessionId + ".jsonl" if fm.fileExists(atPath: path) { return path } dir = (dir as NSString).deletingLastPathComponent } - // Fallback to original cwd-based path let projectDir = cwd.replacingOccurrences(of: "/", with: "-").replacingOccurrences(of: ".", with: "-") - return home + "/.claude/projects/" + projectDir + "/" + sessionId + ".jsonl" + return projectsDir + "/" + projectDir + "/" + sessionId + ".jsonl" } private func parseMessageLine(_ json: [String: Any], seenToolIds: inout Set, toolIdToName: inout [String: String]) -> ChatMessage? { @@ -975,7 +973,7 @@ actor ConversationParser { guard !agentId.isEmpty else { return [] } let projectDir = cwd.replacingOccurrences(of: "/", with: "-").replacingOccurrences(of: ".", with: "-") - let agentFile = NSHomeDirectory() + "/.claude/projects/" + projectDir + "/agent-" + agentId + ".jsonl" + let agentFile = ConfigPaths.claudeProjectsDir.path + "/" + projectDir + "/agent-" + agentId + ".jsonl" guard FileManager.default.fileExists(atPath: agentFile), let content = try? String(contentsOfFile: agentFile, encoding: .utf8) else { @@ -1067,7 +1065,7 @@ extension ConversationParser { guard !agentId.isEmpty else { return [] } let projectDir = cwd.replacingOccurrences(of: "/", with: "-").replacingOccurrences(of: ".", with: "-") - let agentFile = NSHomeDirectory() + "/.claude/projects/" + projectDir + "/agent-" + agentId + ".jsonl" + let agentFile = ConfigPaths.claudeProjectsDir.path + "/" + projectDir + "/agent-" + agentId + ".jsonl" guard FileManager.default.fileExists(atPath: agentFile), let content = try? String(contentsOfFile: agentFile, encoding: .utf8) else { diff --git a/ClaudeIsland/Services/Session/JSONLInterruptWatcher.swift b/ClaudeIsland/Services/Session/JSONLInterruptWatcher.swift index 16f108b33..dbd358dd6 100644 --- a/ClaudeIsland/Services/Session/JSONLInterruptWatcher.swift +++ b/ClaudeIsland/Services/Session/JSONLInterruptWatcher.swift @@ -41,7 +41,7 @@ class JSONLInterruptWatcher { self.sessionId = sessionId let projectDir = cwd.replacingOccurrences(of: "/", with: "-") .replacingOccurrences(of: ".", with: "-") - self.filePath = NSHomeDirectory() + "/.claude/projects/" + projectDir + "/" + sessionId + ".jsonl" + self.filePath = ConfigPaths.claudeProjectsDir.path + "/" + projectDir + "/" + sessionId + ".jsonl" } /// Start watching the JSONL file for interrupts diff --git a/ClaudeIsland/Services/Sync/CapabilityScanner.swift b/ClaudeIsland/Services/Sync/CapabilityScanner.swift index 62ed20777..ec9b03334 100644 --- a/ClaudeIsland/Services/Sync/CapabilityScanner.swift +++ b/ClaudeIsland/Services/Sync/CapabilityScanner.swift @@ -44,7 +44,7 @@ enum CapabilityScanner { /// commands/skills under that directory's `.claude/` are included. static func scan(projectPath: String? = nil) -> CapabilitySnapshot { let home = FileManager.default.homeDirectoryForCurrentUser - let claudeDir = home.appendingPathComponent(".claude") + let claudeDir = ConfigPaths.claudeDir let builtins = builtinSlashCommands() @@ -157,7 +157,7 @@ enum CapabilityScanner { // MARK: - MCP Servers private static func scanMCPServers(home: URL, projectPath: String?) -> [CapabilityItem] { - let configFile = home.appendingPathComponent(".claude.json") + let configFile = ConfigPaths.claudeBuddyFile guard let data = try? Data(contentsOf: configFile), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return [] } diff --git a/ClaudeIsland/Services/Sync/TerminalWriter.swift b/ClaudeIsland/Services/Sync/TerminalWriter.swift index 51548c9d4..572878bda 100644 --- a/ClaudeIsland/Services/Sync/TerminalWriter.swift +++ b/ClaudeIsland/Services/Sync/TerminalWriter.swift @@ -944,8 +944,7 @@ final class TerminalWriter { /// Each file contains `{ "pid", "sessionId", "cwd", "startedAt", "kind", "entrypoint" }`. /// Verifies the pid is still running via `ps -p {pid} -o pid=` before including it. nonisolated private func discoverClaudeSessionsFromConfig() async -> [ClaudeProcessInfo] { - let sessionsDir = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".claude/sessions") + let sessionsDir = ConfigPaths.claudeSessionsDir guard let contents = try? FileManager.default.contentsOfDirectory( at: sessionsDir, includingPropertiesForKeys: [.contentModificationDateKey] diff --git a/ClaudeIsland/UI/Views/HookDiagnosticsView.swift b/ClaudeIsland/UI/Views/HookDiagnosticsView.swift index e4d1f9607..94142be3e 100644 --- a/ClaudeIsland/UI/Views/HookDiagnosticsView.swift +++ b/ClaudeIsland/UI/Views/HookDiagnosticsView.swift @@ -260,15 +260,13 @@ struct HookDiagnosticsView: View { private func runLegacyCleanup() { // Detect whether anything existed before the call, so we can report // "cleaned" vs "nothing to clean" without a dedicated return value. - let hooksDir = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".claude/hooks") + let hooksDir = ConfigPaths.claudeHooksDir let hadLegacyFile = HookInstaller.legacyHookScripts.contains { name in FileManager.default.fileExists( atPath: hooksDir.appendingPathComponent(name).path ) } - let settings = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".claude/settings.json") + let settings = ConfigPaths.claudeSettings let hadLegacyRef: Bool = { guard let data = try? Data(contentsOf: settings), let str = String(data: data, encoding: .utf8) else { return false }