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
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,12 @@ tail -f "$(cat /tmp/cmux-last-debug-log-path 2>/dev/null || echo /tmp/cmux-debug
- **Submodule safety:** When modifying a submodule (ghostty, vendor/bonsplit, etc.), always push the submodule commit to its remote `main` branch BEFORE committing the updated pointer in the parent repo. Never commit on a detached HEAD or temporary branch — the commit will be orphaned and lost. Verify with: `cd <submodule> && git merge-base --is-ancestor HEAD origin/main`.
- **All user-facing strings must be localized.** Use `String(localized: "key.name", defaultValue: "English text")` for every string shown in the UI (labels, buttons, menus, dialogs, tooltips, error messages). Keys go in `Resources/Localizable.xcstrings` with translations for all supported languages (currently English and Japanese). Never use bare string literals in SwiftUI `Text()`, `Button()`, alert titles, etc.

## Test quality policy

- Do not add tests that only verify source code text, method signatures, AST fragments, or grep-style patterns.
- Tests must verify observable runtime behavior through executable paths (unit/integration/e2e/CLI), not implementation shape.
- If a behavior cannot be exercised end-to-end yet, add a small runtime seam or harness first, then test through that seam.

## Socket command threading policy

- Do not use `DispatchQueue.main.sync` for high-frequency socket telemetry commands (`report_*`, `ports_kick`, status/progress/log metadata updates).
Expand Down
180 changes: 172 additions & 8 deletions CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1736,6 +1736,45 @@ struct CMUXCLI {
return (cwd as NSString).appendingPathComponent(expanded)
}

private func sanitizedFilenameComponent(_ raw: String) -> String {
let sanitized = raw.replacingOccurrences(
of: #"[^\p{L}\p{N}._-]+"#,
with: "-",
options: .regularExpression
)
let trimmed = sanitized.trimmingCharacters(in: CharacterSet(charactersIn: "-."))
return trimmed.isEmpty ? "item" : trimmed
}

private func bestEffortPruneTemporaryFiles(
in directoryURL: URL,
keepingMostRecent maxCount: Int = 50,
maxAge: TimeInterval = 24 * 60 * 60
) {
guard let entries = try? FileManager.default.contentsOfDirectory(
at: directoryURL,
includingPropertiesForKeys: [.isRegularFileKey, .contentModificationDateKey, .creationDateKey],
options: [.skipsHiddenFiles]
) else {
return
}

let now = Date()
let datedEntries = entries.compactMap { url -> (url: URL, date: Date)? in
guard let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .contentModificationDateKey, .creationDateKey]),
values.isRegularFile == true else {
return nil
}
return (url, values.contentModificationDate ?? values.creationDate ?? .distantPast)
}.sorted { $0.date > $1.date }

for (index, entry) in datedEntries.enumerated() {
if index >= maxCount || now.timeIntervalSince(entry.date) > maxAge {
try? FileManager.default.removeItem(at: entry.url)
}
}
}

// MARK: - Markdown Commands

