1use 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#[derive(Debug)]
15pub enum Warning {
16 Recovered(Recovered),
18 ConfigNotUpdated(Error),
20 ParksPendingRemoval(usize),
22 ParkedLoginRefused {
24 label: String,
25 },
26 RenewalFailed(Error),
27 AuthOverridden {
30 names: Vec<String>,
31 },
32}
33
34impl Warning {
35 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#[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 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 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 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 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 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 pub fn sign_in_watched(&self) -> Result<switch::WatchedSignIn> {
164 self.before_signing_in()?;
165 switch::sign_in_watched(&self.ctx)
166 }
167
168 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 pub fn forget(&self, label: &str) -> Changing<String> {
192 self.changing("forget", label, |settled| switch::forget(settled, label))
193 }
194
195 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 pub fn log(&self, limit: usize) -> Vec<audit::Entry> {
213 audit::read(&self.ctx, limit)
214 }
215
216 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 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 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
269trait 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}