Skip to main content

pitboard_core/
service.rs

1//! pitboard's operations, each run the way every front end must run it: a change settles any
2//! interrupted switch first and is recorded in the audit log, and what went wrong on the way
3//! is reported alongside the result, whether or not the change then succeeds.
4
5use crate::context::Context;
6use crate::doctor::{self, Diagnosis};
7use crate::error::{Error, Result};
8use crate::state::{self, Account};
9use crate::switch::{self, Enrolled, Outcome, Recovered, Renewal, Settled, SignIn};
10use crate::{audit, claude, status, statusline};
11use std::fmt;
12
13/// Something to know about that did not stop the operation.
14#[derive(Debug)]
15pub enum Warning {
16    /// An earlier switch had been interrupted; this run found what it did and recorded it.
17    Recovered(Recovered),
18    /// The login moved, but Claude Code's config still names the previous account.
19    ConfigNotUpdated(Error),
20    /// Parked logins no longer in use that could not be deleted yet.
21    ParksPendingRemoval(usize),
22    /// Anthropic refuses a parked login for good, so it was dropped.
23    ParkedLoginRefused {
24        label: String,
25    },
26    RenewalFailed(Error),
27    /// The environment authenticates Claude Code some other way, so the login pitboard
28    /// moved is not the one a session will use.
29    AuthOverridden {
30        names: Vec<String>,
31    },
32}
33
34impl Warning {
35    /// Stable, for a program to branch on.
36    pub fn code(&self) -> &'static str {
37        match self {
38            Warning::Recovered(r) => r.code(),
39            Warning::ConfigNotUpdated(e) | Warning::RenewalFailed(e) => e.code(),
40            Warning::ParksPendingRemoval(_) => "parks_pending_removal",
41            Warning::ParkedLoginRefused { .. } => "parked_login_refused",
42            Warning::AuthOverridden { .. } => "auth_overridden",
43        }
44    }
45}
46
47impl fmt::Display for Warning {
48    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49        match self {
50            Warning::Recovered(r) => write!(f, "{r}"),
51            Warning::ConfigNotUpdated(e) | Warning::RenewalFailed(e) => write!(f, "{e}"),
52            Warning::ParksPendingRemoval(count) => write!(
53                f,
54                "{count} parked login(s) no longer in use could not be removed yet; pitboard \
55                 tries again on its next change"
56            ),
57            Warning::ParkedLoginRefused { label } => write!(
58                f,
59                "Anthropic no longer accepts the parked login for `{label}`. Run `pitboard \
60                 enroll {label} --sign-in` to sign in to it again."
61            ),
62            Warning::AuthOverridden { names } => write!(
63                f,
64                "{} is set, so Claude Code signs in with it and not with the login pitboard \
65                 moved. Unset it for the switch to take effect.",
66                names.join(" and ")
67            ),
68        }
69    }
70}
71
72#[derive(Debug)]
73pub struct Done<T> {
74    pub value: T,
75    pub warnings: Vec<Warning>,
76}
77
78/// A change that failed, with what was found on the way: recovering an interrupted switch
79/// is reported even when the change that followed it fails.
80#[derive(Debug)]
81pub struct Failed {
82    pub error: Error,
83    pub warnings: Vec<Warning>,
84}
85
86pub type Changing<T> = std::result::Result<Done<T>, Failed>;
87
88pub struct Pitboard {
89    ctx: Context,
90}
91
92impl Pitboard {
93    pub fn new(ctx: Context) -> Pitboard {
94        Pitboard { ctx }
95    }
96
97    /// Who is signed in and what every account has left. Parked logins whose access has
98    /// lapsed are renewed first, so every account is asked live.
99    pub fn status(&self) -> Result<Done<status::Report>> {
100        let mut warnings = Vec::new();
101        for (label, outcome) in switch::renew_parked(&self.ctx) {
102            audit::record(&self.ctx, "renew", &label, outcome.code());
103            match outcome {
104                Renewal::Refused => warnings.push(Warning::ParkedLoginRefused { label }),
105                Renewal::Failed(e) => warnings.push(Warning::RenewalFailed(e)),
106                Renewal::Renewed | Renewal::Deferred => {}
107            }
108        }
109        // Unreadable is not the same as empty: reporting it as empty would say the enrolled
110        // logins are gone.
111        let state = state::load(&self.ctx)?;
112        Ok(Done {
113            value: status::gather(&self.ctx, &state),
114            warnings,
115        })
116    }
117
118    pub fn doctor(&self) -> Diagnosis {
119        doctor::run(&self.ctx)
120    }
121
122    /// The status line for Claude Code's session JSON. Reads only files.
123    /// The same report without asking anyone: the last numbers pitboard measured, and who
124    /// Claude Code's config says is signed in. Nothing is renewed and nothing is asked, so
125    /// it answers at once wherever there is no network.
126    pub fn status_offline(&self) -> Result<Done<status::Report>> {
127        let state = state::load(&self.ctx)?;
128        Ok(Done {
129            value: status::gather_offline(&self.ctx, &state),
130            warnings: Vec::new(),
131        })
132    }
133
134    pub fn statusline(&self, session: &str) -> statusline::StatusLine {
135        statusline::read(&self.ctx, session)
136    }
137
138    /// The enrolled account under `label`, if any, read without taking the lock.
139    pub fn account(&self, label: &str) -> Option<Account> {
140        state::load(&self.ctx).ok()?.get(label).cloned()
141    }
142
143    pub fn switch_to(&self, label: &str) -> Changing<Outcome> {
144        self.changing("use", label, |settled| switch::switch(settled, label))
145    }
146
147    pub fn enroll_current(&self, label: &str) -> Changing<Enrolled> {
148        self.changing("enroll", label, |settled| {
149            switch::enroll(settled, label, None).map(|e| (e, Vec::new()))
150        })
151    }
152
153    /// Claude Code's own sign-in in a private directory. It takes no lock but its own, so a
154    /// person taking their time in a browser never holds up a switch.
155    pub fn sign_in(&self, label: &str) -> Result<SignIn> {
156        self.before_signing_in()
157            .and_then(|()| switch::sign_in(&self.ctx))
158            .inspect_err(|e| audit::record(&self.ctx, "enroll", label, e.code()))
159    }
160
161    /// The same sign-in with its output piped, for a front end that has no terminal to
162    /// hand over. The caller shows what Claude Code says and can type a code back.
163    pub fn sign_in_watched(&self) -> Result<switch::WatchedSignIn> {
164        self.before_signing_in()?;
165        switch::sign_in_watched(&self.ctx)
166    }
167
168    /// Everything that can refuse an enrolment and is knowable before the new login exists.
169    /// Checked first, so a person does not sign in through a browser only to be told the
170    /// state file belongs to another machine or that `claude` is not installed.
171    fn before_signing_in(&self) -> Result<()> {
172        if self.ctx.custom_oauth() {
173            return Err(Error::CustomOauthEndpoint);
174        }
175        state::load(&self.ctx)?;
176        if claude::program(&self.ctx).is_none() {
177            return Err(Error::ClaudeProgramMissing {
178                program: self.ctx.claude_program().display().to_string(),
179            });
180        }
181        Ok(())
182    }
183
184    pub fn enroll_signed_in(&self, label: &str, login: SignIn) -> Changing<Enrolled> {
185        self.changing("enroll", label, |settled| {
186            switch::enroll(settled, label, Some(login)).map(|e| (e, Vec::new()))
187        })
188    }
189
190    /// Returns the account's email.
191    pub fn forget(&self, label: &str) -> Changing<String> {
192        self.changing("forget", label, |settled| switch::forget(settled, label))
193    }
194
195    /// Throws away a record of an interrupted switch that cannot be finished, keeping
196    /// every login it names. The way out when recovery cannot reach Anthropic.
197    pub fn abandon_recovery(&self) -> Result<Option<switch::Abandoned>> {
198        let outcome = switch::abandon(&self.ctx);
199        audit::record(
200            &self.ctx,
201            "abandon",
202            "",
203            match &outcome {
204                Ok(_) => "ok",
205                Err(e) => e.code(),
206            },
207        );
208        outcome
209    }
210
211    /// The changes pitboard has made, newest last.
212    pub fn log(&self, limit: usize) -> Vec<audit::Entry> {
213        audit::read(&self.ctx, limit)
214    }
215
216    /// Deletes every parked login and pitboard's own directory. Claude Code's login is
217    /// left alone: whoever is signed in stays signed in.
218    pub fn uninstall(&self) -> Changing<switch::Removed> {
219        self.changing("uninstall", "", |settled| {
220            switch::uninstall(settled).map(|r| (r, Vec::new()))
221        })
222    }
223
224    /// Returns the account's email.
225    pub fn rename(&self, from: &str, to: &str) -> Changing<String> {
226        self.changing("rename", &format!("{from} -> {to}"), |settled| {
227            switch::rename(settled, from, to).map(|email| (email, Vec::new()))
228        })
229    }
230
231    /// Settles, runs the change, and records it in the audit log.
232    fn changing<T: Audited>(
233        &self,
234        verb: &str,
235        subject: &str,
236        run: impl FnOnce(Settled) -> Result<(T, Vec<Warning>)>,
237    ) -> Changing<T> {
238        let (settled, recovered) = switch::settle(&self.ctx).map_err(|error| {
239            audit::record(&self.ctx, verb, subject, error.code());
240            Failed {
241                error,
242                warnings: Vec::new(),
243            }
244        })?;
245        let mut warnings = Vec::new();
246        if !self.ctx.overriding_auth().is_empty() {
247            warnings.push(Warning::AuthOverridden {
248                names: self.ctx.overriding_auth().to_vec(),
249            });
250        }
251        if let Some(r) = recovered {
252            audit::record(&self.ctx, "recover", &r.to, r.code());
253            warnings.push(Warning::Recovered(r));
254        }
255        match run(settled) {
256            Ok((value, more)) => {
257                audit::record(&self.ctx, verb, subject, value.audit_code());
258                warnings.extend(more);
259                Ok(Done { value, warnings })
260            }
261            Err(error) => {
262                audit::record(&self.ctx, verb, subject, error.code());
263                Err(Failed { error, warnings })
264            }
265        }
266    }
267}
268
269/// How a successful change is written in the audit log.
270trait Audited {
271    fn audit_code(&self) -> &'static str {
272        "ok"
273    }
274}
275
276impl Audited for Outcome {
277    fn audit_code(&self) -> &'static str {
278        match self {
279            Outcome::Switched { .. } => "ok",
280            Outcome::AlreadyActive { .. } => "already_active",
281        }
282    }
283}
284
285impl Audited for Enrolled {}
286impl Audited for String {}
287
288impl Audited for switch::Removed {
289    fn audit_code(&self) -> &'static str {
290        if self.pending > 0 {
291            "parks_pending_removal"
292        } else {
293            "ok"
294        }
295    }
296}