private func runMarkdownCommand(
Expand Down Expand Up @@ -3052,17 +3091,139 @@ struct CMUXCLI {
if subcommand == "screenshot" {
let sid = try requireSurface()
let (outPathOpt, _) = parseOption(subArgs, name: "--out")
let payload = try client.sendV2(method: "browser.screenshot", params: ["surface_id": sid])
if let outPathOpt,
let b64 = payload["png_base64"] as? String,
let data = Data(base64Encoded: b64) {
try data.write(to: URL(fileURLWithPath: outPathOpt))
let localJSONOutput = hasFlag(subArgs, name: "--json")
let outputAsJSON = jsonOutput || localJSONOutput
var payload = try client.sendV2(method: "browser.screenshot", params: ["surface_id": sid])

func fileURL(fromPath rawPath: String) -> URL {
let resolvedPath = resolvePath(rawPath)
return URL(fileURLWithPath: resolvedPath).standardizedFileURL
}

if jsonOutput {
print(jsonString(formatIDs(payload, mode: idFormat)))
func writeScreenshot(_ data: Data, to destinationURL: URL) throws {
try FileManager.default.createDirectory(
at: destinationURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try data.write(to: destinationURL, options: .atomic)
}

func hasText(_ value: String?) -> Bool {
guard let value else { return false }
return !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}

var screenshotPath = payload["path"] as? String
var screenshotURL = payload["url"] as? String

func syncScreenshotLocationFields() {
if !hasText(screenshotPath),
let rawURL = screenshotURL,
let fileURL = URL(string: rawURL),
fileURL.isFileURL,
!fileURL.path.isEmpty {
screenshotPath = fileURL.path
}
if !hasText(screenshotURL),
let screenshotPath,
hasText(screenshotPath) {
screenshotURL = URL(fileURLWithPath: screenshotPath).standardizedFileURL.absoluteString
}
if let screenshotPath, hasText(screenshotPath) {
payload["path"] = screenshotPath
}
if let screenshotURL, hasText(screenshotURL) {
payload["url"] = screenshotURL
}
}

func persistPayloadScreenshot(to destinationURL: URL, allowFailure: Bool) throws -> Bool {
if let sourcePath = screenshotPath, hasText(sourcePath) {
let sourceURL = URL(fileURLWithPath: sourcePath).standardizedFileURL
do {
if sourceURL.path != destinationURL.path {
try FileManager.default.createDirectory(
at: destinationURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try? FileManager.default.removeItem(at: destinationURL)
try FileManager.default.copyItem(at: sourceURL, to: destinationURL)
}
return true
} catch {
if payload["png_base64"] == nil {
if allowFailure {
return false
}
throw error
}
}
}

if let b64 = payload["png_base64"] as? String,
let data = Data(base64Encoded: b64) {
do {
try writeScreenshot(data, to: destinationURL)
return true
} catch {
if allowFailure {
return false
}
throw error
}
}

return false
}

if let outPathOpt {
let outputURL = fileURL(fromPath: outPathOpt)
guard try persistPayloadScreenshot(to: outputURL, allowFailure: false) else {
throw CLIError(message: "browser screenshot missing image data")
}
screenshotPath = outputURL.path
screenshotURL = outputURL.absoluteString
payload["path"] = screenshotPath
payload["url"] = screenshotURL
Comment thread
lawrencecchen marked this conversation as resolved.
} else {
syncScreenshotLocationFields()
if !hasText(screenshotPath) && !hasText(screenshotURL) {
let outputDir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-browser-screenshots-cli", isDirectory: true)
if (try? FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true)) != nil {
bestEffortPruneTemporaryFiles(in: outputDir)
let timestampMs = Int(Date().timeIntervalSince1970 * 1000)
let safeSid = sanitizedFilenameComponent(sid)
let filename = "surface-\(safeSid)-\(timestampMs)-\(String(UUID().uuidString.prefix(8))).png"
let outputURL = outputDir.appendingPathComponent(filename, isDirectory: false)
if (try? persistPayloadScreenshot(to: outputURL, allowFailure: true)) == true {
screenshotPath = outputURL.path
screenshotURL = outputURL.absoluteString
payload["path"] = screenshotPath
payload["url"] = screenshotURL
}
}
}
}
Comment thread
lawrencecchen marked this conversation as resolved.

if outputAsJSON {
let formattedPayload = formatIDs(payload, mode: idFormat)
if var outputPayload = formattedPayload as? [String: Any] {
if hasText(screenshotPath) || hasText(screenshotURL) {
outputPayload.removeValue(forKey: "png_base64")
}
print(jsonString(outputPayload))
} else {
print(jsonString(formattedPayload))
}
} else if let outPathOpt {
print("OK \(outPathOpt)")
} else if let screenshotURL,
hasText(screenshotURL) {
print("OK \(screenshotURL)")
} else if let screenshotPath,
hasText(screenshotPath) {
print("OK \(screenshotPath)")
} else {
print("OK")
}
Expand Down Expand Up @@ -5511,8 +5672,10 @@ struct CMUXCLI {
}

private func jsonString(_ object: Any) -> String {
var options: JSONSerialization.WritingOptions = [.prettyPrinted]
options.insert(.withoutEscapingSlashes)
guard JSONSerialization.isValidJSONObject(object),
let data = try? JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted]),
let data = try? JSONSerialization.data(withJSONObject: object, options: options),
let output = String(data: data, encoding: .utf8) else {
return "{}"
}
Expand Down Expand Up @@ -6797,6 +6960,7 @@ struct CMUXCLI {
browser press|keydown|keyup <key> [--snapshot-after]
browser select <selector> <value> [--snapshot-after]
browser scroll [--selector <css>] [--dx <n>] [--dy <n>] [--snapshot-after]
browser screenshot [--out <path>] [--json]
browser get <url|title|text|html|value|attr|count|box|styles> [...]
browser is <visible|enabled|checked> <selector>
browser find <role|text|label|placeholder|alt|title|testid|first|last|nth> ...
Expand Down
Loading