crates/tui/src/lib.rs
Rama main · réplica de lectura del repositorio original.
//! Codewhale TUI library — single-binary entry point.
#![allow(
clippy::large_enum_variant,
clippy::too_many_arguments,
clippy::uninlined_format_args
)]
use codewhale_engine::runtime_options::RuntimeOptions;
use codewhale_engine::{
fleet, pricing, project_context, receipts, runtime_threads, session_export,
session_image_prune, session_manager, session_secret_scrub, task_manager,
};
use codewhale_runtime::config;
use std::collections::BTreeSet;
use std::io::{self, IsTerminal, Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::Arc;
use std::time::{Duration, Instant};
use anyhow::{Context, Result, anyhow, bail};
use clap::{Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum};
use tempfile::NamedTempFile;
use wait_timeout::ChildExt;
use codewhale_dependencies::ExternalTool;
mod commands;
mod composer_history;
mod composer_stash;
#[cfg(test)]
mod conformance;
mod context_report;
/// Guarded installation I/O reusing Engine's confined file primitives.
mod doctor;
mod doctor_fix;
use codewhale_engine::fleet::executor::exec_stream_final_answer_excerpt;
mod integrations;
#[cfg(test)]
mod plugins_dsh_tests;
#[cfg(test)]
mod plugins_provider_tests;
#[cfg(test)]
mod plugins_skill_tests;
#[cfg(test)]
mod sandbox_stay_tests;
// Acceptance matrix for #2934 / #4397. Test-only: the table documents the
// contract for reviewers and is enforced by the tests beside it, so it does
// not need to exist in a shipped binary.
#[cfg(test)]
#[path = "main/tests.rs"]
mod doctor_loader_tests;
#[cfg(test)]
mod session_control_acceptance;
#[cfg(test)]
mod test_support;
mod tui;
/// Portable, dependency-free dot-whale core and conformance helpers.
/// The terminal and external renderers share this implementation.
pub use tui::ambient_life::pet_sim as pet;
pub mod whats_new;
use crate::tui::app::ScreenMode;
use codewhale_app_server::eval::EvalHarness;
use codewhale_app_server::eval::EvalHarnessConfig;
use codewhale_app_server::eval::ScenarioStepKind;
use codewhale_app_server::exec_agent::{
CliAutoRoute, ExecOutputFormat, ExecStreamEvent, ExecStreamInputAnalysis, ExecStreamMeta,
ONE_SHOT_DEFAULT_MAX_STEPS, admit_cli_saved_operation, cli_reasoning_effort_value,
config_for_cli_route, emit_exec_stream_event, exec_max_steps, exec_stream_provider_route,
exec_stream_session_ref, paths_equal_for_config, resolve_cli_exec_route, run_exec_agent,
start_binary_sha256, tool_artifact_receipt, validate_exec_tool_authority_resume,
};
use codewhale_config::descriptors::defaults::DEFAULT_TEXT_MODEL;
use codewhale_engine::llm_client::LlmClient;
use codewhale_engine::mcp::{McpCommandAvailability, McpPool};
#[cfg(test)]
use codewhale_engine::session_manager::create_saved_session;
use codewhale_engine::session_manager::{SessionManager, truncate_id};
use codewhale_mcp::config::{McpServerConfig, McpServerOAuthConfig, is_relative_stdio_path_arg};
use codewhale_models::Role;
#[cfg(test)]
use codewhale_models::{ContentBlock, Message, SystemPrompt};
use codewhale_runtime::child_env;
use codewhale_runtime::config::{
Config, DEFAULT_MAX_SUBAGENTS, MAX_SUBAGENTS, initialize_cloud_facts,
};
use codewhale_runtime::features::{Feature, render_feature_table};
use codewhale_runtime::{logging, startup_trace};
#[cfg(windows)]
fn configure_windows_console_utf8() {
use windows::Win32::System::Console::{SetConsoleCP, SetConsoleOutputCP};
const CP_UTF8: u32 = 65001;
// SAFETY: integer argument only; failures discarded.
unsafe {
let _ = SetConsoleCP(CP_UTF8);
let _ = SetConsoleOutputCP(CP_UTF8);
}
}
#[cfg(not(windows))]
fn configure_windows_console_utf8() {}
fn install_rustls_crypto_provider() {
codewhale_release::tls::ensure_rustls_crypto_provider();
}
// The canonical executable owns process arguments. This decoder handles only
// delegated Engine commands and their command-specific interactive flags.
#[derive(Parser, Debug)]
#[command(name = "codewhale", bin_name = "codewhale", version = env!("CODEWHALE_BUILD_VERSION"))]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
#[command(flatten)]
options: RuntimeOptions,
/// Initial prompt to submit in the interactive TUI.
#[arg(short, long, value_name = "PROMPT", num_args = 1..)]
prompt: Vec<String>,
/// Resume a previous session by ID or prefix.
#[arg(short, long, conflicts_with = "continue_session")]
resume: Option<String>,
/// Continue the most recent session in this workspace.
#[arg(short = 'c', long = "continue", conflicts_with = "resume")]
continue_session: bool,
/// Recover the exact durable Resume/Fork intent without repeating its mutation.
#[arg(long, global = true, value_name = "KEY")]
operation_key: Option<String>,
}
impl std::ops::Deref for Cli {
type Target = RuntimeOptions;
fn deref(&self) -> &Self::Target {
&self.options
}
}
#[derive(Subcommand, Debug, Clone)]
#[allow(clippy::large_enum_variant)]
enum Commands {
/// Run system diagnostics and check configuration
Doctor(DoctorArgs),
/// Summarize failure signals from a local JSONL session log without raw content
SessionDiagnostics(SessionDiagnosticsArgs),
#[command(about = "Install a local, GitHub or version-pinned npm plugin bundle")]
Install { source: String },
#[command(about = "Inspect or restage reviewed plugin bundles")]
Plugins {
#[command(subcommand)]
command: PluginsCommand,
},
/// Bootstrap MCP config and/or skills directories
Setup(SetupArgs),
/// Generate a remote Codewhale agent deploy bundle (cloud + chat bridge)
RemoteSetup(codewhale_app_server::remote_setup::RemoteSetupArgs),
/// List saved sessions, or export one as a full-fidelity archive
Sessions {
/// Maximum number of sessions to display
#[arg(short, long, default_value = "20")]
limit: usize,
/// Search sessions by title
#[arg(short, long)]
search: Option<String>,
#[command(subcommand)]
command: Option<SessionsCommand>,
},
/// Show what a session did: files changed, commands run, web and MCP
/// calls, agents, approvals, and failures, read from its saved record
#[command(visible_alias = "receipt")]
Receipts {
/// Session id or unique prefix, or a Runtime thread id (thr_...).
/// Omit it (or pass --last) for the most recently updated one.
#[arg(value_name = "SESSION_ID")]
id: Option<String>,
/// Use the most recently updated session or thread
#[arg(long, conflicts_with = "id")]
last: bool,
/// Limit to one turn: a thread's turn id, or a session's turn number
#[arg(long, value_name = "TURN")]
turn: Option<String>,
/// Output format
#[arg(long, value_enum, default_value = "md")]
format: receipts::ReceiptFormat,
},
/// Create default AGENTS.md in current directory
Init,
/// Sign in to your Codewhale account (use the `codewhale` CLI).
Login {
/// Legacy provider-key flag: rejected with a redirect to `auth set`.
#[arg(long, hide = true)]
api_key: Option<String>,
},
/// Remove the saved API key
Logout,
/// Manage provider authentication flows.
Auth(TuiAuthArgs),
/// List cached models, or update catalogs for configured providers
Models(ModelsArgs),
/// Generate speech audio with Xiaomi MiMo TTS models
#[command(visible_alias = "tts")]
Speech(SpeechArgs),
/// Run a non-interactive prompt. Use --auto for agent-with-tools mode.
Exec(ExecArgs),
/// Manage local Agent fleet runs and workers (`fleet` is a compatibility alias)
#[command(name = "fleet")]
Fleet(FleetArgs),
/// Internal model-free Workflow tool dispatcher used by Lane Runtime.
#[command(name = "workflow-tool", hide = true)]
WorkflowTool(WorkflowToolArgs),
/// Run a code review over a git diff
Review(ReviewArgs),
/// Open the TUI pre-seeded with a GitHub PR's title, body, and diff
Pr {
/// PR number
#[arg(value_name = "NUMBER")]
number: u32,
/// Repository in `owner/name` form. Defaults to the current
/// workspace's `gh` config (i.e. the repo gh thinks you're in).
#[arg(short = 'R', long)]
repo: Option<String>,
/// Skip `gh pr checkout` even if gh is available. By default
/// the working tree is left as-is — checkout is opt-in via
/// `--checkout` because dirty trees fail it loudly.
#[arg(long, default_value_t = false)]
checkout: bool,
},
/// Apply a patch file (or stdin) to the working tree
Apply(ApplyArgs),
/// Run the offline evaluation harness (no network/LLM calls)
Eval(EvalArgs),
/// Score a run's token/cache/cost from recorded turns; flag regressions vs a baseline
Scorecard(ScorecardArgs),
/// Manage MCP servers
Mcp {
#[command(subcommand)]
command: McpCommand,
},
/// Inspect feature flags
Features(FeaturesCli),
/// Connect third-party harnesses through Codewhale (currently: DeepSeek Harness `dsh`)
Integrations {
#[command(subcommand)]
command: IntegrationsCommand,
},
/// Run a command inside the sandbox
Sandbox(SandboxArgs),
/// Run a local server (e.g. MCP)
Serve(ServeArgs),
/// Resume a previous session by ID (use --last for most recent)
Resume {
/// Conversation/session id (UUID or prefix)
#[arg(value_name = "SESSION_ID")]
session_id: Option<String>,
/// Continue the most recent session in this workspace without a picker
#[arg(long = "last", default_value_t = false, conflicts_with = "session_id")]
last: bool,
},
/// Fork a previous session by ID (use --last for most recent)
Fork {
/// Conversation/session id (UUID or prefix)
#[arg(value_name = "SESSION_ID")]
session_id: Option<String>,
/// Fork the most recent session in this workspace without a picker
#[arg(long = "last", default_value_t = false, conflicts_with = "session_id")]
last: bool,
},
}
#[derive(Subcommand, Debug, Clone)]
enum PluginsCommand {
#[command(about = "Inspect plugin status and diagnostics", alias = "list")]
Status {
#[arg(value_name = "NAME")]
name: Option<String>,
#[arg(long)]
json: bool,
},
#[command(about = "Restore an eligible reviewed plugin snapshot")]
Restage {
#[arg(value_name = "NAME")]
name: String,
#[arg(long)]
json: bool,
},
}
/// Subcommands of `codewhale sessions`. Without one, the command falls back
/// to listing sessions.
#[derive(Subcommand, Debug, Clone)]
enum SessionsCommand {
/// List saved sessions (default when no subcommand is given)
List {
/// Maximum number of sessions to display
#[arg(short, long, default_value = "20")]
limit: usize,
/// Search sessions by title
#[arg(short, long)]
search: Option<String>,
},
/// Mask credentials that older builds stored in saved sessions' tool
/// output. Reports what it would change unless `--apply` is given. Run it
/// while no Codewhale session is open.
ScrubSecrets {
/// Rewrite the affected session files (default: report only)
#[arg(long, default_value_t = false)]
apply: bool,
},
/// Delete pasted clipboard images that no saved session, draft or history
/// still names. Reports what it would delete unless `--apply` is given.
/// Run it while no Codewhale session is open.
PruneImages {
/// Delete the listed images (default: report only)
#[arg(long, default_value_t = false)]
apply: bool,
/// Keep unreferenced images newer than this many days
#[arg(long, value_name = "DAYS", default_value_t = 7)]
older_than_days: u64,
},
/// Export a session as a full-fidelity tar.xz archive (complete context:
/// system prompt, messages, tool calls and results, plus artifacts)
Export {
/// Session id (or unambiguous id prefix) to export
#[arg(value_name = "SESSION_ID")]
id: String,
/// Destination .tar.xz path (default: codewhale-session-<id>.tar.xz)
#[arg(short, long, value_name = "PATH")]
output: Option<PathBuf>,
/// Exclude the session artifacts directory from the archive
#[arg(long, default_value_t = false)]
skip_artifacts: bool,
/// xz compression preset, 0 (fastest) through 9 (smallest)
#[arg(long, default_value_t = session_export::DEFAULT_XZ_COMPRESSION_LEVEL)]
compression: u32,
/// Overwrite the destination file if it already exists
#[arg(long, default_value_t = false)]
force: bool,
},
}
#[derive(Args, Debug, Clone)]
#[command(after_help = codewhale_localization::tr(
codewhale_localization::resolve_locale("auto"),
codewhale_localization::MessageId::ExecAfterHelp,
))]
struct ExecArgs {
/// Override model for this run
#[arg(long)]
model: Option<String>,
/// Override the provider for this run (e.g. `deepseek`, `openrouter`).
/// Non-secret identifier only — credentials still resolve from the
/// environment/config. Fleet uses this to launch a worker on its
/// profile-pinned provider even when the parent session is on another
/// one (#4093).
#[arg(long)]
provider: Option<String>,
/// Override reasoning/thinking effort for this run.
/// Accepted values: auto, off, low, medium, high, max.
#[arg(long = "reasoning-effort", value_name = "EFFORT")]
reasoning_effort: Option<String>,
#[arg(long, default_value_t = false, help = codewhale_localization::tr(
codewhale_localization::resolve_locale("auto"),
codewhale_localization::MessageId::ExecAutoHelp,
))]
auto: bool,
#[arg(long, value_name = "POLICY", help = codewhale_localization::tr(
codewhale_localization::resolve_locale("auto"),
codewhale_localization::MessageId::ExecSandboxHelp,
))]
sandbox: Option<String>,
/// Explicitly allow a denied tool to retry with danger-full-access.
#[arg(long, default_value_t = false)]
allow_sandbox_elevation: bool,
/// Emit machine-readable JSON output
#[arg(long, default_value_t = false, conflicts_with = "output_format")]
json: bool,
/// Resume a previous session by ID or prefix
#[arg(long, value_name = "SESSION_ID", conflicts_with_all = ["session_id", "continue_session"])]
resume: Option<String>,
/// Resume a previous session by ID or prefix
#[arg(long = "session-id", value_name = "SESSION_ID", conflicts_with_all = ["resume", "continue_session"])]
session_id: Option<String>,
/// Continue the most recent session for this workspace
#[arg(long = "continue", default_value_t = false, conflicts_with_all = ["resume", "session_id"])]
continue_session: bool,
/// Output format for exec mode
#[arg(long, value_enum, default_value_t = ExecOutputFormat::Text)]
output_format: ExecOutputFormat,
/// Comma-separated list of canonical tools to allow (all others denied).
/// Names are case-insensitive: Bash, File, Git, Run, etc.
#[arg(long, value_delimiter = ',')]
allowed_tools: Option<Vec<String>>,
/// Comma-separated list of tools to deny (deny wins over allow).
#[arg(long, value_delimiter = ',')]
disallowed_tools: Option<Vec<String>>,
/// Maximum number of model steps before the run ends. Omitted means unlimited.
#[arg(long, value_parser = clap::value_parser!(u32).range(1..))]
max_turns: Option<u32>,
/// Maximum number of tool calls admitted in one model turn. Omitted means unlimited.
#[arg(long, value_parser = clap::value_parser!(u32).range(1..))]
max_tool_calls: Option<u32>,
/// Shut down when the parent closes this process's stdin. Fleet workers
/// pass this automatically: a dead manager must not leave detached workers
/// spending forever (R7).
#[arg(long, default_value_t = false)]
parent_death_watch: bool,
/// Extra text appended to the system prompt for this run.
#[arg(long)]
append_system_prompt: Option<String>,
/// Internal Fleet worker authority envelope. Non-secret, versioned JSON.
#[arg(long, value_name = "JSON", hide = true)]
tool_authority_json: Option<String>,
/// Fire the configured hooks in this run (opt-in). On the headless path
/// the engine-side events are `tool_call_before` — which may still deny
/// a call — and `shell_env`. A hook `ask` resolves fail-closed because
/// nothing can prompt headlessly. Fleet worker subprocesses never fire
/// operator hooks.
#[arg(long, default_value_t = false)]
hooks: bool,
/// Run one answer with no tools. The default offers the normal tool
/// surface under the configured sandbox and approval posture.
#[arg(long, default_value_t = false)]
no_tools: bool,
/// Additional directory the file tools may read and write (repeatable).
#[arg(long = "add-dir", value_name = "PATH")]
add_dir: Vec<PathBuf>,
/// Read the prompt from a file (`-` reads stdin) instead of argv, for
/// prompts past the OS per-argument limit (~128 KiB on Linux).
#[arg(long, value_name = "PATH", conflicts_with = "prompt")]
prompt_file: Option<PathBuf>,
#[arg(
value_name = "PROMPT",
trailing_var_arg = true,
help = codewhale_localization::tr(
codewhale_localization::resolve_locale("auto"),
codewhale_localization::MessageId::ExecPromptHelp,
)
)]
prompt: Vec<String>,
}
fn parse_cli_args(args: &[impl AsRef<str>]) -> Result<Cli, clap::Error> {
let mut command = Cli::command();
let matches = command.try_get_matches_from_mut(args.iter().map(|arg| arg.as_ref()))?;
let cli = Cli::from_arg_matches(&matches)?;
if let Some(Commands::Exec(exec)) = &cli.command {
let prompt_start = args.len().saturating_sub(exec.prompt.len());
if !args[..prompt_start]
.last()
.is_some_and(|arg| arg.as_ref() == "--")
{
let command = command.find_subcommand_mut("exec").expect("exec command");
for word in exec.prompt.iter().take_while(|word| *word != "--") {
let word = ascii_flag_spelling(word);
let flag = word.split_once('=').map_or(word.as_str(), |(flag, _)| flag);
if flag.strip_prefix("--").is_some_and(|name| {
command.get_arguments().any(|arg| {
arg.get_long() == Some(name)
|| arg
.get_all_aliases()
.is_some_and(|aliases| aliases.contains(&name))
})
}) {
return Err(command.error(
clap::error::ErrorKind::UnknownArgument,
codewhale_localization::tr(
codewhale_localization::resolve_locale("auto"),
codewhale_localization::MessageId::ExecFlagAfterPrompt,
)
.replace("{flag}", flag),
));
}
}
}
}
Ok(cli)
}
fn ascii_flag_spelling(word: &str) -> String {
let typographic = |c: char| matches!(c, '\u{2010}'..='\u{2015}' | '\u{2212}' | '\u{FF0D}');
let folded: String = word
.chars()
.map(|c| match c {
'\u{FF01}'..='\u{FF5E}' => char::from_u32(c as u32 - 0xFEE0).unwrap_or(c),
c if typographic(c) => '-',
c => c,
})
.collect();
let single_typographic = word.chars().next().is_some_and(typographic)
&& !word.chars().nth(1).is_some_and(typographic);
if single_typographic && folded.len() > 1 {
format!("-{folded}")
} else {
folded
}
}
fn headless_exec_args(
resume: Option<String>,
continue_session: bool,
prompt: &[String],
) -> Result<ExecArgs> {
use clap::{Args as _, FromArgMatches as _};
let mut argv: Vec<String> = vec!["exec".to_string()];
if let Some(id) = &resume {
argv.push("--resume".to_string());
argv.push(id.clone());
}
if continue_session {
argv.push("--continue".to_string());
}
if !prompt.is_empty() {
argv.push("--".to_string());
argv.extend(prompt.iter().cloned());
}
let command = ExecArgs::augment_args(clap::Command::new("exec"));
let matches = command
.try_get_matches_from(argv)
.map_err(|error| anyhow!(error.to_string()))?;
ExecArgs::from_arg_matches(&matches).map_err(|error| anyhow!(error.to_string()))
}
#[derive(Args, Debug, Clone)]
struct WorkflowToolArgs {
/// Authority provenance stamped by the public `workflow run` command.
#[arg(long, value_name = "SOURCE")]
approval_source: String,
/// Exact Workflow tool input serialized as one JSON object.
#[arg(long, value_name = "JSON")]
input_json: String,
}
#[derive(Args, Debug, Clone)]
struct TuiAuthArgs {
#[command(subcommand)]
command: TuiAuthCommand,
}
#[derive(Subcommand, Debug, Clone)]
enum TuiAuthCommand {
/// Sign in to xAI/Grok with an SSH-friendly device code; run again to switch accounts.
#[command(name = "xai-device")]
XaiDevice,
/// Sign in with ChatGPT for Codex subscription access; run again to switch accounts.
#[command(name = "chatgpt")]
Chatgpt,
/// Revoke Codewhale-owned ChatGPT tokens. Codex CLI consent is unchanged.
#[command(name = "chatgpt-revoke")]
ChatgptRevoke,
/// Sign in to OrcaRouter with OAuth 2.0 + PKCE; run again to switch accounts.
#[command(name = "orcarouter")]
Orcarouter,
/// Revoke the saved OrcaRouter credential. The OrcaRouter console also
/// revokes every key it issued to this app in one click.
#[command(name = "orcarouter-revoke")]
OrcarouterRevoke,
/// Sign in to a provider contributed by an enabled, reviewed plugin.
PluginLogin {
#[arg(long)]
provider: String,
},
/// Remove credentials for one plugin provider without changing trust.
PluginLogout {
#[arg(long)]
provider: String,
},
}
const CODEWHALE_TOOL_SURFACE_ENV: &str = "CODEWHALE_TOOL_SURFACE";
const SHELL_ONLY_EXEC_TOOLS: &[&str] = &["bash"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ExecToolSurface {
ShellOnly,
}
fn exec_tool_surface_from_env() -> Option<ExecToolSurface> {
std::env::var(CODEWHALE_TOOL_SURFACE_ENV)
.ok()
.and_then(|value| {
if should_warn_unknown_exec_tool_surface(&value) {
eprintln!(
"warning: unrecognized {CODEWHALE_TOOL_SURFACE_ENV}; leaving exec tool surface unchanged. Use `shell-only`, `full`, or `native-tools`."
);
}
parse_exec_tool_surface(&value)
})
}
fn parse_exec_tool_surface(value: &str) -> Option<ExecToolSurface> {
match value.trim().to_ascii_lowercase().as_str() {
"shell-only" | "shell_only" | "shell" => Some(ExecToolSurface::ShellOnly),
"full" | "native-tools" | "native_tools" | "" => None,
_ => None,
}
}
fn should_warn_unknown_exec_tool_surface(value: &str) -> bool {
let normalized = value.trim().to_ascii_lowercase();
!matches!(
normalized.as_str(),
"" | "shell-only" | "shell_only" | "shell" | "full" | "native-tools" | "native_tools"
)
}
fn normalize_exec_tool_names(tools: &[String]) -> Vec<String> {
tools
.iter()
.map(|name| name.to_ascii_lowercase().trim().to_string())
.collect()
}
fn shell_only_exec_allowed_tools() -> Vec<String> {
SHELL_ONLY_EXEC_TOOLS
.iter()
.map(|name| (*name).to_string())
.collect()
}
/// Whether an `exec` run is offered tools. The default is the normal tool
/// surface, still approval-gated by the configured sandbox. `--no-tools` is
/// the explicit one-shot. A resumed session and a Fleet authority envelope
/// keep their own surface.
fn exec_grants_tool_surface(
args: &ExecArgs,
_yolo: bool,
_resuming: bool,
_env_tool_surface: bool,
) -> bool {
!args.no_tools
}
/// Flags that only shape a tool surface, passed on a run that has none.
fn exec_tool_flags_without_grant(args: &ExecArgs) -> Vec<&'static str> {
[
(args.max_tool_calls.is_some(), "--max-tool-calls"),
(args.disallowed_tools.is_some(), "--disallowed-tools"),
(args.sandbox.is_some(), "--sandbox"),
(args.allow_sandbox_elevation, "--allow-sandbox-elevation"),
(args.hooks, "--hooks"),
]
.into_iter()
.filter_map(|(passed, flag)| passed.then_some(flag))
.collect()
}
fn resolve_exec_allowed_tools(
cli_allowed_tools: Option<&[String]>,
env_tool_surface: Option<ExecToolSurface>,
) -> Option<Vec<String>> {
if let Some(tools) = cli_allowed_tools {
return Some(normalize_exec_tool_names(tools));
}
env_tool_surface.map(|ExecToolSurface::ShellOnly| shell_only_exec_allowed_tools())
}
#[derive(Args, Debug, Clone)]
struct FleetArgs {
#[command(subcommand)]
command: FleetCommand,
}
#[derive(Subcommand, Debug, Clone)]
enum FleetCommand {
/// Initialize the local fleet ledger for this workspace
Init,
/// Create a run from a task spec and start the foreground manager loop
Run(FleetRunArgs),
/// List durable fleet runs from this workspace's ledger
List,
/// Show queued/running/completed/failed/stale fleet counts
Status,
/// Inspect one worker's status, heartbeat, latest event, and artifacts
Inspect {
/// Worker id printed by `codewhale fleet run`
worker_id: String,
},
/// Print bounded log artifacts for one worker
Logs {
/// Worker id printed by `codewhale fleet run`
worker_id: String,
},
/// List artifact refs for one worker
Artifacts {
/// Worker id printed by `codewhale fleet run`
worker_id: String,
},
/// Interrupt a running worker task and record a terminal cancellation
Interrupt {
/// Worker id printed by `codewhale fleet run`
worker_id: String,
},
/// Restart the latest task for a worker
Restart {
/// Worker id printed by `codewhale fleet run`
worker_id: String,
},
/// Resume a run from durable ledger state, reconciling orphaned/stale leases
Resume {
/// Run id printed by `codewhale fleet run`
run_id: String,
/// Seconds without heartbeat before a leased task is treated as stale
#[arg(long, default_value_t = 300)]
stale_after_seconds: u64,
},
/// Stop all queued and running fleet work
Stop {
/// Confirm stopping all queued and running fleet tasks
#[arg(long, required = true)]
all: bool,
},
/// Render a redacted fleet alert payload without sending it
AlertDryRun(FleetAlertDryRunArgs),
}
#[derive(Args, Debug, Clone)]
struct FleetRunArgs {
/// JSON or TOML task spec to enqueue
#[arg(value_name = "TASK_SPEC")]
task_spec: PathBuf,
/// Maximum workers to lease concurrently
#[arg(long, default_value_t = 4)]
max_workers: usize,
#[arg(
long = "require-placement",
help = "Require environment placement support; older Engines refuse this flag"
)]
_require_placement: bool,
/// Seconds without heartbeat before a running task is counted stale
#[arg(long, default_value_t = 300)]
stale_after_seconds: u64,
/// Schedule once and return instead of staying in the manager loop
#[arg(long, hide = true, default_value_t = false)]
once: bool,
/// Validate the spec (shape, roster members, profiles, model routes)
/// without creating a run or starting any worker
#[arg(long, default_value_t = false)]
check: bool,
}
#[derive(Args, Debug, Clone)]
struct FleetAlertDryRunArgs {
/// Alert event class to render
#[arg(long, value_enum)]
event: FleetAlertEventArg,
/// fleet run id
#[arg(long)]
run_id: String,
/// Worker id, when the event belongs to one worker
#[arg(long)]
worker_id: Option<String>,
/// Task id, when the event belongs to one task
#[arg(long)]
task_id: Option<String>,
/// Short human-readable reason for the alert
#[arg(long, default_value = "manual fleet alert dry-run")]
reason: String,
/// Status label to include in the payload
#[arg(long)]
status: Option<String>,
/// Adapter payload shape to render
#[arg(long, value_enum, default_value_t = FleetAlertAdapterArg::Slack)]
adapter: FleetAlertAdapterArg,
/// Environment variable containing the Slack webhook URL
#[arg(long, default_value = "CODEWHALE_FLEET_SLACK_WEBHOOK")]
slack_webhook_env: String,
/// Environment variable containing the generic webhook URL
#[arg(long, default_value = "CODEWHALE_FLEET_WEBHOOK_URL")]
webhook_url_env: String,
/// Optional environment variable containing the generic webhook secret
#[arg(long)]
webhook_secret_env: Option<String>,
/// Environment variable containing the PagerDuty routing key
#[arg(long, default_value = "CODEWHALE_FLEET_PAGERDUTY_ROUTING_KEY")]
pagerduty_routing_key_env: String,
/// PagerDuty severity to render
#[arg(long, default_value = "error")]
pagerduty_severity: String,
}
#[derive(ValueEnum, Debug, Clone, Copy)]
enum FleetAlertEventArg {
Stale,
RestartExhausted,
NeedsHuman,
BudgetExceeded,
VerifierFailed,
RunCompleted,
}
#[derive(ValueEnum, Debug, Clone, Copy)]
enum FleetAlertAdapterArg {
Slack,
Webhook,
PagerDuty,
}
/// Spawn a tokio task that listens for terminating signals (SIGINT, SIGTERM
/// and SIGHUP on Unix; Ctrl+C, Ctrl+Break and console close on Windows), gives
/// the interactive owner and persistence actor a bounded recovery window,
/// restores terminal modes and exits with the signal cancellation code.
/// Multiple deliveries are tolerated: once the cleanup runs, a second
/// signal short-circuits to plain exit so a stuck cleanup can never
/// trap a frustrated user pressing Ctrl+C repeatedly.
///
/// See the call site in `main` for the rationale (#1583).
///
/// Registration is synchronous, before the spawn: a `tokio::spawn`ed task does
/// not run until the scheduler first polls it, so registering the signal
/// streams *inside* it leaves a window — unbounded under load — where SIGINT
/// still has its default disposition and kills the process outright. That is
/// the very outcome this handler exists to prevent, and it produced a real
/// terminated-by-signal exit (no code, no terminal restore, no `session_end`).
/// After this function returns, the signals are armed.
fn spawn_signal_cleanup_task() {
let mut signals = TerminatingSignals::register();
tokio::spawn(async move {
let exit_code = signals.wait().await;
// Persistence and a serving Runtime API share a bounded window to
// flush queued saves and end open streams with a typed `stream.end`.
// A second signal skips both waits.
tokio::select! {
_ = async {
tokio::join!(
codewhale_app_server::runtime_api::drain_for_signal_exit(),
crate::tui::persistence_actor::flush_for_signal_exit(),
)
} => {}
_ = signals.wait() => {}
}
// If we get here a fatal signal arrived. Restore the terminal
// and exit. A second signal during cleanup re-enters this
// path and aborts via `std::process::exit` directly.
static CLEANED_UP: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
if !CLEANED_UP.swap(true, std::sync::atomic::Ordering::SeqCst) {
#[cfg(unix)]
codewhale_engine::tools::shell::abort_pending_persistent_process_groups_for_exit();
#[cfg(unix)]
codewhale_runtime::process_tree::kill_contained_trees_for_exit();
crate::tui::ui::emergency_restore_terminal();
// Nothing async survives the `exit` below, so this is the last
// chance to say how the session ended. `record_blocking` is one
// `O_APPEND` write with no lock: taking the compaction lock here
// would let a second Codewhale process sharing CODEWHALE_HOME hang
// Ctrl-C, and the second-signal short-circuit below has to stay
// reachable. A no-op unless this process was armed.
//
// The class is stated, not derived: `RunTerminationReason::Canceled`
// also exits 130, so `exit_code` cannot tell a signal from an
// Esc-cancelled turn.
record_signal_session_end();
}
std::process::exit(exit_code);
});
}
/// When this process's armed telemetry session began. Set once, at arming, and
/// read from both the ordinary teardown and the signal path.
static TELEMETRY_SESSION_START: std::sync::OnceLock<std::time::Instant> =
std::sync::OnceLock::new();
/// Build `session_end` from what this process actually accumulated.
///
/// The exit class is read from the process-wide atomic and never derived from
/// an exit code: `RunTerminationReason::Canceled` maps to 130, the same value
/// the SIGINT path uses, so a code-based derivation would report every
/// Esc-cancelled turn as a signal.
///
/// The cold-start bucket is `None` unless the interactive event loop actually
/// began, which is what keeps it absent rather than invented on the surfaces
/// that have no event loop.
fn telemetry_session_end() -> codewhale_telemetry::Event {
let counters = codewhale_telemetry::session_counters();
codewhale_telemetry::Event::SessionEnd {
duration_bucket: codewhale_telemetry::DurationBucket::from_secs(
TELEMETRY_SESSION_START
.get()
.map_or(0, |start| start.elapsed().as_secs()),
),
exit_class: codewhale_telemetry::exit_class(),
cold_start_bucket: codewhale_runtime::startup_trace::cold_start_ms()
.map(codewhale_telemetry::ColdStartBucket::from_millis),
providers: counters.providers(),
counters: counters.counters(),
errors: counters.errors(),
turn_wall: counters.turn_wall(),
}
}
/// Close the session synchronously, from the signal handler.
///
/// A no-op unless this process was armed.
fn record_signal_session_end() {
codewhale_telemetry::set_exit_class(codewhale_telemetry::ExitClass::Signal);
for event in codewhale_telemetry::session_counters().usage_events() {
codewhale_telemetry::record_blocking(event);
}
codewhale_telemetry::record_blocking(telemetry_session_end());
}
/// Terminating-signal streams, registered up front and awaited later.
///
/// Splitting registration from the await is the point: the OS disposition
/// changes when `register` returns, not when the waiting task is first polled.
#[cfg(unix)]
struct TerminatingSignals {
sigint: Option<tokio::signal::unix::Signal>,
sigterm: Option<tokio::signal::unix::Signal>,
sighup: Option<tokio::signal::unix::Signal>,
}
#[cfg(unix)]
impl TerminatingSignals {
/// Install the handlers. Failing to install any individual stream is
/// non-fatal: we still want the others to work.
fn register() -> Self {
use tokio::signal::unix::{SignalKind, signal};
Self {
sigint: signal(SignalKind::interrupt()).ok(),
sigterm: signal(SignalKind::terminate()).ok(),
sighup: signal(SignalKind::hangup()).ok(),
}
}
/// Resolve with 128 + signal number for whichever arrives first. The
/// fallback never-resolving future keeps `select!` well-typed when a
/// stream failed to register.
async fn wait(&mut self) -> i32 {
tokio::select! {
_ = async { match self.sigint.as_mut() { Some(s) => { s.recv().await; }, None => std::future::pending::<()>().await, } } => 130,
_ = async { match self.sigterm.as_mut() { Some(s) => { s.recv().await; }, None => std::future::pending::<()>().await, } } => 143,
_ = async { match self.sighup.as_mut() { Some(s) => { s.recv().await; }, None => std::future::pending::<()>().await, } } => 129,
}
}
}
/// Windows console events share the bounded cleanup path. Logoff and shutdown
/// notifications are not handled here.
#[cfg(not(unix))]
struct TerminatingSignals {
ctrl_c: Option<tokio::signal::windows::CtrlC>,
ctrl_break: Option<tokio::signal::windows::CtrlBreak>,
ctrl_close: Option<tokio::signal::windows::CtrlClose>,
}
#[cfg(not(unix))]
impl TerminatingSignals {
fn register() -> Self {
Self {
ctrl_c: tokio::signal::windows::ctrl_c().ok(),
ctrl_break: tokio::signal::windows::ctrl_break().ok(),
ctrl_close: tokio::signal::windows::ctrl_close().ok(),
}
}
async fn wait(&mut self) -> i32 {
tokio::select! {
_ = async { match self.ctrl_c.as_mut() { Some(s) => { s.recv().await; }, None => std::future::pending::<()>().await, } } => {},
_ = async { match self.ctrl_break.as_mut() { Some(s) => { s.recv().await; }, None => std::future::pending::<()>().await, } } => {},
_ = async { match self.ctrl_close.as_mut() { Some(s) => { s.recv().await; }, None => std::future::pending::<()>().await, } } => {},
}
130
}
}
fn join_prompt_parts(parts: &[String]) -> String {
parts.join(" ")
}
/// Maximum bytes accepted for an exec prompt read from `--prompt-file` or
/// stdin. Far past argv's per-argument ceiling; the model's context window
/// stays the real limit (#6688).
const MAX_EXEC_PROMPT_BYTES: u64 = 64 * 1024 * 1024;
/// The effective exec prompt: argv words, a `--prompt-file`, or piped stdin.
/// A positional `-` stays literal text. When stdin is not a terminal and no
/// `--prompt-file` is set, piped stdin is the prompt, or is appended to the
/// argv prompt when it delivers data within `EXEC_STDIN_GRACE`.
/// `--parent-death-watch` owns stdin and does not read it as a prompt.
fn resolve_exec_prompt(args: &ExecArgs) -> Result<String> {
let Some(path) = &args.prompt_file else {
return resolve_argv_or_piped_prompt(args);
};
let prompt = if path.as_os_str() == "-" {
if args.parent_death_watch {
bail!("--parent-death-watch owns stdin; pass the prompt with --prompt-file <PATH>");
}
let stdin = io::stdin();
if stdin.is_terminal() {
bail!("--prompt-file - reads stdin, but stdin is a terminal; pipe the prompt in.");
}
read_capped_text(stdin.lock(), MAX_EXEC_PROMPT_BYTES, "exec prompt on stdin")?
} else {
let file = std::fs::File::open(path)
.with_context(|| format!("failed to open --prompt-file {}", path.display()))?;
read_capped_text(file, MAX_EXEC_PROMPT_BYTES, "--prompt-file")
.with_context(|| format!("failed to read --prompt-file {}", path.display()))?
};
if prompt.trim().is_empty() {
let source = if path.as_os_str() == "-" {
"stdin".to_string()
} else {
path.display().to_string()
};
bail!("exec prompt from {source} is empty");
}
Ok(prompt)
}
const EXEC_STDIN_GRACE: Duration = Duration::from_secs(2);
fn read_stdin_context(grace: Duration) -> Result<Option<String>> {
let (tx, rx) = std::sync::mpsc::channel::<Result<Option<String>, String>>();
let (started_tx, started_rx) = std::sync::mpsc::channel::<()>();
std::thread::spawn(move || {
let mut stdin = io::stdin().lock();
let mut first = [0u8; 1];
let read = loop {
match stdin.read(&mut first) {
Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
other => break other,
}
};
let _ = started_tx.send(());
let outcome = match read {
Ok(0) => Ok(Some(String::new())),
Ok(_) => read_capped_text(
io::Cursor::new(first).chain(stdin),
MAX_EXEC_PROMPT_BYTES,
"exec prompt on stdin",
)
.map(Some)
.map_err(|error| error.to_string()),
Err(error) => Err(error.to_string()),
};
let _ = tx.send(outcome);
});
if started_rx.recv_timeout(grace).is_err() {
return Ok(None);
}
match rx.recv() {
Ok(Ok(piped)) => Ok(piped),
Ok(Err(message)) => bail!("{message}"),
Err(_) => bail!("stdin reader stopped unexpectedly"),
}
}
fn resolve_argv_or_piped_prompt(args: &ExecArgs) -> Result<String> {
let argv = join_prompt_parts(&args.prompt);
if args.parent_death_watch || io::stdin().is_terminal() {
if argv.trim().is_empty() {
bail!("missing exec prompt; pass it as arguments, --prompt-file, or a pipe");
}
return Ok(argv);
}
let piped = if argv.trim().is_empty() {
read_capped_text(
io::stdin().lock(),
MAX_EXEC_PROMPT_BYTES,
"exec prompt on stdin",
)?
} else {
match read_stdin_context(EXEC_STDIN_GRACE)? {
Some(piped) => piped,
None => {
eprintln!(
"note: stdin produced no data within {}s; running with the prompt argument alone. To wait for a slow producer, pipe the prompt too: {{ echo \"<prompt>\"; cmd; }} | codewhale exec.",
EXEC_STDIN_GRACE.as_secs()
);
String::new()
}
}
};
if argv.trim().is_empty() {
if piped.trim().is_empty() {
bail!("exec prompt from stdin is empty");
}
return Ok(piped);
}
if piped.trim().is_empty() {
return Ok(argv);
}
Ok(format!("{argv}\n\n{piped}"))
}
fn resolve_exec_model(config: &Config, explicit_model: Option<&str>) -> String {
explicit_model
.map(str::trim)
.filter(|model| !model.is_empty())
.map(ToOwned::to_owned)
.or_else(exec_model_env_override)
.unwrap_or_else(|| config.default_model())
}
fn apply_exec_provider_override(config: &mut Config, provider_arg: &str) -> Result<()> {
let provider_arg = provider_arg.trim();
if provider_arg.is_empty() {
return Ok(());
}
if config
.providers
.as_ref()
.and_then(|providers| providers.custom_provider_config(provider_arg))
.is_some()
{
config.provider = Some(provider_arg.to_string());
return Ok(());
}
if let Some(provider) = codewhale_config::ProviderKind::parse(provider_arg) {
config.provider = Some(provider.as_str().to_string());
return Ok(());
}
bail!(
"Unrecognized --provider {provider_arg:?}. Known providers: {} \
or a configured [providers.<name>] custom provider",
codewhale_config::ProviderKind::names_hint()
);
}
fn exec_model_env_override() -> Option<String> {
let read = || {
["CODEWHALE_MODEL", "DEEPSEEK_MODEL"]
.into_iter()
.find_map(|key| {
std::env::var(key)
.ok()
.map(|model| model.trim().to_string())
.filter(|model| !model.is_empty())
})
};
#[cfg(test)]
{
crate::test_support::with_test_env_lock(read)
}
#[cfg(not(test))]
{
read()
}
}
fn top_level_prompt_initial_input(parts: &[String]) -> Option<tui::InitialInput> {
(!parts.is_empty()).then(|| tui::InitialInput::Submit(join_prompt_parts(parts)))
}
fn resolve_exec_resume_session_id(args: &ExecArgs, workspace: &Path) -> Result<Option<String>> {
if let Some(id) = args.resume.as_ref().or(args.session_id.as_ref()) {
return Ok(Some(id.clone()));
}
if !args.continue_session {
return Ok(None);
}
latest_session_id_for_workspace(workspace)?.map_or_else(
|| {
bail!(
"No saved sessions found for workspace {}. Use `codewhale sessions` to list sessions, or pass `codewhale exec --resume <SESSION_ID> ...`.",
workspace.display()
)
},
|id| Ok(Some(id)),
)
}
fn load_exec_resume_session(session_id: &str) -> Result<session_manager::SavedSession> {
match SessionManager::default_location()
.context("could not open session manager for resume")?
.attach_session_by_prefix(session_id)
{
Ok((recovery, lease)) => {
// This exec run owns the session until it exits.
lease.commit();
Ok(recovery.session)
}
// Resuming a session a TUI has open would give its document two
// writers; the TUI's next autosave would drop this run's turns.
Err(error) if error.kind() == io::ErrorKind::ResourceBusy => {
bail!(exec_resume_busy_error(session_id))
}
Err(error) => Err(error).with_context(|| exec_resume_load_error(session_id)),
}
}
fn exec_resume_busy_error(session_id: &str) -> String {
format!(
"session {} is open in another Codewhale window. Continue it there, or run \
`codewhale fork <SESSION_ID>` and resume the copy.",
exec_stream_session_ref(session_id)
)
}
/// The typed `--resume` value stays redacted in every output mode: exec runs
/// in CI logs, and a mistyped or pasted value can be a secret.
fn exec_resume_load_error(session_id: &str) -> String {
format!(
"could not load session {}. Run `codewhale sessions` to list ids.",
exec_stream_session_ref(session_id)
)
}
/// Select the route for `exec --resume` before any engine/client is built.
///
/// Precedence is intentionally field-aware:
/// - no explicit `--provider` or `--model`: restore the saved provider/model;
/// - explicit `--provider`: keep that route and use its configured/default model
/// unless `--model` is also present;
/// - explicit `--model` alone: restore the saved provider, then use that model.
fn resolve_exec_resume_route(
config: &mut Config,
saved: &session_manager::SavedSession,
explicit_provider: bool,
explicit_model: Option<&str>,
) -> Result<String> {
if !explicit_provider {
let saved_provider_identity = saved
.metadata
.model_provider_id
.as_deref()
.filter(|identity| !identity.trim().is_empty())
.unwrap_or(&saved.metadata.model_provider);
let identity = config
.resolve_persisted_provider_identity(
Some(&saved.metadata.model_provider),
saved.metadata.model_provider_id.as_deref(),
)
.map_err(anyhow::Error::msg)
.with_context(|| {
format!(
"saved session provider '{}' is unavailable; Codewhale will not fall back. {}",
saved_provider_identity,
session_export::export_recovery_hint(&saved.metadata.id)
)
})?;
config
.scope_to_provider_identity(&identity)
.map_err(anyhow::Error::msg)?;
}
if let Some(model) = explicit_model {
return Ok(resolve_exec_model(config, Some(model)));
}
if explicit_provider {
return Ok(resolve_exec_model(config, None));
}
Ok(saved.metadata.model.clone())
}
/// Fold the dispatcher-forwarded launch overrides (`CODEWHALE_PROVIDER` /
/// `CODEWHALE_MODEL`, set by `codewhale --provider X --model Y exec ...`)
/// into the explicit route signals `exec --resume`/`--continue` honour.
///
/// Exec-level flags win when both are present; either source counts as
/// "the user named a route for this run", so a resume must not silently
/// restore the saved provider/model over it.
fn exec_resume_route_overrides(
exec_provider: Option<&str>,
exec_model: Option<&str>,
launch_provider: Option<&str>,
launch_model: Option<&str>,
) -> (bool, Option<String>) {
let non_empty = |value: Option<&str>| {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
};
let explicit_provider =
non_empty(exec_provider).is_some() || non_empty(launch_provider).is_some();
let explicit_model = non_empty(exec_model).or_else(|| non_empty(launch_model));
(explicit_provider, explicit_model)
}
#[derive(Args, Debug, Clone, Default)]
struct SetupArgs {
/// Initialize MCP configuration at the configured path
#[arg(long, default_value_t = false)]
mcp: bool,
/// Initialize skills directory and an example skill
#[arg(long, default_value_t = false)]
skills: bool,
/// Initialize tools directory with a self-describing example script
#[arg(long, default_value_t = false)]
tools: bool,
/// Initialize plugins directory with a self-describing example
#[arg(long, default_value_t = false)]
plugins: bool,
/// Initialize MCP config, skills, tools, and plugins
#[arg(long, default_value_t = false)]
all: bool,
/// Create a local workspace skills directory (./.codewhale/skills)
#[arg(long, default_value_t = false)]
local: bool,
/// Overwrite existing template files
#[arg(long, default_value_t = false)]
force: bool,
/// Print a compact, read-only status report (no network calls)
#[arg(long, default_value_t = false, conflicts_with_all = ["mcp", "skills", "tools", "plugins", "all", "local", "clean"])]
status: bool,
/// Remove crash checkpoints while preserving unsent offline input
#[arg(long, default_value_t = false, conflicts_with_all = ["mcp", "skills", "tools", "plugins", "all", "local", "status"])]
clean: bool,
}
#[derive(Args, Debug, Clone, Default)]
struct DoctorArgs {
/// Emit machine-readable structural JSON output (always offline)
#[arg(long, default_value_t = false)]
json: bool,
/// Emit only the diagnostic context source map as JSON
#[arg(long, default_value_t = false, conflicts_with = "json")]
context_json: bool,
/// Opt in to probing a local provider endpoint (may start a local service)
#[arg(
long,
default_value_t = false,
conflicts_with_all = ["json", "context_json"]
)]
probe_local: bool,
/// Opt in to probing the configured hosted provider API
#[arg(
long,
default_value_t = false,
conflicts_with_all = ["json", "context_json"]
)]
probe_api: bool,
/// Opt in to contacting the release service for an update check
#[arg(
long,
default_value_t = false,
conflicts_with_all = ["json", "context_json"]
)]
check_updates: bool,
/// Opt in to starting enabled MCP servers and checking process/protocol reachability
#[arg(
long,
default_value_t = false,
conflicts_with_all = ["json", "context_json"]
)]
probe_mcp: bool,
/// Opt in to a credential-free transport probe of the selected search provider
#[arg(
long,
default_value_t = false,
conflicts_with_all = ["json", "context_json"]
)]
probe_search: bool,
/// Plan and apply automatic repairs with consent (#5552)
#[arg(
long,
default_value_t = false,
conflicts_with_all = ["json", "context_json"]
)]
fix: bool,
/// Apply the planned repairs without prompting (requires --fix)
#[arg(long, default_value_t = false, requires = "fix")]
yes: bool,
/// Repair the saved-session store now: re-index recoverable sessions,
/// unbind dead thread links, set aside (never delete) what nothing uses
#[arg(
long,
default_value_t = false,
conflicts_with_all = ["json", "context_json"]
)]
repair_sessions: bool,
/// With --repair-sessions: report what would be repaired, change nothing
#[arg(long, default_value_t = false, requires = "repair_sessions")]
dry_run: bool,
/// Report every Codewhale copy on this machine and who owns it (offline; nothing is run or changed)
#[arg(long, default_value_t = false, conflicts_with_all = ["context_json", "repair_sessions", "fix"])]
installs: bool,
/// With --installs: print only what needs attention, nothing on a tidy machine
#[arg(long, default_value_t = false, requires = "installs")]
brief: bool,
/// Move everything from a recovery folder back where it was (the newest when STAMP is omitted)
#[arg(
long,
value_name = "STAMP",
num_args = 0..=1,
default_missing_value = "latest",
conflicts_with_all = ["json", "context_json", "fix", "installs", "repair_sessions"]
)]
restore: Option<String>,
/// Show every resolved path, the search provider and secret-backend details
#[arg(long, default_value_t = false, conflicts_with_all = ["json", "context_json"])]
all: bool,
}
#[derive(Args, Debug, Clone)]
struct SessionDiagnosticsArgs {
/// JSONL session log to inspect
#[arg(value_name = "JSONL")]
path: PathBuf,
/// Emit machine-readable JSON with redacted source handles
#[arg(long, default_value_t = false)]
json: bool,
}
#[derive(Args, Debug, Clone)]
struct ScorecardArgs {
/// JSON file with the recorded turns to score: an array of
/// `{ "turn_id", "provider", "model", "billing_surface", "usage": {…} }`.
/// `turn_end` hooks emit this route provenance plus `created_at`; persisted
/// runtime exports may instead use `id`, `effective_provider`,
/// `effective_model`, and `effective_billing_surface`.
/// Shell-only hook rows marked `model_backed: false` are excluded. Legacy
/// rows without provider remain readable but their cost is unavailable.
#[arg(long, value_name = "FILE")]
input: PathBuf,
/// Optional baseline scorecard-metrics JSON to compare against. When set,
/// the command exits non-zero if any metric regresses past the threshold.
#[arg(long, value_name = "FILE")]
baseline: Option<PathBuf>,
/// Regression threshold, in percent increase over the baseline.
#[arg(long, default_value_t = 5.0, value_parser = parse_regression_threshold)]
threshold: f64,
/// Emit machine-readable JSON instead of the human summary.
#[arg(long, default_value_t = false)]
json: bool,
}
#[derive(Args, Debug, Clone)]
struct EvalArgs {
/// Intentionally fail a specific step (list, read, search, edit, patch, shell)
#[arg(long, value_name = "STEP")]
fail_step: Option<String>,
/// Shell command to run during the exec step
#[arg(long, default_value = "printf eval-harness")]
shell_command: String,
/// Token that must appear in shell output for validation
#[arg(long, default_value = "eval-harness")]
shell_expect_token: String,
/// Maximum characters stored per step output summary
#[arg(long, default_value_t = 240)]
max_output_chars: usize,
/// Emit machine-readable JSON output
#[arg(long, default_value_t = false)]
json: bool,
/// Append one JSONL fixture line per step to `<DIR>/<scenario>.jsonl`.
/// Mock LLM tests can later replay these fixtures.
#[arg(long, value_name = "DIR")]
record: Option<PathBuf>,
}
#[derive(Args, Debug, Clone, Default)]
struct ModelsArgs {
/// Print models as pretty JSON
#[arg(long, default_value_t = false)]
json: bool,
/// Refresh catalogs for all configured providers (no inference requests)
#[arg(long, visible_alias = "refresh")]
update: bool,
/// Limit listing or refresh to this exact provider identity
#[arg(long, value_name = "ID")]
provider: Option<String>,
}
#[derive(Args, Debug, Clone)]
struct SpeechArgs {
/// Text to synthesize. This is sent as the assistant message content.
#[arg(value_name = "TEXT")]
text: String,
/// Output audio path. Defaults to `speech.<format>` in `--output-dir`,
/// `[speech].output_dir`, or the current directory.
#[arg(short, long, value_name = "FILE")]
output: Option<PathBuf>,
/// Directory for the default `speech.<format>` output file when `-o`/`--output` is omitted.
#[arg(long = "output-dir", value_name = "DIR")]
output_dir: Option<PathBuf>,
/// TTS model. Defaults to built-in voices, or is inferred from --voice-prompt/--clone-voice.
#[arg(long)]
model: Option<String>,
/// Built-in voice ID, or a data:audio/...;base64,... URI for voice clone.
#[arg(long)]
voice: Option<String>,
/// Natural language style instruction; not spoken verbatim.
#[arg(long)]
instruction: Option<String>,
/// Voice design prompt. Implies mimo-v2.5-tts-voicedesign when --model is omitted.
#[arg(long = "voice-prompt")]
voice_prompt: Option<String>,
/// MP3/WAV sample used for voice cloning. Implies mimo-v2.5-tts-voiceclone when --model is omitted.
#[arg(long = "clone-voice", value_name = "FILE")]
clone_voice: Option<PathBuf>,
/// Output audio format requested from the API
#[arg(long, default_value = "wav")]
format: String,
/// Emit machine-readable JSON output
#[arg(long, default_value_t = false)]
json: bool,
}
#[derive(Args, Debug, Clone)]
struct ReviewArgs {
/// Review staged changes instead of the working tree
#[arg(long, conflicts_with_all = ["pr", "base"])]
staged: bool,
/// Review GitHub pull request #N instead of a local diff (fetched via `gh`)
#[arg(long, conflicts_with_all = ["staged", "base", "path"])]
pr: Option<u32>,
/// Repository in `owner/name` form for --pr. Defaults to the current
/// workspace's `gh` config (i.e. the repo gh thinks you're in).
#[arg(long, requires = "pr", conflicts_with_all = ["staged", "base", "path"])]
repo: Option<String>,
/// Post the review to the pull request (one COMMENT review with inline
/// line comments plus a summary). Requires --pr; without it the review
/// is only printed locally.
#[arg(long, requires = "pr", conflicts_with_all = ["staged", "base", "path"])]
post: bool,
/// Base ref to diff against (e.g. origin/main). The reviewer also gets the
/// base..HEAD commit messages as the author's stated intent.
#[arg(long, conflicts_with_all = ["staged", "pr"])]
base: Option<String>,
/// Limit diff to a specific path
#[arg(long)]
path: Option<PathBuf>,
/// Override model for this review
#[arg(long)]
model: Option<String>,
/// Override the provider route for this review (e.g. `zai`, `openrouter`,
/// `deepseek`). Non-secret identifier only — credentials still resolve
/// from the environment/config. Use it to disambiguate a `--model` that
/// more than one configured route offers (which otherwise hard-errors
/// with "available from configured provider route(s): ...").
#[arg(long)]
provider: Option<String>,
/// Maximum diff characters; an oversized diff is refused, never truncated
#[arg(long, default_value_t = 200_000)]
max_chars: usize,
/// Maximum complete PR review passes. Values above 1 explicitly authorize
/// additional model requests; the default preserves single-pass behavior.
#[arg(long, default_value_t = 1)]
max_passes: usize,
/// Per-review money cap in USD. It is only enforceable together with
/// `--input-usd-per-mtok` and `--output-usd-per-mtok`; without both, the
/// receipt reports the cap as unsupported instead of implying it applied.
#[arg(long)]
money_cap_usd: Option<f64>,
/// Input price per million tokens for this exact route, in USD
#[arg(long, requires_all = ["money_cap_usd"])]
input_usd_per_mtok: Option<f64>,
/// Output price per million tokens for this exact route, in USD
#[arg(long, requires_all = ["money_cap_usd"])]
output_usd_per_mtok: Option<f64>,
/// Write a durable pre-push review receipt after a successful review
#[arg(long, default_value_t = false)]
write_receipt: bool,
/// Validate the current diff against a durable review receipt without calling a model
#[arg(long, default_value_t = false)]
check_receipt: bool,
/// Override where the review receipt is written or read
#[arg(long)]
receipt_path: Option<PathBuf>,
/// Emit machine-readable JSON output
#[arg(long, default_value_t = false)]
json: bool,
}
#[derive(Args, Debug, Clone)]
struct ApplyArgs {
/// Patch file to apply (defaults to stdin)
#[arg(value_name = "PATCH_FILE")]
patch_file: Option<PathBuf>,
}
#[derive(Args, Debug, Clone)]
struct ServeArgs {
/// Start MCP server over stdio
#[arg(long)]
mcp: bool,
/// Start runtime HTTP/SSE API server
#[arg(long)]
http: bool,
/// Start runtime HTTP/SSE API server with the built-in mobile control page
#[arg(long)]
mobile: bool,
/// Start the embedded loopback-only browser client and open it
#[arg(long)]
web: bool,
/// Show a QR code for the mobile URL in the terminal (requires --mobile)
#[arg(long, requires = "mobile")]
qr: bool,
/// Start ACP server over stdio for editor clients such as Zed
#[arg(long)]
acp: bool,
/// Bind host for HTTP server (default loopback; mobile is always loopback-only)
#[arg(long)]
host: Option<String>,
/// Bind port for HTTP server
#[arg(long, default_value_t = 7878)]
port: u16,
/// Background task worker count (1-8)
#[arg(long, default_value_t = 2)]
workers: usize,
/// Additional CORS origin to allow (repeatable). Stacks on top of the
/// built-in defaults (localhost:3000, localhost:1420, tauri://localhost).
/// Also reads `CODEWHALE_CORS_ORIGINS` (comma-separated), then
/// `DEEPSEEK_CORS_ORIGINS` as an alias, and `[runtime_api] cors_origins`
/// from `config.toml`. Whalescale#255.
#[arg(long = "cors-origin", value_name = "URL")]
cors_origin: Vec<String>,
/// Require this bearer token for `/v1/*` runtime API routes. Also reads
/// `CODEWHALE_RUNTIME_TOKEN` when omitted, then `DEEPSEEK_RUNTIME_TOKEN`
/// as an alias.
#[arg(long = "auth-token", value_name = "TOKEN")]
auth_token: Option<String>,
/// Disable runtime API auth when no token is configured. Only use on a trusted loopback.
#[arg(long = "insecure")]
insecure_no_auth: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ServeBindHost {
host: String,
}
fn resolve_serve_bind_host(_mobile: bool, host: Option<String>) -> ServeBindHost {
ServeBindHost {
host: host.unwrap_or_else(|| "127.0.0.1".to_string()),
}
}
fn validate_serve_mode_selection(
mcp: bool,
http: bool,
mobile: bool,
web: bool,
acp: bool,
) -> Result<bool> {
if http && mobile {
bail!("--http and --mobile are mutually exclusive; choose one");
}
if web && (http || mobile) {
bail!("--web is mutually exclusive with --http and --mobile");
}
let http_selected = http || mobile || web;
let selected_modes = [mcp, http_selected, acp]
.into_iter()
.filter(|selected| *selected)
.count();
if selected_modes != 1 {
bail!("Choose exactly one server mode: --mcp, --http/--mobile/--web, or --acp");
}
Ok(http_selected)
}
#[derive(Args, Debug, Clone)]
struct McpAddArgs {
/// Server name
name: String,
/// Command to launch stdio server
#[arg(long, conflicts_with = "url")]
command: Option<String>,
/// URL for streamable HTTP/SSE server
#[arg(long, conflicts_with = "command")]
url: Option<String>,
/// Explicit URL transport override. Use "sse" for legacy SSE endpoints.
#[arg(long, requires = "url")]
transport: Option<String>,
/// Environment variable containing a bearer token for URL-based servers
#[arg(long, requires = "url")]
bearer_token_env_var: Option<String>,
/// Pre-registered OAuth client ID, bound to --oauth-issuer
#[arg(long, requires_all = ["url", "oauth_issuer"])]
oauth_client_id: Option<String>,
#[arg(
long,
requires = "url",
help = "Exact authorization issuer bound to this OAuth client"
)]
oauth_issuer: Option<String>,
#[arg(
long,
requires = "url",
help = "HTTPS URL of a hosted OAuth client metadata document"
)]
oauth_client_metadata_url: Option<String>,
/// Exact OAuth resource pin checked against server discovery
#[arg(long, requires = "url")]
oauth_resource: Option<String>,
/// OAuth scope to request during login. Repeat or comma-separate.
#[arg(long = "scope", requires = "url", value_delimiter = ',')]
scopes: Vec<String>,
/// Arguments for command-based servers
#[arg(long = "arg", allow_hyphen_values = true)]
args: Vec<String>,
}
#[derive(Subcommand, Debug, Clone)]
enum McpCommand {
/// List configured MCP servers
List,
/// Create a template MCP config at the configured path
Init {
/// Overwrite an existing MCP config file
#[arg(long, default_value_t = false)]
force: bool,
},
/// Connect to MCP servers and report status (does not attach to a
/// running session)
Connect {
/// Optional server name to connect to
#[arg(value_name = "SERVER")]
server: Option<String>,
},
/// List tools discovered from MCP servers
Tools {
/// Optional server name to list tools for
#[arg(value_name = "SERVER")]
server: Option<String>,
},
/// Add an MCP server entry
Add(Box<McpAddArgs>),
/// Authenticate to a URL-based MCP server using OAuth
Login {
/// Server name
name: String,
/// OAuth scope to request. Repeat or comma-separate; defaults to config/discovery.
#[arg(long = "scope", value_delimiter = ',')]
scopes: Vec<String>,
},
/// Delete stored OAuth credentials for a URL-based MCP server
Logout {
/// Server name
name: String,
},
/// Remove an MCP server entry
Remove {
/// Server name
name: String,
},
/// Enable an MCP server
Enable {
/// Server name
name: String,
},
/// Disable an MCP server
Disable {
/// Server name
name: String,
},
/// Validate MCP config and required servers
Validate,
/// Register this Codewhale binary as a local MCP stdio server.
///
/// This adds a config entry that runs `codewhale serve --mcp` (stdio protocol).
/// For the HTTP/SSE runtime API, use `codewhale serve --http` directly instead.
#[command(
name = "add-self",
long_about = "Register this Codewhale binary as a local MCP stdio server.\n\nAdds a config entry to ~/.codewhale/mcp.json that launches `codewhale serve --mcp`\nvia the stdio transport. Other Codewhale sessions (or any MCP client) can then\ndiscover and call tools exposed by this server.\n\nUse `codewhale serve --http` instead if you need the HTTP/SSE runtime API."
)]
AddSelf {
/// Server name in mcp.json (default: "codewhale")
#[arg(long, default_value = "codewhale")]
name: String,
/// Workspace directory for the MCP server
#[arg(long)]
workspace: Option<String>,
},
}
/// The `codewhale mcp` subcommands with their one-line descriptions, for the
/// dispatcher's `codewhale mcp --help`.
///
/// That help exits in the dispatcher's parser, before this crate's parser
/// runs, and the dispatcher only sees forwarded arguments. Reading the list
/// off `McpCommand` keeps it from naming a subcommand that does not exist
/// or missing one that does.
#[must_use]
pub fn mcp_subcommand_help() -> String {
use std::fmt::Write as _;
let command = McpCommand::augment_subcommands(clap::Command::new("mcp"));
let width = command
.get_subcommands()
.map(|subcommand| subcommand.get_name().len())
.max()
.unwrap_or(0);
let mut help = String::from("Commands:\n");
for subcommand in command.get_subcommands() {
let about = subcommand
.get_about()
.map(ToString::to_string)
.unwrap_or_default();
let _ = writeln!(help, " {:<width$} {about}", subcommand.get_name());
}
help.push_str("\nRun `codewhale mcp <COMMAND> --help` for a command's options.");
help
}
#[must_use]
pub fn doctor_options_help() -> String {
DoctorArgs::augment_args(clap::Command::new("doctor"))
.disable_help_flag(true)
.help_template("Doctor options:\n{options}")
.render_help()
.to_string()
}
#[derive(Subcommand, Debug, Clone)]
pub(crate) enum IntegrationsCommand {
/// Official DeepSeek Harness (`dsh`) connected through Codewhale
Dsh {
#[command(subcommand)]
command: DshIntegrationCommand,
},
}
#[derive(Subcommand, Debug, Clone)]
pub(crate) enum DshIntegrationCommand {
/// Detect dsh and report the integration state without writing anything
Status {
/// Emit machine-readable JSON
#[arg(long, default_value_t = false)]
json: bool,
},
/// Show exactly what `connect`/`update` would write, without writing it
Plan {
#[arg(long, default_value_t = false)]
json: bool,
/// DSH profile the overlay targets (`web` or `headless`)
#[arg(long, default_value = "web")]
profile: String,
/// Mirror Codewhale full access as DSH danger-full-access (only when Codewhale itself runs with full access)
#[arg(long, default_value_t = false)]
allow_full_access: bool,
/// Record the Codewhale palette (skin) decision for the bundle profile; applied via DSH's `overrideTokens`, never through the overlay
#[arg(long, default_value_t = false)]
skin: bool,
},
/// Write the overlay and receipt under $CODEWHALE_HOME/integrations/dsh
Connect {
#[arg(long, default_value = "web")]
profile: String,
#[arg(long, default_value_t = false)]
allow_full_access: bool,
#[arg(long, default_value_t = false)]
skin: bool,
/// Confirm the disclosed plan without an interactive prompt (required when stdin is not a terminal)
#[arg(long, default_value_t = false)]
yes: bool,
},
/// Re-derive the overlay from the current Codewhale route
Update {
#[arg(long)]
profile: Option<String>,
#[arg(long, default_value_t = false)]
allow_full_access: bool,
/// Turn the bundle-profile skin on/off (`--skin false`; defaults to the previous choice)
#[arg(long)]
skin: Option<bool>,
/// Turn the ambient ocean scene behind the DSH web UI on/off (`--ocean false`; defaults to the previous choice, initially on; needs the skin)
#[arg(long)]
ocean: Option<bool>,
#[arg(long, default_value_t = false)]
yes: bool,
},
/// Run dsh with the Codewhale overlay; extra args go to the dsh app
Launch {
/// Override the recorded profile (`web` or `headless`)
#[arg(long)]
profile: Option<String>,
/// Print the exact command instead of running it
#[arg(long, default_value_t = false)]
dry_run: bool,
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Keep the overlay but refuse launches
Disable,
/// Allow launches again
Enable,
/// Delete Codewhale-owned files only; $DSH_HOME is never touched
Remove {
#[arg(long, default_value_t = false)]
yes: bool,
},
/// Documented DSH plugin path: install the Codewhale bundle into a dedicated `codewhale` DSH profile via `dsh plugin add` (pnpm required)
InstallBundle {
/// Which shipped DSH app the dedicated profile boots (`web` or `headless`)
#[arg(long, default_value = "web")]
app: String,
#[arg(long, default_value_t = false)]
yes: bool,
},
/// `dsh plugin --profile codewhale remove codewhale-dsh-bundle`, then delete only Codewhale-owned bundle files
RemoveBundle {
#[arg(long, default_value_t = false)]
yes: bool,
},
}
#[derive(Args, Debug, Clone)]
struct FeaturesCli {
#[command(subcommand)]
command: FeaturesSubcommand,
}
#[derive(Subcommand, Debug, Clone)]
enum FeaturesSubcommand {
/// List known feature flags and their state
List,
}
#[derive(Args, Debug, Clone)]
struct SandboxArgs {
#[command(subcommand)]
command: SandboxCommand,
}
#[derive(Subcommand, Debug, Clone)]
enum SandboxCommand {
/// Run a command with sandboxing
Run {
/// Sandbox policy (danger-full-access, read-only, external-sandbox, workspace-write)
#[arg(long, default_value = "workspace-write")]
policy: String,
/// Allow outbound network access
#[arg(long)]
network: bool,
/// Additional writable roots (repeatable)
#[arg(long, value_name = "PATH")]
writable_root: Vec<PathBuf>,
/// Exclude TMPDIR from writable paths
#[arg(long)]
exclude_tmpdir: bool,
/// Exclude /tmp from writable paths
#[arg(long)]
exclude_slash_tmp: bool,
/// Command working directory
#[arg(long)]
cwd: Option<PathBuf>,
/// Timeout in milliseconds
#[arg(long, default_value_t = 60_000)]
timeout_ms: u64,
/// Command and arguments to run
#[arg(required = true, trailing_var_arg = true)]
command: Vec<String>,
},
}
/// Pre-clap seam feeding `apply_process_hardening` (#5723): resolve only the
/// *startup* sandbox posture — `CODEWHALE_SANDBOX_MODE` /
/// `DEEPSEEK_SANDBOX_MODE` first, then the config file's `sandbox_mode` key —
/// so the irreversible `PR_SET_NO_NEW_PRIVS` decision can honor a
/// `danger-full-access` launch.
///
/// This is deliberately a narrow single-key read, not a config-system
/// reorder: the full pipeline (clap flags such as `--config`/`exec
/// --sandbox`, profiles, managed and project overlays) cannot run before
/// process hardening, which must land before Tokio and any worker threads.
/// What the seam cannot see keeps the hardened default — fail-closed. The
/// one deliberate gap in the other direction: a later-resolved override that
/// *tightens* a config-file `danger-full-access` (e.g. managed requirements)
/// leaves the flag off; `CODEWHALE_NO_NEW_PRIVS=1` remains the explicit
/// override that forces it on in any posture. The env path override
/// (`CODEWHALE_CONFIG_PATH`) is honored through `resolve_load_config_path`;
/// only clap-parsed paths are invisible here.
fn resolve_startup_sandbox_mode_for_hardening() -> Option<String> {
if let Ok(value) =
std::env::var("CODEWHALE_SANDBOX_MODE").or_else(|_| std::env::var("DEEPSEEK_SANDBOX_MODE"))
{
return Some(value);
}
let path = codewhale_runtime::config::resolve_load_config_path(None)
.ok()
.flatten()?;
let raw = std::fs::read_to_string(path).ok()?;
let doc = raw.parse::<toml::Value>().ok()?;
doc.get("sandbox_mode")
.and_then(toml::Value::as_str)
.map(str::to_string)
}
/// Run a delegated Engine command in the canonical CLI process. Startup facts
/// are typed; `args` contains only command arguments, without a binary name.
pub fn run(options: RuntimeOptions, args: Vec<String>) -> std::process::ExitCode {
let outcome = run_with_args(options, args);
codewhale_app_server::runtime_api::listener::discard_runtime_listener_handoff();
match outcome {
Ok(()) => std::process::ExitCode::SUCCESS,
Err(err) => {
eprintln!("error: {err}");
for cause in err.chain().skip(1) {
eprintln!(" caused by: {cause}");
}
std::process::ExitCode::FAILURE
}
}
}
/// Internal implementation that mirrors the old `main()` but takes explicit
/// args instead of reading `std::env::args()`. Startup facts are captured by
/// the canonical CLI and applied before config/discovery or worker startup.
fn run_with_args(options: RuntimeOptions, args: Vec<String>) -> Result<()> {
codewhale_engine::extension_host::install_ports();
let inherited_listener =
codewhale_app_server::runtime_api::listener::prepare_runtime_listener_handoff()?;
let args: Vec<String> = std::iter::once("codewhale".to_string())
.chain(args)
.collect();
// Match the dispatcher entrypoint: Unix shells and supervisors may inherit
// SIGPIPE ignored, which turns short pipelines such as `codewhale doctor |
// head` into BrokenPipe panics once this delegated TUI binary prints.
// SAFETY: first call at startup; no threads or handlers yet.
#[cfg(unix)]
unsafe {
libc::signal(libc::SIGPIPE, libc::SIG_DFL);
}
startup_trace::mark_process_start();
configure_windows_console_utf8();
install_rustls_crypto_provider();
// The TUI is the terminal host for every mode this binary runs
// (interactive, exec, serve): runtime code reaches raw mode and
// notification delivery only through this port.
crate::tui::ui::install_host_terminal();
codewhale_engine::command_catalog::install(std::sync::Arc::new(crate::commands::CommandTable));
// ── Process hardening (#2183) ─────────────────────────────────────────
// MUST run before Tokio is booted and before any threads are spawned.
// See crates/sandbox/src/process_hardening.rs for ordering rationale.
// The startup-posture read is the narrow seam documented above: a startup
// resolved to danger-full-access skips PR_SET_NO_NEW_PRIVS (#5723), every
// other outcome keeps it.
let startup_sandbox_mode = resolve_startup_sandbox_mode_for_hardening();
codewhale_sandbox::process_hardening::apply_process_hardening(startup_sandbox_mode.as_deref());
if args.get(1).is_some_and(|arg| arg == "pet") {
let root = crate::tui::pet_watch::owner::directory()?;
match args.get(2).map(String::as_str) {
Some("serve") if args.len() == 3 => {
return crate::tui::pet_watch::owner::serve(
root,
std::env::var("CODEWHALE_PET_PORT")
.ok()
.map(|p| p.parse::<u16>())
.transpose()?
.unwrap_or(4633),
);
}
_ => anyhow::bail!("Usage: codewhale pet serve"),
}
}
// ── Fatal-signal terminal guard (#5424) ───────────────────────────────
// Abort-class deaths (stack overflow, allocation failure, double panic)
// skip the panic hook AND every Drop guard, leaving mouse capture and
// the kitty keyboard stack leaking into the user's shell. A classic
// sigaction handler restores the terminal and stamps a marker before
// re-raising. Also before any threads exist.
crate::tui::ui::fatal_signal_guard::install_fatal_signal_guard();
// Set up process panic hook before anything else — writes crash dumps
// to the selected profile's crashes/ even before tokio is up,
// and restores the terminal so a panicked TUI doesn't leave the user's
// shell stuck in alt-screen mode.
let orig_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| {
// Restore the terminal first so the panic message itself, plus the
// user's shell after exit, are visible. Best-effort — we may not be
// in raw / alt-screen mode if the panic happens pre-TUI. Shared
// with the signal handler installed below so both exit paths leave
// the terminal in the same well-defined state.
crate::tui::ui::emergency_restore_terminal();
let msg = if let Some(s) = panic_info.payload().downcast_ref::<&str>() {
s.to_string()
} else if let Some(s) = panic_info.payload().downcast_ref::<String>() {
s.clone()
} else {
format!("{:?}", panic_info.payload())
};
let location = panic_info
.location()
.map(|loc| loc.to_string())
.unwrap_or_else(|| "unknown".to_string());
tracing::error!(target: "panic", "Process panicked at {location}: {msg}");
// Telemetry, if and only if this process was armed. This hook is
// installed before `Cli::parse()` and long before any config is
// resolved, so it cannot consult a resolved value — but it can consult
// a `OnceLock` that is by construction empty until resolution
// completes. A user who never opted in panics without writing a byte
// and without creating a directory.
//
// The site is allowlist-reduced and `msg` is deliberately not read: a
// slicing panic embeds the entire string being sliced, and this tree
// slices user and model text in dozens of places.
codewhale_telemetry::set_exit_class(codewhale_telemetry::ExitClass::Panic);
if let Some(site) = panic_info
.location()
.map(|loc| codewhale_telemetry::reduce_panic_site(loc.file(), loc.line(), loc.column()))
{
codewhale_telemetry::record_blocking(codewhale_telemetry::Event::Panic { site });
}
// Write crash dump best-effort
if let Ok(home) = codewhale_config::codewhale_home() {
let crash_dir = home.join("crashes");
let _ = std::fs::create_dir_all(&crash_dir);
use chrono::Utc;
let ts = Utc::now().format("%Y%m%dT%H%M%S%.3fZ");
let path = crash_dir.join(format!("{ts}-process-panic.log"));
let contents =
format!("Process panicked\nLocation: {location}\nTimestamp: {ts}\nPanic: {msg}\n",);
let _ = std::fs::write(&path, contents);
}
// Invoke the original hook (prints to stderr, etc.)
orig_hook(panic_info);
}));
// Parse and freeze every startup authority before Tokio or any other
// worker thread exists. A workspace `.env` is intentionally a narrow
// credential convenience surface: it must never redirect product state,
// configuration, MCP, trust, sandbox, executable lookup, or plugin
// discovery. Plugin discovery therefore runs first, and the loader below
// admits only built-in provider credential names from a stable file read.
let cli = match parse_cli_args(&args) {
Ok(mut cli) => {
cli.options = options.merge(cli.options)?;
cli
}
Err(e) => {
e.exit();
}
};
anyhow::ensure!(
!inherited_listener
|| matches!(
cli.command.as_ref(),
Some(Commands::Serve(args)) if args.http && !args.mcp && !args.acp
),
"An inherited Runtime listener requires serve --http or app-server --http"
);
// #5098: project-scope fleet agent profiles (`.codewhale/agents/*.toml`)
// join the dispatch roster under the same trust decision as the rest of
// project-level config — `--no-project-config` opts the layer out for
// every roster read in this process.
codewhale_engine::fleet::roster::set_project_agent_profiles_enabled(!cli.no_project_config);
let workspace = resolve_workspace(&cli);
let inspect_plugin_cli = matches!(cli.command.as_ref(), Some(Commands::Plugins { .. }));
let mut plugin_discovery = None;
let mut plugin_registry = None;
let (cli, command) = prepare_cli_startup(
cli,
|| {
let discovery = codewhale_plugins::PluginDiscoveryContext::capture_pre_dotenv();
plugin_registry = Some(if inspect_plugin_cli {
discovery.inspect_registry_for_workspace(&workspace)
} else {
discovery.registry_for_workspace(&workspace)
});
plugin_discovery = Some(discovery);
},
|| warn_on_workspace_dotenv_result(&workspace),
);
let plugin_discovery = plugin_discovery
.expect("plugin discovery initialization must precede workspace dotenv loading");
let plugin_registry = plugin_registry
.expect("plugin discovery initialization must precede workspace dotenv loading");
codewhale_plugins::providers::install_startup_registry(plugin_registry.clone());
// The interactive runtime intentionally carries a large state machine:
// terminal rendering, modal dispatch, provider setup, and fleet/workflow
// events all share one async owner. Debug builds retain enough stack
// temporaries that nesting a modal event over the TUI loop can exceed the
// platform main-thread default (8 MiB on macOS). Give that owner an
// explicit stack while keeping process hardening and the global panic hook
// above this boundary, before Tokio or any worker thread exists.
//
// 16 MiB stopped being enough: in a debug build the deepest measured
// chain — the event-loop poll stack down to the trust-confirm engine
// respawn (`handle_view_events` → `apply_command_result` →
// `spawn_tui_engine` → `Engine::new` → `CodewhaleClient::new` →
// `resolve_runtime_route` → `Config::clone`) — consumed ~16.5 MiB and
// aborted on the guard page (the plugin_toml_binary cucumber acceptance
// on the ubuntu CI leg). The fat frames are the debug poll functions of
// the giant top-level futures (`run_async_main_inner` ~5.5 MiB, `run_tui`
// ~3.2 MiB, `handle_view_events` ~2.1 MiB), so any small addition to them
// re-tips a zero-margin stack. 32 MiB restores real headroom; the cost is
// address space only, since thread stacks commit lazily.
let runtime_thread = std::thread::Builder::new()
.name("codewhale-main".to_string())
.stack_size(codewhale_runtime::CODEWHALE_MAIN_STACK_BYTES)
.spawn(move || run_async_main(cli, command, plugin_discovery, plugin_registry))
.context("Failed to start the Codewhale runtime thread")?;
match runtime_thread.join() {
Ok(result) => result,
Err(payload) => {
let message = payload
.downcast_ref::<&str>()
.map(|value| (*value).to_string())
.or_else(|| payload.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "unknown panic payload".to_string());
Err(anyhow!("Codewhale runtime thread panicked: {message}"))
}
}
}
fn run_async_main(
cli: Cli,
command: Option<Commands>,
plugin_discovery: Arc<codewhale_plugins::PluginDiscoveryContext>,
plugin_registry: Arc<codewhale_plugins::PluginRegistry>,
) -> Result<()> {
build_runtime(command.as_ref())?.block_on(run_async_main_inner(
cli,
command,
plugin_discovery,
plugin_registry,
))
}
/// Build the runtime that owns every async task in this binary.
///
/// `#[tokio::main]` used to expand here, which left every worker thread on
/// tokio's 2 MiB default while only the `codewhale-main` owner thread above
/// received `CODEWHALE_MAIN_STACK_BYTES`. The engine does not run on that owner
/// thread — `core::engine::spawn_engine` hands `Engine::run` to
/// `utils::spawn_supervised`, a bare `tokio::spawn` — so the explicit stack
/// never applied where the depth actually is.
///
/// A debug-build `agent` dispatch (turn_loop -> FuturesUnordered ->
/// execute_rich_full_with_context -> AgentTool::execute -> spawn_subagent_from_input)
/// measured a stack high-water mark between 2.25 and 2.5 MiB and aborted the
/// whole process on the guard page. A Rust stack overflow is not a panic: it
/// raises SIGABRT, so `spawn_supervised`'s `catch_unwind` cannot see it and the
/// process dies with 134 mid-dispatch.
///
/// Build the runtime that owns every async task in this binary.
///
/// `command` selects the worker-count policy: read-only diagnostic commands
/// run on a small fixed pool instead of tokio's one-worker-per-CPU default
/// (see [`diagnostic_worker_count`]). Interactive sessions and servers keep
/// the default sizing unchanged.
///
/// `#[tokio::main]` used to expand here, which left every worker thread on
/// tokio's 2 MiB default while only the `codewhale-main` owner thread above
/// received `CODEWHALE_MAIN_STACK_BYTES`. The engine does not run on that owner
/// thread — `core::engine::spawn_engine` hands `Engine::run` to
/// `utils::spawn_supervised`, a bare `tokio::spawn` — so the explicit stack
/// never applied where the depth actually is.
///
/// A debug-build `agent` dispatch (turn_loop -> FuturesUnordered ->
/// execute_rich_full_with_context -> AgentTool::execute -> spawn_subagent_from_input)
/// measured a stack high-water mark between 2.25 and 2.5 MiB and aborted the
/// whole process on the guard page. A Rust stack overflow is not a panic: it
/// raises SIGABRT, so `spawn_supervised`'s `catch_unwind` cannot see it and the
/// process dies with 134 mid-dispatch.
///
/// This is behavior-identical to the old `#[tokio::main]` expansion apart from
/// the stack size, and it makes the knob greppable.
pub(crate) fn build_runtime(command: Option<&Commands>) -> Result<tokio::runtime::Runtime> {
let mut builder = tokio_runtime_builder();
if let Some(workers) = diagnostic_worker_count(command) {
builder.worker_threads(workers);
}
builder
.build()
.context("Failed to build the Codewhale Tokio runtime")
}
/// Number of async workers to request from tokio.
///
/// Unset means tokio's default: one worker per CPU. That default is right for
/// interactive sessions and long-running servers, but short-lived offline
/// commands gain nothing from a full-CPU pool — they pay thread spawn, stack
/// reservation, and teardown futex traffic for capacity they never use (perf
/// attribution: pthread_create under `Builder::build` dominates init samples).
const DIAGNOSTIC_WORKER_CAP: usize = 2;
fn diagnostic_worker_count(command: Option<&Commands>) -> Option<usize> {
let capped = match command {
// Read-only diagnostic surfaces (doctor family).
Some(
Commands::Doctor(_)
| Commands::Eval(_)
| Commands::SessionDiagnostics(_)
| Commands::Sessions { .. }
| Commands::Receipts { .. },
) => true,
// Only the read-only status report; mutating setup keeps defaults.
Some(Commands::Setup(args)) => args.status,
Some(Commands::Plugins { .. }) => true,
_ => false,
};
capped.then_some(DIAGNOSTIC_WORKER_CAP)
}
fn tokio_runtime_builder() -> tokio::runtime::Builder {
let mut builder = tokio::runtime::Builder::new_multi_thread();
builder
.enable_all()
.thread_stack_size(codewhale_runtime::CODEWHALE_MAIN_STACK_BYTES);
builder
}
/// Which product surface this process is serving.
///
/// A function of the parsed subcommand, never of the executable: this one
/// binary serves at least five surfaces, so `current_exe()` would label all of
/// them the same.
fn telemetry_surface(command: Option<&Commands>) -> codewhale_telemetry::Surface {
telemetry_surface_with(command, embedded_surface_override())
}
/// The injectable form of [`telemetry_surface`].
///
/// The declared surface is a parameter rather than an environment read inside
/// the match, so the "only the server branch consults it" rule can be proven
/// without a test mutating process-wide environment — which would race every
/// other test in this binary.
fn telemetry_surface_with(
command: Option<&Commands>,
declared: Option<codewhale_telemetry::Surface>,
) -> codewhale_telemetry::Surface {
use codewhale_telemetry::Surface;
match command {
None | Some(Commands::Resume { .. } | Commands::Fork { .. } | Commands::Pr { .. }) => {
Surface::Tui
}
Some(Commands::Exec(_)) => Surface::Exec,
Some(Commands::Serve(args)) => {
if args.mcp {
Surface::McpServer
} else {
declared.unwrap_or(Surface::Serve)
}
}
Some(_) => Surface::Cli,
}
}
/// The surface name an embedding client declared for a server it started.
///
/// An API server is the one surface a third party legitimately *starts*, so it
/// is the one surface an embedder may name. The editor extension, for instance,
/// runs `codewhale serve --http` and declares itself, so its sessions are not
/// counted as anonymous `serve` traffic from nobody in particular.
///
/// Three properties are deliberate:
///
/// 1. **`serve` only.** The variable is inherited by any process this one
/// starts, and an agent running a shell command is running `codewhale`
/// descendants with this environment. A nested `codewhale exec` that
/// inherited the label would report itself as the embedder — wrong, and
/// invisible. Consulting it only in the branch that *is* the embedder's own
/// server bounds that to a process the embedder actually started.
/// 2. **`tui` is refused.** Nothing starts the interactive terminal UI on a
/// user's behalf, and reporting a server as `tui` would put it in the one
/// surface whose numbers are the terminal's. `mcp` is decided before this is
/// consulted, so a server that *is* an MCP server still says so.
/// 3. **Unrecognised is not an error.** A name outside [`Surface::ALL`] is
/// ignored and the caller falls back, rather than failing a startup over a
/// field that is only a label. The value is not "sanitised" into a variant
/// it resembles; it is dropped.
fn embedded_surface_override() -> Option<codewhale_telemetry::Surface> {
embedded_surface_override_from(std::env::var("CODEWHALE_TELEMETRY_SURFACE").ok())
}
/// The injectable form of [`embedded_surface_override`], used by tests.
///
/// Separate from the environment read for the same reason
/// `load_setup_state_for_decision_at` is separate from
/// `load_setup_state_for_decision`: a test that mutated process-wide
/// environment for this would race every other test in the binary.
fn embedded_surface_override_from(raw: Option<String>) -> Option<codewhale_telemetry::Surface> {
use codewhale_telemetry::Surface;
let surface = Surface::parse(raw?.trim())?;
if surface == Surface::Tui {
return None;
}
Some(surface)
}
/// How this session was started, for `session_start`.
fn telemetry_session_source(command: Option<&Commands>) -> codewhale_telemetry::SessionSource {
use codewhale_telemetry::SessionSource;
match command {
None | Some(Commands::Pr { .. }) => SessionSource::Interactive,
Some(Commands::Resume { .. }) => SessionSource::Resume,
Some(Commands::Fork { .. }) => SessionSource::Fork,
Some(Commands::Serve(_)) => SessionSource::Api,
Some(_) => SessionSource::Unknown,
}
}
fn telemetry_session_source_for_run(
command: Option<&Commands>,
) -> codewhale_telemetry::SessionSource {
let flag = |name: &str| {
std::env::var(name).is_ok_and(|value| !matches!(value.trim(), "" | "0" | "false"))
};
if flag("CI") || flag("GITHUB_ACTIONS") {
codewhale_telemetry::SessionSource::Ci
} else {
telemetry_session_source(command)
}
}
/// Read-only commands must not create telemetry state as a side effect.
// Sessions export only projects local records to an explicit output. Like
// listing, it must not initialize telemetry or interactive session state.
fn telemetry_command_is_read_only(command: Option<&Commands>) -> bool {
matches!(
command,
Some(
Commands::Doctor(_)
| Commands::SessionDiagnostics(_)
| Commands::Sessions { .. }
| Commands::Receipts { .. }
)
) || matches!(command, Some(Commands::Setup(args)) if args.status)
|| matches!(
command,
Some(Commands::Plugins {
command: PluginsCommand::Status { .. }
})
)
}
/// Resolve the emit predicate and arm, once, before anything can record.
///
/// This is the read that v1 of the design was missing entirely:
/// `resolve_runtime_options` had no non-test caller in this crate, so neither
/// `telemetry = false` in the config file nor `CODEWHALE_TELEMETRY=0` was ever
/// consulted by a process that would have emitted.
///
/// `CliRuntimeOverrides::default()` is correct here. The dispatcher has already
/// applied the kill-switch floor and forwarded the *resolved* value through
/// `CODEWHALE_TELEMETRY`, which `EnvRuntimeOverrides::load()` picks up — and
/// re-reading `CODEWHALE_TELEMETRY` inside the telemetry crate would fork
/// `parse_bool`, the `DEEPSEEK_TELEMETRY` alias, and the floor into a second
/// source of truth.
fn arm_telemetry_with_setup(
config_path: Option<PathBuf>,
surface: codewhale_telemetry::Surface,
source: codewhale_telemetry::SessionSource,
setup_override: Option<&codewhale_config::SetupState>,
) {
let Ok(store) = codewhale_config::ConfigStore::load(config_path) else {
return;
};
let resolved = store
.config
.resolve_runtime_options(&codewhale_config::CliRuntimeOverrides::default());
let setup = if let Some(setup) = setup_override {
setup.clone()
} else {
let Some(setup) = codewhale_telemetry::load_setup_state_for_decision() else {
// An existing unreadable privacy record may contain a decline.
// Failing closed is safer than replacing it with default-on.
return;
};
setup
};
let codewhale_telemetry::TelemetryDecision::Enabled(consent) =
codewhale_telemetry::decide(&resolved, &setup, surface)
else {
return;
};
codewhale_telemetry::init(consent.with_config_path(Some(store.path().to_path_buf())));
let _ = TELEMETRY_SESSION_START.set(std::time::Instant::now());
codewhale_telemetry::record(codewhale_telemetry::Event::SessionStart { source });
}
fn arm_telemetry(cli: &Cli, command: Option<&Commands>) {
if telemetry_command_is_read_only(command) {
return;
}
arm_telemetry_with_setup(
cli.config.clone(),
telemetry_surface(command),
telemetry_session_source_for_run(command),
None,
);
}
/// Whether a resolved route is the canonical public ChatGPT API endpoint.
/// CLI diagnostics use the same destination check as inference dispatch.
#[must_use]
pub fn is_official_chatgpt_api_base(base_url: &str) -> bool {
pricing::is_official_chatgpt_api(base_url)
}
/// Compatibility command to explicitly enable usage under the current policy.
/// This optional choice never arms the current process.
pub fn accept_telemetry_notice(config_path: Option<PathBuf>, version: u32) -> Result<String> {
if version != codewhale_telemetry::NOTICE_VERSION {
anyhow::bail!(
"read `codewhale config telemetry`, then explicitly accept current notice version {}",
codewhale_config::TELEMETRY_NOTICE_VERSION
);
}
set_telemetry_preference(config_path, true)
}
/// Set the durable usage preference through the same transition as Settings.
/// Enabling affects the next launch; disabling immediately erases queued usage.
pub fn set_telemetry_preference(config_path: Option<PathBuf>, enabled: bool) -> Result<String> {
let applied =
codewhale_engine::telemetry_notice::apply_persistent_preference(config_path, enabled);
let message = applied.message(codewhale_localization::Locale::En);
if applied.is_error() {
anyhow::bail!("{message}");
}
Ok(message)
}
/// Close the armed session and flush, bounded.
///
/// Short CLI (`config`, `doctor`, `auth`, …) records `session_end`, seals the
/// local queue within a much smaller deadline, and returns. The 3s network
/// flush is a TUI/exec concern: a hung TLS handshake must not hold
/// `codewhale config list`. A configured endpoint remains buffered for the
/// next interactive session; an explicitly empty endpoint writes its local
/// dry-run batch immediately.
async fn finish_telemetry(outcome: &Result<()>, surface: codewhale_telemetry::Surface) {
if !codewhale_telemetry::is_armed() {
return;
}
// Only escalate: the panic hook and the signal path have already spoken if
// they ran, and a stated class must not be overwritten by an inferred one.
if outcome.is_err()
&& codewhale_telemetry::exit_class() == codewhale_telemetry::ExitClass::Clean
{
codewhale_telemetry::set_exit_class(codewhale_telemetry::ExitClass::Error);
}
for event in codewhale_telemetry::session_counters().usage_events() {
codewhale_telemetry::record(event);
}
codewhale_telemetry::record(telemetry_session_end());
if surface == codewhale_telemetry::Surface::Cli {
let persistence = codewhale_telemetry::persist_local_blocking();
logging::info(format!(
"telemetry local persistence outcome={persistence:?}"
));
return;
}
// `shutdown_blocking` parks a thread waiting on the writer, so it goes to
// the blocking pool, and it is bounded there. The persistence actor's
// unbounded `let _ = task.await` next door is not a pattern to copy here: a
// hung TLS handshake would hold the process open past the last frame.
let _ = tokio::time::timeout(
codewhale_telemetry::SHUTDOWN_FLUSH_TIMEOUT,
tokio::task::spawn_blocking(|| {
codewhale_telemetry::shutdown_blocking(codewhale_telemetry::SHUTDOWN_FLUSH_TIMEOUT)
}),
)
.await;
}
async fn run_async_main_inner(
cli: Cli,
command: Option<Commands>,
plugin_discovery: Arc<codewhale_plugins::PluginDiscoveryContext>,
plugin_registry: Arc<codewhale_plugins::PluginRegistry>,
) -> Result<()> {
// Install signal handlers that restore the terminal before the process
// exits. Without this, Ctrl+C delivered while raw mode / kitty keyboard
// enhancement / alt-screen are active (or in the brief windows around
// startup and teardown where they're being toggled) leaves the user's shell
// receiving raw CSI sequences like `^[[>5u` until they run `reset` (#1583).
//
// Once the TUI's raw mode is engaged the terminal driver delivers Ctrl+C as
// the byte 0x03 rather than SIGINT, so the in-TUI key handler — not this
// handler — is what processes user interrupts during normal operation. This
// handler exists for the gaps: pre-TUI subcommands (--version, doctor,
// login, …), the moments around enable_raw_mode / disable_raw_mode, the
// external-editor suspend path, and SIGTERM / SIGHUP from the OS.
//
// It goes up before arming and before the notice: arming is the first
// externally observable thing this process does (it creates the telemetry
// buffer), and the notice is the first thing that can sit waiting on a
// human. A Ctrl-C in either window must still restore the terminal and exit
// 130 rather than kill the process outright. Recording a `session_end` from
// the signal path is a no-op until `arm_telemetry` runs, so installing
// ahead of it collects nothing.
spawn_signal_cleanup_task();
let mut command = command;
if cli.operation_key.is_none()
&& (!io::stdin().is_terminal() || !io::stdout().is_terminal())
&& matches!(command, Some(Commands::Resume { .. }))
&& let Some(Commands::Resume { session_id, last }) = command.take()
{
let exec = if last || session_id.is_none() {
headless_exec_args(None, true, &cli.prompt)?
} else {
headless_exec_args(session_id, false, &cli.prompt)?
};
command = Some(Commands::Exec(exec));
}
// A due interactive disclosure belongs to the native TUI. Presentation
// does not gate the default-on policy; unreadable privacy state does.
// Settings can disable this session and erase its pending aggregates.
let surface = telemetry_surface(command.as_ref());
let telemetry_notice_plan = if surface == codewhale_telemetry::Surface::Tui {
codewhale_engine::telemetry_notice::plan_if_due(
cli.config.clone(),
telemetry_session_source_for_run(command.as_ref()),
)
} else {
codewhale_engine::telemetry_notice::TelemetryNoticePlan::NotDue
};
let should_arm_before_dispatch = surface != codewhale_telemetry::Surface::Tui
|| telemetry_notice_plan.should_arm_before_tui();
let pending_telemetry_notice = telemetry_notice_plan.into_pending();
if should_arm_before_dispatch {
arm_telemetry(&cli, command.as_ref());
}
let outcome = Box::pin(run_async_main_dispatch(
cli,
command,
plugin_discovery,
plugin_registry,
pending_telemetry_notice,
))
.await;
finish_telemetry(&outcome, surface).await;
outcome
}
async fn run_async_main_dispatch(
cli: Cli,
command: Option<Commands>,
plugin_discovery: Arc<codewhale_plugins::PluginDiscoveryContext>,
plugin_registry: Arc<codewhale_plugins::PluginRegistry>,
mut pending_telemetry_notice: Option<
codewhale_engine::telemetry_notice::PendingTelemetryNotice,
>,
) -> Result<()> {
logging::set_verbose(cli.verbose || logging::env_requests_verbose_logging());
anyhow::ensure!(
cli.operation_key.is_none()
|| matches!(
command.as_ref(),
Some(Commands::Resume { .. } | Commands::Fork { .. })
),
"--operation-key is an exact read-only recovery of an explicit resume/fork intent"
);
// Install any user prompt overrides from the config directory before an
// engine can compose a system prompt. The override cells are
// first-call-wins; doing this once here keeps every downstream turn
// consistent. Missing files are a no-op (bundled defaults). See #3638.
codewhale_engine::prompts::load_prompt_overrides_from_config_home();
// Plugins own one read-only discovery snapshot per process. Initialize it
// before the subcommand match so plain launch, resume, fork, exec, serve,
// and every other runtime surface use the same plugin trust decision
// (#3916, #4399). Discovery never enables, trusts, executes, or persists a
// bundle.
let mut command = command;
if cli.operation_key.is_none()
&& (!io::stdin().is_terminal() || !io::stdout().is_terminal())
&& matches!(command, Some(Commands::Fork { .. }))
&& let Some(Commands::Fork { session_id, last }) = command.take()
{
let config = load_config_from_cli(&cli)?;
let registry = policy_current_registry(std::sync::Arc::clone(&plugin_registry));
let prepared = prepare_interactive_config(&cli, &config, true)?;
let source_id = resolve_session_id(session_id, last, &prepared.workspace)?;
let (_, new_session_id) = prepare_mounted_session(
&cli,
prepared,
source_id,
MountedHistoryIntent::Fork,
registry,
)
.await?;
command = Some(Commands::Exec(headless_exec_args(
Some(new_session_id),
false,
&cli.prompt,
)?));
}
// Handle subcommands first
if let Some(command) = command {
return match command {
Commands::Doctor(args) => {
if let Some(stamp) = &args.restore {
return crate::doctor::run_restore(stamp);
}
if args.installs {
return crate::doctor::run_installs_report(args.json, args.brief);
}
let config = match load_doctor_config_from_cli(&cli, &args) {
Ok(config) => config,
Err(error) if args.json => return run_doctor_json_config_error(&error),
Err(error) => bail!(doctor_config_error_text(&error)),
};
let workspace = resolve_workspace(&cli);
if args.repair_sessions {
return run_doctor_repair_sessions(args.dry_run);
}
if args.context_json {
run_doctor_context_json(&config, &workspace)
} else if args.json {
run_doctor_json(
&config,
&workspace,
cli.config.as_deref(),
plugin_registry.as_ref(),
)
} else {
let probes = crate::doctor::DoctorProbeRequest {
check_updates: args.check_updates,
probe_api: args.probe_api,
probe_local: args.probe_local,
probe_mcp: args.probe_mcp,
probe_search: args.probe_search,
};
run_doctor(
&config,
&workspace,
cli.config.as_deref(),
effective_config_profile(&cli).as_deref(),
probes,
plugin_registry.as_ref(),
args.all,
)
.await;
if args.fix {
let plan = crate::doctor_fix::plan_fixes(
&config,
&workspace,
plugin_registry.as_ref(),
);
crate::doctor_fix::print_fix_plan(&plan);
if !plan.is_empty() && (args.yes || crate::doctor_fix::confirm_fix(&plan)) {
let results = crate::doctor_fix::apply_fixes(&plan);
crate::doctor_fix::print_apply_results(&results);
}
}
Ok(())
}
}
Commands::SessionDiagnostics(args) => run_session_diagnostics(args),
Commands::Install { source } => {
let config = load_config_from_cli(&cli)?;
codewhale_plugins::mutation::install_from_cli(
source,
config,
(*plugin_registry).clone(),
)
.await
}
Commands::Plugins { command } => tokio::task::spawn_blocking(move || {
let _config = load_structural_config_from_cli(&cli)?;
let workspace = resolve_workspace(&cli);
let registry = plugin_discovery.inspect_registry_for_workspace(&workspace);
run_plugins_cli(command, registry)
})
.await
.context("Plugin command failed to finish")?,
Commands::Setup(args) => {
let config = load_config_from_cli(&cli)?;
let workspace = resolve_workspace(&cli);
run_setup(&config, &workspace, args, plugin_registry.as_ref())
}
Commands::RemoteSetup(args) => {
codewhale_app_server::remote_setup::run_remote_setup(args)
}
Commands::Sessions {
command,
limit,
search,
} => match command {
None => list_sessions(limit, search),
Some(SessionsCommand::List { limit, search }) => list_sessions(limit, search),
Some(SessionsCommand::ScrubSecrets { apply }) => {
run_sessions_scrub_secrets(
apply,
cli.config.clone(),
effective_config_profile(&cli),
)
.await
}
Some(SessionsCommand::PruneImages {
apply,
older_than_days,
}) => run_sessions_prune_images(apply, older_than_days).await,
Some(SessionsCommand::Export {
id,
output,
skip_artifacts,
compression,
force,
}) => {
run_sessions_export(&id, output.as_deref(), skip_artifacts, compression, force)
}
},
Commands::Receipts {
id,
last,
turn,
format,
} => receipts::run_receipts_command(
if last { None } else { id.as_deref() },
turn.as_deref(),
format,
),
Commands::Init => init_project(),
Commands::Login { api_key } => run_login(api_key),
Commands::Logout => run_logout(),
Commands::Auth(args) => match args.command {
TuiAuthCommand::XaiDevice => run_xai_device_auth(cli.config.as_deref()).await,
TuiAuthCommand::Chatgpt => run_chatgpt_pkce_auth(cli.config.as_deref()).await,
TuiAuthCommand::ChatgptRevoke => run_chatgpt_pkce_revoke(cli.config.as_deref()),
TuiAuthCommand::Orcarouter => run_orcarouter_pkce_auth(cli.config.as_deref()).await,
TuiAuthCommand::OrcarouterRevoke => run_orcarouter_revoke(cli.config.as_deref()),
TuiAuthCommand::PluginLogin { provider } => {
let entry = plugin_auth_entry_from_cli(&cli, &provider).await?;
codewhale_plugins::providers::plugin_oauth_login(
provider,
entry.base_url.clone().unwrap(),
entry.oauth.clone().unwrap(),
entry.plugin_authority.clone().unwrap(),
)
.await
}
TuiAuthCommand::PluginLogout { provider } => {
let entry = plugin_auth_entry_from_cli(&cli, &provider).await?;
let policy = codewhale_plugins::activation::extension_host_policy_enabled();
tokio::task::spawn_blocking(move || {
let _scope = codewhale_plugins::activation::PolicyScope::propagate(policy);
codewhale_plugins::registry::verify_plugin_component_authority(
entry.plugin_authority.as_ref().unwrap(),
codewhale_plugins::activation::PluginActivationCapability::Providers,
)
.map_err(anyhow::Error::msg)?;
codewhale_runtime::oauth::plugin_oauth_logout(
&provider,
entry.base_url.as_deref().unwrap(),
entry.oauth.as_ref().unwrap(),
)
})
.await?
}
},
Commands::Models(args) => {
let config = load_config_from_cli(&cli)?;
initialize_cloud_facts(&config);
run_models(&config, args).await
}
Commands::Speech(args) => {
let config = load_config_from_cli(&cli)?;
run_speech(&config, args).await
}
Commands::Exec(args) => {
let config = load_config_from_cli(&cli)?;
let plugin_registry = policy_current_registry(plugin_registry);
let workspace = cli.workspace.clone().unwrap_or_else(|| {
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
});
let mut config = config.clone();
// #4641: `--no-project-config` skips the workspace-specific
// `[workspace]`/`[projects]` user-config overlay so a headless
// launch (e.g. a future Verifiers harness) sees a reproducible
// config surface that depends only on the explicit `--config`.
if !cli.no_project_config {
merge_user_workspace_config(&mut config, cli.config.clone(), &workspace);
}
if let Some(sandbox) = args.sandbox.as_deref() {
let _ = parse_sandbox_policy(sandbox, true, Vec::new(), false, false)?;
config.sandbox_mode = Some(sandbox.to_ascii_lowercase());
}
// Honour CODEWHALE_BASE_URL (forwarded by the CLI dispatcher
// from --base-url) or the user-set legacy DEEPSEEK_BASE_URL.
if let Ok(env_url) = std::env::var("CODEWHALE_BASE_URL")
.or_else(|_| std::env::var("DEEPSEEK_BASE_URL"))
{
let trimmed = env_url.trim();
if !trimmed.is_empty() {
let identity = config
.active_provider_identity()
.map_err(anyhow::Error::msg)?;
config
.set_provider_base_url_override(&identity, Some(trimmed.to_string()))
.map_err(anyhow::Error::msg)?;
}
}
// Honour `--provider` (#4093): a Fleet worker whose profile pins
// a provider launches on that provider even when the parent
// session is on another one. This sets ONLY the non-secret
// provider identity (`config.provider`); credentials/base URL
// still resolve from the worker's own env/config, and the
// endpoint above stays in the previously active provider's
// own table, so a different pinned provider never reads it.
// Must precede model
// resolution so an `auto`/default model resolves to the
// overridden provider's default.
let explicit_provider = args
.provider
.as_deref()
.map(str::trim)
.filter(|provider| !provider.is_empty());
if let Some(provider_arg) = explicit_provider {
apply_exec_provider_override(&mut config, provider_arg)?;
}
let explicit_reasoning = args
.reasoning_effort
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
if let Some(reasoning_arg) = explicit_reasoning {
config.reasoning_effort = normalize_cli_reasoning_effort(reasoning_arg)?;
config.reasoning_effort_inferred_from_legacy_alias = false;
}
initialize_cloud_facts(&config);
// #6705: OpenCode Zen's per-model wire comes from its
// Models.dev catalog. Seed the persisted snapshot (disk only,
// no network) so exec routes the models the picker offers
// instead of only the ones compiled into this build. This runs
// unconditionally, as in the interactive path: the final route
// is not known yet (a selected Fleet operator or `--resume` can
// still move it onto Zen, and both resolve through the lake).
// The read and JSON parse are blocking, so they leave the
// runtime's worker threads.
if let Err(error) = tokio::task::spawn_blocking(
codewhale_engine::models_dev_live::maybe_load_persisted_cache,
)
.await
{
tracing::warn!(
target: "models_dev_live",
%error,
"persisted Models.dev cache load did not complete; keeping bundled"
);
}
if !args.add_dir.is_empty() {
let mut extra = Vec::with_capacity(args.add_dir.len());
for path in &args.add_dir {
let canonical = path.canonicalize().with_context(|| {
format!("--add-dir {} does not exist", path.display())
})?;
if !canonical.is_dir() {
bail!("--add-dir {} is not a directory", path.display());
}
extra.push(canonical);
}
config.extra_trusted_paths = extra;
}
let prompt = resolve_exec_prompt(&args)?;
let resume_session_id = resolve_exec_resume_session_id(&args, &workspace)?;
validate_exec_tool_authority_resume(
args.tool_authority_json.as_deref(),
resume_session_id.is_some(),
)?;
let resume_session = resume_session_id
.as_deref()
.map(load_exec_resume_session)
.transpose()?;
let explicit_model = args
.model
.as_deref()
.map(str::trim)
.filter(|model| !model.is_empty());
if resume_session.is_none() {
let explicit_route_override = explicit_provider.is_some()
|| explicit_model.is_some()
|| codewhale_runtime::config::explicit_launch_provider_override().is_some()
|| codewhale_runtime::config::explicit_launch_model_override().is_some();
let fleet_route_applied = apply_selected_fleet_operator_for_launch(
&mut config,
&workspace,
explicit_route_override,
explicit_reasoning.is_some(),
)?;
if !explicit_route_override
&& !fleet_route_applied
&& let Some(tag) =
codewhale_engine::local_ollama::adopt_live_local_ollama_for_run(
&mut config,
false,
)
.await
{
eprintln!(
"codewhale exec: no cloud API key, so this run uses local Ollama {tag}; no configuration was changed."
);
}
}
// The `codewhale` dispatcher refuses `--provider`/`--model`
// after `exec` and forwards the top-level flags as
// `CODEWHALE_PROVIDER` / `CODEWHALE_MODEL` instead, so a
// resume must treat those launch overrides as explicit or it
// silently restores the saved route (cloud-agent e2e,
// 2026-08-30).
let (resume_explicit_provider, resume_explicit_model) = exec_resume_route_overrides(
explicit_provider,
explicit_model,
codewhale_runtime::config::explicit_launch_provider_override().as_deref(),
codewhale_runtime::config::explicit_launch_model_override().as_deref(),
);
let model = if let Some(saved) = resume_session.as_ref() {
resolve_exec_resume_route(
&mut config,
saved,
resume_explicit_provider,
resume_explicit_model.as_deref(),
)?
} else {
resolve_exec_model(&config, explicit_model)
};
let force_configured_route = should_force_configured_exec_route(
resume_session.is_some(),
explicit_provider,
explicit_model,
);
// A launcher can forward `--yolo` to this binary via the
// CODEWHALE_YOLO env var (which the config loader folds into
// `config.yolo`), not as a CLI flag. Honour either source.
let yolo = cli.yolo || config.yolo.unwrap_or(false);
let env_tool_surface = exec_tool_surface_from_env();
// #6510: every exec runs on the Engine — one turn loop, one
// prompt authority (BASE_PROMPT, AGENTS.md, skills). Without
// a tool grant the run is a one-shot answer on a zero-tool
// surface.
let tool_surface_requested = exec_grants_tool_surface(
&args,
yolo,
resume_session_id.is_some(),
env_tool_surface.is_some(),
);
{
if args.parent_death_watch {
spawn_parent_death_watch();
}
let identity = config
.active_provider_identity()
.map_err(anyhow::Error::msg)?;
let max_subagents = cli.max_subagents.map_or_else(
|| config.max_subagents_for_provider(&identity),
|value| value.clamp(1, MAX_SUBAGENTS),
);
let auto_mode = args.auto || yolo;
// A zero-tool run only spends model steps on output-limit
// continuations; without `--max-turns` those would run to
// the turn wall clock, so it gets a small default ceiling.
let max_turns = exec_max_steps(
args.max_turns
.or((!tool_surface_requested).then_some(ONE_SHOT_DEFAULT_MAX_STEPS)),
);
if !tool_surface_requested {
let ignored = exec_tool_flags_without_grant(&args);
if !ignored.is_empty() {
eprintln!(
"codewhale exec: {} only apply when tools are offered; drop --no-tools to use them.",
ignored.join(", ")
);
}
}
let allowed_tools = if tool_surface_requested {
resolve_exec_allowed_tools(args.allowed_tools.as_deref(), env_tool_surface)
} else {
Some(Vec::new())
};
let disallowed_tools = args
.disallowed_tools
.as_deref()
.map(normalize_exec_tool_names);
run_exec_agent(
&config,
&model,
&prompt,
workspace,
max_subagents,
auto_mode,
args.allow_sandbox_elevation,
args.sandbox.as_deref(),
args.json,
resume_session,
force_configured_route,
args.output_format,
max_turns,
args.max_tool_calls,
allowed_tools,
disallowed_tools,
args.append_system_prompt.clone(),
args.tool_authority_json.clone(),
args.hooks,
std::sync::Arc::clone(&plugin_registry),
!tool_surface_requested,
)
.await
}
}
Commands::Fleet(args) => {
let config = load_config_from_cli(&cli)?;
let workspace = resolve_workspace(&cli);
run_fleet_command(&workspace, &config, args).await
}
Commands::WorkflowTool(args) => {
run_workflow_tool_command(&cli, args, std::sync::Arc::clone(&plugin_registry)).await
}
Commands::Review(args) => {
let config = load_config_from_cli(&cli)?;
run_review(&config, args).await
}
Commands::Pr {
number,
repo,
checkout,
} => {
let config = load_config_from_cli(&cli)?;
run_pr(
&cli,
&config,
number,
repo.as_deref(),
checkout,
pending_telemetry_notice.take(),
Arc::clone(&plugin_registry),
)
.await
}
Commands::Apply(args) => run_apply(args),
Commands::Eval(args) => run_eval(args),
Commands::Scorecard(args) => run_scorecard(args),
Commands::Mcp { command } => {
let config = load_config_from_cli(&cli)?;
let workspace = resolve_workspace(&cli);
run_mcp_command(&config, &workspace, command, plugin_registry.as_ref()).await
}
Commands::Features(command) => {
let config = load_config_from_cli(&cli)?;
run_features_command(&config, command)
}
Commands::Integrations { command } => {
// Identity derivation is structural: credential-bearing
// environment values never enter this path.
let config = load_structural_config_from_cli(&cli)?;
let workspace = resolve_workspace(&cli);
integrations::cli::run(&config, &workspace, command)
}
Commands::Sandbox(args) => {
let config = load_config_from_cli(&cli)?;
run_sandbox_command(&config, args)
}
Commands::Serve(args) => {
let workspace = cli.workspace.clone().unwrap_or_else(|| {
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
});
let http_selected = validate_serve_mode_selection(
args.mcp,
args.http,
args.mobile,
args.web,
args.acp,
)?;
if args.mcp {
let config = load_structural_config_from_cli(&cli)?;
let skills_dir = config.skills_dir();
let skills_config = std::sync::Arc::new(config.skills_config());
codewhale_app_server::mcp_server::run_mcp_server(
workspace,
skills_dir,
skills_config,
)
.await
} else if http_selected {
let (mut config, config_profile) =
load_config_from_cli_with_effective_profile(&cli)?;
initialize_cloud_facts(&config);
let explicit_route_override =
codewhale_runtime::config::explicit_launch_provider_override().is_some()
|| codewhale_runtime::config::explicit_launch_model_override()
.is_some();
apply_selected_fleet_operator_for_launch(
&mut config,
&workspace,
explicit_route_override,
false,
)?;
let cors_origins = resolve_cors_origins(&config, &args.cors_origin);
let bind_host = resolve_serve_bind_host(args.mobile, args.host);
if args.web && bind_host.host != "127.0.0.1" {
bail!("Codewhale web is loopback-only and must bind to 127.0.0.1");
}
codewhale_app_server::runtime_api::run_http_server(
config,
workspace,
std::sync::Arc::clone(&plugin_discovery),
codewhale_app_server::runtime_api::RuntimeApiOptions {
host: bind_host.host,
port: args.port,
workers: args.workers.clamp(1, 8),
cors_origins,
auth_token: args.auth_token,
insecure_no_auth: args.insecure_no_auth,
mobile: args.mobile,
web: args.web,
show_qr: args.qr,
config_path: cli.config.clone(),
config_profile,
control_frontend: cli.control_frontend.clone(),
runtime_event_tap: Some(crate::tui::pet_watch::observe_runtime),
},
)
.await
} else if args.acp {
let (config, config_profile) =
load_config_from_cli_with_effective_profile(&cli)?;
initialize_cloud_facts(&config);
let model = config.default_model();
codewhale_app_server::acp_server::run_acp_server(
config,
model,
workspace,
std::sync::Arc::clone(&plugin_discovery),
cli.config.clone(),
config_profile,
)
.await
} else {
unreachable!("server mode count checked above")
}
}
Commands::Resume { session_id, last } => {
tui::ui::require_interactive_terminal(
io::stdin().is_terminal(),
io::stdout().is_terminal(),
)?;
let config = load_config_from_cli(&cli)?;
let workspace = resolve_workspace(&cli);
let resume_id = resolve_session_id(session_id, last, &workspace)?;
Box::pin(run_interactive(
&cli,
&config,
Some(resume_id),
None,
pending_telemetry_notice.take(),
std::sync::Arc::clone(&plugin_registry),
))
.await
}
Commands::Fork { session_id, last } => {
tui::ui::require_interactive_terminal(
io::stdin().is_terminal(),
io::stdout().is_terminal(),
)?;
let config = load_config_from_cli(&cli)?;
let plugin_registry = policy_current_registry(plugin_registry);
let prepared = prepare_interactive_config(&cli, &config, true)?;
let source_id = resolve_session_id(session_id, last, &prepared.workspace)?;
let (prepared, new_session_id) = prepare_mounted_session(
&cli,
prepared,
source_id,
MountedHistoryIntent::Fork,
std::sync::Arc::clone(&plugin_registry),
)
.await?;
Box::pin(run_interactive_prepared(
&cli,
prepared,
Some(new_session_id),
None,
None,
pending_telemetry_notice.take(),
std::sync::Arc::clone(&plugin_registry),
))
.await
}
};
}
tui::ui::require_interactive_terminal(io::stdin().is_terminal(), io::stdout().is_terminal())?;
// Top-level prompt mode: submit the initial prompt, then keep the TUI alive
// for follow-up messages. Use `codewhale exec` for explicit non-interactive
// one-shot behavior (#2370).
let config = load_config_from_cli(&cli)?;
if let Some(initial_input) = top_level_prompt_initial_input(&cli.prompt) {
return Box::pin(run_interactive(
&cli,
&config,
None,
Some(initial_input),
pending_telemetry_notice.take(),
std::sync::Arc::clone(&plugin_registry),
))
.await;
}
// Handle session resume. Plain `codewhale` starts fresh: interrupted
// snapshots are preserved for explicit resume, but never auto-attached.
let mut startup_notice = None;
let resume_session_id = if cli.continue_session {
let workspace = resolve_workspace(&cli);
resolve_continue_session_id(
&workspace,
io::stdin().is_terminal() && io::stdout().is_terminal(),
)
} else if let Some(id) = cli.resume.clone() {
Some(id)
} else if !cli.fresh {
let workspace = resolve_workspace(&cli);
let checkpoint_notice = preserve_interrupted_checkpoint_for_explicit_resume(&workspace);
// Opt-in auto-resume (#2934). Off by default, so the historical
// "plain `codewhale` starts fresh" behaviour is unchanged unless the
// user asked for something else. The decision never resumes an
// archived, unreadable, or foreign-workspace session; every fallback
// carries a receipt rather than silently starting blank.
let (session_id, notice) = resolve_auto_resume(&workspace);
startup_notice = notice.or(checkpoint_notice);
session_id
} else {
None
};
// Default: Interactive TUI
// --yolo starts in YOLO mode (auto-approve; shell enabled)
Box::pin(run_interactive_with_notice(
&cli,
&config,
resume_session_id,
None,
startup_notice,
pending_telemetry_notice.take(),
plugin_registry,
))
.await
}
/// Resolve the opt-in auto-resume setting into a session id plus a receipt.
///
/// Deliberately scoped to the plain interactive launch. `codewhale "do X"`
/// (top-level prompt) and `codewhale exec` are not covered: silently prefixing
/// a one-shot task with a prior conversation would change what is sent to the
/// model, which is not a layout preference the user opted into.
fn resolve_auto_resume(workspace: &Path) -> (Option<String>, Option<String>) {
use codewhale_engine::session_resume::{ResumeRequest, decide_auto_resume};
let enabled = codewhale_runtime::settings::Settings::load_persisted()
.map(|settings| settings.session_auto_resume)
.unwrap_or(false);
if !enabled {
return (None, None);
}
let Ok(manager) = SessionManager::default_location() else {
return (None, None);
};
let decision = decide_auto_resume(true, &ResumeRequest::default(), workspace, &manager);
(
decision.session_id().map(str::to_string),
decision.status_message(),
)
}
fn prepare_cli_startup(
cli: Cli,
initialize_plugins: impl FnOnce(),
load_dotenv: impl FnOnce(),
) -> (Cli, Option<Commands>) {
initialize_plugins();
let command = cli.command.clone();
let should_load_dotenv = match command.as_ref() {
Some(Commands::Doctor(args)) => args.probe_api || args.probe_local,
Some(Commands::Plugins { .. }) => false,
_ => true,
};
if should_load_dotenv {
load_dotenv();
}
(cli, command)
}
const MAX_WORKSPACE_DOTENV_BYTES: u64 = 1024 * 1024;
#[derive(Debug, Default)]
struct WorkspaceDotenvReport {
path: PathBuf,
loaded: BTreeSet<String>,
ignored: BTreeSet<String>,
}
/// Load the narrow, data-plane subset of a workspace `.env` before Tokio.
///
/// Repository content is not product authority. In particular, a committed
/// `.env` must not be able to redirect `CODEWHALE_HOME`, config/profile files,
/// MCP servers, plugin trust, executable lookup, sandbox/approval posture, or
/// network destinations. Shell-exported values and config/CLI arguments remain
/// the explicit surfaces for those controls.
fn warn_on_workspace_dotenv_result(workspace: &Path) {
match load_workspace_dotenv_credentials(workspace) {
Ok(Some(report)) if !report.ignored.is_empty() => {
eprintln!(
"Codewhale ignored non-credential settings in {}: {}. Use config.toml, CLI flags, or the launching shell for control settings.",
report.path.display(),
display_env_key_set(&report.ignored)
);
}
Ok(_) => {}
Err(error) => {
// The error intentionally contains no file contents or parsed
// values. A malformed or unsafe workspace file fails closed while
// shell/config credentials remain available.
eprintln!("Codewhale did not load workspace .env: {error}");
}
}
}
fn display_env_key_set(keys: &BTreeSet<String>) -> String {
const MAX_DISPLAYED: usize = 12;
let mut labels = keys
.iter()
.take(MAX_DISPLAYED)
.map(|key| {
if key
.chars()
.all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_')
{
key.as_str()
} else {
"<invalid-name>"
}
})
.collect::<Vec<_>>();
if keys.len() > MAX_DISPLAYED {
labels.push("...");
}
labels.join(", ")
}
fn load_workspace_dotenv_credentials(workspace: &Path) -> Result<Option<WorkspaceDotenvReport>> {
let Some(path) = find_workspace_dotenv(workspace)? else {
return Ok(None);
};
load_workspace_dotenv_credentials_from_path(&path).map(Some)
}
/// The nearest `.env` from the resolved launch workspace (`--workspace`, else
/// the current directory) up to its repository root. Searching from the
/// process directory instead would load another tree's credentials when the
/// two differ.
fn find_workspace_dotenv(workspace: &Path) -> Result<Option<PathBuf>> {
let start = if workspace.is_absolute() {
workspace.to_path_buf()
} else {
std::env::current_dir()
.context("could not resolve the current workspace")?
.join(workspace)
};
let boundary = start
.ancestors()
.find(|ancestor| std::fs::symlink_metadata(ancestor.join(".git")).is_ok())
.unwrap_or(start.as_path());
for ancestor in start.ancestors() {
let candidate = ancestor.join(".env");
match std::fs::symlink_metadata(&candidate) {
Ok(_) => return Ok(Some(candidate)),
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => {
return Err(anyhow!(
"could not inspect {}: {error}",
candidate.display()
));
}
}
if ancestor == boundary {
break;
}
}
Ok(None)
}
fn load_workspace_dotenv_credentials_from_path(path: &Path) -> Result<WorkspaceDotenvReport> {
let contents = read_stable_workspace_dotenv(path)?;
let text = std::str::from_utf8(&contents)
.map_err(|_| anyhow!("{} is not valid UTF-8", path.display()))?;
if dotenv_has_variable_expansion(text) {
bail!(
"{} uses variable expansion; workspace .env values must be literal to prevent ambient-secret substitution",
path.display()
);
}
let mut report = WorkspaceDotenvReport {
path: path.to_path_buf(),
..WorkspaceDotenvReport::default()
};
let entries = dotenvy::from_read_iter(std::io::Cursor::new(contents))
.collect::<std::result::Result<Vec<_>, _>>()
.map_err(|_| anyhow!("{} could not be parsed safely", path.display()))?;
for entry in entries {
let (key, value) = entry;
if !is_workspace_dotenv_credential_key(&key) {
report.ignored.insert(key);
continue;
}
if std::env::var_os(&key).is_some() {
continue;
}
// SAFETY: this loader runs synchronously in `main` before the runtime
// owner or Tokio workers are spawned. No concurrent environment reader
// exists inside Codewhale, and later startup code treats this process
// environment as immutable.
unsafe { std::env::set_var(&key, value) };
report.loaded.insert(key);
}
Ok(report)
}
fn is_workspace_dotenv_credential_key(key: &str) -> bool {
codewhale_config::provider::providers_sorted_for_display()
.into_iter()
.any(|provider| provider.env_vars().contains(&key))
|| matches!(
key,
"DEEPSEEK_SEARCH_API_KEY"
| "SOFYA_API_KEY"
| "SERPLY_API_KEY"
| "METASO_API_KEY"
| "BAIDU_SEARCH_API_KEY"
| "TAVILY_API_KEY"
| "DEEPSEEK_SANDBOX_API_KEY"
)
}
fn dotenv_has_variable_expansion(contents: &str) -> bool {
let mut escaped = false;
let mut single_quoted = false;
let mut double_quoted = false;
let mut comment = false;
for ch in contents.chars() {
if comment {
// Reject expansion markers even in comments. This is deliberately
// conservative, and ignoring other comment text prevents an
// unmatched quote there from changing how the next line is read.
if ch == '$' {
return true;
}
if ch == '\n' {
comment = false;
escaped = false;
}
continue;
}
if single_quoted {
if ch == '\'' {
single_quoted = false;
}
continue;
}
if escaped {
escaped = false;
continue;
}
if ch == '\\' {
escaped = true;
continue;
}
if ch == '\'' && !double_quoted {
single_quoted = true;
continue;
}
if ch == '"' {
double_quoted = !double_quoted;
continue;
}
if ch == '#' && !double_quoted {
comment = true;
continue;
}
if ch == '$' {
return true;
}
}
false
}
fn read_stable_workspace_dotenv(path: &Path) -> Result<Vec<u8>> {
let mut file = open_workspace_dotenv_without_following_links(path)?;
let metadata = file
.metadata()
.map_err(|error| anyhow!("could not inspect {}: {error}", path.display()))?;
if !metadata.is_file() {
bail!("{} is not a regular file", path.display());
}
if workspace_dotenv_has_multiple_links(&file, &metadata)? {
bail!(
"{} has multiple filesystem links, not a unique workspace-owned file",
path.display()
);
}
if metadata.len() > MAX_WORKSPACE_DOTENV_BYTES {
bail!(
"{} exceeds the {} byte workspace .env limit",
path.display(),
MAX_WORKSPACE_DOTENV_BYTES
);
}
let mut contents = Vec::with_capacity(metadata.len() as usize);
(&mut file)
.take(MAX_WORKSPACE_DOTENV_BYTES + 1)
.read_to_end(&mut contents)
.map_err(|error| anyhow!("could not read {}: {error}", path.display()))?;
if contents.len() as u64 > MAX_WORKSPACE_DOTENV_BYTES {
bail!(
"{} exceeds the {} byte workspace .env limit",
path.display(),
MAX_WORKSPACE_DOTENV_BYTES
);
}
Ok(contents)
}
#[cfg(unix)]
fn workspace_dotenv_has_multiple_links(
_file: &std::fs::File,
metadata: &std::fs::Metadata,
) -> Result<bool> {
use std::os::unix::fs::MetadataExt;
Ok(metadata.nlink() > 1)
}
#[cfg(windows)]
fn workspace_dotenv_has_multiple_links(
file: &std::fs::File,
_metadata: &std::fs::Metadata,
) -> Result<bool> {
use std::os::windows::io::AsRawHandle;
use windows::Win32::Foundation::HANDLE;
use windows::Win32::Storage::FileSystem::{
BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle,
};
let mut information = BY_HANDLE_FILE_INFORMATION::default();
// SAFETY: `file` owns a live kernel handle for the already-open `.env`;
// `information` remains writable for the duration of this synchronous
// call. No path lookup or re-open occurs here.
unsafe {
GetFileInformationByHandle(HANDLE(file.as_raw_handle()), &mut information)
.map_err(|error| anyhow!("could not inspect workspace .env link count: {error}"))?;
}
Ok(information.nNumberOfLinks > 1)
}
#[cfg(not(any(unix, windows)))]
fn workspace_dotenv_has_multiple_links(
_file: &std::fs::File,
_metadata: &std::fs::Metadata,
) -> Result<bool> {
Ok(false)
}
#[cfg(unix)]
fn open_workspace_dotenv_without_following_links(path: &Path) -> Result<std::fs::File> {
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.read(true)
// `O_NONBLOCK` is inert for regular files but prevents a FIFO named
// `.env` from hanging startup before the metadata check can reject it.
.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK)
.open(path)
.map_err(|error| anyhow!("could not securely open {}: {error}", path.display()))
}
#[cfg(windows)]
fn open_workspace_dotenv_without_following_links(path: &Path) -> Result<std::fs::File> {
use std::os::windows::fs::{MetadataExt, OpenOptionsExt};
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
let file = std::fs::OpenOptions::new()
.read(true)
.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
.open(path)
.map_err(|error| anyhow!("could not securely open {}: {error}", path.display()))?;
let metadata = file
.metadata()
.map_err(|error| anyhow!("could not inspect {}: {error}", path.display()))?;
if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
bail!(
"{} is a reparse point, not a workspace-owned file",
path.display()
);
}
Ok(file)
}
#[cfg(not(any(unix, windows)))]
fn open_workspace_dotenv_without_following_links(path: &Path) -> Result<std::fs::File> {
let metadata = std::fs::symlink_metadata(path)
.map_err(|error| anyhow!("could not inspect {}: {error}", path.display()))?;
if metadata.file_type().is_symlink() {
bail!(
"{} is a symbolic link, not a workspace-owned file",
path.display()
);
}
std::fs::File::open(path)
.map_err(|error| anyhow!("could not securely open {}: {error}", path.display()))
}
/// Run the offline evaluation harness (no network/LLM calls).
fn run_eval(args: EvalArgs) -> Result<()> {
let fail_step = match args.fail_step.as_deref() {
Some(value) => ScenarioStepKind::parse(value)
.map(Some)
.ok_or_else(|| anyhow!("invalid --fail-step '{value}'"))?,
None => None,
};
let config = EvalHarnessConfig {
fail_step,
shell_command: args.shell_command,
shell_expect_token: args.shell_expect_token,
max_output_chars: args.max_output_chars,
record_dir: args.record.clone(),
..EvalHarnessConfig::default()
};
let harness = EvalHarness::new(config);
let run = harness.run().context("evaluation harness failed")?;
let report = run.to_report();
if args.json {
let json = serde_json::to_string_pretty(&report)?;
println!("{json}");
} else {
println!("Offline Eval Harness");
println!("scenario: {}", report.scenario_name);
println!("workspace: {}", report.workspace_root.display());
println!("success: {}", report.metrics.success);
println!("steps: {}", report.metrics.steps);
println!("tool_errors: {}", report.metrics.tool_errors);
println!("duration_ms: {}", report.metrics.duration.as_millis());
if !report.metrics.per_tool.is_empty() {
println!("per_tool:");
for (kind, stats) in &report.metrics.per_tool {
println!(
" {} invocations={} errors={} duration_ms={}",
kind.tool_name(),
stats.invocations,
stats.errors,
stats.total_duration.as_millis()
);
}
}
let failed_steps: Vec<_> = report.steps.iter().filter(|s| !s.success).collect();
if !failed_steps.is_empty() {
println!("failed_steps:");
for step in failed_steps {
let error = step.error.as_deref().unwrap_or("unknown error");
println!(
" {} tool={} error={}",
step.kind.tool_name(),
step.tool_name,
error
);
}
}
}
if report.metrics.success {
Ok(())
} else {
bail!("offline evaluation harness reported failure")
}
}
/// A regression gate threshold must be a finite percentage: `NaN` compares
/// false against every change (a gate that always passes) and infinity
/// disables the gate outright.
fn parse_regression_threshold(raw: &str) -> Result<f64, String> {
let value: f64 = raw
.trim()
.parse()
.map_err(|_| format!("`{raw}` is not a number"))?;
if !value.is_finite() {
return Err(format!("`{raw}` is not a finite percentage"));
}
Ok(value)
}
/// Score a run's token/cache/cost from recorded turns and (optionally) flag
/// regressions against a committed baseline. Offline: reads recorded usage from
/// a JSON file, reuses the pricing layer, never calls a model. Exits non-zero
/// when a baseline is supplied and a metric regresses past the threshold, so it
/// can be wired as a release gate (#3388).
fn run_scorecard(args: ScorecardArgs) -> Result<()> {
use codewhale_engine::scorecard::{RecordedTurn, Scorecard, ScorecardMetrics};
let raw = std::fs::read_to_string(&args.input)
.with_context(|| format!("failed to read scorecard input {}", args.input.display()))?;
let recorded: Vec<RecordedTurn> = serde_json::from_str(&raw)
.with_context(|| format!("failed to parse scorecard input {}", args.input.display()))?;
let card = Scorecard::from_recorded_turns(&recorded);
let regressions = match &args.baseline {
Some(path) => {
let baseline_raw = std::fs::read_to_string(path)
.with_context(|| format!("failed to read baseline {}", path.display()))?;
let baseline: ScorecardMetrics = serde_json::from_str(&baseline_raw)
.with_context(|| format!("failed to parse baseline {}", path.display()))?;
card.metrics.regressions_against(&baseline, args.threshold)
}
None => Vec::new(),
};
if args.json {
let out = serde_json::json!({
"per_turn": card.per_turn,
"metrics": card.metrics,
"regressions": regressions,
});
println!("{}", serde_json::to_string_pretty(&out)?);
} else {
print!("{}", card.to_summary());
for r in ®ressions {
println!(
"REGRESSION {}: baseline {:.4} -> current {:.4} (+{:.1}%)",
r.metric, r.baseline, r.current, r.pct_increase
);
}
}
if regressions.is_empty() {
Ok(())
} else {
bail!(
"{} metric(s) regressed past the {:.1}% threshold",
regressions.len(),
args.threshold
)
}
}
async fn run_fleet_command(workspace: &Path, config: &Config, args: FleetArgs) -> Result<()> {
use codewhale_engine::fleet::alerts::{
FleetAlertAdapterConfig, FleetAlertConfig, FleetAlertDispatcher, FleetAlertEvent,
FleetEnvSecretResolver,
};
use codewhale_engine::fleet::control as fleet_control;
use codewhale_engine::fleet::executor::FleetExecutor;
use codewhale_engine::fleet::manager::{
FleetManager, FleetStatusSnapshot, FleetWorkerInspection,
};
use codewhale_lane::{ControlOperation, ControlSurface};
use codewhale_protocol::fleet::{FleetAlertEventClass, FleetArtifactKind, FleetRunId};
// Every label and every row below comes from the shared Fleet control
// surface, so `codewhale fleet …` and `/fleet …` cannot drift in how they
// describe the same durable ledger (#1888, #4022).
fn print_status(status: &FleetStatusSnapshot) {
println!("{}", fleet_control::render_fleet_status_snapshot(status));
}
fn print_inspection(inspection: &FleetWorkerInspection) {
println!("{}", fleet_control::render_inspection(inspection));
}
fn print_artifacts(inspection: &FleetWorkerInspection) {
println!("{}", fleet_control::render_artifacts(inspection));
}
/// Print one shared control receipt on the CLI surface.
fn emit_fleet_receipt(receipt: &codewhale_lane::ControlReceipt) -> Result<()> {
if receipt.is_error() {
eprintln!("{}", receipt.render());
let detail = receipt
.failure
.as_ref()
.map(ToString::to_string)
.unwrap_or_else(|| receipt.outcome.as_str().to_string());
bail!("{}: {detail}", receipt.operation_id);
}
println!("{}", receipt.render());
Ok(())
}
fn print_logs(workspace: &Path, inspection: &FleetWorkerInspection) -> Result<()> {
let mut printed = false;
for artifact in inspection
.artifacts
.iter()
.filter(|artifact| matches!(artifact.kind, FleetArtifactKind::Log))
{
let path = workspace.join(&artifact.path);
println!("== {} ==", artifact.path.display());
let contents = std::fs::read_to_string(&path)
.with_context(|| format!("reading Fleet log {}", path.display()))?;
let preview: String = contents.chars().take(16 * 1024).collect();
// Worker logs can contain captured terminal bytes (a child TUI's
// mouse-tracking handshake, SGR, OSC). Printing them raw would
// re-arm mouse reporting in the caller's shell and leave it
// executing escape fragments after this command exits.
let mut safe_preview = String::with_capacity(preview.len());
codewhale_tui_kit::osc8::strip_ansi_into(&preview, &mut safe_preview);
print!("{safe_preview}");
if contents.chars().count() > preview.chars().count() {
println!("\n[truncated]");
} else if !preview.ends_with('\n') {
println!();
}
printed = true;
}
if !printed {
println!("logs: none");
}
Ok(())
}
fn alert_event_class(arg: FleetAlertEventArg) -> FleetAlertEventClass {
match arg {
FleetAlertEventArg::Stale => FleetAlertEventClass::Stale,
FleetAlertEventArg::RestartExhausted => FleetAlertEventClass::RestartExhausted,
FleetAlertEventArg::NeedsHuman => FleetAlertEventClass::NeedsHuman,
FleetAlertEventArg::BudgetExceeded => FleetAlertEventClass::BudgetExceeded,
FleetAlertEventArg::VerifierFailed => FleetAlertEventClass::VerifierFailed,
FleetAlertEventArg::RunCompleted => FleetAlertEventClass::RunCompleted,
}
}
fn alert_status(class: FleetAlertEventClass, override_status: Option<String>) -> String {
if let Some(status) = override_status {
return status;
}
match class {
FleetAlertEventClass::Stale => "stale",
FleetAlertEventClass::RestartExhausted => "failed",
FleetAlertEventClass::NeedsHuman => "needs_human",
FleetAlertEventClass::BudgetExceeded => "budget_exceeded",
FleetAlertEventClass::VerifierFailed => "verifier_failed",
FleetAlertEventClass::RunCompleted => "completed",
}
.to_string()
}
fn alert_adapter(args: &FleetAlertDryRunArgs) -> FleetAlertAdapterConfig {
match args.adapter {
FleetAlertAdapterArg::Slack => FleetAlertAdapterConfig::Slack {
webhook_env: args.slack_webhook_env.clone(),
channel: None,
},
FleetAlertAdapterArg::Webhook => FleetAlertAdapterConfig::Webhook {
url_env: args.webhook_url_env.clone(),
secret_env: args.webhook_secret_env.clone(),
},
FleetAlertAdapterArg::PagerDuty => FleetAlertAdapterConfig::PagerDuty {
routing_key_env: args.pagerduty_routing_key_env.clone(),
severity: args.pagerduty_severity.clone(),
},
}
}
let fleet_config = config.fleet_config();
// `fleet run --check` must not conjure the ledger or the sub-agent state it
// would write to, so it validates before either is opened below.
if let FleetCommand::Run(run_args) = &args.command
&& run_args.check
{
initialize_cloud_facts(config);
let check = FleetManager::check_task_spec_path_in(
workspace,
fleet_config,
config.default_model(),
config.clone(),
&run_args.task_spec,
)?;
println!(
"Fleet spec ok: {} ({} task{}). Nothing was created or launched.",
run_args.task_spec.display(),
check.task_count,
if check.task_count == 1 { "" } else { "s" }
);
for warning in &check.warnings {
println!("warning: {warning}");
}
return Ok(());
}
let identity = config
.active_provider_identity()
.map_err(anyhow::Error::msg)?;
// Probe the durable ledger *before* opening the manager: FleetManager::open
// creates `.codewhale/fleet.jsonl` as a side effect, so a later probe would
// always find a ledger and the CLI would report availability differently
// from the slash surface for the same workspace (#4022).
let fleet_context = fleet_control::fleet_control_context(workspace);
// Probing is not enough on its own: `FleetManager::open` *creates* the
// ledger, and it used to run for every subcommand before this match. That
// made `codewhale fleet status` in a ledgerless workspace print
// "no_fleet_ledger" while simultaneously creating the file it said was
// missing — and the next invocation then reported an empty ledger as if a
// Fleet had existed all along. Refuse the control verbs here, before the
// manager exists, so the CLI and `/fleet` agree and neither surface
// conjures the store it is reporting on (#4022).
if let Some(operation) = match &args.command {
FleetCommand::List => Some(ControlOperation::FleetList),
FleetCommand::Status => Some(ControlOperation::FleetStatus),
FleetCommand::Interrupt { .. } => Some(ControlOperation::FleetInterrupt),
FleetCommand::Resume { .. } => Some(ControlOperation::FleetResume),
_ => None,
} {
let descriptor = operation.descriptor();
let availability = descriptor.availability(ControlSurface::Cli, fleet_context);
if !availability.is_available() {
return emit_fleet_receipt(&codewhale_lane::ControlReceipt::unavailable(
descriptor,
ControlSurface::Cli,
availability,
));
}
}
// The configured route is the operator: fleet workers without a
// task/profile model pin inherit the session's active model.
let owned_workspace = workspace.to_path_buf();
let owned_config = config.clone();
let manager = codewhale_runtime::runtime_control::owner_work(move || {
let config = &owned_config;
let max_subagents = config.max_subagents_for_provider(&identity);
let coordination_manager =
codewhale_engine::tools::subagent::new_shared_subagent_manager_with_timeout(
owned_workspace.clone(),
max_subagents,
config
.max_admitted_subagents_for_provider(&identity)
.max(max_subagents),
Duration::from_secs(config.subagent_heartbeat_timeout_secs_for_provider(&identity)),
config.launch_concurrency_for_provider(&identity),
);
let manager = FleetManager::open(&owned_workspace)?
.with_exec_config(fleet_config.exec.clone())
.with_fleet_config(fleet_config)
.with_sub_agent_manager(coordination_manager)
.with_session_model(config.default_model())
.with_route_config(config.clone());
Ok(manager)
})
.await?;
match args.command {
FleetCommand::Init => {
println!("Fleet ledger: {}", manager.ledger_path().display());
Ok(())
}
FleetCommand::Run(args) => {
initialize_cloud_facts(config);
let max_workers = args.max_workers.clamp(1, 128);
let manager =
manager.with_stale_after(Duration::from_secs(args.stale_after_seconds.max(1)));
let launch_manager = manager.clone();
let task_spec = args.task_spec.clone();
let report = codewhale_runtime::runtime_control::owner_work(move || {
launch_manager.create_run_from_task_spec_path(&task_spec, max_workers)
})
.await?;
println!(
"Fleet run: {} tasks={} leased={} queued={}",
report.run_id.0, report.task_count, report.leased, report.queued
);
for warning in &report.warnings {
println!("warning: {warning}");
}
println!("workers:");
for worker_id in &report.worker_ids {
println!(" {worker_id}");
}
if args.once {
print_status(&manager.run_status(&report.run_id)?);
return Ok(());
}
println!(
"manager loop running; use `codewhale fleet status`, `inspect`, `interrupt`, or `stop --all` from another terminal."
);
let executor = FleetExecutor::new(workspace)
.with_sessions_dir(session_manager::default_sessions_dir()?);
let codewhale_binary = fleet::executor::configured_codewhale_binary();
let status = manager
.run_to_completion(
&report.run_id,
max_workers,
executor,
&codewhale_binary,
None,
Duration::from_secs(2),
)
.await?
.0;
print_status(&status);
Ok(())
}
FleetCommand::List => emit_fleet_receipt(&fleet_control::execute_fleet_control_with(
ControlSurface::Cli,
workspace,
fleet_context,
&manager,
ControlOperation::FleetList,
None,
)),
FleetCommand::Status => emit_fleet_receipt(&fleet_control::execute_fleet_control_with(
ControlSurface::Cli,
workspace,
fleet_context,
&manager,
ControlOperation::FleetStatus,
None,
)),
FleetCommand::Inspect { worker_id } => {
print_inspection(&manager.inspect_worker(&worker_id)?);
Ok(())
}
FleetCommand::Logs { worker_id } => {
let inspection = manager.inspect_worker(&worker_id)?;
print_logs(workspace, &inspection)
}
FleetCommand::Artifacts { worker_id } => {
let inspection = manager.inspect_worker(&worker_id)?;
print_artifacts(&inspection);
Ok(())
}
FleetCommand::Interrupt { worker_id } => {
emit_fleet_receipt(&fleet_control::execute_fleet_control_with(
ControlSurface::Cli,
workspace,
fleet_context,
&manager,
ControlOperation::FleetInterrupt,
Some(&worker_id),
))
}
FleetCommand::Restart { worker_id } => {
let restart_manager = manager.clone();
let report = codewhale_runtime::runtime_control::owner_work(move || {
restart_manager.restart_worker(&worker_id)
})
.await?;
print_inspection(&report.inspection);
println!(
"manager loop running for restarted run {}; use `codewhale fleet status`, `inspect`, `interrupt`, or `stop --all` from another terminal.",
report.run_id.0
);
let executor = FleetExecutor::new(workspace)
.with_sessions_dir(session_manager::default_sessions_dir()?);
let codewhale_binary = fleet::executor::configured_codewhale_binary();
let status = manager
.run_to_completion(
&report.run_id,
report.max_workers,
executor,
&codewhale_binary,
None,
Duration::from_secs(2),
)
.await?
.0;
print_status(&status);
Ok(())
}
FleetCommand::Resume {
run_id,
stale_after_seconds,
} => {
let manager = manager.with_stale_after(Duration::from_secs(stale_after_seconds.max(1)));
let owned_workspace = workspace.to_path_buf();
let receipt = codewhale_runtime::runtime_control::owner_work(move || {
Ok(fleet_control::execute_fleet_control_with(
ControlSurface::Cli,
&owned_workspace,
fleet_context,
&manager,
ControlOperation::FleetResume,
Some(&run_id),
))
})
.await?;
emit_fleet_receipt(&receipt)
}
FleetCommand::Stop { all } => {
if !all {
bail!("pass --all to stop all Fleet work");
}
let stopped = manager.stop_all()?;
println!("stopped: {stopped}");
Ok(())
}
FleetCommand::AlertDryRun(args) => {
let class = alert_event_class(args.event);
let adapter = alert_adapter(&args);
let event = FleetAlertEvent {
class,
run_id: FleetRunId::from(args.run_id.clone()),
worker_id: args.worker_id.clone(),
task_id: args.task_id.clone(),
status: alert_status(class, args.status.clone()),
reason: args.reason.clone(),
};
let dispatcher = FleetAlertDispatcher::new(
FleetAlertConfig::dry_run_for_adapter(adapter),
FleetEnvSecretResolver,
);
let deliveries = dispatcher.dispatch(&event)?;
for delivery in deliveries {
println!(
"{}",
serde_json::to_string_pretty(&delivery.redacted_payload)?
);
}
Ok(())
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WriteStatus {
Created,
Overwritten,
SkippedExists,
}
fn ensure_parent_dir(path: &Path) -> Result<()> {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory for {}", parent.display()))?;
}
Ok(())
}
fn write_template_file(path: &Path, contents: &str, force: bool) -> Result<WriteStatus> {
ensure_parent_dir(path)?;
if path.exists() && !force {
return Ok(WriteStatus::SkippedExists);
}
let status = if path.exists() {
WriteStatus::Overwritten
} else {
WriteStatus::Created
};
std::fs::write(path, contents)
.with_context(|| format!("Failed to write template at {}", path.display()))?;
Ok(status)
}
fn init_mcp_config(path: &Path, force: bool) -> Result<WriteStatus> {
Ok(match codewhale_engine::mcp::init_config(path, force)? {
codewhale_engine::mcp::McpWriteStatus::Created => WriteStatus::Created,
codewhale_engine::mcp::McpWriteStatus::Overwritten => WriteStatus::Overwritten,
codewhale_engine::mcp::McpWriteStatus::SkippedExists => WriteStatus::SkippedExists,
})
}
fn skills_template(name: &str) -> String {
format!(
"\
---\n\
name: {name}\n\
description: Quick repo diagnostics and setup guidance\n\
allowed-tools: diagnostics, list_dir, read_file, grep_files, git_status, git_diff\n\
---\n\n\
When this skill is active:\n\
1. Run the diagnostics tool to report workspace and sandbox status.\n\
2. Skim key project files (README.md, Cargo.toml, AGENTS.md) before editing.\n\
3. Prefer small, validated changes and summarize what you verified.\n\
"
)
}
fn init_skills_dir(skills_dir: &Path, force: bool) -> Result<(PathBuf, WriteStatus)> {
std::fs::create_dir_all(skills_dir)
.with_context(|| format!("Failed to create skills dir {}", skills_dir.display()))?;
let skill_name = "getting-started";
let skill_path = skills_dir.join(skill_name).join("SKILL.md");
ensure_parent_dir(&skill_path)?;
let status = write_template_file(&skill_path, &skills_template(skill_name), force)?;
Ok((skill_path, status))
}
fn tools_readme_template() -> &'static str {
"# Local tools\n\n\
Drop self-describing scripts here so they can be discovered by\n\
`codewhale setup --status` and surfaced in `codewhale doctor`.\n\n\
When `[tools.plugin_dir]` is set in config.toml (or when the default\n\
`~/.codewhale/tools/` directory exists), they are auto-discovered and\n\
registered as model-visible tools.\n\n\
Each script should start with a frontmatter-style header so the\n\
description is visible without executing the file and the agent knows\n\
the tool name, description, and input schema:\n\n\
```\n\
# name: my-tool\n\
# description: One-line summary of what this tool does\n\
# usage: my-tool [args...]\n\
```\n\n\
The directory is intentionally not auto-loaded into the agent's tool\n\
catalog. Wire individual tools through MCP, hooks, or skills when you\n\
want them available inside a session.\n"
}
fn tools_example_script() -> &'static str {
"#!/usr/bin/env sh\n\
# name: example\n\
# description: Print a confirmation that local tool discovery works\n\
# usage: example [name]\n\
printf 'codewhale local tool ok: %s\\n' \"${1:-world}\"\n"
}
fn init_tools_dir(tools_dir: &Path, force: bool) -> Result<(PathBuf, WriteStatus, WriteStatus)> {
std::fs::create_dir_all(tools_dir)
.with_context(|| format!("Failed to create tools dir {}", tools_dir.display()))?;
let readme_path = tools_dir.join("README.md");
let readme_status = write_template_file(&readme_path, tools_readme_template(), force)?;
let example_path = tools_dir.join("example.sh");
let example_status = write_template_file(&example_path, tools_example_script(), force)?;
Ok((tools_dir.to_path_buf(), readme_status, example_status))
}
fn plugins_readme_template() -> &'static str {
"# Local plugins\n\n\
Each Codewhale plugin bundle lives in its own subdirectory with a\n\
versioned `plugin.toml`. User bundles live here; workspace bundles live\n\
under `<workspace>/.codewhale/plugins/`. Both are discovered read-only,\n\
untrusted, and disabled by default.\n\n\
A v0.9.1 bundle layout looks like:\n\n\
```\n\
plugins/\n\
my-plugin/\n\
plugin.toml\n\
skills/\n\
my-skill/SKILL.md\n\
```\n\n\
Run `/plugin validate`, `/plugin show <name>`, then `/plugin enable <name>`.\n\
Enablement opens a content- and capability-bound trust review;\n\
confirm the displayed `/plugin trust` command to create an owner-only,\n\
content-addressed runtime snapshot, then enable the bundle. Remote MCP\n\
authentication must name environment sources; never store secret values\n\
in `plugin.toml`.\n\n\
Codewhale activates declarative Skills, MCP servers, Commands, Agent\n\
profiles, and Hooks through their existing engines. LSP, native\n\
extensions, filesystem grants, and lifecycle mutation stay inventoried\n\
and inactive; a mixed bundle can still activate supported components.\n\
Marketplace catalogs, install, update, and uninstall all feed this same\n\
disabled-and-untrusted review path; none grants automatic trust. Codewhale\n\
does not scan other applications for ambient plugins.\n"
}
fn plugin_example_manifest_template() -> &'static str {
"schema_version = 1\n\n\
[plugin]\n\
name = \"example\"\n\
version = \"0.1.0\"\n\
description = \"Starter Codewhale plugin bundle\"\n\n\
[skills]\n\
path = \"skills\"\n"
}
fn plugin_example_skill_template() -> &'static str {
"---\n\
name: hello\n\
description: Explain that the example plugin bundle is active.\n\
---\n\n\
Tell the user this instruction came from the namespaced\n\
`example:hello` plugin skill. Do not perform side effects.\n"
}
fn init_plugins_dir(
plugins_dir: &Path,
force: bool,
) -> Result<(
PathBuf,
PathBuf,
PathBuf,
WriteStatus,
WriteStatus,
WriteStatus,
)> {
std::fs::create_dir_all(plugins_dir)
.with_context(|| format!("Failed to create plugins dir {}", plugins_dir.display()))?;
let readme_path = plugins_dir.join("README.md");
let readme_status = write_template_file(&readme_path, plugins_readme_template(), force)?;
let manifest_path = plugins_dir.join("example").join("plugin.toml");
ensure_parent_dir(&manifest_path)?;
let manifest_status =
write_template_file(&manifest_path, plugin_example_manifest_template(), force)?;
let skill_path = plugins_dir
.join("example")
.join("skills")
.join("hello")
.join("SKILL.md");
ensure_parent_dir(&skill_path)?;
let skill_status = write_template_file(&skill_path, plugin_example_skill_template(), force)?;
Ok((
readme_path,
manifest_path,
skill_path,
readme_status,
manifest_status,
skill_status,
))
}
/// Resolve the user-supplied CORS origins for `codewhale serve --http`.
///
/// Sources, in priority order (later sources extend earlier ones):
/// 1. `--cors-origin URL` flags (repeatable)
/// 2. `CODEWHALE_CORS_ORIGINS` env var (comma-separated),
/// then `DEEPSEEK_CORS_ORIGINS` as an alias
/// 3. `[runtime_api] cors_origins = [...]` in `config.toml`
///
/// The runtime API always allows the built-in dev defaults
/// (localhost:3000, localhost:1420, tauri://localhost). User entries are
/// appended on top — empty strings are skipped, and duplicates are deduped
/// while preserving first-seen order. Whalescale#255 / #561.
fn resolve_cors_origins(config: &Config, flag_origins: &[String]) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut push = |raw: &str| {
let trimmed = raw.trim();
if trimmed.is_empty() {
return;
}
if !out.iter().any(|existing| existing == trimmed) {
out.push(trimmed.to_string());
}
};
for o in flag_origins {
push(o);
}
if let Ok(env_value) =
std::env::var("CODEWHALE_CORS_ORIGINS").or_else(|_| std::env::var("DEEPSEEK_CORS_ORIGINS"))
{
for piece in env_value.split(',') {
push(piece);
}
}
if let Some(rt) = &config.runtime_api
&& let Some(list) = &rt.cors_origins
{
for o in list {
push(o);
}
}
out
}
fn deepseek_home_dir() -> PathBuf {
codewhale_config::codewhale_home().unwrap_or_else(|_| {
codewhale_paths::user_home()
.map_or_else(|| PathBuf::from(".codewhale"), |h| h.join(".codewhale"))
})
}
/// Resolve the default tools directory. Mirrors `default_skills_dir` shape.
fn default_tools_dir() -> PathBuf {
deepseek_home_dir().join("tools")
}
/// Resolve the default plugins directory.
fn default_plugins_dir() -> PathBuf {
deepseek_home_dir().join("plugins")
}
/// Default location for crash/offline-queue checkpoints managed by the TUI.
fn default_checkpoints_dir() -> PathBuf {
deepseek_home_dir().join("sessions").join("checkpoints")
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct CleanPlan {
targets: Vec<PathBuf>,
}
fn collect_clean_targets(checkpoints_dir: &Path) -> CleanPlan {
// Unsent input is not regenerable. Preserve legacy and per-session queue
// files by name even if their contents are malformed or from a newer
// schema; cleanup must not erase drafts it cannot currently decode.
let mut targets: Vec<PathBuf> = std::fs::read_dir(checkpoints_dir)
.map(|entries| {
entries
.filter_map(|entry| entry.ok().map(|e| e.path()))
.filter(|p| p.is_file() && p.extension().is_some_and(|ext| ext == "json"))
.filter(|p| {
p.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| {
!codewhale_engine::session_manager::is_offline_queue_file(name)
})
})
.collect()
})
.unwrap_or_default();
targets.sort();
CleanPlan { targets }
}
fn execute_clean_plan(plan: &CleanPlan) -> Result<Vec<PathBuf>> {
let mut removed = Vec::with_capacity(plan.targets.len());
for path in &plan.targets {
std::fs::remove_file(path)
.with_context(|| format!("Failed to remove {}", path.display()))?;
removed.push(path.clone());
}
Ok(removed)
}
fn run_setup(
config: &Config,
workspace: &Path,
args: SetupArgs,
plugins: &codewhale_plugins::PluginRegistry,
) -> Result<()> {
if args.status {
return run_setup_status(config, workspace, plugins);
}
if args.clean {
return run_setup_clean(&default_checkpoints_dir(), args.force);
}
use codewhale_palette as palette;
use colored::Colorize;
let (aqua_r, aqua_g, aqua_b) = palette::WHALE_ACTION_RGB;
let (sky_r, sky_g, sky_b) = palette::WHALE_ACTION_RGB;
let any_explicit = args.mcp || args.skills || args.tools || args.plugins;
let run_mcp = args.mcp || args.all || !any_explicit;
let run_skills = args.skills || args.all || !any_explicit;
let run_tools = args.tools || args.all;
let run_plugins = args.plugins || args.all;
println!(
"{}",
"Codewhale Setup".truecolor(aqua_r, aqua_g, aqua_b).bold()
);
println!("{}", "==============".truecolor(sky_r, sky_g, sky_b));
println!(
"Workspace: {}",
codewhale_util::utils::display_path(workspace)
);
if run_mcp {
let mcp_path = config.mcp_config_path();
let status = init_mcp_config(&mcp_path, args.force)?;
match status {
WriteStatus::Created => {
println!(" ✓ Created MCP config at {}", mcp_path.display());
}
WriteStatus::Overwritten => {
println!(" ✓ Overwrote MCP config at {}", mcp_path.display());
}
WriteStatus::SkippedExists => {
println!(" · MCP config already exists at {}", mcp_path.display());
}
}
println!(
" Next: edit the file, then run `codewhale mcp list` or `codewhale mcp tools`."
);
}
if run_skills {
let skills_dir = if args.local {
workspace.join(".codewhale").join("skills")
} else {
config.skills_dir()
};
let (skill_path, status) = init_skills_dir(&skills_dir, args.force)?;
match status {
WriteStatus::Created => {
println!(" ✓ Created example skill at {}", skill_path.display());
}
WriteStatus::Overwritten => {
println!(" ✓ Overwrote example skill at {}", skill_path.display());
}
WriteStatus::SkippedExists => {
println!(
" · Example skill already exists at {}",
skill_path.display()
);
}
}
if args.local {
println!(
" Project skills dir (loads after workspace trust): {}",
codewhale_util::utils::display_path(&skills_dir)
);
} else {
println!(
" Skills dir: {}",
codewhale_util::utils::display_path(&skills_dir)
);
}
println!(" Next: run the TUI and use `/skills` then `/skill getting-started`.");
}
if run_tools {
let tools_dir = default_tools_dir();
let (dir, readme_status, example_status) = init_tools_dir(&tools_dir, args.force)?;
report_write_status("Tools README", &dir.join("README.md"), readme_status);
report_write_status("Example tool", &dir.join("example.sh"), example_status);
println!(
" Tools dir: {}",
codewhale_util::utils::display_path(&dir)
);
println!(" Next: drop scripts here; surface them via skills/MCP when ready.");
}
if run_plugins {
let plugins_dir = default_plugins_dir();
let (readme_path, manifest_path, skill_path, readme_status, manifest_status, skill_status) =
init_plugins_dir(&plugins_dir, args.force)?;
report_write_status("Plugins README", &readme_path, readme_status);
report_write_status("Example plugin manifest", &manifest_path, manifest_status);
report_write_status("Example plugin skill", &skill_path, skill_status);
println!(
" Plugins dir: {}",
codewhale_util::utils::display_path(&plugins_dir)
);
println!(" Next: run `/plugin validate`, review `example`, then trust and enable it.");
}
let sandbox =
codewhale_sandbox::get_platform_sandbox_with_bwrap_preference(config.prefers_bwrap());
if let Some(kind) = sandbox {
println!(" ✓ Sandbox available: {kind}");
} else {
println!(" · Sandbox not available on this platform (best-effort only).");
}
Ok(())
}
fn report_write_status(label: &str, path: &Path, status: WriteStatus) {
match status {
WriteStatus::Created => {
println!(" ✓ Created {label} at {}", path.display());
}
WriteStatus::Overwritten => {
println!(" ✓ Overwrote {label} at {}", path.display());
}
WriteStatus::SkippedExists => {
println!(" · {label} already exists at {}", path.display());
}
}
}
/// Source of the resolved API key, used only by static doctor/setup reports.
///
/// These reports must not migrate a legacy secret store or acquire a
/// write-capable credential handle just to label a source.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ApiKeySource {
ConfigDeclared,
EnvDeclared,
ExternalAuthDeclared,
SecretStoreUnprobed,
SecretStoreUnavailable,
OAuth,
ExternalConsent,
NoAuth,
LocalRuntime,
Unknown,
}
/// What structural diagnostics can truthfully say about credential
/// availability without consulting environment values or durable stores.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CredentialAvailability {
Present,
NotRequired,
Unknown,
NotProbed,
Unavailable,
}
impl CredentialAvailability {
fn label(self) -> &'static str {
match self {
Self::Present => "present",
Self::NotRequired => "not_required",
Self::Unknown => "unknown",
Self::NotProbed => "not_probed",
Self::Unavailable => "unavailable",
}
}
fn certifies_ready(self) -> bool {
matches!(self, Self::Present | Self::NotRequired)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct CredentialDiagnostic {
source: ApiKeySource,
availability: CredentialAvailability,
}
impl CredentialDiagnostic {
const fn new(source: ApiKeySource, availability: CredentialAvailability) -> Self {
Self {
source,
availability,
}
}
}
fn resolve_credential_diagnostic(config: &Config) -> CredentialDiagnostic {
let Ok(identity) = config.active_provider_identity() else {
return CredentialDiagnostic::new(
ApiKeySource::Unknown,
CredentialAvailability::Unavailable,
);
};
let provider = identity.provider;
let base_url = config.base_url_for_route(&identity);
let auth_mode = config.auth_mode_for_provider(&identity);
if codewhale_config::auth_mode_disables_api_key(auth_mode.as_deref()) {
return CredentialDiagnostic::new(
ApiKeySource::NoAuth,
CredentialAvailability::NotRequired,
);
}
if !codewhale_config::auth_mode_requires_api_key(auth_mode.as_deref())
&& (codewhale_runtime::config::provider_route_is_keyless_self_hosted(provider, &base_url)
|| codewhale_runtime::config::base_url_uses_local_host(&base_url))
{
return CredentialDiagnostic::new(
ApiKeySource::LocalRuntime,
CredentialAvailability::NotRequired,
);
}
let custom_endpoint = config.provider_uses_custom_endpoint(&identity);
if !custom_endpoint && provider == codewhale_config::ProviderKind::OpenaiCodex {
return config
.external_credential_consent_status(&identity)
.filter(|status| status.route_state == "active")
.map_or_else(
|| {
CredentialDiagnostic::new(
ApiKeySource::OAuth,
CredentialAvailability::NotProbed,
)
},
|_| {
CredentialDiagnostic::new(
ApiKeySource::ExternalConsent,
CredentialAvailability::NotProbed,
)
},
);
}
if !custom_endpoint
&& provider == codewhale_config::ProviderKind::Xai
&& auth_mode
.as_deref()
.is_some_and(codewhale_runtime::oauth::auth_mode_uses_xai_oauth)
{
return config
.external_credential_consent_status(&identity)
.filter(|status| status.route_state == "active")
.map_or_else(
|| {
CredentialDiagnostic::new(
ApiKeySource::OAuth,
CredentialAvailability::NotProbed,
)
},
|_| {
CredentialDiagnostic::new(
ApiKeySource::ExternalConsent,
CredentialAvailability::NotProbed,
)
},
);
}
let provider_config = config.provider_config();
let provider_config_key_kind = provider_config
.and_then(|entry| entry.api_key.as_deref())
.map(codewhale_config::classify_config_api_key_value);
// DeepSeek-CN shares `[providers.deepseek]`'s key (the two identities
// used to share the top-level `api_key`, #6394).
let root_key_kind = (identity.key.as_str()
== codewhale_config::descriptors::LEGACY_DEEPSEEK_CN.id)
.then(|| {
config
.builtin_provider_identity(codewhale_config::ProviderKind::Deepseek)
.ok()
})
.flatten()
.and_then(|owner| config.provider_config_for(&owner))
.and_then(|entry| entry.api_key.as_deref())
.map(codewhale_config::classify_config_api_key_value);
if matches!(
provider_config_key_kind,
Some(codewhale_config::ConfigApiKeyValueKind::Literal)
) || matches!(
root_key_kind,
Some(codewhale_config::ConfigApiKeyValueKind::Literal)
) {
CredentialDiagnostic::new(
ApiKeySource::ConfigDeclared,
CredentialAvailability::Present,
)
} else if config
.provider_config()
.and_then(|entry| entry.api_key_env.as_deref())
.is_some_and(|name| !name.trim().is_empty())
{
CredentialDiagnostic::new(ApiKeySource::EnvDeclared, CredentialAvailability::NotProbed)
} else if config
.provider_config()
.and_then(|entry| entry.auth.as_ref())
.is_some()
{
CredentialDiagnostic::new(
ApiKeySource::ExternalAuthDeclared,
CredentialAvailability::NotProbed,
)
} else if matches!(
provider_config_key_kind,
Some(codewhale_config::ConfigApiKeyValueKind::SecretStoreSentinel)
) || matches!(
root_key_kind,
Some(codewhale_config::ConfigApiKeyValueKind::SecretStoreSentinel)
) {
if config.should_skip_secret_store_for_provider(&identity) {
return CredentialDiagnostic::new(
ApiKeySource::SecretStoreUnavailable,
CredentialAvailability::Unavailable,
);
}
// The sentinel is a declaration that runtime resolution should use
// the secret-store layer, never a literal key. Doctor does not read it.
CredentialDiagnostic::new(
ApiKeySource::SecretStoreUnprobed,
CredentialAvailability::NotProbed,
)
} else if !config.should_skip_secret_store_for_provider(&identity) {
// No literal config declaration was found, but this route can continue
// through the durable store and ambient provider environment. Ordinary
// doctor deliberately does not inspect either source.
CredentialDiagnostic::new(
ApiKeySource::SecretStoreUnprobed,
CredentialAvailability::NotProbed,
)
} else {
CredentialDiagnostic::new(ApiKeySource::Unknown, CredentialAvailability::Unknown)
}
}
#[cfg(test)]
fn resolve_api_key_source(config: &Config) -> ApiKeySource {
resolve_credential_diagnostic(config).source
}
#[cfg(test)]
fn provider_config_table_key(provider: codewhale_config::ProviderKind) -> String {
format!(
"providers.{}",
codewhale_config::descriptors::compatibility_for_kind(provider).config_key
)
}
fn count_dir_entries(dir: &Path) -> usize {
std::fs::read_dir(dir)
.map(|entries| entries.filter_map(std::result::Result::ok).count())
.unwrap_or(0)
}
fn skills_count_for(dir: &Path) -> usize {
if !dir.exists() {
return 0;
}
codewhale_skills::SkillRegistry::discover(dir).len()
}
fn run_setup_status(
config: &Config,
workspace: &Path,
plugins: &codewhale_plugins::PluginRegistry,
) -> Result<()> {
use codewhale_palette as palette;
use colored::Colorize;
let (aqua_r, aqua_g, aqua_b) = palette::WHALE_ACTION_RGB;
let (sky_r, sky_g, sky_b) = palette::WHALE_ACTION_RGB;
println!(
"{}",
"Codewhale Status".truecolor(aqua_r, aqua_g, aqua_b).bold()
);
println!("{}", "===============".truecolor(sky_r, sky_g, sky_b));
println!("workspace: {}", workspace.display());
let credential = resolve_credential_diagnostic(config);
match credential.source {
ApiKeySource::ConfigDeclared => println!(
" {} api_key: literal config value structurally present",
"✓".truecolor(aqua_r, aqua_g, aqua_b)
),
ApiKeySource::EnvDeclared => println!(
" {} api_key: environment source declared (value not inspected)",
"·".dimmed()
),
ApiKeySource::ExternalAuthDeclared => println!(
" {} api_key: external auth source declared (value not inspected)",
"·".dimmed()
),
ApiKeySource::SecretStoreUnprobed => println!(
" {} api_key: secret store eligible (store not probed)",
"·".dimmed()
),
ApiKeySource::SecretStoreUnavailable => println!(
" {} api_key: secret-store sentinel declared, but this route cannot use that store",
"!".truecolor(sky_r, sky_g, sky_b)
),
ApiKeySource::OAuth => println!(
" {} oauth: Codewhale-owned route selected (token availability not probed)",
"·".dimmed()
),
ApiKeySource::ExternalConsent => println!(
" {} oauth: external read-only consent configured (credential file not probed)",
"·".dimmed()
),
ApiKeySource::NoAuth => println!(
" {} api_key: disabled for this route",
"✓".truecolor(aqua_r, aqua_g, aqua_b)
),
ApiKeySource::LocalRuntime => println!(
" {} api_key: not required for this local runtime",
"✓".truecolor(aqua_r, aqua_g, aqua_b)
),
ApiKeySource::Unknown => println!(
" {} api_key: unknown (credential environment and durable stores not inspected)",
"·".dimmed()
),
}
println!(
" · credential availability: {}",
credential.availability.label()
);
println!(
" · base_url: {}",
codewhale_config::structural_url_authority(&config.active_route_base_url())
);
let model = config
.default_text_model
.clone()
.unwrap_or_else(|| DEFAULT_TEXT_MODEL.to_string());
println!(" · default_text_model: {model}");
let (default_mode, default_mode_source) = doctor_runtime_default_mode();
println!(" · default_mode: {default_mode} ({default_mode_source})");
let mcp_path = config.mcp_config_path();
let project_mcp_path = codewhale_engine::mcp::workspace_mcp_config_path(workspace);
let mcp_count = match codewhale_engine::mcp::load_config_with_workspace_and_plugins(
&mcp_path, workspace, plugins,
) {
Ok(cfg) => cfg.servers.len(),
Err(_) => 0,
};
let mcp_present = if mcp_path.exists() { "" } else { " (missing)" };
let project_mcp_present = if project_mcp_path.exists() {
""
} else {
" (missing)"
};
println!(
" · mcp servers: {mcp_count} from {}{mcp_present} + {}{project_mcp_present}",
mcp_path.display(),
project_mcp_path.display()
);
let skills_dir = config.skills_dir();
println!(
" · skills: {} at {}",
skills_count_for(&skills_dir),
codewhale_util::utils::display_path(&skills_dir)
);
let tools_dir = default_tools_dir();
let tools_present = if tools_dir.exists() {
""
} else {
" (missing — run `setup --tools`)"
};
println!(
" · tools: {} entries at {}{tools_present}",
if tools_dir.exists() {
count_dir_entries(&tools_dir)
} else {
0
},
codewhale_util::utils::display_path(&tools_dir)
);
let plugins_dir = default_plugins_dir();
let plugins_present = if plugins_dir.exists() {
""
} else {
" (missing — run `setup --plugins`)"
};
println!(
" · plugins: {} entries at {}{plugins_present}",
if plugins_dir.exists() {
count_dir_entries(&plugins_dir)
} else {
0
},
codewhale_util::utils::display_path(&plugins_dir)
);
let sandbox =
codewhale_sandbox::get_platform_sandbox_with_bwrap_preference(config.prefers_bwrap());
match sandbox {
Some(kind) => println!(
" {} sandbox: {kind}",
"✓".truecolor(aqua_r, aqua_g, aqua_b)
),
None => println!(
" {} sandbox: unavailable (commands run best-effort)",
"!".truecolor(sky_r, sky_g, sky_b)
),
}
println!(" {} {}", "·".dimmed(), dotenv_status_line(workspace));
println!();
println!("Run `codewhale doctor --json` for a machine-readable check.");
Ok(())
}
fn dotenv_status_line(workspace: &Path) -> String {
let dotenv = workspace.join(".env");
if dotenv.exists() {
return format!(
".env present at {} (literal provider credentials only)",
dotenv.display()
);
}
if workspace.join(".env.example").exists() {
return ".env not present in workspace (run `cp .env.example .env` and edit)".to_string();
}
".env not present in workspace".to_string()
}
fn run_setup_clean(checkpoints_dir: &Path, force: bool) -> Result<()> {
use colored::Colorize;
if !checkpoints_dir.exists() {
println!(
"Nothing to clean — checkpoints dir does not exist: {}",
checkpoints_dir.display()
);
return Ok(());
}
let plan = collect_clean_targets(checkpoints_dir);
if plan.targets.is_empty() {
println!(
"Nothing to clean — no checkpoint files in {}",
checkpoints_dir.display()
);
return Ok(());
}
if !force {
println!(
"Would remove {} checkpoint file(s) (use --force to apply):",
plan.targets.len()
);
for path in &plan.targets {
println!(" · {}", path.display());
}
return Ok(());
}
let removed = execute_clean_plan(&plan)?;
println!("{}", "Cleaned checkpoints:".bold());
for path in &removed {
println!(" ✓ {}", path.display());
}
Ok(())
}
fn run_session_diagnostics(args: SessionDiagnosticsArgs) -> Result<()> {
let contents = std::fs::read_to_string(&args.path).with_context(|| {
format!(
"read session diagnostic JSONL from {}",
codewhale_util::utils::display_path(&args.path)
)
})?;
let summary = codewhale_runtime::session_diagnostics::analyze_session_failure_jsonl(&contents);
if args.json {
println!("{}", serde_json::to_string_pretty(&summary)?);
} else {
println!(
"{}",
codewhale_runtime::session_diagnostics::format_redacted_failure_summary(&summary)
);
}
Ok(())
}
/// Live API checks are explicit. Local endpoints have a separate opt-in because
/// an HTTP request can wake a desktop-managed daemon (notably Ollama.app).
fn doctor_should_probe_api(
provider: codewhale_config::ProviderKind,
base_url: &str,
probes: crate::doctor::DoctorProbeRequest,
) -> bool {
let local =
codewhale_runtime::config::provider_route_is_keyless_self_hosted(provider, base_url)
|| codewhale_runtime::config::base_url_uses_local_host(base_url);
probes.should_probe_api(local)
}
/// Providers whose credential *presence* `codewhale doctor` reports.
///
/// The retired Antigravity identity is a non-runnable tombstone kept only so
/// legacy tables deserialize and clear; doctor never advertises it as a slot.
fn doctor_api_key_providers() -> impl Iterator<Item = codewhale_config::ProviderKind> {
codewhale_config::ProviderKind::all()
.iter()
.copied()
.filter(|provider| *provider != codewhale_config::ProviderKind::Antigravity)
}
/// Doctor must never turn credential inspection into a refresh/write path.
/// OAuth connectivity is exercised by an ordinary user request instead;
/// doctor limits itself to non-mutating readiness inspection.
fn doctor_should_probe_auth(config: &Config) -> bool {
let Ok(identity) = config.active_provider_identity() else {
return false;
};
let provider = identity.provider;
if provider == codewhale_config::ProviderKind::OpenaiCodex
&& !config.provider_uses_custom_endpoint(&identity)
{
return false;
}
let auth_mode = config.auth_mode_for_provider(&identity);
if provider == codewhale_config::ProviderKind::Xai
&& auth_mode
.as_deref()
.is_some_and(codewhale_runtime::oauth::auth_mode_uses_xai_oauth)
{
return false;
}
!(provider == codewhale_config::ProviderKind::Moonshot
&& auth_mode
.as_deref()
.is_some_and(codewhale_config::auth_mode_uses_kimi_imported_token))
}
/// Run system diagnostics
async fn run_doctor(
config: &Config,
workspace: &Path,
config_path_override: Option<&Path>,
profile: Option<&str>,
probes: crate::doctor::DoctorProbeRequest,
plugins: &codewhale_plugins::PluginRegistry,
all: bool,
) {
use codewhale_palette as palette;
use colored::Colorize;
let (accent_r, accent_g, accent_b) = palette::WHALE_HUMAN_RGB;
let (sky_r, sky_g, sky_b) = palette::WHALE_ACTION_RGB;
let (aqua_r, aqua_g, aqua_b) = palette::WHALE_ACTION_RGB;
let (red_r, red_g, red_b) = palette::WHALE_ERROR_RGB;
println!(
"{}",
"codewhale Doctor"
.truecolor(accent_r, accent_g, accent_b)
.bold()
);
println!("{}", "==================".truecolor(sky_r, sky_g, sky_b));
let local_route = if probes.probe_local {
let mut local = config.clone();
codewhale_engine::local_ollama::adopt_live_local_ollama_for_run(&mut local, true)
.await
.map(|tag| (local, tag))
} else {
None
};
if let Some((_, tag)) = &local_route {
println!(
"No cloud API key, so Codewhale and `codewhale exec` use local Ollama {tag}; this report checks that route, and no configuration was changed."
);
}
let config = local_route.as_ref().map_or(config, |(local, _)| local);
// The answer comes before the detail (U7). A requested live probe runs
// first so that answer can include it; the probe line is the only thing
// printed while that check is in flight.
let (verdict_state, _) = doctor_setup_state(config, workspace);
let identity = config.active_provider_identity().ok();
let api_target = doctor_api_target(config);
let live_api_requested = identity.as_ref().is_some_and(|identity| {
doctor_should_probe_api(identity.provider, &api_target.base_url, probes)
});
// The opt-in live check runs once, before the verdict, so the verdict,
// the credential lines and API Connectivity all report the same result.
let live_probe = if doctor_should_probe_auth(config) && live_api_requested {
println!("{} Testing connection...", "·".dimmed());
// Resolve a credential through the diagnostic-only store first, then
// probe with an in-memory clone. Constructing the normal client from
// the original config could otherwise trigger its legacy secret-store
// migration while a user merely asks doctor to test connectivity.
Some(match config.with_read_only_api_key_for_diagnostic() {
Ok(diagnostic_config) => test_api_connectivity(&diagnostic_config).await,
Err(error) => Err(error),
})
} else {
None
};
// Presence and variable name only; the value is never held here.
let env_key_source = codewhale_runtime::config::active_provider_env_api_key_source(config);
let verdict = doctor_verdict(
&verdict_state,
identity
.as_ref()
.map_or("unavailable", |identity| identity.key.as_str()),
&DoctorVerdictFacts {
onboarded: crate::tui::onboarding::is_onboarded(),
env_key_source: env_key_source.clone(),
live_probe: live_probe.as_ref().map(Result::is_ok),
live_probe_timed_out: live_probe
.as_ref()
.is_some_and(|result| result.as_ref().is_err_and(doctor_probe_timed_out)),
local_discovery: !probes.probe_local
&& identity.as_ref().is_some_and(|identity| {
codewhale_engine::local_ollama::should_adopt_live_local_ollama(
codewhale_engine::local_ollama::startup_route_configured(config),
identity.provider,
false,
true,
)
}),
},
);
println!("{}", verdict.truecolor(aqua_r, aqua_g, aqua_b).bold());
println!();
// Version info
println!("{}", "Version Information:".bold());
println!(" codewhale: {}", env!("CODEWHALE_BUILD_VERSION"));
// A release binary needs no Rust toolchain; this line describes the host,
// not the build, so a missing rustc must not read as a fault.
println!(" host rustc: {}", rustc_version());
println!();
println!("{}", "Updates:".bold());
crate::doctor::print_update_report(probes, &config.update_config()).await;
println!();
println!("{}", "Installations:".bold());
let installation_lines =
tokio::task::spawn_blocking(|| crate::doctor::installation_lines(false))
.await
.unwrap_or_default();
for line in installation_lines {
println!(" {line}");
}
println!();
// Configuration summary
let doctor_paths = match crate::doctor::DoctorPathReport::resolve(config_path_override) {
Ok(paths) => paths,
Err(error) => {
println!("{}", "Resolved User Paths:".bold());
println!(
" {} unavailable: {error:#}",
"✗".truecolor(red_r, red_g, red_b)
);
return;
}
};
println!("{}", "Configuration:".bold());
let config_path = &doctor_paths.config;
if tokio::fs::try_exists(config_path).await.unwrap_or(false) {
println!(
" {} config.toml found at {}",
"✓".truecolor(aqua_r, aqua_g, aqua_b),
codewhale_util::utils::display_path(config_path)
);
// Secret hygiene: name the keys, never the values. Plain-text config
// is not a secret store.
if let Ok(raw) = tokio::fs::read_to_string(config_path).await {
let flagged = crate::doctor::config_credential_shaped_keys(&raw);
if !flagged.is_empty() {
println!(
" {} credential-shaped value(s) in config.toml ({}): move them to the secret backend, then scrub the file — config.toml is plain text",
"!".truecolor(sky_r, sky_g, sky_b),
flagged.join(", ")
);
}
}
// Legacy top-level `base_url` / `api_key` (#6394): which moves the
// file still has pending, which pair disagrees and which side is in
// use. Sources only, never values.
for note in &config.legacy_root.notes {
let pending = !matches!(
note,
codewhale_config::legacy_root::LegacyRootNote::Conflict { .. }
);
println!(
" {} {note}{}",
"!".truecolor(sky_r, sky_g, sky_b),
if pending {
" (read that way already; `codewhale config migrate` updates the file)"
} else {
""
}
);
}
} else {
println!(
" {} config.toml not found at {} (using defaults/env)",
"!".truecolor(sky_r, sky_g, sky_b),
codewhale_util::utils::display_path(config_path)
);
}
println!(
" workspace: {}",
codewhale_util::utils::display_path(workspace)
);
println!(" {}", doctor_search_provider_line(config));
println!();
println!("{}", "Resolved User Paths (read-only):".bold());
let entries = doctor_paths.entries();
let shown = if all { entries.len() } else { 5 };
for (label, path) in entries.iter().take(shown) {
println!(" · {label}: {}", codewhale_util::utils::display_path(path));
}
let secret_backend = codewhale_secrets::diagnose_secret_backend();
println!();
println!("{}", "Secret Backend (structural only):".bold());
let backend_lines = crate::doctor::secret_backend_human_lines(&secret_backend);
let shown = if all { backend_lines.len() } else { 1 };
for line in backend_lines.iter().take(shown) {
println!(" · {line}");
}
if !all {
println!(" · `codewhale doctor --all` shows every path and backend detail");
}
println!();
println!("{}", "Sessions:".bold());
println!(
" · {}",
match crate::doctor::session_store_counts(&doctor_paths.sessions) {
Ok(Some(counts)) => format!(
"{} saved sessions, {} crash checkpoints",
counts.saved, counts.checkpoints
),
Ok(None) => "no sessions directory yet".to_string(),
Err(error) => format!("could not count sessions: {error}"),
}
);
println!(
" · {}",
codewhale_engine::session_reconcile::last_run(&doctor_paths.sessions).map_or_else(
|| "no session repair has run yet (it runs at launch; `codewhale doctor --repair-sessions` runs it now)".to_string(),
|summary| summary.doctor_detail()
)
);
// State root (v0.8.44)
println!();
println!("{}", "State Root:".bold());
let (code_home, legacy_home) = doctor_state_roots();
let active_root = if code_home.exists() {
&code_home
} else if legacy_home.exists() {
&legacy_home
} else {
&code_home
};
println!(
" active: {}",
codewhale_util::utils::display_path(active_root)
);
if active_root != &code_home {
println!(
" note: legacy {} found; start Codewhale once to trigger safe migration where available.",
codewhale_util::utils::display_path(&legacy_home)
);
}
if legacy_home.exists() && code_home.exists() {
println!(
" dual roots: {} (primary) + {} (legacy)",
codewhale_util::utils::display_path(&code_home),
codewhale_util::utils::display_path(&legacy_home)
);
}
let legacy_state_report = doctor_legacy_state_report(&code_home, &legacy_home);
let session_recovery = doctor_session_recovery_report(
&code_home,
&legacy_home,
codewhale_config::codewhale_home_is_explicit(),
);
print_doctor_legacy_state_report(
&legacy_state_report,
&session_recovery,
(aqua_r, aqua_g, aqua_b),
(sky_r, sky_g, sky_b),
);
print_doctor_stored_secrets_report(
config_path_override.map(Path::to_path_buf),
profile.map(str::to_owned),
)
.await;
let (setup_state, setup_source) = doctor_setup_state(config, workspace);
print_doctor_setup_report(
config,
workspace,
&setup_state,
setup_source,
(aqua_r, aqua_g, aqua_b),
(sky_r, sky_g, sky_b),
);
print_doctor_fleet_roster_layers(config, workspace);
// Check API keys
println!();
println!("{}", "API Keys:".bold());
// Per-provider state: env + config file only (no values printed).
// Keep doctor/status prompt-free and credential-value-free even for
// unsigned rebuilt binaries.
for provider in doctor_api_key_providers() {
let slot = provider.as_str();
let provider_identity = config.builtin_provider_identity(provider).ok();
let provider_config = provider_identity
.as_ref()
.and_then(|identity| config.provider_config_for(identity));
let config_declared = provider_config.is_some_and(|entry| {
entry.api_key.as_deref().is_some_and(|key| {
codewhale_config::classify_config_api_key_value(key)
== codewhale_config::ConfigApiKeyValueKind::Literal
})
});
let declared_env = provider_config
.and_then(|entry| entry.api_key_env.as_deref())
.map(str::trim)
.filter(|name| !name.is_empty());
let env_source_declared = declared_env.is_some();
// Presence only: name the variable that holds a key, never its value.
let env_set = declared_env
.filter(|name| {
std::env::var_os(name)
.is_some_and(|value| !value.to_string_lossy().trim().is_empty())
})
.or_else(|| codewhale_runtime::config::provider_env_api_key_var(provider));
let icon = if config_declared || env_source_declared || env_set.is_some() {
"·".truecolor(aqua_r, aqua_g, aqua_b)
} else {
"·".dimmed()
};
println!(
" {} {slot}: env_source={}, config_source={}",
icon,
match env_set {
Some(var) => doctor_env_key_label(var),
None if env_source_declared => "declared (value not inspected)".to_string(),
None => "not inspected".to_string(),
},
if config_declared {
"declared (value not inspected)"
} else {
"not declared"
}
);
}
println!(" · credential precedence is unchanged; doctor does not inspect credential values");
println!();
println!(
"{}",
"External credential consent (configuration only):".bold()
);
for line in doctor_external_credential_consent_lines(config) {
println!(" {line}");
}
println!();
println!(
"{}",
"DeepSeek Harness integration (read-only detection):".bold()
);
for line in doctor_dsh_integration_lines(config, workspace) {
println!(" {line}");
}
let credential = resolve_credential_diagnostic(config);
let source_label = match credential.source {
ApiKeySource::ConfigDeclared => "literal config value structurally present",
ApiKeySource::EnvDeclared => "environment source declared; value not inspected",
ApiKeySource::ExternalAuthDeclared => {
"external auth source declared; credential not resolved"
}
ApiKeySource::SecretStoreUnprobed => "secret store eligible; store not probed",
ApiKeySource::SecretStoreUnavailable => {
"secret-store sentinel declared, but this route cannot use that store"
}
ApiKeySource::OAuth => "OAuth route configured; token availability not probed",
ApiKeySource::ExternalConsent => "external consent configured; token file not read",
ApiKeySource::NoAuth => "no-auth route",
ApiKeySource::LocalRuntime => "local runtime; credentials not required",
ApiKeySource::Unknown => "unknown; credential environment and stores not inspected",
};
match env_key_source.as_deref() {
Some(source) => println!(
" {} active provider credential source: {}",
"·".dimmed(),
doctor_env_key_label(source)
),
None => println!(
" {} active provider credential source: {source_label}",
"·".dimmed()
),
}
println!(
" · active provider credential availability: {}",
credential.availability.label()
);
match &live_probe {
Some(Ok(())) => println!(
" {} active provider credential: accepted by the live API check",
"✓".truecolor(aqua_r, aqua_g, aqua_b)
),
// #6889: no answer in time says nothing about the credential.
Some(Err(error)) if doctor_probe_timed_out(error) => println!(
" {} active provider credential: not confirmed, the live API check got no answer in time (see API Connectivity)",
"·".dimmed()
),
Some(Err(_)) => println!(
" {} active provider credential: live API check failed (see API Connectivity)",
"✗".truecolor(red_r, red_g, red_b)
),
None => {}
}
// API connectivity test
println!();
println!("{}", "API Connectivity:".bold());
// Configured-vs-active honesty (DGF-01): doctor describes the route a
// session launched NOW would resolve. It cannot see inside an already
// running session, which keeps the route it resolved at its own launch.
println!(
" · scope: configured route — what a session launched now would use; a running session keeps the route it resolved at launch (its TUI header shows the live route)"
);
println!(" · provider: {}", api_target.provider);
println!(
" · base_url: {}",
codewhale_config::structural_url_authority(&api_target.base_url)
);
match api_target.resolution {
DoctorModelResolution::Resolved => {
println!(" · model: {} (resolved)", api_target.model);
}
DoctorModelResolution::ConfiguredOnly => {
println!(
" · model: {} (configured; route resolution unavailable)",
api_target.model
);
}
}
let tls_status = doctor_tls_status(config);
if !tls_status.certificate_verification {
println!(" ! {}", tls_status.message);
println!(" Prefer SSL_CERT_FILE with a trusted custom CA bundle when possible.");
}
let strict_tool_mode = doctor_strict_tool_mode_status(config);
let strict_icon = match strict_tool_mode.status {
"ready" => "✓".truecolor(aqua_r, aqua_g, aqua_b),
"fallback_non_beta" | "custom_endpoint" => "!".truecolor(sky_r, sky_g, sky_b),
_ => "·".dimmed(),
};
println!(
" {} strict_tool_mode: {}",
strict_icon, strict_tool_mode.message
);
if let Some(recommended) = strict_tool_mode.recommended_base_url.as_deref() {
println!(
" Use the {} endpoint for DeepSeek strict schemas.",
codewhale_config::structural_url_authority(recommended)
);
}
let capability = identity.as_ref().map(|identity| {
codewhale_engine::provider_lake::provider_capability(identity.provider, &api_target.model)
});
if let Some(alias) = capability
.as_ref()
.and_then(|capability| capability.alias_deprecation.as_ref())
{
println!(
" ! model alias {} retires {}; switch to {}",
alias.alias, alias.retirement_date, alias.replacement
);
}
let endpoint_is_local = identity.as_ref().is_some_and(|identity| {
codewhale_runtime::config::provider_route_is_keyless_self_hosted(
identity.provider,
&api_target.base_url,
) || codewhale_runtime::config::base_url_uses_local_host(&api_target.base_url)
});
if let Some(connectivity_result) = &live_probe {
match connectivity_result {
Ok(()) => {
println!(
" {} API connection successful",
"✓".truecolor(aqua_r, aqua_g, aqua_b)
);
}
Err(e) => {
let error_msg = e.to_string();
let timed_out = doctor_probe_timed_out(e);
println!(
" {} {}",
"✗".truecolor(red_r, red_g, red_b),
if timed_out {
"API check got no answer in time"
} else {
"API connection failed"
}
);
let names_status = |status| {
codewhale_engine::mcp::oauth::text_names_http_status(&error_msg, status)
};
let provider = identity
.as_ref()
.map(|identity| identity.provider)
.unwrap_or(codewhale_config::ProviderKind::Deepseek);
if names_status("401") || error_msg.contains("Unauthorized") {
println!(
" Invalid API key. Check `codewhale auth status`, {}, or config.toml",
doctor_provider_key_place(provider)
);
} else if names_status("403") || error_msg.contains("Forbidden") {
println!(
" API key lacks permissions. Verify the {} key is active.",
provider.provider().display_name()
);
} else if timed_out
|| error_msg.contains("timeout")
|| error_msg.contains("Timeout")
{
for line in doctor_timeout_recovery_lines(config) {
println!(" {line}");
}
} else if error_msg.contains("dns") || error_msg.contains("resolve") {
println!(" DNS resolution failed. Check your network connection");
} else if error_msg.contains("connect") {
println!(" Connection failed. Check firewall settings or try again");
} else if crate::doctor::is_keyless_ds4_route(config) {
println!(" {error_msg}");
} else {
println!(
" Error details omitted because provider failures can contain credential material."
);
}
}
}
} else if !doctor_should_probe_auth(config) {
println!(
" {} Live OAuth connectivity not checked by non-mutating doctor",
"·".dimmed()
);
println!(
" Doctor never refreshes or rewrites credentials; exercise the route with a normal request."
);
} else {
if endpoint_is_local {
println!(
" {} Live connectivity not checked for this local endpoint",
"·".dimmed()
);
println!(
" Run `codewhale doctor --probe-local` to opt in; the request may start a local service."
);
} else {
println!(
" {} Live hosted connectivity not checked (offline default)",
"·".dimmed()
);
println!(" Run `codewhale doctor --probe-api` to opt in.");
}
}
println!();
println!("{}", "Search Provider Reachability:".bold());
let search_probe = crate::doctor::doctor_search_probe(config, probes).await;
for line in crate::doctor::doctor_search_probe_lines(&search_probe) {
println!(" {line}");
}
// MCP configuration
println!();
println!("{}", "MCP Servers (configuration only):".bold());
println!(" · Static check only; no server process was started.");
let features = config.features();
if features.enabled(Feature::Mcp) {
println!(
" {} MCP feature flag enabled",
"✓".truecolor(aqua_r, aqua_g, aqua_b)
);
} else {
println!(
" {} MCP feature flag disabled",
"!".truecolor(sky_r, sky_g, sky_b)
);
}
let mcp_config_path = config.mcp_config_path();
let project_mcp_config_path = codewhale_engine::mcp::workspace_mcp_config_path(workspace);
if mcp_config_path.exists() {
println!(
" {} MCP config found at {}",
"✓".truecolor(aqua_r, aqua_g, aqua_b),
codewhale_util::utils::display_path(&mcp_config_path)
);
} else {
println!(
" {} MCP config not found at {}",
"·".dimmed(),
codewhale_util::utils::display_path(&mcp_config_path)
);
}
if project_mcp_config_path.exists() {
println!(
" {} Project MCP config found at {}",
"✓".truecolor(aqua_r, aqua_g, aqua_b),
codewhale_util::utils::display_path(&project_mcp_config_path)
);
} else {
println!(
" {} Project MCP config not found at {}",
"·".dimmed(),
codewhale_util::utils::display_path(&project_mcp_config_path)
);
}
match codewhale_engine::mcp::load_config_with_workspace_and_plugins(
&mcp_config_path,
workspace,
plugins,
) {
Ok(cfg) if cfg.servers.is_empty() => {
println!(" {} 0 merged server(s) configured", "·".dimmed());
if !mcp_config_path.exists() && !project_mcp_config_path.exists() {
println!(" Run `codewhale mcp init` or add `.codewhale/mcp.json`.");
}
}
Ok(cfg) => {
println!(
" {} {} merged server(s) configured",
"·".dimmed(),
cfg.servers.len()
);
let duplicate_computer_use =
codewhale_engine::mcp::duplicate_computer_use_servers(&cfg);
for (name, server) in &cfg.servers {
let status = doctor_check_mcp_server(server);
let icon = match &status {
McpServerDoctorStatus::Ok(detail) => {
format!(
" {} {name}: configuration valid; {}",
"✓".truecolor(aqua_r, aqua_g, aqua_b),
detail
)
}
McpServerDoctorStatus::Warning(detail) => {
format!(
" {} {name}: configuration warning; {}",
"!".truecolor(sky_r, sky_g, sky_b),
detail
)
}
McpServerDoctorStatus::Error(detail) => {
format!(
" {} {name}: configuration invalid; {}",
"✗".truecolor(red_r, red_g, red_b),
detail
)
}
};
println!("{icon}");
if !server.is_enabled() {
println!(" disabled; live health not checked");
} else {
println!(
" process/protocol/backend: not checked; `codewhale mcp validate` explicitly starts and initializes configured servers"
);
}
}
for (name, _) in &duplicate_computer_use {
println!(
" {} {}",
"!".truecolor(sky_r, sky_g, sky_b),
duplicate_computer_use_warning(name).trim_start()
);
}
if probes.should_probe_mcp() {
println!();
println!(
" {} Live MCP probe enabled: starting enabled servers; backend tool health remains untested.",
"!".truecolor(sky_r, sky_g, sky_b)
);
match codewhale_engine::mcp::McpPool::from_config_path_with_workspace_and_plugins(
&mcp_config_path,
workspace,
std::sync::Arc::new(plugins.clone()),
) {
Ok(pool) => {
let mut pool = pool.with_backend(config.mcp_backend());
let errors = pool.connect_all().await;
let failed = errors
.iter()
.map(|(name, _)| name.as_str())
.collect::<std::collections::BTreeSet<_>>();
for (name, server) in &cfg.servers {
if !server.is_enabled() {
continue;
}
if failed.contains(name.as_str()) {
println!(
" {} {name}: process/protocol unreachable; error details omitted",
"✗".truecolor(red_r, red_g, red_b)
);
} else {
println!(
" {} {name}: process reachable and protocol initialized; backend tool health not checked",
"✓".truecolor(aqua_r, aqua_g, aqua_b)
);
}
}
}
Err(_) => println!(
" {} live MCP probe could not load merged configuration; details omitted",
"✗".truecolor(red_r, red_g, red_b)
),
}
} else {
println!(
" Use codewhale doctor --probe-mcp to opt in to live process/protocol checks; it may start configured servers."
);
}
}
Err(_) => {
println!(
" {} MCP configuration could not be loaded; details omitted",
"✗".truecolor(red_r, red_g, red_b)
);
}
}
// Skills configuration
println!();
println!("{}", "Skills:".bold());
let global_skills_dir = config.skills_dir();
let skills_config = config.skills_config();
let skill_registry = codewhale_skills::discover_for_workspace(
workspace,
Some(&global_skills_dir),
&skills_config,
None,
);
if let Some(status) = codewhale_skills::bundled_skills_status(&skills_config, &skill_registry) {
println!(" {}", status.summary());
if !status.unknown.is_empty() {
println!(" ⚠ Unknown bundled names: {}", status.unknown.join(", "));
}
if !status.ignored.is_empty() {
println!(
" ⚠ Ignored explicit_only entries: {}",
status.ignored.join(", ")
);
}
if !status.selected_missing.is_empty() {
println!(
" Selected but not installed: {}",
status.selected_missing.join(", ")
);
}
}
let agents_skills_dir = workspace.join(".agents").join("skills");
let local_skills_dir = workspace.join("skills");
let agents_global_skills_dir = codewhale_skills::agents_global_skills_dir();
// #432: cross-tool skill discovery dirs. Presence is reported here
// even though they sit lower in the precedence chain so users can
// see at a glance whether a `.opencode/skills/`, `.claude/skills/`,
// `.cursor/skills/`, or global agentskills.io directory is contributing
// to the merged catalogue.
let opencode_skills_dir = workspace.join(".opencode").join("skills");
let claude_skills_dir = workspace.join(".claude").join("skills");
let selected_skills_dir =
crate::tui::app::resolve_skills_dir(workspace, &global_skills_dir, config);
let describe_dir = |dir: &Path| -> usize {
std::fs::read_dir(dir)
.map(|entries| entries.filter_map(std::result::Result::ok).count())
.unwrap_or(0)
};
if local_skills_dir.exists() {
println!(
" {} local skills dir found at {} ({} items)",
"✓".truecolor(aqua_r, aqua_g, aqua_b),
codewhale_util::utils::display_path(&local_skills_dir),
describe_dir(&local_skills_dir)
);
} else {
println!(
" {} local skills dir not found at {}",
"·".dimmed(),
codewhale_util::utils::display_path(&local_skills_dir)
);
}
if agents_skills_dir.exists() {
println!(
" {} .agents skills dir found at {} ({} items)",
"✓".truecolor(aqua_r, aqua_g, aqua_b),
codewhale_util::utils::display_path(&agents_skills_dir),
describe_dir(&agents_skills_dir)
);
} else {
println!(
" {} .agents skills dir not found at {}",
"·".dimmed(),
codewhale_util::utils::display_path(&agents_skills_dir)
);
}
if let Some(agents_global_skills_dir) = agents_global_skills_dir.as_ref() {
if agents_global_skills_dir.exists() {
println!(
" {} global .agents skills dir found at {} ({} items)",
"✓".truecolor(aqua_r, aqua_g, aqua_b),
codewhale_util::utils::display_path(agents_global_skills_dir),
describe_dir(agents_global_skills_dir)
);
} else {
println!(
" {} global .agents skills dir not found at {}",
"·".dimmed(),
codewhale_util::utils::display_path(agents_global_skills_dir)
);
}
}
if global_skills_dir.exists() {
println!(
" {} global skills dir found at {} ({} items)",
"✓".truecolor(aqua_r, aqua_g, aqua_b),
codewhale_util::utils::display_path(&global_skills_dir),
describe_dir(&global_skills_dir)
);
} else {
println!(
" {} global skills dir not found at {}",
"·".dimmed(),
codewhale_util::utils::display_path(&global_skills_dir)
);
}
// #432: only print interop dirs when they're populated — empty
// .opencode/.claude folders are common and would just clutter
// the report with false-positive "absent" lines.
if opencode_skills_dir.exists() {
println!(
" {} .opencode skills dir found at {} ({} items)",
"✓".truecolor(aqua_r, aqua_g, aqua_b),
codewhale_util::utils::display_path(&opencode_skills_dir),
describe_dir(&opencode_skills_dir)
);
}
if claude_skills_dir.exists() {
println!(
" {} .claude skills dir found at {} ({} items)",
"✓".truecolor(aqua_r, aqua_g, aqua_b),
codewhale_util::utils::display_path(&claude_skills_dir),
describe_dir(&claude_skills_dir)
);
}
println!(
" {} selected skills dir: {}",
"·".dimmed(),
codewhale_util::utils::display_path(&selected_skills_dir)
);
let unrecognized_system_skills = codewhale_runtime::config::owned_skills_dir()
.map(|dir| {
codewhale_skills::unrecognized_system_skills(&dir, &config.skills_config().bundled)
})
.unwrap_or_default();
if !unrecognized_system_skills.is_empty() {
println!(
" {} {} bundled skill(s) in the owned global root are customized or from an unknown generation and are never refreshed: {}",
"!".truecolor(sky_r, sky_g, sky_b),
unrecognized_system_skills.len(),
unrecognized_system_skills.join(", ")
);
}
if !workspace.join(".codewhale").join("skills").is_dir()
&& !agents_skills_dir.exists()
&& !local_skills_dir.exists()
&& !agents_global_skills_dir
.as_ref()
.is_some_and(|dir| dir.exists())
&& !global_skills_dir.exists()
{
println!(" Run `codewhale setup --skills` (or add --local for ./.codewhale/skills).");
}
// Tools directory
println!();
println!("{}", "Tools:".bold());
let tools_dir = default_tools_dir();
if tools_dir.exists() {
let count = count_dir_entries(&tools_dir);
println!(
" {} tools dir found at {} ({} items)",
"✓".truecolor(aqua_r, aqua_g, aqua_b),
codewhale_util::utils::display_path(&tools_dir),
count
);
} else {
println!(
" {} tools dir not found at {}",
"·".dimmed(),
codewhale_util::utils::display_path(&tools_dir)
);
println!(" Run `codewhale setup --tools` to scaffold a starter dir.");
}
// Plugins directory
println!();
println!("{}", "Plugins:".bold());
let plugins_dir = default_plugins_dir();
if plugins_dir.exists() {
let count = count_dir_entries(&plugins_dir);
println!(
" {} plugins dir found at {} ({} items)",
"✓".truecolor(aqua_r, aqua_g, aqua_b),
codewhale_util::utils::display_path(&plugins_dir),
count
);
} else {
println!(
" {} plugins dir not found at {}",
"·".dimmed(),
codewhale_util::utils::display_path(&plugins_dir)
);
println!(" Run `codewhale setup --plugins` to scaffold a starter dir.");
}
// Storage surfaces (#422 / #440 / #500)
println!();
println!("{}", "Storage:".bold());
if let Some(spillover_root) = codewhale_engine::tools::truncate::spillover_root() {
let (present, count) = if spillover_root.is_dir() {
(true, count_dir_entries(&spillover_root))
} else {
(false, 0)
};
if present {
println!(
" {} tool-output spillover at {} ({} file{})",
"✓".truecolor(aqua_r, aqua_g, aqua_b),
codewhale_util::utils::display_path(&spillover_root),
count,
if count == 1 { "" } else { "s" }
);
} else {
println!(
" {} tool-output spillover dir not yet created at {}",
"·".dimmed(),
codewhale_util::utils::display_path(&spillover_root)
);
}
}
let stash = crate::composer_stash::diagnostic_stash_report();
if let Some(stash_path) = stash.path.as_ref() {
if let Some(error) = stash.error.as_deref() {
println!(
" {} composer stash was not inspected at {}: {error}",
"!".truecolor(sky_r, sky_g, sky_b),
codewhale_util::utils::display_path(stash_path),
);
} else if stash.present {
println!(
" {} composer stash at {} ({} parked draft{})",
"✓".truecolor(aqua_r, aqua_g, aqua_b),
codewhale_util::utils::display_path(stash_path),
stash.count,
if stash.count == 1 { "" } else { "s" }
);
} else {
println!(
" {} composer stash empty (Ctrl+G or Ctrl+S in the composer to park a draft)",
"·".dimmed()
);
}
} else if let Some(error) = stash.error.as_deref() {
println!(
" {} composer stash was not inspected: {error}",
"!".truecolor(sky_r, sky_g, sky_b),
);
}
// Tool dependencies — probe external binaries that individual
// tools rely on (Python for code_execution, pdftotext for PDF
// reading) so users see explicit ✓/✗ rather than the tool failing
// at execution time with "program not found". New in v0.8.31.
println!();
println!("{}", "Tool Dependencies:".bold());
match codewhale_dependencies::resolve_python_interpreter() {
Some(name) => println!(
" {} Python: {} → code_execution tool registered",
"✓".truecolor(aqua_r, aqua_g, aqua_b),
name
),
None => {
println!(
" {} Python: not found (tried {:?})",
"✗".truecolor(red_r, red_g, red_b),
codewhale_dependencies::PYTHON_CANDIDATES,
);
println!(" code_execution tool is NOT advertised to the model on this install.");
println!(" Install Python 3 and ensure one of those names is on PATH:");
match std::env::consts::OS {
"macos" => {
println!(" brew install python@3.12 (or download from python.org)")
}
"linux" => println!(
" sudo apt install python3 (Debian/Ubuntu) — or your distro's equivalent"
),
"windows" => {
println!(" winget install Python.Python.3 (or download from python.org)")
}
other => println!(" install Python 3 for {other} from python.org"),
}
}
}
match codewhale_dependencies::resolve_node() {
Some(_) => println!(
" {} Node.js: present → js_execution tool registered",
"✓".truecolor(aqua_r, aqua_g, aqua_b),
),
None => {
println!(
" {} Node.js: not found (tried `node`)",
"✗".truecolor(red_r, red_g, red_b),
);
println!(" js_execution tool is NOT advertised to the model on this install.");
println!(" Install Node 18+ and ensure `node` is on PATH:");
match std::env::consts::OS {
"macos" => println!(" brew install node (or download from nodejs.org)"),
"linux" => println!(
" sudo apt install nodejs (Debian/Ubuntu) — or your distro's equivalent"
),
"windows" => {
println!(" winget install OpenJS.NodeJS (or download from nodejs.org)")
}
other => println!(" install Node.js for {other} from nodejs.org"),
}
}
}
{
// The runtime the TypeScript extension host would use, resolved the
// same way the host launcher does (`[extension_host] runtime`), and
// with the host on, the OS sandbox it would get, planned (on Linux,
// bwrap-probed) the way a launch plans it. Both run child processes,
// so they stay off the async runtime. Known limit: the runtime probes
// have no timeout, so a runtime binary that hangs on `--version`
// stalls doctor here.
let options = codewhale_engine::extension_host::ExtensionHostOptions::from_config(
config.extension_host.as_ref(),
);
let native_enabled = config
.features()
.enabled(codewhale_runtime::features::Feature::ExtensionHost);
let mcp_enabled = config.mcp_backend() == codewhale_config::McpBackend::Host;
let enabled = native_enabled || mcp_enabled;
let tier = if mcp_enabled {
codewhale_engine::extension_host::tier::HostTier::Builtin
} else {
codewhale_engine::extension_host::tier::HostTier::Plugin
};
let resolution = tokio::task::spawn_blocking(move || {
let resolution = codewhale_dependencies::resolve_extension_host_runtime(
options.runtime,
options.node_override.as_deref(),
options.bun_override.as_deref(),
codewhale_engine::extension_host::bundle_sha256(),
);
let sandbox = resolution
.selected
.as_ref()
.filter(|_| enabled)
.map(|runtime| {
codewhale_engine::extension_host::planned_sandbox(&options, runtime, tier)
});
(resolution, sandbox)
})
.await;
let state = if enabled {
""
} else {
" (unused: Native extensions and Host MCP are off)"
};
let failed = if enabled {
"✗".truecolor(red_r, red_g, red_b)
} else {
"·".dimmed()
};
match resolution {
Ok((resolution, sandbox)) => {
match &resolution.selected {
Some(runtime) => {
println!(
" {} Extension host runtime: {}{state}",
"✓".truecolor(aqua_r, aqua_g, aqua_b),
resolution.summary(),
);
println!(
" Node remains the default; Bun default eligibility awaits measured runtime and platform isolation gates."
);
if native_enabled {
println!(
" Native admission requires a verified OS sandbox; an unsandboxed pinned Builtin host does not qualify Native extensions."
);
}
println!(
" {}",
codewhale_engine::extension_host::supervisor::MemoryEnforcement::planned(runtime.kind)
.describe(codewhale_engine::extension_host::supervisor::HOST_MEMORY_CAP)
);
match sandbox {
Some(Ok(sandbox)) => println!(" {sandbox}"),
Some(Err(error)) => {
println!(" host sandbox not determined: {error}")
}
None => {}
}
}
None => println!(
" {failed} Extension host runtime: {}{state}",
resolution.failure(),
),
}
}
Err(error) => println!(
" {failed} Extension host runtime: the runtime probe did not finish ({error}){state}"
),
}
}
match codewhale_dependencies::resolve_pandoc() {
Some(_) => println!(
" {} pandoc: present → pandoc_convert tool registered",
"✓".truecolor(aqua_r, aqua_g, aqua_b),
),
None => {
println!(" {} pandoc: not found (optional)", "·".dimmed(),);
println!(
" pandoc_convert tool is NOT advertised to the model. Install pandoc 2.15+ to enable:"
);
match std::env::consts::OS {
"macos" => println!(" brew install pandoc"),
"linux" => println!(
" release package from pandoc.org/installing.html (distro packages may predate 2.15)"
),
"windows" => {
println!(" winget install JohnMacFarlane.Pandoc")
}
other => println!(" install pandoc for {other} from pandoc.org"),
}
}
}
match codewhale_dependencies::resolve_tesseract() {
Some(_) => {
if cfg!(target_os = "macos") {
println!(
" {} OCR: macOS Vision + tesseract available → image_ocr/read_file screenshot OCR enabled",
"✓".truecolor(aqua_r, aqua_g, aqua_b),
);
} else {
println!(
" {} tesseract: present → image_ocr/read_file screenshot OCR enabled",
"✓".truecolor(aqua_r, aqua_g, aqua_b),
);
}
}
None => {
if cfg!(target_os = "macos") {
println!(
" {} OCR: macOS Vision available → image_ocr/read_file screenshot OCR enabled",
"✓".truecolor(aqua_r, aqua_g, aqua_b),
);
println!(
" tesseract not found (optional; install only for alternate OCR packs)."
);
} else {
println!(" {} tesseract: not found (optional)", "·".dimmed(),);
println!(
" image_ocr tool is NOT advertised to the model. Install tesseract to enable:"
);
match std::env::consts::OS {
"macos" => println!(" brew install tesseract"),
"linux" => println!(
" sudo apt install tesseract-ocr (Debian/Ubuntu) — or your distro's equivalent"
),
"windows" => println!(" winget install UB-Mannheim.TesseractOCR"),
other => {
println!(" install tesseract for {other} from tesseract-ocr.github.io")
}
}
}
}
}
// PDF text extraction is an optional integration. Codewhale itself stays
// a single required executable; file and web tools report a typed
// failed `binary_unavailable` result when Poppler is not installed.
match codewhale_dependencies::resolve_pdftotext() {
Some(_) => println!(
" {} pdftotext: available → PDF text extraction enabled",
"✓".truecolor(aqua_r, aqua_g, aqua_b),
),
None => {
println!(
" {} pdftotext: not found (optional; PDF text reads fail as `binary_unavailable`)",
"·".dimmed(),
);
match std::env::consts::OS {
"macos" => println!(" Install via: brew install poppler"),
"linux" => {
println!(" Install via: sudo apt install poppler-utils (Debian/Ubuntu)")
}
"windows" => println!(
" Install Poppler for Windows from https://blog.alivate.com.au/poppler-windows/"
),
_ => {}
}
}
}
// Terminal-quirk overrides currently active. Mirrors the env
// signals checked by `Settings::apply_env_overrides` so users
// can see at a glance which a11y/compat overrides fired.
println!();
println!("{}", "Terminal Quirks:".bold());
let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default();
let term_program_lc = term_program.to_ascii_lowercase();
let mut any_quirk = false;
if matches!(term_program.as_str(), "vscode" | "ghostty") {
println!(
" {} TERM_PROGRAM={} → low_motion + fancy_animations=false (auto)",
"•".truecolor(sky_r, sky_g, sky_b),
term_program
);
any_quirk = true;
}
if term_program == "Termius"
|| std::env::var_os("SSH_CLIENT").is_some_and(|v| !v.is_empty())
|| std::env::var_os("SSH_TTY").is_some_and(|v| !v.is_empty())
{
println!(
" {} SSH/Termius session → low_motion + fancy_animations=false (auto, #1433)",
"•".truecolor(sky_r, sky_g, sky_b)
);
any_quirk = true;
}
if term_program_lc.contains("ptyxis")
|| std::env::var_os("PTYXIS_VERSION").is_some_and(|v| !v.is_empty())
{
println!(
" {} Ptyxis detected → synchronized_output=off (auto, v0.8.31)",
"•".truecolor(sky_r, sky_g, sky_b)
);
any_quirk = true;
}
if codewhale_runtime::settings::detected_legacy_windows_console_host() {
println!(
" {} legacy Windows console host → low_motion + fancy_animations=false + bracketed_paste=false + synchronized_output=off (auto)",
"•".truecolor(sky_r, sky_g, sky_b)
);
any_quirk = true;
}
if !any_quirk {
println!(
" {} no env-driven terminal-quirk overrides active",
"·".dimmed()
);
}
// Platform and sandbox checks
println!();
println!("{}", "Platform:".bold());
println!(" OS: {}", std::env::consts::OS);
println!(" Arch: {}", std::env::consts::ARCH);
let sandbox =
codewhale_sandbox::get_platform_sandbox_with_bwrap_preference(config.prefers_bwrap());
if let Some(kind) = sandbox {
println!(
" {} sandbox available: {}",
"✓".truecolor(aqua_r, aqua_g, aqua_b),
kind
);
} else {
println!(
" {} sandbox not available (commands run best-effort)",
"!".truecolor(sky_r, sky_g, sky_b)
);
}
println!();
println!("{}", verdict.truecolor(aqua_r, aqua_g, aqua_b).bold());
}
/// Human-facing facts the verdict uses beyond the setup-state record. None of
/// these change structural Setup/Fleet readiness (the JSON contract): a set
/// environment variable is reported, never certified, until a live probe.
#[derive(Debug, Clone, Default)]
struct DoctorVerdictFacts {
/// The TUI's own first-run receipt (`.onboarded`). TUI onboarding finishes
/// after its key gate but never fills the `/setup` wizard's language and
/// constitution steps, so `first_run_ready()` alone misreads it.
onboarded: bool,
/// Name of the env place holding the active provider's key, from
/// `config::active_provider_env_api_key_source`; never the value.
env_key_source: Option<String>,
/// Outcome of the opt-in live API check; `None` when it did not run.
live_probe: Option<bool>,
/// The live check failed by running out of time, not by being refused
/// (#6889). A model the provider is still loading looks exactly like this.
live_probe_timed_out: bool,
local_discovery: bool,
}
/// `set via <VAR> (value not shown; not checked offline)`.
fn doctor_env_key_label(source: &str) -> String {
format!("set via {source} (value not shown; not checked offline)")
}
/// Where a rejected key might live, named without reading it. A route that
/// binds `api_key_env` is named by that variable; otherwise the provider's
/// first ambient variable.
fn doctor_provider_key_place(provider: codewhale_config::ProviderKind) -> String {
provider
.provider()
.env_vars()
.first()
.copied()
.unwrap_or("the provider environment variable")
.to_string()
}
/// Doctor's one-line answer: ready, or the single next step (U7). Readiness
/// starts from the setup lane's record. Offline doctor never reads the secret
/// store, so a stored key is "not checked", not missing; "Not ready" is kept
/// for a missing route, a key nothing can account for, or a failed probe.
fn doctor_verdict(
state: &codewhale_config::SetupState,
provider: &str,
facts: &DoctorVerdictFacts,
) -> String {
use codewhale_config::StepStatus;
const PROBE_HINT: &str = "run `codewhale doctor --probe-api` to verify";
match facts.live_probe {
Some(false) if facts.live_probe_timed_out => {
return format!(
"Not confirmed: the live {provider} API check got no answer in time → the model may still be loading, so wait a minute and run `codewhale doctor --probe-api` again; see API Connectivity below."
);
}
Some(false) => {
return format!(
"Not ready: the live {provider} API check failed → see API Connectivity below; `codewhale auth set --provider {provider}` replaces a rejected key."
);
}
Some(true) => return format!("Ready: the live {provider} API check passed."),
None => {}
}
let env_ready = facts.env_key_source.as_deref().map(|source| {
format!("Ready: {provider} key is set via {source} (not checked offline; {PROBE_HINT}).")
});
// NeedsAction means a named route exists but its key is missing, unchecked
// or failed. Configured routes can be used without a prior probe.
// `first_run_ready` accepts NeedsAction (a failed key still reaches the
// wizard's ready screen), so check the provider first.
match state.status(codewhale_config::SetupStep::ProviderModel) {
StepStatus::Verified if state.first_run_ready() || facts.onboarded => {
"Ready: setup is complete.".to_string()
}
StepStatus::Configured | StepStatus::Verified
if state.first_run_ready() || facts.onboarded =>
{
format!("Ready: setup is complete (saved key not checked offline; {PROBE_HINT}).")
}
StepStatus::Configured | StepStatus::Verified => env_ready.unwrap_or_else(|| {
"Not ready: first-run setup is unfinished → run `codewhale setup`.".to_string()
}),
StepStatus::NeedsAction => env_ready.unwrap_or_else(|| {
// A derived NeedsAction only means offline doctor cannot see the
// stored key; onboarding already gated on one. A NeedsAction the
// setup lane persisted is a real missing or failed key.
if state.inherited && facts.onboarded {
format!(
"Ready: onboarding is complete (saved {provider} key not checked offline; {PROBE_HINT})."
)
} else if facts.local_discovery {
format!(
"Not confirmed: the {provider} route has no verified key, so Codewhale and `codewhale exec` use a local Ollama model if one is running → `codewhale doctor --probe-local` checks for one; or save a key with /provider in Codewhale or `codewhale auth set --provider {provider}`."
)
} else {
format!(
"Not ready: the {provider} route has no verified key → save one with /provider in Codewhale or `codewhale auth set --provider {provider}`; `codewhale doctor --probe-api` checks a key already saved."
)
}
}),
_ => env_ready.unwrap_or_else(|| {
if facts.local_discovery {
"Not confirmed: no model provider is set up, so Codewhale and `codewhale exec` use a local Ollama model if one is running → `codewhale doctor --probe-local` checks for one; or run /provider in Codewhale, or `codewhale setup`."
.to_string()
} else {
"Not ready: no model provider set up → run /provider in Codewhale, or `codewhale setup`."
.to_string()
}
}),
}
}
#[cfg(test)]
mod doctor_verdict_tests {
#[test]
fn a_fresh_home_is_not_ready_and_names_the_provider_step() {
let verdict = super::doctor_verdict(
&codewhale_config::SetupState::default(),
"deepseek",
&super::DoctorVerdictFacts::default(),
);
assert!(verdict.starts_with("Not ready"), "{verdict}");
assert!(verdict.contains("/provider"), "{verdict}");
}
#[test]
fn a_route_without_a_verified_key_is_not_called_missing() {
// A fresh home derives NeedsAction for the default route because
// doctor does not read saved keys; the verdict must not claim there is
// no provider, and names both the headless fix and the probe.
use codewhale_config::{SetupState, SetupStep, StepEntry, StepStatus};
let mut state = SetupState::default();
state.set_step(
SetupStep::ProviderModel,
StepEntry::new(StepStatus::NeedsAction, true, "inherited"),
);
let verdict =
super::doctor_verdict(&state, "deepseek", &super::DoctorVerdictFacts::default());
assert!(verdict.starts_with("Not ready"), "{verdict}");
assert!(!verdict.contains("no model provider"), "{verdict}");
assert!(
verdict.contains("`codewhale auth set --provider deepseek`"),
"{verdict}"
);
assert!(verdict.contains("--probe-api"), "{verdict}");
}
#[test]
fn finished_setup_with_an_unconfirmed_key_is_not_ready() {
use codewhale_config::{
ConstitutionChoice, RuntimePostureSource, SetupState, SetupStep, StepEntry, StepStatus,
};
let mut state = SetupState::default();
state.set_step(
SetupStep::Language,
StepEntry::new(StepStatus::Verified, true, "0.10.1"),
);
state.set_step(
SetupStep::ProviderModel,
StepEntry::new(StepStatus::NeedsAction, true, "0.10.1"),
);
state.runtime_posture_source = RuntimePostureSource::Confirmed;
state.constitution_choice = ConstitutionChoice::Bundled;
assert!(state.first_run_ready(), "fixture must be wizard-ready");
let verdict =
super::doctor_verdict(&state, "deepseek", &super::DoctorVerdictFacts::default());
assert!(verdict.starts_with("Not ready"), "{verdict}");
assert!(verdict.contains("/provider"), "{verdict}");
state.set_step(
SetupStep::ProviderModel,
StepEntry::new(StepStatus::Verified, true, "0.10.1"),
);
assert_eq!(
super::doctor_verdict(&state, "deepseek", &super::DoctorVerdictFacts::default()),
"Ready: setup is complete."
);
}
fn facts(
onboarded: bool,
env_key_source: Option<&str>,
live_probe: Option<bool>,
) -> super::DoctorVerdictFacts {
super::DoctorVerdictFacts {
onboarded,
env_key_source: env_key_source.map(str::to_string),
live_probe,
live_probe_timed_out: false,
local_discovery: false,
}
}
#[test]
fn completed_tui_onboarding_is_not_unfinished_setup() {
// TUI onboarding records the route step only; never the wizard's
// language/constitution steps.
use codewhale_config::{SetupState, SetupStep, StepEntry, StepStatus};
let mut state = SetupState::default();
state.set_step(
SetupStep::ProviderModel,
StepEntry::new(StepStatus::Configured, true, "0.10.1"),
);
assert!(!state.first_run_ready(), "fixture is not wizard-ready");
let verdict = super::doctor_verdict(&state, "openai", &facts(true, None, None));
assert!(verdict.starts_with("Ready: setup is complete"), "{verdict}");
assert!(verdict.contains("--probe-api"), "{verdict}");
assert!(!verdict.contains("unfinished"), "{verdict}");
// Without the receipt (or a key) the wizard's verdict stands.
let verdict = super::doctor_verdict(&state, "openai", &facts(false, None, None));
assert!(
verdict.contains("first-run setup is unfinished"),
"{verdict}"
);
}
#[test]
fn onboarded_home_without_a_record_is_ready_but_a_recorded_failure_is_not() {
use codewhale_config::{InheritedConfigFacts, SetupState};
let derived = SetupState::derive_inherited(&InheritedConfigFacts {
has_provider_route: true,
..Default::default()
});
let verdict = super::doctor_verdict(&derived, "openai", &facts(true, None, None));
assert!(
verdict.starts_with("Ready: onboarding is complete"),
"{verdict}"
);
let mut recorded = derived.clone();
recorded.inherited = false;
let verdict = super::doctor_verdict(&recorded, "openai", &facts(true, None, None));
assert!(verdict.starts_with("Not ready"), "{verdict}");
}
#[test]
fn a_set_env_key_is_ready_and_named_for_any_provider() {
use codewhale_config::{InheritedConfigFacts, SetupState};
// Structural derivation keeps NeedsAction: env never certifies it.
let derived = SetupState::derive_inherited(&InheritedConfigFacts {
has_provider_route: true,
..Default::default()
});
let verdict = super::doctor_verdict(
&derived,
"anthropic",
&facts(false, Some("ANTHROPIC_API_KEY"), None),
);
assert_eq!(
verdict,
"Ready: anthropic key is set via ANTHROPIC_API_KEY (not checked offline; run `codewhale doctor --probe-api` to verify)."
);
}
#[test]
fn the_live_probe_result_decides_the_verdict() {
use codewhale_config::{SetupState, SetupStep, StepEntry, StepStatus};
let mut state = SetupState::default();
state.set_step(
SetupStep::ProviderModel,
StepEntry::new(StepStatus::NeedsAction, true, "0.10.1"),
);
let verdict = super::doctor_verdict(&state, "openai", &facts(false, None, Some(true)));
assert!(
verdict.starts_with("Ready: the live openai API check passed"),
"{verdict}"
);
let verdict = super::doctor_verdict(
&state,
"openai",
&facts(true, Some("OPENAI_API_KEY"), Some(false)),
);
assert!(verdict.starts_with("Not ready"), "{verdict}");
assert!(verdict.contains("API Connectivity"), "{verdict}");
}
/// #6889: a live check that ran out of time has not shown the key or the
/// route to be wrong, so the verdict does not say "Not ready" or point at
/// replacing the key.
#[test]
fn a_live_probe_that_timed_out_is_not_reported_as_a_rejected_key() {
let state = codewhale_config::SetupState::default();
let verdict = super::doctor_verdict(
&state,
"openai",
&super::DoctorVerdictFacts {
live_probe_timed_out: true,
..facts(true, Some("OPENAI_API_KEY"), Some(false))
},
);
assert!(verdict.starts_with("Not confirmed"), "{verdict}");
assert!(verdict.contains("may still be loading"), "{verdict}");
assert!(verdict.contains("--probe-api"), "{verdict}");
assert!(!verdict.contains("auth set"), "{verdict}");
let timed_out = anyhow::Error::new(codewhale_engine::llm_client::LlmError::Timeout(
super::DOCTOR_PROBE_TIMEOUT,
))
.context("live check");
assert!(super::doctor_probe_timed_out(&timed_out));
let refused = anyhow::Error::new(codewhale_engine::llm_client::LlmError::ServerError {
status: 502,
message: "resources busy".to_string(),
});
assert!(!super::doctor_probe_timed_out(&refused));
assert!(!super::doctor_probe_timed_out(&anyhow::anyhow!(
"connect timeout"
)));
}
#[test]
fn env_key_source_names_the_providers_own_variable_without_its_value() {
let _lock = crate::test_support::lock_test_env();
let _cli = crate::test_support::EnvVarGuard::remove(codewhale_config::CLI_API_KEY_ENV);
let _openai = crate::test_support::EnvVarGuard::set("OPENAI_API_KEY", "MUST-NOT-BE-SHOWN");
let config = {
let __cw_config_init_0_0 = Some("openai".to_string());
let mut __cw_config_init_0_value: codewhale_runtime::config::Config =
Default::default();
__cw_config_init_0_value.provider = __cw_config_init_0_0;
__cw_config_init_0_value
};
let source = codewhale_runtime::config::active_provider_env_api_key_source(&config)
.expect("ambient key present");
assert_eq!(source, "OPENAI_API_KEY");
let label = super::doctor_env_key_label(&source);
assert_eq!(
label,
"set via OPENAI_API_KEY (value not shown; not checked offline)"
);
assert!(!label.contains("MUST-NOT-BE-SHOWN"));
// The structural contract is untouched: env presence is not readiness.
assert!(
!super::resolve_credential_diagnostic(&config)
.availability
.certifies_ready()
);
let _openai = crate::test_support::EnvVarGuard::remove("OPENAI_API_KEY");
assert_eq!(
codewhale_runtime::config::active_provider_env_api_key_source(&config),
None
);
}
}
const DOCTOR_LEGACY_STATE_ITEMS: &[&str] = &[
"sessions",
"tasks",
"skills",
"slop_ledger",
"trophies",
"catalog",
"review-receipts",
"config.toml",
"settings.toml",
"mcp.json",
];
const DOCTOR_SESSION_RECOVERY_HUMAN_SAMPLE_LIMIT: usize = 20;
const DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT: usize = 100;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DoctorLegacyStateStatus {
PrimaryOnly,
LegacyOnly,
Both,
Absent,
}
impl DoctorLegacyStateStatus {
fn as_str(self) -> &'static str {
match self {
Self::PrimaryOnly => "primary_only",
Self::LegacyOnly => "legacy_only",
Self::Both => "both",
Self::Absent => "absent",
}
}
}
#[derive(Debug, Clone)]
struct DoctorLegacyStateEntry {
name: &'static str,
primary_path: PathBuf,
legacy_path: PathBuf,
primary_present: bool,
legacy_present: bool,
status: DoctorLegacyStateStatus,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DoctorSessionRecoveryStatus {
Isolated,
NoLegacySessions,
MigrationPending,
MigrationIncomplete,
MigrationComplete,
ScanFailed,
}
impl DoctorSessionRecoveryStatus {
fn as_str(self) -> &'static str {
match self {
Self::Isolated => "isolated",
Self::NoLegacySessions => "no_legacy_sessions",
Self::MigrationPending => "migration_pending",
Self::MigrationIncomplete => "migration_incomplete",
Self::MigrationComplete => "migration_complete",
Self::ScanFailed => "scan_failed",
}
}
}
#[derive(Debug, Clone)]
struct DoctorRecoverableSessionEntry {
name: PathBuf,
source_path: PathBuf,
destination_path: PathBuf,
}
#[derive(Debug, Clone)]
struct DoctorSessionRecoveryReport {
status: DoctorSessionRecoveryStatus,
primary_sessions_path: PathBuf,
legacy_sessions_path: PathBuf,
codewhale_home_is_explicit: bool,
legacy_session_file_count: usize,
already_present_file_count: usize,
recoverable_file_count: usize,
/// Bounded filename/path sample; the total is `recoverable_file_count`.
recoverable: Vec<DoctorRecoverableSessionEntry>,
error: Option<String>,
}
impl DoctorSessionRecoveryReport {
fn needs_attention(&self) -> bool {
matches!(
self.status,
DoctorSessionRecoveryStatus::MigrationPending
| DoctorSessionRecoveryStatus::MigrationIncomplete
| DoctorSessionRecoveryStatus::ScanFailed
)
}
}
fn doctor_legacy_state_status(
primary_present: bool,
legacy_present: bool,
) -> DoctorLegacyStateStatus {
match (primary_present, legacy_present) {
(true, false) => DoctorLegacyStateStatus::PrimaryOnly,
(false, true) => DoctorLegacyStateStatus::LegacyOnly,
(true, true) => DoctorLegacyStateStatus::Both,
(false, false) => DoctorLegacyStateStatus::Absent,
}
}
fn doctor_state_roots() -> (PathBuf, PathBuf) {
let code_home =
codewhale_config::codewhale_home().unwrap_or_else(|_| PathBuf::from("~/.codewhale"));
let legacy_home = if codewhale_config::codewhale_home_is_explicit() {
code_home.join(codewhale_config::LEGACY_APP_DIR)
} else {
codewhale_config::legacy_deepseek_home().unwrap_or_else(|_| PathBuf::from("~/.deepseek"))
};
(code_home, legacy_home)
}
fn doctor_legacy_state_report(
primary_root: &Path,
legacy_root: &Path,
) -> Vec<DoctorLegacyStateEntry> {
DOCTOR_LEGACY_STATE_ITEMS
.iter()
.copied()
.map(|name| {
let primary_path = primary_root.join(name);
let legacy_path = legacy_root.join(name);
let primary_present = primary_path.exists();
let legacy_present = legacy_path.exists();
let status = doctor_legacy_state_status(primary_present, legacy_present);
DoctorLegacyStateEntry {
name,
primary_path,
legacy_path,
primary_present,
legacy_present,
status,
}
})
.collect()
}
/// Compare legacy and primary session filenames without opening session files.
///
/// This is deliberately separate from `SessionManager::default_location()`:
/// constructing the manager can trigger the additive legacy migration, while
/// doctor must remain a read-only diagnostic. Session history is stored as
/// top-level JSON files. The state and sessions roots may be links the user
/// made to relocate state (a junction onto another volume is the supported way
/// to keep `~/.codewhale` off the system drive on Windows), so a linked root is
/// resolved and read through; symlink *entries* observed during the scan are
/// still ignored, so the diagnostic does not traverse checkpoint internals or
/// per-file link targets. These checks are best-effort observations, not a
/// race-free no-follow guarantee. A matching filename is only a regular-file
/// counterpart check: doctor does not parse or compare session descriptors.
fn doctor_session_recovery_report(
primary_root: &Path,
legacy_root: &Path,
codewhale_home_is_explicit: bool,
) -> DoctorSessionRecoveryReport {
let primary_sessions_path = primary_root.join("sessions");
let legacy_sessions_path = legacy_root.join("sessions");
let mut report = DoctorSessionRecoveryReport {
status: DoctorSessionRecoveryStatus::NoLegacySessions,
primary_sessions_path,
legacy_sessions_path,
codewhale_home_is_explicit,
legacy_session_file_count: 0,
already_present_file_count: 0,
recoverable_file_count: 0,
recoverable: Vec::new(),
error: None,
};
if codewhale_home_is_explicit {
report.status = DoctorSessionRecoveryStatus::Isolated;
return report;
}
let legacy_root_is_present =
match doctor_session_directory_is_safe(legacy_root, "legacy state root") {
Ok(present) => present,
Err(error) => {
report.status = DoctorSessionRecoveryStatus::ScanFailed;
report.error = Some(error);
return report;
}
};
if !legacy_root_is_present {
return report;
}
if let Err(error) = doctor_session_directory_is_safe(primary_root, "primary state root") {
report.status = DoctorSessionRecoveryStatus::ScanFailed;
report.error = Some(error);
return report;
}
let legacy_sessions_are_present = match doctor_session_directory_is_safe(
&report.legacy_sessions_path,
"legacy sessions root",
) {
Ok(present) => present,
Err(error) => {
report.status = DoctorSessionRecoveryStatus::ScanFailed;
report.error = Some(error);
return report;
}
};
if !legacy_sessions_are_present {
return report;
}
let primary_sessions_are_present = match doctor_session_directory_is_safe(
&report.primary_sessions_path,
"primary sessions root",
) {
Ok(present) => present,
Err(error) => {
report.status = DoctorSessionRecoveryStatus::ScanFailed;
report.error = Some(error);
return report;
}
};
let entries = match std::fs::read_dir(&report.legacy_sessions_path) {
Ok(entries) => entries,
Err(err) => {
report.status = DoctorSessionRecoveryStatus::ScanFailed;
report.error = Some(format!(
"could not inspect legacy session filenames at {}: {err}",
codewhale_util::utils::display_path(&report.legacy_sessions_path)
));
return report;
}
};
for entry in entries {
let entry = match entry {
Ok(entry) => entry,
Err(err) => {
report.status = DoctorSessionRecoveryStatus::ScanFailed;
report.error = Some(format!(
"could not inspect an entry under {}: {err}",
codewhale_util::utils::display_path(&report.legacy_sessions_path)
));
return report;
}
};
let file_type = match entry.file_type() {
Ok(file_type) => file_type,
Err(err) => {
report.status = DoctorSessionRecoveryStatus::ScanFailed;
report.error = Some(format!(
"could not inspect legacy session entry metadata under {}: {err}",
codewhale_util::utils::display_path(&report.legacy_sessions_path)
));
return report;
}
};
if !file_type.is_file() || entry.path().extension().is_none_or(|ext| ext != "json") {
continue;
}
report.legacy_session_file_count += 1;
let name = PathBuf::from(entry.file_name());
let destination_path = report.primary_sessions_path.join(&name);
match std::fs::symlink_metadata(&destination_path) {
Ok(metadata) if metadata.file_type().is_file() => {
report.already_present_file_count += 1;
}
Ok(metadata) => {
report.status = DoctorSessionRecoveryStatus::ScanFailed;
let shape = if metadata.file_type().is_symlink() {
"destination session entry is a symlink"
} else {
"destination session entry is not a regular file"
};
report.error = Some(format!(
"could not inspect destination session metadata at {}: {shape}",
codewhale_util::utils::display_path(&destination_path)
));
return report;
}
Err(err) if err.kind() == io::ErrorKind::NotFound => {
report.recoverable_file_count += 1;
record_doctor_recoverable_session(
&mut report.recoverable,
DoctorRecoverableSessionEntry {
source_path: entry.path(),
destination_path,
name,
},
);
}
Err(err) => {
report.status = DoctorSessionRecoveryStatus::ScanFailed;
report.error = Some(format!(
"could not inspect destination metadata at {}: {err}",
codewhale_util::utils::display_path(&destination_path)
));
return report;
}
}
}
report.status = if report.legacy_session_file_count == 0 {
DoctorSessionRecoveryStatus::NoLegacySessions
} else if report.recoverable_file_count == 0 {
DoctorSessionRecoveryStatus::MigrationComplete
} else if primary_sessions_are_present {
DoctorSessionRecoveryStatus::MigrationIncomplete
} else {
DoctorSessionRecoveryStatus::MigrationPending
};
report
}
/// Validate a session-state directory from observed metadata.
///
/// `doctor` only compares top-level filenames. A directory the user relocated
/// behind a link is resolved and inspected as its target: a junction is the
/// supported way to keep `~/.codewhale` off the system drive on Windows, and
/// refusing it left the migration diagnostic unable to run on exactly the
/// layout it reports about. Only this directory check follows the link; the
/// enumeration below still walks the path it was handed.
///
/// This is a best-effort observation, not a race-free no-follow guarantee.
/// Missing paths are normal on a fresh install and are reported as `false`.
fn doctor_session_directory_is_safe(path: &Path, label: &str) -> std::result::Result<bool, String> {
let metadata = match std::fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false),
Err(error) => {
return Err(format!(
"could not inspect {label} at {}: {error}",
codewhale_util::utils::display_path(path)
));
}
};
let metadata = if codewhale_util::path_identity::metadata_is_link_or_reparse(&metadata) {
match std::fs::metadata(path) {
Ok(target) => target,
Err(error) => {
return Err(format!(
"could not resolve {label} at {}: {error}",
codewhale_util::utils::display_path(path)
));
}
}
} else {
metadata
};
if !metadata.is_dir() {
return Err(format!(
"could not inspect {label} at {}: path is not a directory",
codewhale_util::utils::display_path(path)
));
}
Ok(true)
}
/// Keep the report bounded while preserving a deterministic, lexical sample.
/// `read_dir` order is platform- and filesystem-dependent, so retaining the
/// first entries encountered would make the JSON and human receipts drift.
fn record_doctor_recoverable_session(
recoverable: &mut Vec<DoctorRecoverableSessionEntry>,
entry: DoctorRecoverableSessionEntry,
) {
let insert_at = recoverable
.binary_search_by(|existing| existing.name.cmp(&entry.name))
.unwrap_or_else(|index| index);
if recoverable.len() == DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT
&& insert_at == recoverable.len()
{
return;
}
recoverable.insert(insert_at, entry);
if recoverable.len() > DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT {
recoverable.pop();
}
}
fn legacy_state_needs_attention(entry: &DoctorLegacyStateEntry) -> bool {
entry.name != "sessions"
&& matches!(
entry.status,
DoctorLegacyStateStatus::LegacyOnly | DoctorLegacyStateStatus::Both
)
}
fn print_doctor_legacy_state_report(
report: &[DoctorLegacyStateEntry],
session_recovery: &DoctorSessionRecoveryReport,
ok_rgb: (u8, u8, u8),
warn_rgb: (u8, u8, u8),
) {
use colored::Colorize;
let attention: Vec<_> = report
.iter()
.filter(|entry| legacy_state_needs_attention(entry))
.collect();
if attention.is_empty()
&& !session_recovery.needs_attention()
&& session_recovery.status != DoctorSessionRecoveryStatus::Isolated
{
println!(
" {} legacy state: no known .deepseek entries need migration",
"✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2)
);
} else if !attention.is_empty() {
println!(
" {} legacy state needs review:",
"!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2)
);
for entry in attention {
match entry.status {
DoctorLegacyStateStatus::LegacyOnly => {
println!(
" {} {} exists but {} is missing",
"!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2),
codewhale_util::utils::display_path(&entry.legacy_path),
codewhale_util::utils::display_path(&entry.primary_path),
);
}
DoctorLegacyStateStatus::Both => {
println!(
" {} {} exists alongside primary {}; legacy data may still need review",
"!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2),
codewhale_util::utils::display_path(&entry.legacy_path),
codewhale_util::utils::display_path(&entry.primary_path),
);
}
DoctorLegacyStateStatus::PrimaryOnly | DoctorLegacyStateStatus::Absent => {}
}
}
println!(
" Start Codewhale once to trigger safe migration where available, then rerun `codewhale doctor`."
);
}
print_doctor_session_recovery_report(session_recovery, ok_rgb, warn_rgb);
}
fn print_doctor_session_recovery_report(
report: &DoctorSessionRecoveryReport,
ok_rgb: (u8, u8, u8),
warn_rgb: (u8, u8, u8),
) {
use colored::Colorize;
match report.status {
DoctorSessionRecoveryStatus::Isolated => {
println!(
" {} legacy sessions: ambient ~/.deepseek/sessions was not inspected because CODEWHALE_HOME is set",
"·".dimmed()
);
println!(
" This preserves the explicit home boundary. To inspect the default home, use a separate shell with CODEWHALE_HOME unset and rerun `codewhale doctor`."
);
}
DoctorSessionRecoveryStatus::NoLegacySessions => {
println!(
" {} legacy sessions: no top-level session JSON files found",
"✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2)
);
}
DoctorSessionRecoveryStatus::MigrationComplete => {
println!(
" {} legacy sessions: all {} filename(s) have regular-file counterparts under {}; descriptor contents were not compared and legacy originals remain preserved",
"✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2),
report.legacy_session_file_count,
codewhale_util::utils::display_path(&report.primary_sessions_path),
);
}
DoctorSessionRecoveryStatus::MigrationPending
| DoctorSessionRecoveryStatus::MigrationIncomplete => {
let label = if report.status == DoctorSessionRecoveryStatus::MigrationIncomplete {
"migration is incomplete"
} else {
"migration has not completed"
};
println!(
" {} legacy sessions: {label}; {} recoverable file(s) are absent from {}",
"!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2),
report.recoverable_file_count,
codewhale_util::utils::display_path(&report.primary_sessions_path),
);
for entry in report
.recoverable
.iter()
.take(DOCTOR_SESSION_RECOVERY_HUMAN_SAMPLE_LIMIT)
{
println!(
" {} {} -> {}",
"·".dimmed(),
codewhale_util::utils::display_path(&entry.source_path),
codewhale_util::utils::display_path(&entry.destination_path),
);
}
if report.recoverable_file_count > DOCTOR_SESSION_RECOVERY_HUMAN_SAMPLE_LIMIT {
println!(
" · {} more filename(s); `codewhale doctor --json` includes a bounded metadata-only sample",
report.recoverable_file_count - DOCTOR_SESSION_RECOVERY_HUMAN_SAMPLE_LIMIT
);
}
println!(" Safe recovery:");
println!(
" 1. Back up {} and {} (if present).",
codewhale_util::utils::display_path(&report.legacy_sessions_path),
codewhale_util::utils::display_path(&report.primary_sessions_path),
);
println!(
" 2. Close other Codewhale processes, then run `codewhale sessions`; migration adds only missing files, never overwrites primary files, and leaves legacy originals in place."
);
println!(
" 3. Rerun `codewhale doctor`. If filenames remain, keep both backups and report only the listed source/destination names."
);
}
DoctorSessionRecoveryStatus::ScanFailed => {
println!(
" {} legacy sessions: recovery diagnostic could not complete",
"!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2)
);
if let Some(error) = report.error.as_deref() {
println!(" {error}");
}
println!(
" Keep both session directories unchanged, back them up, fix path permissions or shape, and rerun `codewhale doctor` before attempting migration."
);
}
}
if report.status != DoctorSessionRecoveryStatus::Isolated {
println!(
" Doctor inspected filenames and filesystem metadata only; it did not read chat contents, traverse checkpoints, or modify session files."
);
}
}
fn doctor_session_recovery_json(report: &DoctorSessionRecoveryReport) -> serde_json::Value {
use serde_json::json;
let recoverable: Vec<_> = report
.recoverable
.iter()
.take(DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT)
.map(|entry| {
json!({
"name": entry.name.display().to_string(),
"source_path": entry.source_path.display().to_string(),
"destination_path": entry.destination_path.display().to_string(),
})
})
.collect();
json!({
"status": report.status.as_str(),
"needs_attention": report.needs_attention(),
"read_only": true,
"chat_contents_read": false,
"checkpoint_internals_scanned": false,
"session_descriptors_compared": false,
"counterpart_check": "top_level_filename_and_regular_file_only",
"codewhale_home_is_explicit": report.codewhale_home_is_explicit,
"legacy_sessions_path": report.legacy_sessions_path.display().to_string(),
"primary_sessions_path": report.primary_sessions_path.display().to_string(),
"legacy_session_file_count": report.legacy_session_file_count,
"already_present_file_count": report.already_present_file_count,
"recoverable_file_count": report.recoverable_file_count,
"recoverable_files": recoverable,
"recoverable_files_truncated": report.recoverable_file_count > report.recoverable.len(),
"error": report.error,
"recovery_command": if report.needs_attention() && report.status != DoctorSessionRecoveryStatus::ScanFailed {
Some("codewhale sessions")
} else {
None
},
})
}
fn doctor_legacy_state_json(
primary_root: &Path,
legacy_root: &Path,
report: &[DoctorLegacyStateEntry],
session_recovery: &DoctorSessionRecoveryReport,
) -> serde_json::Value {
use serde_json::json;
let legacy_only = report
.iter()
.filter(|entry| entry.status == DoctorLegacyStateStatus::LegacyOnly)
.count();
let both = report
.iter()
.filter(|entry| entry.status == DoctorLegacyStateStatus::Both)
.count();
let entries: Vec<_> = report
.iter()
.map(|entry| {
json!({
"name": entry.name,
"primary_path": entry.primary_path.display().to_string(),
"legacy_path": entry.legacy_path.display().to_string(),
"primary_present": entry.primary_present,
"legacy_present": entry.legacy_present,
"status": entry.status.as_str(),
})
})
.collect();
json!({
"primary_root": primary_root.display().to_string(),
"legacy_root": legacy_root.display().to_string(),
"needs_attention": report.iter().any(legacy_state_needs_attention) || session_recovery.needs_attention(),
"legacy_only_count": legacy_only,
"dual_present_count": both,
"session_recovery": doctor_session_recovery_json(session_recovery),
"entries": entries,
})
}
fn doctor_setup_state(
config: &Config,
workspace: &Path,
) -> (codewhale_config::SetupState, &'static str) {
if let Ok(Some(state)) = codewhale_config::SetupState::load() {
return (state, "persisted");
}
(
codewhale_config::SetupState::derive_inherited(&doctor_inherited_setup_facts(
config, workspace,
)),
"derived",
)
}
fn doctor_inherited_setup_facts(
config: &Config,
workspace: &Path,
) -> codewhale_config::InheritedConfigFacts {
let user_constitution = codewhale_config::UserConstitution::load().ok();
let user_constitution_validity = user_constitution.as_ref().map_or(
codewhale_config::ConstitutionValidity::Unknown,
codewhale_config::UserConstitutionLoad::validity,
);
let has_user_constitution = user_constitution
.as_ref()
.is_some_and(|loaded| !matches!(loaded, codewhale_config::UserConstitutionLoad::Missing));
let has_expert_override = codewhale_config::codewhale_home()
.ok()
.map(|home| {
home.join(Path::new(
codewhale_engine::prompts::CONSTITUTION_OVERRIDE_FILE,
))
})
.is_some_and(|path| path.exists());
codewhale_config::InheritedConfigFacts {
language: None,
has_provider_route: !config.default_model().trim().is_empty(),
has_credentials_or_local_runtime: doctor_has_credentials_or_local_runtime(config),
trust_chosen: !codewhale_runtime::config::needs_trust(workspace),
has_expert_override,
has_user_constitution,
user_constitution_validity,
}
}
fn doctor_has_credentials_or_local_runtime(config: &Config) -> bool {
resolve_credential_diagnostic(config)
.availability
.certifies_ready()
}
fn print_doctor_setup_report(
config: &Config,
workspace: &Path,
state: &codewhale_config::SetupState,
source: &str,
ok_rgb: (u8, u8, u8),
warn_rgb: (u8, u8, u8),
) {
use colored::Colorize;
let credential = resolve_credential_diagnostic(config);
// Setup completion is persisted independently from credential probing.
// Ordinary doctor deliberately does not read environment values or the
// durable secret store, so `not_probed` must not erase a completed lane.
let first_run_ready = state.first_run_ready();
let update_ready =
state.update_ready(codewhale_engine::prompts::CONSTITUTION_CHECKPOINT_VERSION);
let operate_ready = state.operate_ready();
let first_run_icon = if first_run_ready {
"✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2)
} else {
"!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2)
};
let update_icon = if update_ready {
"✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2)
} else {
"!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2)
};
let operate_icon = if operate_ready {
"✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2)
} else {
"!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2)
};
println!();
println!("{}", "Setup State:".bold());
println!(" · source: {source}");
println!(
" · credential: source={}, availability={}",
doctor_api_key_source_label(credential.source),
credential.availability.label()
);
println!(
" {first_run_icon} first-run: {}",
doctor_ready_label(first_run_ready)
);
// An update checkpoint only means something once a prior setup exists;
// on a fresh home it is a stale version number with nothing to update.
if first_run_ready {
println!(
" {update_icon} update checkpoint {}: {}",