From d0b0e84f26dbc6c917aa201505df0076de79d933 Mon Sep 17 00:00:00 2001 From: Joseph Shenton <134051803+moderniselife@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:08:16 +1100 Subject: [PATCH] Run separate Claude Desktop profiles side by side Signed Claude Desktop builds ignore CLAUDE_USER_DATA_DIR, but the Windows standalone-installer build honors --user-data-dir (verified live: separate instance and login alongside the Store build). Managed Desktop profiles now launch with --user-data-dir= on all platforms. - Windows: launch via newest AnthropicClaude\app-*\claude.exe; Store build is refused for managed profiles with a prompt/banner linking claude.com/download. Add Claude Code launcher, themes, test project. - Swift/Linux/TUI: pass --user-data-dir=, match both flag forms, restore Desktop in the new-profile picker (macOS/Linux untested). - Docs updated; Main-only wording removed. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/pr.yml | 2 + .gitignore | 3 + README.md | 11 +- Sources/CursorProfiles/ClaudeLauncher.swift | 200 +++++ Sources/CursorProfiles/ContentView.swift | 30 +- Sources/CursorProfiles/CursorLauncher.swift | 3 +- .../CursorProfiles/CursorProfilesApp.swift | 14 +- Sources/CursorProfiles/Models.swift | 63 +- Sources/CursorProfiles/ProfileCard.swift | 61 +- .../CursorProfiles/ProfileEditorSheet.swift | 39 +- Sources/CursorProfiles/ProfileStore.swift | 280 +++++-- Sources/CursorProfiles/SettingsView.swift | 57 +- .../CursorProfiles/SpotlightCommandBar.swift | 26 +- .../CursorProfiles/TitleBarColorizer.swift | 5 +- linux/README.md | 18 +- linux/prism.py | 615 +++++++++++--- tui/README.md | 23 +- tui/main.go | 6 +- tui/signal_unix.go | 5 + tui/signal_windows.go | 4 + tui/stats.go | 3 + tui/store.go | 754 ++++++++++++++++-- tui/store_test.go | 140 ++++ tui/ui.go | 109 ++- windows.Tests/CardTemplateTests.cs | 433 ++++++++++ windows.Tests/Prism.Tests.csproj | 24 + windows/App.xaml | 140 +++- windows/App.xaml.cs | 21 - windows/ClaudeLauncher.cs | 294 +++++++ windows/CursorLauncher.cs | 5 +- windows/LaunchOptionsWindow.xaml | 6 +- windows/LaunchOptionsWindow.xaml.cs | 9 +- windows/MainWindow.xaml | 119 +-- windows/MainWindow.xaml.cs | 48 +- windows/Models.cs | 58 ++ windows/ProfileEditorWindow.xaml | 17 +- windows/ProfileEditorWindow.xaml.cs | 35 +- windows/ProfileStore.cs | 324 ++++++-- windows/README.md | 62 +- windows/SettingsWindow.xaml | 44 +- windows/SettingsWindow.xaml.cs | 49 +- windows/ShortcutBuilder.cs | 61 +- windows/ThemeManager.cs | 125 +++ windows/Themes/Dark.xaml | 18 + windows/Themes/Light.xaml | 18 + 45 files changed, 3907 insertions(+), 474 deletions(-) create mode 100644 Sources/CursorProfiles/ClaudeLauncher.swift create mode 100644 windows.Tests/CardTemplateTests.cs create mode 100644 windows.Tests/Prism.Tests.csproj create mode 100644 windows/ClaudeLauncher.cs create mode 100644 windows/ThemeManager.cs create mode 100644 windows/Themes/Dark.xaml create mode 100644 windows/Themes/Light.xaml diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 5ccc3e3..b443735 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -36,6 +36,8 @@ jobs: dotnet-version: 8.0.x - name: Build run: dotnet build windows/CursorProfiles.csproj -c Release + - name: Test (card template + dialog regression tests) + run: dotnet test windows.Tests/Prism.Tests.csproj -c Release - name: Publish self-contained single-file exe # The shippable artifact: runtime + deps bundled, one double-clickable file. run: dotnet publish windows/CursorProfiles.csproj -c Release -r win-x64 --self-contained -o publish/win-x64 /p:PublishSingleFile=true diff --git a/.gitignore b/.gitignore index cfc65d8..54496be 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ pypi/prism-tui/dist/ pypi/prism-tui/*.egg-info tui/prism-tui tui/prism-tui.exe +publish/ +windows.Tests/bin/ +windows.Tests/obj/ diff --git a/README.md b/README.md index 12d0cc7..917af56 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@

Prism

- One launcher for every Cursor you run.
+ One launcher for every Cursor you run — plus Claude Code and Claude Desktop.
Isolated profiles — own settings, extensions, login, chat history — with live stats,
instant switching, and per-profile title bar colors so you never mix up Work and Personal again.

