// Swap in mimalloc on Windows — the default HeapAlloc is the biggest single
// contributor to per-keystroke render latency (hundreds of small Line/Span
// clones per frame). No-op on macOS/Linux where the system allocator is fine.
#[cfg(target_os = "windows")]
#[global_allocator]
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
use std::collections::HashMap;
use std::io::{self, Write};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
mod telemetry_cmd;
mod uninstall;
use atomcode_core::agent::{AgentCommand, AgentEvent, AgentLoop, AgentRuntimeFactory};
use atomcode_core::config::provider::{default_context_window_for, ProviderConfig};
use atomcode_core::config::Config;
use atomcode_core::conversation::Conversation;
use atomcode_core::lsp::manager::build_lsp_manager;
use atomcode_core::mcp::{
load_mcp_config, login_mcp_oauth, merge_http_oauth_mcp_server_into_json_file,
merge_stdio_mcp_server_into_json_file, register_mcp_tools, McpHttpAuthConfig,
McpOAuthLoginOptions, McpRegistry, McpTokenStore, McpTransportConfig,
};
use atomcode_core::provider::{create_provider, unavailable_provider};
use atomcode_core::session::SessionManager;
use atomcode_core::tool::bash::BashTool;
use atomcode_core::tool::cd::CdTool;
use atomcode_core::tool::diagnostics::DiagnosticsTool;
use atomcode_core::tool::edit::EditFileTool;
use atomcode_core::tool::glob::GlobTool;
use atomcode_core::tool::grep::GrepTool;
use atomcode_core::tool::list_dir::ListDirTool;
use atomcode_core::tool::open_file::OpenFileTool;
use atomcode_core::tool::read::ReadFileTool;
use atomcode_core::tool::search_replace::SearchReplaceTool;
use atomcode_core::tool::web_fetch::WebFetchTool;
use atomcode_core::tool::web_search::WebSearchTool;
use atomcode_core::tool::write::WriteFileTool;
use atomcode_core::tool::{ToolContext, ToolRegistry};
use atomcode_core::auth;
use atomcode_telemetry::{
config::{resolve, ProcessEnv},
event::SessionMode,
notice, CliOverride, CurrentContext, Event, Telemetry,
};
/// Set to `true` at the start of `run_headless` so the panic hook and the
/// top-level error handler can skip TUI cleanup. In headless mode raw mode
/// was never enabled, so calling `disable_raw_mode` would be a wasted ioctl
/// and on Windows can panic if the console handle isn't a real TTY.
static HEADLESS_MODE: AtomicBool = AtomicBool::new(false);
/// Restore terminal state if (and only if) we ever entered TUI mode.
/// No-op in headless mode — see [`HEADLESS_MODE`].
///
/// TUI mode (v4.23.2+) runs entirely in the primary screen via the
/// append-only RetainedRenderer — we never emit `\x1b[?1049h`, so there
/// is no `LeaveAlternateScreen` counterpart to issue here. Mouse mode,
/// cursor visibility, autowrap and DECSTBM are restored by
/// `RetainedRenderer::Drop` / `shutdown()`; this hook only owns the
/// raw-mode toggle.
fn restore_terminal_if_tui() {
if HEADLESS_MODE.load(Ordering::Relaxed) {
return;
}
let _ = crossterm::terminal::disable_raw_mode();
}
/// Resolve the working directory at startup. **Always** uses the current
/// working directory unless the user explicitly passed `-C / --dir`.
///
/// We deliberately do **not** read `~/.atomcode/recent_dirs.txt` (or any other
/// "remembered" path). The previous implementation silently substituted the
/// first entry of recent_dirs for the user's cwd, which made commands like
/// `atomcode -p "describe this project"` operate on whatever directory the
/// TUI happened to visit last — a violation of least surprise. recent_dirs
/// remains a TUI picker convenience only; it must never override cwd.
fn resolve_working_dir(cli_dir: Option<PathBuf>) -> PathBuf {
if let Some(d) = cli_dir {
std::fs::canonicalize(&d).unwrap_or(d)
} else {
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
}
}
/// Truncate a string to at most `max_chars` *characters* (not bytes), replacing
/// any newlines with spaces and appending "..." when truncated.
///
/// Used for headless-mode log lines on stderr. **Counts characters, not bytes**,
/// so multi-byte UTF-8 (e.g. CJK) is safe — `&s[..N]` would panic when N falls
/// inside a multi-byte char.
fn truncate_log_line(s: &str, max_chars: usize) -> String {
let single_line: String = s.chars().map(|c| if c == '\n' { ' ' } else { c }).collect();
if single_line.chars().count() > max_chars {
let head: String = single_line.chars().take(max_chars).collect();
format!("{}...", head)
} else {
single_line
}
}
/// Append a streaming reasoning/thinking `chunk` to `out`, maintaining a
/// single-line `[thinking] ...` representation across many tiny deltas.
///
/// `open` tracks whether a `[thinking]` line is currently open (i.e. has a
/// prefix written but no trailing newline). The first chunk gets a fresh
/// `[thinking] ` prefix; subsequent chunks append directly. Embedded newlines
/// inside a chunk are preserved, with each non-empty new line getting its own
/// `[thinking] ` prefix so multi-line thinking stays readable.
///
/// Pulled out of `run_headless` so it can be unit-tested without spinning up
/// the agent loop. Regression target: the old per-chunk `eprintln!` produced
/// "one word per line" output for streaming reasoning models.
fn format_thinking_chunk(out: &mut String, open: &mut bool, chunk: &str) {
if chunk.is_empty() {
return;
}
if !*open {
out.push_str("[thinking] ");
*open = true;
}
let mut parts = chunk.split('\n');
if let Some(first) = parts.next() {
out.push_str(first);
}
for part in parts {
out.push('\n');
*open = false;
if !part.is_empty() {
out.push_str("[thinking] ");
out.push_str(part);
*open = true;
}
}
}
/// Close any in-flight `[thinking]` line by writing a newline if one is open.
/// Mirrors the inline `close_thinking_line` used inside `run_headless`, but
/// writes to a buffer so it can be unit-tested.
fn close_thinking_chunk(out: &mut String, open: &mut bool) {
if *open {
out.push('\n');
*open = false;
}
}
/// True if `--dev` is present in argv. Used to skip every auto-update
/// path (pre-parse `apply_pending_upgrade`, sync stage+apply, and the
/// post-parse detached stager). Scanned manually because two of those
/// paths run before clap touches argv. The flag is also declared on
/// `Cli` so `clap::Parser` accepts it without erroring after the early
/// scan.
fn is_dev_mode() -> bool {
std::env::args().skip(1).any(|a| a == "--dev")
}
/// True when the currently-running binary's filename ends in `.bak`.
/// `self_update::replace_binary` renames the previous version to
/// `atomcode.bak` (or `atomcode.exe.bak`) during an upgrade so the user
/// can roll back. Running that backup must NOT auto-upgrade — otherwise
/// rolling back is impossible: any launch of `.bak` would just overwrite
/// itself with the latest version again.
///
/// Defensive: if we can't read `current_exe()` for any reason, assume
/// we're the live binary (not backup) so auto-upgrade still works for
/// the common case.
fn is_running_as_backup() -> bool {
std::env::current_exe()
.ok()
.as_deref()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
.map(|n| n.ends_with(".bak"))
.unwrap_or(false)
}
/// Decide whether the startup-time synchronous upgrade path should fire.
/// Returns false when any of these hold:
/// * We're running as `atomcode.bak` → user wants the old binary,
/// don't silently swap it back to latest.
/// * `-p` / `--prompt` / `--prompt-file` is in argv → headless script run,
/// shouldn't stall 5-20 s on a network download for a 2 s task.
/// * A subcommand (login, logout, status, upgrade, rollback, mcp) is in argv
/// → those have their own flows and don't want a surprise re-exec.
/// * Config has `auto_update = false` → user explicitly opted out.
/// Anything else (including missing config) → true, because fresh installs
/// that haven't written a config yet are exactly the case we want to help.
///
/// Deliberately scans argv by hand — clap hasn't parsed yet at this point
/// in main(), and we need to decide before any slower setup happens.
fn should_try_sync_upgrade() -> bool {
if is_running_as_backup() {
return false;
}
if is_dev_mode() {
return false;
}
let args: Vec<String> = std::env::args().collect();
let any = |needle: &[&str]| {
args.iter().skip(1).any(|a| {
needle
.iter()
.any(|n| a == n || a.starts_with(&format!("{}=", n)))
})
};
if any(&["-p", "--prompt", "--prompt-file"]) {
return false;
}
if args.iter().skip(1).any(|a| {
matches!(
a.as_str(),
"login"
| "logout"
| "status"
| "upgrade"
| "rollback"
| "uninstall"
| "mcp"
| "telemetry"
| "--version"
| "-V"
| "--help"
| "-h"
)
}) {
return false;
}
// Load just enough of the config to honor `auto_update = false`.
// Failure to load = assume default (true) — fresh installs benefit.
let path = atomcode_core::config::Config::default_path();
if path.exists() {
if let Ok(cfg) = atomcode_core::config::Config::load(&path) {
if !cfg.auto_update {
return false;
}
}
}
true
}
/// Startup-time synchronous upgrade. Fetches the manifest, and if a newer
/// release exists, downloads + verifies + stages + applies it in-line,
/// then re-execs into the new binary. Progress is printed to stderr so
/// the user sees something happen during the 5-20 s window (as opposed
/// to a silent hang). Anything that fails → fall through; the parent's
/// `main` continues with the current binary, and the detached worker
/// spawned later (`spawn_detached_upgrade_prep`) is still there as a
/// second chance for the next session.
///
/// Bounded by an overall 120 s timeout so a slow mirror / hung DNS can't
/// wedge startup forever.
async fn sync_stage_and_apply_if_newer() {
use atomcode_core::self_update::{self, UpgradeEvent};
let current = format!("v{}", env!("CARGO_PKG_VERSION"));
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<UpgradeEvent>();
// Progress consumer: renders ManifestFetched / Downloading / Verifying
// as a single-line updating status on stderr. Percent-debounced so a
// 15 MB download at 64 KiB chunks doesn't flood the terminal.
let progress = tokio::spawn(async move {
use std::io::Write;
let mut last_pct: i32 = -1;
while let Some(ev) = rx.recv().await {
match ev {
UpgradeEvent::ManifestFetched { version } => {
eprintln!("✨ New version available: {}", version);
}
UpgradeEvent::Downloading { bytes, total } => {
let pct = if total == 0 {
0
} else {
((bytes * 100) / total) as i32
};
if pct != last_pct {
eprint!(
"\r Downloading {}% ({:.1} / {:.1} MB) ",
pct,
bytes as f64 / 1_048_576.0,
total as f64 / 1_048_576.0
);
let _ = std::io::stderr().flush();
last_pct = pct;
}
}
UpgradeEvent::Verifying => {
eprintln!("\n✓ Verifying sha256");
}
_ => {}
}
}
});
let outcome = tokio::time::timeout(
std::time::Duration::from_secs(120),
self_update::prepare_deferred_upgrade(¤t, tx),
)
.await;
// Wait briefly for the progress consumer to drain — it closes when
// the sender drops at the end of prepare_deferred_upgrade.
let _ = progress.await;
match outcome {
Ok(Ok(Some(_staged))) => {
// Staged successfully. Apply right now so the user gets the new
// binary on this same invocation.
match self_update::apply_pending_upgrade() {
Ok(Some(applied)) => {
eprintln!("✓ Upgrading to {}...", applied.version);
// Save the CURRENT version (before upgrade) so TUI can show "Upgraded old → new"
std::env::set_var(UPGRADED_FROM_ENV, ¤t);
match self_update::re_exec_self(Some(&applied.exe)) {
Ok(_infallible) => unreachable!("re_exec_self returned Ok"),
Err(e) => {
eprintln!(
"Upgrade applied but re-exec failed ({}). The new version will be used on the next launch.",
e
);
std::env::remove_var(UPGRADED_FROM_ENV);
}
}
}
_ => {
// Stage succeeded but apply didn't — weird, just continue.
}
}
}
Ok(Ok(None)) => {
// Already latest, no-op.
}
Ok(Err(_)) | Err(_) => {
// Network error or 120 s timeout. Don't spam the user —
// `/upgrade` will surface the real error if they ask.
eprintln!("Note: could not check for updates at startup (will retry in background).");
}
}
}
/// Body of the detached upgrade-prep worker. One call to
/// `prepare_deferred_upgrade` (which fetches the manifest, downloads the
/// next version's binary if newer, verifies sha256, and writes
/// `pending.json`). On success the next parent-atomcode start will pick
/// up `pending.json` and apply. Silent: stdout/stderr are already /dev/null
/// (see `spawn_detached_upgrade_prep`), so any output would be discarded.
async fn run_prepare_upgrade_worker() -> i32 {
let current = format!("v{}", env!("CARGO_PKG_VERSION"));
// UpgradeEvent stream is per-byte progress; we don't surface it here
// (parent is gone), so drain to /dev/null.
let (tx, mut rx) =
tokio::sync::mpsc::unbounded_channel::<atomcode_core::self_update::UpgradeEvent>();
tokio::spawn(async move { while rx.recv().await.is_some() {} });
match atomcode_core::self_update::prepare_deferred_upgrade(¤t, tx).await {
Ok(_) => 0,
Err(_) => 1,
}
}
/// Spawn a detached copy of this binary that runs the upgrade-prep worker
/// and exits. "Detached" means:
/// * New session on Unix (`setsid`) — parent's Ctrl+C goes to parent's
/// foreground process group only; the child is in its own and ignores it.
/// * `CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW` on Windows, same idea
/// * stdin/stdout/stderr → /dev/null so the child can't scribble over the
/// parent's terminal and has no reason to stay attached to it.
///
/// Does NOT wait for the child (we intentionally don't — that would recreate
/// the cancel-on-exit problem we're trying to solve). If spawning fails we
/// just drop the error; auto-upgrade is best-effort.
fn spawn_detached_upgrade_prep() {
let exe = match std::env::current_exe() {
Ok(p) => p,
Err(_) => return,
};
let mut cmd = std::process::Command::new(&exe);
cmd.env(INTERNAL_PREPARE_UPGRADE_ENV, "1")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
unsafe {
cmd.pre_exec(|| {
// Detach from parent's controlling terminal / process group.
// Return value ignored — setsid only fails when caller is
// already a process group leader (not our case post-fork).
libc::setsid();
Ok(())
});
}
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
const CREATE_NO_WINDOW: u32 = 0x08000000;
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW);
}
let _ = cmd.spawn();
}
const VERSION: &str = concat!(
env!("CARGO_PKG_VERSION"),
" (",
env!("ATOMCODE_BUILD_ID"),
env!("ATOMCODE_BUILD_DIRTY"),
")"
);
#[derive(Parser)]
#[command(name = "atomcode", version = VERSION, about = "AI coding assistant in your terminal")]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
/// Continue the previous session instead of starting a new one
#[arg(short = 'c', long = "continue")]
continue_last: bool,
/// Provider to use (overrides config default)
#[arg(long)]
provider: Option<String>,
/// Model to use (overrides config provider model)
#[arg(long)]
model: Option<String>,
/// Set interface language (e.g. en, zh-CN, zh)
#[arg(long)]
lang: Option<String>,
/// Path to config file
#[arg(long)]
config: Option<PathBuf>,
/// Working directory (defaults to current directory)
#[arg(long, short = 'C')]
dir: Option<PathBuf>,
/// Prompt to run in headless (non-interactive) mode. If omitted, launches the TUI.
#[arg(short = 'p', long)]
prompt: Option<String>,
/// Read the prompt from a file (alternative to -p). Useful for long prompts
/// that would exceed ARG_MAX or whose trailing newlines matter.
#[arg(long, value_name = "PATH", conflicts_with = "prompt")]
prompt_file: Option<std::path::PathBuf>,
/// Show tool calls, token usage, and turn summary on stderr (headless mode only).
/// Without this flag, headless output is the assistant reply only — Claude Code -p style.
#[arg(short = 'v', long)]
verbose: bool,
/// Maximum number of LLM turns before the agent loop is force-stopped.
/// Bounds context accumulation on long-running tasks (e.g. SWE-bench eval).
/// Default: unbounded — the agent stops naturally when the model returns
/// no tool calls or when the step budget (tool-call cap) is reached.
#[arg(long)]
max_turns: Option<usize>,
/// Disable auto-update for this launch. Skips applying any staged
/// upgrade, skips the sync stage+apply on startup, and skips the
/// detached background stager. Use during local development so a
/// fresh `cargo run` build isn't silently overwritten by the
/// released binary.
#[arg(long)]
dev: bool,
/// Comma-separated list of tool names to exclude from the registry.
/// Use this to disable tools that are useless or harmful in a particular
/// environment — e.g. `--disable-tools bash,web_fetch` for SWE-bench eval
/// where the sandbox can't run commands and offline mode is required.
/// Tools the LLM tries to call after disabling will be invisible to it
/// (they won't appear in the schemas list at all), so the model will not
/// retry against a permanently-blocked tool.
#[arg(long, value_delimiter = ',', value_name = "NAMES")]
disable_tools: Vec<String>,
/// Disable telemetry for this invocation.
#[arg(long = "no-telemetry", default_value_t = false, global = true)]
pub no_telemetry: bool,
/// Skip all permission prompts — auto-approve every tool call (bash,
/// file edits, MCP, etc.). Equivalent to Claude Code's
/// --dangerously-skip-permissions. The TUI shows a red ⚠ BYPASS
/// badge while active. Use in CI/CD, eval harnesses, or when you
/// trust the agent's built-in safety constraints.
#[arg(short = 'y', long = "dangerously-skip-permissions", default_value_t = false)]
pub dangerously_skip_permissions: bool,
}
#[derive(Subcommand)]
enum Commands {
/// Sign in with AtomGit OAuth and claim CodingPlan models in one
/// flow: OAuth (if needed) → claim → fetch models → register
/// providers → fetch status. Reports each step and exits.
Login,
/// Logout from AtomCode
Logout,
/// Show current login status
Status,
/// Upgrade atomcode in-place to the latest released version
Upgrade {
/// Reinstall even when already on the latest version
#[arg(long)]
force: bool,
},
/// Roll back to the previous version (swap with .bak on disk)
Rollback,
/// Fetch an AtomGit issue assigned to you and let the agent fix it
/// in the current project (no commit, no push — edits local files only).
Fixissue {
/// Full issue URL, e.g. https://atomgit.com/owner/repo/issues/42
url: String,
},
/// Hidden alias for `atomcode login` — kept so existing scripts /
/// muscle memory don't break after `/codingplan` and `atomcode
/// codingplan` were folded into the unified `/login` flow.
#[command(hide = true)]
Codingplan,
/// Manage MCP server entries in `.mcp.json` (similar to `claude mcp add`)
#[command(subcommand)]
Mcp(McpCli),
/// Start the HTTP daemon for IDE integration (VS Code extension connects to this)
Daemon {
/// Port to listen on (default: 13456)
#[arg(long, default_value = "13456")]
port: u16,
/// Client identifier for telemetry (e.g. "vscode", "atomcode-air")
#[arg(long)]
client: Option<String>,
},
/// 启动本地浏览器 webui(进程内起 server,无需额外二进制)
Webui {
/// 端口(默认 13457,刻意错开 VSCode 守护进程的 13456,避免抢端口导致扩展 401/无响应)
#[arg(long, default_value_t = atomcode_daemon::WEBUI_DEFAULT_PORT)]
port: u16,
/// 绑定地址(默认 127.0.0.1;用 0.0.0.0 暴露到局域网/外网,注意仅 token 保护、无 TLS)
#[arg(long, default_value = "127.0.0.1")]
host: String,
},
/// Telemetry controls
Telemetry {
#[command(subcommand)]
action: TelemetryAction,
},
/// Manage skill/command plugins (mirrors `claude plugin ...`).
/// Operates on `$ATOMCODE_HOME/plugins/` shared with the TUI's `/plugin`
/// slash command — anything installed via either path is visible to both.
#[command(subcommand)]
Plugin(PluginCli),
/// Uninstall AtomCode: remove the binary, PATH edit, and (interactively)
/// data under ~/.atomcode/. With no flags, runs interactively and asks
/// per-group; pass --yes / --purge / --keep-data for non-interactive use.
Uninstall {
/// Skip prompts; use per-group default decisions
/// (binary=yes, credentials=no, state=yes).
#[arg(long)]
yes: bool,
/// Wipe ~/.atomcode/ entirely.
#[arg(long, conflicts_with = "keep_data")]
purge: bool,
/// Keep ~/.atomcode/ entirely (only remove binary + PATH edit).
#[arg(long)]
keep_data: bool,
/// Print the plan; do nothing.
#[arg(long)]
dry_run: bool,
},
/// Install seed files (skills/commands/hooks/MCP) to `~/.atomcode/`.
Setup {
/// Take over a stale lock AND force reinstall even if seeds are already present.
#[arg(long)]
force: bool,
},
/// Manage hooks (list, test, enable/disable)
#[command(subcommand)]
Hooks(HookCommands),
}
/// Subcommands for hooks management
#[derive(Subcommand)]
enum HookCommands {
/// List all loaded hooks with their status
List,
/// Test a specific hook by name
Test {
/// Hook name to test
name: String,
},
/// Show hook configuration paths
Paths,
}
#[derive(Subcommand)]
enum PluginCli {
/// Marketplace registry operations (add/remove/update/list).
#[command(subcommand)]
Marketplace(MarketplaceCli),
/// Install a plugin from a registered marketplace.
/// Spec format: `<plugin>@<marketplace>` (matches the slash command).
Install {
/// e.g. `ascend-model-agent-plugin@ascend-model-agent-plugin`
spec: String,
},
/// Uninstall a previously-installed plugin (does not touch its marketplace).
Uninstall {
/// e.g. `ascend-model-agent-plugin@ascend-model-agent-plugin`
spec: String,
},
/// List installed plugins.
List,
}
#[derive(Subcommand)]
enum MarketplaceCli {
/// Clone a marketplace git repo and register it locally.
Add {
/// Git URL (https or ssh) of a marketplace repo.
url: String,
},
/// Drop a registered marketplace. Refuses if any plugin still installed.
Remove {
/// Marketplace name (the key shown by `marketplace list`).
name: String,
},
/// Re-pull a registered marketplace and refresh its plugin index.
Update { name: String },
/// List registered marketplaces.
List,
}
#[derive(Subcommand)]
enum McpCli {
/// Add or replace a stdio MCP server (`mcpServers.<name>` with `command` + `args`)
Add {
/// Server key (tools appear as `mcp__<name>__…`)
name: String,
/// Executable and arguments, e.g. `npx @playwright/mcp@latest`
#[arg(required = true, num_args = 1..)]
command: Vec<String>,
/// Write `~/.atomcode/mcp.json` instead of `<dir>/.mcp.json`
#[arg(long)]
global: bool,
/// Directory for project `.mcp.json` (defaults to current directory)
#[arg(short = 'C', long)]
dir: Option<PathBuf>,
},
/// Add GitHub's remote MCP server using OAuth.
AddGithubOauth {
/// Server key (tools appear as `mcp__<name>__…`)
#[arg(default_value = "github")]
name: String,
/// Write `~/.atomcode/mcp.json` instead of `<dir>/.mcp.json`
#[arg(long)]
global: bool,
/// Directory for project `.mcp.json` (defaults to current directory)
#[arg(short = 'C', long)]
dir: Option<PathBuf>,
},
/// Complete OAuth login for a remote MCP server.
Login {
/// Server key in mcpServers (for GitHub, usually `github`)
name: String,
/// OAuth provider to use.
#[arg(long, default_value = "github")]
provider: String,
/// OAuth client id. Defaults to ATOMCODE_GITHUB_MCP_CLIENT_ID.
#[arg(long)]
client_id: Option<String>,
/// Environment variable containing the OAuth client secret.
#[arg(long)]
client_secret_env: Option<String>,
/// OAuth scopes. Defaults to GitHub MCP's broad repo-oriented set.
#[arg(long, value_delimiter = ',')]
scopes: Vec<String>,
},
/// Remove saved OAuth credentials for a remote MCP server.
Logout {
/// Server key in mcpServers.
name: String,
},
}
#[derive(clap::Subcommand)]
pub enum TelemetryAction {
/// Show current telemetry state and queue stats
Status,
/// Enable telemetry (writes to ~/.atomcode/config.toml)
Enable,
/// Disable telemetry (writes to ~/.atomcode/config.toml)
Disable,
/// Print pending queued events (never-sent)
Dump {
#[arg(long, default_value_t = 50)]
last: usize,
#[arg(long)]
pretty: bool,
},
/// Clear queued events (does not change enabled state)
Clear,
}
/// Environment variable set by this process for its re-exec'd child, so
/// the child knows which version it was just upgraded from and can show
/// a one-time "✓ Upgraded to vX.Y.Z" banner on the welcome screen.
/// The child clears this env var after reading it so grandchildren
/// (spawned tools, subprocesses) don't inherit a stale hint.
const UPGRADED_FROM_ENV: &str = "ATOMCODE_UPGRADED_FROM";
/// Env var the parent sets when spawning a detached upgrade-prep worker.
/// The child detects it at the very top of `main` and runs one
/// `prepare_deferred_upgrade` cycle in its own session (setsid'd) so the
/// parent can be Ctrl+C'd without cancelling the download.
const INTERNAL_PREPARE_UPGRADE_ENV: &str = "ATOMCODE_INTERNAL_PREPARE_UPGRADE";
#[tokio::main]
async fn main() {
// Set Windows console to UTF-8 so CJK and other multi-byte characters
// render correctly instead of showing garbled output (mojibake).
#[cfg(target_os = "windows")]
{
use windows_sys::Win32::Globalization::CP_UTF8;
use windows_sys::Win32::System::Console::{SetConsoleCP, SetConsoleOutputCP};
unsafe {
SetConsoleOutputCP(CP_UTF8);
SetConsoleCP(CP_UTF8);
}
}
// Detached upgrade-prep worker mode. The parent atomcode spawns a
// subprocess with this env var set; that subprocess does one full
// download + verify + `pending.json` write, then exits. Because the
// subprocess is setsid'd (see `spawn_detached_upgrade_prep`), it
// survives Ctrl+C / quit in the parent — which is the whole point,
// since the previous in-process download was tied to the parent's
// tokio runtime and got cancelled on any quick exit.
if std::env::var(INTERNAL_PREPARE_UPGRADE_ENV).is_ok() {
let code = run_prepare_upgrade_worker().await;
std::process::exit(code);
}
// If this invocation is the `.bak` backup binary (left behind by a
// previous upgrade), skip all upgrade bootstrapping. `apply_pending_upgrade`
// would rewrite ourselves with the latest version and destroy the
// rollback target; the whole point of keeping `.bak` is for the user
// to be able to run / keep the old version. The only upgrade path
// still reachable from a `.bak` launch is the explicit `/upgrade`
// slash command inside the TUI — that's user-initiated and fine.
let is_backup = is_running_as_backup();
let dev_mode = is_dev_mode();
if dev_mode {
eprintln!("[dev] auto-update disabled");
}
// Bootstrap: if a prior session staged an upgrade, apply it NOW — before
// we spin up tokio, the TUI, or any other heavy state. On success we
// re-exec the new binary (Unix: same PID; Windows: child+exit). The user
// sees one continuous "atomcode" invocation, just 100-300ms longer than
// normal. On failure we log and carry on with the current binary; the
// circuit-breaker in `apply_pending_upgrade` ensures a broken release
// can't wedge this loop indefinitely.
if !is_backup && !dev_mode {
// Capture current version BEFORE applying upgrade, so we can pass it to the re-exec'd child
let current_version = format!("v{}", env!("CARGO_PKG_VERSION"));
match atomcode_core::self_update::apply_pending_upgrade() {
Ok(Some(applied)) => {
eprintln!("✓ Upgrading to {}...", applied.version);
// Pass the CURRENT version (before upgrade) to the re-exec'd child so the TUI
// can surface a welcome-screen confirmation exactly once.
std::env::set_var(UPGRADED_FROM_ENV, ¤t_version);
match atomcode_core::self_update::re_exec_self(Some(&applied.exe)) {
Ok(_infallible) => unreachable!("re_exec_self returned Ok"),
Err(e) => {
eprintln!(
"Upgrade applied but re-exec failed ({}). The new version will be used on the next launch.",
e
);
std::env::remove_var(UPGRADED_FROM_ENV);
std::process::exit(1);
}
}
}
Ok(None) => {
// No pre-staged upgrade. If the user isn't passing `-p` /
// `--prompt-file` (headless one-shots shouldn't pay the network
// tax) and auto_update isn't disabled, try to fetch + stage +
// apply v_next right here. This is the "user launched atomcode,
// wants it upgraded NOW" path — single invocation instead of
// the stage-on-session-N / apply-on-session-N+1 dance.
//
// Anything goes wrong (offline, timeout, sha mismatch, no
// newer release) → silently fall through and continue with
// the current binary. The `/upgrade` slash command is still
// there as the explicit/loud alternative.
if should_try_sync_upgrade() {
sync_stage_and_apply_if_newer().await;
}
}
Err(e) => {
eprintln!("Note: pending upgrade could not be applied ({}). Continuing with current version.", e);
}
}
} // end `if !is_backup`
// Set a minimal pre-telemetry panic hook (replaced after telemetry init in run()).
std::panic::set_hook(Box::new(|info| {
write_crash_log(info);
restore_terminal_if_tui();
eprintln!("\nAtomCode crashed: {}", info);
if let Some(location) = info.location() {
eprintln!(
" at {}:{}:{}",
location.file(),
location.line(),
location.column()
);
}
eprintln!("\nPlease report this at: https://atomgit.com/atomgit_atomcode/atomcode/issues");
}));
match run().await {
Ok(code) => std::process::exit(code),
Err(e) => {
restore_terminal_if_tui();
eprintln!("\nAtomCode error: {:#}", e);
std::process::exit(1);
}
}
}
async fn run() -> Result<i32> {
let cli = Cli::parse();
// ── Telemetry init ────────────────────────────────────────────────────────
// Load config early (before subcommand dispatch) so we can read the
// [telemetry] section. Failure to load config is non-fatal; telemetry
// will operate on defaults (enabled, built-in endpoint).
let config_path_for_tel = cli.config.clone().unwrap_or_else(Config::default_path);
let telemetry_cfg = if config_path_for_tel.exists() {
Config::load(&config_path_for_tel)
.map(|c| c.telemetry)
.unwrap_or_default()
} else {
Default::default()
};
let atomcode_dir = Config::config_dir();
let cli_override = CliOverride {
disabled: cli.no_telemetry,
};
let resolved = resolve(
&telemetry_cfg,
&cli_override,
atomcode_dir.clone(),
&ProcessEnv,
);
// First-run notice: only show when telemetry would be active.
if resolved.state.is_enabled() {
if let Ok(true) = notice::should_show_and_mark(&resolved.atomcode_dir) {
eprintln!("{}", notice::NOTICE_TEXT);
}
}
let telemetry = Telemetry::init(resolved.clone(), env!("CARGO_PKG_VERSION").into());
install_panic_hook(telemetry.clone());
// Emit install_completed if this is the first launch after a referral install
telemetry
.maybe_emit_install_completed(&resolved.atomcode_dir)
.await;
// ── End telemetry init ────────────────────────────────────────────────────
// Handle subcommands. Most are self-contained (`handle_command` runs
// and exits); `Login` (and its hidden alias `Codingplan`) run the
// full OAuth + CodingPlan setup flow and then fall through to the
// TUI. `Fixissue` is like headless `-p` but with the prompt
// synthesised from a remote issue payload — so we resolve it here
// and hand it to the agent loop via `fixissue_prompt` below.
let mut fixissue_prompt: Option<String> = None;
// Saved when fixissue is parsed, so after the agent finishes we can
// POST the summary back to AtomGit as a comment + add the `fixed`
// label. `None` = not a fixissue run, skip the post-back step.
let mut fixissue_ref: Option<atomcode_core::atomgit::IssueRef> = None;
// `fixissue` is an interactive-feeling structured workflow (the user
// is watching progress, not piping output). Force verbose so they see
// tool calls / edits instead of long silences while the agent works.
let mut force_verbose = false;
if let Some(cmd) = cli.command {
match cmd {
Commands::Login | Commands::Codingplan => {
// Unified login flow: OAuth (if needed) → claim → fetch
// models → register providers → fetch status. Falls
// through to TUI startup regardless of outcome. On
// success the freshly saved config.toml is picked up by
// `Config::load` further down. On failure the TUI opens
// in onboarding mode (no providers) so the user can
// retry via `/login` without re-launching the binary.
// Emits open_atomcode (mode=headless) then take_codingplan
// (emitted internally by run_codingplan_core via coding_plan::run).
HEADLESS_MODE.store(true, Ordering::Relaxed);
let repo = atomcode_core::telemetry_bootstrap::detect_repo_origin(
&std::env::current_dir().unwrap_or_default(),
);
telemetry.set_account_id(auth::get_stored_auth().map(|a| a.user.id.to_string()));
let scope_ctx = CurrentContext {
repo_origin: Some(repo),
mode: Some(SessionMode::Headless),
..CurrentContext::current()
};
let outcome = CurrentContext::scope(scope_ctx, || async {
telemetry.track(Event::OpenAtomcode {
dangerously_skip_permissions: cli.dangerously_skip_permissions,
});
run_codingplan_core(Some(&telemetry))
})
.await;
match outcome {
Ok(report) => {
print!("{}", report);
}
Err(e) => {
eprintln!("login setup failed: {:#}", e);
}
}
println!("\n Starting AtomCode...\n");
HEADLESS_MODE.store(false, Ordering::Relaxed);
// Fall through to TUI startup below
}
Commands::Fixissue { url } => {
HEADLESS_MODE.store(true, Ordering::Relaxed);
let cwd = cli.dir.clone().unwrap_or_else(|| {
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
});
match atomcode_core::atomgit::fixissue::prepare(&url, &cwd) {
Ok(atomcode_core::atomgit::fixissue::Prepared::Run {
prompt,
issue_title,
issue_number,
issue_ref,
}) => {
eprintln!("[fixissue] issue #{}: {}", issue_number, issue_title);
fixissue_prompt = Some(prompt);
fixissue_ref = Some(issue_ref);
force_verbose = true;
// Fall through: agent loop will run this as a headless prompt.
}
Ok(atomcode_core::atomgit::fixissue::Prepared::Skip { reason }) => {
eprintln!("{}", reason);
return Ok(0);
}
Err(e) => {
eprintln!("fixissue failed: {:#}", e);
return Ok(1);
}
}
}
Commands::Daemon { port, client } => {
HEADLESS_MODE.store(true, Ordering::Relaxed);
eprintln!("Starting AtomCode daemon on port {}...", port);
eprintln!("Press Ctrl+C to stop.");
// Re-exec into the atomcode-daemon binary with matching port.
// This keeps the daemon as a separate compilation unit while
// providing a user-friendly `atomcode daemon` subcommand.
let daemon_bin = std::env::current_exe().ok().and_then(|p| {
let dir = p.parent()?;
let daemon = dir.join("atomcode-daemon");
if daemon.exists() {
Some(daemon)
} else {
None
}
});
match daemon_bin {
Some(bin) => {
let mut cmd = std::process::Command::new(bin);
cmd.arg("--port").arg(port.to_string());
if let Some(ref c) = client {
cmd.arg("--client").arg(c);
}
let status = cmd.status().context("Failed to start atomcode-daemon")?;
return Ok(if status.success() { 0 } else { 1 });
}
None => {
eprintln!("Error: atomcode-daemon binary not found next to atomcode.");
eprintln!("Make sure both binaries are installed together.");
return Ok(1);
}
}
}
Commands::Webui { port, host } => {
HEADLESS_MODE.store(true, Ordering::Relaxed);
let msg = atomcode_daemon::ensure_server_and_open(&host, port, false).await;
eprintln!("{msg}");
// server 是后台 task;保持进程存活直到用户 Ctrl+C
let _ = tokio::signal::ctrl_c().await;
return Ok(0);
}
Commands::Telemetry { action } => {
HEADLESS_MODE.store(true, Ordering::Relaxed);
let config_file_path = Config::default_path();
match action {
TelemetryAction::Status => {
telemetry_cmd::status(&atomcode_dir, &telemetry_cfg)?
}
TelemetryAction::Enable => telemetry_cmd::enable(&config_file_path)?,
TelemetryAction::Disable => {
telemetry_cmd::disable(&config_file_path, &telemetry).await?
}
TelemetryAction::Dump { last, pretty } => {
telemetry_cmd::dump(&atomcode_dir, last, pretty)?
}
TelemetryAction::Clear => telemetry_cmd::clear(&atomcode_dir)?,
}
return Ok(0);
}
Commands::Setup { force } => {
HEADLESS_MODE.store(true, Ordering::Relaxed);
let exit_code = run_setup_command(force);
telemetry
.shutdown(std::time::Duration::from_millis(500))
.await;
return Ok(exit_code);
}
other => {
let result = handle_command(other, &telemetry).await.map(|_| 0);
// Flush any events emitted by the subcommand (e.g. login_success)
// before the process exits. Bounded by the same 500ms budget as
// other exit paths.
telemetry
.shutdown(std::time::Duration::from_millis(500))
.await;
return result;
}
}
}
// Default: start TUI
let config_path = cli.config.clone().unwrap_or_else(Config::default_path);
let mut config = if config_path.exists() {
Config::load(&config_path).unwrap_or_else(|e| {
eprintln!("Warning: failed to load config ({}), using defaults", e);
Config {
default_provider: String::new(),
default_workdir: None,
providers: HashMap::new(),
datalog: Default::default(),
notifications: Default::default(),
auto_update: true,
telemetry: Default::default(),
lsp: Default::default(),
auto_commit: false,
subagent: Default::default(),
vision_preprocessor_provider: None,
language: None,
ui: Default::default(),
plugin: Default::default(),
}
})
} else {
// No config yet — TUI Welcome screen will guide first-run setup
Config {
default_provider: String::new(),
default_workdir: None,
providers: HashMap::new(),
datalog: Default::default(),
notifications: Default::default(),
auto_update: true,
telemetry: Default::default(),
lsp: Default::default(),
auto_commit: false,
subagent: Default::default(),
vision_preprocessor_provider: None,
language: None,
ui: Default::default(),
plugin: Default::default(),
}
};
// ── i18n locale ──
let locale = atomcode_tuix::i18n::resolve_initial_locale(cli.lang.as_deref(), config.language);
atomcode_tuix::i18n::set_locale(locale);
// ── Plugin marketplace bootstrap + post-upgrade refresh ──
//
// Two best-effort hooks (auto-install default skills marketplace
// on first startup, `git pull` every installed marketplace after a
// self-upgrade) used to fire here synchronously, blocking the
// input box for 1–3s on a warm path (and 5–10s on first clone).
// Both now run as a detached `spawn_blocking` from inside
// `atomcode_tuix::run` after the skill registry is constructed —
// see lib.rs near `spawn_plugin_bootstrap`. Newly-installed skills
// are picked up by a `skill_registry.reload()` + wake pulse the
// background task fires on completion, so the slash menu refreshes
// without a restart.
let unavailable_reason = if config.providers.is_empty() {
Some(atomcode_tuix::i18n::t(atomcode_tuix::i18n::Msg::CmdNoActiveProvider).into_owned())
} else {
None
};
/// Build the placeholder `ProviderConfig` used when no real provider is
/// available. The TUI still boots — the Welcome wizard / status-row
/// hints nudge the user to `/login`, and a successful
/// auth flow rebuilds the real provider via `rebuild_provider`.
fn dummy_provider_config() -> (ProviderConfig, String) {
(
ProviderConfig {
provider_type: "openai".to_string(),
api_key: Some("unavailable".to_string()),
model: String::new(),
base_url: None,
system_prompt: None,
user_agent: None,
context_window: default_context_window_for("openai"),
max_tokens: None,
thinking_type: None,
thinking_keep: None,
reasoning_history: None,
thinking_enabled: None,
thinking_budget: None,
skip_tls_verify: false,
ephemeral: false,
},
String::new(),
)
}
let (provider_config, model_name) = if unavailable_reason.is_some() {
dummy_provider_config()
} else {
if let Some(ref model) = cli.model {
let provider_name = cli.provider.as_deref().unwrap_or(&config.default_provider);
if let Some(p) = config.providers.get_mut(provider_name) {
p.model = model.clone();
}
}
// Keep api_key as None here so `create_provider()` auto-loads
// from `~/.atomcode/auth.toml`. Setting "not-configured" would
// bypass that path and force the user to manually provide a key.
//
// `active_provider` already falls back to the first available
// provider when `default_provider` points to a deleted section,
// but as a defence-in-depth measure we catch any remaining
// errors and swap in the dummy so the TUI still boots.
match config.active_provider(cli.provider.as_deref()) {
Ok(pc) => {
let name = pc.model.clone();
(pc.clone(), name)
}
Err(e) => {
eprintln!(
"Warning: could not resolve active provider ({}). \
Launching TUI in onboarding mode — use /login to set up.",
e
);
dummy_provider_config()
}
}
};
// `create_provider` may need to load an OAuth token from
// `~/.atomcode/auth.toml`. Pre-v4.20 this was a fatal startup
// error — if the user had a config.toml with an `AtomGit*` entry
// (from an older `/login` that auto-registered one) but no
// auth.toml (fresh machine, or auth.toml was deleted), the CLI
// bailed before the TUI could load, leaving the user stuck:
// they wanted to `/login` but couldn't start the app to run it.
//
// Graceful fallback: if provider construction fails because the
// token is unavailable, swap in the same dummy used on first-run
// so the TUI boots. The Welcome-wizard / status-row hints will
// nudge the user to `/login`, and a successful
// auth flow rebuilds the real provider via `rebuild_provider`.
let (provider, model_name) = if let Some(reason) = unavailable_reason {
(unavailable_provider(reason), model_name)
} else {
match create_provider(&provider_config) {
Ok(p) => (p, model_name),
Err(e) => {
let msg = format!("{:#}", e);
if is_auth_gap_error(&msg) {
eprintln!(
"Note: provider credentials not available ({}). \
Launching TUI in onboarding mode — use /login to set up.",
msg
);
(
unavailable_provider(format!(
"Provider 凭证不可用:{}。请使用 /login 完成配置后再试。",
msg
)),
String::new(),
)
} else {
return Err(e);
}
}
}
};
let working_dir = resolve_working_dir(cli.dir.clone());
// Build the disabled-tool set from --disable-tools (CLI) merged with the
// ATOMCODE_DISABLE_TOOLS env var. The env var allows the SWE-bench
// harness to opt-out of bash without rebuilding atomcode or threading a
// CLI flag through every shell wrapper.
let mut disabled_tools: std::collections::HashSet<String> = cli
.disable_tools
.iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
if let Ok(env_list) = std::env::var("ATOMCODE_DISABLE_TOOLS") {
for name in env_list
.split(',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
{
disabled_tools.insert(name.to_string());
}
}
if !disabled_tools.is_empty() {
let mut sorted: Vec<&String> = disabled_tools.iter().collect();
sorted.sort();
eprintln!(
"[atomcode] tools disabled: {}",
sorted
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(", ")
);
}
let enabled = |name: &str| !disabled_tools.contains(name);
let mut tool_registry = ToolRegistry::new();
if enabled("read_file") {
tool_registry.register_sync(Box::new(ReadFileTool));
}
if enabled("write_file") {
tool_registry.register_sync(Box::new(WriteFileTool));
}
if enabled("edit_file") {
tool_registry.register_sync(Box::new(EditFileTool));
}
if enabled("bash") {
tool_registry.register_sync(Box::new(BashTool));
}
// change_dir is opt-in: weak models (e.g. deepseek-v4-flash) repeatedly
// emit empty `arguments: {}` for it, looping the same broken call until
// the identical-args guard blocks it. Bash with `cd` already covers the
// legitimate use case (atomcode tracks `cd` in bash and mutates
// `working_dir` accordingly), and `/cd` lets the user switch manually.
// Set ATOMCODE_ENABLE_CD=1 to re-expose the tool to the LLM.
if enabled("change_dir") && std::env::var("ATOMCODE_ENABLE_CD").is_ok() {
tool_registry.register_sync(Box::new(CdTool));
}
if enabled("grep") {
tool_registry.register_sync(Box::new(GrepTool));
}
if enabled("glob") {
tool_registry.register_sync(Box::new(GlobTool));
}
if enabled("list_directory") {
tool_registry.register_sync(Box::new(ListDirTool));
}
if enabled("web_search") {
tool_registry.register_sync(Box::new(WebSearchTool));
}
if enabled("web_fetch") {
tool_registry.register_sync(Box::new(WebFetchTool));
}
if enabled("search_replace") {
tool_registry.register_sync(Box::new(SearchReplaceTool));
}
if enabled("open_file") {
tool_registry.register_sync(Box::new(OpenFileTool));
}
// Determine if we're running in headless mode BEFORE loading MCP.
// Headless mode requires MCP tools immediately; TUI can load them in background.
let is_headless =
cli.prompt.is_some() || cli.prompt_file.is_some() || fixissue_prompt.is_some();
// Load MCP tools from .mcp.json (project) and ~/.atomcode/mcp.json (user).
// For TUI mode, start connections in background to avoid blocking startup.
// For headless mode (-p/--prompt-file/fixissue), wait for connections since tools
// are needed immediately.
let (mcp_registry, mcp_connect_rx) = if is_headless {
// Headless: need tools right now, wait for connections
let registry = McpRegistry::from_config(&working_dir).await;
let mcp_tools = registry.list_all_tools().await;
let mcp_registry = if !mcp_tools.is_empty() {
let mcp_registry = std::sync::Arc::new(registry);
register_mcp_tools(&mut tool_registry, mcp_registry.clone(), mcp_tools);
Some(mcp_registry)
} else {
None
};
(mcp_registry, None)
} else {
// TUI: start in background, tools populate as servers connect
// Create event channel so TUI can display connection status in scrollback
use atomcode_core::mcp::McpConnectEvent;
let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<McpConnectEvent>();
let registry = McpRegistry::from_config_background_with_events(&working_dir, Some(tx));
let mcp_registry = std::sync::Arc::new(registry);
// Don't wait for tools - they'll be registered dynamically as servers connect
(Some(mcp_registry), Some(rx))
};
// Build LSP manager from config and inject into ToolContext.
// TUI mode uses the event-channel constructor so server start /
// failure surfaces in scrollback (✓/✗ lines) instead of being
// eprintln!'d directly to stderr — which would land inside the
// input box while the renderer owns the terminal. Headless keeps
// the no-channel path: stderr leakage doesn't matter when no TUI
// is active and CI logs benefit from raw error visibility.
let (lsp_manager, lsp_connect_rx) = if is_headless {
(build_lsp_manager(&config.lsp, &working_dir), None)
} else {
match atomcode_core::lsp::build_lsp_manager_with_events(&config.lsp, &working_dir) {
Some((mgr, rx)) => (Some(mgr), Some(rx)),
None => (None, None),
}
};
if lsp_manager.is_some() && enabled("diagnostics") {
tool_registry.register_sync(Box::new(DiagnosticsTool));
}
// Pass the already-initialized telemetry handle into ToolContext.
let mut tool_context =
ToolContext::with_telemetry(working_dir.clone(), "default", telemetry.clone());
tool_context.lsp = lsp_manager;
// Continue the previous session only when the user explicitly opts
// in via `-c` / `--continue`. Bare `atomcode` starts a fresh
// session — no auto-resume, no scrollback replay. Users who want to
// pick a specific older session can still use `/resume` inside the
// TUI.
let session_to_continue = if cli.continue_last {
let session_manager = SessionManager::new(&working_dir);
match session_manager.latest() {
Ok(Some(session)) => Some(session),
_ => None,
}
} else {
None
};
// Start with a fresh conversation each session. When `session_to_continue`
// is present (via `-c` / `--continue`), the TUI replays the prior session's
// messages into scrollback AND sends them to the agent via
// `AgentCommand::SetMessages` so the model context is fully restored.
// Bare `atomcode` (no `-c`) starts completely fresh.
let conversation = Conversation::new();
let (mut agent_loop, agent_handle) = AgentLoop::new_with_skip_permissions(
config.clone(),
provider,
tool_registry,
tool_context.clone(),
conversation,
cli.dangerously_skip_permissions,
);
agent_loop.set_max_turns(cli.max_turns);
let runtime_factory = AgentRuntimeFactory::from_initial_loop(&agent_loop, cli.max_turns);
// Resolve effective prompt: --prompt-file reads from disk; -p is inline;
// `fixissue` synthesises one from the AtomGit issue body. fixissue takes
// precedence — when it's set we've already committed to headless mode.
// clap's conflicts_with ensures `-p` and `--prompt-file` can't both be given.
let effective_prompt: Option<String> = if let Some(p) = fixissue_prompt.take() {
Some(p)
} else {
match (cli.prompt.as_ref(), cli.prompt_file.as_ref()) {
(Some(p), None) => Some(p.clone()),
(None, Some(path)) => match std::fs::read_to_string(path) {
Ok(s) => Some(s),
Err(e) => {
eprintln!(
"error: failed to read --prompt-file {}: {}",
path.display(),
e
);
std::process::exit(2);
}
},
(None, None) => None,
(Some(_), Some(_)) => unreachable!("clap conflicts_with prevents this"),
}
};
// Build the session-scope context: repo_origin, mode.
// session_id and account_id are managed on Telemetry directly via
// set_session_id() / set_account_id(). Seed account_id from stored auth so
// events from this session correlate to the user even before any explicit
// login action this run; login()/logout() update it later as needed.
// mode: Headless when a prompt is supplied (-p / --prompt-file / fixissue);
// Tui when the user launches the interactive terminal UI.
let repo = atomcode_core::telemetry_bootstrap::detect_repo_origin(
&std::env::current_dir().unwrap_or_else(|_| working_dir.clone()),
);
telemetry.set_account_id(auth::get_stored_auth().map(|a| a.user.id.to_string()));
let session_mode = if effective_prompt.is_some() {
SessionMode::Headless
} else {
SessionMode::Tui
};
// Bind telemetry to the continued session's id (if any). A fresh run needs
// nothing here: the agent bootstraps telemetry + header + datalog from its
// own session id. The TUI manages its own binding via
// `bind_telemetry_to_session`.
if let Some(ref s) = session_to_continue {
if let Ok(uuid) = uuid::Uuid::parse_str(s.id.as_str()) {
telemetry.set_session_id(uuid);
}
// Headless `-c`: the TUI rebinds itself, so only push to the agent
// here, so the continued session's id reaches the header + datalog.
if effective_prompt.is_some() {
agent_handle
.client
.cmd_tx
.send(AgentCommand::SetSessionId(s.id.as_str().to_string()))
.ok();
}
}
let scope_ctx = CurrentContext {
repo_origin: Some(repo),
mode: Some(session_mode),
..CurrentContext::current()
};
let result = CurrentContext::scope(scope_ctx, || async {
// Emit open_atomcode once at agent-flow entry. Meta-commands
// (--version, --help, --update, login, logout, status, upgrade,
// rollback, telemetry) return via handle_command before reaching
// this point and must NOT emit open_atomcode.
telemetry.track(Event::OpenAtomcode { dangerously_skip_permissions: cli.dangerously_skip_permissions });
// Headless mode: -p / --prompt-file triggers non-interactive execution.
// `fixissue` also sets `force_verbose` so tool activity is visible — the
// user is watching a single long-running task, not feeding a pipe.
let exit_code = if let Some(prompt) = effective_prompt {
let verbose = cli.verbose || force_verbose;
// Capture the assistant's streamed text only when we need to post
// it back to AtomGit (fixissue). Plain `-p` stays zero-alloc.
let capture = fixissue_ref.is_some();
let (ec, captured) = run_headless(
agent_loop,
agent_handle,
prompt,
cli.provider.as_deref(),
verbose,
capture,
working_dir.clone(),
cli.dangerously_skip_permissions,
)
.await?;
// Post-run side effects for fixissue: only on clean completion
// (exit 0 = TurnComplete Natural; 1 = error; 2 = denial; 130 = cancel).
// On non-zero we leave the issue alone — the user can retry.
if let Some(issue_ref) = fixissue_ref {
if ec == 0 {
if let Some(summary) = captured.filter(|s| !s.trim().is_empty()) {
match atomcode_core::atomgit::fixissue::post_completion(&issue_ref, &summary) {
Ok(()) => eprintln!(
"[fixissue] ✓ posted summary + applied 'fixed' label to issue #{}",
issue_ref.number
),
Err(e) => eprintln!(
"[fixissue] ✗ post-back failed (local fix is still saved): {:#}",
e
),
}
} else {
eprintln!(
"[fixissue] agent produced no text; skipping comment + label on issue #{}",
issue_ref.number
);
}
} else {
eprintln!(
"[fixissue] agent exited non-zero ({}); skipping comment + label on issue #{}",
ec, issue_ref.number
);
}
}
Ok::<i32, anyhow::Error>(ec)
} else {
// Fire-and-forget: spawn a setsid'd subprocess to stage the next
// release if one is out. Detached so a Ctrl+C in this parent doesn't
// also kill the download — that was the whole reason "exit and come
// back" wasn't picking up v_next on short sessions. Only armed when
// the user hasn't opted out via `auto_update = false` AND we're not
// running as `atomcode.bak` (backup should stay pinned; see the
// `is_running_as_backup` guard up top).
// In distro-pm (HarmonyBrew) builds the package manager owns
// upgrades, so skip spawning the detached prep process entirely —
// `prepare_deferred_upgrade` would no-op anyway.
if config.auto_update
&& !is_running_as_backup()
&& !cli.dev
&& !atomcode_core::self_update::is_package_managed()
{
spawn_detached_upgrade_prep();
}
// Redirect fd 2 → $ATOMCODE_HOME/stderr.log before the TUI takes
// ownership of the terminal. NSPasteboard deprecation warnings
// (arboard clipboard polling, ~1.5 s interval) and any other
// rogue C-lib stderr writes would otherwise land at the raw-mode
// cursor position, painting into the input box.
//
// Only fires here — the TUI branch. Headless (-p/--prompt-file)
// leaves stderr pointing at the real terminal so the user sees
// actual errors in their shell/CI output.
redirect_stderr_to_log_file();
let ctx = atomcode_telemetry::CurrentContext::current();
tokio::spawn(async move {
atomcode_telemetry::CurrentContext::scope(ctx, || agent_loop.run()).await
});
atomcode_tuix::run(config, model_name, agent_handle, runtime_factory, working_dir, session_to_continue, mcp_registry, mcp_connect_rx, lsp_connect_rx, telemetry.clone(), cli.dangerously_skip_permissions).await?;
Ok(0)
};
telemetry.shutdown(std::time::Duration::from_millis(500)).await;
exit_code
})
.await;
result
}
/// On macOS the NSPasteboard runtime prints deprecation warnings to
/// stderr when arboard calls into AppKit (via clipboard polling for
/// the "ctrl+v to paste image" hint). In raw mode, stderr shares the
/// TTY with the TUI paint stream, so those warnings paint into the
/// input box at whatever cursor row happens to be active. Other libs
/// (LSP, MCP shells) can leak the same way.
///
/// Redirect fd 2 to `$ATOMCODE_HOME/stderr.log` once we know we're
/// entering interactive TUI mode. plain / headless / piped paths
/// don't call this — they want stderr to reach the terminal so the
/// user sees real errors.
///
/// Best-effort: if the home dir can't be created or the file can't
/// be opened, do nothing and let stderr leak (the original bug); we
/// don't want to take down atomcode startup because logging failed.
#[cfg(unix)]
fn redirect_stderr_to_log_file() {
use std::os::unix::io::AsRawFd;
let Some(home) = std::env::var_os("ATOMCODE_HOME")
.map(std::path::PathBuf::from)
.or_else(|| dirs::home_dir().map(|h| h.join(".atomcode")))
else {
return;
};
if std::fs::create_dir_all(&home).is_err() {
return;
}
let Ok(file) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(home.join("stderr.log"))
else {
return;
};
// Write a session marker so users can see in stderr.log where
// each atomcode session starts — helps separate one run's noise
// from another's when grepping for actual problems.
// Use epoch seconds (std::time only — no chrono dep needed).
let epoch_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let marker = format!("\n--- atomcode session start (unix={epoch_secs}) ---\n");
let _ = std::io::Write::write_all(&mut std::io::BufWriter::new(&file), marker.as_bytes());
// SAFETY: dup2 swaps the file descriptor table entry for fd 2
// to point at `file`'s underlying fd. This is a standard, safe
// operation; the worst case (dup2 fails) is the redirect doesn't
// happen and we log nothing — same as the no-redirect baseline.
unsafe {
libc::dup2(file.as_raw_fd(), libc::STDERR_FILENO);
}
// Intentionally keep `file` alive via the dup2 — the kernel
// holds a reference to the underlying inode, so even after
// `file` is dropped, fd 2 stays pointing at the same file.
// No need to std::mem::forget.
}
#[cfg(not(unix))]
fn redirect_stderr_to_log_file() {
// Windows: NSPasteboard is mac-only; arboard on Windows uses
// OpenClipboard which doesn't NSLog. Not a known leak path.
// No-op for now; revisit if a similar Windows issue surfaces.
}
/// Run agent in headless mode (pipe-friendly: stdout = LLM text only,
/// logs/diagnostics → stderr). Non-interactive: `bash` approvals are
/// auto-allowed (stderr logs the reason); other tools that require approval
/// are still denied — **unless** `--dangerously-skip-permissions` was
/// passed, in which case all tool calls are auto-approved.
///
/// `verbose=false` (default): Claude Code -p style — only the assistant reply
/// reaches the user. Tool calls, token usage, and turn summary are silent.
/// Errors, approval denials, and cancellations are still surfaced on stderr.
///
/// `verbose=true`: also emit tool calls, token usage, [done] summary, working
/// dir changes, and sub-agent progress on stderr.
async fn run_headless(
agent_loop: AgentLoop,
agent_handle: atomcode_core::agent::AgentHandle,
prompt: String,
_provider_name: Option<&str>,
verbose: bool,
capture: bool,
working_dir: PathBuf,
skip_permissions: bool,
) -> Result<(i32, Option<String>)> {
// Tell the panic hook / error path to skip TUI cleanup — raw mode was
// never enabled here, so `disable_raw_mode` would be a wasted ioctl
// (and on Windows can panic when stdin isn't a real console handle).
HEADLESS_MODE.store(true, Ordering::Relaxed);
// Emit a warning when --dangerously-skip-permissions is active.
// The actual permission bypass is handled by InteractivePermissionDecider
// (will_auto_approve always returns true), so ApprovalNeeded events
// should never reach this loop — but the log line gives the user a
// clear signal that the flag is in effect.
if skip_permissions {
eprintln!(
"{}",
atomcode_core::i18n::t(atomcode_core::i18n::Msg::BypassWarningHeadless)
);
}
let notifications = agent_loop.config.notifications.clone();
let (cmd_tx, mut event_rx) = {
let handle = agent_handle;
(handle.client.cmd_tx, handle.event_rx)
};
let ctx = atomcode_telemetry::CurrentContext::current();
tokio::spawn(async move {
atomcode_telemetry::CurrentContext::scope(ctx, || agent_loop.run()).await
});
cmd_tx.send(AgentCommand::SendMessage {
text: prompt,
images: vec![],
image_markers: vec![],
})?;
let mut exit_code: i32 = 0;
let mut had_denial = false;
let mut last_text_ended_with_newline = true;
// Tracks whether we're inside a streaming `[thinking]` line. When true, the
// next non-reasoning event must close the line with a `\n` so subsequent
// log lines don't glue onto the tail of the thinking text.
let mut thinking_line_open = false;
// When `capture` is set, we also buffer every TextDelta the agent
// emits so the caller (e.g. the fixissue workflow) can post the
// full assistant output back to AtomGit as an issue comment.
let mut captured: Option<String> = if capture { Some(String::new()) } else { None };
// Helper: close any in-flight `[thinking]` line before emitting a new log
// line on stderr. Avoids `[thinking] ...[tool→ ...]` mashups in verbose.
// Thin wrapper over the unit-tested `close_thinking_chunk` that flushes
// directly to stderr.
fn close_thinking_line(open: &mut bool) {
let mut buf = String::new();
close_thinking_chunk(&mut buf, open);
if !buf.is_empty() {
eprint!("{}", buf);
let _ = io::stderr().flush();
}
}
while let Some(event) = event_rx.recv().await {
match event {
AgentEvent::TextDelta(text) => {
close_thinking_line(&mut thinking_line_open);
if !text.is_empty() {
last_text_ended_with_newline = text.ends_with('\n');
}
if let Some(buf) = captured.as_mut() {
buf.push_str(&text);
}
print!("{}", text);
io::stdout().flush()?;
}
AgentEvent::ReasoningDelta(text) => {
// In CLI verbose mode, show reasoning/thinking content.
// Reasoning arrives as many tiny streaming chunks (often a
// single token). The old implementation used `eprintln!` per
// chunk, producing "one word per line" output. We now stream
// chunks onto a single `[thinking] ...` line via the
// unit-tested `format_thinking_chunk` helper.
if verbose && !text.is_empty() {
let mut buf = String::new();
format_thinking_chunk(&mut buf, &mut thinking_line_open, &text);
eprint!("{}", buf);
let _ = io::stderr().flush();
}
}
AgentEvent::ToolCallStreaming { name, hint } => {
if verbose {
close_thinking_line(&mut thinking_line_open);
let detail = if hint.is_empty() {
String::new()
} else {
format!(" → {}", hint)
};
eprintln!("[tool-streaming← {}{}]", name, detail);
}
}
AgentEvent::ToolCallStarted {
id: _,
name,
arguments,
} => {
if verbose {
close_thinking_line(&mut thinking_line_open);
let args = truncate_log_line(&arguments, 200);
eprintln!("[tool→ {} args={}]", name, args);
}
}
AgentEvent::ToolOutputChunk { call_id: _, chunk } => {
if verbose {
close_thinking_line(&mut thinking_line_open);
eprint!("{}", chunk);
let _ = io::stderr().flush();
}
}
AgentEvent::ToolCallResult {
call_id: _,
name,
output,
success,
duration,
} => {
if verbose {
close_thinking_line(&mut thinking_line_open);
let status = if success { "OK" } else { "FAILED" };
let dur_ms = duration.as_millis();
let trimmed = output.trim_end();
if trimmed.is_empty() {
eprintln!("[tool← {} {} {}ms]", name, status, dur_ms);
} else {
let snippet = truncate_log_line(trimmed, 500);
eprintln!("[tool← {} {} {}ms] {}", name, status, dur_ms, snippet);
}
}
}
AgentEvent::ApprovalNeeded {
tool_name, reason, ..
} => {
close_thinking_line(&mut thinking_line_open);
if skip_permissions {
// --dangerously-skip-permissions: auto-approve everything.
// This branch should rarely be reached because
// InteractivePermissionDecider.will_auto_approve()
// returns true, preventing ApprovalRequested from being
// emitted in the first place. But if it does reach here
// (e.g. a race), honor the flag.
eprintln!("[headless] auto-approved {}: {}", tool_name, reason);
cmd_tx.send(AgentCommand::ApproveTool)?;
} else if tool_name == "bash" {
// -p / headless cannot prompt; user opts in by using non-interactive mode.
eprintln!("[headless] auto-approved bash: {}", reason);
cmd_tx.send(AgentCommand::ApproveTool)?;
} else {
// Always shown — security signal must not be silent.
eprintln!("[approval-denied] tool={} reason={}", tool_name, reason);
cmd_tx.send(AgentCommand::DenyTool)?;
had_denial = true;
}
}
AgentEvent::TokenUsage(usage) => {
if verbose {
close_thinking_line(&mut thinking_line_open);
eprintln!(
"[tokens] prompt={} completion={}",
usage.prompt_tokens, usage.completion_tokens
);
}
}
AgentEvent::PhaseChange(_) => {
// Silent in headless mode (in both default and verbose).
}
AgentEvent::TurnComplete {
duration,
total_tokens,
turn_count,
tool_call_count,
stop_reason,
messages: _,
} => {
close_thinking_line(&mut thinking_line_open);
atomcode_core::notify::notify_turn_finished(
¬ifications,
atomcode_core::notify::TurnNotification {
duration,
turn_count,
tool_call_count,
total_tokens: Some(total_tokens),
stop_reason,
working_dir: Some(&working_dir),
},
);
// Always ensure stdout ends with a newline so downstream parsers see a clean line.
if !last_text_ended_with_newline {
println!();
io::stdout().flush()?;
}
if verbose {
// Natural completion stays silent on the stop reason to
// preserve the familiar Claude Code -p [done] format.
// Budget-enforced / error / cancel truncation gets an
// explicit `stopped=<tag>` suffix so eval runners and
// humans can tell "natural end" from "we hit a limit".
let suffix = match stop_reason {
atomcode_core::agent::TurnStopReason::Natural => String::new(),
other => format!(" stopped={}", other.as_tag()),
};
eprintln!(
"[done] {:.1}s tokens={} turns={} tool_calls={}{}",
duration.as_secs_f64(),
total_tokens,
turn_count,
tool_call_count,
suffix
);
}
let _ = cmd_tx.send(AgentCommand::Shutdown);
break;
}
AgentEvent::TurnCancelled { .. } => {
close_thinking_line(&mut thinking_line_open);
// Always shown — user needs to know cancellation happened.
eprintln!("[cancelled]");
exit_code = 130;
let _ = cmd_tx.send(AgentCommand::Shutdown);
break;
}
AgentEvent::Error { error, messages: _ } => {
close_thinking_line(&mut thinking_line_open);
// Always shown — errors are not noise.
eprintln!("[error] {}", error);
exit_code = 1;
let _ = cmd_tx.send(AgentCommand::Shutdown);
break;
}
AgentEvent::Warning(w) => {
// Headless CLI: warnings go to stderr always (they're
// meant to be loud). No exit-code change, no shutdown —
// we expect the turn to keep running.
eprintln!("[warning] {}", w);
}
AgentEvent::HookWarningHint(msg) => {
eprintln!("[hook-warning] {}", msg);
}
AgentEvent::WorkingDirChanged(new_dir) => {
if verbose {
eprintln!("[cwd] {}", new_dir.display());
}
}
AgentEvent::ContextStats { .. } => {
// Silent in headless mode
}
AgentEvent::ToolBatchStarted { batch_id: _, calls } => {
if verbose {
eprintln!("[tool-batch] {} calls in parallel", calls.len());
}
}
AgentEvent::ToolBatchCompleted {
batch_id: _,
ok,
total,
elapsed_ms,
} => {
if verbose {
eprintln!(
"[tool-batch] completed {}/{} ok in {}ms",
ok, total, elapsed_ms
);
}
}
AgentEvent::SubAgentDispatchStart { tasks } => {
if verbose {
eprintln!("[sub-agent] dispatching {} in parallel", tasks.len());
for (i, t) in tasks.iter().enumerate() {
eprintln!("[sub-agent {}] {}{}", i, t.path, t.dedup_suffix);
}
}
}
AgentEvent::SubAgentDispatchEnd => {
if verbose {
eprintln!("[sub-agent] dispatch complete");
}
}
AgentEvent::SubAgentTaskStarted { index } => {
if verbose {
eprintln!("[sub-agent {}] running", index);
}
}
AgentEvent::SubAgentTaskDone {
index,
elapsed_ms,
turns,
summary: _,
} => {
if verbose {
eprintln!(
"[sub-agent {}] done {}s · {}T",
index,
elapsed_ms / 1000,
turns
);
}
}
AgentEvent::SubAgentTaskFailed {
index,
elapsed_ms,
turns: _,
reason,
} => {
if verbose {
eprintln!(
"[sub-agent {}] failed {}s · {}",
index,
elapsed_ms / 1000,
reason.lines().next().unwrap_or("")
);
}
}
AgentEvent::BackgroundComplete {
summary,
files_edited,
turns,
success,
} => {
let status = if success { "ok" } else { "fail" };
eprintln!("[background {} turns={}] {}", status, turns, summary);
if verbose && !files_edited.is_empty() {
eprintln!("[background files={}]", files_edited.join(","));
}
}
// VL preprocessor failure restores pending image bytes for the
// TUI to re-attach. CLI has no interactive input buffer to put
// them in, so just ignore — the failure itself was already
// surfaced as AgentEvent::Warning above, and the conversation
// proceeds with the placeholder. No retry path exists in CLI.
AgentEvent::RestorePendingImages { .. } => {}
// VL preprocessor success notice. Mirror the TUI behaviour
// briefly to stderr so non-interactive users (CI, scripts)
// still see that VL ran. Char count helps spot degenerate
// outputs.
AgentEvent::VisionPreprocessSuccess { vl_key, char_count } => {
eprintln!(
"[vl-preprocess ok provider={} chars={}]",
vl_key, char_count
);
}
AgentEvent::ConversationTruncated { .. }
| AgentEvent::UndoFailed { .. }
| AgentEvent::MessagesSync { .. } => {
// Only used by TUI for /bg or /undo; ignore in headless CLI.
}
AgentEvent::UserEcho(_)
| AgentEvent::PeerBusy(_)
| AgentEvent::ProviderChanged(_) => {
// Live-sync only — not applicable in headless CLI.
}
}
}
// Priority: Error(1) > Denial(2) > 0; TurnCancelled(130) is absolute.
if exit_code == 0 && had_denial {
exit_code = 2;
}
Ok((exit_code, captured))
}
/// Drive `atomcode_core::setup::run` end-to-end and return the CLI exit code
/// (0 on success, 1 on any setup error). `setup::run` is synchronous; we
/// run it directly since `Commands::Setup` already runs outside the TUI loop.
fn run_setup_command(force: bool) -> i32 {
use atomcode_core::setup;
let project_root = match std::env::current_dir() {
Ok(p) => p,
Err(e) => {
eprintln!("setup error: cannot read current directory: {e}");
return 1;
}
};
let mut opts = setup::RunOptions::new(project_root);
opts.force = force;
match setup::run(opts) {
Ok(report) => {
println!("{}", report.render_cli());
0
}
Err(e) => {
eprintln!("setup error: {e}");
1
}
}
}
/// Handle subcommands (login, logout, status)
async fn handle_command(cmd: Commands, telemetry: &std::sync::Arc<Telemetry>) -> Result<()> {
// Subcommands never enter TUI, so tell the panic hook to skip terminal
// cleanup — otherwise `disable_raw_mode` panics on Windows with
// "initial console mode not set" because raw mode was never enabled.
HEADLESS_MODE.store(true, Ordering::Relaxed);
match cmd {
Commands::Login => {
// `run()` intercepts Login (and its Codingplan alias) before
// handle_command is called, running the full OAuth + setup
// flow and falling through to the TUI. This arm is
// unreachable in normal execution but kept defensive.
unreachable!("Login is handled inline in run() before handle_command")
}
Commands::Logout => {
auth::logout()?;
telemetry.set_account_id(None);
println!(" You have been logged out.");
Ok(())
}
Commands::Status => {
if let Some(auth) = auth::get_stored_auth() {
println!(
"\n Logged in as: {} ({})",
auth.user.username, auth.user.id
);
if let Some(name) = auth.user.name {
println!(" Name: {}", name);
}
if let Some(email) = auth.user.email {
println!(" Email: {}", email);
}
println!(" Auth file: {}\n", auth::auth_file_path().display());
} else {
println!("\n Not logged in.");
println!(" Run 'atomcode login' to authenticate.\n");
}
Ok(())
}
Commands::Upgrade { force } => run_upgrade_cli(force).await,
Commands::Rollback => run_rollback_cli(),
Commands::Uninstall {
yes,
purge,
keep_data,
dry_run,
} => uninstall::run(uninstall::Args {
yes,
purge,
keep_data,
dry_run,
}),
Commands::Fixissue { .. } => {
unreachable!("Fixissue is handled inline in run() before handle_command")
}
Commands::Codingplan => {
// Hidden alias for Login — `run()` intercepts both before
// handle_command is called, so this arm is unreachable.
unreachable!("Codingplan is handled inline in run() before handle_command")
}
Commands::Telemetry { .. } => {
unreachable!("Telemetry is handled inline in run() before handle_command")
}
Commands::Daemon { .. } => {
unreachable!("Daemon is handled inline in run() before handle_command")
}
Commands::Webui { .. } => {
unreachable!("Webui is handled inline in run() before handle_command")
}
Commands::Setup { .. } => {
unreachable!("Setup is handled inline in run() before handle_command")
}
Commands::Plugin(sub) => handle_plugin_cli(sub),
Commands::Mcp(McpCli::Add {
name,
command,
global,
dir,
}) => {
let base = resolve_working_dir(dir);
let path = if global {
Config::config_dir().join("mcp.json")
} else {
base.join(".mcp.json")
};
let program = command
.first()
.expect("clap ensures at least one command token")
.clone();
let args: Vec<String> = command.into_iter().skip(1).collect();
merge_stdio_mcp_server_into_json_file(&path, &name, &program, &args)?;
println!(
" Added MCP server {:?} → {} (stdio: {} + {} arg(s))",
name,
path.display(),
program,
args.len()
);
Ok(())
}
Commands::Mcp(McpCli::AddGithubOauth { name, global, dir }) => {
let base = resolve_working_dir(dir);
let path = if global {
Config::config_dir().join("mcp.json")
} else {
base.join(".mcp.json")
};
merge_http_oauth_mcp_server_into_json_file(
&path,
&name,
"https://api.githubcopilot.com/mcp/",
"github",
)?;
println!(
" Added GitHub OAuth MCP server {:?} → {}",
name,
path.display()
);
Ok(())
}
Commands::Mcp(McpCli::Login {
name,
provider,
client_id,
client_secret_env,
scopes,
}) => {
let configs = load_mcp_config(&std::env::current_dir()?)?;
let server = configs
.into_iter()
.find(|config| config.name == name)
.ok_or_else(|| anyhow::anyhow!("MCP server {:?} not found in config", name))?;
let is_github_server = matches!(
&server.config,
McpTransportConfig::Http {
auth: Some(McpHttpAuthConfig::OAuth(auth)),
..
} if auth.provider.as_deref() == Some("github")
);
let client_id = client_id.or_else(|| {
if is_github_server && provider == "github" {
std::env::var("ATOMCODE_GITHUB_MCP_CLIENT_ID").ok()
} else {
None
}
});
let token = login_mcp_oauth(
&server,
McpOAuthLoginOptions {
client_id,
client_secret_env,
scopes,
},
)?;
println!(
" Saved {} OAuth token for MCP server {:?} with {} scope(s)",
token.provider,
name,
token.scopes.len()
);
Ok(())
}
Commands::Mcp(McpCli::Logout { name }) => {
let removed = McpTokenStore::default().delete_token(&name)?;
if removed {
println!(" Removed saved OAuth token for MCP server {:?}", name);
} else {
println!(" No saved OAuth token found for MCP server {:?}", name);
}
Ok(())
}
Commands::Hooks(subcmd) => handle_hooks(subcmd).await,
}
}
/// Handle hooks subcommands
async fn handle_hooks(cmd: HookCommands) -> Result<()> {
HEADLESS_MODE.store(true, Ordering::Relaxed);
match cmd {
HookCommands::List => {
let mut engine = atomcode_core::hook::HookEngine::new();
engine.load_all(&std::env::current_dir().unwrap_or_default());
let stats = engine.stats();
let total = stats.pre_tool_hooks
+ stats.post_tool_hooks
+ stats.post_turn_hooks
+ stats.system_prompt_hooks
+ stats.on_session_start_hooks
+ stats.on_session_end_hooks
+ stats.on_error_hooks
+ stats.on_user_prompt_submit_hooks
+ stats.on_tool_call_start_hooks
+ stats.on_model_response_hooks;
println!("\nLoaded Hooks:");
println!("─────────────────────────────────────────────");
if total == 0 {
println!(" (No hooks loaded)");
} else {
println!(" {:<30} {:>5}", "Type", "Count");
println!(" {:<30} {:>5}", "─".repeat(30), "─".repeat(5));
if stats.pre_tool_hooks > 0 {
println!(" {:<30} {:>5}", "PreToolExecution", stats.pre_tool_hooks);
}
if stats.post_tool_hooks > 0 {
println!(" {:<30} {:>5}", "PostToolExecution", stats.post_tool_hooks);
}
if stats.on_tool_call_start_hooks > 0 {
println!(
" {:<30} {:>5}",
"OnToolCallStart", stats.on_tool_call_start_hooks
);
}
if stats.post_turn_hooks > 0 {
println!(" {:<30} {:>5}", "PostTurn (legacy)", stats.post_turn_hooks);
}
if stats.on_model_response_hooks > 0 {
println!(
" {:<30} {:>5}",
"OnModelResponse", stats.on_model_response_hooks
);
}
if stats.on_session_start_hooks > 0 {
println!(
" {:<30} {:>5}",
"OnSessionStart", stats.on_session_start_hooks
);
}
if stats.on_session_end_hooks > 0 {
println!(" {:<30} {:>5}", "OnSessionEnd", stats.on_session_end_hooks);
}
if stats.on_error_hooks > 0 {
println!(" {:<30} {:>5}", "OnError", stats.on_error_hooks);
}
if stats.system_prompt_hooks > 0 {
println!(" {:<30} {:>5}", "SystemPrompt", stats.system_prompt_hooks);
}
println!(" {:<30} {:>5}", "─".repeat(30), "─".repeat(5));
println!(" {:<30} {:>5}", "Total", total);
}
println!();
// 显示 hooks 目录
println!("Hook Directories:");
println!("─────────────────────────────────────────────");
if let Some(home) = dirs::home_dir() {
let global_dir = home.join(".atomcode").join("hooks");
let exists = if global_dir.exists() { "✓" } else { "✗" };
println!(" {} Global: {}", exists, global_dir.display());
}
if let Ok(cwd) = std::env::current_dir() {
let project_dir = cwd.join(".atomcode").join("hooks");
let exists = if project_dir.exists() { "✓" } else { "✗" };
println!(" {} Project: {}", exists, project_dir.display());
}
println!();
Ok(())
}
HookCommands::Test { name } => {
println!("Testing hook: {}", name);
println!("(TODO: Implement hook testing)");
Ok(())
}
HookCommands::Paths => {
println!("\nHook Configuration Paths:");
println!("─────────────────────────────────────────────");
if let Some(home) = dirs::home_dir() {
let global_config = home.join(".atomcode").join("hooks").join("hooks.toml");
let exists = if global_config.exists() { "✓" } else { "✗" };
println!(" {} Global config: {}", exists, global_config.display());
}
if let Ok(cwd) = std::env::current_dir() {
let project_config = cwd.join(".atomcode").join("hooks").join("hooks.toml");
let exists = if project_config.exists() {
"✓"
} else {
"✗"
};
println!(" {} Project config: {}", exists, project_config.display());
}
println!("\nDocumentation:");
println!("─────────────────────────────────────────────");
println!(" docs/hooks.md - Hook usage guide");
println!(" docs/hook-timing-complete.md - Complete timing list");
println!(" docs/hook-expansion-summary.md - Expansion summary");
println!();
Ok(())
}
}
}
/// Dispatch `atomcode plugin ...` subcommands. Each branch calls the same
/// `atomcode_core::plugin::*` API the TUI's `/plugin` slash command uses, so
/// CLI installs and TUI installs share state under `$ATOMCODE_HOME/plugins/`.
fn handle_plugin_cli(sub: PluginCli) -> Result<()> {
use atomcode_core::plugin::{installer, marketplace};
match sub {
PluginCli::Marketplace(MarketplaceCli::Add { url }) => {
let info = marketplace::add_marketplace(&url)
.map_err(|e| anyhow::anyhow!("add marketplace: {:#}", e))?;
println!(
" marketplace `{}` added at {} ({} plugins)",
info.name,
&info.git_commit[..7.min(info.git_commit.len())],
info.plugins.len()
);
Ok(())
}
PluginCli::Marketplace(MarketplaceCli::Remove { name }) => {
marketplace::remove_marketplace(&name)
.map_err(|e| anyhow::anyhow!("remove marketplace: {:#}", e))?;
println!(" marketplace `{}` removed", name);
Ok(())
}
PluginCli::Marketplace(MarketplaceCli::Update { name }) => {
let info = marketplace::update_marketplace(&name)
.map_err(|e| anyhow::anyhow!("update marketplace: {:#}", e))?;
println!(
" marketplace `{}` updated to {}",
info.name,
&info.git_commit[..7.min(info.git_commit.len())]
);
Ok(())
}
PluginCli::Marketplace(MarketplaceCli::List) => {
let items = marketplace::list_marketplaces()?;
if items.is_empty() {
println!(" no marketplaces registered");
} else {
for m in items {
println!(
" {} {} {} ({} plugins)",
m.name,
m.source,
&m.git_commit[..7.min(m.git_commit.len())],
m.plugins.len()
);
}
}
Ok(())
}
PluginCli::Install { spec } => {
let (plugin, mp) = parse_plugin_spec(&spec)?;
let info = installer::install(&plugin, &mp, atomcode_core::plugin::InstallScope::User)
.map_err(|e| anyhow::anyhow!("install: {:#}", e))?;
println!(" installed `{}@{}`", info.plugin, info.marketplace);
Ok(())
}
PluginCli::Uninstall { spec } => {
let (plugin, mp) = parse_plugin_spec(&spec)?;
installer::uninstall(&plugin, &mp, atomcode_core::plugin::InstallScope::User)
.map_err(|e| anyhow::anyhow!("uninstall: {:#}", e))?;
println!(" uninstalled `{}@{}`", plugin, mp);
Ok(())
}
PluginCli::List => {
let items = installer::list_installed()?;
if items.is_empty() {
println!(" no installed plugins");
} else {
for p in items {
println!(" {}@{} {}", p.plugin, p.marketplace, p.plugin_dir);
}
}
Ok(())
}
}
}
/// Split `<plugin>@<marketplace>` into its two parts. Reject empty halves
/// up front so we surface a single clean error instead of letting the
/// installer reject `""` later with a confusing "not found" message.
fn parse_plugin_spec(s: &str) -> Result<(String, String)> {
let (plugin, mp) = s
.split_once('@')
.ok_or_else(|| anyhow::anyhow!("expected <plugin>@<marketplace>, got `{}`", s))?;
if plugin.trim().is_empty() || mp.trim().is_empty() {
anyhow::bail!("plugin/marketplace name must not be empty in `{}`", s);
}
Ok((plugin.trim().to_string(), mp.trim().to_string()))
}
/// CLI (non-TUI) upgrade driver — prints progress to stdout and
/// success/error messages the same way `install.sh` does.
async fn run_upgrade_cli(force: bool) -> Result<()> {
use atomcode_core::self_update::{self, UpgradeEvent, ALREADY_LATEST};
let current = format!("v{}", env!("CARGO_PKG_VERSION"));
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<UpgradeEvent>();
// Spawn the driver; consume events on the main task so stdout
// writes don't interleave unpredictably with the upgrade work.
let driver = tokio::spawn(self_update::run_upgrade(current.clone(), force, tx));
let mut last_pct: i32 = -1;
while let Some(ev) = rx.recv().await {
match ev {
UpgradeEvent::ManifestFetched { version } => {
println!("==> Latest: {}", version);
}
UpgradeEvent::Downloading { bytes, total } => {
// Debounce to whole percents so we don't spam stdout —
// piping the CLI through `tee` with 10k updates is no
// fun for anyone.
let pct = if total == 0 {
0
} else {
((bytes * 100) / total) as i32
};
if pct != last_pct {
print!(
"\r downloading {}% ({} / {} bytes) ",
pct, bytes, total
);
io::stdout().flush().ok();
last_pct = pct;
}
}
UpgradeEvent::Verifying => {
println!("\n==> Verifying SHA256");
}
UpgradeEvent::Replacing => {
println!("==> Replacing binary");
}
UpgradeEvent::Done {
version,
backup,
exe: _,
} => {
println!(
"\n✓ Upgraded to {} (previous version kept at {})",
version,
backup.display()
);
println!(" Run `atomcode` to start the new version.");
}
// CLI path never spawns a rollback via this channel and the
// driver below translates errors into the returned Result
// (not a Failed event) — these arms exist only to keep the
// match exhaustive if the TUI path ever reuses this code.
UpgradeEvent::Failed(msg) => {
if msg.contains(atomcode_core::self_update::PACKAGE_MANAGED) {
println!(
"\n{}",
atomcode_core::i18n::t(
atomcode_core::i18n::Msg::UpgradePackageManaged
)
);
} else {
eprintln!("\nupgrade failed: {}", msg);
}
}
UpgradeEvent::RolledBack { exe, backup } => {
println!(
"\n✓ Rolled back. exe={}, backup={}",
exe.display(),
backup.display()
);
}
}
}
match driver.await {
Ok(Ok(_summary)) => Ok(()),
Ok(Err(e)) => {
let msg = format!("{:#}", e);
if msg.contains(atomcode_core::self_update::PACKAGE_MANAGED) {
println!(
"{}",
atomcode_core::i18n::t(atomcode_core::i18n::Msg::UpgradePackageManaged)
);
Ok(())
} else if msg.contains(ALREADY_LATEST) {
// Friendly path — not an error, just "nothing to do".
println!(" {}", msg.replace(&format!("{}: ", ALREADY_LATEST), ""));
Ok(())
} else {
Err(e)
}
}
Err(e) => Err(anyhow::anyhow!("upgrade task panicked: {}", e)),
}
}
fn run_rollback_cli() -> Result<()> {
let summary = match atomcode_core::self_update::run_rollback() {
Ok(s) => s,
Err(e) => {
let msg = format!("{:#}", e);
if msg.contains(atomcode_core::self_update::PACKAGE_MANAGED) {
println!(
"{}",
atomcode_core::i18n::t(atomcode_core::i18n::Msg::UpgradePackageManaged)
);
return Ok(());
}
return Err(e);
}
};
println!(
"✓ Rolled back. Previous binary is now at {}, other version saved at {}",
summary.exe.display(),
summary.backup.display()
);
println!(" Run `atomcode` to start the rolled-back version.");
Ok(())
}
/// Core CodingPlan flow shared by CLI-exit and CLI→TUI paths. Loads
/// the config (or starts from defaults if missing), runs the shared
/// `coding_plan::setup` orchestrator, persists the config on success,
/// and returns the rendered human-readable report — the caller decides
/// whether to print it to stdout or stash it for the TUI to surface.
fn run_codingplan_core(
telemetry: Option<&std::sync::Arc<atomcode_telemetry::Telemetry>>,
) -> Result<String> {
let path = Config::default_path();
// Missing config is legitimate on first install — start from defaults
// so the flow can still add AtomGit providers to a fresh config.toml.
let mut config = match Config::load(&path) {
Ok(c) => c,
Err(_) => Config {
default_provider: String::new(),
default_workdir: None,
providers: std::collections::HashMap::new(),
datalog: Default::default(),
auto_update: true,
notifications: Default::default(),
telemetry: Default::default(),
lsp: Default::default(),
auto_commit: false,
subagent: Default::default(),
vision_preprocessor_provider: None,
language: None,
ui: Default::default(),
plugin: Default::default(),
},
};
// If the stored token is locally valid (file present, expires_in
// not yet past) but the server rejects it (revoked, refresh-token
// dead, etc.), the orchestrator sets `report.auth_expired = true`.
// Run OAuth *once* on that path — same flow `atomcode login` would
// use — then re-run setup against the fresh token. Without this
// the user sees the report ending in "claim failed — run `atomcode
// login` again" and has to do manually what `codingplan` could
// do itself.
let mut report = atomcode_core::coding_plan::run(&mut config, telemetry)?;
if report.auth_expired {
use atomcode_core::i18n::{t, Msg};
print!("{}", t(Msg::CpReauthAfter401));
match atomcode_core::auth::login(telemetry)
.and_then(|auth| atomcode_core::auth::save_auth(&auth).map(|_| auth))
{
Ok(_) => {
report = atomcode_core::coding_plan::run(&mut config, telemetry)?;
}
Err(e) => {
// Re-OAuth itself failed (user pressed Ctrl+C, network
// dead, etc.). Print the *original* report so users
// still see what triggered the retry, then bail.
println!("{}", report.render());
anyhow::bail!("re-authentication failed: {:#}", e);
}
}
}
if report.should_persist_config() {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Err(e) = config.save(&path) {
eprintln!(" ⚠ Failed to save config to {}: {:#}", path.display(), e);
}
// Stamp the sync marker alongside the config write. The drift
// monitor on the TUI side reads this to decide whether to warn
// about stale provider lists (> 24h + server drift). A failed
// marker write is non-fatal — the config already landed; only
// the 24h hint would be miscounted, which self-corrects on the
// next successful run.
if let Err(e) = atomcode_core::coding_plan::write_last_sync_now() {
eprintln!(" ⚠ Failed to write codingplan sync marker: {:#}", e);
}
}
Ok(report.render())
}
/// Guard so the two-link panic-hook chain (pre-telemetry hook + telemetry-aware
/// hook that chains to it) writes the crash log exactly once.
static CRASH_LOGGED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// Synchronously append a panic's location + message + backtrace to
/// `~/.atomcode/logs/panic.log`, then `flush` + `sync_all` so the bytes are
/// durable **before** the hook returns and the runtime calls `abort()`
/// (`panic = "abort"` in the release profile).
///
/// Why this exists: with `abort`, neither of the existing report paths
/// survives a crash — stderr is lost when the terminal window closes (Windows
/// resize crash), and `Telemetry::track` is async (mpsc → background tokio
/// writer), so abort kills the writer mid-segment and the `.partial` queue file
/// is discarded. A blocking, fsync'd file write is the only sink that survives.
/// Best-effort: every step swallows errors so the hook never re-panics.
fn write_crash_log(info: &std::panic::PanicHookInfo<'_>) {
use std::io::Write;
use std::sync::atomic::Ordering;
if CRASH_LOGGED.swap(true, Ordering::SeqCst) {
return;
}
let Some(home) = atomcode_core::tool::real_home_dir() else {
return;
};
let dir = home.join(".atomcode").join("logs");
let _ = std::fs::create_dir_all(&dir);
let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(dir.join("panic.log"))
else {
return;
};
let loc = info
.location()
.map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column()))
.unwrap_or_else(|| "unknown".into());
let msg = info
.payload()
.downcast_ref::<&str>()
.map(|s| s.to_string())
.or_else(|| info.payload().downcast_ref::<String>().cloned())
.unwrap_or_default();
let thread = std::thread::current().name().unwrap_or("unknown").to_string();
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
// `force_capture` ignores RUST_BACKTRACE and always resolves frames.
let bt = std::backtrace::Backtrace::force_capture();
let _ = writeln!(f, "\n==== AtomCode panic @ unix:{ts} thread:{thread} ====");
let _ = writeln!(f, "location: {loc}");
let _ = writeln!(f, "message : {msg}");
let _ = writeln!(f, "backtrace:\n{bt}");
let _ = f.flush();
let _ = f.sync_all();
}
/// Install the telemetry-aware panic hook. Replaces the minimal pre-init hook
/// set in `main()` so panics are both reported cleanly to the terminal AND
/// sent as a `Panic` telemetry event before the process exits.
fn install_panic_hook(telemetry: std::sync::Arc<atomcode_telemetry::Telemetry>) {
let default_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
// Durable crash log FIRST — before restore_terminal / async telemetry /
// abort, any of which can lose the panic on Windows (see write_crash_log).
write_crash_log(info);
restore_terminal_if_tui();
let home = atomcode_core::tool::real_home_dir();
let cwd = std::env::current_dir().ok();
let loc = info
.location()
.map(|l| format!("{}:{}", l.file(), l.line()))
.unwrap_or_else(|| "unknown".into());
let msg = info
.payload()
.downcast_ref::<&str>()
.map(|s| s.to_string())
.or_else(|| info.payload().downcast_ref::<String>().cloned())
.unwrap_or_default();
let bt = std::backtrace::Backtrace::force_capture().to_string();
let scrubbed_loc =
atomcode_telemetry::scrub::scrub_path(&loc, home.as_deref(), cwd.as_deref());
let scrubbed_msg = atomcode_telemetry::scrub::truncate_head(
&atomcode_telemetry::scrub::scrub_path(&msg, home.as_deref(), cwd.as_deref()),
atomcode_telemetry::scrub::HEAD_MAX,
);
let frames =
atomcode_telemetry::scrub::backtrace_top_k(&bt, 5, home.as_deref(), cwd.as_deref());
telemetry.track(atomcode_telemetry::Event::Panic {
location: scrubbed_loc,
message_head: scrubbed_msg,
thread: std::thread::current().name().unwrap_or("unknown").into(),
backtrace_top_5: frames,
error_kind: Some("panic".to_string()),
error_data: Some(
serde_json::json!({
"session_duration_secs": telemetry.uptime().as_secs() as u32,
"turns_completed": null,
"last_tool_name": null,
"last_event": null,
})
.to_string(),
),
});
default_hook(info);
}));
}
/// Classify a `create_provider` error display string as an "auth gap" —
/// i.e. the user has no usable OAuth token (file missing, malformed,
/// access_token expired, refresh_token expired/missing, refresh network
/// error, etc.). Auth-gap errors must NOT abort startup; they should
/// fall back to the onboarding TUI so the user can run `/login` from
/// within the app.
///
/// Implementation note — substring matching, not typed errors:
///
/// `create_provider` returns `anyhow::Error` and the auth-gap producers
/// live in three modules (`provider::mod::load_auth_token`,
/// `provider::mod::refresh_and_save`, `auth::oauth::get_valid_token`).
/// Threading a typed sentinel through all of them is invasive; instead
/// we match on a stable convention: **every auth-gap error message in
/// the codebase ends with the substring `"/login"`** (either
/// `"please /login"` or `"please use /login"`). That hint is part of
/// the user-facing contract — any future auth-gap producer that wants
/// graceful fallback simply has to follow the same convention.
///
/// The three legacy substrings (`Not logged in` / `Invalid auth.toml`
/// / `Token expired`) are retained as belt-and-braces: they were the
/// original matches before the `"/login"` rule was extracted, and
/// keeping them ensures the test for the historical contract stays
/// green even if some future refactor temporarily strips the `/login`
/// suffix from a specific message.
///
/// Non-auth errors (config parse failure, network issues unrelated to
/// the refresh endpoint, provider validation rejects model name, etc.)
/// fall through to `return Err(e)` and abort startup as designed.
fn is_auth_gap_error(msg: &str) -> bool {
msg.contains("/login")
|| msg.contains("Not logged in")
|| msg.contains("Invalid auth.toml")
|| msg.contains("Token expired")
}
#[cfg(test)]
mod tests {
use super::{
close_thinking_chunk, format_thinking_chunk, is_auth_gap_error, resolve_working_dir,
truncate_log_line,
};
use std::path::PathBuf;
#[test]
fn ascii_short_unchanged() {
assert_eq!(truncate_log_line("hello", 10), "hello");
}
#[test]
fn ascii_long_truncated_with_ellipsis() {
assert_eq!(truncate_log_line("0123456789abcdef", 10), "0123456789...");
}
#[test]
fn newlines_become_spaces() {
assert_eq!(truncate_log_line("a\nb\nc", 10), "a b c");
}
#[test]
fn mixed_ascii_cjk_truncates_at_char_boundary() {
// 8 chars: ['a','b','c','计','算','d','e','f']; max 5 → "abc计算..."
assert_eq!(truncate_log_line("abc计算def", 5), "abc计算...");
}
/// Regression test for panic at `crates/atomcode-cli/src/main.rs:272:42`:
/// "byte index 500 is not a char boundary; it is inside '计' (bytes 498..501)".
/// Triggered when ToolCallResult output was a CJK-heavy string > 500 bytes
/// and the old code did `trimmed[..500]` (byte slice). Pure CJK at 3 bytes
/// per char means almost any 500-byte cut lands inside a multi-byte char.
#[test]
fn cjk_truncation_does_not_panic() {
let s: String = "计算".repeat(500); // 1000 chars, 3000 bytes
let result = truncate_log_line(&s, 500);
assert_eq!(result.chars().count(), 503); // 500 + "..."
assert!(result.ends_with("..."));
}
/// Regression test for cwd-override bug: when no `-C` is given, working dir
/// must equal `std::env::current_dir()`. Old code silently substituted the
/// first line of `~/.atomcode/recent_dirs.txt`, breaking `atomcode -p` from
/// any directory that wasn't the TUI's last-visited project.
#[test]
fn resolve_working_dir_uses_cwd_when_no_cli_dir() {
let expected = std::env::current_dir().unwrap();
assert_eq!(resolve_working_dir(None), expected);
}
#[test]
fn resolve_working_dir_honors_cli_dir() {
let temp = std::env::temp_dir();
let canon = std::fs::canonicalize(&temp).unwrap_or(temp.clone());
assert_eq!(resolve_working_dir(Some(temp)), canon);
}
#[test]
fn resolve_working_dir_falls_back_to_input_when_canonicalize_fails() {
// Use a non-existent path so canonicalize() returns Err and the
// function falls back to the raw input rather than panicking.
let bogus = PathBuf::from("/nonexistent/atomcode-test-path-xyzzy");
assert_eq!(resolve_working_dir(Some(bogus.clone())), bogus);
}
/// Verify that std::fs::read_to_string reads a temp file correctly,
/// which is the core of --prompt-file. This is a unit-level stand-in for
/// the integration test (full CLI parse requires a running provider).
#[test]
fn prompt_file_read_preserves_trailing_newline() {
use std::io::Write as _;
let path = std::env::temp_dir().join("atomcode_test_prompt_file.txt");
let content = "fix the bug\n";
{
let mut f = std::fs::File::create(&path).unwrap();
f.write_all(content.as_bytes()).unwrap();
}
let read_back = std::fs::read_to_string(&path).unwrap();
std::fs::remove_file(&path).ok();
assert_eq!(
read_back, content,
"--prompt-file must preserve trailing newline (unlike bash $(...))"
);
}
// ---- format_thinking_chunk / close_thinking_chunk ----
//
// Regression suite for the "one word per line" bug in headless verbose
// output. The old `eprintln!("[thinking] {}", text)` printed a fresh
// prefix + trailing newline for every streaming chunk, so a streaming
// reasoning model produced output like:
//
// [thinking] are
// [thinking] already
// [thinking] configured
//
// The new formatter must keep a single line open across many tiny
// chunks until something else (text, tool call, turn complete, etc.)
// explicitly closes it.
/// Streaming many single-token chunks must produce ONE line, not N.
#[test]
fn thinking_chunks_stream_onto_single_line() {
let mut buf = String::new();
let mut open = false;
for tok in ["are", " already", " configured", " and", " what"] {
format_thinking_chunk(&mut buf, &mut open, tok);
}
assert_eq!(buf, "[thinking] are already configured and what");
assert!(open, "line should remain open until something closes it");
}
/// A non-reasoning event must close the line with a single newline.
#[test]
fn close_appends_newline_and_clears_open() {
let mut buf = String::new();
let mut open = false;
format_thinking_chunk(&mut buf, &mut open, "hello");
close_thinking_chunk(&mut buf, &mut open);
assert_eq!(buf, "[thinking] hello\n");
assert!(!open);
// Closing again is a no-op (idempotent).
close_thinking_chunk(&mut buf, &mut open);
assert_eq!(buf, "[thinking] hello\n");
}
/// Embedded newlines inside a chunk must produce a re-prefixed next line.
#[test]
fn embedded_newline_reprefixes_next_line() {
let mut buf = String::new();
let mut open = false;
format_thinking_chunk(&mut buf, &mut open, "first line\nsecond line");
assert_eq!(buf, "[thinking] first line\n[thinking] second line");
assert!(open);
}
/// A chunk ending with `\n` closes the line; the next chunk must
/// re-introduce the `[thinking] ` prefix.
#[test]
fn trailing_newline_closes_and_next_chunk_reprefixes() {
let mut buf = String::new();
let mut open = false;
format_thinking_chunk(&mut buf, &mut open, "para1\n");
assert!(!open, "trailing newline should close the line");
format_thinking_chunk(&mut buf, &mut open, "para2");
assert_eq!(buf, "[thinking] para1\n[thinking] para2");
assert!(open);
}
/// Empty chunks must be skipped without emitting a stray prefix.
#[test]
fn empty_chunk_is_noop() {
let mut buf = String::new();
let mut open = false;
format_thinking_chunk(&mut buf, &mut open, "");
assert_eq!(buf, "");
assert!(!open);
// Still no prefix after empty input.
format_thinking_chunk(&mut buf, &mut open, "x");
assert_eq!(buf, "[thinking] x");
}
/// CJK content (common in Chinese reasoning models) must not break the
/// single-line invariant — every char-level chunk just appends.
#[test]
fn cjk_chunks_stream_correctly() {
let mut buf = String::new();
let mut open = false;
for tok in ["先", "看", "看", "你", "当前的", "环境"] {
format_thinking_chunk(&mut buf, &mut open, tok);
}
assert_eq!(buf, "[thinking] 先看看你当前的环境");
}
/// Simulated end-to-end event sequence: thinking deltas, then a tool
/// call. The tool call must appear on its OWN line, not mashed onto
/// the tail of the thinking text.
#[test]
fn thinking_followed_by_tool_call_is_separated() {
let mut buf = String::new();
let mut open = false;
format_thinking_chunk(&mut buf, &mut open, "I should");
format_thinking_chunk(&mut buf, &mut open, " check");
format_thinking_chunk(&mut buf, &mut open, " the file");
// Now a non-reasoning event arrives → close, then emit it.
close_thinking_chunk(&mut buf, &mut open);
buf.push_str("[tool→ read_file]\n");
assert_eq!(
buf,
"[thinking] I should check the file\n[tool→ read_file]\n"
);
}
// ── is_auth_gap_error ────────────────────────────────────────────
//
// Regression set for the "both access_token AND refresh_token
// expired → CLI bails before TUI loads" bug. Pre-fix catch list
// only matched `Not logged in` / `Invalid auth.toml` / `Token
// expired`, missing the 4 paths below — user landed in an
// unrecoverable startup error because they couldn't get into the
// TUI to run `/login`. Post-fix every auth-gap error's `/login`
// suffix triggers graceful fallback to onboarding mode.
#[test]
fn auth_gap_catches_historical_three() {
// Pre-existing matches — must stay green.
assert!(is_auth_gap_error("Not logged in — please use /login"));
assert!(is_auth_gap_error("Invalid auth.toml — please use /login"));
assert!(is_auth_gap_error("Token expired — please use /login"));
}
#[test]
fn auth_gap_catches_refresh_http_failure() {
// The actual user-reported scenario: access_token expired,
// `refresh_and_save` POSTs the (also-expired) refresh_token to
// the broker, broker returns 401, `provider::mod::refresh_and_save`
// bails with this message. Pre-fix catch list MISSED this —
// none of the 3 historical substrings appeared, so CLI fell
// through to `return Err(e)` and aborted startup.
assert!(is_auth_gap_error(
"Token refresh failed (401 Unauthorized) — please /login"
));
assert!(is_auth_gap_error(
"Token refresh failed (400 Bad Request) — please /login"
));
}
#[test]
fn auth_gap_catches_refresh_network_failure() {
// `refresh_and_save` network-layer error path (broker host
// unreachable, DNS failure, TLS error, etc.). Same root cause
// as the HTTP path — token can't be refreshed → user needs
// `/login`. Onboarding TUI is the only way they can run it.
assert!(is_auth_gap_error(
"Token refresh failed: connection refused — please /login"
));
}
#[test]
fn auth_gap_catches_refresh_parse_failure() {
// Broker returned 200 but body wasn't valid refresh-token
// JSON. Treated as auth gap because there's no usable token
// either way.
assert!(is_auth_gap_error(
"Token refresh parse error: missing field `access_token` — please /login"
));
}
#[test]
fn auth_gap_catches_missing_refresh_token() {
// `auth/oauth.rs::refresh_access_token` when stored auth.toml
// has access_token but no refresh_token field. Suffix is
// "please /login again" — still ends in `/login`, so the new
// substring rule catches it.
assert!(is_auth_gap_error(
"No refresh_token available — please /login again"
));
}
#[test]
fn auth_gap_does_not_swallow_non_auth_errors() {
// Non-auth errors must NOT trigger the onboarding fallback —
// the user should see the real failure, not a generic "please
// /login" prompt that doesn't apply.
assert!(!is_auth_gap_error(
"Config parse error: invalid TOML at line 12"
));
assert!(!is_auth_gap_error(
"Provider rejected model name 'foo-bar-9000': unknown model"
));
assert!(!is_auth_gap_error(
"HTTP request to https://api.example.com failed: timeout"
));
assert!(!is_auth_gap_error(""));
}
}