Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
1c2db4a
config: annotate project-local schema scope
teamleaderleo Sep 19, 2026
93459cf
config: add schema embedding generator
teamleaderleo Sep 19, 2026
16260cb
config: embed canonical cmux schema in CLI
teamleaderleo Sep 19, 2026
7caddfa
config: add schema-backed semantic validator
teamleaderleo Sep 19, 2026
064df6d
config: harden semantic validator traversal
teamleaderleo Sep 19, 2026
cadc9a7
config: make validate enforce semantic schema
teamleaderleo Sep 19, 2026
c0d396e
settings helper: validate mutations before writing
teamleaderleo Sep 19, 2026
8adc6ac
config: preserve recognized right sidebar section
teamleaderleo Sep 19, 2026
dc74639
config: regenerate embedded schema
teamleaderleo Sep 19, 2026
452c040
tests: cover semantic config validation and safe writes
teamleaderleo Sep 19, 2026
5800151
docs: describe semantic settings validation
teamleaderleo Sep 19, 2026
e8df769
docs: define semantic config validation contract
teamleaderleo Sep 19, 2026
c12efd1
config: expose semantic validator from CmuxFoundation
teamleaderleo Sep 19, 2026
0130157
config: move embedded schema into CmuxFoundation
teamleaderleo Sep 19, 2026
9334c18
config: generate shared semantic schema
teamleaderleo Sep 19, 2026
2c994ab
config: consume shared semantic validator
teamleaderleo Sep 19, 2026
306f29c
config: remove CLI-local validator copy
teamleaderleo Sep 19, 2026
c40994b
config: remove CLI-local schema copy
teamleaderleo Sep 19, 2026
857c57d
tests: cover shared semantic config validator
teamleaderleo Sep 19, 2026
12294f4
config: polish shared validator implementation
teamleaderleo Sep 19, 2026
587a91d
settings helper: infer root project config scope
teamleaderleo Sep 19, 2026
b0a6044
docs: describe project config semantic scope
teamleaderleo Sep 19, 2026
6bb0882
config: format schema generator path
teamleaderleo Sep 19, 2026
a809bd6
tests: verify helper validation delegates semantically
teamleaderleo Sep 19, 2026
eda0bea
config validation: cover schema pattern and multiple constraints
teamleaderleo Sep 19, 2026
a82921f
tests: cover remaining config schema keywords
teamleaderleo Sep 19, 2026
9b0da16
config: regenerate embedded semantic schema
teamleaderleo Sep 19, 2026
3b32df3
config validation: preserve future schema compatibility
teamleaderleo Sep 19, 2026
287ffaf
tests: preserve future config schema compatibility
teamleaderleo Sep 19, 2026
9a61019
settings: share semantic validation diagnostics
teamleaderleo Sep 19, 2026
f92922f
config: keep generated schema within Swift file budget
teamleaderleo Sep 19, 2026
fa70f56
config: regenerate compact embedded schema
teamleaderleo Sep 19, 2026
f1e3c09
config validation: split value helpers for file budget
teamleaderleo Sep 19, 2026
7ee75b2
config validation: add split value helpers
teamleaderleo Sep 19, 2026
4bab613
cli: extract config validation command
teamleaderleo Sep 19, 2026
29d47e8
cli: add extracted config validation command
teamleaderleo Sep 19, 2026
f8d1c8e
settings: keep shared validation within file budget
teamleaderleo Sep 19, 2026
769052d
settings: preserve settings store line budget
teamleaderleo Sep 19, 2026
6aba415
project: wire config validation CLI source
teamleaderleo Sep 19, 2026
4e6316a
ci: exercise semantic config validation
teamleaderleo Sep 19, 2026
77fd47e
ci: avoid unrelated package warning failures
teamleaderleo Sep 19, 2026
edfeb57
ci: use pinned Xcode for config validation
teamleaderleo Sep 19, 2026
626eadc
settings helper: infer config scope from runtime discovery
teamleaderleo Sep 19, 2026
582bf1c
foundation: bundle config validation localizations
teamleaderleo Sep 19, 2026
9129428
foundation: add config validation localization helper
teamleaderleo Sep 19, 2026
432feef
foundation: localize config validation diagnostics
teamleaderleo Sep 19, 2026
01f2457
config validation: localize semantic diagnostics
teamleaderleo Sep 19, 2026
2deba38
config validation: localize value descriptions
teamleaderleo Sep 19, 2026
e2b7b28
config validation: split constraint checks for file budget
teamleaderleo Sep 19, 2026
7964765
config validation: add constraint checks
teamleaderleo Sep 19, 2026
6007312
cli: localize semantic config validation output
teamleaderleo Sep 19, 2026
4555eee
cli: localize config validation help
teamleaderleo Sep 19, 2026
fcf44c0
tests: cover custom global helper scope
teamleaderleo Sep 19, 2026
bc48ad8
docs: describe config validation scope override
teamleaderleo Sep 19, 2026
cf7fb8b
fix: unblock semantic config validation build and tests
teamleaderleo Sep 19, 2026
0ee7e20
Merge origin/main into PR 13052
teamleaderleo Sep 20, 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
359 changes: 19 additions & 340 deletions CLI/CMUXCLI+Config.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,20 @@ extension CMUXCLI {
try runDocsCommand(commandArgs: ["settings"], jsonOutput: wantsJSON)
case "doctor", "check", "validate":
let doctorArgs = Array(args.dropFirst())
let report = try runConfigDoctor(arguments: doctorArgs, jsonOutput: wantsJSON)
let report = try runConfigDoctor(
arguments: doctorArgs,
jsonOutput: wantsJSON,
commandName: subcommand
)
if report.errorCount > 0 {
throw CLIError(message: "cmux config doctor found \(report.errorCount) error(s)")
throw CLIError(
message: CmuxConfigValidationLocalization().format(
"config.validation.cli.errorCount",
defaultValue: "cmux config %@ found %lld error(s)",
subcommand,
Int64(report.errorCount)
)
)
}
case "reload":
guard args.count == 1 else {
Expand Down Expand Up @@ -105,13 +116,18 @@ extension CMUXCLI {
}

func configUsage() -> String {
let validationHelp = CmuxConfigValidationLocalization().string(
"config.validation.cli.help",
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>

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

Subcommands:
doctor|check|validate [--path <path>] Validate JSONC syntax for cmux config files.
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`).
Expand Down Expand Up @@ -366,341 +382,4 @@ extension CMUXCLI {
return trimmed.isEmpty ? nil : trimmed
}

private struct ConfigDoctorOptions {
let paths: [String]
}

private struct ConfigDoctorTarget {
let label: String
let displayPath: String
let path: String
let missingIsError: Bool
}

private struct ConfigDoctorFinding {
let label: String
let displayPath: String
let path: String
let status: String
let message: String?
let keys: [String]
let byteCount: Int?

var isError: Bool { status == "error" }

var payload: [String: Any] {
var result: [String: Any] = [
"label": label,
"display_path": displayPath,
"path": path,
"status": status,
"ok": !isError,
"keys": keys,
]
if let message {
result["message"] = message
}
if let byteCount {
result["bytes"] = byteCount
}
return result
}
}

private struct ConfigDoctorReport {
let findings: [ConfigDoctorFinding]

var errorCount: Int {
findings.filter(\.isError).count
}

var payload: [String: Any] {
[
"ok": errorCount == 0,
"error_count": errorCount,
"findings": findings.map(\.payload),
"reload_command": "cmux reload-config",
"docs_url": CMUXCLI.settingsDocsURL,
"schema_url": CMUXCLI.settingsSchemaURL,
]
}
}

private func runConfigDoctor(arguments: [String], jsonOutput: Bool) throws -> ConfigDoctorReport {
let options = try parseConfigDoctorOptions(arguments)
let targets = options.paths.isEmpty
? defaultConfigDoctorTargets()
: options.paths.enumerated().map { index, rawPath in
let path = Self.absoluteConfigPath(rawPath)
return ConfigDoctorTarget(
label: "custom \(index + 1)",
displayPath: Self.tildePath(path),
path: path,
missingIsError: true
)
}
let findings = targets.map(configDoctorFinding(for:))
let report = ConfigDoctorReport(findings: findings)

if jsonOutput {
print(jsonString(report.payload))
} else {
printConfigDoctorReport(report)
}
return report
}

private func parseConfigDoctorOptions(_ arguments: [String]) throws -> ConfigDoctorOptions {
var paths: [String] = []
var index = 0
while index < arguments.count {
let argument = arguments[index]
if argument == "--path" {
let nextIndex = index + 1
guard nextIndex < arguments.count else {
throw CLIError(message: "cmux config doctor --path requires a path")
}
paths.append(arguments[nextIndex])
index += 2
continue
}
if argument.hasPrefix("--path=") {
let rawPath = String(argument.dropFirst("--path=".count))
guard !rawPath.isEmpty else {
throw CLIError(message: "cmux config doctor --path requires a path")
}
paths.append(rawPath)
index += 1
continue
}
if argument.hasPrefix("-") {
throw CLIError(message: "Unknown config doctor option '\(argument)'")
}
throw CLIError(message: "Unknown config doctor argument '\(argument)'. Use --path <path>.")
}
return ConfigDoctorOptions(paths: paths)
}

private func defaultConfigDoctorTargets() -> [ConfigDoctorTarget] {
let primary = Self.absoluteConfigPath(Self.primarySettingsDisplayPath)
var targets = [
ConfigDoctorTarget(
label: "primary",
displayPath: Self.primarySettingsDisplayPath,
path: primary,
missingIsError: false
)
]

if let projectPath = findProjectConfigPath(), projectPath != primary {
targets.append(
ConfigDoctorTarget(
label: "project",
displayPath: Self.tildePath(projectPath),
path: projectPath,
missingIsError: false
)
)
}

let optionalPaths = [
("legacy config", Self.legacySettingsDisplayPath),
("legacy app support", Self.fallbackSettingsDisplayPath),
]
for (label, displayPath) in optionalPaths {
let path = Self.absoluteConfigPath(displayPath)
guard path != primary,
FileManager.default.fileExists(atPath: path),
!targets.contains(where: { $0.path == path }) else {
continue
}
targets.append(
ConfigDoctorTarget(
label: label,
displayPath: displayPath,
path: path,
missingIsError: false
)
)
}
return targets
}

private func findProjectConfigPath() -> String? {
let fileManager = FileManager.default
let rawHomePath = ProcessInfo.processInfo.environment["HOME"] ?? fileManager.homeDirectoryForCurrentUser.path
let homePath = URL(fileURLWithPath: rawHomePath).standardizedFileURL.path
var current = URL(fileURLWithPath: fileManager.currentDirectoryPath).standardizedFileURL.path
while true {
if current == homePath {
return nil
}
let candidates = [
((current as NSString).appendingPathComponent(".cmux") as NSString)
.appendingPathComponent("cmux.json"),
(current as NSString).appendingPathComponent("cmux.json"),
]
for candidate in candidates {
var isDirectory = ObjCBool(false)
if fileManager.fileExists(atPath: candidate, isDirectory: &isDirectory),
!isDirectory.boolValue {
return URL(fileURLWithPath: candidate).standardizedFileURL.path
}
}
let parent = (current as NSString).deletingLastPathComponent
if parent == current {
return nil
}
current = parent
}
}

private func configDoctorFinding(for target: ConfigDoctorTarget) -> ConfigDoctorFinding {
let fileManager = FileManager.default
var isDirectory = ObjCBool(false)
guard fileManager.fileExists(atPath: target.path, isDirectory: &isDirectory) else {
let message = target.missingIsError
? "file not found"
: "not found; cmux will use defaults until this file exists"
return ConfigDoctorFinding(
label: target.label,
displayPath: target.displayPath,
path: target.path,
status: target.missingIsError ? "error" : "missing",
message: message,
keys: [],
byteCount: nil
)
}
if isDirectory.boolValue {
return ConfigDoctorFinding(
label: target.label,
displayPath: target.displayPath,
path: target.path,
status: "error",
message: "path is a directory, expected a file",
keys: [],
byteCount: nil
)
}

do {
let data = try Data(contentsOf: URL(fileURLWithPath: target.path))
guard !data.isEmpty else {
return ConfigDoctorFinding(
label: target.label,
displayPath: target.displayPath,
path: target.path,
status: "error",
message: "file is empty",
keys: [],
byteCount: 0
)
}
let sanitized = try JSONCParser.preprocess(data: data)
let object = try JSONSerialization.jsonObject(with: sanitized)
guard let dictionary = object as? [String: Any] else {
return ConfigDoctorFinding(
label: target.label,
displayPath: target.displayPath,
path: target.path,
status: "error",
message: "top-level value must be a JSON object",
keys: [],
byteCount: data.count
)
}
return ConfigDoctorFinding(
label: target.label,
displayPath: target.displayPath,
path: target.path,
status: "ok",
message: "JSONC syntax is valid",
keys: dictionary.keys.sorted(),
byteCount: data.count
)
} catch {
return ConfigDoctorFinding(
label: target.label,
displayPath: target.displayPath,
path: target.path,
status: "error",
message: Self.configDoctorErrorMessage(error),
keys: [],
byteCount: nil
)
}
}

private func printConfigDoctorReport(_ report: ConfigDoctorReport) {
print("cmux config doctor")
for finding in report.findings {
print("\(finding.status.uppercased()) \(finding.label): \(finding.displayPath)")
print(" path: \(finding.path)")
if let byteCount = finding.byteCount {
print(" bytes: \(byteCount)")
}
if !finding.keys.isEmpty {
print(" keys: \(finding.keys.joined(separator: ", "))")
}
if let message = finding.message {
print(" \(message)")
}
}
print()
print("Docs: \(Self.settingsDocsURL)")
print("Schema: \(Self.settingsSchemaURL)")
print("Reload: cmux reload-config")
}

private static func absoluteConfigPath(_ rawPath: String) -> String {
let homePath = ProcessInfo.processInfo.environment["HOME"] ?? NSHomeDirectory()
let expanded: String
if rawPath == "~" {
expanded = homePath
} else if rawPath.hasPrefix("~/") {
expanded = (homePath as NSString).appendingPathComponent(String(rawPath.dropFirst(2)))
} else {
expanded = rawPath
}

let absolute = (expanded as NSString).isAbsolutePath
? expanded
: (FileManager.default.currentDirectoryPath as NSString).appendingPathComponent(expanded)
return URL(fileURLWithPath: absolute).standardizedFileURL.path
}

private static func tildePath(_ path: String) -> String {
let homePath = URL(fileURLWithPath: ProcessInfo.processInfo.environment["HOME"] ?? NSHomeDirectory())
.standardizedFileURL
.path
let normalized = URL(fileURLWithPath: path).standardizedFileURL.path
if normalized == homePath {
return "~"
}
let prefix = homePath.hasSuffix("/") ? homePath : homePath + "/"
if normalized.hasPrefix(prefix) {
return "~/" + String(normalized.dropFirst(prefix.count))
}
return normalized
}

private static func configDoctorErrorMessage(_ error: Error) -> String {
let nsError = error as NSError
if let debug = nsError.userInfo[NSDebugDescriptionErrorKey] as? String {
let trimmed = debug.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
return trimmed
}
}
let described = String(describing: error).trimmingCharacters(in: .whitespacesAndNewlines)
if !described.isEmpty {
return described
}
let localized = nsError.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
if !localized.isEmpty {
return localized
}
return "unknown config parse error"
}
}
Loading
Loading