#!/usr/bin/env zsh emulate -L zsh setopt err_exit no_unset pipe_fail # wm-cursor: Manage Cursor SSH remote windows with grouped tmux sessions # Each worktree gets its own Cursor window with an independently-focused # grouped tmux session, sharing the same window list in the status bar. # --- Resolve script path (must be at top level, not inside a function) --- local script_path=${0:A} # --- Lazy Cursor CLI resolution (only when needed) --- local cursor_bin= resolve_cursor_cli() { [[ -n $cursor_bin ]] && return 0 local -a cursor_bins=(~/.cursor-server/cli/servers/*/server/bin/remote-cli/cursor(NOm)) if (( ${#cursor_bins} == 0 )); then print -u2 "Error: Cursor remote CLI not found in ~/.cursor-server/cli/servers/" exit 1 fi cursor_bin=${cursor_bins[1]} # Refresh Cursor IPC socket (tmux may hold a stale one) # Multiple stale sockets may exist; probe to find a live one local sock for sock in /tmp/vscode-ipc-*.sock(NOm); do if timeout 2 env VSCODE_IPC_HOOK_CLI=$sock $cursor_bin --status &>/dev/null; then export VSCODE_IPC_HOOK_CLI=$sock break fi done } # --- Helper functions --- ensure_tmux() { if [[ -z ${TMUX-} ]]; then print -u2 "Error: Not inside a tmux session" exit 1 fi } check_dev_db() { if ! docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^windmill-db-dev$'; then print -u2 "Warning: windmill-db-dev container is not running" fi } setup_grouped_session() { local handle=$1 worktree_path=$2 local session_name=cursor-${handle} # Detect the current main tmux session local main_session=$(tmux display-message -p '#S') # Create grouped session (shares windows with the main session) if ! tmux has-session -t $session_name 2>/dev/null; then tmux new-session -d -t $main_session -s $session_name fi # Focus on the worktree's window tmux select-window -t ${session_name}:wm-${handle} 2>/dev/null || true # Write .vscode/settings.json in the worktree if it doesn't already exist local settings_file=${worktree_path}/.vscode/settings.json if [[ ! -f $settings_file ]]; then mkdir -p ${settings_file:h} # Read ports from .env.local if available local env_file=${worktree_path}/.env.local local ports_config="" if [[ -f $env_file ]]; then local backend_port frontend_port source $env_file backend_port=${BACKEND_PORT-} frontend_port=${FRONTEND_PORT-} if [[ -n $backend_port && -n $frontend_port ]]; then ports_config=', "remote.autoForwardPorts": true, "remote.otherPortsAttributes": { "onAutoForward": "ignore" }, "remote.portsAttributes": { "'$backend_port'": { "label": "Backend", "onAutoForward": "silent" }, "'$frontend_port'": { "label": "Frontend", "onAutoForward": "openBrowserOnce" } }' fi fi cat > $settings_file < from args, exports CARGO_FEATURES, returns remaining args. # Usage: parse_features_flag "$@"; set -- "${remaining_args[@]}" parse_flags() { remaining_args=() while (( $# )); do case $1 in --features) if (( $# < 2 )); then print -u2 "Error: --features requires a value" exit 1 fi export CARGO_FEATURES=$2 shift 2 ;; --features=*) export CARGO_FEATURES=${1#--features=} shift ;; --clone-db) export WM_CLONE_DB=1 shift ;; *) remaining_args+=("$1") shift ;; esac done } # --- Subcommands --- cmd_add() { resolve_cursor_cli ensure_tmux check_dev_db parse_flags "$@" set -- "${remaining_args[@]}" # Snapshot worktree list before local -a before=("${(@f)$(git worktree list --porcelain | grep '^worktree ')}") workmux add -b "$@" # Diff to find the new entry local -a after=("${(@f)$(git worktree list --porcelain | grep '^worktree ')}") local -a new=(${after:|before}) if (( ${#new} == 0 )); then print -u2 "Error: Could not detect new worktree path" exit 1 fi local new_path=${new[1]#worktree } local handle=${new_path:t} print "New worktree: ${handle} at ${new_path}" setup_grouped_session $handle $new_path $cursor_bin -n $new_path print "Opened Cursor for ${handle}" } cmd_open() { local name=${1:?Usage: wm-cursor open } shift resolve_cursor_cli ensure_tmux check_dev_db parse_flags "$@" set -- "${remaining_args[@]}" # Write CARGO_FEATURES to .env.local if specified if [[ -n ${CARGO_FEATURES-} ]]; then local wt_env=$(workmux path $name)/.env.local if [[ -f $wt_env ]]; then # Remove existing CARGO_FEATURES line and append new one sed -i '/^CARGO_FEATURES=/d' $wt_env echo "CARGO_FEATURES=$CARGO_FEATURES" >> $wt_env fi fi local wt_path=$(workmux path $name) local prev_target=$(tmux display-message -p '#{session_name}:#{window_index}') workmux open $name "$@" tmux select-window -t $prev_target setup_grouped_session $name $wt_path $cursor_bin -n $wt_path print "Opened Cursor for ${name}" } cmd_close() { local name=${1:?Usage: wm-cursor close } tmux kill-session -t cursor-${name} 2>/dev/null || true workmux close $name } cmd_open_ee() { local name=${1:?Usage: wm-cursor open-ee } resolve_cursor_cli local wt_path=$(workmux path $name) local main_repo_root="$(cd "$(git -C "$wt_path" rev-parse --git-common-dir 2>/dev/null)/.." && pwd)" # Find ee repo (same discovery logic as worktree-env) local ee_repo="" candidate for candidate in \ "${main_repo_root:+${main_repo_root}/../windmill-ee-private}" \ "${wt_path}/../windmill-ee-private" \ "${HOME}/windmill-ee-private" \ "${HOME}/projects/windmill-ee-private"; do if [[ -n $candidate ]] && [[ -d $candidate ]]; then ee_repo=${candidate:A} break fi done if [[ -z $ee_repo ]]; then print -u2 "Error: Could not find windmill-ee-private repo" exit 1 fi local ee_worktree_dir="${ee_repo}__worktrees/${name}" if [[ ! -d $ee_worktree_dir ]]; then print -u2 "Error: EE worktree not found at ${ee_worktree_dir}" exit 1 fi $cursor_bin -n $ee_worktree_dir print "Opened Cursor for EE worktree: ${ee_worktree_dir}" } cmd_setup() { local repo_root=${1:?Usage: wm-cursor setup } repo_root=${repo_root:A} local vscode_dir=${repo_root}/.vscode mkdir -p $vscode_dir # --- tasks.json --- local tasks_file=${vscode_dir}/tasks.json local write_tasks=true if [[ -f $tasks_file ]]; then print -n "tasks.json already exists. Overwrite? [y/N] " read -q || { print; write_tasks=false } print fi if $write_tasks; then cat > $tasks_file <<'TASKS' { "version": "2.0.0", "tasks": [ { "label": "Start dev DB", "type": "shell", "command": "./start-dev-db.sh", "options": { "shell": { "executable": "/bin/bash" } }, "runOptions": { "runOn": "folderOpen" }, "presentation": { "reveal": "silent", "close": true }, "problemMatcher": [] } ] } TASKS print "Wrote ${tasks_file}" fi # --- settings.json (merge wm-cursor keys, preserve existing) --- local settings_file=${vscode_dir}/settings.json local wmc_settings=' { "rust-analyzer.initializeStopped": true, "terminal.integrated.defaultProfile.linux": "wm-tmux", "terminal.integrated.profiles.linux": { "wm-tmux": { "path": "tmux", "args": ["new-session", "-A", "-s", "main"] } }, "remote.autoForwardPorts": true, "remote.otherPortsAttributes": { "onAutoForward": "ignore" }, "remote.portsAttributes": { "8000": { "label": "Backend", "onAutoForward": "silent" }, "3000": { "label": "Frontend", "onAutoForward": "openBrowserOnce" }, "5432": { "label": "PostgreSQL", "onAutoForward": "silent" } } }' if [[ -f $settings_file ]]; then # Strip // comments so jq can parse, merge, then write back local existing existing=$(python3 -c ' import json, re, sys text = sys.stdin.read() # Remove // comments only outside of strings text = re.sub(r'"'"'("(?:[^"\\]|\\.)*")|//[^\n]*'"'"', lambda m: m.group(1) or "", text) json.dump(json.loads(text), sys.stdout, indent=2) ' < $settings_file) jq --argjson wmc "$wmc_settings" '. * $wmc' <<< "$existing" > ${settings_file}.tmp \ && mv ${settings_file}.tmp $settings_file print "Merged wm-cursor settings into ${settings_file}" else jq . <<< "$wmc_settings" > $settings_file print "Created ${settings_file}" fi # --- zsh alias + completion --- local rc=${ZDOTDIR:-$HOME}/.zshrc local alias_line="alias wmc=${(q)script_path}" if [[ -f $rc ]] && grep -qF 'alias wmc=' $rc; then sed -i "s|^alias wmc=.*|${alias_line}|" $rc print "Updated wmc alias in ${rc}" else print "\n# wm-cursor alias\n${alias_line}" >> $rc print "Added wmc alias to ${rc}" fi local eval_line='eval "$(wmc completions)"' if ! grep -qF 'wmc completions' $rc; then print "${eval_line}" >> $rc print "Added completions eval to ${rc}" fi } cmd_completions() { cat <<'COMP' _wmc_worktree_names() { local -a names names=(${(f)"$(git worktree list --porcelain 2>/dev/null | sed -n 's|^worktree .*/||p' | tail -n +2)"}) _describe 'worktree' names } _wmc() { local -a subcmds=( 'add:Create worktree + open Cursor' 'open:Open Cursor for existing worktree' 'open-ee:Open EE worktree in Cursor' 'close:Clean up grouped tmux session' 'setup:Set up .vscode settings, tasks + wmc alias' 'completions:Print zsh completions' ) if (( CURRENT == 2 )); then _describe 'subcommand' subcmds else case $words[2] in open|open-ee|close) _wmc_worktree_names ;; esac fi } compdef _wmc wmc wm-cursor COMP } # --- Main --- case ${1-} in add) shift; cmd_add "$@" ;; open) shift; cmd_open "$@" ;; open-ee) shift; cmd_open_ee "$@" ;; close) shift; cmd_close "$@" ;; setup) shift; cmd_setup "$@" ;; completions) cmd_completions ;; *) print -u2 "Usage: wm-cursor [args...]" print -u2 "" print -u2 "Subcommands:" print -u2 " add [--features ] [workmux-add-args...] Create worktree + open Cursor" print -u2 " open [--features ] Open Cursor for existing worktree" print -u2 " open-ee Open EE worktree in Cursor" print -u2 " close Clean up grouped tmux session" print -u2 " setup Set up .vscode settings, tasks + wmc alias" print -u2 " completions Print zsh completions (use with eval)" print -u2 "" print -u2 "Options:" print -u2 " --features Cargo features for the backend (e.g. \"enterprise,parquet\")" print -u2 " --clone-db Clone the main 'windmill' database instead of creating an empty one" exit 1 ;; esac