cship 1.7.0

Beautiful, Blazing-fast, Customizable Claude Code Statusline
Documentation
/// Retrieve the Claude Code OAuth token from the OS credential store.
/// Returns Err with a descriptive message if the token is not found or the
/// credential tool is not installed.
///
/// Token is held only as a local String for the duration of the API call —
/// never written to disk, cache, stdout, or stderr (NFR-S1).
///
/// The credential store holds a JSON blob of the form:
/// `{"claudeAiOauth":{"accessToken":"sk-ant-oat01-...","refreshToken":"...","expiresAt":...}}`
/// This function extracts `accessToken` from that blob.
///
/// # Credential lookup by OS
///
/// - **macOS**: reads from the system Keychain using
///   `security find-generic-password -s "Claude Code-credentials" -w`.
///   The service name `"Claude Code-credentials"` is verified against a live
///   Claude Code credential store.
///
/// - **Linux**: attempts `~/.claude/.credentials.json` first (the path Claude
///   Code uses on Linux and WSL2, where gnome-keyring / D-Bus is typically
///   unavailable). Falls back to
///   `secret-tool lookup service "Claude Code-credentials"` when the file is
///   absent.
///
/// # Error cases
///
/// The function returns `Err(String)` in these situations:
///
/// - **Credential tool not found**: the OS binary (`security` on macOS,
///   `secret-tool` on Linux) was not found on `PATH` — returns a
///   platform-specific install hint. On Linux this only triggers after
///   `~/.claude/.credentials.json` is also absent.
///
/// - **Credential not found** (subprocess exits non-zero, or credentials
///   file absent with no secret-tool fallback): returns
///   `"Claude Code credentials not found — authenticate in Claude Code first"`.
///
/// - **Malformed credential**: subprocess exits 0 but the JSON blob does not
///   contain `claudeAiOauth.accessToken` (or the token string is empty):
///   returns `"Claude Code credentials found but access token could not be
///   parsed — credential may be malformed"`.
///
/// # Token expiry
///
/// `get_oauth_token` cannot detect whether the returned token is expired or
/// has been revoked by the API. It extracts whatever is stored in the OS
/// credential store. Token expiry manifests as HTTP 401 from the Anthropic
/// API. Callers that need to distinguish "no token" from "expired token"
/// should probe the `Ok`/`Err` result: `Ok(_)` means a token was found (may
/// still be expired); `Err` containing `"not found"` means no credential is
/// stored.
///
/// Service name verified against live Claude Code credential store: "Claude Code-credentials"
/// Reference: <https://codelynx.dev/posts/claude-code-usage-limits-statusline>
#[cfg(target_os = "macos")]
pub fn get_oauth_token() -> Result<String, String> {
    // CI gap: macOS path only compiled on target_os = "macos".
    // Validated by code review only — cannot be tested in WSL2/ubuntu CI.
    //
    // `security find-generic-password -s <service> -w` prints the stored password
    // (the JSON blob) to stdout and exits 0 on success, non-zero if not found.
    // No `-a <account>` flag is needed — the service name alone is unique.
    get_oauth_token_with_cmd(
        "security",
        &[
            "find-generic-password",
            "-s",
            "Claude Code-credentials",
            "-w",
        ],
    )
}

#[cfg(target_os = "linux")]
pub fn get_oauth_token() -> Result<String, String> {
    // Primary path: read ~/.claude/.credentials.json directly.
    // Claude Code on Linux (including WSL2) stores credentials as a JSON file at this
    // well-known path. This avoids requiring a running gnome-keyring / D-Bus session,
    // which is unavailable in WSL2 by default.
    if let Some(token) = read_credentials_file() {
        return Ok(token);
    }

    // Fallback: secret-tool (for users with a running gnome-keyring).
    get_oauth_token_with_cmd(
        "secret-tool",
        &["lookup", "service", "Claude Code-credentials"],
    )
}

/// Read `~/.claude/.credentials.json` and extract the OAuth access token.
/// Returns `None` if the file is absent, unreadable, or malformed.
#[cfg(target_os = "linux")]
fn read_credentials_file() -> Option<String> {
    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())
}

/// 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)
///
/// # Note on `CLAUDE_HOME`
///
/// `CLAUDE_HOME` must point to the *parent* of `.claude`, not to `.claude` itself.
/// e.g. `CLAUDE_HOME=/home/user` (the parent of `.claude`, not `.claude` itself)
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()
    })
}

#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
compile_error!("cship: get_oauth_token() is only supported on macOS, Linux, and Windows");

