pitboard-core 0.15.2

The engine behind Pitboard: switches Claude Code, Codex and Claude Desktop between accounts by parking and restoring their logins. Its API serves Pitboard's own front ends.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
//! Turning what somebody typed into one account.
//!
//! A label was a name unique across the whole machine, because there was one tool and one
//! set of accounts. With three, `work` is a name somebody will want for their work account
//! on each of them, and refusing the second one would be Pitboard imposing a namespace
//! nobody asked for.
//!
//! So a label is unique within a provider, and `codex/work` says which. A bare `work` still
//! works and still means what it always did, as long as it names one account; where it
//! names two, Pitboard says so and lists them rather than picking.

use crate::error::{Enrolled, Error, Result};
use crate::provider::ProviderId;
use crate::state::{Account, Key, State};

/// The separator between a provider and a label. A label may not contain one.
pub const SEPARATOR: char = '/';

/// What somebody typed, before it is looked up.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Spec<'a> {
    /// `work`: whichever provider has it, if only one does.
    Bare(&'a str),
    /// `codex/work`: this provider's, and no other's.
    Qualified(ProviderId, &'a str),
}

/// Read `provider/label`, or a bare label.
///
/// A prefix that is not a provider is an error rather than a label containing a slash,
/// because the alternative is `pitboard use codx/work` quietly looking for an account
/// literally called `codx/work` and reporting it missing.
pub(crate) fn parse(typed: &str) -> Result<Spec<'_>> {
    match typed.split_once(SEPARATOR) {
        None => Ok(Spec::Bare(typed)),
        Some((prefix, label)) => match ProviderId::parse(prefix) {
            Some(provider) => Ok(Spec::Qualified(provider, label)),
            None => Err(Error::ProviderUnknown {
                typed: prefix.to_string(),
                known: ProviderId::ALL
                    .iter()
                    .map(|p| p.code().to_string())
                    .collect(),
            }),
        },
    }
}

/// The one account this names.
///
/// A label written by 0.1.x could contain a slash, because nothing then separated a tool
/// from a name. Such an account is still found by what it is called, whole: `team/a` finds
/// the Claude Code account literally labelled `team/a` when no tool is called `team`, and
/// when one is and has no such account. Otherwise the app, which passes the label it was
/// given, could neither switch to it nor forget it.
pub(crate) fn resolve<'a>(state: &'a State, typed: &str) -> Result<&'a Account> {
    let literal = || state.accounts.iter().find(|a| a.label == typed);
    let spec = match parse(typed) {
        Ok(spec) => spec,
        Err(unknown) => return literal().ok_or(unknown),
    };
    if let Spec::Qualified(provider, label) = spec
        && state.get(&Key::new(provider, label)).is_none()
        && let Some(account) = literal()
    {
        return Ok(account);
    }
    match spec {
        Spec::Qualified(provider, label) => {
            state
                .get(&Key::new(provider, label))
                .ok_or_else(|| Error::AccountUnknown {
                    label: typed.to_string(),
                    enrolled: state.labels(provider),
                })
        }
        Spec::Bare(label) => {
            let mut found = state.accounts.iter().filter(|a| a.label == label);
            let first = found.next().ok_or_else(|| Error::AccountUnknown {
                label: label.to_string(),
                enrolled: qualified(state),
            })?;
            match found.next() {
                None => Ok(first),
                // Every one of them, not just the two that clash, so the next attempt can
                // be typed from what is on the screen.
                Some(_) => Err(Error::LabelAmbiguous {
                    label: label.to_string(),
                    matches: state
                        .accounts
                        .iter()
                        .filter(|a| a.label == label)
                        .map(qualify)
                        .collect(),
                }),
            }
        }
    }
}

/// `claude/work`, as a person would type it back.
pub(crate) fn qualify(account: &Account) -> String {
    account.key().qualified()
}

/// Every enrolled label, for a bare label that found nothing.
///
/// Qualified only when more than one tool has accounts, because then the reason the label
/// missed may be that it needs a prefix. With one tool a prefix is noise on every line, and
/// this message is one people read often.
fn qualified(state: &State) -> Enrolled {
    let mut providers = state.accounts.iter().map(Account::provider);
    let first = providers.next();
    let one_tool = providers.all(|p| Some(p) == first);
    Enrolled(
        state
            .accounts
            .iter()
            .map(|a| {
                if one_tool {
                    a.label.clone()
                } else {
                    qualify(a)
                }
            })
            .collect(),
    )
}

