← back to Iterm Project Grouper
iterm-project-grouper: claude-proj launcher (per-project windows) + iterm-map + API regroup tool
bd33534959bff181cd344a3a67d0be2dd2b907d2 · 2026-09-02 10:59:43 -0700 · Steve Abrams
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EkcNnNkckprSfgZT2eKiGs
Files touched
A .gitignoreA README.mdA claude-projA install.shA iterm-map.shA iterm-regroup.py
Diff
commit bd33534959bff181cd344a3a67d0be2dd2b907d2
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Sep 2 10:59:43 2026 -0700
iterm-project-grouper: claude-proj launcher (per-project windows) + iterm-map + API regroup tool
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EkcNnNkckprSfgZT2eKiGs
---
.gitignore | 5 +++
README.md | 47 +++++++++++++++++++++++
claude-proj | 86 ++++++++++++++++++++++++++++++++++++++++++
install.sh | 6 +++
iterm-map.sh | 45 ++++++++++++++++++++++
iterm-regroup.py | 111 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 300 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ff2422c
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..39e7919
--- /dev/null
+++ b/README.md
@@ -0,0 +1,47 @@
+# iterm-project-grouper
+
+Group iTerm2 sessions by project — **each project's Claude sessions as tabs in one window**.
+
+## The tools
+
+### `claude-proj` — the launcher (Option B, the real solution)
+Opens a Claude Code session as a **tab inside its project's dedicated iTerm2 window**,
+creating that window the first time. Every later session for the same project (matched by
+**git repo root**) lands as another tab in the same window. Non-destructive: it only ever
+*creates* tabs/windows — it never closes or moves a live session.
+
+```bash
+claude-proj # group by the git root of the current directory
+claude-proj ~/Projects/foo # open a foo session in foo's window
+claude-proj --list # show the project → window registry
+claude-proj --forget foo # drop a stale registry entry
+```
+- Registry: `~/.claude/iterm-proj-windows.tsv` (`<project>\t<iterm-window-id>`).
+- Self-healing: if a project's window was closed, the next `claude-proj` makes a fresh one.
+- Override the launched command for testing: `CLAUDE_PROJ_CMD='echo hi' claude-proj ~/x`.
+
+Put it on PATH (a symlink into `~/bin` is created by `install.sh`), then use `claude-proj`
+instead of bare `claude` to keep every project's sessions grouped from the start.
+
+### `iterm-map.sh` — accurate current grouping (read-only)
+Prints which live windows belong to which project, reading each session's real shell
+`path` (iTerm2 shell-integration variable — reliable, unlike the OS process cwd).
+Use it as a manual-drag guide for the sessions you already have open.
+```bash
+./iterm-map.sh
+```
+Caveat: `W#` are **positional** window indices and shift as sessions come/go; use them as
+an at-a-glance grouping, not stable ids.
+
+### `iterm-regroup.py` — Option C (API), with an honest limit
+Reads the authoritative grouping via the iTerm2 Python API (`--plan`). **iTerm2 exposes no
+supported way to move an existing tab between windows** (AppleScript *or* Python API), so a
+non-destructive *retroactive* regroup of already-open sessions is not possible — `--apply`
+says so rather than killing sessions to force it. Enable the API only if you want `--plan`;
+it needs an iTerm2 restart (which ends current sessions), so it isn't worth it just to
+regroup — use `claude-proj` going forward instead.
+
+## Recommendation
+- **Going forward:** launch project work with `claude-proj` → automatic per-project windows.
+- **Right now:** run `./iterm-map.sh` and drag same-project tabs together (manual, safe).
+- Don't restart iTerm2 to enable the API purely to regroup — the move isn't supported.
diff --git a/claude-proj b/claude-proj
new file mode 100755
index 0000000..048be3f
--- /dev/null
+++ b/claude-proj
@@ -0,0 +1,86 @@
+#!/usr/bin/env bash
+# claude-proj — open a Claude Code session as a TAB inside its project's
+# dedicated iTerm2 window (creating the window the first time). Groups every
+# session of the same project (git repo root) into ONE window, going forward,
+# without ever touching existing live sessions.
+#
+# Usage:
+# claude-proj [dir] # group by the git root of dir (default: $PWD)
+# claude-proj # current directory's project
+# CLAUDE_PROJ_CMD='...' claude-proj [dir] # override the launched command (testing)
+# claude-proj --list # show the project→window registry
+# claude-proj --forget PROJ # drop a stale registry entry
+#
+# Registry: ~/.claude/iterm-proj-windows.tsv (lines: <project>\t<iterm-window-id>)
+# Non-destructive: only ever CREATES tabs/windows; never closes or moves live ones.
+set -euo pipefail
+
+REG="$HOME/.claude/iterm-proj-windows.tsv"
+mkdir -p "$(dirname "$REG")"; touch "$REG"
+
+case "${1:-}" in
+ --list) printf 'project\twindow-id\n'; cat "$REG"; exit 0 ;;
+ --forget) awk -F'\t' -v p="${2:-__none__}" '$1!=p' "$REG" > "$REG.tmp" && mv "$REG.tmp" "$REG"; echo "forgot: ${2:-}"; exit 0 ;;
+esac
+
+DIR="${1:-$PWD}"
+[ -d "$DIR" ] || { echo "claude-proj: no such dir: $DIR" >&2; exit 1; }
+DIR="$(cd "$DIR" && pwd -P)"
+ROOT="$(git -C "$DIR" rev-parse --show-toplevel 2>/dev/null || echo "$DIR")"
+PROJ="$(basename "$ROOT")"
+CMD="${CLAUDE_PROJ_CMD:-claude}"
+
+# look up an existing window id for this project
+WINID="$(awk -F'\t' -v p="$PROJ" '$1==p{print $2; exit}' "$REG")"
+
+# ask iTerm2 to place the tab; returns the window id actually used.
+# CP_* env vars are read inside AppleScript via `system attribute` — no quoting hell.
+USED_ID="$(CP_DIR="$DIR" CP_CMD="$CMD" CP_PROJ="$PROJ" CP_WINID="${WINID:-0}" osascript <<'APPLESCRIPT'
+on envv(k)
+ try
+ return (do shell script "printf %s \"$" & k & "\"")
+ on error
+ return ""
+ end try
+end envv
+set theDir to envv("CP_DIR")
+set theCmd to envv("CP_CMD")
+set theProj to envv("CP_PROJ")
+set wantId to envv("CP_WINID")
+set runLine to "cd " & quoted form of theDir & " && " & theCmd
+tell application "iTerm2"
+ set targetWin to missing value
+ if wantId is not "" and wantId is not "0" then
+ repeat with w in windows
+ try
+ if (id of w as string) is wantId then set targetWin to w
+ end try
+ end repeat
+ end if
+ if targetWin is missing value then
+ -- no live window for this project → make one
+ set targetWin to (create window with default profile)
+ tell current session of targetWin to write text runLine
+ tell current session of targetWin to set name to theProj
+ else
+ -- reuse the project's window: add a tab
+ tell targetWin
+ set newTab to (create tab with default profile)
+ end tell
+ tell current session of newTab to write text runLine
+ tell current session of newTab to set name to theProj
+ end if
+ return (id of targetWin as string)
+end tell
+APPLESCRIPT
+)"
+
+# persist / refresh the registry mapping
+if [ -n "$USED_ID" ]; then
+ grep -v -E "^${PROJ} " "$REG" > "$REG.tmp" 2>/dev/null || true
+ printf '%s\t%s\n' "$PROJ" "$USED_ID" >> "$REG.tmp"
+ mv "$REG.tmp" "$REG"
+ echo "claude-proj: '$PROJ' → iTerm2 window $USED_ID (dir: $DIR)"
+else
+ echo "claude-proj: failed to place tab (is iTerm2 running?)" >&2; exit 1
+fi
diff --git a/install.sh b/install.sh
new file mode 100755
index 0000000..1d67b7b
--- /dev/null
+++ b/install.sh
@@ -0,0 +1,6 @@
+#!/usr/bin/env bash
+set -euo pipefail
+mkdir -p "$HOME/bin"
+ln -sf "$(cd "$(dirname "$0")" && pwd)/claude-proj" "$HOME/bin/claude-proj"
+echo "linked: $HOME/bin/claude-proj -> $(cd "$(dirname "$0")" && pwd)/claude-proj"
+case ":$PATH:" in *":$HOME/bin:"*) echo "~/bin already on PATH — 'claude-proj' is ready";; *) echo "add ~/bin to PATH: echo 'export PATH=\$HOME/bin:\$PATH' >> ~/.zshrc";; esac
diff --git a/iterm-map.sh b/iterm-map.sh
new file mode 100755
index 0000000..101be3c
--- /dev/null
+++ b/iterm-map.sh
@@ -0,0 +1,45 @@
+#!/usr/bin/env bash
+# iterm-map.sh — accurate "which iTerm2 windows belong to which project" map.
+# Reads each session's live shell path (iTerm2 shell-integration `path` variable,
+# reliable unlike the OS process cwd), resolves each to its git-repo root, and
+# groups windows by project. READ-ONLY: touches nothing, moves nothing.
+set -euo pipefail
+
+RAW="$(osascript <<'APPLESCRIPT'
+tell application "iTerm2"
+ set o to ""
+ set wi to 0
+ repeat with w in windows
+ set wi to wi + 1
+ repeat with t in tabs of w
+ repeat with s in sessions of t
+ set p to ""
+ try
+ tell s to set p to (get variable named "path")
+ end try
+ set o to o & wi & "|||" & p & linefeed
+ end repeat
+ end repeat
+ end repeat
+ return o
+end tell
+APPLESCRIPT
+)"
+
+# group: project(git-root basename) -> sorted unique window list
+printf '%s\n' "$RAW" | while IFS= read -r line; do
+ [ -z "$line" ] && continue
+ win="${line%%|||*}"; path="${line#*|||}"
+ [ "$path" = "$line" ] && path="" # no delimiter present
+ if [ -z "$path" ]; then echo "${win}"$'\t'"(no path / non-shell)"; continue; fi
+ root="$(git -C "$path" rev-parse --show-toplevel 2>/dev/null || echo "$path")"
+ echo "${win}"$'\t'"$(basename "$root")"
+done | sort -t$'\t' -k2 | awk -F'\t' '
+{ proj=$2; win="W"$1; if (!(proj in seen) || index(list[proj],win)==0){ list[proj]=list[proj] (list[proj]==""?"":", ") win; cnt[proj]++ } seen[proj]=1 }
+END{
+ printf "=== iTerm2 windows grouped by project ===\n"
+ # print by descending count
+ n=0; for(p in cnt){ order[n++]=p }
+ for(i=0;i<n;i++) for(j=i+1;j<n;j++) if(cnt[order[j]]>cnt[order[i]]){ t=order[i];order[i]=order[j];order[j]=t }
+ for(i=0;i<n;i++){ p=order[i]; printf "%2d window(s) %-34s %s\n", cnt[p], p, list[p] }
+}'
diff --git a/iterm-regroup.py b/iterm-regroup.py
new file mode 100755
index 0000000..d33eef9
--- /dev/null
+++ b/iterm-regroup.py
@@ -0,0 +1,111 @@
+#!/usr/bin/env python3
+"""
+iterm-regroup.py — (Option C) iTerm2 Python API tool to read the AUTHORITATIVE
+project grouping of all live sessions and, where the platform allows, assist
+regrouping.
+
+Run AFTER enabling the API: iTerm2 → Settings → General → Magic →
+"Enable Python API" (it prompts to allow on first connect). Requires a
+restart of iTerm2 to take effect (which ends current sessions — do it only
+when they can be dropped).
+
+ python3 iterm-regroup.py --plan # print the authoritative grouping (stable window ids)
+ python3 iterm-regroup.py --apply # attempt to regroup (see the honest note below)
+
+HONEST NOTE ON --apply
+----------------------
+iTerm2 does NOT expose moving an existing tab from one window to another —
+neither in AppleScript nor (as of this writing) in the Python API. The API can
+CREATE windows/tabs, split panes, activate, run commands, and read variables,
+but there is no supported "reparent this live tab into that window."
+
+Consequences:
+ * --plan is fully supported and reliable (reads each session's shell `path`).
+ * --apply cannot MOVE live tabs without recreating (killing) the sessions,
+ which this tool will NOT do. If a future iTerm2 build adds a tab-move API,
+ wire it into `move_tab_into()` below and flip SUPPORTS_MOVE = True.
+
+The durable, non-destructive answer is the `claude-proj` launcher (Option B):
+new project sessions open as tabs inside their project's window from the start.
+"""
+import sys, os, subprocess, collections
+
+SUPPORTS_MOVE = False # flip to True only when a real tab-move API is wired in
+
+def git_root(path):
+ try:
+ r = subprocess.run(["git", "-C", path, "rev-parse", "--show-toplevel"],
+ capture_output=True, text=True)
+ return r.stdout.strip() or path
+ except Exception:
+ return path
+
+async def main(connection):
+ import iterm2
+ app = await iterm2.async_get_app(connection)
+ groups = collections.defaultdict(list) # project -> [(window_id, tab_id, path)]
+ for window in app.terminal_windows:
+ for tab in window.tabs:
+ # a tab's project = its (active) session's shell path
+ sess = tab.current_session or (tab.sessions[0] if tab.sessions else None)
+ path = ""
+ if sess:
+ try:
+ path = await sess.async_get_variable("path") or ""
+ except Exception:
+ path = ""
+ proj = os.path.basename(git_root(path)) if path else "(no path)"
+ groups[proj].append((window.window_id, tab.tab_id, path))
+
+ print("=== AUTHORITATIVE grouping (iTerm2 Python API) ===")
+ for proj, items in sorted(groups.items(), key=lambda kv: -len(kv[1])):
+ wins = sorted({w for (w, _, _) in items})
+ print(f"{len(items):2d} tab(s) / {len(wins)} window(s) {proj}")
+ for (w, t, p) in items:
+ print(f" window={w} tab={t} {p}")
+
+ if "--apply" in sys.argv:
+ multi = {p: it for p, it in groups.items()
+ if p != "(no path)" and len({w for (w, _, _) in it}) > 1}
+ print("\n--apply requested.")
+ if not SUPPORTS_MOVE:
+ print("iTerm2 exposes no supported tab-move-between-windows API, so a")
+ print("NON-DESTRUCTIVE regroup of existing sessions is not possible.")
+ print("Projects that are currently split across windows:")
+ for p, it in multi.items():
+ print(f" {p}: windows {sorted({w for (w,_,_) in it})}")
+ print("\nUse the `claude-proj` launcher for all NEW sessions (Option B),")
+ print("or drag these tabs together manually. This tool will not recreate")
+ print("(kill) live sessions to force a regroup.")
+ else:
+ for p, it in multi.items():
+ await regroup_project(app, p, it)
+
+async def move_tab_into(app, tab, target_window):
+ """Placeholder for a future iTerm2 tab-move API. Not currently supported."""
+ raise NotImplementedError("iTerm2 has no supported move-tab-between-windows API")
+
+async def regroup_project(app, proj, items):
+ wins = sorted({w for (w, _, _) in items})
+ target = app.get_window_by_id(wins[0])
+ for (w, t, _) in items:
+ if w == wins[0]:
+ continue
+ tab = None
+ for win in app.terminal_windows:
+ for tb in win.tabs:
+ if tb.tab_id == t:
+ tab = tb
+ if tab:
+ await move_tab_into(app, tab, target)
+
+if __name__ == "__main__":
+ if not any(a in sys.argv for a in ("--plan", "--apply")):
+ print(__doc__); sys.exit(0)
+ try:
+ import iterm2
+ except ModuleNotFoundError:
+ sys.exit("iterm2 module not found. Enable the API and run with iTerm2's "
+ "python3, e.g.: ~/Library/ApplicationSupport/iTerm2/iterm2env/versions/*/bin/python3 "
+ "iterm-regroup.py --plan")
+ iterm2.run_until_complete(main)
(oldest)
·
back to Iterm Project Grouper
·
(newest)