DEV Community

JackWu
JackWu

Posted on

Automating 4 macOS Terminals for Claude Code: AppleScript, Ghostty's `-e` Trap, and Warp's Missing API

I launch Claude Code 30+ times a day. The workflow always looks like this:

cd ~/Projects/my-app
claude "fix the login bug"

# switch project
cd ~/Projects/api-server
claude "add rate limiting"

# switch again...
Enter fullscreen mode Exit fullscreen mode

Four steps every time. Open terminal, cd, type command, wait. Multiply by 30 and you've burned 600 keystrokes on navigation alone.

I wanted a single hotkey that takes a prompt and launches Claude Code in the right directory. So I built GroAsk -- a macOS menu bar launcher. Press Option+Space, type your prompt, hit Enter. Terminal opens, cd's to the project, Claude Code starts with your prompt already loaded.

The hard part wasn't the launcher UI. It was making it work across Terminal.app, iTerm2, Ghostty, and Warp -- because they all automate differently, and some barely automate at all.

This post covers the technical implementation of each terminal integration, the PATH detection problem in GUI apps, and the one-click CLI installer with dependency chain resolution.


The Problem: 4 Terminals, 4 Different Automation Models

Most Claude Code tools assume Terminal.app. But developers use different terminals. iTerm2 is the most popular third-party option. Ghostty (by Mitchell Hashimoto) is gaining traction fast. Warp has a dedicated following.

Supporting "just Terminal.app" means excluding a large chunk of your users. So I implemented all four.

Here's how they differ:

Terminal Automation Method Quirks
Terminal.app AppleScript do script Full scripting dictionary, straightforward
iTerm2 AppleScript write text Full scripting dictionary, different commands
Ghostty Clipboard paste via System Events No AppleScript dictionary, requires Accessibility permission
Warp Clipboard paste via System Events No programmatic API at all, requires Accessibility permission

Let's walk through each.


Terminal.app: The Baseline

Terminal.app has a complete AppleScript dictionary. You can create windows, run commands, and control tabs programmatically.

tell application "Terminal"
    activate
    do script "cd ~/Projects/my-app && claude \"fix the login bug\""
end tell
Enter fullscreen mode Exit fullscreen mode

One catch: if Terminal.app isn't running yet, there's no window to target. You need to wait for the initial window to appear:

set wasRunning to application "Terminal" is running
tell application "Terminal"
    activate
    if wasRunning then
        do script "cd ~/Projects/my-app && claude \"fix the bug\""
    else
        -- Wait for the default window to spawn
        repeat 50 times
            if (count of windows) > 0 then exit repeat
            delay 0.1
        end repeat
        if (count of windows) > 0 then
            do script "cd ~/Projects/my-app && claude \"fix the bug\"" in front window
        else
            do script "cd ~/Projects/my-app && claude \"fix the bug\""
        end if
    end if
end tell
Enter fullscreen mode Exit fullscreen mode

This avoids opening a second tab when Terminal launches for the first time -- the initial blank window already exists, so we execute into it.

I also added an osascript subprocess fallback. NSAppleScript can fail silently due to TCC permission issues on newer macOS versions, but spawning /usr/bin/osascript as a child process sometimes bypasses the restriction.


iTerm2: Similar But Different Dictionary

iTerm2 also has a full AppleScript dictionary, but the commands are different. Instead of do script, you use create window with default profile and write text:

tell application id "com.googlecode.iterm2"
    activate
    create window with default profile
    tell current session of current window
        write text "cd ~/Projects/my-app && claude \"fix the bug\""
    end tell
end tell
Enter fullscreen mode Exit fullscreen mode

Note the use of bundle ID (com.googlecode.iterm2) instead of the app name. Some users have the app named "iTerm" and others "iTerm2" -- the bundle ID is stable across installations.

