Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ jobs:
dotnet-version: 8.0.x
- name: Build
run: dotnet build windows/CursorProfiles.csproj -c Release
- name: Test (card template + dialog regression tests)
run: dotnet test windows.Tests/Prism.Tests.csproj -c Release
- name: Publish self-contained single-file exe
# The shippable artifact: runtime + deps bundled, one double-clickable file.
run: dotnet publish windows/CursorProfiles.csproj -c Release -r win-x64 --self-contained -o publish/win-x64 /p:PublishSingleFile=true
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,6 @@ pypi/prism-tui/dist/
pypi/prism-tui/*.egg-info
tui/prism-tui
tui/prism-tui.exe
publish/
windows.Tests/bin/
windows.Tests/obj/
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<h1 align="center">Prism</h1>

<p align="center">
<strong>One launcher for every Cursor you run.</strong><br />
<strong>One launcher for every Cursor you run — plus Claude Code and Claude Desktop.</strong><br />
Isolated profiles — own settings, extensions, login, chat history — with live stats,<br />
instant switching, and per-profile title bar colors so you never mix up Work and Personal again.
</p>
Expand Down Expand Up @@ -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 <profile> --max-memory <limit>`, 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 <profile> --max-memory <limit>`, 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=<profile>` 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

Expand All @@ -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
Expand Down
200 changes: 200 additions & 0 deletions Sources/CursorProfiles/ClaudeLauncher.swift
Original file line number Diff line number Diff line change
@@ -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=<path>` 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 <path>` 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) }
}
}
30 changes: 24 additions & 6 deletions Sources/CursorProfiles/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
Expand Down Expand Up @@ -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)
}
}

Expand All @@ -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
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion Sources/CursorProfiles/CursorLauncher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
}
}
14 changes: 11 additions & 3 deletions Sources/CursorProfiles/CursorProfilesApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
Loading
Loading