1use crate::context::Context;
6use crate::error::Error;
7use crate::state::{Park, State};
8use crate::{claude, home, park, slot, store, switch, time, usage};
9use serde_json::{Value, json};
10use std::path::PathBuf;
11
12#[derive(Debug, PartialEq, Clone, Copy)]
13pub enum Level {
14 Ok,
15 Warn,
16 Fail,
17}
18
19pub struct Check {
20 pub code: &'static str,
22 pub name: String,
23 pub level: Level,
24 pub detail: String,
25 pub advice: String,
27}
28
29pub struct Facts {
31 pub security_tool: Option<String>,
32 pub config_path: PathBuf,
33 pub config: Result<Value, Error>,
34 pub identity: Option<claude::Identity>,
35 pub service: String,
36 pub account: String,
37 pub default_slot: bool,
38 pub storage_dir: String,
39 pub backend: Result<store::Backend, store::Error>,
40 pub credential_file: PathBuf,
41 pub credential: Result<Option<Value>, store::Error>,
42 pub credential_cost: Option<(usize, usize)>,
44 pub home: PathBuf,
45 pub home_mode: Option<u32>,
46 pub machine_id_known: bool,
47 pub hover_rest_env: bool,
49 pub state: Result<State, Error>,
50 pub parks: Vec<ParkFact>,
52 pub interrupted: bool,
53 pub now: i64,
54}
55
56pub struct ParkFact {
57 pub label: String,
58 pub active: bool,
59 pub park: Option<Park>,
60 pub unreadable: Option<String>,
62}
63
64fn park_facts(ctx: &Context, state: &State, live_uuid: Option<&str>) -> Vec<ParkFact> {
65 state
66 .accounts
67 .iter()
68 .map(|a| ParkFact {
69 label: a.label.clone(),
70 active: match live_uuid {
73 Some(uuid) => a.account_uuid == uuid,
74 None => state.active.as_deref() == Some(a.label.as_str()),
75 },
76 park: a.parked.clone(),
77 unreadable: a.parked.as_ref().and_then(|p| {
78 park::load(ctx, &a.label, p).err().map(|e| match e {
79 Error::ParkedCredentialMissing { .. } => "missing from the vault".into(),
80 Error::ParkedCredentialCorrupt { detail, .. } => detail,
81 other => other.to_string(),
82 })
83 }),
84 })
85 .collect()
86}
87
88pub fn gather(ctx: &Context) -> Facts {
89 let config = claude::load_config(ctx);
90 let state = crate::state::load(ctx);
91 let service = claude::live_service(ctx);
92 let home = home::dir(ctx);
93 let identity = config.as_ref().ok().and_then(claude::identity);
94 Facts {
95 security_tool: cfg!(target_os = "macos")
96 .then(|| store::SECURITY.to_string())
97 .filter(|p| std::fs::metadata(p).is_ok()),
98 config_path: claude::config_file(ctx),
99 identity: identity.clone(),
100 config,
101 account: slot::account_name(ctx),
102 default_slot: claude::is_default_slot(ctx),
103 storage_dir: claude::storage_dir(ctx),
104 backend: store::resolve(ctx, &service),
105 credential_file: store::credential_file(ctx),
106 credential_cost: store::read_raw(ctx, &service)
107 .ok()
108 .flatten()
109 .and_then(|raw| store::cost(ctx, &service, &raw)),
110 credential: store::read(ctx, &service),
111 home_mode: mode_of(&home),
112 home,
113 machine_id_known: crate::state::machine_id() != "unknown",
114 hover_rest_env: ctx.hover_rest,
115 parks: state
116 .as_ref()
117 .map(|s| park_facts(ctx, s, identity.as_ref().map(|i| i.account_uuid.as_str())))
118 .unwrap_or_default(),
119 state,
120 interrupted: switch::interrupted(ctx),
121 service,
122 now: crate::time::now(),
123 }
124}
125
126fn mode_of(path: &std::path::Path) -> Option<u32> {
127 use std::os::unix::fs::PermissionsExt;
128 Some(std::fs::metadata(path).ok()?.permissions().mode() & 0o777)
129}
130
131fn ok(code: &'static str, name: impl Into<String>, detail: impl Into<String>) -> Check {
132 Check {
133 code,
134 name: name.into(),
135 level: Level::Ok,
136 detail: detail.into(),
137 advice: String::new(),
138 }
139}
140fn warn(
141 code: &'static str,
142 name: impl Into<String>,
143 detail: impl Into<String>,
144 advice: impl Into<String>,
145) -> Check {
146 Check {
147 code,
148 name: name.into(),
149 level: Level::Warn,
150 detail: detail.into(),
151 advice: advice.into(),
152 }
153}
154fn fail(
155 code: &'static str,
156 name: impl Into<String>,
157 detail: impl Into<String>,
158 advice: impl Into<String>,
159) -> Check {
160 Check {
161 code,
162 name: name.into(),
163 level: Level::Fail,
164 detail: detail.into(),
165 advice: advice.into(),
166 }
167}
168
169pub fn evaluate(facts: &Facts) -> Vec<Check> {
170 let mut checks = Vec::new();
171
172 if cfg!(target_os = "macos") {
173 checks.push(match &facts.security_tool {
174 Some(path) => ok("security_tool", "security tool", path.clone()),
175 None => fail(
176 "security_tool",
177 "security tool",
178 format!("{} is missing", store::SECURITY),
179 "pitboard reads the keychain the same way Claude Code does. Without it, nothing works.",
180 ),
181 });
182 }
183
184 checks.push(match &facts.config {
185 Ok(v) => ok(
186 "config_file",
187 "config file",
188 format!(
189 "{} ({} keys)",
190 facts.config_path.display(),
191 v.as_object().map_or(0, serde_json::Map::len)
192 ),
193 ),
194 Err(e) => fail(
195 "config_file",
196 "config file",
197 e.to_string(),
198 "Run `claude` once.",
199 ),
200 });
201
202 checks.push(match &facts.identity {
203 Some(id) => ok(
204 "identity",
205 "identity",
206 format!("{} · org {}", id.email, id.organization_uuid),
207 ),
208 None => warn(
209 "identity",
210 "identity",
211 "Claude Code has not recorded a signed-in account",
212 "It writes this after its first successful call. Run `claude` once.",
213 ),
214 });
215
216 checks.push(ok(
217 "slot",
218 "slot",
219 if facts.default_slot {
220 format!(
221 "default · {} · account {}",
222 facts.service, facts.account
223 )
224 } else {
225 format!(
226 "{} · account {} (selected by {})",
227 facts.service, facts.account, facts.storage_dir
228 )
229 },
230 ));
231
232 if let Some((bytes, limit)) = facts.credential_cost {
235 let detail = format!("{bytes} of {limit} bytes");
236 checks.push(if bytes * 10 >= limit * 9 {
237 warn(
238 "credential_size",
239 "login size",
240 detail,
241 "`security` reads at most this much of a command from stdin, and pitboard \
242 will not put a token on the argument line unless asked. Past the limit a \
243 switch is refused, unless PITBOARD_ARGV_FALLBACK=1 says to write it the \
244 way Claude Code writes it.",
245 )
246 } else {
247 ok("credential_size", "login size", detail)
248 });
249 }
250
251 checks.push(match &facts.backend {
252 Ok(store::Backend::Keychain) => ok("credential_store", "credential store", "keychain"),
253 Ok(store::Backend::File) => {
254 let detail = format!("plaintext file · {}", facts.credential_file.display());
255 if cfg!(target_os = "macos") {
256 warn(
257 "credential_store",
258 "credential store",
259 detail,
260 "Claude Code fell back to a file, which means a keychain write failed at some point.",
261 )
262 } else {
263 ok("credential_store", "credential store", detail)
264 }
265 }
266 Ok(store::Backend::Absent) => warn(
267 "credential_store",
268 "credential store",
269 "no credential in any backend",
270 "Nothing is signed in for this slot.",
271 ),
272 Err(e) => fail(
273 "credential_store",
274 "credential store",
275 e.to_string(),
276 "Treat this as unknown, never as empty.",
277 ),
278 });
279
280 checks.push(judge_credential(facts));
281
282 checks.push(
283 match (
284 facts
285 .config
286 .as_ref()
287 .ok()
288 .and_then(usage::from_config_cache),
289 &facts.identity,
290 ) {
291 (Some(s), Some(id)) if s.account_uuid.as_deref() == Some(id.account_uuid.as_str()) => {
292 ok(
293 "usage_cache",
294 "usage cache",
295 format!("{} windows, measured for this account", s.windows.len()),
296 )
297 }
298 (Some(_), Some(_)) => warn(
299 "usage_cache",
300 "usage cache",
301 "cached for a different account",
302 "Its numbers are ignored rather than shown, which is why status may look empty.",
303 ),
304 (Some(s), None) => ok(
305 "usage_cache",
306 "usage cache",
307 format!("{} windows", s.windows.len()),
308 ),
309 (None, _) => ok(
310 "usage_cache",
311 "usage cache",
312 "absent; Claude Code writes it after a call that reports usage",
313 ),
314 },
315 );
316
317 checks.push(match facts.home_mode {
318 None => ok(
319 "home",
320 "pitboard home",
321 format!("{} (not created yet)", facts.home.display()),
322 ),
323 Some(0o700) => ok("home", "pitboard home", facts.home.display().to_string()),
324 Some(mode) => warn(
325 "home",
326 "pitboard home",
327 format!("{} is mode {mode:o}", facts.home.display()),
328 format!(
329 "Park names contain account identifiers, so only you should read it: \
330 `chmod 700 {}`.",
331 facts.home.display()
332 ),
333 ),
334 });
335
336 checks.push(match &facts.state {
337 Ok(state) => ok(
338 "state",
339 "accounts",
340 match state.accounts.len() {
341 0 => "none enrolled yet".to_string(),
342 1 => "1 enrolled".to_string(),
343 n => format!("{n} enrolled"),
344 },
345 ),
346 Err(e) => fail(
347 "state",
348 "accounts",
349 e.to_string(),
350 "pitboard will not switch until its account list can be read.",
351 ),
352 });
353 checks.extend(facts.parks.iter().map(|p| judge_park(p, facts.now)));
354 if facts.interrupted {
355 checks.push(warn(
356 "interrupted_switch",
357 "interrupted switch",
358 "a switch did not finish",
359 "The next `pitboard use`, `enroll` or `forget` finishes it before anything else.",
360 ));
361 }
362 if let Ok(state) = &facts.state
363 && !state.discarded.is_empty()
364 {
365 checks.push(warn(
366 "discarded",
367 "old parked logins",
368 format!("{} waiting to be deleted", state.discarded.len()),
369 "pitboard deletes them on its next change; if they stay, check the keychain is unlocked.",
370 ));
371 }
372
373 if !facts.machine_id_known {
374 checks.push(warn(
375 "machine_id",
376 "machine id",
377 "this machine has no stable identifier",
378 "pitboard cannot tell this machine from another that also lacks one, so it cannot \
379 refuse state copied between them. Never copy ~/.pitboard between machines.",
380 ));
381 }
382
383 checks.push(judge_storage_v5(facts));
384 checks
385}
386
387fn judge_credential(facts: &Facts) -> Check {
388 match &facts.credential {
389 Ok(Some(doc)) => {
390 let keys: Vec<&str> = doc
391 .as_object()
392 .map(|o| o.keys().map(String::as_str).collect())
393 .unwrap_or_default();
394 let Some(oauth) = doc.get("claudeAiOauth").and_then(Value::as_object) else {
395 return fail(
396 "credential",
397 "credential",
398 format!("the document has no claudeAiOauth; its keys are {keys:?}"),
399 "The credential's shape changed. Do not switch accounts until this is understood.",
400 );
401 };
402 let fingerprint = oauth
403 .get("refreshToken")
404 .and_then(Value::as_str)
405 .map(store::fingerprint)
406 .unwrap_or_else(|| "none".into());
407 let days = oauth
408 .get("refreshTokenExpiresAt")
409 .and_then(Value::as_i64)
410 .map_or(-1, |ms| (ms / 1000 - facts.now) / 86_400);
411 let detail = format!("refresh {fingerprint} · {days} days left · keys {keys:?}");
412 if days < 3 {
413 warn(
414 "credential",
415 "credential",
416 detail,
417 "This login expires soon and will need signing in again.",
418 )
419 } else {
420 ok("credential", "credential", detail)
421 }
422 }
423 Ok(None) => warn(
424 "credential",
425 "credential",
426 "nothing stored",
427 "Nothing is signed in for this slot.",
428 ),
429 Err(e) => fail(
430 "credential",
431 "credential",
432 e.to_string(),
433 "Do not write to the store while this is failing.",
434 ),
435 }
436}
437
438pub const RENEW_WITHIN: i64 = 3 * 86_400;
440
441fn judge_park(fact: &ParkFact, now: i64) -> Check {
442 let name = format!("account {}", fact.label);
443 let renew = format!("Run `pitboard enroll {} --sign-in`.", fact.label);
444 let Some(park) = &fact.park else {
445 return if fact.active {
446 ok(
447 "parked_login",
448 name,
449 "signed in; parked when you switch away",
450 )
451 } else {
452 warn("parked_login", name, "nothing parked to switch to", renew)
453 };
454 };
455 if let Some(why) = &fact.unreadable {
456 return fail(
457 "parked_login",
458 name,
459 format!("its parked login is unusable: {why}"),
460 renew,
461 );
462 }
463 match park.refresh_expires_at {
464 Some(at) if at <= now => warn(
465 "parked_login",
466 name,
467 format!("its parked login expired {}", time::moment(at, now)),
468 renew,
469 ),
470 Some(at) if at - now < RENEW_WITHIN => warn(
471 "parked_login",
472 name,
473 format!("its parked login expires in {}", time::span(at - now)),
474 renew,
475 ),
476 Some(at) => ok(
477 "parked_login",
478 name,
479 format!("parked, good for {}", time::span(at - now)),
480 ),
481 None => ok("parked_login", name, "parked"),
482 }
483}
484
485fn judge_storage_v5(facts: &Facts) -> Check {
488 let flag_on = facts
489 .config
490 .as_ref()
491 .ok()
492 .and_then(|c| c.get("cachedGrowthBookFeatures"))
493 .and_then(|f| f.get("tengu_hover_rest"))
494 .and_then(Value::as_bool)
495 .unwrap_or(false);
496 if facts.hover_rest_env || flag_on {
497 warn(
498 "storage_v5",
499 "storage v5",
500 "the successor credential backend is switched on",
501 "pitboard has not been verified against it. Check for an update before switching.",
502 )
503 } else {
504 ok("storage_v5", "storage v5", "inactive")
505 }
506}
507
508pub struct Diagnosis {
511 pub checks: Vec<Check>,
512 pub environment: Value,
513}
514
515pub fn run(ctx: &Context) -> Diagnosis {
516 let facts = gather(ctx);
517 Diagnosis {
518 checks: evaluate(&facts),
519 environment: json!({
520 "config_file": facts.config_path,
521 "storage_dir": facts.storage_dir,
522 "credential_service": facts.service,
523 "credential_store": facts.backend.as_ref().map_or("unreadable", |b| b.name()),
524 "home": facts.home,
525 }),
526 }
527}
528
529pub fn healthy(checks: &[Check]) -> bool {
531 checks.iter().all(|c| c.level != Level::Fail)
532}
533
534#[cfg(test)]
535mod tests {
536 use super::*;
537
538 fn facts() -> Facts {
539 Facts {
540 security_tool: Some("/usr/bin/security".into()),
541 config_path: PathBuf::from("/home/x/.claude.json"),
542 config: Ok(json!({"oauthAccount": {}})),
543 identity: Some(claude::Identity {
544 email: "a@b.c".into(),
545 account_uuid: "acc".into(),
546 organization_uuid: "org".into(),
547 organization_name: None,
548 subscription: None,
549 rate_limit_tier: None,
550 }),
551 credential_cost: Some((900, 4032)),
552 service: "Claude Code-credentials".into(),
553 account: "someone".into(),
554 default_slot: true,
555 storage_dir: "/home/x/.claude".into(),
556 backend: Ok(store::Backend::Keychain),
557 credential_file: PathBuf::from("/home/x/.claude/.credentials.json"),
558 credential: Ok(Some(json!({"claudeAiOauth": {
559 "refreshToken": "r",
560 "refreshTokenExpiresAt": 2_000_000_000_000i64
561 }}))),
562 home: PathBuf::from("/home/x/.pitboard"),
563 home_mode: Some(0o700),
564 machine_id_known: true,
565 hover_rest_env: false,
566 state: Ok(State::default()),
567 parks: Vec::new(),
568 interrupted: false,
569 now: NOW,
570 }
571 }
572
573 const NOW: i64 = 1_789_935_600;
574
575 fn parked(label: &str, refresh_expires_at: Option<i64>) -> ParkFact {
576 ParkFact {
577 label: label.into(),
578 active: false,
579 park: Some(Park {
580 service: format!("pitboard-park-{label}-1"),
581 parked_at: NOW - 86_400,
582 refresh_fingerprint: "f".into(),
583 access_expires_at: None,
584 refresh_expires_at,
585 }),
586 unreadable: None,
587 }
588 }
589
590 fn named<'a>(checks: &'a [Check], name: &str) -> &'a Check {
591 checks.iter().find(|c| c.name == name).expect(name)
592 }
593
594 fn check<'a>(checks: &'a [Check], code: &str) -> &'a Check {
595 checks.iter().find(|c| c.code == code).expect(code)
596 }
597
598 #[test]
599 fn a_healthy_machine_reports_no_failures() {
600 let checks = evaluate(&facts());
601 assert!(checks.iter().all(|c| c.level != Level::Fail));
602 assert!(healthy(&checks));
603 }
604
605 #[test]
606 fn a_credential_without_claude_ai_oauth_is_a_failure_not_a_warning() {
607 let mut f = facts();
608 f.credential = Ok(Some(json!({"slackTag": {}})));
609 let checks = evaluate(&f);
610 assert_eq!(check(&checks, "credential").level, Level::Fail);
611 assert!(!healthy(&checks));
612 }
613
614 #[test]
615 fn an_unreadable_store_fails_rather_than_reporting_nothing_stored() {
616 let mut f = facts();
617 f.credential = Err(store::Error::Unreadable("security exited 1".into()));
618 f.backend = Err(store::Error::Unreadable("security exited 1".into()));
619 let checks = evaluate(&f);
620 assert_eq!(check(&checks, "credential").level, Level::Fail);
621 assert_eq!(check(&checks, "credential_store").level, Level::Fail);
622 }
623
624 #[test]
625 fn a_login_about_to_expire_is_flagged() {
626 let mut f = facts();
627 f.credential = Ok(Some(json!({"claudeAiOauth": {
628 "refreshToken": "r",
629 "refreshTokenExpiresAt": (f.now + 86_400) * 1000
630 }})));
631 assert_eq!(check(&evaluate(&f), "credential").level, Level::Warn);
632 }
633
634 #[test]
635 fn a_loose_home_directory_is_flagged() {
636 let mut f = facts();
637 f.home_mode = Some(0o755);
638 let checks = evaluate(&f);
639 let home = check(&checks, "home");
640 assert_eq!(home.level, Level::Warn);
641 assert!(home.detail.contains("755"));
642 }
643
644 #[test]
645 fn the_successor_backend_is_flagged_when_the_server_turns_it_on() {
646 let mut f = facts();
647 f.config = Ok(json!({"cachedGrowthBookFeatures": {"tengu_hover_rest": true}}));
648 assert_eq!(check(&evaluate(&f), "storage_v5").level, Level::Warn);
649 }
650
651 #[test]
652 fn the_successor_backend_is_flagged_when_the_environment_turns_it_on() {
653 let mut f = facts();
654 assert_eq!(check(&evaluate(&f), "storage_v5").level, Level::Ok);
655 f.hover_rest_env = true;
656 assert_eq!(check(&evaluate(&f), "storage_v5").level, Level::Warn);
657 }
658
659 #[test]
660 fn every_failure_and_warning_tells_the_user_something() {
661 let mut f = facts();
662 f.credential = Ok(Some(json!({"slackTag": {}})));
663 f.home_mode = Some(0o755);
664 for c in evaluate(&f) {
665 if c.level != Level::Ok {
666 assert!(!c.advice.is_empty(), "{} has no advice", c.code);
667 }
668 }
669 }
670
671 #[test]
672 fn a_machine_without_a_stable_identifier_is_flagged() {
673 let mut f = facts();
674 f.machine_id_known = false;
675 let checks = evaluate(&f);
676 assert_eq!(check(&checks, "machine_id").level, Level::Warn);
677 assert!(evaluate(&facts()).iter().all(|c| c.code != "machine_id"));
678 }
679
680 #[test]
681 fn each_check_is_told_apart_by_code_and_name() {
682 let mut f = facts();
683 f.parks = vec![parked("work", None), parked("personal", None)];
684 let checks = evaluate(&f);
685 let mut keys: Vec<(&str, &str)> =
686 checks.iter().map(|c| (c.code, c.name.as_str())).collect();
687 keys.sort_unstable();
688 let before = keys.len();
689 keys.dedup();
690 assert_eq!(keys.len(), before);
691 }
692
693 #[test]
694 fn every_parked_login_is_judged_and_a_way_back_is_offered() {
695 let mut f = facts();
696 let mut unusable = parked("broken", Some(NOW + 30 * 86_400));
697 unusable.unreadable = Some("missing from the vault".into());
698 f.parks = vec![
699 parked("fine", Some(NOW + 20 * 86_400)),
700 parked("soon", Some(NOW + 86_400)),
701 parked("gone", Some(NOW - 1)),
702 unusable,
703 ParkFact {
704 label: "empty".into(),
705 active: false,
706 park: None,
707 unreadable: None,
708 },
709 ParkFact {
710 label: "live".into(),
711 active: true,
712 park: None,
713 unreadable: None,
714 },
715 ];
716 let checks = evaluate(&f);
717 for (name, level) in [
718 ("account fine", Level::Ok),
719 ("account soon", Level::Warn),
720 ("account gone", Level::Warn),
721 ("account broken", Level::Fail),
722 ("account empty", Level::Warn),
723 ("account live", Level::Ok),
724 ] {
725 let c = named(&checks, name);
726 assert_eq!(c.level, level, "{name}: {}", c.detail);
727 if level != Level::Ok {
728 let label = name.trim_start_matches("account ");
729 assert!(
730 c.advice
731 .contains(&format!("pitboard enroll {label} --sign-in"))
732 );
733 }
734 }
735 }
736
737 #[test]
738 fn an_interrupted_switch_and_leftover_parks_are_reported() {
739 let mut f = facts();
740 f.interrupted = true;
741 f.state = Ok(State {
742 discarded: vec!["pitboard-park-x-1".into()],
743 ..State::default()
744 });
745 let checks = evaluate(&f);
746 assert_eq!(check(&checks, "interrupted_switch").level, Level::Warn);
747 assert_eq!(check(&checks, "discarded").level, Level::Warn);
748 assert!(healthy(&checks), "neither stops pitboard working");
749 }
750}