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...
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
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
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
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\""
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\""
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
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\""
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
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\""]
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
}
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)\""
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
Others need npm:
# Gemini CLI
npm install -g @google/gemini-cli
# Codex
npm install -g @openai/codex
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:
- Check if
npmexists -> if yes, runnpm install -g @google/gemini-cli - No npm? Check if
fnmexists -> if yes, runfnm install --lts && npm install -g @google/gemini-cli - 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
}
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
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)