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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 24 additions & 6 deletions CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9688,12 +9688,30 @@ struct CMUXCLI {
.deletingLastPathComponent()
.appendingPathComponent("claude", isDirectory: false)
.path
let claudeExecutablePath = resolveClaudeExecutable(searchPath: launcherEnvironment["PATH"])
?? {
guard let bundledClaudePath,
FileManager.default.isExecutableFile(atPath: bundledClaudePath) else { return nil }
return bundledClaudePath
}()
let claudeExecutablePath: String? = {
// Check custom path from Settings > Automation > Claude Code.
// Try env var first (set by the app per-session), then UserDefaults.
let candidates = [
launcherEnvironment["CMUX_CUSTOM_CLAUDE_PATH"],
UserDefaults.standard.string(forKey: "claudeCodeCustomClaudePath"),
]
for raw in candidates {
guard let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines),
!trimmed.isEmpty else { continue }
var isDir: ObjCBool = false
guard FileManager.default.fileExists(atPath: trimmed, isDirectory: &isDir),
!isDir.boolValue,
FileManager.default.isExecutableFile(atPath: trimmed),
!isCmuxClaudeWrapper(at: trimmed) else { continue }
return trimmed
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Comment on lines +9691 to +9707

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 CMUX_CUSTOM_CLAUDE_PATH env var overrides the Settings UI silently

The two-source lookup has a subtle precedence issue: any process that injects CMUX_CUSTOM_CLAUDE_PATH into the environment before invoking cmux claude-teams will silently override whatever the user configured in Settings > Automation. This includes the GUI app itself (which sets the env var from the UserDefaults value in GhosttyTerminalView.swift), but also any external script or shell function that happens to set this variable.

In practice, for the GUI-launched path the env var and UserDefaults are always in sync (both come from the same UserDefaults key), so the env-var branch will always win for that case and the UserDefaults branch is effectively dead code in GUI-launched terminals. The UserDefaults branch is only reachable when cmux claude-teams is run directly from the shell without the app setting the env var.

This is probably intentional, but it's worth confirming that having a hidden override mechanism (env var) that takes priority over the visible Settings UI is the desired UX — particularly because there is no user-visible indication that a different path is being used when the env var is set externally.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The env var taking precedence over UserDefaults is intentional: the app sets it per-session from the same UserDefaults value. The env var path exists so the bundled wrapper script can read the setting without accessing UserDefaults directly. In practice they always agree.

— Claude Code

return resolveClaudeExecutable(searchPath: launcherEnvironment["PATH"])
?? {
guard let bundledClaudePath,
FileManager.default.isExecutableFile(atPath: bundledClaudePath) else { return nil }
return bundledClaudePath
}()
}()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
configureClaudeTeamsEnvironment(
processEnvironment: launcherEnvironment,
shimDirectory: shimDirectory,
Expand Down
51 changes: 51 additions & 0 deletions Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -53123,6 +53123,57 @@
}
}
},
"settings.automation.claudeCode.customPath": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Claude Binary Path"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "Claudeバイナリパス"
}
}
}
},
"settings.automation.claudeCode.customPath.placeholder": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "e.g. /usr/local/bin/claude"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "例: /usr/local/bin/claude"
}
}
}
},
"settings.automation.claudeCode.customPath.subtitle": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Custom path to the claude binary. Leave empty to use PATH."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "claudeバイナリのカスタムパス。空欄の場合はPATHを使用します。"
}
}
}
},
"settings.automation.claudeCode.note": {
"extractionState": "manual",
"localizations": {
Expand Down
12 changes: 12 additions & 0 deletions Resources/bin/claude
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,18 @@

# Find the real claude binary, skipping our own directory.
find_real_claude() {
# Honor custom path from Settings > Automation > Claude Code > Claude Binary Path.
# Guard against self-reference (user pointing back to this wrapper).
local custom="${CMUX_CUSTOM_CLAUDE_PATH:-}"
custom="${custom#"${custom%%[![:space:]]*}"}" # trim leading whitespace
custom="${custom%"${custom##*[![:space:]]}"}" # trim trailing whitespace
if [[ -n "$custom" && -f "$custom" && -x "$custom" ]]; then
# Use -ef to detect same underlying file even through symlinks
if [[ ! "$custom" -ef "$0" ]]; then
printf '%s' "$custom"
return 0
fi
fi
local self_dir
self_dir="$(cd "$(dirname "$0")" && pwd)"
local IFS=:
Expand Down
3 changes: 3 additions & 0 deletions Sources/GhosttyTerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3756,6 +3756,9 @@ final class TerminalSurface: Identifiable, ObservableObject {
if !claudeHooksEnabled {
setManagedEnvironmentValue("CMUX_CLAUDE_HOOKS_DISABLED", "1")
}
if let customClaudePath = ClaudeCodeIntegrationSettings.customClaudePath() {
setManagedEnvironmentValue("CMUX_CUSTOM_CLAUDE_PATH", customClaudePath)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if let cliBinPath = Bundle.main.resourceURL?.appendingPathComponent("bin").path {
let currentPath = env["PATH"]
Expand Down
24 changes: 24 additions & 0 deletions Sources/cmuxApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3921,13 +3921,20 @@ enum CommandPaletteSwitcherSearchSettings {
enum ClaudeCodeIntegrationSettings {
static let hooksEnabledKey = "claudeCodeHooksEnabled"
static let defaultHooksEnabled = true
static let customClaudePathKey = "claudeCodeCustomClaudePath"

static func hooksEnabled(defaults: UserDefaults = .standard) -> Bool {
if defaults.object(forKey: hooksEnabledKey) == nil {
return defaultHooksEnabled
}
return defaults.bool(forKey: hooksEnabledKey)
}

static func customClaudePath(defaults: UserDefaults = .standard) -> String? {
let value = defaults.string(forKey: customClaudePathKey)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return value.isEmpty ? nil : value
}
Comment on lines +3933 to +3937

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Missing whitespace trimming before use

customClaudePath() does not trim leading/trailing whitespace from the stored value. If a user accidentally enters a path with surrounding spaces, both FileManager.default.isExecutableFile(atPath:) in the CLI and the -x check in the shell wrapper will silently fail, causing a quiet fallback to PATH resolution with no indication of why the custom path was ignored.

This is inconsistent with the existing pattern used by PreferredEditorSettings.resolvedCommand() in the same file, which applies .trimmingCharacters(in: .whitespacesAndNewlines) before the empty check. The same trimming should be applied here to stay consistent and avoid confusing silent fallbacks.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. customClaudePath() now trims whitespace before the empty check.

— Claude Code

Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

enum WelcomeSettings {
Expand Down Expand Up @@ -4006,6 +4013,8 @@ struct SettingsView: View {
@AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue
@AppStorage(ClaudeCodeIntegrationSettings.hooksEnabledKey)
private var claudeCodeHooksEnabled = ClaudeCodeIntegrationSettings.defaultHooksEnabled
@AppStorage(ClaudeCodeIntegrationSettings.customClaudePathKey)
private var customClaudePath = ""
@AppStorage(TelemetrySettings.sendAnonymousTelemetryKey)
private var sendAnonymousTelemetry = TelemetrySettings.defaultSendAnonymousTelemetry
@AppStorage(PreferredEditorSettings.key) private var preferredEditorCommand = ""
Expand Down Expand Up @@ -5383,6 +5392,20 @@ struct SettingsView: View {
SettingsCardNote(String(localized: "settings.automation.claudeCode.note", defaultValue: "When enabled, cmux wraps the claude command to inject session tracking and notification hooks. Disable if you prefer to manage Claude Code hooks yourself."))
}

SettingsCard {
SettingsCardRow(
String(localized: "settings.automation.claudeCode.customPath", defaultValue: "Claude Binary Path"),
subtitle: String(localized: "settings.automation.claudeCode.customPath.subtitle", defaultValue: "Custom path to the claude binary. Leave empty to use PATH.")
) {
TextField(
String(localized: "settings.automation.claudeCode.customPath.placeholder", defaultValue: "e.g. /usr/local/bin/claude"),
text: $customClaudePath
)
.textFieldStyle(.roundedBorder)
Comment on lines 5394 to +5404

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Fixed-width text field may be too narrow for long paths

The TextField is constrained to 200 points, which is often insufficient for real filesystem paths (e.g., /Users/username/.volta/bin/claude or paths inside Homebrew's opt tree). Users will need to scroll within the field to verify what they typed, which makes accidental typos hard to spot.

Consider using .frame(maxWidth: .infinity) or a larger fixed width, or following the pattern used for the "Preferred Editor" field elsewhere in the view, to give users enough room to read the full path they've entered.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

.frame(width: 200)
}
}

SettingsCard {
SettingsCardRow(String(localized: "settings.automation.portBase", defaultValue: "Port Base"), subtitle: String(localized: "settings.automation.portBase.subtitle", defaultValue: "Starting port for CMUX_PORT env var."), controlWidth: pickerColumnWidth) {
TextField("", value: $cmuxPortBase, format: .number)
Expand Down Expand Up @@ -5945,6 +5968,7 @@ struct SettingsView: View {
AppIconSettings.applyIcon(.automatic)
socketControlMode = SocketControlSettings.defaultMode.rawValue
claudeCodeHooksEnabled = ClaudeCodeIntegrationSettings.defaultHooksEnabled
customClaudePath = ""
sendAnonymousTelemetry = TelemetrySettings.defaultSendAnonymousTelemetry
preferredEditorCommand = ""
browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue
Expand Down
Loading