From fd13f2704eacc1ee8ee9dce8ac1ba924ac667cc8 Mon Sep 17 00:00:00 2001 From: bdeshi Date: Tue, 4 Aug 2026 21:56:22 +0600 Subject: [PATCH] Rewrite shannoncoat as a native Swift menu-bar app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the original shannoncoat.sh entirely with in-process Swift. The script stopped earning its keep once the rest went native, and being a persistent process rather than a one-shot CLI is what later makes live window tracking possible at all. - ProfileStore: JSON config at ~/.shannoncoat/.json (was hand-rolled YAML), with ~ expansion, paths relative to the config dir, and name/dir-collision validation in one place. - ProcessInspector: sysctl(KERN_PROC_ALL/KERN_PROCARGS2) enumeration instead of shelling out to pgrep. - ClaudeControl: launch via Process, focus via direct Accessibility calls (unhide, un-minimize, poll-for-window, AXRaise) instead of AppleScript, and a quit that respects Claude's own termination handling rather than unconditionally SIGKILLing after a flat 5s timeout. - LiveState: NSWorkspace notification-driven state with no polling timer, so the menu bar reflects reality within about a second — including changes made outside the app entirely. - ManageWindow: one non-modal window replacing what would otherwise be a string of separate popup alerts, with inline add/remove and directory pickers. - LaunchAtLogin / UpdateChecker: SMAppService login-item toggle, and a minimal VERSION-file update check that alerts on a manual check and posts a quiet notification on the automatic one. Also drops the standalone CLI in favour of GUI-only, and adds a placeholder app icon drawn from vector shapes rather than composited on top of Claude's own icon assets. Claude Desktop is located at runtime rather than assumed: a path the user picked previously, then /Applications, then a per-user ~/Applications install, each accepted only if it actually contains the executable. If none match, launching a profile asks the user to locate it once and remembers the answer — a launch that silently does nothing gives them no way to work out what's wrong. Co-Authored-By: Claude Opus 5 --- .github/workflows/release.yml | 41 +++ .github/workflows/tag-on-version-bump.yml | 49 +++ .gitignore | 141 ++++++++ Sources/AppDelegate.swift | 292 ++++++++++++++++ Sources/ClaudeControl.swift | 240 +++++++++++++ Sources/LaunchAtLogin.swift | 21 ++ Sources/LiveState.swift | 87 +++++ Sources/ManageWindow.swift | 390 ++++++++++++++++++++++ Sources/ProcessInspector.swift | 86 +++++ Sources/ProfileColor.swift | 39 +++ Sources/ProfileStore.swift | 180 ++++++++++ Sources/UpdateChecker.swift | 90 +++++ Sources/main.swift | 10 + VERSION | 1 + build.sh | 60 ++++ icon/AppIcon.icns | Bin 0 -> 112936 bytes icon/generate-icon.swift | 115 +++++++ shannoncoat.sh | 182 ---------- 18 files changed, 1842 insertions(+), 182 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/tag-on-version-bump.yml create mode 100644 .gitignore create mode 100644 Sources/AppDelegate.swift create mode 100644 Sources/ClaudeControl.swift create mode 100644 Sources/LaunchAtLogin.swift create mode 100644 Sources/LiveState.swift create mode 100644 Sources/ManageWindow.swift create mode 100644 Sources/ProcessInspector.swift create mode 100644 Sources/ProfileColor.swift create mode 100644 Sources/ProfileStore.swift create mode 100644 Sources/UpdateChecker.swift create mode 100644 Sources/main.swift create mode 100644 VERSION create mode 100755 build.sh create mode 100644 icon/AppIcon.icns create mode 100644 icon/generate-icon.swift delete mode 100755 shannoncoat.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..270c171 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,41 @@ +name: Release + +# Two ways in: a manually pushed version tag, or a direct call from +# tag-on-version-bump.yml (which fires on VERSION changes on main). The +# workflow_call path exists because pushes made with the default +# GITHUB_TOKEN don't trigger other workflows' `on.push` — a tag pushed by +# that workflow wouldn't reach this one's `tags:` trigger on its own. +on: + push: + tags: + - "*" + workflow_call: + inputs: + tag: + required: true + type: string + +permissions: + contents: write + +jobs: + build: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag || github.ref }} + + - name: Build shannoncoat.app + run: ./build.sh + + - name: Zip app bundle + run: ditto -c -k --sequesterRsrc --keepParent ".build/shannoncoat.app" "shannoncoat.app.zip" + + - name: Publish release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ inputs.tag || github.ref_name }} + files: | + shannoncoat.app.zip + generate_release_notes: true diff --git a/.github/workflows/tag-on-version-bump.yml b/.github/workflows/tag-on-version-bump.yml new file mode 100644 index 0000000..0054ebc --- /dev/null +++ b/.github/workflows/tag-on-version-bump.yml @@ -0,0 +1,49 @@ +name: Tag on version bump + +on: + push: + branches: [main] + paths: ["VERSION"] + +permissions: + contents: write + +jobs: + tag: + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.tag.outputs.tag }} + created: ${{ steps.tag.outputs.created }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # Guards against re-tagging on a no-op push (e.g. VERSION edited then + # reverted to the same value in a later commit) — only pushes a tag + # that doesn't exist yet. + - name: Create tag if new + id: tag + run: | + VERSION="$(tr -d '[:space:]' < VERSION)" + TAG="$VERSION" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "Tag $TAG already exists, skipping." + echo "created=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -a "$TAG" -m "Release $TAG" + git push origin "$TAG" + echo "created=true" >> "$GITHUB_OUTPUT" + + release: + needs: tag + if: needs.tag.outputs.created == 'true' + uses: ./.github/workflows/release.yml + with: + tag: ${{ needs.tag.outputs.tag }} + permissions: + contents: write diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ee98377 --- /dev/null +++ b/.gitignore @@ -0,0 +1,141 @@ +# Icon generation: raw Claude.app icon assets extracted as source material +# for icon/generate-icon.swift — the derived composite (icon/AppIcon.icns) +# is committed, but Anthropic's own unmodified icon files aren't. +icon/claude-src/ +icon/AppIcon.iconset/ + +# Local reference material (e.g. nested checkouts of other projects) — not +# part of this project, never meant to be committed. +.scratch/ + +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## User settings +xcuserdata/ + +## Obj-C/Swift specific +*.hmap + +## App packaging +*.ipa +*.dSYM.zip +*.dSYM + +## Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Swift Package Manager +# +# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. +# Packages/ +# Package.pins +# Package.resolved +# *.xcodeproj +# +# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata +# hence it is not needed unless you have added a package configuration file to your project +# .swiftpm + +.build/ + +# CocoaPods +# +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# +# Pods/ +# +# Add this line if you want to avoid checking in source code from the Xcode workspace +# *.xcworkspace + +# Carthage +# +# Add this line if you want to avoid checking in source code from Carthage dependencies. +# Carthage/Checkouts + +Carthage/Build/ + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. +# Instead, use fastlane to re-generate the screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/#source-control + +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output + +# AI agents and assistants +# +# Some common agent instruction and project configuration files are listed +# below as commented-out examples. They are often intentionally committed and +# shared with a team, so only uncomment them if they are local-only in your +# project. + +# GEMINI.md +# WARP.md +# CRUSH.md +# QWEN.md + +# OpenAI Codex +# AGENTS.md +# .codex/ + +# Aider +# .aider.input.history +# .aider.chat.history.md +# .aider.llm.history +# .aider.tags.cache.v* +# .aiderignore + +# Claude Code +.claude/*.local.json +.claude/**/*.log +CLAUDE.local.md +# .claude/ + +# Gemini CLI +# gemini-debug.log +# .gemini-clipboard/ +# .gemini/ + +# Cursor AI +# .cursorrules +# .cursor/ +# .cursor.json +# .cursor-settings.yaml + +# Continue +# .continue/ +# .continuerc.json + +# Cline +# .cline/ +# .clinerules +# cline.json + +# Other agent/editor project config +# .warp/ +# .crush/ +# .codeium/ +# .deepseek/ +# .amazon-codewhisperer/ +# .tabnineignore +# .tabnine/ + +# GitHub Copilot +# .github/copilot-instructions.md + +# Windsurf Editor +# .windsurfrules +# .windsurf/ + +# Replit AI Development +# .replit +# replit.nix diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift new file mode 100644 index 0000000..45418f6 --- /dev/null +++ b/Sources/AppDelegate.swift @@ -0,0 +1,292 @@ +// Menu bar behavior: title dots, per-profile menu, Manage Profiles window. +// Reads live state from LiveState (push-based, no polling) and calls +// ProfileStore/ClaudeControl in-process — no subprocess, no text parsing. +import AppKit + +private let author = "bdeshi" +private let homepage = "https://github.com/bdeshi/shannoncoat" + +final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveStateDelegate { + let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + let menu = NSMenu() + let liveState = LiveState() + private var currentInfos: [ProfileInfo] = [] + private var collisionAlertShown = false + + func applicationDidFinishLaunching(_ notification: Notification) { + menu.delegate = self + menu.autoenablesItems = false + statusItem.menu = menu + statusItem.button?.image = menuBarIcon() + statusItem.button?.imagePosition = .imageLeft + + liveState.delegate = self + ManageWindowController.shared.onProfilesChanged = { [weak self] in self?.liveState.reconcile() } + liveState.reconcile() + + // Launching this app fresh with nothing running means there's no + // menu-bar title/dots to click yet — offer a picker up front + // instead of leaving the user to discover the dropdown. + if currentInfos.filter(\.running).isEmpty { + showStartupPicker(currentInfos) + } + + UpdateChecker.checkAutomaticallyIfDue() + } + + // Claude's own app icon (bundled as Contents/Resources/AppIcon.icns — + // currently a placeholder, see icon/generate-icon.swift), scaled down + // for the menu bar. + private func menuBarIcon() -> NSImage { + let source = NSApp.applicationIconImage ?? NSImage(named: NSImage.applicationIconName) ?? NSImage() + let resized = NSImage(size: NSSize(width: 18, height: 18)) + resized.lockFocus() + source.draw(in: NSRect(x: 0, y: 0, width: 18, height: 18), from: .zero, operation: .sourceOver, fraction: 1.0) + resized.unlockFocus() + return resized + } + + // MARK: - LiveStateDelegate + + func liveStateDidChange(_ infos: [ProfileInfo]) { + collisionAlertShown = false + currentInfos = infos + applyTitle(infos) + ManageWindowController.shared.update(infos) + } + + // Two profiles sharing a dir is a fatal misconfiguration (would + // silently merge their Claude sessions) — surfaced with an alert + // rather than crashing the app outright, since that would leave the + // user with no way to fix it short of hand-editing files blind. + func liveStateFoundCollisions(_ collisions: [ProfileStore.DirCollision]) { + guard !collisionAlertShown else { return } + collisionAlertShown = true + let lines = collisions.map { "\u{2022} \($0.kind): \"\($0.profileA)\" and \"\($0.profileB)\" both use \($0.dir)" } + let alert = NSAlert() + alert.alertStyle = .critical + alert.messageText = "shannoncoat: configuration error" + alert.informativeText = "Two profiles can't share a directory:\n\n" + lines.joined(separator: "\n") + alert.addButton(withTitle: "OK") + alert.runModal() + } + + // Title reflects what's ACTUALLY running — one profile shows as a dot + // + name; several show as dots only, to keep the menu bar from growing + // unbounded, with the full list available via tooltip and the dropdown. + private func applyTitle(_ profiles: [ProfileInfo]) { + guard let button = statusItem.button else { return } + let running = profiles.filter(\.running) + switch running.count { + case 0: + button.attributedTitle = NSAttributedString(string: "") + button.toolTip = "Claude not running" + case 1: + let name = running[0].profile.name + let title = NSMutableAttributedString( + string: "\u{25cf} ", attributes: [.foregroundColor: ProfileColor.dotColor(for: name)]) + title.append(NSAttributedString(string: name, attributes: [.foregroundColor: NSColor.labelColor])) + button.attributedTitle = title + button.toolTip = nil + default: + let title = NSMutableAttributedString() + for info in running { + title.append(NSAttributedString( + string: "\u{25cf}", attributes: [.foregroundColor: ProfileColor.dotColor(for: info.profile.name)])) + } + button.attributedTitle = title + button.toolTip = running.map(\.profile.name).joined(separator: ", ") + } + } + + // MARK: - Menu + + func menuNeedsUpdate(_ menu: NSMenu) { + menu.removeAllItems() + liveState.reconcile() // correctness backstop; live updates are the primary mechanism + let profiles = currentInfos + let runningCount = profiles.filter(\.running).count + + let header = NSMenuItem( + title: runningCount == 0 ? "Claude not running" + : runningCount == 1 ? "1 profile running" : "\(runningCount) profiles running", + action: nil, keyEquivalent: "") + header.isEnabled = false + menu.addItem(header) + menu.addItem(.separator()) + + let hint = NSMenuItem(title: "\u{21e7}-click to open alongside", action: nil, keyEquivalent: "") + hint.isEnabled = false + menu.addItem(hint) + menu.addItem(.separator()) + + for info in profiles { + // Click = exclusive switch (quits every other running profile). + // Shift-click = open alongside instead, without disturbing + // anything else that's running. + let title = info.profile.name == liveState.lastActiveName + ? "\(info.profile.name) \u{21c6}" : info.profile.name + let item = NSMenuItem(title: title, action: #selector(pick(_:)), keyEquivalent: "") + item.target = self + item.representedObject = info.profile.name + item.image = ProfileColor.dotImage(for: info.profile.name, dimmed: !info.running) + item.state = info.running ? .on : .off + item.toolTip = info.running ? "Running — click to focus" : "Click to switch • Shift-click to open alongside" + menu.addItem(item) + } + + menu.addItem(.separator()) + let manageItem = NSMenuItem( + title: "Manage Profiles\u{2026}", action: #selector(openManageWindow), keyEquivalent: ",") + manageItem.keyEquivalentModifierMask = [.command] + manageItem.target = self + menu.addItem(manageItem) + + let closeItem = NSMenuItem(title: "Close Profile", action: nil, keyEquivalent: "") + let closeMenu = NSMenu() + let closeable = profiles.filter(\.running) + if closeable.isEmpty { + let none = NSMenuItem(title: "(none running)", action: nil, keyEquivalent: "") + none.isEnabled = false + closeMenu.addItem(none) + } else { + for info in closeable { + let mi = NSMenuItem(title: info.profile.name, action: #selector(closeProfile(_:)), keyEquivalent: "") + mi.target = self + mi.representedObject = info.profile.name + closeMenu.addItem(mi) + } + } + closeItem.submenu = closeMenu + menu.addItem(closeItem) + + menu.addItem(.separator()) + let aboutItem = NSMenuItem(title: "About shannoncoat", action: #selector(showAbout), keyEquivalent: "") + aboutItem.target = self + menu.addItem(aboutItem) + + menu.addItem(.separator()) + let quitClaudeItem = NSMenuItem(title: "Quit Claude", action: #selector(quitClaude), keyEquivalent: "") + quitClaudeItem.target = self + menu.addItem(quitClaudeItem) + + let quitSelfItem = NSMenuItem(title: "Quit shannoncoat", action: #selector(quitSelf), keyEquivalent: "q") + quitSelfItem.target = self + menu.addItem(quitSelfItem) + } + + // Shown once at launch when nothing is running. A checklist rather + // than a single pick: unlike the menu's click/Shift-click (one + // profile, exclusive vs. alongside), there's nothing running yet to be + // "alongside" of, so this lets several profiles be selected to start + // together in one go. + private func showStartupPicker(_ profiles: [ProfileInfo]) { + guard !profiles.isEmpty else { return } + let alert = NSAlert() + alert.messageText = "Start Claude" + alert.informativeText = "Nothing is running. Choose which profiles to start:" + alert.addButton(withTitle: "Start") + alert.addButton(withTitle: "Cancel") + + let rowHeight: CGFloat = 22 + let container = NSView(frame: NSRect(x: 0, y: 0, width: 240, height: rowHeight * CGFloat(profiles.count))) + var checkboxes: [(box: NSButton, name: String)] = [] + for (i, info) in profiles.enumerated() { + let y = rowHeight * CGFloat(profiles.count - 1 - i) + let box = NSButton(checkboxWithTitle: info.profile.name, target: nil, action: nil) + box.frame = NSRect(x: 0, y: y, width: 240, height: rowHeight) + box.state = info.profile.name == liveState.lastActiveName ? .on : .off + container.addSubview(box) + checkboxes.append((box, info.profile.name)) + } + alert.accessoryView = container + + guard alert.runModal() == .alertFirstButtonReturn else { return } + + for (box, name) in checkboxes where box.state == .on { + guard let profile = profiles.first(where: { $0.profile.name == name })?.profile else { continue } + liveState.markActive(name) + ClaudeControl.launch(profile) + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in self?.liveState.reconcile() } + } + + // MARK: - Actions + + @objc func pick(_ sender: NSMenuItem) { + guard let name = sender.representedObject as? String, + let profile = currentInfos.first(where: { $0.profile.name == name })?.profile + else { return } + liveState.markActive(name) + if NSApp.currentEvent?.modifierFlags.contains(.shift) == true { + openProfile(profile) + } else { + switchTo(profile) + } + } + + private func openProfile(_ profile: ResolvedProfile) { + if let pid = ClaudeControl.pid(for: profile) { + ClaudeControl.focus(pid: pid) { _ in DispatchQueue.main.async { [weak self] in self?.liveState.reconcile() } } + } else { + ClaudeControl.launch(profile) + // The launch notification usually beats this, but a short + // explicit poke keeps the UI snappy even if it doesn't. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in self?.liveState.reconcile() } + } + } + + private func switchTo(_ profile: ResolvedProfile) { + let others = currentInfos.filter { $0.running && $0.profile.name != profile.name } + guard !others.isEmpty else { openProfile(profile); return } + var remaining = others.count + for info in others { + guard let pid = info.pid else { remaining -= 1; continue } + ClaudeControl.quit(pid: pid) { [weak self] _ in + remaining -= 1 + if remaining == 0 { + DispatchQueue.main.async { self?.openProfile(profile) } + } + } + } + } + + @objc func closeProfile(_ sender: NSMenuItem) { + guard let name = sender.representedObject as? String, + let info = currentInfos.first(where: { $0.profile.name == name }), let pid = info.pid + else { return } + ClaudeControl.quit(pid: pid) { _ in DispatchQueue.main.async { [weak self] in self?.liveState.reconcile() } } + } + + @objc func openManageWindow() { + ManageWindowController.shared.update(currentInfos) + ManageWindowController.shared.show() + } + + @objc func showAbout() { + // Accessory apps (no Dock icon) don't auto-activate for a window + // they open, so the standard About panel would appear behind + // whatever's frontmost without this. + NSApp.activate(ignoringOtherApps: true) + let credits = """ + Which coat will Claude wear today? Menu bar switcher for running multiple isolated Claude Desktop profiles side by side — each paired with its own Claude Code config so the two never cross-contaminate. + + Author: \(author) + Source: \(homepage) + + Version: \(UpdateChecker.currentVersion) + """ + NSApp.orderFrontStandardAboutPanel(options: [ + .applicationName: "shannoncoat", + .credits: NSAttributedString(string: credits), + ]) + } + + @objc func quitClaude() { + ClaudeControl.quitAll { DispatchQueue.main.async { [weak self] in self?.liveState.reconcile() } } + } + + @objc func quitSelf() { + NSApp.terminate(nil) + } +} diff --git a/Sources/ClaudeControl.swift b/Sources/ClaudeControl.swift new file mode 100644 index 0000000..6409009 --- /dev/null +++ b/Sources/ClaudeControl.swift @@ -0,0 +1,240 @@ +// Everything about controlling the Claude process itself: launch, focus, +// quit. Only two real primitives — launch/focus one profile ("open"), and +// quit one profile ("close"); "switch" (exclusive) is composed from these +// two in AppDelegate rather than being a third code path here. +import AppKit +import ApplicationServices +import Foundation +import UniformTypeIdentifiers + +enum ClaudeControl { + private static let appPathKey = "ClaudeAppPath" + + // Both places an app legitimately lives on macOS: the machine-wide + // /Applications and a per-user ~/Applications. Checked in that order, + // after any location the user has pointed us at themselves. + private static var candidatePaths: [String] { + [ + "/Applications/Claude.app", + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Applications/Claude.app").path, + ] + } + + private static var cachedAppPath: String? + + // Resolved once per launch, because this is consulted on every process + // enumeration — which happens on every workspace notification. + static var appPath: String { + if let cachedAppPath { return cachedAppPath } + let resolved = resolveAppPath() + cachedAppPath = resolved + return resolved + } + + static var binaryPath: String { "\(appPath)/Contents/MacOS/Claude" } + + static var isInstalled: Bool { FileManager.default.fileExists(atPath: binaryPath) } + + private static func holdsClaude(_ path: String) -> Bool { + FileManager.default.fileExists(atPath: "\(path)/Contents/MacOS/Claude") + } + + private static func resolveAppPath() -> String { + if let saved = UserDefaults.standard.string(forKey: appPathKey), holdsClaude(saved) { + return saved + } + if let found = candidatePaths.first(where: holdsClaude) { + return found + } + // Found nothing. Report against the standard location anyway, so + // anything that surfaces this path names somewhere meaningful + // rather than an empty string. + return candidatePaths[0] + } + + // For an install in neither standard location. Modal by nature, but only + // ever reached from an explicit user action that can't proceed without + // an answer — better than a menu click that silently does nothing. + @discardableResult + static func promptForAppLocation() -> Bool { + let panel = NSOpenPanel() + panel.message = "Couldn't find Claude in Applications. Choose Claude.app to continue." + panel.prompt = "Choose" + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowsMultipleSelection = false + panel.allowedContentTypes = [.application] + panel.directoryURL = URL(fileURLWithPath: "/Applications") + NSApp.activate(ignoringOtherApps: true) + + guard panel.runModal() == .OK, let url = panel.url else { return false } + guard holdsClaude(url.path) else { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = "That doesn't look like Claude." + alert.informativeText = "\(url.lastPathComponent) doesn't contain a Claude executable." + alert.runModal() + return false + } + + UserDefaults.standard.set(url.path, forKey: appPathKey) + cachedAppPath = url.path + return true + } + + static func runningInstances() -> [RunningClaude] { + ProcessInspector.listRunningClaude(binaryPath: binaryPath) + } + + static func pid(for profile: ResolvedProfile) -> pid_t? { + let dir = profile.name == "default" ? nil : profile.appDir + return ProcessInspector.pid(forUserDataDir: dir, binaryPath: binaryPath) + } + + // MARK: - Launch + + @discardableResult + static func launch(_ profile: ResolvedProfile) -> Bool { + // Ask once rather than fail silently: a launch that does nothing + // gives the user no way to work out that Claude simply isn't where + // this expected it to be. + if !isInstalled, !promptForAppLocation() { return false } + + let process = Process() + process.executableURL = URL(fileURLWithPath: binaryPath) + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + + if profile.name != "default" { + try? FileManager.default.createDirectory(atPath: profile.codeDir, withIntermediateDirectories: true) + try? FileManager.default.createDirectory(atPath: profile.appDir, withIntermediateDirectories: true) + var env = ProcessInfo.processInfo.environment + env["CLAUDE_CONFIG_DIR"] = profile.codeDir + process.environment = env + process.arguments = ["--user-data-dir=\(profile.appDir)"] + } + + do { + try process.run() + return true + } catch { + return false + } + } + + // MARK: - Focus + + // Robust focus: unhide, un-minimize, raise across Spaces via the + // Accessibility API directly (replacing the old AppleScript/System + // Events call), then NSRunningApplication.activate() as a second push. + // Waits briefly for a window to exist if the process was just launched, + // rather than giving up immediately. Best-effort by design (a focus + // request shouldn't be able to crash or block the caller), but no + // longer silent about the one failure that's actually actionable: + // Accessibility not granted. + static func focus(_ profile: ResolvedProfile, completion: @escaping (Bool) -> Void) { + guard let pid = pid(for: profile) else { completion(false); return } + focus(pid: pid, completion: completion) + } + + static func focus(pid: pid_t, completion: @escaping (Bool) -> Void) { + if !AXIsProcessTrusted() { + promptForAccessibility() + } + guard let app = NSRunningApplication(processIdentifier: pid) else { completion(false); return } + if app.isHidden { app.unhide() } + + let axApp = AXUIElementCreateApplication(pid) + waitForWindow(axApp: axApp, attemptsRemaining: 12) { window in + if let window { + setMinimized(window, false) + AXUIElementPerformAction(window, kAXRaiseAction as CFString) + } + app.activate() + completion(window != nil) + } + } + + private static func waitForWindow( + axApp: AXUIElement, attemptsRemaining: Int, completion: @escaping (AXUIElement?) -> Void + ) { + if let window = firstWindow(of: axApp) { + completion(window) + } else if attemptsRemaining <= 0 { + completion(nil) + } else { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + waitForWindow(axApp: axApp, attemptsRemaining: attemptsRemaining - 1, completion: completion) + } + } + } + + private static func firstWindow(of axApp: AXUIElement) -> AXUIElement? { + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue(axApp, kAXWindowsAttribute as CFString, &value) == .success, + let windows = value as? [AXUIElement], let first = windows.first + else { return nil } + return first + } + + private static func setMinimized(_ window: AXUIElement, _ minimized: Bool) { + AXUIElementSetAttributeValue(window, kAXMinimizedAttribute as CFString, minimized as CFTypeRef) + } + + private static func promptForAccessibility() { + let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary + _ = AXIsProcessTrustedWithOptions(options) + } + + // MARK: - Quit + + enum QuitResult { case exited, blockedByOwnDialog, forceKilled, alreadyGone } + + // Fixes a real bug in the old shell version: it waited a flat 5s then + // unconditionally SIGKILLed, which would kill Claude out from under its + // own "unsaved work" / "generation in progress" confirmation if it + // showed one. `.terminate()` is a request the target can legitimately + // delay or decline (it can return `.terminateCancel` from its own + // applicationShouldTerminate:), not a command it's forced to obey — so + // this waits much longer, and if Claude still has a window up at the + // end of that window (a sign it's showing its own dialog, or otherwise + // isn't done with something) it stops short of forceTerminate and + // reports that back instead of killing underneath it. + static func quit(pid: pid_t, completion: @escaping (QuitResult) -> Void) { + guard let app = NSRunningApplication(processIdentifier: pid) else { completion(.alreadyGone); return } + app.terminate() + pollForExit(app: app, attemptsRemaining: 40, completion: completion) // 40 * 0.25s = 10s grace period + } + + static func quitAll(completion: @escaping () -> Void) { + let pids = runningInstances().map(\.pid) + guard !pids.isEmpty else { completion(); return } + var remaining = pids.count + for pid in pids { + quit(pid: pid) { _ in + remaining -= 1 + if remaining == 0 { completion() } + } + } + } + + private static func pollForExit( + app: NSRunningApplication, attemptsRemaining: Int, completion: @escaping (QuitResult) -> Void + ) { + if app.isTerminated { + completion(.exited) + } else if attemptsRemaining <= 0 { + if firstWindow(of: AXUIElementCreateApplication(app.processIdentifier)) != nil { + completion(.blockedByOwnDialog) + } else { + app.forceTerminate() + completion(.forceKilled) + } + } else { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + pollForExit(app: app, attemptsRemaining: attemptsRemaining - 1, completion: completion) + } + } + } +} diff --git a/Sources/LaunchAtLogin.swift b/Sources/LaunchAtLogin.swift new file mode 100644 index 0000000..be52542 --- /dev/null +++ b/Sources/LaunchAtLogin.swift @@ -0,0 +1,21 @@ +// Thin wrapper around the modern login-item API, toggled from the Manage +// window's Settings tab. Standard, expected baseline behavior for a +// persistent menu-bar utility that's otherwise easy to forget to reopen +// after a restart. +import ServiceManagement + +enum LaunchAtLogin { + static var isEnabled: Bool { + SMAppService.mainApp.status == .enabled + } + + static func enable() throws { + guard SMAppService.mainApp.status != .enabled else { return } + try SMAppService.mainApp.register() + } + + static func disable() throws { + guard SMAppService.mainApp.status == .enabled else { return } + try SMAppService.mainApp.unregister() + } +} diff --git a/Sources/LiveState.swift b/Sources/LiveState.swift new file mode 100644 index 0000000..7286a58 --- /dev/null +++ b/Sources/LiveState.swift @@ -0,0 +1,87 @@ +// Keeps the menu bar in sync with reality without ever polling. Registers +// NSWorkspace notifications for launch/terminate/activate/hide/unhide, +// filtered to Claude's own binary, and reconciles an in-memory +// [ProfileInfo] the instant one fires (sub-second, push-based) — this is +// what makes external changes (Force Quit, Spotlight, Dock, a hand quit) +// show up immediately instead of needing a menu click or an app restart. +import AppKit +import Foundation + +struct ProfileInfo { + let profile: ResolvedProfile + let running: Bool + let pid: pid_t? +} + +protocol LiveStateDelegate: AnyObject { + func liveStateDidChange(_ infos: [ProfileInfo]) + func liveStateFoundCollisions(_ collisions: [ProfileStore.DirCollision]) +} + +final class LiveState { + weak var delegate: LiveStateDelegate? + + // Best-effort "last switched to" pointer for the ⇆ marker in the menu — + // reflects profiles opened/switched through this app; there's no + // ordered history to fall back through if that one gets hand-quit + // (matches the "don't overdo it" brief — reality-vs-stopped state is + // still always ground-truth, this only affects the ⇆ hint). + private(set) var lastActiveName = "default" + + private var observers: [NSObjectProtocol] = [] + + init() { + let center = NSWorkspace.shared.notificationCenter + let names: [Notification.Name] = [ + NSWorkspace.didLaunchApplicationNotification, + NSWorkspace.didTerminateApplicationNotification, + NSWorkspace.didActivateApplicationNotification, + NSWorkspace.didHideApplicationNotification, + NSWorkspace.didUnhideApplicationNotification, + ] + observers = names.map { name in + center.addObserver(forName: name, object: nil, queue: .main) { [weak self] note in + self?.handle(note) + } + } + // Deliberately not calling reconcile() here — `delegate` is set by + // the caller right after init, and the caller does the first + // reconcile itself once it's ready to receive the callback. + } + + deinit { + let center = NSWorkspace.shared.notificationCenter + observers.forEach { center.removeObserver($0) } + } + + private func handle(_ note: Notification) { + guard let app = note.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication, + app.executableURL?.path == ClaudeControl.binaryPath + else { return } + reconcile() + } + + func markActive(_ name: String) { + lastActiveName = name + } + + // Public so callers (menu open, right after a command completes) can + // force an immediate reconcile as a correctness backstop, without that + // being the primary update mechanism. + func reconcile() { + let profiles = ProfileStore.loadAll() + let collisions = ProfileStore.findCollisions(among: profiles) + guard collisions.isEmpty else { + delegate?.liveStateFoundCollisions(collisions) + return + } + + let running = ClaudeControl.runningInstances() + let infos = profiles.map { profile -> ProfileInfo in + let dir = profile.name == "default" ? nil : profile.appDir + let match = running.first { $0.userDataDir == dir } + return ProfileInfo(profile: profile, running: match != nil, pid: match?.pid) + } + delegate?.liveStateDidChange(infos) + } +} diff --git a/Sources/ManageWindow.swift b/Sources/ManageWindow.swift new file mode 100644 index 0000000..20fbd6a --- /dev/null +++ b/Sources/ManageWindow.swift @@ -0,0 +1,390 @@ +// One ordinary, non-modal window for everything profile-management related +// — replaces what would otherwise be a string of separate popup alerts. +// Opened from the menu bar's "Manage Profiles…" item or the standard +// Cmd+, shortcut. +import AppKit + +final class ManageWindowController: NSWindowController { + static let shared = ManageWindowController() + + private let profilesVC = ProfilesViewController() + private let settingsVC = SettingsViewController() + + // Called after a profile is added/removed so the caller can trigger an + // immediate LiveState reconcile instead of waiting on the next + // notification. + var onProfilesChanged: (() -> Void)? { + didSet { profilesVC.onChange = onProfilesChanged } + } + + private init() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 460, height: 360), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, defer: false) + window.title = "shannoncoat" + window.isReleasedWhenClosed = false + window.center() + + let tabs = NSTabViewController() + profilesVC.title = "Profiles" + settingsVC.title = "Settings" + tabs.addTabViewItem(NSTabViewItem(viewController: profilesVC)) + tabs.addTabViewItem(NSTabViewItem(viewController: settingsVC)) + window.contentViewController = tabs + + super.init(window: window) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func show() { + NSApp.activate(ignoringOtherApps: true) + window?.makeKeyAndOrderFront(nil) + } + + func update(_ infos: [ProfileInfo]) { + profilesVC.update(infos) + } +} + +// MARK: - Profiles tab + +private final class ProfilesViewController: NSViewController, NSTableViewDataSource, NSTableViewDelegate, + NSTextFieldDelegate +{ + var onChange: (() -> Void)? + + private var infos: [ProfileInfo] = [] + private let tableView = NSTableView() + private let removeButton = NSButton(title: "\u{2212}", target: nil, action: nil) + private let addForm = NSStackView() + private let nameField = NSTextField() + private let codeField = NSTextField() + private let appField = NSTextField() + private let errorLabel = NSTextField(labelWithString: "") + + override func loadView() { + view = NSView(frame: NSRect(x: 0, y: 0, width: 460, height: 360)) + + let column = NSTableColumn(identifier: .init("profile")) + column.title = "Profile" + column.width = 380 + tableView.addTableColumn(column) + tableView.headerView = nil + tableView.dataSource = self + tableView.delegate = self + tableView.rowHeight = 22 + tableView.menu = buildContextMenu() + + let scroll = NSScrollView() + scroll.documentView = tableView + scroll.hasVerticalScroller = true + scroll.translatesAutoresizingMaskIntoConstraints = false + + let addButton = NSButton(title: "+", target: self, action: #selector(toggleAddForm)) + removeButton.target = self + removeButton.action = #selector(removeSelected) + removeButton.isEnabled = false + let buttonRow = NSStackView(views: [addButton, removeButton, NSView()]) + buttonRow.orientation = .horizontal + buttonRow.spacing = 4 + buttonRow.translatesAutoresizingMaskIntoConstraints = false + + buildAddForm() + addForm.isHidden = true + addForm.translatesAutoresizingMaskIntoConstraints = false + + let stack = NSStackView(views: [scroll, buttonRow, addForm]) + stack.orientation = .vertical + stack.spacing = 8 + stack.edgeInsets = NSEdgeInsets(top: 12, left: 12, bottom: 12, right: 12) + stack.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(stack) + + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: view.topAnchor), + stack.leadingAnchor.constraint(equalTo: view.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: view.trailingAnchor), + stack.bottomAnchor.constraint(equalTo: view.bottomAnchor), + scroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 160), + ]) + } + + private func buildAddForm() { + nameField.placeholderString = "Profile name" + nameField.delegate = self + + codeField.placeholderString = "~/.shannoncoat/data//code" + let codeChoose = NSButton(title: "Choose\u{2026}", target: self, action: #selector(chooseCodeDir)) + let codeRow = NSStackView(views: [labeled("Claude Code:"), codeField, codeChoose]) + codeRow.orientation = .horizontal + codeRow.spacing = 6 + + appField.placeholderString = "~/.shannoncoat/data//app" + let appChoose = NSButton(title: "Choose\u{2026}", target: self, action: #selector(chooseAppDir)) + let appRow = NSStackView(views: [labeled("Claude Desktop:"), appField, appChoose]) + appRow.orientation = .horizontal + appRow.spacing = 6 + + errorLabel.textColor = .systemRed + errorLabel.font = .systemFont(ofSize: NSFont.smallSystemFontSize) + + let createButton = NSButton(title: "Create", target: self, action: #selector(createProfile)) + createButton.keyEquivalent = "\r" + let cancelButton = NSButton(title: "Cancel", target: self, action: #selector(toggleAddForm)) + let buttonRow = NSStackView(views: [NSView(), cancelButton, createButton]) + buttonRow.orientation = .horizontal + buttonRow.spacing = 8 + + addForm.orientation = .vertical + addForm.alignment = .leading + addForm.spacing = 6 + [nameField, codeRow, appRow, errorLabel, buttonRow].forEach(addForm.addArrangedSubview) + nameField.widthAnchor.constraint(equalTo: addForm.widthAnchor).isActive = true + codeRow.widthAnchor.constraint(equalTo: addForm.widthAnchor).isActive = true + appRow.widthAnchor.constraint(equalTo: addForm.widthAnchor).isActive = true + buttonRow.widthAnchor.constraint(equalTo: addForm.widthAnchor).isActive = true + } + + private func labeled(_ text: String) -> NSTextField { + let field = NSTextField(labelWithString: text) + field.setContentHuggingPriority(.required, for: .horizontal) + return field + } + + func update(_ infos: [ProfileInfo]) { + self.infos = infos + tableView.reloadData() + removeButton.isEnabled = tableView.selectedRow >= 0 + && infos.indices.contains(tableView.selectedRow) + && infos[tableView.selectedRow].profile.name != "default" + } + + // MARK: NSTableViewDataSource / Delegate + + func numberOfRows(in tableView: NSTableView) -> Int { infos.count } + + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { + let identifier = NSUserInterfaceItemIdentifier("profileCell") + let info = infos[row] + let cell: NSTableCellView + if let reused = tableView.makeView(withIdentifier: identifier, owner: self) as? NSTableCellView { + cell = reused + } else { + cell = NSTableCellView() + cell.identifier = identifier + let imageView = NSImageView() + let textField = NSTextField(labelWithString: "") + imageView.translatesAutoresizingMaskIntoConstraints = false + textField.translatesAutoresizingMaskIntoConstraints = false + cell.addSubview(imageView) + cell.addSubview(textField) + cell.imageView = imageView + cell.textField = textField + NSLayoutConstraint.activate([ + imageView.leadingAnchor.constraint(equalTo: cell.leadingAnchor, constant: 4), + imageView.centerYAnchor.constraint(equalTo: cell.centerYAnchor), + imageView.widthAnchor.constraint(equalToConstant: 10), + imageView.heightAnchor.constraint(equalToConstant: 10), + textField.leadingAnchor.constraint(equalTo: imageView.trailingAnchor, constant: 6), + textField.trailingAnchor.constraint(lessThanOrEqualTo: cell.trailingAnchor, constant: -4), + textField.centerYAnchor.constraint(equalTo: cell.centerYAnchor), + ]) + } + cell.imageView?.image = ProfileColor.dotImage(for: info.profile.name, dimmed: !info.running) + cell.textField?.stringValue = "\(info.profile.name) — \(info.running ? "running" : "stopped")" + return cell + } + + func tableViewSelectionDidChange(_ notification: Notification) { + removeButton.isEnabled = tableView.selectedRow >= 0 + && infos.indices.contains(tableView.selectedRow) + && infos[tableView.selectedRow].profile.name != "default" + } + + // MARK: NSTextFieldDelegate — live default-path preview as the name is typed + + func controlTextDidChange(_ obj: Notification) { + guard (obj.object as? NSTextField) === nameField else { return } + let name = nameField.stringValue.isEmpty ? "" : nameField.stringValue + codeField.placeholderString = "~/.shannoncoat/data/\(name)/code" + appField.placeholderString = "~/.shannoncoat/data/\(name)/app" + } + + // MARK: Actions + + @objc private func toggleAddForm() { + addForm.isHidden.toggle() + if !addForm.isHidden { + nameField.stringValue = "" + codeField.stringValue = "" + appField.stringValue = "" + errorLabel.stringValue = "" + view.window?.makeFirstResponder(nameField) + } + } + + @objc private func chooseCodeDir() { choose(into: codeField) } + @objc private func chooseAppDir() { choose(into: appField) } + + private func choose(into field: NSTextField) { + let panel = NSOpenPanel() + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.allowsMultipleSelection = false + guard let window = view.window else { return } + panel.beginSheetModal(for: window) { response in + if response == .OK, let url = panel.url { + field.stringValue = url.path + } + } + } + + @objc private func createProfile() { + let name = nameField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + let code = codeField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + let app = appField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + do { + try ProfileStore.create(name: name, codeDir: code.isEmpty ? nil : code, appDir: app.isEmpty ? nil : app) + toggleAddForm() + onChange?() + } catch { + errorLabel.stringValue = (error as? LocalizedError)?.errorDescription ?? "\(error)" + } + } + + @objc private func removeSelected() { + let row = tableView.selectedRow + guard infos.indices.contains(row) else { return } + let info = infos[row] + guard info.profile.name != "default", let window = view.window else { return } + + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = "Delete profile \u{201C}\(info.profile.name)\u{201D}?" + alert.informativeText = info.running + ? "This profile is currently running \u{2014} deleting it will quit Claude first. Its Claude Desktop and Claude Code data stay on disk; only the profile pointer is removed." + : "Its Claude Desktop and Claude Code data stay on disk; only the profile pointer is removed." + alert.addButton(withTitle: "Delete") + alert.addButton(withTitle: "Cancel") + alert.buttons.first?.hasDestructiveAction = true + + alert.beginSheetModal(for: window) { [weak self] response in + guard response == .alertFirstButtonReturn else { return } + let finish = { + try? ProfileStore.delete(info.profile.name) + self?.onChange?() + } + if info.running, let pid = info.pid { + ClaudeControl.quit(pid: pid) { _ in DispatchQueue.main.async(execute: finish) } + } else { + finish() + } + } + } + + private func buildContextMenu() -> NSMenu { + let menu = NSMenu() + let revealCode = NSMenuItem(title: "Reveal Code Folder", action: #selector(revealCodeDir), keyEquivalent: "") + revealCode.target = self + let revealApp = NSMenuItem( + title: "Reveal Desktop Folder", action: #selector(revealAppDir), keyEquivalent: "") + revealApp.target = self + menu.addItem(revealCode) + menu.addItem(revealApp) + return menu + } + + private func clickedInfo() -> ProfileInfo? { + let row = tableView.clickedRow + guard infos.indices.contains(row) else { return nil } + return infos[row] + } + + @objc private func revealCodeDir() { + guard let info = clickedInfo() else { return } + NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: info.profile.codeDir) + } + + @objc private func revealAppDir() { + guard let info = clickedInfo() else { return } + NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: info.profile.appDir) + } +} + +// MARK: - Settings tab + +private final class SettingsViewController: NSViewController { + private let autoUpdateCheckbox = NSButton( + checkboxWithTitle: "Automatically check for updates", target: nil, action: nil) + private let launchAtLoginCheckbox = NSButton(checkboxWithTitle: "Launch at Login", target: nil, action: nil) + private let checkNowButton = NSButton(title: "Check for Updates\u{2026}", target: nil, action: nil) + + override func loadView() { + view = NSView(frame: NSRect(x: 0, y: 0, width: 460, height: 360)) + + launchAtLoginCheckbox.target = self + launchAtLoginCheckbox.action = #selector(toggleLaunchAtLogin) + launchAtLoginCheckbox.state = LaunchAtLogin.isEnabled ? .on : .off + + autoUpdateCheckbox.target = self + autoUpdateCheckbox.action = #selector(toggleAutoUpdate) + autoUpdateCheckbox.state = UpdateChecker.automaticCheckEnabled ? .on : .off + + checkNowButton.target = self + checkNowButton.action = #selector(checkNow) + + let stack = NSStackView(views: [launchAtLoginCheckbox, autoUpdateCheckbox, checkNowButton]) + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 12 + stack.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(stack) + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: view.topAnchor, constant: 20), + stack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + ]) + } + + @objc private func toggleLaunchAtLogin() { + do { + if launchAtLoginCheckbox.state == .on { + try LaunchAtLogin.enable() + } else { + try LaunchAtLogin.disable() + } + } catch { + launchAtLoginCheckbox.state = LaunchAtLogin.isEnabled ? .on : .off + NSAlert(error: error).runModal() + } + } + + @objc private func toggleAutoUpdate() { + UpdateChecker.automaticCheckEnabled = autoUpdateCheckbox.state == .on + } + + @objc private func checkNow() { + UpdateChecker.checkManually { result in + DispatchQueue.main.async { + let alert = NSAlert() + switch result { + case .upToDate: + alert.messageText = "You're up to date." + alert.runModal() + case .updateAvailable(let version, let url): + alert.messageText = "shannoncoat \(version) is available" + alert.addButton(withTitle: "View Release") + alert.addButton(withTitle: "Later") + if alert.runModal() == .alertFirstButtonReturn { + NSWorkspace.shared.open(url) + } + case .failed: + alert.alertStyle = .warning + alert.messageText = "Couldn't check for updates." + alert.runModal() + } + } + } + } +} diff --git a/Sources/ProcessInspector.swift b/Sources/ProcessInspector.swift new file mode 100644 index 0000000..f088fad --- /dev/null +++ b/Sources/ProcessInspector.swift @@ -0,0 +1,86 @@ +// Enumerates running Claude processes and their --user-data-dir, without +// spawning pgrep (avoids both the subprocess-per-check overhead and an +// assumption that /usr/bin/pgrep exists at a fixed path). Uses the same +// sysctl(KERN_PROC_ALL)/sysctl(KERN_PROCARGS2) technique ps/pgrep use +// internally for same-user processes — no extra entitlement needed. +// +// Called on demand from live-state notification handlers (see AppDelegate), +// never on a timer. +import Darwin +import Foundation + +struct RunningClaude { + let pid: pid_t + // nil means launched with no --user-data-dir flag at all (i.e. the + // "default" profile). + let userDataDir: String? +} + +enum ProcessInspector { + static func listRunningClaude(binaryPath: String) -> [RunningClaude] { + allPIDs().compactMap { pid in + guard let info = execInfo(pid: pid), info.execPath == binaryPath else { return nil } + let flagPrefix = "--user-data-dir=" + let dir = info.args.first(where: { $0.hasPrefix(flagPrefix) }).map { String($0.dropFirst(flagPrefix.count)) } + return RunningClaude(pid: pid, userDataDir: dir) + } + } + + static func pid(forUserDataDir dir: String?, binaryPath: String) -> pid_t? { + listRunningClaude(binaryPath: binaryPath).first { $0.userDataDir == dir }?.pid + } + + private static func allPIDs() -> [pid_t] { + var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_ALL, 0] + var size = 0 + guard sysctl(&mib, 4, nil, &size, nil, 0) == 0, size > 0 else { return [] } + + let capacity = size / MemoryLayout.stride + 1 + var procs = [kinfo_proc](repeating: kinfo_proc(), count: capacity) + var actualSize = capacity * MemoryLayout.stride + guard sysctl(&mib, 4, &procs, &actualSize, nil, 0) == 0 else { return [] } + + let actualCount = actualSize / MemoryLayout.stride + return procs[0.. ExecInfo? { + var mib: [Int32] = [CTL_KERN, KERN_PROCARGS2, pid] + var size = 0 + guard sysctl(&mib, 3, nil, &size, nil, 0) == 0, size > MemoryLayout.size else { return nil } + + var buffer = [UInt8](repeating: 0, count: size) + guard sysctl(&mib, 3, &buffer, &size, nil, 0) == 0 else { return nil } + + var argc = Int32(0) + withUnsafeMutableBytes(of: &argc) { dst in + dst.copyBytes(from: buffer[0...size]) + } + + var offset = MemoryLayout.size + func readCString() -> String? { + guard offset < size else { return nil } + let start = offset + while offset < size, buffer[offset] != 0 { offset += 1 } + guard offset > start else { return nil } + let string = String(decoding: buffer[start.. UInt32 { + var hash: UInt32 = 0x811c_9dc5 + for byte in s.utf8 { + hash ^= UInt32(byte) + hash = hash &* 0x0100_0193 + } + return hash + } + + static func dotColor(for name: String) -> NSColor { + let hue = CGFloat(fnv1aHash(name) % 360) / 360.0 + return NSColor(calibratedHue: hue, saturation: 0.65, brightness: 0.9, alpha: 1.0) + } + + // `dimmed` marks a profile that isn't actually running right now — the + // colored dot still identifies it, but faded, so "open" vs "known but + // closed" is visible without reading a tooltip. + static func dotImage(for name: String, dimmed: Bool = false) -> NSImage { + let size = NSSize(width: 10, height: 10) + let image = NSImage(size: size) + image.lockFocus() + dotColor(for: name).withAlphaComponent(dimmed ? 0.3 : 1.0).setFill() + NSBezierPath(ovalIn: NSRect(origin: .zero, size: size)).fill() + image.unlockFocus() + image.isTemplate = false + return image + } +} diff --git a/Sources/ProfileStore.swift b/Sources/ProfileStore.swift new file mode 100644 index 0000000..652af10 --- /dev/null +++ b/Sources/ProfileStore.swift @@ -0,0 +1,180 @@ +// Profile config storage: one ~/.shannoncoat/.json per profile, +// holding the two dirs a profile needs — Claude Desktop's --user-data-dir, +// and the CLAUDE_CONFIG_DIR paired with it so any Claude Code session +// launched from that Desktop instance gets its own isolated config too, +// instead of sharing one with every other profile. (Claude Code already +// has its own profile mechanism for use outside Desktop — this isn't a +// second one, just making sure a managed Desktop session doesn't leak +// into it.) "default" is implicit — no file for it — and always means the +// real, untouched ~/.claude + Application Support/Claude. +import Foundation + +struct ResolvedProfile: Equatable { + let name: String + let codeDir: String + let appDir: String +} + +enum ProfileError: LocalizedError { + case emptyName + case reservedName + case invalidName(String) + case alreadyExists(String) + case notFound(String) + case dirCollision(existingProfile: String, dir: String, kind: String) + + var errorDescription: String? { + switch self { + case .emptyName: + return "Name is required." + case .reservedName: + return "\"default\" is reserved for the real, untouched Claude install." + case .invalidName(let name): + return "\"\(name)\" isn't a valid profile name (no spaces, slashes, colons, or leading dots)." + case .alreadyExists(let name): + return "A profile named \"\(name)\" already exists." + case .notFound(let name): + return "No profile named \"\(name)\"." + case .dirCollision(let existingProfile, let dir, let kind): + return "\(kind) dir \(dir) is already used by profile \"\(existingProfile)\"." + } + } +} + +// What lives on disk. Profile name comes from the filename, not a field in +// here, so there's only one place a profile's name can disagree with +// itself. `paths` is nested (matching the old YAML's shape) rather than +// flattened, so a later profile-specific feature has an obvious place to +// add its own top-level key alongside `paths` without disturbing this one. +private struct ProfileFile: Codable { + var paths: Paths + + struct Paths: Codable { + var code: String + var desktop: String + } +} + +enum ProfileStore { + static let root: URL = { + let url = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".shannoncoat") + try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + }() + + static let defaultProfile = ResolvedProfile( + name: "default", + codeDir: FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".claude").path, + appDir: FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/Claude").path + ) + + // "~" expands to home; anything else non-absolute resolves relative to + // `root` (the config dir itself) rather than the process's cwd; an + // already-absolute path is left alone. + static func expand(_ raw: String) -> String { + if raw.hasPrefix("~") { + return (raw as NSString).expandingTildeInPath + } + if raw.hasPrefix("/") { + return raw + } + return root.appendingPathComponent(raw).path + } + + static func validateName(_ name: String) throws { + if name.isEmpty { throw ProfileError.emptyName } + if name == "default" { throw ProfileError.reservedName } + let hasBadChar = name.contains("/") || name.contains(":") || name.hasPrefix(".") + || name.rangeOfCharacter(from: .whitespacesAndNewlines) != nil + if hasBadChar { throw ProfileError.invalidName(name) } + } + + private static func configURL(for name: String) -> URL { + root.appendingPathComponent("\(name).json") + } + + // Every configured profile plus the implicit `default`, in no + // particular order. Config files that fail to decode are skipped + // rather than surfaced here — a hand-edited-into-garbage file + // shouldn't take the whole menu down. + static func loadAll() -> [ResolvedProfile] { + var profiles = [defaultProfile] + let files = (try? FileManager.default.contentsOfDirectory( + at: root, includingPropertiesForKeys: nil)) ?? [] + for file in files where file.pathExtension == "json" { + guard let data = try? Data(contentsOf: file), + let profileFile = try? JSONDecoder().decode(ProfileFile.self, from: data) + else { continue } + let name = file.deletingPathExtension().lastPathComponent + profiles.append(ResolvedProfile( + name: name, codeDir: expand(profileFile.paths.code), appDir: expand(profileFile.paths.desktop))) + } + return profiles + } + + struct DirCollision { let profileA: String; let profileB: String; let dir: String; let kind: String } + + // Two profiles secretly sharing a dir would silently merge their Claude + // sessions — defeats the entire point of switching. Checked on every + // load (see `loadAll` call sites), not just when a profile is created, + // so a hand-edited file can't sneak one in unnoticed. + static func findCollisions(among profiles: [ResolvedProfile]) -> [DirCollision] { + var collisions: [DirCollision] = [] + for i in profiles.indices { + for j in profiles.index(after: i).. ResolvedProfile { + try validateName(name) + let url = configURL(for: name) + guard !FileManager.default.fileExists(atPath: url.path) else { + throw ProfileError.alreadyExists(name) + } + + let rawCode = codeDir?.isEmpty == false ? codeDir! : "data/\(name)/code" + let rawApp = appDir?.isEmpty == false ? appDir! : "data/\(name)/app" + let resolvedCode = expand(rawCode) + let resolvedApp = expand(rawApp) + + for existing in loadAll() { + if existing.codeDir == resolvedCode { + throw ProfileError.dirCollision(existingProfile: existing.name, dir: resolvedCode, kind: "Claude Code") + } + if existing.appDir == resolvedApp { + throw ProfileError.dirCollision(existingProfile: existing.name, dir: resolvedApp, kind: "Claude Desktop") + } + } + + try FileManager.default.createDirectory(atPath: resolvedCode, withIntermediateDirectories: true) + try FileManager.default.createDirectory(atPath: resolvedApp, withIntermediateDirectories: true) + + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + let data = try encoder.encode(ProfileFile(paths: .init(code: rawCode, desktop: rawApp))) + try data.write(to: url) + + return ResolvedProfile(name: name, codeDir: resolvedCode, appDir: resolvedApp) + } + + static func delete(_ name: String) throws { + guard name != "default" else { throw ProfileError.reservedName } + let url = configURL(for: name) + guard FileManager.default.fileExists(atPath: url.path) else { throw ProfileError.notFound(name) } + try FileManager.default.removeItem(at: url) + } +} diff --git a/Sources/UpdateChecker.swift b/Sources/UpdateChecker.swift new file mode 100644 index 0000000..8258fe9 --- /dev/null +++ b/Sources/UpdateChecker.swift @@ -0,0 +1,90 @@ +// Deliberately minimal: fetch the raw VERSION file from `main` on GitHub +// and compare semver to the bundled version. No silent binary-replacing +// auto-update — that needs signing/notarization/an appcast server this +// project doesn't have. A manual check shows an alert (an explicit action +// that expects a response); the periodic automatic check instead posts a +// quiet system notification, since that one wasn't asked for in the +// moment. +import Foundation +import UserNotifications + +enum UpdateChecker { + enum Outcome { + case upToDate + case updateAvailable(version: String, url: URL) + case failed + } + + private static let versionURL = URL(string: "https://raw.githubusercontent.com/bdeshi/shannoncoat/main/VERSION")! + private static let releaseURL = URL(string: "https://github.com/bdeshi/shannoncoat/releases/latest")! + private static let enabledDefaultsKey = "AutomaticUpdateCheckEnabled" + private static let lastCheckDefaultsKey = "LastUpdateCheckDate" + private static let checkInterval: TimeInterval = 24 * 60 * 60 + + static var automaticCheckEnabled: Bool { + get { UserDefaults.standard.object(forKey: enabledDefaultsKey) as? Bool ?? true } + set { UserDefaults.standard.set(newValue, forKey: enabledDefaultsKey) } + } + + static var currentVersion: String { + guard let resourceURL = Bundle.main.resourceURL, + let contents = try? String(contentsOf: resourceURL.appendingPathComponent("VERSION"), encoding: .utf8) + else { return "unknown" } + return contents.trimmingCharacters(in: .whitespacesAndNewlines) + } + + static func checkManually(completion: @escaping (Outcome) -> Void) { + fetchLatestVersion { latest in + guard let latest else { completion(.failed); return } + if isNewer(latest, than: currentVersion) { + completion(.updateAvailable(version: latest, url: releaseURL)) + } else { + completion(.upToDate) + } + } + } + + // Called once at launch; a no-op unless the toggle is on and it's been + // at least a day since the last check. + static func checkAutomaticallyIfDue() { + guard automaticCheckEnabled else { return } + let last = UserDefaults.standard.object(forKey: lastCheckDefaultsKey) as? Date ?? .distantPast + guard Date().timeIntervalSince(last) > checkInterval else { return } + UserDefaults.standard.set(Date(), forKey: lastCheckDefaultsKey) + + fetchLatestVersion { latest in + guard let latest, isNewer(latest, than: currentVersion) else { return } + postAvailableNotification(version: latest) + } + } + + private static func fetchLatestVersion(completion: @escaping (String?) -> Void) { + URLSession.shared.dataTask(with: versionURL) { data, _, _ in + let version = data.flatMap { String(data: $0, encoding: .utf8) }? + .trimmingCharacters(in: .whitespacesAndNewlines) + completion((version?.isEmpty == false) ? version : nil) + }.resume() + } + + private static func isNewer(_ a: String, than b: String) -> Bool { + let av = a.split(separator: ".").compactMap { Int($0) } + let bv = b.split(separator: ".").compactMap { Int($0) } + for i in 0.. y } + } + return false + } + + private static func postAvailableNotification(version: String) { + let center = UNUserNotificationCenter.current() + center.requestAuthorization(options: [.alert]) { granted, _ in + guard granted else { return } + let content = UNMutableNotificationContent() + content.title = "shannoncoat \(version) is available" + content.body = "Click to view the release notes." + center.add(UNNotificationRequest(identifier: "shannoncoat-update-available", content: content, trigger: nil)) + } + } +} diff --git a/Sources/main.swift b/Sources/main.swift new file mode 100644 index 0000000..f3ea456 --- /dev/null +++ b/Sources/main.swift @@ -0,0 +1,10 @@ +// Entry point. Its own file only because swiftc restricts top-level +// executable statements to a file named main.swift once a module has more +// than one source file. +import Cocoa + +let delegate = AppDelegate() +let app = NSApplication.shared +app.delegate = delegate +app.setActivationPolicy(.accessory) +app.run() diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..8acdd82 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.0.1 diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..9c91167 --- /dev/null +++ b/build.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Builds "shannoncoat.app" — a menu bar app (no Dock icon, no window) with a +# custom icon (icon/AppIcon.icns — see icon/generate-icon.swift) for +# Finder/Spotlight. It stays running in the menu bar: click the status item +# to switch profiles, or open Manage Profiles… (Cmd+,) to add/remove one. +# Launch at Login is a toggle inside the app itself. +# +# Builds into ./.build (gitignored) rather than installing straight into an +# Applications dir — copy/drag the .app from there yourself. It's a fully +# native, self-contained bundle — no bundled script, so copying it anywhere +# after the build is safe. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SOURCES_DIR="$SCRIPT_DIR/Sources" +VERSION_FILE="$SCRIPT_DIR/VERSION" +ICON_SRC="$SCRIPT_DIR/icon/AppIcon.icns" +OUT_DIR="${1:-$SCRIPT_DIR/.build}" +APP="$OUT_DIR/shannoncoat.app" + +# Read from the repo's VERSION file so the .app and the script it bundles +# never report different numbers. +VERSION="$(cat "$VERSION_FILE" 2>/dev/null)" +[[ -n "$VERSION" ]] || { echo "error: couldn't read $VERSION_FILE" >&2; exit 1; } + +mkdir -p "$OUT_DIR" "$APP/Contents/MacOS" "$APP/Contents/Resources" + +[[ -f "$ICON_SRC" ]] || { echo "error: $ICON_SRC missing (see icon/generate-icon.swift)" >&2; exit 1; } +cp "$ICON_SRC" "$APP/Contents/Resources/AppIcon.icns" + +cat >"$APP/Contents/Info.plist" < + + + + CFBundleNameshannoncoat + CFBundleDisplayNameshannoncoat + CFBundleIdentifiercom.local.shannoncoat + CFBundleVersion$VERSION + CFBundleShortVersionString$VERSION + CFBundlePackageTypeAPPL + CFBundleExecutablelauncher + CFBundleIconFileAppIcon + LSUIElement + NSHighResolutionCapable + + +PLIST + +command -v swiftc >/dev/null || { echo "error: swiftc not found (install Xcode Command Line Tools: xcode-select --install)" >&2; exit 1; } + +# VERSION travels in Resources (not baked into the binary) so the About +# panel and the update checker read the same file this script stamped into +# Info.plist above. +cp "$VERSION_FILE" "$APP/Contents/Resources/VERSION" +swiftc -O "$SOURCES_DIR"/*.swift -o "$APP/Contents/MacOS/launcher" + +touch "$APP" +echo "Built: $APP" +echo "Copy it to /Applications (or ~/Applications) to install." diff --git a/icon/AppIcon.icns b/icon/AppIcon.icns new file mode 100644 index 0000000000000000000000000000000000000000..39e3506c4f534ce44b2366dcdcd2aa2f9f6611aa GIT binary patch literal 112936 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 K6$k| Data { + let s = CGFloat(pixels) + guard let rep = NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: pixels, + pixelsHigh: pixels, + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: 0, + bitsPerPixel: 0 + ) else { fatalError("could not create bitmap rep") } + rep.size = NSSize(width: s, height: s) + + NSGraphicsContext.saveGraphicsState() + let ctx = NSGraphicsContext(bitmapImageRep: rep) + NSGraphicsContext.current = ctx + ctx?.imageInterpolation = .high + + // Base: a plain rounded-square background (macOS "squircle"-ish corner + // radius), a neutral slate color — no borrowed assets. + let bg = NSColor(calibratedRed: 0.30, green: 0.32, blue: 0.36, alpha: 1.0) + let bgPath = NSBezierPath(roundedRect: NSRect(x: 0, y: 0, width: s, height: s), + xRadius: s * 0.22, yRadius: s * 0.22) + bg.setFill() + bgPath.fill() + + // Foreground: the same coat glyph as before — an original shape, not + // derived from any borrowed icon. + let coat = NSColor(calibratedRed: 0.62, green: 0.44, blue: 0.24, alpha: 1.0) + let coatShadow = NSColor(calibratedRed: 0.48, green: 0.33, blue: 0.16, alpha: 1.0) + let midX = s * 0.5 + let topY = s * 0.62 + let collarOutX = s * 0.34 + let collarInX = s * 0.11 + let shoulderY = s * 0.53 + let waistY = s * 0.30 + let waistHalf = s * 0.30 + let hemY = s * 0.06 + let hemHalf = s * 0.40 + + let path = NSBezierPath() + path.move(to: NSPoint(x: midX, y: topY)) + path.line(to: NSPoint(x: midX - collarOutX, y: shoulderY)) + path.line(to: NSPoint(x: midX - collarInX, y: shoulderY - s * 0.07)) + path.line(to: NSPoint(x: midX - waistHalf, y: waistY)) + path.line(to: NSPoint(x: midX - hemHalf, y: hemY)) + path.line(to: NSPoint(x: midX + hemHalf, y: hemY)) + path.line(to: NSPoint(x: midX + waistHalf, y: waistY)) + path.line(to: NSPoint(x: midX + collarInX, y: shoulderY - s * 0.07)) + path.line(to: NSPoint(x: midX + collarOutX, y: shoulderY)) + path.close() + coat.setFill() + path.fill() + + // A thin darker strip down the centerline reads as the coat's front + // seam/placket, breaking up the flat fill at larger sizes. + let seam = NSBezierPath() + seam.move(to: NSPoint(x: midX, y: topY)) + seam.line(to: NSPoint(x: midX, y: hemY)) + seam.lineWidth = max(s * 0.012, 1) + coatShadow.setStroke() + seam.stroke() + + let buttonRadius = max(s * 0.02, 0.75) + for t: CGFloat in [0.20, 0.42, 0.64] { + let y = hemY + (shoulderY - hemY) * t + let r = NSRect(x: midX - buttonRadius, y: y - buttonRadius, width: buttonRadius * 2, height: buttonRadius * 2) + coatShadow.setFill() + NSBezierPath(ovalIn: r).fill() + } + + ctx?.flushGraphics() + NSGraphicsContext.restoreGraphicsState() + + guard let png = rep.representation(using: .png, properties: [:]) else { + fatalError("failed to encode PNG at \(pixels)px") + } + return png +} + +let sizes: [(name: String, px: Int)] = [ + ("icon_16x16", 16), ("icon_16x16@2x", 32), + ("icon_32x32", 32), ("icon_32x32@2x", 64), + ("icon_128x128", 128), ("icon_128x128@2x", 256), + ("icon_256x256", 256), ("icon_256x256@2x", 512), + ("icon_512x512", 512), ("icon_512x512@2x", 1024), +] + +let outDir = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "AppIcon.iconset" +try? FileManager.default.createDirectory(atPath: outDir, withIntermediateDirectories: true) +for (name, px) in sizes { + let data = renderIcon(pixels: px) + let path = "\(outDir)/\(name).png" + try? data.write(to: URL(fileURLWithPath: path)) + print("wrote \(path)") +} diff --git a/shannoncoat.sh b/shannoncoat.sh deleted file mode 100755 index ff9aae1..0000000 --- a/shannoncoat.sh +++ /dev/null @@ -1,182 +0,0 @@ -#!/usr/bin/env bash -# shannoncoat - run more than one claude desktop profile on mac. -# -# claude desktop app seems to keep everything important in one config directory, -# so a different --user-data-dir gives a practically separate install. -# and CLAUDE_CONFIG_DIR env var can isolate claude code sessions natively. -# this script combines these to isolate both desktop sessions and the claude -# code sessions they run. -# -# profiles are yaml files in ~/.shannoncoat: -# # ~/.shannoncoat/client1.yaml -> profile named "client1" -# paths: -# code: ~/work/client1/.claude/code -# desktop: ~/work/client1/.claude/desktop -# -# claude code inside the desktop app can run in some folder which already -# defines a CLAUDE_CONFIG_DIR with direnv etc, that variable can be defined -# this way to allow both individual and desktop sessions work properly: -# export CLAUDE_CONFIG_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}" -# - -set -euo pipefail - -APP="${CLAUDE_APP_PATH:-/Applications/Claude.app}" -BIN="$APP/Contents/MacOS/Claude" -ROOT="$HOME/.shannoncoat" -DEFAULT="default" -APP_DIR="" -CODE_DIR="" -CODE_KEY="code" -DESKTOP_KEY="desktop" - -show_usage() { - cat < [profile] - - launch open a profile, leaving others running - switch quit all other profiles, then open this one - quit quit a profile - list list all running profiles - -Profiles are yaml files in ~/.shannoncoat/. Syntax: - # profile-name.yaml - paths: - $CODE_KEY: ~/path/to/claude/code - $DESKTOP_KEY: ~/path/to/claude/desktop -EOF -} - -# expand leading ~ in path string to $HOME -tilde_path() { - local p="$1" - echo "${p/#\~/$HOME}" -} - -# pull one key out of the yaml, simple job avoid yq for now -read_key() { - local file="$1" key="$2" - sed -EHn "s/^\s+$key:\s+//p" "$file" | tr -d '"' -} - -# set APP_DIR and CODE_DIR for a profile, or keep empty for $DEFAULT -get_dirs() { - local name="$1" file="$ROOT/$1.yaml" - - [[ "$name" == "$DEFAULT" ]] && { return; } - if [[ ! -f "$file" ]]; then - echo "no profile named $name (expected $file)" >&2 - exit 1 - fi - APP_DIR="$(tilde_path "$(read_key "$file" $DESKTOP_KEY)")" - CODE_DIR="$(tilde_path "$(read_key "$file" $CODE_KEY)")" -} - -# match a claude data dir to its profile name -dir_to_profile() { - local dir="$1" file name - [[ "$dir" == "$DEFAULT" ]] && { echo "$DEFAULT"; return; } - for file in "$ROOT"/*.yaml; do - [[ -f "$file" ]] || continue - name="$(basename "$file" .yaml)" - if [[ "$(tilde_path "$(read_key "$file" $DESKTOP_KEY)")" == "$dir" ]]; then - echo "$name" - return - fi - done - echo "$dir" -} - -# get all running claude processes as "piduser-data-dir" -running_info() { - local pid args dir - while read -r pid args; do - # if args has --user-data-dir=, extract it, otherwise use $DEFAULT - if [[ "$args" == *--user-data-dir=* ]]; then - dir="${args#*--user-data-dir=}" - dir="${dir%% *}" - else - dir="$DEFAULT" - fi - printf '%s\t%s\n' "$pid" "$dir" - done < <(pgrep -fl "$BIN" || true) -} - -# get pid of a profile's claude process -profile_to_pid() { - get_dirs "$1" - running_info | awk -F'\t' -v d="${APP_DIR:-default}" '$2 == d { print $1; exit }' -} - -# switch focus to a process window, shell/term needs accessibility permissions -app_focus() { - local pid="$1" - osascript > /dev/null 2>&1 < /dev/null 2>&1 & - disown -} - -# list all running profiles -cmd_list() { - local pid dir - while read -r pid dir; do - printf 'profile: %s\tpid: %s\n' "$(dir_to_profile "$dir")" "$pid" - done < <(running_info) -} - -# launch a profile or focus window if already running -cmd_launch() { - local name="${1:?profile name required}" pid - pid="$(profile_to_pid "$name")" - [[ -n "$pid" ]] && { app_focus "$pid"; return; } - app_start "$name" -} - -# switch to a profile quitting all others -cmd_switch() { - local name="${1:?profile name required}" pid dir keep - # marks which profile to keep running, "default" if empty - get_dirs "$name" - keep="${APP_DIR:-"$DEFAULT"}" - # quit all other running claudes except the one to $keep - while read -r pid dir; do - [[ "$dir" == "$keep" ]] && continue - kill "$pid" 2>/dev/null || true - done < <(running_info) - # then launch selected profile - cmd_launch "$name" -} - -# quit a profile if running -cmd_quit() { - local name="${1:?profile name required}" pid - pid="$(profile_to_pid "$name")" - [[ -z "$pid" ]] && { echo "$name isn't running"; return; } - kill "$pid" -} - -# main entry point -case "${1:-}" in - launch) cmd_launch "${2:-}" ;; - switch) cmd_switch "${2:-}" ;; - quit) cmd_quit "${2:-}" ;; - list) cmd_list ;; - -h|--help) show_usage; exit 0 ;; - *) show_usage; exit 1 ;; -esac