COORD: 44.21.90
OFFSET: +12.5°
SYS.READY
BUFFER: 99%
FOCAL_PT
BACK TO DEVLOG
GHOSTTY-CLAUDDY

Ghostty Clauddy — macOS App + Hammerspoon Hotkey

Had a \"Claude Code\" mock app somewhere in `~/Applications` — an AppleScript-based wrapper that launches Ghostty with a custom config. Wanted to:

2025-01-07 // RAW LEARNING CAPTURE
PROJECTGHOSTTY-CLAUDDY

Starting Point

Had a "Claude Code" mock app somewhere in ~/Applications — an AppleScript-based wrapper that launches Ghostty with a custom config. Wanted to:

  1. Move it into ~/Code so it's easy to find and modify
  2. Add a global hotkey that opens a new tab AND starts claude

The app lived at /Users/joel/Applications/Claude Code.app — a shell script launcher disguised as a macOS app.

Discovering the Existing App Structure

Found it via:

find /Users/joel/Applications -maxdepth 2 -name "*Claude*" -o -name "*claude*"
# /Users/joel/Applications/Claude Code.app
# /Users/joel/Applications/Claude Code Browser.app

The launcher was simple — just exec's Ghostty with a custom config:

#!/bin/bash
exec /Applications/Ghostty.app/Contents/MacOS/ghostty \
    --config-file=/Users/joel/.config/ghostty/config-claude-code

The Ghostty config at ~/.config/ghostty/config-claude-code had:

  • Catppuccin Mocha theme (distinct from main terminal's Gruvbox Dark)
  • Title set to "Claude Code"
  • Working directory: /Users/joel/Code
  • Window state: never restore (single session per launch)

Creating the Project Structure

Set up ~/Code/claude-code-app/ (later renamed) with:

ghostty-clauddy/
├── src/
│   ├── launcher.sh              # Main launcher script
│   └── config-claude-code       # Ghostty config
├── resources/
│   └── AppIcon.icns             # Custom icon (~1.4MB)
├── build/
│   └── Claude Code.app/         # Built app bundle
├── build.sh                     # Build + install script
└── CLAUDE.md

The build.sh does:

  1. Creates .app bundle structure in build/
  2. Copies launcher, icon, generates Info.plist
  3. Symlinks Ghostty config to ~/.config/ghostty/config-claude-code
  4. Symlinks the built app to ~/Applications/Claude Code.app

Key insight: symlinks mean edits take effect immediately without rebuilding.

Investigating Ghostty Scripting for New Tab + Command

Wanted: hotkey that opens new tab AND runs claude. Checked Ghostty's capabilities:

/Applications/Ghostty.app/Contents/MacOS/ghostty --help
# Shows +new-window action, but...

/Applications/Ghostty.app/Contents/MacOS/ghostty +new-window --help
# "Only supported on GTK" — macOS doesn't have IPC for this

Checked for AppleScript support:

osascript -e 'tell application "System Events" to get properties of first application process whose name is "Ghostty"'
# has scripting terminology:false

No native scripting. The new_tab action exists but can't be triggered externally on macOS, and there's no way to chain "new tab + run command" in a keybind.

Web search confirmed: Ghostty's command config applies to ALL tabs globally, not specific keybinds. No way to do "new tab with specific command" natively.

Hammerspoon Approach

Since Ghostty can't do it natively, use Hammerspoon for:

  1. Global hotkey detection
  2. Find/focus the Claude Code window
  3. Send Cmd+T keystroke
  4. Type "claude" + Enter

First attempt — looked for app by name:

local claudeCode = hs.application.get("Claude Code")
if claudeCode then
    claudeCode:activate()

This errored:

attempt to call a nil value (method 'activate')

The problem: "Claude Code.app" just execs into Ghostty. There's no separate process — the launcher script becomes a Ghostty process immediately. So hs.application.get("Claude Code") returns nil (or something weird).

The Fix: Find Ghostty Window by Title

Since Claude Code uses title = Claude Code in its Ghostty config, we can find it by window title:

hs.hotkey.bind({"cmd", "shift"}, "C", function()
    local ghostty = hs.application.get("Ghostty")
    local claudeWindow = nil

    if ghostty then
        for _, win in ipairs(ghostty:allWindows()) do
            if win:title():find("Claude Code") then
                claudeWindow = win
                break
            end
        end
    end

    if claudeWindow then
        claudeWindow:focus()
        hs.timer.doAfter(0.1, function()
            hs.eventtap.keyStroke({"cmd"}, "T")
            hs.timer.doAfter(0.5, function()
                hs.eventtap.keyStrokes("claude")
                hs.eventtap.keyStroke({}, "return")
            end)
        end)
    else
        hs.task.new("/usr/bin/open", function()
            hs.timer.doAfter(1.5, function()
                hs.eventtap.keyStrokes("claude")
                hs.eventtap.keyStroke({}, "return")
            end)
        end, {"-a", "Claude Code"}):start()
    end
end)

Timing Issues

First test with shorter delays typed partial text:

~/Code
❯ de
zsh: command not found: de

The shell wasn't ready when Hammerspoon started typing. Bumped delays:

  • New tab: 0.2s → 0.5s (shell needs to initialize)
  • Cold start: 1s → 1.5s (app + shell initialization)

No way to detect shell readiness without parsing output, so fixed delays are the pragmatic solution.

Side Quest: Claude Code 2.1.0 Broken

During testing, Claude Code auto-updated to 2.1.0 which was broken. Existing sessions were fine (running 2.0.75) but new ones failed.

Found the version structure:

ls -la ~/.local/share/claude/versions/
# 2.0.65, 2.0.71-2.0.76, 2.1.0

ls -la ~/.local/bin/claude
# symlink -> /Users/joel/.local/share/claude/versions/2.1.0

Reverted:

ln -sf /Users/joel/.local/share/claude/versions/2.0.75 /Users/joel/.local/bin/claude

Disabled auto-update by adding to ~/.claude/settings.json:

{
  "env": {
    "DISABLE_AUTOUPDATER": "1"
  },
  ...
}

Manual update when ready: claude update

Where We Landed

Project: ~/Code/ghostty-clauddy/

Global hotkey: Cmd+Shift+C anywhere:

  • If Claude Code window exists → focus it, new tab, run claude
  • If not running → launch app, wait for shell, run claude

Overhead: ~0.3-0.5s on top of claude's own 1-2s startup. Acceptable.

Future idea discussed but not implemented: Pre-warming tabs. Keep 1-2 tabs with claude already loaded, hotkey cycles to next ready one, background spawns replacement. Would need state tracking in Hammerspoon since Ghostty doesn't expose tab state.

Takeaways

  • macOS .app bundles using exec lose their identity — the process becomes whatever they exec into. Find windows by title instead of app name.
  • Ghostty has no IPC on macOS (+new-window is GTK-only). Keystroke simulation via Hammerspoon is the workaround.
  • Shell initialization takes real time. 0.5s is safe for new tabs, 1.5s for cold starts.
  • Claude Code versions live in ~/.local/share/claude/versions/, symlinked from ~/.local/bin/claude. Easy to rollback.
  • DISABLE_AUTOUPDATER=1 in settings.json prevents auto-updates.
LOG.ENTRY_END
ref:ghostty-clauddy
RAW