codeburn/mac/Sources/CodeBurnMenubar/Data/CLIClaudeConfig.swift
Resham Joshi 3cdd13881f
feat(menubar): configure CLAUDE_CONFIG_DIRS via Settings (#434) (#436)
Adds a "Config Directories" section under the Claude settings tab so users
can aggregate usage across multiple Claude config directories (work /
personal accounts) from the GUI. The menubar is an accessory app that
doesn't inherit the user's shell environment, so CLAUDE_CONFIG_DIRS was
previously unreachable from the app.

Rather than injecting the value as an env var into every spawned subprocess
(which would force arbitrary user paths through the shell/AppleScript
allowlist that deliberately excludes shell metacharacters), the list is
persisted to the shared ~/.config/codeburn/config.json. The CLI reads it
regardless of how it's launched, so both the menubar's data refresh and
terminal-launched `report`/`optimize` honor it consistently.

CLI: getClaudeConfigDirs() now reads a claudeConfigDirs array from config
as a fallback below the CLAUDE_CONFIG_DIRS / CLAUDE_CONFIG_DIR env vars
(env still wins for per-shell overrides), above the ~/.claude default.

Menubar: CLIClaudeConfig mirrors CLICurrencyConfig's flock-guarded write;
the Settings UI offers an add/remove list with a folder picker and forces
a refresh on edit.
2026-06-06 02:30:46 +02:00

74 lines
3.2 KiB
Swift

import Foundation
/// Reads and writes the CLI's `claudeConfigDirs` list in
/// `~/.config/codeburn/config.json`. The menubar is a GUI app that doesn't
/// inherit the user's shell environment, so it can't rely on `CLAUDE_CONFIG_DIRS`
/// to aggregate usage across multiple Claude config directories (work / personal
/// accounts). Persisting to the shared config file instead means every `codeburn`
/// invocation honors the list whether the menubar spawns it directly or the
/// user launches `codeburn report` in a terminal without injecting environment
/// variables through the shell/AppleScript paths (which only accept a strict
/// metacharacter-free allowlist that arbitrary directory paths can't satisfy).
///
/// Shares the same on-disk flock as `CLICurrencyConfig` so a concurrent
/// `codeburn` write from a terminal can't race the menubar and drop the other's
/// changes (TOCTOU on config.json).
enum CLIClaudeConfig {
private static var configDir: String {
(NSHomeDirectory() as NSString).appendingPathComponent(".config/codeburn")
}
private static var configPath: String {
(configDir as NSString).appendingPathComponent("config.json")
}
private static var lockPath: String {
(configDir as NSString).appendingPathComponent(".config.lock")
}
/// Returns the persisted config directories, or an empty array when none are
/// set (the CLI then falls back to `~/.claude`).
static func load() -> [String] {
guard
let data = try? SafeFile.read(from: configPath),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let dirs = json["claudeConfigDirs"] as? [Any]
else {
return []
}
return dirs.compactMap { $0 as? String }.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
}
/// Persists the given directories. An empty list removes the key entirely so
/// the CLI reverts to its default `~/.claude` behavior. Entries are trimmed
/// and blanks dropped; order is preserved.
static func persist(dirs: [String]) {
let cleaned = dirs
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
do {
try SafeFile.withExclusiveLock(at: lockPath) {
var existing: [String: Any] = [:]
if let data = try? SafeFile.read(from: configPath),
let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
existing = parsed
}
if cleaned.isEmpty {
existing.removeValue(forKey: "claudeConfigDirs")
} else {
existing["claudeConfigDirs"] = cleaned
}
guard let data = try? JSONSerialization.data(
withJSONObject: existing,
options: [.prettyPrinted, .sortedKeys]
) else {
return
}
try SafeFile.write(data, to: configPath, mode: 0o600)
}
} catch {
NSLog("CodeBurn: failed to persist claudeConfigDirs config: \(error)")
}
}
}