// Profile config storage: one ~/.shannoncoat/.json per profile, // holding the two dirs a profile needs — Claude Desktop's --user-data-dir, // and the CLAUDE_CONFIG_DIR paired with it so any Claude Code session // launched from that Desktop instance gets its own isolated config too, // instead of sharing one with every other profile. (Claude Code already // has its own profile mechanism for use outside Desktop — this isn't a // second one, just making sure a managed Desktop session doesn't leak // into it.) "default" is implicit — no file for it — and always means the // real, untouched ~/.claude + Application Support/Claude. import Foundation struct ResolvedProfile: Equatable { let name: String let codeDir: String let appDir: String } enum ProfileError: LocalizedError { case emptyName case reservedName case invalidName(String) case alreadyExists(String) case notFound(String) case dirCollision(existingProfile: String, dir: String, kind: String) var errorDescription: String? { switch self { case .emptyName: return "Name is required." case .reservedName: return "\"default\" is reserved for the real, untouched Claude install." case .invalidName(let name): return "\"\(name)\" isn't a valid profile name (no spaces, slashes, colons, or leading dots)." case .alreadyExists(let name): return "A profile named \"\(name)\" already exists." case .notFound(let name): return "No profile named \"\(name)\"." case .dirCollision(let existingProfile, let dir, let kind): return "\(kind) dir \(dir) is already used by profile \"\(existingProfile)\"." } } } // What lives on disk. Profile name comes from the filename, not a field in // here, so there's only one place a profile's name can disagree with // itself. `paths` is nested (matching the old YAML's shape) rather than // flattened, so a later profile-specific feature has an obvious place to // add its own top-level key alongside `paths` without disturbing this one. private struct ProfileFile: Codable { var paths: Paths struct Paths: Codable { var code: String var desktop: String } } enum ProfileStore { static let root: URL = { let url = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".shannoncoat") try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) return url }() static let defaultProfile = ResolvedProfile( name: "default", codeDir: FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".claude").path, appDir: FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent("Library/Application Support/Claude").path ) // "~" expands to home; anything else non-absolute resolves relative to // `root` (the config dir itself) rather than the process's cwd; an // already-absolute path is left alone. static func expand(_ raw: String) -> String { if raw.hasPrefix("~") { return (raw as NSString).expandingTildeInPath } if raw.hasPrefix("/") { return raw } return root.appendingPathComponent(raw).path } static func validateName(_ name: String) throws { if name.isEmpty { throw ProfileError.emptyName } if name == "default" { throw ProfileError.reservedName } let hasBadChar = name.contains("/") || name.contains(":") || name.hasPrefix(".") || name.rangeOfCharacter(from: .whitespacesAndNewlines) != nil if hasBadChar { throw ProfileError.invalidName(name) } } private static func configURL(for name: String) -> URL { root.appendingPathComponent("\(name).json") } // Every configured profile plus the implicit `default`, in no // particular order. Config files that fail to decode are skipped // rather than surfaced here — a hand-edited-into-garbage file // shouldn't take the whole menu down. static func loadAll() -> [ResolvedProfile] { var profiles = [defaultProfile] let files = (try? FileManager.default.contentsOfDirectory( at: root, includingPropertiesForKeys: nil)) ?? [] for file in files where file.pathExtension == "json" { guard let data = try? Data(contentsOf: file), let profileFile = try? JSONDecoder().decode(ProfileFile.self, from: data) else { continue } let name = file.deletingPathExtension().lastPathComponent profiles.append(ResolvedProfile( name: name, codeDir: expand(profileFile.paths.code), appDir: expand(profileFile.paths.desktop))) } return profiles } struct DirCollision { let profileA: String; let profileB: String; let dir: String; let kind: String } // Two profiles secretly sharing a dir would silently merge their Claude // sessions — defeats the entire point of switching. Checked on every // load (see `loadAll` call sites), not just when a profile is created, // so a hand-edited file can't sneak one in unnoticed. static func findCollisions(among profiles: [ResolvedProfile]) -> [DirCollision] { var collisions: [DirCollision] = [] for i in profiles.indices { for j in profiles.index(after: i).. ResolvedProfile { try validateName(name) let url = configURL(for: name) guard !FileManager.default.fileExists(atPath: url.path) else { throw ProfileError.alreadyExists(name) } let rawCode = codeDir?.isEmpty == false ? codeDir! : "data/\(name)/code" let rawApp = appDir?.isEmpty == false ? appDir! : "data/\(name)/app" let resolvedCode = expand(rawCode) let resolvedApp = expand(rawApp) for existing in loadAll() { if existing.codeDir == resolvedCode { throw ProfileError.dirCollision(existingProfile: existing.name, dir: resolvedCode, kind: "Claude Code") } if existing.appDir == resolvedApp { throw ProfileError.dirCollision(existingProfile: existing.name, dir: resolvedApp, kind: "Claude Desktop") } } try FileManager.default.createDirectory(atPath: resolvedCode, withIntermediateDirectories: true) try FileManager.default.createDirectory(atPath: resolvedApp, withIntermediateDirectories: true) let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] let data = try encoder.encode(ProfileFile(paths: .init(code: rawCode, desktop: rawApp))) try data.write(to: url) return ResolvedProfile(name: name, codeDir: resolvedCode, appDir: resolvedApp) } static func delete(_ name: String) throws { guard name != "default" else { throw ProfileError.reservedName } let url = configURL(for: name) guard FileManager.default.fileExists(atPath: url.path) else { throw ProfileError.notFound(name) } try FileManager.default.removeItem(at: url) } }