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 std::path::PathBuf;
6
7#[derive(Clone, Debug)]
8pub struct Context {
9    pub(crate) home: PathBuf,
10    pub(crate) pitboard_home: PathBuf,
11    /// `CLAUDE_CONFIG_DIR`, which Claude Code reads with `||`: empty means unset.
12    pub(crate) claude_config_dir: Option<String>,
13    /// `CLAUDE_SECURESTORAGE_CONFIG_DIR`, which Claude Code reads with `!== undefined`:
14    /// empty is set, and pins the default credential slot.
15    pub(crate) secure_storage_dir: Option<String>,
16    /// `$USER`, which names Claude Code's keychain account once screened by `slot`.
17    pub(crate) user: Option<String>,
18    /// `CLAUDE_CODE_CUSTOM_OAUTH_URL`. Set, it renames both the keychain item and the config
19    /// file Claude Code uses, so pitboard would be reading and writing the wrong ones.
20    pub(crate) custom_oauth: bool,
21    /// Environment variables that make Claude Code use something other than the login
22    /// pitboard moves, so a switch would change nothing it can see.
23    pub(crate) overriding_auth: Vec<String>,
24    /// Whether a login too large for `security -i` may be written the way Claude Code
25    /// writes it: as a command argument, where `ps` can see it for the length of the call.
26    pub(crate) argv_fallback: bool,
27    /// Which front end asked, for the audit log. A change made from the menu bar and one
28    /// typed at a prompt read the same otherwise.
29    pub(crate) caller: String,
30    /// The `claude` that runs a sign-in; a bare name is looked up on `PATH`.
31    pub(crate) claude_program: PathBuf,
32    /// Where Anthropic's endpoints are reached instead, for tests; `api` honours loopback only.
33    pub(crate) api_base: Option<String>,
34    /// `CLAUDE_CODE_HOVER_REST`, which switches on Claude Code's successor credential backend.
35    pub(crate) hover_rest: bool,
36}
37
38impl Context {
39    /// Claude Code's defaults for a person whose home is `home`: `~/.pitboard`, `~/.claude`,
40    /// the default credential slot, `claude` looked up on `PATH`. An app starts here and sets
41    /// only what differs.
42    pub fn new(home: PathBuf) -> Context {
43        Context {
44            pitboard_home: home.join(".pitboard"),
45            home,
46            claude_config_dir: None,
47            secure_storage_dir: None,
48            user: None,
49            custom_oauth: false,
50            argv_fallback: false,
51            overriding_auth: Vec::new(),
52            caller: "unknown".into(),
53            claude_program: PathBuf::from("claude"),
54            api_base: None,
55            hover_rest: false,
56        }
57    }
58
59    pub fn with_pitboard_home(mut self, dir: PathBuf) -> Context {
60        self.pitboard_home = dir;
61        self
62    }
63
64    /// Empty means unset, as Claude Code reads `CLAUDE_CONFIG_DIR`.
65    pub fn with_claude_config_dir(mut self, dir: String) -> Context {
66        self.claude_config_dir = Some(dir).filter(|d| !d.is_empty());
67        self
68    }
69
70    /// Empty is set, and pins the default slot, as Claude Code reads
71    /// `CLAUDE_SECURESTORAGE_CONFIG_DIR`.
72    pub fn with_secure_storage_dir(mut self, dir: String) -> Context {
73        self.secure_storage_dir = Some(dir);
74        self
75    }
76
77    /// The login name whose keychain account Claude Code stores under.
78    /// Allows the argument-line write for a login too large for the stdin one.
79    pub fn with_argv_fallback(mut self, allowed: bool) -> Context {
80        self.argv_fallback = allowed;
81        self
82    }
83
84    /// Whether a custom OAuth endpoint is configured, which moves Claude Code's login.
85    pub fn custom_oauth(&self) -> bool {
86        self.custom_oauth
87    }
88
89    /// The `claude` pitboard would run to sign someone in.
90    pub fn claude_program(&self) -> &std::path::Path {
91        &self.claude_program
92    }
93
94    /// Whether the argument-line write is allowed for an oversized login.
95    pub fn argv_fallback(&self) -> bool {
96        self.argv_fallback
97    }
98
99    /// Environment variables that authenticate Claude Code some other way, if any.
100    pub fn overriding_auth(&self) -> &[String] {
101        &self.overriding_auth
102    }
103
104    /// Names the front end in the audit log.
105    pub fn with_caller(mut self, caller: String) -> Context {
106        self.caller = caller;
107        self
108    }
109
110    pub fn with_user(mut self, user: String) -> Context {
111        self.user = Some(user);
112        self
113    }
114
115    /// An app started from Finder does not see the shell's `PATH`, so it names `claude` itself.
116    pub fn with_claude_program(mut self, program: PathBuf) -> Context {
117        self.claude_program = program;
118        self
119    }
120
121    pub fn from_env() -> Context {
122        let var = |name: &str| std::env::var(name).ok();
123        let home = std::env::var_os("HOME")
124            .map(PathBuf::from)
125            .unwrap_or_default();
126        Context {
127            pitboard_home: std::env::var_os("PITBOARD_HOME")
128                .map(PathBuf::from)
129                .unwrap_or_else(|| home.join(".pitboard")),
130            home,
131            claude_config_dir: var("CLAUDE_CONFIG_DIR").filter(|v| !v.is_empty()),
132            secure_storage_dir: var("CLAUDE_SECURESTORAGE_CONFIG_DIR"),
133            user: var("USER"),
134            custom_oauth: var("CLAUDE_CODE_CUSTOM_OAUTH_URL").is_some_and(|v| !v.is_empty()),
135            argv_fallback: var("PITBOARD_ARGV_FALLBACK").is_some_and(|v| v == "1"),
136            overriding_auth: OVERRIDING_AUTH
137                .iter()
138                .filter(|name| var(name).is_some_and(|v| !v.is_empty()))
139                .map(|name| (*name).to_string())
140                .collect(),
141            caller: "cli".into(),
142            claude_program: PathBuf::from("claude"),
143            api_base: var("PITBOARD_API_BASE"),
144            hover_rest: var("CLAUDE_CODE_HOVER_REST").is_some_and(|v| v == "1" || v == "true"),
145        }
146    }
147}
148
149/// Set, any of these makes Claude Code authenticate with something other than the login in
150/// the credential store, so moving that login changes nothing a session would notice.
151const OVERRIDING_AUTH: [&str; 3] = [
152    "ANTHROPIC_API_KEY",
153    "ANTHROPIC_AUTH_TOKEN",
154    "CLAUDE_CODE_OAUTH_TOKEN",
155];
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn an_explicit_context_reads_claude_codes_settings_the_way_the_environment_does() {
163        let ctx = Context::new(PathBuf::from("/home/x"))
164            .with_claude_config_dir(String::new())
165            .with_secure_storage_dir(String::new());
166        assert_eq!(ctx.pitboard_home, PathBuf::from("/home/x/.pitboard"));
167        assert_eq!(ctx.claude_config_dir, None, "empty means unset");
168        assert_eq!(
169            ctx.secure_storage_dir.as_deref(),
170            Some(""),
171            "empty is set, and pins the default slot"
172        );
173        assert_eq!(ctx.claude_program, PathBuf::from("claude"));
174    }
175}