Merge pull request #298 from manaflow-ai/task-cmux-version-command

Add cmux --version command with regression test
This commit is contained in:
Lawrence Chen 2026-02-22 00:39:29 -08:00 committed by GitHub
commit 57efdbd254
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 290 additions and 1 deletions

View file

@ -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..<source.endIndex, in: source)
guard let match = regex.firstMatch(in: source, options: [], range: searchRange),
match.numberOfRanges > 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<String> = []
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<CChar>(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] <command> [options]
cmux [--socket PATH] [--window WINDOW] [--json] [--id-format refs|uuids|both] [--version] <command> [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 <id|ref|index>] [--surface <id|ref|index>] [--no-caller]

View file

@ -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())