import Cocoa enum Exit: Int32 { case ok = 0 case usage = 1 case notFound = 2 case appMissing = 3 case noPermission = 4 case postcondition = 5 case optionMissing = 6 } var emitted = false func out(_ obj: [String: Any]) { let data = try! JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]) print(String(data: data, encoding: .utf8)!) emitted = true } var inFatalHook = false func die(_ code: Exit, _ message: String, _ extra: [String: Any] = [:]) -> Never { var o: [String: Any] = ["ok": false, "error": message, "exit": Int(code.rawValue)] o.merge(extra) { a, _ in a } if code == .notFound || code == .postcondition, !inFatalHook, let hook = fatalHook { inFatalHook = true if let report = hook(code, message, extra) { o.merge(report) { a, _ in a } } } out(o) exit(code.rawValue) } func envStr(_ name: String) -> String? { guard let v = ProcessInfo.processInfo.environment[name], !v.isEmpty else { return nil } return v } let HOME = envStr("HOME") ?? FileManager.default.homeDirectoryForCurrentUser.path func envBool(_ name: String, default def: Bool) -> Bool { guard let v = envStr(name)?.lowercased() else { return def } return !["0", "false", "no", "off"].contains(v) } let STATE_DIR = envStr("ROUTINECTL_HOME") ?? (HOME + "/.routinectl") let SELECTORS_PATH = envStr("ROUTINECTL_SELECTORS") ?? (STATE_DIR + "/selectors.json") let FAILURES_DIR = STATE_DIR + "/failures" let REPAIR_DEPTH = Int(envStr("ROUTINECTL_REPAIR_DEPTH") ?? "0") ?? 0 var autoRepairEnabled = envBool("ROUTINECTL_AUTOREPAIR", default: true) && !CommandLine.arguments.contains("--no-repair") func ensureDir(_ path: String) { try? FileManager.default.createDirectory(atPath: path, withIntermediateDirectories: true) } var fatalHook: ((Exit, String, [String: Any]) -> [String: Any]?)? = nil struct Selector { let key: String let role: String? var labels: [String] var exact: Bool let what: String } var SELECTORS: [String: Selector] = [ "sidebar.code": Selector(key: "sidebar.code", role: "AXButton", labels: ["Code"], exact: true, what: "Sidebar Code button"), "sidebar.routines": Selector(key: "sidebar.routines", role: "AXButton", labels: ["Routines"], exact: true, what: "Routines sidebar entry"), "routines.new": Selector(key: "routines.new", role: nil, labels: ["New routine"], exact: false, what: "New routine button"), "routines.new.local": Selector(key: "routines.new.local", role: nil, labels: ["Local"], exact: false, what: "Local entry in the New routine menu"), "form.name": Selector(key: "form.name", role: "AXTextField", labels: ["Name"], exact: false, what: "Name field"), "form.description": Selector(key: "form.description", role: "AXTextField", labels: ["Description"], exact: false, what: "Description field"), "form.instructions": Selector(key: "form.instructions", role: "AXTextArea", labels: ["Instructions"], exact: false, what: "Instructions field"), "form.permissions": Selector(key: "form.permissions", role: "AXComboBox", labels: ["Permissions"], exact: true, what: "Permissions control"), "form.model": Selector(key: "form.model", role: "AXComboBox", labels: ["Model"], exact: true, what: "Model control"), "form.folder": Selector(key: "form.folder", role: "AXComboBox", labels: ["Folder"], exact: true, what: "Folder control"), "form.worktree": Selector(key: "form.worktree", role: "AXCheckBox", labels: ["Worktree"], exact: true, what: "Worktree checkbox"), "form.cron": Selector(key: "form.cron", role: "AXTextField", labels: ["Cron expression"], exact: false, what: "Cron field"), "form.create": Selector(key: "form.create", role: "AXButton", labels: ["Create"], exact: true, what: "Create button"), "form.cancel": Selector(key: "form.cancel", role: "AXButton", labels: ["Cancel"], exact: true, what: "Cancel button"), "detail.runnow": Selector(key: "detail.runnow", role: "AXButton", labels: ["Run now"], exact: true, what: "Run now button on the routine detail page"), "detail.delete": Selector(key: "detail.delete", role: "AXButton", labels: ["Delete"], exact: true, what: "Delete button"), ] var selectorOverlayLoaded: String? = nil var selectorOverlayRejected: [String: String] = [:] func loadSelectorOverlay() { guard let data = FileManager.default.contents(atPath: SELECTORS_PATH), let root = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], let table = root["selectors"] as? [String: Any] else { return } var applied = 0 for (key, raw) in table { guard let entry = raw as? [String: Any] else { selectorOverlayRejected[key] = "entry is not an object"; continue } guard var sel = SELECTORS[key] else { selectorOverlayRejected[key] = "not a selector this build knows"; continue } guard let labels = entry["labels"] as? [String] else { selectorOverlayRejected[key] = "no labels array"; continue } let clean = labels.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty && $0.count <= 120 } guard !clean.isEmpty, clean.count <= 8 else { selectorOverlayRejected[key] = "labels are empty or too many"; continue } sel.labels = clean if let exact = entry["exact"] as? Bool { sel.exact = exact } SELECTORS[key] = sel applied += 1 } if applied > 0 { selectorOverlayLoaded = "\(SELECTORS_PATH) (\(applied) selector\(applied == 1 ? "" : "s"))" } } func writeSelectorOverlay(_ table: [String: Any], note: String) -> Bool { ensureDir(STATE_DIR) let doc: [String: Any] = [ "written_by": "routinectl repair", "written_at": ISO8601DateFormatter().string(from: Date()), "note": note, "selectors": table, ] guard let data = try? JSONSerialization.data(withJSONObject: doc, options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]) else { return false } return FileManager.default.createFile(atPath: SELECTORS_PATH, contents: data) } if CommandLine.arguments.count >= 2, CommandLine.arguments[1] == "repair" { let sub = Array(CommandLine.arguments.dropFirst(2)) if sub.contains("--reset") { try? FileManager.default.removeItem(atPath: SELECTORS_PATH) out(["ok": true, "reset": SELECTORS_PATH, "detail": "selector overlay removed; the compiled-in table is in force again"]) exit(0) } if !sub.contains("--run") { loadSelectorOverlay() var effective: [String: Any] = [:] for (key, sel) in SELECTORS { effective[key] = ["labels": sel.labels, "exact": sel.exact, "role": sel.role ?? "any", "what": sel.what] } let failures = ((try? FileManager.default.contentsOfDirectory(atPath: FAILURES_DIR)) ?? []) .filter { $0.hasSuffix(".json") }.sorted().suffix(10) out(["ok": true, "selectors_path": SELECTORS_PATH, "overlay_in_force": selectorOverlayLoaded ?? "none: running on the compiled-in table", "overlay_rejected": selectorOverlayRejected, "selectors": effective, "recent_failures": Array(failures), "failures_dir": FAILURES_DIR, "repair_agent": findRepairAgent() ?? "none found", "autorepair": envBool("ROUTINECTL_AUTOREPAIR", default: true), "usage": ["routinectl repair show what self-repair has changed", "routinectl repair --run force a repair pass against the open window", "routinectl repair --reset discard the overlay, back to the built-in table"]]) exit(0) } } let BUNDLE = "com.anthropic.claudefordesktop" func attr(_ el: AXUIElement, _ name: String) -> AnyObject? { var v: AnyObject? return AXUIElementCopyAttributeValue(el, name as CFString, &v) == .success ? v : nil } func kids(_ el: AXUIElement) -> [AXUIElement] { (attr(el, kAXChildrenAttribute as String) as? [AXUIElement]) ?? [] } func role(_ el: AXUIElement) -> String { attr(el, kAXRoleAttribute as String) as? String ?? "" } func subrole(_ el: AXUIElement) -> String { attr(el, kAXSubroleAttribute as String) as? String ?? "" } func title(_ el: AXUIElement) -> String { attr(el, kAXTitleAttribute as String) as? String ?? "" } func desc(_ el: AXUIElement) -> String { attr(el, kAXDescriptionAttribute as String) as? String ?? "" } func isFocused(_ el: AXUIElement) -> Bool { (attr(el, kAXFocusedAttribute as String) as? NSNumber)?.boolValue ?? false } func value(_ el: AXUIElement) -> String { guard let v = attr(el, kAXValueAttribute as String) else { return "" } if let s = v as? String { return s } if let n = v as? NSNumber { return n.stringValue } return "" } func label(_ el: AXUIElement) -> String { for s in [desc(el), title(el), value(el)] where !s.isEmpty { return s } return "" } func plainLabel(_ el: AXUIElement) -> String { var s = label(el) for suffix in [" not selected", " selected"] where s.hasSuffix(suffix) { s = String(s.dropLast(suffix.count)) } return s.trimmingCharacters(in: .whitespacesAndNewlines) } func norm(_ s: String) -> String { s.trimmingCharacters(in: .whitespacesAndNewlines) .replacingOccurrences(of: " *", with: "") .lowercased() } let app: NSRunningApplication = { guard let a = NSWorkspace.shared.runningApplications.first(where: { $0.bundleIdentifier == BUNDLE }) else { die(.appMissing, "Claude Desktop is not running (bundle \(BUNDLE)). Start it, then retry.") } return a }() let PID = app.processIdentifier let appEl = AXUIElementCreateApplication(PID) func windows() -> [AXUIElement] { (attr(appEl, kAXWindowsAttribute as String) as? [AXUIElement]) ?? [] } func mainWindow() -> AXUIElement { guard let w = windows().first else { die(.appMissing, "Claude Desktop has no open window. Open its main window, then retry.") } return w } func enableWebTree() { guard AXIsProcessTrusted() else { die(.noPermission, """ No Accessibility permission. Grant it to the process that RUNS this \ binary (iTerm2, Terminal, Ghostty, BetterTouchTool, Claude Desktop's own \ terminal pane) in System Settings > Privacy & Security > Accessibility. \ The permission belongs to the host application, not to routinectl. """) } AXUIElementSetAttributeValue(appEl, "AXManualAccessibility" as CFString, kCFBooleanTrue) for _ in 0..<20 { if descendants(mainWindow(), max: 40).count > 20 { return } Thread.sleep(forTimeInterval: 0.1) } } func descendants(_ root: AXUIElement, max: Int = 6000) -> [AXUIElement] { var found: [AXUIElement] = [] var queue = [root] while !queue.isEmpty, found.count < max { let el = queue.removeFirst() found.append(el) queue.append(contentsOf: kids(el)) } return found } func find(role wantRole: String? = nil, label wantLabel: String, exact: Bool = false, in root: AXUIElement? = nil) -> AXUIElement? { let want = norm(wantLabel) for el in descendants(root ?? mainWindow()) { if let r = wantRole, role(el) != r { continue } let have = norm(plainLabel(el)) if exact ? have == want : (have == want || have.hasPrefix(want)) { return el } } return nil } func findAll(role wantRole: String, in root: AXUIElement? = nil) -> [AXUIElement] { descendants(root ?? mainWindow()).filter { role($0) == wantRole } } func require(role r: String? = nil, label l: String, exact: Bool = false, what: String) -> AXUIElement { guard let el = find(role: r, label: l, exact: exact) else { die(.notFound, "\(what) not found in the window (looked for \(r ?? "any") labeled \"\(l)\").", ["hint": "Run `routinectl doctor` to confirm the web AX tree is exposed, and check the app is on the expected page."]) } return el } func snapshotTree(limit: Int = 400) -> [[String: String]] { guard let win = windows().first else { return [] } var seen = Set() var rows: [[String: String]] = [] for el in descendants(win, max: 4000) { let l = plainLabel(el) guard !l.isEmpty, l.count <= 120 else { continue } let r = role(el) let key = r + "\u{1}" + subrole(el) + "\u{1}" + l if seen.contains(key) { continue } seen.insert(key) rows.append(["role": r, "subrole": subrole(el), "label": l]) if rows.count >= limit { break } } return rows } func appVersion() -> String { guard let plist = NSDictionary(contentsOf: app.bundleURL!.appendingPathComponent("Contents/Info.plist")) else { return "unknown" } return (plist["CFBundleShortVersionString"] as? String) ?? (plist["CFBundleVersion"] as? String) ?? "unknown" } @discardableResult func writeFailureBundle(code: Exit, message: String, selectorKey: String?, extra: [String: Any]) -> (path: String, doc: [String: Any]) { ensureDir(FAILURES_DIR) let stamp = ISO8601DateFormatter().string(from: Date()) .replacingOccurrences(of: ":", with: "-") let path = "\(FAILURES_DIR)/\(stamp)-\(selectorKey ?? "unknown").json" var wanted: [String: Any] = [:] if let k = selectorKey, let sel = SELECTORS[k] { wanted = ["key": k, "role": sel.role ?? "any", "labels": sel.labels, "exact": sel.exact, "what": sel.what] } let doc: [String: Any] = [ "routinectl_failure": 1, "at": stamp, "exit": Int(code.rawValue), "exit_name": code == .notFound ? "notFound" : "postcondition", "message": message, "argv": CommandLine.arguments, "app_version": appVersion(), "selector": wanted, "extra": extra, "window": snapshotTree(), ] if let data = try? JSONSerialization.data(withJSONObject: doc, options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]) { FileManager.default.createFile(atPath: path, contents: data) } return (path, doc) } func findRepairAgent() -> String? { if let explicit = envStr("ROUTINECTL_REPAIR_CMD") { return explicit } var candidates: [String] = [] if let path = envStr("PATH") { candidates += path.split(separator: ":").map { String($0) + "/claude" } } candidates += [ HOME + "/.local/bin/claude", HOME + "/.claude/local/claude", HOME + "/bin/claude", "/opt/homebrew/bin/claude", "/usr/local/bin/claude", ] return candidates.first { FileManager.default.isExecutableFile(atPath: $0) } } func repairPrompt(bundlePath: String, doc: [String: Any]) -> String { let wanted = (doc["selector"] as? [String: Any]) ?? [:] let rows = (doc["window"] as? [[String: String]]) ?? [] let inventory = rows.map { row -> String in let sub = row["subrole"] ?? "" return "\(row["role"] ?? "")\(sub.isEmpty ? "" : "/" + sub) \(row["label"] ?? "")" }.joined(separator: "\n") let wantedJSON = (try? JSONSerialization.data(withJSONObject: wanted, options: [.sortedKeys, .withoutEscapingSlashes])) .flatMap { String(data: $0, encoding: .utf8) } ?? "{}" return """ You are repairing `routinectl`, a command line tool that drives the Claude Desktop \ routine form through the macOS Accessibility API. It finds every control by role \ plus label. One lookup just failed, which normally means the application renamed \ or restructured that control. Failed lookup: \(wantedJSON) Reported: \(doc["message"] as? String ?? "") Application version: \(doc["app_version"] as? String ?? "unknown") Full record: \(bundlePath) Every named element actually present in the window right now, as `ROLE LABEL`: \(inventory) Decide which element in that inventory is the control the failed lookup was \ looking for. Judge by what the control DOES, not by string similarity: a \ "Schedule" heading is not the schedule combo box, and a menu item that happens \ to read "Create" is not the form's Create button. Reply with one JSON object and nothing else. No prose, no markdown fence. {"selectors": {"": {"labels": ["", ""], "exact": true|false}}} Rules: - Keep the old label in the list, after the new one. Both builds then work. - `exact` true means the label must match in full, false means prefix matching. - Change only the key that failed. - If nothing in the inventory is that control, reply {"selectors": {}} and nothing \ else. An empty answer is correct and useful; a guess is neither. """ } func runRepairAgent(_ agent: String, prompt: String) -> (ok: Bool, detail: String, table: [String: Any]) { let proc = Process() proc.executableURL = URL(fileURLWithPath: agent) proc.arguments = ["-p", prompt, "--output-format", "text"] let pipe = Pipe() proc.standardOutput = pipe proc.standardError = FileHandle.nullDevice proc.standardInput = FileHandle.nullDevice do { try proc.run() } catch { return (false, "could not start \(agent): \(error)", [:]) } var data = Data() let done = DispatchSemaphore(value: 0) DispatchQueue(label: "routinectl.repair.read").async { data = pipe.fileHandleForReading.readDataToEndOfFile() done.signal() } let deadline = Date().addingTimeInterval(240) while proc.isRunning, Date() < deadline { Thread.sleep(forTimeInterval: 0.25) } if proc.isRunning { proc.terminate() return (false, "repair agent did not answer within 240s", [:]) } _ = done.wait(timeout: .now() + 5) let text = String(data: data, encoding: .utf8) ?? "" guard let start = text.firstIndex(of: "{"), let end = text.lastIndex(of: "}"), start < end else { return (false, "repair agent returned no JSON object", [:]) } let slice = String(text[start...end]) guard let obj = (try? JSONSerialization.jsonObject(with: Data(slice.utf8))) as? [String: Any], let proposed = obj["selectors"] as? [String: Any] else { return (false, "repair agent's JSON has no selectors object", [:]) } if proposed.isEmpty { return (false, "repair agent found no matching control in the window", [:]) } var clean: [String: Any] = [:] for (key, raw) in proposed { guard SELECTORS[key] != nil, let entry = raw as? [String: Any], let labels = entry["labels"] as? [String] else { continue } let ok = labels.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty && $0.count <= 120 } guard !ok.isEmpty, ok.count <= 8 else { continue } clean[key] = ["labels": Array(ok.prefix(8)), "exact": (entry["exact"] as? Bool) ?? false] } guard !clean.isEmpty else { return (false, "no valid selector survived validation", [:]) } return (true, "accepted \(clean.count) selector\(clean.count == 1 ? "" : "s")", clean) } func openTerminalRepair(agent: String, prompt: String) -> String { ensureDir(STATE_DIR) let promptPath = STATE_DIR + "/last-repair-prompt.txt" FileManager.default.createFile(atPath: promptPath, contents: Data(prompt.utf8)) let script = """ tell application "Terminal" do script "\(agent) < \(promptPath)" activate end tell """ let proc = Process() proc.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") proc.arguments = ["-e", script] try? proc.run() proc.waitUntilExit() return promptPath } func reexecSelf() -> Never { var env = ProcessInfo.processInfo.environment env["ROUTINECTL_REPAIR_DEPTH"] = String(REPAIR_DEPTH + 1) let proc = Process() proc.executableURL = URL(fileURLWithPath: CommandLine.arguments[0]) proc.arguments = Array(CommandLine.arguments.dropFirst()) proc.environment = env do { try proc.run() } catch { exit(Exit.postcondition.rawValue) } proc.waitUntilExit() exit(proc.terminationStatus) } func attemptRepair(code: Exit, message: String, selectorKey: String?, extra: [String: Any]) -> [String: Any] { let (bundlePath, doc) = writeFailureBundle(code: code, message: message, selectorKey: selectorKey, extra: extra) var report: [String: Any] = ["failure_record": bundlePath] guard selectorKey != nil else { report["repair"] = "not attempted: this failure is not tied to a named selector" return report } guard REPAIR_DEPTH == 0 else { report["repair"] = "not attempted: a repair already ran for this command and it failed again" return report } guard autoRepairEnabled else { report["repair"] = "off (--no-repair, or ROUTINECTL_AUTOREPAIR=0)" report["repair_by_hand"] = "routinectl repair \(bundlePath)" return report } guard let agent = findRepairAgent() else { report["repair"] = "no repair agent found: install the Claude CLI, or set ROUTINECTL_REPAIR_CMD to one" report["repair_by_hand"] = "edit \(SELECTORS_PATH) using the window inventory in \(bundlePath)" return report } let prompt = repairPrompt(bundlePath: bundlePath, doc: doc) if (envStr("ROUTINECTL_REPAIR_MODE") ?? "headless") == "terminal" { let p = openTerminalRepair(agent: agent, prompt: prompt) report["repair"] = "opened an interactive repair session in Terminal" report["repair_prompt"] = p return report } let result = runRepairAgent(agent, prompt: prompt) guard result.ok else { report["repair"] = "attempted and did not fix it: \(result.detail)" report["repair_agent"] = agent return report } guard writeSelectorOverlay(result.table, note: "repair after \(code == .notFound ? "notFound" : "postcondition") on \(selectorKey ?? "?"), record \(bundlePath)") else { report["repair"] = "agent produced a fix and it could not be written to \(SELECTORS_PATH)" return report } FileHandle.standardError.write(Data("routinectl: interface moved, repaired \(SELECTORS_PATH), retrying\n".utf8)) reexecSelf() } fatalHook = { code, message, extra in attemptRepair(code: code, message: message, selectorKey: extra["selector"] as? String, extra: extra) } loadSelectorOverlay() func findSel(_ key: String, in root: AXUIElement? = nil) -> AXUIElement? { guard let sel = SELECTORS[key] else { return nil } for candidate in sel.labels { if let el = find(role: sel.role, label: candidate, exact: sel.exact, in: root) { return el } } return nil } func requireSel(_ key: String, in root: AXUIElement? = nil) -> AXUIElement { if let el = findSel(key, in: root) { return el } let sel = SELECTORS[key] die(.notFound, "\(sel?.what ?? key) not found in the window.", ["selector": key, "tried_labels": sel?.labels ?? [], "tried_role": sel?.role ?? "any", "hint": "Run `routinectl doctor` to confirm the web AX tree is exposed and the app is on the expected page. If the interface has been renamed, self-repair rewrites \(SELECTORS_PATH); `routinectl repair` prints what it has changed."]) } func dieSel(_ key: String, _ message: String, _ extra: [String: Any] = [:]) -> Never { var e = extra e["selector"] = key die(.postcondition, message, e) } @discardableResult func waitFor(_ seconds: Double = 4.0, _ check: () -> Bool) -> Bool { let deadline = Date().addingTimeInterval(seconds) while Date() < deadline { if check() { return true } Thread.sleep(forTimeInterval: 0.15) } return check() } let keyMap: [String: CGKeyCode] = [ "return": 36, "space": 49, "tab": 48, "escape": 53, "down": 125, "up": 126, "left": 123, "right": 124, ] func postKey(_ name: String, settle: Double = 0.16) { guard let vk = keyMap[name] else { return } let src = CGEventSource(stateID: .hidSystemState) CGEvent(keyboardEventSource: src, virtualKey: vk, keyDown: true)?.postToPid(PID) usleep(30_000) CGEvent(keyboardEventSource: src, virtualKey: vk, keyDown: false)?.postToPid(PID) Thread.sleep(forTimeInterval: settle) } func typeText(_ s: String) { let src = CGEventSource(stateID: .hidSystemState) for ch in s { var utf16 = Array(String(ch).utf16) let down = CGEvent(keyboardEventSource: src, virtualKey: 0, keyDown: true) down?.keyboardSetUnicodeString(stringLength: utf16.count, unicodeString: &utf16) down?.postToPid(PID) usleep(12_000) let up = CGEvent(keyboardEventSource: src, virtualKey: 0, keyDown: false) up?.keyboardSetUnicodeString(stringLength: utf16.count, unicodeString: &utf16) up?.postToPid(PID) usleep(12_000) } } func focus(_ el: AXUIElement) { AXUIElementSetAttributeValue(el, kAXFocusedAttribute as CFString, kCFBooleanTrue) Thread.sleep(forTimeInterval: 0.2) } func press(_ el: AXUIElement) { focus(el) postKey("return", settle: 0.35) } func toggle(_ el: AXUIElement) { focus(el) postKey("space", settle: 0.35) } func setText(_ el: AXUIElement, _ text: String, field: String) { focus(el) AXUIElementSetAttributeValue(el, kAXValueAttribute as CFString, text as CFString) if !waitFor(3.0, { value(el) == text }) { die(.postcondition, "\"\(field)\" did not accept the text.", ["wanted": text, "got": value(el), "hint": "A value that reads back is not always a value the app accepted; this check compares after a settle. If it keeps failing the field may now require typed input."]) } } func chooseFromMenu(opener: AXUIElement, want: String, field: String, verify: (() -> Bool)? = nil) { press(opener) var items: [AXUIElement] = [] _ = waitFor(3.0) { items = findAll(role: "AXMenuItem") return !items.isEmpty } guard !items.isEmpty else { die(.notFound, "\(field): the menu did not open.", ["hint": "The opener was focused and Return posted but no AXMenuItem appeared. Re-run; if it persists the control may no longer be a menu."]) } let names = items.map { plainLabel($0) } guard let target = names.firstIndex(where: { norm($0) == norm(want) }) ?? names.firstIndex(where: { norm($0).hasPrefix(norm(want)) }) else { postKey("escape") die(.optionMissing, "\(field): \"\(want)\" is not offered.", ["available": names]) } for _ in 0..<(items.count * 2 + 3) { let live = findAll(role: "AXMenuItem") guard live.count == items.count else { break } let cur = live.firstIndex(where: { isFocused($0) }) if cur == target { postKey("return", settle: 0.5) let check = verify ?? { norm(plainLabel(opener)) == norm(want) || norm(value(opener)) == norm(want) } if waitFor(4.0, check) { return } die(.postcondition, "\(field): chose \"\(want)\" but the postcondition never held.", ["control_reads": label(opener)]) } postKey("down") } postKey("escape") die(.postcondition, "\(field): could not move the menu highlight onto \"\(want)\".", ["available": names, "hint": "The highlight is read from AXFocused on each AXMenuItem. If no item ever reports AXFocused=1 the menu is not taking key events from this process."]) } func detailValue(under key: String) -> String? { guard let hdr = descendants(mainWindow()) .first(where: { role($0) == "AXStaticText" && value($0) == key }) else { return nil } guard let p = attr(hdr, kAXParentAttribute as String), let gp = attr(p as! AXUIElement, kAXParentAttribute as String) else { return nil } let heading = p as! AXUIElement let siblings = kids(gp as! AXUIElement).filter { !CFEqual($0, heading) } guard !siblings.isEmpty else { return nil } func texts(_ el: AXUIElement) -> [String] { descendants(el).filter { role($0) == "AXStaticText" }.map { value($0) } } if siblings.count == 1 { let joined = texts(siblings[0]).joined() return joined.isEmpty ? nil : joined } return siblings.flatMap(texts).first(where: { !$0.isEmpty }) } struct RoutineRow { let el: AXUIElement let name: String let schedule: String let status: String } func routineRows() -> [RoutineRow] { findAll(role: "AXButton").compactMap { btn -> RoutineRow? in let c = kids(btn) guard c.count >= 2, role(c[0]) == "AXGroup", role(c[1]) == "AXGroup" else { return nil } let nameTexts = kids(c[0]).filter { role($0) == "AXStaticText" }.map { value($0) } let schedTexts = kids(c[1]).filter { role($0) == "AXStaticText" }.map { value($0) } guard let n = nameTexts.first, !n.isEmpty, !schedTexts.isEmpty else { return nil } let status = c.count >= 3 && role(c[2]) == "AXStaticText" ? value(c[2]) : "Active" return RoutineRow(el: btn, name: n, schedule: schedTexts.joined().trimmingCharacters(in: .whitespaces), status: status) } } func ensureCodeMode() { if findSel("sidebar.routines") != nil { return } guard let code = findSel("sidebar.code") else { die(.notFound, "Sidebar Code button not found.", ["selector": "sidebar.code", "hint": "The sidebar may be collapsed. Expand it, or run `routinectl doctor`."]) } press(code) if !waitFor(5.0, { findSel("sidebar.routines") != nil }) { dieSel("sidebar.routines", "Pressed Code but the Routines sidebar entry never appeared.") } } func openRoutinesPage() { if findSel("routines.new") != nil { return } press(requireSel("sidebar.routines")) if !waitFor(5.0, { findSel("routines.new") != nil }) { dieSel("routines.new", "Pressed Routines but the page never showed its \"New routine\" control.") } } func openNewLocalForm() { let newBtn = requireSel("routines.new") chooseFromMenu(opener: newBtn, want: SELECTORS["routines.new.local"]!.labels[0], field: "New routine", verify: { findSel("form.name") != nil }) } func setSchedule(_ kind: String) { let wanted = kind.prefix(1).uppercased() + kind.dropFirst().lowercased() let btn = require(role: "AXCheckBox", label: wanted, exact: true, what: "Schedule option \"\(wanted)\"") if value(btn) == "1" { return } toggle(btn) if !waitFor(4.0, { value(btn) == "1" }) { die(.postcondition, "Schedule option \"\(wanted)\" did not switch on.", ["hint": "Schedule pills are AXCheckBox with subrole AXToggleButton; they answer to Space, not Return."]) } } func setTimeField(_ hhmm: String) { let parts = hhmm.split(separator: ":") guard parts.count == 2, let h24 = Int(parts[0]), let m = Int(parts[1]), (0...23).contains(h24), (0...59).contains(m) else { die(.usage, "--at expects 24-hour HH:MM, got \"\(hhmm)\".") } let field = require(label: "At", what: "time field") let h12 = h24 % 12 == 0 ? 12 : h24 % 12 let ampm = h24 < 12 ? "AM" : "PM" let wanted = String(format: "%02d:%02d", h12, m) focus(field) AXUIElementSetAttributeValue(field, kAXValueAttribute as CFString, wanted as CFString) Thread.sleep(forTimeInterval: 0.3) if value(field).hasPrefix(wanted) { return } typeText(String(format: "%02d%02d", h12, m) + ampm.prefix(1)) if !waitFor(3.0, { value(field).hasPrefix(wanted) }) { die(.postcondition, "Time field did not accept \(hhmm).", ["got": value(field), "hint": "Use --schedule custom with --cron instead; the cron field is a plain text field and always accepts a write."]) } } var argv = Array(CommandLine.arguments.dropFirst()) guard let command = argv.first else { die(.usage, "no command", ["usage": [ "routinectl create --name N --description D [--instructions T | --instructions-file F] [--permissions P] [--model M] [--folder PATH] [--worktree] [--schedule manual|hourly|daily|weekdays|weekly|custom] [--cron EXPR] [--at HH:MM]", "routinectl create --spec FILE.json", "routinectl list", "routinectl options", "routinectl doctor", "routinectl repair [--run | --reset]", ]]) } argv.removeFirst() func opt(_ name: String) -> String? { guard let i = argv.firstIndex(of: "--" + name), i + 1 < argv.count else { return nil } return argv[i + 1] } func flag(_ name: String) -> Bool { argv.contains("--" + name) } enableWebTree() switch command { case "doctor": let win = mainWindow() let nodes = descendants(win).count var checks: [[String: Any]] = [] checks.append(["check": "accessibility_permission", "ok": AXIsProcessTrusted(), "detail": "granted to the host process that runs this binary"]) checks.append(["check": "app_running", "ok": true, "pid": Int(PID)]) checks.append(["check": "web_ax_tree", "ok": nodes > 100, "nodes": nodes, "detail": nodes > 100 ? "AXManualAccessibility is on" : "tree is opaque; Chromium did not expose web content"]) let codeMode = findSel("sidebar.routines") != nil checks.append(["check": "code_mode", "ok": codeMode, "detail": codeMode ? "Routines sidebar entry present" : "app is in Home mode; create will switch it"]) var pressLive = false if let probe = findSel("sidebar.routines") { pressLive = AXUIElementPerformAction(probe, kAXPressAction as CFString) == .success } checks.append(["check": "axpress_returns_success", "ok": pressLive, "detail": "informational only: AXPress returns success but does not act as of app 1.26832.0; routinectl uses focus + posted Return regardless"]) var unresolved: [String] = [] for key in SELECTORS.keys.sorted() where findSel(key) == nil { unresolved.append(key) } checks.append(["check": "selectors_resolve", "ok": unresolved.count < SELECTORS.count, "resolved": SELECTORS.count - unresolved.count, "of": SELECTORS.count, "unresolved_here": unresolved, "detail": "a selector only resolves on the page that owns it, so some are expected to be absent here; all of them absent means the tree is opaque or the app was redesigned"]) let agent = findRepairAgent() checks.append(["check": "self_repair", "ok": agent != nil && autoRepairEnabled, "agent": agent ?? "none found", "enabled": autoRepairEnabled, "selectors": selectorOverlayLoaded ?? "compiled-in table, no overlay", "detail": "on a notFound or postcondition against a named control, routinectl records the window, asks the agent for corrected labels, writes the overlay and retries once"]) out(["ok": nodes > 100, "checks": checks, "window_nodes": nodes, "app_version": appVersion(), "selectors_path": SELECTORS_PATH]) case "repair": let wantedSel = opt("selector") var missingSel: [String] = [] for key in SELECTORS.keys.sorted() where findSel(key) == nil { if let w = wantedSel, w != key { continue } missingSel.append(key) } guard let firstMissing = missingSel.first else { out(["ok": true, "repaired": false, "detail": "every selector resolves against the window as it stands; nothing to repair", "checked": SELECTORS.count, "selectors_path": SELECTORS_PATH]) exit(0) } die(.notFound, "\(SELECTORS[firstMissing]?.what ?? firstMissing) not found in the window.", ["selector": firstMissing, "also_unresolved_here": Array(missingSel.dropFirst()), "hint": "requested by `routinectl repair --run`"]) case "options": ensureCodeMode(); openRoutinesPage(); openNewLocalForm() var result: [String: [String]] = [:] for (field, key) in [("Permissions", "form.permissions"), ("Model", "form.model"), ("Folder", "form.folder")] { guard let combo = findSel(key) else { continue } press(combo) var items: [AXUIElement] = [] _ = waitFor(3.0) { items = findAll(role: "AXMenuItem"); return !items.isEmpty } result[field] = items.map { plainLabel($0) } postKey("escape") Thread.sleep(forTimeInterval: 0.4) } result["Schedule"] = ["Manual", "Hourly", "Daily", "Weekdays", "Weekly", "Custom"] if let cancel = findSel("form.cancel") { press(cancel) } out(["ok": true, "options": result]) case "list": ensureCodeMode(); openRoutinesPage() let rows = routineRows().map { ["name": $0.name, "schedule": $0.schedule, "status": $0.status] } out(["ok": true, "count": rows.count, "routines": rows]) case "create": var name = opt("name"), description = opt("description") var instructions = opt("instructions") var permissions = opt("permissions"), model = opt("model"), folder = opt("folder") var schedule = opt("schedule"), cron = opt("cron"), at = opt("at") var worktree = flag("worktree") if let specPath = opt("spec") { guard let data = FileManager.default.contents(atPath: specPath), let spec = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else { die(.usage, "could not read --spec \(specPath) as JSON") } func s(_ k: String) -> String? { spec[k] as? String } name = name ?? s("name"); description = description ?? s("description") instructions = instructions ?? s("instructions") permissions = permissions ?? s("permissions"); model = model ?? s("model") folder = folder ?? s("folder"); schedule = schedule ?? s("schedule") cron = cron ?? s("cron"); at = at ?? s("at") worktree = worktree || (spec["worktree"] as? Bool ?? false) } if let f = opt("instructions-file") { guard let t = try? String(contentsOfFile: f, encoding: .utf8) else { die(.usage, "could not read --instructions-file \(f)") } instructions = t.trimmingCharacters(in: .whitespacesAndNewlines) } guard let theName = name, !theName.isEmpty else { die(.usage, "--name is required") } guard let theDesc = description, !theDesc.isEmpty else { die(.usage, "--description is required") } let sched = (schedule ?? "daily").lowercased() if sched == "custom", cron == nil { die(.usage, "--schedule custom requires --cron") } if flag("dry-run") { out(["ok": true, "dry_run": true, "would_create": [ "name": theName, "description": theDesc, "instructions": instructions ?? "", "permissions": permissions ?? "(unchanged)", "model": model ?? "(unchanged)", "folder": folder ?? "(unchanged)", "worktree": worktree, "schedule": sched, "cron": cron ?? "", "at": at ?? "", ]]) exit(0) } ensureCodeMode() openRoutinesPage() openNewLocalForm() setText(requireSel("form.name"), theName, field: "Name") setText(requireSel("form.description"), theDesc, field: "Description") if let i = instructions, !i.isEmpty { setText(requireSel("form.instructions"), i, field: "Instructions") } if let p = permissions { chooseFromMenu(opener: requireSel("form.permissions"), want: p, field: "Permissions") } if let m = model { chooseFromMenu(opener: requireSel("form.model"), want: m, field: "Model") } if let f = folder { chooseFromMenu(opener: requireSel("form.folder"), want: f, field: "Folder") } if worktree { let wt = requireSel("form.worktree") if value(wt) != "1" { toggle(wt) if !waitFor(3.0, { value(wt) == "1" }) { dieSel("form.worktree", "Worktree did not switch on.") } } } setSchedule(sched) if sched == "custom", let c = cron { guard waitFor(4.0, { findSel("form.cron") != nil }) else { die(.postcondition, "Custom is selected but the Cron expression field never appeared.") } setText(requireSel("form.cron"), c, field: "Cron expression") } else if let t = at { setTimeField(t) } let create = requireSel("form.create") press(create) let saved = waitFor(8.0) { findSel("form.name") == nil && find(label: theName) != nil } if !saved { die(.postcondition, "Pressed Create but the routine did not save.", ["hint": "The form is still open. A required field is probably empty or the cron expression is rejected - read the form back with `routinectl doctor` or check for an inline validation message."]) } var confirmed: [String: Any] = ["name": theName] for key in ["Repeats", "Folder", "Status"] { if let v = detailValue(under: key) { confirmed[key.lowercased()] = v } } if let next = descendants(mainWindow()).map({ value($0) }).first(where: { $0.hasPrefix("Next run") }) { confirmed["next_run"] = next } out(["ok": true, "created": confirmed]) case "show", "delete": guard let target = argv.first(where: { !$0.hasPrefix("--") }) else { die(.usage, "\(command) needs a routine name") } ensureCodeMode(); openRoutinesPage() let rows = routineRows() guard let row = rows.first(where: { norm($0.name) == norm(target) }) ?? rows.first(where: { norm($0.name).hasPrefix(norm(target)) }) else { die(.notFound, "no routine named \"\(target)\".", ["available": rows.map { $0.name }, "hint": "Names are matched exactly first, then by prefix. Run `routinectl list`."]) } press(row.el) guard waitFor(5.0, { findSel("detail.runnow") != nil }) else { die(.postcondition, "opened \"\(target)\" but its detail page never appeared.") } if command == "show" { if flag("dump") { var dumps: [[String: Any]] = [] for key in ["Description", "Repeats", "Folder", "Status", "Instructions"] { guard let hdr = descendants(mainWindow()) .first(where: { role($0) == "AXStaticText" && value($0) == key }), let p = attr(hdr, kAXParentAttribute as String), let gp = attr(p as! AXUIElement, kAXParentAttribute as String) else { continue } let nodes = descendants(gp as! AXUIElement).enumerated().map { i, el -> [String: Any] in ["i": i, "role": role(el), "subrole": subrole(el), "value": value(el), "desc": desc(el), "title": title(el), "kids": kids(el).count] } dumps.append(["key": key, "nodes": nodes]) } out(["ok": true, "dump": dumps]) break } var info: [String: Any] = ["name": target] for key in ["Description", "Repeats", "Folder", "Status", "Instructions"] { if let v = detailValue(under: key) { info[key.lowercased()] = v } } if let next = descendants(mainWindow()).map({ value($0) }).first(where: { $0.hasPrefix("Next run") }) { info["next_run"] = next } out(["ok": true, "routine": info]) break } if !flag("yes") { die(.usage, "delete is irreversible; pass --yes to confirm.", ["would_delete": target]) } press(requireSel("detail.delete")) Thread.sleep(forTimeInterval: 0.8) if let confirm = findAll(role: "AXButton").last(where: { norm(label($0)) == "delete" }) { press(confirm) } if !waitFor(8.0, { find(label: target) == nil }) { die(.postcondition, "pressed Delete but \"\(target)\" is still listed.") } out(["ok": true, "deleted": target]) default: die(.usage, "unknown command \"\(command)\"", ["commands": ["create", "list", "show", "delete", "options", "doctor", "repair"]]) }