Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
f40faee
Add setting actions, setting presets, and cmux config set
teamleaderleo Sep 26, 2026
ba012db
Address review: empty preset objects, fail-closed trust, docs
teamleaderleo Sep 26, 2026
479c6e9
Merge main (b1daa44765bb) into setting-actions
teamleaderleo Sep 27, 2026
e478696
Honor confirm on setting actions and explain keys containing "."
teamleaderleo Sep 27, 2026
ee53d48
Write fractional setting values in their short form
teamleaderleo Sep 27, 2026
7f9dcbd
Start setting changes from the live value; keep project configs out
teamleaderleo Sep 27, 2026
7fb4665
Only trust live values that are stored in cmux.json form
teamleaderleo Sep 27, 2026
1f2d10d
Merge main (5c6849915490) into setting-actions
teamleaderleo Sep 27, 2026
2a889ab
Map the session content width sentinel back to false
teamleaderleo Sep 27, 2026
81bda19
Merge commit '8685bf5f7f5d7d5fa234ff0ff7cacf5e1db14b11' into setting-…
teamleaderleo Sep 27, 2026
2b3b53a
Merge main (f5c179fb9427) into setting-actions
teamleaderleo Sep 27, 2026
5456a4a
Move the changelog line to the end of Unreleased/Added
teamleaderleo Sep 27, 2026
13aaffb
Merge remote-tracking branch 'mf/main' into setting-actions
teamleaderleo Sep 28, 2026
3da6e2f
Merge main (478e3232b099) into setting-actions
teamleaderleo Sep 30, 2026
b71f2be
Fix setting action docs rich text
teamleaderleo Sep 30, 2026
97d8ac9
Fix setting presets from global packs
teamleaderleo Sep 30, 2026
5bb811a
Merge main (d87c3be1c054) into setting-actions
teamleaderleo Sep 30, 2026
dcc8b22
Merge main (efb762c914e7) into setting-actions
teamleaderleo Sep 30, 2026
a8ba12c
Merge main (64ec56d4cf96) into setting-actions
teamleaderleo Sep 30, 2026
2cc8f3d
Fix settings module import
teamleaderleo Sep 30, 2026
c4bfe5c
Fix config executor settings import
teamleaderleo Sep 30, 2026
6529fdb
Add setting actions dogfood tour
teamleaderleo Sep 30, 2026
b028d06
Fix setting actions tour JSON
teamleaderleo Sep 30, 2026
493597d
Refine setting actions dogfood tour
teamleaderleo Sep 30, 2026
efd6735
Make setting actions tour reload config
teamleaderleo Sep 30, 2026
9b225b8
Merge main (6d5645c487a7) into setting-actions
teamleaderleo Sep 30, 2026
91799dd
Merge main into setting actions
teamleaderleo Sep 30, 2026
0cb867c
Isolate setting actions dogfood config
teamleaderleo Sep 30, 2026
aa16e45
Use isolated home in setting actions tour shell
teamleaderleo Sep 30, 2026
e82d086
Merge main (e709b69d8c8c) into setting-actions
teamleaderleo Sep 30, 2026
7614b6d
Fix setting actions tour cleanup
teamleaderleo Sep 30, 2026
9913844
Merge main (b3d644ba8732) into setting-actions
teamleaderleo Sep 30, 2026
74c3a5f
Harden setting action persistence
teamleaderleo Sep 30, 2026
a70f1fb
Merge main (fc3e6e33d98e) into setting-actions
teamleaderleo Sep 30, 2026
0044788
Merge main (a6c098abf20d) into setting-actions
teamleaderleo Sep 30, 2026
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
192 changes: 179 additions & 13 deletions CLI/CMUXCLI+Config.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Foundation
import CmuxFoundation
import CmuxSettings

