1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
//! One override-first path resolver per tool. Every canonical credential path
//! goes through here so tests can redirect to a temp tree and never touch a
//! real login. Precedence: explicit root (tests) > tool env var > home dir.
use std::path::{Path, PathBuf};
#[derive(Clone)]
pub struct Paths {
home: PathBuf, // for ~/.claude.json (sibling of ~/.claude)
claude_dir: PathBuf, // ~/.claude or $CLAUDE_CONFIG_DIR
codex_dir: PathBuf, // ~/.codex or $CODEX_HOME
gemini_dir: PathBuf, // ~/.gemini
data: PathBuf, // ~/.local/share/swapdex
/// These paths are a sandbox, not the machine's real ones. Anything with an
/// effect that OUTLIVES the process has to ask: a detached proxy started for
/// a temporary store keeps its port and answers for a directory that is
/// deleted moments later.
sandboxed: bool,
}
impl Paths {
/// Test constructor: everything under one temp root, so no test can touch a
/// real credential. `.claude.json` sits at <root>/.claude.json (home root),
/// matching the real sibling layout.
pub fn rooted(root: &Path) -> Paths {
Paths {
home: root.to_path_buf(),
claude_dir: root.join(".claude"),
codex_dir: root.join(".codex"),
gemini_dir: root.join(".gemini"),
data: root.join(".local/share/swapdex"),
sandboxed: true,
}
}
/// Are these a redirected (test or SWAPDEX_ROOT) tree rather than the real
/// one? Checked before starting anything that outlives this process.
pub fn sandboxed(&self) -> bool {
self.sandboxed
}
/// The real resolver: honors CLAUDE_CONFIG_DIR / CODEX_HOME, else home dir.
/// SWAPDEX_ROOT redirects everything under one dir (dev/test override).
pub fn resolve() -> anyhow::Result<Paths> {
use anyhow::Context;
if let Some(root) = std::env::var_os("SWAPDEX_ROOT") {
return Ok(Paths::rooted(Path::new(&root)));
}
let home = dirs::home_dir().context("cannot determine home dir")?;
let claude_dir = std::env::var_os("CLAUDE_CONFIG_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| home.join(".claude"));
let codex_dir = std::env::var_os("CODEX_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| home.join(".codex"));
let data = dirs::data_dir()
.context("cannot determine data dir")?
.join("swapdex");
let gemini_dir = home.join(".gemini");
Ok(Paths {
home,
claude_dir,
codex_dir,
gemini_dir,
data,
sandboxed: false,
})
}
pub fn claude_credentials(&self) -> PathBuf {
self.claude_dir.join(".credentials.json")
}
pub fn claude_config_json(&self) -> PathBuf {
self.home.join(".claude.json")
}
pub fn codex_auth(&self) -> PathBuf {
self.codex_dir.join("auth.json")
}
pub fn gemini_oauth(&self) -> PathBuf {
self.gemini_dir.join("oauth_creds.json")
}
pub fn gemini_accounts(&self) -> PathBuf {
self.gemini_dir.join("google_accounts.json")
}
/// Antigravity CLI keeps its own token under the gemini dir.
pub fn antigravity_token(&self) -> PathBuf {
self.gemini_dir
.join("antigravity-cli")
.join("antigravity-oauth-token")
}
/// Where a backgrounded proxy writes what it says.
///
/// One per tool. A proxy started by the shim used to discard its output, so
/// on a machine where that is how it starts there was no record of which
/// account served which turn - the single question these lines exist to
/// answer, and three wrong diagnoses came out of not having it.
pub fn proxy_log(&self, tool: &str) -> PathBuf {
let name = match tool {
"codex" => "proxy-codex.log",
"gemini" => "proxy-gemini.log",
"antigravity" => "proxy-antigravity.log",
_ => "proxy-claude.log",
};
self.data.join("logs").join(name)
}
pub fn store_dir(&self) -> PathBuf {
self.data.clone()
}
/// The bare Claude config dir (`~/.claude`) - the source of shared,
/// account-agnostic config (settings, global memory) linked into new slots.
pub fn claude_dir(&self) -> &Path {
&self.claude_dir
}
/// The bare Codex home (`~/.codex`, or `$CODEX_HOME`) - the source of
/// shared, account-agnostic config linked into new Codex slots.
pub fn codex_dir(&self) -> &Path {
&self.codex_dir
}
/// Sibling `~/.claude-*` config dirs a user already runs via aliases -
/// adoptable as slots during onboarding. Excludes the bare `~/.claude`.
/// Best-effort; empty on failure.
pub fn discover_claude_config_dirs(&self) -> Vec<PathBuf> {
let mut out = Vec::new();
if let Ok(rd) = std::fs::read_dir(&self.home) {
for e in rd.flatten() {
let n = e.file_name().to_string_lossy().into_owned();
if n.starts_with(".claude-") && e.path().is_dir() {
out.push(e.path());
}
}
}
out.sort();
// The bare `~/.claude` LAST, and only when it exists. Leaving it out made
// the account everyone starts from the one account swapdex could not
// switch back to - and every conversation begun before the first switch
// lives in it, so they became unreachable by a plain `claude -r`.
if self.claude_dir.is_dir() {
out.push(self.claude_dir.clone());
}
out
}
/// Claude Code's session transcripts (for local, no-network usage reads).
pub fn claude_projects(&self) -> PathBuf {
self.claude_dir.join("projects")
}
/// Codex's session transcripts.
pub fn codex_sessions(&self) -> PathBuf {
self.codex_dir.join("sessions")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rooted_redirects_every_path_under_the_temp_root() {
let dir = tempfile::tempdir().unwrap();
let p = Paths::rooted(dir.path());
for path in [
p.claude_credentials(),
p.claude_config_json(),
p.codex_auth(),
p.store_dir(),
] {
assert!(path.starts_with(dir.path()), "{path:?} escaped the root");
}
// .claude.json is a sibling of .claude/, at the home root.
assert_eq!(p.claude_config_json(), dir.path().join(".claude.json"));
assert!(p
.claude_credentials()
.starts_with(dir.path().join(".claude")));
}
}
#[cfg(test)]
mod log_path_tests {
use super::*;
/// A proxy started in the background used to throw its output away, so on a
/// machine where the shim starts it there was no record of which account
/// served which turn - the one question these logs exist to answer. Three
/// wrong diagnoses came out of that silence.
#[test]
fn a_backgrounded_proxy_has_somewhere_to_write() {
let dir = tempfile::tempdir().unwrap();
let p = Paths::rooted(dir.path());
let a = p.proxy_log("claude-code");
let b = p.proxy_log("codex");
assert_ne!(a, b, "each tool keeps its own log");
// Under the store, so it travels with the rest of swapdex's state.
assert!(a.starts_with(p.store_dir()), "{a:?}");
assert!(a.to_string_lossy().contains("log"), "named as a log: {a:?}");
}
}