use crate::paths::Paths;
use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
pub fn shim_path(paths: &Paths) -> PathBuf {
shim_path_for(paths, "claude-code")
}
pub fn shim_path_for(paths: &Paths, tool: &str) -> PathBuf {
let bin = match tool {
"codex" => "codex",
_ => "claude",
};
paths.store_dir().join("bin").join(bin)
}
fn sh_quote(p: &Path) -> String {
let s = p.to_string_lossy();
format!("'{}'", s.replace('\'', "'\\''"))
}
pub fn shim_script(pointer: &Path, real_claude: &Path, swapdex: &Path) -> String {
format!(
"#!/bin/sh\n\
# swapdex claude shim - launch claude in the default account's slot.\n\
# Managed by swapdex; re-created by `swapdex shim`.\n\
# Signing in must reach Anthropic directly. The OAuth exchange is between\n\
# the browser and the real API, and a proxy in the middle both breaks the\n\
# code exchange and answers with whichever account it already has - so a\n\
# fresh slot looks signed in as somebody else, or the prompt takes no\n\
# input at all.\n\
sx_login=no\n\
for a in \"$@\"; do\n\
\tcase \"$a\" in login|/login|logout|/logout|setup-token) sx_login=yes ;; esac\n\
done\n\
# Ask swapdex for a live proxy (it starts one if needed and prints the\n\
# port); silence and a non-zero status mean \"run without one\".\n\
if [ \"$sx_login\" = no ]; then\n\
\tport=$({sx} proxy --ensure 2>/dev/null)\n\
\tif [ -n \"$port\" ]; then\n\
\t\tANTHROPIC_BASE_URL=\"http://127.0.0.1:$port\"\n\
\t\texport ANTHROPIC_BASE_URL\n\
\tfi\n\
fi\n\
if [ -z \"$CLAUDE_CONFIG_DIR\" ]; then\n\
\tdir=$(cat {ptr} 2>/dev/null)\n\
\tif [ -n \"$dir\" ]; then\n\
\t\tCLAUDE_CONFIG_DIR=\"$dir\"\n\
\t\texport CLAUDE_CONFIG_DIR\n\
\tfi\n\
fi\n\
exec {real} \"$@\"\n",
sx = sh_quote(swapdex),
ptr = sh_quote(pointer),
real = sh_quote(real_claude),
)
}
pub fn codex_shim_script(pointer: &Path, real_codex: &Path, swapdex: &Path) -> String {
format!(
"#!/bin/sh\n\
# swapdex codex shim - launch codex in the default account's slot.\n\
# Managed by swapdex; re-created by `swapdex shim`.\n\
# The provider overrides belong on a run that TALKS to the model. On\n\
# `resume` they emptied the session picker: Codex lists the sessions that\n\
# match the configured provider, and a conversation held long before\n\
# swapdex existed matches none. A sign-in is excluded for its own reason -\n\
# the OAuth exchange is between the browser and the real backend, and a\n\
# proxy in the middle answers with whichever account it already holds.\n\
sx_plain=no\n\
for a in \"$@\"; do\n\
\tcase \"$a\" in login|/login|logout|/logout|resume|/resume|history|sessions) sx_plain=yes ;; esac\n\
done\n\
# Ask swapdex for a live proxy (it starts one if needed and prints the\n\
# port); silence means \"run without one\", exactly as before.\n\
if [ \"$sx_plain\" = no ]; then\n\
\tport=$({sx} proxy --ensure --tool codex 2>/dev/null)\n\
fi\n\
if [ -n \"$port\" ]; then\n\
\tset -- -c model_provider=swapdex \\\n\
\t\t-c model_providers.swapdex.name=swapdex \\\n\
\t\t-c model_providers.swapdex.base_url=\"http://127.0.0.1:$port/v1\" \\\n\
\t\t-c model_providers.swapdex.wire_api=responses \"$@\"\n\
fi\n\
if [ -z \"$CODEX_HOME\" ]; then\n\
\tdir=$(cat {ptr} 2>/dev/null)\n\
\tif [ -n \"$dir\" ]; then\n\
\t\tCODEX_HOME=\"$dir\"\n\
\t\texport CODEX_HOME\n\
\tfi\n\
fi\n\
exec {real} \"$@\"\n",
sx = sh_quote(swapdex),
ptr = sh_quote(pointer),
real = sh_quote(real_codex),
)
}
pub fn proxy_marker(paths: &Paths) -> PathBuf {
proxy_marker_for(paths, "claude-code")
}
pub fn proxy_marker_for(paths: &Paths, tool: &str) -> PathBuf {
match tool {
"codex" => paths.store_dir().join("proxy-codex"),
_ => paths.store_dir().join("proxy"),
}
}
const SHIM_MARKER: &str = "swapdex claude shim";
const SHIM_MARKER_CODEX: &str = "swapdex codex shim";
fn is_our_shim(path: &Path) -> bool {
let mut buf = [0u8; 256];
let Ok(mut f) = std::fs::File::open(path) else {
return false;
};
use std::io::Read;
let n = f.read(&mut buf).unwrap_or(0);
let head = String::from_utf8_lossy(&buf[..n]);
head.contains(SHIM_MARKER) || head.contains(SHIM_MARKER_CODEX)
}
pub(crate) fn resolved_claude() -> Option<(PathBuf, bool)> {
let path = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path) {
let cand = dir.join("claude");
if cand.is_file() {
let ours = is_our_shim(&cand);
return Some((cand, ours));
}
}
None
}
fn find_real_claude(shim_dir: &Path) -> Option<PathBuf> {
find_real(shim_dir, "claude")
}
fn find_real(shim_dir: &Path, bin: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path) {
if dir == shim_dir {
continue;
}
let cand = dir.join(bin);
if cand.is_file() && !is_our_shim(&cand) {
return Some(cand);
}
}
None
}
fn profile_already_adds(profile_text: &str, shim_dir: &Path) -> bool {
let full = shim_dir.to_string_lossy().to_string();
let home = dirs::home_dir().map(|h| h.to_string_lossy().to_string());
let alts: Vec<String> = home
.iter()
.filter_map(|h| full.strip_prefix(h.as_str()))
.flat_map(|rest| [format!("$HOME{rest}"), format!("~{rest}")])
.collect();
profile_text.lines().any(|l| {
let l = l.trim();
if !l.contains("PATH") || l.starts_with('#') {
return false;
}
l.contains(&full) || alts.iter().any(|a| l.contains(a))
})
}
fn path_line(shim_dir: &Path) -> String {
format!("export PATH=\"{}:$PATH\"", shim_dir.display())
}
const PROFILE_MARKER: &str = "# added by swapdex (claude shim)";
fn shell_profile() -> Option<PathBuf> {
let home = dirs::home_dir()?;
let shell = std::env::var("SHELL").unwrap_or_default();
let name = shell.rsplit('/').next().unwrap_or("");
match name {
"zsh" => Some(home.join(".zshrc")),
"bash" => {
let bp = home.join(".bash_profile");
if bp.exists() {
Some(bp)
} else {
Some(home.join(".bashrc"))
}
}
_ => None,
}
}
fn already_on_path(shim_dir: &Path) -> bool {
std::env::var_os("PATH")
.map(|p| std::env::split_paths(&p).any(|d| d == shim_dir))
.unwrap_or(false)
}
pub enum PathSetup {
AlreadyThere,
Added(PathBuf),
Manual,
}
pub fn ensure_on_path(shim_dir: &Path) -> Result<PathSetup> {
if already_on_path(shim_dir) {
return Ok(PathSetup::AlreadyThere);
}
let Some(profile) = shell_profile() else {
return Ok(PathSetup::Manual);
};
let existing = std::fs::read_to_string(&profile).unwrap_or_default();
let line = path_line(shim_dir);
if existing.contains(PROFILE_MARKER) || profile_already_adds(&existing, shim_dir) {
return Ok(PathSetup::Added(profile));
}
let mut out = existing;
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
out.push_str(&format!("\n{PROFILE_MARKER}\n{line}\n"));
std::fs::write(&profile, out).with_context(|| format!("edit {}", profile.display()))?;
Ok(PathSetup::Added(profile))
}
pub fn install(paths: &Paths) -> Result<(PathBuf, PathBuf)> {
let shim = shim_path(paths);
let shim_dir = shim
.parent()
.map(|p| p.to_path_buf())
.context("shim path has no parent")?;
let real = find_real_claude(&shim_dir)
.context("could not find the real `claude` on PATH - install it first")?;
let pointer = paths.store_dir().join("active-claude");
let me = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("swapdex"));
std::fs::create_dir_all(&shim_dir).context("create shim dir")?;
std::fs::write(&shim, shim_script(&pointer, &real, &me)).context("write shim")?;
make_executable(&shim)?;
Ok((shim, shim_dir))
}
pub fn install_codex(paths: &Paths) -> Result<Option<PathBuf>> {
let shim = shim_path_for(paths, "codex");
let shim_dir = shim
.parent()
.map(|p| p.to_path_buf())
.context("shim path has no parent")?;
let Some(real) = find_real(&shim_dir, "codex") else {
return Ok(None);
};
let pointer = paths.store_dir().join("active-codex");
let me = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("swapdex"));
std::fs::create_dir_all(&shim_dir).context("create shim dir")?;
std::fs::write(&shim, codex_shim_script(&pointer, &real, &me)).context("write codex shim")?;
make_executable(&shim)?;
Ok(Some(shim))
}
fn make_executable(p: &Path) -> Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(p, std::fs::Permissions::from_mode(0o755))
.context("chmod shim")?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn an_existing_path_line_is_recognised_however_it_is_spelled() {
let home = dirs::home_dir().expect("a home dir");
let shim_dir = home.join("Library/Application Support/swapdex/bin");
let full = shim_dir.display().to_string();
for spelling in [
format!("export PATH=\"{full}:$PATH\""),
"export PATH=\"$HOME/Library/Application Support/swapdex/bin:$PATH\"".to_string(),
"export PATH=\"~/Library/Application Support/swapdex/bin:$PATH\"".to_string(),
] {
assert!(
profile_already_adds(&format!("# something\n{spelling}\n"), &shim_dir),
"not recognised: {spelling}"
);
}
assert!(!profile_already_adds(
"export PATH=\"/usr/local/bin:$PATH\"\n",
&shim_dir
));
assert!(!profile_already_adds(
&format!("# export PATH=\"{full}:$PATH\"\n"),
&shim_dir
));
assert!(!profile_already_adds(&format!("echo {full}\n"), &shim_dir));
}
#[test]
fn the_shim_does_not_proxy_a_sign_in() {
let s = shim_script(
Path::new("/store/active-claude"),
Path::new("/usr/bin/claude"),
Path::new("/bin/swapdex"),
);
assert!(
s.contains("sx_login=no"),
"it decides whether this is a sign-in: {s}"
);
for verb in ["login", "/login", "logout", "setup-token"] {
assert!(s.contains(verb), "recognised: {verb}");
}
let guard = s.find("if [ \"$sx_login\" = no ]").expect("the guard");
let export = s.find("ANTHROPIC_BASE_URL").expect("the export");
assert!(
guard < export,
"the proxy address is only set when not signing in"
);
}
#[test]
fn the_codex_shim_routes_through_a_running_proxy() {
let s = codex_shim_script(
Path::new("/store/active-codex"),
Path::new("/usr/bin/codex"),
Path::new("/bin/swapdex"),
);
assert!(
s.contains("proxy --ensure --tool codex"),
"asks swapdex for a live codex proxy: {s}"
);
assert!(s.contains("model_provider=swapdex"), "selects the provider");
assert!(
s.contains("model_providers.swapdex.base_url=\"http://127.0.0.1:$port/v1\""),
"points it at the proxy: {s}"
);
assert!(
s.contains("model_providers.swapdex.wire_api=responses"),
"the protocol codex speaks"
);
assert!(
!s.contains("env_key"),
"declaring an api key would stop codex attaching its own OAuth"
);
assert!(
s.contains("if [ -n \"$port\" ]"),
"the overrides are conditional: {s}"
);
}
#[test]
fn the_codex_shim_leaves_reading_commands_alone() {
let s = codex_shim_script(
Path::new("/store/active-codex"),
Path::new("/usr/bin/codex"),
Path::new("/bin/swapdex"),
);
for verb in ["resume", "history", "sessions"] {
assert!(s.contains(verb), "recognised as a plain run: {verb}");
}
let guard = s.find("if [ \"$sx_plain\" = no ]").expect("the guard");
let ask = s.find("proxy --ensure").expect("the ask");
assert!(guard < ask, "the proxy is only asked for on a talking run");
let home = s.find("CODEX_HOME=").expect("home");
assert!(home > guard, "the home is set outside the guard: {s}");
}
#[test]
fn the_codex_shim_points_codex_home_at_the_default_slot() {
let s = codex_shim_script(
Path::new("/store/active-codex"),
Path::new("/usr/bin/codex"),
Path::new("/bin/swapdex"),
);
assert!(s.starts_with("#!/bin/sh"));
assert!(
s.contains("/store/active-codex"),
"reads codex's own pointer"
);
assert!(s.contains("/usr/bin/codex"), "execs the real codex");
assert!(s.contains("CODEX_HOME="), "sets the slot env");
assert!(
s.contains("if [ -z \"$CODEX_HOME\" ]"),
"an explicit CODEX_HOME is a decision already made"
);
assert!(s.contains("exec "), "replaces the process");
assert!(!s.contains("CLAUDE_CONFIG_DIR"));
assert!(!s.contains("ANTHROPIC_BASE_URL"));
}
#[test]
fn script_references_pointer_real_claude_and_config_dir() {
let s = shim_script(
Path::new("/store/active-claude"),
Path::new("/usr/bin/claude"),
Path::new("/bin/swapdex"),
);
assert!(s.starts_with("#!/bin/sh"));
assert!(s.contains("/store/active-claude"), "reads the pointer");
assert!(s.contains("/usr/bin/claude"), "execs the real claude");
assert!(s.contains("CLAUDE_CONFIG_DIR="), "sets the slot env");
assert!(s.contains("exec "), "replaces the process");
}
#[test]
fn an_explicit_config_dir_wins_over_the_default_pointer() {
let s = shim_script(
Path::new("/store/active-claude"),
Path::new("/usr/bin/claude"),
Path::new("/bin/swapdex"),
);
assert!(
s.contains("if [ -z \"$CLAUDE_CONFIG_DIR\" ]"),
"the pointer only fills in when nothing chose a dir: {s}"
);
assert!(s.contains("/store/active-claude"), "{s}");
assert!(s.contains("CLAUDE_CONFIG_DIR="), "{s}");
}
#[test]
fn script_gets_its_proxy_from_swapdex_and_tolerates_none() {
let s = shim_script(
Path::new("/store/active-claude"),
Path::new("/usr/bin/claude"),
Path::new("/bin/swapdex"),
);
assert!(
s.contains("'/bin/swapdex' proxy --ensure"),
"asks swapdex by absolute path, so the user starts nothing: {s}"
);
assert!(
s.contains("ANTHROPIC_BASE_URL"),
"points claude at the proxy"
);
assert!(
s.contains("http://127.0.0.1:$port"),
"loopback only, port from swapdex"
);
assert!(
s.contains("2>/dev/null") && s.contains("if [ -n \"$port\" ]"),
"no proxy is not an error - claude still runs: {s}"
);
}
#[test]
fn script_quotes_paths_with_spaces() {
let s = shim_script(
Path::new("/a b/active-claude"),
Path::new("/c d/claude"),
Path::new("/e f/swapdex"),
);
assert!(s.contains("'/a b/active-claude'"), "pointer is quoted");
assert!(s.contains("'/e f/swapdex'"), "swapdex path is quoted");
assert!(s.contains("'/c d/claude'"), "real claude is quoted");
}
#[test]
fn recognizes_our_own_shim_by_marker() {
let dir = tempfile::tempdir().unwrap();
let shim = dir.path().join("claude");
std::fs::write(
&shim,
shim_script(Path::new("/p"), Path::new("/real"), Path::new("/sx")),
)
.unwrap();
assert!(is_our_shim(&shim), "our shim is recognized by its marker");
let real = dir.path().join("real-claude");
std::fs::write(&real, "#!/bin/sh\nexec node /opt/claude \"$@\"\n").unwrap();
assert!(!is_our_shim(&real), "a real claude is not flagged");
}
}