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