Files
kapture/Sources/KaptureCLI/main.swift
T
TZGyn 6f8f436987 Add a kapture CLI so agents can capture windows and screens
kapture capture --app <name> (or --window-id / --screen) writes a PNG and
prints its path; kapture list enumerates on-screen windows. The CLI is a
thin client: requests go to the running menu-bar app over distributed
notifications plus JSON files in Application Support, so captures use the
app's Screen Recording grant and its window/display logic — no second
permission. The app launches automatically when needed, and the bundled
CLI opens its own app bundle rather than a stale installed copy.

Built as `kapture-cli` (Kapture/kapture collide on the case-insensitive
filesystem), signed nested in the app bundle, and installed on PATH as
`kapture` by the Homebrew cask's binary stanza.

Generated with OpenCode

Assisted-by: Kimi:k3
2026-10-27 23:38:43 +08:00

255 lines
9.0 KiB
Swift

import AppKit
import Foundation
// kapture — command-line client for the Kapture menu-bar app.
//
// It does not capture by itself: requests go to the running app, which
// already holds the Screen Recording permission and owns all window /
// display capture logic. Requests and responses are JSON files in
// ~/Library/Application Support/Kapture/cli, signalled by a distributed
// notification carrying the request id.
let helpText = """
kapture — screenshot capture for agents and scripts
USAGE
kapture capture [options] Capture a window or the main display to a PNG
kapture list [--json] List on-screen windows (id, app, title, size)
kapture --help Show this help
CAPTURE OPTIONS
--app <name|bundle-id> App whose frontmost window to capture. Matches
localized names and bundle ids, case-insensitive
substring. Default: the frontmost app's window.
--window-id <id> Capture a specific window id from `kapture list`.
--screen Capture the main display instead of a window.
--out <path> Output PNG path (default: a new file in $TMPDIR).
--json Print {"ok","path","width","height"} instead of
just the path.
BEHAVIOR
Prints the absolute PNG path on stdout (exit 0). Errors go to stderr.
Requires the Kapture menu-bar app (Homebrew cask); it is launched
automatically if it is not running.
EXAMPLES
kapture capture --app Xcode --out /tmp/xcode.png
kapture capture --app Safari --json
kapture list
kapture capture --window-id 1234 --out shot.png
kapture capture --screen
"""
// MARK: - Bridge
enum Bridge {
static let notificationName = "dev.tzgyn.kapture.cli.request"
static let appBundleID = "dev.tzgyn.kapture"
static var directory: URL {
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("Kapture/cli", isDirectory: true)
}
static func appIsRunning() -> Bool {
NSWorkspace.shared.runningApplications.contains {
$0.bundleIdentifier == appBundleID || $0.localizedName == "Kapture"
}
}
static func launchApp() {
// Prefer the bundle this CLI lives inside, so a dev CLI launches its
// own app build rather than a stale installed copy with the same id.
let executable = URL(fileURLWithPath: CommandLine.arguments[0]).resolvingSymlinksInPath()
let appBundle = executable
.deletingLastPathComponent() // Contents/MacOS
.deletingLastPathComponent() // Contents
.deletingLastPathComponent() // Kapture.app
let task = Process()
task.executableURL = URL(fileURLWithPath: "/usr/bin/open")
if appBundle.pathExtension == "app" {
task.arguments = [appBundle.path]
} else {
task.arguments = ["-b", appBundleID]
}
task.standardOutput = FileHandle.nullDevice
task.standardError = FileHandle.nullDevice
try? task.run()
task.waitUntilExit()
}
/// Send a request to the app and wait for its response. Posts the
/// notification repeatedly so a still-starting app can pick it up.
static func send(_ request: [String: Any], timeout: TimeInterval = 12) -> [String: Any]? {
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let id = UUID().uuidString
let requestURL = directory.appendingPathComponent("\(id).request.json")
let responseURL = directory.appendingPathComponent("\(id).response.json")
defer {
try? FileManager.default.removeItem(at: requestURL)
try? FileManager.default.removeItem(at: responseURL)
}
guard let data = try? JSONSerialization.data(withJSONObject: request),
(try? data.write(to: requestURL, options: .atomic)) != nil else {
return nil
}
if !appIsRunning() { launchApp() }
let deadline = Date().addingTimeInterval(timeout)
var lastPost = Date.distantPast
while Date() < deadline {
if Date().timeIntervalSince(lastPost) > 0.3 {
lastPost = Date()
DistributedNotificationCenter.default().postNotificationName(
.init(notificationName), object: nil,
userInfo: ["id": id], deliverImmediately: true
)
}
if let responseData = try? Data(contentsOf: responseURL),
let object = (try? JSONSerialization.jsonObject(with: responseData)) as? [String: Any] {
return object
}
usleep(50_000)
}
return nil
}
}
// MARK: - Output helpers
func fail(_ message: String, code: Int32) -> Never {
FileHandle.standardError.write(("kapture: " + message + "\n").data(using: .utf8)!)
exit(code)
}
func usageError(_ message: String) -> Never {
FileHandle.standardError.write(("kapture: " + message + "\n\n" + helpText + "\n").data(using: .utf8)!)
exit(2)
}
func printJSON(_ object: [String: Any]) {
guard let data = try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) else { return }
print(String(data: data, encoding: .utf8) ?? "{}")
}
func timestamp() -> String {
let formatter = DateFormatter()
formatter.dateFormat = "yyyyMMdd-HHmmss"
return formatter.string(from: Date())
}
// MARK: - Commands
func runCapture(_ args: [String]) {
var app: String?
var windowID: Int?
var screen = false
var out: String?
var json = false
var index = 0
while index < args.count {
switch args[index] {
case "--app":
index += 1
guard index < args.count else { usageError("--app needs a value") }
app = args[index]
case "--window-id":
index += 1
guard index < args.count, let id = Int(args[index]) else { usageError("--window-id needs a number") }
windowID = id
case "--screen":
screen = true
case "--out":
index += 1
guard index < args.count else { usageError("--out needs a path") }
out = args[index]
case "--json":
json = true
default:
usageError("unknown option \"\(args[index])\"")
}
index += 1
}
let outURL = URL(fileURLWithPath: (out.map { ($0 as NSString).expandingTildeInPath }
?? FileManager.default.temporaryDirectory.appendingPathComponent("kapture-\(timestamp()).png").path))
var request: [String: Any] = ["command": "capture", "screen": screen, "out": outURL.path]
if let app { request["app"] = app }
if let windowID { request["windowID"] = windowID }
guard let response = Bridge.send(request) else {
fail("the Kapture app did not respond. Is it installed and running?", code: 3)
}
guard (response["ok"] as? Bool) == true else {
fail(response["error"] as? String ?? "capture failed", code: 1)
}
if json {
printJSON([
"ok": true,
"path": response["path"] as? String ?? outURL.path,
"width": response["width"] as? Int ?? 0,
"height": response["height"] as? Int ?? 0,
])
} else {
print(response["path"] as? String ?? outURL.path)
}
}
func runList(_ args: [String]) {
var json = false
for arg in args {
switch arg {
case "--json": json = true
default: usageError("unknown option \"\(arg)\"")
}
}
guard let response = Bridge.send(["command": "list"]) else {
fail("the Kapture app did not respond. Is it installed and running?", code: 3)
}
guard (response["ok"] as? Bool) == true else {
fail(response["error"] as? String ?? "could not list windows", code: 1)
}
let windows = response["windows"] as? [[String: Any]] ?? []
if json {
printJSON(["ok": true, "windows": windows])
return
}
func pad(_ text: String, _ width: Int) -> String {
text.count >= width ? String(text.prefix(width - 1)) + "…" : text.padding(toLength: width, withPad: " ", startingAt: 0)
}
print("\(pad("ID", 8)) \(pad("APP", 20)) \(pad("TITLE", 50)) SIZE")
for window in windows {
let id = window["id"] as? Int ?? 0
let app = window["app"] as? String ?? ""
let title = window["title"] as? String ?? ""
let width = window["width"] as? Int ?? 0
let height = window["height"] as? Int ?? 0
print("\(pad(String(id), 8)) \(pad(app, 20)) \(pad(title, 50)) \(width)x\(height)")
}
}
// MARK: - Main
var arguments = Array(CommandLine.arguments.dropFirst())
guard let command = arguments.first else {
print(helpText)
exit(2)
}
arguments.removeFirst()
switch command {
case "capture":
runCapture(arguments)
case "list":
runList(arguments)
case "--help", "-h", "help":
print(helpText)
case "--version", "-v":
print("kapture (bundled with the Kapture app)")
default:
usageError("unknown command \"\(command)\"")
}