From f184f882c057d579ea61c30891310ef43f322614 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 22 Feb 2026 00:16:01 -0800 Subject: [PATCH] Add CLI --version output and regression test --- CLI/cmux.swift | 204 ++++++++++++++++++++++++++++++++- tests/test_cli_version_flag.py | 87 ++++++++++++++ 2 files changed, 290 insertions(+), 1 deletion(-) create mode 100644 tests/test_cli_version_flag.py diff --git a/CLI/cmux.swift b/CLI/cmux.swift index d2ea40e7..caa7ed48 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -435,6 +435,10 @@ struct CMUXCLI { index += 2 continue } + if arg == "-v" || arg == "--version" { + print(versionSummary()) + return + } if arg == "-h" || arg == "--help" { print(usage()) return @@ -450,6 +454,11 @@ struct CMUXCLI { let command = args[index] let commandArgs = Array(args[(index + 1)...]) + if command == "version" { + print(versionSummary()) + return + } + // Check for --help/-h on subcommands before connecting to the socket, // so help text is available even when cmux is not running. if commandArgs.contains("--help") || commandArgs.contains("-h") { @@ -4393,12 +4402,204 @@ struct CMUXCLI { return truncate(normalized, maxLength: 180) } + private func versionSummary() -> String { + let info = resolvedVersionInfo() + if let version = info["CFBundleShortVersionString"], let build = info["CFBundleVersion"] { + return "cmux \(version) (\(build))" + } + if let version = info["CFBundleShortVersionString"] { + return "cmux \(version)" + } + if let build = info["CFBundleVersion"] { + return "cmux build \(build)" + } + return "cmux version unknown" + } + + private func resolvedVersionInfo() -> [String: String] { + if let main = versionInfo(from: Bundle.main.infoDictionary) { + return main + } + + for plistURL in candidateInfoPlistURLs() { + guard let data = try? Data(contentsOf: plistURL), + let raw = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil), + let dictionary = raw as? [String: Any], + let parsed = versionInfo(from: dictionary) + else { + continue + } + return parsed + } + + if let fromProject = versionInfoFromProjectFile() { + return fromProject + } + + return [:] + } + + private func versionInfo(from dictionary: [String: Any]?) -> [String: String]? { + guard let dictionary else { return nil } + + var info: [String: String] = [:] + if let version = dictionary["CFBundleShortVersionString"] as? String { + let trimmed = version.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty && !trimmed.contains("$(") { + info["CFBundleShortVersionString"] = trimmed + } + } + if let build = dictionary["CFBundleVersion"] as? String { + let trimmed = build.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty && !trimmed.contains("$(") { + info["CFBundleVersion"] = trimmed + } + } + return info.isEmpty ? nil : info + } + + private func versionInfoFromProjectFile() -> [String: String]? { + guard let executable = currentExecutablePath(), !executable.isEmpty else { + return nil + } + + let fileManager = FileManager.default + var current = URL(fileURLWithPath: executable) + .resolvingSymlinksInPath() + .standardizedFileURL + .deletingLastPathComponent() + + while true { + let projectFile = current.appendingPathComponent("GhosttyTabs.xcodeproj/project.pbxproj") + if fileManager.fileExists(atPath: projectFile.path), + let contents = try? String(contentsOf: projectFile, encoding: .utf8) { + var info: [String: String] = [:] + if let version = firstProjectSetting("MARKETING_VERSION", in: contents) { + info["CFBundleShortVersionString"] = version + } + if let build = firstProjectSetting("CURRENT_PROJECT_VERSION", in: contents) { + info["CFBundleVersion"] = build + } + if !info.isEmpty { + return info + } + } + + let parent = current.deletingLastPathComponent() + if parent.path == current.path { + break + } + current = parent + } + + return nil + } + + private func firstProjectSetting(_ key: String, in source: String) -> String? { + let pattern = NSRegularExpression.escapedPattern(for: key) + "\\s*=\\s*([^;]+);" + guard let regex = try? NSRegularExpression(pattern: pattern) else { + return nil + } + let searchRange = NSRange(source.startIndex.. 1, + let valueRange = Range(match.range(at: 1), in: source) + else { + return nil + } + let value = source[valueRange] + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + guard !value.isEmpty, !value.contains("$(") else { + return nil + } + return value + } + + private func candidateInfoPlistURLs() -> [URL] { + guard let executable = currentExecutablePath(), !executable.isEmpty else { + return [] + } + + let fileManager = FileManager.default + let executableURL = URL(fileURLWithPath: executable) + .resolvingSymlinksInPath() + .standardizedFileURL + + var candidates: [URL] = [] + var current = executableURL.deletingLastPathComponent() + while true { + if current.pathExtension == "app" { + candidates.append(current.appendingPathComponent("Contents/Info.plist")) + } + if current.lastPathComponent == "Contents" { + candidates.append(current.appendingPathComponent("Info.plist")) + } + + // Local dev fallback: resolve version from the repo's app Info.plist + // when running a standalone cmux-cli binary from build/Debug. + let projectMarker = current.appendingPathComponent("GhosttyTabs.xcodeproj/project.pbxproj") + let repoInfo = current.appendingPathComponent("Resources/Info.plist") + if fileManager.fileExists(atPath: projectMarker.path), + fileManager.fileExists(atPath: repoInfo.path) { + candidates.append(repoInfo) + break + } + + let parent = current.deletingLastPathComponent() + if parent.path == current.path { + break + } + current = parent + } + + let searchRoots = [ + executableURL.deletingLastPathComponent(), + executableURL.deletingLastPathComponent().deletingLastPathComponent() + ] + for root in searchRoots { + guard let entries = try? fileManager.contentsOfDirectory( + at: root, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ) else { + continue + } + for entry in entries where entry.pathExtension == "app" { + candidates.append(entry.appendingPathComponent("Contents/Info.plist")) + } + } + + var seen: Set = [] + return candidates.filter { url in + let path = url.path + guard !path.isEmpty else { return false } + guard seen.insert(path).inserted else { return false } + return fileManager.fileExists(atPath: path) + } + } + + private func currentExecutablePath() -> String? { + var size: UInt32 = 0 + _ = _NSGetExecutablePath(nil, &size) + if size > 0 { + var buffer = Array(repeating: 0, count: Int(size)) + if _NSGetExecutablePath(&buffer, &size) == 0 { + let path = String(cString: buffer).trimmingCharacters(in: .whitespacesAndNewlines) + if !path.isEmpty { + return path + } + } + } + return Bundle.main.executableURL?.path ?? args.first + } + private func usage() -> String { return """ cmux - control cmux via Unix socket Usage: - cmux [--socket PATH] [--window WINDOW] [--json] [--id-format refs|uuids|both] [options] + cmux [--socket PATH] [--window WINDOW] [--json] [--id-format refs|uuids|both] [--version] [options] Handle Inputs: For most v2-backed commands you can use UUIDs, short refs (window:1/workspace:2/pane:3/surface:4), or indexes. @@ -4406,6 +4607,7 @@ struct CMUXCLI { Output defaults to refs; pass --id-format uuids or --id-format both to include UUIDs. Commands: + version ping capabilities identify [--workspace ] [--surface ] [--no-caller] diff --git a/tests/test_cli_version_flag.py b/tests/test_cli_version_flag.py new file mode 100644 index 00000000..b48419f2 --- /dev/null +++ b/tests/test_cli_version_flag.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +""" +Regression test: `cmux --version` should print version text without requiring a socket. +""" + +from __future__ import annotations + +import glob +import os +import re +import shutil +import subprocess + + +def resolve_cmux_cli() -> str: + explicit = os.environ.get("CMUX_CLI_BIN") or os.environ.get("CMUX_CLI") + if explicit and os.path.exists(explicit) and os.access(explicit, os.X_OK): + return explicit + + candidates: list[str] = [] + candidates.extend(glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/*/Build/Products/Debug/cmux"))) + candidates.extend(glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")) + candidates = [p for p in candidates if os.path.exists(p) and os.access(p, os.X_OK)] + if candidates: + candidates.sort(key=os.path.getmtime, reverse=True) + return candidates[0] + + in_path = shutil.which("cmux") + if in_path: + return in_path + + raise RuntimeError("Unable to find cmux CLI binary. Set CMUX_CLI_BIN.") + + +def run(cli_path: str, *args: str) -> tuple[int, str, str]: + proc = subprocess.run( + [cli_path, *args], + text=True, + capture_output=True, + check=False, + ) + return proc.returncode, proc.stdout.strip(), proc.stderr.strip() + + +def main() -> int: + try: + cli_path = resolve_cmux_cli() + except Exception as exc: + print(f"FAIL: {exc}") + return 1 + + code, out, err = run(cli_path, "--version") + if code != 0: + print("FAIL: `cmux --version` exited non-zero") + print(f"exit={code}") + print(f"stdout={out}") + print(f"stderr={err}") + return 1 + + if not out: + print("FAIL: `cmux --version` produced empty stdout") + return 1 + + if not re.search(r"\b\d+\.\d+\.\d+\b", out): + print(f"FAIL: version output missing semantic version: {out!r}") + return 1 + + code2, out2, err2 = run(cli_path, "version") + if code2 != 0: + print("FAIL: `cmux version` exited non-zero") + print(f"exit={code2}") + print(f"stdout={out2}") + print(f"stderr={err2}") + return 1 + + if out2 != out: + print("FAIL: `cmux --version` and `cmux version` differ") + print(f"--version: {out!r}") + print(f"version: {out2!r}") + return 1 + + print(f"PASS: cmux version command works ({out})") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())