Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions install.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
#Requires -Version 5.1
<#
.SYNOPSIS
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
registers the statusline in Claude Code's settings.json.
#>
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"

$REPO = "stephenleo/cship"
$INSTALL_DIR = Join-Path $env:LOCALAPPDATA "Programs\cship"
$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"

# --- Arch detection ---
$arch = $env:PROCESSOR_ARCHITECTURE
if ($arch -eq "AMD64") {
$TARGET = "x86_64-pc-windows-msvc"
} elseif ($arch -eq "ARM64") {
$TARGET = "aarch64-pc-windows-msvc"
} else {
Write-Error "Unsupported architecture: $arch"
exit 1
}

# --- Fetch latest release tag ---
Write-Host "Fetching latest cship release..."
$releaseUrl = "https://api.github.com/repos/$REPO/releases/latest"
$release = Invoke-RestMethod -Uri $releaseUrl -UseBasicParsing
$tag = $release.tag_name
$assetName = "cship-$TARGET.exe"
$downloadUrl = $release.assets |
Where-Object { $_.name -eq $assetName } |
Select-Object -ExpandProperty browser_download_url

if (-not $downloadUrl) {
Write-Error "Asset '$assetName' not found in release $tag. Available assets:`n$($release.assets.name -join "`n")"
exit 1
}

# --- Download ---
Write-Host "Downloading $assetName ($tag)..."
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) ---
$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)."
}
}

# --- Write default cship.toml ---
if (-not (Test-Path $CONFIG_FILE)) {
New-Item -ItemType Directory -Force -Path $CONFIG_DIR | Out-Null
@'
[cship]
lines = ["$cship.model $cship.cost"]

[cship.model]
disabled = false

[cship.cost]
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."
}

# --- 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 "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."
} else {
$json = Get-Content $SETTINGS -Raw | ConvertFrom-Json
if (-not $json.PSObject.Properties["statusline"]) {
$json | Add-Member -NotePropertyName "statusline" -NotePropertyValue "cship"
} else {
$json.statusline = "cship"
}
$json | ConvertTo-Json -Depth 10 | Set-Content -Path $SETTINGS -Encoding UTF8
Write-Host "Updated settings.json with statusline entry."
}

# --- First-run preview ---
Write-Host ""
Write-Host "Running 'cship explain' as a first-run preview..."
& $BIN explain

Write-Host ""
Write-Host "cship $tag installed successfully."
Write-Host "Restart Claude Code for the statusline to take effect."
10 changes: 3 additions & 7 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -289,10 +289,8 @@ pub fn load_with_source(
}

// Step 3: Global fallback — check ~/.config/cship.toml before ~/.config/starship.toml
if let Ok(home) = std::env::var("HOME") {
let cship_global = std::path::Path::new(&home)
.join(".config")
.join("cship.toml");
if let Some(home) = crate::platform::home_dir() {
let cship_global = home.join(".config").join("cship.toml");
if cship_global.exists() {
let config = load_cship_toml(&cship_global).unwrap_or_else(|e| {
tracing::warn!("cship: failed to load global dedicated config: {e}");
Expand All @@ -303,9 +301,7 @@ pub fn load_with_source(
source: ConfigSource::DedicatedFile(cship_global),
};
}
let global = std::path::Path::new(&home)
.join(".config")
.join("starship.toml");
let global = home.join(".config").join("starship.toml");
if global.exists() {
let config = load_from_path(&global).unwrap_or_else(|e| {
tracing::warn!("cship: failed to load global config: {e}");
Expand Down
8 changes: 5 additions & 3 deletions src/passthrough.rs
Original file line number Diff line number Diff line change
Expand Up @@ -355,18 +355,20 @@ mod tests {
assert!(result.is_none());
}

// Unix-only: faking a `starship` binary requires a +x shell script, which has no
// simple equivalent on Windows (Command::new resolves only .exe, not .cmd/.bat).
// The env-injection logic itself (cmd.env) is platform-independent.
#[cfg(unix)]
#[test]
fn test_render_passthrough_injects_cship_model_env_var() {
use std::fs;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;

let dir = std::env::temp_dir().join("cship_test_cship_env");
fs::create_dir_all(&dir).unwrap();

let script = dir.join("starship");
// Script: print CSHIP_MODEL env var, exit 0
fs::write(&script, "#!/bin/sh\nprintf '%s' \"$CSHIP_MODEL\"\n").unwrap();
#[cfg(unix)]
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();

let _guard = PATH_MUTEX.lock().unwrap();
Expand Down
46 changes: 40 additions & 6 deletions src/platform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,19 +92,47 @@ pub fn get_oauth_token() -> Result<String, String> {
/// Returns `None` if the file is absent, unreadable, or malformed.
#[cfg(target_os = "linux")]
fn read_credentials_file() -> Option<String> {
let home = std::env::var("HOME").ok()?;
let path = std::path::Path::new(&home)
.join(".claude")
.join(".credentials.json");
let home = home_dir()?;
let path = home.join(".claude").join(".credentials.json");
let contents = std::fs::read_to_string(&path).ok()?;
extract_access_token(contents.trim())
}

#[cfg(not(any(target_os = "macos", target_os = "linux")))]
compile_error!("cship: get_oauth_token() is only supported on macOS and Linux");
/// Returns the user's effective home directory, checked in priority order:
/// 1. `CLAUDE_HOME` env var (explicit override for non-standard installs, all platforms)
/// 2. `HOME` env var (Unix standard; also set by Git Bash / WSL on Windows)
/// 3. `USERPROFILE` env var (Windows native; Claude Code stores .claude here)
pub(crate) fn home_dir() -> Option<std::path::PathBuf> {
for var in ["CLAUDE_HOME", "HOME", "USERPROFILE"] {
if let Ok(h) = std::env::var(var)
&& !h.is_empty()
{
return Some(std::path::PathBuf::from(h));
}
}
None
}

#[cfg(target_os = "windows")]
pub fn get_oauth_token() -> Result<String, String> {
let home = home_dir().ok_or_else(|| {
"Cannot locate home directory — set CLAUDE_HOME to your .claude folder parent".to_string()
})?;
let path = home.join(".claude").join(".credentials.json");
let contents = std::fs::read_to_string(&path).map_err(|_| {
format!(
"Claude Code credentials not found at {} — authenticate in Claude Code first",
path.display()
)
})?;
extract_access_token(contents.trim()).ok_or_else(|| {
"Claude Code credentials found but access token could not be parsed — credential may be malformed".into()
})
}

/// Inner implementation with injectable command name for testability.
/// `tool` is the binary; `args` are the arguments passed to it.
#[cfg(not(target_os = "windows"))]
fn get_oauth_token_with_cmd(tool: &str, args: &[&str]) -> Result<String, String> {
use std::process::Command;

Expand Down Expand Up @@ -151,6 +179,7 @@ fn extract_access_token(json: &str) -> Option<String> {
}

/// Return the platform-specific install hint for a missing credential tool.
#[cfg(not(target_os = "windows"))]
fn install_hint(tool: &str) -> String {
match tool {
"secret-tool" => {
Expand All @@ -171,6 +200,7 @@ mod tests {

// These tests exercise the Linux path only; macOS path is validated by code review.

#[cfg(not(target_os = "windows"))]
#[test]
fn test_tool_not_found_returns_install_hint() {
// A non-existent binary triggers io::ErrorKind::NotFound on spawn.
Expand All @@ -184,6 +214,7 @@ mod tests {
);
}

#[cfg(not(target_os = "windows"))]
#[test]
fn test_nonzero_exit_returns_credential_not_found_error() {
// `/bin/sh -c "exit 1"` always exits with code 1 — simulates "credential not found".
Expand All @@ -197,6 +228,7 @@ mod tests {
);
}

#[cfg(not(target_os = "windows"))]
#[test]
fn test_successful_token_extraction() {
// Use `/bin/sh` (absolute path, present on both macOS and Linux) to emit a JSON blob.
Expand Down Expand Up @@ -255,12 +287,14 @@ mod tests {
assert_eq!(result, Some("sk-ant-file-token".to_string()));
}

#[cfg(not(target_os = "windows"))]
#[test]
fn test_install_hint_secret_tool() {
let hint = install_hint("secret-tool");
assert!(hint.contains("sudo apt install libsecret-tools"), "{hint}");
}

#[cfg(not(target_os = "windows"))]
#[test]
fn test_install_hint_security() {
let hint = install_hint("security");
Expand Down
38 changes: 28 additions & 10 deletions src/uninstall.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,27 @@
//! Intentionally leaves `[cship.*]` sections in `starship.toml` intact (FR44).

pub fn run() {
let home = match std::env::var("HOME") {
Ok(h) if !h.is_empty() => h,
_ => {
println!("HOME is not setcannot determine paths. Aborting uninstall.");
let home = match crate::platform::home_dir() {
Some(h) => h,
None => {
println!("Cannot determine home directoryset CLAUDE_HOME. Aborting uninstall.");
return;
}
};
let home = std::path::PathBuf::from(home);
remove_binary(&home);
remove_statusline_from_settings(&home);
remove_cache_directories(&home);
}

fn remove_binary(home: &std::path::Path) {
for bin in [home.join(".local/bin/cship"), home.join(".cargo/bin/cship")] {
#[cfg(not(target_os = "windows"))]
let candidates = [home.join(".local/bin/cship"), home.join(".cargo/bin/cship")];
#[cfg(target_os = "windows")]
let candidates = [
home.join(".cargo/bin/cship.exe"),
home.join(r".local\bin\cship.exe"),
];
for bin in candidates {
if bin.exists() {
match std::fs::remove_file(&bin) {
Ok(()) => println!("Removed: {}", bin.display()),
Expand Down Expand Up @@ -109,14 +115,20 @@ mod tests {
#[test]
fn test_remove_binary_present() {
with_tempdir(|home| {
let bin_name = if cfg!(target_os = "windows") {
"cship.exe"
} else {
"cship"
};

let local_bin = home.join(".local/bin");
std::fs::create_dir_all(&local_bin).unwrap();
let local_path = local_bin.join("cship");
let local_path = local_bin.join(bin_name);
std::fs::write(&local_path, b"fake binary").unwrap();

let cargo_bin = home.join(".cargo/bin");
std::fs::create_dir_all(&cargo_bin).unwrap();
let cargo_path = cargo_bin.join("cship");
let cargo_path = cargo_bin.join(bin_name);
std::fs::write(&cargo_path, b"fake binary").unwrap();

remove_binary(home);
Expand Down Expand Up @@ -223,9 +235,15 @@ mod tests {
#[test]
fn test_run_with_empty_home_does_not_panic() {
let _guard = HOME_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
// SAFETY: guarded by HOME_MUTEX; no other threads read HOME concurrently.
unsafe { std::env::set_var("HOME", "") };
// SAFETY: guarded by HOME_MUTEX; no other threads read these env vars concurrently.
unsafe {
std::env::set_var("HOME", "");
std::env::set_var("USERPROFILE", "");
std::env::set_var("CLAUDE_HOME", "");
};
// Should print message and return, not panic or touch root paths
run();
// Restore to avoid poisoning other tests
unsafe { std::env::remove_var("CLAUDE_HOME") };
}
}
5 changes: 3 additions & 2 deletions tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -824,20 +824,21 @@ fn test_native_renders_alongside_passthrough_not_installed() {

// ── Story 4.2: Cache and CSHIP_* env var integration tests ────────────────

// Unix-only: faking a `starship` binary requires a +x shell script, which has no
// simple equivalent on Windows (Command::new resolves only .exe, not .cmd/.bat).
#[cfg(unix)]
#[test]
fn test_passthrough_env_vars_injected_via_cship_model() {
// Create a fake starship script that echoes $CSHIP_MODEL to stdout.
// Uses .env("PATH", ...) on the cship subprocess rather than mutating
// the test process's global PATH — safe for parallel test execution.
use std::fs;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;

let dir = std::env::temp_dir().join("cship_inttest_cship_env");
fs::create_dir_all(&dir).unwrap();
let script = dir.join("starship");
fs::write(&script, "#!/bin/sh\nprintf '%s' \"$CSHIP_MODEL\"\n").unwrap();
#[cfg(unix)]
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();

let json = r#"{"session_id":"test","cwd":"/tmp","transcript_path":"/tmp/cship_inttest_tp.jsonl","version":"1.0","exceeds_200k_tokens":false,"model":{"id":"claude-opus-4-6","display_name":"IntTestModel"},"workspace":{"current_dir":"/tmp","project_dir":"/tmp"},"output_style":{"name":"default"},"cost":{"total_cost_usd":0.0}}"#;
Expand Down
Loading