#[cfg(target_os = "macos")]
pub fn get_oauth_token() -> Result<String, String> {
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> {
if let Some(token) = read_credentials_file() {
return Ok(token);
}
get_oauth_token_with_cmd(
"secret-tool",
&["lookup", "service", "Claude Code-credentials"],
)
}
#[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())
}
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");
#[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();
extract_access_token(raw).ok_or_else(|| {
"Claude Code credentials found but access token could not be parsed — credential may be malformed".into()
})
}
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) }
}
#[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" => {
"security command not found — reinstall macOS command line tools: xcode-select --install"
.into()
}
other => format!("{other} not found"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(not(target_os = "windows"))]
#[test]
fn test_tool_not_found_returns_install_hint() {
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() {
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() {
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();
let orig_home = std::env::var("HOME").unwrap_or_default();
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}");
}
}