pitboard_core/
settings.rs1use crate::context::Context;
23use crate::host::Os;
24use crate::provider::claude::paths as claude;
25use serde_json::Value;
26use std::path::PathBuf;
27
28pub(crate) const OVERRIDING_ENV: [&str; 9] = [
31 "ANTHROPIC_API_KEY",
32 "ANTHROPIC_AUTH_TOKEN",
33 "CLAUDE_CODE_OAUTH_TOKEN",
34 "CLAUDE_CODE_USE_BEDROCK",
35 "CLAUDE_CODE_USE_VERTEX",
36 "CLAUDE_CODE_USE_FOUNDRY",
37 "CLAUDE_CODE_USE_GATEWAY",
38 "CLAUDE_CODE_USE_ANTHROPIC_AWS",
39 "CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD",
40];
41
42#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct Override {
45 pub layer: String,
47 pub key: String,
49}
50
51impl std::fmt::Display for Override {
52 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53 write!(f, "{} in {}", self.key, self.layer)
54 }
55}
56
57fn managed_dir(os: Os) -> Option<PathBuf> {
61 match os {
62 Os::MacOs => Some("/Library/Application Support/ClaudeCode".into()),
63 Os::Linux => Some("/etc/claude-code".into()),
64 Os::Windows => None,
65 }
66}
67
68pub(crate) fn unread() -> Option<&'static str> {
71 unread_on(crate::host::OS)
72}
73
74fn unread_on(os: Os) -> Option<&'static str> {
75 managed_dir(os)
76 .is_none()
77 .then_some("Claude Code's managed settings are not read on Windows yet")
78}
79
80fn managed_files() -> Vec<PathBuf> {
81 let Some(dir) = managed_dir(crate::host::OS) else {
82 return Vec::new();
83 };
84 let mut files = vec![dir.join("managed-settings.json")];
85 if let Ok(entries) = std::fs::read_dir(dir.join("managed-settings.d")) {
86 let mut drop_ins: Vec<PathBuf> = entries
87 .flatten()
88 .map(|e| e.path())
89 .filter(|p| p.extension().is_some_and(|e| e == "json"))
90 .collect();
91 drop_ins.sort();
92 files.extend(drop_ins);
93 }
94 files
95}
96
97fn is_set(value: &str) -> bool {
99 !matches!(value.trim(), "" | "0" | "false")
100}
101
102fn overrides_in(path: &std::path::Path) -> Vec<Override> {
103 let Ok(raw) = std::fs::read_to_string(path) else {
104 return Vec::new();
105 };
106 let Ok(json) = serde_json::from_str::<Value>(&raw) else {
107 return Vec::new();
108 };
109 let layer = path.display().to_string();
110 let mut found = Vec::new();
111 if json
113 .get("apiKeyHelper")
114 .and_then(Value::as_str)
115 .is_some_and(|h| !h.trim().is_empty())
116 {
117 found.push(Override {
118 layer: layer.clone(),
119 key: "apiKeyHelper".into(),
120 });
121 }
122 if let Some(env) = json.get("env").and_then(Value::as_object) {
123 for name in OVERRIDING_ENV {
124 let set = match env.get(name) {
125 Some(Value::String(v)) => is_set(v),
126 Some(Value::Bool(b)) => *b,
127 Some(Value::Number(n)) => n.as_i64() != Some(0),
128 _ => false,
129 };
130 if set {
131 found.push(Override {
132 layer: layer.clone(),
133 key: format!("env.{name}"),
134 });
135 }
136 }
137 }
138 found
139}
140
141pub fn custom_oauth(ctx: &Context) -> bool {
148 if ctx.custom_oauth() {
149 return true;
150 }
151 managed_files()
152 .iter()
153 .chain(std::iter::once(
154 &claude::config_dir(ctx).join("settings.json"),
155 ))
156 .any(|path| {
157 std::fs::read_to_string(path)
158 .ok()
159 .and_then(|raw| serde_json::from_str::<Value>(&raw).ok())
160 .and_then(|json| {
161 json.get("env")?
162 .get("CLAUDE_CODE_CUSTOM_OAUTH_URL")?
163 .as_str()
164 .map(is_set)
165 })
166 .unwrap_or(false)
167 })
168}
169
170pub fn overrides(ctx: &Context) -> Vec<Override> {
174 let mut found: Vec<Override> = managed_files()
175 .iter()
176 .chain(std::iter::once(
177 &claude::config_dir(ctx).join("settings.json"),
178 ))
179 .flat_map(|path| overrides_in(path))
180 .collect();
181 found.extend(ctx.overriding_auth().iter().map(|key| Override {
183 layer: "the environment".into(),
184 key: key.clone(),
185 }));
186 found
187}
188
189#[cfg(test)]
190pub(crate) mod tests_support {
191 use super::*;
192
193 pub(crate) struct Scratch(pub(crate) PathBuf);
194 impl Drop for Scratch {
195 fn drop(&mut self) {
196 let _ = std::fs::remove_dir_all(&self.0);
197 }
198 }
199
200 pub(crate) fn home(name: &str) -> (Context, PathBuf, Scratch) {
201 let root = std::env::temp_dir().join(format!(
202 "pitboard-settings-{name}-{}-{:?}",
203 std::process::id(),
204 std::thread::current().id()
205 ));
206 let _ = std::fs::remove_dir_all(&root);
207 let config = root.join(".claude");
208 std::fs::create_dir_all(&config).expect("a config dir");
209 let ctx = Context::new(root.clone())
210 .with_claude_config_dir(config.to_string_lossy().into_owned());
211 (ctx, config, Scratch(root))
212 }
213
214 pub(crate) fn write(path: &std::path::Path, value: Value) {
215 std::fs::write(path, value.to_string()).expect("written");
216 }
217}
218
219#[cfg(test)]
220mod tests {
221 use super::tests_support::*;
222 use super::*;
223 use serde_json::json;
224
225 #[test]
228 fn managed_settings_are_said_unread_where_pitboard_does_not_read_them() {
229 assert_eq!(unread_on(Os::MacOs), None);
230 assert_eq!(unread_on(Os::Linux), None);
231 assert_eq!(
232 unread_on(Os::Windows),
233 Some("Claude Code's managed settings are not read on Windows yet")
234 );
235 }
236
237 #[test]
238 fn an_ordinary_machine_has_nothing_in_the_way() {
239 let (ctx, config, _s) = home("clean");
240 write(&config.join("settings.json"), json!({"theme": "dark"}));
241 assert_eq!(overrides(&ctx), Vec::new());
242 }
243
244 #[test]
245 fn a_settings_file_that_sets_a_key_is_found_where_the_environment_would_not_be() {
246 let (ctx, config, _s) = home("env-block");
247 write(
248 &config.join("settings.json"),
249 json!({"env": {"ANTHROPIC_API_KEY": "sk-ant-something"}}),
250 );
251 let found = overrides(&ctx);
252 assert_eq!(found.len(), 1, "{found:?}");
253 assert_eq!(found[0].key, "env.ANTHROPIC_API_KEY");
254 assert!(found[0].layer.ends_with("settings.json"));
255 }
256
257 #[test]
258 fn a_helper_that_prints_a_key_is_an_override_whatever_it_prints() {
259 let (ctx, config, _s) = home("helper");
260 write(
261 &config.join("settings.json"),
262 json!({"apiKeyHelper": "/usr/local/bin/get-key"}),
263 );
264 assert_eq!(overrides(&ctx)[0].key, "apiKeyHelper");
265 }
266
267 #[test]
268 fn a_third_party_provider_is_an_override_too() {
269 let (ctx, config, _s) = home("bedrock");
270 write(
271 &config.join("settings.json"),
272 json!({"env": {"CLAUDE_CODE_USE_BEDROCK": "1"}}),
273 );
274 assert_eq!(overrides(&ctx)[0].key, "env.CLAUDE_CODE_USE_BEDROCK");
275 }
276
277 #[test]
279 fn a_key_that_is_present_but_empty_sets_nothing() {
280 let (ctx, config, _s) = home("empty");
281 write(
282 &config.join("settings.json"),
283 json!({
284 "apiKeyHelper": " ",
285 "env": {
286 "ANTHROPIC_API_KEY": "",
287 "CLAUDE_CODE_USE_BEDROCK": "0",
288 "CLAUDE_CODE_USE_VERTEX": "false",
289 },
290 }),
291 );
292 assert_eq!(overrides(&ctx), Vec::new());
293 }
294
295 #[test]
296 fn a_settings_file_that_is_not_json_is_not_a_reason_to_refuse_anything() {
297 let (ctx, config, _s) = home("broken");
298 std::fs::write(config.join("settings.json"), "{ not json").expect("written");
299 assert_eq!(overrides(&ctx), Vec::new());
300 }
301}
302
303#[cfg(test)]
304mod custom_oauth_tests {
305 use super::tests_support::*;
306 use super::*;
307 use serde_json::json;
308
309 #[test]
310 fn a_custom_endpoint_in_a_settings_file_is_found_the_way_the_environment_one_is() {
311 let (ctx, config, _s) = home("custom-oauth");
312 assert!(!custom_oauth(&ctx));
313 write(
314 &config.join("settings.json"),
315 json!({"env": {"CLAUDE_CODE_CUSTOM_OAUTH_URL": "https://oauth.example"}}),
316 );
317 assert!(custom_oauth(&ctx));
318 }
319}