/// 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;

    let mut cmd = Command::new(tool);
    cmd.args(args)
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::null());

    let child = match cmd.spawn() {
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
            return Err(install_hint(tool));
        }
        Err(e) => return Err(format!("failed to invoke {tool}: {e}")),
        Ok(child) => child,
    };

    let output = match child.wait_with_output() {
        Err(e) => return Err(format!("failed to wait for {tool}: {e}")),
        Ok(o) => o,
    };

    if !output.status.success() {
        return Err("Claude Code credentials not found — authenticate in Claude Code first".into());
    }

    let raw = String::from_utf8_lossy(&output.stdout);
    let raw = raw.trim();

    // The credential store holds a JSON blob; extract the access token.
    extract_access_token(raw).ok_or_else(|| {
        "Claude Code credentials found but access token could not be parsed — credential may be malformed".into()
    })
}

/// Parse `{"claudeAiOauth":{"accessToken":"...","refreshToken":"...",...}}` and return the token.
fn extract_access_token(json: &str) -> Option<String> {
    let v: serde_json::Value = serde_json::from_str(json).ok()?;
    let token = v
        .get("claudeAiOauth")?
        .get("accessToken")?
        .as_str()?
        .to_string();
    if token.is_empty() { None } else { Some(token) }
}

/// 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" => {
            "secret-tool not found — install with: sudo apt install libsecret-tools".into()
        }
        "security" => {
            // Rare on macOS; `security` ships with the OS.
            "security command not found — reinstall macOS command line tools: xcode-select --install"
                .into()
        }
        other => format!("{other} not found"),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    // 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.
        let result =
            get_oauth_token_with_cmd("cship_nonexistent_tool_xyz", &["lookup", "service", "test"]);
        assert!(result.is_err());
        let msg = result.unwrap_err();
        assert!(
            msg.contains("not found"),
            "expected 'not found' in error: {msg}"
        );
    }

    #[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".
        // Uses absolute path so the test is independent of PATH on any CI runner.
        let result = get_oauth_token_with_cmd("/bin/sh", &["-c", "exit 1"]);
        assert!(result.is_err());
        let msg = result.unwrap_err();
        assert!(
            msg.contains("authenticate in Claude Code first"),
            "expected credential-not-found hint in error: {msg}"
        );
    }

    #[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.
        let json = r#"{"claudeAiOauth":{"accessToken":"sk-ant-test-token","refreshToken":"rt","expiresAt":9999}}"#;
        let script = format!("printf '%s' '{json}'");
        let result = get_oauth_token_with_cmd("/bin/sh", &["-c", &script]);
        assert!(result.is_ok(), "expected Ok, got: {:?}", result);
        assert_eq!(result.unwrap(), "sk-ant-test-token");
    }

    #[test]
    fn test_extract_access_token_valid_json() {
        let json = r#"{"claudeAiOauth":{"accessToken":"sk-ant-oat01-abc","refreshToken":"rt","expiresAt":1234567890,"scopes":["read"]}}"#;
        assert_eq!(
            extract_access_token(json),
            Some("sk-ant-oat01-abc".to_string())
        );
    }

    #[test]
    fn test_extract_access_token_missing_field() {
        let json = r#"{"claudeAiOauth":{"refreshToken":"rt"}}"#;
        assert_eq!(extract_access_token(json), None);
    }

    #[test]
    fn test_extract_access_token_invalid_json() {
        assert_eq!(extract_access_token("not json"), None);
    }

    #[test]
    fn test_extract_access_token_empty_token() {
        let json = r#"{"claudeAiOauth":{"accessToken":""}}"#;
        assert_eq!(extract_access_token(json), None);
    }

    #[cfg(target_os = "linux")]
    #[test]
    fn test_read_credentials_file_valid() {
        use std::io::Write;
        let dir = tempfile::tempdir().unwrap();
        let claude_dir = dir.path().join(".claude");
        std::fs::create_dir_all(&claude_dir).unwrap();
        let creds_path = claude_dir.join(".credentials.json");
        let json = r#"{"claudeAiOauth":{"accessToken":"sk-ant-file-token","refreshToken":"rt","expiresAt":9999}}"#;
        std::fs::File::create(&creds_path)
            .unwrap()
            .write_all(json.as_bytes())
            .unwrap();
        // Temporarily override HOME for this test
        let orig_home = std::env::var("HOME").unwrap_or_default();
        // SAFETY: single-threaded test, no concurrent env reads
        unsafe { std::env::set_var("HOME", dir.path()) };
        let result = read_credentials_file();
        unsafe { std::env::set_var("HOME", &orig_home) };
        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");
        assert!(hint.contains("xcode-select"), "{hint}");
    }
}