mod cli;
mod doctor;
mod mcp;
mod recall;
mod wait;
mod why;
use cctop_core::{
alias, attach, burn, cache, elog, embed, hook, insight, loader, pricing, quota, sandbox,
settings, shim, trace, update,
};
use cctop_serve as serve;
use cctop_ui as ui;
use clap::Parser;
use std::io::IsTerminal;
#[global_allocator]
static ALLOCATOR: mimalloc::MiMalloc = mimalloc::MiMalloc;
fn main() -> anyhow::Result<()> {
cctop_core::running_as_the_binary();
if std::env::args_os().nth(1).as_deref() == Some(std::ffi::OsStr::new("hook")) {
std::panic::set_hook(Box::new(|_| std::process::exit(0)));
let argv: Vec<String> = std::env::args().collect();
std::process::exit(hook::emit(&argv[2..]));
}
{
let mut argv = std::env::args_os().skip(1);
let first = argv.next();
let rest = || {
argv.map(|a| a.to_string_lossy().into_owned())
.collect::<Vec<_>>()
};
match first.as_deref().and_then(std::ffi::OsStr::to_str) {
Some("--sandbox-exec") => std::process::exit(sandbox::exec(&rest())),
Some("--sandbox-guard") => std::process::exit(sandbox::guard(&rest())),
Some("--sandbox-shell") => std::process::exit(sandbox::shell(&rest())),
_ => {}
}
}
{
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let payload = info.payload();
let message = payload
.downcast_ref::<String>()
.map(String::as_str)
.or_else(|| payload.downcast_ref::<&str>().copied());
let closed = message.is_some_and(|m| {
m.starts_with("failed printing to stdout") && m.contains("Broken pipe")
});
if closed {
std::process::exit(0);
}
previous(info);
}));
}
{
let argv: Vec<String> = std::env::args().collect();
if argv.get(1).map(String::as_str) == Some("doctor") {
std::process::exit(doctor::run(&argv[2..]));
}
}
{
let argv: Vec<String> = std::env::args().collect();
if argv.get(1).map(String::as_str) == Some("why") {
std::process::exit(why::run(&argv[2..]));
}
}
{
let argv: Vec<String> = std::env::args().collect();
if argv.get(1).map(String::as_str) == Some("burn") {
std::process::exit(burn::run(&argv[2..]));
}
}
{
let argv: Vec<String> = std::env::args().collect();
if argv.get(1).map(String::as_str) == Some("log") {
std::process::exit(elog::run(&argv[2..]));
}
}
{
let argv: Vec<String> = std::env::args().collect();
if let Some(word @ ("optimize" | "compare" | "yield")) = argv.get(1).map(String::as_str) {
std::process::exit(insight::run(word, &argv[2..]));
}
}
{
let argv: Vec<String> = std::env::args().collect();
if argv.get(1).map(String::as_str) == Some("recall") {
std::process::exit(recall::run(&argv[2..]));
}
}
{
let argv: Vec<String> = std::env::args().collect();
if argv.get(1).map(String::as_str) == Some("wait") {
let args = cli::WaitArgs::parse_from(
std::iter::once("cctop wait".to_string()).chain(argv[2..].iter().cloned()),
);
std::process::exit(wait::run(&args));
}
}
{
let argv: Vec<String> = std::env::args().collect();
if argv.get(1).map(String::as_str) == Some("as") {
std::process::exit(quota::run_as(&argv[2..])?);
}
}
{
let argv: Vec<String> = std::env::args().collect();
if argv.get(1).map(String::as_str) == Some("sandbox") {
std::process::exit(sandbox::run_held(&argv[2..])?);
}
}
{
let argv: Vec<String> = std::env::args().collect();
if argv.get(1).map(String::as_str) == Some("serve") {
std::process::exit(serve::run(&argv[2..])?);
}
}
let mut agent: Option<Vec<String>> = None;
{
let argv: Vec<String> = std::env::args().collect();
if argv.get(1).map(String::as_str) == Some("attach") {
std::process::exit(attach::run_terminal(&argv[2..])?);
}
match argv.get(1).map(String::as_str) {
Some("run") => std::process::exit(shim::run(&argv[2..])?),
Some(word) if !word.starts_with('-') && shim::is_command(word) => {
agent = Some(argv[1..].to_vec());
}
_ => {}
}
}
let args = match agent {
Some(_) => cli::Args::parse_from(["cctop"]),
None => cli::Args::parse(),
};
if args.trace.is_some() {
trace::enable();
}
if let Some(staged) = &args.install_update {
return update::install_staged(&staged[0], &staged[1]);
}
if args.update {
return update::run(false);
}
if args.fetch_search_model {
embed::fetch::fetch()?;
return Ok(());
}
if args.hooks_status {
let cwd = std::env::current_dir().ok();
for (line, problem) in hook::status(cwd.as_deref(), None).lines() {
eprintln!("{} {line}", if problem { "!" } else { "·" });
}
return Ok(());
}
if let Some(scope) = args
.install_hooks
.as_deref()
.or(args.remove_hooks.as_deref())
{
let installing = args.install_hooks.is_some();
let cwd = std::env::current_dir().unwrap_or_default();
let Some(scope) = hook::Scope::parse(scope, &cwd) else {
anyhow::bail!("unknown scope '{scope}'; use `user` or `project`");
};
for line in match installing {
true => hook::install(&scope),
false => hook::remove(&scope),
} {
eprintln!("{line}");
}
eprintln!("Sessions already running keep their old hooks until restarted.");
return Ok(());
}
if args.install_alias || args.remove_alias {
let changed = if args.install_alias {
alias::install()
} else {
alias::remove()
};
match changed.as_slice() {
[] => eprintln!("No shell startup file needed changing."),
files => {
for f in files {
eprintln!("Updated {}", f.display());
}
eprintln!("Restart your shell, or source the file, to pick it up.");
}
}
return Ok(());
}
if let Some(profile) = &args.add_account {
return quota::add_account(profile);
}
if args.clear_cache && cache::clear_session_cache()? {
eprintln!("Cleared cctop session extraction cache.");
}
if let Some(scope) = args.install_mcp.as_deref() {
if !matches!(scope, "user" | "project") {
anyhow::bail!("unknown scope '{scope}'; use `user` or `project`");
}
for line in mcp::install(scope) {
eprintln!("{line}");
}
eprintln!("Agents already running pick it up when restarted.");
return Ok(());
}
if args.mcp {
return mcp::serve();
}
if let Some(which) = &args.handoff {
let mut loader = loader::Loader::new();
let sessions = loader.load(args.plan);
cli::run_handoff(&sessions, which, &loader)?;
loader.store().save();
finish_trace(&args);
return Ok(());
}
if !args.convert.is_empty() || args.converted {
let mut loader = loader::Loader::new();
let sessions = loader.load(args.plan);
if args.converted {
cli::run_converted(&sessions, args.remove)?;
} else {
let (which, agent) = args.convert.split_at(1);
cli::run_convert(&sessions, which[0].as_str(), agent[0].as_str())?;
}
loader.store().save();
finish_trace(&args);
return Ok(());
}
if args.list
|| args.json
|| args.statusline
|| args.report.is_some()
|| args.chat.is_some()
|| args.export.is_some()
|| args.access.is_some()
{
pricing::refresh_pricing_blocking();
let mut loader = loader::Loader::new();
let sessions = loader.load(args.plan);
if args.json {
cli::run_json(&sessions, args.plan, &loader)?;
} else if args.list {
cli::run_list(&sessions, args.plan);
} else if let Some(which) = &args.report {
cli::run_report(&sessions, which, args.plan, &loader)?;
} else if let Some(which) = &args.chat {
cli::run_chat(&sessions, which, args.before)?;
} else if let Some(which) = &args.export {
cli::run_export(&sessions, which, args.tool_output)?;
} else if let Some(which) = &args.access {
cli::run_access(&sessions, which, &loader)?;
} else {
cli::run_statusline(&sessions);
}
loader.store().save();
finish_trace(&args);
return Ok(());
}
if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
if let Some(agent) = agent {
std::process::exit(shim::run(&agent)?);
}
anyhow::bail!("the interactive UI needs a TTY; use --list or --json instead");
}
let launching_agent = agent.is_some();
let mut prefs = cache::UiPrefs::load();
let auto_update = !args.no_auto_update
&& !launching_agent
&& settings::Settings::load()
.auto_update
.unwrap_or(prefs.auto_update);
update::auto_at_startup(auto_update, &mut prefs);
if !launching_agent && std::env::var_os("CI").is_none() {
alias::ask_on_first_run(&mut prefs);
}
pricing::load_cached_pricing();
shim::answer_colours_from(|| ui::theme::variant() == ui::theme::Variant::Light);
let hosted = agent
.map(|agent| shim::host(&agent, None, ui::render::pane_size()))
.transpose()?;
let code = ui::run(
args.plan,
args.delay,
&args.hosts,
hosted,
serve_for_dashboard,
)?;
finish_trace(&args);
std::process::exit(code)
}
fn serve_for_dashboard(request: ui::ServeRequest) -> anyhow::Result<ui::Served> {
let serving = serve::start(serve::Options {
tunnel: request.tunnel,
plan: request.plan,
scan: false,
hosts: request.hosts,
..Default::default()
})?;
Ok(ui::Served {
local: serving.local.clone(),
public: serving.public.clone(),
readonly: serving.readonly.clone(),
actions: serving.actions,
publish: Box::new(move |sessions, quota| serving.publish_with_quota(sessions, quota)),
})
}
fn finish_trace(args: &cli::Args) {
let Some(requested) = args.trace.as_deref() else {
return;
};
let path = match requested {
"" => trace::default_path(),
given => std::path::PathBuf::from(given),
};
match trace::write_to(&path) {
Ok(()) => eprintln!("cctop: trace written to {}", path.display()),
Err(error) => eprintln!(
"cctop: could not write trace to {}: {error}",
path.display()
),
}
}
#[cfg(test)]
mod tests {
#[test]
fn the_dashboard_gets_a_real_servers_links() {
let served = super::serve_for_dashboard(cctop_ui::ServeRequest {
tunnel: false,
plan: cctop_core::pricing::Plan::Retail,
hosts: Vec::new(),
})
.expect("a loopback server");
assert!(
served.local.starts_with("http://127.0.0.1:"),
"{}",
served.local
);
let token = served.local.split_once("?t=").map(|(_, token)| token);
assert_eq!(
token.map(str::len),
Some(32),
"no token in {}",
served.local
);
assert!(served.public.is_none());
assert!(served.actions);
assert_eq!(served.best(), served.local);
}
}