diff --git a/apps/macos/Sources/Clawdis/AgeFormatting.swift b/apps/macos/Sources/Clawdis/AgeFormatting.swift
new file mode 100644
index 000000000..f992c2d95
--- /dev/null
+++ b/apps/macos/Sources/Clawdis/AgeFormatting.swift
@@ -0,0 +1,17 @@
+import Foundation
+
+// Human-friendly age string (e.g., "2m ago").
+func age(from date: Date, now: Date = .init()) -> String {
+ let seconds = max(0, Int(now.timeIntervalSince(date)))
+ let minutes = seconds / 60
+ let hours = minutes / 60
+ let days = hours / 24
+
+ if seconds < 60 { return "just now" }
+ if minutes == 1 { return "1 minute ago" }
+ if minutes < 60 { return "\(minutes)m ago" }
+ if hours == 1 { return "1 hour ago" }
+ if hours < 24 { return "\(hours)h ago" }
+ if days == 1 { return "yesterday" }
+ return "\(days)d ago"
+}
diff --git a/apps/macos/Sources/Clawdis/CLIInstaller.swift b/apps/macos/Sources/Clawdis/CLIInstaller.swift
new file mode 100644
index 000000000..eab6e337e
--- /dev/null
+++ b/apps/macos/Sources/Clawdis/CLIInstaller.swift
@@ -0,0 +1,102 @@
+import Foundation
+
+@MainActor
+enum CLIInstaller {
+ private static func embeddedHelperURL() -> URL {
+ Bundle.main.bundleURL.appendingPathComponent("Contents/Resources/Relay/clawdis")
+ }
+
+ static func installedLocation() -> String? {
+ self.installedLocation(
+ searchPaths: cliHelperSearchPaths,
+ embeddedHelper: self.embeddedHelperURL(),
+ fileManager: .default)
+ }
+
+ static func installedLocation(
+ searchPaths: [String],
+ embeddedHelper: URL,
+ fileManager: FileManager) -> String?
+ {
+ let embedded = embeddedHelper.resolvingSymlinksInPath()
+
+ for basePath in searchPaths {
+ let candidate = URL(fileURLWithPath: basePath).appendingPathComponent("clawdis").path
+ var isDirectory: ObjCBool = false
+
+ guard fileManager.fileExists(atPath: candidate, isDirectory: &isDirectory),
+ !isDirectory.boolValue
+ else {
+ continue
+ }
+
+ guard fileManager.isExecutableFile(atPath: candidate) else { continue }
+
+ let resolved = URL(fileURLWithPath: candidate).resolvingSymlinksInPath()
+ if resolved == embedded {
+ return candidate
+ }
+ }
+
+ return nil
+ }
+
+ static func isInstalled() -> Bool {
+ self.installedLocation() != nil
+ }
+
+ static func install(statusHandler: @escaping @Sendable (String) async -> Void) async {
+ let helper = self.embeddedHelperURL()
+ guard FileManager.default.isExecutableFile(atPath: helper.path) else {
+ await statusHandler(
+ "Embedded CLI missing in bundle; repackage via scripts/package-mac-app.sh " +
+ "(or restart-mac.sh without SKIP_GATEWAY_PACKAGE=1).")
+ return
+ }
+
+ let targets = cliHelperSearchPaths.map { "\($0)/clawdis" }
+ let result = await self.privilegedSymlink(source: helper.path, targets: targets)
+ await statusHandler(result)
+ }
+
+ private static func privilegedSymlink(source: String, targets: [String]) async -> String {
+ let escapedSource = self.shellEscape(source)
+ let targetList = targets.map(self.shellEscape).joined(separator: " ")
+ let cmds = [
+ "mkdir -p /usr/local/bin /opt/homebrew/bin",
+ targets.map { "ln -sf \(escapedSource) \($0)" }.joined(separator: "; "),
+ ].joined(separator: "; ")
+
+ let script = """
+ do shell script "\(cmds)" with administrator privileges
+ """
+
+ let proc = Process()
+ proc.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
+ proc.arguments = ["-e", script]
+
+ let pipe = Pipe()
+ proc.standardOutput = pipe
+ proc.standardError = pipe
+
+ do {
+ try proc.run()
+ proc.waitUntilExit()
+ let data = pipe.fileHandleForReading.readToEndSafely()
+ let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ if proc.terminationStatus == 0 {
+ return output.isEmpty ? "CLI helper linked into \(targetList)" : output
+ }
+ if output.lowercased().contains("user canceled") {
+ return "Install canceled"
+ }
+ return "Failed to install CLI helper: \(output)"
+ } catch {
+ return "Failed to run installer: \(error.localizedDescription)"
+ }
+ }
+
+ private static func shellEscape(_ path: String) -> String {
+ "'" + path.replacingOccurrences(of: "'", with: "'\"'\"'") + "'"
+ }
+}
diff --git a/apps/macos/Sources/Clawdis/Utilities.swift b/apps/macos/Sources/Clawdis/CommandResolver.swift
similarity index 64%
rename from apps/macos/Sources/Clawdis/Utilities.swift
rename to apps/macos/Sources/Clawdis/CommandResolver.swift
index e863aef7f..d418cd5e0 100644
--- a/apps/macos/Sources/Clawdis/Utilities.swift
+++ b/apps/macos/Sources/Clawdis/CommandResolver.swift
@@ -1,237 +1,5 @@
-import AppKit
import Foundation
-extension ProcessInfo {
- var isPreview: Bool {
- self.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
- }
-
- var isRunningTests: Bool {
- // SwiftPM tests load one or more `.xctest` bundles. With Swift Testing, `Bundle.main` is not
- // guaranteed to be the `.xctest` bundle, so check all loaded bundles.
- if Bundle.allBundles.contains(where: { $0.bundleURL.pathExtension == "xctest" }) { return true }
- if Bundle.main.bundleURL.pathExtension == "xctest" { return true }
-
- // Backwards-compatible fallbacks for runners that still set XCTest env vars.
- return self.environment["XCTestConfigurationFilePath"] != nil
- || self.environment["XCTestBundlePath"] != nil
- || self.environment["XCTestSessionIdentifier"] != nil
- }
-}
-
-enum LaunchdManager {
- private static func runLaunchctl(_ args: [String]) {
- let process = Process()
- process.launchPath = "/bin/launchctl"
- process.arguments = args
- try? process.run()
- }
-
- static func startClawdis() {
- let userTarget = "gui/\(getuid())/\(launchdLabel)"
- self.runLaunchctl(["kickstart", "-k", userTarget])
- }
-
- static func stopClawdis() {
- let userTarget = "gui/\(getuid())/\(launchdLabel)"
- self.runLaunchctl(["stop", userTarget])
- }
-}
-
-enum LaunchAgentManager {
- private static var plistURL: URL {
- FileManager.default.homeDirectoryForCurrentUser
- .appendingPathComponent("Library/LaunchAgents/com.steipete.clawdis.plist")
- }
-
- static func status() async -> Bool {
- guard FileManager.default.fileExists(atPath: self.plistURL.path) else { return false }
- let result = await self.runLaunchctl(["print", "gui/\(getuid())/\(launchdLabel)"])
- return result == 0
- }
-
- static func set(enabled: Bool, bundlePath: String) async {
- if enabled {
- self.writePlist(bundlePath: bundlePath)
- _ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(launchdLabel)"])
- _ = await self.runLaunchctl(["bootstrap", "gui/\(getuid())", self.plistURL.path])
- _ = await self.runLaunchctl(["kickstart", "-k", "gui/\(getuid())/\(launchdLabel)"])
- } else {
- // Disable autostart going forward but leave the current app running.
- // bootout would terminate the launchd job immediately (and crash the app if launched via agent).
- try? FileManager.default.removeItem(at: self.plistURL)
- }
- }
-
- private static func writePlist(bundlePath: String) {
- let plist = """
-
-
-
-
- Label
- com.steipete.clawdis
- ProgramArguments
-
- \(bundlePath)/Contents/MacOS/Clawdis
-
- WorkingDirectory
- \(FileManager.default.homeDirectoryForCurrentUser.path)
- RunAtLoad
-
- KeepAlive
-
- EnvironmentVariables
-
- PATH
- \(CommandResolver.preferredPaths().joined(separator: ":"))
-
- StandardOutPath
- \(LogLocator.launchdLogPath)
- StandardErrorPath
- \(LogLocator.launchdLogPath)
-
-
- """
- try? plist.write(to: self.plistURL, atomically: true, encoding: .utf8)
- }
-
- @discardableResult
- private static func runLaunchctl(_ args: [String]) async -> Int32 {
- await Task.detached(priority: .utility) { () -> Int32 in
- let process = Process()
- process.launchPath = "/bin/launchctl"
- process.arguments = args
- process.standardOutput = Pipe()
- process.standardError = Pipe()
- do {
- try process.run()
- process.waitUntilExit()
- return process.terminationStatus
- } catch {
- return -1
- }
- }.value
- }
-}
-
-// Human-friendly age string (e.g., "2m ago").
-func age(from date: Date, now: Date = .init()) -> String {
- let seconds = max(0, Int(now.timeIntervalSince(date)))
- let minutes = seconds / 60
- let hours = minutes / 60
- let days = hours / 24
-
- if seconds < 60 { return "just now" }
- if minutes == 1 { return "1 minute ago" }
- if minutes < 60 { return "\(minutes)m ago" }
- if hours == 1 { return "1 hour ago" }
- if hours < 24 { return "\(hours)h ago" }
- if days == 1 { return "yesterday" }
- return "\(days)d ago"
-}
-
-@MainActor
-enum CLIInstaller {
- private static func embeddedHelperURL() -> URL {
- Bundle.main.bundleURL.appendingPathComponent("Contents/Resources/Relay/clawdis")
- }
-
- static func installedLocation() -> String? {
- self.installedLocation(
- searchPaths: cliHelperSearchPaths,
- embeddedHelper: self.embeddedHelperURL(),
- fileManager: .default)
- }
-
- static func installedLocation(
- searchPaths: [String],
- embeddedHelper: URL,
- fileManager: FileManager) -> String?
- {
- let embedded = embeddedHelper.resolvingSymlinksInPath()
-
- for basePath in searchPaths {
- let candidate = URL(fileURLWithPath: basePath).appendingPathComponent("clawdis").path
- var isDirectory: ObjCBool = false
-
- guard fileManager.fileExists(atPath: candidate, isDirectory: &isDirectory),
- !isDirectory.boolValue
- else {
- continue
- }
-
- guard fileManager.isExecutableFile(atPath: candidate) else { continue }
-
- let resolved = URL(fileURLWithPath: candidate).resolvingSymlinksInPath()
- if resolved == embedded {
- return candidate
- }
- }
-
- return nil
- }
-
- static func isInstalled() -> Bool {
- self.installedLocation() != nil
- }
-
- static func install(statusHandler: @escaping @Sendable (String) async -> Void) async {
- let helper = self.embeddedHelperURL()
- guard FileManager.default.isExecutableFile(atPath: helper.path) else {
- await statusHandler(
- "Embedded CLI missing in bundle; repackage via scripts/package-mac-app.sh " +
- "(or restart-mac.sh without SKIP_GATEWAY_PACKAGE=1).")
- return
- }
-
- let targets = cliHelperSearchPaths.map { "\($0)/clawdis" }
- let result = await self.privilegedSymlink(source: helper.path, targets: targets)
- await statusHandler(result)
- }
-
- private static func privilegedSymlink(source: String, targets: [String]) async -> String {
- let escapedSource = self.shellEscape(source)
- let targetList = targets.map(self.shellEscape).joined(separator: " ")
- let cmds = [
- "mkdir -p /usr/local/bin /opt/homebrew/bin",
- targets.map { "ln -sf \(escapedSource) \($0)" }.joined(separator: "; "),
- ].joined(separator: "; ")
-
- let script = """
- do shell script "\(cmds)" with administrator privileges
- """
-
- let proc = Process()
- proc.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
- proc.arguments = ["-e", script]
-
- let pipe = Pipe()
- proc.standardOutput = pipe
- proc.standardError = pipe
-
- do {
- try proc.run()
- proc.waitUntilExit()
- let data = pipe.fileHandleForReading.readToEndSafely()
- let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
- if proc.terminationStatus == 0 {
- return output.isEmpty ? "CLI helper linked into \(targetList)" : output
- }
- if output.lowercased().contains("user canceled") {
- return "Install canceled"
- }
- return "Failed to install CLI helper: \(output)"
- } catch {
- return "Failed to run installer: \(error.localizedDescription)"
- }
- }
-
- private static func shellEscape(_ path: String) -> String {
- "'" + path.replacingOccurrences(of: "'", with: "'\"'\"'") + "'"
- }
-}
-
enum CommandResolver {
private static let projectRootDefaultsKey = "clawdis.gatewayProjectRootPath"
private static let helperName = "clawdis"
diff --git a/apps/macos/Sources/Clawdis/LaunchAgentManager.swift b/apps/macos/Sources/Clawdis/LaunchAgentManager.swift
new file mode 100644
index 000000000..67abe06ec
--- /dev/null
+++ b/apps/macos/Sources/Clawdis/LaunchAgentManager.swift
@@ -0,0 +1,78 @@
+import Foundation
+
+enum LaunchAgentManager {
+ private static var plistURL: URL {
+ FileManager.default.homeDirectoryForCurrentUser
+ .appendingPathComponent("Library/LaunchAgents/com.steipete.clawdis.plist")
+ }
+
+ static func status() async -> Bool {
+ guard FileManager.default.fileExists(atPath: self.plistURL.path) else { return false }
+ let result = await self.runLaunchctl(["print", "gui/\(getuid())/\(launchdLabel)"])
+ return result == 0
+ }
+
+ static func set(enabled: Bool, bundlePath: String) async {
+ if enabled {
+ self.writePlist(bundlePath: bundlePath)
+ _ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(launchdLabel)"])
+ _ = await self.runLaunchctl(["bootstrap", "gui/\(getuid())", self.plistURL.path])
+ _ = await self.runLaunchctl(["kickstart", "-k", "gui/\(getuid())/\(launchdLabel)"])
+ } else {
+ // Disable autostart going forward but leave the current app running.
+ // bootout would terminate the launchd job immediately (and crash the app if launched via agent).
+ try? FileManager.default.removeItem(at: self.plistURL)
+ }
+ }
+
+ private static func writePlist(bundlePath: String) {
+ let plist = """
+
+
+
+
+ Label
+ com.steipete.clawdis
+ ProgramArguments
+
+ \(bundlePath)/Contents/MacOS/Clawdis
+
+ WorkingDirectory
+ \(FileManager.default.homeDirectoryForCurrentUser.path)
+ RunAtLoad
+
+ KeepAlive
+
+ EnvironmentVariables
+
+ PATH
+ \(CommandResolver.preferredPaths().joined(separator: ":"))
+
+ StandardOutPath
+ \(LogLocator.launchdLogPath)
+ StandardErrorPath
+ \(LogLocator.launchdLogPath)
+
+
+ """
+ try? plist.write(to: self.plistURL, atomically: true, encoding: .utf8)
+ }
+
+ @discardableResult
+ private static func runLaunchctl(_ args: [String]) async -> Int32 {
+ await Task.detached(priority: .utility) { () -> Int32 in
+ let process = Process()
+ process.launchPath = "/bin/launchctl"
+ process.arguments = args
+ process.standardOutput = Pipe()
+ process.standardError = Pipe()
+ do {
+ try process.run()
+ process.waitUntilExit()
+ return process.terminationStatus
+ } catch {
+ return -1
+ }
+ }.value
+ }
+}
diff --git a/apps/macos/Sources/Clawdis/LaunchdManager.swift b/apps/macos/Sources/Clawdis/LaunchdManager.swift
new file mode 100644
index 000000000..1b8f6884b
--- /dev/null
+++ b/apps/macos/Sources/Clawdis/LaunchdManager.swift
@@ -0,0 +1,20 @@
+import Foundation
+
+enum LaunchdManager {
+ private static func runLaunchctl(_ args: [String]) {
+ let process = Process()
+ process.launchPath = "/bin/launchctl"
+ process.arguments = args
+ try? process.run()
+ }
+
+ static func startClawdis() {
+ let userTarget = "gui/\(getuid())/\(launchdLabel)"
+ self.runLaunchctl(["kickstart", "-k", userTarget])
+ }
+
+ static func stopClawdis() {
+ let userTarget = "gui/\(getuid())/\(launchdLabel)"
+ self.runLaunchctl(["stop", userTarget])
+ }
+}
diff --git a/apps/macos/Sources/Clawdis/ProcessInfo+Clawdis.swift b/apps/macos/Sources/Clawdis/ProcessInfo+Clawdis.swift
new file mode 100644
index 000000000..aacf7e83c
--- /dev/null
+++ b/apps/macos/Sources/Clawdis/ProcessInfo+Clawdis.swift
@@ -0,0 +1,19 @@
+import Foundation
+
+extension ProcessInfo {
+ var isPreview: Bool {
+ self.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
+ }
+
+ var isRunningTests: Bool {
+ // SwiftPM tests load one or more `.xctest` bundles. With Swift Testing, `Bundle.main` is not
+ // guaranteed to be the `.xctest` bundle, so check all loaded bundles.
+ if Bundle.allBundles.contains(where: { $0.bundleURL.pathExtension == "xctest" }) { return true }
+ if Bundle.main.bundleURL.pathExtension == "xctest" { return true }
+
+ // Backwards-compatible fallbacks for runners that still set XCTest env vars.
+ return self.environment["XCTestConfigurationFilePath"] != nil
+ || self.environment["XCTestBundlePath"] != nil
+ || self.environment["XCTestSessionIdentifier"] != nil
+ }
+}