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    /// What the live login costs against the store's ceiling, where there is one.
43    pub credential_cost: Option<(usize, usize)>,
44    pub home: PathBuf,
45    pub home_mode: Option<u32>,
46    pub machine_id_known: bool,
47    /// `CLAUDE_CODE_HOVER_REST`, which switches on the successor credential backend.
48    pub hover_rest_env: bool,
49    pub state: Result<State, Error>,
50    /// Each enrolled account's parked login, read back from the vault.
51    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    /// Why it cannot be read back, if it cannot.
61    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            // What Claude Code's config says, when it says anything. pitboard's own
71            // record of its last switch says nothing about a sign-in made elsewhere.
72            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    // A login that grows past the ceiling cannot be switched at all, and it grows by things
233    // done elsewhere, so it is worth saying before the day it refuses.
234    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
438/// A parked login this close to expiring is worth renewing now.
439pub 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
485/// Claude Code's successor credential backend: a stub in every build so far, but compiled
486/// in and switched on from the server.
487fn 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
508/// The checks, and where Claude Code's files were found, for a program to read rather than
509/// parse out of the checks' wording.
510pub 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
529/// No check failed. Warnings are advice; a failure means an assumption broke.
530pub 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}