One command to open a Claude Code workspace in iTerm2.
cws ~/projects/myappOpens 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 freshclaudesession. - 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.
- macOS with iTerm2
- Claude Code CLI
No other dependencies. Uses AppleScript (built into macOS) to control iTerm2.
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"
}
]
}
EOFWhat 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.
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 |
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"
}source ~/.zshrc # or: source ~/.functions
cws ~/projects/myappcws ~/projects/myapp # open workspace in a new tab
cws # use current directoryDrop a .ws.env file in any project root:
# ~/projects/myapp/.ws.env
export NODE_ENV=development
export API_URL=http://localhost:3000Every pane in that workspace sources it automatically.
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 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).