2 Commits
Author SHA1 Message Date
Claude Opus 5andbdeshi c6a7da54ca Show profile paths, report the build version, and colour each profile
- Selecting a row in the Profiles tab shows its Claude Desktop and Claude
  Code dirs inline and selectable, instead of requiring a right-click
  "Reveal in Finder" to find out where a profile actually lives.
- build.sh stamps Contents/Resources/COMMIT with the short HEAD SHA, left
  empty when HEAD sits exactly on a tag (how a real release is built),
  since the version number alone is unambiguous there. About appends it
  when present, so two dev builds off the same VERSION are
  distinguishable. The update checker still compares plain semver.
- Profile dot colours are picked per NSApp.effectiveAppearance at render
  time: same hue either way, but full saturation with brightness split
  per background (1.0 dark, 0.55 light), so they read as bold hues rather
  than washing out on white or muddying against a dark menu.
2026-08-04 22:44:21 +06:00
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 1922 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.displayVersion)
"""
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)
}
}
+434
View File
@@ -0,0 +1,434 @@
// 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 detailStack = NSStackView()
private let detailAppValue = NSTextField(labelWithString: "")
private let detailCodeValue = NSTextField(labelWithString: "")
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
buildDetailStack()
detailStack.isHidden = true
detailStack.translatesAutoresizingMaskIntoConstraints = false
buildAddForm()
addForm.isHidden = true
addForm.translatesAutoresizingMaskIntoConstraints = false
let stack = NSStackView(views: [scroll, detailStack, 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),
detailStack.widthAnchor.constraint(equalTo: stack.widthAnchor),
])
}
// Shows the selected profile's two dirs the thing you'd otherwise
// have to right-click "Reveal" to find out.
private func buildDetailStack() {
for value in [detailAppValue, detailCodeValue] {
value.isSelectable = true
value.textColor = .secondaryLabelColor
value.font = .systemFont(ofSize: NSFont.smallSystemFontSize)
value.lineBreakMode = .byTruncatingMiddle
value.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
}
let appRow = NSStackView(views: [labeled("Claude Desktop:"), detailAppValue])
appRow.orientation = .horizontal
appRow.spacing = 6
let codeRow = NSStackView(views: [labeled("Claude Code:"), detailCodeValue])
codeRow.orientation = .horizontal
codeRow.spacing = 6
detailStack.orientation = .vertical
detailStack.alignment = .leading
detailStack.spacing = 2
[appRow, codeRow].forEach(detailStack.addArrangedSubview)
appRow.widthAnchor.constraint(equalTo: detailStack.widthAnchor).isActive = true
codeRow.widthAnchor.constraint(equalTo: detailStack.widthAnchor).isActive = true
}
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()
refreshForSelection()
}
private func refreshForSelection() {
let row = tableView.selectedRow
guard infos.indices.contains(row) else {
removeButton.isEnabled = false
detailStack.isHidden = true
return
}
let profile = infos[row].profile
removeButton.isEnabled = profile.name != "default"
detailAppValue.stringValue = profile.appDir
detailCodeValue.stringValue = profile.codeDir
detailStack.isHidden = false
}
// 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) {
refreshForSelection()
}
// 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)
}
}
+48
View File
@@ -0,0 +1,48 @@
// 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
}
// A brightness/saturation pair that reads clearly on its own
// background a color bright enough to pop on a dark menu would wash
// out on a light one, and vice versa, so this picks per current
// appearance rather than using one fixed value for both.
private static func isDarkAppearance() -> Bool {
NSApp.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
}
static func dotColor(for name: String) -> NSColor {
let hue = CGFloat(fnv1aHash(name) % 360) / 360.0
let (saturation, brightness): (CGFloat, CGFloat) = isDarkAppearance() ? (0.90, 1.0) : (1.0, 0.55)
return NSColor(calibratedHue: hue, saturation: saturation, brightness: brightness, 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)
}
}
+105
View File
@@ -0,0 +1,105 @@
// 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)
}
// What the About panel shows: the version alone for a real release
// build (COMMIT is written empty when build.sh finds HEAD sitting
// exactly on a tag), or version + short SHA for a dev build, so two
// local builds off the same version number are distinguishable.
static var displayVersion: String {
commitSHA.isEmpty ? currentVersion : "\(currentVersion) (\(commitSHA))"
}
private static var commitSHA: String {
guard let resourceURL = Bundle.main.resourceURL,
let contents = try? String(contentsOf: resourceURL.appendingPathComponent("COMMIT"), encoding: .utf8)
else { return "" }
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.2
Executable
+72
View File
@@ -0,0 +1,72 @@
#!/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"
# COMMIT is the short SHA the About panel appends to the version, so a dev
# build is distinguishable from another — left empty for a real release
# build (HEAD sitting exactly on a tag, which is how release.yml checks
# this out), since the version number alone is unambiguous there.
GIT_SHA=""
if git -C "$SCRIPT_DIR" rev-parse --git-dir >/dev/null 2>&1 \
&& ! git -C "$SCRIPT_DIR" describe --tags --exact-match >/dev/null 2>&1; then
GIT_SHA="$(git -C "$SCRIPT_DIR" rev-parse --short HEAD 2>/dev/null || true)"
fi
printf '%s' "$GIT_SHA" >"$APP/Contents/Resources/COMMIT"
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