Created
February 7, 2026 22:45
-
-
Save sunapi386/56be9fc3eb5bc3b6024b52a6f3e53493 to your computer and use it in GitHub Desktop.
Claude Code Command Center for tmux — one-shot installer
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # Claude Code Command Center — one-shot installer | |
| # Run: bash ~/.claude/command-center/install.sh | |
| set -euo pipefail | |
| log() { printf '\e[1;34m→\e[0m %s\n' "$1"; } | |
| ok() { printf '\e[1;32m✓\e[0m %s\n' "$1"; } | |
| err() { printf '\e[1;31m✗\e[0m %s\n' "$1" >&2; exit 1; } | |
| # ── Preflight ──────────────────────────────────────────────────────────────── | |
| command -v jq >/dev/null || err "jq not found" | |
| command -v flock >/dev/null || err "flock not found" | |
| command -v tmux >/dev/null || err "tmux not found" | |
| command -v fish >/dev/null || err "fish not found" | |
| # ── Directories ────────────────────────────────────────────────────────────── | |
| log "Creating directories" | |
| mkdir -p "$HOME/.claude/hooks" | |
| mkdir -p "$HOME/.claude/command-center/sessions" | |
| # ── 1. Hook script ────────────────────────────────────────────────────────── | |
| log "Writing hook script" | |
| cat > "$HOME/.claude/hooks/command-center.sh" << 'HOOKEOF' | |
| #!/usr/bin/env bash | |
| # Claude Code Command Center — hook script | |
| # Handles: SessionStart, SessionEnd, Notification, PreToolUse | |
| # Writes per-session JSON state to ~/.claude/command-center/sessions/ | |
| set -euo pipefail | |
| STATE_DIR="$HOME/.claude/command-center/sessions" | |
| mkdir -p "$STATE_DIR" | |
| INPUT=$(cat) | |
| EVENT=$(echo "$INPUT" | jq -r '.event // empty') | |
| SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // empty') | |
| [ -z "$EVENT" ] && exit 0 | |
| [ -z "$SESSION_ID" ] && exit 0 | |
| STATE_FILE="$STATE_DIR/${SESSION_ID}.json" | |
| LOCK_FILE="$STATE_FILE.lock" | |
| TMP_FILE="$STATE_FILE.tmp.$$" | |
| NOW=$(date -u +"%Y-%m-%dT%H:%M:%SZ") | |
| cleanup() { rm -f "$TMP_FILE"; } | |
| trap cleanup EXIT | |
| case "$EVENT" in | |
| SessionStart) | |
| CWD=$(echo "$INPUT" | jq -r '.cwd // empty') | |
| PROJECT=$(basename "${CWD:-unknown}") | |
| PANE="${TMUX_PANE:-}" | |
| jq -n \ | |
| --arg sid "$SESSION_ID" \ | |
| --arg cwd "${CWD:-}" \ | |
| --arg project "$PROJECT" \ | |
| --arg started "$NOW" \ | |
| --arg activity "$NOW" \ | |
| --arg pane "$PANE" \ | |
| '{ | |
| session_id: $sid, | |
| cwd: $cwd, | |
| project: $project, | |
| started_at: $started, | |
| last_activity: $activity, | |
| tmux_pane: $pane, | |
| status: "active", | |
| pending_question: null, | |
| current_tool: null | |
| }' > "$TMP_FILE" && mv "$TMP_FILE" "$STATE_FILE" | |
| ;; | |
| SessionEnd) | |
| rm -f "$STATE_FILE" "$LOCK_FILE" | |
| ;; | |
| Notification) | |
| ( | |
| flock -n 200 || exit 0 | |
| NOTIF_TYPE=$(echo "$INPUT" | jq -r '.notification.type // "notification"') | |
| MESSAGE=$(echo "$INPUT" | jq -r '.notification.message // empty') | |
| if [ -f "$STATE_FILE" ]; then | |
| jq \ | |
| --arg activity "$NOW" \ | |
| --arg ntype "$NOTIF_TYPE" \ | |
| --arg msg "$MESSAGE" \ | |
| --arg asked "$NOW" \ | |
| '.status = "waiting" | |
| | .last_activity = $activity | |
| | .pending_question = { | |
| type: $ntype, | |
| message: $msg, | |
| asked_at: $asked | |
| }' "$STATE_FILE" > "$TMP_FILE" && mv "$TMP_FILE" "$STATE_FILE" | |
| fi | |
| ) 200>"$LOCK_FILE" | |
| ;; | |
| PreToolUse) | |
| ( | |
| flock -n 200 || exit 0 | |
| TOOL_NAME=$(echo "$INPUT" | jq -r '.tool.name // empty') | |
| TOOL_DESC=$(echo "$INPUT" | jq -r '.tool.description // empty') | |
| if [ -f "$STATE_FILE" ]; then | |
| jq \ | |
| --arg activity "$NOW" \ | |
| --arg tname "$TOOL_NAME" \ | |
| --arg tdesc "$TOOL_DESC" \ | |
| '.status = "active" | |
| | .last_activity = $activity | |
| | .pending_question = null | |
| | .current_tool = { | |
| name: $tname, | |
| description: $tdesc | |
| }' "$STATE_FILE" > "$TMP_FILE" && mv "$TMP_FILE" "$STATE_FILE" | |
| fi | |
| ) 200>"$LOCK_FILE" | |
| ;; | |
| esac | |
| HOOKEOF | |
| chmod +x "$HOME/.claude/hooks/command-center.sh" | |
| ok "Hook script" | |
| # ── 2. Fish functions ──────────────────────────────────────────────────────── | |
| log "Writing fish functions" | |
| FISH_FUNCS_DIR="$HOME/.config/fish/my_funcs" | |
| mkdir -p "$FISH_FUNCS_DIR" | |
| cat > "$FISH_FUNCS_DIR/claude-center.fish" << 'FISHEOF' | |
| # Claude Code Command Center — fish functions | |
| # Auto-loaded by config.fish via the my_funcs loop | |
| function _cc_cleanup_stale | |
| set -l sessions_dir "$HOME/.claude/command-center/sessions" | |
| for f in $sessions_dir/*.json | |
| test -f "$f"; or continue | |
| set -l pane (jq -r '.tmux_pane // empty' "$f" 2>/dev/null) | |
| if test -n "$pane" | |
| if not tmux display-message -t "$pane" -p '' 2>/dev/null | |
| rm -f "$f" "$f.lock" | |
| end | |
| end | |
| end | |
| end | |
| function _cc_status_count | |
| set -l sessions_dir "$HOME/.claude/command-center/sessions" | |
| set -l waiting 0 | |
| set -l total 0 | |
| for f in $sessions_dir/*.json | |
| test -f "$f"; or continue | |
| set total (math $total + 1) | |
| set -l status_val (jq -r '.status // empty' "$f" 2>/dev/null) | |
| if test "$status_val" = waiting | |
| set waiting (math $waiting + 1) | |
| end | |
| end | |
| if test $total -eq 0 | |
| echo "" | |
| else if test $waiting -gt 0 | |
| echo "#[fg=red,bold]CC:"$waiting"!#[default] " | |
| else | |
| echo "#[fg=green]CC:ok#[default] " | |
| end | |
| end | |
| function _cc_render_session --argument-names file mode | |
| set -l data (cat "$file" 2>/dev/null) | |
| test -n "$data"; or return | |
| set -l project (echo "$data" | jq -r '.project // "unknown"') | |
| set -l status_val (echo "$data" | jq -r '.status // "active"') | |
| set -l pane (echo "$data" | jq -r '.tmux_pane // ""') | |
| set -l pending_msg (echo "$data" | jq -r '.pending_question.message // empty') | |
| set -l pending_type (echo "$data" | jq -r '.pending_question.type // empty') | |
| set -l tool_name (echo "$data" | jq -r '.current_tool.name // empty') | |
| set -l tool_desc (echo "$data" | jq -r '.current_tool.description // empty') | |
| set -l asked_at (echo "$data" | jq -r '.pending_question.asked_at // empty') | |
| # Get tmux window:pane index for display | |
| set -l pane_display "" | |
| if test -n "$pane" | |
| set pane_display (tmux display-message -t "$pane" -p '[#{window_index}:#{pane_index}]' 2>/dev/null; or echo "[$pane]") | |
| end | |
| # Calculate wait duration | |
| set -l wait_str "" | |
| if test -n "$asked_at" | |
| set -l asked_epoch (date -d "$asked_at" +%s 2>/dev/null) | |
| if test -n "$asked_epoch" | |
| set -l now_epoch (date +%s) | |
| set -l diff (math $now_epoch - $asked_epoch) | |
| set -l mins (math "floor($diff / 60)") | |
| set -l secs (math "$diff % 60") | |
| set wait_str (printf "%dm%02ds" $mins $secs) | |
| end | |
| end | |
| if test "$status_val" = waiting | |
| set_color red; echo -n " ! "; set_color normal | |
| set_color --bold; echo -n "$project"; set_color normal | |
| echo " $pane_display" | |
| set_color yellow | |
| if test -n "$wait_str" | |
| echo -n " Waiting $wait_str: " | |
| else | |
| echo -n " Waiting: " | |
| end | |
| if test -n "$pending_type" -a -n "$tool_name" | |
| echo "Permission needed for $tool_name($tool_desc)" | |
| else if test -n "$pending_msg" | |
| set -l max_len 60 | |
| if test "$mode" = narrow | |
| set max_len 30 | |
| end | |
| if test (string length "$pending_msg") -gt $max_len | |
| echo (string sub -l $max_len "$pending_msg")"..." | |
| else | |
| echo "$pending_msg" | |
| end | |
| else | |
| echo "Needs attention" | |
| end | |
| set_color normal | |
| else | |
| set_color cyan; echo -n " > "; set_color normal | |
| set_color --bold; echo -n "$project"; set_color normal | |
| echo " $pane_display" | |
| if test -n "$tool_name" | |
| set_color brblack | |
| if test -n "$tool_desc" | |
| set -l max_len 60 | |
| if test "$mode" = narrow | |
| set max_len 30 | |
| end | |
| set -l desc "$tool_desc" | |
| if test (string length "$desc") -gt $max_len | |
| set desc (string sub -l $max_len "$desc")"..." | |
| end | |
| echo " $tool_name: $desc" | |
| else | |
| echo " $tool_name" | |
| end | |
| set_color normal | |
| end | |
| end | |
| end | |
| function claude_center --argument-names mode | |
| test -n "$mode"; or set mode full | |
| set -l sessions_dir "$HOME/.claude/command-center/sessions" | |
| mkdir -p "$sessions_dir" | |
| while true | |
| clear | |
| _cc_cleanup_stale | |
| set -l waiting_files | |
| set -l active_files | |
| set -l total 0 | |
| for f in $sessions_dir/*.json | |
| test -f "$f"; or continue | |
| set total (math $total + 1) | |
| set -l status_val (jq -r '.status // "active"' "$f" 2>/dev/null) | |
| if test "$status_val" = waiting | |
| set -a waiting_files "$f" | |
| else | |
| set -a active_files "$f" | |
| end | |
| end | |
| # Sort waiting by asked_at (oldest first = most urgent) | |
| if test (count $waiting_files) -gt 1 | |
| set waiting_files (for f in $waiting_files | |
| set -l asked (jq -r '.pending_question.asked_at // "9999"' "$f" 2>/dev/null) | |
| echo "$asked $f" | |
| end | sort | string replace -r '^\S+ ' '') | |
| end | |
| # Header | |
| if test "$mode" = narrow | |
| set_color --bold brwhite | |
| echo " COMMAND CENTER" | |
| set_color normal | |
| set_color brblack | |
| echo " "(date +%H:%M:%S)" | q=quit" | |
| set_color normal | |
| else if test "$mode" = status-only | |
| _cc_status_count | |
| return | |
| else | |
| set_color --bold brwhite | |
| echo " CLAUDE CODE COMMAND CENTER" | |
| set_color normal | |
| set_color brblack | |
| echo " "(date +%H:%M:%S)" | Press q to quit" | |
| set_color normal | |
| end | |
| echo "" | |
| # Waiting section | |
| if test (count $waiting_files) -gt 0 | |
| set_color --bold red | |
| echo " NEEDS ATTENTION" | |
| set_color brblack | |
| if test "$mode" = narrow | |
| echo " ──────────────────────────" | |
| else | |
| echo " ─────────────────────────────────────────" | |
| end | |
| set_color normal | |
| for f in $waiting_files | |
| _cc_render_session "$f" "$mode" | |
| end | |
| echo "" | |
| end | |
| # Active section | |
| if test (count $active_files) -gt 0 | |
| set_color --bold cyan | |
| echo " ACTIVE" | |
| set_color brblack | |
| if test "$mode" = narrow | |
| echo " ──────────────────────────" | |
| else | |
| echo " ─────────────────────────────────────────" | |
| end | |
| set_color normal | |
| for f in $active_files | |
| _cc_render_session "$f" "$mode" | |
| end | |
| echo "" | |
| end | |
| if test $total -eq 0 | |
| set_color brblack | |
| echo " No active Claude sessions" | |
| set_color normal | |
| echo "" | |
| end | |
| # Footer | |
| set_color brblack | |
| echo " "(count $waiting_files)" waiting | "(count $active_files)" active | $total total" | |
| set_color normal | |
| # Wait for input or timeout (2s refresh) | |
| # fish read --timeout is in seconds | |
| set -l key "" | |
| if not read --nchars 1 --timeout 2 key 2>/dev/null | |
| sleep 1.5 | |
| end | |
| switch "$key" | |
| case q Q | |
| return | |
| case j J | |
| if test (count $waiting_files) -gt 0 | |
| set -l pane (jq -r '.tmux_pane // empty' "$waiting_files[1]" 2>/dev/null) | |
| if test -n "$pane" | |
| tmux select-pane -t "$pane" 2>/dev/null | |
| tmux select-window -t "$pane" 2>/dev/null | |
| return | |
| end | |
| end | |
| end | |
| end | |
| end | |
| function claude_center_sidebar | |
| set -l existing (tmux list-panes -F '#{pane_id} #{pane_start_command}' 2>/dev/null | grep 'claude_center' | head -1 | string split ' ')[1] | |
| if test -n "$existing" | |
| tmux kill-pane -t "$existing" 2>/dev/null | |
| else | |
| tmux split-window -h -l 40 "fish -c 'claude_center narrow'" | |
| end | |
| end | |
| function claude_center_window | |
| set -l existing (tmux list-windows -F '#{window_id} #{window_name}' 2>/dev/null | grep 'CC-Dashboard' | head -1 | string split ' ')[1] | |
| if test -n "$existing" | |
| tmux kill-window -t "$existing" 2>/dev/null | |
| else | |
| tmux new-window -n 'CC-Dashboard' "fish -c 'claude_center full'" | |
| end | |
| end | |
| function claude_center_popup | |
| tmux display-popup -w 80% -h 70% -E "fish -c 'claude_center full'" | |
| end | |
| function claude_center_jump | |
| set -l sessions_dir "$HOME/.claude/command-center/sessions" | |
| _cc_cleanup_stale | |
| set -l oldest_pane "" | |
| set -l oldest_time "9999" | |
| for f in $sessions_dir/*.json | |
| test -f "$f"; or continue | |
| set -l status_val (jq -r '.status // "active"' "$f" 2>/dev/null) | |
| if test "$status_val" = waiting | |
| set -l asked (jq -r '.pending_question.asked_at // "9999"' "$f" 2>/dev/null) | |
| if test "$asked" \< "$oldest_time" | |
| set oldest_time "$asked" | |
| set oldest_pane (jq -r '.tmux_pane // empty' "$f" 2>/dev/null) | |
| end | |
| end | |
| end | |
| if test -n "$oldest_pane" | |
| tmux select-window -t "$oldest_pane" 2>/dev/null | |
| tmux select-pane -t "$oldest_pane" 2>/dev/null | |
| tmux display-message "Jumped to waiting Claude session" 2>/dev/null | |
| else | |
| tmux display-message "No waiting Claude sessions" 2>/dev/null | |
| end | |
| end | |
| FISHEOF | |
| ok "Fish functions" | |
| # ── 3. Settings hooks ──────────────────────────────────────────────────────── | |
| log "Updating ~/.claude/settings.json" | |
| SETTINGS="$HOME/.claude/settings.json" | |
| HOOK_CMD="bash \$HOME/.claude/hooks/command-center.sh" | |
| HOOK_ENTRY='[{"matcher":"","hooks":[{"type":"command","command":"'"$HOOK_CMD"'","timeout":5}]}]' | |
| if [ -f "$SETTINGS" ]; then | |
| cp "$SETTINGS" "$SETTINGS.bak" | |
| jq --argjson entry "$HOOK_ENTRY" ' | |
| .hooks //= {} | | |
| .hooks.Notification = $entry | | |
| .hooks.PreToolUse = $entry | | |
| .hooks.SessionStart = $entry | | |
| .hooks.SessionEnd = $entry | |
| ' "$SETTINGS" > "$SETTINGS.tmp" && mv "$SETTINGS.tmp" "$SETTINGS" | |
| else | |
| jq -n --argjson entry "$HOOK_ENTRY" '{ | |
| hooks: { | |
| Notification: $entry, | |
| PreToolUse: $entry, | |
| SessionStart: $entry, | |
| SessionEnd: $entry | |
| } | |
| }' > "$SETTINGS" | |
| fi | |
| ok "Settings hooks (backup at settings.json.bak)" | |
| # ── 4. tmux keybindings ───────────────────────────────────────────────────── | |
| log "Updating ~/.tmux.conf" | |
| TMUX_CONF="$HOME/.tmux.conf" | |
| MARKER="# Claude Code Command Center" | |
| if [ -f "$TMUX_CONF" ] && grep -qF "$MARKER" "$TMUX_CONF"; then | |
| # Already present — replace the block | |
| sed -i "/^$MARKER$/,/^$/{ /^$MARKER$/!{ /^$/!d; }; }" "$TMUX_CONF" | |
| sed -i "/^$MARKER$/a\\ | |
| bind-key C-c display-popup -w 80% -h 70% -E \"fish -c 'claude_center full'\"\\ | |
| bind-key M-c run-shell \"fish -c 'claude_center_sidebar'\"\\ | |
| bind-key C-w run-shell \"fish -c 'claude_center_window'\"\\ | |
| bind-key C-j run-shell \"fish -c 'claude_center_jump'\"\\ | |
| set -g status-right '#(fish -c \"_cc_status_count\" 2>/dev/null) %H:%M '\\ | |
| set -g status-interval 2" "$TMUX_CONF" | |
| ok "tmux.conf (updated existing block)" | |
| elif [ -f "$TMUX_CONF" ]; then | |
| cp "$TMUX_CONF" "$TMUX_CONF.bak" | |
| # Insert before TPM init block (comment + run line), or append | |
| if grep -qF "# Initialize TMUX plugin manager" "$TMUX_CONF"; then | |
| sed -i "/^# Initialize TMUX plugin manager/i\\ | |
| $MARKER\\ | |
| bind-key C-c display-popup -w 80% -h 70% -E \"fish -c 'claude_center full'\"\\ | |
| bind-key M-c run-shell \"fish -c 'claude_center_sidebar'\"\\ | |
| bind-key C-w run-shell \"fish -c 'claude_center_window'\"\\ | |
| bind-key C-j run-shell \"fish -c 'claude_center_jump'\"\\ | |
| set -g status-right '#(fish -c \"_cc_status_count\" 2>/dev/null) %H:%M '\\ | |
| set -g status-interval 2\\ | |
| " "$TMUX_CONF" | |
| elif grep -qF "run '~/.tmux/plugins/tpm/tpm'" "$TMUX_CONF"; then | |
| sed -i "/^run '~\/.tmux\/plugins\/tpm\/tpm'/i\\ | |
| $MARKER\\ | |
| bind-key C-c display-popup -w 80% -h 70% -E \"fish -c 'claude_center full'\"\\ | |
| bind-key M-c run-shell \"fish -c 'claude_center_sidebar'\"\\ | |
| bind-key C-w run-shell \"fish -c 'claude_center_window'\"\\ | |
| bind-key C-j run-shell \"fish -c 'claude_center_jump'\"\\ | |
| set -g status-right '#(fish -c \"_cc_status_count\" 2>/dev/null) %H:%M '\\ | |
| set -g status-interval 2\\ | |
| " "$TMUX_CONF" | |
| else | |
| cat >> "$TMUX_CONF" << 'TMUXEOF' | |
| # Claude Code Command Center | |
| bind-key C-c display-popup -w 80% -h 70% -E "fish -c 'claude_center full'" | |
| bind-key M-c run-shell "fish -c 'claude_center_sidebar'" | |
| bind-key C-w run-shell "fish -c 'claude_center_window'" | |
| bind-key C-j run-shell "fish -c 'claude_center_jump'" | |
| set -g status-right '#(fish -c "_cc_status_count" 2>/dev/null) %H:%M ' | |
| set -g status-interval 2 | |
| TMUXEOF | |
| fi | |
| ok "tmux.conf (backup at .tmux.conf.bak)" | |
| else | |
| err "~/.tmux.conf not found" | |
| fi | |
| # ── 5. Reload ──────────────────────────────────────────────────────────────── | |
| if [ -n "${TMUX:-}" ]; then | |
| log "Reloading tmux config" | |
| tmux source-file "$TMUX_CONF" 2>/dev/null && ok "tmux reloaded" || echo " (reload manually: tmux source-file ~/.tmux.conf)" | |
| else | |
| log "Not inside tmux — reload manually: tmux source-file ~/.tmux.conf" | |
| fi | |
| printf '\n\e[1;32mDone!\e[0m Command Center installed.\n' | |
| printf ' Prefix+Ctrl-c popup Prefix+Alt-c sidebar\n' | |
| printf ' Prefix+Ctrl-w window Prefix+Ctrl-j jump\n' |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment