1#![allow(clippy::uninlined_format_args)]
4
5use std::collections::{BTreeSet, HashMap};
6use std::io::{self, IsTerminal, Read, Write};
7use std::path::{Path, PathBuf};
8use std::process::{Command, Stdio};
9use std::sync::Arc;
10use std::time::{Duration, Instant};
11
12use anyhow::{Context, Result, anyhow, bail};
13use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};
14use clap_complete::{Shell, generate};
15use tempfile::NamedTempFile;
16use wait_timeout::ChildExt;
17
18use crate::dependencies::ExternalTool;
19
20use rust_i18n::i18n;
21i18n!("locales", fallback = ["en"]);
22
23mod acp_server;
24mod artifacts;
25mod audit;
26mod auto_reasoning;
27mod automation_manager;
28mod child_env;
29mod client;
30mod codex_model_cache;
31mod command_safety;
32mod commands;
33mod compaction;
34mod composer_history;
35mod composer_stash;
36mod config;
37mod config_persistence;
38mod config_ui;
39mod context_budget;
40mod context_report;
41mod continual_harness;
42mod core;
43mod cost_status;
44mod deepseek_theme;
45mod dependencies;
46mod doctor;
47mod elapsed;
48mod error_taxonomy;
49mod eval;
50mod execpolicy;
51mod external_credentials;
52mod fast_hash;
53mod features;
54mod fleet;
55mod goal_loop;
56mod hashing;
57mod hooks;
58mod image_attach;
59mod lane_control;
60mod llm_client;
61mod llm_response_cache;
62mod localization;
63mod logging;
64mod lsp;
65mod mcp;
66mod mcp_server;
67mod model_catalog;
68mod model_context;
69mod model_inventory;
70mod model_profile;
71mod model_registry;
72mod model_routing;
73mod models;
74mod models_dev_live;
75mod native_memory;
76mod network_policy;
77mod oauth;
78mod palette;
79mod plugins;
80mod prefix_cache;
81mod pricing;
82mod project_context;
83mod project_context_cache;
84mod prompt_zones;
85mod prompts;
86mod provider_lake;
87mod provider_readiness;
88mod purge;
89mod regex_cache;
90mod remote_control;
91mod remote_setup;
92pub mod repl;
93mod repo_law;
94mod request_manifest;
95mod request_tuning;
96mod resource_telemetry;
97mod retry_status;
98pub mod rlm;
99mod route_billing;
100mod route_budget;
101mod route_receipt;
102mod route_runtime;
103mod runtime_api;
104mod runtime_handoff;
105mod runtime_log;
106mod runtime_policy;
107mod runtime_threads;
108mod safe_label;
109mod sandbox;
110mod scorecard;
111#[allow(dead_code)]
112mod session_diagnostics;
113#[cfg(test)]
117#[path = "main/tests.rs"]
118mod doctor_loader_tests;
119#[cfg(test)]
120mod session_control_acceptance;
121#[allow(dead_code)]
122mod session_manager;
123mod session_peek;
124mod session_projection;
125mod session_resume;
126pub mod session_tree;
127mod settings;
128mod shell_dispatcher;
129mod skill_state;
130mod skills;
131mod snapshot;
132mod startup_trace;
133mod task_manager;
134mod telemetry_notice;
135#[cfg(test)]
136mod test_support;
137mod tls;
138mod tool_history_repair;
139mod tool_inspection;
140mod tool_output_receipts;
141mod tools;
142mod tui;
143mod turn_route_plan;
144mod utils;
145mod vision;
146mod work_graph;
147mod work_grounding;
148mod worker_profile;
149mod working_set;
150mod workspace_discovery;
151mod workspace_trust;
152mod xai_oauth;
153
154use crate::config::{Config, DEFAULT_TEXT_MODEL, MAX_SUBAGENTS, effective_home_dir};
155use crate::eval::{EvalHarness, EvalHarnessConfig, ScenarioStepKind};
156use crate::features::{Feature, render_feature_table};
157use crate::llm_client::LlmClient;
158use crate::mcp::{
159 McpCommandAvailability, McpConfig, McpPool, McpServerConfig, McpServerOAuthConfig,
160 is_relative_stdio_path_arg,
161};
162use crate::models::{ContentBlock, Message, MessageRequest, SystemPrompt};
163use crate::session_manager::{SessionManager, create_saved_session, truncate_id};
164use crate::tui::history::{summarize_tool_args, summarize_tool_output};
165
166#[cfg(windows)]
167fn configure_windows_console_utf8() {
168 use windows::Win32::System::Console::{SetConsoleCP, SetConsoleOutputCP};
169
170 const CP_UTF8: u32 = 65001;
171 unsafe {
172 let _ = SetConsoleCP(CP_UTF8);
173 let _ = SetConsoleOutputCP(CP_UTF8);
174 }
175}
176
177#[cfg(not(windows))]
178fn configure_windows_console_utf8() {}
179
180fn install_rustls_crypto_provider() {
181 crate::tls::ensure_rustls_crypto_provider();
182}
183
184#[derive(Parser, Debug)]
185#[command(
186 name = "codewhale-tui",
187 bin_name = "codewhale-tui",
188 author,
189 version = env!("DEEPSEEK_BUILD_VERSION"),
190 about = "Codewhale terminal coding agent",
191 long_about = "Terminal-native TUI and CLI for open-source and open-weight coding models.\n\nRun 'codewhale' to start.\n\nProvider routes include DeepSeek, Arcee, Hugging Face, OpenRouter, Xiaomi MiMo, local vLLM/SGLang/Ollama, and more."
192)]
193struct Cli {
194 #[command(subcommand)]
196 command: Option<Commands>,
197
198 #[command(flatten)]
199 feature_toggles: FeatureToggles,
200
201 #[arg(short, long, value_name = "PROMPT", num_args = 1..)]
203 prompt: Vec<String>,
204
205 #[arg(long, hide = true)]
207 yolo: bool,
208
209 #[arg(long)]
211 max_subagents: Option<usize>,
212
213 #[arg(long)]
215 config: Option<PathBuf>,
216
217 #[arg(short, long)]
219 verbose: bool,
220
221 #[arg(long)]
223 profile: Option<String>,
224
225 #[arg(short, long)]
227 workspace: Option<PathBuf>,
228
229 #[arg(short, long)]
231 resume: Option<String>,
232
233 #[arg(short = 'c', long = "continue")]
235 continue_session: bool,
236
237 #[arg(long = "mouse-capture", conflicts_with = "no_mouse_capture")]
241 mouse_capture: bool,
242
243 #[arg(long = "no-mouse-capture", conflicts_with = "mouse_capture")]
245 no_mouse_capture: bool,
246
247 #[arg(long)]
249 skip_onboarding: bool,
250
251 #[arg(long, hide = true)]
253 remote_control: bool,
254
255 #[arg(long = "fresh")]
257 fresh: bool,
258
259 #[arg(long = "no-project-config")]
261 no_project_config: bool,
262}
263
264#[derive(Subcommand, Debug, Clone)]
265#[allow(clippy::large_enum_variant)]
266enum Commands {
267 Doctor(DoctorArgs),
269 SessionDiagnostics(SessionDiagnosticsArgs),
271 Setup(SetupArgs),
273 RemoteSetup(remote_setup::RemoteSetupArgs),
275 Completions {
277 #[arg(value_enum)]
279 shell: Shell,
280 },
281 Sessions {
283 #[arg(short, long, default_value = "20")]
285 limit: usize,
286 #[arg(short, long)]
288 search: Option<String>,
289 },
290 Init,
292 Login {
294 #[arg(long)]
296 api_key: Option<String>,
297 },
298 Logout,
300 Auth(TuiAuthArgs),
302 Models(ModelsArgs),
304 #[command(visible_alias = "tts")]
306 Speech(SpeechArgs),
307 Exec(ExecArgs),
309 Fleet(FleetArgs),
311 #[command(name = "workflow-tool", hide = true)]
313 WorkflowTool(WorkflowToolArgs),
314 Review(ReviewArgs),
316 Pr {
318 #[arg(value_name = "NUMBER")]
320 number: u32,
321 #[arg(short = 'R', long)]
324 repo: Option<String>,
325 #[arg(long, default_value_t = false)]
329 checkout: bool,
330 },
331 Apply(ApplyArgs),
333 Eval(EvalArgs),
335 Scorecard(ScorecardArgs),
337 Mcp {
339 #[command(subcommand)]
340 command: McpCommand,
341 },
342 Features(FeaturesCli),
344 Sandbox(SandboxArgs),
346 Serve(ServeArgs),
348 Resume {
350 #[arg(value_name = "SESSION_ID")]
352 session_id: Option<String>,
353 #[arg(long = "last", default_value_t = false, conflicts_with = "session_id")]
355 last: bool,
356 },
357 Fork {
359 #[arg(value_name = "SESSION_ID")]
361 session_id: Option<String>,
362 #[arg(long = "last", default_value_t = false, conflicts_with = "session_id")]
364 last: bool,
365 },
366}
367
368#[derive(Args, Debug, Clone)]
369#[command(after_help = "\
370Examples:
371 codewhale exec \"explain this function\"
372 codewhale exec --auto \"list crates/ with ls\"
373 codewhale exec --auto --output-format stream-json \"fix the failing test\"
374
375Plain `codewhale exec` is a one-shot model response. Use `--auto` for
376non-interactive agent-with-tools execution. `--auto` does not change the
377sandbox posture or elevate a denied tool. Use `--sandbox danger-full-access`
378or `--allow-sandbox-elevation` to explicitly authorize sandbox elevation.
379")]
380struct ExecArgs {
381 #[arg(long)]
383 model: Option<String>,
384 #[arg(long)]
390 provider: Option<String>,
391 #[arg(long = "reasoning-effort", value_name = "EFFORT")]
394 reasoning_effort: Option<String>,
395 #[arg(long, default_value_t = false)]
398 auto: bool,
399 #[arg(long, value_name = "POLICY")]
401 sandbox: Option<String>,
402 #[arg(long, default_value_t = false)]
404 allow_sandbox_elevation: bool,
405 #[arg(long, default_value_t = false, conflicts_with = "output_format")]
407 json: bool,
408 #[arg(long, value_name = "SESSION_ID", conflicts_with_all = ["session_id", "continue_session"])]
410 resume: Option<String>,
411 #[arg(long = "session-id", value_name = "SESSION_ID", conflicts_with_all = ["resume", "continue_session"])]
413 session_id: Option<String>,
414 #[arg(long = "continue", default_value_t = false, conflicts_with_all = ["resume", "session_id"])]
416 continue_session: bool,
417 #[arg(long, value_enum, default_value_t = ExecOutputFormat::Text)]
419 output_format: ExecOutputFormat,
420 #[arg(long, value_delimiter = ',')]
423 allowed_tools: Option<Vec<String>>,
424 #[arg(long, value_delimiter = ',')]
426 disallowed_tools: Option<Vec<String>>,
427 #[arg(long, value_parser = clap::value_parser!(u32).range(1..))]
429 max_turns: Option<u32>,
430 #[arg(long)]
432 append_system_prompt: Option<String>,
433 #[arg(long, value_name = "JSON", hide = true)]
435 tool_authority_json: Option<String>,
436 #[arg(
438 value_name = "PROMPT",
439 required = true,
440 trailing_var_arg = true,
441 allow_hyphen_values = true
442 )]
443 prompt: Vec<String>,
444}
445
446#[derive(Args, Debug, Clone)]
447struct WorkflowToolArgs {
448 #[arg(long, value_name = "SOURCE")]
450 approval_source: String,
451 #[arg(long, value_name = "JSON")]
453 input_json: String,
454}
455
456#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
457enum ExecOutputFormat {
458 Text,
459 #[value(name = "stream-json")]
460 StreamJson,
461}
462
463#[derive(Args, Debug, Clone)]
464struct TuiAuthArgs {
465 #[command(subcommand)]
466 command: TuiAuthCommand,
467}
468
469#[derive(Subcommand, Debug, Clone)]
470enum TuiAuthCommand {
471 #[command(name = "xai-device")]
473 XaiDevice,
474}
475
476const CODEWHALE_TOOL_SURFACE_ENV: &str = "CODEWHALE_TOOL_SURFACE";
477const SHELL_ONLY_EXEC_TOOLS: &[&str] = &["bash"];
478
479#[derive(Debug, Clone, Copy, PartialEq, Eq)]
480enum ExecToolSurface {
481 ShellOnly,
482}
483
484fn exec_tool_surface_from_env() -> Option<ExecToolSurface> {
485 std::env::var(CODEWHALE_TOOL_SURFACE_ENV)
486 .ok()
487 .and_then(|value| {
488 if should_warn_unknown_exec_tool_surface(&value) {
489 eprintln!(
490 "warning: unrecognized {CODEWHALE_TOOL_SURFACE_ENV}; leaving exec tool surface unchanged. Use `shell-only`, `full`, or `native-tools`."
491 );
492 }
493 parse_exec_tool_surface(&value)
494 })
495}
496
497fn parse_exec_tool_surface(value: &str) -> Option<ExecToolSurface> {
498 match value.trim().to_ascii_lowercase().as_str() {
499 "shell-only" | "shell_only" | "shell" => Some(ExecToolSurface::ShellOnly),
500 "full" | "native-tools" | "native_tools" | "" => None,
501 _ => None,
502 }
503}
504
505fn should_warn_unknown_exec_tool_surface(value: &str) -> bool {
506 let normalized = value.trim().to_ascii_lowercase();
507 !matches!(
508 normalized.as_str(),
509 "" | "shell-only" | "shell_only" | "shell" | "full" | "native-tools" | "native_tools"
510 )
511}
512
513fn normalize_exec_tool_names(tools: &[String]) -> Vec<String> {
514 tools
515 .iter()
516 .map(|name| name.to_ascii_lowercase().trim().to_string())
517 .collect()
518}
519
520fn shell_only_exec_allowed_tools() -> Vec<String> {
521 SHELL_ONLY_EXEC_TOOLS
522 .iter()
523 .map(|name| (*name).to_string())
524 .collect()
525}
526
527fn resolve_exec_allowed_tools(
528 cli_allowed_tools: Option<&[String]>,
529 env_tool_surface: Option<ExecToolSurface>,
530) -> Option<Vec<String>> {
531 if let Some(tools) = cli_allowed_tools {
532 return Some(normalize_exec_tool_names(tools));
533 }
534
535 env_tool_surface.map(|ExecToolSurface::ShellOnly| shell_only_exec_allowed_tools())
536}
537
538#[derive(Args, Debug, Clone)]
539struct FleetArgs {
540 #[command(subcommand)]
541 command: FleetCommand,
542}
543
544#[derive(Subcommand, Debug, Clone)]
545enum FleetCommand {
546 Init,
548 Run(FleetRunArgs),
550 List,
552 Status,
554 Inspect {
556 worker_id: String,
558 },
559 Logs {
561 worker_id: String,
563 },
564 Artifacts {
566 worker_id: String,
568 },
569 Interrupt {
571 worker_id: String,
573 },
574 Restart {
576 worker_id: String,
578 },
579 Resume {
581 run_id: String,
583 #[arg(long, default_value_t = 300)]
585 stale_after_seconds: u64,
586 },
587 Stop {
589 #[arg(long, required = true)]
591 all: bool,
592 },
593 AlertDryRun(FleetAlertDryRunArgs),
595}
596
597#[derive(Args, Debug, Clone)]
598struct FleetRunArgs {
599 #[arg(value_name = "TASK_SPEC")]
601 task_spec: PathBuf,
602 #[arg(long, default_value_t = 4)]
604 max_workers: usize,
605 #[arg(long, default_value_t = 300)]
607 stale_after_seconds: u64,
608 #[arg(long, hide = true, default_value_t = false)]
610 once: bool,
611}
612
613#[derive(Args, Debug, Clone)]
614struct FleetAlertDryRunArgs {
615 #[arg(long, value_enum)]
617 event: FleetAlertEventArg,
618 #[arg(long)]
620 run_id: String,
621 #[arg(long)]
623 worker_id: Option<String>,
624 #[arg(long)]
626 task_id: Option<String>,
627 #[arg(long, default_value = "manual fleet alert dry-run")]
629 reason: String,
630 #[arg(long)]
632 status: Option<String>,
633 #[arg(long, value_enum, default_value_t = FleetAlertAdapterArg::Slack)]
635 adapter: FleetAlertAdapterArg,
636 #[arg(long, default_value = "CODEWHALE_FLEET_SLACK_WEBHOOK")]
638 slack_webhook_env: String,
639 #[arg(long, default_value = "CODEWHALE_FLEET_WEBHOOK_URL")]
641 webhook_url_env: String,
642 #[arg(long)]
644 webhook_secret_env: Option<String>,
645 #[arg(long, default_value = "CODEWHALE_FLEET_PAGERDUTY_ROUTING_KEY")]
647 pagerduty_routing_key_env: String,
648 #[arg(long, default_value = "error")]
650 pagerduty_severity: String,
651}
652
653#[derive(ValueEnum, Debug, Clone, Copy)]
654enum FleetAlertEventArg {
655 Stale,
656 RestartExhausted,
657 NeedsHuman,
658 BudgetExceeded,
659 VerifierFailed,
660 RunCompleted,
661}
662
663#[derive(ValueEnum, Debug, Clone, Copy)]
664enum FleetAlertAdapterArg {
665 Slack,
666 Webhook,
667 PagerDuty,
668}
669
670fn spawn_signal_cleanup_task() {
687 let signals = TerminatingSignals::register();
688 tokio::spawn(async move {
689 let exit_code = signals.wait().await;
690 static CLEANED_UP: std::sync::atomic::AtomicBool =
694 std::sync::atomic::AtomicBool::new(false);
695 if !CLEANED_UP.swap(true, std::sync::atomic::Ordering::SeqCst) {
696 crate::tui::ui::emergency_restore_terminal();
697 record_signal_session_end();
708 }
709 std::process::exit(exit_code);
710 });
711}
712
713static TELEMETRY_SESSION_START: std::sync::OnceLock<std::time::Instant> =
716 std::sync::OnceLock::new();
717
718fn telemetry_session_end() -> codewhale_telemetry::Event {
729 let counters = codewhale_telemetry::session_counters();
730 codewhale_telemetry::Event::SessionEnd {
731 duration_bucket: codewhale_telemetry::DurationBucket::from_secs(
732 TELEMETRY_SESSION_START
733 .get()
734 .map_or(0, |start| start.elapsed().as_secs()),
735 ),
736 exit_class: codewhale_telemetry::exit_class(),
737 cold_start_bucket: crate::startup_trace::cold_start_ms()
738 .map(codewhale_telemetry::ColdStartBucket::from_millis),
739 providers: counters.providers(),
740 counters: counters.counters(),
741 errors: counters.errors(),
742 turn_wall: counters.turn_wall(),
743 }
744}
745
746fn record_signal_session_end() {
750 codewhale_telemetry::set_exit_class(codewhale_telemetry::ExitClass::Signal);
751 codewhale_telemetry::record_blocking(telemetry_session_end());
752}
753
754#[cfg(unix)]
759struct TerminatingSignals {
760 sigint: Option<tokio::signal::unix::Signal>,
761 sigterm: Option<tokio::signal::unix::Signal>,
762 sighup: Option<tokio::signal::unix::Signal>,
763}
764
765#[cfg(unix)]
766impl TerminatingSignals {
767 fn register() -> Self {
770 use tokio::signal::unix::{SignalKind, signal};
771 Self {
772 sigint: signal(SignalKind::interrupt()).ok(),
773 sigterm: signal(SignalKind::terminate()).ok(),
774 sighup: signal(SignalKind::hangup()).ok(),
775 }
776 }
777
778 async fn wait(mut self) -> i32 {
782 tokio::select! {
783 _ = async { match self.sigint.as_mut() { Some(s) => { s.recv().await; }, None => std::future::pending::<()>().await, } } => 130,
784 _ = async { match self.sigterm.as_mut() { Some(s) => { s.recv().await; }, None => std::future::pending::<()>().await, } } => 143,
785 _ = async { match self.sighup.as_mut() { Some(s) => { s.recv().await; }, None => std::future::pending::<()>().await, } } => 129,
786 }
787 }
788}
789
790#[cfg(not(unix))]
794struct TerminatingSignals {
795 ctrl_c: Option<tokio::signal::windows::CtrlC>,
796}
797
798#[cfg(not(unix))]
799impl TerminatingSignals {
800 fn register() -> Self {
801 Self {
802 ctrl_c: tokio::signal::windows::ctrl_c().ok(),
803 }
804 }
805
806 async fn wait(mut self) -> i32 {
807 match self.ctrl_c.as_mut() {
808 Some(s) => {
809 s.recv().await;
810 }
811 None => std::future::pending::<()>().await,
812 }
813 130
814 }
815}
816
817fn join_prompt_parts(parts: &[String]) -> String {
818 parts.join(" ")
819}
820
821fn resolve_exec_model(config: &Config, explicit_model: Option<&str>) -> String {
822 explicit_model
823 .map(str::trim)
824 .filter(|model| !model.is_empty())
825 .map(ToOwned::to_owned)
826 .or_else(exec_model_env_override)
827 .unwrap_or_else(|| config.default_model())
828}
829
830fn apply_exec_provider_override(config: &mut Config, provider_arg: &str) -> Result<()> {
831 let provider_arg = provider_arg.trim();
832 if provider_arg.is_empty() {
833 return Ok(());
834 }
835 if config
836 .providers
837 .as_ref()
838 .and_then(|providers| providers.custom_provider_config(provider_arg))
839 .is_some()
840 {
841 config.provider = Some(provider_arg.to_string());
842 return Ok(());
843 }
844 if let Some(provider) = crate::config::ApiProvider::parse(provider_arg) {
845 config.provider = Some(provider.as_str().to_string());
846 return Ok(());
847 }
848 bail!(
849 "Unrecognized --provider {provider_arg:?}. Known providers: {} \
850 or a configured [providers.<name>] custom provider",
851 crate::config::ApiProvider::names_hint()
852 );
853}
854
855fn exec_model_env_override() -> Option<String> {
856 let read = || {
857 ["CODEWHALE_MODEL", "DEEPSEEK_MODEL"]
858 .into_iter()
859 .find_map(|key| {
860 std::env::var(key)
861 .ok()
862 .map(|model| model.trim().to_string())
863 .filter(|model| !model.is_empty())
864 })
865 };
866 #[cfg(test)]
867 {
868 crate::test_support::with_test_env_lock(read)
869 }
870 #[cfg(not(test))]
871 {
872 read()
873 }
874}
875
876fn top_level_prompt_initial_input(parts: &[String]) -> Option<tui::InitialInput> {
877 (!parts.is_empty()).then(|| tui::InitialInput::Submit(join_prompt_parts(parts)))
878}
879
880fn resolve_exec_resume_session_id(args: &ExecArgs, workspace: &Path) -> Result<Option<String>> {
881 if let Some(id) = args.resume.as_ref().or(args.session_id.as_ref()) {
882 return Ok(Some(id.clone()));
883 }
884 if !args.continue_session {
885 return Ok(None);
886 }
887 latest_session_id_for_workspace(workspace)?.map_or_else(
888 || {
889 bail!(
890 "No saved sessions found for workspace {}. Use `codewhale sessions` to list sessions, or pass `codewhale exec --resume <SESSION_ID> ...`.",
891 workspace.display()
892 )
893 },
894 |id| Ok(Some(id)),
895 )
896}
897
898fn load_exec_resume_session(session_id: &str) -> Result<session_manager::SavedSession> {
899 let session_ref = exec_stream_session_ref(session_id);
900 SessionManager::default_location()
901 .context("could not open session manager for resume")?
902 .load_session_by_prefix(session_id)
903 .with_context(|| format!("could not load session {session_ref}"))
904}
905
906fn resolve_exec_resume_route(
914 config: &mut Config,
915 saved: &session_manager::SavedSession,
916 explicit_provider: bool,
917 explicit_model: Option<&str>,
918) -> Result<String> {
919 if !explicit_provider {
920 let saved_provider_identity = saved
921 .metadata
922 .model_provider_id
923 .as_deref()
924 .filter(|identity| !identity.trim().is_empty())
925 .unwrap_or(&saved.metadata.model_provider);
926 let identity = config
927 .resolve_persisted_provider_identity(
928 Some(&saved.metadata.model_provider),
929 saved.metadata.model_provider_id.as_deref(),
930 )
931 .map_err(anyhow::Error::msg)
932 .with_context(|| {
933 format!(
934 "saved session provider '{}' is unavailable; Codewhale will not fall back",
935 saved_provider_identity
936 )
937 })?;
938 config.scope_to_provider_identity(&identity);
939 }
940
941 if let Some(model) = explicit_model {
942 return Ok(resolve_exec_model(config, Some(model)));
943 }
944 if explicit_provider {
945 return Ok(resolve_exec_model(config, None));
946 }
947 Ok(saved.metadata.model.clone())
948}
949
950#[derive(Args, Debug, Clone, Default)]
951struct SetupArgs {
952 #[arg(long, default_value_t = false)]
954 mcp: bool,
955 #[arg(long, default_value_t = false)]
957 skills: bool,
958 #[arg(long, default_value_t = false)]
960 tools: bool,
961 #[arg(long, default_value_t = false)]
963 plugins: bool,
964 #[arg(long, default_value_t = false)]
966 all: bool,
967 #[arg(long, default_value_t = false)]
969 local: bool,
970 #[arg(long, default_value_t = false)]
972 force: bool,
973 #[arg(long, default_value_t = false, conflicts_with_all = ["mcp", "skills", "tools", "plugins", "all", "local", "clean"])]
975 status: bool,
976 #[arg(long, default_value_t = false, conflicts_with_all = ["mcp", "skills", "tools", "plugins", "all", "local", "status"])]
978 clean: bool,
979}
980
981#[derive(Args, Debug, Clone, Default)]
982struct DoctorArgs {
983 #[arg(long, default_value_t = false)]
985 json: bool,
986 #[arg(long, default_value_t = false, conflicts_with = "json")]
988 context_json: bool,
989 #[arg(
991 long,
992 default_value_t = false,
993 conflicts_with_all = ["json", "context_json"]
994 )]
995 probe_local: bool,
996 #[arg(
998 long,
999 default_value_t = false,
1000 conflicts_with_all = ["json", "context_json"]
1001 )]
1002 probe_api: bool,
1003 #[arg(
1005 long,
1006 default_value_t = false,
1007 conflicts_with_all = ["json", "context_json"]
1008 )]
1009 check_updates: bool,
1010 #[arg(
1012 long,
1013 default_value_t = false,
1014 conflicts_with_all = ["json", "context_json"]
1015 )]
1016 probe_mcp: bool,
1017}
1018
1019#[derive(Args, Debug, Clone)]
1020struct SessionDiagnosticsArgs {
1021 #[arg(value_name = "JSONL")]
1023 path: PathBuf,
1024 #[arg(long, default_value_t = false)]
1026 json: bool,
1027}
1028
1029#[derive(Args, Debug, Clone)]
1030struct ScorecardArgs {
1031 #[arg(long, value_name = "FILE")]
1039 input: PathBuf,
1040 #[arg(long, value_name = "FILE")]
1043 baseline: Option<PathBuf>,
1044 #[arg(long, default_value_t = 5.0)]
1046 threshold: f64,
1047 #[arg(long, default_value_t = false)]
1049 json: bool,
1050}
1051
1052#[derive(Args, Debug, Clone)]
1053struct EvalArgs {
1054 #[arg(long, value_name = "STEP")]
1056 fail_step: Option<String>,
1057 #[arg(long, default_value = "printf eval-harness")]
1059 shell_command: String,
1060 #[arg(long, default_value = "eval-harness")]
1062 shell_expect_token: String,
1063 #[arg(long, default_value_t = 240)]
1065 max_output_chars: usize,
1066 #[arg(long, default_value_t = false)]
1068 json: bool,
1069 #[arg(long, value_name = "DIR")]
1072 record: Option<PathBuf>,
1073}
1074
1075#[derive(Args, Debug, Clone, Default)]
1076struct ModelsArgs {
1077 #[arg(long, default_value_t = false)]
1079 json: bool,
1080}
1081
1082#[derive(Args, Debug, Clone)]
1083struct SpeechArgs {
1084 #[arg(value_name = "TEXT")]
1086 text: String,
1087
1088 #[arg(short, long, value_name = "FILE")]
1091 output: Option<PathBuf>,
1092
1093 #[arg(long = "output-dir", value_name = "DIR")]
1095 output_dir: Option<PathBuf>,
1096
1097 #[arg(long)]
1099 model: Option<String>,
1100
1101 #[arg(long)]
1103 voice: Option<String>,
1104
1105 #[arg(long)]
1107 instruction: Option<String>,
1108
1109 #[arg(long = "voice-prompt")]
1111 voice_prompt: Option<String>,
1112
1113 #[arg(long = "clone-voice", value_name = "FILE")]
1115 clone_voice: Option<PathBuf>,
1116
1117 #[arg(long, default_value = "wav")]
1119 format: String,
1120
1121 #[arg(long, default_value_t = false)]
1123 json: bool,
1124}
1125
1126#[derive(Args, Debug, Default, Clone)]
1127struct FeatureToggles {
1128 #[arg(long = "enable", value_name = "FEATURE", action = clap::ArgAction::Append, global = true)]
1130 enable: Vec<String>,
1131
1132 #[arg(long = "disable", value_name = "FEATURE", action = clap::ArgAction::Append, global = true)]
1134 disable: Vec<String>,
1135}
1136
1137impl FeatureToggles {
1138 fn apply(&self, config: &mut Config) -> Result<()> {
1139 for feature in &self.enable {
1140 config.set_feature(feature, true)?;
1141 }
1142 for feature in &self.disable {
1143 config.set_feature(feature, false)?;
1144 }
1145 Ok(())
1146 }
1147}
1148
1149#[derive(Args, Debug, Clone)]
1150struct ReviewArgs {
1151 #[arg(long, conflicts_with = "base")]
1153 staged: bool,
1154 #[arg(long)]
1156 base: Option<String>,
1157 #[arg(long)]
1159 path: Option<PathBuf>,
1160 #[arg(long)]
1162 model: Option<String>,
1163 #[arg(long, default_value_t = 200_000)]
1165 max_chars: usize,
1166 #[arg(long, default_value_t = false)]
1168 write_receipt: bool,
1169 #[arg(long, default_value_t = false)]
1171 check_receipt: bool,
1172 #[arg(long)]
1174 receipt_path: Option<PathBuf>,
1175 #[arg(long, default_value_t = false)]
1177 json: bool,
1178}
1179
1180#[derive(Args, Debug, Clone)]
1181struct ApplyArgs {
1182 #[arg(value_name = "PATCH_FILE")]
1184 patch_file: Option<PathBuf>,
1185}
1186
1187#[derive(Args, Debug, Clone)]
1188struct ServeArgs {
1189 #[arg(long)]
1191 mcp: bool,
1192 #[arg(long)]
1194 http: bool,
1195 #[arg(long)]
1197 mobile: bool,
1198 #[arg(long)]
1200 web: bool,
1201 #[arg(long, requires = "mobile")]
1203 qr: bool,
1204 #[arg(long)]
1206 acp: bool,
1207 #[arg(long)]
1209 host: Option<String>,
1210 #[arg(long, default_value_t = 7878)]
1212 port: u16,
1213 #[arg(long, default_value_t = 2)]
1215 workers: usize,
1216 #[arg(long = "cors-origin", value_name = "URL")]
1222 cors_origin: Vec<String>,
1223 #[arg(long = "auth-token", value_name = "TOKEN")]
1227 auth_token: Option<String>,
1228 #[arg(long = "insecure")]
1230 insecure_no_auth: bool,
1231}
1232
1233#[derive(Debug, Clone, PartialEq, Eq)]
1234struct ServeBindHost {
1235 host: String,
1236 mobile_rebound_to_lan: bool,
1237}
1238
1239fn resolve_serve_bind_host(mobile: bool, host: Option<String>) -> ServeBindHost {
1240 match (mobile, host) {
1241 (true, None) => ServeBindHost {
1242 host: "0.0.0.0".to_string(),
1243 mobile_rebound_to_lan: true,
1244 },
1245 (_, Some(host)) => ServeBindHost {
1246 host,
1247 mobile_rebound_to_lan: false,
1248 },
1249 (false, None) => ServeBindHost {
1250 host: "127.0.0.1".to_string(),
1251 mobile_rebound_to_lan: false,
1252 },
1253 }
1254}
1255
1256fn validate_serve_mode_selection(
1257 mcp: bool,
1258 http: bool,
1259 mobile: bool,
1260 web: bool,
1261 acp: bool,
1262) -> Result<bool> {
1263 if http && mobile {
1264 bail!("--http and --mobile are mutually exclusive; choose one");
1265 }
1266 if web && (http || mobile) {
1267 bail!("--web is mutually exclusive with --http and --mobile");
1268 }
1269 let http_selected = http || mobile || web;
1270 let selected_modes = [mcp, http_selected, acp]
1271 .into_iter()
1272 .filter(|selected| *selected)
1273 .count();
1274 if selected_modes != 1 {
1275 bail!("Choose exactly one server mode: --mcp, --http/--mobile/--web, or --acp");
1276 }
1277 Ok(http_selected)
1278}
1279
1280#[derive(Subcommand, Debug, Clone)]
1281enum McpCommand {
1282 List,
1284 Init {
1286 #[arg(long, default_value_t = false)]
1288 force: bool,
1289 },
1290 Connect {
1292 #[arg(value_name = "SERVER")]
1294 server: Option<String>,
1295 },
1296 Tools {
1298 #[arg(value_name = "SERVER")]
1300 server: Option<String>,
1301 },
1302 Add {
1304 name: String,
1306 #[arg(long, conflicts_with = "url")]
1308 command: Option<String>,
1309 #[arg(long, conflicts_with = "command")]
1311 url: Option<String>,
1312 #[arg(long, requires = "url")]
1314 transport: Option<String>,
1315 #[arg(long, requires = "url")]
1317 bearer_token_env_var: Option<String>,
1318 #[arg(long, requires = "url")]
1320 oauth_client_id: Option<String>,
1321 #[arg(long, requires = "url")]
1323 oauth_resource: Option<String>,
1324 #[arg(long = "scope", requires = "url", value_delimiter = ',')]
1326 scopes: Vec<String>,
1327 #[arg(long = "arg")]
1329 args: Vec<String>,
1330 },
1331 Login {
1333 name: String,
1335 #[arg(long = "scope", value_delimiter = ',')]
1337 scopes: Vec<String>,
1338 },
1339 Logout {
1341 name: String,
1343 },
1344 Remove {
1346 name: String,
1348 },
1349 Enable {
1351 name: String,
1353 },
1354 Disable {
1356 name: String,
1358 },
1359 Validate,
1361 #[command(
1366 name = "add-self",
1367 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."
1368 )]
1369 AddSelf {
1370 #[arg(long, default_value = "codewhale")]
1372 name: String,
1373 #[arg(long)]
1375 workspace: Option<String>,
1376 },
1377}
1378
1379#[derive(Args, Debug, Clone)]
1380struct FeaturesCli {
1381 #[command(subcommand)]
1382 command: FeaturesSubcommand,
1383}
1384
1385#[derive(Subcommand, Debug, Clone)]
1386enum FeaturesSubcommand {
1387 List,
1389}
1390
1391#[derive(Args, Debug, Clone)]
1392struct SandboxArgs {
1393 #[command(subcommand)]
1394 command: SandboxCommand,
1395}
1396
1397#[derive(Subcommand, Debug, Clone)]
1398enum SandboxCommand {
1399 Run {
1401 #[arg(long, default_value = "workspace-write")]
1403 policy: String,
1404 #[arg(long)]
1406 network: bool,
1407 #[arg(long, value_name = "PATH")]
1409 writable_root: Vec<PathBuf>,
1410 #[arg(long)]
1412 exclude_tmpdir: bool,
1413 #[arg(long)]
1415 exclude_slash_tmp: bool,
1416 #[arg(long)]
1418 cwd: Option<PathBuf>,
1419 #[arg(long, default_value_t = 60_000)]
1421 timeout_ms: u64,
1422 #[arg(required = true, trailing_var_arg = true)]
1424 command: Vec<String>,
1425 },
1426}
1427
1428const CODEWHALE_MAIN_STACK_BYTES: usize = 16 * 1024 * 1024;
1429
1430pub fn run(args: Vec<String>) -> std::process::ExitCode {
1434 match run_with_args(args) {
1435 Ok(()) => std::process::ExitCode::SUCCESS,
1436 Err(err) => {
1437 eprintln!("error: {err}");
1438 for cause in err.chain().skip(1) {
1439 eprintln!(" caused by: {cause}");
1440 }
1441 std::process::ExitCode::FAILURE
1442 }
1443 }
1444}
1445
1446fn run_with_args(args: Vec<String>) -> Result<()> {
1450 #[cfg(unix)]
1454 unsafe {
1455 libc::signal(libc::SIGPIPE, libc::SIG_DFL);
1456 }
1457
1458 startup_trace::mark_process_start();
1459 configure_windows_console_utf8();
1460 install_rustls_crypto_provider();
1461
1462 crate::sandbox::process_hardening::apply_process_hardening();
1466
1467 let orig_hook = std::panic::take_hook();
1472 std::panic::set_hook(Box::new(move |panic_info| {
1473 crate::tui::ui::emergency_restore_terminal();
1479
1480 let msg = if let Some(s) = panic_info.payload().downcast_ref::<&str>() {
1481 s.to_string()
1482 } else if let Some(s) = panic_info.payload().downcast_ref::<String>() {
1483 s.clone()
1484 } else {
1485 format!("{:?}", panic_info.payload())
1486 };
1487 let location = panic_info
1488 .location()
1489 .map(|loc| loc.to_string())
1490 .unwrap_or_else(|| "unknown".to_string());
1491 tracing::error!(target: "panic", "Process panicked at {location}: {msg}");
1492
1493 codewhale_telemetry::set_exit_class(codewhale_telemetry::ExitClass::Panic);
1504 if let Some(site) = panic_info
1505 .location()
1506 .map(|loc| codewhale_telemetry::reduce_panic_site(loc.file(), loc.line(), loc.column()))
1507 {
1508 codewhale_telemetry::record_blocking(codewhale_telemetry::Event::Panic { site });
1509 }
1510 if let Some(home) = crate::config::effective_home_dir() {
1512 let crash_dir = home.join(".deepseek").join("crashes");
1513 let _ = std::fs::create_dir_all(&crash_dir);
1514 use chrono::Utc;
1515 let ts = Utc::now().format("%Y%m%dT%H%M%S%.3fZ");
1516 let path = crash_dir.join(format!("{ts}-process-panic.log"));
1517 let contents =
1518 format!("Process panicked\nLocation: {location}\nTimestamp: {ts}\nPanic: {msg}\n",);
1519 let _ = std::fs::write(&path, contents);
1520 }
1521 orig_hook(panic_info);
1523 }));
1524
1525 let cli = match Cli::try_parse_from(args) {
1532 Ok(c) => c,
1533 Err(e) => {
1534 e.exit();
1535 }
1536 };
1537 crate::fleet::roster::set_project_agent_profiles_enabled(!cli.no_project_config);
1542 let workspace = resolve_workspace(&cli);
1543 let mut plugin_discovery = None;
1544 let mut plugin_registry = None;
1545 let (cli, command) = prepare_cli_startup(
1546 cli,
1547 || {
1548 let discovery = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv();
1549 plugin_registry = Some(discovery.registry_for_workspace(&workspace));
1550 plugin_discovery = Some(discovery);
1551 },
1552 warn_on_workspace_dotenv_result,
1553 );
1554 let plugin_discovery = plugin_discovery
1555 .expect("plugin discovery initialization must precede workspace dotenv loading");
1556 let plugin_registry = plugin_registry
1557 .expect("plugin discovery initialization must precede workspace dotenv loading");
1558
1559 let runtime_thread = std::thread::Builder::new()
1567 .name("codewhale-main".to_string())
1568 .stack_size(CODEWHALE_MAIN_STACK_BYTES)
1569 .spawn(move || run_async_main(cli, command, plugin_discovery, plugin_registry))
1570 .context("Failed to start the Codewhale runtime thread")?;
1571 match runtime_thread.join() {
1572 Ok(result) => result,
1573 Err(payload) => {
1574 let message = payload
1575 .downcast_ref::<&str>()
1576 .map(|value| (*value).to_string())
1577 .or_else(|| payload.downcast_ref::<String>().cloned())
1578 .unwrap_or_else(|| "unknown panic payload".to_string());
1579 Err(anyhow!("Codewhale runtime thread panicked: {message}"))
1580 }
1581 }
1582}
1583
1584fn run_async_main(
1585 cli: Cli,
1586 command: Option<Commands>,
1587 plugin_discovery: Arc<crate::plugins::PluginDiscoveryContext>,
1588 plugin_registry: Arc<crate::plugins::PluginRegistry>,
1589) -> Result<()> {
1590 build_runtime()?.block_on(run_async_main_inner(
1591 cli,
1592 command,
1593 plugin_discovery,
1594 plugin_registry,
1595 ))
1596}
1597
1598pub(crate) fn build_runtime() -> Result<tokio::runtime::Runtime> {
1617 tokio::runtime::Builder::new_multi_thread()
1618 .enable_all()
1619 .thread_stack_size(CODEWHALE_MAIN_STACK_BYTES)
1620 .build()
1621 .context("Failed to build the Codewhale Tokio runtime")
1622}
1623
1624fn telemetry_surface(command: Option<&Commands>) -> codewhale_telemetry::Surface {
1630 use codewhale_telemetry::Surface;
1631 match command {
1632 None | Some(Commands::Resume { .. } | Commands::Fork { .. }) => Surface::Tui,
1633 Some(Commands::Exec(_)) => Surface::Exec,
1634 Some(Commands::Serve(args)) => {
1635 if args.mcp {
1636 Surface::McpServer
1637 } else {
1638 Surface::Serve
1639 }
1640 }
1641 Some(_) => Surface::Cli,
1642 }
1643}
1644
1645fn telemetry_session_source(command: Option<&Commands>) -> codewhale_telemetry::SessionSource {
1647 use codewhale_telemetry::SessionSource;
1648 match command {
1649 None => SessionSource::Interactive,
1650 Some(Commands::Resume { .. }) => SessionSource::Resume,
1651 Some(Commands::Fork { .. }) => SessionSource::Fork,
1652 Some(Commands::Serve(_)) => SessionSource::Api,
1653 Some(_) => SessionSource::Unknown,
1654 }
1655}
1656
1657fn arm_telemetry(cli: &Cli, command: Option<&Commands>) {
1671 let surface = telemetry_surface(command);
1672 let Ok(store) = codewhale_config::ConfigStore::load(cli.config.clone()) else {
1673 return;
1674 };
1675 let resolved = store
1676 .config
1677 .resolve_runtime_options(&codewhale_config::CliRuntimeOverrides::default());
1678 let setup = codewhale_config::SetupState::load()
1679 .ok()
1680 .flatten()
1681 .unwrap_or_default();
1682 let codewhale_telemetry::TelemetryDecision::Enabled(consent) =
1683 codewhale_telemetry::decide(&resolved, &setup, surface)
1684 else {
1685 return;
1686 };
1687 codewhale_telemetry::init(consent.with_config_path(Some(store.path().to_path_buf())));
1688 let _ = TELEMETRY_SESSION_START.set(std::time::Instant::now());
1689 codewhale_telemetry::record(codewhale_telemetry::Event::SessionStart {
1690 source: telemetry_session_source(command),
1691 });
1692}
1693
1694async fn finish_telemetry(outcome: &Result<()>) {
1696 if !codewhale_telemetry::is_armed() {
1697 return;
1698 }
1699 if outcome.is_err()
1702 && codewhale_telemetry::exit_class() == codewhale_telemetry::ExitClass::Clean
1703 {
1704 codewhale_telemetry::set_exit_class(codewhale_telemetry::ExitClass::Error);
1705 }
1706 codewhale_telemetry::record(telemetry_session_end());
1707 let _ = tokio::time::timeout(
1712 codewhale_telemetry::SHUTDOWN_FLUSH_TIMEOUT,
1713 tokio::task::spawn_blocking(|| {
1714 codewhale_telemetry::shutdown_blocking(codewhale_telemetry::SHUTDOWN_FLUSH_TIMEOUT)
1715 }),
1716 )
1717 .await;
1718}
1719
1720async fn run_async_main_inner(
1721 cli: Cli,
1722 command: Option<Commands>,
1723 plugin_discovery: Arc<crate::plugins::PluginDiscoveryContext>,
1724 plugin_registry: Arc<crate::plugins::PluginRegistry>,
1725) -> Result<()> {
1726 spawn_signal_cleanup_task();
1747
1748 if telemetry_surface(command.as_ref()) == codewhale_telemetry::Surface::Tui {
1761 crate::telemetry_notice::prompt_if_due(cli.skip_onboarding, cli.config.clone());
1762 }
1763 arm_telemetry(&cli, command.as_ref());
1764 let outcome = run_async_main_dispatch(cli, command, plugin_discovery, plugin_registry).await;
1765 finish_telemetry(&outcome).await;
1766 outcome
1767}
1768
1769async fn run_async_main_dispatch(
1770 cli: Cli,
1771 command: Option<Commands>,
1772 plugin_discovery: Arc<crate::plugins::PluginDiscoveryContext>,
1773 plugin_registry: Arc<crate::plugins::PluginRegistry>,
1774) -> Result<()> {
1775 logging::set_verbose(cli.verbose || logging::env_requests_verbose_logging());
1776
1777 crate::prompts::load_prompt_overrides_from_config_home();
1782
1783 if let Some(command) = command {
1791 return match command {
1792 Commands::Doctor(args) => {
1793 let config = match load_doctor_config_from_cli(&cli, &args) {
1794 Ok(config) => config,
1795 Err(error) if args.json => return run_doctor_json_config_error(&error),
1796 Err(_) => {
1797 bail!(
1798 "doctor configuration validation failed; details omitted because configuration errors may contain credential material"
1799 )
1800 }
1801 };
1802 let workspace = resolve_workspace(&cli);
1803 if args.context_json {
1804 run_doctor_context_json(&config, &workspace)
1805 } else if args.json {
1806 run_doctor_json(
1807 &config,
1808 &workspace,
1809 cli.config.as_deref(),
1810 plugin_registry.as_ref(),
1811 )
1812 } else {
1813 let probes = crate::doctor::DoctorProbeRequest {
1814 check_updates: args.check_updates,
1815 probe_api: args.probe_api,
1816 probe_local: args.probe_local,
1817 probe_mcp: args.probe_mcp,
1818 };
1819 run_doctor(
1820 &config,
1821 &workspace,
1822 cli.config.as_deref(),
1823 probes,
1824 plugin_registry.as_ref(),
1825 )
1826 .await;
1827 Ok(())
1828 }
1829 }
1830 Commands::SessionDiagnostics(args) => run_session_diagnostics(args),
1831 Commands::Setup(args) => {
1832 let config = load_config_from_cli(&cli)?;
1833 let workspace = resolve_workspace(&cli);
1834 run_setup(&config, &workspace, args, plugin_registry.as_ref())
1835 }
1836 Commands::RemoteSetup(args) => remote_setup::run_remote_setup(args),
1837 Commands::Completions { shell } => {
1838 generate_completions(shell);
1839 Ok(())
1840 }
1841 Commands::Sessions { limit, search } => list_sessions(limit, search),
1842 Commands::Init => init_project(),
1843 Commands::Login { api_key } => run_login(api_key),
1844 Commands::Logout => run_logout(),
1845 Commands::Auth(args) => match args.command {
1846 TuiAuthCommand::XaiDevice => run_xai_device_auth(cli.config.as_deref()).await,
1847 },
1848 Commands::Models(args) => {
1849 let config = load_config_from_cli(&cli)?;
1850 run_models(&config, args).await
1851 }
1852 Commands::Speech(args) => {
1853 let config = load_config_from_cli(&cli)?;
1854 run_speech(&config, args).await
1855 }
1856 Commands::Exec(args) => {
1857 let config = load_config_from_cli(&cli)?;
1858 let workspace = cli.workspace.clone().unwrap_or_else(|| {
1859 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
1860 });
1861 let mut config = config.clone();
1862 if !cli.no_project_config {
1867 merge_user_workspace_config(&mut config, cli.config.clone(), &workspace);
1868 }
1869 if let Some(sandbox) = args.sandbox.as_deref() {
1870 let _ = parse_sandbox_policy(sandbox, true, Vec::new(), false, false)?;
1871 config.sandbox_mode = Some(sandbox.to_ascii_lowercase());
1872 }
1873 if let Ok(env_url) = std::env::var("CODEWHALE_BASE_URL")
1876 .or_else(|_| std::env::var("DEEPSEEK_BASE_URL"))
1877 {
1878 let trimmed = env_url.trim();
1879 if !trimmed.is_empty() {
1880 config.base_url = Some(trimmed.to_string());
1881 }
1882 }
1883 let explicit_provider = args
1893 .provider
1894 .as_deref()
1895 .map(str::trim)
1896 .filter(|provider| !provider.is_empty());
1897 if let Some(provider_arg) = explicit_provider {
1898 apply_exec_provider_override(&mut config, provider_arg)?;
1899 }
1900 if let Some(reasoning_arg) = args
1901 .reasoning_effort
1902 .as_deref()
1903 .map(str::trim)
1904 .filter(|value| !value.is_empty())
1905 {
1906 config.reasoning_effort = normalize_cli_reasoning_effort(reasoning_arg)?;
1907 config.reasoning_effort_inferred_from_legacy_alias = false;
1908 }
1909 let prompt = join_prompt_parts(&args.prompt);
1910 let resume_session_id = resolve_exec_resume_session_id(&args, &workspace)?;
1911 validate_exec_tool_authority_resume(
1912 args.tool_authority_json.as_deref(),
1913 resume_session_id.is_some(),
1914 )?;
1915 let resume_session = resume_session_id
1916 .as_deref()
1917 .map(load_exec_resume_session)
1918 .transpose()?;
1919 let explicit_model = args
1920 .model
1921 .as_deref()
1922 .map(str::trim)
1923 .filter(|model| !model.is_empty());
1924 let model = if let Some(saved) = resume_session.as_ref() {
1925 resolve_exec_resume_route(
1926 &mut config,
1927 saved,
1928 explicit_provider.is_some(),
1929 explicit_model,
1930 )?
1931 } else {
1932 resolve_exec_model(&config, explicit_model)
1933 };
1934 let force_configured_route = should_force_configured_exec_route(
1935 resume_session.is_some(),
1936 explicit_provider,
1937 explicit_model,
1938 );
1939 let yolo = cli.yolo || config.yolo.unwrap_or(false);
1943 let env_tool_surface = exec_tool_surface_from_env();
1944 let needs_engine = args.auto
1945 || yolo
1946 || resume_session_id.is_some()
1947 || args.output_format == ExecOutputFormat::StreamJson
1948 || args.max_turns.is_some()
1949 || args.allowed_tools.is_some()
1950 || args.disallowed_tools.is_some()
1951 || args.append_system_prompt.is_some()
1952 || args.tool_authority_json.is_some()
1953 || args.sandbox.is_some()
1954 || args.allow_sandbox_elevation
1955 || env_tool_surface.is_some();
1956 if needs_engine {
1957 let provider = config.api_provider();
1958 let max_subagents = cli.max_subagents.map_or_else(
1959 || config.max_subagents_for_provider(provider),
1960 |value| value.clamp(1, MAX_SUBAGENTS),
1961 );
1962 let auto_mode = args.auto || yolo;
1963 let max_turns = exec_max_steps(args.max_turns);
1964 let allowed_tools =
1965 resolve_exec_allowed_tools(args.allowed_tools.as_deref(), env_tool_surface);
1966 let disallowed_tools = args
1967 .disallowed_tools
1968 .as_deref()
1969 .map(normalize_exec_tool_names);
1970 run_exec_agent(
1971 &config,
1972 &model,
1973 &prompt,
1974 workspace,
1975 max_subagents,
1976 auto_mode,
1977 args.allow_sandbox_elevation,
1978 args.sandbox.as_deref(),
1979 auto_mode,
1980 args.json,
1981 resume_session,
1982 force_configured_route,
1983 args.output_format,
1984 max_turns,
1985 allowed_tools,
1986 disallowed_tools,
1987 args.append_system_prompt.clone(),
1988 args.tool_authority_json.clone(),
1989 std::sync::Arc::clone(&plugin_registry),
1990 )
1991 .await
1992 } else if args.json {
1993 run_one_shot_json(&config, &model, &prompt, force_configured_route).await
1994 } else {
1995 run_one_shot(&config, &model, &prompt, force_configured_route).await
1996 }
1997 }
1998 Commands::Fleet(args) => {
1999 let config = load_config_from_cli(&cli)?;
2000 let workspace = resolve_workspace(&cli);
2001 run_fleet_command(&workspace, &config, args).await
2002 }
2003 Commands::WorkflowTool(args) => {
2004 run_workflow_tool_command(&cli, args, std::sync::Arc::clone(&plugin_registry)).await
2005 }
2006 Commands::Review(args) => {
2007 let config = load_config_from_cli(&cli)?;
2008 run_review(&config, args).await
2009 }
2010 Commands::Pr {
2011 number,
2012 repo,
2013 checkout,
2014 } => {
2015 let config = load_config_from_cli(&cli)?;
2016 run_pr(
2017 &cli,
2018 &config,
2019 number,
2020 repo.as_deref(),
2021 checkout,
2022 Arc::clone(&plugin_registry),
2023 )
2024 .await
2025 }
2026 Commands::Apply(args) => run_apply(args),
2027 Commands::Eval(args) => run_eval(args),
2028 Commands::Scorecard(args) => run_scorecard(args),
2029 Commands::Mcp { command } => {
2030 let config = load_config_from_cli(&cli)?;
2031 let workspace = resolve_workspace(&cli);
2032 run_mcp_command(&config, &workspace, command, plugin_registry.as_ref()).await
2033 }
2034 Commands::Features(command) => {
2035 let config = load_config_from_cli(&cli)?;
2036 run_features_command(&config, command)
2037 }
2038 Commands::Sandbox(args) => run_sandbox_command(args),
2039 Commands::Serve(args) => {
2040 let workspace = cli.workspace.clone().unwrap_or_else(|| {
2041 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
2042 });
2043 let http_selected = validate_serve_mode_selection(
2044 args.mcp,
2045 args.http,
2046 args.mobile,
2047 args.web,
2048 args.acp,
2049 )?;
2050 if args.mcp {
2051 tokio::task::block_in_place(|| mcp_server::run_mcp_server(workspace))
2052 } else if http_selected {
2053 let (config, config_profile) =
2054 load_config_from_cli_with_effective_profile(&cli)?;
2055 let cors_origins = resolve_cors_origins(&config, &args.cors_origin);
2056 let bind_host = resolve_serve_bind_host(args.mobile, args.host);
2057 if args.web && bind_host.host != "127.0.0.1" {
2058 bail!("Codewhale web is loopback-only and must bind to 127.0.0.1");
2059 }
2060 if bind_host.mobile_rebound_to_lan {
2061 println!(
2062 "WARNING: --mobile is binding to 0.0.0.0 so LAN devices can reach the mobile control page. Use --host 127.0.0.1 to keep mobile loopback-only."
2063 );
2064 }
2065 runtime_api::run_http_server(
2066 config,
2067 workspace,
2068 std::sync::Arc::clone(&plugin_discovery),
2069 runtime_api::RuntimeApiOptions {
2070 host: bind_host.host,
2071 port: args.port,
2072 workers: args.workers.clamp(1, 8),
2073 cors_origins,
2074 auth_token: args.auth_token,
2075 insecure_no_auth: args.insecure_no_auth,
2076 mobile: args.mobile,
2077 web: args.web,
2078 show_qr: args.qr,
2079 config_path: cli.config.clone(),
2080 config_profile,
2081 },
2082 )
2083 .await
2084 } else if args.acp {
2085 let config = load_config_from_cli(&cli)?;
2086 let model = config.default_model();
2087 acp_server::run_acp_server(config, model, workspace).await
2088 } else {
2089 unreachable!("server mode count checked above")
2090 }
2091 }
2092 Commands::Resume { session_id, last } => {
2093 let config = load_config_from_cli(&cli)?;
2094 let workspace = resolve_workspace(&cli);
2095 let resume_id = resolve_session_id(session_id, last, &workspace)?;
2096 run_interactive(
2097 &cli,
2098 &config,
2099 Some(resume_id),
2100 None,
2101 std::sync::Arc::clone(&plugin_registry),
2102 )
2103 .await
2104 }
2105 Commands::Fork { session_id, last } => {
2106 let config = load_config_from_cli(&cli)?;
2107 let workspace = resolve_workspace(&cli);
2108 let new_session_id = fork_session(&config, session_id, last, &workspace)?;
2109 run_interactive(
2110 &cli,
2111 &config,
2112 Some(new_session_id),
2113 None,
2114 std::sync::Arc::clone(&plugin_registry),
2115 )
2116 .await
2117 }
2118 };
2119 }
2120
2121 let config = load_config_from_cli(&cli)?;
2125 if let Some(initial_input) = top_level_prompt_initial_input(&cli.prompt) {
2126 return run_interactive(
2127 &cli,
2128 &config,
2129 None,
2130 Some(initial_input),
2131 std::sync::Arc::clone(&plugin_registry),
2132 )
2133 .await;
2134 }
2135
2136 let mut startup_notice = None;
2139 let resume_session_id = if cli.continue_session {
2140 let workspace = resolve_workspace(&cli);
2141 recover_interrupted_checkpoint_for_resume(&workspace)
2142 .or_else(|| latest_session_id_for_workspace(&workspace).ok().flatten())
2143 } else if let Some(id) = cli.resume.clone() {
2144 Some(id)
2145 } else if !cli.fresh {
2146 let workspace = resolve_workspace(&cli);
2147 preserve_interrupted_checkpoint_for_explicit_resume(&workspace);
2148 let (session_id, notice) = resolve_auto_resume(&workspace);
2154 startup_notice = notice;
2155 session_id
2156 } else {
2157 None
2158 };
2159
2160 run_interactive_with_notice(
2163 &cli,
2164 &config,
2165 resume_session_id,
2166 None,
2167 startup_notice,
2168 plugin_registry,
2169 )
2170 .await
2171}
2172
2173fn resolve_auto_resume(workspace: &Path) -> (Option<String>, Option<String>) {
2180 use crate::session_resume::{ResumeRequest, decide_auto_resume};
2181
2182 let enabled = crate::settings::Settings::load_persisted()
2183 .map(|settings| settings.session_auto_resume)
2184 .unwrap_or(false);
2185 if !enabled {
2186 return (None, None);
2187 }
2188 let Ok(manager) = SessionManager::default_location() else {
2189 return (None, None);
2190 };
2191 let decision = decide_auto_resume(true, &ResumeRequest::default(), workspace, &manager);
2192 (
2193 decision.session_id().map(str::to_string),
2194 decision.status_message(),
2195 )
2196}
2197
2198fn prepare_cli_startup(
2199 cli: Cli,
2200 initialize_plugins: impl FnOnce(),
2201 load_dotenv: impl FnOnce(),
2202) -> (Cli, Option<Commands>) {
2203 initialize_plugins();
2204 let command = cli.command.clone();
2205 let should_load_dotenv = match command.as_ref() {
2206 Some(Commands::Doctor(args)) => args.probe_api || args.probe_local,
2207 _ => true,
2208 };
2209 if should_load_dotenv {
2210 load_dotenv();
2211 }
2212 (cli, command)
2213}
2214
2215const MAX_WORKSPACE_DOTENV_BYTES: u64 = 1024 * 1024;
2216
2217#[derive(Debug, Default)]
2218struct WorkspaceDotenvReport {
2219 path: PathBuf,
2220 loaded: BTreeSet<String>,
2221 ignored: BTreeSet<String>,
2222}
2223
2224fn warn_on_workspace_dotenv_result() {
2232 match load_workspace_dotenv_credentials() {
2233 Ok(Some(report)) if !report.ignored.is_empty() => {
2234 eprintln!(
2235 "Codewhale ignored non-credential settings in {}: {}. Use config.toml, CLI flags, or the launching shell for control settings.",
2236 report.path.display(),
2237 display_env_key_set(&report.ignored)
2238 );
2239 }
2240 Ok(_) => {}
2241 Err(error) => {
2242 eprintln!("Codewhale did not load workspace .env: {error}");
2246 }
2247 }
2248}
2249
2250fn display_env_key_set(keys: &BTreeSet<String>) -> String {
2251 const MAX_DISPLAYED: usize = 12;
2252 let mut labels = keys
2253 .iter()
2254 .take(MAX_DISPLAYED)
2255 .map(|key| {
2256 if key
2257 .chars()
2258 .all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_')
2259 {
2260 key.as_str()
2261 } else {
2262 "<invalid-name>"
2263 }
2264 })
2265 .collect::<Vec<_>>();
2266 if keys.len() > MAX_DISPLAYED {
2267 labels.push("...");
2268 }
2269 labels.join(", ")
2270}
2271
2272fn load_workspace_dotenv_credentials() -> Result<Option<WorkspaceDotenvReport>> {
2273 let Some(path) = find_workspace_dotenv()? else {
2274 return Ok(None);
2275 };
2276 load_workspace_dotenv_credentials_from_path(&path).map(Some)
2277}
2278
2279fn find_workspace_dotenv() -> Result<Option<PathBuf>> {
2280 let cwd = std::env::current_dir().context("could not resolve the current workspace")?;
2281 let boundary = cwd
2282 .ancestors()
2283 .find(|ancestor| std::fs::symlink_metadata(ancestor.join(".git")).is_ok())
2284 .unwrap_or(cwd.as_path());
2285
2286 for ancestor in cwd.ancestors() {
2287 let candidate = ancestor.join(".env");
2288 match std::fs::symlink_metadata(&candidate) {
2289 Ok(_) => return Ok(Some(candidate)),
2290 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
2291 Err(error) => {
2292 return Err(anyhow!(
2293 "could not inspect {}: {error}",
2294 candidate.display()
2295 ));
2296 }
2297 }
2298 if ancestor == boundary {
2299 break;
2300 }
2301 }
2302 Ok(None)
2303}
2304
2305fn load_workspace_dotenv_credentials_from_path(path: &Path) -> Result<WorkspaceDotenvReport> {
2306 let contents = read_stable_workspace_dotenv(path)?;
2307 let text = std::str::from_utf8(&contents)
2308 .map_err(|_| anyhow!("{} is not valid UTF-8", path.display()))?;
2309 if dotenv_has_variable_expansion(text) {
2310 bail!(
2311 "{} uses variable expansion; workspace .env values must be literal to prevent ambient-secret substitution",
2312 path.display()
2313 );
2314 }
2315
2316 let mut report = WorkspaceDotenvReport {
2317 path: path.to_path_buf(),
2318 ..WorkspaceDotenvReport::default()
2319 };
2320 let entries = dotenvy::from_read_iter(std::io::Cursor::new(contents))
2321 .collect::<std::result::Result<Vec<_>, _>>()
2322 .map_err(|_| anyhow!("{} could not be parsed safely", path.display()))?;
2323 for entry in entries {
2324 let (key, value) = entry;
2325 if !is_workspace_dotenv_credential_key(&key) {
2326 report.ignored.insert(key);
2327 continue;
2328 }
2329 if std::env::var_os(&key).is_some() {
2330 continue;
2331 }
2332
2333 unsafe { std::env::set_var(&key, value) };
2338 report.loaded.insert(key);
2339 }
2340 Ok(report)
2341}
2342
2343fn is_workspace_dotenv_credential_key(key: &str) -> bool {
2344 codewhale_config::provider::providers_sorted_for_display()
2345 .into_iter()
2346 .any(|provider| provider.env_vars().contains(&key))
2347 || matches!(
2348 key,
2349 "DEEPSEEK_SEARCH_API_KEY"
2350 | "SOFYA_API_KEY"
2351 | "METASO_API_KEY"
2352 | "BAIDU_SEARCH_API_KEY"
2353 | "DEEPSEEK_SANDBOX_API_KEY"
2354 )
2355}
2356
2357fn dotenv_has_variable_expansion(contents: &str) -> bool {
2358 let mut escaped = false;
2359 let mut single_quoted = false;
2360 let mut double_quoted = false;
2361 let mut comment = false;
2362
2363 for ch in contents.chars() {
2364 if comment {
2365 if ch == '$' {
2369 return true;
2370 }
2371 if ch == '\n' {
2372 comment = false;
2373 escaped = false;
2374 }
2375 continue;
2376 }
2377 if single_quoted {
2378 if ch == '\'' {
2379 single_quoted = false;
2380 }
2381 continue;
2382 }
2383 if escaped {
2384 escaped = false;
2385 continue;
2386 }
2387 if ch == '\\' {
2388 escaped = true;
2389 continue;
2390 }
2391 if ch == '\'' && !double_quoted {
2392 single_quoted = true;
2393 continue;
2394 }
2395 if ch == '"' {
2396 double_quoted = !double_quoted;
2397 continue;
2398 }
2399 if ch == '#' && !double_quoted {
2400 comment = true;
2401 continue;
2402 }
2403 if ch == '$' {
2404 return true;
2405 }
2406 }
2407 false
2408}
2409
2410fn read_stable_workspace_dotenv(path: &Path) -> Result<Vec<u8>> {
2411 let mut file = open_workspace_dotenv_without_following_links(path)?;
2412 let metadata = file
2413 .metadata()
2414 .map_err(|error| anyhow!("could not inspect {}: {error}", path.display()))?;
2415 if !metadata.is_file() {
2416 bail!("{} is not a regular file", path.display());
2417 }
2418 if workspace_dotenv_has_multiple_links(&file, &metadata)? {
2419 bail!(
2420 "{} has multiple filesystem links, not a unique workspace-owned file",
2421 path.display()
2422 );
2423 }
2424 if metadata.len() > MAX_WORKSPACE_DOTENV_BYTES {
2425 bail!(
2426 "{} exceeds the {} byte workspace .env limit",
2427 path.display(),
2428 MAX_WORKSPACE_DOTENV_BYTES
2429 );
2430 }
2431
2432 let mut contents = Vec::with_capacity(metadata.len() as usize);
2433 (&mut file)
2434 .take(MAX_WORKSPACE_DOTENV_BYTES + 1)
2435 .read_to_end(&mut contents)
2436 .map_err(|error| anyhow!("could not read {}: {error}", path.display()))?;
2437 if contents.len() as u64 > MAX_WORKSPACE_DOTENV_BYTES {
2438 bail!(
2439 "{} exceeds the {} byte workspace .env limit",
2440 path.display(),
2441 MAX_WORKSPACE_DOTENV_BYTES
2442 );
2443 }
2444 Ok(contents)
2445}
2446
2447#[cfg(unix)]
2448fn workspace_dotenv_has_multiple_links(
2449 _file: &std::fs::File,
2450 metadata: &std::fs::Metadata,
2451) -> Result<bool> {
2452 use std::os::unix::fs::MetadataExt;
2453
2454 Ok(metadata.nlink() > 1)
2455}
2456
2457#[cfg(windows)]
2458fn workspace_dotenv_has_multiple_links(
2459 file: &std::fs::File,
2460 _metadata: &std::fs::Metadata,
2461) -> Result<bool> {
2462 use std::os::windows::io::AsRawHandle;
2463 use windows::Win32::Foundation::HANDLE;
2464 use windows::Win32::Storage::FileSystem::{
2465 BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle,
2466 };
2467
2468 let mut information = BY_HANDLE_FILE_INFORMATION::default();
2469 unsafe {
2473 GetFileInformationByHandle(HANDLE(file.as_raw_handle()), &mut information)
2474 .map_err(|error| anyhow!("could not inspect workspace .env link count: {error}"))?;
2475 }
2476 Ok(information.nNumberOfLinks > 1)
2477}
2478
2479#[cfg(not(any(unix, windows)))]
2480fn workspace_dotenv_has_multiple_links(
2481 _file: &std::fs::File,
2482 _metadata: &std::fs::Metadata,
2483) -> Result<bool> {
2484 Ok(false)
2485}
2486
2487#[cfg(unix)]
2488fn open_workspace_dotenv_without_following_links(path: &Path) -> Result<std::fs::File> {
2489 use std::os::unix::fs::OpenOptionsExt;
2490
2491 std::fs::OpenOptions::new()
2492 .read(true)
2493 .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK)
2496 .open(path)
2497 .map_err(|error| anyhow!("could not securely open {}: {error}", path.display()))
2498}
2499
2500#[cfg(windows)]
2501fn open_workspace_dotenv_without_following_links(path: &Path) -> Result<std::fs::File> {
2502 use std::os::windows::fs::{MetadataExt, OpenOptionsExt};
2503
2504 const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
2505 const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
2506 let file = std::fs::OpenOptions::new()
2507 .read(true)
2508 .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
2509 .open(path)
2510 .map_err(|error| anyhow!("could not securely open {}: {error}", path.display()))?;
2511 let metadata = file
2512 .metadata()
2513 .map_err(|error| anyhow!("could not inspect {}: {error}", path.display()))?;
2514 if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
2515 bail!(
2516 "{} is a reparse point, not a workspace-owned file",
2517 path.display()
2518 );
2519 }
2520 Ok(file)
2521}
2522
2523#[cfg(not(any(unix, windows)))]
2524fn open_workspace_dotenv_without_following_links(path: &Path) -> Result<std::fs::File> {
2525 let metadata = std::fs::symlink_metadata(path)
2526 .map_err(|error| anyhow!("could not inspect {}: {error}", path.display()))?;
2527 if metadata.file_type().is_symlink() {
2528 bail!(
2529 "{} is a symbolic link, not a workspace-owned file",
2530 path.display()
2531 );
2532 }
2533 std::fs::File::open(path)
2534 .map_err(|error| anyhow!("could not securely open {}: {error}", path.display()))
2535}
2536
2537fn generate_completions(shell: Shell) {
2539 let mut cmd = Cli::command();
2540 let name = cmd.get_name().to_string();
2541 generate(shell, &mut cmd, name, &mut io::stdout());
2542}
2543
2544fn run_eval(args: EvalArgs) -> Result<()> {
2546 let fail_step = match args.fail_step.as_deref() {
2547 Some(value) => ScenarioStepKind::parse(value)
2548 .map(Some)
2549 .ok_or_else(|| anyhow!("invalid --fail-step '{value}'"))?,
2550 None => None,
2551 };
2552
2553 let config = EvalHarnessConfig {
2554 fail_step,
2555 shell_command: args.shell_command,
2556 shell_expect_token: args.shell_expect_token,
2557 max_output_chars: args.max_output_chars,
2558 record_dir: args.record.clone(),
2559 ..EvalHarnessConfig::default()
2560 };
2561
2562 let harness = EvalHarness::new(config);
2563 let run = harness.run().context("evaluation harness failed")?;
2564 let report = run.to_report();
2565
2566 if args.json {
2567 let json = serde_json::to_string_pretty(&report)?;
2568 println!("{json}");
2569 } else {
2570 println!("Offline Eval Harness");
2571 println!("scenario: {}", report.scenario_name);
2572 println!("workspace: {}", report.workspace_root.display());
2573 println!("success: {}", report.metrics.success);
2574 println!("steps: {}", report.metrics.steps);
2575 println!("tool_errors: {}", report.metrics.tool_errors);
2576 println!("duration_ms: {}", report.metrics.duration.as_millis());
2577
2578 if !report.metrics.per_tool.is_empty() {
2579 println!("per_tool:");
2580 for (kind, stats) in &report.metrics.per_tool {
2581 println!(
2582 " {} invocations={} errors={} duration_ms={}",
2583 kind.tool_name(),
2584 stats.invocations,
2585 stats.errors,
2586 stats.total_duration.as_millis()
2587 );
2588 }
2589 }
2590
2591 let failed_steps: Vec<_> = report.steps.iter().filter(|s| !s.success).collect();
2592 if !failed_steps.is_empty() {
2593 println!("failed_steps:");
2594 for step in failed_steps {
2595 let error = step.error.as_deref().unwrap_or("unknown error");
2596 println!(
2597 " {} tool={} error={}",
2598 step.kind.tool_name(),
2599 step.tool_name,
2600 error
2601 );
2602 }
2603 }
2604 }
2605
2606 if report.metrics.success {
2607 Ok(())
2608 } else {
2609 bail!("offline evaluation harness reported failure")
2610 }
2611}
2612
2613fn run_scorecard(args: ScorecardArgs) -> Result<()> {
2619 use crate::scorecard::{RecordedTurn, Scorecard, ScorecardMetrics};
2620
2621 let raw = std::fs::read_to_string(&args.input)
2622 .with_context(|| format!("failed to read scorecard input {}", args.input.display()))?;
2623 let recorded: Vec<RecordedTurn> = serde_json::from_str(&raw)
2624 .with_context(|| format!("failed to parse scorecard input {}", args.input.display()))?;
2625
2626 let card = Scorecard::from_recorded_turns(&recorded);
2627
2628 let regressions = match &args.baseline {
2629 Some(path) => {
2630 let baseline_raw = std::fs::read_to_string(path)
2631 .with_context(|| format!("failed to read baseline {}", path.display()))?;
2632 let baseline: ScorecardMetrics = serde_json::from_str(&baseline_raw)
2633 .with_context(|| format!("failed to parse baseline {}", path.display()))?;
2634 card.metrics.regressions_against(&baseline, args.threshold)
2635 }
2636 None => Vec::new(),
2637 };
2638
2639 if args.json {
2640 let out = serde_json::json!({
2641 "per_turn": card.per_turn,
2642 "metrics": card.metrics,
2643 "regressions": regressions,
2644 });
2645 println!("{}", serde_json::to_string_pretty(&out)?);
2646 } else {
2647 print!("{}", card.to_summary());
2648 for r in ®ressions {
2649 println!(
2650 "REGRESSION {}: baseline {:.4} -> current {:.4} (+{:.1}%)",
2651 r.metric, r.baseline, r.current, r.pct_increase
2652 );
2653 }
2654 }
2655
2656 if regressions.is_empty() {
2657 Ok(())
2658 } else {
2659 bail!(
2660 "{} metric(s) regressed past the {:.1}% threshold",
2661 regressions.len(),
2662 args.threshold
2663 )
2664 }
2665}
2666
2667async fn run_fleet_command(workspace: &Path, config: &Config, args: FleetArgs) -> Result<()> {
2668 use crate::fleet::alerts::{
2669 FleetAlertAdapterConfig, FleetAlertConfig, FleetAlertDispatcher, FleetAlertEvent,
2670 FleetEnvSecretResolver,
2671 };
2672 use crate::fleet::control as fleet_control;
2673 use crate::fleet::executor::FleetExecutor;
2674 use crate::fleet::manager::{FleetManager, FleetStatusSnapshot, FleetWorkerInspection};
2675 use codewhale_lane::{ControlOperation, ControlSurface};
2676 use codewhale_protocol::fleet::{FleetAlertEventClass, FleetArtifactKind, FleetRunId};
2677
2678 fn print_status(status: &FleetStatusSnapshot) {
2682 println!("{}", fleet_control::render_fleet_status_snapshot(status));
2683 }
2684
2685 fn print_inspection(inspection: &FleetWorkerInspection) {
2686 println!("{}", fleet_control::render_inspection(inspection));
2687 }
2688
2689 fn print_artifacts(inspection: &FleetWorkerInspection) {
2690 println!("{}", fleet_control::render_artifacts(inspection));
2691 }
2692
2693 fn emit_fleet_receipt(receipt: &codewhale_lane::ControlReceipt) -> Result<()> {
2695 if receipt.is_error() {
2696 eprintln!("{}", receipt.render());
2697 let detail = receipt
2698 .failure
2699 .as_ref()
2700 .map(ToString::to_string)
2701 .unwrap_or_else(|| receipt.outcome.as_str().to_string());
2702 bail!("{}: {detail}", receipt.operation_id);
2703 }
2704 println!("{}", receipt.render());
2705 Ok(())
2706 }
2707
2708 fn print_logs(workspace: &Path, inspection: &FleetWorkerInspection) -> Result<()> {
2709 let mut printed = false;
2710 for artifact in inspection
2711 .artifacts
2712 .iter()
2713 .filter(|artifact| matches!(artifact.kind, FleetArtifactKind::Log))
2714 {
2715 let path = workspace.join(&artifact.path);
2716 println!("== {} ==", artifact.path.display());
2717 let contents = std::fs::read_to_string(&path)
2718 .with_context(|| format!("reading fleet log {}", path.display()))?;
2719 let preview: String = contents.chars().take(16 * 1024).collect();
2720 let mut safe_preview = String::with_capacity(preview.len());
2725 crate::tui::osc8::strip_ansi_into(&preview, &mut safe_preview);
2726 print!("{safe_preview}");
2727 if contents.chars().count() > preview.chars().count() {
2728 println!("\n[truncated]");
2729 } else if !preview.ends_with('\n') {
2730 println!();
2731 }
2732 printed = true;
2733 }
2734 if !printed {
2735 println!("logs: none");
2736 }
2737 Ok(())
2738 }
2739
2740 fn alert_event_class(arg: FleetAlertEventArg) -> FleetAlertEventClass {
2741 match arg {
2742 FleetAlertEventArg::Stale => FleetAlertEventClass::Stale,
2743 FleetAlertEventArg::RestartExhausted => FleetAlertEventClass::RestartExhausted,
2744 FleetAlertEventArg::NeedsHuman => FleetAlertEventClass::NeedsHuman,
2745 FleetAlertEventArg::BudgetExceeded => FleetAlertEventClass::BudgetExceeded,
2746 FleetAlertEventArg::VerifierFailed => FleetAlertEventClass::VerifierFailed,
2747 FleetAlertEventArg::RunCompleted => FleetAlertEventClass::RunCompleted,
2748 }
2749 }
2750
2751 fn alert_status(class: FleetAlertEventClass, override_status: Option<String>) -> String {
2752 if let Some(status) = override_status {
2753 return status;
2754 }
2755 match class {
2756 FleetAlertEventClass::Stale => "stale",
2757 FleetAlertEventClass::RestartExhausted => "failed",
2758 FleetAlertEventClass::NeedsHuman => "needs_human",
2759 FleetAlertEventClass::BudgetExceeded => "budget_exceeded",
2760 FleetAlertEventClass::VerifierFailed => "verifier_failed",
2761 FleetAlertEventClass::RunCompleted => "completed",
2762 }
2763 .to_string()
2764 }
2765
2766 fn alert_adapter(args: &FleetAlertDryRunArgs) -> FleetAlertAdapterConfig {
2767 match args.adapter {
2768 FleetAlertAdapterArg::Slack => FleetAlertAdapterConfig::Slack {
2769 webhook_env: args.slack_webhook_env.clone(),
2770 channel: None,
2771 },
2772 FleetAlertAdapterArg::Webhook => FleetAlertAdapterConfig::Webhook {
2773 url_env: args.webhook_url_env.clone(),
2774 secret_env: args.webhook_secret_env.clone(),
2775 },
2776 FleetAlertAdapterArg::PagerDuty => FleetAlertAdapterConfig::PagerDuty {
2777 routing_key_env: args.pagerduty_routing_key_env.clone(),
2778 severity: args.pagerduty_severity.clone(),
2779 },
2780 }
2781 }
2782
2783 let fleet_config = config.fleet_config();
2784 let provider = config.api_provider();
2785 let max_subagents = config.max_subagents_for_provider(provider);
2786 let coordination_manager = crate::tools::subagent::new_shared_subagent_manager_with_timeout(
2787 workspace.to_path_buf(),
2788 max_subagents,
2789 config
2790 .max_admitted_subagents_for_provider(provider)
2791 .max(max_subagents),
2792 Duration::from_secs(config.subagent_heartbeat_timeout_secs_for_provider(provider)),
2793 config.launch_concurrency_for_provider(provider),
2794 config.subagent_token_budget_for_provider(provider),
2795 );
2796 let fleet_context = fleet_control::fleet_control_context(workspace);
2801 if let Some(operation) = match &args.command {
2810 FleetCommand::List => Some(ControlOperation::FleetList),
2811 FleetCommand::Status => Some(ControlOperation::FleetStatus),
2812 FleetCommand::Interrupt { .. } => Some(ControlOperation::FleetInterrupt),
2813 FleetCommand::Resume { .. } => Some(ControlOperation::FleetResume),
2814 _ => None,
2815 } {
2816 let descriptor = operation.descriptor();
2817 let availability = descriptor.availability(ControlSurface::Cli, fleet_context);
2818 if !availability.is_available() {
2819 return emit_fleet_receipt(&codewhale_lane::ControlReceipt::unavailable(
2820 descriptor,
2821 ControlSurface::Cli,
2822 availability,
2823 ));
2824 }
2825 }
2826
2827 let manager = FleetManager::open(workspace)?
2830 .with_exec_config(fleet_config.exec.clone())
2831 .with_fleet_config(fleet_config)
2832 .with_sub_agent_manager(coordination_manager)
2833 .with_session_model(config.default_model())
2834 .with_route_config(config.clone());
2835 match args.command {
2836 FleetCommand::Init => {
2837 println!("fleet ledger: {}", manager.ledger_path().display());
2838 Ok(())
2839 }
2840 FleetCommand::Run(args) => {
2841 let max_workers = args.max_workers.clamp(1, 128);
2842 let manager =
2843 manager.with_stale_after(Duration::from_secs(args.stale_after_seconds.max(1)));
2844 let report = manager.create_run_from_task_spec_path(&args.task_spec, max_workers)?;
2845 println!(
2846 "fleet run: {} tasks={} leased={} queued={}",
2847 report.run_id.0, report.task_count, report.leased, report.queued
2848 );
2849 for warning in &report.warnings {
2850 println!("warning: {warning}");
2851 }
2852 println!("workers:");
2853 for worker_id in &report.worker_ids {
2854 println!(" {worker_id}");
2855 }
2856 if args.once {
2857 print_status(&manager.run_status(&report.run_id)?);
2858 return Ok(());
2859 }
2860 println!(
2861 "manager loop running; use `codewhale fleet status`, `inspect`, `interrupt`, or `stop --all` from another terminal."
2862 );
2863 let mut executor = FleetExecutor::new(workspace);
2864 let codewhale_binary = fleet::executor::configured_codewhale_binary();
2865 let status = manager
2866 .run_to_completion(
2867 &report.run_id,
2868 max_workers,
2869 &mut executor,
2870 &codewhale_binary,
2871 None,
2872 Duration::from_secs(2),
2873 )
2874 .await?;
2875 print_status(&status);
2876 Ok(())
2877 }
2878 FleetCommand::List => emit_fleet_receipt(&fleet_control::execute_fleet_control_with(
2879 ControlSurface::Cli,
2880 workspace,
2881 fleet_context,
2882 &manager,
2883 ControlOperation::FleetList,
2884 None,
2885 )),
2886 FleetCommand::Status => emit_fleet_receipt(&fleet_control::execute_fleet_control_with(
2887 ControlSurface::Cli,
2888 workspace,
2889 fleet_context,
2890 &manager,
2891 ControlOperation::FleetStatus,
2892 None,
2893 )),
2894 FleetCommand::Inspect { worker_id } => {
2895 print_inspection(&manager.inspect_worker(&worker_id)?);
2896 Ok(())
2897 }
2898 FleetCommand::Logs { worker_id } => {
2899 let inspection = manager.inspect_worker(&worker_id)?;
2900 print_logs(workspace, &inspection)
2901 }
2902 FleetCommand::Artifacts { worker_id } => {
2903 let inspection = manager.inspect_worker(&worker_id)?;
2904 print_artifacts(&inspection);
2905 Ok(())
2906 }
2907 FleetCommand::Interrupt { worker_id } => {
2908 emit_fleet_receipt(&fleet_control::execute_fleet_control_with(
2909 ControlSurface::Cli,
2910 workspace,
2911 fleet_context,
2912 &manager,
2913 ControlOperation::FleetInterrupt,
2914 Some(&worker_id),
2915 ))
2916 }
2917 FleetCommand::Restart { worker_id } => {
2918 let report = manager.restart_worker(&worker_id)?;
2919 print_inspection(&report.inspection);
2920 println!(
2921 "manager loop running for restarted run {}; use `codewhale fleet status`, `inspect`, `interrupt`, or `stop --all` from another terminal.",
2922 report.run_id.0
2923 );
2924 let mut executor = FleetExecutor::new(workspace);
2925 let codewhale_binary = fleet::executor::configured_codewhale_binary();
2926 let status = manager
2927 .run_to_completion(
2928 &report.run_id,
2929 report.max_workers,
2930 &mut executor,
2931 &codewhale_binary,
2932 None,
2933 Duration::from_secs(2),
2934 )
2935 .await?;
2936 print_status(&status);
2937 Ok(())
2938 }
2939 FleetCommand::Resume {
2940 run_id,
2941 stale_after_seconds,
2942 } => {
2943 let manager = manager.with_stale_after(Duration::from_secs(stale_after_seconds.max(1)));
2944 emit_fleet_receipt(&fleet_control::execute_fleet_control_with(
2945 ControlSurface::Cli,
2946 workspace,
2947 fleet_context,
2948 &manager,
2949 ControlOperation::FleetResume,
2950 Some(&run_id),
2951 ))
2952 }
2953 FleetCommand::Stop { all } => {
2954 if !all {
2955 bail!("pass --all to stop all fleet work");
2956 }
2957 let stopped = manager.stop_all()?;
2958 println!("stopped: {stopped}");
2959 Ok(())
2960 }
2961 FleetCommand::AlertDryRun(args) => {
2962 let class = alert_event_class(args.event);
2963 let adapter = alert_adapter(&args);
2964 let event = FleetAlertEvent {
2965 class,
2966 run_id: FleetRunId::from(args.run_id.clone()),
2967 worker_id: args.worker_id.clone(),
2968 task_id: args.task_id.clone(),
2969 status: alert_status(class, args.status.clone()),
2970 reason: args.reason.clone(),
2971 };
2972 let dispatcher = FleetAlertDispatcher::new(
2973 FleetAlertConfig::dry_run_for_adapter(adapter),
2974 FleetEnvSecretResolver,
2975 );
2976 let deliveries = dispatcher.dispatch(&event)?;
2977 for delivery in deliveries {
2978 println!(
2979 "{}",
2980 serde_json::to_string_pretty(&delivery.redacted_payload)?
2981 );
2982 }
2983 Ok(())
2984 }
2985 }
2986}
2987
2988#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2989enum WriteStatus {
2990 Created,
2991 Overwritten,
2992 SkippedExists,
2993}
2994
2995fn ensure_parent_dir(path: &Path) -> Result<()> {
2996 if let Some(parent) = path.parent()
2997 && !parent.as_os_str().is_empty()
2998 {
2999 std::fs::create_dir_all(parent)
3000 .with_context(|| format!("Failed to create directory for {}", parent.display()))?;
3001 }
3002 Ok(())
3003}
3004
3005fn write_template_file(path: &Path, contents: &str, force: bool) -> Result<WriteStatus> {
3006 ensure_parent_dir(path)?;
3007
3008 if path.exists() && !force {
3009 return Ok(WriteStatus::SkippedExists);
3010 }
3011
3012 let status = if path.exists() {
3013 WriteStatus::Overwritten
3014 } else {
3015 WriteStatus::Created
3016 };
3017
3018 std::fs::write(path, contents)
3019 .with_context(|| format!("Failed to write template at {}", path.display()))?;
3020
3021 Ok(status)
3022}
3023
3024fn mcp_template_json() -> Result<String> {
3025 let mut cfg = McpConfig::default();
3026 cfg.servers.insert(
3027 "example".to_string(),
3028 McpServerConfig {
3029 command: Some("node".to_string()),
3030 args: vec!["./path/to/your-mcp-server.js".to_string()],
3031 env: std::collections::HashMap::new(),
3032 cwd: None,
3033 url: None,
3034 transport: None,
3035 connect_timeout: None,
3036 execute_timeout: None,
3037 read_timeout: None,
3038 disabled: true,
3039 enabled: true,
3040 required: false,
3041 enabled_tools: Vec::new(),
3042 disabled_tools: Vec::new(),
3043 headers: std::collections::HashMap::new(),
3044 env_headers: std::collections::HashMap::new(),
3045 bearer_token_env_var: None,
3046 scopes: Vec::new(),
3047 oauth: None,
3048 oauth_resource: None,
3049 reviewed_plugin: None,
3050 },
3051 );
3052 serde_json::to_string_pretty(&cfg)
3053 .map_err(|e| anyhow!("Failed to render MCP template JSON: {e}"))
3054}
3055
3056fn init_mcp_config(path: &Path, force: bool) -> Result<WriteStatus> {
3057 let template = mcp_template_json()?;
3058 write_template_file(path, &template, force)
3059}
3060
3061fn skills_template(name: &str) -> String {
3062 format!(
3063 "\
3064---\n\
3065name: {name}\n\
3066description: Quick repo diagnostics and setup guidance\n\
3067allowed-tools: diagnostics, list_dir, read_file, grep_files, git_status, git_diff\n\
3068---\n\n\
3069When this skill is active:\n\
30701. Run the diagnostics tool to report workspace and sandbox status.\n\
30712. Skim key project files (README.md, Cargo.toml, AGENTS.md) before editing.\n\
30723. Prefer small, validated changes and summarize what you verified.\n\
3073"
3074 )
3075}
3076
3077fn init_skills_dir(skills_dir: &Path, force: bool) -> Result<(PathBuf, WriteStatus)> {
3078 std::fs::create_dir_all(skills_dir)
3079 .with_context(|| format!("Failed to create skills dir {}", skills_dir.display()))?;
3080
3081 let skill_name = "getting-started";
3082 let skill_path = skills_dir.join(skill_name).join("SKILL.md");
3083 ensure_parent_dir(&skill_path)?;
3084
3085 let status = write_template_file(&skill_path, &skills_template(skill_name), force)?;
3086 Ok((skill_path, status))
3087}
3088
3089fn tools_readme_template() -> &'static str {
3090 "# Local tools\n\n\
3091 Drop self-describing scripts here so they can be discovered by\n\
3092 `codewhale-tui setup --status` and surfaced in `codewhale-tui doctor`.\n\n\
3093 When `[tools.plugin_dir]` is set in config.toml (or when the default\n\
3094 `~/.codewhale/tools/` directory exists), they are auto-discovered and\n\
3095 registered as model-visible tools.\n\n\
3096 Each script should start with a frontmatter-style header so the\n\
3097 description is visible without executing the file and the agent knows\n\
3098 the tool name, description, and input schema:\n\n\
3099 ```\n\
3100 # name: my-tool\n\
3101 # description: One-line summary of what this tool does\n\
3102 # usage: my-tool [args...]\n\
3103 ```\n\n\
3104 The directory is intentionally not auto-loaded into the agent's tool\n\
3105 catalog. Wire individual tools through MCP, hooks, or skills when you\n\
3106 want them available inside a session.\n"
3107}
3108
3109fn tools_example_script() -> &'static str {
3110 "#!/usr/bin/env sh\n\
3111 # name: example\n\
3112 # description: Print a confirmation that local tool discovery works\n\
3113 # usage: example [name]\n\
3114 printf 'codewhale-tui local tool ok: %s\\n' \"${1:-world}\"\n"
3115}
3116
3117fn init_tools_dir(tools_dir: &Path, force: bool) -> Result<(PathBuf, WriteStatus, WriteStatus)> {
3118 std::fs::create_dir_all(tools_dir)
3119 .with_context(|| format!("Failed to create tools dir {}", tools_dir.display()))?;
3120
3121 let readme_path = tools_dir.join("README.md");
3122 let readme_status = write_template_file(&readme_path, tools_readme_template(), force)?;
3123
3124 let example_path = tools_dir.join("example.sh");
3125 let example_status = write_template_file(&example_path, tools_example_script(), force)?;
3126
3127 Ok((tools_dir.to_path_buf(), readme_status, example_status))
3128}
3129
3130fn plugins_readme_template() -> &'static str {
3131 "# Local plugins\n\n\
3132 Each Codewhale plugin bundle lives in its own subdirectory with a\n\
3133 versioned `plugin.toml`. User bundles live here; workspace bundles live\n\
3134 under `<workspace>/.codewhale/plugins/`. Both are discovered read-only,\n\
3135 untrusted, and disabled by default.\n\n\
3136 A v0.9.1 bundle layout looks like:\n\n\
3137 ```\n\
3138 plugins/\n\
3139 my-plugin/\n\
3140 plugin.toml\n\
3141 skills/\n\
3142 my-skill/SKILL.md\n\
3143 ```\n\n\
3144 Run `/plugin validate`, `/plugin show <name>`, then `/plugin enable <name>`.\n\
3145 Enablement opens a content- and capability-bound trust review;\n\
3146 confirm the displayed `/plugin trust` command to create an owner-only,\n\
3147 content-addressed runtime snapshot, then enable the bundle. Remote MCP\n\
3148 authentication must name environment sources; never store secret values\n\
3149 in `plugin.toml`.\n\n\
3150 v0.9.1 activates only declarative Skills and MCP servers through their\n\
3151 existing engines. Commands, agents, hooks, LSP, native extensions,\n\
3152 filesystem grants, and lifecycle mutation are inventoried but inactive.\n\
3153 There is no marketplace, install, update, ambient compatibility scan, or\n\
3154 automatic trust surface in this release.\n"
3155}
3156
3157fn plugin_example_manifest_template() -> &'static str {
3158 "schema_version = 1\n\n\
3159 [plugin]\n\
3160 name = \"example\"\n\
3161 version = \"0.1.0\"\n\
3162 description = \"Starter Codewhale plugin bundle\"\n\n\
3163 [skills]\n\
3164 path = \"skills\"\n"
3165}
3166
3167fn plugin_example_skill_template() -> &'static str {
3168 "---\n\
3169 name: hello\n\
3170 description: Explain that the example plugin bundle is active.\n\
3171 ---\n\n\
3172 Tell the user this instruction came from the namespaced\n\
3173 `example:hello` plugin skill. Do not perform side effects.\n"
3174}
3175
3176fn init_plugins_dir(
3177 plugins_dir: &Path,
3178 force: bool,
3179) -> Result<(
3180 PathBuf,
3181 PathBuf,
3182 PathBuf,
3183 WriteStatus,
3184 WriteStatus,
3185 WriteStatus,
3186)> {
3187 std::fs::create_dir_all(plugins_dir)
3188 .with_context(|| format!("Failed to create plugins dir {}", plugins_dir.display()))?;
3189
3190 let readme_path = plugins_dir.join("README.md");
3191 let readme_status = write_template_file(&readme_path, plugins_readme_template(), force)?;
3192
3193 let manifest_path = plugins_dir.join("example").join("plugin.toml");
3194 ensure_parent_dir(&manifest_path)?;
3195 let manifest_status =
3196 write_template_file(&manifest_path, plugin_example_manifest_template(), force)?;
3197
3198 let skill_path = plugins_dir
3199 .join("example")
3200 .join("skills")
3201 .join("hello")
3202 .join("SKILL.md");
3203 ensure_parent_dir(&skill_path)?;
3204 let skill_status = write_template_file(&skill_path, plugin_example_skill_template(), force)?;
3205
3206 Ok((
3207 readme_path,
3208 manifest_path,
3209 skill_path,
3210 readme_status,
3211 manifest_status,
3212 skill_status,
3213 ))
3214}
3215
3216fn resolve_cors_origins(config: &Config, flag_origins: &[String]) -> Vec<String> {
3229 let mut out: Vec<String> = Vec::new();
3230 let mut push = |raw: &str| {
3231 let trimmed = raw.trim();
3232 if trimmed.is_empty() {
3233 return;
3234 }
3235 if !out.iter().any(|existing| existing == trimmed) {
3236 out.push(trimmed.to_string());
3237 }
3238 };
3239 for o in flag_origins {
3240 push(o);
3241 }
3242 if let Ok(env_value) =
3243 std::env::var("CODEWHALE_CORS_ORIGINS").or_else(|_| std::env::var("DEEPSEEK_CORS_ORIGINS"))
3244 {
3245 for piece in env_value.split(',') {
3246 push(piece);
3247 }
3248 }
3249 if let Some(rt) = &config.runtime_api
3250 && let Some(list) = &rt.cors_origins
3251 {
3252 for o in list {
3253 push(o);
3254 }
3255 }
3256 out
3257}
3258
3259fn deepseek_home_dir() -> PathBuf {
3260 codewhale_config::codewhale_home().unwrap_or_else(|_| {
3261 crate::config::effective_home_dir()
3262 .map_or_else(|| PathBuf::from(".codewhale"), |h| h.join(".codewhale"))
3263 })
3264}
3265
3266fn default_tools_dir() -> PathBuf {
3268 deepseek_home_dir().join("tools")
3269}
3270
3271fn default_plugins_dir() -> PathBuf {
3273 deepseek_home_dir().join("plugins")
3274}
3275
3276fn default_checkpoints_dir() -> PathBuf {
3278 deepseek_home_dir().join("sessions").join("checkpoints")
3279}
3280
3281#[derive(Debug, Clone, PartialEq, Eq)]
3282struct CleanPlan {
3283 targets: Vec<PathBuf>,
3284}
3285
3286fn collect_clean_targets(checkpoints_dir: &Path) -> CleanPlan {
3287 let mut targets: Vec<PathBuf> = std::fs::read_dir(checkpoints_dir)
3293 .map(|entries| {
3294 entries
3295 .filter_map(|entry| entry.ok().map(|e| e.path()))
3296 .filter(|p| p.is_file() && p.extension().is_some_and(|ext| ext == "json"))
3297 .collect()
3298 })
3299 .unwrap_or_default();
3300 targets.sort();
3301 CleanPlan { targets }
3302}
3303
3304fn execute_clean_plan(plan: &CleanPlan) -> Result<Vec<PathBuf>> {
3305 let mut removed = Vec::with_capacity(plan.targets.len());
3306 for path in &plan.targets {
3307 std::fs::remove_file(path)
3308 .with_context(|| format!("Failed to remove {}", path.display()))?;
3309 removed.push(path.clone());
3310 }
3311 Ok(removed)
3312}
3313
3314fn run_setup(
3315 config: &Config,
3316 workspace: &Path,
3317 args: SetupArgs,
3318 plugins: &crate::plugins::PluginRegistry,
3319) -> Result<()> {
3320 if args.status {
3321 return run_setup_status(config, workspace, plugins);
3322 }
3323 if args.clean {
3324 return run_setup_clean(&default_checkpoints_dir(), args.force);
3325 }
3326
3327 use crate::palette;
3328 use colored::Colorize;
3329
3330 let (aqua_r, aqua_g, aqua_b) = palette::WHALE_INFO_RGB;
3331 let (sky_r, sky_g, sky_b) = palette::WHALE_INFO_RGB;
3332
3333 let any_explicit = args.mcp || args.skills || args.tools || args.plugins;
3334 let run_mcp = args.mcp || args.all || !any_explicit;
3335 let run_skills = args.skills || args.all || !any_explicit;
3336 let run_tools = args.tools || args.all;
3337 let run_plugins = args.plugins || args.all;
3338
3339 println!(
3340 "{}",
3341 "Codewhale Setup".truecolor(aqua_r, aqua_g, aqua_b).bold()
3342 );
3343 println!("{}", "==============".truecolor(sky_r, sky_g, sky_b));
3344 println!("Workspace: {}", crate::utils::display_path(workspace));
3345
3346 if run_mcp {
3347 let mcp_path = config.mcp_config_path();
3348 let status = init_mcp_config(&mcp_path, args.force)?;
3349 match status {
3350 WriteStatus::Created => {
3351 println!(" ✓ Created MCP config at {}", mcp_path.display());
3352 }
3353 WriteStatus::Overwritten => {
3354 println!(" ✓ Overwrote MCP config at {}", mcp_path.display());
3355 }
3356 WriteStatus::SkippedExists => {
3357 println!(" · MCP config already exists at {}", mcp_path.display());
3358 }
3359 }
3360 println!(
3361 " Next: edit the file, then run `codewhale mcp list` or `codewhale mcp tools`."
3362 );
3363 }
3364
3365 if run_skills {
3366 let skills_dir = if args.local {
3367 workspace.join("skills")
3368 } else {
3369 config.skills_dir()
3370 };
3371 let (skill_path, status) = init_skills_dir(&skills_dir, args.force)?;
3372 match status {
3373 WriteStatus::Created => {
3374 println!(" ✓ Created example skill at {}", skill_path.display());
3375 }
3376 WriteStatus::Overwritten => {
3377 println!(" ✓ Overwrote example skill at {}", skill_path.display());
3378 }
3379 WriteStatus::SkippedExists => {
3380 println!(
3381 " · Example skill already exists at {}",
3382 skill_path.display()
3383 );
3384 }
3385 }
3386 if args.local {
3387 println!(
3388 " Local skills dir enabled for this workspace: {}",
3389 crate::utils::display_path(&skills_dir)
3390 );
3391 } else {
3392 println!(
3393 " Skills dir: {}",
3394 crate::utils::display_path(&skills_dir)
3395 );
3396 }
3397 println!(" Next: run the TUI and use `/skills` then `/skill getting-started`.");
3398 }
3399
3400 if run_tools {
3401 let tools_dir = default_tools_dir();
3402 let (dir, readme_status, example_status) = init_tools_dir(&tools_dir, args.force)?;
3403 report_write_status("Tools README", &dir.join("README.md"), readme_status);
3404 report_write_status("Example tool", &dir.join("example.sh"), example_status);
3405 println!(" Tools dir: {}", crate::utils::display_path(&dir));
3406 println!(" Next: drop scripts here; surface them via skills/MCP when ready.");
3407 }
3408
3409 if run_plugins {
3410 let plugins_dir = default_plugins_dir();
3411 let (readme_path, manifest_path, skill_path, readme_status, manifest_status, skill_status) =
3412 init_plugins_dir(&plugins_dir, args.force)?;
3413 report_write_status("Plugins README", &readme_path, readme_status);
3414 report_write_status("Example plugin manifest", &manifest_path, manifest_status);
3415 report_write_status("Example plugin skill", &skill_path, skill_status);
3416 println!(
3417 " Plugins dir: {}",
3418 crate::utils::display_path(&plugins_dir)
3419 );
3420 println!(" Next: run `/plugin validate`, review `example`, then trust and enable it.");
3421 }
3422
3423 let sandbox = crate::sandbox::get_platform_sandbox_with_bwrap_preference(
3424 config.prefer_bwrap.unwrap_or(false),
3425 );
3426 if let Some(kind) = sandbox {
3427 println!(" ✓ Sandbox available: {kind}");
3428 } else {
3429 println!(" · Sandbox not available on this platform (best-effort only).");
3430 }
3431
3432 Ok(())
3433}
3434
3435fn report_write_status(label: &str, path: &Path, status: WriteStatus) {
3436 match status {
3437 WriteStatus::Created => {
3438 println!(" ✓ Created {label} at {}", path.display());
3439 }
3440 WriteStatus::Overwritten => {
3441 println!(" ✓ Overwrote {label} at {}", path.display());
3442 }
3443 WriteStatus::SkippedExists => {
3444 println!(" · {label} already exists at {}", path.display());
3445 }
3446 }
3447}
3448
3449#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3454enum ApiKeySource {
3455 ConfigDeclared,
3456 EnvDeclared,
3457 ExternalAuthDeclared,
3458 SecretStoreUnprobed,
3459 SecretStoreUnavailable,
3460 OAuth,
3461 ExternalConsent,
3462 NoAuth,
3463 LocalRuntime,
3464 Unknown,
3465}
3466
3467#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3470enum CredentialAvailability {
3471 Present,
3472 NotRequired,
3473 Unknown,
3474 NotProbed,
3475 Unavailable,
3476}
3477
3478impl CredentialAvailability {
3479 fn label(self) -> &'static str {
3480 match self {
3481 Self::Present => "present",
3482 Self::NotRequired => "not_required",
3483 Self::Unknown => "unknown",
3484 Self::NotProbed => "not_probed",
3485 Self::Unavailable => "unavailable",
3486 }
3487 }
3488
3489 fn certifies_ready(self) -> bool {
3490 matches!(self, Self::Present | Self::NotRequired)
3491 }
3492}
3493
3494#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3495struct CredentialDiagnostic {
3496 source: ApiKeySource,
3497 availability: CredentialAvailability,
3498}
3499
3500impl CredentialDiagnostic {
3501 const fn new(source: ApiKeySource, availability: CredentialAvailability) -> Self {
3502 Self {
3503 source,
3504 availability,
3505 }
3506 }
3507}
3508
3509fn resolve_credential_diagnostic(config: &Config) -> CredentialDiagnostic {
3510 let provider = config.api_provider();
3511 let auth_mode = config.auth_mode_for_provider(provider);
3512 if crate::config::auth_mode_disables_api_key(auth_mode.as_deref()) {
3513 return CredentialDiagnostic::new(
3514 ApiKeySource::NoAuth,
3515 CredentialAvailability::NotRequired,
3516 );
3517 }
3518 if !crate::config::auth_mode_requires_api_key(auth_mode.as_deref())
3519 && (provider.is_self_hosted()
3520 || crate::config::base_url_uses_local_host(&config.deepseek_base_url()))
3521 {
3522 return CredentialDiagnostic::new(
3523 ApiKeySource::LocalRuntime,
3524 CredentialAvailability::NotRequired,
3525 );
3526 }
3527 let custom_endpoint = config.provider_uses_custom_endpoint(provider);
3528 if !custom_endpoint && provider == crate::config::ApiProvider::OpenaiCodex {
3529 return config
3530 .external_credential_consent_status(provider)
3531 .filter(|status| status.route_state == "active")
3532 .map_or_else(
3533 || {
3534 CredentialDiagnostic::new(
3535 ApiKeySource::OAuth,
3536 CredentialAvailability::NotProbed,
3537 )
3538 },
3539 |_| {
3540 CredentialDiagnostic::new(
3541 ApiKeySource::ExternalConsent,
3542 CredentialAvailability::NotProbed,
3543 )
3544 },
3545 );
3546 }
3547 if !custom_endpoint
3548 && provider == crate::config::ApiProvider::Xai
3549 && auth_mode
3550 .as_deref()
3551 .is_some_and(crate::xai_oauth::auth_mode_uses_xai_oauth)
3552 {
3553 return config
3554 .external_credential_consent_status(provider)
3555 .filter(|status| status.route_state == "active")
3556 .map_or_else(
3557 || {
3558 CredentialDiagnostic::new(
3559 ApiKeySource::OAuth,
3560 CredentialAvailability::NotProbed,
3561 )
3562 },
3563 |_| {
3564 CredentialDiagnostic::new(
3565 ApiKeySource::ExternalConsent,
3566 CredentialAvailability::NotProbed,
3567 )
3568 },
3569 );
3570 }
3571 let provider_config = config.provider_config();
3572 let provider_config_key_kind = provider_config
3573 .and_then(|entry| entry.api_key.as_deref())
3574 .map(crate::config::classify_config_api_key_value);
3575 let root_key_applies = matches!(
3576 provider,
3577 crate::config::ApiProvider::Deepseek | crate::config::ApiProvider::DeepseekCN
3578 ) || (provider == crate::config::ApiProvider::Custom
3579 && config.uses_legacy_literal_custom_route());
3580 let root_key_kind = root_key_applies
3581 .then_some(config.api_key.as_deref())
3582 .flatten()
3583 .map(crate::config::classify_config_api_key_value);
3584
3585 if matches!(
3586 provider_config_key_kind,
3587 Some(crate::config::ConfigApiKeyValueKind::Literal)
3588 ) || matches!(
3589 root_key_kind,
3590 Some(crate::config::ConfigApiKeyValueKind::Literal)
3591 ) {
3592 CredentialDiagnostic::new(
3593 ApiKeySource::ConfigDeclared,
3594 CredentialAvailability::Present,
3595 )
3596 } else if config
3597 .provider_config()
3598 .and_then(|entry| entry.api_key_env.as_deref())
3599 .is_some_and(|name| !name.trim().is_empty())
3600 {
3601 CredentialDiagnostic::new(ApiKeySource::EnvDeclared, CredentialAvailability::NotProbed)
3602 } else if config
3603 .provider_config()
3604 .and_then(|entry| entry.auth.as_ref())
3605 .is_some()
3606 {
3607 CredentialDiagnostic::new(
3608 ApiKeySource::ExternalAuthDeclared,
3609 CredentialAvailability::NotProbed,
3610 )
3611 } else if matches!(
3612 provider_config_key_kind,
3613 Some(crate::config::ConfigApiKeyValueKind::SecretStoreSentinel)
3614 ) || matches!(
3615 root_key_kind,
3616 Some(crate::config::ConfigApiKeyValueKind::SecretStoreSentinel)
3617 ) {
3618 if config.should_skip_secret_store_for_provider(provider) {
3619 return CredentialDiagnostic::new(
3620 ApiKeySource::SecretStoreUnavailable,
3621 CredentialAvailability::Unavailable,
3622 );
3623 }
3624 CredentialDiagnostic::new(
3627 ApiKeySource::SecretStoreUnprobed,
3628 CredentialAvailability::NotProbed,
3629 )
3630 } else if !config.should_skip_secret_store_for_provider(provider) {
3631 CredentialDiagnostic::new(
3635 ApiKeySource::SecretStoreUnprobed,
3636 CredentialAvailability::NotProbed,
3637 )
3638 } else {
3639 CredentialDiagnostic::new(ApiKeySource::Unknown, CredentialAvailability::Unknown)
3640 }
3641}
3642
3643#[cfg(test)]
3644fn resolve_api_key_source(config: &Config) -> ApiKeySource {
3645 resolve_credential_diagnostic(config).source
3646}
3647
3648fn provider_config_table_key(provider: crate::config::ApiProvider) -> &'static str {
3649 provider
3650 .metadata()
3651 .map(|metadata| metadata.provider_config_key())
3652 .unwrap_or("deepseek_cn")
3653}
3654
3655fn count_dir_entries(dir: &Path) -> usize {
3656 std::fs::read_dir(dir)
3657 .map(|entries| entries.filter_map(std::result::Result::ok).count())
3658 .unwrap_or(0)
3659}
3660
3661fn skills_count_for(dir: &Path) -> usize {
3662 if !dir.exists() {
3663 return 0;
3664 }
3665 crate::skills::SkillRegistry::discover(dir).len()
3666}
3667
3668fn run_setup_status(
3669 config: &Config,
3670 workspace: &Path,
3671 plugins: &crate::plugins::PluginRegistry,
3672) -> Result<()> {
3673 use crate::palette;
3674 use colored::Colorize;
3675
3676 let (aqua_r, aqua_g, aqua_b) = palette::WHALE_INFO_RGB;
3677 let (sky_r, sky_g, sky_b) = palette::WHALE_INFO_RGB;
3678
3679 println!(
3680 "{}",
3681 "Codewhale Status".truecolor(aqua_r, aqua_g, aqua_b).bold()
3682 );
3683 println!("{}", "===============".truecolor(sky_r, sky_g, sky_b));
3684 println!("workspace: {}", workspace.display());
3685
3686 let credential = resolve_credential_diagnostic(config);
3687 match credential.source {
3688 ApiKeySource::ConfigDeclared => println!(
3689 " {} api_key: literal config value structurally present",
3690 "✓".truecolor(aqua_r, aqua_g, aqua_b)
3691 ),
3692 ApiKeySource::EnvDeclared => println!(
3693 " {} api_key: environment source declared (value not inspected)",
3694 "·".dimmed()
3695 ),
3696 ApiKeySource::ExternalAuthDeclared => println!(
3697 " {} api_key: external auth source declared (value not inspected)",
3698 "·".dimmed()
3699 ),
3700 ApiKeySource::SecretStoreUnprobed => println!(
3701 " {} api_key: secret store eligible (store not probed)",
3702 "·".dimmed()
3703 ),
3704 ApiKeySource::SecretStoreUnavailable => println!(
3705 " {} api_key: secret-store sentinel declared, but this route cannot use that store",
3706 "!".truecolor(sky_r, sky_g, sky_b)
3707 ),
3708 ApiKeySource::OAuth => println!(
3709 " {} oauth: Codewhale-owned route selected (token availability not probed)",
3710 "·".dimmed()
3711 ),
3712 ApiKeySource::ExternalConsent => println!(
3713 " {} oauth: external read-only consent configured (credential file not probed)",
3714 "·".dimmed()
3715 ),
3716 ApiKeySource::NoAuth => println!(
3717 " {} api_key: disabled for this route",
3718 "✓".truecolor(aqua_r, aqua_g, aqua_b)
3719 ),
3720 ApiKeySource::LocalRuntime => println!(
3721 " {} api_key: not required for this local runtime",
3722 "✓".truecolor(aqua_r, aqua_g, aqua_b)
3723 ),
3724 ApiKeySource::Unknown => println!(
3725 " {} api_key: unknown (credential environment and durable stores not inspected)",
3726 "·".dimmed()
3727 ),
3728 }
3729 println!(
3730 " · credential availability: {}",
3731 credential.availability.label()
3732 );
3733 println!(
3734 " · base_url: {}",
3735 crate::doctor::structural_url_authority(&config.deepseek_base_url())
3736 );
3737 let model = config
3738 .default_text_model
3739 .clone()
3740 .unwrap_or_else(|| DEFAULT_TEXT_MODEL.to_string());
3741 println!(" · default_text_model: {model}");
3742 let (default_mode, default_mode_source) = doctor_runtime_default_mode();
3743 println!(" · default_mode: {default_mode} ({default_mode_source})");
3744
3745 let mcp_path = config.mcp_config_path();
3746 let project_mcp_path = crate::mcp::workspace_mcp_config_path(workspace);
3747 let mcp_count =
3748 match crate::mcp::load_config_with_workspace_and_plugins(&mcp_path, workspace, plugins) {
3749 Ok(cfg) => cfg.servers.len(),
3750 Err(_) => 0,
3751 };
3752 let mcp_present = if mcp_path.exists() { "" } else { " (missing)" };
3753 let project_mcp_present = if project_mcp_path.exists() {
3754 ""
3755 } else {
3756 " (missing)"
3757 };
3758 println!(
3759 " · mcp servers: {mcp_count} from {}{mcp_present} + {}{project_mcp_present}",
3760 mcp_path.display(),
3761 project_mcp_path.display()
3762 );
3763
3764 let skills_dir = config.skills_dir();
3765 println!(
3766 " · skills: {} at {}",
3767 skills_count_for(&skills_dir),
3768 crate::utils::display_path(&skills_dir)
3769 );
3770
3771 let tools_dir = default_tools_dir();
3772 let tools_present = if tools_dir.exists() {
3773 ""
3774 } else {
3775 " (missing — run `setup --tools`)"
3776 };
3777 println!(
3778 " · tools: {} entries at {}{tools_present}",
3779 if tools_dir.exists() {
3780 count_dir_entries(&tools_dir)
3781 } else {
3782 0
3783 },
3784 crate::utils::display_path(&tools_dir)
3785 );
3786
3787 let plugins_dir = default_plugins_dir();
3788 let plugins_present = if plugins_dir.exists() {
3789 ""
3790 } else {
3791 " (missing — run `setup --plugins`)"
3792 };
3793 println!(
3794 " · plugins: {} entries at {}{plugins_present}",
3795 if plugins_dir.exists() {
3796 count_dir_entries(&plugins_dir)
3797 } else {
3798 0
3799 },
3800 crate::utils::display_path(&plugins_dir)
3801 );
3802
3803 let sandbox = crate::sandbox::get_platform_sandbox_with_bwrap_preference(
3804 config.prefer_bwrap.unwrap_or(false),
3805 );
3806 match sandbox {
3807 Some(kind) => println!(
3808 " {} sandbox: {kind}",
3809 "✓".truecolor(aqua_r, aqua_g, aqua_b)
3810 ),
3811 None => println!(
3812 " {} sandbox: unavailable (commands run best-effort)",
3813 "!".truecolor(sky_r, sky_g, sky_b)
3814 ),
3815 }
3816
3817 println!(" {} {}", "·".dimmed(), dotenv_status_line(workspace));
3818
3819 println!();
3820 println!("Run `codewhale doctor --json` for a machine-readable check.");
3821 Ok(())
3822}
3823
3824fn dotenv_status_line(workspace: &Path) -> String {
3825 let dotenv = workspace.join(".env");
3826 if dotenv.exists() {
3827 return format!(
3828 ".env present at {} (literal provider credentials only)",
3829 dotenv.display()
3830 );
3831 }
3832
3833 if workspace.join(".env.example").exists() {
3834 return ".env not present in workspace (run `cp .env.example .env` and edit)".to_string();
3835 }
3836
3837 ".env not present in workspace".to_string()
3838}
3839
3840fn run_setup_clean(checkpoints_dir: &Path, force: bool) -> Result<()> {
3841 use colored::Colorize;
3842
3843 if !checkpoints_dir.exists() {
3844 println!(
3845 "Nothing to clean — checkpoints dir does not exist: {}",
3846 checkpoints_dir.display()
3847 );
3848 return Ok(());
3849 }
3850
3851 let plan = collect_clean_targets(checkpoints_dir);
3852 if plan.targets.is_empty() {
3853 println!(
3854 "Nothing to clean — no checkpoint files in {}",
3855 checkpoints_dir.display()
3856 );
3857 return Ok(());
3858 }
3859
3860 if !force {
3861 println!(
3862 "Would remove {} checkpoint file(s) (use --force to apply):",
3863 plan.targets.len()
3864 );
3865 for path in &plan.targets {
3866 println!(" · {}", path.display());
3867 }
3868 return Ok(());
3869 }
3870
3871 let removed = execute_clean_plan(&plan)?;
3872 println!("{}", "Cleaned checkpoints:".bold());
3873 for path in &removed {
3874 println!(" ✓ {}", path.display());
3875 }
3876 Ok(())
3877}
3878
3879fn run_session_diagnostics(args: SessionDiagnosticsArgs) -> Result<()> {
3880 let contents = std::fs::read_to_string(&args.path).with_context(|| {
3881 format!(
3882 "read session diagnostic JSONL from {}",
3883 crate::utils::display_path(&args.path)
3884 )
3885 })?;
3886 let summary = crate::session_diagnostics::analyze_session_failure_jsonl(&contents);
3887 if args.json {
3888 println!("{}", serde_json::to_string_pretty(&summary)?);
3889 } else {
3890 println!(
3891 "{}",
3892 crate::session_diagnostics::format_redacted_failure_summary(&summary)
3893 );
3894 }
3895 Ok(())
3896}
3897
3898fn doctor_should_probe_api(
3901 provider: crate::config::ApiProvider,
3902 base_url: &str,
3903 probes: crate::doctor::DoctorProbeRequest,
3904) -> bool {
3905 let local = provider.is_self_hosted() || crate::config::base_url_uses_local_host(base_url);
3906 probes.should_probe_api(local)
3907}
3908
3909fn doctor_should_probe_auth(config: &Config) -> bool {
3913 let provider = config.api_provider();
3914 if provider == crate::config::ApiProvider::OpenaiCodex
3915 && !config.provider_uses_custom_endpoint(provider)
3916 {
3917 return false;
3918 }
3919 let auth_mode = config.auth_mode_for_provider(provider);
3920 if provider == crate::config::ApiProvider::Xai
3921 && auth_mode
3922 .as_deref()
3923 .is_some_and(crate::xai_oauth::auth_mode_uses_xai_oauth)
3924 {
3925 return false;
3926 }
3927 !(provider == crate::config::ApiProvider::Moonshot
3928 && auth_mode
3929 .as_deref()
3930 .is_some_and(crate::config::auth_mode_uses_kimi_imported_token))
3931}
3932
3933async fn run_doctor(
3935 config: &Config,
3936 workspace: &Path,
3937 config_path_override: Option<&Path>,
3938 probes: crate::doctor::DoctorProbeRequest,
3939 plugins: &crate::plugins::PluginRegistry,
3940) {
3941 use crate::palette;
3942 use colored::Colorize;
3943
3944 let (accent_r, accent_g, accent_b) = palette::WHALE_HUMAN_RGB;
3945 let (sky_r, sky_g, sky_b) = palette::WHALE_INFO_RGB;
3946 let (aqua_r, aqua_g, aqua_b) = palette::WHALE_INFO_RGB;
3947 let (red_r, red_g, red_b) = palette::WHALE_ERROR_RGB;
3948
3949 println!(
3950 "{}",
3951 "codewhale Doctor"
3952 .truecolor(accent_r, accent_g, accent_b)
3953 .bold()
3954 );
3955 println!("{}", "==================".truecolor(sky_r, sky_g, sky_b));
3956 println!();
3957
3958 println!("{}", "Version Information:".bold());
3960 println!(" codewhale-tui: {}", env!("DEEPSEEK_BUILD_VERSION"));
3961 println!(" rust: {}", rustc_version());
3962 println!();
3963
3964 println!("{}", "Updates:".bold());
3965 crate::doctor::print_update_report(probes).await;
3966 println!();
3967
3968 let doctor_paths = match crate::doctor::DoctorPathReport::resolve(config_path_override) {
3970 Ok(paths) => paths,
3971 Err(error) => {
3972 println!("{}", "Resolved User Paths:".bold());
3973 println!(
3974 " {} unavailable: {error:#}",
3975 "✗".truecolor(red_r, red_g, red_b)
3976 );
3977 return;
3978 }
3979 };
3980 println!("{}", "Configuration:".bold());
3981 let config_path = &doctor_paths.config;
3982
3983 if config_path.exists() {
3984 println!(
3985 " {} config.toml found at {}",
3986 "✓".truecolor(aqua_r, aqua_g, aqua_b),
3987 crate::utils::display_path(config_path)
3988 );
3989 if let Ok(raw) = std::fs::read_to_string(config_path) {
3992 let flagged = crate::doctor::config_credential_shaped_keys(&raw);
3993 if !flagged.is_empty() {
3994 println!(
3995 " {} credential-shaped value(s) in config.toml ({}): move them to the secret backend, then scrub the file — config.toml is plain text",
3996 "!".truecolor(sky_r, sky_g, sky_b),
3997 flagged.join(", ")
3998 );
3999 }
4000 }
4001 } else {
4002 println!(
4003 " {} config.toml not found at {} (using defaults/env)",
4004 "!".truecolor(sky_r, sky_g, sky_b),
4005 crate::utils::display_path(config_path)
4006 );
4007 }
4008 println!(" workspace: {}", crate::utils::display_path(workspace));
4009 println!(" {}", doctor_search_provider_line(config));
4010
4011 println!();
4012 println!("{}", "Resolved User Paths (read-only):".bold());
4013 for (label, path) in doctor_paths.entries() {
4014 println!(" · {label}: {}", crate::utils::display_path(path));
4015 }
4016
4017 let secret_backend = codewhale_secrets::diagnose_secret_backend();
4018 println!();
4019 println!("{}", "Secret Backend (structural only):".bold());
4020 for line in crate::doctor::secret_backend_human_lines(&secret_backend) {
4021 println!(" · {line}");
4022 }
4023
4024 println!();
4026 println!("{}", "State Root:".bold());
4027 let (code_home, legacy_home) = doctor_state_roots();
4028 let active_root = if code_home.exists() {
4029 &code_home
4030 } else if legacy_home.exists() {
4031 &legacy_home
4032 } else {
4033 &code_home
4034 };
4035 println!(" active: {}", crate::utils::display_path(active_root));
4036 if active_root != &code_home {
4037 println!(
4038 " note: legacy {} found; start Codewhale once to trigger safe migration where available.",
4039 crate::utils::display_path(&legacy_home)
4040 );
4041 }
4042 if legacy_home.exists() && code_home.exists() {
4043 println!(
4044 " dual roots: {} (primary) + {} (legacy)",
4045 crate::utils::display_path(&code_home),
4046 crate::utils::display_path(&legacy_home)
4047 );
4048 }
4049 let legacy_state_report = doctor_legacy_state_report(&code_home, &legacy_home);
4050 let session_recovery = doctor_session_recovery_report(
4051 &code_home,
4052 &legacy_home,
4053 codewhale_config::codewhale_home_is_explicit(),
4054 );
4055 print_doctor_legacy_state_report(
4056 &legacy_state_report,
4057 &session_recovery,
4058 (aqua_r, aqua_g, aqua_b),
4059 (sky_r, sky_g, sky_b),
4060 );
4061
4062 let (setup_state, setup_source) = doctor_setup_state(config, workspace);
4063 print_doctor_setup_report(
4064 config,
4065 workspace,
4066 &setup_state,
4067 setup_source,
4068 (aqua_r, aqua_g, aqua_b),
4069 (sky_r, sky_g, sky_b),
4070 );
4071
4072 println!();
4074 println!("{}", "API Keys:".bold());
4075
4076 for provider in crate::config::ApiProvider::all().iter().copied() {
4080 let slot = provider.as_str();
4081 let provider_config = config.provider_config_for(provider);
4082 let config_declared = provider_config.is_some_and(|entry| {
4083 entry.api_key.as_deref().is_some_and(|key| {
4084 crate::config::classify_config_api_key_value(key)
4085 == crate::config::ConfigApiKeyValueKind::Literal
4086 })
4087 }) || (matches!(provider, crate::config::ApiProvider::Deepseek)
4088 && config.api_key.as_deref().is_some_and(|key| {
4089 crate::config::classify_config_api_key_value(key)
4090 == crate::config::ConfigApiKeyValueKind::Literal
4091 }));
4092 let env_source_declared = provider_config
4093 .and_then(|entry| entry.api_key_env.as_deref())
4094 .is_some_and(|name| !name.trim().is_empty());
4095 let icon = if config_declared || env_source_declared {
4096 "·".truecolor(aqua_r, aqua_g, aqua_b)
4097 } else {
4098 "·".dimmed()
4099 };
4100 println!(
4101 " {} {slot}: env_source={}, config_source={}",
4102 icon,
4103 if env_source_declared {
4104 "declared (value not inspected)"
4105 } else {
4106 "not inspected"
4107 },
4108 if config_declared {
4109 "declared (value not inspected)"
4110 } else {
4111 "not declared"
4112 }
4113 );
4114 }
4115 println!(" · credential precedence is unchanged; doctor does not inspect credential values");
4116 println!();
4117 println!(
4118 "{}",
4119 "External credential consent (configuration only):".bold()
4120 );
4121 for line in doctor_external_credential_consent_lines(config) {
4122 println!(" {line}");
4123 }
4124
4125 let credential = resolve_credential_diagnostic(config);
4126 let source_label = match credential.source {
4127 ApiKeySource::ConfigDeclared => "literal config value structurally present",
4128 ApiKeySource::EnvDeclared => "environment source declared; value not inspected",
4129 ApiKeySource::ExternalAuthDeclared => {
4130 "external auth source declared; credential not resolved"
4131 }
4132 ApiKeySource::SecretStoreUnprobed => "secret store eligible; store not probed",
4133 ApiKeySource::SecretStoreUnavailable => {
4134 "secret-store sentinel declared, but this route cannot use that store"
4135 }
4136 ApiKeySource::OAuth => "OAuth route configured; token availability not probed",
4137 ApiKeySource::ExternalConsent => "external consent configured; token file not read",
4138 ApiKeySource::NoAuth => "no-auth route",
4139 ApiKeySource::LocalRuntime => "local runtime; credentials not required",
4140 ApiKeySource::Unknown => "unknown; credential environment and stores not inspected",
4141 };
4142 println!(
4143 " {} active provider credential source: {source_label}",
4144 "·".dimmed()
4145 );
4146 println!(
4147 " · active provider credential availability: {}",
4148 credential.availability.label()
4149 );
4150
4151 println!();
4153 println!("{}", "API Connectivity:".bold());
4154 let api_target = doctor_api_target(config);
4155 println!(
4159 " · 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)"
4160 );
4161 println!(" · provider: {}", api_target.provider);
4162 println!(
4163 " · base_url: {}",
4164 crate::doctor::structural_url_authority(&api_target.base_url)
4165 );
4166 match api_target.resolution {
4167 DoctorModelResolution::Resolved => {
4168 println!(" · model: {} (resolved)", api_target.model);
4169 }
4170 DoctorModelResolution::ConfiguredOnly => {
4171 println!(
4172 " · model: {} (configured; route resolution unavailable)",
4173 api_target.model
4174 );
4175 }
4176 }
4177 let tls_status = doctor_tls_status(config);
4178 if !tls_status.certificate_verification {
4179 println!(" ! {}", tls_status.message);
4180 println!(" Prefer SSL_CERT_FILE with a trusted custom CA bundle when possible.");
4181 }
4182 let strict_tool_mode = doctor_strict_tool_mode_status(config);
4183 let strict_icon = match strict_tool_mode.status {
4184 "ready" => "✓".truecolor(aqua_r, aqua_g, aqua_b),
4185 "fallback_non_beta" | "custom_endpoint" => "!".truecolor(sky_r, sky_g, sky_b),
4186 _ => "·".dimmed(),
4187 };
4188 println!(
4189 " {} strict_tool_mode: {}",
4190 strict_icon, strict_tool_mode.message
4191 );
4192 if let Some(recommended) = strict_tool_mode.recommended_base_url.as_deref() {
4193 println!(
4194 " Use the {} endpoint for DeepSeek strict schemas.",
4195 crate::doctor::structural_url_authority(recommended)
4196 );
4197 }
4198 let capability = crate::config::provider_capability(config.api_provider(), &api_target.model);
4199 if let Some(alias) = capability.alias_deprecation.as_ref() {
4200 println!(
4201 " ! model alias {} retires {}; switch to {}",
4202 alias.alias, alias.retirement_date, alias.replacement
4203 );
4204 }
4205 let live_api_requested =
4206 doctor_should_probe_api(config.api_provider(), &api_target.base_url, probes);
4207 let endpoint_is_local = config.api_provider().is_self_hosted()
4208 || crate::config::base_url_uses_local_host(&api_target.base_url);
4209 if doctor_should_probe_auth(config) && live_api_requested {
4210 print!(" {} Testing connection...", "·".dimmed());
4211 use std::io::Write;
4212 std::io::stdout().flush().ok();
4213
4214 let connectivity_result = match config.with_read_only_api_key_for_diagnostic() {
4219 Ok(diagnostic_config) => test_api_connectivity(&diagnostic_config).await,
4220 Err(error) => Err(error),
4221 };
4222 match connectivity_result {
4223 Ok(()) => {
4224 println!(
4225 "\r {} API connection successful",
4226 "✓".truecolor(aqua_r, aqua_g, aqua_b)
4227 );
4228 }
4229 Err(e) => {
4230 let error_msg = e.to_string();
4231 println!(
4232 "\r {} API connection failed",
4233 "✗".truecolor(red_r, red_g, red_b)
4234 );
4235 if error_msg.contains("401") || error_msg.contains("Unauthorized") {
4236 println!(
4237 " Invalid API key. Check `codewhale auth status`, DEEPSEEK_API_KEY, or config.toml"
4238 );
4239 } else if error_msg.contains("403") || error_msg.contains("Forbidden") {
4240 println!(
4241 " API key lacks permissions. Verify key is active at platform.deepseek.com"
4242 );
4243 } else if error_msg.contains("timeout") || error_msg.contains("Timeout") {
4244 for line in doctor_timeout_recovery_lines(config) {
4245 println!(" {line}");
4246 }
4247 } else if error_msg.contains("dns") || error_msg.contains("resolve") {
4248 println!(" DNS resolution failed. Check your network connection");
4249 } else if error_msg.contains("connect") {
4250 println!(" Connection failed. Check firewall settings or try again");
4251 } else {
4252 println!(
4253 " Error details omitted because provider failures can contain credential material."
4254 );
4255 }
4256 }
4257 }
4258 } else if !doctor_should_probe_auth(config) {
4259 println!(
4260 " {} Live OAuth connectivity not checked by non-mutating doctor",
4261 "·".dimmed()
4262 );
4263 println!(
4264 " Doctor never refreshes or rewrites credentials; exercise the route with a normal request."
4265 );
4266 } else {
4267 if endpoint_is_local {
4268 println!(
4269 " {} Live connectivity not checked for this local endpoint",
4270 "·".dimmed()
4271 );
4272 println!(
4273 " Run `codewhale doctor --probe-local` to opt in; the request may start a local service."
4274 );
4275 } else {
4276 println!(
4277 " {} Live hosted connectivity not checked (offline default)",
4278 "·".dimmed()
4279 );
4280 println!(" Run `codewhale doctor --probe-api` to opt in.");
4281 }
4282 }
4283
4284 println!();
4286 println!("{}", "MCP Servers (configuration only):".bold());
4287 println!(" · Static check only; no server process was started.");
4288 let features = config.features();
4289 if features.enabled(Feature::Mcp) {
4290 println!(
4291 " {} MCP feature flag enabled",
4292 "✓".truecolor(aqua_r, aqua_g, aqua_b)
4293 );
4294 } else {
4295 println!(
4296 " {} MCP feature flag disabled",
4297 "!".truecolor(sky_r, sky_g, sky_b)
4298 );
4299 }
4300
4301 let mcp_config_path = config.mcp_config_path();
4302 let project_mcp_config_path = crate::mcp::workspace_mcp_config_path(workspace);
4303 if mcp_config_path.exists() {
4304 println!(
4305 " {} MCP config found at {}",
4306 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4307 crate::utils::display_path(&mcp_config_path)
4308 );
4309 } else {
4310 println!(
4311 " {} MCP config not found at {}",
4312 "·".dimmed(),
4313 crate::utils::display_path(&mcp_config_path)
4314 );
4315 }
4316 if project_mcp_config_path.exists() {
4317 println!(
4318 " {} Project MCP config found at {}",
4319 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4320 crate::utils::display_path(&project_mcp_config_path)
4321 );
4322 } else {
4323 println!(
4324 " {} Project MCP config not found at {}",
4325 "·".dimmed(),
4326 crate::utils::display_path(&project_mcp_config_path)
4327 );
4328 }
4329
4330 match crate::mcp::load_config_with_workspace_and_plugins(&mcp_config_path, workspace, plugins) {
4331 Ok(cfg) if cfg.servers.is_empty() => {
4332 println!(" {} 0 merged server(s) configured", "·".dimmed());
4333 if !mcp_config_path.exists() && !project_mcp_config_path.exists() {
4334 println!(" Run `codewhale mcp init` or add `.codewhale/mcp.json`.");
4335 }
4336 }
4337 Ok(cfg) => {
4338 println!(
4339 " {} {} merged server(s) configured",
4340 "·".dimmed(),
4341 cfg.servers.len()
4342 );
4343 for (name, server) in &cfg.servers {
4344 let status = doctor_check_mcp_server(server);
4345 let icon = match &status {
4346 McpServerDoctorStatus::Ok(detail) => {
4347 format!(
4348 " {} {name}: configuration valid; {}",
4349 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4350 detail
4351 )
4352 }
4353 McpServerDoctorStatus::Warning(detail) => {
4354 format!(
4355 " {} {name}: configuration warning; {}",
4356 "!".truecolor(sky_r, sky_g, sky_b),
4357 detail
4358 )
4359 }
4360 McpServerDoctorStatus::Error(detail) => {
4361 format!(
4362 " {} {name}: configuration invalid; {}",
4363 "✗".truecolor(red_r, red_g, red_b),
4364 detail
4365 )
4366 }
4367 };
4368 println!("{icon}");
4369 if !server.is_enabled() {
4370 println!(" disabled; live health not checked");
4371 } else {
4372 println!(
4373 " process/protocol/backend: not checked; `codewhale mcp validate` explicitly starts and initializes configured servers"
4374 );
4375 }
4376 }
4377 if probes.should_probe_mcp() {
4378 println!();
4379 println!(
4380 " {} Live MCP probe enabled: starting enabled servers; backend tool health remains untested.",
4381 "!".truecolor(sky_r, sky_g, sky_b)
4382 );
4383 match crate::mcp::McpPool::from_config_path_with_workspace_and_plugins(
4384 &mcp_config_path,
4385 workspace,
4386 std::sync::Arc::new(plugins.clone()),
4387 ) {
4388 Ok(mut pool) => {
4389 let errors = pool.connect_all().await;
4390 let failed = errors
4391 .iter()
4392 .map(|(name, _)| name.as_str())
4393 .collect::<std::collections::BTreeSet<_>>();
4394 for (name, server) in &cfg.servers {
4395 if !server.is_enabled() {
4396 continue;
4397 }
4398 if failed.contains(name.as_str()) {
4399 println!(
4400 " {} {name}: process/protocol unreachable; error details omitted",
4401 "✗".truecolor(red_r, red_g, red_b)
4402 );
4403 } else {
4404 println!(
4405 " {} {name}: process reachable and protocol initialized; backend tool health not checked",
4406 "✓".truecolor(aqua_r, aqua_g, aqua_b)
4407 );
4408 }
4409 }
4410 }
4411 Err(_) => println!(
4412 " {} live MCP probe could not load merged configuration; details omitted",
4413 "✗".truecolor(red_r, red_g, red_b)
4414 ),
4415 }
4416 } else {
4417 println!(
4418 " Use codewhale doctor --probe-mcp to opt in to live process/protocol checks; it may start configured servers."
4419 );
4420 }
4421 }
4422 Err(_) => {
4423 println!(
4424 " {} MCP configuration could not be loaded; details omitted",
4425 "✗".truecolor(red_r, red_g, red_b)
4426 );
4427 }
4428 }
4429
4430 println!();
4432 println!("{}", "Skills:".bold());
4433 let global_skills_dir = config.skills_dir();
4434 let agents_skills_dir = workspace.join(".agents").join("skills");
4435 let local_skills_dir = workspace.join("skills");
4436 let agents_global_skills_dir = crate::skills::agents_global_skills_dir();
4437 let opencode_skills_dir = workspace.join(".opencode").join("skills");
4443 let claude_skills_dir = workspace.join(".claude").join("skills");
4444 let selected_skills_dir = if agents_skills_dir.exists() {
4445 agents_skills_dir.clone()
4446 } else if local_skills_dir.exists() {
4447 local_skills_dir.clone()
4448 } else if config.skills_dir.is_none()
4449 && let Some(global_agents) = agents_global_skills_dir.as_ref()
4450 && global_agents.exists()
4451 {
4452 global_agents.clone()
4453 } else {
4454 global_skills_dir.clone()
4455 };
4456
4457 let describe_dir = |dir: &Path| -> usize {
4458 std::fs::read_dir(dir)
4459 .map(|entries| entries.filter_map(std::result::Result::ok).count())
4460 .unwrap_or(0)
4461 };
4462
4463 if local_skills_dir.exists() {
4464 println!(
4465 " {} local skills dir found at {} ({} items)",
4466 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4467 crate::utils::display_path(&local_skills_dir),
4468 describe_dir(&local_skills_dir)
4469 );
4470 } else {
4471 println!(
4472 " {} local skills dir not found at {}",
4473 "·".dimmed(),
4474 crate::utils::display_path(&local_skills_dir)
4475 );
4476 }
4477
4478 if agents_skills_dir.exists() {
4479 println!(
4480 " {} .agents skills dir found at {} ({} items)",
4481 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4482 crate::utils::display_path(&agents_skills_dir),
4483 describe_dir(&agents_skills_dir)
4484 );
4485 } else {
4486 println!(
4487 " {} .agents skills dir not found at {}",
4488 "·".dimmed(),
4489 crate::utils::display_path(&agents_skills_dir)
4490 );
4491 }
4492
4493 if let Some(agents_global_skills_dir) = agents_global_skills_dir.as_ref() {
4494 if agents_global_skills_dir.exists() {
4495 println!(
4496 " {} global .agents skills dir found at {} ({} items)",
4497 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4498 crate::utils::display_path(agents_global_skills_dir),
4499 describe_dir(agents_global_skills_dir)
4500 );
4501 } else {
4502 println!(
4503 " {} global .agents skills dir not found at {}",
4504 "·".dimmed(),
4505 crate::utils::display_path(agents_global_skills_dir)
4506 );
4507 }
4508 }
4509
4510 if global_skills_dir.exists() {
4511 println!(
4512 " {} global skills dir found at {} ({} items)",
4513 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4514 crate::utils::display_path(&global_skills_dir),
4515 describe_dir(&global_skills_dir)
4516 );
4517 } else {
4518 println!(
4519 " {} global skills dir not found at {}",
4520 "·".dimmed(),
4521 crate::utils::display_path(&global_skills_dir)
4522 );
4523 }
4524
4525 if opencode_skills_dir.exists() {
4529 println!(
4530 " {} .opencode skills dir found at {} ({} items)",
4531 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4532 crate::utils::display_path(&opencode_skills_dir),
4533 describe_dir(&opencode_skills_dir)
4534 );
4535 }
4536 if claude_skills_dir.exists() {
4537 println!(
4538 " {} .claude skills dir found at {} ({} items)",
4539 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4540 crate::utils::display_path(&claude_skills_dir),
4541 describe_dir(&claude_skills_dir)
4542 );
4543 }
4544
4545 println!(
4546 " {} selected skills dir: {}",
4547 "·".dimmed(),
4548 crate::utils::display_path(&selected_skills_dir)
4549 );
4550 if !agents_skills_dir.exists()
4551 && !local_skills_dir.exists()
4552 && !agents_global_skills_dir
4553 .as_ref()
4554 .is_some_and(|dir| dir.exists())
4555 && !global_skills_dir.exists()
4556 {
4557 println!(" Run `codewhale setup --skills` (or add --local for ./skills).");
4558 }
4559
4560 println!();
4562 println!("{}", "Tools:".bold());
4563 let tools_dir = default_tools_dir();
4564 if tools_dir.exists() {
4565 let count = count_dir_entries(&tools_dir);
4566 println!(
4567 " {} tools dir found at {} ({} items)",
4568 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4569 crate::utils::display_path(&tools_dir),
4570 count
4571 );
4572 } else {
4573 println!(
4574 " {} tools dir not found at {}",
4575 "·".dimmed(),
4576 crate::utils::display_path(&tools_dir)
4577 );
4578 println!(" Run `codewhale setup --tools` to scaffold a starter dir.");
4579 }
4580
4581 println!();
4583 println!("{}", "Plugins:".bold());
4584 let plugins_dir = default_plugins_dir();
4585 if plugins_dir.exists() {
4586 let count = count_dir_entries(&plugins_dir);
4587 println!(
4588 " {} plugins dir found at {} ({} items)",
4589 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4590 crate::utils::display_path(&plugins_dir),
4591 count
4592 );
4593 } else {
4594 println!(
4595 " {} plugins dir not found at {}",
4596 "·".dimmed(),
4597 crate::utils::display_path(&plugins_dir)
4598 );
4599 println!(" Run `codewhale setup --plugins` to scaffold a starter dir.");
4600 }
4601
4602 println!();
4604 println!("{}", "Storage:".bold());
4605 if let Some(spillover_root) = crate::tools::truncate::spillover_root() {
4606 let (present, count) = if spillover_root.is_dir() {
4607 (true, count_dir_entries(&spillover_root))
4608 } else {
4609 (false, 0)
4610 };
4611 if present {
4612 println!(
4613 " {} tool-output spillover at {} ({} file{})",
4614 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4615 crate::utils::display_path(&spillover_root),
4616 count,
4617 if count == 1 { "" } else { "s" }
4618 );
4619 } else {
4620 println!(
4621 " {} tool-output spillover dir not yet created at {}",
4622 "·".dimmed(),
4623 crate::utils::display_path(&spillover_root)
4624 );
4625 }
4626 }
4627 let stash = crate::composer_stash::diagnostic_stash_report();
4628 if let Some(stash_path) = stash.path.as_ref() {
4629 if let Some(error) = stash.error.as_deref() {
4630 println!(
4631 " {} composer stash was not inspected at {}: {error}",
4632 "!".truecolor(sky_r, sky_g, sky_b),
4633 crate::utils::display_path(stash_path),
4634 );
4635 } else if stash.present {
4636 println!(
4637 " {} composer stash at {} ({} parked draft{})",
4638 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4639 crate::utils::display_path(stash_path),
4640 stash.count,
4641 if stash.count == 1 { "" } else { "s" }
4642 );
4643 } else {
4644 println!(
4645 " {} composer stash empty (Ctrl+G or Ctrl+S in the composer to park a draft)",
4646 "·".dimmed()
4647 );
4648 }
4649 } else if let Some(error) = stash.error.as_deref() {
4650 println!(
4651 " {} composer stash was not inspected: {error}",
4652 "!".truecolor(sky_r, sky_g, sky_b),
4653 );
4654 }
4655
4656 println!();
4661 println!("{}", "Tool Dependencies:".bold());
4662
4663 match crate::dependencies::resolve_python_interpreter() {
4664 Some(name) => println!(
4665 " {} Python: {} → code_execution tool registered",
4666 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4667 name
4668 ),
4669 None => {
4670 println!(
4671 " {} Python: not found (tried {:?})",
4672 "✗".truecolor(red_r, red_g, red_b),
4673 crate::dependencies::PYTHON_CANDIDATES,
4674 );
4675 println!(" code_execution tool is NOT advertised to the model on this install.");
4676 println!(" Install Python 3 and ensure one of those names is on PATH:");
4677 match std::env::consts::OS {
4678 "macos" => {
4679 println!(" brew install python@3.12 (or download from python.org)")
4680 }
4681 "linux" => println!(
4682 " sudo apt install python3 (Debian/Ubuntu) — or your distro's equivalent"
4683 ),
4684 "windows" => {
4685 println!(" winget install Python.Python.3 (or download from python.org)")
4686 }
4687 other => println!(" install Python 3 for {other} from python.org"),
4688 }
4689 }
4690 }
4691
4692 match crate::dependencies::resolve_node() {
4693 Some(_) => println!(
4694 " {} Node.js: present → js_execution tool registered",
4695 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4696 ),
4697 None => {
4698 println!(
4699 " {} Node.js: not found (tried `node`)",
4700 "✗".truecolor(red_r, red_g, red_b),
4701 );
4702 println!(" js_execution tool is NOT advertised to the model on this install.");
4703 println!(" Install Node 18+ and ensure `node` is on PATH:");
4704 match std::env::consts::OS {
4705 "macos" => println!(" brew install node (or download from nodejs.org)"),
4706 "linux" => println!(
4707 " sudo apt install nodejs (Debian/Ubuntu) — or your distro's equivalent"
4708 ),
4709 "windows" => {
4710 println!(" winget install OpenJS.NodeJS (or download from nodejs.org)")
4711 }
4712 other => println!(" install Node.js for {other} from nodejs.org"),
4713 }
4714 }
4715 }
4716
4717 match crate::dependencies::resolve_pandoc() {
4718 Some(_) => println!(
4719 " {} pandoc: present → pandoc_convert tool registered",
4720 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4721 ),
4722 None => {
4723 println!(" {} pandoc: not found (optional)", "·".dimmed(),);
4724 println!(
4725 " pandoc_convert tool is NOT advertised to the model. Install pandoc to enable:"
4726 );
4727 match std::env::consts::OS {
4728 "macos" => println!(" brew install pandoc"),
4729 "linux" => println!(
4730 " sudo apt install pandoc (Debian/Ubuntu) — or your distro's equivalent"
4731 ),
4732 "windows" => {
4733 println!(" winget install JohnMacFarlane.Pandoc")
4734 }
4735 other => println!(" install pandoc for {other} from pandoc.org"),
4736 }
4737 }
4738 }
4739
4740 match crate::dependencies::resolve_tesseract() {
4741 Some(_) => {
4742 if cfg!(target_os = "macos") {
4743 println!(
4744 " {} OCR: macOS Vision + tesseract available → image_ocr/read_file screenshot OCR enabled",
4745 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4746 );
4747 } else {
4748 println!(
4749 " {} tesseract: present → image_ocr/read_file screenshot OCR enabled",
4750 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4751 );
4752 }
4753 }
4754 None => {
4755 if cfg!(target_os = "macos") {
4756 println!(
4757 " {} OCR: macOS Vision available → image_ocr/read_file screenshot OCR enabled",
4758 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4759 );
4760 println!(
4761 " tesseract not found (optional; install only for alternate OCR packs)."
4762 );
4763 } else {
4764 println!(" {} tesseract: not found (optional)", "·".dimmed(),);
4765 println!(
4766 " image_ocr tool is NOT advertised to the model. Install tesseract to enable:"
4767 );
4768 match std::env::consts::OS {
4769 "macos" => println!(" brew install tesseract"),
4770 "linux" => println!(
4771 " sudo apt install tesseract-ocr (Debian/Ubuntu) — or your distro's equivalent"
4772 ),
4773 "windows" => println!(" winget install UB-Mannheim.TesseractOCR"),
4774 other => {
4775 println!(" install tesseract for {other} from tesseract-ocr.github.io")
4776 }
4777 }
4778 }
4779 }
4780 }
4781
4782 match crate::dependencies::resolve_pdftotext() {
4786 Some(_) => println!(
4787 " {} pdftotext: available → PDF text extraction enabled",
4788 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4789 ),
4790 None => {
4791 println!(
4792 " {} pdftotext: not found (optional; PDF text reads fail as `binary_unavailable`)",
4793 "·".dimmed(),
4794 );
4795 match std::env::consts::OS {
4796 "macos" => println!(" Install via: brew install poppler"),
4797 "linux" => {
4798 println!(" Install via: sudo apt install poppler-utils (Debian/Ubuntu)")
4799 }
4800 "windows" => println!(
4801 " Install Poppler for Windows from https://blog.alivate.com.au/poppler-windows/"
4802 ),
4803 _ => {}
4804 }
4805 }
4806 }
4807
4808 println!();
4812 println!("{}", "Terminal Quirks:".bold());
4813 let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default();
4814 let term_program_lc = term_program.to_ascii_lowercase();
4815 let mut any_quirk = false;
4816 if matches!(term_program.as_str(), "vscode" | "ghostty") {
4817 println!(
4818 " {} TERM_PROGRAM={} → low_motion + fancy_animations=false (auto)",
4819 "•".truecolor(sky_r, sky_g, sky_b),
4820 term_program
4821 );
4822 any_quirk = true;
4823 }
4824 if term_program == "Termius"
4825 || std::env::var_os("SSH_CLIENT").is_some_and(|v| !v.is_empty())
4826 || std::env::var_os("SSH_TTY").is_some_and(|v| !v.is_empty())
4827 {
4828 println!(
4829 " {} SSH/Termius session → low_motion + fancy_animations=false (auto, #1433)",
4830 "•".truecolor(sky_r, sky_g, sky_b)
4831 );
4832 any_quirk = true;
4833 }
4834 if term_program_lc.contains("ptyxis")
4835 || std::env::var_os("PTYXIS_VERSION").is_some_and(|v| !v.is_empty())
4836 {
4837 println!(
4838 " {} Ptyxis detected → synchronized_output=off (auto, v0.8.31)",
4839 "•".truecolor(sky_r, sky_g, sky_b)
4840 );
4841 any_quirk = true;
4842 }
4843 if crate::settings::detected_legacy_windows_console_host() {
4844 println!(
4845 " {} legacy Windows console host → low_motion + fancy_animations=false + bracketed_paste=false + synchronized_output=off (auto)",
4846 "•".truecolor(sky_r, sky_g, sky_b)
4847 );
4848 any_quirk = true;
4849 }
4850 if !any_quirk {
4851 println!(
4852 " {} no env-driven terminal-quirk overrides active",
4853 "·".dimmed()
4854 );
4855 }
4856
4857 println!();
4859 println!("{}", "Platform:".bold());
4860 println!(" OS: {}", std::env::consts::OS);
4861 println!(" Arch: {}", std::env::consts::ARCH);
4862
4863 let sandbox = crate::sandbox::get_platform_sandbox_with_bwrap_preference(
4864 config.prefer_bwrap.unwrap_or(false),
4865 );
4866 if let Some(kind) = sandbox {
4867 println!(
4868 " {} sandbox available: {}",
4869 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4870 kind
4871 );
4872 } else {
4873 println!(
4874 " {} sandbox not available (commands run best-effort)",
4875 "!".truecolor(sky_r, sky_g, sky_b)
4876 );
4877 }
4878
4879 println!();
4880 println!(
4881 "{}",
4882 "All checks complete!"
4883 .truecolor(aqua_r, aqua_g, aqua_b)
4884 .bold()
4885 );
4886}
4887
4888const DOCTOR_LEGACY_STATE_ITEMS: &[&str] = &[
4889 "sessions",
4890 "tasks",
4891 "skills",
4892 "slop_ledger",
4893 "trophies",
4894 "catalog",
4895 "review-receipts",
4896 "config.toml",
4897 "settings.toml",
4898 "mcp.json",
4899];
4900const DOCTOR_SESSION_RECOVERY_HUMAN_SAMPLE_LIMIT: usize = 20;
4901const DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT: usize = 100;
4902
4903#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4904enum DoctorLegacyStateStatus {
4905 PrimaryOnly,
4906 LegacyOnly,
4907 Both,
4908 Absent,
4909}
4910
4911impl DoctorLegacyStateStatus {
4912 fn as_str(self) -> &'static str {
4913 match self {
4914 Self::PrimaryOnly => "primary_only",
4915 Self::LegacyOnly => "legacy_only",
4916 Self::Both => "both",
4917 Self::Absent => "absent",
4918 }
4919 }
4920}
4921
4922#[derive(Debug, Clone)]
4923struct DoctorLegacyStateEntry {
4924 name: &'static str,
4925 primary_path: PathBuf,
4926 legacy_path: PathBuf,
4927 primary_present: bool,
4928 legacy_present: bool,
4929 status: DoctorLegacyStateStatus,
4930}
4931
4932#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4933enum DoctorSessionRecoveryStatus {
4934 Isolated,
4935 NoLegacySessions,
4936 MigrationPending,
4937 MigrationIncomplete,
4938 MigrationComplete,
4939 ScanFailed,
4940}
4941
4942impl DoctorSessionRecoveryStatus {
4943 fn as_str(self) -> &'static str {
4944 match self {
4945 Self::Isolated => "isolated",
4946 Self::NoLegacySessions => "no_legacy_sessions",
4947 Self::MigrationPending => "migration_pending",
4948 Self::MigrationIncomplete => "migration_incomplete",
4949 Self::MigrationComplete => "migration_complete",
4950 Self::ScanFailed => "scan_failed",
4951 }
4952 }
4953}
4954
4955#[derive(Debug, Clone)]
4956struct DoctorRecoverableSessionEntry {
4957 name: PathBuf,
4958 source_path: PathBuf,
4959 destination_path: PathBuf,
4960}
4961
4962#[derive(Debug, Clone)]
4963struct DoctorSessionRecoveryReport {
4964 status: DoctorSessionRecoveryStatus,
4965 primary_sessions_path: PathBuf,
4966 legacy_sessions_path: PathBuf,
4967 codewhale_home_is_explicit: bool,
4968 legacy_session_file_count: usize,
4969 already_present_file_count: usize,
4970 recoverable_file_count: usize,
4971 recoverable: Vec<DoctorRecoverableSessionEntry>,
4973 error: Option<String>,
4974}
4975
4976impl DoctorSessionRecoveryReport {
4977 fn needs_attention(&self) -> bool {
4978 matches!(
4979 self.status,
4980 DoctorSessionRecoveryStatus::MigrationPending
4981 | DoctorSessionRecoveryStatus::MigrationIncomplete
4982 | DoctorSessionRecoveryStatus::ScanFailed
4983 )
4984 }
4985}
4986
4987fn doctor_legacy_state_status(
4988 primary_present: bool,
4989 legacy_present: bool,
4990) -> DoctorLegacyStateStatus {
4991 match (primary_present, legacy_present) {
4992 (true, false) => DoctorLegacyStateStatus::PrimaryOnly,
4993 (false, true) => DoctorLegacyStateStatus::LegacyOnly,
4994 (true, true) => DoctorLegacyStateStatus::Both,
4995 (false, false) => DoctorLegacyStateStatus::Absent,
4996 }
4997}
4998
4999fn doctor_state_roots() -> (PathBuf, PathBuf) {
5000 let code_home =
5001 codewhale_config::codewhale_home().unwrap_or_else(|_| PathBuf::from("~/.codewhale"));
5002 let legacy_home = if codewhale_config::codewhale_home_is_explicit() {
5003 code_home.join(codewhale_config::LEGACY_APP_DIR)
5004 } else {
5005 codewhale_config::legacy_deepseek_home().unwrap_or_else(|_| PathBuf::from("~/.deepseek"))
5006 };
5007 (code_home, legacy_home)
5008}
5009
5010fn doctor_legacy_state_report(
5011 primary_root: &Path,
5012 legacy_root: &Path,
5013) -> Vec<DoctorLegacyStateEntry> {
5014 DOCTOR_LEGACY_STATE_ITEMS
5015 .iter()
5016 .copied()
5017 .map(|name| {
5018 let primary_path = primary_root.join(name);
5019 let legacy_path = legacy_root.join(name);
5020 let primary_present = primary_path.exists();
5021 let legacy_present = legacy_path.exists();
5022 let status = doctor_legacy_state_status(primary_present, legacy_present);
5023 DoctorLegacyStateEntry {
5024 name,
5025 primary_path,
5026 legacy_path,
5027 primary_present,
5028 legacy_present,
5029 status,
5030 }
5031 })
5032 .collect()
5033}
5034
5035fn doctor_session_recovery_report(
5047 primary_root: &Path,
5048 legacy_root: &Path,
5049 codewhale_home_is_explicit: bool,
5050) -> DoctorSessionRecoveryReport {
5051 let primary_sessions_path = primary_root.join("sessions");
5052 let legacy_sessions_path = legacy_root.join("sessions");
5053 let mut report = DoctorSessionRecoveryReport {
5054 status: DoctorSessionRecoveryStatus::NoLegacySessions,
5055 primary_sessions_path,
5056 legacy_sessions_path,
5057 codewhale_home_is_explicit,
5058 legacy_session_file_count: 0,
5059 already_present_file_count: 0,
5060 recoverable_file_count: 0,
5061 recoverable: Vec::new(),
5062 error: None,
5063 };
5064
5065 if codewhale_home_is_explicit {
5066 report.status = DoctorSessionRecoveryStatus::Isolated;
5067 return report;
5068 }
5069
5070 let legacy_root_is_present =
5071 match doctor_session_directory_is_safe(legacy_root, "legacy state root") {
5072 Ok(present) => present,
5073 Err(error) => {
5074 report.status = DoctorSessionRecoveryStatus::ScanFailed;
5075 report.error = Some(error);
5076 return report;
5077 }
5078 };
5079 if !legacy_root_is_present {
5080 return report;
5081 }
5082 if let Err(error) = doctor_session_directory_is_safe(primary_root, "primary state root") {
5083 report.status = DoctorSessionRecoveryStatus::ScanFailed;
5084 report.error = Some(error);
5085 return report;
5086 }
5087
5088 let legacy_sessions_are_present = match doctor_session_directory_is_safe(
5089 &report.legacy_sessions_path,
5090 "legacy sessions root",
5091 ) {
5092 Ok(present) => present,
5093 Err(error) => {
5094 report.status = DoctorSessionRecoveryStatus::ScanFailed;
5095 report.error = Some(error);
5096 return report;
5097 }
5098 };
5099 if !legacy_sessions_are_present {
5100 return report;
5101 }
5102 let primary_sessions_are_present = match doctor_session_directory_is_safe(
5103 &report.primary_sessions_path,
5104 "primary sessions root",
5105 ) {
5106 Ok(present) => present,
5107 Err(error) => {
5108 report.status = DoctorSessionRecoveryStatus::ScanFailed;
5109 report.error = Some(error);
5110 return report;
5111 }
5112 };
5113
5114 let entries = match std::fs::read_dir(&report.legacy_sessions_path) {
5115 Ok(entries) => entries,
5116 Err(err) => {
5117 report.status = DoctorSessionRecoveryStatus::ScanFailed;
5118 report.error = Some(format!(
5119 "could not inspect legacy session filenames at {}: {err}",
5120 crate::utils::display_path(&report.legacy_sessions_path)
5121 ));
5122 return report;
5123 }
5124 };
5125
5126 for entry in entries {
5127 let entry = match entry {
5128 Ok(entry) => entry,
5129 Err(err) => {
5130 report.status = DoctorSessionRecoveryStatus::ScanFailed;
5131 report.error = Some(format!(
5132 "could not inspect an entry under {}: {err}",
5133 crate::utils::display_path(&report.legacy_sessions_path)
5134 ));
5135 return report;
5136 }
5137 };
5138 let file_type = match entry.file_type() {
5139 Ok(file_type) => file_type,
5140 Err(err) => {
5141 report.status = DoctorSessionRecoveryStatus::ScanFailed;
5142 report.error = Some(format!(
5143 "could not inspect legacy session entry metadata under {}: {err}",
5144 crate::utils::display_path(&report.legacy_sessions_path)
5145 ));
5146 return report;
5147 }
5148 };
5149 if !file_type.is_file() || entry.path().extension().is_none_or(|ext| ext != "json") {
5150 continue;
5151 }
5152
5153 report.legacy_session_file_count += 1;
5154 let name = PathBuf::from(entry.file_name());
5155 let destination_path = report.primary_sessions_path.join(&name);
5156 match std::fs::symlink_metadata(&destination_path) {
5157 Ok(metadata) if metadata.file_type().is_file() => {
5158 report.already_present_file_count += 1;
5159 }
5160 Ok(metadata) => {
5161 report.status = DoctorSessionRecoveryStatus::ScanFailed;
5162 let shape = if metadata.file_type().is_symlink() {
5163 "destination session entry is a symlink"
5164 } else {
5165 "destination session entry is not a regular file"
5166 };
5167 report.error = Some(format!(
5168 "could not inspect destination session metadata at {}: {shape}",
5169 crate::utils::display_path(&destination_path)
5170 ));
5171 return report;
5172 }
5173 Err(err) if err.kind() == io::ErrorKind::NotFound => {
5174 report.recoverable_file_count += 1;
5175 record_doctor_recoverable_session(
5176 &mut report.recoverable,
5177 DoctorRecoverableSessionEntry {
5178 source_path: entry.path(),
5179 destination_path,
5180 name,
5181 },
5182 );
5183 }
5184 Err(err) => {
5185 report.status = DoctorSessionRecoveryStatus::ScanFailed;
5186 report.error = Some(format!(
5187 "could not inspect destination metadata at {}: {err}",
5188 crate::utils::display_path(&destination_path)
5189 ));
5190 return report;
5191 }
5192 }
5193 }
5194
5195 report.status = if report.legacy_session_file_count == 0 {
5196 DoctorSessionRecoveryStatus::NoLegacySessions
5197 } else if report.recoverable_file_count == 0 {
5198 DoctorSessionRecoveryStatus::MigrationComplete
5199 } else if primary_sessions_are_present {
5200 DoctorSessionRecoveryStatus::MigrationIncomplete
5201 } else {
5202 DoctorSessionRecoveryStatus::MigrationPending
5203 };
5204 report
5205}
5206
5207fn doctor_session_directory_is_safe(path: &Path, label: &str) -> std::result::Result<bool, String> {
5215 let metadata = match std::fs::symlink_metadata(path) {
5216 Ok(metadata) => metadata,
5217 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false),
5218 Err(error) => {
5219 return Err(format!(
5220 "could not inspect {label} at {}: {error}",
5221 crate::utils::display_path(path)
5222 ));
5223 }
5224 };
5225 if metadata.file_type().is_symlink() {
5226 return Err(format!(
5227 "could not inspect {label} at {}: path is a symlink",
5228 crate::utils::display_path(path)
5229 ));
5230 }
5231 if !metadata.file_type().is_dir() {
5232 return Err(format!(
5233 "could not inspect {label} at {}: path is not a directory",
5234 crate::utils::display_path(path)
5235 ));
5236 }
5237 Ok(true)
5238}
5239
5240fn record_doctor_recoverable_session(
5244 recoverable: &mut Vec<DoctorRecoverableSessionEntry>,
5245 entry: DoctorRecoverableSessionEntry,
5246) {
5247 let insert_at = recoverable
5248 .binary_search_by(|existing| existing.name.cmp(&entry.name))
5249 .unwrap_or_else(|index| index);
5250 if recoverable.len() == DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT
5251 && insert_at == recoverable.len()
5252 {
5253 return;
5254 }
5255 recoverable.insert(insert_at, entry);
5256 if recoverable.len() > DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT {
5257 recoverable.pop();
5258 }
5259}
5260
5261fn legacy_state_needs_attention(entry: &DoctorLegacyStateEntry) -> bool {
5262 entry.name != "sessions"
5263 && matches!(
5264 entry.status,
5265 DoctorLegacyStateStatus::LegacyOnly | DoctorLegacyStateStatus::Both
5266 )
5267}
5268
5269fn print_doctor_legacy_state_report(
5270 report: &[DoctorLegacyStateEntry],
5271 session_recovery: &DoctorSessionRecoveryReport,
5272 ok_rgb: (u8, u8, u8),
5273 warn_rgb: (u8, u8, u8),
5274) {
5275 use colored::Colorize;
5276
5277 let attention: Vec<_> = report
5278 .iter()
5279 .filter(|entry| legacy_state_needs_attention(entry))
5280 .collect();
5281 if attention.is_empty()
5282 && !session_recovery.needs_attention()
5283 && session_recovery.status != DoctorSessionRecoveryStatus::Isolated
5284 {
5285 println!(
5286 " {} legacy state: no known .deepseek entries need migration",
5287 "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2)
5288 );
5289 } else if !attention.is_empty() {
5290 println!(
5291 " {} legacy state needs review:",
5292 "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2)
5293 );
5294 for entry in attention {
5295 match entry.status {
5296 DoctorLegacyStateStatus::LegacyOnly => {
5297 println!(
5298 " {} {} exists but {} is missing",
5299 "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2),
5300 crate::utils::display_path(&entry.legacy_path),
5301 crate::utils::display_path(&entry.primary_path),
5302 );
5303 }
5304 DoctorLegacyStateStatus::Both => {
5305 println!(
5306 " {} {} exists alongside primary {}; legacy data may still need review",
5307 "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2),
5308 crate::utils::display_path(&entry.legacy_path),
5309 crate::utils::display_path(&entry.primary_path),
5310 );
5311 }
5312 DoctorLegacyStateStatus::PrimaryOnly | DoctorLegacyStateStatus::Absent => {}
5313 }
5314 }
5315 println!(
5316 " Start Codewhale once to trigger safe migration where available, then rerun `codewhale doctor`."
5317 );
5318 }
5319
5320 print_doctor_session_recovery_report(session_recovery, ok_rgb, warn_rgb);
5321}
5322
5323fn print_doctor_session_recovery_report(
5324 report: &DoctorSessionRecoveryReport,
5325 ok_rgb: (u8, u8, u8),
5326 warn_rgb: (u8, u8, u8),
5327) {
5328 use colored::Colorize;
5329
5330 match report.status {
5331 DoctorSessionRecoveryStatus::Isolated => {
5332 println!(
5333 " {} legacy sessions: ambient ~/.deepseek/sessions was not inspected because CODEWHALE_HOME is set",
5334 "·".dimmed()
5335 );
5336 println!(
5337 " This preserves the explicit home boundary. To inspect the default home, use a separate shell with CODEWHALE_HOME unset and rerun `codewhale doctor`."
5338 );
5339 }
5340 DoctorSessionRecoveryStatus::NoLegacySessions => {
5341 println!(
5342 " {} legacy sessions: no top-level session JSON files found",
5343 "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2)
5344 );
5345 }
5346 DoctorSessionRecoveryStatus::MigrationComplete => {
5347 println!(
5348 " {} legacy sessions: all {} filename(s) have regular-file counterparts under {}; descriptor contents were not compared and legacy originals remain preserved",
5349 "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2),
5350 report.legacy_session_file_count,
5351 crate::utils::display_path(&report.primary_sessions_path),
5352 );
5353 }
5354 DoctorSessionRecoveryStatus::MigrationPending
5355 | DoctorSessionRecoveryStatus::MigrationIncomplete => {
5356 let label = if report.status == DoctorSessionRecoveryStatus::MigrationIncomplete {
5357 "migration is incomplete"
5358 } else {
5359 "migration has not completed"
5360 };
5361 println!(
5362 " {} legacy sessions: {label}; {} recoverable file(s) are absent from {}",
5363 "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2),
5364 report.recoverable_file_count,
5365 crate::utils::display_path(&report.primary_sessions_path),
5366 );
5367 for entry in report
5368 .recoverable
5369 .iter()
5370 .take(DOCTOR_SESSION_RECOVERY_HUMAN_SAMPLE_LIMIT)
5371 {
5372 println!(
5373 " {} {} -> {}",
5374 "·".dimmed(),
5375 crate::utils::display_path(&entry.source_path),
5376 crate::utils::display_path(&entry.destination_path),
5377 );
5378 }
5379 if report.recoverable_file_count > DOCTOR_SESSION_RECOVERY_HUMAN_SAMPLE_LIMIT {
5380 println!(
5381 " · {} more filename(s); `codewhale doctor --json` includes a bounded metadata-only sample",
5382 report.recoverable_file_count - DOCTOR_SESSION_RECOVERY_HUMAN_SAMPLE_LIMIT
5383 );
5384 }
5385 println!(" Safe recovery:");
5386 println!(
5387 " 1. Back up {} and {} (if present).",
5388 crate::utils::display_path(&report.legacy_sessions_path),
5389 crate::utils::display_path(&report.primary_sessions_path),
5390 );
5391 println!(
5392 " 2. Close other Codewhale processes, then run `codewhale sessions`; migration adds only missing files, never overwrites primary files, and leaves legacy originals in place."
5393 );
5394 println!(
5395 " 3. Rerun `codewhale doctor`. If filenames remain, keep both backups and report only the listed source/destination names."
5396 );
5397 }
5398 DoctorSessionRecoveryStatus::ScanFailed => {
5399 println!(
5400 " {} legacy sessions: recovery diagnostic could not complete",
5401 "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2)
5402 );
5403 if let Some(error) = report.error.as_deref() {
5404 println!(" {error}");
5405 }
5406 println!(
5407 " Keep both session directories unchanged, back them up, fix path permissions or shape, and rerun `codewhale doctor` before attempting migration."
5408 );
5409 }
5410 }
5411 if report.status != DoctorSessionRecoveryStatus::Isolated {
5412 println!(
5413 " Doctor inspected filenames and filesystem metadata only; it did not read chat contents, traverse checkpoints, or modify session files."
5414 );
5415 }
5416}
5417
5418fn doctor_session_recovery_json(report: &DoctorSessionRecoveryReport) -> serde_json::Value {
5419 use serde_json::json;
5420
5421 let recoverable: Vec<_> = report
5422 .recoverable
5423 .iter()
5424 .take(DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT)
5425 .map(|entry| {
5426 json!({
5427 "name": entry.name.display().to_string(),
5428 "source_path": entry.source_path.display().to_string(),
5429 "destination_path": entry.destination_path.display().to_string(),
5430 })
5431 })
5432 .collect();
5433
5434 json!({
5435 "status": report.status.as_str(),
5436 "needs_attention": report.needs_attention(),
5437 "read_only": true,
5438 "chat_contents_read": false,
5439 "checkpoint_internals_scanned": false,
5440 "session_descriptors_compared": false,
5441 "counterpart_check": "top_level_filename_and_regular_file_only",
5442 "codewhale_home_is_explicit": report.codewhale_home_is_explicit,
5443 "legacy_sessions_path": report.legacy_sessions_path.display().to_string(),
5444 "primary_sessions_path": report.primary_sessions_path.display().to_string(),
5445 "legacy_session_file_count": report.legacy_session_file_count,
5446 "already_present_file_count": report.already_present_file_count,
5447 "recoverable_file_count": report.recoverable_file_count,
5448 "recoverable_files": recoverable,
5449 "recoverable_files_truncated": report.recoverable_file_count > report.recoverable.len(),
5450 "error": report.error,
5451 "recovery_command": if report.needs_attention() && report.status != DoctorSessionRecoveryStatus::ScanFailed {
5452 Some("codewhale sessions")
5453 } else {
5454 None
5455 },
5456 })
5457}
5458
5459fn doctor_legacy_state_json(
5460 primary_root: &Path,
5461 legacy_root: &Path,
5462 report: &[DoctorLegacyStateEntry],
5463 session_recovery: &DoctorSessionRecoveryReport,
5464) -> serde_json::Value {
5465 use serde_json::json;
5466
5467 let legacy_only = report
5468 .iter()
5469 .filter(|entry| entry.status == DoctorLegacyStateStatus::LegacyOnly)
5470 .count();
5471 let both = report
5472 .iter()
5473 .filter(|entry| entry.status == DoctorLegacyStateStatus::Both)
5474 .count();
5475 let entries: Vec<_> = report
5476 .iter()
5477 .map(|entry| {
5478 json!({
5479 "name": entry.name,
5480 "primary_path": entry.primary_path.display().to_string(),
5481 "legacy_path": entry.legacy_path.display().to_string(),
5482 "primary_present": entry.primary_present,
5483 "legacy_present": entry.legacy_present,
5484 "status": entry.status.as_str(),
5485 })
5486 })
5487 .collect();
5488
5489 json!({
5490 "primary_root": primary_root.display().to_string(),
5491 "legacy_root": legacy_root.display().to_string(),
5492 "needs_attention": report.iter().any(legacy_state_needs_attention) || session_recovery.needs_attention(),
5493 "legacy_only_count": legacy_only,
5494 "dual_present_count": both,
5495 "session_recovery": doctor_session_recovery_json(session_recovery),
5496 "entries": entries,
5497 })
5498}
5499
5500fn doctor_setup_state(
5501 config: &Config,
5502 workspace: &Path,
5503) -> (codewhale_config::SetupState, &'static str) {
5504 if let Ok(Some(state)) = codewhale_config::SetupState::load() {
5505 return (state, "persisted");
5506 }
5507
5508 (
5509 codewhale_config::SetupState::derive_inherited(&doctor_inherited_setup_facts(
5510 config, workspace,
5511 )),
5512 "derived",
5513 )
5514}
5515
5516fn doctor_inherited_setup_facts(
5517 config: &Config,
5518 workspace: &Path,
5519) -> codewhale_config::InheritedConfigFacts {
5520 let user_constitution = codewhale_config::UserConstitution::load().ok();
5521 let user_constitution_validity = user_constitution.as_ref().map_or(
5522 codewhale_config::ConstitutionValidity::Unknown,
5523 codewhale_config::UserConstitutionLoad::validity,
5524 );
5525 let has_user_constitution = user_constitution
5526 .as_ref()
5527 .is_some_and(|loaded| !matches!(loaded, codewhale_config::UserConstitutionLoad::Missing));
5528 let has_expert_override = codewhale_config::codewhale_home()
5529 .ok()
5530 .map(|home| home.join(Path::new(crate::prompts::CONSTITUTION_OVERRIDE_FILE)))
5531 .is_some_and(|path| path.exists());
5532
5533 codewhale_config::InheritedConfigFacts {
5534 language: None,
5535 has_provider_route: !config.default_model().trim().is_empty(),
5536 has_credentials_or_local_runtime: doctor_has_credentials_or_local_runtime(config),
5537 trust_chosen: !crate::tui::onboarding::needs_trust(workspace),
5538 has_expert_override,
5539 has_user_constitution,
5540 user_constitution_validity,
5541 }
5542}
5543
5544fn doctor_has_credentials_or_local_runtime(config: &Config) -> bool {
5545 resolve_credential_diagnostic(config)
5546 .availability
5547 .certifies_ready()
5548}
5549
5550fn print_doctor_setup_report(
5551 config: &Config,
5552 workspace: &Path,
5553 state: &codewhale_config::SetupState,
5554 source: &str,
5555 ok_rgb: (u8, u8, u8),
5556 warn_rgb: (u8, u8, u8),
5557) {
5558 use colored::Colorize;
5559
5560 let credential = resolve_credential_diagnostic(config);
5561 let credential_ready = credential.availability.certifies_ready();
5562 let first_run_ready = state.first_run_ready() && credential_ready;
5563 let update_ready =
5564 state.update_ready(crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION) && credential_ready;
5565 let operate_ready = state.operate_ready() && credential_ready;
5566 let first_run_icon = if first_run_ready {
5567 "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2)
5568 } else {
5569 "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2)
5570 };
5571 let update_icon = if update_ready {
5572 "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2)
5573 } else {
5574 "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2)
5575 };
5576 let operate_icon = if operate_ready {
5577 "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2)
5578 } else {
5579 "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2)
5580 };
5581
5582 println!();
5583 println!("{}", "Setup State:".bold());
5584 println!(" · source: {source}");
5585 println!(
5586 " · credential: source={}, availability={}",
5587 doctor_api_key_source_label(credential.source),
5588 credential.availability.label()
5589 );
5590 println!(
5591 " {first_run_icon} first-run: {}",
5592 doctor_ready_label(first_run_ready)
5593 );
5594 println!(
5595 " {update_icon} update checkpoint {}: {}",
5596 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
5597 doctor_ready_label(update_ready)
5598 );
5599 println!(
5600 " {operate_icon} operate/fleet: {}",
5601 doctor_ready_label(operate_ready)
5602 );
5603 println!(
5604 " · constitution autonomy: {} (guidance only)",
5605 doctor_constitution_autonomy_preference_id()
5606 );
5607 println!(
5608 " · runtime posture: {}",
5609 doctor_runtime_posture_line(config, workspace)
5610 );
5611 let consistency = doctor_setup_consistency(state, source);
5612 if consistency["status"] == "inconsistent" {
5613 let issues = consistency["issues"]
5614 .as_array()
5615 .map(|issues| {
5616 issues
5617 .iter()
5618 .filter_map(serde_json::Value::as_str)
5619 .collect::<Vec<_>>()
5620 .join(", ")
5621 })
5622 .unwrap_or_default();
5623 println!(
5624 " {} consistency: half-applied setup detected ({issues}) — {}",
5625 "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2),
5626 consistency["repair"].as_str().unwrap_or("/setup"),
5627 );
5628 }
5629 println!(
5630 " · next actions: /constitution (standing law), /setup report (readiness), /setup provider or /provider setup <name> (provider credentials), /model (route), /config (runtime posture), /setup fleet (Operate/Fleet readiness), /fleet setup (explicit profile authoring), /setup hotbar (optional shortcuts), /setup tools (Tools/MCP readiness), /setup remote (remote runtime on-ramp), /setup persistence (path review)"
5631 );
5632 for step in codewhale_config::SetupStep::ALL {
5633 let entry = state.steps.get(&step);
5634 let required = entry.is_some_and(|entry| entry.required);
5635 let version = entry.and_then(|entry| entry.version.as_deref());
5636 let result = entry.and_then(|entry| entry.result.as_deref());
5637 let required_label = if required { "required" } else { "optional" };
5638 let version_label = version.unwrap_or("unversioned");
5639 let result_label = result.unwrap_or("no result");
5640 println!(
5641 " · {}: {} ({required_label}, {version_label}, {result_label})",
5642 setup_step_id(step),
5643 setup_status_id(state.status(step))
5644 );
5645 }
5646}
5647
5648fn doctor_ready_label(ready: bool) -> &'static str {
5649 if ready { "ready" } else { "needs action" }
5650}
5651
5652fn doctor_setup_consistency(
5660 state: &codewhale_config::SetupState,
5661 source: &str,
5662) -> serde_json::Value {
5663 use serde_json::json;
5664
5665 let mut issues: Vec<&'static str> = Vec::new();
5666
5667 if source == "persisted"
5668 && matches!(
5669 state.constitution_source,
5670 codewhale_config::ConstitutionSource::UserGlobal
5671 )
5672 {
5673 match codewhale_config::UserConstitution::load() {
5674 Ok(codewhale_config::UserConstitutionLoad::Missing) => {
5675 issues.push("setup_state_points_at_missing_user_constitution");
5676 }
5677 Ok(codewhale_config::UserConstitutionLoad::Empty) => {
5678 issues.push("user_constitution_empty");
5679 }
5680 Ok(codewhale_config::UserConstitutionLoad::Invalid(_)) => {
5681 issues.push("user_constitution_invalid");
5682 }
5683 Ok(codewhale_config::UserConstitutionLoad::Unreadable(_)) | Err(_) => {
5684 issues.push("user_constitution_unreadable");
5685 }
5686 Ok(codewhale_config::UserConstitutionLoad::Loaded(_)) => {}
5687 }
5688 }
5689
5690 if doctor_home_has_stale_setup_temp_files() {
5691 issues.push("stale_setup_temp_files_in_codewhale_home");
5692 }
5693
5694 json!({
5695 "status": if issues.is_empty() { "consistent" } else { "inconsistent" },
5696 "issues": issues,
5697 "repair": "/constitution to rebuild standing law, /setup to re-run the checkpoint",
5698 })
5699}
5700
5701fn doctor_home_has_stale_setup_temp_files() -> bool {
5702 let Ok(home) = codewhale_config::codewhale_home() else {
5703 return false;
5704 };
5705 let Ok(entries) = std::fs::read_dir(&home) else {
5706 return false;
5707 };
5708 entries.flatten().any(|entry| {
5709 entry.file_name().to_string_lossy().starts_with(".tmp")
5710 && entry.file_type().is_ok_and(|kind| kind.is_file())
5711 })
5712}
5713
5714fn doctor_constitution_autonomy_preference() -> codewhale_config::AutonomyPreference {
5715 codewhale_config::UserConstitution::load()
5716 .ok()
5717 .and_then(|load| {
5718 load.constitution()
5719 .map(|constitution| constitution.autonomy_preference)
5720 })
5721 .unwrap_or(codewhale_config::AutonomyPreference::Unspecified)
5722}
5723
5724fn doctor_constitution_autonomy_preference_id() -> &'static str {
5725 autonomy_preference_id(doctor_constitution_autonomy_preference())
5726}
5727
5728fn autonomy_preference_id(preference: codewhale_config::AutonomyPreference) -> &'static str {
5729 match preference {
5730 codewhale_config::AutonomyPreference::Unspecified => "unspecified",
5731 codewhale_config::AutonomyPreference::Cautious => "cautious",
5732 codewhale_config::AutonomyPreference::Balanced => "balanced",
5733 codewhale_config::AutonomyPreference::Autonomous => "autonomous",
5734 }
5735}
5736
5737fn doctor_runtime_default_mode() -> (String, &'static str) {
5738 match crate::settings::Settings::load_read_only() {
5739 Ok(settings) => (settings.default_mode, "settings"),
5740 Err(_) => (crate::settings::Settings::default().default_mode, "default"),
5741 }
5742}
5743
5744fn doctor_runtime_permission_posture() -> (String, &'static str) {
5748 match crate::settings::Settings::load_read_only() {
5749 Ok(settings) => match settings.permission_posture {
5750 Some(posture) => (posture, "settings"),
5751 None => ("unset".to_string(), "default"),
5752 },
5753 Err(_) => ("unset".to_string(), "default"),
5754 }
5755}
5756
5757fn doctor_runtime_posture_line(config: &Config, workspace: &Path) -> String {
5758 let (default_mode, default_mode_source) = doctor_runtime_default_mode();
5759 let (permission_posture, permission_posture_source) = doctor_runtime_permission_posture();
5760 let approval = config.approval_policy.as_deref().unwrap_or("on-request");
5761 let approval_source = if config.approval_policy.is_some() {
5762 "config"
5763 } else {
5764 "default"
5765 };
5766 let allow_shell = config.interactive_allow_shell();
5767 let allow_shell_source = if config.allow_shell.is_some() {
5768 "config"
5769 } else {
5770 "interactive default"
5771 };
5772 let sandbox = config.sandbox_mode.as_deref().unwrap_or("mode-derived");
5773 let sandbox_source = if config.sandbox_mode.is_some() {
5774 "config"
5775 } else {
5776 "default"
5777 };
5778 let network = config
5779 .network
5780 .as_ref()
5781 .map_or("prompt", |policy| policy.default.as_str());
5782 let network_source = if config.network.is_some() {
5783 "config"
5784 } else {
5785 "default"
5786 };
5787 let trust = if crate::tui::onboarding::needs_trust(workspace) {
5788 "workspace not elevated"
5789 } else {
5790 "workspace trusted"
5791 };
5792
5793 format!(
5794 "default_mode={default_mode} ({default_mode_source}), permission_posture={permission_posture} ({permission_posture_source}), approval_policy={approval} ({approval_source}), allow_shell={allow_shell} ({allow_shell_source}), sandbox={sandbox} ({sandbox_source}), network.default={network} ({network_source}), trust={trust}"
5795 )
5796}
5797
5798fn doctor_operate_fleet_report_json(config: &Config, workspace: &Path) -> serde_json::Value {
5799 use serde_json::json;
5800
5801 let provider = config.api_provider();
5802 let credential = resolve_credential_diagnostic(config);
5805 let has_credentials_or_local = credential.availability.certifies_ready();
5806 let subagents_enabled = config.subagents_enabled_for_provider(provider);
5807 let disabled_reason = if subagents_enabled {
5808 None
5809 } else {
5810 Some(
5811 config
5812 .subagents_disabled_reason()
5813 .unwrap_or("disabled for active provider"),
5814 )
5815 };
5816 let max_subagents = config.max_subagents_for_provider(provider);
5817 let launch_concurrency = config.launch_concurrency_for_provider(provider);
5818 let max_admitted = config.max_admitted_subagents_for_provider(provider);
5819 let max_spawn_depth = config.subagent_max_spawn_depth_for_provider(provider);
5820 let roster = crate::fleet::roster::FleetRoster::load(&config.fleet_config(), workspace);
5821 let mut built_in_members = 0usize;
5822 let mut config_members = 0usize;
5823 let mut personal_members = 0usize;
5824 let mut workspace_members = 0usize;
5825 for member in roster.members() {
5826 match member.origin {
5827 crate::fleet::roster::ProfileOrigin::BuiltIn => built_in_members += 1,
5828 crate::fleet::roster::ProfileOrigin::Config => config_members += 1,
5829 crate::fleet::roster::ProfileOrigin::Personal => personal_members += 1,
5830 crate::fleet::roster::ProfileOrigin::Workspace => workspace_members += 1,
5831 }
5832 }
5833 let roster_members = roster.members().len();
5834 let custom_members = config_members + personal_members + workspace_members;
5835 let roster_ready = roster_members > 0;
5836 let runtime_ready =
5837 subagents_enabled && max_subagents > 0 && launch_concurrency > 0 && max_spawn_depth > 0;
5838
5839 json!({
5840 "ready": has_credentials_or_local && runtime_ready && roster_ready,
5841 "provider": {
5842 "id": config.provider_identity_for(provider),
5843 "auth": {
5844 "present_or_local": has_credentials_or_local,
5845 "source": doctor_api_key_source_label(credential.source),
5846 "availability": credential.availability.label(),
5847 },
5848 },
5849 "worker_runtime": {
5850 "ready": runtime_ready,
5851 "enabled": subagents_enabled,
5852 "disabled_reason": disabled_reason,
5853 "max_subagents": max_subagents,
5854 "launch_concurrency": launch_concurrency,
5855 "max_admitted": max_admitted,
5856 "max_spawn_depth": max_spawn_depth,
5857 "host_enforced_workflow_receipts": true,
5858 },
5859 "roster": {
5860 "ready": roster_ready,
5861 "total": roster_members,
5862 "built_in": built_in_members,
5863 "config": config_members,
5864 "personal": personal_members,
5865 "workspace": workspace_members,
5866 "custom": custom_members,
5867 "starter_roster_available": built_in_members > 0,
5868 "readiness_rule": "built-in starter roster or custom roster",
5869 },
5870 "concurrency": {
5871 "launch_concurrency": launch_concurrency,
5872 "max_subagents": max_subagents,
5873 "max_admitted": max_admitted,
5874 "plan_limit_probed": false,
5875 },
5876 })
5877}
5878
5879fn doctor_provider_model_report_json(config: &Config) -> serde_json::Value {
5880 use serde_json::json;
5881
5882 let provider = config.api_provider();
5883 let credential = resolve_credential_diagnostic(config);
5884 let auth_present_or_local = credential.availability.certifies_ready();
5885 let credential_help = provider.credential_help();
5886 let credential_url = credential_help
5887 .credential_url
5888 .map(crate::doctor::structural_url_authority);
5889 let credential_docs_url = credential_help
5890 .docs_url
5891 .map(crate::doctor::structural_url_authority);
5892
5893 json!({
5894 "provider": {
5895 "id": config.provider_identity_for(provider),
5896 "display": provider.display_name(),
5897 },
5898 "model": {
5899 "resolved": config.default_model(),
5900 },
5901 "auth": {
5902 "present_or_local": auth_present_or_local,
5903 "source": doctor_api_key_source_label(credential.source),
5904 "availability": credential.availability.label(),
5905 "env_vars": provider.env_vars(),
5906 "credential_mode": credential_help.acquisition.as_str(),
5907 "credential_url": credential_url,
5908 "credential_docs_url": credential_docs_url,
5909 "credential_guidance": credential_help.guidance,
5910 "oauth_only": credential_help.acquisition
5911 == codewhale_config::provider::CredentialAcquisition::OAuth,
5912 },
5913 "health": {
5914 "live_validation": false,
5915 "next_action": if auth_present_or_local {
5916 "/model"
5917 } else {
5918 "/setup provider or /provider setup <name>"
5919 },
5920 },
5921 })
5922}
5923
5924fn doctor_external_credential_consent_statuses(
5925 config: &Config,
5926) -> Vec<codewhale_config::ExternalCredentialConsentStatus> {
5927 [
5928 crate::config::ApiProvider::OpenaiCodex,
5929 crate::config::ApiProvider::Xai,
5930 ]
5931 .into_iter()
5932 .filter_map(|provider| config.external_credential_consent_status(provider))
5933 .collect()
5934}
5935
5936fn doctor_external_credential_consent_lines(config: &Config) -> Vec<String> {
5937 doctor_external_credential_consent_statuses(config)
5938 .into_iter()
5939 .flat_map(|status| {
5940 let mut lines = vec![
5941 format!(
5942 "{}: access={}, provider={}, source={}, owner={}, path={}, version={}, state={}, ambient_path_changed={}",
5943 status.provider,
5944 status.access.as_str(),
5945 status.provider,
5946 status.source.as_str(),
5947 status.owner,
5948 codewhale_config::quote_os_path(&status.path),
5949 status.consent_version,
5950 status.route_state,
5951 status.ambient_path_changed,
5952 ),
5953 format!(" semantics: {}", status.semantics),
5954 format!(" revoke: {}", status.revoke_command),
5955 ];
5956 if let Some(warning) = status.ambient_path_warning() {
5957 lines.push(format!(" {warning}"));
5958 }
5959 lines
5960 })
5961 .collect()
5962}
5963
5964fn doctor_external_credential_consent_json(config: &Config) -> serde_json::Value {
5965 serde_json::Value::Array(
5966 doctor_external_credential_consent_statuses(config)
5967 .into_iter()
5968 .map(|status| {
5969 serde_json::json!({
5970 "provider": status.provider,
5971 "access": status.access.as_str(),
5972 "source": status.source.as_str(),
5973 "owner": status.owner,
5974 "path": codewhale_config::quote_os_path(&status.path),
5975 "consent_version": status.consent_version,
5976 "scope_valid": status.scope_valid,
5977 "ambient_path_changed": status.ambient_path_changed,
5978 "ambient_path_warning": status.ambient_path_warning(),
5979 "route_state": status.route_state,
5980 "semantics": status.semantics,
5981 "revoke_command": status.revoke_command,
5982 })
5983 })
5984 .collect(),
5985 )
5986}
5987
5988fn doctor_setup_report_json(config: &Config, workspace: &Path) -> serde_json::Value {
5989 use serde_json::json;
5990
5991 let (state, source) = doctor_setup_state(config, workspace);
5992 let (default_mode, default_mode_source) = doctor_runtime_default_mode();
5993 let (permission_posture, permission_posture_source) = doctor_runtime_permission_posture();
5994 let approval_policy = config.approval_policy.as_deref().unwrap_or("on-request");
5995 let approval_policy_source = if config.approval_policy.is_some() {
5996 "config"
5997 } else {
5998 "default"
5999 };
6000 let allow_shell = config.interactive_allow_shell();
6001 let allow_shell_source = if config.allow_shell.is_some() {
6002 "config"
6003 } else {
6004 "interactive_default"
6005 };
6006 let sandbox_mode = config.sandbox_mode.as_deref().unwrap_or("mode-derived");
6007 let sandbox_mode_source = if config.sandbox_mode.is_some() {
6008 "config"
6009 } else {
6010 "default"
6011 };
6012 let network_default = config
6013 .network
6014 .as_ref()
6015 .map_or("prompt", |policy| policy.default.as_str());
6016 let network_source = if config.network.is_some() {
6017 "config"
6018 } else {
6019 "default"
6020 };
6021 let workspace_trusted = !crate::tui::onboarding::needs_trust(workspace);
6022 let credential = resolve_credential_diagnostic(config);
6023 let credential_ready = credential.availability.certifies_ready();
6024 let steps: Vec<_> = codewhale_config::SetupStep::ALL
6025 .into_iter()
6026 .map(|step| {
6027 let entry = state.steps.get(&step);
6028 json!({
6029 "step": setup_step_id(step),
6030 "status": setup_status_id(state.status(step)),
6031 "required": entry.is_some_and(|entry| entry.required),
6032 "version": entry.and_then(|entry| entry.version.clone()),
6033 "result": entry.and_then(|entry| entry.result.clone()),
6034 })
6035 })
6036 .collect();
6037
6038 json!({
6039 "source": source,
6040 "schema_version": state.schema_version,
6041 "inherited": state.inherited,
6042 "checkpoint_version": crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
6043 "first_run_ready": state.first_run_ready() && credential_ready,
6044 "update_ready": state.update_ready(crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION)
6045 && credential_ready,
6046 "operate_ready": state.operate_ready() && credential_ready,
6047 "credential": {
6048 "ready": credential_ready,
6049 "source": doctor_api_key_source_label(credential.source),
6050 "availability": credential.availability.label(),
6051 },
6052 "constitution": {
6053 "choice": constitution_choice_id(state.constitution_choice),
6054 "source": constitution_source_id(state.constitution_source),
6055 "validity": constitution_validity_id(state.constitution_validity),
6056 "checkpoint_completed_for": state.constitution_checkpoint_completed_for.clone(),
6057 "language": state.constitution_language.clone(),
6058 "preview_hash_present": state.constitution_preview_hash.is_some(),
6059 "preview_version": state.constitution_preview_version,
6060 "autonomy_preference": doctor_constitution_autonomy_preference_id(),
6061 },
6062 "runtime_posture_source": runtime_posture_source_id(state.runtime_posture_source),
6063 "runtime_posture": {
6064 "source": runtime_posture_source_id(state.runtime_posture_source),
6065 "default_mode": {
6066 "value": default_mode,
6067 "source": default_mode_source,
6068 },
6069 "permission_posture": {
6070 "value": permission_posture,
6071 "source": permission_posture_source,
6072 },
6073 "approval_policy": {
6074 "value": approval_policy,
6075 "source": approval_policy_source,
6076 },
6077 "allow_shell": {
6078 "value": allow_shell,
6079 "source": allow_shell_source,
6080 },
6081 "sandbox_mode": {
6082 "value": sandbox_mode,
6083 "source": sandbox_mode_source,
6084 },
6085 "network_default": {
6086 "value": network_default,
6087 "source": network_source,
6088 },
6089 "workspace_trust": {
6090 "trusted": workspace_trusted,
6091 "source": "workspace",
6092 },
6093 },
6094 "provider_model": doctor_provider_model_report_json(config),
6095 "operate_fleet": doctor_operate_fleet_report_json(config, workspace),
6096 "consistency": doctor_setup_consistency(&state, source),
6097 "next_actions": {
6098 "constitution": "/constitution",
6099 "setup_report": "/setup report",
6100 "provider_model": "/setup provider, /provider setup <name>, or /model",
6101 "runtime_posture": "/config",
6102 "operate_fleet": "/setup fleet (readiness), /fleet setup (explicit profile authoring)",
6103 "hotbar": "/setup hotbar",
6104 "tools_mcp": "/setup tools",
6105 "remote_runtime": "/setup remote",
6106 "persistence": "/setup persistence",
6107 },
6108 "steps": steps,
6109 })
6110}
6111
6112fn setup_step_id(step: codewhale_config::SetupStep) -> &'static str {
6113 match step {
6114 codewhale_config::SetupStep::Language => "language",
6115 codewhale_config::SetupStep::ProviderModel => "provider_model",
6116 codewhale_config::SetupStep::TrustSandbox => "trust_sandbox",
6117 codewhale_config::SetupStep::ToolsMcp => "tools_mcp",
6118 codewhale_config::SetupStep::Hotbar => "hotbar",
6119 codewhale_config::SetupStep::RemoteRuntime => "remote_runtime",
6120 codewhale_config::SetupStep::Persistence => "persistence",
6121 codewhale_config::SetupStep::Constitution => "constitution",
6122 codewhale_config::SetupStep::OperateFleet => "operate_fleet",
6123 codewhale_config::SetupStep::Verification => "verification",
6124 }
6125}
6126
6127fn setup_status_id(status: codewhale_config::StepStatus) -> &'static str {
6128 match status {
6129 codewhale_config::StepStatus::NotStarted => "not_started",
6130 codewhale_config::StepStatus::Recommended => "recommended",
6131 codewhale_config::StepStatus::Optional => "optional",
6132 codewhale_config::StepStatus::Deferred => "deferred",
6133 codewhale_config::StepStatus::InProgress => "in_progress",
6134 codewhale_config::StepStatus::Verified => "verified",
6135 codewhale_config::StepStatus::NeedsAction => "needs_action",
6136 codewhale_config::StepStatus::Failed => "failed",
6137 codewhale_config::StepStatus::Skipped => "skipped",
6138 }
6139}
6140
6141fn constitution_choice_id(choice: codewhale_config::ConstitutionChoice) -> &'static str {
6142 match choice {
6143 codewhale_config::ConstitutionChoice::Unset => "unset",
6144 codewhale_config::ConstitutionChoice::Bundled => "bundled",
6145 codewhale_config::ConstitutionChoice::GuidedCustom => "guided_custom",
6146 codewhale_config::ConstitutionChoice::ExpertOverride => "expert_override",
6147 codewhale_config::ConstitutionChoice::Deferred => "deferred",
6148 }
6149}
6150
6151fn constitution_source_id(source: codewhale_config::ConstitutionSource) -> &'static str {
6152 match source {
6153 codewhale_config::ConstitutionSource::Bundled => "bundled",
6154 codewhale_config::ConstitutionSource::UserGlobal => "user_global",
6155 codewhale_config::ConstitutionSource::ExpertOverride => "expert_override",
6156 }
6157}
6158
6159fn constitution_validity_id(validity: codewhale_config::ConstitutionValidity) -> &'static str {
6160 match validity {
6161 codewhale_config::ConstitutionValidity::Unknown => "unknown",
6162 codewhale_config::ConstitutionValidity::Valid => "valid",
6163 codewhale_config::ConstitutionValidity::Invalid => "invalid",
6164 codewhale_config::ConstitutionValidity::Empty => "empty",
6165 codewhale_config::ConstitutionValidity::Unreadable => "unreadable",
6166 }
6167}
6168
6169fn runtime_posture_source_id(source: codewhale_config::RuntimePostureSource) -> &'static str {
6170 match source {
6171 codewhale_config::RuntimePostureSource::Unset => "unset",
6172 codewhale_config::RuntimePostureSource::Inherited => "inherited",
6173 codewhale_config::RuntimePostureSource::Confirmed => "confirmed",
6174 }
6175}
6176
6177fn run_doctor_json_config_error(error: &anyhow::Error) -> Result<()> {
6181 let safe_message = error
6182 .downcast_ref::<crate::config::SafeConfigDiagnostic>()
6183 .map(ToString::to_string);
6184 let report = serde_json::json!({
6185 "status": "error",
6186 "error": {
6187 "kind": "config_validation",
6188 "message": safe_message.as_deref().unwrap_or("configuration validation failed; details omitted because configuration errors may contain credential material"),
6189 },
6190 });
6191 println!("{}", serde_json::to_string_pretty(&report)?);
6192
6193 bail!("doctor configuration validation failed; see JSON output")
6196}
6197
6198fn run_doctor_json(
6201 config: &Config,
6202 workspace: &Path,
6203 config_path_override: Option<&Path>,
6204 plugins: &crate::plugins::PluginRegistry,
6205) -> Result<()> {
6206 use serde_json::json;
6207
6208 let doctor_paths = crate::doctor::DoctorPathReport::resolve(config_path_override)?;
6209 let config_path = &doctor_paths.config;
6210 let secret_backend = codewhale_secrets::diagnose_secret_backend();
6211
6212 let credential = resolve_credential_diagnostic(config);
6213
6214 let mcp_config_path = config.mcp_config_path();
6215 let project_mcp_config_path = crate::mcp::workspace_mcp_config_path(workspace);
6216 let mcp_present = mcp_config_path.exists();
6217 let project_mcp_present = project_mcp_config_path.exists();
6218 let mcp_summary = match crate::mcp::load_config_with_workspace_and_plugins(
6219 &mcp_config_path,
6220 workspace,
6221 plugins,
6222 ) {
6223 Ok(cfg) => {
6224 let servers: Vec<serde_json::Value> = cfg
6225 .servers
6226 .iter()
6227 .map(|(name, server)| doctor_mcp_server_json(name, server))
6228 .collect();
6229 json!({
6230 "config_path": mcp_config_path.display().to_string(),
6231 "present": mcp_present,
6232 "project_config_path": project_mcp_config_path.display().to_string(),
6233 "project_present": project_mcp_present,
6234 "probe_scope": "configuration",
6235 "live_health_checked": false,
6236 "servers": servers,
6237 })
6238 }
6239 Err(_) => json!({
6240 "config_path": mcp_config_path.display().to_string(),
6241 "present": mcp_present,
6242 "project_config_path": project_mcp_config_path.display().to_string(),
6243 "project_present": project_mcp_present,
6244 "probe_scope": "configuration",
6245 "live_health_checked": false,
6246 "servers": [],
6247 "error": "configuration_unavailable_details_omitted",
6248 }),
6249 };
6250
6251 let global_skills_dir = config.skills_dir();
6252 let agents_skills_dir = workspace.join(".agents").join("skills");
6253 let local_skills_dir = workspace.join("skills");
6254 let agents_global_skills_dir = crate::skills::agents_global_skills_dir();
6255 let opencode_skills_dir = workspace.join(".opencode").join("skills");
6260 let claude_skills_dir = workspace.join(".claude").join("skills");
6261 let selected_skills_dir = if agents_skills_dir.exists() {
6262 agents_skills_dir.clone()
6263 } else if local_skills_dir.exists() {
6264 local_skills_dir.clone()
6265 } else if config.skills_dir.is_none()
6266 && let Some(global_agents) = agents_global_skills_dir.as_ref()
6267 && global_agents.exists()
6268 {
6269 global_agents.clone()
6270 } else {
6271 global_skills_dir.clone()
6272 };
6273 let agents_global_summary = agents_global_skills_dir
6274 .as_ref()
6275 .map(|path| {
6276 json!({
6277 "path": path.display().to_string(),
6278 "present": path.exists(),
6279 "count": skills_count_for(path),
6280 })
6281 })
6282 .unwrap_or_else(|| {
6283 json!({
6284 "path": null,
6285 "present": false,
6286 "count": 0,
6287 })
6288 });
6289
6290 let tools_dir = default_tools_dir();
6291 let plugins_dir = default_plugins_dir();
6292
6293 let memory_path = config.memory_path();
6302 let memory_enabled_env = std::env::var("CODEWHALE_MEMORY")
6303 .or_else(|_| std::env::var("DEEPSEEK_MEMORY"))
6304 .ok()
6305 .map(|raw| {
6306 matches!(
6307 raw.trim().to_ascii_lowercase().as_str(),
6308 "1" | "on" | "true" | "yes" | "y" | "enabled"
6309 )
6310 })
6311 .unwrap_or(false);
6312 let memory_summary = json!({
6313 "enabled": memory_enabled_env,
6316 "path": memory_path.display().to_string(),
6317 "file_present": memory_path.exists(),
6318 });
6319 let api_target = doctor_api_target(config);
6320 let strict_tool_mode = doctor_strict_tool_mode_status(config);
6321 let tls_status = doctor_tls_status(config);
6322 let (code_home, legacy_home) = doctor_state_roots();
6323 let legacy_state_report = doctor_legacy_state_report(&code_home, &legacy_home);
6324 let session_recovery = doctor_session_recovery_report(
6325 &code_home,
6326 &legacy_home,
6327 codewhale_config::codewhale_home_is_explicit(),
6328 );
6329
6330 let stash = crate::composer_stash::diagnostic_stash_report();
6331 let report = json!({
6332 "version": env!("CARGO_PKG_VERSION"),
6333 "config_path": config_path.display().to_string(),
6334 "config_present": config_path.exists(),
6335 "paths": doctor_paths,
6336 "secret_backend": secret_backend,
6337 "workspace": workspace.display().to_string(),
6338 "legacy_state": doctor_legacy_state_json(
6339 &code_home,
6340 &legacy_home,
6341 &legacy_state_report,
6342 &session_recovery,
6343 ),
6344 "setup": doctor_setup_report_json(config, workspace),
6345 "api_key": {
6346 "source": doctor_api_key_source_label(credential.source),
6347 "availability": credential.availability.label(),
6348 },
6349 "external_credentials": doctor_external_credential_consent_json(config),
6350 "base_url": crate::doctor::structural_url_authority(&api_target.base_url),
6351 "default_text_model": api_target.model,
6352 "route_scope": "configured_at_launch",
6355 "model_resolution": match api_target.resolution {
6356 DoctorModelResolution::Resolved => "resolved",
6357 DoctorModelResolution::ConfiguredOnly => "configured_unresolved",
6358 },
6359 "route": doctor_route_report(config),
6360 "strict_tool_mode": doctor_strict_tool_mode_report_json(&strict_tool_mode),
6361 "tls": {
6362 "certificate_verification": tls_status.certificate_verification,
6363 "insecure_skip_tls_verify": tls_status.insecure_skip_tls_verify,
6364 "provider": tls_status.provider,
6365 "message": tls_status.message,
6366 },
6367 "search_provider": doctor_search_provider_json(config),
6368 "memory": memory_summary,
6369 "mcp": mcp_summary,
6370 "skills": {
6371 "selected": selected_skills_dir.display().to_string(),
6372 "global": {
6373 "path": global_skills_dir.display().to_string(),
6374 "present": global_skills_dir.exists(),
6375 "count": skills_count_for(&global_skills_dir),
6376 },
6377 "agents": {
6378 "path": agents_skills_dir.display().to_string(),
6379 "present": agents_skills_dir.exists(),
6380 "count": skills_count_for(&agents_skills_dir),
6381 },
6382 "agents_global": agents_global_summary,
6383 "local": {
6384 "path": local_skills_dir.display().to_string(),
6385 "present": local_skills_dir.exists(),
6386 "count": skills_count_for(&local_skills_dir),
6387 },
6388 "opencode": {
6389 "path": opencode_skills_dir.display().to_string(),
6390 "present": opencode_skills_dir.exists(),
6391 "count": skills_count_for(&opencode_skills_dir),
6392 },
6393 "claude": {
6394 "path": claude_skills_dir.display().to_string(),
6395 "present": claude_skills_dir.exists(),
6396 "count": skills_count_for(&claude_skills_dir),
6397 },
6398 },
6399 "tools": {
6400 "path": tools_dir.display().to_string(),
6401 "present": tools_dir.exists(),
6402 "count": if tools_dir.exists() { count_dir_entries(&tools_dir) } else { 0 },
6403 },
6404 "plugins": {
6405 "path": plugins_dir.display().to_string(),
6406 "present": plugins_dir.exists(),
6407 "count": if plugins_dir.exists() { count_dir_entries(&plugins_dir) } else { 0 },
6408 },
6409 "storage": {
6410 "spillover": {
6411 "path": crate::tools::truncate::spillover_root()
6412 .map(|p| p.display().to_string())
6413 .unwrap_or_default(),
6414 "present": crate::tools::truncate::spillover_root()
6415 .is_some_and(|p| p.is_dir()),
6416 "count": crate::tools::truncate::spillover_root()
6417 .filter(|p| p.is_dir())
6418 .map(|p| count_dir_entries(&p))
6419 .unwrap_or(0),
6420 },
6421 "stash": {
6422 "path": stash
6423 .path
6424 .as_ref()
6425 .map(|path| path.display().to_string())
6426 .unwrap_or_default(),
6427 "present": stash.present,
6428 "count": stash.count,
6429 "error": stash.error,
6430 },
6431 },
6432 "sandbox": match crate::sandbox::get_platform_sandbox_with_bwrap_preference(
6433 config.prefer_bwrap.unwrap_or(false),
6434 ) {
6435 Some(kind) => json!({"available": true, "kind": kind.to_string()}),
6436 None => json!({"available": false, "kind": null}),
6437 },
6438 "platform": {
6439 "os": std::env::consts::OS,
6440 "arch": std::env::consts::ARCH,
6441 },
6442 "api_connectivity": {
6443 "checked": false,
6444 "status": "not_probed",
6445 "note": "JSON doctor is offline; use `codewhale doctor --probe-api` or `--probe-local` for an explicit live check.",
6446 },
6447 "capability": provider_capability_report(config),
6448 });
6449
6450 println!("{}", serde_json::to_string_pretty(&report)?);
6451 Ok(())
6452}
6453
6454fn run_doctor_context_json(config: &Config, workspace: &Path) -> Result<()> {
6455 let report = crate::context_report::build_headless_context_report(config, workspace);
6456 println!("{}", crate::context_report::context_report_json(&report));
6457 Ok(())
6458}
6459
6460fn provider_capability_report(config: &Config) -> serde_json::Value {
6466 use serde_json::json;
6467
6468 let provider = config.api_provider();
6469 let configured_model = config.default_model();
6470 let route_result =
6471 crate::route_runtime::resolve_runtime_route(config, provider, Some(&configured_model));
6472 let route_error = route_result
6473 .is_err()
6474 .then_some("route_resolution_failed_details_omitted");
6475 let route = route_result.ok();
6476 let resolved_model = route
6477 .as_ref()
6478 .map_or(configured_model.as_str(), |route| route.model.as_str());
6479 let cap = crate::config::provider_capability(provider, resolved_model);
6480 let route_profile = route.as_ref().map(|route| {
6481 crate::model_profile::resolved_capability_profile_for_route(
6482 provider,
6483 resolved_model,
6484 route.candidate.capabilities(),
6485 route.candidate.limits(),
6486 )
6487 });
6488 let context_window = route
6489 .as_ref()
6490 .map_or(cap.context_window, |route| route.context_window.tokens);
6491 let context_window_source = route.as_ref().map_or(
6492 crate::route_runtime::ContextWindowSource::Fallback.label(),
6493 |route| route.context_window.source.label(),
6494 );
6495 let max_output = route_profile
6498 .as_ref()
6499 .and_then(|profile| profile.max_output)
6500 .or(cap.max_output);
6501 let is_exact_kimi_code_k3 = route.as_ref().is_some_and(|route| {
6502 crate::config::is_exact_kimi_code_k3_route(
6503 provider,
6504 &route.candidate.endpoint().base_url,
6505 route.candidate.wire_model_id().as_str(),
6506 )
6507 });
6508 let thinking_supported = is_exact_kimi_code_k3
6509 || route_profile
6510 .as_ref()
6511 .map_or(cap.thinking_supported, |profile| {
6512 profile.supports_reasoning()
6513 });
6514 let cache_telemetry_supported = route_profile
6515 .as_ref()
6516 .map_or(cap.cache_telemetry_supported, |profile| {
6517 profile.prompt_caching.is_supported()
6518 });
6519 let request_payload_mode = route_profile
6520 .as_ref()
6521 .map_or(cap.request_payload_mode, |profile| {
6522 profile.request_payload_mode
6523 });
6524 let alias_deprecation = config.active_deepseek_alias_deprecation();
6525
6526 json!({
6527 "resolved_provider": config.provider_identity_for(provider),
6528 "resolved_model": resolved_model,
6529 "context_window": context_window,
6530 "context_window_source": context_window_source,
6531 "max_output": max_output,
6532 "thinking_supported": thinking_supported,
6533 "cache_telemetry_supported": cache_telemetry_supported,
6534 "request_payload_mode": serde_json::to_value(request_payload_mode).unwrap_or_default(),
6535 "route_error": route_error,
6536 "alias_deprecation": alias_deprecation,
6537 })
6538}
6539
6540fn doctor_route_report(config: &Config) -> serde_json::Value {
6541 use serde_json::json;
6542
6543 let target = doctor_api_target(config);
6544 let provider = config.api_provider();
6545 let redacted_base_url = crate::doctor::structural_url_authority(&target.base_url);
6546 let route_result =
6547 crate::route_runtime::resolve_runtime_route(config, provider, Some(&target.model));
6548 let route_error = route_result
6549 .is_err()
6550 .then_some("route_resolution_failed_details_omitted");
6551 let context_window = route_result
6552 .ok()
6553 .map(|route| {
6554 json!({
6555 "tokens": route.context_window.tokens,
6556 "source": route.context_window.source.label(),
6557 })
6558 })
6559 .unwrap_or_else(|| {
6560 json!({
6561 "tokens": crate::config::provider_capability(provider, &target.model).context_window,
6562 "source": crate::route_runtime::ContextWindowSource::Fallback.label(),
6563 })
6564 });
6565
6566 let route_identity =
6567 crate::config::moonshot_k3_route_display_name(&target.base_url, &target.model);
6568 let credential = resolve_credential_diagnostic(config);
6569
6570 json!({
6571 "provider": target.provider,
6572 "provider_source": doctor_provider_source(config),
6573 "provider_config_table": doctor_provider_config_table(config, provider),
6574 "model": target.model,
6575 "route_identity": route_identity,
6576 "wire_protocol": doctor_wire_protocol(provider),
6577 "base_url": {
6578 "redacted": redacted_base_url,
6579 "class": doctor_base_url_class(provider, &target.base_url),
6580 "fingerprint": crate::utils::redacted_identifier_for_log(&target.base_url),
6581 },
6582 "auth": {
6583 "scheme": doctor_auth_scheme(config),
6584 "source": doctor_api_key_source_label(credential.source),
6585 "availability": credential.availability.label(),
6586 },
6587 "context_window": context_window,
6588 "route_error": route_error,
6589 })
6590}
6591
6592fn doctor_provider_config_table(config: &Config, provider: crate::config::ApiProvider) -> String {
6593 if provider != crate::config::ApiProvider::Custom {
6594 return provider_config_table_key(provider).to_string();
6595 }
6596 if config.uses_legacy_literal_custom_route() {
6597 "root (legacy literal custom)".to_string()
6598 } else {
6599 format!("providers.{}", config.provider_identity_for(provider))
6600 }
6601}
6602
6603fn doctor_provider_source(config: &Config) -> &'static str {
6604 if config
6605 .provider
6606 .as_ref()
6607 .is_some_and(|provider| !provider.trim().is_empty())
6608 {
6609 "config"
6610 } else {
6611 "default"
6612 }
6613}
6614
6615fn doctor_wire_protocol(provider: crate::config::ApiProvider) -> &'static str {
6616 let policy = provider
6617 .metadata()
6618 .map(|metadata| metadata.wire_policy())
6619 .unwrap_or(codewhale_config::provider::WirePolicy::Fixed(
6620 codewhale_config::provider::WireFormat::ChatCompletions,
6621 ));
6622 match policy.fixed() {
6623 Some(codewhale_config::provider::WireFormat::ChatCompletions) => "chat_completions",
6624 Some(codewhale_config::provider::WireFormat::Responses) => "responses",
6625 Some(codewhale_config::provider::WireFormat::AnthropicMessages) => "anthropic_messages",
6626 None => "model_aware",
6627 }
6628}
6629
6630fn doctor_base_url_class(provider: crate::config::ApiProvider, base_url: &str) -> &'static str {
6631 let normalized = base_url.trim_end_matches('/').to_ascii_lowercase();
6632 if normalized.starts_with("http://localhost")
6633 || normalized.starts_with("http://127.0.0.1")
6634 || normalized.starts_with("http://[::1]")
6635 {
6636 return "local";
6637 }
6638 if normalized
6639 == provider
6640 .default_base_url()
6641 .trim_end_matches('/')
6642 .to_ascii_lowercase()
6643 {
6644 "default"
6645 } else {
6646 "custom"
6647 }
6648}
6649
6650fn doctor_auth_scheme(config: &Config) -> &'static str {
6651 let provider = config.api_provider();
6652 if crate::config::auth_mode_disables_api_key(config.auth_mode_for_provider(provider).as_deref())
6653 {
6654 "none"
6655 } else if provider == crate::config::ApiProvider::Anthropic {
6656 "x-api-key"
6657 } else if provider == crate::config::ApiProvider::XiaomiMimo
6658 && doctor_xiaomi_mimo_base_url_uses_token_plan(&config.deepseek_base_url())
6659 {
6660 "api-key"
6661 } else if provider == crate::config::ApiProvider::XiaomiMimo {
6662 "unknown"
6665 } else if matches!(
6666 provider,
6667 crate::config::ApiProvider::Sglang
6668 | crate::config::ApiProvider::Vllm
6669 | crate::config::ApiProvider::Ollama
6670 ) {
6671 "optional_bearer"
6672 } else {
6673 "bearer"
6674 }
6675}
6676
6677fn doctor_xiaomi_mimo_base_url_uses_token_plan(base_url: &str) -> bool {
6678 let normalized = base_url.trim_end_matches('/');
6679 [
6680 crate::config::XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL,
6681 crate::config::XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL,
6682 crate::config::XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL,
6683 ]
6684 .iter()
6685 .any(|candidate| normalized.eq_ignore_ascii_case(candidate.trim_end_matches('/')))
6686}
6687
6688fn doctor_api_key_source_label(source: ApiKeySource) -> &'static str {
6689 match source {
6690 ApiKeySource::ConfigDeclared => "config_declared",
6691 ApiKeySource::EnvDeclared => "env_declared",
6692 ApiKeySource::ExternalAuthDeclared => "external_auth_declared",
6693 ApiKeySource::SecretStoreUnprobed => "secret_store_unprobed",
6694 ApiKeySource::SecretStoreUnavailable => "secret_store_unavailable",
6695 ApiKeySource::OAuth => "oauth_unprobed",
6696 ApiKeySource::ExternalConsent => "external_consent",
6697 ApiKeySource::NoAuth => "none",
6698 ApiKeySource::LocalRuntime => "local_runtime",
6699 ApiKeySource::Unknown => "unknown",
6700 }
6701}
6702
6703fn doctor_search_provider_line(config: &Config) -> String {
6704 let search_provider = config.search_provider_resolution();
6705 let switch_hint = if matches!(
6706 (search_provider.provider, search_provider.source),
6707 (
6708 crate::config::SearchProvider::DuckDuckGo,
6709 crate::config::SearchProviderSource::Default
6710 )
6711 ) {
6712 "; set [search] provider = \"bing\" | \"tavily\" | \"bocha\" to switch"
6713 } else {
6714 ""
6715 };
6716
6717 format!(
6718 "search_provider: {} (source: {}{})",
6719 search_provider.provider.as_str(),
6720 search_provider.source.as_str(),
6721 switch_hint
6722 )
6723}
6724
6725fn doctor_search_provider_json(config: &Config) -> serde_json::Value {
6726 use serde_json::json;
6727
6728 let search_provider = config.search_provider_resolution();
6729 json!({
6730 "provider": search_provider.provider.as_str(),
6731 "source": search_provider.source.as_str(),
6732 })
6733}
6734
6735#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6740enum DoctorModelResolution {
6741 Resolved,
6742 ConfiguredOnly,
6743}
6744
6745#[derive(Debug, Clone, PartialEq, Eq)]
6746struct DoctorApiTarget {
6747 provider: String,
6748 base_url: String,
6749 model: String,
6750 resolution: DoctorModelResolution,
6751}
6752
6753#[derive(Debug, Clone, PartialEq, Eq)]
6754struct DoctorStrictToolModeStatus {
6755 enabled: bool,
6756 status: &'static str,
6757 function_strict_sent: bool,
6758 message: String,
6759 recommended_base_url: Option<String>,
6760}
6761
6762fn doctor_api_target(config: &Config) -> DoctorApiTarget {
6763 let provider = config.api_provider();
6764 let (model, resolution) =
6771 match crate::route_runtime::resolve_runtime_route(config, provider, None) {
6772 Ok(route) => (route.model.clone(), DoctorModelResolution::Resolved),
6773 Err(_) => (
6774 config.default_model(),
6775 DoctorModelResolution::ConfiguredOnly,
6776 ),
6777 };
6778 DoctorApiTarget {
6779 provider: config.provider_identity_for(provider),
6780 base_url: config.deepseek_base_url(),
6781 model,
6782 resolution,
6783 }
6784}
6785
6786fn doctor_strict_tool_mode_status(config: &Config) -> DoctorStrictToolModeStatus {
6787 if !config.strict_tool_mode.unwrap_or(false) {
6788 return DoctorStrictToolModeStatus {
6789 enabled: false,
6790 status: "disabled",
6791 function_strict_sent: false,
6792 message: "disabled".to_string(),
6793 recommended_base_url: None,
6794 };
6795 }
6796
6797 let target = doctor_api_target(config);
6798 match known_deepseek_base_url_kind(&target.base_url) {
6799 Some(DeepSeekBaseUrlKind::Beta) => DoctorStrictToolModeStatus {
6800 enabled: true,
6801 status: "ready",
6802 function_strict_sent: true,
6803 message: "enabled; DeepSeek strict schemas use the beta endpoint".to_string(),
6804 recommended_base_url: None,
6805 },
6806 Some(DeepSeekBaseUrlKind::NonBeta) => {
6807 let recommended = recommended_strict_base_url(config, &target.base_url);
6808 DoctorStrictToolModeStatus {
6809 enabled: true,
6810 status: "fallback_non_beta",
6811 function_strict_sent: false,
6812 message:
6813 "enabled, but function.strict is stripped for this non-beta DeepSeek endpoint"
6814 .to_string(),
6815 recommended_base_url: Some(recommended.to_string()),
6816 }
6817 }
6818 None => DoctorStrictToolModeStatus {
6819 enabled: true,
6820 status: "custom_endpoint",
6821 function_strict_sent: true,
6822 message: "enabled; function.strict will be sent to this custom endpoint".to_string(),
6823 recommended_base_url: None,
6824 },
6825 }
6826}
6827
6828fn doctor_strict_tool_mode_report_json(status: &DoctorStrictToolModeStatus) -> serde_json::Value {
6829 serde_json::json!({
6830 "enabled": status.enabled,
6831 "status": status.status,
6832 "function_strict_sent": status.function_strict_sent,
6833 "message": status.message,
6834 "recommended_base_url": status
6835 .recommended_base_url
6836 .as_deref()
6837 .map(crate::doctor::structural_url_authority),
6838 })
6839}
6840
6841#[derive(Debug, Clone, PartialEq, Eq)]
6842struct DoctorTlsStatus {
6843 certificate_verification: bool,
6844 insecure_skip_tls_verify: bool,
6845 provider: String,
6846 message: String,
6847}
6848
6849fn doctor_tls_status(config: &Config) -> DoctorTlsStatus {
6850 let provider = config.provider_identity_for(config.api_provider());
6851 let insecure_skip_tls_verify = config.insecure_skip_tls_verify();
6852 let message = if insecure_skip_tls_verify {
6853 format!(
6854 "TLS certificate verification cannot be disabled for provider {provider}; use SSL_CERT_FILE with a trusted custom CA bundle"
6855 )
6856 } else {
6857 "TLS certificate verification enabled".to_string()
6858 };
6859 DoctorTlsStatus {
6860 certificate_verification: true,
6861 insecure_skip_tls_verify,
6862 provider,
6863 message,
6864 }
6865}
6866
6867#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6868enum DeepSeekBaseUrlKind {
6869 Beta,
6870 NonBeta,
6871}
6872
6873fn known_deepseek_base_url_kind(base_url: &str) -> Option<DeepSeekBaseUrlKind> {
6874 let normalized = base_url.trim_end_matches('/');
6875 if normalized.eq_ignore_ascii_case("https://api.deepseek.com/beta")
6876 || normalized.eq_ignore_ascii_case("https://api.deepseeki.com/beta")
6877 {
6878 Some(DeepSeekBaseUrlKind::Beta)
6879 } else if normalized.eq_ignore_ascii_case("https://api.deepseek.com")
6880 || normalized.eq_ignore_ascii_case("https://api.deepseek.com/v1")
6881 || normalized.eq_ignore_ascii_case("https://api.deepseeki.com")
6882 || normalized.eq_ignore_ascii_case("https://api.deepseeki.com/v1")
6883 {
6884 Some(DeepSeekBaseUrlKind::NonBeta)
6885 } else {
6886 None
6887 }
6888}
6889
6890fn recommended_strict_base_url(_config: &Config, _base_url: &str) -> &'static str {
6891 crate::config::DEFAULT_DEEPSEEK_BASE_URL
6892}
6893
6894fn doctor_timeout_recovery_lines(config: &Config) -> Vec<String> {
6895 let target = doctor_api_target(config);
6896 let mut lines = vec![format!(
6897 "Connection timed out while reaching {}.",
6898 crate::doctor::structural_url_authority(&target.base_url)
6899 )];
6900
6901 match config.api_provider() {
6902 crate::config::ApiProvider::Deepseek
6903 if target.base_url.contains("api.deepseek.com")
6904 && !target.base_url.contains("api.deepseeki.com") =>
6905 {
6906 lines.push(
6907 "If this is a custom DeepSeek-compatible endpoint, set its HTTPS base URL in ~/.codewhale/config.toml and rerun `codewhale doctor`."
6908 .to_string(),
6909 );
6910 }
6911 crate::config::ApiProvider::Deepseek | crate::config::ApiProvider::DeepseekCN => {
6912 lines.push(
6913 "If this is a custom DeepSeek-compatible endpoint, confirm it serves `/v1/models` and `/v1/chat/completions` over HTTPS."
6914 .to_string(),
6915 );
6916 }
6917 _ => {
6918 lines.push(
6919 "Confirm the configured provider endpoint is reachable and OpenAI-compatible for `/v1/models` and `/v1/chat/completions`."
6920 .to_string(),
6921 );
6922 }
6923 }
6924
6925 lines.push(
6926 "Run `codewhale doctor --json` and include `base_url`, `default_text_model`, and `api_connectivity` when filing an issue."
6927 .to_string(),
6928 );
6929 lines
6930}
6931
6932fn run_features_command(config: &Config, command: FeaturesCli) -> Result<()> {
6933 match command.command {
6934 FeaturesSubcommand::List => {
6935 print!("{}", render_feature_table(&config.features()));
6936 Ok(())
6937 }
6938 }
6939}
6940
6941async fn run_models(config: &Config, args: ModelsArgs) -> Result<()> {
6942 use crate::client::DeepSeekClient;
6943
6944 let client = DeepSeekClient::new(config)?;
6945 let mut models = client.list_models().await?;
6946 models.sort_by(|a, b| a.id.cmp(&b.id));
6947
6948 if args.json {
6949 println!("{}", serde_json::to_string_pretty(&models)?);
6950 return Ok(());
6951 }
6952
6953 if models.is_empty() {
6954 println!("No models returned by the API.");
6955 return Ok(());
6956 }
6957
6958 let default_model = config.default_model();
6959
6960 println!("Available models (default: {default_model})");
6961 for model in models {
6962 let marker = if model.id == default_model { "*" } else { " " };
6963 if let Some(owner) = model.owned_by {
6964 println!("{marker} {} ({owner})", model.id);
6965 } else {
6966 println!("{marker} {}", model.id);
6967 }
6968 }
6969
6970 Ok(())
6971}
6972
6973async fn run_speech(config: &Config, args: SpeechArgs) -> Result<()> {
6974 use crate::client::{DeepSeekClient, SpeechSynthesisRequest};
6975 use crate::config::ApiProvider;
6976 use crate::tools::speech::{
6977 DEFAULT_VOICE, SPEECH_MODEL_EXAMPLES, combine_speech_instructions,
6978 default_speech_output_name, describe_speech_voice, encode_voice_clone_sample_data_uri,
6979 infer_speech_model, normalize_speech_format,
6980 };
6981
6982 let SpeechArgs {
6983 text,
6984 output,
6985 output_dir,
6986 model,
6987 voice,
6988 instruction,
6989 voice_prompt,
6990 clone_voice,
6991 format,
6992 json: json_output,
6993 } = args;
6994
6995 if config.api_provider() != ApiProvider::XiaomiMimo {
6996 bail!(
6997 "`speech` requires provider = \"xiaomi-mimo\" (current: {}). Run with `--provider xiaomi-mimo` or set it in config.",
6998 config.api_provider().as_str()
6999 );
7000 }
7001
7002 if text.trim().is_empty() {
7003 bail!("Speech text cannot be empty");
7004 }
7005 let voice_is_data_uri = voice
7006 .as_deref()
7007 .map(str::trim)
7008 .is_some_and(|value| value.starts_with("data:audio/"));
7009 if clone_voice.is_some() && voice.is_some() {
7010 bail!("Use either --clone-voice or --voice for cloned voice data, not both");
7011 }
7012 let model = infer_speech_model(
7013 model.as_deref(),
7014 clone_voice.is_some() || voice_is_data_uri,
7015 voice_prompt.is_some(),
7016 );
7017 let model_lower = model.to_ascii_lowercase();
7018 if !model_lower.contains("tts") {
7019 bail!(
7020 "speech requires a TTS model (examples: {}); got {model}",
7021 SPEECH_MODEL_EXAMPLES.join(", ")
7022 );
7023 }
7024 let is_voice_design = model_lower.contains("voicedesign");
7025 let is_voice_clone = model_lower.contains("voiceclone");
7026
7027 let instruction = combine_speech_instructions(instruction, voice_prompt);
7028 if is_voice_design
7029 && instruction
7030 .as_deref()
7031 .is_none_or(|value| value.trim().is_empty())
7032 {
7033 bail!(
7034 "mimo-v2.5-tts-voicedesign requires --voice-prompt or --instruction to describe the voice"
7035 );
7036 }
7037
7038 let voice = if let Some(clone_path) = clone_voice {
7039 Some(encode_voice_clone_sample_data_uri(&clone_path)?)
7040 } else if is_voice_design {
7041 None
7042 } else if let Some(value) = voice.filter(|value| !value.trim().is_empty()) {
7043 Some(value)
7044 } else if is_voice_clone {
7045 bail!("mimo-v2.5-tts-voiceclone requires --clone-voice <mp3|wav> or --voice <data-uri>");
7046 } else {
7047 Some(DEFAULT_VOICE.to_string())
7048 };
7049 let format = normalize_speech_format(&format).with_context(|| {
7050 format!("Unsupported speech format '{format}' (allowed: wav, mp3, pcm16)")
7051 })?;
7052 let output = output.unwrap_or_else(|| {
7053 output_dir
7054 .or_else(|| config.speech_output_dir())
7055 .unwrap_or_default()
7056 .join(default_speech_output_name(&format))
7057 });
7058
7059 let client = DeepSeekClient::new(config)?;
7060 let response = client
7061 .synthesize_speech(SpeechSynthesisRequest {
7062 model: model.clone(),
7063 text,
7064 instruction,
7065 audio_format: format.clone(),
7066 voice,
7067 })
7068 .await?;
7069
7070 if let Some(parent) = output.parent().filter(|path| !path.as_os_str().is_empty()) {
7071 std::fs::create_dir_all(parent)
7072 .with_context(|| format!("Failed to create output directory {}", parent.display()))?;
7073 }
7074 std::fs::write(&output, &response.audio_bytes)
7075 .with_context(|| format!("Failed to write audio file {}", output.display()))?;
7076
7077 if json_output {
7078 println!(
7079 "{}",
7080 serde_json::to_string_pretty(&serde_json::json!({
7081 "mode": "speech",
7082 "success": true,
7083 "model": response.model,
7084 "format": response.audio_format,
7085 "output": output.display().to_string(),
7086 "bytes": response.audio_bytes.len(),
7087 "voice": response.voice.as_deref().map(describe_speech_voice),
7088 "transcript": response.transcript,
7089 }))?
7090 );
7091 } else {
7092 println!(
7093 "Generated speech: {} ({} bytes, model: {}, format: {})",
7094 output.display(),
7095 response.audio_bytes.len(),
7096 response.model,
7097 response.audio_format
7098 );
7099 }
7100
7101 Ok(())
7102}
7103
7104#[cfg(test)]
7105mod speech_cli_tests {
7106 use super::*;
7107 use crate::tools::speech::{
7108 default_speech_output_name, infer_speech_model, normalize_speech_format,
7109 };
7110
7111 #[test]
7112 fn normalizes_documented_speech_formats() {
7113 assert_eq!(normalize_speech_format("WAV").as_deref(), Some("wav"));
7114 assert_eq!(normalize_speech_format("pcm16").as_deref(), Some("pcm16"));
7115 assert_eq!(normalize_speech_format("pcm").as_deref(), Some("pcm16"));
7116 assert_eq!(normalize_speech_format("flac"), None);
7117 }
7118
7119 #[test]
7120 fn default_speech_output_tracks_requested_format() {
7121 assert_eq!(
7122 PathBuf::from(default_speech_output_name("mp3")),
7123 PathBuf::from("speech.mp3")
7124 );
7125 assert_eq!(
7126 PathBuf::from("audio").join(default_speech_output_name("pcm")),
7127 PathBuf::from("audio").join("speech.pcm16")
7128 );
7129 }
7130
7131 #[test]
7132 fn speech_command_parses_cli_passthrough_smoke() {
7133 let cli = Cli::try_parse_from([
7134 "codewhale-tui",
7135 "speech",
7136 "hello",
7137 "--model",
7138 "tts",
7139 "--format",
7140 "pcm",
7141 "--output-dir",
7142 "audio",
7143 "--voice",
7144 "Mia",
7145 ])
7146 .expect("speech command parses");
7147
7148 let Some(Commands::Speech(args)) = cli.command else {
7149 panic!("expected speech command");
7150 };
7151 assert_eq!(args.text, "hello");
7152 assert_eq!(
7153 infer_speech_model(args.model.as_deref(), false, false),
7154 "mimo-v2.5-tts"
7155 );
7156 assert_eq!(
7157 normalize_speech_format(&args.format).as_deref(),
7158 Some("pcm16")
7159 );
7160 assert_eq!(args.output_dir, Some(PathBuf::from("audio")));
7161 assert_eq!(args.voice.as_deref(), Some("Mia"));
7162 }
7163}
7164
7165async fn test_api_connectivity(config: &Config) -> Result<()> {
7167 use crate::client::DeepSeekClient;
7168 use crate::models::{ContentBlock, Message, MessageRequest};
7169
7170 let client = DeepSeekClient::new(config)?;
7171 let model = client.model().to_string();
7172
7173 let request = MessageRequest {
7175 model: model.clone(),
7176 messages: vec![Message {
7177 role: "user".to_string(),
7178 content: vec![ContentBlock::Text {
7179 text: "hi".to_string(),
7180 cache_control: None,
7181 }],
7182 }],
7183 max_tokens: 1,
7184 system: None,
7185 tools: None,
7186 tool_choice: None,
7187 metadata: None,
7188 thinking: None,
7189 reasoning_effort: None,
7190 stream: Some(false),
7191 temperature: None,
7192 top_p: None,
7193 };
7194
7195 let timeout_duration = std::time::Duration::from_secs(15);
7197 match tokio::time::timeout(timeout_duration, client.create_message(request)).await {
7198 Ok(Ok(_response)) => Ok(()),
7199 Ok(Err(e)) => Err(e),
7200 Err(_) => anyhow::bail!("Request timeout after 15 seconds"),
7201 }
7202}
7203
7204fn rustc_version() -> String {
7205 let Some(mut cmd) = crate::dependencies::RustC::command() else {
7206 return "unknown".to_string();
7207 };
7208 let Ok(output) = cmd.arg("--version").output() else {
7209 return "unknown".to_string();
7210 };
7211 String::from_utf8(output.stdout)
7212 .map(|s| s.trim().to_string())
7213 .unwrap_or_else(|_| "unknown".to_string())
7214}
7215
7216fn sessions_resume_command() -> &'static str {
7218 "codewhale resume"
7219}
7220
7221fn list_sessions(limit: usize, search: Option<String>) -> Result<()> {
7222 use crate::palette;
7223 use colored::Colorize;
7224 use session_manager::{SessionManager, format_session_line};
7225
7226 let (action_r, action_g, action_b) = palette::WHALE_ACTION_RGB;
7227 let (human_r, human_g, human_b) = palette::WHALE_HUMAN_RGB;
7228 let (sky_r, sky_g, sky_b) = palette::WHALE_INFO_RGB;
7229 let (aqua_r, aqua_g, aqua_b) = palette::WHALE_INFO_RGB;
7230
7231 let manager = SessionManager::default_location()?;
7232
7233 let sessions = if let Some(query) = search {
7234 manager.search_sessions(&query)?
7235 } else {
7236 manager.list_sessions()?
7237 };
7238
7239 if sessions.is_empty() {
7240 println!("{}", "No sessions found.".truecolor(sky_r, sky_g, sky_b));
7241 println!(
7242 "Start a new session with: {}",
7243 "codewhale".truecolor(human_r, human_g, human_b)
7244 );
7245 return Ok(());
7246 }
7247
7248 println!(
7249 "{}",
7250 "Saved Sessions"
7251 .truecolor(action_r, action_g, action_b)
7252 .bold()
7253 );
7254 println!("{}", "==============".truecolor(sky_r, sky_g, sky_b));
7255 println!();
7256
7257 for (i, session) in sessions.iter().take(limit).enumerate() {
7258 let line = format_session_line(session);
7259 if i == 0 {
7260 println!(" {} {}", "*".truecolor(aqua_r, aqua_g, aqua_b), line);
7261 } else {
7262 println!(" {line}");
7263 }
7264 }
7265
7266 let total = sessions.len();
7267 if total > limit {
7268 println!();
7269 println!(
7270 " {} more session(s). Use --limit to show more.",
7271 total - limit
7272 );
7273 }
7274
7275 println!();
7276 println!(
7277 "Resume with: {} {}",
7278 sessions_resume_command().truecolor(action_r, action_g, action_b),
7279 "<session-id>".dimmed()
7280 );
7281 println!(
7282 "Continue latest in this workspace: {}",
7283 "codewhale --continue".truecolor(action_r, action_g, action_b)
7284 );
7285
7286 Ok(())
7287}
7288
7289fn init_project() -> Result<()> {
7291 use crate::palette;
7292 use colored::Colorize;
7293 use project_context::create_default_agents_md;
7294
7295 let (sky_r, sky_g, sky_b) = palette::WHALE_INFO_RGB;
7296 let (aqua_r, aqua_g, aqua_b) = palette::WHALE_INFO_RGB;
7297 let (red_r, red_g, red_b) = palette::WHALE_ERROR_RGB;
7298
7299 let workspace = std::env::current_dir()?;
7300 let agents_path = workspace.join("AGENTS.md");
7301
7302 if agents_path.exists() {
7303 println!(
7304 "{} AGENTS.md already exists at {}",
7305 "!".truecolor(sky_r, sky_g, sky_b),
7306 agents_path.display()
7307 );
7308 return Ok(());
7309 }
7310
7311 match create_default_agents_md(&workspace) {
7312 Ok(path) => {
7313 println!(
7314 "{} Created {}",
7315 "✓".truecolor(aqua_r, aqua_g, aqua_b),
7316 path.display()
7317 );
7318 println!();
7319 println!("Edit this file to customize how the AI agent works with your project.");
7320 println!("The instructions will be loaded automatically when you run codewhale.");
7321 }
7322 Err(e) => {
7323 println!(
7324 "{} Failed to create AGENTS.md: {}",
7325 "✗".truecolor(red_r, red_g, red_b),
7326 e
7327 );
7328 }
7329 }
7330
7331 Ok(())
7332}
7333
7334fn resolve_workspace(cli: &Cli) -> PathBuf {
7335 cli.workspace
7336 .clone()
7337 .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
7338}
7339
7340fn load_config_from_cli(cli: &Cli) -> Result<Config> {
7341 load_config_from_cli_with_effective_profile(cli).map(|(config, _)| config)
7342}
7343
7344fn load_doctor_config_from_cli(cli: &Cli, args: &DoctorArgs) -> Result<Config> {
7349 if args.probe_api || args.probe_local {
7350 return load_config_from_cli(cli);
7351 }
7352 load_structural_config_from_cli(cli)
7353}
7354
7355fn load_structural_config_from_cli(cli: &Cli) -> Result<Config> {
7356 let profile = effective_config_profile(cli);
7357 let mut config = Config::load_structural(cli.config.clone(), profile.as_deref())?;
7358 if let Ok(settings) = crate::settings::Settings::load_read_only() {
7359 apply_saved_reasoning_preference(&mut config, &settings);
7360 }
7361 cli.feature_toggles.apply(&mut config)?;
7362 Ok(config)
7363}
7364
7365fn effective_config_profile(cli: &Cli) -> Option<String> {
7366 cli.profile
7367 .clone()
7368 .or_else(|| std::env::var("CODEWHALE_PROFILE").ok())
7369 .or_else(|| std::env::var("DEEPSEEK_PROFILE").ok())
7370}
7371
7372fn load_config_from_cli_with_effective_profile(cli: &Cli) -> Result<(Config, Option<String>)> {
7373 let profile = effective_config_profile(cli);
7374 let mut config = Config::load(cli.config.clone(), profile.as_deref())?;
7375 if let Ok(settings) = crate::settings::Settings::load_read_only() {
7379 apply_saved_reasoning_preference(&mut config, &settings);
7380 }
7381 cli.feature_toggles.apply(&mut config)?;
7382 Ok((config, profile))
7383}
7384
7385fn apply_saved_reasoning_preference(config: &mut Config, settings: &crate::settings::Settings) {
7393 let Some(reasoning_effort) = settings.reasoning_effort.as_ref() else {
7394 return;
7395 };
7396 config.reasoning_effort = Some(reasoning_effort.clone());
7397 config.reasoning_effort_inferred_from_legacy_alias = false;
7398}
7399
7400fn read_api_key_from_stdin() -> Result<String> {
7401 let mut stdin = io::stdin();
7402 if stdin.is_terminal() {
7403 bail!("No API key provided. Pass --api-key or pipe one via stdin.");
7404 }
7405 let mut buffer = String::new();
7406 stdin.read_to_string(&mut buffer)?;
7407 let api_key = buffer.trim().to_string();
7408 if api_key.is_empty() {
7409 bail!("No API key provided via stdin.");
7410 }
7411 Ok(api_key)
7412}
7413
7414fn run_login(api_key: Option<String>) -> Result<()> {
7415 let api_key = match api_key {
7416 Some(key) => key,
7417 None => read_api_key_from_stdin()?,
7418 };
7419 let saved = config::save_api_key(&api_key)?;
7420 println!("Saved API key to {}", saved.describe());
7421 Ok(())
7422}
7423
7424fn run_logout() -> Result<()> {
7425 config::clear_api_key()?;
7426 println!("Cleared saved API key.");
7427 Ok(())
7428}
7429
7430async fn run_xai_device_auth(config_path: Option<&Path>) -> Result<()> {
7431 let pending = xai_oauth::device_code_login().await?;
7432 let activation = xai_oauth::activate_device_login(pending, config_path, None)?;
7433 println!(
7434 "xAI OAuth is ready; activated {} via {}",
7435 codewhale_config::quote_os_path(&activation.auth_path),
7436 codewhale_config::quote_os_path(&activation.config_path)
7437 );
7438 Ok(())
7439}
7440
7441fn resolve_session_id(session_id: Option<String>, last: bool, workspace: &Path) -> Result<String> {
7442 if last {
7443 return latest_session_id_for_workspace(workspace)?.ok_or_else(|| {
7444 anyhow!(
7445 "No saved sessions found for workspace {}. Use `codewhale sessions` to list all sessions, or `codewhale resume <SESSION_ID>` to resume one explicitly.",
7446 workspace.display()
7447 )
7448 });
7449 }
7450 if let Some(id) = session_id {
7451 return Ok(id);
7452 }
7453 pick_session_id()
7454}
7455
7456fn latest_session_id_for_workspace(workspace: &Path) -> std::io::Result<Option<String>> {
7457 let manager = SessionManager::default_location()?;
7458 Ok(manager
7459 .get_latest_session_for_workspace(workspace)?
7460 .map(|session| session.id))
7461}
7462
7463fn fork_session(
7464 config: &Config,
7465 session_id: Option<String>,
7466 last: bool,
7467 workspace: &Path,
7468) -> Result<String> {
7469 let manager = SessionManager::default_location()?;
7470 let saved = if last {
7471 let Some(meta) = manager.get_latest_session_for_workspace(workspace)? else {
7472 bail!(
7473 "No saved sessions found for workspace {}.",
7474 workspace.display()
7475 );
7476 };
7477 manager.load_session(&meta.id)?
7478 } else {
7479 let id = resolve_session_id(session_id, false, workspace)?;
7480 manager.load_session_by_prefix(&id)?
7481 };
7482 let saved_provider_identity = saved
7483 .metadata
7484 .model_provider_id
7485 .as_deref()
7486 .filter(|identity| !identity.trim().is_empty())
7487 .unwrap_or(&saved.metadata.model_provider);
7488 let provider_identity = config
7489 .resolve_persisted_provider_identity(
7490 Some(&saved.metadata.model_provider),
7491 saved.metadata.model_provider_id.as_deref(),
7492 )
7493 .map_err(anyhow::Error::msg)
7494 .with_context(|| {
7495 format!(
7496 "saved session provider '{}' is unavailable; fork will not fall back",
7497 saved_provider_identity
7498 )
7499 })?;
7500
7501 let system_prompt = saved
7502 .system_prompt
7503 .as_ref()
7504 .map(|text| SystemPrompt::Text(text.clone()));
7505 let mut forked = create_saved_session(
7506 &saved.messages,
7507 &saved.metadata.model,
7508 &saved.metadata.workspace,
7509 saved.metadata.total_tokens,
7510 system_prompt.as_ref(),
7511 );
7512 forked.metadata.set_model_provider_route(
7513 provider_identity.provider.as_str(),
7514 provider_identity.persisted_id(),
7515 );
7516 forked.metadata.copy_cost_from(&saved.metadata);
7517 forked.metadata.mark_forked_from(&saved.metadata);
7518 manager.save_session(&forked)?;
7519
7520 let source_title = saved.metadata.title.trim();
7521 let source_label = if source_title.is_empty() {
7522 "session".to_string()
7523 } else {
7524 format!("\"{source_title}\"")
7525 };
7526 println!(
7527 "Forked {source_label} ({source_id}) → new session {new_id}",
7528 source_id = truncate_id(&saved.metadata.id),
7529 new_id = truncate_id(&forked.metadata.id),
7530 );
7531
7532 Ok(forked.metadata.id)
7533}
7534
7535fn pick_session_id() -> Result<String> {
7536 let manager = SessionManager::default_location()?;
7537 let sessions = manager.list_sessions()?;
7538 if sessions.is_empty() {
7539 bail!("No saved sessions found.");
7540 }
7541
7542 println!("Select a session to resume:");
7543 for (idx, session) in sessions.iter().enumerate() {
7544 println!(" {:>2}. {} ({})", idx + 1, session.title, session.id);
7545 }
7546 print!("Enter a number (or press Enter to cancel): ");
7547 io::stdout().flush()?;
7548
7549 let mut input = String::new();
7550 io::stdin().read_line(&mut input)?;
7551 let input = input.trim();
7552 if input.is_empty() {
7553 bail!("No session selected.");
7554 }
7555 let idx: usize = input
7556 .parse()
7557 .map_err(|_| anyhow::anyhow!("Invalid input"))?;
7558 let session = sessions
7559 .get(idx.saturating_sub(1))
7560 .ok_or_else(|| anyhow::anyhow!("Selection out of range"))?;
7561 Ok(session.id.clone())
7562}
7563
7564async fn run_review(config: &Config, args: ReviewArgs) -> Result<()> {
7565 use crate::client::DeepSeekClient;
7566
7567 let diff = collect_diff(&args)?;
7568 if diff.trim().is_empty() {
7569 bail!("No diff to review.");
7570 }
7571 validate_review_receipt_args(&args)?;
7572 if args.check_receipt {
7573 return run_review_receipt_check(&diff, &args);
7574 }
7575
7576 let model = resolve_review_model(config, args.model.as_deref());
7577 let route = resolve_cli_exec_route(config, &model, &diff, args.model.is_none()).await?;
7578 let execution_config = config_for_cli_route(config, &route);
7579 let route_provider = execution_config.provider_identity_for(route.provider);
7580 let model = route.model.clone();
7581 let user_prompt =
7582 format!("Review the following diff and provide feedback:\n\n{diff}\n\nEnd of diff.");
7583 let reasoning_effort = route.reasoning_effort.and_then(|effort| {
7584 cli_reasoning_effort_value_for_prompt(&execution_config, &model, effort, &user_prompt)
7585 });
7586
7587 let system = SystemPrompt::Text(
7588 "You are a senior code reviewer. Focus on bugs, risks, behavioral regressions, and missing tests. \
7589Provide findings ordered by severity with file references, then open questions, then a brief summary."
7590 .to_string(),
7591 );
7592 let client = DeepSeekClient::new(&execution_config)?;
7593 let request = MessageRequest {
7594 model: model.clone(),
7595 messages: vec![Message {
7596 role: "user".to_string(),
7597 content: vec![ContentBlock::Text {
7598 text: user_prompt,
7599 cache_control: None,
7600 }],
7601 }],
7602 max_tokens: 4096,
7603 system: Some(system),
7604 tools: None,
7605 tool_choice: None,
7606 metadata: None,
7607 thinking: None,
7608 reasoning_effort,
7609 stream: Some(false),
7610 temperature: Some(0.2),
7611 top_p: Some(0.9),
7612 };
7613
7614 let response = client.create_message(request).await?;
7615 let mut output = String::new();
7616 for block in response.content {
7617 if let ContentBlock::Text { text, .. } = block {
7618 output.push_str(&text);
7619 }
7620 }
7621 let receipt = if args.write_receipt {
7622 let parsed_output = crate::tools::review::ReviewOutput::from_str(&output);
7623 let receipt = crate::tools::review::build_review_receipt(
7624 review_target_label(&args),
7625 &diff,
7626 &route_provider,
7627 &model,
7628 &parsed_output,
7629 &output,
7630 Vec::new(),
7631 );
7632 let path =
7633 crate::tools::review::write_review_receipt(&receipt, args.receipt_path.as_deref())?;
7634 Some((path, receipt))
7635 } else {
7636 None
7637 };
7638 if args.json {
7639 println!(
7640 "{}",
7641 serde_json::to_string_pretty(&serde_json::json!({
7642 "mode": "review",
7643 "provider": route_provider,
7644 "model": model,
7645 "success": true,
7646 "content": output,
7647 "receipt_path": receipt
7648 .as_ref()
7649 .map(|(path, _)| path.display().to_string()),
7650 "receipt": receipt.as_ref().map(|(_, receipt)| receipt),
7651 }))?
7652 );
7653 } else {
7654 println!("{output}");
7655 if let Some((path, _)) = receipt {
7656 eprintln!("Review receipt written: {}", path.display());
7657 }
7658 }
7659 Ok(())
7660}
7661
7662fn resolve_review_model(config: &Config, explicit_model: Option<&str>) -> String {
7663 explicit_model
7664 .map(str::trim)
7665 .filter(|model| !model.is_empty())
7666 .map(str::to_string)
7667 .unwrap_or_else(|| config.default_model())
7668}
7669
7670fn validate_review_receipt_args(args: &ReviewArgs) -> Result<()> {
7671 if args.receipt_path.is_some() && !args.write_receipt && !args.check_receipt {
7672 bail!("--receipt-path requires --write-receipt or --check-receipt");
7673 }
7674 if args.write_receipt && args.check_receipt {
7675 bail!("--write-receipt and --check-receipt are mutually exclusive");
7676 }
7677 Ok(())
7678}
7679
7680fn run_review_receipt_check(diff: &str, args: &ReviewArgs) -> Result<()> {
7681 let (path, receipt) = if let Some(path) = args.receipt_path.as_ref() {
7682 (
7683 path.clone(),
7684 crate::tools::review::read_review_receipt(path)
7685 .with_context(|| format!("failed to read review receipt {}", path.display()))?,
7686 )
7687 } else {
7688 crate::tools::review::latest_review_receipt_for_diff(diff)?.ok_or_else(|| {
7689 anyhow!(
7690 "No review receipt found for the current diff. Run `codewhale review --write-receipt` first, or pass --receipt-path."
7691 )
7692 })?
7693 };
7694 let validation =
7695 crate::tools::review::validate_review_receipt_for_diff(diff, &receipt, Some(path.clone()));
7696
7697 if args.json {
7698 println!(
7699 "{}",
7700 serde_json::to_string_pretty(&serde_json::json!({
7701 "mode": "review_receipt_check",
7702 "success": validation.passed,
7703 "validation": review_receipt_validation_public_json(&validation),
7704 }))?
7705 );
7706 } else if validation.passed {
7707 println!("Review receipt valid: {}", path.display());
7708 }
7709
7710 if !validation.passed {
7711 bail!("Review receipt check failed: {}", validation.reason);
7712 }
7713 Ok(())
7714}
7715
7716fn review_receipt_validation_public_json(
7717 validation: &crate::tools::review::ReviewReceiptValidation,
7718) -> serde_json::Value {
7719 let unresolved_risk = validation.unresolved_risk.as_ref();
7720 serde_json::json!({
7721 "passed": validation.passed,
7722 "status": review_receipt_validation_status(validation),
7723 "diff_fingerprint": validation.diff_fingerprint.as_str(),
7724 "receipt_fingerprint": validation.receipt_fingerprint.as_deref(),
7725 "unresolved": unresolved_risk.is_some_and(|risk| risk.unresolved),
7726 "risk_level": unresolved_risk.map(|risk| risk.level.as_str()),
7727 })
7728}
7729
7730fn review_receipt_validation_status(
7731 validation: &crate::tools::review::ReviewReceiptValidation,
7732) -> &'static str {
7733 if validation.passed {
7734 "valid"
7735 } else if validation
7736 .receipt_fingerprint
7737 .as_deref()
7738 .is_some_and(|fingerprint| fingerprint != validation.diff_fingerprint.as_str())
7739 {
7740 "diff_mismatch"
7741 } else if validation
7742 .unresolved_risk
7743 .as_ref()
7744 .is_some_and(|risk| risk.unresolved)
7745 {
7746 "unresolved_risk"
7747 } else if validation
7748 .reason
7749 .starts_with("unsupported review receipt schema version")
7750 {
7751 "unsupported_schema"
7752 } else if validation.reason.starts_with("review receipt check ") {
7753 "check_failed"
7754 } else {
7755 "invalid"
7756 }
7757}
7758
7759async fn run_pr(
7763 cli: &Cli,
7764 config: &Config,
7765 number: u32,
7766 repo: Option<&str>,
7767 checkout: bool,
7768 plugin_registry: Arc<crate::plugins::PluginRegistry>,
7769) -> Result<()> {
7770 if !is_command_available("gh") {
7771 bail!(
7772 "`gh` CLI not found on PATH. Install GitHub CLI \
7773 (https://cli.github.com) and authenticate (`gh auth login`) \
7774 so `codewhale pr <N>` can fetch PR metadata and the diff."
7775 );
7776 }
7777
7778 let view = run_gh_pr_view(number, repo)?;
7779 let diff = run_gh_pr_diff(number, repo)?;
7780
7781 if checkout {
7782 match run_gh_pr_checkout(number, repo) {
7783 Ok(()) => eprintln!("Checked out PR #{number} into the current workspace."),
7784 Err(err) => eprintln!(
7785 "warning: gh pr checkout #{number} failed ({err}). Continuing without checkout."
7786 ),
7787 }
7788 }
7789
7790 let prompt = format_pr_prompt(number, &view, &diff);
7791 let resume_session_id = if cli.continue_session {
7792 let workspace = resolve_workspace(cli);
7793 latest_session_id_for_workspace(&workspace).ok().flatten()
7794 } else {
7795 cli.resume.clone()
7796 };
7797 run_interactive(
7798 cli,
7799 config,
7800 resume_session_id,
7801 Some(tui::InitialInput::Prefill(prompt)),
7802 plugin_registry,
7803 )
7804 .await
7805}
7806
7807fn is_command_available(name: &str) -> bool {
7821 let Some(path) = std::env::var_os("PATH") else {
7822 return false;
7823 };
7824 for dir in std::env::split_paths(&path) {
7825 let candidate = dir.join(name);
7826 if candidate.is_file() {
7827 return true;
7828 }
7829 #[cfg(windows)]
7830 {
7831 if candidate.extension().is_none() && candidate.with_extension("exe").is_file() {
7835 return true;
7836 }
7837 }
7838 }
7839 false
7840}
7841
7842#[derive(Debug, Clone, Default)]
7843struct GhPullRequest {
7844 title: String,
7845 body: String,
7846 base: String,
7847 head: String,
7848 url: String,
7849}
7850
7851fn run_gh_pr_view(number: u32, repo: Option<&str>) -> Result<GhPullRequest> {
7852 let mut cmd = crate::dependencies::Gh::command()
7853 .ok_or_else(|| anyhow::anyhow!("gh not found on PATH"))?;
7854 cmd.arg("pr").arg("view").arg(number.to_string());
7855 if let Some(r) = repo {
7856 cmd.arg("--repo").arg(r);
7857 }
7858 cmd.arg("--json")
7859 .arg("title,body,baseRefName,headRefName,url");
7860 let output = cmd
7861 .output()
7862 .map_err(|e| anyhow::anyhow!("Failed to run `gh pr view`: {e}"))?;
7863 if !output.status.success() {
7864 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
7865 bail!("gh pr view #{number} failed: {stderr}");
7866 }
7867 let raw = String::from_utf8_lossy(&output.stdout).to_string();
7868 let value: serde_json::Value = serde_json::from_str(&raw)
7869 .map_err(|e| anyhow::anyhow!("gh pr view returned non-JSON output: {e}"))?;
7870 let pick = |key: &str| {
7871 value
7872 .get(key)
7873 .and_then(serde_json::Value::as_str)
7874 .unwrap_or_default()
7875 .to_string()
7876 };
7877 Ok(GhPullRequest {
7878 title: pick("title"),
7879 body: pick("body"),
7880 base: pick("baseRefName"),
7881 head: pick("headRefName"),
7882 url: pick("url"),
7883 })
7884}
7885
7886fn run_gh_pr_diff(number: u32, repo: Option<&str>) -> Result<String> {
7887 let mut cmd = crate::dependencies::Gh::command()
7888 .ok_or_else(|| anyhow::anyhow!("gh not found on PATH"))?;
7889 cmd.arg("pr").arg("diff").arg(number.to_string());
7890 if let Some(r) = repo {
7891 cmd.arg("--repo").arg(r);
7892 }
7893 let output = cmd
7894 .output()
7895 .map_err(|e| anyhow::anyhow!("Failed to run `gh pr diff`: {e}"))?;
7896 if !output.status.success() {
7897 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
7898 bail!("gh pr diff #{number} failed: {stderr}");
7899 }
7900 Ok(String::from_utf8_lossy(&output.stdout).to_string())
7901}
7902
7903fn run_gh_pr_checkout(number: u32, repo: Option<&str>) -> Result<()> {
7904 let mut cmd = crate::dependencies::Gh::command()
7905 .ok_or_else(|| anyhow::anyhow!("gh not found on PATH"))?;
7906 cmd.arg("pr").arg("checkout").arg(number.to_string());
7907 if let Some(r) = repo {
7908 cmd.arg("--repo").arg(r);
7909 }
7910 let output = cmd
7911 .output()
7912 .map_err(|e| anyhow::anyhow!("Failed to run `gh pr checkout`: {e}"))?;
7913 if !output.status.success() {
7914 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
7915 bail!("gh pr checkout #{number} failed: {stderr}");
7916 }
7917 Ok(())
7918}
7919
7920fn format_pr_prompt(number: u32, view: &GhPullRequest, diff: &str) -> String {
7925 const MAX_DIFF_BYTES: usize = 200 * 1024;
7926 let diff_section = if diff.len() > MAX_DIFF_BYTES {
7927 let cut = (0..=MAX_DIFF_BYTES)
7928 .rev()
7929 .find(|&i| diff.is_char_boundary(i))
7930 .unwrap_or(0);
7931 format!(
7932 "{}\n\n[…diff truncated at {} KiB; ask me to fetch more if needed]\n",
7933 &diff[..cut],
7934 MAX_DIFF_BYTES / 1024
7935 )
7936 } else {
7937 diff.to_string()
7938 };
7939 let body = if view.body.trim().is_empty() {
7940 "(no description)".to_string()
7941 } else {
7942 view.body.trim().to_string()
7943 };
7944 let title = if view.title.trim().is_empty() {
7945 format!("(PR #{number})")
7946 } else {
7947 view.title.trim().to_string()
7948 };
7949 let branches = match (view.base.is_empty(), view.head.is_empty()) {
7950 (false, false) => format!("{} ← {}", view.base, view.head),
7951 (false, true) => view.base.clone(),
7952 (true, false) => view.head.clone(),
7953 _ => "(unknown)".to_string(),
7954 };
7955 format!(
7956 "Review PR #{number} — {title}\n\
7957 \n\
7958 URL: {url}\n\
7959 Branches: {branches}\n\
7960 \n\
7961 ## Description\n\
7962 \n\
7963 {body}\n\
7964 \n\
7965 ## Diff\n\
7966 \n\
7967 ```diff\n\
7968 {diff_section}\n\
7969 ```\n",
7970 url = if view.url.is_empty() {
7971 "(unavailable)"
7972 } else {
7973 view.url.as_str()
7974 },
7975 )
7976}
7977
7978fn collect_diff(args: &ReviewArgs) -> Result<String> {
7979 let mut cmd = crate::dependencies::Git::command()
7980 .ok_or_else(|| anyhow::anyhow!("git not found on PATH"))?;
7981 cmd.arg("diff");
7982 if args.staged {
7983 cmd.arg("--cached");
7984 }
7985 if let Some(base) = &args.base {
7986 cmd.arg(format!("{base}...HEAD"));
7987 }
7988 if let Some(path) = &args.path {
7989 cmd.arg("--").arg(path);
7990 }
7991
7992 let output = cmd
7993 .output()
7994 .map_err(|e| anyhow::anyhow!("Failed to run git diff. Is git installed? ({e})"))?;
7995 if !output.status.success() {
7996 let stderr = String::from_utf8_lossy(&output.stderr);
7997 bail!("git diff failed: {}", stderr.trim());
7998 }
7999 let mut diff = String::from_utf8_lossy(&output.stdout).to_string();
8000 if diff.len() > args.max_chars {
8001 diff = crate::utils::truncate_with_ellipsis(&diff, args.max_chars, "\n...[truncated]\n");
8002 }
8003 Ok(diff)
8004}
8005
8006fn review_target_label(args: &ReviewArgs) -> String {
8007 let mut label = if args.staged {
8008 "staged".to_string()
8009 } else if let Some(base) = args
8010 .base
8011 .as_deref()
8012 .map(str::trim)
8013 .filter(|base| !base.is_empty())
8014 {
8015 format!("base:{base}")
8016 } else {
8017 "working-tree".to_string()
8018 };
8019 if let Some(path) = &args.path {
8020 label.push(' ');
8021 label.push_str(path.to_string_lossy().as_ref());
8022 }
8023 label
8024}
8025
8026fn run_apply(args: ApplyArgs) -> Result<()> {
8027 let patch = if let Some(path) = args.patch_file {
8028 std::fs::read_to_string(&path)
8029 .map_err(|e| anyhow::anyhow!("Failed to read patch {}: {}", path.display(), e))?
8030 } else {
8031 read_patch_from_stdin()?
8032 };
8033 if patch.trim().is_empty() {
8034 bail!("Patch is empty.");
8035 }
8036
8037 let mut tmp = NamedTempFile::new()?;
8038 tmp.write_all(patch.as_bytes())?;
8039 let tmp_path = tmp.path().to_path_buf();
8040
8041 let output = crate::dependencies::Git::command()
8042 .ok_or_else(|| anyhow::anyhow!("git not found on PATH"))?
8043 .arg("apply")
8044 .arg("--whitespace=nowarn")
8045 .arg(&tmp_path)
8046 .output()
8047 .map_err(|e| anyhow::anyhow!("Failed to run git apply: {e}"))?;
8048
8049 if !output.status.success() {
8050 let stderr = String::from_utf8_lossy(&output.stderr);
8051 bail!("git apply failed: {}", stderr.trim());
8052 }
8053 println!("Applied patch successfully.");
8054 Ok(())
8055}
8056
8057fn read_patch_from_stdin() -> Result<String> {
8058 let mut stdin = io::stdin();
8059 if stdin.is_terminal() {
8060 bail!("No patch file provided and stdin is empty.");
8061 }
8062 let mut buffer = String::new();
8063 stdin.read_to_string(&mut buffer)?;
8064 Ok(buffer)
8065}
8066
8067async fn run_mcp_command(
8068 config: &Config,
8069 workspace: &Path,
8070 command: McpCommand,
8071 plugins: &crate::plugins::PluginRegistry,
8072) -> Result<()> {
8073 let config_path = config.mcp_config_path();
8074 match command {
8075 McpCommand::Init { force } => {
8076 let status = init_mcp_config(&config_path, force)?;
8077 match status {
8078 WriteStatus::Created => {
8079 println!("Created MCP config at {}", config_path.display());
8080 }
8081 WriteStatus::Overwritten => {
8082 println!("Overwrote MCP config at {}", config_path.display());
8083 }
8084 WriteStatus::SkippedExists => {
8085 println!(
8086 "MCP config already exists at {} (use --force to overwrite)",
8087 config_path.display()
8088 );
8089 }
8090 }
8091 println!("Edit the file, then run `codewhale mcp list` or `codewhale mcp tools`.");
8092 Ok(())
8093 }
8094 McpCommand::List => {
8095 let cfg = crate::mcp::load_config_with_workspace_and_plugins(
8096 &config_path,
8097 workspace,
8098 plugins,
8099 )?;
8100 if cfg.servers.is_empty() {
8101 println!(
8102 "No MCP servers configured in {} or {}",
8103 config_path.display(),
8104 crate::mcp::workspace_mcp_config_path(workspace).display()
8105 );
8106 return Ok(());
8107 }
8108 println!("MCP servers ({}):", cfg.servers.len());
8109 for (name, server) in cfg.servers {
8110 let status = if server.enabled && !server.disabled {
8111 "enabled"
8112 } else {
8113 "disabled"
8114 };
8115 let auth_status = crate::mcp::oauth::auth_status_for_server(&name, &server).await;
8116 let auth = if auth_status == crate::mcp::oauth::McpAuthStatus::Unsupported {
8117 String::new()
8118 } else {
8119 format!(
8120 " auth={}",
8121 auth_status
8122 .to_string()
8123 .to_ascii_lowercase()
8124 .replace(' ', "-")
8125 )
8126 };
8127 let args = if server.args.is_empty() {
8128 "".to_string()
8129 } else {
8130 format!(" {}", server.args.join(" "))
8131 };
8132 let cmd_str = if let Some(cmd) = server.command {
8133 format!("{cmd}{args}")
8134 } else if let Some(url) = server.url {
8135 url
8136 } else {
8137 "unknown".to_string()
8138 };
8139 let required = if server.required { " required" } else { "" };
8140 println!(" - {name} [{status}{required}{auth}] {cmd_str}");
8141 }
8142 Ok(())
8143 }
8144 McpCommand::Connect { server } => {
8145 let mut pool = McpPool::from_config_path_with_workspace_and_plugins(
8146 &config_path,
8147 workspace,
8148 std::sync::Arc::new(plugins.clone()),
8149 )?;
8150 if let Some(name) = server {
8151 if let Err(err) = pool.get_or_connect(&name).await {
8152 if crate::mcp::oauth::error_looks_auth_required(&err) {
8153 let hint = crate::mcp::oauth::auth_required_login_hint(&name);
8154 return Err(err).context(hint);
8155 }
8156 return Err(err);
8157 }
8158 println!("Connected to MCP server: {name}");
8159 } else {
8160 let errors = pool.connect_all().await;
8161 if errors.is_empty() {
8162 println!("Connected to all configured MCP servers.");
8163 } else {
8164 for (name, err) in errors {
8165 eprintln!("Failed to connect {name}: {err:#}");
8166 if crate::mcp::oauth::error_looks_auth_required(&err) {
8167 eprintln!(" {}", crate::mcp::oauth::auth_required_login_hint(&name));
8168 }
8169 }
8170 }
8171 }
8172 Ok(())
8173 }
8174 McpCommand::Tools { server } => {
8175 let mut pool = McpPool::from_config_path_with_workspace_and_plugins(
8176 &config_path,
8177 workspace,
8178 std::sync::Arc::new(plugins.clone()),
8179 )?;
8180 if let Some(name) = server {
8181 let conn = match pool.get_or_connect(&name).await {
8182 Ok(conn) => conn,
8183 Err(err) => {
8184 if crate::mcp::oauth::error_looks_auth_required(&err) {
8185 let hint = crate::mcp::oauth::auth_required_login_hint(&name);
8186 return Err(err).context(hint);
8187 }
8188 return Err(err);
8189 }
8190 };
8191 if conn.tools().is_empty() {
8192 println!("No tools found for MCP server: {name}");
8193 } else {
8194 println!("Tools for {name}:");
8195 for tool in conn.tools() {
8196 println!(
8197 " - {}{}",
8198 tool.name,
8199 crate::mcp::format_mcp_tool_description(tool.description.as_deref())
8200 );
8201 }
8202 }
8203 } else {
8204 let errors = pool.connect_all().await;
8205 for (name, err) in errors {
8206 eprintln!("Failed to connect {name}: {err:#}");
8207 if crate::mcp::oauth::error_looks_auth_required(&err) {
8208 eprintln!(" {}", crate::mcp::oauth::auth_required_login_hint(&name));
8209 }
8210 }
8211 let tools = pool.all_tools();
8212 if tools.is_empty() {
8213 println!("No MCP tools discovered.");
8214 } else {
8215 println!("MCP tools:");
8216 for (name, tool) in tools {
8217 println!(
8218 " - {}{}",
8219 name,
8220 crate::mcp::format_mcp_tool_description(tool.description.as_deref())
8221 );
8222 }
8223 }
8224 }
8225 Ok(())
8226 }
8227 McpCommand::Add {
8228 name,
8229 command,
8230 url,
8231 transport,
8232 bearer_token_env_var,
8233 oauth_client_id,
8234 oauth_resource,
8235 scopes,
8236 args,
8237 } => {
8238 if command.is_none() && url.is_none() {
8239 bail!("Provide either --command or --url for `mcp add`.");
8240 }
8241 if let Some(transport) = transport.as_deref()
8242 && !transport.trim().eq_ignore_ascii_case("sse")
8243 {
8244 bail!("Unsupported MCP transport '{transport}'. Supported values: sse");
8245 }
8246 let added_server = McpServerConfig {
8247 command,
8248 args,
8249 env: std::collections::HashMap::new(),
8250 cwd: None,
8251 url,
8252 transport,
8253 connect_timeout: None,
8254 execute_timeout: None,
8255 read_timeout: None,
8256 disabled: false,
8257 enabled: true,
8258 required: false,
8259 enabled_tools: Vec::new(),
8260 disabled_tools: Vec::new(),
8261 headers: std::collections::HashMap::new(),
8262 env_headers: std::collections::HashMap::new(),
8263 bearer_token_env_var,
8264 scopes,
8265 oauth: oauth_client_id.map(|client_id| McpServerOAuthConfig {
8266 client_id: Some(client_id),
8267 }),
8268 oauth_resource,
8269 reviewed_plugin: None,
8270 };
8271 let can_suggest_oauth = added_server.url.is_some()
8272 && added_server.bearer_token_env_var.is_none()
8273 && added_server
8274 .headers
8275 .keys()
8276 .all(|key| !key.trim().eq_ignore_ascii_case("authorization"))
8277 && added_server
8278 .env_headers
8279 .keys()
8280 .all(|key| !key.trim().eq_ignore_ascii_case("authorization"));
8281 let mut cfg = load_mcp_config(&config_path)?;
8282 cfg.servers.insert(name.clone(), added_server.clone());
8283 save_mcp_config(&config_path, &cfg)?;
8284 println!("Added MCP server '{name}' in {}", config_path.display());
8285 if can_suggest_oauth
8286 && crate::mcp::oauth::oauth_login_support(&added_server)
8287 .await
8288 .is_ok_and(|support| support.is_some())
8289 {
8290 println!(
8291 "OAuth is available for '{name}'. Run `codewhale mcp login {name}` to authenticate."
8292 );
8293 }
8294 Ok(())
8295 }
8296 McpCommand::Login { name, scopes } => {
8297 let cfg = crate::mcp::load_config_with_workspace_and_plugins(
8298 &config_path,
8299 workspace,
8300 plugins,
8301 )?;
8302 let server = cfg
8303 .servers
8304 .get(&name)
8305 .ok_or_else(|| anyhow!("MCP server '{name}' not found"))?;
8306 let explicit_scopes = (!scopes.is_empty()).then_some(scopes);
8307 crate::mcp::oauth::perform_oauth_login_for_server(
8308 &name,
8309 server,
8310 explicit_scopes,
8311 config.mcp_oauth_callback_port,
8312 config.mcp_oauth_callback_url.as_deref(),
8313 )
8314 .await?;
8315 println!("Stored OAuth credentials for MCP server '{name}'.");
8316 Ok(())
8317 }
8318 McpCommand::Logout { name } => {
8319 let cfg = crate::mcp::load_config_with_workspace_and_plugins(
8320 &config_path,
8321 workspace,
8322 plugins,
8323 )?;
8324 let server = cfg
8325 .servers
8326 .get(&name)
8327 .ok_or_else(|| anyhow!("MCP server '{name}' not found"))?;
8328 if crate::mcp::oauth::delete_oauth_tokens_for_server(&name, server)? {
8329 println!("Deleted stored OAuth credentials for MCP server '{name}'.");
8330 } else {
8331 println!("No stored OAuth credentials found for MCP server '{name}'.");
8332 }
8333 Ok(())
8334 }
8335 McpCommand::Remove { name } => {
8336 let mut cfg = load_mcp_config(&config_path)?;
8337 if cfg.servers.remove(&name).is_none() {
8338 bail!("MCP server '{name}' not found");
8339 }
8340 save_mcp_config(&config_path, &cfg)?;
8341 println!("Removed MCP server '{name}'");
8342 Ok(())
8343 }
8344 McpCommand::Enable { name } => {
8345 let mut cfg = load_mcp_config(&config_path)?;
8346 let server = cfg
8347 .servers
8348 .get_mut(&name)
8349 .ok_or_else(|| anyhow!("MCP server '{name}' not found"))?;
8350 server.enabled = true;
8351 server.disabled = false;
8352 save_mcp_config(&config_path, &cfg)?;
8353 println!("Enabled MCP server '{name}'");
8354 Ok(())
8355 }
8356 McpCommand::Disable { name } => {
8357 let mut cfg = load_mcp_config(&config_path)?;
8358 let server = cfg
8359 .servers
8360 .get_mut(&name)
8361 .ok_or_else(|| anyhow!("MCP server '{name}' not found"))?;
8362 server.enabled = false;
8363 server.disabled = true;
8364 save_mcp_config(&config_path, &cfg)?;
8365 println!("Disabled MCP server '{name}'");
8366 Ok(())
8367 }
8368 McpCommand::Validate => {
8369 let mut pool = McpPool::from_config_path_with_workspace_and_plugins(
8370 &config_path,
8371 workspace,
8372 std::sync::Arc::new(plugins.clone()),
8373 )?;
8374 let errors = pool.connect_all().await;
8375 if errors.is_empty() {
8376 println!("MCP config is valid. All enabled servers connected.");
8377 return Ok(());
8378 }
8379 eprintln!("MCP validation failed:");
8380 for (name, err) in errors {
8381 eprintln!(" - {name}: {err:#}");
8382 }
8383 bail!("one or more MCP servers failed validation");
8384 }
8385 McpCommand::AddSelf { name, workspace } => {
8386 let exe_path = std::env::current_exe()
8387 .map_err(|e| anyhow!("Cannot resolve current binary path: {e}"))?;
8388 let exe_str = exe_path.to_string_lossy().to_string();
8389
8390 let mut args = vec!["serve".to_string(), "--mcp".to_string()];
8391 if let Some(ref ws) = workspace {
8392 args.push("--workspace".to_string());
8393 args.push(ws.clone());
8394 }
8395
8396 let mut cfg = load_mcp_config(&config_path)?;
8397 if cfg.servers.contains_key(&name) {
8398 bail!(
8399 "MCP server '{name}' already exists in {}. Use `codewhale mcp remove {name}` first, or choose a different --name.",
8400 config_path.display()
8401 );
8402 }
8403 cfg.servers.insert(
8404 name.clone(),
8405 McpServerConfig {
8406 command: Some(exe_str.clone()),
8407 args,
8408 env: std::collections::HashMap::new(),
8409 cwd: None,
8410 url: None,
8411 transport: None,
8412 connect_timeout: None,
8413 execute_timeout: None,
8414 read_timeout: None,
8415 disabled: false,
8416 enabled: true,
8417 required: false,
8418 enabled_tools: Vec::new(),
8419 disabled_tools: Vec::new(),
8420 headers: std::collections::HashMap::new(),
8421 env_headers: std::collections::HashMap::new(),
8422 bearer_token_env_var: None,
8423 scopes: Vec::new(),
8424 oauth: None,
8425 oauth_resource: None,
8426 reviewed_plugin: None,
8427 },
8428 );
8429 save_mcp_config(&config_path, &cfg)?;
8430 println!(
8431 "Registered Codewhale as MCP server '{name}' in {}",
8432 config_path.display()
8433 );
8434 println!(" command: {exe_str}");
8435 println!(
8436 " args: serve --mcp{}",
8437 workspace.map_or(String::new(), |ws| format!(" --workspace {ws}"))
8438 );
8439 println!();
8440 println!("Tip: Use `codewhale mcp validate` to test the connection.");
8441 println!(" Use `codewhale serve --http` for the HTTP/SSE runtime API instead.");
8442 Ok(())
8443 }
8444 }
8445}
8446
8447fn load_mcp_config(path: &Path) -> Result<McpConfig> {
8448 if !path.exists() {
8449 return Ok(McpConfig::default());
8450 }
8451 let contents = std::fs::read_to_string(path)
8452 .map_err(|e| anyhow::anyhow!("Failed to read MCP config {}: {}", path.display(), e))?;
8453 let cfg: McpConfig = serde_json::from_str(&contents).map_err(|_| {
8454 anyhow::anyhow!(
8455 "Failed to parse MCP config {}; file contents were omitted",
8456 codewhale_config::quote_os_path(path)
8457 )
8458 })?;
8459 Ok(cfg)
8460}
8461
8462#[derive(Debug)]
8464enum McpServerDoctorStatus {
8465 Ok(String),
8466 Warning(String),
8467 Error(String),
8468}
8469
8470impl McpServerDoctorStatus {
8471 fn legacy_status(&self) -> &'static str {
8472 match self {
8473 Self::Ok(_) => "ok",
8474 Self::Warning(_) => "warning",
8475 Self::Error(_) => "error",
8476 }
8477 }
8478
8479 fn configuration_status(&self) -> &'static str {
8480 match self {
8481 Self::Ok(_) => "valid",
8482 Self::Warning(_) => "warning",
8483 Self::Error(_) => "invalid",
8484 }
8485 }
8486
8487 fn detail(&self) -> &str {
8488 match self {
8489 Self::Ok(detail) | Self::Warning(detail) | Self::Error(detail) => detail,
8490 }
8491 }
8492}
8493
8494fn doctor_mcp_command_status(server: &McpServerConfig) -> McpCommandAvailability {
8496 if server.url.is_some() {
8497 return McpCommandAvailability::NotApplicable;
8498 }
8499 match server.command.as_deref() {
8500 Some("") => McpCommandAvailability::Missing,
8501 Some(_) | None => McpCommandAvailability::NotChecked,
8502 }
8503}
8504
8505fn doctor_mcp_server_json(name: &str, server: &McpServerConfig) -> serde_json::Value {
8506 use serde_json::json;
8507
8508 let status = doctor_check_mcp_server(server);
8509 json!({
8510 "name": name,
8511 "enabled": server.enabled && !server.disabled,
8512 "status": status.legacy_status(),
8515 "detail": status.detail(),
8516 "transport": if server.url.is_some() { "http" } else { "stdio" },
8517 "endpoint": server.url.as_deref().map(crate::doctor::structural_url_authority),
8518 "command_configured": server.command.is_some(),
8519 "args_count": server.args.len(),
8520 "env_count": server.env.len(),
8521 "headers_count": server.headers.len(),
8522 "env_headers_count": server.env_headers.len(),
8523 "check_scope": "configuration",
8524 "checks": {
8525 "configuration": {
8526 "status": status.configuration_status(),
8527 "detail": status.detail(),
8528 },
8529 "command": {
8530 "status": doctor_mcp_command_status(server).as_str(),
8531 },
8532 "process_reachable": {
8533 "status": "not_checked",
8534 },
8535 "protocol_initialized": {
8536 "status": "not_checked",
8537 },
8538 "backend_tool_health": {
8539 "status": "not_checked",
8540 },
8541 },
8542 })
8543}
8544
8545fn doctor_check_mcp_server(server: &McpServerConfig) -> McpServerDoctorStatus {
8547 if server.command.is_none() && server.url.is_none() {
8549 return McpServerDoctorStatus::Error("no command or url configured".to_string());
8550 }
8551
8552 if let Some(ref url) = server.url {
8554 let authority = crate::doctor::structural_url_authority(url);
8555 return if authority.starts_with("unparseable") {
8556 McpServerDoctorStatus::Warning(
8557 "HTTP/SSE server URL is invalid; configured value omitted".to_string(),
8558 )
8559 } else {
8560 McpServerDoctorStatus::Ok(format!("HTTP/SSE server at {authority}"))
8561 };
8562 }
8563
8564 let cmd = server.command.as_deref().unwrap_or("");
8566 if cmd.is_empty() {
8567 return McpServerDoctorStatus::Error("empty command".to_string());
8568 }
8569
8570 if server.cwd.is_none() {
8571 if is_relative_stdio_path_arg(cmd) {
8572 return McpServerDoctorStatus::Warning(
8573 "stdio server uses a relative command without cwd; command value omitted"
8574 .to_string(),
8575 );
8576 }
8577 if server
8578 .args
8579 .iter()
8580 .any(|arg| is_relative_stdio_path_arg(arg))
8581 {
8582 return McpServerDoctorStatus::Warning(
8583 "stdio server uses a relative path argument without cwd; argument values omitted"
8584 .to_string(),
8585 );
8586 }
8587 }
8588
8589 McpServerDoctorStatus::Ok(format!(
8590 "stdio server configured (command omitted; {} argument(s), {} environment binding(s))",
8591 server.args.len(),
8592 server.env.len()
8593 ))
8594}
8595
8596fn save_mcp_config(path: &Path, cfg: &McpConfig) -> Result<()> {
8597 if let Some(parent) = path.parent() {
8598 std::fs::create_dir_all(parent).with_context(|| {
8599 format!("Failed to create MCP config directory {}", parent.display())
8600 })?;
8601 }
8602 let rendered = serde_json::to_string_pretty(cfg)
8603 .map_err(|e| anyhow!("Failed to serialize MCP config: {e}"))?;
8604 crate::utils::write_atomic(path, rendered.as_bytes())
8605 .map_err(|e| anyhow!("Failed to write MCP config {}: {}", path.display(), e))?;
8606 Ok(())
8607}
8608
8609fn run_sandbox_command(args: SandboxArgs) -> Result<()> {
8610 use crate::sandbox::{CommandSpec, SandboxManager};
8611
8612 let SandboxCommand::Run {
8613 policy,
8614 network,
8615 writable_root,
8616 exclude_tmpdir,
8617 exclude_slash_tmp,
8618 cwd,
8619 timeout_ms,
8620 command,
8621 } = args.command;
8622
8623 let policy = parse_sandbox_policy(
8624 &policy,
8625 network,
8626 writable_root,
8627 exclude_tmpdir,
8628 exclude_slash_tmp,
8629 )?;
8630 let cwd = cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
8631 let timeout = Duration::from_millis(timeout_ms.clamp(1000, 600_000));
8632
8633 let (program, args) = command
8634 .split_first()
8635 .ok_or_else(|| anyhow::anyhow!("Command is required"))?;
8636 let spec =
8637 CommandSpec::program(program, args.to_vec(), cwd.clone(), timeout).with_policy(policy);
8638 let manager = SandboxManager::new();
8639 let exec_env = manager.prepare(&spec);
8640
8641 let mut cmd = Command::new(exec_env.program());
8642 cmd.args(exec_env.args())
8643 .current_dir(&exec_env.cwd)
8644 .stdout(Stdio::piped())
8645 .stderr(Stdio::piped());
8646 child_env::apply_to_command(&mut cmd, child_env::string_map_env(&exec_env.env));
8647
8648 let mut child = cmd
8649 .spawn()
8650 .map_err(|e| anyhow::anyhow!("Failed to run command: {e}"))?;
8651 let stdout_handle = child
8652 .stdout
8653 .take()
8654 .ok_or_else(|| anyhow::anyhow!("stdout unavailable"))?;
8655 let stderr_handle = child
8656 .stderr
8657 .take()
8658 .ok_or_else(|| anyhow::anyhow!("stderr unavailable"))?;
8659
8660 let timeout = exec_env.timeout;
8661 let stdout_thread = std::thread::spawn(move || {
8662 let mut reader = stdout_handle;
8663 let mut buf = Vec::new();
8664 let _ = reader.read_to_end(&mut buf);
8665 buf
8666 });
8667 let stderr_thread = std::thread::spawn(move || {
8668 let mut reader = stderr_handle;
8669 let mut buf = Vec::new();
8670 let _ = reader.read_to_end(&mut buf);
8671 buf
8672 });
8673
8674 if let Some(status) = child.wait_timeout(timeout)? {
8675 let stdout = stdout_thread.join().unwrap_or_default();
8676 let stderr = stderr_thread.join().unwrap_or_default();
8677 let stderr_str = String::from_utf8_lossy(&stderr);
8678 let exit_code = status.code().unwrap_or(-1);
8679 let sandbox_type = exec_env.sandbox_type;
8680 let sandbox_denied = SandboxManager::was_denied(sandbox_type, exit_code, &stderr_str);
8681
8682 if !stdout.is_empty() {
8683 print!("{}", String::from_utf8_lossy(&stdout));
8684 }
8685 if !stderr.is_empty() {
8686 eprint!("{stderr_str}");
8687 }
8688 if sandbox_denied {
8689 eprintln!(
8690 "{}",
8691 SandboxManager::denial_message(sandbox_type, &stderr_str)
8692 );
8693 }
8694
8695 if !status.success() {
8696 bail!("Command failed with exit code {exit_code}");
8697 }
8698 } else {
8699 let _ = child.kill();
8700 let _ = child.wait();
8701 bail!("Command timed out after {}ms", timeout.as_millis());
8702 }
8703 Ok(())
8704}
8705
8706fn parse_sandbox_policy(
8707 policy: &str,
8708 network: bool,
8709 writable_root: Vec<PathBuf>,
8710 exclude_tmpdir: bool,
8711 exclude_slash_tmp: bool,
8712) -> Result<crate::sandbox::SandboxPolicy> {
8713 use crate::sandbox::SandboxPolicy;
8714
8715 match policy {
8716 "danger-full-access" => Ok(SandboxPolicy::DangerFullAccess),
8717 "read-only" => Ok(SandboxPolicy::ReadOnly),
8718 "external-sandbox" => Ok(SandboxPolicy::ExternalSandbox {
8719 network_access: network,
8720 }),
8721 "workspace-write" => Ok(SandboxPolicy::WorkspaceWrite {
8722 writable_roots: writable_root,
8723 network_access: network,
8724 exclude_tmpdir,
8725 exclude_slash_tmp,
8726 }),
8727 other => bail!("Unknown sandbox policy: {other}"),
8728 }
8729}
8730
8731fn should_use_alt_screen(_cli: &Cli, _config: &Config) -> bool {
8732 true
8733}
8734
8735fn should_use_mouse_capture(cli: &Cli, config: &Config, use_alt_screen: bool) -> bool {
8736 let terminal_emulator = std::env::var("TERMINAL_EMULATOR").ok();
8737 let wt_session = std::env::var("WT_SESSION").ok().filter(|s| !s.is_empty());
8738 let conemu_pid = std::env::var("ConEmuPID").ok().filter(|s| !s.is_empty());
8739 should_use_mouse_capture_with(
8740 cli,
8741 config,
8742 use_alt_screen,
8743 terminal_emulator.as_deref(),
8744 wt_session.as_deref(),
8745 conemu_pid.as_deref(),
8746 )
8747}
8748
8749fn should_use_mouse_capture_with(
8750 cli: &Cli,
8751 config: &Config,
8752 use_alt_screen: bool,
8753 terminal_emulator: Option<&str>,
8754 wt_session: Option<&str>,
8755 conemu_pid: Option<&str>,
8756) -> bool {
8757 if !use_alt_screen || cli.no_mouse_capture {
8758 return false;
8759 }
8760 if cli.mouse_capture {
8761 return true;
8762 }
8763 config
8764 .tui
8765 .as_ref()
8766 .and_then(|tui| tui.mouse_capture)
8767 .unwrap_or_else(|| default_mouse_capture_enabled(terminal_emulator, wt_session, conemu_pid))
8768}
8769
8770fn default_mouse_capture_enabled(
8785 terminal_emulator: Option<&str>,
8786 wt_session: Option<&str>,
8787 conemu_pid: Option<&str>,
8788) -> bool {
8789 if cfg!(windows) {
8790 return wt_session.is_some() || conemu_pid.is_some();
8791 }
8792 if matches!(terminal_emulator, Some(t) if t.eq_ignore_ascii_case("JetBrains-JediTerm")) {
8793 return false;
8794 }
8795 true
8796}
8797
8798struct RecentCheckpoint {
8802 session: session_manager::SavedSession,
8803 age: std::time::Duration,
8804 source: session_manager::CheckpointSource,
8805}
8806
8807const CHECKPOINT_MAX_AGE: std::time::Duration = std::time::Duration::from_secs(24 * 3600);
8808
8809fn load_recent_checkpoints(manager: &session_manager::SessionManager) -> Vec<RecentCheckpoint> {
8816 let refs = manager.list_checkpoints().unwrap_or_default();
8817 let mut recent = Vec::new();
8818 for checkpoint_ref in refs {
8819 let Ok(age) = std::time::SystemTime::now().duration_since(checkpoint_ref.modified) else {
8820 continue;
8821 };
8822 if age > CHECKPOINT_MAX_AGE {
8823 let _ = match &checkpoint_ref.source {
8824 session_manager::CheckpointSource::Session(id) => {
8825 manager.clear_session_checkpoint(id)
8826 }
8827 session_manager::CheckpointSource::Legacy => manager.clear_legacy_checkpoint(),
8828 };
8829 continue;
8830 }
8831 let loaded = match &checkpoint_ref.source {
8832 session_manager::CheckpointSource::Session(id) => manager.load_session_checkpoint(id),
8833 session_manager::CheckpointSource::Legacy => manager.load_legacy_checkpoint(),
8834 };
8835 let Ok(Some(session)) = loaded else {
8836 continue;
8837 };
8838 recent.push(RecentCheckpoint {
8839 session,
8840 age,
8841 source: checkpoint_ref.source,
8842 });
8843 }
8844 recent.sort_by_key(|c| c.age);
8847 recent
8848}
8849
8850fn checkpoint_age_label(age: std::time::Duration) -> String {
8851 if age.as_secs() < 60 {
8852 format!("{}s ago", age.as_secs())
8853 } else if age.as_secs() < 3600 {
8854 format!("{}m ago", age.as_secs() / 60)
8855 } else {
8856 format!("{}h ago", age.as_secs() / 3600)
8857 }
8858}
8859
8860fn recover_interrupted_checkpoint_for_resume(launch_workspace: &Path) -> Option<String> {
8872 let manager = session_manager::SessionManager::default_location().ok()?;
8873 let candidates = load_recent_checkpoints(&manager);
8874 if candidates.is_empty() {
8875 return None;
8876 }
8877
8878 let (matching, mismatched): (Vec<_>, Vec<_>) = candidates.into_iter().partition(|candidate| {
8883 session_manager::workspace_scope_matches(
8884 &candidate.session.metadata.workspace,
8885 launch_workspace,
8886 )
8887 });
8888
8889 let Some(best) = matching.into_iter().next() else {
8890 if let Some(newest) = mismatched.first() {
8891 eprintln!(
8892 "Note: an interrupted session from another workspace ({}) is \
8893 available. Run `codewhale sessions` to list saved sessions. Starting \
8894 fresh in {}.",
8895 newest.session.metadata.workspace.display(),
8896 launch_workspace.display(),
8897 );
8898 }
8899 return None;
8900 };
8901
8902 let session_id = best.session.metadata.id.clone();
8903
8904 if !saved_session_is_newer(&manager, &best.session)
8909 && manager.save_session(&best.session).is_err()
8910 {
8911 return None;
8912 }
8913
8914 match &best.source {
8915 session_manager::CheckpointSource::Session(id) => {
8916 let _ = manager.clear_session_checkpoint(id);
8918 }
8919 session_manager::CheckpointSource::Legacy => {
8920 let _ = manager.write_session_checkpoint_if_absent(&best.session);
8925 }
8926 }
8927
8928 let age_str = checkpoint_age_label(best.age);
8929 eprintln!("Recovered interrupted session ({age_str}). Use --fresh to start fresh.",);
8930
8931 Some(session_id)
8932}
8933
8934fn saved_session_is_newer(
8939 manager: &session_manager::SessionManager,
8940 checkpoint: &session_manager::SavedSession,
8941) -> bool {
8942 manager
8943 .load_session(&checkpoint.metadata.id)
8944 .is_ok_and(|existing| existing.metadata.updated_at >= checkpoint.metadata.updated_at)
8945}
8946
8947fn preserve_interrupted_checkpoint_for_explicit_resume(launch_workspace: &Path) {
8958 let Some(manager) = session_manager::SessionManager::default_location().ok() else {
8959 return;
8960 };
8961 let Some(newest) = load_recent_checkpoints(&manager).into_iter().next() else {
8962 return;
8963 };
8964
8965 let session_workspace = newest.session.metadata.workspace.clone();
8966 if newest.source == session_manager::CheckpointSource::Legacy {
8968 let _ = manager.save_checkpoint(&newest.session);
8971 let _ = manager.clear_legacy_checkpoint();
8972 }
8973
8974 let age_str = checkpoint_age_label(newest.age);
8975 if session_manager::workspace_scope_matches(&session_workspace, launch_workspace) {
8976 eprintln!(
8977 "Found an in-flight session snapshot ({age_str}). Starting a new \
8978 session. Run `codewhale --continue` to resume it."
8979 );
8980 } else {
8981 eprintln!(
8982 "Note: an interrupted session from another workspace ({}) is \
8983 available. Run `codewhale sessions` to list saved sessions. Starting \
8984 fresh in {}.",
8985 session_workspace.display(),
8986 launch_workspace.display(),
8987 );
8988 }
8989}
8990
8991#[cfg(test)]
8997fn merge_project_config(config: &mut Config, workspace: &Path) {
8998 merge_project_config_with_approval_baseline(config, workspace, None);
8999}
9000
9001fn merge_project_config_with_approval_baseline(
9006 config: &mut Config,
9007 workspace: &Path,
9008 saved_permission_posture: Option<&str>,
9009) {
9010 if let Some(home) = effective_home_dir()
9015 && let (Ok(w), Ok(h)) = (
9016 std::fs::canonicalize(workspace),
9017 std::fs::canonicalize(&home),
9018 )
9019 && w == h
9020 {
9021 return;
9022 }
9023
9024 let path = workspace
9026 .join(codewhale_config::CODEWHALE_APP_DIR)
9027 .join("config.toml");
9028 let raw = match read_project_config_file(&path) {
9029 Ok(Some(r)) => r,
9030 Ok(None) => {
9031 let legacy = workspace
9032 .join(codewhale_config::LEGACY_APP_DIR)
9033 .join("config.toml");
9034 match read_project_config_file(&legacy) {
9035 Ok(Some(r)) => r,
9036 Ok(None) => return,
9037 Err(err) => {
9038 eprintln!(
9039 "warning: failed to read project-scope config {}: {err}",
9040 legacy.display()
9041 );
9042 return;
9043 }
9044 }
9045 }
9046 Err(err) => {
9047 eprintln!(
9048 "warning: failed to read project-scope config {}: {err}",
9049 path.display()
9050 );
9051 return;
9052 }
9053 };
9054 let project: toml::Value = match toml::from_str(&raw) {
9055 Ok(v) => v,
9056 Err(_) => return,
9057 };
9058 let table = match project.as_table() {
9059 Some(t) => t,
9060 None => return,
9061 };
9062
9063 const DENY_AT_PROJECT_SCOPE: &[&str] = &[
9079 "api_key",
9080 "base_url",
9081 "provider",
9082 "mcp_config_path",
9083 "mcp_oauth_callback_port",
9084 "mcp_oauth_callback_url",
9085 ];
9086 for key in DENY_AT_PROJECT_SCOPE {
9087 if table.contains_key(*key) {
9088 eprintln!(
9089 "warning: project-scope config key `{key}` is ignored — \
9090 set it in `~/.codewhale/config.toml` instead. \
9091 (See #417 for the deny-list rationale.)"
9092 );
9093 }
9094 }
9095
9096 for (key, field) in [
9099 ("model", &mut config.default_text_model),
9100 ("reasoning_effort", &mut config.reasoning_effort),
9101 ("notes_path", &mut config.notes_path),
9102 ] {
9103 if let Some(v) = table.get(key).and_then(toml::Value::as_str)
9104 && !v.is_empty()
9105 {
9106 *field = Some(v.to_string());
9107 }
9108 }
9109
9110 if let Some(v) = table.get("approval_policy").and_then(toml::Value::as_str)
9111 && !v.is_empty()
9112 {
9113 let saved_approval_baseline =
9114 crate::config::approval_policy_baseline_from_permission_posture(
9115 saved_permission_posture,
9116 );
9117 let approval_baseline = config
9118 .approval_policy
9119 .as_deref()
9120 .or(saved_approval_baseline);
9121 if codewhale_config::project_approval_policy_is_allowed(approval_baseline, v) {
9122 config.approval_policy = Some(v.to_string());
9123 } else {
9124 eprintln!(
9125 "warning: project-scope `approval_policy = \"{v}\"` is ignored — \
9126 project config can only tighten the user's approval policy. \
9127 (See #417.)"
9128 );
9129 }
9130 }
9131
9132 if let Some(v) = table.get("sandbox_mode").and_then(toml::Value::as_str)
9133 && !v.is_empty()
9134 {
9135 if codewhale_config::project_sandbox_mode_is_allowed(config.sandbox_mode.as_deref(), v) {
9136 config.sandbox_mode = Some(v.to_string());
9137 } else {
9138 eprintln!(
9139 "warning: project-scope `sandbox_mode = \"{v}\"` is ignored — \
9140 project config can only tighten the user's sandbox mode. \
9141 (See #417.)"
9142 );
9143 }
9144 }
9145
9146 if let Some(v) = table.get("max_subagents").and_then(toml::Value::as_integer)
9148 && v > 0
9149 {
9150 config.max_subagents = Some((v as usize).clamp(1, crate::config::MAX_SUBAGENTS));
9151 }
9152 if let Some(v) = table.get("allow_shell").and_then(toml::Value::as_bool) {
9153 if v {
9154 eprintln!(
9155 "warning: project-scope `allow_shell = true` is ignored — \
9156 enable shell from user config for this workspace instead. \
9157 (See #417.)"
9158 );
9159 } else {
9160 config.allow_shell = Some(false);
9161 }
9162 }
9163
9164 if table.contains_key("instructions") {
9165 eprintln!(
9166 "warning: project-scope `instructions` is ignored — \
9167 configure instruction files from user config instead. \
9168 (See #417.)"
9169 );
9170 }
9171}
9172
9173fn read_project_config_file(path: &Path) -> io::Result<Option<String>> {
9174 let metadata = match std::fs::symlink_metadata(path) {
9175 Ok(metadata) => metadata,
9176 Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
9177 Err(err) => return Err(err),
9178 };
9179 let file_type = metadata.file_type();
9180 if file_type.is_symlink() {
9181 return Err(io::Error::new(
9182 io::ErrorKind::InvalidInput,
9183 "project-scope config must not be a symlink",
9184 ));
9185 }
9186 if !file_type.is_file() {
9187 return Ok(None);
9188 }
9189
9190 let mut file = open_project_config_file(path)?;
9191 let mut raw = String::new();
9192 file.read_to_string(&mut raw)?;
9193 Ok(Some(raw))
9194}
9195
9196#[cfg(unix)]
9197fn open_project_config_file(path: &Path) -> io::Result<std::fs::File> {
9198 use std::os::unix::fs::OpenOptionsExt;
9199
9200 std::fs::OpenOptions::new()
9201 .read(true)
9202 .custom_flags(libc::O_NOFOLLOW)
9203 .open(path)
9204}
9205
9206#[cfg(not(unix))]
9207fn open_project_config_file(path: &Path) -> io::Result<std::fs::File> {
9208 std::fs::File::open(path)
9209}
9210
9211fn merge_user_workspace_config(
9212 config: &mut Config,
9213 config_path: Option<PathBuf>,
9214 workspace: &Path,
9215) {
9216 if config.managed_config_path.is_some() || config.requirements_path.is_some() {
9217 return;
9218 }
9219 let allow_shell_before = config.allow_shell;
9220 let allow_shell_from_env = std::env::var_os("CODEWHALE_ALLOW_SHELL").is_some()
9221 || std::env::var_os("DEEPSEEK_ALLOW_SHELL").is_some();
9222 let path = match crate::config::resolve_load_config_path(config_path) {
9223 Ok(Some(path)) => path,
9224 Ok(None) => return,
9225 Err(error) => {
9226 tracing::error!(
9227 error = %error,
9228 "failed to resolve workspace config overlay; refusing to substitute another file"
9229 );
9230 return;
9231 }
9232 };
9233 let raw = match std::fs::read_to_string(&path) {
9234 Ok(raw) => raw,
9235 Err(error) => {
9236 eprintln!(
9237 "warning: could not read user config at {}: {error}. \
9238 Ignoring it — `[workspace]`/`[projects]` grants (e.g. `allow_shell`) \
9239 revert to defaults for this session. Fix or remove the file to \
9240 restore them.",
9241 path.display()
9242 );
9243 return;
9244 }
9245 };
9246 let doc = match toml::from_str::<toml::Value>(&raw) {
9247 Ok(doc) => doc,
9248 Err(error) => {
9249 eprintln!(
9250 "warning: could not parse user config at {}: {error}. \
9251 Ignoring it — `[workspace]`/`[projects]` grants (e.g. `allow_shell`) \
9252 revert to defaults for this session. Fix the TOML syntax to \
9253 restore them.",
9254 path.display()
9255 );
9256 return;
9257 }
9258 };
9259 merge_user_workspace_config_from_doc(config, &doc, workspace);
9260 if allow_shell_from_env {
9261 config.allow_shell = allow_shell_before;
9262 }
9263}
9264
9265fn merge_user_workspace_config_from_doc(config: &mut Config, doc: &toml::Value, workspace: &Path) {
9266 for table_name in ["workspace", "projects"] {
9267 let Some(entries) = doc.get(table_name).and_then(toml::Value::as_table) else {
9268 continue;
9269 };
9270 for (raw_path, entry) in entries {
9271 if !workspace_config_path_matches(raw_path, workspace) {
9272 continue;
9273 }
9274 if let Some(allow_shell) = entry.get("allow_shell").and_then(toml::Value::as_bool) {
9275 config.allow_shell = Some(allow_shell);
9276 }
9277 }
9278 }
9279}
9280
9281fn workspace_config_path_matches(raw_path: &str, workspace: &Path) -> bool {
9282 let configured = crate::config::expand_path(raw_path);
9283 let configured = configured.canonicalize().unwrap_or(configured);
9284 let workspace = workspace
9285 .canonicalize()
9286 .unwrap_or_else(|_| workspace.to_path_buf());
9287 paths_equal_for_config(&configured, &workspace)
9288}
9289
9290#[cfg(windows)]
9291fn paths_equal_for_config(left: &Path, right: &Path) -> bool {
9292 normalize_windows_config_path_for_compare(left)
9293 == normalize_windows_config_path_for_compare(right)
9294}
9295
9296#[cfg(not(windows))]
9297fn paths_equal_for_config(left: &Path, right: &Path) -> bool {
9298 left == right
9299}
9300
9301#[cfg(windows)]
9302fn normalize_windows_config_path_for_compare(path: &Path) -> String {
9303 normalize_windows_config_path_str(&path.to_string_lossy())
9304}
9305
9306#[cfg(any(windows, test))]
9307fn normalize_windows_config_path_str(path: &str) -> String {
9308 let mut normalized = path.replace('/', "\\");
9309 if let Some(rest) = normalized.strip_prefix(r"\\?\UNC\") {
9310 normalized = format!("\\\\{rest}");
9311 } else if let Some(rest) = normalized.strip_prefix(r"\\?\") {
9312 normalized = rest.to_string();
9313 }
9314 while normalized.len() > 3 && normalized.ends_with('\\') {
9315 normalized.pop();
9316 }
9317 normalized.to_ascii_lowercase()
9318}
9319
9320fn interactive_tui_allow_shell(yolo: bool, config: &Config) -> bool {
9321 yolo || config.interactive_allow_shell()
9322}
9323
9324async fn run_interactive(
9325 cli: &Cli,
9326 config: &Config,
9327 resume_session_id: Option<String>,
9328 initial_input: Option<tui::InitialInput>,
9329 plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
9330) -> Result<()> {
9331 run_interactive_with_notice(
9332 cli,
9333 config,
9334 resume_session_id,
9335 initial_input,
9336 None,
9337 plugin_registry,
9338 )
9339 .await
9340}
9341
9342async fn run_interactive_with_notice(
9346 cli: &Cli,
9347 config: &Config,
9348 resume_session_id: Option<String>,
9349 initial_input: Option<tui::InitialInput>,
9350 startup_notice: Option<String>,
9351 plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
9352) -> Result<()> {
9353 let initial_input = if cli.remote_control {
9354 Some(tui::InitialInput::RemoteControl)
9355 } else {
9356 initial_input
9357 };
9358 let workspace = cli
9359 .workspace
9360 .clone()
9361 .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
9362
9363 let mut merged_config = config.clone();
9367 merge_user_workspace_config(&mut merged_config, cli.config.clone(), &workspace);
9368 if !cli.no_project_config {
9369 let saved_permission_posture = crate::settings::Settings::load_persisted()
9370 .ok()
9371 .and_then(|settings| settings.permission_posture);
9372 merge_project_config_with_approval_baseline(
9373 &mut merged_config,
9374 &workspace,
9375 saved_permission_posture.as_deref(),
9376 );
9377 }
9378 let config = &merged_config;
9379
9380 if !cli.skip_onboarding {
9381 match crate::config::ensure_config_file_exists(cli.config.clone()) {
9382 Ok(Some(path)) => logging::info(format!(
9383 "Created first-run config file at {}",
9384 path.display()
9385 )),
9386 Ok(None) => {}
9387 Err(err) => logging::warn(format!("Failed to create first-run config file: {err}")),
9388 }
9389 }
9390
9391 match codewhale_config::migrate_config_if_needed() {
9394 Ok(Some(migration)) => {
9395 eprintln!("{}", migration.user_notice());
9396 }
9397 Ok(None) => {}
9398 Err(err) => logging::warn(format!("Config migration skipped: {err}")),
9399 }
9400
9401 let model = config.default_model();
9402 let provider = config.api_provider();
9403 let max_subagents = cli.max_subagents.map_or_else(
9404 || config.max_subagents_for_provider(provider),
9405 |value| value.clamp(1, MAX_SUBAGENTS),
9406 );
9407 let use_alt_screen = should_use_alt_screen(cli, config);
9408 let use_mouse_capture = should_use_mouse_capture(cli, config, use_alt_screen);
9409 let use_bracketed_paste = crate::settings::Settings::load()
9410 .map(|s| s.effective_bracketed_paste())
9411 .unwrap_or_else(|_| !crate::settings::detected_legacy_windows_console_host());
9412
9413 let skills_dir = config.skills_dir();
9416 if let Err(e) = crate::skills::install_system_skills(&skills_dir) {
9417 logging::warn(format!("Failed to install system skills: {e}"));
9418 }
9419
9420 startup_trace::mark("interactive_config");
9421
9422 crate::models_dev_live::maybe_load_persisted_cache();
9426 crate::models_dev_live::spawn_background_refresh();
9427 crate::client::DeepSeekClient::spawn_active_provider_catalog_refresh(config);
9432
9433 let snapshots = config.snapshots_config();
9440 let janitor_snapshots_enabled = snapshots.enabled;
9441 let janitor_max_age = snapshots.max_age();
9442 let janitor_workspace = workspace.clone();
9443 let janitor_resume_id = resume_session_id.clone();
9448 let janitor_skip_session_cleanup = resume_session_id.is_some() || cli.continue_session;
9449 tokio::task::spawn_blocking(move || {
9450 if janitor_snapshots_enabled {
9451 session_manager::prune_workspace_snapshots(&janitor_workspace, janitor_max_age);
9452 }
9453
9454 match crate::tools::truncate::prune_older_than(crate::tools::truncate::SPILLOVER_MAX_AGE) {
9455 Ok(0) => {}
9456 Ok(n) => tracing::debug!(
9457 target: "spillover",
9458 "boot prune removed {n} spillover file(s)"
9459 ),
9460 Err(err) => tracing::warn!(
9461 target: "spillover",
9462 ?err,
9463 "spillover prune skipped on boot"
9464 ),
9465 }
9466
9467 if !janitor_skip_session_cleanup
9468 && let Ok(manager) = session_manager::SessionManager::default_location()
9469 {
9470 let _ = manager.cleanup_old_sessions_keeping(janitor_resume_id.as_deref());
9471 }
9472 });
9473
9474 let yolo = cli.yolo || config.yolo.unwrap_or(false);
9477
9478 tui::run_tui(
9479 config,
9480 tui::TuiOptions {
9481 model,
9482 workspace,
9483 config_path: cli.config.clone(),
9484 config_profile: effective_config_profile(cli),
9485 allow_shell: interactive_tui_allow_shell(yolo, config),
9486 use_alt_screen,
9487 use_mouse_capture,
9488 use_bracketed_paste,
9489 skills_dir,
9490 memory_path: config.memory_path(),
9491 notes_path: config.notes_path(),
9492 mcp_config_path: config.mcp_config_path(),
9493 use_memory: config.memory_enabled(),
9494 start_in_agent_mode: yolo,
9495 skip_onboarding: cli.skip_onboarding,
9496 yolo, resume_session_id,
9498 initial_input,
9499 startup_notice,
9500 max_subagents,
9501 },
9502 plugin_registry,
9503 )
9504 .await
9505}
9506
9507#[derive(Debug)]
9508struct CliAutoRoute {
9509 provider: crate::config::ApiProvider,
9510 model: String,
9511 reasoning_effort: Option<crate::tui::app::ReasoningEffort>,
9512 auto_controls_reasoning: bool,
9517 auto_model: bool,
9518}
9519
9520fn cli_reasoning_effort_value(
9521 config: &Config,
9522 model: &str,
9523 effort: crate::tui::app::ReasoningEffort,
9524) -> Option<String> {
9525 effort
9526 .api_value_for_route(config.api_provider(), &config.deepseek_base_url(), model)
9527 .map(str::to_string)
9528}
9529
9530fn cli_reasoning_effort_value_for_prompt(
9531 config: &Config,
9532 model: &str,
9533 effort: crate::tui::app::ReasoningEffort,
9534 prompt: &str,
9535) -> Option<String> {
9536 let resolved = if effort == crate::tui::app::ReasoningEffort::Auto {
9537 crate::auto_reasoning::select(false, prompt)
9538 } else {
9539 effort
9540 };
9541 cli_reasoning_effort_value(config, model, resolved)
9542}
9543
9544fn normalize_cli_reasoning_effort(value: &str) -> Result<Option<String>> {
9545 let trimmed = value.trim();
9546 if trimmed.is_empty() {
9547 return Ok(None);
9548 }
9549 if matches!(
9550 trimmed.to_ascii_lowercase().as_str(),
9551 "inherit" | "parent" | "same" | "current" | "default" | "unset"
9552 ) {
9553 return Ok(None);
9554 }
9555 crate::tui::app::ReasoningEffort::parse_strict(trimmed)
9556 .map(|effort| Some(effort.as_setting().to_string()))
9557 .map_err(anyhow::Error::msg)
9558}
9559
9560fn config_for_cli_route(config: &Config, route: &CliAutoRoute) -> Config {
9561 let mut execution_config = config.clone();
9562 execution_config.provider = Some(config.provider_identity_for(route.provider));
9563 execution_config.set_provider_model_override(route.provider, Some(route.model.clone()));
9564 if matches!(
9565 route.provider,
9566 crate::config::ApiProvider::Deepseek | crate::config::ApiProvider::DeepseekCN
9567 ) {
9568 execution_config.default_text_model = Some(route.model.clone());
9569 }
9570 execution_config
9571}
9572
9573async fn resolve_cli_auto_route(
9574 config: &Config,
9575 model: &str,
9576 prompt: &str,
9577) -> Result<CliAutoRoute> {
9578 if model.trim().eq_ignore_ascii_case("auto") {
9579 let selection =
9580 model_routing::resolve_auto_route_with_inventory(config, prompt, "", "auto", "auto")
9581 .await?;
9582 let preference = config
9583 .reasoning_effort()
9584 .filter(|_| config.reasoning_effort_is_explicit())
9585 .map(crate::tui::app::ReasoningEffort::from_setting);
9586 let (reasoning_effort, auto_controls_reasoning) =
9587 model_routing::resolve_auto_model_reasoning(preference, selection.reasoning_effort);
9588 Ok(CliAutoRoute {
9589 provider: selection.provider,
9590 model: selection.model,
9591 reasoning_effort,
9592 auto_controls_reasoning,
9593 auto_model: true,
9594 })
9595 } else {
9596 if let Some(selection) = model_routing::resolve_explicit_route_with_inventory(config, model)
9597 {
9598 let auto_controls_reasoning = matches!(
9599 selection.reasoning_effort,
9600 Some(crate::tui::app::ReasoningEffort::Auto)
9601 );
9602 return Ok(CliAutoRoute {
9603 provider: selection.provider,
9604 model: selection.model,
9605 reasoning_effort: selection.reasoning_effort,
9606 auto_controls_reasoning,
9607 auto_model: false,
9608 });
9609 }
9610
9611 let candidate_providers = model_routing::explicit_route_candidate_providers(config, model);
9612 if !candidate_providers.is_empty() && !candidate_providers.contains(&config.api_provider())
9613 {
9614 let providers = candidate_providers
9615 .iter()
9616 .map(|provider| provider.as_str())
9617 .collect::<Vec<_>>()
9618 .join(", ");
9619 bail!(
9620 "model `{model}` is available from configured provider route(s): {providers}. \
9621 Pass `--provider <provider>` with `--model {model}` to choose one explicitly. \
9622 In the TUI, use `/provider`, `/model`, or `/setup` to resolve the route before sending."
9623 );
9624 }
9625
9626 let reasoning_effort = config
9633 .reasoning_effort()
9634 .map(crate::tui::app::ReasoningEffort::from_setting);
9635 Ok(CliAutoRoute {
9636 provider: config.api_provider(),
9637 model: model.to_string(),
9638 auto_controls_reasoning: matches!(
9639 reasoning_effort,
9640 Some(crate::tui::app::ReasoningEffort::Auto)
9641 ),
9642 reasoning_effort,
9643 auto_model: false,
9644 })
9645 }
9646}
9647
9648async fn resolve_cli_exec_route(
9649 config: &Config,
9650 model: &str,
9651 prompt: &str,
9652 force_configured_route: bool,
9653) -> Result<CliAutoRoute> {
9654 if force_configured_route && !model.trim().eq_ignore_ascii_case("auto") {
9655 let reasoning_effort = config
9656 .reasoning_effort()
9657 .map(crate::tui::app::ReasoningEffort::from_setting);
9658 return Ok(CliAutoRoute {
9659 provider: config.api_provider(),
9660 model: model.to_string(),
9661 auto_controls_reasoning: matches!(
9662 reasoning_effort,
9663 Some(crate::tui::app::ReasoningEffort::Auto)
9664 ),
9665 reasoning_effort,
9666 auto_model: false,
9667 });
9668 }
9669 resolve_cli_auto_route(config, model, prompt).await
9670}
9671
9672fn should_force_configured_exec_route(
9673 resuming: bool,
9674 explicit_provider: Option<&str>,
9675 explicit_model: Option<&str>,
9676) -> bool {
9677 resuming || explicit_provider.is_some() || explicit_model.is_none()
9682}
9683
9684async fn run_one_shot(
9685 config: &Config,
9686 model: &str,
9687 prompt: &str,
9688 force_configured_route: bool,
9689) -> Result<()> {
9690 use crate::client::DeepSeekClient;
9691 use crate::models::{ContentBlock, Message, MessageRequest};
9692
9693 let route = resolve_cli_exec_route(config, model, prompt, force_configured_route).await?;
9694 let execution_config = config_for_cli_route(config, &route);
9695 let client = DeepSeekClient::new(&execution_config)?;
9696 let reasoning_effort = route.reasoning_effort.and_then(|effort| {
9697 cli_reasoning_effort_value_for_prompt(&execution_config, &route.model, effort, prompt)
9698 });
9699
9700 let request = MessageRequest {
9701 model: route.model,
9702 messages: vec![Message {
9703 role: "user".to_string(),
9704 content: vec![ContentBlock::Text {
9705 text: prompt.to_string(),
9706 cache_control: None,
9707 }],
9708 }],
9709 max_tokens: 4096,
9710 system: None,
9711 tools: None,
9712 tool_choice: None,
9713 metadata: None,
9714 thinking: None,
9715 reasoning_effort,
9716 stream: Some(false),
9717 temperature: None,
9718 top_p: None,
9719 };
9720
9721 let response = client.create_message(request).await?;
9722
9723 for block in response.content {
9724 if let ContentBlock::Text { text, .. } = block {
9725 println!("{text}");
9726 }
9727 }
9728
9729 Ok(())
9730}
9731
9732async fn run_one_shot_json(
9733 config: &Config,
9734 model: &str,
9735 prompt: &str,
9736 force_configured_route: bool,
9737) -> Result<()> {
9738 use crate::client::DeepSeekClient;
9739 use crate::models::{ContentBlock, Message, MessageRequest, SystemPrompt};
9740
9741 let route = resolve_cli_exec_route(config, model, prompt, force_configured_route).await?;
9742 let execution_config = config_for_cli_route(config, &route);
9743 let provider = execution_config.provider_identity_for(route.provider);
9744 let client = DeepSeekClient::new(&execution_config)?;
9745 let model = route.model.clone();
9746 let reasoning_effort = route.reasoning_effort.and_then(|effort| {
9747 cli_reasoning_effort_value_for_prompt(&execution_config, &model, effort, prompt)
9748 });
9749 let request = MessageRequest {
9750 model: model.clone(),
9751 messages: vec![Message {
9752 role: "user".to_string(),
9753 content: vec![ContentBlock::Text {
9754 text: prompt.to_string(),
9755 cache_control: None,
9756 }],
9757 }],
9758 max_tokens: 4096,
9759 system: Some(SystemPrompt::Text(
9760 "You are a coding assistant. Give concise, actionable responses.".to_string(),
9761 )),
9762 tools: None,
9763 tool_choice: None,
9764 metadata: None,
9765 thinking: None,
9766 reasoning_effort,
9767 stream: Some(false),
9768 temperature: Some(0.2),
9769 top_p: Some(0.9),
9770 };
9771
9772 let response = client.create_message(request).await?;
9773 let mut output = String::new();
9774 for block in response.content {
9775 if let ContentBlock::Text { text, .. } = block {
9776 output.push_str(&text);
9777 }
9778 }
9779 println!(
9780 "{}",
9781 serde_json::to_string_pretty(&one_shot_exec_json_receipt(provider, model, output,))?
9782 );
9783 Ok(())
9784}
9785
9786fn one_shot_exec_json_receipt(
9787 provider: String,
9788 model: String,
9789 output: String,
9790) -> serde_json::Value {
9791 serde_json::json!({
9792 "mode": "one-shot",
9793 "provider": provider,
9794 "model": model,
9795 "success": true,
9796 "output": output
9797 })
9798}
9799
9800fn exec_stream_provider_route(
9801 identity: &crate::config::ProviderIdentity,
9802) -> (String, Option<String>) {
9803 let provider = identity.provider.as_str().to_string();
9804 let provider_id = if identity.provider == crate::config::ApiProvider::Custom {
9805 identity.exact_id.clone()
9806 } else {
9807 None
9808 };
9809 (provider, provider_id)
9810}
9811
9812#[derive(serde::Serialize)]
9813struct ExecStreamMeta {
9814 receipt_kind: &'static str,
9815 provider: String,
9816 #[serde(skip_serializing_if = "Option::is_none")]
9820 provider_id: Option<String>,
9821 model: String,
9822 route_source: String,
9823 #[serde(skip_serializing_if = "Option::is_none")]
9824 input_tokens: Option<u32>,
9825 #[serde(skip_serializing_if = "Option::is_none")]
9826 output_tokens: Option<u32>,
9827 #[serde(skip_serializing_if = "Option::is_none")]
9828 prompt_cache_hit_tokens: Option<u32>,
9829 #[serde(skip_serializing_if = "Option::is_none")]
9830 prompt_cache_miss_tokens: Option<u32>,
9831 #[serde(skip_serializing_if = "Option::is_none")]
9832 prompt_cache_write_tokens: Option<u32>,
9833 #[serde(skip_serializing_if = "Option::is_none")]
9834 reasoning_tokens: Option<u32>,
9835 duration_ms: u64,
9836 #[serde(skip_serializing_if = "Option::is_none")]
9837 retry_count: Option<u32>,
9838 approval_posture: String,
9839 sandbox_posture: String,
9840 #[serde(skip_serializing_if = "Option::is_none")]
9841 binary_sha256: Option<String>,
9842 #[serde(skip_serializing_if = "Option::is_none")]
9843 config_sha256: Option<String>,
9844 prompt_sha256: String,
9845 #[serde(skip_serializing_if = "Option::is_none")]
9846 tool_catalog_sha256: Option<String>,
9847 input_analysis: ExecStreamInputAnalysis,
9848 visible_final_answer_chars: usize,
9849 session_id: String,
9850 resume_command: String,
9851 workspace: String,
9852 message_count: usize,
9853 #[serde(skip_serializing_if = "Option::is_none")]
9854 status: Option<String>,
9855 #[serde(skip_serializing_if = "Option::is_none")]
9856 termination_reason: Option<String>,
9857 #[serde(skip_serializing_if = "Option::is_none")]
9858 error_category: Option<String>,
9859}
9860
9861#[derive(Debug, Default, Clone, serde::Serialize, PartialEq, Eq)]
9862struct ExecStreamInputAnalysis {
9863 estimated_request_tokens: usize,
9864 estimated_message_content_tokens: usize,
9865 estimated_system_tokens: usize,
9866 estimated_framing_tokens: usize,
9867 user_message_count: usize,
9868 assistant_message_count: usize,
9869 tool_message_count: usize,
9870 tool_use_count: usize,
9871 tool_result_count: usize,
9872 text_chars: usize,
9873 thinking_chars: usize,
9874 tool_use_input_chars: usize,
9875 tool_result_chars: usize,
9876 text_estimated_tokens: usize,
9877 thinking_estimated_tokens: usize,
9878 tool_use_input_estimated_tokens: usize,
9879 tool_result_estimated_tokens: usize,
9880}
9881
9882#[derive(serde::Serialize)]
9883#[serde(tag = "type")]
9884#[allow(clippy::large_enum_variant)]
9887enum ExecStreamEvent {
9888 #[serde(rename = "content")]
9889 Content { content: String },
9890 #[serde(rename = "tool_use")]
9891 ToolUse {
9892 name: String,
9893 id: String,
9894 input: serde_json::Value,
9895 started_at: String,
9896 },
9897 #[serde(rename = "tool_result")]
9898 ToolResult {
9899 id: String,
9900 name: String,
9901 output: String,
9902 status: String,
9903 started_at: String,
9904 completed_at: String,
9905 duration_ms: u64,
9906 side_effect_status: String,
9907 #[serde(skip_serializing_if = "Option::is_none")]
9908 error_category: Option<String>,
9909 #[serde(skip_serializing_if = "Option::is_none")]
9910 truncated: Option<bool>,
9911 #[serde(skip_serializing_if = "Option::is_none")]
9912 artifact: Option<serde_json::Value>,
9913 #[serde(skip_serializing_if = "Option::is_none")]
9914 result_metadata: Option<serde_json::Value>,
9915 },
9916 #[serde(rename = "agent_spawned")]
9924 AgentSpawned {
9925 id: String,
9926 model: String,
9927 spawn_depth: u32,
9928 #[serde(skip_serializing_if = "Option::is_none")]
9929 parent_run_id: Option<String>,
9930 #[serde(skip_serializing_if = "Option::is_none")]
9932 route_source: Option<String>,
9933 },
9934 #[serde(rename = "sandbox_denied")]
9935 SandboxDenied {
9936 tool_id: String,
9937 tool_name: String,
9938 reason: String,
9939 outcome: String,
9940 },
9941 #[serde(rename = "workflow_event")]
9942 WorkflowEvent {
9943 run_id: String,
9944 event: serde_json::Value,
9945 },
9946 #[serde(rename = "session_capture")]
9947 SessionCapture { content: String },
9948 #[serde(rename = "turn_usage")]
9955 TurnUsage {
9956 turn: u32,
9958 input_tokens: u32,
9959 output_tokens: u32,
9960 #[serde(skip_serializing_if = "Option::is_none")]
9961 reasoning_tokens: Option<u32>,
9962 #[serde(skip_serializing_if = "Option::is_none")]
9963 prompt_cache_hit_tokens: Option<u32>,
9964 #[serde(skip_serializing_if = "Option::is_none")]
9965 prompt_cache_miss_tokens: Option<u32>,
9966 #[serde(skip_serializing_if = "Option::is_none")]
9967 prompt_cache_write_tokens: Option<u32>,
9968 #[serde(skip_serializing_if = "Option::is_none")]
9969 reasoning_replay_tokens: Option<u32>,
9970 duration_ms: u64,
9971 },
9972 #[serde(rename = "metadata")]
9973 Metadata { meta: Box<ExecStreamMeta> },
9974 #[serde(rename = "done")]
9975 Done,
9976 #[serde(rename = "error")]
9977 Error { error: String },
9978}
9979
9980fn exec_sandbox_elevation_authorized(
9981 allow_sandbox_elevation: bool,
9982 explicit_sandbox: Option<&str>,
9983) -> bool {
9984 allow_sandbox_elevation
9985 || explicit_sandbox.is_some_and(|policy| policy.eq_ignore_ascii_case("danger-full-access"))
9986}
9987
9988fn emit_exec_stream_event(event: &ExecStreamEvent) -> Result<()> {
9989 println!("{}", serde_json::to_string(&exec_stream_value(event)?)?);
9990 Ok(())
9991}
9992
9993const EXEC_EXIT_RETRYABLE_INFRA: i32 = 75; fn exec_failure_exit_code(error_category: Option<&str>) -> i32 {
10009 match error_category {
10010 Some("network" | "timeout") => EXEC_EXIT_RETRYABLE_INFRA,
10011 _ => 1,
10012 }
10013}
10014
10015fn exec_error_event_is_fatal(envelope: &crate::error_taxonomy::ErrorEnvelope) -> bool {
10022 !envelope.recoverable
10023}
10024
10025fn exec_stream_value(event: &ExecStreamEvent) -> Result<serde_json::Value> {
10026 let mut value = serde_json::to_value(event)?;
10027 if let Some(object) = value.as_object_mut() {
10028 object.insert("schema_version".to_string(), serde_json::json!(1));
10029 object.insert(
10030 "schema".to_string(),
10031 serde_json::json!("codewhale.exec-stream"),
10032 );
10033 }
10034 Ok(value)
10035}
10036
10037fn tool_error_receipt_category(error: &crate::tools::spec::ToolError) -> &'static str {
10038 use crate::tools::spec::ToolError;
10039 match error {
10040 ToolError::InvalidInput { .. } => "invalid_input",
10041 ToolError::MissingField { .. } => "missing_field",
10042 ToolError::PathEscape { .. } => "path_escape",
10043 ToolError::ExecutionFailed { .. } => "execution_failed",
10044 ToolError::Timeout { .. } => "timeout",
10045 ToolError::Cancelled { .. } => "cancelled",
10046 ToolError::NotAvailable { .. } => "not_available",
10047 ToolError::PermissionDenied { .. } => "permission_denied",
10048 }
10049}
10050
10051fn tool_artifact_receipt(metadata: Option<&serde_json::Value>) -> Option<serde_json::Value> {
10052 let object = metadata?.as_object()?;
10053 let mut artifact = serde_json::Map::new();
10054 for key in [
10055 "artifact_id",
10056 "artifact_path",
10057 "artifact_relative_path",
10058 "artifact_byte_size",
10059 "spillover_path",
10060 "content_digest",
10061 "original_byte_count",
10062 "retained_head_bytes",
10063 "retained_tail_bytes",
10064 ] {
10065 if let Some(value) = object.get(key) {
10066 artifact.insert(key.to_string(), value.clone());
10067 }
10068 }
10069 (!artifact.is_empty()).then_some(serde_json::Value::Object(artifact))
10070}
10071
10072fn current_binary_sha256() -> Option<String> {
10073 let bytes = std::fs::read(std::env::current_exe().ok()?).ok()?;
10074 Some(format!("sha256:{}", crate::hashing::sha256_hex(&bytes)))
10075}
10076
10077async fn run_workflow_tool_command(
10078 cli: &Cli,
10079 args: WorkflowToolArgs,
10080 plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
10081) -> Result<()> {
10082 match run_workflow_tool_command_inner(cli, args, plugin_registry).await {
10083 Ok(()) => Ok(()),
10084 Err(error) => {
10085 let _ = emit_exec_stream_event(&ExecStreamEvent::Error {
10086 error: format!("{error:#}"),
10087 });
10088 exit_workflow_tool_failure();
10089 }
10090 }
10091}
10092
10093async fn run_workflow_tool_command_inner(
10094 cli: &Cli,
10095 args: WorkflowToolArgs,
10096 plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
10097) -> Result<()> {
10098 use crate::tools::spec::ToolSpec;
10099
10100 if args.approval_source != "explicit-workflow-command" {
10101 bail!("workflow-tool requires --approval-source explicit-workflow-command");
10102 }
10103 let input: serde_json::Value = serde_json::from_str(&args.input_json)
10104 .context("--input-json must be a valid Workflow tool input object")?;
10105 if !input.is_object() {
10106 bail!("--input-json must be a JSON object");
10107 }
10108 if !input
10109 .get("action")
10110 .and_then(serde_json::Value::as_str)
10111 .is_some_and(|action| action.eq_ignore_ascii_case("run"))
10112 {
10113 bail!("workflow-tool accepts only action=run");
10114 }
10115
10116 let workspace = resolve_workspace(cli);
10117 let mut config = load_config_from_cli(cli)?;
10118 merge_user_workspace_config(&mut config, cli.config.clone(), &workspace);
10119 if let Ok(env_url) =
10120 std::env::var("CODEWHALE_BASE_URL").or_else(|_| std::env::var("DEEPSEEK_BASE_URL"))
10121 {
10122 let trimmed = env_url.trim();
10123 if !trimmed.is_empty() {
10124 config.base_url = Some(trimmed.to_string());
10125 }
10126 }
10127
10128 let model = resolve_exec_model(&config, None);
10129 let route = resolve_cli_exec_route(
10130 &config,
10131 &model,
10132 "Run a checked-in Workflow through the host runtime",
10133 true,
10134 )
10135 .await?;
10136 let execution_config = config_for_cli_route(&config, &route);
10137 let route_identity = execution_config
10138 .active_provider_identity(route.provider)
10139 .map_err(anyhow::Error::msg)
10140 .context("workflow terminal route lost its exact provider identity")?;
10141 let (route_provider, route_provider_id) = exec_stream_provider_route(&route_identity);
10142 let workflow_input_sha256 = format!(
10143 "sha256:{}",
10144 crate::hashing::sha256_hex(&serde_json::to_vec(&input)?)
10145 );
10146 let tool_id = format!("workflow_host_{}", &uuid::Uuid::new_v4().to_string()[..8]);
10147 let tool_started = Instant::now();
10148 let tool_started_at = chrono::Utc::now().to_rfc3339();
10149
10150 emit_exec_stream_event(&ExecStreamEvent::ToolUse {
10151 name: "workflow".to_string(),
10152 id: tool_id.clone(),
10153 input: input.clone(),
10154 started_at: tool_started_at.clone(),
10155 })?;
10156
10157 let (event_tx, event_rx) = tokio::sync::mpsc::channel(1024);
10158 let (stop_tx, stop_rx) = tokio::sync::oneshot::channel();
10159 let event_forwarder = tokio::spawn(forward_direct_workflow_events(event_rx, stop_rx));
10160 let (tool, context) = match build_direct_workflow_tool(
10161 &execution_config,
10162 &route,
10163 &workspace,
10164 event_tx,
10165 plugin_registry,
10166 )
10167 .await
10168 {
10169 Ok(built) => built,
10170 Err(err) => {
10171 let _ = stop_tx.send(());
10172 let _ = event_forwarder.await;
10173 exit_workflow_tool_error(&tool_id, err.to_string());
10174 }
10175 };
10176
10177 let result = tool.execute(input, &context).await;
10178 drop(tool);
10179 let _ = stop_tx.send(());
10180 event_forwarder
10181 .await
10182 .context("workflow event forwarder task failed")??;
10183
10184 let result = match result {
10185 Ok(result) => result,
10186 Err(err) => {
10187 let error = err.to_string();
10188 exit_workflow_tool_error(&tool_id, error);
10189 }
10190 };
10191
10192 let workflow_status =
10193 direct_workflow_status(&result.content).unwrap_or_else(|| "unknown".to_string());
10194 let completed = result.success && workflow_status == "completed";
10195 emit_exec_stream_event(&ExecStreamEvent::ToolResult {
10196 id: tool_id,
10197 name: "workflow".to_string(),
10198 output: result.content.clone(),
10199 status: if completed { "success" } else { "error" }.to_string(),
10200 started_at: tool_started_at,
10201 completed_at: chrono::Utc::now().to_rfc3339(),
10202 duration_ms: u64::try_from(tool_started.elapsed().as_millis()).unwrap_or(u64::MAX),
10203 side_effect_status: result
10204 .metadata
10205 .as_ref()
10206 .and_then(|metadata| metadata.get("side_effect_status"))
10207 .and_then(serde_json::Value::as_str)
10208 .unwrap_or("unknown")
10209 .to_string(),
10210 error_category: (!completed).then(|| "tool_error".to_string()),
10211 truncated: result
10212 .metadata
10213 .as_ref()
10214 .and_then(|metadata| metadata.get("truncated"))
10215 .and_then(serde_json::Value::as_bool),
10216 artifact: tool_artifact_receipt(result.metadata.as_ref()),
10217 result_metadata: result.metadata.clone(),
10218 })?;
10219 emit_exec_stream_event(&ExecStreamEvent::Metadata {
10220 meta: Box::new(ExecStreamMeta {
10221 receipt_kind: "terminal",
10222 provider: route_provider,
10223 provider_id: route_provider_id,
10224 model: "host-workflow".to_string(),
10228 route_source: "host_workflow".to_string(),
10229 input_tokens: None,
10230 output_tokens: None,
10231 prompt_cache_hit_tokens: None,
10232 prompt_cache_miss_tokens: None,
10233 prompt_cache_write_tokens: None,
10234 reasoning_tokens: None,
10235 duration_ms: u64::try_from(tool_started.elapsed().as_millis()).unwrap_or(u64::MAX),
10236 retry_count: None,
10237 approval_posture: "explicit_workflow_command".to_string(),
10238 sandbox_posture: "configured".to_string(),
10239 binary_sha256: current_binary_sha256(),
10240 config_sha256: None,
10241 prompt_sha256: workflow_input_sha256,
10242 tool_catalog_sha256: None,
10243 input_analysis: ExecStreamInputAnalysis::default(),
10244 visible_final_answer_chars: result.content.chars().count(),
10245 session_id: String::new(),
10246 resume_command: String::new(),
10247 workspace: workspace.display().to_string(),
10248 message_count: 0,
10249 status: Some(workflow_status.clone()),
10250 termination_reason: Some(if completed { "resolved" } else { "tool_error" }.to_string()),
10251 error_category: (!completed).then(|| "tool".to_string()),
10252 }),
10253 })?;
10254 if !completed {
10255 let error = format!("workflow run ended with terminal status {workflow_status}");
10256 emit_exec_stream_event(&ExecStreamEvent::Error {
10257 error: error.clone(),
10258 })?;
10259 exit_workflow_tool_failure();
10260 }
10261 emit_exec_stream_event(&ExecStreamEvent::Done)?;
10262 Ok(())
10263}
10264
10265fn exit_workflow_tool_failure() -> ! {
10266 let _ = io::stdout().flush();
10267 std::process::exit(1)
10268}
10269
10270fn exit_workflow_tool_error(tool_id: &str, error: String) -> ! {
10271 let now = chrono::Utc::now().to_rfc3339();
10272 let _ = emit_exec_stream_event(&ExecStreamEvent::ToolResult {
10273 id: tool_id.to_string(),
10274 name: "workflow".to_string(),
10275 output: error.clone(),
10276 status: "error".to_string(),
10277 started_at: now.clone(),
10278 completed_at: now,
10279 duration_ms: 0,
10280 side_effect_status: "unknown".to_string(),
10281 error_category: Some("execution_failed".to_string()),
10282 truncated: None,
10283 artifact: None,
10284 result_metadata: None,
10285 });
10286 let _ = emit_exec_stream_event(&ExecStreamEvent::Error { error });
10287 exit_workflow_tool_failure()
10288}
10289
10290async fn initialize_direct_workflow_mcp_pool(
10291 config: &Config,
10292 workspace: &Path,
10293 network_policy: Option<crate::network_policy::NetworkPolicyDecider>,
10294 plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
10295) -> Option<(
10296 std::sync::Arc<tokio::sync::Mutex<crate::mcp::McpPool>>,
10297 Vec<(String, String)>,
10298)> {
10299 if !config.features().enabled(Feature::Mcp) {
10300 return None;
10301 }
10302 let mut pool = crate::mcp::McpPool::from_config_path_with_workspace_and_plugins(
10303 &config.mcp_config_path(),
10304 workspace,
10305 plugin_registry,
10306 )
10307 .unwrap_or_else(|error| {
10308 tracing::debug!("No MCP config for direct Workflow runtime: {error:#}");
10309 crate::mcp::McpPool::new(crate::mcp::McpConfig::default())
10310 });
10311 if let Some(policy) = network_policy {
10312 pool = pool.with_network_policy(policy);
10313 }
10314 let failures = pool
10315 .connect_all()
10316 .await
10317 .into_iter()
10318 .map(|(server, error)| (server, format!("{error:#}")))
10319 .collect();
10320 Some((std::sync::Arc::new(tokio::sync::Mutex::new(pool)), failures))
10321}
10322
10323async fn build_direct_workflow_tool(
10324 config: &Config,
10325 route: &CliAutoRoute,
10326 workspace: &Path,
10327 event_tx: tokio::sync::mpsc::Sender<crate::core::events::Event>,
10328 plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
10329) -> Result<(
10330 crate::tools::workflow::WorkflowTool,
10331 crate::tools::ToolContext,
10332)> {
10333 use std::sync::Arc;
10334
10335 use crate::client::DeepSeekClient;
10336 use crate::core::authority::shell_policy_for_mode;
10337 use crate::fleet::roster::FleetRoster;
10338 use crate::tools::AgentToolSurfaceOptions;
10339 use crate::tools::goal::new_shared_goal_state;
10340 use crate::tools::subagent::{SubAgentRuntime, new_shared_subagent_manager_with_timeout};
10341 use crate::tools::todo::new_shared_todo_list;
10342 use crate::tui::app::AppMode;
10343
10344 let provider = config.api_provider();
10345 if !config.subagents_enabled_for_provider(provider) {
10346 bail!(
10347 "Workflow dispatch requires sub-agents for provider {} ({})",
10348 provider.as_str(),
10349 config
10350 .subagents_disabled_reason()
10351 .unwrap_or("provider-specific sub-agent configuration disabled it")
10352 );
10353 }
10354
10355 let yolo = config.yolo.unwrap_or(false);
10356 let mode = if yolo {
10357 AppMode::Yolo
10358 } else {
10359 AppMode::Operate
10360 };
10361 let allow_shell = yolo || config.allow_shell();
10362 let shell_policy = shell_policy_for_mode(mode, allow_shell);
10363 let trusted = crate::workspace_trust::WorkspaceTrust::load_for(workspace);
10364 let mut context = crate::tools::ToolContext::with_auto_approve(
10365 workspace.to_path_buf(),
10366 yolo,
10367 config.notes_path(),
10368 config.mcp_config_path(),
10369 yolo,
10370 )
10371 .with_features(config.features())
10372 .with_skills_config(
10373 config.skills_dir(),
10374 config.skills_config().scan_codewhale_only(),
10375 )
10376 .with_plugin_registry(std::sync::Arc::clone(&plugin_registry))
10377 .with_shell_policy(shell_policy)
10378 .with_trusted_external_paths(trusted.paths().to_vec())
10379 .with_elevated_sandbox_policy(crate::core::authority::sandbox_policy_for_turn(
10380 mode,
10381 if yolo {
10382 crate::tui::approval::ApprovalMode::Bypass
10383 } else {
10384 crate::tui::approval::ApprovalMode::Suggest
10385 },
10386 config.sandbox_mode.as_deref(),
10387 workspace,
10388 ));
10389 let network_policy = config.network.clone().map(|network| {
10390 crate::network_policy::NetworkPolicyDecider::with_default_audit(network.into_runtime())
10391 });
10392 if let Some(policy) = network_policy.as_ref() {
10393 context = context.with_network_policy(policy.clone());
10394 }
10395 if config.memory_enabled() {
10396 context.memory_path = Some(config.memory_path());
10397 }
10398 context.search_provider = config.search_provider();
10399 context.search_api_key = config
10400 .search
10401 .as_ref()
10402 .and_then(|search| search.api_key.clone());
10403 context.search_base_url = config
10404 .search
10405 .as_ref()
10406 .and_then(|search| search.base_url.clone());
10407 if let Some(backend) = crate::sandbox::backend::create_backend(config)? {
10408 context = context.with_sandbox_backend(Arc::from(backend));
10409 }
10410
10411 let max_subagents = config.max_subagents_for_provider(provider);
10412 let manager = new_shared_subagent_manager_with_timeout(
10413 workspace.to_path_buf(),
10414 max_subagents,
10415 config
10416 .max_admitted_subagents_for_provider(provider)
10417 .max(max_subagents),
10418 Duration::from_secs(config.subagent_heartbeat_timeout_secs_for_provider(provider)),
10419 config.launch_concurrency_for_provider(provider),
10420 config.subagent_token_budget_for_provider(provider),
10421 );
10422 let roster = Arc::new(FleetRoster::load(&config.fleet_config(), workspace));
10423 let mut role_models = roster.model_overrides();
10424 role_models.extend(config.subagent_model_overrides());
10425
10426 let features = config.features();
10427 let mut surface = AgentToolSurfaceOptions::new(shell_policy);
10428 surface.apply_patch_enabled = features.enabled(Feature::ApplyPatch);
10429 surface.web_search_enabled = features.enabled(Feature::WebSearch);
10430 surface.memory_tool_enabled = config.memory_enabled();
10431 surface.vision_config = features
10432 .enabled(Feature::VisionModel)
10433 .then(|| config.vision_model_config())
10434 .flatten();
10435 surface.speech_output_dir = config.speech_output_dir();
10436 surface.goal_state = Some(new_shared_goal_state());
10437
10438 let client = DeepSeekClient::new(config)?;
10439 let reasoning_effort_auto = route.auto_controls_reasoning;
10446 let reasoning_effort = route
10447 .reasoning_effort
10448 .and_then(|effort| cli_reasoning_effort_value(config, &route.model, effort));
10449 let mcp_pool = if let Some((pool, failures)) =
10450 initialize_direct_workflow_mcp_pool(config, workspace, network_policy, plugin_registry)
10451 .await
10452 {
10453 for (server, error) in failures {
10454 tracing::warn!(
10455 server = %server,
10456 error = %error,
10457 "direct Workflow runtime could not connect MCP server"
10458 );
10459 }
10460 Some(pool)
10461 } else {
10462 None
10463 };
10464 let runtime = SubAgentRuntime::new(
10465 client,
10466 route.model.clone(),
10467 context.clone(),
10468 allow_shell,
10469 Some(event_tx),
10470 manager.clone(),
10471 )
10472 .with_locale_tag(
10473 crate::localization::resolve_locale(
10474 &crate::settings::Settings::load_persisted()
10475 .unwrap_or_default()
10476 .locale,
10477 )
10478 .tag(),
10479 )
10480 .with_role_models(role_models)
10481 .with_api_config(config.clone())
10482 .with_fleet_roster(roster)
10483 .with_auto_model(route.auto_model)
10484 .with_reasoning_effort(reasoning_effort, reasoning_effort_auto)
10485 .with_agent_tool_surface_options(surface)
10486 .with_max_spawn_depth(config.subagent_max_spawn_depth_for_provider(provider))
10487 .with_step_api_timeout(Duration::from_secs(
10488 config.subagent_api_timeout_secs_for_provider(provider),
10489 ))
10490 .with_speech_output_dir(config.speech_output_dir())
10491 .with_mcp_pool(mcp_pool)
10492 .with_todos(new_shared_todo_list())
10493 .with_parent_mode(mode);
10494
10495 Ok((
10496 crate::tools::workflow::WorkflowTool::new(manager, runtime).with_explicit_cli_approval(),
10497 context,
10498 ))
10499}
10500
10501async fn forward_direct_workflow_events(
10502 mut event_rx: tokio::sync::mpsc::Receiver<crate::core::events::Event>,
10503 mut stop_rx: tokio::sync::oneshot::Receiver<()>,
10504) -> Result<()> {
10505 loop {
10506 tokio::select! {
10507 biased;
10508 event = event_rx.recv() => match event {
10509 Some(event) => emit_direct_workflow_event(event)?,
10510 None => return Ok(()),
10511 },
10512 _ = &mut stop_rx => {
10513 while let Ok(event) = event_rx.try_recv() {
10514 emit_direct_workflow_event(event)?;
10515 }
10516 return Ok(());
10517 }
10518 }
10519 }
10520}
10521
10522fn emit_direct_workflow_event(event: crate::core::events::Event) -> Result<()> {
10523 if let crate::core::events::Event::WorkflowUi { run_id, event } = event {
10524 emit_exec_stream_event(&ExecStreamEvent::WorkflowEvent { run_id, event })?;
10525 }
10526 Ok(())
10527}
10528
10529fn direct_workflow_status(content: &str) -> Option<String> {
10530 serde_json::from_str::<serde_json::Value>(content)
10531 .ok()?
10532 .get("status")?
10533 .as_str()
10534 .map(str::to_ascii_lowercase)
10535}
10536
10537fn exec_stream_input_analysis(
10538 messages: &[Message],
10539 system: Option<&SystemPrompt>,
10540) -> ExecStreamInputAnalysis {
10541 let mut analysis = ExecStreamInputAnalysis {
10542 estimated_request_tokens: crate::compaction::estimate_input_tokens_conservative(
10543 messages, system,
10544 ),
10545 estimated_message_content_tokens: crate::compaction::estimate_tokens(messages),
10546 estimated_system_tokens: exec_stream_estimate_system_tokens(system),
10547 estimated_framing_tokens: messages.len().saturating_mul(12).saturating_add(48),
10548 ..ExecStreamInputAnalysis::default()
10549 };
10550
10551 for message in messages {
10552 match message.role.as_str() {
10553 "user" => analysis.user_message_count += 1,
10554 "assistant" => analysis.assistant_message_count += 1,
10555 "tool" => analysis.tool_message_count += 1,
10556 _ => {}
10557 }
10558
10559 for block in &message.content {
10560 match block {
10561 ContentBlock::Text { text, .. } => {
10562 exec_stream_add_text_estimate(
10563 text,
10564 &mut analysis.text_chars,
10565 &mut analysis.text_estimated_tokens,
10566 );
10567 }
10568 ContentBlock::Thinking { thinking, .. } => {
10569 exec_stream_add_text_estimate(
10570 thinking,
10571 &mut analysis.thinking_chars,
10572 &mut analysis.thinking_estimated_tokens,
10573 );
10574 }
10575 ContentBlock::ToolUse { input, .. } | ContentBlock::ServerToolUse { input, .. } => {
10576 analysis.tool_use_count += 1;
10577 exec_stream_add_json_estimate(
10578 input,
10579 &mut analysis.tool_use_input_chars,
10580 &mut analysis.tool_use_input_estimated_tokens,
10581 );
10582 }
10583 ContentBlock::ToolResult {
10584 content,
10585 content_blocks,
10586 ..
10587 } => {
10588 analysis.tool_result_count += 1;
10589 exec_stream_add_text_estimate(
10590 content,
10591 &mut analysis.tool_result_chars,
10592 &mut analysis.tool_result_estimated_tokens,
10593 );
10594 if let Some(blocks) = content_blocks {
10595 exec_stream_add_json_estimate(
10596 blocks,
10597 &mut analysis.tool_result_chars,
10598 &mut analysis.tool_result_estimated_tokens,
10599 );
10600 }
10601 }
10602 ContentBlock::ToolSearchToolResult { content, .. }
10603 | ContentBlock::CodeExecutionToolResult { content, .. } => {
10604 analysis.tool_result_count += 1;
10605 exec_stream_add_json_estimate(
10606 content,
10607 &mut analysis.tool_result_chars,
10608 &mut analysis.tool_result_estimated_tokens,
10609 );
10610 }
10611 ContentBlock::ImageUrl { .. } => {}
10612 }
10613 }
10614 }
10615
10616 analysis
10617}
10618
10619fn exec_stream_add_text_estimate(text: &str, chars: &mut usize, tokens: &mut usize) {
10620 *chars = chars.saturating_add(text.chars().count());
10621 *tokens = tokens.saturating_add(crate::compaction::estimate_text_tokens_conservative(text));
10622}
10623
10624fn exec_stream_add_json_estimate<T: serde::Serialize>(
10625 value: &T,
10626 chars: &mut usize,
10627 tokens: &mut usize,
10628) {
10629 let text = serde_json::to_string(value).unwrap_or_default();
10630 exec_stream_add_text_estimate(&text, chars, tokens);
10631}
10632
10633fn exec_stream_estimate_system_tokens(system: Option<&SystemPrompt>) -> usize {
10634 match system {
10635 Some(SystemPrompt::Text(text)) => {
10636 crate::compaction::estimate_text_tokens_conservative(text)
10637 }
10638 Some(SystemPrompt::Blocks(blocks)) => blocks
10639 .iter()
10640 .map(|block| crate::compaction::estimate_text_tokens_conservative(&block.text))
10641 .sum(),
10642 None => 0,
10643 }
10644}
10645
10646fn exec_saved_session_line(session_id: &str) -> String {
10647 format!("session: {}", truncate_id(session_id))
10648}
10649
10650fn exec_resumed_session_line(session_id: &str) -> String {
10651 format!("resumed session: {}", truncate_id(session_id))
10652}
10653
10654fn exec_stream_session_ref(session_id: &str) -> String {
10655 crate::utils::redacted_identifier_for_log(session_id)
10656}
10657
10658fn exec_stream_resume_hint(session_id: &str) -> String {
10659 if session_id.trim().is_empty() {
10660 String::new()
10661 } else {
10662 "codewhale exec --resume <redacted-session-id>".to_string()
10663 }
10664}
10665
10666#[derive(Clone, Copy)]
10667struct PersistedProviderRoute<'a> {
10668 kind: &'a str,
10669 id: Option<&'a str>,
10670}
10671
10672fn persist_exec_session(
10673 messages: &[Message],
10674 model: &str,
10675 provider_route: PersistedProviderRoute<'_>,
10676 workspace: &Path,
10677 system_prompt: &Option<SystemPrompt>,
10678 session_id: Option<&str>,
10679 total_tokens: u64,
10680) -> Result<String> {
10681 let manager =
10682 SessionManager::default_location().context("could not open session manager for save")?;
10683 let mut saved = if let Some(id) = session_id.filter(|id| !id.trim().is_empty()) {
10684 match manager.load_session(id) {
10685 Ok(existing) => session_manager::update_session(
10686 existing,
10687 messages,
10688 total_tokens,
10689 system_prompt.as_ref(),
10690 ),
10691 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
10692 session_manager::create_saved_session_with_id_and_mode(
10693 id.to_string(),
10694 messages,
10695 model,
10696 workspace,
10697 total_tokens,
10698 system_prompt.as_ref(),
10699 Some("exec"),
10700 )
10701 }
10702 Err(err) => return Err(err).context("could not load existing exec session"),
10703 }
10704 } else {
10705 session_manager::create_saved_session_with_mode(
10706 messages,
10707 model,
10708 workspace,
10709 total_tokens,
10710 system_prompt.as_ref(),
10711 Some("exec"),
10712 )
10713 };
10714 stamp_exec_session_metadata(
10715 &mut saved,
10716 model,
10717 provider_route.kind,
10718 provider_route.id,
10719 workspace,
10720 );
10721 let id = saved.metadata.id.clone();
10722 manager
10723 .save_session(&saved)
10724 .context("could not save exec session")?;
10725 Ok(id)
10726}
10727
10728fn stamp_exec_session_metadata(
10729 saved: &mut session_manager::SavedSession,
10730 model: &str,
10731 model_provider_kind: &str,
10732 model_provider_id: Option<&str>,
10733 workspace: &Path,
10734) {
10735 saved.metadata.model = model.to_string();
10736 saved
10737 .metadata
10738 .set_model_provider_route(model_provider_kind, model_provider_id);
10739 saved.metadata.workspace = workspace.to_path_buf();
10740 saved.metadata.mode = Some("exec".to_string());
10741}
10742
10743#[derive(serde::Serialize)]
10744struct ExecToolEntry {
10745 name: String,
10746 success: bool,
10747 output: String,
10748}
10749
10750#[derive(serde::Serialize)]
10751struct ExecOutcome {
10752 kind: String,
10753 outcome: String,
10754 tool_name: String,
10755 reason: String,
10756}
10757
10758#[derive(serde::Serialize, Default)]
10759struct ExecSummary {
10760 mode: String,
10761 provider: String,
10762 model: String,
10763 prompt: String,
10764 output: String,
10765 tools: Vec<ExecToolEntry>,
10766 outcomes: Vec<ExecOutcome>,
10767 status: Option<String>,
10768 termination_reason: Option<String>,
10769 error_category: Option<String>,
10770 error: Option<String>,
10771}
10772
10773fn validate_exec_tool_authority_resume(
10774 tool_authority_json: Option<&str>,
10775 resuming: bool,
10776) -> Result<()> {
10777 if tool_authority_json.is_some() && resuming {
10778 bail!(
10779 "Fleet tool authority cannot be combined with exec --resume, --session-id, or --continue"
10780 );
10781 }
10782 Ok(())
10783}
10784
10785fn exec_network_policy(
10786 config: &Config,
10787 outer_network_access: Option<bool>,
10788) -> Option<crate::network_policy::NetworkPolicyDecider> {
10789 if outer_network_access == Some(false) {
10792 return Some(crate::network_policy::NetworkPolicyDecider::new(
10793 crate::network_policy::NetworkPolicy {
10794 default: crate::network_policy::DecisionToml::Deny,
10795 ..crate::network_policy::NetworkPolicy::default()
10796 },
10797 None,
10798 ));
10799 }
10800 config.network.clone().map(|toml_cfg| {
10801 crate::network_policy::NetworkPolicyDecider::with_default_audit(toml_cfg.into_runtime())
10802 })
10803}
10804
10805fn apply_fleet_engine_feature_caps(
10806 features: &mut crate::features::Features,
10807 fleet_authority_active: bool,
10808 outer_network_access: Option<bool>,
10809 shell_authority: crate::tools::spec::ToolShellAuthority,
10810) {
10811 if fleet_authority_active {
10812 features.disable(crate::features::Feature::Subagents);
10813 features.disable(crate::features::Feature::Mcp);
10814 if shell_authority != crate::tools::spec::ToolShellAuthority::ReadOnly {
10815 features.disable(crate::features::Feature::ShellTool);
10816 }
10817 }
10818 if outer_network_access == Some(false) {
10819 features.disable(crate::features::Feature::WebSearch);
10820 }
10821}
10822
10823fn exec_max_steps(max_turns: Option<u32>) -> u32 {
10827 max_turns.unwrap_or(u32::MAX)
10828}
10829
10830#[allow(clippy::too_many_arguments)]
10831async fn run_exec_agent(
10832 config: &Config,
10833 model: &str,
10834 prompt: &str,
10835 workspace: PathBuf,
10836 max_subagents: usize,
10837 auto_approve: bool,
10838 allow_sandbox_elevation: bool,
10839 explicit_sandbox: Option<&str>,
10840 trust_mode: bool,
10841 json_output: bool,
10842 resume_session: Option<session_manager::SavedSession>,
10843 force_configured_route: bool,
10844 output_format: ExecOutputFormat,
10845 max_turns: u32,
10846 allowed_tools: Option<Vec<String>>,
10847 disallowed_tools: Option<Vec<String>>,
10848 append_system_prompt: Option<String>,
10849 tool_authority_json: Option<String>,
10850 plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
10851) -> Result<()> {
10852 use crate::compaction::CompactionConfig;
10853 use crate::core::engine::{EngineConfig, spawn_engine};
10854 use crate::core::events::Event;
10855 use crate::core::ops::Op;
10856 use crate::tools::plan::new_shared_plan_state;
10857 use crate::tools::todo::new_shared_todo_list;
10858 use crate::tui::app::AppMode;
10859
10860 validate_exec_tool_authority_resume(tool_authority_json.as_deref(), resume_session.is_some())?;
10861 let fleet_authority = tool_authority_json
10862 .as_deref()
10863 .map(crate::tools::spec::ToolAuthorityEnvelope::from_json)
10864 .transpose()
10865 .map_err(anyhow::Error::msg)?;
10866 let fleet_authority_active = fleet_authority.is_some();
10867 let outer_network_access = fleet_authority
10868 .as_ref()
10869 .and_then(|authority| authority.network_access);
10870 let outer_shell_authority = fleet_authority
10871 .as_ref()
10872 .map(|authority| authority.shell)
10873 .unwrap_or_default();
10874 if let Some(envelope) = fleet_authority {
10875 crate::tools::spec::install_process_tool_authority(envelope).map_err(anyhow::Error::msg)?;
10876 }
10877
10878 let route = resolve_cli_exec_route(config, model, prompt, force_configured_route).await?;
10879 let execution_config = config_for_cli_route(config, &route);
10880 let auto_model = route.auto_model;
10881 let effective_provider = route.provider;
10882 let effective_model = route.model;
10883 let validated_route = crate::route_runtime::resolve_runtime_route(
10884 &execution_config,
10885 effective_provider,
10886 Some(&effective_model),
10887 )
10888 .map_err(anyhow::Error::msg)?
10889 .validate()
10890 .map_err(anyhow::Error::msg)?;
10891 let effective_provider_name = validated_route.identity.key.clone();
10892 let effective_provider_id = validated_route.identity.exact_id.clone();
10893 let (effective_provider_kind, effective_stream_provider_id) =
10894 exec_stream_provider_route(&validated_route.identity);
10895 let route_source = if auto_model {
10896 "auto_resolver"
10897 } else {
10898 "explicit_or_configured"
10899 }
10900 .to_string();
10901 let exec_started = Instant::now();
10902 let prompt_sha256 = format!("sha256:{}", crate::hashing::sha256_hex(prompt.as_bytes()));
10903 let binary_sha256 = current_binary_sha256();
10904 let approval_posture = if auto_approve { "auto_tools" } else { "ask" }.to_string();
10905 let sandbox_posture = explicit_sandbox.unwrap_or("configured_default").to_string();
10906 let active_route_limits =
10907 crate::route_budget::known_route_limits(validated_route.candidate.limits());
10908 let max_subagents = if max_subagents == config.max_subagents_for_provider(config.api_provider())
10909 {
10910 execution_config
10911 .max_subagents_for_provider(effective_provider)
10912 .clamp(1, MAX_SUBAGENTS)
10913 } else {
10914 max_subagents
10915 };
10916 let reasoning_effort_auto = route.auto_controls_reasoning;
10923 let effective_reasoning_effort = route.reasoning_effort.and_then(|effort| {
10927 cli_reasoning_effort_value_for_prompt(&execution_config, &effective_model, effort, prompt)
10928 });
10929
10930 let settings = crate::settings::Settings::load().unwrap_or_default();
10931 let auto_compact_enabled = if crate::settings::Settings::auto_compact_explicitly_configured() {
10932 settings.auto_compact
10933 } else {
10934 crate::route_budget::auto_compact_default_for_route(
10935 effective_provider,
10936 &effective_model,
10937 active_route_limits,
10938 )
10939 };
10940 let compaction = CompactionConfig {
10941 enabled: auto_compact_enabled,
10942 model: effective_model.clone(),
10943 effective_context_window: Some(crate::route_budget::route_context_window_tokens(
10944 effective_provider,
10945 &effective_model,
10946 active_route_limits,
10947 )),
10948 token_threshold: crate::route_budget::compaction_threshold_for_route_at_percent(
10949 effective_provider,
10950 &effective_model,
10951 active_route_limits,
10952 settings.auto_compact_threshold_percent,
10953 ),
10954 ..Default::default()
10955 };
10956
10957 let network_policy = exec_network_policy(&execution_config, outer_network_access);
10958
10959 let lsp_config = (!fleet_authority_active)
10960 .then(|| {
10961 execution_config
10962 .lsp
10963 .clone()
10964 .map(crate::config::LspConfigToml::into_runtime)
10965 })
10966 .flatten();
10967 let mut engine_features = execution_config.features();
10968 apply_fleet_engine_feature_caps(
10969 &mut engine_features,
10970 fleet_authority_active,
10971 outer_network_access,
10972 outer_shell_authority,
10973 );
10974 let engine_plugin_registry = if fleet_authority_active {
10975 std::sync::Arc::new(crate::plugins::PluginRegistry::empty(&workspace))
10976 } else {
10977 plugin_registry
10978 };
10979 let engine_config = EngineConfig {
10980 model: effective_model.clone(),
10981 active_route_limits,
10982 workspace: workspace.clone(),
10983 subagent_state_root: None,
10984 plugin_registry: Some(engine_plugin_registry),
10985 allow_shell: crate::tools::spec::fleet_exec_shell_enabled(
10986 fleet_authority_active,
10987 outer_shell_authority,
10988 disallowed_tools.as_deref(),
10989 ) || (!fleet_authority_active
10990 && (auto_approve || execution_config.allow_shell())),
10991 trust_mode,
10992 notes_path: execution_config.notes_path(),
10993 mcp_config_path: execution_config.mcp_config_path(),
10994 skills_dir: execution_config.skills_dir(),
10995 skills_scan_codewhale_only: execution_config.skills_config().scan_codewhale_only(),
10996 instructions: {
10997 let mut instrs: Vec<crate::prompts::InstructionSource> = execution_config
10998 .instructions_paths()
10999 .into_iter()
11000 .map(Into::into)
11001 .collect();
11002 if let Some(ref extra) = append_system_prompt {
11003 instrs.push(crate::prompts::InstructionSource::Inline {
11004 name: "cli:append-system-prompt".into(),
11005 content: extra.clone(),
11006 });
11007 }
11008 instrs
11009 },
11010 project_context_pack_enabled: execution_config.project_context_pack_enabled(),
11011 translation_enabled: false,
11012 max_steps: max_turns,
11013 max_subagents,
11014 max_admitted_subagents: execution_config
11015 .max_admitted_subagents_for_provider(effective_provider)
11016 .max(max_subagents),
11017 launch_concurrency: execution_config.launch_concurrency_for_provider(effective_provider),
11018 subagents_enabled: !fleet_authority_active
11019 && execution_config.subagents_enabled_for_provider(effective_provider),
11020 features: engine_features,
11021 auto_review_policy: execution_config.auto_review_policy(),
11022 compaction: compaction.clone(),
11023 todos: new_shared_todo_list(),
11024 plan_state: new_shared_plan_state(),
11025 goal_state: crate::tools::goal::new_shared_goal_state(),
11026 max_spawn_depth: if fleet_authority_active {
11027 0
11028 } else {
11029 execution_config.subagent_max_spawn_depth_for_provider(effective_provider)
11030 },
11031 subagent_token_budget: execution_config
11032 .subagent_token_budget_for_provider(effective_provider),
11033 network_policy,
11034 snapshots_enabled: !fleet_authority_active && execution_config.snapshots_config().enabled,
11035 snapshots_max_workspace_bytes: execution_config
11036 .snapshots_config()
11037 .max_workspace_gb
11038 .saturating_mul(1024 * 1024 * 1024),
11039 lsp_config,
11040 runtime_services: crate::tools::spec::RuntimeToolServices::default(),
11041 subagent_model_overrides: execution_config.subagent_model_overrides(),
11042 fleet_roster: std::sync::Arc::new(crate::fleet::roster::FleetRoster::load(
11043 &execution_config.fleet_config(),
11044 &workspace,
11045 )),
11046 subagent_api_timeout: std::time::Duration::from_secs(
11047 execution_config.subagent_api_timeout_secs_for_provider(effective_provider),
11048 ),
11049 stream_chunk_timeout: std::time::Duration::from_secs(
11050 execution_config.stream_chunk_timeout_secs(),
11051 ),
11052 subagent_heartbeat_timeout: std::time::Duration::from_secs(
11053 execution_config.subagent_heartbeat_timeout_secs_for_provider(effective_provider),
11054 ),
11055 prefer_bwrap: execution_config.prefer_bwrap.unwrap_or(false),
11056 memory_enabled: execution_config.memory_enabled(),
11057 memory_path: execution_config.memory_path(),
11058 speech_output_dir: execution_config.speech_output_dir(),
11059 vision_config: execution_config.vision_model_config(),
11060 strict_tool_mode: execution_config.strict_tool_mode.unwrap_or(false),
11061 goal_objective: None,
11062 goal_token_budget: None,
11063 goal_status: crate::tools::goal::GoalStatus::Active,
11064 goal_max_continuations: execution_config.goal_max_continuations(),
11065 allowed_tools: allowed_tools.clone(),
11066 disallowed_tools: disallowed_tools.clone(),
11067 max_tool_calls: None,
11068 hook_executor: None,
11069 locale_tag: crate::localization::resolve_locale(&settings.locale)
11070 .tag()
11071 .to_string(),
11072 workshop: config.workshop.clone(),
11073 search_provider: execution_config.search_provider(),
11074 search_api_key: execution_config
11075 .search
11076 .as_ref()
11077 .and_then(|s| s.api_key.clone()),
11078 search_base_url: execution_config
11079 .search
11080 .as_ref()
11081 .and_then(|s| s.base_url.clone()),
11082 tools_always_load: if fleet_authority_active {
11083 std::collections::HashSet::new()
11084 } else {
11085 execution_config.tools_always_load()
11086 },
11087 tools: if fleet_authority_active {
11088 None
11089 } else {
11090 execution_config.tools.clone()
11091 },
11092 verbosity: execution_config.verbosity.clone(),
11093 workspace_follow_symlinks: settings.workspace_follow_symlinks,
11094 exec_policy_engine: execution_config.exec_policy_engine.clone(),
11095 terminal_chrome_enabled: false,
11096 advisor_config: execution_config
11097 .advisor
11098 .as_ref()
11099 .map(crate::tools::subagent::AdvisorConfig::from_toml)
11100 .unwrap_or_else(crate::tools::subagent::AdvisorConfig::disabled),
11101 };
11102
11103 let engine_handle = spawn_engine(engine_config, &execution_config);
11104 let mode = if auto_approve {
11105 AppMode::Yolo
11106 } else {
11107 AppMode::Agent
11108 };
11109
11110 let resuming_session = resume_session.is_some();
11111 let mut loaded_session_id = None;
11112 if let Some(saved) = resume_session {
11113 let saved_id = saved.metadata.id.clone();
11114 if saved.metadata.workspace != workspace && output_format == ExecOutputFormat::Text {
11115 eprintln!(
11116 "Warning: session {} was created in a different workspace ({}). Resuming anyway.",
11117 truncate_id(&saved_id),
11118 saved.metadata.workspace.display(),
11119 );
11120 }
11121
11122 engine_handle
11123 .send(Op::SyncSession {
11124 session_id: Some(saved_id.clone()),
11125 messages: saved.messages,
11126 system_prompt: saved.system_prompt.map(SystemPrompt::Text),
11127 system_prompt_override: false,
11128 model: saved.metadata.model,
11129 workspace: saved.metadata.workspace,
11130 mode,
11131 })
11132 .await?;
11133 loaded_session_id = Some(saved_id.clone());
11134 if output_format == ExecOutputFormat::Text && !json_output {
11135 eprintln!("{}", exec_resumed_session_line(&saved_id));
11136 }
11137 }
11138
11139 engine_handle
11140 .send(Op::SendMessage {
11141 content: prompt.to_string(),
11142 mode,
11143 route: Box::new(validated_route.into_resolved()),
11144 compaction: Box::new(compaction.clone()),
11145 goal_objective: None,
11146 goal_token_budget: None,
11147 goal_status: crate::tools::goal::GoalStatus::Active,
11148 allowed_tools: allowed_tools.clone(),
11149 dynamic_tools: Vec::new(),
11150 hook_executor: None,
11151 reasoning_effort: effective_reasoning_effort,
11152 reasoning_effort_auto,
11153 auto_model,
11154 allow_shell: auto_approve || execution_config.allow_shell(),
11155 trust_mode,
11156 auto_approve,
11157 translation_enabled: false,
11158 approval_mode: if auto_approve {
11159 crate::tui::approval::ApprovalMode::Bypass
11160 } else {
11161 execution_config
11162 .approval_policy
11163 .as_deref()
11164 .and_then(crate::tui::approval::ApprovalMode::from_config_value)
11165 .unwrap_or_default()
11166 },
11167 verbosity: execution_config.verbosity.clone(),
11168 provenance: crate::core::ops::UserInputProvenance::ExternalUser,
11169 })
11170 .await?;
11171
11172 let mut summary = ExecSummary {
11173 mode: "agent".to_string(),
11174 provider: effective_provider_name.clone(),
11175 model: effective_model.clone(),
11176 prompt: prompt.to_string(),
11177 ..ExecSummary::default()
11178 };
11179 let can_elevate_sandbox =
11180 exec_sandbox_elevation_authorized(allow_sandbox_elevation, explicit_sandbox);
11181 let mut sandbox_denied = false;
11182 let mut approval_required = false;
11183 let mut tool_error_seen = false;
11184 let mut last_error_category = None;
11185 let mut reported_sandbox_contract = false;
11186
11187 let should_persist_session = resuming_session || output_format == ExecOutputFormat::StreamJson;
11188 let mut latest_session_id = loaded_session_id;
11189 let mut latest_messages: Vec<Message> = Vec::new();
11190 let mut latest_system_prompt: Option<SystemPrompt> = None;
11191 let mut latest_model = effective_model;
11192 let mut latest_workspace = workspace.clone();
11193 let mut tool_starts: HashMap<String, (Instant, String)> = HashMap::new();
11194 let mut turn_usage_seq: u32 = 0;
11195
11196 let mut stdout = io::stdout();
11197 let mut ends_with_newline = false;
11198 loop {
11199 let event = {
11200 let mut rx = engine_handle.rx_event.write().await;
11201 rx.recv().await
11202 };
11203
11204 let Some(event) = event else {
11205 break;
11206 };
11207
11208 match event {
11209 Event::MessageDelta { content, .. } => {
11210 summary.output.push_str(&content);
11211 if output_format == ExecOutputFormat::StreamJson {
11212 emit_exec_stream_event(&ExecStreamEvent::Content { content })?;
11213 } else if !json_output {
11214 print!("{content}");
11215 stdout.flush()?;
11216 }
11217 ends_with_newline = summary.output.ends_with('\n');
11218 }
11219 Event::MessageComplete { .. }
11220 if output_format == ExecOutputFormat::Text
11221 && !json_output
11222 && !ends_with_newline =>
11223 {
11224 println!();
11225 }
11226 Event::ThinkingDelta { .. } => {
11227 }
11230 Event::ToolCallStarted { id, name, input } => {
11231 let started_at = chrono::Utc::now().to_rfc3339();
11232 tool_starts.insert(id.clone(), (Instant::now(), started_at.clone()));
11233 if output_format == ExecOutputFormat::StreamJson {
11234 emit_exec_stream_event(&ExecStreamEvent::ToolUse {
11235 name,
11236 id,
11237 input,
11238 started_at,
11239 })?;
11240 } else if !json_output {
11241 let summary = summarize_tool_args(&input);
11242 if let Some(summary) = summary {
11243 eprintln!("tool: {name} ({summary})");
11244 } else {
11245 eprintln!("tool: {name}");
11246 }
11247 }
11248 }
11249 Event::ToolCallComplete {
11250 id, name, result, ..
11251 } => {
11252 let (duration_ms, started_at) = tool_starts
11253 .remove(&id)
11254 .map(|(started, timestamp)| {
11255 (
11256 u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
11257 timestamp,
11258 )
11259 })
11260 .unwrap_or_else(|| (0, chrono::Utc::now().to_rfc3339()));
11261 let receipt_name = name.clone();
11262 match result {
11263 Ok(output) => {
11264 tool_error_seen |= !output.success;
11265 summary.tools.push(ExecToolEntry {
11266 name: name.clone(),
11267 success: output.success,
11268 output: output.content.clone(),
11269 });
11270 if output_format == ExecOutputFormat::StreamJson {
11271 emit_exec_stream_event(&ExecStreamEvent::ToolResult {
11272 id,
11273 name: receipt_name,
11274 output: output.content,
11275 status: if output.success {
11276 "success".to_string()
11277 } else {
11278 "error".to_string()
11279 },
11280 started_at,
11281 completed_at: chrono::Utc::now().to_rfc3339(),
11282 duration_ms,
11283 side_effect_status: output
11284 .metadata
11285 .as_ref()
11286 .and_then(|metadata| metadata.get("side_effect_status"))
11287 .and_then(serde_json::Value::as_str)
11288 .unwrap_or("unknown")
11289 .to_string(),
11290 error_category: (!output.success).then(|| {
11291 output
11292 .metadata
11293 .as_ref()
11294 .and_then(|metadata| metadata.get("error_category"))
11295 .and_then(serde_json::Value::as_str)
11296 .unwrap_or("tool_reported_failure")
11297 .to_string()
11298 }),
11299 truncated: output
11300 .metadata
11301 .as_ref()
11302 .and_then(|metadata| metadata.get("truncated"))
11303 .and_then(serde_json::Value::as_bool),
11304 artifact: tool_artifact_receipt(output.metadata.as_ref()),
11305 result_metadata: output.metadata,
11306 })?;
11307 } else if !json_output {
11308 if name == "exec_shell" && !output.content.trim().is_empty() {
11309 eprintln!("tool {name} completed");
11310 eprintln!(
11311 "--- stdout/stderr ---\n{}\n---------------------",
11312 output.content
11313 );
11314 } else {
11315 eprintln!(
11316 "tool {name} completed: {}",
11317 summarize_tool_output(&output.content)
11318 );
11319 }
11320 }
11321 }
11322 Err(err) => {
11323 tool_error_seen = true;
11324 let error_text = err.to_string();
11325 summary.tools.push(ExecToolEntry {
11326 name: name.clone(),
11327 success: false,
11328 output: error_text.clone(),
11329 });
11330 if output_format == ExecOutputFormat::StreamJson {
11331 emit_exec_stream_event(&ExecStreamEvent::ToolResult {
11332 id,
11333 name: receipt_name,
11334 output: error_text,
11335 status: "error".to_string(),
11336 started_at,
11337 completed_at: chrono::Utc::now().to_rfc3339(),
11338 duration_ms,
11339 side_effect_status: "not_started_or_unknown".to_string(),
11340 error_category: Some(tool_error_receipt_category(&err).to_string()),
11341 truncated: None,
11342 artifact: None,
11343 result_metadata: None,
11344 })?;
11345 } else if !json_output {
11346 eprintln!("tool {name} failed: {err}");
11347 }
11348 }
11349 }
11350 }
11351 Event::AgentSpawned { id, prompt, .. }
11352 if output_format == ExecOutputFormat::Text && !json_output =>
11353 {
11354 eprintln!("sub-agent {id} spawned: {}", summarize_tool_output(&prompt));
11355 }
11356 Event::AgentProgress { id, status, .. }
11357 if output_format == ExecOutputFormat::Text && !json_output =>
11358 {
11359 eprintln!("sub-agent {id}: {status}");
11360 }
11361 Event::AgentComplete { id, result }
11362 if output_format == ExecOutputFormat::Text && !json_output =>
11363 {
11364 eprintln!(
11365 "sub-agent {id} completed: {}",
11366 summarize_tool_output(&result)
11367 );
11368 }
11369 Event::AgentSpawned {
11370 id,
11371 parent_run_id,
11372 spawn_depth,
11373 model,
11374 route_source,
11375 ..
11376 } if output_format == ExecOutputFormat::StreamJson => {
11377 emit_exec_stream_event(&ExecStreamEvent::AgentSpawned {
11378 id,
11379 model,
11380 spawn_depth,
11381 parent_run_id,
11382 route_source,
11383 })?;
11384 }
11385 Event::AgentSpawned { .. }
11386 | Event::AgentProgress { .. }
11387 | Event::AgentComplete { .. } => {}
11388 Event::WorkflowUi { run_id, event }
11389 if output_format == ExecOutputFormat::StreamJson =>
11390 {
11391 emit_exec_stream_event(&ExecStreamEvent::WorkflowEvent { run_id, event })?;
11392 }
11393 Event::ApprovalRequired { id, .. } => {
11394 if auto_approve {
11395 let _ = engine_handle.approve_tool_call(id).await;
11396 } else {
11397 approval_required = true;
11398 let _ = engine_handle.deny_tool_call(id).await;
11399 }
11400 }
11401 Event::ElevationRequired {
11402 tool_id,
11403 tool_name,
11404 denial_reason,
11405 ..
11406 } => {
11407 if can_elevate_sandbox {
11408 let policy = crate::sandbox::SandboxPolicy::DangerFullAccess;
11409 let _ = engine_handle.retry_tool_with_policy(tool_id, policy).await;
11410 } else {
11411 sandbox_denied = true;
11412 approval_required = true;
11413 summary.outcomes.push(ExecOutcome {
11414 kind: "sandbox_denied".to_string(),
11415 outcome: "approval_required".to_string(),
11416 tool_name: tool_name.clone(),
11417 reason: denial_reason.clone(),
11418 });
11419 if !reported_sandbox_contract {
11420 eprintln!(
11421 "sandbox denied {tool_name}: {denial_reason}; --auto approves tools but does not elevate sandbox access — use --sandbox danger-full-access or --allow-sandbox-elevation to opt in"
11422 );
11423 reported_sandbox_contract = true;
11424 }
11425 if output_format == ExecOutputFormat::StreamJson {
11426 emit_exec_stream_event(&ExecStreamEvent::SandboxDenied {
11427 tool_id: tool_id.clone(),
11428 tool_name,
11429 reason: denial_reason,
11430 outcome: "approval_required".to_string(),
11431 })?;
11432 }
11433 let _ = engine_handle.deny_tool_call(tool_id).await;
11434 }
11435 }
11436 Event::Error {
11437 envelope,
11438 recoverable: _,
11439 } => {
11440 if exec_error_event_is_fatal(&envelope) {
11448 last_error_category = Some(envelope.category);
11449 summary.error_category = Some(envelope.category.to_string());
11450 summary.error = Some(envelope.message.clone());
11451 }
11452 if output_format == ExecOutputFormat::StreamJson {
11453 emit_exec_stream_event(&ExecStreamEvent::Error {
11454 error: envelope.message,
11455 })?;
11456 } else if !json_output {
11457 eprintln!("error: {}", envelope.message);
11458 }
11459 }
11460 Event::TurnUsage { usage, duration_ms } => {
11461 if output_format == ExecOutputFormat::StreamJson {
11462 turn_usage_seq = turn_usage_seq.saturating_add(1);
11463 emit_exec_stream_event(&ExecStreamEvent::TurnUsage {
11464 turn: turn_usage_seq,
11465 input_tokens: usage.input_tokens,
11466 output_tokens: usage.output_tokens,
11467 reasoning_tokens: usage.reasoning_tokens,
11468 prompt_cache_hit_tokens: usage.prompt_cache_hit_tokens,
11469 prompt_cache_miss_tokens: usage.prompt_cache_miss_tokens,
11470 prompt_cache_write_tokens: usage.prompt_cache_write_tokens,
11471 reasoning_replay_tokens: usage.reasoning_replay_tokens,
11472 duration_ms,
11473 })?;
11474 }
11475 }
11476 Event::TurnComplete {
11477 status,
11478 error,
11479 usage,
11480 tool_catalog,
11481 ..
11482 } => {
11483 summary.status = Some(format!("{status:?}").to_lowercase());
11484 if error.is_some() {
11485 summary.error = error;
11486 }
11487 if sandbox_denied
11488 && summary.error.is_none()
11489 && matches!(status, crate::core::events::TurnOutcomeStatus::Failed)
11490 {
11491 summary.error = Some(
11492 "exec turn failed after sandbox denial; explicit sandbox elevation was not authorized"
11493 .to_string(),
11494 );
11495 }
11496 if last_error_category.is_none() {
11497 last_error_category = summary
11498 .error
11499 .as_deref()
11500 .map(crate::error_taxonomy::classify_error_message);
11501 summary.error_category =
11502 last_error_category.map(|category| category.to_string());
11503 }
11504 let termination_reason = crate::core::termination::classify_turn_termination(
11505 status,
11506 last_error_category,
11507 tool_error_seen,
11508 approval_required,
11509 );
11510 summary.termination_reason = Some(termination_reason.as_str().to_string());
11511 if !termination_reason.is_success() {
11517 codewhale_telemetry::set_exit_class(codewhale_telemetry::ExitClass::Error);
11518 }
11519 let saved_session_id = if should_persist_session && !latest_messages.is_empty() {
11520 match persist_exec_session(
11521 &latest_messages,
11522 &latest_model,
11523 PersistedProviderRoute {
11524 kind: effective_provider.as_str(),
11525 id: effective_provider_id.as_deref(),
11526 },
11527 &latest_workspace,
11528 &latest_system_prompt,
11529 latest_session_id.as_deref(),
11530 u64::from(usage.input_tokens) + u64::from(usage.output_tokens),
11531 ) {
11532 Ok(id) => {
11533 if output_format == ExecOutputFormat::Text && !json_output {
11534 eprintln!("{}", exec_saved_session_line(&id));
11535 }
11536 Some(id)
11537 }
11538 Err(err) => {
11539 if output_format == ExecOutputFormat::Text && !json_output {
11540 eprintln!("warning: failed to save exec session: {err}");
11541 }
11542 latest_session_id.clone()
11543 }
11544 }
11545 } else {
11546 latest_session_id.clone()
11547 };
11548
11549 if output_format == ExecOutputFormat::StreamJson {
11550 if let Some(id) = saved_session_id.as_ref() {
11551 emit_exec_stream_event(&ExecStreamEvent::SessionCapture {
11552 content: exec_stream_session_ref(id),
11553 })?;
11554 }
11555 emit_exec_stream_event(&ExecStreamEvent::Metadata {
11556 meta: Box::new(ExecStreamMeta {
11557 receipt_kind: "terminal",
11558 provider: effective_provider_kind.clone(),
11559 provider_id: effective_stream_provider_id.clone(),
11560 model: latest_model.clone(),
11561 route_source: route_source.clone(),
11562 input_tokens: Some(usage.input_tokens),
11563 output_tokens: Some(usage.output_tokens),
11564 prompt_cache_hit_tokens: usage.prompt_cache_hit_tokens,
11565 prompt_cache_miss_tokens: usage.prompt_cache_miss_tokens,
11566 prompt_cache_write_tokens: usage.prompt_cache_write_tokens,
11567 reasoning_tokens: usage.reasoning_tokens,
11568 duration_ms: u64::try_from(exec_started.elapsed().as_millis())
11569 .unwrap_or(u64::MAX),
11570 retry_count: None,
11571 approval_posture: approval_posture.clone(),
11572 sandbox_posture: sandbox_posture.clone(),
11573 binary_sha256: binary_sha256.clone(),
11574 config_sha256: None,
11575 prompt_sha256: prompt_sha256.clone(),
11576 tool_catalog_sha256: tool_catalog.as_ref().and_then(|catalog| {
11577 serde_json::to_vec(catalog).ok().map(|bytes| {
11578 format!("sha256:{}", crate::hashing::sha256_hex(&bytes))
11579 })
11580 }),
11581 input_analysis: exec_stream_input_analysis(
11582 &latest_messages,
11583 latest_system_prompt.as_ref(),
11584 ),
11585 visible_final_answer_chars: summary.output.chars().count(),
11586 resume_command: saved_session_id
11587 .as_deref()
11588 .map(exec_stream_resume_hint)
11589 .unwrap_or_default(),
11590 session_id: saved_session_id
11591 .as_deref()
11592 .map(exec_stream_session_ref)
11593 .unwrap_or_default(),
11594 workspace: latest_workspace.display().to_string(),
11595 message_count: latest_messages.len(),
11596 status: summary.status.clone(),
11597 termination_reason: summary.termination_reason.clone(),
11598 error_category: summary.error_category.clone(),
11599 }),
11600 })?;
11601 emit_exec_stream_event(&ExecStreamEvent::Done)?;
11602 }
11603 let _ = engine_handle.send(Op::Shutdown).await;
11604 break;
11605 }
11606 Event::SessionUpdated {
11607 session_id,
11608 messages,
11609 system_prompt,
11610 model,
11611 workspace,
11612 } => {
11613 latest_session_id = Some(session_id);
11614 latest_messages = messages;
11615 latest_system_prompt = system_prompt;
11616 latest_model = model;
11617 latest_workspace = workspace;
11618 }
11619 Event::Status { message }
11622 if output_format == ExecOutputFormat::Text
11623 && !json_output
11624 && message.contains("Reached maximum steps") =>
11625 {
11626 eprintln!("{message}");
11627 }
11628 _ => {}
11629 }
11630 }
11631
11632 if json_output {
11633 println!("{}", serde_json::to_string_pretty(&summary)?);
11634 }
11635
11636 if let Some(error) = summary.error.as_ref()
11637 && !error.trim().is_empty()
11638 {
11639 let exit_code = exec_failure_exit_code(summary.error_category.as_deref());
11645 if exit_code != 1 {
11646 eprintln!("Error: exec turn failed: {error}");
11647 let _ = io::stdout().flush();
11648 std::process::exit(exit_code);
11649 }
11650 bail!("exec turn failed: {error}");
11651 }
11652
11653 if matches!(
11654 summary.status.as_deref(),
11655 Some("failed" | "canceled" | "interrupted")
11656 ) {
11657 let status = summary.status.as_deref().unwrap_or("unknown");
11658 bail!("exec turn ended with status {status}");
11659 }
11660
11661 Ok(())
11662}
11663
11664#[cfg(test)]
11665mod serve_bind_host_tests {
11666 use super::*;
11667
11668 #[test]
11669 fn http_defaults_to_loopback() {
11670 assert_eq!(
11671 resolve_serve_bind_host(false, None),
11672 ServeBindHost {
11673 host: "127.0.0.1".to_string(),
11674 mobile_rebound_to_lan: false,
11675 }
11676 );
11677 }
11678
11679 #[test]
11680 fn mobile_default_rebinds_to_lan_with_warning_flag() {
11681 assert_eq!(
11682 resolve_serve_bind_host(true, None),
11683 ServeBindHost {
11684 host: "0.0.0.0".to_string(),
11685 mobile_rebound_to_lan: true,
11686 }
11687 );
11688 }
11689
11690 #[test]
11691 fn mobile_respects_explicit_loopback_host() {
11692 assert_eq!(
11693 resolve_serve_bind_host(true, Some("127.0.0.1".to_string())),
11694 ServeBindHost {
11695 host: "127.0.0.1".to_string(),
11696 mobile_rebound_to_lan: false,
11697 }
11698 );
11699 }
11700
11701 #[test]
11702 fn http_and_mobile_are_mutually_exclusive() {
11703 let err = validate_serve_mode_selection(false, true, true, false, false).unwrap_err();
11704 assert!(
11705 err.to_string()
11706 .contains("--http and --mobile are mutually exclusive")
11707 );
11708 }
11709
11710 #[test]
11711 fn web_is_a_distinct_loopback_runtime_mode() {
11712 assert!(validate_serve_mode_selection(false, false, false, true, false).unwrap());
11713 let err = validate_serve_mode_selection(false, true, false, true, false).unwrap_err();
11714 assert!(err.to_string().contains("--web is mutually exclusive"));
11715 assert_eq!(
11716 resolve_serve_bind_host(false, None),
11717 ServeBindHost {
11718 host: "127.0.0.1".to_string(),
11719 mobile_rebound_to_lan: false,
11720 }
11721 );
11722 }
11723}
11724
11725#[cfg(test)]
11726mod exec_exit_semantics_tests {
11727 use super::*;
11728
11729 #[test]
11730 fn retryable_infra_categories_exit_with_ex_tempfail() {
11731 assert_eq!(exec_failure_exit_code(Some("network")), 75);
11734 assert_eq!(exec_failure_exit_code(Some("timeout")), 75);
11735 assert_eq!(EXEC_EXIT_RETRYABLE_INFRA, 75, "EX_TEMPFAIL from sysexits.h");
11736 }
11737
11738 #[test]
11739 fn genuine_failures_keep_exit_1() {
11740 assert_eq!(exec_failure_exit_code(Some("tool")), 1);
11743 assert_eq!(exec_failure_exit_code(Some("authentication")), 1);
11744 assert_eq!(exec_failure_exit_code(Some("invalid_input")), 1);
11745 assert_eq!(exec_failure_exit_code(None), 1);
11746 assert_eq!(exec_failure_exit_code(Some("rate_limit")), 1);
11749 }
11750
11751 #[test]
11752 fn recoverable_error_events_do_not_fail_the_run_summary() {
11753 let warning = crate::error_taxonomy::ErrorEnvelope::network(
11757 "Stream stalled: no data received for 120s, closing stream",
11758 );
11759 assert!(
11760 !exec_error_event_is_fatal(&warning),
11761 "recoverable envelopes must not poison the exec summary"
11762 );
11763 let fatal = crate::error_taxonomy::ErrorEnvelope::fatal("engine exploded");
11764 assert!(exec_error_event_is_fatal(&fatal));
11765 }
11766}
11767
11768#[cfg(test)]
11769mod doctor_legacy_state_tests {
11770 use super::*;
11771 use std::env;
11772 use std::ffi::OsString;
11773 use std::fs;
11774 use tempfile::TempDir;
11775
11776 struct EnvVarRestore {
11777 key: &'static str,
11778 previous: Option<OsString>,
11779 }
11780
11781 impl EnvVarRestore {
11782 fn set(key: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
11783 let previous = env::var_os(key);
11784 unsafe {
11785 env::set_var(key, value);
11786 }
11787 Self { key, previous }
11788 }
11789 }
11790
11791 impl Drop for EnvVarRestore {
11792 fn drop(&mut self) {
11793 unsafe {
11794 match &self.previous {
11795 Some(value) => env::set_var(self.key, value),
11796 None => env::remove_var(self.key),
11797 }
11798 }
11799 }
11800 }
11801
11802 fn roots(tmp: &TempDir) -> (PathBuf, PathBuf) {
11803 (tmp.path().join(".codewhale"), tmp.path().join(".deepseek"))
11804 }
11805
11806 fn entry<'a>(report: &'a [DoctorLegacyStateEntry], name: &str) -> &'a DoctorLegacyStateEntry {
11807 report
11808 .iter()
11809 .find(|entry| entry.name == name)
11810 .expect("legacy state entry should exist")
11811 }
11812
11813 #[test]
11814 fn doctor_legacy_state_report_marks_unmigrated_legacy_entries() {
11815 let tmp = TempDir::new().expect("tempdir");
11816 let (primary_root, legacy_root) = roots(&tmp);
11817 fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
11818 fs::create_dir_all(legacy_root.join("tasks")).expect("legacy tasks");
11819 fs::create_dir_all(&primary_root).expect("primary root");
11820 fs::write(legacy_root.join("config.toml"), "api_key = 'old'").expect("legacy config");
11821
11822 let report = doctor_legacy_state_report(&primary_root, &legacy_root);
11823 let session_recovery = doctor_session_recovery_report(&primary_root, &legacy_root, false);
11824
11825 assert_eq!(
11826 entry(&report, "sessions").status,
11827 DoctorLegacyStateStatus::LegacyOnly
11828 );
11829 assert_eq!(
11830 entry(&report, "config.toml").status,
11831 DoctorLegacyStateStatus::LegacyOnly
11832 );
11833 assert_eq!(
11834 entry(&report, "skills").status,
11835 DoctorLegacyStateStatus::Absent
11836 );
11837
11838 let json =
11839 doctor_legacy_state_json(&primary_root, &legacy_root, &report, &session_recovery);
11840 assert_eq!(json["needs_attention"], true);
11841 assert_eq!(json["legacy_only_count"], 3);
11842 assert_eq!(json["dual_present_count"], 0);
11843 }
11844
11845 #[test]
11846 fn doctor_legacy_state_report_marks_dual_present_entries() {
11847 let tmp = TempDir::new().expect("tempdir");
11848 let (primary_root, legacy_root) = roots(&tmp);
11849 fs::create_dir_all(primary_root.join("sessions")).expect("primary sessions");
11850 fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
11851 fs::write(primary_root.join("mcp.json"), "{}").expect("primary mcp");
11852 fs::write(legacy_root.join("mcp.json"), "{}").expect("legacy mcp");
11853
11854 let report = doctor_legacy_state_report(&primary_root, &legacy_root);
11855 let session_recovery = doctor_session_recovery_report(&primary_root, &legacy_root, false);
11856
11857 assert_eq!(
11858 entry(&report, "sessions").status,
11859 DoctorLegacyStateStatus::Both
11860 );
11861 assert_eq!(
11862 entry(&report, "mcp.json").status,
11863 DoctorLegacyStateStatus::Both
11864 );
11865
11866 let json =
11867 doctor_legacy_state_json(&primary_root, &legacy_root, &report, &session_recovery);
11868 assert_eq!(json["needs_attention"], true);
11869 assert_eq!(json["legacy_only_count"], 0);
11870 assert_eq!(json["dual_present_count"], 2);
11871 }
11872
11873 #[test]
11874 fn doctor_legacy_state_report_is_clear_when_only_primary_exists() {
11875 let tmp = TempDir::new().expect("tempdir");
11876 let (primary_root, legacy_root) = roots(&tmp);
11877 fs::create_dir_all(primary_root.join("sessions")).expect("primary sessions");
11878 fs::write(primary_root.join("settings.toml"), "default_mode = 'ask'")
11879 .expect("primary settings");
11880
11881 let report = doctor_legacy_state_report(&primary_root, &legacy_root);
11882 let session_recovery = doctor_session_recovery_report(&primary_root, &legacy_root, false);
11883
11884 assert_eq!(
11885 entry(&report, "sessions").status,
11886 DoctorLegacyStateStatus::PrimaryOnly
11887 );
11888 assert!(!report.iter().any(legacy_state_needs_attention));
11889
11890 let json =
11891 doctor_legacy_state_json(&primary_root, &legacy_root, &report, &session_recovery);
11892 assert_eq!(json["needs_attention"], false);
11893 assert_eq!(json["legacy_only_count"], 0);
11894 assert_eq!(json["dual_present_count"], 0);
11895 }
11896
11897 #[test]
11898 fn doctor_legacy_state_report_is_clear_when_neither_root_exists() {
11899 let tmp = TempDir::new().expect("tempdir");
11900 let (primary_root, legacy_root) = roots(&tmp);
11901
11902 let report = doctor_legacy_state_report(&primary_root, &legacy_root);
11903 let session_recovery = doctor_session_recovery_report(&primary_root, &legacy_root, false);
11904
11905 assert!(
11906 report
11907 .iter()
11908 .all(|entry| entry.status == DoctorLegacyStateStatus::Absent)
11909 );
11910 assert!(!report.iter().any(legacy_state_needs_attention));
11911
11912 let json =
11913 doctor_legacy_state_json(&primary_root, &legacy_root, &report, &session_recovery);
11914 assert_eq!(json["needs_attention"], false);
11915 assert_eq!(json["legacy_only_count"], 0);
11916 assert_eq!(json["dual_present_count"], 0);
11917 }
11918
11919 #[test]
11920 fn doctor_reports_incomplete_session_migration_without_mutating_files() {
11921 let tmp = TempDir::new().expect("tempdir");
11922 let (primary_root, legacy_root) = roots(&tmp);
11923 let primary_sessions = primary_root.join("sessions");
11924 let legacy_sessions = legacy_root.join("sessions");
11925 fs::create_dir_all(&primary_sessions).expect("primary sessions");
11926 fs::create_dir_all(legacy_sessions.join("checkpoints")).expect("legacy checkpoints");
11927 fs::write(primary_sessions.join("already-there.json"), b"primary")
11928 .expect("primary session");
11929 fs::write(legacy_sessions.join("already-there.json"), b"legacy")
11930 .expect("legacy matching session");
11931 fs::write(
11932 legacy_sessions.join("recover-me.json"),
11933 b"not parsed by doctor",
11934 )
11935 .expect("legacy recoverable session");
11936 fs::write(
11937 legacy_sessions.join("checkpoints").join("latest.json"),
11938 b"checkpoint not inspected",
11939 )
11940 .expect("legacy checkpoint");
11941
11942 let legacy_before = fs::read(legacy_sessions.join("recover-me.json"))
11943 .expect("read legacy fixture before diagnostic");
11944 let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
11945
11946 assert_eq!(
11947 report.status,
11948 DoctorSessionRecoveryStatus::MigrationIncomplete
11949 );
11950 assert_eq!(report.legacy_session_file_count, 2);
11951 assert_eq!(report.already_present_file_count, 1);
11952 assert_eq!(report.recoverable_file_count, 1);
11953 assert_eq!(report.recoverable.len(), 1);
11954 assert_eq!(report.recoverable[0].name, PathBuf::from("recover-me.json"));
11955 assert!(
11956 !primary_sessions.join("recover-me.json").exists(),
11957 "doctor must not copy a recoverable session"
11958 );
11959 assert_eq!(
11960 fs::read(legacy_sessions.join("recover-me.json"))
11961 .expect("legacy file remains after diagnostic"),
11962 legacy_before,
11963 "doctor must not rewrite or delete the legacy source"
11964 );
11965
11966 let json = doctor_session_recovery_json(&report);
11967 assert_eq!(json["needs_attention"], true);
11968 assert_eq!(json["read_only"], true);
11969 assert_eq!(json["chat_contents_read"], false);
11970 assert_eq!(json["checkpoint_internals_scanned"], false);
11971 assert_eq!(json["recoverable_file_count"], 1);
11972 assert_eq!(json["recovery_command"], "codewhale sessions");
11973 assert_eq!(json["recoverable_files"][0]["name"], "recover-me.json");
11974 let serialized = json.to_string();
11975 assert!(
11976 !serialized.contains("not parsed by doctor"),
11977 "the report must not expose session contents"
11978 );
11979 assert!(
11980 !serialized.contains("checkpoint not inspected"),
11981 "the report must not expose checkpoint contents"
11982 );
11983 }
11984
11985 #[test]
11986 fn doctor_treats_preserved_legacy_sessions_as_complete_by_filename() {
11987 let tmp = TempDir::new().expect("tempdir");
11988 let (primary_root, legacy_root) = roots(&tmp);
11989 let primary_sessions = primary_root.join("sessions");
11990 let legacy_sessions = legacy_root.join("sessions");
11991 fs::create_dir_all(&primary_sessions).expect("primary sessions");
11992 fs::create_dir_all(&legacy_sessions).expect("legacy sessions");
11993 fs::write(primary_sessions.join("same-name.json"), b"primary").expect("primary session");
11994 fs::write(legacy_sessions.join("same-name.json"), b"legacy").expect("legacy session");
11995
11996 let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
11997
11998 assert_eq!(
11999 report.status,
12000 DoctorSessionRecoveryStatus::MigrationComplete
12001 );
12002 assert!(!report.needs_attention());
12003 assert_eq!(report.recoverable_file_count, 0);
12004 assert!(report.recoverable.is_empty());
12005 assert_eq!(report.already_present_file_count, 1);
12006 let json = doctor_session_recovery_json(&report);
12007 assert_eq!(json["session_descriptors_compared"], false);
12008 assert_eq!(
12009 json["counterpart_check"],
12010 "top_level_filename_and_regular_file_only"
12011 );
12012 }
12013
12014 #[test]
12015 fn doctor_bounds_recoverable_session_filename_samples() {
12016 let tmp = TempDir::new().expect("tempdir");
12017 let (primary_root, legacy_root) = roots(&tmp);
12018 let legacy_sessions = legacy_root.join("sessions");
12019 fs::create_dir_all(&legacy_sessions).expect("legacy sessions");
12020 for index in 0..DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT {
12021 fs::write(
12022 legacy_sessions.join(format!("late-{index:03}.json")),
12023 b"fixture",
12024 )
12025 .expect("legacy session fixture");
12026 }
12027 fs::write(legacy_sessions.join("early-000.json"), b"fixture")
12028 .expect("earliest legacy session fixture");
12029 fs::write(legacy_sessions.join("early-001.json"), b"fixture")
12030 .expect("second earliest legacy session fixture");
12031 let total = DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT + 2;
12032
12033 let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12034 let json = doctor_session_recovery_json(&report);
12035
12036 assert_eq!(report.recoverable_file_count, total);
12037 assert_eq!(
12038 report.recoverable.len(),
12039 DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT
12040 );
12041 assert_eq!(
12042 json["recoverable_files"].as_array().map(Vec::len),
12043 Some(DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT)
12044 );
12045 assert_eq!(
12046 report.recoverable.first().map(|entry| entry.name.as_path()),
12047 Some(Path::new("early-000.json")),
12048 "the bounded sample must not depend on read_dir order"
12049 );
12050 assert_eq!(
12051 report.recoverable.last().map(|entry| entry.name.as_path()),
12052 Some(Path::new("late-097.json")),
12053 "the bounded sample must retain the lexical prefix"
12054 );
12055 assert_eq!(json["recoverable_files_truncated"], true);
12056 }
12057
12058 #[test]
12059 fn doctor_session_recovery_fails_closed_on_an_unreadable_path_shape() {
12060 let tmp = TempDir::new().expect("tempdir");
12061 let (primary_root, legacy_root) = roots(&tmp);
12062 fs::create_dir_all(&legacy_root).expect("legacy root");
12063 fs::write(legacy_root.join("sessions"), b"not a directory")
12064 .expect("invalid legacy sessions path");
12065
12066 let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12067
12068 assert_eq!(report.status, DoctorSessionRecoveryStatus::ScanFailed);
12069 assert!(report.needs_attention());
12070 assert!(report.error.as_deref().is_some_and(|error| {
12071 error.contains("legacy sessions root") && error.contains("not a directory")
12072 }));
12073 }
12074
12075 #[test]
12076 fn doctor_session_recovery_rejects_a_non_directory_legacy_state_root() {
12077 let tmp = TempDir::new().expect("tempdir");
12078 let (primary_root, legacy_root) = roots(&tmp);
12079 fs::write(&legacy_root, b"not a state directory").expect("invalid legacy root");
12080
12081 let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12082
12083 assert_eq!(report.status, DoctorSessionRecoveryStatus::ScanFailed);
12084 assert!(report.error.as_deref().is_some_and(|error| {
12085 error.contains("legacy state root") && error.contains("not a directory")
12086 }));
12087 }
12088
12089 #[test]
12090 fn doctor_session_recovery_rejects_a_non_directory_primary_state_root() {
12091 let tmp = TempDir::new().expect("tempdir");
12092 let (primary_root, legacy_root) = roots(&tmp);
12093 fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
12094 fs::write(&primary_root, b"not a state directory").expect("invalid primary root");
12095
12096 let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12097
12098 assert_eq!(report.status, DoctorSessionRecoveryStatus::ScanFailed);
12099 assert!(report.error.as_deref().is_some_and(|error| {
12100 error.contains("primary state root") && error.contains("not a directory")
12101 }));
12102 }
12103
12104 #[test]
12105 fn doctor_session_recovery_rejects_a_non_directory_primary_sessions_root() {
12106 let tmp = TempDir::new().expect("tempdir");
12107 let (primary_root, legacy_root) = roots(&tmp);
12108 fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
12109 fs::create_dir_all(&primary_root).expect("primary root");
12110 fs::write(primary_root.join("sessions"), b"not a sessions directory")
12111 .expect("invalid primary sessions path");
12112
12113 let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12114
12115 assert_eq!(report.status, DoctorSessionRecoveryStatus::ScanFailed);
12116 assert!(report.error.as_deref().is_some_and(|error| {
12117 error.contains("primary sessions root") && error.contains("not a directory")
12118 }));
12119 }
12120
12121 #[cfg(unix)]
12122 #[test]
12123 fn doctor_session_recovery_rejects_a_symlinked_legacy_sessions_root() {
12124 use std::os::unix::fs::symlink;
12125
12126 let tmp = TempDir::new().expect("tempdir");
12127 let (primary_root, legacy_root) = roots(&tmp);
12128 let external_sessions = tmp.path().join("external-sessions");
12129 fs::create_dir_all(&external_sessions).expect("external sessions");
12130 fs::write(
12131 external_sessions.join("must-not-be-enumerated.json"),
12132 b"session contents must stay unread",
12133 )
12134 .expect("external session fixture");
12135 fs::create_dir_all(&legacy_root).expect("legacy root");
12136 symlink(&external_sessions, legacy_root.join("sessions"))
12137 .expect("symlinked legacy sessions root");
12138
12139 let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12140
12141 assert_eq!(report.status, DoctorSessionRecoveryStatus::ScanFailed);
12142 assert!(report.needs_attention());
12143 assert_eq!(report.legacy_session_file_count, 0);
12144 assert!(report.recoverable.is_empty());
12145 assert!(
12146 report
12147 .error
12148 .as_deref()
12149 .is_some_and(|error| error.contains("legacy sessions root")
12150 && error.contains("path is a symlink"))
12151 );
12152 }
12153
12154 #[cfg(unix)]
12155 #[test]
12156 fn doctor_session_recovery_rejects_symlinked_primary_root_and_sessions_root() {
12157 use std::os::unix::fs::symlink;
12158
12159 let tmp = TempDir::new().expect("tempdir");
12160 let (primary_root, legacy_root) = roots(&tmp);
12161 let external_primary = tmp.path().join("external-primary");
12162 fs::create_dir_all(external_primary.join("sessions")).expect("external primary");
12163 fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
12164 symlink(&external_primary, &primary_root).expect("symlinked primary root");
12165
12166 let root_report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12167 assert_eq!(root_report.status, DoctorSessionRecoveryStatus::ScanFailed);
12168 assert!(root_report.error.as_deref().is_some_and(|error| {
12169 error.contains("primary state root") && error.contains("path is a symlink")
12170 }));
12171
12172 fs::remove_file(&primary_root).expect("remove primary root symlink");
12173 fs::create_dir_all(&primary_root).expect("primary root");
12174 symlink(&external_primary, primary_root.join("sessions"))
12175 .expect("symlinked primary sessions root");
12176
12177 let sessions_report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12178 assert_eq!(
12179 sessions_report.status,
12180 DoctorSessionRecoveryStatus::ScanFailed
12181 );
12182 assert!(sessions_report.error.as_deref().is_some_and(|error| {
12183 error.contains("primary sessions root") && error.contains("path is a symlink")
12184 }));
12185 }
12186
12187 #[test]
12188 fn explicit_codewhale_home_skips_session_recovery_scan() {
12189 let tmp = TempDir::new().expect("tempdir");
12190 let (primary_root, legacy_root) = roots(&tmp);
12191 fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
12192 fs::write(legacy_root.join("sessions").join("ambient.json"), b"legacy")
12193 .expect("legacy session");
12194
12195 let report = doctor_session_recovery_report(&primary_root, &legacy_root, true);
12196
12197 assert_eq!(report.status, DoctorSessionRecoveryStatus::Isolated);
12198 assert!(report.codewhale_home_is_explicit);
12199 assert_eq!(report.legacy_session_file_count, 0);
12200 assert_eq!(report.recoverable_file_count, 0);
12201 assert!(report.recoverable.is_empty());
12202 assert!(!report.needs_attention());
12203 }
12204
12205 #[test]
12206 fn doctor_state_roots_ignore_ambient_legacy_home_when_codewhale_home_is_explicit() {
12207 let _env_lock = crate::test_support::lock_test_env();
12208 let tmp = TempDir::new().expect("tempdir");
12209 let explicit_home = tmp.path().join("isolated-codewhale");
12210 let ambient_legacy = tmp.path().join(".deepseek");
12211 fs::create_dir_all(&ambient_legacy).expect("ambient legacy root");
12212 fs::write(
12213 ambient_legacy.join("config.toml"),
12214 "provider = 'deepseek'\n",
12215 )
12216 .expect("ambient legacy config");
12217 let _home = EnvVarRestore::set("HOME", tmp.path());
12218 let _codewhale_home = EnvVarRestore::set("CODEWHALE_HOME", &explicit_home);
12219
12220 let (primary_root, legacy_root) = doctor_state_roots();
12221 let report = doctor_legacy_state_report(&primary_root, &legacy_root);
12222 let session_recovery = doctor_session_recovery_report(
12223 &primary_root,
12224 &legacy_root,
12225 codewhale_config::codewhale_home_is_explicit(),
12226 );
12227
12228 assert_eq!(primary_root, explicit_home);
12229 assert_eq!(
12230 legacy_root,
12231 primary_root.join(codewhale_config::LEGACY_APP_DIR)
12232 );
12233 assert!(
12234 report
12235 .iter()
12236 .all(|entry| entry.status == DoctorLegacyStateStatus::Absent),
12237 "doctor must not report ambient legacy state when CODEWHALE_HOME is explicit"
12238 );
12239 assert!(!report.iter().any(legacy_state_needs_attention));
12240 assert_eq!(
12241 session_recovery.status,
12242 DoctorSessionRecoveryStatus::Isolated
12243 );
12244 assert!(session_recovery.recoverable.is_empty());
12245 }
12246}
12247
12248#[cfg(test)]
12249mod doctor_setup_state_tests {
12250 use super::*;
12251 use std::fs;
12252 use tempfile::TempDir;
12253
12254 fn prepare_env(tmp: &TempDir) -> (crate::test_support::EnvVarGuard, PathBuf) {
12255 let codewhale_home = tmp.path().join(".codewhale");
12256 fs::create_dir_all(&codewhale_home).expect("codewhale home");
12257 (
12258 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", codewhale_home.as_os_str()),
12259 codewhale_home,
12260 )
12261 }
12262
12263 fn provider_step(report: &serde_json::Value) -> &serde_json::Value {
12264 report["steps"]
12265 .as_array()
12266 .expect("steps array")
12267 .iter()
12268 .find(|step| step["step"] == "provider_model")
12269 .expect("provider/model step")
12270 }
12271
12272 #[test]
12273 fn doctor_setup_consistency_flags_missing_user_constitution() {
12274 let _guard = crate::test_support::lock_test_env();
12275 let tmp = TempDir::new().expect("tempdir");
12276 let (_home_guard, _codewhale_home) = prepare_env(&tmp);
12277 let _key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
12278 let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
12279 let workspace = tmp.path().join("workspace");
12280 fs::create_dir_all(&workspace).expect("workspace");
12281
12282 let state = codewhale_config::SetupState {
12283 constitution_source: codewhale_config::ConstitutionSource::UserGlobal,
12284 ..Default::default()
12285 };
12286 state.save().expect("persist setup state");
12287
12288 let report = doctor_setup_report_json(&Config::default(), &workspace);
12289
12290 assert_eq!(report["source"], "persisted");
12291 assert_eq!(report["consistency"]["status"], "inconsistent");
12292 let issues = report["consistency"]["issues"].to_string();
12293 assert!(
12294 issues.contains("setup_state_points_at_missing_user_constitution"),
12295 "{issues}"
12296 );
12297 }
12298
12299 #[test]
12300 fn doctor_setup_consistency_flags_stale_temp_files() {
12301 let _guard = crate::test_support::lock_test_env();
12302 let tmp = TempDir::new().expect("tempdir");
12303 let (_home_guard, codewhale_home) = prepare_env(&tmp);
12304 let _key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
12305 let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
12306 let workspace = tmp.path().join("workspace");
12307 fs::create_dir_all(&workspace).expect("workspace");
12308 fs::write(codewhale_home.join(".tmpAbC123"), b"orphaned atomic write")
12309 .expect("stale temp file");
12310
12311 let report = doctor_setup_report_json(&Config::default(), &workspace);
12312
12313 assert_eq!(report["consistency"]["status"], "inconsistent");
12314 let issues = report["consistency"]["issues"].to_string();
12315 assert!(
12316 issues.contains("stale_setup_temp_files_in_codewhale_home"),
12317 "{issues}"
12318 );
12319 }
12320
12321 #[test]
12322 fn doctor_setup_consistency_reports_consistent_for_clean_home() {
12323 let _guard = crate::test_support::lock_test_env();
12324 let tmp = TempDir::new().expect("tempdir");
12325 let (_home_guard, _codewhale_home) = prepare_env(&tmp);
12326 let _key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
12327 let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
12328 let workspace = tmp.path().join("workspace");
12329 fs::create_dir_all(&workspace).expect("workspace");
12330
12331 let report = doctor_setup_report_json(&Config::default(), &workspace);
12332
12333 assert_eq!(report["consistency"]["status"], "consistent");
12334 assert_eq!(
12335 report["consistency"]["issues"]
12336 .as_array()
12337 .map(Vec::len)
12338 .unwrap_or_default(),
12339 0
12340 );
12341 }
12342
12343 #[test]
12344 fn doctor_setup_report_json_derives_state_without_sidecar() {
12345 let _guard = crate::test_support::lock_test_env();
12346 let tmp = TempDir::new().expect("tempdir");
12347 let (_home_guard, _codewhale_home) = prepare_env(&tmp);
12348 let _key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
12349 let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
12350 let workspace = tmp.path().join("workspace");
12351 fs::create_dir_all(&workspace).expect("workspace");
12352
12353 let report = doctor_setup_report_json(&Config::default(), &workspace);
12354
12355 assert_eq!(report["source"], "derived");
12356 assert_eq!(report["inherited"], true);
12357 assert_eq!(report["next_actions"]["constitution"], "/constitution");
12358 assert_eq!(report["next_actions"]["setup_report"], "/setup report");
12359 assert_eq!(
12360 report["next_actions"]["provider_model"],
12361 "/setup provider, /provider setup <name>, or /model"
12362 );
12363 assert_eq!(report["next_actions"]["runtime_posture"], "/config");
12364 assert_eq!(
12365 report["next_actions"]["operate_fleet"],
12366 "/setup fleet (readiness), /fleet setup (explicit profile authoring)"
12367 );
12368 assert_eq!(report["next_actions"]["hotbar"], "/setup hotbar");
12369 assert_eq!(report["next_actions"]["tools_mcp"], "/setup tools");
12370 assert_eq!(report["next_actions"]["remote_runtime"], "/setup remote");
12371 assert_eq!(report["next_actions"]["persistence"], "/setup persistence");
12372 assert_eq!(
12373 report["checkpoint_version"],
12374 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION
12375 );
12376 assert_eq!(report["update_ready"], false);
12377 assert_eq!(report["operate_ready"], false);
12378 assert_eq!(
12379 report["operate_fleet"]["concurrency"]["plan_limit_probed"],
12380 false
12381 );
12382 assert_eq!(
12383 report["operate_fleet"]["roster"]["readiness_rule"],
12384 "built-in starter roster or custom roster"
12385 );
12386 assert_eq!(report["provider_model"]["provider"]["id"], "deepseek");
12387 assert_eq!(report["provider_model"]["provider"]["display"], "DeepSeek");
12388 assert_eq!(
12389 report["provider_model"]["model"]["resolved"],
12390 crate::config::DEFAULT_TEXT_MODEL
12391 );
12392 assert_eq!(
12393 report["provider_model"]["auth"]["source"],
12394 "secret_store_unprobed"
12395 );
12396 assert_eq!(
12397 report["provider_model"]["auth"]["availability"],
12398 "not_probed"
12399 );
12400 assert_eq!(
12401 report["provider_model"]["auth"]["credential_url"],
12402 "https://platform.deepseek.com"
12403 );
12404 assert_eq!(
12405 report["provider_model"]["auth"]["credential_mode"],
12406 "api_key"
12407 );
12408 assert_eq!(
12409 report["provider_model"]["auth"]["env_vars"][0],
12410 "DEEPSEEK_API_KEY"
12411 );
12412 assert_eq!(report["provider_model"]["health"]["live_validation"], false);
12413 assert_eq!(report["constitution"]["source"], "bundled");
12414 assert_eq!(report["constitution"]["autonomy_preference"], "unspecified");
12415 assert_eq!(report["runtime_posture"]["source"], "unset");
12416 assert_eq!(report["runtime_posture"]["default_mode"]["value"], "agent");
12417 assert_eq!(
12418 report["runtime_posture"]["approval_policy"]["value"],
12419 "on-request"
12420 );
12421 assert_eq!(report["runtime_posture"]["allow_shell"]["value"], true);
12422 assert_eq!(
12423 report["runtime_posture"]["sandbox_mode"]["value"],
12424 "mode-derived"
12425 );
12426 assert_eq!(
12427 report["runtime_posture"]["network_default"]["value"],
12428 "prompt"
12429 );
12430 assert_eq!(provider_step(&report)["status"], "needs_action");
12431 }
12432
12433 #[test]
12434 fn doctor_setup_provider_model_json_covers_cn_codex_and_local_matrix() {
12435 let _guard = crate::test_support::lock_test_env();
12436 let tmp = TempDir::new().expect("tempdir");
12437 let (_home_guard, _codewhale_home) = prepare_env(&tmp);
12438 let _home = crate::test_support::EnvVarGuard::set("HOME", tmp.path());
12439 let _userprofile = crate::test_support::EnvVarGuard::set("USERPROFILE", tmp.path());
12440 let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
12441 let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
12442 let _codex_key = crate::test_support::EnvVarGuard::remove("OPENAI_CODEX_ACCESS_TOKEN");
12443 let _codex_legacy_key = crate::test_support::EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
12444 let codex_auth_path = tmp.path().join("external-codex-auth.json");
12445 let codex_auth_raw = serde_json::json!({
12446 "tokens": {
12447 "access_token": crate::test_support::future_test_jwt("doctor"),
12448 "account_id": "acct-doctor-read-only",
12449 "refresh_token": "must-never-be-used",
12450 "unknown": {"preserve": true}
12451 }
12452 })
12453 .to_string();
12454 fs::write(&codex_auth_path, &codex_auth_raw).expect("Codex auth trap fixture");
12455 let _codex_auth =
12456 crate::test_support::EnvVarGuard::set("OPENAI_CODEX_AUTH_FILE", &codex_auth_path);
12457 let workspace = tmp.path().join("workspace");
12458 fs::create_dir_all(&workspace).expect("workspace");
12459
12460 let cn_config = Config {
12461 provider: Some("deepseek-cn".to_string()),
12462 ..Config::default()
12463 };
12464 let cn_report = doctor_setup_report_json(&cn_config, &workspace);
12465 assert_eq!(cn_report["provider_model"]["provider"]["id"], "deepseek-cn");
12466 assert_eq!(
12467 cn_report["provider_model"]["provider"]["display"],
12468 "DeepSeek (legacy alias)"
12469 );
12470 assert_eq!(
12471 cn_report["provider_model"]["auth"]["env_vars"][0],
12472 "DEEPSEEK_API_KEY"
12473 );
12474 assert_eq!(
12475 cn_report["provider_model"]["auth"]["credential_url"],
12476 "https://platform.deepseek.com"
12477 );
12478 assert_eq!(cn_report["provider_model"]["auth"]["oauth_only"], false);
12479 assert_eq!(
12480 cn_report["provider_model"]["health"]["live_validation"],
12481 false
12482 );
12483
12484 let codex_config = Config {
12485 provider: Some("openai-codex".to_string()),
12486 ..Config::default()
12487 };
12488 crate::external_credentials::reset_side_effect_trap();
12489 let codex_report = doctor_setup_report_json(&codex_config, &workspace);
12490 assert_eq!(
12491 codex_report["provider_model"]["provider"]["id"],
12492 crate::config::ApiProvider::OpenaiCodex.as_str()
12493 );
12494 assert!(codex_report["provider_model"]["auth"]["credential_url"].is_null());
12495 assert_eq!(
12496 codex_report["provider_model"]["auth"]["credential_mode"],
12497 "oauth"
12498 );
12499 assert_eq!(codex_report["provider_model"]["auth"]["oauth_only"], true);
12500 assert_eq!(
12501 codex_report["provider_model"]["health"]["next_action"],
12502 "/setup provider or /provider setup <name>"
12503 );
12504 assert_eq!(
12505 crate::external_credentials::side_effect_trap_counts(),
12506 (0, 0),
12507 "doctor must not stat or read external credentials without consent"
12508 );
12509
12510 let mut consent = codewhale_config::ExternalCredentialConsentToml::read_only(
12511 codewhale_config::ProviderKind::OpenaiCodex,
12512 codewhale_config::ExternalCredentialSource::CodexCli,
12513 codex_auth_path.clone(),
12514 );
12515 let codex_read_only = Config {
12516 provider: Some("openai-codex".to_string()),
12517 providers: Some(crate::config::ProvidersConfig {
12518 openai_codex: crate::config::ProviderConfig {
12519 auth_mode: Some("oauth".to_string()),
12520 external_credentials: Some(consent.clone()),
12521 ..Default::default()
12522 },
12523 ..Default::default()
12524 }),
12525 ..Config::default()
12526 };
12527 let changed_ambient_path = tmp.path().join("new-ambient-codex-auth.json");
12528 let _changed_codex_auth =
12529 crate::test_support::EnvVarGuard::set("OPENAI_CODEX_AUTH_FILE", &changed_ambient_path);
12530 crate::external_credentials::reset_side_effect_trap();
12531 let codex_read_only_report = doctor_setup_report_json(&codex_read_only, &workspace);
12532 assert_eq!(
12533 codex_read_only_report["provider_model"]["auth"]["present_or_local"],
12534 false
12535 );
12536 assert_eq!(
12537 codex_read_only_report["provider_model"]["auth"]["source"],
12538 "external_consent"
12539 );
12540 let status_json = doctor_external_credential_consent_json(&codex_read_only);
12541 let codex_status = status_json
12542 .as_array()
12543 .and_then(|rows| rows.first())
12544 .expect("Codex structural status");
12545 assert_eq!(codex_status["access"], "read_only");
12546 assert_eq!(codex_status["provider"], "openai-codex");
12547 assert_eq!(codex_status["source"], "codex_cli");
12548 assert_eq!(codex_status["route_state"], "active");
12549 assert_eq!(codex_status["ambient_path_changed"], true);
12550 assert!(
12551 codex_status["ambient_path_warning"]
12552 .as_str()
12553 .is_some_and(|warning| warning.contains("remains pinned"))
12554 );
12555 assert_eq!(
12556 codex_status["revoke_command"],
12557 "codewhale auth external-revoke --provider openai-codex"
12558 );
12559 let human = doctor_external_credential_consent_lines(&codex_read_only).join("\n");
12560 assert!(human.contains("path="), "{human}");
12561 assert!(human.contains("version=1"), "{human}");
12562 assert!(human.contains("no refresh, identity-provider or discovery requests"));
12563 assert!(human.contains("normal requests to the explicitly selected provider"));
12564 assert!(human.contains("consent remains pinned"), "{human}");
12565 assert!(
12566 human.contains(&codewhale_config::quote_os_path(&codex_auth_path)),
12567 "{human}"
12568 );
12569 assert!(!human.contains(&changed_ambient_path.display().to_string()));
12570 assert_eq!(
12571 crate::external_credentials::complete_side_effect_trap_counts(),
12572 (0, 0, 0, 0, 0),
12573 "doctor consent status is structural and must not inspect the file"
12574 );
12575 assert_eq!(
12576 fs::read_to_string(&codex_auth_path).expect("unchanged Codex auth fixture"),
12577 codex_auth_raw
12578 );
12579
12580 consent.access = codewhale_config::ExternalCredentialAccess::Managed;
12581 let codex_managed = Config {
12582 provider: Some("openai-codex".to_string()),
12583 providers: Some(crate::config::ProvidersConfig {
12584 openai_codex: crate::config::ProviderConfig {
12585 auth_mode: Some("oauth".to_string()),
12586 external_credentials: Some(consent),
12587 ..Default::default()
12588 },
12589 ..Default::default()
12590 }),
12591 ..Config::default()
12592 };
12593 crate::external_credentials::reset_side_effect_trap();
12594 let codex_managed_report = doctor_setup_report_json(&codex_managed, &workspace);
12595 assert_eq!(
12596 codex_managed_report["provider_model"]["auth"]["present_or_local"],
12597 false
12598 );
12599 assert_eq!(
12600 crate::external_credentials::side_effect_trap_counts(),
12601 (0, 0),
12602 "unsupported managed mode must fail before external I/O"
12603 );
12604 assert_eq!(
12605 fs::read_to_string(&codex_auth_path).expect("unchanged managed auth fixture"),
12606 codex_auth_raw
12607 );
12608
12609 let local_config = Config {
12610 provider: Some("ollama".to_string()),
12611 ..Config::default()
12612 };
12613 let local_report = doctor_setup_report_json(&local_config, &workspace);
12614 assert_eq!(local_report["provider_model"]["provider"]["id"], "ollama");
12615 assert_eq!(
12616 local_report["provider_model"]["auth"]["present_or_local"],
12617 true
12618 );
12619 assert!(local_report["provider_model"]["auth"]["credential_url"].is_null());
12620 assert_eq!(
12621 local_report["provider_model"]["auth"]["credential_mode"],
12622 "local_optional"
12623 );
12624 assert_eq!(local_report["provider_model"]["auth"]["oauth_only"], false);
12625 assert_eq!(
12626 local_report["provider_model"]["health"]["next_action"],
12627 "/model"
12628 );
12629
12630 let kimi_config = Config {
12631 provider: Some("moonshot".to_string()),
12632 ..Config::default()
12633 };
12634 let kimi_report = doctor_setup_report_json(&kimi_config, &workspace);
12635 assert_eq!(
12636 kimi_report["provider_model"]["auth"]["credential_url"],
12637 "https://platform.kimi.ai"
12638 );
12639 assert_eq!(
12640 kimi_report["provider_model"]["auth"]["credential_docs_url"],
12641 "https://platform.kimi.ai"
12642 );
12643 assert_eq!(
12644 kimi_report["provider_model"]["auth"]["credential_mode"],
12645 "api_key"
12646 );
12647 assert!(
12648 kimi_report["provider_model"]["auth"]["credential_guidance"]
12649 .as_str()
12650 .is_some_and(|guidance| guidance.contains("OAuth is not available"))
12651 );
12652 }
12653
12654 #[test]
12655 fn doctor_setup_report_json_uses_persisted_state() {
12656 let _guard = crate::test_support::lock_test_env();
12657 let tmp = TempDir::new().expect("tempdir");
12658 let (_home_guard, _codewhale_home) = prepare_env(&tmp);
12659 let workspace = tmp.path().join("workspace");
12660 fs::create_dir_all(&workspace).expect("workspace");
12661 let mut state = codewhale_config::SetupState::default();
12662 state.set_step(
12663 codewhale_config::SetupStep::Language,
12664 codewhale_config::StepEntry::new(
12665 codewhale_config::StepStatus::Verified,
12666 true,
12667 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
12668 ),
12669 );
12670 state.set_step(
12671 codewhale_config::SetupStep::ProviderModel,
12672 codewhale_config::StepEntry::new(
12673 codewhale_config::StepStatus::Verified,
12674 true,
12675 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
12676 )
12677 .with_result("deepseek/deepseek-chat"),
12678 );
12679 state.set_step(
12680 codewhale_config::SetupStep::TrustSandbox,
12681 codewhale_config::StepEntry::new(
12682 codewhale_config::StepStatus::Verified,
12683 true,
12684 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
12685 ),
12686 );
12687 state
12688 .complete_constitution_checkpoint(
12689 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
12690 codewhale_config::ConstitutionChoice::Bundled,
12691 )
12692 .set_step(
12693 codewhale_config::SetupStep::Constitution,
12694 codewhale_config::StepEntry::new(
12695 codewhale_config::StepStatus::Verified,
12696 true,
12697 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
12698 ),
12699 );
12700 state.runtime_posture_source = codewhale_config::RuntimePostureSource::Confirmed;
12701 state.save().expect("persist setup state");
12702 codewhale_config::UserConstitution {
12703 autonomy_preference: codewhale_config::AutonomyPreference::Balanced,
12704 ..Default::default()
12705 }
12706 .save()
12707 .expect("persist user constitution");
12708 let config = Config {
12709 api_key: Some("TEST-STRUCTURAL-LITERAL".to_string()),
12710 approval_policy: Some("never".to_string()),
12711 allow_shell: Some(false),
12712 sandbox_mode: Some("read-only".to_string()),
12713 network: Some(crate::config::NetworkPolicyToml {
12714 default: "deny".to_string(),
12715 ..Default::default()
12716 }),
12717 ..Config::default()
12718 };
12719
12720 let report = doctor_setup_report_json(&config, &workspace);
12721
12722 assert_eq!(report["source"], "persisted");
12723 assert_eq!(report["first_run_ready"], true);
12724 assert_eq!(report["update_ready"], true);
12725 assert_eq!(report["operate_ready"], false);
12726 assert_eq!(report["constitution"]["choice"], "bundled");
12727 assert_eq!(
12728 report["constitution"]["checkpoint_completed_for"],
12729 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION
12730 );
12731 assert_eq!(report["constitution"]["autonomy_preference"], "balanced");
12732 assert_eq!(report["runtime_posture_source"], "confirmed");
12733 assert_eq!(report["runtime_posture"]["source"], "confirmed");
12734 assert_eq!(
12735 report["runtime_posture"]["approval_policy"]["value"],
12736 "never"
12737 );
12738 assert_eq!(
12739 report["runtime_posture"]["approval_policy"]["source"],
12740 "config"
12741 );
12742 assert_eq!(report["runtime_posture"]["allow_shell"]["value"], false);
12743 assert_eq!(report["runtime_posture"]["allow_shell"]["source"], "config");
12744 assert_eq!(
12745 report["runtime_posture"]["sandbox_mode"]["value"],
12746 "read-only"
12747 );
12748 assert_eq!(
12749 report["runtime_posture"]["sandbox_mode"]["source"],
12750 "config"
12751 );
12752 assert_eq!(
12753 report["runtime_posture"]["network_default"]["value"],
12754 "deny"
12755 );
12756 assert_eq!(
12757 report["runtime_posture"]["network_default"]["source"],
12758 "config"
12759 );
12760 assert_eq!(provider_step(&report)["result"], "deepseek/deepseek-chat");
12761 }
12762
12763 #[test]
12764 fn doctor_reports_settings_permission_posture_when_approval_policy_unset() {
12765 let _guard = crate::test_support::lock_test_env();
12766 let tmp = TempDir::new().expect("tempdir");
12767 let (_home_guard, codewhale_home) = prepare_env(&tmp);
12768 let workspace = tmp.path().join("workspace");
12769 fs::create_dir_all(&workspace).expect("workspace");
12770 fs::write(
12771 codewhale_home.join("settings.toml"),
12772 "permission_posture = \"full-access\"\n",
12773 )
12774 .expect("write settings.toml");
12775
12776 let config = Config::default();
12777 assert!(config.approval_policy.is_none());
12778
12779 let line = doctor_runtime_posture_line(&config, &workspace);
12780 assert!(
12781 line.contains("permission_posture=full-access (settings)"),
12782 "text doctor should report saved settings posture: {line}"
12783 );
12784 assert!(
12785 line.contains("approval_policy=on-request (default)"),
12786 "text doctor should keep unset config approval_policy default: {line}"
12787 );
12788
12789 let report = doctor_setup_report_json(&config, &workspace);
12790 assert_eq!(
12791 report["runtime_posture"]["permission_posture"]["value"],
12792 "full-access"
12793 );
12794 assert_eq!(
12795 report["runtime_posture"]["permission_posture"]["source"],
12796 "settings"
12797 );
12798 assert_eq!(
12799 report["runtime_posture"]["approval_policy"]["value"],
12800 "on-request"
12801 );
12802 assert_eq!(
12803 report["runtime_posture"]["approval_policy"]["source"],
12804 "default"
12805 );
12806 }
12807
12808 #[test]
12809 fn doctor_setup_report_json_fails_closed_without_operate_receipts() {
12810 let _guard = crate::test_support::lock_test_env();
12811 let tmp = TempDir::new().expect("tempdir");
12812 let (_home_guard, _codewhale_home) = prepare_env(&tmp);
12813 let workspace = tmp.path().join("workspace");
12814 fs::create_dir_all(&workspace).expect("workspace");
12815 let mut state = codewhale_config::SetupState::default();
12816 state.set_step(
12817 codewhale_config::SetupStep::Language,
12818 codewhale_config::StepEntry::new(
12819 codewhale_config::StepStatus::Verified,
12820 true,
12821 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
12822 ),
12823 );
12824 state.set_step(
12825 codewhale_config::SetupStep::ProviderModel,
12826 codewhale_config::StepEntry::new(
12827 codewhale_config::StepStatus::Verified,
12828 true,
12829 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
12830 ),
12831 );
12832 state.runtime_posture_source = codewhale_config::RuntimePostureSource::Confirmed;
12833 state.complete_constitution_checkpoint(
12834 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
12835 codewhale_config::ConstitutionChoice::Bundled,
12836 );
12837 state.set_step(
12838 codewhale_config::SetupStep::OperateFleet,
12839 codewhale_config::StepEntry::new(
12840 codewhale_config::StepStatus::Verified,
12841 false,
12842 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
12843 )
12844 .with_result(
12845 "provider=ready, runtime=ready, roster=ready, concurrency=plan limit not probed",
12846 ),
12847 );
12848 state.save().expect("persist setup state");
12849
12850 let config = Config {
12851 api_key: Some("TEST-STRUCTURAL-LITERAL".to_string()),
12852 ..Config::default()
12853 };
12854 let report = doctor_setup_report_json(&config, &workspace);
12855
12856 assert_eq!(report["first_run_ready"], true);
12857 assert_eq!(report["operate_ready"], false);
12858 assert_eq!(
12859 report["operate_fleet"]["concurrency"]["plan_limit_probed"],
12860 false
12861 );
12862 assert!(
12863 report["operate_fleet"]["roster"]["built_in"]
12864 .as_u64()
12865 .is_some_and(|count| count > 0)
12866 );
12867 let operate_step = report["steps"]
12868 .as_array()
12869 .expect("steps array")
12870 .iter()
12871 .find(|step| step["step"] == "operate_fleet")
12872 .expect("operate/fleet step");
12873 assert_eq!(operate_step["status"], "verified");
12874 assert!(
12875 operate_step["result"]
12876 .as_str()
12877 .is_some_and(|result| result.contains("plan limit not probed"))
12878 );
12879 }
12880}
12881
12882#[cfg(test)]
12883mod doctor_endpoint_tests {
12884 use super::*;
12885
12886 #[test]
12887 fn doctor_api_target_reports_default_endpoint() {
12888 let config = Config::default();
12889
12890 let target = doctor_api_target(&config);
12891
12892 assert_eq!(target.provider, "deepseek");
12893 assert_eq!(target.base_url, crate::config::DEFAULT_DEEPSEEK_BASE_URL);
12894 assert_eq!(target.model, crate::config::DEFAULT_TEXT_MODEL);
12895 assert_eq!(target.resolution, DoctorModelResolution::Resolved);
12896 }
12897
12898 #[test]
12899 fn doctor_api_target_falls_back_to_configured_model_when_resolution_fails() {
12900 let config = Config {
12904 provider: Some("custom".to_string()),
12905 ..Default::default()
12906 };
12907
12908 let target = doctor_api_target(&config);
12909
12910 assert_eq!(target.resolution, DoctorModelResolution::ConfiguredOnly);
12911 assert_eq!(target.model, config.default_model());
12912 }
12913
12914 #[test]
12915 fn doctor_api_target_routes_deepseek_cn_alias_to_beta_endpoint() {
12916 let config = Config {
12917 provider: Some("deepseek-cn".to_string()),
12918 ..Default::default()
12919 };
12920
12921 let target = doctor_api_target(&config);
12922
12923 assert_eq!(target.provider, "deepseek-cn");
12924 assert_eq!(target.base_url, crate::config::DEFAULT_DEEPSEEKCN_BASE_URL);
12925 assert_eq!(target.base_url, crate::config::DEFAULT_DEEPSEEK_BASE_URL);
12926 assert_eq!(target.model, crate::config::DEFAULT_TEXT_MODEL);
12927 assert_eq!(target.resolution, DoctorModelResolution::Resolved);
12928 }
12929
12930 #[test]
12931 fn strict_tool_mode_doctor_reports_disabled_by_default() {
12932 let config = Config::default();
12933
12934 let status = doctor_strict_tool_mode_status(&config);
12935
12936 assert!(!status.enabled);
12937 assert_eq!(status.status, "disabled");
12938 assert!(!status.function_strict_sent);
12939 assert!(status.recommended_base_url.is_none());
12940 }
12941
12942 #[test]
12943 fn doctor_known_base_urls_are_ascii_case_insensitive() {
12944 assert!(doctor_xiaomi_mimo_base_url_uses_token_plan(
12945 "HTTPS://TOKEN-PLAN-CN.XIAOMIMIMO.COM/V1/"
12946 ));
12947 assert_eq!(
12948 known_deepseek_base_url_kind("HTTPS://API.DEEPSEEK.COM/BETA/"),
12949 Some(DeepSeekBaseUrlKind::Beta)
12950 );
12951 assert_eq!(
12952 known_deepseek_base_url_kind("HTTPS://API.DEEPSEEK.COM/V1/"),
12953 Some(DeepSeekBaseUrlKind::NonBeta)
12954 );
12955 }
12956
12957 #[test]
12958 fn strict_tool_mode_doctor_accepts_default_beta_endpoint() {
12959 let config = Config {
12960 strict_tool_mode: Some(true),
12961 ..Default::default()
12962 };
12963
12964 let status = doctor_strict_tool_mode_status(&config);
12965
12966 assert!(status.enabled);
12967 assert_eq!(status.status, "ready");
12968 assert!(status.function_strict_sent);
12969 assert!(status.message.contains("beta endpoint"));
12970 assert!(status.recommended_base_url.is_none());
12971 }
12972
12973 #[test]
12974 fn strict_tool_mode_doctor_warns_for_non_beta_deepseek_endpoint() {
12975 let config = Config {
12976 strict_tool_mode: Some(true),
12977 base_url: Some("https://api.deepseek.com".to_string()),
12978 ..Default::default()
12979 };
12980
12981 let status = doctor_strict_tool_mode_status(&config);
12982
12983 assert_eq!(status.status, "fallback_non_beta");
12984 assert!(!status.function_strict_sent);
12985 assert_eq!(
12986 status.recommended_base_url.as_deref(),
12987 Some(crate::config::DEFAULT_DEEPSEEK_BASE_URL)
12988 );
12989 assert_eq!(
12990 doctor_strict_tool_mode_report_json(&status)["recommended_base_url"],
12991 "https://api.deepseek.com"
12992 );
12993 }
12994
12995 #[test]
12996 fn strict_tool_mode_doctor_accepts_deepseek_cn_alias_default_endpoint() {
12997 let config = Config {
12998 provider: Some("deepseek-cn".to_string()),
12999 strict_tool_mode: Some(true),
13000 ..Default::default()
13001 };
13002
13003 let status = doctor_strict_tool_mode_status(&config);
13004
13005 assert_eq!(status.status, "ready");
13006 assert!(status.function_strict_sent);
13007 assert!(status.message.contains("beta endpoint"));
13008 assert!(status.recommended_base_url.is_none());
13009 }
13010
13011 #[test]
13012 fn strict_tool_mode_doctor_marks_custom_endpoint_as_forwarded() {
13013 let config = Config {
13014 provider: Some("vllm".to_string()),
13015 strict_tool_mode: Some(true),
13016 ..Default::default()
13017 };
13018
13019 let status = doctor_strict_tool_mode_status(&config);
13020
13021 assert_eq!(status.status, "custom_endpoint");
13022 assert!(status.function_strict_sent);
13023 assert!(status.message.contains("custom endpoint"));
13024 }
13025
13026 #[test]
13027 fn doctor_tls_status_reports_verification_enabled_by_default() {
13028 let status = doctor_tls_status(&Config::default());
13029
13030 assert!(status.certificate_verification);
13031 assert!(!status.insecure_skip_tls_verify);
13032 assert_eq!(status.provider, "deepseek");
13033 assert!(status.message.contains("enabled"));
13034 }
13035
13036 #[test]
13037 fn doctor_tls_status_warns_when_active_provider_skips_verification() {
13038 let mut providers = crate::config::ProvidersConfig::default();
13039 providers.openai.insecure_skip_tls_verify = Some(true);
13040 let config = Config {
13041 provider: Some("openai".to_string()),
13042 providers: Some(providers),
13043 ..Default::default()
13044 };
13045
13046 let status = doctor_tls_status(&config);
13047
13048 assert!(status.certificate_verification);
13049 assert!(status.insecure_skip_tls_verify);
13050 assert_eq!(status.provider, "openai");
13051 assert!(status.message.contains("cannot be disabled"));
13052 assert!(status.message.contains("SSL_CERT_FILE"));
13053 }
13054
13055 #[test]
13056 fn provider_capability_report_exposes_alias_deprecation_for_deepseek_chat() {
13057 let mut config = Config {
13058 default_text_model: Some("deepseek-chat".to_string()),
13059 ..Default::default()
13060 };
13061 crate::config::normalize_model_config_for_test(&mut config);
13062
13063 let report = provider_capability_report(&config);
13064
13065 assert_eq!(report["resolved_model"], "deepseek-v4-flash");
13066 assert_eq!(report["context_window"], 1_000_000);
13067 assert_eq!(report["thinking_supported"], true);
13068 assert_eq!(report["alias_deprecation"]["alias"], "deepseek-chat");
13069 assert_eq!(
13070 report["alias_deprecation"]["replacement"],
13071 "deepseek-v4-flash"
13072 );
13073 assert_eq!(
13074 report["alias_deprecation"]["retirement_utc"],
13075 "2026-07-24T15:59:00Z"
13076 );
13077 }
13078
13079 #[test]
13080 fn provider_capability_report_preserves_custom_deepseek_alias_namespace() {
13081 let mut config = Config {
13082 base_url: Some("https://models.example/v1".to_string()),
13083 default_text_model: Some("deepseek-chat".to_string()),
13084 ..Default::default()
13085 };
13086 crate::config::normalize_model_config_for_test(&mut config);
13087
13088 let report = provider_capability_report(&config);
13089
13090 assert_eq!(report["resolved_model"], "deepseek-chat");
13091 assert!(report["alias_deprecation"].is_null());
13092 }
13093
13094 #[test]
13095 fn provider_capability_report_leaves_canonical_flash_alias_metadata_null() {
13096 let config = Config {
13097 default_text_model: Some("deepseek-v4-flash".to_string()),
13098 ..Default::default()
13099 };
13100
13101 let report = provider_capability_report(&config);
13102
13103 assert_eq!(report["resolved_model"], "deepseek-v4-flash");
13104 assert!(report["alias_deprecation"].is_null());
13105 }
13106
13107 #[test]
13108 fn doctor_route_report_exposes_tokenhub_openai_compatible_route_without_secret() {
13109 let mut providers = crate::config::ProvidersConfig::default();
13110 providers.openai.api_key = Some("tokenhub-secret-value".to_string());
13111 providers.openai.base_url = Some("https://tokenhub.tencentmaas.com/v1".to_string());
13112 providers.openai.model = Some("deepseek-ai/DeepSeek-V4-Pro".to_string());
13113 let config = Config {
13114 provider: Some("openai".to_string()),
13115 providers: Some(providers),
13116 ..Default::default()
13117 };
13118
13119 let report = doctor_route_report(&config);
13120 let serialized = report.to_string();
13121
13122 assert_eq!(report["provider"], "openai");
13123 assert_eq!(report["provider_source"], "config");
13124 assert_eq!(report["provider_config_table"], "openai");
13125 assert_eq!(report["model"], "deepseek-ai/DeepSeek-V4-Pro");
13126 assert_eq!(report["wire_protocol"], "chat_completions");
13127 assert_eq!(
13128 report["base_url"]["redacted"],
13129 "https://tokenhub.tencentmaas.com"
13130 );
13131 assert_eq!(report["base_url"]["class"], "custom");
13132 assert_eq!(report["auth"]["scheme"], "bearer");
13133 assert_eq!(report["auth"]["source"], "config_declared");
13134 assert!(
13135 report["base_url"]["fingerprint"]
13136 .as_str()
13137 .is_some_and(|value| value.starts_with("<redacted:"))
13138 );
13139 assert!(!serialized.contains("tokenhub-secret-value"));
13140 }
13141
13142 #[test]
13143 fn doctor_route_report_exposes_siliconflow_cn_provider_route() {
13144 let mut providers = crate::config::ProvidersConfig::default();
13145 providers.siliconflow_cn.api_key = Some("sf-cn-secret-value".to_string());
13146 providers.siliconflow_cn.base_url =
13147 Some(crate::config::DEFAULT_SILICONFLOW_CN_BASE_URL.to_string());
13148 providers.siliconflow_cn.model = Some(crate::config::DEFAULT_SILICONFLOW_MODEL.to_string());
13149 let config = Config {
13150 provider: Some("siliconflow-CN".to_string()),
13151 providers: Some(providers),
13152 ..Default::default()
13153 };
13154
13155 let report = doctor_route_report(&config);
13156 let serialized = report.to_string();
13157
13158 assert_eq!(report["provider"], "siliconflow-CN");
13159 assert_eq!(report["provider_config_table"], "siliconflow_cn");
13160 assert_eq!(report["model"], crate::config::DEFAULT_SILICONFLOW_MODEL);
13161 assert_eq!(
13162 report["base_url"]["redacted"],
13163 crate::doctor::structural_url_authority(crate::config::DEFAULT_SILICONFLOW_CN_BASE_URL)
13164 );
13165 assert_eq!(report["base_url"]["class"], "default");
13166 assert_eq!(report["auth"]["scheme"], "bearer");
13167 assert_eq!(report["auth"]["source"], "config_declared");
13168 assert!(!serialized.contains("sf-cn-secret-value"));
13169 }
13170
13171 #[test]
13172 fn doctor_route_report_names_kimi_code_context_provenance() {
13173 let config = Config {
13174 provider: Some("moonshot".to_string()),
13175 providers: Some(crate::config::ProvidersConfig {
13176 moonshot: crate::config::ProviderConfig {
13177 api_key: Some("kimi-plan-secret".to_string()),
13178 base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
13179 model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
13180 ..Default::default()
13181 },
13182 ..Default::default()
13183 }),
13184 ..Default::default()
13185 };
13186
13187 let report = doctor_route_report(&config);
13188 let serialized = report.to_string();
13189
13190 assert_eq!(report["context_window"]["tokens"], 262_144);
13191 assert_eq!(
13192 report["context_window"]["source"],
13193 "static Kimi Code safe floor"
13194 );
13195 assert!(!serialized.contains("kimi-plan-secret"));
13196 }
13197
13198 #[test]
13199 fn provider_capability_report_uses_exact_kimi_code_route_facts() {
13200 let config = Config {
13201 provider: Some("moonshot".to_string()),
13202 providers: Some(crate::config::ProvidersConfig {
13203 moonshot: crate::config::ProviderConfig {
13204 api_key: Some("kimi-plan-secret".to_string()),
13205 base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
13206 model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
13207 ..Default::default()
13208 },
13209 ..Default::default()
13210 }),
13211 ..Default::default()
13212 };
13213
13214 let report = provider_capability_report(&config);
13215
13216 assert_eq!(report["resolved_model"], crate::config::KIMI_CODE_K3_MODEL);
13217 assert_eq!(report["context_window"], 262_144);
13218 assert_eq!(
13219 report["context_window_source"],
13220 "static Kimi Code safe floor"
13221 );
13222 assert_eq!(report["thinking_supported"], true);
13223 }
13224
13225 #[test]
13226 fn provider_capability_report_honors_kimi_code_context_override() {
13227 let config = Config {
13228 provider: Some("moonshot".to_string()),
13229 providers: Some(crate::config::ProvidersConfig {
13230 moonshot: crate::config::ProviderConfig {
13231 api_key: Some("kimi-plan-secret".to_string()),
13232 base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
13233 model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
13234 context_window: Some(1_048_576),
13235 ..Default::default()
13236 },
13237 ..Default::default()
13238 }),
13239 ..Default::default()
13240 };
13241
13242 let report = provider_capability_report(&config);
13243
13244 assert_eq!(
13245 report["resolved_model"],
13246 crate::config::KIMI_CODE_K3_MODEL,
13247 "the configured window must preserve Kimi Code's bare wire id"
13248 );
13249 assert_eq!(report["context_window"], 1_048_576);
13250 assert_eq!(report["context_window_source"], "configured");
13251 assert_eq!(report["thinking_supported"], true);
13252 }
13253
13254 #[test]
13255 fn provider_capability_report_uses_direct_moonshot_k3_route_facts() {
13256 let config = Config {
13257 provider: Some("moonshot".to_string()),
13258 providers: Some(crate::config::ProvidersConfig {
13259 moonshot: crate::config::ProviderConfig {
13260 api_key: Some("moonshot-secret".to_string()),
13261 base_url: Some(crate::config::DEFAULT_MOONSHOT_BASE_URL.to_string()),
13262 model: Some("kimi-k3".to_string()),
13263 ..Default::default()
13264 },
13265 ..Default::default()
13266 }),
13267 ..Default::default()
13268 };
13269
13270 let report = provider_capability_report(&config);
13271
13272 assert_eq!(report["resolved_model"], "kimi-k3");
13273 assert_eq!(report["context_window"], 1_048_576);
13274 assert_eq!(report["context_window_source"], "catalog");
13275 assert_eq!(report["max_output"], 1_048_576);
13276 assert_eq!(report["thinking_supported"], true);
13277 }
13278
13279 #[test]
13280 fn doctor_search_provider_line_includes_duckduckgo_default_source_and_switch_hint() {
13281 let _guard = crate::test_support::lock_test_env();
13282 let prev = std::env::var_os("DEEPSEEK_SEARCH_PROVIDER");
13283 unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") };
13284
13285 let line = doctor_search_provider_line(&Config::default());
13286
13287 match prev {
13288 Some(value) => unsafe { std::env::set_var("DEEPSEEK_SEARCH_PROVIDER", value) },
13289 None => unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") },
13290 }
13291 assert!(line.contains("search_provider: duckduckgo"));
13292 assert!(line.contains("source: default"));
13293 assert!(line.contains("[search] provider"));
13294 assert!(line.contains("provider = \"bing\""));
13295 }
13296
13297 #[test]
13298 fn doctor_search_provider_json_reports_config_source() {
13299 let _guard = crate::test_support::lock_test_env();
13300 let prev = std::env::var_os("DEEPSEEK_SEARCH_PROVIDER");
13301 unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") };
13302 let config = Config {
13303 search: Some(crate::config::SearchConfig {
13304 provider: Some(crate::config::SearchProvider::DuckDuckGo),
13305 base_url: None,
13306 api_key: None,
13307 }),
13308 ..Default::default()
13309 };
13310
13311 let report = doctor_search_provider_json(&config);
13312
13313 match prev {
13314 Some(value) => unsafe { std::env::set_var("DEEPSEEK_SEARCH_PROVIDER", value) },
13315 None => unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") },
13316 }
13317 assert_eq!(report["provider"], "duckduckgo");
13318 assert_eq!(report["source"], "config");
13319 }
13320
13321 #[test]
13322 fn doctor_search_provider_json_reports_env_override_source() {
13323 let _guard = crate::test_support::lock_test_env();
13324 let prev = std::env::var_os("DEEPSEEK_SEARCH_PROVIDER");
13325 unsafe { std::env::set_var("DEEPSEEK_SEARCH_PROVIDER", "tavily") };
13326
13327 let report = doctor_search_provider_json(&Config::default());
13328
13329 match prev {
13330 Some(value) => unsafe { std::env::set_var("DEEPSEEK_SEARCH_PROVIDER", value) },
13331 None => unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") },
13332 }
13333 assert_eq!(report["provider"], "tavily");
13334 assert_eq!(report["source"], "env override");
13335 }
13336
13337 #[test]
13338 fn doctor_search_provider_line_omits_switch_hint_when_bing_is_configured() {
13339 let _guard = crate::test_support::lock_test_env();
13340 let prev = std::env::var_os("DEEPSEEK_SEARCH_PROVIDER");
13341 unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") };
13342 let config = Config {
13343 search: Some(crate::config::SearchConfig {
13344 provider: Some(crate::config::SearchProvider::Bing),
13345 base_url: None,
13346 api_key: None,
13347 }),
13348 ..Default::default()
13349 };
13350
13351 let line = doctor_search_provider_line(&config);
13352
13353 match prev {
13354 Some(value) => unsafe { std::env::set_var("DEEPSEEK_SEARCH_PROVIDER", value) },
13355 None => unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") },
13356 }
13357 assert!(line.contains("search_provider: bing"));
13358 assert!(line.contains("source: config"));
13359 assert!(!line.contains("[search] provider"));
13360 }
13361
13362 #[test]
13363 fn timeout_recovery_keeps_default_deepseek_users_on_default_endpoint() {
13364 let config = Config::default();
13365
13366 let text = doctor_timeout_recovery_lines(&config).join("\n");
13367
13368 assert!(text.contains("api.deepseek.com"));
13369 assert!(text.contains("custom DeepSeek-compatible endpoint"));
13370 assert!(!text.contains("provider = \"deepseek-cn\""));
13371 assert!(text.contains("codewhale doctor --json"));
13372 }
13373
13374 #[test]
13375 fn timeout_recovery_for_custom_provider_checks_openai_compatibility() {
13376 let config = Config {
13377 provider: Some("vllm".to_string()),
13378 ..Default::default()
13379 };
13380
13381 let text = doctor_timeout_recovery_lines(&config).join("\n");
13382
13383 assert!(text.contains("/v1/models"));
13384 assert!(text.contains("/v1/chat/completions"));
13385 assert!(!text.contains("api.deepseeki.com"));
13386 }
13387}
13388
13389#[cfg(test)]
13390mod terminal_mode_tests {
13391 use super::*;
13392 use clap::Parser;
13393
13394 fn parse_cli(args: &[&str]) -> Cli {
13395 Cli::try_parse_from(args).expect("CLI args should parse")
13396 }
13397
13398 #[test]
13399 fn headless_consultant_authority_overrides_network_allow_and_disables_web_search() {
13400 let config = Config {
13401 network: Some(crate::config::NetworkPolicyToml {
13402 default: "allow".to_string(),
13403 audit: false,
13404 ..crate::config::NetworkPolicyToml::default()
13405 }),
13406 ..Config::default()
13407 };
13408 let authority = crate::tools::spec::ToolAuthorityEnvelope {
13409 schema_version: 1,
13410 owner: "consultant-1".to_string(),
13411 authority: crate::tools::spec::ToolMutationAuthority::ReadOnly,
13412 network_access: Some(false),
13413 shell: crate::tools::spec::ToolShellAuthority::None,
13414 verification: crate::tools::spec::ToolVerificationAuthority::None,
13415 writable_roots: Vec::new(),
13416 writable_files: Vec::new(),
13417 coordination_contracts: Vec::new(),
13418 }
13419 .normalized()
13420 .expect("Consultant authority");
13421
13422 let policy = exec_network_policy(&config, authority.network_access)
13423 .expect("explicit network=false always installs a policy");
13424 assert_eq!(
13425 policy.evaluate("example.com", "web_search"),
13426 crate::network_policy::Decision::Deny,
13427 "the permissive user config must not widen Consultant network authority"
13428 );
13429 let mut features = crate::features::Features::default();
13430 features.enable(crate::features::Feature::ShellTool);
13431 features.enable(crate::features::Feature::WebSearch);
13432 apply_fleet_engine_feature_caps(
13433 &mut features,
13434 true,
13435 authority.network_access,
13436 authority.shell,
13437 );
13438 assert!(!features.enabled(crate::features::Feature::WebSearch));
13439 assert!(!features.enabled(crate::features::Feature::ShellTool));
13440
13441 let worker_policy = exec_network_policy(&config, Some(true)).expect("configured policy");
13442 assert_eq!(
13443 worker_policy.evaluate("example.com", "web_search"),
13444 crate::network_policy::Decision::Allow,
13445 "a network-capable role keeps the configured policy"
13446 );
13447 }
13448 #[test]
13449 fn hidden_remote_control_flag_starts_the_interactive_handoff() {
13450 let cli = parse_cli(&["codewhale-tui", "--remote-control"]);
13451 assert!(cli.remote_control);
13452 }
13453
13454 #[test]
13455 fn plugin_registry_discovery_is_route_independent_and_read_only() {
13456 let _env_lock = crate::test_support::lock_test_env();
13457 let temp = tempfile::tempdir().unwrap();
13458 let workspace = temp.path().join("workspace");
13459 let codewhale_home = temp.path().join("home");
13460 std::fs::create_dir_all(&workspace).unwrap();
13461 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
13462 let workspace_arg = workspace.to_string_lossy().into_owned();
13463
13464 for route in [
13465 Vec::<&str>::new(),
13466 vec!["resume", "--last"],
13467 vec!["fork", "--last"],
13468 vec!["exec", "hello"],
13469 vec!["serve", "--mcp"],
13470 ] {
13471 let mut args = vec![
13472 "codewhale-tui".to_string(),
13473 "--workspace".to_string(),
13474 workspace_arg.clone(),
13475 ];
13476 args.extend(route.into_iter().map(str::to_string));
13477 let cli = Cli::try_parse_from(args).expect("route should parse");
13478 let discovery = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv();
13479 let registry = discovery
13480 .registry_for_workspace(cli.workspace.as_deref().unwrap_or(workspace.as_path()));
13481 assert_eq!(registry.workspace(), workspace.as_path());
13482 assert!(
13483 !codewhale_home.join("plugins/state.json").exists(),
13484 "startup discovery must remain read-only"
13485 );
13486 }
13487 }
13488
13489 fn custom_exec_config(active: &str) -> Config {
13490 let mut custom = std::collections::HashMap::new();
13491 for (name, base_url, model) in [
13492 (
13493 "custom-a",
13494 "http://127.0.0.1:18181/v1",
13495 crate::config::ZAI_GLM_5_2_MODEL,
13496 ),
13497 ("custom-b", "http://127.0.0.1:18182/v1", "model-b"),
13498 ] {
13499 custom.insert(
13500 name.to_string(),
13501 crate::config::ProviderConfig {
13502 kind: Some("openai-compatible".to_string()),
13503 base_url: Some(base_url.to_string()),
13504 model: Some(model.to_string()),
13505 api_key: Some("local-test-key".to_string()),
13506 ..Default::default()
13507 },
13508 );
13509 }
13510 Config {
13511 provider: Some(active.to_string()),
13512 providers: Some(crate::config::ProvidersConfig {
13513 custom,
13514 ..Default::default()
13515 }),
13516 ..Default::default()
13517 }
13518 }
13519
13520 #[test]
13521 fn doctor_json_surfaces_keep_exact_named_custom_provider() {
13522 let config = custom_exec_config("custom-a");
13523 let workspace = tempfile::tempdir().expect("doctor workspace");
13524
13525 let operate = doctor_operate_fleet_report_json(&config, workspace.path());
13526 let provider_model = doctor_provider_model_report_json(&config);
13527 let capability = provider_capability_report(&config);
13528 let route = doctor_route_report(&config);
13529
13530 assert_eq!(operate["provider"]["id"], "custom-a");
13531 assert_eq!(provider_model["provider"]["id"], "custom-a");
13532 assert_eq!(capability["resolved_provider"], "custom-a");
13533 assert_eq!(route["provider"], "custom-a");
13534 assert_eq!(route["provider_config_table"], "providers.custom-a");
13535 let serialized = serde_json::to_string(&serde_json::json!({
13536 "operate": operate,
13537 "provider_model": provider_model,
13538 "capability": capability,
13539 "route": route,
13540 }))
13541 .expect("doctor JSON");
13542 assert!(!serialized.contains("local-test-key"));
13543 }
13544
13545 fn saved_exec_session(provider: &str, model: &str) -> session_manager::SavedSession {
13546 let mut saved = session_manager::create_saved_session_with_mode(
13547 &[],
13548 model,
13549 Path::new("/tmp/exec-resume"),
13550 0,
13551 None,
13552 Some("exec"),
13553 );
13554 let kind = crate::config::ApiProvider::parse(provider)
13555 .unwrap_or(crate::config::ApiProvider::Custom)
13556 .as_str();
13557 let exact_id = (!provider
13558 .eq_ignore_ascii_case(crate::config::ApiProvider::Custom.as_str()))
13559 .then_some(provider);
13560 saved.metadata.set_model_provider_route(kind, exact_id);
13561 saved
13562 }
13563
13564 #[test]
13565 fn prompt_flag_accepts_split_prompt_words_for_windows_cmd_shims() {
13566 let cli = parse_cli(&["codewhale", "-p", "hello", "world"]);
13567
13568 assert_eq!(cli.prompt, vec!["hello", "world"]);
13569 }
13570
13571 #[test]
13572 fn prompt_flag_starts_interactive_submit_input() {
13573 let cli = parse_cli(&["codewhale", "-p", "read", "the", "project"]);
13574
13575 assert_eq!(
13576 top_level_prompt_initial_input(&cli.prompt),
13577 Some(tui::InitialInput::Submit("read the project".to_string()))
13578 );
13579 }
13580
13581 #[test]
13582 fn companion_binary_reports_its_own_name() {
13583 assert_eq!(Cli::command().get_name(), "codewhale-tui");
13584 }
13585
13586 #[test]
13587 fn xai_device_auth_subcommand_parses() {
13588 let cli = parse_cli(&["codewhale-tui", "auth", "xai-device"]);
13589 assert!(matches!(
13590 cli.command,
13591 Some(Commands::Auth(TuiAuthArgs {
13592 command: TuiAuthCommand::XaiDevice
13593 }))
13594 ));
13595 }
13596
13597 #[test]
13598 fn workflow_tool_internal_subcommand_parses_exact_json() {
13599 let cli = parse_cli(&[
13600 "codewhale-tui",
13601 "workflow-tool",
13602 "--approval-source",
13603 "explicit-workflow-command",
13604 "--input-json",
13605 r#"{"action":"run","source_path":"workflows/demo.js"}"#,
13606 ]);
13607 let Some(Commands::WorkflowTool(args)) = cli.command else {
13608 panic!("expected workflow-tool command");
13609 };
13610 assert!(args.input_json.contains("\"action\":\"run\""));
13611 }
13612
13613 #[tokio::test]
13614 async fn direct_workflow_tool_runs_without_an_operator_model_turn() {
13615 use crate::tools::spec::ToolSpec;
13616
13617 let workspace = tempfile::tempdir().expect("workspace");
13618 let config = Config {
13619 provider: Some("vllm".to_string()),
13620 mcp_config_path: Some(
13621 workspace
13622 .path()
13623 .join("missing-mcp.json")
13624 .display()
13625 .to_string(),
13626 ),
13627 providers: Some(crate::config::ProvidersConfig {
13628 vllm: crate::config::ProviderConfig {
13629 base_url: Some("http://127.0.0.1:9/v1".to_string()),
13630 model: Some("offline-test-model".to_string()),
13631 ..Default::default()
13632 },
13633 ..Default::default()
13634 }),
13635 ..Default::default()
13636 };
13637 let route = CliAutoRoute {
13638 provider: crate::config::ApiProvider::Vllm,
13639 model: "offline-test-model".to_string(),
13640 reasoning_effort: None,
13641 auto_controls_reasoning: false,
13642 auto_model: false,
13643 };
13644 let (event_tx, mut event_rx) = tokio::sync::mpsc::channel(64);
13645 let plugins = Arc::new(crate::plugins::PluginRegistry::empty(workspace.path()));
13646 let (tool, context) =
13647 build_direct_workflow_tool(&config, &route, workspace.path(), event_tx, plugins)
13648 .await
13649 .expect("build direct workflow runtime");
13650
13651 let result = tool
13652 .execute(
13653 serde_json::json!({
13654 "action": "run",
13655 "script": "phase('offline'); return { ok: true };",
13656 "token_budget": 1_000_000
13657 }),
13658 &context,
13659 )
13660 .await
13661 .expect("model-free workflow run");
13662 let payload: serde_json::Value =
13663 serde_json::from_str(&result.content).expect("workflow JSON");
13664
13665 assert_eq!(payload["status"], "completed");
13666 assert_eq!(payload["result"]["ok"], true);
13667 assert_eq!(payload["child_ids"].as_array().map(Vec::len), Some(0));
13668 assert_eq!(
13669 payload["plan_approval"]["decision"],
13670 "approved_explicit_cli_command"
13671 );
13672 assert!(!context.auto_approve);
13673 assert!(!context.trust_mode);
13674 assert_eq!(
13675 context.shell_policy,
13676 crate::worker_profile::ShellPolicy::None
13677 );
13678 assert!(matches!(
13679 context.elevated_sandbox_policy,
13680 Some(crate::sandbox::SandboxPolicy::WorkspaceWrite { .. })
13681 ));
13682 let mut event_types = Vec::new();
13683 while let Ok(event) = event_rx.try_recv() {
13684 if let crate::core::events::Event::WorkflowUi { event, .. } = event
13685 && let Some(kind) = event["type"].as_str()
13686 {
13687 event_types.push(kind.to_string());
13688 }
13689 }
13690 assert!(event_types.iter().any(|kind| kind == "run_started"));
13691 assert!(event_types.iter().any(|kind| kind == "run_completed"));
13692 }
13693
13694 #[tokio::test]
13695 async fn direct_workflow_mcp_pool_applies_network_policy_before_connect() {
13696 let workspace = tempfile::tempdir().expect("workspace");
13697 let mcp_path = workspace.path().join("mcp.json");
13698 std::fs::write(
13699 &mcp_path,
13700 r#"{
13701 "mcpServers": {
13702 "blocked": { "url": "https://blocked.invalid/mcp" }
13703 }
13704 }"#,
13705 )
13706 .expect("write MCP config");
13707 let config = Config {
13708 mcp_config_path: Some(mcp_path.display().to_string()),
13709 ..Default::default()
13710 };
13711 let policy = crate::network_policy::NetworkPolicyDecider::new(
13712 crate::network_policy::NetworkPolicy {
13713 default: crate::network_policy::DecisionToml::Deny,
13714 allow: Vec::new(),
13715 deny: Vec::new(),
13716 proxy: Vec::new(),
13717 proxy_fake_ip_cidrs: Vec::new(),
13718 audit: false,
13719 },
13720 None,
13721 );
13722
13723 let plugins = Arc::new(crate::plugins::PluginRegistry::empty(workspace.path()));
13724 let (_pool, failures) =
13725 initialize_direct_workflow_mcp_pool(&config, workspace.path(), Some(policy), plugins)
13726 .await
13727 .expect("MCP feature enabled");
13728 assert_eq!(failures.len(), 1, "failures={failures:?}");
13729 assert_eq!(failures[0].0, "blocked");
13730 assert!(failures[0].1.contains("blocked by network policy"));
13731 }
13732
13733 #[test]
13734 fn exec_model_resolution_uses_provider_scoped_default() {
13735 let _env_lock = crate::test_support::lock_test_env();
13736 let _codewhale_model = crate::test_support::EnvVarGuard::remove("CODEWHALE_MODEL");
13737 let _deepseek_model = crate::test_support::EnvVarGuard::remove("DEEPSEEK_MODEL");
13738 let config = Config {
13739 provider: Some("openrouter".to_string()),
13740 default_text_model: Some("deepseek/deepseek-v4-pro".to_string()),
13741 providers: Some(crate::config::ProvidersConfig {
13742 openrouter: crate::config::ProviderConfig {
13743 model: Some("arcee-ai/trinity-large-thinking".to_string()),
13744 ..Default::default()
13745 },
13746 ..Default::default()
13747 }),
13748 ..Default::default()
13749 };
13750
13751 assert_eq!(
13752 resolve_exec_model(&config, None),
13753 "arcee-ai/trinity-large-thinking"
13754 );
13755 assert_eq!(
13756 resolve_exec_model(&config, Some("arcee-ai/trinity-large-thinking")),
13757 "arcee-ai/trinity-large-thinking"
13758 );
13759 }
13760
13761 #[test]
13762 fn exec_model_resolution_prefers_codewhale_model_env_override() {
13763 let _env_lock = crate::test_support::lock_test_env();
13764 let _codewhale_model = crate::test_support::EnvVarGuard::set("CODEWHALE_MODEL", " auto ");
13765 let _deepseek_model =
13766 crate::test_support::EnvVarGuard::set("DEEPSEEK_MODEL", "stale-deepseek-model");
13767 let config = Config {
13768 default_text_model: Some("deepseek/deepseek-v4-pro".to_string()),
13769 ..Default::default()
13770 };
13771
13772 assert_eq!(resolve_exec_model(&config, None), "auto");
13773 }
13774
13775 #[test]
13776 fn exec_model_resolution_uses_legacy_deepseek_model_env_override() {
13777 let _env_lock = crate::test_support::lock_test_env();
13778 let _codewhale_model = crate::test_support::EnvVarGuard::remove("CODEWHALE_MODEL");
13779 let _deepseek_model = crate::test_support::EnvVarGuard::set("DEEPSEEK_MODEL", " auto ");
13780 let config = Config {
13781 default_text_model: Some("deepseek/deepseek-v4-pro".to_string()),
13782 ..Default::default()
13783 };
13784
13785 assert_eq!(resolve_exec_model(&config, None), "auto");
13786 }
13787
13788 #[test]
13789 fn exec_model_resolution_uses_provider_safe_default_for_zai() {
13790 let _env_lock = crate::test_support::lock_test_env();
13791 let _codewhale_model = crate::test_support::EnvVarGuard::remove("CODEWHALE_MODEL");
13792 let _deepseek_model = crate::test_support::EnvVarGuard::remove("DEEPSEEK_MODEL");
13793 let config = Config {
13794 provider: Some("zai".to_string()),
13795 default_text_model: Some(crate::config::DEFAULT_TEXT_MODEL.to_string()),
13796 ..Default::default()
13797 };
13798
13799 assert_eq!(
13800 resolve_exec_model(&config, None),
13801 crate::config::DEFAULT_ZAI_MODEL
13802 );
13803 }
13804
13805 #[tokio::test]
13806 #[allow(clippy::await_holding_lock)]
13807 async fn explicit_exec_model_routes_to_unique_authenticated_provider_candidate() {
13808 let _env_lock = crate::test_support::lock_test_env();
13809 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
13810 let _openrouter = crate::test_support::EnvVarGuard::remove("OPENROUTER_API_KEY");
13811 let config = Config {
13812 provider: Some("deepseek".to_string()),
13813 default_text_model: Some(crate::config::DEFAULT_TEXT_MODEL.to_string()),
13814 ..Default::default()
13815 };
13816
13817 let route = resolve_cli_auto_route(&config, crate::config::ZAI_GLM_5_2_MODEL, "pong")
13818 .await
13819 .expect("explicit GLM should route to the configured Z.ai provider");
13820
13821 assert_eq!(route.provider, crate::config::ApiProvider::Zai);
13822 assert_eq!(route.model, crate::config::ZAI_GLM_5_2_MODEL);
13823 assert!(!route.auto_model);
13824 }
13825
13826 #[tokio::test]
13827 #[allow(clippy::await_holding_lock)]
13828 async fn explicit_exec_model_reports_ambiguous_authenticated_provider_candidates() {
13829 let _env_lock = crate::test_support::lock_test_env();
13830 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
13831 let _openrouter = crate::test_support::EnvVarGuard::set("OPENROUTER_API_KEY", "or-key");
13832 let config = Config {
13833 provider: Some("deepseek".to_string()),
13834 default_text_model: Some(crate::config::DEFAULT_TEXT_MODEL.to_string()),
13835 ..Default::default()
13836 };
13837
13838 let err = resolve_cli_auto_route(&config, crate::config::ZAI_GLM_5_2_MODEL, "pong")
13839 .await
13840 .expect_err("ambiguous GLM route should ask for an explicit provider");
13841 let message = err.to_string();
13842
13843 assert!(message.contains("model `GLM-5.2` is available"));
13844 assert!(message.contains("openrouter"));
13845 assert!(message.contains("zai"));
13846 assert!(message.contains("--provider"));
13847 assert!(message.contains("/provider"));
13848 assert!(message.contains("/model"));
13849 assert!(message.contains("/setup"));
13850 }
13851
13852 #[tokio::test]
13853 async fn cli_auto_model_honors_a_fixed_reasoning_preference() {
13854 let config = Config {
13855 provider: Some("vllm".to_string()),
13856 reasoning_effort: Some("low".to_string()),
13857 providers: Some(crate::config::ProvidersConfig {
13858 vllm: crate::config::ProviderConfig {
13859 base_url: Some("http://127.0.0.1:18190/v1".to_string()),
13860 model: Some("local-auto-model".to_string()),
13861 ..Default::default()
13862 },
13863 ..Default::default()
13864 }),
13865 ..Default::default()
13866 };
13867
13868 let route = resolve_cli_auto_route(&config, "auto", "debug a failing test")
13869 .await
13870 .expect("Auto model route");
13871
13872 assert!(route.auto_model);
13873 assert_eq!(
13874 route.reasoning_effort,
13875 Some(crate::tui::app::ReasoningEffort::Low)
13876 );
13877 assert!(
13878 !route.auto_controls_reasoning,
13879 "a fixed saved tier must not be replaced per prompt"
13880 );
13881 }
13882
13883 #[test]
13884 fn cli_route_execution_config_stamps_routed_model_into_provider_slot() {
13885 let mut providers = crate::config::ProvidersConfig::default();
13886 providers.deepseek.model = Some("deepseek-v4-pro".to_string());
13887 let config = Config {
13888 provider: Some("deepseek".to_string()),
13889 providers: Some(providers),
13890 ..Default::default()
13891 };
13892 let route = CliAutoRoute {
13893 provider: crate::config::ApiProvider::Deepseek,
13894 model: "deepseek-v4-flash".to_string(),
13895 reasoning_effort: None,
13896 auto_controls_reasoning: true,
13897 auto_model: true,
13898 };
13899
13900 let execution_config = config_for_cli_route(&config, &route);
13901
13902 assert_eq!(execution_config.default_model(), "deepseek-v4-flash");
13903 assert_eq!(
13904 execution_config
13905 .provider_config_for(crate::config::ApiProvider::Deepseek)
13906 .and_then(|entry| entry.model.as_deref()),
13907 Some("deepseek-v4-flash")
13908 );
13909 }
13910
13911 #[test]
13912 fn cli_route_execution_config_preserves_legacy_literal_custom_root_route() {
13913 let _lock = crate::test_support::lock_test_env();
13914 let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
13915 let _cli_key = crate::test_support::EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
13916 let config = Config {
13917 provider: Some("custom".to_string()),
13918 api_key: Some("legacy-root-key".to_string()),
13919 base_url: Some("http://127.0.0.1:18183/v1".to_string()),
13920 default_text_model: Some("legacy-model".to_string()),
13921 ..Default::default()
13922 };
13923 let route = CliAutoRoute {
13924 provider: crate::config::ApiProvider::Custom,
13925 model: "routed-legacy-model".to_string(),
13926 reasoning_effort: None,
13927 auto_controls_reasoning: false,
13928 auto_model: false,
13929 };
13930
13931 let execution = config_for_cli_route(&config, &route);
13932
13933 assert!(execution.uses_legacy_literal_custom_route());
13934 assert!(
13935 execution
13936 .providers
13937 .as_ref()
13938 .is_none_or(|providers| !providers.custom.contains_key("custom"))
13939 );
13940 assert_eq!(execution.provider.as_deref(), Some("custom"));
13941 assert_eq!(execution.default_model(), "routed-legacy-model");
13942 assert_eq!(execution.deepseek_base_url(), "http://127.0.0.1:18183/v1");
13943 assert_eq!(execution.deepseek_api_key().unwrap(), "legacy-root-key");
13944 for _ in 0..2 {
13945 let identity = execution
13946 .resolve_provider_identity("custom")
13947 .expect("legacy identity remains repeatedly resolvable");
13948 assert_eq!(identity.key, "custom");
13949 }
13950 let client =
13951 crate::client::DeepSeekClient::new(&execution).expect("legacy execution client");
13952 assert_eq!(client.base_url(), "http://127.0.0.1:18183/v1");
13953 }
13954
13955 #[test]
13956 fn exec_accepts_split_prompt_words_for_windows_cmd_shims() {
13957 let cli = parse_cli(&["codewhale", "exec", "hello", "world"]);
13958 let Some(Commands::Exec(args)) = cli.command else {
13959 panic!("expected exec command");
13960 };
13961
13962 assert_eq!(args.prompt, vec!["hello", "world"]);
13963 }
13964
13965 #[test]
13966 fn exec_keeps_model_flag_before_split_prompt_words() {
13967 let cli = parse_cli(&["codewhale", "exec", "--model", "auto", "hello", "world"]);
13968 let Some(Commands::Exec(args)) = cli.command else {
13969 panic!("expected exec command");
13970 };
13971
13972 assert_eq!(args.model.as_deref(), Some("auto"));
13973 assert_eq!(args.prompt, vec!["hello", "world"]);
13974 }
13975
13976 #[test]
13977 fn exec_keeps_flags_before_split_prompt_words() {
13978 let cli = parse_cli(&["codewhale", "exec", "--json", "hello", "world"]);
13979 let Some(Commands::Exec(args)) = cli.command else {
13980 panic!("expected exec command");
13981 };
13982
13983 assert!(args.json);
13984 assert_eq!(args.prompt, vec!["hello", "world"]);
13985 }
13986
13987 #[test]
13988 fn exec_parses_provider_flag_alongside_model() {
13989 let cli = parse_cli(&[
13992 "codewhale",
13993 "exec",
13994 "--provider",
13995 "openrouter",
13996 "--model",
13997 "glm-5.2",
13998 "audit",
13999 ]);
14000 let Some(Commands::Exec(args)) = cli.command else {
14001 panic!("expected exec command");
14002 };
14003
14004 assert_eq!(args.provider.as_deref(), Some("openrouter"));
14005 assert_eq!(args.model.as_deref(), Some("glm-5.2"));
14006 assert_eq!(args.prompt, vec!["audit"]);
14007 assert_eq!(
14010 crate::config::ApiProvider::parse(args.provider.as_deref().unwrap()),
14011 Some(crate::config::ApiProvider::Openrouter)
14012 );
14013 }
14014
14015 #[test]
14016 fn exec_provider_override_accepts_configured_custom_provider() {
14017 let mut custom = std::collections::HashMap::new();
14018 custom.insert(
14019 "lm-studio".to_string(),
14020 crate::config::ProviderConfig {
14021 kind: Some("openai-compatible".to_string()),
14022 base_url: Some("http://127.0.0.1:1234/v1".to_string()),
14023 model: Some("qwen-2.5-7b".to_string()),
14024 api_key: Some("lm-studio".to_string()),
14025 ..Default::default()
14026 },
14027 );
14028 let mut config = Config {
14029 provider: Some("deepseek".to_string()),
14030 providers: Some(crate::config::ProvidersConfig {
14031 custom,
14032 ..Default::default()
14033 }),
14034 ..Default::default()
14035 };
14036
14037 apply_exec_provider_override(&mut config, "lm-studio")
14038 .expect("configured custom provider should be accepted");
14039
14040 assert_eq!(config.provider.as_deref(), Some("lm-studio"));
14041 assert_eq!(config.api_provider(), crate::config::ApiProvider::Custom);
14042 }
14043
14044 #[test]
14045 fn exec_provider_override_prefers_exact_case_colliding_custom_key() {
14046 let mut config = Config {
14047 provider: Some("deepseek".to_string()),
14048 providers: Some(crate::config::ProvidersConfig {
14049 custom: std::collections::HashMap::from([(
14050 "CUSTOM".to_string(),
14051 crate::config::ProviderConfig {
14052 kind: Some("openai-compatible".to_string()),
14053 base_url: Some("http://127.0.0.1:5678/v1".to_string()),
14054 model: Some("case-model".to_string()),
14055 api_key: Some("case-key".to_string()),
14056 ..Default::default()
14057 },
14058 )]),
14059 ..Default::default()
14060 }),
14061 ..Default::default()
14062 };
14063
14064 apply_exec_provider_override(&mut config, "CUSTOM")
14065 .expect("exact case-colliding custom provider");
14066 assert_eq!(config.provider.as_deref(), Some("CUSTOM"));
14067 assert_eq!(config.api_provider(), crate::config::ApiProvider::Custom);
14068 assert_eq!(
14069 config.provider_identity_for(crate::config::ApiProvider::Custom),
14070 "CUSTOM"
14071 );
14072 let route = crate::route_runtime::resolve_runtime_route(
14073 &config,
14074 crate::config::ApiProvider::Custom,
14075 Some("case-model"),
14076 )
14077 .expect("resolve exact case-colliding route")
14078 .validate()
14079 .expect("preflight exact case-colliding route");
14080 assert_eq!(route.identity.key, "CUSTOM");
14081 assert_eq!(route.client.base_url(), "http://127.0.0.1:5678/v1");
14082 }
14083
14084 #[test]
14085 fn exec_provider_override_rejects_unknown_provider() {
14086 let mut config = Config {
14087 provider: Some("deepseek".to_string()),
14088 ..Default::default()
14089 };
14090
14091 let err = apply_exec_provider_override(&mut config, "lm-studio")
14092 .expect_err("unconfigured custom provider should fail closed");
14093 let message = err.to_string();
14094
14095 assert!(message.contains("Unrecognized --provider"));
14096 assert!(message.contains("[providers.<name>] custom provider"));
14097 assert_eq!(config.provider.as_deref(), Some("deepseek"));
14098 }
14099
14100 #[test]
14101 fn exec_resume_route_matrix_preserves_or_overrides_exact_provider_deliberately() {
14102 let saved = saved_exec_session("custom-a", crate::config::ZAI_GLM_5_2_MODEL);
14103
14104 let mut restored = custom_exec_config("custom-b");
14105 let model = resolve_exec_resume_route(&mut restored, &saved, false, None)
14106 .expect("plain resume restores saved route");
14107 assert_eq!(restored.provider.as_deref(), Some("custom-a"));
14108 assert_eq!(model, crate::config::ZAI_GLM_5_2_MODEL);
14109
14110 let mut explicit_provider = custom_exec_config("custom-a");
14111 apply_exec_provider_override(&mut explicit_provider, "custom-b").expect("custom B");
14112 let model = resolve_exec_resume_route(&mut explicit_provider, &saved, true, None)
14113 .expect("explicit provider wins");
14114 assert_eq!(explicit_provider.provider.as_deref(), Some("custom-b"));
14115 assert_eq!(model, "model-b");
14116
14117 let mut explicit_model = custom_exec_config("custom-b");
14118 let model =
14119 resolve_exec_resume_route(&mut explicit_model, &saved, false, Some("override-model"))
14120 .expect("explicit model keeps saved provider");
14121 assert_eq!(explicit_model.provider.as_deref(), Some("custom-a"));
14122 assert_eq!(model, "override-model");
14123
14124 let mut missing = custom_exec_config("custom-b");
14125 missing
14126 .providers
14127 .as_mut()
14128 .expect("providers")
14129 .custom
14130 .remove("custom-a");
14131 let before = missing.provider.clone();
14132 let err = resolve_exec_resume_route(&mut missing, &saved, false, None)
14133 .expect_err("removed saved provider must fail closed");
14134 assert!(err.to_string().contains("will not fall back"), "{err}");
14135 assert_eq!(missing.provider, before);
14136 }
14137
14138 #[test]
14139 fn exec_model_reads_wait_for_foreign_test_env_overrides_to_restore() {
14140 let (started_tx, started_rx) = std::sync::mpsc::channel();
14141 let (tx, rx) = std::sync::mpsc::channel();
14142
14143 let (reader, expected_after_restore) = {
14144 let lock = crate::test_support::lock_test_env();
14145 let expected_after_restore = exec_model_env_override();
14146 let temporary =
14147 crate::test_support::EnvVarGuard::set("CODEWHALE_MODEL", "temporary-model");
14148 let reader = std::thread::spawn(move || {
14149 started_tx.send(()).expect("signal model read start");
14150 tx.send(exec_model_env_override())
14151 .expect("send resolved model override");
14152 });
14153
14154 started_rx
14155 .recv_timeout(std::time::Duration::from_secs(2))
14156 .expect("reader reached model read");
14157 assert!(
14158 rx.recv_timeout(std::time::Duration::from_millis(50))
14159 .is_err(),
14160 "a foreign reader observed another test's temporary model override"
14161 );
14162 drop(temporary);
14163 drop(lock);
14164 (reader, expected_after_restore)
14165 };
14166
14167 let observed = rx
14168 .recv_timeout(std::time::Duration::from_secs(2))
14169 .expect("reader resumed after model override was restored");
14170 reader.join().expect("reader thread");
14171 assert_eq!(observed, expected_after_restore);
14172 }
14173
14174 #[tokio::test]
14175 async fn forced_exec_route_keeps_custom_provider_when_model_matches_builtin_catalog() {
14176 let config = custom_exec_config("custom-a");
14177
14178 let route =
14179 resolve_cli_exec_route(&config, crate::config::ZAI_GLM_5_2_MODEL, "audit", true)
14180 .await
14181 .expect("forced route");
14182 let execution = config_for_cli_route(&config, &route);
14183
14184 assert_eq!(route.provider, crate::config::ApiProvider::Custom);
14185 assert_eq!(route.model, crate::config::ZAI_GLM_5_2_MODEL);
14186 assert_eq!(execution.provider.as_deref(), Some("custom-a"));
14187 }
14188
14189 #[tokio::test]
14190 async fn no_flag_exec_keeps_configured_named_custom_route_for_matching_builtin_model() {
14191 let mut config = custom_exec_config("custom-a");
14192 config
14193 .providers
14194 .as_mut()
14195 .expect("providers")
14196 .custom
14197 .get_mut("custom-a")
14198 .expect("custom A")
14199 .model = Some(crate::config::ZAI_GLM_5_2_MODEL.to_string());
14200 let model = resolve_exec_model(&config, None);
14201 let force = should_force_configured_exec_route(false, None, None);
14202
14203 assert!(force, "configured/default exec route must be authoritative");
14204 assert!(!should_force_configured_exec_route(
14205 false,
14206 None,
14207 Some(crate::config::ZAI_GLM_5_2_MODEL)
14208 ));
14209 assert!(should_force_configured_exec_route(
14210 false,
14211 Some("custom-a"),
14212 Some(crate::config::ZAI_GLM_5_2_MODEL)
14213 ));
14214 assert!(should_force_configured_exec_route(
14215 true,
14216 None,
14217 Some("override-model")
14218 ));
14219
14220 let route = resolve_cli_exec_route(&config, &model, "audit", force)
14221 .await
14222 .expect("no-flag configured route");
14223 let execution = config_for_cli_route(&config, &route);
14224 assert_eq!(route.provider, crate::config::ApiProvider::Custom);
14225 assert_eq!(route.model, crate::config::ZAI_GLM_5_2_MODEL);
14226 assert_eq!(execution.provider.as_deref(), Some("custom-a"));
14227 }
14228
14229 #[tokio::test]
14230 async fn configured_review_default_keeps_named_custom_route_and_exact_receipt() {
14231 let mut config = custom_exec_config("custom-a");
14232 config
14233 .providers
14234 .as_mut()
14235 .expect("providers")
14236 .custom
14237 .get_mut("custom-a")
14238 .expect("custom A")
14239 .model = Some("model-a".to_string());
14240 config.default_text_model = Some("stale-root-deepseek-model".to_string());
14241 let model = resolve_review_model(&config, None);
14242 assert_eq!(model, "model-a");
14243 assert_eq!(
14244 resolve_review_model(&config, Some("explicit-review-model")),
14245 "explicit-review-model"
14246 );
14247
14248 let route = resolve_cli_exec_route(&config, &model, "review diff", true)
14249 .await
14250 .expect("configured review route");
14251 let execution = config_for_cli_route(&config, &route);
14252 let provider = execution.provider_identity_for(route.provider);
14253
14254 assert_eq!(route.provider, crate::config::ApiProvider::Custom);
14255 assert_eq!(provider, "custom-a");
14256 assert_eq!(execution.deepseek_base_url(), "http://127.0.0.1:18181/v1");
14257 let output = crate::tools::review::ReviewOutput::from_str("{}");
14258 let receipt = crate::tools::review::build_review_receipt(
14259 "working tree",
14260 "diff --git a/a b/a",
14261 provider,
14262 &route.model,
14263 &output,
14264 "{}",
14265 Vec::new(),
14266 );
14267 assert_eq!(receipt.provider, "custom-a");
14268 let serialized = serde_json::to_string(&receipt).expect("review receipt");
14269 assert!(!serialized.contains("127.0.0.1"));
14270 assert!(!serialized.contains("local-test-key"));
14271 }
14272
14273 #[tokio::test]
14274 async fn configured_workflow_default_keeps_named_custom_route() {
14275 let config = custom_exec_config("custom-a");
14276 let model = config.default_model();
14277
14278 let route = resolve_cli_exec_route(
14279 &config,
14280 &model,
14281 "Run a checked-in Workflow through the host runtime",
14282 true,
14283 )
14284 .await
14285 .expect("configured workflow route");
14286 let execution = config_for_cli_route(&config, &route);
14287
14288 assert_eq!(route.provider, crate::config::ApiProvider::Custom);
14289 assert_eq!(execution.provider_identity_for(route.provider), "custom-a");
14290 assert_eq!(execution.deepseek_base_url(), "http://127.0.0.1:18181/v1");
14291 let client = crate::client::DeepSeekClient::new(&execution).expect("workflow client");
14292 assert_eq!(client.base_url(), "http://127.0.0.1:18181/v1");
14293 }
14294
14295 #[test]
14296 fn exec_json_receipts_keep_exact_named_custom_provider() {
14297 let config = custom_exec_config("custom-a");
14298 let provider = config.provider_identity_for(crate::config::ApiProvider::Custom);
14299 let one_shot =
14300 one_shot_exec_json_receipt(provider.clone(), "model-a".to_string(), "done".to_string());
14301 assert_eq!(one_shot["provider"], "custom-a");
14302
14303 let agent = serde_json::to_value(ExecSummary {
14304 mode: "agent".to_string(),
14305 provider,
14306 model: "model-a".to_string(),
14307 ..ExecSummary::default()
14308 })
14309 .expect("agent exec JSON receipt");
14310 assert_eq!(agent["provider"], "custom-a");
14311 let serialized = serde_json::to_string(&agent).expect("serialize receipt");
14312 assert!(!serialized.contains("127.0.0.1"));
14313 assert!(!serialized.contains("local-test-key"));
14314 }
14315
14316 #[test]
14317 fn exec_stream_provider_pair_preserves_named_literal_and_root_custom_provenance() {
14318 let named = crate::config::ProviderIdentity {
14319 provider: crate::config::ApiProvider::Custom,
14320 key: "lm-studio".to_string(),
14321 exact_id: Some("lm-studio".to_string()),
14322 };
14323 let literal = crate::config::ProviderIdentity {
14324 provider: crate::config::ApiProvider::Custom,
14325 key: "custom".to_string(),
14326 exact_id: Some("custom".to_string()),
14327 };
14328 let root = crate::config::ProviderIdentity {
14329 provider: crate::config::ApiProvider::Custom,
14330 key: "custom".to_string(),
14331 exact_id: None,
14332 };
14333 let built_in = crate::config::ProviderIdentity {
14334 provider: crate::config::ApiProvider::Deepseek,
14335 key: "deepseek".to_string(),
14336 exact_id: Some("deepseek".to_string()),
14337 };
14338
14339 assert_eq!(
14340 exec_stream_provider_route(&named),
14341 ("custom".to_string(), Some("lm-studio".to_string()))
14342 );
14343 assert_eq!(
14344 exec_stream_provider_route(&literal),
14345 ("custom".to_string(), Some("custom".to_string()))
14346 );
14347 assert_eq!(
14348 exec_stream_provider_route(&root),
14349 ("custom".to_string(), None)
14350 );
14351 assert_eq!(
14352 exec_stream_provider_route(&built_in),
14353 ("deepseek".to_string(), None)
14354 );
14355 }
14356
14357 #[test]
14358 fn resumed_exec_persistence_updates_provider_and_model_as_one_route() {
14359 let saved_a = saved_exec_session("custom-a", crate::config::ZAI_GLM_5_2_MODEL);
14360 let mut config = custom_exec_config("custom-a");
14361 apply_exec_provider_override(&mut config, "custom-b").expect("custom B");
14362 let model = resolve_exec_resume_route(&mut config, &saved_a, true, None)
14363 .expect("explicit provider route");
14364 let mut persisted = saved_a;
14365 stamp_exec_session_metadata(
14366 &mut persisted,
14367 &model,
14368 crate::config::ApiProvider::Custom.as_str(),
14369 Some("custom-b"),
14370 Path::new("/tmp/exec-resume"),
14371 );
14372
14373 let mut next_config = custom_exec_config("custom-a");
14374 let resumed_model = resolve_exec_resume_route(&mut next_config, &persisted, false, None)
14375 .expect("next plain resume");
14376
14377 assert_eq!(persisted.metadata.model_provider, "custom");
14378 assert_eq!(
14379 persisted.metadata.model_provider_id.as_deref(),
14380 Some("custom-b")
14381 );
14382 assert_eq!(persisted.metadata.model, "model-b");
14383 assert_eq!(next_config.provider.as_deref(), Some("custom-b"));
14384 assert_eq!(resumed_model, "model-b");
14385 }
14386
14387 #[test]
14388 fn exec_persistence_omits_id_for_legacy_root_custom_route() {
14389 let mut saved = session_manager::create_saved_session_with_mode(
14390 &[],
14391 "legacy-root-model",
14392 Path::new("/tmp/exec-root"),
14393 0,
14394 None,
14395 Some("exec"),
14396 );
14397 stamp_exec_session_metadata(
14398 &mut saved,
14399 "legacy-root-model",
14400 crate::config::ApiProvider::Custom.as_str(),
14401 None,
14402 Path::new("/tmp/exec-root"),
14403 );
14404
14405 assert_eq!(saved.metadata.model_provider, "custom");
14406 assert_eq!(saved.metadata.model_provider_id, None);
14407 assert!(
14408 !serde_json::to_string(&saved)
14409 .expect("serialize exec session")
14410 .contains("model_provider_id")
14411 );
14412 }
14413
14414 #[test]
14415 fn exec_parses_reasoning_effort_flag_alongside_provider() {
14416 let cli = parse_cli(&[
14417 "codewhale",
14418 "exec",
14419 "--provider",
14420 "openrouter",
14421 "--model",
14422 "glm-5.2",
14423 "--reasoning-effort",
14424 "max",
14425 "audit",
14426 ]);
14427 let Some(Commands::Exec(args)) = cli.command else {
14428 panic!("expected exec command");
14429 };
14430
14431 assert_eq!(args.provider.as_deref(), Some("openrouter"));
14432 assert_eq!(args.model.as_deref(), Some("glm-5.2"));
14433 assert_eq!(args.reasoning_effort.as_deref(), Some("max"));
14434 assert_eq!(args.prompt, vec!["audit"]);
14435 }
14436
14437 #[test]
14438 fn cli_reasoning_effort_normalizes_aliases_and_rejects_typos() {
14439 assert_eq!(
14440 normalize_cli_reasoning_effort("xhigh").unwrap().as_deref(),
14441 Some("max")
14442 );
14443 assert_eq!(normalize_cli_reasoning_effort("default").unwrap(), None);
14444 assert!(normalize_cli_reasoning_effort("expensive").is_err());
14445 }
14446
14447 #[test]
14448 fn cli_prompt_paths_resolve_auto_before_k3_route_normalization() {
14449 let config = Config {
14450 provider: Some("moonshot".to_string()),
14451 providers: Some(crate::config::ProvidersConfig {
14452 moonshot: crate::config::ProviderConfig {
14453 base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
14454 model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
14455 ..Default::default()
14456 },
14457 ..Default::default()
14458 }),
14459 ..Default::default()
14460 };
14461
14462 for (prompt, expected) in [
14463 ("lookup the public docs", "low"),
14464 ("debug this error", "max"),
14465 ("review this ordinary change", "high"),
14466 ] {
14467 assert_eq!(
14468 cli_reasoning_effort_value_for_prompt(
14469 &config,
14470 crate::config::KIMI_CODE_K3_MODEL,
14471 crate::tui::app::ReasoningEffort::Auto,
14472 prompt,
14473 )
14474 .as_deref(),
14475 Some(expected),
14476 "prompt selector must resolve Auto for `{prompt}`"
14477 );
14478 }
14479
14480 assert_eq!(
14481 cli_reasoning_effort_value_for_prompt(
14482 &config,
14483 crate::config::KIMI_CODE_K3_MODEL,
14484 crate::tui::app::ReasoningEffort::Off,
14485 "debug must not override an explicit effort",
14486 )
14487 .as_deref(),
14488 Some("low"),
14489 "membership K3 still applies its exact-route always-thinking floor"
14490 );
14491 }
14492
14493 #[test]
14494 fn cli_route_tracks_auto_reasoning_independently_from_auto_model() {
14495 use crate::tui::app::ReasoningEffort;
14496
14497 let fixed_model_auto_reasoning = CliAutoRoute {
14498 provider: crate::config::ApiProvider::Deepseek,
14499 model: crate::config::DEFAULT_TEXT_MODEL.to_string(),
14500 reasoning_effort: Some(ReasoningEffort::Auto),
14501 auto_controls_reasoning: true,
14502 auto_model: false,
14503 };
14504 let auto_model_fixed_reasoning = CliAutoRoute {
14505 provider: crate::config::ApiProvider::OpenaiCodex,
14506 model: crate::config::DEFAULT_OPENAI_CODEX_MODEL.to_string(),
14507 reasoning_effort: Some(ReasoningEffort::High),
14508 auto_controls_reasoning: false,
14509 auto_model: true,
14510 };
14511
14512 assert!(fixed_model_auto_reasoning.auto_controls_reasoning);
14513 assert!(!fixed_model_auto_reasoning.auto_model);
14514 assert!(!auto_model_fixed_reasoning.auto_controls_reasoning);
14515 assert!(auto_model_fixed_reasoning.auto_model);
14516 }
14517
14518 #[test]
14519 fn saved_reasoning_preference_overrides_config_for_non_tui_runtimes() {
14520 let mut config = Config {
14521 reasoning_effort: Some("max".to_string()),
14522 reasoning_effort_inferred_from_legacy_alias: true,
14523 ..Default::default()
14524 };
14525 let settings = crate::settings::Settings {
14526 reasoning_effort: Some("low".to_string()),
14527 ..Default::default()
14528 };
14529
14530 apply_saved_reasoning_preference(&mut config, &settings);
14531
14532 assert_eq!(config.reasoning_effort(), Some("low"));
14533 assert!(config.reasoning_effort_is_explicit());
14534 }
14535
14536 #[test]
14539 fn fixed_model_exec_auto_resolves_to_a_concrete_tier_not_the_auto_sentinel() {
14540 let config = Config {
14541 provider: Some("zai".to_string()),
14542 ..Default::default()
14543 };
14544
14545 let resolved = cli_reasoning_effort_value_for_prompt(
14546 &config,
14547 crate::config::ZAI_GLM_5_2_MODEL,
14548 crate::tui::app::ReasoningEffort::Auto,
14549 "debug this failing integration test",
14550 )
14551 .expect("Auto must resolve to a concrete tier");
14552
14553 assert_ne!(
14554 resolved, "auto",
14555 "the literal auto sentinel must never reach a provider"
14556 );
14557 assert!(
14558 matches!(resolved.as_str(), "off" | "low" | "medium" | "high" | "max"),
14559 "unexpected resolved tier: {resolved}"
14560 );
14561 }
14562
14563 #[test]
14564 fn exec_accepts_resume_session_flags_for_harnesses() {
14565 let cli = parse_cli(&[
14566 "codewhale",
14567 "exec",
14568 "--resume",
14569 "abc123",
14570 "--output-format",
14571 "stream-json",
14572 "follow up",
14573 ]);
14574 let Some(Commands::Exec(args)) = cli.command else {
14575 panic!("expected exec command");
14576 };
14577
14578 assert_eq!(args.resume.as_deref(), Some("abc123"));
14579 assert_eq!(args.output_format, ExecOutputFormat::StreamJson);
14580 assert_eq!(args.prompt, vec!["follow up"]);
14581 }
14582
14583 #[test]
14584 fn exec_accepts_session_id_alias() {
14585 let cli = parse_cli(&["codewhale", "exec", "--session-id", "abc123", "follow up"]);
14586 let Some(Commands::Exec(args)) = cli.command else {
14587 panic!("expected exec command");
14588 };
14589
14590 assert_eq!(args.session_id.as_deref(), Some("abc123"));
14591 assert_eq!(args.output_format, ExecOutputFormat::Text);
14592 }
14593
14594 #[test]
14595 fn exec_parses_tool_gate_and_hardening_flags() {
14596 let envelope = r#"{"schema_version":1,"owner":"fleet-worker-1","authority":"read_only"}"#;
14597 let cli = parse_cli(&[
14598 "codewhale",
14599 "exec",
14600 "--allowed-tools",
14601 "File,Git",
14602 "--disallowed-tools",
14603 "Bash",
14604 "--max-turns",
14605 "7",
14606 "--append-system-prompt",
14607 "extra rules",
14608 "--tool-authority-json",
14609 envelope,
14610 "do the thing",
14611 ]);
14612 let Some(Commands::Exec(args)) = cli.command else {
14613 panic!("expected exec command");
14614 };
14615
14616 assert_eq!(
14617 args.allowed_tools.as_deref(),
14618 Some(&["File".to_string(), "Git".to_string()][..])
14619 );
14620 assert_eq!(
14621 args.disallowed_tools.as_deref(),
14622 Some(&["Bash".to_string()][..])
14623 );
14624 assert_eq!(args.max_turns, Some(7));
14625 assert_eq!(args.append_system_prompt.as_deref(), Some("extra rules"));
14626 assert_eq!(args.tool_authority_json.as_deref(), Some(envelope));
14627 assert_eq!(args.prompt, vec!["do the thing"]);
14628 }
14629
14630 #[test]
14631 fn fleet_tool_authority_cannot_cross_an_exec_resume_boundary() {
14632 assert!(validate_exec_tool_authority_resume(None, true).is_ok());
14633 assert!(validate_exec_tool_authority_resume(Some("{}"), false).is_ok());
14634 let error = validate_exec_tool_authority_resume(Some("{}"), true)
14635 .expect_err("authority must remain bound to its fresh Fleet launch")
14636 .to_string();
14637 assert!(error.contains("cannot be combined with exec --resume"));
14638 }
14639
14640 #[test]
14641 fn exec_auto_does_not_authorize_sandbox_elevation() {
14642 let cli = parse_cli(&["codewhale", "exec", "--auto", "run it"]);
14643 let Some(Commands::Exec(args)) = cli.command else {
14644 panic!("expected exec command");
14645 };
14646
14647 assert!(!exec_sandbox_elevation_authorized(
14648 args.allow_sandbox_elevation,
14649 args.sandbox.as_deref()
14650 ));
14651 }
14652
14653 #[test]
14654 fn exec_explicit_sandbox_elevation_opt_ins_authorize_retry() {
14655 let danger = parse_cli(&[
14656 "codewhale",
14657 "exec",
14658 "--auto",
14659 "--sandbox",
14660 "danger-full-access",
14661 "run it",
14662 ]);
14663 let Some(Commands::Exec(args)) = danger.command else {
14664 panic!("expected exec command");
14665 };
14666 assert!(exec_sandbox_elevation_authorized(
14667 args.allow_sandbox_elevation,
14668 args.sandbox.as_deref()
14669 ));
14670
14671 let flag = parse_cli(&[
14672 "codewhale",
14673 "exec",
14674 "--auto",
14675 "--allow-sandbox-elevation",
14676 "run it",
14677 ]);
14678 let Some(Commands::Exec(args)) = flag.command else {
14679 panic!("expected exec command");
14680 };
14681 assert!(exec_sandbox_elevation_authorized(
14682 args.allow_sandbox_elevation,
14683 args.sandbox.as_deref()
14684 ));
14685 }
14686
14687 #[test]
14688 fn exec_sandbox_denial_stream_event_is_typed() {
14689 let event = ExecStreamEvent::SandboxDenied {
14690 tool_id: "call_1".to_string(),
14691 tool_name: "exec_shell".to_string(),
14692 reason: "write blocked".to_string(),
14693 outcome: "approval_required".to_string(),
14694 };
14695 let value: serde_json::Value =
14696 serde_json::from_str(&serde_json::to_string(&event).expect("serializes"))
14697 .expect("valid json");
14698 assert_eq!(value["type"], "sandbox_denied");
14699 assert_eq!(value["outcome"], "approval_required");
14700 }
14701
14702 #[test]
14703 fn exec_help_separates_agent_mode_from_sandbox_elevation() {
14704 let mut cli = Cli::command();
14705 let help = cli
14706 .find_subcommand_mut("exec")
14707 .expect("exec command")
14708 .render_help()
14709 .to_string();
14710 assert!(help.contains("--auto"));
14711 assert!(help.contains("--sandbox"));
14712 assert!(help.contains("--allow-sandbox-elevation"));
14713 assert!(help.contains("does not change the"));
14714 assert!(help.contains("explicitly authorize sandbox elevation"));
14715 }
14716
14717 #[test]
14718 fn exec_shell_only_tool_surface_env_sets_shell_allowlist() {
14719 let _env_lock = crate::test_support::lock_test_env();
14720 let _surface =
14721 crate::test_support::EnvVarGuard::set(CODEWHALE_TOOL_SURFACE_ENV, " shell-only ");
14722
14723 let allowed_tools = resolve_exec_allowed_tools(None, exec_tool_surface_from_env())
14724 .expect("shell-only surface should set an allowlist");
14725
14726 assert_eq!(allowed_tools, vec!["bash".to_string()]);
14727 }
14728
14729 #[test]
14730 fn exec_explicit_allowed_tools_override_shell_only_env() {
14731 let _env_lock = crate::test_support::lock_test_env();
14732 let _surface =
14733 crate::test_support::EnvVarGuard::set(CODEWHALE_TOOL_SURFACE_ENV, "shell-only");
14734 let explicit = vec![" File ".to_string(), "GIT".to_string()];
14735
14736 let allowed_tools =
14737 resolve_exec_allowed_tools(Some(&explicit), exec_tool_surface_from_env())
14738 .expect("explicit allowlist should be preserved");
14739
14740 assert_eq!(allowed_tools, vec!["file".to_string(), "git".to_string()]);
14741 }
14742
14743 #[test]
14744 fn exec_full_tool_surface_env_leaves_allowlist_unset() {
14745 let _env_lock = crate::test_support::lock_test_env();
14746 let _surface = crate::test_support::EnvVarGuard::set(CODEWHALE_TOOL_SURFACE_ENV, "full");
14747
14748 assert_eq!(
14749 resolve_exec_allowed_tools(None, exec_tool_surface_from_env()),
14750 None
14751 );
14752 }
14753
14754 #[test]
14755 fn exec_unknown_tool_surface_env_warns_without_allowlist() {
14756 assert!(should_warn_unknown_exec_tool_surface("shell_onyl"));
14757 assert!(!should_warn_unknown_exec_tool_surface("shell-only"));
14758 assert!(!should_warn_unknown_exec_tool_surface("native-tools"));
14759 assert!(!should_warn_unknown_exec_tool_surface("full"));
14760 assert!(!should_warn_unknown_exec_tool_surface(" "));
14761 assert_eq!(parse_exec_tool_surface("shell_onyl"), None);
14762 }
14763
14764 #[test]
14765 fn exec_rejects_zero_max_turns() {
14766 let err = Cli::try_parse_from(["codewhale", "exec", "--max-turns", "0", "hello"])
14767 .expect_err("max-turns must be >= 1");
14768 assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation);
14769 }
14770
14771 #[test]
14772 fn exec_omits_the_headless_turn_cap_by_default() {
14773 let cli = parse_cli(&["codewhale", "exec", "--auto", "benchmark this"]);
14774 let Some(Commands::Exec(args)) = cli.command else {
14775 panic!("expected exec command");
14776 };
14777
14778 assert_eq!(args.max_turns, None);
14779 assert_eq!(exec_max_steps(args.max_turns), u32::MAX);
14780 assert_eq!(exec_max_steps(Some(7)), 7);
14781 }
14782
14783 #[test]
14784 fn exec_accepts_continue_for_latest_workspace_session() {
14785 let cli = parse_cli(&["codewhale", "exec", "--continue", "follow up"]);
14786 let Some(Commands::Exec(args)) = cli.command else {
14787 panic!("expected exec command");
14788 };
14789
14790 assert!(args.continue_session);
14791 }
14792
14793 #[test]
14794 fn sessions_footer_points_to_resume_subcommand() {
14795 let cli = parse_cli(&["codewhale", "resume", "abc123"]);
14796 let Some(Commands::Resume { session_id, last }) = cli.command else {
14797 panic!("expected resume command");
14798 };
14799
14800 assert_eq!(session_id.as_deref(), Some("abc123"));
14801 assert!(!last);
14802 assert_eq!(sessions_resume_command(), "codewhale resume");
14803 assert!(!sessions_resume_command().contains("--resume"));
14804 }
14805
14806 #[test]
14807 fn plugin_registry_initialization_precedes_dotenv_for_all_launch_paths() {
14808 use std::cell::Cell;
14809
14810 #[derive(Clone, Copy)]
14811 enum Expected {
14812 Plain,
14813 Resume,
14814 Fork,
14815 Exec,
14816 Serve,
14817 }
14818
14819 let cases: &[(&[&str], Expected)] = &[
14820 (&["codewhale"], Expected::Plain),
14821 (&["codewhale", "resume", "--last"], Expected::Resume),
14822 (&["codewhale", "fork", "--last"], Expected::Fork),
14823 (&["codewhale", "exec", "probe"], Expected::Exec),
14824 (&["codewhale", "serve", "--mcp"], Expected::Serve),
14825 ];
14826
14827 for (args, expected) in cases {
14828 let phase = Cell::new(0);
14829 let (_cli, command) = prepare_cli_startup(
14830 parse_cli(args),
14831 || {
14832 assert_eq!(phase.get(), 0, "plugin init order for {args:?}");
14833 phase.set(1);
14834 },
14835 || {
14836 assert_eq!(phase.get(), 1, "dotenv load order for {args:?}");
14837 phase.set(2);
14838 },
14839 );
14840
14841 assert_eq!(phase.get(), 2, "startup phases for {args:?}");
14842 let correct_variant = matches!(
14843 (expected, command.as_ref()),
14844 (Expected::Plain, None)
14845 | (Expected::Resume, Some(Commands::Resume { .. }))
14846 | (Expected::Fork, Some(Commands::Fork { .. }))
14847 | (Expected::Exec, Some(Commands::Exec(_)))
14848 | (Expected::Serve, Some(Commands::Serve(_)))
14849 );
14850 assert!(correct_variant, "unexpected command for {args:?}");
14851 }
14852 }
14853
14854 #[test]
14855 fn workspace_dotenv_loads_only_provider_credentials_and_preserves_shell_values() {
14856 let _lock = crate::test_support::lock_test_env();
14857 let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
14858 let _nvidia = crate::test_support::EnvVarGuard::set("NVIDIA_API_KEY", "shell-key");
14859 let _home = crate::test_support::EnvVarGuard::remove("CODEWHALE_HOME");
14860 let _config = crate::test_support::EnvVarGuard::remove("CODEWHALE_CONFIG_PATH");
14861 let _shell = crate::test_support::EnvVarGuard::remove("DEEPSEEK_ALLOW_SHELL");
14862 let tmp = tempfile::TempDir::new().expect("temp workspace");
14863 let dotenv = tmp.path().join(".env");
14864 std::fs::write(
14865 &dotenv,
14866 "DEEPSEEK_API_KEY=workspace-key\n\
14867 NVIDIA_API_KEY=repo-must-not-override-shell\n\
14868 CODEWHALE_HOME=./attacker-home\n\
14869 CODEWHALE_CONFIG_PATH=./attacker.toml\n\
14870 DEEPSEEK_ALLOW_SHELL=true\n",
14871 )
14872 .expect("write dotenv");
14873
14874 let report = load_workspace_dotenv_credentials_from_path(&dotenv).expect("safe load");
14875
14876 assert_eq!(
14877 std::env::var("DEEPSEEK_API_KEY").as_deref(),
14878 Ok("workspace-key")
14879 );
14880 assert_eq!(std::env::var("NVIDIA_API_KEY").as_deref(), Ok("shell-key"));
14881 assert!(std::env::var_os("CODEWHALE_HOME").is_none());
14882 assert!(std::env::var_os("CODEWHALE_CONFIG_PATH").is_none());
14883 assert!(std::env::var_os("DEEPSEEK_ALLOW_SHELL").is_none());
14884 assert_eq!(
14885 report.loaded,
14886 BTreeSet::from(["DEEPSEEK_API_KEY".to_string()])
14887 );
14888 assert_eq!(
14889 report.ignored,
14890 BTreeSet::from([
14891 "CODEWHALE_CONFIG_PATH".to_string(),
14892 "CODEWHALE_HOME".to_string(),
14893 "DEEPSEEK_ALLOW_SHELL".to_string(),
14894 ])
14895 );
14896 }
14897
14898 #[test]
14899 fn workspace_dotenv_rejects_ambient_variable_substitution() {
14900 let _lock = crate::test_support::lock_test_env();
14901 let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
14902 let _ambient = crate::test_support::EnvVarGuard::set(
14903 "CODEWHALE_JS_SECRET_LEAK_TEST",
14904 "ambient-secret-must-not-expand",
14905 );
14906 let tmp = tempfile::TempDir::new().expect("temp workspace");
14907 let dotenv = tmp.path().join(".env");
14908 std::fs::write(
14909 &dotenv,
14910 "DEEPSEEK_API_KEY=${CODEWHALE_JS_SECRET_LEAK_TEST}\n",
14911 )
14912 .expect("write dotenv");
14913
14914 let error = load_workspace_dotenv_credentials_from_path(&dotenv)
14915 .expect_err("expansion must fail closed")
14916 .to_string();
14917
14918 assert!(error.contains("variable expansion"));
14919 assert!(!error.contains("ambient-secret-must-not-expand"));
14920 assert!(std::env::var_os("DEEPSEEK_API_KEY").is_none());
14921 }
14922
14923 #[test]
14924 fn workspace_dotenv_rejects_multiline_ambient_variable_substitution() {
14925 let _lock = crate::test_support::lock_test_env();
14926 let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
14927 let _ambient = crate::test_support::EnvVarGuard::set(
14928 "CODEWHALE_JS_SECRET_LEAK_TEST",
14929 "ambient-secret-must-not-expand",
14930 );
14931 let tmp = tempfile::TempDir::new().expect("temp workspace");
14932 let dotenv = tmp.path().join(".env");
14933 std::fs::write(
14934 &dotenv,
14935 "DEEPSEEK_API_KEY=\"prefix\n$CODEWHALE_JS_SECRET_LEAK_TEST=bar\nsuffix\"\n",
14936 )
14937 .expect("write dotenv");
14938
14939 let error = load_workspace_dotenv_credentials_from_path(&dotenv)
14940 .expect_err("multiline expansion must fail closed")
14941 .to_string();
14942
14943 assert!(error.contains("variable expansion"));
14944 assert!(!error.contains("ambient-secret-must-not-expand"));
14945 assert!(std::env::var_os("DEEPSEEK_API_KEY").is_none());
14946 }
14947
14948 #[test]
14949 fn workspace_dotenv_comment_quote_cannot_hide_later_expansion() {
14950 let _lock = crate::test_support::lock_test_env();
14951 let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
14952 let _ambient = crate::test_support::EnvVarGuard::set(
14953 "CODEWHALE_JS_SECRET_LEAK_TEST",
14954 "ambient-secret-must-not-expand",
14955 );
14956 let tmp = tempfile::TempDir::new().expect("temp workspace");
14957 let dotenv = tmp.path().join(".env");
14958 std::fs::write(
14959 &dotenv,
14960 "# unmatched quote in ignored comment: '\n\
14961 DEEPSEEK_API_KEY=$CODEWHALE_JS_SECRET_LEAK_TEST\n",
14962 )
14963 .expect("write dotenv");
14964
14965 let error = load_workspace_dotenv_credentials_from_path(&dotenv)
14966 .expect_err("comment quote must not hide expansion")
14967 .to_string();
14968
14969 assert!(error.contains("variable expansion"));
14970 assert!(!error.contains("ambient-secret-must-not-expand"));
14971 assert!(std::env::var_os("DEEPSEEK_API_KEY").is_none());
14972 }
14973
14974 #[test]
14975 fn workspace_dotenv_allows_single_quoted_literal_dollar() {
14976 let _lock = crate::test_support::lock_test_env();
14977 let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
14978 let tmp = tempfile::TempDir::new().expect("temp workspace");
14979 let dotenv = tmp.path().join(".env");
14980 std::fs::write(&dotenv, "DEEPSEEK_API_KEY='$literal-value'\n").expect("write dotenv");
14981
14982 load_workspace_dotenv_credentials_from_path(&dotenv).expect("literal dollar load");
14983
14984 assert_eq!(
14985 std::env::var("DEEPSEEK_API_KEY").as_deref(),
14986 Ok("$literal-value")
14987 );
14988 }
14989
14990 #[test]
14991 fn workspace_dotenv_parse_failure_applies_no_earlier_credentials() {
14992 let _lock = crate::test_support::lock_test_env();
14993 let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
14994 let tmp = tempfile::TempDir::new().expect("temp workspace");
14995 let dotenv = tmp.path().join(".env");
14996 std::fs::write(
14997 &dotenv,
14998 "DEEPSEEK_API_KEY=must-not-survive\nBROKEN=\"unterminated\n",
14999 )
15000 .expect("write dotenv");
15001
15002 let error = load_workspace_dotenv_credentials_from_path(&dotenv)
15003 .expect_err("parse failure must be transactional")
15004 .to_string();
15005
15006 assert!(error.contains("could not be parsed safely"), "{error}");
15007 assert!(!error.contains("must-not-survive"));
15008 assert!(std::env::var_os("DEEPSEEK_API_KEY").is_none());
15009 }
15010
15011 #[test]
15012 fn workspace_dotenv_credential_allowlist_excludes_control_plane_names() {
15013 for provider in codewhale_config::provider::providers_sorted_for_display() {
15014 for key in provider.env_vars() {
15015 assert!(
15016 is_workspace_dotenv_credential_key(key),
15017 "provider credential {key} must remain supported"
15018 );
15019 }
15020 }
15021 for key in [
15022 "CODEWHALE_HOME",
15023 "CODEWHALE_CONFIG_PATH",
15024 "DEEPSEEK_CONFIG_PATH",
15025 "DEEPSEEK_PROFILE",
15026 "DEEPSEEK_MANAGED_CONFIG_PATH",
15027 "DEEPSEEK_REQUIREMENTS_PATH",
15028 "DEEPSEEK_PROVIDER",
15029 "DEEPSEEK_BASE_URL",
15030 "DEEPSEEK_MODEL",
15031 "DEEPSEEK_APPROVAL_POLICY",
15032 "DEEPSEEK_SANDBOX_MODE",
15033 "DEEPSEEK_ALLOW_SHELL",
15034 "DEEPSEEK_YOLO",
15035 "DEEPSEEK_MCP_CONFIG",
15036 "CODEWHALE_RUNTIME_TOKEN",
15037 "PATH",
15038 "NODE_OPTIONS",
15039 "PYTHONPATH",
15040 "LD_PRELOAD",
15041 "DYLD_INSERT_LIBRARIES",
15042 ] {
15043 assert!(
15044 !is_workspace_dotenv_credential_key(key),
15045 "control-plane variable {key} must not load from a workspace"
15046 );
15047 }
15048 }
15049
15050 #[cfg(unix)]
15051 #[test]
15052 fn workspace_dotenv_does_not_follow_symbolic_links() {
15053 use std::os::unix::fs::symlink;
15054
15055 let tmp = tempfile::TempDir::new().expect("temp workspace");
15056 let external = tmp.path().join("external-credentials");
15057 let dotenv = tmp.path().join(".env");
15058 std::fs::write(&external, "DEEPSEEK_API_KEY=external-secret\n")
15059 .expect("write external fixture");
15060 symlink(&external, &dotenv).expect("create dotenv symlink");
15061
15062 let error = load_workspace_dotenv_credentials_from_path(&dotenv)
15063 .expect_err("symlink must fail closed")
15064 .to_string();
15065
15066 assert!(error.contains("securely open"), "{error}");
15067 assert!(!error.contains("external-secret"));
15068 }
15069
15070 #[cfg(unix)]
15071 #[test]
15072 fn workspace_dotenv_rejects_hard_links_to_external_files() {
15073 let tmp = tempfile::TempDir::new().expect("temp workspace");
15074 let external = tmp.path().join("external-credentials");
15075 let dotenv = tmp.path().join(".env");
15076 std::fs::write(&external, "DEEPSEEK_API_KEY=external-secret\n")
15077 .expect("write external fixture");
15078 std::fs::hard_link(&external, &dotenv).expect("create dotenv hard link");
15079
15080 let error = load_workspace_dotenv_credentials_from_path(&dotenv)
15081 .expect_err("hard link must fail closed")
15082 .to_string();
15083
15084 assert!(error.contains("multiple filesystem links"), "{error}");
15085 assert!(!error.contains("external-secret"));
15086 }
15087
15088 #[cfg(unix)]
15089 #[test]
15090 fn workspace_dotenv_rejects_fifo_without_blocking_startup() {
15091 use std::ffi::CString;
15092 use std::os::unix::ffi::OsStrExt;
15093 use std::sync::mpsc;
15094 use std::time::Duration;
15095
15096 let tmp = tempfile::TempDir::new().expect("temp workspace");
15097 let dotenv = tmp.path().join(".env");
15098 let c_path = CString::new(dotenv.as_os_str().as_bytes()).expect("fifo path");
15099 let result = unsafe { libc::mkfifo(c_path.as_ptr(), libc::S_IRUSR | libc::S_IWUSR) };
15102 assert_eq!(result, 0, "mkfifo failed: {}", io::Error::last_os_error());
15103
15104 let (tx, rx) = mpsc::channel();
15105 let worker_path = dotenv.clone();
15106 let worker = std::thread::spawn(move || {
15107 let result = load_workspace_dotenv_credentials_from_path(&worker_path)
15108 .map(|_| "unexpected success".to_string())
15109 .unwrap_or_else(|error| error.to_string());
15110 tx.send(result).expect("send loader result");
15111 });
15112
15113 let error = match rx.recv_timeout(Duration::from_secs(1)) {
15114 Ok(error) => error,
15115 Err(timeout) => {
15116 let _writer = std::fs::OpenOptions::new()
15119 .write(true)
15120 .open(&dotenv)
15121 .expect("open fifo writer to release blocked reader");
15122 let _ = rx.recv_timeout(Duration::from_secs(1));
15123 worker.join().expect("join released loader");
15124 panic!("workspace .env FIFO blocked startup: {timeout}");
15125 }
15126 };
15127 worker.join().expect("join loader");
15128
15129 assert!(error.contains("not a regular file"), "{error}");
15130 }
15131
15132 #[test]
15133 fn exec_json_conflicts_with_stream_json_output() {
15134 let err = Cli::try_parse_from([
15135 "codewhale",
15136 "exec",
15137 "--json",
15138 "--output-format",
15139 "stream-json",
15140 "hello",
15141 ])
15142 .expect_err("json summary and stream-json must not mix");
15143
15144 assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
15145 }
15146
15147 #[test]
15148 fn exec_stream_turn_usage_event_serializes_reported_fields() {
15149 let event = ExecStreamEvent::TurnUsage {
15150 turn: 2,
15151 input_tokens: 1200,
15152 output_tokens: 180,
15153 reasoning_tokens: Some(90),
15154 prompt_cache_hit_tokens: Some(900),
15155 prompt_cache_miss_tokens: Some(300),
15156 prompt_cache_write_tokens: Some(0),
15157 reasoning_replay_tokens: Some(40),
15158 duration_ms: 1834,
15159 };
15160
15161 let value = exec_stream_value(&event).expect("serializes");
15162 let json = serde_json::to_string(&value).expect("serializes");
15163 assert!(!json.contains('\n'));
15164 let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json");
15165 assert_eq!(parsed["type"], "turn_usage");
15166 assert_eq!(parsed["schema"], "codewhale.exec-stream");
15167 assert_eq!(parsed["schema_version"], 1);
15168 assert_eq!(parsed["turn"], 2);
15169 assert_eq!(parsed["input_tokens"], 1200);
15170 assert_eq!(parsed["output_tokens"], 180);
15171 assert_eq!(parsed["reasoning_tokens"], 90);
15172 assert_eq!(parsed["prompt_cache_hit_tokens"], 900);
15173 assert_eq!(parsed["prompt_cache_miss_tokens"], 300);
15174 assert_eq!(parsed["prompt_cache_write_tokens"], 0);
15175 assert_eq!(parsed["reasoning_replay_tokens"], 40);
15176 assert_eq!(parsed["duration_ms"], 1834);
15177 }
15178
15179 #[test]
15180 fn exec_stream_turn_usage_event_omits_unreported_fields() {
15181 let event = ExecStreamEvent::TurnUsage {
15185 turn: 1,
15186 input_tokens: 11,
15187 output_tokens: 3,
15188 reasoning_tokens: None,
15189 prompt_cache_hit_tokens: None,
15190 prompt_cache_miss_tokens: None,
15191 prompt_cache_write_tokens: None,
15192 reasoning_replay_tokens: None,
15193 duration_ms: 250,
15194 };
15195
15196 let value = exec_stream_value(&event).expect("serializes");
15197 let parsed = value;
15198 assert_eq!(parsed["type"], "turn_usage");
15199 assert_eq!(parsed["input_tokens"], 11);
15200 assert_eq!(parsed["output_tokens"], 3);
15201 assert_eq!(parsed["duration_ms"], 250);
15202 let object = parsed.as_object().expect("event object");
15203 for absent in [
15204 "reasoning_tokens",
15205 "prompt_cache_hit_tokens",
15206 "prompt_cache_miss_tokens",
15207 "prompt_cache_write_tokens",
15208 "reasoning_replay_tokens",
15209 ] {
15210 assert!(!object.contains_key(absent), "{absent} leaked: {parsed}");
15211 }
15212 }
15213
15214 #[test]
15215 fn exec_stream_pre_existing_event_type_tags_are_unchanged() {
15216 let cases: Vec<(ExecStreamEvent, &str)> = vec![
15219 (
15220 ExecStreamEvent::Content {
15221 content: "hi".to_string(),
15222 },
15223 "content",
15224 ),
15225 (
15226 ExecStreamEvent::ToolUse {
15227 name: "read_file".to_string(),
15228 id: "call_1".to_string(),
15229 input: serde_json::json!({}),
15230 started_at: "2026-08-03T00:00:00Z".to_string(),
15231 },
15232 "tool_use",
15233 ),
15234 (
15235 ExecStreamEvent::ToolResult {
15236 id: "call_1".to_string(),
15237 name: "read_file".to_string(),
15238 output: "ok".to_string(),
15239 status: "success".to_string(),
15240 started_at: "2026-08-03T00:00:00Z".to_string(),
15241 completed_at: "2026-08-03T00:00:01Z".to_string(),
15242 duration_ms: 1,
15243 side_effect_status: "unknown".to_string(),
15244 error_category: None,
15245 truncated: None,
15246 artifact: None,
15247 result_metadata: None,
15248 },
15249 "tool_result",
15250 ),
15251 (
15252 ExecStreamEvent::SandboxDenied {
15253 tool_id: "call_1".to_string(),
15254 tool_name: "exec_shell".to_string(),
15255 reason: "denied".to_string(),
15256 outcome: "approval_required".to_string(),
15257 },
15258 "sandbox_denied",
15259 ),
15260 (
15261 ExecStreamEvent::WorkflowEvent {
15262 run_id: "workflow_1".to_string(),
15263 event: serde_json::json!({"type": "task_completed"}),
15264 },
15265 "workflow_event",
15266 ),
15267 (
15268 ExecStreamEvent::SessionCapture {
15269 content: "x".to_string(),
15270 },
15271 "session_capture",
15272 ),
15273 (
15274 ExecStreamEvent::Error {
15275 error: "boom".to_string(),
15276 },
15277 "error",
15278 ),
15279 (ExecStreamEvent::Done, "done"),
15280 ];
15281
15282 for (event, expected_type) in cases {
15283 let value = exec_stream_value(&event).expect("serializes");
15284 assert_eq!(value["type"], expected_type, "event tag drifted");
15285 assert_eq!(value["schema"], "codewhale.exec-stream");
15286 assert_eq!(value["schema_version"], 1);
15287 }
15288 }
15289
15290 #[test]
15291 fn exec_stream_events_are_json_lines() {
15292 let event = ExecStreamEvent::ToolResult {
15293 id: "call_1".to_string(),
15294 name: "read_file".to_string(),
15295 output: "line 1\nline 2".to_string(),
15296 status: "success".to_string(),
15297 started_at: "2026-07-13T00:00:00Z".to_string(),
15298 completed_at: "2026-07-13T00:00:01Z".to_string(),
15299 duration_ms: 1000,
15300 side_effect_status: "not_started".to_string(),
15301 error_category: None,
15302 truncated: Some(false),
15303 artifact: None,
15304 result_metadata: None,
15305 };
15306
15307 let value = exec_stream_value(&event).expect("serializes");
15308 let json = serde_json::to_string(&value).expect("serializes");
15309 assert!(!json.contains('\n'));
15310 let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json");
15311 assert_eq!(parsed["type"], "tool_result");
15312 assert_eq!(parsed["schema"], "codewhale.exec-stream");
15313 assert_eq!(parsed["schema_version"], 1);
15314 assert_eq!(parsed["duration_ms"], 1000);
15315 assert_eq!(parsed["side_effect_status"], "not_started");
15316 }
15317
15318 #[test]
15319 fn workflow_receipt_stream_event_is_one_json_line() {
15320 let event = ExecStreamEvent::WorkflowEvent {
15321 run_id: "workflow_1234".to_string(),
15322 event: serde_json::json!({
15323 "type": "handoff_promoted",
15324 "artifact_id": "workflow_1234:agent_1:review-gate:review_report",
15325 "gate_id": "review-gate",
15326 "kind": "review_report",
15327 "from_role": "reviewer",
15328 "to_role": "verifier",
15329 "producer_task_id": "agent_1"
15330 }),
15331 };
15332
15333 let value = exec_stream_value(&event).expect("serializes");
15334 let json = serde_json::to_string(&value).expect("serializes");
15335 assert!(!json.contains('\n'));
15336 let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json");
15337 assert_eq!(parsed["type"], "workflow_event");
15338 assert_eq!(parsed["schema"], "codewhale.exec-stream");
15339 assert_eq!(parsed["schema_version"], 1);
15340 assert_eq!(parsed["run_id"], "workflow_1234");
15341 assert_eq!(parsed["event"]["type"], "handoff_promoted");
15342 assert_eq!(
15343 parsed["event"]["artifact_id"],
15344 "workflow_1234:agent_1:review-gate:review_report"
15345 );
15346 assert_eq!(parsed["event"]["gate_id"], "review-gate");
15347 assert_eq!(parsed["event"]["kind"], "review_report");
15348 assert_eq!(parsed["event"]["from_role"], "reviewer");
15349 assert_eq!(parsed["event"]["to_role"], "verifier");
15350 assert_eq!(parsed["event"]["producer_task_id"], "agent_1");
15351 assert!(parsed["event"].get("payload").is_none(), "{parsed}");
15352
15353 let consumed = ExecStreamEvent::WorkflowEvent {
15354 run_id: "workflow_1234".to_string(),
15355 event: serde_json::json!({
15356 "type": "handoff_consumed",
15357 "artifact_id": "workflow_1234:agent_1:review-gate:review_report",
15358 "kind": "review_report",
15359 "from_role": "reviewer",
15360 "to_role": "verifier",
15361 "consumer_task_id": "agent_2"
15362 }),
15363 };
15364 let consumed = exec_stream_value(&consumed).expect("serializes consumed receipt");
15365 assert_eq!(consumed["type"], "workflow_event");
15366 assert_eq!(consumed["schema"], "codewhale.exec-stream");
15367 assert_eq!(consumed["schema_version"], 1);
15368 assert_eq!(consumed["event"]["type"], "handoff_consumed");
15369 assert_eq!(
15370 consumed["event"]["artifact_id"],
15371 "workflow_1234:agent_1:review-gate:review_report"
15372 );
15373 assert_eq!(consumed["event"]["consumer_task_id"], "agent_2");
15374 assert!(consumed["event"].get("payload").is_none(), "{consumed}");
15375 }
15376
15377 #[test]
15378 fn exec_stream_metadata_redacts_resume_breadcrumbs() {
15379 let raw_session_id = "abc123fullsecret";
15380 let event = ExecStreamEvent::Metadata {
15381 meta: Box::new(ExecStreamMeta {
15382 receipt_kind: "terminal",
15383 provider: "deepseek".to_string(),
15384 provider_id: None,
15385 model: "deepseek-v4-flash".to_string(),
15386 route_source: "explicit_or_configured".to_string(),
15387 input_tokens: Some(123),
15388 output_tokens: Some(45),
15389 prompt_cache_hit_tokens: Some(10),
15390 prompt_cache_miss_tokens: None,
15391 prompt_cache_write_tokens: None,
15392 reasoning_tokens: Some(3),
15393 duration_ms: 2500,
15394 retry_count: None,
15395 approval_posture: "ask".to_string(),
15396 sandbox_posture: "configured_default".to_string(),
15397 binary_sha256: Some("sha256:binary".to_string()),
15398 config_sha256: None,
15399 prompt_sha256: "sha256:prompt".to_string(),
15400 tool_catalog_sha256: Some("sha256:tools".to_string()),
15401 input_analysis: ExecStreamInputAnalysis::default(),
15402 visible_final_answer_chars: 17,
15403 session_id: exec_stream_session_ref(raw_session_id),
15404 resume_command: exec_stream_resume_hint(raw_session_id),
15405 workspace: "/tmp/work".to_string(),
15406 message_count: 4,
15407 status: Some("completed".to_string()),
15408 termination_reason: Some("resolved".to_string()),
15409 error_category: None,
15410 }),
15411 };
15412
15413 let json = serde_json::to_string(&event).expect("serializes");
15414 assert!(!json.contains('\n'));
15415 assert!(!json.contains(raw_session_id));
15416 let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json");
15417 assert_eq!(parsed["type"], "metadata");
15418 assert_ne!(parsed["meta"]["session_id"], raw_session_id);
15419 assert!(
15420 parsed["meta"]["session_id"]
15421 .as_str()
15422 .unwrap()
15423 .starts_with("<redacted:")
15424 );
15425 assert_eq!(
15426 parsed["meta"]["resume_command"],
15427 "codewhale exec --resume <redacted-session-id>"
15428 );
15429 assert_eq!(parsed["meta"]["workspace"], "/tmp/work");
15430 assert_eq!(parsed["meta"]["message_count"], 4);
15431 assert_eq!(parsed["meta"]["visible_final_answer_chars"], 17);
15432
15433 let capture = ExecStreamEvent::SessionCapture {
15434 content: exec_stream_session_ref(raw_session_id),
15435 };
15436 let capture_json = serde_json::to_string(&capture).expect("serializes");
15437 assert!(!capture_json.contains(raw_session_id));
15438 let parsed_capture: serde_json::Value =
15439 serde_json::from_str(&capture_json).expect("valid json");
15440 assert_eq!(parsed_capture["type"], "session_capture");
15441 assert_ne!(parsed_capture["content"], raw_session_id);
15442 }
15443
15444 #[test]
15445 fn exec_stream_input_analysis_reports_prompt_composition() {
15446 let system = SystemPrompt::Text("system rules".to_string());
15447 let messages = vec![
15448 Message {
15449 role: "user".to_string(),
15450 content: vec![ContentBlock::Text {
15451 text: "run tests".to_string(),
15452 cache_control: None,
15453 }],
15454 },
15455 Message {
15456 role: "assistant".to_string(),
15457 content: vec![
15458 ContentBlock::Thinking {
15459 thinking: "checking context".to_string(),
15460 signature: None,
15461 },
15462 ContentBlock::Text {
15463 text: "working".to_string(),
15464 cache_control: None,
15465 },
15466 ContentBlock::ToolUse {
15467 id: "call-1".to_string(),
15468 name: "exec_shell".to_string(),
15469 input: serde_json::json!({"command": "cargo test"}),
15470 caller: None,
15471 },
15472 ],
15473 },
15474 Message {
15475 role: "user".to_string(),
15476 content: vec![ContentBlock::ToolResult {
15477 tool_use_id: "call-1".to_string(),
15478 content: "stdout line\nstderr line".to_string(),
15479 is_error: Some(false),
15480 content_blocks: Some(vec![serde_json::json!({
15481 "type": "text",
15482 "text": "structured output"
15483 })]),
15484 }],
15485 },
15486 ];
15487
15488 let analysis = exec_stream_input_analysis(&messages, Some(&system));
15489
15490 assert_eq!(analysis.user_message_count, 2);
15491 assert_eq!(analysis.assistant_message_count, 1);
15492 assert_eq!(analysis.tool_message_count, 0);
15493 assert_eq!(analysis.tool_use_count, 1);
15494 assert_eq!(analysis.tool_result_count, 1);
15495 assert_eq!(analysis.thinking_chars, "checking context".chars().count());
15496 assert!(analysis.text_chars >= "run testsworking".chars().count());
15497 assert!(analysis.tool_use_input_chars > 0);
15498 assert!(analysis.tool_result_chars >= "stdout line\nstderr line".chars().count());
15499 assert!(analysis.estimated_system_tokens > 0);
15500 assert!(analysis.estimated_message_content_tokens > 0);
15501 assert!(
15502 analysis.estimated_request_tokens
15503 >= analysis.estimated_system_tokens
15504 + analysis.estimated_message_content_tokens
15505 + analysis.estimated_framing_tokens
15506 );
15507 }
15508
15509 #[test]
15510 fn review_receipt_check_public_json_omits_private_details() {
15511 let validation = crate::tools::review::ReviewReceiptValidation {
15512 passed: false,
15513 reason: "secret reason with /tmp/private/receipt.json".to_string(),
15514 diff_fingerprint: "sha256:current".to_string(),
15515 receipt_fingerprint: Some("sha256:current".to_string()),
15516 receipt_path: Some(PathBuf::from("/tmp/private/receipt.json")),
15517 unresolved_risk: Some(crate::tools::review::ReviewReceiptRisk {
15518 unresolved: true,
15519 level: "error".to_string(),
15520 summary: "secret summary".to_string(),
15521 }),
15522 };
15523
15524 let public = review_receipt_validation_public_json(&validation);
15525 let encoded = serde_json::to_string(&public).expect("public json");
15526
15527 assert_eq!(public["passed"], false);
15528 assert_eq!(public["status"], "unresolved_risk");
15529 assert_eq!(public["risk_level"], "error");
15530 assert!(!encoded.contains("secret"));
15531 assert!(!encoded.contains("/tmp/private"));
15532 }
15533
15534 #[test]
15535 fn exec_text_session_breadcrumbs_use_compact_ids() {
15536 let session_id = "1234567890abcdef";
15537
15538 assert_eq!(exec_saved_session_line(session_id), "session: 12345678");
15539 assert_eq!(
15540 exec_resumed_session_line(session_id),
15541 "resumed session: 12345678"
15542 );
15543 assert!(!exec_saved_session_line(session_id).contains(session_id));
15544 assert!(!exec_resumed_session_line(session_id).contains(session_id));
15545 }
15546
15547 #[test]
15548 fn alternate_screen_defaults_on_in_auto_mode() {
15549 let cli = parse_cli(&["codewhale"]);
15550 let config = Config::default();
15551
15552 assert!(should_use_alt_screen(&cli, &config));
15553 }
15554
15555 #[test]
15556 fn removed_no_alt_screen_flag_is_rejected() {
15557 let error = Cli::try_parse_from(["codewhale", "--no-alt-screen"])
15560 .expect_err("--no-alt-screen must no longer parse");
15561 assert_eq!(
15562 error.kind(),
15563 clap::error::ErrorKind::UnknownArgument,
15564 "retired flag should fail as an unknown argument, not be absorbed"
15565 );
15566 }
15567
15568 #[test]
15569 fn config_never_is_accepted_but_keeps_alternate_screen() {
15570 let cli = parse_cli(&["codewhale"]);
15571 let config = Config {
15572 tui: Some(crate::config::TuiConfig {
15573 alternate_screen: Some("never".to_string()),
15574 mouse_capture: None,
15575 terminal_probe_timeout_ms: None,
15576 stream_chunk_timeout_secs: None,
15577 status_items: None,
15578 osc8_links: None,
15579 composer_arrows_scroll: None,
15580 notification_condition: None,
15581 header_items: None,
15582 }),
15583 ..Config::default()
15584 };
15585
15586 assert!(should_use_alt_screen(&cli, &config));
15587 }
15588
15589 #[test]
15590 #[cfg(not(windows))]
15591 fn mouse_capture_defaults_on_when_alternate_screen_is_active() {
15592 let cli = parse_cli(&["codewhale"]);
15593 let config = Config::default();
15594
15595 assert!(should_use_mouse_capture_with(
15596 &cli, &config, true, None, None, None
15597 ));
15598 }
15599
15600 #[test]
15601 #[cfg(windows)]
15602 fn mouse_capture_defaults_off_on_legacy_windows_console() {
15603 let cli = parse_cli(&["codewhale"]);
15607 let config = Config::default();
15608
15609 assert!(!should_use_mouse_capture_with(
15610 &cli, &config, true, None, None, None
15611 ));
15612 }
15613
15614 #[test]
15620 #[cfg(windows)]
15621 fn mouse_capture_defaults_on_in_windows_terminal() {
15622 let cli = parse_cli(&["codewhale"]);
15623 let config = Config::default();
15624
15625 assert!(should_use_mouse_capture_with(
15626 &cli,
15627 &config,
15628 true,
15629 None,
15630 Some("{a3a3b3a8-aa00-0000-0000-000000000000}"),
15631 None,
15632 ));
15633 }
15634
15635 #[test]
15638 #[cfg(windows)]
15639 fn mouse_capture_defaults_on_in_conemu() {
15640 let cli = parse_cli(&["codewhale"]);
15641 let config = Config::default();
15642
15643 assert!(should_use_mouse_capture_with(
15644 &cli,
15645 &config,
15646 true,
15647 None,
15648 None,
15649 Some("12345"),
15650 ));
15651 }
15652
15653 #[test]
15654 fn no_mouse_capture_flag_disables_mouse_capture() {
15655 let cli = parse_cli(&["codewhale", "--no-mouse-capture"]);
15656 let config = Config::default();
15657
15658 assert!(!should_use_mouse_capture_with(
15659 &cli, &config, true, None, None, None
15660 ));
15661 }
15662
15663 #[test]
15664 fn config_can_disable_default_mouse_capture() {
15665 let cli = parse_cli(&["codewhale"]);
15666 let config = Config {
15667 tui: Some(crate::config::TuiConfig {
15668 alternate_screen: None,
15669 mouse_capture: Some(false),
15670 terminal_probe_timeout_ms: None,
15671 stream_chunk_timeout_secs: None,
15672 status_items: None,
15673 osc8_links: None,
15674 composer_arrows_scroll: None,
15675 notification_condition: None,
15676 header_items: None,
15677 }),
15678 ..Config::default()
15679 };
15680
15681 assert!(!should_use_mouse_capture_with(
15682 &cli, &config, true, None, None, None
15683 ));
15684 }
15685
15686 #[test]
15687 fn mouse_capture_flag_enables_mouse_capture() {
15688 let cli = parse_cli(&["codewhale", "--mouse-capture"]);
15689 let config = Config::default();
15690
15691 assert!(should_use_mouse_capture_with(
15692 &cli, &config, true, None, None, None
15693 ));
15694 }
15695
15696 #[test]
15697 fn config_can_enable_mouse_capture() {
15698 let cli = parse_cli(&["codewhale"]);
15699 let config = Config {
15700 tui: Some(crate::config::TuiConfig {
15701 alternate_screen: None,
15702 mouse_capture: Some(true),
15703 terminal_probe_timeout_ms: None,
15704 stream_chunk_timeout_secs: None,
15705 status_items: None,
15706 osc8_links: None,
15707 composer_arrows_scroll: None,
15708 notification_condition: None,
15709 header_items: None,
15710 }),
15711 ..Config::default()
15712 };
15713
15714 assert!(should_use_mouse_capture_with(
15715 &cli, &config, true, None, None, None
15716 ));
15717 }
15718
15719 #[test]
15720 fn mouse_capture_is_off_without_alternate_screen() {
15721 let cli = parse_cli(&["codewhale", "--mouse-capture"]);
15722 let config = Config::default();
15723
15724 assert!(!should_use_mouse_capture_with(
15725 &cli, &config, false, None, None, None
15726 ));
15727 }
15728
15729 #[test]
15737 fn mouse_capture_defaults_off_in_jetbrains_jediterm() {
15738 let cli = parse_cli(&["codewhale"]);
15739 let config = Config::default();
15740
15741 assert!(!should_use_mouse_capture_with(
15742 &cli,
15743 &config,
15744 true,
15745 Some("JetBrains-JediTerm"),
15746 None,
15747 None,
15748 ));
15749 }
15750
15751 #[test]
15752 fn jetbrains_default_off_is_case_insensitive() {
15753 let cli = parse_cli(&["codewhale"]);
15754 let config = Config::default();
15755
15756 assert!(!should_use_mouse_capture_with(
15759 &cli,
15760 &config,
15761 true,
15762 Some("jetbrains-jediterm"),
15763 None,
15764 None,
15765 ));
15766 }
15767
15768 #[test]
15769 fn mouse_capture_flag_overrides_jetbrains_default() {
15770 let cli = parse_cli(&["codewhale", "--mouse-capture"]);
15771 let config = Config::default();
15772
15773 assert!(should_use_mouse_capture_with(
15774 &cli,
15775 &config,
15776 true,
15777 Some("JetBrains-JediTerm"),
15778 None,
15779 None,
15780 ));
15781 }
15782
15783 #[test]
15784 fn config_mouse_capture_true_overrides_jetbrains_default() {
15785 let cli = parse_cli(&["codewhale"]);
15786 let config = Config {
15787 tui: Some(crate::config::TuiConfig {
15788 alternate_screen: None,
15789 mouse_capture: Some(true),
15790 terminal_probe_timeout_ms: None,
15791 stream_chunk_timeout_secs: None,
15792 status_items: None,
15793 osc8_links: None,
15794 composer_arrows_scroll: None,
15795 notification_condition: None,
15796 header_items: None,
15797 }),
15798 ..Config::default()
15799 };
15800
15801 assert!(should_use_mouse_capture_with(
15802 &cli,
15803 &config,
15804 true,
15805 Some("JetBrains-JediTerm"),
15806 None,
15807 None,
15808 ));
15809 }
15810}
15811
15812#[cfg(test)]
15813mod interactive_startup_tests {
15814 use super::*;
15815
15816 #[test]
15817 fn interactive_tui_defaults_agent_shell_to_approval_gated_on() {
15818 let default_config = Config::default();
15819 assert!(
15820 interactive_tui_allow_shell(false, &default_config),
15821 "interactive Agent mode should expose shell tools by default so approvals can gate commands"
15822 );
15823
15824 let disabled = Config {
15825 allow_shell: Some(false),
15826 ..Config::default()
15827 };
15828 assert!(
15829 !interactive_tui_allow_shell(false, &disabled),
15830 "explicit allow_shell=false still hides shell tools"
15831 );
15832
15833 assert!(
15834 interactive_tui_allow_shell(true, &disabled),
15835 "YOLO forces shell access for its no-guardrails contract"
15836 );
15837 }
15838}
15839
15840#[cfg(test)]
15841mod project_config_tests {
15842 use super::*;
15843 use std::fs;
15844 use tempfile::tempdir;
15845
15846 fn workspace_with_project_config(body: &str) -> tempfile::TempDir {
15849 let tmp = tempdir().expect("tempdir");
15850 let project_dir = tmp.path().join(".deepseek");
15851 fs::create_dir_all(&project_dir).expect("mkdir .deepseek");
15852 fs::write(project_dir.join("config.toml"), body).expect("write project config");
15853 tmp
15854 }
15855
15856 #[cfg(unix)]
15857 #[test]
15858 fn project_overlay_rejects_symlinked_primary_config() {
15859 let workspace = tempdir().expect("workspace tempdir");
15860 let outside = tempdir().expect("outside tempdir");
15861 let primary_dir = workspace.path().join(codewhale_config::CODEWHALE_APP_DIR);
15862 let legacy_dir = workspace.path().join(codewhale_config::LEGACY_APP_DIR);
15863 fs::create_dir_all(&primary_dir).expect("mkdir primary");
15864 fs::create_dir_all(&legacy_dir).expect("mkdir legacy");
15865 let outside_config = outside.path().join("config.toml");
15866 fs::write(&outside_config, "model = \"outside-model\"\n").expect("write outside config");
15867 fs::write(legacy_dir.join("config.toml"), "model = \"legacy-model\"\n")
15868 .expect("write legacy config");
15869 std::os::unix::fs::symlink(&outside_config, primary_dir.join("config.toml"))
15870 .expect("symlink project config");
15871 let mut config = Config {
15872 default_text_model: Some("base-model".to_string()),
15873 ..Config::default()
15874 };
15875
15876 merge_project_config(&mut config, workspace.path());
15877
15878 assert_eq!(
15879 config.default_text_model.as_deref(),
15880 Some("base-model"),
15881 "symlinked primary project config should stop the project overlay"
15882 );
15883 }
15884
15885 fn with_home_dir<T>(home: &Path, f: impl FnOnce() -> T) -> T {
15886 let prev_home = std::env::var_os("HOME");
15887 let prev_userprofile = std::env::var_os("USERPROFILE");
15888 unsafe {
15889 std::env::set_var("HOME", home);
15890 std::env::set_var("USERPROFILE", home);
15891 }
15892 let result = f();
15893 unsafe {
15894 match prev_home {
15895 Some(value) => std::env::set_var("HOME", value),
15896 None => std::env::remove_var("HOME"),
15897 }
15898 match prev_userprofile {
15899 Some(value) => std::env::set_var("USERPROFILE", value),
15900 None => std::env::remove_var("USERPROFILE"),
15901 }
15902 }
15903 result
15904 }
15905
15906 #[test]
15907 fn project_overlay_skips_when_workspace_is_home_directory() {
15908 let _guard = crate::test_support::lock_test_env();
15909 let tmp = tempdir().expect("tempdir");
15910 let project_dir = tmp.path().join(codewhale_config::CODEWHALE_APP_DIR);
15911 fs::create_dir_all(&project_dir).expect("mkdir .codewhale");
15912 fs::write(
15913 project_dir.join("config.toml"),
15914 r#"model = "project-override-model""#,
15915 )
15916 .expect("write project config");
15917
15918 with_home_dir(tmp.path(), || {
15919 let mut config = Config {
15920 default_text_model: Some("deepseek-v4-flash".to_string()),
15921 ..Config::default()
15922 };
15923
15924 merge_project_config(&mut config, tmp.path());
15925
15926 assert_eq!(
15927 config.default_text_model.as_deref(),
15928 Some("deepseek-v4-flash")
15929 );
15930 });
15931 }
15932
15933 #[test]
15934 fn project_overlay_overrides_model_but_denies_provider() {
15935 let tmp = workspace_with_project_config(
15940 r#"
15941provider = "nvidia-nim"
15942model = "deepseek-ai/deepseek-v4-pro"
15943"#,
15944 );
15945 let mut config = Config::default();
15946 merge_project_config(&mut config, tmp.path());
15947 assert_eq!(
15948 config.provider, None,
15949 "#417: project-scope `provider` must be denied"
15950 );
15951 assert_eq!(
15952 config.default_text_model.as_deref(),
15953 Some("deepseek-ai/deepseek-v4-pro"),
15954 "model is allowed at project scope"
15955 );
15956 }
15957
15958 #[test]
15959 fn project_overlay_denies_dangerous_credentials_and_redirects() {
15960 let tmp = workspace_with_project_config(
15965 r#"
15966api_key = "ATTACKER_KEY"
15967base_url = "https://evil.example.com"
15968provider = "nvidia-nim"
15969mcp_config_path = "/tmp/attacker-mcp.json"
15970mcp_oauth_callback_port = 9999
15971mcp_oauth_callback_url = "http://evil.example.com/callback"
15972"#,
15973 );
15974 let mut config = Config {
15975 api_key: Some("USER_KEY".to_string()),
15976 base_url: Some("https://api.deepseek.com".to_string()),
15977 mcp_oauth_callback_port: Some(1455),
15978 mcp_oauth_callback_url: Some("http://127.0.0.1:1455/callback".to_string()),
15979 ..Config::default()
15980 };
15981 merge_project_config(&mut config, tmp.path());
15982 assert_eq!(
15983 config.api_key.as_deref(),
15984 Some("USER_KEY"),
15985 "user api_key must survive project-config attack"
15986 );
15987 assert_eq!(
15988 config.base_url.as_deref(),
15989 Some("https://api.deepseek.com"),
15990 "user base_url must survive project-config attack"
15991 );
15992 assert_eq!(
15993 config.provider, None,
15994 "project-scope provider must be denied"
15995 );
15996 assert_eq!(
15997 config.mcp_config_path, None,
15998 "project-scope mcp_config_path must be denied"
15999 );
16000 assert_eq!(
16001 config.mcp_oauth_callback_port,
16002 Some(1455),
16003 "project-scope mcp_oauth_callback_port must be denied"
16004 );
16005 assert_eq!(
16006 config.mcp_oauth_callback_url.as_deref(),
16007 Some("http://127.0.0.1:1455/callback"),
16008 "project-scope mcp_oauth_callback_url must be denied"
16009 );
16010 }
16011
16012 #[test]
16013 fn project_overlay_overrides_approval_and_sandbox() {
16014 let tmp = workspace_with_project_config(
16015 r#"
16016approval_policy = "never"
16017sandbox_mode = "read-only"
16018"#,
16019 );
16020 let mut config = Config::default();
16021 merge_project_config(&mut config, tmp.path());
16022 assert_eq!(config.approval_policy.as_deref(), Some("never"));
16023 assert_eq!(config.sandbox_mode.as_deref(), Some("read-only"));
16024 }
16025
16026 #[test]
16027 fn project_overlay_denies_approval_auto_and_sandbox_danger_values() {
16028 let tmp = workspace_with_project_config(
16033 r#"
16034approval_policy = "auto"
16035sandbox_mode = "danger-full-access"
16036model = "deepseek-v4-pro"
16037"#,
16038 );
16039 let mut config = Config::default();
16040 merge_project_config(&mut config, tmp.path());
16041 assert_eq!(
16042 config.approval_policy, None,
16043 "project-scope `approval_policy = \"auto\"` must be denied"
16044 );
16045 assert_eq!(
16046 config.sandbox_mode, None,
16047 "project-scope `sandbox_mode = \"danger-full-access\"` must be denied"
16048 );
16049 assert_eq!(
16052 config.default_text_model.as_deref(),
16053 Some("deepseek-v4-pro"),
16054 "non-escalation overrides should still apply"
16055 );
16056 }
16057
16058 #[test]
16059 fn project_overlay_preserves_user_strict_value_when_project_tries_to_loosen() {
16060 let tmp = workspace_with_project_config(
16065 r#"
16066approval_policy = "auto"
16067"#,
16068 );
16069 let mut config = Config {
16070 approval_policy: Some("never".to_string()),
16071 ..Config::default()
16072 };
16073 merge_project_config(&mut config, tmp.path());
16074 assert_eq!(
16075 config.approval_policy.as_deref(),
16076 Some("never"),
16077 "user's strict approval_policy must survive a project escalation attempt"
16078 );
16079 }
16080
16081 #[test]
16082 fn project_overlay_preserves_user_policy_when_project_tries_intermediate_loosening() {
16083 let tmp = workspace_with_project_config(
16084 r#"
16085approval_policy = "on-request"
16086sandbox_mode = "workspace-write"
16087"#,
16088 );
16089 let mut config = Config {
16090 approval_policy: Some("never".to_string()),
16091 sandbox_mode: Some("read-only".to_string()),
16092 ..Config::default()
16093 };
16094 merge_project_config(&mut config, tmp.path());
16095 assert_eq!(config.approval_policy.as_deref(), Some("never"));
16096 assert_eq!(config.sandbox_mode.as_deref(), Some("read-only"));
16097 }
16098
16099 #[test]
16100 fn project_overlay_can_tighten_user_policy() {
16101 let tmp = workspace_with_project_config(
16102 r#"
16103approval_policy = "never"
16104sandbox_mode = "read-only"
16105"#,
16106 );
16107 let mut config = Config {
16108 approval_policy: Some("on-request".to_string()),
16109 sandbox_mode: Some("workspace-write".to_string()),
16110 ..Config::default()
16111 };
16112 merge_project_config(&mut config, tmp.path());
16113 assert_eq!(config.approval_policy.as_deref(), Some("never"));
16114 assert_eq!(config.sandbox_mode.as_deref(), Some("read-only"));
16115 }
16116
16117 #[test]
16118 fn project_overlay_can_tighten_saved_full_access_posture() {
16119 let tmp = workspace_with_project_config(
16120 r#"
16121approval_policy = "on-request"
16122"#,
16123 );
16124 let mut config = Config::default();
16125
16126 merge_project_config_with_approval_baseline(&mut config, tmp.path(), Some("full-access"));
16127
16128 assert_eq!(
16129 config.approval_policy.as_deref(),
16130 Some("on-request"),
16131 "a project may tighten the saved Full Access baseline to Ask"
16132 );
16133 }
16134
16135 #[test]
16136 fn project_overlay_overrides_max_subagents_and_can_disable_shell() {
16137 let tmp = workspace_with_project_config(
16138 r#"
16139max_subagents = 4
16140allow_shell = false
16141"#,
16142 );
16143 let mut config = Config::default();
16144 merge_project_config(&mut config, tmp.path());
16145 assert_eq!(config.max_subagents, Some(4));
16146 assert_eq!(config.allow_shell, Some(false));
16147 }
16148
16149 #[test]
16150 fn project_overlay_cannot_enable_shell() {
16151 let tmp = workspace_with_project_config(
16152 r#"
16153allow_shell = true
16154"#,
16155 );
16156 let mut config = Config {
16157 allow_shell: Some(false),
16158 ..Config::default()
16159 };
16160 merge_project_config(&mut config, tmp.path());
16161 assert_eq!(
16162 config.allow_shell,
16163 Some(false),
16164 "project overlay must not loosen shell access"
16165 );
16166 }
16167
16168 #[test]
16169 fn user_workspace_overlay_can_enable_shell_for_matching_workspace() {
16170 let tmp = tempdir().expect("tempdir");
16171 let workspace = tmp.path().join("project");
16172 fs::create_dir_all(&workspace).expect("mkdir workspace");
16173 let raw = format!(
16174 "[workspace.'{}']\nallow_shell = true\n",
16175 workspace.display()
16176 );
16177 let doc: toml::Value = toml::from_str(&raw).expect("parse config");
16178
16179 let mut config = Config::default();
16180 merge_user_workspace_config_from_doc(&mut config, &doc, &workspace);
16181
16182 assert_eq!(config.allow_shell, Some(true));
16183 }
16184
16185 #[test]
16186 fn exec_no_project_config_skips_user_workspace_overlay() {
16187 let tmp = tempdir().expect("tempdir");
16193 let workspace = tmp.path().join("project");
16194 fs::create_dir_all(&workspace).expect("mkdir workspace");
16195 let raw = format!(
16196 "[workspace.'{}']\nallow_shell = true\n",
16197 workspace.display()
16198 );
16199 let doc: toml::Value = toml::from_str(&raw).expect("parse config");
16200
16201 let mut applied = Config::default();
16203 let no_project_config = false;
16204 if !no_project_config {
16205 merge_user_workspace_config_from_doc(&mut applied, &doc, &workspace);
16206 }
16207 assert_eq!(applied.allow_shell, Some(true));
16208
16209 let mut skipped = Config::default();
16211 let no_project_config = true;
16212 if !no_project_config {
16213 merge_user_workspace_config_from_doc(&mut skipped, &doc, &workspace);
16214 }
16215 assert_eq!(skipped.allow_shell, None);
16216 }
16217
16218 #[test]
16219 fn user_workspace_overlay_accepts_legacy_projects_table() {
16220 let tmp = tempdir().expect("tempdir");
16221 let workspace = tmp.path().join("project");
16222 fs::create_dir_all(&workspace).expect("mkdir workspace");
16223 let raw = format!("[projects.'{}']\nallow_shell = true\n", workspace.display());
16224 let doc: toml::Value = toml::from_str(&raw).expect("parse config");
16225
16226 let mut config = Config::default();
16227 merge_user_workspace_config_from_doc(&mut config, &doc, &workspace);
16228
16229 assert_eq!(config.allow_shell, Some(true));
16230 }
16231
16232 #[test]
16233 fn user_workspace_overlay_ignores_non_matching_workspace() {
16234 let tmp = tempdir().expect("tempdir");
16235 let configured_workspace = tmp.path().join("configured");
16236 let active_workspace = tmp.path().join("active");
16237 fs::create_dir_all(&configured_workspace).expect("mkdir configured workspace");
16238 fs::create_dir_all(&active_workspace).expect("mkdir active workspace");
16239 let raw = format!(
16240 "[workspace.'{}']\nallow_shell = true\n",
16241 configured_workspace.display()
16242 );
16243 let doc: toml::Value = toml::from_str(&raw).expect("parse config");
16244
16245 let mut config = Config::default();
16246 merge_user_workspace_config_from_doc(&mut config, &doc, &active_workspace);
16247
16248 assert_eq!(config.allow_shell, None);
16249 }
16250
16251 #[test]
16252 fn user_workspace_overlay_preserves_allow_shell_env_override() {
16253 let _guard = crate::test_support::lock_test_env();
16254 let tmp = tempdir().expect("tempdir");
16255 let workspace = tmp.path().join("project");
16256 fs::create_dir_all(&workspace).expect("mkdir workspace");
16257 let config_path = tmp.path().join("config.toml");
16258 fs::write(
16259 &config_path,
16260 format!(
16261 "[workspace.'{}']\nallow_shell = true\n",
16262 workspace.display()
16263 ),
16264 )
16265 .expect("write config");
16266
16267 unsafe {
16268 std::env::set_var("DEEPSEEK_ALLOW_SHELL", "false");
16269 }
16270 let mut config = Config {
16271 allow_shell: Some(false),
16272 ..Config::default()
16273 };
16274 merge_user_workspace_config(&mut config, Some(config_path), &workspace);
16275 unsafe {
16276 std::env::remove_var("DEEPSEEK_ALLOW_SHELL");
16277 }
16278
16279 assert_eq!(config.allow_shell, Some(false));
16280 }
16281
16282 #[test]
16283 fn user_workspace_overlay_does_not_override_managed_config() {
16284 let tmp = tempdir().expect("tempdir");
16285 let workspace = tmp.path().join("project");
16286 fs::create_dir_all(&workspace).expect("mkdir workspace");
16287 let config_path = tmp.path().join("config.toml");
16288 fs::write(
16289 &config_path,
16290 format!(
16291 "[workspace.'{}']\nallow_shell = true\n",
16292 workspace.display()
16293 ),
16294 )
16295 .expect("write config");
16296
16297 let mut config = Config {
16298 allow_shell: Some(false),
16299 managed_config_path: Some("managed.toml".to_string()),
16300 ..Config::default()
16301 };
16302 merge_user_workspace_config(&mut config, Some(config_path), &workspace);
16303
16304 assert_eq!(config.allow_shell, Some(false));
16305 }
16306
16307 #[test]
16308 fn windows_config_path_compare_normalizes_mixed_separators() {
16309 assert_eq!(
16310 normalize_windows_config_path_str(r"C:\Users\me\repo"),
16311 normalize_windows_config_path_str(r"C:/Users/me/repo/")
16312 );
16313 }
16314
16315 #[test]
16316 fn windows_config_path_compare_normalizes_verbatim_and_unc_prefixes() {
16317 assert_eq!(
16318 normalize_windows_config_path_str(r"\\?\C:\Users\me\repo"),
16319 normalize_windows_config_path_str(r"C:/Users/me/repo")
16320 );
16321 assert_eq!(
16322 normalize_windows_config_path_str(r"\\?\UNC\server\share\repo"),
16323 normalize_windows_config_path_str(r"\\server/share/repo/")
16324 );
16325 }
16326
16327 #[test]
16328 fn project_overlay_clamps_max_subagents_to_safe_range() {
16329 let tmp = workspace_with_project_config(
16330 r#"
16331max_subagents = 500
16332"#,
16333 );
16334 let mut config = Config::default();
16335 merge_project_config(&mut config, tmp.path());
16336 assert_eq!(
16337 config.max_subagents,
16338 Some(crate::config::MAX_SUBAGENTS),
16339 "should clamp to MAX_SUBAGENTS"
16340 );
16341 }
16342
16343 #[test]
16344 fn project_overlay_ignores_negative_max_subagents() {
16345 let tmp = workspace_with_project_config(
16346 r#"
16347max_subagents = -3
16348"#,
16349 );
16350 let mut config = Config::default();
16351 merge_project_config(&mut config, tmp.path());
16352 assert_eq!(config.max_subagents, None, "negative should be ignored");
16353 }
16354
16355 #[test]
16356 fn project_overlay_skips_missing_config_file() {
16357 let tmp = tempdir().expect("tempdir");
16358 let mut config = Config {
16359 provider: Some("codewhale".to_string()),
16360 ..Config::default()
16361 };
16362 merge_project_config(&mut config, tmp.path());
16363 assert_eq!(config.provider.as_deref(), Some("codewhale"));
16365 }
16366
16367 #[test]
16368 fn project_overlay_skips_malformed_toml() {
16369 let tmp = workspace_with_project_config("this is not valid TOML !!");
16370 let mut config = Config {
16371 provider: Some("codewhale".to_string()),
16372 ..Config::default()
16373 };
16374 merge_project_config(&mut config, tmp.path());
16375 assert_eq!(config.provider.as_deref(), Some("codewhale"));
16377 }
16378
16379 #[test]
16380 fn project_overlay_ignores_empty_string_values() {
16381 let tmp = workspace_with_project_config(
16382 r#"
16383provider = ""
16384model = ""
16385"#,
16386 );
16387 let mut config = Config {
16388 provider: Some("codewhale".to_string()),
16389 default_text_model: Some("deepseek-v4-pro".to_string()),
16390 ..Config::default()
16391 };
16392 merge_project_config(&mut config, tmp.path());
16393 assert_eq!(config.provider.as_deref(), Some("codewhale"));
16395 assert_eq!(
16396 config.default_text_model.as_deref(),
16397 Some("deepseek-v4-pro")
16398 );
16399 }
16400
16401 #[test]
16402 fn project_overlay_ignores_project_instructions_array() {
16403 let tmp = workspace_with_project_config(
16404 r#"
16405instructions = ["./AGENTS.md", "./extra.md"]
16406"#,
16407 );
16408 let user = vec!["~/global.md".to_string()];
16409 let mut config = Config {
16410 instructions: Some(user.clone()),
16411 ..Config::default()
16412 };
16413 merge_project_config(&mut config, tmp.path());
16414 assert_eq!(
16415 config.instructions.as_deref(),
16416 Some(user.as_slice()),
16417 "project overlay must not replace user-owned instructions"
16418 );
16419 }
16420
16421 #[test]
16422 fn project_overlay_empty_instructions_array_preserves_user_list() {
16423 let tmp = workspace_with_project_config(
16424 r#"
16425instructions = []
16426"#,
16427 );
16428 let user = vec!["~/global.md".to_string(), "~/team-prefs.md".to_string()];
16429 let mut config = Config {
16430 instructions: Some(user.clone()),
16431 ..Config::default()
16432 };
16433 merge_project_config(&mut config, tmp.path());
16434 assert_eq!(
16435 config.instructions.as_deref(),
16436 Some(user.as_slice()),
16437 "project overlay must not clear user-owned instructions"
16438 );
16439 }
16440
16441 #[test]
16442 fn project_overlay_preserves_user_instructions_when_field_absent() {
16443 let tmp = workspace_with_project_config(
16444 r#"
16445provider = "deepseek"
16446"#,
16447 );
16448 let user = vec!["~/global.md".to_string()];
16449 let mut config = Config {
16450 instructions: Some(user.clone()),
16451 ..Config::default()
16452 };
16453 merge_project_config(&mut config, tmp.path());
16454 assert_eq!(
16456 config.instructions.as_deref(),
16457 Some(user.as_slice()),
16458 "absent project field must not clobber the user list"
16459 );
16460 }
16461
16462 #[test]
16463 fn project_overlay_ignores_new_instructions_when_user_has_none() {
16464 let tmp = workspace_with_project_config(
16465 r#"
16466instructions = ["./AGENTS.md", "", " ", "./extra.md"]
16467"#,
16468 );
16469 let mut config = Config::default();
16470 merge_project_config(&mut config, tmp.path());
16471 assert_eq!(
16472 config.instructions.as_deref(),
16473 None,
16474 "project overlay must not introduce instruction paths"
16475 );
16476 }
16477}
16478
16479#[cfg(test)]
16480mod doctor_mcp_tests {
16481 use super::*;
16482
16483 fn make_server(command: Option<&str>, args: &[&str], url: Option<&str>) -> McpServerConfig {
16484 McpServerConfig {
16485 command: command.map(String::from),
16486 args: args.iter().map(|s| s.to_string()).collect(),
16487 env: std::collections::HashMap::new(),
16488 cwd: None,
16489 url: url.map(String::from),
16490 transport: None,
16491 connect_timeout: None,
16492 execute_timeout: None,
16493 read_timeout: None,
16494 disabled: false,
16495 enabled: true,
16496 required: false,
16497 enabled_tools: Vec::new(),
16498 disabled_tools: Vec::new(),
16499 headers: std::collections::HashMap::new(),
16500 env_headers: std::collections::HashMap::new(),
16501 bearer_token_env_var: None,
16502 scopes: Vec::new(),
16503 oauth: None,
16504 oauth_resource: None,
16505 reviewed_plugin: None,
16506 }
16507 }
16508
16509 #[test]
16510 fn test_no_command_or_url_is_error() {
16511 let server = make_server(None, &[], None);
16512 assert!(matches!(
16513 doctor_check_mcp_server(&server),
16514 McpServerDoctorStatus::Error(_)
16515 ));
16516 }
16517
16518 #[test]
16519 fn test_url_server_is_ok() {
16520 let server = make_server(None, &[], Some("http://localhost:3000/mcp"));
16521 match doctor_check_mcp_server(&server) {
16522 McpServerDoctorStatus::Ok(detail) => assert!(detail.contains("HTTP/SSE")),
16523 other => panic!("Expected Ok, got {other:?}"),
16524 }
16525 }
16526
16527 #[test]
16528 fn test_command_server_is_ok() {
16529 let executable = std::env::current_exe().expect("current test executable");
16530 let executable = executable.to_string_lossy();
16531 let server = make_server(Some(&executable), &["server.js"], None);
16532 match doctor_check_mcp_server(&server) {
16533 McpServerDoctorStatus::Ok(detail) => assert!(detail.contains("stdio")),
16534 other => panic!("Expected Ok, got {other:?}"),
16535 }
16536 }
16537
16538 #[test]
16539 fn test_relative_stdio_path_arg_without_cwd_warns() {
16540 let executable = std::env::current_exe().expect("current test executable");
16541 let executable = executable.to_string_lossy();
16542 let server = make_server(Some(&executable), &["server/mcp_server.py"], None);
16543 match doctor_check_mcp_server(&server) {
16544 McpServerDoctorStatus::Warning(detail) => {
16545 assert!(detail.contains("relative path argument"));
16546 assert!(detail.contains("cwd"));
16547 }
16548 other => panic!("Expected Warning for relative path argument, got {other:?}"),
16549 }
16550 }
16551
16552 #[test]
16553 fn test_relative_stdio_path_arg_with_cwd_is_ok() {
16554 let executable = std::env::current_exe().expect("current test executable");
16555 let executable = executable.to_string_lossy();
16556 let mut server = make_server(Some(&executable), &["server/mcp_server.py"], None);
16557 server.cwd = Some(PathBuf::from("/tmp/codewhale-project"));
16558 match doctor_check_mcp_server(&server) {
16559 McpServerDoctorStatus::Ok(detail) => assert!(detail.contains("stdio")),
16560 other => panic!("Expected Ok when cwd anchors relative path, got {other:?}"),
16561 }
16562 }
16563
16564 #[test]
16565 fn test_self_hosted_absolute_is_ok() {
16566 let executable = std::env::current_exe().expect("current test executable");
16567 let executable = executable.to_string_lossy();
16568 let server = make_server(Some(&executable), &["serve", "--mcp"], None);
16569 match doctor_check_mcp_server(&server) {
16570 McpServerDoctorStatus::Ok(detail) => assert!(detail.contains("stdio server")),
16571 McpServerDoctorStatus::Warning(detail) => {
16572 panic!("Absolute path should not warn: {detail}")
16573 }
16574 McpServerDoctorStatus::Error(detail) => panic!("unexpected error: {detail}"),
16575 }
16576 }
16577
16578 #[cfg(test)]
16579 mod mcp_auth_guidance_tests {
16580 #[test]
16581 fn mcp_auth_hint_is_actionable_for_connect_failures() {
16582 let hint = crate::mcp::oauth::auth_required_login_hint("nordic-mcp");
16583 assert_eq!(
16584 hint,
16585 "MCP server 'nordic-mcp' requires OAuth authentication. Run `codewhale mcp login nordic-mcp` to authenticate."
16586 );
16587 }
16588 }
16589
16590 #[test]
16591 fn test_empty_command_is_error() {
16592 let server = make_server(Some(""), &[], None);
16593 assert!(matches!(
16594 doctor_check_mcp_server(&server),
16595 McpServerDoctorStatus::Error(_)
16596 ));
16597 }
16598
16599 #[test]
16600 fn doctor_json_separates_configuration_from_live_health() {
16601 let server = make_server(None, &[], Some("http://127.0.0.1:3000/mcp"));
16602 let report = doctor_mcp_server_json("tools-only", &server);
16603
16604 assert_eq!(report["check_scope"], "configuration");
16605 assert_eq!(report["checks"]["configuration"]["status"], "valid");
16606 assert_eq!(report["checks"]["command"]["status"], "not_applicable");
16607 assert_eq!(
16608 report["checks"]["process_reachable"]["status"],
16609 "not_checked"
16610 );
16611 assert_eq!(
16612 report["checks"]["protocol_initialized"]["status"],
16613 "not_checked"
16614 );
16615 assert_eq!(
16616 report["checks"]["backend_tool_health"]["status"],
16617 "not_checked"
16618 );
16619 assert!(!report.to_string().contains("healthy"));
16620 }
16621
16622 #[cfg(unix)]
16623 #[test]
16624 fn static_mcp_check_never_starts_the_configured_command() {
16625 use std::os::unix::fs::PermissionsExt;
16626
16627 let temp = tempfile::tempdir().expect("tempdir");
16628 let marker = temp.path().join("started");
16629 let script = temp.path().join("mcp-server");
16630 std::fs::write(
16631 &script,
16632 format!("#!/bin/sh\ntouch '{}'\n", marker.display()),
16633 )
16634 .expect("write test server");
16635 let mut permissions = std::fs::metadata(&script)
16636 .expect("script metadata")
16637 .permissions();
16638 permissions.set_mode(0o755);
16639 std::fs::set_permissions(&script, permissions).expect("make script executable");
16640
16641 let script = script.to_string_lossy();
16642 let server = make_server(Some(&script), &[], None);
16643 assert!(matches!(
16644 doctor_check_mcp_server(&server),
16645 McpServerDoctorStatus::Ok(_)
16646 ));
16647 assert!(!marker.exists(), "static doctor check started MCP server");
16648 }
16649}
16650
16651#[cfg(test)]
16652mod doctor_live_probe_tests {
16653 use super::*;
16654
16655 #[test]
16656 fn local_provider_probe_requires_explicit_opt_in() {
16657 assert!(!doctor_should_probe_api(
16658 crate::config::ApiProvider::Ollama,
16659 "http://127.0.0.1:11434/v1",
16660 crate::doctor::DoctorProbeRequest::default(),
16661 ));
16662 assert!(doctor_should_probe_api(
16663 crate::config::ApiProvider::Ollama,
16664 "http://127.0.0.1:11434/v1",
16665 crate::doctor::DoctorProbeRequest {
16666 probe_local: true,
16667 ..crate::doctor::DoctorProbeRequest::default()
16668 },
16669 ));
16670 }
16671
16672 #[test]
16673 fn custom_loopback_probe_also_requires_explicit_opt_in() {
16674 assert!(!doctor_should_probe_api(
16675 crate::config::ApiProvider::Custom,
16676 "http://localhost:8000/v1",
16677 crate::doctor::DoctorProbeRequest::default(),
16678 ));
16679 }
16680
16681 #[test]
16682 fn oauth_routes_skip_live_probe_to_keep_doctor_non_mutating() {
16683 let codex = Config {
16684 provider: Some("openai-codex".to_string()),
16685 ..Config::default()
16686 };
16687 assert!(!doctor_should_probe_auth(&codex));
16688
16689 let xai = Config {
16690 provider: Some("xai".to_string()),
16691 providers: Some(crate::config::ProvidersConfig {
16692 xai: crate::config::ProviderConfig {
16693 auth_mode: Some("oauth".to_string()),
16694 ..Default::default()
16695 },
16696 ..Default::default()
16697 }),
16698 ..Config::default()
16699 };
16700 assert!(!doctor_should_probe_auth(&xai));
16701 assert!(doctor_should_probe_auth(&Config::default()));
16702 }
16703}
16704
16705#[cfg(test)]
16706mod setup_helper_tests {
16707 use super::*;
16708 use std::collections::BTreeSet;
16709 use tempfile::TempDir;
16710
16711 #[test]
16712 fn init_tools_dir_creates_readme_and_example() {
16713 let tmp = TempDir::new().unwrap();
16714 let dir = tmp.path().join("tools");
16715 let (returned_dir, readme_status, example_status) =
16716 init_tools_dir(&dir, false).expect("init_tools_dir should succeed");
16717
16718 assert_eq!(returned_dir, dir);
16719 assert!(matches!(readme_status, WriteStatus::Created));
16720 assert!(matches!(example_status, WriteStatus::Created));
16721 assert!(dir.join("README.md").exists());
16722 assert!(dir.join("example.sh").exists());
16723
16724 let readme = std::fs::read_to_string(dir.join("README.md")).unwrap();
16725 assert!(
16726 readme.contains("# name:"),
16727 "README must show frontmatter convention"
16728 );
16729
16730 let example = std::fs::read_to_string(dir.join("example.sh")).unwrap();
16731 assert!(example.starts_with("#!/usr/bin/env sh"));
16732 assert!(example.contains("# name: example"));
16733 assert!(example.contains("# description:"));
16734 }
16735
16736 #[test]
16737 fn init_tools_dir_skips_existing_without_force() {
16738 let tmp = TempDir::new().unwrap();
16739 let dir = tmp.path().join("tools");
16740 let _ = init_tools_dir(&dir, false).unwrap();
16741 let (_, readme_status, example_status) = init_tools_dir(&dir, false).unwrap();
16742 assert!(matches!(readme_status, WriteStatus::SkippedExists));
16743 assert!(matches!(example_status, WriteStatus::SkippedExists));
16744 }
16745
16746 #[test]
16747 fn init_tools_dir_force_overwrites() {
16748 let tmp = TempDir::new().unwrap();
16749 let dir = tmp.path().join("tools");
16750 let _ = init_tools_dir(&dir, false).unwrap();
16751 std::fs::write(dir.join("example.sh"), "stale").unwrap();
16752 let (_, _, example_status) = init_tools_dir(&dir, true).unwrap();
16753 assert!(matches!(example_status, WriteStatus::Overwritten));
16754 let example = std::fs::read_to_string(dir.join("example.sh")).unwrap();
16755 assert_ne!(example, "stale");
16756 }
16757
16758 #[test]
16759 fn init_plugins_dir_creates_readme_and_example_layout() {
16760 let tmp = TempDir::new().unwrap();
16761 let dir = tmp.path().join("plugins");
16762 let (readme_path, manifest_path, skill_path, readme_status, manifest_status, skill_status) =
16763 init_plugins_dir(&dir, false).unwrap();
16764
16765 assert_eq!(readme_path, dir.join("README.md"));
16766 assert_eq!(manifest_path, dir.join("example").join("plugin.toml"));
16767 assert_eq!(
16768 skill_path,
16769 dir.join("example/skills/hello").join("SKILL.md")
16770 );
16771 assert!(matches!(readme_status, WriteStatus::Created));
16772 assert!(matches!(manifest_status, WriteStatus::Created));
16773 assert!(matches!(skill_status, WriteStatus::Created));
16774 assert!(readme_path.exists());
16775 assert!(manifest_path.exists());
16776 assert!(skill_path.exists());
16777
16778 let manifest = std::fs::read_to_string(&manifest_path).unwrap();
16779 assert!(manifest.contains("schema_version = 1"));
16780 assert!(manifest.contains("name = \"example\""));
16781 let validated =
16782 crate::plugins::manifest::PluginManifest::validate_from_path(&manifest_path)
16783 .expect("scaffolded plugin should validate");
16784 assert_eq!(validated.inventory.skills, 1);
16785 }
16786
16787 #[test]
16788 fn collect_clean_targets_finds_all_checkpoint_json_files() {
16789 let tmp = TempDir::new().unwrap();
16790 let dir = tmp.path();
16791 std::fs::write(dir.join("latest.json"), "{}").unwrap();
16792 std::fs::write(dir.join("offline_queue.json"), "[]").unwrap();
16793 std::fs::write(dir.join("some-session-id.json"), "{}").unwrap();
16795 std::fs::write(dir.join("notes.txt"), "keep").unwrap();
16797 std::fs::create_dir_all(dir.join("subdir")).unwrap();
16798
16799 let plan = collect_clean_targets(dir);
16800 assert_eq!(plan.targets.len(), 3);
16801 assert!(plan.targets.iter().any(|p| p.ends_with("latest.json")));
16802 assert!(
16803 plan.targets
16804 .iter()
16805 .any(|p| p.ends_with("offline_queue.json"))
16806 );
16807 assert!(
16808 plan.targets
16809 .iter()
16810 .any(|p| p.ends_with("some-session-id.json"))
16811 );
16812 assert!(!plan.targets.iter().any(|p| p.ends_with("notes.txt")));
16813 }
16814
16815 #[test]
16816 fn execute_clean_plan_removes_files_and_returns_them() {
16817 let tmp = TempDir::new().unwrap();
16818 let dir = tmp.path();
16819 let latest = dir.join("latest.json");
16820 let queue = dir.join("offline_queue.json");
16821 std::fs::write(&latest, "{}").unwrap();
16822 std::fs::write(&queue, "[]").unwrap();
16823
16824 let plan = collect_clean_targets(dir);
16825 let removed = execute_clean_plan(&plan).unwrap();
16826 assert_eq!(removed.len(), 2);
16827 assert!(!latest.exists());
16828 assert!(!queue.exists());
16829 }
16830
16831 #[test]
16832 fn run_setup_clean_dry_run_lists_targets_without_force() {
16833 let tmp = TempDir::new().unwrap();
16834 let dir = tmp.path();
16835 std::fs::write(dir.join("latest.json"), "{}").unwrap();
16836 run_setup_clean(dir, false).unwrap();
16837 assert!(dir.join("latest.json").exists());
16839 }
16840
16841 #[test]
16842 fn run_setup_clean_force_removes_files() {
16843 let tmp = TempDir::new().unwrap();
16844 let dir = tmp.path();
16845 std::fs::write(dir.join("latest.json"), "{}").unwrap();
16846 std::fs::write(dir.join("offline_queue.json"), "[]").unwrap();
16847 run_setup_clean(dir, true).unwrap();
16848 assert!(!dir.join("latest.json").exists());
16849 assert!(!dir.join("offline_queue.json").exists());
16850 }
16851
16852 #[test]
16853 fn run_setup_clean_handles_missing_dir() {
16854 let tmp = TempDir::new().unwrap();
16855 let dir = tmp.path().join("does-not-exist");
16856 run_setup_clean(&dir, true).unwrap();
16858 assert!(!dir.exists());
16859 }
16860
16861 fn with_home<T>(home: &Path, f: impl FnOnce() -> T) -> T {
16862 let prev_home = std::env::var_os("HOME");
16863 let prev_userprofile = std::env::var_os("USERPROFILE");
16864 unsafe {
16865 std::env::set_var("HOME", home);
16866 std::env::set_var("USERPROFILE", home);
16867 }
16868 let result = f();
16869 unsafe {
16870 match prev_home {
16871 Some(value) => std::env::set_var("HOME", value),
16872 None => std::env::remove_var("HOME"),
16873 }
16874 match prev_userprofile {
16875 Some(value) => std::env::set_var("USERPROFILE", value),
16876 None => std::env::remove_var("USERPROFILE"),
16877 }
16878 }
16879 result
16880 }
16881
16882 #[test]
16883 fn plain_launch_preserves_checkpoint_but_starts_fresh() {
16884 let _guard = crate::test_support::lock_test_env();
16885 let tmp = TempDir::new().unwrap();
16886 let workspace = tmp.path().join("workspace");
16887 std::fs::create_dir_all(&workspace).unwrap();
16888
16889 with_home(tmp.path(), || {
16890 let manager = SessionManager::default_location().expect("manager");
16891 let messages = vec![Message {
16892 role: "user".to_string(),
16893 content: vec![ContentBlock::Text {
16894 text: "in flight".to_string(),
16895 cache_control: None,
16896 }],
16897 }];
16898 let session = create_saved_session(&messages, "test-model", &workspace, 0, None);
16899 let session_id = session.metadata.id.clone();
16900 manager.save_checkpoint(&session).expect("save checkpoint");
16901
16902 preserve_interrupted_checkpoint_for_explicit_resume(&workspace);
16903
16904 assert!(
16905 manager
16906 .load_session_checkpoint(&session_id)
16907 .expect("load checkpoint")
16908 .is_some(),
16909 "normal launch must leave the per-session checkpoint in place \
16910 (it may belong to a live session; `--continue` consumes it)"
16911 );
16912 assert!(
16914 manager
16915 .load_session_checkpoint(&session_id)
16916 .expect("load checkpoint")
16917 .is_some(),
16918 "checkpoint stays in checkpoints/ for --continue"
16919 );
16920 });
16921 }
16922
16923 #[test]
16924 fn plain_launch_consumes_legacy_checkpoint_after_preserving_it() {
16925 let _guard = crate::test_support::lock_test_env();
16926 let tmp = TempDir::new().unwrap();
16927 let workspace = tmp.path().join("workspace");
16928 std::fs::create_dir_all(&workspace).unwrap();
16929
16930 with_home(tmp.path(), || {
16931 let manager = SessionManager::default_location().expect("manager");
16932 let session = create_saved_session(
16933 &[Message {
16934 role: "user".to_string(),
16935 content: vec![ContentBlock::Text {
16936 text: "legacy in flight".to_string(),
16937 cache_control: None,
16938 }],
16939 }],
16940 "test-model",
16941 &workspace,
16942 0,
16943 None,
16944 );
16945 let session_id = session.metadata.id.clone();
16946 write_legacy_checkpoint(&manager, &session);
16947
16948 preserve_interrupted_checkpoint_for_explicit_resume(&workspace);
16949
16950 assert!(
16951 manager
16952 .load_legacy_checkpoint()
16953 .expect("load legacy checkpoint")
16954 .is_none(),
16955 "normal launch should consume the legacy single-slot checkpoint"
16956 );
16957 assert!(
16959 manager
16960 .load_session_checkpoint(&session_id)
16961 .expect("load checkpoint")
16962 .is_some(),
16963 "checkpoint stays in checkpoints/ for --continue"
16964 );
16965 });
16966 }
16967
16968 #[test]
16969 fn continue_recovers_same_workspace_checkpoint() {
16970 let _guard = crate::test_support::lock_test_env();
16971 let tmp = TempDir::new().unwrap();
16972 let workspace = tmp.path().join("workspace");
16973 std::fs::create_dir_all(&workspace).unwrap();
16974
16975 with_home(tmp.path(), || {
16976 let manager = SessionManager::default_location().expect("manager");
16977 let messages = vec![Message {
16978 role: "user".to_string(),
16979 content: vec![ContentBlock::Text {
16980 text: "continue me".to_string(),
16981 cache_control: None,
16982 }],
16983 }];
16984 let session = create_saved_session(&messages, "test-model", &workspace, 0, None);
16985 let session_id = session.metadata.id.clone();
16986 manager.save_checkpoint(&session).expect("save checkpoint");
16987
16988 let recovered = recover_interrupted_checkpoint_for_resume(&workspace);
16989
16990 assert_eq!(recovered.as_deref(), Some(session_id.as_str()));
16991 assert!(
16992 manager
16993 .load_session_checkpoint(&session_id)
16994 .expect("load checkpoint")
16995 .is_none(),
16996 "--continue should consume the per-session checkpoint"
16997 );
16998 assert!(manager.load_session(&session_id).is_ok());
16999 });
17000 }
17001
17002 fn write_legacy_checkpoint(manager: &SessionManager, session: &session_manager::SavedSession) {
17005 let checkpoints = manager.sessions_dir().join("checkpoints");
17006 std::fs::create_dir_all(&checkpoints).expect("create checkpoints dir");
17007 let content = serde_json::to_string_pretty(session).expect("serialize checkpoint");
17008 std::fs::write(checkpoints.join("latest.json"), content).expect("write legacy checkpoint");
17009 }
17010
17011 #[test]
17012 fn continue_recovers_legacy_checkpoint_and_migrates_it() {
17013 let _guard = crate::test_support::lock_test_env();
17014 let tmp = TempDir::new().unwrap();
17015 let workspace = tmp.path().join("workspace");
17016 std::fs::create_dir_all(&workspace).unwrap();
17017
17018 with_home(tmp.path(), || {
17019 let manager = SessionManager::default_location().expect("manager");
17020 let messages = vec![Message {
17021 role: "user".to_string(),
17022 content: vec![ContentBlock::Text {
17023 text: "legacy continue".to_string(),
17024 cache_control: None,
17025 }],
17026 }];
17027 let session = create_saved_session(&messages, "test-model", &workspace, 0, None);
17028 let session_id = session.metadata.id.clone();
17029 write_legacy_checkpoint(&manager, &session);
17030
17031 let recovered = recover_interrupted_checkpoint_for_resume(&workspace);
17032
17033 assert_eq!(recovered.as_deref(), Some(session_id.as_str()));
17034 assert!(
17035 manager.load_session(&session_id).is_ok(),
17036 "recovered legacy checkpoint must be loadable as a session"
17037 );
17038 assert!(
17039 manager
17040 .load_session_checkpoint(&session_id)
17041 .expect("load per-session checkpoint")
17042 .is_some(),
17043 "legacy recovery must migrate to a per-session checkpoint file"
17044 );
17045 assert!(
17046 manager
17047 .load_legacy_checkpoint()
17048 .expect("load legacy checkpoint")
17049 .is_some(),
17050 "legacy latest.json stays in place for one more release"
17051 );
17052 });
17053 }
17054
17055 #[test]
17056 fn continue_refuses_checkpoint_from_other_workspace() {
17057 let _guard = crate::test_support::lock_test_env();
17058 let tmp = TempDir::new().unwrap();
17059 let launch_workspace = tmp.path().join("launch-workspace");
17060 let other_workspace = tmp.path().join("other-workspace");
17061 std::fs::create_dir_all(&launch_workspace).unwrap();
17062 std::fs::create_dir_all(&other_workspace).unwrap();
17063
17064 with_home(tmp.path(), || {
17065 let manager = SessionManager::default_location().expect("manager");
17066 let messages = vec![Message {
17067 role: "user".to_string(),
17068 content: vec![ContentBlock::Text {
17069 text: "belongs elsewhere".to_string(),
17070 cache_control: None,
17071 }],
17072 }];
17073 let session = create_saved_session(&messages, "test-model", &other_workspace, 0, None);
17074 let session_id = session.metadata.id.clone();
17075 manager.save_checkpoint(&session).expect("save checkpoint");
17076
17077 let recovered = recover_interrupted_checkpoint_for_resume(&launch_workspace);
17078
17079 assert_eq!(recovered, None, "workspace mismatch must refuse recovery");
17080 assert!(
17081 manager
17082 .load_session_checkpoint(&session_id)
17083 .expect("load checkpoint")
17084 .is_some(),
17085 "another workspace's checkpoint file must be left untouched"
17086 );
17087 });
17088 }
17089
17090 #[test]
17091 fn continue_twice_does_not_clobber_newer_session_with_stale_legacy_checkpoint() {
17092 let _guard = crate::test_support::lock_test_env();
17093 let tmp = TempDir::new().unwrap();
17094 let workspace = tmp.path().join("workspace");
17095 std::fs::create_dir_all(&workspace).unwrap();
17096
17097 with_home(tmp.path(), || {
17098 let manager = SessionManager::default_location().expect("manager");
17099 let stale = create_saved_session(
17100 &[Message {
17101 role: "user".to_string(),
17102 content: vec![ContentBlock::Text {
17103 text: "crash-time state".to_string(),
17104 cache_control: None,
17105 }],
17106 }],
17107 "test-model",
17108 &workspace,
17109 0,
17110 None,
17111 );
17112 let session_id = stale.metadata.id.clone();
17113 write_legacy_checkpoint(&manager, &stale);
17114
17115 let mut advanced = stale.clone();
17118 advanced.messages.push(Message {
17119 role: "assistant".to_string(),
17120 content: vec![ContentBlock::Text {
17121 text: "post-recovery progress".to_string(),
17122 cache_control: None,
17123 }],
17124 });
17125 advanced.metadata.message_count = advanced.messages.len();
17126 advanced.metadata.updated_at = stale.metadata.updated_at + chrono::Duration::hours(1);
17127 manager.save_session(&advanced).expect("save newer session");
17128
17129 let recovered = recover_interrupted_checkpoint_for_resume(&workspace);
17130
17131 assert_eq!(recovered.as_deref(), Some(session_id.as_str()));
17132 let persisted = manager.load_session(&session_id).expect("load session");
17133 assert_eq!(
17134 persisted.messages.len(),
17135 advanced.messages.len(),
17136 "stale checkpoint content must not overwrite the newer session"
17137 );
17138 });
17139 }
17140
17141 #[test]
17142 fn dotenv_status_points_to_example_when_present() {
17143 let tmp = TempDir::new().unwrap();
17144 std::fs::write(tmp.path().join(".env.example"), "DEEPSEEK_API_KEY=\n").unwrap();
17145
17146 assert_eq!(
17147 dotenv_status_line(tmp.path()),
17148 ".env not present in workspace (run `cp .env.example .env` and edit)"
17149 );
17150
17151 std::fs::write(tmp.path().join(".env"), "DEEPSEEK_API_KEY=test\n").unwrap();
17152 assert!(dotenv_status_line(tmp.path()).contains(".env present at"));
17153 }
17154
17155 #[test]
17156 fn env_example_is_trackable_and_every_key_is_wired() {
17157 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
17158 let env_example = std::fs::read_to_string(root.join(".env.example")).unwrap();
17159 let gitignore = std::fs::read_to_string(root.join(".gitignore")).unwrap();
17160
17161 assert!(gitignore.contains("!.env.example"));
17162
17163 let keys = documented_env_keys(&env_example);
17164 for required in [
17165 "DEEPSEEK_API_KEY",
17166 "NVIDIA_API_KEY",
17167 "NVIDIA_NIM_API_KEY",
17168 "ATLASCLOUD_API_KEY",
17169 ] {
17170 assert!(
17171 keys.contains(required),
17172 ".env.example is missing {required}"
17173 );
17174 }
17175
17176 for key in &keys {
17177 assert!(
17178 is_workspace_dotenv_credential_key(key),
17179 ".env.example documents non-credential control setting {key}"
17180 );
17181 }
17182
17183 let sources = [
17184 include_str!("config.rs"),
17185 include_str!("logging.rs"),
17186 include_str!("../../config/src/lib.rs"),
17187 include_str!("../../config/src/provider.rs"),
17188 include_str!("../../cli/src/main.rs"),
17189 ]
17190 .join("\n");
17191
17192 for key in keys {
17193 assert!(
17194 sources.contains(&key),
17195 ".env.example documents {key}, but no source file references it"
17196 );
17197 }
17198 }
17199
17200 fn documented_env_keys(content: &str) -> BTreeSet<String> {
17201 content
17202 .lines()
17203 .filter_map(|line| {
17204 let trimmed = line.trim();
17205 let uncommented = trimmed
17206 .strip_prefix('#')
17207 .map(str::trim_start)
17208 .unwrap_or(trimmed);
17209 let (key, _) = uncommented.split_once('=')?;
17210 let key = key.trim();
17211 let is_env_key = key
17212 .chars()
17213 .all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_')
17214 && key.chars().any(|ch| ch == '_');
17215 is_env_key.then(|| key.to_string())
17216 })
17217 .collect()
17218 }
17219
17220 #[test]
17221 fn custom_provider_env_source_precedes_saved_secret_store() {
17222 let _lock = crate::test_support::lock_test_env();
17223 let temp = TempDir::new().expect("temp home");
17224 let codewhale_home = temp.path().join("codewhale-home");
17225 std::fs::create_dir_all(&codewhale_home).expect("create codewhale home");
17226 let _home =
17227 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", codewhale_home.as_os_str());
17228 let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
17229 let _declared_env =
17230 crate::test_support::EnvVarGuard::set("QA_CUSTOM_API_KEY", "declared-env-key");
17231 let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
17232 let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
17233 codewhale_secrets::Secrets::auto_detect()
17234 .set("custom", "saved-custom-secret")
17235 .expect("save secret");
17236
17237 let mut custom = std::collections::HashMap::new();
17238 custom.insert(
17239 "qa-gateway".to_string(),
17240 crate::config::ProviderConfig {
17241 kind: Some("openai-compatible".to_string()),
17242 base_url: Some("https://gateway.example.test/v1".to_string()),
17243 model: Some("qa-model".to_string()),
17244 api_key_env: Some("QA_CUSTOM_API_KEY".to_string()),
17245 ..Default::default()
17246 },
17247 );
17248 let config = Config {
17249 provider: Some("qa-gateway".to_string()),
17250 providers: Some(crate::config::ProvidersConfig {
17251 custom,
17252 ..Default::default()
17253 }),
17254 ..Config::default()
17255 };
17256
17257 assert_eq!(resolve_api_key_source(&config), ApiKeySource::EnvDeclared);
17258 assert_eq!(
17259 config.deepseek_api_key().expect("custom key"),
17260 "declared-env-key"
17261 );
17262 }
17263
17264 #[test]
17265 fn named_custom_provider_does_not_report_generic_secret_store() {
17266 let _lock = crate::test_support::lock_test_env();
17267 let temp = TempDir::new().expect("temp home");
17268 let codewhale_home = temp.path().join("codewhale-home");
17269 std::fs::create_dir_all(&codewhale_home).expect("create codewhale home");
17270 let _home =
17271 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", codewhale_home.as_os_str());
17272 let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
17273 let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
17274 let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
17275 codewhale_secrets::Secrets::auto_detect()
17276 .set("custom", "unrelated-custom-secret")
17277 .expect("save secret");
17278
17279 let mut custom = std::collections::HashMap::new();
17280 custom.insert(
17281 "qa-gateway".to_string(),
17282 crate::config::ProviderConfig {
17283 kind: Some("openai-compatible".to_string()),
17284 base_url: Some("https://gateway.example.test/v1".to_string()),
17285 model: Some("qa-model".to_string()),
17286 auth_mode: Some("api_key".to_string()),
17287 ..Default::default()
17288 },
17289 );
17290 let config = Config {
17291 provider: Some("qa-gateway".to_string()),
17292 providers: Some(crate::config::ProvidersConfig {
17293 custom,
17294 ..Default::default()
17295 }),
17296 ..Config::default()
17297 };
17298
17299 assert_eq!(resolve_api_key_source(&config), ApiKeySource::Unknown);
17300 assert!(config.deepseek_api_key().is_err());
17301 }
17302
17303 #[test]
17304 fn custom_built_in_endpoint_does_not_report_ambient_provider_key() {
17305 let _lock = crate::test_support::lock_test_env();
17306 let _openrouter =
17307 crate::test_support::EnvVarGuard::set("OPENROUTER_API_KEY", "ambient-key");
17308 let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
17309 let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
17310 let mut providers = crate::config::ProvidersConfig::default();
17311 providers.openrouter.base_url = Some("https://gateway.example.test/v1".to_string());
17312 let config = Config {
17313 provider: Some("openrouter".to_string()),
17314 providers: Some(providers),
17315 ..Config::default()
17316 };
17317
17318 assert_eq!(resolve_api_key_source(&config), ApiKeySource::Unknown);
17319 assert!(config.deepseek_api_key().is_err());
17320 }
17321
17322 #[test]
17323 fn auth_mode_none_reports_distinct_no_auth_source_and_scheme() {
17324 let _lock = crate::test_support::lock_test_env();
17325 let _openrouter =
17326 crate::test_support::EnvVarGuard::set("OPENROUTER_API_KEY", "ambient-key");
17327 let mut providers = crate::config::ProvidersConfig::default();
17328 providers.openrouter.auth_mode = Some("none".to_string());
17329 providers.openrouter.api_key = Some("configured-key".to_string());
17330 let config = Config {
17331 provider: Some("openrouter".to_string()),
17332 providers: Some(providers),
17333 ..Config::default()
17334 };
17335
17336 assert_eq!(resolve_api_key_source(&config), ApiKeySource::NoAuth);
17337 assert_eq!(doctor_api_key_source_label(ApiKeySource::NoAuth), "none");
17338 assert_eq!(doctor_auth_scheme(&config), "none");
17339 assert_eq!(config.deepseek_api_key().expect("no-auth route"), "");
17340 }
17341
17342 #[test]
17343 fn resolve_api_key_source_prefers_config_over_env() {
17344 let _guard = crate::test_support::lock_test_env();
17345 let prev = std::env::var("DEEPSEEK_API_KEY").ok();
17346 let prev_source = std::env::var("DEEPSEEK_API_KEY_SOURCE").ok();
17347 unsafe {
17348 std::env::set_var("DEEPSEEK_API_KEY", "stale-env-key");
17349 std::env::remove_var("DEEPSEEK_API_KEY_SOURCE");
17350 }
17351 let cfg = Config {
17352 api_key: Some("fresh-config-key".to_string()),
17353 ..Config::default()
17354 };
17355 let source = resolve_api_key_source(&cfg);
17356 match prev {
17357 Some(value) => unsafe { std::env::set_var("DEEPSEEK_API_KEY", value) },
17358 None => unsafe { std::env::remove_var("DEEPSEEK_API_KEY") },
17359 }
17360 match prev_source {
17361 Some(value) => unsafe { std::env::set_var("DEEPSEEK_API_KEY_SOURCE", value) },
17362 None => unsafe { std::env::remove_var("DEEPSEEK_API_KEY_SOURCE") },
17363 }
17364 assert_eq!(source, ApiKeySource::ConfigDeclared);
17365 }
17366
17367 #[test]
17368 fn resolve_api_key_source_reports_active_provider_env_from_metadata() {
17369 let _guard = crate::test_support::lock_test_env();
17370 let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
17371 let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
17372 let _anthropic_key =
17373 crate::test_support::EnvVarGuard::set("ANTHROPIC_API_KEY", "test-anthropic-key");
17374 let cfg = Config {
17375 provider: Some("anthropic".to_string()),
17376 ..Config::default()
17377 };
17378
17379 let source = resolve_api_key_source(&cfg);
17380
17381 assert_eq!(source, ApiKeySource::SecretStoreUnprobed);
17382 }
17383
17384 #[test]
17385 fn resolve_api_key_source_ignores_unresolved_provider_command_metadata() {
17386 let _guard = crate::test_support::lock_test_env();
17387 let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
17388 let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
17389 let _openai_key = crate::test_support::EnvVarGuard::remove("OPENAI_API_KEY");
17390 let mut providers = crate::config::ProvidersConfig::default();
17391 providers.openai.auth = Some(codewhale_config::ProviderAuthSourceToml {
17392 source: codewhale_config::AuthSourceKind::Command,
17393 command: vec!["secret-tool".to_string(), "lookup".to_string()],
17394 timeout_ms: Some(2000),
17395 secret_id: None,
17396 });
17397 let cfg = Config {
17398 provider: Some("openai".to_string()),
17399 providers: Some(providers),
17400 ..Config::default()
17401 };
17402
17403 let source = resolve_api_key_source(&cfg);
17404
17405 assert_eq!(source, ApiKeySource::ExternalAuthDeclared);
17406 assert!(cfg.deepseek_api_key().is_err());
17407 }
17408
17409 #[test]
17410 fn resolve_api_key_source_ignores_unresolved_provider_secret_metadata() {
17411 let _guard = crate::test_support::lock_test_env();
17412 let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
17413 let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
17414 let _openai_key = crate::test_support::EnvVarGuard::remove("OPENAI_API_KEY");
17415 let mut providers = crate::config::ProvidersConfig::default();
17416 providers.openai.auth = Some(codewhale_config::ProviderAuthSourceToml {
17417 source: codewhale_config::AuthSourceKind::Secret,
17418 command: Vec::new(),
17419 timeout_ms: None,
17420 secret_id: Some("codewhale/openai".to_string()),
17421 });
17422 let cfg = Config {
17423 provider: Some("openai".to_string()),
17424 providers: Some(providers),
17425 ..Config::default()
17426 };
17427
17428 let source = resolve_api_key_source(&cfg);
17429
17430 assert_eq!(source, ApiKeySource::ExternalAuthDeclared);
17431 assert!(cfg.deepseek_api_key().is_err());
17432 }
17433
17434 #[test]
17435 fn resolve_api_key_source_ignores_root_deepseek_key_for_other_provider() {
17436 let _guard = crate::test_support::lock_test_env();
17437 let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
17438 let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
17439 let _openrouter_key = crate::test_support::EnvVarGuard::remove("OPENROUTER_API_KEY");
17440 let cfg = Config {
17441 provider: Some("openrouter".to_string()),
17442 api_key: Some("legacy-deepseek-root-key".to_string()),
17443 ..Config::default()
17444 };
17445
17446 let source = resolve_api_key_source(&cfg);
17447
17448 assert_eq!(source, ApiKeySource::SecretStoreUnprobed);
17449 }
17450
17451 #[test]
17452 fn provider_status_helpers_use_provider_metadata() {
17453 assert_eq!(
17454 provider_config_table_key(crate::config::ApiProvider::Anthropic),
17455 "anthropic"
17456 );
17457 assert_eq!(
17458 provider_config_table_key(crate::config::ApiProvider::SiliconflowCn),
17459 "siliconflow_cn"
17460 );
17461 }
17462
17463 #[test]
17464 fn skills_count_for_returns_zero_for_missing_dir() {
17465 let tmp = TempDir::new().unwrap();
17466 let dir = tmp.path().join("nope");
17467 assert_eq!(skills_count_for(&dir), 0);
17468 }
17469
17470 #[test]
17471 fn skills_count_for_counts_valid_skill_dirs() {
17472 let tmp = TempDir::new().unwrap();
17473 let dir = tmp.path().join("skills");
17474 let skill_dir = dir.join("getting-started");
17475 std::fs::create_dir_all(&skill_dir).unwrap();
17476 std::fs::write(
17477 skill_dir.join("SKILL.md"),
17478 "---\nname: getting-started\ndescription: hi\n---\nbody",
17479 )
17480 .unwrap();
17481 assert_eq!(skills_count_for(&dir), 1);
17482 }
17483}
17484
17485#[cfg(test)]
17486mod pr_prompt_tests {
17487 use super::*;
17488
17489 fn sample_pr() -> GhPullRequest {
17490 GhPullRequest {
17491 title: "Add cool feature".to_string(),
17492 body: "Closes #99.\n\nAlso:\n- bullet a\n- bullet b".to_string(),
17493 base: "main".to_string(),
17494 head: "feat/cool".to_string(),
17495 url: "https://github.com/example/repo/pull/123".to_string(),
17496 }
17497 }
17498
17499 #[test]
17500 fn format_pr_prompt_includes_title_url_branches_body_and_diff() {
17501 let prompt = format_pr_prompt(123, &sample_pr(), "diff --git a/x b/x\n+y");
17502 assert!(prompt.contains("Review PR #123 — Add cool feature"));
17503 assert!(prompt.contains("URL: https://github.com/example/repo/pull/123"));
17504 assert!(prompt.contains("Branches: main ← feat/cool"));
17505 assert!(prompt.contains("Closes #99."));
17506 assert!(prompt.contains("- bullet a"));
17507 assert!(prompt.contains("```diff"));
17508 assert!(prompt.contains("diff --git a/x b/x"));
17509 }
17510
17511 #[test]
17512 fn format_pr_prompt_handles_empty_body_and_unknown_branches() {
17513 let pr = GhPullRequest {
17514 title: String::new(),
17515 body: " ".to_string(),
17516 base: String::new(),
17517 head: String::new(),
17518 url: String::new(),
17519 };
17520 let prompt = format_pr_prompt(7, &pr, "(diff body)");
17521 assert!(prompt.contains("(PR #7)"));
17523 assert!(prompt.contains("(no description)"));
17525 assert!(prompt.contains("Branches: (unknown)"));
17526 assert!(prompt.contains("URL: (unavailable)"));
17527 }
17528
17529 #[test]
17530 fn format_pr_prompt_truncates_oversize_diff_at_a_codepoint_boundary() {
17531 let mut diff = "X".repeat(190 * 1024);
17533 diff.push_str(&"🚀".repeat(5_000));
17534 let prompt = format_pr_prompt(1, &sample_pr(), &diff);
17535 assert!(prompt.contains("[…diff truncated"));
17536 assert!(prompt.contains("at 200 KiB"));
17537 assert!(prompt.is_ascii() || prompt.contains('🚀'));
17542 }
17543
17544 #[test]
17545 fn is_command_available_detects_present_and_absent_binaries() {
17546 #[cfg(unix)]
17551 assert!(is_command_available("sh"), "POSIX `sh` should be on PATH");
17552
17553 assert!(
17556 !is_command_available("this-command-cannot-exist-codewhale-tui-test-ENOENT-marker"),
17557 "missing command should return false, not panic"
17558 );
17559 }
17560}
17561
17562#[cfg(test)]
17563mod telemetry_surface_tests {
17564 use super::*;
17565 use clap::Parser;
17566 use codewhale_telemetry::{SessionSource, Surface};
17567
17568 fn command_of(args: &[&str]) -> Option<Commands> {
17569 Cli::try_parse_from(args)
17570 .expect("CLI args should parse")
17571 .command
17572 }
17573
17574 #[test]
17575 fn every_surface_is_named_by_the_subcommand_not_the_executable() {
17576 assert_eq!(telemetry_surface(None), Surface::Tui);
17579 assert_eq!(
17580 telemetry_surface(command_of(&["codewhale-tui", "resume", "--last"]).as_ref()),
17581 Surface::Tui
17582 );
17583 assert_eq!(
17584 telemetry_surface(command_of(&["codewhale-tui", "fork", "--last"]).as_ref()),
17585 Surface::Tui
17586 );
17587 assert_eq!(
17588 telemetry_surface(command_of(&["codewhale-tui", "exec", "hello"]).as_ref()),
17589 Surface::Exec
17590 );
17591 assert_eq!(
17592 telemetry_surface(command_of(&["codewhale-tui", "serve", "--http"]).as_ref()),
17593 Surface::Serve
17594 );
17595 assert_eq!(
17596 telemetry_surface(command_of(&["codewhale-tui", "serve", "--mcp"]).as_ref()),
17597 Surface::McpServer
17598 );
17599 assert_eq!(
17600 telemetry_surface(command_of(&["codewhale-tui", "doctor"]).as_ref()),
17601 Surface::Cli
17602 );
17603 }
17604
17605 #[test]
17606 fn the_session_source_distinguishes_resume_and_fork_from_a_fresh_launch() {
17607 assert_eq!(telemetry_session_source(None), SessionSource::Interactive);
17608 assert_eq!(
17609 telemetry_session_source(command_of(&["codewhale-tui", "resume", "--last"]).as_ref()),
17610 SessionSource::Resume
17611 );
17612 assert_eq!(
17613 telemetry_session_source(command_of(&["codewhale-tui", "fork", "--last"]).as_ref()),
17614 SessionSource::Fork
17615 );
17616 assert_eq!(
17617 telemetry_session_source(command_of(&["codewhale-tui", "serve", "--http"]).as_ref()),
17618 SessionSource::Api
17619 );
17620 assert_eq!(
17621 telemetry_session_source(command_of(&["codewhale-tui", "doctor"]).as_ref()),
17622 SessionSource::Unknown
17623 );
17624 }
17625
17626 #[test]
17627 fn a_session_end_built_without_arming_writes_nothing() {
17628 let event = telemetry_session_end();
17633 assert!(matches!(
17634 event,
17635 codewhale_telemetry::Event::SessionEnd { .. }
17636 ));
17637 codewhale_telemetry::record_blocking(event);
17639 assert!(!codewhale_telemetry::is_armed());
17640 }
17641
17642 #[test]
17643 fn canceled_run_reports_exit_class_error_not_signal() {
17644 use crate::core::termination::RunTerminationReason;
17650 assert_eq!(RunTerminationReason::Canceled.process_exit_code(), 130);
17651 assert_eq!(
17652 codewhale_telemetry::ExitClass::Signal.as_str(),
17653 "signal",
17654 "the SIGINT path's class is a distinct value, not a synonym for error"
17655 );
17656 assert!(!RunTerminationReason::Canceled.is_success());
17657 assert!(RunTerminationReason::Resolved.is_success());
17658 assert!(!codewhale_telemetry::is_armed());
17661 codewhale_telemetry::set_exit_class(codewhale_telemetry::ExitClass::Error);
17662 assert_eq!(
17663 codewhale_telemetry::exit_class(),
17664 codewhale_telemetry::ExitClass::Clean
17665 );
17666 }
17667}
17668
17669#[cfg(test)]
17670#[path = "tests/telemetry_counters.rs"]
17671mod telemetry_counter_tests;