From 30f06e143449037c8045e9a0be4cc3a01d54cb7b Mon Sep 17 00:00:00 2001 From: stephenleo Date: Sat, 28 Mar 2026 21:24:50 +0800 Subject: [PATCH] fix(installer): fix Windows installer bugs found during live testing - Replace em dashes with ASCII hyphens to prevent PowerShell 5.1 encoding bug (UTF-8 bytes misread as Windows-1252 curly quotes, breaking elseif syntax) - Change install location from %LOCALAPPDATA%\Programs\cship\ to ~/.local/bin so the binary is on Git Bash PATH without session restart - Fix settings.json path: was %APPDATA%\Claude\settings.json, correct path is ~/.claude/settings.json on all platforms including Windows - Fix statusLine format: was "statusline":"cship", correct format is "statusLine":{"type":"command","command":"cship"} - Auto-create ~/.local/bin and silently add to user PATH if missing - Show full settings.json path in all installer/uninstaller messages - Remove Windows-specific APPDATA branch from uninstall.rs (now uses same home-relative path as all other platforms) - Update README, docs, explain.rs hints, and install.sh manual hint Co-Authored-By: Claude Sonnet 4.6 --- README.md | 2 +- docs/index.md | 2 +- install.ps1 | 42 +++++++++++++-------------- install.sh | 2 +- src/explain.rs | 8 +++--- src/uninstall.rs | 74 ++++++++---------------------------------------- 6 files changed, 39 insertions(+), 91 deletions(-) diff --git a/README.md b/README.md index d7a6d40..10bf184 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ Run this one-liner in PowerShell (5.1 or later): irm https://cship.dev/install.ps1 | iex ``` -Installs to `%LOCALAPPDATA%\Programs\cship\cship.exe`, writes config to `%USERPROFILE%\.config\cship.toml`, and registers the statusline in `%APPDATA%\Claude\settings.json`. +Installs to `%USERPROFILE%\.local\bin\cship.exe`, writes config to `%USERPROFILE%\.config\cship.toml`, and registers the statusline in `%USERPROFILE%\.claude\settings.json`. > You can inspect the script before running: [install.ps1](https://cship.dev/install.ps1) diff --git a/docs/index.md b/docs/index.md index 1e15018..9c750b5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -54,7 +54,7 @@ Run this one-liner in PowerShell (5.1 or later): irm https://cship.dev/install.ps1 | iex ``` -Installs to `%LOCALAPPDATA%\Programs\cship\cship.exe`, writes config to `%USERPROFILE%\.config\cship.toml`, and registers the statusline in `%APPDATA%\Claude\settings.json`. +Installs to `%USERPROFILE%\.local\bin\cship.exe`, writes config to `%USERPROFILE%\.config\cship.toml`, and registers the statusline in `%USERPROFILE%\.claude\settings.json`. > You can inspect the script before running: [install.ps1](https://cship.dev/install.ps1) diff --git a/install.ps1 b/install.ps1 index 7b68f13..47d6a0e 100644 --- a/install.ps1 +++ b/install.ps1 @@ -1,7 +1,7 @@ #Requires -Version 5.1 <# .SYNOPSIS - Install cship — Claude Code statusline tool for Windows. + Install cship - Claude Code statusline tool for Windows. .DESCRIPTION Downloads the cship binary from GitHub Releases, installs it to %LOCALAPPDATA%\Programs\cship\, writes a default cship.toml, and @@ -11,11 +11,11 @@ Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" $REPO = "stephenleo/cship" -$INSTALL_DIR = Join-Path $env:LOCALAPPDATA "Programs\cship" +$INSTALL_DIR = Join-Path $env:USERPROFILE ".local\bin" $BIN = Join-Path $INSTALL_DIR "cship.exe" $CONFIG_DIR = Join-Path $env:USERPROFILE ".config" $CONFIG_FILE = Join-Path $CONFIG_DIR "cship.toml" -$SETTINGS = Join-Path $env:APPDATA "Claude\settings.json" +$SETTINGS = Join-Path $env:USERPROFILE ".claude\settings.json" # --- Arch detection --- $arch = $env:PROCESSOR_ARCHITECTURE @@ -53,19 +53,16 @@ New-Item -ItemType Directory -Force -Path $INSTALL_DIR | Out-Null Invoke-WebRequest -Uri $downloadUrl -OutFile $BIN -UseBasicParsing Write-Host "Installed to: $BIN" -# --- Add to PATH (offer) --- +# --- Ensure ~/.local/bin is on PATH --- $currentPath = [Environment]::GetEnvironmentVariable("PATH", "User") if ($currentPath -notlike "*$INSTALL_DIR*") { - $add = Read-Host "Add $INSTALL_DIR to your PATH? [Y/n]" - if ($add -ne "n" -and $add -ne "N") { - [Environment]::SetEnvironmentVariable( - "PATH", - "$currentPath;$INSTALL_DIR", - "User" - ) - $env:PATH += ";$INSTALL_DIR" - Write-Host "Added to PATH (effective in new shells)." - } + [Environment]::SetEnvironmentVariable( + "PATH", + "$currentPath;$INSTALL_DIR", + "User" + ) + $env:PATH += ";$INSTALL_DIR" + Write-Host "Added $INSTALL_DIR to your user PATH (effective in new shells)." } # --- Write default cship.toml --- @@ -83,28 +80,29 @@ disabled = false '@ | Set-Content -Path $CONFIG_FILE -Encoding UTF8 Write-Host "Config written to: $CONFIG_FILE" } else { - Write-Host "Config already exists at $CONFIG_FILE — skipping." + Write-Host "Config already exists at $CONFIG_FILE - skipping." } # --- Register statusline in Claude Code settings.json --- $claudeDir = Split-Path $SETTINGS if (-not (Test-Path $claudeDir)) { - Write-Host "Claude Code settings directory not found at $claudeDir — skipping settings update." + Write-Host "Claude Code settings directory not found at $claudeDir - skipping settings update." Write-Host "Authenticate in Claude Code first, then re-run this script." } elseif (-not (Test-Path $SETTINGS)) { # Create minimal settings.json New-Item -ItemType Directory -Force -Path $claudeDir | Out-Null - '{"statusline": "cship"}' | Set-Content -Path $SETTINGS -Encoding UTF8 - Write-Host "Created settings.json with statusline entry." + '{"statusLine": {"type": "command", "command": "cship"}}' | Set-Content -Path $SETTINGS -Encoding UTF8 + Write-Host "Created settings.json with statusLine entry: $SETTINGS" } else { $json = Get-Content $SETTINGS -Raw | ConvertFrom-Json - if (-not $json.PSObject.Properties["statusline"]) { - $json | Add-Member -NotePropertyName "statusline" -NotePropertyValue "cship" + $statusLineValue = [PSCustomObject]@{ type = "command"; command = "cship" } + if (-not $json.PSObject.Properties["statusLine"]) { + $json | Add-Member -NotePropertyName "statusLine" -NotePropertyValue $statusLineValue } else { - $json.statusline = "cship" + $json.statusLine = $statusLineValue } $json | ConvertTo-Json -Depth 100 | Set-Content -Path $SETTINGS -Encoding UTF8 - Write-Host "Updated settings.json with statusline entry." + Write-Host "Updated settings.json with statusLine entry: $SETTINGS" } # --- First-run preview --- diff --git a/install.sh b/install.sh index b5ceaf1..79d5b0c 100644 --- a/install.sh +++ b/install.sh @@ -116,7 +116,7 @@ fi SETTINGS="$ROOT/.claude/settings.json" if ! command -v python3 >/dev/null 2>&1; then echo "Warning: python3 not found. Skipping settings.json update." - echo "To wire cship manually, add \"statusline\": \"cship\" to $SETTINGS" + echo "To wire cship manually, add \"statusLine\": {\"type\": \"command\", \"command\": \"cship\"} to $SETTINGS" elif [ -f "$SETTINGS" ]; then python3 - "$SETTINGS" <<'PYEOF' || echo "Warning: failed to update settings.json — add statusLine manually." import json, sys diff --git a/src/explain.rs b/src/explain.rs index e703d99..528688f 100644 --- a/src/explain.rs +++ b/src/explain.rs @@ -204,11 +204,11 @@ fn error_hint_for( match segment { "model" => ( "model data absent from Claude Code context".into(), - "Ensure Claude Code is running and cship is invoked via the \"statusline\" key in ~/.claude/settings.json.".into(), + "Ensure Claude Code is running and cship is wired via \"statusLine\": {\"type\": \"command\", \"command\": \"cship\"} in ~/.claude/settings.json.".into(), ), "cost" => ( "cost data absent from Claude Code context".into(), - "Ensure Claude Code is running and cship is invoked via the \"statusline\" key in ~/.claude/settings.json.".into(), + "Ensure Claude Code is running and cship is wired via \"statusLine\": {\"type\": \"command\", \"command\": \"cship\"} in ~/.claude/settings.json.".into(), ), "context_bar" | "context_window" => ( "context_window data absent from Claude Code context (may be absent early in a session)".into(), @@ -224,11 +224,11 @@ fn error_hint_for( ), "cwd" | "session_id" | "transcript_path" | "version" | "output_style" => ( "session field absent from Claude Code context".into(), - "Ensure Claude Code is running and cship is invoked via the \"statusline\" key in ~/.claude/settings.json.".into(), + "Ensure Claude Code is running and cship is wired via \"statusLine\": {\"type\": \"command\", \"command\": \"cship\"} in ~/.claude/settings.json.".into(), ), "workspace" => ( "workspace data absent from Claude Code context".into(), - "Ensure Claude Code is running and cship is invoked via the \"statusline\" key in ~/.claude/settings.json.".into(), + "Ensure Claude Code is running and cship is wired via \"statusLine\": {\"type\": \"command\", \"command\": \"cship\"} in ~/.claude/settings.json.".into(), ), "usage_limits" => { // Probe credential state to distinguish missing token from expired token. diff --git a/src/uninstall.rs b/src/uninstall.rs index ef5c363..c37c665 100644 --- a/src/uninstall.rs +++ b/src/uninstall.rs @@ -52,53 +52,40 @@ fn remove_binary(home: &std::path::Path) { } fn remove_statusline_from_settings(home: &std::path::Path) { - #[cfg(target_os = "windows")] - let path = match std::env::var("APPDATA") { - Ok(app_data) => std::path::Path::new(&app_data) - .join("Claude") - .join("settings.json"), - Err(_) => { - tracing::warn!( - "APPDATA env var not set; falling back to ~/.claude/settings.json for settings path" - ); - home.join(".claude/settings.json") - } - }; - #[cfg(not(target_os = "windows"))] let path = home.join(".claude/settings.json"); if !path.exists() { - println!("settings.json not found — skipping."); + println!("settings.json not found at {} — skipping.", path.display()); return; } let raw = match std::fs::read_to_string(&path) { Ok(s) => s, Err(e) => { - println!("Could not read settings.json: {e}"); + println!("Could not read {}: {e}", path.display()); return; } }; let mut map: serde_json::Map = match serde_json::from_str(&raw) { Ok(m) => m, Err(e) => { - println!("Could not parse settings.json: {e}"); + println!("Could not parse {}: {e}", path.display()); return; } }; - if map.remove("statusline").is_some() { + if map.remove("statusLine").is_some() { let updated = match serde_json::to_string_pretty(&map) { Ok(s) => s, Err(e) => { - println!("Could not serialize settings.json: {e}"); + println!("Could not serialize {}: {e}", path.display()); return; } }; match std::fs::write(&path, updated + "\n") { - Ok(()) => println!("Removed \"statusline\" from settings.json"), - Err(e) => println!("Could not write settings.json: {e}"), + Ok(()) => println!("Removed \"statusLine\" from {}", path.display()), + Err(e) => println!("Could not write {}: {e}", path.display()), } } else { - println!("\"statusline\" not found in settings.json — skipping."); + println!("\"statusLine\" not found in {} — skipping.", path.display()); } } @@ -211,15 +198,15 @@ mod tests { let settings_path = claude_dir.join("settings.json"); std::fs::write( &settings_path, - r#"{"statusline":"cship","otherKey":"value"}"#, + r#"{"statusLine":{"type":"command","command":"cship"},"otherKey":"value"}"#, ) .unwrap(); remove_statusline_from_settings(home); let content = std::fs::read_to_string(&settings_path).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&content).unwrap(); assert!( - parsed.get("statusline").is_none(), - "statusline key should be removed" + parsed.get("statusLine").is_none(), + "statusLine key should be removed" ); assert_eq!( parsed.get("otherKey").and_then(|v| v.as_str()), @@ -229,43 +216,6 @@ mod tests { }); } - #[test] - #[cfg(target_os = "windows")] - fn test_remove_statusline_uses_appdata_on_windows() { - with_tempdir(|home| { - // Create a temp APPDATA directory with Claude/settings.json - let tmp_appdata = tempfile::tempdir().unwrap(); - let claude_dir = tmp_appdata.path().join("Claude"); - std::fs::create_dir_all(&claude_dir).unwrap(); - let settings_path = claude_dir.join("settings.json"); - std::fs::write( - &settings_path, - r#"{"statusline":"cship","otherKey":"value"}"#, - ) - .unwrap(); - - // SAFETY: guarded by HOME_MUTEX; no other threads read APPDATA concurrently. - unsafe { std::env::set_var("APPDATA", tmp_appdata.path()) }; - - remove_statusline_from_settings(home); - - let content = std::fs::read_to_string(&settings_path).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&content).unwrap(); - assert!( - parsed.get("statusline").is_none(), - "statusline key should be removed from APPDATA path on Windows" - ); - assert_eq!( - parsed.get("otherKey").and_then(|v| v.as_str()), - Some("value"), - "other keys should be preserved" - ); - - // SAFETY: guarded by HOME_MUTEX; no other threads read APPDATA concurrently. - unsafe { std::env::remove_var("APPDATA") }; - }); - } - #[test] fn test_remove_statusline_absent_key() { with_tempdir(|home| { @@ -278,7 +228,7 @@ mod tests { // File should still be parseable and unchanged in content let content = std::fs::read_to_string(&settings_path).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&content).unwrap(); - assert!(parsed.get("statusline").is_none()); + assert!(parsed.get("statusLine").is_none()); assert_eq!( parsed.get("otherKey").and_then(|v| v.as_str()), Some("value")