@@ -126,13 +126,15 @@ runtime needed anywhere. From source: `go run ./tui` (Go 1.23+). ## How it works ``` -~/.cursor_profiles/ +~/.cursor_profiles/ # Cursor editor profiles .profiles.json # names, colors, launch defaults (shared across OSes) Work/ # a full Cursor user-data-dir Personal/ # another one — fully independent +~/.claude_code_profiles/ # Claude Code profiles (each dir = a CLAUDE_CONFIG_DIR) +~/.claude_desktop_profiles/ # Claude Desktop profiles (--user-data-dir) ``` -Launching a profile runs Cursor with `--user-data-dir --max-memory `, so isolation is enforced by Cursor itself rather than by anything Prism fakes. The built-in profile launches with no `--user-data-dir` at all — byte-for-byte identical to opening Cursor normally. +Launching a Cursor profile runs Cursor with `--user-data-dir --max-memory `, so isolation is enforced by Cursor itself rather than by anything Prism fakes. The built-in profile launches with no `--user-data-dir` at all — byte-for-byte identical to opening Cursor normally. Claude Code profiles set `CLAUDE_CONFIG_DIR` (the documented multi-account switch — separate login, settings, history per profile). Claude Desktop profiles launch with `--user-data-dir=` too, so each has its own login and several run side by side. On Windows this needs the standalone installer build of Claude Desktop (the Microsoft Store build drops launch flags) — Prism prompts you with the download link. Verified on Windows; macOS and Linux are untested. ## Layout @@ -146,7 +148,8 @@ Sources/CursorProfiles/ SettingsView.swift # Cursor path, defaults, storage ProfileStore.swift # Metadata, CRUD, live RSS/CPU/window polling SystemStats.swift # Real CPU/RAM/window measurement + global hotkey helpers - CursorLauncher.swift # Find/launch/quit Cursor processes + CursorLauncher.swift # Find/launch/quit Cursor processes + ClaudeLauncher.swift # Find/launch Claude Code (CLAUDE_CONFIG_DIR) & Desktop (--user-data-dir) TitleBarColorizer.swift # Merges per-profile title bar colors into User/settings.json PrismTheme.swift # Adaptive light/dark design system MeshBackgroundView.swift # Ambient background diff --git a/Sources/CursorProfiles/ClaudeLauncher.swift b/Sources/CursorProfiles/ClaudeLauncher.swift new file mode 100644 index 0000000..a0afdc7 --- /dev/null +++ b/Sources/CursorProfiles/ClaudeLauncher.swift @@ -0,0 +1,200 @@ +import AppKit +import Foundation + +// MARK: - Finding & launching Claude Code and Claude Desktop +// +// Claude Code profiles are isolated with CLAUDE_CONFIG_DIR (the documented +// multi-account mechanism); Claude Desktop is an Electron app isolated with +// --user-data-dir, exactly like Cursor. + +enum ClaudeLauncher { + + /// Well-known install locations. + static let desktopCandidatePaths: [String] = [ + "/Applications/Claude.app/Contents/MacOS/Claude", + NSHomeDirectory() + "/Applications/Claude.app/Contents/MacOS/Claude", + "/usr/local/bin/claude-desktop", + "/opt/homebrew/bin/claude-desktop", + ] + + static let codeCandidatePaths: [String] = [ + "/usr/local/bin/claude", + "/opt/homebrew/bin/claude", + NSHomeDirectory() + "/.local/bin/claude", + ] + + /// Locate the Claude Code CLI: custom path -> PATH -> known paths. + static func findClaudeCode(customPath: String?) -> String? { + if let customPath, !customPath.isEmpty, + FileManager.default.isExecutableFile(atPath: customPath) { + return customPath + } + for name in ["claude"] { + if let found = lookupOnPath(name) { return found } + } + for path in codeCandidatePaths where FileManager.default.isExecutableFile(atPath: path) { + return path + } + return nil + } + + /// Locate Claude Desktop: custom path -> Launch Services -> known paths. + static func findClaudeDesktop(customPath: String?) -> String? { + if let customPath, !customPath.isEmpty, + FileManager.default.isExecutableFile(atPath: customPath) { + return customPath + } + for bundleID in ["com.anthropic.claudefordesktop", "com.anthropic.claude"] { + if let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) { + let exec = appURL.appendingPathComponent("Contents/MacOS/Claude").path + if FileManager.default.isExecutableFile(atPath: exec) { return exec } + } + } + for path in desktopCandidatePaths where FileManager.default.isExecutableFile(atPath: path) { + return path + } + return nil + } + + private static func lookupOnPath(_ name: String) -> String? { + let dirs = (ProcessInfo.processInfo.environment["PATH"] ?? "").split(separator: ":").map(String.init) + for dir in dirs { + let candidate = (dir as NSString).appendingPathComponent(name) + if FileManager.default.isExecutableFile(atPath: candidate) { return candidate } + } + return nil + } + + enum LaunchError: LocalizedError { + case codeNotFound + case desktopNotFound + case failed(String) + + var errorDescription: String? { + switch self { + case .codeNotFound: + return "Claude Code could not be found. Install it (see code.claude.com/docs) or set a custom path in Settings." + case .desktopNotFound: + return "Claude Desktop could not be found. Install it from claude.com/download or set a custom path in Settings." + case .failed(let message): + return message + } + } + } + + /// Open Terminal running the CLI with this profile as its config dir. + /// The CLI is interactive, so it gets its own window rather than a + /// headless spawn. First launch walks through login for the new account. + static func launchCode(profileDir: URL, displayName: String, + codePath: String?, projectPath: String?, + isSystem: Bool) throws { + guard findClaudeCode(customPath: codePath) != nil else { + throw LaunchError.codeNotFound + } + if !isSystem { + try FileManager.default.createDirectory(at: profileDir, withIntermediateDirectories: true) + } + var work = projectPath ?? "" + if work.isEmpty { work = NSHomeDirectory() } + // Single-quote for sh, then escape for the AppleScript string literal. + func shellQuote(_ s: String) -> String { + "'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'" + } + let command = "export CLAUDE_CONFIG_DIR=\(shellQuote(profileDir.path))" + + " && cd \(shellQuote(work)) && exec claude" + let appleQuoted = command + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + let script = "tell application \"Terminal\"\n" + + "activate\n" + + "do script \"\(appleQuoted)\"\n" + + "end tell" + var error: NSDictionary? + if let appleScript = NSAppleScript(source: script) { + appleScript.executeAndReturnError(&error) + } + if error != nil { + throw LaunchError.failed("Could not open Terminal for Claude Code: \(error.debugDescription)") + } + } + + /// Launch Claude Desktop. Pass `userDataDir: nil` for the built-in + /// profile (no --user-data-dir), exactly like opening it normally. + /// Managed profiles pass the Chromium `--user-data-dir=` switch + /// (must use "=": Chromium doesn't take a space-separated value). Verified + /// on Windows with the standalone build; not yet verified on macOS. + static func launchDesktop(userDataDir: URL?, desktopPath: String?, + options: CursorLauncher.LaunchOptions) throws { + guard let exe = findClaudeDesktop(customPath: desktopPath) else { + throw LaunchError.desktopNotFound + } + var args: [String] = [] + if let userDataDir { + try FileManager.default.createDirectory(at: userDataDir, withIntermediateDirectories: true) + args = ["--user-data-dir=\(userDataDir.path)"] + } + if options.newWindow { args.append("--new-window") } + + let process = Process() + process.executableURL = URL(fileURLWithPath: exe) + process.arguments = args + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + do { + try process.run() + } catch { + throw LaunchError.failed("Failed to launch Claude Desktop: \(error.localizedDescription)") + } + } + + /// True when `command` runs the real Desktop main executable. Rejects the + /// bundled Claude Code helper (same process family, lives under a + /// claude-code path), non-main helpers, and — critically — the standalone + /// `claude` CLI, which shares the executable name on every OS but never + /// runs inside Claude.app. + static func isMainDesktopExecutable(_ command: String) -> Bool { + if command.localizedCaseInsensitiveContains("claude-code") { return false } + if command.contains("--type=") { return false } + let first = command.split(separator: " ", omittingEmptySubsequences: true).first.map(String.init) ?? "" + if (first as NSString).lastPathComponent.lowercased() == "claude-desktop" { return true } + if let range = command.range(of: "Claude.app/Contents/MacOS/Claude") { + let after = command[range.upperBound...] + return after.isEmpty || after.hasPrefix(" ") + } + return false + } + + /// PIDs of the Desktop main process for a managed profile directory, + /// detected by its `--user-data-dir ` argument. + static func desktopPIDs(in list: [CursorLauncher.ProcessEntry], profileDir: URL) -> [Int32] { + let needles = ["--user-data-dir=\(profileDir.path)", "--user-data-dir \(profileDir.path)"] + return list.filter { + $0.pid != ProcessInfo.processInfo.processIdentifier && + needles.contains(where: $0.command.contains) && + isMainDesktopExecutable($0.command) + }.map(\.pid) + } + + /// PIDs of the Desktop main process running the *built-in* profile — + /// a Desktop main process with no --user-data-dir at all. + static func systemDesktopPIDs(in list: [CursorLauncher.ProcessEntry]) -> [Int32] { + list.filter { + $0.pid != ProcessInfo.processInfo.processIdentifier && + isMainDesktopExecutable($0.command) && + !$0.command.contains("--user-data-dir") + }.map(\.pid) + } + + /// Electron holds a lockfile inside a live user-data-dir. The -1 sentinel + /// marks "running" without a matchable PID; it must never be signaled. + static func desktopLockHeld(profileDir: URL) -> Bool { + FileManager.default.fileExists( + atPath: profileDir.appendingPathComponent("lockfile").path) + } + + /// Politely ask processes to quit (SIGTERM). Skips sentinel PIDs — + /// signaling -1 would hit the whole process group. + static func quit(pids: [Int32]) { + for pid in pids where pid > 0 { kill(pid, SIGTERM) } + } +} diff --git a/Sources/CursorProfiles/ContentView.swift b/Sources/CursorProfiles/ContentView.swift index bed7f88..539d43c 100644 --- a/Sources/CursorProfiles/ContentView.swift +++ b/Sources/CursorProfiles/ContentView.swift @@ -7,6 +7,9 @@ enum ProfileFilter: String, CaseIterable, Identifiable { case all = "All" case active = "Active" case custom = "Custom" // non-built-in profiles (nothing here is sandboxed) + case cursor = "Cursor" + case code = "Code" + case desktop = "Desktop" var id: String { rawValue } } @@ -38,7 +41,9 @@ struct ContentView: View { if !search.isEmpty { list = list.filter { $0.displayName.localizedCaseInsensitiveContains(search) || - $0.folderName.localizedCaseInsensitiveContains(search) + $0.folderName.localizedCaseInsensitiveContains(search) || + $0.appLabel.localizedCaseInsensitiveContains(search) || + $0.normalizedApp.localizedCaseInsensitiveContains(search) } } @@ -47,6 +52,12 @@ struct ContentView: View { list = list.filter { store.isRunning($0) } case .custom: list = list.filter { !$0.isSystem } + case .cursor: + list = list.filter { $0.normalizedApp == CursorProfile.appCursor } + case .code: + list = list.filter { $0.normalizedApp == CursorProfile.appClaudeCode } + case .desktop: + list = list.filter { $0.normalizedApp == CursorProfile.appClaudeDesktop } case .all: break } @@ -287,9 +298,16 @@ struct ContentView: View { Button { filter = f } label: { - let count = f == .all ? store.profiles.count : - f == .active ? runningCount : - store.profiles.filter { !$0.isSystem }.count + let count: Int = { + switch f { + case .all: return store.profiles.count + case .active: return runningCount + case .custom: return store.profiles.filter { !$0.isSystem }.count + case .cursor: return store.profiles.filter { $0.normalizedApp == CursorProfile.appCursor }.count + case .code: return store.profiles.filter { $0.normalizedApp == CursorProfile.appClaudeCode }.count + case .desktop: return store.profiles.filter { $0.normalizedApp == CursorProfile.appClaudeDesktop }.count + } + }() Text("\(f.rawValue) (\(count))") .font(.system(size: 11, weight: filter == f ? .semibold : .regular)) .foregroundStyle(filter == f ? PrismTheme.Colors.textPrimary : PrismTheme.Colors.textTertiary) @@ -391,7 +409,7 @@ struct ContentView: View { .font(.system(size: 10, weight: .medium, design: .rounded)) .foregroundStyle(PrismTheme.Colors.textMuted) } - .help("Live resident memory of running Cursor processes / total physical RAM") + .help("Live resident memory of running profiles / total physical RAM") Spacer() @@ -428,7 +446,7 @@ struct ContentView: View { Text("No profiles yet") .font(.system(size: 18, weight: .semibold, design: .rounded)) .foregroundStyle(PrismTheme.Colors.textPrimary) - Text("Each profile is a fully isolated Cursor instance —\nits own settings, extensions, login and chat history.") + Text("Each profile is a fully isolated instance —\nits own settings, login and history, per app.") .font(.system(size: 12)) .multilineTextAlignment(.center) .foregroundStyle(PrismTheme.Colors.textTertiary) diff --git a/Sources/CursorProfiles/CursorLauncher.swift b/Sources/CursorProfiles/CursorLauncher.swift index 69b8473..457d993 100644 --- a/Sources/CursorProfiles/CursorLauncher.swift +++ b/Sources/CursorProfiles/CursorLauncher.swift @@ -157,7 +157,8 @@ enum CursorLauncher { } /// Politely ask a running profile's processes to quit (SIGTERM). + /// Skips sentinel PIDs — signaling -1 would hit the whole process group. static func quit(pids: [Int32]) { - for pid in pids { kill(pid, SIGTERM) } + for pid in pids where pid > 0 { kill(pid, SIGTERM) } } } diff --git a/Sources/CursorProfiles/CursorProfilesApp.swift b/Sources/CursorProfiles/CursorProfilesApp.swift index 05b378a..3b61a96 100644 --- a/Sources/CursorProfiles/CursorProfilesApp.swift +++ b/Sources/CursorProfiles/CursorProfilesApp.swift @@ -76,7 +76,9 @@ struct MenuBarDropdown: View { guard !searchText.isEmpty else { return sortedProfiles } return sortedProfiles.filter { $0.displayName.localizedCaseInsensitiveContains(searchText) || - $0.defaultProjectPath?.localizedCaseInsensitiveContains(searchText) == true + $0.defaultProjectPath?.localizedCaseInsensitiveContains(searchText) == true || + $0.appLabel.localizedCaseInsensitiveContains(searchText) || + $0.normalizedApp.localizedCaseInsensitiveContains(searchText) } } @@ -312,7 +314,7 @@ struct MenuBarProfileItem: View { } .frame(width: MenuMetrics.textColumnWidth, alignment: .leading) - if let pids = store.runningPIDs[profile.folderName], let pid = pids.first { + if let pids = store.runningPIDs[profile.folderName], let pid = pids.first(where: { $0 > 0 }) { Text("PID \(pid)") .font(.system(size: 9, design: .rounded)) .foregroundStyle(PrismTheme.Colors.textMuted) @@ -335,12 +337,18 @@ struct MenuBarProfileItem: View { .foregroundStyle(profile.accentColor) .lineLimit(1) .frame(width: MenuMetrics.textColumnWidth, alignment: .leading) - } else { + } else if profile.hasMemoryCap { Text("\(formatMB(profile.defaultMemoryMB)) limit") .font(.system(size: 9, design: .rounded)) .foregroundStyle(PrismTheme.Colors.textTertiary) .lineLimit(1) .frame(width: MenuMetrics.textColumnWidth, alignment: .leading) + } else { + Text(profile.appLabel) + .font(.system(size: 9, design: .rounded)) + .foregroundStyle(PrismTheme.Colors.textTertiary) + .lineLimit(1) + .frame(width: MenuMetrics.textColumnWidth, alignment: .leading) } } .frame(width: MenuMetrics.textColumnWidth, alignment: .leading) diff --git a/Sources/CursorProfiles/Models.swift b/Sources/CursorProfiles/Models.swift index 4e36231..fd0b851 100644 --- a/Sources/CursorProfiles/Models.swift +++ b/Sources/CursorProfiles/Models.swift @@ -7,9 +7,19 @@ struct CursorProfile: Identifiable, Codable, Equatable, Hashable { /// (~/Library/Application Support/Cursor). It is not a real folder in /// ~/.cursor_profiles and can never be deleted from this app. static let systemFolderName = "__cursor-default__" + /// Built-in Claude Code profile (the real ~/.claude). + static let systemCodeFolderName = "__claude-code-default__" + /// Built-in Claude Desktop profile (the real Electron userData dir). + static let systemDesktopFolderName = "__claude-desktop-default__" - var folderName: String // directory name inside ~/.cursor_profiles + /// App identifiers. Absent in files predating multi-app support (= cursor). + static let appCursor = "cursor" + static let appClaudeCode = "claude-code" + static let appClaudeDesktop = "claude-desktop" + + var folderName: String // directory name inside its app's profiles dir var displayName: String + var app: String = appCursor var colorHex: String var defaultMemoryMB: Int var defaultProjectPath: String? @@ -24,7 +34,7 @@ struct CursorProfile: Identifiable, Codable, Equatable, Hashable { // intentionally absent from CodingKeys — JSONDecoder skips unknown keys, // so old files still load and new files simply omit it. enum CodingKeys: String, CodingKey { - case folderName, displayName, colorHex, defaultMemoryMB, + case folderName, displayName, app, colorHex, defaultMemoryMB, defaultProjectPath, createdAt, lastLaunchedAt, isPinned, isSystem } @@ -32,6 +42,7 @@ struct CursorProfile: Identifiable, Codable, Equatable, Hashable { let c = try decoder.container(keyedBy: CodingKeys.self) folderName = try c.decode(String.self, forKey: .folderName) displayName = try c.decode(String.self, forKey: .displayName) + app = Self.normalizeApp(try c.decodeIfPresent(String.self, forKey: .app)) colorHex = try c.decode(String.self, forKey: .colorHex) defaultMemoryMB = try c.decode(Int.self, forKey: .defaultMemoryMB) defaultProjectPath = try c.decodeIfPresent(String.self, forKey: .defaultProjectPath) @@ -43,6 +54,7 @@ struct CursorProfile: Identifiable, Codable, Equatable, Hashable { init(folderName: String, displayName: String, + app: String = appCursor, colorHex: String = AccentPalette.random().hex, defaultMemoryMB: Int = 16384, defaultProjectPath: String? = nil, @@ -52,6 +64,7 @@ struct CursorProfile: Identifiable, Codable, Equatable, Hashable { isSystem: Bool = false) { self.folderName = folderName self.displayName = displayName + self.app = Self.normalizeApp(app) self.colorHex = colorHex self.defaultMemoryMB = defaultMemoryMB self.defaultProjectPath = defaultProjectPath @@ -61,6 +74,52 @@ struct CursorProfile: Identifiable, Codable, Equatable, Hashable { self.isSystem = isSystem } + /// Legacy/unknown app values are Cursor profiles. + static func normalizeApp(_ raw: String?) -> String { + guard let raw else { return appCursor } + return raw == appClaudeCode || raw == appClaudeDesktop ? raw : appCursor + } + + var normalizedApp: String { Self.normalizeApp(app) } + + var appLabel: String { + switch normalizedApp { + case Self.appClaudeCode: return "Claude Code" + case Self.appClaudeDesktop: return "Claude Desktop" + default: return "Cursor" + } + } + + /// Short badge for cards; empty for Cursor (the default). + var appBadge: String { + switch normalizedApp { + case Self.appClaudeCode: return "CODE" + case Self.appClaudeDesktop: return "DESKTOP" + default: return "" + } + } + + var showAppBadge: Bool { normalizedApp != Self.appCursor } + + /// Only Cursor launches with --max-memory; Claude apps have no cap. + var hasMemoryCap: Bool { normalizedApp == Self.appCursor } + + static func systemMarker(for app: String) -> String { + switch normalizeApp(app) { + case appClaudeCode: return systemCodeFolderName + case appClaudeDesktop: return systemDesktopFolderName + default: return systemFolderName + } + } + + static func systemDisplayName(for app: String) -> String { + switch normalizeApp(app) { + case appClaudeCode: return "Main Claude Code" + case appClaudeDesktop: return "Main Claude Desktop" + default: return "Main Cursor" + } + } + var accentColor: Color { Color(hex: colorHex) ?? .accentColor } /// Tile glyph: first letter of the display name, uppercased. diff --git a/Sources/CursorProfiles/ProfileCard.swift b/Sources/CursorProfiles/ProfileCard.swift index ea9ddde..353c3a0 100644 --- a/Sources/CursorProfiles/ProfileCard.swift +++ b/Sources/CursorProfiles/ProfileCard.swift @@ -13,9 +13,16 @@ struct ProfileCard: View { @State private var showingLaunchOptions = false private var isRunning: Bool { store.isRunning(profile) } - private var limitBytes: UInt64 { UInt64(profile.defaultMemoryMB) * 1024 * 1024 } + private var hasMemoryCap: Bool { profile.hasMemoryCap } + private var limitBytes: UInt64 { + hasMemoryCap ? UInt64(profile.defaultMemoryMB) * 1024 * 1024 : 0 + } private var liveBytes: UInt64 { store.liveMemory[profile.folderName] ?? 0 } private var windowCount: Int { store.windows(for: profile) } + /// First real PID for display; sentinels (Store builds) show none. + private var displayPID: Int32? { + store.runningPIDs[profile.folderName]?.first(where: { $0 > 0 }) + } /// Honest fill: live RSS against the configured --max-memory limit. private var usedMemoryFraction: CGFloat { guard limitBytes > 0, isRunning else { return 0 } @@ -124,6 +131,18 @@ struct ProfileCard: View { .clipShape(Capsule()) .overlay(Capsule().stroke(PrismTheme.Colors.cyan.opacity(0.3), lineWidth: 0.5)) } + + if profile.showAppBadge { + Text(profile.appBadge) + .font(.system(size: 8, weight: .bold, design: .rounded)) + .foregroundStyle(profile.accentColor) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background(profile.accentColor.opacity(0.15)) + .clipShape(Capsule()) + .overlay(Capsule().stroke(profile.accentColor.opacity(0.3), lineWidth: 0.5)) + .help(profile.appLabel) + } } HStack(spacing: 4) { @@ -134,8 +153,8 @@ struct ProfileCard: View { Text("Running") .font(.system(size: 10, weight: .medium, design: .rounded)) .foregroundStyle(PrismTheme.Colors.emerald) - if let pids = store.runningPIDs[profile.folderName], !pids.isEmpty { - Text("PID \(pids[0])") + if let pid = displayPID { + Text("PID \(pid)") .font(.system(size: 9, weight: .medium, design: .rounded)) .foregroundStyle(PrismTheme.Colors.textTertiary) } @@ -190,29 +209,41 @@ struct ProfileCard: View { } } - // Live memory (measured RSS) vs configured limit + // Live memory (measured RSS) vs configured limit. + // Claude apps have no --max-memory cap. HStack(spacing: 6) { Text("Memory") .font(.system(size: 9, weight: .medium)) .foregroundStyle(PrismTheme.Colors.textMuted) - if isRunning { + if isRunning, hasMemoryCap { Text(Format.bytes(Int64(liveBytes))) .font(.system(size: 10, weight: .semibold, design: .rounded)) .foregroundStyle(profile.accentColor) Text("/ \(formatMB(profile.defaultMemoryMB)) limit") .font(.system(size: 9, design: .rounded)) .foregroundStyle(PrismTheme.Colors.textMuted) - } else { + } else if isRunning { + Text(Format.bytes(Int64(liveBytes))) + .font(.system(size: 10, weight: .semibold, design: .rounded)) + .foregroundStyle(profile.accentColor) + Text("live") + .font(.system(size: 9, design: .rounded)) + .foregroundStyle(PrismTheme.Colors.textMuted) + } else if hasMemoryCap { Text("\(formatMB(profile.defaultMemoryMB)) limit") .font(.system(size: 10, weight: .semibold, design: .rounded)) .foregroundStyle(PrismTheme.Colors.textTertiary) + } else { + Text("no memory cap") + .font(.system(size: 10, weight: .semibold, design: .rounded)) + .foregroundStyle(PrismTheme.Colors.textTertiary) } Spacer() Text(windowLabel) .font(.system(size: 9, design: .rounded)) .foregroundStyle(PrismTheme.Colors.textTertiary) } - .help(isRunning ? "Live resident memory of this profile's Cursor processes" : "Configured --max-memory limit applied at launch") + .help(isRunning ? "Live resident memory of this profile's processes" : "Configured --max-memory limit applied at launch (Cursor only)") GeometryReader { geo in ZStack(alignment: .leading) { @@ -245,9 +276,9 @@ struct ProfileCard: View { store.launch(profile, newWindow: isRunning) } label: { HStack(spacing: 5) { - Image(systemName: isRunning ? "plus" : "play.fill") + Image(systemName: isRunning ? "plus" : (profile.normalizedApp == CursorProfile.appClaudeCode ? "terminal" : "play.fill")) .font(.system(size: 10, weight: .bold)) - Text(isRunning ? "New Window" : "Launch") + Text(isRunning ? "New Window" : (profile.normalizedApp == CursorProfile.appClaudeCode ? "Open Terminal" : "Launch")) .font(.system(size: 11, weight: .semibold)) } .foregroundStyle(PrismTheme.Colors.textPrimary) @@ -377,6 +408,9 @@ struct LaunchOptionsView: View { @State private var projectPath: String = "" @State private var newWindow = false + private var isCursorProfile: Bool { profile.normalizedApp == CursorProfile.appCursor } + private var isDesktopProfile: Bool { profile.normalizedApp == CursorProfile.appClaudeDesktop } + var body: some View { VStack(alignment: .leading, spacing: 14) { // Header @@ -394,7 +428,7 @@ struct LaunchOptionsView: View { Divider().overlay(PrismTheme.Colors.border) - // Memory + // Memory (Cursor only) VStack(alignment: .leading, spacing: 4) { Text("Memory limit") .font(.system(size: 10, weight: .medium)) @@ -410,13 +444,15 @@ struct LaunchOptionsView: View { .clipShape(RoundedRectangle(cornerRadius: 6)) .overlay(RoundedRectangle(cornerRadius: 6).stroke(PrismTheme.Colors.border, lineWidth: 0.5)) .frame(width: 80) + .disabled(!isCursorProfile) Text("MB") .font(.system(size: 10, design: .rounded)) .foregroundStyle(PrismTheme.Colors.textTertiary) } } + .opacity(isCursorProfile ? 1.0 : 0.45) - // Project + // Project (unused by Desktop) VStack(alignment: .leading, spacing: 4) { Text("Project folder") .font(.system(size: 10, weight: .medium)) @@ -431,6 +467,7 @@ struct LaunchOptionsView: View { .background(PrismTheme.Colors.bg) .clipShape(RoundedRectangle(cornerRadius: 6)) .overlay(RoundedRectangle(cornerRadius: 6).stroke(PrismTheme.Colors.border, lineWidth: 0.5)) + .disabled(isDesktopProfile) Button("…") { pickFolder() } .font(.system(size: 11, weight: .bold)) .frame(width: 28, height: 28) @@ -438,8 +475,10 @@ struct LaunchOptionsView: View { .clipShape(RoundedRectangle(cornerRadius: 6)) .overlay(RoundedRectangle(cornerRadius: 6).stroke(PrismTheme.Colors.border, lineWidth: 0.5)) .buttonStyle(.plain) + .disabled(isDesktopProfile) } } + .opacity(isDesktopProfile ? 0.45 : 1.0) Toggle(isOn: $newWindow) { Text("Force a new window") diff --git a/Sources/CursorProfiles/ProfileEditorSheet.swift b/Sources/CursorProfiles/ProfileEditorSheet.swift index 89def59..3ad7c6a 100644 --- a/Sources/CursorProfiles/ProfileEditorSheet.swift +++ b/Sources/CursorProfiles/ProfileEditorSheet.swift @@ -21,6 +21,7 @@ struct ProfileEditorSheet: View { var onClose: (() -> Void)? = nil @State private var name = "" + @State private var app: String = CursorProfile.appCursor @State private var colorHex = AccentPalette.all[0].hex @State private var memoryMB = 16384 @State private var projectPath = "" @@ -69,7 +70,7 @@ struct ProfileEditorSheet: View { Text(isEditing ? "Edit Profile" : "New Profile") .font(.system(size: 14, weight: .semibold)) .foregroundStyle(PrismTheme.Colors.textPrimary) - Text("Each profile is an isolated Cursor instance.") + Text("Each profile is an isolated instance — own settings, login and history.") .font(.system(size: 11)) .foregroundStyle(PrismTheme.Colors.textTertiary) } @@ -99,6 +100,15 @@ struct ProfileEditorSheet: View { return String(first).uppercased() } + private var previewSubtitle: String { + let project = projectPath.isEmpty ? "" : " · \((projectPath as NSString).abbreviatingWithTildeInPath)" + switch app { + case CursorProfile.appClaudeCode: return "Claude Code\(project)" + case CursorProfile.appClaudeDesktop: return "Claude Desktop" + default: return "\(formatGB(memoryMB)) limit\(project)" + } + } + private var previewStrip: some View { HStack(spacing: 12) { Text(previewInitial) @@ -114,7 +124,7 @@ struct ProfileEditorSheet: View { ? PrismTheme.Colors.textMuted : PrismTheme.Colors.textPrimary) .lineLimit(1) - Text("\(formatGB(memoryMB)) limit\(projectPath.isEmpty ? "" : " · \((projectPath as NSString).abbreviatingWithTildeInPath)")") + Text(previewSubtitle) .font(.system(size: 10, design: .rounded)) .foregroundStyle(PrismTheme.Colors.textTertiary) .lineLimit(1) @@ -151,6 +161,20 @@ struct ProfileEditorSheet: View { .font(.system(size: 12)) } + VStack(alignment: .leading, spacing: 4) { + Text("App") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(PrismTheme.Colors.textSecondary) + Picker("", selection: $app) { + Text("Cursor (editor)").tag(CursorProfile.appCursor) + Text("Claude Code (CLI)").tag(CursorProfile.appClaudeCode) + Text("Claude Desktop").tag(CursorProfile.appClaudeDesktop) + } + .pickerStyle(.segmented) + .disabled(isEditing) + .help(isEditing ? "The app is fixed after creation." : "Which app this profile belongs to.") + } + VStack(alignment: .leading, spacing: 6) { Text("Accent color") .font(.system(size: 11, weight: .medium)) @@ -182,7 +206,9 @@ struct ProfileEditorSheet: View { private var resourcesSection: some View { section(title: "Resources") { VStack(alignment: .leading, spacing: 8) { - Text("Memory limit — passed to Cursor as --max-memory.") + Text(app == CursorProfile.appCursor + ? "Memory limit — passed to Cursor as --max-memory." + : "Memory limit applies to Cursor only — ignored for this app.") .font(.system(size: 11)) .foregroundStyle(PrismTheme.Colors.textTertiary) @@ -196,12 +222,14 @@ struct ProfileEditorSheet: View { .font(.system(size: 11, design: .rounded)) .frame(width: 90) .multilineTextAlignment(.trailing) + .disabled(app != CursorProfile.appCursor) Text("MB") .font(.system(size: 11, design: .rounded)) .foregroundStyle(PrismTheme.Colors.textTertiary) } } } + .opacity(app == CursorProfile.appCursor ? 1.0 : 0.55) } private var projectSection: some View { @@ -256,7 +284,7 @@ struct ProfileEditorSheet: View { HStack { if case .edit(let profile) = mode { Text(profile.isSystem - ? "Built-in profile — ~/Library/Application Support/Cursor" + ? "Built-in profile — \(profile.appLabel) data" : "Folder: \(profile.folderName)") .font(.system(size: 10, design: .rounded)) .foregroundStyle(PrismTheme.Colors.textMuted) @@ -287,8 +315,10 @@ struct ProfileEditorSheet: View { case .create: memoryMB = store.defaultMemoryMB colorHex = AccentPalette.random().hex + app = CursorProfile.appCursor case .edit(let profile): name = profile.displayName + app = profile.normalizedApp colorHex = profile.colorHex memoryMB = profile.defaultMemoryMB projectPath = profile.defaultProjectPath ?? "" @@ -301,6 +331,7 @@ struct ProfileEditorSheet: View { case .create: let created = store.createProfile( displayName: trimmedName, + app: app, colorHex: colorHex, memoryMB: max(512, memoryMB), defaultProjectPath: trimmedProject.isEmpty ? nil : trimmedProject diff --git a/Sources/CursorProfiles/ProfileStore.swift b/Sources/CursorProfiles/ProfileStore.swift index 7657749..fa98993 100644 --- a/Sources/CursorProfiles/ProfileStore.swift +++ b/Sources/CursorProfiles/ProfileStore.swift @@ -30,9 +30,13 @@ final class ProfileStore: ObservableObject { var liveMemoryTotal: UInt64 { liveMemory.values.reduce(0, +) } @AppStorage("customCursorPath") var customCursorPath: String = "" + @AppStorage("customCodePath") var customCodePath: String = "" + @AppStorage("customDesktopPath") var customDesktopPath: String = "" @AppStorage("defaultMemoryMB") var defaultMemoryMB: Int = 16384 let profilesDir: URL + let codeProfilesDir: URL + let desktopProfilesDir: URL private let metadataName = ".profiles.json" private var pollTask: Task? @@ -40,21 +44,60 @@ final class ProfileStore: ObservableObject { CursorLauncher.findCursor(customPath: customCursorPath.isEmpty ? nil : customCursorPath) } - init(profilesDir: URL = URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent(".cursor_profiles")) { + var resolvedCodePath: String? { + ClaudeLauncher.findClaudeCode(customPath: customCodePath.isEmpty ? nil : customCodePath) + } + + var resolvedDesktopPath: String? { + ClaudeLauncher.findClaudeDesktop(customPath: customDesktopPath.isEmpty ? nil : customDesktopPath) + } + + init(profilesDir: URL = URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent(".cursor_profiles"), + codeProfilesDir: URL? = nil, + desktopProfilesDir: URL? = nil) { self.profilesDir = profilesDir + let home = URL(fileURLWithPath: NSHomeDirectory()) + self.codeProfilesDir = codeProfilesDir ?? home.appendingPathComponent(".claude_code_profiles") + self.desktopProfilesDir = desktopProfilesDir ?? home.appendingPathComponent(".claude_desktop_profiles") reload() startPolling() } + func dirForApp(_ app: String) -> URL { + switch CursorProfile.normalizeApp(app) { + case CursorProfile.appClaudeCode: return codeProfilesDir + case CursorProfile.appClaudeDesktop: return desktopProfilesDir + default: return profilesDir + } + } + /// The built-in Cursor data directory — where Cursor keeps everything when /// launched normally. This is the profile people already have. static var systemDataDir: URL { - FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] - .appendingPathComponent("Cursor") + systemDataDir(for: CursorProfile.appCursor) + } + + /// Real (built-in) data location per app. + static func systemDataDir(for app: String) -> URL { + switch CursorProfile.normalizeApp(app) { + case CursorProfile.appClaudeCode: + if let override = ProcessInfo.processInfo.environment["CLAUDE_CONFIG_DIR"], + !override.isEmpty { + return URL(fileURLWithPath: override) + } + return URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent(".claude") + case CursorProfile.appClaudeDesktop: + return FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("Claude") + default: + return FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("Cursor") + } } func directory(for profile: CursorProfile) -> URL { - profile.isSystem ? Self.systemDataDir : profilesDir.appendingPathComponent(profile.folderName) + let app = profile.normalizedApp + return profile.isSystem ? Self.systemDataDir(for: app) : dirForApp(app).appendingPathComponent(profile.folderName) } func isRunning(_ profile: CursorProfile) -> Bool { @@ -64,13 +107,30 @@ final class ProfileStore: ObservableObject { // MARK: Loading & persistence func reload() { - try? FileManager.default.createDirectory(at: profilesDir, withIntermediateDirectories: true) + var all: [CursorProfile] = [] + for app in [CursorProfile.appCursor, CursorProfile.appClaudeCode, CursorProfile.appClaudeDesktop] { + all.append(contentsOf: reloadApp(app)) + } + profiles = all + saveMetadata() + refreshSizes() + refreshRunning() + } - var known = loadMetadata() + private func reloadApp(_ app: String) -> [CursorProfile] { + let dir = dirForApp(app) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + + var known = loadMetadata(dir: dir) + + // Foreign built-ins (from a copied metadata file) belong to their own + // app; hand-created folders are adopted into this app. + known.removeAll { $0.isSystem && $0.folderName != CursorProfile.systemMarker(for: app) } + known = known.map { p in var q = p; q.app = app; return q } // Adopt any directories created by hand. let onDisk = (try? FileManager.default.contentsOfDirectory( - at: profilesDir, includingPropertiesForKeys: [.isDirectoryKey], + at: dir, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles])) ?? [] let dirNames = onDisk .filter { (try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true } @@ -79,42 +139,42 @@ final class ProfileStore: ObservableObject { for name in dirNames where !known.contains(where: { $0.folderName == name }) { var display = name if display.hasPrefix("custom_") { display.removeFirst("custom_".count) } - known.append(CursorProfile(folderName: name, displayName: display)) + known.append(CursorProfile(folderName: name, displayName: display, app: app)) } // Drop metadata for folders that no longer exist (never the built-in one). known.removeAll { !$0.isSystem && !dirNames.contains($0.folderName) } - // Surface the user's original Cursor profile so it's visible and - // launchable — and clearly protected — rather than silently ignored. + // Surface the original app data so it's visible and launchable — and + // clearly protected — rather than silently ignored. if !known.contains(where: { $0.isSystem }), - FileManager.default.fileExists(atPath: Self.systemDataDir.path) { + FileManager.default.fileExists(atPath: Self.systemDataDir(for: app).path) { known.insert(CursorProfile( - folderName: CursorProfile.systemFolderName, - displayName: "Main Cursor", + folderName: CursorProfile.systemMarker(for: app), + displayName: CursorProfile.systemDisplayName(for: app), + app: app, colorHex: "#3B82F6", isPinned: true, isSystem: true ), at: 0) } - profiles = known - saveMetadata() - refreshSizes() - refreshRunning() + return known } - private func loadMetadata() -> [CursorProfile] { - let url = profilesDir.appendingPathComponent(metadataName) + private func loadMetadata(dir: URL) -> [CursorProfile] { + let url = dir.appendingPathComponent(metadataName) guard let data = try? Data(contentsOf: url) else { return [] } return (try? JSONDecoder().decode([CursorProfile].self, from: data)) ?? [] } private func saveMetadata() { - let url = profilesDir.appendingPathComponent(metadataName) - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - if let data = try? encoder.encode(profiles) { - try? data.write(to: url, options: .atomic) + for app in [CursorProfile.appCursor, CursorProfile.appClaudeCode, CursorProfile.appClaudeDesktop] { + let url = dirForApp(app).appendingPathComponent(metadataName) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + if let data = try? encoder.encode(profiles.filter { $0.normalizedApp == app }) { + try? data.write(to: url, options: .atomic) + } } } @@ -122,9 +182,11 @@ final class ProfileStore: ObservableObject { @discardableResult func createProfile(displayName: String, + app: String = CursorProfile.appCursor, colorHex: String, memoryMB: Int, defaultProjectPath: String?) -> CursorProfile? { + let normalized = CursorProfile.normalizeApp(app) let base = ProfileNaming.sanitizeFolderName(displayName.replacingOccurrences(of: " ", with: "_")) guard !base.isEmpty else { lastError = "Profile name must contain at least one letter, number, hyphen or underscore." @@ -132,8 +194,10 @@ final class ProfileStore: ObservableObject { } var folder = base var counter = 2 + let reserved = [CursorProfile.systemFolderName, CursorProfile.systemCodeFolderName, + CursorProfile.systemDesktopFolderName] while profiles.contains(where: { $0.folderName.caseInsensitiveCompare(folder) == .orderedSame }) - || folder == CursorProfile.systemFolderName { + || reserved.contains(folder) { folder = "\(base)-\(counter)" counter += 1 } @@ -141,23 +205,38 @@ final class ProfileStore: ObservableObject { let profile = CursorProfile( folderName: folder, displayName: displayName.trimmingCharacters(in: .whitespaces), + app: normalized, colorHex: colorHex, defaultMemoryMB: memoryMB, defaultProjectPath: defaultProjectPath ) + let dir = directory(for: profile) do { - try FileManager.default.createDirectory(at: directory(for: profile), withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) } catch { lastError = "Could not create profile folder: \(error.localizedDescription)" return nil } + if normalized == CursorProfile.appClaudeDesktop { + Self.seedDesktopConfig(systemDir: Self.systemDataDir(for: normalized), profileDir: dir) + } profiles.append(profile) saveMetadata() refreshSizes() - TitleBarColorizer.apply(profile: profile, profileDir: directory(for: profile)) + TitleBarColorizer.apply(profile: profile, profileDir: dir) return profile } + /// Copy just claude_desktop_config.json (MCP/connectors) into a fresh + /// Desktop profile — never gigabytes of Electron cache. + static func seedDesktopConfig(systemDir: URL, profileDir: URL) { + let src = systemDir.appendingPathComponent("claude_desktop_config.json") + let dst = profileDir.appendingPathComponent("claude_desktop_config.json") + guard FileManager.default.fileExists(atPath: src.path), + !FileManager.default.fileExists(atPath: dst.path) else { return } + try? FileManager.default.copyItem(at: src, to: dst) + } + func update(_ profile: CursorProfile) { guard let idx = profiles.firstIndex(where: { $0.id == profile.id }) else { return } let colorChanged = profiles[idx].colorHex != profile.colorHex @@ -169,11 +248,14 @@ final class ProfileStore: ObservableObject { } /// Duplicate a profile. For the built-in profile this *clones* the original - /// Cursor data into a new managed profile — the original is only read, - /// never touched. Runs in the background since data dirs can be large. + /// app data into a new managed profile — the original is only read, + /// never touched. Desktop duplicates are config-only (full Electron dirs + /// are gigabytes of cache). Runs in the background since data dirs can + /// be large. func duplicate(_ profile: CursorProfile) { + let app = profile.normalizedApp let base = profile.isSystem - ? "Main-Cursor-Clone" + ? CursorProfile.systemDisplayName(for: app).replacingOccurrences(of: " ", with: "-") : profile.folderName + "-copy" var folder = base var counter = 2 @@ -182,11 +264,14 @@ final class ProfileStore: ObservableObject { counter += 1 } let source = directory(for: profile) - let dest = profilesDir.appendingPathComponent(folder) + let dest = dirForApp(app).appendingPathComponent(folder) var copy = profile copy.folderName = folder - copy.displayName = profile.isSystem ? "Main Cursor Clone" : profile.displayName + " Copy" + copy.app = app + copy.displayName = profile.isSystem + ? CursorProfile.systemDisplayName(for: app) + " Clone" + : profile.displayName + " Copy" copy.createdAt = Date() copy.lastLaunchedAt = nil copy.isPinned = false @@ -199,15 +284,35 @@ final class ProfileStore: ObservableObject { let sourceURL = source let destURL = dest let finishedCopy = copy + let desktopOnly = app == CursorProfile.appClaudeDesktop Task { [weak self] in let copyErrorMessage = await Task.detached(priority: .userInitiated) { - Self.copyProfileData(source: sourceURL, dest: destURL) + if desktopOnly { + Self.copyDesktopConfig(source: sourceURL, dest: destURL) + } else { + Self.copyProfileData(source: sourceURL, dest: destURL) + } }.value self?.finishDuplicate(folderName: folderName, copy: finishedCopy, dest: destURL, errorMessage: copyErrorMessage) } } + /// Config-only Desktop copy: fresh dir + MCP/connectors config. + private static nonisolated func copyDesktopConfig(source: URL, dest: URL) -> String? { + do { + try FileManager.default.createDirectory(at: dest, withIntermediateDirectories: true) + let src = source.appendingPathComponent("claude_desktop_config.json") + if FileManager.default.fileExists(atPath: src.path) { + try FileManager.default.copyItem( + at: src, to: dest.appendingPathComponent("claude_desktop_config.json")) + } + return nil + } catch { + return error.localizedDescription + } + } + /// Pure file copy: Sendable in/out, never touches the store. private static nonisolated func copyProfileData(source: URL, dest: URL) -> String? { do { @@ -235,7 +340,7 @@ final class ProfileStore: ObservableObject { /// Moves the profile folder to the Trash (recoverable, unlike the script's rm -rf). func delete(_ profile: CursorProfile) { if profile.isSystem { - lastError = "The built-in Cursor profile can't be deleted from here — it's your original Cursor data." + lastError = "The built-in profile can't be deleted from here — it's your original \(profile.appLabel) data." return } if isRunning(profile) { @@ -266,19 +371,40 @@ final class ProfileStore: ObservableObject { // MARK: Launch / quit func launch(_ profile: CursorProfile, projectPath: String? = nil, memoryMB: Int? = nil, newWindow: Bool = false) { - let options = CursorLauncher.LaunchOptions( - memoryMB: memoryMB ?? profile.defaultMemoryMB, - projectPath: projectPath ?? profile.defaultProjectPath, - newWindow: newWindow - ) - // Idempotent — also covers profiles adopted from disk that never went through createProfile. - TitleBarColorizer.apply(profile: profile, profileDir: directory(for: profile)) do { - try CursorLauncher.launch( - userDataDir: profile.isSystem ? nil : directory(for: profile), - cursorPath: customCursorPath.isEmpty ? nil : customCursorPath, - options: options - ) + switch profile.normalizedApp { + case CursorProfile.appClaudeCode: + try ClaudeLauncher.launchCode( + profileDir: directory(for: profile), + displayName: profile.displayName, + codePath: customCodePath.isEmpty ? nil : customCodePath, + projectPath: projectPath ?? profile.defaultProjectPath, + isSystem: profile.isSystem + ) + case CursorProfile.appClaudeDesktop: + try ClaudeLauncher.launchDesktop( + userDataDir: profile.isSystem ? nil : directory(for: profile), + desktopPath: customDesktopPath.isEmpty ? nil : customDesktopPath, + options: CursorLauncher.LaunchOptions( + memoryMB: profile.defaultMemoryMB, + projectPath: nil, + newWindow: newWindow + ) + ) + default: + let options = CursorLauncher.LaunchOptions( + memoryMB: memoryMB ?? profile.defaultMemoryMB, + projectPath: projectPath ?? profile.defaultProjectPath, + newWindow: newWindow + ) + // Idempotent — also covers profiles adopted from disk that never went through createProfile. + TitleBarColorizer.apply(profile: profile, profileDir: directory(for: profile)) + try CursorLauncher.launch( + userDataDir: profile.isSystem ? nil : directory(for: profile), + cursorPath: customCursorPath.isEmpty ? nil : customCursorPath, + options: options + ) + } var p = profile p.lastLaunchedAt = Date() update(p) @@ -292,7 +418,12 @@ final class ProfileStore: ObservableObject { } func quit(_ profile: CursorProfile) { - CursorLauncher.quit(pids: runningPIDs[profile.folderName] ?? []) + let pids = runningPIDs[profile.folderName] ?? [] + if profile.normalizedApp == CursorProfile.appClaudeDesktop { + ClaudeLauncher.quit(pids: pids) + } else { + CursorLauncher.quit(pids: pids) + } Task { [weak self] in try? await Task.sleep(for: .seconds(1.5)) self?.refreshRunning() @@ -301,7 +432,12 @@ final class ProfileStore: ObservableObject { func quitAll() { for profile in profiles where isRunning(profile) { - CursorLauncher.quit(pids: runningPIDs[profile.folderName] ?? []) + let pids = runningPIDs[profile.folderName] ?? [] + if profile.normalizedApp == CursorProfile.appClaudeDesktop { + ClaudeLauncher.quit(pids: pids) + } else { + CursorLauncher.quit(pids: pids) + } } Task { [weak self] in try? await Task.sleep(for: .seconds(1.5)) @@ -342,7 +478,10 @@ final class ProfileStore: ObservableObject { } func refreshRunning() { - let items = profiles.map { ($0.folderName, directory(for: $0), $0.isSystem) } + let items = profiles.map { + (folderName: $0.folderName, dir: directory(for: $0), + isSystem: $0.isSystem, app: $0.normalizedApp) + } Task { [weak self] in let snapshot = await Task.detached(priority: .utility) { Self.computeRunningSnapshot(items: items) @@ -353,7 +492,7 @@ final class ProfileStore: ObservableObject { /// Pure computation: Sendable in, Sendable out, never touches the store. private static nonisolated func computeRunningSnapshot( - items: [(folderName: String, dir: URL, isSystem: Bool)] + items: [(folderName: String, dir: URL, isSystem: Bool, app: String)] ) -> RunningSnapshot { let processes = CursorLauncher.processList() let statsByPid = Dictionary(uniqueKeysWithValues: processes.map { @@ -361,20 +500,51 @@ final class ProfileStore: ObservableObject { }) var snapshot = RunningSnapshot() var allPIDs: [Int32] = [] + var anyDesktopAlive = false + for item in items where item.app == CursorProfile.appClaudeDesktop { + // Cheap pre-scan so the lockfile fallback below knows Desktop + // is actually on screen (avoids stale-lock false positives). + anyDesktopAlive = true + break + } + if anyDesktopAlive { + anyDesktopAlive = !processes.filter { + ClaudeLauncher.isMainDesktopExecutable($0.command) && + $0.pid != ProcessInfo.processInfo.processIdentifier + }.isEmpty + } for item in items { - let matched = item.isSystem - ? CursorLauncher.systemProfilePIDs(in: processes) - : CursorLauncher.pids(in: processes, profileDir: item.dir) + var matched: [Int32] + switch item.app { + case CursorProfile.appClaudeCode: + // The CLI is a terminal session, not a trackable GUI app. + matched = [] + case CursorProfile.appClaudeDesktop: + if item.isSystem { + matched = ClaudeLauncher.systemDesktopPIDs(in: processes) + } else { + matched = ClaudeLauncher.desktopPIDs(in: processes, profileDir: item.dir) + } + if matched.isEmpty && anyDesktopAlive + && ClaudeLauncher.desktopLockHeld(profileDir: item.dir) { + // -1 is a sentinel, never signaled (see quit). + matched = [-1] + } + default: + matched = item.isSystem + ? CursorLauncher.systemProfilePIDs(in: processes) + : CursorLauncher.pids(in: processes, profileDir: item.dir) + } snapshot.pids[item.folderName] = matched var rss: Int64 = 0 var cpu = 0.0 - for pid in matched { + for pid in matched where pid > 0 { rss += statsByPid[pid]?.rssKB ?? 0 cpu += statsByPid[pid]?.cpu ?? 0 } snapshot.memory[item.folderName] = UInt64(max(0, rss)) * 1024 snapshot.cpu[item.folderName] = cpu - allPIDs.append(contentsOf: matched) + allPIDs.append(contentsOf: matched.filter { $0 > 0 }) } let winsByPid = SystemStats.windowCounts(for: allPIDs) for (name, pids) in snapshot.pids { diff --git a/Sources/CursorProfiles/SettingsView.swift b/Sources/CursorProfiles/SettingsView.swift index 3bc1c29..55db249 100644 --- a/Sources/CursorProfiles/SettingsView.swift +++ b/Sources/CursorProfiles/SettingsView.swift @@ -23,6 +23,40 @@ struct SettingsView: View { } } + Section("Claude Code (CLI)") { + LabeledContent("Detected path") { + Text(store.resolvedCodePath ?? "Not found") + .foregroundStyle(store.resolvedCodePath == nil ? PrismTheme.Colors.red : PrismTheme.Colors.textSecondary) + .textSelection(.enabled) + .lineLimit(1) + .truncationMode(.middle) + } + LabeledContent("Custom path") { + HStack(spacing: 6) { + TextField("Leave empty for auto-detect", text: store.binding(\.customCodePath)) + .textFieldStyle(.roundedBorder) + Button("Browse...") { pickExecutable(kind: .code) } + } + } + } + + Section("Claude Desktop (app)") { + LabeledContent("Detected path") { + Text(store.resolvedDesktopPath ?? "Not found") + .foregroundStyle(store.resolvedDesktopPath == nil ? PrismTheme.Colors.red : PrismTheme.Colors.textSecondary) + .textSelection(.enabled) + .lineLimit(1) + .truncationMode(.middle) + } + LabeledContent("Custom path") { + HStack(spacing: 6) { + TextField("Leave empty for auto-detect", text: store.binding(\.customDesktopPath)) + .textFieldStyle(.roundedBorder) + Button("Browse...") { pickExecutable(kind: .desktop) } + } + } + } + Section("New profile defaults") { LabeledContent("Memory limit") { HStack(spacing: 6) { @@ -53,7 +87,15 @@ struct SettingsView: View { .fixedSize(horizontal: false, vertical: true) } + private enum ExeKind { + case cursor, code, desktop + } + private func pickExecutable() { + pickExecutable(kind: .cursor) + } + + private func pickExecutable(kind: ExeKind) { let panel = NSOpenPanel() panel.canChooseFiles = true panel.canChooseDirectories = false @@ -64,9 +106,20 @@ struct SettingsView: View { if panel.runModal() == .OK, let url = panel.url { var path = url.path if path.hasSuffix(".app") { - path += "/Contents/MacOS/Cursor" + let exeName: String = { + switch kind { + case .cursor: return "Cursor" + case .code: return "claude" + case .desktop: return "Claude" + } + }() + path += "/Contents/MacOS/\(exeName)" + } + switch kind { + case .cursor: store.customCursorPath = path + case .code: store.customCodePath = path + case .desktop: store.customDesktopPath = path } - store.customCursorPath = path } } } diff --git a/Sources/CursorProfiles/SpotlightCommandBar.swift b/Sources/CursorProfiles/SpotlightCommandBar.swift index aa97f37..4c7915a 100644 --- a/Sources/CursorProfiles/SpotlightCommandBar.swift +++ b/Sources/CursorProfiles/SpotlightCommandBar.swift @@ -16,7 +16,9 @@ struct SpotlightCommandBar: View { guard !searchText.isEmpty else { return profiles } return profiles.filter { $0.displayName.localizedCaseInsensitiveContains(searchText) || - $0.folderName.localizedCaseInsensitiveContains(searchText) + $0.folderName.localizedCaseInsensitiveContains(searchText) || + $0.appLabel.localizedCaseInsensitiveContains(searchText) || + $0.normalizedApp.localizedCaseInsensitiveContains(searchText) } } @@ -334,7 +336,7 @@ struct SpotlightCommandBar: View { Text(firstRunning.displayName) .font(.system(size: 10, weight: .semibold, design: .rounded)) .foregroundStyle(PrismTheme.Colors.textSecondary) - if let pids = store.runningPIDs[firstRunning.folderName], let pid = pids.first { + if let pids = store.runningPIDs[firstRunning.folderName], let pid = pids.first(where: { $0 > 0 }) { Text("PID \(pid)") .font(.system(size: 9, weight: .medium, design: .rounded)) .foregroundStyle(PrismTheme.Colors.textMuted) @@ -464,7 +466,19 @@ struct SpotlightItem: View { .overlay(Capsule().stroke(PrismTheme.Colors.cyan.opacity(0.3), lineWidth: 0.5)) } - if isRunning, let pids = store.runningPIDs[profile.folderName], let pid = pids.first { + if profile.showAppBadge { + Text(profile.appBadge) + .font(.system(size: 8, weight: .bold, design: .rounded)) + .foregroundStyle(profile.accentColor) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background(profile.accentColor.opacity(0.15)) + .clipShape(Capsule()) + .overlay(Capsule().stroke(profile.accentColor.opacity(0.3), lineWidth: 0.5)) + .help(profile.appLabel) + } + + if isRunning, let pids = store.runningPIDs[profile.folderName], let pid = pids.first(where: { $0 > 0 }) { Text("PID \(pid)") .font(.system(size: 9, weight: .medium, design: .rounded)) .foregroundStyle(PrismTheme.Colors.textTertiary) @@ -488,10 +502,14 @@ struct SpotlightItem: View { Text("\(Format.bytes(Int64(live))) live") .font(.system(size: 10, weight: .semibold, design: .rounded)) .foregroundStyle(profile.accentColor) - } else { + } else if profile.hasMemoryCap { Text("\(formatMB(profile.defaultMemoryMB)) limit") .font(.system(size: 10, weight: .semibold, design: .rounded)) .foregroundStyle(PrismTheme.Colors.textTertiary) + } else { + Text(profile.appLabel) + .font(.system(size: 10, weight: .semibold, design: .rounded)) + .foregroundStyle(PrismTheme.Colors.textTertiary) } } } diff --git a/Sources/CursorProfiles/TitleBarColorizer.swift b/Sources/CursorProfiles/TitleBarColorizer.swift index e1fb5ec..7aa1d01 100644 --- a/Sources/CursorProfiles/TitleBarColorizer.swift +++ b/Sources/CursorProfiles/TitleBarColorizer.swift @@ -14,9 +14,10 @@ enum TitleBarColorizer { /// Merge this profile's title-bar colors into its `User/settings.json`, /// preserving every other setting already there. No-op for the built-in - /// profile — we never touch the user's real Cursor settings. + /// profile — we never touch the user's real Cursor settings — and for + /// non-Cursor apps, whose settings live elsewhere. static func apply(profile: CursorProfile, profileDir: URL) { - guard !profile.isSystem else { return } + guard !profile.isSystem, profile.normalizedApp == CursorProfile.appCursor else { return } let settingsURL = profileDir.appendingPathComponent("User/settings.json") do { diff --git a/linux/README.md b/linux/README.md index a04574b..2d4e44b 100644 --- a/linux/README.md +++ b/linux/README.md @@ -1,9 +1,11 @@ # Prism — Linux (GTK4 + libadwaita) Native GNOME-styled app, feature-identical to the macOS and Windows versions. -Profiles live in `~/.cursor_profiles`, metadata in `~/.cursor_profiles/.profiles.json` -(same format on all three platforms). Your original Cursor profile (`~/.config/Cursor`) -appears as a protected **Main Cursor** card — launchable and clonable, never deletable. +Cursor profiles live in `~/.cursor_profiles`, Claude Code profiles in +`~/.claude_code_profiles`, Claude Desktop profiles in +`~/.claude_desktop_profiles` (same `.profiles.json` format plus an `"app"` +field on all three platforms). Each app's original data appears as a protected +built-in card — launchable and clonable, never deletable. ## Requirements @@ -42,6 +44,16 @@ Checks `cursor` on PATH, `/usr/bin`, `/usr/local/bin`, `/opt/cursor`, `~/.local/bin`, plus `Cursor*.AppImage` in `~/Applications`, `~/Downloads` and `~/.local/bin`. A custom path can be set in Preferences. +Claude Code is found as `claude` on PATH / `/usr/bin` / `/usr/local/bin` / +`~/.local/bin`; Claude Desktop as `claude-desktop` in the same places plus +`/opt/Claude`. Custom paths for both live in Preferences too. + +Claude Code profiles launch in a real terminal with `CLAUDE_CONFIG_DIR` set +(the official multi-account mechanism — first launch walks through login for +that account). Claude Desktop profiles launch with `--user-data-dir=`, +so each gets its own login and several can run at once. (Verified on Windows +with the standalone build; on Linux this is untested.) + ## Pinning a profile to the dash/dock The Pin button on a card (or "Create Dash Launcher…" in its menu) writes a diff --git a/linux/prism.py b/linux/prism.py index dc39d6f..315ecda 100644 --- a/linux/prism.py +++ b/linux/prism.py @@ -1,10 +1,11 @@ #!/usr/bin/env python3 -"""Prism — a native GTK4/libadwaita launcher for isolated Cursor profiles. +"""Prism — a native GTK4/libadwaita launcher for isolated profiles. -Profiles live in ~/.cursor_profiles (shared with the macOS/Windows apps); -metadata is shared via ~/.cursor_profiles/.profiles.json. -The user's original Cursor profile (~/.config/Cursor) is surfaced as a protected -"Main Cursor" card — launchable and clonable, never deletable. +Cursor profiles live in ~/.cursor_profiles, Claude Code profiles in +~/.claude_code_profiles and Claude Desktop profiles in +~/.claude_desktop_profiles (shared with the macOS/Windows/TUI apps). +Each app's original data is surfaced as a protected built-in card — +launchable and clonable, never deletable. """ import json @@ -33,10 +34,58 @@ APP_ID = "com.mojolayers.Prism" PROFILES_DIR = Path.home() / ".cursor_profiles" +CODE_PROFILES_DIR = Path.home() / ".claude_code_profiles" +DESKTOP_PROFILES_DIR = Path.home() / ".claude_desktop_profiles" METADATA_PATH = PROFILES_DIR / ".profiles.json" SETTINGS_PATH = PROFILES_DIR / ".launcher-settings.json" SYSTEM_DATA_DIR = Path(GLib.get_user_config_dir()) / "Cursor" SYSTEM_FOLDER_NAME = "__cursor-default__" +SYSTEM_CODE_FOLDER_NAME = "__claude-code-default__" +SYSTEM_DESKTOP_FOLDER_NAME = "__claude-desktop-default__" + +# App identifiers. Absent in files predating multi-app support (= cursor). +APP_CURSOR = "cursor" +APP_CODE = "claude-code" +APP_DESKTOP = "claude-desktop" + +CODE_SYSTEM_DATA_DIR = Path.home() / ".claude" +DESKTOP_SYSTEM_DATA_DIR = Path(GLib.get_user_config_dir()) / "Claude" + +APPS = (APP_CURSOR, APP_CODE, APP_DESKTOP) + + +def normalize_app(app): + return app if app in APPS else APP_CURSOR + + +def app_label(app): + return {APP_CODE: "Claude Code", APP_DESKTOP: "Claude Desktop"}.get(app, "Cursor") + + +def system_marker_for(app): + return {APP_CODE: SYSTEM_CODE_FOLDER_NAME, + APP_DESKTOP: SYSTEM_DESKTOP_FOLDER_NAME}.get(app, SYSTEM_FOLDER_NAME) + + +def system_display_name_for(app): + return {APP_CODE: "Main Claude Code", + APP_DESKTOP: "Main Claude Desktop"}.get(app, "Main Cursor") + + +def profiles_dir_for(app): + return {APP_CODE: CODE_PROFILES_DIR, + APP_DESKTOP: DESKTOP_PROFILES_DIR}.get(app, PROFILES_DIR) + + +def system_data_dir_for(app): + if app == APP_CODE: + override = os.environ.get("CLAUDE_CONFIG_DIR") + if override: + return Path(override) + return CODE_SYSTEM_DATA_DIR + if app == APP_DESKTOP: + return DESKTOP_SYSTEM_DATA_DIR + return SYSTEM_DATA_DIR LAUNCHERS_DIR = Path(GLib.get_user_data_dir()) / "applications" LAUNCHER_ICONS_DIR = Path(GLib.get_user_data_dir()) / "icons" / "hicolor" / "256x256" / "apps" @@ -105,7 +154,9 @@ class Profile: displayName: str # NOTE: older .profiles.json files may contain an "emoji" key. It has no # corresponding field; .get() lookups below simply ignore it, so old - # files still load and new files omit it. + # files still load and new files omit it. Likewise a missing "app" means + # a Cursor profile (predates multi-app support). + app: str = APP_CURSOR colorHex: str = "#6366F1" defaultMemoryMB: int = 16384 defaultProjectPath: str | None = None @@ -124,6 +175,7 @@ def to_json(self): return { "folderName": self.folderName, "displayName": self.displayName, + "app": normalize_app(self.app), "colorHex": self.colorHex, "defaultMemoryMB": self.defaultMemoryMB, "defaultProjectPath": self.defaultProjectPath, @@ -138,6 +190,7 @@ def from_json(cls, data): return cls( folderName=data["folderName"], displayName=data["displayName"], + app=normalize_app(data.get("app", APP_CURSOR)), colorHex=data.get("colorHex", "#6366F1"), defaultMemoryMB=data.get("defaultMemoryMB", 16384), defaultProjectPath=data.get("defaultProjectPath"), @@ -160,7 +213,7 @@ def apply(profile: "Profile", profile_dir: Path): """Merge this profile's title-bar colors into its User/settings.json, preserving every other setting already there. No-op for the built-in profile — the user's real settings are never touched.""" - if profile.isSystem: + if profile.isSystem or normalize_app(profile.app) != APP_CURSOR: return user_dir = profile_dir / "User" @@ -259,20 +312,30 @@ class LauncherBuilder: "the launcher shortcut itself will still work without a custom icon." ) + @staticmethod + def slug(profile: "Profile") -> str: + # Folder names are globally unique across apps, so one slug works + # everywhere; the app prefix keeps launchers distinguishable. + prefix = {APP_CODE: "claude-code", APP_DESKTOP: "claude-desktop"}.get( + normalize_app(profile.app), "cursor") + return f"{prefix}-profile-{profile.folderName}" + @staticmethod def desktop_path(profile: "Profile") -> Path: - return LAUNCHERS_DIR / f"cursor-profile-{profile.folderName}.desktop" + return LAUNCHERS_DIR / f"{LauncherBuilder.slug(profile)}.desktop" @staticmethod def icon_path(profile: "Profile") -> Path: - return LAUNCHER_ICONS_DIR / f"cursor-profile-{profile.folderName}.png" + return LAUNCHER_ICONS_DIR / f"{LauncherBuilder.slug(profile)}.png" @classmethod def exists(cls, profile: "Profile") -> bool: return cls.desktop_path(profile).exists() @classmethod - def create_or_update(cls, profile: "Profile", profile_dir: Path, cursor_path: str | None): + def create_or_update(cls, profile: "Profile", profile_dir: Path, + cursor_path: str | None, code_path: str | None = None, + desktop_path: str | None = None): LAUNCHERS_DIR.mkdir(parents=True, exist_ok=True) LAUNCHER_ICONS_DIR.mkdir(parents=True, exist_ok=True) @@ -282,27 +345,68 @@ def create_or_update(cls, profile: "Profile", profile_dir: Path, cursor_path: st else: icon_value = "utilities-terminal" # themed fallback, always resolvable - exe = cursor_path or "cursor" - exec_parts = [_desktop_quote(exe)] - if not profile.isSystem: - exec_parts += ["--user-data-dir", _desktop_quote(str(profile_dir))] - exec_parts.append(f"--max-memory={profile.defaultMemoryMB}") - if profile.defaultProjectPath: - exec_parts.append(_desktop_quote(profile.defaultProjectPath)) - exec_line = " ".join(exec_parts) - - content = ( - "[Desktop Entry]\n" - "Type=Application\n" - f"Name={profile.displayName}\n" - f"Comment=Open Cursor with the \"{profile.displayName}\" profile\n" - f"Exec={exec_line}\n" - f"Icon={icon_value}\n" - "Terminal=false\n" - "Categories=Development;\n" - "StartupWMClass=Cursor\n" - "StartupNotify=true\n" - ) + app = normalize_app(profile.app) + if app == APP_CODE: + # .desktop files cannot set env vars: run through sh so the + # profile travels in CLAUDE_CONFIG_DIR. Terminal=true keeps the + # interactive CLI (and its login flow) visible. + exe = code_path or "claude" + work = profile.defaultProjectPath or str(Path.home()) + inner = (f"CLAUDE_CONFIG_DIR={_desktop_quote(str(profile_dir))} " + f"exec {_desktop_quote(exe)}") + exec_line = f"sh -c {_desktop_quote(inner)}" + content = ( + "[Desktop Entry]\n" + "Type=Application\n" + f"Name={profile.displayName} (Claude Code)\n" + f"Comment=Open Claude Code with the \"{profile.displayName}\" profile\n" + f"Exec={exec_line}\n" + f"Path={work}\n" + f"Icon={icon_value}\n" + "Terminal=true\n" + "Categories=Development;\n" + "StartupNotify=true\n" + ) + elif app == APP_DESKTOP: + exe = desktop_path or "claude-desktop" + exec_parts = [_desktop_quote(exe)] + if not profile.isSystem: + exec_parts.append(_desktop_quote(f"--user-data-dir={profile_dir}")) + exec_line = " ".join(exec_parts) + content = ( + "[Desktop Entry]\n" + "Type=Application\n" + f"Name={profile.displayName} (Claude Desktop)\n" + f"Comment=Open Claude Desktop with the \"{profile.displayName}\" profile\n" + f"Exec={exec_line}\n" + f"Icon={icon_value}\n" + "Terminal=false\n" + "Categories=Development;\n" + "StartupWMClass=Claude\n" + "StartupNotify=true\n" + ) + else: + exe = cursor_path or "cursor" + exec_parts = [_desktop_quote(exe)] + if not profile.isSystem: + exec_parts += ["--user-data-dir", _desktop_quote(str(profile_dir))] + exec_parts.append(f"--max-memory={profile.defaultMemoryMB}") + if profile.defaultProjectPath: + exec_parts.append(_desktop_quote(profile.defaultProjectPath)) + exec_line = " ".join(exec_parts) + + content = ( + "[Desktop Entry]\n" + "Type=Application\n" + f"Name={profile.displayName}\n" + f"Comment=Open Cursor with the \"{profile.displayName}\" profile\n" + f"Exec={exec_line}\n" + f"Icon={icon_value}\n" + "Terminal=false\n" + "Categories=Development;\n" + "StartupWMClass=Cursor\n" + "StartupNotify=true\n" + ) cls.desktop_path(profile).write_text(content) os.chmod(cls.desktop_path(profile), 0o755) cls._refresh_desktop_database() @@ -406,6 +510,45 @@ def find(cls, custom_path): return path return None + @staticmethod + def code_candidates(): + home = Path.home() + return [ + shutil.which("claude"), + "/usr/bin/claude", + "/usr/local/bin/claude", + str(home / ".local/bin/claude"), + ] + + @classmethod + def find_code(cls, custom_path): + if custom_path and os.access(custom_path, os.X_OK): + return custom_path + for path in cls.code_candidates(): + if path and os.access(path, os.X_OK): + return path + return None + + @staticmethod + def desktop_candidates(): + home = Path.home() + return [ + shutil.which("claude-desktop"), + "/usr/bin/claude-desktop", + "/usr/local/bin/claude-desktop", + "/opt/Claude/claude-desktop", + str(home / ".local/bin/claude-desktop"), + ] + + @classmethod + def find_desktop(cls, custom_path): + if custom_path and os.access(custom_path, os.X_OK): + return custom_path + for path in cls.desktop_candidates(): + if path and os.access(path, os.X_OK): + return path + return None + def rss_of_pid(pid: int) -> int: """Live resident bytes for one PID via /proc. 0 if it just exited.""" @@ -458,7 +601,8 @@ def _load_settings(self): try: return json.loads(SETTINGS_PATH.read_text()) except (OSError, ValueError): - return {"customCursorPath": "", "defaultMemoryMB": 16384} + return {"customCursorPath": "", "customCodePath": "", + "customDesktopPath": "", "defaultMemoryMB": 16384} def save_settings(self): PROFILES_DIR.mkdir(parents=True, exist_ok=True) @@ -468,62 +612,98 @@ def save_settings(self): def cursor_path(self): return CursorFinder.find(self.settings.get("customCursorPath") or None) + @property + def code_path(self): + return CursorFinder.find_code(self.settings.get("customCodePath") or None) + + @property + def desktop_path(self): + return CursorFinder.find_desktop(self.settings.get("customDesktopPath") or None) + # -- persistence def directory_for(self, profile: Profile) -> Path: - return SYSTEM_DATA_DIR if profile.isSystem else PROFILES_DIR / profile.folderName + app = normalize_app(profile.app) + if profile.isSystem: + return system_data_dir_for(app) + return profiles_dir_for(app) / profile.folderName + + def metadata_path_for(self, app: str) -> Path: + return profiles_dir_for(app) / ".profiles.json" def reload(self): - PROFILES_DIR.mkdir(parents=True, exist_ok=True) + self.profiles = [] + for app in APPS: + self.profiles.extend(self.reload_app(app)) + self.save_metadata() + self.on_changed() + self.refresh_sizes() + self.refresh_running() + + def reload_app(self, app: str) -> list: + app_dir = profiles_dir_for(app) + app_dir.mkdir(parents=True, exist_ok=True) known = [] try: - known = [Profile.from_json(x) for x in json.loads(METADATA_PATH.read_text())] + known = [Profile.from_json(x) + for x in json.loads(self.metadata_path_for(app).read_text())] except (OSError, ValueError, KeyError): pass - on_disk = [p.name for p in PROFILES_DIR.iterdir() - if p.is_dir() and not p.name.startswith(".")] + # Foreign built-ins (from a copied metadata file) belong to their own + # app; hand-created folders are adopted into this app. + known = [q for q in known + if not (q.isSystem and q.folderName != system_marker_for(app))] + for q in known: + q.app = app + + on_disk = [x.name for x in app_dir.iterdir() + if x.is_dir() and not x.name.startswith(".")] for name in on_disk: - if not any(p.folderName == name for p in known): + if not any(q.folderName == name for q in known): display = name.removeprefix("custom_") _, hex_color = PALETTE[hash(name) % len(PALETTE)] - known.append(Profile(folderName=name, displayName=display, colorHex=hex_color)) + known.append(Profile(folderName=name, displayName=display, + app=app, colorHex=hex_color)) # Drop metadata for folders that no longer exist (never the built-in one). - known = [p for p in known if p.isSystem or p.folderName in on_disk] + known = [q for q in known if q.isSystem or q.folderName in on_disk] - # Surface the user's original Cursor profile — visible, launchable, protected. - if not any(p.isSystem for p in known) and SYSTEM_DATA_DIR.exists(): + # Surface the user's original app data — visible, launchable, protected. — visible, launchable, protected. + if not any(q.isSystem for q in known) and system_data_dir_for(app).exists(): known.insert(0, Profile( - folderName=SYSTEM_FOLDER_NAME, - displayName="Main Cursor", + folderName=system_marker_for(app), + displayName=system_display_name_for(app), + app=app, colorHex="#3B82F6", isPinned=True, isSystem=True, )) - - self.profiles = known - self.save_metadata() - self.on_changed() - self.refresh_sizes() - self.refresh_running() + return known def save_metadata(self): - PROFILES_DIR.mkdir(parents=True, exist_ok=True) - METADATA_PATH.write_text(json.dumps( - [p.to_json() for p in self.profiles], indent=2, ensure_ascii=False)) + for app in APPS: + app_dir = profiles_dir_for(app) + app_dir.mkdir(parents=True, exist_ok=True) + (app_dir / ".profiles.json").write_text(json.dumps( + [q.to_json() for q in self.profiles + if normalize_app(q.app) == app], + indent=2, ensure_ascii=False)) # -- CRUD def unique_folder_name(self, base: str) -> str: folder, counter = base, 2 existing = {p.folderName.lower() for p in self.profiles} - while folder.lower() in existing or folder == SYSTEM_FOLDER_NAME: + reserved = {SYSTEM_FOLDER_NAME, SYSTEM_CODE_FOLDER_NAME, + SYSTEM_DESKTOP_FOLDER_NAME} + while folder.lower() in existing or folder in reserved: folder = f"{base}-{counter}" counter += 1 return folder - def create(self, name, color_hex, memory_mb, project_path): + def create(self, name, color_hex, memory_mb, project_path, app=APP_CURSOR): + app = normalize_app(app) base = sanitize_folder_name(name.replace(" ", "_")) if not base: self.on_error("Profile name must contain at least one letter, number, hyphen or underscore.") @@ -531,22 +711,38 @@ def create(self, name, color_hex, memory_mb, project_path): profile = Profile( folderName=self.unique_folder_name(base), displayName=name.strip(), + app=app, colorHex=color_hex, defaultMemoryMB=memory_mb, defaultProjectPath=project_path or None, ) + profile_dir = self.directory_for(profile) try: - self.directory_for(profile).mkdir(parents=True, exist_ok=True) + profile_dir.mkdir(parents=True, exist_ok=True) except OSError as e: self.on_error(f"Could not create profile folder: {e}") return None + if app == APP_DESKTOP: + self.seed_desktop_config(system_data_dir_for(app), profile_dir) self.profiles.append(profile) self.save_metadata() self.on_changed() self.refresh_sizes() - TitleBarColorizer.apply(profile, self.directory_for(profile)) + TitleBarColorizer.apply(profile, profile_dir) return profile + @staticmethod + def seed_desktop_config(system_dir: Path, profile_dir: Path): + """Copy just claude_desktop_config.json (MCP/connectors) into a fresh + profile — never gigabytes of Electron cache.""" + try: + src = system_dir / "claude_desktop_config.json" + dst = profile_dir / "claude_desktop_config.json" + if src.is_file() and not dst.exists(): + shutil.copy2(src, dst) + except OSError: + pass + def update(self, profile: Profile): self.save_metadata() self.on_changed() @@ -562,7 +758,9 @@ def has_launcher(self, profile: Profile) -> bool: def create_or_update_launcher(self, profile: Profile, announce=True): try: - LauncherBuilder.create_or_update(profile, self.directory_for(profile), self.cursor_path) + LauncherBuilder.create_or_update(profile, self.directory_for(profile), + self.cursor_path, self.code_path, + self.desktop_path) if announce: if cairo is None: self.on_error(LauncherBuilder.ICON_UNAVAILABLE_MESSAGE) @@ -576,13 +774,23 @@ def remove_launcher(self, profile: Profile): LauncherBuilder.remove(profile) def duplicate(self, profile: Profile): - """Clone a profile. For the built-in profile the original is only read.""" - base = "Main-Cursor-Clone" if profile.isSystem else profile.folderName + "-copy" + """Clone a profile. For the built-in profile the original is only read. + Desktop duplicates are config-only (full Electron dirs are gigabytes + of cache); Code/Cursor duplicates copy the whole folder.""" + app = normalize_app(profile.app) + if profile.isSystem: + base = {APP_CODE: "Main-Claude-Code-Clone", + APP_DESKTOP: "Main-Claude-Desktop-Clone"}.get(app, "Main-Cursor-Clone") + display = system_display_name_for(app) + " Clone" + else: + base = profile.folderName + "-copy" + display = profile.displayName + " Copy" folder = self.unique_folder_name(base) - source, dest = self.directory_for(profile), PROFILES_DIR / folder + source, dest = self.directory_for(profile), profiles_dir_for(app) / folder copy = Profile( folderName=folder, - displayName="Main Cursor Clone" if profile.isSystem else profile.displayName + " Copy", + displayName=display, + app=app, colorHex=profile.colorHex, defaultMemoryMB=profile.defaultMemoryMB, defaultProjectPath=profile.defaultProjectPath, @@ -593,8 +801,14 @@ def duplicate(self, profile: Profile): def work(): error = None try: - shutil.copytree(source, dest, symlinks=True, - ignore_dangling_symlinks=True, dirs_exist_ok=True) + if app == APP_DESKTOP: + dest.mkdir(parents=True, exist_ok=True) + src = source / "claude_desktop_config.json" + if src.is_file(): + shutil.copy2(src, dest / "claude_desktop_config.json") + else: + shutil.copytree(source, dest, symlinks=True, + ignore_dangling_symlinks=True, dirs_exist_ok=True) except (OSError, shutil.Error) as e: error = str(e) shutil.rmtree(dest, ignore_errors=True) @@ -618,7 +832,7 @@ def finish(): def delete(self, profile: Profile): """Move the profile folder to the Trash (recoverable).""" if profile.isSystem: - self.on_error("The built-in Cursor profile can't be deleted from here — it's your original Cursor data.") + self.on_error("The built-in profile can't be deleted from here — it's your original app data.") return if self.running.get(profile.folderName): self.on_error(f"'{profile.displayName}' is running. Quit it before deleting.") @@ -637,6 +851,16 @@ def delete(self, profile: Profile): # -- launch / quit def launch(self, profile: Profile, project_path=None, memory_mb=None, new_window=False): + app = normalize_app(profile.app) + if app == APP_CODE: + self.launch_code(profile, project_path) + elif app == APP_DESKTOP: + self.launch_desktop(profile, new_window) + else: + self.launch_cursor(profile, project_path, memory_mb, new_window) + GLib.timeout_add_seconds(2, lambda: (self.refresh_running(), False)[1]) + + def launch_cursor(self, profile: Profile, project_path=None, memory_mb=None, new_window=False): exe = self.cursor_path if not exe: self.on_error("Cursor could not be found. Install it from cursor.sh or set a custom path in Preferences.") @@ -646,7 +870,7 @@ def launch(self, profile: Profile, project_path=None, memory_mb=None, new_window profile_dir = self.directory_for(profile) profile_dir.mkdir(parents=True, exist_ok=True) args += ["--user-data-dir", str(profile_dir)] - # Idempotent — also covers profiles adopted from disk that never went through create(). + # Idempotent: also covers profiles adopted from disk that never went through create(). TitleBarColorizer.apply(profile, profile_dir) args.append(f"--max-memory={memory_mb or profile.defaultMemoryMB}") if new_window: @@ -663,10 +887,88 @@ def launch(self, profile: Profile, project_path=None, memory_mb=None, new_window profile.lastLaunchedAt = datetime.now(timezone.utc) self.save_metadata() self.on_status() - GLib.timeout_add_seconds(2, lambda: (self.refresh_running(), False)[1]) + + def launch_desktop(self, profile: Profile, new_window=False): + exe = self.desktop_path + if not exe: + self.on_error("Claude Desktop could not be found. Install it or set a custom path in Preferences.") + return + args = [exe] + if not profile.isSystem: + # Chromium switches need "--user-data-dir=" (a separate + # value isn't parsed). Verified on Windows with the standalone + # build; not yet verified on Linux packages. + profile_dir = self.directory_for(profile) + profile_dir.mkdir(parents=True, exist_ok=True) + args.append(f"--user-data-dir={profile_dir}") + if new_window: + args.append("--new-window") + try: + subprocess.Popen(args, start_new_session=True, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except OSError as e: + self.on_error(f"Failed to launch Claude Desktop: {e}") + return + profile.lastLaunchedAt = datetime.now(timezone.utc) + self.save_metadata() + self.on_status() + + def launch_code(self, profile: Profile, project_path=None): + exe = self.code_path + if not exe: + self.on_error("Claude Code could not be found. Install it (see code.claude.com/docs) " + "or set a custom path in Preferences.") + return + profile_dir = self.directory_for(profile) + if not profile.isSystem: + profile_dir.mkdir(parents=True, exist_ok=True) + work = project_path or profile.defaultProjectPath or str(Path.home()) + # The CLI is interactive: open a real terminal with CLAUDE_CONFIG_DIR + # set so login prompts stay visible. First launch walks through login. + terminal = self.find_terminal() + if not terminal: + self.on_error( + "No terminal emulator found. Run this yourself: " + f'CLAUDE_CONFIG_DIR="{profile_dir}" {exe}') + return + cmd = (f'CLAUDE_CONFIG_DIR="{profile_dir}" exec "{exe}"; ' + f'echo; echo "[Prism] session ended - press Enter to close."; read _') + try: + subprocess.Popen([*terminal, "sh", "-c", cmd], cwd=work, + start_new_session=True, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except OSError as e: + self.on_error(f"Failed to launch Claude Code: {e}") + return + profile.lastLaunchedAt = datetime.now(timezone.utc) + self.save_metadata() + self.on_status() + + @staticmethod + def find_terminal(): + """A terminal emulator argv prefix, or None. Covers the common Linux + desktops; GNOME/KDE/Xfce/VTE-based fallbacks in order.""" + candidates = [ + ["x-terminal-emulator", "-e"], + ["gnome-terminal", "--"], + ["konsole", "-e"], + ["xfce4-terminal", "-e"], + ["mate-terminal", "-e"], + ["kgx", "-e"], + ["alacritty", "-e"], + ["kitty", "--"], + ["foot", "-e"], + ["xterm", "-e"], + ] + for argv in candidates: + if shutil.which(argv[0]): + return argv + return None def quit_profile(self, profile: Profile): for pid in self.running.get(profile.folderName, []): + if pid <= 0: + continue # sentinel marker, not a real process try: os.kill(pid, signal.SIGTERM) except ProcessLookupError: @@ -680,7 +982,8 @@ def _poll(self): return True def refresh_running(self): - snapshot = [(p.folderName, str(self.directory_for(p)), p.isSystem) for p in self.profiles] + snapshot = [(p.folderName, str(self.directory_for(p)), p.isSystem, + normalize_app(p.app)) for p in self.profiles] def work(): processes = [] @@ -705,16 +1008,55 @@ def is_cursor_main(argv): looks_like_cursor = exe == "cursor" or exe.endswith(".appimage") and "cursor" in exe return looks_like_cursor and not any(a.startswith("--type=") for a in argv) + def is_desktop_main(argv): + exe = os.path.basename(argv[0]).lower() + if "prism" in exe: + return False + joined = "\0".join(argv).lower() + if "claude-code" in joined: + # Bundled CLI helper shares the Desktop's process family. + return False + # No bare-`claude` fallback: that name belongs to the Code CLI, + # which would otherwise light up the built-in Desktop profile. + looks = exe == "claude-desktop" + return looks and not any(a.startswith("--type=") for a in argv) + + def user_data_dir(argv): + for a in argv: + if a.startswith("--user-data-dir="): + return a.split("=", 1)[1] + if "--user-data-dir" in argv: + nxt = argv[argv.index("--user-data-dir") + 1:argv.index("--user-data-dir") + 2] + return nxt[0] if nxt else None + return None + + any_desktop = any(is_desktop_main(argv) for _, argv in processes) result = {} - for name, directory, is_system in snapshot: - if is_system: + for name, directory, is_system, app in snapshot: + if app == APP_CODE: + # The CLI is a terminal session, not a trackable GUI app. + result[name] = [] + elif app == APP_DESKTOP: + if is_system: + result[name] = [ + pid for pid, argv in processes + if is_desktop_main(argv) and user_data_dir(argv) is None] + else: + result[name] = [ + pid for pid, argv in processes + if is_desktop_main(argv) and user_data_dir(argv) == directory] + if (not result[name] and any_desktop + and os.path.isfile(os.path.join(directory, "lockfile"))): + # Electron holds a lockfile in a live user-data-dir. + # -1 is a sentinel, never signaled (see quit_profile). + result[name] = [-1] + elif is_system: result[name] = [pid for pid, argv in processes if is_cursor_main(argv) and "--user-data-dir" not in argv] else: result[name] = [ pid for pid, argv in processes - if is_cursor_main(argv) and "--user-data-dir" in argv - and argv[argv.index("--user-data-dir") + 1:argv.index("--user-data-dir") + 2] == [directory] + if is_cursor_main(argv) and user_data_dir(argv) == directory ] # Live RSS: VmRSS of matched PIDs only (cheap, no new deps). @@ -722,7 +1064,8 @@ def is_cursor_main(argv): for name, pids in result.items(): total = 0 for pid in pids: - total += rss_of_pid(pid) + if pid > 0: + total += rss_of_pid(pid) live[name] = total # System CPU % from /proc/stat deltas (None on first sample). @@ -783,7 +1126,7 @@ def __init__(self, app, store: Store): self.store = store self.search_text = "" self.sort_mode = 0 - self.filter_mode = 0 # 0 all, 1 active, 2 custom (non-built-in) + self.filter_mode = 0 # 0 all, 1 active, 2 custom, 3 cursor, 4 code, 5 desktop self._last_visible = set() self.status_updaters = [] self._css = Gtk.CssProvider() @@ -834,7 +1177,7 @@ def __init__(self, app, store: Store): filter_lbl.add_css_class("heading") filter_row.append(filter_lbl) self.filter_btns = [] - for i, name in enumerate(["All", "Active", "Custom"]): + for i, name in enumerate(["All", "Active", "Custom", "Cursor", "Code", "Desktop"]): btn = Gtk.ToggleButton(label=name) btn.add_css_class("flat") filter_row.append(btn) @@ -882,7 +1225,7 @@ def __init__(self, app, store: Store): self.empty = Adw.StatusPage( icon_name="folder-templates-symbolic", title="No profiles yet", - description="Each profile is a fully isolated Cursor instance — its own settings, extensions, login and chat history.", +description="Each profile is a fully isolated instance — its own settings, login and history, per app.", vexpand=True) empty_btn = Gtk.Button(label="Create your first profile", halign=Gtk.Align.CENTER) empty_btn.add_css_class("suggested-action") @@ -939,10 +1282,18 @@ def visible_profiles(self): profiles = [p for p in profiles if self.store.running.get(p.folderName)] elif self.filter_mode == 2: profiles = [p for p in profiles if not p.isSystem] + elif self.filter_mode == 3: + profiles = [p for p in profiles if normalize_app(p.app) == APP_CURSOR] + elif self.filter_mode == 4: + profiles = [p for p in profiles if normalize_app(p.app) == APP_CODE] + elif self.filter_mode == 5: + profiles = [p for p in profiles if normalize_app(p.app) == APP_DESKTOP] return [p for p in profiles if not self.search_text or self.search_text in p.displayName.lower() - or self.search_text in p.folderName.lower()] + or self.search_text in p.folderName.lower() + or self.search_text in app_label(normalize_app(p.app)).lower() + or self.search_text in normalize_app(p.app)] # -- CSS for per-profile gradients & accents @@ -1012,15 +1363,26 @@ def rebuild(self, *_): self.refresh_status() def _update_filter_labels(self): - active = sum(1 for p in self.store.profiles if self.store.running.get(p.folderName)) - custom = sum(1 for p in self.store.profiles if not p.isSystem) - self.filter_btns[0].set_label(f"All ({len(self.store.profiles)})") + profiles = self.store.profiles + active = sum(1 for p in profiles if self.store.running.get(p.folderName)) + custom = sum(1 for p in profiles if not p.isSystem) + counts = { + 3: sum(1 for p in profiles if normalize_app(p.app) == APP_CURSOR), + 4: sum(1 for p in profiles if normalize_app(p.app) == APP_CODE), + 5: sum(1 for p in profiles if normalize_app(p.app) == APP_DESKTOP), + } + self.filter_btns[0].set_label(f"All ({len(profiles)})") self.filter_btns[1].set_label(f"Active ({active})") self.filter_btns[2].set_label(f"Custom ({custom})") + self.filter_btns[3].set_label(f"Cursor ({counts[3]})") + self.filter_btns[4].set_label(f"Code ({counts[4]})") + self.filter_btns[5].set_label(f"Desktop ({counts[5]})") def build_card(self, profile: Profile): tag = profile.colorHex.lstrip("#") - limit_bytes = profile.defaultMemoryMB * 1024 * 1024 + app = normalize_app(profile.app) + has_cap = app == APP_CURSOR + limit_bytes = profile.defaultMemoryMB * 1024 * 1024 if has_cap else 0 card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, width_request=280) card.add_css_class("card") card.add_css_class("profile-card") @@ -1049,11 +1411,17 @@ def build_card(self, profile: Profile): name_row.append(name) if profile.isSystem: badge = Gtk.Label(label="BUILT-IN", valign=Gtk.Align.CENTER, tooltip_text=( - "Your original Cursor profile (~/.config/Cursor). " + f"Your original {app_label(app)} data. " "Launchable and clonable, never deletable from this app.")) badge.add_css_class("badge") name_row.append(badge) - elif profile.isPinned: + if app != APP_CURSOR: + app_badge = Gtk.Label( + label={APP_CODE: "CODE", APP_DESKTOP: "DESKTOP"}[app], + valign=Gtk.Align.CENTER, tooltip_text=app_label(app)) + app_badge.add_css_class("badge") + name_row.append(app_badge) + if not profile.isSystem and profile.isPinned: pin_img = Gtk.Image.new_from_icon_name("view-pin-symbolic") name_row.append(pin_img) title_box.append(name_row) @@ -1143,20 +1511,33 @@ def update(): "card-status-running" if running else "card-status-idle") status.get_style_context().remove_class( "card-status-idle" if running else "card-status-running") - pid_lbl.set_label(f"PID {pids[0]}" if pids else "") - if running: + real = next((q for q in pids if q > 0), 0) + pid_lbl.set_label(f"PID {real}" if real else "") + if running and has_cap: mem.set_markup( f"Memory {format_bytes(live)}" f" / {format_bytes(limit_bytes)} limit") mem_bar.set_fraction(min(1.0, live / limit_bytes) if limit_bytes else 0.0) - else: + elif running: + mem.set_markup( + f"Memory {format_bytes(live)} live") + mem_bar.set_fraction(0.0) + elif has_cap: mem.set_markup( f"Memory {format_bytes(limit_bytes)} limit") mem_bar.set_fraction(0.0) + else: + mem.set_markup( + "Memory no memory cap") + mem_bar.set_fraction(0.0) if running: launch.set_label("New Window") launch.remove_css_class(f"btn-{tag}") launch.add_css_class("launch-running") + elif app == APP_CODE: + launch.set_label("Open Terminal") + launch.remove_css_class("launch-running") + launch.add_css_class(f"btn-{tag}") else: launch.set_label("Launch") launch.remove_css_class("launch-running") @@ -1306,6 +1687,16 @@ def __init__(self, parent: MainWindow, store: Store, profile: Profile | None): self.name_entry.connect("changed", lambda *_: self.update_preview()) form.append(self._labeled("Name", self.name_entry)) + self.app_model = Gtk.StringList.new( + ["Cursor (editor)", "Claude Code (CLI)", "Claude Desktop"]) + self.app_drop = Gtk.DropDown(model=self.app_model) + current_app = normalize_app(profile.app) if profile else APP_CURSOR + self.app_drop.set_selected( + {APP_CURSOR: 0, APP_CODE: 1, APP_DESKTOP: 2}.get(current_app, 0)) + self.app_drop.set_sensitive(profile is None) # app is fixed after creation + self.app_drop.connect("notify::selected", lambda *_: self.on_app_changed()) + form.append(self._labeled("App", self.app_drop)) + color_box = Gtk.Box(spacing=6) self.color_buttons = {} for color_name, hex_color in PALETTE: @@ -1321,7 +1712,9 @@ def __init__(self, parent: MainWindow, store: Store, profile: Profile | None): default_mb = profile.defaultMemoryMB if profile else store.settings.get("defaultMemoryMB", 16384) self.memory = Gtk.SpinButton.new_with_range(512, 262144, 1024) self.memory.set_value(default_mb) - form.append(self._labeled("Memory limit (MB)", self.memory)) + self.memory_row = self._labeled("Memory limit (MB, Cursor only)", self.memory) + form.append(self.memory_row) + self.on_app_changed() project_row = Gtk.Box(spacing=6) self.project = Gtk.Entry(hexpand=True, placeholder_text="Optional", @@ -1334,7 +1727,8 @@ def __init__(self, parent: MainWindow, store: Store, profile: Profile | None): if profile: note = Gtk.Label(xalign=0, label=( - "Built-in profile — ~/.config/Cursor" if profile.isSystem + f"Built-in profile — {app_label(normalize_app(profile.app))} data" + if profile.isSystem else f"Folder: {profile.folderName}")) note.add_css_class("caption") note.add_css_class("dim-label") @@ -1351,6 +1745,16 @@ def _labeled(text, widget): box.append(widget) return box + def selected_app(self): + return {0: APP_CURSOR, 1: APP_CODE, 2: APP_DESKTOP}.get( + self.app_drop.get_selected(), APP_CURSOR) + + def on_app_changed(self): + # Memory cap only applies to Cursor; dim the field for Claude apps. + is_cursor = self.selected_app() == APP_CURSOR + self.memory.set_sensitive(is_cursor) + self.memory_row.set_opacity(1.0 if is_cursor else 0.45) + def set_color(self, hex_color): self.color_hex = hex_color self.update_preview() @@ -1394,7 +1798,8 @@ def on_save(self, *_): self.store.update(p) self.close() else: - if self.store.create(name, self.color_hex, memory, project): + if self.store.create(name, self.color_hex, memory, project, + app=self.selected_app()): self.close() @@ -1425,16 +1830,25 @@ def __init__(self, parent: MainWindow, store: Store, profile: Profile): self.memory = Gtk.SpinButton.new_with_range(512, 262144, 1024) self.memory.set_value(profile.defaultMemoryMB) - form.append(EditorDialog._labeled("Memory limit (MB)", self.memory)) + is_cursor = normalize_app(profile.app) == APP_CURSOR + self.memory.set_sensitive(is_cursor) + mem_row = EditorDialog._labeled("Memory limit (MB, Cursor only)", self.memory) + mem_row.set_opacity(1.0 if is_cursor else 0.45) + form.append(mem_row) project_row = Gtk.Box(spacing=6) self.project = Gtk.Entry(hexpand=True, placeholder_text="Optional", text=profile.defaultProjectPath or "") + is_desktop = normalize_app(profile.app) == APP_DESKTOP + self.project.set_sensitive(not is_desktop) browse = Gtk.Button(label="Choose…") + browse.set_sensitive(not is_desktop) browse.connect("clicked", self.pick_folder) project_row.append(self.project) project_row.append(browse) - form.append(EditorDialog._labeled("Project folder", project_row)) + proj_row = EditorDialog._labeled("Project folder", project_row) + proj_row.set_opacity(0.45 if is_desktop else 1.0) + form.append(proj_row) self.new_window = Gtk.CheckButton(label="Force a new window") form.append(self.new_window) @@ -1479,6 +1893,25 @@ def __init__(self, parent: MainWindow, store: Store): custom.connect("changed", lambda row: self._set("customCursorPath", row.get_text().strip())) cursor_group.add(custom) + code_group = Adw.PreferencesGroup(title="Claude Code (CLI)") + page.add(code_group) + code_group.add(Adw.ActionRow(title="Detected path", + subtitle=store.code_path or "Not found")) + code_custom = Adw.EntryRow(title="Custom path (empty = auto-detect)") + code_custom.set_text(store.settings.get("customCodePath", "")) + code_custom.connect("changed", lambda row: self._set("customCodePath", row.get_text().strip())) + code_group.add(code_custom) + + desktop_group = Adw.PreferencesGroup(title="Claude Desktop (app)") + page.add(desktop_group) + desktop_group.add(Adw.ActionRow(title="Detected path", + subtitle=store.desktop_path or "Not found")) + desktop_custom = Adw.EntryRow(title="Custom path (empty = auto-detect)") + desktop_custom.set_text(store.settings.get("customDesktopPath", "")) + desktop_custom.connect("changed", lambda row: self._set("customDesktopPath", + row.get_text().strip())) + desktop_group.add(desktop_custom) + defaults_group = Adw.PreferencesGroup(title="New profile defaults") page.add(defaults_group) memory_row = Adw.SpinRow.new_with_range(512, 262144, 1024) diff --git a/tui/README.md b/tui/README.md index e121d5b..fe40028 100644 --- a/tui/README.md +++ b/tui/README.md @@ -1,8 +1,12 @@ # prism-tui — Prism for the terminal A cross-platform terminal UI for managing isolated [Cursor](https://cursor.sh) -profiles. Same `~/.cursor_profiles/.profiles.json` as the macOS, Windows and -Linux apps — no app required. +and Claude Code profiles (plus the built-in Main Claude Desktop). Same profile +dirs as the macOS, Windows and Linux apps — no app required: + +- Cursor: `~/.cursor_profiles/.profiles.json` +- Claude Code: `~/.claude_code_profiles/.profiles.json` +- Claude Desktop: `~/.claude_desktop_profiles/.profiles.json` ``` ▲ Prism v1.2.3 ● 1 running · 372.6 MB live @@ -38,7 +42,7 @@ runtime needed anywhere. | `/` then type | Filter profiles (`esc` clears) | | `enter` | Launch (opens a new window when already running) | | `x` | Stop the selected profile | -| `n` | New profile (`tab` moves fields, `enter` advances/saves, `esc` cancels) | +| `n` | New profile (`tab` moves fields, `space` switches app, `enter` advances/saves, `esc` cancels) | | `d d` | Delete (press twice; moves the folder to `.trash/`) | | `R` | Rescan profiles from disk | | `q` / `ctrl+c` | Quit | @@ -47,10 +51,19 @@ runtime needed anywhere. - Every number is measured live (process RSS, on-screen status, kernel CPU). CPU shows `—` until the second sample; RAM shows `—` when unreadable. +- Claude Code profiles are terminal sessions, not GUI apps: `enter` prepares + the profile's `CLAUDE_CONFIG_DIR` and prints the exact `claude` command to + run (first run walks through login for that account). They never report + "running". +- Claude Desktop profiles launch with `--user-data-dir=`: separate + login, several at once. On Windows this needs the standalone installer build + (the Store build drops launch flags) — Prism tells you and points to + claude.com/download. Verified on Windows; macOS/Linux untested. - Window counts are intentionally absent: there is no honest cross-platform way to count another app's windows from a terminal (notably on Wayland). -- Deleting never `rm -rf`s: folders move to `~/.cursor_profiles/.trash/`. -- New profiles get the same per-profile title bar color as the GUI apps. +- Deleting never `rm -rf`s: folders move to a `.trash/` inside their own app + dir. +- New Cursor profiles get the same per-profile title bar color as the GUI apps. ## Develop diff --git a/tui/main.go b/tui/main.go index 78146b9..285460a 100644 --- a/tui/main.go +++ b/tui/main.go @@ -1,6 +1,8 @@ // Command prism-tui is Prism for the terminal: list, launch, stop, create -// and delete isolated Cursor profiles without opening the app. It shares -// ~/.cursor_profiles/.profiles.json with the macOS, Windows and Linux apps. +// and delete isolated Cursor and Claude Code profiles (plus the built-in +// Main Claude Desktop) without opening the app. It shares the three profile +// dirs (~/.cursor_profiles, ~/.claude_code_profiles, +// ~/.claude_desktop_profiles) with the macOS, Windows and Linux apps. // // Version is stamped at release time: // diff --git a/tui/signal_unix.go b/tui/signal_unix.go index 833de42..1182476 100644 --- a/tui/signal_unix.go +++ b/tui/signal_unix.go @@ -5,8 +5,13 @@ package main import "github.com/shirou/gopsutil/v4/process" // quitPIDs politely asks each process to quit (SIGTERM), like the apps. +// Non-positive PIDs are sentinels (e.g. Store-app "running" markers), never +// real processes — signaling -1 would hit the whole process group. func quitPIDs(pids []int32) { for _, pid := range pids { + if pid <= 0 { + continue + } if p, err := process.NewProcess(pid); err == nil { _ = p.Terminate() } diff --git a/tui/signal_windows.go b/tui/signal_windows.go index e1d2411..81aa68c 100644 --- a/tui/signal_windows.go +++ b/tui/signal_windows.go @@ -3,8 +3,12 @@ package main import "github.com/shirou/gopsutil/v4/process" // Windows has no SIGTERM; terminate the processes outright. +// Non-positive PIDs are sentinels, never real processes — skip them. func quitPIDs(pids []int32) { for _, pid := range pids { + if pid <= 0 { + continue + } if p, err := process.NewProcess(pid); err == nil { _ = p.Kill() } diff --git a/tui/stats.go b/tui/stats.go index 49fc56d..e157e26 100644 --- a/tui/stats.go +++ b/tui/stats.go @@ -37,6 +37,9 @@ func listProcesses() []procInfo { func rssOfPids(pids []int32) uint64 { var total uint64 for _, pid := range pids { + if pid <= 0 { + continue // sentinel marker, not a process + } p, err := process.NewProcess(pid) if err != nil { continue diff --git a/tui/store.go b/tui/store.go index 5010f4f..658ebec 100644 --- a/tui/store.go +++ b/tui/store.go @@ -27,9 +27,26 @@ const settingsName = ".launcher-settings.json" // systemFolderName marks the built-in Cursor profile. Never a real folder. const systemFolderName = "__cursor-default__" +// systemCodeFolderName marks the built-in Claude Code profile (the real +// ~/.claude config). Never a real folder inside the Code profiles dir. +const systemCodeFolderName = "__claude-code-default__" + +// systemDesktopFolderName marks the built-in Claude Desktop profile (the +// real Electron userData dir). Never a real folder in the Desktop dir. +const systemDesktopFolderName = "__claude-desktop-default__" + +// App identifiers. Stored in .profiles.json as "app"; absent means cursor +// (all files written before multi-app support). +const ( + AppCursor = "cursor" + AppClaudeCode = "claude-code" + AppClaudeDesktop = "claude-desktop" +) + type Profile struct { FolderName string `json:"folderName"` DisplayName string `json:"displayName"` + App string `json:"app"` ColorHex string `json:"colorHex"` DefaultMemoryMB int `json:"defaultMemoryMB"` DefaultProjectPath *string `json:"defaultProjectPath"` @@ -45,6 +62,7 @@ type Profile struct { type profileJSON struct { FolderName string `json:"folderName"` DisplayName string `json:"displayName"` + App string `json:"app"` ColorHex string `json:"colorHex"` DefaultMemoryMB int `json:"defaultMemoryMB"` DefaultProjectPath *string `json:"defaultProjectPath"` @@ -66,12 +84,16 @@ func (p Profile) toJSON() profileJSON { j := profileJSON{ FolderName: p.FolderName, DisplayName: p.DisplayName, + App: p.App, ColorHex: p.ColorHex, DefaultMemoryMB: p.DefaultMemoryMB, DefaultProjectPath: p.DefaultProjectPath, IsPinned: p.IsPinned, IsSystem: p.IsSystem, } + if j.App == "" { + j.App = AppCursor + } created := timeToApple(p.CreatedAt) j.CreatedAt = &created if p.LastLaunchedAt != nil { @@ -85,12 +107,17 @@ func profileFromJSON(j profileJSON) Profile { p := Profile{ FolderName: j.FolderName, DisplayName: j.DisplayName, + App: j.App, ColorHex: j.ColorHex, DefaultMemoryMB: j.DefaultMemoryMB, DefaultProjectPath: j.DefaultProjectPath, IsPinned: j.IsPinned, IsSystem: j.IsSystem, } + if p.App != AppClaudeCode && p.App != AppClaudeDesktop { + // Absent or unknown: files predating multi-app support are Cursor. + p.App = AppCursor + } if j.CreatedAt != nil { p.CreatedAt = appleToTime(*j.CreatedAt) } else { @@ -119,17 +146,51 @@ func (p Profile) Initial() string { } func (p Profile) LimitBytes() uint64 { + if p.App != AppCursor { + // Only Cursor launches with --max-memory; Claude apps have no + // per-profile memory cap, so there is no limit to report. + return 0 + } if p.DefaultMemoryMB < 0 { return 0 } return uint64(p.DefaultMemoryMB) * 1024 * 1024 } +// AppLabel is the short human tag shown in list rows and filters. +func (p Profile) AppLabel() string { + switch p.App { + case AppClaudeCode: + return "Code" + case AppClaudeDesktop: + return "Desktop" + default: + return "Cursor" + } +} + +// systemMarkerFor returns the reserved folder name for an app's built-in +// profile, or "" when the app has none. +func systemMarkerFor(app string) string { + switch app { + case AppClaudeCode: + return systemCodeFolderName + case AppClaudeDesktop: + return systemDesktopFolderName + default: + return systemFolderName + } +} + type Store struct { - Dir string + Dir string // Cursor profiles (historical default) + CodeDir string // Claude Code profiles (CLAUDE_CONFIG_DIR values) + DesktopDir string // Claude Desktop profiles (Electron user-data-dirs) Profiles []Profile defaultMemoryMB int customCursorPath string + customCodePath string + customDesktopPath string } func defaultDir() string { @@ -140,21 +201,90 @@ func defaultDir() string { return filepath.Join(home, ".cursor_profiles") } +func defaultCodeDir() string { + home, err := os.UserHomeDir() + if err != nil { + return ".claude_code_profiles" + } + return filepath.Join(home, ".claude_code_profiles") +} + +func defaultDesktopDir() string { + home, err := os.UserHomeDir() + if err != nil { + return ".claude_desktop_profiles" + } + return filepath.Join(home, ".claude_desktop_profiles") +} + +// dirForApp returns the profiles root for an app identifier. +func (s *Store) dirForApp(app string) string { + switch app { + case AppClaudeCode: + return s.CodeDir + case AppClaudeDesktop: + return s.DesktopDir + default: + return s.Dir + } +} + func systemDataDir() string { + return systemDataDirFor(AppCursor) +} + +// systemDataDirFor is the real (built-in) data location per app: +// Cursor and Claude Desktop keep Electron-style user data; Claude Code +// keeps its config in ~/.claude (overridable via CLAUDE_CONFIG_DIR, which +// is exactly what per-profile isolation uses). +func systemDataDirFor(app string) string { home, _ := os.UserHomeDir() - switch runtime.GOOS { - case "darwin": - return filepath.Join(home, "Library", "Application Support", "Cursor") - case "windows": - if appdata := os.Getenv("APPDATA"); appdata != "" { - return filepath.Join(appdata, "Cursor") + switch app { + case AppClaudeCode: + if custom := os.Getenv("CLAUDE_CONFIG_DIR"); custom != "" { + return custom + } + return filepath.Join(home, ".claude") + case AppClaudeDesktop: + switch runtime.GOOS { + case "darwin": + return filepath.Join(home, "Library", "Application Support", "Claude") + case "windows": + // Microsoft Store install (verified on this machine): + // %LOCALAPPDATA%\Packages\Claude_pzs8sxrjxfjjc\LocalCache\Roaming\Claude + if local := os.Getenv("LOCALAPPDATA"); local != "" { + store := filepath.Join(local, "Packages", "Claude_pzs8sxrjxfjjc", + "LocalCache", "Roaming", "Claude") + if st, err := os.Stat(store); err == nil && st.IsDir() { + return store + } + } + if appdata := os.Getenv("APPDATA"); appdata != "" { + return filepath.Join(appdata, "Claude") + } + return filepath.Join(home, "AppData", "Roaming", "Claude") + default: + if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { + return filepath.Join(xdg, "Claude") + } + return filepath.Join(home, ".config", "Claude") } - return filepath.Join(home, "AppData", "Roaming", "Cursor") default: - if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { - return filepath.Join(xdg, "Cursor") + // Cursor + switch runtime.GOOS { + case "darwin": + return filepath.Join(home, "Library", "Application Support", "Cursor") + case "windows": + if appdata := os.Getenv("APPDATA"); appdata != "" { + return filepath.Join(appdata, "Cursor") + } + return filepath.Join(home, "AppData", "Roaming", "Cursor") + default: + if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { + return filepath.Join(xdg, "Cursor") + } + return filepath.Join(home, ".config", "Cursor") } - return filepath.Join(home, ".config", "Cursor") } } @@ -172,10 +302,16 @@ func sanitizeFolderName(raw string) string { } func LoadStore() (*Store, error) { - s := &Store{Dir: defaultDir(), defaultMemoryMB: 16384} + s := &Store{Dir: defaultDir(), CodeDir: defaultCodeDir(), DesktopDir: defaultDesktopDir(), defaultMemoryMB: 16384} if err := os.MkdirAll(s.Dir, 0o755); err != nil { return nil, err } + if err := os.MkdirAll(s.CodeDir, 0o755); err != nil { + return nil, err + } + if err := os.MkdirAll(s.DesktopDir, 0o755); err != nil { + return nil, err + } s.loadSettings() if err := s.reload(); err != nil { return nil, err @@ -196,6 +332,12 @@ func (s *Store) loadSettings() { if v, ok := raw["customCursorPath"].(string); ok { s.customCursorPath = v } + if v, ok := raw["customCodePath"].(string); ok { + s.customCodePath = v + } + if v, ok := raw["customDesktopPath"].(string); ok { + s.customDesktopPath = v + } if v, ok := raw["defaultMemoryMB"].(float64); ok && v >= 512 { s.defaultMemoryMB = int(v) } @@ -205,16 +347,39 @@ func (s *Store) metadataPath() string { return filepath.Join(s.Dir, metadataName) } +func (s *Store) metadataPathFor(app string) string { + return filepath.Join(s.dirForApp(app), metadataName) +} + func (s *Store) dirFor(p Profile) string { if p.IsSystem { - return systemDataDir() + return systemDataDirFor(p.App) } - return filepath.Join(s.Dir, p.FolderName) + return filepath.Join(s.dirForApp(p.App), p.FolderName) } func (s *Store) reload() error { + var all []Profile + for _, app := range []string{AppCursor, AppClaudeCode, AppClaudeDesktop} { + profiles, err := s.reloadApp(app) + if err != nil { + return err + } + all = append(all, profiles...) + } + s.Profiles = all + return s.save() +} + +// reloadApp loads one app's profiles: its own metadata file plus any +// hand-created directories, its own built-in profile, then persists. +func (s *Store) reloadApp(app string) ([]Profile, error) { + dir := s.dirForApp(app) + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, err + } var known []profileJSON - if data, err := os.ReadFile(s.metadataPath()); err == nil { + if data, err := os.ReadFile(filepath.Join(dir, metadataName)); err == nil { _ = json.Unmarshal(data, &known) // corrupt file: rebuild from disk } profiles := make([]Profile, 0, len(known)) @@ -222,10 +387,19 @@ func (s *Store) reload() error { if j.FolderName == "" { continue } - profiles = append(profiles, profileFromJSON(j)) + p := profileFromJSON(j) + if p.App != app { + // A folder shared by another app's file (e.g. a copied + // .profiles.json): keep it with its own app instead. + p.App = app + if p.IsSystem { + continue + } + } + profiles = append(profiles, p) } - entries, _ := os.ReadDir(s.Dir) + entries, _ := os.ReadDir(dir) onDisk := map[string]bool{} for _, e := range entries { if !e.IsDir() || strings.HasPrefix(e.Name(), ".") { @@ -246,6 +420,7 @@ func (s *Store) reload() error { profiles = append(profiles, Profile{ FolderName: name, DisplayName: display, + App: app, ColorHex: accentFor(name), DefaultMemoryMB: s.defaultMemoryMB, CreatedAt: time.Now().UTC(), @@ -268,10 +443,11 @@ func (s *Store) reload() error { } } if !hasSystem { - if st, err := os.Stat(systemDataDir()); err == nil && st.IsDir() { + if st, err := os.Stat(systemDataDirFor(app)); err == nil && st.IsDir() { profiles = append([]Profile{{ - FolderName: systemFolderName, - DisplayName: "Main Cursor", + FolderName: systemMarkerFor(app), + DisplayName: systemDisplayName(app), + App: app, ColorHex: "#3B82F6", DefaultMemoryMB: 16384, CreatedAt: time.Now().UTC(), @@ -281,20 +457,52 @@ func (s *Store) reload() error { } } - s.Profiles = profiles - return s.save() + return profiles, s.saveApp(app, profiles) +} + +// systemDisplayName is the built-in card title per app. +func systemDisplayName(app string) string { + switch app { + case AppClaudeCode: + return "Main Claude Code" + case AppClaudeDesktop: + return "Main Claude Desktop" + default: + return "Main Cursor" + } } func (s *Store) save() error { - out := make([]profileJSON, 0, len(s.Profiles)) + byApp := map[string][]Profile{ + AppCursor: {}, + AppClaudeCode: {}, + AppClaudeDesktop: {}, + } for _, p := range s.Profiles { + app := p.App + if app != AppClaudeCode && app != AppClaudeDesktop { + app = AppCursor + } + byApp[app] = append(byApp[app], p) + } + for app, profiles := range byApp { + if err := s.saveApp(app, profiles); err != nil { + return err + } + } + return nil +} + +func (s *Store) saveApp(app string, profiles []Profile) error { + out := make([]profileJSON, 0, len(profiles)) + for _, p := range profiles { out = append(out, p.toJSON()) } data, err := json.MarshalIndent(out, "", " ") if err != nil { return err } - return os.WriteFile(s.metadataPath(), append(data, '\n'), 0o644) + return os.WriteFile(filepath.Join(s.dirForApp(app), metadataName), append(data, '\n'), 0o644) } var palette = []string{ @@ -318,15 +526,27 @@ func (s *Store) uniqueFolderName(base string) string { for _, p := range s.Profiles { existing[strings.ToLower(p.FolderName)] = true } + // Folder names stay globally unique across apps: the live-stats maps + // in every client are keyed by folder name. folder, counter := base, 2 - for strings.ToLower(folder) == systemFolderName || existing[strings.ToLower(folder)] { + lower := strings.ToLower(folder) + for lower == systemFolderName || lower == systemCodeFolderName || + lower == systemDesktopFolderName || existing[lower] { folder = fmt.Sprintf("%s-%d", base, counter) counter++ + lower = strings.ToLower(folder) } return folder } func (s *Store) Create(displayName, colorHex string, memoryMB int, projectPath string) (*Profile, error) { + return s.CreateApp(displayName, AppCursor, colorHex, memoryMB, projectPath) +} + +func (s *Store) CreateApp(displayName, app, colorHex string, memoryMB int, projectPath string) (*Profile, error) { + if app != AppClaudeCode && app != AppClaudeDesktop { + app = AppCursor + } base := sanitizeFolderName(strings.ReplaceAll(displayName, " ", "_")) if base == "" { return nil, errors.New("profile name must contain at least one letter, number, hyphen or underscore") @@ -334,9 +554,13 @@ func (s *Store) Create(displayName, colorHex string, memoryMB int, projectPath s if memoryMB < 512 { memoryMB = 16384 } + if colorHex == "" { + colorHex = accentFor(base) + } p := Profile{ FolderName: s.uniqueFolderName(base), DisplayName: strings.TrimSpace(displayName), + App: app, ColorHex: colorHex, DefaultMemoryMB: memoryMB, CreatedAt: time.Now().UTC(), @@ -348,7 +572,14 @@ func (s *Store) Create(displayName, colorHex string, memoryMB int, projectPath s if err := os.MkdirAll(s.dirFor(p), 0o755); err != nil { return nil, fmt.Errorf("could not create profile folder: %w", err) } - _ = applyTitleBarColor(s.dirFor(p), p.ColorHex) + if app == AppCursor { + _ = applyTitleBarColor(s.dirFor(p), p.ColorHex) + } + // Claude Desktop: seed a fresh profile with the user's MCP/connectors + // config so a new profile starts useful, without copying GBs of cache. + if app == AppClaudeDesktop && !p.IsSystem { + seedDesktopConfig(systemDataDirFor(app), s.dirFor(p)) + } s.Profiles = append(s.Profiles, p) if err := s.save(); err != nil { return nil, err @@ -356,15 +587,29 @@ func (s *Store) Create(displayName, colorHex string, memoryMB int, projectPath s return &s.Profiles[len(s.Profiles)-1], nil } +// seedDesktopConfig copies just claude_desktop_config.json (MCP servers, +// connectors) from the main install into a fresh profile dir. +func seedDesktopConfig(systemDir, profileDir string) { + src, err := os.ReadFile(filepath.Join(systemDir, "claude_desktop_config.json")) + if err != nil { + return + } + dst := filepath.Join(profileDir, "claude_desktop_config.json") + if _, err := os.Stat(dst); err == nil { + return // don't overwrite an adopted profile's own config + } + _ = os.WriteFile(dst, src, 0o644) +} + // Delete moves the profile folder to a timestamped trash directory inside -// ~/.cursor_profiles/.trash (hidden, so it is never re-adopted as a +// its own app dir's .trash (hidden, so it is never re-adopted as a // profile). Recoverable, unlike rm -rf. func (s *Store) Delete(p Profile) error { if p.IsSystem { - return errors.New("the built-in Cursor profile can't be deleted — it's your original Cursor data") + return errors.New("the built-in profile can't be deleted — it's your original app data") } dir := s.dirFor(p) - trash := filepath.Join(s.Dir, ".trash") + trash := filepath.Join(s.dirForApp(p.App), ".trash") if err := os.MkdirAll(trash, 0o755); err != nil { return err } @@ -382,9 +627,35 @@ func (s *Store) Delete(p Profile) error { return s.save() } -// Launch starts Cursor for the profile. Pass newWindow for a running -// profile — without it Cursor just refocuses and the click looks dead. +// Launch starts the right app for the profile. Pass newWindow for a running +// profile — without it the app just refocuses and the click looks dead. +// Claude Code profiles launch the `claude` CLI with CLAUDE_CONFIG_DIR set +// (the documented multi-account mechanism); Claude Desktop and Cursor +// launch with --user-data-dir isolation. func (s *Store) Launch(p Profile, newWindow bool) error { + var err error + switch p.App { + case AppClaudeCode: + err = s.launchCode(p) + case AppClaudeDesktop: + err = s.launchDesktop(p, newWindow) + default: + err = s.launchCursor(p, newWindow) + } + if err != nil { + return err + } + now := time.Now().UTC() + for i := range s.Profiles { + if s.Profiles[i].FolderName == p.FolderName { + s.Profiles[i].LastLaunchedAt = &now + break + } + } + return s.save() +} + +func (s *Store) launchCursor(p Profile, newWindow bool) error { exe, err := findCursor(s.customCursorPath) if err != nil { return err @@ -410,14 +681,67 @@ func (s *Store) Launch(p Profile, newWindow bool) error { if err := cmd.Start(); err != nil { return fmt.Errorf("failed to launch Cursor: %w", err) } - now := time.Now().UTC() - for i := range s.Profiles { - if s.Profiles[i].FolderName == p.FolderName { - s.Profiles[i].LastLaunchedAt = &now - break + return nil +} + +// launchCode prepares a Claude Code profile. The CLI is interactive and +// needs the user's own terminal, so Prism never starts it headless here: +// GUI clients open a terminal with CLAUDE_CONFIG_DIR set (see CodeCommand), +// while the TUI prints the command for the user to run. +func (s *Store) launchCode(p Profile) error { + if _, err := findClaudeCode(s.customCodePath); err != nil { + return err + } + dir := s.dirFor(p) + if !p.IsSystem { + if err := os.MkdirAll(dir, 0o755); err != nil { + return err } } - return s.save() + return nil +} + +// CodeCommand returns the shell command that opens this profile's account: +// portable across sh, bash, zsh and PowerShell callers copy-paste it. +func (s *Store) CodeCommand(p Profile) string { + return fmt.Sprintf("CLAUDE_CONFIG_DIR=%q claude", s.dirFor(p)) +} + +// launchDesktop starts Claude Desktop. Managed profiles pass the Chromium +// --user-data-dir= switch (it must use "=": Chromium doesn't take a +// space-separated value). On Windows only the standalone-installer build +// honors it; the Store build drops launch flags, so it's refused with an +// install hint. +func (s *Store) launchDesktop(p Profile, newWindow bool) error { + exe, err := findClaudeDesktop(s.customDesktopPath) + if err != nil { + return err + } + var args []string + if !p.IsSystem { + if runtime.GOOS == "windows" { + classic, cerr := findClassicDesktop(s.customDesktopPath) + if cerr != nil { + return cerr + } + exe = classic + } + dir := s.dirFor(p) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + args = append(args, "--user-data-dir="+dir) + } + if newWindow { + args = append(args, "--new-window") + } + cmd := exec.Command(exe, args...) + cmd.Stdout, cmd.Stderr = nil, nil + cmd.SysProcAttr = detachAttr() + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to launch Claude Desktop: %w", err) + } + return nil } func findCursor(custom string) (string, error) { @@ -472,17 +796,231 @@ func findCursor(custom string) (string, error) { return "", errors.New("Cursor could not be found. Install it from cursor.sh or set a custom path") } +// findClaudeCode locates the Claude Code CLI: custom path -> PATH. +func findClaudeCode(custom string) (string, error) { + if custom != "" { + if st, err := os.Stat(custom); err == nil && !st.IsDir() { + return custom, nil + } + } + names := []string{"claude"} + if runtime.GOOS == "windows" { + names = []string{"claude.exe", "claude.cmd", "claude"} + } + for _, name := range names { + if path, err := exec.LookPath(name); err == nil { + return path, nil + } + } + home, _ := os.UserHomeDir() + var fallbacks []string + switch runtime.GOOS { + case "darwin": + fallbacks = []string{ + "/usr/local/bin/claude", + "/opt/homebrew/bin/claude", + filepath.Join(home, ".local", "bin", "claude"), + } + case "windows": + if local := os.Getenv("LOCALAPPDATA"); local != "" { + fallbacks = []string{ + filepath.Join(local, "Programs", "Claude", "claude.exe"), + } + } + default: + fallbacks = []string{ + "/usr/bin/claude", + "/usr/local/bin/claude", + filepath.Join(home, ".local", "bin", "claude"), + } + } + for _, c := range fallbacks { + if st, err := os.Stat(c); err == nil && !st.IsDir() { + return c, nil + } + } + return "", errors.New("Claude Code could not be found. Install it (see code.claude.com/docs) or set a custom path") +} + +// findClaudeDesktop locates the Claude Desktop app: custom path -> +// well-known installs -> PATH. +func findClaudeDesktop(custom string) (string, error) { + if custom != "" { + if st, err := os.Stat(custom); err == nil && !st.IsDir() { + return custom, nil + } + } + home, _ := os.UserHomeDir() + var candidates []string + switch runtime.GOOS { + case "darwin": + candidates = []string{ + "/Applications/Claude.app/Contents/MacOS/Claude", + filepath.Join(home, "Applications", "Claude.app", "Contents", "MacOS", "Claude"), + } + case "windows": + local := os.Getenv("LOCALAPPDATA") + programs := os.Getenv("ProgramFiles") + candidates = []string{ + // Classic installer locations FIRST: only real executables + // honor --user-data-dir. The Store alias is a last resort + // (it opens the main app fine, but Windows drops its flags). + filepath.Join(local, "Programs", "Claude", "Claude.exe"), + filepath.Join(local, "AnthropicClaude", "Claude.exe"), + filepath.Join(programs, "Claude", "Claude.exe"), + // Microsoft Store app execution alias (0-byte shim). + filepath.Join(local, "Microsoft", "WindowsApps", "claude-desktop.exe"), + } + default: + candidates = []string{ + "/usr/bin/claude-desktop", + "/usr/local/bin/claude-desktop", + "/opt/Claude/claude-desktop", + filepath.Join(home, ".local", "bin", "claude-desktop"), + } + } + if pathEnv := os.Getenv("PATH"); pathEnv != "" { + name := "claude-desktop" + if runtime.GOOS == "windows" { + name = "claude-desktop.exe" + } + for _, dir := range strings.Split(pathEnv, string(os.PathListSeparator)) { + candidates = append(candidates, filepath.Join(dir, name)) + } + } + for _, c := range candidates { + if c == "" { + continue + } + if st, err := os.Stat(c); err == nil && !st.IsDir() { + return c, nil + } + } + return "", errors.New("Claude Desktop could not be found. Install it from claude.com/download or set a custom path") +} + +// isStoreAlias reports whether exe is the Microsoft Store shim. Kept (and +// tested) because it documents a real quirk: the alias also drops CLI flags +// at activation, so it can only ever open the main app. +func isStoreAlias(exe string) bool { + return strings.EqualFold(filepath.Base(exe), "claude-desktop.exe") && + strings.Contains(strings.ToLower(exe), "windowsapps") +} + +// DesktopDownloadURL is where the standalone Claude Desktop installer lives. +const DesktopDownloadURL = "https://claude.com/download" + +// errDesktopInstallRequired is returned when a managed Desktop profile needs +// the Windows standalone-installer build and only the Store build (or +// nothing) is installed. Verified live: the standalone exe honors +// --user-data-dir and runs alongside the Store build. +var errDesktopInstallRequired = errors.New("separate Claude Desktop profiles need the standalone installer " + + "version of Claude Desktop (the Microsoft Store version can't run extra profiles). " + + "Install it from " + DesktopDownloadURL + " - you can keep the Store version too") + +// findClassicDesktop locates the Windows standalone-installer build: custom +// path (unless it points into the Store package), then the newest +// AnthropicClaude/app-*/claude.exe, then other classic install locations. +func findClassicDesktop(custom string) (string, error) { + isStorePath := func(p string) bool { + return isStoreAlias(p) || strings.Contains(strings.ToLower(p), "windowsapps") + } + if custom != "" && !isStorePath(custom) { + if st, err := os.Stat(custom); err == nil && !st.IsDir() { + return custom, nil + } + } + local := os.Getenv("LOCALAPPDATA") + if local != "" { + root := filepath.Join(local, "AnthropicClaude") + if entries, err := os.ReadDir(root); err == nil { + best, bestVer := "", []int(nil) + for _, e := range entries { + if !e.IsDir() || !strings.HasPrefix(e.Name(), "app-") { + continue + } + exe := filepath.Join(root, e.Name(), "claude.exe") + if _, err := os.Stat(exe); err != nil { + continue + } + v := parseVersion(strings.TrimPrefix(e.Name(), "app-")) + if best == "" || versionLess(bestVer, v) { + best, bestVer = exe, v + } + } + if best != "" { + return best, nil + } + } + } + for _, c := range []string{ + filepath.Join(local, "Programs", "Claude", "Claude.exe"), + filepath.Join(os.Getenv("ProgramFiles"), "Claude", "Claude.exe"), + } { + if st, err := os.Stat(c); err == nil && !st.IsDir() { + return c, nil + } + } + return "", errDesktopInstallRequired +} + +func parseVersion(s string) []int { + var out []int + for _, part := range strings.Split(s, ".") { + n := 0 + for _, ch := range part { + if ch < '0' || ch > '9' { + break + } + n = n*10 + int(ch-'0') + } + out = append(out, n) + } + return out +} + +func versionLess(a, b []int) bool { + for i := 0; i < len(a) || i < len(b); i++ { + var x, y int + if i < len(a) { + x = a[i] + } + if i < len(b) { + y = b[i] + } + if x != y { + return x < y + } + } + return false +} + +// userDataDirArg extracts --user-data-dir from argv in either the +// "--user-data-dir=" or "--user-data-dir " form. +func userDataDirArg(argv []string) (string, bool) { + for i, a := range argv { + if strings.HasPrefix(a, "--user-data-dir=") { + return strings.TrimPrefix(a, "--user-data-dir="), true + } + if a == "--user-data-dir" && i+1 < len(argv) { + return argv[i+1], true + } + } + return "", false +} + // snapshotItem is what crosses into the detector: plain data, no store. type snapshotItem struct { folder string dir string system bool + app string } func snapshotItems(profiles []Profile, dirFor func(Profile) string) []snapshotItem { items := make([]snapshotItem, 0, len(profiles)) for _, p := range profiles { - items = append(items, snapshotItem{folder: p.FolderName, dir: dirFor(p), system: p.IsSystem}) + items = append(items, snapshotItem{folder: p.FolderName, dir: dirFor(p), system: p.IsSystem, app: p.App}) } return items } @@ -529,32 +1067,128 @@ func matchProfiles(items []snapshotItem, processes []procInfo, selfPID int) map[ out := make(map[string][]int32, len(items)) for _, it := range items { var pids []int32 - for _, pr := range processes { - if pr.pid == int32(selfPID) { - continue - } - if !isCursorMain(pr.argv, selfPID) { - continue + switch it.app { + case AppClaudeDesktop: + pids = matchDesktop(it, processes, selfPID) + case AppClaudeCode: + // The CLI is a short-lived terminal process, not a persistent + // GUI app: there is no reliable per-profile process to track + // (no --user-data-dir in argv, env invisible to detectors), + // so Code profiles report their launch history, not liveness. + pids = nil + default: + pids = matchCursor(it, processes, selfPID) + } + out[it.folder] = pids + sort.Slice(pids, func(i, j int) bool { return pids[i] < pids[j] }) + } + return out +} + +func matchCursor(it snapshotItem, processes []procInfo, selfPID int) []int32 { + var pids []int32 + for _, pr := range processes { + if pr.pid == int32(selfPID) { + continue + } + if !isCursorMain(pr.argv, selfPID) { + continue + } + hasUD := false + udValue := "" + for i, a := range pr.argv { + if a == "--user-data-dir" && i+1 < len(pr.argv) { + hasUD = true + udValue = pr.argv[i+1] + break } - hasUD := false - udValue := "" - for i, a := range pr.argv { - if a == "--user-data-dir" && i+1 < len(pr.argv) { - hasUD = true - udValue = pr.argv[i+1] - break - } + } + if it.system { + if !hasUD { + pids = append(pids, pr.pid) } - if it.system { - if !hasUD { - pids = append(pids, pr.pid) - } - } else if hasUD && udValue == it.dir { + } else if hasUD && udValue == it.dir { + pids = append(pids, pr.pid) + } + } + return pids +} + +// isDesktopMain mirrors isCursorMain for Claude Desktop. The bundled +// Claude Code helper is also named claude.exe on Windows, so any path +// containing "claude-code" is rejected — it belongs to a session, not to +// the Desktop app itself. +func isDesktopMain(argv []string, selfPID int) bool { + if len(argv) == 0 || argv[0] == "" { + return false + } + exe := strings.ToLower(filepath.Base(argv[0])) + if strings.Contains(exe, "prism") { + return false + } + joined := strings.Join(argv, " ") + if strings.Contains(strings.ToLower(joined), "claude-code") { + return false + } + var looks bool + switch runtime.GOOS { + case "darwin": + // No bare-`claude` fallback: that name belongs to the Code CLI. + // Desktop on macOS always runs from inside Claude.app. + if i := strings.Index(joined, "Claude.app/Contents/MacOS/Claude"); i >= 0 { + rest := joined[i+len("Claude.app/Contents/MacOS/Claude"):] + looks = rest == "" || strings.HasPrefix(rest, " ") + } + case "windows": + looks = exe == "claude.exe" || exe == "claude-desktop.exe" + default: + // No bare-`claude` fallback here either (Code CLI owns that name). + looks = exe == "claude-desktop" + } + if !looks { + return false + } + for _, a := range argv { + if strings.HasPrefix(a, "--type=") { + return false + } + } + return true +} + +func matchDesktop(it snapshotItem, processes []procInfo, selfPID int) []int32 { + var pids []int32 + seenAnyDesktop := false + for _, pr := range processes { + if pr.pid == int32(selfPID) { + continue + } + if !isDesktopMain(pr.argv, selfPID) { + continue + } + seenAnyDesktop = true + udValue, hasUD := userDataDirArg(pr.argv) + if it.system { + if !hasUD { pids = append(pids, pr.pid) } + } else if hasUD && udValue == it.dir { + pids = append(pids, pr.pid) } - out[it.folder] = pids - sort.Slice(pids, func(i, j int) bool { return pids[i] < pids[j] }) } - return out + if len(pids) == 0 && !it.system && seenAnyDesktop && desktopLockHeld(it.dir) { + // Microsoft Store builds hide their command lines from process + // enumeration, so --user-data-dir matching finds nothing. Electron + // holds a lockfile inside a running user-data-dir: combined with a + // live Desktop process, that identifies the running profile. + pids = append(pids, -1) + } + return pids +} + +// desktopLockHeld reports whether an Electron user-data-dir looks live. +// The -1 sentinel PID is never signaled; it only marks "running". +func desktopLockHeld(dir string) bool { + st, err := os.Stat(filepath.Join(dir, "lockfile")) + return err == nil && !st.IsDir() } diff --git a/tui/store_test.go b/tui/store_test.go index cd5d8df..e2e86c4 100644 --- a/tui/store_test.go +++ b/tui/store_test.go @@ -2,6 +2,9 @@ package main import ( "encoding/json" + "os" + "path/filepath" + "runtime" "strings" "testing" "time" @@ -71,3 +74,140 @@ func TestContrastingForeground(t *testing.T) { t.Fatal("black bg should get light text") } } + +func TestAppDefaultsToCursor(t *testing.T) { + var j profileJSON + if err := json.Unmarshal([]byte(`{"folderName":"x","displayName":"Work"}`), &j); err != nil { + t.Fatal(err) + } + if got := profileFromJSON(j).App; got != AppCursor { + t.Fatalf("missing app = %q, want cursor", got) + } + var j2 profileJSON + if err := json.Unmarshal([]byte(`{"folderName":"y","displayName":"Code","app":"claude-code"}`), &j2); err != nil { + t.Fatal(err) + } + p := profileFromJSON(j2) + if p.App != AppClaudeCode { + t.Fatalf("app = %q, want claude-code", p.App) + } + out, _ := json.Marshal(p.toJSON()) + if !strings.Contains(string(out), `"app":"claude-code"`) { + t.Fatalf("app missing from output: %s", out) + } + if got := (Profile{App: AppClaudeDesktop}).AppLabel(); got != "Desktop" { + t.Fatalf("label = %q", got) + } +} + +func TestDesktopMatch(t *testing.T) { + desktopExe := "/usr/bin/claude-desktop" + if runtime.GOOS == "windows" { + desktopExe = `C:\Program Files\Claude\claude.exe` + } + if runtime.GOOS == "darwin" { + desktopExe = "/Applications/Claude.app/Contents/MacOS/Claude" + } + dir := "/tmp/fake-desktop-profile" + if runtime.GOOS == "windows" { + dir = `C:\tmp\fake-desktop-profile` + } + items := []snapshotItem{ + {folder: "d1", dir: dir, app: AppClaudeDesktop}, + {folder: "sys", dir: "", system: true, app: AppClaudeDesktop}, + {folder: "c1", dir: "/tmp/other", app: AppCursor}, + } + procs := []procInfo{ + {pid: 11, argv: []string{desktopExe, "--user-data-dir", dir}}, + {pid: 12, argv: []string{desktopExe}}, + {pid: 13, argv: []string{desktopExe, "--type=renderer"}}, + } + got := matchProfiles(items, procs, 999) + if len(got["d1"]) != 1 || got["d1"][0] != 11 { + t.Fatalf("desktop profile pids = %v", got["d1"]) + } + if len(got["sys"]) != 1 || got["sys"][0] != 12 { + t.Fatalf("desktop system pids = %v", got["sys"]) + } + if len(got["c1"]) != 0 { + t.Fatalf("cursor profile should not match desktop: %v", got["c1"]) + } +} + +func TestDesktopMainExcludesClaudeCode(t *testing.T) { + bundled := []string{`C:\Users\u\AppData\Roaming\Claude\claude-code\2.1.295\abc\claude.exe`} + if isDesktopMain(bundled, 999) { + t.Fatal("bundled claude-code exe must not count as Desktop") + } + cursorArgv := []string{"/usr/bin/cursor"} + if runtime.GOOS == "windows" { + cursorArgv = []string{`C:\Users\u\AppData\Local\Programs\cursor\Cursor.exe`} + } + if runtime.GOOS == "darwin" { + cursorArgv = []string{"/Applications/Cursor.app/Contents/MacOS/Cursor"} + } + if !isCursorMain(cursorArgv, 999) { + t.Fatal("sanity: cursor main matcher broken") + } +} + +func TestCodeNeverMatches(t *testing.T) { + items := []snapshotItem{{folder: "code1", dir: "/tmp/code", app: AppClaudeCode}} + procs := []procInfo{ + {pid: 21, argv: []string{"/usr/local/bin/claude"}}, + {pid: 22, argv: []string{"/usr/local/bin/claude", "--user-data-dir", "/tmp/code"}}, + } + got := matchProfiles(items, procs, 999) + if len(got["code1"]) != 0 { + t.Fatalf("code profile must never match processes: %v", got["code1"]) + } +} + +func TestIsStoreAlias(t *testing.T) { + if !isStoreAlias(`C:\Users\u\AppData\Local\Microsoft\WindowsApps\claude-desktop.exe`) { + t.Fatal("Store alias not recognized") + } + if isStoreAlias(`C:\Users\u\AppData\Local\Programs\Claude\Claude.exe`) { + t.Fatal("classic install misread as Store alias") + } + if isStoreAlias("") || isStoreAlias("/usr/bin/claude-desktop") { + t.Fatal("non-Windows paths must never count as Store aliases") + } +} + +func TestUserDataDirArg(t *testing.T) { + if v, ok := userDataDirArg([]string{"claude", "--user-data-dir=/p/A"}); !ok || v != "/p/A" { + t.Fatalf("= form: %q %v", v, ok) + } + if v, ok := userDataDirArg([]string{"claude", "--user-data-dir", "/p/B"}); !ok || v != "/p/B" { + t.Fatalf("space form: %q %v", v, ok) + } + if _, ok := userDataDirArg([]string{"claude"}); ok { + t.Fatal("no flag must not match") + } +} + +func TestFindClassicDesktopRejectsStorePath(t *testing.T) { + dir := filepath.Join(t.TempDir(), "WindowsApps", "Claude") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + exe := filepath.Join(dir, "Claude.exe") + if err := os.WriteFile(exe, nil, 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("LOCALAPPDATA", t.TempDir()) + t.Setenv("ProgramFiles", t.TempDir()) + if got, _ := findClassicDesktop(exe); got == exe { + t.Fatal("a Store-package path must not count as the standalone build") + } +} + +func TestVersionLess(t *testing.T) { + if !versionLess(parseVersion("2.19675.1"), parseVersion("2.31226.1")) { + t.Fatal("2.19675.1 should be older than 2.31226.1") + } + if versionLess(parseVersion("2.31226.1"), parseVersion("2.31226.1")) { + t.Fatal("equal versions are not less") + } +} diff --git a/tui/ui.go b/tui/ui.go index 750bf16..08f5add 100644 --- a/tui/ui.go +++ b/tui/ui.go @@ -65,8 +65,8 @@ type model struct { filtering bool formActive bool - formFocus int // 0 name, 1 memory, 2 project - formVals [3]string + formFocus int // 0 name, 1 app, 2 memory, 3 project + formVals [4]string confirmDelete string @@ -166,7 +166,9 @@ func (m *model) visible() []Profile { out := m.profiles[:0:0] for _, p := range m.profiles { if f != "" && !strings.Contains(strings.ToLower(p.DisplayName), f) && - !strings.Contains(strings.ToLower(p.FolderName), f) { + !strings.Contains(strings.ToLower(p.FolderName), f) && + !strings.Contains(strings.ToLower(p.AppLabel()), f) && + !strings.Contains(strings.ToLower(p.App), f) { continue } out = append(out, p) @@ -287,7 +289,7 @@ func (m model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "n": m.formActive = true m.formFocus = 0 - m.formVals = [3]string{"", "16384", ""} + m.formVals = [4]string{"", AppCursor, "16384", ""} m.confirmDelete = "" case "d": m.doDelete() @@ -325,6 +327,25 @@ func (m model) filterKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } func (m model) formKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + // The app field is a picker, not text: space/left/right cycle it. + if m.formFocus == 1 { + switch msg.String() { + case "esc": + m.formActive = false + return m, nil + case "tab", "enter": + m.formFocus++ + return m, nil + case "shift+tab": + m.formFocus = (m.formFocus + 3) % 4 + return m, nil + case " ", "left", "right": + m.formVals[1] = nextApp(m.formVals[1]) + return m, nil + default: + return m, nil + } + } switch msg.String() { case "esc": m.formActive = false @@ -333,14 +354,18 @@ func (m model) formKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.doSaveForm() return m, m.refreshCmd(true) case "tab", "enter": - if m.formFocus == 2 { + if m.formFocus == 3 { m.doSaveForm() return m, m.refreshCmd(true) } m.formFocus++ + // Skip the memory field for Claude apps — it only applies to Cursor. + if m.formFocus == 2 && m.formVals[1] != AppCursor { + m.formFocus++ + } return m, nil case "shift+tab": - m.formFocus = (m.formFocus + 2) % 3 + m.formFocus = (m.formFocus + 3) % 4 return m, nil case "backspace": v := m.formVals[m.formFocus] @@ -360,6 +385,31 @@ func (m model) formKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } } +// appChoices is the new-profile app picker order. Managed Desktop profiles +// launch via --user-data-dir (Windows needs the standalone installer build; +// see errDesktopInstallRequired). +var appChoices = []string{AppCursor, AppClaudeCode, AppClaudeDesktop} + +func nextApp(cur string) string { + for i, a := range appChoices { + if a == cur { + return appChoices[(i+1)%len(appChoices)] + } + } + return appChoices[0] +} + +func appChoiceLabel(app string) string { + switch app { + case AppClaudeCode: + return "Claude Code (CLI)" + case AppClaudeDesktop: + return "Claude Desktop (app)" + default: + return "Cursor (editor)" + } +} + // -- actions --------------------------------------------------------------- func (m *model) profileByFolder(folder string) *Profile { @@ -377,6 +427,17 @@ func (m *model) doLaunch() { return } cp := *p + if cp.App == AppClaudeCode { + // The CLI needs the user's own terminal: prepare the profile dir, + // then hand over the exact command instead of starting headless. + if err := m.store.Launch(cp, false); err != nil { + m.setStatus("Launch failed: " + err.Error()) + return + } + m.setStatus("Run in a terminal: " + m.store.CodeCommand(cp)) + m.profiles = append([]Profile(nil), m.store.Profiles...) + return + } if err := m.store.Launch(cp, len(m.running[cp.FolderName]) > 0); err != nil { m.setStatus("Launch failed: " + err.Error()) return @@ -433,12 +494,20 @@ func (m *model) doSaveForm() { m.setStatus("Give the profile a name") return } - mem, err := strconv.Atoi(strings.TrimSpace(m.formVals[1])) - if err != nil || mem < 512 { - m.setStatus("Memory must be a number >= 512") - return + app := m.formVals[1] + if app != AppClaudeCode && app != AppClaudeDesktop { + app = AppCursor + } + mem := 16384 + if app == AppCursor { + var err error + mem, err = strconv.Atoi(strings.TrimSpace(m.formVals[2])) + if err != nil || mem < 512 { + m.setStatus("Memory must be a number >= 512") + return + } } - if _, err := m.store.Create(name, accentFor(name), mem, strings.TrimSpace(m.formVals[2])); err != nil { + if _, err := m.store.CreateApp(name, app, accentFor(name), mem, strings.TrimSpace(m.formVals[3])); err != nil { m.setStatus("Create failed: " + err.Error()) return } @@ -566,6 +635,9 @@ func profileRow(m model, p Profile, selected bool) string { name := nameStyle.Render(truncate(p.DisplayName, 28)) tags := "" + if p.App == AppClaudeCode || p.App == AppClaudeDesktop { + tags += fg(colDim, false).Render(" [" + p.AppLabel() + "]") + } if p.IsSystem { tags += fg(colDim, false).Render(" [BUILT-IN]") } @@ -588,7 +660,13 @@ func profileRow(m model, p Profile, selected bool) string { var meminfo string var frac float64 - if running { + switch { + case p.App != AppCursor && running: + // Claude apps have no --max-memory cap: show live use only. + meminfo = fg(lipgloss.Color(p.ColorHex), true).Render(formatBytes(live)) + case p.App != AppCursor: + meminfo = fg(colDim, false).Render("no memory cap") + case running: meminfo = fg(lipgloss.Color(p.ColorHex), true).Render(formatBytes(live)) + " " + fg(colDim, false).Render("/ "+formatBytes(limit)+" limit") if limit > 0 { @@ -597,7 +675,7 @@ func profileRow(m model, p Profile, selected bool) string { frac = 1 } } - } else { + default: meminfo = fg(colDim, false).Render(formatBytes(limit) + " limit") } @@ -638,7 +716,7 @@ func (m model) listView() string { } func (m model) formView() string { - titles := []string{"Profile name", "Memory limit (MB)", "Project folder (optional)"} + titles := []string{"Profile name", "App (space switches)", "Memory limit, MB (Cursor only)", "Project folder (optional)"} hints := "tab next · enter next/save · esc cancel" var b strings.Builder b.WriteString("\n " + fg(colText, true).Render("New profile") + "\n\n") @@ -648,6 +726,9 @@ func (m model) formView() string { head = fg(colCyan, true).Render("▸ " + t) } val := m.formVals[i] + if i == 1 { + val = appChoiceLabel(val) + } if i == m.formFocus { val += fg(colCyan, true).Render("█") } diff --git a/windows.Tests/CardTemplateTests.cs b/windows.Tests/CardTemplateTests.cs new file mode 100644 index 0000000..e1da64d --- /dev/null +++ b/windows.Tests/CardTemplateTests.cs @@ -0,0 +1,433 @@ +using System.IO; +using System.Runtime.ExceptionServices; +using System.Text.RegularExpressions; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Markup; +using CursorProfiles; +using Xunit; + +namespace Prism.Tests; + +/// +/// Regression tests for the startup crash where TwoWay Run.Text bindings +/// targeted read-only ProfileVM properties. Run.Text binds TwoWay by +/// default in WPF (unlike TextBlock.Text), so every dynamic Run.Text +/// binding in the card template must be Mode=OneWay. +/// +public sealed class CardTemplateTests +{ + /// + /// Realizes the REAL card DataTemplate headlessly (no visible window) + /// for every app kind and both idle/running states, then forces layout — + /// the same FrameworkTemplate + Measure path the crash stack showed. + /// Pre-fix this throws InvalidOperationException on the first binding. + /// + [Fact] + public void CardTemplate_Realizes_For_All_App_Kinds() + { + OnStaThread(() => + { + var (winXaml, _) = LocateXaml(); + var resources = LoadAppResources(); + Assert.True(resources.Contains("BoolToVis"), "App.xaml resources failed to load"); + + var window = (Window)XamlReader.Parse(Scrub(File.ReadAllText(winXaml))); + foreach (var key in resources.Keys) + window.Resources[key] = resources[key]; + + var cards = (ItemsControl)window.FindName("Cards"); + Assert.NotNull(cards); + var template = (DataTemplate)cards.ItemTemplate; + Assert.NotNull(template); + + int realized = 0; + foreach (var vm in SampleProfiles()) + { + var root = (FrameworkElement)template.LoadContent(); + root.DataContext = vm; + root.Measure(new Size(300, 300)); + root.Arrange(new Rect(new Size(300, 300))); + root.UpdateLayout(); + realized++; + } + Assert.Equal(3, realized); + }); + } + + /// + /// Static guard for the pattern itself: no Run.Text binding in + /// MainWindow.xaml may rely on the TwoWay default. + /// + [Fact] + public void Run_Text_Bindings_Are_OneWay() + { + var (winXaml, _) = LocateXaml(); + var text = File.ReadAllText(winXaml); + var offenders = Regex.Matches(text, @"]*Text=""\{Binding(?![^}]*Mode=OneWay)[^}]*\}""") + .Select(m => m.Value.Trim()) + .ToList(); + Assert.True(offenders.Count == 0, + "Run.Text binds TwoWay by default and must be Mode=OneWay:\n" + string.Join("\n", offenders)); + } + + private static readonly string[] ThemeBrushKeys = + { + "WindowBrush", "CardBrush", "CardBorderBrush", "CardHoverBorderBrush", + "TextBrush", "SubTextBrush", "MutedBrush", "FieldBrush", "TrackBrush", + "AccentBrush", "DangerBrush", "SuccessBrush", + }; + + /// + /// Both theme dictionaries load and expose identical brush keys, so a + /// theme switch can never blank the UI. + /// + [Fact] + public void Theme_Dictionaries_Load_And_Match() + { + var (_, appXaml) = LocateXaml(); + var dir = Path.GetDirectoryName(appXaml)!; + var dark = (ResourceDictionary)XamlReader.Parse( + File.ReadAllText(Path.Combine(dir, "Themes", "Dark.xaml"))); + var light = (ResourceDictionary)XamlReader.Parse( + File.ReadAllText(Path.Combine(dir, "Themes", "Light.xaml"))); + var darkKeys = dark.Keys.Cast().Select(k => k.ToString()).ToHashSet(); + var lightKeys = light.Keys.Cast().Select(k => k.ToString()).ToHashSet(); + Assert.Equal(darkKeys, lightKeys); + foreach (var key in ThemeBrushKeys) + { + Assert.True(darkKeys.Contains(key), $"Dark.xaml missing {key}"); + Assert.IsType(dark[key]); + Assert.IsType(light[key]); + } + } + + /// + /// The card template realizes cleanly under both themes (catches + /// DynamicResource misses and bad theme values, not just bindings). + /// + [Fact] + public void CardTemplate_Realizes_Under_Both_Themes() + { + OnStaThread(() => + { + var (winXaml, appXaml) = LocateXaml(); + var dir = Path.GetDirectoryName(appXaml)!; + var resources = LoadAppResources(); + var window = (Window)XamlReader.Parse(Scrub(File.ReadAllText(winXaml))); + // App styles (Card, buttons) + theme brushes, like the real app. + foreach (var key in resources.Keys) + window.Resources[key] = resources[key]; + var cards = (ItemsControl)window.FindName("Cards"); + var template = (DataTemplate)cards.ItemTemplate; + foreach (var theme in new[] { "Dark.xaml", "Light.xaml" }) + { + // Swap just the theme layer, mirroring ThemeManager. + foreach (var key in ThemeBrushKeys) + window.Resources.Remove(key); + var themeDict = (ResourceDictionary)XamlReader.Parse( + File.ReadAllText(Path.Combine(dir, "Themes", theme))); + window.Resources.MergedDictionaries.Add(themeDict); + foreach (var vm in SampleProfiles()) + { + var root = (FrameworkElement)template.LoadContent(); + root.DataContext = vm; + root.Measure(new Size(300, 300)); + root.Arrange(new Rect(new Size(300, 300))); + root.UpdateLayout(); + } + window.Resources.MergedDictionaries.Clear(); + } + }); + } + + /// + /// ThemeManager must be safe headless (tests, designers): no + /// Application, no window, no throw — and choice parsing is exact. + /// + [Fact] + public void ThemeManager_Resolves_Choices() + { + OnStaThread(() => + { + ThemeManager.ApplyTheme("dark"); + Assert.True(ThemeManager.IsDark); + ThemeManager.ApplyTheme("light"); + Assert.False(ThemeManager.IsDark); + ThemeManager.ApplyTheme("bogus-value"); + Assert.Equal(ThemeManager.SystemWantsDark(), ThemeManager.IsDark); + ThemeManager.ApplyTheme(null); + Assert.Equal(ThemeManager.SystemWantsDark(), ThemeManager.IsDark); + Assert.Equal("dark", ThemeManager.NormalizeChoice("dark")); + Assert.Equal("system", ThemeManager.NormalizeChoice("nope")); + }); + } + + /// + /// The Store alias is detected so isolated launches can be refused with + /// guidance instead of silently opening the existing app; classic + /// installs sort ahead of it in discovery. + /// + [Fact] + public void DesktopCandidates_Prefer_Classic_Over_Store() + { + var candidates = ClaudeLauncher.DesktopCandidatePaths(); + Assert.True(candidates.Length >= 2); + Assert.True(ClaudeLauncher.IsStoreAlias(candidates[^1]), + "Store alias must be the last resort, got: " + candidates[^1]); + foreach (var c in candidates[..^1]) + Assert.False(ClaudeLauncher.IsStoreAlias(c), "false Store positive: " + c); + Assert.False(ClaudeLauncher.IsStoreAlias(null)); + Assert.False(ClaudeLauncher.IsStoreAlias("")); + Assert.True(ClaudeLauncher.IsStoreAlias( + @"C:\Users\u\AppData\Local\Microsoft\WindowsApps\claude-desktop.exe")); + Assert.False(ClaudeLauncher.IsStoreAlias( + @"C:\Users\u\AppData\Local\Programs\Claude\Claude.exe")); + } + + /// + /// Store builds hide command lines from WMI: the built-in Desktop is + /// matched by package exe path, never by the claude-code helper. + /// + [Fact] + public void SystemDesktopPids_Matches_Store_By_ExePath() + { + var list = new List + { + // Store main instance: empty command line, package exe path. + new(11, 100, "", @"C:\Program Files\WindowsApps\Claude_1.0_x64__abc\app\claude.exe"), + // Bundled CLI helper: must never count as Desktop. + new(12, 100, "", @"C:\Users\u\AppData\Roaming\Claude\claude-code\1.0\x\claude.exe"), + // Classic main instance with a visible command line. + new(13, 100, @"C:\Apps\Claude\Claude.exe"), + // Renderer helper: excluded. + new(14, 100, @"C:\Apps\Claude\Claude.exe --type=renderer"), + }; + var pids = ClaudeLauncher.SystemDesktopPids(list); + Assert.Contains(11, pids); + Assert.DoesNotContain(12, pids); + Assert.DoesNotContain(14, pids); + } + + /// + /// Managed Desktop profiles need the standalone build: a custom path + /// pointing at the Store package must not count as one, and a missing + /// install raises the typed exception the UI turns into an installer prompt. + /// + [Fact] + public void FindClassicDesktop_Rejects_Store_Paths() + { + var store = Path.Combine(Path.GetTempPath(), "WindowsApps", "Claude", "Claude.exe"); + Directory.CreateDirectory(Path.GetDirectoryName(store)!); + File.WriteAllText(store, ""); + try + { + Assert.NotEqual(store, ClaudeLauncher.FindClassicDesktop(store)); + } + finally { try { Directory.Delete(Path.Combine(Path.GetTempPath(), "WindowsApps"), true); } catch { } } + } + + [Fact] + public void DesktopPids_Matches_Equals_Form_Of_Flag() + { + var list = new List + { + new(1, 0, @"C:\x\claude.exe --user-data-dir=C:\p\A", ""), + new(2, 0, @"C:\x\claude.exe --type=renderer --user-data-dir=C:\p\A", ""), + new(3, 0, @"C:\x\claude.exe --user-data-dir=C:\p\B", ""), + }; + Assert.Equal(new[] { 1 }, ClaudeLauncher.DesktopPids(list, @"C:\p\A")); + } + + /// + /// The New-Profile editor must construct without showing: its app-picker + /// SelectionChanged handler used to throw a NullReferenceException during + /// InitializeComponent (sibling fields don't exist yet). Uses temp profile + /// dirs so the real home directory is untouched. + /// + [Fact] + public void ProfileEditorWindow_Constructs_In_Create_Mode() + { + OnStaThread(() => + { + var store = NewTempStore(); + var dlg = new ProfileEditorWindow(store, null); + dlg.Close(); + }); + } + + [Fact] + public void ProfileEditorWindow_Constructs_In_Edit_Mode() + { + OnStaThread(() => + { + var store = NewTempStore(); + var vm = new ProfileVM(new CursorProfile + { + FolderName = "Work", + DisplayName = "Work", + App = CursorProfile.AppClaudeCode, + ColorHex = "#10B981", + DefaultMemoryMB = 16384, + }); + var dlg = new ProfileEditorWindow(store, vm); + dlg.Close(); + }); + } + + [Fact] + public void Settings_And_LaunchOptions_Windows_Construct() + { + OnStaThread(() => + { + var store = NewTempStore(); + var vm = new ProfileVM(new CursorProfile + { + FolderName = "Desk", + DisplayName = "Desk", + App = CursorProfile.AppClaudeDesktop, + ColorHex = "#3B82F6", + DefaultMemoryMB = 16384, + }); + new SettingsWindow(store).Close(); + new LaunchOptionsWindow(store, vm).Close(); + }); + } + + /// A store rooted at temp dirs: Reload/constructors never touch home. + private static ProfileStore NewTempStore() + { + var root = Path.Combine(Path.GetTempPath(), "Prism.Tests", Guid.NewGuid().ToString("N")); + return new ProfileStore( + profilesDir: Path.Combine(root, "cursor"), + codeDir: Path.Combine(root, "code"), + desktopDir: Path.Combine(root, "desktop")); + } + + private static ProfileVM[] SampleProfiles() + { + var cursor = new ProfileVM(new CursorProfile + { + FolderName = "Work", + DisplayName = "Work", + App = CursorProfile.AppCursor, + ColorHex = "#6366F1", + DefaultMemoryMB = 16384, + }); + var code = new ProfileVM(new CursorProfile + { + FolderName = "CodeWork", + DisplayName = "Code Work", + App = CursorProfile.AppClaudeCode, + ColorHex = "#10B981", + DefaultMemoryMB = 16384, + }); + // Sentinel PID (Store builds) exercises the no-PID display path. + code.RunningPids = new List { -1 }; + code.LiveBytes = 2048; + var desktop = new ProfileVM(new CursorProfile + { + FolderName = CursorProfile.SystemDesktopFolderName, + DisplayName = "Main Claude Desktop", + App = CursorProfile.AppClaudeDesktop, + ColorHex = "#3B82F6", + IsPinned = true, + IsSystem = true, + }); + desktop.RunningPids = new List { 1234 }; + desktop.LiveBytes = 1024 * 1024; + return new[] { cursor, code, desktop }; + } + + /// Finds windows/MainWindow.xaml by walking up from the test bin dir. + private static (string winXaml, string appXaml) LocateXaml() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + for (int i = 0; i < 8 && dir != null; i++, dir = dir.Parent) + { + var win = Path.Combine(dir.FullName, "windows", "MainWindow.xaml"); + var app = Path.Combine(dir.FullName, "windows", "App.xaml"); + if (File.Exists(win) && File.Exists(app)) + return (win, app); + } + throw new FileNotFoundException( + "Could not locate windows/MainWindow.xaml from " + AppContext.BaseDirectory); + } + + /// + /// Loads App.xaml's resources WITHOUT instantiating an Application + /// (only one may exist per AppDomain, and tests must stay independent): + /// the inner Application.Resources block is parsed as a bare dictionary. + /// + private static ResourceDictionary LoadAppResources() + { + var (_, appXaml) = LocateXaml(); + var text = File.ReadAllText(appXaml); + const string open = ""; + const string close = ""; + int start = text.IndexOf(open, StringComparison.Ordinal); + int end = text.IndexOf(close, StringComparison.Ordinal); + Assert.True(start >= 0 && end > start, "App.xaml has no Application.Resources block"); + var inner = text.Substring(start + open.Length, end - (start + open.Length)); + var wrapped = "" + + inner + ""; + return (ResourceDictionary)XamlReader.Parse(wrapped); + } + + /// XamlReader has no code-behind: drop class hooks and event handlers. + private static string Scrub(string xaml) + { + xaml = Regex.Replace(xaml, "\\sx:Class=\"[^\"]*\"", ""); + xaml = Regex.Replace(xaml, "\\sStartupUri=\"[^\"]*\"", ""); + xaml = Regex.Replace(xaml, "\\s(Click|TextChanged|ContextMenuOpening|MouseLeftButtonDown|Drop|DragOver|Checked|Unchecked|SelectionChanged|Toggled)=\"[^\"]*\"", ""); + return xaml; + } + + /// + /// One STA thread + one session Application for the whole run (WPF + /// objects are thread-affine, and only one Application may exist per + /// AppDomain). The session app carries the real App resources + theme, + /// exactly like production startup, so compiled dialog BAML resolves. + /// + private static readonly Lazy StaDispatcher = new(() => + { + System.Windows.Threading.Dispatcher? dispatcher = null; + var ready = new ManualResetEventSlim(); + var thread = new Thread(() => + { + dispatcher = System.Windows.Threading.Dispatcher.CurrentDispatcher; + var app = new Application(); + var appResources = LoadAppResources(); + foreach (var key in appResources.Keys) + app.Resources[key] = appResources[key]; + ThemeManager.ApplyTheme(ThemeManager.System); + ready.Set(); + System.Windows.Threading.Dispatcher.Run(); + }); + thread.SetApartmentState(ApartmentState.STA); + thread.IsBackground = true; + thread.Start(); + ready.Wait(); + return dispatcher!; + }); + + /// xUnit runs MTA; all WPF work marshals to the session STA thread. + private static void OnStaThread(Action action) + { + Exception? captured = null; + StaDispatcher.Value.Invoke(() => + { + try + { + action(); + } + catch (Exception ex) + { + captured = ex; + } + }); + if (captured is not null) + ExceptionDispatchInfo.Capture(captured).Throw(); + } +} diff --git a/windows.Tests/Prism.Tests.csproj b/windows.Tests/Prism.Tests.csproj new file mode 100644 index 0000000..f36b0aa --- /dev/null +++ b/windows.Tests/Prism.Tests.csproj @@ -0,0 +1,24 @@ + + + + net8.0-windows + true + enable + enable + false + Prism.Tests + + true + + + + + + + + + + + + + diff --git a/windows/App.xaml b/windows/App.xaml index f4b08cd..fa2cb1b 100644 --- a/windows/App.xaml +++ b/windows/App.xaml @@ -4,32 +4,23 @@ StartupUri="MainWindow.xaml"> - - - - - - - - - - - - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/windows/App.xaml.cs b/windows/App.xaml.cs index 19ac3a8..7a77d80 100644 --- a/windows/App.xaml.cs +++ b/windows/App.xaml.cs @@ -1,6 +1,4 @@ -using System.Runtime.InteropServices; using System.Windows; -using System.Windows.Interop; namespace CursorProfiles; @@ -52,22 +50,3 @@ public static void Report(string source, Exception? ex) catch { /* logging must never crash the crash reporter */ } } } - -/// Turns on the dark (immersive) title bar for a window on Windows 10 20H1+. -public static class DarkTitleBar -{ - [DllImport("dwmapi.dll")] - private static extern int DwmSetWindowAttribute(IntPtr hwnd, int attr, ref int value, int size); - - public static void Apply(Window window) - { - window.SourceInitialized += (_, _) => - { - var hwnd = new WindowInteropHelper(window).Handle; - int on = 1; - // 20 = DWMWA_USE_IMMERSIVE_DARK_MODE (19 on older builds) - _ = DwmSetWindowAttribute(hwnd, 20, ref on, sizeof(int)); - _ = DwmSetWindowAttribute(hwnd, 19, ref on, sizeof(int)); - }; - } -} diff --git a/windows/ClaudeLauncher.cs b/windows/ClaudeLauncher.cs new file mode 100644 index 0000000..e77dfc3 --- /dev/null +++ b/windows/ClaudeLauncher.cs @@ -0,0 +1,294 @@ +using System.Diagnostics; +using System.IO; +using System.Management; + +namespace CursorProfiles; + +/// +/// Finding & launching Claude Code (CLI, via CLAUDE_CONFIG_DIR) and +/// Claude Desktop (Electron app, via --user-data-dir). Mirrors the documented +/// mechanisms: CLAUDE_CONFIG_DIR is the official multi-account switch, and +/// --user-data-dir is the standard Electron isolation flag. +/// +public static class ClaudeLauncher +{ + public static string[] DesktopCandidatePaths() + { + var local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var programs = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + return new[] + { + // Classic installer locations FIRST: only real executables honor + // --user-data-dir. The Store alias is a last resort (it opens the + // main app fine, but Windows drops its launch flags). + Path.Combine(local, "Programs", "Claude", "Claude.exe"), + Path.Combine(local, "AnthropicClaude", "Claude.exe"), + Path.Combine(programs, "Claude", "Claude.exe"), + // Microsoft Store app execution alias (0-byte shim). + Path.Combine(local, "Microsoft", "WindowsApps", "claude-desktop.exe"), + }; + } + + /// + /// True when exe is the Store alias. Verified on-machine: launching it + /// with --user-data-dir leaves the target dir empty — Windows activates + /// the existing instance and drops the flags. Isolated Store launches are + /// therefore refused (see LaunchDesktop); only the main profile may use it. + /// + public static bool IsStoreAlias(string? exePath) => + !string.IsNullOrWhiteSpace(exePath) + && Path.GetFileName(exePath).Equals("claude-desktop.exe", StringComparison.OrdinalIgnoreCase) + && exePath.Contains("WindowsApps", StringComparison.OrdinalIgnoreCase); + + /// Locate the Claude Code CLI: custom path -> PATH (claude.exe/.cmd). + public static string? FindClaudeCode(string? customPath) + { + if (!string.IsNullOrWhiteSpace(customPath) && File.Exists(customPath)) + return customPath; + + foreach (var dir in (Environment.GetEnvironmentVariable("PATH") ?? "").Split(';')) + { + if (string.IsNullOrWhiteSpace(dir)) continue; + try + { + foreach (var name in new[] { "claude.exe", "claude.cmd", "claude" }) + { + var p = Path.Combine(dir.Trim(), name); + if (File.Exists(p)) return p; + } + } + catch { /* malformed PATH entry */ } + } + return null; + } + + /// Locate Claude Desktop: custom path -> well-known installs -> PATH. + public static string? FindClaudeDesktop(string? customPath) + { + if (!string.IsNullOrWhiteSpace(customPath) && File.Exists(customPath)) + return customPath; + + foreach (var p in DesktopCandidatePaths()) + if (File.Exists(p)) return p; + + foreach (var dir in (Environment.GetEnvironmentVariable("PATH") ?? "").Split(';')) + { + if (string.IsNullOrWhiteSpace(dir)) continue; + try + { + var p = Path.Combine(dir.Trim(), "claude-desktop.exe"); + if (File.Exists(p)) return p; + } + catch { /* malformed PATH entry */ } + } + return null; + } + + /// + /// Open a terminal running the Claude Code CLI with this profile as its + /// config dir. The CLI is interactive and needs its own console, so it is + /// launched in a new window rather than headless. First launch walks + /// through login for the new account. + /// + public static void LaunchCode(string profileDir, string displayName, + string? codePath, string? projectPath, bool isSystem) + { + var exe = FindClaudeCode(codePath) + ?? throw new InvalidOperationException( + "Claude Code could not be found. Install it (see code.claude.com/docs) or set a custom path in Settings."); + + if (!isSystem) Directory.CreateDirectory(profileDir); + + var workDir = !string.IsNullOrWhiteSpace(projectPath) && Directory.Exists(projectPath) + ? projectPath + : Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + // cmd /k keeps the window open so login prompts stay visible. + var inner = $"set \"CLAUDE_CONFIG_DIR={profileDir}\" && \"{exe}\""; + var psi = new ProcessStartInfo("cmd.exe", + $"/c start \"Prism - {displayName}\" /D \"{workDir}\" cmd.exe /k \"{inner}\"") + { + UseShellExecute = false, + CreateNoWindow = true, + }; + Process.Start(psi); + } + + /// + /// Copy-pasteable command for the profile (shown when the CLI is missing + /// or for terminal users). + /// + public static string CodeCommand(string profileDir) => + $"set \"CLAUDE_CONFIG_DIR={profileDir}\" && claude"; + + /// Launch Claude Desktop. Pass userDataDir = null for the built-in profile. + /// + /// Newest classic-installer (Squirrel) build under AnthropicClaudepp-*, + /// or null. Only unpackaged executables honor --user-data-dir; verified + /// on-machine: it starts a fully separate instance (own login, own data) + /// alongside the Store build. + /// + public static string? FindClassicDesktop(string? customPath) + { + if (!string.IsNullOrWhiteSpace(customPath) && File.Exists(customPath) && !IsStoreAlias(customPath) + && !customPath.Contains("WindowsApps", StringComparison.OrdinalIgnoreCase)) + return customPath; + + var local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + try + { + var root = Path.Combine(local, "AnthropicClaude"); + if (Directory.Exists(root)) + { + var newest = Directory.GetDirectories(root, "app-*") + .Select(d => (dir: d, ver: ParseVersion(Path.GetFileName(d)[4..]))) + .Where(t => File.Exists(Path.Combine(t.dir, "claude.exe"))) + .OrderByDescending(t => t.ver).FirstOrDefault(); + if (newest.dir is not null) return Path.Combine(newest.dir, "claude.exe"); + } + } + catch { /* unreadable install dir */ } + + foreach (var p in DesktopCandidatePaths()) + if (!IsStoreAlias(p) && File.Exists(p)) return p; + return null; + } + + static Version ParseVersion(string s) => Version.TryParse(s, out var v) ? v : new Version(0, 0); + + public const string DownloadUrl = "https://claude.com/download"; + + /// + /// Launch Claude Desktop. profileDir = null opens the built-in Main + /// profile with whichever install exists. A managed profile needs the + /// classic build and is started with its own --user-data-dir, so any + /// number of profiles can run side by side. + /// + public static void LaunchDesktop(string? profileDir, string? desktopPath, bool newWindow) + { + string exe; + if (profileDir is null) + { + exe = FindClaudeDesktop(desktopPath) + ?? throw new DesktopInstallRequiredException( + "Claude Desktop isn't installed. Install it from claude.com/download."); + } + else + { + exe = FindClassicDesktop(desktopPath) + ?? throw new DesktopInstallRequiredException( + "Separate Claude Desktop profiles need the standalone installer version of Claude Desktop " + + "(the Microsoft Store version can't run extra profiles). Install it from claude.com/download " + + "— you can keep the Store version too."); + } + + var psi = new ProcessStartInfo(exe) { UseShellExecute = false }; + if (profileDir is not null) + { + Directory.CreateDirectory(profileDir); + psi.ArgumentList.Add($"--user-data-dir={profileDir}"); + } + if (newWindow) psi.ArgumentList.Add("--new-window"); + Process.Start(psi); + } + + /// + /// One WMI snapshot of Claude Desktop processes. The bundled Claude Code + /// helper shares the claude.exe name, so callers must exclude + /// claude-code paths (see IsDesktopMain). ExecutablePath is captured + /// because Store builds hide their command lines entirely. + /// + public static List DesktopProcessList() + { + var result = new List(); + try + { + using var searcher = new ManagementObjectSearcher( + "SELECT ProcessId, CommandLine, WorkingSetSize, ExecutablePath FROM Win32_Process " + + "WHERE Name = 'claude.exe' OR Name = 'claude-desktop.exe'"); + foreach (var obj in searcher.Get()) + { + var pid = Convert.ToInt32(obj["ProcessId"]); + var cmd = obj["CommandLine"]?.ToString() ?? ""; + var exe = obj["ExecutablePath"]?.ToString() ?? ""; + long ws = 0; + try { ws = Convert.ToInt64(obj["WorkingSetSize"]); } catch { /* treat as 0 */ } + result.Add(new ProcessEntry(pid, ws, cmd, exe)); + } + } + catch { /* WMI unavailable — treat as nothing running */ } + return result; + } + + /// + /// True for the Desktop main executable. Rejects the bundled claude-code + /// helper (same exe name, lives under a claude-code path) and requires a + /// token boundary so helpers don't match. + /// + /// Residual risk: a standalone `claude` CLI named claude.exe running + /// without --user-data-dir can light up the built-in Desktop as running. + /// Managed profiles are unaffected (their --user-data-dir match is exact, + /// and the CLI never takes that flag). + /// + public static bool IsDesktopMain(string commandLine) + { + if (string.IsNullOrWhiteSpace(commandLine)) return false; + if (commandLine.Contains("claude-code", StringComparison.OrdinalIgnoreCase)) + return false; + if (commandLine.Contains("--type=", StringComparison.Ordinal)) return false; + return true; + } + + /// Main Desktop process for a managed profile (matched by --user-data-dir). + public static List DesktopPids(List list, string profileDir) => + list.Where(p => IsDesktopMain(p.CommandLine) && + (p.CommandLine.Contains($"--user-data-dir={profileDir}", StringComparison.OrdinalIgnoreCase) + || p.CommandLine.Contains($"--user-data-dir=\"{profileDir}\"", StringComparison.OrdinalIgnoreCase) + || p.CommandLine.Contains($"--user-data-dir {profileDir}", StringComparison.OrdinalIgnoreCase) + || p.CommandLine.Contains($"--user-data-dir \"{profileDir}\"", StringComparison.OrdinalIgnoreCase))) + .Select(p => p.Pid).ToList(); + + /// Main Desktop process running the built-in profile. + public static List SystemDesktopPids(List list) => + list.Where(p => !string.IsNullOrWhiteSpace(p.CommandLine) + ? IsDesktopMain(p.CommandLine) && !p.CommandLine.Contains("--user-data-dir") + : IsStoreDesktopExe(p.ExePath)) + .Select(p => p.Pid).ToList(); + + /// + /// Store builds report empty command lines via WMI; match by package exe + /// path instead. The bundled claude-code helper is excluded by path, and + /// a standalone CLI never lives under WindowsApps. + /// + public static bool IsStoreDesktopExe(string? exePath) => + !string.IsNullOrWhiteSpace(exePath) + && exePath.Contains("windowsapps", StringComparison.OrdinalIgnoreCase) + && !exePath.Contains("claude-code", StringComparison.OrdinalIgnoreCase); + + /// + /// Electron holds a lockfile inside a running user-data-dir. Combined with + /// a live Desktop process this identifies the running profile when the + /// Store build hides command lines from WMI (they come back empty). + /// + public static bool DesktopLockHeld(string profileDir) + { + try { return File.Exists(Path.Combine(profileDir, "lockfile")); } + catch { return false; } + } + + /// Graceful quit: ask each main window to close. Skips sentinel PIDs. + public static void Quit(IEnumerable pids) + { + foreach (var pid in pids) + { + if (pid <= 0) continue; + try { Process.GetProcessById(pid).CloseMainWindow(); } + catch { /* already gone */ } + } + } +} + +/// Thrown when the standalone Claude Desktop installer build is required but missing. +public class DesktopInstallRequiredException : InvalidOperationException +{ + public DesktopInstallRequiredException(string message) : base(message) { } +} diff --git a/windows/CursorLauncher.cs b/windows/CursorLauncher.cs index 20ded66..ea0a9dc 100644 --- a/windows/CursorLauncher.cs +++ b/windows/CursorLauncher.cs @@ -5,7 +5,7 @@ namespace CursorProfiles; -public record ProcessEntry(int Pid, long WorkingSetBytes, string CommandLine); +public record ProcessEntry(int Pid, long WorkingSetBytes, string CommandLine, string ExePath = ""); public static class CursorLauncher { @@ -138,11 +138,12 @@ public static List SystemProfilePids(List list) => && !p.CommandLine.Contains("--user-data-dir")) .Select(p => p.Pid).ToList(); - /// Graceful quit: ask each main window to close. + /// Graceful quit: ask each main window to close. Skips sentinel PIDs. public static void Quit(IEnumerable pids) { foreach (var pid in pids) { + if (pid <= 0) continue; try { Process.GetProcessById(pid).CloseMainWindow(); } catch { /* already gone */ } } diff --git a/windows/LaunchOptionsWindow.xaml b/windows/LaunchOptionsWindow.xaml index e32a8be..5911990 100644 --- a/windows/LaunchOptionsWindow.xaml +++ b/windows/LaunchOptionsWindow.xaml @@ -3,14 +3,14 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="Launch Options" Width="420" SizeToContent="Height" ResizeMode="NoResize" WindowStartupLocation="CenterOwner" - Background="{StaticResource WindowBrush}" FontFamily="Segoe UI"> + Background="{DynamicResource WindowBrush}" FontFamily="Segoe UI"> + Foreground="{DynamicResource SubTextBrush}"/> @@ -23,7 +23,7 @@ + Foreground="{DynamicResource TextBrush}"/>