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
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
//! 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.
/// The same paths, but with ONE tool config dir pointed somewhere else.
///
/// The sign-in key lands a login in an account own slot directory, while
/// every capture reads whatever dir was resolved at startup - so the saved
/// copy could never be refreshed from the login just made, and the "stale"
/// marker had nothing that could clear it. The store is deliberately left
/// alone: only where the TOOL credential is read from moves.
///
/// An environment variable would not do: the sandbox used by tests ignores
/// those by design, so the one check that proves this works could never run.
pub fn with_tool_dir(&self, tool: &str, dir: &Path) -> Paths {
self.try_with_tool_dir(tool, dir)
.unwrap_or_else(|| self.clone())
}
/// The same, but `None` for a name that is not a tool, so a caller that
/// depends on the redirect can tell it did not happen.
///
/// Antigravity used to land in the catch-all and change nothing, and the
/// caller then captured from the live default dir - the exact outcome the
/// doc above says this function exists to prevent, filing whoever happened
/// to be signed in under the slot account's name. Its token lives under
/// the gemini dir, so that is what moves.
pub fn try_with_tool_dir(&self, tool: &str, dir: &Path) -> Option<Paths> {
let mut p = self.clone();
match tool {
"claude-code" => p.claude_dir = dir.to_path_buf(),
"codex" => p.codex_dir = dir.to_path_buf(),
"gemini" | "antigravity" => p.gemini_dir = dir.to_path_buf(),
_ => return None,
}
Some(p)
}
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")
}
/// The home these paths hang off - the temp root under a test root, the
/// real one otherwise. Anything that needs a path in the user's home must
/// ask here rather than call `dirs::home_dir()`, which a sandbox cannot
/// redirect: a test doing that reached out and restarted the developer's
/// own service.
pub fn home(&self) -> &Path {
&self.home
}
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:?}");
}
}
#[cfg(test)]
mod pointed_at_slot_tests {
use super::*;
/// Capturing must be able to read ONE named slot, not just the default home.
///
/// The sign-in key lands a login in the account's own slot directory, while
/// every capture path reads whatever dir `Paths` resolved at startup. So the
/// saved copy could never be refreshed from the login just made, and the
/// stale marker had nothing that could clear it. Pointing an existing Paths
/// at one slot is what makes that capture possible - and testable, which
/// matters because a SWAPDEX_ROOT fixture deliberately ignores the env vars
/// the real resolver honours.
#[test]
fn a_paths_can_be_pointed_at_one_slot() {
let base = Paths::rooted(std::path::Path::new("/tmp/swapdex-pointed"));
let slot = std::path::Path::new("/tmp/swapdex-pointed/slotdir");
let at = base.with_tool_dir("claude-code", slot);
assert_eq!(at.claude_dir(), slot, "claude reads the slot");
assert_eq!(
at.codex_dir(),
base.codex_dir(),
"other tools are untouched"
);
assert_eq!(at.store_dir(), base.store_dir(), "the store never moves");
let cx = base.with_tool_dir("codex", slot);
assert_eq!(cx.codex_dir(), slot);
assert_eq!(cx.claude_dir(), base.claude_dir());
}
}
#[cfg(test)]
mod with_tool_dir_tests {
use super::*;
/// `with_tool_dir` exists so a capture reads ONE named slot instead of the
/// live default - its own comment says reading the default "would file
/// whoever happens to be live under this account's name". For antigravity
/// the match had no arm and fell through to `_ => {}`, so it returned the
/// paths unchanged and the capture read the default anyway, silently.
#[test]
fn every_tool_is_actually_redirected() {
let root = tempfile::tempdir().unwrap();
let base = Paths::rooted(root.path());
let slot = root.path().join("slot-one");
let at = base.with_tool_dir("claude-code", &slot);
assert!(at.claude_credentials().starts_with(&slot));
let cx = base.with_tool_dir("codex", &slot);
assert!(cx.codex_auth().starts_with(&slot));
let gm = base.with_tool_dir("gemini", &slot);
assert!(gm.gemini_oauth().starts_with(&slot));
// Antigravity keeps its token under the gemini dir, so redirecting it
// means redirecting that.
let ag = base.with_tool_dir("antigravity", &slot);
assert!(
ag.antigravity_token().starts_with(&slot),
"antigravity was not redirected: {}",
ag.antigravity_token().display()
);
}
/// A name that is not a tool must not look like a successful redirect.
#[test]
fn an_unknown_tool_is_refused_rather_than_ignored() {
let root = tempfile::tempdir().unwrap();
let base = Paths::rooted(root.path());
assert!(base
.try_with_tool_dir("not-a-tool", &root.path().join("s"))
.is_none());
assert!(base
.try_with_tool_dir("codex", &root.path().join("s"))
.is_some());
}
}