- 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