Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions ClaudeIsland/Core/ConfigPaths.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
4 changes: 1 addition & 3 deletions ClaudeIsland/Core/DebugLogger.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion ClaudeIsland/Core/LogStreamer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion ClaudeIsland/Models/SessionState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: "-")
Expand Down
20 changes: 8 additions & 12 deletions ClaudeIsland/Services/Hooks/CodexHookInstaller.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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 }
Expand All @@ -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)
Expand Down
6 changes: 2 additions & 4 deletions ClaudeIsland/Services/Hooks/HookHealthCheck.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []
Expand Down Expand Up @@ -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] = []
Expand Down
28 changes: 10 additions & 18 deletions ClaudeIsland/Services/Hooks/HookInstaller.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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]]
Expand Down Expand Up @@ -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],
Expand All @@ -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)

Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion ClaudeIsland/Services/Session/AgentFileWatcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions ClaudeIsland/Services/Session/BuddyReader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -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 }) {
Expand Down Expand Up @@ -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)),
Expand Down
3 changes: 1 addition & 2 deletions ClaudeIsland/Services/Session/CodexUsage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 5 additions & 7 deletions ClaudeIsland/Services/Session/ConversationParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>, toolIdToName: inout [String: String]) -> ChatMessage? {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion ClaudeIsland/Services/Session/JSONLInterruptWatcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions ClaudeIsland/Services/Sync/CapabilityScanner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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 [] }
Expand Down
3 changes: 1 addition & 2 deletions ClaudeIsland/Services/Sync/TerminalWriter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
6 changes: 2 additions & 4 deletions ClaudeIsland/UI/Views/HookDiagnosticsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down