Skip to main content

pitboard_core/
doctor.rs

1//! `pitboard doctor`: check, on this machine, that what pitboard relies on about Claude Code
2//! still holds, and say which assumption broke when one has. Gathering is kept apart from
3//! judging so every judgement can be tested.
4
5use 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    /// Stable, snake_case, safe for a program to branch on.
21    pub code: &'static str,
22    pub name: String,
23    pub level: Level,
24    pub detail: String,
25    /// What the user should do. Empty when there is nothing to do.
26    pub advice: String,
27}
28
29/// Everything read from the machine, so judging it touches nothing.
30pub 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    /// `CLAUDE_CODE_HOVER_REST`, which switches on the successor credential backend.
46    pub hover_rest_env: bool,
47    pub state: Result<State, Error>,
48    /// Each enrolled account's parked login, read back from the vault.
49    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    /// Why it cannot be read back, if it cannot.
59    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
407/// A parked login this close to expiring is worth renewing now.
408pub 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
454/// Claude Code's successor credential backend: a stub in every build so far, but compiled
455/// in and switched on from the server.
456fn 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
477/// The checks, and where Claude Code's files were found, for a program to read rather than
478/// parse out of the checks' wording.
479pub 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
498/// No check failed. Warnings are advice; a failure means an assumption broke.
499pub 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}