The write text approach has a benefit: the shell stays alive after the command runs. With Terminal.app's do script, the behavior is similar, but write text is more predictable for interactive tools like Claude Code that keep running.


Ghostty: The Clipboard Paste Workaround

Ghostty is where things get interesting. It's a relatively new terminal by Mitchell Hashimoto (of HashiCorp fame). It has no AppleScript dictionary.

Ghostty does support a -e flag for executing commands:

open -na Ghostty --args -e "claude \"fix the bug\""
Enter fullscreen mode Exit fullscreen mode

But -e replaces the shell. It doesn't load .zshrc or .bashrc, which means your PATH, aliases, and version manager initialization (nvm, fnm, etc.) are all missing. Claude Code itself likely won't be found.

You can work around this by wrapping in a login shell:

open -na Ghostty --args -e bash -l -c "cd ~/Projects/my-app && claude \"fix the bug\""
Enter fullscreen mode Exit fullscreen mode

But in practice, this still had edge cases -- particularly with how Ghostty handles argument quoting and process lifecycle.

The approach I settled on: clipboard paste via System Events. It requires macOS Accessibility permission, but it's reliable:

-- First, put the command in the clipboard (done in Swift before this script runs)
set wasRunning to application id "com.mitchellh.ghostty" is running
tell application id "com.mitchellh.ghostty" to activate
tell application "System Events"
    tell (first process whose bundle identifier is "com.mitchellh.ghostty")
        if wasRunning then
            delay 0.3
            keystroke "n" using command down  -- new window
            delay 0.5
        else
            delay 1.5  -- wait for first launch
        end if
        keystroke "v" using command down  -- paste from clipboard
        delay 0.1
        keystroke return
    end tell
end tell
Enter fullscreen mode Exit fullscreen mode

The Swift side saves the original clipboard contents, writes the command, executes the AppleScript, then restores the clipboard after a 3-second delay (enough time for the terminal to process the paste).

Why clipboard paste instead of keystroke character-by-character? Because keystroke goes through macOS's input method system. If the user has a CJK input method active, the command gets garbled. Cmd+V bypasses input methods entirely.


Warp: No API, Period

Warp is a Rust-based modern terminal. It has no AppleScript dictionary, no -e flag equivalent, and no documented programmatic API.

I initially tried its Launch Configuration feature -- YAML files that define startup commands, triggered via URI scheme:

# ~/.warp/launch_configurations/groask.yaml
---
name: groask
commands:
  - exec: "cd ~/Projects/my-app && claude \"fix the bug\""
Enter fullscreen mode Exit fullscreen mode

Then trigger with warp://action/launch?config=groask. This works, but writing a temp file for every launch felt fragile, and the YAML parsing had quoting issues with complex prompts.

The final implementation uses the same clipboard paste approach as Ghostty, but opens a new tab (Cmd+T) instead of a new window:

set wasRunning to application id "dev.warp.Warp-Stable" is running
tell application id "dev.warp.Warp-Stable" to activate
tell application "System Events"
    tell process "Warp"
        if wasRunning then
            delay 0.3
            keystroke "t" using command down  -- new tab
            delay 0.5
        else
            delay 1.5
        end if
        keystroke "v" using command down
        delay 0.1
        keystroke return
    end tell
end tell
Enter fullscreen mode Exit fullscreen mode

Both Ghostty and Warp require the user to grant Accessibility permission to GroAsk. On first attempt, the app detects AXIsProcessTrusted() == false and shows a dialog pointing the user to System Settings.


The PATH Problem in GUI Apps

Here's a problem most terminal-based tools don't face: a macOS GUI app launched from Finder or Spotlight doesn't inherit your shell's PATH.

When you open Terminal and type which claude, it finds ~/.local/bin/claude because your .zshrc added that directory to PATH. But a .app bundle launched via LaunchServices gets a minimal PATH -- typically just /usr/bin:/bin:/usr/sbin:/sbin.

