Skip to main content

pitboard_core/
context.rs

1//! Everything pitboard takes from its environment, read in one place. The CLI builds a
2//! `Context` from the process environment once. A program linking the library builds one
3//! itself: an app started from Finder does not see a shell's environment.
4
5use crate::api::{Anthropic, Api};
6use crate::provider::codex::api::{Network as OpenAiNetwork, OpenAi};
7use crate::store::Host;
8use crate::time::{Clock, SystemClock};
9use std::path::PathBuf;
10use std::sync::Arc;
11
12#[derive(Clone, Debug)]
13pub struct Context {
14    pub(crate) home: PathBuf,
15    pub(crate) pitboard_home: PathBuf,
16    /// `CLAUDE_CONFIG_DIR`, which Claude Code reads with `||`: empty means unset.
17    pub(crate) claude_config_dir: Option<String>,
18    /// `CLAUDE_SECURESTORAGE_CONFIG_DIR`, which Claude Code reads with `!== undefined`:
19    /// empty is set, and pins the default credential slot.
20    pub(crate) secure_storage_dir: Option<String>,
21    /// `$USER`, which names Claude Code's keychain account once screened by `slot`.
22    pub(crate) user: Option<String>,
23    /// `CLAUDE_CODE_CUSTOM_OAUTH_URL`. Set, it renames both the keychain item and the config
24    /// file Claude Code uses, so pitboard would be reading and writing the wrong ones.
25    pub(crate) custom_oauth: bool,
26    /// Environment variables this process was started with that make Claude Code use
27    /// something other than the login pitboard moves. Only half the answer: the rest is in
28    /// files, which [`crate::settings::overrides`] reads and an app can see too.
29    pub(crate) overriding_auth: Vec<String>,
30    /// Whether a login too large for `security -i` may be written the way Claude Code
31    /// writes it: as a command argument, where `ps` can see it for the length of the call.
32    /// On by default, because there is no third way and Claude Code writes the same
33    /// document that way itself on every token refresh.
34    pub(crate) argv_fallback: bool,
35    /// Which front end asked, for the audit log. A change made from the menu bar and one
36    /// typed at a prompt read the same otherwise.
37    pub(crate) caller: String,
38    /// The `claude` that runs a sign-in; a bare name is looked up on the search path.
39    pub(crate) claude_program: PathBuf,
40    /// Where Anthropic's endpoints are reached instead, for tests; `api` honours loopback only.
41    pub(crate) api_base: Option<String>,
42    /// `CLAUDE_CODE_HOVER_REST`, which switches on Claude Code's successor credential backend.
43    pub(crate) hover_rest: bool,
44    /// `CODEX_HOME`, which moves everything Codex keeps, including its keyring account.
45    pub(crate) codex_home: Option<String>,
46    /// The `codex` that runs a sign-in; a bare name is looked up on the search path.
47    pub(crate) codex_program: PathBuf,
48    /// Where a tool's program is looked for, in `PATH`'s form, and what its sign-in is given
49    /// as `PATH`, behind the program's own directory where that is not on it. `None` is this
50    /// process's own `PATH`: an app opened from Finder has almost nothing on it, so it
51    /// passes the one the person's login shell would have.
52    pub(crate) search_path: Option<std::ffi::OsString>,
53    /// Where the time comes from. The machine's clock in every real context; a test puts
54    /// its own here to reach the judgements that only happen at a particular moment.
55    pub(crate) clock: Arc<dyn Clock>,
56    /// The machine's credential stores. This build's host in every real context.
57    pub(crate) host: Arc<dyn Host>,
58    /// Who answers for Anthropic. The network in every real context.
59    pub(crate) api: Arc<dyn Api>,
60    /// Who answers for OpenAI. The network in every real context.
61    pub(crate) openai: Arc<dyn OpenAi>,
62}
63
64impl Context {
65    /// Epoch seconds, from this context's clock.
66    pub(crate) fn now(&self) -> i64 {
67        self.clock.now()
68    }
69
70    /// Epoch milliseconds, from this context's clock.
71    pub(crate) fn now_millis(&self) -> i64 {
72        self.clock.now_millis()
73    }
74
75    /// The person's home directory, which is where a platform's own scheduler lives.
76    pub(crate) fn home(&self) -> &std::path::Path {
77        &self.home
78    }
79
80    /// The credential stores this context reaches.
81    pub(crate) fn host(&self) -> &dyn Host {
82        self.host.as_ref()
83    }
84
85    /// Who this context asks about a login.
86    pub(crate) fn openai(&self) -> &dyn OpenAi {
87        self.openai.as_ref()
88    }
89
90    pub(crate) fn api(&self) -> &dyn Api {
91        self.api.as_ref()
92    }
93
94    /// Claude Code's defaults for a person whose home is `home`: `~/.pitboard`, `~/.claude`,
95    /// the default credential slot, `claude` looked up on `PATH`. An app starts here and sets
96    /// only what differs.
97    pub fn new(home: PathBuf) -> Context {
98        Context {
99            pitboard_home: home.join(".pitboard"),
100            home,
101            claude_config_dir: None,
102            secure_storage_dir: None,
103            user: None,
104            custom_oauth: false,
105            argv_fallback: true,
106            overriding_auth: Vec::new(),
107            caller: "unknown".into(),
108            claude_program: PathBuf::from("claude"),
109            api_base: None,
110            hover_rest: false,
111            codex_home: None,
112            codex_program: PathBuf::from("codex"),
113            search_path: None,
114            clock: Arc::new(SystemClock),
115            host: crate::store::host(),
116            api: Arc::new(Anthropic),
117            openai: Arc::new(OpenAiNetwork),
118        }
119    }
120
121    /// Where Codex keeps its login. Empty means unset, as Codex reads it.
122    pub fn with_codex_home(mut self, dir: String) -> Context {
123        self.codex_home = Some(dir).filter(|d| !d.is_empty());
124        self
125    }
126
127    pub(crate) fn codex_home(&self) -> Option<&str> {
128        self.codex_home.as_deref()
129    }
130
131    pub fn with_pitboard_home(mut self, dir: PathBuf) -> Context {
132        self.pitboard_home = dir;
133        self
134    }
135
136    /// Empty means unset, as Claude Code reads `CLAUDE_CONFIG_DIR`.
137    pub fn with_claude_config_dir(mut self, dir: String) -> Context {
138        self.claude_config_dir = Some(dir).filter(|d| !d.is_empty());
139        self
140    }
141
142    /// Empty is set, and pins the default slot, as Claude Code reads
143    /// `CLAUDE_SECURESTORAGE_CONFIG_DIR`.
144    pub fn with_secure_storage_dir(mut self, dir: String) -> Context {
145        self.secure_storage_dir = Some(dir);
146        self
147    }
148
149    /// The login name whose keychain account Claude Code stores under.
150    /// Allows the argument-line write for a login too large for the stdin one.
151    pub fn with_argv_fallback(mut self, allowed: bool) -> Context {
152        self.argv_fallback = allowed;
153        self
154    }
155
156    /// Whether a custom OAuth endpoint is configured, which moves Claude Code's login.
157    pub fn custom_oauth(&self) -> bool {
158        self.custom_oauth
159    }
160
161    /// The `claude` pitboard would run to sign someone in.
162    pub fn claude_program(&self) -> &std::path::Path {
163        &self.claude_program
164    }
165
166    /// Whether the argument-line write is allowed for an oversized login.
167    pub fn argv_fallback(&self) -> bool {
168        self.argv_fallback
169    }
170
171    /// Environment variables that authenticate Claude Code some other way, if any.
172    pub fn overriding_auth(&self) -> &[String] {
173        &self.overriding_auth
174    }
175
176    /// Names the front end in the audit log.
177    pub fn with_caller(mut self, caller: String) -> Context {
178        self.caller = caller;
179        self
180    }
181
182    pub fn with_user(mut self, user: String) -> Context {
183        self.user = Some(user);
184        self
185    }
186
187    /// An app started from Finder does not see the shell's `PATH`, so it names `claude` itself.
188    pub fn with_claude_program(mut self, program: PathBuf) -> Context {
189        self.claude_program = program;
190        self
191    }
192
193    /// The same for `codex`.
194    pub fn with_codex_program(mut self, program: PathBuf) -> Context {
195        self.codex_program = program;
196        self
197    }
198
199    /// The `codex` pitboard would run to sign someone in.
200    pub fn codex_program(&self) -> &std::path::Path {
201        &self.codex_program
202    }
203
204    /// Look for a tool's program on `path`, in `PATH`'s form, rather than on this process's
205    /// own `PATH`. An app opened from Finder has only the system's directories there, so a
206    /// tool installed through a version manager or an npm prefix is found only on the `PATH`
207    /// the person's shell has, and a script it runs finds its interpreter only there.
208    pub fn with_search_path(mut self, path: String) -> Context {
209        self.search_path = Some(path.into());
210        self
211    }
212
213    /// Where a tool's program is looked for: the path given, or this process's `PATH`.
214    pub(crate) fn search_path(&self) -> std::ffi::OsString {
215        self.search_path
216            .clone()
217            .or_else(|| std::env::var_os("PATH"))
218            .unwrap_or_default()
219    }
220
221    /// The program named for this tool, found or not.
222    pub fn program_for(&self, tool: crate::provider::ProviderId) -> &std::path::Path {
223        match tool {
224            crate::provider::ProviderId::Claude => &self.claude_program,
225            crate::provider::ProviderId::Codex => &self.codex_program,
226        }
227    }
228
229    pub fn from_env() -> Context {
230        let var = |name: &str| std::env::var(name).ok();
231        let home = std::env::var_os("HOME")
232            .map(PathBuf::from)
233            .unwrap_or_default();
234        Context {
235            pitboard_home: std::env::var_os("PITBOARD_HOME")
236                .map(PathBuf::from)
237                .unwrap_or_else(|| home.join(".pitboard")),
238            home,
239            claude_config_dir: var("CLAUDE_CONFIG_DIR").filter(|v| !v.is_empty()),
240            secure_storage_dir: var("CLAUDE_SECURESTORAGE_CONFIG_DIR"),
241            user: var("USER"),
242            custom_oauth: var("CLAUDE_CODE_CUSTOM_OAUTH_URL").is_some_and(|v| !v.is_empty()),
243            argv_fallback: !var("PITBOARD_NO_ARGV").is_some_and(|v| v == "1"),
244            overriding_auth: crate::settings::OVERRIDING_ENV
245                .iter()
246                .filter(|name| var(name).is_some_and(|v| !v.is_empty()))
247                .map(|name| (*name).to_string())
248                .collect(),
249            caller: "cli".into(),
250            claude_program: PathBuf::from("claude"),
251            api_base: var("PITBOARD_API_BASE"),
252            hover_rest: var("CLAUDE_CODE_HOVER_REST").is_some_and(|v| v == "1" || v == "true"),
253            codex_home: var("CODEX_HOME").filter(|v| !v.is_empty()),
254            codex_program: PathBuf::from("codex"),
255            search_path: None,
256            clock: Arc::new(SystemClock),
257            host: crate::store::host(),
258            api: Arc::new(Anthropic),
259            openai: Arc::new(OpenAiNetwork),
260        }
261    }
262
263    /// Answer for every service from a script, where a test can produce a 429 or a
264    /// refusal. One script for all of them, so a test that forgets to script a tool's
265    /// service gets a refusal rather than a request to the real one.
266    #[cfg(any(test, feature = "test-support"))]
267    #[doc(hidden)]
268    pub fn with_scripted_api(mut self, api: Arc<crate::api::scripted::ScriptedApi>) -> Context {
269        self.api = api.clone();
270        self.openai = api;
271        self
272    }
273
274    /// Put the credential stores in memory, where a test can make them fail. Only the
275    /// tests do this, which is why the trait behind it is not public.
276    #[cfg(any(test, feature = "test-support"))]
277    #[doc(hidden)]
278    pub fn with_memory_stores(mut self, memory: Arc<crate::store::memory::MemoryHost>) -> Context {
279        self.host = memory;
280        self
281    }
282
283    /// Read the time from somewhere else. Only the tests do this, which is why it is not
284    /// part of the builder a front end uses.
285    #[cfg(any(test, feature = "test-support"))]
286    #[doc(hidden)]
287    pub fn with_clock(mut self, clock: Arc<dyn Clock>) -> Context {
288        self.clock = clock;
289        self
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    #[test]
298    fn an_explicit_context_reads_claude_codes_settings_the_way_the_environment_does() {
299        let ctx = Context::new(PathBuf::from("/home/x"))
300            .with_claude_config_dir(String::new())
301            .with_secure_storage_dir(String::new());
302        assert_eq!(ctx.pitboard_home, PathBuf::from("/home/x/.pitboard"));
303        assert_eq!(ctx.claude_config_dir, None, "empty means unset");
304        assert_eq!(
305            ctx.secure_storage_dir.as_deref(),
306            Some(""),
307            "empty is set, and pins the default slot"
308        );
309        assert_eq!(ctx.claude_program, PathBuf::from("claude"));
310    }
311}