use anstream::{ColorChoice, eprintln, print, println};
use anstyle::{AnsiColor, Style};
use clap::{CommandFactory, Parser, Subcommand};
use pitboard_core::context::Context;
use pitboard_core::doctor;
use pitboard_core::error::Error;
use pitboard_core::provider::Adoption;
use pitboard_core::service::{Changing, Done, Failed, Pitboard, Warning};
use pitboard_core::switch::{Enrolled, Outcome, Renewal};
use serde_json::{Value, json};
use std::io::{IsTerminal, Read, Write};
use std::process::ExitCode;
use ui::{BOLD, DIM, WARN, paint};
mod manpage;
mod render;
mod ui;
const CONTRACT: u32 = 1;
const ERROR: Style = AnsiColor::Red.on_default().bold();
const WARNING: Style = AnsiColor::Yellow.on_default().bold();
#[derive(Parser)]
#[command(name = "pitboard", version)]
struct Cli {
#[arg(long, global = true)]
json: bool,
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Subcommand)]
enum Command {
Status {
#[arg(long)]
offline: bool,
#[arg(long, conflicts_with = "offline")]
fresh: bool,
},
Enroll {
#[arg(value_parser = label_to_enroll)]
label: String,
#[arg(long)]
sign_in: bool,
},
Use {
label: String,
},
Forget {
label: String,
#[arg(short = 'y', long)]
yes: bool,
},
Abandon,
Repair,
Adopt,
Renew,
Schedule {
#[command(subcommand)]
what: ScheduleCommand,
},
Log {
#[arg(short = 'n', long, default_value_t = 20)]
lines: usize,
},
Uninstall {
#[arg(short = 'y', long)]
yes: bool,
},
Rename {
from: String,
#[arg(value_parser = new_label)]
to: String,
},
Doctor,
Statusline,
Completions { shell: clap_complete::Shell },
#[command(hide = true)]
Manpage,
}
fn label_to_enroll(text: &str) -> Result<String, String> {
if text.is_empty() || text.chars().any(|c| c.is_whitespace() || c.is_control()) {
return Err("a label must be one word, such as `personal` or `work`".into());
}
Ok(text.to_string())
}
fn new_label(text: &str) -> Result<String, String> {
if text.is_empty() || text.chars().any(|c| c.is_whitespace() || c.is_control()) {
return Err("a label must be one word, such as `personal` or `work`".into());
}
pitboard_core::label::choose(text)?;
Ok(text.to_string())
}
struct Report {
command: Option<&'static str>,
result: Result<Value, Error>,
warnings: Vec<Value>,
human: String,
exit: u8,
failure: Option<(&'static str, String)>,
}
impl Report {
fn done(command: &'static str, data: Value, human: String) -> Report {
Report {
command: Some(command),
result: Ok(data),
warnings: Vec::new(),
human,
exit: 0,
failure: None,
}
}
fn failed(command: Option<&'static str>, error: Error) -> Report {
Report {
command,
exit: error.exit_code(),
result: Err(error),
warnings: Vec::new(),
human: String::new(),
failure: None,
}
}
fn refused(command: &'static str, failed: Failed) -> Report {
Report {
warnings: warnings(&failed.warnings),
..Report::failed(Some(command), failed.error)
}
}
}
fn emit(report: Report, as_json: bool) -> ExitCode {
if as_json {
let (data, error) = match (&report.result, &report.failure) {
(Ok(data), None) => (data.clone(), Value::Null),
(Ok(data), Some((code, message))) => {
(data.clone(), json!({ "code": code, "message": message }))
}
(Err(e), _) => (
Value::Null,
json!({
"code": e.code(),
"message": e.to_string(),
"cause": e.cause().map(|c| json!({
"code": c.code(),
"worth_retrying": c.worth_retrying(),
})),
}),
),
};
let envelope = json!({
"v": CONTRACT,
"command": report.command,
"ok": report.result.is_ok() && report.exit == 0,
"data": data,
"warnings": report.warnings,
"error": error,
});
println!("{envelope}");
} else {
match &report.result {
Ok(_) => print!("{}", report.human),
Err(e) => eprintln!("{} {e}", paint(ERROR, "error:")),
}
for w in &report.warnings {
eprintln!(
"{} {}",
paint(WARNING, "warning:"),
w["message"].as_str().unwrap_or_default()
);
}
}
ExitCode::from(report.exit)
}
fn warnings(list: &[Warning]) -> Vec<Value> {
list.iter()
.map(|w| json!({ "code": w.code(), "message": w.to_string() }))
.collect()
}
fn changed<T>(
command: &'static str,
outcome: Changing<T>,
render: impl FnOnce(T) -> (Value, String),
) -> Report {
match outcome {
Ok(Done {
value,
warnings: found,
}) => {
let (data, human) = render(value);
Report {
warnings: warnings(&found),
..Report::done(command, data, human)
}
}
Err(failed) => Report::refused(command, failed),
}
}
fn status(pitboard: &Pitboard, offline: bool, fresh: bool) -> Report {
let read = if offline {
pitboard.status_offline()
} else {
pitboard.status(fresh)
};
match read {
Ok(Done {
value,
warnings: found,
}) => Report {
warnings: warnings(&found),
..Report::done(
"status",
render::status::json(&value),
render::status::human(&value),
)
},
Err(e) => Report::failed(Some("status"), e),
}
}
fn doctor(pitboard: &Pitboard) -> Report {
let diagnosis = pitboard.doctor();
let healthy = doctor::healthy(&diagnosis.checks);
let failed = diagnosis
.checks
.iter()
.filter(|c| c.level == doctor::Level::Fail)
.count();
Report {
exit: if healthy { 0 } else { 3 },
failure: (!healthy).then(|| {
(
"checks_failed",
format!("{failed} check(s) failed; see data.checks"),
)
}),
..Report::done(
"doctor",
render::doctor::json(&diagnosis),
render::doctor::human(&diagnosis.checks),
)
}
}
fn statusline(pitboard: &Pitboard) -> Report {
let mut input = String::new();
if !std::io::stdin().is_terminal() {
let _ = std::io::stdin().read_to_string(&mut input);
}
let line = render::statusline::human(&pitboard.statusline(&input));
if std::env::var_os("NO_COLOR").is_none() {
ColorChoice::Always.write_global();
}
Report::done(
"statusline",
json!({ "line": anstream::adapter::strip_str(&line).to_string() }),
format!("{line}\n"),
)
}
fn enroll_signing_in(pitboard: &Pitboard, label: &str) -> Report {
let existing = pitboard.account_to_enroll(label);
let who = existing
.as_ref()
.map_or_else(|| "the account to add".to_string(), |a| a.email.clone());
let tool = existing.as_ref().map_or_else(
|| {
pitboard_core::label::choose(label)
.map(|chosen| chosen.provider)
.unwrap_or(pitboard_core::label::DEFAULT)
},
pitboard_core::state::Account::provider,
);
eprintln!(
"Opening {}'s sign-in. Sign in as {who}; the account in use now stays signed in.",
tool.name()
);
match pitboard.sign_in(label) {
Ok(login) => enrolled(pitboard, label, pitboard.enroll_signed_in(label, login)),
Err(failed) => Report::refused("enroll", failed),
}
}
fn enrolled(pitboard: &Pitboard, label: &str, outcome: Changing<Enrolled>) -> Report {
let name = paint(BOLD, label);
let provider = pitboard.account_to_enroll(label).map_or_else(
|| {
pitboard_core::label::choose(label)
.map(|chosen| chosen.provider)
.unwrap_or(pitboard_core::label::DEFAULT)
},
|account| account.provider(),
);
let to_use = pitboard.name_to_type(label);
let another = pitboard_core::state::Key::new(provider, "<label>").typed();
changed("enroll", outcome, |enrolled| {
let (kind, email, human) = match enrolled {
Enrolled::Current { email } => {
let human = format!(
"Enrolled {name} ({email}), the account signed in now.\n\
Add another without signing out of it: pitboard enroll {another} \
--sign-in\n"
);
("current", email, human)
}
Enrolled::SignedIn { email } => {
let human = format!(
"Enrolled {name} ({email}). Switch to it with: pitboard use {to_use}\n"
);
("signed_in", email, human)
}
Enrolled::Renewed { email } => {
let human = format!("Renewed {name} ({email}): its parked login is a fresh one.\n");
("renewed", email, human)
}
Enrolled::InUse { email, again } => {
let human = if again {
format!(
"Signed in to {name} ({email}) again. Its new login is the one in use \
now.\n"
)
} else {
format!(
"Enrolled {name} ({email}), the account signed in now. Its new login is \
the one in use.\n"
)
};
("in_use", email, human)
}
};
(
json!({
"label": label,
"provider": provider,
"email": email,
"enrolled": kind,
}),
human,
)
})
}
fn use_account(pitboard: &Pitboard, label: &str) -> Report {
changed("use", pitboard.switch_to(label), |outcome| match outcome {
Outcome::AlreadyActive { label } => (
json!({ "to": label, "changed": false }),
format!("{} is already signed in.\n", paint(BOLD, &label)),
),
Outcome::Switched {
provider,
from,
to,
parked,
adoption,
} => {
let (seconds, follows, adoption_json) = match adoption {
Adoption::PollingWithin(seconds) => (
Some(seconds),
format!(
"{} sessions already running follow within {seconds} seconds.\n",
provider.name()
),
json!({ "follows": "polling", "within_seconds": seconds }),
),
Adoption::RestartRequired { program } => (
None,
format!(
"Restart any running `{program}` for this to take effect. \
It will not pick the switch up on its own.\n"
),
json!({ "follows": "restart", "program": program }),
),
};
(
json!({
"from": from,
"to": to,
"provider": provider,
"changed": true,
"parked_at": parked.parked_at,
"adoption_ceiling_seconds": seconds,
"adoption": adoption_json,
}),
format!(
"Switched to {}; {} is parked.\n{follows}",
paint(BOLD, &to),
paint(BOLD, &from),
),
)
}
})
}
fn forget(pitboard: &Pitboard, label: &str) -> Report {
changed("forget", pitboard.forget(label), |email| {
(
json!({ "label": label, "email": email }),
format!("Forgot {} ({email}).\n", paint(BOLD, label)),
)
})
}
fn abandon(pitboard: &Pitboard) -> Report {
match pitboard.abandon_recovery() {
Err(error) => Report::failed(Some("abandon"), error),
Ok(None) => Report::done(
"abandon",
json!({ "abandoned": false }),
"There is no interrupted switch to give up on.\n".into(),
),
Ok(Some(a)) => Report::done(
"abandon",
json!({
"abandoned": true,
"from": a.from,
"to": a.to,
"logins_kept": a.kept,
}),
format!(
"Gave up on the interrupted switch from {} to {}. {} login(s) kept; \
nothing was deleted. Run `pitboard` to see who is signed in.\n",
paint(BOLD, &a.from),
paint(BOLD, &a.to),
a.kept
),
),
}
}
#[derive(Subcommand)]
enum ScheduleCommand {
Install,
Status,
Uninstall,
}
fn renew(pitboard: &Pitboard) -> Report {
let outcomes = pitboard.renew();
let renewed = outcomes
.iter()
.filter(|(_, r)| matches!(r, Renewal::Renewed))
.count();
let human = match (outcomes.len(), renewed) {
(0, _) => "No parked login was due.\n".to_string(),
(_, 0) => format!("{} due; none could be renewed this time.\n", outcomes.len()),
(all, done) if all == done => format!("Renewed {done}.\n"),
(all, done) => format!("Renewed {done} of {all}; the rest are tried again next time.\n"),
};
Report::done(
"renew",
json!({
"accounts": outcomes.iter().map(|(key, outcome)| json!({
"label": key.typed(),
"provider": key.provider,
"outcome": outcome.code(),
})).collect::<Vec<_>>(),
"renewed": renewed,
}),
human,
)
}
fn schedule(pitboard: &Pitboard, what: &ScheduleCommand) -> Report {
use pitboard_core::schedule::Installed;
let say = |installed: &Installed| match installed {
Installed::Yes {
path,
every_seconds,
} => (
json!({
"installed": true,
"path": path,
"every_seconds": every_seconds,
}),
format!(
"Parked logins are renewed every {} by this computer's own scheduler.\n{}\n",
pitboard_core::time::span(i64::from(*every_seconds)),
path.display()
),
),
Installed::No => (
json!({"installed": false}),
"Nothing is keeping parked logins alive here. They are renewed when you run \
`pitboard`, and otherwise not.\nRun `pitboard schedule install` to change \
that.\n"
.to_string(),
),
Installed::Unsupported => (
json!({"installed": false, "supported": false}),
"This computer has no scheduler pitboard knows how to write.\n".to_string(),
),
};
match what {
ScheduleCommand::Status => {
let (data, human) = say(&pitboard.schedule());
Report::done("schedule", data, human)
}
ScheduleCommand::Install => match pitboard.schedule_install() {
Err(error) => Report::failed(Some("schedule"), error),
Ok(path) => Report::done(
"schedule",
json!({"installed": true, "path": path}),
format!(
"Parked logins will be renewed daily.\n{}\n\nIt renews your own \
parked logins and does nothing else: it never switches account and \
never asks Anthropic for usage.\n",
path.display()
),
),
},
ScheduleCommand::Uninstall => match pitboard.schedule_uninstall() {
Err(error) => Report::failed(Some("schedule"), error),
Ok(removed) => Report::done(
"schedule",
json!({"installed": false, "removed": removed}),
if removed {
"Stopped renewing parked logins on a schedule. They are renewed when \
you run `pitboard`, and otherwise not.\n"
.to_string()
} else {
"There was nothing scheduled.\n".to_string()
},
),
},
}
}
fn adopt(pitboard: &Pitboard) -> Report {
match pitboard.adopt() {
Err(error) => Report::failed(Some("adopt"), error),
Ok(None) => Report::done(
"adopt",
json!({ "adopted": false }),
"This pitboard directory was already written on this computer.\n".into(),
),
Ok(Some(a)) => {
let ways_back: Vec<String> = a
.logins_dropped
.iter()
.map(|label| format!(" pitboard enroll {label} --sign-in\n"))
.collect();
Report::done(
"adopt",
json!({
"adopted": true,
"accounts": a.accounts,
"logins_dropped": a.logins_dropped,
}),
format!(
"Took over this directory: {} account(s) kept.\n\
{} parked login(s) dropped, because a login belongs to the computer \
that signed in.\n{}",
a.accounts.len(),
a.logins_dropped.len(),
if ways_back.is_empty() {
String::new()
} else {
format!("Sign in to each again:\n{}", ways_back.concat())
}
),
)
}
}
}
fn repair(pitboard: &Pitboard) -> Report {
changed("repair", pitboard.repair(), |r| {
let human = if r.is_empty() {
"Every parked login here is accounted for.\n".to_string()
} else {
let mut said = String::new();
for (label, _) in &r.given_back {
said.push_str(&format!(
"Gave {} back a parked login that nothing named.\n",
paint(BOLD, label)
));
}
if !r.deleted.is_empty() {
said.push_str(&format!(
"Deleted {} parked login(s) pitboard wrote down here and nothing \
recorded.\n",
r.deleted.len()
));
}
if !r.strangers.is_empty() {
said.push_str(&format!(
"{} parked login(s) here belong to no account pitboard knows and were \
not written down by this one. Left alone: the keychain is shared by \
the whole machine, and they may be another pitboard's.\n",
r.strangers.len()
));
}
if !r.unreadable.is_empty() {
said.push_str(&format!(
"{} could not be read this time and were left alone. Unlock the \
keychain and run this again.\n",
r.unreadable.len()
));
}
said
};
(
json!({
"given_back": r.given_back.iter().map(|(label, service)| json!({
"label": label,
"service": service,
})).collect::<Vec<_>>(),
"deleted": r.deleted,
"strangers": r.strangers,
"unreadable": r.unreadable,
}),
human,
)
})
}
fn log(pitboard: &Pitboard, lines: usize) -> Report {
let entries = pitboard.log(lines);
let width = entries.iter().map(|e| e.verb.len()).max().unwrap_or(0);
let human = if entries.is_empty() {
"pitboard has not changed anything yet.\n".to_string()
} else {
entries
.iter()
.map(|e| {
format!(
"{} {} {} {}\n",
paint(DIM, &e.at),
ui::pad(&e.verb, width),
e.subject,
paint(if e.outcome == "ok" { DIM } else { WARN }, &e.outcome),
)
})
.collect()
};
Report::done(
"log",
json!({
"entries": entries
.iter()
.map(|e| json!({
"at": e.at,
"caller": e.caller,
"verb": e.verb,
"subject": e.subject,
"outcome": e.outcome,
}))
.collect::<Vec<_>>(),
}),
human,
)
}
fn uninstall(pitboard: &Pitboard) -> Report {
changed("uninstall", pitboard.uninstall(), |removed| {
let mut human = format!(
"Removed {} parked login(s). The login each tool is signed in with is untouched.\n",
removed.parks
);
if removed.schedule_removed {
human.push_str("Turned off the daily renewal schedule.\n");
}
if removed.left > 0 {
human.push_str(&format!(
"Left {} parked login(s) that `pitboard repair` found and this pitboard did \
not write, because they may be another pitboard's.\n",
removed.left
));
}
if removed.pending > 0 {
human.push_str(&format!(
"{} could not be deleted, so ~/.pitboard was kept; run `pitboard \
uninstall` again.\n",
removed.pending
));
} else if removed.home_removed {
human.push_str(
"~/.pitboard is gone. Remove pitboard itself the way you installed it.\n",
);
}
(
json!({
"parks_removed": removed.parks,
"parks_pending": removed.pending,
"parks_left": removed.left,
"home_removed": removed.home_removed,
"schedule_removed": removed.schedule_removed,
}),
human,
)
})
}
fn rename(pitboard: &Pitboard, from: &str, to: &str) -> Report {
changed("rename", pitboard.rename(from, to), |email| {
(
json!({ "from": from, "to": to, "email": email }),
format!(
"Renamed {} to {} ({email}).\n",
paint(BOLD, from),
paint(BOLD, to)
),
)
})
}
fn parse() -> Result<Cli, ExitCode> {
Cli::try_parse().map_err(|e| {
let wants_json = std::env::args_os().any(|arg| arg == "--json");
if wants_json && e.use_stderr() {
let message = e.render().to_string().trim().to_string();
emit(Report::failed(None, Error::Usage(message)), true)
} else {
let _ = e.print();
ExitCode::from(e.exit_code().clamp(0, 255) as u8)
}
})
}
fn main() -> ExitCode {
let cli = match parse() {
Ok(cli) => cli,
Err(exit) => return exit,
};
let pitboard = Pitboard::new(Context::from_env());
let report = match cli.command.unwrap_or(Command::Status {
offline: false,
fresh: false,
}) {
Command::Status { offline, fresh } => status(&pitboard, offline, fresh),
Command::Doctor => doctor(&pitboard),
Command::Statusline => statusline(&pitboard),
Command::Enroll {
label,
sign_in: true,
} => enroll_signing_in(&pitboard, &label),
Command::Enroll { label, .. } => {
enrolled(&pitboard, &label, pitboard.enroll_current(&label))
}
Command::Use { label } => use_account(&pitboard, &label),
Command::Forget { label, yes } => {
if !yes
&& !cli.json
&& std::io::stdin().is_terminal()
&& std::io::stderr().is_terminal()
{
eprint!(
"Forget {} and delete its parked login? \
Adding it again needs a browser sign-in. [y/N] ",
label
);
let _ = std::io::stderr().flush();
let mut answer = String::new();
let _ = std::io::stdin().read_line(&mut answer);
if !matches!(answer.trim(), "y" | "Y" | "yes") {
return ExitCode::SUCCESS;
}
}
forget(&pitboard, &label)
}
Command::Abandon => abandon(&pitboard),
Command::Repair => repair(&pitboard),
Command::Adopt => adopt(&pitboard),
Command::Renew => renew(&pitboard),
Command::Schedule { ref what } => schedule(&pitboard, what),
Command::Log { lines } => log(&pitboard, lines),
Command::Uninstall { yes } => {
if !yes
&& !cli.json
&& std::io::stdin().is_terminal()
&& std::io::stderr().is_terminal()
{
eprint!(
"Delete every parked login this pitboard wrote, the daily renewal \
schedule and ~/.pitboard? The account you are signed in to stays signed \
in; the others need a browser sign-in again. [y/N] "
);
let _ = std::io::stderr().flush();
let mut answer = String::new();
let _ = std::io::stdin().read_line(&mut answer);
if !matches!(answer.trim(), "y" | "Y" | "yes") {
return ExitCode::SUCCESS;
}
}
uninstall(&pitboard)
}
Command::Rename { from, to } => rename(&pitboard, &from, &to),
Command::Completions { .. } | Command::Manpage if cli.json => Report {
exit: 2,
failure: Some((
"output_is_not_a_report",
"this command writes a generated file to stdout, so it has no JSON form".into(),
)),
..Report::done("generate", json!({}), String::new())
},
Command::Completions { shell } => {
clap_complete::generate(
shell,
&mut Cli::command(),
env!("CARGO_BIN_NAME"),
&mut std::io::stdout(),
);
return ExitCode::SUCCESS;
}
Command::Manpage => {
return match manpage::render(Cli::command(), &mut std::io::stdout()) {
Ok(()) => ExitCode::SUCCESS,
Err(_) => ExitCode::FAILURE,
};
}
};
emit(report, cli.json)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_command_line_definition_is_internally_consistent() {
Cli::command().debug_assert();
}
#[test]
fn a_mistyped_flag_is_rejected_rather_than_ignored() {
assert!(Cli::try_parse_from(["pitboard", "--jsno", "status"]).is_err());
assert!(Cli::try_parse_from(["pitboard", "status", "--verbose"]).is_err());
assert!(Cli::try_parse_from(["pitboard", "status", "extra", "words"]).is_err());
}
#[test]
fn json_is_accepted_before_or_after_the_subcommand() {
assert!(
Cli::try_parse_from(["pitboard", "--json", "use", "work"])
.unwrap()
.json
);
assert!(
Cli::try_parse_from(["pitboard", "use", "work", "--json"])
.unwrap()
.json
);
}
#[test]
fn a_new_label_is_one_word() {
assert!(Cli::try_parse_from(["pitboard", "rename", "a", "personal"]).is_ok());
for bad in ["", "two words", "tab\there"] {
assert!(
Cli::try_parse_from(["pitboard", "rename", "a", bad]).is_err(),
"{bad:?}"
);
assert!(
Cli::try_parse_from(["pitboard", "enroll", bad]).is_err(),
"{bad:?}"
);
}
}
#[test]
fn no_arguments_means_status() {
assert!(Cli::try_parse_from(["pitboard"]).unwrap().command.is_none());
}
#[test]
fn the_man_page_sets_out_every_command_itself() {
let mut out = Vec::new();
manpage::render(Cli::command(), &mut out).unwrap();
let page = String::from_utf8(out).unwrap();
assert!(
!page.contains("(1)"),
"a reference to another page:\n{page}"
);
for typed in [
"pitboard status",
"pitboard enroll",
"pitboard use",
"pitboard forget",
"pitboard abandon",
"pitboard repair",
"pitboard adopt",
"pitboard renew",
"pitboard schedule install",
"pitboard schedule status",
"pitboard schedule uninstall",
"pitboard log",
"pitboard uninstall",
"pitboard rename",
"pitboard doctor",
"pitboard statusline",
"pitboard completions",
] {
assert!(
page.contains(&format!("\\fB{typed}\\fR")),
"{typed} missing:\n{page}"
);
}
for option in [r"\-\-offline", r"\-\-sign\-in", r"\-y", r"\-\-lines"] {
assert!(page.contains(option), "{option} missing:\n{page}");
}
assert!(page.contains("[possible values: bash"), "{page}");
assert!(page.contains("[default: 20]"), "{page}");
assert!(
!page.contains("pitboard manpage"),
"the hidden command is shown"
);
assert!(!page.contains("pitboard help"), "clap's own help is shown");
assert!(
page.contains(".SH SYNOPSIS") && page.contains(".SH VERSION"),
"{page}"
);
}
}