From 57773675f1e23914ad4c9a525b5f7de42e3ce1ef Mon Sep 17 00:00:00 2001 From: Matt Clarke Date: Sun, 8 Mar 2026 14:33:47 -0400 Subject: [PATCH 1/6] feat: add shell escaping for Ghostty CLI launch Co-Authored-By: Claude Opus 4.6 --- .../Utilities/AppleScriptTemplates.swift | 6 ++++ tests/StringEscapingTests.swift | 32 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/switchboard/Utilities/AppleScriptTemplates.swift b/switchboard/Utilities/AppleScriptTemplates.swift index eb0f49f..7e816e2 100644 --- a/switchboard/Utilities/AppleScriptTemplates.swift +++ b/switchboard/Utilities/AppleScriptTemplates.swift @@ -181,4 +181,10 @@ extension String { .replacingOccurrences(of: "\\", with: "\\\\") .replacingOccurrences(of: "'", with: "\\'") } + + /// Escape string for use in single-quoted shell arguments + /// Uses the '\'' pattern to break out of single quotes safely + var escapedForShell: String { + self.replacingOccurrences(of: "'", with: "'\\''") + } } diff --git a/tests/StringEscapingTests.swift b/tests/StringEscapingTests.swift index d0404c7..2dc7dae 100644 --- a/tests/StringEscapingTests.swift +++ b/tests/StringEscapingTests.swift @@ -129,6 +129,38 @@ final class StringEscapingTests: XCTestCase { XCTAssertTrue(script.contains("keystroke return")) } + // MARK: - Shell Escaping Tests + + func testShellEscaping_SimpleString() { + let input = "my-profile" + XCTAssertEqual(input.escapedForShell, "my-profile") + } + + func testShellEscaping_SingleQuote() { + let input = "it's-my-profile" + XCTAssertEqual(input.escapedForShell, "it'\\''s-my-profile") + } + + func testShellEscaping_MultipleQuotes() { + let input = "it's a 'quoted' profile" + XCTAssertEqual(input.escapedForShell, "it'\\''s a '\\''quoted'\\'' profile") + } + + func testShellEscaping_Backslash() { + let input = "path\\to\\profile" + XCTAssertEqual(input.escapedForShell, "path\\to\\profile") + } + + func testShellEscaping_EmptyString() { + let input = "" + XCTAssertEqual(input.escapedForShell, "") + } + + func testShellEscaping_SpecialCharacters() { + let input = "profile-with_special.chars@123" + XCTAssertEqual(input.escapedForShell, "profile-with_special.chars@123") + } + // MARK: - Edge Cases func testEscaping_UnicodeCharacters() { From 159fdcea52b07f66707c1986d8482ae050a1ced6 Mon Sep 17 00:00:00 2001 From: Matt Clarke Date: Sun, 8 Mar 2026 14:34:24 -0400 Subject: [PATCH 2/6] feat: add GhosttyCommands for CLI-based launching Co-Authored-By: Claude Opus 4.6 --- .../Utilities/AppleScriptTemplates.swift | 17 +++++++++++++++++ tests/StringEscapingTests.swift | 17 +++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/switchboard/Utilities/AppleScriptTemplates.swift b/switchboard/Utilities/AppleScriptTemplates.swift index 7e816e2..df4e316 100644 --- a/switchboard/Utilities/AppleScriptTemplates.swift +++ b/switchboard/Utilities/AppleScriptTemplates.swift @@ -171,6 +171,23 @@ enum AppleScriptTemplates { } +// MARK: - Ghostty CLI Commands + +/// Command arguments for launching Ghostty via CLI (no AppleScript/Accessibility needed) +enum GhosttyCommands { + + /// Build arguments for `ghostty` CLI to run assume command + /// - Parameters: + /// - profileName: AWS profile name to assume + /// - forConsole: Whether to use assume -c (console mode) + /// - Returns: Array of arguments to pass to the ghostty process + static func ghosttyArgs(profileName: String, forConsole: Bool) -> [String] { + let escapedProfile = profileName.escapedForShell + let assumeCmd = forConsole ? "assume -c" : "assume" + return ["-e", "bash", "-c", "\(assumeCmd) '\(escapedProfile)'; exec bash"] + } +} + // MARK: - String Escaping Extensions extension String { diff --git a/tests/StringEscapingTests.swift b/tests/StringEscapingTests.swift index 2dc7dae..4940065 100644 --- a/tests/StringEscapingTests.swift +++ b/tests/StringEscapingTests.swift @@ -161,6 +161,23 @@ final class StringEscapingTests: XCTestCase { XCTAssertEqual(input.escapedForShell, "profile-with_special.chars@123") } + // MARK: - Ghostty CLI Command Tests + + func testGhosttyCommand_Assume() { + let args = GhosttyCommands.ghosttyArgs(profileName: "test-profile", forConsole: false) + XCTAssertEqual(args, ["-e", "bash", "-c", "assume 'test-profile'; exec bash"]) + } + + func testGhosttyCommand_AssumeConsole() { + let args = GhosttyCommands.ghosttyArgs(profileName: "test-profile", forConsole: true) + XCTAssertEqual(args, ["-e", "bash", "-c", "assume -c 'test-profile'; exec bash"]) + } + + func testGhosttyCommand_WithQuotesInProfile() { + let args = GhosttyCommands.ghosttyArgs(profileName: "it's-my-profile", forConsole: false) + XCTAssertEqual(args, ["-e", "bash", "-c", "assume 'it'\\''s-my-profile'; exec bash"]) + } + // MARK: - Edge Cases func testEscaping_UnicodeCharacters() { From dfb12e97380b64b1e0d7759d7e602416fe579ad7 Mon Sep 17 00:00:00 2001 From: Matt Clarke Date: Sun, 8 Mar 2026 14:36:48 -0400 Subject: [PATCH 3/6] feat: switch Ghostty to CLI-based launching, no accessibility needed Co-Authored-By: Claude Opus 4.6 --- switchboard/Services/TerminalService.swift | 36 ++++++++++++-- .../Utilities/AppleScriptTemplates.swift | 48 ------------------- tests/StringEscapingTests.swift | 20 -------- tests/TerminalServiceTests.swift | 14 ++---- 4 files changed, 35 insertions(+), 83 deletions(-) diff --git a/switchboard/Services/TerminalService.swift b/switchboard/Services/TerminalService.swift index 8f39edf..87344c5 100644 --- a/switchboard/Services/TerminalService.swift +++ b/switchboard/Services/TerminalService.swift @@ -78,8 +78,8 @@ class TerminalService { /// Warp and Ghostty rely on System Events keystrokes, which require Accessibility access. var requiresSystemEventsAccess: Bool { switch self { - case .terminal, .iTerm2: return false - case .warp, .ghostty: return true + case .terminal, .iTerm2, .ghostty: return false + case .warp: return true } } } @@ -139,8 +139,36 @@ class TerminalService { // MARK: - Private Methods + /// Launch Ghostty using CLI Process (no Accessibility permissions needed) + private func launchViaProcess(terminal: Terminal, profileName: String, forConsole: Bool) throws { + let ghosttyURL: URL + if let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: terminal.bundleIdentifier) { + ghosttyURL = appURL.appendingPathComponent("Contents/MacOS/ghostty") + } else { + throw TerminalLaunchError.terminalNotInstalled(name: terminal.displayName) + } + + let args = GhosttyCommands.ghosttyArgs(profileName: profileName, forConsole: forConsole) + + let process = Process() + process.executableURL = ghosttyURL + process.arguments = args + + do { + try process.run() + } catch { + throw TerminalLaunchError.launchFailed(terminal: terminal.displayName, reason: error.localizedDescription) + } + } + /// Launch terminal using AppleScript private func launchViaAppleScript(terminal: Terminal, profileName: String, forConsole: Bool) throws { + // Ghostty uses CLI-based launching (no Accessibility permissions needed) + if terminal == .ghostty { + try launchViaProcess(terminal: terminal, profileName: profileName, forConsole: forConsole) + return + } + let script: String switch terminal { @@ -157,9 +185,7 @@ class TerminalService { ? AppleScriptTemplates.warpConsole(profileName: profileName) : AppleScriptTemplates.warp(profileName: profileName) case .ghostty: - script = forConsole - ? AppleScriptTemplates.ghosttyConsole(profileName: profileName) - : AppleScriptTemplates.ghostty(profileName: profileName) + fatalError("Ghostty should be handled by launchViaProcess") } var error: NSDictionary? diff --git a/switchboard/Utilities/AppleScriptTemplates.swift b/switchboard/Utilities/AppleScriptTemplates.swift index df4e316..d1e355a 100644 --- a/switchboard/Utilities/AppleScriptTemplates.swift +++ b/switchboard/Utilities/AppleScriptTemplates.swift @@ -121,54 +121,6 @@ enum AppleScriptTemplates { } - // MARK: - Ghostty - - /// Generate AppleScript for Ghostty terminal with Granted assume - /// - Parameter profileName: AWS profile name to assume - /// - Returns: AppleScript string - static func ghostty(profileName: String) -> String { - let escapedProfile = profileName.escapedForAppleScript - return """ - tell application id "com.mitchellh.ghostty" - activate - end tell - - delay 0.5 - - tell application "System Events" - tell process "Ghostty" - keystroke "n" using command down - delay 0.3 - keystroke "assume '\(escapedProfile)'" - keystroke return - end tell - end tell - """ - } - - /// Generate AppleScript for Ghostty terminal with Granted assume -c (console) - /// - Parameter profileName: AWS profile name to assume - /// - Returns: AppleScript string - static func ghosttyConsole(profileName: String) -> String { - let escapedProfile = profileName.escapedForAppleScript - return """ - tell application id "com.mitchellh.ghostty" - activate - end tell - - delay 0.5 - - tell application "System Events" - tell process "Ghostty" - keystroke "n" using command down - delay 0.3 - keystroke "assume -c '\(escapedProfile)'" - keystroke return - end tell - end tell - """ - } - } // MARK: - Ghostty CLI Commands diff --git a/tests/StringEscapingTests.swift b/tests/StringEscapingTests.swift index 4940065..4e51f6c 100644 --- a/tests/StringEscapingTests.swift +++ b/tests/StringEscapingTests.swift @@ -109,26 +109,6 @@ final class StringEscapingTests: XCTestCase { XCTAssertTrue(script.contains("assume -c 'test-profile'")) } - func testAppleScriptTemplate_Ghostty() { - let profileName = "test-profile" - let script = AppleScriptTemplates.ghostty(profileName: profileName) - - XCTAssertTrue(script.contains("com.mitchellh.ghostty")) - XCTAssertTrue(script.contains("keystroke \"n\" using command down")) - XCTAssertTrue(script.contains("assume 'test-profile'")) - XCTAssertTrue(script.contains("keystroke return")) - } - - func testAppleScriptTemplate_GhosttyConsole() { - let profileName = "test-profile" - let script = AppleScriptTemplates.ghosttyConsole(profileName: profileName) - - XCTAssertTrue(script.contains("com.mitchellh.ghostty")) - XCTAssertTrue(script.contains("keystroke \"n\" using command down")) - XCTAssertTrue(script.contains("assume -c 'test-profile'")) - XCTAssertTrue(script.contains("keystroke return")) - } - // MARK: - Shell Escaping Tests func testShellEscaping_SimpleString() { diff --git a/tests/TerminalServiceTests.swift b/tests/TerminalServiceTests.swift index 5343689..539e055 100644 --- a/tests/TerminalServiceTests.swift +++ b/tests/TerminalServiceTests.swift @@ -31,8 +31,8 @@ final class TerminalServiceTests: XCTestCase { XCTAssertTrue(TerminalService.Terminal.warp.requiresSystemEventsAccess) } - func testRequiresSystemEventsAccess_Ghostty_IsTrue() { - XCTAssertTrue(TerminalService.Terminal.ghostty.requiresSystemEventsAccess) + func testRequiresSystemEventsAccess_Ghostty_IsFalse() { + XCTAssertFalse(TerminalService.Terminal.ghostty.requiresSystemEventsAccess) } // MARK: - requiresAccessibilityPermissions @@ -52,9 +52,9 @@ final class TerminalServiceTests: XCTestCase { XCTAssertTrue(service.requiresAccessibilityPermissions(.warp)) } - func testRequiresAccessibilityPermissions_Ghostty_IsTrue() { + func testRequiresAccessibilityPermissions_Ghostty_IsFalse() { let service = TerminalService() - XCTAssertTrue(service.requiresAccessibilityPermissions(.ghostty)) + XCTAssertFalse(service.requiresAccessibilityPermissions(.ghostty)) } // MARK: - Warp and Ghostty AppleScript templates use System Events @@ -68,12 +68,6 @@ final class TerminalServiceTests: XCTestCase { XCTAssertTrue(script.contains("keystroke")) } - func testGhosttyScript_UsesSystemEvents() { - let script = AppleScriptTemplates.ghostty(profileName: "test") - XCTAssertTrue(script.contains("tell application \"System Events\"")) - XCTAssertTrue(script.contains("keystroke")) - } - // MARK: - Terminal.app and iTerm2 do NOT use System Events func testTerminalAppScript_DoesNotUseSystemEvents() { From 6f4ba0a8c0d2dce5b744e55d866ce20b176ecd80 Mon Sep 17 00:00:00 2001 From: Matt Clarke Date: Sun, 8 Mar 2026 14:38:24 -0400 Subject: [PATCH 4/6] feat: use AXIsProcessTrustedWithOptions prompt for Warp permissions Co-Authored-By: Claude Opus 4.6 --- switchboard/Services/TerminalService.swift | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/switchboard/Services/TerminalService.swift b/switchboard/Services/TerminalService.swift index 87344c5..086bd87 100644 --- a/switchboard/Services/TerminalService.swift +++ b/switchboard/Services/TerminalService.swift @@ -87,8 +87,13 @@ class TerminalService { // MARK: - Public Methods /// Check if the app has Accessibility permissions + /// - Parameter prompt: If true, shows the system dialog to request permissions /// - Returns: True if Accessibility permissions are granted - func hasAccessibilityPermissions() -> Bool { + func hasAccessibilityPermissions(prompt: Bool = false) -> Bool { + if prompt { + let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary + return AXIsProcessTrustedWithOptions(options) + } return AXIsProcessTrusted() } @@ -99,10 +104,15 @@ class TerminalService { return terminal.requiresSystemEventsAccess } - /// Open System Settings to the Accessibility privacy pane + /// Request Accessibility permissions via system prompt, with fallback to System Settings func openAccessibilitySettings() { - let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")! - NSWorkspace.shared.open(url) + // Try the system prompt first + let granted = hasAccessibilityPermissions(prompt: true) + if !granted { + // Also open System Settings as fallback + let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")! + NSWorkspace.shared.open(url) + } } /// Detect all installed terminal applications From 4559a04fbf4248788d73f06fdac7ebb2dd8f605a Mon Sep 17 00:00:00 2001 From: Matt Clarke Date: Sun, 8 Mar 2026 14:39:40 -0400 Subject: [PATCH 5/6] feat: trigger accessibility prompt on first Warp launch attempt Co-Authored-By: Claude Opus 4.6 --- switchboard/ViewModels/ProfileViewModel.swift | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/switchboard/ViewModels/ProfileViewModel.swift b/switchboard/ViewModels/ProfileViewModel.swift index f60d169..dcfabf2 100644 --- a/switchboard/ViewModels/ProfileViewModel.swift +++ b/switchboard/ViewModels/ProfileViewModel.swift @@ -191,10 +191,15 @@ class ProfileViewModel: ObservableObject { /// Check accessibility permissions and update warning status func checkAccessibilityPermissions() { - hasAccessibilityPermissions = terminalService.hasAccessibilityPermissions() - - // Show warning if selected terminal needs Accessibility permissions but they're not granted let needsPermissions = terminalService.requiresAccessibilityPermissions(selectedTerminal) + + if needsPermissions { + // Use prompt: true to trigger the system dialog on first check + hasAccessibilityPermissions = terminalService.hasAccessibilityPermissions(prompt: true) + } else { + hasAccessibilityPermissions = true + } + showAccessibilityWarning = needsPermissions && !hasAccessibilityPermissions // Auto-clear error message if permissions are now granted or if terminal doesn't need them From 15dc99e91a147291ca7b64a4fb92abd46adda838 Mon Sep 17 00:00:00 2001 From: Matt Clarke Date: Sun, 8 Mar 2026 14:40:38 -0400 Subject: [PATCH 6/6] fix(core): rewrite ghostty and warp accessibility --- ...-03-08-accessibility-permissions-design.md | 41 ++ ...26-03-08-accessibility-permissions-plan.md | 385 ++++++++++++++++++ 2 files changed, 426 insertions(+) create mode 100644 docs/plans/2026-03-08-accessibility-permissions-design.md create mode 100644 docs/plans/2026-03-08-accessibility-permissions-plan.md diff --git a/docs/plans/2026-03-08-accessibility-permissions-design.md b/docs/plans/2026-03-08-accessibility-permissions-design.md new file mode 100644 index 0000000..d283af1 --- /dev/null +++ b/docs/plans/2026-03-08-accessibility-permissions-design.md @@ -0,0 +1,41 @@ +# Accessibility Permission Fixes for Ghostty and Warp + +## Problem + +Ghostty and Warp terminals use System Events keystrokes via AppleScript, which requires Accessibility permissions. Terminal.app and iTerm2 work fine because they have native AppleScript dictionaries. The Accessibility requirement is bad UX: it resets on debug rebuilds, requires manual System Settings navigation, and users are wary of granting it. + +## Design + +### Ghostty: CLI-based launching + +Replace the System Events AppleScript with a `Process`-based launch using Ghostty's `-e` flag: + +``` +ghostty -e bash -c 'assume '\''profile'\''; exec bash' +``` + +Changes: +- Remove `ghostty()` and `ghosttyConsole()` from `AppleScriptTemplates` +- Add a `launchViaProcess` path in `TerminalService` for Ghostty +- Set `requiresSystemEventsAccess = false` for Ghostty +- Warning banner automatically stops showing for Ghostty users + +### Warp: `AXIsProcessTrustedWithOptions` prompt + +Replace the passive "go to System Settings" flow with an active system prompt: + +- Call `AXIsProcessTrustedWithOptions` with `kAXTrustedCheckOptionPrompt: true` to trigger the native macOS permission dialog +- Trigger proactively in `checkAccessibilityPermissions()` when Warp is selected and permissions aren't granted (fires on first launch attempt) +- Existing warning banner remains as fallback if user dismisses the dialog + +### Unchanged + +- Terminal.app and iTerm2 (native AppleScript, no permissions needed) +- Warning banner UI (stays as Warp fallback) +- Error handling patterns (`TerminalLaunchError` types) + +### Testing + +- Update escaping tests if shell escaping differs from AppleScript escaping +- Update `TerminalServiceTests` for new Ghostty path +- Manual: Ghostty launches without accessibility, Warp shows system dialog diff --git a/docs/plans/2026-03-08-accessibility-permissions-plan.md b/docs/plans/2026-03-08-accessibility-permissions-plan.md new file mode 100644 index 0000000..edcb541 --- /dev/null +++ b/docs/plans/2026-03-08-accessibility-permissions-plan.md @@ -0,0 +1,385 @@ +# Accessibility Permission Fixes Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Eliminate Accessibility permission requirement for Ghostty (CLI launch) and improve permission UX for Warp (`AXIsProcessTrustedWithOptions` prompt). + +**Architecture:** Ghostty switches from AppleScript System Events keystrokes to `Process`-based CLI launch via `ghostty -e`. Warp keeps AppleScript but replaces passive "go to Settings" with the native macOS accessibility prompt dialog. Shell escaping added for Ghostty's `Process` path. + +**Tech Stack:** Swift, AppKit, ApplicationServices (`AXIsProcessTrustedWithOptions`), `Process` (Foundation) + +--- + +### Task 1: Add shell escaping for Ghostty CLI launch + +**Files:** +- Modify: `switchboard/Utilities/AppleScriptTemplates.swift:176-184` (add `escapedForShell` extension) +- Test: `tests/StringEscapingTests.swift` + +**Step 1: Write the failing test** + +Add to `tests/StringEscapingTests.swift`: + +```swift +// MARK: - Shell Escaping Tests + +func testShellEscaping_SimpleString() { + let input = "my-profile" + XCTAssertEqual(input.escapedForShell, "my-profile") +} + +func testShellEscaping_SingleQuote() { + let input = "it's-my-profile" + XCTAssertEqual(input.escapedForShell, "it'\\''s-my-profile") +} + +func testShellEscaping_MultipleQuotes() { + let input = "it's a 'quoted' profile" + XCTAssertEqual(input.escapedForShell, "it'\\''s a '\\''quoted'\\'' profile") +} + +func testShellEscaping_Backslash() { + let input = "path\\to\\profile" + XCTAssertEqual(input.escapedForShell, "path\\to\\profile") +} + +func testShellEscaping_EmptyString() { + let input = "" + XCTAssertEqual(input.escapedForShell, "") +} + +func testShellEscaping_SpecialCharacters() { + let input = "profile-with_special.chars@123" + XCTAssertEqual(input.escapedForShell, "profile-with_special.chars@123") +} +``` + +**Step 2: Run test to verify it fails** + +Run: `swift test --filter StringEscapingTests.testShellEscaping_SimpleString` +Expected: FAIL — `escapedForShell` does not exist + +**Step 3: Write minimal implementation** + +Add to `switchboard/Utilities/AppleScriptTemplates.swift` in the `String` extension: + +```swift +/// Escape string for use in single-quoted shell arguments +/// Uses the '\'' pattern to break out of single quotes safely +var escapedForShell: String { + self.replacingOccurrences(of: "'", with: "'\\''") +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `swift test --filter StringEscapingTests` +Expected: All PASS + +**Step 5: Commit** + +```bash +git add switchboard/Utilities/AppleScriptTemplates.swift tests/StringEscapingTests.swift +git commit -m "feat: add shell escaping for Ghostty CLI launch" +``` + +--- + +### Task 2: Add Ghostty CLI launch command builder + +**Files:** +- Modify: `switchboard/Utilities/AppleScriptTemplates.swift` (add `GhosttyCommands` enum) +- Test: `tests/StringEscapingTests.swift` + +**Step 1: Write the failing tests** + +Add to `tests/StringEscapingTests.swift`, replacing the existing Ghostty template tests: + +```swift +// MARK: - Ghostty CLI Command Tests + +func testGhosttyCommand_Assume() { + let args = GhosttyCommands.ghosttyArgs(profileName: "test-profile", forConsole: false) + XCTAssertEqual(args, ["-e", "bash", "-c", "assume 'test-profile'; exec bash"]) +} + +func testGhosttyCommand_AssumeConsole() { + let args = GhosttyCommands.ghosttyArgs(profileName: "test-profile", forConsole: true) + XCTAssertEqual(args, ["-e", "bash", "-c", "assume -c 'test-profile'; exec bash"]) +} + +func testGhosttyCommand_WithQuotesInProfile() { + let args = GhosttyCommands.ghosttyArgs(profileName: "it's-my-profile", forConsole: false) + XCTAssertEqual(args, ["-e", "bash", "-c", "assume 'it'\\''s-my-profile'; exec bash"]) +} +``` + +**Step 2: Run test to verify it fails** + +Run: `swift test --filter StringEscapingTests.testGhosttyCommand_Assume` +Expected: FAIL — `GhosttyCommands` does not exist + +**Step 3: Write minimal implementation** + +Add to `switchboard/Utilities/AppleScriptTemplates.swift`: + +```swift +// MARK: - Ghostty CLI Commands + +/// Command arguments for launching Ghostty via CLI (no AppleScript/Accessibility needed) +enum GhosttyCommands { + + /// Build arguments for `ghostty` CLI to run assume command + /// - Parameters: + /// - profileName: AWS profile name to assume + /// - forConsole: Whether to use assume -c (console mode) + /// - Returns: Array of arguments to pass to the ghostty process + static func ghosttyArgs(profileName: String, forConsole: Bool) -> [String] { + let escapedProfile = profileName.escapedForShell + let assumeCmd = forConsole ? "assume -c" : "assume" + return ["-e", "bash", "-c", "\(assumeCmd) '\(escapedProfile)'; exec bash"] + } +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `swift test --filter StringEscapingTests` +Expected: All PASS + +**Step 5: Commit** + +```bash +git add switchboard/Utilities/AppleScriptTemplates.swift tests/StringEscapingTests.swift +git commit -m "feat: add GhosttyCommands for CLI-based launching" +``` + +--- + +### Task 3: Switch Ghostty launching from AppleScript to Process + +**Files:** +- Modify: `switchboard/Services/TerminalService.swift:79-84,143-163` +- Modify: `switchboard/Utilities/AppleScriptTemplates.swift` (remove Ghostty AppleScript templates) +- Test: `tests/TerminalServiceTests.swift` + +**Step 1: Update tests — Ghostty no longer requires System Events** + +In `tests/TerminalServiceTests.swift`: + +- Change `testRequiresSystemEventsAccess_Ghostty_IsTrue` to assert **false** +- Change `testRequiresAccessibilityPermissions_Ghostty_IsTrue` to assert **false** +- Remove `testGhosttyScript_UsesSystemEvents` (no more AppleScript for Ghostty) + +Updated tests: + +```swift +func testRequiresSystemEventsAccess_Ghostty_IsFalse() { + XCTAssertFalse(TerminalService.Terminal.ghostty.requiresSystemEventsAccess) +} + +func testRequiresAccessibilityPermissions_Ghostty_IsFalse() { + let service = TerminalService() + XCTAssertFalse(service.requiresAccessibilityPermissions(.ghostty)) +} +``` + +Also remove `testAppleScriptTemplate_Ghostty` and `testAppleScriptTemplate_GhosttyConsole` from `tests/StringEscapingTests.swift` (replaced by `testGhosttyCommand_*` tests from Task 2). + +**Step 2: Run tests to verify they fail** + +Run: `swift test --filter TerminalServiceTests` +Expected: FAIL — Ghostty still returns `true` for `requiresSystemEventsAccess` + +**Step 3: Update TerminalService** + +In `switchboard/Services/TerminalService.swift`: + +a) Change `requiresSystemEventsAccess` for Ghostty to `false`: + +```swift +var requiresSystemEventsAccess: Bool { + switch self { + case .terminal, .iTerm2, .ghostty: return false + case .warp: return true + } +} +``` + +b) Add `launchViaProcess` method and update `launchViaAppleScript` to route Ghostty to it: + +```swift +/// Launch Ghostty using CLI Process (no Accessibility permissions needed) +private func launchViaProcess(terminal: Terminal, profileName: String, forConsole: Bool) throws { + let ghosttyURL: URL + if let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: terminal.bundleIdentifier) { + ghosttyURL = appURL.appendingPathComponent("Contents/MacOS/ghostty") + } else { + throw TerminalLaunchError.terminalNotInstalled(name: terminal.displayName) + } + + let args = GhosttyCommands.ghosttyArgs(profileName: profileName, forConsole: forConsole) + + let process = Process() + process.executableURL = ghosttyURL + process.arguments = args + + do { + try process.run() + } catch { + throw TerminalLaunchError.launchFailed(terminal: terminal.displayName, reason: error.localizedDescription) + } +} +``` + +c) Update `launchViaAppleScript` to route Ghostty to the new method. Replace the Ghostty case in the switch and add routing at the top: + +```swift +private func launchViaAppleScript(terminal: Terminal, profileName: String, forConsole: Bool) throws { + // Ghostty uses CLI-based launching (no Accessibility permissions needed) + if terminal == .ghostty { + try launchViaProcess(terminal: terminal, profileName: profileName, forConsole: forConsole) + return + } + + let script: String + // ... rest of existing switch (remove .ghostty case) +``` + +d) Remove the `.ghostty` case from the switch statement in `launchViaAppleScript`. + +**Step 4: Remove Ghostty AppleScript templates** + +Delete `ghostty()` and `ghosttyConsole()` from `switchboard/Utilities/AppleScriptTemplates.swift`. + +**Step 5: Run tests to verify they pass** + +Run: `swift test` +Expected: All PASS + +**Step 6: Commit** + +```bash +git add switchboard/Services/TerminalService.swift switchboard/Utilities/AppleScriptTemplates.swift tests/TerminalServiceTests.swift tests/StringEscapingTests.swift +git commit -m "feat: switch Ghostty to CLI-based launching, no accessibility needed" +``` + +--- + +### Task 4: Add `AXIsProcessTrustedWithOptions` prompt for Warp + +**Files:** +- Modify: `switchboard/Services/TerminalService.swift:91-93,103-106` + +**Step 1: Update `hasAccessibilityPermissions` to accept a prompt parameter** + +Replace the existing method and add a prompting variant: + +```swift +/// Check if the app has Accessibility permissions +/// - Parameter prompt: If true, shows the system dialog to request permissions +/// - Returns: True if Accessibility permissions are granted +func hasAccessibilityPermissions(prompt: Bool = false) -> Bool { + if prompt { + let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary + return AXIsProcessTrustedWithOptions(options) + } + return AXIsProcessTrusted() +} +``` + +**Step 2: Update `openAccessibilitySettings` to use the prompt** + +```swift +/// Request Accessibility permissions via system prompt, with fallback to System Settings +func openAccessibilitySettings() { + // Try the system prompt first + let granted = hasAccessibilityPermissions(prompt: true) + if !granted { + // Also open System Settings as fallback + let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")! + NSWorkspace.shared.open(url) + } +} +``` + +**Step 3: Run tests to verify nothing broke** + +Run: `swift test` +Expected: All PASS + +**Step 4: Commit** + +```bash +git add switchboard/Services/TerminalService.swift +git commit -m "feat: use AXIsProcessTrustedWithOptions prompt for Warp permissions" +``` + +--- + +### Task 5: Trigger accessibility prompt on first Warp launch attempt + +**Files:** +- Modify: `switchboard/ViewModels/ProfileViewModel.swift:193-209` + +**Step 1: Update `checkAccessibilityPermissions` to prompt when Warp is selected** + +```swift +/// Check accessibility permissions and update warning status +func checkAccessibilityPermissions() { + let needsPermissions = terminalService.requiresAccessibilityPermissions(selectedTerminal) + + if needsPermissions { + // Use prompt: true to trigger the system dialog on first check + hasAccessibilityPermissions = terminalService.hasAccessibilityPermissions(prompt: true) + } else { + hasAccessibilityPermissions = true + } + + showAccessibilityWarning = needsPermissions && !hasAccessibilityPermissions + + // Auto-clear error message if permissions are now granted or if terminal doesn't need them + if let error = errorMessage, + (error.contains("Accessibility permissions") || error.contains("accessibility")) { + if hasAccessibilityPermissions || !needsPermissions { + errorMessage = nil + } + } + + logger.info("Accessibility check: hasPermissions=\(self.hasAccessibilityPermissions), needsPermissions=\(needsPermissions), showWarning=\(self.showAccessibilityWarning)") +} +``` + +**Step 2: Run tests to verify nothing broke** + +Run: `swift test` +Expected: All PASS + +**Step 3: Commit** + +```bash +git add switchboard/ViewModels/ProfileViewModel.swift +git commit -m "feat: trigger accessibility prompt on first Warp launch attempt" +``` + +--- + +### Task 6: Final verification + +**Step 1: Run full test suite** + +Run: `swift test` +Expected: All PASS + +**Step 2: Build release** + +Run: `swift build -c release` +Expected: Build succeeds + +**Step 3: Manual testing checklist** + +- [ ] Select Ghostty as terminal — no accessibility warning banner +- [ ] Click "Open in Terminal" with Ghostty — launches without permission error +- [ ] Select Warp as terminal — system accessibility dialog appears +- [ ] Dismiss dialog — warning banner shows as fallback +- [ ] Terminal.app and iTerm2 still work as before