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
mod alias;
mod attach;
mod cache;
mod cli;
mod config;
mod handoff;
mod hook;
mod inject;
mod loader;
mod mcp;
mod notify;
mod pricing;
mod proc;
mod quota;
mod session;
#[cfg(unix)]
mod shim;
// The tabs and their agents are unix-only, but the code that opens them is not
// cfg'd apart — so Windows gets the same surface with nothing behind it rather
// than a cfg on every call site. See `shim_stub` for the bargain.
#[cfg(not(unix))]
#[path = "shim_stub.rs"]
mod shim;
mod tmux;
mod ui;
mod update;
mod util;
mod watch;
use clap::Parser;
use std::io::IsTerminal;
fn main() -> anyhow::Result<()> {
// `cctop run <agent> …` is handled before clap so the agent's own flags are
// never mistaken for cctop's — `cctop claude --help` must reach claude.
//
// A first argument that names an executable is the same thing without the
// `run`: cctop takes no positionals, so a command is the only thing it can
// be. Anything else (a typo, a stray word) falls through to clap's usage
// error rather than being exec'd.
//
// The two forms differ in what surrounds the agent. `cctop run claude` is a
// transparent stand-in for `claude` and hands over the terminal; `cctop
// claude` starts cctop with the agent attached inside it, which is the point
// of launching it through cctop at all.
// `cctop hook` is spawned by the agent itself, many times a session. It is
// answered before anything else is set up — no config, no pricing, no cache
// — because the agent is blocked until it returns.
//
// Every platform, deliberately. Delivery is a no-op where there are no unix
// sockets, but the *exit code* is not: `--install-hooks` writes the agent's
// settings file on any platform, and a `cctop hook` that fell through to
// clap would exit non-zero, which Claude Code reads as a decision to block
// the tool call and feed stderr back to the model. Answering here is what
// keeps the guarantee the hook module is built around — see its docs.
if std::env::args().nth(1).as_deref() == Some("hook") {
let argv: Vec<String> = std::env::args().collect();
std::process::exit(hook::emit(&argv[2..]));
}
#[cfg(unix)]
let mut agent: Option<Vec<String>> = None;
#[cfg(unix)]
{
let argv: Vec<String> = std::env::args().collect();
// `cctop attach` puts a running agent on this terminal directly, with no
// UI around it. Handled here for the same reason as `run`: it takes a
// positional, and cctop otherwise has none.
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());
}
_ => {}
}
}
#[cfg(unix)]
// Everything after the agent's name belongs to the agent, so clap must not
// be shown it; the UI wrapped around it runs on its defaults.
let args = match agent {
Some(_) => cli::Args::parse_from(["cctop"]),
None => cli::Args::parse(),
};
#[cfg(not(unix))]
let args = cli::Args::parse();
if args.update {
return update::run(false);
}
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`");
};
// One line per harness, including the ones that could not be done:
// they are separate files, and a `notify` slot that already belongs to
// somebody else must not undo the four installs that succeeded.
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 args.clear_cache && cache::clear_session_cache()? {
eprintln!("Cleared cctop session extraction cache.");
}
if args.mcp {
// Nothing may be printed to stdout but JSON-RPC: the transport is the
// stream, and one stray line of logging desynchronises the client.
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();
return Ok(());
}
if args.list || args.json {
// Non-interactive modes need pricing before they can print anything, so
// fetch synchronously. The TUI refreshes it on a background thread.
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 {
cli::run_list(&sessions, args.plan);
}
loader.store().save();
return Ok(());
}
if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
// With no terminal there is no UI to wrap the agent in, but there is
// still an agent to run: `claude` is aliased to this, and a pipeline or
// a script must not be told to use --json instead.
#[cfg(unix)]
if let Some(agent) = agent {
std::process::exit(shim::run(&agent)?);
}
anyhow::bail!("the interactive UI needs a TTY; use --list or --json instead");
}
alias::install_once(&mut cache::UiPrefs::load());
// Load whatever pricing is already cached so the first frame isn't zeroed
// while the network fetch is still in flight.
pricing::load_cached_pricing();
// Started before the UI so a failure to launch prints as an ordinary error
// rather than from inside the alternate screen.
#[cfg(unix)]
let hosted = agent.map(|agent| shim::host(&agent, None)).transpose()?;
#[cfg(not(unix))]
let hosted = None;
std::process::exit(ui::run(&args, hosted)?)
}