/// A name somebody is choosing for a new account, with the tool it is for.
///
/// `pitboard enroll codex/personal --sign-in` says both at once. A bare name means the
/// default tool, which keeps every command anybody has typed before working unchanged.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Chosen {
    pub provider: ProviderId,
    pub label: String,
}

/// The tool a bare name is for.
///
/// Claude Code, because a bare name is what every Pitboard command before this took and it
/// meant Claude Code. Adding a second tool must not change what somebody's existing script
/// does.
pub const DEFAULT: ProviderId = ProviderId::Claude;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Unusable {
    Empty,
    NotOneWord,
    Separator,
}

// A new account's name within its tool, by the rules docs/reference/commands.mdx lists.
pub fn check(name: &str) -> std::result::Result<(), Unusable> {
    if name.is_empty() {
        Err(Unusable::Empty)
    } else if name.chars().any(|c| c.is_whitespace() || c.is_control()) {
        Err(Unusable::NotOneWord)
    } else if name.contains(SEPARATOR) {
        Err(Unusable::Separator)
    } else {
        Ok(())
    }
}

/// Read `[provider/]label` as a name for an account that does not exist yet.
///
/// Unlike `resolve`, nothing is enrolled yet to disambiguate against, so a bare name
/// cannot mean "whichever tool has it". It means [`DEFAULT`].
pub fn choose(typed: &str) -> std::result::Result<Chosen, String> {
    let (provider, label) = match typed.split_once(SEPARATOR) {
        None => (DEFAULT, typed),
        Some((prefix, label)) => {
            let provider = ProviderId::parse(prefix).ok_or_else(|| {
                let known: Vec<&str> = ProviderId::ALL.iter().map(|p| p.code()).collect();
                format!(
                    "`{prefix}` is not a tool Pitboard knows. It knows: {}",
                    known.join(", ")
                )
            })?;
            (provider, label)
        }
    };
    match check(label) {
        Ok(()) => Ok(Chosen {
            provider,
            label: label.to_string(),
        }),
        Err(Unusable::Empty) if typed.contains(SEPARATOR) => Err(format!(
            "`{typed}` names a tool but no account. Try `{}{SEPARATOR}work`",
            provider.code()
        )),
        Err(Unusable::Empty | Unusable::NotOneWord) => {
            Err("a label must be one word, such as `personal` or `work`".into())
        }
        Err(Unusable::Separator) => Err(format!(
            "a label cannot contain `{SEPARATOR}`: it separates the tool from the name, so \
             at most one belongs in `{typed}`"
        )),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::state::Detail;

    fn account(provider: ProviderId, label: &str) -> Account {
        let detail = match provider {
            ProviderId::Claude => Detail::Claude {
                organization_uuid: String::new(),
                oauth_account: serde_json::Value::Null,
            },
            ProviderId::Codex => Detail::Codex { workspace_id: None },
            ProviderId::Desktop => Detail::Desktop {},
        };
        Account {
            label: label.into(),
            id: format!("{}-{label}", provider.code()),
            account_uuid: format!("{}-{label}", provider.code()),
            email: format!("{label}@example.com"),
            parked: None,
            last_used_at: None,
            replaced_at: None,
            plan: None,
            detail,
        }
    }

    fn state(labels: &[(ProviderId, &str)]) -> State {
        let mut state = State::default();
        for (provider, label) in labels {
            state.accounts.push(account(*provider, label));
        }
        state
    }

    #[test]
    fn a_prefix_says_which_provider() {
        assert_eq!(
            parse("claude/work").unwrap(),
            Spec::Qualified(ProviderId::Claude, "work")
        );
        assert_eq!(parse("work").unwrap(), Spec::Bare("work"));
    }

    /// Without this, `pitboard use codx/work` looks for an account literally called
    /// `codx/work`, fails to find one, and says the label is not enrolled. The person then
    /// checks their labels, sees `work` is right there, and has no way to tell what
    /// happened.
    #[test]
    fn a_prefix_that_is_not_a_provider_says_so() {
        let err = parse("codx/work").unwrap_err();
        assert_eq!(err.code(), "provider_unknown");
        assert!(err.to_string().contains("codx"), "{err}");
        assert!(err.to_string().contains("claude"), "{err}");
    }

    #[test]
    fn a_bare_label_still_works_when_it_names_one_account() {
        let state = state(&[(ProviderId::Claude, "work")]);
        assert_eq!(resolve(&state, "work").unwrap().label, "work");
        assert_eq!(resolve(&state, "claude/work").unwrap().label, "work");
    }

    #[test]
    fn a_label_nothing_has_lists_what_is_enrolled_qualified() {
        let state = state(&[(ProviderId::Claude, "work")]);
        let err = resolve(&state, "personal").unwrap_err();
        assert_eq!(err.code(), "account_unknown");
        assert!(
            err.to_string().contains("`work`") && !err.to_string().contains("claude/work"),
            "one tool enrolled, so a prefix on every line would be noise: {err}"
        );
    }

    /// Once two tools have accounts, the prefix is the point: a bare label that missed may
    /// have missed because it needed one.
    #[test]
    fn a_miss_lists_labels_qualified_once_two_tools_have_accounts() {
        let one = state(&[
            (ProviderId::Claude, "work"),
            (ProviderId::Claude, "personal"),
        ]);
        assert!(
            !resolve(&one, "nobody")
                .unwrap_err()
                .to_string()
                .contains("claude/"),
            "one tool stays bare"
        );

        let two = state(&[
            (ProviderId::Claude, "work"),
            (ProviderId::Codex, "personal"),
        ]);
        let err = resolve(&two, "nobody").unwrap_err().to_string();
        assert!(
            err.contains("claude/work") && err.contains("codex/personal"),
            "{err}"
        );
    }

    /// A label from before there was a tool prefix is still found by its whole name.
    #[test]
    fn a_label_written_with_a_slash_before_prefixes_existed_is_still_found() {
        let state = state(&[(ProviderId::Claude, "team/a"), (ProviderId::Codex, "a")]);
        assert_eq!(resolve(&state, "team/a").unwrap().label, "team/a");
        assert_eq!(
            resolve(&state, "codex/a").unwrap().provider(),
            ProviderId::Codex,
            "a prefix that names a real account of that tool still wins"
        );
        assert_eq!(
            resolve(&state, "codx/a").unwrap_err().code(),
            "provider_unknown",
            "and a mistyped prefix is still said to be one"
        );
    }

    /// Pitboard picking one would switch an account the person did not name.
    #[test]
    fn a_bare_label_two_providers_share_is_refused_and_both_are_named() {
        let state = state(&[(ProviderId::Claude, "work"), (ProviderId::Codex, "work")]);

        let err = resolve(&state, "work").unwrap_err();
        assert_eq!(err.code(), "label_ambiguous");
        assert!(err.to_string().contains("claude/work"), "{err}");
        assert!(err.to_string().contains("codex/work"), "{err}");

        assert_eq!(
            resolve(&state, "codex/work").unwrap().provider(),
            ProviderId::Codex,
            "and a prefix picks exactly the one it names"
        );
    }

    #[test]
    fn a_qualified_miss_lists_only_that_providers_labels() {
        let state = state(&[(ProviderId::Claude, "work")]);
        let err = resolve(&state, "claude/personal").unwrap_err();
        assert!(err.to_string().contains("`work`"), "{err}");
        assert!(
            !err.to_string().contains("claude/work"),
            "inside one provider the prefix is noise: {err}"
        );
    }

    /// The syntax for adding an account, which has to carry the tool because nothing is
    /// enrolled yet to work it out from.
    #[test]
    fn a_new_account_can_name_its_tool() {
        assert_eq!(
            choose("work").unwrap(),
            Chosen {
                provider: DEFAULT,
                label: "work".into()
            }
        );
        assert_eq!(
            choose("claude/work").unwrap(),
            Chosen {
                provider: ProviderId::Claude,
                label: "work".into()
            }
        );
    }

    /// A bare name meant Claude Code in every Pitboard anybody has run. A second tool must
    /// not change what a script somebody already wrote does.
    #[test]
    fn a_bare_name_still_means_what_it_always_did() {
        assert_eq!(DEFAULT, ProviderId::Claude);
        assert_eq!(choose("work").unwrap().provider, ProviderId::Claude);
    }

    #[test]
    fn a_new_name_is_refused_when_it_is_not_one() {
        for (typed, says) in [
            ("codx/work", "not a tool"),
            ("claude/", "no account"),
            ("claude/b/c", "cannot contain"),
            ("two words", "one word"),
            ("codex/two words", "one word"),
            ("tab\there", "one word"),
            ("", "one word"),
        ] {
            let refused = choose(typed).unwrap_err();
            assert!(refused.contains(says), "{typed}: {refused}");
        }
    }

    #[test]
    fn a_name_is_one_word_with_no_separator() {
        assert_eq!(check("work"), Ok(()));
        assert_eq!(check("é工"), Ok(()));
        assert_eq!(check(""), Err(Unusable::Empty));
        assert_eq!(check("my work"), Err(Unusable::NotOneWord));
        assert_eq!(check("line\u{7}"), Err(Unusable::NotOneWord));
        assert_eq!(check("codex/phu"), Err(Unusable::Separator));
    }
}