use std::io::Write;
use std::path::Path;
use std::process::{Command, Stdio};
use crate::error::{AppError, Result};
const SERVICE: &str = "Claude Code-credentials";
fn service_name_for(config_dir: &Path) -> Result<String> {
let mut child = Command::new("/usr/bin/shasum")
.args(["-a", "256"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.map_err(|e| AppError::Other(format!("could not run `shasum`: {e}")))?;
child
.stdin
.take()
.expect("stdin was piped")
.write_all(config_dir.display().to_string().as_bytes())
.map_err(|e| AppError::Other(format!("could not run `shasum`: {e}")))?;
let out = child
.wait_with_output()
.map_err(|e| AppError::Other(format!("could not run `shasum`: {e}")))?;
let stdout = String::from_utf8_lossy(&out.stdout);
let hash = stdout
.split_whitespace()
.next()
.and_then(|h| h.get(..8))
.ok_or_else(|| AppError::Other("shasum produced unexpected output".into()))?;
Ok(format!("{SERVICE}-{hash}"))
}
fn account() -> Option<String> {
std::env::var("USER").ok().filter(|u| !u.is_empty())
}
const ERR_SEC_ITEM_NOT_FOUND: i32 = 44;
pub fn read_raw() -> Result<Option<String>> {
read_raw_service(SERVICE)
}
pub fn read_raw_for(config_dir: &Path) -> Result<Option<String>> {
read_raw_service(&service_name_for(config_dir)?)
}
fn read_raw_service(service: &str) -> Result<Option<String>> {
let mut cmd = Command::new("/usr/bin/security");
cmd.args(["find-generic-password", "-s", service, "-w"]);
if let Some(acct) = account() {
cmd.args(["-a", &acct]);
}
let out = cmd
.output()
.map_err(|e| AppError::Other(format!("could not run `security`: {e}")))?;
if !out.status.success() {
if out.status.code() == Some(ERR_SEC_ITEM_NOT_FOUND) {
return Ok(None);
}
let detail = String::from_utf8_lossy(&out.stderr);
let detail = detail.trim();
return Err(AppError::Credentials(format!(
"could not read the Claude credentials from the macOS Keychain \
(security exited {}): {}. If the login Keychain is locked, unlock \
it and retry; if access was denied, allow ai-usagebar when prompted.",
out.status.code().unwrap_or(-1),
if detail.is_empty() {
"no detail"
} else {
detail
}
)));
}
let value = String::from_utf8(out.stdout)
.map_err(|e| AppError::Other(format!("Keychain value was not UTF-8: {e}")))?;
let value = value.trim_end_matches('\n').to_string();
if value.is_empty() {
Ok(None)
} else {
Ok(Some(value))
}
}
pub fn write_raw(json: &str) -> Result<()> {
write_raw_service(SERVICE, json)
}
pub fn write_raw_for(config_dir: &Path, json: &str) -> Result<()> {
write_raw_service(&service_name_for(config_dir)?, json)
}
pub fn delete_raw() -> Result<()> {
delete_raw_service(SERVICE)
}
pub fn delete_raw_for(config_dir: &Path) -> Result<()> {
delete_raw_service(&service_name_for(config_dir)?)
}
fn write_raw_service(service: &str, json: &str) -> Result<()> {
let mut cmd = Command::new("/usr/bin/security");
cmd.args(["add-generic-password", "-U", "-s", service]);
if let Some(acct) = account() {
cmd.args(["-a", &acct]);
}
cmd.args(["-w", json]);
let out = cmd
.output()
.map_err(|e| AppError::Other(format!("could not run `security`: {e}")))?;
if out.status.success() {
return Ok(());
}
let detail = String::from_utf8_lossy(&out.stderr);
let detail = detail.trim();
Err(AppError::Credentials(format!(
"failed to update the Claude credentials in the macOS Keychain \
(security exited {}): {}",
out.status.code().unwrap_or(-1),
if detail.is_empty() {
"no detail"
} else {
detail
}
)))
}
fn delete_raw_service(service: &str) -> Result<()> {
let mut cmd = Command::new("/usr/bin/security");
cmd.args(["delete-generic-password", "-s", service]);
if let Some(acct) = account() {
cmd.args(["-a", &acct]);
}
let out = cmd
.output()
.map_err(|e| AppError::Other(format!("could not run `security`: {e}")))?;
if out.status.success() || out.status.code() == Some(ERR_SEC_ITEM_NOT_FOUND) {
return Ok(());
}
let detail = String::from_utf8_lossy(&out.stderr);
Err(AppError::Credentials(format!(
"failed to remove the Claude credentials from the macOS Keychain \
(security exited {}): {}",
out.status.code().unwrap_or(-1),
detail.trim()
)))
}