diff --git a/.gitignore b/.gitignore index 07984226..7f357815 100644 --- a/.gitignore +++ b/.gitignore @@ -70,3 +70,7 @@ skills/ skills-lock.json CLAUDE.md build-dir/ + +# Python bytecode from local syntax checks +__pycache__/ +*.pyc diff --git a/Sources/CodeIsland/AppState.swift b/Sources/CodeIsland/AppState.swift index 33a8bf53..c060d898 100644 --- a/Sources/CodeIsland/AppState.swift +++ b/Sources/CodeIsland/AppState.swift @@ -1891,8 +1891,7 @@ final class AppState { private nonisolated static func readModelFromTranscript(sessionId: String, cwd: String?) -> String? { guard let cwd = cwd else { return nil } let projectDir = cwd.claudeProjectDirEncoded() - let home = FileManager.default.homeDirectoryForCurrentUser.path - let path = "\(home)/.claude/projects/\(projectDir)/\(sessionId).jsonl" + let path = "\(ClaudeConfigPaths.projectsDir())/\(projectDir)/\(sessionId).jsonl" guard let handle = FileHandle(forReadingAtPath: path) else { return nil } defer { handle.closeFile() } let chunk = handle.readData(ofLength: 32768) @@ -2272,7 +2271,7 @@ final class AppState { private nonisolated static func discoveryWatchRoots() -> [String] { let home = FileManager.default.homeDirectoryForCurrentUser.path let candidates: [(String, String)] = [ - ("claude", "\(home)/.claude/projects"), + ("claude", ClaudeConfigPaths.projectsDir()), ("codex", "\(home)/.codex/sessions"), ("gemini", "\(home)/.gemini/tmp"), ("qoder", "\(home)/.qoder/projects"), @@ -2835,8 +2834,8 @@ final class AppState { let claudePids = findClaudePids(candidatePids: candidatePids) guard !claudePids.isEmpty else { return [] } - let home = FileManager.default.homeDirectoryForCurrentUser.path let fm = FileManager.default + let claudeProjects = ClaudeConfigPaths.projectsDir() var results: [DiscoveredSession] = [] var seenSessionIds: Set = [] @@ -2853,7 +2852,7 @@ final class AppState { let processStart = getProcessStartTime(pid) let projectDir = cwd.claudeProjectDirEncoded() - let projectPath = "\(home)/.claude/projects/\(projectDir)" + let projectPath = "\(claudeProjects)/\(projectDir)" guard let files = try? fm.contentsOfDirectory(atPath: projectPath) else { continue } // Find the most recently modified .jsonl that was written AFTER this process started diff --git a/Sources/CodeIsland/ConfigInstaller.swift b/Sources/CodeIsland/ConfigInstaller.swift index 1470b67e..1d7728de 100644 --- a/Sources/CodeIsland/ConfigInstaller.swift +++ b/Sources/CodeIsland/ConfigInstaller.swift @@ -200,7 +200,7 @@ struct ConfigInstaller { // Claude Code — uses hook script (with bridge dispatcher + nc fallback) CLIConfig( name: "Claude Code", source: "claude", - configPath: ".claude/settings.json", configKey: "hooks", + configPath: "settings.json", configKey: "hooks", format: .claude, events: [ ("UserPromptSubmit", 5, true), @@ -218,7 +218,9 @@ struct ConfigInstaller { ], versionedEvents: [ "PostToolUseFailure": "2.1.89", - ] + ], + rootOverride: { ClaudeConfigPaths.configDir() }, + displayPathOverride: { ClaudeConfigPaths.displayPath(ClaudeConfigPaths.settingsPath()) } ), // Codex — honors $CODEX_HOME (falls back to ~/.codex) CLIConfig( @@ -749,6 +751,10 @@ struct ConfigInstaller { try? fm.removeItem(atPath: legacyBridgePath) try? fm.removeItem(atPath: legacyHookScriptPath) + // A previous run may have created the Claude config dir after the resolution was + // memoized, so re-resolve before installing into it. + ClaudeConfigPaths.invalidateCache() + // Install hook script + bridge binary (shared by all CLIs) installHookScript(fm: fm) installBridgeBinary(fm: fm) diff --git a/Sources/CodeIsland/DiagnosticsExporter.swift b/Sources/CodeIsland/DiagnosticsExporter.swift index 47218be4..c90bc2d3 100644 --- a/Sources/CodeIsland/DiagnosticsExporter.swift +++ b/Sources/CodeIsland/DiagnosticsExporter.swift @@ -53,7 +53,7 @@ struct DiagnosticsExporter { // 3. CLI config files let home = fm.homeDirectoryForCurrentUser.path let configs: [(source: String, dest: String)] = [ - ("\(home)/.claude/settings.json", "configs/claude-settings.json"), + (ClaudeConfigPaths.settingsPath(), "configs/claude-settings.json"), ("\(home)/.codex/hooks.json", "configs/codex-hooks.json"), ("\(home)/.gemini/settings.json", "configs/gemini-settings.json"), ("\(home)/.cursor/hooks.json", "configs/cursor-hooks.json"), diff --git a/Sources/CodeIsland/L10n.swift b/Sources/CodeIsland/L10n.swift index f3b8852e..c1c45c77 100644 --- a/Sources/CodeIsland/L10n.swift +++ b/Sources/CodeIsland/L10n.swift @@ -149,6 +149,10 @@ final class L10n: ObservableObject { "excluded_hook_cwd_title": "Ignore Hooks From Paths", "excluded_hook_cwd_desc": "Comma-separated substrings. Any hook event whose working directory contains one of them is silently dropped — useful for filtering out background plugins like claude-mem. Example: .claude-mem,.cache/agents", "excluded_hook_cwd_placeholder": "e.g. .claude-mem,.cache/agents", + "claude_config_dir_title": "Claude Code Config Directory", + "claude_config_dir_desc": "Where Claude Code keeps projects/ and settings.json. Leave empty to auto-detect ($CLAUDE_CONFIG_DIR, then whichever of ~/.claude or ~/.config/claude-code actually holds your projects, preferring ~/.claude). Set this if you use a custom CLAUDE_CONFIG_DIR — a Finder-launched app does not inherit your shell environment. Restart CodeIsland after changing.", + "claude_config_dir_placeholder": "auto-detect", + "claude_config_dir_resolved": "Currently using: %@", // Webhook forwarding "webhook_title": "Webhook Forwarding", @@ -829,6 +833,10 @@ final class L10n: ObservableObject { "excluded_hook_cwd_title": "忽略指定路径的 Hook", "excluded_hook_cwd_desc": "用逗号分隔的子串。任何 hook 事件的工作目录如果包含其中之一就会被静默丢弃 —— 适合过滤 claude-mem 等后台插件。示例:.claude-mem,.cache/agents", "excluded_hook_cwd_placeholder": "例如 .claude-mem,.cache/agents", + "claude_config_dir_title": "Claude Code 配置目录", + "claude_config_dir_desc": "Claude Code 存放 projects/ 和 settings.json 的位置。留空则自动检测(先 $CLAUDE_CONFIG_DIR,再是确实包含 projects/ 的 ~/.claude,最后 ~/.config/claude-code)。如果你使用自定义的 CLAUDE_CONFIG_DIR,请在此设置——从访达启动的应用不会继承 shell 环境变量。修改后请重启 CodeIsland。", + "claude_config_dir_placeholder": "自动检测", + "claude_config_dir_resolved": "当前使用:%@", // Webhook 转发 "webhook_title": "Webhook 转发", @@ -1169,6 +1177,10 @@ final class L10n: ObservableObject { "excluded_hook_cwd_title": "忽略指定路徑的 Hook", "excluded_hook_cwd_desc": "以逗號分隔的子字串。任何 hook 事件的工作目錄如果包含其中之一就會被靜默丟棄 —— 適合過濾 claude-mem 等背景外掛。範例:.claude-mem,.cache/agents", "excluded_hook_cwd_placeholder": "例如 .claude-mem,.cache/agents", + "claude_config_dir_title": "Claude Code 設定目錄", + "claude_config_dir_desc": "Claude Code 存放 projects/ 和 settings.json 的位置。留空則自動偵測(先 $CLAUDE_CONFIG_DIR,再是確實包含 projects/ 的 ~/.claude,最後 ~/.config/claude-code)。若你使用自訂的 CLAUDE_CONFIG_DIR,請在此設定——從 Finder 啟動的應用程式不會繼承 shell 環境變數。修改後請重新啟動 CodeIsland。", + "claude_config_dir_placeholder": "自動偵測", + "claude_config_dir_resolved": "目前使用:%@", // Webhook 轉發 "webhook_title": "Webhook 轉發", @@ -1509,6 +1521,10 @@ final class L10n: ObservableObject { "excluded_hook_cwd_title": "指定パスの Hook を無視", "excluded_hook_cwd_desc": "カンマ区切りの部分文字列。作業ディレクトリにいずれかを含む hook イベントは静かに破棄されます。claude-mem 等のバックグラウンドプラグイン除外に便利です。例:.claude-mem,.cache/agents", "excluded_hook_cwd_placeholder": "例: .claude-mem,.cache/agents", + "claude_config_dir_title": "Claude Code 設定ディレクトリ", + "claude_config_dir_desc": "Claude Code が projects/ と settings.json を保存する場所。空欄の場合は自動検出します($CLAUDE_CONFIG_DIR、次に projects/ が実在する ~/.claude、次に ~/.config/claude-code の順)。カスタムの CLAUDE_CONFIG_DIR を使用している場合は設定してください。Finder から起動したアプリはシェルの環境変数を継承しません。変更後は CodeIsland を再起動してください。", + "claude_config_dir_placeholder": "自動検出", + "claude_config_dir_resolved": "現在の使用先: %@", // Webhook 転送 "webhook_title": "Webhook 転送", @@ -1849,6 +1865,10 @@ final class L10n: ObservableObject { "excluded_hook_cwd_title": "지정 경로의 Hook 무시", "excluded_hook_cwd_desc": "쉼표로 구분된 부분 문자열. 작업 디렉터리에 하나라도 포함된 hook 이벤트는 조용히 폐기됩니다. claude-mem 같은 백그라운드 플러그인 필터링에 유용합니다. 예: .claude-mem,.cache/agents", "excluded_hook_cwd_placeholder": "예: .claude-mem,.cache/agents", + "claude_config_dir_title": "Claude Code 설정 디렉터리", + "claude_config_dir_desc": "Claude Code가 projects/ 및 settings.json을 저장하는 위치입니다. 비워 두면 자동으로 감지합니다($CLAUDE_CONFIG_DIR, 그다음 projects/가 실제로 있는 ~/.claude, 그다음 ~/.config/claude-code). 사용자 지정 CLAUDE_CONFIG_DIR을 사용하는 경우 설정하세요. Finder에서 실행된 앱은 셸 환경 변수를 상속하지 않습니다. 변경 후 CodeIsland를 다시 시작하세요.", + "claude_config_dir_placeholder": "자동 감지", + "claude_config_dir_resolved": "현재 사용 중: %@", // Webhook 전달 "webhook_title": "Webhook 전달", @@ -2189,6 +2209,10 @@ final class L10n: ObservableObject { "excluded_hook_cwd_title": "Belirli Yolların Hook'larını Yoksay", "excluded_hook_cwd_desc": "Virgülle ayrılmış alt dizeler. Çalışma dizini bunlardan birini içeren hook olayları sessizce yok sayılır — claude-mem gibi arka plan eklentilerini filtrelemek için kullanışlı. Örn: .claude-mem,.cache/agents", "excluded_hook_cwd_placeholder": "örn. .claude-mem,.cache/agents", + "claude_config_dir_title": "Claude Code Yapılandırma Dizini", + "claude_config_dir_desc": "Claude Code'un projects/ ve settings.json dosyalarını sakladığı yer. Otomatik algılama için boş bırakın ($CLAUDE_CONFIG_DIR, ardından projects/ klasörünü gerçekten içeren ~/.claude, ardından ~/.config/claude-code). Özel bir CLAUDE_CONFIG_DIR kullanıyorsanız bunu ayarlayın; Finder'dan başlatılan bir uygulama kabuk ortam değişkenlerini devralmaz. Değişiklikten sonra CodeIsland'ı yeniden başlatın.", + "claude_config_dir_placeholder": "otomatik algıla", + "claude_config_dir_resolved": "Şu anda kullanılan: %@", // Webhook iletme "webhook_title": "Webhook İletme", diff --git a/Sources/CodeIsland/SessionTitleStore.swift b/Sources/CodeIsland/SessionTitleStore.swift index 20141243..55e4190e 100644 --- a/Sources/CodeIsland/SessionTitleStore.swift +++ b/Sources/CodeIsland/SessionTitleStore.swift @@ -72,8 +72,7 @@ enum SessionTitleStore { guard let cwd else { return nil } let projectDir = cwd.claudeProjectDirEncoded() - let home = FileManager.default.homeDirectoryForCurrentUser.path - let path = "\(home)/.claude/projects/\(projectDir)/\(sessionId).jsonl" + let path = "\(ClaudeConfigPaths.projectsDir())/\(projectDir)/\(sessionId).jsonl" guard let handle = FileHandle(forReadingAtPath: path) else { return nil diff --git a/Sources/CodeIsland/Settings.swift b/Sources/CodeIsland/Settings.swift index 8e6ecd5d..d3cfa38c 100644 --- a/Sources/CodeIsland/Settings.swift +++ b/Sources/CodeIsland/Settings.swift @@ -119,6 +119,10 @@ enum SettingsKey { // Hook cwd exclusion (comma-separated substrings; cwd containing any drops the event) static let excludedHookCwdSubstrings = "excludedHookCwdSubstrings" + // Claude Code config dir override (empty = auto-detect). Must match + // ClaudeConfigPaths.preferenceKey — that resolver is the only reader. + static let claudeConfigDir = "claude_config_dir" + // Webhook forwarding: POST hook events to an external URL static let webhookEnabled = "webhookEnabled" static let webhookURL = "webhookURL" @@ -195,6 +199,8 @@ struct SettingsDefaults { static let excludedHookCwdSubstrings = "" + static let claudeConfigDir = "" + static let webhookEnabled = false static let webhookURL = "" static let webhookEventFilter = "" @@ -259,6 +265,7 @@ class SettingsManager { SettingsKey.defaultSource: SettingsDefaults.defaultSource, SettingsKey.autoApproveTools: SettingsDefaults.autoApproveTools, SettingsKey.excludedHookCwdSubstrings: SettingsDefaults.excludedHookCwdSubstrings, + SettingsKey.claudeConfigDir: SettingsDefaults.claudeConfigDir, SettingsKey.webhookEnabled: SettingsDefaults.webhookEnabled, SettingsKey.webhookURL: SettingsDefaults.webhookURL, SettingsKey.webhookEventFilter: SettingsDefaults.webhookEventFilter, @@ -410,6 +417,13 @@ class SettingsManager { get { defaults.string(forKey: SettingsKey.excludedHookCwdSubstrings) ?? SettingsDefaults.excludedHookCwdSubstrings } set { defaults.set(newValue, forKey: SettingsKey.excludedHookCwdSubstrings) } } + + /// Explicit Claude Code config dir. Empty means auto-detect — see `ClaudeConfigPaths`. + /// Needed because a Finder-launched app inherits no `$CLAUDE_CONFIG_DIR`. + var claudeConfigDir: String { + get { defaults.string(forKey: SettingsKey.claudeConfigDir) ?? SettingsDefaults.claudeConfigDir } + set { defaults.set(newValue, forKey: SettingsKey.claudeConfigDir) } + } } // MARK: - Shortcut Actions diff --git a/Sources/CodeIsland/SettingsView.swift b/Sources/CodeIsland/SettingsView.swift index 5a0aaa53..02dfcb31 100644 --- a/Sources/CodeIsland/SettingsView.swift +++ b/Sources/CodeIsland/SettingsView.swift @@ -392,6 +392,7 @@ private struct BehaviorPage: View { @AppStorage(SettingsKey.maxToolHistory) private var maxToolHistory = SettingsDefaults.maxToolHistory @AppStorage(SettingsKey.autoApproveTools) private var autoApproveRaw: String = SettingsDefaults.autoApproveTools @AppStorage(SettingsKey.excludedHookCwdSubstrings) private var excludedHookCwdSubstrings: String = SettingsDefaults.excludedHookCwdSubstrings + @AppStorage(SettingsKey.claudeConfigDir) private var claudeConfigDir: String = SettingsDefaults.claudeConfigDir @AppStorage(SettingsKey.webhookEnabled) private var webhookEnabled: Bool = SettingsDefaults.webhookEnabled @AppStorage(SettingsKey.webhookURL) private var webhookURL: String = SettingsDefaults.webhookURL @AppStorage(SettingsKey.webhookEventFilter) private var webhookEventFilter: String = SettingsDefaults.webhookEventFilter @@ -500,6 +501,19 @@ private struct BehaviorPage: View { } } + Section(l10n["claude_config_dir_title"]) { + Text(l10n["claude_config_dir_desc"]) + .font(.caption) + .foregroundStyle(.secondary) + TextField(l10n["claude_config_dir_placeholder"], text: $claudeConfigDir) + .textFieldStyle(.roundedBorder) + .font(.system(size: 12, design: .monospaced)) + Text(String(format: l10n["claude_config_dir_resolved"], + ClaudeConfigPaths.displayPath(ClaudeConfigPaths.configDir()))) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(.secondary) + } + Section(l10n["excluded_hook_cwd_title"]) { Text(l10n["excluded_hook_cwd_desc"]) .font(.caption) diff --git a/Sources/CodeIslandCore/ClaudeConfigPaths.swift b/Sources/CodeIslandCore/ClaudeConfigPaths.swift new file mode 100644 index 00000000..a918d0dd --- /dev/null +++ b/Sources/CodeIslandCore/ClaudeConfigPaths.swift @@ -0,0 +1,160 @@ +import Foundation + +/// Resolves the Claude Code configuration directory. +/// +/// Claude Code honors `$CLAUDE_CONFIG_DIR` and falls back to `~/.claude`. CodeIsland is a +/// GUI app, so it is usually launched from Finder/login items and does **not** inherit the +/// user's shell environment — reading the env var alone is not enough. The chain below adds +/// a user-settable preference (the only thing that reliably survives a Finder launch) and a +/// probe of the common XDG-style location before defaulting. +/// +/// Resolution order: +/// 1. `claude_config_dir` user preference (Settings → set explicitly) +/// 2. `$CLAUDE_CONFIG_DIR` (present when launched from a shell) +/// 3. `~/.claude`, when populated — Claude Code's own default must not be shadowed +/// 4. `~/.config/claude-code`, when populated +/// 5. `~/.claude` +/// +/// Rungs 3–4 test for `projects/` specifically; see `isLiveConfigDir`. +public enum ClaudeConfigPaths { + /// UserDefaults key backing the Settings field. + public static let preferenceKey = "claude_config_dir" + + private static let cacheLock = NSLock() + private static var cachedKey: String? + private static var cachedValue: String? + + /// Absolute path to the resolved Claude config directory (no trailing slash). + /// + /// Called per session per refresh tick, so the filesystem probes in `resolve` are + /// memoized. The cache is keyed on the *inputs* (preference, environment, home) + /// rather than being a one-shot latch: reading them is cheap and in-memory, while + /// the probes are syscalls. That keeps the Settings live-preview correct — typing a + /// new preference changes the key and re-resolves immediately — while the steady + /// state costs no stat() at all. + /// + /// Only filesystem *layout* changes are missed until the inputs change or the app + /// restarts, which matches the Settings copy ("Restart CodeIsland after changing"). + public static func configDir() -> String { + let pref = UserDefaults.standard.string(forKey: preferenceKey) + let env = ProcessInfo.processInfo.environment["CLAUDE_CONFIG_DIR"] + let home = NSHomeDirectory() + let key = [pref ?? "", env ?? "", home].joined(separator: "\u{0}") + + cacheLock.lock() + defer { cacheLock.unlock() } + if key == cachedKey, let cachedValue { return cachedValue } + + let resolved = resolve( + preference: pref, + environment: env, + homeDir: home, + directoryExists: defaultDirectoryExists + ) + cachedKey = key + cachedValue = resolved + return resolved + } + + /// The real filesystem probe used by `configDir`. Exposed so the file-vs-directory + /// distinction is testable — `resolve` takes it injected, which abstracts it away. + static func defaultDirectoryExists(_ path: String) -> Bool { + var isDir: ObjCBool = false + let exists = FileManager.default.fileExists(atPath: path, isDirectory: &isDir) + return exists && isDir.boolValue + } + + /// Canonical NFC form of a path, for comparing two paths for identity. + /// + /// `configDir()` returns an NFC-normalized value, so a caller comparing it against a + /// hand-built literal (`home + "/.claude"`) can get a false mismatch when the home + /// path contains decomposed Unicode. Note that Swift's `==` on String compares by + /// canonical equivalence and would mask this — the hazard is real only where paths + /// are compared as bytes or used to key a filesystem operation. + public static func canonical(_ path: String) -> String { + path.precomposedStringWithCanonicalMapping + } + + /// Drop the memoized resolution. Call after creating or moving a config directory + /// within the app's lifetime; not needed for preference edits, which re-key the cache. + public static func invalidateCache() { + cacheLock.lock() + defer { cacheLock.unlock() } + cachedKey = nil + cachedValue = nil + } + + /// Where per-project transcripts live. + public static func projectsDir() -> String { configDir() + "/projects" } + + /// The `settings.json` that holds hook configuration. + public static func settingsPath() -> String { configDir() + "/settings.json" } + + /// Pure resolution core — all inputs injected so the fallback chain is testable + /// without touching UserDefaults, the environment, or the real filesystem. + public static func resolve( + preference: String?, + environment: String?, + homeDir: String, + directoryExists: (String) -> Bool + ) -> String { + if let pref = normalized(preference, homeDir: homeDir) { return pref } + if let env = normalized(environment, homeDir: homeDir) { return env } + + // Claude Code itself has no XDG rung — it reads $CLAUDE_CONFIG_DIR, else ~/.claude. + // So a live ~/.claude must win over the XDG probe, or a stale ~/.config/claude-code + // would silently repoint us away from the directory Claude Code actually reads. + let dotClaude = homeDir + "/.claude" + if isLiveConfigDir(dotClaude, directoryExists: directoryExists) { return dotClaude } + + let xdg = homeDir + "/.config/claude-code" + if isLiveConfigDir(xdg, directoryExists: directoryExists) { return xdg } + + return dotClaude + } + + /// User-visible form — collapses the home prefix back to `~` so Settings and + /// diagnostics show `~/.config/claude-code/settings.json` rather than a full path. + public static func displayPath(_ absolute: String, homeDir: String = NSHomeDirectory()) -> String { + guard absolute == homeDir || absolute.hasPrefix(homeDir + "/") else { return absolute } + return "~" + absolute.dropFirst(homeDir.count) + } + + /// A directory only counts as a live Claude config dir if it holds `projects/`. + /// + /// `projects/` is written only by Claude Code itself, which makes it the sole reliable + /// proof. `settings.json` deliberately does NOT count: CodeIsland's own hook installer + /// creates that file, so treating it as evidence would let us manufacture the very + /// signal we then read back — an empty `~/.claude` containing nothing but a + /// CodeIsland-written `settings.json` would outrank the user's real config dir forever. + /// Note this asks whether `projects` is a DIRECTORY, not merely that the path + /// exists — a stray regular file of that name must not mark a config dir live. + static func isLiveConfigDir(_ path: String, directoryExists: (String) -> Bool) -> Bool { + directoryExists(path + "/projects") + } + + /// Trims, expands `~`, and drops any trailing slash. Returns nil for empty input. + /// + /// `$CLAUDE_CONFIG_DIR` holds a single path — Claude Code reads it verbatim and does + /// not treat it as a `PATH`-style list, so no separator splitting happens here. That + /// matters on APFS, where `:` is legal in a filename. + /// + /// Claude Code normalizes the path to Unicode NFC before use; we match that so a + /// decomposed path (as some macOS APIs hand back) still resolves to the same directory. + static func normalized(_ raw: String?, homeDir: String) -> String? { + var value = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty else { return nil } + if value == "~" { return homeDir.precomposedStringWithCanonicalMapping } + if value.hasPrefix("~/") { value = homeDir + "/" + value.dropFirst(2) } + while value.count > 1 && value.hasSuffix("/") { value.removeLast() } + + // Reject anything that cannot be a config dir. A relative path would resolve + // against the app's cwd, and `/` would yield "//projects" — in both cases the + // hook installer would happily `createDirectory` at the bogus location and then + // report success while Claude Code read somewhere else entirely. Falling through + // to auto-detect is strictly better than acting on a typo. + guard value.hasPrefix("/"), value != "/" else { return nil } + + return value.precomposedStringWithCanonicalMapping + } +} diff --git a/Sources/CodeIslandCore/ClaudeUsageScanner.swift b/Sources/CodeIslandCore/ClaudeUsageScanner.swift index ecbd3cf2..c251bb22 100644 --- a/Sources/CodeIslandCore/ClaudeUsageScanner.swift +++ b/Sources/CodeIslandCore/ClaudeUsageScanner.swift @@ -66,7 +66,7 @@ public enum ClaudeUsageScanner { /// One-shot convenience (tests, callers without persistent state). public static func scan( - claudeHome: String = NSHomeDirectory() + "/.claude", + claudeHome: String = ClaudeConfigPaths.configDir(), now: Date = Date() ) -> Snapshot { var cache = FileCache() @@ -74,7 +74,7 @@ public enum ClaudeUsageScanner { } public static func scan( - claudeHome: String = NSHomeDirectory() + "/.claude", + claudeHome: String = ClaudeConfigPaths.configDir(), now: Date = Date(), cache: inout FileCache ) -> Snapshot { diff --git a/Tests/CodeIslandCoreTests/ClaudeConfigPathsTests.swift b/Tests/CodeIslandCoreTests/ClaudeConfigPathsTests.swift new file mode 100644 index 00000000..f48bd228 --- /dev/null +++ b/Tests/CodeIslandCoreTests/ClaudeConfigPathsTests.swift @@ -0,0 +1,163 @@ +import XCTest +@testable import CodeIslandCore + +final class ClaudeConfigPathsTests: XCTestCase { + private let home = "/Users/tester" + + /// Nothing exists anywhere → the historical `~/.claude` default. + func testFallsBackToDotClaudeWhenNothingSet() { + let result = ClaudeConfigPaths.resolve( + preference: nil, environment: nil, homeDir: home, directoryExists: { _ in false }) + XCTAssertEqual(result, "/Users/tester/.claude") + } + + /// The regression this resolver exists for: a user with a custom CLAUDE_CONFIG_DIR + /// was silently scanned at ~/.claude, which is empty, so no sessions ever appeared. + func testEnvironmentVariableWins() { + let result = ClaudeConfigPaths.resolve( + preference: nil, + environment: "/Users/tester/.config/claude-code", + homeDir: home, + directoryExists: { _ in false }) + XCTAssertEqual(result, "/Users/tester/.config/claude-code") + } + + /// The preference must outrank the environment — it is the only channel that + /// survives a Finder launch, where no shell environment is inherited. + func testPreferenceOutranksEnvironment() { + let result = ClaudeConfigPaths.resolve( + preference: "/explicit/dir", + environment: "/env/dir", + homeDir: home, + directoryExists: { _ in true }) + XCTAssertEqual(result, "/explicit/dir") + } + + /// With no preference and no environment (the Finder case), a populated + /// ~/.config/claude-code is auto-detected instead of defaulting to ~/.claude. + func testProbesXDGDirectoryWhenPopulated() { + let result = ClaudeConfigPaths.resolve( + preference: nil, environment: nil, homeDir: home, + directoryExists: { $0 == "/Users/tester/.config/claude-code/projects" }) + XCTAssertEqual(result, "/Users/tester/.config/claude-code") + } + + /// An empty ~/.config/claude-code (created by some other tool) must not shadow + /// a real ~/.claude. + func testEmptyXDGDirectoryDoesNotShadowDotClaude() { + let result = ClaudeConfigPaths.resolve( + preference: nil, environment: nil, homeDir: home, + directoryExists: { $0 == "/Users/tester/.config/claude-code" }) + XCTAssertEqual(result, "/Users/tester/.claude") + } + + /// Claude Code has no XDG rung — it reads $CLAUDE_CONFIG_DIR or ~/.claude. So when + /// BOTH are populated and nothing points elsewhere, ~/.claude must win, or a stale + /// XDG dir would silently repoint us away from what Claude Code actually reads. + func testLiveDotClaudeOutranksPopulatedXDGDirectory() { + let result = ClaudeConfigPaths.resolve( + preference: nil, environment: nil, homeDir: home, + directoryExists: { $0 == "/Users/tester/.claude/projects" + || $0 == "/Users/tester/.config/claude-code/projects" }) + XCTAssertEqual(result, "/Users/tester/.claude") + } + + /// A settings.json alone must NOT mark a dir live — CodeIsland's own hook installer + /// writes that file, so counting it would let us manufacture the signal we read back. + func testSettingsJsonAloneDoesNotMarkDirectoryLive() { + let result = ClaudeConfigPaths.resolve( + preference: nil, environment: nil, homeDir: home, + directoryExists: { $0 == "/Users/tester/.claude/settings.json" + || $0 == "/Users/tester/.config/claude-code/projects" }) + XCTAssertEqual(result, "/Users/tester/.config/claude-code") + } + + /// A typo'd or relative value must fall through to auto-detect rather than being + /// taken literally — the hook installer would otherwise create the bogus directory + /// and report success while Claude Code read somewhere else. + func testUnusableValuesFallThroughToAutoDetect() { + XCTAssertNil(ClaudeConfigPaths.normalized("claude-config", homeDir: home)) + XCTAssertNil(ClaudeConfigPaths.normalized("./claude", homeDir: home)) + XCTAssertNil(ClaudeConfigPaths.normalized("/", homeDir: home)) + + // A bad preference must not win — resolution continues down the chain. + let result = ClaudeConfigPaths.resolve( + preference: "relative/typo", environment: nil, homeDir: home, + directoryExists: { $0 == "/Users/tester/.config/claude-code/projects" }) + XCTAssertEqual(result, "/Users/tester/.config/claude-code") + } + + /// A stray regular FILE named `projects` must not mark a config dir live. This + /// exercises the REAL filesystem probe against a real temp tree — asserting it via + /// the injected `directoryExists` would prove nothing, since the injection is + /// precisely what abstracts the file-vs-directory distinction away. + func testRegularFileNamedProjectsIsNotTreatedAsLive() throws { + let fm = FileManager.default + let root = fm.temporaryDirectory.appendingPathComponent("ccp-" + UUID().uuidString) + let asDir = root.appendingPathComponent("dir") + let asFile = root.appendingPathComponent("file") + try fm.createDirectory(at: asDir.appendingPathComponent("projects"), + withIntermediateDirectories: true) + try fm.createDirectory(at: asFile, withIntermediateDirectories: true) + try Data("not a directory".utf8) + .write(to: asFile.appendingPathComponent("projects")) + defer { try? fm.removeItem(at: root) } + + XCTAssertTrue(ClaudeConfigPaths.isLiveConfigDir( + asDir.path, directoryExists: ClaudeConfigPaths.defaultDirectoryExists)) + XCTAssertFalse(ClaudeConfigPaths.isLiveConfigDir( + asFile.path, directoryExists: ClaudeConfigPaths.defaultDirectoryExists), + "a regular file named projects must not mark the dir live") + } + + func testEmptyAndWhitespaceValuesAreIgnored() { + XCTAssertNil(ClaudeConfigPaths.normalized("", homeDir: home)) + XCTAssertNil(ClaudeConfigPaths.normalized(" ", homeDir: home)) + XCTAssertNil(ClaudeConfigPaths.normalized(nil, homeDir: home)) + } + + func testTildeExpansionAndTrailingSlashes() { + XCTAssertEqual(ClaudeConfigPaths.normalized("~", homeDir: home), home) + XCTAssertEqual( + ClaudeConfigPaths.normalized("~/.config/claude-code", homeDir: home), + "/Users/tester/.config/claude-code") + XCTAssertEqual(ClaudeConfigPaths.normalized("/a/b///", homeDir: home), "/a/b") + XCTAssertEqual(ClaudeConfigPaths.normalized(" /a/b ", homeDir: home), "/a/b") + } + + /// CLAUDE_CONFIG_DIR is a single path, not a PATH-style list. `:` is legal in an + /// APFS filename, so splitting on it would silently truncate a valid directory. + func testColonIsPreservedAsPartOfPath() { + XCTAssertEqual( + ClaudeConfigPaths.normalized("/Users/tester/AI:ML/claude", homeDir: home), + "/Users/tester/AI:ML/claude") + } + + /// Claude Code normalizes its config path to NFC; matching that keeps a decomposed + /// path (what some macOS APIs return) resolving to the same directory. + func testUnicodePathsAreNormalizedToNFC() { + let decomposed = "/Users/tester/Cafe\u{0301}/claude" // e + combining acute + let precomposed = "/Users/tester/Caf\u{00E9}/claude" // é + + let result = try? XCTUnwrap(ClaudeConfigPaths.normalized(decomposed, homeDir: home)) + + // Compare BYTES, not Strings. Swift's == on String uses canonical equivalence, + // so `decomposed == precomposed` is already true and an XCTAssertEqual here + // would pass even if the normalization were deleted entirely. + XCTAssertEqual(Array((result ?? "").utf8), Array(precomposed.utf8)) + XCTAssertNotEqual(Array((result ?? "").utf8), Array(decomposed.utf8)) + } + + func testDisplayPathCollapsesHomePrefix() { + XCTAssertEqual( + ClaudeConfigPaths.displayPath("/Users/tester/.claude/settings.json", homeDir: home), + "~/.claude/settings.json") + XCTAssertEqual( + ClaudeConfigPaths.displayPath("/opt/elsewhere/settings.json", homeDir: home), + "/opt/elsewhere/settings.json") + // A different user's home that merely shares a prefix must not be collapsed. + XCTAssertEqual( + ClaudeConfigPaths.displayPath("/Users/tester2/.claude", homeDir: home), + "/Users/tester2/.claude") + } +} diff --git a/Tests/CodeIslandTests/ClaudeConfigPathsWiringTests.swift b/Tests/CodeIslandTests/ClaudeConfigPathsWiringTests.swift new file mode 100644 index 00000000..842e6c87 --- /dev/null +++ b/Tests/CodeIslandTests/ClaudeConfigPathsWiringTests.swift @@ -0,0 +1,33 @@ +import XCTest +import CodeIslandCore +@testable import CodeIsland + +/// Guards the seams between `ClaudeConfigPaths` (CodeIslandCore) and the app targets +/// that depend on it. Both contracts below are enforced only by convention in the +/// source, so a rename or a dropped override would otherwise fail silently at runtime. +final class ClaudeConfigPathsWiringTests: XCTestCase { + + /// The preference key literal is duplicated: `SettingsKey.claudeConfigDir` drives the + /// @AppStorage field, `ClaudeConfigPaths.preferenceKey` is what the resolver reads. + /// If they drift, the Settings field silently stops affecting resolution — the user + /// types a path, the UI accepts it, and nothing changes. + func testSettingsKeyMatchesResolverPreferenceKey() { + XCTAssertEqual(SettingsKey.claudeConfigDir, ClaudeConfigPaths.preferenceKey) + } + + /// The Claude CLIConfig carries `configPath: "settings.json"` — correct ONLY because + /// `rootOverride` supplies the config dir. Drop the override and `fullPath` silently + /// becomes `~/settings.json`: it would not error, it would write hooks into the + /// user's home directory. Pin the resolved path to the resolver's own answer. + func testClaudeConfigPathResolvesThroughRootOverride() throws { + let claude = try XCTUnwrap( + ConfigInstaller.allCLIs.first { $0.source == "claude" }, + "built-in Claude CLIConfig is missing") + + XCTAssertEqual(claude.fullPath, ClaudeConfigPaths.settingsPath()) + XCTAssertEqual(claude.dirPath, ClaudeConfigPaths.configDir()) + XCTAssertNotEqual( + claude.fullPath, NSHomeDirectory() + "/settings.json", + "rootOverride was dropped — hooks would be written to the home directory") + } +}