Skip to content

Instantly share code, notes, and snippets.

@xiaolai
Last active May 12, 2026 08:23
Show Gist options
  • Select an option

  • Save xiaolai/85694b4d5910ea8b947ff329da90b888 to your computer and use it in GitHub Desktop.

Select an option

Save xiaolai/85694b4d5910ea8b947ff329da90b888 to your computer and use it in GitHub Desktop.
cws — Claude Code workspace launcher for iTerm2 (AppleScript, zero dependencies)

cws — Claude Workspace Launcher

One command to open a Claude Code workspace in iTerm2.

cws ~/projects/myapp

What it does

Opens a new iTerm2 tab with Claude Code and a shell, side by side:

+---------------------+---------------------+
|                     |                     |
|    Claude Code      |    plain shell      |
|                     |                     |
+---------------------+---------------------+
          tab title: "myapp"
  • Left pane: Claude Code. Resumes your last session (claude --continue) if you've used Claude in that directory before, otherwise starts a fresh claude session.
  • Right pane: plain shell in the same directory.
  • Tab title: the project directory name — locked so neither Claude Code nor Oh-My-Zsh can override it.

If your project uses git worktrees, the layout adapts automatically:

+---------------------+---------------------+
|                     |  shell (worktree-1) |
|    Claude Code      +---------------------+
|                     |  shell (worktree-2) |
+---------------------+---------------------+
|                     |  shell (worktree-3) |
|    plain shell      |                     |
+---------------------+---------------------+

Up to 3 most recently modified worktrees appear on the right.

Requirements

No other dependencies. Uses AppleScript (built into macOS) to control iTerm2.

Setup

Step 1: Create the iTerm2 profile

Run this once to create the dynamic profile:

mkdir -p ~/Library/Application\ Support/iTerm2/DynamicProfiles && cat > ~/Library/Application\ Support/iTerm2/DynamicProfiles/cws.json <<'EOF'
{
  "Profiles": [
    {
      "Name": "cws",
      "Guid": "cws-workspace-profile",
      "Dynamic Profile Parent Name": "Default",
      "Title Components": 1,
      "Sync Title": false,
      "Custom Directory": "No"
    }
  ]
}
EOF

What this does: iTerm2 watches the DynamicProfiles folder and loads profiles automatically — no restart needed. The "cws" profile inherits everything from your Default profile but shows only the session name as the tab title. The cws function sets this to the project directory name.

Title Components bitmask: 1 = Session Name. Other values: 2 = Job, 4 = Working Directory (full), 128 = Short PWD, 256 = Git Branch, 512 = Job+Args (iTerm2 default). These can be combined (e.g., 257 = Session Name + Git Branch).

Why "Custom Directory": "No": it pins the profile's initial working directory to $HOME and stops iTerm2 from inheriting "Reuse previous session's directory" from Default. Without this, iTerm2 types its own cd into every new tab/split (and a bogus cd '' when no previous directory is tracked) before cws can run its own cd. With it, each pane gets exactly one cd — from cws.

Step 2: Add env vars to .zshrc

These prevent Claude Code and Oh-My-Zsh from overriding the tab title:

export DISABLE_AUTO_TITLE=true
export CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1
Variable What it blocks
DISABLE_AUTO_TITLE Oh-My-Zsh auto-title (sets title to current command)
CLAUDE_CODE_DISABLE_TERMINAL_TITLE Claude Code's built-in title setting

Step 3: Add the function

Add to your ~/.zshrc (or a sourced file like ~/.functions):

