Skip to content

Commit 7dca4a5

Browse files
Add configurable Claude path setting with error handling
1 parent bd66a64 commit 7dca4a5

5 files changed

Lines changed: 209 additions & 15 deletions

File tree

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,8 @@ Package.resolved
3030
# Temporary files
3131
*.swp
3232
*~
33+
34+
# Claude Code rules (local)
35+
CLAUDE.md
36+
*.zip
37+
generate_icon.swift

clive/CliveApp.swift

Lines changed: 87 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,19 +17,30 @@ class AppDelegate: NSObject, NSApplicationDelegate {
1717
var usageManager: UsageManager?
1818
var settingsWindow: NSWindow?
1919
var currentUsage: UsageInfo?
20+
var currentError: UsageError?
2021
var settingsCancellable: AnyCancellable?
2122

2223
func applicationDidFinishLaunching(_ notification: Notification) {
2324
setupMenuBar()
24-
usageManager = UsageManager { [weak self] usage in
25-
self?.currentUsage = usage
26-
self?.updateMenuBar(with: usage)
27-
}
25+
usageManager = UsageManager(
26+
onUpdate: { [weak self] usage in
27+
self?.currentUsage = usage
28+
self?.updateMenuBar(with: usage)
29+
},
30+
onError: { [weak self] error in
31+
self?.currentError = error
32+
self?.updateMenuBarForError(error)
33+
}
34+
)
2835
usageManager?.startPolling()
2936

3037
// Listen for settings changes
3138
settingsCancellable = SettingsManager.shared.$displayMode.sink { [weak self] _ in
32-
self?.updateMenuBar(with: self?.currentUsage)
39+
if self?.currentError != nil {
40+
self?.updateMenuBarForError(self?.currentError)
41+
} else {
42+
self?.updateMenuBar(with: self?.currentUsage)
43+
}
3344
}
3445
}
3546

@@ -50,6 +61,12 @@ class AppDelegate: NSObject, NSApplicationDelegate {
5061
private func setupMenu() {
5162
let menu = NSMenu()
5263

64+
let errorItem = NSMenuItem(title: "", action: nil, keyEquivalent: "")
65+
errorItem.isEnabled = false
66+
errorItem.tag = 99
67+
errorItem.isHidden = true
68+
menu.addItem(errorItem)
69+
5370
let sessionItem = NSMenuItem(title: "Session: --", action: nil, keyEquivalent: "")
5471
sessionItem.isEnabled = false
5572
sessionItem.tag = 100
@@ -129,11 +146,75 @@ class AppDelegate: NSObject, NSApplicationDelegate {
129146
return Double(numStr)
130147
}
131148

149+
private func updateMenuBarForError(_ error: UsageError?) {
150+
DispatchQueue.main.async { [weak self] in
151+
guard let self = self, let button = self.statusItem?.button else { return }
152+
153+
// Update error menu item
154+
if let menu = self.statusItem?.menu, let errorItem = menu.item(withTag: 99) {
155+
if let error = error {
156+
errorItem.title = error.message
157+
errorItem.isHidden = false
158+
} else {
159+
errorItem.title = ""
160+
errorItem.isHidden = true
161+
}
162+
}
163+
164+
if error != nil {
165+
// Show error state with warning icon
166+
let image = self.createErrorIcon()
167+
image.isTemplate = false
168+
button.image = image
169+
button.title = "CC:"
170+
button.imagePosition = .imageRight
171+
172+
// Also clear the usage display items
173+
if let menu = self.statusItem?.menu,
174+
let sessionItem = menu.item(withTag: 100),
175+
let weeklyItem = menu.item(withTag: 101) {
176+
sessionItem.title = "Session: --"
177+
weeklyItem.title = "Weekly: --"
178+
}
179+
} else {
180+
// Error cleared, restore normal display
181+
self.currentError = nil
182+
self.updateMenuBar(with: self.currentUsage)
183+
}
184+
}
185+
}
186+
187+
private func createErrorIcon() -> NSImage {
188+
let size = NSSize(width: 18, height: 18)
189+
let image = NSImage(size: size, flipped: false) { rect in
190+
// Draw warning triangle with exclamation mark
191+
let warningColor = NSColor(red: 1.0, green: 0.3, blue: 0.3, alpha: 1.0)
192+
193+
// Use SF Symbol if available
194+
if let symbolImage = NSImage(systemSymbolName: "exclamationmark.triangle.fill", accessibilityDescription: "Error") {
195+
let config = NSImage.SymbolConfiguration(pointSize: 14, weight: .medium)
196+
let configuredImage = symbolImage.withSymbolConfiguration(config)
197+
198+
// Draw with tint color
199+
warningColor.set()
200+
let imageRect = NSRect(x: (rect.width - 16) / 2, y: (rect.height - 16) / 2, width: 16, height: 16)
201+
configuredImage?.draw(in: imageRect, from: .zero, operation: .sourceOver, fraction: 1.0)
202+
203+
// Apply color tint by drawing over
204+
NSGraphicsContext.current?.cgContext.setBlendMode(.sourceAtop)
205+
warningColor.setFill()
206+
imageRect.fill()
207+
}
208+
return true
209+
}
210+
return image
211+
}
212+
132213
@objc func openSettings() {
133214
if settingsWindow == nil {
134215
let view = SettingsView()
135216
settingsWindow = NSWindow(
136-
contentRect: NSRect(x: 0, y: 0, width: 280, height: 140),
217+
contentRect: NSRect(x: 0, y: 0, width: 400, height: 280),
137218
styleMask: [.titled, .closable],
138219
backing: .buffered,
139220
defer: false

clive/SettingsManager.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ class SettingsManager: ObservableObject {
3131

3232
private let displayModeKey = "displayMode"
3333
private let refreshIntervalKey = "refreshInterval"
34+
private let claudePathKey = "claudePath"
35+
36+
static let defaultClaudePath = "/opt/homebrew/bin/claude"
3437

3538
@Published var displayMode: DisplayMode {
3639
didSet {
@@ -44,6 +47,12 @@ class SettingsManager: ObservableObject {
4447
}
4548
}
4649

50+
@Published var claudePath: String {
51+
didSet {
52+
UserDefaults.standard.set(claudePath, forKey: claudePathKey)
53+
}
54+
}
55+
4756
init() {
4857
if let saved = UserDefaults.standard.string(forKey: displayModeKey),
4958
let mode = DisplayMode(rawValue: saved) {
@@ -58,5 +67,11 @@ class SettingsManager: ObservableObject {
5867
} else {
5968
self.refreshInterval = .fiveMinutes
6069
}
70+
71+
if let savedPath = UserDefaults.standard.string(forKey: claudePathKey), !savedPath.isEmpty {
72+
self.claudePath = savedPath
73+
} else {
74+
self.claudePath = SettingsManager.defaultClaudePath
75+
}
6176
}
6277
}

clive/SettingsView.swift

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import SwiftUI
2+
import AppKit
23

34
struct SettingsView: View {
45
@ObservedObject var settings = SettingsManager.shared
6+
@State private var isPathValid: Bool = true
57

68
var body: some View {
79
Form {
@@ -18,8 +20,58 @@ struct SettingsView: View {
1820
}
1921
}
2022
.pickerStyle(.menu)
23+
24+
Divider()
25+
.padding(.vertical, 8)
26+
27+
VStack(alignment: .leading, spacing: 6) {
28+
Text("Claude Executable Path")
29+
.font(.headline)
30+
31+
HStack {
32+
TextField("Path to claude", text: $settings.claudePath)
33+
.textFieldStyle(.roundedBorder)
34+
.onChange(of: settings.claudePath) { newValue in
35+
isPathValid = FileManager.default.isExecutableFile(atPath: newValue)
36+
}
37+
38+
Button("Browse...") {
39+
selectClaudePath()
40+
}
41+
}
42+
43+
if !isPathValid && !settings.claudePath.isEmpty {
44+
Text("Executable not found at this path")
45+
.font(.caption)
46+
.foregroundColor(.red)
47+
}
48+
49+
Button("Reset to Default") {
50+
settings.claudePath = SettingsManager.defaultClaudePath
51+
isPathValid = FileManager.default.isExecutableFile(atPath: settings.claudePath)
52+
}
53+
.buttonStyle(.link)
54+
.font(.caption)
55+
}
2156
}
2257
.padding(20)
23-
.frame(width: 280, height: 140)
58+
.frame(width: 400, height: 280)
59+
.onAppear {
60+
isPathValid = FileManager.default.isExecutableFile(atPath: settings.claudePath)
61+
}
62+
}
63+
64+
private func selectClaudePath() {
65+
let panel = NSOpenPanel()
66+
panel.title = "Select Claude Executable"
67+
panel.canChooseFiles = true
68+
panel.canChooseDirectories = false
69+
panel.allowsMultipleSelection = false
70+
panel.directoryURL = URL(fileURLWithPath: "/opt/homebrew/bin")
71+
72+
if panel.runModal() == .OK, let url = panel.url {
73+
settings.claudePath = url.path
74+
isPathValid = FileManager.default.isExecutableFile(atPath: settings.claudePath)
75+
}
2476
}
2577
}

clive/UsageManager.swift

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,24 +13,50 @@ struct UsageInfo {
1313
}
1414
}
1515

16+
enum UsageError {
17+
case executableNotFound(path: String)
18+
case launchFailed(error: String)
19+
20+
var message: String {
21+
switch self {
22+
case .executableNotFound(let path):
23+
return "Claude not found at: \(path)"
24+
case .launchFailed(let error):
25+
return "Failed to launch Claude: \(error)"
26+
}
27+
}
28+
}
29+
1630
class UsageManager {
1731
private var refreshTimer: Timer?
1832
private let onUpdate: (UsageInfo?) -> Void
33+
private let onError: (UsageError?) -> Void
1934
private var process: Process?
2035
private var outputPipe: Pipe?
2136
private var timeoutTimer: Timer?
2237
private var isRefreshing = false
23-
private var settingsCancellable: AnyCancellable?
38+
private var settingsCancellables = Set<AnyCancellable>()
2439

2540
private let timeout: TimeInterval = 30
2641

27-
init(onUpdate: @escaping (UsageInfo?) -> Void) {
42+
init(onUpdate: @escaping (UsageInfo?) -> Void, onError: @escaping (UsageError?) -> Void) {
2843
self.onUpdate = onUpdate
44+
self.onError = onError
2945

3046
// Listen for refresh interval changes
31-
settingsCancellable = SettingsManager.shared.$refreshInterval.sink { [weak self] _ in
47+
SettingsManager.shared.$refreshInterval.sink { [weak self] _ in
3248
self?.restartTimer()
33-
}
49+
}.store(in: &settingsCancellables)
50+
51+
// Listen for claude path changes and refresh when changed
52+
SettingsManager.shared.$claudePath.sink { [weak self] _ in
53+
self?.refreshNow()
54+
}.store(in: &settingsCancellables)
55+
}
56+
57+
private func checkExecutableExists() -> Bool {
58+
let path = SettingsManager.shared.claudePath
59+
return FileManager.default.isExecutableFile(atPath: path)
3460
}
3561

3662
func startPolling() {
@@ -68,6 +94,18 @@ class UsageManager {
6894
private func performRefresh() {
6995
isRefreshing = true
7096

97+
let claudePath = SettingsManager.shared.claudePath
98+
99+
// Check if executable exists
100+
guard checkExecutableExists() else {
101+
isRefreshing = false
102+
onError(.executableNotFound(path: claudePath))
103+
return
104+
}
105+
106+
// Clear any previous error on successful check
107+
onError(nil)
108+
71109
let proc = Process()
72110
let pipe = Pipe()
73111

@@ -76,12 +114,15 @@ class UsageManager {
76114
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
77115
proc.currentDirectoryURL = tempDir
78116

117+
// Get directory containing claude for PATH
118+
let claudeDir = (claudePath as NSString).deletingLastPathComponent
119+
79120
// Write expect script to temp directory
80121
let expectScript = """
81122
#!/usr/bin/expect -f
82123
log_user 1
83124
set timeout 25
84-
spawn /opt/homebrew/bin/claude /usage
125+
spawn \(claudePath) /usage
85126
86127
# Handle trust dialog if it appears, then read output
87128
expect {
@@ -112,9 +153,9 @@ class UsageManager {
112153
proc.standardOutput = pipe
113154
proc.standardError = pipe
114155

115-
// Minimal environment
156+
// Minimal environment - include claude's directory in PATH
116157
var env: [String: String] = [:]
117-
env["PATH"] = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
158+
env["PATH"] = "\(claudeDir):/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
118159
env["HOME"] = ProcessInfo.processInfo.environment["HOME"] ?? ""
119160
env["USER"] = ProcessInfo.processInfo.environment["USER"] ?? ""
120161
env["TERM"] = "dumb"
@@ -172,7 +213,7 @@ class UsageManager {
172213
try proc.run()
173214
} catch {
174215
isRefreshing = false
175-
onUpdate(nil)
216+
onError(.launchFailed(error: error.localizedDescription))
176217
}
177218
}
178219

0 commit comments

Comments
 (0)