This means when GroAsk tries to check if Claude Code is installed, which claude or command -v claude would fail even though the tool is perfectly usable from a terminal.

My solution has two layers:

Layer 1: Read the user's actual shell PATH

let marker = "__GROASK__"
let shell = ProcessInfo.processInfo.environment["SHELL"] ?? "/bin/zsh"
let process = Process()
process.executableURL = URL(fileURLWithPath: shell)
process.arguments = ["-i", "-l", "-c", "printf '\(marker)%s' \"$PATH\""]
Enter fullscreen mode Exit fullscreen mode

Launch a login shell subprocess and ask it what PATH looks like. The printf with a marker is critical -- tools like conda, pyenv, and some version managers print extra output during shell initialization. The marker lets me find where the actual PATH value starts and ignore everything before it.

Layer 2: File system scanning as fallback

private static func discoverPaths(home: String) -> [String] {
    var paths = [
        "\(home)/.local/bin",      // Claude Code, CodeBuddy, Kimi Code
        "\(home)/.npm-global/bin", // npm global prefix
        "\(home)/.volta/bin",      // Volta
        "\(home)/.cargo/bin",      // Rust / Cargo
        "\(home)/.bun/bin",        // Bun
        "\(home)/.mise/shims",     // mise
        "/opt/homebrew/bin",       // Homebrew (Apple Silicon)
        "/usr/local/bin",          // Homebrew (Intel) / system
    ]

    // Scan nvm versions: ~/.nvm/versions/node/v<version>/bin
    let nvmBase = "\(home)/.nvm/versions/node"
    if let versions = try? FileManager.default.contentsOfDirectory(atPath: nvmBase) {
        for v in versions where v.hasPrefix("v") {
            paths.append("\(nvmBase)/\(v)/bin")
        }
    }

    // Scan fnm versions (macOS and Linux paths)
    for fnmBase in [
        "\(home)/Library/Application Support/fnm/node-versions",
        "\(home)/.local/share/fnm/node-versions",
    ] {
        if let versions = try? FileManager.default.contentsOfDirectory(atPath: fnmBase) {
            for v in versions where v.hasPrefix("v") {
                paths.append("\(fnmBase)/\(v)/installation/bin")
            }
        }
    }

    return paths
}
Enter fullscreen mode Exit fullscreen mode

This scans known installation directories directly on the filesystem. No subprocess needed, no shell rc loaded. It catches tools installed via nvm, fnm, Volta, Homebrew, Cargo, mise, and the standard ~/.local/bin location.