cws() {
  local dir="${1:-.}"
  dir="$(cd "$dir" 2>/dev/null && pwd -P)" || { echo "Invalid path: $1"; return 1; }
  local name="${dir##*/}"

  # Escape strings for safe embedding in AppleScript double-quoted literals
  _as_escape() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'; }
  # Wrap a string as a single zsh argument (handles apostrophes, spaces, etc.)
  _sh_quote()  { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; }

  local q_dir="$(_sh_quote "$dir")"
  local q_name="$(_sh_quote "$name")"

  # Determine which profile to use (fall back to Default if cws profile missing)
  local profile="cws"
  if [[ ! -f "$HOME/Library/Application Support/iTerm2/DynamicProfiles/cws.json" ]]; then
    if ! defaults read com.googlecode.iterm2 "New Bookmarks" 2>/dev/null | grep -q '"cws"'; then
      profile="Default"
    fi
  fi

  # Collect other worktrees (excluding current dir), sorted by most recent, max 3
  local worktrees=()
  if git -C "$dir" rev-parse --git-dir &>/dev/null; then
    while IFS= read -r wt; do
      [[ -n "$wt" ]] && worktrees+=("$wt")
    done < <(
      git -C "$dir" worktree list --porcelain 2>/dev/null \
        | grep '^worktree ' | sed 's/^worktree //' \
        | while read -r wt; do
            local real_wt="$(cd "$wt" 2>/dev/null && pwd -P)"
            [[ "$real_wt" == "$dir" ]] && continue
            echo "$(stat -f '%m' "$wt" 2>/dev/null || echo 0) $wt"
          done \
        | sort -rn | head -3 | sed 's/^[0-9]* //'
    )
  fi

  # Source workspace env if present
  local env_prefix=""
  [[ -f "$dir/.ws.env" ]] && env_prefix="source $(_sh_quote "$dir/.ws.env") && "

  # Set session name via escape sequence (Title Components: 1 shows this)
  # DISABLE_AUTO_TITLE stops OMZ from overriding it with the current command
  # CLAUDE_CODE_DISABLE_TERMINAL_TITLE stops Claude from overriding it
  local title_cmd="export DISABLE_AUTO_TITLE=true && printf '\\033]1;%s\\007' ${q_name}"
  local claude_cmd="${env_prefix}cd ${q_dir} && ${title_cmd} && (export CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1; claude --continue || claude)"
  local shell_cmd="${env_prefix}cd ${q_dir} && ${title_cmd}"

  # Build the AppleScript
  local script=""

  # Ensure a window exists
  script+='tell application "iTerm2"
  if (count of windows) = 0 then
    create window with profile "'"$profile"'"
  else
    tell current window
      create tab with profile "'"$profile"'"
    end tell
  end if
  tell current session of current tab of current window
    write text "'"$(_as_escape "$claude_cmd")"'"
'

  if [[ ${#worktrees[@]} -eq 0 ]]; then
    # No worktrees: Claude left, shell right
    script+='    tell (split vertically with profile "'"$profile"'")
      write text "'"$(_as_escape "$shell_cmd")"'"
    end tell
'
  else
    # Has worktrees: Claude + shell left, worktrees right
    local q_wt1="$(_sh_quote "${worktrees[1]}")"
    script+='    set wtSession to (split vertically with profile "'"$profile"'")
    tell wtSession
      write text "'"$(_as_escape "${env_prefix}cd ${q_wt1}")"'"
    end tell
    tell (split horizontally with profile "'"$profile"'")
      write text "'"$(_as_escape "$shell_cmd")"'"
    end tell
'
    # Additional worktrees: split wtSession horizontally
    for i in $(seq 2 ${#worktrees[@]}); do
      local q_wt="$(_sh_quote "${worktrees[$i]}")"
      script+='    tell wtSession
      tell (split horizontally with profile "'"$profile"'")
        write text "'"$(_as_escape "${env_prefix}cd ${q_wt}")"'"
      end tell
    end tell
'
    done
  fi

  script+='  end tell
end tell'

  osascript -e "$script"
}

Step 4: Reload and use

source ~/.zshrc    # or: source ~/.functions
cws ~/projects/myapp

Usage

cws ~/projects/myapp       # open workspace in a new tab
cws                        # use current directory

Per-workspace environment

Drop a .ws.env file in any project root:

# ~/projects/myapp/.ws.env
export NODE_ENV=development
export API_URL=http://localhost:3000

Every pane in that workspace sources it automatically.

How it works

AppleScript + iTerm2: the function builds an AppleScript that tells iTerm2 to create a tab, split panes, and send commands to each pane. No third-party tools involved.

Tab title: set via escape sequence \033]1;name\007 before Claude starts. Protected from override by three env vars:

Threat Blocked by
Oh-My-Zsh auto-title DISABLE_AUTO_TITLE=true
Claude Code title setting CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1
Other programs Title Components: 1 ignores window title escape sequences

claude --continue || claude: claude --continue resumes the last session for the current directory; if none exists, it exits non-zero with "No session to continue," and the shell falls through to plain claude to start fresh. No session detection logic needed.

Git worktree detection: uses git worktree list --porcelain to find worktrees, stat to sort by modification time, excludes the current directory, and caps at 3.

Path safety: paths cross two layers before reaching the shell. _sh_quote wraps each path as a single zsh argument (handling apostrophes like Writer's Office, spaces, and other shell metacharacters via the '\'' idiom), then _as_escape escapes backslashes and double quotes for the AppleScript double-quoted literal. Symlinks are resolved via pwd -P.

Profile fallback: if the "cws" iTerm2 profile doesn't exist (Step 1 was skipped), falls back to "Default". Tab titles will show the running command instead of the project name, but everything else works.

Dynamic Profiles: iTerm2 watches ~/Library/Application Support/iTerm2/DynamicProfiles/ and loads profiles from JSON files automatically. No restart needed, no plist conflicts.

Closing and reopening

Closing iTerm2 kills the panes. But cws recreates the layout instantly, and claude --continue resumes the conversation. Shell history is preserved by zsh. The only thing lost is running processes (dev servers, builds).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment