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 agy_credentials;
25mod approval_log;
26mod artifacts;
27mod audit;
28mod auto_reasoning;
29mod automation_manager;
30mod child_env;
31mod client;
32mod codex_model_cache;
33mod command_safety;
34mod commands;
35mod compaction;
36mod composer_history;
37mod composer_stash;
38mod config;
39mod config_persistence;
40mod config_ui;
41mod context_budget;
42mod context_report;
43mod continual_harness;
44mod core;
45mod cost_status;
46mod deepseek_theme;
47mod dependencies;
48mod doctor;
49mod dsh_credentials;
50mod elapsed;
51mod error_taxonomy;
52mod eval;
53mod execpolicy;
54mod external_credentials;
55mod fast_hash;
56mod features;
57mod fleet;
58mod goal_loop;
59mod hashing;
60mod hooks;
61mod image_attach;
62mod integrations;
63mod lane_control;
64mod llm_client;
65mod llm_response_cache;
66mod localization;
67mod logging;
68mod lsp;
69mod mcp;
70mod mcp_server;
71mod model_catalog;
72mod model_context;
73mod model_inventory;
74mod model_profile;
75mod model_registry;
76mod model_routing;
77mod models;
78mod models_dev_live;
79mod native_memory;
80mod network_policy;
81mod oauth;
82mod palette;
83mod plugins;
84mod prefix_cache;
85mod pricing;
86mod project_context;
87mod project_context_cache;
88mod prompt_zones;
89mod prompts;
90mod provider_lake;
91mod provider_readiness;
92mod purge;
93mod regex_cache;
94mod remote_control;
95mod remote_setup;
96pub mod repl;
97mod repo_law;
98mod request_manifest;
99mod request_tuning;
100mod resource_telemetry;
101mod retry_status;
102pub mod rlm;
103mod route_billing;
104mod route_budget;
105mod route_receipt;
106mod route_runtime;
107mod runtime_api;
108mod runtime_handoff;
109mod runtime_log;
110mod runtime_policy;
111mod runtime_threads;
112mod safe_label;
113mod sandbox;
114mod scorecard;
115#[allow(dead_code)]
116mod session_diagnostics;
117#[cfg(test)]
121#[path = "main/tests.rs"]
122mod doctor_loader_tests;
123#[cfg(test)]
124mod session_control_acceptance;
125#[allow(dead_code)]
126mod session_manager;
127mod session_peek;
128mod session_projection;
129mod session_resume;
130pub mod session_tree;
131mod settings;
132mod shell_dispatcher;
133mod skill_state;
134mod skills;
135mod snapshot;
136mod startup_trace;
137mod task_manager;
138mod telemetry_notice;
139#[cfg(test)]
140mod test_support;
141use codewhale_release::tls;
144mod todo_snapshot;
145mod tool_history_repair;
146mod tool_inspection;
147mod tool_output_receipts;
148mod tools;
149mod tui;
150mod turn_route_plan;
151mod utils;
152mod vision;
153mod work_graph;
154mod worker_profile;
155mod working_set;
156mod workspace_discovery;
157mod workspace_trust;
158mod xai_oauth;
159
160use crate::config::{Config, DEFAULT_TEXT_MODEL, MAX_SUBAGENTS, effective_home_dir};
161use crate::eval::{EvalHarness, EvalHarnessConfig, ScenarioStepKind};
162use crate::features::{Feature, render_feature_table};
163use crate::llm_client::LlmClient;
164use crate::mcp::{
165 McpCommandAvailability, McpConfig, McpPool, McpServerConfig, McpServerOAuthConfig,
166 is_relative_stdio_path_arg,
167};
168use crate::models::{ContentBlock, Message, MessageRequest, SystemPrompt};
169use crate::session_manager::{SessionManager, create_saved_session, truncate_id};
170use crate::tui::history::{summarize_tool_args, summarize_tool_output};
171
172#[cfg(windows)]
173fn configure_windows_console_utf8() {
174 use windows::Win32::System::Console::{SetConsoleCP, SetConsoleOutputCP};
175
176 const CP_UTF8: u32 = 65001;
177 unsafe {
178 let _ = SetConsoleCP(CP_UTF8);
179 let _ = SetConsoleOutputCP(CP_UTF8);
180 }
181}
182
183#[cfg(not(windows))]
184fn configure_windows_console_utf8() {}
185
186fn install_rustls_crypto_provider() {
187 crate::tls::ensure_rustls_crypto_provider();
188}
189
190#[derive(Parser, Debug)]
191#[command(
192 name = "codewhale-tui",
193 bin_name = "codewhale-tui",
194 author,
195 version = env!("CODEWHALE_BUILD_VERSION"),
196 about = "Codewhale terminal coding agent",
197 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."
198)]
199struct Cli {
200 #[command(subcommand)]
202 command: Option<Commands>,
203
204 #[command(flatten)]
205 feature_toggles: FeatureToggles,
206
207 #[arg(short, long, value_name = "PROMPT", num_args = 1..)]
209 prompt: Vec<String>,
210
211 #[arg(long, hide = true)]
213 yolo: bool,
214
215 #[arg(long)]
217 max_subagents: Option<usize>,
218
219 #[arg(long)]
221 config: Option<PathBuf>,
222
223 #[arg(short, long)]
225 verbose: bool,
226
227 #[arg(long)]
229 profile: Option<String>,
230
231 #[arg(short, long)]
233 workspace: Option<PathBuf>,
234
235 #[arg(short, long)]
237 resume: Option<String>,
238
239 #[arg(short = 'c', long = "continue")]
241 continue_session: bool,
242
243 #[arg(long = "mouse-capture", conflicts_with = "no_mouse_capture")]
247 mouse_capture: bool,
248
249 #[arg(long = "no-mouse-capture", conflicts_with = "mouse_capture")]
251 no_mouse_capture: bool,
252
253 #[arg(long)]
255 skip_onboarding: bool,
256
257 #[arg(long, hide = true)]
259 remote_control: bool,
260
261 #[arg(long = "fresh")]
263 fresh: bool,
264
265 #[arg(long = "no-project-config")]
267 no_project_config: bool,
268}
269
270#[derive(Subcommand, Debug, Clone)]
271#[allow(clippy::large_enum_variant)]
272enum Commands {
273 Doctor(DoctorArgs),
275 SessionDiagnostics(SessionDiagnosticsArgs),
277 Setup(SetupArgs),
279 RemoteSetup(remote_setup::RemoteSetupArgs),
281 Completions {
283 #[arg(value_enum)]
285 shell: Shell,
286 },
287 Sessions {
289 #[arg(short, long, default_value = "20")]
291 limit: usize,
292 #[arg(short, long)]
294 search: Option<String>,
295 },
296 Init,
298 Login {
300 #[arg(long)]
302 api_key: Option<String>,
303 },
304 Logout,
306 Auth(TuiAuthArgs),
308 Models(ModelsArgs),
310 #[command(visible_alias = "tts")]
312 Speech(SpeechArgs),
313 Exec(ExecArgs),
315 Fleet(FleetArgs),
317 #[command(name = "workflow-tool", hide = true)]
319 WorkflowTool(WorkflowToolArgs),
320 Review(ReviewArgs),
322 Pr {
324 #[arg(value_name = "NUMBER")]
326 number: u32,
327 #[arg(short = 'R', long)]
330 repo: Option<String>,
331 #[arg(long, default_value_t = false)]
335 checkout: bool,
336 },
337 Apply(ApplyArgs),
339 Eval(EvalArgs),
341 Scorecard(ScorecardArgs),
343 Mcp {
345 #[command(subcommand)]
346 command: McpCommand,
347 },
348 Features(FeaturesCli),
350 Integrations {
352 #[command(subcommand)]
353 command: IntegrationsCommand,
354 },
355 Sandbox(SandboxArgs),
357 Serve(ServeArgs),
359 Resume {
361 #[arg(value_name = "SESSION_ID")]
363 session_id: Option<String>,
364 #[arg(long = "last", default_value_t = false, conflicts_with = "session_id")]
366 last: bool,
367 },
368 Fork {
370 #[arg(value_name = "SESSION_ID")]
372 session_id: Option<String>,
373 #[arg(long = "last", default_value_t = false, conflicts_with = "session_id")]
375 last: bool,
376 },
377}
378
379#[derive(Args, Debug, Clone)]
380#[command(after_help = "\
381Examples:
382 codewhale exec \"explain this function\"
383 codewhale exec --auto \"list crates/ with ls\"
384 codewhale exec --auto --output-format stream-json \"fix the failing test\"
385
386Plain `codewhale exec` is a one-shot model response. Use `--auto` for
387non-interactive agent-with-tools execution. `--auto` does not change the
388sandbox posture or elevate a denied tool. Use `--sandbox danger-full-access`
389or `--allow-sandbox-elevation` to explicitly authorize sandbox elevation.
390")]
391struct ExecArgs {
392 #[arg(long)]
394 model: Option<String>,
395 #[arg(long)]
401 provider: Option<String>,
402 #[arg(long = "reasoning-effort", value_name = "EFFORT")]
405 reasoning_effort: Option<String>,
406 #[arg(long, default_value_t = false)]
409 auto: bool,
410 #[arg(long, value_name = "POLICY")]
412 sandbox: Option<String>,
413 #[arg(long, default_value_t = false)]
415 allow_sandbox_elevation: bool,
416 #[arg(long, default_value_t = false, conflicts_with = "output_format")]
418 json: bool,
419 #[arg(long, value_name = "SESSION_ID", conflicts_with_all = ["session_id", "continue_session"])]
421 resume: Option<String>,
422 #[arg(long = "session-id", value_name = "SESSION_ID", conflicts_with_all = ["resume", "continue_session"])]
424 session_id: Option<String>,
425 #[arg(long = "continue", default_value_t = false, conflicts_with_all = ["resume", "session_id"])]
427 continue_session: bool,
428 #[arg(long, value_enum, default_value_t = ExecOutputFormat::Text)]
430 output_format: ExecOutputFormat,
431 #[arg(long, value_delimiter = ',')]
434 allowed_tools: Option<Vec<String>>,
435 #[arg(long, value_delimiter = ',')]
437 disallowed_tools: Option<Vec<String>>,
438 #[arg(long, value_parser = clap::value_parser!(u32).range(1..))]
440 max_turns: Option<u32>,
441 #[arg(long)]
443 append_system_prompt: Option<String>,
444 #[arg(long, value_name = "JSON", hide = true)]
446 tool_authority_json: Option<String>,
447 #[arg(
449 value_name = "PROMPT",
450 required = true,
451 trailing_var_arg = true,
452 allow_hyphen_values = true
453 )]
454 prompt: Vec<String>,
455}
456
457#[derive(Args, Debug, Clone)]
458struct WorkflowToolArgs {
459 #[arg(long, value_name = "SOURCE")]
461 approval_source: String,
462 #[arg(long, value_name = "JSON")]
464 input_json: String,
465}
466
467#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
468enum ExecOutputFormat {
469 Text,
470 #[value(name = "stream-json")]
471 StreamJson,
472}
473
474#[derive(Args, Debug, Clone)]
475struct TuiAuthArgs {
476 #[command(subcommand)]
477 command: TuiAuthCommand,
478}
479
480#[derive(Subcommand, Debug, Clone)]
481enum TuiAuthCommand {
482 #[command(name = "xai-device")]
484 XaiDevice,
485}
486
487const CODEWHALE_TOOL_SURFACE_ENV: &str = "CODEWHALE_TOOL_SURFACE";
488const SHELL_ONLY_EXEC_TOOLS: &[&str] = &["bash"];
489
490#[derive(Debug, Clone, Copy, PartialEq, Eq)]
491enum ExecToolSurface {
492 ShellOnly,
493}
494
495fn exec_tool_surface_from_env() -> Option<ExecToolSurface> {
496 std::env::var(CODEWHALE_TOOL_SURFACE_ENV)
497 .ok()
498 .and_then(|value| {
499 if should_warn_unknown_exec_tool_surface(&value) {
500 eprintln!(
501 "warning: unrecognized {CODEWHALE_TOOL_SURFACE_ENV}; leaving exec tool surface unchanged. Use `shell-only`, `full`, or `native-tools`."
502 );
503 }
504 parse_exec_tool_surface(&value)
505 })
506}
507
508fn parse_exec_tool_surface(value: &str) -> Option<ExecToolSurface> {
509 match value.trim().to_ascii_lowercase().as_str() {
510 "shell-only" | "shell_only" | "shell" => Some(ExecToolSurface::ShellOnly),
511 "full" | "native-tools" | "native_tools" | "" => None,
512 _ => None,
513 }
514}
515
516fn should_warn_unknown_exec_tool_surface(value: &str) -> bool {
517 let normalized = value.trim().to_ascii_lowercase();
518 !matches!(
519 normalized.as_str(),
520 "" | "shell-only" | "shell_only" | "shell" | "full" | "native-tools" | "native_tools"
521 )
522}
523
524fn normalize_exec_tool_names(tools: &[String]) -> Vec<String> {
525 tools
526 .iter()
527 .map(|name| name.to_ascii_lowercase().trim().to_string())
528 .collect()
529}
530
531fn shell_only_exec_allowed_tools() -> Vec<String> {
532 SHELL_ONLY_EXEC_TOOLS
533 .iter()
534 .map(|name| (*name).to_string())
535 .collect()
536}
537
538fn resolve_exec_allowed_tools(
539 cli_allowed_tools: Option<&[String]>,
540 env_tool_surface: Option<ExecToolSurface>,
541) -> Option<Vec<String>> {
542 if let Some(tools) = cli_allowed_tools {
543 return Some(normalize_exec_tool_names(tools));
544 }
545
546 env_tool_surface.map(|ExecToolSurface::ShellOnly| shell_only_exec_allowed_tools())
547}
548
549#[derive(Args, Debug, Clone)]
550struct FleetArgs {
551 #[command(subcommand)]
552 command: FleetCommand,
553}
554
555#[derive(Subcommand, Debug, Clone)]
556enum FleetCommand {
557 Init,
559 Run(FleetRunArgs),
561 List,
563 Status,
565 Inspect {
567 worker_id: String,
569 },
570 Logs {
572 worker_id: String,
574 },
575 Artifacts {
577 worker_id: String,
579 },
580 Interrupt {
582 worker_id: String,
584 },
585 Restart {
587 worker_id: String,
589 },
590 Resume {
592 run_id: String,
594 #[arg(long, default_value_t = 300)]
596 stale_after_seconds: u64,
597 },
598 Stop {
600 #[arg(long, required = true)]
602 all: bool,
603 },
604 AlertDryRun(FleetAlertDryRunArgs),
606}
607
608#[derive(Args, Debug, Clone)]
609struct FleetRunArgs {
610 #[arg(value_name = "TASK_SPEC")]
612 task_spec: PathBuf,
613 #[arg(long, default_value_t = 4)]
615 max_workers: usize,
616 #[arg(long, default_value_t = 300)]
618 stale_after_seconds: u64,
619 #[arg(long, hide = true, default_value_t = false)]
621 once: bool,
622}
623
624#[derive(Args, Debug, Clone)]
625struct FleetAlertDryRunArgs {
626 #[arg(long, value_enum)]
628 event: FleetAlertEventArg,
629 #[arg(long)]
631 run_id: String,
632 #[arg(long)]
634 worker_id: Option<String>,
635 #[arg(long)]
637 task_id: Option<String>,
638 #[arg(long, default_value = "manual fleet alert dry-run")]
640 reason: String,
641 #[arg(long)]
643 status: Option<String>,
644 #[arg(long, value_enum, default_value_t = FleetAlertAdapterArg::Slack)]
646 adapter: FleetAlertAdapterArg,
647 #[arg(long, default_value = "CODEWHALE_FLEET_SLACK_WEBHOOK")]
649 slack_webhook_env: String,
650 #[arg(long, default_value = "CODEWHALE_FLEET_WEBHOOK_URL")]
652 webhook_url_env: String,
653 #[arg(long)]
655 webhook_secret_env: Option<String>,
656 #[arg(long, default_value = "CODEWHALE_FLEET_PAGERDUTY_ROUTING_KEY")]
658 pagerduty_routing_key_env: String,
659 #[arg(long, default_value = "error")]
661 pagerduty_severity: String,
662}
663
664#[derive(ValueEnum, Debug, Clone, Copy)]
665enum FleetAlertEventArg {
666 Stale,
667 RestartExhausted,
668 NeedsHuman,
669 BudgetExceeded,
670 VerifierFailed,
671 RunCompleted,
672}
673
674#[derive(ValueEnum, Debug, Clone, Copy)]
675enum FleetAlertAdapterArg {
676 Slack,
677 Webhook,
678 PagerDuty,
679}
680
681fn spawn_signal_cleanup_task() {
698 let signals = TerminatingSignals::register();
699 tokio::spawn(async move {
700 let exit_code = signals.wait().await;
701 static CLEANED_UP: std::sync::atomic::AtomicBool =
705 std::sync::atomic::AtomicBool::new(false);
706 if !CLEANED_UP.swap(true, std::sync::atomic::Ordering::SeqCst) {
707 #[cfg(unix)]
708 crate::tools::shell::abort_pending_persistent_process_groups_for_exit();
709 crate::tui::ui::emergency_restore_terminal();
710 record_signal_session_end();
721 }
722 std::process::exit(exit_code);
723 });
724}
725
726static TELEMETRY_SESSION_START: std::sync::OnceLock<std::time::Instant> =
729 std::sync::OnceLock::new();
730
731fn telemetry_session_end() -> codewhale_telemetry::Event {
742 let counters = codewhale_telemetry::session_counters();
743 codewhale_telemetry::Event::SessionEnd {
744 duration_bucket: codewhale_telemetry::DurationBucket::from_secs(
745 TELEMETRY_SESSION_START
746 .get()
747 .map_or(0, |start| start.elapsed().as_secs()),
748 ),
749 exit_class: codewhale_telemetry::exit_class(),
750 cold_start_bucket: crate::startup_trace::cold_start_ms()
751 .map(codewhale_telemetry::ColdStartBucket::from_millis),
752 providers: counters.providers(),
753 counters: counters.counters(),
754 errors: counters.errors(),
755 turn_wall: counters.turn_wall(),
756 }
757}
758
759fn record_signal_session_end() {
763 codewhale_telemetry::set_exit_class(codewhale_telemetry::ExitClass::Signal);
764 codewhale_telemetry::record_blocking(telemetry_session_end());
765}
766
767#[cfg(unix)]
772struct TerminatingSignals {
773 sigint: Option<tokio::signal::unix::Signal>,
774 sigterm: Option<tokio::signal::unix::Signal>,
775 sighup: Option<tokio::signal::unix::Signal>,
776}
777
778#[cfg(unix)]
779impl TerminatingSignals {
780 fn register() -> Self {
783 use tokio::signal::unix::{SignalKind, signal};
784 Self {
785 sigint: signal(SignalKind::interrupt()).ok(),
786 sigterm: signal(SignalKind::terminate()).ok(),
787 sighup: signal(SignalKind::hangup()).ok(),
788 }
789 }
790
791 async fn wait(mut self) -> i32 {
795 tokio::select! {
796 _ = async { match self.sigint.as_mut() { Some(s) => { s.recv().await; }, None => std::future::pending::<()>().await, } } => 130,
797 _ = async { match self.sigterm.as_mut() { Some(s) => { s.recv().await; }, None => std::future::pending::<()>().await, } } => 143,
798 _ = async { match self.sighup.as_mut() { Some(s) => { s.recv().await; }, None => std::future::pending::<()>().await, } } => 129,
799 }
800 }
801}
802
803#[cfg(not(unix))]
807struct TerminatingSignals {
808 ctrl_c: Option<tokio::signal::windows::CtrlC>,
809}
810
811#[cfg(not(unix))]
812impl TerminatingSignals {
813 fn register() -> Self {
814 Self {
815 ctrl_c: tokio::signal::windows::ctrl_c().ok(),
816 }
817 }
818
819 async fn wait(mut self) -> i32 {
820 match self.ctrl_c.as_mut() {
821 Some(s) => {
822 s.recv().await;
823 }
824 None => std::future::pending::<()>().await,
825 }
826 130
827 }
828}
829
830fn join_prompt_parts(parts: &[String]) -> String {
831 parts.join(" ")
832}
833
834fn resolve_exec_model(config: &Config, explicit_model: Option<&str>) -> String {
835 explicit_model
836 .map(str::trim)
837 .filter(|model| !model.is_empty())
838 .map(ToOwned::to_owned)
839 .or_else(exec_model_env_override)
840 .unwrap_or_else(|| config.default_model())
841}
842
843fn apply_exec_provider_override(config: &mut Config, provider_arg: &str) -> Result<()> {
844 let provider_arg = provider_arg.trim();
845 if provider_arg.is_empty() {
846 return Ok(());
847 }
848 if config
849 .providers
850 .as_ref()
851 .and_then(|providers| providers.custom_provider_config(provider_arg))
852 .is_some()
853 {
854 config.provider = Some(provider_arg.to_string());
855 return Ok(());
856 }
857 if let Some(provider) = crate::config::ApiProvider::parse(provider_arg) {
858 config.provider = Some(provider.as_str().to_string());
859 return Ok(());
860 }
861 bail!(
862 "Unrecognized --provider {provider_arg:?}. Known providers: {} \
863 or a configured [providers.<name>] custom provider",
864 crate::config::ApiProvider::names_hint()
865 );
866}
867
868fn exec_model_env_override() -> Option<String> {
869 let read = || {
870 ["CODEWHALE_MODEL", "DEEPSEEK_MODEL"]
871 .into_iter()
872 .find_map(|key| {
873 std::env::var(key)
874 .ok()
875 .map(|model| model.trim().to_string())
876 .filter(|model| !model.is_empty())
877 })
878 };
879 #[cfg(test)]
880 {
881 crate::test_support::with_test_env_lock(read)
882 }
883 #[cfg(not(test))]
884 {
885 read()
886 }
887}
888
889fn top_level_prompt_initial_input(parts: &[String]) -> Option<tui::InitialInput> {
890 (!parts.is_empty()).then(|| tui::InitialInput::Submit(join_prompt_parts(parts)))
891}
892
893fn resolve_exec_resume_session_id(args: &ExecArgs, workspace: &Path) -> Result<Option<String>> {
894 if let Some(id) = args.resume.as_ref().or(args.session_id.as_ref()) {
895 return Ok(Some(id.clone()));
896 }
897 if !args.continue_session {
898 return Ok(None);
899 }
900 latest_session_id_for_workspace(workspace)?.map_or_else(
901 || {
902 bail!(
903 "No saved sessions found for workspace {}. Use `codewhale sessions` to list sessions, or pass `codewhale exec --resume <SESSION_ID> ...`.",
904 workspace.display()
905 )
906 },
907 |id| Ok(Some(id)),
908 )
909}
910
911fn load_exec_resume_session(session_id: &str) -> Result<session_manager::SavedSession> {
912 let session_ref = exec_stream_session_ref(session_id);
913 SessionManager::default_location()
914 .context("could not open session manager for resume")?
915 .load_session_by_prefix(session_id)
916 .with_context(|| format!("could not load session {session_ref}"))
917}
918
919fn resolve_exec_resume_route(
927 config: &mut Config,
928 saved: &session_manager::SavedSession,
929 explicit_provider: bool,
930 explicit_model: Option<&str>,
931) -> Result<String> {
932 if !explicit_provider {
933 let saved_provider_identity = saved
934 .metadata
935 .model_provider_id
936 .as_deref()
937 .filter(|identity| !identity.trim().is_empty())
938 .unwrap_or(&saved.metadata.model_provider);
939 let identity = config
940 .resolve_persisted_provider_identity(
941 Some(&saved.metadata.model_provider),
942 saved.metadata.model_provider_id.as_deref(),
943 )
944 .map_err(anyhow::Error::msg)
945 .with_context(|| {
946 format!(
947 "saved session provider '{}' is unavailable; Codewhale will not fall back",
948 saved_provider_identity
949 )
950 })?;
951 config.scope_to_provider_identity(&identity);
952 }
953
954 if let Some(model) = explicit_model {
955 return Ok(resolve_exec_model(config, Some(model)));
956 }
957 if explicit_provider {
958 return Ok(resolve_exec_model(config, None));
959 }
960 Ok(saved.metadata.model.clone())
961}
962
963#[derive(Args, Debug, Clone, Default)]
964struct SetupArgs {
965 #[arg(long, default_value_t = false)]
967 mcp: bool,
968 #[arg(long, default_value_t = false)]
970 skills: bool,
971 #[arg(long, default_value_t = false)]
973 tools: bool,
974 #[arg(long, default_value_t = false)]
976 plugins: bool,
977 #[arg(long, default_value_t = false)]
979 all: bool,
980 #[arg(long, default_value_t = false)]
982 local: bool,
983 #[arg(long, default_value_t = false)]
985 force: bool,
986 #[arg(long, default_value_t = false, conflicts_with_all = ["mcp", "skills", "tools", "plugins", "all", "local", "clean"])]
988 status: bool,
989 #[arg(long, default_value_t = false, conflicts_with_all = ["mcp", "skills", "tools", "plugins", "all", "local", "status"])]
991 clean: bool,
992}
993
994#[derive(Args, Debug, Clone, Default)]
995struct DoctorArgs {
996 #[arg(long, default_value_t = false)]
998 json: bool,
999 #[arg(long, default_value_t = false, conflicts_with = "json")]
1001 context_json: bool,
1002 #[arg(
1004 long,
1005 default_value_t = false,
1006 conflicts_with_all = ["json", "context_json"]
1007 )]
1008 probe_local: bool,
1009 #[arg(
1011 long,
1012 default_value_t = false,
1013 conflicts_with_all = ["json", "context_json"]
1014 )]
1015 probe_api: bool,
1016 #[arg(
1018 long,
1019 default_value_t = false,
1020 conflicts_with_all = ["json", "context_json"]
1021 )]
1022 check_updates: bool,
1023 #[arg(
1025 long,
1026 default_value_t = false,
1027 conflicts_with_all = ["json", "context_json"]
1028 )]
1029 probe_mcp: bool,
1030 #[arg(
1032 long,
1033 default_value_t = false,
1034 conflicts_with_all = ["json", "context_json"]
1035 )]
1036 probe_search: bool,
1037}
1038
1039#[derive(Args, Debug, Clone)]
1040struct SessionDiagnosticsArgs {
1041 #[arg(value_name = "JSONL")]
1043 path: PathBuf,
1044 #[arg(long, default_value_t = false)]
1046 json: bool,
1047}
1048
1049#[derive(Args, Debug, Clone)]
1050struct ScorecardArgs {
1051 #[arg(long, value_name = "FILE")]
1059 input: PathBuf,
1060 #[arg(long, value_name = "FILE")]
1063 baseline: Option<PathBuf>,
1064 #[arg(long, default_value_t = 5.0)]
1066 threshold: f64,
1067 #[arg(long, default_value_t = false)]
1069 json: bool,
1070}
1071
1072#[derive(Args, Debug, Clone)]
1073struct EvalArgs {
1074 #[arg(long, value_name = "STEP")]
1076 fail_step: Option<String>,
1077 #[arg(long, default_value = "printf eval-harness")]
1079 shell_command: String,
1080 #[arg(long, default_value = "eval-harness")]
1082 shell_expect_token: String,
1083 #[arg(long, default_value_t = 240)]
1085 max_output_chars: usize,
1086 #[arg(long, default_value_t = false)]
1088 json: bool,
1089 #[arg(long, value_name = "DIR")]
1092 record: Option<PathBuf>,
1093}
1094
1095#[derive(Args, Debug, Clone, Default)]
1096struct ModelsArgs {
1097 #[arg(long, default_value_t = false)]
1099 json: bool,
1100}
1101
1102#[derive(Args, Debug, Clone)]
1103struct SpeechArgs {
1104 #[arg(value_name = "TEXT")]
1106 text: String,
1107
1108 #[arg(short, long, value_name = "FILE")]
1111 output: Option<PathBuf>,
1112
1113 #[arg(long = "output-dir", value_name = "DIR")]
1115 output_dir: Option<PathBuf>,
1116
1117 #[arg(long)]
1119 model: Option<String>,
1120
1121 #[arg(long)]
1123 voice: Option<String>,
1124
1125 #[arg(long)]
1127 instruction: Option<String>,
1128
1129 #[arg(long = "voice-prompt")]
1131 voice_prompt: Option<String>,
1132
1133 #[arg(long = "clone-voice", value_name = "FILE")]
1135 clone_voice: Option<PathBuf>,
1136
1137 #[arg(long, default_value = "wav")]
1139 format: String,
1140
1141 #[arg(long, default_value_t = false)]
1143 json: bool,
1144}
1145
1146#[derive(Args, Debug, Default, Clone)]
1147struct FeatureToggles {
1148 #[arg(long = "enable", value_name = "FEATURE", action = clap::ArgAction::Append, global = true)]
1150 enable: Vec<String>,
1151
1152 #[arg(long = "disable", value_name = "FEATURE", action = clap::ArgAction::Append, global = true)]
1154 disable: Vec<String>,
1155}
1156
1157impl FeatureToggles {
1158 fn apply(&self, config: &mut Config) -> Result<()> {
1159 for feature in &self.enable {
1160 config.set_feature(feature, true)?;
1161 }
1162 for feature in &self.disable {
1163 config.set_feature(feature, false)?;
1164 }
1165 Ok(())
1166 }
1167}
1168
1169#[derive(Args, Debug, Clone)]
1170struct ReviewArgs {
1171 #[arg(long, conflicts_with = "base")]
1173 staged: bool,
1174 #[arg(long)]
1176 base: Option<String>,
1177 #[arg(long)]
1179 path: Option<PathBuf>,
1180 #[arg(long)]
1182 model: Option<String>,
1183 #[arg(long, default_value_t = 200_000)]
1185 max_chars: usize,
1186 #[arg(long, default_value_t = false)]
1188 write_receipt: bool,
1189 #[arg(long, default_value_t = false)]
1191 check_receipt: bool,
1192 #[arg(long)]
1194 receipt_path: Option<PathBuf>,
1195 #[arg(long, default_value_t = false)]
1197 json: bool,
1198}
1199
1200#[derive(Args, Debug, Clone)]
1201struct ApplyArgs {
1202 #[arg(value_name = "PATCH_FILE")]
1204 patch_file: Option<PathBuf>,
1205}
1206
1207#[derive(Args, Debug, Clone)]
1208struct ServeArgs {
1209 #[arg(long)]
1211 mcp: bool,
1212 #[arg(long)]
1214 http: bool,
1215 #[arg(long)]
1217 mobile: bool,
1218 #[arg(long)]
1220 web: bool,
1221 #[arg(long, requires = "mobile")]
1223 qr: bool,
1224 #[arg(long)]
1226 acp: bool,
1227 #[arg(long)]
1229 host: Option<String>,
1230 #[arg(long, default_value_t = 7878)]
1232 port: u16,
1233 #[arg(long, default_value_t = 2)]
1235 workers: usize,
1236 #[arg(long = "cors-origin", value_name = "URL")]
1242 cors_origin: Vec<String>,
1243 #[arg(long = "auth-token", value_name = "TOKEN")]
1247 auth_token: Option<String>,
1248 #[arg(long = "insecure")]
1250 insecure_no_auth: bool,
1251}
1252
1253#[derive(Debug, Clone, PartialEq, Eq)]
1254struct ServeBindHost {
1255 host: String,
1256 mobile_rebound_to_lan: bool,
1257}
1258
1259fn resolve_serve_bind_host(mobile: bool, host: Option<String>) -> ServeBindHost {
1260 match (mobile, host) {
1261 (true, None) => ServeBindHost {
1262 host: "0.0.0.0".to_string(),
1263 mobile_rebound_to_lan: true,
1264 },
1265 (_, Some(host)) => ServeBindHost {
1266 host,
1267 mobile_rebound_to_lan: false,
1268 },
1269 (false, None) => ServeBindHost {
1270 host: "127.0.0.1".to_string(),
1271 mobile_rebound_to_lan: false,
1272 },
1273 }
1274}
1275
1276fn validate_serve_mode_selection(
1277 mcp: bool,
1278 http: bool,
1279 mobile: bool,
1280 web: bool,
1281 acp: bool,
1282) -> Result<bool> {
1283 if http && mobile {
1284 bail!("--http and --mobile are mutually exclusive; choose one");
1285 }
1286 if web && (http || mobile) {
1287 bail!("--web is mutually exclusive with --http and --mobile");
1288 }
1289 let http_selected = http || mobile || web;
1290 let selected_modes = [mcp, http_selected, acp]
1291 .into_iter()
1292 .filter(|selected| *selected)
1293 .count();
1294 if selected_modes != 1 {
1295 bail!("Choose exactly one server mode: --mcp, --http/--mobile/--web, or --acp");
1296 }
1297 Ok(http_selected)
1298}
1299
1300#[derive(Subcommand, Debug, Clone)]
1301enum McpCommand {
1302 List,
1304 Init {
1306 #[arg(long, default_value_t = false)]
1308 force: bool,
1309 },
1310 Connect {
1312 #[arg(value_name = "SERVER")]
1314 server: Option<String>,
1315 },
1316 Tools {
1318 #[arg(value_name = "SERVER")]
1320 server: Option<String>,
1321 },
1322 Add {
1324 name: String,
1326 #[arg(long, conflicts_with = "url")]
1328 command: Option<String>,
1329 #[arg(long, conflicts_with = "command")]
1331 url: Option<String>,
1332 #[arg(long, requires = "url")]
1334 transport: Option<String>,
1335 #[arg(long, requires = "url")]
1337 bearer_token_env_var: Option<String>,
1338 #[arg(long, requires = "url")]
1340 oauth_client_id: Option<String>,
1341 #[arg(long, requires = "url")]
1343 oauth_resource: Option<String>,
1344 #[arg(long = "scope", requires = "url", value_delimiter = ',')]
1346 scopes: Vec<String>,
1347 #[arg(long = "arg")]
1349 args: Vec<String>,
1350 },
1351 Login {
1353 name: String,
1355 #[arg(long = "scope", value_delimiter = ',')]
1357 scopes: Vec<String>,
1358 },
1359 Logout {
1361 name: String,
1363 },
1364 Remove {
1366 name: String,
1368 },
1369 Enable {
1371 name: String,
1373 },
1374 Disable {
1376 name: String,
1378 },
1379 Validate,
1381 #[command(
1386 name = "add-self",
1387 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."
1388 )]
1389 AddSelf {
1390 #[arg(long, default_value = "codewhale")]
1392 name: String,
1393 #[arg(long)]
1395 workspace: Option<String>,
1396 },
1397}
1398
1399#[derive(Subcommand, Debug, Clone)]
1400pub(crate) enum IntegrationsCommand {
1401 Dsh {
1403 #[command(subcommand)]
1404 command: DshIntegrationCommand,
1405 },
1406}
1407
1408#[derive(Subcommand, Debug, Clone)]
1409pub(crate) enum DshIntegrationCommand {
1410 Status {
1412 #[arg(long, default_value_t = false)]
1414 json: bool,
1415 },
1416 Plan {
1418 #[arg(long, default_value_t = false)]
1419 json: bool,
1420 #[arg(long, default_value = "web")]
1422 profile: String,
1423 #[arg(long, default_value_t = false)]
1425 allow_full_access: bool,
1426 #[arg(long, default_value_t = false)]
1428 skin: bool,
1429 },
1430 Connect {
1432 #[arg(long, default_value = "web")]
1433 profile: String,
1434 #[arg(long, default_value_t = false)]
1435 allow_full_access: bool,
1436 #[arg(long, default_value_t = false)]
1437 skin: bool,
1438 #[arg(long, default_value_t = false)]
1440 yes: bool,
1441 },
1442 Update {
1444 #[arg(long)]
1445 profile: Option<String>,
1446 #[arg(long, default_value_t = false)]
1447 allow_full_access: bool,
1448 #[arg(long)]
1450 skin: Option<bool>,
1451 #[arg(long)]
1453 ocean: Option<bool>,
1454 #[arg(long, default_value_t = false)]
1455 yes: bool,
1456 },
1457 Launch {
1459 #[arg(long)]
1461 profile: Option<String>,
1462 #[arg(long, default_value_t = false)]
1464 dry_run: bool,
1465 #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
1466 args: Vec<String>,
1467 },
1468 Disable,
1470 Enable,
1472 Remove {
1474 #[arg(long, default_value_t = false)]
1475 yes: bool,
1476 },
1477 InstallBundle {
1479 #[arg(long, default_value = "web")]
1481 app: String,
1482 #[arg(long, default_value_t = false)]
1483 yes: bool,
1484 },
1485 RemoveBundle {
1487 #[arg(long, default_value_t = false)]
1488 yes: bool,
1489 },
1490}
1491
1492#[derive(Args, Debug, Clone)]
1493struct FeaturesCli {
1494 #[command(subcommand)]
1495 command: FeaturesSubcommand,
1496}
1497
1498#[derive(Subcommand, Debug, Clone)]
1499enum FeaturesSubcommand {
1500 List,
1502}
1503
1504#[derive(Args, Debug, Clone)]
1505struct SandboxArgs {
1506 #[command(subcommand)]
1507 command: SandboxCommand,
1508}
1509
1510#[derive(Subcommand, Debug, Clone)]
1511enum SandboxCommand {
1512 Run {
1514 #[arg(long, default_value = "workspace-write")]
1516 policy: String,
1517 #[arg(long)]
1519 network: bool,
1520 #[arg(long, value_name = "PATH")]
1522 writable_root: Vec<PathBuf>,
1523 #[arg(long)]
1525 exclude_tmpdir: bool,
1526 #[arg(long)]
1528 exclude_slash_tmp: bool,
1529 #[arg(long)]
1531 cwd: Option<PathBuf>,
1532 #[arg(long, default_value_t = 60_000)]
1534 timeout_ms: u64,
1535 #[arg(required = true, trailing_var_arg = true)]
1537 command: Vec<String>,
1538 },
1539}
1540
1541const CODEWHALE_MAIN_STACK_BYTES: usize = 16 * 1024 * 1024;
1542
1543pub fn run(args: Vec<String>) -> std::process::ExitCode {
1547 match run_with_args(args) {
1548 Ok(()) => std::process::ExitCode::SUCCESS,
1549 Err(err) => {
1550 eprintln!("error: {err}");
1551 for cause in err.chain().skip(1) {
1552 eprintln!(" caused by: {cause}");
1553 }
1554 std::process::ExitCode::FAILURE
1555 }
1556 }
1557}
1558
1559fn run_with_args(args: Vec<String>) -> Result<()> {
1563 #[cfg(unix)]
1567 unsafe {
1568 libc::signal(libc::SIGPIPE, libc::SIG_DFL);
1569 }
1570
1571 startup_trace::mark_process_start();
1572 configure_windows_console_utf8();
1573 install_rustls_crypto_provider();
1574
1575 crate::sandbox::process_hardening::apply_process_hardening();
1579
1580 crate::tui::ui::fatal_signal_guard::install_fatal_signal_guard();
1587
1588 let orig_hook = std::panic::take_hook();
1593 std::panic::set_hook(Box::new(move |panic_info| {
1594 crate::tui::ui::emergency_restore_terminal();
1600
1601 let msg = if let Some(s) = panic_info.payload().downcast_ref::<&str>() {
1602 s.to_string()
1603 } else if let Some(s) = panic_info.payload().downcast_ref::<String>() {
1604 s.clone()
1605 } else {
1606 format!("{:?}", panic_info.payload())
1607 };
1608 let location = panic_info
1609 .location()
1610 .map(|loc| loc.to_string())
1611 .unwrap_or_else(|| "unknown".to_string());
1612 tracing::error!(target: "panic", "Process panicked at {location}: {msg}");
1613
1614 codewhale_telemetry::set_exit_class(codewhale_telemetry::ExitClass::Panic);
1625 if let Some(site) = panic_info
1626 .location()
1627 .map(|loc| codewhale_telemetry::reduce_panic_site(loc.file(), loc.line(), loc.column()))
1628 {
1629 codewhale_telemetry::record_blocking(codewhale_telemetry::Event::Panic { site });
1630 }
1631 if let Some(home) = crate::config::effective_home_dir() {
1633 let crash_dir = home.join(".deepseek").join("crashes");
1634 let _ = std::fs::create_dir_all(&crash_dir);
1635 use chrono::Utc;
1636 let ts = Utc::now().format("%Y%m%dT%H%M%S%.3fZ");
1637 let path = crash_dir.join(format!("{ts}-process-panic.log"));
1638 let contents =
1639 format!("Process panicked\nLocation: {location}\nTimestamp: {ts}\nPanic: {msg}\n",);
1640 let _ = std::fs::write(&path, contents);
1641 }
1642 orig_hook(panic_info);
1644 }));
1645
1646 let cli = match Cli::try_parse_from(args) {
1653 Ok(c) => c,
1654 Err(e) => {
1655 e.exit();
1656 }
1657 };
1658 crate::fleet::roster::set_project_agent_profiles_enabled(!cli.no_project_config);
1663 let workspace = resolve_workspace(&cli);
1664 let mut plugin_discovery = None;
1665 let mut plugin_registry = None;
1666 let (cli, command) = prepare_cli_startup(
1667 cli,
1668 || {
1669 let discovery = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv();
1670 plugin_registry = Some(discovery.registry_for_workspace(&workspace));
1671 plugin_discovery = Some(discovery);
1672 },
1673 warn_on_workspace_dotenv_result,
1674 );
1675 let plugin_discovery = plugin_discovery
1676 .expect("plugin discovery initialization must precede workspace dotenv loading");
1677 let plugin_registry = plugin_registry
1678 .expect("plugin discovery initialization must precede workspace dotenv loading");
1679
1680 let runtime_thread = std::thread::Builder::new()
1688 .name("codewhale-main".to_string())
1689 .stack_size(CODEWHALE_MAIN_STACK_BYTES)
1690 .spawn(move || run_async_main(cli, command, plugin_discovery, plugin_registry))
1691 .context("Failed to start the Codewhale runtime thread")?;
1692 match runtime_thread.join() {
1693 Ok(result) => result,
1694 Err(payload) => {
1695 let message = payload
1696 .downcast_ref::<&str>()
1697 .map(|value| (*value).to_string())
1698 .or_else(|| payload.downcast_ref::<String>().cloned())
1699 .unwrap_or_else(|| "unknown panic payload".to_string());
1700 Err(anyhow!("Codewhale runtime thread panicked: {message}"))
1701 }
1702 }
1703}
1704
1705fn run_async_main(
1706 cli: Cli,
1707 command: Option<Commands>,
1708 plugin_discovery: Arc<crate::plugins::PluginDiscoveryContext>,
1709 plugin_registry: Arc<crate::plugins::PluginRegistry>,
1710) -> Result<()> {
1711 build_runtime()?.block_on(run_async_main_inner(
1712 cli,
1713 command,
1714 plugin_discovery,
1715 plugin_registry,
1716 ))
1717}
1718
1719pub(crate) fn build_runtime() -> Result<tokio::runtime::Runtime> {
1738 tokio::runtime::Builder::new_multi_thread()
1739 .enable_all()
1740 .thread_stack_size(CODEWHALE_MAIN_STACK_BYTES)
1741 .build()
1742 .context("Failed to build the Codewhale Tokio runtime")
1743}
1744
1745fn telemetry_surface(command: Option<&Commands>) -> codewhale_telemetry::Surface {
1751 use codewhale_telemetry::Surface;
1752 match command {
1753 None | Some(Commands::Resume { .. } | Commands::Fork { .. } | Commands::Pr { .. }) => {
1754 Surface::Tui
1755 }
1756 Some(Commands::Exec(_)) => Surface::Exec,
1757 Some(Commands::Serve(args)) => {
1758 if args.mcp {
1759 Surface::McpServer
1760 } else {
1761 Surface::Serve
1762 }
1763 }
1764 Some(_) => Surface::Cli,
1765 }
1766}
1767
1768fn telemetry_session_source(command: Option<&Commands>) -> codewhale_telemetry::SessionSource {
1770 use codewhale_telemetry::SessionSource;
1771 match command {
1772 None | Some(Commands::Pr { .. }) => SessionSource::Interactive,
1773 Some(Commands::Resume { .. }) => SessionSource::Resume,
1774 Some(Commands::Fork { .. }) => SessionSource::Fork,
1775 Some(Commands::Serve(_)) => SessionSource::Api,
1776 Some(_) => SessionSource::Unknown,
1777 }
1778}
1779
1780fn telemetry_command_is_read_only(command: Option<&Commands>) -> bool {
1782 matches!(
1783 command,
1784 Some(Commands::Doctor(_) | Commands::SessionDiagnostics(_) | Commands::Sessions { .. })
1785 ) || matches!(command, Some(Commands::Setup(args)) if args.status)
1786}
1787
1788fn arm_telemetry_with_setup(
1802 config_path: Option<PathBuf>,
1803 surface: codewhale_telemetry::Surface,
1804 source: codewhale_telemetry::SessionSource,
1805 setup_override: Option<&codewhale_config::SetupState>,
1806) {
1807 let Ok(store) = codewhale_config::ConfigStore::load(config_path) else {
1808 return;
1809 };
1810 let resolved = store
1811 .config
1812 .resolve_runtime_options(&codewhale_config::CliRuntimeOverrides::default());
1813 let setup = if let Some(setup) = setup_override {
1814 setup.clone()
1815 } else {
1816 let Some(setup) = codewhale_telemetry::load_setup_state_for_decision() else {
1817 return;
1820 };
1821 setup
1822 };
1823 let codewhale_telemetry::TelemetryDecision::Enabled(consent) =
1824 codewhale_telemetry::decide(&resolved, &setup, surface)
1825 else {
1826 return;
1827 };
1828 codewhale_telemetry::init(consent.with_config_path(Some(store.path().to_path_buf())));
1829 let _ = TELEMETRY_SESSION_START.set(std::time::Instant::now());
1830 codewhale_telemetry::record(codewhale_telemetry::Event::SessionStart { source });
1831}
1832
1833fn arm_telemetry(cli: &Cli, command: Option<&Commands>) {
1834 if telemetry_command_is_read_only(command) {
1835 return;
1836 }
1837 arm_telemetry_with_setup(
1838 cli.config.clone(),
1839 telemetry_surface(command),
1840 telemetry_session_source(command),
1841 None,
1842 );
1843}
1844
1845pub(crate) fn apply_tui_telemetry_decision(
1852 pending: &crate::telemetry_notice::PendingTelemetryNotice,
1853 setup: &codewhale_config::SetupState,
1854) {
1855 arm_telemetry_with_setup(
1856 pending.config_path.clone(),
1857 codewhale_telemetry::Surface::Tui,
1858 pending.session_source,
1859 Some(setup),
1860 );
1861}
1862
1863async fn finish_telemetry(outcome: &Result<()>, surface: codewhale_telemetry::Surface) {
1872 if !codewhale_telemetry::is_armed() {
1873 return;
1874 }
1875 if outcome.is_err()
1878 && codewhale_telemetry::exit_class() == codewhale_telemetry::ExitClass::Clean
1879 {
1880 codewhale_telemetry::set_exit_class(codewhale_telemetry::ExitClass::Error);
1881 }
1882 codewhale_telemetry::record(telemetry_session_end());
1883 if surface == codewhale_telemetry::Surface::Cli {
1884 let _ =
1885 codewhale_telemetry::persist_local_blocking(codewhale_telemetry::CLI_PERSIST_TIMEOUT);
1886 return;
1887 }
1888 let _ = tokio::time::timeout(
1893 codewhale_telemetry::SHUTDOWN_FLUSH_TIMEOUT,
1894 tokio::task::spawn_blocking(|| {
1895 codewhale_telemetry::shutdown_blocking(codewhale_telemetry::SHUTDOWN_FLUSH_TIMEOUT)
1896 }),
1897 )
1898 .await;
1899}
1900
1901async fn run_async_main_inner(
1902 cli: Cli,
1903 command: Option<Commands>,
1904 plugin_discovery: Arc<crate::plugins::PluginDiscoveryContext>,
1905 plugin_registry: Arc<crate::plugins::PluginRegistry>,
1906) -> Result<()> {
1907 spawn_signal_cleanup_task();
1928
1929 let surface = telemetry_surface(command.as_ref());
1935 let telemetry_notice_plan = if surface == codewhale_telemetry::Surface::Tui {
1936 crate::telemetry_notice::plan_if_due(
1937 cli.config.clone(),
1938 telemetry_session_source(command.as_ref()),
1939 )
1940 } else {
1941 crate::telemetry_notice::TelemetryNoticePlan::NotDue
1942 };
1943 let should_arm_before_dispatch = surface != codewhale_telemetry::Surface::Tui
1944 || telemetry_notice_plan.should_arm_before_tui();
1945 let pending_telemetry_notice = telemetry_notice_plan.into_pending();
1946 if should_arm_before_dispatch {
1947 arm_telemetry(&cli, command.as_ref());
1948 }
1949 let outcome = run_async_main_dispatch(
1950 cli,
1951 command,
1952 plugin_discovery,
1953 plugin_registry,
1954 pending_telemetry_notice,
1955 )
1956 .await;
1957 finish_telemetry(&outcome, surface).await;
1958 outcome
1959}
1960
1961async fn run_async_main_dispatch(
1962 cli: Cli,
1963 command: Option<Commands>,
1964 plugin_discovery: Arc<crate::plugins::PluginDiscoveryContext>,
1965 plugin_registry: Arc<crate::plugins::PluginRegistry>,
1966 mut pending_telemetry_notice: Option<crate::telemetry_notice::PendingTelemetryNotice>,
1967) -> Result<()> {
1968 logging::set_verbose(cli.verbose || logging::env_requests_verbose_logging());
1969
1970 crate::prompts::load_prompt_overrides_from_config_home();
1975
1976 if let Some(command) = command {
1984 return match command {
1985 Commands::Doctor(args) => {
1986 let config = match load_doctor_config_from_cli(&cli, &args) {
1987 Ok(config) => config,
1988 Err(error) if args.json => return run_doctor_json_config_error(&error),
1989 Err(_) => {
1990 bail!(
1991 "doctor configuration validation failed; details omitted because configuration errors may contain credential material"
1992 )
1993 }
1994 };
1995 let workspace = resolve_workspace(&cli);
1996 if args.context_json {
1997 run_doctor_context_json(&config, &workspace)
1998 } else if args.json {
1999 run_doctor_json(
2000 &config,
2001 &workspace,
2002 cli.config.as_deref(),
2003 plugin_registry.as_ref(),
2004 )
2005 } else {
2006 let probes = crate::doctor::DoctorProbeRequest {
2007 check_updates: args.check_updates,
2008 probe_api: args.probe_api,
2009 probe_local: args.probe_local,
2010 probe_mcp: args.probe_mcp,
2011 probe_search: args.probe_search,
2012 };
2013 run_doctor(
2014 &config,
2015 &workspace,
2016 cli.config.as_deref(),
2017 probes,
2018 plugin_registry.as_ref(),
2019 )
2020 .await;
2021 Ok(())
2022 }
2023 }
2024 Commands::SessionDiagnostics(args) => run_session_diagnostics(args),
2025 Commands::Setup(args) => {
2026 let config = load_config_from_cli(&cli)?;
2027 let workspace = resolve_workspace(&cli);
2028 run_setup(&config, &workspace, args, plugin_registry.as_ref())
2029 }
2030 Commands::RemoteSetup(args) => remote_setup::run_remote_setup(args),
2031 Commands::Completions { shell } => {
2032 generate_completions(shell);
2033 Ok(())
2034 }
2035 Commands::Sessions { limit, search } => list_sessions(limit, search),
2036 Commands::Init => init_project(),
2037 Commands::Login { api_key } => run_login(api_key),
2038 Commands::Logout => run_logout(),
2039 Commands::Auth(args) => match args.command {
2040 TuiAuthCommand::XaiDevice => run_xai_device_auth(cli.config.as_deref()).await,
2041 },
2042 Commands::Models(args) => {
2043 let config = load_config_from_cli(&cli)?;
2044 run_models(&config, args).await
2045 }
2046 Commands::Speech(args) => {
2047 let config = load_config_from_cli(&cli)?;
2048 run_speech(&config, args).await
2049 }
2050 Commands::Exec(args) => {
2051 let config = load_config_from_cli(&cli)?;
2052 let workspace = cli.workspace.clone().unwrap_or_else(|| {
2053 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
2054 });
2055 let mut config = config.clone();
2056 if !cli.no_project_config {
2061 merge_user_workspace_config(&mut config, cli.config.clone(), &workspace);
2062 }
2063 if let Some(sandbox) = args.sandbox.as_deref() {
2064 let _ = parse_sandbox_policy(sandbox, true, Vec::new(), false, false)?;
2065 config.sandbox_mode = Some(sandbox.to_ascii_lowercase());
2066 }
2067 if let Ok(env_url) = std::env::var("CODEWHALE_BASE_URL")
2070 .or_else(|_| std::env::var("DEEPSEEK_BASE_URL"))
2071 {
2072 let trimmed = env_url.trim();
2073 if !trimmed.is_empty() {
2074 config.base_url = Some(trimmed.to_string());
2075 }
2076 }
2077 let explicit_provider = args
2087 .provider
2088 .as_deref()
2089 .map(str::trim)
2090 .filter(|provider| !provider.is_empty());
2091 if let Some(provider_arg) = explicit_provider {
2092 apply_exec_provider_override(&mut config, provider_arg)?;
2093 }
2094 if let Some(reasoning_arg) = args
2095 .reasoning_effort
2096 .as_deref()
2097 .map(str::trim)
2098 .filter(|value| !value.is_empty())
2099 {
2100 config.reasoning_effort = normalize_cli_reasoning_effort(reasoning_arg)?;
2101 config.reasoning_effort_inferred_from_legacy_alias = false;
2102 }
2103 let prompt = join_prompt_parts(&args.prompt);
2104 let resume_session_id = resolve_exec_resume_session_id(&args, &workspace)?;
2105 validate_exec_tool_authority_resume(
2106 args.tool_authority_json.as_deref(),
2107 resume_session_id.is_some(),
2108 )?;
2109 let resume_session = resume_session_id
2110 .as_deref()
2111 .map(load_exec_resume_session)
2112 .transpose()?;
2113 let explicit_model = args
2114 .model
2115 .as_deref()
2116 .map(str::trim)
2117 .filter(|model| !model.is_empty());
2118 let model = if let Some(saved) = resume_session.as_ref() {
2119 resolve_exec_resume_route(
2120 &mut config,
2121 saved,
2122 explicit_provider.is_some(),
2123 explicit_model,
2124 )?
2125 } else {
2126 resolve_exec_model(&config, explicit_model)
2127 };
2128 let force_configured_route = should_force_configured_exec_route(
2129 resume_session.is_some(),
2130 explicit_provider,
2131 explicit_model,
2132 );
2133 let yolo = cli.yolo || config.yolo.unwrap_or(false);
2137 let env_tool_surface = exec_tool_surface_from_env();
2138 let needs_engine = args.auto
2139 || yolo
2140 || resume_session_id.is_some()
2141 || args.output_format == ExecOutputFormat::StreamJson
2142 || args.max_turns.is_some()
2143 || args.allowed_tools.is_some()
2144 || args.disallowed_tools.is_some()
2145 || args.append_system_prompt.is_some()
2146 || args.tool_authority_json.is_some()
2147 || args.sandbox.is_some()
2148 || args.allow_sandbox_elevation
2149 || env_tool_surface.is_some();
2150 if needs_engine {
2151 let provider = config.api_provider();
2152 let max_subagents = cli.max_subagents.map_or_else(
2153 || config.max_subagents_for_provider(provider),
2154 |value| value.clamp(1, MAX_SUBAGENTS),
2155 );
2156 let auto_mode = args.auto || yolo;
2157 let max_turns = exec_max_steps(args.max_turns);
2158 let allowed_tools =
2159 resolve_exec_allowed_tools(args.allowed_tools.as_deref(), env_tool_surface);
2160 let disallowed_tools = args
2161 .disallowed_tools
2162 .as_deref()
2163 .map(normalize_exec_tool_names);
2164 run_exec_agent(
2165 &config,
2166 &model,
2167 &prompt,
2168 workspace,
2169 max_subagents,
2170 auto_mode,
2171 args.allow_sandbox_elevation,
2172 args.sandbox.as_deref(),
2173 auto_mode,
2174 args.json,
2175 resume_session,
2176 force_configured_route,
2177 args.output_format,
2178 max_turns,
2179 allowed_tools,
2180 disallowed_tools,
2181 args.append_system_prompt.clone(),
2182 args.tool_authority_json.clone(),
2183 std::sync::Arc::clone(&plugin_registry),
2184 )
2185 .await
2186 } else if args.json {
2187 run_one_shot_json(&config, &model, &prompt, force_configured_route).await
2188 } else {
2189 run_one_shot(&config, &model, &prompt, force_configured_route).await
2190 }
2191 }
2192 Commands::Fleet(args) => {
2193 let config = load_config_from_cli(&cli)?;
2194 let workspace = resolve_workspace(&cli);
2195 run_fleet_command(&workspace, &config, args).await
2196 }
2197 Commands::WorkflowTool(args) => {
2198 run_workflow_tool_command(&cli, args, std::sync::Arc::clone(&plugin_registry)).await
2199 }
2200 Commands::Review(args) => {
2201 let config = load_config_from_cli(&cli)?;
2202 run_review(&config, args).await
2203 }
2204 Commands::Pr {
2205 number,
2206 repo,
2207 checkout,
2208 } => {
2209 let config = load_config_from_cli(&cli)?;
2210 run_pr(
2211 &cli,
2212 &config,
2213 number,
2214 repo.as_deref(),
2215 checkout,
2216 pending_telemetry_notice.take(),
2217 Arc::clone(&plugin_registry),
2218 )
2219 .await
2220 }
2221 Commands::Apply(args) => run_apply(args),
2222 Commands::Eval(args) => run_eval(args),
2223 Commands::Scorecard(args) => run_scorecard(args),
2224 Commands::Mcp { command } => {
2225 let config = load_config_from_cli(&cli)?;
2226 let workspace = resolve_workspace(&cli);
2227 run_mcp_command(&config, &workspace, command, plugin_registry.as_ref()).await
2228 }
2229 Commands::Features(command) => {
2230 let config = load_config_from_cli(&cli)?;
2231 run_features_command(&config, command)
2232 }
2233 Commands::Integrations { command } => {
2234 let config = load_structural_config_from_cli(&cli)?;
2237 let workspace = resolve_workspace(&cli);
2238 integrations::cli::run(&config, &workspace, command)
2239 }
2240 Commands::Sandbox(args) => run_sandbox_command(args),
2241 Commands::Serve(args) => {
2242 let workspace = cli.workspace.clone().unwrap_or_else(|| {
2243 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
2244 });
2245 let http_selected = validate_serve_mode_selection(
2246 args.mcp,
2247 args.http,
2248 args.mobile,
2249 args.web,
2250 args.acp,
2251 )?;
2252 if args.mcp {
2253 tokio::task::block_in_place(|| mcp_server::run_mcp_server(workspace))
2254 } else if http_selected {
2255 let (config, config_profile) =
2256 load_config_from_cli_with_effective_profile(&cli)?;
2257 let cors_origins = resolve_cors_origins(&config, &args.cors_origin);
2258 let bind_host = resolve_serve_bind_host(args.mobile, args.host);
2259 if args.web && bind_host.host != "127.0.0.1" {
2260 bail!("Codewhale web is loopback-only and must bind to 127.0.0.1");
2261 }
2262 if bind_host.mobile_rebound_to_lan {
2263 println!(
2264 "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."
2265 );
2266 }
2267 runtime_api::run_http_server(
2268 config,
2269 workspace,
2270 std::sync::Arc::clone(&plugin_discovery),
2271 runtime_api::RuntimeApiOptions {
2272 host: bind_host.host,
2273 port: args.port,
2274 workers: args.workers.clamp(1, 8),
2275 cors_origins,
2276 auth_token: args.auth_token,
2277 insecure_no_auth: args.insecure_no_auth,
2278 mobile: args.mobile,
2279 web: args.web,
2280 show_qr: args.qr,
2281 config_path: cli.config.clone(),
2282 config_profile,
2283 },
2284 )
2285 .await
2286 } else if args.acp {
2287 let config = load_config_from_cli(&cli)?;
2288 let model = config.default_model();
2289 acp_server::run_acp_server(config, model, workspace).await
2290 } else {
2291 unreachable!("server mode count checked above")
2292 }
2293 }
2294 Commands::Resume { session_id, last } => {
2295 let config = load_config_from_cli(&cli)?;
2296 let workspace = resolve_workspace(&cli);
2297 let resume_id = resolve_session_id(session_id, last, &workspace)?;
2298 run_interactive(
2299 &cli,
2300 &config,
2301 Some(resume_id),
2302 None,
2303 pending_telemetry_notice.take(),
2304 std::sync::Arc::clone(&plugin_registry),
2305 )
2306 .await
2307 }
2308 Commands::Fork { session_id, last } => {
2309 let config = load_config_from_cli(&cli)?;
2310 let workspace = resolve_workspace(&cli);
2311 let new_session_id = fork_session(&config, session_id, last, &workspace)?;
2312 run_interactive(
2313 &cli,
2314 &config,
2315 Some(new_session_id),
2316 None,
2317 pending_telemetry_notice.take(),
2318 std::sync::Arc::clone(&plugin_registry),
2319 )
2320 .await
2321 }
2322 };
2323 }
2324
2325 let config = load_config_from_cli(&cli)?;
2329 if let Some(initial_input) = top_level_prompt_initial_input(&cli.prompt) {
2330 return run_interactive(
2331 &cli,
2332 &config,
2333 None,
2334 Some(initial_input),
2335 pending_telemetry_notice.take(),
2336 std::sync::Arc::clone(&plugin_registry),
2337 )
2338 .await;
2339 }
2340
2341 let mut startup_notice = None;
2344 let resume_session_id = if cli.continue_session {
2345 let workspace = resolve_workspace(&cli);
2346 recover_interrupted_checkpoint_for_resume(&workspace)
2347 .or_else(|| latest_session_id_for_workspace(&workspace).ok().flatten())
2348 } else if let Some(id) = cli.resume.clone() {
2349 Some(id)
2350 } else if !cli.fresh {
2351 let workspace = resolve_workspace(&cli);
2352 preserve_interrupted_checkpoint_for_explicit_resume(&workspace);
2353 let (session_id, notice) = resolve_auto_resume(&workspace);
2359 startup_notice = notice;
2360 session_id
2361 } else {
2362 None
2363 };
2364
2365 run_interactive_with_notice(
2368 &cli,
2369 &config,
2370 resume_session_id,
2371 None,
2372 startup_notice,
2373 pending_telemetry_notice.take(),
2374 plugin_registry,
2375 )
2376 .await
2377}
2378
2379fn resolve_auto_resume(workspace: &Path) -> (Option<String>, Option<String>) {
2386 use crate::session_resume::{ResumeRequest, decide_auto_resume};
2387
2388 let enabled = crate::settings::Settings::load_persisted()
2389 .map(|settings| settings.session_auto_resume)
2390 .unwrap_or(false);
2391 if !enabled {
2392 return (None, None);
2393 }
2394 let Ok(manager) = SessionManager::default_location() else {
2395 return (None, None);
2396 };
2397 let decision = decide_auto_resume(true, &ResumeRequest::default(), workspace, &manager);
2398 (
2399 decision.session_id().map(str::to_string),
2400 decision.status_message(),
2401 )
2402}
2403
2404fn prepare_cli_startup(
2405 cli: Cli,
2406 initialize_plugins: impl FnOnce(),
2407 load_dotenv: impl FnOnce(),
2408) -> (Cli, Option<Commands>) {
2409 initialize_plugins();
2410 let command = cli.command.clone();
2411 let should_load_dotenv = match command.as_ref() {
2412 Some(Commands::Doctor(args)) => args.probe_api || args.probe_local,
2413 _ => true,
2414 };
2415 if should_load_dotenv {
2416 load_dotenv();
2417 }
2418 (cli, command)
2419}
2420
2421const MAX_WORKSPACE_DOTENV_BYTES: u64 = 1024 * 1024;
2422
2423#[derive(Debug, Default)]
2424struct WorkspaceDotenvReport {
2425 path: PathBuf,
2426 loaded: BTreeSet<String>,
2427 ignored: BTreeSet<String>,
2428}
2429
2430fn warn_on_workspace_dotenv_result() {
2438 match load_workspace_dotenv_credentials() {
2439 Ok(Some(report)) if !report.ignored.is_empty() => {
2440 eprintln!(
2441 "Codewhale ignored non-credential settings in {}: {}. Use config.toml, CLI flags, or the launching shell for control settings.",
2442 report.path.display(),
2443 display_env_key_set(&report.ignored)
2444 );
2445 }
2446 Ok(_) => {}
2447 Err(error) => {
2448 eprintln!("Codewhale did not load workspace .env: {error}");
2452 }
2453 }
2454}
2455
2456fn display_env_key_set(keys: &BTreeSet<String>) -> String {
2457 const MAX_DISPLAYED: usize = 12;
2458 let mut labels = keys
2459 .iter()
2460 .take(MAX_DISPLAYED)
2461 .map(|key| {
2462 if key
2463 .chars()
2464 .all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_')
2465 {
2466 key.as_str()
2467 } else {
2468 "<invalid-name>"
2469 }
2470 })
2471 .collect::<Vec<_>>();
2472 if keys.len() > MAX_DISPLAYED {
2473 labels.push("...");
2474 }
2475 labels.join(", ")
2476}
2477
2478fn load_workspace_dotenv_credentials() -> Result<Option<WorkspaceDotenvReport>> {
2479 let Some(path) = find_workspace_dotenv()? else {
2480 return Ok(None);
2481 };
2482 load_workspace_dotenv_credentials_from_path(&path).map(Some)
2483}
2484
2485fn find_workspace_dotenv() -> Result<Option<PathBuf>> {
2486 let cwd = std::env::current_dir().context("could not resolve the current workspace")?;
2487 let boundary = cwd
2488 .ancestors()
2489 .find(|ancestor| std::fs::symlink_metadata(ancestor.join(".git")).is_ok())
2490 .unwrap_or(cwd.as_path());
2491
2492 for ancestor in cwd.ancestors() {
2493 let candidate = ancestor.join(".env");
2494 match std::fs::symlink_metadata(&candidate) {
2495 Ok(_) => return Ok(Some(candidate)),
2496 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
2497 Err(error) => {
2498 return Err(anyhow!(
2499 "could not inspect {}: {error}",
2500 candidate.display()
2501 ));
2502 }
2503 }
2504 if ancestor == boundary {
2505 break;
2506 }
2507 }
2508 Ok(None)
2509}
2510
2511fn load_workspace_dotenv_credentials_from_path(path: &Path) -> Result<WorkspaceDotenvReport> {
2512 let contents = read_stable_workspace_dotenv(path)?;
2513 let text = std::str::from_utf8(&contents)
2514 .map_err(|_| anyhow!("{} is not valid UTF-8", path.display()))?;
2515 if dotenv_has_variable_expansion(text) {
2516 bail!(
2517 "{} uses variable expansion; workspace .env values must be literal to prevent ambient-secret substitution",
2518 path.display()
2519 );
2520 }
2521
2522 let mut report = WorkspaceDotenvReport {
2523 path: path.to_path_buf(),
2524 ..WorkspaceDotenvReport::default()
2525 };
2526 let entries = dotenvy::from_read_iter(std::io::Cursor::new(contents))
2527 .collect::<std::result::Result<Vec<_>, _>>()
2528 .map_err(|_| anyhow!("{} could not be parsed safely", path.display()))?;
2529 for entry in entries {
2530 let (key, value) = entry;
2531 if !is_workspace_dotenv_credential_key(&key) {
2532 report.ignored.insert(key);
2533 continue;
2534 }
2535 if std::env::var_os(&key).is_some() {
2536 continue;
2537 }
2538
2539 unsafe { std::env::set_var(&key, value) };
2544 report.loaded.insert(key);
2545 }
2546 Ok(report)
2547}
2548
2549fn is_workspace_dotenv_credential_key(key: &str) -> bool {
2550 codewhale_config::provider::providers_sorted_for_display()
2551 .into_iter()
2552 .any(|provider| provider.env_vars().contains(&key))
2553 || matches!(
2554 key,
2555 "DEEPSEEK_SEARCH_API_KEY"
2556 | "SOFYA_API_KEY"
2557 | "METASO_API_KEY"
2558 | "BAIDU_SEARCH_API_KEY"
2559 | "DEEPSEEK_SANDBOX_API_KEY"
2560 )
2561}
2562
2563fn dotenv_has_variable_expansion(contents: &str) -> bool {
2564 let mut escaped = false;
2565 let mut single_quoted = false;
2566 let mut double_quoted = false;
2567 let mut comment = false;
2568
2569 for ch in contents.chars() {
2570 if comment {
2571 if ch == '$' {
2575 return true;
2576 }
2577 if ch == '\n' {
2578 comment = false;
2579 escaped = false;
2580 }
2581 continue;
2582 }
2583 if single_quoted {
2584 if ch == '\'' {
2585 single_quoted = false;
2586 }
2587 continue;
2588 }
2589 if escaped {
2590 escaped = false;
2591 continue;
2592 }
2593 if ch == '\\' {
2594 escaped = true;
2595 continue;
2596 }
2597 if ch == '\'' && !double_quoted {
2598 single_quoted = true;
2599 continue;
2600 }
2601 if ch == '"' {
2602 double_quoted = !double_quoted;
2603 continue;
2604 }
2605 if ch == '#' && !double_quoted {
2606 comment = true;
2607 continue;
2608 }
2609 if ch == '$' {
2610 return true;
2611 }
2612 }
2613 false
2614}
2615
2616fn read_stable_workspace_dotenv(path: &Path) -> Result<Vec<u8>> {
2617 let mut file = open_workspace_dotenv_without_following_links(path)?;
2618 let metadata = file
2619 .metadata()
2620 .map_err(|error| anyhow!("could not inspect {}: {error}", path.display()))?;
2621 if !metadata.is_file() {
2622 bail!("{} is not a regular file", path.display());
2623 }
2624 if workspace_dotenv_has_multiple_links(&file, &metadata)? {
2625 bail!(
2626 "{} has multiple filesystem links, not a unique workspace-owned file",
2627 path.display()
2628 );
2629 }
2630 if metadata.len() > MAX_WORKSPACE_DOTENV_BYTES {
2631 bail!(
2632 "{} exceeds the {} byte workspace .env limit",
2633 path.display(),
2634 MAX_WORKSPACE_DOTENV_BYTES
2635 );
2636 }
2637
2638 let mut contents = Vec::with_capacity(metadata.len() as usize);
2639 (&mut file)
2640 .take(MAX_WORKSPACE_DOTENV_BYTES + 1)
2641 .read_to_end(&mut contents)
2642 .map_err(|error| anyhow!("could not read {}: {error}", path.display()))?;
2643 if contents.len() as u64 > MAX_WORKSPACE_DOTENV_BYTES {
2644 bail!(
2645 "{} exceeds the {} byte workspace .env limit",
2646 path.display(),
2647 MAX_WORKSPACE_DOTENV_BYTES
2648 );
2649 }
2650 Ok(contents)
2651}
2652
2653#[cfg(unix)]
2654fn workspace_dotenv_has_multiple_links(
2655 _file: &std::fs::File,
2656 metadata: &std::fs::Metadata,
2657) -> Result<bool> {
2658 use std::os::unix::fs::MetadataExt;
2659
2660 Ok(metadata.nlink() > 1)
2661}
2662
2663#[cfg(windows)]
2664fn workspace_dotenv_has_multiple_links(
2665 file: &std::fs::File,
2666 _metadata: &std::fs::Metadata,
2667) -> Result<bool> {
2668 use std::os::windows::io::AsRawHandle;
2669 use windows::Win32::Foundation::HANDLE;
2670 use windows::Win32::Storage::FileSystem::{
2671 BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle,
2672 };
2673
2674 let mut information = BY_HANDLE_FILE_INFORMATION::default();
2675 unsafe {
2679 GetFileInformationByHandle(HANDLE(file.as_raw_handle()), &mut information)
2680 .map_err(|error| anyhow!("could not inspect workspace .env link count: {error}"))?;
2681 }
2682 Ok(information.nNumberOfLinks > 1)
2683}
2684
2685#[cfg(not(any(unix, windows)))]
2686fn workspace_dotenv_has_multiple_links(
2687 _file: &std::fs::File,
2688 _metadata: &std::fs::Metadata,
2689) -> Result<bool> {
2690 Ok(false)
2691}
2692
2693#[cfg(unix)]
2694fn open_workspace_dotenv_without_following_links(path: &Path) -> Result<std::fs::File> {
2695 use std::os::unix::fs::OpenOptionsExt;
2696
2697 std::fs::OpenOptions::new()
2698 .read(true)
2699 .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK)
2702 .open(path)
2703 .map_err(|error| anyhow!("could not securely open {}: {error}", path.display()))
2704}
2705
2706#[cfg(windows)]
2707fn open_workspace_dotenv_without_following_links(path: &Path) -> Result<std::fs::File> {
2708 use std::os::windows::fs::{MetadataExt, OpenOptionsExt};
2709
2710 const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
2711 const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
2712 let file = std::fs::OpenOptions::new()
2713 .read(true)
2714 .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
2715 .open(path)
2716 .map_err(|error| anyhow!("could not securely open {}: {error}", path.display()))?;
2717 let metadata = file
2718 .metadata()
2719 .map_err(|error| anyhow!("could not inspect {}: {error}", path.display()))?;
2720 if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
2721 bail!(
2722 "{} is a reparse point, not a workspace-owned file",
2723 path.display()
2724 );
2725 }
2726 Ok(file)
2727}
2728
2729#[cfg(not(any(unix, windows)))]
2730fn open_workspace_dotenv_without_following_links(path: &Path) -> Result<std::fs::File> {
2731 let metadata = std::fs::symlink_metadata(path)
2732 .map_err(|error| anyhow!("could not inspect {}: {error}", path.display()))?;
2733 if metadata.file_type().is_symlink() {
2734 bail!(
2735 "{} is a symbolic link, not a workspace-owned file",
2736 path.display()
2737 );
2738 }
2739 std::fs::File::open(path)
2740 .map_err(|error| anyhow!("could not securely open {}: {error}", path.display()))
2741}
2742
2743fn generate_completions(shell: Shell) {
2745 let mut cmd = Cli::command();
2746 let name = cmd.get_name().to_string();
2747 generate(shell, &mut cmd, name, &mut io::stdout());
2748}
2749
2750fn run_eval(args: EvalArgs) -> Result<()> {
2752 let fail_step = match args.fail_step.as_deref() {
2753 Some(value) => ScenarioStepKind::parse(value)
2754 .map(Some)
2755 .ok_or_else(|| anyhow!("invalid --fail-step '{value}'"))?,
2756 None => None,
2757 };
2758
2759 let config = EvalHarnessConfig {
2760 fail_step,
2761 shell_command: args.shell_command,
2762 shell_expect_token: args.shell_expect_token,
2763 max_output_chars: args.max_output_chars,
2764 record_dir: args.record.clone(),
2765 ..EvalHarnessConfig::default()
2766 };
2767
2768 let harness = EvalHarness::new(config);
2769 let run = harness.run().context("evaluation harness failed")?;
2770 let report = run.to_report();
2771
2772 if args.json {
2773 let json = serde_json::to_string_pretty(&report)?;
2774 println!("{json}");
2775 } else {
2776 println!("Offline Eval Harness");
2777 println!("scenario: {}", report.scenario_name);
2778 println!("workspace: {}", report.workspace_root.display());
2779 println!("success: {}", report.metrics.success);
2780 println!("steps: {}", report.metrics.steps);
2781 println!("tool_errors: {}", report.metrics.tool_errors);
2782 println!("duration_ms: {}", report.metrics.duration.as_millis());
2783
2784 if !report.metrics.per_tool.is_empty() {
2785 println!("per_tool:");
2786 for (kind, stats) in &report.metrics.per_tool {
2787 println!(
2788 " {} invocations={} errors={} duration_ms={}",
2789 kind.tool_name(),
2790 stats.invocations,
2791 stats.errors,
2792 stats.total_duration.as_millis()
2793 );
2794 }
2795 }
2796
2797 let failed_steps: Vec<_> = report.steps.iter().filter(|s| !s.success).collect();
2798 if !failed_steps.is_empty() {
2799 println!("failed_steps:");
2800 for step in failed_steps {
2801 let error = step.error.as_deref().unwrap_or("unknown error");
2802 println!(
2803 " {} tool={} error={}",
2804 step.kind.tool_name(),
2805 step.tool_name,
2806 error
2807 );
2808 }
2809 }
2810 }
2811
2812 if report.metrics.success {
2813 Ok(())
2814 } else {
2815 bail!("offline evaluation harness reported failure")
2816 }
2817}
2818
2819fn run_scorecard(args: ScorecardArgs) -> Result<()> {
2825 use crate::scorecard::{RecordedTurn, Scorecard, ScorecardMetrics};
2826
2827 let raw = std::fs::read_to_string(&args.input)
2828 .with_context(|| format!("failed to read scorecard input {}", args.input.display()))?;
2829 let recorded: Vec<RecordedTurn> = serde_json::from_str(&raw)
2830 .with_context(|| format!("failed to parse scorecard input {}", args.input.display()))?;
2831
2832 let card = Scorecard::from_recorded_turns(&recorded);
2833
2834 let regressions = match &args.baseline {
2835 Some(path) => {
2836 let baseline_raw = std::fs::read_to_string(path)
2837 .with_context(|| format!("failed to read baseline {}", path.display()))?;
2838 let baseline: ScorecardMetrics = serde_json::from_str(&baseline_raw)
2839 .with_context(|| format!("failed to parse baseline {}", path.display()))?;
2840 card.metrics.regressions_against(&baseline, args.threshold)
2841 }
2842 None => Vec::new(),
2843 };
2844
2845 if args.json {
2846 let out = serde_json::json!({
2847 "per_turn": card.per_turn,
2848 "metrics": card.metrics,
2849 "regressions": regressions,
2850 });
2851 println!("{}", serde_json::to_string_pretty(&out)?);
2852 } else {
2853 print!("{}", card.to_summary());
2854 for r in ®ressions {
2855 println!(
2856 "REGRESSION {}: baseline {:.4} -> current {:.4} (+{:.1}%)",
2857 r.metric, r.baseline, r.current, r.pct_increase
2858 );
2859 }
2860 }
2861
2862 if regressions.is_empty() {
2863 Ok(())
2864 } else {
2865 bail!(
2866 "{} metric(s) regressed past the {:.1}% threshold",
2867 regressions.len(),
2868 args.threshold
2869 )
2870 }
2871}
2872
2873async fn run_fleet_command(workspace: &Path, config: &Config, args: FleetArgs) -> Result<()> {
2874 use crate::fleet::alerts::{
2875 FleetAlertAdapterConfig, FleetAlertConfig, FleetAlertDispatcher, FleetAlertEvent,
2876 FleetEnvSecretResolver,
2877 };
2878 use crate::fleet::control as fleet_control;
2879 use crate::fleet::executor::FleetExecutor;
2880 use crate::fleet::manager::{FleetManager, FleetStatusSnapshot, FleetWorkerInspection};
2881 use codewhale_lane::{ControlOperation, ControlSurface};
2882 use codewhale_protocol::fleet::{FleetAlertEventClass, FleetArtifactKind, FleetRunId};
2883
2884 fn print_status(status: &FleetStatusSnapshot) {
2888 println!("{}", fleet_control::render_fleet_status_snapshot(status));
2889 }
2890
2891 fn print_inspection(inspection: &FleetWorkerInspection) {
2892 println!("{}", fleet_control::render_inspection(inspection));
2893 }
2894
2895 fn print_artifacts(inspection: &FleetWorkerInspection) {
2896 println!("{}", fleet_control::render_artifacts(inspection));
2897 }
2898
2899 fn emit_fleet_receipt(receipt: &codewhale_lane::ControlReceipt) -> Result<()> {
2901 if receipt.is_error() {
2902 eprintln!("{}", receipt.render());
2903 let detail = receipt
2904 .failure
2905 .as_ref()
2906 .map(ToString::to_string)
2907 .unwrap_or_else(|| receipt.outcome.as_str().to_string());
2908 bail!("{}: {detail}", receipt.operation_id);
2909 }
2910 println!("{}", receipt.render());
2911 Ok(())
2912 }
2913
2914 fn print_logs(workspace: &Path, inspection: &FleetWorkerInspection) -> Result<()> {
2915 let mut printed = false;
2916 for artifact in inspection
2917 .artifacts
2918 .iter()
2919 .filter(|artifact| matches!(artifact.kind, FleetArtifactKind::Log))
2920 {
2921 let path = workspace.join(&artifact.path);
2922 println!("== {} ==", artifact.path.display());
2923 let contents = std::fs::read_to_string(&path)
2924 .with_context(|| format!("reading fleet log {}", path.display()))?;
2925 let preview: String = contents.chars().take(16 * 1024).collect();
2926 let mut safe_preview = String::with_capacity(preview.len());
2931 crate::tui::osc8::strip_ansi_into(&preview, &mut safe_preview);
2932 print!("{safe_preview}");
2933 if contents.chars().count() > preview.chars().count() {
2934 println!("\n[truncated]");
2935 } else if !preview.ends_with('\n') {
2936 println!();
2937 }
2938 printed = true;
2939 }
2940 if !printed {
2941 println!("logs: none");
2942 }
2943 Ok(())
2944 }
2945
2946 fn alert_event_class(arg: FleetAlertEventArg) -> FleetAlertEventClass {
2947 match arg {
2948 FleetAlertEventArg::Stale => FleetAlertEventClass::Stale,
2949 FleetAlertEventArg::RestartExhausted => FleetAlertEventClass::RestartExhausted,
2950 FleetAlertEventArg::NeedsHuman => FleetAlertEventClass::NeedsHuman,
2951 FleetAlertEventArg::BudgetExceeded => FleetAlertEventClass::BudgetExceeded,
2952 FleetAlertEventArg::VerifierFailed => FleetAlertEventClass::VerifierFailed,
2953 FleetAlertEventArg::RunCompleted => FleetAlertEventClass::RunCompleted,
2954 }
2955 }
2956
2957 fn alert_status(class: FleetAlertEventClass, override_status: Option<String>) -> String {
2958 if let Some(status) = override_status {
2959 return status;
2960 }
2961 match class {
2962 FleetAlertEventClass::Stale => "stale",
2963 FleetAlertEventClass::RestartExhausted => "failed",
2964 FleetAlertEventClass::NeedsHuman => "needs_human",
2965 FleetAlertEventClass::BudgetExceeded => "budget_exceeded",
2966 FleetAlertEventClass::VerifierFailed => "verifier_failed",
2967 FleetAlertEventClass::RunCompleted => "completed",
2968 }
2969 .to_string()
2970 }
2971
2972 fn alert_adapter(args: &FleetAlertDryRunArgs) -> FleetAlertAdapterConfig {
2973 match args.adapter {
2974 FleetAlertAdapterArg::Slack => FleetAlertAdapterConfig::Slack {
2975 webhook_env: args.slack_webhook_env.clone(),
2976 channel: None,
2977 },
2978 FleetAlertAdapterArg::Webhook => FleetAlertAdapterConfig::Webhook {
2979 url_env: args.webhook_url_env.clone(),
2980 secret_env: args.webhook_secret_env.clone(),
2981 },
2982 FleetAlertAdapterArg::PagerDuty => FleetAlertAdapterConfig::PagerDuty {
2983 routing_key_env: args.pagerduty_routing_key_env.clone(),
2984 severity: args.pagerduty_severity.clone(),
2985 },
2986 }
2987 }
2988
2989 let fleet_config = config.fleet_config();
2990 let provider = config.api_provider();
2991 let max_subagents = config.max_subagents_for_provider(provider);
2992 let coordination_manager = crate::tools::subagent::new_shared_subagent_manager_with_timeout(
2993 workspace.to_path_buf(),
2994 max_subagents,
2995 config
2996 .max_admitted_subagents_for_provider(provider)
2997 .max(max_subagents),
2998 Duration::from_secs(config.subagent_heartbeat_timeout_secs_for_provider(provider)),
2999 config.launch_concurrency_for_provider(provider),
3000 config.subagent_token_budget_for_provider(provider),
3001 );
3002 let fleet_context = fleet_control::fleet_control_context(workspace);
3007 if let Some(operation) = match &args.command {
3016 FleetCommand::List => Some(ControlOperation::FleetList),
3017 FleetCommand::Status => Some(ControlOperation::FleetStatus),
3018 FleetCommand::Interrupt { .. } => Some(ControlOperation::FleetInterrupt),
3019 FleetCommand::Resume { .. } => Some(ControlOperation::FleetResume),
3020 _ => None,
3021 } {
3022 let descriptor = operation.descriptor();
3023 let availability = descriptor.availability(ControlSurface::Cli, fleet_context);
3024 if !availability.is_available() {
3025 return emit_fleet_receipt(&codewhale_lane::ControlReceipt::unavailable(
3026 descriptor,
3027 ControlSurface::Cli,
3028 availability,
3029 ));
3030 }
3031 }
3032
3033 let manager = FleetManager::open(workspace)?
3036 .with_exec_config(fleet_config.exec.clone())
3037 .with_fleet_config(fleet_config)
3038 .with_sub_agent_manager(coordination_manager)
3039 .with_session_model(config.default_model())
3040 .with_route_config(config.clone());
3041 match args.command {
3042 FleetCommand::Init => {
3043 println!("fleet ledger: {}", manager.ledger_path().display());
3044 Ok(())
3045 }
3046 FleetCommand::Run(args) => {
3047 let max_workers = args.max_workers.clamp(1, 128);
3048 let manager =
3049 manager.with_stale_after(Duration::from_secs(args.stale_after_seconds.max(1)));
3050 let report = manager.create_run_from_task_spec_path(&args.task_spec, max_workers)?;
3051 println!(
3052 "fleet run: {} tasks={} leased={} queued={}",
3053 report.run_id.0, report.task_count, report.leased, report.queued
3054 );
3055 for warning in &report.warnings {
3056 println!("warning: {warning}");
3057 }
3058 println!("workers:");
3059 for worker_id in &report.worker_ids {
3060 println!(" {worker_id}");
3061 }
3062 if args.once {
3063 print_status(&manager.run_status(&report.run_id)?);
3064 return Ok(());
3065 }
3066 println!(
3067 "manager loop running; use `codewhale fleet status`, `inspect`, `interrupt`, or `stop --all` from another terminal."
3068 );
3069 let mut executor = FleetExecutor::new(workspace);
3070 let codewhale_binary = fleet::executor::configured_codewhale_binary();
3071 let status = manager
3072 .run_to_completion(
3073 &report.run_id,
3074 max_workers,
3075 &mut executor,
3076 &codewhale_binary,
3077 None,
3078 Duration::from_secs(2),
3079 )
3080 .await?;
3081 print_status(&status);
3082 Ok(())
3083 }
3084 FleetCommand::List => emit_fleet_receipt(&fleet_control::execute_fleet_control_with(
3085 ControlSurface::Cli,
3086 workspace,
3087 fleet_context,
3088 &manager,
3089 ControlOperation::FleetList,
3090 None,
3091 )),
3092 FleetCommand::Status => emit_fleet_receipt(&fleet_control::execute_fleet_control_with(
3093 ControlSurface::Cli,
3094 workspace,
3095 fleet_context,
3096 &manager,
3097 ControlOperation::FleetStatus,
3098 None,
3099 )),
3100 FleetCommand::Inspect { worker_id } => {
3101 print_inspection(&manager.inspect_worker(&worker_id)?);
3102 Ok(())
3103 }
3104 FleetCommand::Logs { worker_id } => {
3105 let inspection = manager.inspect_worker(&worker_id)?;
3106 print_logs(workspace, &inspection)
3107 }
3108 FleetCommand::Artifacts { worker_id } => {
3109 let inspection = manager.inspect_worker(&worker_id)?;
3110 print_artifacts(&inspection);
3111 Ok(())
3112 }
3113 FleetCommand::Interrupt { worker_id } => {
3114 emit_fleet_receipt(&fleet_control::execute_fleet_control_with(
3115 ControlSurface::Cli,
3116 workspace,
3117 fleet_context,
3118 &manager,
3119 ControlOperation::FleetInterrupt,
3120 Some(&worker_id),
3121 ))
3122 }
3123 FleetCommand::Restart { worker_id } => {
3124 let report = manager.restart_worker(&worker_id)?;
3125 print_inspection(&report.inspection);
3126 println!(
3127 "manager loop running for restarted run {}; use `codewhale fleet status`, `inspect`, `interrupt`, or `stop --all` from another terminal.",
3128 report.run_id.0
3129 );
3130 let mut executor = FleetExecutor::new(workspace);
3131 let codewhale_binary = fleet::executor::configured_codewhale_binary();
3132 let status = manager
3133 .run_to_completion(
3134 &report.run_id,
3135 report.max_workers,
3136 &mut executor,
3137 &codewhale_binary,
3138 None,
3139 Duration::from_secs(2),
3140 )
3141 .await?;
3142 print_status(&status);
3143 Ok(())
3144 }
3145 FleetCommand::Resume {
3146 run_id,
3147 stale_after_seconds,
3148 } => {
3149 let manager = manager.with_stale_after(Duration::from_secs(stale_after_seconds.max(1)));
3150 emit_fleet_receipt(&fleet_control::execute_fleet_control_with(
3151 ControlSurface::Cli,
3152 workspace,
3153 fleet_context,
3154 &manager,
3155 ControlOperation::FleetResume,
3156 Some(&run_id),
3157 ))
3158 }
3159 FleetCommand::Stop { all } => {
3160 if !all {
3161 bail!("pass --all to stop all fleet work");
3162 }
3163 let stopped = manager.stop_all()?;
3164 println!("stopped: {stopped}");
3165 Ok(())
3166 }
3167 FleetCommand::AlertDryRun(args) => {
3168 let class = alert_event_class(args.event);
3169 let adapter = alert_adapter(&args);
3170 let event = FleetAlertEvent {
3171 class,
3172 run_id: FleetRunId::from(args.run_id.clone()),
3173 worker_id: args.worker_id.clone(),
3174 task_id: args.task_id.clone(),
3175 status: alert_status(class, args.status.clone()),
3176 reason: args.reason.clone(),
3177 };
3178 let dispatcher = FleetAlertDispatcher::new(
3179 FleetAlertConfig::dry_run_for_adapter(adapter),
3180 FleetEnvSecretResolver,
3181 );
3182 let deliveries = dispatcher.dispatch(&event)?;
3183 for delivery in deliveries {
3184 println!(
3185 "{}",
3186 serde_json::to_string_pretty(&delivery.redacted_payload)?
3187 );
3188 }
3189 Ok(())
3190 }
3191 }
3192}
3193
3194#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3195enum WriteStatus {
3196 Created,
3197 Overwritten,
3198 SkippedExists,
3199}
3200
3201fn ensure_parent_dir(path: &Path) -> Result<()> {
3202 if let Some(parent) = path.parent()
3203 && !parent.as_os_str().is_empty()
3204 {
3205 std::fs::create_dir_all(parent)
3206 .with_context(|| format!("Failed to create directory for {}", parent.display()))?;
3207 }
3208 Ok(())
3209}
3210
3211fn write_template_file(path: &Path, contents: &str, force: bool) -> Result<WriteStatus> {
3212 ensure_parent_dir(path)?;
3213
3214 if path.exists() && !force {
3215 return Ok(WriteStatus::SkippedExists);
3216 }
3217
3218 let status = if path.exists() {
3219 WriteStatus::Overwritten
3220 } else {
3221 WriteStatus::Created
3222 };
3223
3224 std::fs::write(path, contents)
3225 .with_context(|| format!("Failed to write template at {}", path.display()))?;
3226
3227 Ok(status)
3228}
3229
3230fn mcp_template_json() -> Result<String> {
3231 let mut cfg = McpConfig::default();
3232 cfg.servers.insert(
3233 "example".to_string(),
3234 McpServerConfig {
3235 command: Some("node".to_string()),
3236 args: vec!["./path/to/your-mcp-server.js".to_string()],
3237 env: std::collections::HashMap::new(),
3238 cwd: None,
3239 url: None,
3240 transport: None,
3241 connect_timeout: None,
3242 execute_timeout: None,
3243 read_timeout: None,
3244 disabled: true,
3245 enabled: true,
3246 required: false,
3247 enabled_tools: Vec::new(),
3248 disabled_tools: Vec::new(),
3249 headers: std::collections::HashMap::new(),
3250 env_headers: std::collections::HashMap::new(),
3251 bearer_token_env_var: None,
3252 scopes: Vec::new(),
3253 oauth: None,
3254 oauth_resource: None,
3255 reviewed_plugin: None,
3256 },
3257 );
3258 serde_json::to_string_pretty(&cfg)
3259 .map_err(|e| anyhow!("Failed to render MCP template JSON: {e}"))
3260}
3261
3262fn init_mcp_config(path: &Path, force: bool) -> Result<WriteStatus> {
3263 let template = mcp_template_json()?;
3264 write_template_file(path, &template, force)
3265}
3266
3267fn skills_template(name: &str) -> String {
3268 format!(
3269 "\
3270---\n\
3271name: {name}\n\
3272description: Quick repo diagnostics and setup guidance\n\
3273allowed-tools: diagnostics, list_dir, read_file, grep_files, git_status, git_diff\n\
3274---\n\n\
3275When this skill is active:\n\
32761. Run the diagnostics tool to report workspace and sandbox status.\n\
32772. Skim key project files (README.md, Cargo.toml, AGENTS.md) before editing.\n\
32783. Prefer small, validated changes and summarize what you verified.\n\
3279"
3280 )
3281}
3282
3283fn init_skills_dir(skills_dir: &Path, force: bool) -> Result<(PathBuf, WriteStatus)> {
3284 std::fs::create_dir_all(skills_dir)
3285 .with_context(|| format!("Failed to create skills dir {}", skills_dir.display()))?;
3286
3287 let skill_name = "getting-started";
3288 let skill_path = skills_dir.join(skill_name).join("SKILL.md");
3289 ensure_parent_dir(&skill_path)?;
3290
3291 let status = write_template_file(&skill_path, &skills_template(skill_name), force)?;
3292 Ok((skill_path, status))
3293}
3294
3295fn tools_readme_template() -> &'static str {
3296 "# Local tools\n\n\
3297 Drop self-describing scripts here so they can be discovered by\n\
3298 `codewhale-tui setup --status` and surfaced in `codewhale-tui doctor`.\n\n\
3299 When `[tools.plugin_dir]` is set in config.toml (or when the default\n\
3300 `~/.codewhale/tools/` directory exists), they are auto-discovered and\n\
3301 registered as model-visible tools.\n\n\
3302 Each script should start with a frontmatter-style header so the\n\
3303 description is visible without executing the file and the agent knows\n\
3304 the tool name, description, and input schema:\n\n\
3305 ```\n\
3306 # name: my-tool\n\
3307 # description: One-line summary of what this tool does\n\
3308 # usage: my-tool [args...]\n\
3309 ```\n\n\
3310 The directory is intentionally not auto-loaded into the agent's tool\n\
3311 catalog. Wire individual tools through MCP, hooks, or skills when you\n\
3312 want them available inside a session.\n"
3313}
3314
3315fn tools_example_script() -> &'static str {
3316 "#!/usr/bin/env sh\n\
3317 # name: example\n\
3318 # description: Print a confirmation that local tool discovery works\n\
3319 # usage: example [name]\n\
3320 printf 'codewhale-tui local tool ok: %s\\n' \"${1:-world}\"\n"
3321}
3322
3323fn init_tools_dir(tools_dir: &Path, force: bool) -> Result<(PathBuf, WriteStatus, WriteStatus)> {
3324 std::fs::create_dir_all(tools_dir)
3325 .with_context(|| format!("Failed to create tools dir {}", tools_dir.display()))?;
3326
3327 let readme_path = tools_dir.join("README.md");
3328 let readme_status = write_template_file(&readme_path, tools_readme_template(), force)?;
3329
3330 let example_path = tools_dir.join("example.sh");
3331 let example_status = write_template_file(&example_path, tools_example_script(), force)?;
3332
3333 Ok((tools_dir.to_path_buf(), readme_status, example_status))
3334}
3335
3336fn plugins_readme_template() -> &'static str {
3337 "# Local plugins\n\n\
3338 Each Codewhale plugin bundle lives in its own subdirectory with a\n\
3339 versioned `plugin.toml`. User bundles live here; workspace bundles live\n\
3340 under `<workspace>/.codewhale/plugins/`. Both are discovered read-only,\n\
3341 untrusted, and disabled by default.\n\n\
3342 A v0.9.1 bundle layout looks like:\n\n\
3343 ```\n\
3344 plugins/\n\
3345 my-plugin/\n\
3346 plugin.toml\n\
3347 skills/\n\
3348 my-skill/SKILL.md\n\
3349 ```\n\n\
3350 Run `/plugin validate`, `/plugin show <name>`, then `/plugin enable <name>`.\n\
3351 Enablement opens a content- and capability-bound trust review;\n\
3352 confirm the displayed `/plugin trust` command to create an owner-only,\n\
3353 content-addressed runtime snapshot, then enable the bundle. Remote MCP\n\
3354 authentication must name environment sources; never store secret values\n\
3355 in `plugin.toml`.\n\n\
3356 Codewhale activates declarative Skills, MCP servers, Commands, Agent\n\
3357 profiles, and Hooks through their existing engines. LSP, native\n\
3358 extensions, filesystem grants, and lifecycle mutation stay inventoried\n\
3359 and inactive; a mixed bundle can still activate supported components.\n\
3360 Marketplace catalogs, install, update, and uninstall all feed this same\n\
3361 disabled-and-untrusted review path; none grants automatic trust. Codewhale\n\
3362 does not scan other applications for ambient plugins.\n"
3363}
3364
3365fn plugin_example_manifest_template() -> &'static str {
3366 "schema_version = 1\n\n\
3367 [plugin]\n\
3368 name = \"example\"\n\
3369 version = \"0.1.0\"\n\
3370 description = \"Starter Codewhale plugin bundle\"\n\n\
3371 [skills]\n\
3372 path = \"skills\"\n"
3373}
3374
3375fn plugin_example_skill_template() -> &'static str {
3376 "---\n\
3377 name: hello\n\
3378 description: Explain that the example plugin bundle is active.\n\
3379 ---\n\n\
3380 Tell the user this instruction came from the namespaced\n\
3381 `example:hello` plugin skill. Do not perform side effects.\n"
3382}
3383
3384fn init_plugins_dir(
3385 plugins_dir: &Path,
3386 force: bool,
3387) -> Result<(
3388 PathBuf,
3389 PathBuf,
3390 PathBuf,
3391 WriteStatus,
3392 WriteStatus,
3393 WriteStatus,
3394)> {
3395 std::fs::create_dir_all(plugins_dir)
3396 .with_context(|| format!("Failed to create plugins dir {}", plugins_dir.display()))?;
3397
3398 let readme_path = plugins_dir.join("README.md");
3399 let readme_status = write_template_file(&readme_path, plugins_readme_template(), force)?;
3400
3401 let manifest_path = plugins_dir.join("example").join("plugin.toml");
3402 ensure_parent_dir(&manifest_path)?;
3403 let manifest_status =
3404 write_template_file(&manifest_path, plugin_example_manifest_template(), force)?;
3405
3406 let skill_path = plugins_dir
3407 .join("example")
3408 .join("skills")
3409 .join("hello")
3410 .join("SKILL.md");
3411 ensure_parent_dir(&skill_path)?;
3412 let skill_status = write_template_file(&skill_path, plugin_example_skill_template(), force)?;
3413
3414 Ok((
3415 readme_path,
3416 manifest_path,
3417 skill_path,
3418 readme_status,
3419 manifest_status,
3420 skill_status,
3421 ))
3422}
3423
3424fn resolve_cors_origins(config: &Config, flag_origins: &[String]) -> Vec<String> {
3437 let mut out: Vec<String> = Vec::new();
3438 let mut push = |raw: &str| {
3439 let trimmed = raw.trim();
3440 if trimmed.is_empty() {
3441 return;
3442 }
3443 if !out.iter().any(|existing| existing == trimmed) {
3444 out.push(trimmed.to_string());
3445 }
3446 };
3447 for o in flag_origins {
3448 push(o);
3449 }
3450 if let Ok(env_value) =
3451 std::env::var("CODEWHALE_CORS_ORIGINS").or_else(|_| std::env::var("DEEPSEEK_CORS_ORIGINS"))
3452 {
3453 for piece in env_value.split(',') {
3454 push(piece);
3455 }
3456 }
3457 if let Some(rt) = &config.runtime_api
3458 && let Some(list) = &rt.cors_origins
3459 {
3460 for o in list {
3461 push(o);
3462 }
3463 }
3464 out
3465}
3466
3467fn deepseek_home_dir() -> PathBuf {
3468 codewhale_config::codewhale_home().unwrap_or_else(|_| {
3469 crate::config::effective_home_dir()
3470 .map_or_else(|| PathBuf::from(".codewhale"), |h| h.join(".codewhale"))
3471 })
3472}
3473
3474fn default_tools_dir() -> PathBuf {
3476 deepseek_home_dir().join("tools")
3477}
3478
3479fn default_plugins_dir() -> PathBuf {
3481 deepseek_home_dir().join("plugins")
3482}
3483
3484fn default_checkpoints_dir() -> PathBuf {
3486 deepseek_home_dir().join("sessions").join("checkpoints")
3487}
3488
3489#[derive(Debug, Clone, PartialEq, Eq)]
3490struct CleanPlan {
3491 targets: Vec<PathBuf>,
3492}
3493
3494fn collect_clean_targets(checkpoints_dir: &Path) -> CleanPlan {
3495 let mut targets: Vec<PathBuf> = std::fs::read_dir(checkpoints_dir)
3501 .map(|entries| {
3502 entries
3503 .filter_map(|entry| entry.ok().map(|e| e.path()))
3504 .filter(|p| p.is_file() && p.extension().is_some_and(|ext| ext == "json"))
3505 .collect()
3506 })
3507 .unwrap_or_default();
3508 targets.sort();
3509 CleanPlan { targets }
3510}
3511
3512fn execute_clean_plan(plan: &CleanPlan) -> Result<Vec<PathBuf>> {
3513 let mut removed = Vec::with_capacity(plan.targets.len());
3514 for path in &plan.targets {
3515 std::fs::remove_file(path)
3516 .with_context(|| format!("Failed to remove {}", path.display()))?;
3517 removed.push(path.clone());
3518 }
3519 Ok(removed)
3520}
3521
3522fn run_setup(
3523 config: &Config,
3524 workspace: &Path,
3525 args: SetupArgs,
3526 plugins: &crate::plugins::PluginRegistry,
3527) -> Result<()> {
3528 if args.status {
3529 return run_setup_status(config, workspace, plugins);
3530 }
3531 if args.clean {
3532 return run_setup_clean(&default_checkpoints_dir(), args.force);
3533 }
3534
3535 use crate::palette;
3536 use colored::Colorize;
3537
3538 let (aqua_r, aqua_g, aqua_b) = palette::WHALE_INFO_RGB;
3539 let (sky_r, sky_g, sky_b) = palette::WHALE_INFO_RGB;
3540
3541 let any_explicit = args.mcp || args.skills || args.tools || args.plugins;
3542 let run_mcp = args.mcp || args.all || !any_explicit;
3543 let run_skills = args.skills || args.all || !any_explicit;
3544 let run_tools = args.tools || args.all;
3545 let run_plugins = args.plugins || args.all;
3546
3547 println!(
3548 "{}",
3549 "Codewhale Setup".truecolor(aqua_r, aqua_g, aqua_b).bold()
3550 );
3551 println!("{}", "==============".truecolor(sky_r, sky_g, sky_b));
3552 println!("Workspace: {}", crate::utils::display_path(workspace));
3553
3554 if run_mcp {
3555 let mcp_path = config.mcp_config_path();
3556 let status = init_mcp_config(&mcp_path, args.force)?;
3557 match status {
3558 WriteStatus::Created => {
3559 println!(" ✓ Created MCP config at {}", mcp_path.display());
3560 }
3561 WriteStatus::Overwritten => {
3562 println!(" ✓ Overwrote MCP config at {}", mcp_path.display());
3563 }
3564 WriteStatus::SkippedExists => {
3565 println!(" · MCP config already exists at {}", mcp_path.display());
3566 }
3567 }
3568 println!(
3569 " Next: edit the file, then run `codewhale mcp list` or `codewhale mcp tools`."
3570 );
3571 }
3572
3573 if run_skills {
3574 let skills_dir = if args.local {
3575 workspace.join("skills")
3576 } else {
3577 config.skills_dir()
3578 };
3579 let (skill_path, status) = init_skills_dir(&skills_dir, args.force)?;
3580 match status {
3581 WriteStatus::Created => {
3582 println!(" ✓ Created example skill at {}", skill_path.display());
3583 }
3584 WriteStatus::Overwritten => {
3585 println!(" ✓ Overwrote example skill at {}", skill_path.display());
3586 }
3587 WriteStatus::SkippedExists => {
3588 println!(
3589 " · Example skill already exists at {}",
3590 skill_path.display()
3591 );
3592 }
3593 }
3594 if args.local {
3595 println!(
3596 " Local skills dir enabled for this workspace: {}",
3597 crate::utils::display_path(&skills_dir)
3598 );
3599 } else {
3600 println!(
3601 " Skills dir: {}",
3602 crate::utils::display_path(&skills_dir)
3603 );
3604 }
3605 println!(" Next: run the TUI and use `/skills` then `/skill getting-started`.");
3606 }
3607
3608 if run_tools {
3609 let tools_dir = default_tools_dir();
3610 let (dir, readme_status, example_status) = init_tools_dir(&tools_dir, args.force)?;
3611 report_write_status("Tools README", &dir.join("README.md"), readme_status);
3612 report_write_status("Example tool", &dir.join("example.sh"), example_status);
3613 println!(" Tools dir: {}", crate::utils::display_path(&dir));
3614 println!(" Next: drop scripts here; surface them via skills/MCP when ready.");
3615 }
3616
3617 if run_plugins {
3618 let plugins_dir = default_plugins_dir();
3619 let (readme_path, manifest_path, skill_path, readme_status, manifest_status, skill_status) =
3620 init_plugins_dir(&plugins_dir, args.force)?;
3621 report_write_status("Plugins README", &readme_path, readme_status);
3622 report_write_status("Example plugin manifest", &manifest_path, manifest_status);
3623 report_write_status("Example plugin skill", &skill_path, skill_status);
3624 println!(
3625 " Plugins dir: {}",
3626 crate::utils::display_path(&plugins_dir)
3627 );
3628 println!(" Next: run `/plugin validate`, review `example`, then trust and enable it.");
3629 }
3630
3631 let sandbox = crate::sandbox::get_platform_sandbox_with_bwrap_preference(
3632 config.prefer_bwrap.unwrap_or(false),
3633 );
3634 if let Some(kind) = sandbox {
3635 println!(" ✓ Sandbox available: {kind}");
3636 } else {
3637 println!(" · Sandbox not available on this platform (best-effort only).");
3638 }
3639
3640 Ok(())
3641}
3642
3643fn report_write_status(label: &str, path: &Path, status: WriteStatus) {
3644 match status {
3645 WriteStatus::Created => {
3646 println!(" ✓ Created {label} at {}", path.display());
3647 }
3648 WriteStatus::Overwritten => {
3649 println!(" ✓ Overwrote {label} at {}", path.display());
3650 }
3651 WriteStatus::SkippedExists => {
3652 println!(" · {label} already exists at {}", path.display());
3653 }
3654 }
3655}
3656
3657#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3662enum ApiKeySource {
3663 ConfigDeclared,
3664 EnvDeclared,
3665 ExternalAuthDeclared,
3666 SecretStoreUnprobed,
3667 SecretStoreUnavailable,
3668 OAuth,
3669 ExternalConsent,
3670 NoAuth,
3671 LocalRuntime,
3672 Unknown,
3673}
3674
3675#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3678enum CredentialAvailability {
3679 Present,
3680 NotRequired,
3681 Unknown,
3682 NotProbed,
3683 Unavailable,
3684}
3685
3686impl CredentialAvailability {
3687 fn label(self) -> &'static str {
3688 match self {
3689 Self::Present => "present",
3690 Self::NotRequired => "not_required",
3691 Self::Unknown => "unknown",
3692 Self::NotProbed => "not_probed",
3693 Self::Unavailable => "unavailable",
3694 }
3695 }
3696
3697 fn certifies_ready(self) -> bool {
3698 matches!(self, Self::Present | Self::NotRequired)
3699 }
3700}
3701
3702#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3703struct CredentialDiagnostic {
3704 source: ApiKeySource,
3705 availability: CredentialAvailability,
3706}
3707
3708impl CredentialDiagnostic {
3709 const fn new(source: ApiKeySource, availability: CredentialAvailability) -> Self {
3710 Self {
3711 source,
3712 availability,
3713 }
3714 }
3715}
3716
3717fn resolve_credential_diagnostic(config: &Config) -> CredentialDiagnostic {
3718 let provider = config.api_provider();
3719 let base_url = config.deepseek_base_url();
3720 let auth_mode = config.auth_mode_for_provider(provider);
3721 if crate::config::auth_mode_disables_api_key(auth_mode.as_deref()) {
3722 return CredentialDiagnostic::new(
3723 ApiKeySource::NoAuth,
3724 CredentialAvailability::NotRequired,
3725 );
3726 }
3727 if !crate::config::auth_mode_requires_api_key(auth_mode.as_deref())
3728 && (crate::config::provider_route_is_keyless_self_hosted(provider, &base_url)
3729 || crate::config::base_url_uses_local_host(&base_url))
3730 {
3731 return CredentialDiagnostic::new(
3732 ApiKeySource::LocalRuntime,
3733 CredentialAvailability::NotRequired,
3734 );
3735 }
3736 let custom_endpoint = config.provider_uses_custom_endpoint(provider);
3737 if !custom_endpoint && provider == crate::config::ApiProvider::OpenaiCodex {
3738 return config
3739 .external_credential_consent_status(provider)
3740 .filter(|status| status.route_state == "active")
3741 .map_or_else(
3742 || {
3743 CredentialDiagnostic::new(
3744 ApiKeySource::OAuth,
3745 CredentialAvailability::NotProbed,
3746 )
3747 },
3748 |_| {
3749 CredentialDiagnostic::new(
3750 ApiKeySource::ExternalConsent,
3751 CredentialAvailability::NotProbed,
3752 )
3753 },
3754 );
3755 }
3756 if !custom_endpoint
3757 && provider == crate::config::ApiProvider::Xai
3758 && auth_mode
3759 .as_deref()
3760 .is_some_and(crate::xai_oauth::auth_mode_uses_xai_oauth)
3761 {
3762 return config
3763 .external_credential_consent_status(provider)
3764 .filter(|status| status.route_state == "active")
3765 .map_or_else(
3766 || {
3767 CredentialDiagnostic::new(
3768 ApiKeySource::OAuth,
3769 CredentialAvailability::NotProbed,
3770 )
3771 },
3772 |_| {
3773 CredentialDiagnostic::new(
3774 ApiKeySource::ExternalConsent,
3775 CredentialAvailability::NotProbed,
3776 )
3777 },
3778 );
3779 }
3780 let provider_config = config.provider_config();
3781 let provider_config_key_kind = provider_config
3782 .and_then(|entry| entry.api_key.as_deref())
3783 .map(crate::config::classify_config_api_key_value);
3784 let root_key_applies = matches!(
3785 provider,
3786 crate::config::ApiProvider::Deepseek | crate::config::ApiProvider::DeepseekCN
3787 ) || (provider == crate::config::ApiProvider::Custom
3788 && config.uses_legacy_literal_custom_route());
3789 let root_key_kind = root_key_applies
3790 .then_some(config.api_key.as_deref())
3791 .flatten()
3792 .map(crate::config::classify_config_api_key_value);
3793
3794 if matches!(
3795 provider_config_key_kind,
3796 Some(crate::config::ConfigApiKeyValueKind::Literal)
3797 ) || matches!(
3798 root_key_kind,
3799 Some(crate::config::ConfigApiKeyValueKind::Literal)
3800 ) {
3801 CredentialDiagnostic::new(
3802 ApiKeySource::ConfigDeclared,
3803 CredentialAvailability::Present,
3804 )
3805 } else if config
3806 .provider_config()
3807 .and_then(|entry| entry.api_key_env.as_deref())
3808 .is_some_and(|name| !name.trim().is_empty())
3809 {
3810 CredentialDiagnostic::new(ApiKeySource::EnvDeclared, CredentialAvailability::NotProbed)
3811 } else if config
3812 .provider_config()
3813 .and_then(|entry| entry.auth.as_ref())
3814 .is_some()
3815 {
3816 CredentialDiagnostic::new(
3817 ApiKeySource::ExternalAuthDeclared,
3818 CredentialAvailability::NotProbed,
3819 )
3820 } else if matches!(
3821 provider_config_key_kind,
3822 Some(crate::config::ConfigApiKeyValueKind::SecretStoreSentinel)
3823 ) || matches!(
3824 root_key_kind,
3825 Some(crate::config::ConfigApiKeyValueKind::SecretStoreSentinel)
3826 ) {
3827 if config.should_skip_secret_store_for_provider(provider) {
3828 return CredentialDiagnostic::new(
3829 ApiKeySource::SecretStoreUnavailable,
3830 CredentialAvailability::Unavailable,
3831 );
3832 }
3833 CredentialDiagnostic::new(
3836 ApiKeySource::SecretStoreUnprobed,
3837 CredentialAvailability::NotProbed,
3838 )
3839 } else if !config.should_skip_secret_store_for_provider(provider) {
3840 CredentialDiagnostic::new(
3844 ApiKeySource::SecretStoreUnprobed,
3845 CredentialAvailability::NotProbed,
3846 )
3847 } else {
3848 CredentialDiagnostic::new(ApiKeySource::Unknown, CredentialAvailability::Unknown)
3849 }
3850}
3851
3852#[cfg(test)]
3853fn resolve_api_key_source(config: &Config) -> ApiKeySource {
3854 resolve_credential_diagnostic(config).source
3855}
3856
3857fn provider_config_table_key(provider: crate::config::ApiProvider) -> &'static str {
3858 provider
3859 .metadata()
3860 .map(|metadata| metadata.provider_config_key())
3861 .unwrap_or("deepseek_cn")
3862}
3863
3864fn count_dir_entries(dir: &Path) -> usize {
3865 std::fs::read_dir(dir)
3866 .map(|entries| entries.filter_map(std::result::Result::ok).count())
3867 .unwrap_or(0)
3868}
3869
3870fn skills_count_for(dir: &Path) -> usize {
3871 if !dir.exists() {
3872 return 0;
3873 }
3874 crate::skills::SkillRegistry::discover(dir).len()
3875}
3876
3877fn run_setup_status(
3878 config: &Config,
3879 workspace: &Path,
3880 plugins: &crate::plugins::PluginRegistry,
3881) -> Result<()> {
3882 use crate::palette;
3883 use colored::Colorize;
3884
3885 let (aqua_r, aqua_g, aqua_b) = palette::WHALE_INFO_RGB;
3886 let (sky_r, sky_g, sky_b) = palette::WHALE_INFO_RGB;
3887
3888 println!(
3889 "{}",
3890 "Codewhale Status".truecolor(aqua_r, aqua_g, aqua_b).bold()
3891 );
3892 println!("{}", "===============".truecolor(sky_r, sky_g, sky_b));
3893 println!("workspace: {}", workspace.display());
3894
3895 let credential = resolve_credential_diagnostic(config);
3896 match credential.source {
3897 ApiKeySource::ConfigDeclared => println!(
3898 " {} api_key: literal config value structurally present",
3899 "✓".truecolor(aqua_r, aqua_g, aqua_b)
3900 ),
3901 ApiKeySource::EnvDeclared => println!(
3902 " {} api_key: environment source declared (value not inspected)",
3903 "·".dimmed()
3904 ),
3905 ApiKeySource::ExternalAuthDeclared => println!(
3906 " {} api_key: external auth source declared (value not inspected)",
3907 "·".dimmed()
3908 ),
3909 ApiKeySource::SecretStoreUnprobed => println!(
3910 " {} api_key: secret store eligible (store not probed)",
3911 "·".dimmed()
3912 ),
3913 ApiKeySource::SecretStoreUnavailable => println!(
3914 " {} api_key: secret-store sentinel declared, but this route cannot use that store",
3915 "!".truecolor(sky_r, sky_g, sky_b)
3916 ),
3917 ApiKeySource::OAuth => println!(
3918 " {} oauth: Codewhale-owned route selected (token availability not probed)",
3919 "·".dimmed()
3920 ),
3921 ApiKeySource::ExternalConsent => println!(
3922 " {} oauth: external read-only consent configured (credential file not probed)",
3923 "·".dimmed()
3924 ),
3925 ApiKeySource::NoAuth => println!(
3926 " {} api_key: disabled for this route",
3927 "✓".truecolor(aqua_r, aqua_g, aqua_b)
3928 ),
3929 ApiKeySource::LocalRuntime => println!(
3930 " {} api_key: not required for this local runtime",
3931 "✓".truecolor(aqua_r, aqua_g, aqua_b)
3932 ),
3933 ApiKeySource::Unknown => println!(
3934 " {} api_key: unknown (credential environment and durable stores not inspected)",
3935 "·".dimmed()
3936 ),
3937 }
3938 println!(
3939 " · credential availability: {}",
3940 credential.availability.label()
3941 );
3942 println!(
3943 " · base_url: {}",
3944 crate::doctor::structural_url_authority(&config.deepseek_base_url())
3945 );
3946 let model = config
3947 .default_text_model
3948 .clone()
3949 .unwrap_or_else(|| DEFAULT_TEXT_MODEL.to_string());
3950 println!(" · default_text_model: {model}");
3951 let (default_mode, default_mode_source) = doctor_runtime_default_mode();
3952 println!(" · default_mode: {default_mode} ({default_mode_source})");
3953
3954 let mcp_path = config.mcp_config_path();
3955 let project_mcp_path = crate::mcp::workspace_mcp_config_path(workspace);
3956 let mcp_count =
3957 match crate::mcp::load_config_with_workspace_and_plugins(&mcp_path, workspace, plugins) {
3958 Ok(cfg) => cfg.servers.len(),
3959 Err(_) => 0,
3960 };
3961 let mcp_present = if mcp_path.exists() { "" } else { " (missing)" };
3962 let project_mcp_present = if project_mcp_path.exists() {
3963 ""
3964 } else {
3965 " (missing)"
3966 };
3967 println!(
3968 " · mcp servers: {mcp_count} from {}{mcp_present} + {}{project_mcp_present}",
3969 mcp_path.display(),
3970 project_mcp_path.display()
3971 );
3972
3973 let skills_dir = config.skills_dir();
3974 println!(
3975 " · skills: {} at {}",
3976 skills_count_for(&skills_dir),
3977 crate::utils::display_path(&skills_dir)
3978 );
3979
3980 let tools_dir = default_tools_dir();
3981 let tools_present = if tools_dir.exists() {
3982 ""
3983 } else {
3984 " (missing — run `setup --tools`)"
3985 };
3986 println!(
3987 " · tools: {} entries at {}{tools_present}",
3988 if tools_dir.exists() {
3989 count_dir_entries(&tools_dir)
3990 } else {
3991 0
3992 },
3993 crate::utils::display_path(&tools_dir)
3994 );
3995
3996 let plugins_dir = default_plugins_dir();
3997 let plugins_present = if plugins_dir.exists() {
3998 ""
3999 } else {
4000 " (missing — run `setup --plugins`)"
4001 };
4002 println!(
4003 " · plugins: {} entries at {}{plugins_present}",
4004 if plugins_dir.exists() {
4005 count_dir_entries(&plugins_dir)
4006 } else {
4007 0
4008 },
4009 crate::utils::display_path(&plugins_dir)
4010 );
4011
4012 let sandbox = crate::sandbox::get_platform_sandbox_with_bwrap_preference(
4013 config.prefer_bwrap.unwrap_or(false),
4014 );
4015 match sandbox {
4016 Some(kind) => println!(
4017 " {} sandbox: {kind}",
4018 "✓".truecolor(aqua_r, aqua_g, aqua_b)
4019 ),
4020 None => println!(
4021 " {} sandbox: unavailable (commands run best-effort)",
4022 "!".truecolor(sky_r, sky_g, sky_b)
4023 ),
4024 }
4025
4026 println!(" {} {}", "·".dimmed(), dotenv_status_line(workspace));
4027
4028 println!();
4029 println!("Run `codewhale doctor --json` for a machine-readable check.");
4030 Ok(())
4031}
4032
4033fn dotenv_status_line(workspace: &Path) -> String {
4034 let dotenv = workspace.join(".env");
4035 if dotenv.exists() {
4036 return format!(
4037 ".env present at {} (literal provider credentials only)",
4038 dotenv.display()
4039 );
4040 }
4041
4042 if workspace.join(".env.example").exists() {
4043 return ".env not present in workspace (run `cp .env.example .env` and edit)".to_string();
4044 }
4045
4046 ".env not present in workspace".to_string()
4047}
4048
4049fn run_setup_clean(checkpoints_dir: &Path, force: bool) -> Result<()> {
4050 use colored::Colorize;
4051
4052 if !checkpoints_dir.exists() {
4053 println!(
4054 "Nothing to clean — checkpoints dir does not exist: {}",
4055 checkpoints_dir.display()
4056 );
4057 return Ok(());
4058 }
4059
4060 let plan = collect_clean_targets(checkpoints_dir);
4061 if plan.targets.is_empty() {
4062 println!(
4063 "Nothing to clean — no checkpoint files in {}",
4064 checkpoints_dir.display()
4065 );
4066 return Ok(());
4067 }
4068
4069 if !force {
4070 println!(
4071 "Would remove {} checkpoint file(s) (use --force to apply):",
4072 plan.targets.len()
4073 );
4074 for path in &plan.targets {
4075 println!(" · {}", path.display());
4076 }
4077 return Ok(());
4078 }
4079
4080 let removed = execute_clean_plan(&plan)?;
4081 println!("{}", "Cleaned checkpoints:".bold());
4082 for path in &removed {
4083 println!(" ✓ {}", path.display());
4084 }
4085 Ok(())
4086}
4087
4088fn run_session_diagnostics(args: SessionDiagnosticsArgs) -> Result<()> {
4089 let contents = std::fs::read_to_string(&args.path).with_context(|| {
4090 format!(
4091 "read session diagnostic JSONL from {}",
4092 crate::utils::display_path(&args.path)
4093 )
4094 })?;
4095 let summary = crate::session_diagnostics::analyze_session_failure_jsonl(&contents);
4096 if args.json {
4097 println!("{}", serde_json::to_string_pretty(&summary)?);
4098 } else {
4099 println!(
4100 "{}",
4101 crate::session_diagnostics::format_redacted_failure_summary(&summary)
4102 );
4103 }
4104 Ok(())
4105}
4106
4107fn doctor_should_probe_api(
4110 provider: crate::config::ApiProvider,
4111 base_url: &str,
4112 probes: crate::doctor::DoctorProbeRequest,
4113) -> bool {
4114 let local = crate::config::provider_route_is_keyless_self_hosted(provider, base_url)
4115 || crate::config::base_url_uses_local_host(base_url);
4116 probes.should_probe_api(local)
4117}
4118
4119fn doctor_should_probe_auth(config: &Config) -> bool {
4123 let provider = config.api_provider();
4124 if provider == crate::config::ApiProvider::OpenaiCodex
4125 && !config.provider_uses_custom_endpoint(provider)
4126 {
4127 return false;
4128 }
4129 let auth_mode = config.auth_mode_for_provider(provider);
4130 if provider == crate::config::ApiProvider::Xai
4131 && auth_mode
4132 .as_deref()
4133 .is_some_and(crate::xai_oauth::auth_mode_uses_xai_oauth)
4134 {
4135 return false;
4136 }
4137 !(provider == crate::config::ApiProvider::Moonshot
4138 && auth_mode
4139 .as_deref()
4140 .is_some_and(crate::config::auth_mode_uses_kimi_imported_token))
4141}
4142
4143async fn run_doctor(
4145 config: &Config,
4146 workspace: &Path,
4147 config_path_override: Option<&Path>,
4148 probes: crate::doctor::DoctorProbeRequest,
4149 plugins: &crate::plugins::PluginRegistry,
4150) {
4151 use crate::palette;
4152 use colored::Colorize;
4153
4154 let (accent_r, accent_g, accent_b) = palette::WHALE_HUMAN_RGB;
4155 let (sky_r, sky_g, sky_b) = palette::WHALE_INFO_RGB;
4156 let (aqua_r, aqua_g, aqua_b) = palette::WHALE_INFO_RGB;
4157 let (red_r, red_g, red_b) = palette::WHALE_ERROR_RGB;
4158
4159 println!(
4160 "{}",
4161 "codewhale Doctor"
4162 .truecolor(accent_r, accent_g, accent_b)
4163 .bold()
4164 );
4165 println!("{}", "==================".truecolor(sky_r, sky_g, sky_b));
4166 println!();
4167
4168 println!("{}", "Version Information:".bold());
4170 println!(" codewhale-tui: {}", env!("CODEWHALE_BUILD_VERSION"));
4171 println!(" rust: {}", rustc_version());
4172 println!();
4173
4174 println!("{}", "Updates:".bold());
4175 crate::doctor::print_update_report(probes).await;
4176 println!();
4177
4178 let doctor_paths = match crate::doctor::DoctorPathReport::resolve(config_path_override) {
4180 Ok(paths) => paths,
4181 Err(error) => {
4182 println!("{}", "Resolved User Paths:".bold());
4183 println!(
4184 " {} unavailable: {error:#}",
4185 "✗".truecolor(red_r, red_g, red_b)
4186 );
4187 return;
4188 }
4189 };
4190 println!("{}", "Configuration:".bold());
4191 let config_path = &doctor_paths.config;
4192
4193 if config_path.exists() {
4194 println!(
4195 " {} config.toml found at {}",
4196 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4197 crate::utils::display_path(config_path)
4198 );
4199 if let Ok(raw) = std::fs::read_to_string(config_path) {
4202 let flagged = crate::doctor::config_credential_shaped_keys(&raw);
4203 if !flagged.is_empty() {
4204 println!(
4205 " {} credential-shaped value(s) in config.toml ({}): move them to the secret backend, then scrub the file — config.toml is plain text",
4206 "!".truecolor(sky_r, sky_g, sky_b),
4207 flagged.join(", ")
4208 );
4209 }
4210 }
4211 } else {
4212 println!(
4213 " {} config.toml not found at {} (using defaults/env)",
4214 "!".truecolor(sky_r, sky_g, sky_b),
4215 crate::utils::display_path(config_path)
4216 );
4217 }
4218 println!(" workspace: {}", crate::utils::display_path(workspace));
4219 println!(" {}", doctor_search_provider_line(config));
4220
4221 println!();
4222 println!("{}", "Resolved User Paths (read-only):".bold());
4223 for (label, path) in doctor_paths.entries() {
4224 println!(" · {label}: {}", crate::utils::display_path(path));
4225 }
4226
4227 let secret_backend = codewhale_secrets::diagnose_secret_backend();
4228 println!();
4229 println!("{}", "Secret Backend (structural only):".bold());
4230 for line in crate::doctor::secret_backend_human_lines(&secret_backend) {
4231 println!(" · {line}");
4232 }
4233
4234 println!();
4236 println!("{}", "State Root:".bold());
4237 let (code_home, legacy_home) = doctor_state_roots();
4238 let active_root = if code_home.exists() {
4239 &code_home
4240 } else if legacy_home.exists() {
4241 &legacy_home
4242 } else {
4243 &code_home
4244 };
4245 println!(" active: {}", crate::utils::display_path(active_root));
4246 if active_root != &code_home {
4247 println!(
4248 " note: legacy {} found; start Codewhale once to trigger safe migration where available.",
4249 crate::utils::display_path(&legacy_home)
4250 );
4251 }
4252 if legacy_home.exists() && code_home.exists() {
4253 println!(
4254 " dual roots: {} (primary) + {} (legacy)",
4255 crate::utils::display_path(&code_home),
4256 crate::utils::display_path(&legacy_home)
4257 );
4258 }
4259 let legacy_state_report = doctor_legacy_state_report(&code_home, &legacy_home);
4260 let session_recovery = doctor_session_recovery_report(
4261 &code_home,
4262 &legacy_home,
4263 codewhale_config::codewhale_home_is_explicit(),
4264 );
4265 print_doctor_legacy_state_report(
4266 &legacy_state_report,
4267 &session_recovery,
4268 (aqua_r, aqua_g, aqua_b),
4269 (sky_r, sky_g, sky_b),
4270 );
4271
4272 let (setup_state, setup_source) = doctor_setup_state(config, workspace);
4273 print_doctor_setup_report(
4274 config,
4275 workspace,
4276 &setup_state,
4277 setup_source,
4278 (aqua_r, aqua_g, aqua_b),
4279 (sky_r, sky_g, sky_b),
4280 );
4281 print_doctor_fleet_roster_layers(config, workspace);
4282
4283 println!();
4285 println!("{}", "API Keys:".bold());
4286
4287 for provider in crate::config::ApiProvider::all().iter().copied() {
4291 let slot = provider.as_str();
4292 let provider_config = config.provider_config_for(provider);
4293 let config_declared = provider_config.is_some_and(|entry| {
4294 entry.api_key.as_deref().is_some_and(|key| {
4295 crate::config::classify_config_api_key_value(key)
4296 == crate::config::ConfigApiKeyValueKind::Literal
4297 })
4298 }) || (matches!(provider, crate::config::ApiProvider::Deepseek)
4299 && config.api_key.as_deref().is_some_and(|key| {
4300 crate::config::classify_config_api_key_value(key)
4301 == crate::config::ConfigApiKeyValueKind::Literal
4302 }));
4303 let env_source_declared = provider_config
4304 .and_then(|entry| entry.api_key_env.as_deref())
4305 .is_some_and(|name| !name.trim().is_empty());
4306 let icon = if config_declared || env_source_declared {
4307 "·".truecolor(aqua_r, aqua_g, aqua_b)
4308 } else {
4309 "·".dimmed()
4310 };
4311 println!(
4312 " {} {slot}: env_source={}, config_source={}",
4313 icon,
4314 if env_source_declared {
4315 "declared (value not inspected)"
4316 } else {
4317 "not inspected"
4318 },
4319 if config_declared {
4320 "declared (value not inspected)"
4321 } else {
4322 "not declared"
4323 }
4324 );
4325 }
4326 println!(" · credential precedence is unchanged; doctor does not inspect credential values");
4327 println!();
4328 println!(
4329 "{}",
4330 "External credential consent (configuration only):".bold()
4331 );
4332 for line in doctor_external_credential_consent_lines(config) {
4333 println!(" {line}");
4334 }
4335
4336 println!();
4337 println!(
4338 "{}",
4339 "DeepSeek Harness integration (read-only detection):".bold()
4340 );
4341 for line in doctor_dsh_integration_lines(config, workspace) {
4342 println!(" {line}");
4343 }
4344
4345 let credential = resolve_credential_diagnostic(config);
4346 let source_label = match credential.source {
4347 ApiKeySource::ConfigDeclared => "literal config value structurally present",
4348 ApiKeySource::EnvDeclared => "environment source declared; value not inspected",
4349 ApiKeySource::ExternalAuthDeclared => {
4350 "external auth source declared; credential not resolved"
4351 }
4352 ApiKeySource::SecretStoreUnprobed => "secret store eligible; store not probed",
4353 ApiKeySource::SecretStoreUnavailable => {
4354 "secret-store sentinel declared, but this route cannot use that store"
4355 }
4356 ApiKeySource::OAuth => "OAuth route configured; token availability not probed",
4357 ApiKeySource::ExternalConsent => "external consent configured; token file not read",
4358 ApiKeySource::NoAuth => "no-auth route",
4359 ApiKeySource::LocalRuntime => "local runtime; credentials not required",
4360 ApiKeySource::Unknown => "unknown; credential environment and stores not inspected",
4361 };
4362 println!(
4363 " {} active provider credential source: {source_label}",
4364 "·".dimmed()
4365 );
4366 println!(
4367 " · active provider credential availability: {}",
4368 credential.availability.label()
4369 );
4370
4371 println!();
4373 println!("{}", "API Connectivity:".bold());
4374 let api_target = doctor_api_target(config);
4375 println!(
4379 " · 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)"
4380 );
4381 println!(" · provider: {}", api_target.provider);
4382 println!(
4383 " · base_url: {}",
4384 crate::doctor::structural_url_authority(&api_target.base_url)
4385 );
4386 match api_target.resolution {
4387 DoctorModelResolution::Resolved => {
4388 println!(" · model: {} (resolved)", api_target.model);
4389 }
4390 DoctorModelResolution::ConfiguredOnly => {
4391 println!(
4392 " · model: {} (configured; route resolution unavailable)",
4393 api_target.model
4394 );
4395 }
4396 }
4397 let tls_status = doctor_tls_status(config);
4398 if !tls_status.certificate_verification {
4399 println!(" ! {}", tls_status.message);
4400 println!(" Prefer SSL_CERT_FILE with a trusted custom CA bundle when possible.");
4401 }
4402 let strict_tool_mode = doctor_strict_tool_mode_status(config);
4403 let strict_icon = match strict_tool_mode.status {
4404 "ready" => "✓".truecolor(aqua_r, aqua_g, aqua_b),
4405 "fallback_non_beta" | "custom_endpoint" => "!".truecolor(sky_r, sky_g, sky_b),
4406 _ => "·".dimmed(),
4407 };
4408 println!(
4409 " {} strict_tool_mode: {}",
4410 strict_icon, strict_tool_mode.message
4411 );
4412 if let Some(recommended) = strict_tool_mode.recommended_base_url.as_deref() {
4413 println!(
4414 " Use the {} endpoint for DeepSeek strict schemas.",
4415 crate::doctor::structural_url_authority(recommended)
4416 );
4417 }
4418 let capability = crate::config::provider_capability(config.api_provider(), &api_target.model);
4419 if let Some(alias) = capability.alias_deprecation.as_ref() {
4420 println!(
4421 " ! model alias {} retires {}; switch to {}",
4422 alias.alias, alias.retirement_date, alias.replacement
4423 );
4424 }
4425 let live_api_requested =
4426 doctor_should_probe_api(config.api_provider(), &api_target.base_url, probes);
4427 let endpoint_is_local = crate::config::provider_route_is_keyless_self_hosted(
4428 config.api_provider(),
4429 &api_target.base_url,
4430 ) || crate::config::base_url_uses_local_host(&api_target.base_url);
4431 if doctor_should_probe_auth(config) && live_api_requested {
4432 print!(" {} Testing connection...", "·".dimmed());
4433 use std::io::Write;
4434 std::io::stdout().flush().ok();
4435
4436 let connectivity_result = match config.with_read_only_api_key_for_diagnostic() {
4441 Ok(diagnostic_config) => test_api_connectivity(&diagnostic_config).await,
4442 Err(error) => Err(error),
4443 };
4444 match connectivity_result {
4445 Ok(()) => {
4446 println!(
4447 "\r {} API connection successful",
4448 "✓".truecolor(aqua_r, aqua_g, aqua_b)
4449 );
4450 }
4451 Err(e) => {
4452 let error_msg = e.to_string();
4453 println!(
4454 "\r {} API connection failed",
4455 "✗".truecolor(red_r, red_g, red_b)
4456 );
4457 if error_msg.contains("401") || error_msg.contains("Unauthorized") {
4458 println!(
4459 " Invalid API key. Check `codewhale auth status`, DEEPSEEK_API_KEY, or config.toml"
4460 );
4461 } else if error_msg.contains("403") || error_msg.contains("Forbidden") {
4462 println!(
4463 " API key lacks permissions. Verify key is active at platform.deepseek.com"
4464 );
4465 } else if error_msg.contains("timeout") || error_msg.contains("Timeout") {
4466 for line in doctor_timeout_recovery_lines(config) {
4467 println!(" {line}");
4468 }
4469 } else if error_msg.contains("dns") || error_msg.contains("resolve") {
4470 println!(" DNS resolution failed. Check your network connection");
4471 } else if error_msg.contains("connect") {
4472 println!(" Connection failed. Check firewall settings or try again");
4473 } else if crate::doctor::is_keyless_ds4_route(config) {
4474 println!(" {error_msg}");
4475 } else {
4476 println!(
4477 " Error details omitted because provider failures can contain credential material."
4478 );
4479 }
4480 }
4481 }
4482 } else if !doctor_should_probe_auth(config) {
4483 println!(
4484 " {} Live OAuth connectivity not checked by non-mutating doctor",
4485 "·".dimmed()
4486 );
4487 println!(
4488 " Doctor never refreshes or rewrites credentials; exercise the route with a normal request."
4489 );
4490 } else {
4491 if endpoint_is_local {
4492 println!(
4493 " {} Live connectivity not checked for this local endpoint",
4494 "·".dimmed()
4495 );
4496 println!(
4497 " Run `codewhale doctor --probe-local` to opt in; the request may start a local service."
4498 );
4499 } else {
4500 println!(
4501 " {} Live hosted connectivity not checked (offline default)",
4502 "·".dimmed()
4503 );
4504 println!(" Run `codewhale doctor --probe-api` to opt in.");
4505 }
4506 }
4507
4508 println!();
4509 println!("{}", "Search Provider Reachability:".bold());
4510 let search_probe = crate::doctor::doctor_search_probe(config, probes).await;
4511 for line in crate::doctor::doctor_search_probe_lines(&search_probe) {
4512 println!(" {line}");
4513 }
4514
4515 println!();
4517 println!("{}", "MCP Servers (configuration only):".bold());
4518 println!(" · Static check only; no server process was started.");
4519 let features = config.features();
4520 if features.enabled(Feature::Mcp) {
4521 println!(
4522 " {} MCP feature flag enabled",
4523 "✓".truecolor(aqua_r, aqua_g, aqua_b)
4524 );
4525 } else {
4526 println!(
4527 " {} MCP feature flag disabled",
4528 "!".truecolor(sky_r, sky_g, sky_b)
4529 );
4530 }
4531
4532 let mcp_config_path = config.mcp_config_path();
4533 let project_mcp_config_path = crate::mcp::workspace_mcp_config_path(workspace);
4534 if mcp_config_path.exists() {
4535 println!(
4536 " {} MCP config found at {}",
4537 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4538 crate::utils::display_path(&mcp_config_path)
4539 );
4540 } else {
4541 println!(
4542 " {} MCP config not found at {}",
4543 "·".dimmed(),
4544 crate::utils::display_path(&mcp_config_path)
4545 );
4546 }
4547 if project_mcp_config_path.exists() {
4548 println!(
4549 " {} Project MCP config found at {}",
4550 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4551 crate::utils::display_path(&project_mcp_config_path)
4552 );
4553 } else {
4554 println!(
4555 " {} Project MCP config not found at {}",
4556 "·".dimmed(),
4557 crate::utils::display_path(&project_mcp_config_path)
4558 );
4559 }
4560
4561 match crate::mcp::load_config_with_workspace_and_plugins(&mcp_config_path, workspace, plugins) {
4562 Ok(cfg) if cfg.servers.is_empty() => {
4563 println!(" {} 0 merged server(s) configured", "·".dimmed());
4564 if !mcp_config_path.exists() && !project_mcp_config_path.exists() {
4565 println!(" Run `codewhale mcp init` or add `.codewhale/mcp.json`.");
4566 }
4567 }
4568 Ok(cfg) => {
4569 println!(
4570 " {} {} merged server(s) configured",
4571 "·".dimmed(),
4572 cfg.servers.len()
4573 );
4574 for (name, server) in &cfg.servers {
4575 let status = doctor_check_mcp_server(server);
4576 let icon = match &status {
4577 McpServerDoctorStatus::Ok(detail) => {
4578 format!(
4579 " {} {name}: configuration valid; {}",
4580 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4581 detail
4582 )
4583 }
4584 McpServerDoctorStatus::Warning(detail) => {
4585 format!(
4586 " {} {name}: configuration warning; {}",
4587 "!".truecolor(sky_r, sky_g, sky_b),
4588 detail
4589 )
4590 }
4591 McpServerDoctorStatus::Error(detail) => {
4592 format!(
4593 " {} {name}: configuration invalid; {}",
4594 "✗".truecolor(red_r, red_g, red_b),
4595 detail
4596 )
4597 }
4598 };
4599 println!("{icon}");
4600 if !server.is_enabled() {
4601 println!(" disabled; live health not checked");
4602 } else {
4603 println!(
4604 " process/protocol/backend: not checked; `codewhale mcp validate` explicitly starts and initializes configured servers"
4605 );
4606 }
4607 }
4608 if probes.should_probe_mcp() {
4609 println!();
4610 println!(
4611 " {} Live MCP probe enabled: starting enabled servers; backend tool health remains untested.",
4612 "!".truecolor(sky_r, sky_g, sky_b)
4613 );
4614 match crate::mcp::McpPool::from_config_path_with_workspace_and_plugins(
4615 &mcp_config_path,
4616 workspace,
4617 std::sync::Arc::new(plugins.clone()),
4618 ) {
4619 Ok(mut pool) => {
4620 let errors = pool.connect_all().await;
4621 let failed = errors
4622 .iter()
4623 .map(|(name, _)| name.as_str())
4624 .collect::<std::collections::BTreeSet<_>>();
4625 for (name, server) in &cfg.servers {
4626 if !server.is_enabled() {
4627 continue;
4628 }
4629 if failed.contains(name.as_str()) {
4630 println!(
4631 " {} {name}: process/protocol unreachable; error details omitted",
4632 "✗".truecolor(red_r, red_g, red_b)
4633 );
4634 } else {
4635 println!(
4636 " {} {name}: process reachable and protocol initialized; backend tool health not checked",
4637 "✓".truecolor(aqua_r, aqua_g, aqua_b)
4638 );
4639 }
4640 }
4641 }
4642 Err(_) => println!(
4643 " {} live MCP probe could not load merged configuration; details omitted",
4644 "✗".truecolor(red_r, red_g, red_b)
4645 ),
4646 }
4647 } else {
4648 println!(
4649 " Use codewhale doctor --probe-mcp to opt in to live process/protocol checks; it may start configured servers."
4650 );
4651 }
4652 }
4653 Err(_) => {
4654 println!(
4655 " {} MCP configuration could not be loaded; details omitted",
4656 "✗".truecolor(red_r, red_g, red_b)
4657 );
4658 }
4659 }
4660
4661 println!();
4663 println!("{}", "Skills:".bold());
4664 let global_skills_dir = config.skills_dir();
4665 let agents_skills_dir = workspace.join(".agents").join("skills");
4666 let local_skills_dir = workspace.join("skills");
4667 let agents_global_skills_dir = crate::skills::agents_global_skills_dir();
4668 let opencode_skills_dir = workspace.join(".opencode").join("skills");
4674 let claude_skills_dir = workspace.join(".claude").join("skills");
4675 let selected_skills_dir = if agents_skills_dir.exists() {
4676 agents_skills_dir.clone()
4677 } else if local_skills_dir.exists() {
4678 local_skills_dir.clone()
4679 } else if config.skills_dir.is_none()
4680 && let Some(global_agents) = agents_global_skills_dir.as_ref()
4681 && global_agents.exists()
4682 {
4683 global_agents.clone()
4684 } else {
4685 global_skills_dir.clone()
4686 };
4687
4688 let describe_dir = |dir: &Path| -> usize {
4689 std::fs::read_dir(dir)
4690 .map(|entries| entries.filter_map(std::result::Result::ok).count())
4691 .unwrap_or(0)
4692 };
4693
4694 if local_skills_dir.exists() {
4695 println!(
4696 " {} local skills dir found at {} ({} items)",
4697 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4698 crate::utils::display_path(&local_skills_dir),
4699 describe_dir(&local_skills_dir)
4700 );
4701 } else {
4702 println!(
4703 " {} local skills dir not found at {}",
4704 "·".dimmed(),
4705 crate::utils::display_path(&local_skills_dir)
4706 );
4707 }
4708
4709 if agents_skills_dir.exists() {
4710 println!(
4711 " {} .agents skills dir found at {} ({} items)",
4712 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4713 crate::utils::display_path(&agents_skills_dir),
4714 describe_dir(&agents_skills_dir)
4715 );
4716 } else {
4717 println!(
4718 " {} .agents skills dir not found at {}",
4719 "·".dimmed(),
4720 crate::utils::display_path(&agents_skills_dir)
4721 );
4722 }
4723
4724 if let Some(agents_global_skills_dir) = agents_global_skills_dir.as_ref() {
4725 if agents_global_skills_dir.exists() {
4726 println!(
4727 " {} global .agents skills dir found at {} ({} items)",
4728 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4729 crate::utils::display_path(agents_global_skills_dir),
4730 describe_dir(agents_global_skills_dir)
4731 );
4732 } else {
4733 println!(
4734 " {} global .agents skills dir not found at {}",
4735 "·".dimmed(),
4736 crate::utils::display_path(agents_global_skills_dir)
4737 );
4738 }
4739 }
4740
4741 if global_skills_dir.exists() {
4742 println!(
4743 " {} global skills dir found at {} ({} items)",
4744 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4745 crate::utils::display_path(&global_skills_dir),
4746 describe_dir(&global_skills_dir)
4747 );
4748 } else {
4749 println!(
4750 " {} global skills dir not found at {}",
4751 "·".dimmed(),
4752 crate::utils::display_path(&global_skills_dir)
4753 );
4754 }
4755
4756 if opencode_skills_dir.exists() {
4760 println!(
4761 " {} .opencode skills dir found at {} ({} items)",
4762 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4763 crate::utils::display_path(&opencode_skills_dir),
4764 describe_dir(&opencode_skills_dir)
4765 );
4766 }
4767 if claude_skills_dir.exists() {
4768 println!(
4769 " {} .claude skills dir found at {} ({} items)",
4770 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4771 crate::utils::display_path(&claude_skills_dir),
4772 describe_dir(&claude_skills_dir)
4773 );
4774 }
4775
4776 println!(
4777 " {} selected skills dir: {}",
4778 "·".dimmed(),
4779 crate::utils::display_path(&selected_skills_dir)
4780 );
4781 if !agents_skills_dir.exists()
4782 && !local_skills_dir.exists()
4783 && !agents_global_skills_dir
4784 .as_ref()
4785 .is_some_and(|dir| dir.exists())
4786 && !global_skills_dir.exists()
4787 {
4788 println!(" Run `codewhale setup --skills` (or add --local for ./skills).");
4789 }
4790
4791 println!();
4793 println!("{}", "Tools:".bold());
4794 let tools_dir = default_tools_dir();
4795 if tools_dir.exists() {
4796 let count = count_dir_entries(&tools_dir);
4797 println!(
4798 " {} tools dir found at {} ({} items)",
4799 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4800 crate::utils::display_path(&tools_dir),
4801 count
4802 );
4803 } else {
4804 println!(
4805 " {} tools dir not found at {}",
4806 "·".dimmed(),
4807 crate::utils::display_path(&tools_dir)
4808 );
4809 println!(" Run `codewhale setup --tools` to scaffold a starter dir.");
4810 }
4811
4812 println!();
4814 println!("{}", "Plugins:".bold());
4815 let plugins_dir = default_plugins_dir();
4816 if plugins_dir.exists() {
4817 let count = count_dir_entries(&plugins_dir);
4818 println!(
4819 " {} plugins dir found at {} ({} items)",
4820 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4821 crate::utils::display_path(&plugins_dir),
4822 count
4823 );
4824 } else {
4825 println!(
4826 " {} plugins dir not found at {}",
4827 "·".dimmed(),
4828 crate::utils::display_path(&plugins_dir)
4829 );
4830 println!(" Run `codewhale setup --plugins` to scaffold a starter dir.");
4831 }
4832
4833 println!();
4835 println!("{}", "Storage:".bold());
4836 if let Some(spillover_root) = crate::tools::truncate::spillover_root() {
4837 let (present, count) = if spillover_root.is_dir() {
4838 (true, count_dir_entries(&spillover_root))
4839 } else {
4840 (false, 0)
4841 };
4842 if present {
4843 println!(
4844 " {} tool-output spillover at {} ({} file{})",
4845 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4846 crate::utils::display_path(&spillover_root),
4847 count,
4848 if count == 1 { "" } else { "s" }
4849 );
4850 } else {
4851 println!(
4852 " {} tool-output spillover dir not yet created at {}",
4853 "·".dimmed(),
4854 crate::utils::display_path(&spillover_root)
4855 );
4856 }
4857 }
4858 let stash = crate::composer_stash::diagnostic_stash_report();
4859 if let Some(stash_path) = stash.path.as_ref() {
4860 if let Some(error) = stash.error.as_deref() {
4861 println!(
4862 " {} composer stash was not inspected at {}: {error}",
4863 "!".truecolor(sky_r, sky_g, sky_b),
4864 crate::utils::display_path(stash_path),
4865 );
4866 } else if stash.present {
4867 println!(
4868 " {} composer stash at {} ({} parked draft{})",
4869 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4870 crate::utils::display_path(stash_path),
4871 stash.count,
4872 if stash.count == 1 { "" } else { "s" }
4873 );
4874 } else {
4875 println!(
4876 " {} composer stash empty (Ctrl+G or Ctrl+S in the composer to park a draft)",
4877 "·".dimmed()
4878 );
4879 }
4880 } else if let Some(error) = stash.error.as_deref() {
4881 println!(
4882 " {} composer stash was not inspected: {error}",
4883 "!".truecolor(sky_r, sky_g, sky_b),
4884 );
4885 }
4886
4887 println!();
4892 println!("{}", "Tool Dependencies:".bold());
4893
4894 match crate::dependencies::resolve_python_interpreter() {
4895 Some(name) => println!(
4896 " {} Python: {} → code_execution tool registered",
4897 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4898 name
4899 ),
4900 None => {
4901 println!(
4902 " {} Python: not found (tried {:?})",
4903 "✗".truecolor(red_r, red_g, red_b),
4904 crate::dependencies::PYTHON_CANDIDATES,
4905 );
4906 println!(" code_execution tool is NOT advertised to the model on this install.");
4907 println!(" Install Python 3 and ensure one of those names is on PATH:");
4908 match std::env::consts::OS {
4909 "macos" => {
4910 println!(" brew install python@3.12 (or download from python.org)")
4911 }
4912 "linux" => println!(
4913 " sudo apt install python3 (Debian/Ubuntu) — or your distro's equivalent"
4914 ),
4915 "windows" => {
4916 println!(" winget install Python.Python.3 (or download from python.org)")
4917 }
4918 other => println!(" install Python 3 for {other} from python.org"),
4919 }
4920 }
4921 }
4922
4923 match crate::dependencies::resolve_node() {
4924 Some(_) => println!(
4925 " {} Node.js: present → js_execution tool registered",
4926 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4927 ),
4928 None => {
4929 println!(
4930 " {} Node.js: not found (tried `node`)",
4931 "✗".truecolor(red_r, red_g, red_b),
4932 );
4933 println!(" js_execution tool is NOT advertised to the model on this install.");
4934 println!(" Install Node 18+ and ensure `node` is on PATH:");
4935 match std::env::consts::OS {
4936 "macos" => println!(" brew install node (or download from nodejs.org)"),
4937 "linux" => println!(
4938 " sudo apt install nodejs (Debian/Ubuntu) — or your distro's equivalent"
4939 ),
4940 "windows" => {
4941 println!(" winget install OpenJS.NodeJS (or download from nodejs.org)")
4942 }
4943 other => println!(" install Node.js for {other} from nodejs.org"),
4944 }
4945 }
4946 }
4947
4948 match crate::dependencies::resolve_pandoc() {
4949 Some(_) => println!(
4950 " {} pandoc: present → pandoc_convert tool registered",
4951 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4952 ),
4953 None => {
4954 println!(" {} pandoc: not found (optional)", "·".dimmed(),);
4955 println!(
4956 " pandoc_convert tool is NOT advertised to the model. Install pandoc to enable:"
4957 );
4958 match std::env::consts::OS {
4959 "macos" => println!(" brew install pandoc"),
4960 "linux" => println!(
4961 " sudo apt install pandoc (Debian/Ubuntu) — or your distro's equivalent"
4962 ),
4963 "windows" => {
4964 println!(" winget install JohnMacFarlane.Pandoc")
4965 }
4966 other => println!(" install pandoc for {other} from pandoc.org"),
4967 }
4968 }
4969 }
4970
4971 match crate::dependencies::resolve_tesseract() {
4972 Some(_) => {
4973 if cfg!(target_os = "macos") {
4974 println!(
4975 " {} OCR: macOS Vision + tesseract available → image_ocr/read_file screenshot OCR enabled",
4976 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4977 );
4978 } else {
4979 println!(
4980 " {} tesseract: present → image_ocr/read_file screenshot OCR enabled",
4981 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4982 );
4983 }
4984 }
4985 None => {
4986 if cfg!(target_os = "macos") {
4987 println!(
4988 " {} OCR: macOS Vision available → image_ocr/read_file screenshot OCR enabled",
4989 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4990 );
4991 println!(
4992 " tesseract not found (optional; install only for alternate OCR packs)."
4993 );
4994 } else {
4995 println!(" {} tesseract: not found (optional)", "·".dimmed(),);
4996 println!(
4997 " image_ocr tool is NOT advertised to the model. Install tesseract to enable:"
4998 );
4999 match std::env::consts::OS {
5000 "macos" => println!(" brew install tesseract"),
5001 "linux" => println!(
5002 " sudo apt install tesseract-ocr (Debian/Ubuntu) — or your distro's equivalent"
5003 ),
5004 "windows" => println!(" winget install UB-Mannheim.TesseractOCR"),
5005 other => {
5006 println!(" install tesseract for {other} from tesseract-ocr.github.io")
5007 }
5008 }
5009 }
5010 }
5011 }
5012
5013 match crate::dependencies::resolve_pdftotext() {
5017 Some(_) => println!(
5018 " {} pdftotext: available → PDF text extraction enabled",
5019 "✓".truecolor(aqua_r, aqua_g, aqua_b),
5020 ),
5021 None => {
5022 println!(
5023 " {} pdftotext: not found (optional; PDF text reads fail as `binary_unavailable`)",
5024 "·".dimmed(),
5025 );
5026 match std::env::consts::OS {
5027 "macos" => println!(" Install via: brew install poppler"),
5028 "linux" => {
5029 println!(" Install via: sudo apt install poppler-utils (Debian/Ubuntu)")
5030 }
5031 "windows" => println!(
5032 " Install Poppler for Windows from https://blog.alivate.com.au/poppler-windows/"
5033 ),
5034 _ => {}
5035 }
5036 }
5037 }
5038
5039 println!();
5043 println!("{}", "Terminal Quirks:".bold());
5044 let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default();
5045 let term_program_lc = term_program.to_ascii_lowercase();
5046 let mut any_quirk = false;
5047 if matches!(term_program.as_str(), "vscode" | "ghostty") {
5048 println!(
5049 " {} TERM_PROGRAM={} → low_motion + fancy_animations=false (auto)",
5050 "•".truecolor(sky_r, sky_g, sky_b),
5051 term_program
5052 );
5053 any_quirk = true;
5054 }
5055 if term_program == "Termius"
5056 || std::env::var_os("SSH_CLIENT").is_some_and(|v| !v.is_empty())
5057 || std::env::var_os("SSH_TTY").is_some_and(|v| !v.is_empty())
5058 {
5059 println!(
5060 " {} SSH/Termius session → low_motion + fancy_animations=false (auto, #1433)",
5061 "•".truecolor(sky_r, sky_g, sky_b)
5062 );
5063 any_quirk = true;
5064 }
5065 if term_program_lc.contains("ptyxis")
5066 || std::env::var_os("PTYXIS_VERSION").is_some_and(|v| !v.is_empty())
5067 {
5068 println!(
5069 " {} Ptyxis detected → synchronized_output=off (auto, v0.8.31)",
5070 "•".truecolor(sky_r, sky_g, sky_b)
5071 );
5072 any_quirk = true;
5073 }
5074 if crate::settings::detected_legacy_windows_console_host() {
5075 println!(
5076 " {} legacy Windows console host → low_motion + fancy_animations=false + bracketed_paste=false + synchronized_output=off (auto)",
5077 "•".truecolor(sky_r, sky_g, sky_b)
5078 );
5079 any_quirk = true;
5080 }
5081 if !any_quirk {
5082 println!(
5083 " {} no env-driven terminal-quirk overrides active",
5084 "·".dimmed()
5085 );
5086 }
5087
5088 println!();
5090 println!("{}", "Platform:".bold());
5091 println!(" OS: {}", std::env::consts::OS);
5092 println!(" Arch: {}", std::env::consts::ARCH);
5093
5094 let sandbox = crate::sandbox::get_platform_sandbox_with_bwrap_preference(
5095 config.prefer_bwrap.unwrap_or(false),
5096 );
5097 if let Some(kind) = sandbox {
5098 println!(
5099 " {} sandbox available: {}",
5100 "✓".truecolor(aqua_r, aqua_g, aqua_b),
5101 kind
5102 );
5103 } else {
5104 println!(
5105 " {} sandbox not available (commands run best-effort)",
5106 "!".truecolor(sky_r, sky_g, sky_b)
5107 );
5108 }
5109
5110 println!();
5111 println!(
5112 "{}",
5113 "All checks complete!"
5114 .truecolor(aqua_r, aqua_g, aqua_b)
5115 .bold()
5116 );
5117}
5118
5119const DOCTOR_LEGACY_STATE_ITEMS: &[&str] = &[
5120 "sessions",
5121 "tasks",
5122 "skills",
5123 "slop_ledger",
5124 "trophies",
5125 "catalog",
5126 "review-receipts",
5127 "config.toml",
5128 "settings.toml",
5129 "mcp.json",
5130];
5131const DOCTOR_SESSION_RECOVERY_HUMAN_SAMPLE_LIMIT: usize = 20;
5132const DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT: usize = 100;
5133
5134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5135enum DoctorLegacyStateStatus {
5136 PrimaryOnly,
5137 LegacyOnly,
5138 Both,
5139 Absent,
5140}
5141
5142impl DoctorLegacyStateStatus {
5143 fn as_str(self) -> &'static str {
5144 match self {
5145 Self::PrimaryOnly => "primary_only",
5146 Self::LegacyOnly => "legacy_only",
5147 Self::Both => "both",
5148 Self::Absent => "absent",
5149 }
5150 }
5151}
5152
5153#[derive(Debug, Clone)]
5154struct DoctorLegacyStateEntry {
5155 name: &'static str,
5156 primary_path: PathBuf,
5157 legacy_path: PathBuf,
5158 primary_present: bool,
5159 legacy_present: bool,
5160 status: DoctorLegacyStateStatus,
5161}
5162
5163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5164enum DoctorSessionRecoveryStatus {
5165 Isolated,
5166 NoLegacySessions,
5167 MigrationPending,
5168 MigrationIncomplete,
5169 MigrationComplete,
5170 ScanFailed,
5171}
5172
5173impl DoctorSessionRecoveryStatus {
5174 fn as_str(self) -> &'static str {
5175 match self {
5176 Self::Isolated => "isolated",
5177 Self::NoLegacySessions => "no_legacy_sessions",
5178 Self::MigrationPending => "migration_pending",
5179 Self::MigrationIncomplete => "migration_incomplete",
5180 Self::MigrationComplete => "migration_complete",
5181 Self::ScanFailed => "scan_failed",
5182 }
5183 }
5184}
5185
5186#[derive(Debug, Clone)]
5187struct DoctorRecoverableSessionEntry {
5188 name: PathBuf,
5189 source_path: PathBuf,
5190 destination_path: PathBuf,
5191}
5192
5193#[derive(Debug, Clone)]
5194struct DoctorSessionRecoveryReport {
5195 status: DoctorSessionRecoveryStatus,
5196 primary_sessions_path: PathBuf,
5197 legacy_sessions_path: PathBuf,
5198 codewhale_home_is_explicit: bool,
5199 legacy_session_file_count: usize,
5200 already_present_file_count: usize,
5201 recoverable_file_count: usize,
5202 recoverable: Vec<DoctorRecoverableSessionEntry>,
5204 error: Option<String>,
5205}
5206
5207impl DoctorSessionRecoveryReport {
5208 fn needs_attention(&self) -> bool {
5209 matches!(
5210 self.status,
5211 DoctorSessionRecoveryStatus::MigrationPending
5212 | DoctorSessionRecoveryStatus::MigrationIncomplete
5213 | DoctorSessionRecoveryStatus::ScanFailed
5214 )
5215 }
5216}
5217
5218fn doctor_legacy_state_status(
5219 primary_present: bool,
5220 legacy_present: bool,
5221) -> DoctorLegacyStateStatus {
5222 match (primary_present, legacy_present) {
5223 (true, false) => DoctorLegacyStateStatus::PrimaryOnly,
5224 (false, true) => DoctorLegacyStateStatus::LegacyOnly,
5225 (true, true) => DoctorLegacyStateStatus::Both,
5226 (false, false) => DoctorLegacyStateStatus::Absent,
5227 }
5228}
5229
5230fn doctor_state_roots() -> (PathBuf, PathBuf) {
5231 let code_home =
5232 codewhale_config::codewhale_home().unwrap_or_else(|_| PathBuf::from("~/.codewhale"));
5233 let legacy_home = if codewhale_config::codewhale_home_is_explicit() {
5234 code_home.join(codewhale_config::LEGACY_APP_DIR)
5235 } else {
5236 codewhale_config::legacy_deepseek_home().unwrap_or_else(|_| PathBuf::from("~/.deepseek"))
5237 };
5238 (code_home, legacy_home)
5239}
5240
5241fn doctor_legacy_state_report(
5242 primary_root: &Path,
5243 legacy_root: &Path,
5244) -> Vec<DoctorLegacyStateEntry> {
5245 DOCTOR_LEGACY_STATE_ITEMS
5246 .iter()
5247 .copied()
5248 .map(|name| {
5249 let primary_path = primary_root.join(name);
5250 let legacy_path = legacy_root.join(name);
5251 let primary_present = primary_path.exists();
5252 let legacy_present = legacy_path.exists();
5253 let status = doctor_legacy_state_status(primary_present, legacy_present);
5254 DoctorLegacyStateEntry {
5255 name,
5256 primary_path,
5257 legacy_path,
5258 primary_present,
5259 legacy_present,
5260 status,
5261 }
5262 })
5263 .collect()
5264}
5265
5266fn doctor_session_recovery_report(
5278 primary_root: &Path,
5279 legacy_root: &Path,
5280 codewhale_home_is_explicit: bool,
5281) -> DoctorSessionRecoveryReport {
5282 let primary_sessions_path = primary_root.join("sessions");
5283 let legacy_sessions_path = legacy_root.join("sessions");
5284 let mut report = DoctorSessionRecoveryReport {
5285 status: DoctorSessionRecoveryStatus::NoLegacySessions,
5286 primary_sessions_path,
5287 legacy_sessions_path,
5288 codewhale_home_is_explicit,
5289 legacy_session_file_count: 0,
5290 already_present_file_count: 0,
5291 recoverable_file_count: 0,
5292 recoverable: Vec::new(),
5293 error: None,
5294 };
5295
5296 if codewhale_home_is_explicit {
5297 report.status = DoctorSessionRecoveryStatus::Isolated;
5298 return report;
5299 }
5300
5301 let legacy_root_is_present =
5302 match doctor_session_directory_is_safe(legacy_root, "legacy state root") {
5303 Ok(present) => present,
5304 Err(error) => {
5305 report.status = DoctorSessionRecoveryStatus::ScanFailed;
5306 report.error = Some(error);
5307 return report;
5308 }
5309 };
5310 if !legacy_root_is_present {
5311 return report;
5312 }
5313 if let Err(error) = doctor_session_directory_is_safe(primary_root, "primary state root") {
5314 report.status = DoctorSessionRecoveryStatus::ScanFailed;
5315 report.error = Some(error);
5316 return report;
5317 }
5318
5319 let legacy_sessions_are_present = match doctor_session_directory_is_safe(
5320 &report.legacy_sessions_path,
5321 "legacy sessions root",
5322 ) {
5323 Ok(present) => present,
5324 Err(error) => {
5325 report.status = DoctorSessionRecoveryStatus::ScanFailed;
5326 report.error = Some(error);
5327 return report;
5328 }
5329 };
5330 if !legacy_sessions_are_present {
5331 return report;
5332 }
5333 let primary_sessions_are_present = match doctor_session_directory_is_safe(
5334 &report.primary_sessions_path,
5335 "primary sessions root",
5336 ) {
5337 Ok(present) => present,
5338 Err(error) => {
5339 report.status = DoctorSessionRecoveryStatus::ScanFailed;
5340 report.error = Some(error);
5341 return report;
5342 }
5343 };
5344
5345 let entries = match std::fs::read_dir(&report.legacy_sessions_path) {
5346 Ok(entries) => entries,
5347 Err(err) => {
5348 report.status = DoctorSessionRecoveryStatus::ScanFailed;
5349 report.error = Some(format!(
5350 "could not inspect legacy session filenames at {}: {err}",
5351 crate::utils::display_path(&report.legacy_sessions_path)
5352 ));
5353 return report;
5354 }
5355 };
5356
5357 for entry in entries {
5358 let entry = match entry {
5359 Ok(entry) => entry,
5360 Err(err) => {
5361 report.status = DoctorSessionRecoveryStatus::ScanFailed;
5362 report.error = Some(format!(
5363 "could not inspect an entry under {}: {err}",
5364 crate::utils::display_path(&report.legacy_sessions_path)
5365 ));
5366 return report;
5367 }
5368 };
5369 let file_type = match entry.file_type() {
5370 Ok(file_type) => file_type,
5371 Err(err) => {
5372 report.status = DoctorSessionRecoveryStatus::ScanFailed;
5373 report.error = Some(format!(
5374 "could not inspect legacy session entry metadata under {}: {err}",
5375 crate::utils::display_path(&report.legacy_sessions_path)
5376 ));
5377 return report;
5378 }
5379 };
5380 if !file_type.is_file() || entry.path().extension().is_none_or(|ext| ext != "json") {
5381 continue;
5382 }
5383
5384 report.legacy_session_file_count += 1;
5385 let name = PathBuf::from(entry.file_name());
5386 let destination_path = report.primary_sessions_path.join(&name);
5387 match std::fs::symlink_metadata(&destination_path) {
5388 Ok(metadata) if metadata.file_type().is_file() => {
5389 report.already_present_file_count += 1;
5390 }
5391 Ok(metadata) => {
5392 report.status = DoctorSessionRecoveryStatus::ScanFailed;
5393 let shape = if metadata.file_type().is_symlink() {
5394 "destination session entry is a symlink"
5395 } else {
5396 "destination session entry is not a regular file"
5397 };
5398 report.error = Some(format!(
5399 "could not inspect destination session metadata at {}: {shape}",
5400 crate::utils::display_path(&destination_path)
5401 ));
5402 return report;
5403 }
5404 Err(err) if err.kind() == io::ErrorKind::NotFound => {
5405 report.recoverable_file_count += 1;
5406 record_doctor_recoverable_session(
5407 &mut report.recoverable,
5408 DoctorRecoverableSessionEntry {
5409 source_path: entry.path(),
5410 destination_path,
5411 name,
5412 },
5413 );
5414 }
5415 Err(err) => {
5416 report.status = DoctorSessionRecoveryStatus::ScanFailed;
5417 report.error = Some(format!(
5418 "could not inspect destination metadata at {}: {err}",
5419 crate::utils::display_path(&destination_path)
5420 ));
5421 return report;
5422 }
5423 }
5424 }
5425
5426 report.status = if report.legacy_session_file_count == 0 {
5427 DoctorSessionRecoveryStatus::NoLegacySessions
5428 } else if report.recoverable_file_count == 0 {
5429 DoctorSessionRecoveryStatus::MigrationComplete
5430 } else if primary_sessions_are_present {
5431 DoctorSessionRecoveryStatus::MigrationIncomplete
5432 } else {
5433 DoctorSessionRecoveryStatus::MigrationPending
5434 };
5435 report
5436}
5437
5438fn doctor_session_directory_is_safe(path: &Path, label: &str) -> std::result::Result<bool, String> {
5446 let metadata = match std::fs::symlink_metadata(path) {
5447 Ok(metadata) => metadata,
5448 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false),
5449 Err(error) => {
5450 return Err(format!(
5451 "could not inspect {label} at {}: {error}",
5452 crate::utils::display_path(path)
5453 ));
5454 }
5455 };
5456 if metadata.file_type().is_symlink() {
5457 return Err(format!(
5458 "could not inspect {label} at {}: path is a symlink",
5459 crate::utils::display_path(path)
5460 ));
5461 }
5462 if !metadata.file_type().is_dir() {
5463 return Err(format!(
5464 "could not inspect {label} at {}: path is not a directory",
5465 crate::utils::display_path(path)
5466 ));
5467 }
5468 Ok(true)
5469}
5470
5471fn record_doctor_recoverable_session(
5475 recoverable: &mut Vec<DoctorRecoverableSessionEntry>,
5476 entry: DoctorRecoverableSessionEntry,
5477) {
5478 let insert_at = recoverable
5479 .binary_search_by(|existing| existing.name.cmp(&entry.name))
5480 .unwrap_or_else(|index| index);
5481 if recoverable.len() == DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT
5482 && insert_at == recoverable.len()
5483 {
5484 return;
5485 }
5486 recoverable.insert(insert_at, entry);
5487 if recoverable.len() > DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT {
5488 recoverable.pop();
5489 }
5490}
5491
5492fn legacy_state_needs_attention(entry: &DoctorLegacyStateEntry) -> bool {
5493 entry.name != "sessions"
5494 && matches!(
5495 entry.status,
5496 DoctorLegacyStateStatus::LegacyOnly | DoctorLegacyStateStatus::Both
5497 )
5498}
5499
5500fn print_doctor_legacy_state_report(
5501 report: &[DoctorLegacyStateEntry],
5502 session_recovery: &DoctorSessionRecoveryReport,
5503 ok_rgb: (u8, u8, u8),
5504 warn_rgb: (u8, u8, u8),
5505) {
5506 use colored::Colorize;
5507
5508 let attention: Vec<_> = report
5509 .iter()
5510 .filter(|entry| legacy_state_needs_attention(entry))
5511 .collect();
5512 if attention.is_empty()
5513 && !session_recovery.needs_attention()
5514 && session_recovery.status != DoctorSessionRecoveryStatus::Isolated
5515 {
5516 println!(
5517 " {} legacy state: no known .deepseek entries need migration",
5518 "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2)
5519 );
5520 } else if !attention.is_empty() {
5521 println!(
5522 " {} legacy state needs review:",
5523 "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2)
5524 );
5525 for entry in attention {
5526 match entry.status {
5527 DoctorLegacyStateStatus::LegacyOnly => {
5528 println!(
5529 " {} {} exists but {} is missing",
5530 "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2),
5531 crate::utils::display_path(&entry.legacy_path),
5532 crate::utils::display_path(&entry.primary_path),
5533 );
5534 }
5535 DoctorLegacyStateStatus::Both => {
5536 println!(
5537 " {} {} exists alongside primary {}; legacy data may still need review",
5538 "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2),
5539 crate::utils::display_path(&entry.legacy_path),
5540 crate::utils::display_path(&entry.primary_path),
5541 );
5542 }
5543 DoctorLegacyStateStatus::PrimaryOnly | DoctorLegacyStateStatus::Absent => {}
5544 }
5545 }
5546 println!(
5547 " Start Codewhale once to trigger safe migration where available, then rerun `codewhale doctor`."
5548 );
5549 }
5550
5551 print_doctor_session_recovery_report(session_recovery, ok_rgb, warn_rgb);
5552}
5553
5554fn print_doctor_session_recovery_report(
5555 report: &DoctorSessionRecoveryReport,
5556 ok_rgb: (u8, u8, u8),
5557 warn_rgb: (u8, u8, u8),
5558) {
5559 use colored::Colorize;
5560
5561 match report.status {
5562 DoctorSessionRecoveryStatus::Isolated => {
5563 println!(
5564 " {} legacy sessions: ambient ~/.deepseek/sessions was not inspected because CODEWHALE_HOME is set",
5565 "·".dimmed()
5566 );
5567 println!(
5568 " This preserves the explicit home boundary. To inspect the default home, use a separate shell with CODEWHALE_HOME unset and rerun `codewhale doctor`."
5569 );
5570 }
5571 DoctorSessionRecoveryStatus::NoLegacySessions => {
5572 println!(
5573 " {} legacy sessions: no top-level session JSON files found",
5574 "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2)
5575 );
5576 }
5577 DoctorSessionRecoveryStatus::MigrationComplete => {
5578 println!(
5579 " {} legacy sessions: all {} filename(s) have regular-file counterparts under {}; descriptor contents were not compared and legacy originals remain preserved",
5580 "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2),
5581 report.legacy_session_file_count,
5582 crate::utils::display_path(&report.primary_sessions_path),
5583 );
5584 }
5585 DoctorSessionRecoveryStatus::MigrationPending
5586 | DoctorSessionRecoveryStatus::MigrationIncomplete => {
5587 let label = if report.status == DoctorSessionRecoveryStatus::MigrationIncomplete {
5588 "migration is incomplete"
5589 } else {
5590 "migration has not completed"
5591 };
5592 println!(
5593 " {} legacy sessions: {label}; {} recoverable file(s) are absent from {}",
5594 "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2),
5595 report.recoverable_file_count,
5596 crate::utils::display_path(&report.primary_sessions_path),
5597 );
5598 for entry in report
5599 .recoverable
5600 .iter()
5601 .take(DOCTOR_SESSION_RECOVERY_HUMAN_SAMPLE_LIMIT)
5602 {
5603 println!(
5604 " {} {} -> {}",
5605 "·".dimmed(),
5606 crate::utils::display_path(&entry.source_path),
5607 crate::utils::display_path(&entry.destination_path),
5608 );
5609 }
5610 if report.recoverable_file_count > DOCTOR_SESSION_RECOVERY_HUMAN_SAMPLE_LIMIT {
5611 println!(
5612 " · {} more filename(s); `codewhale doctor --json` includes a bounded metadata-only sample",
5613 report.recoverable_file_count - DOCTOR_SESSION_RECOVERY_HUMAN_SAMPLE_LIMIT
5614 );
5615 }
5616 println!(" Safe recovery:");
5617 println!(
5618 " 1. Back up {} and {} (if present).",
5619 crate::utils::display_path(&report.legacy_sessions_path),
5620 crate::utils::display_path(&report.primary_sessions_path),
5621 );
5622 println!(
5623 " 2. Close other Codewhale processes, then run `codewhale sessions`; migration adds only missing files, never overwrites primary files, and leaves legacy originals in place."
5624 );
5625 println!(
5626 " 3. Rerun `codewhale doctor`. If filenames remain, keep both backups and report only the listed source/destination names."
5627 );
5628 }
5629 DoctorSessionRecoveryStatus::ScanFailed => {
5630 println!(
5631 " {} legacy sessions: recovery diagnostic could not complete",
5632 "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2)
5633 );
5634 if let Some(error) = report.error.as_deref() {
5635 println!(" {error}");
5636 }
5637 println!(
5638 " Keep both session directories unchanged, back them up, fix path permissions or shape, and rerun `codewhale doctor` before attempting migration."
5639 );
5640 }
5641 }
5642 if report.status != DoctorSessionRecoveryStatus::Isolated {
5643 println!(
5644 " Doctor inspected filenames and filesystem metadata only; it did not read chat contents, traverse checkpoints, or modify session files."
5645 );
5646 }
5647}
5648
5649fn doctor_session_recovery_json(report: &DoctorSessionRecoveryReport) -> serde_json::Value {
5650 use serde_json::json;
5651
5652 let recoverable: Vec<_> = report
5653 .recoverable
5654 .iter()
5655 .take(DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT)
5656 .map(|entry| {
5657 json!({
5658 "name": entry.name.display().to_string(),
5659 "source_path": entry.source_path.display().to_string(),
5660 "destination_path": entry.destination_path.display().to_string(),
5661 })
5662 })
5663 .collect();
5664
5665 json!({
5666 "status": report.status.as_str(),
5667 "needs_attention": report.needs_attention(),
5668 "read_only": true,
5669 "chat_contents_read": false,
5670 "checkpoint_internals_scanned": false,
5671 "session_descriptors_compared": false,
5672 "counterpart_check": "top_level_filename_and_regular_file_only",
5673 "codewhale_home_is_explicit": report.codewhale_home_is_explicit,
5674 "legacy_sessions_path": report.legacy_sessions_path.display().to_string(),
5675 "primary_sessions_path": report.primary_sessions_path.display().to_string(),
5676 "legacy_session_file_count": report.legacy_session_file_count,
5677 "already_present_file_count": report.already_present_file_count,
5678 "recoverable_file_count": report.recoverable_file_count,
5679 "recoverable_files": recoverable,
5680 "recoverable_files_truncated": report.recoverable_file_count > report.recoverable.len(),
5681 "error": report.error,
5682 "recovery_command": if report.needs_attention() && report.status != DoctorSessionRecoveryStatus::ScanFailed {
5683 Some("codewhale sessions")
5684 } else {
5685 None
5686 },
5687 })
5688}
5689
5690fn doctor_legacy_state_json(
5691 primary_root: &Path,
5692 legacy_root: &Path,
5693 report: &[DoctorLegacyStateEntry],
5694 session_recovery: &DoctorSessionRecoveryReport,
5695) -> serde_json::Value {
5696 use serde_json::json;
5697
5698 let legacy_only = report
5699 .iter()
5700 .filter(|entry| entry.status == DoctorLegacyStateStatus::LegacyOnly)
5701 .count();
5702 let both = report
5703 .iter()
5704 .filter(|entry| entry.status == DoctorLegacyStateStatus::Both)
5705 .count();
5706 let entries: Vec<_> = report
5707 .iter()
5708 .map(|entry| {
5709 json!({
5710 "name": entry.name,
5711 "primary_path": entry.primary_path.display().to_string(),
5712 "legacy_path": entry.legacy_path.display().to_string(),
5713 "primary_present": entry.primary_present,
5714 "legacy_present": entry.legacy_present,
5715 "status": entry.status.as_str(),
5716 })
5717 })
5718 .collect();
5719
5720 json!({
5721 "primary_root": primary_root.display().to_string(),
5722 "legacy_root": legacy_root.display().to_string(),
5723 "needs_attention": report.iter().any(legacy_state_needs_attention) || session_recovery.needs_attention(),
5724 "legacy_only_count": legacy_only,
5725 "dual_present_count": both,
5726 "session_recovery": doctor_session_recovery_json(session_recovery),
5727 "entries": entries,
5728 })
5729}
5730
5731fn doctor_setup_state(
5732 config: &Config,
5733 workspace: &Path,
5734) -> (codewhale_config::SetupState, &'static str) {
5735 if let Ok(Some(state)) = codewhale_config::SetupState::load() {
5736 return (state, "persisted");
5737 }
5738
5739 (
5740 codewhale_config::SetupState::derive_inherited(&doctor_inherited_setup_facts(
5741 config, workspace,
5742 )),
5743 "derived",
5744 )
5745}
5746
5747fn doctor_inherited_setup_facts(
5748 config: &Config,
5749 workspace: &Path,
5750) -> codewhale_config::InheritedConfigFacts {
5751 let user_constitution = codewhale_config::UserConstitution::load().ok();
5752 let user_constitution_validity = user_constitution.as_ref().map_or(
5753 codewhale_config::ConstitutionValidity::Unknown,
5754 codewhale_config::UserConstitutionLoad::validity,
5755 );
5756 let has_user_constitution = user_constitution
5757 .as_ref()
5758 .is_some_and(|loaded| !matches!(loaded, codewhale_config::UserConstitutionLoad::Missing));
5759 let has_expert_override = codewhale_config::codewhale_home()
5760 .ok()
5761 .map(|home| home.join(Path::new(crate::prompts::CONSTITUTION_OVERRIDE_FILE)))
5762 .is_some_and(|path| path.exists());
5763
5764 codewhale_config::InheritedConfigFacts {
5765 language: None,
5766 has_provider_route: !config.default_model().trim().is_empty(),
5767 has_credentials_or_local_runtime: doctor_has_credentials_or_local_runtime(config),
5768 trust_chosen: !crate::tui::onboarding::needs_trust(workspace),
5769 has_expert_override,
5770 has_user_constitution,
5771 user_constitution_validity,
5772 }
5773}
5774
5775fn doctor_has_credentials_or_local_runtime(config: &Config) -> bool {
5776 resolve_credential_diagnostic(config)
5777 .availability
5778 .certifies_ready()
5779}
5780
5781fn print_doctor_setup_report(
5782 config: &Config,
5783 workspace: &Path,
5784 state: &codewhale_config::SetupState,
5785 source: &str,
5786 ok_rgb: (u8, u8, u8),
5787 warn_rgb: (u8, u8, u8),
5788) {
5789 use colored::Colorize;
5790
5791 let credential = resolve_credential_diagnostic(config);
5792 let first_run_ready = state.first_run_ready();
5796 let update_ready = state.update_ready(crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION);
5797 let operate_ready = state.operate_ready();
5798 let first_run_icon = if first_run_ready {
5799 "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2)
5800 } else {
5801 "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2)
5802 };
5803 let update_icon = if update_ready {
5804 "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2)
5805 } else {
5806 "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2)
5807 };
5808 let operate_icon = if operate_ready {
5809 "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2)
5810 } else {
5811 "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2)
5812 };
5813
5814 println!();
5815 println!("{}", "Setup State:".bold());
5816 println!(" · source: {source}");
5817 println!(
5818 " · credential: source={}, availability={}",
5819 doctor_api_key_source_label(credential.source),
5820 credential.availability.label()
5821 );
5822 println!(
5823 " {first_run_icon} first-run: {}",
5824 doctor_ready_label(first_run_ready)
5825 );
5826 println!(
5827 " {update_icon} update checkpoint {}: {}",
5828 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
5829 doctor_ready_label(update_ready)
5830 );
5831 println!(
5832 " {operate_icon} operate/fleet: {}",
5833 doctor_ready_label(operate_ready)
5834 );
5835 println!(
5836 " · constitution autonomy: {} (guidance only)",
5837 doctor_constitution_autonomy_preference_id()
5838 );
5839 println!(
5840 " · runtime posture: {}",
5841 doctor_runtime_posture_line(config, workspace)
5842 );
5843 let consistency = doctor_setup_consistency(state, source);
5844 if consistency["status"] == "inconsistent" {
5845 let issues = consistency["issues"]
5846 .as_array()
5847 .map(|issues| {
5848 issues
5849 .iter()
5850 .filter_map(serde_json::Value::as_str)
5851 .collect::<Vec<_>>()
5852 .join(", ")
5853 })
5854 .unwrap_or_default();
5855 println!(
5856 " {} consistency: half-applied setup detected ({issues}) — {}",
5857 "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2),
5858 consistency["repair"].as_str().unwrap_or("/setup"),
5859 );
5860 }
5861 println!(
5862 " · 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)"
5863 );
5864 for step in codewhale_config::SetupStep::ALL {
5865 let entry = state.steps.get(&step);
5866 let required = entry.is_some_and(|entry| entry.required);
5867 let version = entry.and_then(|entry| entry.version.as_deref());
5868 let result = entry.and_then(|entry| entry.result.as_deref());
5869 let required_label = if required { "required" } else { "optional" };
5870 let version_label = version.unwrap_or("unversioned");
5871 let result_label = result.unwrap_or("no result");
5872 println!(
5873 " · {}: {} ({required_label}, {version_label}, {result_label})",
5874 setup_step_id(step),
5875 setup_status_id(state.status(step))
5876 );
5877 }
5878}
5879
5880fn print_doctor_fleet_roster_layers(config: &Config, workspace: &Path) {
5884 use colored::Colorize;
5885
5886 let roster = crate::fleet::roster::FleetRoster::load(&config.fleet_config(), workspace);
5887 println!();
5888 println!("{}", "Fleet roster layers:".bold());
5889 let lines = roster.doctor_layer_lines();
5890 if lines.is_empty() {
5891 println!(" · no profile id is defined in more than one layer");
5892 return;
5893 }
5894 for line in lines {
5895 if let Some(layer) = line.strip_prefix(" ") {
5896 println!(" {layer}");
5897 } else {
5898 println!(" · {line}");
5899 }
5900 }
5901}
5902
5903fn doctor_ready_label(ready: bool) -> &'static str {
5904 if ready { "ready" } else { "needs action" }
5905}
5906
5907fn doctor_setup_consistency(
5915 state: &codewhale_config::SetupState,
5916 source: &str,
5917) -> serde_json::Value {
5918 use serde_json::json;
5919
5920 let mut issues: Vec<&'static str> = Vec::new();
5921
5922 if source == "persisted"
5923 && matches!(
5924 state.constitution_source,
5925 codewhale_config::ConstitutionSource::UserGlobal
5926 )
5927 {
5928 match codewhale_config::UserConstitution::load() {
5929 Ok(codewhale_config::UserConstitutionLoad::Missing) => {
5930 issues.push("setup_state_points_at_missing_user_constitution");
5931 }
5932 Ok(codewhale_config::UserConstitutionLoad::Empty) => {
5933 issues.push("user_constitution_empty");
5934 }
5935 Ok(codewhale_config::UserConstitutionLoad::Invalid(_)) => {
5936 issues.push("user_constitution_invalid");
5937 }
5938 Ok(codewhale_config::UserConstitutionLoad::Unreadable(_)) | Err(_) => {
5939 issues.push("user_constitution_unreadable");
5940 }
5941 Ok(codewhale_config::UserConstitutionLoad::Loaded(_)) => {}
5942 }
5943 }
5944
5945 if doctor_home_has_stale_setup_temp_files() {
5946 issues.push("stale_setup_temp_files_in_codewhale_home");
5947 }
5948
5949 json!({
5950 "status": if issues.is_empty() { "consistent" } else { "inconsistent" },
5951 "issues": issues,
5952 "repair": "/constitution to rebuild standing law, /setup to re-run the checkpoint",
5953 })
5954}
5955
5956fn doctor_home_has_stale_setup_temp_files() -> bool {
5957 let Ok(home) = codewhale_config::codewhale_home() else {
5958 return false;
5959 };
5960 let Ok(entries) = std::fs::read_dir(&home) else {
5961 return false;
5962 };
5963 entries.flatten().any(|entry| {
5964 entry.file_name().to_string_lossy().starts_with(".tmp")
5965 && entry.file_type().is_ok_and(|kind| kind.is_file())
5966 })
5967}
5968
5969fn doctor_constitution_autonomy_preference() -> codewhale_config::AutonomyPreference {
5970 codewhale_config::UserConstitution::load()
5971 .ok()
5972 .and_then(|load| {
5973 load.constitution()
5974 .map(|constitution| constitution.autonomy_preference)
5975 })
5976 .unwrap_or(codewhale_config::AutonomyPreference::Unspecified)
5977}
5978
5979fn doctor_constitution_autonomy_preference_id() -> &'static str {
5980 autonomy_preference_id(doctor_constitution_autonomy_preference())
5981}
5982
5983fn autonomy_preference_id(preference: codewhale_config::AutonomyPreference) -> &'static str {
5984 match preference {
5985 codewhale_config::AutonomyPreference::Unspecified => "unspecified",
5986 codewhale_config::AutonomyPreference::Cautious => "cautious",
5987 codewhale_config::AutonomyPreference::Balanced => "balanced",
5988 codewhale_config::AutonomyPreference::Autonomous => "autonomous",
5989 }
5990}
5991
5992fn doctor_runtime_default_mode() -> (String, &'static str) {
5993 match crate::settings::Settings::load_read_only() {
5994 Ok(settings) => (settings.default_mode, "settings"),
5995 Err(_) => (crate::settings::Settings::default().default_mode, "default"),
5996 }
5997}
5998
5999fn doctor_runtime_permission_posture() -> (String, &'static str) {
6003 match crate::settings::Settings::load_read_only() {
6004 Ok(settings) => match settings.permission_posture {
6005 Some(posture) => (posture, "settings"),
6006 None => ("unset".to_string(), "default"),
6007 },
6008 Err(_) => ("unset".to_string(), "default"),
6009 }
6010}
6011
6012fn doctor_runtime_posture_line(config: &Config, workspace: &Path) -> String {
6013 let (default_mode, default_mode_source) = doctor_runtime_default_mode();
6014 let (permission_posture, permission_posture_source) = doctor_runtime_permission_posture();
6015 let approval = config.approval_policy.as_deref().unwrap_or("on-request");
6016 let approval_source = if config.approval_policy.is_some() {
6017 "config"
6018 } else {
6019 "default"
6020 };
6021 let allow_shell = config.interactive_allow_shell();
6022 let allow_shell_source = if config.allow_shell.is_some() {
6023 "config"
6024 } else {
6025 "interactive default"
6026 };
6027 let sandbox = config.sandbox_mode.as_deref().unwrap_or("mode-derived");
6028 let sandbox_source = if config.sandbox_mode.is_some() {
6029 "config"
6030 } else {
6031 "default"
6032 };
6033 let network = config
6034 .network
6035 .as_ref()
6036 .map_or("prompt", |policy| policy.default.as_str());
6037 let network_source = if config.network.is_some() {
6038 "config"
6039 } else {
6040 "default"
6041 };
6042 let trust = if crate::tui::onboarding::needs_trust(workspace) {
6043 "workspace not elevated"
6044 } else {
6045 "workspace trusted"
6046 };
6047 let (telemetry_on, telemetry_source) = doctor_runtime_telemetry(config);
6048 let telemetry = if telemetry_on { "on" } else { "off" };
6049
6050 format!(
6051 "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}), telemetry={telemetry} ({telemetry_source}), trust={trust}"
6052 )
6053}
6054
6055fn doctor_runtime_telemetry(config: &Config) -> (bool, &'static str) {
6061 let (on, source) = codewhale_config::resolved_telemetry_consent(config.telemetry);
6062 (on, source.as_str())
6063}
6064
6065fn doctor_operate_fleet_report_json(config: &Config, workspace: &Path) -> serde_json::Value {
6066 use serde_json::json;
6067
6068 let provider = config.api_provider();
6069 let credential = resolve_credential_diagnostic(config);
6072 let has_credentials_or_local = credential.availability.certifies_ready();
6073 let subagents_enabled = config.subagents_enabled_for_provider(provider);
6074 let disabled_reason = if subagents_enabled {
6075 None
6076 } else {
6077 Some(
6078 config
6079 .subagents_disabled_reason()
6080 .unwrap_or("disabled for active provider"),
6081 )
6082 };
6083 let max_subagents = config.max_subagents_for_provider(provider);
6084 let launch_concurrency = config.launch_concurrency_for_provider(provider);
6085 let max_admitted = config.max_admitted_subagents_for_provider(provider);
6086 let max_spawn_depth = config.subagent_max_spawn_depth_for_provider(provider);
6087 let roster = crate::fleet::roster::FleetRoster::load(&config.fleet_config(), workspace);
6088 let mut built_in_members = 0usize;
6089 let mut plugin_members = 0usize;
6090 let mut config_members = 0usize;
6091 let mut personal_members = 0usize;
6092 let mut workspace_members = 0usize;
6093 for member in roster.members() {
6094 match member.origin {
6095 crate::fleet::roster::ProfileOrigin::BuiltIn => built_in_members += 1,
6096 crate::fleet::roster::ProfileOrigin::Plugin => plugin_members += 1,
6097 crate::fleet::roster::ProfileOrigin::Config => config_members += 1,
6098 crate::fleet::roster::ProfileOrigin::Personal => personal_members += 1,
6099 crate::fleet::roster::ProfileOrigin::Workspace => workspace_members += 1,
6100 }
6101 }
6102 let roster_members = roster.members().len();
6103 let custom_members = plugin_members + config_members + personal_members + workspace_members;
6104 let roster_ready = roster_members > 0;
6105 let runtime_ready =
6106 subagents_enabled && max_subagents > 0 && launch_concurrency > 0 && max_spawn_depth > 0;
6107 let multi_layer: Vec<serde_json::Value> = roster
6108 .multi_layer_report()
6109 .into_iter()
6110 .map(|entry| {
6111 json!({
6112 "id": entry.id,
6113 "effective": entry.effective.to_string(),
6114 "effective_path": entry.effective_path.display().to_string(),
6115 "layers": entry
6116 .layers
6117 .iter()
6118 .map(|layer| {
6119 json!({
6120 "origin": layer.origin.to_string(),
6121 "path": layer.source.display().to_string(),
6122 "wins": layer.wins,
6123 })
6124 })
6125 .collect::<Vec<_>>(),
6126 })
6127 })
6128 .collect();
6129
6130 json!({
6131 "ready": has_credentials_or_local && runtime_ready && roster_ready,
6132 "provider": {
6133 "id": config.provider_identity_for(provider),
6134 "auth": {
6135 "present_or_local": has_credentials_or_local,
6136 "source": doctor_api_key_source_label(credential.source),
6137 "availability": credential.availability.label(),
6138 },
6139 },
6140 "worker_runtime": {
6141 "ready": runtime_ready,
6142 "enabled": subagents_enabled,
6143 "disabled_reason": disabled_reason,
6144 "max_subagents": max_subagents,
6145 "launch_concurrency": launch_concurrency,
6146 "max_admitted": max_admitted,
6147 "max_spawn_depth": max_spawn_depth,
6148 "host_enforced_workflow_receipts": true,
6149 },
6150 "roster": {
6151 "ready": roster_ready,
6152 "total": roster_members,
6153 "built_in": built_in_members,
6154 "config": config_members,
6155 "personal": personal_members,
6156 "workspace": workspace_members,
6157 "custom": custom_members,
6158 "starter_roster_available": built_in_members > 0,
6159 "readiness_rule": "built-in starter roster or custom roster",
6160 "multi_layer": multi_layer,
6161 },
6162 "concurrency": {
6163 "launch_concurrency": launch_concurrency,
6164 "max_subagents": max_subagents,
6165 "max_admitted": max_admitted,
6166 "plan_limit_probed": false,
6167 },
6168 })
6169}
6170
6171fn doctor_provider_model_report_json(config: &Config) -> serde_json::Value {
6172 use serde_json::json;
6173
6174 let provider = config.api_provider();
6175 let credential = resolve_credential_diagnostic(config);
6176 let auth_present_or_local = credential.availability.certifies_ready();
6177 let credential_help = provider.credential_help();
6178 let credential_url = credential_help
6179 .credential_url
6180 .map(crate::doctor::structural_url_authority);
6181 let credential_docs_url = credential_help
6182 .docs_url
6183 .map(crate::doctor::structural_url_authority);
6184
6185 json!({
6186 "provider": {
6187 "id": config.provider_identity_for(provider),
6188 "display": provider.display_name(),
6189 },
6190 "model": {
6191 "resolved": config.default_model(),
6192 },
6193 "auth": {
6194 "present_or_local": auth_present_or_local,
6195 "source": doctor_api_key_source_label(credential.source),
6196 "availability": credential.availability.label(),
6197 "env_vars": provider.env_vars(),
6198 "credential_mode": credential_help.acquisition.as_str(),
6199 "credential_url": credential_url,
6200 "credential_docs_url": credential_docs_url,
6201 "credential_guidance": credential_help.guidance,
6202 "oauth_only": credential_help.acquisition
6203 == codewhale_config::provider::CredentialAcquisition::OAuth,
6204 },
6205 "health": {
6206 "live_validation": false,
6207 "next_action": if auth_present_or_local {
6208 "/model"
6209 } else {
6210 "/setup provider or /provider setup <name>"
6211 },
6212 },
6213 })
6214}
6215
6216fn doctor_dsh_integration_report(
6217 config: &Config,
6218 workspace: &Path,
6219) -> anyhow::Result<crate::integrations::dsh::DshStatusReport> {
6220 use crate::integrations::dsh;
6221 let paths = dsh::DshPaths::from_process()?;
6222 let detection = dsh::detect::detect(&dsh::DetectEnv::from_process(), &dsh::ProcessRunner);
6223 let identity = dsh::codewhale_route_identity(config, workspace);
6224 dsh::compute_status(
6225 &paths,
6226 detection,
6227 identity,
6228 false,
6229 dsh::bundle_availability_now(),
6230 )
6231}
6232
6233fn doctor_dsh_integration_lines(config: &Config, workspace: &Path) -> Vec<String> {
6234 match doctor_dsh_integration_report(config, workspace) {
6235 Ok(report) => {
6236 let mut lines = vec![
6237 format!("state: {}", report.state.label()),
6238 crate::integrations::dsh::status_line(&report),
6239 format!(
6240 "owned files: {} (overlay {})",
6241 crate::utils::display_path(&report.paths_root),
6242 if report.overlay_present {
6243 "present"
6244 } else {
6245 "absent"
6246 }
6247 ),
6248 ];
6249 if !report.shadowing_namespaces.is_empty() {
6250 lines.push(format!(
6251 "dsh settings.yaml sections that can shadow the overlay: {}",
6252 report.shadowing_namespaces.join(", ")
6253 ));
6254 }
6255 lines
6256 }
6257 Err(error) => vec![format!("unavailable: {error}")],
6258 }
6259}
6260
6261fn doctor_dsh_integration_json(config: &Config, workspace: &Path) -> serde_json::Value {
6262 match doctor_dsh_integration_report(config, workspace) {
6263 Ok(report) => serde_json::json!({
6264 "state": report.state.label(),
6265 "summary": crate::integrations::dsh::status_line(&report),
6266 "dsh_version": report.detection.version,
6267 "compatibility": report.detection.compatibility.label(),
6268 "overlay_present": report.overlay_present,
6269 "shadowing_namespaces": report.shadowing_namespaces,
6270 }),
6271 Err(error) => serde_json::json!({ "state": "unavailable", "error": error.to_string() }),
6272 }
6273}
6274
6275fn doctor_external_credential_consent_statuses(
6276 config: &Config,
6277) -> Vec<codewhale_config::ExternalCredentialConsentStatus> {
6278 [
6279 crate::config::ApiProvider::OpenaiCodex,
6280 crate::config::ApiProvider::Xai,
6281 crate::config::ApiProvider::Deepseek,
6282 ]
6283 .into_iter()
6284 .filter_map(|provider| config.external_credential_consent_status(provider))
6285 .collect()
6286}
6287
6288fn doctor_external_credential_consent_lines(config: &Config) -> Vec<String> {
6289 doctor_external_credential_consent_statuses(config)
6290 .into_iter()
6291 .flat_map(|status| {
6292 let mut lines = vec![
6293 format!(
6294 "{}: access={}, provider={}, source={}, owner={}, path={}, version={}, state={}, ambient_path_changed={}",
6295 status.provider,
6296 status.access.as_str(),
6297 status.provider,
6298 status.source.as_str(),
6299 status.owner,
6300 codewhale_config::quote_os_path(&status.path),
6301 status.consent_version,
6302 status.route_state,
6303 status.ambient_path_changed,
6304 ),
6305 format!(" semantics: {}", status.semantics),
6306 format!(" revoke: {}", status.revoke_command),
6307 ];
6308 if let Some(warning) = status.ambient_path_warning() {
6309 lines.push(format!(" {warning}"));
6310 }
6311 lines
6312 })
6313 .collect()
6314}
6315
6316fn doctor_external_credential_consent_json(config: &Config) -> serde_json::Value {
6317 serde_json::Value::Array(
6318 doctor_external_credential_consent_statuses(config)
6319 .into_iter()
6320 .map(|status| {
6321 serde_json::json!({
6322 "provider": status.provider,
6323 "access": status.access.as_str(),
6324 "source": status.source.as_str(),
6325 "owner": status.owner,
6326 "path": codewhale_config::quote_os_path(&status.path),
6327 "consent_version": status.consent_version,
6328 "scope_valid": status.scope_valid,
6329 "ambient_path_changed": status.ambient_path_changed,
6330 "ambient_path_warning": status.ambient_path_warning(),
6331 "route_state": status.route_state,
6332 "semantics": status.semantics,
6333 "revoke_command": status.revoke_command,
6334 })
6335 })
6336 .collect(),
6337 )
6338}
6339
6340fn doctor_setup_report_json(config: &Config, workspace: &Path) -> serde_json::Value {
6341 use serde_json::json;
6342
6343 let (state, source) = doctor_setup_state(config, workspace);
6344 let (default_mode, default_mode_source) = doctor_runtime_default_mode();
6345 let (permission_posture, permission_posture_source) = doctor_runtime_permission_posture();
6346 let approval_policy = config.approval_policy.as_deref().unwrap_or("on-request");
6347 let approval_policy_source = if config.approval_policy.is_some() {
6348 "config"
6349 } else {
6350 "default"
6351 };
6352 let allow_shell = config.interactive_allow_shell();
6353 let allow_shell_source = if config.allow_shell.is_some() {
6354 "config"
6355 } else {
6356 "interactive_default"
6357 };
6358 let sandbox_mode = config.sandbox_mode.as_deref().unwrap_or("mode-derived");
6359 let sandbox_mode_source = if config.sandbox_mode.is_some() {
6360 "config"
6361 } else {
6362 "default"
6363 };
6364 let network_default = config
6365 .network
6366 .as_ref()
6367 .map_or("prompt", |policy| policy.default.as_str());
6368 let network_source = if config.network.is_some() {
6369 "config"
6370 } else {
6371 "default"
6372 };
6373 let (telemetry_value, telemetry_source) = doctor_runtime_telemetry(config);
6374 let workspace_trusted = !crate::tui::onboarding::needs_trust(workspace);
6375 let credential = resolve_credential_diagnostic(config);
6376 let credential_ready = credential.availability.certifies_ready();
6377 let steps: Vec<_> = codewhale_config::SetupStep::ALL
6378 .into_iter()
6379 .map(|step| {
6380 let entry = state.steps.get(&step);
6381 json!({
6382 "step": setup_step_id(step),
6383 "status": setup_status_id(state.status(step)),
6384 "required": entry.is_some_and(|entry| entry.required),
6385 "version": entry.and_then(|entry| entry.version.clone()),
6386 "result": entry.and_then(|entry| entry.result.clone()),
6387 })
6388 })
6389 .collect();
6390
6391 json!({
6392 "source": source,
6393 "schema_version": state.schema_version,
6394 "inherited": state.inherited,
6395 "checkpoint_version": crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
6396 "first_run_ready": state.first_run_ready(),
6397 "update_ready": state.update_ready(crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION),
6398 "operate_ready": state.operate_ready(),
6399 "credential": {
6400 "ready": credential_ready,
6401 "source": doctor_api_key_source_label(credential.source),
6402 "availability": credential.availability.label(),
6403 },
6404 "constitution": {
6405 "choice": constitution_choice_id(state.constitution_choice),
6406 "source": constitution_source_id(state.constitution_source),
6407 "validity": constitution_validity_id(state.constitution_validity),
6408 "checkpoint_completed_for": state.constitution_checkpoint_completed_for.clone(),
6409 "language": state.constitution_language.clone(),
6410 "preview_hash_present": state.constitution_preview_hash.is_some(),
6411 "preview_version": state.constitution_preview_version,
6412 "autonomy_preference": doctor_constitution_autonomy_preference_id(),
6413 },
6414 "runtime_posture_source": runtime_posture_source_id(state.runtime_posture_source),
6415 "runtime_posture": {
6416 "source": runtime_posture_source_id(state.runtime_posture_source),
6417 "default_mode": {
6418 "value": default_mode,
6419 "source": default_mode_source,
6420 },
6421 "permission_posture": {
6422 "value": permission_posture,
6423 "source": permission_posture_source,
6424 },
6425 "approval_policy": {
6426 "value": approval_policy,
6427 "source": approval_policy_source,
6428 },
6429 "allow_shell": {
6430 "value": allow_shell,
6431 "source": allow_shell_source,
6432 },
6433 "sandbox_mode": {
6434 "value": sandbox_mode,
6435 "source": sandbox_mode_source,
6436 },
6437 "network_default": {
6438 "value": network_default,
6439 "source": network_source,
6440 },
6441 "telemetry": {
6442 "value": telemetry_value,
6443 "source": telemetry_source,
6444 },
6445 "workspace_trust": {
6446 "trusted": workspace_trusted,
6447 "source": "workspace",
6448 },
6449 },
6450 "provider_model": doctor_provider_model_report_json(config),
6451 "operate_fleet": doctor_operate_fleet_report_json(config, workspace),
6452 "consistency": doctor_setup_consistency(&state, source),
6453 "next_actions": {
6454 "constitution": "/constitution",
6455 "setup_report": "/setup report",
6456 "provider_model": "/setup provider, /provider setup <name>, or /model",
6457 "runtime_posture": "/config",
6458 "operate_fleet": "/setup fleet (readiness), /fleet setup (explicit profile authoring)",
6459 "hotbar": "/setup hotbar",
6460 "tools_mcp": "/setup tools",
6461 "remote_runtime": "/setup remote",
6462 "persistence": "/setup persistence",
6463 },
6464 "steps": steps,
6465 })
6466}
6467
6468fn setup_step_id(step: codewhale_config::SetupStep) -> &'static str {
6469 match step {
6470 codewhale_config::SetupStep::Language => "language",
6471 codewhale_config::SetupStep::ProviderModel => "provider_model",
6472 codewhale_config::SetupStep::TrustSandbox => "trust_sandbox",
6473 codewhale_config::SetupStep::ToolsMcp => "tools_mcp",
6474 codewhale_config::SetupStep::Hotbar => "hotbar",
6475 codewhale_config::SetupStep::RemoteRuntime => "remote_runtime",
6476 codewhale_config::SetupStep::Persistence => "persistence",
6477 codewhale_config::SetupStep::Constitution => "constitution",
6478 codewhale_config::SetupStep::OperateFleet => "operate_fleet",
6479 codewhale_config::SetupStep::Verification => "verification",
6480 }
6481}
6482
6483fn setup_status_id(status: codewhale_config::StepStatus) -> &'static str {
6484 match status {
6485 codewhale_config::StepStatus::NotStarted => "not_started",
6486 codewhale_config::StepStatus::Recommended => "recommended",
6487 codewhale_config::StepStatus::Optional => "optional",
6488 codewhale_config::StepStatus::Deferred => "deferred",
6489 codewhale_config::StepStatus::InProgress => "in_progress",
6490 codewhale_config::StepStatus::Verified => "verified",
6491 codewhale_config::StepStatus::NeedsAction => "needs_action",
6492 codewhale_config::StepStatus::Failed => "failed",
6493 codewhale_config::StepStatus::Skipped => "skipped",
6494 }
6495}
6496
6497fn constitution_choice_id(choice: codewhale_config::ConstitutionChoice) -> &'static str {
6498 match choice {
6499 codewhale_config::ConstitutionChoice::Unset => "unset",
6500 codewhale_config::ConstitutionChoice::Bundled => "bundled",
6501 codewhale_config::ConstitutionChoice::GuidedCustom => "guided_custom",
6502 codewhale_config::ConstitutionChoice::ExpertOverride => "expert_override",
6503 codewhale_config::ConstitutionChoice::Deferred => "deferred",
6504 }
6505}
6506
6507fn constitution_source_id(source: codewhale_config::ConstitutionSource) -> &'static str {
6508 match source {
6509 codewhale_config::ConstitutionSource::Bundled => "bundled",
6510 codewhale_config::ConstitutionSource::UserGlobal => "user_global",
6511 codewhale_config::ConstitutionSource::ExpertOverride => "expert_override",
6512 }
6513}
6514
6515fn constitution_validity_id(validity: codewhale_config::ConstitutionValidity) -> &'static str {
6516 match validity {
6517 codewhale_config::ConstitutionValidity::Unknown => "unknown",
6518 codewhale_config::ConstitutionValidity::Valid => "valid",
6519 codewhale_config::ConstitutionValidity::Invalid => "invalid",
6520 codewhale_config::ConstitutionValidity::Empty => "empty",
6521 codewhale_config::ConstitutionValidity::Unreadable => "unreadable",
6522 }
6523}
6524
6525fn runtime_posture_source_id(source: codewhale_config::RuntimePostureSource) -> &'static str {
6526 match source {
6527 codewhale_config::RuntimePostureSource::Unset => "unset",
6528 codewhale_config::RuntimePostureSource::Inherited => "inherited",
6529 codewhale_config::RuntimePostureSource::Confirmed => "confirmed",
6530 }
6531}
6532
6533fn run_doctor_json_config_error(error: &anyhow::Error) -> Result<()> {
6537 let safe_message = error
6538 .downcast_ref::<crate::config::SafeConfigDiagnostic>()
6539 .map(ToString::to_string);
6540 let report = serde_json::json!({
6541 "status": "error",
6542 "error": {
6543 "kind": "config_validation",
6544 "message": safe_message.as_deref().unwrap_or("configuration validation failed; details omitted because configuration errors may contain credential material"),
6545 },
6546 });
6547 println!("{}", serde_json::to_string_pretty(&report)?);
6548
6549 bail!("doctor configuration validation failed; see JSON output")
6552}
6553
6554fn run_doctor_json(
6557 config: &Config,
6558 workspace: &Path,
6559 config_path_override: Option<&Path>,
6560 plugins: &crate::plugins::PluginRegistry,
6561) -> Result<()> {
6562 use serde_json::json;
6563
6564 let doctor_paths = crate::doctor::DoctorPathReport::resolve(config_path_override)?;
6565 let config_path = &doctor_paths.config;
6566 let secret_backend = codewhale_secrets::diagnose_secret_backend();
6567
6568 let credential = resolve_credential_diagnostic(config);
6569
6570 let mcp_config_path = config.mcp_config_path();
6571 let project_mcp_config_path = crate::mcp::workspace_mcp_config_path(workspace);
6572 let mcp_present = mcp_config_path.exists();
6573 let project_mcp_present = project_mcp_config_path.exists();
6574 let mcp_summary = match crate::mcp::load_config_with_workspace_and_plugins(
6575 &mcp_config_path,
6576 workspace,
6577 plugins,
6578 ) {
6579 Ok(cfg) => {
6580 let servers: Vec<serde_json::Value> = cfg
6581 .servers
6582 .iter()
6583 .map(|(name, server)| doctor_mcp_server_json(name, server))
6584 .collect();
6585 json!({
6586 "config_path": mcp_config_path.display().to_string(),
6587 "present": mcp_present,
6588 "project_config_path": project_mcp_config_path.display().to_string(),
6589 "project_present": project_mcp_present,
6590 "probe_scope": "configuration",
6591 "live_health_checked": false,
6592 "servers": servers,
6593 })
6594 }
6595 Err(_) => json!({
6596 "config_path": mcp_config_path.display().to_string(),
6597 "present": mcp_present,
6598 "project_config_path": project_mcp_config_path.display().to_string(),
6599 "project_present": project_mcp_present,
6600 "probe_scope": "configuration",
6601 "live_health_checked": false,
6602 "servers": [],
6603 "error": "configuration_unavailable_details_omitted",
6604 }),
6605 };
6606
6607 let global_skills_dir = config.skills_dir();
6608 let agents_skills_dir = workspace.join(".agents").join("skills");
6609 let local_skills_dir = workspace.join("skills");
6610 let agents_global_skills_dir = crate::skills::agents_global_skills_dir();
6611 let opencode_skills_dir = workspace.join(".opencode").join("skills");
6616 let claude_skills_dir = workspace.join(".claude").join("skills");
6617 let selected_skills_dir = if agents_skills_dir.exists() {
6618 agents_skills_dir.clone()
6619 } else if local_skills_dir.exists() {
6620 local_skills_dir.clone()
6621 } else if config.skills_dir.is_none()
6622 && let Some(global_agents) = agents_global_skills_dir.as_ref()
6623 && global_agents.exists()
6624 {
6625 global_agents.clone()
6626 } else {
6627 global_skills_dir.clone()
6628 };
6629 let agents_global_summary = agents_global_skills_dir
6630 .as_ref()
6631 .map(|path| {
6632 json!({
6633 "path": path.display().to_string(),
6634 "present": path.exists(),
6635 "count": skills_count_for(path),
6636 })
6637 })
6638 .unwrap_or_else(|| {
6639 json!({
6640 "path": null,
6641 "present": false,
6642 "count": 0,
6643 })
6644 });
6645
6646 let tools_dir = default_tools_dir();
6647 let plugins_dir = default_plugins_dir();
6648
6649 let memory_path = config.memory_path();
6658 let memory_enabled_env = std::env::var("CODEWHALE_MEMORY")
6659 .or_else(|_| std::env::var("DEEPSEEK_MEMORY"))
6660 .ok()
6661 .map(|raw| {
6662 matches!(
6663 raw.trim().to_ascii_lowercase().as_str(),
6664 "1" | "on" | "true" | "yes" | "y" | "enabled"
6665 )
6666 })
6667 .unwrap_or(false);
6668 let memory_summary = json!({
6669 "enabled": memory_enabled_env,
6672 "path": memory_path.display().to_string(),
6673 "file_present": memory_path.exists(),
6674 });
6675 let api_target = doctor_api_target(config);
6676 let strict_tool_mode = doctor_strict_tool_mode_status(config);
6677 let tls_status = doctor_tls_status(config);
6678 let (code_home, legacy_home) = doctor_state_roots();
6679 let legacy_state_report = doctor_legacy_state_report(&code_home, &legacy_home);
6680 let session_recovery = doctor_session_recovery_report(
6681 &code_home,
6682 &legacy_home,
6683 codewhale_config::codewhale_home_is_explicit(),
6684 );
6685
6686 let stash = crate::composer_stash::diagnostic_stash_report();
6687 let report = json!({
6688 "version": env!("CARGO_PKG_VERSION"),
6689 "config_path": config_path.display().to_string(),
6690 "config_present": config_path.exists(),
6691 "paths": doctor_paths,
6692 "secret_backend": secret_backend,
6693 "workspace": workspace.display().to_string(),
6694 "legacy_state": doctor_legacy_state_json(
6695 &code_home,
6696 &legacy_home,
6697 &legacy_state_report,
6698 &session_recovery,
6699 ),
6700 "setup": doctor_setup_report_json(config, workspace),
6701 "api_key": {
6702 "source": doctor_api_key_source_label(credential.source),
6703 "availability": credential.availability.label(),
6704 },
6705 "external_credentials": doctor_external_credential_consent_json(config),
6706 "dsh_integration": doctor_dsh_integration_json(config, workspace),
6707 "base_url": crate::doctor::structural_url_authority(&api_target.base_url),
6708 "default_text_model": api_target.model,
6709 "route_scope": "configured_at_launch",
6712 "model_resolution": match api_target.resolution {
6713 DoctorModelResolution::Resolved => "resolved",
6714 DoctorModelResolution::ConfiguredOnly => "configured_unresolved",
6715 },
6716 "route": doctor_route_report(config),
6717 "strict_tool_mode": doctor_strict_tool_mode_report_json(&strict_tool_mode),
6718 "tls": {
6719 "certificate_verification": tls_status.certificate_verification,
6720 "insecure_skip_tls_verify": tls_status.insecure_skip_tls_verify,
6721 "provider": tls_status.provider,
6722 "message": tls_status.message,
6723 },
6724 "search_provider": doctor_search_provider_json(config),
6725 "memory": memory_summary,
6726 "mcp": mcp_summary,
6727 "skills": {
6728 "selected": selected_skills_dir.display().to_string(),
6729 "global": {
6730 "path": global_skills_dir.display().to_string(),
6731 "present": global_skills_dir.exists(),
6732 "count": skills_count_for(&global_skills_dir),
6733 },
6734 "agents": {
6735 "path": agents_skills_dir.display().to_string(),
6736 "present": agents_skills_dir.exists(),
6737 "count": skills_count_for(&agents_skills_dir),
6738 },
6739 "agents_global": agents_global_summary,
6740 "local": {
6741 "path": local_skills_dir.display().to_string(),
6742 "present": local_skills_dir.exists(),
6743 "count": skills_count_for(&local_skills_dir),
6744 },
6745 "opencode": {
6746 "path": opencode_skills_dir.display().to_string(),
6747 "present": opencode_skills_dir.exists(),
6748 "count": skills_count_for(&opencode_skills_dir),
6749 },
6750 "claude": {
6751 "path": claude_skills_dir.display().to_string(),
6752 "present": claude_skills_dir.exists(),
6753 "count": skills_count_for(&claude_skills_dir),
6754 },
6755 },
6756 "tools": {
6757 "path": tools_dir.display().to_string(),
6758 "present": tools_dir.exists(),
6759 "count": if tools_dir.exists() { count_dir_entries(&tools_dir) } else { 0 },
6760 },
6761 "plugins": {
6762 "path": plugins_dir.display().to_string(),
6763 "present": plugins_dir.exists(),
6764 "count": if plugins_dir.exists() { count_dir_entries(&plugins_dir) } else { 0 },
6765 },
6766 "storage": {
6767 "spillover": {
6768 "path": crate::tools::truncate::spillover_root()
6769 .map(|p| p.display().to_string())
6770 .unwrap_or_default(),
6771 "present": crate::tools::truncate::spillover_root()
6772 .is_some_and(|p| p.is_dir()),
6773 "count": crate::tools::truncate::spillover_root()
6774 .filter(|p| p.is_dir())
6775 .map(|p| count_dir_entries(&p))
6776 .unwrap_or(0),
6777 },
6778 "stash": {
6779 "path": stash
6780 .path
6781 .as_ref()
6782 .map(|path| path.display().to_string())
6783 .unwrap_or_default(),
6784 "present": stash.present,
6785 "count": stash.count,
6786 "error": stash.error,
6787 },
6788 },
6789 "sandbox": match crate::sandbox::get_platform_sandbox_with_bwrap_preference(
6790 config.prefer_bwrap.unwrap_or(false),
6791 ) {
6792 Some(kind) => json!({"available": true, "kind": kind.to_string()}),
6793 None => json!({"available": false, "kind": null}),
6794 },
6795 "platform": {
6796 "os": std::env::consts::OS,
6797 "arch": std::env::consts::ARCH,
6798 },
6799 "api_connectivity": {
6800 "checked": false,
6801 "status": "not_probed",
6802 "note": "JSON doctor is offline; use `codewhale doctor --probe-api` or `--probe-local` for an explicit live check.",
6803 },
6804 "capability": provider_capability_report(config),
6805 });
6806
6807 println!("{}", serde_json::to_string_pretty(&report)?);
6808 Ok(())
6809}
6810
6811fn run_doctor_context_json(config: &Config, workspace: &Path) -> Result<()> {
6812 let report = crate::context_report::build_headless_context_report(config, workspace);
6813 println!("{}", crate::context_report::context_report_json(&report));
6814 Ok(())
6815}
6816
6817fn provider_capability_report(config: &Config) -> serde_json::Value {
6823 use serde_json::json;
6824
6825 let provider = config.api_provider();
6826 let configured_model = config.default_model();
6827 let route_result =
6828 crate::route_runtime::resolve_runtime_route(config, provider, Some(&configured_model));
6829 let route_error = route_result
6830 .is_err()
6831 .then_some("route_resolution_failed_details_omitted");
6832 let route = route_result.ok();
6833 let resolved_model = route
6834 .as_ref()
6835 .map_or(configured_model.as_str(), |route| route.model.as_str());
6836 let cap = crate::config::provider_capability(provider, resolved_model);
6837 let route_profile = route.as_ref().map(|route| {
6838 crate::model_profile::resolved_capability_profile_for_route(
6839 provider,
6840 resolved_model,
6841 route.candidate.capabilities(),
6842 route.candidate.limits(),
6843 )
6844 });
6845 let context_window = route
6846 .as_ref()
6847 .map_or(cap.context_window, |route| route.context_window.tokens);
6848 let context_window_source = route.as_ref().map_or(
6849 crate::route_runtime::ContextWindowSource::Fallback.label(),
6850 |route| route.context_window.source.label(),
6851 );
6852 let max_output = route_profile
6855 .as_ref()
6856 .and_then(|profile| profile.max_output)
6857 .or(cap.max_output);
6858 let is_exact_kimi_code_k3 = route.as_ref().is_some_and(|route| {
6859 crate::config::is_exact_kimi_code_k3_route(
6860 provider,
6861 &route.candidate.endpoint().base_url,
6862 route.candidate.wire_model_id().as_str(),
6863 )
6864 });
6865 let thinking_supported = is_exact_kimi_code_k3
6866 || route_profile
6867 .as_ref()
6868 .map_or(cap.thinking_supported, |profile| {
6869 profile.supports_reasoning()
6870 });
6871 let cache_telemetry_supported = route_profile
6872 .as_ref()
6873 .map_or(cap.cache_telemetry_supported, |profile| {
6874 profile.prompt_caching.is_supported()
6875 });
6876 let request_payload_mode = route_profile
6877 .as_ref()
6878 .map_or(cap.request_payload_mode, |profile| {
6879 profile.request_payload_mode
6880 });
6881 let alias_deprecation = config.active_deepseek_alias_deprecation();
6882
6883 json!({
6884 "resolved_provider": config.provider_identity_for(provider),
6885 "resolved_model": resolved_model,
6886 "context_window": context_window,
6887 "context_window_source": context_window_source,
6888 "max_output": max_output,
6889 "thinking_supported": thinking_supported,
6890 "cache_telemetry_supported": cache_telemetry_supported,
6891 "request_payload_mode": serde_json::to_value(request_payload_mode).unwrap_or_default(),
6892 "route_error": route_error,
6893 "alias_deprecation": alias_deprecation,
6894 })
6895}
6896
6897fn doctor_route_report(config: &Config) -> serde_json::Value {
6898 use serde_json::json;
6899
6900 let target = doctor_api_target(config);
6901 let provider = config.api_provider();
6902 let redacted_base_url = crate::doctor::structural_url_authority(&target.base_url);
6903 let route_result =
6904 crate::route_runtime::resolve_runtime_route(config, provider, Some(&target.model));
6905 let route_error = route_result
6906 .is_err()
6907 .then_some("route_resolution_failed_details_omitted");
6908 let context_window = route_result
6909 .ok()
6910 .map(|route| {
6911 json!({
6912 "tokens": route.context_window.tokens,
6913 "source": route.context_window.source.label(),
6914 })
6915 })
6916 .unwrap_or_else(|| {
6917 json!({
6918 "tokens": crate::config::provider_capability(provider, &target.model).context_window,
6919 "source": crate::route_runtime::ContextWindowSource::Fallback.label(),
6920 })
6921 });
6922
6923 let route_identity =
6924 crate::config::moonshot_k3_route_display_name(&target.base_url, &target.model);
6925 let credential = resolve_credential_diagnostic(config);
6926
6927 json!({
6928 "provider": target.provider,
6929 "provider_source": doctor_provider_source(config),
6930 "provider_config_table": doctor_provider_config_table(config, provider),
6931 "model": target.model,
6932 "route_identity": route_identity,
6933 "wire_protocol": doctor_wire_protocol(provider),
6934 "base_url": {
6935 "redacted": redacted_base_url,
6936 "class": doctor_base_url_class(provider, &target.base_url),
6937 "fingerprint": crate::utils::redacted_identifier_for_log(&target.base_url),
6938 },
6939 "auth": {
6940 "scheme": doctor_auth_scheme(config),
6941 "source": doctor_api_key_source_label(credential.source),
6942 "availability": credential.availability.label(),
6943 },
6944 "context_window": context_window,
6945 "route_error": route_error,
6946 })
6947}
6948
6949fn doctor_provider_config_table(config: &Config, provider: crate::config::ApiProvider) -> String {
6950 if provider != crate::config::ApiProvider::Custom {
6951 return provider_config_table_key(provider).to_string();
6952 }
6953 if config.uses_legacy_literal_custom_route() {
6954 "root (legacy literal custom)".to_string()
6955 } else {
6956 format!("providers.{}", config.provider_identity_for(provider))
6957 }
6958}
6959
6960fn doctor_provider_source(config: &Config) -> &'static str {
6961 if config
6962 .provider
6963 .as_ref()
6964 .is_some_and(|provider| !provider.trim().is_empty())
6965 {
6966 "config"
6967 } else {
6968 "default"
6969 }
6970}
6971
6972fn doctor_wire_protocol(provider: crate::config::ApiProvider) -> &'static str {
6973 let policy = provider
6974 .metadata()
6975 .map(|metadata| metadata.wire_policy())
6976 .unwrap_or(codewhale_config::provider::WirePolicy::Fixed(
6977 codewhale_config::provider::WireFormat::ChatCompletions,
6978 ));
6979 match policy.fixed() {
6980 Some(codewhale_config::provider::WireFormat::ChatCompletions) => "chat_completions",
6981 Some(codewhale_config::provider::WireFormat::Responses) => "responses",
6982 Some(codewhale_config::provider::WireFormat::AnthropicMessages) => "anthropic_messages",
6983 None => "model_aware",
6984 }
6985}
6986
6987fn doctor_base_url_class(provider: crate::config::ApiProvider, base_url: &str) -> &'static str {
6988 let normalized = base_url.trim_end_matches('/').to_ascii_lowercase();
6989 if normalized.starts_with("http://localhost")
6990 || normalized.starts_with("http://127.0.0.1")
6991 || normalized.starts_with("http://[::1]")
6992 {
6993 return "local";
6994 }
6995 if normalized
6996 == provider
6997 .default_base_url()
6998 .trim_end_matches('/')
6999 .to_ascii_lowercase()
7000 {
7001 "default"
7002 } else {
7003 "custom"
7004 }
7005}
7006
7007fn doctor_auth_scheme(config: &Config) -> &'static str {
7008 let provider = config.api_provider();
7009 if crate::config::auth_mode_disables_api_key(config.auth_mode_for_provider(provider).as_deref())
7010 {
7011 "none"
7012 } else if provider == crate::config::ApiProvider::Anthropic {
7013 "x-api-key"
7014 } else if provider == crate::config::ApiProvider::XiaomiMimo
7015 && doctor_xiaomi_mimo_base_url_uses_token_plan(&config.deepseek_base_url())
7016 {
7017 "api-key"
7018 } else if provider == crate::config::ApiProvider::XiaomiMimo {
7019 "unknown"
7022 } else if matches!(
7023 provider,
7024 crate::config::ApiProvider::Sglang
7025 | crate::config::ApiProvider::Vllm
7026 | crate::config::ApiProvider::Ollama
7027 ) {
7028 "optional_bearer"
7029 } else {
7030 "bearer"
7031 }
7032}
7033
7034fn doctor_xiaomi_mimo_base_url_uses_token_plan(base_url: &str) -> bool {
7035 let normalized = base_url.trim_end_matches('/');
7036 [
7037 crate::config::XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL,
7038 crate::config::XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL,
7039 crate::config::XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL,
7040 ]
7041 .iter()
7042 .any(|candidate| normalized.eq_ignore_ascii_case(candidate.trim_end_matches('/')))
7043}
7044
7045fn doctor_api_key_source_label(source: ApiKeySource) -> &'static str {
7046 match source {
7047 ApiKeySource::ConfigDeclared => "config_declared",
7048 ApiKeySource::EnvDeclared => "env_declared",
7049 ApiKeySource::ExternalAuthDeclared => "external_auth_declared",
7050 ApiKeySource::SecretStoreUnprobed => "secret_store_unprobed",
7051 ApiKeySource::SecretStoreUnavailable => "secret_store_unavailable",
7052 ApiKeySource::OAuth => "oauth_unprobed",
7053 ApiKeySource::ExternalConsent => "external_consent",
7054 ApiKeySource::NoAuth => "none",
7055 ApiKeySource::LocalRuntime => "local_runtime",
7056 ApiKeySource::Unknown => "unknown",
7057 }
7058}
7059
7060fn doctor_search_provider_line(config: &Config) -> String {
7061 let search_provider = config.search_provider_resolution();
7062 let switch_hint = if matches!(
7063 (search_provider.provider, search_provider.source),
7064 (
7065 crate::config::SearchProvider::Firecrawl,
7066 crate::config::SearchProviderSource::Default
7067 )
7068 ) {
7069 "; set [search] provider = \"baidu\" | \"metaso\" | \"volcengine\" for China"
7070 } else {
7071 ""
7072 };
7073
7074 format!(
7075 "search_provider: {} (source: {}{})",
7076 search_provider.provider.as_str(),
7077 search_provider.source.as_str(),
7078 switch_hint
7079 )
7080}
7081
7082fn doctor_search_provider_json(config: &Config) -> serde_json::Value {
7083 use serde_json::json;
7084
7085 let search_provider = config.search_provider_resolution();
7086 json!({
7087 "provider": search_provider.provider.as_str(),
7088 "source": search_provider.source.as_str(),
7089 "reachability": "not_checked",
7090 "reachability_reason": "offline_json",
7091 })
7092}
7093
7094#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7099enum DoctorModelResolution {
7100 Resolved,
7101 ConfiguredOnly,
7102}
7103
7104#[derive(Debug, Clone, PartialEq, Eq)]
7105struct DoctorApiTarget {
7106 provider: String,
7107 base_url: String,
7108 model: String,
7109 resolution: DoctorModelResolution,
7110}
7111
7112#[derive(Debug, Clone, PartialEq, Eq)]
7113struct DoctorStrictToolModeStatus {
7114 enabled: bool,
7115 status: &'static str,
7116 function_strict_sent: bool,
7117 message: String,
7118 recommended_base_url: Option<String>,
7119}
7120
7121fn doctor_api_target(config: &Config) -> DoctorApiTarget {
7122 let provider = config.api_provider();
7123 let (model, resolution) =
7130 match crate::route_runtime::resolve_runtime_route(config, provider, None) {
7131 Ok(route) => (route.model.clone(), DoctorModelResolution::Resolved),
7132 Err(_) => (
7133 config.default_model(),
7134 DoctorModelResolution::ConfiguredOnly,
7135 ),
7136 };
7137 DoctorApiTarget {
7138 provider: config.provider_identity_for(provider),
7139 base_url: config.deepseek_base_url(),
7140 model,
7141 resolution,
7142 }
7143}
7144
7145fn doctor_strict_tool_mode_status(config: &Config) -> DoctorStrictToolModeStatus {
7146 if !config.strict_tool_mode.unwrap_or(false) {
7147 return DoctorStrictToolModeStatus {
7148 enabled: false,
7149 status: "disabled",
7150 function_strict_sent: false,
7151 message: "disabled".to_string(),
7152 recommended_base_url: None,
7153 };
7154 }
7155
7156 let target = doctor_api_target(config);
7157 match known_deepseek_base_url_kind(&target.base_url) {
7158 Some(DeepSeekBaseUrlKind::Beta) => DoctorStrictToolModeStatus {
7159 enabled: true,
7160 status: "ready",
7161 function_strict_sent: true,
7162 message: "enabled; DeepSeek strict schemas use the beta endpoint".to_string(),
7163 recommended_base_url: None,
7164 },
7165 Some(DeepSeekBaseUrlKind::NonBeta) => {
7166 let recommended = recommended_strict_base_url(config, &target.base_url);
7167 DoctorStrictToolModeStatus {
7168 enabled: true,
7169 status: "fallback_non_beta",
7170 function_strict_sent: false,
7171 message:
7172 "enabled, but function.strict is stripped for this non-beta DeepSeek endpoint"
7173 .to_string(),
7174 recommended_base_url: Some(recommended.to_string()),
7175 }
7176 }
7177 None => DoctorStrictToolModeStatus {
7178 enabled: true,
7179 status: "custom_endpoint",
7180 function_strict_sent: true,
7181 message: "enabled; function.strict will be sent to this custom endpoint".to_string(),
7182 recommended_base_url: None,
7183 },
7184 }
7185}
7186
7187fn doctor_strict_tool_mode_report_json(status: &DoctorStrictToolModeStatus) -> serde_json::Value {
7188 serde_json::json!({
7189 "enabled": status.enabled,
7190 "status": status.status,
7191 "function_strict_sent": status.function_strict_sent,
7192 "message": status.message,
7193 "recommended_base_url": status
7194 .recommended_base_url
7195 .as_deref()
7196 .map(crate::doctor::structural_url_authority),
7197 })
7198}
7199
7200#[derive(Debug, Clone, PartialEq, Eq)]
7201struct DoctorTlsStatus {
7202 certificate_verification: bool,
7203 insecure_skip_tls_verify: bool,
7204 provider: String,
7205 message: String,
7206}
7207
7208fn doctor_tls_status(config: &Config) -> DoctorTlsStatus {
7209 let provider = config.provider_identity_for(config.api_provider());
7210 let insecure_skip_tls_verify = config.insecure_skip_tls_verify();
7211 let message = if insecure_skip_tls_verify {
7212 format!(
7213 "TLS certificate verification cannot be disabled for provider {provider}; use SSL_CERT_FILE with a trusted custom CA bundle"
7214 )
7215 } else {
7216 "TLS certificate verification enabled".to_string()
7217 };
7218 DoctorTlsStatus {
7219 certificate_verification: true,
7220 insecure_skip_tls_verify,
7221 provider,
7222 message,
7223 }
7224}
7225
7226#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7227enum DeepSeekBaseUrlKind {
7228 Beta,
7229 NonBeta,
7230}
7231
7232fn known_deepseek_base_url_kind(base_url: &str) -> Option<DeepSeekBaseUrlKind> {
7233 let normalized = base_url.trim_end_matches('/');
7234 if normalized.eq_ignore_ascii_case("https://api.deepseek.com/beta")
7235 || normalized.eq_ignore_ascii_case("https://api.deepseeki.com/beta")
7236 {
7237 Some(DeepSeekBaseUrlKind::Beta)
7238 } else if normalized.eq_ignore_ascii_case("https://api.deepseek.com")
7239 || normalized.eq_ignore_ascii_case("https://api.deepseek.com/v1")
7240 || normalized.eq_ignore_ascii_case("https://api.deepseeki.com")
7241 || normalized.eq_ignore_ascii_case("https://api.deepseeki.com/v1")
7242 {
7243 Some(DeepSeekBaseUrlKind::NonBeta)
7244 } else {
7245 None
7246 }
7247}
7248
7249fn recommended_strict_base_url(_config: &Config, _base_url: &str) -> &'static str {
7250 crate::config::DEFAULT_DEEPSEEK_BASE_URL
7251}
7252
7253fn doctor_timeout_recovery_lines(config: &Config) -> Vec<String> {
7254 let target = doctor_api_target(config);
7255 let mut lines = vec![format!(
7256 "Connection timed out while reaching {}.",
7257 crate::doctor::structural_url_authority(&target.base_url)
7258 )];
7259
7260 match config.api_provider() {
7261 crate::config::ApiProvider::Deepseek
7262 if target.base_url.contains("api.deepseek.com")
7263 && !target.base_url.contains("api.deepseeki.com") =>
7264 {
7265 lines.push(
7266 "If this is a custom DeepSeek-compatible endpoint, set its HTTPS base URL in ~/.codewhale/config.toml and rerun `codewhale doctor`."
7267 .to_string(),
7268 );
7269 }
7270 crate::config::ApiProvider::Deepseek | crate::config::ApiProvider::DeepseekCN => {
7271 lines.push(
7272 "If this is a custom DeepSeek-compatible endpoint, confirm it serves `/v1/models` and `/v1/chat/completions` over HTTPS."
7273 .to_string(),
7274 );
7275 }
7276 _ => {
7277 lines.push(
7278 "Confirm the configured provider endpoint is reachable and OpenAI-compatible for `/v1/models` and `/v1/chat/completions`."
7279 .to_string(),
7280 );
7281 }
7282 }
7283
7284 lines.push(
7285 "Run `codewhale doctor --json` and include `base_url`, `default_text_model`, and `api_connectivity` when filing an issue."
7286 .to_string(),
7287 );
7288 lines
7289}
7290
7291fn run_features_command(config: &Config, command: FeaturesCli) -> Result<()> {
7292 match command.command {
7293 FeaturesSubcommand::List => {
7294 print!("{}", render_feature_table(&config.features()));
7295 Ok(())
7296 }
7297 }
7298}
7299
7300async fn run_models(config: &Config, args: ModelsArgs) -> Result<()> {
7301 use crate::client::DeepSeekClient;
7302
7303 let client = DeepSeekClient::new(config)?;
7304 let mut models = client.list_models().await?;
7305 models.sort_by(|a, b| a.id.cmp(&b.id));
7306
7307 if args.json {
7308 println!("{}", serde_json::to_string_pretty(&models)?);
7309 return Ok(());
7310 }
7311
7312 if models.is_empty() {
7313 println!("No models returned by the API.");
7314 return Ok(());
7315 }
7316
7317 let default_model = config.default_model();
7318
7319 println!("Available models (default: {default_model})");
7320 for model in models {
7321 let marker = if model.id == default_model { "*" } else { " " };
7322 if let Some(owner) = model.owned_by {
7323 println!("{marker} {} ({owner})", model.id);
7324 } else {
7325 println!("{marker} {}", model.id);
7326 }
7327 }
7328
7329 Ok(())
7330}
7331
7332async fn run_speech(config: &Config, args: SpeechArgs) -> Result<()> {
7333 use crate::client::{DeepSeekClient, SpeechSynthesisRequest};
7334 use crate::config::ApiProvider;
7335 use crate::tools::speech::{
7336 DEFAULT_VOICE, SPEECH_MODEL_EXAMPLES, combine_speech_instructions,
7337 default_speech_output_name, describe_speech_voice, encode_voice_clone_sample_data_uri,
7338 infer_speech_model, normalize_speech_format,
7339 };
7340
7341 let SpeechArgs {
7342 text,
7343 output,
7344 output_dir,
7345 model,
7346 voice,
7347 instruction,
7348 voice_prompt,
7349 clone_voice,
7350 format,
7351 json: json_output,
7352 } = args;
7353
7354 if config.api_provider() != ApiProvider::XiaomiMimo {
7355 bail!(
7356 "`speech` requires provider = \"xiaomi-mimo\" (current: {}). Run with `--provider xiaomi-mimo` or set it in config.",
7357 config.api_provider().as_str()
7358 );
7359 }
7360
7361 if text.trim().is_empty() {
7362 bail!("Speech text cannot be empty");
7363 }
7364 let voice_is_data_uri = voice
7365 .as_deref()
7366 .map(str::trim)
7367 .is_some_and(|value| value.starts_with("data:audio/"));
7368 if clone_voice.is_some() && voice.is_some() {
7369 bail!("Use either --clone-voice or --voice for cloned voice data, not both");
7370 }
7371 let model = infer_speech_model(
7372 model.as_deref(),
7373 clone_voice.is_some() || voice_is_data_uri,
7374 voice_prompt.is_some(),
7375 );
7376 let model_lower = model.to_ascii_lowercase();
7377 if !model_lower.contains("tts") {
7378 bail!(
7379 "speech requires a TTS model (examples: {}); got {model}",
7380 SPEECH_MODEL_EXAMPLES.join(", ")
7381 );
7382 }
7383 let is_voice_design = model_lower.contains("voicedesign");
7384 let is_voice_clone = model_lower.contains("voiceclone");
7385
7386 let instruction = combine_speech_instructions(instruction, voice_prompt);
7387 if is_voice_design
7388 && instruction
7389 .as_deref()
7390 .is_none_or(|value| value.trim().is_empty())
7391 {
7392 bail!(
7393 "mimo-v2.5-tts-voicedesign requires --voice-prompt or --instruction to describe the voice"
7394 );
7395 }
7396
7397 let voice = if let Some(clone_path) = clone_voice {
7398 Some(encode_voice_clone_sample_data_uri(&clone_path)?)
7399 } else if is_voice_design {
7400 None
7401 } else if let Some(value) = voice.filter(|value| !value.trim().is_empty()) {
7402 Some(value)
7403 } else if is_voice_clone {
7404 bail!("mimo-v2.5-tts-voiceclone requires --clone-voice <mp3|wav> or --voice <data-uri>");
7405 } else {
7406 Some(DEFAULT_VOICE.to_string())
7407 };
7408 let format = normalize_speech_format(&format).with_context(|| {
7409 format!("Unsupported speech format '{format}' (allowed: wav, mp3, pcm16)")
7410 })?;
7411 let output = output.unwrap_or_else(|| {
7412 output_dir
7413 .or_else(|| config.speech_output_dir())
7414 .unwrap_or_default()
7415 .join(default_speech_output_name(&format))
7416 });
7417
7418 let client = DeepSeekClient::new(config)?;
7419 let response = client
7420 .synthesize_speech(SpeechSynthesisRequest {
7421 model: model.clone(),
7422 text,
7423 instruction,
7424 audio_format: format.clone(),
7425 voice,
7426 })
7427 .await?;
7428
7429 if let Some(parent) = output.parent().filter(|path| !path.as_os_str().is_empty()) {
7430 std::fs::create_dir_all(parent)
7431 .with_context(|| format!("Failed to create output directory {}", parent.display()))?;
7432 }
7433 std::fs::write(&output, &response.audio_bytes)
7434 .with_context(|| format!("Failed to write audio file {}", output.display()))?;
7435
7436 if json_output {
7437 println!(
7438 "{}",
7439 serde_json::to_string_pretty(&serde_json::json!({
7440 "mode": "speech",
7441 "success": true,
7442 "model": response.model,
7443 "format": response.audio_format,
7444 "output": output.display().to_string(),
7445 "bytes": response.audio_bytes.len(),
7446 "voice": response.voice.as_deref().map(describe_speech_voice),
7447 "transcript": response.transcript,
7448 }))?
7449 );
7450 } else {
7451 println!(
7452 "Generated speech: {} ({} bytes, model: {}, format: {})",
7453 output.display(),
7454 response.audio_bytes.len(),
7455 response.model,
7456 response.audio_format
7457 );
7458 }
7459
7460 Ok(())
7461}
7462
7463#[cfg(test)]
7464mod speech_cli_tests {
7465 use super::*;
7466 use crate::tools::speech::{
7467 default_speech_output_name, infer_speech_model, normalize_speech_format,
7468 };
7469
7470 #[test]
7471 fn normalizes_documented_speech_formats() {
7472 assert_eq!(normalize_speech_format("WAV").as_deref(), Some("wav"));
7473 assert_eq!(normalize_speech_format("pcm16").as_deref(), Some("pcm16"));
7474 assert_eq!(normalize_speech_format("pcm").as_deref(), Some("pcm16"));
7475 assert_eq!(normalize_speech_format("flac"), None);
7476 }
7477
7478 #[test]
7479 fn default_speech_output_tracks_requested_format() {
7480 assert_eq!(
7481 PathBuf::from(default_speech_output_name("mp3")),
7482 PathBuf::from("speech.mp3")
7483 );
7484 assert_eq!(
7485 PathBuf::from("audio").join(default_speech_output_name("pcm")),
7486 PathBuf::from("audio").join("speech.pcm16")
7487 );
7488 }
7489
7490 #[test]
7491 fn speech_command_parses_cli_passthrough_smoke() {
7492 let cli = Cli::try_parse_from([
7493 "codewhale-tui",
7494 "speech",
7495 "hello",
7496 "--model",
7497 "tts",
7498 "--format",
7499 "pcm",
7500 "--output-dir",
7501 "audio",
7502 "--voice",
7503 "Mia",
7504 ])
7505 .expect("speech command parses");
7506
7507 let Some(Commands::Speech(args)) = cli.command else {
7508 panic!("expected speech command");
7509 };
7510 assert_eq!(args.text, "hello");
7511 assert_eq!(
7512 infer_speech_model(args.model.as_deref(), false, false),
7513 "mimo-v2.5-tts"
7514 );
7515 assert_eq!(
7516 normalize_speech_format(&args.format).as_deref(),
7517 Some("pcm16")
7518 );
7519 assert_eq!(args.output_dir, Some(PathBuf::from("audio")));
7520 assert_eq!(args.voice.as_deref(), Some("Mia"));
7521 }
7522}
7523
7524async fn test_api_connectivity(config: &Config) -> Result<()> {
7526 use crate::client::DeepSeekClient;
7527 use crate::models::{ContentBlock, Message, MessageRequest};
7528
7529 let client = DeepSeekClient::new(config)?;
7530 let model = client.model().to_string();
7531
7532 if crate::doctor::is_keyless_ds4_route(config) {
7533 return crate::doctor::probe_ds4_models(config).await;
7534 }
7535
7536 let request = MessageRequest {
7538 model: model.clone(),
7539 messages: vec![Message {
7540 role: "user".to_string(),
7541 content: vec![ContentBlock::Text {
7542 text: "hi".to_string(),
7543 cache_control: None,
7544 }],
7545 }],
7546 max_tokens: 1,
7547 system: None,
7548 tools: None,
7549 tool_choice: None,
7550 metadata: None,
7551 thinking: None,
7552 reasoning_effort: Some("off".to_string()),
7554 stream: Some(false),
7555 temperature: None,
7556 top_p: None,
7557 };
7558
7559 let timeout_duration = std::time::Duration::from_secs(15);
7561 match tokio::time::timeout(timeout_duration, client.create_message(request)).await {
7562 Ok(Ok(_response)) => Ok(()),
7563 Ok(Err(e)) => Err(e),
7564 Err(_) => anyhow::bail!("Request timeout after 15 seconds"),
7565 }
7566}
7567
7568fn rustc_version() -> String {
7569 let Some(mut cmd) = crate::dependencies::RustC::command() else {
7570 return "unknown".to_string();
7571 };
7572 let Ok(output) = cmd.arg("--version").output() else {
7573 return "unknown".to_string();
7574 };
7575 String::from_utf8(output.stdout)
7576 .map(|s| s.trim().to_string())
7577 .unwrap_or_else(|_| "unknown".to_string())
7578}
7579
7580fn sessions_resume_command() -> &'static str {
7582 "codewhale resume"
7583}
7584
7585fn list_sessions(limit: usize, search: Option<String>) -> Result<()> {
7586 use crate::palette;
7587 use colored::Colorize;
7588 use session_manager::{SessionManager, format_session_line};
7589
7590 let (action_r, action_g, action_b) = palette::WHALE_ACTION_RGB;
7591 let (human_r, human_g, human_b) = palette::WHALE_HUMAN_RGB;
7592 let (sky_r, sky_g, sky_b) = palette::WHALE_INFO_RGB;
7593 let (aqua_r, aqua_g, aqua_b) = palette::WHALE_INFO_RGB;
7594
7595 let manager = SessionManager::default_location()?;
7596
7597 let sessions = if let Some(query) = search {
7598 manager.search_sessions(&query)?
7599 } else {
7600 manager.list_sessions()?
7601 };
7602
7603 if sessions.is_empty() {
7604 println!("{}", "No sessions found.".truecolor(sky_r, sky_g, sky_b));
7605 println!(
7606 "Start a new session with: {}",
7607 "codewhale".truecolor(human_r, human_g, human_b)
7608 );
7609 return Ok(());
7610 }
7611
7612 println!(
7613 "{}",
7614 "Saved Sessions"
7615 .truecolor(action_r, action_g, action_b)
7616 .bold()
7617 );
7618 println!("{}", "==============".truecolor(sky_r, sky_g, sky_b));
7619 println!();
7620
7621 for (i, session) in sessions.iter().take(limit).enumerate() {
7622 let line = format_session_line(session);
7623 if i == 0 {
7624 println!(" {} {}", "*".truecolor(aqua_r, aqua_g, aqua_b), line);
7625 } else {
7626 println!(" {line}");
7627 }
7628 }
7629
7630 let total = sessions.len();
7631 if total > limit {
7632 println!();
7633 println!(
7634 " {} more session(s). Use --limit to show more.",
7635 total - limit
7636 );
7637 }
7638
7639 println!();
7640 println!(
7641 "Resume with: {} {}",
7642 sessions_resume_command().truecolor(action_r, action_g, action_b),
7643 "<session-id>".dimmed()
7644 );
7645 println!(
7646 "Continue latest in this workspace: {}",
7647 "codewhale --continue".truecolor(action_r, action_g, action_b)
7648 );
7649
7650 Ok(())
7651}
7652
7653fn init_project() -> Result<()> {
7655 use crate::palette;
7656 use colored::Colorize;
7657 use project_context::create_default_agents_md;
7658
7659 let (sky_r, sky_g, sky_b) = palette::WHALE_INFO_RGB;
7660 let (aqua_r, aqua_g, aqua_b) = palette::WHALE_INFO_RGB;
7661 let (red_r, red_g, red_b) = palette::WHALE_ERROR_RGB;
7662
7663 let workspace = std::env::current_dir()?;
7664 let agents_path = workspace.join("AGENTS.md");
7665
7666 if agents_path.exists() {
7667 println!(
7668 "{} AGENTS.md already exists at {}",
7669 "!".truecolor(sky_r, sky_g, sky_b),
7670 agents_path.display()
7671 );
7672 return Ok(());
7673 }
7674
7675 match create_default_agents_md(&workspace) {
7676 Ok(path) => {
7677 println!(
7678 "{} Created {}",
7679 "✓".truecolor(aqua_r, aqua_g, aqua_b),
7680 path.display()
7681 );
7682 println!();
7683 println!("Edit this file to customize how the AI agent works with your project.");
7684 println!("The instructions will be loaded automatically when you run codewhale.");
7685 }
7686 Err(e) => {
7687 println!(
7688 "{} Failed to create AGENTS.md: {}",
7689 "✗".truecolor(red_r, red_g, red_b),
7690 e
7691 );
7692 }
7693 }
7694
7695 Ok(())
7696}
7697
7698fn resolve_workspace(cli: &Cli) -> PathBuf {
7699 cli.workspace
7700 .clone()
7701 .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
7702}
7703
7704fn load_config_from_cli(cli: &Cli) -> Result<Config> {
7705 load_config_from_cli_with_effective_profile(cli).map(|(config, _)| config)
7706}
7707
7708fn load_doctor_config_from_cli(cli: &Cli, args: &DoctorArgs) -> Result<Config> {
7713 if args.probe_api || args.probe_local {
7714 return load_config_from_cli(cli);
7715 }
7716 load_structural_config_from_cli(cli)
7717}
7718
7719fn load_structural_config_from_cli(cli: &Cli) -> Result<Config> {
7720 let profile = effective_config_profile(cli);
7721 let mut config = Config::load_structural(cli.config.clone(), profile.as_deref())?;
7722 if let Ok(settings) = crate::settings::Settings::load_read_only() {
7723 apply_saved_reasoning_preference(&mut config, &settings);
7724 }
7725 cli.feature_toggles.apply(&mut config)?;
7726 Ok(config)
7727}
7728
7729fn effective_config_profile(cli: &Cli) -> Option<String> {
7730 cli.profile
7731 .clone()
7732 .or_else(|| std::env::var("CODEWHALE_PROFILE").ok())
7733 .or_else(|| std::env::var("DEEPSEEK_PROFILE").ok())
7734}
7735
7736fn load_config_from_cli_with_effective_profile(cli: &Cli) -> Result<(Config, Option<String>)> {
7737 let profile = effective_config_profile(cli);
7738 let mut config = Config::load(cli.config.clone(), profile.as_deref())?;
7739 if let Ok(settings) = crate::settings::Settings::load_read_only() {
7743 apply_saved_reasoning_preference(&mut config, &settings);
7744 }
7745 cli.feature_toggles.apply(&mut config)?;
7746 Ok((config, profile))
7747}
7748
7749fn apply_saved_reasoning_preference(config: &mut Config, settings: &crate::settings::Settings) {
7757 let Some(reasoning_effort) = settings.reasoning_effort.as_ref() else {
7758 return;
7759 };
7760 config.reasoning_effort = Some(reasoning_effort.clone());
7761 config.reasoning_effort_inferred_from_legacy_alias = false;
7762}
7763
7764fn read_api_key_from_stdin() -> Result<String> {
7765 let mut stdin = io::stdin();
7766 if stdin.is_terminal() {
7767 bail!("No API key provided. Pass --api-key or pipe one via stdin.");
7768 }
7769 let mut buffer = String::new();
7770 stdin.read_to_string(&mut buffer)?;
7771 let api_key = buffer.trim().to_string();
7772 if api_key.is_empty() {
7773 bail!("No API key provided via stdin.");
7774 }
7775 Ok(api_key)
7776}
7777
7778fn run_login(api_key: Option<String>) -> Result<()> {
7779 let api_key = match api_key {
7780 Some(key) => key,
7781 None => read_api_key_from_stdin()?,
7782 };
7783 let saved = config::save_api_key(&api_key)?;
7784 println!("Saved API key to {}", saved.describe());
7785 Ok(())
7786}
7787
7788fn run_logout() -> Result<()> {
7789 config::clear_api_key()?;
7790 println!("Cleared saved API key.");
7791 Ok(())
7792}
7793
7794async fn run_xai_device_auth(config_path: Option<&Path>) -> Result<()> {
7795 let pending = xai_oauth::device_code_login().await?;
7796 let activation = xai_oauth::activate_device_login(pending, config_path, None)?;
7797 println!(
7798 "xAI OAuth is ready; activated {} via {}",
7799 codewhale_config::quote_os_path(&activation.auth_path),
7800 codewhale_config::quote_os_path(&activation.config_path)
7801 );
7802 Ok(())
7803}
7804
7805fn resolve_session_id(session_id: Option<String>, last: bool, workspace: &Path) -> Result<String> {
7806 if last {
7807 return latest_session_id_for_workspace(workspace)?.ok_or_else(|| {
7808 anyhow!(
7809 "No saved sessions found for workspace {}. Use `codewhale sessions` to list all sessions, or `codewhale resume <SESSION_ID>` to resume one explicitly.",
7810 workspace.display()
7811 )
7812 });
7813 }
7814 if let Some(id) = session_id {
7815 return Ok(id);
7816 }
7817 pick_session_id()
7818}
7819
7820fn latest_session_id_for_workspace(workspace: &Path) -> std::io::Result<Option<String>> {
7821 let manager = SessionManager::default_location()?;
7822 Ok(manager
7823 .get_latest_session_for_workspace(workspace)?
7824 .map(|session| session.id))
7825}
7826
7827fn fork_session(
7828 config: &Config,
7829 session_id: Option<String>,
7830 last: bool,
7831 workspace: &Path,
7832) -> Result<String> {
7833 let manager = SessionManager::default_location()?;
7834 let saved = if last {
7835 let Some(meta) = manager.get_latest_session_for_workspace(workspace)? else {
7836 bail!(
7837 "No saved sessions found for workspace {}.",
7838 workspace.display()
7839 );
7840 };
7841 manager.load_session(&meta.id)?
7842 } else {
7843 let id = resolve_session_id(session_id, false, workspace)?;
7844 manager.load_session_by_prefix(&id)?
7845 };
7846 let saved_provider_identity = saved
7847 .metadata
7848 .model_provider_id
7849 .as_deref()
7850 .filter(|identity| !identity.trim().is_empty())
7851 .unwrap_or(&saved.metadata.model_provider);
7852 let provider_identity = config
7853 .resolve_persisted_provider_identity(
7854 Some(&saved.metadata.model_provider),
7855 saved.metadata.model_provider_id.as_deref(),
7856 )
7857 .map_err(anyhow::Error::msg)
7858 .with_context(|| {
7859 format!(
7860 "saved session provider '{}' is unavailable; fork will not fall back",
7861 saved_provider_identity
7862 )
7863 })?;
7864
7865 let system_prompt = saved
7866 .system_prompt
7867 .as_ref()
7868 .map(|text| SystemPrompt::Text(text.clone()));
7869 let mut forked = create_saved_session(
7870 &saved.messages,
7871 &saved.metadata.model,
7872 &saved.metadata.workspace,
7873 saved.metadata.total_tokens,
7874 system_prompt.as_ref(),
7875 );
7876 forked.metadata.set_model_provider_route(
7877 provider_identity.provider.as_str(),
7878 provider_identity.persisted_id(),
7879 );
7880 forked.metadata.copy_cost_from(&saved.metadata);
7881 forked.metadata.mark_forked_from(&saved.metadata);
7882 manager.save_session(&forked)?;
7883
7884 let source_title = saved.metadata.title.trim();
7885 let source_label = if source_title.is_empty() {
7886 "session".to_string()
7887 } else {
7888 format!("\"{source_title}\"")
7889 };
7890 println!(
7891 "Forked {source_label} ({source_id}) → new session {new_id}",
7892 source_id = truncate_id(&saved.metadata.id),
7893 new_id = truncate_id(&forked.metadata.id),
7894 );
7895
7896 Ok(forked.metadata.id)
7897}
7898
7899fn pick_session_id() -> Result<String> {
7900 let manager = SessionManager::default_location()?;
7901 let sessions = manager.list_sessions()?;
7902 if sessions.is_empty() {
7903 bail!("No saved sessions found.");
7904 }
7905
7906 println!("Select a session to resume:");
7907 for (idx, session) in sessions.iter().enumerate() {
7908 println!(" {:>2}. {} ({})", idx + 1, session.title, session.id);
7909 }
7910 print!("Enter a number (or press Enter to cancel): ");
7911 io::stdout().flush()?;
7912
7913 let mut input = String::new();
7914 io::stdin().read_line(&mut input)?;
7915 let input = input.trim();
7916 if input.is_empty() {
7917 bail!("No session selected.");
7918 }
7919 let idx: usize = input
7920 .parse()
7921 .map_err(|_| anyhow::anyhow!("Invalid input"))?;
7922 let session = sessions
7923 .get(idx.saturating_sub(1))
7924 .ok_or_else(|| anyhow::anyhow!("Selection out of range"))?;
7925 Ok(session.id.clone())
7926}
7927
7928async fn run_review(config: &Config, args: ReviewArgs) -> Result<()> {
7929 use crate::client::DeepSeekClient;
7930
7931 let diff = collect_diff(&args)?;
7932 if diff.trim().is_empty() {
7933 bail!("No diff to review.");
7934 }
7935 validate_review_receipt_args(&args)?;
7936 if args.check_receipt {
7937 return run_review_receipt_check(&diff, &args);
7938 }
7939
7940 let model = resolve_review_model(config, args.model.as_deref());
7941 let route = resolve_cli_exec_route(config, &model, &diff, args.model.is_none()).await?;
7942 let execution_config = config_for_cli_route(config, &route);
7943 let route_provider = execution_config.provider_identity_for(route.provider);
7944 let model = route.model.clone();
7945 let user_prompt =
7946 format!("Review the following diff and provide feedback:\n\n{diff}\n\nEnd of diff.");
7947 let reasoning_effort = route.reasoning_effort.and_then(|effort| {
7948 cli_reasoning_effort_value_for_prompt(&execution_config, &model, effort, &user_prompt)
7949 });
7950
7951 let system = SystemPrompt::Text(
7952 "You are a senior code reviewer. Focus on bugs, risks, behavioral regressions, and missing tests. \
7953Provide findings ordered by severity with file references, then open questions, then a brief summary."
7954 .to_string(),
7955 );
7956 let client = DeepSeekClient::new(&execution_config)?;
7957 let request_route = client.effective_route_envelope(&model, chrono::Utc::now());
7958 let request = MessageRequest {
7959 model: model.clone(),
7960 messages: vec![Message {
7961 role: "user".to_string(),
7962 content: vec![ContentBlock::Text {
7963 text: user_prompt,
7964 cache_control: None,
7965 }],
7966 }],
7967 max_tokens: client.effective_max_output_tokens(&request_route.model),
7968 system: Some(system),
7969 tools: None,
7970 tool_choice: None,
7971 metadata: None,
7972 thinking: None,
7973 reasoning_effort,
7974 stream: Some(false),
7975 temperature: None,
7976 top_p: None,
7977 };
7978
7979 let response = client.create_message(request).await?;
7980 let review_stop_reason = response.stop_reason.clone();
7981 let review_incomplete = crate::models::is_incomplete_stop_reason(review_stop_reason.as_deref());
7982 let mut output = String::new();
7983 for block in response.content {
7984 if let ContentBlock::Text { text, .. } = block {
7985 output.push_str(&text);
7986 }
7987 }
7988 let receipt = if args.write_receipt && !review_incomplete {
7991 let parsed_output = crate::tools::review::ReviewOutput::from_str(&output);
7992 let receipt = crate::tools::review::build_review_receipt(
7993 review_target_label(&args),
7994 &diff,
7995 &route_provider,
7996 &model,
7997 &parsed_output,
7998 &output,
7999 Vec::new(),
8000 );
8001 let path =
8002 crate::tools::review::write_review_receipt(&receipt, args.receipt_path.as_deref())?;
8003 Some((path, receipt))
8004 } else {
8005 None
8006 };
8007 let review_error = review_incomplete.then(|| {
8008 format!(
8009 "Model response incomplete: provider stop reason `{}`; the partial review was not accepted.",
8010 crate::models::stop_reason_detail(review_stop_reason.as_deref())
8011 )
8012 });
8013 if args.json {
8014 println!(
8015 "{}",
8016 serde_json::to_string_pretty(&serde_json::json!({
8017 "mode": "review",
8018 "provider": route_provider,
8019 "model": model,
8020 "success": !review_incomplete,
8021 "content": output,
8022 "stop_reason": review_stop_reason,
8023 "error": review_error,
8024 "receipt_path": receipt
8025 .as_ref()
8026 .map(|(path, _)| path.display().to_string()),
8027 "receipt": receipt.as_ref().map(|(_, receipt)| receipt),
8028 }))?
8029 );
8030 if let Some(error) = review_error {
8031 anyhow::bail!(error);
8032 }
8033 } else {
8034 println!("{output}");
8035 if let Some((path, _)) = receipt {
8036 eprintln!("Review receipt written: {}", path.display());
8037 }
8038 if let Some(error) = review_error {
8039 anyhow::bail!(error);
8040 }
8041 }
8042 Ok(())
8043}
8044
8045fn resolve_review_model(config: &Config, explicit_model: Option<&str>) -> String {
8046 explicit_model
8047 .map(str::trim)
8048 .filter(|model| !model.is_empty())
8049 .map(str::to_string)
8050 .unwrap_or_else(|| config.default_model())
8051}
8052
8053fn validate_review_receipt_args(args: &ReviewArgs) -> Result<()> {
8054 if args.receipt_path.is_some() && !args.write_receipt && !args.check_receipt {
8055 bail!("--receipt-path requires --write-receipt or --check-receipt");
8056 }
8057 if args.write_receipt && args.check_receipt {
8058 bail!("--write-receipt and --check-receipt are mutually exclusive");
8059 }
8060 Ok(())
8061}
8062
8063fn run_review_receipt_check(diff: &str, args: &ReviewArgs) -> Result<()> {
8064 let (path, receipt) = if let Some(path) = args.receipt_path.as_ref() {
8065 (
8066 path.clone(),
8067 crate::tools::review::read_review_receipt(path)
8068 .with_context(|| format!("failed to read review receipt {}", path.display()))?,
8069 )
8070 } else {
8071 crate::tools::review::latest_review_receipt_for_diff(diff)?.ok_or_else(|| {
8072 anyhow!(
8073 "No review receipt found for the current diff. Run `codewhale review --write-receipt` first, or pass --receipt-path."
8074 )
8075 })?
8076 };
8077 let validation =
8078 crate::tools::review::validate_review_receipt_for_diff(diff, &receipt, Some(path.clone()));
8079
8080 if args.json {
8081 println!(
8082 "{}",
8083 serde_json::to_string_pretty(&serde_json::json!({
8084 "mode": "review_receipt_check",
8085 "success": validation.passed,
8086 "validation": review_receipt_validation_public_json(&validation),
8087 }))?
8088 );
8089 } else if validation.passed {
8090 println!("Review receipt valid: {}", path.display());
8091 }
8092
8093 if !validation.passed {
8094 bail!("Review receipt check failed: {}", validation.reason);
8095 }
8096 Ok(())
8097}
8098
8099fn review_receipt_validation_public_json(
8100 validation: &crate::tools::review::ReviewReceiptValidation,
8101) -> serde_json::Value {
8102 let unresolved_risk = validation.unresolved_risk.as_ref();
8103 serde_json::json!({
8104 "passed": validation.passed,
8105 "status": review_receipt_validation_status(validation),
8106 "diff_fingerprint": validation.diff_fingerprint.as_str(),
8107 "receipt_fingerprint": validation.receipt_fingerprint.as_deref(),
8108 "unresolved": unresolved_risk.is_some_and(|risk| risk.unresolved),
8109 "risk_level": unresolved_risk.map(|risk| risk.level.as_str()),
8110 })
8111}
8112
8113fn review_receipt_validation_status(
8114 validation: &crate::tools::review::ReviewReceiptValidation,
8115) -> &'static str {
8116 if validation.passed {
8117 "valid"
8118 } else if validation
8119 .receipt_fingerprint
8120 .as_deref()
8121 .is_some_and(|fingerprint| fingerprint != validation.diff_fingerprint.as_str())
8122 {
8123 "diff_mismatch"
8124 } else if validation
8125 .unresolved_risk
8126 .as_ref()
8127 .is_some_and(|risk| risk.unresolved)
8128 {
8129 "unresolved_risk"
8130 } else if validation
8131 .reason
8132 .starts_with("unsupported review receipt schema version")
8133 {
8134 "unsupported_schema"
8135 } else if validation.reason.starts_with("review receipt check ") {
8136 "check_failed"
8137 } else {
8138 "invalid"
8139 }
8140}
8141
8142async fn run_pr(
8146 cli: &Cli,
8147 config: &Config,
8148 number: u32,
8149 repo: Option<&str>,
8150 checkout: bool,
8151 pending_telemetry_notice: Option<crate::telemetry_notice::PendingTelemetryNotice>,
8152 plugin_registry: Arc<crate::plugins::PluginRegistry>,
8153) -> Result<()> {
8154 if !is_command_available("gh") {
8155 bail!(
8156 "`gh` CLI not found on PATH. Install GitHub CLI \
8157 (https://cli.github.com) and authenticate (`gh auth login`) \
8158 so `codewhale pr <N>` can fetch PR metadata and the diff."
8159 );
8160 }
8161
8162 let view = run_gh_pr_view(number, repo)?;
8163 let diff = run_gh_pr_diff(number, repo)?;
8164
8165 if checkout {
8166 match run_gh_pr_checkout(number, repo) {
8167 Ok(()) => eprintln!("Checked out PR #{number} into the current workspace."),
8168 Err(err) => eprintln!(
8169 "warning: gh pr checkout #{number} failed ({err}). Continuing without checkout."
8170 ),
8171 }
8172 }
8173
8174 let prompt = format_pr_prompt(number, &view, &diff);
8175 let resume_session_id = if cli.continue_session {
8176 let workspace = resolve_workspace(cli);
8177 latest_session_id_for_workspace(&workspace).ok().flatten()
8178 } else {
8179 cli.resume.clone()
8180 };
8181 run_interactive(
8182 cli,
8183 config,
8184 resume_session_id,
8185 Some(tui::InitialInput::Prefill(prompt)),
8186 pending_telemetry_notice,
8187 plugin_registry,
8188 )
8189 .await
8190}
8191
8192fn is_command_available(name: &str) -> bool {
8206 let Some(path) = std::env::var_os("PATH") else {
8207 return false;
8208 };
8209 for dir in std::env::split_paths(&path) {
8210 let candidate = dir.join(name);
8211 if candidate.is_file() {
8212 return true;
8213 }
8214 #[cfg(windows)]
8215 {
8216 if candidate.extension().is_none() && candidate.with_extension("exe").is_file() {
8220 return true;
8221 }
8222 }
8223 }
8224 false
8225}
8226
8227#[derive(Debug, Clone, Default)]
8228struct GhPullRequest {
8229 title: String,
8230 body: String,
8231 base: String,
8232 head: String,
8233 url: String,
8234}
8235
8236fn run_gh_pr_view(number: u32, repo: Option<&str>) -> Result<GhPullRequest> {
8237 let mut cmd = crate::dependencies::Gh::command()
8238 .ok_or_else(|| anyhow::anyhow!("gh not found on PATH"))?;
8239 cmd.arg("pr").arg("view").arg(number.to_string());
8240 if let Some(r) = repo {
8241 cmd.arg("--repo").arg(r);
8242 }
8243 cmd.arg("--json")
8244 .arg("title,body,baseRefName,headRefName,url");
8245 let output = cmd
8246 .output()
8247 .map_err(|e| anyhow::anyhow!("Failed to run `gh pr view`: {e}"))?;
8248 if !output.status.success() {
8249 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
8250 bail!("gh pr view #{number} failed: {stderr}");
8251 }
8252 let raw = String::from_utf8_lossy(&output.stdout).to_string();
8253 let value: serde_json::Value = serde_json::from_str(&raw)
8254 .map_err(|e| anyhow::anyhow!("gh pr view returned non-JSON output: {e}"))?;
8255 let pick = |key: &str| {
8256 value
8257 .get(key)
8258 .and_then(serde_json::Value::as_str)
8259 .unwrap_or_default()
8260 .to_string()
8261 };
8262 Ok(GhPullRequest {
8263 title: pick("title"),
8264 body: pick("body"),
8265 base: pick("baseRefName"),
8266 head: pick("headRefName"),
8267 url: pick("url"),
8268 })
8269}
8270
8271fn run_gh_pr_diff(number: u32, repo: Option<&str>) -> Result<String> {
8272 let mut cmd = crate::dependencies::Gh::command()
8273 .ok_or_else(|| anyhow::anyhow!("gh not found on PATH"))?;
8274 cmd.arg("pr").arg("diff").arg(number.to_string());
8275 if let Some(r) = repo {
8276 cmd.arg("--repo").arg(r);
8277 }
8278 let output = cmd
8279 .output()
8280 .map_err(|e| anyhow::anyhow!("Failed to run `gh pr diff`: {e}"))?;
8281 if !output.status.success() {
8282 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
8283 bail!("gh pr diff #{number} failed: {stderr}");
8284 }
8285 Ok(String::from_utf8_lossy(&output.stdout).to_string())
8286}
8287
8288fn run_gh_pr_checkout(number: u32, repo: Option<&str>) -> Result<()> {
8289 let mut cmd = crate::dependencies::Gh::command()
8290 .ok_or_else(|| anyhow::anyhow!("gh not found on PATH"))?;
8291 cmd.arg("pr").arg("checkout").arg(number.to_string());
8292 if let Some(r) = repo {
8293 cmd.arg("--repo").arg(r);
8294 }
8295 let output = cmd
8296 .output()
8297 .map_err(|e| anyhow::anyhow!("Failed to run `gh pr checkout`: {e}"))?;
8298 if !output.status.success() {
8299 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
8300 bail!("gh pr checkout #{number} failed: {stderr}");
8301 }
8302 Ok(())
8303}
8304
8305fn format_pr_prompt(number: u32, view: &GhPullRequest, diff: &str) -> String {
8310 const MAX_DIFF_BYTES: usize = 200 * 1024;
8311 let diff_section = if diff.len() > MAX_DIFF_BYTES {
8312 let cut = (0..=MAX_DIFF_BYTES)
8313 .rev()
8314 .find(|&i| diff.is_char_boundary(i))
8315 .unwrap_or(0);
8316 format!(
8317 "{}\n\n[…diff truncated at {} KiB; ask me to fetch more if needed]\n",
8318 &diff[..cut],
8319 MAX_DIFF_BYTES / 1024
8320 )
8321 } else {
8322 diff.to_string()
8323 };
8324 let body = if view.body.trim().is_empty() {
8325 "(no description)".to_string()
8326 } else {
8327 view.body.trim().to_string()
8328 };
8329 let title = if view.title.trim().is_empty() {
8330 format!("(PR #{number})")
8331 } else {
8332 view.title.trim().to_string()
8333 };
8334 let branches = match (view.base.is_empty(), view.head.is_empty()) {
8335 (false, false) => format!("{} ← {}", view.base, view.head),
8336 (false, true) => view.base.clone(),
8337 (true, false) => view.head.clone(),
8338 _ => "(unknown)".to_string(),
8339 };
8340 format!(
8341 "Review PR #{number} — {title}\n\
8342 \n\
8343 URL: {url}\n\
8344 Branches: {branches}\n\
8345 \n\
8346 ## Description\n\
8347 \n\
8348 {body}\n\
8349 \n\
8350 ## Diff\n\
8351 \n\
8352 ```diff\n\
8353 {diff_section}\n\
8354 ```\n",
8355 url = if view.url.is_empty() {
8356 "(unavailable)"
8357 } else {
8358 view.url.as_str()
8359 },
8360 )
8361}
8362
8363fn collect_diff(args: &ReviewArgs) -> Result<String> {
8364 let mut cmd = crate::dependencies::Git::command()
8365 .ok_or_else(|| anyhow::anyhow!("git not found on PATH"))?;
8366 cmd.arg("diff");
8367 if args.staged {
8368 cmd.arg("--cached");
8369 }
8370 if let Some(base) = &args.base {
8371 cmd.arg(format!("{base}...HEAD"));
8372 }
8373 if let Some(path) = &args.path {
8374 cmd.arg("--").arg(path);
8375 }
8376
8377 let output = cmd
8378 .output()
8379 .map_err(|e| anyhow::anyhow!("Failed to run git diff. Is git installed? ({e})"))?;
8380 if !output.status.success() {
8381 let stderr = String::from_utf8_lossy(&output.stderr);
8382 bail!("git diff failed: {}", stderr.trim());
8383 }
8384 let mut diff = String::from_utf8_lossy(&output.stdout).to_string();
8385 if diff.len() > args.max_chars {
8386 diff = crate::utils::truncate_with_ellipsis(&diff, args.max_chars, "\n...[truncated]\n");
8387 }
8388 Ok(diff)
8389}
8390
8391fn review_target_label(args: &ReviewArgs) -> String {
8392 let mut label = if args.staged {
8393 "staged".to_string()
8394 } else if let Some(base) = args
8395 .base
8396 .as_deref()
8397 .map(str::trim)
8398 .filter(|base| !base.is_empty())
8399 {
8400 format!("base:{base}")
8401 } else {
8402 "working-tree".to_string()
8403 };
8404 if let Some(path) = &args.path {
8405 label.push(' ');
8406 label.push_str(path.to_string_lossy().as_ref());
8407 }
8408 label
8409}
8410
8411fn run_apply(args: ApplyArgs) -> Result<()> {
8412 let patch = if let Some(path) = args.patch_file {
8413 std::fs::read_to_string(&path)
8414 .map_err(|e| anyhow::anyhow!("Failed to read patch {}: {}", path.display(), e))?
8415 } else {
8416 read_patch_from_stdin()?
8417 };
8418 if patch.trim().is_empty() {
8419 bail!("Patch is empty.");
8420 }
8421
8422 let mut tmp = NamedTempFile::new()?;
8423 tmp.write_all(patch.as_bytes())?;
8424 let tmp_path = tmp.path().to_path_buf();
8425
8426 let output = crate::dependencies::Git::command()
8427 .ok_or_else(|| anyhow::anyhow!("git not found on PATH"))?
8428 .arg("apply")
8429 .arg("--whitespace=nowarn")
8430 .arg(&tmp_path)
8431 .output()
8432 .map_err(|e| anyhow::anyhow!("Failed to run git apply: {e}"))?;
8433
8434 if !output.status.success() {
8435 let stderr = String::from_utf8_lossy(&output.stderr);
8436 bail!("git apply failed: {}", stderr.trim());
8437 }
8438 println!("Applied patch successfully.");
8439 Ok(())
8440}
8441
8442fn read_patch_from_stdin() -> Result<String> {
8443 let mut stdin = io::stdin();
8444 if stdin.is_terminal() {
8445 bail!("No patch file provided and stdin is empty.");
8446 }
8447 let mut buffer = String::new();
8448 stdin.read_to_string(&mut buffer)?;
8449 Ok(buffer)
8450}
8451
8452async fn run_mcp_command(
8453 config: &Config,
8454 workspace: &Path,
8455 command: McpCommand,
8456 plugins: &crate::plugins::PluginRegistry,
8457) -> Result<()> {
8458 let config_path = config.mcp_config_path();
8459 match command {
8460 McpCommand::Init { force } => {
8461 let status = init_mcp_config(&config_path, force)?;
8462 match status {
8463 WriteStatus::Created => {
8464 println!("Created MCP config at {}", config_path.display());
8465 }
8466 WriteStatus::Overwritten => {
8467 println!("Overwrote MCP config at {}", config_path.display());
8468 }
8469 WriteStatus::SkippedExists => {
8470 println!(
8471 "MCP config already exists at {} (use --force to overwrite)",
8472 config_path.display()
8473 );
8474 }
8475 }
8476 println!("Edit the file, then run `codewhale mcp list` or `codewhale mcp tools`.");
8477 Ok(())
8478 }
8479 McpCommand::List => {
8480 let cfg = crate::mcp::load_config_with_workspace_and_plugins(
8481 &config_path,
8482 workspace,
8483 plugins,
8484 )?;
8485 if cfg.servers.is_empty() {
8486 println!(
8487 "No MCP servers configured in {} or {}",
8488 config_path.display(),
8489 crate::mcp::workspace_mcp_config_path(workspace).display()
8490 );
8491 return Ok(());
8492 }
8493 println!("MCP servers ({}):", cfg.servers.len());
8494 for (name, server) in cfg.servers {
8495 let status = if server.enabled && !server.disabled {
8496 "enabled"
8497 } else {
8498 "disabled"
8499 };
8500 let auth_status = crate::mcp::oauth::auth_status_for_server(&name, &server).await;
8501 let auth = if auth_status == crate::mcp::oauth::McpAuthStatus::Unsupported {
8502 String::new()
8503 } else {
8504 format!(
8505 " auth={}",
8506 auth_status
8507 .to_string()
8508 .to_ascii_lowercase()
8509 .replace(' ', "-")
8510 )
8511 };
8512 let args = if server.args.is_empty() {
8513 "".to_string()
8514 } else {
8515 format!(" {}", server.args.join(" "))
8516 };
8517 let cmd_str = if let Some(cmd) = server.command {
8518 format!("{cmd}{args}")
8519 } else if let Some(url) = server.url {
8520 url
8521 } else {
8522 "unknown".to_string()
8523 };
8524 let required = if server.required { " required" } else { "" };
8525 println!(" - {name} [{status}{required}{auth}] {cmd_str}");
8526 }
8527 Ok(())
8528 }
8529 McpCommand::Connect { server } => {
8530 let mut pool = McpPool::from_config_path_with_workspace_and_plugins(
8531 &config_path,
8532 workspace,
8533 std::sync::Arc::new(plugins.clone()),
8534 )?;
8535 if let Some(name) = server {
8536 if let Err(err) = pool.get_or_connect(&name).await {
8537 if crate::mcp::oauth::error_looks_auth_required(&err) {
8538 let hint = crate::mcp::oauth::auth_required_login_hint(&name);
8539 return Err(err).context(hint);
8540 }
8541 return Err(err);
8542 }
8543 println!("Connected to MCP server: {name}");
8544 } else {
8545 let errors = pool.connect_all().await;
8546 if errors.is_empty() {
8547 println!("Connected to all configured MCP servers.");
8548 } else {
8549 for (name, err) in errors {
8550 eprintln!("Failed to connect {name}: {err:#}");
8551 if crate::mcp::oauth::error_looks_auth_required(&err) {
8552 eprintln!(" {}", crate::mcp::oauth::auth_required_login_hint(&name));
8553 }
8554 }
8555 }
8556 }
8557 Ok(())
8558 }
8559 McpCommand::Tools { server } => {
8560 let mut pool = McpPool::from_config_path_with_workspace_and_plugins(
8561 &config_path,
8562 workspace,
8563 std::sync::Arc::new(plugins.clone()),
8564 )?;
8565 if let Some(name) = server {
8566 let conn = match pool.get_or_connect(&name).await {
8567 Ok(conn) => conn,
8568 Err(err) => {
8569 if crate::mcp::oauth::error_looks_auth_required(&err) {
8570 let hint = crate::mcp::oauth::auth_required_login_hint(&name);
8571 return Err(err).context(hint);
8572 }
8573 return Err(err);
8574 }
8575 };
8576 if conn.tools().is_empty() {
8577 println!("No tools found for MCP server: {name}");
8578 } else {
8579 println!("Tools for {name}:");
8580 for tool in conn.tools() {
8581 println!(
8582 " - {}{}",
8583 tool.name,
8584 crate::mcp::format_mcp_tool_description(tool.description.as_deref())
8585 );
8586 }
8587 }
8588 } else {
8589 let errors = pool.connect_all().await;
8590 for (name, err) in errors {
8591 eprintln!("Failed to connect {name}: {err:#}");
8592 if crate::mcp::oauth::error_looks_auth_required(&err) {
8593 eprintln!(" {}", crate::mcp::oauth::auth_required_login_hint(&name));
8594 }
8595 }
8596 let tools = pool.all_tools();
8597 if tools.is_empty() {
8598 println!("No MCP tools discovered.");
8599 } else {
8600 println!("MCP tools:");
8601 for (name, tool) in tools {
8602 println!(
8603 " - {}{}",
8604 name,
8605 crate::mcp::format_mcp_tool_description(tool.description.as_deref())
8606 );
8607 }
8608 }
8609 }
8610 Ok(())
8611 }
8612 McpCommand::Add {
8613 name,
8614 command,
8615 url,
8616 transport,
8617 bearer_token_env_var,
8618 oauth_client_id,
8619 oauth_resource,
8620 scopes,
8621 args,
8622 } => {
8623 if command.is_none() && url.is_none() {
8624 bail!("Provide either --command or --url for `mcp add`.");
8625 }
8626 if let Some(transport) = transport.as_deref()
8627 && !transport.trim().eq_ignore_ascii_case("sse")
8628 {
8629 bail!("Unsupported MCP transport '{transport}'. Supported values: sse");
8630 }
8631 let added_server = McpServerConfig {
8632 command,
8633 args,
8634 env: std::collections::HashMap::new(),
8635 cwd: None,
8636 url,
8637 transport,
8638 connect_timeout: None,
8639 execute_timeout: None,
8640 read_timeout: None,
8641 disabled: false,
8642 enabled: true,
8643 required: false,
8644 enabled_tools: Vec::new(),
8645 disabled_tools: Vec::new(),
8646 headers: std::collections::HashMap::new(),
8647 env_headers: std::collections::HashMap::new(),
8648 bearer_token_env_var,
8649 scopes,
8650 oauth: oauth_client_id.map(|client_id| McpServerOAuthConfig {
8651 client_id: Some(client_id),
8652 }),
8653 oauth_resource,
8654 reviewed_plugin: None,
8655 };
8656 let can_suggest_oauth = added_server.url.is_some()
8657 && added_server.bearer_token_env_var.is_none()
8658 && added_server
8659 .headers
8660 .keys()
8661 .all(|key| !key.trim().eq_ignore_ascii_case("authorization"))
8662 && added_server
8663 .env_headers
8664 .keys()
8665 .all(|key| !key.trim().eq_ignore_ascii_case("authorization"));
8666 let mut cfg = load_mcp_config(&config_path)?;
8667 cfg.servers.insert(name.clone(), added_server.clone());
8668 save_mcp_config(&config_path, &cfg)?;
8669 println!("Added MCP server '{name}' in {}", config_path.display());
8670 if can_suggest_oauth
8671 && crate::mcp::oauth::oauth_login_support(&added_server)
8672 .await
8673 .is_ok_and(|support| support.is_some())
8674 {
8675 println!(
8676 "OAuth is available for '{name}'. Run `codewhale mcp login {name}` to authenticate."
8677 );
8678 }
8679 Ok(())
8680 }
8681 McpCommand::Login { name, scopes } => {
8682 let cfg = crate::mcp::load_config_with_workspace_and_plugins(
8683 &config_path,
8684 workspace,
8685 plugins,
8686 )?;
8687 let server = cfg
8688 .servers
8689 .get(&name)
8690 .ok_or_else(|| anyhow!("MCP server '{name}' not found"))?;
8691 let explicit_scopes = (!scopes.is_empty()).then_some(scopes);
8692 crate::mcp::oauth::perform_oauth_login_for_server(
8693 &name,
8694 server,
8695 explicit_scopes,
8696 config.mcp_oauth_callback_port,
8697 config.mcp_oauth_callback_url.as_deref(),
8698 )
8699 .await?;
8700 println!("Stored OAuth credentials for MCP server '{name}'.");
8701 Ok(())
8702 }
8703 McpCommand::Logout { name } => {
8704 let cfg = crate::mcp::load_config_with_workspace_and_plugins(
8705 &config_path,
8706 workspace,
8707 plugins,
8708 )?;
8709 let server = cfg
8710 .servers
8711 .get(&name)
8712 .ok_or_else(|| anyhow!("MCP server '{name}' not found"))?;
8713 if crate::mcp::oauth::delete_oauth_tokens_for_server(&name, server)? {
8714 println!("Deleted stored OAuth credentials for MCP server '{name}'.");
8715 } else {
8716 println!("No stored OAuth credentials found for MCP server '{name}'.");
8717 }
8718 Ok(())
8719 }
8720 McpCommand::Remove { name } => {
8721 let mut cfg = load_mcp_config(&config_path)?;
8722 if cfg.servers.remove(&name).is_none() {
8723 bail!("MCP server '{name}' not found");
8724 }
8725 save_mcp_config(&config_path, &cfg)?;
8726 println!("Removed MCP server '{name}'");
8727 Ok(())
8728 }
8729 McpCommand::Enable { name } => {
8730 let mut cfg = load_mcp_config(&config_path)?;
8731 let server = cfg
8732 .servers
8733 .get_mut(&name)
8734 .ok_or_else(|| anyhow!("MCP server '{name}' not found"))?;
8735 server.enabled = true;
8736 server.disabled = false;
8737 save_mcp_config(&config_path, &cfg)?;
8738 println!("Enabled MCP server '{name}'");
8739 Ok(())
8740 }
8741 McpCommand::Disable { name } => {
8742 let mut cfg = load_mcp_config(&config_path)?;
8743 let server = cfg
8744 .servers
8745 .get_mut(&name)
8746 .ok_or_else(|| anyhow!("MCP server '{name}' not found"))?;
8747 server.enabled = false;
8748 server.disabled = true;
8749 save_mcp_config(&config_path, &cfg)?;
8750 println!("Disabled MCP server '{name}'");
8751 Ok(())
8752 }
8753 McpCommand::Validate => {
8754 let mut pool = McpPool::from_config_path_with_workspace_and_plugins(
8755 &config_path,
8756 workspace,
8757 std::sync::Arc::new(plugins.clone()),
8758 )?;
8759 let errors = pool.connect_all().await;
8760 if errors.is_empty() {
8761 println!("MCP config is valid. All enabled servers connected.");
8762 return Ok(());
8763 }
8764 eprintln!("MCP validation failed:");
8765 for (name, err) in errors {
8766 eprintln!(" - {name}: {err:#}");
8767 }
8768 bail!("one or more MCP servers failed validation");
8769 }
8770 McpCommand::AddSelf { name, workspace } => {
8771 let exe_path = std::env::current_exe()
8772 .map_err(|e| anyhow!("Cannot resolve current binary path: {e}"))?;
8773 let exe_str = exe_path.to_string_lossy().to_string();
8774
8775 let mut args = vec!["serve".to_string(), "--mcp".to_string()];
8776 if let Some(ref ws) = workspace {
8777 args.push("--workspace".to_string());
8778 args.push(ws.clone());
8779 }
8780
8781 let mut cfg = load_mcp_config(&config_path)?;
8782 if cfg.servers.contains_key(&name) {
8783 bail!(
8784 "MCP server '{name}' already exists in {}. Use `codewhale mcp remove {name}` first, or choose a different --name.",
8785 config_path.display()
8786 );
8787 }
8788 cfg.servers.insert(
8789 name.clone(),
8790 McpServerConfig {
8791 command: Some(exe_str.clone()),
8792 args,
8793 env: std::collections::HashMap::new(),
8794 cwd: None,
8795 url: None,
8796 transport: None,
8797 connect_timeout: None,
8798 execute_timeout: None,
8799 read_timeout: None,
8800 disabled: false,
8801 enabled: true,
8802 required: false,
8803 enabled_tools: Vec::new(),
8804 disabled_tools: Vec::new(),
8805 headers: std::collections::HashMap::new(),
8806 env_headers: std::collections::HashMap::new(),
8807 bearer_token_env_var: None,
8808 scopes: Vec::new(),
8809 oauth: None,
8810 oauth_resource: None,
8811 reviewed_plugin: None,
8812 },
8813 );
8814 save_mcp_config(&config_path, &cfg)?;
8815 println!(
8816 "Registered Codewhale as MCP server '{name}' in {}",
8817 config_path.display()
8818 );
8819 println!(" command: {exe_str}");
8820 println!(
8821 " args: serve --mcp{}",
8822 workspace.map_or(String::new(), |ws| format!(" --workspace {ws}"))
8823 );
8824 println!();
8825 println!("Tip: Use `codewhale mcp validate` to test the connection.");
8826 println!(" Use `codewhale serve --http` for the HTTP/SSE runtime API instead.");
8827 Ok(())
8828 }
8829 }
8830}
8831
8832fn load_mcp_config(path: &Path) -> Result<McpConfig> {
8833 if !path.exists() {
8834 return Ok(McpConfig::default());
8835 }
8836 let contents = std::fs::read_to_string(path)
8837 .map_err(|e| anyhow::anyhow!("Failed to read MCP config {}: {}", path.display(), e))?;
8838 let cfg: McpConfig = serde_json::from_str(&contents).map_err(|_| {
8839 anyhow::anyhow!(
8840 "Failed to parse MCP config {}; file contents were omitted",
8841 codewhale_config::quote_os_path(path)
8842 )
8843 })?;
8844 Ok(cfg)
8845}
8846
8847#[derive(Debug)]
8849enum McpServerDoctorStatus {
8850 Ok(String),
8851 Warning(String),
8852 Error(String),
8853}
8854
8855impl McpServerDoctorStatus {
8856 fn legacy_status(&self) -> &'static str {
8857 match self {
8858 Self::Ok(_) => "ok",
8859 Self::Warning(_) => "warning",
8860 Self::Error(_) => "error",
8861 }
8862 }
8863
8864 fn configuration_status(&self) -> &'static str {
8865 match self {
8866 Self::Ok(_) => "valid",
8867 Self::Warning(_) => "warning",
8868 Self::Error(_) => "invalid",
8869 }
8870 }
8871
8872 fn detail(&self) -> &str {
8873 match self {
8874 Self::Ok(detail) | Self::Warning(detail) | Self::Error(detail) => detail,
8875 }
8876 }
8877}
8878
8879fn doctor_mcp_command_status(server: &McpServerConfig) -> McpCommandAvailability {
8881 if server.url.is_some() {
8882 return McpCommandAvailability::NotApplicable;
8883 }
8884 match server.command.as_deref() {
8885 Some("") => McpCommandAvailability::Missing,
8886 Some(_) | None => McpCommandAvailability::NotChecked,
8887 }
8888}
8889
8890fn doctor_mcp_server_json(name: &str, server: &McpServerConfig) -> serde_json::Value {
8891 use serde_json::json;
8892
8893 let status = doctor_check_mcp_server(server);
8894 json!({
8895 "name": name,
8896 "enabled": server.enabled && !server.disabled,
8897 "status": status.legacy_status(),
8900 "detail": status.detail(),
8901 "transport": if server.url.is_some() { "http" } else { "stdio" },
8902 "endpoint": server.url.as_deref().map(crate::doctor::structural_url_authority),
8903 "command_configured": server.command.is_some(),
8904 "args_count": server.args.len(),
8905 "env_count": server.env.len(),
8906 "headers_count": server.headers.len(),
8907 "env_headers_count": server.env_headers.len(),
8908 "check_scope": "configuration",
8909 "checks": {
8910 "configuration": {
8911 "status": status.configuration_status(),
8912 "detail": status.detail(),
8913 },
8914 "command": {
8915 "status": doctor_mcp_command_status(server).as_str(),
8916 },
8917 "process_reachable": {
8918 "status": "not_checked",
8919 },
8920 "protocol_initialized": {
8921 "status": "not_checked",
8922 },
8923 "backend_tool_health": {
8924 "status": "not_checked",
8925 },
8926 },
8927 })
8928}
8929
8930fn doctor_check_mcp_server(server: &McpServerConfig) -> McpServerDoctorStatus {
8932 if server.command.is_none() && server.url.is_none() {
8934 return McpServerDoctorStatus::Error("no command or url configured".to_string());
8935 }
8936
8937 if let Some(ref url) = server.url {
8939 let authority = crate::doctor::structural_url_authority(url);
8940 return if authority.starts_with("unparseable") {
8941 McpServerDoctorStatus::Warning(
8942 "HTTP/SSE server URL is invalid; configured value omitted".to_string(),
8943 )
8944 } else {
8945 McpServerDoctorStatus::Ok(format!("HTTP/SSE server at {authority}"))
8946 };
8947 }
8948
8949 let cmd = server.command.as_deref().unwrap_or("");
8951 if cmd.is_empty() {
8952 return McpServerDoctorStatus::Error("empty command".to_string());
8953 }
8954
8955 if server.cwd.is_none() {
8956 if is_relative_stdio_path_arg(cmd) {
8957 return McpServerDoctorStatus::Warning(
8958 "stdio server uses a relative command without cwd; command value omitted"
8959 .to_string(),
8960 );
8961 }
8962 if server
8963 .args
8964 .iter()
8965 .any(|arg| is_relative_stdio_path_arg(arg) && !is_scoped_npm_package_arg(cmd, arg))
8966 {
8967 return McpServerDoctorStatus::Warning(
8968 "stdio server uses a relative path argument without cwd; argument values omitted"
8969 .to_string(),
8970 );
8971 }
8972 }
8973
8974 McpServerDoctorStatus::Ok(format!(
8975 "stdio server configured (command omitted; {} argument(s), {} environment binding(s))",
8976 server.args.len(),
8977 server.env.len()
8978 ))
8979}
8980
8981fn is_scoped_npm_package_arg(command: &str, argument: &str) -> bool {
8986 let launcher = Path::new(command)
8987 .file_name()
8988 .and_then(|name| name.to_str())
8989 .unwrap_or(command);
8990 if !launcher.eq_ignore_ascii_case("npx") && !launcher.eq_ignore_ascii_case("npx.cmd") {
8991 return false;
8992 }
8993
8994 let Some(scoped) = argument.strip_prefix('@') else {
8995 return false;
8996 };
8997 let Some((scope, package_and_version)) = scoped.split_once('/') else {
8998 return false;
8999 };
9000 if scope.is_empty()
9001 || package_and_version.is_empty()
9002 || package_and_version.contains('/')
9003 || package_and_version.contains('\\')
9004 {
9005 return false;
9006 }
9007
9008 let (package, version) = match package_and_version.split_once('@') {
9009 Some((package, version)) => (package, Some(version)),
9010 None => (package_and_version, None),
9011 };
9012 let valid_name = |value: &str| {
9013 !value.is_empty()
9014 && !value.starts_with(['.', '_'])
9015 && value
9016 .chars()
9017 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
9018 };
9019 valid_name(scope)
9020 && valid_name(package)
9021 && version.is_none_or(|value| {
9022 !value.is_empty()
9023 && !value.contains(['@', '/', '\\'])
9024 && !value.chars().any(char::is_whitespace)
9025 })
9026}
9027
9028fn save_mcp_config(path: &Path, cfg: &McpConfig) -> Result<()> {
9029 if let Some(parent) = path.parent() {
9030 std::fs::create_dir_all(parent).with_context(|| {
9031 format!("Failed to create MCP config directory {}", parent.display())
9032 })?;
9033 }
9034 let rendered = serde_json::to_string_pretty(cfg)
9035 .map_err(|e| anyhow!("Failed to serialize MCP config: {e}"))?;
9036 crate::utils::write_atomic(path, rendered.as_bytes())
9037 .map_err(|e| anyhow!("Failed to write MCP config {}: {}", path.display(), e))?;
9038 Ok(())
9039}
9040
9041fn run_sandbox_command(args: SandboxArgs) -> Result<()> {
9042 use crate::sandbox::{CommandSpec, SandboxManager};
9043
9044 let SandboxCommand::Run {
9045 policy,
9046 network,
9047 writable_root,
9048 exclude_tmpdir,
9049 exclude_slash_tmp,
9050 cwd,
9051 timeout_ms,
9052 command,
9053 } = args.command;
9054
9055 let policy = parse_sandbox_policy(
9056 &policy,
9057 network,
9058 writable_root,
9059 exclude_tmpdir,
9060 exclude_slash_tmp,
9061 )?;
9062 let cwd = cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
9063 let timeout = Duration::from_millis(timeout_ms.clamp(1000, 600_000));
9064
9065 let (program, args) = command
9066 .split_first()
9067 .ok_or_else(|| anyhow::anyhow!("Command is required"))?;
9068 let spec =
9069 CommandSpec::program(program, args.to_vec(), cwd.clone(), timeout).with_policy(policy);
9070 let manager = SandboxManager::new();
9071 let exec_env = manager.prepare(&spec);
9072
9073 let mut cmd = Command::new(exec_env.program());
9074 cmd.args(exec_env.args())
9075 .current_dir(&exec_env.cwd)
9076 .stdout(Stdio::piped())
9077 .stderr(Stdio::piped());
9078 child_env::apply_to_command(&mut cmd, child_env::string_map_env(&exec_env.env));
9079
9080 let mut child = cmd
9081 .spawn()
9082 .map_err(|e| anyhow::anyhow!("Failed to run command: {e}"))?;
9083 let stdout_handle = child
9084 .stdout
9085 .take()
9086 .ok_or_else(|| anyhow::anyhow!("stdout unavailable"))?;
9087 let stderr_handle = child
9088 .stderr
9089 .take()
9090 .ok_or_else(|| anyhow::anyhow!("stderr unavailable"))?;
9091
9092 let timeout = exec_env.timeout;
9093 let stdout_thread = std::thread::spawn(move || {
9094 let mut reader = stdout_handle;
9095 let mut buf = Vec::new();
9096 let _ = reader.read_to_end(&mut buf);
9097 buf
9098 });
9099 let stderr_thread = std::thread::spawn(move || {
9100 let mut reader = stderr_handle;
9101 let mut buf = Vec::new();
9102 let _ = reader.read_to_end(&mut buf);
9103 buf
9104 });
9105
9106 if let Some(status) = child.wait_timeout(timeout)? {
9107 let stdout = stdout_thread.join().unwrap_or_default();
9108 let stderr = stderr_thread.join().unwrap_or_default();
9109 let stderr_str = String::from_utf8_lossy(&stderr);
9110 let exit_code = status.code().unwrap_or(-1);
9111 let sandbox_type = exec_env.sandbox_type;
9112 let sandbox_denied = SandboxManager::was_denied(sandbox_type, exit_code, &stderr_str);
9113
9114 if !stdout.is_empty() {
9115 print!("{}", String::from_utf8_lossy(&stdout));
9116 }
9117 if !stderr.is_empty() {
9118 eprint!("{stderr_str}");
9119 }
9120 if sandbox_denied {
9121 eprintln!(
9122 "{}",
9123 SandboxManager::denial_message(sandbox_type, &stderr_str)
9124 );
9125 }
9126
9127 if !status.success() {
9128 bail!("Command failed with exit code {exit_code}");
9129 }
9130 } else {
9131 let _ = child.kill();
9132 let _ = child.wait();
9133 bail!("Command timed out after {}ms", timeout.as_millis());
9134 }
9135 Ok(())
9136}
9137
9138fn parse_sandbox_policy(
9139 policy: &str,
9140 network: bool,
9141 writable_root: Vec<PathBuf>,
9142 exclude_tmpdir: bool,
9143 exclude_slash_tmp: bool,
9144) -> Result<crate::sandbox::SandboxPolicy> {
9145 use crate::sandbox::SandboxPolicy;
9146
9147 match policy {
9148 "danger-full-access" => Ok(SandboxPolicy::DangerFullAccess),
9149 "read-only" => Ok(SandboxPolicy::ReadOnly),
9150 "external-sandbox" => Ok(SandboxPolicy::ExternalSandbox {
9151 network_access: network,
9152 }),
9153 "workspace-write" => Ok(SandboxPolicy::WorkspaceWrite {
9154 writable_roots: writable_root,
9155 network_access: network,
9156 exclude_tmpdir,
9157 exclude_slash_tmp,
9158 }),
9159 other => bail!("Unknown sandbox policy: {other}"),
9160 }
9161}
9162
9163fn should_use_alt_screen(_cli: &Cli, _config: &Config) -> bool {
9164 true
9165}
9166
9167fn should_use_mouse_capture(cli: &Cli, config: &Config, use_alt_screen: bool) -> bool {
9168 let terminal_emulator = std::env::var("TERMINAL_EMULATOR").ok();
9169 let wt_session = std::env::var("WT_SESSION").ok().filter(|s| !s.is_empty());
9170 let conemu_pid = std::env::var("ConEmuPID").ok().filter(|s| !s.is_empty());
9171 should_use_mouse_capture_with(
9172 cli,
9173 config,
9174 use_alt_screen,
9175 terminal_emulator.as_deref(),
9176 wt_session.as_deref(),
9177 conemu_pid.as_deref(),
9178 )
9179}
9180
9181fn should_use_mouse_capture_with(
9182 cli: &Cli,
9183 config: &Config,
9184 use_alt_screen: bool,
9185 terminal_emulator: Option<&str>,
9186 wt_session: Option<&str>,
9187 conemu_pid: Option<&str>,
9188) -> bool {
9189 if !use_alt_screen || cli.no_mouse_capture {
9190 return false;
9191 }
9192 if cli.mouse_capture {
9193 return true;
9194 }
9195 config
9196 .tui
9197 .as_ref()
9198 .and_then(|tui| tui.mouse_capture)
9199 .unwrap_or_else(|| default_mouse_capture_enabled(terminal_emulator, wt_session, conemu_pid))
9200}
9201
9202fn default_mouse_capture_enabled(
9217 terminal_emulator: Option<&str>,
9218 wt_session: Option<&str>,
9219 conemu_pid: Option<&str>,
9220) -> bool {
9221 if cfg!(windows) {
9222 return wt_session.is_some() || conemu_pid.is_some();
9223 }
9224 if matches!(terminal_emulator, Some(t) if t.eq_ignore_ascii_case("JetBrains-JediTerm")) {
9225 return false;
9226 }
9227 true
9228}
9229
9230struct RecentCheckpoint {
9234 session: session_manager::SavedSession,
9235 age: std::time::Duration,
9236 source: session_manager::CheckpointSource,
9237}
9238
9239const CHECKPOINT_MAX_AGE: std::time::Duration = std::time::Duration::from_secs(24 * 3600);
9240
9241fn load_recent_checkpoints(manager: &session_manager::SessionManager) -> Vec<RecentCheckpoint> {
9248 let refs = manager.list_checkpoints().unwrap_or_default();
9249 let mut recent = Vec::new();
9250 for checkpoint_ref in refs {
9251 let Ok(age) = std::time::SystemTime::now().duration_since(checkpoint_ref.modified) else {
9252 continue;
9253 };
9254 if age > CHECKPOINT_MAX_AGE {
9255 let _ = match &checkpoint_ref.source {
9256 session_manager::CheckpointSource::Session(id) => {
9257 manager.clear_session_checkpoint(id)
9258 }
9259 session_manager::CheckpointSource::Legacy => manager.clear_legacy_checkpoint(),
9260 };
9261 continue;
9262 }
9263 let loaded = match &checkpoint_ref.source {
9264 session_manager::CheckpointSource::Session(id) => manager.load_session_checkpoint(id),
9265 session_manager::CheckpointSource::Legacy => manager.load_legacy_checkpoint(),
9266 };
9267 let Ok(Some(session)) = loaded else {
9268 continue;
9269 };
9270 recent.push(RecentCheckpoint {
9271 session,
9272 age,
9273 source: checkpoint_ref.source,
9274 });
9275 }
9276 recent.sort_by_key(|c| c.age);
9279 recent
9280}
9281
9282fn checkpoint_age_label(age: std::time::Duration) -> String {
9283 if age.as_secs() < 60 {
9284 format!("{}s ago", age.as_secs())
9285 } else if age.as_secs() < 3600 {
9286 format!("{}m ago", age.as_secs() / 60)
9287 } else {
9288 format!("{}h ago", age.as_secs() / 3600)
9289 }
9290}
9291
9292fn recover_interrupted_checkpoint_for_resume(launch_workspace: &Path) -> Option<String> {
9304 let manager = session_manager::SessionManager::default_location().ok()?;
9305 let candidates = load_recent_checkpoints(&manager);
9306 if candidates.is_empty() {
9307 return None;
9308 }
9309
9310 let (matching, mismatched): (Vec<_>, Vec<_>) = candidates.into_iter().partition(|candidate| {
9315 session_manager::workspace_scope_matches(
9316 &candidate.session.metadata.workspace,
9317 launch_workspace,
9318 )
9319 });
9320
9321 let Some(best) = matching.into_iter().next() else {
9322 if let Some(newest) = mismatched.first() {
9323 eprintln!(
9324 "Note: an interrupted session from another workspace ({}) is \
9325 available. Run `codewhale sessions` to list saved sessions. Starting \
9326 fresh in {}.",
9327 newest.session.metadata.workspace.display(),
9328 launch_workspace.display(),
9329 );
9330 }
9331 return None;
9332 };
9333
9334 let session_id = best.session.metadata.id.clone();
9335
9336 if !saved_session_is_newer(&manager, &best.session)
9341 && manager.save_session(&best.session).is_err()
9342 {
9343 return None;
9344 }
9345
9346 match &best.source {
9347 session_manager::CheckpointSource::Session(id) => {
9348 let _ = manager.clear_session_checkpoint(id);
9350 }
9351 session_manager::CheckpointSource::Legacy => {
9352 let _ = manager.write_session_checkpoint_if_absent(&best.session);
9357 }
9358 }
9359
9360 let age_str = checkpoint_age_label(best.age);
9361 eprintln!("Recovered interrupted session ({age_str}). Use --fresh to start fresh.",);
9362
9363 Some(session_id)
9364}
9365
9366fn saved_session_is_newer(
9371 manager: &session_manager::SessionManager,
9372 checkpoint: &session_manager::SavedSession,
9373) -> bool {
9374 manager
9375 .load_session(&checkpoint.metadata.id)
9376 .is_ok_and(|existing| existing.metadata.updated_at >= checkpoint.metadata.updated_at)
9377}
9378
9379fn preserve_interrupted_checkpoint_for_explicit_resume(launch_workspace: &Path) {
9390 let Some(manager) = session_manager::SessionManager::default_location().ok() else {
9391 return;
9392 };
9393 let Some(newest) = load_recent_checkpoints(&manager).into_iter().next() else {
9394 return;
9395 };
9396
9397 let session_workspace = newest.session.metadata.workspace.clone();
9398 if newest.source == session_manager::CheckpointSource::Legacy {
9400 let _ = manager.save_checkpoint(&newest.session);
9403 let _ = manager.clear_legacy_checkpoint();
9404 }
9405
9406 let age_str = checkpoint_age_label(newest.age);
9407 if session_manager::workspace_scope_matches(&session_workspace, launch_workspace) {
9408 eprintln!(
9409 "Found an in-flight session snapshot ({age_str}). Starting a new \
9410 session. Run `codewhale --continue` to resume it."
9411 );
9412 } else {
9413 eprintln!(
9414 "Note: an interrupted session from another workspace ({}) is \
9415 available. Run `codewhale sessions` to list saved sessions. Starting \
9416 fresh in {}.",
9417 session_workspace.display(),
9418 launch_workspace.display(),
9419 );
9420 }
9421}
9422
9423#[cfg(test)]
9429fn merge_project_config(config: &mut Config, workspace: &Path) {
9430 merge_project_config_with_approval_baseline(config, workspace, None);
9431}
9432
9433fn merge_project_config_with_approval_baseline(
9438 config: &mut Config,
9439 workspace: &Path,
9440 saved_permission_posture: Option<&str>,
9441) {
9442 if let Some(home) = effective_home_dir()
9447 && let (Ok(w), Ok(h)) = (
9448 std::fs::canonicalize(workspace),
9449 std::fs::canonicalize(&home),
9450 )
9451 && w == h
9452 {
9453 return;
9454 }
9455
9456 let path = workspace
9458 .join(codewhale_config::CODEWHALE_APP_DIR)
9459 .join("config.toml");
9460 let raw = match read_project_config_file(&path) {
9461 Ok(Some(r)) => r,
9462 Ok(None) => {
9463 let legacy = workspace
9464 .join(codewhale_config::LEGACY_APP_DIR)
9465 .join("config.toml");
9466 match read_project_config_file(&legacy) {
9467 Ok(Some(r)) => r,
9468 Ok(None) => return,
9469 Err(err) => {
9470 eprintln!(
9471 "warning: failed to read project-scope config {}: {err}",
9472 legacy.display()
9473 );
9474 return;
9475 }
9476 }
9477 }
9478 Err(err) => {
9479 eprintln!(
9480 "warning: failed to read project-scope config {}: {err}",
9481 path.display()
9482 );
9483 return;
9484 }
9485 };
9486 let project: toml::Value = match toml::from_str(&raw) {
9487 Ok(v) => v,
9488 Err(_) => return,
9489 };
9490 let table = match project.as_table() {
9491 Some(t) => t,
9492 None => return,
9493 };
9494
9495 const DENY_AT_PROJECT_SCOPE: &[&str] = &[
9511 "api_key",
9512 "base_url",
9513 "provider",
9514 "mcp_config_path",
9515 "mcp_oauth_callback_port",
9516 "mcp_oauth_callback_url",
9517 ];
9518 for key in DENY_AT_PROJECT_SCOPE {
9519 if table.contains_key(*key) {
9520 eprintln!(
9521 "warning: project-scope config key `{key}` is ignored — \
9522 set it in `~/.codewhale/config.toml` instead. \
9523 (See #417 for the deny-list rationale.)"
9524 );
9525 }
9526 }
9527
9528 for (key, field) in [
9531 ("model", &mut config.default_text_model),
9532 ("reasoning_effort", &mut config.reasoning_effort),
9533 ("notes_path", &mut config.notes_path),
9534 ] {
9535 if let Some(v) = table.get(key).and_then(toml::Value::as_str)
9536 && !v.is_empty()
9537 {
9538 *field = Some(v.to_string());
9539 }
9540 }
9541
9542 if let Some(v) = table.get("approval_policy").and_then(toml::Value::as_str)
9543 && !v.is_empty()
9544 {
9545 let saved_approval_baseline =
9546 crate::config::approval_policy_baseline_from_permission_posture(
9547 saved_permission_posture,
9548 );
9549 let approval_baseline = config
9550 .approval_policy
9551 .as_deref()
9552 .or(saved_approval_baseline);
9553 if codewhale_config::project_approval_policy_is_allowed(approval_baseline, v) {
9554 config.approval_policy = Some(v.to_string());
9555 } else {
9556 eprintln!(
9557 "warning: project-scope `approval_policy = \"{v}\"` is ignored — \
9558 project config can only tighten the user's approval policy. \
9559 (See #417.)"
9560 );
9561 }
9562 }
9563
9564 if let Some(v) = table.get("sandbox_mode").and_then(toml::Value::as_str)
9565 && !v.is_empty()
9566 {
9567 if codewhale_config::project_sandbox_mode_is_allowed(config.sandbox_mode.as_deref(), v) {
9568 config.sandbox_mode = Some(v.to_string());
9569 } else {
9570 eprintln!(
9571 "warning: project-scope `sandbox_mode = \"{v}\"` is ignored — \
9572 project config can only tighten the user's sandbox mode. \
9573 (See #417.)"
9574 );
9575 }
9576 }
9577
9578 if let Some(v) = table.get("max_subagents").and_then(toml::Value::as_integer)
9580 && v > 0
9581 {
9582 config.max_subagents = Some((v as usize).clamp(1, crate::config::MAX_SUBAGENTS));
9583 }
9584 if let Some(v) = table.get("allow_shell").and_then(toml::Value::as_bool) {
9585 if v {
9586 eprintln!(
9587 "warning: project-scope `allow_shell = true` is ignored — \
9588 enable shell from user config for this workspace instead. \
9589 (See #417.)"
9590 );
9591 } else {
9592 config.allow_shell = Some(false);
9593 }
9594 }
9595
9596 if table.contains_key("instructions") {
9597 eprintln!(
9598 "warning: project-scope `instructions` is ignored — \
9599 configure instruction files from user config instead. \
9600 (See #417.)"
9601 );
9602 }
9603}
9604
9605fn read_project_config_file(path: &Path) -> io::Result<Option<String>> {
9606 let metadata = match std::fs::symlink_metadata(path) {
9607 Ok(metadata) => metadata,
9608 Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
9609 Err(err) => return Err(err),
9610 };
9611 let file_type = metadata.file_type();
9612 if file_type.is_symlink() {
9613 return Err(io::Error::new(
9614 io::ErrorKind::InvalidInput,
9615 "project-scope config must not be a symlink",
9616 ));
9617 }
9618 if !file_type.is_file() {
9619 return Ok(None);
9620 }
9621
9622 let mut file = open_project_config_file(path)?;
9623 let mut raw = String::new();
9624 file.read_to_string(&mut raw)?;
9625 Ok(Some(raw))
9626}
9627
9628#[cfg(unix)]
9629fn open_project_config_file(path: &Path) -> io::Result<std::fs::File> {
9630 use std::os::unix::fs::OpenOptionsExt;
9631
9632 std::fs::OpenOptions::new()
9633 .read(true)
9634 .custom_flags(libc::O_NOFOLLOW)
9635 .open(path)
9636}
9637
9638#[cfg(not(unix))]
9639fn open_project_config_file(path: &Path) -> io::Result<std::fs::File> {
9640 std::fs::File::open(path)
9641}
9642
9643fn merge_user_workspace_config(
9644 config: &mut Config,
9645 config_path: Option<PathBuf>,
9646 workspace: &Path,
9647) {
9648 if config.managed_config_path.is_some() || config.requirements_path.is_some() {
9649 return;
9650 }
9651 let allow_shell_before = config.allow_shell;
9652 let allow_shell_from_env = std::env::var_os("CODEWHALE_ALLOW_SHELL").is_some()
9653 || std::env::var_os("DEEPSEEK_ALLOW_SHELL").is_some();
9654 let path = match crate::config::resolve_load_config_path(config_path) {
9655 Ok(Some(path)) => path,
9656 Ok(None) => return,
9657 Err(error) => {
9658 tracing::error!(
9659 error = %error,
9660 "failed to resolve workspace config overlay; refusing to substitute another file"
9661 );
9662 return;
9663 }
9664 };
9665 let raw = match read_user_config_file(&path) {
9666 Ok(Some(raw)) => raw,
9667 Ok(None) => return,
9668 Err(error) => {
9669 eprintln!(
9670 "warning: could not read user config at {}: {error}. \
9671 Ignoring it — `[workspace]`/`[projects]` grants (e.g. `allow_shell`) \
9672 revert to defaults for this session. Fix or remove the file to \
9673 restore them.",
9674 path.display()
9675 );
9676 return;
9677 }
9678 };
9679 let doc = match toml::from_str::<toml::Value>(&raw) {
9680 Ok(doc) => doc,
9681 Err(error) => {
9682 eprintln!(
9683 "warning: could not parse user config at {}: {error}. \
9684 Ignoring it — `[workspace]`/`[projects]` grants (e.g. `allow_shell`) \
9685 revert to defaults for this session. Fix the TOML syntax to \
9686 restore them.",
9687 path.display()
9688 );
9689 return;
9690 }
9691 };
9692 merge_user_workspace_config_from_doc(config, &doc, workspace);
9693 if allow_shell_from_env {
9694 config.allow_shell = allow_shell_before;
9695 }
9696}
9697
9698fn read_user_config_file(path: &Path) -> io::Result<Option<String>> {
9699 match std::fs::read_to_string(path) {
9700 Ok(raw) => Ok(Some(raw)),
9701 Err(error) if error.kind() == io::ErrorKind::NotFound => {
9702 match std::fs::symlink_metadata(path) {
9703 Err(metadata_error) if metadata_error.kind() == io::ErrorKind::NotFound => Ok(None),
9704 Err(metadata_error) => Err(metadata_error),
9705 _ => Err(error),
9706 }
9707 }
9708 Err(error) => Err(error),
9709 }
9710}
9711
9712fn merge_user_workspace_config_from_doc(config: &mut Config, doc: &toml::Value, workspace: &Path) {
9713 for table_name in ["workspace", "projects"] {
9714 let Some(entries) = doc.get(table_name).and_then(toml::Value::as_table) else {
9715 continue;
9716 };
9717 for (raw_path, entry) in entries {
9718 if !workspace_config_path_matches(raw_path, workspace) {
9719 continue;
9720 }
9721 if let Some(allow_shell) = entry.get("allow_shell").and_then(toml::Value::as_bool) {
9722 config.allow_shell = Some(allow_shell);
9723 }
9724 }
9725 }
9726}
9727
9728fn workspace_config_path_matches(raw_path: &str, workspace: &Path) -> bool {
9729 let configured = crate::config::expand_path(raw_path);
9730 let configured = configured.canonicalize().unwrap_or(configured);
9731 let workspace = workspace
9732 .canonicalize()
9733 .unwrap_or_else(|_| workspace.to_path_buf());
9734 paths_equal_for_config(&configured, &workspace)
9735}
9736
9737#[cfg(windows)]
9738fn paths_equal_for_config(left: &Path, right: &Path) -> bool {
9739 normalize_windows_config_path_for_compare(left)
9740 == normalize_windows_config_path_for_compare(right)
9741}
9742
9743#[cfg(not(windows))]
9744fn paths_equal_for_config(left: &Path, right: &Path) -> bool {
9745 left == right
9746}
9747
9748#[cfg(windows)]
9749fn normalize_windows_config_path_for_compare(path: &Path) -> String {
9750 normalize_windows_config_path_str(&path.to_string_lossy())
9751}
9752
9753#[cfg(any(windows, test))]
9754fn normalize_windows_config_path_str(path: &str) -> String {
9755 let mut normalized = path.replace('/', "\\");
9756 if let Some(rest) = normalized.strip_prefix(r"\\?\UNC\") {
9757 normalized = format!("\\\\{rest}");
9758 } else if let Some(rest) = normalized.strip_prefix(r"\\?\") {
9759 normalized = rest.to_string();
9760 }
9761 while normalized.len() > 3 && normalized.ends_with('\\') {
9762 normalized.pop();
9763 }
9764 normalized.to_ascii_lowercase()
9765}
9766
9767fn interactive_tui_allow_shell(yolo: bool, config: &Config) -> bool {
9768 yolo || config.interactive_allow_shell()
9769}
9770
9771async fn run_interactive(
9772 cli: &Cli,
9773 config: &Config,
9774 resume_session_id: Option<String>,
9775 initial_input: Option<tui::InitialInput>,
9776 pending_telemetry_notice: Option<crate::telemetry_notice::PendingTelemetryNotice>,
9777 plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
9778) -> Result<()> {
9779 run_interactive_with_notice(
9780 cli,
9781 config,
9782 resume_session_id,
9783 initial_input,
9784 None,
9785 pending_telemetry_notice,
9786 plugin_registry,
9787 )
9788 .await
9789}
9790
9791async fn run_interactive_with_notice(
9795 cli: &Cli,
9796 config: &Config,
9797 resume_session_id: Option<String>,
9798 initial_input: Option<tui::InitialInput>,
9799 startup_notice: Option<String>,
9800 pending_telemetry_notice: Option<crate::telemetry_notice::PendingTelemetryNotice>,
9801 plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
9802) -> Result<()> {
9803 let initial_input = if cli.remote_control {
9804 Some(tui::InitialInput::RemoteControl)
9805 } else {
9806 initial_input
9807 };
9808 let workspace = cli
9809 .workspace
9810 .clone()
9811 .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
9812
9813 let mut merged_config = config.clone();
9817 merge_user_workspace_config(&mut merged_config, cli.config.clone(), &workspace);
9818 if !cli.no_project_config {
9819 let saved_permission_posture = crate::settings::Settings::load_persisted()
9820 .ok()
9821 .and_then(|settings| settings.permission_posture);
9822 merge_project_config_with_approval_baseline(
9823 &mut merged_config,
9824 &workspace,
9825 saved_permission_posture.as_deref(),
9826 );
9827 }
9828 let config = &merged_config;
9829
9830 if !cli.skip_onboarding {
9831 match crate::config::ensure_config_file_exists(cli.config.clone()) {
9832 Ok(Some(path)) => logging::info(format!(
9833 "Created first-run config file at {}",
9834 path.display()
9835 )),
9836 Ok(None) => {}
9837 Err(err) => logging::warn(format!("Failed to create first-run config file: {err}")),
9838 }
9839 }
9840
9841 match codewhale_config::migrate_config_if_needed() {
9844 Ok(Some(migration)) => {
9845 eprintln!("{}", migration.user_notice());
9846 }
9847 Ok(None) => {}
9848 Err(err) => logging::warn(format!("Config migration skipped: {err}")),
9849 }
9850
9851 let model = config.default_model();
9852 let provider = config.api_provider();
9853 let max_subagents = cli.max_subagents.map_or_else(
9854 || config.max_subagents_for_provider(provider),
9855 |value| value.clamp(1, MAX_SUBAGENTS),
9856 );
9857 let use_alt_screen = should_use_alt_screen(cli, config);
9858 let use_mouse_capture = should_use_mouse_capture(cli, config, use_alt_screen);
9859 let use_bracketed_paste = crate::settings::Settings::load()
9860 .map(|s| s.effective_bracketed_paste())
9861 .unwrap_or_else(|_| !crate::settings::detected_legacy_windows_console_host());
9862
9863 let skills_dir = config.skills_dir();
9866 if let Err(e) = crate::skills::install_system_skills(&skills_dir) {
9867 logging::warn(format!("Failed to install system skills: {e}"));
9868 }
9869
9870 startup_trace::mark("interactive_config");
9871
9872 crate::models_dev_live::maybe_load_persisted_cache();
9876 crate::models_dev_live::spawn_background_refresh();
9877 crate::client::DeepSeekClient::spawn_active_provider_catalog_refresh(config);
9882
9883 let snapshots = config.snapshots_config();
9890 let janitor_snapshots_enabled = snapshots.enabled;
9891 let janitor_max_age = snapshots.max_age();
9892 let janitor_workspace = workspace.clone();
9893 let janitor_resume_id = resume_session_id.clone();
9898 let janitor_skip_session_cleanup = resume_session_id.is_some() || cli.continue_session;
9899 tokio::task::spawn_blocking(move || {
9900 if janitor_snapshots_enabled {
9901 session_manager::prune_workspace_snapshots(&janitor_workspace, janitor_max_age);
9902 }
9903
9904 match crate::tools::truncate::prune_older_than(crate::tools::truncate::SPILLOVER_MAX_AGE) {
9905 Ok(0) => {}
9906 Ok(n) => tracing::debug!(
9907 target: "spillover",
9908 "boot prune removed {n} spillover file(s)"
9909 ),
9910 Err(err) => tracing::warn!(
9911 target: "spillover",
9912 ?err,
9913 "spillover prune skipped on boot"
9914 ),
9915 }
9916
9917 if !janitor_skip_session_cleanup
9918 && let Ok(manager) = session_manager::SessionManager::default_location()
9919 {
9920 let _ = manager.cleanup_old_sessions_keeping(janitor_resume_id.as_deref());
9921 }
9922 });
9923
9924 let yolo = cli.yolo || config.yolo.unwrap_or(false);
9927
9928 tui::run_tui(
9929 config,
9930 tui::TuiOptions {
9931 model,
9932 workspace,
9933 config_path: cli.config.clone(),
9934 config_profile: effective_config_profile(cli),
9935 allow_shell: interactive_tui_allow_shell(yolo, config),
9936 use_alt_screen,
9937 use_mouse_capture,
9938 use_bracketed_paste,
9939 skills_dir,
9940 memory_path: config.memory_path(),
9941 notes_path: config.notes_path(),
9942 mcp_config_path: config.mcp_config_path(),
9943 use_memory: config.memory_enabled(),
9944 start_in_agent_mode: yolo,
9945 skip_onboarding: cli.skip_onboarding,
9946 yolo, resume_session_id,
9948 initial_input,
9949 startup_notice,
9950 max_subagents,
9951 },
9952 plugin_registry,
9953 pending_telemetry_notice,
9954 )
9955 .await
9956}
9957
9958#[derive(Debug)]
9959struct CliAutoRoute {
9960 provider: crate::config::ApiProvider,
9961 model: String,
9962 reasoning_effort: Option<crate::tui::app::ReasoningEffort>,
9963 auto_controls_reasoning: bool,
9968 auto_model: bool,
9969}
9970
9971fn cli_reasoning_effort_value(
9972 config: &Config,
9973 model: &str,
9974 effort: crate::tui::app::ReasoningEffort,
9975) -> Option<String> {
9976 effort
9977 .api_value_for_route(config.api_provider(), &config.deepseek_base_url(), model)
9978 .map(str::to_string)
9979}
9980
9981fn cli_reasoning_effort_value_for_prompt(
9982 config: &Config,
9983 model: &str,
9984 effort: crate::tui::app::ReasoningEffort,
9985 prompt: &str,
9986) -> Option<String> {
9987 let resolved = if effort == crate::tui::app::ReasoningEffort::Auto {
9988 crate::auto_reasoning::select(false, prompt)
9989 } else {
9990 effort
9991 };
9992 cli_reasoning_effort_value(config, model, resolved)
9993}
9994
9995fn normalize_cli_reasoning_effort(value: &str) -> Result<Option<String>> {
9996 let trimmed = value.trim();
9997 if trimmed.is_empty() {
9998 return Ok(None);
9999 }
10000 if matches!(
10001 trimmed.to_ascii_lowercase().as_str(),
10002 "inherit" | "parent" | "same" | "current" | "default" | "unset"
10003 ) {
10004 return Ok(None);
10005 }
10006 crate::tui::app::ReasoningEffort::parse_strict(trimmed)
10007 .map(|effort| Some(effort.as_setting().to_string()))
10008 .map_err(anyhow::Error::msg)
10009}
10010
10011fn config_for_cli_route(config: &Config, route: &CliAutoRoute) -> Config {
10012 let mut execution_config = config.clone();
10013 execution_config.provider = Some(config.provider_identity_for(route.provider));
10014 execution_config.set_provider_model_override(route.provider, Some(route.model.clone()));
10015 if matches!(
10016 route.provider,
10017 crate::config::ApiProvider::Deepseek | crate::config::ApiProvider::DeepseekCN
10018 ) {
10019 execution_config.default_text_model = Some(route.model.clone());
10020 }
10021 execution_config
10022}
10023
10024async fn resolve_cli_auto_route(
10025 config: &Config,
10026 model: &str,
10027 prompt: &str,
10028) -> Result<CliAutoRoute> {
10029 if model.trim().eq_ignore_ascii_case("auto") {
10030 let selection =
10031 model_routing::resolve_auto_route_with_inventory(config, prompt, "", "auto", "auto")
10032 .await?;
10033 let preference = config
10034 .reasoning_effort()
10035 .filter(|_| config.reasoning_effort_is_explicit())
10036 .map(crate::tui::app::ReasoningEffort::from_setting);
10037 let (reasoning_effort, auto_controls_reasoning) =
10038 model_routing::resolve_auto_model_reasoning(preference, selection.reasoning_effort);
10039 Ok(CliAutoRoute {
10040 provider: selection.provider,
10041 model: selection.model,
10042 reasoning_effort,
10043 auto_controls_reasoning,
10044 auto_model: true,
10045 })
10046 } else {
10047 if let Some(selection) = model_routing::resolve_explicit_route_with_inventory(config, model)
10048 {
10049 let auto_controls_reasoning = matches!(
10050 selection.reasoning_effort,
10051 Some(crate::tui::app::ReasoningEffort::Auto)
10052 );
10053 return Ok(CliAutoRoute {
10054 provider: selection.provider,
10055 model: selection.model,
10056 reasoning_effort: selection.reasoning_effort,
10057 auto_controls_reasoning,
10058 auto_model: false,
10059 });
10060 }
10061
10062 let candidate_providers = model_routing::explicit_route_candidate_providers(config, model);
10063 if !candidate_providers.is_empty() && !candidate_providers.contains(&config.api_provider())
10064 {
10065 let providers = candidate_providers
10066 .iter()
10067 .map(|provider| provider.as_str())
10068 .collect::<Vec<_>>()
10069 .join(", ");
10070 bail!(
10071 "model `{model}` is available from configured provider route(s): {providers}. \
10072 Pass `--provider <provider>` with `--model {model}` to choose one explicitly. \
10073 In the TUI, use `/provider`, `/model`, or `/setup` to resolve the route before sending."
10074 );
10075 }
10076
10077 let reasoning_effort = config
10084 .reasoning_effort()
10085 .map(crate::tui::app::ReasoningEffort::from_setting);
10086 Ok(CliAutoRoute {
10087 provider: config.api_provider(),
10088 model: model.to_string(),
10089 auto_controls_reasoning: matches!(
10090 reasoning_effort,
10091 Some(crate::tui::app::ReasoningEffort::Auto)
10092 ),
10093 reasoning_effort,
10094 auto_model: false,
10095 })
10096 }
10097}
10098
10099async fn resolve_cli_exec_route(
10100 config: &Config,
10101 model: &str,
10102 prompt: &str,
10103 force_configured_route: bool,
10104) -> Result<CliAutoRoute> {
10105 if force_configured_route && !model.trim().eq_ignore_ascii_case("auto") {
10106 let reasoning_effort = config
10107 .reasoning_effort()
10108 .map(crate::tui::app::ReasoningEffort::from_setting);
10109 return Ok(CliAutoRoute {
10110 provider: config.api_provider(),
10111 model: model.to_string(),
10112 auto_controls_reasoning: matches!(
10113 reasoning_effort,
10114 Some(crate::tui::app::ReasoningEffort::Auto)
10115 ),
10116 reasoning_effort,
10117 auto_model: false,
10118 });
10119 }
10120 resolve_cli_auto_route(config, model, prompt).await
10121}
10122
10123fn should_force_configured_exec_route(
10124 resuming: bool,
10125 explicit_provider: Option<&str>,
10126 explicit_model: Option<&str>,
10127) -> bool {
10128 resuming || explicit_provider.is_some() || explicit_model.is_none()
10133}
10134
10135async fn run_one_shot(
10136 config: &Config,
10137 model: &str,
10138 prompt: &str,
10139 force_configured_route: bool,
10140) -> Result<()> {
10141 use crate::client::DeepSeekClient;
10142 use crate::models::{
10143 ContentBlock, Message, MessageRequest, is_incomplete_stop_reason, stop_reason_detail,
10144 };
10145
10146 let route = resolve_cli_exec_route(config, model, prompt, force_configured_route).await?;
10147 let execution_config = config_for_cli_route(config, &route);
10148 let client = DeepSeekClient::new(&execution_config)?;
10149 let reasoning_effort = route.reasoning_effort.and_then(|effort| {
10150 cli_reasoning_effort_value_for_prompt(&execution_config, &route.model, effort, prompt)
10151 });
10152 let model = route.model;
10153 let request_route = client.effective_route_envelope(&model, chrono::Utc::now());
10154
10155 let request = MessageRequest {
10156 model,
10157 messages: vec![Message {
10158 role: "user".to_string(),
10159 content: vec![ContentBlock::Text {
10160 text: prompt.to_string(),
10161 cache_control: None,
10162 }],
10163 }],
10164 max_tokens: client.effective_max_output_tokens(&request_route.model),
10165 system: None,
10166 tools: None,
10167 tool_choice: None,
10168 metadata: None,
10169 thinking: None,
10170 reasoning_effort,
10171 stream: Some(false),
10172 temperature: None,
10173 top_p: None,
10174 };
10175
10176 let response = client.create_message(request).await?;
10177 let stop_reason = response.stop_reason.clone();
10178
10179 for block in response.content {
10180 if let ContentBlock::Text { text, .. } = block {
10181 println!("{text}");
10182 }
10183 }
10184
10185 if is_incomplete_stop_reason(stop_reason.as_deref()) {
10186 anyhow::bail!(
10187 "Model response incomplete: provider stop reason `{}`; the partial response was printed but the command did not succeed.",
10188 stop_reason_detail(stop_reason.as_deref())
10189 );
10190 }
10191
10192 Ok(())
10193}
10194
10195async fn run_one_shot_json(
10196 config: &Config,
10197 model: &str,
10198 prompt: &str,
10199 force_configured_route: bool,
10200) -> Result<()> {
10201 use crate::client::DeepSeekClient;
10202 use crate::models::{
10203 ContentBlock, Message, MessageRequest, SystemPrompt, is_incomplete_stop_reason,
10204 stop_reason_detail,
10205 };
10206
10207 let route = resolve_cli_exec_route(config, model, prompt, force_configured_route).await?;
10208 let execution_config = config_for_cli_route(config, &route);
10209 let provider = execution_config.provider_identity_for(route.provider);
10210 let client = DeepSeekClient::new(&execution_config)?;
10211 let model = route.model.clone();
10212 let reasoning_effort = route.reasoning_effort.and_then(|effort| {
10213 cli_reasoning_effort_value_for_prompt(&execution_config, &model, effort, prompt)
10214 });
10215 let request_route = client.effective_route_envelope(&model, chrono::Utc::now());
10216 let request = MessageRequest {
10217 model: model.clone(),
10218 messages: vec![Message {
10219 role: "user".to_string(),
10220 content: vec![ContentBlock::Text {
10221 text: prompt.to_string(),
10222 cache_control: None,
10223 }],
10224 }],
10225 max_tokens: client.effective_max_output_tokens(&request_route.model),
10226 system: Some(SystemPrompt::Text(
10227 "You are a coding assistant. Give concise, actionable responses.".to_string(),
10228 )),
10229 tools: None,
10230 tool_choice: None,
10231 metadata: None,
10232 thinking: None,
10233 reasoning_effort,
10234 stream: Some(false),
10235 temperature: None,
10236 top_p: None,
10237 };
10238
10239 let response = client.create_message(request).await?;
10240 let stop_reason = response.stop_reason.clone();
10241 let usage = response.usage.clone();
10242 let mut output = String::new();
10243 for block in response.content {
10244 if let ContentBlock::Text { text, .. } = block {
10245 output.push_str(&text);
10246 }
10247 }
10248 println!(
10249 "{}",
10250 serde_json::to_string_pretty(&one_shot_exec_json_receipt(
10251 provider,
10252 model,
10253 output,
10254 stop_reason.clone(),
10255 usage,
10256 ))?
10257 );
10258 if is_incomplete_stop_reason(stop_reason.as_deref()) {
10259 anyhow::bail!(
10260 "Model response incomplete: provider stop reason `{}`; the JSON receipt records success=false.",
10261 stop_reason_detail(stop_reason.as_deref())
10262 );
10263 }
10264 Ok(())
10265}
10266
10267fn one_shot_exec_json_receipt(
10268 provider: String,
10269 model: String,
10270 output: String,
10271 stop_reason: Option<String>,
10272 usage: crate::models::Usage,
10273) -> serde_json::Value {
10274 let incomplete = crate::models::is_incomplete_stop_reason(stop_reason.as_deref());
10275 let error = incomplete.then(|| {
10276 format!(
10277 "Model response incomplete: provider stop reason `{}`.",
10278 crate::models::stop_reason_detail(stop_reason.as_deref())
10279 )
10280 });
10281 serde_json::json!({
10282 "mode": "one-shot",
10283 "provider": provider,
10284 "model": model,
10285 "success": !incomplete,
10286 "output": output,
10287 "stop_reason": stop_reason,
10288 "usage": usage,
10289 "error": error,
10290 })
10291}
10292
10293fn exec_stream_provider_route(
10294 identity: &crate::config::ProviderIdentity,
10295) -> (String, Option<String>) {
10296 let provider = identity.provider.as_str().to_string();
10297 let provider_id = if identity.provider == crate::config::ApiProvider::Custom {
10298 identity.exact_id.clone()
10299 } else {
10300 None
10301 };
10302 (provider, provider_id)
10303}
10304
10305#[derive(serde::Serialize)]
10306struct ExecStreamMeta {
10307 receipt_kind: &'static str,
10308 provider: String,
10309 #[serde(skip_serializing_if = "Option::is_none")]
10313 provider_id: Option<String>,
10314 model: String,
10315 route_source: String,
10316 #[serde(skip_serializing_if = "Option::is_none")]
10317 input_tokens: Option<u32>,
10318 #[serde(skip_serializing_if = "Option::is_none")]
10319 output_tokens: Option<u32>,
10320 #[serde(skip_serializing_if = "Option::is_none")]
10321 prompt_cache_hit_tokens: Option<u32>,
10322 #[serde(skip_serializing_if = "Option::is_none")]
10323 prompt_cache_miss_tokens: Option<u32>,
10324 #[serde(skip_serializing_if = "Option::is_none")]
10325 prompt_cache_write_tokens: Option<u32>,
10326 #[serde(skip_serializing_if = "Option::is_none")]
10327 reasoning_tokens: Option<u32>,
10328 #[serde(skip_serializing_if = "Option::is_none")]
10330 codewhale_max_output_tokens: Option<u32>,
10331 #[serde(skip_serializing_if = "Option::is_none")]
10334 codewhale_max_output_tokens_source: Option<&'static str>,
10335 duration_ms: u64,
10336 #[serde(skip_serializing_if = "Option::is_none")]
10337 retry_count: Option<u32>,
10338 approval_posture: String,
10339 sandbox_posture: String,
10340 #[serde(skip_serializing_if = "Option::is_none")]
10341 binary_sha256: Option<String>,
10342 #[serde(skip_serializing_if = "Option::is_none")]
10343 config_sha256: Option<String>,
10344 prompt_sha256: String,
10345 #[serde(skip_serializing_if = "Option::is_none")]
10346 tool_catalog_sha256: Option<String>,
10347 input_analysis: ExecStreamInputAnalysis,
10348 visible_final_answer_chars: usize,
10349 session_id: String,
10350 resume_command: String,
10351 workspace: String,
10352 message_count: usize,
10353 #[serde(skip_serializing_if = "Option::is_none")]
10354 status: Option<String>,
10355 #[serde(skip_serializing_if = "Option::is_none")]
10356 termination_reason: Option<String>,
10357 #[serde(skip_serializing_if = "Option::is_none")]
10358 error_category: Option<String>,
10359 #[serde(skip_serializing_if = "Option::is_none")]
10360 error: Option<String>,
10361}
10362
10363#[derive(Debug, Default, Clone, serde::Serialize, PartialEq, Eq)]
10364struct ExecStreamInputAnalysis {
10365 estimated_request_tokens: usize,
10366 estimated_message_content_tokens: usize,
10367 estimated_system_tokens: usize,
10368 estimated_framing_tokens: usize,
10369 user_message_count: usize,
10370 assistant_message_count: usize,
10371 tool_message_count: usize,
10372 tool_use_count: usize,
10373 tool_result_count: usize,
10374 text_chars: usize,
10375 thinking_chars: usize,
10376 tool_use_input_chars: usize,
10377 tool_result_chars: usize,
10378 text_estimated_tokens: usize,
10379 thinking_estimated_tokens: usize,
10380 tool_use_input_estimated_tokens: usize,
10381 tool_result_estimated_tokens: usize,
10382}
10383
10384#[derive(serde::Serialize)]
10385#[serde(tag = "type")]
10386#[allow(clippy::large_enum_variant)]
10389enum ExecStreamEvent {
10390 #[serde(rename = "content")]
10391 Content { content: String },
10392 #[serde(rename = "tool_use")]
10393 ToolUse {
10394 name: String,
10395 id: String,
10396 input: serde_json::Value,
10397 started_at: String,
10398 },
10399 #[serde(rename = "tool_result")]
10400 ToolResult {
10401 id: String,
10402 name: String,
10403 output: String,
10404 status: String,
10405 started_at: String,
10406 completed_at: String,
10407 duration_ms: u64,
10408 side_effect_status: String,
10409 #[serde(skip_serializing_if = "Option::is_none")]
10410 error_category: Option<String>,
10411 #[serde(skip_serializing_if = "Option::is_none")]
10412 truncated: Option<bool>,
10413 #[serde(skip_serializing_if = "Option::is_none")]
10414 artifact: Option<serde_json::Value>,
10415 #[serde(skip_serializing_if = "Option::is_none")]
10416 result_metadata: Option<serde_json::Value>,
10417 },
10418 #[serde(rename = "agent_spawned")]
10426 AgentSpawned {
10427 id: String,
10428 model: String,
10429 spawn_depth: u32,
10430 #[serde(skip_serializing_if = "Option::is_none")]
10431 parent_run_id: Option<String>,
10432 #[serde(skip_serializing_if = "Option::is_none")]
10434 route_source: Option<String>,
10435 },
10436 #[serde(rename = "sandbox_denied")]
10437 SandboxDenied {
10438 tool_id: String,
10439 tool_name: String,
10440 reason: String,
10441 outcome: String,
10442 },
10443 #[serde(rename = "workflow_event")]
10444 WorkflowEvent {
10445 run_id: String,
10446 event: serde_json::Value,
10447 },
10448 #[serde(rename = "session_capture")]
10449 SessionCapture { content: String },
10450 #[serde(rename = "service_released")]
10451 #[cfg(unix)]
10452 ServiceReleased {
10453 task_id: String,
10454 pid: u32,
10455 process_group_id: u32,
10456 ownership: String,
10457 },
10458 #[serde(rename = "turn_usage")]
10465 TurnUsage {
10466 turn: u32,
10468 input_tokens: u32,
10469 output_tokens: u32,
10470 #[serde(skip_serializing_if = "Option::is_none")]
10471 reasoning_tokens: Option<u32>,
10472 #[serde(skip_serializing_if = "Option::is_none")]
10473 prompt_cache_hit_tokens: Option<u32>,
10474 #[serde(skip_serializing_if = "Option::is_none")]
10475 prompt_cache_miss_tokens: Option<u32>,
10476 #[serde(skip_serializing_if = "Option::is_none")]
10477 prompt_cache_write_tokens: Option<u32>,
10478 #[serde(skip_serializing_if = "Option::is_none")]
10479 reasoning_replay_tokens: Option<u32>,
10480 duration_ms: u64,
10481 },
10482 #[serde(rename = "metadata")]
10483 Metadata { meta: Box<ExecStreamMeta> },
10484 #[serde(rename = "done")]
10485 Done,
10486 #[serde(rename = "error")]
10487 Error { error: String },
10488}
10489
10490fn exec_sandbox_elevation_authorized(
10491 allow_sandbox_elevation: bool,
10492 explicit_sandbox: Option<&str>,
10493) -> bool {
10494 allow_sandbox_elevation
10495 || explicit_sandbox.is_some_and(|policy| policy.eq_ignore_ascii_case("danger-full-access"))
10496}
10497
10498fn emit_exec_stream_event(event: &ExecStreamEvent) -> Result<()> {
10499 println!("{}", serde_json::to_string(&exec_stream_value(event)?)?);
10500 Ok(())
10501}
10502
10503const EXEC_EXIT_RETRYABLE_INFRA: i32 = 75; fn exec_failure_exit_code(error_category: Option<&str>) -> i32 {
10519 match error_category {
10520 Some("network" | "timeout") => EXEC_EXIT_RETRYABLE_INFRA,
10521 _ => 1,
10522 }
10523}
10524
10525fn exec_error_event_is_fatal(envelope: &crate::error_taxonomy::ErrorEnvelope) -> bool {
10532 !envelope.recoverable
10533}
10534
10535fn exec_stream_value(event: &ExecStreamEvent) -> Result<serde_json::Value> {
10536 let mut value = serde_json::to_value(event)?;
10537 if let Some(object) = value.as_object_mut() {
10538 object.insert("schema_version".to_string(), serde_json::json!(1));
10539 object.insert(
10540 "schema".to_string(),
10541 serde_json::json!("codewhale.exec-stream"),
10542 );
10543 }
10544 Ok(value)
10545}
10546
10547fn tool_error_receipt_category(error: &crate::tools::spec::ToolError) -> &'static str {
10548 use crate::tools::spec::ToolError;
10549 match error {
10550 ToolError::InvalidInput { .. } => "invalid_input",
10551 ToolError::MissingField { .. } => "missing_field",
10552 ToolError::PathEscape { .. } => "path_escape",
10553 ToolError::ExecutionFailed { .. } => "execution_failed",
10554 ToolError::Timeout { .. } => "timeout",
10555 ToolError::Cancelled { .. } => "cancelled",
10556 ToolError::NotAvailable { .. } => "not_available",
10557 ToolError::PermissionDenied { .. } => "permission_denied",
10558 }
10559}
10560
10561fn tool_artifact_receipt(metadata: Option<&serde_json::Value>) -> Option<serde_json::Value> {
10562 let object = metadata?.as_object()?;
10563 let mut artifact = serde_json::Map::new();
10564 for key in [
10565 "artifact_id",
10566 "artifact_path",
10567 "artifact_relative_path",
10568 "artifact_byte_size",
10569 "spillover_path",
10570 "content_digest",
10571 "original_byte_count",
10572 "retained_head_bytes",
10573 "retained_tail_bytes",
10574 ] {
10575 if let Some(value) = object.get(key) {
10576 artifact.insert(key.to_string(), value.clone());
10577 }
10578 }
10579 (!artifact.is_empty()).then_some(serde_json::Value::Object(artifact))
10580}
10581
10582fn current_binary_sha256() -> Option<String> {
10583 let bytes = std::fs::read(std::env::current_exe().ok()?).ok()?;
10584 Some(format!("sha256:{}", crate::hashing::sha256_hex(&bytes)))
10585}
10586
10587async fn run_workflow_tool_command(
10588 cli: &Cli,
10589 args: WorkflowToolArgs,
10590 plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
10591) -> Result<()> {
10592 match run_workflow_tool_command_inner(cli, args, plugin_registry).await {
10593 Ok(()) => Ok(()),
10594 Err(error) => {
10595 let _ = emit_exec_stream_event(&ExecStreamEvent::Error {
10596 error: format!("{error:#}"),
10597 });
10598 exit_workflow_tool_failure();
10599 }
10600 }
10601}
10602
10603async fn run_workflow_tool_command_inner(
10604 cli: &Cli,
10605 args: WorkflowToolArgs,
10606 plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
10607) -> Result<()> {
10608 use crate::tools::spec::ToolSpec;
10609
10610 if args.approval_source != "explicit-workflow-command" {
10611 bail!("workflow-tool requires --approval-source explicit-workflow-command");
10612 }
10613 let input: serde_json::Value = serde_json::from_str(&args.input_json)
10614 .context("--input-json must be a valid Workflow tool input object")?;
10615 if !input.is_object() {
10616 bail!("--input-json must be a JSON object");
10617 }
10618 if !input
10619 .get("action")
10620 .and_then(serde_json::Value::as_str)
10621 .is_some_and(|action| action.eq_ignore_ascii_case("run"))
10622 {
10623 bail!("workflow-tool accepts only action=run");
10624 }
10625
10626 let workspace = resolve_workspace(cli);
10627 let mut config = load_config_from_cli(cli)?;
10628 merge_user_workspace_config(&mut config, cli.config.clone(), &workspace);
10629 if let Ok(env_url) =
10630 std::env::var("CODEWHALE_BASE_URL").or_else(|_| std::env::var("DEEPSEEK_BASE_URL"))
10631 {
10632 let trimmed = env_url.trim();
10633 if !trimmed.is_empty() {
10634 config.base_url = Some(trimmed.to_string());
10635 }
10636 }
10637
10638 let model = resolve_exec_model(&config, None);
10639 let route = resolve_cli_exec_route(
10640 &config,
10641 &model,
10642 "Run a checked-in Workflow through the host runtime",
10643 true,
10644 )
10645 .await?;
10646 let execution_config = config_for_cli_route(&config, &route);
10647 let route_identity = execution_config
10648 .active_provider_identity(route.provider)
10649 .map_err(anyhow::Error::msg)
10650 .context("workflow terminal route lost its exact provider identity")?;
10651 let (route_provider, route_provider_id) = exec_stream_provider_route(&route_identity);
10652 let workflow_input_sha256 = format!(
10653 "sha256:{}",
10654 crate::hashing::sha256_hex(&serde_json::to_vec(&input)?)
10655 );
10656 let tool_id = format!("workflow_host_{}", &uuid::Uuid::new_v4().to_string()[..8]);
10657 let tool_started = Instant::now();
10658 let tool_started_at = chrono::Utc::now().to_rfc3339();
10659
10660 emit_exec_stream_event(&ExecStreamEvent::ToolUse {
10661 name: "workflow".to_string(),
10662 id: tool_id.clone(),
10663 input: input.clone(),
10664 started_at: tool_started_at.clone(),
10665 })?;
10666
10667 let (event_tx, event_rx) = tokio::sync::mpsc::channel(1024);
10668 let (stop_tx, stop_rx) = tokio::sync::oneshot::channel();
10669 let event_forwarder = tokio::spawn(forward_direct_workflow_events(event_rx, stop_rx));
10670 let (tool, context) = match build_direct_workflow_tool(
10671 &execution_config,
10672 &route,
10673 &workspace,
10674 event_tx,
10675 plugin_registry,
10676 )
10677 .await
10678 {
10679 Ok(built) => built,
10680 Err(err) => {
10681 let _ = stop_tx.send(());
10682 let _ = event_forwarder.await;
10683 exit_workflow_tool_error(&tool_id, err.to_string());
10684 }
10685 };
10686
10687 let result = tool.execute(input, &context).await;
10688 drop(tool);
10689 let _ = stop_tx.send(());
10690 event_forwarder
10691 .await
10692 .context("workflow event forwarder task failed")??;
10693
10694 let result = match result {
10695 Ok(result) => result,
10696 Err(err) => {
10697 let error = err.to_string();
10698 exit_workflow_tool_error(&tool_id, error);
10699 }
10700 };
10701
10702 let workflow_status =
10703 direct_workflow_status(&result.content).unwrap_or_else(|| "unknown".to_string());
10704 let completed = result.success && workflow_status == "completed";
10705 emit_exec_stream_event(&ExecStreamEvent::ToolResult {
10706 id: tool_id,
10707 name: "workflow".to_string(),
10708 output: result.content.clone(),
10709 status: if completed { "success" } else { "error" }.to_string(),
10710 started_at: tool_started_at,
10711 completed_at: chrono::Utc::now().to_rfc3339(),
10712 duration_ms: u64::try_from(tool_started.elapsed().as_millis()).unwrap_or(u64::MAX),
10713 side_effect_status: result
10714 .metadata
10715 .as_ref()
10716 .and_then(|metadata| metadata.get("side_effect_status"))
10717 .and_then(serde_json::Value::as_str)
10718 .unwrap_or("unknown")
10719 .to_string(),
10720 error_category: (!completed).then(|| "tool_error".to_string()),
10721 truncated: result
10722 .metadata
10723 .as_ref()
10724 .and_then(|metadata| metadata.get("truncated"))
10725 .and_then(serde_json::Value::as_bool),
10726 artifact: tool_artifact_receipt(result.metadata.as_ref()),
10727 result_metadata: result.metadata.clone(),
10728 })?;
10729 emit_exec_stream_event(&ExecStreamEvent::Metadata {
10730 meta: Box::new(ExecStreamMeta {
10731 receipt_kind: "terminal",
10732 provider: route_provider,
10733 provider_id: route_provider_id,
10734 model: "host-workflow".to_string(),
10738 route_source: "host_workflow".to_string(),
10739 input_tokens: None,
10740 output_tokens: None,
10741 prompt_cache_hit_tokens: None,
10742 prompt_cache_miss_tokens: None,
10743 prompt_cache_write_tokens: None,
10744 reasoning_tokens: None,
10745 codewhale_max_output_tokens: None,
10746 codewhale_max_output_tokens_source: None,
10747 duration_ms: u64::try_from(tool_started.elapsed().as_millis()).unwrap_or(u64::MAX),
10748 retry_count: None,
10749 approval_posture: "explicit_workflow_command".to_string(),
10750 sandbox_posture: "configured".to_string(),
10751 binary_sha256: current_binary_sha256(),
10752 config_sha256: None,
10753 prompt_sha256: workflow_input_sha256,
10754 tool_catalog_sha256: None,
10755 input_analysis: ExecStreamInputAnalysis::default(),
10756 visible_final_answer_chars: result.content.chars().count(),
10757 session_id: String::new(),
10758 resume_command: String::new(),
10759 workspace: workspace.display().to_string(),
10760 message_count: 0,
10761 status: Some(workflow_status.clone()),
10762 termination_reason: Some(if completed { "resolved" } else { "tool_error" }.to_string()),
10763 error_category: (!completed).then(|| "tool".to_string()),
10764 error: (!completed)
10765 .then(|| format!("workflow run ended with terminal status {workflow_status}")),
10766 }),
10767 })?;
10768 if !completed {
10769 let error = format!("workflow run ended with terminal status {workflow_status}");
10770 emit_exec_stream_event(&ExecStreamEvent::Error {
10771 error: error.clone(),
10772 })?;
10773 exit_workflow_tool_failure();
10774 }
10775 emit_exec_stream_event(&ExecStreamEvent::Done)?;
10776 Ok(())
10777}
10778
10779fn exit_workflow_tool_failure() -> ! {
10780 let _ = io::stdout().flush();
10781 std::process::exit(1)
10782}
10783
10784fn exit_workflow_tool_error(tool_id: &str, error: String) -> ! {
10785 let now = chrono::Utc::now().to_rfc3339();
10786 let _ = emit_exec_stream_event(&ExecStreamEvent::ToolResult {
10787 id: tool_id.to_string(),
10788 name: "workflow".to_string(),
10789 output: error.clone(),
10790 status: "error".to_string(),
10791 started_at: now.clone(),
10792 completed_at: now,
10793 duration_ms: 0,
10794 side_effect_status: "unknown".to_string(),
10795 error_category: Some("execution_failed".to_string()),
10796 truncated: None,
10797 artifact: None,
10798 result_metadata: None,
10799 });
10800 let _ = emit_exec_stream_event(&ExecStreamEvent::Error { error });
10801 exit_workflow_tool_failure()
10802}
10803
10804async fn initialize_direct_workflow_mcp_pool(
10805 config: &Config,
10806 workspace: &Path,
10807 network_policy: Option<crate::network_policy::NetworkPolicyDecider>,
10808 plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
10809) -> Option<(
10810 std::sync::Arc<tokio::sync::Mutex<crate::mcp::McpPool>>,
10811 Vec<(String, String)>,
10812)> {
10813 if !config.features().enabled(Feature::Mcp) {
10814 return None;
10815 }
10816 let mut pool = crate::mcp::McpPool::from_config_path_with_workspace_and_plugins(
10817 &config.mcp_config_path(),
10818 workspace,
10819 plugin_registry,
10820 )
10821 .unwrap_or_else(|error| {
10822 tracing::debug!("No MCP config for direct Workflow runtime: {error:#}");
10823 crate::mcp::McpPool::new(crate::mcp::McpConfig::default())
10824 });
10825 if let Some(policy) = network_policy {
10826 pool = pool.with_network_policy(policy);
10827 }
10828 let failures = pool
10829 .connect_all()
10830 .await
10831 .into_iter()
10832 .map(|(server, error)| (server, format!("{error:#}")))
10833 .collect();
10834 Some((std::sync::Arc::new(tokio::sync::Mutex::new(pool)), failures))
10835}
10836
10837async fn build_direct_workflow_tool(
10838 config: &Config,
10839 route: &CliAutoRoute,
10840 workspace: &Path,
10841 event_tx: tokio::sync::mpsc::Sender<crate::core::events::Event>,
10842 plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
10843) -> Result<(
10844 crate::tools::workflow::WorkflowTool,
10845 crate::tools::ToolContext,
10846)> {
10847 use std::sync::Arc;
10848
10849 use crate::client::DeepSeekClient;
10850 use crate::core::authority::shell_policy_for_mode;
10851 use crate::fleet::roster::FleetRoster;
10852 use crate::tools::AgentToolSurfaceOptions;
10853 use crate::tools::goal::new_shared_goal_state;
10854 use crate::tools::subagent::{SubAgentRuntime, new_shared_subagent_manager_with_timeout};
10855 use crate::tools::todo::new_shared_todo_list;
10856 use crate::tui::app::AppMode;
10857
10858 let provider = config.api_provider();
10859 if !config.subagents_enabled_for_provider(provider) {
10860 bail!(
10861 "Workflow dispatch requires sub-agents for provider {} ({})",
10862 provider.as_str(),
10863 config
10864 .subagents_disabled_reason()
10865 .unwrap_or("provider-specific sub-agent configuration disabled it")
10866 );
10867 }
10868
10869 let yolo = config.yolo.unwrap_or(false);
10870 let mode = if yolo {
10871 AppMode::Yolo
10872 } else {
10873 AppMode::Operate
10874 };
10875 let allow_shell = yolo || config.allow_shell();
10876 let shell_policy = shell_policy_for_mode(mode, allow_shell);
10877 let trusted = crate::workspace_trust::WorkspaceTrust::load_for(workspace);
10878 let mut context = crate::tools::ToolContext::with_auto_approve(
10879 workspace.to_path_buf(),
10880 yolo,
10881 config.notes_path(),
10882 config.mcp_config_path(),
10883 yolo,
10884 )
10885 .with_features(config.features())
10886 .with_skills_config(
10887 config.skills_dir(),
10888 config.skills_config().scan_codewhale_only(),
10889 )
10890 .with_plugin_registry(std::sync::Arc::clone(&plugin_registry))
10891 .with_shell_policy(shell_policy)
10892 .with_trusted_external_paths(trusted.paths().to_vec())
10893 .with_elevated_sandbox_policy(crate::core::authority::sandbox_policy_for_turn(
10894 mode,
10895 if yolo {
10896 crate::tui::approval::ApprovalMode::Bypass
10897 } else {
10898 crate::tui::approval::ApprovalMode::Suggest
10899 },
10900 config.sandbox_mode.as_deref(),
10901 workspace,
10902 ));
10903 let network_policy = config.network.clone().map(|network| {
10904 crate::network_policy::NetworkPolicyDecider::with_default_audit(network.into_runtime())
10905 });
10906 if let Some(policy) = network_policy.as_ref() {
10907 context = context.with_network_policy(policy.clone());
10908 }
10909 if config.memory_enabled() {
10910 context.memory_path = Some(config.memory_path());
10911 }
10912 context.search_provider = config.search_provider();
10913 context.search_api_key = config
10914 .search
10915 .as_ref()
10916 .and_then(|search| search.api_key.clone());
10917 context.search_base_url = config
10918 .search
10919 .as_ref()
10920 .and_then(|search| search.base_url.clone());
10921 if let Some(backend) = crate::sandbox::backend::create_backend(config)? {
10922 context = context.with_sandbox_backend(Arc::from(backend));
10923 }
10924
10925 let max_subagents = config.max_subagents_for_provider(provider);
10926 let manager = new_shared_subagent_manager_with_timeout(
10927 workspace.to_path_buf(),
10928 max_subagents,
10929 config
10930 .max_admitted_subagents_for_provider(provider)
10931 .max(max_subagents),
10932 Duration::from_secs(config.subagent_heartbeat_timeout_secs_for_provider(provider)),
10933 config.launch_concurrency_for_provider(provider),
10934 config.subagent_token_budget_for_provider(provider),
10935 );
10936 let roster = Arc::new(FleetRoster::load(&config.fleet_config(), workspace));
10937 let mut role_models = roster.model_overrides();
10938 role_models.extend(config.subagent_model_overrides());
10939
10940 let features = config.features();
10941 let mut surface = AgentToolSurfaceOptions::new(shell_policy);
10942 surface.apply_patch_enabled = features.enabled(Feature::ApplyPatch);
10943 surface.web_search_enabled = features.enabled(Feature::WebSearch);
10944 surface.memory_tool_enabled = config.memory_enabled();
10945 surface.vision_config = features
10946 .enabled(Feature::VisionModel)
10947 .then(|| config.vision_model_config())
10948 .flatten();
10949 surface.speech_output_dir = config.speech_output_dir();
10950 surface.goal_state = Some(new_shared_goal_state());
10951
10952 let client = DeepSeekClient::new(config)?;
10953 let reasoning_effort_auto = route.auto_controls_reasoning;
10960 let reasoning_effort = route
10961 .reasoning_effort
10962 .and_then(|effort| cli_reasoning_effort_value(config, &route.model, effort));
10963 let mcp_pool = if let Some((pool, failures)) =
10964 initialize_direct_workflow_mcp_pool(config, workspace, network_policy, plugin_registry)
10965 .await
10966 {
10967 for (server, error) in failures {
10968 tracing::warn!(
10969 server = %server,
10970 error = %error,
10971 "direct Workflow runtime could not connect MCP server"
10972 );
10973 }
10974 Some(pool)
10975 } else {
10976 None
10977 };
10978 let runtime = SubAgentRuntime::new(
10979 client,
10980 route.model.clone(),
10981 context.clone(),
10982 allow_shell,
10983 Some(event_tx),
10984 manager.clone(),
10985 )
10986 .with_locale_tag(
10987 crate::localization::resolve_locale(
10988 &crate::settings::Settings::load_persisted()
10989 .unwrap_or_default()
10990 .locale,
10991 )
10992 .tag(),
10993 )
10994 .with_role_models(role_models)
10995 .with_api_config(config.clone())
10996 .with_fleet_roster(roster)
10997 .with_auto_model(route.auto_model)
10998 .with_reasoning_effort(reasoning_effort, reasoning_effort_auto)
10999 .with_agent_tool_surface_options(surface)
11000 .with_max_spawn_depth(config.subagent_max_spawn_depth_for_provider(provider))
11001 .with_step_api_timeout(Duration::from_secs(
11002 config.subagent_api_timeout_secs_for_provider(provider),
11003 ))
11004 .with_speech_output_dir(config.speech_output_dir())
11005 .with_mcp_pool(mcp_pool)
11006 .with_todos(new_shared_todo_list())
11007 .with_parent_mode(mode);
11008
11009 Ok((
11010 crate::tools::workflow::WorkflowTool::new(manager, runtime).with_explicit_cli_approval(),
11011 context,
11012 ))
11013}
11014
11015async fn forward_direct_workflow_events(
11016 mut event_rx: tokio::sync::mpsc::Receiver<crate::core::events::Event>,
11017 mut stop_rx: tokio::sync::oneshot::Receiver<()>,
11018) -> Result<()> {
11019 loop {
11020 tokio::select! {
11021 biased;
11022 event = event_rx.recv() => match event {
11023 Some(event) => emit_direct_workflow_event(event)?,
11024 None => return Ok(()),
11025 },
11026 _ = &mut stop_rx => {
11027 while let Ok(event) = event_rx.try_recv() {
11028 emit_direct_workflow_event(event)?;
11029 }
11030 return Ok(());
11031 }
11032 }
11033 }
11034}
11035
11036fn emit_direct_workflow_event(event: crate::core::events::Event) -> Result<()> {
11037 if let crate::core::events::Event::WorkflowUi { run_id, event, .. } = event {
11038 emit_exec_stream_event(&ExecStreamEvent::WorkflowEvent { run_id, event })?;
11039 }
11040 Ok(())
11041}
11042
11043fn direct_workflow_status(content: &str) -> Option<String> {
11044 serde_json::from_str::<serde_json::Value>(content)
11045 .ok()?
11046 .get("status")?
11047 .as_str()
11048 .map(str::to_ascii_lowercase)
11049}
11050
11051fn exec_stream_input_analysis(
11052 messages: &[Message],
11053 system: Option<&SystemPrompt>,
11054) -> ExecStreamInputAnalysis {
11055 let mut analysis = ExecStreamInputAnalysis {
11056 estimated_request_tokens: crate::compaction::estimate_input_tokens_conservative(
11057 messages, system,
11058 ),
11059 estimated_message_content_tokens: crate::compaction::estimate_tokens(messages),
11060 estimated_system_tokens: exec_stream_estimate_system_tokens(system),
11061 estimated_framing_tokens: messages.len().saturating_mul(12).saturating_add(48),
11062 ..ExecStreamInputAnalysis::default()
11063 };
11064
11065 for message in messages {
11066 match message.role.as_str() {
11067 "user" => analysis.user_message_count += 1,
11068 "assistant" => analysis.assistant_message_count += 1,
11069 "tool" => analysis.tool_message_count += 1,
11070 _ => {}
11071 }
11072
11073 for block in &message.content {
11074 match block {
11075 ContentBlock::Text { text, .. } => {
11076 exec_stream_add_text_estimate(
11077 text,
11078 &mut analysis.text_chars,
11079 &mut analysis.text_estimated_tokens,
11080 );
11081 }
11082 ContentBlock::Thinking { thinking, .. } => {
11083 exec_stream_add_text_estimate(
11084 thinking,
11085 &mut analysis.thinking_chars,
11086 &mut analysis.thinking_estimated_tokens,
11087 );
11088 }
11089 ContentBlock::ToolUse { input, .. } | ContentBlock::ServerToolUse { input, .. } => {
11090 analysis.tool_use_count += 1;
11091 exec_stream_add_json_estimate(
11092 input,
11093 &mut analysis.tool_use_input_chars,
11094 &mut analysis.tool_use_input_estimated_tokens,
11095 );
11096 }
11097 ContentBlock::ToolResult {
11098 content,
11099 content_blocks,
11100 ..
11101 } => {
11102 analysis.tool_result_count += 1;
11103 exec_stream_add_text_estimate(
11104 content,
11105 &mut analysis.tool_result_chars,
11106 &mut analysis.tool_result_estimated_tokens,
11107 );
11108 if let Some(blocks) = content_blocks {
11109 exec_stream_add_json_estimate(
11110 blocks,
11111 &mut analysis.tool_result_chars,
11112 &mut analysis.tool_result_estimated_tokens,
11113 );
11114 }
11115 }
11116 ContentBlock::ToolSearchToolResult { content, .. }
11117 | ContentBlock::CodeExecutionToolResult { content, .. } => {
11118 analysis.tool_result_count += 1;
11119 exec_stream_add_json_estimate(
11120 content,
11121 &mut analysis.tool_result_chars,
11122 &mut analysis.tool_result_estimated_tokens,
11123 );
11124 }
11125 ContentBlock::ImageUrl { .. } => {}
11126 }
11127 }
11128 }
11129
11130 analysis
11131}
11132
11133fn exec_stream_add_text_estimate(text: &str, chars: &mut usize, tokens: &mut usize) {
11134 *chars = chars.saturating_add(text.chars().count());
11135 *tokens = tokens.saturating_add(crate::compaction::estimate_text_tokens_conservative(text));
11136}
11137
11138fn exec_stream_add_json_estimate<T: serde::Serialize>(
11139 value: &T,
11140 chars: &mut usize,
11141 tokens: &mut usize,
11142) {
11143 let text = serde_json::to_string(value).unwrap_or_default();
11144 exec_stream_add_text_estimate(&text, chars, tokens);
11145}
11146
11147fn exec_stream_estimate_system_tokens(system: Option<&SystemPrompt>) -> usize {
11148 match system {
11149 Some(SystemPrompt::Text(text)) => {
11150 crate::compaction::estimate_text_tokens_conservative(text)
11151 }
11152 Some(SystemPrompt::Blocks(blocks)) => blocks
11153 .iter()
11154 .map(|block| crate::compaction::estimate_text_tokens_conservative(&block.text))
11155 .sum(),
11156 None => 0,
11157 }
11158}
11159
11160fn exec_saved_session_line(session_id: &str) -> String {
11161 format!("session: {}", truncate_id(session_id))
11162}
11163
11164fn exec_resumed_session_line(session_id: &str) -> String {
11165 format!("resumed session: {}", truncate_id(session_id))
11166}
11167
11168fn exec_stream_session_ref(session_id: &str) -> String {
11169 crate::utils::redacted_identifier_for_log(session_id)
11170}
11171
11172fn exec_stream_resume_hint(session_id: &str) -> String {
11173 if session_id.trim().is_empty() {
11174 String::new()
11175 } else {
11176 "codewhale exec --resume <redacted-session-id>".to_string()
11177 }
11178}
11179
11180#[derive(Clone, Copy)]
11181struct PersistedProviderRoute<'a> {
11182 kind: &'a str,
11183 id: Option<&'a str>,
11184}
11185
11186fn persist_exec_session(
11187 messages: &[Message],
11188 model: &str,
11189 provider_route: PersistedProviderRoute<'_>,
11190 workspace: &Path,
11191 system_prompt: &Option<SystemPrompt>,
11192 session_id: Option<&str>,
11193 total_tokens: u64,
11194) -> Result<String> {
11195 let manager =
11196 SessionManager::default_location().context("could not open session manager for save")?;
11197 let mut saved = if let Some(id) = session_id.filter(|id| !id.trim().is_empty()) {
11198 match manager.load_session(id) {
11199 Ok(existing) => session_manager::update_session(
11200 existing,
11201 messages,
11202 total_tokens,
11203 system_prompt.as_ref(),
11204 ),
11205 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
11206 session_manager::create_saved_session_with_id_and_mode(
11207 id.to_string(),
11208 messages,
11209 model,
11210 workspace,
11211 total_tokens,
11212 system_prompt.as_ref(),
11213 Some("exec"),
11214 )
11215 }
11216 Err(err) => return Err(err).context("could not load existing exec session"),
11217 }
11218 } else {
11219 session_manager::create_saved_session_with_mode(
11220 messages,
11221 model,
11222 workspace,
11223 total_tokens,
11224 system_prompt.as_ref(),
11225 Some("exec"),
11226 )
11227 };
11228 stamp_exec_session_metadata(
11229 &mut saved,
11230 model,
11231 provider_route.kind,
11232 provider_route.id,
11233 workspace,
11234 );
11235 let id = saved.metadata.id.clone();
11236 manager
11237 .save_session(&saved)
11238 .context("could not save exec session")?;
11239 Ok(id)
11240}
11241
11242fn stamp_exec_session_metadata(
11243 saved: &mut session_manager::SavedSession,
11244 model: &str,
11245 model_provider_kind: &str,
11246 model_provider_id: Option<&str>,
11247 workspace: &Path,
11248) {
11249 saved.metadata.model = model.to_string();
11250 saved
11251 .metadata
11252 .set_model_provider_route(model_provider_kind, model_provider_id);
11253 saved.metadata.workspace = workspace.to_path_buf();
11254 saved.metadata.mode = Some("exec".to_string());
11255}
11256
11257#[derive(serde::Serialize)]
11258struct ExecToolEntry {
11259 name: String,
11260 success: bool,
11261 output: String,
11262}
11263
11264#[derive(serde::Serialize)]
11265struct ExecOutcome {
11266 kind: String,
11267 outcome: String,
11268 tool_name: String,
11269 reason: String,
11270}
11271
11272#[derive(serde::Serialize, Default)]
11273struct ExecSummary {
11274 mode: String,
11275 provider: String,
11276 model: String,
11277 prompt: String,
11278 output: String,
11279 tools: Vec<ExecToolEntry>,
11280 outcomes: Vec<ExecOutcome>,
11281 status: Option<String>,
11282 termination_reason: Option<String>,
11283 error_category: Option<String>,
11284 error: Option<String>,
11285 #[serde(skip_serializing_if = "Vec::is_empty")]
11286 released_services: Vec<crate::tools::shell::PersistentServiceReceipt>,
11287}
11288
11289fn validate_exec_tool_authority_resume(
11290 tool_authority_json: Option<&str>,
11291 resuming: bool,
11292) -> Result<()> {
11293 if tool_authority_json.is_some() && resuming {
11294 bail!(
11295 "Fleet tool authority cannot be combined with exec --resume, --session-id, or --continue"
11296 );
11297 }
11298 Ok(())
11299}
11300
11301fn exec_network_policy(
11302 config: &Config,
11303 outer_network_access: Option<bool>,
11304) -> Option<crate::network_policy::NetworkPolicyDecider> {
11305 if outer_network_access == Some(false) {
11308 return Some(crate::network_policy::NetworkPolicyDecider::new(
11309 crate::network_policy::NetworkPolicy {
11310 default: crate::network_policy::DecisionToml::Deny,
11311 ..crate::network_policy::NetworkPolicy::default()
11312 },
11313 None,
11314 ));
11315 }
11316 config.network.clone().map(|toml_cfg| {
11317 crate::network_policy::NetworkPolicyDecider::with_default_audit(toml_cfg.into_runtime())
11318 })
11319}
11320
11321fn apply_fleet_engine_feature_caps(
11322 features: &mut crate::features::Features,
11323 fleet_authority_active: bool,
11324 outer_network_access: Option<bool>,
11325 shell_authority: crate::tools::spec::ToolShellAuthority,
11326) {
11327 if fleet_authority_active {
11328 features.disable(crate::features::Feature::Subagents);
11329 features.disable(crate::features::Feature::Mcp);
11330 if shell_authority != crate::tools::spec::ToolShellAuthority::ReadOnly {
11331 features.disable(crate::features::Feature::ShellTool);
11332 }
11333 }
11334 if outer_network_access == Some(false) {
11335 features.disable(crate::features::Feature::WebSearch);
11336 }
11337}
11338
11339fn exec_max_steps(max_turns: Option<u32>) -> u32 {
11343 max_turns.unwrap_or(u32::MAX)
11344}
11345
11346#[allow(clippy::too_many_arguments)]
11347async fn run_exec_agent(
11348 config: &Config,
11349 model: &str,
11350 prompt: &str,
11351 workspace: PathBuf,
11352 max_subagents: usize,
11353 auto_approve: bool,
11354 allow_sandbox_elevation: bool,
11355 explicit_sandbox: Option<&str>,
11356 trust_mode: bool,
11357 json_output: bool,
11358 resume_session: Option<session_manager::SavedSession>,
11359 force_configured_route: bool,
11360 output_format: ExecOutputFormat,
11361 max_turns: u32,
11362 allowed_tools: Option<Vec<String>>,
11363 disallowed_tools: Option<Vec<String>>,
11364 append_system_prompt: Option<String>,
11365 tool_authority_json: Option<String>,
11366 plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
11367) -> Result<()> {
11368 use crate::compaction::CompactionConfig;
11369 use crate::core::engine::{EngineConfig, spawn_engine};
11370 use crate::core::events::Event;
11371 use crate::core::ops::Op;
11372 use crate::tools::plan::new_shared_plan_state;
11373 use crate::tools::todo::new_shared_todo_list;
11374 use crate::tui::app::AppMode;
11375
11376 validate_exec_tool_authority_resume(tool_authority_json.as_deref(), resume_session.is_some())?;
11377 let fleet_authority = tool_authority_json
11378 .as_deref()
11379 .map(crate::tools::spec::ToolAuthorityEnvelope::from_json)
11380 .transpose()
11381 .map_err(anyhow::Error::msg)?;
11382 let fleet_authority_active = fleet_authority.is_some();
11383 let outer_network_access = fleet_authority
11384 .as_ref()
11385 .and_then(|authority| authority.network_access);
11386 let outer_shell_authority = fleet_authority
11387 .as_ref()
11388 .map(|authority| authority.shell)
11389 .unwrap_or_default();
11390 if let Some(envelope) = fleet_authority {
11391 crate::tools::spec::install_process_tool_authority(envelope).map_err(anyhow::Error::msg)?;
11392 }
11393
11394 let route = resolve_cli_exec_route(config, model, prompt, force_configured_route).await?;
11395 let execution_config = config_for_cli_route(config, &route);
11396 let auto_model = route.auto_model;
11397 let effective_provider = route.provider;
11398 let effective_model = route.model;
11399 let validated_route = crate::route_runtime::resolve_runtime_route(
11400 &execution_config,
11401 effective_provider,
11402 Some(&effective_model),
11403 )
11404 .map_err(anyhow::Error::msg)?
11405 .validate()
11406 .map_err(anyhow::Error::msg)?;
11407 let effective_provider_name = validated_route.identity.key.clone();
11408 let effective_provider_id = validated_route.identity.exact_id.clone();
11409 let (effective_provider_kind, effective_stream_provider_id) =
11410 exec_stream_provider_route(&validated_route.identity);
11411 let route_source = if auto_model {
11412 "auto_resolver"
11413 } else {
11414 "explicit_or_configured"
11415 }
11416 .to_string();
11417 let exec_started = Instant::now();
11418 let prompt_sha256 = format!("sha256:{}", crate::hashing::sha256_hex(prompt.as_bytes()));
11419 let binary_sha256 = current_binary_sha256();
11420 let approval_posture = if auto_approve { "auto_tools" } else { "ask" }.to_string();
11421 let sandbox_posture = explicit_sandbox.unwrap_or("configured_default").to_string();
11422 let active_route_limits =
11423 crate::route_budget::known_route_limits(validated_route.candidate.limits());
11424 let max_subagents = if max_subagents == config.max_subagents_for_provider(config.api_provider())
11425 {
11426 execution_config
11427 .max_subagents_for_provider(effective_provider)
11428 .clamp(1, MAX_SUBAGENTS)
11429 } else {
11430 max_subagents
11431 };
11432 let reasoning_effort_auto = route.auto_controls_reasoning;
11439 let effective_reasoning_effort = route.reasoning_effort.and_then(|effort| {
11443 cli_reasoning_effort_value_for_prompt(&execution_config, &effective_model, effort, prompt)
11444 });
11445
11446 let settings = crate::settings::Settings::load().unwrap_or_default();
11447 let auto_compact_enabled = if crate::settings::Settings::auto_compact_explicitly_configured() {
11448 settings.auto_compact
11449 } else {
11450 crate::route_budget::auto_compact_default_for_route(
11451 effective_provider,
11452 &effective_model,
11453 active_route_limits,
11454 )
11455 };
11456 let compaction = CompactionConfig {
11457 enabled: auto_compact_enabled,
11458 model: effective_model.clone(),
11459 effective_context_window: Some(crate::route_budget::route_context_window_tokens(
11460 effective_provider,
11461 &effective_model,
11462 active_route_limits,
11463 )),
11464 token_threshold: crate::route_budget::compaction_threshold_for_route_at_percent(
11465 effective_provider,
11466 &effective_model,
11467 active_route_limits,
11468 settings.auto_compact_threshold_percent,
11469 ),
11470 ..Default::default()
11471 };
11472
11473 let network_policy = exec_network_policy(&execution_config, outer_network_access);
11474
11475 let lsp_config = (!fleet_authority_active)
11476 .then(|| {
11477 execution_config
11478 .lsp
11479 .clone()
11480 .map(crate::config::LspConfigToml::into_runtime)
11481 })
11482 .flatten();
11483 let mut engine_features = execution_config.features();
11484 apply_fleet_engine_feature_caps(
11485 &mut engine_features,
11486 fleet_authority_active,
11487 outer_network_access,
11488 outer_shell_authority,
11489 );
11490 if crate::core::allowlist_is_native_file_and_shell_only(allowed_tools.as_deref()) {
11491 engine_features.disable(crate::features::Feature::Mcp);
11492 }
11493 let engine_plugin_registry = if fleet_authority_active {
11494 std::sync::Arc::new(crate::plugins::PluginRegistry::empty(&workspace))
11495 } else {
11496 plugin_registry
11497 };
11498 let exec_allow_shell = crate::tools::spec::fleet_exec_shell_enabled(
11499 fleet_authority_active,
11500 outer_shell_authority,
11501 disallowed_tools.as_deref(),
11502 ) || (!fleet_authority_active
11503 && (auto_approve || execution_config.allow_shell()));
11504 let persist_services_enabled = cfg!(unix)
11505 && !fleet_authority_active
11506 && exec_allow_shell
11507 && explicit_sandbox
11508 .is_some_and(|sandbox| sandbox.eq_ignore_ascii_case("danger-full-access"));
11509 let exec_shell_manager = crate::tools::shell::new_shared_shell_manager(workspace.clone());
11510 let runtime_services = crate::tools::spec::RuntimeToolServices {
11511 shell_manager: Some(exec_shell_manager.clone()),
11512 persist_services_enabled,
11513 ..crate::tools::spec::RuntimeToolServices::default()
11514 };
11515
11516 let engine_config = EngineConfig {
11517 model: effective_model.clone(),
11518 active_route_limits,
11519 workspace: workspace.clone(),
11520 subagent_state_root: None,
11521 plugin_registry: Some(std::sync::Arc::clone(&engine_plugin_registry)),
11522 allow_shell: exec_allow_shell,
11523 trust_mode,
11524 notes_path: execution_config.notes_path(),
11525 mcp_config_path: execution_config.mcp_config_path(),
11526 skills_dir: execution_config.skills_dir(),
11527 skills_scan_codewhale_only: execution_config.skills_config().scan_codewhale_only(),
11528 instructions: {
11529 let mut instrs: Vec<crate::prompts::InstructionSource> = execution_config
11530 .instructions_paths()
11531 .into_iter()
11532 .map(Into::into)
11533 .collect();
11534 if let Some(ref extra) = append_system_prompt {
11535 instrs.push(crate::prompts::InstructionSource::Inline {
11536 name: "cli:append-system-prompt".into(),
11537 content: extra.clone(),
11538 });
11539 }
11540 instrs
11541 },
11542 project_context_pack_enabled: execution_config.project_context_pack_enabled(),
11543 translation_enabled: false,
11544 max_steps: max_turns,
11545 max_subagents,
11546 max_admitted_subagents: execution_config
11547 .max_admitted_subagents_for_provider(effective_provider)
11548 .max(max_subagents),
11549 launch_concurrency: execution_config.launch_concurrency_for_provider(effective_provider),
11550 subagents_enabled: !fleet_authority_active
11551 && execution_config.subagents_enabled_for_provider(effective_provider),
11552 features: engine_features,
11553 auto_review_policy: execution_config.auto_review_policy(),
11554 compaction: compaction.clone(),
11555 todos: new_shared_todo_list(),
11556 plan_state: new_shared_plan_state(),
11557 goal_state: crate::tools::goal::new_shared_goal_state(),
11558 max_spawn_depth: if fleet_authority_active {
11559 0
11560 } else {
11561 execution_config.subagent_max_spawn_depth_for_provider(effective_provider)
11562 },
11563 subagent_token_budget: execution_config
11564 .subagent_token_budget_for_provider(effective_provider),
11565 network_policy,
11566 snapshots_enabled: !fleet_authority_active && execution_config.snapshots_config().enabled,
11567 snapshots_max_workspace_bytes: execution_config
11568 .snapshots_config()
11569 .max_workspace_gb
11570 .saturating_mul(1024 * 1024 * 1024),
11571 lsp_config,
11572 runtime_services,
11573 subagent_model_overrides: execution_config.subagent_model_overrides(),
11574 fleet_roster: std::sync::Arc::new(crate::fleet::roster::FleetRoster::load_with_plugins(
11575 &execution_config.fleet_config(),
11576 &workspace,
11577 engine_plugin_registry.as_ref(),
11578 )),
11579 subagent_api_timeout: std::time::Duration::from_secs(
11580 execution_config.subagent_api_timeout_secs_for_provider(effective_provider),
11581 ),
11582 stream_chunk_timeout: std::time::Duration::from_secs(
11583 execution_config.stream_chunk_timeout_secs(),
11584 ),
11585 subagent_heartbeat_timeout: std::time::Duration::from_secs(
11586 execution_config.subagent_heartbeat_timeout_secs_for_provider(effective_provider),
11587 ),
11588 prefer_bwrap: execution_config.prefer_bwrap.unwrap_or(false),
11589 bwrap_extensions: crate::sandbox::BwrapMountExtensions {
11590 read_only_roots: execution_config.bwrap_ro_roots.clone(),
11591 device_roots: execution_config.bwrap_dev_roots.clone(),
11592 },
11593 memory_enabled: execution_config.memory_enabled(),
11594 memory_path: execution_config.memory_path(),
11595 speech_output_dir: execution_config.speech_output_dir(),
11596 vision_config: execution_config.vision_model_config(),
11597 strict_tool_mode: execution_config.strict_tool_mode.unwrap_or(false),
11598 goal_objective: None,
11599 goal_token_budget: None,
11600 goal_status: crate::tools::goal::GoalStatus::Active,
11601 goal_max_continuations: execution_config.goal_max_continuations(),
11602 goal_continuation_delay_seconds: execution_config.goal_continuation_delay_seconds(),
11603 allowed_tools: allowed_tools.clone(),
11604 disallowed_tools: disallowed_tools.clone(),
11605 max_tool_calls: None,
11606 hook_executor: None,
11607 locale_tag: crate::localization::resolve_locale(&settings.locale)
11608 .tag()
11609 .to_string(),
11610 workshop: {
11611 crate::tools::large_output_router::WorkshopConfig::install_active(
11612 config.workshop.as_ref(),
11613 );
11614 config.workshop.clone()
11615 },
11616 search_provider: execution_config.search_provider(),
11617 search_api_key: execution_config
11618 .search
11619 .as_ref()
11620 .and_then(|s| s.api_key.clone()),
11621 search_base_url: execution_config
11622 .search
11623 .as_ref()
11624 .and_then(|s| s.base_url.clone()),
11625 tools_always_load: if fleet_authority_active {
11626 std::collections::HashSet::new()
11627 } else {
11628 execution_config.tools_always_load()
11629 },
11630 tools: if fleet_authority_active {
11631 None
11632 } else {
11633 execution_config.tools.clone()
11634 },
11635 verbosity: execution_config.verbosity.clone(),
11636 workspace_follow_symlinks: settings.workspace_follow_symlinks,
11637 exec_policy_engine: execution_config.exec_policy_engine.clone(),
11638 terminal_chrome_enabled: false,
11639 advisor_config: execution_config
11640 .advisor
11641 .as_ref()
11642 .map(crate::tools::subagent::AdvisorConfig::from_toml)
11643 .unwrap_or_else(crate::tools::subagent::AdvisorConfig::disabled),
11644 };
11645
11646 let engine_handle = spawn_engine(engine_config, &execution_config);
11647 let mode = if auto_approve {
11648 AppMode::Yolo
11649 } else {
11650 AppMode::Agent
11651 };
11652
11653 let resuming_session = resume_session.is_some();
11654 let mut loaded_session_id = None;
11655 if let Some(saved) = resume_session {
11656 let saved_id = saved.metadata.id.clone();
11657 if saved.metadata.workspace != workspace && output_format == ExecOutputFormat::Text {
11658 eprintln!(
11659 "Warning: session {} was created in a different workspace ({}). Resuming anyway.",
11660 truncate_id(&saved_id),
11661 saved.metadata.workspace.display(),
11662 );
11663 }
11664
11665 engine_handle
11666 .send(Op::SyncSession {
11667 session_id: Some(saved_id.clone()),
11668 messages: saved.messages,
11669 system_prompt: saved.system_prompt.map(SystemPrompt::Text),
11670 system_prompt_override: false,
11671 model: saved.metadata.model,
11672 workspace: saved.metadata.workspace,
11673 mode,
11674 })
11675 .await?;
11676 loaded_session_id = Some(saved_id.clone());
11677 if output_format == ExecOutputFormat::Text && !json_output {
11678 eprintln!("{}", exec_resumed_session_line(&saved_id));
11679 }
11680 }
11681
11682 engine_handle
11683 .send(Op::SendMessage {
11684 content: prompt.to_string(),
11685 mode,
11686 route: Box::new(validated_route.into_resolved()),
11687 compaction: Box::new(compaction.clone()),
11688 goal_objective: None,
11689 goal_token_budget: None,
11690 goal_status: crate::tools::goal::GoalStatus::Active,
11691 allowed_tools: allowed_tools.clone(),
11692 dynamic_tools: Vec::new(),
11693 hook_executor: None,
11694 reasoning_effort: effective_reasoning_effort,
11695 reasoning_effort_auto,
11696 auto_model,
11697 allow_shell: auto_approve || execution_config.allow_shell(),
11698 trust_mode,
11699 auto_approve,
11700 translation_enabled: false,
11701 approval_mode: if auto_approve {
11702 crate::tui::approval::ApprovalMode::Bypass
11703 } else {
11704 execution_config
11705 .approval_policy
11706 .as_deref()
11707 .and_then(crate::tui::approval::ApprovalMode::from_config_value)
11708 .unwrap_or_default()
11709 },
11710 verbosity: execution_config.verbosity.clone(),
11711 provenance: crate::core::ops::UserInputProvenance::ExternalUser,
11712 })
11713 .await?;
11714
11715 let mut summary = ExecSummary {
11716 mode: "agent".to_string(),
11717 provider: effective_provider_name.clone(),
11718 model: effective_model.clone(),
11719 prompt: prompt.to_string(),
11720 ..ExecSummary::default()
11721 };
11722 let can_elevate_sandbox =
11723 exec_sandbox_elevation_authorized(allow_sandbox_elevation, explicit_sandbox);
11724 let mut sandbox_denied = false;
11725 let mut approval_required = false;
11726 let mut tool_error_seen = false;
11727 let mut last_error_category = None;
11728 let mut reported_sandbox_contract = false;
11729
11730 let should_persist_session = resuming_session || output_format == ExecOutputFormat::StreamJson;
11731 let mut latest_session_id = loaded_session_id;
11732 let mut latest_messages: Vec<Message> = Vec::new();
11733 let mut latest_system_prompt: Option<SystemPrompt> = None;
11734 let mut latest_model = effective_model;
11735 let mut latest_workspace = workspace.clone();
11736 let mut tool_starts: HashMap<String, (Instant, String)> = HashMap::new();
11737 let mut turn_usage_seq: u32 = 0;
11738
11739 let mut stdout = io::stdout();
11740 let mut ends_with_newline = false;
11741 loop {
11742 let event = {
11743 let mut rx = engine_handle.rx_event.write().await;
11744 rx.recv().await
11745 };
11746
11747 let Some(event) = event else {
11748 break;
11749 };
11750
11751 match event {
11752 Event::MessageDelta { content, .. } => {
11753 summary.output.push_str(&content);
11754 if output_format == ExecOutputFormat::StreamJson {
11755 emit_exec_stream_event(&ExecStreamEvent::Content { content })?;
11756 } else if !json_output {
11757 print!("{content}");
11758 stdout.flush()?;
11759 }
11760 ends_with_newline = summary.output.ends_with('\n');
11761 }
11762 Event::MessageComplete { .. }
11763 if output_format == ExecOutputFormat::Text
11764 && !json_output
11765 && !ends_with_newline =>
11766 {
11767 println!();
11768 }
11769 Event::ThinkingDelta { .. } => {
11770 }
11773 Event::ToolCallStarted { id, name, input } => {
11774 let started_at = chrono::Utc::now().to_rfc3339();
11775 tool_starts.insert(id.clone(), (Instant::now(), started_at.clone()));
11776 if output_format == ExecOutputFormat::StreamJson {
11777 emit_exec_stream_event(&ExecStreamEvent::ToolUse {
11778 name,
11779 id,
11780 input,
11781 started_at,
11782 })?;
11783 } else if !json_output {
11784 let summary = summarize_tool_args(&input);
11785 if let Some(summary) = summary {
11786 eprintln!("tool: {name} ({summary})");
11787 } else {
11788 eprintln!("tool: {name}");
11789 }
11790 }
11791 }
11792 Event::ToolCallComplete {
11793 id, name, result, ..
11794 } => {
11795 let (duration_ms, started_at) = tool_starts
11796 .remove(&id)
11797 .map(|(started, timestamp)| {
11798 (
11799 u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
11800 timestamp,
11801 )
11802 })
11803 .unwrap_or_else(|| (0, chrono::Utc::now().to_rfc3339()));
11804 let receipt_name = name.clone();
11805 match result {
11806 Ok(output) => {
11807 tool_error_seen |= !output.success;
11808 summary.tools.push(ExecToolEntry {
11809 name: name.clone(),
11810 success: output.success,
11811 output: output.content.clone(),
11812 });
11813 if output_format == ExecOutputFormat::StreamJson {
11814 emit_exec_stream_event(&ExecStreamEvent::ToolResult {
11815 id,
11816 name: receipt_name,
11817 output: output.content,
11818 status: if output.success {
11819 "success".to_string()
11820 } else {
11821 "error".to_string()
11822 },
11823 started_at,
11824 completed_at: chrono::Utc::now().to_rfc3339(),
11825 duration_ms,
11826 side_effect_status: output
11827 .metadata
11828 .as_ref()
11829 .and_then(|metadata| metadata.get("side_effect_status"))
11830 .and_then(serde_json::Value::as_str)
11831 .unwrap_or("unknown")
11832 .to_string(),
11833 error_category: (!output.success).then(|| {
11834 output
11835 .metadata
11836 .as_ref()
11837 .and_then(|metadata| metadata.get("error_category"))
11838 .and_then(serde_json::Value::as_str)
11839 .unwrap_or("tool_reported_failure")
11840 .to_string()
11841 }),
11842 truncated: output
11843 .metadata
11844 .as_ref()
11845 .and_then(|metadata| metadata.get("truncated"))
11846 .and_then(serde_json::Value::as_bool),
11847 artifact: tool_artifact_receipt(output.metadata.as_ref()),
11848 result_metadata: output.metadata,
11849 })?;
11850 } else if !json_output {
11851 if name == "exec_shell" && !output.content.trim().is_empty() {
11852 eprintln!("tool {name} completed");
11853 eprintln!(
11854 "--- stdout/stderr ---\n{}\n---------------------",
11855 output.content
11856 );
11857 } else {
11858 eprintln!(
11859 "tool {name} completed: {}",
11860 summarize_tool_output(&output.content)
11861 );
11862 }
11863 }
11864 }
11865 Err(err) => {
11866 tool_error_seen = true;
11867 let error_text = err.to_string();
11868 summary.tools.push(ExecToolEntry {
11869 name: name.clone(),
11870 success: false,
11871 output: error_text.clone(),
11872 });
11873 if output_format == ExecOutputFormat::StreamJson {
11874 emit_exec_stream_event(&ExecStreamEvent::ToolResult {
11875 id,
11876 name: receipt_name,
11877 output: error_text,
11878 status: "error".to_string(),
11879 started_at,
11880 completed_at: chrono::Utc::now().to_rfc3339(),
11881 duration_ms,
11882 side_effect_status: "not_started_or_unknown".to_string(),
11883 error_category: Some(tool_error_receipt_category(&err).to_string()),
11884 truncated: None,
11885 artifact: None,
11886 result_metadata: None,
11887 })?;
11888 } else if !json_output {
11889 eprintln!("tool {name} failed: {err}");
11890 }
11891 }
11892 }
11893 }
11894 Event::AgentSpawned { id, prompt, .. }
11895 if output_format == ExecOutputFormat::Text && !json_output =>
11896 {
11897 eprintln!("sub-agent {id} spawned: {}", summarize_tool_output(&prompt));
11898 }
11899 Event::AgentProgress { id, status, .. }
11900 if output_format == ExecOutputFormat::Text && !json_output =>
11901 {
11902 eprintln!("sub-agent {id}: {status}");
11903 }
11904 Event::AgentComplete { id, result, .. }
11905 if output_format == ExecOutputFormat::Text && !json_output =>
11906 {
11907 eprintln!(
11908 "sub-agent {id} completed: {}",
11909 summarize_tool_output(&result)
11910 );
11911 }
11912 Event::AgentSpawned {
11913 id,
11914 parent_run_id,
11915 spawn_depth,
11916 model,
11917 route_source,
11918 ..
11919 } if output_format == ExecOutputFormat::StreamJson => {
11920 emit_exec_stream_event(&ExecStreamEvent::AgentSpawned {
11921 id,
11922 model,
11923 spawn_depth,
11924 parent_run_id,
11925 route_source,
11926 })?;
11927 }
11928 Event::AgentSpawned { .. }
11929 | Event::AgentProgress { .. }
11930 | Event::AgentComplete { .. } => {}
11931 Event::WorkflowUi { run_id, event, .. }
11932 if output_format == ExecOutputFormat::StreamJson =>
11933 {
11934 emit_exec_stream_event(&ExecStreamEvent::WorkflowEvent { run_id, event })?;
11935 }
11936 Event::ApprovalRequired { id, .. } => {
11937 if auto_approve {
11938 let _ = engine_handle.approve_tool_call(id).await;
11939 } else {
11940 approval_required = true;
11941 let _ = engine_handle.deny_tool_call(id).await;
11942 }
11943 }
11944 Event::ElevationRequired {
11945 tool_id,
11946 tool_name,
11947 denial_reason,
11948 ..
11949 } => {
11950 if can_elevate_sandbox {
11951 let policy = crate::sandbox::SandboxPolicy::DangerFullAccess;
11952 let _ = engine_handle.retry_tool_with_policy(tool_id, policy).await;
11953 } else {
11954 sandbox_denied = true;
11955 approval_required = true;
11956 summary.outcomes.push(ExecOutcome {
11957 kind: "sandbox_denied".to_string(),
11958 outcome: "approval_required".to_string(),
11959 tool_name: tool_name.clone(),
11960 reason: denial_reason.clone(),
11961 });
11962 if !reported_sandbox_contract {
11963 eprintln!(
11964 "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"
11965 );
11966 reported_sandbox_contract = true;
11967 }
11968 if output_format == ExecOutputFormat::StreamJson {
11969 emit_exec_stream_event(&ExecStreamEvent::SandboxDenied {
11970 tool_id: tool_id.clone(),
11971 tool_name,
11972 reason: denial_reason,
11973 outcome: "approval_required".to_string(),
11974 })?;
11975 }
11976 let _ = engine_handle.deny_tool_call(tool_id).await;
11977 }
11978 }
11979 Event::Error {
11980 envelope,
11981 recoverable: _,
11982 } => {
11983 if exec_error_event_is_fatal(&envelope) {
11991 last_error_category = Some(envelope.category);
11992 summary.error_category = Some(envelope.category.to_string());
11993 summary.error = Some(envelope.message.clone());
11994 }
11995 if output_format == ExecOutputFormat::StreamJson {
11996 emit_exec_stream_event(&ExecStreamEvent::Error {
11997 error: envelope.message,
11998 })?;
11999 } else if !json_output {
12000 eprintln!("error: {}", envelope.message);
12001 }
12002 }
12003 Event::TurnUsage {
12004 usage, duration_ms, ..
12005 } => {
12006 if output_format == ExecOutputFormat::StreamJson {
12007 turn_usage_seq = turn_usage_seq.saturating_add(1);
12008 emit_exec_stream_event(&ExecStreamEvent::TurnUsage {
12009 turn: turn_usage_seq,
12010 input_tokens: usage.input_tokens,
12011 output_tokens: usage.output_tokens,
12012 reasoning_tokens: usage.reasoning_tokens,
12013 prompt_cache_hit_tokens: usage.prompt_cache_hit_tokens,
12014 prompt_cache_miss_tokens: usage.prompt_cache_miss_tokens,
12015 prompt_cache_write_tokens: usage.prompt_cache_write_tokens,
12016 reasoning_replay_tokens: usage.reasoning_replay_tokens,
12017 duration_ms,
12018 })?;
12019 }
12020 }
12021 Event::TurnComplete {
12022 status,
12023 error,
12024 usage,
12025 tool_catalog,
12026 ..
12027 } => {
12028 let (terminal_status, terminal_error) = (status, error);
12029 #[cfg(unix)]
12030 let (mut terminal_status, mut terminal_error) = (terminal_status, terminal_error);
12031 if matches!(
12032 terminal_status,
12033 crate::core::events::TurnOutcomeStatus::Completed
12034 ) && terminal_error.is_none()
12035 {
12036 #[cfg(unix)]
12037 match exec_shell_manager.lock() {
12038 Ok(mut manager) => match manager.commit_persistent_services() {
12039 Ok(receipts) => {
12040 for receipt in &receipts {
12041 if output_format == ExecOutputFormat::StreamJson {
12042 emit_exec_stream_event(
12043 &ExecStreamEvent::ServiceReleased {
12044 task_id: receipt.task_id.clone(),
12045 pid: receipt.pid,
12046 process_group_id: receipt.process_group_id,
12047 ownership: receipt.ownership.clone(),
12048 },
12049 )?;
12050 } else if !json_output {
12051 eprintln!(
12052 "persistent service released: {} pid={} pgid={} ownership={}",
12053 receipt.task_id,
12054 receipt.pid,
12055 receipt.process_group_id,
12056 receipt.ownership
12057 );
12058 }
12059 }
12060 summary.released_services.extend(receipts);
12061 }
12062 Err(error) => {
12063 manager.abort_persistent_services();
12064 terminal_status = crate::core::events::TurnOutcomeStatus::Failed;
12065 terminal_error = Some(format!(
12066 "Persistent service ownership transfer failed: {error}"
12067 ));
12068 }
12069 },
12070 Err(_) => {
12071 terminal_status = crate::core::events::TurnOutcomeStatus::Failed;
12072 terminal_error = Some(
12073 "Persistent service ownership transfer failed: shell manager lock poisoned"
12074 .to_string(),
12075 );
12076 }
12077 }
12078 } else if let Ok(mut manager) = exec_shell_manager.lock() {
12079 manager.abort_persistent_services();
12080 }
12081 summary.status = Some(format!("{terminal_status:?}").to_lowercase());
12082 if terminal_error.is_some() {
12083 summary.error = terminal_error;
12084 }
12085 if sandbox_denied
12086 && summary.error.is_none()
12087 && matches!(
12088 terminal_status,
12089 crate::core::events::TurnOutcomeStatus::Failed
12090 )
12091 {
12092 summary.error = Some(
12093 "exec turn failed after sandbox denial; explicit sandbox elevation was not authorized"
12094 .to_string(),
12095 );
12096 }
12097 if last_error_category.is_none() {
12098 last_error_category = summary
12099 .error
12100 .as_deref()
12101 .map(crate::error_taxonomy::classify_error_message);
12102 summary.error_category =
12103 last_error_category.map(|category| category.to_string());
12104 }
12105 let termination_reason = crate::core::termination::classify_turn_termination(
12106 terminal_status,
12107 last_error_category,
12108 tool_error_seen,
12109 approval_required,
12110 );
12111 summary.termination_reason = Some(termination_reason.as_str().to_string());
12112 if !termination_reason.is_success() {
12118 codewhale_telemetry::set_exit_class(codewhale_telemetry::ExitClass::Error);
12119 }
12120 let saved_session_id = if should_persist_session && !latest_messages.is_empty() {
12121 match persist_exec_session(
12122 &latest_messages,
12123 &latest_model,
12124 PersistedProviderRoute {
12125 kind: effective_provider.as_str(),
12126 id: effective_provider_id.as_deref(),
12127 },
12128 &latest_workspace,
12129 &latest_system_prompt,
12130 latest_session_id.as_deref(),
12131 u64::from(usage.input_tokens) + u64::from(usage.output_tokens),
12132 ) {
12133 Ok(id) => {
12134 if output_format == ExecOutputFormat::Text && !json_output {
12135 eprintln!("{}", exec_saved_session_line(&id));
12136 }
12137 Some(id)
12138 }
12139 Err(err) => {
12140 if output_format == ExecOutputFormat::Text && !json_output {
12141 eprintln!("warning: failed to save exec session: {err}");
12142 }
12143 latest_session_id.clone()
12144 }
12145 }
12146 } else {
12147 latest_session_id.clone()
12148 };
12149 if output_format == ExecOutputFormat::StreamJson {
12150 if let Some(id) = saved_session_id.as_ref() {
12151 emit_exec_stream_event(&ExecStreamEvent::SessionCapture {
12152 content: exec_stream_session_ref(id),
12153 })?;
12154 }
12155 let codewhale_max_output_tokens =
12159 crate::route_budget::effective_max_output_tokens_for_route(
12160 effective_provider,
12161 &latest_model,
12162 active_route_limits,
12163 );
12164 let codewhale_max_output_tokens_source =
12165 crate::route_budget::output_ceiling_source(
12166 effective_provider,
12167 &latest_model,
12168 )
12169 .as_str();
12170 emit_exec_stream_event(&ExecStreamEvent::Metadata {
12171 meta: Box::new(ExecStreamMeta {
12172 receipt_kind: "terminal",
12173 provider: effective_provider_kind.clone(),
12174 provider_id: effective_stream_provider_id.clone(),
12175 model: latest_model.clone(),
12176 route_source: route_source.clone(),
12177 input_tokens: Some(usage.input_tokens),
12178 output_tokens: Some(usage.output_tokens),
12179 prompt_cache_hit_tokens: usage.prompt_cache_hit_tokens,
12180 prompt_cache_miss_tokens: usage.prompt_cache_miss_tokens,
12181 prompt_cache_write_tokens: usage.prompt_cache_write_tokens,
12182 reasoning_tokens: usage.reasoning_tokens,
12183 codewhale_max_output_tokens: Some(codewhale_max_output_tokens),
12184 codewhale_max_output_tokens_source: Some(
12185 codewhale_max_output_tokens_source,
12186 ),
12187 duration_ms: u64::try_from(exec_started.elapsed().as_millis())
12188 .unwrap_or(u64::MAX),
12189 retry_count: None,
12190 approval_posture: approval_posture.clone(),
12191 sandbox_posture: sandbox_posture.clone(),
12192 binary_sha256: binary_sha256.clone(),
12193 config_sha256: None,
12194 prompt_sha256: prompt_sha256.clone(),
12195 tool_catalog_sha256: tool_catalog.as_ref().and_then(|catalog| {
12196 serde_json::to_vec(catalog).ok().map(|bytes| {
12197 format!("sha256:{}", crate::hashing::sha256_hex(&bytes))
12198 })
12199 }),
12200 input_analysis: exec_stream_input_analysis(
12201 &latest_messages,
12202 latest_system_prompt.as_ref(),
12203 ),
12204 visible_final_answer_chars: summary.output.chars().count(),
12205 resume_command: saved_session_id
12206 .as_deref()
12207 .map(exec_stream_resume_hint)
12208 .unwrap_or_default(),
12209 session_id: saved_session_id
12210 .as_deref()
12211 .map(exec_stream_session_ref)
12212 .unwrap_or_default(),
12213 workspace: latest_workspace.display().to_string(),
12214 message_count: latest_messages.len(),
12215 status: summary.status.clone(),
12216 termination_reason: summary.termination_reason.clone(),
12217 error_category: summary.error_category.clone(),
12218 error: summary.error.clone(),
12219 }),
12220 })?;
12221 emit_exec_stream_event(&ExecStreamEvent::Done)?;
12222 }
12223 let _ = engine_handle.send(Op::Shutdown).await;
12224 break;
12225 }
12226 Event::SessionUpdated {
12227 session_id,
12228 messages,
12229 system_prompt,
12230 model,
12231 workspace,
12232 } => {
12233 latest_session_id = Some(session_id);
12234 latest_messages = messages;
12235 latest_system_prompt = system_prompt;
12236 latest_model = model;
12237 latest_workspace = workspace;
12238 }
12239 Event::Status { message }
12242 if output_format == ExecOutputFormat::Text
12243 && !json_output
12244 && message.contains("Maximum model steps") =>
12245 {
12246 eprintln!("{message}");
12247 }
12248 _ => {}
12249 }
12250 }
12251
12252 if summary.status.is_none() {
12253 if let Ok(mut manager) = exec_shell_manager.lock() {
12254 manager.abort_persistent_services();
12255 }
12256 let error = summary.error.clone().unwrap_or_else(|| {
12257 "Engine event channel closed before a terminal turn receipt".to_string()
12258 });
12259 let category = last_error_category
12260 .unwrap_or_else(|| crate::error_taxonomy::classify_error_message(&error));
12261 let termination_reason = crate::core::termination::classify_turn_termination(
12262 crate::core::events::TurnOutcomeStatus::Failed,
12263 Some(category),
12264 tool_error_seen,
12265 approval_required,
12266 );
12267 summary.status = Some("failed".to_string());
12268 summary.error_category = Some(category.to_string());
12269 summary.termination_reason = Some(termination_reason.as_str().to_string());
12270 summary.error = Some(error.clone());
12271 if output_format == ExecOutputFormat::StreamJson {
12272 emit_exec_stream_event(&ExecStreamEvent::Error { error })?;
12273 }
12274 }
12275
12276 if json_output {
12277 println!("{}", serde_json::to_string_pretty(&summary)?);
12278 }
12279
12280 if let Some(error) = summary.error.as_ref()
12281 && !error.trim().is_empty()
12282 {
12283 let exit_code = exec_failure_exit_code(summary.error_category.as_deref());
12289 if exit_code != 1 {
12290 eprintln!("Error: exec turn failed: {error}");
12291 let _ = io::stdout().flush();
12292 std::process::exit(exit_code);
12293 }
12294 bail!("exec turn failed: {error}");
12295 }
12296
12297 if matches!(
12298 summary.status.as_deref(),
12299 Some("failed" | "canceled" | "interrupted")
12300 ) {
12301 let status = summary.status.as_deref().unwrap_or("unknown");
12302 bail!("exec turn ended with status {status}");
12303 }
12304
12305 Ok(())
12306}
12307
12308#[cfg(test)]
12309mod serve_bind_host_tests {
12310 use super::*;
12311
12312 #[test]
12313 fn http_defaults_to_loopback() {
12314 assert_eq!(
12315 resolve_serve_bind_host(false, None),
12316 ServeBindHost {
12317 host: "127.0.0.1".to_string(),
12318 mobile_rebound_to_lan: false,
12319 }
12320 );
12321 }
12322
12323 #[test]
12324 fn mobile_default_rebinds_to_lan_with_warning_flag() {
12325 assert_eq!(
12326 resolve_serve_bind_host(true, None),
12327 ServeBindHost {
12328 host: "0.0.0.0".to_string(),
12329 mobile_rebound_to_lan: true,
12330 }
12331 );
12332 }
12333
12334 #[test]
12335 fn mobile_respects_explicit_loopback_host() {
12336 assert_eq!(
12337 resolve_serve_bind_host(true, Some("127.0.0.1".to_string())),
12338 ServeBindHost {
12339 host: "127.0.0.1".to_string(),
12340 mobile_rebound_to_lan: false,
12341 }
12342 );
12343 }
12344
12345 #[test]
12346 fn http_and_mobile_are_mutually_exclusive() {
12347 let err = validate_serve_mode_selection(false, true, true, false, false).unwrap_err();
12348 assert!(
12349 err.to_string()
12350 .contains("--http and --mobile are mutually exclusive")
12351 );
12352 }
12353
12354 #[test]
12355 fn web_is_a_distinct_loopback_runtime_mode() {
12356 assert!(validate_serve_mode_selection(false, false, false, true, false).unwrap());
12357 let err = validate_serve_mode_selection(false, true, false, true, false).unwrap_err();
12358 assert!(err.to_string().contains("--web is mutually exclusive"));
12359 assert_eq!(
12360 resolve_serve_bind_host(false, None),
12361 ServeBindHost {
12362 host: "127.0.0.1".to_string(),
12363 mobile_rebound_to_lan: false,
12364 }
12365 );
12366 }
12367}
12368
12369#[cfg(test)]
12370#[path = "tests/exec_exit_semantics.rs"]
12371mod exec_exit_semantics_tests;
12372#[cfg(test)]
12373mod doctor_legacy_state_tests {
12374 use super::*;
12375 use std::env;
12376 use std::ffi::OsString;
12377 use std::fs;
12378 use tempfile::TempDir;
12379
12380 struct EnvVarRestore {
12381 key: &'static str,
12382 previous: Option<OsString>,
12383 }
12384
12385 impl EnvVarRestore {
12386 fn set(key: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
12387 let previous = env::var_os(key);
12388 unsafe {
12389 env::set_var(key, value);
12390 }
12391 Self { key, previous }
12392 }
12393 }
12394
12395 impl Drop for EnvVarRestore {
12396 fn drop(&mut self) {
12397 unsafe {
12398 match &self.previous {
12399 Some(value) => env::set_var(self.key, value),
12400 None => env::remove_var(self.key),
12401 }
12402 }
12403 }
12404 }
12405
12406 fn roots(tmp: &TempDir) -> (PathBuf, PathBuf) {
12407 (tmp.path().join(".codewhale"), tmp.path().join(".deepseek"))
12408 }
12409
12410 fn entry<'a>(report: &'a [DoctorLegacyStateEntry], name: &str) -> &'a DoctorLegacyStateEntry {
12411 report
12412 .iter()
12413 .find(|entry| entry.name == name)
12414 .expect("legacy state entry should exist")
12415 }
12416
12417 #[test]
12418 fn doctor_legacy_state_report_marks_unmigrated_legacy_entries() {
12419 let tmp = TempDir::new().expect("tempdir");
12420 let (primary_root, legacy_root) = roots(&tmp);
12421 fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
12422 fs::create_dir_all(legacy_root.join("tasks")).expect("legacy tasks");
12423 fs::create_dir_all(&primary_root).expect("primary root");
12424 fs::write(legacy_root.join("config.toml"), "api_key = 'old'").expect("legacy config");
12425
12426 let report = doctor_legacy_state_report(&primary_root, &legacy_root);
12427 let session_recovery = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12428
12429 assert_eq!(
12430 entry(&report, "sessions").status,
12431 DoctorLegacyStateStatus::LegacyOnly
12432 );
12433 assert_eq!(
12434 entry(&report, "config.toml").status,
12435 DoctorLegacyStateStatus::LegacyOnly
12436 );
12437 assert_eq!(
12438 entry(&report, "skills").status,
12439 DoctorLegacyStateStatus::Absent
12440 );
12441
12442 let json =
12443 doctor_legacy_state_json(&primary_root, &legacy_root, &report, &session_recovery);
12444 assert_eq!(json["needs_attention"], true);
12445 assert_eq!(json["legacy_only_count"], 3);
12446 assert_eq!(json["dual_present_count"], 0);
12447 }
12448
12449 #[test]
12450 fn doctor_legacy_state_report_marks_dual_present_entries() {
12451 let tmp = TempDir::new().expect("tempdir");
12452 let (primary_root, legacy_root) = roots(&tmp);
12453 fs::create_dir_all(primary_root.join("sessions")).expect("primary sessions");
12454 fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
12455 fs::write(primary_root.join("mcp.json"), "{}").expect("primary mcp");
12456 fs::write(legacy_root.join("mcp.json"), "{}").expect("legacy mcp");
12457
12458 let report = doctor_legacy_state_report(&primary_root, &legacy_root);
12459 let session_recovery = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12460
12461 assert_eq!(
12462 entry(&report, "sessions").status,
12463 DoctorLegacyStateStatus::Both
12464 );
12465 assert_eq!(
12466 entry(&report, "mcp.json").status,
12467 DoctorLegacyStateStatus::Both
12468 );
12469
12470 let json =
12471 doctor_legacy_state_json(&primary_root, &legacy_root, &report, &session_recovery);
12472 assert_eq!(json["needs_attention"], true);
12473 assert_eq!(json["legacy_only_count"], 0);
12474 assert_eq!(json["dual_present_count"], 2);
12475 }
12476
12477 #[test]
12478 fn doctor_legacy_state_report_is_clear_when_only_primary_exists() {
12479 let tmp = TempDir::new().expect("tempdir");
12480 let (primary_root, legacy_root) = roots(&tmp);
12481 fs::create_dir_all(primary_root.join("sessions")).expect("primary sessions");
12482 fs::write(primary_root.join("settings.toml"), "default_mode = 'ask'")
12483 .expect("primary settings");
12484
12485 let report = doctor_legacy_state_report(&primary_root, &legacy_root);
12486 let session_recovery = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12487
12488 assert_eq!(
12489 entry(&report, "sessions").status,
12490 DoctorLegacyStateStatus::PrimaryOnly
12491 );
12492 assert!(!report.iter().any(legacy_state_needs_attention));
12493
12494 let json =
12495 doctor_legacy_state_json(&primary_root, &legacy_root, &report, &session_recovery);
12496 assert_eq!(json["needs_attention"], false);
12497 assert_eq!(json["legacy_only_count"], 0);
12498 assert_eq!(json["dual_present_count"], 0);
12499 }
12500
12501 #[test]
12502 fn doctor_legacy_state_report_is_clear_when_neither_root_exists() {
12503 let tmp = TempDir::new().expect("tempdir");
12504 let (primary_root, legacy_root) = roots(&tmp);
12505
12506 let report = doctor_legacy_state_report(&primary_root, &legacy_root);
12507 let session_recovery = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12508
12509 assert!(
12510 report
12511 .iter()
12512 .all(|entry| entry.status == DoctorLegacyStateStatus::Absent)
12513 );
12514 assert!(!report.iter().any(legacy_state_needs_attention));
12515
12516 let json =
12517 doctor_legacy_state_json(&primary_root, &legacy_root, &report, &session_recovery);
12518 assert_eq!(json["needs_attention"], false);
12519 assert_eq!(json["legacy_only_count"], 0);
12520 assert_eq!(json["dual_present_count"], 0);
12521 }
12522
12523 #[test]
12524 fn doctor_reports_incomplete_session_migration_without_mutating_files() {
12525 let tmp = TempDir::new().expect("tempdir");
12526 let (primary_root, legacy_root) = roots(&tmp);
12527 let primary_sessions = primary_root.join("sessions");
12528 let legacy_sessions = legacy_root.join("sessions");
12529 fs::create_dir_all(&primary_sessions).expect("primary sessions");
12530 fs::create_dir_all(legacy_sessions.join("checkpoints")).expect("legacy checkpoints");
12531 fs::write(primary_sessions.join("already-there.json"), b"primary")
12532 .expect("primary session");
12533 fs::write(legacy_sessions.join("already-there.json"), b"legacy")
12534 .expect("legacy matching session");
12535 fs::write(
12536 legacy_sessions.join("recover-me.json"),
12537 b"not parsed by doctor",
12538 )
12539 .expect("legacy recoverable session");
12540 fs::write(
12541 legacy_sessions.join("checkpoints").join("latest.json"),
12542 b"checkpoint not inspected",
12543 )
12544 .expect("legacy checkpoint");
12545
12546 let legacy_before = fs::read(legacy_sessions.join("recover-me.json"))
12547 .expect("read legacy fixture before diagnostic");
12548 let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12549
12550 assert_eq!(
12551 report.status,
12552 DoctorSessionRecoveryStatus::MigrationIncomplete
12553 );
12554 assert_eq!(report.legacy_session_file_count, 2);
12555 assert_eq!(report.already_present_file_count, 1);
12556 assert_eq!(report.recoverable_file_count, 1);
12557 assert_eq!(report.recoverable.len(), 1);
12558 assert_eq!(report.recoverable[0].name, PathBuf::from("recover-me.json"));
12559 assert!(
12560 !primary_sessions.join("recover-me.json").exists(),
12561 "doctor must not copy a recoverable session"
12562 );
12563 assert_eq!(
12564 fs::read(legacy_sessions.join("recover-me.json"))
12565 .expect("legacy file remains after diagnostic"),
12566 legacy_before,
12567 "doctor must not rewrite or delete the legacy source"
12568 );
12569
12570 let json = doctor_session_recovery_json(&report);
12571 assert_eq!(json["needs_attention"], true);
12572 assert_eq!(json["read_only"], true);
12573 assert_eq!(json["chat_contents_read"], false);
12574 assert_eq!(json["checkpoint_internals_scanned"], false);
12575 assert_eq!(json["recoverable_file_count"], 1);
12576 assert_eq!(json["recovery_command"], "codewhale sessions");
12577 assert_eq!(json["recoverable_files"][0]["name"], "recover-me.json");
12578 let serialized = json.to_string();
12579 assert!(
12580 !serialized.contains("not parsed by doctor"),
12581 "the report must not expose session contents"
12582 );
12583 assert!(
12584 !serialized.contains("checkpoint not inspected"),
12585 "the report must not expose checkpoint contents"
12586 );
12587 }
12588
12589 #[test]
12590 fn doctor_treats_preserved_legacy_sessions_as_complete_by_filename() {
12591 let tmp = TempDir::new().expect("tempdir");
12592 let (primary_root, legacy_root) = roots(&tmp);
12593 let primary_sessions = primary_root.join("sessions");
12594 let legacy_sessions = legacy_root.join("sessions");
12595 fs::create_dir_all(&primary_sessions).expect("primary sessions");
12596 fs::create_dir_all(&legacy_sessions).expect("legacy sessions");
12597 fs::write(primary_sessions.join("same-name.json"), b"primary").expect("primary session");
12598 fs::write(legacy_sessions.join("same-name.json"), b"legacy").expect("legacy session");
12599
12600 let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12601
12602 assert_eq!(
12603 report.status,
12604 DoctorSessionRecoveryStatus::MigrationComplete
12605 );
12606 assert!(!report.needs_attention());
12607 assert_eq!(report.recoverable_file_count, 0);
12608 assert!(report.recoverable.is_empty());
12609 assert_eq!(report.already_present_file_count, 1);
12610 let json = doctor_session_recovery_json(&report);
12611 assert_eq!(json["session_descriptors_compared"], false);
12612 assert_eq!(
12613 json["counterpart_check"],
12614 "top_level_filename_and_regular_file_only"
12615 );
12616 }
12617
12618 #[test]
12619 fn doctor_bounds_recoverable_session_filename_samples() {
12620 let tmp = TempDir::new().expect("tempdir");
12621 let (primary_root, legacy_root) = roots(&tmp);
12622 let legacy_sessions = legacy_root.join("sessions");
12623 fs::create_dir_all(&legacy_sessions).expect("legacy sessions");
12624 for index in 0..DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT {
12625 fs::write(
12626 legacy_sessions.join(format!("late-{index:03}.json")),
12627 b"fixture",
12628 )
12629 .expect("legacy session fixture");
12630 }
12631 fs::write(legacy_sessions.join("early-000.json"), b"fixture")
12632 .expect("earliest legacy session fixture");
12633 fs::write(legacy_sessions.join("early-001.json"), b"fixture")
12634 .expect("second earliest legacy session fixture");
12635 let total = DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT + 2;
12636
12637 let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12638 let json = doctor_session_recovery_json(&report);
12639
12640 assert_eq!(report.recoverable_file_count, total);
12641 assert_eq!(
12642 report.recoverable.len(),
12643 DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT
12644 );
12645 assert_eq!(
12646 json["recoverable_files"].as_array().map(Vec::len),
12647 Some(DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT)
12648 );
12649 assert_eq!(
12650 report.recoverable.first().map(|entry| entry.name.as_path()),
12651 Some(Path::new("early-000.json")),
12652 "the bounded sample must not depend on read_dir order"
12653 );
12654 assert_eq!(
12655 report.recoverable.last().map(|entry| entry.name.as_path()),
12656 Some(Path::new("late-097.json")),
12657 "the bounded sample must retain the lexical prefix"
12658 );
12659 assert_eq!(json["recoverable_files_truncated"], true);
12660 }
12661
12662 #[test]
12663 fn doctor_session_recovery_fails_closed_on_an_unreadable_path_shape() {
12664 let tmp = TempDir::new().expect("tempdir");
12665 let (primary_root, legacy_root) = roots(&tmp);
12666 fs::create_dir_all(&legacy_root).expect("legacy root");
12667 fs::write(legacy_root.join("sessions"), b"not a directory")
12668 .expect("invalid legacy sessions path");
12669
12670 let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12671
12672 assert_eq!(report.status, DoctorSessionRecoveryStatus::ScanFailed);
12673 assert!(report.needs_attention());
12674 assert!(report.error.as_deref().is_some_and(|error| {
12675 error.contains("legacy sessions root") && error.contains("not a directory")
12676 }));
12677 }
12678
12679 #[test]
12680 fn doctor_session_recovery_rejects_a_non_directory_legacy_state_root() {
12681 let tmp = TempDir::new().expect("tempdir");
12682 let (primary_root, legacy_root) = roots(&tmp);
12683 fs::write(&legacy_root, b"not a state directory").expect("invalid legacy root");
12684
12685 let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12686
12687 assert_eq!(report.status, DoctorSessionRecoveryStatus::ScanFailed);
12688 assert!(report.error.as_deref().is_some_and(|error| {
12689 error.contains("legacy state root") && error.contains("not a directory")
12690 }));
12691 }
12692
12693 #[test]
12694 fn doctor_session_recovery_rejects_a_non_directory_primary_state_root() {
12695 let tmp = TempDir::new().expect("tempdir");
12696 let (primary_root, legacy_root) = roots(&tmp);
12697 fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
12698 fs::write(&primary_root, b"not a state directory").expect("invalid primary root");
12699
12700 let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12701
12702 assert_eq!(report.status, DoctorSessionRecoveryStatus::ScanFailed);
12703 assert!(report.error.as_deref().is_some_and(|error| {
12704 error.contains("primary state root") && error.contains("not a directory")
12705 }));
12706 }
12707
12708 #[test]
12709 fn doctor_session_recovery_rejects_a_non_directory_primary_sessions_root() {
12710 let tmp = TempDir::new().expect("tempdir");
12711 let (primary_root, legacy_root) = roots(&tmp);
12712 fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
12713 fs::create_dir_all(&primary_root).expect("primary root");
12714 fs::write(primary_root.join("sessions"), b"not a sessions directory")
12715 .expect("invalid primary sessions path");
12716
12717 let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12718
12719 assert_eq!(report.status, DoctorSessionRecoveryStatus::ScanFailed);
12720 assert!(report.error.as_deref().is_some_and(|error| {
12721 error.contains("primary sessions root") && error.contains("not a directory")
12722 }));
12723 }
12724
12725 #[cfg(unix)]
12726 #[test]
12727 fn doctor_session_recovery_rejects_a_symlinked_legacy_sessions_root() {
12728 use std::os::unix::fs::symlink;
12729
12730 let tmp = TempDir::new().expect("tempdir");
12731 let (primary_root, legacy_root) = roots(&tmp);
12732 let external_sessions = tmp.path().join("external-sessions");
12733 fs::create_dir_all(&external_sessions).expect("external sessions");
12734 fs::write(
12735 external_sessions.join("must-not-be-enumerated.json"),
12736 b"session contents must stay unread",
12737 )
12738 .expect("external session fixture");
12739 fs::create_dir_all(&legacy_root).expect("legacy root");
12740 symlink(&external_sessions, legacy_root.join("sessions"))
12741 .expect("symlinked legacy sessions root");
12742
12743 let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12744
12745 assert_eq!(report.status, DoctorSessionRecoveryStatus::ScanFailed);
12746 assert!(report.needs_attention());
12747 assert_eq!(report.legacy_session_file_count, 0);
12748 assert!(report.recoverable.is_empty());
12749 assert!(
12750 report
12751 .error
12752 .as_deref()
12753 .is_some_and(|error| error.contains("legacy sessions root")
12754 && error.contains("path is a symlink"))
12755 );
12756 }
12757
12758 #[cfg(unix)]
12759 #[test]
12760 fn doctor_session_recovery_rejects_symlinked_primary_root_and_sessions_root() {
12761 use std::os::unix::fs::symlink;
12762
12763 let tmp = TempDir::new().expect("tempdir");
12764 let (primary_root, legacy_root) = roots(&tmp);
12765 let external_primary = tmp.path().join("external-primary");
12766 fs::create_dir_all(external_primary.join("sessions")).expect("external primary");
12767 fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
12768 symlink(&external_primary, &primary_root).expect("symlinked primary root");
12769
12770 let root_report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12771 assert_eq!(root_report.status, DoctorSessionRecoveryStatus::ScanFailed);
12772 assert!(root_report.error.as_deref().is_some_and(|error| {
12773 error.contains("primary state root") && error.contains("path is a symlink")
12774 }));
12775
12776 fs::remove_file(&primary_root).expect("remove primary root symlink");
12777 fs::create_dir_all(&primary_root).expect("primary root");
12778 symlink(&external_primary, primary_root.join("sessions"))
12779 .expect("symlinked primary sessions root");
12780
12781 let sessions_report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12782 assert_eq!(
12783 sessions_report.status,
12784 DoctorSessionRecoveryStatus::ScanFailed
12785 );
12786 assert!(sessions_report.error.as_deref().is_some_and(|error| {
12787 error.contains("primary sessions root") && error.contains("path is a symlink")
12788 }));
12789 }
12790
12791 #[test]
12792 fn explicit_codewhale_home_skips_session_recovery_scan() {
12793 let tmp = TempDir::new().expect("tempdir");
12794 let (primary_root, legacy_root) = roots(&tmp);
12795 fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
12796 fs::write(legacy_root.join("sessions").join("ambient.json"), b"legacy")
12797 .expect("legacy session");
12798
12799 let report = doctor_session_recovery_report(&primary_root, &legacy_root, true);
12800
12801 assert_eq!(report.status, DoctorSessionRecoveryStatus::Isolated);
12802 assert!(report.codewhale_home_is_explicit);
12803 assert_eq!(report.legacy_session_file_count, 0);
12804 assert_eq!(report.recoverable_file_count, 0);
12805 assert!(report.recoverable.is_empty());
12806 assert!(!report.needs_attention());
12807 }
12808
12809 #[test]
12810 fn doctor_state_roots_ignore_ambient_legacy_home_when_codewhale_home_is_explicit() {
12811 let _env_lock = crate::test_support::lock_test_env();
12812 let tmp = TempDir::new().expect("tempdir");
12813 let explicit_home = tmp.path().join("isolated-codewhale");
12814 let ambient_legacy = tmp.path().join(".deepseek");
12815 fs::create_dir_all(&ambient_legacy).expect("ambient legacy root");
12816 fs::write(
12817 ambient_legacy.join("config.toml"),
12818 "provider = 'deepseek'\n",
12819 )
12820 .expect("ambient legacy config");
12821 let _home = EnvVarRestore::set("HOME", tmp.path());
12822 let _codewhale_home = EnvVarRestore::set("CODEWHALE_HOME", &explicit_home);
12823
12824 let (primary_root, legacy_root) = doctor_state_roots();
12825 let report = doctor_legacy_state_report(&primary_root, &legacy_root);
12826 let session_recovery = doctor_session_recovery_report(
12827 &primary_root,
12828 &legacy_root,
12829 codewhale_config::codewhale_home_is_explicit(),
12830 );
12831
12832 assert_eq!(primary_root, explicit_home);
12833 assert_eq!(
12834 legacy_root,
12835 primary_root.join(codewhale_config::LEGACY_APP_DIR)
12836 );
12837 assert!(
12838 report
12839 .iter()
12840 .all(|entry| entry.status == DoctorLegacyStateStatus::Absent),
12841 "doctor must not report ambient legacy state when CODEWHALE_HOME is explicit"
12842 );
12843 assert!(!report.iter().any(legacy_state_needs_attention));
12844 assert_eq!(
12845 session_recovery.status,
12846 DoctorSessionRecoveryStatus::Isolated
12847 );
12848 assert!(session_recovery.recoverable.is_empty());
12849 }
12850}
12851
12852#[cfg(test)]
12853mod doctor_setup_state_tests {
12854 use super::*;
12855 use std::fs;
12856 use tempfile::TempDir;
12857
12858 fn prepare_env(tmp: &TempDir) -> (crate::test_support::EnvVarGuard, PathBuf) {
12859 let codewhale_home = tmp.path().join(".codewhale");
12860 fs::create_dir_all(&codewhale_home).expect("codewhale home");
12861 (
12862 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", codewhale_home.as_os_str()),
12863 codewhale_home,
12864 )
12865 }
12866
12867 fn provider_step(report: &serde_json::Value) -> &serde_json::Value {
12868 report["steps"]
12869 .as_array()
12870 .expect("steps array")
12871 .iter()
12872 .find(|step| step["step"] == "provider_model")
12873 .expect("provider/model step")
12874 }
12875
12876 #[test]
12877 fn doctor_setup_consistency_flags_missing_user_constitution() {
12878 let _guard = crate::test_support::lock_test_env();
12879 let tmp = TempDir::new().expect("tempdir");
12880 let (_home_guard, _codewhale_home) = prepare_env(&tmp);
12881 let _key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
12882 let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
12883 let workspace = tmp.path().join("workspace");
12884 fs::create_dir_all(&workspace).expect("workspace");
12885
12886 let state = codewhale_config::SetupState {
12887 constitution_source: codewhale_config::ConstitutionSource::UserGlobal,
12888 ..Default::default()
12889 };
12890 state.save().expect("persist setup state");
12891
12892 let report = doctor_setup_report_json(&Config::default(), &workspace);
12893
12894 assert_eq!(report["source"], "persisted");
12895 assert_eq!(report["consistency"]["status"], "inconsistent");
12896 let issues = report["consistency"]["issues"].to_string();
12897 assert!(
12898 issues.contains("setup_state_points_at_missing_user_constitution"),
12899 "{issues}"
12900 );
12901 }
12902
12903 #[test]
12904 fn doctor_setup_consistency_flags_stale_temp_files() {
12905 let _guard = crate::test_support::lock_test_env();
12906 let tmp = TempDir::new().expect("tempdir");
12907 let (_home_guard, codewhale_home) = prepare_env(&tmp);
12908 let _key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
12909 let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
12910 let workspace = tmp.path().join("workspace");
12911 fs::create_dir_all(&workspace).expect("workspace");
12912 fs::write(codewhale_home.join(".tmpAbC123"), b"orphaned atomic write")
12913 .expect("stale temp file");
12914
12915 let report = doctor_setup_report_json(&Config::default(), &workspace);
12916
12917 assert_eq!(report["consistency"]["status"], "inconsistent");
12918 let issues = report["consistency"]["issues"].to_string();
12919 assert!(
12920 issues.contains("stale_setup_temp_files_in_codewhale_home"),
12921 "{issues}"
12922 );
12923 }
12924
12925 #[test]
12926 fn doctor_setup_consistency_reports_consistent_for_clean_home() {
12927 let _guard = crate::test_support::lock_test_env();
12928 let tmp = TempDir::new().expect("tempdir");
12929 let (_home_guard, _codewhale_home) = prepare_env(&tmp);
12930 let _key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
12931 let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
12932 let workspace = tmp.path().join("workspace");
12933 fs::create_dir_all(&workspace).expect("workspace");
12934
12935 let report = doctor_setup_report_json(&Config::default(), &workspace);
12936
12937 assert_eq!(report["consistency"]["status"], "consistent");
12938 assert_eq!(
12939 report["consistency"]["issues"]
12940 .as_array()
12941 .map(Vec::len)
12942 .unwrap_or_default(),
12943 0
12944 );
12945 }
12946
12947 #[test]
12948 fn doctor_setup_report_json_derives_state_without_sidecar() {
12949 let _guard = crate::test_support::lock_test_env();
12950 let tmp = TempDir::new().expect("tempdir");
12951 let (_home_guard, _codewhale_home) = prepare_env(&tmp);
12952 let _key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
12953 let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
12954 let workspace = tmp.path().join("workspace");
12955 fs::create_dir_all(&workspace).expect("workspace");
12956
12957 let report = doctor_setup_report_json(&Config::default(), &workspace);
12958
12959 assert_eq!(report["source"], "derived");
12960 assert_eq!(report["inherited"], true);
12961 assert_eq!(report["next_actions"]["constitution"], "/constitution");
12962 assert_eq!(report["next_actions"]["setup_report"], "/setup report");
12963 assert_eq!(
12964 report["next_actions"]["provider_model"],
12965 "/setup provider, /provider setup <name>, or /model"
12966 );
12967 assert_eq!(report["next_actions"]["runtime_posture"], "/config");
12968 assert_eq!(
12969 report["next_actions"]["operate_fleet"],
12970 "/setup fleet (readiness), /fleet setup (explicit profile authoring)"
12971 );
12972 assert_eq!(report["next_actions"]["hotbar"], "/setup hotbar");
12973 assert_eq!(report["next_actions"]["tools_mcp"], "/setup tools");
12974 assert_eq!(report["next_actions"]["remote_runtime"], "/setup remote");
12975 assert_eq!(report["next_actions"]["persistence"], "/setup persistence");
12976 assert_eq!(
12977 report["checkpoint_version"],
12978 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION
12979 );
12980 assert_eq!(report["update_ready"], false);
12981 assert_eq!(report["operate_ready"], false);
12982 assert_eq!(
12983 report["operate_fleet"]["concurrency"]["plan_limit_probed"],
12984 false
12985 );
12986 assert_eq!(
12987 report["operate_fleet"]["roster"]["readiness_rule"],
12988 "built-in starter roster or custom roster"
12989 );
12990 assert_eq!(report["provider_model"]["provider"]["id"], "deepseek");
12991 assert_eq!(report["provider_model"]["provider"]["display"], "DeepSeek");
12992 assert_eq!(
12993 report["provider_model"]["model"]["resolved"],
12994 crate::config::DEFAULT_TEXT_MODEL
12995 );
12996 assert_eq!(
12997 report["provider_model"]["auth"]["source"],
12998 "secret_store_unprobed"
12999 );
13000 assert_eq!(
13001 report["provider_model"]["auth"]["availability"],
13002 "not_probed"
13003 );
13004 assert_eq!(
13005 report["provider_model"]["auth"]["credential_url"],
13006 "https://platform.deepseek.com"
13007 );
13008 assert_eq!(
13009 report["provider_model"]["auth"]["credential_mode"],
13010 "api_key"
13011 );
13012 assert_eq!(
13013 report["provider_model"]["auth"]["env_vars"][0],
13014 "DEEPSEEK_API_KEY"
13015 );
13016 assert_eq!(report["provider_model"]["health"]["live_validation"], false);
13017 assert_eq!(report["constitution"]["source"], "bundled");
13018 assert_eq!(report["constitution"]["autonomy_preference"], "unspecified");
13019 assert_eq!(report["runtime_posture"]["source"], "unset");
13020 assert_eq!(report["runtime_posture"]["default_mode"]["value"], "agent");
13021 assert_eq!(
13022 report["runtime_posture"]["approval_policy"]["value"],
13023 "on-request"
13024 );
13025 assert_eq!(report["runtime_posture"]["allow_shell"]["value"], true);
13026 assert_eq!(
13027 report["runtime_posture"]["sandbox_mode"]["value"],
13028 "mode-derived"
13029 );
13030 assert_eq!(
13031 report["runtime_posture"]["network_default"]["value"],
13032 "prompt"
13033 );
13034 assert_eq!(provider_step(&report)["status"], "needs_action");
13035 }
13036
13037 #[test]
13038 fn doctor_setup_provider_model_json_covers_cn_codex_and_local_matrix() {
13039 let _guard = crate::test_support::lock_test_env();
13040 let tmp = TempDir::new().expect("tempdir");
13041 let (_home_guard, _codewhale_home) = prepare_env(&tmp);
13042 let _home = crate::test_support::EnvVarGuard::set("HOME", tmp.path());
13043 let _userprofile = crate::test_support::EnvVarGuard::set("USERPROFILE", tmp.path());
13044 let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
13045 let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
13046 let _codex_key = crate::test_support::EnvVarGuard::remove("OPENAI_CODEX_ACCESS_TOKEN");
13047 let _codex_legacy_key = crate::test_support::EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
13048 let codex_auth_path = tmp.path().join("external-codex-auth.json");
13049 let codex_auth_raw = serde_json::json!({
13050 "tokens": {
13051 "access_token": crate::test_support::future_test_jwt("doctor"),
13052 "account_id": "acct-doctor-read-only",
13053 "refresh_token": "must-never-be-used",
13054 "unknown": {"preserve": true}
13055 }
13056 })
13057 .to_string();
13058 fs::write(&codex_auth_path, &codex_auth_raw).expect("Codex auth trap fixture");
13059 let _codex_auth =
13060 crate::test_support::EnvVarGuard::set("OPENAI_CODEX_AUTH_FILE", &codex_auth_path);
13061 let workspace = tmp.path().join("workspace");
13062 fs::create_dir_all(&workspace).expect("workspace");
13063
13064 let cn_config = Config {
13065 provider: Some("deepseek-cn".to_string()),
13066 ..Config::default()
13067 };
13068 let cn_report = doctor_setup_report_json(&cn_config, &workspace);
13069 assert_eq!(cn_report["provider_model"]["provider"]["id"], "deepseek-cn");
13070 assert_eq!(
13071 cn_report["provider_model"]["provider"]["display"],
13072 "DeepSeek (legacy alias)"
13073 );
13074 assert_eq!(
13075 cn_report["provider_model"]["auth"]["env_vars"][0],
13076 "DEEPSEEK_API_KEY"
13077 );
13078 assert_eq!(
13079 cn_report["provider_model"]["auth"]["credential_url"],
13080 "https://platform.deepseek.com"
13081 );
13082 assert_eq!(cn_report["provider_model"]["auth"]["oauth_only"], false);
13083 assert_eq!(
13084 cn_report["provider_model"]["health"]["live_validation"],
13085 false
13086 );
13087
13088 let codex_config = Config {
13089 provider: Some("openai-codex".to_string()),
13090 ..Config::default()
13091 };
13092 crate::external_credentials::reset_side_effect_trap();
13093 let codex_report = doctor_setup_report_json(&codex_config, &workspace);
13094 assert_eq!(
13095 codex_report["provider_model"]["provider"]["id"],
13096 crate::config::ApiProvider::OpenaiCodex.as_str()
13097 );
13098 assert!(codex_report["provider_model"]["auth"]["credential_url"].is_null());
13099 assert_eq!(
13100 codex_report["provider_model"]["auth"]["credential_mode"],
13101 "oauth"
13102 );
13103 assert_eq!(codex_report["provider_model"]["auth"]["oauth_only"], true);
13104 assert_eq!(
13105 codex_report["provider_model"]["health"]["next_action"],
13106 "/setup provider or /provider setup <name>"
13107 );
13108 assert_eq!(
13109 crate::external_credentials::side_effect_trap_counts(),
13110 (0, 0),
13111 "doctor must not stat or read external credentials without consent"
13112 );
13113
13114 let mut consent = codewhale_config::ExternalCredentialConsentToml::read_only(
13115 codewhale_config::ProviderKind::OpenaiCodex,
13116 codewhale_config::ExternalCredentialSource::CodexCli,
13117 codex_auth_path.clone(),
13118 );
13119 let codex_read_only = Config {
13120 provider: Some("openai-codex".to_string()),
13121 providers: Some(crate::config::ProvidersConfig {
13122 openai_codex: crate::config::ProviderConfig {
13123 auth_mode: Some("oauth".to_string()),
13124 external_credentials: Some(consent.clone()),
13125 ..Default::default()
13126 },
13127 ..Default::default()
13128 }),
13129 ..Config::default()
13130 };
13131 let changed_ambient_path = tmp.path().join("new-ambient-codex-auth.json");
13132 let _changed_codex_auth =
13133 crate::test_support::EnvVarGuard::set("OPENAI_CODEX_AUTH_FILE", &changed_ambient_path);
13134 crate::external_credentials::reset_side_effect_trap();
13135 let codex_read_only_report = doctor_setup_report_json(&codex_read_only, &workspace);
13136 assert_eq!(
13137 codex_read_only_report["provider_model"]["auth"]["present_or_local"],
13138 false
13139 );
13140 assert_eq!(
13141 codex_read_only_report["provider_model"]["auth"]["source"],
13142 "external_consent"
13143 );
13144 let status_json = doctor_external_credential_consent_json(&codex_read_only);
13145 let codex_status = status_json
13146 .as_array()
13147 .and_then(|rows| rows.first())
13148 .expect("Codex structural status");
13149 assert_eq!(codex_status["access"], "read_only");
13150 assert_eq!(codex_status["provider"], "openai-codex");
13151 assert_eq!(codex_status["source"], "codex_cli");
13152 assert_eq!(codex_status["route_state"], "active");
13153 assert_eq!(codex_status["ambient_path_changed"], true);
13154 assert!(
13155 codex_status["ambient_path_warning"]
13156 .as_str()
13157 .is_some_and(|warning| warning.contains("remains pinned"))
13158 );
13159 assert_eq!(
13160 codex_status["revoke_command"],
13161 "codewhale auth external-revoke --provider openai-codex"
13162 );
13163 let human = doctor_external_credential_consent_lines(&codex_read_only).join("\n");
13164 assert!(human.contains("path="), "{human}");
13165 assert!(human.contains("version=1"), "{human}");
13166 assert!(human.contains("no refresh, identity-provider or discovery requests"));
13167 assert!(human.contains("normal requests to the explicitly selected provider"));
13168 assert!(human.contains("consent remains pinned"), "{human}");
13169 assert!(
13170 human.contains(&codewhale_config::quote_os_path(&codex_auth_path)),
13171 "{human}"
13172 );
13173 assert!(!human.contains(&changed_ambient_path.display().to_string()));
13174 assert_eq!(
13175 crate::external_credentials::complete_side_effect_trap_counts(),
13176 (0, 0, 0, 0, 0),
13177 "doctor consent status is structural and must not inspect the file"
13178 );
13179 assert_eq!(
13180 fs::read_to_string(&codex_auth_path).expect("unchanged Codex auth fixture"),
13181 codex_auth_raw
13182 );
13183
13184 consent.access = codewhale_config::ExternalCredentialAccess::Managed;
13185 let codex_managed = Config {
13186 provider: Some("openai-codex".to_string()),
13187 providers: Some(crate::config::ProvidersConfig {
13188 openai_codex: crate::config::ProviderConfig {
13189 auth_mode: Some("oauth".to_string()),
13190 external_credentials: Some(consent),
13191 ..Default::default()
13192 },
13193 ..Default::default()
13194 }),
13195 ..Config::default()
13196 };
13197 crate::external_credentials::reset_side_effect_trap();
13198 let codex_managed_report = doctor_setup_report_json(&codex_managed, &workspace);
13199 assert_eq!(
13200 codex_managed_report["provider_model"]["auth"]["present_or_local"],
13201 false
13202 );
13203 assert_eq!(
13204 crate::external_credentials::side_effect_trap_counts(),
13205 (0, 0),
13206 "unsupported managed mode must fail before external I/O"
13207 );
13208 assert_eq!(
13209 fs::read_to_string(&codex_auth_path).expect("unchanged managed auth fixture"),
13210 codex_auth_raw
13211 );
13212
13213 let local_config = Config {
13214 provider: Some("ollama".to_string()),
13215 ..Config::default()
13216 };
13217 let local_report = doctor_setup_report_json(&local_config, &workspace);
13218 assert_eq!(local_report["provider_model"]["provider"]["id"], "ollama");
13219 assert_eq!(
13220 local_report["provider_model"]["auth"]["present_or_local"],
13221 true
13222 );
13223 assert!(local_report["provider_model"]["auth"]["credential_url"].is_null());
13224 assert_eq!(
13225 local_report["provider_model"]["auth"]["credential_mode"],
13226 "local_optional"
13227 );
13228 assert_eq!(local_report["provider_model"]["auth"]["oauth_only"], false);
13229 assert_eq!(
13230 local_report["provider_model"]["health"]["next_action"],
13231 "/model"
13232 );
13233
13234 let kimi_config = Config {
13235 provider: Some("moonshot".to_string()),
13236 ..Config::default()
13237 };
13238 let kimi_report = doctor_setup_report_json(&kimi_config, &workspace);
13239 assert_eq!(
13240 kimi_report["provider_model"]["auth"]["credential_url"],
13241 "https://platform.kimi.ai"
13242 );
13243 assert_eq!(
13244 kimi_report["provider_model"]["auth"]["credential_docs_url"],
13245 "https://platform.kimi.ai"
13246 );
13247 assert_eq!(
13248 kimi_report["provider_model"]["auth"]["credential_mode"],
13249 "api_key"
13250 );
13251 assert!(
13252 kimi_report["provider_model"]["auth"]["credential_guidance"]
13253 .as_str()
13254 .is_some_and(|guidance| guidance.contains("OAuth is not available"))
13255 );
13256 }
13257
13258 #[test]
13259 fn doctor_setup_report_json_uses_persisted_state() {
13260 let _guard = crate::test_support::lock_test_env();
13261 let tmp = TempDir::new().expect("tempdir");
13262 let (_home_guard, _codewhale_home) = prepare_env(&tmp);
13263 let workspace = tmp.path().join("workspace");
13264 fs::create_dir_all(&workspace).expect("workspace");
13265 let mut state = codewhale_config::SetupState::default();
13266 state.set_step(
13267 codewhale_config::SetupStep::Language,
13268 codewhale_config::StepEntry::new(
13269 codewhale_config::StepStatus::Verified,
13270 true,
13271 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13272 ),
13273 );
13274 state.set_step(
13275 codewhale_config::SetupStep::ProviderModel,
13276 codewhale_config::StepEntry::new(
13277 codewhale_config::StepStatus::Verified,
13278 true,
13279 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13280 )
13281 .with_result("deepseek/deepseek-chat"),
13282 );
13283 state.set_step(
13284 codewhale_config::SetupStep::TrustSandbox,
13285 codewhale_config::StepEntry::new(
13286 codewhale_config::StepStatus::Verified,
13287 true,
13288 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13289 ),
13290 );
13291 state
13292 .complete_constitution_checkpoint(
13293 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13294 codewhale_config::ConstitutionChoice::Bundled,
13295 )
13296 .set_step(
13297 codewhale_config::SetupStep::Constitution,
13298 codewhale_config::StepEntry::new(
13299 codewhale_config::StepStatus::Verified,
13300 true,
13301 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13302 ),
13303 );
13304 state.runtime_posture_source = codewhale_config::RuntimePostureSource::Confirmed;
13305 state.save().expect("persist setup state");
13306 codewhale_config::UserConstitution {
13307 autonomy_preference: codewhale_config::AutonomyPreference::Balanced,
13308 ..Default::default()
13309 }
13310 .save()
13311 .expect("persist user constitution");
13312 let config = Config {
13313 api_key: Some("TEST-STRUCTURAL-LITERAL".to_string()),
13314 approval_policy: Some("never".to_string()),
13315 allow_shell: Some(false),
13316 sandbox_mode: Some("read-only".to_string()),
13317 network: Some(crate::config::NetworkPolicyToml {
13318 default: "deny".to_string(),
13319 ..Default::default()
13320 }),
13321 ..Config::default()
13322 };
13323
13324 let report = doctor_setup_report_json(&config, &workspace);
13325
13326 assert_eq!(report["source"], "persisted");
13327 assert_eq!(report["first_run_ready"], true);
13328 assert_eq!(report["update_ready"], true);
13329 assert_eq!(report["operate_ready"], false);
13330 assert_eq!(report["constitution"]["choice"], "bundled");
13331 assert_eq!(
13332 report["constitution"]["checkpoint_completed_for"],
13333 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION
13334 );
13335 assert_eq!(report["constitution"]["autonomy_preference"], "balanced");
13336 assert_eq!(report["runtime_posture_source"], "confirmed");
13337 assert_eq!(report["runtime_posture"]["source"], "confirmed");
13338 assert_eq!(
13339 report["runtime_posture"]["approval_policy"]["value"],
13340 "never"
13341 );
13342 assert_eq!(
13343 report["runtime_posture"]["approval_policy"]["source"],
13344 "config"
13345 );
13346 assert_eq!(report["runtime_posture"]["allow_shell"]["value"], false);
13347 assert_eq!(report["runtime_posture"]["allow_shell"]["source"], "config");
13348 assert_eq!(
13349 report["runtime_posture"]["sandbox_mode"]["value"],
13350 "read-only"
13351 );
13352 assert_eq!(
13353 report["runtime_posture"]["sandbox_mode"]["source"],
13354 "config"
13355 );
13356 assert_eq!(
13357 report["runtime_posture"]["network_default"]["value"],
13358 "deny"
13359 );
13360 assert_eq!(
13361 report["runtime_posture"]["network_default"]["source"],
13362 "config"
13363 );
13364 assert_eq!(provider_step(&report)["result"], "deepseek/deepseek-chat");
13365
13366 let unprobed_config = Config {
13367 api_key: Some(crate::config::API_KEYRING_SENTINEL.to_string()),
13368 ..config.clone()
13369 };
13370 let unprobed_report = doctor_setup_report_json(&unprobed_config, &workspace);
13371 assert_eq!(unprobed_report["credential"]["ready"], false);
13372 assert_eq!(unprobed_report["credential"]["availability"], "not_probed");
13373 assert_eq!(unprobed_report["first_run_ready"], true);
13374 assert_eq!(unprobed_report["update_ready"], true);
13375 }
13376
13377 #[test]
13378 fn doctor_reports_settings_permission_posture_when_approval_policy_unset() {
13379 let _guard = crate::test_support::lock_test_env();
13380 let tmp = TempDir::new().expect("tempdir");
13381 let (_home_guard, codewhale_home) = prepare_env(&tmp);
13382 let workspace = tmp.path().join("workspace");
13383 fs::create_dir_all(&workspace).expect("workspace");
13384 fs::write(
13385 codewhale_home.join("settings.toml"),
13386 "permission_posture = \"full-access\"\n",
13387 )
13388 .expect("write settings.toml");
13389
13390 let config = Config::default();
13391 assert!(config.approval_policy.is_none());
13392
13393 let line = doctor_runtime_posture_line(&config, &workspace);
13394 assert!(
13395 line.contains("permission_posture=full-access (settings)"),
13396 "text doctor should report saved settings posture: {line}"
13397 );
13398 assert!(
13399 line.contains("approval_policy=on-request (default)"),
13400 "text doctor should keep unset config approval_policy default: {line}"
13401 );
13402
13403 let report = doctor_setup_report_json(&config, &workspace);
13404 assert_eq!(
13405 report["runtime_posture"]["permission_posture"]["value"],
13406 "full-access"
13407 );
13408 assert_eq!(
13409 report["runtime_posture"]["permission_posture"]["source"],
13410 "settings"
13411 );
13412 assert_eq!(
13413 report["runtime_posture"]["approval_policy"]["value"],
13414 "on-request"
13415 );
13416 assert_eq!(
13417 report["runtime_posture"]["approval_policy"]["source"],
13418 "default"
13419 );
13420 }
13421
13422 #[test]
13426 fn doctor_reports_resolved_telemetry_with_its_source() {
13427 let _guard = crate::test_support::lock_test_env();
13428 let tmp = TempDir::new().expect("tempdir");
13429 let (_home_guard, _codewhale_home) = prepare_env(&tmp);
13430 let _telemetry_env = crate::test_support::EnvVarGuard::remove("CODEWHALE_TELEMETRY");
13431 let _telemetry_alias_env = crate::test_support::EnvVarGuard::remove("DEEPSEEK_TELEMETRY");
13432 let _telemetry_floor =
13433 crate::test_support::EnvVarGuard::remove("CODEWHALE_TELEMETRY_FLOOR");
13434 let workspace = tmp.path().join("workspace");
13435 fs::create_dir_all(&workspace).expect("workspace");
13436
13437 let config = Config::default();
13440 assert!(config.telemetry.is_none());
13441 let line = doctor_runtime_posture_line(&config, &workspace);
13442 assert!(
13443 line.contains("telemetry=on (default)"),
13444 "doctor line should name the defaulted consent: {line}"
13445 );
13446 let report = doctor_setup_report_json(&config, &workspace);
13447 assert_eq!(report["runtime_posture"]["telemetry"]["value"], true);
13448 assert_eq!(report["runtime_posture"]["telemetry"]["source"], "default");
13449
13450 let config = Config {
13452 telemetry: Some(false),
13453 ..Config::default()
13454 };
13455 let line = doctor_runtime_posture_line(&config, &workspace);
13456 assert!(
13457 line.contains("telemetry=off (config)"),
13458 "doctor line should name the persisted opt-out: {line}"
13459 );
13460 let report = doctor_setup_report_json(&config, &workspace);
13461 assert_eq!(report["runtime_posture"]["telemetry"]["value"], false);
13462 assert_eq!(report["runtime_posture"]["telemetry"]["source"], "config");
13463 }
13464
13465 #[test]
13466 fn doctor_setup_report_json_fails_closed_without_operate_receipts() {
13467 let _guard = crate::test_support::lock_test_env();
13468 let tmp = TempDir::new().expect("tempdir");
13469 let (_home_guard, _codewhale_home) = prepare_env(&tmp);
13470 let workspace = tmp.path().join("workspace");
13471 fs::create_dir_all(&workspace).expect("workspace");
13472 let mut state = codewhale_config::SetupState::default();
13473 state.set_step(
13474 codewhale_config::SetupStep::Language,
13475 codewhale_config::StepEntry::new(
13476 codewhale_config::StepStatus::Verified,
13477 true,
13478 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13479 ),
13480 );
13481 state.set_step(
13482 codewhale_config::SetupStep::ProviderModel,
13483 codewhale_config::StepEntry::new(
13484 codewhale_config::StepStatus::Verified,
13485 true,
13486 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13487 ),
13488 );
13489 state.runtime_posture_source = codewhale_config::RuntimePostureSource::Confirmed;
13490 state.complete_constitution_checkpoint(
13491 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13492 codewhale_config::ConstitutionChoice::Bundled,
13493 );
13494 state.set_step(
13495 codewhale_config::SetupStep::OperateFleet,
13496 codewhale_config::StepEntry::new(
13497 codewhale_config::StepStatus::Verified,
13498 false,
13499 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13500 )
13501 .with_result(
13502 "provider=ready, runtime=ready, roster=ready, concurrency=plan limit not probed",
13503 ),
13504 );
13505 state.save().expect("persist setup state");
13506
13507 let config = Config {
13508 api_key: Some("TEST-STRUCTURAL-LITERAL".to_string()),
13509 ..Config::default()
13510 };
13511 let report = doctor_setup_report_json(&config, &workspace);
13512
13513 assert_eq!(report["first_run_ready"], true);
13514 assert_eq!(report["operate_ready"], false);
13515 assert_eq!(
13516 report["operate_fleet"]["concurrency"]["plan_limit_probed"],
13517 false
13518 );
13519 assert!(
13520 report["operate_fleet"]["roster"]["built_in"]
13521 .as_u64()
13522 .is_some_and(|count| count > 0)
13523 );
13524 let operate_step = report["steps"]
13525 .as_array()
13526 .expect("steps array")
13527 .iter()
13528 .find(|step| step["step"] == "operate_fleet")
13529 .expect("operate/fleet step");
13530 assert_eq!(operate_step["status"], "verified");
13531 assert!(
13532 operate_step["result"]
13533 .as_str()
13534 .is_some_and(|result| result.contains("plan limit not probed"))
13535 );
13536 }
13537}
13538
13539#[cfg(test)]
13540mod doctor_endpoint_tests {
13541 use super::*;
13542
13543 #[test]
13544 fn doctor_api_target_reports_default_endpoint() {
13545 let config = Config::default();
13546
13547 let target = doctor_api_target(&config);
13548
13549 assert_eq!(target.provider, "deepseek");
13550 assert_eq!(target.base_url, crate::config::DEFAULT_DEEPSEEK_BASE_URL);
13551 assert_eq!(target.model, crate::config::DEFAULT_TEXT_MODEL);
13552 assert_eq!(target.resolution, DoctorModelResolution::Resolved);
13553 }
13554
13555 #[test]
13556 fn doctor_api_target_falls_back_to_configured_model_when_resolution_fails() {
13557 let config = Config {
13561 provider: Some("custom".to_string()),
13562 ..Default::default()
13563 };
13564
13565 let target = doctor_api_target(&config);
13566
13567 assert_eq!(target.resolution, DoctorModelResolution::ConfiguredOnly);
13568 assert_eq!(target.model, config.default_model());
13569 }
13570
13571 #[test]
13572 fn doctor_api_target_routes_deepseek_cn_alias_to_beta_endpoint() {
13573 let config = Config {
13574 provider: Some("deepseek-cn".to_string()),
13575 ..Default::default()
13576 };
13577
13578 let target = doctor_api_target(&config);
13579
13580 assert_eq!(target.provider, "deepseek-cn");
13581 assert_eq!(target.base_url, crate::config::DEFAULT_DEEPSEEKCN_BASE_URL);
13582 assert_eq!(target.base_url, crate::config::DEFAULT_DEEPSEEK_BASE_URL);
13583 assert_eq!(target.model, crate::config::DEFAULT_TEXT_MODEL);
13584 assert_eq!(target.resolution, DoctorModelResolution::Resolved);
13585 }
13586
13587 #[test]
13588 fn strict_tool_mode_doctor_reports_disabled_by_default() {
13589 let config = Config::default();
13590
13591 let status = doctor_strict_tool_mode_status(&config);
13592
13593 assert!(!status.enabled);
13594 assert_eq!(status.status, "disabled");
13595 assert!(!status.function_strict_sent);
13596 assert!(status.recommended_base_url.is_none());
13597 }
13598
13599 #[test]
13600 fn doctor_known_base_urls_are_ascii_case_insensitive() {
13601 assert!(doctor_xiaomi_mimo_base_url_uses_token_plan(
13602 "HTTPS://TOKEN-PLAN-CN.XIAOMIMIMO.COM/V1/"
13603 ));
13604 assert_eq!(
13605 known_deepseek_base_url_kind("HTTPS://API.DEEPSEEK.COM/BETA/"),
13606 Some(DeepSeekBaseUrlKind::Beta)
13607 );
13608 assert_eq!(
13609 known_deepseek_base_url_kind("HTTPS://API.DEEPSEEK.COM/V1/"),
13610 Some(DeepSeekBaseUrlKind::NonBeta)
13611 );
13612 }
13613
13614 #[test]
13615 fn strict_tool_mode_doctor_accepts_default_beta_endpoint() {
13616 let config = Config {
13617 strict_tool_mode: Some(true),
13618 ..Default::default()
13619 };
13620
13621 let status = doctor_strict_tool_mode_status(&config);
13622
13623 assert!(status.enabled);
13624 assert_eq!(status.status, "ready");
13625 assert!(status.function_strict_sent);
13626 assert!(status.message.contains("beta endpoint"));
13627 assert!(status.recommended_base_url.is_none());
13628 }
13629
13630 #[test]
13631 fn strict_tool_mode_doctor_warns_for_non_beta_deepseek_endpoint() {
13632 let config = Config {
13633 strict_tool_mode: Some(true),
13634 base_url: Some("https://api.deepseek.com".to_string()),
13635 ..Default::default()
13636 };
13637
13638 let status = doctor_strict_tool_mode_status(&config);
13639
13640 assert_eq!(status.status, "fallback_non_beta");
13641 assert!(!status.function_strict_sent);
13642 assert_eq!(
13643 status.recommended_base_url.as_deref(),
13644 Some(crate::config::DEFAULT_DEEPSEEK_BASE_URL)
13645 );
13646 assert_eq!(
13647 doctor_strict_tool_mode_report_json(&status)["recommended_base_url"],
13648 "https://api.deepseek.com"
13649 );
13650 }
13651
13652 #[test]
13653 fn strict_tool_mode_doctor_accepts_deepseek_cn_alias_default_endpoint() {
13654 let config = Config {
13655 provider: Some("deepseek-cn".to_string()),
13656 strict_tool_mode: Some(true),
13657 ..Default::default()
13658 };
13659
13660 let status = doctor_strict_tool_mode_status(&config);
13661
13662 assert_eq!(status.status, "ready");
13663 assert!(status.function_strict_sent);
13664 assert!(status.message.contains("beta endpoint"));
13665 assert!(status.recommended_base_url.is_none());
13666 }
13667
13668 #[test]
13669 fn strict_tool_mode_doctor_marks_custom_endpoint_as_forwarded() {
13670 let config = Config {
13671 provider: Some("vllm".to_string()),
13672 strict_tool_mode: Some(true),
13673 ..Default::default()
13674 };
13675
13676 let status = doctor_strict_tool_mode_status(&config);
13677
13678 assert_eq!(status.status, "custom_endpoint");
13679 assert!(status.function_strict_sent);
13680 assert!(status.message.contains("custom endpoint"));
13681 }
13682
13683 #[test]
13684 fn doctor_tls_status_reports_verification_enabled_by_default() {
13685 let status = doctor_tls_status(&Config::default());
13686
13687 assert!(status.certificate_verification);
13688 assert!(!status.insecure_skip_tls_verify);
13689 assert_eq!(status.provider, "deepseek");
13690 assert!(status.message.contains("enabled"));
13691 }
13692
13693 #[test]
13694 fn doctor_tls_status_warns_when_active_provider_skips_verification() {
13695 let mut providers = crate::config::ProvidersConfig::default();
13696 providers.openai.insecure_skip_tls_verify = Some(true);
13697 let config = Config {
13698 provider: Some("openai".to_string()),
13699 providers: Some(providers),
13700 ..Default::default()
13701 };
13702
13703 let status = doctor_tls_status(&config);
13704
13705 assert!(status.certificate_verification);
13706 assert!(status.insecure_skip_tls_verify);
13707 assert_eq!(status.provider, "openai");
13708 assert!(status.message.contains("cannot be disabled"));
13709 assert!(status.message.contains("SSL_CERT_FILE"));
13710 }
13711
13712 #[test]
13713 fn provider_capability_report_exposes_alias_deprecation_for_deepseek_chat() {
13714 let mut config = Config {
13715 default_text_model: Some("deepseek-chat".to_string()),
13716 ..Default::default()
13717 };
13718 crate::config::normalize_model_config_for_test(&mut config);
13719
13720 let report = provider_capability_report(&config);
13721
13722 assert_eq!(report["resolved_model"], "deepseek-v4-flash");
13723 assert_eq!(report["context_window"], 1_000_000);
13724 assert_eq!(report["thinking_supported"], true);
13725 assert_eq!(report["alias_deprecation"]["alias"], "deepseek-chat");
13726 assert_eq!(
13727 report["alias_deprecation"]["replacement"],
13728 "deepseek-v4-flash"
13729 );
13730 assert_eq!(
13731 report["alias_deprecation"]["retirement_utc"],
13732 "2026-07-24T15:59:00Z"
13733 );
13734 }
13735
13736 #[test]
13737 fn provider_capability_report_preserves_custom_deepseek_alias_namespace() {
13738 let mut config = Config {
13739 base_url: Some("https://models.example/v1".to_string()),
13740 default_text_model: Some("deepseek-chat".to_string()),
13741 ..Default::default()
13742 };
13743 crate::config::normalize_model_config_for_test(&mut config);
13744
13745 let report = provider_capability_report(&config);
13746
13747 assert_eq!(report["resolved_model"], "deepseek-chat");
13748 assert!(report["alias_deprecation"].is_null());
13749 }
13750
13751 #[test]
13752 fn provider_capability_report_leaves_canonical_flash_alias_metadata_null() {
13753 let config = Config {
13754 default_text_model: Some("deepseek-v4-flash".to_string()),
13755 ..Default::default()
13756 };
13757
13758 let report = provider_capability_report(&config);
13759
13760 assert_eq!(report["resolved_model"], "deepseek-v4-flash");
13761 assert!(report["alias_deprecation"].is_null());
13762 }
13763
13764 #[test]
13765 fn doctor_route_report_exposes_tokenhub_openai_compatible_route_without_secret() {
13766 let mut providers = crate::config::ProvidersConfig::default();
13767 providers.openai.api_key = Some("tokenhub-secret-value".to_string());
13768 providers.openai.base_url = Some("https://tokenhub.tencentmaas.com/v1".to_string());
13769 providers.openai.model = Some("deepseek-ai/DeepSeek-V4-Pro".to_string());
13770 let config = Config {
13771 provider: Some("openai".to_string()),
13772 providers: Some(providers),
13773 ..Default::default()
13774 };
13775
13776 let report = doctor_route_report(&config);
13777 let serialized = report.to_string();
13778
13779 assert_eq!(report["provider"], "openai");
13780 assert_eq!(report["provider_source"], "config");
13781 assert_eq!(report["provider_config_table"], "openai");
13782 assert_eq!(report["model"], "deepseek-ai/DeepSeek-V4-Pro");
13783 assert_eq!(report["wire_protocol"], "chat_completions");
13784 assert_eq!(
13785 report["base_url"]["redacted"],
13786 "https://tokenhub.tencentmaas.com"
13787 );
13788 assert_eq!(report["base_url"]["class"], "custom");
13789 assert_eq!(report["auth"]["scheme"], "bearer");
13790 assert_eq!(report["auth"]["source"], "config_declared");
13791 assert!(
13792 report["base_url"]["fingerprint"]
13793 .as_str()
13794 .is_some_and(|value| value.starts_with("<redacted:"))
13795 );
13796 assert!(!serialized.contains("tokenhub-secret-value"));
13797 }
13798
13799 #[test]
13800 fn doctor_route_report_exposes_siliconflow_cn_provider_route() {
13801 let mut providers = crate::config::ProvidersConfig::default();
13802 providers.siliconflow_cn.api_key = Some("sf-cn-secret-value".to_string());
13803 providers.siliconflow_cn.base_url =
13804 Some(crate::config::DEFAULT_SILICONFLOW_CN_BASE_URL.to_string());
13805 providers.siliconflow_cn.model = Some(crate::config::DEFAULT_SILICONFLOW_MODEL.to_string());
13806 let config = Config {
13807 provider: Some("siliconflow-CN".to_string()),
13808 providers: Some(providers),
13809 ..Default::default()
13810 };
13811
13812 let report = doctor_route_report(&config);
13813 let serialized = report.to_string();
13814
13815 assert_eq!(report["provider"], "siliconflow-CN");
13816 assert_eq!(report["provider_config_table"], "siliconflow_cn");
13817 assert_eq!(report["model"], crate::config::DEFAULT_SILICONFLOW_MODEL);
13818 assert_eq!(
13819 report["base_url"]["redacted"],
13820 crate::doctor::structural_url_authority(crate::config::DEFAULT_SILICONFLOW_CN_BASE_URL)
13821 );
13822 assert_eq!(report["base_url"]["class"], "default");
13823 assert_eq!(report["auth"]["scheme"], "bearer");
13824 assert_eq!(report["auth"]["source"], "config_declared");
13825 assert!(!serialized.contains("sf-cn-secret-value"));
13826 }
13827
13828 #[test]
13829 fn doctor_route_report_names_kimi_code_context_provenance() {
13830 let config = Config {
13831 provider: Some("moonshot".to_string()),
13832 providers: Some(crate::config::ProvidersConfig {
13833 moonshot: crate::config::ProviderConfig {
13834 api_key: Some("kimi-plan-secret".to_string()),
13835 base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
13836 model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
13837 ..Default::default()
13838 },
13839 ..Default::default()
13840 }),
13841 ..Default::default()
13842 };
13843
13844 let report = doctor_route_report(&config);
13845 let serialized = report.to_string();
13846
13847 assert_eq!(report["context_window"]["tokens"], 262_144);
13848 assert_eq!(
13849 report["context_window"]["source"],
13850 "static Kimi Code safe floor"
13851 );
13852 assert!(!serialized.contains("kimi-plan-secret"));
13853 }
13854
13855 #[test]
13856 fn provider_capability_report_uses_exact_kimi_code_route_facts() {
13857 let config = Config {
13858 provider: Some("moonshot".to_string()),
13859 providers: Some(crate::config::ProvidersConfig {
13860 moonshot: crate::config::ProviderConfig {
13861 api_key: Some("kimi-plan-secret".to_string()),
13862 base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
13863 model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
13864 ..Default::default()
13865 },
13866 ..Default::default()
13867 }),
13868 ..Default::default()
13869 };
13870
13871 let report = provider_capability_report(&config);
13872
13873 assert_eq!(report["resolved_model"], crate::config::KIMI_CODE_K3_MODEL);
13874 assert_eq!(report["context_window"], 262_144);
13875 assert_eq!(
13876 report["context_window_source"],
13877 "static Kimi Code safe floor"
13878 );
13879 assert_eq!(report["thinking_supported"], true);
13880 }
13881
13882 #[test]
13883 fn provider_capability_report_honors_kimi_code_context_override() {
13884 let config = Config {
13885 provider: Some("moonshot".to_string()),
13886 providers: Some(crate::config::ProvidersConfig {
13887 moonshot: crate::config::ProviderConfig {
13888 api_key: Some("kimi-plan-secret".to_string()),
13889 base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
13890 model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
13891 context_window: Some(1_048_576),
13892 ..Default::default()
13893 },
13894 ..Default::default()
13895 }),
13896 ..Default::default()
13897 };
13898
13899 let report = provider_capability_report(&config);
13900
13901 assert_eq!(
13902 report["resolved_model"],
13903 crate::config::KIMI_CODE_K3_MODEL,
13904 "the configured window must preserve Kimi Code's bare wire id"
13905 );
13906 assert_eq!(report["context_window"], 1_048_576);
13907 assert_eq!(report["context_window_source"], "configured");
13908 assert_eq!(report["thinking_supported"], true);
13909 }
13910
13911 #[test]
13912 fn provider_capability_report_uses_direct_moonshot_k3_route_facts() {
13913 let config = Config {
13914 provider: Some("moonshot".to_string()),
13915 providers: Some(crate::config::ProvidersConfig {
13916 moonshot: crate::config::ProviderConfig {
13917 api_key: Some("moonshot-secret".to_string()),
13918 base_url: Some(crate::config::DEFAULT_MOONSHOT_BASE_URL.to_string()),
13919 model: Some("kimi-k3".to_string()),
13920 ..Default::default()
13921 },
13922 ..Default::default()
13923 }),
13924 ..Default::default()
13925 };
13926
13927 let report = provider_capability_report(&config);
13928
13929 assert_eq!(report["resolved_model"], "kimi-k3");
13930 assert_eq!(report["context_window"], 1_048_576);
13931 assert_eq!(report["context_window_source"], "catalog");
13932 assert_eq!(report["max_output"], 1_048_576);
13933 assert_eq!(report["thinking_supported"], true);
13934 }
13935
13936 #[test]
13937 fn doctor_search_provider_line_includes_firecrawl_default_source_and_switch_hint() {
13938 let _guard = crate::test_support::lock_test_env();
13939 let prev = std::env::var_os("DEEPSEEK_SEARCH_PROVIDER");
13940 unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") };
13941
13942 let line = doctor_search_provider_line(&Config::default());
13943
13944 match prev {
13945 Some(value) => unsafe { std::env::set_var("DEEPSEEK_SEARCH_PROVIDER", value) },
13946 None => unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") },
13947 }
13948 assert!(line.contains("search_provider: firecrawl"));
13949 assert!(line.contains("source: default"));
13950 assert!(line.contains("[search] provider"));
13951 assert!(line.contains("provider = \"baidu\""));
13952 }
13953
13954 #[test]
13955 fn doctor_search_provider_json_reports_config_source() {
13956 let _guard = crate::test_support::lock_test_env();
13957 let prev = std::env::var_os("DEEPSEEK_SEARCH_PROVIDER");
13958 unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") };
13959 let config = Config {
13960 search: Some(crate::config::SearchConfig {
13961 provider: Some(crate::config::SearchProvider::DuckDuckGo),
13962 base_url: None,
13963 api_key: None,
13964 }),
13965 ..Default::default()
13966 };
13967
13968 let report = doctor_search_provider_json(&config);
13969
13970 match prev {
13971 Some(value) => unsafe { std::env::set_var("DEEPSEEK_SEARCH_PROVIDER", value) },
13972 None => unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") },
13973 }
13974 assert_eq!(report["provider"], "duckduckgo");
13975 assert_eq!(report["source"], "config");
13976 assert_eq!(report["reachability"], "not_checked");
13977 assert_eq!(report["reachability_reason"], "offline_json");
13978 }
13979
13980 #[test]
13981 fn doctor_search_provider_json_reports_env_override_source() {
13982 let _guard = crate::test_support::lock_test_env();
13983 let prev = std::env::var_os("DEEPSEEK_SEARCH_PROVIDER");
13984 unsafe { std::env::set_var("DEEPSEEK_SEARCH_PROVIDER", "tavily") };
13985
13986 let report = doctor_search_provider_json(&Config::default());
13987
13988 match prev {
13989 Some(value) => unsafe { std::env::set_var("DEEPSEEK_SEARCH_PROVIDER", value) },
13990 None => unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") },
13991 }
13992 assert_eq!(report["provider"], "tavily");
13993 assert_eq!(report["source"], "env override");
13994 assert_eq!(report["reachability"], "not_checked");
13995 }
13996
13997 #[test]
13998 fn doctor_search_provider_line_omits_switch_hint_when_bing_is_configured() {
13999 let _guard = crate::test_support::lock_test_env();
14000 let prev = std::env::var_os("DEEPSEEK_SEARCH_PROVIDER");
14001 unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") };
14002 let config = Config {
14003 search: Some(crate::config::SearchConfig {
14004 provider: Some(crate::config::SearchProvider::Bing),
14005 base_url: None,
14006 api_key: None,
14007 }),
14008 ..Default::default()
14009 };
14010
14011 let line = doctor_search_provider_line(&config);
14012
14013 match prev {
14014 Some(value) => unsafe { std::env::set_var("DEEPSEEK_SEARCH_PROVIDER", value) },
14015 None => unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") },
14016 }
14017 assert!(line.contains("search_provider: bing"));
14018 assert!(line.contains("source: config"));
14019 assert!(!line.contains("[search] provider"));
14020 }
14021
14022 #[test]
14023 fn timeout_recovery_keeps_default_deepseek_users_on_default_endpoint() {
14024 let config = Config::default();
14025
14026 let text = doctor_timeout_recovery_lines(&config).join("\n");
14027
14028 assert!(text.contains("api.deepseek.com"));
14029 assert!(text.contains("custom DeepSeek-compatible endpoint"));
14030 assert!(!text.contains("provider = \"deepseek-cn\""));
14031 assert!(text.contains("codewhale doctor --json"));
14032 }
14033
14034 #[test]
14035 fn timeout_recovery_for_custom_provider_checks_openai_compatibility() {
14036 let config = Config {
14037 provider: Some("vllm".to_string()),
14038 ..Default::default()
14039 };
14040
14041 let text = doctor_timeout_recovery_lines(&config).join("\n");
14042
14043 assert!(text.contains("/v1/models"));
14044 assert!(text.contains("/v1/chat/completions"));
14045 assert!(!text.contains("api.deepseeki.com"));
14046 }
14047}
14048
14049#[cfg(test)]
14050mod terminal_mode_tests {
14051 use super::*;
14052 use clap::Parser;
14053
14054 fn parse_cli(args: &[&str]) -> Cli {
14055 Cli::try_parse_from(args).expect("CLI args should parse")
14056 }
14057
14058 #[test]
14059 fn headless_consultant_authority_overrides_network_allow_and_disables_web_search() {
14060 let config = Config {
14061 network: Some(crate::config::NetworkPolicyToml {
14062 default: "allow".to_string(),
14063 audit: false,
14064 ..crate::config::NetworkPolicyToml::default()
14065 }),
14066 ..Config::default()
14067 };
14068 let authority = crate::tools::spec::ToolAuthorityEnvelope {
14069 schema_version: 1,
14070 owner: "consultant-1".to_string(),
14071 authority: crate::tools::spec::ToolMutationAuthority::ReadOnly,
14072 network_access: Some(false),
14073 shell: crate::tools::spec::ToolShellAuthority::None,
14074 verification: crate::tools::spec::ToolVerificationAuthority::None,
14075 writable_roots: Vec::new(),
14076 writable_files: Vec::new(),
14077 coordination_contracts: Vec::new(),
14078 }
14079 .normalized()
14080 .expect("Consultant authority");
14081
14082 let policy = exec_network_policy(&config, authority.network_access)
14083 .expect("explicit network=false always installs a policy");
14084 assert_eq!(
14085 policy.evaluate("example.com", "web_search"),
14086 crate::network_policy::Decision::Deny,
14087 "the permissive user config must not widen Consultant network authority"
14088 );
14089 let mut features = crate::features::Features::default();
14090 features.enable(crate::features::Feature::ShellTool);
14091 features.enable(crate::features::Feature::WebSearch);
14092 apply_fleet_engine_feature_caps(
14093 &mut features,
14094 true,
14095 authority.network_access,
14096 authority.shell,
14097 );
14098 assert!(!features.enabled(crate::features::Feature::WebSearch));
14099 assert!(!features.enabled(crate::features::Feature::ShellTool));
14100
14101 let worker_policy = exec_network_policy(&config, Some(true)).expect("configured policy");
14102 assert_eq!(
14103 worker_policy.evaluate("example.com", "web_search"),
14104 crate::network_policy::Decision::Allow,
14105 "a network-capable role keeps the configured policy"
14106 );
14107 }
14108 #[test]
14109 fn hidden_remote_control_flag_starts_the_interactive_handoff() {
14110 let cli = parse_cli(&["codewhale-tui", "--remote-control"]);
14111 assert!(cli.remote_control);
14112 }
14113
14114 #[test]
14115 fn plugin_registry_discovery_is_route_independent_and_read_only() {
14116 let _env_lock = crate::test_support::lock_test_env();
14117 let temp = tempfile::tempdir().unwrap();
14118 let workspace = temp.path().join("workspace");
14119 let codewhale_home = temp.path().join("home");
14120 std::fs::create_dir_all(&workspace).unwrap();
14121 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
14122 let workspace_arg = workspace.to_string_lossy().into_owned();
14123
14124 for route in [
14125 Vec::<&str>::new(),
14126 vec!["resume", "--last"],
14127 vec!["fork", "--last"],
14128 vec!["exec", "hello"],
14129 vec!["serve", "--mcp"],
14130 ] {
14131 let mut args = vec![
14132 "codewhale-tui".to_string(),
14133 "--workspace".to_string(),
14134 workspace_arg.clone(),
14135 ];
14136 args.extend(route.into_iter().map(str::to_string));
14137 let cli = Cli::try_parse_from(args).expect("route should parse");
14138 let discovery = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv();
14139 let registry = discovery
14140 .registry_for_workspace(cli.workspace.as_deref().unwrap_or(workspace.as_path()));
14141 assert_eq!(registry.workspace(), workspace.as_path());
14142 assert!(
14143 !codewhale_home.join("plugins/state.json").exists(),
14144 "startup discovery must remain read-only"
14145 );
14146 }
14147 }
14148
14149 fn custom_exec_config(active: &str) -> Config {
14150 let mut custom = std::collections::HashMap::new();
14151 for (name, base_url, model) in [
14152 (
14153 "custom-a",
14154 "http://127.0.0.1:18181/v1",
14155 crate::config::ZAI_GLM_5_2_MODEL,
14156 ),
14157 ("custom-b", "http://127.0.0.1:18182/v1", "model-b"),
14158 ] {
14159 custom.insert(
14160 name.to_string(),
14161 crate::config::ProviderConfig {
14162 kind: Some("openai-compatible".to_string()),
14163 base_url: Some(base_url.to_string()),
14164 model: Some(model.to_string()),
14165 api_key: Some("local-test-key".to_string()),
14166 ..Default::default()
14167 },
14168 );
14169 }
14170 Config {
14171 provider: Some(active.to_string()),
14172 providers: Some(crate::config::ProvidersConfig {
14173 custom,
14174 ..Default::default()
14175 }),
14176 ..Default::default()
14177 }
14178 }
14179
14180 #[test]
14181 fn doctor_json_surfaces_keep_exact_named_custom_provider() {
14182 let config = custom_exec_config("custom-a");
14183 let workspace = tempfile::tempdir().expect("doctor workspace");
14184
14185 let operate = doctor_operate_fleet_report_json(&config, workspace.path());
14186 let provider_model = doctor_provider_model_report_json(&config);
14187 let capability = provider_capability_report(&config);
14188 let route = doctor_route_report(&config);
14189
14190 assert_eq!(operate["provider"]["id"], "custom-a");
14191 assert_eq!(provider_model["provider"]["id"], "custom-a");
14192 assert_eq!(capability["resolved_provider"], "custom-a");
14193 assert_eq!(route["provider"], "custom-a");
14194 assert_eq!(route["provider_config_table"], "providers.custom-a");
14195 let serialized = serde_json::to_string(&serde_json::json!({
14196 "operate": operate,
14197 "provider_model": provider_model,
14198 "capability": capability,
14199 "route": route,
14200 }))
14201 .expect("doctor JSON");
14202 assert!(!serialized.contains("local-test-key"));
14203 }
14204
14205 #[test]
14206 fn doctor_operate_fleet_json_lists_multi_layer_profile_paths() {
14207 let _env_lock = crate::test_support::lock_test_env();
14210 let tmp = tempfile::TempDir::new().expect("tempdir");
14211 let home = tmp.path().join("home");
14212 let workspace = tmp.path().join("workspace");
14213 let personal = home.join("agents");
14214 let project = workspace.join(".codewhale").join("agents");
14215 std::fs::create_dir_all(&personal).expect("personal agents");
14216 std::fs::create_dir_all(&project).expect("project agents");
14217 std::fs::write(
14218 personal.join("builder.toml"),
14219 "id = \"builder\"\nrole_hint = \"builder\"\nmodel = \"deepseek-v4-flash\"\n",
14220 )
14221 .expect("personal builder");
14222 std::fs::write(
14223 project.join("builder.toml"),
14224 "id = \"builder\"\nrole_hint = \"builder\"\nmodel = \"deepseek-v4-pro\"\n",
14225 )
14226 .expect("project builder");
14227 let _codewhale_home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
14228
14229 let operate = doctor_operate_fleet_report_json(&Config::default(), &workspace);
14230 let layers = operate["roster"]["multi_layer"]
14231 .as_array()
14232 .expect("multi_layer array");
14233 let builder = layers
14234 .iter()
14235 .find(|entry| entry["id"] == "builder")
14236 .expect("builder multi-layer entry");
14237 assert_eq!(builder["effective"], "project");
14238 let paths: Vec<&str> = builder["layers"]
14239 .as_array()
14240 .expect("layers")
14241 .iter()
14242 .filter_map(|layer| layer["path"].as_str())
14243 .collect();
14244 assert!(
14245 paths.iter().any(|path| path.ends_with("builder.toml")),
14246 "layer paths include the profile files: {builder}"
14247 );
14248 assert!(
14249 builder["layers"]
14250 .as_array()
14251 .expect("layers")
14252 .iter()
14253 .any(|layer| layer["origin"] == "personal" && layer["wins"] == false),
14254 "personal layer is listed as ignored: {builder}"
14255 );
14256 assert!(
14257 builder["layers"]
14258 .as_array()
14259 .expect("layers")
14260 .iter()
14261 .any(|layer| layer["origin"] == "project" && layer["wins"] == true),
14262 "project layer wins: {builder}"
14263 );
14264 }
14265
14266 fn saved_exec_session(provider: &str, model: &str) -> session_manager::SavedSession {
14267 let mut saved = session_manager::create_saved_session_with_mode(
14268 &[],
14269 model,
14270 Path::new("/tmp/exec-resume"),
14271 0,
14272 None,
14273 Some("exec"),
14274 );
14275 let kind = crate::config::ApiProvider::parse(provider)
14276 .unwrap_or(crate::config::ApiProvider::Custom)
14277 .as_str();
14278 let exact_id = (!provider
14279 .eq_ignore_ascii_case(crate::config::ApiProvider::Custom.as_str()))
14280 .then_some(provider);
14281 saved.metadata.set_model_provider_route(kind, exact_id);
14282 saved
14283 }
14284
14285 #[test]
14286 fn prompt_flag_accepts_split_prompt_words_for_windows_cmd_shims() {
14287 let cli = parse_cli(&["codewhale", "-p", "hello", "world"]);
14288
14289 assert_eq!(cli.prompt, vec!["hello", "world"]);
14290 }
14291
14292 #[test]
14293 fn prompt_flag_starts_interactive_submit_input() {
14294 let cli = parse_cli(&["codewhale", "-p", "read", "the", "project"]);
14295
14296 assert_eq!(
14297 top_level_prompt_initial_input(&cli.prompt),
14298 Some(tui::InitialInput::Submit("read the project".to_string()))
14299 );
14300 }
14301
14302 #[test]
14303 fn companion_binary_reports_its_own_name() {
14304 assert_eq!(Cli::command().get_name(), "codewhale-tui");
14305 }
14306
14307 #[test]
14308 fn xai_device_auth_subcommand_parses() {
14309 let cli = parse_cli(&["codewhale-tui", "auth", "xai-device"]);
14310 assert!(matches!(
14311 cli.command,
14312 Some(Commands::Auth(TuiAuthArgs {
14313 command: TuiAuthCommand::XaiDevice
14314 }))
14315 ));
14316 }
14317
14318 #[test]
14319 fn workflow_tool_internal_subcommand_parses_exact_json() {
14320 let cli = parse_cli(&[
14321 "codewhale-tui",
14322 "workflow-tool",
14323 "--approval-source",
14324 "explicit-workflow-command",
14325 "--input-json",
14326 r#"{"action":"run","source_path":"workflows/demo.js"}"#,
14327 ]);
14328 let Some(Commands::WorkflowTool(args)) = cli.command else {
14329 panic!("expected workflow-tool command");
14330 };
14331 assert!(args.input_json.contains("\"action\":\"run\""));
14332 }
14333
14334 #[tokio::test]
14335 async fn direct_workflow_tool_runs_without_an_operator_model_turn() {
14336 use crate::tools::spec::ToolSpec;
14337
14338 let workspace = tempfile::tempdir().expect("workspace");
14339 let config = Config {
14340 provider: Some("vllm".to_string()),
14341 mcp_config_path: Some(
14342 workspace
14343 .path()
14344 .join("missing-mcp.json")
14345 .display()
14346 .to_string(),
14347 ),
14348 providers: Some(crate::config::ProvidersConfig {
14349 vllm: crate::config::ProviderConfig {
14350 base_url: Some("http://127.0.0.1:9/v1".to_string()),
14351 model: Some("offline-test-model".to_string()),
14352 ..Default::default()
14353 },
14354 ..Default::default()
14355 }),
14356 ..Default::default()
14357 };
14358 let route = CliAutoRoute {
14359 provider: crate::config::ApiProvider::Vllm,
14360 model: "offline-test-model".to_string(),
14361 reasoning_effort: None,
14362 auto_controls_reasoning: false,
14363 auto_model: false,
14364 };
14365 let (event_tx, mut event_rx) = tokio::sync::mpsc::channel(64);
14366 let plugins = Arc::new(crate::plugins::PluginRegistry::empty(workspace.path()));
14367 let (tool, context) =
14368 build_direct_workflow_tool(&config, &route, workspace.path(), event_tx, plugins)
14369 .await
14370 .expect("build direct workflow runtime");
14371
14372 let result = tool
14373 .execute(
14374 serde_json::json!({
14375 "action": "run",
14376 "script": "phase('offline'); return { ok: true };",
14377 "token_budget": 1_000_000
14378 }),
14379 &context,
14380 )
14381 .await
14382 .expect("model-free workflow run");
14383 let payload: serde_json::Value =
14384 serde_json::from_str(&result.content).expect("workflow JSON");
14385
14386 assert_eq!(payload["status"], "completed");
14387 assert_eq!(payload["result"]["ok"], true);
14388 assert_eq!(payload["child_ids"].as_array().map(Vec::len), Some(0));
14389 assert_eq!(
14390 payload["plan_approval"]["decision"],
14391 "approved_explicit_cli_command"
14392 );
14393 assert!(!context.auto_approve);
14394 assert!(!context.trust_mode);
14395 assert_eq!(
14396 context.shell_policy,
14397 crate::worker_profile::ShellPolicy::None
14398 );
14399 assert!(matches!(
14400 context.elevated_sandbox_policy,
14401 Some(crate::sandbox::SandboxPolicy::WorkspaceWrite { .. })
14402 ));
14403 let mut event_types = Vec::new();
14404 while let Ok(event) = event_rx.try_recv() {
14405 if let crate::core::events::Event::WorkflowUi { event, .. } = event
14406 && let Some(kind) = event["type"].as_str()
14407 {
14408 event_types.push(kind.to_string());
14409 }
14410 }
14411 assert!(event_types.iter().any(|kind| kind == "run_started"));
14412 assert!(event_types.iter().any(|kind| kind == "run_completed"));
14413 }
14414
14415 #[tokio::test]
14416 async fn direct_workflow_mcp_pool_applies_network_policy_before_connect() {
14417 let workspace = tempfile::tempdir().expect("workspace");
14418 let mcp_path = workspace.path().join("mcp.json");
14419 std::fs::write(
14420 &mcp_path,
14421 r#"{
14422 "mcpServers": {
14423 "blocked": { "url": "https://blocked.invalid/mcp" }
14424 }
14425 }"#,
14426 )
14427 .expect("write MCP config");
14428 let config = Config {
14429 mcp_config_path: Some(mcp_path.display().to_string()),
14430 ..Default::default()
14431 };
14432 let policy = crate::network_policy::NetworkPolicyDecider::new(
14433 crate::network_policy::NetworkPolicy {
14434 default: crate::network_policy::DecisionToml::Deny,
14435 allow: Vec::new(),
14436 deny: Vec::new(),
14437 proxy: Vec::new(),
14438 proxy_fake_ip_cidrs: Vec::new(),
14439 audit: false,
14440 },
14441 None,
14442 );
14443
14444 let plugins = Arc::new(crate::plugins::PluginRegistry::empty(workspace.path()));
14445 let (_pool, failures) =
14446 initialize_direct_workflow_mcp_pool(&config, workspace.path(), Some(policy), plugins)
14447 .await
14448 .expect("MCP feature enabled");
14449 assert_eq!(failures.len(), 1, "failures={failures:?}");
14450 assert_eq!(failures[0].0, "blocked");
14451 assert!(failures[0].1.contains("blocked by network policy"));
14452 }
14453
14454 #[test]
14455 fn exec_model_resolution_uses_provider_scoped_default() {
14456 let _env_lock = crate::test_support::lock_test_env();
14457 let _codewhale_model = crate::test_support::EnvVarGuard::remove("CODEWHALE_MODEL");
14458 let _deepseek_model = crate::test_support::EnvVarGuard::remove("DEEPSEEK_MODEL");
14459 let config = Config {
14460 provider: Some("openrouter".to_string()),
14461 default_text_model: Some("deepseek/deepseek-v4-pro".to_string()),
14462 providers: Some(crate::config::ProvidersConfig {
14463 openrouter: crate::config::ProviderConfig {
14464 model: Some("arcee-ai/trinity-large-thinking".to_string()),
14465 ..Default::default()
14466 },
14467 ..Default::default()
14468 }),
14469 ..Default::default()
14470 };
14471
14472 assert_eq!(
14473 resolve_exec_model(&config, None),
14474 "arcee-ai/trinity-large-thinking"
14475 );
14476 assert_eq!(
14477 resolve_exec_model(&config, Some("arcee-ai/trinity-large-thinking")),
14478 "arcee-ai/trinity-large-thinking"
14479 );
14480 }
14481
14482 #[test]
14483 fn exec_model_resolution_prefers_codewhale_model_env_override() {
14484 let _env_lock = crate::test_support::lock_test_env();
14485 let _codewhale_model = crate::test_support::EnvVarGuard::set("CODEWHALE_MODEL", " auto ");
14486 let _deepseek_model =
14487 crate::test_support::EnvVarGuard::set("DEEPSEEK_MODEL", "stale-deepseek-model");
14488 let config = Config {
14489 default_text_model: Some("deepseek/deepseek-v4-pro".to_string()),
14490 ..Default::default()
14491 };
14492
14493 assert_eq!(resolve_exec_model(&config, None), "auto");
14494 }
14495
14496 #[test]
14497 fn exec_model_resolution_uses_legacy_deepseek_model_env_override() {
14498 let _env_lock = crate::test_support::lock_test_env();
14499 let _codewhale_model = crate::test_support::EnvVarGuard::remove("CODEWHALE_MODEL");
14500 let _deepseek_model = crate::test_support::EnvVarGuard::set("DEEPSEEK_MODEL", " auto ");
14501 let config = Config {
14502 default_text_model: Some("deepseek/deepseek-v4-pro".to_string()),
14503 ..Default::default()
14504 };
14505
14506 assert_eq!(resolve_exec_model(&config, None), "auto");
14507 }
14508
14509 #[test]
14510 fn exec_model_resolution_uses_provider_safe_default_for_zai() {
14511 let _env_lock = crate::test_support::lock_test_env();
14512 let _codewhale_model = crate::test_support::EnvVarGuard::remove("CODEWHALE_MODEL");
14513 let _deepseek_model = crate::test_support::EnvVarGuard::remove("DEEPSEEK_MODEL");
14514 let config = Config {
14515 provider: Some("zai".to_string()),
14516 default_text_model: Some(crate::config::DEFAULT_TEXT_MODEL.to_string()),
14517 ..Default::default()
14518 };
14519
14520 assert_eq!(
14521 resolve_exec_model(&config, None),
14522 crate::config::DEFAULT_ZAI_MODEL
14523 );
14524 }
14525
14526 #[tokio::test]
14527 #[allow(clippy::await_holding_lock)]
14528 async fn explicit_exec_model_routes_to_unique_authenticated_provider_candidate() {
14529 let _env_lock = crate::test_support::lock_test_env();
14530 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
14531 let _openrouter = crate::test_support::EnvVarGuard::remove("OPENROUTER_API_KEY");
14532 let config = Config {
14533 provider: Some("deepseek".to_string()),
14534 default_text_model: Some(crate::config::DEFAULT_TEXT_MODEL.to_string()),
14535 ..Default::default()
14536 };
14537
14538 let route = resolve_cli_auto_route(&config, crate::config::ZAI_GLM_5_2_MODEL, "pong")
14539 .await
14540 .expect("explicit GLM should route to the configured Z.ai provider");
14541
14542 assert_eq!(route.provider, crate::config::ApiProvider::Zai);
14543 assert_eq!(route.model, crate::config::ZAI_GLM_5_2_MODEL);
14544 assert!(!route.auto_model);
14545 }
14546
14547 #[tokio::test]
14548 #[allow(clippy::await_holding_lock)]
14549 async fn explicit_exec_model_reports_ambiguous_authenticated_provider_candidates() {
14550 let _env_lock = crate::test_support::lock_test_env();
14551 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
14552 let _openrouter = crate::test_support::EnvVarGuard::set("OPENROUTER_API_KEY", "or-key");
14553 let config = Config {
14554 provider: Some("deepseek".to_string()),
14555 default_text_model: Some(crate::config::DEFAULT_TEXT_MODEL.to_string()),
14556 ..Default::default()
14557 };
14558
14559 let err = resolve_cli_auto_route(&config, crate::config::ZAI_GLM_5_2_MODEL, "pong")
14560 .await
14561 .expect_err("ambiguous GLM route should ask for an explicit provider");
14562 let message = err.to_string();
14563
14564 assert!(message.contains("model `GLM-5.2` is available"));
14565 assert!(message.contains("openrouter"));
14566 assert!(message.contains("zai"));
14567 assert!(message.contains("--provider"));
14568 assert!(message.contains("/provider"));
14569 assert!(message.contains("/model"));
14570 assert!(message.contains("/setup"));
14571 }
14572
14573 #[tokio::test]
14574 async fn cli_auto_model_honors_a_fixed_reasoning_preference() {
14575 let config = Config {
14576 provider: Some("vllm".to_string()),
14577 reasoning_effort: Some("low".to_string()),
14578 providers: Some(crate::config::ProvidersConfig {
14579 vllm: crate::config::ProviderConfig {
14580 base_url: Some("http://127.0.0.1:18190/v1".to_string()),
14581 model: Some("local-auto-model".to_string()),
14582 ..Default::default()
14583 },
14584 ..Default::default()
14585 }),
14586 ..Default::default()
14587 };
14588
14589 let route = resolve_cli_auto_route(&config, "auto", "debug a failing test")
14590 .await
14591 .expect("Auto model route");
14592
14593 assert!(route.auto_model);
14594 assert_eq!(
14595 route.reasoning_effort,
14596 Some(crate::tui::app::ReasoningEffort::Low)
14597 );
14598 assert!(
14599 !route.auto_controls_reasoning,
14600 "a fixed saved tier must not be replaced per prompt"
14601 );
14602 }
14603
14604 #[test]
14605 fn cli_route_execution_config_stamps_routed_model_into_provider_slot() {
14606 let mut providers = crate::config::ProvidersConfig::default();
14607 providers.deepseek.model = Some("deepseek-v4-pro".to_string());
14608 let config = Config {
14609 provider: Some("deepseek".to_string()),
14610 providers: Some(providers),
14611 ..Default::default()
14612 };
14613 let route = CliAutoRoute {
14614 provider: crate::config::ApiProvider::Deepseek,
14615 model: "deepseek-v4-flash".to_string(),
14616 reasoning_effort: None,
14617 auto_controls_reasoning: true,
14618 auto_model: true,
14619 };
14620
14621 let execution_config = config_for_cli_route(&config, &route);
14622
14623 assert_eq!(execution_config.default_model(), "deepseek-v4-flash");
14624 assert_eq!(
14625 execution_config
14626 .provider_config_for(crate::config::ApiProvider::Deepseek)
14627 .and_then(|entry| entry.model.as_deref()),
14628 Some("deepseek-v4-flash")
14629 );
14630 }
14631
14632 #[test]
14633 fn cli_route_execution_config_preserves_legacy_literal_custom_root_route() {
14634 let _lock = crate::test_support::lock_test_env();
14635 let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
14636 let _cli_key = crate::test_support::EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
14637 let config = Config {
14638 provider: Some("custom".to_string()),
14639 api_key: Some("legacy-root-key".to_string()),
14640 base_url: Some("http://127.0.0.1:18183/v1".to_string()),
14641 default_text_model: Some("legacy-model".to_string()),
14642 ..Default::default()
14643 };
14644 let route = CliAutoRoute {
14645 provider: crate::config::ApiProvider::Custom,
14646 model: "routed-legacy-model".to_string(),
14647 reasoning_effort: None,
14648 auto_controls_reasoning: false,
14649 auto_model: false,
14650 };
14651
14652 let execution = config_for_cli_route(&config, &route);
14653
14654 assert!(execution.uses_legacy_literal_custom_route());
14655 assert!(
14656 execution
14657 .providers
14658 .as_ref()
14659 .is_none_or(|providers| !providers.custom.contains_key("custom"))
14660 );
14661 assert_eq!(execution.provider.as_deref(), Some("custom"));
14662 assert_eq!(execution.default_model(), "routed-legacy-model");
14663 assert_eq!(execution.deepseek_base_url(), "http://127.0.0.1:18183/v1");
14664 assert_eq!(execution.deepseek_api_key().unwrap(), "legacy-root-key");
14665 for _ in 0..2 {
14666 let identity = execution
14667 .resolve_provider_identity("custom")
14668 .expect("legacy identity remains repeatedly resolvable");
14669 assert_eq!(identity.key, "custom");
14670 }
14671 let client =
14672 crate::client::DeepSeekClient::new(&execution).expect("legacy execution client");
14673 assert_eq!(client.base_url(), "http://127.0.0.1:18183/v1");
14674 }
14675
14676 #[test]
14677 fn exec_accepts_split_prompt_words_for_windows_cmd_shims() {
14678 let cli = parse_cli(&["codewhale", "exec", "hello", "world"]);
14679 let Some(Commands::Exec(args)) = cli.command else {
14680 panic!("expected exec command");
14681 };
14682
14683 assert_eq!(args.prompt, vec!["hello", "world"]);
14684 }
14685
14686 #[test]
14687 fn exec_keeps_model_flag_before_split_prompt_words() {
14688 let cli = parse_cli(&["codewhale", "exec", "--model", "auto", "hello", "world"]);
14689 let Some(Commands::Exec(args)) = cli.command else {
14690 panic!("expected exec command");
14691 };
14692
14693 assert_eq!(args.model.as_deref(), Some("auto"));
14694 assert_eq!(args.prompt, vec!["hello", "world"]);
14695 }
14696
14697 #[test]
14698 fn exec_keeps_flags_before_split_prompt_words() {
14699 let cli = parse_cli(&["codewhale", "exec", "--json", "hello", "world"]);
14700 let Some(Commands::Exec(args)) = cli.command else {
14701 panic!("expected exec command");
14702 };
14703
14704 assert!(args.json);
14705 assert_eq!(args.prompt, vec!["hello", "world"]);
14706 }
14707
14708 #[test]
14709 fn exec_parses_provider_flag_alongside_model() {
14710 let cli = parse_cli(&[
14713 "codewhale",
14714 "exec",
14715 "--provider",
14716 "openrouter",
14717 "--model",
14718 "glm-5.2",
14719 "audit",
14720 ]);
14721 let Some(Commands::Exec(args)) = cli.command else {
14722 panic!("expected exec command");
14723 };
14724
14725 assert_eq!(args.provider.as_deref(), Some("openrouter"));
14726 assert_eq!(args.model.as_deref(), Some("glm-5.2"));
14727 assert_eq!(args.prompt, vec!["audit"]);
14728 assert_eq!(
14731 crate::config::ApiProvider::parse(args.provider.as_deref().unwrap()),
14732 Some(crate::config::ApiProvider::Openrouter)
14733 );
14734 }
14735
14736 #[test]
14737 fn exec_provider_override_accepts_configured_custom_provider() {
14738 let mut custom = std::collections::HashMap::new();
14739 custom.insert(
14740 "lm-studio".to_string(),
14741 crate::config::ProviderConfig {
14742 kind: Some("openai-compatible".to_string()),
14743 base_url: Some("http://127.0.0.1:1234/v1".to_string()),
14744 model: Some("qwen-2.5-7b".to_string()),
14745 api_key: Some("lm-studio".to_string()),
14746 ..Default::default()
14747 },
14748 );
14749 let mut config = Config {
14750 provider: Some("deepseek".to_string()),
14751 providers: Some(crate::config::ProvidersConfig {
14752 custom,
14753 ..Default::default()
14754 }),
14755 ..Default::default()
14756 };
14757
14758 apply_exec_provider_override(&mut config, "lm-studio")
14759 .expect("configured custom provider should be accepted");
14760
14761 assert_eq!(config.provider.as_deref(), Some("lm-studio"));
14762 assert_eq!(config.api_provider(), crate::config::ApiProvider::Custom);
14763 }
14764
14765 #[test]
14766 fn exec_provider_override_prefers_exact_case_colliding_custom_key() {
14767 let mut config = Config {
14768 provider: Some("deepseek".to_string()),
14769 providers: Some(crate::config::ProvidersConfig {
14770 custom: std::collections::HashMap::from([(
14771 "CUSTOM".to_string(),
14772 crate::config::ProviderConfig {
14773 kind: Some("openai-compatible".to_string()),
14774 base_url: Some("http://127.0.0.1:5678/v1".to_string()),
14775 model: Some("case-model".to_string()),
14776 api_key: Some("case-key".to_string()),
14777 ..Default::default()
14778 },
14779 )]),
14780 ..Default::default()
14781 }),
14782 ..Default::default()
14783 };
14784
14785 apply_exec_provider_override(&mut config, "CUSTOM")
14786 .expect("exact case-colliding custom provider");
14787 assert_eq!(config.provider.as_deref(), Some("CUSTOM"));
14788 assert_eq!(config.api_provider(), crate::config::ApiProvider::Custom);
14789 assert_eq!(
14790 config.provider_identity_for(crate::config::ApiProvider::Custom),
14791 "CUSTOM"
14792 );
14793 let route = crate::route_runtime::resolve_runtime_route(
14794 &config,
14795 crate::config::ApiProvider::Custom,
14796 Some("case-model"),
14797 )
14798 .expect("resolve exact case-colliding route")
14799 .validate()
14800 .expect("preflight exact case-colliding route");
14801 assert_eq!(route.identity.key, "CUSTOM");
14802 assert_eq!(route.client.base_url(), "http://127.0.0.1:5678/v1");
14803 }
14804
14805 #[test]
14806 fn exec_provider_override_rejects_unknown_provider() {
14807 let mut config = Config {
14808 provider: Some("deepseek".to_string()),
14809 ..Default::default()
14810 };
14811
14812 let err = apply_exec_provider_override(&mut config, "lm-studio")
14813 .expect_err("unconfigured custom provider should fail closed");
14814 let message = err.to_string();
14815
14816 assert!(message.contains("Unrecognized --provider"));
14817 assert!(message.contains("[providers.<name>] custom provider"));
14818 assert_eq!(config.provider.as_deref(), Some("deepseek"));
14819 }
14820
14821 #[test]
14822 fn exec_resume_route_matrix_preserves_or_overrides_exact_provider_deliberately() {
14823 let saved = saved_exec_session("custom-a", crate::config::ZAI_GLM_5_2_MODEL);
14824
14825 let mut restored = custom_exec_config("custom-b");
14826 let model = resolve_exec_resume_route(&mut restored, &saved, false, None)
14827 .expect("plain resume restores saved route");
14828 assert_eq!(restored.provider.as_deref(), Some("custom-a"));
14829 assert_eq!(model, crate::config::ZAI_GLM_5_2_MODEL);
14830
14831 let mut explicit_provider = custom_exec_config("custom-a");
14832 apply_exec_provider_override(&mut explicit_provider, "custom-b").expect("custom B");
14833 let model = resolve_exec_resume_route(&mut explicit_provider, &saved, true, None)
14834 .expect("explicit provider wins");
14835 assert_eq!(explicit_provider.provider.as_deref(), Some("custom-b"));
14836 assert_eq!(model, "model-b");
14837
14838 let mut explicit_model = custom_exec_config("custom-b");
14839 let model =
14840 resolve_exec_resume_route(&mut explicit_model, &saved, false, Some("override-model"))
14841 .expect("explicit model keeps saved provider");
14842 assert_eq!(explicit_model.provider.as_deref(), Some("custom-a"));
14843 assert_eq!(model, "override-model");
14844
14845 let mut missing = custom_exec_config("custom-b");
14846 missing
14847 .providers
14848 .as_mut()
14849 .expect("providers")
14850 .custom
14851 .remove("custom-a");
14852 let before = missing.provider.clone();
14853 let err = resolve_exec_resume_route(&mut missing, &saved, false, None)
14854 .expect_err("removed saved provider must fail closed");
14855 assert!(err.to_string().contains("will not fall back"), "{err}");
14856 assert_eq!(missing.provider, before);
14857 }
14858
14859 #[test]
14860 fn exec_model_reads_wait_for_foreign_test_env_overrides_to_restore() {
14861 let (started_tx, started_rx) = std::sync::mpsc::channel();
14862 let (tx, rx) = std::sync::mpsc::channel();
14863
14864 let (reader, expected_after_restore) = {
14865 let lock = crate::test_support::lock_test_env();
14866 let expected_after_restore = exec_model_env_override();
14867 let temporary =
14868 crate::test_support::EnvVarGuard::set("CODEWHALE_MODEL", "temporary-model");
14869 let reader = std::thread::spawn(move || {
14870 started_tx.send(()).expect("signal model read start");
14871 tx.send(exec_model_env_override())
14872 .expect("send resolved model override");
14873 });
14874
14875 started_rx
14876 .recv_timeout(std::time::Duration::from_secs(2))
14877 .expect("reader reached model read");
14878 assert!(
14879 rx.recv_timeout(std::time::Duration::from_millis(50))
14880 .is_err(),
14881 "a foreign reader observed another test's temporary model override"
14882 );
14883 drop(temporary);
14884 drop(lock);
14885 (reader, expected_after_restore)
14886 };
14887
14888 let observed = rx
14889 .recv_timeout(std::time::Duration::from_secs(2))
14890 .expect("reader resumed after model override was restored");
14891 reader.join().expect("reader thread");
14892 assert_eq!(observed, expected_after_restore);
14893 }
14894
14895 #[tokio::test]
14896 async fn forced_exec_route_keeps_custom_provider_when_model_matches_builtin_catalog() {
14897 let config = custom_exec_config("custom-a");
14898
14899 let route =
14900 resolve_cli_exec_route(&config, crate::config::ZAI_GLM_5_2_MODEL, "audit", true)
14901 .await
14902 .expect("forced route");
14903 let execution = config_for_cli_route(&config, &route);
14904
14905 assert_eq!(route.provider, crate::config::ApiProvider::Custom);
14906 assert_eq!(route.model, crate::config::ZAI_GLM_5_2_MODEL);
14907 assert_eq!(execution.provider.as_deref(), Some("custom-a"));
14908 }
14909
14910 #[tokio::test]
14911 async fn no_flag_exec_keeps_configured_named_custom_route_for_matching_builtin_model() {
14912 let mut config = custom_exec_config("custom-a");
14913 config
14914 .providers
14915 .as_mut()
14916 .expect("providers")
14917 .custom
14918 .get_mut("custom-a")
14919 .expect("custom A")
14920 .model = Some(crate::config::ZAI_GLM_5_2_MODEL.to_string());
14921 let model = resolve_exec_model(&config, None);
14922 let force = should_force_configured_exec_route(false, None, None);
14923
14924 assert!(force, "configured/default exec route must be authoritative");
14925 assert!(!should_force_configured_exec_route(
14926 false,
14927 None,
14928 Some(crate::config::ZAI_GLM_5_2_MODEL)
14929 ));
14930 assert!(should_force_configured_exec_route(
14931 false,
14932 Some("custom-a"),
14933 Some(crate::config::ZAI_GLM_5_2_MODEL)
14934 ));
14935 assert!(should_force_configured_exec_route(
14936 true,
14937 None,
14938 Some("override-model")
14939 ));
14940
14941 let route = resolve_cli_exec_route(&config, &model, "audit", force)
14942 .await
14943 .expect("no-flag configured route");
14944 let execution = config_for_cli_route(&config, &route);
14945 assert_eq!(route.provider, crate::config::ApiProvider::Custom);
14946 assert_eq!(route.model, crate::config::ZAI_GLM_5_2_MODEL);
14947 assert_eq!(execution.provider.as_deref(), Some("custom-a"));
14948 }
14949
14950 #[tokio::test]
14951 async fn configured_review_default_keeps_named_custom_route_and_exact_receipt() {
14952 let mut config = custom_exec_config("custom-a");
14953 config
14954 .providers
14955 .as_mut()
14956 .expect("providers")
14957 .custom
14958 .get_mut("custom-a")
14959 .expect("custom A")
14960 .model = Some("model-a".to_string());
14961 config.default_text_model = Some("stale-root-deepseek-model".to_string());
14962 let model = resolve_review_model(&config, None);
14963 assert_eq!(model, "model-a");
14964 assert_eq!(
14965 resolve_review_model(&config, Some("explicit-review-model")),
14966 "explicit-review-model"
14967 );
14968
14969 let route = resolve_cli_exec_route(&config, &model, "review diff", true)
14970 .await
14971 .expect("configured review route");
14972 let execution = config_for_cli_route(&config, &route);
14973 let provider = execution.provider_identity_for(route.provider);
14974
14975 assert_eq!(route.provider, crate::config::ApiProvider::Custom);
14976 assert_eq!(provider, "custom-a");
14977 assert_eq!(execution.deepseek_base_url(), "http://127.0.0.1:18181/v1");
14978 let output = crate::tools::review::ReviewOutput::from_str("{}");
14979 let receipt = crate::tools::review::build_review_receipt(
14980 "working tree",
14981 "diff --git a/a b/a",
14982 provider,
14983 &route.model,
14984 &output,
14985 "{}",
14986 Vec::new(),
14987 );
14988 assert_eq!(receipt.provider, "custom-a");
14989 let serialized = serde_json::to_string(&receipt).expect("review receipt");
14990 assert!(!serialized.contains("127.0.0.1"));
14991 assert!(!serialized.contains("local-test-key"));
14992 }
14993
14994 #[tokio::test]
14995 async fn configured_workflow_default_keeps_named_custom_route() {
14996 let config = custom_exec_config("custom-a");
14997 let model = config.default_model();
14998
14999 let route = resolve_cli_exec_route(
15000 &config,
15001 &model,
15002 "Run a checked-in Workflow through the host runtime",
15003 true,
15004 )
15005 .await
15006 .expect("configured workflow route");
15007 let execution = config_for_cli_route(&config, &route);
15008
15009 assert_eq!(route.provider, crate::config::ApiProvider::Custom);
15010 assert_eq!(execution.provider_identity_for(route.provider), "custom-a");
15011 assert_eq!(execution.deepseek_base_url(), "http://127.0.0.1:18181/v1");
15012 let client = crate::client::DeepSeekClient::new(&execution).expect("workflow client");
15013 assert_eq!(client.base_url(), "http://127.0.0.1:18181/v1");
15014 }
15015
15016 #[test]
15017 fn exec_json_receipts_keep_exact_named_custom_provider() {
15018 let config = custom_exec_config("custom-a");
15019 let provider = config.provider_identity_for(crate::config::ApiProvider::Custom);
15020 let one_shot = one_shot_exec_json_receipt(
15021 provider.clone(),
15022 "model-a".to_string(),
15023 "done".to_string(),
15024 Some("end_turn".to_string()),
15025 crate::models::Usage {
15026 input_tokens: 12,
15027 output_tokens: 3,
15028 ..Default::default()
15029 },
15030 );
15031 assert_eq!(one_shot["provider"], "custom-a");
15032 assert_eq!(one_shot["success"], true);
15033
15034 let truncated = one_shot_exec_json_receipt(
15035 provider.clone(),
15036 "model-a".to_string(),
15037 "partial".to_string(),
15038 Some("max_output_tokens".to_string()),
15039 crate::models::Usage {
15040 input_tokens: 20,
15041 output_tokens: 9,
15042 ..Default::default()
15043 },
15044 );
15045 assert_eq!(truncated["success"], false);
15046 assert_eq!(truncated["stop_reason"], "max_output_tokens");
15047 assert_eq!(truncated["usage"]["input_tokens"], 20);
15048 assert_eq!(truncated["usage"]["output_tokens"], 9);
15049 assert!(truncated["error"].as_str().is_some_and(|error| {
15050 error.contains("Model response incomplete") && error.contains("max_output_tokens")
15051 }));
15052
15053 let agent = serde_json::to_value(ExecSummary {
15054 mode: "agent".to_string(),
15055 provider,
15056 model: "model-a".to_string(),
15057 ..ExecSummary::default()
15058 })
15059 .expect("agent exec JSON receipt");
15060 assert_eq!(agent["provider"], "custom-a");
15061 let serialized = serde_json::to_string(&agent).expect("serialize receipt");
15062 assert!(!serialized.contains("127.0.0.1"));
15063 assert!(!serialized.contains("local-test-key"));
15064 }
15065
15066 #[test]
15067 fn exec_stream_provider_pair_preserves_named_literal_and_root_custom_provenance() {
15068 let named = crate::config::ProviderIdentity {
15069 provider: crate::config::ApiProvider::Custom,
15070 key: "lm-studio".to_string(),
15071 exact_id: Some("lm-studio".to_string()),
15072 migrated_legacy_ollama_cloud_route: false,
15073 };
15074 let literal = crate::config::ProviderIdentity {
15075 provider: crate::config::ApiProvider::Custom,
15076 key: "custom".to_string(),
15077 exact_id: Some("custom".to_string()),
15078 migrated_legacy_ollama_cloud_route: false,
15079 };
15080 let root = crate::config::ProviderIdentity {
15081 provider: crate::config::ApiProvider::Custom,
15082 key: "custom".to_string(),
15083 exact_id: None,
15084 migrated_legacy_ollama_cloud_route: false,
15085 };
15086 let built_in = crate::config::ProviderIdentity {
15087 provider: crate::config::ApiProvider::Deepseek,
15088 key: "deepseek".to_string(),
15089 exact_id: Some("deepseek".to_string()),
15090 migrated_legacy_ollama_cloud_route: false,
15091 };
15092
15093 assert_eq!(
15094 exec_stream_provider_route(&named),
15095 ("custom".to_string(), Some("lm-studio".to_string()))
15096 );
15097 assert_eq!(
15098 exec_stream_provider_route(&literal),
15099 ("custom".to_string(), Some("custom".to_string()))
15100 );
15101 assert_eq!(
15102 exec_stream_provider_route(&root),
15103 ("custom".to_string(), None)
15104 );
15105 assert_eq!(
15106 exec_stream_provider_route(&built_in),
15107 ("deepseek".to_string(), None)
15108 );
15109 }
15110
15111 #[test]
15112 fn resumed_exec_persistence_updates_provider_and_model_as_one_route() {
15113 let saved_a = saved_exec_session("custom-a", crate::config::ZAI_GLM_5_2_MODEL);
15114 let mut config = custom_exec_config("custom-a");
15115 apply_exec_provider_override(&mut config, "custom-b").expect("custom B");
15116 let model = resolve_exec_resume_route(&mut config, &saved_a, true, None)
15117 .expect("explicit provider route");
15118 let mut persisted = saved_a;
15119 stamp_exec_session_metadata(
15120 &mut persisted,
15121 &model,
15122 crate::config::ApiProvider::Custom.as_str(),
15123 Some("custom-b"),
15124 Path::new("/tmp/exec-resume"),
15125 );
15126
15127 let mut next_config = custom_exec_config("custom-a");
15128 let resumed_model = resolve_exec_resume_route(&mut next_config, &persisted, false, None)
15129 .expect("next plain resume");
15130
15131 assert_eq!(persisted.metadata.model_provider, "custom");
15132 assert_eq!(
15133 persisted.metadata.model_provider_id.as_deref(),
15134 Some("custom-b")
15135 );
15136 assert_eq!(persisted.metadata.model, "model-b");
15137 assert_eq!(next_config.provider.as_deref(), Some("custom-b"));
15138 assert_eq!(resumed_model, "model-b");
15139 }
15140
15141 #[test]
15142 fn exec_persistence_omits_id_for_legacy_root_custom_route() {
15143 let mut saved = session_manager::create_saved_session_with_mode(
15144 &[],
15145 "legacy-root-model",
15146 Path::new("/tmp/exec-root"),
15147 0,
15148 None,
15149 Some("exec"),
15150 );
15151 stamp_exec_session_metadata(
15152 &mut saved,
15153 "legacy-root-model",
15154 crate::config::ApiProvider::Custom.as_str(),
15155 None,
15156 Path::new("/tmp/exec-root"),
15157 );
15158
15159 assert_eq!(saved.metadata.model_provider, "custom");
15160 assert_eq!(saved.metadata.model_provider_id, None);
15161 assert!(
15162 !serde_json::to_string(&saved)
15163 .expect("serialize exec session")
15164 .contains("model_provider_id")
15165 );
15166 }
15167
15168 #[test]
15169 fn exec_parses_reasoning_effort_flag_alongside_provider() {
15170 let cli = parse_cli(&[
15171 "codewhale",
15172 "exec",
15173 "--provider",
15174 "openrouter",
15175 "--model",
15176 "glm-5.2",
15177 "--reasoning-effort",
15178 "max",
15179 "audit",
15180 ]);
15181 let Some(Commands::Exec(args)) = cli.command else {
15182 panic!("expected exec command");
15183 };
15184
15185 assert_eq!(args.provider.as_deref(), Some("openrouter"));
15186 assert_eq!(args.model.as_deref(), Some("glm-5.2"));
15187 assert_eq!(args.reasoning_effort.as_deref(), Some("max"));
15188 assert_eq!(args.prompt, vec!["audit"]);
15189 }
15190
15191 #[test]
15192 fn cli_reasoning_effort_normalizes_aliases_and_rejects_typos() {
15193 assert_eq!(
15196 normalize_cli_reasoning_effort("xhigh").unwrap().as_deref(),
15197 Some("xhigh")
15198 );
15199 assert_eq!(
15200 normalize_cli_reasoning_effort("ultracode")
15201 .unwrap()
15202 .as_deref(),
15203 Some("ultra")
15204 );
15205 assert_eq!(normalize_cli_reasoning_effort("default").unwrap(), None);
15206 assert!(normalize_cli_reasoning_effort("expensive").is_err());
15207 }
15208
15209 #[test]
15210 fn cli_prompt_paths_resolve_auto_before_k3_route_normalization() {
15211 let config = Config {
15212 provider: Some("moonshot".to_string()),
15213 providers: Some(crate::config::ProvidersConfig {
15214 moonshot: crate::config::ProviderConfig {
15215 base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
15216 model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
15217 ..Default::default()
15218 },
15219 ..Default::default()
15220 }),
15221 ..Default::default()
15222 };
15223
15224 for (prompt, expected) in [
15225 ("lookup the public docs", "low"),
15226 ("debug this error", "max"),
15227 ("review this ordinary change", "high"),
15228 ] {
15229 assert_eq!(
15230 cli_reasoning_effort_value_for_prompt(
15231 &config,
15232 crate::config::KIMI_CODE_K3_MODEL,
15233 crate::tui::app::ReasoningEffort::Auto,
15234 prompt,
15235 )
15236 .as_deref(),
15237 Some(expected),
15238 "prompt selector must resolve Auto for `{prompt}`"
15239 );
15240 }
15241
15242 assert_eq!(
15243 cli_reasoning_effort_value_for_prompt(
15244 &config,
15245 crate::config::KIMI_CODE_K3_MODEL,
15246 crate::tui::app::ReasoningEffort::Off,
15247 "debug must not override an explicit effort",
15248 )
15249 .as_deref(),
15250 Some("low"),
15251 "membership K3 still applies its exact-route always-thinking floor"
15252 );
15253 }
15254
15255 #[test]
15256 fn cli_route_tracks_auto_reasoning_independently_from_auto_model() {
15257 use crate::tui::app::ReasoningEffort;
15258
15259 let fixed_model_auto_reasoning = CliAutoRoute {
15260 provider: crate::config::ApiProvider::Deepseek,
15261 model: crate::config::DEFAULT_TEXT_MODEL.to_string(),
15262 reasoning_effort: Some(ReasoningEffort::Auto),
15263 auto_controls_reasoning: true,
15264 auto_model: false,
15265 };
15266 let auto_model_fixed_reasoning = CliAutoRoute {
15267 provider: crate::config::ApiProvider::OpenaiCodex,
15268 model: crate::config::DEFAULT_OPENAI_CODEX_MODEL.to_string(),
15269 reasoning_effort: Some(ReasoningEffort::High),
15270 auto_controls_reasoning: false,
15271 auto_model: true,
15272 };
15273
15274 assert!(fixed_model_auto_reasoning.auto_controls_reasoning);
15275 assert!(!fixed_model_auto_reasoning.auto_model);
15276 assert!(!auto_model_fixed_reasoning.auto_controls_reasoning);
15277 assert!(auto_model_fixed_reasoning.auto_model);
15278 }
15279
15280 #[test]
15281 fn saved_reasoning_preference_overrides_config_for_non_tui_runtimes() {
15282 let mut config = Config {
15283 reasoning_effort: Some("max".to_string()),
15284 reasoning_effort_inferred_from_legacy_alias: true,
15285 ..Default::default()
15286 };
15287 let settings = crate::settings::Settings {
15288 reasoning_effort: Some("low".to_string()),
15289 ..Default::default()
15290 };
15291
15292 apply_saved_reasoning_preference(&mut config, &settings);
15293
15294 assert_eq!(config.reasoning_effort(), Some("low"));
15295 assert!(config.reasoning_effort_is_explicit());
15296 }
15297
15298 #[test]
15301 fn fixed_model_exec_auto_resolves_to_a_concrete_tier_not_the_auto_sentinel() {
15302 let config = Config {
15303 provider: Some("zai".to_string()),
15304 ..Default::default()
15305 };
15306
15307 let resolved = cli_reasoning_effort_value_for_prompt(
15308 &config,
15309 crate::config::ZAI_GLM_5_2_MODEL,
15310 crate::tui::app::ReasoningEffort::Auto,
15311 "debug this failing integration test",
15312 )
15313 .expect("Auto must resolve to a concrete tier");
15314
15315 assert_ne!(
15316 resolved, "auto",
15317 "the literal auto sentinel must never reach a provider"
15318 );
15319 assert!(
15320 matches!(resolved.as_str(), "off" | "low" | "medium" | "high" | "max"),
15321 "unexpected resolved tier: {resolved}"
15322 );
15323 }
15324
15325 #[test]
15326 fn exec_accepts_resume_session_flags_for_harnesses() {
15327 let cli = parse_cli(&[
15328 "codewhale",
15329 "exec",
15330 "--resume",
15331 "abc123",
15332 "--output-format",
15333 "stream-json",
15334 "follow up",
15335 ]);
15336 let Some(Commands::Exec(args)) = cli.command else {
15337 panic!("expected exec command");
15338 };
15339
15340 assert_eq!(args.resume.as_deref(), Some("abc123"));
15341 assert_eq!(args.output_format, ExecOutputFormat::StreamJson);
15342 assert_eq!(args.prompt, vec!["follow up"]);
15343 }
15344
15345 #[test]
15346 fn exec_accepts_session_id_alias() {
15347 let cli = parse_cli(&["codewhale", "exec", "--session-id", "abc123", "follow up"]);
15348 let Some(Commands::Exec(args)) = cli.command else {
15349 panic!("expected exec command");
15350 };
15351
15352 assert_eq!(args.session_id.as_deref(), Some("abc123"));
15353 assert_eq!(args.output_format, ExecOutputFormat::Text);
15354 }
15355
15356 #[test]
15357 fn exec_parses_tool_gate_and_hardening_flags() {
15358 let envelope = r#"{"schema_version":1,"owner":"fleet-worker-1","authority":"read_only"}"#;
15359 let cli = parse_cli(&[
15360 "codewhale",
15361 "exec",
15362 "--allowed-tools",
15363 "File,Git",
15364 "--disallowed-tools",
15365 "Bash",
15366 "--max-turns",
15367 "7",
15368 "--append-system-prompt",
15369 "extra rules",
15370 "--tool-authority-json",
15371 envelope,
15372 "do the thing",
15373 ]);
15374 let Some(Commands::Exec(args)) = cli.command else {
15375 panic!("expected exec command");
15376 };
15377
15378 assert_eq!(
15379 args.allowed_tools.as_deref(),
15380 Some(&["File".to_string(), "Git".to_string()][..])
15381 );
15382 assert_eq!(
15383 args.disallowed_tools.as_deref(),
15384 Some(&["Bash".to_string()][..])
15385 );
15386 assert_eq!(args.max_turns, Some(7));
15387 assert_eq!(args.append_system_prompt.as_deref(), Some("extra rules"));
15388 assert_eq!(args.tool_authority_json.as_deref(), Some(envelope));
15389 assert_eq!(args.prompt, vec!["do the thing"]);
15390 }
15391
15392 #[test]
15393 fn fleet_tool_authority_cannot_cross_an_exec_resume_boundary() {
15394 assert!(validate_exec_tool_authority_resume(None, true).is_ok());
15395 assert!(validate_exec_tool_authority_resume(Some("{}"), false).is_ok());
15396 let error = validate_exec_tool_authority_resume(Some("{}"), true)
15397 .expect_err("authority must remain bound to its fresh Fleet launch")
15398 .to_string();
15399 assert!(error.contains("cannot be combined with exec --resume"));
15400 }
15401
15402 #[test]
15403 fn exec_auto_does_not_authorize_sandbox_elevation() {
15404 let cli = parse_cli(&["codewhale", "exec", "--auto", "run it"]);
15405 let Some(Commands::Exec(args)) = cli.command else {
15406 panic!("expected exec command");
15407 };
15408
15409 assert!(!exec_sandbox_elevation_authorized(
15410 args.allow_sandbox_elevation,
15411 args.sandbox.as_deref()
15412 ));
15413 }
15414
15415 #[test]
15416 fn exec_explicit_sandbox_elevation_opt_ins_authorize_retry() {
15417 let danger = parse_cli(&[
15418 "codewhale",
15419 "exec",
15420 "--auto",
15421 "--sandbox",
15422 "danger-full-access",
15423 "run it",
15424 ]);
15425 let Some(Commands::Exec(args)) = danger.command else {
15426 panic!("expected exec command");
15427 };
15428 assert!(exec_sandbox_elevation_authorized(
15429 args.allow_sandbox_elevation,
15430 args.sandbox.as_deref()
15431 ));
15432
15433 let flag = parse_cli(&[
15434 "codewhale",
15435 "exec",
15436 "--auto",
15437 "--allow-sandbox-elevation",
15438 "run it",
15439 ]);
15440 let Some(Commands::Exec(args)) = flag.command else {
15441 panic!("expected exec command");
15442 };
15443 assert!(exec_sandbox_elevation_authorized(
15444 args.allow_sandbox_elevation,
15445 args.sandbox.as_deref()
15446 ));
15447 }
15448
15449 #[test]
15450 fn exec_sandbox_denial_stream_event_is_typed() {
15451 let event = ExecStreamEvent::SandboxDenied {
15452 tool_id: "call_1".to_string(),
15453 tool_name: "exec_shell".to_string(),
15454 reason: "write blocked".to_string(),
15455 outcome: "approval_required".to_string(),
15456 };
15457 let value: serde_json::Value =
15458 serde_json::from_str(&serde_json::to_string(&event).expect("serializes"))
15459 .expect("valid json");
15460 assert_eq!(value["type"], "sandbox_denied");
15461 assert_eq!(value["outcome"], "approval_required");
15462 }
15463
15464 #[test]
15465 fn exec_help_separates_agent_mode_from_sandbox_elevation() {
15466 let mut cli = Cli::command();
15467 let help = cli
15468 .find_subcommand_mut("exec")
15469 .expect("exec command")
15470 .render_help()
15471 .to_string();
15472 assert!(help.contains("--auto"));
15473 assert!(help.contains("--sandbox"));
15474 assert!(help.contains("--allow-sandbox-elevation"));
15475 assert!(help.contains("does not change the"));
15476 assert!(help.contains("explicitly authorize sandbox elevation"));
15477 }
15478
15479 #[test]
15480 fn exec_shell_only_tool_surface_env_sets_shell_allowlist() {
15481 let _env_lock = crate::test_support::lock_test_env();
15482 let _surface =
15483 crate::test_support::EnvVarGuard::set(CODEWHALE_TOOL_SURFACE_ENV, " shell-only ");
15484
15485 let allowed_tools = resolve_exec_allowed_tools(None, exec_tool_surface_from_env())
15486 .expect("shell-only surface should set an allowlist");
15487
15488 assert_eq!(allowed_tools, vec!["bash".to_string()]);
15489 }
15490
15491 #[test]
15492 fn exec_explicit_allowed_tools_override_shell_only_env() {
15493 let _env_lock = crate::test_support::lock_test_env();
15494 let _surface =
15495 crate::test_support::EnvVarGuard::set(CODEWHALE_TOOL_SURFACE_ENV, "shell-only");
15496 let explicit = vec![" File ".to_string(), "GIT".to_string()];
15497
15498 let allowed_tools =
15499 resolve_exec_allowed_tools(Some(&explicit), exec_tool_surface_from_env())
15500 .expect("explicit allowlist should be preserved");
15501
15502 assert_eq!(allowed_tools, vec!["file".to_string(), "git".to_string()]);
15503 }
15504
15505 #[test]
15506 fn exec_full_tool_surface_env_leaves_allowlist_unset() {
15507 let _env_lock = crate::test_support::lock_test_env();
15508 let _surface = crate::test_support::EnvVarGuard::set(CODEWHALE_TOOL_SURFACE_ENV, "full");
15509
15510 assert_eq!(
15511 resolve_exec_allowed_tools(None, exec_tool_surface_from_env()),
15512 None
15513 );
15514 }
15515
15516 #[test]
15517 fn exec_unknown_tool_surface_env_warns_without_allowlist() {
15518 assert!(should_warn_unknown_exec_tool_surface("shell_onyl"));
15519 assert!(!should_warn_unknown_exec_tool_surface("shell-only"));
15520 assert!(!should_warn_unknown_exec_tool_surface("native-tools"));
15521 assert!(!should_warn_unknown_exec_tool_surface("full"));
15522 assert!(!should_warn_unknown_exec_tool_surface(" "));
15523 assert_eq!(parse_exec_tool_surface("shell_onyl"), None);
15524 }
15525
15526 #[test]
15527 fn exec_rejects_zero_max_turns() {
15528 let err = Cli::try_parse_from(["codewhale", "exec", "--max-turns", "0", "hello"])
15529 .expect_err("max-turns must be >= 1");
15530 assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation);
15531 }
15532
15533 #[test]
15534 fn exec_omits_the_headless_turn_cap_by_default() {
15535 let cli = parse_cli(&["codewhale", "exec", "--auto", "benchmark this"]);
15536 let Some(Commands::Exec(args)) = cli.command else {
15537 panic!("expected exec command");
15538 };
15539
15540 assert_eq!(args.max_turns, None);
15541 assert_eq!(exec_max_steps(args.max_turns), u32::MAX);
15542 assert_eq!(exec_max_steps(Some(7)), 7);
15543 }
15544
15545 #[test]
15546 fn exec_accepts_continue_for_latest_workspace_session() {
15547 let cli = parse_cli(&["codewhale", "exec", "--continue", "follow up"]);
15548 let Some(Commands::Exec(args)) = cli.command else {
15549 panic!("expected exec command");
15550 };
15551
15552 assert!(args.continue_session);
15553 }
15554
15555 #[test]
15556 fn sessions_footer_points_to_resume_subcommand() {
15557 let cli = parse_cli(&["codewhale", "resume", "abc123"]);
15558 let Some(Commands::Resume { session_id, last }) = cli.command else {
15559 panic!("expected resume command");
15560 };
15561
15562 assert_eq!(session_id.as_deref(), Some("abc123"));
15563 assert!(!last);
15564 assert_eq!(sessions_resume_command(), "codewhale resume");
15565 assert!(!sessions_resume_command().contains("--resume"));
15566 }
15567
15568 #[test]
15569 fn plugin_registry_initialization_precedes_dotenv_for_all_launch_paths() {
15570 use std::cell::Cell;
15571
15572 #[derive(Clone, Copy)]
15573 enum Expected {
15574 Plain,
15575 Resume,
15576 Fork,
15577 Exec,
15578 Serve,
15579 }
15580
15581 let cases: &[(&[&str], Expected)] = &[
15582 (&["codewhale"], Expected::Plain),
15583 (&["codewhale", "resume", "--last"], Expected::Resume),
15584 (&["codewhale", "fork", "--last"], Expected::Fork),
15585 (&["codewhale", "exec", "probe"], Expected::Exec),
15586 (&["codewhale", "serve", "--mcp"], Expected::Serve),
15587 ];
15588
15589 for (args, expected) in cases {
15590 let phase = Cell::new(0);
15591 let (_cli, command) = prepare_cli_startup(
15592 parse_cli(args),
15593 || {
15594 assert_eq!(phase.get(), 0, "plugin init order for {args:?}");
15595 phase.set(1);
15596 },
15597 || {
15598 assert_eq!(phase.get(), 1, "dotenv load order for {args:?}");
15599 phase.set(2);
15600 },
15601 );
15602
15603 assert_eq!(phase.get(), 2, "startup phases for {args:?}");
15604 let correct_variant = matches!(
15605 (expected, command.as_ref()),
15606 (Expected::Plain, None)
15607 | (Expected::Resume, Some(Commands::Resume { .. }))
15608 | (Expected::Fork, Some(Commands::Fork { .. }))
15609 | (Expected::Exec, Some(Commands::Exec(_)))
15610 | (Expected::Serve, Some(Commands::Serve(_)))
15611 );
15612 assert!(correct_variant, "unexpected command for {args:?}");
15613 }
15614 }
15615
15616 #[test]
15617 fn workspace_dotenv_loads_only_provider_credentials_and_preserves_shell_values() {
15618 let _lock = crate::test_support::lock_test_env();
15619 let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
15620 let _nvidia = crate::test_support::EnvVarGuard::set("NVIDIA_API_KEY", "shell-key");
15621 let _home = crate::test_support::EnvVarGuard::remove("CODEWHALE_HOME");
15622 let _config = crate::test_support::EnvVarGuard::remove("CODEWHALE_CONFIG_PATH");
15623 let _shell = crate::test_support::EnvVarGuard::remove("DEEPSEEK_ALLOW_SHELL");
15624 let tmp = tempfile::TempDir::new().expect("temp workspace");
15625 let dotenv = tmp.path().join(".env");
15626 std::fs::write(
15627 &dotenv,
15628 "DEEPSEEK_API_KEY=workspace-key\n\
15629 NVIDIA_API_KEY=repo-must-not-override-shell\n\
15630 CODEWHALE_HOME=./attacker-home\n\
15631 CODEWHALE_CONFIG_PATH=./attacker.toml\n\
15632 DEEPSEEK_ALLOW_SHELL=true\n",
15633 )
15634 .expect("write dotenv");
15635
15636 let report = load_workspace_dotenv_credentials_from_path(&dotenv).expect("safe load");
15637
15638 assert_eq!(
15639 std::env::var("DEEPSEEK_API_KEY").as_deref(),
15640 Ok("workspace-key")
15641 );
15642 assert_eq!(std::env::var("NVIDIA_API_KEY").as_deref(), Ok("shell-key"));
15643 assert!(std::env::var_os("CODEWHALE_HOME").is_none());
15644 assert!(std::env::var_os("CODEWHALE_CONFIG_PATH").is_none());
15645 assert!(std::env::var_os("DEEPSEEK_ALLOW_SHELL").is_none());
15646 assert_eq!(
15647 report.loaded,
15648 BTreeSet::from(["DEEPSEEK_API_KEY".to_string()])
15649 );
15650 assert_eq!(
15651 report.ignored,
15652 BTreeSet::from([
15653 "CODEWHALE_CONFIG_PATH".to_string(),
15654 "CODEWHALE_HOME".to_string(),
15655 "DEEPSEEK_ALLOW_SHELL".to_string(),
15656 ])
15657 );
15658 }
15659
15660 #[test]
15661 fn workspace_dotenv_rejects_ambient_variable_substitution() {
15662 let _lock = crate::test_support::lock_test_env();
15663 let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
15664 let _ambient = crate::test_support::EnvVarGuard::set(
15665 "CODEWHALE_JS_SECRET_LEAK_TEST",
15666 "ambient-secret-must-not-expand",
15667 );
15668 let tmp = tempfile::TempDir::new().expect("temp workspace");
15669 let dotenv = tmp.path().join(".env");
15670 std::fs::write(
15671 &dotenv,
15672 "DEEPSEEK_API_KEY=${CODEWHALE_JS_SECRET_LEAK_TEST}\n",
15673 )
15674 .expect("write dotenv");
15675
15676 let error = load_workspace_dotenv_credentials_from_path(&dotenv)
15677 .expect_err("expansion must fail closed")
15678 .to_string();
15679
15680 assert!(error.contains("variable expansion"));
15681 assert!(!error.contains("ambient-secret-must-not-expand"));
15682 assert!(std::env::var_os("DEEPSEEK_API_KEY").is_none());
15683 }
15684
15685 #[test]
15686 fn workspace_dotenv_rejects_multiline_ambient_variable_substitution() {
15687 let _lock = crate::test_support::lock_test_env();
15688 let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
15689 let _ambient = crate::test_support::EnvVarGuard::set(
15690 "CODEWHALE_JS_SECRET_LEAK_TEST",
15691 "ambient-secret-must-not-expand",
15692 );
15693 let tmp = tempfile::TempDir::new().expect("temp workspace");
15694 let dotenv = tmp.path().join(".env");
15695 std::fs::write(
15696 &dotenv,
15697 "DEEPSEEK_API_KEY=\"prefix\n$CODEWHALE_JS_SECRET_LEAK_TEST=bar\nsuffix\"\n",
15698 )
15699 .expect("write dotenv");
15700
15701 let error = load_workspace_dotenv_credentials_from_path(&dotenv)
15702 .expect_err("multiline expansion must fail closed")
15703 .to_string();
15704
15705 assert!(error.contains("variable expansion"));
15706 assert!(!error.contains("ambient-secret-must-not-expand"));
15707 assert!(std::env::var_os("DEEPSEEK_API_KEY").is_none());
15708 }
15709
15710 #[test]
15711 fn workspace_dotenv_comment_quote_cannot_hide_later_expansion() {
15712 let _lock = crate::test_support::lock_test_env();
15713 let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
15714 let _ambient = crate::test_support::EnvVarGuard::set(
15715 "CODEWHALE_JS_SECRET_LEAK_TEST",
15716 "ambient-secret-must-not-expand",
15717 );
15718 let tmp = tempfile::TempDir::new().expect("temp workspace");
15719 let dotenv = tmp.path().join(".env");
15720 std::fs::write(
15721 &dotenv,
15722 "# unmatched quote in ignored comment: '\n\
15723 DEEPSEEK_API_KEY=$CODEWHALE_JS_SECRET_LEAK_TEST\n",
15724 )
15725 .expect("write dotenv");
15726
15727 let error = load_workspace_dotenv_credentials_from_path(&dotenv)
15728 .expect_err("comment quote must not hide expansion")
15729 .to_string();
15730
15731 assert!(error.contains("variable expansion"));
15732 assert!(!error.contains("ambient-secret-must-not-expand"));
15733 assert!(std::env::var_os("DEEPSEEK_API_KEY").is_none());
15734 }
15735
15736 #[test]
15737 fn workspace_dotenv_allows_single_quoted_literal_dollar() {
15738 let _lock = crate::test_support::lock_test_env();
15739 let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
15740 let tmp = tempfile::TempDir::new().expect("temp workspace");
15741 let dotenv = tmp.path().join(".env");
15742 std::fs::write(&dotenv, "DEEPSEEK_API_KEY='$literal-value'\n").expect("write dotenv");
15743
15744 load_workspace_dotenv_credentials_from_path(&dotenv).expect("literal dollar load");
15745
15746 assert_eq!(
15747 std::env::var("DEEPSEEK_API_KEY").as_deref(),
15748 Ok("$literal-value")
15749 );
15750 }
15751
15752 #[test]
15753 fn workspace_dotenv_parse_failure_applies_no_earlier_credentials() {
15754 let _lock = crate::test_support::lock_test_env();
15755 let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
15756 let tmp = tempfile::TempDir::new().expect("temp workspace");
15757 let dotenv = tmp.path().join(".env");
15758 std::fs::write(
15759 &dotenv,
15760 "DEEPSEEK_API_KEY=must-not-survive\nBROKEN=\"unterminated\n",
15761 )
15762 .expect("write dotenv");
15763
15764 let error = load_workspace_dotenv_credentials_from_path(&dotenv)
15765 .expect_err("parse failure must be transactional")
15766 .to_string();
15767
15768 assert!(error.contains("could not be parsed safely"), "{error}");
15769 assert!(!error.contains("must-not-survive"));
15770 assert!(std::env::var_os("DEEPSEEK_API_KEY").is_none());
15771 }
15772
15773 #[test]
15774 fn workspace_dotenv_credential_allowlist_excludes_control_plane_names() {
15775 for provider in codewhale_config::provider::providers_sorted_for_display() {
15776 for key in provider.env_vars() {
15777 assert!(
15778 is_workspace_dotenv_credential_key(key),
15779 "provider credential {key} must remain supported"
15780 );
15781 }
15782 }
15783 for key in [
15784 "CODEWHALE_HOME",
15785 "CODEWHALE_CONFIG_PATH",
15786 "DEEPSEEK_CONFIG_PATH",
15787 "DEEPSEEK_PROFILE",
15788 "DEEPSEEK_MANAGED_CONFIG_PATH",
15789 "DEEPSEEK_REQUIREMENTS_PATH",
15790 "DEEPSEEK_PROVIDER",
15791 "DEEPSEEK_BASE_URL",
15792 "DEEPSEEK_MODEL",
15793 "DEEPSEEK_APPROVAL_POLICY",
15794 "DEEPSEEK_SANDBOX_MODE",
15795 "DEEPSEEK_ALLOW_SHELL",
15796 "DEEPSEEK_YOLO",
15797 "DEEPSEEK_MCP_CONFIG",
15798 "CODEWHALE_RUNTIME_TOKEN",
15799 "PATH",
15800 "NODE_OPTIONS",
15801 "PYTHONPATH",
15802 "LD_PRELOAD",
15803 "DYLD_INSERT_LIBRARIES",
15804 ] {
15805 assert!(
15806 !is_workspace_dotenv_credential_key(key),
15807 "control-plane variable {key} must not load from a workspace"
15808 );
15809 }
15810 }
15811
15812 #[cfg(unix)]
15813 #[test]
15814 fn workspace_dotenv_does_not_follow_symbolic_links() {
15815 use std::os::unix::fs::symlink;
15816
15817 let tmp = tempfile::TempDir::new().expect("temp workspace");
15818 let external = tmp.path().join("external-credentials");
15819 let dotenv = tmp.path().join(".env");
15820 std::fs::write(&external, "DEEPSEEK_API_KEY=external-secret\n")
15821 .expect("write external fixture");
15822 symlink(&external, &dotenv).expect("create dotenv symlink");
15823
15824 let error = load_workspace_dotenv_credentials_from_path(&dotenv)
15825 .expect_err("symlink must fail closed")
15826 .to_string();
15827
15828 assert!(error.contains("securely open"), "{error}");
15829 assert!(!error.contains("external-secret"));
15830 }
15831
15832 #[cfg(unix)]
15833 #[test]
15834 fn workspace_dotenv_rejects_hard_links_to_external_files() {
15835 let tmp = tempfile::TempDir::new().expect("temp workspace");
15836 let external = tmp.path().join("external-credentials");
15837 let dotenv = tmp.path().join(".env");
15838 std::fs::write(&external, "DEEPSEEK_API_KEY=external-secret\n")
15839 .expect("write external fixture");
15840 std::fs::hard_link(&external, &dotenv).expect("create dotenv hard link");
15841
15842 let error = load_workspace_dotenv_credentials_from_path(&dotenv)
15843 .expect_err("hard link must fail closed")
15844 .to_string();
15845
15846 assert!(error.contains("multiple filesystem links"), "{error}");
15847 assert!(!error.contains("external-secret"));
15848 }
15849
15850 #[cfg(unix)]
15851 #[test]
15852 fn workspace_dotenv_rejects_fifo_without_blocking_startup() {
15853 use std::ffi::CString;
15854 use std::os::unix::ffi::OsStrExt;
15855 use std::sync::mpsc;
15856 use std::time::Duration;
15857
15858 let tmp = tempfile::TempDir::new().expect("temp workspace");
15859 let dotenv = tmp.path().join(".env");
15860 let c_path = CString::new(dotenv.as_os_str().as_bytes()).expect("fifo path");
15861 let result = unsafe { libc::mkfifo(c_path.as_ptr(), libc::S_IRUSR | libc::S_IWUSR) };
15864 assert_eq!(result, 0, "mkfifo failed: {}", io::Error::last_os_error());
15865
15866 let (tx, rx) = mpsc::channel();
15867 let worker_path = dotenv.clone();
15868 let worker = std::thread::spawn(move || {
15869 let result = load_workspace_dotenv_credentials_from_path(&worker_path)
15870 .map(|_| "unexpected success".to_string())
15871 .unwrap_or_else(|error| error.to_string());
15872 tx.send(result).expect("send loader result");
15873 });
15874
15875 let error = match rx.recv_timeout(Duration::from_secs(1)) {
15876 Ok(error) => error,
15877 Err(timeout) => {
15878 let _writer = std::fs::OpenOptions::new()
15881 .write(true)
15882 .open(&dotenv)
15883 .expect("open fifo writer to release blocked reader");
15884 let _ = rx.recv_timeout(Duration::from_secs(1));
15885 worker.join().expect("join released loader");
15886 panic!("workspace .env FIFO blocked startup: {timeout}");
15887 }
15888 };
15889 worker.join().expect("join loader");
15890
15891 assert!(error.contains("not a regular file"), "{error}");
15892 }
15893
15894 #[test]
15895 fn exec_json_conflicts_with_stream_json_output() {
15896 let err = Cli::try_parse_from([
15897 "codewhale",
15898 "exec",
15899 "--json",
15900 "--output-format",
15901 "stream-json",
15902 "hello",
15903 ])
15904 .expect_err("json summary and stream-json must not mix");
15905
15906 assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
15907 }
15908
15909 #[test]
15910 fn exec_stream_turn_usage_event_serializes_reported_fields() {
15911 let event = ExecStreamEvent::TurnUsage {
15912 turn: 2,
15913 input_tokens: 1200,
15914 output_tokens: 180,
15915 reasoning_tokens: Some(90),
15916 prompt_cache_hit_tokens: Some(900),
15917 prompt_cache_miss_tokens: Some(300),
15918 prompt_cache_write_tokens: Some(0),
15919 reasoning_replay_tokens: Some(40),
15920 duration_ms: 1834,
15921 };
15922
15923 let value = exec_stream_value(&event).expect("serializes");
15924 let json = serde_json::to_string(&value).expect("serializes");
15925 assert!(!json.contains('\n'));
15926 let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json");
15927 assert_eq!(parsed["type"], "turn_usage");
15928 assert_eq!(parsed["schema"], "codewhale.exec-stream");
15929 assert_eq!(parsed["schema_version"], 1);
15930 assert_eq!(parsed["turn"], 2);
15931 assert_eq!(parsed["input_tokens"], 1200);
15932 assert_eq!(parsed["output_tokens"], 180);
15933 assert_eq!(parsed["reasoning_tokens"], 90);
15934 assert_eq!(parsed["prompt_cache_hit_tokens"], 900);
15935 assert_eq!(parsed["prompt_cache_miss_tokens"], 300);
15936 assert_eq!(parsed["prompt_cache_write_tokens"], 0);
15937 assert_eq!(parsed["reasoning_replay_tokens"], 40);
15938 assert_eq!(parsed["duration_ms"], 1834);
15939 }
15940
15941 #[test]
15942 fn exec_stream_turn_usage_event_omits_unreported_fields() {
15943 let event = ExecStreamEvent::TurnUsage {
15947 turn: 1,
15948 input_tokens: 11,
15949 output_tokens: 3,
15950 reasoning_tokens: None,
15951 prompt_cache_hit_tokens: None,
15952 prompt_cache_miss_tokens: None,
15953 prompt_cache_write_tokens: None,
15954 reasoning_replay_tokens: None,
15955 duration_ms: 250,
15956 };
15957
15958 let value = exec_stream_value(&event).expect("serializes");
15959 let parsed = value;
15960 assert_eq!(parsed["type"], "turn_usage");
15961 assert_eq!(parsed["input_tokens"], 11);
15962 assert_eq!(parsed["output_tokens"], 3);
15963 assert_eq!(parsed["duration_ms"], 250);
15964 let object = parsed.as_object().expect("event object");
15965 for absent in [
15966 "reasoning_tokens",
15967 "prompt_cache_hit_tokens",
15968 "prompt_cache_miss_tokens",
15969 "prompt_cache_write_tokens",
15970 "reasoning_replay_tokens",
15971 ] {
15972 assert!(!object.contains_key(absent), "{absent} leaked: {parsed}");
15973 }
15974 }
15975
15976 #[test]
15977 fn exec_stream_pre_existing_event_type_tags_are_unchanged() {
15978 let cases: Vec<(ExecStreamEvent, &str)> = vec![
15981 (
15982 ExecStreamEvent::Content {
15983 content: "hi".to_string(),
15984 },
15985 "content",
15986 ),
15987 (
15988 ExecStreamEvent::ToolUse {
15989 name: "read_file".to_string(),
15990 id: "call_1".to_string(),
15991 input: serde_json::json!({}),
15992 started_at: "2026-08-03T00:00:00Z".to_string(),
15993 },
15994 "tool_use",
15995 ),
15996 (
15997 ExecStreamEvent::ToolResult {
15998 id: "call_1".to_string(),
15999 name: "read_file".to_string(),
16000 output: "ok".to_string(),
16001 status: "success".to_string(),
16002 started_at: "2026-08-03T00:00:00Z".to_string(),
16003 completed_at: "2026-08-03T00:00:01Z".to_string(),
16004 duration_ms: 1,
16005 side_effect_status: "unknown".to_string(),
16006 error_category: None,
16007 truncated: None,
16008 artifact: None,
16009 result_metadata: None,
16010 },
16011 "tool_result",
16012 ),
16013 (
16014 ExecStreamEvent::SandboxDenied {
16015 tool_id: "call_1".to_string(),
16016 tool_name: "exec_shell".to_string(),
16017 reason: "denied".to_string(),
16018 outcome: "approval_required".to_string(),
16019 },
16020 "sandbox_denied",
16021 ),
16022 (
16023 ExecStreamEvent::WorkflowEvent {
16024 run_id: "workflow_1".to_string(),
16025 event: serde_json::json!({"type": "task_completed"}),
16026 },
16027 "workflow_event",
16028 ),
16029 (
16030 ExecStreamEvent::SessionCapture {
16031 content: "x".to_string(),
16032 },
16033 "session_capture",
16034 ),
16035 (
16036 ExecStreamEvent::Error {
16037 error: "boom".to_string(),
16038 },
16039 "error",
16040 ),
16041 (ExecStreamEvent::Done, "done"),
16042 ];
16043
16044 for (event, expected_type) in cases {
16045 let value = exec_stream_value(&event).expect("serializes");
16046 assert_eq!(value["type"], expected_type, "event tag drifted");
16047 assert_eq!(value["schema"], "codewhale.exec-stream");
16048 assert_eq!(value["schema_version"], 1);
16049 }
16050 }
16051
16052 #[test]
16053 fn exec_stream_events_are_json_lines() {
16054 let event = ExecStreamEvent::ToolResult {
16055 id: "call_1".to_string(),
16056 name: "read_file".to_string(),
16057 output: "line 1\nline 2".to_string(),
16058 status: "success".to_string(),
16059 started_at: "2026-07-13T00:00:00Z".to_string(),
16060 completed_at: "2026-07-13T00:00:01Z".to_string(),
16061 duration_ms: 1000,
16062 side_effect_status: "not_started".to_string(),
16063 error_category: None,
16064 truncated: Some(false),
16065 artifact: None,
16066 result_metadata: None,
16067 };
16068
16069 let value = exec_stream_value(&event).expect("serializes");
16070 let json = serde_json::to_string(&value).expect("serializes");
16071 assert!(!json.contains('\n'));
16072 let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json");
16073 assert_eq!(parsed["type"], "tool_result");
16074 assert_eq!(parsed["schema"], "codewhale.exec-stream");
16075 assert_eq!(parsed["schema_version"], 1);
16076 assert_eq!(parsed["duration_ms"], 1000);
16077 assert_eq!(parsed["side_effect_status"], "not_started");
16078 }
16079
16080 #[test]
16081 fn workflow_receipt_stream_event_is_one_json_line() {
16082 let event = ExecStreamEvent::WorkflowEvent {
16083 run_id: "workflow_1234".to_string(),
16084 event: serde_json::json!({
16085 "type": "handoff_promoted",
16086 "artifact_id": "workflow_1234:agent_1:review-gate:review_report",
16087 "gate_id": "review-gate",
16088 "kind": "review_report",
16089 "from_role": "reviewer",
16090 "to_role": "verifier",
16091 "producer_task_id": "agent_1"
16092 }),
16093 };
16094
16095 let value = exec_stream_value(&event).expect("serializes");
16096 let json = serde_json::to_string(&value).expect("serializes");
16097 assert!(!json.contains('\n'));
16098 let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json");
16099 assert_eq!(parsed["type"], "workflow_event");
16100 assert_eq!(parsed["schema"], "codewhale.exec-stream");
16101 assert_eq!(parsed["schema_version"], 1);
16102 assert_eq!(parsed["run_id"], "workflow_1234");
16103 assert_eq!(parsed["event"]["type"], "handoff_promoted");
16104 assert_eq!(
16105 parsed["event"]["artifact_id"],
16106 "workflow_1234:agent_1:review-gate:review_report"
16107 );
16108 assert_eq!(parsed["event"]["gate_id"], "review-gate");
16109 assert_eq!(parsed["event"]["kind"], "review_report");
16110 assert_eq!(parsed["event"]["from_role"], "reviewer");
16111 assert_eq!(parsed["event"]["to_role"], "verifier");
16112 assert_eq!(parsed["event"]["producer_task_id"], "agent_1");
16113 assert!(parsed["event"].get("payload").is_none(), "{parsed}");
16114
16115 let consumed = ExecStreamEvent::WorkflowEvent {
16116 run_id: "workflow_1234".to_string(),
16117 event: serde_json::json!({
16118 "type": "handoff_consumed",
16119 "artifact_id": "workflow_1234:agent_1:review-gate:review_report",
16120 "kind": "review_report",
16121 "from_role": "reviewer",
16122 "to_role": "verifier",
16123 "consumer_task_id": "agent_2"
16124 }),
16125 };
16126 let consumed = exec_stream_value(&consumed).expect("serializes consumed receipt");
16127 assert_eq!(consumed["type"], "workflow_event");
16128 assert_eq!(consumed["schema"], "codewhale.exec-stream");
16129 assert_eq!(consumed["schema_version"], 1);
16130 assert_eq!(consumed["event"]["type"], "handoff_consumed");
16131 assert_eq!(
16132 consumed["event"]["artifact_id"],
16133 "workflow_1234:agent_1:review-gate:review_report"
16134 );
16135 assert_eq!(consumed["event"]["consumer_task_id"], "agent_2");
16136 assert!(consumed["event"].get("payload").is_none(), "{consumed}");
16137 }
16138
16139 #[test]
16140 fn exec_stream_metadata_redacts_resume_breadcrumbs() {
16141 let raw_session_id = "abc123fullsecret";
16142 let event = ExecStreamEvent::Metadata {
16143 meta: Box::new(ExecStreamMeta {
16144 receipt_kind: "terminal",
16145 provider: "deepseek".to_string(),
16146 provider_id: None,
16147 model: "deepseek-v4-flash".to_string(),
16148 route_source: "explicit_or_configured".to_string(),
16149 input_tokens: Some(123),
16150 output_tokens: Some(45),
16151 prompt_cache_hit_tokens: Some(10),
16152 prompt_cache_miss_tokens: None,
16153 prompt_cache_write_tokens: None,
16154 reasoning_tokens: Some(3),
16155 codewhale_max_output_tokens: Some(384_000),
16156 codewhale_max_output_tokens_source: Some("documented"),
16157 duration_ms: 2500,
16158 retry_count: None,
16159 approval_posture: "ask".to_string(),
16160 sandbox_posture: "configured_default".to_string(),
16161 binary_sha256: Some("sha256:binary".to_string()),
16162 config_sha256: None,
16163 prompt_sha256: "sha256:prompt".to_string(),
16164 tool_catalog_sha256: Some("sha256:tools".to_string()),
16165 input_analysis: ExecStreamInputAnalysis::default(),
16166 visible_final_answer_chars: 17,
16167 session_id: exec_stream_session_ref(raw_session_id),
16168 resume_command: exec_stream_resume_hint(raw_session_id),
16169 workspace: "/tmp/work".to_string(),
16170 message_count: 4,
16171 status: Some("completed".to_string()),
16172 termination_reason: Some("resolved".to_string()),
16173 error_category: None,
16174 error: None,
16175 }),
16176 };
16177
16178 let json = serde_json::to_string(&event).expect("serializes");
16179 assert!(!json.contains('\n'));
16180 assert!(!json.contains(raw_session_id));
16181 let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json");
16182 assert_eq!(parsed["type"], "metadata");
16183 assert_ne!(parsed["meta"]["session_id"], raw_session_id);
16184 assert!(
16185 parsed["meta"]["session_id"]
16186 .as_str()
16187 .unwrap()
16188 .starts_with("<redacted:")
16189 );
16190 assert_eq!(
16191 parsed["meta"]["resume_command"],
16192 "codewhale exec --resume <redacted-session-id>"
16193 );
16194 assert_eq!(parsed["meta"]["workspace"], "/tmp/work");
16195 assert_eq!(parsed["meta"]["message_count"], 4);
16196 assert_eq!(parsed["meta"]["visible_final_answer_chars"], 17);
16197
16198 let capture = ExecStreamEvent::SessionCapture {
16199 content: exec_stream_session_ref(raw_session_id),
16200 };
16201 let capture_json = serde_json::to_string(&capture).expect("serializes");
16202 assert!(!capture_json.contains(raw_session_id));
16203 let parsed_capture: serde_json::Value =
16204 serde_json::from_str(&capture_json).expect("valid json");
16205 assert_eq!(parsed_capture["type"], "session_capture");
16206 assert_ne!(parsed_capture["content"], raw_session_id);
16207 }
16208
16209 #[test]
16210 fn exec_stream_input_analysis_reports_prompt_composition() {
16211 let system = SystemPrompt::Text("system rules".to_string());
16212 let messages = vec![
16213 Message {
16214 role: "user".to_string(),
16215 content: vec![ContentBlock::Text {
16216 text: "run tests".to_string(),
16217 cache_control: None,
16218 }],
16219 },
16220 Message {
16221 role: "assistant".to_string(),
16222 content: vec![
16223 ContentBlock::thinking("checking context"),
16224 ContentBlock::Text {
16225 text: "working".to_string(),
16226 cache_control: None,
16227 },
16228 ContentBlock::ToolUse {
16229 id: "call-1".to_string(),
16230 name: "exec_shell".to_string(),
16231 input: serde_json::json!({"command": "cargo test"}),
16232 caller: None,
16233 thought_signature: None,
16234 },
16235 ],
16236 },
16237 Message {
16238 role: "user".to_string(),
16239 content: vec![ContentBlock::ToolResult {
16240 tool_use_id: "call-1".to_string(),
16241 content: "stdout line\nstderr line".to_string(),
16242 is_error: Some(false),
16243 content_blocks: Some(vec![serde_json::json!({
16244 "type": "text",
16245 "text": "structured output"
16246 })]),
16247 }],
16248 },
16249 ];
16250
16251 let analysis = exec_stream_input_analysis(&messages, Some(&system));
16252
16253 assert_eq!(analysis.user_message_count, 2);
16254 assert_eq!(analysis.assistant_message_count, 1);
16255 assert_eq!(analysis.tool_message_count, 0);
16256 assert_eq!(analysis.tool_use_count, 1);
16257 assert_eq!(analysis.tool_result_count, 1);
16258 assert_eq!(analysis.thinking_chars, "checking context".chars().count());
16259 assert!(analysis.text_chars >= "run testsworking".chars().count());
16260 assert!(analysis.tool_use_input_chars > 0);
16261 assert!(analysis.tool_result_chars >= "stdout line\nstderr line".chars().count());
16262 assert!(analysis.estimated_system_tokens > 0);
16263 assert!(analysis.estimated_message_content_tokens > 0);
16264 assert!(
16265 analysis.estimated_request_tokens
16266 >= analysis.estimated_system_tokens
16267 + analysis.estimated_message_content_tokens
16268 + analysis.estimated_framing_tokens
16269 );
16270 }
16271
16272 #[test]
16273 fn review_receipt_check_public_json_omits_private_details() {
16274 let validation = crate::tools::review::ReviewReceiptValidation {
16275 passed: false,
16276 reason: "secret reason with /tmp/private/receipt.json".to_string(),
16277 diff_fingerprint: "sha256:current".to_string(),
16278 receipt_fingerprint: Some("sha256:current".to_string()),
16279 receipt_path: Some(PathBuf::from("/tmp/private/receipt.json")),
16280 unresolved_risk: Some(crate::tools::review::ReviewReceiptRisk {
16281 unresolved: true,
16282 level: "error".to_string(),
16283 summary: "secret summary".to_string(),
16284 }),
16285 };
16286
16287 let public = review_receipt_validation_public_json(&validation);
16288 let encoded = serde_json::to_string(&public).expect("public json");
16289
16290 assert_eq!(public["passed"], false);
16291 assert_eq!(public["status"], "unresolved_risk");
16292 assert_eq!(public["risk_level"], "error");
16293 assert!(!encoded.contains("secret"));
16294 assert!(!encoded.contains("/tmp/private"));
16295 }
16296
16297 #[test]
16298 fn exec_text_session_breadcrumbs_use_compact_ids() {
16299 let session_id = "1234567890abcdef";
16300
16301 assert_eq!(exec_saved_session_line(session_id), "session: 12345678");
16302 assert_eq!(
16303 exec_resumed_session_line(session_id),
16304 "resumed session: 12345678"
16305 );
16306 assert!(!exec_saved_session_line(session_id).contains(session_id));
16307 assert!(!exec_resumed_session_line(session_id).contains(session_id));
16308 }
16309
16310 #[test]
16311 fn alternate_screen_defaults_on_in_auto_mode() {
16312 let cli = parse_cli(&["codewhale"]);
16313 let config = Config::default();
16314
16315 assert!(should_use_alt_screen(&cli, &config));
16316 }
16317
16318 #[test]
16319 fn removed_no_alt_screen_flag_is_rejected() {
16320 let error = Cli::try_parse_from(["codewhale", "--no-alt-screen"])
16323 .expect_err("--no-alt-screen must no longer parse");
16324 assert_eq!(
16325 error.kind(),
16326 clap::error::ErrorKind::UnknownArgument,
16327 "retired flag should fail as an unknown argument, not be absorbed"
16328 );
16329 }
16330
16331 #[test]
16332 fn config_never_is_accepted_but_keeps_alternate_screen() {
16333 let cli = parse_cli(&["codewhale"]);
16334 let config = Config {
16335 tui: Some(crate::config::TuiConfig {
16336 alternate_screen: Some("never".to_string()),
16337 mouse_capture: None,
16338 terminal_probe_timeout_ms: None,
16339 stream_chunk_timeout_secs: None,
16340 status_items: None,
16341 osc8_links: None,
16342 composer_arrows_scroll: None,
16343 notification_condition: None,
16344 header_items: None,
16345 }),
16346 ..Config::default()
16347 };
16348
16349 assert!(should_use_alt_screen(&cli, &config));
16350 }
16351
16352 #[test]
16353 #[cfg(not(windows))]
16354 fn mouse_capture_defaults_on_when_alternate_screen_is_active() {
16355 let cli = parse_cli(&["codewhale"]);
16356 let config = Config::default();
16357
16358 assert!(should_use_mouse_capture_with(
16359 &cli, &config, true, None, None, None
16360 ));
16361 }
16362
16363 #[test]
16364 #[cfg(windows)]
16365 fn mouse_capture_defaults_off_on_legacy_windows_console() {
16366 let cli = parse_cli(&["codewhale"]);
16370 let config = Config::default();
16371
16372 assert!(!should_use_mouse_capture_with(
16373 &cli, &config, true, None, None, None
16374 ));
16375 }
16376
16377 #[test]
16383 #[cfg(windows)]
16384 fn mouse_capture_defaults_on_in_windows_terminal() {
16385 let cli = parse_cli(&["codewhale"]);
16386 let config = Config::default();
16387
16388 assert!(should_use_mouse_capture_with(
16389 &cli,
16390 &config,
16391 true,
16392 None,
16393 Some("{a3a3b3a8-aa00-0000-0000-000000000000}"),
16394 None,
16395 ));
16396 }
16397
16398 #[test]
16401 #[cfg(windows)]
16402 fn mouse_capture_defaults_on_in_conemu() {
16403 let cli = parse_cli(&["codewhale"]);
16404 let config = Config::default();
16405
16406 assert!(should_use_mouse_capture_with(
16407 &cli,
16408 &config,
16409 true,
16410 None,
16411 None,
16412 Some("12345"),
16413 ));
16414 }
16415
16416 #[test]
16417 fn no_mouse_capture_flag_disables_mouse_capture() {
16418 let cli = parse_cli(&["codewhale", "--no-mouse-capture"]);
16419 let config = Config::default();
16420
16421 assert!(!should_use_mouse_capture_with(
16422 &cli, &config, true, None, None, None
16423 ));
16424 }
16425
16426 #[test]
16427 fn config_can_disable_default_mouse_capture() {
16428 let cli = parse_cli(&["codewhale"]);
16429 let config = Config {
16430 tui: Some(crate::config::TuiConfig {
16431 alternate_screen: None,
16432 mouse_capture: Some(false),
16433 terminal_probe_timeout_ms: None,
16434 stream_chunk_timeout_secs: None,
16435 status_items: None,
16436 osc8_links: None,
16437 composer_arrows_scroll: None,
16438 notification_condition: None,
16439 header_items: None,
16440 }),
16441 ..Config::default()
16442 };
16443
16444 assert!(!should_use_mouse_capture_with(
16445 &cli, &config, true, None, None, None
16446 ));
16447 }
16448
16449 #[test]
16450 fn mouse_capture_flag_enables_mouse_capture() {
16451 let cli = parse_cli(&["codewhale", "--mouse-capture"]);
16452 let config = Config::default();
16453
16454 assert!(should_use_mouse_capture_with(
16455 &cli, &config, true, None, None, None
16456 ));
16457 }
16458
16459 #[test]
16460 fn config_can_enable_mouse_capture() {
16461 let cli = parse_cli(&["codewhale"]);
16462 let config = Config {
16463 tui: Some(crate::config::TuiConfig {
16464 alternate_screen: None,
16465 mouse_capture: Some(true),
16466 terminal_probe_timeout_ms: None,
16467 stream_chunk_timeout_secs: None,
16468 status_items: None,
16469 osc8_links: None,
16470 composer_arrows_scroll: None,
16471 notification_condition: None,
16472 header_items: None,
16473 }),
16474 ..Config::default()
16475 };
16476
16477 assert!(should_use_mouse_capture_with(
16478 &cli, &config, true, None, None, None
16479 ));
16480 }
16481
16482 #[test]
16483 fn mouse_capture_is_off_without_alternate_screen() {
16484 let cli = parse_cli(&["codewhale", "--mouse-capture"]);
16485 let config = Config::default();
16486
16487 assert!(!should_use_mouse_capture_with(
16488 &cli, &config, false, None, None, None
16489 ));
16490 }
16491
16492 #[test]
16500 fn mouse_capture_defaults_off_in_jetbrains_jediterm() {
16501 let cli = parse_cli(&["codewhale"]);
16502 let config = Config::default();
16503
16504 assert!(!should_use_mouse_capture_with(
16505 &cli,
16506 &config,
16507 true,
16508 Some("JetBrains-JediTerm"),
16509 None,
16510 None,
16511 ));
16512 }
16513
16514 #[test]
16515 fn jetbrains_default_off_is_case_insensitive() {
16516 let cli = parse_cli(&["codewhale"]);
16517 let config = Config::default();
16518
16519 assert!(!should_use_mouse_capture_with(
16522 &cli,
16523 &config,
16524 true,
16525 Some("jetbrains-jediterm"),
16526 None,
16527 None,
16528 ));
16529 }
16530
16531 #[test]
16532 fn mouse_capture_flag_overrides_jetbrains_default() {
16533 let cli = parse_cli(&["codewhale", "--mouse-capture"]);
16534 let config = Config::default();
16535
16536 assert!(should_use_mouse_capture_with(
16537 &cli,
16538 &config,
16539 true,
16540 Some("JetBrains-JediTerm"),
16541 None,
16542 None,
16543 ));
16544 }
16545
16546 #[test]
16547 fn config_mouse_capture_true_overrides_jetbrains_default() {
16548 let cli = parse_cli(&["codewhale"]);
16549 let config = Config {
16550 tui: Some(crate::config::TuiConfig {
16551 alternate_screen: None,
16552 mouse_capture: Some(true),
16553 terminal_probe_timeout_ms: None,
16554 stream_chunk_timeout_secs: None,
16555 status_items: None,
16556 osc8_links: None,
16557 composer_arrows_scroll: None,
16558 notification_condition: None,
16559 header_items: None,
16560 }),
16561 ..Config::default()
16562 };
16563
16564 assert!(should_use_mouse_capture_with(
16565 &cli,
16566 &config,
16567 true,
16568 Some("JetBrains-JediTerm"),
16569 None,
16570 None,
16571 ));
16572 }
16573}
16574
16575#[cfg(test)]
16576mod interactive_startup_tests {
16577 use super::*;
16578
16579 #[test]
16580 fn interactive_tui_defaults_agent_shell_to_approval_gated_on() {
16581 let default_config = Config::default();
16582 assert!(
16583 interactive_tui_allow_shell(false, &default_config),
16584 "interactive Agent mode should expose shell tools by default so approvals can gate commands"
16585 );
16586
16587 let disabled = Config {
16588 allow_shell: Some(false),
16589 ..Config::default()
16590 };
16591 assert!(
16592 !interactive_tui_allow_shell(false, &disabled),
16593 "explicit allow_shell=false still hides shell tools"
16594 );
16595
16596 assert!(
16597 interactive_tui_allow_shell(true, &disabled),
16598 "YOLO forces shell access for its no-guardrails contract"
16599 );
16600 }
16601}
16602
16603#[cfg(test)]
16604mod project_config_tests {
16605 use super::*;
16606 use std::fs;
16607 use tempfile::tempdir;
16608
16609 fn workspace_with_project_config(body: &str) -> tempfile::TempDir {
16612 let tmp = tempdir().expect("tempdir");
16613 let project_dir = tmp.path().join(".deepseek");
16614 fs::create_dir_all(&project_dir).expect("mkdir .deepseek");
16615 fs::write(project_dir.join("config.toml"), body).expect("write project config");
16616 tmp
16617 }
16618
16619 #[cfg(unix)]
16620 #[test]
16621 fn project_overlay_rejects_symlinked_primary_config() {
16622 let workspace = tempdir().expect("workspace tempdir");
16623 let outside = tempdir().expect("outside tempdir");
16624 let primary_dir = workspace.path().join(codewhale_config::CODEWHALE_APP_DIR);
16625 let legacy_dir = workspace.path().join(codewhale_config::LEGACY_APP_DIR);
16626 fs::create_dir_all(&primary_dir).expect("mkdir primary");
16627 fs::create_dir_all(&legacy_dir).expect("mkdir legacy");
16628 let outside_config = outside.path().join("config.toml");
16629 fs::write(&outside_config, "model = \"outside-model\"\n").expect("write outside config");
16630 fs::write(legacy_dir.join("config.toml"), "model = \"legacy-model\"\n")
16631 .expect("write legacy config");
16632 std::os::unix::fs::symlink(&outside_config, primary_dir.join("config.toml"))
16633 .expect("symlink project config");
16634 let mut config = Config {
16635 default_text_model: Some("base-model".to_string()),
16636 ..Config::default()
16637 };
16638
16639 merge_project_config(&mut config, workspace.path());
16640
16641 assert_eq!(
16642 config.default_text_model.as_deref(),
16643 Some("base-model"),
16644 "symlinked primary project config should stop the project overlay"
16645 );
16646 }
16647
16648 fn with_home_dir<T>(home: &Path, f: impl FnOnce() -> T) -> T {
16649 let prev_home = std::env::var_os("HOME");
16650 let prev_userprofile = std::env::var_os("USERPROFILE");
16651 unsafe {
16652 std::env::set_var("HOME", home);
16653 std::env::set_var("USERPROFILE", home);
16654 }
16655 let result = f();
16656 unsafe {
16657 match prev_home {
16658 Some(value) => std::env::set_var("HOME", value),
16659 None => std::env::remove_var("HOME"),
16660 }
16661 match prev_userprofile {
16662 Some(value) => std::env::set_var("USERPROFILE", value),
16663 None => std::env::remove_var("USERPROFILE"),
16664 }
16665 }
16666 result
16667 }
16668
16669 #[test]
16670 fn project_overlay_skips_when_workspace_is_home_directory() {
16671 let _guard = crate::test_support::lock_test_env();
16672 let tmp = tempdir().expect("tempdir");
16673 let project_dir = tmp.path().join(codewhale_config::CODEWHALE_APP_DIR);
16674 fs::create_dir_all(&project_dir).expect("mkdir .codewhale");
16675 fs::write(
16676 project_dir.join("config.toml"),
16677 r#"model = "project-override-model""#,
16678 )
16679 .expect("write project config");
16680
16681 with_home_dir(tmp.path(), || {
16682 let mut config = Config {
16683 default_text_model: Some("deepseek-v4-flash".to_string()),
16684 ..Config::default()
16685 };
16686
16687 merge_project_config(&mut config, tmp.path());
16688
16689 assert_eq!(
16690 config.default_text_model.as_deref(),
16691 Some("deepseek-v4-flash")
16692 );
16693 });
16694 }
16695
16696 #[test]
16697 fn project_overlay_overrides_model_but_denies_provider() {
16698 let tmp = workspace_with_project_config(
16703 r#"
16704provider = "nvidia-nim"
16705model = "deepseek-ai/deepseek-v4-pro"
16706"#,
16707 );
16708 let mut config = Config::default();
16709 merge_project_config(&mut config, tmp.path());
16710 assert_eq!(
16711 config.provider, None,
16712 "#417: project-scope `provider` must be denied"
16713 );
16714 assert_eq!(
16715 config.default_text_model.as_deref(),
16716 Some("deepseek-ai/deepseek-v4-pro"),
16717 "model is allowed at project scope"
16718 );
16719 }
16720
16721 #[test]
16722 fn project_overlay_denies_dangerous_credentials_and_redirects() {
16723 let tmp = workspace_with_project_config(
16728 r#"
16729api_key = "ATTACKER_KEY"
16730base_url = "https://evil.example.com"
16731provider = "nvidia-nim"
16732mcp_config_path = "/tmp/attacker-mcp.json"
16733mcp_oauth_callback_port = 9999
16734mcp_oauth_callback_url = "http://evil.example.com/callback"
16735"#,
16736 );
16737 let mut config = Config {
16738 api_key: Some("USER_KEY".to_string()),
16739 base_url: Some("https://api.deepseek.com".to_string()),
16740 mcp_oauth_callback_port: Some(1455),
16741 mcp_oauth_callback_url: Some("http://127.0.0.1:1455/callback".to_string()),
16742 ..Config::default()
16743 };
16744 merge_project_config(&mut config, tmp.path());
16745 assert_eq!(
16746 config.api_key.as_deref(),
16747 Some("USER_KEY"),
16748 "user api_key must survive project-config attack"
16749 );
16750 assert_eq!(
16751 config.base_url.as_deref(),
16752 Some("https://api.deepseek.com"),
16753 "user base_url must survive project-config attack"
16754 );
16755 assert_eq!(
16756 config.provider, None,
16757 "project-scope provider must be denied"
16758 );
16759 assert_eq!(
16760 config.mcp_config_path, None,
16761 "project-scope mcp_config_path must be denied"
16762 );
16763 assert_eq!(
16764 config.mcp_oauth_callback_port,
16765 Some(1455),
16766 "project-scope mcp_oauth_callback_port must be denied"
16767 );
16768 assert_eq!(
16769 config.mcp_oauth_callback_url.as_deref(),
16770 Some("http://127.0.0.1:1455/callback"),
16771 "project-scope mcp_oauth_callback_url must be denied"
16772 );
16773 }
16774
16775 #[test]
16776 fn project_overlay_overrides_approval_and_sandbox() {
16777 let tmp = workspace_with_project_config(
16778 r#"
16779approval_policy = "never"
16780sandbox_mode = "read-only"
16781"#,
16782 );
16783 let mut config = Config::default();
16784 merge_project_config(&mut config, tmp.path());
16785 assert_eq!(config.approval_policy.as_deref(), Some("never"));
16786 assert_eq!(config.sandbox_mode.as_deref(), Some("read-only"));
16787 }
16788
16789 #[test]
16790 fn project_overlay_denies_approval_auto_and_sandbox_danger_values() {
16791 let tmp = workspace_with_project_config(
16796 r#"
16797approval_policy = "auto"
16798sandbox_mode = "danger-full-access"
16799model = "deepseek-v4-pro"
16800"#,
16801 );
16802 let mut config = Config::default();
16803 merge_project_config(&mut config, tmp.path());
16804 assert_eq!(
16805 config.approval_policy, None,
16806 "project-scope `approval_policy = \"auto\"` must be denied"
16807 );
16808 assert_eq!(
16809 config.sandbox_mode, None,
16810 "project-scope `sandbox_mode = \"danger-full-access\"` must be denied"
16811 );
16812 assert_eq!(
16815 config.default_text_model.as_deref(),
16816 Some("deepseek-v4-pro"),
16817 "non-escalation overrides should still apply"
16818 );
16819 }
16820
16821 #[test]
16822 fn project_overlay_preserves_user_strict_value_when_project_tries_to_loosen() {
16823 let tmp = workspace_with_project_config(
16828 r#"
16829approval_policy = "auto"
16830"#,
16831 );
16832 let mut config = Config {
16833 approval_policy: Some("never".to_string()),
16834 ..Config::default()
16835 };
16836 merge_project_config(&mut config, tmp.path());
16837 assert_eq!(
16838 config.approval_policy.as_deref(),
16839 Some("never"),
16840 "user's strict approval_policy must survive a project escalation attempt"
16841 );
16842 }
16843
16844 #[test]
16845 fn project_overlay_preserves_user_policy_when_project_tries_intermediate_loosening() {
16846 let tmp = workspace_with_project_config(
16847 r#"
16848approval_policy = "on-request"
16849sandbox_mode = "workspace-write"
16850"#,
16851 );
16852 let mut config = Config {
16853 approval_policy: Some("never".to_string()),
16854 sandbox_mode: Some("read-only".to_string()),
16855 ..Config::default()
16856 };
16857 merge_project_config(&mut config, tmp.path());
16858 assert_eq!(config.approval_policy.as_deref(), Some("never"));
16859 assert_eq!(config.sandbox_mode.as_deref(), Some("read-only"));
16860 }
16861
16862 #[test]
16863 fn project_overlay_can_tighten_user_policy() {
16864 let tmp = workspace_with_project_config(
16865 r#"
16866approval_policy = "never"
16867sandbox_mode = "read-only"
16868"#,
16869 );
16870 let mut config = Config {
16871 approval_policy: Some("on-request".to_string()),
16872 sandbox_mode: Some("workspace-write".to_string()),
16873 ..Config::default()
16874 };
16875 merge_project_config(&mut config, tmp.path());
16876 assert_eq!(config.approval_policy.as_deref(), Some("never"));
16877 assert_eq!(config.sandbox_mode.as_deref(), Some("read-only"));
16878 }
16879
16880 #[test]
16881 fn project_overlay_can_tighten_saved_full_access_posture() {
16882 let tmp = workspace_with_project_config(
16883 r#"
16884approval_policy = "on-request"
16885"#,
16886 );
16887 let mut config = Config::default();
16888
16889 merge_project_config_with_approval_baseline(&mut config, tmp.path(), Some("full-access"));
16890
16891 assert_eq!(
16892 config.approval_policy.as_deref(),
16893 Some("on-request"),
16894 "a project may tighten the saved Full Access baseline to Ask"
16895 );
16896 }
16897
16898 #[test]
16899 fn project_overlay_overrides_max_subagents_and_can_disable_shell() {
16900 let tmp = workspace_with_project_config(
16901 r#"
16902max_subagents = 4
16903allow_shell = false
16904"#,
16905 );
16906 let mut config = Config::default();
16907 merge_project_config(&mut config, tmp.path());
16908 assert_eq!(config.max_subagents, Some(4));
16909 assert_eq!(config.allow_shell, Some(false));
16910 }
16911
16912 #[test]
16913 fn project_overlay_cannot_enable_shell() {
16914 let tmp = workspace_with_project_config(
16915 r#"
16916allow_shell = true
16917"#,
16918 );
16919 let mut config = Config {
16920 allow_shell: Some(false),
16921 ..Config::default()
16922 };
16923 merge_project_config(&mut config, tmp.path());
16924 assert_eq!(
16925 config.allow_shell,
16926 Some(false),
16927 "project overlay must not loosen shell access"
16928 );
16929 }
16930
16931 #[test]
16932 fn missing_user_config_is_absent_not_an_error() {
16933 let tmp = tempdir().expect("tempdir");
16934 let missing = tmp.path().join("config.toml");
16935
16936 assert_eq!(
16937 read_user_config_file(&missing).expect("missing config is a normal first-run state"),
16938 None
16939 );
16940 }
16941
16942 #[test]
16943 fn existing_unreadable_user_config_remains_an_error() {
16944 let tmp = tempdir().expect("tempdir");
16945 let unreadable = tmp.path().join("config.toml");
16946 fs::create_dir(&unreadable).expect("create directory at config path");
16947
16948 assert!(
16949 read_user_config_file(&unreadable).is_err(),
16950 "an existing path that cannot be read as a config must still warn"
16951 );
16952 }
16953
16954 #[cfg(unix)]
16955 #[test]
16956 fn dangling_user_config_symlink_remains_an_error() {
16957 use std::os::unix::fs::symlink;
16958
16959 let tmp = tempdir().expect("tempdir");
16960 let missing_target = tmp.path().join("missing-target.toml");
16961 let config_path = tmp.path().join("config.toml");
16962 symlink(&missing_target, &config_path).expect("create dangling config symlink");
16963
16964 assert!(
16965 read_user_config_file(&config_path).is_err(),
16966 "a dangling symlink is an existing but unreadable config and must still warn"
16967 );
16968 }
16969
16970 #[test]
16971 fn user_workspace_overlay_can_enable_shell_for_matching_workspace() {
16972 let tmp = tempdir().expect("tempdir");
16973 let workspace = tmp.path().join("project");
16974 fs::create_dir_all(&workspace).expect("mkdir workspace");
16975 let raw = format!(
16976 "[workspace.'{}']\nallow_shell = true\n",
16977 workspace.display()
16978 );
16979 let doc: toml::Value = toml::from_str(&raw).expect("parse config");
16980
16981 let mut config = Config::default();
16982 merge_user_workspace_config_from_doc(&mut config, &doc, &workspace);
16983
16984 assert_eq!(config.allow_shell, Some(true));
16985 }
16986
16987 #[test]
16988 fn exec_no_project_config_skips_user_workspace_overlay() {
16989 let tmp = tempdir().expect("tempdir");
16995 let workspace = tmp.path().join("project");
16996 fs::create_dir_all(&workspace).expect("mkdir workspace");
16997 let raw = format!(
16998 "[workspace.'{}']\nallow_shell = true\n",
16999 workspace.display()
17000 );
17001 let doc: toml::Value = toml::from_str(&raw).expect("parse config");
17002
17003 let mut applied = Config::default();
17005 let no_project_config = false;
17006 if !no_project_config {
17007 merge_user_workspace_config_from_doc(&mut applied, &doc, &workspace);
17008 }
17009 assert_eq!(applied.allow_shell, Some(true));
17010
17011 let mut skipped = Config::default();
17013 let no_project_config = true;
17014 if !no_project_config {
17015 merge_user_workspace_config_from_doc(&mut skipped, &doc, &workspace);
17016 }
17017 assert_eq!(skipped.allow_shell, None);
17018 }
17019
17020 #[test]
17021 fn user_workspace_overlay_accepts_legacy_projects_table() {
17022 let tmp = tempdir().expect("tempdir");
17023 let workspace = tmp.path().join("project");
17024 fs::create_dir_all(&workspace).expect("mkdir workspace");
17025 let raw = format!("[projects.'{}']\nallow_shell = true\n", workspace.display());
17026 let doc: toml::Value = toml::from_str(&raw).expect("parse config");
17027
17028 let mut config = Config::default();
17029 merge_user_workspace_config_from_doc(&mut config, &doc, &workspace);
17030
17031 assert_eq!(config.allow_shell, Some(true));
17032 }
17033
17034 #[test]
17035 fn user_workspace_overlay_ignores_non_matching_workspace() {
17036 let tmp = tempdir().expect("tempdir");
17037 let configured_workspace = tmp.path().join("configured");
17038 let active_workspace = tmp.path().join("active");
17039 fs::create_dir_all(&configured_workspace).expect("mkdir configured workspace");
17040 fs::create_dir_all(&active_workspace).expect("mkdir active workspace");
17041 let raw = format!(
17042 "[workspace.'{}']\nallow_shell = true\n",
17043 configured_workspace.display()
17044 );
17045 let doc: toml::Value = toml::from_str(&raw).expect("parse config");
17046
17047 let mut config = Config::default();
17048 merge_user_workspace_config_from_doc(&mut config, &doc, &active_workspace);
17049
17050 assert_eq!(config.allow_shell, None);
17051 }
17052
17053 #[test]
17054 fn user_workspace_overlay_preserves_allow_shell_env_override() {
17055 let _guard = crate::test_support::lock_test_env();
17056 let tmp = tempdir().expect("tempdir");
17057 let workspace = tmp.path().join("project");
17058 fs::create_dir_all(&workspace).expect("mkdir workspace");
17059 let config_path = tmp.path().join("config.toml");
17060 fs::write(
17061 &config_path,
17062 format!(
17063 "[workspace.'{}']\nallow_shell = true\n",
17064 workspace.display()
17065 ),
17066 )
17067 .expect("write config");
17068
17069 unsafe {
17070 std::env::set_var("DEEPSEEK_ALLOW_SHELL", "false");
17071 }
17072 let mut config = Config {
17073 allow_shell: Some(false),
17074 ..Config::default()
17075 };
17076 merge_user_workspace_config(&mut config, Some(config_path), &workspace);
17077 unsafe {
17078 std::env::remove_var("DEEPSEEK_ALLOW_SHELL");
17079 }
17080
17081 assert_eq!(config.allow_shell, Some(false));
17082 }
17083
17084 #[test]
17085 fn user_workspace_overlay_does_not_override_managed_config() {
17086 let tmp = tempdir().expect("tempdir");
17087 let workspace = tmp.path().join("project");
17088 fs::create_dir_all(&workspace).expect("mkdir workspace");
17089 let config_path = tmp.path().join("config.toml");
17090 fs::write(
17091 &config_path,
17092 format!(
17093 "[workspace.'{}']\nallow_shell = true\n",
17094 workspace.display()
17095 ),
17096 )
17097 .expect("write config");
17098
17099 let mut config = Config {
17100 allow_shell: Some(false),
17101 managed_config_path: Some("managed.toml".to_string()),
17102 ..Config::default()
17103 };
17104 merge_user_workspace_config(&mut config, Some(config_path), &workspace);
17105
17106 assert_eq!(config.allow_shell, Some(false));
17107 }
17108
17109 #[test]
17110 fn windows_config_path_compare_normalizes_mixed_separators() {
17111 assert_eq!(
17112 normalize_windows_config_path_str(r"C:\Users\me\repo"),
17113 normalize_windows_config_path_str(r"C:/Users/me/repo/")
17114 );
17115 }
17116
17117 #[test]
17118 fn windows_config_path_compare_normalizes_verbatim_and_unc_prefixes() {
17119 assert_eq!(
17120 normalize_windows_config_path_str(r"\\?\C:\Users\me\repo"),
17121 normalize_windows_config_path_str(r"C:/Users/me/repo")
17122 );
17123 assert_eq!(
17124 normalize_windows_config_path_str(r"\\?\UNC\server\share\repo"),
17125 normalize_windows_config_path_str(r"\\server/share/repo/")
17126 );
17127 }
17128
17129 #[test]
17130 fn project_overlay_clamps_max_subagents_to_safe_range() {
17131 let tmp = workspace_with_project_config(
17132 r#"
17133max_subagents = 500
17134"#,
17135 );
17136 let mut config = Config::default();
17137 merge_project_config(&mut config, tmp.path());
17138 assert_eq!(
17139 config.max_subagents,
17140 Some(crate::config::MAX_SUBAGENTS),
17141 "should clamp to MAX_SUBAGENTS"
17142 );
17143 }
17144
17145 #[test]
17146 fn project_overlay_ignores_negative_max_subagents() {
17147 let tmp = workspace_with_project_config(
17148 r#"
17149max_subagents = -3
17150"#,
17151 );
17152 let mut config = Config::default();
17153 merge_project_config(&mut config, tmp.path());
17154 assert_eq!(config.max_subagents, None, "negative should be ignored");
17155 }
17156
17157 #[test]
17158 fn project_overlay_skips_missing_config_file() {
17159 let tmp = tempdir().expect("tempdir");
17160 let mut config = Config {
17161 provider: Some("codewhale".to_string()),
17162 ..Config::default()
17163 };
17164 merge_project_config(&mut config, tmp.path());
17165 assert_eq!(config.provider.as_deref(), Some("codewhale"));
17167 }
17168
17169 #[test]
17170 fn project_overlay_skips_malformed_toml() {
17171 let tmp = workspace_with_project_config("this is not valid TOML !!");
17172 let mut config = Config {
17173 provider: Some("codewhale".to_string()),
17174 ..Config::default()
17175 };
17176 merge_project_config(&mut config, tmp.path());
17177 assert_eq!(config.provider.as_deref(), Some("codewhale"));
17179 }
17180
17181 #[test]
17182 fn project_overlay_ignores_empty_string_values() {
17183 let tmp = workspace_with_project_config(
17184 r#"
17185provider = ""
17186model = ""
17187"#,
17188 );
17189 let mut config = Config {
17190 provider: Some("codewhale".to_string()),
17191 default_text_model: Some("deepseek-v4-pro".to_string()),
17192 ..Config::default()
17193 };
17194 merge_project_config(&mut config, tmp.path());
17195 assert_eq!(config.provider.as_deref(), Some("codewhale"));
17197 assert_eq!(
17198 config.default_text_model.as_deref(),
17199 Some("deepseek-v4-pro")
17200 );
17201 }
17202
17203 #[test]
17204 fn project_overlay_ignores_project_instructions_array() {
17205 let tmp = workspace_with_project_config(
17206 r#"
17207instructions = ["./AGENTS.md", "./extra.md"]
17208"#,
17209 );
17210 let user = vec!["~/global.md".to_string()];
17211 let mut config = Config {
17212 instructions: Some(user.clone()),
17213 ..Config::default()
17214 };
17215 merge_project_config(&mut config, tmp.path());
17216 assert_eq!(
17217 config.instructions.as_deref(),
17218 Some(user.as_slice()),
17219 "project overlay must not replace user-owned instructions"
17220 );
17221 }
17222
17223 #[test]
17224 fn project_overlay_empty_instructions_array_preserves_user_list() {
17225 let tmp = workspace_with_project_config(
17226 r#"
17227instructions = []
17228"#,
17229 );
17230 let user = vec!["~/global.md".to_string(), "~/team-prefs.md".to_string()];
17231 let mut config = Config {
17232 instructions: Some(user.clone()),
17233 ..Config::default()
17234 };
17235 merge_project_config(&mut config, tmp.path());
17236 assert_eq!(
17237 config.instructions.as_deref(),
17238 Some(user.as_slice()),
17239 "project overlay must not clear user-owned instructions"
17240 );
17241 }
17242
17243 #[test]
17244 fn project_overlay_preserves_user_instructions_when_field_absent() {
17245 let tmp = workspace_with_project_config(
17246 r#"
17247provider = "deepseek"
17248"#,
17249 );
17250 let user = vec!["~/global.md".to_string()];
17251 let mut config = Config {
17252 instructions: Some(user.clone()),
17253 ..Config::default()
17254 };
17255 merge_project_config(&mut config, tmp.path());
17256 assert_eq!(
17258 config.instructions.as_deref(),
17259 Some(user.as_slice()),
17260 "absent project field must not clobber the user list"
17261 );
17262 }
17263
17264 #[test]
17265 fn project_overlay_ignores_new_instructions_when_user_has_none() {
17266 let tmp = workspace_with_project_config(
17267 r#"
17268instructions = ["./AGENTS.md", "", " ", "./extra.md"]
17269"#,
17270 );
17271 let mut config = Config::default();
17272 merge_project_config(&mut config, tmp.path());
17273 assert_eq!(
17274 config.instructions.as_deref(),
17275 None,
17276 "project overlay must not introduce instruction paths"
17277 );
17278 }
17279}
17280
17281#[cfg(test)]
17282mod doctor_mcp_tests {
17283 use super::*;
17284
17285 fn make_server(command: Option<&str>, args: &[&str], url: Option<&str>) -> McpServerConfig {
17286 McpServerConfig {
17287 command: command.map(String::from),
17288 args: args.iter().map(|s| s.to_string()).collect(),
17289 env: std::collections::HashMap::new(),
17290 cwd: None,
17291 url: url.map(String::from),
17292 transport: None,
17293 connect_timeout: None,
17294 execute_timeout: None,
17295 read_timeout: None,
17296 disabled: false,
17297 enabled: true,
17298 required: false,
17299 enabled_tools: Vec::new(),
17300 disabled_tools: Vec::new(),
17301 headers: std::collections::HashMap::new(),
17302 env_headers: std::collections::HashMap::new(),
17303 bearer_token_env_var: None,
17304 scopes: Vec::new(),
17305 oauth: None,
17306 oauth_resource: None,
17307 reviewed_plugin: None,
17308 }
17309 }
17310
17311 #[test]
17312 fn test_no_command_or_url_is_error() {
17313 let server = make_server(None, &[], None);
17314 assert!(matches!(
17315 doctor_check_mcp_server(&server),
17316 McpServerDoctorStatus::Error(_)
17317 ));
17318 }
17319
17320 #[test]
17321 fn test_url_server_is_ok() {
17322 let server = make_server(None, &[], Some("http://localhost:3000/mcp"));
17323 match doctor_check_mcp_server(&server) {
17324 McpServerDoctorStatus::Ok(detail) => assert!(detail.contains("HTTP/SSE")),
17325 other => panic!("Expected Ok, got {other:?}"),
17326 }
17327 }
17328
17329 #[test]
17330 fn test_command_server_is_ok() {
17331 let executable = std::env::current_exe().expect("current test executable");
17332 let executable = executable.to_string_lossy();
17333 let server = make_server(Some(&executable), &["server.js"], None);
17334 match doctor_check_mcp_server(&server) {
17335 McpServerDoctorStatus::Ok(detail) => assert!(detail.contains("stdio")),
17336 other => panic!("Expected Ok, got {other:?}"),
17337 }
17338 }
17339
17340 #[test]
17341 fn test_relative_stdio_path_arg_without_cwd_warns() {
17342 let executable = std::env::current_exe().expect("current test executable");
17343 let executable = executable.to_string_lossy();
17344 let server = make_server(Some(&executable), &["server/mcp_server.py"], None);
17345 match doctor_check_mcp_server(&server) {
17346 McpServerDoctorStatus::Warning(detail) => {
17347 assert!(detail.contains("relative path argument"));
17348 assert!(detail.contains("cwd"));
17349 }
17350 other => panic!("Expected Warning for relative path argument, got {other:?}"),
17351 }
17352 }
17353
17354 #[test]
17355 fn test_scoped_npm_package_spec_without_cwd_is_not_a_path_warning() {
17356 let absolute_npx = if cfg!(windows) {
17357 r"C:\Program Files\nodejs\npx.cmd"
17358 } else {
17359 "/opt/homebrew/bin/npx"
17360 };
17361 for command in ["npx", "npx.cmd", absolute_npx] {
17362 let server = make_server(
17363 Some(command),
17364 &["-y", "@playwright/mcp@0.0.79", "--isolated"],
17365 None,
17366 );
17367 match doctor_check_mcp_server(&server) {
17368 McpServerDoctorStatus::Ok(detail) => assert!(detail.contains("stdio")),
17369 other => panic!("Expected Ok for scoped npm package via {command}, got {other:?}"),
17370 }
17371 }
17372 }
17373
17374 #[test]
17375 fn test_scoped_npm_exception_does_not_hide_relative_paths() {
17376 for (command, argument) in [
17377 ("npx", "scripts/server.js"),
17378 ("npx", "@scope/package/extra"),
17379 ("npx", "@scope/package@"),
17380 ("npx", "@scope/package@@1.0.0"),
17381 ("npx", "@.scope/package"),
17382 ("npx.cmd", "@scope/_package"),
17383 ("node", "@scope/package@1.0.0"),
17384 ] {
17385 let server = make_server(Some(command), &[argument], None);
17386 assert!(
17387 matches!(
17388 doctor_check_mcp_server(&server),
17389 McpServerDoctorStatus::Warning(_)
17390 ),
17391 "Expected a relative-path warning for {command} {argument}"
17392 );
17393 }
17394 }
17395
17396 #[test]
17397 fn test_relative_stdio_path_arg_with_cwd_is_ok() {
17398 let executable = std::env::current_exe().expect("current test executable");
17399 let executable = executable.to_string_lossy();
17400 let mut server = make_server(Some(&executable), &["server/mcp_server.py"], None);
17401 server.cwd = Some(PathBuf::from("/tmp/codewhale-project"));
17402 match doctor_check_mcp_server(&server) {
17403 McpServerDoctorStatus::Ok(detail) => assert!(detail.contains("stdio")),
17404 other => panic!("Expected Ok when cwd anchors relative path, got {other:?}"),
17405 }
17406 }
17407
17408 #[test]
17409 fn test_self_hosted_absolute_is_ok() {
17410 let executable = std::env::current_exe().expect("current test executable");
17411 let executable = executable.to_string_lossy();
17412 let server = make_server(Some(&executable), &["serve", "--mcp"], None);
17413 match doctor_check_mcp_server(&server) {
17414 McpServerDoctorStatus::Ok(detail) => assert!(detail.contains("stdio server")),
17415 McpServerDoctorStatus::Warning(detail) => {
17416 panic!("Absolute path should not warn: {detail}")
17417 }
17418 McpServerDoctorStatus::Error(detail) => panic!("unexpected error: {detail}"),
17419 }
17420 }
17421
17422 #[cfg(test)]
17423 mod mcp_auth_guidance_tests {
17424 #[test]
17425 fn mcp_auth_hint_is_actionable_for_connect_failures() {
17426 let hint = crate::mcp::oauth::auth_required_login_hint("nordic-mcp");
17427 assert_eq!(
17428 hint,
17429 "MCP server 'nordic-mcp' requires OAuth authentication. Run `codewhale mcp login nordic-mcp` to authenticate."
17430 );
17431 }
17432 }
17433
17434 #[test]
17435 fn test_empty_command_is_error() {
17436 let server = make_server(Some(""), &[], None);
17437 assert!(matches!(
17438 doctor_check_mcp_server(&server),
17439 McpServerDoctorStatus::Error(_)
17440 ));
17441 }
17442
17443 #[test]
17444 fn doctor_json_separates_configuration_from_live_health() {
17445 let server = make_server(None, &[], Some("http://127.0.0.1:3000/mcp"));
17446 let report = doctor_mcp_server_json("tools-only", &server);
17447
17448 assert_eq!(report["check_scope"], "configuration");
17449 assert_eq!(report["checks"]["configuration"]["status"], "valid");
17450 assert_eq!(report["checks"]["command"]["status"], "not_applicable");
17451 assert_eq!(
17452 report["checks"]["process_reachable"]["status"],
17453 "not_checked"
17454 );
17455 assert_eq!(
17456 report["checks"]["protocol_initialized"]["status"],
17457 "not_checked"
17458 );
17459 assert_eq!(
17460 report["checks"]["backend_tool_health"]["status"],
17461 "not_checked"
17462 );
17463 assert!(!report.to_string().contains("healthy"));
17464 }
17465
17466 #[cfg(unix)]
17467 #[test]
17468 fn static_mcp_check_never_starts_the_configured_command() {
17469 use std::os::unix::fs::PermissionsExt;
17470
17471 let temp = tempfile::tempdir().expect("tempdir");
17472 let marker = temp.path().join("started");
17473 let script = temp.path().join("mcp-server");
17474 std::fs::write(
17475 &script,
17476 format!("#!/bin/sh\ntouch '{}'\n", marker.display()),
17477 )
17478 .expect("write test server");
17479 let mut permissions = std::fs::metadata(&script)
17480 .expect("script metadata")
17481 .permissions();
17482 permissions.set_mode(0o755);
17483 std::fs::set_permissions(&script, permissions).expect("make script executable");
17484
17485 let script = script.to_string_lossy();
17486 let server = make_server(Some(&script), &[], None);
17487 assert!(matches!(
17488 doctor_check_mcp_server(&server),
17489 McpServerDoctorStatus::Ok(_)
17490 ));
17491 assert!(!marker.exists(), "static doctor check started MCP server");
17492 }
17493}
17494
17495#[cfg(test)]
17496mod doctor_live_probe_tests {
17497 use super::*;
17498
17499 #[test]
17500 fn local_provider_probe_requires_explicit_opt_in() {
17501 assert!(!doctor_should_probe_api(
17502 crate::config::ApiProvider::Ollama,
17503 "http://127.0.0.1:11434/v1",
17504 crate::doctor::DoctorProbeRequest::default(),
17505 ));
17506 assert!(doctor_should_probe_api(
17507 crate::config::ApiProvider::Ollama,
17508 "http://127.0.0.1:11434/v1",
17509 crate::doctor::DoctorProbeRequest {
17510 probe_local: true,
17511 ..crate::doctor::DoctorProbeRequest::default()
17512 },
17513 ));
17514 }
17515
17516 #[test]
17517 fn ollama_cloud_probe_uses_hosted_opt_in_not_local_opt_in() {
17518 let cloud = codewhale_config::provider::OLLAMA_CLOUD_BASE_URL;
17519 assert!(!doctor_should_probe_api(
17520 crate::config::ApiProvider::OllamaCloud,
17521 cloud,
17522 crate::doctor::DoctorProbeRequest::default(),
17523 ));
17524 assert!(doctor_should_probe_api(
17525 crate::config::ApiProvider::OllamaCloud,
17526 cloud,
17527 crate::doctor::DoctorProbeRequest {
17528 probe_api: true,
17529 ..crate::doctor::DoctorProbeRequest::default()
17530 },
17531 ));
17532 assert!(!doctor_should_probe_api(
17533 crate::config::ApiProvider::OllamaCloud,
17534 cloud,
17535 crate::doctor::DoctorProbeRequest {
17536 probe_local: true,
17537 ..crate::doctor::DoctorProbeRequest::default()
17538 },
17539 ));
17540 }
17541
17542 #[test]
17543 fn custom_loopback_probe_also_requires_explicit_opt_in() {
17544 assert!(!doctor_should_probe_api(
17545 crate::config::ApiProvider::Custom,
17546 "http://localhost:8000/v1",
17547 crate::doctor::DoctorProbeRequest::default(),
17548 ));
17549 }
17550
17551 #[test]
17552 fn oauth_routes_skip_live_probe_to_keep_doctor_non_mutating() {
17553 let codex = Config {
17554 provider: Some("openai-codex".to_string()),
17555 ..Config::default()
17556 };
17557 assert!(!doctor_should_probe_auth(&codex));
17558
17559 let xai = Config {
17560 provider: Some("xai".to_string()),
17561 providers: Some(crate::config::ProvidersConfig {
17562 xai: crate::config::ProviderConfig {
17563 auth_mode: Some("oauth".to_string()),
17564 ..Default::default()
17565 },
17566 ..Default::default()
17567 }),
17568 ..Config::default()
17569 };
17570 assert!(!doctor_should_probe_auth(&xai));
17571 assert!(doctor_should_probe_auth(&Config::default()));
17572 }
17573}
17574
17575#[cfg(test)]
17576mod setup_helper_tests {
17577 use super::*;
17578 use std::collections::BTreeSet;
17579 use tempfile::TempDir;
17580
17581 #[test]
17582 fn init_tools_dir_creates_readme_and_example() {
17583 let tmp = TempDir::new().unwrap();
17584 let dir = tmp.path().join("tools");
17585 let (returned_dir, readme_status, example_status) =
17586 init_tools_dir(&dir, false).expect("init_tools_dir should succeed");
17587
17588 assert_eq!(returned_dir, dir);
17589 assert!(matches!(readme_status, WriteStatus::Created));
17590 assert!(matches!(example_status, WriteStatus::Created));
17591 assert!(dir.join("README.md").exists());
17592 assert!(dir.join("example.sh").exists());
17593
17594 let readme = std::fs::read_to_string(dir.join("README.md")).unwrap();
17595 assert!(
17596 readme.contains("# name:"),
17597 "README must show frontmatter convention"
17598 );
17599
17600 let example = std::fs::read_to_string(dir.join("example.sh")).unwrap();
17601 assert!(example.starts_with("#!/usr/bin/env sh"));
17602 assert!(example.contains("# name: example"));
17603 assert!(example.contains("# description:"));
17604 }
17605
17606 #[test]
17607 fn init_tools_dir_skips_existing_without_force() {
17608 let tmp = TempDir::new().unwrap();
17609 let dir = tmp.path().join("tools");
17610 let _ = init_tools_dir(&dir, false).unwrap();
17611 let (_, readme_status, example_status) = init_tools_dir(&dir, false).unwrap();
17612 assert!(matches!(readme_status, WriteStatus::SkippedExists));
17613 assert!(matches!(example_status, WriteStatus::SkippedExists));
17614 }
17615
17616 #[test]
17617 fn init_tools_dir_force_overwrites() {
17618 let tmp = TempDir::new().unwrap();
17619 let dir = tmp.path().join("tools");
17620 let _ = init_tools_dir(&dir, false).unwrap();
17621 std::fs::write(dir.join("example.sh"), "stale").unwrap();
17622 let (_, _, example_status) = init_tools_dir(&dir, true).unwrap();
17623 assert!(matches!(example_status, WriteStatus::Overwritten));
17624 let example = std::fs::read_to_string(dir.join("example.sh")).unwrap();
17625 assert_ne!(example, "stale");
17626 }
17627
17628 #[test]
17629 fn init_plugins_dir_creates_readme_and_example_layout() {
17630 let tmp = TempDir::new().unwrap();
17631 let dir = tmp.path().join("plugins");
17632 let (readme_path, manifest_path, skill_path, readme_status, manifest_status, skill_status) =
17633 init_plugins_dir(&dir, false).unwrap();
17634
17635 assert_eq!(readme_path, dir.join("README.md"));
17636 assert_eq!(manifest_path, dir.join("example").join("plugin.toml"));
17637 assert_eq!(
17638 skill_path,
17639 dir.join("example/skills/hello").join("SKILL.md")
17640 );
17641 assert!(matches!(readme_status, WriteStatus::Created));
17642 assert!(matches!(manifest_status, WriteStatus::Created));
17643 assert!(matches!(skill_status, WriteStatus::Created));
17644 assert!(readme_path.exists());
17645 assert!(manifest_path.exists());
17646 assert!(skill_path.exists());
17647
17648 let manifest = std::fs::read_to_string(&manifest_path).unwrap();
17649 assert!(manifest.contains("schema_version = 1"));
17650 assert!(manifest.contains("name = \"example\""));
17651 let validated =
17652 crate::plugins::manifest::PluginManifest::validate_from_path(&manifest_path)
17653 .expect("scaffolded plugin should validate");
17654 assert_eq!(validated.inventory.skills, 1);
17655 }
17656
17657 #[test]
17658 fn collect_clean_targets_finds_all_checkpoint_json_files() {
17659 let tmp = TempDir::new().unwrap();
17660 let dir = tmp.path();
17661 std::fs::write(dir.join("latest.json"), "{}").unwrap();
17662 std::fs::write(dir.join("offline_queue.json"), "[]").unwrap();
17663 std::fs::write(dir.join("some-session-id.json"), "{}").unwrap();
17665 std::fs::write(dir.join("notes.txt"), "keep").unwrap();
17667 std::fs::create_dir_all(dir.join("subdir")).unwrap();
17668
17669 let plan = collect_clean_targets(dir);
17670 assert_eq!(plan.targets.len(), 3);
17671 assert!(plan.targets.iter().any(|p| p.ends_with("latest.json")));
17672 assert!(
17673 plan.targets
17674 .iter()
17675 .any(|p| p.ends_with("offline_queue.json"))
17676 );
17677 assert!(
17678 plan.targets
17679 .iter()
17680 .any(|p| p.ends_with("some-session-id.json"))
17681 );
17682 assert!(!plan.targets.iter().any(|p| p.ends_with("notes.txt")));
17683 }
17684
17685 #[test]
17686 fn execute_clean_plan_removes_files_and_returns_them() {
17687 let tmp = TempDir::new().unwrap();
17688 let dir = tmp.path();
17689 let latest = dir.join("latest.json");
17690 let queue = dir.join("offline_queue.json");
17691 std::fs::write(&latest, "{}").unwrap();
17692 std::fs::write(&queue, "[]").unwrap();
17693
17694 let plan = collect_clean_targets(dir);
17695 let removed = execute_clean_plan(&plan).unwrap();
17696 assert_eq!(removed.len(), 2);
17697 assert!(!latest.exists());
17698 assert!(!queue.exists());
17699 }
17700
17701 #[test]
17702 fn run_setup_clean_dry_run_lists_targets_without_force() {
17703 let tmp = TempDir::new().unwrap();
17704 let dir = tmp.path();
17705 std::fs::write(dir.join("latest.json"), "{}").unwrap();
17706 run_setup_clean(dir, false).unwrap();
17707 assert!(dir.join("latest.json").exists());
17709 }
17710
17711 #[test]
17712 fn run_setup_clean_force_removes_files() {
17713 let tmp = TempDir::new().unwrap();
17714 let dir = tmp.path();
17715 std::fs::write(dir.join("latest.json"), "{}").unwrap();
17716 std::fs::write(dir.join("offline_queue.json"), "[]").unwrap();
17717 run_setup_clean(dir, true).unwrap();
17718 assert!(!dir.join("latest.json").exists());
17719 assert!(!dir.join("offline_queue.json").exists());
17720 }
17721
17722 #[test]
17723 fn run_setup_clean_handles_missing_dir() {
17724 let tmp = TempDir::new().unwrap();
17725 let dir = tmp.path().join("does-not-exist");
17726 run_setup_clean(&dir, true).unwrap();
17728 assert!(!dir.exists());
17729 }
17730
17731 fn with_home<T>(home: &Path, f: impl FnOnce() -> T) -> T {
17732 let prev_home = std::env::var_os("HOME");
17733 let prev_userprofile = std::env::var_os("USERPROFILE");
17734 unsafe {
17735 std::env::set_var("HOME", home);
17736 std::env::set_var("USERPROFILE", home);
17737 }
17738 let result = f();
17739 unsafe {
17740 match prev_home {
17741 Some(value) => std::env::set_var("HOME", value),
17742 None => std::env::remove_var("HOME"),
17743 }
17744 match prev_userprofile {
17745 Some(value) => std::env::set_var("USERPROFILE", value),
17746 None => std::env::remove_var("USERPROFILE"),
17747 }
17748 }
17749 result
17750 }
17751
17752 #[test]
17753 fn plain_launch_preserves_checkpoint_but_starts_fresh() {
17754 let _guard = crate::test_support::lock_test_env();
17755 let tmp = TempDir::new().unwrap();
17756 let workspace = tmp.path().join("workspace");
17757 std::fs::create_dir_all(&workspace).unwrap();
17758
17759 with_home(tmp.path(), || {
17760 let manager = SessionManager::default_location().expect("manager");
17761 let messages = vec![Message {
17762 role: "user".to_string(),
17763 content: vec![ContentBlock::Text {
17764 text: "in flight".to_string(),
17765 cache_control: None,
17766 }],
17767 }];
17768 let session = create_saved_session(&messages, "test-model", &workspace, 0, None);
17769 let session_id = session.metadata.id.clone();
17770 manager.save_checkpoint(&session).expect("save checkpoint");
17771
17772 preserve_interrupted_checkpoint_for_explicit_resume(&workspace);
17773
17774 assert!(
17775 manager
17776 .load_session_checkpoint(&session_id)
17777 .expect("load checkpoint")
17778 .is_some(),
17779 "normal launch must leave the per-session checkpoint in place \
17780 (it may belong to a live session; `--continue` consumes it)"
17781 );
17782 assert!(
17784 manager
17785 .load_session_checkpoint(&session_id)
17786 .expect("load checkpoint")
17787 .is_some(),
17788 "checkpoint stays in checkpoints/ for --continue"
17789 );
17790 });
17791 }
17792
17793 #[test]
17794 fn plain_launch_consumes_legacy_checkpoint_after_preserving_it() {
17795 let _guard = crate::test_support::lock_test_env();
17796 let tmp = TempDir::new().unwrap();
17797 let workspace = tmp.path().join("workspace");
17798 std::fs::create_dir_all(&workspace).unwrap();
17799
17800 with_home(tmp.path(), || {
17801 let manager = SessionManager::default_location().expect("manager");
17802 let session = create_saved_session(
17803 &[Message {
17804 role: "user".to_string(),
17805 content: vec![ContentBlock::Text {
17806 text: "legacy in flight".to_string(),
17807 cache_control: None,
17808 }],
17809 }],
17810 "test-model",
17811 &workspace,
17812 0,
17813 None,
17814 );
17815 let session_id = session.metadata.id.clone();
17816 write_legacy_checkpoint(&manager, &session);
17817
17818 preserve_interrupted_checkpoint_for_explicit_resume(&workspace);
17819
17820 assert!(
17821 manager
17822 .load_legacy_checkpoint()
17823 .expect("load legacy checkpoint")
17824 .is_none(),
17825 "normal launch should consume the legacy single-slot checkpoint"
17826 );
17827 assert!(
17829 manager
17830 .load_session_checkpoint(&session_id)
17831 .expect("load checkpoint")
17832 .is_some(),
17833 "checkpoint stays in checkpoints/ for --continue"
17834 );
17835 });
17836 }
17837
17838 #[test]
17839 fn continue_recovers_same_workspace_checkpoint() {
17840 let _guard = crate::test_support::lock_test_env();
17841 let tmp = TempDir::new().unwrap();
17842 let workspace = tmp.path().join("workspace");
17843 std::fs::create_dir_all(&workspace).unwrap();
17844
17845 with_home(tmp.path(), || {
17846 let manager = SessionManager::default_location().expect("manager");
17847 let messages = vec![Message {
17848 role: "user".to_string(),
17849 content: vec![ContentBlock::Text {
17850 text: "continue me".to_string(),
17851 cache_control: None,
17852 }],
17853 }];
17854 let session = create_saved_session(&messages, "test-model", &workspace, 0, None);
17855 let session_id = session.metadata.id.clone();
17856 manager.save_checkpoint(&session).expect("save checkpoint");
17857
17858 let recovered = recover_interrupted_checkpoint_for_resume(&workspace);
17859
17860 assert_eq!(recovered.as_deref(), Some(session_id.as_str()));
17861 assert!(
17862 manager
17863 .load_session_checkpoint(&session_id)
17864 .expect("load checkpoint")
17865 .is_none(),
17866 "--continue should consume the per-session checkpoint"
17867 );
17868 assert!(manager.load_session(&session_id).is_ok());
17869 });
17870 }
17871
17872 fn write_legacy_checkpoint(manager: &SessionManager, session: &session_manager::SavedSession) {
17875 let checkpoints = manager.sessions_dir().join("checkpoints");
17876 std::fs::create_dir_all(&checkpoints).expect("create checkpoints dir");
17877 let content = serde_json::to_string_pretty(session).expect("serialize checkpoint");
17878 std::fs::write(checkpoints.join("latest.json"), content).expect("write legacy checkpoint");
17879 }
17880
17881 #[test]
17882 fn continue_recovers_legacy_checkpoint_and_migrates_it() {
17883 let _guard = crate::test_support::lock_test_env();
17884 let tmp = TempDir::new().unwrap();
17885 let workspace = tmp.path().join("workspace");
17886 std::fs::create_dir_all(&workspace).unwrap();
17887
17888 with_home(tmp.path(), || {
17889 let manager = SessionManager::default_location().expect("manager");
17890 let messages = vec![Message {
17891 role: "user".to_string(),
17892 content: vec![ContentBlock::Text {
17893 text: "legacy continue".to_string(),
17894 cache_control: None,
17895 }],
17896 }];
17897 let session = create_saved_session(&messages, "test-model", &workspace, 0, None);
17898 let session_id = session.metadata.id.clone();
17899 write_legacy_checkpoint(&manager, &session);
17900
17901 let recovered = recover_interrupted_checkpoint_for_resume(&workspace);
17902
17903 assert_eq!(recovered.as_deref(), Some(session_id.as_str()));
17904 assert!(
17905 manager.load_session(&session_id).is_ok(),
17906 "recovered legacy checkpoint must be loadable as a session"
17907 );
17908 assert!(
17909 manager
17910 .load_session_checkpoint(&session_id)
17911 .expect("load per-session checkpoint")
17912 .is_some(),
17913 "legacy recovery must migrate to a per-session checkpoint file"
17914 );
17915 assert!(
17916 manager
17917 .load_legacy_checkpoint()
17918 .expect("load legacy checkpoint")
17919 .is_some(),
17920 "legacy latest.json stays in place for one more release"
17921 );
17922 });
17923 }
17924
17925 #[test]
17926 fn continue_refuses_checkpoint_from_other_workspace() {
17927 let _guard = crate::test_support::lock_test_env();
17928 let tmp = TempDir::new().unwrap();
17929 let launch_workspace = tmp.path().join("launch-workspace");
17930 let other_workspace = tmp.path().join("other-workspace");
17931 std::fs::create_dir_all(&launch_workspace).unwrap();
17932 std::fs::create_dir_all(&other_workspace).unwrap();
17933
17934 with_home(tmp.path(), || {
17935 let manager = SessionManager::default_location().expect("manager");
17936 let messages = vec![Message {
17937 role: "user".to_string(),
17938 content: vec![ContentBlock::Text {
17939 text: "belongs elsewhere".to_string(),
17940 cache_control: None,
17941 }],
17942 }];
17943 let session = create_saved_session(&messages, "test-model", &other_workspace, 0, None);
17944 let session_id = session.metadata.id.clone();
17945 manager.save_checkpoint(&session).expect("save checkpoint");
17946
17947 let recovered = recover_interrupted_checkpoint_for_resume(&launch_workspace);
17948
17949 assert_eq!(recovered, None, "workspace mismatch must refuse recovery");
17950 assert!(
17951 manager
17952 .load_session_checkpoint(&session_id)
17953 .expect("load checkpoint")
17954 .is_some(),
17955 "another workspace's checkpoint file must be left untouched"
17956 );
17957 });
17958 }
17959
17960 #[test]
17961 fn continue_twice_does_not_clobber_newer_session_with_stale_legacy_checkpoint() {
17962 let _guard = crate::test_support::lock_test_env();
17963 let tmp = TempDir::new().unwrap();
17964 let workspace = tmp.path().join("workspace");
17965 std::fs::create_dir_all(&workspace).unwrap();
17966
17967 with_home(tmp.path(), || {
17968 let manager = SessionManager::default_location().expect("manager");
17969 let stale = create_saved_session(
17970 &[Message {
17971 role: "user".to_string(),
17972 content: vec![ContentBlock::Text {
17973 text: "crash-time state".to_string(),
17974 cache_control: None,
17975 }],
17976 }],
17977 "test-model",
17978 &workspace,
17979 0,
17980 None,
17981 );
17982 let session_id = stale.metadata.id.clone();
17983 write_legacy_checkpoint(&manager, &stale);
17984
17985 let mut advanced = stale.clone();
17988 advanced.messages.push(Message {
17989 role: "assistant".to_string(),
17990 content: vec![ContentBlock::Text {
17991 text: "post-recovery progress".to_string(),
17992 cache_control: None,
17993 }],
17994 });
17995 advanced.metadata.message_count = advanced.messages.len();
17996 advanced.metadata.updated_at = stale.metadata.updated_at + chrono::Duration::hours(1);
17997 manager.save_session(&advanced).expect("save newer session");
17998
17999 let recovered = recover_interrupted_checkpoint_for_resume(&workspace);
18000
18001 assert_eq!(recovered.as_deref(), Some(session_id.as_str()));
18002 let persisted = manager.load_session(&session_id).expect("load session");
18003 assert_eq!(
18004 persisted.messages.len(),
18005 advanced.messages.len(),
18006 "stale checkpoint content must not overwrite the newer session"
18007 );
18008 });
18009 }
18010
18011 #[test]
18012 fn dotenv_status_points_to_example_when_present() {
18013 let tmp = TempDir::new().unwrap();
18014 std::fs::write(tmp.path().join(".env.example"), "DEEPSEEK_API_KEY=\n").unwrap();
18015
18016 assert_eq!(
18017 dotenv_status_line(tmp.path()),
18018 ".env not present in workspace (run `cp .env.example .env` and edit)"
18019 );
18020
18021 std::fs::write(tmp.path().join(".env"), "DEEPSEEK_API_KEY=test\n").unwrap();
18022 assert!(dotenv_status_line(tmp.path()).contains(".env present at"));
18023 }
18024
18025 #[test]
18026 fn env_example_is_trackable_and_every_key_is_wired() {
18027 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
18028 let env_example = std::fs::read_to_string(root.join(".env.example")).unwrap();
18029 let gitignore = std::fs::read_to_string(root.join(".gitignore")).unwrap();
18030
18031 assert!(gitignore.contains("!.env.example"));
18032
18033 let keys = documented_env_keys(&env_example);
18034 for required in [
18035 "DEEPSEEK_API_KEY",
18036 "NVIDIA_API_KEY",
18037 "NVIDIA_NIM_API_KEY",
18038 "ATLASCLOUD_API_KEY",
18039 ] {
18040 assert!(
18041 keys.contains(required),
18042 ".env.example is missing {required}"
18043 );
18044 }
18045
18046 for key in &keys {
18047 assert!(
18048 is_workspace_dotenv_credential_key(key),
18049 ".env.example documents non-credential control setting {key}"
18050 );
18051 }
18052
18053 let sources = [
18054 include_str!("config.rs"),
18055 include_str!("logging.rs"),
18056 include_str!("../../config/src/lib.rs"),
18057 include_str!("../../config/src/provider.rs"),
18058 include_str!("../../cli/src/main.rs"),
18059 ]
18060 .join("\n");
18061
18062 for key in keys {
18063 assert!(
18064 sources.contains(&key),
18065 ".env.example documents {key}, but no source file references it"
18066 );
18067 }
18068 }
18069
18070 fn documented_env_keys(content: &str) -> BTreeSet<String> {
18071 content
18072 .lines()
18073 .filter_map(|line| {
18074 let trimmed = line.trim();
18075 let uncommented = trimmed
18076 .strip_prefix('#')
18077 .map(str::trim_start)
18078 .unwrap_or(trimmed);
18079 let (key, _) = uncommented.split_once('=')?;
18080 let key = key.trim();
18081 let is_env_key = key
18082 .chars()
18083 .all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_')
18084 && key.chars().any(|ch| ch == '_');
18085 is_env_key.then(|| key.to_string())
18086 })
18087 .collect()
18088 }
18089
18090 #[test]
18091 fn custom_provider_env_source_precedes_saved_secret_store() {
18092 let _lock = crate::test_support::lock_test_env();
18093 let temp = TempDir::new().expect("temp home");
18094 let codewhale_home = temp.path().join("codewhale-home");
18095 std::fs::create_dir_all(&codewhale_home).expect("create codewhale home");
18096 let _home =
18097 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", codewhale_home.as_os_str());
18098 let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
18099 let _declared_env =
18100 crate::test_support::EnvVarGuard::set("QA_CUSTOM_API_KEY", "declared-env-key");
18101 let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
18102 let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
18103 codewhale_secrets::Secrets::auto_detect()
18104 .set("custom", "saved-custom-secret")
18105 .expect("save secret");
18106
18107 let mut custom = std::collections::HashMap::new();
18108 custom.insert(
18109 "qa-gateway".to_string(),
18110 crate::config::ProviderConfig {
18111 kind: Some("openai-compatible".to_string()),
18112 base_url: Some("https://gateway.example.test/v1".to_string()),
18113 model: Some("qa-model".to_string()),
18114 api_key_env: Some("QA_CUSTOM_API_KEY".to_string()),
18115 ..Default::default()
18116 },
18117 );
18118 let config = Config {
18119 provider: Some("qa-gateway".to_string()),
18120 providers: Some(crate::config::ProvidersConfig {
18121 custom,
18122 ..Default::default()
18123 }),
18124 ..Config::default()
18125 };
18126
18127 assert_eq!(resolve_api_key_source(&config), ApiKeySource::EnvDeclared);
18128 assert_eq!(
18129 config.deepseek_api_key().expect("custom key"),
18130 "declared-env-key"
18131 );
18132 }
18133
18134 #[test]
18135 fn named_custom_provider_does_not_report_generic_secret_store() {
18136 let _lock = crate::test_support::lock_test_env();
18137 let temp = TempDir::new().expect("temp home");
18138 let codewhale_home = temp.path().join("codewhale-home");
18139 std::fs::create_dir_all(&codewhale_home).expect("create codewhale home");
18140 let _home =
18141 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", codewhale_home.as_os_str());
18142 let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
18143 let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
18144 let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
18145 codewhale_secrets::Secrets::auto_detect()
18146 .set("custom", "unrelated-custom-secret")
18147 .expect("save secret");
18148
18149 let mut custom = std::collections::HashMap::new();
18150 custom.insert(
18151 "qa-gateway".to_string(),
18152 crate::config::ProviderConfig {
18153 kind: Some("openai-compatible".to_string()),
18154 base_url: Some("https://gateway.example.test/v1".to_string()),
18155 model: Some("qa-model".to_string()),
18156 auth_mode: Some("api_key".to_string()),
18157 ..Default::default()
18158 },
18159 );
18160 let config = Config {
18161 provider: Some("qa-gateway".to_string()),
18162 providers: Some(crate::config::ProvidersConfig {
18163 custom,
18164 ..Default::default()
18165 }),
18166 ..Config::default()
18167 };
18168
18169 assert_eq!(resolve_api_key_source(&config), ApiKeySource::Unknown);
18170 assert!(config.deepseek_api_key().is_err());
18171 }
18172
18173 #[test]
18174 fn custom_built_in_endpoint_does_not_report_ambient_provider_key() {
18175 let _lock = crate::test_support::lock_test_env();
18176 let _openrouter =
18177 crate::test_support::EnvVarGuard::set("OPENROUTER_API_KEY", "ambient-key");
18178 let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
18179 let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
18180 let mut providers = crate::config::ProvidersConfig::default();
18181 providers.openrouter.base_url = Some("https://gateway.example.test/v1".to_string());
18182 let config = Config {
18183 provider: Some("openrouter".to_string()),
18184 providers: Some(providers),
18185 ..Config::default()
18186 };
18187
18188 assert_eq!(resolve_api_key_source(&config), ApiKeySource::Unknown);
18189 assert!(config.deepseek_api_key().is_err());
18190 }
18191
18192 #[test]
18193 fn ollama_doctor_credential_source_is_route_aware() {
18194 let local = Config {
18195 provider: Some("ollama".to_string()),
18196 ..Config::default()
18197 };
18198 assert_eq!(resolve_api_key_source(&local), ApiKeySource::LocalRuntime);
18199 assert_eq!(
18200 resolve_credential_diagnostic(&local).availability,
18201 CredentialAvailability::NotRequired
18202 );
18203
18204 let ollama_config = |base_url: &str| Config {
18205 provider: Some("ollama".to_string()),
18206 providers: Some(crate::config::ProvidersConfig {
18207 ollama: crate::config::ProviderConfig {
18208 base_url: Some(base_url.to_string()),
18209 ..Default::default()
18210 },
18211 ..Default::default()
18212 }),
18213 ..Config::default()
18214 };
18215 let cloud = ollama_config(codewhale_config::provider::OLLAMA_CLOUD_BASE_URL);
18216 assert_eq!(
18217 cloud.api_provider(),
18218 crate::config::ApiProvider::OllamaCloud
18219 );
18220 assert_eq!(
18221 resolve_api_key_source(&cloud),
18222 ApiKeySource::SecretStoreUnprobed
18223 );
18224 assert_eq!(
18225 resolve_credential_diagnostic(&cloud).availability,
18226 CredentialAvailability::NotProbed
18227 );
18228 assert_eq!(doctor_auth_scheme(&cloud), "bearer");
18229 let report = doctor_route_report(&cloud);
18230 assert_eq!(report["provider"], "ollama-cloud");
18231 assert_eq!(report["provider_config_table"], "ollama_cloud");
18232
18233 let custom_remote = ollama_config("https://ollama-gateway.example.test/v1");
18234 assert_eq!(
18235 resolve_api_key_source(&custom_remote),
18236 ApiKeySource::Unknown
18237 );
18238 assert_eq!(
18239 resolve_credential_diagnostic(&custom_remote).availability,
18240 CredentialAvailability::Unknown
18241 );
18242 }
18243
18244 #[test]
18245 fn auth_mode_none_reports_distinct_no_auth_source_and_scheme() {
18246 let _lock = crate::test_support::lock_test_env();
18247 let _openrouter =
18248 crate::test_support::EnvVarGuard::set("OPENROUTER_API_KEY", "ambient-key");
18249 let mut providers = crate::config::ProvidersConfig::default();
18250 providers.openrouter.auth_mode = Some("none".to_string());
18251 providers.openrouter.api_key = Some("configured-key".to_string());
18252 let config = Config {
18253 provider: Some("openrouter".to_string()),
18254 providers: Some(providers),
18255 ..Config::default()
18256 };
18257
18258 assert_eq!(resolve_api_key_source(&config), ApiKeySource::NoAuth);
18259 assert_eq!(doctor_api_key_source_label(ApiKeySource::NoAuth), "none");
18260 assert_eq!(doctor_auth_scheme(&config), "none");
18261 assert_eq!(config.deepseek_api_key().expect("no-auth route"), "");
18262 }
18263
18264 #[test]
18265 fn resolve_api_key_source_prefers_config_over_env() {
18266 let _guard = crate::test_support::lock_test_env();
18267 let prev = std::env::var("DEEPSEEK_API_KEY").ok();
18268 let prev_source = std::env::var("DEEPSEEK_API_KEY_SOURCE").ok();
18269 unsafe {
18270 std::env::set_var("DEEPSEEK_API_KEY", "stale-env-key");
18271 std::env::remove_var("DEEPSEEK_API_KEY_SOURCE");
18272 }
18273 let cfg = Config {
18274 api_key: Some("fresh-config-key".to_string()),
18275 ..Config::default()
18276 };
18277 let source = resolve_api_key_source(&cfg);
18278 match prev {
18279 Some(value) => unsafe { std::env::set_var("DEEPSEEK_API_KEY", value) },
18280 None => unsafe { std::env::remove_var("DEEPSEEK_API_KEY") },
18281 }
18282 match prev_source {
18283 Some(value) => unsafe { std::env::set_var("DEEPSEEK_API_KEY_SOURCE", value) },
18284 None => unsafe { std::env::remove_var("DEEPSEEK_API_KEY_SOURCE") },
18285 }
18286 assert_eq!(source, ApiKeySource::ConfigDeclared);
18287 }
18288
18289 #[test]
18290 fn resolve_api_key_source_reports_active_provider_env_from_metadata() {
18291 let _guard = crate::test_support::lock_test_env();
18292 let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
18293 let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
18294 let _anthropic_key =
18295 crate::test_support::EnvVarGuard::set("ANTHROPIC_API_KEY", "test-anthropic-key");
18296 let cfg = Config {
18297 provider: Some("anthropic".to_string()),
18298 ..Config::default()
18299 };
18300
18301 let source = resolve_api_key_source(&cfg);
18302
18303 assert_eq!(source, ApiKeySource::SecretStoreUnprobed);
18304 }
18305
18306 #[test]
18307 fn resolve_api_key_source_ignores_unresolved_provider_command_metadata() {
18308 let _guard = crate::test_support::lock_test_env();
18309 let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
18310 let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
18311 let _openai_key = crate::test_support::EnvVarGuard::remove("OPENAI_API_KEY");
18312 let mut providers = crate::config::ProvidersConfig::default();
18313 providers.openai.auth = Some(codewhale_config::ProviderAuthSourceToml {
18314 source: codewhale_config::AuthSourceKind::Command,
18315 command: vec!["secret-tool".to_string(), "lookup".to_string()],
18316 timeout_ms: Some(2000),
18317 secret_id: None,
18318 });
18319 let cfg = Config {
18320 provider: Some("openai".to_string()),
18321 providers: Some(providers),
18322 ..Config::default()
18323 };
18324
18325 let source = resolve_api_key_source(&cfg);
18326
18327 assert_eq!(source, ApiKeySource::ExternalAuthDeclared);
18328 assert!(cfg.deepseek_api_key().is_err());
18329 }
18330
18331 #[test]
18332 fn resolve_api_key_source_ignores_unresolved_provider_secret_metadata() {
18333 let _guard = crate::test_support::lock_test_env();
18334 let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
18335 let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
18336 let _openai_key = crate::test_support::EnvVarGuard::remove("OPENAI_API_KEY");
18337 let mut providers = crate::config::ProvidersConfig::default();
18338 providers.openai.auth = Some(codewhale_config::ProviderAuthSourceToml {
18339 source: codewhale_config::AuthSourceKind::Secret,
18340 command: Vec::new(),
18341 timeout_ms: None,
18342 secret_id: Some("codewhale/openai".to_string()),
18343 });
18344 let cfg = Config {
18345 provider: Some("openai".to_string()),
18346 providers: Some(providers),
18347 ..Config::default()
18348 };
18349
18350 let source = resolve_api_key_source(&cfg);
18351
18352 assert_eq!(source, ApiKeySource::ExternalAuthDeclared);
18353 assert!(cfg.deepseek_api_key().is_err());
18354 }
18355
18356 #[test]
18357 fn resolve_api_key_source_ignores_root_deepseek_key_for_other_provider() {
18358 let _guard = crate::test_support::lock_test_env();
18359 let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
18360 let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
18361 let _openrouter_key = crate::test_support::EnvVarGuard::remove("OPENROUTER_API_KEY");
18362 let cfg = Config {
18363 provider: Some("openrouter".to_string()),
18364 api_key: Some("legacy-deepseek-root-key".to_string()),
18365 ..Config::default()
18366 };
18367
18368 let source = resolve_api_key_source(&cfg);
18369
18370 assert_eq!(source, ApiKeySource::SecretStoreUnprobed);
18371 }
18372
18373 #[test]
18374 fn provider_status_helpers_use_provider_metadata() {
18375 assert_eq!(
18376 provider_config_table_key(crate::config::ApiProvider::Anthropic),
18377 "anthropic"
18378 );
18379 assert_eq!(
18380 provider_config_table_key(crate::config::ApiProvider::SiliconflowCn),
18381 "siliconflow_cn"
18382 );
18383 }
18384
18385 #[test]
18386 fn skills_count_for_returns_zero_for_missing_dir() {
18387 let tmp = TempDir::new().unwrap();
18388 let dir = tmp.path().join("nope");
18389 assert_eq!(skills_count_for(&dir), 0);
18390 }
18391
18392 #[test]
18393 fn skills_count_for_counts_valid_skill_dirs() {
18394 let tmp = TempDir::new().unwrap();
18395 let dir = tmp.path().join("skills");
18396 let skill_dir = dir.join("getting-started");
18397 std::fs::create_dir_all(&skill_dir).unwrap();
18398 std::fs::write(
18399 skill_dir.join("SKILL.md"),
18400 "---\nname: getting-started\ndescription: hi\n---\nbody",
18401 )
18402 .unwrap();
18403 assert_eq!(skills_count_for(&dir), 1);
18404 }
18405}
18406
18407#[cfg(test)]
18408#[path = "tests/pr_prompt.rs"]
18409mod pr_prompt_tests;
18410
18411#[cfg(test)]
18412#[path = "tests/telemetry_surface.rs"]
18413mod telemetry_surface_tests;
18414
18415#[cfg(test)]
18416#[path = "tests/telemetry_counters.rs"]
18417mod telemetry_counter_tests;