mod parsers;
use cctop_core::config;
use cctop_core::pricing::Provider;
use cctop_core::util::plural;
use std::io::IsTerminal;
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Level {
Ok,
Warn,
Fail,
}
impl Level {
fn glyph(self) -> &'static str {
match self {
Level::Ok => "✓",
Level::Warn => "!",
Level::Fail => "✗",
}
}
fn colour(self, tty: bool) -> &'static str {
match (tty, self) {
(false, _) => "",
(true, Level::Ok) => "\x1b[32m",
(true, Level::Warn) => "\x1b[33m",
(true, Level::Fail) => "\x1b[31m",
}
}
}
pub struct Check {
pub level: Level,
pub label: String,
pub detail: String,
pub fix: Option<String>,
}
fn ok(label: impl Into<String>, detail: impl Into<String>) -> Check {
Check {
level: Level::Ok,
label: label.into(),
detail: detail.into(),
fix: None,
}
}
fn warn(label: impl Into<String>, detail: impl Into<String>, fix: &str) -> Check {
Check {
level: Level::Warn,
label: label.into(),
detail: detail.into(),
fix: Some(fix.to_string()),
}
}
fn fail(label: impl Into<String>, detail: impl Into<String>, fix: &str) -> Check {
Check {
level: Level::Fail,
label: label.into(),
detail: detail.into(),
fix: Some(fix.to_string()),
}
}
pub struct Section {
pub title: &'static str,
pub checks: Vec<Check>,
}
fn parse_args(args: &[String]) -> Result<Vec<String>, String> {
let mut hosts = Vec::new();
let mut rest = args.iter();
while let Some(arg) = rest.next() {
match arg.split_once('=') {
Some(("--host", value)) => hosts.push(value.to_string()),
_ if arg == "--host" => match rest.next() {
Some(value) => hosts.push(value.clone()),
None => return Err("--host needs a value".into()),
},
_ => return Err(format!("unknown argument '{arg}'")),
}
}
Ok(hosts)
}
pub fn run(args: &[String]) -> i32 {
let hosts = match parse_args(args) {
Ok(hosts) => hosts,
Err(why) => {
eprintln!("cctop doctor: {why}");
eprintln!("usage: cctop doctor [--host [user@]host[:command]]…");
return 2;
}
};
let hosts = &hosts;
let sections = vec![
cctop_itself(),
environment(),
sources(),
pricing(),
parsers(),
cache(),
secrets(),
hooks(),
typing(),
remotes(hosts),
];
let tty = std::io::stdout().is_terminal();
let reset = if tty { "\x1b[0m" } else { "" };
for section in §ions {
if section.checks.is_empty() {
continue;
}
println!("\n{}", section.title);
for check in §ion.checks {
match check.label.is_empty() {
true => println!(
" {}{}{reset} {}",
check.level.colour(tty),
check.level.glyph(),
check.detail
),
false => println!(
" {}{}{reset} {:<22} {}",
check.level.colour(tty),
check.level.glyph(),
check.label,
check.detail
),
}
if let Some(fix) = &check.fix {
println!(" → {fix}");
}
}
}
let counts = |want: Level| {
sections
.iter()
.flat_map(|s| &s.checks)
.filter(|c| c.level == want)
.count()
};
let (warns, fails) = (counts(Level::Warn), counts(Level::Fail));
println!();
match (fails, warns) {
(0, 0) => println!("All checks passed."),
(0, w) => println!("{w} warning(s). Nothing is broken."),
(f, w) => println!("{f} problem(s), {w} warning(s)."),
}
i32::from(fails > 0)
}
impl PartialOrd for Level {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Level {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
fn rank(l: Level) -> u8 {
match l {
Level::Ok => 0,
Level::Warn => 1,
Level::Fail => 2,
}
}
rank(*self).cmp(&rank(*other))
}
}
fn cctop_itself() -> Section {
let mut checks = vec![ok("version", cctop_core::update::current_version())];
checks.push(match std::env::current_exe() {
Ok(path) => ok("binary", path.display().to_string()),
Err(e) => warn(
"binary",
format!("could not resolve this executable's path: {e}"),
"hook installs record an absolute path, so they may be wrong",
),
});
if let Some(latest) = cctop_core::update::cached_latest_version()
&& latest != cctop_core::update::current_version()
{
let route = match cctop_core::update::built_by_cargo() {
true => "cargo install cctop --force",
false => "cctop --update",
};
checks.push(warn("update", format!("v{latest} is available"), route));
}
Section {
title: "cctop",
checks,
}
}
fn environment() -> Section {
const VARS: &[&str] = &[
"CLAUDE_CONFIG_DIR",
"CODEX_HOME",
"CURSOR_HOME",
"GEMINI_DIR",
"OPENCODE_DATA_DIR",
"PI_CODING_AGENT_DIR",
"PI_CODING_AGENT_SESSION_DIR",
"VIBE_HOME",
"WINDSURF_USER_DIR",
"CCTOP_ALL_USERS",
"CCTOP_HOMES",
"CCTOP_HOSTS",
"CCTOP_COLUMNS_HIDE",
"CCTOP_SETTLE_MS",
];
let mut checks: Vec<Check> = VARS
.iter()
.filter_map(|name| {
let value = std::env::var(name).ok()?;
Some(ok(*name, value))
})
.collect();
if cctop_core::util::unzoned_over_ssh().is_some() {
checks.push(warn(
"TZ",
"unset over ssh, and this machine's clock is UTC: \"today\" and \"this hour\" \
are cut at UTC midnight, not yours",
"export TZ=<your zone, e.g. Pacific/Auckland> in the shell rc on this machine",
));
}
Section {
title: "Environment overrides",
checks,
}
}
pub(crate) fn sessions_root(provider: Provider) -> std::path::PathBuf {
match provider {
Provider::Claude => config::CLAUDE_PROJECTS_ROOT.clone(),
Provider::Codex => config::CODEX_SESSIONS_ROOT.clone(),
Provider::Cursor => config::CURSOR_PROJECTS_ROOT.clone(),
Provider::Devin => config::DEVIN_CLI_DIR.clone(),
Provider::Gemini => config::GEMINI_CHATS_ROOT.clone(),
Provider::OpenCode => config::OPENCODE_DATA_DIR.clone(),
Provider::Pi => config::PI_SESSIONS_ROOT.clone(),
Provider::Vibe => config::VIBE_SESSIONS_ROOT.clone(),
Provider::Windsurf => config::WINDSURF_USER_DIR.clone(),
}
}
fn sources() -> Section {
let sessions = cctop_core::session::list_all();
let mut checks: Vec<Check> = Provider::ALL
.into_iter()
.map(|provider| {
let name = provider.display_name();
let root = sessions_root(provider);
let found = sessions.iter().filter(|s| s.provider == provider).count();
match (config::dir_exists(&root) || found > 0, found) {
(false, _) => ok(name, format!("not installed ({})", root.display())),
(true, 0) => warn(
name,
format!(
"directory exists but holds no sessions ({})",
root.display()
),
"if that is wrong, check the environment overrides above",
),
(true, n) => ok(name, format!("{n} session(s)")),
}
})
.collect();
let others = &*config::OTHER_HOMES;
if !others.is_empty() {
let names: Vec<&str> = others.iter().map(|o| o.user.as_str()).collect();
checks.push(ok(
"all users",
format!(
"also reading {} other home(s): {}",
names.len(),
names.join(", ")
),
));
}
if sessions.is_empty() {
checks.push(warn(
"total",
"no sessions found in any harness",
"run an agent once, then try again — cctop reads what they leave on disk",
));
} else {
let running = sessions.iter().filter(|s| s.is_running()).count();
checks.push(ok(
"total",
format!("{} session(s), {running} running now", sessions.len()),
));
}
Section {
title: "Session sources",
checks,
}
}
fn pricing() -> Section {
let path: &Path = &config::PRICING_CACHE_FILE;
let fresh = cctop_core::pricing::load_cached_pricing();
let loaded = cctop_core::pricing::pricing_epoch() != 0;
let age = cache_age_secs(path);
let checks = vec![match (loaded, fresh, age) {
(true, true, _) => ok("LiteLLM table", "cached and fresh"),
(true, false, Some(secs)) => warn(
"LiteLLM table",
format!(
"cached but {} old",
cctop_core::util::long_duration((secs * 1000) as i64)
),
"the next interactive run refreshes it; \
costs use the stale rates until then",
),
(true, false, None) => ok("LiteLLM table", "loaded"),
(false, _, _) => fail(
"LiteLLM table",
format!("no usable pricing cache at {}", path.display()),
"check network access to raw.githubusercontent.com; until it downloads, \
models outside the built-in tables price at $0.00, \
which is indistinguishable from a free plan",
),
}];
Section {
title: "Pricing",
checks,
}
}
fn parsers() -> Section {
let mut loader = cctop_core::loader::Loader::new();
let sessions = loader.load(cctop_core::pricing::Plan::Retail);
let data: Vec<_> = sessions
.iter()
.map(|s| loader.store().session_data(s))
.collect();
loader.store().save();
parsers::section(&sessions, &data)
}
fn cache() -> Section {
let dir: &Path = &config::CACHE_DIR;
let mut checks = Vec::new();
match writable(dir) {
Ok(()) => checks.push(ok("directory", dir.display().to_string())),
Err(e) => checks.push(fail(
"directory",
format!("{} is not writable: {e}", dir.display()),
"without it every transcript is re-parsed on each launch",
)),
}
let bytes = dir_size(dir);
if bytes > 0 {
checks.push(ok("size", cctop_core::util::compact_bytes(bytes)));
}
Section {
title: "Cache",
checks,
}
}
fn secrets() -> Section {
use cctop_core::secrets::{Store, redact};
let store = Store::current();
let status = store.status();
let at = |p: &Path| cctop_core::util::tildify(&p.to_string_lossy());
let wide = |mode: u32| mode & 0o077 != 0;
let mut checks = vec![ok(
"kept in",
format!("{}, key {}", at(&store.enc), at(&store.key)),
)];
checks.push(match status.key_mode {
None if status.enc_mode.is_some() => fail(
"key",
format!("{} is missing", at(&store.key)),
"the saved tokens cannot be read without it; add them again",
),
None => ok("key", "none yet · made with the first token saved"),
Some(mode) if wide(mode) => warn(
"key",
format!("{} is mode {mode:o}, readable by others", at(&store.key)),
&format!("chmod 600 {}", at(&store.key)),
),
Some(_) => ok("key", "present, owner-only"),
});
match &status.sealed {
Ok(sealed) => {
checks.push(match status.enc_mode {
None => ok("tokens", "none saved"),
Some(mode) if wide(mode) => warn(
"tokens",
format!("{} is mode {mode:o}", at(&store.enc)),
&format!("chmod 600 {}", at(&store.enc)),
),
Some(_) => ok(
"tokens",
format!("{} encrypted", plural(sealed.len(), "token")),
),
});
for (key, value) in sealed {
checks.push(ok(format!(" {key}"), redact(value)));
}
}
Err(why) => checks.push(fail(
"tokens",
format!("{} does not open: {why}", at(&store.enc)),
"add the tokens again; the old file is moved aside then, and kept",
)),
}
if !status.machine_id {
checks.push(warn(
"machine id",
"no /etc/machine-id, so the key alone opens the file",
"the two files copied together would open on another machine",
));
}
if !status.plaintext.is_empty() {
let found: Vec<String> = status
.plaintext
.iter()
.map(|(key, value)| format!("{key} {}", redact(value)))
.collect();
checks.push(warn(
"plain text",
format!("in {}: {}", at(&store.config), found.join(", ")),
match status.sealed {
Ok(_) => "start cctop: it moves them into the encrypted file",
Err(_) => "left there while the encrypted file does not open",
},
));
}
Section {
title: "Secrets",
checks,
}
}
fn hooks() -> Section {
let cwd = std::env::current_dir().ok();
let checks = cctop_core::hook::status(cwd.as_deref(), None)
.lines()
.into_iter()
.map(|(text, problem)| {
if text.starts_with("Listener") {
return Check {
level: Level::Ok,
label: String::new(),
detail: "Listener: only runs inside the cctop UI, so not expected here".into(),
fix: None,
};
}
Check {
level: if problem { Level::Warn } else { Level::Ok },
label: String::new(),
detail: text,
fix: problem.then(|| "cctop --install-hooks".to_string()),
}
})
.collect();
Section {
title: "Agent hooks",
checks,
}
}
fn typing() -> Section {
let mut checks = vec![
match alias_installed() {
true => ok(
"shell aliases",
"installed; agents started from a shell run under cctop",
),
false => warn(
"shell aliases",
"not installed",
"cctop --install-alias — or start agents as `cctop claude`; \
this is what lets `s` and `a` reach a session",
),
},
mux_check(),
];
checks.extend(tiocsti_check());
Section {
title: "Typing into sessions",
checks,
}
}
fn mux_check() -> Check {
let socket = match cctop_core::mux::socket() {
Ok(socket) => socket,
Err(why) => return fail("rmux", why, "set XDG_RUNTIME_DIR to a shorter directory"),
};
let mut check = match cctop_core::mux::status() {
Some(status) => ok(
"rmux",
format!(
"built in (rmux {}); {} at {}",
status.version,
plural(status.sessions, "agent session"),
socket.display()
),
),
None => ok(
"rmux",
format!(
"built in; not running — it starts with the first tab, at {}",
socket.display()
),
),
};
if cctop_core::shim::is_command("rmux") {
check.fix = Some(
"agents cctop 0.31 or older started are still in your own rmux, and not \
shown here: `rmux ls` lists them, `rmux attach -t <name>` reaches one"
.into(),
);
}
check
}
fn tiocsti_check() -> Option<Check> {
Some(match is_root() {
true => ok(
"TIOCSTI",
"running as root; sessions in plain terminals can be typed into",
),
false => ok(
"TIOCSTI",
"unavailable (not root) — the last-resort backend only, \
and not needed if the aliases above are installed",
),
})
}
fn remotes(hosts: &[String]) -> Section {
let checks = cctop_core::fleet::Host::collect(hosts)
.iter()
.map(|host| match host.poll() {
cctop_core::fleet::Snapshot::Rows(rows) => {
let mut read = format!("{} session(s) via `{}`", rows.len(), host.command);
let dropped = cctop_core::fleet::unknown_harness(&host.target);
if dropped > 0 {
read.push_str(&format!(
" (+{dropped} from a harness this cctop does not know)"
));
}
let probe = host.probe();
let theirs = match &probe {
cctop_core::fleet::Probe::Version(v) => format!(", cctop {v}"),
_ => String::new(),
};
let local = cctop_core::update::current_version();
match cctop_core::fleet::skew(&probe, local) {
Some(cctop_core::fleet::Skew::Older(_)) => warn(
host.target.clone(),
format!("{read}{theirs}, older than this {local}"),
"update it there with `cctop --update`, or from a remote row's menu",
),
Some(cctop_core::fleet::Skew::Newer(_)) => warn(
host.target.clone(),
format!("{read}{theirs}, newer than this {local}"),
"this machine's cctop is behind: run `cctop --update` here",
),
_ if dropped > 0 => warn(
host.target.clone(),
format!("{read}{theirs}"),
"this machine's cctop does not know a harness that one runs: \
run `cctop --update` here",
),
_ => ok(host.target.clone(), format!("{read}{theirs}")),
}
}
cctop_core::fleet::Snapshot::Failed(why) => {
fail(host.target.clone(), why.clone(), remote_hint(&why))
}
})
.collect();
Section {
title: "Remote hosts",
checks,
}
}
fn remote_hint(why: &str) -> &'static str {
let lower = why.to_ascii_lowercase();
if lower.contains("not found") || lower.contains("no such file") {
return "a non-interactive ssh gets a different PATH than a login shell; \
name the binary with --host host:/path/to/cctop";
}
if lower.contains("permission denied") || lower.contains("publickey") {
return "ssh runs with BatchMode=yes, so a key that needs a passphrase \
cannot be used; add it to an agent or use one without";
}
if lower.contains("resolve") || lower.contains("timed out") || lower.contains("refused") {
return "cctop passes the target to ssh unchanged — if `ssh <host>` does not \
work by hand, it will not work here either";
}
if lower.contains("host key") || lower.contains("known_hosts") {
return "BatchMode=yes will not accept a new host key; \
ssh to it once by hand first";
}
"run the same command by hand to see it in full: ssh <host> cctop --json"
}
fn cache_age_secs(path: &Path) -> Option<u64> {
let modified = std::fs::metadata(path).ok()?.modified().ok()?;
Some(modified.elapsed().ok()?.as_secs())
}
fn writable(dir: &Path) -> Result<(), String> {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
let probe = dir.join(format!(".cctop-doctor-{}", std::process::id()));
std::fs::write(&probe, b"").map_err(|e| e.to_string())?;
std::fs::remove_file(&probe).map_err(|e| e.to_string())
}
fn dir_size(dir: &Path) -> u64 {
let Ok(entries) = std::fs::read_dir(dir) else {
return 0;
};
entries
.flatten()
.filter_map(|e| e.metadata().ok())
.filter(|m| m.is_file())
.map(|m| m.len())
.sum()
}
fn alias_installed() -> bool {
let home = &*config::HOME;
let fish = home.join(".config/fish/conf.d/cctop.fish");
if fish.is_file() {
return true;
}
[".zshrc", ".bashrc"].iter().any(|name| {
std::fs::read_to_string(home.join(name)).is_ok_and(|t| t.contains("# >>> cctop >>>"))
})
}
fn is_root() -> bool {
unsafe { libc::geteuid() == 0 }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_a_real_problem_is_worth_a_non_zero_exit() {
assert!(Level::Fail > Level::Warn);
assert!(Level::Warn > Level::Ok);
}
#[test]
fn each_ssh_failure_gets_the_fix_that_matches_it() {
let hint = |why: &str| remote_hint(why);
assert!(hint("bash: cctop: command not found").contains("PATH"));
assert!(hint("Permission denied (publickey).").contains("passphrase"));
assert!(
hint("ssh: Could not resolve hostname box: Name or service not known")
.contains("by hand")
);
assert!(hint("Host key verification failed.").contains("host key"));
assert!(hint("something new and strange").contains("ssh <host> cctop --json"));
}
#[test]
fn a_missing_file_has_no_age_and_a_written_one_does() {
let dir = std::env::temp_dir().join(format!("cctop-doctor-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("scratch");
assert!(cache_age_secs(&dir.join("absent")).is_none());
let file = dir.join("present");
std::fs::write(&file, b"x").expect("write");
assert!(cache_age_secs(&file).is_some());
assert!(writable(&dir).is_ok());
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1);
assert_eq!(dir_size(&dir), 1);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn an_unwritable_directory_is_reported() {
let file = std::env::temp_dir().join(format!("cctop-doctor-file-{}", std::process::id()));
std::fs::write(&file, b"x").expect("write");
assert!(writable(&file.join("under-a-file")).is_err());
std::fs::remove_file(&file).ok();
}
}