extension CMUXCLI {
func runConfigCommand(
Expand All @@ -22,21 +23,55 @@ extension CMUXCLI {
case "help":
print(configUsage())
case "get":
guard args.count == 2, let key = canonicalFontSizeKey(args[1]) else {
throw CLIError(message: "Usage: cmux config get <sidebar-font-size|surface-tab-bar-font-size>")
guard args.count == 2 else {
throw CLIError(message: "Usage: cmux config get <setting.path|sidebar-font-size|surface-tab-bar-font-size>")
}
if let key = canonicalFontSizeKey(args[1]) {
try runConfigGetFontSize(forKey: key, jsonOutput: wantsJSON)
} else {
try runConfigGetSetting(path: args[1], jsonOutput: wantsJSON)
}
try runConfigGetFontSize(forKey: key, jsonOutput: wantsJSON)
case "set":
guard args.count == 3, let key = canonicalFontSizeKey(args[1]) else {
throw CLIError(message: "Usage: cmux config set <sidebar-font-size|surface-tab-bar-font-size> <points>")
guard args.count == 3 else {
throw CLIError(message: "Usage: cmux config set <setting.path> <value>")
}
try runConfigSetFontSize(
forKey: key,
rawValue: args[2],
socketPath: socketPath,
explicitPassword: explicitPassword,
if let key = canonicalFontSizeKey(args[1]) {
try runConfigSetFontSize(
forKey: key,
rawValue: args[2],
socketPath: socketPath,
explicitPassword: explicitPassword,
jsonOutput: wantsJSON
)
} else {
try runConfigSettingChange(
.set(path: args[1], value: CmuxSettingValue(commandLineArgument: args[2])),
jsonOutput: wantsJSON
)
}
case "unset":
guard args.count == 2 else {
throw CLIError(message: "Usage: cmux config unset <setting.path>")
}
try runConfigSettingChange(.unset(path: args[1]), jsonOutput: wantsJSON)
case "toggle":
guard args.count == 2 else {
throw CLIError(message: "Usage: cmux config toggle <setting.path>")
}
try runConfigSettingChange(.toggle(path: args[1]), jsonOutput: wantsJSON)
case "cycle":
guard args.count >= 3 else {
throw CLIError(message: "Usage: cmux config cycle <setting.path> <value> [value...]")
}
try runConfigSettingChange(
.cycle(path: args[1], values: args.dropFirst(2).map { CmuxSettingValue(commandLineArgument: $0) }),
jsonOutput: wantsJSON
)
case "preset":
guard args.count == 2 else {
throw CLIError(message: "Usage: cmux config preset <name>")
}
try runConfigSettingChange(.preset(name: args[1]), jsonOutput: wantsJSON)
case CmuxGhosttyConfigSettingEditor.sidebarFontSizeKey, CmuxGhosttyConfigSettingEditor.surfaceTabBarFontSizeKey:
if args.count == 1 {
try runConfigGetFontSize(forKey: subcommand, jsonOutput: wantsJSON)
Expand Down Expand Up @@ -104,9 +139,15 @@ extension CMUXCLI {
func configCommandDoesNotNeedSocket(_ commandArgs: [String]) -> Bool {
let parsedArgs = docsSettingsArguments(commandArgs)
let subcommand = parsedArgs.arguments.first?.lowercased() ?? "help"
if subcommand == "get" {
if ["get", "unset", "toggle", "cycle", "preset"].contains(subcommand) {
return true
}
// `set` only reloads the running app for the Ghostty font-size keys;
// cmux.json settings are applied by the app's file watcher.
if subcommand == "set" {
let key = parsedArgs.arguments.count > 1 ? parsedArgs.arguments[1] : ""
return canonicalFontSizeKey(key) == nil
}
if subcommand == CmuxGhosttyConfigSettingEditor.sidebarFontSizeKey
|| subcommand == CmuxGhosttyConfigSettingEditor.surfaceTabBarFontSizeKey {
return parsedArgs.arguments.count == 1
Expand All @@ -121,21 +162,31 @@ extension CMUXCLI {
defaultValue: "Validate JSONC syntax and cmux config semantics."
)
return """
Usage: cmux config <doctor|check|validate|path|paths|docs|documentation|reload|get|set|sidebar-font-size|surface-tab-bar-font-size>
Usage: cmux config <doctor|check|validate|path|paths|docs|documentation|reload|get|set|unset|toggle|cycle|preset|sidebar-font-size|surface-tab-bar-font-size>

Inspect cmux.json, print configuration references, update selected Ghostty config keys, or reload the running app.
Inspect and change cmux.json settings, print configuration references, update selected Ghostty config keys, or reload the running app.

Subcommands:
doctor|check|validate [--path <path>] [--scope <global|project>]
\(validationHelp)
path|paths Print cmux.json paths, docs URL, and schema URL.
docs|documentation Print the same output as `cmux docs settings`.
reload Reload Ghostty config + cmux.json and refresh terminals (alias for `cmux reload-config`).
get <setting.path> Print a setting: its cmux.json value, else the value set in Settings, else the default.
set <setting.path> <value> Write a setting to ~/.config/cmux/cmux.json. <value> is JSON (true, 1.4, "dark", [...]);
other text is stored as a string. Comments and other keys are kept.
unset <setting.path> Remove a setting from cmux.json, so the value set in Settings (or the default) applies.
toggle <setting.path> Flip a true/false setting, starting from the value cmux is using.
cycle <setting.path> <value> [value...] Move a setting to the value after its current one, wrapping around.
preset <name> Apply the settings stored at settingPresets.<name> in cmux.json.
get <key> Print sidebar-font-size or surface-tab-bar-font-size.
set <key> <points> Set sidebar-font-size (10-20 pt) or surface-tab-bar-font-size (8-24 pt), then reload if cmux is running.
sidebar-font-size [points] Get or set the left sidebar text size.
surface-tab-bar-font-size [points] Get or set the workspace tab bar text size.

Setting changes are validated against the cmux.json schema before anything is written. A running
cmux applies them automatically; no reload is needed.

Config files:
\(Self.primarySettingsDisplayPath)
legacy config: \(Self.legacySettingsDisplayPath)
Expand All @@ -145,6 +196,11 @@ extension CMUXCLI {
\(Self.ghosttyConfigDisplayPath)

Examples:
cmux config get terminal.scrollSpeed
cmux config set terminal.scrollSpeed 1.4
cmux config toggle fileEditor.wordWrap
cmux config cycle terminal.scrollSpeed 1.0 1.4 1.8
cmux config preset sidebar.quiet
cmux config doctor
cmux config doctor --path .cmux/cmux.json
cmux config set sidebar-font-size 14
Expand Down Expand Up @@ -197,6 +253,116 @@ extension CMUXCLI {
print(" cmux reload-config")
}

/// Applies a setting change to the global cmux.json through the same
/// ``JSONConfigStore/apply(_:)`` path setting actions use.
private func runConfigSettingChange(_ change: CmuxSettingChange, jsonOutput: Bool) throws {
let store = JSONConfigStore(fileURL: CmuxConfigLocation().userConfigFile)
let result: CmuxSettingChangeResult
do {
let liveValues = Self.configSettingLiveValues()
result = try runConfigSettingsBlocking { try await store.apply(change, liveValues: liveValues) }
} catch {
throw CLIError(message: error.localizedDescription)
}
let changed = result.receipts.filter { $0.before != $0.installed }

if jsonOutput {
let paths: [[String: Any]] = result.receipts.map { receipt in
var entry: [String: Any] = [
"path": receipt.path,
"changed": receipt.before != receipt.installed,
]
if let value = result.installedValue(at: receipt.path) {
entry["value"] = value.jsonObject
}
return entry
}
print(jsonString([
"ok": true,
"file": store.fileURL.path,
"paths": paths,
]))
return
}

if changed.isEmpty {
print("OK unchanged")
}
for receipt in changed {
if let value = result.installedValue(at: receipt.path) {
print("OK \(receipt.path) = \(value.jsonText)")
} else {
print("OK \(receipt.path) unset")
}
}
print("path: \(Self.tildePath(store.fileURL.path))")
}

private func runConfigGetSetting(path: String, jsonOutput: Bool) throws {
let store = JSONConfigStore(fileURL: CmuxConfigLocation().userConfigFile)
let reading: CmuxSettingReading
do {
reading = try store.reading(at: path, liveValues: Self.configSettingLiveValues())
} catch {
throw CLIError(message: error.localizedDescription)
}

let source: String
if reading.configured != nil {
source = "cmux.json"
} else if reading.live != nil {
source = "settings"
} else {
source = "default"
}

if jsonOutput {
var payload: [String: Any] = [
"path": reading.path,
"file": store.fileURL.path,
"configured": reading.configured != nil,
"source": source,
]
payload["value"] = reading.effective?.jsonObject ?? NSNull()
if let defaultValue = reading.defaultValue {
payload["default"] = defaultValue.jsonObject
}
print(jsonString(payload))
return
}

let suffix: String
switch source {
case "settings": suffix = " (set in Settings, not cmux.json)"
case "default": suffix = " (default)"
default: suffix = ""
}
print("\(reading.path) = \(reading.effective?.jsonText ?? "null")\(suffix)")
}

/// Reads settings the Settings window stored in the cmux app's
/// UserDefaults, so `get`, `toggle`, and `cycle` start from the value
/// the app is using when cmux.json doesn't set the key.
private static func configSettingLiveValues() -> CmuxSettingLiveValues {
.userDefaults(suiteName: CLISocketPathResolver.currentAppBundleIdentifier())
}

/// Bridges the actor-backed store to this synchronous command. The
/// semaphore orders the result hand-off after the task's write.
private func runConfigSettingsBlocking<T: Sendable>(
_ work: @escaping @Sendable () async throws -> T
) throws -> T {
let semaphore = DispatchSemaphore(value: 0)
nonisolated(unsafe) var output: Result<T, any Error>!
Task {
do { output = .success(try await work()) }
catch { output = .failure(error) }
semaphore.signal()
}
semaphore.wait()
return try output.get()
}

/// Normalizes a user-supplied key to a supported editable font-size key, or nil if unsupported.
private func canonicalFontSizeKey(_ raw: String) -> String? {
switch raw.lowercased() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ enum CmuxEmbeddedConfigSchema {
},
"actions": {
"title": "actions",
"description": "Action registry used by the surface tab bar, Command Palette, shortcuts, and plus-button menu. Each entry supports type \"builtin\", \"command\", \"agent\" (any CLI agent name, e.g. claude, codex, opencode, or a custom binary, with optional args), \"workspaceCommand\", or \"workspace\" (inline workspace with name/cwd/color/env/setup/layout, plus optional restart). Inline workspace entries are auto-offered in the new-workspace plus-button menu; set newWorkspaceMenu true/false on any action to override. \"Save Workspace as Layout\" in the plus-button menu writes entries here.",
"description": "Action registry used by the surface tab bar, Command Palette, shortcuts, and plus-button menu. Each entry supports type \"builtin\", \"command\", \"agent\" (any CLI agent name, e.g. claude, codex, opencode, or a custom binary, with optional args), \"workspaceCommand\", \"workspace\" (inline workspace with name/cwd/color/env/setup/layout, plus optional restart), \"setting\" (change one setting in the global cmux.json: path plus exactly one of set, toggle: true, cycle, or unset: true), or \"settingPreset\" (apply a named entry from settingPresets). Setting actions only run when declared in the global ~/.config/cmux/cmux.json or a pack it references; with confirm: true they ask before saving. Inline workspace entries are auto-offered in the new-workspace plus-button menu; set newWorkspaceMenu true/false on any action to override. \"Save Workspace as Layout\" in the plus-button menu writes entries here.",
"type": "object",
"additionalProperties": true
},
Expand Down Expand Up @@ -108,6 +108,27 @@ enum CmuxEmbeddedConfigSchema {
"additionalProperties": true
}
},
"settingPresets": {
"x-cmux-scopes": ["global"],
"title": "settingPresets",
"description": "Named groups of settings applied together by a \"settingPreset\" action or `cmux config preset <name>`. Each preset is a partial cmux.json holding only settings sections, for example {\"sidebar\": {\"showPorts\": false}}. Nested objects merge key by key; other values replace the current one. Keys the preset doesn't name keep their values.",
"type": "object",
"default": {},
"additionalProperties": {
"allOf": [
{ "$ref": "#" },
{
"type": "object",
"minProperties": 1,
"propertyNames": {
"not": {
"enum": ["$schema", "schemaVersion", "actions", "commands", "newWorkspaceCommand", "packs", "rightSidebar", "settingPresets", "surfaceTabBarButtons", "ui", "vault"]
}
}
}
]
}
},
"computerUse": {
"x-cmux-scopes": ["global"],
"title": "computerUse",
Expand Down
Loading
Loading