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    /// The pitboard the daily renewal schedule runs. `None` is this program, which is right
49    /// for the command line and wrong for an app: the schedule runs `pitboard renew`, so an
50    /// app names the command line it comes with.
51    pub(crate) schedule_program: Option<PathBuf>,
52    /// Where a tool's program is looked for, in `PATH`'s form, and what its sign-in is given
53    /// as `PATH`, behind the program's own directory where that is not on it. `None` is this
54    /// process's own `PATH`: an app opened from Finder has almost nothing on it, so it
55    /// passes the one the person's login shell would have.
56    pub(crate) search_path: Option<std::ffi::OsString>,
57    /// The launchd job this process runs as, where a test says. `None` is the one launchd
58    /// named when it started this process.
59    #[cfg(target_os = "macos")]
60    pub(crate) launchd_job: Option<String>,
61    /// Where the time comes from. The machine's clock in every real context; a test puts
62    /// its own here to reach the judgements that only happen at a particular moment.
63    pub(crate) clock: Arc<dyn Clock>,
64    /// The machine's credential stores. This build's host in every real context.
65    pub(crate) host: Arc<dyn Host>,
66    /// Who answers for Anthropic. The network in every real context.
67    pub(crate) api: Arc<dyn Api>,
68    /// Who answers for OpenAI. The network in every real context.
69    pub(crate) openai: Arc<dyn OpenAi>,
70}
71
72impl Context {
73    /// Epoch seconds, from this context's clock.
74    pub(crate) fn now(&self) -> i64 {
75        self.clock.now()
76    }
77
78    /// Epoch milliseconds, from this context's clock.
79    pub(crate) fn now_millis(&self) -> i64 {
80        self.clock.now_millis()
81    }
82
83    /// The person's home directory, which is where a platform's own scheduler lives.
84    pub(crate) fn home(&self) -> &std::path::Path {
85        &self.home
86    }
87
88    /// The credential stores this context reaches.
89    pub(crate) fn host(&self) -> &dyn Host {
90        self.host.as_ref()
91    }
92
93    /// Who this context asks about a login.
94    pub(crate) fn openai(&self) -> &dyn OpenAi {
95        self.openai.as_ref()
96    }
97
98    pub(crate) fn api(&self) -> &dyn Api {
99        self.api.as_ref()
100    }
101
102    /// Claude Code's defaults for a person whose home is `home`: `~/.pitboard`, `~/.claude`,
103    /// the default credential slot, `claude` looked up on `PATH`. An app starts here and sets
104    /// only what differs.
105    pub fn new(home: PathBuf) -> Context {
106        Context {
107            pitboard_home: home.join(".pitboard"),
108            home,
109            claude_config_dir: None,
110            secure_storage_dir: None,
111            user: None,
112            custom_oauth: false,
113            argv_fallback: true,
114            overriding_auth: Vec::new(),
115            caller: "unknown".into(),
116            claude_program: PathBuf::from("claude"),
117            api_base: None,
118            hover_rest: false,
119            codex_home: None,
120            codex_program: PathBuf::from("codex"),
121            schedule_program: None,
122            search_path: None,
123            #[cfg(target_os = "macos")]
124            launchd_job: None,
125            clock: Arc::new(SystemClock),
126            host: crate::store::host(),
127            api: Arc::new(Anthropic),
128            openai: Arc::new(OpenAiNetwork),
129        }
130    }
131
132    /// Where Codex keeps its login. Empty means unset, as Codex reads it.
133    pub fn with_codex_home(mut self, dir: String) -> Context {
134        self.codex_home = Some(dir).filter(|d| !d.is_empty());
135        self
136    }
137
138    pub(crate) fn codex_home(&self) -> Option<&str> {
139        self.codex_home.as_deref()
140    }
141
142    pub fn with_pitboard_home(mut self, dir: PathBuf) -> Context {
143        self.pitboard_home = dir;
144        self
145    }
146
147    /// Empty means unset, as Claude Code reads `CLAUDE_CONFIG_DIR`.
148    pub fn with_claude_config_dir(mut self, dir: String) -> Context {
149        self.claude_config_dir = Some(dir).filter(|d| !d.is_empty());
150        self
151    }
152
153    /// Empty is set, and pins the default slot, as Claude Code reads
154    /// `CLAUDE_SECURESTORAGE_CONFIG_DIR`.
155    pub fn with_secure_storage_dir(mut self, dir: String) -> Context {
156        self.secure_storage_dir = Some(dir);
157        self
158    }
159
160    /// The login name whose keychain account Claude Code stores under.
161    /// Allows the argument-line write for a login too large for the stdin one.
162    pub fn with_argv_fallback(mut self, allowed: bool) -> Context {
163        self.argv_fallback = allowed;
164        self
165    }
166
167    /// Whether a custom OAuth endpoint is configured, which moves Claude Code's login.
168    pub fn custom_oauth(&self) -> bool {
169        self.custom_oauth
170    }
171
172    /// The `claude` pitboard would run to sign someone in.
173    pub fn claude_program(&self) -> &std::path::Path {
174        &self.claude_program
175    }
176
177    /// Whether the argument-line write is allowed for an oversized login.
178    pub fn argv_fallback(&self) -> bool {
179        self.argv_fallback
180    }
181
182    /// Environment variables that authenticate Claude Code some other way, if any.
183    pub fn overriding_auth(&self) -> &[String] {
184        &self.overriding_auth
185    }
186
187    /// Names the front end in the audit log.
188    pub fn with_caller(mut self, caller: String) -> Context {
189        self.caller = caller;
190        self
191    }
192
193    pub fn with_user(mut self, user: String) -> Context {
194        self.user = Some(user);
195        self
196    }
197
198    /// An app started from Finder does not see the shell's `PATH`, so it names `claude` itself.
199    pub fn with_claude_program(mut self, program: PathBuf) -> Context {
200        self.claude_program = program;
201        self
202    }
203
204    /// The same for `codex`.
205    pub fn with_codex_program(mut self, program: PathBuf) -> Context {
206        self.codex_program = program;
207        self
208    }
209
210    /// The `codex` pitboard would run to sign someone in.
211    pub fn codex_program(&self) -> &std::path::Path {
212        &self.codex_program
213    }
214
215    /// An app is not a command line, so it names the one it comes with for the schedule to
216    /// run.
217    pub fn with_schedule_program(mut self, program: PathBuf) -> Context {
218        self.schedule_program = Some(program);
219        self
220    }
221
222    /// The pitboard the daily renewal schedule is written to run, where one was named.
223    pub fn schedule_program(&self) -> Option<&std::path::Path> {
224        self.schedule_program.as_deref()
225    }
226
227    /// Look for a tool's program on `path`, in `PATH`'s form, rather than on this process's
228    /// own `PATH`. An app opened from Finder has only the system's directories there, so a
229    /// tool installed through a version manager or an npm prefix is found only on the `PATH`
230    /// the person's shell has, and a script it runs finds its interpreter only there.
231    pub fn with_search_path(mut self, path: String) -> Context {
232        self.search_path = Some(path.into());
233        self
234    }
235
236    /// Where a tool's program is looked for: the path given, or this process's `PATH`.
237    pub(crate) fn search_path(&self) -> std::ffi::OsString {
238        self.search_path
239            .clone()
240            .or_else(|| std::env::var_os("PATH"))
241            .unwrap_or_default()
242    }
243
244    /// The label of the launchd job this process runs as, which launchd puts in
245    /// `XPC_SERVICE_NAME` when it starts one.
246    #[cfg(target_os = "macos")]
247    pub(crate) fn launchd_job(&self) -> Option<String> {
248        self.launchd_job
249            .clone()
250            .or_else(|| std::env::var("XPC_SERVICE_NAME").ok())
251    }
252
253    /// Say this process runs as the launchd job `label`, which no test does.
254    #[cfg(all(target_os = "macos", test))]
255    pub(crate) fn with_launchd_job(mut self, label: String) -> Context {
256        self.launchd_job = Some(label);
257        self
258    }
259
260    /// The program named for this tool, found or not.
261    pub fn program_for(&self, tool: crate::provider::ProviderId) -> &std::path::Path {
262        match tool {
263            crate::provider::ProviderId::Claude => &self.claude_program,
264            crate::provider::ProviderId::Codex => &self.codex_program,
265        }
266    }
267
268    pub fn from_env() -> Context {
269        let var = |name: &str| std::env::var(name).ok();
270        let home = std::env::var_os("HOME")
271            .map(PathBuf::from)
272            .unwrap_or_default();
273        Context {
274            pitboard_home: std::env::var_os("PITBOARD_HOME")
275                .map(PathBuf::from)
276                .unwrap_or_else(|| home.join(".pitboard")),
277            home,
278            claude_config_dir: var("CLAUDE_CONFIG_DIR").filter(|v| !v.is_empty()),
279            secure_storage_dir: var("CLAUDE_SECURESTORAGE_CONFIG_DIR"),
280            user: var("USER"),
281            custom_oauth: var("CLAUDE_CODE_CUSTOM_OAUTH_URL").is_some_and(|v| !v.is_empty()),
282            argv_fallback: !var("PITBOARD_NO_ARGV").is_some_and(|v| v == "1"),
283            overriding_auth: crate::settings::OVERRIDING_ENV
284                .iter()
285                .filter(|name| var(name).is_some_and(|v| !v.is_empty()))
286                .map(|name| (*name).to_string())
287                .collect(),
288            caller: "cli".into(),
289            claude_program: PathBuf::from("claude"),
290            api_base: var("PITBOARD_API_BASE"),
291            hover_rest: var("CLAUDE_CODE_HOVER_REST").is_some_and(|v| v == "1" || v == "true"),
292            codex_home: var("CODEX_HOME").filter(|v| !v.is_empty()),
293            codex_program: PathBuf::from("codex"),
294            schedule_program: None,
295            search_path: None,
296            #[cfg(target_os = "macos")]
297            launchd_job: None,
298            clock: Arc::new(SystemClock),
299            host: crate::store::host(),
300            api: Arc::new(Anthropic),
301            openai: Arc::new(OpenAiNetwork),
302        }
303    }
304
305    /// Answer for every service from a script, where a test can produce a 429 or a
306    /// refusal. One script for all of them, so a test that forgets to script a tool's
307    /// service gets a refusal rather than a request to the real one.
308    #[cfg(any(test, feature = "test-support"))]
309    #[doc(hidden)]
310    pub fn with_scripted_api(mut self, api: Arc<crate::api::scripted::ScriptedApi>) -> Context {
311        self.api = api.clone();
312        self.openai = api;
313        self
314    }
315
316    /// Put the credential stores in memory, where a test can make them fail. Only the
317    /// tests do this, which is why the trait behind it is not public.
318    #[cfg(any(test, feature = "test-support"))]
319    #[doc(hidden)]
320    pub fn with_memory_stores(mut self, memory: Arc<crate::store::memory::MemoryHost>) -> Context {
321        self.host = memory;
322        self
323    }
324
325    /// Read the time from somewhere else. Only the tests do this, which is why it is not
326    /// part of the builder a front end uses.
327    #[cfg(any(test, feature = "test-support"))]
328    #[doc(hidden)]
329    pub fn with_clock(mut self, clock: Arc<dyn Clock>) -> Context {
330        self.clock = clock;
331        self
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338
339    #[test]
340    fn an_explicit_context_reads_claude_codes_settings_the_way_the_environment_does() {
341        let ctx = Context::new(PathBuf::from("/home/x"))
342            .with_claude_config_dir(String::new())
343            .with_secure_storage_dir(String::new());
344        assert_eq!(ctx.pitboard_home, PathBuf::from("/home/x/.pitboard"));
345        assert_eq!(ctx.claude_config_dir, None, "empty means unset");
346        assert_eq!(
347            ctx.secure_storage_dir.as_deref(),
348            Some(""),
349            "empty is set, and pins the default slot"
350        );
351        assert_eq!(ctx.claude_program, PathBuf::from("claude"));
352    }
353
354    #[test]
355    fn only_a_front_end_that_names_one_changes_what_the_schedule_runs() {
356        assert_eq!(Context::from_env().schedule_program(), None);
357        let ctx = Context::new(PathBuf::from("/Users/x"));
358        assert_eq!(ctx.schedule_program(), None);
359        let bundled = PathBuf::from("/Applications/Pitboard.app/Contents/Helpers/pitboard");
360        assert_eq!(
361            ctx.with_schedule_program(bundled.clone())
362                .schedule_program(),
363            Some(bundled.as_path())
364        );
365    }
366}