1use crate::provider::ProviderId;
5use crate::state::Key;
6use std::path::PathBuf;
7
8#[derive(Debug, Clone, PartialEq, Eq, Default)]
11pub struct Enrolled(pub Vec<String>);
12
13impl std::fmt::Display for Enrolled {
14 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
15 if self.0.is_empty() {
16 return write!(f, " Nothing is enrolled yet.");
17 }
18 let labels: Vec<String> = self.0.iter().map(|l| format!("`{l}`")).collect();
19 write!(f, " Enrolled: {}.", labels.join(", "))
20 }
21}
22
23#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum Partway {
27 Nothing,
29 StoredRenewedKept,
33 RenewedUnwritten,
36 RenewedKept,
39 Renewed,
41 Parked { label: String },
43}
44
45impl std::fmt::Display for Partway {
46 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48 match self {
49 Partway::Nothing => write!(f, "changed nothing"),
50 Partway::StoredRenewedKept => write!(
51 f,
52 "deleted nothing. It renewed the login Claude Code has stored and keeps the \
53 renewed login until the next change saves it there"
54 ),
55 Partway::RenewedUnwritten => write!(
56 f,
57 "deleted nothing. It renewed the login the file held and could not write the \
58 renewed login back there, so that login no longer works"
59 ),
60 Partway::RenewedKept => write!(
61 f,
62 "deleted nothing. It renewed the login the file held and keeps the renewed login \
63 until the next change writes it back there"
64 ),
65 Partway::Renewed => write!(
66 f,
67 "deleted nothing, having renewed the login in the file and written it back there"
68 ),
69 Partway::Parked { label } => write!(
70 f,
71 "deleted nothing, having parked the login in the file for `{label}`"
72 ),
73 }
74 }
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
85#[serde(rename_all = "snake_case")]
86#[non_exhaustive]
87pub enum Cause {
88 Unreachable,
90 RateLimited,
92 ServerError,
94 AnswerNotUnderstood,
97 LoginRefused,
99 TokenExpired,
102}
103
104impl Cause {
105 pub fn of(error: &crate::api::ApiError) -> Cause {
106 use crate::api::ApiError;
107 match error {
108 ApiError::Unauthorized => Cause::TokenExpired,
109 ApiError::RateLimited { .. } => Cause::RateLimited,
110 ApiError::Network(_) => Cause::Unreachable,
111 ApiError::Unexpected { .. } => Cause::ServerError,
112 ApiError::Malformed(_) => Cause::AnswerNotUnderstood,
113 ApiError::InvalidGrant => Cause::LoginRefused,
114 }
115 }
116
117 pub fn of_provider(error: &crate::provider::ProviderError) -> Cause {
119 use crate::provider::ProviderError as P;
120 match error {
121 P::Unauthorized => Cause::TokenExpired,
122 P::RateLimited { .. } => Cause::RateLimited,
123 P::Network { .. } => Cause::Unreachable,
124 P::Unexpected { .. } => Cause::ServerError,
125 P::Malformed { .. }
126 | P::ShapeUnexpected { .. }
127 | P::Unsupported { .. }
128 | P::NoLogin { .. } => Cause::AnswerNotUnderstood,
129 P::InvalidGrant { .. } => Cause::LoginRefused,
130 }
131 }
132
133 pub fn code(self) -> &'static str {
135 match self {
136 Cause::Unreachable => "unreachable",
137 Cause::RateLimited => "rate_limited",
138 Cause::ServerError => "server_error",
139 Cause::AnswerNotUnderstood => "answer_not_understood",
140 Cause::LoginRefused => "login_refused",
141 Cause::TokenExpired => "token_expired",
142 }
143 }
144
145 pub fn worth_retrying(self) -> bool {
148 match self {
149 Cause::Unreachable | Cause::RateLimited | Cause::ServerError => true,
150 Cause::AnswerNotUnderstood | Cause::LoginRefused | Cause::TokenExpired => false,
151 }
152 }
153}
154
155#[derive(Debug, thiserror::Error)]
156#[non_exhaustive]
157pub enum Error {
158 #[error(
159 "{path} looks like it is inside {marker}, which syncs to other machines. \
160 Parked logins belong to one machine; set PITBOARD_HOME to a local folder."
161 )]
162 StateOnSyncedDrive { path: PathBuf, marker: String },
163
164 #[error("{}", not_absolute(variable, path))]
167 HomeNotAbsolute {
168 variable: &'static str,
169 path: PathBuf,
170 },
171
172 #[error(
173 "the login for `{label}` needs {bytes} bytes and `security` reads {limit} from \
174 stdin, so it can only be written on the argument line, which PITBOARD_NO_ARGV \
175 forbids. {}",
176 smaller(*tool)
177 )]
178 CredentialTooLarge {
179 tool: ProviderId,
180 label: String,
181 bytes: usize,
182 limit: usize,
183 },
184
185 #[error(
186 "`{program}` is not on this machine, and Pitboard signs in with {}'s own sign-in. \
187 Install {}, or point Pitboard at it.",
188 tool.name(),
189 tool.name()
190 )]
191 ProgramMissing { tool: ProviderId, program: String },
192
193 #[error(
194 "CLAUDE_CODE_CUSTOM_OAUTH_URL is set, so Claude Code keeps its login under a \
195 different name than the one Pitboard reads. Unset it to use Pitboard."
196 )]
197 CustomOauthEndpoint,
198
199 #[error("could not read Pitboard's account list at {path}: {source}")]
200 StateUnreadable {
201 path: PathBuf,
202 #[source]
203 source: std::io::Error,
204 },
205
206 #[error(
207 "Pitboard's account list at {path} is corrupt ({source}). \
208 Delete it and enroll your accounts again; parked logins will be lost."
209 )]
210 StateCorrupt {
211 path: PathBuf,
212 #[source]
213 source: serde_json::Error,
214 },
215
216 #[error(
217 "{path} was written by a newer Pitboard (its format is {found}, this one reads \
218 {expected}). Update this Pitboard the way you installed it. The app, and the command \
219 line inside it, update with the app's Check for Updates."
220 )]
221 StateFromNewerVersion {
222 path: PathBuf,
223 found: u32,
224 expected: u32,
225 },
226
227 #[error(
228 "{path} is in a format ({found}) no version of Pitboard has ever written. Delete it \
229 and enroll your accounts again."
230 )]
231 StateVersionUnknown { path: PathBuf, found: u32 },
232
233 #[error("could not read which accounts Pitboard warms up, at {path}: {source}")]
234 WarmSettingUnreadable {
235 path: PathBuf,
236 #[source]
237 source: std::io::Error,
238 },
239
240 #[error(
241 "which accounts Pitboard warms up, at {path}, cannot be read ({detail}). Delete it, \
242 then turn warm-up on again for each account you want it for."
243 )]
244 WarmSettingCorrupt { path: PathBuf, detail: String },
245
246 #[error(
247 "`{label}` is a {} account, and Pitboard warms up Claude Code accounts only: when a {} \
248 period starts has not been measured.",
249 .tool.name(),
250 .tool.name()
251 )]
252 WarmUpUnsupported { tool: ProviderId, label: String },
253
254 #[error("`{label}` was not warmed up: {}.", crate::words::warm_failed(.why))]
255 WarmUpFailed {
256 label: String,
257 why: crate::warm::Failure,
258 },
259
260 #[error("`{label}` {why}, so it is not warmed up now.")]
261 WarmUpNotDue {
262 label: String,
263 why: String,
264 code: &'static str,
265 },
266
267 #[error("Pitboard warms up no account now: {}.", crate::words::not_warming(.halt))]
268 WarmUpHalted { halt: crate::warm::Halt },
269
270 #[error("another Pitboard is warming up an account. Try again in a minute.")]
271 WarmUpInProgress,
272
273 #[error(
274 "{path} has an account for `{tool}`, a tool this Pitboard does not know, so it was \
275 written by a newer one. Update this Pitboard the way you installed it. The app, and \
276 the command line inside it, update with the app's Check for Updates."
277 )]
278 StateNamesUnknownTool { path: PathBuf, tool: String },
279
280 #[error(
281 "{path} was written on another computer. Parked logins do not move between \
282 machines, because two machines taking turns presenting one refresh token ends the \
283 login for both. Run `pitboard adopt` to keep your accounts here and drop the \
284 logins they came with; each then needs one `pitboard enroll <label> --sign-in`."
285 )]
286 StateWrongMachine { path: PathBuf },
287
288 #[error(
289 "this platform has no scheduler Pitboard knows how to write. Keeping parked logins \
290 alive here means running `pitboard` yourself from time to time."
291 )]
292 ScheduleUnsupported,
293
294 #[error("the scheduler refused: {detail}")]
295 ScheduleRefused { detail: String },
296
297 #[error(
301 "the renewal schedule renews only the parked logins in {}, not those in {}, which \
302 PITBOARD_HOME names. Nothing was scheduled. To schedule it for {}, unset \
303 PITBOARD_HOME and install it again.",
304 default.display(),
305 home.display(),
306 default.display()
307 )]
308 ScheduleNotDefaultHome { home: PathBuf, default: PathBuf },
309
310 #[error("the renewal schedule would run {path}, which is not there. Nothing was scheduled.")]
311 ScheduleProgramMissing { path: PathBuf },
312
313 #[error(
316 "the renewal schedule would run {path}, which is in a temporary copy macOS made of \
317 the app and is gone once the app quits. Move Pitboard to your Applications folder, \
318 open it from there, and turn on daily renewal again."
319 )]
320 ScheduleProgramTemporary { path: PathBuf },
321
322 #[error("this copy of Pitboard has no command line inside it for the renewal schedule to run.")]
325 ScheduleProgramUnnamed,
326
327 #[error("could not write to Pitboard's directory at {path}: {source}")]
328 HomeUnwritable {
329 path: PathBuf,
330 #[source]
331 source: std::io::Error,
332 },
333
334 #[error("could not save Pitboard's account list at {path}: {source}")]
335 StateWriteFailed {
336 path: PathBuf,
337 #[source]
338 source: std::io::Error,
339 },
340
341 #[error(
342 "Claude Code has not run on this machine yet ({path} does not exist). \
343 Run `claude` once, sign in, then try again."
344 )]
345 ClaudeConfigMissing { path: PathBuf },
346
347 #[error("could not read Claude Code's config at {path}: {source}")]
348 ClaudeConfigUnreadable {
349 path: PathBuf,
350 #[source]
351 source: std::io::Error,
352 },
353
354 #[error(
355 "Claude Code's config at {path} is not valid JSON right now ({source}). \
356 It may be mid-write; wait a few seconds and try again."
357 )]
358 ClaudeConfigNotJson {
359 path: PathBuf,
360 #[source]
361 source: serde_json::Error,
362 },
363
364 #[error(
365 "nothing is signed in to {} right now. Run `{}`, sign in, then try again.",
366 tool.name(),
367 tool.login_command()
368 )]
369 LiveCredentialAbsent { tool: ProviderId },
370
371 #[error(
372 "Claude Code's config says {email} is signed in, but Pitboard cannot find that \
373 login in the keychain or in the file it also reads. It will not write a login \
374 where nobody reads it. This usually means Claude Code has started keeping logins \
375 somewhere Pitboard does not know about yet: check for a Pitboard update, and \
376 report it with `pitboard doctor --json` if there is none."
377 )]
378 LiveCredentialElsewhere { email: String },
379
380 #[error(
381 "the signed-in credential is not shaped like a {} login ({detail}). \
382 Run `pitboard doctor` before switching again.",
383 tool.name()
384 )]
385 LiveCredentialShapeUnexpected { tool: ProviderId, detail: String },
386
387 #[error("{reason}.")]
389 LiveStoreUnsupported { tool: ProviderId, reason: String },
390
391 #[error(
392 "no account is enrolled as `{label}`.{enrolled} Run `pitboard enroll {label} \
393 --sign-in` to add it."
394 )]
395 AccountUnknown {
396 label: String,
397 enrolled: Enrolled,
399 },
400
401 #[error(
402 "`{label}` has no parked login to switch to: the last one went back into use and \
403 {} has moved on from it. Run `pitboard enroll {label} --sign-in` to sign in to it \
404 again.",
405 tool.name()
406 )]
407 NothingParked { tool: ProviderId, label: String },
408
409 #[error(
410 "the parked login for `{label}` has expired. Run `pitboard enroll {label} --sign-in` \
411 to sign in to it again."
412 )]
413 ParkedLoginExpired { label: String },
414
415 #[error(
416 "{who} is signed in but not enrolled, so it cannot be parked. \
417 Run `pitboard enroll {}` for it first.",
418 Key::new(*tool, "<label>").typed()
419 )]
420 LiveAccountNotEnrolled { tool: ProviderId, who: String },
421
422 #[error(
423 "{email} is already enrolled as `{label}`. To add a different account, run \
424 `pitboard enroll {} --sign-in`.",
425 Key::new(*tool, "<label>").typed()
426 )]
427 AlreadyEnrolled {
428 tool: ProviderId,
429 email: String,
430 label: String,
431 },
432
433 #[error("`{label}` already refers to {who}. Choose a different label.")]
434 LabelTaken { label: String, who: String },
435
436 #[error(
437 "`{typed}` is not a tool Pitboard knows. It knows: {}.",
438 known.join(", ")
439 )]
440 ProviderUnknown { typed: String, known: Vec<String> },
441
442 #[error(
443 "`{label}` is enrolled for more than one tool: {}. Say which one.",
444 matches.iter().map(|m| format!("`{m}`")).collect::<Vec<_>>().join(", ")
445 )]
446 LabelAmbiguous { label: String, matches: Vec<String> },
447
448 #[error("`{label}` is signed in; switch to another account before forgetting it.")]
449 CannotForgetActiveAccount { label: String },
450
451 #[error(
454 "`{label}` is not signed in now, so Pitboard left {}'s config as it is. A switch \
455 writes the account it puts in use there.",
456 tool.name()
457 )]
458 AccountNotInUse { tool: ProviderId, label: String },
459
460 #[error("could not find a free place to park this login. Run `pitboard doctor`.")]
461 ParkSlotExhausted,
462
463 #[error(
464 "the parked login for `{label}` is missing. Run `pitboard enroll {label} --sign-in` \
465 to sign in to it again."
466 )]
467 ParkedCredentialMissing { label: String },
468
469 #[error(
470 "the parked login for `{label}` is not the one Pitboard recorded ({detail}). Run \
471 `pitboard enroll {label} --sign-in` to replace it."
472 )]
473 ParkedCredentialCorrupt { label: String, detail: String },
474
475 #[error("could not back up Claude Code's config ({path}): {source}. Nothing was changed.")]
476 ConfigBackupFailed {
477 path: PathBuf,
478 #[source]
479 source: std::io::Error,
480 },
481
482 #[error(
486 "Claude Code's config at {path} could not be updated ({detail}), so Claude Code may \
487 show another account's name than the one in use. `pitboard use` with the account in \
488 use writes it there."
489 )]
490 ConfigWriteFailed { path: PathBuf, detail: String },
491
492 #[error("{}", session_expired(*tool, *refused))]
496 SessionExpired { tool: ProviderId, refused: bool },
497
498 #[error(
499 "Pitboard could not confirm with {} which account is signed in ({detail}), and will \
500 not move a login it cannot identify. Check the connection and try again.",
501 tool.service()
502 )]
503 IdentityUnverifiable {
504 tool: ProviderId,
505 cause: Cause,
506 detail: String,
507 },
508
509 #[error("the signed-in account changed while switching. Nothing was moved; try again.")]
510 SignedInAccountChanged,
511
512 #[error(
513 "an earlier switch from `{from}` to `{to}` was interrupted, and Pitboard cannot yet \
514 tell whether it finished ({detail}). Nothing was changed. Run `{}` once so its \
515 session is current, then try again.",
516 tool.program()
517 )]
518 RecoveryUndetermined {
519 tool: ProviderId,
520 from: String,
521 to: String,
522 detail: String,
523 },
524
525 #[error(
526 "could not sign in as `{to}` ({detail}); `{from}` is still signed in, nothing was lost."
527 )]
528 SwitchRolledBack {
529 from: String,
530 to: String,
531 detail: String,
532 },
533
534 #[error(
535 "{} no longer accepts `{label}`'s parked login, so Pitboard did not move anything. \
536 The copy has been dropped; sign in to that account again with \
537 `pitboard enroll {label} --sign-in`.",
538 tool.service()
539 )]
540 ParkedLoginRefused { tool: ProviderId, label: String },
541
542 #[error(
543 "`{label}`'s parked login belongs to {who}, not to the account Pitboard has \
544 under that label. Nothing was moved. Run `pitboard doctor`, then \
545 `pitboard enroll {label} --sign-in` to replace it."
546 )]
547 ParkedLoginBelongsElsewhere { label: String, who: String },
548
549 #[error(
550 "signed in as `{to}`, and the login was gone again before Pitboard finished. {}",
551 after_it_did_not_hold(*tool, from, to)
552 )]
553 SwitchDidNotHold {
554 tool: ProviderId,
555 from: String,
556 to: String,
557 },
558
559 #[error(
560 "could not sign in as `{to}` ({detail}), and could not read the credential store \
561 back to find out whether anything changed. Nothing has been deleted and both \
562 logins are still here. {} and run `pitboard use {to}` again; it finishes or \
563 undoes this before doing anything else.",
564 make_readable(*tool)
565 )]
566 SwitchUnverified {
567 tool: ProviderId,
568 from: String,
569 to: String,
570 detail: String,
571 },
572
573 #[error(
574 "could not sign in as `{to}`, and could not put `{from}` back either ({detail}). \
575 `{from}`'s login is still parked: run `{}` and sign in to any enrolled account, \
576 then `pitboard use {from}`.",
577 tool.login_command()
578 )]
579 SwitchCorrupted {
580 tool: ProviderId,
581 from: String,
582 to: String,
583 detail: String,
584 },
585
586 #[error(
587 "signed in to `{label}` again, and Pitboard could not confirm that its new login \
588 took the place of the one in use ({detail}), so {} may have no login for it now. {}",
589 tool.name(),
590 not_in_use(*tool, label, *parked)
591 )]
592 SignInNotInstalled {
593 tool: ProviderId,
594 label: String,
595 detail: String,
596 parked: bool,
598 warnings: Vec<crate::service::Warning>,
601 },
602
603 #[error(
604 "the new login for `{label}` could not be put in use ({detail}), so it was not \
605 kept, and {} keeps the login it has. Run `pitboard enroll {label} --sign-in` to \
606 sign in again.",
607 tool.name()
608 )]
609 SignInNotKept {
610 tool: ProviderId,
611 label: String,
612 detail: String,
613 },
614
615 #[error(
616 "the record of an interrupted switch at {path} is damaged ({source}), so Pitboard \
617 cannot tell what that switch did. Nothing was changed. Check that `pitboard status` \
618 shows the account you expect, then delete the file to continue."
619 )]
620 RecoveryRecordCorrupt {
621 path: PathBuf,
622 #[source]
623 source: serde_json::Error,
624 },
625
626 #[error(
627 "an earlier switch of {} from `{from}` to `{to}` was interrupted while its login was \
628 at {slot}, and this run reads it from somewhere else, so it cannot tell what that \
629 switch did. Nothing was changed. Run Pitboard with {} pointing where it did to \
630 finish it, or `pitboard abandon` to keep every login it names and move on.",
631 tool.name(),
632 tool.home_variable()
633 )]
634 RecoveryElsewhere {
635 tool: ProviderId,
636 from: String,
637 to: String,
638 slot: String,
639 },
640
641 #[error("could not read or write Pitboard's recovery record at {path}: {source}")]
642 RecoveryFailed {
643 path: PathBuf,
644 #[source]
645 source: std::io::Error,
646 },
647
648 #[error(
649 "`{}` was not found on PATH. Install {}, run it once, then try again.",
650 tool.program(),
651 tool.name()
652 )]
653 ProgramNotFound { tool: ProviderId },
654
655 #[error(
656 "could not renew the parked login for `{label}` ({detail}); its last reading is shown \
657 instead. Run `pitboard doctor` if this keeps happening."
658 )]
659 RenewalFailed {
660 label: String,
661 cause: Option<Cause>,
662 detail: String,
663 },
664
665 #[error("the sign-in did not finish, so nothing was enrolled.")]
666 SignInIncomplete,
667
668 #[error(
671 "{} has changed since putting it away was confirmed. Putting it away again says first \
672 what it holds now.",
673 path.display()
674 )]
675 LeftLoginChanged { path: PathBuf },
676
677 #[error(
680 "the login {} has stored changed while {} was being put away, and whether the file's \
681 login is kept somewhere depended on it. Putting it away again says first what it does \
682 now.",
683 ProviderId::Claude.name(),
684 path.display()
685 )]
686 StoredLoginChanged { path: PathBuf },
687
688 #[error(
691 "{} holds {email}'s login{}, an account not enrolled here, so Pitboard has no account to \
692 keep it for. Enrol that account first, with `pitboard enroll <label> --sign-in`, or \
693 with `pitboard enroll <label>` while Claude Code is signed in to it, then run \
694 `pitboard stow` again.",
695 path.display(),
696 crate::words::in_organisation(organization)
697 )]
698 LeftLoginNotEnrolled {
699 path: PathBuf,
700 email: String,
701 organization: String,
702 },
703
704 #[error(
705 "Pitboard could not ask {} whose the login left in {} is ({detail}). Check the \
706 connection and try again.",
707 ProviderId::Claude.service(),
708 path.display()
709 )]
710 LeftLoginUnidentified {
711 path: PathBuf,
712 cause: Cause,
713 detail: String,
714 },
715
716 #[error("{} Pitboard {partway}.", sentence(error))]
720 StowStopped {
721 partway: Partway,
722 warnings: Vec<crate::service::Warning>,
725 error: Box<Error>,
726 },
727
728 #[error(
732 "{} Pitboard renewed {}'s login and could not save the renewed login, so the refresh \
733 token stored is spent. Run `{}`, sign in, then try again.",
734 sentence(error),
735 tool.name(),
736 tool.login_command()
737 )]
738 RenewalLost { tool: ProviderId, error: Box<Error> },
739
740 #[error(
744 "Pitboard will not sign in to a second account on this machine: {reason} Signing \
745 in would write over the login you are using."
746 )]
747 SignInNotIsolated { reason: String },
748
749 #[error(
750 "another `pitboard enroll --sign-in` is already waiting for its sign-in. Finish or \
751 cancel that one first."
752 )]
753 SignInInProgress,
754
755 #[error("{}", crate::words::elevated(crate::host::OS, *why).refusal)]
760 Elevated { why: Option<&'static str> },
761
762 #[error("{}", crate::words::too_old(*build).refusal)]
763 SystemTooOld { build: Option<u32> },
764
765 #[error("Pitboard for Windows is not released yet. This build changes nothing.")]
769 WindowsNotReleased,
770
771 #[error("{0}")]
773 Usage(String),
774
775 #[error(
777 "`pitboard {command}` asks before it deletes anything, and can ask only in a terminal \
778 without `--json`. Nothing was changed. Run it in a terminal, or add `--yes`."
779 )]
780 ConfirmationNeeded { command: &'static str },
781
782 #[error(transparent)]
783 Store(#[from] crate::store::Error),
784
785 #[error(transparent)]
786 Lock(#[from] crate::lock::LockError),
787}
788
789impl Error {
790 pub fn code(&self) -> &'static str {
792 use Error::*;
793 match self {
794 StateOnSyncedDrive { .. } => "state_on_synced_drive",
795 HomeNotAbsolute { .. } => "home_not_absolute",
796 ProgramMissing { tool, .. } => match tool {
797 ProviderId::Claude => "claude_program_missing",
798 ProviderId::Codex => "codex_program_missing",
799 },
800 CredentialTooLarge { .. } => "credential_too_large",
801 CustomOauthEndpoint => "custom_oauth_endpoint",
802 StateUnreadable { .. } => "state_unreadable",
803 StateCorrupt { .. } => "state_corrupt",
804 StateFromNewerVersion { .. } => "state_from_newer_version",
805 StateVersionUnknown { .. } => "state_version_unknown",
806 WarmSettingUnreadable { .. } => "warm_setting_unreadable",
807 WarmSettingCorrupt { .. } => "warm_setting_corrupt",
808 WarmUpUnsupported { .. } => "warm_up_unsupported",
809 WarmUpFailed { why, .. } => why.code(),
810 WarmUpNotDue { code, .. } => code,
811 WarmUpHalted { halt } => halt.code(),
812 WarmUpInProgress => "warm_up_in_progress",
813 StateNamesUnknownTool { .. } => "state_names_unknown_tool",
814 StateWrongMachine { .. } => "state_wrong_machine",
815 StateWriteFailed { .. } => "state_write_failed",
816 ScheduleUnsupported => "schedule_unsupported",
817 ScheduleRefused { .. } => "schedule_refused",
818 ScheduleNotDefaultHome { .. } => "schedule_not_default_home",
819 ScheduleProgramMissing { .. } => "schedule_program_missing",
820 ScheduleProgramTemporary { .. } => "schedule_program_temporary",
821 ScheduleProgramUnnamed => "schedule_program_unnamed",
822 HomeUnwritable { .. } => "home_unwritable",
823 ClaudeConfigMissing { .. } => "claude_config_missing",
824 ClaudeConfigUnreadable { .. } => "claude_config_unreadable",
825 ClaudeConfigNotJson { .. } => "claude_config_not_json",
826 LiveCredentialAbsent { .. } => "live_credential_absent",
827 LiveStoreUnsupported { .. } => "live_store_unsupported",
828 LiveCredentialElsewhere { .. } => "live_credential_elsewhere",
829 LiveCredentialShapeUnexpected { .. } => "live_credential_shape_unexpected",
830 AccountUnknown { .. } => "account_unknown",
831 NothingParked { .. } => "nothing_parked",
832 ParkedLoginExpired { .. } => "parked_login_expired",
833 ParkedLoginRefused { .. } => "parked_login_refused",
834 ParkedLoginBelongsElsewhere { .. } => "parked_login_belongs_elsewhere",
835 LiveAccountNotEnrolled { .. } => "live_account_not_enrolled",
836 AlreadyEnrolled { .. } => "already_enrolled",
837 LabelTaken { .. } => "label_taken",
838 ProviderUnknown { .. } => "provider_unknown",
839 LabelAmbiguous { .. } => "label_ambiguous",
840 CannotForgetActiveAccount { .. } => "cannot_forget_active_account",
841 AccountNotInUse { .. } => "account_not_in_use",
842 ParkSlotExhausted => "park_slot_exhausted",
843 ParkedCredentialMissing { .. } => "parked_credential_missing",
844 ParkedCredentialCorrupt { .. } => "parked_credential_corrupt",
845 ConfigBackupFailed { .. } => "config_backup_failed",
846 ConfigWriteFailed { .. } => "config_write_failed",
847 SwitchRolledBack { .. } => "switch_rolled_back",
848 SwitchDidNotHold { .. } => "switch_did_not_hold",
849 SwitchUnverified { .. } => "switch_unverified",
850 SwitchCorrupted { .. } => "switch_corrupted",
851 SignInNotInstalled { .. } => "sign_in_not_installed",
852 SignInNotKept { .. } => "sign_in_not_kept",
853 RecoveryFailed { .. } => "recovery_failed",
854 RecoveryRecordCorrupt { .. } => "recovery_record_corrupt",
855 SessionExpired { .. } => "session_expired",
856 IdentityUnverifiable { .. } => "identity_unverifiable",
857 SignedInAccountChanged => "signed_in_account_changed",
858 RecoveryUndetermined { .. } => "recovery_undetermined",
859 RecoveryElsewhere { .. } => "recovery_elsewhere",
860 ProgramNotFound { tool } => match tool {
861 ProviderId::Claude => "claude_not_found",
862 ProviderId::Codex => "codex_not_found",
863 },
864 SignInIncomplete => "sign_in_incomplete",
865 LeftLoginChanged { .. } => "left_login_changed",
866 StoredLoginChanged { .. } => "stored_login_changed",
867 LeftLoginNotEnrolled { .. } => "left_login_not_enrolled",
868 LeftLoginUnidentified { .. } => "left_login_unidentified",
869 StowStopped { error, .. } | RenewalLost { error, .. } => error.code(),
870 SignInNotIsolated { .. } => "sign_in_not_isolated",
871 RenewalFailed { .. } => "renewal_failed",
872 SignInInProgress => "sign_in_in_progress",
873 Elevated { .. } => "elevated",
874 SystemTooOld { .. } => "system_too_old",
875 WindowsNotReleased => "windows_not_released",
876 Usage(_) => "usage",
877 ConfirmationNeeded { .. } => "confirmation_needed",
878 Store(e) => e.code(),
879 Lock(e) => e.code(),
880 }
881 }
882
883 pub fn cause(&self) -> Option<Cause> {
886 use Error::*;
887 match self {
888 IdentityUnverifiable { cause, .. } | LeftLoginUnidentified { cause, .. } => {
889 Some(*cause)
890 }
891 RenewalFailed { cause, .. } => *cause,
892 SessionExpired { refused: true, .. } => Some(Cause::LoginRefused),
893 SessionExpired { .. } => Some(Cause::TokenExpired),
894 StowStopped { error, .. } | RenewalLost { error, .. } => error.cause(),
895 _ => None,
896 }
897 }
898
899 pub(crate) fn take_warnings(&mut self) -> Vec<crate::service::Warning> {
902 match self {
903 Error::SignInNotInstalled { warnings, .. } | Error::StowStopped { warnings, .. } => {
904 std::mem::take(warnings)
905 }
906 _ => Vec::new(),
907 }
908 }
909
910 pub fn exit_code(&self) -> u8 {
915 use Error::*;
916 match self {
917 LiveCredentialShapeUnexpected { .. }
920 | LiveStoreUnsupported { .. }
921 | ClaudeConfigNotJson { .. }
922 | SwitchCorrupted { .. }
923 | SwitchUnverified { .. }
924 | SwitchDidNotHold { .. }
925 | SignInNotInstalled { .. }
926 | LiveCredentialElsewhere { .. }
927 | CredentialTooLarge { .. }
928 | CustomOauthEndpoint
929 | RecoveryRecordCorrupt { .. } => 3,
930 Usage(_) | ConfirmationNeeded { .. } => 2,
931 Store(e) => e.exit_code(),
932 StowStopped { error, .. } | RenewalLost { error, .. } => error.exit_code(),
933 _ => 1,
934 }
935 }
936}
937
938fn sentence(error: &Error) -> String {
941 let said = error.to_string();
942 if said.ends_with('.') {
943 said
944 } else {
945 format!("{said}.")
946 }
947}
948
949pub type Result<T> = std::result::Result<T, Error>;
950
951fn not_absolute(variable: &str, path: &std::path::Path) -> String {
954 let holds = if path.as_os_str().is_empty() {
955 "empty".to_string()
956 } else {
957 format!("`{}`, which is not a full path", path.display())
958 };
959 format!(
960 "{variable} is {holds}, so the folder it names would depend on where each program \
961 runs. Set it to a full path, or unset it; Pitboard reads and changes nothing until \
962 then."
963 )
964}
965
966fn session_expired(tool: ProviderId, refused: bool) -> String {
968 if refused {
969 return format!(
970 "{}'s login in use has expired and cannot be renewed, so Pitboard cannot confirm \
971 which account is signed in. Run `{}`, sign in, then try again.",
972 tool.name(),
973 tool.login_command()
974 );
975 }
976 format!(
977 "{}'s session has expired, so Pitboard cannot confirm which account is signed in. \
978 Run `{}` once so it refreshes, then try again.",
979 tool.name(),
980 tool.program()
981 )
982}
983
984fn make_readable(tool: ProviderId) -> &'static str {
986 match tool {
987 ProviderId::Claude => "Unlock the keychain",
988 ProviderId::Codex => "Make Codex's auth.json readable to you again",
989 }
990}
991
992fn smaller(tool: ProviderId) -> &'static str {
994 match tool {
995 ProviderId::Claude => {
996 "Unset it, or sign out of MCP servers you no longer use to make the login smaller."
997 }
998 ProviderId::Codex => "Unset it to let Pitboard write it.",
999 }
1000}
1001
1002fn after_it_did_not_hold(tool: ProviderId, from: &str, to: &str) -> String {
1005 match tool {
1006 ProviderId::Claude => format!(
1007 "Claude Code removes a login without taking the write lock when `/logout` has \
1008 given up waiting, which is the one write Pitboard cannot exclude. Nothing was \
1009 lost: both `{from}` and `{to}` are parked. Run `claude` and sign in to any \
1010 enrolled account, then `pitboard use {to}`."
1011 ),
1012 ProviderId::Codex => format!(
1016 "Codex takes no lock, so a codex session still running from before the switch, \
1017 refreshing or signing out, rewrote auth.json underneath it. `{to}` is still \
1018 parked. `{from}`'s park may hold a token that refresh spent. Quit every running \
1019 codex, then run `pitboard` to see what is signed in; do not run `codex login` \
1020 or `codex logout` until you have, because either revokes the login they find."
1021 ),
1022 }
1023}
1024
1025fn not_in_use(tool: ProviderId, label: &str, parked: bool) -> String {
1027 if parked {
1028 format!(
1029 "The new login is parked, so it is not lost. Run `pitboard` to see what is \
1030 signed in; if nothing is, run `{}` and sign in to any enrolled account, then \
1031 `pitboard use {label}`.",
1032 tool.login_command()
1033 )
1034 } else {
1035 format!(
1036 "It could not be parked either: run `{}` and sign in to `{label}` again.",
1037 tool.login_command()
1038 )
1039 }
1040}
1041
1042#[cfg(test)]
1043mod tests {
1044 use super::*;
1045
1046 #[test]
1047 fn codes_are_unique_so_a_caller_can_branch_on_them() {
1048 let samples = [
1049 Error::LiveCredentialAbsent {
1050 tool: ProviderId::Claude,
1051 },
1052 Error::ParkSlotExhausted,
1053 Error::SignedInAccountChanged,
1054 Error::AccountUnknown {
1055 label: "x".into(),
1056 enrolled: Enrolled::default(),
1057 },
1058 Error::NothingParked {
1059 tool: ProviderId::Claude,
1060 label: "x".into(),
1061 },
1062 Error::ParkedLoginExpired { label: "x".into() },
1063 Error::AccountNotInUse {
1064 tool: ProviderId::Claude,
1065 label: "x".into(),
1066 },
1067 Error::LabelTaken {
1068 label: "x".into(),
1069 who: "e".into(),
1070 },
1071 Error::SwitchRolledBack {
1072 from: "x".into(),
1073 to: "y".into(),
1074 detail: "d".into(),
1075 },
1076 Error::SignInNotKept {
1077 tool: ProviderId::Codex,
1078 label: "x".into(),
1079 detail: "d".into(),
1080 },
1081 Error::Elevated { why: None },
1082 Error::SystemTooOld { build: Some(22631) },
1083 Error::WindowsNotReleased,
1084 Error::LeftLoginChanged { path: "f".into() },
1085 Error::StoredLoginChanged { path: "f".into() },
1086 Error::Usage("u".into()),
1087 Error::ConfirmationNeeded { command: "stow" },
1088 ];
1089 let mut codes: Vec<&str> = samples.iter().map(Error::code).collect();
1090 codes.sort_unstable();
1091 let before = codes.len();
1092 codes.dedup();
1093 assert_eq!(codes.len(), before);
1094 }
1095
1096 #[test]
1099 fn what_stopped_putting_a_file_away_keeps_its_code_and_says_how_far_it_went() {
1100 let stopped = Error::StowStopped {
1101 partway: Partway::Parked {
1102 label: "work".into(),
1103 },
1104 warnings: Vec::new(),
1105 error: Box::new(Error::Store(crate::store::Error::Locked)),
1106 };
1107
1108 assert_eq!(stopped.code(), "credential_store_locked");
1109 assert_eq!(
1110 stopped.exit_code(),
1111 Error::Store(crate::store::Error::Locked).exit_code()
1112 );
1113 assert!(
1114 stopped.to_string().ends_with(
1115 "desktop session. Pitboard deleted nothing, having parked the login in the file \
1116 for `work`."
1117 ),
1118 "{stopped}"
1119 );
1120
1121 let unidentified = Error::StowStopped {
1122 partway: Partway::Nothing,
1123 warnings: Vec::new(),
1124 error: Box::new(Error::LeftLoginUnidentified {
1125 path: "f".into(),
1126 cause: Cause::Unreachable,
1127 detail: "offline".into(),
1128 }),
1129 };
1130 assert_eq!(unidentified.code(), "left_login_unidentified");
1131 assert_eq!(unidentified.cause(), Some(Cause::Unreachable));
1132 assert!(
1133 unidentified
1134 .to_string()
1135 .ends_with("try again. Pitboard changed nothing."),
1136 "{unidentified}"
1137 );
1138 }
1139
1140 #[test]
1141 fn a_broken_assumption_exits_differently_from_a_bad_request() {
1142 assert_eq!(
1143 Error::LiveCredentialShapeUnexpected {
1144 tool: ProviderId::Claude,
1145 detail: "x".into()
1146 }
1147 .exit_code(),
1148 3
1149 );
1150 assert_eq!(
1151 Error::AccountUnknown {
1152 label: "x".into(),
1153 enrolled: Enrolled::default()
1154 }
1155 .exit_code(),
1156 1
1157 );
1158 }
1159
1160 #[test]
1161 fn every_message_tells_the_user_something_to_do() {
1162 let actionable = [
1164 Error::LiveCredentialAbsent {
1165 tool: ProviderId::Claude,
1166 }
1167 .to_string(),
1168 Error::LiveCredentialAbsent {
1169 tool: ProviderId::Codex,
1170 }
1171 .to_string(),
1172 Error::AccountUnknown {
1173 label: "work".into(),
1174 enrolled: Enrolled(vec!["personal".into()]),
1175 }
1176 .to_string(),
1177 Error::NothingParked {
1178 tool: ProviderId::Claude,
1179 label: "work".into(),
1180 }
1181 .to_string(),
1182 Error::ParkedLoginExpired {
1183 label: "work".into(),
1184 }
1185 .to_string(),
1186 Error::ParkedCredentialMissing {
1187 label: "work".into(),
1188 }
1189 .to_string(),
1190 Error::LiveAccountNotEnrolled {
1191 tool: ProviderId::Claude,
1192 who: "a@b.c".into(),
1193 }
1194 .to_string(),
1195 Error::SignInNotKept {
1196 tool: ProviderId::Codex,
1197 label: "codex/work".into(),
1198 detail: "the keychain is locked".into(),
1199 }
1200 .to_string(),
1201 Error::ConfirmationNeeded { command: "forget" }.to_string(),
1202 ];
1203 for message in actionable {
1204 assert!(
1205 message.contains("Run ") || message.contains("Sign in"),
1206 "no action offered: {message}"
1207 );
1208 }
1209 }
1210
1211 #[test]
1214 fn a_message_names_the_tool_it_is_about() {
1215 let codex = Error::LiveCredentialAbsent {
1216 tool: ProviderId::Codex,
1217 }
1218 .to_string();
1219 assert!(
1220 codex.contains("Codex") && codex.contains("`codex login`"),
1221 "{codex}"
1222 );
1223 assert!(!codex.contains("Claude"), "{codex}");
1224
1225 let unverifiable = Error::IdentityUnverifiable {
1226 tool: ProviderId::Codex,
1227 cause: Cause::Unreachable,
1228 detail: "offline".into(),
1229 }
1230 .to_string();
1231 assert!(unverifiable.contains("OpenAI"), "{unverifiable}");
1232
1233 let claude = Error::IdentityUnverifiable {
1234 tool: ProviderId::Claude,
1235 cause: Cause::Unreachable,
1236 detail: "offline".into(),
1237 }
1238 .to_string();
1239 assert!(claude.contains("confirm with Anthropic"), "{claude}");
1240 }
1241
1242 #[test]
1245 fn a_missing_program_keeps_its_released_code() {
1246 let missing = |tool| Error::ProgramNotFound { tool }.code();
1247 assert_eq!(missing(ProviderId::Claude), "claude_not_found");
1248 assert_eq!(missing(ProviderId::Codex), "codex_not_found");
1249 let absent = |tool| {
1250 Error::ProgramMissing {
1251 tool,
1252 program: "x".into(),
1253 }
1254 .code()
1255 };
1256 assert_eq!(absent(ProviderId::Claude), "claude_program_missing");
1257 assert_eq!(absent(ProviderId::Codex), "codex_program_missing");
1258 }
1259
1260 #[test]
1264 fn a_newer_pitboards_files_say_which_update_moves_which_pitboard() {
1265 let path = PathBuf::from("/home/x/.pitboard/state.json");
1266 for message in [
1267 Error::StateFromNewerVersion {
1268 path: path.clone(),
1269 found: 9,
1270 expected: 5,
1271 }
1272 .to_string(),
1273 Error::StateNamesUnknownTool {
1274 path: path.clone(),
1275 tool: "gemini".into(),
1276 }
1277 .to_string(),
1278 ] {
1279 assert!(
1280 message.contains("Update this Pitboard the way you installed it."),
1281 "{message}"
1282 );
1283 assert!(
1284 message.contains("The app, and the command line inside it, update with"),
1285 "{message}"
1286 );
1287 assert!(!message.contains("or with the app"), "{message}");
1288 }
1289 }
1290
1291 #[test]
1294 fn enrolment_advice_keeps_the_tool() {
1295 let codex = Error::LiveAccountNotEnrolled {
1296 tool: ProviderId::Codex,
1297 who: "a@b.c".into(),
1298 }
1299 .to_string();
1300 assert!(codex.contains("pitboard enroll codex/<label>"), "{codex}");
1301 let claude = Error::LiveAccountNotEnrolled {
1302 tool: ProviderId::Claude,
1303 who: "a@b.c".into(),
1304 }
1305 .to_string();
1306 assert!(claude.contains("pitboard enroll <label>"), "{claude}");
1307 let already = Error::AlreadyEnrolled {
1308 tool: ProviderId::Codex,
1309 email: "a@b.c".into(),
1310 label: "codex/work".into(),
1311 }
1312 .to_string();
1313 assert!(
1314 already.contains("pitboard enroll codex/<label> --sign-in"),
1315 "{already}"
1316 );
1317 }
1318
1319 #[test]
1323 fn an_unverified_switch_names_a_command_that_finishes_it() {
1324 let message = Error::SwitchUnverified {
1325 tool: ProviderId::Codex,
1326 from: "codex/personal".into(),
1327 to: "codex/work".into(),
1328 detail: "auth.json is not readable".into(),
1329 }
1330 .to_string();
1331 assert!(
1332 message.contains("run `pitboard use codex/work` again"),
1333 "{message}"
1334 );
1335 assert!(!message.contains("run `pitboard` again"), "{message}");
1336 }
1337}