The two layers merge: shell PATH takes priority (preserves user's ordering), filesystem scan fills in anything the shell missed.

Smart PATH injection

When launching a command in a new terminal window, that window loads the user's shell rc files. So if claude is in a directory that's already in the shell's PATH, we don't need to inject anything -- it'll just work.

But if we found claude via filesystem scanning in a directory not in the shell's PATH (e.g., an unusual nvm version), we prepend an export PATH= to the command:

let pathPrefix: String
if let dir = searchPaths.first(where: {
    FileManager.default.isExecutableFile(atPath: "\($0)/\(command)")
}), !shellNativePaths.contains(dir) {
    pathPrefix = "export PATH=\"\(dir):$PATH\" && "
} else {
    pathPrefix = ""
}
let fullCommand = "\(pathPrefix)cd \"\(safeDir)\" && \(command) \"\(cleanPrompt)\""
Enter fullscreen mode Exit fullscreen mode

This avoids polluting the terminal session with unnecessary PATH modifications while ensuring the command always resolves.


One-Click CLI Installation with Dependency Chains

The last piece: installing CLI tools without touching the terminal.

Some tools have simple curl installers:

# Claude Code
curl -fsSL https://claude.ai/install.sh | bash

# CodeBuddy
curl -fsSL https://copilot.tencent.com/cli/install.sh | bash
Enter fullscreen mode Exit fullscreen mode

Others need npm:

# Gemini CLI
npm install -g @google/gemini-cli

# Codex
npm install -g @openai/codex
Enter fullscreen mode Exit fullscreen mode

The problem: if the user doesn't have npm, they need Node.js. If they don't have Node.js, they need a version manager. That's three installations before you can even start.

GroAsk handles the full dependency chain:

  1. Check if npm exists -> if yes, run npm install -g @google/gemini-cli
  2. No npm? Check if fnm exists -> if yes, run fnm install --lts && npm install -g @google/gemini-cli
  3. No fnm either? Run the full chain: install fnm -> install Node.js -> install the CLI tool

All in one click. No sudo, no Homebrew, no Xcode Command Line Tools required.

After triggering the install, the app polls every 2 seconds using command -v through the user's login shell:

static func isCommandAvailableViaShell(_ command: String) -> Bool {
    guard command.allSatisfy({
        $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_"
    }) else { return false }  // prevent command injection

    let shell = ProcessInfo.processInfo.environment["SHELL"] ?? "/bin/zsh"
    let process = Process()
    process.executableURL = URL(fileURLWithPath: shell)
    process.arguments = ["-i", "-l", "-c", "command -v \(command)"]
    // ...
    return process.terminationStatus == 0
}
Enter fullscreen mode Exit fullscreen mode

The poll runs through a full login shell (-i -l) so it picks up PATH changes from the installation. 3-minute timeout, then it stops. As soon as the tool is detected, the UI updates from "installing" to "ready."


The Resulting Architecture

⌥Space pressed
    |
    v
Launcher UI (prompt input)
    |
    v
Router.dispatch(channel, prompt)
    |
    +--- Web AI? --> WebManager (Chrome AppleScript + JS injection)
    |
    +--- CLI AI? --> TerminalBridge.execute()
                        |
                        +-- Resolve working directory
                        |     (Finder path / alias / default)
                        |
                        +-- Find command binary
                        |     (shell PATH + filesystem scan)
                        |
                        +-- Build command string
                        |     (PATH prefix if needed)
                        |
                        +-- Dispatch to terminal
                              |
                              +-- Terminal.app: AppleScript do script
                              +-- iTerm2: AppleScript write text
                              +-- Ghostty: Clipboard paste + System Events
                              +-- Warp: Clipboard paste + System Events
Enter fullscreen mode Exit fullscreen mode

The whole app is 5,600 lines of Swift (pure AppKit, no SwiftUI App framework, no Electron) + 1,200 lines for the server (Cloudflare Workers for license validation). Memory usage stays under 30MB.


Numbers

  • 6 CLI AIs supported: Claude Code, Gemini CLI, Codex, CodeBuddy, Kimi Code, Qwen Code
  • 4 Web AIs: ChatGPT, Claude, Gemini, Monica
  • 4 terminal implementations
  • 190 commits, 49% co-authored with Claude Code
  • 14 days from first line of code to launch
  • Free and open source

Comparison with Existing Tools

Capability Claude Code Now Raycast Plugin GroAsk
Launch with prompt No No Yes -- prompt goes straight to the agent
Multi-terminal Terminal.app only Terminal/Alacritty/Ghostty/Warp Terminal/iTerm2/Ghostty/Warp
GUI install No No One-click with dependency resolution
Multi-AI Claude Code only Claude Code only 6 CLI + 4 Web AI
Text selection send No No Select text -> hotkey -> sent
PATH auto-detection No No Shell PATH + filesystem scan

Not knocking the existing tools -- they solve real problems. GroAsk just goes further: it's not only a Claude Code launcher, it's a bridge to all your AIs.


If you want to try it: groask.com

It's free, open source, and runs entirely on your machine. No data leaves your Mac -- GroAsk is a local bridge between your keyboard and whatever AI you're talking to.

If you find it useful, a star on GitHub helps more than you'd think for a solo dev project.

Top comments (0)