1 Commits
Author SHA1 Message Date
Claude Opus 5andbdeshi 135db9b308 Rewrite shannoncoat as a native Swift menu-bar app
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/<name>.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.
2026-08-04 21:56:22 +06:00
18 changed files with 1842 additions and 182 deletions
+41
View File
@@ -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
+49
View File
@@ -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
+141
View File
@@ -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
+292
View File
@@ -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)
}
}
+240
View File
@@ -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)
}
}
}
}
+21
View File
@@ -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()
}
}
+87
View File
@@ -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)
}
}
+390
View File
@@ -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/<name>/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/<name>/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 ? "<name>" : 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()
}
}
}
}
}
+86
View File
@@ -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<kinfo_proc>.stride + 1
var procs = [kinfo_proc](repeating: kinfo_proc(), count: capacity)
var actualSize = capacity * MemoryLayout<kinfo_proc>.stride
guard sysctl(&mib, 4, &procs, &actualSize, nil, 0) == 0 else { return [] }
let actualCount = actualSize / MemoryLayout<kinfo_proc>.stride
return procs[0..<actualCount].map { $0.kp_proc.p_pid }
}
private struct ExecInfo { let execPath: String; let args: [String] }
// KERN_PROCARGS2 buffer layout: argc (Int32), then the exec path
// (NUL-terminated, followed by NUL padding), then argv[0..<argc] each
// NUL-terminated, then envp (which we don't read). Same layout `ps`
// itself parses.
private static func execInfo(pid: pid_t) -> ExecInfo? {
var mib: [Int32] = [CTL_KERN, KERN_PROCARGS2, pid]
var size = 0
guard sysctl(&mib, 3, nil, &size, nil, 0) == 0, size > MemoryLayout<Int32>.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..<MemoryLayout<Int32>.size])
}
var offset = MemoryLayout<Int32>.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..<offset], as: UTF8.self)
while offset < size, buffer[offset] == 0 { offset += 1 } // skip padding NULs
return string
}
guard let execPath = readCString() else { return nil }
var args: [String] = []
var i: Int32 = 0
while i < argc, let arg = readCString() {
args.append(arg)
i += 1
}
return ExecInfo(execPath: execPath, args: args)
}
}
+39
View File
@@ -0,0 +1,39 @@
// A stable, well-distributed color per profile name, shared by the menu
// bar dots and the Manage window's profile list so the same name always
// reads as the same color everywhere.
import AppKit
enum ProfileColor {
// FNV-1a: a proper avalanching hash, not a preset-palette lookup the
// full hue wheel is available, and single-character name edits (e.g.
// "personal" vs "personal2") land on unrelated hues instead of an
// adjacent bucket the way a plain sum-of-scalars hash mod'd into a
// short color list would.
private static func fnv1aHash(_ s: String) -> 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
}
}
+180
View File
@@ -0,0 +1,180 @@
// Profile config storage: one ~/.shannoncoat/<name>.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)..<profiles.endIndex {
if profiles[i].codeDir == profiles[j].codeDir {
collisions.append(DirCollision(
profileA: profiles[i].name, profileB: profiles[j].name,
dir: profiles[i].codeDir, kind: "Claude Code"))
}
if profiles[i].appDir == profiles[j].appDir {
collisions.append(DirCollision(
profileA: profiles[i].name, profileB: profiles[j].name,
dir: profiles[i].appDir, kind: "Claude Desktop"))
}
}
}
return collisions
}
@discardableResult
static func create(name: String, codeDir: String?, appDir: String?) throws -> 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)
}
}
+90
View File
@@ -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..<max(av.count, bv.count) {
let x = i < av.count ? av[i] : 0
let y = i < bv.count ? bv[i] : 0
if x != y { return x > 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))
}
}
}
+10
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
0.0.1
Executable
+60
View File
@@ -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" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key><string>shannoncoat</string>
<key>CFBundleDisplayName</key><string>shannoncoat</string>
<key>CFBundleIdentifier</key><string>com.local.shannoncoat</string>
<key>CFBundleVersion</key><string>$VERSION</string>
<key>CFBundleShortVersionString</key><string>$VERSION</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleExecutable</key><string>launcher</string>
<key>CFBundleIconFile</key><string>AppIcon</string>
<key>LSUIElement</key><true/>
<key>NSHighResolutionCapable</key><true/>
</dict>
</plist>
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."
BIN
View File
Binary file not shown.
+115
View File
@@ -0,0 +1,115 @@
// Generates a placeholder AppIcon.iconset a plain rounded-square
// background with the coat glyph on top. Deliberately not derived from
// Claude's own icon assets (unlike the old version of this file, which
// composited on top of Claude.app's icon) this is a stand-in until a
// real icon is designed, so it draws everything itself from vector shapes.
//
// Usage:
// swift icon/generate-icon.swift icon/AppIcon.iconset
// iconutil -c icns icon/AppIcon.iconset -o icon/AppIcon.icns
// rm -rf icon/AppIcon.iconset
import Cocoa
// Renders at exact pixel dimensions via an explicit NSBitmapImageRep rather
// than NSImage.lockFocus() lockFocus rasterizes at the *main screen's*
// backing scale factor, which would silently double every size on a Retina
// display instead of producing the exact px asked for.
func renderIcon(pixels: Int) -> 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)")
}
-182
View File
@@ -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 <<EOF
Usage: $0 <command> [profile]
launch <profile> open a profile, leaving others running
switch <profile> quit all other profiles, then open this one
quit <profile> 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 "pid<TAB>user-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 <<EOF || true
tell application "System Events"
set proc to first process whose unix id is $pid
set frontmost of proc to true
try
perform action "AXRaise" of window 1 of proc
end try
end tell
EOF
}
# start a claude desktop process with the right env vars and user-data-dir
app_start() {
get_dirs "$1"
# app dir empty means default profile, just open normally
[[ -z "$APP_DIR" ]] && { open -a "$APP"; return; }
mkdir -p "$APP_DIR" "$CODE_DIR"
CLAUDE_CONFIG_DIR="$CODE_DIR" "$BIN" --user-data-dir="$APP_DIR" > /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