|
#!/usr/bin/env python3 |
|
"""Install Claude Code Powerline Statusline — Catppuccin Mocha theme. |
|
|
|
Writes ~/.claude/statusline.sh with embedded Nerd Font glyphs (as Python |
|
Unicode escapes so the file gets correct bytes regardless of editor or tool). |
|
|
|
Usage: |
|
python3 install-statusline.py |
|
""" |
|
|
|
import json |
|
import os |
|
import platform |
|
import stat |
|
import shutil |
|
import subprocess |
|
import sys |
|
|
|
STATUSLINE_PATH = os.path.expanduser("~/.claude/statusline.sh") |
|
|
|
# Full statusline.sh content — all non-ASCII chars are Python Unicode escapes. |
|
# This guarantees correct bytes even when piped through tools that strip glyphs. |
|
STATUSLINE_CONTENT = ( |
|
"#!/bin/bash\n" |
|
"# Claude Code status line \u2014 Catppuccin Mocha powerline style\n" |
|
"# Receives JSON session data on stdin, prints a single colored line\n" |
|
"# Requires any Nerd Font patched terminal font\n" |
|
"\n" |
|
"input=$(cat)\n" |
|
"\n" |
|
"# Catppuccin Mocha \u2014 truecolor ANSI\n" |
|
"BG_BLUE='\x1b[48;2;137;180;250m'\n" |
|
"BG_GREEN='\x1b[48;2;166;227;161m'\n" |
|
"BG_YELLOW='\x1b[48;2;249;226;175m'\n" |
|
"BG_MAUVE='\x1b[48;2;203;166;247m'\n" |
|
"BG_TEAL='\x1b[48;2;148;226;213m'\n" |
|
"BG_PEACH='\x1b[48;2;250;179;135m'\n" |
|
"BG_SKY='\x1b[48;2;137;220;235m'\n" |
|
"FG_BASE='\x1b[38;2;30;30;46m'\n" |
|
"FG_DIM='\x1b[38;2;108;112;134m' # Catppuccin Mocha overlay0 \u2014 for empty bar portion\n" |
|
"\n" |
|
"# Foreground versions of segment bg colors \u2014 used for powerline arrow transitions\n" |
|
"FG_BLUE='\x1b[38;2;137;180;250m'\n" |
|
"FG_GREEN='\x1b[38;2;166;227;161m'\n" |
|
"FG_YELLOW='\x1b[38;2;249;226;175m'\n" |
|
"FG_MAUVE='\x1b[38;2;203;166;247m'\n" |
|
"FG_TEAL='\x1b[38;2;148;226;213m'\n" |
|
"FG_PEACH='\x1b[38;2;250;179;135m'\n" |
|
"FG_SKY='\x1b[38;2;137;220;235m'\n" |
|
"\n" |
|
"BOLD='\x1b[1m'\n" |
|
"RESET='\x1b[0m'\n" |
|
"\n" |
|
"# Nerd Font powerline glyphs\n" |
|
"SEP='\ue0b0' # U+E0B0 right-arrow: fg=prev_bg, bg=next_bg \u2192 seamless segment join\n" |
|
"CAP_L='\ue0b6' # U+E0B6 left rounded cap: fg=first_seg_bg, bg=terminal\n" |
|
"CAP_R='\ue0b4' # U+E0B4 right rounded cap: fg=last_seg_bg, bg=terminal\n" |
|
"CHIP='\uf2db' # U+F2DB fa-microchip \u2014 model glyph\n" |
|
"BRANCH='\ue0a0' # U+E0A0 Powerline VCS branch glyph\n" |
|
"ROBOT='\uf544' # U+F544 fa-robot \u2014 agent glyph\n" |
|
"FOLDER='\uf07b' # U+F07B fa-folder \u2014 project glyph\n" |
|
"\n" |
|
"# Extract all fields in one jq call\n" |
|
"IFS=$'\\x1f' read -r MODEL DIR PCT COST VIM_MODE DURATION_MS STYLE AGENT TOKENS_IN TOKENS_OUT < <(\n" |
|
" echo \"$input\" | jq -r '[\n" |
|
" (.model.display_name // \"claude\"),\n" |
|
" (.workspace.current_dir // \"\"),\n" |
|
" ((.context_window.used_percentage // 0) | floor | tostring),\n" |
|
" (.cost.total_cost_usd // 0 | tostring),\n" |
|
" (.vim.mode // \"\"),\n" |
|
" (.cost.total_duration_ms // 0 | tostring),\n" |
|
" (.output_style.name // \"default\"),\n" |
|
" (.agent.name // \"\"),\n" |
|
" (.context_window.total_input_tokens // 0 | tostring),\n" |
|
" (.context_window.total_output_tokens // 0 | tostring)\n" |
|
" ] | join(\"\\u001f\")'\n" |
|
")\n" |
|
"\n" |
|
"# Git status \u2014 cached to avoid lag on large repos\n" |
|
"# Key cache by directory so switching projects gets fresh git info\n" |
|
"CACHE_DIR_KEY=$(printf '%s' \"$DIR\" | md5 2>/dev/null || printf '%s' \"$DIR\" | md5sum 2>/dev/null | cut -d' ' -f1)\n" |
|
"CACHE_FILE=\"/tmp/statusline-git-cache-${CACHE_DIR_KEY}\"\n" |
|
"CACHE_MAX_AGE=5 # seconds\n" |
|
"\n" |
|
"cache_is_stale() {\n" |
|
" [ ! -f \"$CACHE_FILE\" ] && return 0\n" |
|
" local age=$(( $(date +%s) - $(stat -f %m \"$CACHE_FILE\" 2>/dev/null || stat -c %Y \"$CACHE_FILE\" 2>/dev/null || echo 0) ))\n" |
|
" [ \"$age\" -gt \"$CACHE_MAX_AGE\" ]\n" |
|
"}\n" |
|
"\n" |
|
"if cache_is_stale; then\n" |
|
" if [ -n \"$DIR\" ] && git -C \"$DIR\" rev-parse --git-dir > /dev/null 2>&1; then\n" |
|
" BRANCH_NAME=$(git -C \"$DIR\" branch --show-current 2>/dev/null)\n" |
|
" STAGED=$(git -C \"$DIR\" diff --cached --numstat 2>/dev/null | wc -l | tr -d ' ')\n" |
|
" MODIFIED=$(git -C \"$DIR\" diff --numstat 2>/dev/null | wc -l | tr -d ' ')\n" |
|
" REPO_NAME=$(basename \"$(git -C \"$DIR\" rev-parse --show-toplevel 2>/dev/null)\")\n" |
|
" printf '1|%s|%s|%s|%s\\n' \"$BRANCH_NAME\" \"$STAGED\" \"$MODIFIED\" \"$REPO_NAME\" > \"$CACHE_FILE\"\n" |
|
" else\n" |
|
" printf '0||||\\n' > \"$CACHE_FILE\"\n" |
|
" fi\n" |
|
"fi\n" |
|
"\n" |
|
"IFS='|' read -r IS_GIT BRANCH_NAME STAGED MODIFIED REPO_NAME < \"$CACHE_FILE\"\n" |
|
"\n" |
|
"# Context bar \u2014 \u2501 (heavy) for filled, \u2500 (light) for empty\n" |
|
"BAR=\"\"\n" |
|
"FILLED=$((PCT * 10 / 100))\n" |
|
"EMPTY=$((10 - FILLED))\n" |
|
"[ \"$FILLED\" -gt 0 ] && BAR=\"${FG_BASE}$(printf \"%${FILLED}s\" | tr ' ' '\u2501')\"\n" |
|
"[ \"$EMPTY\" -gt 0 ] && BAR=\"${BAR}${FG_DIM}$(printf \"%${EMPTY}s\" | tr ' ' '\u2500')\"\n" |
|
"BAR=\"${BAR}${FG_BASE}\"\n" |
|
"BAR_SEG=\"\"\n" |
|
"[ -n \"$BAR\" ] && BAR_SEG=\" ${BAR}\"\n" |
|
"\n" |
|
"# Cost, duration, and token formatting\n" |
|
"COST_FMT=$(awk -v c=\"$COST\" 'BEGIN { printf \"$%.2f\\n\", c+0 }')\n" |
|
"DURATION_FMT=$(awk -v ms=\"$DURATION_MS\" 'BEGIN {\n" |
|
" s = int(ms / 1000); m = int(s / 60); h = int(m / 60)\n" |
|
" if (h > 0) printf \"%dh%dm\", h, m % 60\n" |
|
" else printf \"%dm\", m\n" |
|
"}')\n" |
|
"TOKENS_IN_FMT=$(awk -v n=\"$TOKENS_IN\" 'BEGIN {\n" |
|
" if (n+0 < 1000) printf \"%d\", n+0\n" |
|
" else printf \"%.1fk\", (n+0)/1000\n" |
|
"}')\n" |
|
"TOKENS_OUT_FMT=$(awk -v n=\"$TOKENS_OUT\" 'BEGIN {\n" |
|
" if (n+0 < 1000) printf \"%d\", n+0\n" |
|
" else printf \"%.1fk\", (n+0)/1000\n" |
|
"}')\n" |
|
"\n" |
|
"# Determine git bg/fg colors based on dirty state\n" |
|
"GIT_BG=\"$BG_GREEN\"; GIT_FG=\"$FG_GREEN\"\n" |
|
"if [ \"${IS_GIT:-0}\" = \"1\" ]; then\n" |
|
" GIT_DIRTY=0\n" |
|
" [ \"${STAGED:-0}\" -gt 0 ] || [ \"${MODIFIED:-0}\" -gt 0 ] && GIT_DIRTY=1\n" |
|
" [ \"$GIT_DIRTY\" = \"1\" ] && GIT_BG=\"$BG_YELLOW\" && GIT_FG=\"$FG_YELLOW\"\n" |
|
"fi\n" |
|
"\n" |
|
"# Determine vim bg/fg colors\n" |
|
"VIM_BG=\"$BG_GREEN\"; VIM_FG=\"$FG_GREEN\"\n" |
|
"[ \"$VIM_MODE\" = \"NORMAL\" ] && VIM_BG=\"$BG_YELLOW\" && VIM_FG=\"$FG_YELLOW\"\n" |
|
"\n" |
|
"# Build line \u2014 LAST_FG tracks the previous segment's bg color for seamless transitions\n" |
|
"# and is used for the final right rounded cap\n" |
|
"\n" |
|
"# \u2014 Left rounded cap + Model segment \u2014\n" |
|
"LINE=\"${RESET}${FG_BLUE}${CAP_L}${BG_BLUE}${FG_BASE}${BOLD} ${CHIP} ${MODEL} \"\n" |
|
"LAST_FG=\"$FG_BLUE\"\n" |
|
"\n" |
|
"# \u2014 Project segment (sky) \u2014 repo root basename\n" |
|
"if [ \"${IS_GIT:-0}\" = \"1\" ] && [ -n \"$REPO_NAME\" ]; then\n" |
|
" LINE=\"${LINE}${LAST_FG}${BG_SKY}${SEP}${FG_BASE}${BOLD} ${FOLDER} ${REPO_NAME} \"\n" |
|
" LAST_FG=\"$FG_SKY\"\n" |
|
"fi\n" |
|
"\n" |
|
"if [ \"${IS_GIT:-0}\" = \"1\" ]; then\n" |
|
" GIT_TEXT=\"${BRANCH} ${BRANCH_NAME}\"\n" |
|
" [ \"${STAGED:-0}\" -gt 0 ] && GIT_TEXT=\"${GIT_TEXT} +${STAGED}\"\n" |
|
" [ \"${MODIFIED:-0}\" -gt 0 ] && GIT_TEXT=\"${GIT_TEXT} ~${MODIFIED}\"\n" |
|
" LINE=\"${LINE}${LAST_FG}${GIT_BG}${SEP}${FG_BASE}${BOLD} ${GIT_TEXT} \"\n" |
|
" LAST_FG=\"$GIT_FG\"\n" |
|
"fi\n" |
|
"\n" |
|
"# Context + cost + duration segment\n" |
|
"LINE=\"${LINE}${LAST_FG}${BG_MAUVE}${SEP}${FG_BASE}${BOLD} ${TOKENS_IN_FMT}\u2193 ${TOKENS_OUT_FMT}\u2191${BAR_SEG} ${PCT}% ${COST_FMT} ${DURATION_FMT} \"\n" |
|
"LAST_FG=\"$FG_MAUVE\"\n" |
|
"\n" |
|
"# Output style \u2014 teal pill, hidden when style is \"default\"\n" |
|
"if [ -n \"$STYLE\" ] && [ \"$STYLE\" != \"default\" ]; then\n" |
|
" LINE=\"${LINE}${LAST_FG}${BG_TEAL}${SEP}${FG_BASE}${BOLD} ${STYLE} \"\n" |
|
" LAST_FG=\"$FG_TEAL\"\n" |
|
"fi\n" |
|
"\n" |
|
"# Agent \u2014 peach pill, only shown when --agent flag is active\n" |
|
"if [ -n \"$AGENT\" ]; then\n" |
|
" LINE=\"${LINE}${LAST_FG}${BG_PEACH}${SEP}${FG_BASE}${BOLD} ${ROBOT} ${AGENT} \"\n" |
|
" LAST_FG=\"$FG_PEACH\"\n" |
|
"fi\n" |
|
"\n" |
|
"# Vim mode \u2014 only shown when vim mode is enabled\n" |
|
"if [ -n \"$VIM_MODE\" ]; then\n" |
|
" LINE=\"${LINE}${LAST_FG}${VIM_BG}${SEP}${FG_BASE}${BOLD} ${VIM_MODE} \"\n" |
|
" LAST_FG=\"$VIM_FG\"\n" |
|
"fi\n" |
|
"\n" |
|
"# Right rounded cap using last segment's color\n" |
|
"LINE=\"${LINE}${RESET}${LAST_FG}${CAP_R}${RESET}\"\n" |
|
"\n" |
|
"printf '%b\\n' \"$LINE\"\n" |
|
) |
|
|
|
SETTINGS_PATH = os.path.expanduser("~/.claude/settings.json") |
|
INTERACTIVE = sys.stdin.isatty() |
|
STATUSLINE_CONFIG = { |
|
"type": "command", |
|
"command": "~/.claude/statusline.sh", |
|
} |
|
|
|
|
|
def write_statusline(): |
|
"""Write statusline.sh and make it executable.""" |
|
dest_dir = os.path.dirname(STATUSLINE_PATH) |
|
os.makedirs(dest_dir, exist_ok=True) |
|
|
|
if os.path.exists(STATUSLINE_PATH): |
|
# Back up existing file |
|
backup = STATUSLINE_PATH + ".bak" |
|
shutil.copy2(STATUSLINE_PATH, backup) |
|
print(f" Backed up existing script to {backup}") |
|
|
|
with open(STATUSLINE_PATH, "w", encoding="utf-8") as f: |
|
f.write(STATUSLINE_CONTENT) |
|
|
|
# chmod +x |
|
st = os.stat(STATUSLINE_PATH) |
|
os.chmod(STATUSLINE_PATH, st.st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) |
|
|
|
print(f" Wrote {STATUSLINE_PATH}") |
|
|
|
|
|
def check_jq(): |
|
"""Check if jq is available.""" |
|
if shutil.which("jq"): |
|
print(" jq found") |
|
return True |
|
else: |
|
print(" WARNING: jq not found -- statusline requires jq to parse session JSON") |
|
print(" Install it: brew install jq (macOS) / apt install jq (Debian/Ubuntu)") |
|
return False |
|
|
|
|
|
def configure_settings(): |
|
"""Add statusLine config to ~/.claude/settings.json.""" |
|
settings = {} |
|
if os.path.exists(SETTINGS_PATH): |
|
with open(SETTINGS_PATH, "r", encoding="utf-8") as f: |
|
try: |
|
settings = json.load(f) |
|
except json.JSONDecodeError: |
|
print(f" WARNING: {SETTINGS_PATH} has invalid JSON, backing up") |
|
shutil.copy2(SETTINGS_PATH, SETTINGS_PATH + ".bak") |
|
settings = {} |
|
|
|
if settings.get("statusLine") == STATUSLINE_CONFIG: |
|
print(f" {SETTINGS_PATH} already configured") |
|
return |
|
|
|
if "statusLine" in settings: |
|
print(f" Existing statusLine config in {SETTINGS_PATH}:") |
|
print(f" {json.dumps(settings['statusLine'])}") |
|
answer = input(" Overwrite with powerline statusline? [y/N] ").strip().lower() if INTERACTIVE else "" |
|
if answer not in ("y", "yes"): |
|
print(" Skipped settings.json update") |
|
return |
|
|
|
settings["statusLine"] = STATUSLINE_CONFIG |
|
|
|
os.makedirs(os.path.dirname(SETTINGS_PATH), exist_ok=True) |
|
with open(SETTINGS_PATH, "w", encoding="utf-8") as f: |
|
json.dump(settings, f, indent=2) |
|
f.write("\n") |
|
|
|
print(f" Configured statusLine in {SETTINGS_PATH}") |
|
|
|
|
|
def check_nerd_font(): |
|
"""Check if a Nerd Font is installed; offer to install one if not. |
|
|
|
Nerd Fonts patch popular programming fonts with thousands of extra glyphs |
|
(icons, powerline symbols, devicons). The statusline uses these glyphs for |
|
segment separators and icons. Without a Nerd Font configured in your |
|
terminal, those characters render as boxes or question marks. |
|
""" |
|
found = False |
|
|
|
if shutil.which("fc-list"): |
|
# Linux / macOS with fontconfig |
|
try: |
|
result = subprocess.run( |
|
["fc-list", ":family"], |
|
capture_output=True, text=True, timeout=5, |
|
) |
|
if "Nerd" in result.stdout: |
|
found = True |
|
except (subprocess.TimeoutExpired, FileNotFoundError): |
|
pass |
|
|
|
if not found and platform.system() == "Darwin": |
|
# macOS fallback -- check ~/Library/Fonts and system font dirs |
|
font_dirs = [ |
|
os.path.expanduser("~/Library/Fonts"), |
|
"/Library/Fonts", |
|
"/System/Library/Fonts", |
|
] |
|
for d in font_dirs: |
|
if os.path.isdir(d): |
|
for name in os.listdir(d): |
|
if "Nerd" in name or "NerdFont" in name: |
|
found = True |
|
break |
|
if found: |
|
break |
|
|
|
if found: |
|
print(" Nerd Font detected") |
|
return True |
|
|
|
print() |
|
print(" WARNING: No Nerd Font detected.") |
|
print(" The statusline uses Nerd Font glyphs (powerline arrows, icons).") |
|
print(" Without one, those characters will render as boxes or blanks.") |
|
print() |
|
|
|
# Only offer auto-install on macOS with Homebrew |
|
if platform.system() == "Darwin" and shutil.which("brew"): |
|
answer = input(" Install JetBrainsMono Nerd Font via Homebrew? [y/N] ").strip().lower() if INTERACTIVE else "" |
|
if answer in ("y", "yes"): |
|
print(" Installing font-jetbrains-mono-nerd-font...") |
|
subprocess.run(["brew", "install", "--cask", "font-jetbrains-mono-nerd-font"]) |
|
print() |
|
print(" Font installed. You still need to configure your terminal to use it:") |
|
print(" Ghostty: font-family = JetBrainsMono Nerd Font") |
|
print(" iTerm2: Profiles > Text > Font > JetBrainsMono Nerd Font") |
|
print(" Kitty: font_family JetBrainsMono Nerd Font") |
|
print(" WezTerm: font = wezterm.font('JetBrainsMono Nerd Font')") |
|
return True |
|
else: |
|
print(" Skipped. To install manually:") |
|
else: |
|
print(" To install a Nerd Font:") |
|
|
|
print(" macOS: brew install --cask font-jetbrains-mono-nerd-font") |
|
print(" Linux: https://www.nerdfonts.com/font-downloads") |
|
print(" Then set your terminal's font to the installed Nerd Font.") |
|
return False |
|
|
|
|
|
def main(): |
|
print("Claude Code Powerline Statusline -- Catppuccin Mocha") |
|
print() |
|
check_nerd_font() |
|
print() |
|
write_statusline() |
|
check_jq() |
|
configure_settings() |
|
print() |
|
print(" Done! Restart Claude Code to see the new statusline.") |
|
|
|
|
|
if __name__ == "__main__": |
|
main() |