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 artifacts;
26mod audit;
27mod auto_reasoning;
28mod automation_manager;
29mod child_env;
30mod client;
31mod codex_model_cache;
32mod command_safety;
33mod commands;
34mod compaction;
35mod composer_history;
36mod composer_stash;
37mod config;
38mod config_persistence;
39mod config_ui;
40mod context_budget;
41mod context_report;
42mod continual_harness;
43mod core;
44mod cost_status;
45mod deepseek_theme;
46mod dependencies;
47mod doctor;
48mod dsh_credentials;
49mod elapsed;
50mod error_taxonomy;
51mod eval;
52mod execpolicy;
53mod external_credentials;
54mod fast_hash;
55mod features;
56mod fleet;
57mod goal_loop;
58mod hashing;
59mod hooks;
60mod image_attach;
61mod integrations;
62mod lane_control;
63mod llm_client;
64mod llm_response_cache;
65mod localization;
66mod logging;
67mod lsp;
68mod mcp;
69mod mcp_server;
70mod model_catalog;
71mod model_context;
72mod model_inventory;
73mod model_profile;
74mod model_registry;
75mod model_routing;
76mod models;
77mod models_dev_live;
78mod native_memory;
79mod network_policy;
80mod oauth;
81mod palette;
82mod plugins;
83mod prefix_cache;
84mod pricing;
85mod project_context;
86mod project_context_cache;
87mod prompt_zones;
88mod prompts;
89mod provider_lake;
90mod provider_readiness;
91mod purge;
92mod regex_cache;
93mod remote_control;
94mod remote_setup;
95pub mod repl;
96mod repo_law;
97mod request_manifest;
98mod request_tuning;
99mod resource_telemetry;
100mod retry_status;
101pub mod rlm;
102mod route_billing;
103mod route_budget;
104mod route_receipt;
105mod route_runtime;
106mod runtime_api;
107mod runtime_handoff;
108mod runtime_log;
109mod runtime_policy;
110mod runtime_threads;
111mod safe_label;
112mod sandbox;
113mod scorecard;
114#[allow(dead_code)]
115mod session_diagnostics;
116#[cfg(test)]
120#[path = "main/tests.rs"]
121mod doctor_loader_tests;
122#[cfg(test)]
123mod session_control_acceptance;
124#[allow(dead_code)]
125mod session_manager;
126mod session_peek;
127mod session_projection;
128mod session_resume;
129pub mod session_tree;
130mod settings;
131mod shell_dispatcher;
132mod skill_state;
133mod skills;
134mod snapshot;
135mod startup_trace;
136mod task_manager;
137mod telemetry_notice;
138#[cfg(test)]
139mod test_support;
140use codewhale_release::tls;
143mod todo_snapshot;
144mod tool_history_repair;
145mod tool_inspection;
146mod tool_output_receipts;
147mod tools;
148mod tui;
149mod turn_route_plan;
150mod utils;
151mod vision;
152mod work_graph;
153mod worker_profile;
154mod working_set;
155mod workspace_discovery;
156mod workspace_trust;
157mod xai_oauth;
158
159use crate::config::{Config, DEFAULT_TEXT_MODEL, MAX_SUBAGENTS, effective_home_dir};
160use crate::eval::{EvalHarness, EvalHarnessConfig, ScenarioStepKind};
161use crate::features::{Feature, render_feature_table};
162use crate::llm_client::LlmClient;
163use crate::mcp::{
164 McpCommandAvailability, McpConfig, McpPool, McpServerConfig, McpServerOAuthConfig,
165 is_relative_stdio_path_arg,
166};
167use crate::models::{ContentBlock, Message, MessageRequest, SystemPrompt};
168use crate::session_manager::{SessionManager, create_saved_session, truncate_id};
169use crate::tui::history::{summarize_tool_args, summarize_tool_output};
170
171#[cfg(windows)]
172fn configure_windows_console_utf8() {
173 use windows::Win32::System::Console::{SetConsoleCP, SetConsoleOutputCP};
174
175 const CP_UTF8: u32 = 65001;
176 unsafe {
177 let _ = SetConsoleCP(CP_UTF8);
178 let _ = SetConsoleOutputCP(CP_UTF8);
179 }
180}
181
182#[cfg(not(windows))]
183fn configure_windows_console_utf8() {}
184
185fn install_rustls_crypto_provider() {
186 crate::tls::ensure_rustls_crypto_provider();
187}
188
189#[derive(Parser, Debug)]
190#[command(
191 name = "codewhale-tui",
192 bin_name = "codewhale-tui",
193 author,
194 version = env!("DEEPSEEK_BUILD_VERSION"),
195 about = "Codewhale terminal coding agent",
196 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."
197)]
198struct Cli {
199 #[command(subcommand)]
201 command: Option<Commands>,
202
203 #[command(flatten)]
204 feature_toggles: FeatureToggles,
205
206 #[arg(short, long, value_name = "PROMPT", num_args = 1..)]
208 prompt: Vec<String>,
209
210 #[arg(long, hide = true)]
212 yolo: bool,
213
214 #[arg(long)]
216 max_subagents: Option<usize>,
217
218 #[arg(long)]
220 config: Option<PathBuf>,
221
222 #[arg(short, long)]
224 verbose: bool,
225
226 #[arg(long)]
228 profile: Option<String>,
229
230 #[arg(short, long)]
232 workspace: Option<PathBuf>,
233
234 #[arg(short, long)]
236 resume: Option<String>,
237
238 #[arg(short = 'c', long = "continue")]
240 continue_session: bool,
241
242 #[arg(long = "mouse-capture", conflicts_with = "no_mouse_capture")]
246 mouse_capture: bool,
247
248 #[arg(long = "no-mouse-capture", conflicts_with = "mouse_capture")]
250 no_mouse_capture: bool,
251
252 #[arg(long)]
254 skip_onboarding: bool,
255
256 #[arg(long, hide = true)]
258 remote_control: bool,
259
260 #[arg(long = "fresh")]
262 fresh: bool,
263
264 #[arg(long = "no-project-config")]
266 no_project_config: bool,
267}
268
269#[derive(Subcommand, Debug, Clone)]
270#[allow(clippy::large_enum_variant)]
271enum Commands {
272 Doctor(DoctorArgs),
274 SessionDiagnostics(SessionDiagnosticsArgs),
276 Setup(SetupArgs),
278 RemoteSetup(remote_setup::RemoteSetupArgs),
280 Completions {
282 #[arg(value_enum)]
284 shell: Shell,
285 },
286 Sessions {
288 #[arg(short, long, default_value = "20")]
290 limit: usize,
291 #[arg(short, long)]
293 search: Option<String>,
294 },
295 Init,
297 Login {
299 #[arg(long)]
301 api_key: Option<String>,
302 },
303 Logout,
305 Auth(TuiAuthArgs),
307 Models(ModelsArgs),
309 #[command(visible_alias = "tts")]
311 Speech(SpeechArgs),
312 Exec(ExecArgs),
314 Fleet(FleetArgs),
316 #[command(name = "workflow-tool", hide = true)]
318 WorkflowTool(WorkflowToolArgs),
319 Review(ReviewArgs),
321 Pr {
323 #[arg(value_name = "NUMBER")]
325 number: u32,
326 #[arg(short = 'R', long)]
329 repo: Option<String>,
330 #[arg(long, default_value_t = false)]
334 checkout: bool,
335 },
336 Apply(ApplyArgs),
338 Eval(EvalArgs),
340 Scorecard(ScorecardArgs),
342 Mcp {
344 #[command(subcommand)]
345 command: McpCommand,
346 },
347 Features(FeaturesCli),
349 Integrations {
351 #[command(subcommand)]
352 command: IntegrationsCommand,
353 },
354 Sandbox(SandboxArgs),
356 Serve(ServeArgs),
358 Resume {
360 #[arg(value_name = "SESSION_ID")]
362 session_id: Option<String>,
363 #[arg(long = "last", default_value_t = false, conflicts_with = "session_id")]
365 last: bool,
366 },
367 Fork {
369 #[arg(value_name = "SESSION_ID")]
371 session_id: Option<String>,
372 #[arg(long = "last", default_value_t = false, conflicts_with = "session_id")]
374 last: bool,
375 },
376}
377
378#[derive(Args, Debug, Clone)]
379#[command(after_help = "\
380Examples:
381 codewhale exec \"explain this function\"
382 codewhale exec --auto \"list crates/ with ls\"
383 codewhale exec --auto --output-format stream-json \"fix the failing test\"
384
385Plain `codewhale exec` is a one-shot model response. Use `--auto` for
386non-interactive agent-with-tools execution. `--auto` does not change the
387sandbox posture or elevate a denied tool. Use `--sandbox danger-full-access`
388or `--allow-sandbox-elevation` to explicitly authorize sandbox elevation.
389")]
390struct ExecArgs {
391 #[arg(long)]
393 model: Option<String>,
394 #[arg(long)]
400 provider: Option<String>,
401 #[arg(long = "reasoning-effort", value_name = "EFFORT")]
404 reasoning_effort: Option<String>,
405 #[arg(long, default_value_t = false)]
408 auto: bool,
409 #[arg(long, value_name = "POLICY")]
411 sandbox: Option<String>,
412 #[arg(long, default_value_t = false)]
414 allow_sandbox_elevation: bool,
415 #[arg(long, default_value_t = false, conflicts_with = "output_format")]
417 json: bool,
418 #[arg(long, value_name = "SESSION_ID", conflicts_with_all = ["session_id", "continue_session"])]
420 resume: Option<String>,
421 #[arg(long = "session-id", value_name = "SESSION_ID", conflicts_with_all = ["resume", "continue_session"])]
423 session_id: Option<String>,
424 #[arg(long = "continue", default_value_t = false, conflicts_with_all = ["resume", "session_id"])]
426 continue_session: bool,
427 #[arg(long, value_enum, default_value_t = ExecOutputFormat::Text)]
429 output_format: ExecOutputFormat,
430 #[arg(long, value_delimiter = ',')]
433 allowed_tools: Option<Vec<String>>,
434 #[arg(long, value_delimiter = ',')]
436 disallowed_tools: Option<Vec<String>>,
437 #[arg(long, value_parser = clap::value_parser!(u32).range(1..))]
439 max_turns: Option<u32>,
440 #[arg(long)]
442 append_system_prompt: Option<String>,
443 #[arg(long, value_name = "JSON", hide = true)]
445 tool_authority_json: Option<String>,
446 #[arg(
448 value_name = "PROMPT",
449 required = true,
450 trailing_var_arg = true,
451 allow_hyphen_values = true
452 )]
453 prompt: Vec<String>,
454}
455
456#[derive(Args, Debug, Clone)]
457struct WorkflowToolArgs {
458 #[arg(long, value_name = "SOURCE")]
460 approval_source: String,
461 #[arg(long, value_name = "JSON")]
463 input_json: String,
464}
465
466#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
467enum ExecOutputFormat {
468 Text,
469 #[value(name = "stream-json")]
470 StreamJson,
471}
472
473#[derive(Args, Debug, Clone)]
474struct TuiAuthArgs {
475 #[command(subcommand)]
476 command: TuiAuthCommand,
477}
478
479#[derive(Subcommand, Debug, Clone)]
480enum TuiAuthCommand {
481 #[command(name = "xai-device")]
483 XaiDevice,
484}
485
486const CODEWHALE_TOOL_SURFACE_ENV: &str = "CODEWHALE_TOOL_SURFACE";
487const SHELL_ONLY_EXEC_TOOLS: &[&str] = &["bash"];
488
489#[derive(Debug, Clone, Copy, PartialEq, Eq)]
490enum ExecToolSurface {
491 ShellOnly,
492}
493
494fn exec_tool_surface_from_env() -> Option<ExecToolSurface> {
495 std::env::var(CODEWHALE_TOOL_SURFACE_ENV)
496 .ok()
497 .and_then(|value| {
498 if should_warn_unknown_exec_tool_surface(&value) {
499 eprintln!(
500 "warning: unrecognized {CODEWHALE_TOOL_SURFACE_ENV}; leaving exec tool surface unchanged. Use `shell-only`, `full`, or `native-tools`."
501 );
502 }
503 parse_exec_tool_surface(&value)
504 })
505}
506
507fn parse_exec_tool_surface(value: &str) -> Option<ExecToolSurface> {
508 match value.trim().to_ascii_lowercase().as_str() {
509 "shell-only" | "shell_only" | "shell" => Some(ExecToolSurface::ShellOnly),
510 "full" | "native-tools" | "native_tools" | "" => None,
511 _ => None,
512 }
513}
514
515fn should_warn_unknown_exec_tool_surface(value: &str) -> bool {
516 let normalized = value.trim().to_ascii_lowercase();
517 !matches!(
518 normalized.as_str(),
519 "" | "shell-only" | "shell_only" | "shell" | "full" | "native-tools" | "native_tools"
520 )
521}
522
523fn normalize_exec_tool_names(tools: &[String]) -> Vec<String> {
524 tools
525 .iter()
526 .map(|name| name.to_ascii_lowercase().trim().to_string())
527 .collect()
528}
529
530fn shell_only_exec_allowed_tools() -> Vec<String> {
531 SHELL_ONLY_EXEC_TOOLS
532 .iter()
533 .map(|name| (*name).to_string())
534 .collect()
535}
536
537fn resolve_exec_allowed_tools(
538 cli_allowed_tools: Option<&[String]>,
539 env_tool_surface: Option<ExecToolSurface>,
540) -> Option<Vec<String>> {
541 if let Some(tools) = cli_allowed_tools {
542 return Some(normalize_exec_tool_names(tools));
543 }
544
545 env_tool_surface.map(|ExecToolSurface::ShellOnly| shell_only_exec_allowed_tools())
546}
547
548#[derive(Args, Debug, Clone)]
549struct FleetArgs {
550 #[command(subcommand)]
551 command: FleetCommand,
552}
553
554#[derive(Subcommand, Debug, Clone)]
555enum FleetCommand {
556 Init,
558 Run(FleetRunArgs),
560 List,
562 Status,
564 Inspect {
566 worker_id: String,
568 },
569 Logs {
571 worker_id: String,
573 },
574 Artifacts {
576 worker_id: String,
578 },
579 Interrupt {
581 worker_id: String,
583 },
584 Restart {
586 worker_id: String,
588 },
589 Resume {
591 run_id: String,
593 #[arg(long, default_value_t = 300)]
595 stale_after_seconds: u64,
596 },
597 Stop {
599 #[arg(long, required = true)]
601 all: bool,
602 },
603 AlertDryRun(FleetAlertDryRunArgs),
605}
606
607#[derive(Args, Debug, Clone)]
608struct FleetRunArgs {
609 #[arg(value_name = "TASK_SPEC")]
611 task_spec: PathBuf,
612 #[arg(long, default_value_t = 4)]
614 max_workers: usize,
615 #[arg(long, default_value_t = 300)]
617 stale_after_seconds: u64,
618 #[arg(long, hide = true, default_value_t = false)]
620 once: bool,
621}
622
623#[derive(Args, Debug, Clone)]
624struct FleetAlertDryRunArgs {
625 #[arg(long, value_enum)]
627 event: FleetAlertEventArg,
628 #[arg(long)]
630 run_id: String,
631 #[arg(long)]
633 worker_id: Option<String>,
634 #[arg(long)]
636 task_id: Option<String>,
637 #[arg(long, default_value = "manual fleet alert dry-run")]
639 reason: String,
640 #[arg(long)]
642 status: Option<String>,
643 #[arg(long, value_enum, default_value_t = FleetAlertAdapterArg::Slack)]
645 adapter: FleetAlertAdapterArg,
646 #[arg(long, default_value = "CODEWHALE_FLEET_SLACK_WEBHOOK")]
648 slack_webhook_env: String,
649 #[arg(long, default_value = "CODEWHALE_FLEET_WEBHOOK_URL")]
651 webhook_url_env: String,
652 #[arg(long)]
654 webhook_secret_env: Option<String>,
655 #[arg(long, default_value = "CODEWHALE_FLEET_PAGERDUTY_ROUTING_KEY")]
657 pagerduty_routing_key_env: String,
658 #[arg(long, default_value = "error")]
660 pagerduty_severity: String,
661}
662
663#[derive(ValueEnum, Debug, Clone, Copy)]
664enum FleetAlertEventArg {
665 Stale,
666 RestartExhausted,
667 NeedsHuman,
668 BudgetExceeded,
669 VerifierFailed,
670 RunCompleted,
671}
672
673#[derive(ValueEnum, Debug, Clone, Copy)]
674enum FleetAlertAdapterArg {
675 Slack,
676 Webhook,
677 PagerDuty,
678}
679
680fn spawn_signal_cleanup_task() {
697 let signals = TerminatingSignals::register();
698 tokio::spawn(async move {
699 let exit_code = signals.wait().await;
700 static CLEANED_UP: std::sync::atomic::AtomicBool =
704 std::sync::atomic::AtomicBool::new(false);
705 if !CLEANED_UP.swap(true, std::sync::atomic::Ordering::SeqCst) {
706 #[cfg(unix)]
707 crate::tools::shell::abort_pending_persistent_process_groups_for_exit();
708 crate::tui::ui::emergency_restore_terminal();
709 record_signal_session_end();
720 }
721 std::process::exit(exit_code);
722 });
723}
724
725static TELEMETRY_SESSION_START: std::sync::OnceLock<std::time::Instant> =
728 std::sync::OnceLock::new();
729
730fn telemetry_session_end() -> codewhale_telemetry::Event {
741 let counters = codewhale_telemetry::session_counters();
742 codewhale_telemetry::Event::SessionEnd {
743 duration_bucket: codewhale_telemetry::DurationBucket::from_secs(
744 TELEMETRY_SESSION_START
745 .get()
746 .map_or(0, |start| start.elapsed().as_secs()),
747 ),
748 exit_class: codewhale_telemetry::exit_class(),
749 cold_start_bucket: crate::startup_trace::cold_start_ms()
750 .map(codewhale_telemetry::ColdStartBucket::from_millis),
751 providers: counters.providers(),
752 counters: counters.counters(),
753 errors: counters.errors(),
754 turn_wall: counters.turn_wall(),
755 }
756}
757
758fn record_signal_session_end() {
762 codewhale_telemetry::set_exit_class(codewhale_telemetry::ExitClass::Signal);
763 codewhale_telemetry::record_blocking(telemetry_session_end());
764}
765
766#[cfg(unix)]
771struct TerminatingSignals {
772 sigint: Option<tokio::signal::unix::Signal>,
773 sigterm: Option<tokio::signal::unix::Signal>,
774 sighup: Option<tokio::signal::unix::Signal>,
775}
776
777#[cfg(unix)]
778impl TerminatingSignals {
779 fn register() -> Self {
782 use tokio::signal::unix::{SignalKind, signal};
783 Self {
784 sigint: signal(SignalKind::interrupt()).ok(),
785 sigterm: signal(SignalKind::terminate()).ok(),
786 sighup: signal(SignalKind::hangup()).ok(),
787 }
788 }
789
790 async fn wait(mut self) -> i32 {
794 tokio::select! {
795 _ = async { match self.sigint.as_mut() { Some(s) => { s.recv().await; }, None => std::future::pending::<()>().await, } } => 130,
796 _ = async { match self.sigterm.as_mut() { Some(s) => { s.recv().await; }, None => std::future::pending::<()>().await, } } => 143,
797 _ = async { match self.sighup.as_mut() { Some(s) => { s.recv().await; }, None => std::future::pending::<()>().await, } } => 129,
798 }
799 }
800}
801
802#[cfg(not(unix))]
806struct TerminatingSignals {
807 ctrl_c: Option<tokio::signal::windows::CtrlC>,
808}
809
810#[cfg(not(unix))]
811impl TerminatingSignals {
812 fn register() -> Self {
813 Self {
814 ctrl_c: tokio::signal::windows::ctrl_c().ok(),
815 }
816 }
817
818 async fn wait(mut self) -> i32 {
819 match self.ctrl_c.as_mut() {
820 Some(s) => {
821 s.recv().await;
822 }
823 None => std::future::pending::<()>().await,
824 }
825 130
826 }
827}
828
829fn join_prompt_parts(parts: &[String]) -> String {
830 parts.join(" ")
831}
832
833fn resolve_exec_model(config: &Config, explicit_model: Option<&str>) -> String {
834 explicit_model
835 .map(str::trim)
836 .filter(|model| !model.is_empty())
837 .map(ToOwned::to_owned)
838 .or_else(exec_model_env_override)
839 .unwrap_or_else(|| config.default_model())
840}
841
842fn apply_exec_provider_override(config: &mut Config, provider_arg: &str) -> Result<()> {
843 let provider_arg = provider_arg.trim();
844 if provider_arg.is_empty() {
845 return Ok(());
846 }
847 if config
848 .providers
849 .as_ref()
850 .and_then(|providers| providers.custom_provider_config(provider_arg))
851 .is_some()
852 {
853 config.provider = Some(provider_arg.to_string());
854 return Ok(());
855 }
856 if let Some(provider) = crate::config::ApiProvider::parse(provider_arg) {
857 config.provider = Some(provider.as_str().to_string());
858 return Ok(());
859 }
860 bail!(
861 "Unrecognized --provider {provider_arg:?}. Known providers: {} \
862 or a configured [providers.<name>] custom provider",
863 crate::config::ApiProvider::names_hint()
864 );
865}
866
867fn exec_model_env_override() -> Option<String> {
868 let read = || {
869 ["CODEWHALE_MODEL", "DEEPSEEK_MODEL"]
870 .into_iter()
871 .find_map(|key| {
872 std::env::var(key)
873 .ok()
874 .map(|model| model.trim().to_string())
875 .filter(|model| !model.is_empty())
876 })
877 };
878 #[cfg(test)]
879 {
880 crate::test_support::with_test_env_lock(read)
881 }
882 #[cfg(not(test))]
883 {
884 read()
885 }
886}
887
888fn top_level_prompt_initial_input(parts: &[String]) -> Option<tui::InitialInput> {
889 (!parts.is_empty()).then(|| tui::InitialInput::Submit(join_prompt_parts(parts)))
890}
891
892fn resolve_exec_resume_session_id(args: &ExecArgs, workspace: &Path) -> Result<Option<String>> {
893 if let Some(id) = args.resume.as_ref().or(args.session_id.as_ref()) {
894 return Ok(Some(id.clone()));
895 }
896 if !args.continue_session {
897 return Ok(None);
898 }
899 latest_session_id_for_workspace(workspace)?.map_or_else(
900 || {
901 bail!(
902 "No saved sessions found for workspace {}. Use `codewhale sessions` to list sessions, or pass `codewhale exec --resume <SESSION_ID> ...`.",
903 workspace.display()
904 )
905 },
906 |id| Ok(Some(id)),
907 )
908}
909
910fn load_exec_resume_session(session_id: &str) -> Result<session_manager::SavedSession> {
911 let session_ref = exec_stream_session_ref(session_id);
912 SessionManager::default_location()
913 .context("could not open session manager for resume")?
914 .load_session_by_prefix(session_id)
915 .with_context(|| format!("could not load session {session_ref}"))
916}
917
918fn resolve_exec_resume_route(
926 config: &mut Config,
927 saved: &session_manager::SavedSession,
928 explicit_provider: bool,
929 explicit_model: Option<&str>,
930) -> Result<String> {
931 if !explicit_provider {
932 let saved_provider_identity = saved
933 .metadata
934 .model_provider_id
935 .as_deref()
936 .filter(|identity| !identity.trim().is_empty())
937 .unwrap_or(&saved.metadata.model_provider);
938 let identity = config
939 .resolve_persisted_provider_identity(
940 Some(&saved.metadata.model_provider),
941 saved.metadata.model_provider_id.as_deref(),
942 )
943 .map_err(anyhow::Error::msg)
944 .with_context(|| {
945 format!(
946 "saved session provider '{}' is unavailable; Codewhale will not fall back",
947 saved_provider_identity
948 )
949 })?;
950 config.scope_to_provider_identity(&identity);
951 }
952
953 if let Some(model) = explicit_model {
954 return Ok(resolve_exec_model(config, Some(model)));
955 }
956 if explicit_provider {
957 return Ok(resolve_exec_model(config, None));
958 }
959 Ok(saved.metadata.model.clone())
960}
961
962#[derive(Args, Debug, Clone, Default)]
963struct SetupArgs {
964 #[arg(long, default_value_t = false)]
966 mcp: bool,
967 #[arg(long, default_value_t = false)]
969 skills: bool,
970 #[arg(long, default_value_t = false)]
972 tools: bool,
973 #[arg(long, default_value_t = false)]
975 plugins: bool,
976 #[arg(long, default_value_t = false)]
978 all: bool,
979 #[arg(long, default_value_t = false)]
981 local: bool,
982 #[arg(long, default_value_t = false)]
984 force: bool,
985 #[arg(long, default_value_t = false, conflicts_with_all = ["mcp", "skills", "tools", "plugins", "all", "local", "clean"])]
987 status: bool,
988 #[arg(long, default_value_t = false, conflicts_with_all = ["mcp", "skills", "tools", "plugins", "all", "local", "status"])]
990 clean: bool,
991}
992
993#[derive(Args, Debug, Clone, Default)]
994struct DoctorArgs {
995 #[arg(long, default_value_t = false)]
997 json: bool,
998 #[arg(long, default_value_t = false, conflicts_with = "json")]
1000 context_json: bool,
1001 #[arg(
1003 long,
1004 default_value_t = false,
1005 conflicts_with_all = ["json", "context_json"]
1006 )]
1007 probe_local: bool,
1008 #[arg(
1010 long,
1011 default_value_t = false,
1012 conflicts_with_all = ["json", "context_json"]
1013 )]
1014 probe_api: bool,
1015 #[arg(
1017 long,
1018 default_value_t = false,
1019 conflicts_with_all = ["json", "context_json"]
1020 )]
1021 check_updates: bool,
1022 #[arg(
1024 long,
1025 default_value_t = false,
1026 conflicts_with_all = ["json", "context_json"]
1027 )]
1028 probe_mcp: bool,
1029}
1030
1031#[derive(Args, Debug, Clone)]
1032struct SessionDiagnosticsArgs {
1033 #[arg(value_name = "JSONL")]
1035 path: PathBuf,
1036 #[arg(long, default_value_t = false)]
1038 json: bool,
1039}
1040
1041#[derive(Args, Debug, Clone)]
1042struct ScorecardArgs {
1043 #[arg(long, value_name = "FILE")]
1051 input: PathBuf,
1052 #[arg(long, value_name = "FILE")]
1055 baseline: Option<PathBuf>,
1056 #[arg(long, default_value_t = 5.0)]
1058 threshold: f64,
1059 #[arg(long, default_value_t = false)]
1061 json: bool,
1062}
1063
1064#[derive(Args, Debug, Clone)]
1065struct EvalArgs {
1066 #[arg(long, value_name = "STEP")]
1068 fail_step: Option<String>,
1069 #[arg(long, default_value = "printf eval-harness")]
1071 shell_command: String,
1072 #[arg(long, default_value = "eval-harness")]
1074 shell_expect_token: String,
1075 #[arg(long, default_value_t = 240)]
1077 max_output_chars: usize,
1078 #[arg(long, default_value_t = false)]
1080 json: bool,
1081 #[arg(long, value_name = "DIR")]
1084 record: Option<PathBuf>,
1085}
1086
1087#[derive(Args, Debug, Clone, Default)]
1088struct ModelsArgs {
1089 #[arg(long, default_value_t = false)]
1091 json: bool,
1092}
1093
1094#[derive(Args, Debug, Clone)]
1095struct SpeechArgs {
1096 #[arg(value_name = "TEXT")]
1098 text: String,
1099
1100 #[arg(short, long, value_name = "FILE")]
1103 output: Option<PathBuf>,
1104
1105 #[arg(long = "output-dir", value_name = "DIR")]
1107 output_dir: Option<PathBuf>,
1108
1109 #[arg(long)]
1111 model: Option<String>,
1112
1113 #[arg(long)]
1115 voice: Option<String>,
1116
1117 #[arg(long)]
1119 instruction: Option<String>,
1120
1121 #[arg(long = "voice-prompt")]
1123 voice_prompt: Option<String>,
1124
1125 #[arg(long = "clone-voice", value_name = "FILE")]
1127 clone_voice: Option<PathBuf>,
1128
1129 #[arg(long, default_value = "wav")]
1131 format: String,
1132
1133 #[arg(long, default_value_t = false)]
1135 json: bool,
1136}
1137
1138#[derive(Args, Debug, Default, Clone)]
1139struct FeatureToggles {
1140 #[arg(long = "enable", value_name = "FEATURE", action = clap::ArgAction::Append, global = true)]
1142 enable: Vec<String>,
1143
1144 #[arg(long = "disable", value_name = "FEATURE", action = clap::ArgAction::Append, global = true)]
1146 disable: Vec<String>,
1147}
1148
1149impl FeatureToggles {
1150 fn apply(&self, config: &mut Config) -> Result<()> {
1151 for feature in &self.enable {
1152 config.set_feature(feature, true)?;
1153 }
1154 for feature in &self.disable {
1155 config.set_feature(feature, false)?;
1156 }
1157 Ok(())
1158 }
1159}
1160
1161#[derive(Args, Debug, Clone)]
1162struct ReviewArgs {
1163 #[arg(long, conflicts_with = "base")]
1165 staged: bool,
1166 #[arg(long)]
1168 base: Option<String>,
1169 #[arg(long)]
1171 path: Option<PathBuf>,
1172 #[arg(long)]
1174 model: Option<String>,
1175 #[arg(long, default_value_t = 200_000)]
1177 max_chars: usize,
1178 #[arg(long, default_value_t = false)]
1180 write_receipt: bool,
1181 #[arg(long, default_value_t = false)]
1183 check_receipt: bool,
1184 #[arg(long)]
1186 receipt_path: Option<PathBuf>,
1187 #[arg(long, default_value_t = false)]
1189 json: bool,
1190}
1191
1192#[derive(Args, Debug, Clone)]
1193struct ApplyArgs {
1194 #[arg(value_name = "PATCH_FILE")]
1196 patch_file: Option<PathBuf>,
1197}
1198
1199#[derive(Args, Debug, Clone)]
1200struct ServeArgs {
1201 #[arg(long)]
1203 mcp: bool,
1204 #[arg(long)]
1206 http: bool,
1207 #[arg(long)]
1209 mobile: bool,
1210 #[arg(long)]
1212 web: bool,
1213 #[arg(long, requires = "mobile")]
1215 qr: bool,
1216 #[arg(long)]
1218 acp: bool,
1219 #[arg(long)]
1221 host: Option<String>,
1222 #[arg(long, default_value_t = 7878)]
1224 port: u16,
1225 #[arg(long, default_value_t = 2)]
1227 workers: usize,
1228 #[arg(long = "cors-origin", value_name = "URL")]
1234 cors_origin: Vec<String>,
1235 #[arg(long = "auth-token", value_name = "TOKEN")]
1239 auth_token: Option<String>,
1240 #[arg(long = "insecure")]
1242 insecure_no_auth: bool,
1243}
1244
1245#[derive(Debug, Clone, PartialEq, Eq)]
1246struct ServeBindHost {
1247 host: String,
1248 mobile_rebound_to_lan: bool,
1249}
1250
1251fn resolve_serve_bind_host(mobile: bool, host: Option<String>) -> ServeBindHost {
1252 match (mobile, host) {
1253 (true, None) => ServeBindHost {
1254 host: "0.0.0.0".to_string(),
1255 mobile_rebound_to_lan: true,
1256 },
1257 (_, Some(host)) => ServeBindHost {
1258 host,
1259 mobile_rebound_to_lan: false,
1260 },
1261 (false, None) => ServeBindHost {
1262 host: "127.0.0.1".to_string(),
1263 mobile_rebound_to_lan: false,
1264 },
1265 }
1266}
1267
1268fn validate_serve_mode_selection(
1269 mcp: bool,
1270 http: bool,
1271 mobile: bool,
1272 web: bool,
1273 acp: bool,
1274) -> Result<bool> {
1275 if http && mobile {
1276 bail!("--http and --mobile are mutually exclusive; choose one");
1277 }
1278 if web && (http || mobile) {
1279 bail!("--web is mutually exclusive with --http and --mobile");
1280 }
1281 let http_selected = http || mobile || web;
1282 let selected_modes = [mcp, http_selected, acp]
1283 .into_iter()
1284 .filter(|selected| *selected)
1285 .count();
1286 if selected_modes != 1 {
1287 bail!("Choose exactly one server mode: --mcp, --http/--mobile/--web, or --acp");
1288 }
1289 Ok(http_selected)
1290}
1291
1292#[derive(Subcommand, Debug, Clone)]
1293enum McpCommand {
1294 List,
1296 Init {
1298 #[arg(long, default_value_t = false)]
1300 force: bool,
1301 },
1302 Connect {
1304 #[arg(value_name = "SERVER")]
1306 server: Option<String>,
1307 },
1308 Tools {
1310 #[arg(value_name = "SERVER")]
1312 server: Option<String>,
1313 },
1314 Add {
1316 name: String,
1318 #[arg(long, conflicts_with = "url")]
1320 command: Option<String>,
1321 #[arg(long, conflicts_with = "command")]
1323 url: Option<String>,
1324 #[arg(long, requires = "url")]
1326 transport: Option<String>,
1327 #[arg(long, requires = "url")]
1329 bearer_token_env_var: Option<String>,
1330 #[arg(long, requires = "url")]
1332 oauth_client_id: Option<String>,
1333 #[arg(long, requires = "url")]
1335 oauth_resource: Option<String>,
1336 #[arg(long = "scope", requires = "url", value_delimiter = ',')]
1338 scopes: Vec<String>,
1339 #[arg(long = "arg")]
1341 args: Vec<String>,
1342 },
1343 Login {
1345 name: String,
1347 #[arg(long = "scope", value_delimiter = ',')]
1349 scopes: Vec<String>,
1350 },
1351 Logout {
1353 name: String,
1355 },
1356 Remove {
1358 name: String,
1360 },
1361 Enable {
1363 name: String,
1365 },
1366 Disable {
1368 name: String,
1370 },
1371 Validate,
1373 #[command(
1378 name = "add-self",
1379 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."
1380 )]
1381 AddSelf {
1382 #[arg(long, default_value = "codewhale")]
1384 name: String,
1385 #[arg(long)]
1387 workspace: Option<String>,
1388 },
1389}
1390
1391#[derive(Subcommand, Debug, Clone)]
1392pub(crate) enum IntegrationsCommand {
1393 Dsh {
1395 #[command(subcommand)]
1396 command: DshIntegrationCommand,
1397 },
1398}
1399
1400#[derive(Subcommand, Debug, Clone)]
1401pub(crate) enum DshIntegrationCommand {
1402 Status {
1404 #[arg(long, default_value_t = false)]
1406 json: bool,
1407 },
1408 Plan {
1410 #[arg(long, default_value_t = false)]
1411 json: bool,
1412 #[arg(long, default_value = "web")]
1414 profile: String,
1415 #[arg(long, default_value_t = false)]
1417 allow_full_access: bool,
1418 #[arg(long, default_value_t = false)]
1420 skin: bool,
1421 },
1422 Connect {
1424 #[arg(long, default_value = "web")]
1425 profile: String,
1426 #[arg(long, default_value_t = false)]
1427 allow_full_access: bool,
1428 #[arg(long, default_value_t = false)]
1429 skin: bool,
1430 #[arg(long, default_value_t = false)]
1432 yes: bool,
1433 },
1434 Update {
1436 #[arg(long)]
1437 profile: Option<String>,
1438 #[arg(long, default_value_t = false)]
1439 allow_full_access: bool,
1440 #[arg(long)]
1442 skin: Option<bool>,
1443 #[arg(long)]
1445 ocean: Option<bool>,
1446 #[arg(long, default_value_t = false)]
1447 yes: bool,
1448 },
1449 Launch {
1451 #[arg(long)]
1453 profile: Option<String>,
1454 #[arg(long, default_value_t = false)]
1456 dry_run: bool,
1457 #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
1458 args: Vec<String>,
1459 },
1460 Disable,
1462 Enable,
1464 Remove {
1466 #[arg(long, default_value_t = false)]
1467 yes: bool,
1468 },
1469 InstallBundle {
1471 #[arg(long, default_value = "web")]
1473 app: String,
1474 #[arg(long, default_value_t = false)]
1475 yes: bool,
1476 },
1477 RemoveBundle {
1479 #[arg(long, default_value_t = false)]
1480 yes: bool,
1481 },
1482}
1483
1484#[derive(Args, Debug, Clone)]
1485struct FeaturesCli {
1486 #[command(subcommand)]
1487 command: FeaturesSubcommand,
1488}
1489
1490#[derive(Subcommand, Debug, Clone)]
1491enum FeaturesSubcommand {
1492 List,
1494}
1495
1496#[derive(Args, Debug, Clone)]
1497struct SandboxArgs {
1498 #[command(subcommand)]
1499 command: SandboxCommand,
1500}
1501
1502#[derive(Subcommand, Debug, Clone)]
1503enum SandboxCommand {
1504 Run {
1506 #[arg(long, default_value = "workspace-write")]
1508 policy: String,
1509 #[arg(long)]
1511 network: bool,
1512 #[arg(long, value_name = "PATH")]
1514 writable_root: Vec<PathBuf>,
1515 #[arg(long)]
1517 exclude_tmpdir: bool,
1518 #[arg(long)]
1520 exclude_slash_tmp: bool,
1521 #[arg(long)]
1523 cwd: Option<PathBuf>,
1524 #[arg(long, default_value_t = 60_000)]
1526 timeout_ms: u64,
1527 #[arg(required = true, trailing_var_arg = true)]
1529 command: Vec<String>,
1530 },
1531}
1532
1533const CODEWHALE_MAIN_STACK_BYTES: usize = 16 * 1024 * 1024;
1534
1535pub fn run(args: Vec<String>) -> std::process::ExitCode {
1539 match run_with_args(args) {
1540 Ok(()) => std::process::ExitCode::SUCCESS,
1541 Err(err) => {
1542 eprintln!("error: {err}");
1543 for cause in err.chain().skip(1) {
1544 eprintln!(" caused by: {cause}");
1545 }
1546 std::process::ExitCode::FAILURE
1547 }
1548 }
1549}
1550
1551fn run_with_args(args: Vec<String>) -> Result<()> {
1555 #[cfg(unix)]
1559 unsafe {
1560 libc::signal(libc::SIGPIPE, libc::SIG_DFL);
1561 }
1562
1563 startup_trace::mark_process_start();
1564 configure_windows_console_utf8();
1565 install_rustls_crypto_provider();
1566
1567 crate::sandbox::process_hardening::apply_process_hardening();
1571
1572 crate::tui::ui::fatal_signal_guard::install_fatal_signal_guard();
1579
1580 let orig_hook = std::panic::take_hook();
1585 std::panic::set_hook(Box::new(move |panic_info| {
1586 crate::tui::ui::emergency_restore_terminal();
1592
1593 let msg = if let Some(s) = panic_info.payload().downcast_ref::<&str>() {
1594 s.to_string()
1595 } else if let Some(s) = panic_info.payload().downcast_ref::<String>() {
1596 s.clone()
1597 } else {
1598 format!("{:?}", panic_info.payload())
1599 };
1600 let location = panic_info
1601 .location()
1602 .map(|loc| loc.to_string())
1603 .unwrap_or_else(|| "unknown".to_string());
1604 tracing::error!(target: "panic", "Process panicked at {location}: {msg}");
1605
1606 codewhale_telemetry::set_exit_class(codewhale_telemetry::ExitClass::Panic);
1617 if let Some(site) = panic_info
1618 .location()
1619 .map(|loc| codewhale_telemetry::reduce_panic_site(loc.file(), loc.line(), loc.column()))
1620 {
1621 codewhale_telemetry::record_blocking(codewhale_telemetry::Event::Panic { site });
1622 }
1623 if let Some(home) = crate::config::effective_home_dir() {
1625 let crash_dir = home.join(".deepseek").join("crashes");
1626 let _ = std::fs::create_dir_all(&crash_dir);
1627 use chrono::Utc;
1628 let ts = Utc::now().format("%Y%m%dT%H%M%S%.3fZ");
1629 let path = crash_dir.join(format!("{ts}-process-panic.log"));
1630 let contents =
1631 format!("Process panicked\nLocation: {location}\nTimestamp: {ts}\nPanic: {msg}\n",);
1632 let _ = std::fs::write(&path, contents);
1633 }
1634 orig_hook(panic_info);
1636 }));
1637
1638 let cli = match Cli::try_parse_from(args) {
1645 Ok(c) => c,
1646 Err(e) => {
1647 e.exit();
1648 }
1649 };
1650 crate::fleet::roster::set_project_agent_profiles_enabled(!cli.no_project_config);
1655 let workspace = resolve_workspace(&cli);
1656 let mut plugin_discovery = None;
1657 let mut plugin_registry = None;
1658 let (cli, command) = prepare_cli_startup(
1659 cli,
1660 || {
1661 let discovery = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv();
1662 plugin_registry = Some(discovery.registry_for_workspace(&workspace));
1663 plugin_discovery = Some(discovery);
1664 },
1665 warn_on_workspace_dotenv_result,
1666 );
1667 let plugin_discovery = plugin_discovery
1668 .expect("plugin discovery initialization must precede workspace dotenv loading");
1669 let plugin_registry = plugin_registry
1670 .expect("plugin discovery initialization must precede workspace dotenv loading");
1671
1672 let runtime_thread = std::thread::Builder::new()
1680 .name("codewhale-main".to_string())
1681 .stack_size(CODEWHALE_MAIN_STACK_BYTES)
1682 .spawn(move || run_async_main(cli, command, plugin_discovery, plugin_registry))
1683 .context("Failed to start the Codewhale runtime thread")?;
1684 match runtime_thread.join() {
1685 Ok(result) => result,
1686 Err(payload) => {
1687 let message = payload
1688 .downcast_ref::<&str>()
1689 .map(|value| (*value).to_string())
1690 .or_else(|| payload.downcast_ref::<String>().cloned())
1691 .unwrap_or_else(|| "unknown panic payload".to_string());
1692 Err(anyhow!("Codewhale runtime thread panicked: {message}"))
1693 }
1694 }
1695}
1696
1697fn run_async_main(
1698 cli: Cli,
1699 command: Option<Commands>,
1700 plugin_discovery: Arc<crate::plugins::PluginDiscoveryContext>,
1701 plugin_registry: Arc<crate::plugins::PluginRegistry>,
1702) -> Result<()> {
1703 build_runtime()?.block_on(run_async_main_inner(
1704 cli,
1705 command,
1706 plugin_discovery,
1707 plugin_registry,
1708 ))
1709}
1710
1711pub(crate) fn build_runtime() -> Result<tokio::runtime::Runtime> {
1730 tokio::runtime::Builder::new_multi_thread()
1731 .enable_all()
1732 .thread_stack_size(CODEWHALE_MAIN_STACK_BYTES)
1733 .build()
1734 .context("Failed to build the Codewhale Tokio runtime")
1735}
1736
1737fn telemetry_surface(command: Option<&Commands>) -> codewhale_telemetry::Surface {
1743 use codewhale_telemetry::Surface;
1744 match command {
1745 None | Some(Commands::Resume { .. } | Commands::Fork { .. } | Commands::Pr { .. }) => {
1746 Surface::Tui
1747 }
1748 Some(Commands::Exec(_)) => Surface::Exec,
1749 Some(Commands::Serve(args)) => {
1750 if args.mcp {
1751 Surface::McpServer
1752 } else {
1753 Surface::Serve
1754 }
1755 }
1756 Some(_) => Surface::Cli,
1757 }
1758}
1759
1760fn telemetry_session_source(command: Option<&Commands>) -> codewhale_telemetry::SessionSource {
1762 use codewhale_telemetry::SessionSource;
1763 match command {
1764 None | Some(Commands::Pr { .. }) => SessionSource::Interactive,
1765 Some(Commands::Resume { .. }) => SessionSource::Resume,
1766 Some(Commands::Fork { .. }) => SessionSource::Fork,
1767 Some(Commands::Serve(_)) => SessionSource::Api,
1768 Some(_) => SessionSource::Unknown,
1769 }
1770}
1771
1772fn telemetry_command_is_read_only(command: Option<&Commands>) -> bool {
1774 matches!(
1775 command,
1776 Some(Commands::Doctor(_) | Commands::SessionDiagnostics(_) | Commands::Sessions { .. })
1777 ) || matches!(command, Some(Commands::Setup(args)) if args.status)
1778}
1779
1780fn arm_telemetry_with_setup(
1794 config_path: Option<PathBuf>,
1795 surface: codewhale_telemetry::Surface,
1796 source: codewhale_telemetry::SessionSource,
1797 setup_override: Option<&codewhale_config::SetupState>,
1798) {
1799 let Ok(store) = codewhale_config::ConfigStore::load(config_path) else {
1800 return;
1801 };
1802 let resolved = store
1803 .config
1804 .resolve_runtime_options(&codewhale_config::CliRuntimeOverrides::default());
1805 let setup = if let Some(setup) = setup_override {
1806 setup.clone()
1807 } else {
1808 let Some(setup) = codewhale_telemetry::load_setup_state_for_decision() else {
1809 return;
1812 };
1813 setup
1814 };
1815 let codewhale_telemetry::TelemetryDecision::Enabled(consent) =
1816 codewhale_telemetry::decide(&resolved, &setup, surface)
1817 else {
1818 return;
1819 };
1820 codewhale_telemetry::init(consent.with_config_path(Some(store.path().to_path_buf())));
1821 let _ = TELEMETRY_SESSION_START.set(std::time::Instant::now());
1822 codewhale_telemetry::record(codewhale_telemetry::Event::SessionStart { source });
1823}
1824
1825fn arm_telemetry(cli: &Cli, command: Option<&Commands>) {
1826 if telemetry_command_is_read_only(command) {
1827 return;
1828 }
1829 arm_telemetry_with_setup(
1830 cli.config.clone(),
1831 telemetry_surface(command),
1832 telemetry_session_source(command),
1833 None,
1834 );
1835}
1836
1837pub(crate) fn apply_tui_telemetry_decision(
1844 pending: &crate::telemetry_notice::PendingTelemetryNotice,
1845 setup: &codewhale_config::SetupState,
1846) {
1847 arm_telemetry_with_setup(
1848 pending.config_path.clone(),
1849 codewhale_telemetry::Surface::Tui,
1850 pending.session_source,
1851 Some(setup),
1852 );
1853}
1854
1855async fn finish_telemetry(outcome: &Result<()>) {
1857 if !codewhale_telemetry::is_armed() {
1858 return;
1859 }
1860 if outcome.is_err()
1863 && codewhale_telemetry::exit_class() == codewhale_telemetry::ExitClass::Clean
1864 {
1865 codewhale_telemetry::set_exit_class(codewhale_telemetry::ExitClass::Error);
1866 }
1867 codewhale_telemetry::record(telemetry_session_end());
1868 let _ = tokio::time::timeout(
1873 codewhale_telemetry::SHUTDOWN_FLUSH_TIMEOUT,
1874 tokio::task::spawn_blocking(|| {
1875 codewhale_telemetry::shutdown_blocking(codewhale_telemetry::SHUTDOWN_FLUSH_TIMEOUT)
1876 }),
1877 )
1878 .await;
1879}
1880
1881async fn run_async_main_inner(
1882 cli: Cli,
1883 command: Option<Commands>,
1884 plugin_discovery: Arc<crate::plugins::PluginDiscoveryContext>,
1885 plugin_registry: Arc<crate::plugins::PluginRegistry>,
1886) -> Result<()> {
1887 spawn_signal_cleanup_task();
1908
1909 let surface = telemetry_surface(command.as_ref());
1915 let telemetry_notice_plan = if surface == codewhale_telemetry::Surface::Tui {
1916 crate::telemetry_notice::plan_if_due(
1917 cli.config.clone(),
1918 telemetry_session_source(command.as_ref()),
1919 )
1920 } else {
1921 crate::telemetry_notice::TelemetryNoticePlan::NotDue
1922 };
1923 let should_arm_before_dispatch = surface != codewhale_telemetry::Surface::Tui
1924 || telemetry_notice_plan.should_arm_before_tui();
1925 let pending_telemetry_notice = telemetry_notice_plan.into_pending();
1926 if should_arm_before_dispatch {
1927 arm_telemetry(&cli, command.as_ref());
1928 }
1929 let outcome = run_async_main_dispatch(
1930 cli,
1931 command,
1932 plugin_discovery,
1933 plugin_registry,
1934 pending_telemetry_notice,
1935 )
1936 .await;
1937 finish_telemetry(&outcome).await;
1938 outcome
1939}
1940
1941async fn run_async_main_dispatch(
1942 cli: Cli,
1943 command: Option<Commands>,
1944 plugin_discovery: Arc<crate::plugins::PluginDiscoveryContext>,
1945 plugin_registry: Arc<crate::plugins::PluginRegistry>,
1946 mut pending_telemetry_notice: Option<crate::telemetry_notice::PendingTelemetryNotice>,
1947) -> Result<()> {
1948 logging::set_verbose(cli.verbose || logging::env_requests_verbose_logging());
1949
1950 crate::prompts::load_prompt_overrides_from_config_home();
1955
1956 if let Some(command) = command {
1964 return match command {
1965 Commands::Doctor(args) => {
1966 let config = match load_doctor_config_from_cli(&cli, &args) {
1967 Ok(config) => config,
1968 Err(error) if args.json => return run_doctor_json_config_error(&error),
1969 Err(_) => {
1970 bail!(
1971 "doctor configuration validation failed; details omitted because configuration errors may contain credential material"
1972 )
1973 }
1974 };
1975 let workspace = resolve_workspace(&cli);
1976 if args.context_json {
1977 run_doctor_context_json(&config, &workspace)
1978 } else if args.json {
1979 run_doctor_json(
1980 &config,
1981 &workspace,
1982 cli.config.as_deref(),
1983 plugin_registry.as_ref(),
1984 )
1985 } else {
1986 let probes = crate::doctor::DoctorProbeRequest {
1987 check_updates: args.check_updates,
1988 probe_api: args.probe_api,
1989 probe_local: args.probe_local,
1990 probe_mcp: args.probe_mcp,
1991 };
1992 run_doctor(
1993 &config,
1994 &workspace,
1995 cli.config.as_deref(),
1996 probes,
1997 plugin_registry.as_ref(),
1998 )
1999 .await;
2000 Ok(())
2001 }
2002 }
2003 Commands::SessionDiagnostics(args) => run_session_diagnostics(args),
2004 Commands::Setup(args) => {
2005 let config = load_config_from_cli(&cli)?;
2006 let workspace = resolve_workspace(&cli);
2007 run_setup(&config, &workspace, args, plugin_registry.as_ref())
2008 }
2009 Commands::RemoteSetup(args) => remote_setup::run_remote_setup(args),
2010 Commands::Completions { shell } => {
2011 generate_completions(shell);
2012 Ok(())
2013 }
2014 Commands::Sessions { limit, search } => list_sessions(limit, search),
2015 Commands::Init => init_project(),
2016 Commands::Login { api_key } => run_login(api_key),
2017 Commands::Logout => run_logout(),
2018 Commands::Auth(args) => match args.command {
2019 TuiAuthCommand::XaiDevice => run_xai_device_auth(cli.config.as_deref()).await,
2020 },
2021 Commands::Models(args) => {
2022 let config = load_config_from_cli(&cli)?;
2023 run_models(&config, args).await
2024 }
2025 Commands::Speech(args) => {
2026 let config = load_config_from_cli(&cli)?;
2027 run_speech(&config, args).await
2028 }
2029 Commands::Exec(args) => {
2030 let config = load_config_from_cli(&cli)?;
2031 let workspace = cli.workspace.clone().unwrap_or_else(|| {
2032 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
2033 });
2034 let mut config = config.clone();
2035 if !cli.no_project_config {
2040 merge_user_workspace_config(&mut config, cli.config.clone(), &workspace);
2041 }
2042 if let Some(sandbox) = args.sandbox.as_deref() {
2043 let _ = parse_sandbox_policy(sandbox, true, Vec::new(), false, false)?;
2044 config.sandbox_mode = Some(sandbox.to_ascii_lowercase());
2045 }
2046 if let Ok(env_url) = std::env::var("CODEWHALE_BASE_URL")
2049 .or_else(|_| std::env::var("DEEPSEEK_BASE_URL"))
2050 {
2051 let trimmed = env_url.trim();
2052 if !trimmed.is_empty() {
2053 config.base_url = Some(trimmed.to_string());
2054 }
2055 }
2056 let explicit_provider = args
2066 .provider
2067 .as_deref()
2068 .map(str::trim)
2069 .filter(|provider| !provider.is_empty());
2070 if let Some(provider_arg) = explicit_provider {
2071 apply_exec_provider_override(&mut config, provider_arg)?;
2072 }
2073 if let Some(reasoning_arg) = args
2074 .reasoning_effort
2075 .as_deref()
2076 .map(str::trim)
2077 .filter(|value| !value.is_empty())
2078 {
2079 config.reasoning_effort = normalize_cli_reasoning_effort(reasoning_arg)?;
2080 config.reasoning_effort_inferred_from_legacy_alias = false;
2081 }
2082 let prompt = join_prompt_parts(&args.prompt);
2083 let resume_session_id = resolve_exec_resume_session_id(&args, &workspace)?;
2084 validate_exec_tool_authority_resume(
2085 args.tool_authority_json.as_deref(),
2086 resume_session_id.is_some(),
2087 )?;
2088 let resume_session = resume_session_id
2089 .as_deref()
2090 .map(load_exec_resume_session)
2091 .transpose()?;
2092 let explicit_model = args
2093 .model
2094 .as_deref()
2095 .map(str::trim)
2096 .filter(|model| !model.is_empty());
2097 let model = if let Some(saved) = resume_session.as_ref() {
2098 resolve_exec_resume_route(
2099 &mut config,
2100 saved,
2101 explicit_provider.is_some(),
2102 explicit_model,
2103 )?
2104 } else {
2105 resolve_exec_model(&config, explicit_model)
2106 };
2107 let force_configured_route = should_force_configured_exec_route(
2108 resume_session.is_some(),
2109 explicit_provider,
2110 explicit_model,
2111 );
2112 let yolo = cli.yolo || config.yolo.unwrap_or(false);
2116 let env_tool_surface = exec_tool_surface_from_env();
2117 let needs_engine = args.auto
2118 || yolo
2119 || resume_session_id.is_some()
2120 || args.output_format == ExecOutputFormat::StreamJson
2121 || args.max_turns.is_some()
2122 || args.allowed_tools.is_some()
2123 || args.disallowed_tools.is_some()
2124 || args.append_system_prompt.is_some()
2125 || args.tool_authority_json.is_some()
2126 || args.sandbox.is_some()
2127 || args.allow_sandbox_elevation
2128 || env_tool_surface.is_some();
2129 if needs_engine {
2130 let provider = config.api_provider();
2131 let max_subagents = cli.max_subagents.map_or_else(
2132 || config.max_subagents_for_provider(provider),
2133 |value| value.clamp(1, MAX_SUBAGENTS),
2134 );
2135 let auto_mode = args.auto || yolo;
2136 let max_turns = exec_max_steps(args.max_turns);
2137 let allowed_tools =
2138 resolve_exec_allowed_tools(args.allowed_tools.as_deref(), env_tool_surface);
2139 let disallowed_tools = args
2140 .disallowed_tools
2141 .as_deref()
2142 .map(normalize_exec_tool_names);
2143 run_exec_agent(
2144 &config,
2145 &model,
2146 &prompt,
2147 workspace,
2148 max_subagents,
2149 auto_mode,
2150 args.allow_sandbox_elevation,
2151 args.sandbox.as_deref(),
2152 auto_mode,
2153 args.json,
2154 resume_session,
2155 force_configured_route,
2156 args.output_format,
2157 max_turns,
2158 allowed_tools,
2159 disallowed_tools,
2160 args.append_system_prompt.clone(),
2161 args.tool_authority_json.clone(),
2162 std::sync::Arc::clone(&plugin_registry),
2163 )
2164 .await
2165 } else if args.json {
2166 run_one_shot_json(&config, &model, &prompt, force_configured_route).await
2167 } else {
2168 run_one_shot(&config, &model, &prompt, force_configured_route).await
2169 }
2170 }
2171 Commands::Fleet(args) => {
2172 let config = load_config_from_cli(&cli)?;
2173 let workspace = resolve_workspace(&cli);
2174 run_fleet_command(&workspace, &config, args).await
2175 }
2176 Commands::WorkflowTool(args) => {
2177 run_workflow_tool_command(&cli, args, std::sync::Arc::clone(&plugin_registry)).await
2178 }
2179 Commands::Review(args) => {
2180 let config = load_config_from_cli(&cli)?;
2181 run_review(&config, args).await
2182 }
2183 Commands::Pr {
2184 number,
2185 repo,
2186 checkout,
2187 } => {
2188 let config = load_config_from_cli(&cli)?;
2189 run_pr(
2190 &cli,
2191 &config,
2192 number,
2193 repo.as_deref(),
2194 checkout,
2195 pending_telemetry_notice.take(),
2196 Arc::clone(&plugin_registry),
2197 )
2198 .await
2199 }
2200 Commands::Apply(args) => run_apply(args),
2201 Commands::Eval(args) => run_eval(args),
2202 Commands::Scorecard(args) => run_scorecard(args),
2203 Commands::Mcp { command } => {
2204 let config = load_config_from_cli(&cli)?;
2205 let workspace = resolve_workspace(&cli);
2206 run_mcp_command(&config, &workspace, command, plugin_registry.as_ref()).await
2207 }
2208 Commands::Features(command) => {
2209 let config = load_config_from_cli(&cli)?;
2210 run_features_command(&config, command)
2211 }
2212 Commands::Integrations { command } => {
2213 let config = load_structural_config_from_cli(&cli)?;
2216 let workspace = resolve_workspace(&cli);
2217 integrations::cli::run(&config, &workspace, command)
2218 }
2219 Commands::Sandbox(args) => run_sandbox_command(args),
2220 Commands::Serve(args) => {
2221 let workspace = cli.workspace.clone().unwrap_or_else(|| {
2222 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
2223 });
2224 let http_selected = validate_serve_mode_selection(
2225 args.mcp,
2226 args.http,
2227 args.mobile,
2228 args.web,
2229 args.acp,
2230 )?;
2231 if args.mcp {
2232 tokio::task::block_in_place(|| mcp_server::run_mcp_server(workspace))
2233 } else if http_selected {
2234 let (config, config_profile) =
2235 load_config_from_cli_with_effective_profile(&cli)?;
2236 let cors_origins = resolve_cors_origins(&config, &args.cors_origin);
2237 let bind_host = resolve_serve_bind_host(args.mobile, args.host);
2238 if args.web && bind_host.host != "127.0.0.1" {
2239 bail!("Codewhale web is loopback-only and must bind to 127.0.0.1");
2240 }
2241 if bind_host.mobile_rebound_to_lan {
2242 println!(
2243 "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."
2244 );
2245 }
2246 runtime_api::run_http_server(
2247 config,
2248 workspace,
2249 std::sync::Arc::clone(&plugin_discovery),
2250 runtime_api::RuntimeApiOptions {
2251 host: bind_host.host,
2252 port: args.port,
2253 workers: args.workers.clamp(1, 8),
2254 cors_origins,
2255 auth_token: args.auth_token,
2256 insecure_no_auth: args.insecure_no_auth,
2257 mobile: args.mobile,
2258 web: args.web,
2259 show_qr: args.qr,
2260 config_path: cli.config.clone(),
2261 config_profile,
2262 },
2263 )
2264 .await
2265 } else if args.acp {
2266 let config = load_config_from_cli(&cli)?;
2267 let model = config.default_model();
2268 acp_server::run_acp_server(config, model, workspace).await
2269 } else {
2270 unreachable!("server mode count checked above")
2271 }
2272 }
2273 Commands::Resume { session_id, last } => {
2274 let config = load_config_from_cli(&cli)?;
2275 let workspace = resolve_workspace(&cli);
2276 let resume_id = resolve_session_id(session_id, last, &workspace)?;
2277 run_interactive(
2278 &cli,
2279 &config,
2280 Some(resume_id),
2281 None,
2282 pending_telemetry_notice.take(),
2283 std::sync::Arc::clone(&plugin_registry),
2284 )
2285 .await
2286 }
2287 Commands::Fork { session_id, last } => {
2288 let config = load_config_from_cli(&cli)?;
2289 let workspace = resolve_workspace(&cli);
2290 let new_session_id = fork_session(&config, session_id, last, &workspace)?;
2291 run_interactive(
2292 &cli,
2293 &config,
2294 Some(new_session_id),
2295 None,
2296 pending_telemetry_notice.take(),
2297 std::sync::Arc::clone(&plugin_registry),
2298 )
2299 .await
2300 }
2301 };
2302 }
2303
2304 let config = load_config_from_cli(&cli)?;
2308 if let Some(initial_input) = top_level_prompt_initial_input(&cli.prompt) {
2309 return run_interactive(
2310 &cli,
2311 &config,
2312 None,
2313 Some(initial_input),
2314 pending_telemetry_notice.take(),
2315 std::sync::Arc::clone(&plugin_registry),
2316 )
2317 .await;
2318 }
2319
2320 let mut startup_notice = None;
2323 let resume_session_id = if cli.continue_session {
2324 let workspace = resolve_workspace(&cli);
2325 recover_interrupted_checkpoint_for_resume(&workspace)
2326 .or_else(|| latest_session_id_for_workspace(&workspace).ok().flatten())
2327 } else if let Some(id) = cli.resume.clone() {
2328 Some(id)
2329 } else if !cli.fresh {
2330 let workspace = resolve_workspace(&cli);
2331 preserve_interrupted_checkpoint_for_explicit_resume(&workspace);
2332 let (session_id, notice) = resolve_auto_resume(&workspace);
2338 startup_notice = notice;
2339 session_id
2340 } else {
2341 None
2342 };
2343
2344 run_interactive_with_notice(
2347 &cli,
2348 &config,
2349 resume_session_id,
2350 None,
2351 startup_notice,
2352 pending_telemetry_notice.take(),
2353 plugin_registry,
2354 )
2355 .await
2356}
2357
2358fn resolve_auto_resume(workspace: &Path) -> (Option<String>, Option<String>) {
2365 use crate::session_resume::{ResumeRequest, decide_auto_resume};
2366
2367 let enabled = crate::settings::Settings::load_persisted()
2368 .map(|settings| settings.session_auto_resume)
2369 .unwrap_or(false);
2370 if !enabled {
2371 return (None, None);
2372 }
2373 let Ok(manager) = SessionManager::default_location() else {
2374 return (None, None);
2375 };
2376 let decision = decide_auto_resume(true, &ResumeRequest::default(), workspace, &manager);
2377 (
2378 decision.session_id().map(str::to_string),
2379 decision.status_message(),
2380 )
2381}
2382
2383fn prepare_cli_startup(
2384 cli: Cli,
2385 initialize_plugins: impl FnOnce(),
2386 load_dotenv: impl FnOnce(),
2387) -> (Cli, Option<Commands>) {
2388 initialize_plugins();
2389 let command = cli.command.clone();
2390 let should_load_dotenv = match command.as_ref() {
2391 Some(Commands::Doctor(args)) => args.probe_api || args.probe_local,
2392 _ => true,
2393 };
2394 if should_load_dotenv {
2395 load_dotenv();
2396 }
2397 (cli, command)
2398}
2399
2400const MAX_WORKSPACE_DOTENV_BYTES: u64 = 1024 * 1024;
2401
2402#[derive(Debug, Default)]
2403struct WorkspaceDotenvReport {
2404 path: PathBuf,
2405 loaded: BTreeSet<String>,
2406 ignored: BTreeSet<String>,
2407}
2408
2409fn warn_on_workspace_dotenv_result() {
2417 match load_workspace_dotenv_credentials() {
2418 Ok(Some(report)) if !report.ignored.is_empty() => {
2419 eprintln!(
2420 "Codewhale ignored non-credential settings in {}: {}. Use config.toml, CLI flags, or the launching shell for control settings.",
2421 report.path.display(),
2422 display_env_key_set(&report.ignored)
2423 );
2424 }
2425 Ok(_) => {}
2426 Err(error) => {
2427 eprintln!("Codewhale did not load workspace .env: {error}");
2431 }
2432 }
2433}
2434
2435fn display_env_key_set(keys: &BTreeSet<String>) -> String {
2436 const MAX_DISPLAYED: usize = 12;
2437 let mut labels = keys
2438 .iter()
2439 .take(MAX_DISPLAYED)
2440 .map(|key| {
2441 if key
2442 .chars()
2443 .all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_')
2444 {
2445 key.as_str()
2446 } else {
2447 "<invalid-name>"
2448 }
2449 })
2450 .collect::<Vec<_>>();
2451 if keys.len() > MAX_DISPLAYED {
2452 labels.push("...");
2453 }
2454 labels.join(", ")
2455}
2456
2457fn load_workspace_dotenv_credentials() -> Result<Option<WorkspaceDotenvReport>> {
2458 let Some(path) = find_workspace_dotenv()? else {
2459 return Ok(None);
2460 };
2461 load_workspace_dotenv_credentials_from_path(&path).map(Some)
2462}
2463
2464fn find_workspace_dotenv() -> Result<Option<PathBuf>> {
2465 let cwd = std::env::current_dir().context("could not resolve the current workspace")?;
2466 let boundary = cwd
2467 .ancestors()
2468 .find(|ancestor| std::fs::symlink_metadata(ancestor.join(".git")).is_ok())
2469 .unwrap_or(cwd.as_path());
2470
2471 for ancestor in cwd.ancestors() {
2472 let candidate = ancestor.join(".env");
2473 match std::fs::symlink_metadata(&candidate) {
2474 Ok(_) => return Ok(Some(candidate)),
2475 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
2476 Err(error) => {
2477 return Err(anyhow!(
2478 "could not inspect {}: {error}",
2479 candidate.display()
2480 ));
2481 }
2482 }
2483 if ancestor == boundary {
2484 break;
2485 }
2486 }
2487 Ok(None)
2488}
2489
2490fn load_workspace_dotenv_credentials_from_path(path: &Path) -> Result<WorkspaceDotenvReport> {
2491 let contents = read_stable_workspace_dotenv(path)?;
2492 let text = std::str::from_utf8(&contents)
2493 .map_err(|_| anyhow!("{} is not valid UTF-8", path.display()))?;
2494 if dotenv_has_variable_expansion(text) {
2495 bail!(
2496 "{} uses variable expansion; workspace .env values must be literal to prevent ambient-secret substitution",
2497 path.display()
2498 );
2499 }
2500
2501 let mut report = WorkspaceDotenvReport {
2502 path: path.to_path_buf(),
2503 ..WorkspaceDotenvReport::default()
2504 };
2505 let entries = dotenvy::from_read_iter(std::io::Cursor::new(contents))
2506 .collect::<std::result::Result<Vec<_>, _>>()
2507 .map_err(|_| anyhow!("{} could not be parsed safely", path.display()))?;
2508 for entry in entries {
2509 let (key, value) = entry;
2510 if !is_workspace_dotenv_credential_key(&key) {
2511 report.ignored.insert(key);
2512 continue;
2513 }
2514 if std::env::var_os(&key).is_some() {
2515 continue;
2516 }
2517
2518 unsafe { std::env::set_var(&key, value) };
2523 report.loaded.insert(key);
2524 }
2525 Ok(report)
2526}
2527
2528fn is_workspace_dotenv_credential_key(key: &str) -> bool {
2529 codewhale_config::provider::providers_sorted_for_display()
2530 .into_iter()
2531 .any(|provider| provider.env_vars().contains(&key))
2532 || matches!(
2533 key,
2534 "DEEPSEEK_SEARCH_API_KEY"
2535 | "SOFYA_API_KEY"
2536 | "METASO_API_KEY"
2537 | "BAIDU_SEARCH_API_KEY"
2538 | "DEEPSEEK_SANDBOX_API_KEY"
2539 )
2540}
2541
2542fn dotenv_has_variable_expansion(contents: &str) -> bool {
2543 let mut escaped = false;
2544 let mut single_quoted = false;
2545 let mut double_quoted = false;
2546 let mut comment = false;
2547
2548 for ch in contents.chars() {
2549 if comment {
2550 if ch == '$' {
2554 return true;
2555 }
2556 if ch == '\n' {
2557 comment = false;
2558 escaped = false;
2559 }
2560 continue;
2561 }
2562 if single_quoted {
2563 if ch == '\'' {
2564 single_quoted = false;
2565 }
2566 continue;
2567 }
2568 if escaped {
2569 escaped = false;
2570 continue;
2571 }
2572 if ch == '\\' {
2573 escaped = true;
2574 continue;
2575 }
2576 if ch == '\'' && !double_quoted {
2577 single_quoted = true;
2578 continue;
2579 }
2580 if ch == '"' {
2581 double_quoted = !double_quoted;
2582 continue;
2583 }
2584 if ch == '#' && !double_quoted {
2585 comment = true;
2586 continue;
2587 }
2588 if ch == '$' {
2589 return true;
2590 }
2591 }
2592 false
2593}
2594
2595fn read_stable_workspace_dotenv(path: &Path) -> Result<Vec<u8>> {
2596 let mut file = open_workspace_dotenv_without_following_links(path)?;
2597 let metadata = file
2598 .metadata()
2599 .map_err(|error| anyhow!("could not inspect {}: {error}", path.display()))?;
2600 if !metadata.is_file() {
2601 bail!("{} is not a regular file", path.display());
2602 }
2603 if workspace_dotenv_has_multiple_links(&file, &metadata)? {
2604 bail!(
2605 "{} has multiple filesystem links, not a unique workspace-owned file",
2606 path.display()
2607 );
2608 }
2609 if metadata.len() > MAX_WORKSPACE_DOTENV_BYTES {
2610 bail!(
2611 "{} exceeds the {} byte workspace .env limit",
2612 path.display(),
2613 MAX_WORKSPACE_DOTENV_BYTES
2614 );
2615 }
2616
2617 let mut contents = Vec::with_capacity(metadata.len() as usize);
2618 (&mut file)
2619 .take(MAX_WORKSPACE_DOTENV_BYTES + 1)
2620 .read_to_end(&mut contents)
2621 .map_err(|error| anyhow!("could not read {}: {error}", path.display()))?;
2622 if contents.len() as u64 > MAX_WORKSPACE_DOTENV_BYTES {
2623 bail!(
2624 "{} exceeds the {} byte workspace .env limit",
2625 path.display(),
2626 MAX_WORKSPACE_DOTENV_BYTES
2627 );
2628 }
2629 Ok(contents)
2630}
2631
2632#[cfg(unix)]
2633fn workspace_dotenv_has_multiple_links(
2634 _file: &std::fs::File,
2635 metadata: &std::fs::Metadata,
2636) -> Result<bool> {
2637 use std::os::unix::fs::MetadataExt;
2638
2639 Ok(metadata.nlink() > 1)
2640}
2641
2642#[cfg(windows)]
2643fn workspace_dotenv_has_multiple_links(
2644 file: &std::fs::File,
2645 _metadata: &std::fs::Metadata,
2646) -> Result<bool> {
2647 use std::os::windows::io::AsRawHandle;
2648 use windows::Win32::Foundation::HANDLE;
2649 use windows::Win32::Storage::FileSystem::{
2650 BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle,
2651 };
2652
2653 let mut information = BY_HANDLE_FILE_INFORMATION::default();
2654 unsafe {
2658 GetFileInformationByHandle(HANDLE(file.as_raw_handle()), &mut information)
2659 .map_err(|error| anyhow!("could not inspect workspace .env link count: {error}"))?;
2660 }
2661 Ok(information.nNumberOfLinks > 1)
2662}
2663
2664#[cfg(not(any(unix, windows)))]
2665fn workspace_dotenv_has_multiple_links(
2666 _file: &std::fs::File,
2667 _metadata: &std::fs::Metadata,
2668) -> Result<bool> {
2669 Ok(false)
2670}
2671
2672#[cfg(unix)]
2673fn open_workspace_dotenv_without_following_links(path: &Path) -> Result<std::fs::File> {
2674 use std::os::unix::fs::OpenOptionsExt;
2675
2676 std::fs::OpenOptions::new()
2677 .read(true)
2678 .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK)
2681 .open(path)
2682 .map_err(|error| anyhow!("could not securely open {}: {error}", path.display()))
2683}
2684
2685#[cfg(windows)]
2686fn open_workspace_dotenv_without_following_links(path: &Path) -> Result<std::fs::File> {
2687 use std::os::windows::fs::{MetadataExt, OpenOptionsExt};
2688
2689 const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
2690 const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
2691 let file = std::fs::OpenOptions::new()
2692 .read(true)
2693 .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
2694 .open(path)
2695 .map_err(|error| anyhow!("could not securely open {}: {error}", path.display()))?;
2696 let metadata = file
2697 .metadata()
2698 .map_err(|error| anyhow!("could not inspect {}: {error}", path.display()))?;
2699 if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
2700 bail!(
2701 "{} is a reparse point, not a workspace-owned file",
2702 path.display()
2703 );
2704 }
2705 Ok(file)
2706}
2707
2708#[cfg(not(any(unix, windows)))]
2709fn open_workspace_dotenv_without_following_links(path: &Path) -> Result<std::fs::File> {
2710 let metadata = std::fs::symlink_metadata(path)
2711 .map_err(|error| anyhow!("could not inspect {}: {error}", path.display()))?;
2712 if metadata.file_type().is_symlink() {
2713 bail!(
2714 "{} is a symbolic link, not a workspace-owned file",
2715 path.display()
2716 );
2717 }
2718 std::fs::File::open(path)
2719 .map_err(|error| anyhow!("could not securely open {}: {error}", path.display()))
2720}
2721
2722fn generate_completions(shell: Shell) {
2724 let mut cmd = Cli::command();
2725 let name = cmd.get_name().to_string();
2726 generate(shell, &mut cmd, name, &mut io::stdout());
2727}
2728
2729fn run_eval(args: EvalArgs) -> Result<()> {
2731 let fail_step = match args.fail_step.as_deref() {
2732 Some(value) => ScenarioStepKind::parse(value)
2733 .map(Some)
2734 .ok_or_else(|| anyhow!("invalid --fail-step '{value}'"))?,
2735 None => None,
2736 };
2737
2738 let config = EvalHarnessConfig {
2739 fail_step,
2740 shell_command: args.shell_command,
2741 shell_expect_token: args.shell_expect_token,
2742 max_output_chars: args.max_output_chars,
2743 record_dir: args.record.clone(),
2744 ..EvalHarnessConfig::default()
2745 };
2746
2747 let harness = EvalHarness::new(config);
2748 let run = harness.run().context("evaluation harness failed")?;
2749 let report = run.to_report();
2750
2751 if args.json {
2752 let json = serde_json::to_string_pretty(&report)?;
2753 println!("{json}");
2754 } else {
2755 println!("Offline Eval Harness");
2756 println!("scenario: {}", report.scenario_name);
2757 println!("workspace: {}", report.workspace_root.display());
2758 println!("success: {}", report.metrics.success);
2759 println!("steps: {}", report.metrics.steps);
2760 println!("tool_errors: {}", report.metrics.tool_errors);
2761 println!("duration_ms: {}", report.metrics.duration.as_millis());
2762
2763 if !report.metrics.per_tool.is_empty() {
2764 println!("per_tool:");
2765 for (kind, stats) in &report.metrics.per_tool {
2766 println!(
2767 " {} invocations={} errors={} duration_ms={}",
2768 kind.tool_name(),
2769 stats.invocations,
2770 stats.errors,
2771 stats.total_duration.as_millis()
2772 );
2773 }
2774 }
2775
2776 let failed_steps: Vec<_> = report.steps.iter().filter(|s| !s.success).collect();
2777 if !failed_steps.is_empty() {
2778 println!("failed_steps:");
2779 for step in failed_steps {
2780 let error = step.error.as_deref().unwrap_or("unknown error");
2781 println!(
2782 " {} tool={} error={}",
2783 step.kind.tool_name(),
2784 step.tool_name,
2785 error
2786 );
2787 }
2788 }
2789 }
2790
2791 if report.metrics.success {
2792 Ok(())
2793 } else {
2794 bail!("offline evaluation harness reported failure")
2795 }
2796}
2797
2798fn run_scorecard(args: ScorecardArgs) -> Result<()> {
2804 use crate::scorecard::{RecordedTurn, Scorecard, ScorecardMetrics};
2805
2806 let raw = std::fs::read_to_string(&args.input)
2807 .with_context(|| format!("failed to read scorecard input {}", args.input.display()))?;
2808 let recorded: Vec<RecordedTurn> = serde_json::from_str(&raw)
2809 .with_context(|| format!("failed to parse scorecard input {}", args.input.display()))?;
2810
2811 let card = Scorecard::from_recorded_turns(&recorded);
2812
2813 let regressions = match &args.baseline {
2814 Some(path) => {
2815 let baseline_raw = std::fs::read_to_string(path)
2816 .with_context(|| format!("failed to read baseline {}", path.display()))?;
2817 let baseline: ScorecardMetrics = serde_json::from_str(&baseline_raw)
2818 .with_context(|| format!("failed to parse baseline {}", path.display()))?;
2819 card.metrics.regressions_against(&baseline, args.threshold)
2820 }
2821 None => Vec::new(),
2822 };
2823
2824 if args.json {
2825 let out = serde_json::json!({
2826 "per_turn": card.per_turn,
2827 "metrics": card.metrics,
2828 "regressions": regressions,
2829 });
2830 println!("{}", serde_json::to_string_pretty(&out)?);
2831 } else {
2832 print!("{}", card.to_summary());
2833 for r in ®ressions {
2834 println!(
2835 "REGRESSION {}: baseline {:.4} -> current {:.4} (+{:.1}%)",
2836 r.metric, r.baseline, r.current, r.pct_increase
2837 );
2838 }
2839 }
2840
2841 if regressions.is_empty() {
2842 Ok(())
2843 } else {
2844 bail!(
2845 "{} metric(s) regressed past the {:.1}% threshold",
2846 regressions.len(),
2847 args.threshold
2848 )
2849 }
2850}
2851
2852async fn run_fleet_command(workspace: &Path, config: &Config, args: FleetArgs) -> Result<()> {
2853 use crate::fleet::alerts::{
2854 FleetAlertAdapterConfig, FleetAlertConfig, FleetAlertDispatcher, FleetAlertEvent,
2855 FleetEnvSecretResolver,
2856 };
2857 use crate::fleet::control as fleet_control;
2858 use crate::fleet::executor::FleetExecutor;
2859 use crate::fleet::manager::{FleetManager, FleetStatusSnapshot, FleetWorkerInspection};
2860 use codewhale_lane::{ControlOperation, ControlSurface};
2861 use codewhale_protocol::fleet::{FleetAlertEventClass, FleetArtifactKind, FleetRunId};
2862
2863 fn print_status(status: &FleetStatusSnapshot) {
2867 println!("{}", fleet_control::render_fleet_status_snapshot(status));
2868 }
2869
2870 fn print_inspection(inspection: &FleetWorkerInspection) {
2871 println!("{}", fleet_control::render_inspection(inspection));
2872 }
2873
2874 fn print_artifacts(inspection: &FleetWorkerInspection) {
2875 println!("{}", fleet_control::render_artifacts(inspection));
2876 }
2877
2878 fn emit_fleet_receipt(receipt: &codewhale_lane::ControlReceipt) -> Result<()> {
2880 if receipt.is_error() {
2881 eprintln!("{}", receipt.render());
2882 let detail = receipt
2883 .failure
2884 .as_ref()
2885 .map(ToString::to_string)
2886 .unwrap_or_else(|| receipt.outcome.as_str().to_string());
2887 bail!("{}: {detail}", receipt.operation_id);
2888 }
2889 println!("{}", receipt.render());
2890 Ok(())
2891 }
2892
2893 fn print_logs(workspace: &Path, inspection: &FleetWorkerInspection) -> Result<()> {
2894 let mut printed = false;
2895 for artifact in inspection
2896 .artifacts
2897 .iter()
2898 .filter(|artifact| matches!(artifact.kind, FleetArtifactKind::Log))
2899 {
2900 let path = workspace.join(&artifact.path);
2901 println!("== {} ==", artifact.path.display());
2902 let contents = std::fs::read_to_string(&path)
2903 .with_context(|| format!("reading fleet log {}", path.display()))?;
2904 let preview: String = contents.chars().take(16 * 1024).collect();
2905 let mut safe_preview = String::with_capacity(preview.len());
2910 crate::tui::osc8::strip_ansi_into(&preview, &mut safe_preview);
2911 print!("{safe_preview}");
2912 if contents.chars().count() > preview.chars().count() {
2913 println!("\n[truncated]");
2914 } else if !preview.ends_with('\n') {
2915 println!();
2916 }
2917 printed = true;
2918 }
2919 if !printed {
2920 println!("logs: none");
2921 }
2922 Ok(())
2923 }
2924
2925 fn alert_event_class(arg: FleetAlertEventArg) -> FleetAlertEventClass {
2926 match arg {
2927 FleetAlertEventArg::Stale => FleetAlertEventClass::Stale,
2928 FleetAlertEventArg::RestartExhausted => FleetAlertEventClass::RestartExhausted,
2929 FleetAlertEventArg::NeedsHuman => FleetAlertEventClass::NeedsHuman,
2930 FleetAlertEventArg::BudgetExceeded => FleetAlertEventClass::BudgetExceeded,
2931 FleetAlertEventArg::VerifierFailed => FleetAlertEventClass::VerifierFailed,
2932 FleetAlertEventArg::RunCompleted => FleetAlertEventClass::RunCompleted,
2933 }
2934 }
2935
2936 fn alert_status(class: FleetAlertEventClass, override_status: Option<String>) -> String {
2937 if let Some(status) = override_status {
2938 return status;
2939 }
2940 match class {
2941 FleetAlertEventClass::Stale => "stale",
2942 FleetAlertEventClass::RestartExhausted => "failed",
2943 FleetAlertEventClass::NeedsHuman => "needs_human",
2944 FleetAlertEventClass::BudgetExceeded => "budget_exceeded",
2945 FleetAlertEventClass::VerifierFailed => "verifier_failed",
2946 FleetAlertEventClass::RunCompleted => "completed",
2947 }
2948 .to_string()
2949 }
2950
2951 fn alert_adapter(args: &FleetAlertDryRunArgs) -> FleetAlertAdapterConfig {
2952 match args.adapter {
2953 FleetAlertAdapterArg::Slack => FleetAlertAdapterConfig::Slack {
2954 webhook_env: args.slack_webhook_env.clone(),
2955 channel: None,
2956 },
2957 FleetAlertAdapterArg::Webhook => FleetAlertAdapterConfig::Webhook {
2958 url_env: args.webhook_url_env.clone(),
2959 secret_env: args.webhook_secret_env.clone(),
2960 },
2961 FleetAlertAdapterArg::PagerDuty => FleetAlertAdapterConfig::PagerDuty {
2962 routing_key_env: args.pagerduty_routing_key_env.clone(),
2963 severity: args.pagerduty_severity.clone(),
2964 },
2965 }
2966 }
2967
2968 let fleet_config = config.fleet_config();
2969 let provider = config.api_provider();
2970 let max_subagents = config.max_subagents_for_provider(provider);
2971 let coordination_manager = crate::tools::subagent::new_shared_subagent_manager_with_timeout(
2972 workspace.to_path_buf(),
2973 max_subagents,
2974 config
2975 .max_admitted_subagents_for_provider(provider)
2976 .max(max_subagents),
2977 Duration::from_secs(config.subagent_heartbeat_timeout_secs_for_provider(provider)),
2978 config.launch_concurrency_for_provider(provider),
2979 config.subagent_token_budget_for_provider(provider),
2980 );
2981 let fleet_context = fleet_control::fleet_control_context(workspace);
2986 if let Some(operation) = match &args.command {
2995 FleetCommand::List => Some(ControlOperation::FleetList),
2996 FleetCommand::Status => Some(ControlOperation::FleetStatus),
2997 FleetCommand::Interrupt { .. } => Some(ControlOperation::FleetInterrupt),
2998 FleetCommand::Resume { .. } => Some(ControlOperation::FleetResume),
2999 _ => None,
3000 } {
3001 let descriptor = operation.descriptor();
3002 let availability = descriptor.availability(ControlSurface::Cli, fleet_context);
3003 if !availability.is_available() {
3004 return emit_fleet_receipt(&codewhale_lane::ControlReceipt::unavailable(
3005 descriptor,
3006 ControlSurface::Cli,
3007 availability,
3008 ));
3009 }
3010 }
3011
3012 let manager = FleetManager::open(workspace)?
3015 .with_exec_config(fleet_config.exec.clone())
3016 .with_fleet_config(fleet_config)
3017 .with_sub_agent_manager(coordination_manager)
3018 .with_session_model(config.default_model())
3019 .with_route_config(config.clone());
3020 match args.command {
3021 FleetCommand::Init => {
3022 println!("fleet ledger: {}", manager.ledger_path().display());
3023 Ok(())
3024 }
3025 FleetCommand::Run(args) => {
3026 let max_workers = args.max_workers.clamp(1, 128);
3027 let manager =
3028 manager.with_stale_after(Duration::from_secs(args.stale_after_seconds.max(1)));
3029 let report = manager.create_run_from_task_spec_path(&args.task_spec, max_workers)?;
3030 println!(
3031 "fleet run: {} tasks={} leased={} queued={}",
3032 report.run_id.0, report.task_count, report.leased, report.queued
3033 );
3034 for warning in &report.warnings {
3035 println!("warning: {warning}");
3036 }
3037 println!("workers:");
3038 for worker_id in &report.worker_ids {
3039 println!(" {worker_id}");
3040 }
3041 if args.once {
3042 print_status(&manager.run_status(&report.run_id)?);
3043 return Ok(());
3044 }
3045 println!(
3046 "manager loop running; use `codewhale fleet status`, `inspect`, `interrupt`, or `stop --all` from another terminal."
3047 );
3048 let mut executor = FleetExecutor::new(workspace);
3049 let codewhale_binary = fleet::executor::configured_codewhale_binary();
3050 let status = manager
3051 .run_to_completion(
3052 &report.run_id,
3053 max_workers,
3054 &mut executor,
3055 &codewhale_binary,
3056 None,
3057 Duration::from_secs(2),
3058 )
3059 .await?;
3060 print_status(&status);
3061 Ok(())
3062 }
3063 FleetCommand::List => emit_fleet_receipt(&fleet_control::execute_fleet_control_with(
3064 ControlSurface::Cli,
3065 workspace,
3066 fleet_context,
3067 &manager,
3068 ControlOperation::FleetList,
3069 None,
3070 )),
3071 FleetCommand::Status => emit_fleet_receipt(&fleet_control::execute_fleet_control_with(
3072 ControlSurface::Cli,
3073 workspace,
3074 fleet_context,
3075 &manager,
3076 ControlOperation::FleetStatus,
3077 None,
3078 )),
3079 FleetCommand::Inspect { worker_id } => {
3080 print_inspection(&manager.inspect_worker(&worker_id)?);
3081 Ok(())
3082 }
3083 FleetCommand::Logs { worker_id } => {
3084 let inspection = manager.inspect_worker(&worker_id)?;
3085 print_logs(workspace, &inspection)
3086 }
3087 FleetCommand::Artifacts { worker_id } => {
3088 let inspection = manager.inspect_worker(&worker_id)?;
3089 print_artifacts(&inspection);
3090 Ok(())
3091 }
3092 FleetCommand::Interrupt { worker_id } => {
3093 emit_fleet_receipt(&fleet_control::execute_fleet_control_with(
3094 ControlSurface::Cli,
3095 workspace,
3096 fleet_context,
3097 &manager,
3098 ControlOperation::FleetInterrupt,
3099 Some(&worker_id),
3100 ))
3101 }
3102 FleetCommand::Restart { worker_id } => {
3103 let report = manager.restart_worker(&worker_id)?;
3104 print_inspection(&report.inspection);
3105 println!(
3106 "manager loop running for restarted run {}; use `codewhale fleet status`, `inspect`, `interrupt`, or `stop --all` from another terminal.",
3107 report.run_id.0
3108 );
3109 let mut executor = FleetExecutor::new(workspace);
3110 let codewhale_binary = fleet::executor::configured_codewhale_binary();
3111 let status = manager
3112 .run_to_completion(
3113 &report.run_id,
3114 report.max_workers,
3115 &mut executor,
3116 &codewhale_binary,
3117 None,
3118 Duration::from_secs(2),
3119 )
3120 .await?;
3121 print_status(&status);
3122 Ok(())
3123 }
3124 FleetCommand::Resume {
3125 run_id,
3126 stale_after_seconds,
3127 } => {
3128 let manager = manager.with_stale_after(Duration::from_secs(stale_after_seconds.max(1)));
3129 emit_fleet_receipt(&fleet_control::execute_fleet_control_with(
3130 ControlSurface::Cli,
3131 workspace,
3132 fleet_context,
3133 &manager,
3134 ControlOperation::FleetResume,
3135 Some(&run_id),
3136 ))
3137 }
3138 FleetCommand::Stop { all } => {
3139 if !all {
3140 bail!("pass --all to stop all fleet work");
3141 }
3142 let stopped = manager.stop_all()?;
3143 println!("stopped: {stopped}");
3144 Ok(())
3145 }
3146 FleetCommand::AlertDryRun(args) => {
3147 let class = alert_event_class(args.event);
3148 let adapter = alert_adapter(&args);
3149 let event = FleetAlertEvent {
3150 class,
3151 run_id: FleetRunId::from(args.run_id.clone()),
3152 worker_id: args.worker_id.clone(),
3153 task_id: args.task_id.clone(),
3154 status: alert_status(class, args.status.clone()),
3155 reason: args.reason.clone(),
3156 };
3157 let dispatcher = FleetAlertDispatcher::new(
3158 FleetAlertConfig::dry_run_for_adapter(adapter),
3159 FleetEnvSecretResolver,
3160 );
3161 let deliveries = dispatcher.dispatch(&event)?;
3162 for delivery in deliveries {
3163 println!(
3164 "{}",
3165 serde_json::to_string_pretty(&delivery.redacted_payload)?
3166 );
3167 }
3168 Ok(())
3169 }
3170 }
3171}
3172
3173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3174enum WriteStatus {
3175 Created,
3176 Overwritten,
3177 SkippedExists,
3178}
3179
3180fn ensure_parent_dir(path: &Path) -> Result<()> {
3181 if let Some(parent) = path.parent()
3182 && !parent.as_os_str().is_empty()
3183 {
3184 std::fs::create_dir_all(parent)
3185 .with_context(|| format!("Failed to create directory for {}", parent.display()))?;
3186 }
3187 Ok(())
3188}
3189
3190fn write_template_file(path: &Path, contents: &str, force: bool) -> Result<WriteStatus> {
3191 ensure_parent_dir(path)?;
3192
3193 if path.exists() && !force {
3194 return Ok(WriteStatus::SkippedExists);
3195 }
3196
3197 let status = if path.exists() {
3198 WriteStatus::Overwritten
3199 } else {
3200 WriteStatus::Created
3201 };
3202
3203 std::fs::write(path, contents)
3204 .with_context(|| format!("Failed to write template at {}", path.display()))?;
3205
3206 Ok(status)
3207}
3208
3209fn mcp_template_json() -> Result<String> {
3210 let mut cfg = McpConfig::default();
3211 cfg.servers.insert(
3212 "example".to_string(),
3213 McpServerConfig {
3214 command: Some("node".to_string()),
3215 args: vec!["./path/to/your-mcp-server.js".to_string()],
3216 env: std::collections::HashMap::new(),
3217 cwd: None,
3218 url: None,
3219 transport: None,
3220 connect_timeout: None,
3221 execute_timeout: None,
3222 read_timeout: None,
3223 disabled: true,
3224 enabled: true,
3225 required: false,
3226 enabled_tools: Vec::new(),
3227 disabled_tools: Vec::new(),
3228 headers: std::collections::HashMap::new(),
3229 env_headers: std::collections::HashMap::new(),
3230 bearer_token_env_var: None,
3231 scopes: Vec::new(),
3232 oauth: None,
3233 oauth_resource: None,
3234 reviewed_plugin: None,
3235 },
3236 );
3237 serde_json::to_string_pretty(&cfg)
3238 .map_err(|e| anyhow!("Failed to render MCP template JSON: {e}"))
3239}
3240
3241fn init_mcp_config(path: &Path, force: bool) -> Result<WriteStatus> {
3242 let template = mcp_template_json()?;
3243 write_template_file(path, &template, force)
3244}
3245
3246fn skills_template(name: &str) -> String {
3247 format!(
3248 "\
3249---\n\
3250name: {name}\n\
3251description: Quick repo diagnostics and setup guidance\n\
3252allowed-tools: diagnostics, list_dir, read_file, grep_files, git_status, git_diff\n\
3253---\n\n\
3254When this skill is active:\n\
32551. Run the diagnostics tool to report workspace and sandbox status.\n\
32562. Skim key project files (README.md, Cargo.toml, AGENTS.md) before editing.\n\
32573. Prefer small, validated changes and summarize what you verified.\n\
3258"
3259 )
3260}
3261
3262fn init_skills_dir(skills_dir: &Path, force: bool) -> Result<(PathBuf, WriteStatus)> {
3263 std::fs::create_dir_all(skills_dir)
3264 .with_context(|| format!("Failed to create skills dir {}", skills_dir.display()))?;
3265
3266 let skill_name = "getting-started";
3267 let skill_path = skills_dir.join(skill_name).join("SKILL.md");
3268 ensure_parent_dir(&skill_path)?;
3269
3270 let status = write_template_file(&skill_path, &skills_template(skill_name), force)?;
3271 Ok((skill_path, status))
3272}
3273
3274fn tools_readme_template() -> &'static str {
3275 "# Local tools\n\n\
3276 Drop self-describing scripts here so they can be discovered by\n\
3277 `codewhale-tui setup --status` and surfaced in `codewhale-tui doctor`.\n\n\
3278 When `[tools.plugin_dir]` is set in config.toml (or when the default\n\
3279 `~/.codewhale/tools/` directory exists), they are auto-discovered and\n\
3280 registered as model-visible tools.\n\n\
3281 Each script should start with a frontmatter-style header so the\n\
3282 description is visible without executing the file and the agent knows\n\
3283 the tool name, description, and input schema:\n\n\
3284 ```\n\
3285 # name: my-tool\n\
3286 # description: One-line summary of what this tool does\n\
3287 # usage: my-tool [args...]\n\
3288 ```\n\n\
3289 The directory is intentionally not auto-loaded into the agent's tool\n\
3290 catalog. Wire individual tools through MCP, hooks, or skills when you\n\
3291 want them available inside a session.\n"
3292}
3293
3294fn tools_example_script() -> &'static str {
3295 "#!/usr/bin/env sh\n\
3296 # name: example\n\
3297 # description: Print a confirmation that local tool discovery works\n\
3298 # usage: example [name]\n\
3299 printf 'codewhale-tui local tool ok: %s\\n' \"${1:-world}\"\n"
3300}
3301
3302fn init_tools_dir(tools_dir: &Path, force: bool) -> Result<(PathBuf, WriteStatus, WriteStatus)> {
3303 std::fs::create_dir_all(tools_dir)
3304 .with_context(|| format!("Failed to create tools dir {}", tools_dir.display()))?;
3305
3306 let readme_path = tools_dir.join("README.md");
3307 let readme_status = write_template_file(&readme_path, tools_readme_template(), force)?;
3308
3309 let example_path = tools_dir.join("example.sh");
3310 let example_status = write_template_file(&example_path, tools_example_script(), force)?;
3311
3312 Ok((tools_dir.to_path_buf(), readme_status, example_status))
3313}
3314
3315fn plugins_readme_template() -> &'static str {
3316 "# Local plugins\n\n\
3317 Each Codewhale plugin bundle lives in its own subdirectory with a\n\
3318 versioned `plugin.toml`. User bundles live here; workspace bundles live\n\
3319 under `<workspace>/.codewhale/plugins/`. Both are discovered read-only,\n\
3320 untrusted, and disabled by default.\n\n\
3321 A v0.9.1 bundle layout looks like:\n\n\
3322 ```\n\
3323 plugins/\n\
3324 my-plugin/\n\
3325 plugin.toml\n\
3326 skills/\n\
3327 my-skill/SKILL.md\n\
3328 ```\n\n\
3329 Run `/plugin validate`, `/plugin show <name>`, then `/plugin enable <name>`.\n\
3330 Enablement opens a content- and capability-bound trust review;\n\
3331 confirm the displayed `/plugin trust` command to create an owner-only,\n\
3332 content-addressed runtime snapshot, then enable the bundle. Remote MCP\n\
3333 authentication must name environment sources; never store secret values\n\
3334 in `plugin.toml`.\n\n\
3335 Codewhale activates only declarative Skills and MCP servers through their\n\
3336 existing engines. Commands, agents, hooks, LSP, native extensions,\n\
3337 filesystem grants, and lifecycle mutation stay inventoried and inactive;\n\
3338 a mixed bundle can still activate its supported Skills and MCP.\n\
3339 There is no marketplace, install, update, ambient compatibility scan, or\n\
3340 automatic trust surface in this release.\n"
3341}
3342
3343fn plugin_example_manifest_template() -> &'static str {
3344 "schema_version = 1\n\n\
3345 [plugin]\n\
3346 name = \"example\"\n\
3347 version = \"0.1.0\"\n\
3348 description = \"Starter Codewhale plugin bundle\"\n\n\
3349 [skills]\n\
3350 path = \"skills\"\n"
3351}
3352
3353fn plugin_example_skill_template() -> &'static str {
3354 "---\n\
3355 name: hello\n\
3356 description: Explain that the example plugin bundle is active.\n\
3357 ---\n\n\
3358 Tell the user this instruction came from the namespaced\n\
3359 `example:hello` plugin skill. Do not perform side effects.\n"
3360}
3361
3362fn init_plugins_dir(
3363 plugins_dir: &Path,
3364 force: bool,
3365) -> Result<(
3366 PathBuf,
3367 PathBuf,
3368 PathBuf,
3369 WriteStatus,
3370 WriteStatus,
3371 WriteStatus,
3372)> {
3373 std::fs::create_dir_all(plugins_dir)
3374 .with_context(|| format!("Failed to create plugins dir {}", plugins_dir.display()))?;
3375
3376 let readme_path = plugins_dir.join("README.md");
3377 let readme_status = write_template_file(&readme_path, plugins_readme_template(), force)?;
3378
3379 let manifest_path = plugins_dir.join("example").join("plugin.toml");
3380 ensure_parent_dir(&manifest_path)?;
3381 let manifest_status =
3382 write_template_file(&manifest_path, plugin_example_manifest_template(), force)?;
3383
3384 let skill_path = plugins_dir
3385 .join("example")
3386 .join("skills")
3387 .join("hello")
3388 .join("SKILL.md");
3389 ensure_parent_dir(&skill_path)?;
3390 let skill_status = write_template_file(&skill_path, plugin_example_skill_template(), force)?;
3391
3392 Ok((
3393 readme_path,
3394 manifest_path,
3395 skill_path,
3396 readme_status,
3397 manifest_status,
3398 skill_status,
3399 ))
3400}
3401
3402fn resolve_cors_origins(config: &Config, flag_origins: &[String]) -> Vec<String> {
3415 let mut out: Vec<String> = Vec::new();
3416 let mut push = |raw: &str| {
3417 let trimmed = raw.trim();
3418 if trimmed.is_empty() {
3419 return;
3420 }
3421 if !out.iter().any(|existing| existing == trimmed) {
3422 out.push(trimmed.to_string());
3423 }
3424 };
3425 for o in flag_origins {
3426 push(o);
3427 }
3428 if let Ok(env_value) =
3429 std::env::var("CODEWHALE_CORS_ORIGINS").or_else(|_| std::env::var("DEEPSEEK_CORS_ORIGINS"))
3430 {
3431 for piece in env_value.split(',') {
3432 push(piece);
3433 }
3434 }
3435 if let Some(rt) = &config.runtime_api
3436 && let Some(list) = &rt.cors_origins
3437 {
3438 for o in list {
3439 push(o);
3440 }
3441 }
3442 out
3443}
3444
3445fn deepseek_home_dir() -> PathBuf {
3446 codewhale_config::codewhale_home().unwrap_or_else(|_| {
3447 crate::config::effective_home_dir()
3448 .map_or_else(|| PathBuf::from(".codewhale"), |h| h.join(".codewhale"))
3449 })
3450}
3451
3452fn default_tools_dir() -> PathBuf {
3454 deepseek_home_dir().join("tools")
3455}
3456
3457fn default_plugins_dir() -> PathBuf {
3459 deepseek_home_dir().join("plugins")
3460}
3461
3462fn default_checkpoints_dir() -> PathBuf {
3464 deepseek_home_dir().join("sessions").join("checkpoints")
3465}
3466
3467#[derive(Debug, Clone, PartialEq, Eq)]
3468struct CleanPlan {
3469 targets: Vec<PathBuf>,
3470}
3471
3472fn collect_clean_targets(checkpoints_dir: &Path) -> CleanPlan {
3473 let mut targets: Vec<PathBuf> = std::fs::read_dir(checkpoints_dir)
3479 .map(|entries| {
3480 entries
3481 .filter_map(|entry| entry.ok().map(|e| e.path()))
3482 .filter(|p| p.is_file() && p.extension().is_some_and(|ext| ext == "json"))
3483 .collect()
3484 })
3485 .unwrap_or_default();
3486 targets.sort();
3487 CleanPlan { targets }
3488}
3489
3490fn execute_clean_plan(plan: &CleanPlan) -> Result<Vec<PathBuf>> {
3491 let mut removed = Vec::with_capacity(plan.targets.len());
3492 for path in &plan.targets {
3493 std::fs::remove_file(path)
3494 .with_context(|| format!("Failed to remove {}", path.display()))?;
3495 removed.push(path.clone());
3496 }
3497 Ok(removed)
3498}
3499
3500fn run_setup(
3501 config: &Config,
3502 workspace: &Path,
3503 args: SetupArgs,
3504 plugins: &crate::plugins::PluginRegistry,
3505) -> Result<()> {
3506 if args.status {
3507 return run_setup_status(config, workspace, plugins);
3508 }
3509 if args.clean {
3510 return run_setup_clean(&default_checkpoints_dir(), args.force);
3511 }
3512
3513 use crate::palette;
3514 use colored::Colorize;
3515
3516 let (aqua_r, aqua_g, aqua_b) = palette::WHALE_INFO_RGB;
3517 let (sky_r, sky_g, sky_b) = palette::WHALE_INFO_RGB;
3518
3519 let any_explicit = args.mcp || args.skills || args.tools || args.plugins;
3520 let run_mcp = args.mcp || args.all || !any_explicit;
3521 let run_skills = args.skills || args.all || !any_explicit;
3522 let run_tools = args.tools || args.all;
3523 let run_plugins = args.plugins || args.all;
3524
3525 println!(
3526 "{}",
3527 "Codewhale Setup".truecolor(aqua_r, aqua_g, aqua_b).bold()
3528 );
3529 println!("{}", "==============".truecolor(sky_r, sky_g, sky_b));
3530 println!("Workspace: {}", crate::utils::display_path(workspace));
3531
3532 if run_mcp {
3533 let mcp_path = config.mcp_config_path();
3534 let status = init_mcp_config(&mcp_path, args.force)?;
3535 match status {
3536 WriteStatus::Created => {
3537 println!(" ✓ Created MCP config at {}", mcp_path.display());
3538 }
3539 WriteStatus::Overwritten => {
3540 println!(" ✓ Overwrote MCP config at {}", mcp_path.display());
3541 }
3542 WriteStatus::SkippedExists => {
3543 println!(" · MCP config already exists at {}", mcp_path.display());
3544 }
3545 }
3546 println!(
3547 " Next: edit the file, then run `codewhale mcp list` or `codewhale mcp tools`."
3548 );
3549 }
3550
3551 if run_skills {
3552 let skills_dir = if args.local {
3553 workspace.join("skills")
3554 } else {
3555 config.skills_dir()
3556 };
3557 let (skill_path, status) = init_skills_dir(&skills_dir, args.force)?;
3558 match status {
3559 WriteStatus::Created => {
3560 println!(" ✓ Created example skill at {}", skill_path.display());
3561 }
3562 WriteStatus::Overwritten => {
3563 println!(" ✓ Overwrote example skill at {}", skill_path.display());
3564 }
3565 WriteStatus::SkippedExists => {
3566 println!(
3567 " · Example skill already exists at {}",
3568 skill_path.display()
3569 );
3570 }
3571 }
3572 if args.local {
3573 println!(
3574 " Local skills dir enabled for this workspace: {}",
3575 crate::utils::display_path(&skills_dir)
3576 );
3577 } else {
3578 println!(
3579 " Skills dir: {}",
3580 crate::utils::display_path(&skills_dir)
3581 );
3582 }
3583 println!(" Next: run the TUI and use `/skills` then `/skill getting-started`.");
3584 }
3585
3586 if run_tools {
3587 let tools_dir = default_tools_dir();
3588 let (dir, readme_status, example_status) = init_tools_dir(&tools_dir, args.force)?;
3589 report_write_status("Tools README", &dir.join("README.md"), readme_status);
3590 report_write_status("Example tool", &dir.join("example.sh"), example_status);
3591 println!(" Tools dir: {}", crate::utils::display_path(&dir));
3592 println!(" Next: drop scripts here; surface them via skills/MCP when ready.");
3593 }
3594
3595 if run_plugins {
3596 let plugins_dir = default_plugins_dir();
3597 let (readme_path, manifest_path, skill_path, readme_status, manifest_status, skill_status) =
3598 init_plugins_dir(&plugins_dir, args.force)?;
3599 report_write_status("Plugins README", &readme_path, readme_status);
3600 report_write_status("Example plugin manifest", &manifest_path, manifest_status);
3601 report_write_status("Example plugin skill", &skill_path, skill_status);
3602 println!(
3603 " Plugins dir: {}",
3604 crate::utils::display_path(&plugins_dir)
3605 );
3606 println!(" Next: run `/plugin validate`, review `example`, then trust and enable it.");
3607 }
3608
3609 let sandbox = crate::sandbox::get_platform_sandbox_with_bwrap_preference(
3610 config.prefer_bwrap.unwrap_or(false),
3611 );
3612 if let Some(kind) = sandbox {
3613 println!(" ✓ Sandbox available: {kind}");
3614 } else {
3615 println!(" · Sandbox not available on this platform (best-effort only).");
3616 }
3617
3618 Ok(())
3619}
3620
3621fn report_write_status(label: &str, path: &Path, status: WriteStatus) {
3622 match status {
3623 WriteStatus::Created => {
3624 println!(" ✓ Created {label} at {}", path.display());
3625 }
3626 WriteStatus::Overwritten => {
3627 println!(" ✓ Overwrote {label} at {}", path.display());
3628 }
3629 WriteStatus::SkippedExists => {
3630 println!(" · {label} already exists at {}", path.display());
3631 }
3632 }
3633}
3634
3635#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3640enum ApiKeySource {
3641 ConfigDeclared,
3642 EnvDeclared,
3643 ExternalAuthDeclared,
3644 SecretStoreUnprobed,
3645 SecretStoreUnavailable,
3646 OAuth,
3647 ExternalConsent,
3648 NoAuth,
3649 LocalRuntime,
3650 Unknown,
3651}
3652
3653#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3656enum CredentialAvailability {
3657 Present,
3658 NotRequired,
3659 Unknown,
3660 NotProbed,
3661 Unavailable,
3662}
3663
3664impl CredentialAvailability {
3665 fn label(self) -> &'static str {
3666 match self {
3667 Self::Present => "present",
3668 Self::NotRequired => "not_required",
3669 Self::Unknown => "unknown",
3670 Self::NotProbed => "not_probed",
3671 Self::Unavailable => "unavailable",
3672 }
3673 }
3674
3675 fn certifies_ready(self) -> bool {
3676 matches!(self, Self::Present | Self::NotRequired)
3677 }
3678}
3679
3680#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3681struct CredentialDiagnostic {
3682 source: ApiKeySource,
3683 availability: CredentialAvailability,
3684}
3685
3686impl CredentialDiagnostic {
3687 const fn new(source: ApiKeySource, availability: CredentialAvailability) -> Self {
3688 Self {
3689 source,
3690 availability,
3691 }
3692 }
3693}
3694
3695fn resolve_credential_diagnostic(config: &Config) -> CredentialDiagnostic {
3696 let provider = config.api_provider();
3697 let base_url = config.deepseek_base_url();
3698 let auth_mode = config.auth_mode_for_provider(provider);
3699 if crate::config::auth_mode_disables_api_key(auth_mode.as_deref()) {
3700 return CredentialDiagnostic::new(
3701 ApiKeySource::NoAuth,
3702 CredentialAvailability::NotRequired,
3703 );
3704 }
3705 if !crate::config::auth_mode_requires_api_key(auth_mode.as_deref())
3706 && (crate::config::provider_route_is_keyless_self_hosted(provider, &base_url)
3707 || crate::config::base_url_uses_local_host(&base_url))
3708 {
3709 return CredentialDiagnostic::new(
3710 ApiKeySource::LocalRuntime,
3711 CredentialAvailability::NotRequired,
3712 );
3713 }
3714 let custom_endpoint = config.provider_uses_custom_endpoint(provider);
3715 if !custom_endpoint && provider == crate::config::ApiProvider::OpenaiCodex {
3716 return config
3717 .external_credential_consent_status(provider)
3718 .filter(|status| status.route_state == "active")
3719 .map_or_else(
3720 || {
3721 CredentialDiagnostic::new(
3722 ApiKeySource::OAuth,
3723 CredentialAvailability::NotProbed,
3724 )
3725 },
3726 |_| {
3727 CredentialDiagnostic::new(
3728 ApiKeySource::ExternalConsent,
3729 CredentialAvailability::NotProbed,
3730 )
3731 },
3732 );
3733 }
3734 if !custom_endpoint
3735 && provider == crate::config::ApiProvider::Xai
3736 && auth_mode
3737 .as_deref()
3738 .is_some_and(crate::xai_oauth::auth_mode_uses_xai_oauth)
3739 {
3740 return config
3741 .external_credential_consent_status(provider)
3742 .filter(|status| status.route_state == "active")
3743 .map_or_else(
3744 || {
3745 CredentialDiagnostic::new(
3746 ApiKeySource::OAuth,
3747 CredentialAvailability::NotProbed,
3748 )
3749 },
3750 |_| {
3751 CredentialDiagnostic::new(
3752 ApiKeySource::ExternalConsent,
3753 CredentialAvailability::NotProbed,
3754 )
3755 },
3756 );
3757 }
3758 let provider_config = config.provider_config();
3759 let provider_config_key_kind = provider_config
3760 .and_then(|entry| entry.api_key.as_deref())
3761 .map(crate::config::classify_config_api_key_value);
3762 let root_key_applies = matches!(
3763 provider,
3764 crate::config::ApiProvider::Deepseek | crate::config::ApiProvider::DeepseekCN
3765 ) || (provider == crate::config::ApiProvider::Custom
3766 && config.uses_legacy_literal_custom_route());
3767 let root_key_kind = root_key_applies
3768 .then_some(config.api_key.as_deref())
3769 .flatten()
3770 .map(crate::config::classify_config_api_key_value);
3771
3772 if matches!(
3773 provider_config_key_kind,
3774 Some(crate::config::ConfigApiKeyValueKind::Literal)
3775 ) || matches!(
3776 root_key_kind,
3777 Some(crate::config::ConfigApiKeyValueKind::Literal)
3778 ) {
3779 CredentialDiagnostic::new(
3780 ApiKeySource::ConfigDeclared,
3781 CredentialAvailability::Present,
3782 )
3783 } else if config
3784 .provider_config()
3785 .and_then(|entry| entry.api_key_env.as_deref())
3786 .is_some_and(|name| !name.trim().is_empty())
3787 {
3788 CredentialDiagnostic::new(ApiKeySource::EnvDeclared, CredentialAvailability::NotProbed)
3789 } else if config
3790 .provider_config()
3791 .and_then(|entry| entry.auth.as_ref())
3792 .is_some()
3793 {
3794 CredentialDiagnostic::new(
3795 ApiKeySource::ExternalAuthDeclared,
3796 CredentialAvailability::NotProbed,
3797 )
3798 } else if matches!(
3799 provider_config_key_kind,
3800 Some(crate::config::ConfigApiKeyValueKind::SecretStoreSentinel)
3801 ) || matches!(
3802 root_key_kind,
3803 Some(crate::config::ConfigApiKeyValueKind::SecretStoreSentinel)
3804 ) {
3805 if config.should_skip_secret_store_for_provider(provider) {
3806 return CredentialDiagnostic::new(
3807 ApiKeySource::SecretStoreUnavailable,
3808 CredentialAvailability::Unavailable,
3809 );
3810 }
3811 CredentialDiagnostic::new(
3814 ApiKeySource::SecretStoreUnprobed,
3815 CredentialAvailability::NotProbed,
3816 )
3817 } else if !config.should_skip_secret_store_for_provider(provider) {
3818 CredentialDiagnostic::new(
3822 ApiKeySource::SecretStoreUnprobed,
3823 CredentialAvailability::NotProbed,
3824 )
3825 } else {
3826 CredentialDiagnostic::new(ApiKeySource::Unknown, CredentialAvailability::Unknown)
3827 }
3828}
3829
3830#[cfg(test)]
3831fn resolve_api_key_source(config: &Config) -> ApiKeySource {
3832 resolve_credential_diagnostic(config).source
3833}
3834
3835fn provider_config_table_key(provider: crate::config::ApiProvider) -> &'static str {
3836 provider
3837 .metadata()
3838 .map(|metadata| metadata.provider_config_key())
3839 .unwrap_or("deepseek_cn")
3840}
3841
3842fn count_dir_entries(dir: &Path) -> usize {
3843 std::fs::read_dir(dir)
3844 .map(|entries| entries.filter_map(std::result::Result::ok).count())
3845 .unwrap_or(0)
3846}
3847
3848fn skills_count_for(dir: &Path) -> usize {
3849 if !dir.exists() {
3850 return 0;
3851 }
3852 crate::skills::SkillRegistry::discover(dir).len()
3853}
3854
3855fn run_setup_status(
3856 config: &Config,
3857 workspace: &Path,
3858 plugins: &crate::plugins::PluginRegistry,
3859) -> Result<()> {
3860 use crate::palette;
3861 use colored::Colorize;
3862
3863 let (aqua_r, aqua_g, aqua_b) = palette::WHALE_INFO_RGB;
3864 let (sky_r, sky_g, sky_b) = palette::WHALE_INFO_RGB;
3865
3866 println!(
3867 "{}",
3868 "Codewhale Status".truecolor(aqua_r, aqua_g, aqua_b).bold()
3869 );
3870 println!("{}", "===============".truecolor(sky_r, sky_g, sky_b));
3871 println!("workspace: {}", workspace.display());
3872
3873 let credential = resolve_credential_diagnostic(config);
3874 match credential.source {
3875 ApiKeySource::ConfigDeclared => println!(
3876 " {} api_key: literal config value structurally present",
3877 "✓".truecolor(aqua_r, aqua_g, aqua_b)
3878 ),
3879 ApiKeySource::EnvDeclared => println!(
3880 " {} api_key: environment source declared (value not inspected)",
3881 "·".dimmed()
3882 ),
3883 ApiKeySource::ExternalAuthDeclared => println!(
3884 " {} api_key: external auth source declared (value not inspected)",
3885 "·".dimmed()
3886 ),
3887 ApiKeySource::SecretStoreUnprobed => println!(
3888 " {} api_key: secret store eligible (store not probed)",
3889 "·".dimmed()
3890 ),
3891 ApiKeySource::SecretStoreUnavailable => println!(
3892 " {} api_key: secret-store sentinel declared, but this route cannot use that store",
3893 "!".truecolor(sky_r, sky_g, sky_b)
3894 ),
3895 ApiKeySource::OAuth => println!(
3896 " {} oauth: Codewhale-owned route selected (token availability not probed)",
3897 "·".dimmed()
3898 ),
3899 ApiKeySource::ExternalConsent => println!(
3900 " {} oauth: external read-only consent configured (credential file not probed)",
3901 "·".dimmed()
3902 ),
3903 ApiKeySource::NoAuth => println!(
3904 " {} api_key: disabled for this route",
3905 "✓".truecolor(aqua_r, aqua_g, aqua_b)
3906 ),
3907 ApiKeySource::LocalRuntime => println!(
3908 " {} api_key: not required for this local runtime",
3909 "✓".truecolor(aqua_r, aqua_g, aqua_b)
3910 ),
3911 ApiKeySource::Unknown => println!(
3912 " {} api_key: unknown (credential environment and durable stores not inspected)",
3913 "·".dimmed()
3914 ),
3915 }
3916 println!(
3917 " · credential availability: {}",
3918 credential.availability.label()
3919 );
3920 println!(
3921 " · base_url: {}",
3922 crate::doctor::structural_url_authority(&config.deepseek_base_url())
3923 );
3924 let model = config
3925 .default_text_model
3926 .clone()
3927 .unwrap_or_else(|| DEFAULT_TEXT_MODEL.to_string());
3928 println!(" · default_text_model: {model}");
3929 let (default_mode, default_mode_source) = doctor_runtime_default_mode();
3930 println!(" · default_mode: {default_mode} ({default_mode_source})");
3931
3932 let mcp_path = config.mcp_config_path();
3933 let project_mcp_path = crate::mcp::workspace_mcp_config_path(workspace);
3934 let mcp_count =
3935 match crate::mcp::load_config_with_workspace_and_plugins(&mcp_path, workspace, plugins) {
3936 Ok(cfg) => cfg.servers.len(),
3937 Err(_) => 0,
3938 };
3939 let mcp_present = if mcp_path.exists() { "" } else { " (missing)" };
3940 let project_mcp_present = if project_mcp_path.exists() {
3941 ""
3942 } else {
3943 " (missing)"
3944 };
3945 println!(
3946 " · mcp servers: {mcp_count} from {}{mcp_present} + {}{project_mcp_present}",
3947 mcp_path.display(),
3948 project_mcp_path.display()
3949 );
3950
3951 let skills_dir = config.skills_dir();
3952 println!(
3953 " · skills: {} at {}",
3954 skills_count_for(&skills_dir),
3955 crate::utils::display_path(&skills_dir)
3956 );
3957
3958 let tools_dir = default_tools_dir();
3959 let tools_present = if tools_dir.exists() {
3960 ""
3961 } else {
3962 " (missing — run `setup --tools`)"
3963 };
3964 println!(
3965 " · tools: {} entries at {}{tools_present}",
3966 if tools_dir.exists() {
3967 count_dir_entries(&tools_dir)
3968 } else {
3969 0
3970 },
3971 crate::utils::display_path(&tools_dir)
3972 );
3973
3974 let plugins_dir = default_plugins_dir();
3975 let plugins_present = if plugins_dir.exists() {
3976 ""
3977 } else {
3978 " (missing — run `setup --plugins`)"
3979 };
3980 println!(
3981 " · plugins: {} entries at {}{plugins_present}",
3982 if plugins_dir.exists() {
3983 count_dir_entries(&plugins_dir)
3984 } else {
3985 0
3986 },
3987 crate::utils::display_path(&plugins_dir)
3988 );
3989
3990 let sandbox = crate::sandbox::get_platform_sandbox_with_bwrap_preference(
3991 config.prefer_bwrap.unwrap_or(false),
3992 );
3993 match sandbox {
3994 Some(kind) => println!(
3995 " {} sandbox: {kind}",
3996 "✓".truecolor(aqua_r, aqua_g, aqua_b)
3997 ),
3998 None => println!(
3999 " {} sandbox: unavailable (commands run best-effort)",
4000 "!".truecolor(sky_r, sky_g, sky_b)
4001 ),
4002 }
4003
4004 println!(" {} {}", "·".dimmed(), dotenv_status_line(workspace));
4005
4006 println!();
4007 println!("Run `codewhale doctor --json` for a machine-readable check.");
4008 Ok(())
4009}
4010
4011fn dotenv_status_line(workspace: &Path) -> String {
4012 let dotenv = workspace.join(".env");
4013 if dotenv.exists() {
4014 return format!(
4015 ".env present at {} (literal provider credentials only)",
4016 dotenv.display()
4017 );
4018 }
4019
4020 if workspace.join(".env.example").exists() {
4021 return ".env not present in workspace (run `cp .env.example .env` and edit)".to_string();
4022 }
4023
4024 ".env not present in workspace".to_string()
4025}
4026
4027fn run_setup_clean(checkpoints_dir: &Path, force: bool) -> Result<()> {
4028 use colored::Colorize;
4029
4030 if !checkpoints_dir.exists() {
4031 println!(
4032 "Nothing to clean — checkpoints dir does not exist: {}",
4033 checkpoints_dir.display()
4034 );
4035 return Ok(());
4036 }
4037
4038 let plan = collect_clean_targets(checkpoints_dir);
4039 if plan.targets.is_empty() {
4040 println!(
4041 "Nothing to clean — no checkpoint files in {}",
4042 checkpoints_dir.display()
4043 );
4044 return Ok(());
4045 }
4046
4047 if !force {
4048 println!(
4049 "Would remove {} checkpoint file(s) (use --force to apply):",
4050 plan.targets.len()
4051 );
4052 for path in &plan.targets {
4053 println!(" · {}", path.display());
4054 }
4055 return Ok(());
4056 }
4057
4058 let removed = execute_clean_plan(&plan)?;
4059 println!("{}", "Cleaned checkpoints:".bold());
4060 for path in &removed {
4061 println!(" ✓ {}", path.display());
4062 }
4063 Ok(())
4064}
4065
4066fn run_session_diagnostics(args: SessionDiagnosticsArgs) -> Result<()> {
4067 let contents = std::fs::read_to_string(&args.path).with_context(|| {
4068 format!(
4069 "read session diagnostic JSONL from {}",
4070 crate::utils::display_path(&args.path)
4071 )
4072 })?;
4073 let summary = crate::session_diagnostics::analyze_session_failure_jsonl(&contents);
4074 if args.json {
4075 println!("{}", serde_json::to_string_pretty(&summary)?);
4076 } else {
4077 println!(
4078 "{}",
4079 crate::session_diagnostics::format_redacted_failure_summary(&summary)
4080 );
4081 }
4082 Ok(())
4083}
4084
4085fn doctor_should_probe_api(
4088 provider: crate::config::ApiProvider,
4089 base_url: &str,
4090 probes: crate::doctor::DoctorProbeRequest,
4091) -> bool {
4092 let local = crate::config::provider_route_is_keyless_self_hosted(provider, base_url)
4093 || crate::config::base_url_uses_local_host(base_url);
4094 probes.should_probe_api(local)
4095}
4096
4097fn doctor_should_probe_auth(config: &Config) -> bool {
4101 let provider = config.api_provider();
4102 if provider == crate::config::ApiProvider::OpenaiCodex
4103 && !config.provider_uses_custom_endpoint(provider)
4104 {
4105 return false;
4106 }
4107 let auth_mode = config.auth_mode_for_provider(provider);
4108 if provider == crate::config::ApiProvider::Xai
4109 && auth_mode
4110 .as_deref()
4111 .is_some_and(crate::xai_oauth::auth_mode_uses_xai_oauth)
4112 {
4113 return false;
4114 }
4115 !(provider == crate::config::ApiProvider::Moonshot
4116 && auth_mode
4117 .as_deref()
4118 .is_some_and(crate::config::auth_mode_uses_kimi_imported_token))
4119}
4120
4121async fn run_doctor(
4123 config: &Config,
4124 workspace: &Path,
4125 config_path_override: Option<&Path>,
4126 probes: crate::doctor::DoctorProbeRequest,
4127 plugins: &crate::plugins::PluginRegistry,
4128) {
4129 use crate::palette;
4130 use colored::Colorize;
4131
4132 let (accent_r, accent_g, accent_b) = palette::WHALE_HUMAN_RGB;
4133 let (sky_r, sky_g, sky_b) = palette::WHALE_INFO_RGB;
4134 let (aqua_r, aqua_g, aqua_b) = palette::WHALE_INFO_RGB;
4135 let (red_r, red_g, red_b) = palette::WHALE_ERROR_RGB;
4136
4137 println!(
4138 "{}",
4139 "codewhale Doctor"
4140 .truecolor(accent_r, accent_g, accent_b)
4141 .bold()
4142 );
4143 println!("{}", "==================".truecolor(sky_r, sky_g, sky_b));
4144 println!();
4145
4146 println!("{}", "Version Information:".bold());
4148 println!(" codewhale-tui: {}", env!("DEEPSEEK_BUILD_VERSION"));
4149 println!(" rust: {}", rustc_version());
4150 println!();
4151
4152 println!("{}", "Updates:".bold());
4153 crate::doctor::print_update_report(probes).await;
4154 println!();
4155
4156 let doctor_paths = match crate::doctor::DoctorPathReport::resolve(config_path_override) {
4158 Ok(paths) => paths,
4159 Err(error) => {
4160 println!("{}", "Resolved User Paths:".bold());
4161 println!(
4162 " {} unavailable: {error:#}",
4163 "✗".truecolor(red_r, red_g, red_b)
4164 );
4165 return;
4166 }
4167 };
4168 println!("{}", "Configuration:".bold());
4169 let config_path = &doctor_paths.config;
4170
4171 if config_path.exists() {
4172 println!(
4173 " {} config.toml found at {}",
4174 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4175 crate::utils::display_path(config_path)
4176 );
4177 if let Ok(raw) = std::fs::read_to_string(config_path) {
4180 let flagged = crate::doctor::config_credential_shaped_keys(&raw);
4181 if !flagged.is_empty() {
4182 println!(
4183 " {} credential-shaped value(s) in config.toml ({}): move them to the secret backend, then scrub the file — config.toml is plain text",
4184 "!".truecolor(sky_r, sky_g, sky_b),
4185 flagged.join(", ")
4186 );
4187 }
4188 }
4189 } else {
4190 println!(
4191 " {} config.toml not found at {} (using defaults/env)",
4192 "!".truecolor(sky_r, sky_g, sky_b),
4193 crate::utils::display_path(config_path)
4194 );
4195 }
4196 println!(" workspace: {}", crate::utils::display_path(workspace));
4197 println!(" {}", doctor_search_provider_line(config));
4198
4199 println!();
4200 println!("{}", "Resolved User Paths (read-only):".bold());
4201 for (label, path) in doctor_paths.entries() {
4202 println!(" · {label}: {}", crate::utils::display_path(path));
4203 }
4204
4205 let secret_backend = codewhale_secrets::diagnose_secret_backend();
4206 println!();
4207 println!("{}", "Secret Backend (structural only):".bold());
4208 for line in crate::doctor::secret_backend_human_lines(&secret_backend) {
4209 println!(" · {line}");
4210 }
4211
4212 println!();
4214 println!("{}", "State Root:".bold());
4215 let (code_home, legacy_home) = doctor_state_roots();
4216 let active_root = if code_home.exists() {
4217 &code_home
4218 } else if legacy_home.exists() {
4219 &legacy_home
4220 } else {
4221 &code_home
4222 };
4223 println!(" active: {}", crate::utils::display_path(active_root));
4224 if active_root != &code_home {
4225 println!(
4226 " note: legacy {} found; start Codewhale once to trigger safe migration where available.",
4227 crate::utils::display_path(&legacy_home)
4228 );
4229 }
4230 if legacy_home.exists() && code_home.exists() {
4231 println!(
4232 " dual roots: {} (primary) + {} (legacy)",
4233 crate::utils::display_path(&code_home),
4234 crate::utils::display_path(&legacy_home)
4235 );
4236 }
4237 let legacy_state_report = doctor_legacy_state_report(&code_home, &legacy_home);
4238 let session_recovery = doctor_session_recovery_report(
4239 &code_home,
4240 &legacy_home,
4241 codewhale_config::codewhale_home_is_explicit(),
4242 );
4243 print_doctor_legacy_state_report(
4244 &legacy_state_report,
4245 &session_recovery,
4246 (aqua_r, aqua_g, aqua_b),
4247 (sky_r, sky_g, sky_b),
4248 );
4249
4250 let (setup_state, setup_source) = doctor_setup_state(config, workspace);
4251 print_doctor_setup_report(
4252 config,
4253 workspace,
4254 &setup_state,
4255 setup_source,
4256 (aqua_r, aqua_g, aqua_b),
4257 (sky_r, sky_g, sky_b),
4258 );
4259 print_doctor_fleet_roster_layers(config, workspace);
4260
4261 println!();
4263 println!("{}", "API Keys:".bold());
4264
4265 for provider in crate::config::ApiProvider::all().iter().copied() {
4269 let slot = provider.as_str();
4270 let provider_config = config.provider_config_for(provider);
4271 let config_declared = provider_config.is_some_and(|entry| {
4272 entry.api_key.as_deref().is_some_and(|key| {
4273 crate::config::classify_config_api_key_value(key)
4274 == crate::config::ConfigApiKeyValueKind::Literal
4275 })
4276 }) || (matches!(provider, crate::config::ApiProvider::Deepseek)
4277 && config.api_key.as_deref().is_some_and(|key| {
4278 crate::config::classify_config_api_key_value(key)
4279 == crate::config::ConfigApiKeyValueKind::Literal
4280 }));
4281 let env_source_declared = provider_config
4282 .and_then(|entry| entry.api_key_env.as_deref())
4283 .is_some_and(|name| !name.trim().is_empty());
4284 let icon = if config_declared || env_source_declared {
4285 "·".truecolor(aqua_r, aqua_g, aqua_b)
4286 } else {
4287 "·".dimmed()
4288 };
4289 println!(
4290 " {} {slot}: env_source={}, config_source={}",
4291 icon,
4292 if env_source_declared {
4293 "declared (value not inspected)"
4294 } else {
4295 "not inspected"
4296 },
4297 if config_declared {
4298 "declared (value not inspected)"
4299 } else {
4300 "not declared"
4301 }
4302 );
4303 }
4304 println!(" · credential precedence is unchanged; doctor does not inspect credential values");
4305 println!();
4306 println!(
4307 "{}",
4308 "External credential consent (configuration only):".bold()
4309 );
4310 for line in doctor_external_credential_consent_lines(config) {
4311 println!(" {line}");
4312 }
4313
4314 println!();
4315 println!(
4316 "{}",
4317 "DeepSeek Harness integration (read-only detection):".bold()
4318 );
4319 for line in doctor_dsh_integration_lines(config, workspace) {
4320 println!(" {line}");
4321 }
4322
4323 let credential = resolve_credential_diagnostic(config);
4324 let source_label = match credential.source {
4325 ApiKeySource::ConfigDeclared => "literal config value structurally present",
4326 ApiKeySource::EnvDeclared => "environment source declared; value not inspected",
4327 ApiKeySource::ExternalAuthDeclared => {
4328 "external auth source declared; credential not resolved"
4329 }
4330 ApiKeySource::SecretStoreUnprobed => "secret store eligible; store not probed",
4331 ApiKeySource::SecretStoreUnavailable => {
4332 "secret-store sentinel declared, but this route cannot use that store"
4333 }
4334 ApiKeySource::OAuth => "OAuth route configured; token availability not probed",
4335 ApiKeySource::ExternalConsent => "external consent configured; token file not read",
4336 ApiKeySource::NoAuth => "no-auth route",
4337 ApiKeySource::LocalRuntime => "local runtime; credentials not required",
4338 ApiKeySource::Unknown => "unknown; credential environment and stores not inspected",
4339 };
4340 println!(
4341 " {} active provider credential source: {source_label}",
4342 "·".dimmed()
4343 );
4344 println!(
4345 " · active provider credential availability: {}",
4346 credential.availability.label()
4347 );
4348
4349 println!();
4351 println!("{}", "API Connectivity:".bold());
4352 let api_target = doctor_api_target(config);
4353 println!(
4357 " · 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)"
4358 );
4359 println!(" · provider: {}", api_target.provider);
4360 println!(
4361 " · base_url: {}",
4362 crate::doctor::structural_url_authority(&api_target.base_url)
4363 );
4364 match api_target.resolution {
4365 DoctorModelResolution::Resolved => {
4366 println!(" · model: {} (resolved)", api_target.model);
4367 }
4368 DoctorModelResolution::ConfiguredOnly => {
4369 println!(
4370 " · model: {} (configured; route resolution unavailable)",
4371 api_target.model
4372 );
4373 }
4374 }
4375 let tls_status = doctor_tls_status(config);
4376 if !tls_status.certificate_verification {
4377 println!(" ! {}", tls_status.message);
4378 println!(" Prefer SSL_CERT_FILE with a trusted custom CA bundle when possible.");
4379 }
4380 let strict_tool_mode = doctor_strict_tool_mode_status(config);
4381 let strict_icon = match strict_tool_mode.status {
4382 "ready" => "✓".truecolor(aqua_r, aqua_g, aqua_b),
4383 "fallback_non_beta" | "custom_endpoint" => "!".truecolor(sky_r, sky_g, sky_b),
4384 _ => "·".dimmed(),
4385 };
4386 println!(
4387 " {} strict_tool_mode: {}",
4388 strict_icon, strict_tool_mode.message
4389 );
4390 if let Some(recommended) = strict_tool_mode.recommended_base_url.as_deref() {
4391 println!(
4392 " Use the {} endpoint for DeepSeek strict schemas.",
4393 crate::doctor::structural_url_authority(recommended)
4394 );
4395 }
4396 let capability = crate::config::provider_capability(config.api_provider(), &api_target.model);
4397 if let Some(alias) = capability.alias_deprecation.as_ref() {
4398 println!(
4399 " ! model alias {} retires {}; switch to {}",
4400 alias.alias, alias.retirement_date, alias.replacement
4401 );
4402 }
4403 let live_api_requested =
4404 doctor_should_probe_api(config.api_provider(), &api_target.base_url, probes);
4405 let endpoint_is_local = crate::config::provider_route_is_keyless_self_hosted(
4406 config.api_provider(),
4407 &api_target.base_url,
4408 ) || crate::config::base_url_uses_local_host(&api_target.base_url);
4409 if doctor_should_probe_auth(config) && live_api_requested {
4410 print!(" {} Testing connection...", "·".dimmed());
4411 use std::io::Write;
4412 std::io::stdout().flush().ok();
4413
4414 let connectivity_result = match config.with_read_only_api_key_for_diagnostic() {
4419 Ok(diagnostic_config) => test_api_connectivity(&diagnostic_config).await,
4420 Err(error) => Err(error),
4421 };
4422 match connectivity_result {
4423 Ok(()) => {
4424 println!(
4425 "\r {} API connection successful",
4426 "✓".truecolor(aqua_r, aqua_g, aqua_b)
4427 );
4428 }
4429 Err(e) => {
4430 let error_msg = e.to_string();
4431 println!(
4432 "\r {} API connection failed",
4433 "✗".truecolor(red_r, red_g, red_b)
4434 );
4435 if error_msg.contains("401") || error_msg.contains("Unauthorized") {
4436 println!(
4437 " Invalid API key. Check `codewhale auth status`, DEEPSEEK_API_KEY, or config.toml"
4438 );
4439 } else if error_msg.contains("403") || error_msg.contains("Forbidden") {
4440 println!(
4441 " API key lacks permissions. Verify key is active at platform.deepseek.com"
4442 );
4443 } else if error_msg.contains("timeout") || error_msg.contains("Timeout") {
4444 for line in doctor_timeout_recovery_lines(config) {
4445 println!(" {line}");
4446 }
4447 } else if error_msg.contains("dns") || error_msg.contains("resolve") {
4448 println!(" DNS resolution failed. Check your network connection");
4449 } else if error_msg.contains("connect") {
4450 println!(" Connection failed. Check firewall settings or try again");
4451 } else if crate::doctor::is_keyless_ds4_route(config) {
4452 println!(" {error_msg}");
4453 } else {
4454 println!(
4455 " Error details omitted because provider failures can contain credential material."
4456 );
4457 }
4458 }
4459 }
4460 } else if !doctor_should_probe_auth(config) {
4461 println!(
4462 " {} Live OAuth connectivity not checked by non-mutating doctor",
4463 "·".dimmed()
4464 );
4465 println!(
4466 " Doctor never refreshes or rewrites credentials; exercise the route with a normal request."
4467 );
4468 } else {
4469 if endpoint_is_local {
4470 println!(
4471 " {} Live connectivity not checked for this local endpoint",
4472 "·".dimmed()
4473 );
4474 println!(
4475 " Run `codewhale doctor --probe-local` to opt in; the request may start a local service."
4476 );
4477 } else {
4478 println!(
4479 " {} Live hosted connectivity not checked (offline default)",
4480 "·".dimmed()
4481 );
4482 println!(" Run `codewhale doctor --probe-api` to opt in.");
4483 }
4484 }
4485
4486 println!();
4488 println!("{}", "MCP Servers (configuration only):".bold());
4489 println!(" · Static check only; no server process was started.");
4490 let features = config.features();
4491 if features.enabled(Feature::Mcp) {
4492 println!(
4493 " {} MCP feature flag enabled",
4494 "✓".truecolor(aqua_r, aqua_g, aqua_b)
4495 );
4496 } else {
4497 println!(
4498 " {} MCP feature flag disabled",
4499 "!".truecolor(sky_r, sky_g, sky_b)
4500 );
4501 }
4502
4503 let mcp_config_path = config.mcp_config_path();
4504 let project_mcp_config_path = crate::mcp::workspace_mcp_config_path(workspace);
4505 if mcp_config_path.exists() {
4506 println!(
4507 " {} MCP config found at {}",
4508 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4509 crate::utils::display_path(&mcp_config_path)
4510 );
4511 } else {
4512 println!(
4513 " {} MCP config not found at {}",
4514 "·".dimmed(),
4515 crate::utils::display_path(&mcp_config_path)
4516 );
4517 }
4518 if project_mcp_config_path.exists() {
4519 println!(
4520 " {} Project MCP config found at {}",
4521 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4522 crate::utils::display_path(&project_mcp_config_path)
4523 );
4524 } else {
4525 println!(
4526 " {} Project MCP config not found at {}",
4527 "·".dimmed(),
4528 crate::utils::display_path(&project_mcp_config_path)
4529 );
4530 }
4531
4532 match crate::mcp::load_config_with_workspace_and_plugins(&mcp_config_path, workspace, plugins) {
4533 Ok(cfg) if cfg.servers.is_empty() => {
4534 println!(" {} 0 merged server(s) configured", "·".dimmed());
4535 if !mcp_config_path.exists() && !project_mcp_config_path.exists() {
4536 println!(" Run `codewhale mcp init` or add `.codewhale/mcp.json`.");
4537 }
4538 }
4539 Ok(cfg) => {
4540 println!(
4541 " {} {} merged server(s) configured",
4542 "·".dimmed(),
4543 cfg.servers.len()
4544 );
4545 for (name, server) in &cfg.servers {
4546 let status = doctor_check_mcp_server(server);
4547 let icon = match &status {
4548 McpServerDoctorStatus::Ok(detail) => {
4549 format!(
4550 " {} {name}: configuration valid; {}",
4551 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4552 detail
4553 )
4554 }
4555 McpServerDoctorStatus::Warning(detail) => {
4556 format!(
4557 " {} {name}: configuration warning; {}",
4558 "!".truecolor(sky_r, sky_g, sky_b),
4559 detail
4560 )
4561 }
4562 McpServerDoctorStatus::Error(detail) => {
4563 format!(
4564 " {} {name}: configuration invalid; {}",
4565 "✗".truecolor(red_r, red_g, red_b),
4566 detail
4567 )
4568 }
4569 };
4570 println!("{icon}");
4571 if !server.is_enabled() {
4572 println!(" disabled; live health not checked");
4573 } else {
4574 println!(
4575 " process/protocol/backend: not checked; `codewhale mcp validate` explicitly starts and initializes configured servers"
4576 );
4577 }
4578 }
4579 if probes.should_probe_mcp() {
4580 println!();
4581 println!(
4582 " {} Live MCP probe enabled: starting enabled servers; backend tool health remains untested.",
4583 "!".truecolor(sky_r, sky_g, sky_b)
4584 );
4585 match crate::mcp::McpPool::from_config_path_with_workspace_and_plugins(
4586 &mcp_config_path,
4587 workspace,
4588 std::sync::Arc::new(plugins.clone()),
4589 ) {
4590 Ok(mut pool) => {
4591 let errors = pool.connect_all().await;
4592 let failed = errors
4593 .iter()
4594 .map(|(name, _)| name.as_str())
4595 .collect::<std::collections::BTreeSet<_>>();
4596 for (name, server) in &cfg.servers {
4597 if !server.is_enabled() {
4598 continue;
4599 }
4600 if failed.contains(name.as_str()) {
4601 println!(
4602 " {} {name}: process/protocol unreachable; error details omitted",
4603 "✗".truecolor(red_r, red_g, red_b)
4604 );
4605 } else {
4606 println!(
4607 " {} {name}: process reachable and protocol initialized; backend tool health not checked",
4608 "✓".truecolor(aqua_r, aqua_g, aqua_b)
4609 );
4610 }
4611 }
4612 }
4613 Err(_) => println!(
4614 " {} live MCP probe could not load merged configuration; details omitted",
4615 "✗".truecolor(red_r, red_g, red_b)
4616 ),
4617 }
4618 } else {
4619 println!(
4620 " Use codewhale doctor --probe-mcp to opt in to live process/protocol checks; it may start configured servers."
4621 );
4622 }
4623 }
4624 Err(_) => {
4625 println!(
4626 " {} MCP configuration could not be loaded; details omitted",
4627 "✗".truecolor(red_r, red_g, red_b)
4628 );
4629 }
4630 }
4631
4632 println!();
4634 println!("{}", "Skills:".bold());
4635 let global_skills_dir = config.skills_dir();
4636 let agents_skills_dir = workspace.join(".agents").join("skills");
4637 let local_skills_dir = workspace.join("skills");
4638 let agents_global_skills_dir = crate::skills::agents_global_skills_dir();
4639 let opencode_skills_dir = workspace.join(".opencode").join("skills");
4645 let claude_skills_dir = workspace.join(".claude").join("skills");
4646 let selected_skills_dir = if agents_skills_dir.exists() {
4647 agents_skills_dir.clone()
4648 } else if local_skills_dir.exists() {
4649 local_skills_dir.clone()
4650 } else if config.skills_dir.is_none()
4651 && let Some(global_agents) = agents_global_skills_dir.as_ref()
4652 && global_agents.exists()
4653 {
4654 global_agents.clone()
4655 } else {
4656 global_skills_dir.clone()
4657 };
4658
4659 let describe_dir = |dir: &Path| -> usize {
4660 std::fs::read_dir(dir)
4661 .map(|entries| entries.filter_map(std::result::Result::ok).count())
4662 .unwrap_or(0)
4663 };
4664
4665 if local_skills_dir.exists() {
4666 println!(
4667 " {} local skills dir found at {} ({} items)",
4668 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4669 crate::utils::display_path(&local_skills_dir),
4670 describe_dir(&local_skills_dir)
4671 );
4672 } else {
4673 println!(
4674 " {} local skills dir not found at {}",
4675 "·".dimmed(),
4676 crate::utils::display_path(&local_skills_dir)
4677 );
4678 }
4679
4680 if agents_skills_dir.exists() {
4681 println!(
4682 " {} .agents skills dir found at {} ({} items)",
4683 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4684 crate::utils::display_path(&agents_skills_dir),
4685 describe_dir(&agents_skills_dir)
4686 );
4687 } else {
4688 println!(
4689 " {} .agents skills dir not found at {}",
4690 "·".dimmed(),
4691 crate::utils::display_path(&agents_skills_dir)
4692 );
4693 }
4694
4695 if let Some(agents_global_skills_dir) = agents_global_skills_dir.as_ref() {
4696 if agents_global_skills_dir.exists() {
4697 println!(
4698 " {} global .agents skills dir found at {} ({} items)",
4699 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4700 crate::utils::display_path(agents_global_skills_dir),
4701 describe_dir(agents_global_skills_dir)
4702 );
4703 } else {
4704 println!(
4705 " {} global .agents skills dir not found at {}",
4706 "·".dimmed(),
4707 crate::utils::display_path(agents_global_skills_dir)
4708 );
4709 }
4710 }
4711
4712 if global_skills_dir.exists() {
4713 println!(
4714 " {} global skills dir found at {} ({} items)",
4715 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4716 crate::utils::display_path(&global_skills_dir),
4717 describe_dir(&global_skills_dir)
4718 );
4719 } else {
4720 println!(
4721 " {} global skills dir not found at {}",
4722 "·".dimmed(),
4723 crate::utils::display_path(&global_skills_dir)
4724 );
4725 }
4726
4727 if opencode_skills_dir.exists() {
4731 println!(
4732 " {} .opencode skills dir found at {} ({} items)",
4733 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4734 crate::utils::display_path(&opencode_skills_dir),
4735 describe_dir(&opencode_skills_dir)
4736 );
4737 }
4738 if claude_skills_dir.exists() {
4739 println!(
4740 " {} .claude skills dir found at {} ({} items)",
4741 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4742 crate::utils::display_path(&claude_skills_dir),
4743 describe_dir(&claude_skills_dir)
4744 );
4745 }
4746
4747 println!(
4748 " {} selected skills dir: {}",
4749 "·".dimmed(),
4750 crate::utils::display_path(&selected_skills_dir)
4751 );
4752 if !agents_skills_dir.exists()
4753 && !local_skills_dir.exists()
4754 && !agents_global_skills_dir
4755 .as_ref()
4756 .is_some_and(|dir| dir.exists())
4757 && !global_skills_dir.exists()
4758 {
4759 println!(" Run `codewhale setup --skills` (or add --local for ./skills).");
4760 }
4761
4762 println!();
4764 println!("{}", "Tools:".bold());
4765 let tools_dir = default_tools_dir();
4766 if tools_dir.exists() {
4767 let count = count_dir_entries(&tools_dir);
4768 println!(
4769 " {} tools dir found at {} ({} items)",
4770 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4771 crate::utils::display_path(&tools_dir),
4772 count
4773 );
4774 } else {
4775 println!(
4776 " {} tools dir not found at {}",
4777 "·".dimmed(),
4778 crate::utils::display_path(&tools_dir)
4779 );
4780 println!(" Run `codewhale setup --tools` to scaffold a starter dir.");
4781 }
4782
4783 println!();
4785 println!("{}", "Plugins:".bold());
4786 let plugins_dir = default_plugins_dir();
4787 if plugins_dir.exists() {
4788 let count = count_dir_entries(&plugins_dir);
4789 println!(
4790 " {} plugins dir found at {} ({} items)",
4791 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4792 crate::utils::display_path(&plugins_dir),
4793 count
4794 );
4795 } else {
4796 println!(
4797 " {} plugins dir not found at {}",
4798 "·".dimmed(),
4799 crate::utils::display_path(&plugins_dir)
4800 );
4801 println!(" Run `codewhale setup --plugins` to scaffold a starter dir.");
4802 }
4803
4804 println!();
4806 println!("{}", "Storage:".bold());
4807 if let Some(spillover_root) = crate::tools::truncate::spillover_root() {
4808 let (present, count) = if spillover_root.is_dir() {
4809 (true, count_dir_entries(&spillover_root))
4810 } else {
4811 (false, 0)
4812 };
4813 if present {
4814 println!(
4815 " {} tool-output spillover at {} ({} file{})",
4816 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4817 crate::utils::display_path(&spillover_root),
4818 count,
4819 if count == 1 { "" } else { "s" }
4820 );
4821 } else {
4822 println!(
4823 " {} tool-output spillover dir not yet created at {}",
4824 "·".dimmed(),
4825 crate::utils::display_path(&spillover_root)
4826 );
4827 }
4828 }
4829 let stash = crate::composer_stash::diagnostic_stash_report();
4830 if let Some(stash_path) = stash.path.as_ref() {
4831 if let Some(error) = stash.error.as_deref() {
4832 println!(
4833 " {} composer stash was not inspected at {}: {error}",
4834 "!".truecolor(sky_r, sky_g, sky_b),
4835 crate::utils::display_path(stash_path),
4836 );
4837 } else if stash.present {
4838 println!(
4839 " {} composer stash at {} ({} parked draft{})",
4840 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4841 crate::utils::display_path(stash_path),
4842 stash.count,
4843 if stash.count == 1 { "" } else { "s" }
4844 );
4845 } else {
4846 println!(
4847 " {} composer stash empty (Ctrl+G or Ctrl+S in the composer to park a draft)",
4848 "·".dimmed()
4849 );
4850 }
4851 } else if let Some(error) = stash.error.as_deref() {
4852 println!(
4853 " {} composer stash was not inspected: {error}",
4854 "!".truecolor(sky_r, sky_g, sky_b),
4855 );
4856 }
4857
4858 println!();
4863 println!("{}", "Tool Dependencies:".bold());
4864
4865 match crate::dependencies::resolve_python_interpreter() {
4866 Some(name) => println!(
4867 " {} Python: {} → code_execution tool registered",
4868 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4869 name
4870 ),
4871 None => {
4872 println!(
4873 " {} Python: not found (tried {:?})",
4874 "✗".truecolor(red_r, red_g, red_b),
4875 crate::dependencies::PYTHON_CANDIDATES,
4876 );
4877 println!(" code_execution tool is NOT advertised to the model on this install.");
4878 println!(" Install Python 3 and ensure one of those names is on PATH:");
4879 match std::env::consts::OS {
4880 "macos" => {
4881 println!(" brew install python@3.12 (or download from python.org)")
4882 }
4883 "linux" => println!(
4884 " sudo apt install python3 (Debian/Ubuntu) — or your distro's equivalent"
4885 ),
4886 "windows" => {
4887 println!(" winget install Python.Python.3 (or download from python.org)")
4888 }
4889 other => println!(" install Python 3 for {other} from python.org"),
4890 }
4891 }
4892 }
4893
4894 match crate::dependencies::resolve_node() {
4895 Some(_) => println!(
4896 " {} Node.js: present → js_execution tool registered",
4897 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4898 ),
4899 None => {
4900 println!(
4901 " {} Node.js: not found (tried `node`)",
4902 "✗".truecolor(red_r, red_g, red_b),
4903 );
4904 println!(" js_execution tool is NOT advertised to the model on this install.");
4905 println!(" Install Node 18+ and ensure `node` is on PATH:");
4906 match std::env::consts::OS {
4907 "macos" => println!(" brew install node (or download from nodejs.org)"),
4908 "linux" => println!(
4909 " sudo apt install nodejs (Debian/Ubuntu) — or your distro's equivalent"
4910 ),
4911 "windows" => {
4912 println!(" winget install OpenJS.NodeJS (or download from nodejs.org)")
4913 }
4914 other => println!(" install Node.js for {other} from nodejs.org"),
4915 }
4916 }
4917 }
4918
4919 match crate::dependencies::resolve_pandoc() {
4920 Some(_) => println!(
4921 " {} pandoc: present → pandoc_convert tool registered",
4922 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4923 ),
4924 None => {
4925 println!(" {} pandoc: not found (optional)", "·".dimmed(),);
4926 println!(
4927 " pandoc_convert tool is NOT advertised to the model. Install pandoc to enable:"
4928 );
4929 match std::env::consts::OS {
4930 "macos" => println!(" brew install pandoc"),
4931 "linux" => println!(
4932 " sudo apt install pandoc (Debian/Ubuntu) — or your distro's equivalent"
4933 ),
4934 "windows" => {
4935 println!(" winget install JohnMacFarlane.Pandoc")
4936 }
4937 other => println!(" install pandoc for {other} from pandoc.org"),
4938 }
4939 }
4940 }
4941
4942 match crate::dependencies::resolve_tesseract() {
4943 Some(_) => {
4944 if cfg!(target_os = "macos") {
4945 println!(
4946 " {} OCR: macOS Vision + tesseract available → image_ocr/read_file screenshot OCR enabled",
4947 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4948 );
4949 } else {
4950 println!(
4951 " {} tesseract: present → image_ocr/read_file screenshot OCR enabled",
4952 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4953 );
4954 }
4955 }
4956 None => {
4957 if cfg!(target_os = "macos") {
4958 println!(
4959 " {} OCR: macOS Vision available → image_ocr/read_file screenshot OCR enabled",
4960 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4961 );
4962 println!(
4963 " tesseract not found (optional; install only for alternate OCR packs)."
4964 );
4965 } else {
4966 println!(" {} tesseract: not found (optional)", "·".dimmed(),);
4967 println!(
4968 " image_ocr tool is NOT advertised to the model. Install tesseract to enable:"
4969 );
4970 match std::env::consts::OS {
4971 "macos" => println!(" brew install tesseract"),
4972 "linux" => println!(
4973 " sudo apt install tesseract-ocr (Debian/Ubuntu) — or your distro's equivalent"
4974 ),
4975 "windows" => println!(" winget install UB-Mannheim.TesseractOCR"),
4976 other => {
4977 println!(" install tesseract for {other} from tesseract-ocr.github.io")
4978 }
4979 }
4980 }
4981 }
4982 }
4983
4984 match crate::dependencies::resolve_pdftotext() {
4988 Some(_) => println!(
4989 " {} pdftotext: available → PDF text extraction enabled",
4990 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4991 ),
4992 None => {
4993 println!(
4994 " {} pdftotext: not found (optional; PDF text reads fail as `binary_unavailable`)",
4995 "·".dimmed(),
4996 );
4997 match std::env::consts::OS {
4998 "macos" => println!(" Install via: brew install poppler"),
4999 "linux" => {
5000 println!(" Install via: sudo apt install poppler-utils (Debian/Ubuntu)")
5001 }
5002 "windows" => println!(
5003 " Install Poppler for Windows from https://blog.alivate.com.au/poppler-windows/"
5004 ),
5005 _ => {}
5006 }
5007 }
5008 }
5009
5010 println!();
5014 println!("{}", "Terminal Quirks:".bold());
5015 let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default();
5016 let term_program_lc = term_program.to_ascii_lowercase();
5017 let mut any_quirk = false;
5018 if matches!(term_program.as_str(), "vscode" | "ghostty") {
5019 println!(
5020 " {} TERM_PROGRAM={} → low_motion + fancy_animations=false (auto)",
5021 "•".truecolor(sky_r, sky_g, sky_b),
5022 term_program
5023 );
5024 any_quirk = true;
5025 }
5026 if term_program == "Termius"
5027 || std::env::var_os("SSH_CLIENT").is_some_and(|v| !v.is_empty())
5028 || std::env::var_os("SSH_TTY").is_some_and(|v| !v.is_empty())
5029 {
5030 println!(
5031 " {} SSH/Termius session → low_motion + fancy_animations=false (auto, #1433)",
5032 "•".truecolor(sky_r, sky_g, sky_b)
5033 );
5034 any_quirk = true;
5035 }
5036 if term_program_lc.contains("ptyxis")
5037 || std::env::var_os("PTYXIS_VERSION").is_some_and(|v| !v.is_empty())
5038 {
5039 println!(
5040 " {} Ptyxis detected → synchronized_output=off (auto, v0.8.31)",
5041 "•".truecolor(sky_r, sky_g, sky_b)
5042 );
5043 any_quirk = true;
5044 }
5045 if crate::settings::detected_legacy_windows_console_host() {
5046 println!(
5047 " {} legacy Windows console host → low_motion + fancy_animations=false + bracketed_paste=false + synchronized_output=off (auto)",
5048 "•".truecolor(sky_r, sky_g, sky_b)
5049 );
5050 any_quirk = true;
5051 }
5052 if !any_quirk {
5053 println!(
5054 " {} no env-driven terminal-quirk overrides active",
5055 "·".dimmed()
5056 );
5057 }
5058
5059 println!();
5061 println!("{}", "Platform:".bold());
5062 println!(" OS: {}", std::env::consts::OS);
5063 println!(" Arch: {}", std::env::consts::ARCH);
5064
5065 let sandbox = crate::sandbox::get_platform_sandbox_with_bwrap_preference(
5066 config.prefer_bwrap.unwrap_or(false),
5067 );
5068 if let Some(kind) = sandbox {
5069 println!(
5070 " {} sandbox available: {}",
5071 "✓".truecolor(aqua_r, aqua_g, aqua_b),
5072 kind
5073 );
5074 } else {
5075 println!(
5076 " {} sandbox not available (commands run best-effort)",
5077 "!".truecolor(sky_r, sky_g, sky_b)
5078 );
5079 }
5080
5081 println!();
5082 println!(
5083 "{}",
5084 "All checks complete!"
5085 .truecolor(aqua_r, aqua_g, aqua_b)
5086 .bold()
5087 );
5088}
5089
5090const DOCTOR_LEGACY_STATE_ITEMS: &[&str] = &[
5091 "sessions",
5092 "tasks",
5093 "skills",
5094 "slop_ledger",
5095 "trophies",
5096 "catalog",
5097 "review-receipts",
5098 "config.toml",
5099 "settings.toml",
5100 "mcp.json",
5101];
5102const DOCTOR_SESSION_RECOVERY_HUMAN_SAMPLE_LIMIT: usize = 20;
5103const DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT: usize = 100;
5104
5105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5106enum DoctorLegacyStateStatus {
5107 PrimaryOnly,
5108 LegacyOnly,
5109 Both,
5110 Absent,
5111}
5112
5113impl DoctorLegacyStateStatus {
5114 fn as_str(self) -> &'static str {
5115 match self {
5116 Self::PrimaryOnly => "primary_only",
5117 Self::LegacyOnly => "legacy_only",
5118 Self::Both => "both",
5119 Self::Absent => "absent",
5120 }
5121 }
5122}
5123
5124#[derive(Debug, Clone)]
5125struct DoctorLegacyStateEntry {
5126 name: &'static str,
5127 primary_path: PathBuf,
5128 legacy_path: PathBuf,
5129 primary_present: bool,
5130 legacy_present: bool,
5131 status: DoctorLegacyStateStatus,
5132}
5133
5134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5135enum DoctorSessionRecoveryStatus {
5136 Isolated,
5137 NoLegacySessions,
5138 MigrationPending,
5139 MigrationIncomplete,
5140 MigrationComplete,
5141 ScanFailed,
5142}
5143
5144impl DoctorSessionRecoveryStatus {
5145 fn as_str(self) -> &'static str {
5146 match self {
5147 Self::Isolated => "isolated",
5148 Self::NoLegacySessions => "no_legacy_sessions",
5149 Self::MigrationPending => "migration_pending",
5150 Self::MigrationIncomplete => "migration_incomplete",
5151 Self::MigrationComplete => "migration_complete",
5152 Self::ScanFailed => "scan_failed",
5153 }
5154 }
5155}
5156
5157#[derive(Debug, Clone)]
5158struct DoctorRecoverableSessionEntry {
5159 name: PathBuf,
5160 source_path: PathBuf,
5161 destination_path: PathBuf,
5162}
5163
5164#[derive(Debug, Clone)]
5165struct DoctorSessionRecoveryReport {
5166 status: DoctorSessionRecoveryStatus,
5167 primary_sessions_path: PathBuf,
5168 legacy_sessions_path: PathBuf,
5169 codewhale_home_is_explicit: bool,
5170 legacy_session_file_count: usize,
5171 already_present_file_count: usize,
5172 recoverable_file_count: usize,
5173 recoverable: Vec<DoctorRecoverableSessionEntry>,
5175 error: Option<String>,
5176}
5177
5178impl DoctorSessionRecoveryReport {
5179 fn needs_attention(&self) -> bool {
5180 matches!(
5181 self.status,
5182 DoctorSessionRecoveryStatus::MigrationPending
5183 | DoctorSessionRecoveryStatus::MigrationIncomplete
5184 | DoctorSessionRecoveryStatus::ScanFailed
5185 )
5186 }
5187}
5188
5189fn doctor_legacy_state_status(
5190 primary_present: bool,
5191 legacy_present: bool,
5192) -> DoctorLegacyStateStatus {
5193 match (primary_present, legacy_present) {
5194 (true, false) => DoctorLegacyStateStatus::PrimaryOnly,
5195 (false, true) => DoctorLegacyStateStatus::LegacyOnly,
5196 (true, true) => DoctorLegacyStateStatus::Both,
5197 (false, false) => DoctorLegacyStateStatus::Absent,
5198 }
5199}
5200
5201fn doctor_state_roots() -> (PathBuf, PathBuf) {
5202 let code_home =
5203 codewhale_config::codewhale_home().unwrap_or_else(|_| PathBuf::from("~/.codewhale"));
5204 let legacy_home = if codewhale_config::codewhale_home_is_explicit() {
5205 code_home.join(codewhale_config::LEGACY_APP_DIR)
5206 } else {
5207 codewhale_config::legacy_deepseek_home().unwrap_or_else(|_| PathBuf::from("~/.deepseek"))
5208 };
5209 (code_home, legacy_home)
5210}
5211
5212fn doctor_legacy_state_report(
5213 primary_root: &Path,
5214 legacy_root: &Path,
5215) -> Vec<DoctorLegacyStateEntry> {
5216 DOCTOR_LEGACY_STATE_ITEMS
5217 .iter()
5218 .copied()
5219 .map(|name| {
5220 let primary_path = primary_root.join(name);
5221 let legacy_path = legacy_root.join(name);
5222 let primary_present = primary_path.exists();
5223 let legacy_present = legacy_path.exists();
5224 let status = doctor_legacy_state_status(primary_present, legacy_present);
5225 DoctorLegacyStateEntry {
5226 name,
5227 primary_path,
5228 legacy_path,
5229 primary_present,
5230 legacy_present,
5231 status,
5232 }
5233 })
5234 .collect()
5235}
5236
5237fn doctor_session_recovery_report(
5249 primary_root: &Path,
5250 legacy_root: &Path,
5251 codewhale_home_is_explicit: bool,
5252) -> DoctorSessionRecoveryReport {
5253 let primary_sessions_path = primary_root.join("sessions");
5254 let legacy_sessions_path = legacy_root.join("sessions");
5255 let mut report = DoctorSessionRecoveryReport {
5256 status: DoctorSessionRecoveryStatus::NoLegacySessions,
5257 primary_sessions_path,
5258 legacy_sessions_path,
5259 codewhale_home_is_explicit,
5260 legacy_session_file_count: 0,
5261 already_present_file_count: 0,
5262 recoverable_file_count: 0,
5263 recoverable: Vec::new(),
5264 error: None,
5265 };
5266
5267 if codewhale_home_is_explicit {
5268 report.status = DoctorSessionRecoveryStatus::Isolated;
5269 return report;
5270 }
5271
5272 let legacy_root_is_present =
5273 match doctor_session_directory_is_safe(legacy_root, "legacy state root") {
5274 Ok(present) => present,
5275 Err(error) => {
5276 report.status = DoctorSessionRecoveryStatus::ScanFailed;
5277 report.error = Some(error);
5278 return report;
5279 }
5280 };
5281 if !legacy_root_is_present {
5282 return report;
5283 }
5284 if let Err(error) = doctor_session_directory_is_safe(primary_root, "primary state root") {
5285 report.status = DoctorSessionRecoveryStatus::ScanFailed;
5286 report.error = Some(error);
5287 return report;
5288 }
5289
5290 let legacy_sessions_are_present = match doctor_session_directory_is_safe(
5291 &report.legacy_sessions_path,
5292 "legacy sessions root",
5293 ) {
5294 Ok(present) => present,
5295 Err(error) => {
5296 report.status = DoctorSessionRecoveryStatus::ScanFailed;
5297 report.error = Some(error);
5298 return report;
5299 }
5300 };
5301 if !legacy_sessions_are_present {
5302 return report;
5303 }
5304 let primary_sessions_are_present = match doctor_session_directory_is_safe(
5305 &report.primary_sessions_path,
5306 "primary sessions root",
5307 ) {
5308 Ok(present) => present,
5309 Err(error) => {
5310 report.status = DoctorSessionRecoveryStatus::ScanFailed;
5311 report.error = Some(error);
5312 return report;
5313 }
5314 };
5315
5316 let entries = match std::fs::read_dir(&report.legacy_sessions_path) {
5317 Ok(entries) => entries,
5318 Err(err) => {
5319 report.status = DoctorSessionRecoveryStatus::ScanFailed;
5320 report.error = Some(format!(
5321 "could not inspect legacy session filenames at {}: {err}",
5322 crate::utils::display_path(&report.legacy_sessions_path)
5323 ));
5324 return report;
5325 }
5326 };
5327
5328 for entry in entries {
5329 let entry = match entry {
5330 Ok(entry) => entry,
5331 Err(err) => {
5332 report.status = DoctorSessionRecoveryStatus::ScanFailed;
5333 report.error = Some(format!(
5334 "could not inspect an entry under {}: {err}",
5335 crate::utils::display_path(&report.legacy_sessions_path)
5336 ));
5337 return report;
5338 }
5339 };
5340 let file_type = match entry.file_type() {
5341 Ok(file_type) => file_type,
5342 Err(err) => {
5343 report.status = DoctorSessionRecoveryStatus::ScanFailed;
5344 report.error = Some(format!(
5345 "could not inspect legacy session entry metadata under {}: {err}",
5346 crate::utils::display_path(&report.legacy_sessions_path)
5347 ));
5348 return report;
5349 }
5350 };
5351 if !file_type.is_file() || entry.path().extension().is_none_or(|ext| ext != "json") {
5352 continue;
5353 }
5354
5355 report.legacy_session_file_count += 1;
5356 let name = PathBuf::from(entry.file_name());
5357 let destination_path = report.primary_sessions_path.join(&name);
5358 match std::fs::symlink_metadata(&destination_path) {
5359 Ok(metadata) if metadata.file_type().is_file() => {
5360 report.already_present_file_count += 1;
5361 }
5362 Ok(metadata) => {
5363 report.status = DoctorSessionRecoveryStatus::ScanFailed;
5364 let shape = if metadata.file_type().is_symlink() {
5365 "destination session entry is a symlink"
5366 } else {
5367 "destination session entry is not a regular file"
5368 };
5369 report.error = Some(format!(
5370 "could not inspect destination session metadata at {}: {shape}",
5371 crate::utils::display_path(&destination_path)
5372 ));
5373 return report;
5374 }
5375 Err(err) if err.kind() == io::ErrorKind::NotFound => {
5376 report.recoverable_file_count += 1;
5377 record_doctor_recoverable_session(
5378 &mut report.recoverable,
5379 DoctorRecoverableSessionEntry {
5380 source_path: entry.path(),
5381 destination_path,
5382 name,
5383 },
5384 );
5385 }
5386 Err(err) => {
5387 report.status = DoctorSessionRecoveryStatus::ScanFailed;
5388 report.error = Some(format!(
5389 "could not inspect destination metadata at {}: {err}",
5390 crate::utils::display_path(&destination_path)
5391 ));
5392 return report;
5393 }
5394 }
5395 }
5396
5397 report.status = if report.legacy_session_file_count == 0 {
5398 DoctorSessionRecoveryStatus::NoLegacySessions
5399 } else if report.recoverable_file_count == 0 {
5400 DoctorSessionRecoveryStatus::MigrationComplete
5401 } else if primary_sessions_are_present {
5402 DoctorSessionRecoveryStatus::MigrationIncomplete
5403 } else {
5404 DoctorSessionRecoveryStatus::MigrationPending
5405 };
5406 report
5407}
5408
5409fn doctor_session_directory_is_safe(path: &Path, label: &str) -> std::result::Result<bool, String> {
5417 let metadata = match std::fs::symlink_metadata(path) {
5418 Ok(metadata) => metadata,
5419 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false),
5420 Err(error) => {
5421 return Err(format!(
5422 "could not inspect {label} at {}: {error}",
5423 crate::utils::display_path(path)
5424 ));
5425 }
5426 };
5427 if metadata.file_type().is_symlink() {
5428 return Err(format!(
5429 "could not inspect {label} at {}: path is a symlink",
5430 crate::utils::display_path(path)
5431 ));
5432 }
5433 if !metadata.file_type().is_dir() {
5434 return Err(format!(
5435 "could not inspect {label} at {}: path is not a directory",
5436 crate::utils::display_path(path)
5437 ));
5438 }
5439 Ok(true)
5440}
5441
5442fn record_doctor_recoverable_session(
5446 recoverable: &mut Vec<DoctorRecoverableSessionEntry>,
5447 entry: DoctorRecoverableSessionEntry,
5448) {
5449 let insert_at = recoverable
5450 .binary_search_by(|existing| existing.name.cmp(&entry.name))
5451 .unwrap_or_else(|index| index);
5452 if recoverable.len() == DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT
5453 && insert_at == recoverable.len()
5454 {
5455 return;
5456 }
5457 recoverable.insert(insert_at, entry);
5458 if recoverable.len() > DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT {
5459 recoverable.pop();
5460 }
5461}
5462
5463fn legacy_state_needs_attention(entry: &DoctorLegacyStateEntry) -> bool {
5464 entry.name != "sessions"
5465 && matches!(
5466 entry.status,
5467 DoctorLegacyStateStatus::LegacyOnly | DoctorLegacyStateStatus::Both
5468 )
5469}
5470
5471fn print_doctor_legacy_state_report(
5472 report: &[DoctorLegacyStateEntry],
5473 session_recovery: &DoctorSessionRecoveryReport,
5474 ok_rgb: (u8, u8, u8),
5475 warn_rgb: (u8, u8, u8),
5476) {
5477 use colored::Colorize;
5478
5479 let attention: Vec<_> = report
5480 .iter()
5481 .filter(|entry| legacy_state_needs_attention(entry))
5482 .collect();
5483 if attention.is_empty()
5484 && !session_recovery.needs_attention()
5485 && session_recovery.status != DoctorSessionRecoveryStatus::Isolated
5486 {
5487 println!(
5488 " {} legacy state: no known .deepseek entries need migration",
5489 "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2)
5490 );
5491 } else if !attention.is_empty() {
5492 println!(
5493 " {} legacy state needs review:",
5494 "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2)
5495 );
5496 for entry in attention {
5497 match entry.status {
5498 DoctorLegacyStateStatus::LegacyOnly => {
5499 println!(
5500 " {} {} exists but {} is missing",
5501 "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2),
5502 crate::utils::display_path(&entry.legacy_path),
5503 crate::utils::display_path(&entry.primary_path),
5504 );
5505 }
5506 DoctorLegacyStateStatus::Both => {
5507 println!(
5508 " {} {} exists alongside primary {}; legacy data may still need review",
5509 "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2),
5510 crate::utils::display_path(&entry.legacy_path),
5511 crate::utils::display_path(&entry.primary_path),
5512 );
5513 }
5514 DoctorLegacyStateStatus::PrimaryOnly | DoctorLegacyStateStatus::Absent => {}
5515 }
5516 }
5517 println!(
5518 " Start Codewhale once to trigger safe migration where available, then rerun `codewhale doctor`."
5519 );
5520 }
5521
5522 print_doctor_session_recovery_report(session_recovery, ok_rgb, warn_rgb);
5523}
5524
5525fn print_doctor_session_recovery_report(
5526 report: &DoctorSessionRecoveryReport,
5527 ok_rgb: (u8, u8, u8),
5528 warn_rgb: (u8, u8, u8),
5529) {
5530 use colored::Colorize;
5531
5532 match report.status {
5533 DoctorSessionRecoveryStatus::Isolated => {
5534 println!(
5535 " {} legacy sessions: ambient ~/.deepseek/sessions was not inspected because CODEWHALE_HOME is set",
5536 "·".dimmed()
5537 );
5538 println!(
5539 " This preserves the explicit home boundary. To inspect the default home, use a separate shell with CODEWHALE_HOME unset and rerun `codewhale doctor`."
5540 );
5541 }
5542 DoctorSessionRecoveryStatus::NoLegacySessions => {
5543 println!(
5544 " {} legacy sessions: no top-level session JSON files found",
5545 "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2)
5546 );
5547 }
5548 DoctorSessionRecoveryStatus::MigrationComplete => {
5549 println!(
5550 " {} legacy sessions: all {} filename(s) have regular-file counterparts under {}; descriptor contents were not compared and legacy originals remain preserved",
5551 "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2),
5552 report.legacy_session_file_count,
5553 crate::utils::display_path(&report.primary_sessions_path),
5554 );
5555 }
5556 DoctorSessionRecoveryStatus::MigrationPending
5557 | DoctorSessionRecoveryStatus::MigrationIncomplete => {
5558 let label = if report.status == DoctorSessionRecoveryStatus::MigrationIncomplete {
5559 "migration is incomplete"
5560 } else {
5561 "migration has not completed"
5562 };
5563 println!(
5564 " {} legacy sessions: {label}; {} recoverable file(s) are absent from {}",
5565 "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2),
5566 report.recoverable_file_count,
5567 crate::utils::display_path(&report.primary_sessions_path),
5568 );
5569 for entry in report
5570 .recoverable
5571 .iter()
5572 .take(DOCTOR_SESSION_RECOVERY_HUMAN_SAMPLE_LIMIT)
5573 {
5574 println!(
5575 " {} {} -> {}",
5576 "·".dimmed(),
5577 crate::utils::display_path(&entry.source_path),
5578 crate::utils::display_path(&entry.destination_path),
5579 );
5580 }
5581 if report.recoverable_file_count > DOCTOR_SESSION_RECOVERY_HUMAN_SAMPLE_LIMIT {
5582 println!(
5583 " · {} more filename(s); `codewhale doctor --json` includes a bounded metadata-only sample",
5584 report.recoverable_file_count - DOCTOR_SESSION_RECOVERY_HUMAN_SAMPLE_LIMIT
5585 );
5586 }
5587 println!(" Safe recovery:");
5588 println!(
5589 " 1. Back up {} and {} (if present).",
5590 crate::utils::display_path(&report.legacy_sessions_path),
5591 crate::utils::display_path(&report.primary_sessions_path),
5592 );
5593 println!(
5594 " 2. Close other Codewhale processes, then run `codewhale sessions`; migration adds only missing files, never overwrites primary files, and leaves legacy originals in place."
5595 );
5596 println!(
5597 " 3. Rerun `codewhale doctor`. If filenames remain, keep both backups and report only the listed source/destination names."
5598 );
5599 }
5600 DoctorSessionRecoveryStatus::ScanFailed => {
5601 println!(
5602 " {} legacy sessions: recovery diagnostic could not complete",
5603 "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2)
5604 );
5605 if let Some(error) = report.error.as_deref() {
5606 println!(" {error}");
5607 }
5608 println!(
5609 " Keep both session directories unchanged, back them up, fix path permissions or shape, and rerun `codewhale doctor` before attempting migration."
5610 );
5611 }
5612 }
5613 if report.status != DoctorSessionRecoveryStatus::Isolated {
5614 println!(
5615 " Doctor inspected filenames and filesystem metadata only; it did not read chat contents, traverse checkpoints, or modify session files."
5616 );
5617 }
5618}
5619
5620fn doctor_session_recovery_json(report: &DoctorSessionRecoveryReport) -> serde_json::Value {
5621 use serde_json::json;
5622
5623 let recoverable: Vec<_> = report
5624 .recoverable
5625 .iter()
5626 .take(DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT)
5627 .map(|entry| {
5628 json!({
5629 "name": entry.name.display().to_string(),
5630 "source_path": entry.source_path.display().to_string(),
5631 "destination_path": entry.destination_path.display().to_string(),
5632 })
5633 })
5634 .collect();
5635
5636 json!({
5637 "status": report.status.as_str(),
5638 "needs_attention": report.needs_attention(),
5639 "read_only": true,
5640 "chat_contents_read": false,
5641 "checkpoint_internals_scanned": false,
5642 "session_descriptors_compared": false,
5643 "counterpart_check": "top_level_filename_and_regular_file_only",
5644 "codewhale_home_is_explicit": report.codewhale_home_is_explicit,
5645 "legacy_sessions_path": report.legacy_sessions_path.display().to_string(),
5646 "primary_sessions_path": report.primary_sessions_path.display().to_string(),
5647 "legacy_session_file_count": report.legacy_session_file_count,
5648 "already_present_file_count": report.already_present_file_count,
5649 "recoverable_file_count": report.recoverable_file_count,
5650 "recoverable_files": recoverable,
5651 "recoverable_files_truncated": report.recoverable_file_count > report.recoverable.len(),
5652 "error": report.error,
5653 "recovery_command": if report.needs_attention() && report.status != DoctorSessionRecoveryStatus::ScanFailed {
5654 Some("codewhale sessions")
5655 } else {
5656 None
5657 },
5658 })
5659}
5660
5661fn doctor_legacy_state_json(
5662 primary_root: &Path,
5663 legacy_root: &Path,
5664 report: &[DoctorLegacyStateEntry],
5665 session_recovery: &DoctorSessionRecoveryReport,
5666) -> serde_json::Value {
5667 use serde_json::json;
5668
5669 let legacy_only = report
5670 .iter()
5671 .filter(|entry| entry.status == DoctorLegacyStateStatus::LegacyOnly)
5672 .count();
5673 let both = report
5674 .iter()
5675 .filter(|entry| entry.status == DoctorLegacyStateStatus::Both)
5676 .count();
5677 let entries: Vec<_> = report
5678 .iter()
5679 .map(|entry| {
5680 json!({
5681 "name": entry.name,
5682 "primary_path": entry.primary_path.display().to_string(),
5683 "legacy_path": entry.legacy_path.display().to_string(),
5684 "primary_present": entry.primary_present,
5685 "legacy_present": entry.legacy_present,
5686 "status": entry.status.as_str(),
5687 })
5688 })
5689 .collect();
5690
5691 json!({
5692 "primary_root": primary_root.display().to_string(),
5693 "legacy_root": legacy_root.display().to_string(),
5694 "needs_attention": report.iter().any(legacy_state_needs_attention) || session_recovery.needs_attention(),
5695 "legacy_only_count": legacy_only,
5696 "dual_present_count": both,
5697 "session_recovery": doctor_session_recovery_json(session_recovery),
5698 "entries": entries,
5699 })
5700}
5701
5702fn doctor_setup_state(
5703 config: &Config,
5704 workspace: &Path,
5705) -> (codewhale_config::SetupState, &'static str) {
5706 if let Ok(Some(state)) = codewhale_config::SetupState::load() {
5707 return (state, "persisted");
5708 }
5709
5710 (
5711 codewhale_config::SetupState::derive_inherited(&doctor_inherited_setup_facts(
5712 config, workspace,
5713 )),
5714 "derived",
5715 )
5716}
5717
5718fn doctor_inherited_setup_facts(
5719 config: &Config,
5720 workspace: &Path,
5721) -> codewhale_config::InheritedConfigFacts {
5722 let user_constitution = codewhale_config::UserConstitution::load().ok();
5723 let user_constitution_validity = user_constitution.as_ref().map_or(
5724 codewhale_config::ConstitutionValidity::Unknown,
5725 codewhale_config::UserConstitutionLoad::validity,
5726 );
5727 let has_user_constitution = user_constitution
5728 .as_ref()
5729 .is_some_and(|loaded| !matches!(loaded, codewhale_config::UserConstitutionLoad::Missing));
5730 let has_expert_override = codewhale_config::codewhale_home()
5731 .ok()
5732 .map(|home| home.join(Path::new(crate::prompts::CONSTITUTION_OVERRIDE_FILE)))
5733 .is_some_and(|path| path.exists());
5734
5735 codewhale_config::InheritedConfigFacts {
5736 language: None,
5737 has_provider_route: !config.default_model().trim().is_empty(),
5738 has_credentials_or_local_runtime: doctor_has_credentials_or_local_runtime(config),
5739 trust_chosen: !crate::tui::onboarding::needs_trust(workspace),
5740 has_expert_override,
5741 has_user_constitution,
5742 user_constitution_validity,
5743 }
5744}
5745
5746fn doctor_has_credentials_or_local_runtime(config: &Config) -> bool {
5747 resolve_credential_diagnostic(config)
5748 .availability
5749 .certifies_ready()
5750}
5751
5752fn print_doctor_setup_report(
5753 config: &Config,
5754 workspace: &Path,
5755 state: &codewhale_config::SetupState,
5756 source: &str,
5757 ok_rgb: (u8, u8, u8),
5758 warn_rgb: (u8, u8, u8),
5759) {
5760 use colored::Colorize;
5761
5762 let credential = resolve_credential_diagnostic(config);
5763 let first_run_ready = state.first_run_ready();
5767 let update_ready = state.update_ready(crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION);
5768 let operate_ready = state.operate_ready();
5769 let first_run_icon = if first_run_ready {
5770 "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2)
5771 } else {
5772 "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2)
5773 };
5774 let update_icon = if update_ready {
5775 "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2)
5776 } else {
5777 "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2)
5778 };
5779 let operate_icon = if operate_ready {
5780 "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2)
5781 } else {
5782 "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2)
5783 };
5784
5785 println!();
5786 println!("{}", "Setup State:".bold());
5787 println!(" · source: {source}");
5788 println!(
5789 " · credential: source={}, availability={}",
5790 doctor_api_key_source_label(credential.source),
5791 credential.availability.label()
5792 );
5793 println!(
5794 " {first_run_icon} first-run: {}",
5795 doctor_ready_label(first_run_ready)
5796 );
5797 println!(
5798 " {update_icon} update checkpoint {}: {}",
5799 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
5800 doctor_ready_label(update_ready)
5801 );
5802 println!(
5803 " {operate_icon} operate/fleet: {}",
5804 doctor_ready_label(operate_ready)
5805 );
5806 println!(
5807 " · constitution autonomy: {} (guidance only)",
5808 doctor_constitution_autonomy_preference_id()
5809 );
5810 println!(
5811 " · runtime posture: {}",
5812 doctor_runtime_posture_line(config, workspace)
5813 );
5814 let consistency = doctor_setup_consistency(state, source);
5815 if consistency["status"] == "inconsistent" {
5816 let issues = consistency["issues"]
5817 .as_array()
5818 .map(|issues| {
5819 issues
5820 .iter()
5821 .filter_map(serde_json::Value::as_str)
5822 .collect::<Vec<_>>()
5823 .join(", ")
5824 })
5825 .unwrap_or_default();
5826 println!(
5827 " {} consistency: half-applied setup detected ({issues}) — {}",
5828 "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2),
5829 consistency["repair"].as_str().unwrap_or("/setup"),
5830 );
5831 }
5832 println!(
5833 " · 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)"
5834 );
5835 for step in codewhale_config::SetupStep::ALL {
5836 let entry = state.steps.get(&step);
5837 let required = entry.is_some_and(|entry| entry.required);
5838 let version = entry.and_then(|entry| entry.version.as_deref());
5839 let result = entry.and_then(|entry| entry.result.as_deref());
5840 let required_label = if required { "required" } else { "optional" };
5841 let version_label = version.unwrap_or("unversioned");
5842 let result_label = result.unwrap_or("no result");
5843 println!(
5844 " · {}: {} ({required_label}, {version_label}, {result_label})",
5845 setup_step_id(step),
5846 setup_status_id(state.status(step))
5847 );
5848 }
5849}
5850
5851fn print_doctor_fleet_roster_layers(config: &Config, workspace: &Path) {
5855 use colored::Colorize;
5856
5857 let roster = crate::fleet::roster::FleetRoster::load(&config.fleet_config(), workspace);
5858 println!();
5859 println!("{}", "Fleet roster layers:".bold());
5860 let lines = roster.doctor_layer_lines();
5861 if lines.is_empty() {
5862 println!(" · no profile id is defined in more than one layer");
5863 return;
5864 }
5865 for line in lines {
5866 if let Some(layer) = line.strip_prefix(" ") {
5867 println!(" {layer}");
5868 } else {
5869 println!(" · {line}");
5870 }
5871 }
5872}
5873
5874fn doctor_ready_label(ready: bool) -> &'static str {
5875 if ready { "ready" } else { "needs action" }
5876}
5877
5878fn doctor_setup_consistency(
5886 state: &codewhale_config::SetupState,
5887 source: &str,
5888) -> serde_json::Value {
5889 use serde_json::json;
5890
5891 let mut issues: Vec<&'static str> = Vec::new();
5892
5893 if source == "persisted"
5894 && matches!(
5895 state.constitution_source,
5896 codewhale_config::ConstitutionSource::UserGlobal
5897 )
5898 {
5899 match codewhale_config::UserConstitution::load() {
5900 Ok(codewhale_config::UserConstitutionLoad::Missing) => {
5901 issues.push("setup_state_points_at_missing_user_constitution");
5902 }
5903 Ok(codewhale_config::UserConstitutionLoad::Empty) => {
5904 issues.push("user_constitution_empty");
5905 }
5906 Ok(codewhale_config::UserConstitutionLoad::Invalid(_)) => {
5907 issues.push("user_constitution_invalid");
5908 }
5909 Ok(codewhale_config::UserConstitutionLoad::Unreadable(_)) | Err(_) => {
5910 issues.push("user_constitution_unreadable");
5911 }
5912 Ok(codewhale_config::UserConstitutionLoad::Loaded(_)) => {}
5913 }
5914 }
5915
5916 if doctor_home_has_stale_setup_temp_files() {
5917 issues.push("stale_setup_temp_files_in_codewhale_home");
5918 }
5919
5920 json!({
5921 "status": if issues.is_empty() { "consistent" } else { "inconsistent" },
5922 "issues": issues,
5923 "repair": "/constitution to rebuild standing law, /setup to re-run the checkpoint",
5924 })
5925}
5926
5927fn doctor_home_has_stale_setup_temp_files() -> bool {
5928 let Ok(home) = codewhale_config::codewhale_home() else {
5929 return false;
5930 };
5931 let Ok(entries) = std::fs::read_dir(&home) else {
5932 return false;
5933 };
5934 entries.flatten().any(|entry| {
5935 entry.file_name().to_string_lossy().starts_with(".tmp")
5936 && entry.file_type().is_ok_and(|kind| kind.is_file())
5937 })
5938}
5939
5940fn doctor_constitution_autonomy_preference() -> codewhale_config::AutonomyPreference {
5941 codewhale_config::UserConstitution::load()
5942 .ok()
5943 .and_then(|load| {
5944 load.constitution()
5945 .map(|constitution| constitution.autonomy_preference)
5946 })
5947 .unwrap_or(codewhale_config::AutonomyPreference::Unspecified)
5948}
5949
5950fn doctor_constitution_autonomy_preference_id() -> &'static str {
5951 autonomy_preference_id(doctor_constitution_autonomy_preference())
5952}
5953
5954fn autonomy_preference_id(preference: codewhale_config::AutonomyPreference) -> &'static str {
5955 match preference {
5956 codewhale_config::AutonomyPreference::Unspecified => "unspecified",
5957 codewhale_config::AutonomyPreference::Cautious => "cautious",
5958 codewhale_config::AutonomyPreference::Balanced => "balanced",
5959 codewhale_config::AutonomyPreference::Autonomous => "autonomous",
5960 }
5961}
5962
5963fn doctor_runtime_default_mode() -> (String, &'static str) {
5964 match crate::settings::Settings::load_read_only() {
5965 Ok(settings) => (settings.default_mode, "settings"),
5966 Err(_) => (crate::settings::Settings::default().default_mode, "default"),
5967 }
5968}
5969
5970fn doctor_runtime_permission_posture() -> (String, &'static str) {
5974 match crate::settings::Settings::load_read_only() {
5975 Ok(settings) => match settings.permission_posture {
5976 Some(posture) => (posture, "settings"),
5977 None => ("unset".to_string(), "default"),
5978 },
5979 Err(_) => ("unset".to_string(), "default"),
5980 }
5981}
5982
5983fn doctor_runtime_posture_line(config: &Config, workspace: &Path) -> String {
5984 let (default_mode, default_mode_source) = doctor_runtime_default_mode();
5985 let (permission_posture, permission_posture_source) = doctor_runtime_permission_posture();
5986 let approval = config.approval_policy.as_deref().unwrap_or("on-request");
5987 let approval_source = if config.approval_policy.is_some() {
5988 "config"
5989 } else {
5990 "default"
5991 };
5992 let allow_shell = config.interactive_allow_shell();
5993 let allow_shell_source = if config.allow_shell.is_some() {
5994 "config"
5995 } else {
5996 "interactive default"
5997 };
5998 let sandbox = config.sandbox_mode.as_deref().unwrap_or("mode-derived");
5999 let sandbox_source = if config.sandbox_mode.is_some() {
6000 "config"
6001 } else {
6002 "default"
6003 };
6004 let network = config
6005 .network
6006 .as_ref()
6007 .map_or("prompt", |policy| policy.default.as_str());
6008 let network_source = if config.network.is_some() {
6009 "config"
6010 } else {
6011 "default"
6012 };
6013 let trust = if crate::tui::onboarding::needs_trust(workspace) {
6014 "workspace not elevated"
6015 } else {
6016 "workspace trusted"
6017 };
6018 let (telemetry_on, telemetry_source) = doctor_runtime_telemetry(config);
6019 let telemetry = if telemetry_on { "on" } else { "off" };
6020
6021 format!(
6022 "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}"
6023 )
6024}
6025
6026fn doctor_runtime_telemetry(config: &Config) -> (bool, &'static str) {
6032 let (on, source) = codewhale_config::resolved_telemetry_consent(config.telemetry);
6033 (on, source.as_str())
6034}
6035
6036fn doctor_operate_fleet_report_json(config: &Config, workspace: &Path) -> serde_json::Value {
6037 use serde_json::json;
6038
6039 let provider = config.api_provider();
6040 let credential = resolve_credential_diagnostic(config);
6043 let has_credentials_or_local = credential.availability.certifies_ready();
6044 let subagents_enabled = config.subagents_enabled_for_provider(provider);
6045 let disabled_reason = if subagents_enabled {
6046 None
6047 } else {
6048 Some(
6049 config
6050 .subagents_disabled_reason()
6051 .unwrap_or("disabled for active provider"),
6052 )
6053 };
6054 let max_subagents = config.max_subagents_for_provider(provider);
6055 let launch_concurrency = config.launch_concurrency_for_provider(provider);
6056 let max_admitted = config.max_admitted_subagents_for_provider(provider);
6057 let max_spawn_depth = config.subagent_max_spawn_depth_for_provider(provider);
6058 let roster = crate::fleet::roster::FleetRoster::load(&config.fleet_config(), workspace);
6059 let mut built_in_members = 0usize;
6060 let mut config_members = 0usize;
6061 let mut personal_members = 0usize;
6062 let mut workspace_members = 0usize;
6063 for member in roster.members() {
6064 match member.origin {
6065 crate::fleet::roster::ProfileOrigin::BuiltIn => built_in_members += 1,
6066 crate::fleet::roster::ProfileOrigin::Config => config_members += 1,
6067 crate::fleet::roster::ProfileOrigin::Personal => personal_members += 1,
6068 crate::fleet::roster::ProfileOrigin::Workspace => workspace_members += 1,
6069 }
6070 }
6071 let roster_members = roster.members().len();
6072 let custom_members = config_members + personal_members + workspace_members;
6073 let roster_ready = roster_members > 0;
6074 let runtime_ready =
6075 subagents_enabled && max_subagents > 0 && launch_concurrency > 0 && max_spawn_depth > 0;
6076 let multi_layer: Vec<serde_json::Value> = roster
6077 .multi_layer_report()
6078 .into_iter()
6079 .map(|entry| {
6080 json!({
6081 "id": entry.id,
6082 "effective": entry.effective.to_string(),
6083 "effective_path": entry.effective_path.display().to_string(),
6084 "layers": entry
6085 .layers
6086 .iter()
6087 .map(|layer| {
6088 json!({
6089 "origin": layer.origin.to_string(),
6090 "path": layer.source.display().to_string(),
6091 "wins": layer.wins,
6092 })
6093 })
6094 .collect::<Vec<_>>(),
6095 })
6096 })
6097 .collect();
6098
6099 json!({
6100 "ready": has_credentials_or_local && runtime_ready && roster_ready,
6101 "provider": {
6102 "id": config.provider_identity_for(provider),
6103 "auth": {
6104 "present_or_local": has_credentials_or_local,
6105 "source": doctor_api_key_source_label(credential.source),
6106 "availability": credential.availability.label(),
6107 },
6108 },
6109 "worker_runtime": {
6110 "ready": runtime_ready,
6111 "enabled": subagents_enabled,
6112 "disabled_reason": disabled_reason,
6113 "max_subagents": max_subagents,
6114 "launch_concurrency": launch_concurrency,
6115 "max_admitted": max_admitted,
6116 "max_spawn_depth": max_spawn_depth,
6117 "host_enforced_workflow_receipts": true,
6118 },
6119 "roster": {
6120 "ready": roster_ready,
6121 "total": roster_members,
6122 "built_in": built_in_members,
6123 "config": config_members,
6124 "personal": personal_members,
6125 "workspace": workspace_members,
6126 "custom": custom_members,
6127 "starter_roster_available": built_in_members > 0,
6128 "readiness_rule": "built-in starter roster or custom roster",
6129 "multi_layer": multi_layer,
6130 },
6131 "concurrency": {
6132 "launch_concurrency": launch_concurrency,
6133 "max_subagents": max_subagents,
6134 "max_admitted": max_admitted,
6135 "plan_limit_probed": false,
6136 },
6137 })
6138}
6139
6140fn doctor_provider_model_report_json(config: &Config) -> serde_json::Value {
6141 use serde_json::json;
6142
6143 let provider = config.api_provider();
6144 let credential = resolve_credential_diagnostic(config);
6145 let auth_present_or_local = credential.availability.certifies_ready();
6146 let credential_help = provider.credential_help();
6147 let credential_url = credential_help
6148 .credential_url
6149 .map(crate::doctor::structural_url_authority);
6150 let credential_docs_url = credential_help
6151 .docs_url
6152 .map(crate::doctor::structural_url_authority);
6153
6154 json!({
6155 "provider": {
6156 "id": config.provider_identity_for(provider),
6157 "display": provider.display_name(),
6158 },
6159 "model": {
6160 "resolved": config.default_model(),
6161 },
6162 "auth": {
6163 "present_or_local": auth_present_or_local,
6164 "source": doctor_api_key_source_label(credential.source),
6165 "availability": credential.availability.label(),
6166 "env_vars": provider.env_vars(),
6167 "credential_mode": credential_help.acquisition.as_str(),
6168 "credential_url": credential_url,
6169 "credential_docs_url": credential_docs_url,
6170 "credential_guidance": credential_help.guidance,
6171 "oauth_only": credential_help.acquisition
6172 == codewhale_config::provider::CredentialAcquisition::OAuth,
6173 },
6174 "health": {
6175 "live_validation": false,
6176 "next_action": if auth_present_or_local {
6177 "/model"
6178 } else {
6179 "/setup provider or /provider setup <name>"
6180 },
6181 },
6182 })
6183}
6184
6185fn doctor_dsh_integration_report(
6186 config: &Config,
6187 workspace: &Path,
6188) -> anyhow::Result<crate::integrations::dsh::DshStatusReport> {
6189 use crate::integrations::dsh;
6190 let paths = dsh::DshPaths::from_process()?;
6191 let detection = dsh::detect::detect(&dsh::DetectEnv::from_process(), &dsh::ProcessRunner);
6192 let identity = dsh::codewhale_route_identity(config, workspace);
6193 dsh::compute_status(
6194 &paths,
6195 detection,
6196 identity,
6197 false,
6198 dsh::bundle_availability_now(),
6199 )
6200}
6201
6202fn doctor_dsh_integration_lines(config: &Config, workspace: &Path) -> Vec<String> {
6203 match doctor_dsh_integration_report(config, workspace) {
6204 Ok(report) => {
6205 let mut lines = vec![
6206 format!("state: {}", report.state.label()),
6207 crate::integrations::dsh::status_line(&report),
6208 format!(
6209 "owned files: {} (overlay {})",
6210 crate::utils::display_path(&report.paths_root),
6211 if report.overlay_present {
6212 "present"
6213 } else {
6214 "absent"
6215 }
6216 ),
6217 ];
6218 if !report.shadowing_namespaces.is_empty() {
6219 lines.push(format!(
6220 "dsh settings.yaml sections that can shadow the overlay: {}",
6221 report.shadowing_namespaces.join(", ")
6222 ));
6223 }
6224 lines
6225 }
6226 Err(error) => vec![format!("unavailable: {error}")],
6227 }
6228}
6229
6230fn doctor_dsh_integration_json(config: &Config, workspace: &Path) -> serde_json::Value {
6231 match doctor_dsh_integration_report(config, workspace) {
6232 Ok(report) => serde_json::json!({
6233 "state": report.state.label(),
6234 "summary": crate::integrations::dsh::status_line(&report),
6235 "dsh_version": report.detection.version,
6236 "compatibility": report.detection.compatibility.label(),
6237 "overlay_present": report.overlay_present,
6238 "shadowing_namespaces": report.shadowing_namespaces,
6239 }),
6240 Err(error) => serde_json::json!({ "state": "unavailable", "error": error.to_string() }),
6241 }
6242}
6243
6244fn doctor_external_credential_consent_statuses(
6245 config: &Config,
6246) -> Vec<codewhale_config::ExternalCredentialConsentStatus> {
6247 [
6248 crate::config::ApiProvider::OpenaiCodex,
6249 crate::config::ApiProvider::Xai,
6250 crate::config::ApiProvider::Deepseek,
6251 ]
6252 .into_iter()
6253 .filter_map(|provider| config.external_credential_consent_status(provider))
6254 .collect()
6255}
6256
6257fn doctor_external_credential_consent_lines(config: &Config) -> Vec<String> {
6258 doctor_external_credential_consent_statuses(config)
6259 .into_iter()
6260 .flat_map(|status| {
6261 let mut lines = vec![
6262 format!(
6263 "{}: access={}, provider={}, source={}, owner={}, path={}, version={}, state={}, ambient_path_changed={}",
6264 status.provider,
6265 status.access.as_str(),
6266 status.provider,
6267 status.source.as_str(),
6268 status.owner,
6269 codewhale_config::quote_os_path(&status.path),
6270 status.consent_version,
6271 status.route_state,
6272 status.ambient_path_changed,
6273 ),
6274 format!(" semantics: {}", status.semantics),
6275 format!(" revoke: {}", status.revoke_command),
6276 ];
6277 if let Some(warning) = status.ambient_path_warning() {
6278 lines.push(format!(" {warning}"));
6279 }
6280 lines
6281 })
6282 .collect()
6283}
6284
6285fn doctor_external_credential_consent_json(config: &Config) -> serde_json::Value {
6286 serde_json::Value::Array(
6287 doctor_external_credential_consent_statuses(config)
6288 .into_iter()
6289 .map(|status| {
6290 serde_json::json!({
6291 "provider": status.provider,
6292 "access": status.access.as_str(),
6293 "source": status.source.as_str(),
6294 "owner": status.owner,
6295 "path": codewhale_config::quote_os_path(&status.path),
6296 "consent_version": status.consent_version,
6297 "scope_valid": status.scope_valid,
6298 "ambient_path_changed": status.ambient_path_changed,
6299 "ambient_path_warning": status.ambient_path_warning(),
6300 "route_state": status.route_state,
6301 "semantics": status.semantics,
6302 "revoke_command": status.revoke_command,
6303 })
6304 })
6305 .collect(),
6306 )
6307}
6308
6309fn doctor_setup_report_json(config: &Config, workspace: &Path) -> serde_json::Value {
6310 use serde_json::json;
6311
6312 let (state, source) = doctor_setup_state(config, workspace);
6313 let (default_mode, default_mode_source) = doctor_runtime_default_mode();
6314 let (permission_posture, permission_posture_source) = doctor_runtime_permission_posture();
6315 let approval_policy = config.approval_policy.as_deref().unwrap_or("on-request");
6316 let approval_policy_source = if config.approval_policy.is_some() {
6317 "config"
6318 } else {
6319 "default"
6320 };
6321 let allow_shell = config.interactive_allow_shell();
6322 let allow_shell_source = if config.allow_shell.is_some() {
6323 "config"
6324 } else {
6325 "interactive_default"
6326 };
6327 let sandbox_mode = config.sandbox_mode.as_deref().unwrap_or("mode-derived");
6328 let sandbox_mode_source = if config.sandbox_mode.is_some() {
6329 "config"
6330 } else {
6331 "default"
6332 };
6333 let network_default = config
6334 .network
6335 .as_ref()
6336 .map_or("prompt", |policy| policy.default.as_str());
6337 let network_source = if config.network.is_some() {
6338 "config"
6339 } else {
6340 "default"
6341 };
6342 let (telemetry_value, telemetry_source) = doctor_runtime_telemetry(config);
6343 let workspace_trusted = !crate::tui::onboarding::needs_trust(workspace);
6344 let credential = resolve_credential_diagnostic(config);
6345 let credential_ready = credential.availability.certifies_ready();
6346 let steps: Vec<_> = codewhale_config::SetupStep::ALL
6347 .into_iter()
6348 .map(|step| {
6349 let entry = state.steps.get(&step);
6350 json!({
6351 "step": setup_step_id(step),
6352 "status": setup_status_id(state.status(step)),
6353 "required": entry.is_some_and(|entry| entry.required),
6354 "version": entry.and_then(|entry| entry.version.clone()),
6355 "result": entry.and_then(|entry| entry.result.clone()),
6356 })
6357 })
6358 .collect();
6359
6360 json!({
6361 "source": source,
6362 "schema_version": state.schema_version,
6363 "inherited": state.inherited,
6364 "checkpoint_version": crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
6365 "first_run_ready": state.first_run_ready(),
6366 "update_ready": state.update_ready(crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION),
6367 "operate_ready": state.operate_ready(),
6368 "credential": {
6369 "ready": credential_ready,
6370 "source": doctor_api_key_source_label(credential.source),
6371 "availability": credential.availability.label(),
6372 },
6373 "constitution": {
6374 "choice": constitution_choice_id(state.constitution_choice),
6375 "source": constitution_source_id(state.constitution_source),
6376 "validity": constitution_validity_id(state.constitution_validity),
6377 "checkpoint_completed_for": state.constitution_checkpoint_completed_for.clone(),
6378 "language": state.constitution_language.clone(),
6379 "preview_hash_present": state.constitution_preview_hash.is_some(),
6380 "preview_version": state.constitution_preview_version,
6381 "autonomy_preference": doctor_constitution_autonomy_preference_id(),
6382 },
6383 "runtime_posture_source": runtime_posture_source_id(state.runtime_posture_source),
6384 "runtime_posture": {
6385 "source": runtime_posture_source_id(state.runtime_posture_source),
6386 "default_mode": {
6387 "value": default_mode,
6388 "source": default_mode_source,
6389 },
6390 "permission_posture": {
6391 "value": permission_posture,
6392 "source": permission_posture_source,
6393 },
6394 "approval_policy": {
6395 "value": approval_policy,
6396 "source": approval_policy_source,
6397 },
6398 "allow_shell": {
6399 "value": allow_shell,
6400 "source": allow_shell_source,
6401 },
6402 "sandbox_mode": {
6403 "value": sandbox_mode,
6404 "source": sandbox_mode_source,
6405 },
6406 "network_default": {
6407 "value": network_default,
6408 "source": network_source,
6409 },
6410 "telemetry": {
6411 "value": telemetry_value,
6412 "source": telemetry_source,
6413 },
6414 "workspace_trust": {
6415 "trusted": workspace_trusted,
6416 "source": "workspace",
6417 },
6418 },
6419 "provider_model": doctor_provider_model_report_json(config),
6420 "operate_fleet": doctor_operate_fleet_report_json(config, workspace),
6421 "consistency": doctor_setup_consistency(&state, source),
6422 "next_actions": {
6423 "constitution": "/constitution",
6424 "setup_report": "/setup report",
6425 "provider_model": "/setup provider, /provider setup <name>, or /model",
6426 "runtime_posture": "/config",
6427 "operate_fleet": "/setup fleet (readiness), /fleet setup (explicit profile authoring)",
6428 "hotbar": "/setup hotbar",
6429 "tools_mcp": "/setup tools",
6430 "remote_runtime": "/setup remote",
6431 "persistence": "/setup persistence",
6432 },
6433 "steps": steps,
6434 })
6435}
6436
6437fn setup_step_id(step: codewhale_config::SetupStep) -> &'static str {
6438 match step {
6439 codewhale_config::SetupStep::Language => "language",
6440 codewhale_config::SetupStep::ProviderModel => "provider_model",
6441 codewhale_config::SetupStep::TrustSandbox => "trust_sandbox",
6442 codewhale_config::SetupStep::ToolsMcp => "tools_mcp",
6443 codewhale_config::SetupStep::Hotbar => "hotbar",
6444 codewhale_config::SetupStep::RemoteRuntime => "remote_runtime",
6445 codewhale_config::SetupStep::Persistence => "persistence",
6446 codewhale_config::SetupStep::Constitution => "constitution",
6447 codewhale_config::SetupStep::OperateFleet => "operate_fleet",
6448 codewhale_config::SetupStep::Verification => "verification",
6449 }
6450}
6451
6452fn setup_status_id(status: codewhale_config::StepStatus) -> &'static str {
6453 match status {
6454 codewhale_config::StepStatus::NotStarted => "not_started",
6455 codewhale_config::StepStatus::Recommended => "recommended",
6456 codewhale_config::StepStatus::Optional => "optional",
6457 codewhale_config::StepStatus::Deferred => "deferred",
6458 codewhale_config::StepStatus::InProgress => "in_progress",
6459 codewhale_config::StepStatus::Verified => "verified",
6460 codewhale_config::StepStatus::NeedsAction => "needs_action",
6461 codewhale_config::StepStatus::Failed => "failed",
6462 codewhale_config::StepStatus::Skipped => "skipped",
6463 }
6464}
6465
6466fn constitution_choice_id(choice: codewhale_config::ConstitutionChoice) -> &'static str {
6467 match choice {
6468 codewhale_config::ConstitutionChoice::Unset => "unset",
6469 codewhale_config::ConstitutionChoice::Bundled => "bundled",
6470 codewhale_config::ConstitutionChoice::GuidedCustom => "guided_custom",
6471 codewhale_config::ConstitutionChoice::ExpertOverride => "expert_override",
6472 codewhale_config::ConstitutionChoice::Deferred => "deferred",
6473 }
6474}
6475
6476fn constitution_source_id(source: codewhale_config::ConstitutionSource) -> &'static str {
6477 match source {
6478 codewhale_config::ConstitutionSource::Bundled => "bundled",
6479 codewhale_config::ConstitutionSource::UserGlobal => "user_global",
6480 codewhale_config::ConstitutionSource::ExpertOverride => "expert_override",
6481 }
6482}
6483
6484fn constitution_validity_id(validity: codewhale_config::ConstitutionValidity) -> &'static str {
6485 match validity {
6486 codewhale_config::ConstitutionValidity::Unknown => "unknown",
6487 codewhale_config::ConstitutionValidity::Valid => "valid",
6488 codewhale_config::ConstitutionValidity::Invalid => "invalid",
6489 codewhale_config::ConstitutionValidity::Empty => "empty",
6490 codewhale_config::ConstitutionValidity::Unreadable => "unreadable",
6491 }
6492}
6493
6494fn runtime_posture_source_id(source: codewhale_config::RuntimePostureSource) -> &'static str {
6495 match source {
6496 codewhale_config::RuntimePostureSource::Unset => "unset",
6497 codewhale_config::RuntimePostureSource::Inherited => "inherited",
6498 codewhale_config::RuntimePostureSource::Confirmed => "confirmed",
6499 }
6500}
6501
6502fn run_doctor_json_config_error(error: &anyhow::Error) -> Result<()> {
6506 let safe_message = error
6507 .downcast_ref::<crate::config::SafeConfigDiagnostic>()
6508 .map(ToString::to_string);
6509 let report = serde_json::json!({
6510 "status": "error",
6511 "error": {
6512 "kind": "config_validation",
6513 "message": safe_message.as_deref().unwrap_or("configuration validation failed; details omitted because configuration errors may contain credential material"),
6514 },
6515 });
6516 println!("{}", serde_json::to_string_pretty(&report)?);
6517
6518 bail!("doctor configuration validation failed; see JSON output")
6521}
6522
6523fn run_doctor_json(
6526 config: &Config,
6527 workspace: &Path,
6528 config_path_override: Option<&Path>,
6529 plugins: &crate::plugins::PluginRegistry,
6530) -> Result<()> {
6531 use serde_json::json;
6532
6533 let doctor_paths = crate::doctor::DoctorPathReport::resolve(config_path_override)?;
6534 let config_path = &doctor_paths.config;
6535 let secret_backend = codewhale_secrets::diagnose_secret_backend();
6536
6537 let credential = resolve_credential_diagnostic(config);
6538
6539 let mcp_config_path = config.mcp_config_path();
6540 let project_mcp_config_path = crate::mcp::workspace_mcp_config_path(workspace);
6541 let mcp_present = mcp_config_path.exists();
6542 let project_mcp_present = project_mcp_config_path.exists();
6543 let mcp_summary = match crate::mcp::load_config_with_workspace_and_plugins(
6544 &mcp_config_path,
6545 workspace,
6546 plugins,
6547 ) {
6548 Ok(cfg) => {
6549 let servers: Vec<serde_json::Value> = cfg
6550 .servers
6551 .iter()
6552 .map(|(name, server)| doctor_mcp_server_json(name, server))
6553 .collect();
6554 json!({
6555 "config_path": mcp_config_path.display().to_string(),
6556 "present": mcp_present,
6557 "project_config_path": project_mcp_config_path.display().to_string(),
6558 "project_present": project_mcp_present,
6559 "probe_scope": "configuration",
6560 "live_health_checked": false,
6561 "servers": servers,
6562 })
6563 }
6564 Err(_) => json!({
6565 "config_path": mcp_config_path.display().to_string(),
6566 "present": mcp_present,
6567 "project_config_path": project_mcp_config_path.display().to_string(),
6568 "project_present": project_mcp_present,
6569 "probe_scope": "configuration",
6570 "live_health_checked": false,
6571 "servers": [],
6572 "error": "configuration_unavailable_details_omitted",
6573 }),
6574 };
6575
6576 let global_skills_dir = config.skills_dir();
6577 let agents_skills_dir = workspace.join(".agents").join("skills");
6578 let local_skills_dir = workspace.join("skills");
6579 let agents_global_skills_dir = crate::skills::agents_global_skills_dir();
6580 let opencode_skills_dir = workspace.join(".opencode").join("skills");
6585 let claude_skills_dir = workspace.join(".claude").join("skills");
6586 let selected_skills_dir = if agents_skills_dir.exists() {
6587 agents_skills_dir.clone()
6588 } else if local_skills_dir.exists() {
6589 local_skills_dir.clone()
6590 } else if config.skills_dir.is_none()
6591 && let Some(global_agents) = agents_global_skills_dir.as_ref()
6592 && global_agents.exists()
6593 {
6594 global_agents.clone()
6595 } else {
6596 global_skills_dir.clone()
6597 };
6598 let agents_global_summary = agents_global_skills_dir
6599 .as_ref()
6600 .map(|path| {
6601 json!({
6602 "path": path.display().to_string(),
6603 "present": path.exists(),
6604 "count": skills_count_for(path),
6605 })
6606 })
6607 .unwrap_or_else(|| {
6608 json!({
6609 "path": null,
6610 "present": false,
6611 "count": 0,
6612 })
6613 });
6614
6615 let tools_dir = default_tools_dir();
6616 let plugins_dir = default_plugins_dir();
6617
6618 let memory_path = config.memory_path();
6627 let memory_enabled_env = std::env::var("CODEWHALE_MEMORY")
6628 .or_else(|_| std::env::var("DEEPSEEK_MEMORY"))
6629 .ok()
6630 .map(|raw| {
6631 matches!(
6632 raw.trim().to_ascii_lowercase().as_str(),
6633 "1" | "on" | "true" | "yes" | "y" | "enabled"
6634 )
6635 })
6636 .unwrap_or(false);
6637 let memory_summary = json!({
6638 "enabled": memory_enabled_env,
6641 "path": memory_path.display().to_string(),
6642 "file_present": memory_path.exists(),
6643 });
6644 let api_target = doctor_api_target(config);
6645 let strict_tool_mode = doctor_strict_tool_mode_status(config);
6646 let tls_status = doctor_tls_status(config);
6647 let (code_home, legacy_home) = doctor_state_roots();
6648 let legacy_state_report = doctor_legacy_state_report(&code_home, &legacy_home);
6649 let session_recovery = doctor_session_recovery_report(
6650 &code_home,
6651 &legacy_home,
6652 codewhale_config::codewhale_home_is_explicit(),
6653 );
6654
6655 let stash = crate::composer_stash::diagnostic_stash_report();
6656 let report = json!({
6657 "version": env!("CARGO_PKG_VERSION"),
6658 "config_path": config_path.display().to_string(),
6659 "config_present": config_path.exists(),
6660 "paths": doctor_paths,
6661 "secret_backend": secret_backend,
6662 "workspace": workspace.display().to_string(),
6663 "legacy_state": doctor_legacy_state_json(
6664 &code_home,
6665 &legacy_home,
6666 &legacy_state_report,
6667 &session_recovery,
6668 ),
6669 "setup": doctor_setup_report_json(config, workspace),
6670 "api_key": {
6671 "source": doctor_api_key_source_label(credential.source),
6672 "availability": credential.availability.label(),
6673 },
6674 "external_credentials": doctor_external_credential_consent_json(config),
6675 "dsh_integration": doctor_dsh_integration_json(config, workspace),
6676 "base_url": crate::doctor::structural_url_authority(&api_target.base_url),
6677 "default_text_model": api_target.model,
6678 "route_scope": "configured_at_launch",
6681 "model_resolution": match api_target.resolution {
6682 DoctorModelResolution::Resolved => "resolved",
6683 DoctorModelResolution::ConfiguredOnly => "configured_unresolved",
6684 },
6685 "route": doctor_route_report(config),
6686 "strict_tool_mode": doctor_strict_tool_mode_report_json(&strict_tool_mode),
6687 "tls": {
6688 "certificate_verification": tls_status.certificate_verification,
6689 "insecure_skip_tls_verify": tls_status.insecure_skip_tls_verify,
6690 "provider": tls_status.provider,
6691 "message": tls_status.message,
6692 },
6693 "search_provider": doctor_search_provider_json(config),
6694 "memory": memory_summary,
6695 "mcp": mcp_summary,
6696 "skills": {
6697 "selected": selected_skills_dir.display().to_string(),
6698 "global": {
6699 "path": global_skills_dir.display().to_string(),
6700 "present": global_skills_dir.exists(),
6701 "count": skills_count_for(&global_skills_dir),
6702 },
6703 "agents": {
6704 "path": agents_skills_dir.display().to_string(),
6705 "present": agents_skills_dir.exists(),
6706 "count": skills_count_for(&agents_skills_dir),
6707 },
6708 "agents_global": agents_global_summary,
6709 "local": {
6710 "path": local_skills_dir.display().to_string(),
6711 "present": local_skills_dir.exists(),
6712 "count": skills_count_for(&local_skills_dir),
6713 },
6714 "opencode": {
6715 "path": opencode_skills_dir.display().to_string(),
6716 "present": opencode_skills_dir.exists(),
6717 "count": skills_count_for(&opencode_skills_dir),
6718 },
6719 "claude": {
6720 "path": claude_skills_dir.display().to_string(),
6721 "present": claude_skills_dir.exists(),
6722 "count": skills_count_for(&claude_skills_dir),
6723 },
6724 },
6725 "tools": {
6726 "path": tools_dir.display().to_string(),
6727 "present": tools_dir.exists(),
6728 "count": if tools_dir.exists() { count_dir_entries(&tools_dir) } else { 0 },
6729 },
6730 "plugins": {
6731 "path": plugins_dir.display().to_string(),
6732 "present": plugins_dir.exists(),
6733 "count": if plugins_dir.exists() { count_dir_entries(&plugins_dir) } else { 0 },
6734 },
6735 "storage": {
6736 "spillover": {
6737 "path": crate::tools::truncate::spillover_root()
6738 .map(|p| p.display().to_string())
6739 .unwrap_or_default(),
6740 "present": crate::tools::truncate::spillover_root()
6741 .is_some_and(|p| p.is_dir()),
6742 "count": crate::tools::truncate::spillover_root()
6743 .filter(|p| p.is_dir())
6744 .map(|p| count_dir_entries(&p))
6745 .unwrap_or(0),
6746 },
6747 "stash": {
6748 "path": stash
6749 .path
6750 .as_ref()
6751 .map(|path| path.display().to_string())
6752 .unwrap_or_default(),
6753 "present": stash.present,
6754 "count": stash.count,
6755 "error": stash.error,
6756 },
6757 },
6758 "sandbox": match crate::sandbox::get_platform_sandbox_with_bwrap_preference(
6759 config.prefer_bwrap.unwrap_or(false),
6760 ) {
6761 Some(kind) => json!({"available": true, "kind": kind.to_string()}),
6762 None => json!({"available": false, "kind": null}),
6763 },
6764 "platform": {
6765 "os": std::env::consts::OS,
6766 "arch": std::env::consts::ARCH,
6767 },
6768 "api_connectivity": {
6769 "checked": false,
6770 "status": "not_probed",
6771 "note": "JSON doctor is offline; use `codewhale doctor --probe-api` or `--probe-local` for an explicit live check.",
6772 },
6773 "capability": provider_capability_report(config),
6774 });
6775
6776 println!("{}", serde_json::to_string_pretty(&report)?);
6777 Ok(())
6778}
6779
6780fn run_doctor_context_json(config: &Config, workspace: &Path) -> Result<()> {
6781 let report = crate::context_report::build_headless_context_report(config, workspace);
6782 println!("{}", crate::context_report::context_report_json(&report));
6783 Ok(())
6784}
6785
6786fn provider_capability_report(config: &Config) -> serde_json::Value {
6792 use serde_json::json;
6793
6794 let provider = config.api_provider();
6795 let configured_model = config.default_model();
6796 let route_result =
6797 crate::route_runtime::resolve_runtime_route(config, provider, Some(&configured_model));
6798 let route_error = route_result
6799 .is_err()
6800 .then_some("route_resolution_failed_details_omitted");
6801 let route = route_result.ok();
6802 let resolved_model = route
6803 .as_ref()
6804 .map_or(configured_model.as_str(), |route| route.model.as_str());
6805 let cap = crate::config::provider_capability(provider, resolved_model);
6806 let route_profile = route.as_ref().map(|route| {
6807 crate::model_profile::resolved_capability_profile_for_route(
6808 provider,
6809 resolved_model,
6810 route.candidate.capabilities(),
6811 route.candidate.limits(),
6812 )
6813 });
6814 let context_window = route
6815 .as_ref()
6816 .map_or(cap.context_window, |route| route.context_window.tokens);
6817 let context_window_source = route.as_ref().map_or(
6818 crate::route_runtime::ContextWindowSource::Fallback.label(),
6819 |route| route.context_window.source.label(),
6820 );
6821 let max_output = route_profile
6824 .as_ref()
6825 .and_then(|profile| profile.max_output)
6826 .or(cap.max_output);
6827 let is_exact_kimi_code_k3 = route.as_ref().is_some_and(|route| {
6828 crate::config::is_exact_kimi_code_k3_route(
6829 provider,
6830 &route.candidate.endpoint().base_url,
6831 route.candidate.wire_model_id().as_str(),
6832 )
6833 });
6834 let thinking_supported = is_exact_kimi_code_k3
6835 || route_profile
6836 .as_ref()
6837 .map_or(cap.thinking_supported, |profile| {
6838 profile.supports_reasoning()
6839 });
6840 let cache_telemetry_supported = route_profile
6841 .as_ref()
6842 .map_or(cap.cache_telemetry_supported, |profile| {
6843 profile.prompt_caching.is_supported()
6844 });
6845 let request_payload_mode = route_profile
6846 .as_ref()
6847 .map_or(cap.request_payload_mode, |profile| {
6848 profile.request_payload_mode
6849 });
6850 let alias_deprecation = config.active_deepseek_alias_deprecation();
6851
6852 json!({
6853 "resolved_provider": config.provider_identity_for(provider),
6854 "resolved_model": resolved_model,
6855 "context_window": context_window,
6856 "context_window_source": context_window_source,
6857 "max_output": max_output,
6858 "thinking_supported": thinking_supported,
6859 "cache_telemetry_supported": cache_telemetry_supported,
6860 "request_payload_mode": serde_json::to_value(request_payload_mode).unwrap_or_default(),
6861 "route_error": route_error,
6862 "alias_deprecation": alias_deprecation,
6863 })
6864}
6865
6866fn doctor_route_report(config: &Config) -> serde_json::Value {
6867 use serde_json::json;
6868
6869 let target = doctor_api_target(config);
6870 let provider = config.api_provider();
6871 let redacted_base_url = crate::doctor::structural_url_authority(&target.base_url);
6872 let route_result =
6873 crate::route_runtime::resolve_runtime_route(config, provider, Some(&target.model));
6874 let route_error = route_result
6875 .is_err()
6876 .then_some("route_resolution_failed_details_omitted");
6877 let context_window = route_result
6878 .ok()
6879 .map(|route| {
6880 json!({
6881 "tokens": route.context_window.tokens,
6882 "source": route.context_window.source.label(),
6883 })
6884 })
6885 .unwrap_or_else(|| {
6886 json!({
6887 "tokens": crate::config::provider_capability(provider, &target.model).context_window,
6888 "source": crate::route_runtime::ContextWindowSource::Fallback.label(),
6889 })
6890 });
6891
6892 let route_identity =
6893 crate::config::moonshot_k3_route_display_name(&target.base_url, &target.model);
6894 let credential = resolve_credential_diagnostic(config);
6895
6896 json!({
6897 "provider": target.provider,
6898 "provider_source": doctor_provider_source(config),
6899 "provider_config_table": doctor_provider_config_table(config, provider),
6900 "model": target.model,
6901 "route_identity": route_identity,
6902 "wire_protocol": doctor_wire_protocol(provider),
6903 "base_url": {
6904 "redacted": redacted_base_url,
6905 "class": doctor_base_url_class(provider, &target.base_url),
6906 "fingerprint": crate::utils::redacted_identifier_for_log(&target.base_url),
6907 },
6908 "auth": {
6909 "scheme": doctor_auth_scheme(config),
6910 "source": doctor_api_key_source_label(credential.source),
6911 "availability": credential.availability.label(),
6912 },
6913 "context_window": context_window,
6914 "route_error": route_error,
6915 })
6916}
6917
6918fn doctor_provider_config_table(config: &Config, provider: crate::config::ApiProvider) -> String {
6919 if provider != crate::config::ApiProvider::Custom {
6920 return provider_config_table_key(provider).to_string();
6921 }
6922 if config.uses_legacy_literal_custom_route() {
6923 "root (legacy literal custom)".to_string()
6924 } else {
6925 format!("providers.{}", config.provider_identity_for(provider))
6926 }
6927}
6928
6929fn doctor_provider_source(config: &Config) -> &'static str {
6930 if config
6931 .provider
6932 .as_ref()
6933 .is_some_and(|provider| !provider.trim().is_empty())
6934 {
6935 "config"
6936 } else {
6937 "default"
6938 }
6939}
6940
6941fn doctor_wire_protocol(provider: crate::config::ApiProvider) -> &'static str {
6942 let policy = provider
6943 .metadata()
6944 .map(|metadata| metadata.wire_policy())
6945 .unwrap_or(codewhale_config::provider::WirePolicy::Fixed(
6946 codewhale_config::provider::WireFormat::ChatCompletions,
6947 ));
6948 match policy.fixed() {
6949 Some(codewhale_config::provider::WireFormat::ChatCompletions) => "chat_completions",
6950 Some(codewhale_config::provider::WireFormat::Responses) => "responses",
6951 Some(codewhale_config::provider::WireFormat::AnthropicMessages) => "anthropic_messages",
6952 None => "model_aware",
6953 }
6954}
6955
6956fn doctor_base_url_class(provider: crate::config::ApiProvider, base_url: &str) -> &'static str {
6957 let normalized = base_url.trim_end_matches('/').to_ascii_lowercase();
6958 if normalized.starts_with("http://localhost")
6959 || normalized.starts_with("http://127.0.0.1")
6960 || normalized.starts_with("http://[::1]")
6961 {
6962 return "local";
6963 }
6964 if normalized
6965 == provider
6966 .default_base_url()
6967 .trim_end_matches('/')
6968 .to_ascii_lowercase()
6969 {
6970 "default"
6971 } else {
6972 "custom"
6973 }
6974}
6975
6976fn doctor_auth_scheme(config: &Config) -> &'static str {
6977 let provider = config.api_provider();
6978 if crate::config::auth_mode_disables_api_key(config.auth_mode_for_provider(provider).as_deref())
6979 {
6980 "none"
6981 } else if provider == crate::config::ApiProvider::Anthropic {
6982 "x-api-key"
6983 } else if provider == crate::config::ApiProvider::XiaomiMimo
6984 && doctor_xiaomi_mimo_base_url_uses_token_plan(&config.deepseek_base_url())
6985 {
6986 "api-key"
6987 } else if provider == crate::config::ApiProvider::XiaomiMimo {
6988 "unknown"
6991 } else if matches!(
6992 provider,
6993 crate::config::ApiProvider::Sglang
6994 | crate::config::ApiProvider::Vllm
6995 | crate::config::ApiProvider::Ollama
6996 ) {
6997 "optional_bearer"
6998 } else {
6999 "bearer"
7000 }
7001}
7002
7003fn doctor_xiaomi_mimo_base_url_uses_token_plan(base_url: &str) -> bool {
7004 let normalized = base_url.trim_end_matches('/');
7005 [
7006 crate::config::XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL,
7007 crate::config::XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL,
7008 crate::config::XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL,
7009 ]
7010 .iter()
7011 .any(|candidate| normalized.eq_ignore_ascii_case(candidate.trim_end_matches('/')))
7012}
7013
7014fn doctor_api_key_source_label(source: ApiKeySource) -> &'static str {
7015 match source {
7016 ApiKeySource::ConfigDeclared => "config_declared",
7017 ApiKeySource::EnvDeclared => "env_declared",
7018 ApiKeySource::ExternalAuthDeclared => "external_auth_declared",
7019 ApiKeySource::SecretStoreUnprobed => "secret_store_unprobed",
7020 ApiKeySource::SecretStoreUnavailable => "secret_store_unavailable",
7021 ApiKeySource::OAuth => "oauth_unprobed",
7022 ApiKeySource::ExternalConsent => "external_consent",
7023 ApiKeySource::NoAuth => "none",
7024 ApiKeySource::LocalRuntime => "local_runtime",
7025 ApiKeySource::Unknown => "unknown",
7026 }
7027}
7028
7029fn doctor_search_provider_line(config: &Config) -> String {
7030 let search_provider = config.search_provider_resolution();
7031 let switch_hint = if matches!(
7032 (search_provider.provider, search_provider.source),
7033 (
7034 crate::config::SearchProvider::Firecrawl,
7035 crate::config::SearchProviderSource::Default
7036 )
7037 ) {
7038 "; set [search] provider = \"baidu\" | \"metaso\" | \"volcengine\" for China"
7039 } else {
7040 ""
7041 };
7042
7043 format!(
7044 "search_provider: {} (source: {}{})",
7045 search_provider.provider.as_str(),
7046 search_provider.source.as_str(),
7047 switch_hint
7048 )
7049}
7050
7051fn doctor_search_provider_json(config: &Config) -> serde_json::Value {
7052 use serde_json::json;
7053
7054 let search_provider = config.search_provider_resolution();
7055 json!({
7056 "provider": search_provider.provider.as_str(),
7057 "source": search_provider.source.as_str(),
7058 })
7059}
7060
7061#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7066enum DoctorModelResolution {
7067 Resolved,
7068 ConfiguredOnly,
7069}
7070
7071#[derive(Debug, Clone, PartialEq, Eq)]
7072struct DoctorApiTarget {
7073 provider: String,
7074 base_url: String,
7075 model: String,
7076 resolution: DoctorModelResolution,
7077}
7078
7079#[derive(Debug, Clone, PartialEq, Eq)]
7080struct DoctorStrictToolModeStatus {
7081 enabled: bool,
7082 status: &'static str,
7083 function_strict_sent: bool,
7084 message: String,
7085 recommended_base_url: Option<String>,
7086}
7087
7088fn doctor_api_target(config: &Config) -> DoctorApiTarget {
7089 let provider = config.api_provider();
7090 let (model, resolution) =
7097 match crate::route_runtime::resolve_runtime_route(config, provider, None) {
7098 Ok(route) => (route.model.clone(), DoctorModelResolution::Resolved),
7099 Err(_) => (
7100 config.default_model(),
7101 DoctorModelResolution::ConfiguredOnly,
7102 ),
7103 };
7104 DoctorApiTarget {
7105 provider: config.provider_identity_for(provider),
7106 base_url: config.deepseek_base_url(),
7107 model,
7108 resolution,
7109 }
7110}
7111
7112fn doctor_strict_tool_mode_status(config: &Config) -> DoctorStrictToolModeStatus {
7113 if !config.strict_tool_mode.unwrap_or(false) {
7114 return DoctorStrictToolModeStatus {
7115 enabled: false,
7116 status: "disabled",
7117 function_strict_sent: false,
7118 message: "disabled".to_string(),
7119 recommended_base_url: None,
7120 };
7121 }
7122
7123 let target = doctor_api_target(config);
7124 match known_deepseek_base_url_kind(&target.base_url) {
7125 Some(DeepSeekBaseUrlKind::Beta) => DoctorStrictToolModeStatus {
7126 enabled: true,
7127 status: "ready",
7128 function_strict_sent: true,
7129 message: "enabled; DeepSeek strict schemas use the beta endpoint".to_string(),
7130 recommended_base_url: None,
7131 },
7132 Some(DeepSeekBaseUrlKind::NonBeta) => {
7133 let recommended = recommended_strict_base_url(config, &target.base_url);
7134 DoctorStrictToolModeStatus {
7135 enabled: true,
7136 status: "fallback_non_beta",
7137 function_strict_sent: false,
7138 message:
7139 "enabled, but function.strict is stripped for this non-beta DeepSeek endpoint"
7140 .to_string(),
7141 recommended_base_url: Some(recommended.to_string()),
7142 }
7143 }
7144 None => DoctorStrictToolModeStatus {
7145 enabled: true,
7146 status: "custom_endpoint",
7147 function_strict_sent: true,
7148 message: "enabled; function.strict will be sent to this custom endpoint".to_string(),
7149 recommended_base_url: None,
7150 },
7151 }
7152}
7153
7154fn doctor_strict_tool_mode_report_json(status: &DoctorStrictToolModeStatus) -> serde_json::Value {
7155 serde_json::json!({
7156 "enabled": status.enabled,
7157 "status": status.status,
7158 "function_strict_sent": status.function_strict_sent,
7159 "message": status.message,
7160 "recommended_base_url": status
7161 .recommended_base_url
7162 .as_deref()
7163 .map(crate::doctor::structural_url_authority),
7164 })
7165}
7166
7167#[derive(Debug, Clone, PartialEq, Eq)]
7168struct DoctorTlsStatus {
7169 certificate_verification: bool,
7170 insecure_skip_tls_verify: bool,
7171 provider: String,
7172 message: String,
7173}
7174
7175fn doctor_tls_status(config: &Config) -> DoctorTlsStatus {
7176 let provider = config.provider_identity_for(config.api_provider());
7177 let insecure_skip_tls_verify = config.insecure_skip_tls_verify();
7178 let message = if insecure_skip_tls_verify {
7179 format!(
7180 "TLS certificate verification cannot be disabled for provider {provider}; use SSL_CERT_FILE with a trusted custom CA bundle"
7181 )
7182 } else {
7183 "TLS certificate verification enabled".to_string()
7184 };
7185 DoctorTlsStatus {
7186 certificate_verification: true,
7187 insecure_skip_tls_verify,
7188 provider,
7189 message,
7190 }
7191}
7192
7193#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7194enum DeepSeekBaseUrlKind {
7195 Beta,
7196 NonBeta,
7197}
7198
7199fn known_deepseek_base_url_kind(base_url: &str) -> Option<DeepSeekBaseUrlKind> {
7200 let normalized = base_url.trim_end_matches('/');
7201 if normalized.eq_ignore_ascii_case("https://api.deepseek.com/beta")
7202 || normalized.eq_ignore_ascii_case("https://api.deepseeki.com/beta")
7203 {
7204 Some(DeepSeekBaseUrlKind::Beta)
7205 } else if normalized.eq_ignore_ascii_case("https://api.deepseek.com")
7206 || normalized.eq_ignore_ascii_case("https://api.deepseek.com/v1")
7207 || normalized.eq_ignore_ascii_case("https://api.deepseeki.com")
7208 || normalized.eq_ignore_ascii_case("https://api.deepseeki.com/v1")
7209 {
7210 Some(DeepSeekBaseUrlKind::NonBeta)
7211 } else {
7212 None
7213 }
7214}
7215
7216fn recommended_strict_base_url(_config: &Config, _base_url: &str) -> &'static str {
7217 crate::config::DEFAULT_DEEPSEEK_BASE_URL
7218}
7219
7220fn doctor_timeout_recovery_lines(config: &Config) -> Vec<String> {
7221 let target = doctor_api_target(config);
7222 let mut lines = vec![format!(
7223 "Connection timed out while reaching {}.",
7224 crate::doctor::structural_url_authority(&target.base_url)
7225 )];
7226
7227 match config.api_provider() {
7228 crate::config::ApiProvider::Deepseek
7229 if target.base_url.contains("api.deepseek.com")
7230 && !target.base_url.contains("api.deepseeki.com") =>
7231 {
7232 lines.push(
7233 "If this is a custom DeepSeek-compatible endpoint, set its HTTPS base URL in ~/.codewhale/config.toml and rerun `codewhale doctor`."
7234 .to_string(),
7235 );
7236 }
7237 crate::config::ApiProvider::Deepseek | crate::config::ApiProvider::DeepseekCN => {
7238 lines.push(
7239 "If this is a custom DeepSeek-compatible endpoint, confirm it serves `/v1/models` and `/v1/chat/completions` over HTTPS."
7240 .to_string(),
7241 );
7242 }
7243 _ => {
7244 lines.push(
7245 "Confirm the configured provider endpoint is reachable and OpenAI-compatible for `/v1/models` and `/v1/chat/completions`."
7246 .to_string(),
7247 );
7248 }
7249 }
7250
7251 lines.push(
7252 "Run `codewhale doctor --json` and include `base_url`, `default_text_model`, and `api_connectivity` when filing an issue."
7253 .to_string(),
7254 );
7255 lines
7256}
7257
7258fn run_features_command(config: &Config, command: FeaturesCli) -> Result<()> {
7259 match command.command {
7260 FeaturesSubcommand::List => {
7261 print!("{}", render_feature_table(&config.features()));
7262 Ok(())
7263 }
7264 }
7265}
7266
7267async fn run_models(config: &Config, args: ModelsArgs) -> Result<()> {
7268 use crate::client::DeepSeekClient;
7269
7270 let client = DeepSeekClient::new(config)?;
7271 let mut models = client.list_models().await?;
7272 models.sort_by(|a, b| a.id.cmp(&b.id));
7273
7274 if args.json {
7275 println!("{}", serde_json::to_string_pretty(&models)?);
7276 return Ok(());
7277 }
7278
7279 if models.is_empty() {
7280 println!("No models returned by the API.");
7281 return Ok(());
7282 }
7283
7284 let default_model = config.default_model();
7285
7286 println!("Available models (default: {default_model})");
7287 for model in models {
7288 let marker = if model.id == default_model { "*" } else { " " };
7289 if let Some(owner) = model.owned_by {
7290 println!("{marker} {} ({owner})", model.id);
7291 } else {
7292 println!("{marker} {}", model.id);
7293 }
7294 }
7295
7296 Ok(())
7297}
7298
7299async fn run_speech(config: &Config, args: SpeechArgs) -> Result<()> {
7300 use crate::client::{DeepSeekClient, SpeechSynthesisRequest};
7301 use crate::config::ApiProvider;
7302 use crate::tools::speech::{
7303 DEFAULT_VOICE, SPEECH_MODEL_EXAMPLES, combine_speech_instructions,
7304 default_speech_output_name, describe_speech_voice, encode_voice_clone_sample_data_uri,
7305 infer_speech_model, normalize_speech_format,
7306 };
7307
7308 let SpeechArgs {
7309 text,
7310 output,
7311 output_dir,
7312 model,
7313 voice,
7314 instruction,
7315 voice_prompt,
7316 clone_voice,
7317 format,
7318 json: json_output,
7319 } = args;
7320
7321 if config.api_provider() != ApiProvider::XiaomiMimo {
7322 bail!(
7323 "`speech` requires provider = \"xiaomi-mimo\" (current: {}). Run with `--provider xiaomi-mimo` or set it in config.",
7324 config.api_provider().as_str()
7325 );
7326 }
7327
7328 if text.trim().is_empty() {
7329 bail!("Speech text cannot be empty");
7330 }
7331 let voice_is_data_uri = voice
7332 .as_deref()
7333 .map(str::trim)
7334 .is_some_and(|value| value.starts_with("data:audio/"));
7335 if clone_voice.is_some() && voice.is_some() {
7336 bail!("Use either --clone-voice or --voice for cloned voice data, not both");
7337 }
7338 let model = infer_speech_model(
7339 model.as_deref(),
7340 clone_voice.is_some() || voice_is_data_uri,
7341 voice_prompt.is_some(),
7342 );
7343 let model_lower = model.to_ascii_lowercase();
7344 if !model_lower.contains("tts") {
7345 bail!(
7346 "speech requires a TTS model (examples: {}); got {model}",
7347 SPEECH_MODEL_EXAMPLES.join(", ")
7348 );
7349 }
7350 let is_voice_design = model_lower.contains("voicedesign");
7351 let is_voice_clone = model_lower.contains("voiceclone");
7352
7353 let instruction = combine_speech_instructions(instruction, voice_prompt);
7354 if is_voice_design
7355 && instruction
7356 .as_deref()
7357 .is_none_or(|value| value.trim().is_empty())
7358 {
7359 bail!(
7360 "mimo-v2.5-tts-voicedesign requires --voice-prompt or --instruction to describe the voice"
7361 );
7362 }
7363
7364 let voice = if let Some(clone_path) = clone_voice {
7365 Some(encode_voice_clone_sample_data_uri(&clone_path)?)
7366 } else if is_voice_design {
7367 None
7368 } else if let Some(value) = voice.filter(|value| !value.trim().is_empty()) {
7369 Some(value)
7370 } else if is_voice_clone {
7371 bail!("mimo-v2.5-tts-voiceclone requires --clone-voice <mp3|wav> or --voice <data-uri>");
7372 } else {
7373 Some(DEFAULT_VOICE.to_string())
7374 };
7375 let format = normalize_speech_format(&format).with_context(|| {
7376 format!("Unsupported speech format '{format}' (allowed: wav, mp3, pcm16)")
7377 })?;
7378 let output = output.unwrap_or_else(|| {
7379 output_dir
7380 .or_else(|| config.speech_output_dir())
7381 .unwrap_or_default()
7382 .join(default_speech_output_name(&format))
7383 });
7384
7385 let client = DeepSeekClient::new(config)?;
7386 let response = client
7387 .synthesize_speech(SpeechSynthesisRequest {
7388 model: model.clone(),
7389 text,
7390 instruction,
7391 audio_format: format.clone(),
7392 voice,
7393 })
7394 .await?;
7395
7396 if let Some(parent) = output.parent().filter(|path| !path.as_os_str().is_empty()) {
7397 std::fs::create_dir_all(parent)
7398 .with_context(|| format!("Failed to create output directory {}", parent.display()))?;
7399 }
7400 std::fs::write(&output, &response.audio_bytes)
7401 .with_context(|| format!("Failed to write audio file {}", output.display()))?;
7402
7403 if json_output {
7404 println!(
7405 "{}",
7406 serde_json::to_string_pretty(&serde_json::json!({
7407 "mode": "speech",
7408 "success": true,
7409 "model": response.model,
7410 "format": response.audio_format,
7411 "output": output.display().to_string(),
7412 "bytes": response.audio_bytes.len(),
7413 "voice": response.voice.as_deref().map(describe_speech_voice),
7414 "transcript": response.transcript,
7415 }))?
7416 );
7417 } else {
7418 println!(
7419 "Generated speech: {} ({} bytes, model: {}, format: {})",
7420 output.display(),
7421 response.audio_bytes.len(),
7422 response.model,
7423 response.audio_format
7424 );
7425 }
7426
7427 Ok(())
7428}
7429
7430#[cfg(test)]
7431mod speech_cli_tests {
7432 use super::*;
7433 use crate::tools::speech::{
7434 default_speech_output_name, infer_speech_model, normalize_speech_format,
7435 };
7436
7437 #[test]
7438 fn normalizes_documented_speech_formats() {
7439 assert_eq!(normalize_speech_format("WAV").as_deref(), Some("wav"));
7440 assert_eq!(normalize_speech_format("pcm16").as_deref(), Some("pcm16"));
7441 assert_eq!(normalize_speech_format("pcm").as_deref(), Some("pcm16"));
7442 assert_eq!(normalize_speech_format("flac"), None);
7443 }
7444
7445 #[test]
7446 fn default_speech_output_tracks_requested_format() {
7447 assert_eq!(
7448 PathBuf::from(default_speech_output_name("mp3")),
7449 PathBuf::from("speech.mp3")
7450 );
7451 assert_eq!(
7452 PathBuf::from("audio").join(default_speech_output_name("pcm")),
7453 PathBuf::from("audio").join("speech.pcm16")
7454 );
7455 }
7456
7457 #[test]
7458 fn speech_command_parses_cli_passthrough_smoke() {
7459 let cli = Cli::try_parse_from([
7460 "codewhale-tui",
7461 "speech",
7462 "hello",
7463 "--model",
7464 "tts",
7465 "--format",
7466 "pcm",
7467 "--output-dir",
7468 "audio",
7469 "--voice",
7470 "Mia",
7471 ])
7472 .expect("speech command parses");
7473
7474 let Some(Commands::Speech(args)) = cli.command else {
7475 panic!("expected speech command");
7476 };
7477 assert_eq!(args.text, "hello");
7478 assert_eq!(
7479 infer_speech_model(args.model.as_deref(), false, false),
7480 "mimo-v2.5-tts"
7481 );
7482 assert_eq!(
7483 normalize_speech_format(&args.format).as_deref(),
7484 Some("pcm16")
7485 );
7486 assert_eq!(args.output_dir, Some(PathBuf::from("audio")));
7487 assert_eq!(args.voice.as_deref(), Some("Mia"));
7488 }
7489}
7490
7491async fn test_api_connectivity(config: &Config) -> Result<()> {
7493 use crate::client::DeepSeekClient;
7494 use crate::models::{ContentBlock, Message, MessageRequest};
7495
7496 let client = DeepSeekClient::new(config)?;
7497 let model = client.model().to_string();
7498
7499 if crate::doctor::is_keyless_ds4_route(config) {
7500 return crate::doctor::probe_ds4_models(config).await;
7501 }
7502
7503 let request = MessageRequest {
7505 model: model.clone(),
7506 messages: vec![Message {
7507 role: "user".to_string(),
7508 content: vec![ContentBlock::Text {
7509 text: "hi".to_string(),
7510 cache_control: None,
7511 }],
7512 }],
7513 max_tokens: 1,
7514 system: None,
7515 tools: None,
7516 tool_choice: None,
7517 metadata: None,
7518 thinking: None,
7519 reasoning_effort: Some("off".to_string()),
7521 stream: Some(false),
7522 temperature: None,
7523 top_p: None,
7524 };
7525
7526 let timeout_duration = std::time::Duration::from_secs(15);
7528 match tokio::time::timeout(timeout_duration, client.create_message(request)).await {
7529 Ok(Ok(_response)) => Ok(()),
7530 Ok(Err(e)) => Err(e),
7531 Err(_) => anyhow::bail!("Request timeout after 15 seconds"),
7532 }
7533}
7534
7535fn rustc_version() -> String {
7536 let Some(mut cmd) = crate::dependencies::RustC::command() else {
7537 return "unknown".to_string();
7538 };
7539 let Ok(output) = cmd.arg("--version").output() else {
7540 return "unknown".to_string();
7541 };
7542 String::from_utf8(output.stdout)
7543 .map(|s| s.trim().to_string())
7544 .unwrap_or_else(|_| "unknown".to_string())
7545}
7546
7547fn sessions_resume_command() -> &'static str {
7549 "codewhale resume"
7550}
7551
7552fn list_sessions(limit: usize, search: Option<String>) -> Result<()> {
7553 use crate::palette;
7554 use colored::Colorize;
7555 use session_manager::{SessionManager, format_session_line};
7556
7557 let (action_r, action_g, action_b) = palette::WHALE_ACTION_RGB;
7558 let (human_r, human_g, human_b) = palette::WHALE_HUMAN_RGB;
7559 let (sky_r, sky_g, sky_b) = palette::WHALE_INFO_RGB;
7560 let (aqua_r, aqua_g, aqua_b) = palette::WHALE_INFO_RGB;
7561
7562 let manager = SessionManager::default_location()?;
7563
7564 let sessions = if let Some(query) = search {
7565 manager.search_sessions(&query)?
7566 } else {
7567 manager.list_sessions()?
7568 };
7569
7570 if sessions.is_empty() {
7571 println!("{}", "No sessions found.".truecolor(sky_r, sky_g, sky_b));
7572 println!(
7573 "Start a new session with: {}",
7574 "codewhale".truecolor(human_r, human_g, human_b)
7575 );
7576 return Ok(());
7577 }
7578
7579 println!(
7580 "{}",
7581 "Saved Sessions"
7582 .truecolor(action_r, action_g, action_b)
7583 .bold()
7584 );
7585 println!("{}", "==============".truecolor(sky_r, sky_g, sky_b));
7586 println!();
7587
7588 for (i, session) in sessions.iter().take(limit).enumerate() {
7589 let line = format_session_line(session);
7590 if i == 0 {
7591 println!(" {} {}", "*".truecolor(aqua_r, aqua_g, aqua_b), line);
7592 } else {
7593 println!(" {line}");
7594 }
7595 }
7596
7597 let total = sessions.len();
7598 if total > limit {
7599 println!();
7600 println!(
7601 " {} more session(s). Use --limit to show more.",
7602 total - limit
7603 );
7604 }
7605
7606 println!();
7607 println!(
7608 "Resume with: {} {}",
7609 sessions_resume_command().truecolor(action_r, action_g, action_b),
7610 "<session-id>".dimmed()
7611 );
7612 println!(
7613 "Continue latest in this workspace: {}",
7614 "codewhale --continue".truecolor(action_r, action_g, action_b)
7615 );
7616
7617 Ok(())
7618}
7619
7620fn init_project() -> Result<()> {
7622 use crate::palette;
7623 use colored::Colorize;
7624 use project_context::create_default_agents_md;
7625
7626 let (sky_r, sky_g, sky_b) = palette::WHALE_INFO_RGB;
7627 let (aqua_r, aqua_g, aqua_b) = palette::WHALE_INFO_RGB;
7628 let (red_r, red_g, red_b) = palette::WHALE_ERROR_RGB;
7629
7630 let workspace = std::env::current_dir()?;
7631 let agents_path = workspace.join("AGENTS.md");
7632
7633 if agents_path.exists() {
7634 println!(
7635 "{} AGENTS.md already exists at {}",
7636 "!".truecolor(sky_r, sky_g, sky_b),
7637 agents_path.display()
7638 );
7639 return Ok(());
7640 }
7641
7642 match create_default_agents_md(&workspace) {
7643 Ok(path) => {
7644 println!(
7645 "{} Created {}",
7646 "✓".truecolor(aqua_r, aqua_g, aqua_b),
7647 path.display()
7648 );
7649 println!();
7650 println!("Edit this file to customize how the AI agent works with your project.");
7651 println!("The instructions will be loaded automatically when you run codewhale.");
7652 }
7653 Err(e) => {
7654 println!(
7655 "{} Failed to create AGENTS.md: {}",
7656 "✗".truecolor(red_r, red_g, red_b),
7657 e
7658 );
7659 }
7660 }
7661
7662 Ok(())
7663}
7664
7665fn resolve_workspace(cli: &Cli) -> PathBuf {
7666 cli.workspace
7667 .clone()
7668 .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
7669}
7670
7671fn load_config_from_cli(cli: &Cli) -> Result<Config> {
7672 load_config_from_cli_with_effective_profile(cli).map(|(config, _)| config)
7673}
7674
7675fn load_doctor_config_from_cli(cli: &Cli, args: &DoctorArgs) -> Result<Config> {
7680 if args.probe_api || args.probe_local {
7681 return load_config_from_cli(cli);
7682 }
7683 load_structural_config_from_cli(cli)
7684}
7685
7686fn load_structural_config_from_cli(cli: &Cli) -> Result<Config> {
7687 let profile = effective_config_profile(cli);
7688 let mut config = Config::load_structural(cli.config.clone(), profile.as_deref())?;
7689 if let Ok(settings) = crate::settings::Settings::load_read_only() {
7690 apply_saved_reasoning_preference(&mut config, &settings);
7691 }
7692 cli.feature_toggles.apply(&mut config)?;
7693 Ok(config)
7694}
7695
7696fn effective_config_profile(cli: &Cli) -> Option<String> {
7697 cli.profile
7698 .clone()
7699 .or_else(|| std::env::var("CODEWHALE_PROFILE").ok())
7700 .or_else(|| std::env::var("DEEPSEEK_PROFILE").ok())
7701}
7702
7703fn load_config_from_cli_with_effective_profile(cli: &Cli) -> Result<(Config, Option<String>)> {
7704 let profile = effective_config_profile(cli);
7705 let mut config = Config::load(cli.config.clone(), profile.as_deref())?;
7706 if let Ok(settings) = crate::settings::Settings::load_read_only() {
7710 apply_saved_reasoning_preference(&mut config, &settings);
7711 }
7712 cli.feature_toggles.apply(&mut config)?;
7713 Ok((config, profile))
7714}
7715
7716fn apply_saved_reasoning_preference(config: &mut Config, settings: &crate::settings::Settings) {
7724 let Some(reasoning_effort) = settings.reasoning_effort.as_ref() else {
7725 return;
7726 };
7727 config.reasoning_effort = Some(reasoning_effort.clone());
7728 config.reasoning_effort_inferred_from_legacy_alias = false;
7729}
7730
7731fn read_api_key_from_stdin() -> Result<String> {
7732 let mut stdin = io::stdin();
7733 if stdin.is_terminal() {
7734 bail!("No API key provided. Pass --api-key or pipe one via stdin.");
7735 }
7736 let mut buffer = String::new();
7737 stdin.read_to_string(&mut buffer)?;
7738 let api_key = buffer.trim().to_string();
7739 if api_key.is_empty() {
7740 bail!("No API key provided via stdin.");
7741 }
7742 Ok(api_key)
7743}
7744
7745fn run_login(api_key: Option<String>) -> Result<()> {
7746 let api_key = match api_key {
7747 Some(key) => key,
7748 None => read_api_key_from_stdin()?,
7749 };
7750 let saved = config::save_api_key(&api_key)?;
7751 println!("Saved API key to {}", saved.describe());
7752 Ok(())
7753}
7754
7755fn run_logout() -> Result<()> {
7756 config::clear_api_key()?;
7757 println!("Cleared saved API key.");
7758 Ok(())
7759}
7760
7761async fn run_xai_device_auth(config_path: Option<&Path>) -> Result<()> {
7762 let pending = xai_oauth::device_code_login().await?;
7763 let activation = xai_oauth::activate_device_login(pending, config_path, None)?;
7764 println!(
7765 "xAI OAuth is ready; activated {} via {}",
7766 codewhale_config::quote_os_path(&activation.auth_path),
7767 codewhale_config::quote_os_path(&activation.config_path)
7768 );
7769 Ok(())
7770}
7771
7772fn resolve_session_id(session_id: Option<String>, last: bool, workspace: &Path) -> Result<String> {
7773 if last {
7774 return latest_session_id_for_workspace(workspace)?.ok_or_else(|| {
7775 anyhow!(
7776 "No saved sessions found for workspace {}. Use `codewhale sessions` to list all sessions, or `codewhale resume <SESSION_ID>` to resume one explicitly.",
7777 workspace.display()
7778 )
7779 });
7780 }
7781 if let Some(id) = session_id {
7782 return Ok(id);
7783 }
7784 pick_session_id()
7785}
7786
7787fn latest_session_id_for_workspace(workspace: &Path) -> std::io::Result<Option<String>> {
7788 let manager = SessionManager::default_location()?;
7789 Ok(manager
7790 .get_latest_session_for_workspace(workspace)?
7791 .map(|session| session.id))
7792}
7793
7794fn fork_session(
7795 config: &Config,
7796 session_id: Option<String>,
7797 last: bool,
7798 workspace: &Path,
7799) -> Result<String> {
7800 let manager = SessionManager::default_location()?;
7801 let saved = if last {
7802 let Some(meta) = manager.get_latest_session_for_workspace(workspace)? else {
7803 bail!(
7804 "No saved sessions found for workspace {}.",
7805 workspace.display()
7806 );
7807 };
7808 manager.load_session(&meta.id)?
7809 } else {
7810 let id = resolve_session_id(session_id, false, workspace)?;
7811 manager.load_session_by_prefix(&id)?
7812 };
7813 let saved_provider_identity = saved
7814 .metadata
7815 .model_provider_id
7816 .as_deref()
7817 .filter(|identity| !identity.trim().is_empty())
7818 .unwrap_or(&saved.metadata.model_provider);
7819 let provider_identity = config
7820 .resolve_persisted_provider_identity(
7821 Some(&saved.metadata.model_provider),
7822 saved.metadata.model_provider_id.as_deref(),
7823 )
7824 .map_err(anyhow::Error::msg)
7825 .with_context(|| {
7826 format!(
7827 "saved session provider '{}' is unavailable; fork will not fall back",
7828 saved_provider_identity
7829 )
7830 })?;
7831
7832 let system_prompt = saved
7833 .system_prompt
7834 .as_ref()
7835 .map(|text| SystemPrompt::Text(text.clone()));
7836 let mut forked = create_saved_session(
7837 &saved.messages,
7838 &saved.metadata.model,
7839 &saved.metadata.workspace,
7840 saved.metadata.total_tokens,
7841 system_prompt.as_ref(),
7842 );
7843 forked.metadata.set_model_provider_route(
7844 provider_identity.provider.as_str(),
7845 provider_identity.persisted_id(),
7846 );
7847 forked.metadata.copy_cost_from(&saved.metadata);
7848 forked.metadata.mark_forked_from(&saved.metadata);
7849 manager.save_session(&forked)?;
7850
7851 let source_title = saved.metadata.title.trim();
7852 let source_label = if source_title.is_empty() {
7853 "session".to_string()
7854 } else {
7855 format!("\"{source_title}\"")
7856 };
7857 println!(
7858 "Forked {source_label} ({source_id}) → new session {new_id}",
7859 source_id = truncate_id(&saved.metadata.id),
7860 new_id = truncate_id(&forked.metadata.id),
7861 );
7862
7863 Ok(forked.metadata.id)
7864}
7865
7866fn pick_session_id() -> Result<String> {
7867 let manager = SessionManager::default_location()?;
7868 let sessions = manager.list_sessions()?;
7869 if sessions.is_empty() {
7870 bail!("No saved sessions found.");
7871 }
7872
7873 println!("Select a session to resume:");
7874 for (idx, session) in sessions.iter().enumerate() {
7875 println!(" {:>2}. {} ({})", idx + 1, session.title, session.id);
7876 }
7877 print!("Enter a number (or press Enter to cancel): ");
7878 io::stdout().flush()?;
7879
7880 let mut input = String::new();
7881 io::stdin().read_line(&mut input)?;
7882 let input = input.trim();
7883 if input.is_empty() {
7884 bail!("No session selected.");
7885 }
7886 let idx: usize = input
7887 .parse()
7888 .map_err(|_| anyhow::anyhow!("Invalid input"))?;
7889 let session = sessions
7890 .get(idx.saturating_sub(1))
7891 .ok_or_else(|| anyhow::anyhow!("Selection out of range"))?;
7892 Ok(session.id.clone())
7893}
7894
7895async fn run_review(config: &Config, args: ReviewArgs) -> Result<()> {
7896 use crate::client::DeepSeekClient;
7897
7898 let diff = collect_diff(&args)?;
7899 if diff.trim().is_empty() {
7900 bail!("No diff to review.");
7901 }
7902 validate_review_receipt_args(&args)?;
7903 if args.check_receipt {
7904 return run_review_receipt_check(&diff, &args);
7905 }
7906
7907 let model = resolve_review_model(config, args.model.as_deref());
7908 let route = resolve_cli_exec_route(config, &model, &diff, args.model.is_none()).await?;
7909 let execution_config = config_for_cli_route(config, &route);
7910 let route_provider = execution_config.provider_identity_for(route.provider);
7911 let model = route.model.clone();
7912 let user_prompt =
7913 format!("Review the following diff and provide feedback:\n\n{diff}\n\nEnd of diff.");
7914 let reasoning_effort = route.reasoning_effort.and_then(|effort| {
7915 cli_reasoning_effort_value_for_prompt(&execution_config, &model, effort, &user_prompt)
7916 });
7917
7918 let system = SystemPrompt::Text(
7919 "You are a senior code reviewer. Focus on bugs, risks, behavioral regressions, and missing tests. \
7920Provide findings ordered by severity with file references, then open questions, then a brief summary."
7921 .to_string(),
7922 );
7923 let client = DeepSeekClient::new(&execution_config)?;
7924 let request_route = client.effective_route_envelope(&model, chrono::Utc::now());
7925 let request = MessageRequest {
7926 model: model.clone(),
7927 messages: vec![Message {
7928 role: "user".to_string(),
7929 content: vec![ContentBlock::Text {
7930 text: user_prompt,
7931 cache_control: None,
7932 }],
7933 }],
7934 max_tokens: crate::route_budget::effective_max_output_tokens_for_route(
7935 request_route.provider,
7936 &request_route.model,
7937 None,
7938 ),
7939 system: Some(system),
7940 tools: None,
7941 tool_choice: None,
7942 metadata: None,
7943 thinking: None,
7944 reasoning_effort,
7945 stream: Some(false),
7946 temperature: None,
7947 top_p: None,
7948 };
7949
7950 let response = client.create_message(request).await?;
7951 let review_stop_reason = response.stop_reason.clone();
7952 let review_incomplete = crate::models::is_incomplete_stop_reason(review_stop_reason.as_deref());
7953 let mut output = String::new();
7954 for block in response.content {
7955 if let ContentBlock::Text { text, .. } = block {
7956 output.push_str(&text);
7957 }
7958 }
7959 let receipt = if args.write_receipt && !review_incomplete {
7962 let parsed_output = crate::tools::review::ReviewOutput::from_str(&output);
7963 let receipt = crate::tools::review::build_review_receipt(
7964 review_target_label(&args),
7965 &diff,
7966 &route_provider,
7967 &model,
7968 &parsed_output,
7969 &output,
7970 Vec::new(),
7971 );
7972 let path =
7973 crate::tools::review::write_review_receipt(&receipt, args.receipt_path.as_deref())?;
7974 Some((path, receipt))
7975 } else {
7976 None
7977 };
7978 let review_error = review_incomplete.then(|| {
7979 format!(
7980 "Model response incomplete: provider stop reason `{}`; the partial review was not accepted.",
7981 crate::models::stop_reason_detail(review_stop_reason.as_deref())
7982 )
7983 });
7984 if args.json {
7985 println!(
7986 "{}",
7987 serde_json::to_string_pretty(&serde_json::json!({
7988 "mode": "review",
7989 "provider": route_provider,
7990 "model": model,
7991 "success": !review_incomplete,
7992 "content": output,
7993 "stop_reason": review_stop_reason,
7994 "error": review_error,
7995 "receipt_path": receipt
7996 .as_ref()
7997 .map(|(path, _)| path.display().to_string()),
7998 "receipt": receipt.as_ref().map(|(_, receipt)| receipt),
7999 }))?
8000 );
8001 if let Some(error) = review_error {
8002 anyhow::bail!(error);
8003 }
8004 } else {
8005 println!("{output}");
8006 if let Some((path, _)) = receipt {
8007 eprintln!("Review receipt written: {}", path.display());
8008 }
8009 if let Some(error) = review_error {
8010 anyhow::bail!(error);
8011 }
8012 }
8013 Ok(())
8014}
8015
8016fn resolve_review_model(config: &Config, explicit_model: Option<&str>) -> String {
8017 explicit_model
8018 .map(str::trim)
8019 .filter(|model| !model.is_empty())
8020 .map(str::to_string)
8021 .unwrap_or_else(|| config.default_model())
8022}
8023
8024fn validate_review_receipt_args(args: &ReviewArgs) -> Result<()> {
8025 if args.receipt_path.is_some() && !args.write_receipt && !args.check_receipt {
8026 bail!("--receipt-path requires --write-receipt or --check-receipt");
8027 }
8028 if args.write_receipt && args.check_receipt {
8029 bail!("--write-receipt and --check-receipt are mutually exclusive");
8030 }
8031 Ok(())
8032}
8033
8034fn run_review_receipt_check(diff: &str, args: &ReviewArgs) -> Result<()> {
8035 let (path, receipt) = if let Some(path) = args.receipt_path.as_ref() {
8036 (
8037 path.clone(),
8038 crate::tools::review::read_review_receipt(path)
8039 .with_context(|| format!("failed to read review receipt {}", path.display()))?,
8040 )
8041 } else {
8042 crate::tools::review::latest_review_receipt_for_diff(diff)?.ok_or_else(|| {
8043 anyhow!(
8044 "No review receipt found for the current diff. Run `codewhale review --write-receipt` first, or pass --receipt-path."
8045 )
8046 })?
8047 };
8048 let validation =
8049 crate::tools::review::validate_review_receipt_for_diff(diff, &receipt, Some(path.clone()));
8050
8051 if args.json {
8052 println!(
8053 "{}",
8054 serde_json::to_string_pretty(&serde_json::json!({
8055 "mode": "review_receipt_check",
8056 "success": validation.passed,
8057 "validation": review_receipt_validation_public_json(&validation),
8058 }))?
8059 );
8060 } else if validation.passed {
8061 println!("Review receipt valid: {}", path.display());
8062 }
8063
8064 if !validation.passed {
8065 bail!("Review receipt check failed: {}", validation.reason);
8066 }
8067 Ok(())
8068}
8069
8070fn review_receipt_validation_public_json(
8071 validation: &crate::tools::review::ReviewReceiptValidation,
8072) -> serde_json::Value {
8073 let unresolved_risk = validation.unresolved_risk.as_ref();
8074 serde_json::json!({
8075 "passed": validation.passed,
8076 "status": review_receipt_validation_status(validation),
8077 "diff_fingerprint": validation.diff_fingerprint.as_str(),
8078 "receipt_fingerprint": validation.receipt_fingerprint.as_deref(),
8079 "unresolved": unresolved_risk.is_some_and(|risk| risk.unresolved),
8080 "risk_level": unresolved_risk.map(|risk| risk.level.as_str()),
8081 })
8082}
8083
8084fn review_receipt_validation_status(
8085 validation: &crate::tools::review::ReviewReceiptValidation,
8086) -> &'static str {
8087 if validation.passed {
8088 "valid"
8089 } else if validation
8090 .receipt_fingerprint
8091 .as_deref()
8092 .is_some_and(|fingerprint| fingerprint != validation.diff_fingerprint.as_str())
8093 {
8094 "diff_mismatch"
8095 } else if validation
8096 .unresolved_risk
8097 .as_ref()
8098 .is_some_and(|risk| risk.unresolved)
8099 {
8100 "unresolved_risk"
8101 } else if validation
8102 .reason
8103 .starts_with("unsupported review receipt schema version")
8104 {
8105 "unsupported_schema"
8106 } else if validation.reason.starts_with("review receipt check ") {
8107 "check_failed"
8108 } else {
8109 "invalid"
8110 }
8111}
8112
8113async fn run_pr(
8117 cli: &Cli,
8118 config: &Config,
8119 number: u32,
8120 repo: Option<&str>,
8121 checkout: bool,
8122 pending_telemetry_notice: Option<crate::telemetry_notice::PendingTelemetryNotice>,
8123 plugin_registry: Arc<crate::plugins::PluginRegistry>,
8124) -> Result<()> {
8125 if !is_command_available("gh") {
8126 bail!(
8127 "`gh` CLI not found on PATH. Install GitHub CLI \
8128 (https://cli.github.com) and authenticate (`gh auth login`) \
8129 so `codewhale pr <N>` can fetch PR metadata and the diff."
8130 );
8131 }
8132
8133 let view = run_gh_pr_view(number, repo)?;
8134 let diff = run_gh_pr_diff(number, repo)?;
8135
8136 if checkout {
8137 match run_gh_pr_checkout(number, repo) {
8138 Ok(()) => eprintln!("Checked out PR #{number} into the current workspace."),
8139 Err(err) => eprintln!(
8140 "warning: gh pr checkout #{number} failed ({err}). Continuing without checkout."
8141 ),
8142 }
8143 }
8144
8145 let prompt = format_pr_prompt(number, &view, &diff);
8146 let resume_session_id = if cli.continue_session {
8147 let workspace = resolve_workspace(cli);
8148 latest_session_id_for_workspace(&workspace).ok().flatten()
8149 } else {
8150 cli.resume.clone()
8151 };
8152 run_interactive(
8153 cli,
8154 config,
8155 resume_session_id,
8156 Some(tui::InitialInput::Prefill(prompt)),
8157 pending_telemetry_notice,
8158 plugin_registry,
8159 )
8160 .await
8161}
8162
8163fn is_command_available(name: &str) -> bool {
8177 let Some(path) = std::env::var_os("PATH") else {
8178 return false;
8179 };
8180 for dir in std::env::split_paths(&path) {
8181 let candidate = dir.join(name);
8182 if candidate.is_file() {
8183 return true;
8184 }
8185 #[cfg(windows)]
8186 {
8187 if candidate.extension().is_none() && candidate.with_extension("exe").is_file() {
8191 return true;
8192 }
8193 }
8194 }
8195 false
8196}
8197
8198#[derive(Debug, Clone, Default)]
8199struct GhPullRequest {
8200 title: String,
8201 body: String,
8202 base: String,
8203 head: String,
8204 url: String,
8205}
8206
8207fn run_gh_pr_view(number: u32, repo: Option<&str>) -> Result<GhPullRequest> {
8208 let mut cmd = crate::dependencies::Gh::command()
8209 .ok_or_else(|| anyhow::anyhow!("gh not found on PATH"))?;
8210 cmd.arg("pr").arg("view").arg(number.to_string());
8211 if let Some(r) = repo {
8212 cmd.arg("--repo").arg(r);
8213 }
8214 cmd.arg("--json")
8215 .arg("title,body,baseRefName,headRefName,url");
8216 let output = cmd
8217 .output()
8218 .map_err(|e| anyhow::anyhow!("Failed to run `gh pr view`: {e}"))?;
8219 if !output.status.success() {
8220 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
8221 bail!("gh pr view #{number} failed: {stderr}");
8222 }
8223 let raw = String::from_utf8_lossy(&output.stdout).to_string();
8224 let value: serde_json::Value = serde_json::from_str(&raw)
8225 .map_err(|e| anyhow::anyhow!("gh pr view returned non-JSON output: {e}"))?;
8226 let pick = |key: &str| {
8227 value
8228 .get(key)
8229 .and_then(serde_json::Value::as_str)
8230 .unwrap_or_default()
8231 .to_string()
8232 };
8233 Ok(GhPullRequest {
8234 title: pick("title"),
8235 body: pick("body"),
8236 base: pick("baseRefName"),
8237 head: pick("headRefName"),
8238 url: pick("url"),
8239 })
8240}
8241
8242fn run_gh_pr_diff(number: u32, repo: Option<&str>) -> Result<String> {
8243 let mut cmd = crate::dependencies::Gh::command()
8244 .ok_or_else(|| anyhow::anyhow!("gh not found on PATH"))?;
8245 cmd.arg("pr").arg("diff").arg(number.to_string());
8246 if let Some(r) = repo {
8247 cmd.arg("--repo").arg(r);
8248 }
8249 let output = cmd
8250 .output()
8251 .map_err(|e| anyhow::anyhow!("Failed to run `gh pr diff`: {e}"))?;
8252 if !output.status.success() {
8253 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
8254 bail!("gh pr diff #{number} failed: {stderr}");
8255 }
8256 Ok(String::from_utf8_lossy(&output.stdout).to_string())
8257}
8258
8259fn run_gh_pr_checkout(number: u32, repo: Option<&str>) -> Result<()> {
8260 let mut cmd = crate::dependencies::Gh::command()
8261 .ok_or_else(|| anyhow::anyhow!("gh not found on PATH"))?;
8262 cmd.arg("pr").arg("checkout").arg(number.to_string());
8263 if let Some(r) = repo {
8264 cmd.arg("--repo").arg(r);
8265 }
8266 let output = cmd
8267 .output()
8268 .map_err(|e| anyhow::anyhow!("Failed to run `gh pr checkout`: {e}"))?;
8269 if !output.status.success() {
8270 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
8271 bail!("gh pr checkout #{number} failed: {stderr}");
8272 }
8273 Ok(())
8274}
8275
8276fn format_pr_prompt(number: u32, view: &GhPullRequest, diff: &str) -> String {
8281 const MAX_DIFF_BYTES: usize = 200 * 1024;
8282 let diff_section = if diff.len() > MAX_DIFF_BYTES {
8283 let cut = (0..=MAX_DIFF_BYTES)
8284 .rev()
8285 .find(|&i| diff.is_char_boundary(i))
8286 .unwrap_or(0);
8287 format!(
8288 "{}\n\n[…diff truncated at {} KiB; ask me to fetch more if needed]\n",
8289 &diff[..cut],
8290 MAX_DIFF_BYTES / 1024
8291 )
8292 } else {
8293 diff.to_string()
8294 };
8295 let body = if view.body.trim().is_empty() {
8296 "(no description)".to_string()
8297 } else {
8298 view.body.trim().to_string()
8299 };
8300 let title = if view.title.trim().is_empty() {
8301 format!("(PR #{number})")
8302 } else {
8303 view.title.trim().to_string()
8304 };
8305 let branches = match (view.base.is_empty(), view.head.is_empty()) {
8306 (false, false) => format!("{} ← {}", view.base, view.head),
8307 (false, true) => view.base.clone(),
8308 (true, false) => view.head.clone(),
8309 _ => "(unknown)".to_string(),
8310 };
8311 format!(
8312 "Review PR #{number} — {title}\n\
8313 \n\
8314 URL: {url}\n\
8315 Branches: {branches}\n\
8316 \n\
8317 ## Description\n\
8318 \n\
8319 {body}\n\
8320 \n\
8321 ## Diff\n\
8322 \n\
8323 ```diff\n\
8324 {diff_section}\n\
8325 ```\n",
8326 url = if view.url.is_empty() {
8327 "(unavailable)"
8328 } else {
8329 view.url.as_str()
8330 },
8331 )
8332}
8333
8334fn collect_diff(args: &ReviewArgs) -> Result<String> {
8335 let mut cmd = crate::dependencies::Git::command()
8336 .ok_or_else(|| anyhow::anyhow!("git not found on PATH"))?;
8337 cmd.arg("diff");
8338 if args.staged {
8339 cmd.arg("--cached");
8340 }
8341 if let Some(base) = &args.base {
8342 cmd.arg(format!("{base}...HEAD"));
8343 }
8344 if let Some(path) = &args.path {
8345 cmd.arg("--").arg(path);
8346 }
8347
8348 let output = cmd
8349 .output()
8350 .map_err(|e| anyhow::anyhow!("Failed to run git diff. Is git installed? ({e})"))?;
8351 if !output.status.success() {
8352 let stderr = String::from_utf8_lossy(&output.stderr);
8353 bail!("git diff failed: {}", stderr.trim());
8354 }
8355 let mut diff = String::from_utf8_lossy(&output.stdout).to_string();
8356 if diff.len() > args.max_chars {
8357 diff = crate::utils::truncate_with_ellipsis(&diff, args.max_chars, "\n...[truncated]\n");
8358 }
8359 Ok(diff)
8360}
8361
8362fn review_target_label(args: &ReviewArgs) -> String {
8363 let mut label = if args.staged {
8364 "staged".to_string()
8365 } else if let Some(base) = args
8366 .base
8367 .as_deref()
8368 .map(str::trim)
8369 .filter(|base| !base.is_empty())
8370 {
8371 format!("base:{base}")
8372 } else {
8373 "working-tree".to_string()
8374 };
8375 if let Some(path) = &args.path {
8376 label.push(' ');
8377 label.push_str(path.to_string_lossy().as_ref());
8378 }
8379 label
8380}
8381
8382fn run_apply(args: ApplyArgs) -> Result<()> {
8383 let patch = if let Some(path) = args.patch_file {
8384 std::fs::read_to_string(&path)
8385 .map_err(|e| anyhow::anyhow!("Failed to read patch {}: {}", path.display(), e))?
8386 } else {
8387 read_patch_from_stdin()?
8388 };
8389 if patch.trim().is_empty() {
8390 bail!("Patch is empty.");
8391 }
8392
8393 let mut tmp = NamedTempFile::new()?;
8394 tmp.write_all(patch.as_bytes())?;
8395 let tmp_path = tmp.path().to_path_buf();
8396
8397 let output = crate::dependencies::Git::command()
8398 .ok_or_else(|| anyhow::anyhow!("git not found on PATH"))?
8399 .arg("apply")
8400 .arg("--whitespace=nowarn")
8401 .arg(&tmp_path)
8402 .output()
8403 .map_err(|e| anyhow::anyhow!("Failed to run git apply: {e}"))?;
8404
8405 if !output.status.success() {
8406 let stderr = String::from_utf8_lossy(&output.stderr);
8407 bail!("git apply failed: {}", stderr.trim());
8408 }
8409 println!("Applied patch successfully.");
8410 Ok(())
8411}
8412
8413fn read_patch_from_stdin() -> Result<String> {
8414 let mut stdin = io::stdin();
8415 if stdin.is_terminal() {
8416 bail!("No patch file provided and stdin is empty.");
8417 }
8418 let mut buffer = String::new();
8419 stdin.read_to_string(&mut buffer)?;
8420 Ok(buffer)
8421}
8422
8423async fn run_mcp_command(
8424 config: &Config,
8425 workspace: &Path,
8426 command: McpCommand,
8427 plugins: &crate::plugins::PluginRegistry,
8428) -> Result<()> {
8429 let config_path = config.mcp_config_path();
8430 match command {
8431 McpCommand::Init { force } => {
8432 let status = init_mcp_config(&config_path, force)?;
8433 match status {
8434 WriteStatus::Created => {
8435 println!("Created MCP config at {}", config_path.display());
8436 }
8437 WriteStatus::Overwritten => {
8438 println!("Overwrote MCP config at {}", config_path.display());
8439 }
8440 WriteStatus::SkippedExists => {
8441 println!(
8442 "MCP config already exists at {} (use --force to overwrite)",
8443 config_path.display()
8444 );
8445 }
8446 }
8447 println!("Edit the file, then run `codewhale mcp list` or `codewhale mcp tools`.");
8448 Ok(())
8449 }
8450 McpCommand::List => {
8451 let cfg = crate::mcp::load_config_with_workspace_and_plugins(
8452 &config_path,
8453 workspace,
8454 plugins,
8455 )?;
8456 if cfg.servers.is_empty() {
8457 println!(
8458 "No MCP servers configured in {} or {}",
8459 config_path.display(),
8460 crate::mcp::workspace_mcp_config_path(workspace).display()
8461 );
8462 return Ok(());
8463 }
8464 println!("MCP servers ({}):", cfg.servers.len());
8465 for (name, server) in cfg.servers {
8466 let status = if server.enabled && !server.disabled {
8467 "enabled"
8468 } else {
8469 "disabled"
8470 };
8471 let auth_status = crate::mcp::oauth::auth_status_for_server(&name, &server).await;
8472 let auth = if auth_status == crate::mcp::oauth::McpAuthStatus::Unsupported {
8473 String::new()
8474 } else {
8475 format!(
8476 " auth={}",
8477 auth_status
8478 .to_string()
8479 .to_ascii_lowercase()
8480 .replace(' ', "-")
8481 )
8482 };
8483 let args = if server.args.is_empty() {
8484 "".to_string()
8485 } else {
8486 format!(" {}", server.args.join(" "))
8487 };
8488 let cmd_str = if let Some(cmd) = server.command {
8489 format!("{cmd}{args}")
8490 } else if let Some(url) = server.url {
8491 url
8492 } else {
8493 "unknown".to_string()
8494 };
8495 let required = if server.required { " required" } else { "" };
8496 println!(" - {name} [{status}{required}{auth}] {cmd_str}");
8497 }
8498 Ok(())
8499 }
8500 McpCommand::Connect { server } => {
8501 let mut pool = McpPool::from_config_path_with_workspace_and_plugins(
8502 &config_path,
8503 workspace,
8504 std::sync::Arc::new(plugins.clone()),
8505 )?;
8506 if let Some(name) = server {
8507 if let Err(err) = pool.get_or_connect(&name).await {
8508 if crate::mcp::oauth::error_looks_auth_required(&err) {
8509 let hint = crate::mcp::oauth::auth_required_login_hint(&name);
8510 return Err(err).context(hint);
8511 }
8512 return Err(err);
8513 }
8514 println!("Connected to MCP server: {name}");
8515 } else {
8516 let errors = pool.connect_all().await;
8517 if errors.is_empty() {
8518 println!("Connected to all configured MCP servers.");
8519 } else {
8520 for (name, err) in errors {
8521 eprintln!("Failed to connect {name}: {err:#}");
8522 if crate::mcp::oauth::error_looks_auth_required(&err) {
8523 eprintln!(" {}", crate::mcp::oauth::auth_required_login_hint(&name));
8524 }
8525 }
8526 }
8527 }
8528 Ok(())
8529 }
8530 McpCommand::Tools { server } => {
8531 let mut pool = McpPool::from_config_path_with_workspace_and_plugins(
8532 &config_path,
8533 workspace,
8534 std::sync::Arc::new(plugins.clone()),
8535 )?;
8536 if let Some(name) = server {
8537 let conn = match pool.get_or_connect(&name).await {
8538 Ok(conn) => conn,
8539 Err(err) => {
8540 if crate::mcp::oauth::error_looks_auth_required(&err) {
8541 let hint = crate::mcp::oauth::auth_required_login_hint(&name);
8542 return Err(err).context(hint);
8543 }
8544 return Err(err);
8545 }
8546 };
8547 if conn.tools().is_empty() {
8548 println!("No tools found for MCP server: {name}");
8549 } else {
8550 println!("Tools for {name}:");
8551 for tool in conn.tools() {
8552 println!(
8553 " - {}{}",
8554 tool.name,
8555 crate::mcp::format_mcp_tool_description(tool.description.as_deref())
8556 );
8557 }
8558 }
8559 } else {
8560 let errors = pool.connect_all().await;
8561 for (name, err) in errors {
8562 eprintln!("Failed to connect {name}: {err:#}");
8563 if crate::mcp::oauth::error_looks_auth_required(&err) {
8564 eprintln!(" {}", crate::mcp::oauth::auth_required_login_hint(&name));
8565 }
8566 }
8567 let tools = pool.all_tools();
8568 if tools.is_empty() {
8569 println!("No MCP tools discovered.");
8570 } else {
8571 println!("MCP tools:");
8572 for (name, tool) in tools {
8573 println!(
8574 " - {}{}",
8575 name,
8576 crate::mcp::format_mcp_tool_description(tool.description.as_deref())
8577 );
8578 }
8579 }
8580 }
8581 Ok(())
8582 }
8583 McpCommand::Add {
8584 name,
8585 command,
8586 url,
8587 transport,
8588 bearer_token_env_var,
8589 oauth_client_id,
8590 oauth_resource,
8591 scopes,
8592 args,
8593 } => {
8594 if command.is_none() && url.is_none() {
8595 bail!("Provide either --command or --url for `mcp add`.");
8596 }
8597 if let Some(transport) = transport.as_deref()
8598 && !transport.trim().eq_ignore_ascii_case("sse")
8599 {
8600 bail!("Unsupported MCP transport '{transport}'. Supported values: sse");
8601 }
8602 let added_server = McpServerConfig {
8603 command,
8604 args,
8605 env: std::collections::HashMap::new(),
8606 cwd: None,
8607 url,
8608 transport,
8609 connect_timeout: None,
8610 execute_timeout: None,
8611 read_timeout: None,
8612 disabled: false,
8613 enabled: true,
8614 required: false,
8615 enabled_tools: Vec::new(),
8616 disabled_tools: Vec::new(),
8617 headers: std::collections::HashMap::new(),
8618 env_headers: std::collections::HashMap::new(),
8619 bearer_token_env_var,
8620 scopes,
8621 oauth: oauth_client_id.map(|client_id| McpServerOAuthConfig {
8622 client_id: Some(client_id),
8623 }),
8624 oauth_resource,
8625 reviewed_plugin: None,
8626 };
8627 let can_suggest_oauth = added_server.url.is_some()
8628 && added_server.bearer_token_env_var.is_none()
8629 && added_server
8630 .headers
8631 .keys()
8632 .all(|key| !key.trim().eq_ignore_ascii_case("authorization"))
8633 && added_server
8634 .env_headers
8635 .keys()
8636 .all(|key| !key.trim().eq_ignore_ascii_case("authorization"));
8637 let mut cfg = load_mcp_config(&config_path)?;
8638 cfg.servers.insert(name.clone(), added_server.clone());
8639 save_mcp_config(&config_path, &cfg)?;
8640 println!("Added MCP server '{name}' in {}", config_path.display());
8641 if can_suggest_oauth
8642 && crate::mcp::oauth::oauth_login_support(&added_server)
8643 .await
8644 .is_ok_and(|support| support.is_some())
8645 {
8646 println!(
8647 "OAuth is available for '{name}'. Run `codewhale mcp login {name}` to authenticate."
8648 );
8649 }
8650 Ok(())
8651 }
8652 McpCommand::Login { name, scopes } => {
8653 let cfg = crate::mcp::load_config_with_workspace_and_plugins(
8654 &config_path,
8655 workspace,
8656 plugins,
8657 )?;
8658 let server = cfg
8659 .servers
8660 .get(&name)
8661 .ok_or_else(|| anyhow!("MCP server '{name}' not found"))?;
8662 let explicit_scopes = (!scopes.is_empty()).then_some(scopes);
8663 crate::mcp::oauth::perform_oauth_login_for_server(
8664 &name,
8665 server,
8666 explicit_scopes,
8667 config.mcp_oauth_callback_port,
8668 config.mcp_oauth_callback_url.as_deref(),
8669 )
8670 .await?;
8671 println!("Stored OAuth credentials for MCP server '{name}'.");
8672 Ok(())
8673 }
8674 McpCommand::Logout { name } => {
8675 let cfg = crate::mcp::load_config_with_workspace_and_plugins(
8676 &config_path,
8677 workspace,
8678 plugins,
8679 )?;
8680 let server = cfg
8681 .servers
8682 .get(&name)
8683 .ok_or_else(|| anyhow!("MCP server '{name}' not found"))?;
8684 if crate::mcp::oauth::delete_oauth_tokens_for_server(&name, server)? {
8685 println!("Deleted stored OAuth credentials for MCP server '{name}'.");
8686 } else {
8687 println!("No stored OAuth credentials found for MCP server '{name}'.");
8688 }
8689 Ok(())
8690 }
8691 McpCommand::Remove { name } => {
8692 let mut cfg = load_mcp_config(&config_path)?;
8693 if cfg.servers.remove(&name).is_none() {
8694 bail!("MCP server '{name}' not found");
8695 }
8696 save_mcp_config(&config_path, &cfg)?;
8697 println!("Removed MCP server '{name}'");
8698 Ok(())
8699 }
8700 McpCommand::Enable { name } => {
8701 let mut cfg = load_mcp_config(&config_path)?;
8702 let server = cfg
8703 .servers
8704 .get_mut(&name)
8705 .ok_or_else(|| anyhow!("MCP server '{name}' not found"))?;
8706 server.enabled = true;
8707 server.disabled = false;
8708 save_mcp_config(&config_path, &cfg)?;
8709 println!("Enabled MCP server '{name}'");
8710 Ok(())
8711 }
8712 McpCommand::Disable { name } => {
8713 let mut cfg = load_mcp_config(&config_path)?;
8714 let server = cfg
8715 .servers
8716 .get_mut(&name)
8717 .ok_or_else(|| anyhow!("MCP server '{name}' not found"))?;
8718 server.enabled = false;
8719 server.disabled = true;
8720 save_mcp_config(&config_path, &cfg)?;
8721 println!("Disabled MCP server '{name}'");
8722 Ok(())
8723 }
8724 McpCommand::Validate => {
8725 let mut pool = McpPool::from_config_path_with_workspace_and_plugins(
8726 &config_path,
8727 workspace,
8728 std::sync::Arc::new(plugins.clone()),
8729 )?;
8730 let errors = pool.connect_all().await;
8731 if errors.is_empty() {
8732 println!("MCP config is valid. All enabled servers connected.");
8733 return Ok(());
8734 }
8735 eprintln!("MCP validation failed:");
8736 for (name, err) in errors {
8737 eprintln!(" - {name}: {err:#}");
8738 }
8739 bail!("one or more MCP servers failed validation");
8740 }
8741 McpCommand::AddSelf { name, workspace } => {
8742 let exe_path = std::env::current_exe()
8743 .map_err(|e| anyhow!("Cannot resolve current binary path: {e}"))?;
8744 let exe_str = exe_path.to_string_lossy().to_string();
8745
8746 let mut args = vec!["serve".to_string(), "--mcp".to_string()];
8747 if let Some(ref ws) = workspace {
8748 args.push("--workspace".to_string());
8749 args.push(ws.clone());
8750 }
8751
8752 let mut cfg = load_mcp_config(&config_path)?;
8753 if cfg.servers.contains_key(&name) {
8754 bail!(
8755 "MCP server '{name}' already exists in {}. Use `codewhale mcp remove {name}` first, or choose a different --name.",
8756 config_path.display()
8757 );
8758 }
8759 cfg.servers.insert(
8760 name.clone(),
8761 McpServerConfig {
8762 command: Some(exe_str.clone()),
8763 args,
8764 env: std::collections::HashMap::new(),
8765 cwd: None,
8766 url: None,
8767 transport: None,
8768 connect_timeout: None,
8769 execute_timeout: None,
8770 read_timeout: None,
8771 disabled: false,
8772 enabled: true,
8773 required: false,
8774 enabled_tools: Vec::new(),
8775 disabled_tools: Vec::new(),
8776 headers: std::collections::HashMap::new(),
8777 env_headers: std::collections::HashMap::new(),
8778 bearer_token_env_var: None,
8779 scopes: Vec::new(),
8780 oauth: None,
8781 oauth_resource: None,
8782 reviewed_plugin: None,
8783 },
8784 );
8785 save_mcp_config(&config_path, &cfg)?;
8786 println!(
8787 "Registered Codewhale as MCP server '{name}' in {}",
8788 config_path.display()
8789 );
8790 println!(" command: {exe_str}");
8791 println!(
8792 " args: serve --mcp{}",
8793 workspace.map_or(String::new(), |ws| format!(" --workspace {ws}"))
8794 );
8795 println!();
8796 println!("Tip: Use `codewhale mcp validate` to test the connection.");
8797 println!(" Use `codewhale serve --http` for the HTTP/SSE runtime API instead.");
8798 Ok(())
8799 }
8800 }
8801}
8802
8803fn load_mcp_config(path: &Path) -> Result<McpConfig> {
8804 if !path.exists() {
8805 return Ok(McpConfig::default());
8806 }
8807 let contents = std::fs::read_to_string(path)
8808 .map_err(|e| anyhow::anyhow!("Failed to read MCP config {}: {}", path.display(), e))?;
8809 let cfg: McpConfig = serde_json::from_str(&contents).map_err(|_| {
8810 anyhow::anyhow!(
8811 "Failed to parse MCP config {}; file contents were omitted",
8812 codewhale_config::quote_os_path(path)
8813 )
8814 })?;
8815 Ok(cfg)
8816}
8817
8818#[derive(Debug)]
8820enum McpServerDoctorStatus {
8821 Ok(String),
8822 Warning(String),
8823 Error(String),
8824}
8825
8826impl McpServerDoctorStatus {
8827 fn legacy_status(&self) -> &'static str {
8828 match self {
8829 Self::Ok(_) => "ok",
8830 Self::Warning(_) => "warning",
8831 Self::Error(_) => "error",
8832 }
8833 }
8834
8835 fn configuration_status(&self) -> &'static str {
8836 match self {
8837 Self::Ok(_) => "valid",
8838 Self::Warning(_) => "warning",
8839 Self::Error(_) => "invalid",
8840 }
8841 }
8842
8843 fn detail(&self) -> &str {
8844 match self {
8845 Self::Ok(detail) | Self::Warning(detail) | Self::Error(detail) => detail,
8846 }
8847 }
8848}
8849
8850fn doctor_mcp_command_status(server: &McpServerConfig) -> McpCommandAvailability {
8852 if server.url.is_some() {
8853 return McpCommandAvailability::NotApplicable;
8854 }
8855 match server.command.as_deref() {
8856 Some("") => McpCommandAvailability::Missing,
8857 Some(_) | None => McpCommandAvailability::NotChecked,
8858 }
8859}
8860
8861fn doctor_mcp_server_json(name: &str, server: &McpServerConfig) -> serde_json::Value {
8862 use serde_json::json;
8863
8864 let status = doctor_check_mcp_server(server);
8865 json!({
8866 "name": name,
8867 "enabled": server.enabled && !server.disabled,
8868 "status": status.legacy_status(),
8871 "detail": status.detail(),
8872 "transport": if server.url.is_some() { "http" } else { "stdio" },
8873 "endpoint": server.url.as_deref().map(crate::doctor::structural_url_authority),
8874 "command_configured": server.command.is_some(),
8875 "args_count": server.args.len(),
8876 "env_count": server.env.len(),
8877 "headers_count": server.headers.len(),
8878 "env_headers_count": server.env_headers.len(),
8879 "check_scope": "configuration",
8880 "checks": {
8881 "configuration": {
8882 "status": status.configuration_status(),
8883 "detail": status.detail(),
8884 },
8885 "command": {
8886 "status": doctor_mcp_command_status(server).as_str(),
8887 },
8888 "process_reachable": {
8889 "status": "not_checked",
8890 },
8891 "protocol_initialized": {
8892 "status": "not_checked",
8893 },
8894 "backend_tool_health": {
8895 "status": "not_checked",
8896 },
8897 },
8898 })
8899}
8900
8901fn doctor_check_mcp_server(server: &McpServerConfig) -> McpServerDoctorStatus {
8903 if server.command.is_none() && server.url.is_none() {
8905 return McpServerDoctorStatus::Error("no command or url configured".to_string());
8906 }
8907
8908 if let Some(ref url) = server.url {
8910 let authority = crate::doctor::structural_url_authority(url);
8911 return if authority.starts_with("unparseable") {
8912 McpServerDoctorStatus::Warning(
8913 "HTTP/SSE server URL is invalid; configured value omitted".to_string(),
8914 )
8915 } else {
8916 McpServerDoctorStatus::Ok(format!("HTTP/SSE server at {authority}"))
8917 };
8918 }
8919
8920 let cmd = server.command.as_deref().unwrap_or("");
8922 if cmd.is_empty() {
8923 return McpServerDoctorStatus::Error("empty command".to_string());
8924 }
8925
8926 if server.cwd.is_none() {
8927 if is_relative_stdio_path_arg(cmd) {
8928 return McpServerDoctorStatus::Warning(
8929 "stdio server uses a relative command without cwd; command value omitted"
8930 .to_string(),
8931 );
8932 }
8933 if server
8934 .args
8935 .iter()
8936 .any(|arg| is_relative_stdio_path_arg(arg))
8937 {
8938 return McpServerDoctorStatus::Warning(
8939 "stdio server uses a relative path argument without cwd; argument values omitted"
8940 .to_string(),
8941 );
8942 }
8943 }
8944
8945 McpServerDoctorStatus::Ok(format!(
8946 "stdio server configured (command omitted; {} argument(s), {} environment binding(s))",
8947 server.args.len(),
8948 server.env.len()
8949 ))
8950}
8951
8952fn save_mcp_config(path: &Path, cfg: &McpConfig) -> Result<()> {
8953 if let Some(parent) = path.parent() {
8954 std::fs::create_dir_all(parent).with_context(|| {
8955 format!("Failed to create MCP config directory {}", parent.display())
8956 })?;
8957 }
8958 let rendered = serde_json::to_string_pretty(cfg)
8959 .map_err(|e| anyhow!("Failed to serialize MCP config: {e}"))?;
8960 crate::utils::write_atomic(path, rendered.as_bytes())
8961 .map_err(|e| anyhow!("Failed to write MCP config {}: {}", path.display(), e))?;
8962 Ok(())
8963}
8964
8965fn run_sandbox_command(args: SandboxArgs) -> Result<()> {
8966 use crate::sandbox::{CommandSpec, SandboxManager};
8967
8968 let SandboxCommand::Run {
8969 policy,
8970 network,
8971 writable_root,
8972 exclude_tmpdir,
8973 exclude_slash_tmp,
8974 cwd,
8975 timeout_ms,
8976 command,
8977 } = args.command;
8978
8979 let policy = parse_sandbox_policy(
8980 &policy,
8981 network,
8982 writable_root,
8983 exclude_tmpdir,
8984 exclude_slash_tmp,
8985 )?;
8986 let cwd = cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
8987 let timeout = Duration::from_millis(timeout_ms.clamp(1000, 600_000));
8988
8989 let (program, args) = command
8990 .split_first()
8991 .ok_or_else(|| anyhow::anyhow!("Command is required"))?;
8992 let spec =
8993 CommandSpec::program(program, args.to_vec(), cwd.clone(), timeout).with_policy(policy);
8994 let manager = SandboxManager::new();
8995 let exec_env = manager.prepare(&spec);
8996
8997 let mut cmd = Command::new(exec_env.program());
8998 cmd.args(exec_env.args())
8999 .current_dir(&exec_env.cwd)
9000 .stdout(Stdio::piped())
9001 .stderr(Stdio::piped());
9002 child_env::apply_to_command(&mut cmd, child_env::string_map_env(&exec_env.env));
9003
9004 let mut child = cmd
9005 .spawn()
9006 .map_err(|e| anyhow::anyhow!("Failed to run command: {e}"))?;
9007 let stdout_handle = child
9008 .stdout
9009 .take()
9010 .ok_or_else(|| anyhow::anyhow!("stdout unavailable"))?;
9011 let stderr_handle = child
9012 .stderr
9013 .take()
9014 .ok_or_else(|| anyhow::anyhow!("stderr unavailable"))?;
9015
9016 let timeout = exec_env.timeout;
9017 let stdout_thread = std::thread::spawn(move || {
9018 let mut reader = stdout_handle;
9019 let mut buf = Vec::new();
9020 let _ = reader.read_to_end(&mut buf);
9021 buf
9022 });
9023 let stderr_thread = std::thread::spawn(move || {
9024 let mut reader = stderr_handle;
9025 let mut buf = Vec::new();
9026 let _ = reader.read_to_end(&mut buf);
9027 buf
9028 });
9029
9030 if let Some(status) = child.wait_timeout(timeout)? {
9031 let stdout = stdout_thread.join().unwrap_or_default();
9032 let stderr = stderr_thread.join().unwrap_or_default();
9033 let stderr_str = String::from_utf8_lossy(&stderr);
9034 let exit_code = status.code().unwrap_or(-1);
9035 let sandbox_type = exec_env.sandbox_type;
9036 let sandbox_denied = SandboxManager::was_denied(sandbox_type, exit_code, &stderr_str);
9037
9038 if !stdout.is_empty() {
9039 print!("{}", String::from_utf8_lossy(&stdout));
9040 }
9041 if !stderr.is_empty() {
9042 eprint!("{stderr_str}");
9043 }
9044 if sandbox_denied {
9045 eprintln!(
9046 "{}",
9047 SandboxManager::denial_message(sandbox_type, &stderr_str)
9048 );
9049 }
9050
9051 if !status.success() {
9052 bail!("Command failed with exit code {exit_code}");
9053 }
9054 } else {
9055 let _ = child.kill();
9056 let _ = child.wait();
9057 bail!("Command timed out after {}ms", timeout.as_millis());
9058 }
9059 Ok(())
9060}
9061
9062fn parse_sandbox_policy(
9063 policy: &str,
9064 network: bool,
9065 writable_root: Vec<PathBuf>,
9066 exclude_tmpdir: bool,
9067 exclude_slash_tmp: bool,
9068) -> Result<crate::sandbox::SandboxPolicy> {
9069 use crate::sandbox::SandboxPolicy;
9070
9071 match policy {
9072 "danger-full-access" => Ok(SandboxPolicy::DangerFullAccess),
9073 "read-only" => Ok(SandboxPolicy::ReadOnly),
9074 "external-sandbox" => Ok(SandboxPolicy::ExternalSandbox {
9075 network_access: network,
9076 }),
9077 "workspace-write" => Ok(SandboxPolicy::WorkspaceWrite {
9078 writable_roots: writable_root,
9079 network_access: network,
9080 exclude_tmpdir,
9081 exclude_slash_tmp,
9082 }),
9083 other => bail!("Unknown sandbox policy: {other}"),
9084 }
9085}
9086
9087fn should_use_alt_screen(_cli: &Cli, _config: &Config) -> bool {
9088 true
9089}
9090
9091fn should_use_mouse_capture(cli: &Cli, config: &Config, use_alt_screen: bool) -> bool {
9092 let terminal_emulator = std::env::var("TERMINAL_EMULATOR").ok();
9093 let wt_session = std::env::var("WT_SESSION").ok().filter(|s| !s.is_empty());
9094 let conemu_pid = std::env::var("ConEmuPID").ok().filter(|s| !s.is_empty());
9095 should_use_mouse_capture_with(
9096 cli,
9097 config,
9098 use_alt_screen,
9099 terminal_emulator.as_deref(),
9100 wt_session.as_deref(),
9101 conemu_pid.as_deref(),
9102 )
9103}
9104
9105fn should_use_mouse_capture_with(
9106 cli: &Cli,
9107 config: &Config,
9108 use_alt_screen: bool,
9109 terminal_emulator: Option<&str>,
9110 wt_session: Option<&str>,
9111 conemu_pid: Option<&str>,
9112) -> bool {
9113 if !use_alt_screen || cli.no_mouse_capture {
9114 return false;
9115 }
9116 if cli.mouse_capture {
9117 return true;
9118 }
9119 config
9120 .tui
9121 .as_ref()
9122 .and_then(|tui| tui.mouse_capture)
9123 .unwrap_or_else(|| default_mouse_capture_enabled(terminal_emulator, wt_session, conemu_pid))
9124}
9125
9126fn default_mouse_capture_enabled(
9141 terminal_emulator: Option<&str>,
9142 wt_session: Option<&str>,
9143 conemu_pid: Option<&str>,
9144) -> bool {
9145 if cfg!(windows) {
9146 return wt_session.is_some() || conemu_pid.is_some();
9147 }
9148 if matches!(terminal_emulator, Some(t) if t.eq_ignore_ascii_case("JetBrains-JediTerm")) {
9149 return false;
9150 }
9151 true
9152}
9153
9154struct RecentCheckpoint {
9158 session: session_manager::SavedSession,
9159 age: std::time::Duration,
9160 source: session_manager::CheckpointSource,
9161}
9162
9163const CHECKPOINT_MAX_AGE: std::time::Duration = std::time::Duration::from_secs(24 * 3600);
9164
9165fn load_recent_checkpoints(manager: &session_manager::SessionManager) -> Vec<RecentCheckpoint> {
9172 let refs = manager.list_checkpoints().unwrap_or_default();
9173 let mut recent = Vec::new();
9174 for checkpoint_ref in refs {
9175 let Ok(age) = std::time::SystemTime::now().duration_since(checkpoint_ref.modified) else {
9176 continue;
9177 };
9178 if age > CHECKPOINT_MAX_AGE {
9179 let _ = match &checkpoint_ref.source {
9180 session_manager::CheckpointSource::Session(id) => {
9181 manager.clear_session_checkpoint(id)
9182 }
9183 session_manager::CheckpointSource::Legacy => manager.clear_legacy_checkpoint(),
9184 };
9185 continue;
9186 }
9187 let loaded = match &checkpoint_ref.source {
9188 session_manager::CheckpointSource::Session(id) => manager.load_session_checkpoint(id),
9189 session_manager::CheckpointSource::Legacy => manager.load_legacy_checkpoint(),
9190 };
9191 let Ok(Some(session)) = loaded else {
9192 continue;
9193 };
9194 recent.push(RecentCheckpoint {
9195 session,
9196 age,
9197 source: checkpoint_ref.source,
9198 });
9199 }
9200 recent.sort_by_key(|c| c.age);
9203 recent
9204}
9205
9206fn checkpoint_age_label(age: std::time::Duration) -> String {
9207 if age.as_secs() < 60 {
9208 format!("{}s ago", age.as_secs())
9209 } else if age.as_secs() < 3600 {
9210 format!("{}m ago", age.as_secs() / 60)
9211 } else {
9212 format!("{}h ago", age.as_secs() / 3600)
9213 }
9214}
9215
9216fn recover_interrupted_checkpoint_for_resume(launch_workspace: &Path) -> Option<String> {
9228 let manager = session_manager::SessionManager::default_location().ok()?;
9229 let candidates = load_recent_checkpoints(&manager);
9230 if candidates.is_empty() {
9231 return None;
9232 }
9233
9234 let (matching, mismatched): (Vec<_>, Vec<_>) = candidates.into_iter().partition(|candidate| {
9239 session_manager::workspace_scope_matches(
9240 &candidate.session.metadata.workspace,
9241 launch_workspace,
9242 )
9243 });
9244
9245 let Some(best) = matching.into_iter().next() else {
9246 if let Some(newest) = mismatched.first() {
9247 eprintln!(
9248 "Note: an interrupted session from another workspace ({}) is \
9249 available. Run `codewhale sessions` to list saved sessions. Starting \
9250 fresh in {}.",
9251 newest.session.metadata.workspace.display(),
9252 launch_workspace.display(),
9253 );
9254 }
9255 return None;
9256 };
9257
9258 let session_id = best.session.metadata.id.clone();
9259
9260 if !saved_session_is_newer(&manager, &best.session)
9265 && manager.save_session(&best.session).is_err()
9266 {
9267 return None;
9268 }
9269
9270 match &best.source {
9271 session_manager::CheckpointSource::Session(id) => {
9272 let _ = manager.clear_session_checkpoint(id);
9274 }
9275 session_manager::CheckpointSource::Legacy => {
9276 let _ = manager.write_session_checkpoint_if_absent(&best.session);
9281 }
9282 }
9283
9284 let age_str = checkpoint_age_label(best.age);
9285 eprintln!("Recovered interrupted session ({age_str}). Use --fresh to start fresh.",);
9286
9287 Some(session_id)
9288}
9289
9290fn saved_session_is_newer(
9295 manager: &session_manager::SessionManager,
9296 checkpoint: &session_manager::SavedSession,
9297) -> bool {
9298 manager
9299 .load_session(&checkpoint.metadata.id)
9300 .is_ok_and(|existing| existing.metadata.updated_at >= checkpoint.metadata.updated_at)
9301}
9302
9303fn preserve_interrupted_checkpoint_for_explicit_resume(launch_workspace: &Path) {
9314 let Some(manager) = session_manager::SessionManager::default_location().ok() else {
9315 return;
9316 };
9317 let Some(newest) = load_recent_checkpoints(&manager).into_iter().next() else {
9318 return;
9319 };
9320
9321 let session_workspace = newest.session.metadata.workspace.clone();
9322 if newest.source == session_manager::CheckpointSource::Legacy {
9324 let _ = manager.save_checkpoint(&newest.session);
9327 let _ = manager.clear_legacy_checkpoint();
9328 }
9329
9330 let age_str = checkpoint_age_label(newest.age);
9331 if session_manager::workspace_scope_matches(&session_workspace, launch_workspace) {
9332 eprintln!(
9333 "Found an in-flight session snapshot ({age_str}). Starting a new \
9334 session. Run `codewhale --continue` to resume it."
9335 );
9336 } else {
9337 eprintln!(
9338 "Note: an interrupted session from another workspace ({}) is \
9339 available. Run `codewhale sessions` to list saved sessions. Starting \
9340 fresh in {}.",
9341 session_workspace.display(),
9342 launch_workspace.display(),
9343 );
9344 }
9345}
9346
9347#[cfg(test)]
9353fn merge_project_config(config: &mut Config, workspace: &Path) {
9354 merge_project_config_with_approval_baseline(config, workspace, None);
9355}
9356
9357fn merge_project_config_with_approval_baseline(
9362 config: &mut Config,
9363 workspace: &Path,
9364 saved_permission_posture: Option<&str>,
9365) {
9366 if let Some(home) = effective_home_dir()
9371 && let (Ok(w), Ok(h)) = (
9372 std::fs::canonicalize(workspace),
9373 std::fs::canonicalize(&home),
9374 )
9375 && w == h
9376 {
9377 return;
9378 }
9379
9380 let path = workspace
9382 .join(codewhale_config::CODEWHALE_APP_DIR)
9383 .join("config.toml");
9384 let raw = match read_project_config_file(&path) {
9385 Ok(Some(r)) => r,
9386 Ok(None) => {
9387 let legacy = workspace
9388 .join(codewhale_config::LEGACY_APP_DIR)
9389 .join("config.toml");
9390 match read_project_config_file(&legacy) {
9391 Ok(Some(r)) => r,
9392 Ok(None) => return,
9393 Err(err) => {
9394 eprintln!(
9395 "warning: failed to read project-scope config {}: {err}",
9396 legacy.display()
9397 );
9398 return;
9399 }
9400 }
9401 }
9402 Err(err) => {
9403 eprintln!(
9404 "warning: failed to read project-scope config {}: {err}",
9405 path.display()
9406 );
9407 return;
9408 }
9409 };
9410 let project: toml::Value = match toml::from_str(&raw) {
9411 Ok(v) => v,
9412 Err(_) => return,
9413 };
9414 let table = match project.as_table() {
9415 Some(t) => t,
9416 None => return,
9417 };
9418
9419 const DENY_AT_PROJECT_SCOPE: &[&str] = &[
9435 "api_key",
9436 "base_url",
9437 "provider",
9438 "mcp_config_path",
9439 "mcp_oauth_callback_port",
9440 "mcp_oauth_callback_url",
9441 ];
9442 for key in DENY_AT_PROJECT_SCOPE {
9443 if table.contains_key(*key) {
9444 eprintln!(
9445 "warning: project-scope config key `{key}` is ignored — \
9446 set it in `~/.codewhale/config.toml` instead. \
9447 (See #417 for the deny-list rationale.)"
9448 );
9449 }
9450 }
9451
9452 for (key, field) in [
9455 ("model", &mut config.default_text_model),
9456 ("reasoning_effort", &mut config.reasoning_effort),
9457 ("notes_path", &mut config.notes_path),
9458 ] {
9459 if let Some(v) = table.get(key).and_then(toml::Value::as_str)
9460 && !v.is_empty()
9461 {
9462 *field = Some(v.to_string());
9463 }
9464 }
9465
9466 if let Some(v) = table.get("approval_policy").and_then(toml::Value::as_str)
9467 && !v.is_empty()
9468 {
9469 let saved_approval_baseline =
9470 crate::config::approval_policy_baseline_from_permission_posture(
9471 saved_permission_posture,
9472 );
9473 let approval_baseline = config
9474 .approval_policy
9475 .as_deref()
9476 .or(saved_approval_baseline);
9477 if codewhale_config::project_approval_policy_is_allowed(approval_baseline, v) {
9478 config.approval_policy = Some(v.to_string());
9479 } else {
9480 eprintln!(
9481 "warning: project-scope `approval_policy = \"{v}\"` is ignored — \
9482 project config can only tighten the user's approval policy. \
9483 (See #417.)"
9484 );
9485 }
9486 }
9487
9488 if let Some(v) = table.get("sandbox_mode").and_then(toml::Value::as_str)
9489 && !v.is_empty()
9490 {
9491 if codewhale_config::project_sandbox_mode_is_allowed(config.sandbox_mode.as_deref(), v) {
9492 config.sandbox_mode = Some(v.to_string());
9493 } else {
9494 eprintln!(
9495 "warning: project-scope `sandbox_mode = \"{v}\"` is ignored — \
9496 project config can only tighten the user's sandbox mode. \
9497 (See #417.)"
9498 );
9499 }
9500 }
9501
9502 if let Some(v) = table.get("max_subagents").and_then(toml::Value::as_integer)
9504 && v > 0
9505 {
9506 config.max_subagents = Some((v as usize).clamp(1, crate::config::MAX_SUBAGENTS));
9507 }
9508 if let Some(v) = table.get("allow_shell").and_then(toml::Value::as_bool) {
9509 if v {
9510 eprintln!(
9511 "warning: project-scope `allow_shell = true` is ignored — \
9512 enable shell from user config for this workspace instead. \
9513 (See #417.)"
9514 );
9515 } else {
9516 config.allow_shell = Some(false);
9517 }
9518 }
9519
9520 if table.contains_key("instructions") {
9521 eprintln!(
9522 "warning: project-scope `instructions` is ignored — \
9523 configure instruction files from user config instead. \
9524 (See #417.)"
9525 );
9526 }
9527}
9528
9529fn read_project_config_file(path: &Path) -> io::Result<Option<String>> {
9530 let metadata = match std::fs::symlink_metadata(path) {
9531 Ok(metadata) => metadata,
9532 Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
9533 Err(err) => return Err(err),
9534 };
9535 let file_type = metadata.file_type();
9536 if file_type.is_symlink() {
9537 return Err(io::Error::new(
9538 io::ErrorKind::InvalidInput,
9539 "project-scope config must not be a symlink",
9540 ));
9541 }
9542 if !file_type.is_file() {
9543 return Ok(None);
9544 }
9545
9546 let mut file = open_project_config_file(path)?;
9547 let mut raw = String::new();
9548 file.read_to_string(&mut raw)?;
9549 Ok(Some(raw))
9550}
9551
9552#[cfg(unix)]
9553fn open_project_config_file(path: &Path) -> io::Result<std::fs::File> {
9554 use std::os::unix::fs::OpenOptionsExt;
9555
9556 std::fs::OpenOptions::new()
9557 .read(true)
9558 .custom_flags(libc::O_NOFOLLOW)
9559 .open(path)
9560}
9561
9562#[cfg(not(unix))]
9563fn open_project_config_file(path: &Path) -> io::Result<std::fs::File> {
9564 std::fs::File::open(path)
9565}
9566
9567fn merge_user_workspace_config(
9568 config: &mut Config,
9569 config_path: Option<PathBuf>,
9570 workspace: &Path,
9571) {
9572 if config.managed_config_path.is_some() || config.requirements_path.is_some() {
9573 return;
9574 }
9575 let allow_shell_before = config.allow_shell;
9576 let allow_shell_from_env = std::env::var_os("CODEWHALE_ALLOW_SHELL").is_some()
9577 || std::env::var_os("DEEPSEEK_ALLOW_SHELL").is_some();
9578 let path = match crate::config::resolve_load_config_path(config_path) {
9579 Ok(Some(path)) => path,
9580 Ok(None) => return,
9581 Err(error) => {
9582 tracing::error!(
9583 error = %error,
9584 "failed to resolve workspace config overlay; refusing to substitute another file"
9585 );
9586 return;
9587 }
9588 };
9589 let raw = match std::fs::read_to_string(&path) {
9590 Ok(raw) => raw,
9591 Err(error) => {
9592 eprintln!(
9593 "warning: could not read user config at {}: {error}. \
9594 Ignoring it — `[workspace]`/`[projects]` grants (e.g. `allow_shell`) \
9595 revert to defaults for this session. Fix or remove the file to \
9596 restore them.",
9597 path.display()
9598 );
9599 return;
9600 }
9601 };
9602 let doc = match toml::from_str::<toml::Value>(&raw) {
9603 Ok(doc) => doc,
9604 Err(error) => {
9605 eprintln!(
9606 "warning: could not parse user config at {}: {error}. \
9607 Ignoring it — `[workspace]`/`[projects]` grants (e.g. `allow_shell`) \
9608 revert to defaults for this session. Fix the TOML syntax to \
9609 restore them.",
9610 path.display()
9611 );
9612 return;
9613 }
9614 };
9615 merge_user_workspace_config_from_doc(config, &doc, workspace);
9616 if allow_shell_from_env {
9617 config.allow_shell = allow_shell_before;
9618 }
9619}
9620
9621fn merge_user_workspace_config_from_doc(config: &mut Config, doc: &toml::Value, workspace: &Path) {
9622 for table_name in ["workspace", "projects"] {
9623 let Some(entries) = doc.get(table_name).and_then(toml::Value::as_table) else {
9624 continue;
9625 };
9626 for (raw_path, entry) in entries {
9627 if !workspace_config_path_matches(raw_path, workspace) {
9628 continue;
9629 }
9630 if let Some(allow_shell) = entry.get("allow_shell").and_then(toml::Value::as_bool) {
9631 config.allow_shell = Some(allow_shell);
9632 }
9633 }
9634 }
9635}
9636
9637fn workspace_config_path_matches(raw_path: &str, workspace: &Path) -> bool {
9638 let configured = crate::config::expand_path(raw_path);
9639 let configured = configured.canonicalize().unwrap_or(configured);
9640 let workspace = workspace
9641 .canonicalize()
9642 .unwrap_or_else(|_| workspace.to_path_buf());
9643 paths_equal_for_config(&configured, &workspace)
9644}
9645
9646#[cfg(windows)]
9647fn paths_equal_for_config(left: &Path, right: &Path) -> bool {
9648 normalize_windows_config_path_for_compare(left)
9649 == normalize_windows_config_path_for_compare(right)
9650}
9651
9652#[cfg(not(windows))]
9653fn paths_equal_for_config(left: &Path, right: &Path) -> bool {
9654 left == right
9655}
9656
9657#[cfg(windows)]
9658fn normalize_windows_config_path_for_compare(path: &Path) -> String {
9659 normalize_windows_config_path_str(&path.to_string_lossy())
9660}
9661
9662#[cfg(any(windows, test))]
9663fn normalize_windows_config_path_str(path: &str) -> String {
9664 let mut normalized = path.replace('/', "\\");
9665 if let Some(rest) = normalized.strip_prefix(r"\\?\UNC\") {
9666 normalized = format!("\\\\{rest}");
9667 } else if let Some(rest) = normalized.strip_prefix(r"\\?\") {
9668 normalized = rest.to_string();
9669 }
9670 while normalized.len() > 3 && normalized.ends_with('\\') {
9671 normalized.pop();
9672 }
9673 normalized.to_ascii_lowercase()
9674}
9675
9676fn interactive_tui_allow_shell(yolo: bool, config: &Config) -> bool {
9677 yolo || config.interactive_allow_shell()
9678}
9679
9680async fn run_interactive(
9681 cli: &Cli,
9682 config: &Config,
9683 resume_session_id: Option<String>,
9684 initial_input: Option<tui::InitialInput>,
9685 pending_telemetry_notice: Option<crate::telemetry_notice::PendingTelemetryNotice>,
9686 plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
9687) -> Result<()> {
9688 run_interactive_with_notice(
9689 cli,
9690 config,
9691 resume_session_id,
9692 initial_input,
9693 None,
9694 pending_telemetry_notice,
9695 plugin_registry,
9696 )
9697 .await
9698}
9699
9700async fn run_interactive_with_notice(
9704 cli: &Cli,
9705 config: &Config,
9706 resume_session_id: Option<String>,
9707 initial_input: Option<tui::InitialInput>,
9708 startup_notice: Option<String>,
9709 pending_telemetry_notice: Option<crate::telemetry_notice::PendingTelemetryNotice>,
9710 plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
9711) -> Result<()> {
9712 let initial_input = if cli.remote_control {
9713 Some(tui::InitialInput::RemoteControl)
9714 } else {
9715 initial_input
9716 };
9717 let workspace = cli
9718 .workspace
9719 .clone()
9720 .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
9721
9722 let mut merged_config = config.clone();
9726 merge_user_workspace_config(&mut merged_config, cli.config.clone(), &workspace);
9727 if !cli.no_project_config {
9728 let saved_permission_posture = crate::settings::Settings::load_persisted()
9729 .ok()
9730 .and_then(|settings| settings.permission_posture);
9731 merge_project_config_with_approval_baseline(
9732 &mut merged_config,
9733 &workspace,
9734 saved_permission_posture.as_deref(),
9735 );
9736 }
9737 let config = &merged_config;
9738
9739 if !cli.skip_onboarding {
9740 match crate::config::ensure_config_file_exists(cli.config.clone()) {
9741 Ok(Some(path)) => logging::info(format!(
9742 "Created first-run config file at {}",
9743 path.display()
9744 )),
9745 Ok(None) => {}
9746 Err(err) => logging::warn(format!("Failed to create first-run config file: {err}")),
9747 }
9748 }
9749
9750 match codewhale_config::migrate_config_if_needed() {
9753 Ok(Some(migration)) => {
9754 eprintln!("{}", migration.user_notice());
9755 }
9756 Ok(None) => {}
9757 Err(err) => logging::warn(format!("Config migration skipped: {err}")),
9758 }
9759
9760 let model = config.default_model();
9761 let provider = config.api_provider();
9762 let max_subagents = cli.max_subagents.map_or_else(
9763 || config.max_subagents_for_provider(provider),
9764 |value| value.clamp(1, MAX_SUBAGENTS),
9765 );
9766 let use_alt_screen = should_use_alt_screen(cli, config);
9767 let use_mouse_capture = should_use_mouse_capture(cli, config, use_alt_screen);
9768 let use_bracketed_paste = crate::settings::Settings::load()
9769 .map(|s| s.effective_bracketed_paste())
9770 .unwrap_or_else(|_| !crate::settings::detected_legacy_windows_console_host());
9771
9772 let skills_dir = config.skills_dir();
9775 if let Err(e) = crate::skills::install_system_skills(&skills_dir) {
9776 logging::warn(format!("Failed to install system skills: {e}"));
9777 }
9778
9779 startup_trace::mark("interactive_config");
9780
9781 crate::models_dev_live::maybe_load_persisted_cache();
9785 crate::models_dev_live::spawn_background_refresh();
9786 crate::client::DeepSeekClient::spawn_active_provider_catalog_refresh(config);
9791
9792 let snapshots = config.snapshots_config();
9799 let janitor_snapshots_enabled = snapshots.enabled;
9800 let janitor_max_age = snapshots.max_age();
9801 let janitor_workspace = workspace.clone();
9802 let janitor_resume_id = resume_session_id.clone();
9807 let janitor_skip_session_cleanup = resume_session_id.is_some() || cli.continue_session;
9808 tokio::task::spawn_blocking(move || {
9809 if janitor_snapshots_enabled {
9810 session_manager::prune_workspace_snapshots(&janitor_workspace, janitor_max_age);
9811 }
9812
9813 match crate::tools::truncate::prune_older_than(crate::tools::truncate::SPILLOVER_MAX_AGE) {
9814 Ok(0) => {}
9815 Ok(n) => tracing::debug!(
9816 target: "spillover",
9817 "boot prune removed {n} spillover file(s)"
9818 ),
9819 Err(err) => tracing::warn!(
9820 target: "spillover",
9821 ?err,
9822 "spillover prune skipped on boot"
9823 ),
9824 }
9825
9826 if !janitor_skip_session_cleanup
9827 && let Ok(manager) = session_manager::SessionManager::default_location()
9828 {
9829 let _ = manager.cleanup_old_sessions_keeping(janitor_resume_id.as_deref());
9830 }
9831 });
9832
9833 let yolo = cli.yolo || config.yolo.unwrap_or(false);
9836
9837 tui::run_tui(
9838 config,
9839 tui::TuiOptions {
9840 model,
9841 workspace,
9842 config_path: cli.config.clone(),
9843 config_profile: effective_config_profile(cli),
9844 allow_shell: interactive_tui_allow_shell(yolo, config),
9845 use_alt_screen,
9846 use_mouse_capture,
9847 use_bracketed_paste,
9848 skills_dir,
9849 memory_path: config.memory_path(),
9850 notes_path: config.notes_path(),
9851 mcp_config_path: config.mcp_config_path(),
9852 use_memory: config.memory_enabled(),
9853 start_in_agent_mode: yolo,
9854 skip_onboarding: cli.skip_onboarding,
9855 yolo, resume_session_id,
9857 initial_input,
9858 startup_notice,
9859 max_subagents,
9860 },
9861 plugin_registry,
9862 pending_telemetry_notice,
9863 )
9864 .await
9865}
9866
9867#[derive(Debug)]
9868struct CliAutoRoute {
9869 provider: crate::config::ApiProvider,
9870 model: String,
9871 reasoning_effort: Option<crate::tui::app::ReasoningEffort>,
9872 auto_controls_reasoning: bool,
9877 auto_model: bool,
9878}
9879
9880fn cli_reasoning_effort_value(
9881 config: &Config,
9882 model: &str,
9883 effort: crate::tui::app::ReasoningEffort,
9884) -> Option<String> {
9885 effort
9886 .api_value_for_route(config.api_provider(), &config.deepseek_base_url(), model)
9887 .map(str::to_string)
9888}
9889
9890fn cli_reasoning_effort_value_for_prompt(
9891 config: &Config,
9892 model: &str,
9893 effort: crate::tui::app::ReasoningEffort,
9894 prompt: &str,
9895) -> Option<String> {
9896 let resolved = if effort == crate::tui::app::ReasoningEffort::Auto {
9897 crate::auto_reasoning::select(false, prompt)
9898 } else {
9899 effort
9900 };
9901 cli_reasoning_effort_value(config, model, resolved)
9902}
9903
9904fn normalize_cli_reasoning_effort(value: &str) -> Result<Option<String>> {
9905 let trimmed = value.trim();
9906 if trimmed.is_empty() {
9907 return Ok(None);
9908 }
9909 if matches!(
9910 trimmed.to_ascii_lowercase().as_str(),
9911 "inherit" | "parent" | "same" | "current" | "default" | "unset"
9912 ) {
9913 return Ok(None);
9914 }
9915 crate::tui::app::ReasoningEffort::parse_strict(trimmed)
9916 .map(|effort| Some(effort.as_setting().to_string()))
9917 .map_err(anyhow::Error::msg)
9918}
9919
9920fn config_for_cli_route(config: &Config, route: &CliAutoRoute) -> Config {
9921 let mut execution_config = config.clone();
9922 execution_config.provider = Some(config.provider_identity_for(route.provider));
9923 execution_config.set_provider_model_override(route.provider, Some(route.model.clone()));
9924 if matches!(
9925 route.provider,
9926 crate::config::ApiProvider::Deepseek | crate::config::ApiProvider::DeepseekCN
9927 ) {
9928 execution_config.default_text_model = Some(route.model.clone());
9929 }
9930 execution_config
9931}
9932
9933async fn resolve_cli_auto_route(
9934 config: &Config,
9935 model: &str,
9936 prompt: &str,
9937) -> Result<CliAutoRoute> {
9938 if model.trim().eq_ignore_ascii_case("auto") {
9939 let selection =
9940 model_routing::resolve_auto_route_with_inventory(config, prompt, "", "auto", "auto")
9941 .await?;
9942 let preference = config
9943 .reasoning_effort()
9944 .filter(|_| config.reasoning_effort_is_explicit())
9945 .map(crate::tui::app::ReasoningEffort::from_setting);
9946 let (reasoning_effort, auto_controls_reasoning) =
9947 model_routing::resolve_auto_model_reasoning(preference, selection.reasoning_effort);
9948 Ok(CliAutoRoute {
9949 provider: selection.provider,
9950 model: selection.model,
9951 reasoning_effort,
9952 auto_controls_reasoning,
9953 auto_model: true,
9954 })
9955 } else {
9956 if let Some(selection) = model_routing::resolve_explicit_route_with_inventory(config, model)
9957 {
9958 let auto_controls_reasoning = matches!(
9959 selection.reasoning_effort,
9960 Some(crate::tui::app::ReasoningEffort::Auto)
9961 );
9962 return Ok(CliAutoRoute {
9963 provider: selection.provider,
9964 model: selection.model,
9965 reasoning_effort: selection.reasoning_effort,
9966 auto_controls_reasoning,
9967 auto_model: false,
9968 });
9969 }
9970
9971 let candidate_providers = model_routing::explicit_route_candidate_providers(config, model);
9972 if !candidate_providers.is_empty() && !candidate_providers.contains(&config.api_provider())
9973 {
9974 let providers = candidate_providers
9975 .iter()
9976 .map(|provider| provider.as_str())
9977 .collect::<Vec<_>>()
9978 .join(", ");
9979 bail!(
9980 "model `{model}` is available from configured provider route(s): {providers}. \
9981 Pass `--provider <provider>` with `--model {model}` to choose one explicitly. \
9982 In the TUI, use `/provider`, `/model`, or `/setup` to resolve the route before sending."
9983 );
9984 }
9985
9986 let reasoning_effort = config
9993 .reasoning_effort()
9994 .map(crate::tui::app::ReasoningEffort::from_setting);
9995 Ok(CliAutoRoute {
9996 provider: config.api_provider(),
9997 model: model.to_string(),
9998 auto_controls_reasoning: matches!(
9999 reasoning_effort,
10000 Some(crate::tui::app::ReasoningEffort::Auto)
10001 ),
10002 reasoning_effort,
10003 auto_model: false,
10004 })
10005 }
10006}
10007
10008async fn resolve_cli_exec_route(
10009 config: &Config,
10010 model: &str,
10011 prompt: &str,
10012 force_configured_route: bool,
10013) -> Result<CliAutoRoute> {
10014 if force_configured_route && !model.trim().eq_ignore_ascii_case("auto") {
10015 let reasoning_effort = config
10016 .reasoning_effort()
10017 .map(crate::tui::app::ReasoningEffort::from_setting);
10018 return Ok(CliAutoRoute {
10019 provider: config.api_provider(),
10020 model: model.to_string(),
10021 auto_controls_reasoning: matches!(
10022 reasoning_effort,
10023 Some(crate::tui::app::ReasoningEffort::Auto)
10024 ),
10025 reasoning_effort,
10026 auto_model: false,
10027 });
10028 }
10029 resolve_cli_auto_route(config, model, prompt).await
10030}
10031
10032fn should_force_configured_exec_route(
10033 resuming: bool,
10034 explicit_provider: Option<&str>,
10035 explicit_model: Option<&str>,
10036) -> bool {
10037 resuming || explicit_provider.is_some() || explicit_model.is_none()
10042}
10043
10044async fn run_one_shot(
10045 config: &Config,
10046 model: &str,
10047 prompt: &str,
10048 force_configured_route: bool,
10049) -> Result<()> {
10050 use crate::client::DeepSeekClient;
10051 use crate::models::{
10052 ContentBlock, Message, MessageRequest, is_incomplete_stop_reason, stop_reason_detail,
10053 };
10054
10055 let route = resolve_cli_exec_route(config, model, prompt, force_configured_route).await?;
10056 let execution_config = config_for_cli_route(config, &route);
10057 let client = DeepSeekClient::new(&execution_config)?;
10058 let reasoning_effort = route.reasoning_effort.and_then(|effort| {
10059 cli_reasoning_effort_value_for_prompt(&execution_config, &route.model, effort, prompt)
10060 });
10061 let model = route.model;
10062 let request_route = client.effective_route_envelope(&model, chrono::Utc::now());
10063
10064 let request = MessageRequest {
10065 model,
10066 messages: vec![Message {
10067 role: "user".to_string(),
10068 content: vec![ContentBlock::Text {
10069 text: prompt.to_string(),
10070 cache_control: None,
10071 }],
10072 }],
10073 max_tokens: crate::route_budget::effective_max_output_tokens_for_route(
10074 request_route.provider,
10075 &request_route.model,
10076 None,
10077 ),
10078 system: None,
10079 tools: None,
10080 tool_choice: None,
10081 metadata: None,
10082 thinking: None,
10083 reasoning_effort,
10084 stream: Some(false),
10085 temperature: None,
10086 top_p: None,
10087 };
10088
10089 let response = client.create_message(request).await?;
10090 let stop_reason = response.stop_reason.clone();
10091
10092 for block in response.content {
10093 if let ContentBlock::Text { text, .. } = block {
10094 println!("{text}");
10095 }
10096 }
10097
10098 if is_incomplete_stop_reason(stop_reason.as_deref()) {
10099 anyhow::bail!(
10100 "Model response incomplete: provider stop reason `{}`; the partial response was printed but the command did not succeed.",
10101 stop_reason_detail(stop_reason.as_deref())
10102 );
10103 }
10104
10105 Ok(())
10106}
10107
10108async fn run_one_shot_json(
10109 config: &Config,
10110 model: &str,
10111 prompt: &str,
10112 force_configured_route: bool,
10113) -> Result<()> {
10114 use crate::client::DeepSeekClient;
10115 use crate::models::{
10116 ContentBlock, Message, MessageRequest, SystemPrompt, is_incomplete_stop_reason,
10117 stop_reason_detail,
10118 };
10119
10120 let route = resolve_cli_exec_route(config, model, prompt, force_configured_route).await?;
10121 let execution_config = config_for_cli_route(config, &route);
10122 let provider = execution_config.provider_identity_for(route.provider);
10123 let client = DeepSeekClient::new(&execution_config)?;
10124 let model = route.model.clone();
10125 let reasoning_effort = route.reasoning_effort.and_then(|effort| {
10126 cli_reasoning_effort_value_for_prompt(&execution_config, &model, effort, prompt)
10127 });
10128 let request_route = client.effective_route_envelope(&model, chrono::Utc::now());
10129 let request = MessageRequest {
10130 model: model.clone(),
10131 messages: vec![Message {
10132 role: "user".to_string(),
10133 content: vec![ContentBlock::Text {
10134 text: prompt.to_string(),
10135 cache_control: None,
10136 }],
10137 }],
10138 max_tokens: crate::route_budget::effective_max_output_tokens_for_route(
10139 request_route.provider,
10140 &request_route.model,
10141 None,
10142 ),
10143 system: Some(SystemPrompt::Text(
10144 "You are a coding assistant. Give concise, actionable responses.".to_string(),
10145 )),
10146 tools: None,
10147 tool_choice: None,
10148 metadata: None,
10149 thinking: None,
10150 reasoning_effort,
10151 stream: Some(false),
10152 temperature: None,
10153 top_p: None,
10154 };
10155
10156 let response = client.create_message(request).await?;
10157 let stop_reason = response.stop_reason.clone();
10158 let usage = response.usage.clone();
10159 let mut output = String::new();
10160 for block in response.content {
10161 if let ContentBlock::Text { text, .. } = block {
10162 output.push_str(&text);
10163 }
10164 }
10165 println!(
10166 "{}",
10167 serde_json::to_string_pretty(&one_shot_exec_json_receipt(
10168 provider,
10169 model,
10170 output,
10171 stop_reason.clone(),
10172 usage,
10173 ))?
10174 );
10175 if is_incomplete_stop_reason(stop_reason.as_deref()) {
10176 anyhow::bail!(
10177 "Model response incomplete: provider stop reason `{}`; the JSON receipt records success=false.",
10178 stop_reason_detail(stop_reason.as_deref())
10179 );
10180 }
10181 Ok(())
10182}
10183
10184fn one_shot_exec_json_receipt(
10185 provider: String,
10186 model: String,
10187 output: String,
10188 stop_reason: Option<String>,
10189 usage: crate::models::Usage,
10190) -> serde_json::Value {
10191 let incomplete = crate::models::is_incomplete_stop_reason(stop_reason.as_deref());
10192 let error = incomplete.then(|| {
10193 format!(
10194 "Model response incomplete: provider stop reason `{}`.",
10195 crate::models::stop_reason_detail(stop_reason.as_deref())
10196 )
10197 });
10198 serde_json::json!({
10199 "mode": "one-shot",
10200 "provider": provider,
10201 "model": model,
10202 "success": !incomplete,
10203 "output": output,
10204 "stop_reason": stop_reason,
10205 "usage": usage,
10206 "error": error,
10207 })
10208}
10209
10210fn exec_stream_provider_route(
10211 identity: &crate::config::ProviderIdentity,
10212) -> (String, Option<String>) {
10213 let provider = identity.provider.as_str().to_string();
10214 let provider_id = if identity.provider == crate::config::ApiProvider::Custom {
10215 identity.exact_id.clone()
10216 } else {
10217 None
10218 };
10219 (provider, provider_id)
10220}
10221
10222#[derive(serde::Serialize)]
10223struct ExecStreamMeta {
10224 receipt_kind: &'static str,
10225 provider: String,
10226 #[serde(skip_serializing_if = "Option::is_none")]
10230 provider_id: Option<String>,
10231 model: String,
10232 route_source: String,
10233 #[serde(skip_serializing_if = "Option::is_none")]
10234 input_tokens: Option<u32>,
10235 #[serde(skip_serializing_if = "Option::is_none")]
10236 output_tokens: Option<u32>,
10237 #[serde(skip_serializing_if = "Option::is_none")]
10238 prompt_cache_hit_tokens: Option<u32>,
10239 #[serde(skip_serializing_if = "Option::is_none")]
10240 prompt_cache_miss_tokens: Option<u32>,
10241 #[serde(skip_serializing_if = "Option::is_none")]
10242 prompt_cache_write_tokens: Option<u32>,
10243 #[serde(skip_serializing_if = "Option::is_none")]
10244 reasoning_tokens: Option<u32>,
10245 #[serde(skip_serializing_if = "Option::is_none")]
10247 codewhale_max_output_tokens: Option<u32>,
10248 #[serde(skip_serializing_if = "Option::is_none")]
10251 codewhale_max_output_tokens_source: Option<&'static str>,
10252 duration_ms: u64,
10253 #[serde(skip_serializing_if = "Option::is_none")]
10254 retry_count: Option<u32>,
10255 approval_posture: String,
10256 sandbox_posture: String,
10257 #[serde(skip_serializing_if = "Option::is_none")]
10258 binary_sha256: Option<String>,
10259 #[serde(skip_serializing_if = "Option::is_none")]
10260 config_sha256: Option<String>,
10261 prompt_sha256: String,
10262 #[serde(skip_serializing_if = "Option::is_none")]
10263 tool_catalog_sha256: Option<String>,
10264 input_analysis: ExecStreamInputAnalysis,
10265 visible_final_answer_chars: usize,
10266 session_id: String,
10267 resume_command: String,
10268 workspace: String,
10269 message_count: usize,
10270 #[serde(skip_serializing_if = "Option::is_none")]
10271 status: Option<String>,
10272 #[serde(skip_serializing_if = "Option::is_none")]
10273 termination_reason: Option<String>,
10274 #[serde(skip_serializing_if = "Option::is_none")]
10275 error_category: Option<String>,
10276 #[serde(skip_serializing_if = "Option::is_none")]
10277 error: Option<String>,
10278}
10279
10280#[derive(Debug, Default, Clone, serde::Serialize, PartialEq, Eq)]
10281struct ExecStreamInputAnalysis {
10282 estimated_request_tokens: usize,
10283 estimated_message_content_tokens: usize,
10284 estimated_system_tokens: usize,
10285 estimated_framing_tokens: usize,
10286 user_message_count: usize,
10287 assistant_message_count: usize,
10288 tool_message_count: usize,
10289 tool_use_count: usize,
10290 tool_result_count: usize,
10291 text_chars: usize,
10292 thinking_chars: usize,
10293 tool_use_input_chars: usize,
10294 tool_result_chars: usize,
10295 text_estimated_tokens: usize,
10296 thinking_estimated_tokens: usize,
10297 tool_use_input_estimated_tokens: usize,
10298 tool_result_estimated_tokens: usize,
10299}
10300
10301#[derive(serde::Serialize)]
10302#[serde(tag = "type")]
10303#[allow(clippy::large_enum_variant)]
10306enum ExecStreamEvent {
10307 #[serde(rename = "content")]
10308 Content { content: String },
10309 #[serde(rename = "tool_use")]
10310 ToolUse {
10311 name: String,
10312 id: String,
10313 input: serde_json::Value,
10314 started_at: String,
10315 },
10316 #[serde(rename = "tool_result")]
10317 ToolResult {
10318 id: String,
10319 name: String,
10320 output: String,
10321 status: String,
10322 started_at: String,
10323 completed_at: String,
10324 duration_ms: u64,
10325 side_effect_status: String,
10326 #[serde(skip_serializing_if = "Option::is_none")]
10327 error_category: Option<String>,
10328 #[serde(skip_serializing_if = "Option::is_none")]
10329 truncated: Option<bool>,
10330 #[serde(skip_serializing_if = "Option::is_none")]
10331 artifact: Option<serde_json::Value>,
10332 #[serde(skip_serializing_if = "Option::is_none")]
10333 result_metadata: Option<serde_json::Value>,
10334 },
10335 #[serde(rename = "agent_spawned")]
10343 AgentSpawned {
10344 id: String,
10345 model: String,
10346 spawn_depth: u32,
10347 #[serde(skip_serializing_if = "Option::is_none")]
10348 parent_run_id: Option<String>,
10349 #[serde(skip_serializing_if = "Option::is_none")]
10351 route_source: Option<String>,
10352 },
10353 #[serde(rename = "sandbox_denied")]
10354 SandboxDenied {
10355 tool_id: String,
10356 tool_name: String,
10357 reason: String,
10358 outcome: String,
10359 },
10360 #[serde(rename = "workflow_event")]
10361 WorkflowEvent {
10362 run_id: String,
10363 event: serde_json::Value,
10364 },
10365 #[serde(rename = "session_capture")]
10366 SessionCapture { content: String },
10367 #[serde(rename = "service_released")]
10368 #[cfg(unix)]
10369 ServiceReleased {
10370 task_id: String,
10371 pid: u32,
10372 process_group_id: u32,
10373 ownership: String,
10374 },
10375 #[serde(rename = "turn_usage")]
10382 TurnUsage {
10383 turn: u32,
10385 input_tokens: u32,
10386 output_tokens: u32,
10387 #[serde(skip_serializing_if = "Option::is_none")]
10388 reasoning_tokens: Option<u32>,
10389 #[serde(skip_serializing_if = "Option::is_none")]
10390 prompt_cache_hit_tokens: Option<u32>,
10391 #[serde(skip_serializing_if = "Option::is_none")]
10392 prompt_cache_miss_tokens: Option<u32>,
10393 #[serde(skip_serializing_if = "Option::is_none")]
10394 prompt_cache_write_tokens: Option<u32>,
10395 #[serde(skip_serializing_if = "Option::is_none")]
10396 reasoning_replay_tokens: Option<u32>,
10397 duration_ms: u64,
10398 },
10399 #[serde(rename = "metadata")]
10400 Metadata { meta: Box<ExecStreamMeta> },
10401 #[serde(rename = "done")]
10402 Done,
10403 #[serde(rename = "error")]
10404 Error { error: String },
10405}
10406
10407fn exec_sandbox_elevation_authorized(
10408 allow_sandbox_elevation: bool,
10409 explicit_sandbox: Option<&str>,
10410) -> bool {
10411 allow_sandbox_elevation
10412 || explicit_sandbox.is_some_and(|policy| policy.eq_ignore_ascii_case("danger-full-access"))
10413}
10414
10415fn emit_exec_stream_event(event: &ExecStreamEvent) -> Result<()> {
10416 println!("{}", serde_json::to_string(&exec_stream_value(event)?)?);
10417 Ok(())
10418}
10419
10420const EXEC_EXIT_RETRYABLE_INFRA: i32 = 75; fn exec_failure_exit_code(error_category: Option<&str>) -> i32 {
10436 match error_category {
10437 Some("network" | "timeout") => EXEC_EXIT_RETRYABLE_INFRA,
10438 _ => 1,
10439 }
10440}
10441
10442fn exec_error_event_is_fatal(envelope: &crate::error_taxonomy::ErrorEnvelope) -> bool {
10449 !envelope.recoverable
10450}
10451
10452fn exec_stream_value(event: &ExecStreamEvent) -> Result<serde_json::Value> {
10453 let mut value = serde_json::to_value(event)?;
10454 if let Some(object) = value.as_object_mut() {
10455 object.insert("schema_version".to_string(), serde_json::json!(1));
10456 object.insert(
10457 "schema".to_string(),
10458 serde_json::json!("codewhale.exec-stream"),
10459 );
10460 }
10461 Ok(value)
10462}
10463
10464fn tool_error_receipt_category(error: &crate::tools::spec::ToolError) -> &'static str {
10465 use crate::tools::spec::ToolError;
10466 match error {
10467 ToolError::InvalidInput { .. } => "invalid_input",
10468 ToolError::MissingField { .. } => "missing_field",
10469 ToolError::PathEscape { .. } => "path_escape",
10470 ToolError::ExecutionFailed { .. } => "execution_failed",
10471 ToolError::Timeout { .. } => "timeout",
10472 ToolError::Cancelled { .. } => "cancelled",
10473 ToolError::NotAvailable { .. } => "not_available",
10474 ToolError::PermissionDenied { .. } => "permission_denied",
10475 }
10476}
10477
10478fn tool_artifact_receipt(metadata: Option<&serde_json::Value>) -> Option<serde_json::Value> {
10479 let object = metadata?.as_object()?;
10480 let mut artifact = serde_json::Map::new();
10481 for key in [
10482 "artifact_id",
10483 "artifact_path",
10484 "artifact_relative_path",
10485 "artifact_byte_size",
10486 "spillover_path",
10487 "content_digest",
10488 "original_byte_count",
10489 "retained_head_bytes",
10490 "retained_tail_bytes",
10491 ] {
10492 if let Some(value) = object.get(key) {
10493 artifact.insert(key.to_string(), value.clone());
10494 }
10495 }
10496 (!artifact.is_empty()).then_some(serde_json::Value::Object(artifact))
10497}
10498
10499fn current_binary_sha256() -> Option<String> {
10500 let bytes = std::fs::read(std::env::current_exe().ok()?).ok()?;
10501 Some(format!("sha256:{}", crate::hashing::sha256_hex(&bytes)))
10502}
10503
10504async fn run_workflow_tool_command(
10505 cli: &Cli,
10506 args: WorkflowToolArgs,
10507 plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
10508) -> Result<()> {
10509 match run_workflow_tool_command_inner(cli, args, plugin_registry).await {
10510 Ok(()) => Ok(()),
10511 Err(error) => {
10512 let _ = emit_exec_stream_event(&ExecStreamEvent::Error {
10513 error: format!("{error:#}"),
10514 });
10515 exit_workflow_tool_failure();
10516 }
10517 }
10518}
10519
10520async fn run_workflow_tool_command_inner(
10521 cli: &Cli,
10522 args: WorkflowToolArgs,
10523 plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
10524) -> Result<()> {
10525 use crate::tools::spec::ToolSpec;
10526
10527 if args.approval_source != "explicit-workflow-command" {
10528 bail!("workflow-tool requires --approval-source explicit-workflow-command");
10529 }
10530 let input: serde_json::Value = serde_json::from_str(&args.input_json)
10531 .context("--input-json must be a valid Workflow tool input object")?;
10532 if !input.is_object() {
10533 bail!("--input-json must be a JSON object");
10534 }
10535 if !input
10536 .get("action")
10537 .and_then(serde_json::Value::as_str)
10538 .is_some_and(|action| action.eq_ignore_ascii_case("run"))
10539 {
10540 bail!("workflow-tool accepts only action=run");
10541 }
10542
10543 let workspace = resolve_workspace(cli);
10544 let mut config = load_config_from_cli(cli)?;
10545 merge_user_workspace_config(&mut config, cli.config.clone(), &workspace);
10546 if let Ok(env_url) =
10547 std::env::var("CODEWHALE_BASE_URL").or_else(|_| std::env::var("DEEPSEEK_BASE_URL"))
10548 {
10549 let trimmed = env_url.trim();
10550 if !trimmed.is_empty() {
10551 config.base_url = Some(trimmed.to_string());
10552 }
10553 }
10554
10555 let model = resolve_exec_model(&config, None);
10556 let route = resolve_cli_exec_route(
10557 &config,
10558 &model,
10559 "Run a checked-in Workflow through the host runtime",
10560 true,
10561 )
10562 .await?;
10563 let execution_config = config_for_cli_route(&config, &route);
10564 let route_identity = execution_config
10565 .active_provider_identity(route.provider)
10566 .map_err(anyhow::Error::msg)
10567 .context("workflow terminal route lost its exact provider identity")?;
10568 let (route_provider, route_provider_id) = exec_stream_provider_route(&route_identity);
10569 let workflow_input_sha256 = format!(
10570 "sha256:{}",
10571 crate::hashing::sha256_hex(&serde_json::to_vec(&input)?)
10572 );
10573 let tool_id = format!("workflow_host_{}", &uuid::Uuid::new_v4().to_string()[..8]);
10574 let tool_started = Instant::now();
10575 let tool_started_at = chrono::Utc::now().to_rfc3339();
10576
10577 emit_exec_stream_event(&ExecStreamEvent::ToolUse {
10578 name: "workflow".to_string(),
10579 id: tool_id.clone(),
10580 input: input.clone(),
10581 started_at: tool_started_at.clone(),
10582 })?;
10583
10584 let (event_tx, event_rx) = tokio::sync::mpsc::channel(1024);
10585 let (stop_tx, stop_rx) = tokio::sync::oneshot::channel();
10586 let event_forwarder = tokio::spawn(forward_direct_workflow_events(event_rx, stop_rx));
10587 let (tool, context) = match build_direct_workflow_tool(
10588 &execution_config,
10589 &route,
10590 &workspace,
10591 event_tx,
10592 plugin_registry,
10593 )
10594 .await
10595 {
10596 Ok(built) => built,
10597 Err(err) => {
10598 let _ = stop_tx.send(());
10599 let _ = event_forwarder.await;
10600 exit_workflow_tool_error(&tool_id, err.to_string());
10601 }
10602 };
10603
10604 let result = tool.execute(input, &context).await;
10605 drop(tool);
10606 let _ = stop_tx.send(());
10607 event_forwarder
10608 .await
10609 .context("workflow event forwarder task failed")??;
10610
10611 let result = match result {
10612 Ok(result) => result,
10613 Err(err) => {
10614 let error = err.to_string();
10615 exit_workflow_tool_error(&tool_id, error);
10616 }
10617 };
10618
10619 let workflow_status =
10620 direct_workflow_status(&result.content).unwrap_or_else(|| "unknown".to_string());
10621 let completed = result.success && workflow_status == "completed";
10622 emit_exec_stream_event(&ExecStreamEvent::ToolResult {
10623 id: tool_id,
10624 name: "workflow".to_string(),
10625 output: result.content.clone(),
10626 status: if completed { "success" } else { "error" }.to_string(),
10627 started_at: tool_started_at,
10628 completed_at: chrono::Utc::now().to_rfc3339(),
10629 duration_ms: u64::try_from(tool_started.elapsed().as_millis()).unwrap_or(u64::MAX),
10630 side_effect_status: result
10631 .metadata
10632 .as_ref()
10633 .and_then(|metadata| metadata.get("side_effect_status"))
10634 .and_then(serde_json::Value::as_str)
10635 .unwrap_or("unknown")
10636 .to_string(),
10637 error_category: (!completed).then(|| "tool_error".to_string()),
10638 truncated: result
10639 .metadata
10640 .as_ref()
10641 .and_then(|metadata| metadata.get("truncated"))
10642 .and_then(serde_json::Value::as_bool),
10643 artifact: tool_artifact_receipt(result.metadata.as_ref()),
10644 result_metadata: result.metadata.clone(),
10645 })?;
10646 emit_exec_stream_event(&ExecStreamEvent::Metadata {
10647 meta: Box::new(ExecStreamMeta {
10648 receipt_kind: "terminal",
10649 provider: route_provider,
10650 provider_id: route_provider_id,
10651 model: "host-workflow".to_string(),
10655 route_source: "host_workflow".to_string(),
10656 input_tokens: None,
10657 output_tokens: None,
10658 prompt_cache_hit_tokens: None,
10659 prompt_cache_miss_tokens: None,
10660 prompt_cache_write_tokens: None,
10661 reasoning_tokens: None,
10662 codewhale_max_output_tokens: None,
10663 codewhale_max_output_tokens_source: None,
10664 duration_ms: u64::try_from(tool_started.elapsed().as_millis()).unwrap_or(u64::MAX),
10665 retry_count: None,
10666 approval_posture: "explicit_workflow_command".to_string(),
10667 sandbox_posture: "configured".to_string(),
10668 binary_sha256: current_binary_sha256(),
10669 config_sha256: None,
10670 prompt_sha256: workflow_input_sha256,
10671 tool_catalog_sha256: None,
10672 input_analysis: ExecStreamInputAnalysis::default(),
10673 visible_final_answer_chars: result.content.chars().count(),
10674 session_id: String::new(),
10675 resume_command: String::new(),
10676 workspace: workspace.display().to_string(),
10677 message_count: 0,
10678 status: Some(workflow_status.clone()),
10679 termination_reason: Some(if completed { "resolved" } else { "tool_error" }.to_string()),
10680 error_category: (!completed).then(|| "tool".to_string()),
10681 error: (!completed)
10682 .then(|| format!("workflow run ended with terminal status {workflow_status}")),
10683 }),
10684 })?;
10685 if !completed {
10686 let error = format!("workflow run ended with terminal status {workflow_status}");
10687 emit_exec_stream_event(&ExecStreamEvent::Error {
10688 error: error.clone(),
10689 })?;
10690 exit_workflow_tool_failure();
10691 }
10692 emit_exec_stream_event(&ExecStreamEvent::Done)?;
10693 Ok(())
10694}
10695
10696fn exit_workflow_tool_failure() -> ! {
10697 let _ = io::stdout().flush();
10698 std::process::exit(1)
10699}
10700
10701fn exit_workflow_tool_error(tool_id: &str, error: String) -> ! {
10702 let now = chrono::Utc::now().to_rfc3339();
10703 let _ = emit_exec_stream_event(&ExecStreamEvent::ToolResult {
10704 id: tool_id.to_string(),
10705 name: "workflow".to_string(),
10706 output: error.clone(),
10707 status: "error".to_string(),
10708 started_at: now.clone(),
10709 completed_at: now,
10710 duration_ms: 0,
10711 side_effect_status: "unknown".to_string(),
10712 error_category: Some("execution_failed".to_string()),
10713 truncated: None,
10714 artifact: None,
10715 result_metadata: None,
10716 });
10717 let _ = emit_exec_stream_event(&ExecStreamEvent::Error { error });
10718 exit_workflow_tool_failure()
10719}
10720
10721async fn initialize_direct_workflow_mcp_pool(
10722 config: &Config,
10723 workspace: &Path,
10724 network_policy: Option<crate::network_policy::NetworkPolicyDecider>,
10725 plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
10726) -> Option<(
10727 std::sync::Arc<tokio::sync::Mutex<crate::mcp::McpPool>>,
10728 Vec<(String, String)>,
10729)> {
10730 if !config.features().enabled(Feature::Mcp) {
10731 return None;
10732 }
10733 let mut pool = crate::mcp::McpPool::from_config_path_with_workspace_and_plugins(
10734 &config.mcp_config_path(),
10735 workspace,
10736 plugin_registry,
10737 )
10738 .unwrap_or_else(|error| {
10739 tracing::debug!("No MCP config for direct Workflow runtime: {error:#}");
10740 crate::mcp::McpPool::new(crate::mcp::McpConfig::default())
10741 });
10742 if let Some(policy) = network_policy {
10743 pool = pool.with_network_policy(policy);
10744 }
10745 let failures = pool
10746 .connect_all()
10747 .await
10748 .into_iter()
10749 .map(|(server, error)| (server, format!("{error:#}")))
10750 .collect();
10751 Some((std::sync::Arc::new(tokio::sync::Mutex::new(pool)), failures))
10752}
10753
10754async fn build_direct_workflow_tool(
10755 config: &Config,
10756 route: &CliAutoRoute,
10757 workspace: &Path,
10758 event_tx: tokio::sync::mpsc::Sender<crate::core::events::Event>,
10759 plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
10760) -> Result<(
10761 crate::tools::workflow::WorkflowTool,
10762 crate::tools::ToolContext,
10763)> {
10764 use std::sync::Arc;
10765
10766 use crate::client::DeepSeekClient;
10767 use crate::core::authority::shell_policy_for_mode;
10768 use crate::fleet::roster::FleetRoster;
10769 use crate::tools::AgentToolSurfaceOptions;
10770 use crate::tools::goal::new_shared_goal_state;
10771 use crate::tools::subagent::{SubAgentRuntime, new_shared_subagent_manager_with_timeout};
10772 use crate::tools::todo::new_shared_todo_list;
10773 use crate::tui::app::AppMode;
10774
10775 let provider = config.api_provider();
10776 if !config.subagents_enabled_for_provider(provider) {
10777 bail!(
10778 "Workflow dispatch requires sub-agents for provider {} ({})",
10779 provider.as_str(),
10780 config
10781 .subagents_disabled_reason()
10782 .unwrap_or("provider-specific sub-agent configuration disabled it")
10783 );
10784 }
10785
10786 let yolo = config.yolo.unwrap_or(false);
10787 let mode = if yolo {
10788 AppMode::Yolo
10789 } else {
10790 AppMode::Operate
10791 };
10792 let allow_shell = yolo || config.allow_shell();
10793 let shell_policy = shell_policy_for_mode(mode, allow_shell);
10794 let trusted = crate::workspace_trust::WorkspaceTrust::load_for(workspace);
10795 let mut context = crate::tools::ToolContext::with_auto_approve(
10796 workspace.to_path_buf(),
10797 yolo,
10798 config.notes_path(),
10799 config.mcp_config_path(),
10800 yolo,
10801 )
10802 .with_features(config.features())
10803 .with_skills_config(
10804 config.skills_dir(),
10805 config.skills_config().scan_codewhale_only(),
10806 )
10807 .with_plugin_registry(std::sync::Arc::clone(&plugin_registry))
10808 .with_shell_policy(shell_policy)
10809 .with_trusted_external_paths(trusted.paths().to_vec())
10810 .with_elevated_sandbox_policy(crate::core::authority::sandbox_policy_for_turn(
10811 mode,
10812 if yolo {
10813 crate::tui::approval::ApprovalMode::Bypass
10814 } else {
10815 crate::tui::approval::ApprovalMode::Suggest
10816 },
10817 config.sandbox_mode.as_deref(),
10818 workspace,
10819 ));
10820 let network_policy = config.network.clone().map(|network| {
10821 crate::network_policy::NetworkPolicyDecider::with_default_audit(network.into_runtime())
10822 });
10823 if let Some(policy) = network_policy.as_ref() {
10824 context = context.with_network_policy(policy.clone());
10825 }
10826 if config.memory_enabled() {
10827 context.memory_path = Some(config.memory_path());
10828 }
10829 context.search_provider = config.search_provider();
10830 context.search_api_key = config
10831 .search
10832 .as_ref()
10833 .and_then(|search| search.api_key.clone());
10834 context.search_base_url = config
10835 .search
10836 .as_ref()
10837 .and_then(|search| search.base_url.clone());
10838 if let Some(backend) = crate::sandbox::backend::create_backend(config)? {
10839 context = context.with_sandbox_backend(Arc::from(backend));
10840 }
10841
10842 let max_subagents = config.max_subagents_for_provider(provider);
10843 let manager = new_shared_subagent_manager_with_timeout(
10844 workspace.to_path_buf(),
10845 max_subagents,
10846 config
10847 .max_admitted_subagents_for_provider(provider)
10848 .max(max_subagents),
10849 Duration::from_secs(config.subagent_heartbeat_timeout_secs_for_provider(provider)),
10850 config.launch_concurrency_for_provider(provider),
10851 config.subagent_token_budget_for_provider(provider),
10852 );
10853 let roster = Arc::new(FleetRoster::load(&config.fleet_config(), workspace));
10854 let mut role_models = roster.model_overrides();
10855 role_models.extend(config.subagent_model_overrides());
10856
10857 let features = config.features();
10858 let mut surface = AgentToolSurfaceOptions::new(shell_policy);
10859 surface.apply_patch_enabled = features.enabled(Feature::ApplyPatch);
10860 surface.web_search_enabled = features.enabled(Feature::WebSearch);
10861 surface.memory_tool_enabled = config.memory_enabled();
10862 surface.vision_config = features
10863 .enabled(Feature::VisionModel)
10864 .then(|| config.vision_model_config())
10865 .flatten();
10866 surface.speech_output_dir = config.speech_output_dir();
10867 surface.goal_state = Some(new_shared_goal_state());
10868
10869 let client = DeepSeekClient::new(config)?;
10870 let reasoning_effort_auto = route.auto_controls_reasoning;
10877 let reasoning_effort = route
10878 .reasoning_effort
10879 .and_then(|effort| cli_reasoning_effort_value(config, &route.model, effort));
10880 let mcp_pool = if let Some((pool, failures)) =
10881 initialize_direct_workflow_mcp_pool(config, workspace, network_policy, plugin_registry)
10882 .await
10883 {
10884 for (server, error) in failures {
10885 tracing::warn!(
10886 server = %server,
10887 error = %error,
10888 "direct Workflow runtime could not connect MCP server"
10889 );
10890 }
10891 Some(pool)
10892 } else {
10893 None
10894 };
10895 let runtime = SubAgentRuntime::new(
10896 client,
10897 route.model.clone(),
10898 context.clone(),
10899 allow_shell,
10900 Some(event_tx),
10901 manager.clone(),
10902 )
10903 .with_locale_tag(
10904 crate::localization::resolve_locale(
10905 &crate::settings::Settings::load_persisted()
10906 .unwrap_or_default()
10907 .locale,
10908 )
10909 .tag(),
10910 )
10911 .with_role_models(role_models)
10912 .with_api_config(config.clone())
10913 .with_fleet_roster(roster)
10914 .with_auto_model(route.auto_model)
10915 .with_reasoning_effort(reasoning_effort, reasoning_effort_auto)
10916 .with_agent_tool_surface_options(surface)
10917 .with_max_spawn_depth(config.subagent_max_spawn_depth_for_provider(provider))
10918 .with_step_api_timeout(Duration::from_secs(
10919 config.subagent_api_timeout_secs_for_provider(provider),
10920 ))
10921 .with_speech_output_dir(config.speech_output_dir())
10922 .with_mcp_pool(mcp_pool)
10923 .with_todos(new_shared_todo_list())
10924 .with_parent_mode(mode);
10925
10926 Ok((
10927 crate::tools::workflow::WorkflowTool::new(manager, runtime).with_explicit_cli_approval(),
10928 context,
10929 ))
10930}
10931
10932async fn forward_direct_workflow_events(
10933 mut event_rx: tokio::sync::mpsc::Receiver<crate::core::events::Event>,
10934 mut stop_rx: tokio::sync::oneshot::Receiver<()>,
10935) -> Result<()> {
10936 loop {
10937 tokio::select! {
10938 biased;
10939 event = event_rx.recv() => match event {
10940 Some(event) => emit_direct_workflow_event(event)?,
10941 None => return Ok(()),
10942 },
10943 _ = &mut stop_rx => {
10944 while let Ok(event) = event_rx.try_recv() {
10945 emit_direct_workflow_event(event)?;
10946 }
10947 return Ok(());
10948 }
10949 }
10950 }
10951}
10952
10953fn emit_direct_workflow_event(event: crate::core::events::Event) -> Result<()> {
10954 if let crate::core::events::Event::WorkflowUi { run_id, event } = event {
10955 emit_exec_stream_event(&ExecStreamEvent::WorkflowEvent { run_id, event })?;
10956 }
10957 Ok(())
10958}
10959
10960fn direct_workflow_status(content: &str) -> Option<String> {
10961 serde_json::from_str::<serde_json::Value>(content)
10962 .ok()?
10963 .get("status")?
10964 .as_str()
10965 .map(str::to_ascii_lowercase)
10966}
10967
10968fn exec_stream_input_analysis(
10969 messages: &[Message],
10970 system: Option<&SystemPrompt>,
10971) -> ExecStreamInputAnalysis {
10972 let mut analysis = ExecStreamInputAnalysis {
10973 estimated_request_tokens: crate::compaction::estimate_input_tokens_conservative(
10974 messages, system,
10975 ),
10976 estimated_message_content_tokens: crate::compaction::estimate_tokens(messages),
10977 estimated_system_tokens: exec_stream_estimate_system_tokens(system),
10978 estimated_framing_tokens: messages.len().saturating_mul(12).saturating_add(48),
10979 ..ExecStreamInputAnalysis::default()
10980 };
10981
10982 for message in messages {
10983 match message.role.as_str() {
10984 "user" => analysis.user_message_count += 1,
10985 "assistant" => analysis.assistant_message_count += 1,
10986 "tool" => analysis.tool_message_count += 1,
10987 _ => {}
10988 }
10989
10990 for block in &message.content {
10991 match block {
10992 ContentBlock::Text { text, .. } => {
10993 exec_stream_add_text_estimate(
10994 text,
10995 &mut analysis.text_chars,
10996 &mut analysis.text_estimated_tokens,
10997 );
10998 }
10999 ContentBlock::Thinking { thinking, .. } => {
11000 exec_stream_add_text_estimate(
11001 thinking,
11002 &mut analysis.thinking_chars,
11003 &mut analysis.thinking_estimated_tokens,
11004 );
11005 }
11006 ContentBlock::ToolUse { input, .. } | ContentBlock::ServerToolUse { input, .. } => {
11007 analysis.tool_use_count += 1;
11008 exec_stream_add_json_estimate(
11009 input,
11010 &mut analysis.tool_use_input_chars,
11011 &mut analysis.tool_use_input_estimated_tokens,
11012 );
11013 }
11014 ContentBlock::ToolResult {
11015 content,
11016 content_blocks,
11017 ..
11018 } => {
11019 analysis.tool_result_count += 1;
11020 exec_stream_add_text_estimate(
11021 content,
11022 &mut analysis.tool_result_chars,
11023 &mut analysis.tool_result_estimated_tokens,
11024 );
11025 if let Some(blocks) = content_blocks {
11026 exec_stream_add_json_estimate(
11027 blocks,
11028 &mut analysis.tool_result_chars,
11029 &mut analysis.tool_result_estimated_tokens,
11030 );
11031 }
11032 }
11033 ContentBlock::ToolSearchToolResult { content, .. }
11034 | ContentBlock::CodeExecutionToolResult { content, .. } => {
11035 analysis.tool_result_count += 1;
11036 exec_stream_add_json_estimate(
11037 content,
11038 &mut analysis.tool_result_chars,
11039 &mut analysis.tool_result_estimated_tokens,
11040 );
11041 }
11042 ContentBlock::ImageUrl { .. } => {}
11043 }
11044 }
11045 }
11046
11047 analysis
11048}
11049
11050fn exec_stream_add_text_estimate(text: &str, chars: &mut usize, tokens: &mut usize) {
11051 *chars = chars.saturating_add(text.chars().count());
11052 *tokens = tokens.saturating_add(crate::compaction::estimate_text_tokens_conservative(text));
11053}
11054
11055fn exec_stream_add_json_estimate<T: serde::Serialize>(
11056 value: &T,
11057 chars: &mut usize,
11058 tokens: &mut usize,
11059) {
11060 let text = serde_json::to_string(value).unwrap_or_default();
11061 exec_stream_add_text_estimate(&text, chars, tokens);
11062}
11063
11064fn exec_stream_estimate_system_tokens(system: Option<&SystemPrompt>) -> usize {
11065 match system {
11066 Some(SystemPrompt::Text(text)) => {
11067 crate::compaction::estimate_text_tokens_conservative(text)
11068 }
11069 Some(SystemPrompt::Blocks(blocks)) => blocks
11070 .iter()
11071 .map(|block| crate::compaction::estimate_text_tokens_conservative(&block.text))
11072 .sum(),
11073 None => 0,
11074 }
11075}
11076
11077fn exec_saved_session_line(session_id: &str) -> String {
11078 format!("session: {}", truncate_id(session_id))
11079}
11080
11081fn exec_resumed_session_line(session_id: &str) -> String {
11082 format!("resumed session: {}", truncate_id(session_id))
11083}
11084
11085fn exec_stream_session_ref(session_id: &str) -> String {
11086 crate::utils::redacted_identifier_for_log(session_id)
11087}
11088
11089fn exec_stream_resume_hint(session_id: &str) -> String {
11090 if session_id.trim().is_empty() {
11091 String::new()
11092 } else {
11093 "codewhale exec --resume <redacted-session-id>".to_string()
11094 }
11095}
11096
11097#[derive(Clone, Copy)]
11098struct PersistedProviderRoute<'a> {
11099 kind: &'a str,
11100 id: Option<&'a str>,
11101}
11102
11103fn persist_exec_session(
11104 messages: &[Message],
11105 model: &str,
11106 provider_route: PersistedProviderRoute<'_>,
11107 workspace: &Path,
11108 system_prompt: &Option<SystemPrompt>,
11109 session_id: Option<&str>,
11110 total_tokens: u64,
11111) -> Result<String> {
11112 let manager =
11113 SessionManager::default_location().context("could not open session manager for save")?;
11114 let mut saved = if let Some(id) = session_id.filter(|id| !id.trim().is_empty()) {
11115 match manager.load_session(id) {
11116 Ok(existing) => session_manager::update_session(
11117 existing,
11118 messages,
11119 total_tokens,
11120 system_prompt.as_ref(),
11121 ),
11122 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
11123 session_manager::create_saved_session_with_id_and_mode(
11124 id.to_string(),
11125 messages,
11126 model,
11127 workspace,
11128 total_tokens,
11129 system_prompt.as_ref(),
11130 Some("exec"),
11131 )
11132 }
11133 Err(err) => return Err(err).context("could not load existing exec session"),
11134 }
11135 } else {
11136 session_manager::create_saved_session_with_mode(
11137 messages,
11138 model,
11139 workspace,
11140 total_tokens,
11141 system_prompt.as_ref(),
11142 Some("exec"),
11143 )
11144 };
11145 stamp_exec_session_metadata(
11146 &mut saved,
11147 model,
11148 provider_route.kind,
11149 provider_route.id,
11150 workspace,
11151 );
11152 let id = saved.metadata.id.clone();
11153 manager
11154 .save_session(&saved)
11155 .context("could not save exec session")?;
11156 Ok(id)
11157}
11158
11159fn stamp_exec_session_metadata(
11160 saved: &mut session_manager::SavedSession,
11161 model: &str,
11162 model_provider_kind: &str,
11163 model_provider_id: Option<&str>,
11164 workspace: &Path,
11165) {
11166 saved.metadata.model = model.to_string();
11167 saved
11168 .metadata
11169 .set_model_provider_route(model_provider_kind, model_provider_id);
11170 saved.metadata.workspace = workspace.to_path_buf();
11171 saved.metadata.mode = Some("exec".to_string());
11172}
11173
11174#[derive(serde::Serialize)]
11175struct ExecToolEntry {
11176 name: String,
11177 success: bool,
11178 output: String,
11179}
11180
11181#[derive(serde::Serialize)]
11182struct ExecOutcome {
11183 kind: String,
11184 outcome: String,
11185 tool_name: String,
11186 reason: String,
11187}
11188
11189#[derive(serde::Serialize, Default)]
11190struct ExecSummary {
11191 mode: String,
11192 provider: String,
11193 model: String,
11194 prompt: String,
11195 output: String,
11196 tools: Vec<ExecToolEntry>,
11197 outcomes: Vec<ExecOutcome>,
11198 status: Option<String>,
11199 termination_reason: Option<String>,
11200 error_category: Option<String>,
11201 error: Option<String>,
11202 #[serde(skip_serializing_if = "Vec::is_empty")]
11203 released_services: Vec<crate::tools::shell::PersistentServiceReceipt>,
11204}
11205
11206fn validate_exec_tool_authority_resume(
11207 tool_authority_json: Option<&str>,
11208 resuming: bool,
11209) -> Result<()> {
11210 if tool_authority_json.is_some() && resuming {
11211 bail!(
11212 "Fleet tool authority cannot be combined with exec --resume, --session-id, or --continue"
11213 );
11214 }
11215 Ok(())
11216}
11217
11218fn exec_network_policy(
11219 config: &Config,
11220 outer_network_access: Option<bool>,
11221) -> Option<crate::network_policy::NetworkPolicyDecider> {
11222 if outer_network_access == Some(false) {
11225 return Some(crate::network_policy::NetworkPolicyDecider::new(
11226 crate::network_policy::NetworkPolicy {
11227 default: crate::network_policy::DecisionToml::Deny,
11228 ..crate::network_policy::NetworkPolicy::default()
11229 },
11230 None,
11231 ));
11232 }
11233 config.network.clone().map(|toml_cfg| {
11234 crate::network_policy::NetworkPolicyDecider::with_default_audit(toml_cfg.into_runtime())
11235 })
11236}
11237
11238fn apply_fleet_engine_feature_caps(
11239 features: &mut crate::features::Features,
11240 fleet_authority_active: bool,
11241 outer_network_access: Option<bool>,
11242 shell_authority: crate::tools::spec::ToolShellAuthority,
11243) {
11244 if fleet_authority_active {
11245 features.disable(crate::features::Feature::Subagents);
11246 features.disable(crate::features::Feature::Mcp);
11247 if shell_authority != crate::tools::spec::ToolShellAuthority::ReadOnly {
11248 features.disable(crate::features::Feature::ShellTool);
11249 }
11250 }
11251 if outer_network_access == Some(false) {
11252 features.disable(crate::features::Feature::WebSearch);
11253 }
11254}
11255
11256fn exec_max_steps(max_turns: Option<u32>) -> u32 {
11260 max_turns.unwrap_or(u32::MAX)
11261}
11262
11263#[allow(clippy::too_many_arguments)]
11264async fn run_exec_agent(
11265 config: &Config,
11266 model: &str,
11267 prompt: &str,
11268 workspace: PathBuf,
11269 max_subagents: usize,
11270 auto_approve: bool,
11271 allow_sandbox_elevation: bool,
11272 explicit_sandbox: Option<&str>,
11273 trust_mode: bool,
11274 json_output: bool,
11275 resume_session: Option<session_manager::SavedSession>,
11276 force_configured_route: bool,
11277 output_format: ExecOutputFormat,
11278 max_turns: u32,
11279 allowed_tools: Option<Vec<String>>,
11280 disallowed_tools: Option<Vec<String>>,
11281 append_system_prompt: Option<String>,
11282 tool_authority_json: Option<String>,
11283 plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
11284) -> Result<()> {
11285 use crate::compaction::CompactionConfig;
11286 use crate::core::engine::{EngineConfig, spawn_engine};
11287 use crate::core::events::Event;
11288 use crate::core::ops::Op;
11289 use crate::tools::plan::new_shared_plan_state;
11290 use crate::tools::todo::new_shared_todo_list;
11291 use crate::tui::app::AppMode;
11292
11293 validate_exec_tool_authority_resume(tool_authority_json.as_deref(), resume_session.is_some())?;
11294 let fleet_authority = tool_authority_json
11295 .as_deref()
11296 .map(crate::tools::spec::ToolAuthorityEnvelope::from_json)
11297 .transpose()
11298 .map_err(anyhow::Error::msg)?;
11299 let fleet_authority_active = fleet_authority.is_some();
11300 let outer_network_access = fleet_authority
11301 .as_ref()
11302 .and_then(|authority| authority.network_access);
11303 let outer_shell_authority = fleet_authority
11304 .as_ref()
11305 .map(|authority| authority.shell)
11306 .unwrap_or_default();
11307 if let Some(envelope) = fleet_authority {
11308 crate::tools::spec::install_process_tool_authority(envelope).map_err(anyhow::Error::msg)?;
11309 }
11310
11311 let route = resolve_cli_exec_route(config, model, prompt, force_configured_route).await?;
11312 let execution_config = config_for_cli_route(config, &route);
11313 let auto_model = route.auto_model;
11314 let effective_provider = route.provider;
11315 let effective_model = route.model;
11316 let validated_route = crate::route_runtime::resolve_runtime_route(
11317 &execution_config,
11318 effective_provider,
11319 Some(&effective_model),
11320 )
11321 .map_err(anyhow::Error::msg)?
11322 .validate()
11323 .map_err(anyhow::Error::msg)?;
11324 let effective_provider_name = validated_route.identity.key.clone();
11325 let effective_provider_id = validated_route.identity.exact_id.clone();
11326 let (effective_provider_kind, effective_stream_provider_id) =
11327 exec_stream_provider_route(&validated_route.identity);
11328 let route_source = if auto_model {
11329 "auto_resolver"
11330 } else {
11331 "explicit_or_configured"
11332 }
11333 .to_string();
11334 let exec_started = Instant::now();
11335 let prompt_sha256 = format!("sha256:{}", crate::hashing::sha256_hex(prompt.as_bytes()));
11336 let binary_sha256 = current_binary_sha256();
11337 let approval_posture = if auto_approve { "auto_tools" } else { "ask" }.to_string();
11338 let sandbox_posture = explicit_sandbox.unwrap_or("configured_default").to_string();
11339 let active_route_limits =
11340 crate::route_budget::known_route_limits(validated_route.candidate.limits());
11341 let max_subagents = if max_subagents == config.max_subagents_for_provider(config.api_provider())
11342 {
11343 execution_config
11344 .max_subagents_for_provider(effective_provider)
11345 .clamp(1, MAX_SUBAGENTS)
11346 } else {
11347 max_subagents
11348 };
11349 let reasoning_effort_auto = route.auto_controls_reasoning;
11356 let effective_reasoning_effort = route.reasoning_effort.and_then(|effort| {
11360 cli_reasoning_effort_value_for_prompt(&execution_config, &effective_model, effort, prompt)
11361 });
11362
11363 let settings = crate::settings::Settings::load().unwrap_or_default();
11364 let auto_compact_enabled = if crate::settings::Settings::auto_compact_explicitly_configured() {
11365 settings.auto_compact
11366 } else {
11367 crate::route_budget::auto_compact_default_for_route(
11368 effective_provider,
11369 &effective_model,
11370 active_route_limits,
11371 )
11372 };
11373 let compaction = CompactionConfig {
11374 enabled: auto_compact_enabled,
11375 model: effective_model.clone(),
11376 effective_context_window: Some(crate::route_budget::route_context_window_tokens(
11377 effective_provider,
11378 &effective_model,
11379 active_route_limits,
11380 )),
11381 token_threshold: crate::route_budget::compaction_threshold_for_route_at_percent(
11382 effective_provider,
11383 &effective_model,
11384 active_route_limits,
11385 settings.auto_compact_threshold_percent,
11386 ),
11387 ..Default::default()
11388 };
11389
11390 let network_policy = exec_network_policy(&execution_config, outer_network_access);
11391
11392 let lsp_config = (!fleet_authority_active)
11393 .then(|| {
11394 execution_config
11395 .lsp
11396 .clone()
11397 .map(crate::config::LspConfigToml::into_runtime)
11398 })
11399 .flatten();
11400 let mut engine_features = execution_config.features();
11401 apply_fleet_engine_feature_caps(
11402 &mut engine_features,
11403 fleet_authority_active,
11404 outer_network_access,
11405 outer_shell_authority,
11406 );
11407 if crate::core::allowlist_is_native_file_and_shell_only(allowed_tools.as_deref()) {
11408 engine_features.disable(crate::features::Feature::Mcp);
11409 }
11410 let engine_plugin_registry = if fleet_authority_active {
11411 std::sync::Arc::new(crate::plugins::PluginRegistry::empty(&workspace))
11412 } else {
11413 plugin_registry
11414 };
11415 let exec_allow_shell = crate::tools::spec::fleet_exec_shell_enabled(
11416 fleet_authority_active,
11417 outer_shell_authority,
11418 disallowed_tools.as_deref(),
11419 ) || (!fleet_authority_active
11420 && (auto_approve || execution_config.allow_shell()));
11421 let persist_services_enabled = cfg!(unix)
11422 && !fleet_authority_active
11423 && exec_allow_shell
11424 && explicit_sandbox
11425 .is_some_and(|sandbox| sandbox.eq_ignore_ascii_case("danger-full-access"));
11426 let exec_shell_manager = crate::tools::shell::new_shared_shell_manager(workspace.clone());
11427 let runtime_services = crate::tools::spec::RuntimeToolServices {
11428 shell_manager: Some(exec_shell_manager.clone()),
11429 persist_services_enabled,
11430 ..crate::tools::spec::RuntimeToolServices::default()
11431 };
11432
11433 let engine_config = EngineConfig {
11434 model: effective_model.clone(),
11435 active_route_limits,
11436 workspace: workspace.clone(),
11437 subagent_state_root: None,
11438 plugin_registry: Some(engine_plugin_registry),
11439 allow_shell: exec_allow_shell,
11440 trust_mode,
11441 notes_path: execution_config.notes_path(),
11442 mcp_config_path: execution_config.mcp_config_path(),
11443 skills_dir: execution_config.skills_dir(),
11444 skills_scan_codewhale_only: execution_config.skills_config().scan_codewhale_only(),
11445 instructions: {
11446 let mut instrs: Vec<crate::prompts::InstructionSource> = execution_config
11447 .instructions_paths()
11448 .into_iter()
11449 .map(Into::into)
11450 .collect();
11451 if let Some(ref extra) = append_system_prompt {
11452 instrs.push(crate::prompts::InstructionSource::Inline {
11453 name: "cli:append-system-prompt".into(),
11454 content: extra.clone(),
11455 });
11456 }
11457 instrs
11458 },
11459 project_context_pack_enabled: execution_config.project_context_pack_enabled(),
11460 translation_enabled: false,
11461 max_steps: max_turns,
11462 max_subagents,
11463 max_admitted_subagents: execution_config
11464 .max_admitted_subagents_for_provider(effective_provider)
11465 .max(max_subagents),
11466 launch_concurrency: execution_config.launch_concurrency_for_provider(effective_provider),
11467 subagents_enabled: !fleet_authority_active
11468 && execution_config.subagents_enabled_for_provider(effective_provider),
11469 features: engine_features,
11470 auto_review_policy: execution_config.auto_review_policy(),
11471 compaction: compaction.clone(),
11472 todos: new_shared_todo_list(),
11473 plan_state: new_shared_plan_state(),
11474 goal_state: crate::tools::goal::new_shared_goal_state(),
11475 max_spawn_depth: if fleet_authority_active {
11476 0
11477 } else {
11478 execution_config.subagent_max_spawn_depth_for_provider(effective_provider)
11479 },
11480 subagent_token_budget: execution_config
11481 .subagent_token_budget_for_provider(effective_provider),
11482 network_policy,
11483 snapshots_enabled: !fleet_authority_active && execution_config.snapshots_config().enabled,
11484 snapshots_max_workspace_bytes: execution_config
11485 .snapshots_config()
11486 .max_workspace_gb
11487 .saturating_mul(1024 * 1024 * 1024),
11488 lsp_config,
11489 runtime_services,
11490 subagent_model_overrides: execution_config.subagent_model_overrides(),
11491 fleet_roster: std::sync::Arc::new(crate::fleet::roster::FleetRoster::load(
11492 &execution_config.fleet_config(),
11493 &workspace,
11494 )),
11495 subagent_api_timeout: std::time::Duration::from_secs(
11496 execution_config.subagent_api_timeout_secs_for_provider(effective_provider),
11497 ),
11498 stream_chunk_timeout: std::time::Duration::from_secs(
11499 execution_config.stream_chunk_timeout_secs(),
11500 ),
11501 subagent_heartbeat_timeout: std::time::Duration::from_secs(
11502 execution_config.subagent_heartbeat_timeout_secs_for_provider(effective_provider),
11503 ),
11504 prefer_bwrap: execution_config.prefer_bwrap.unwrap_or(false),
11505 bwrap_extensions: crate::sandbox::BwrapMountExtensions {
11506 read_only_roots: execution_config.bwrap_ro_roots.clone(),
11507 device_roots: execution_config.bwrap_dev_roots.clone(),
11508 },
11509 memory_enabled: execution_config.memory_enabled(),
11510 memory_path: execution_config.memory_path(),
11511 speech_output_dir: execution_config.speech_output_dir(),
11512 vision_config: execution_config.vision_model_config(),
11513 strict_tool_mode: execution_config.strict_tool_mode.unwrap_or(false),
11514 goal_objective: None,
11515 goal_token_budget: None,
11516 goal_status: crate::tools::goal::GoalStatus::Active,
11517 goal_max_continuations: execution_config.goal_max_continuations(),
11518 allowed_tools: allowed_tools.clone(),
11519 disallowed_tools: disallowed_tools.clone(),
11520 max_tool_calls: None,
11521 hook_executor: None,
11522 locale_tag: crate::localization::resolve_locale(&settings.locale)
11523 .tag()
11524 .to_string(),
11525 workshop: {
11526 crate::tools::large_output_router::WorkshopConfig::install_active(
11527 config.workshop.as_ref(),
11528 );
11529 config.workshop.clone()
11530 },
11531 search_provider: execution_config.search_provider(),
11532 search_api_key: execution_config
11533 .search
11534 .as_ref()
11535 .and_then(|s| s.api_key.clone()),
11536 search_base_url: execution_config
11537 .search
11538 .as_ref()
11539 .and_then(|s| s.base_url.clone()),
11540 tools_always_load: if fleet_authority_active {
11541 std::collections::HashSet::new()
11542 } else {
11543 execution_config.tools_always_load()
11544 },
11545 tools: if fleet_authority_active {
11546 None
11547 } else {
11548 execution_config.tools.clone()
11549 },
11550 verbosity: execution_config.verbosity.clone(),
11551 workspace_follow_symlinks: settings.workspace_follow_symlinks,
11552 exec_policy_engine: execution_config.exec_policy_engine.clone(),
11553 terminal_chrome_enabled: false,
11554 advisor_config: execution_config
11555 .advisor
11556 .as_ref()
11557 .map(crate::tools::subagent::AdvisorConfig::from_toml)
11558 .unwrap_or_else(crate::tools::subagent::AdvisorConfig::disabled),
11559 };
11560
11561 let engine_handle = spawn_engine(engine_config, &execution_config);
11562 let mode = if auto_approve {
11563 AppMode::Yolo
11564 } else {
11565 AppMode::Agent
11566 };
11567
11568 let resuming_session = resume_session.is_some();
11569 let mut loaded_session_id = None;
11570 if let Some(saved) = resume_session {
11571 let saved_id = saved.metadata.id.clone();
11572 if saved.metadata.workspace != workspace && output_format == ExecOutputFormat::Text {
11573 eprintln!(
11574 "Warning: session {} was created in a different workspace ({}). Resuming anyway.",
11575 truncate_id(&saved_id),
11576 saved.metadata.workspace.display(),
11577 );
11578 }
11579
11580 engine_handle
11581 .send(Op::SyncSession {
11582 session_id: Some(saved_id.clone()),
11583 messages: saved.messages,
11584 system_prompt: saved.system_prompt.map(SystemPrompt::Text),
11585 system_prompt_override: false,
11586 model: saved.metadata.model,
11587 workspace: saved.metadata.workspace,
11588 mode,
11589 })
11590 .await?;
11591 loaded_session_id = Some(saved_id.clone());
11592 if output_format == ExecOutputFormat::Text && !json_output {
11593 eprintln!("{}", exec_resumed_session_line(&saved_id));
11594 }
11595 }
11596
11597 engine_handle
11598 .send(Op::SendMessage {
11599 content: prompt.to_string(),
11600 mode,
11601 route: Box::new(validated_route.into_resolved()),
11602 compaction: Box::new(compaction.clone()),
11603 goal_objective: None,
11604 goal_token_budget: None,
11605 goal_status: crate::tools::goal::GoalStatus::Active,
11606 allowed_tools: allowed_tools.clone(),
11607 dynamic_tools: Vec::new(),
11608 hook_executor: None,
11609 reasoning_effort: effective_reasoning_effort,
11610 reasoning_effort_auto,
11611 auto_model,
11612 allow_shell: auto_approve || execution_config.allow_shell(),
11613 trust_mode,
11614 auto_approve,
11615 translation_enabled: false,
11616 approval_mode: if auto_approve {
11617 crate::tui::approval::ApprovalMode::Bypass
11618 } else {
11619 execution_config
11620 .approval_policy
11621 .as_deref()
11622 .and_then(crate::tui::approval::ApprovalMode::from_config_value)
11623 .unwrap_or_default()
11624 },
11625 verbosity: execution_config.verbosity.clone(),
11626 provenance: crate::core::ops::UserInputProvenance::ExternalUser,
11627 })
11628 .await?;
11629
11630 let mut summary = ExecSummary {
11631 mode: "agent".to_string(),
11632 provider: effective_provider_name.clone(),
11633 model: effective_model.clone(),
11634 prompt: prompt.to_string(),
11635 ..ExecSummary::default()
11636 };
11637 let can_elevate_sandbox =
11638 exec_sandbox_elevation_authorized(allow_sandbox_elevation, explicit_sandbox);
11639 let mut sandbox_denied = false;
11640 let mut approval_required = false;
11641 let mut tool_error_seen = false;
11642 let mut last_error_category = None;
11643 let mut reported_sandbox_contract = false;
11644
11645 let should_persist_session = resuming_session || output_format == ExecOutputFormat::StreamJson;
11646 let mut latest_session_id = loaded_session_id;
11647 let mut latest_messages: Vec<Message> = Vec::new();
11648 let mut latest_system_prompt: Option<SystemPrompt> = None;
11649 let mut latest_model = effective_model;
11650 let mut latest_workspace = workspace.clone();
11651 let mut tool_starts: HashMap<String, (Instant, String)> = HashMap::new();
11652 let mut turn_usage_seq: u32 = 0;
11653
11654 let mut stdout = io::stdout();
11655 let mut ends_with_newline = false;
11656 loop {
11657 let event = {
11658 let mut rx = engine_handle.rx_event.write().await;
11659 rx.recv().await
11660 };
11661
11662 let Some(event) = event else {
11663 break;
11664 };
11665
11666 match event {
11667 Event::MessageDelta { content, .. } => {
11668 summary.output.push_str(&content);
11669 if output_format == ExecOutputFormat::StreamJson {
11670 emit_exec_stream_event(&ExecStreamEvent::Content { content })?;
11671 } else if !json_output {
11672 print!("{content}");
11673 stdout.flush()?;
11674 }
11675 ends_with_newline = summary.output.ends_with('\n');
11676 }
11677 Event::MessageComplete { .. }
11678 if output_format == ExecOutputFormat::Text
11679 && !json_output
11680 && !ends_with_newline =>
11681 {
11682 println!();
11683 }
11684 Event::ThinkingDelta { .. } => {
11685 }
11688 Event::ToolCallStarted { id, name, input } => {
11689 let started_at = chrono::Utc::now().to_rfc3339();
11690 tool_starts.insert(id.clone(), (Instant::now(), started_at.clone()));
11691 if output_format == ExecOutputFormat::StreamJson {
11692 emit_exec_stream_event(&ExecStreamEvent::ToolUse {
11693 name,
11694 id,
11695 input,
11696 started_at,
11697 })?;
11698 } else if !json_output {
11699 let summary = summarize_tool_args(&input);
11700 if let Some(summary) = summary {
11701 eprintln!("tool: {name} ({summary})");
11702 } else {
11703 eprintln!("tool: {name}");
11704 }
11705 }
11706 }
11707 Event::ToolCallComplete {
11708 id, name, result, ..
11709 } => {
11710 let (duration_ms, started_at) = tool_starts
11711 .remove(&id)
11712 .map(|(started, timestamp)| {
11713 (
11714 u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
11715 timestamp,
11716 )
11717 })
11718 .unwrap_or_else(|| (0, chrono::Utc::now().to_rfc3339()));
11719 let receipt_name = name.clone();
11720 match result {
11721 Ok(output) => {
11722 tool_error_seen |= !output.success;
11723 summary.tools.push(ExecToolEntry {
11724 name: name.clone(),
11725 success: output.success,
11726 output: output.content.clone(),
11727 });
11728 if output_format == ExecOutputFormat::StreamJson {
11729 emit_exec_stream_event(&ExecStreamEvent::ToolResult {
11730 id,
11731 name: receipt_name,
11732 output: output.content,
11733 status: if output.success {
11734 "success".to_string()
11735 } else {
11736 "error".to_string()
11737 },
11738 started_at,
11739 completed_at: chrono::Utc::now().to_rfc3339(),
11740 duration_ms,
11741 side_effect_status: output
11742 .metadata
11743 .as_ref()
11744 .and_then(|metadata| metadata.get("side_effect_status"))
11745 .and_then(serde_json::Value::as_str)
11746 .unwrap_or("unknown")
11747 .to_string(),
11748 error_category: (!output.success).then(|| {
11749 output
11750 .metadata
11751 .as_ref()
11752 .and_then(|metadata| metadata.get("error_category"))
11753 .and_then(serde_json::Value::as_str)
11754 .unwrap_or("tool_reported_failure")
11755 .to_string()
11756 }),
11757 truncated: output
11758 .metadata
11759 .as_ref()
11760 .and_then(|metadata| metadata.get("truncated"))
11761 .and_then(serde_json::Value::as_bool),
11762 artifact: tool_artifact_receipt(output.metadata.as_ref()),
11763 result_metadata: output.metadata,
11764 })?;
11765 } else if !json_output {
11766 if name == "exec_shell" && !output.content.trim().is_empty() {
11767 eprintln!("tool {name} completed");
11768 eprintln!(
11769 "--- stdout/stderr ---\n{}\n---------------------",
11770 output.content
11771 );
11772 } else {
11773 eprintln!(
11774 "tool {name} completed: {}",
11775 summarize_tool_output(&output.content)
11776 );
11777 }
11778 }
11779 }
11780 Err(err) => {
11781 tool_error_seen = true;
11782 let error_text = err.to_string();
11783 summary.tools.push(ExecToolEntry {
11784 name: name.clone(),
11785 success: false,
11786 output: error_text.clone(),
11787 });
11788 if output_format == ExecOutputFormat::StreamJson {
11789 emit_exec_stream_event(&ExecStreamEvent::ToolResult {
11790 id,
11791 name: receipt_name,
11792 output: error_text,
11793 status: "error".to_string(),
11794 started_at,
11795 completed_at: chrono::Utc::now().to_rfc3339(),
11796 duration_ms,
11797 side_effect_status: "not_started_or_unknown".to_string(),
11798 error_category: Some(tool_error_receipt_category(&err).to_string()),
11799 truncated: None,
11800 artifact: None,
11801 result_metadata: None,
11802 })?;
11803 } else if !json_output {
11804 eprintln!("tool {name} failed: {err}");
11805 }
11806 }
11807 }
11808 }
11809 Event::AgentSpawned { id, prompt, .. }
11810 if output_format == ExecOutputFormat::Text && !json_output =>
11811 {
11812 eprintln!("sub-agent {id} spawned: {}", summarize_tool_output(&prompt));
11813 }
11814 Event::AgentProgress { id, status, .. }
11815 if output_format == ExecOutputFormat::Text && !json_output =>
11816 {
11817 eprintln!("sub-agent {id}: {status}");
11818 }
11819 Event::AgentComplete { id, result }
11820 if output_format == ExecOutputFormat::Text && !json_output =>
11821 {
11822 eprintln!(
11823 "sub-agent {id} completed: {}",
11824 summarize_tool_output(&result)
11825 );
11826 }
11827 Event::AgentSpawned {
11828 id,
11829 parent_run_id,
11830 spawn_depth,
11831 model,
11832 route_source,
11833 ..
11834 } if output_format == ExecOutputFormat::StreamJson => {
11835 emit_exec_stream_event(&ExecStreamEvent::AgentSpawned {
11836 id,
11837 model,
11838 spawn_depth,
11839 parent_run_id,
11840 route_source,
11841 })?;
11842 }
11843 Event::AgentSpawned { .. }
11844 | Event::AgentProgress { .. }
11845 | Event::AgentComplete { .. } => {}
11846 Event::WorkflowUi { run_id, event }
11847 if output_format == ExecOutputFormat::StreamJson =>
11848 {
11849 emit_exec_stream_event(&ExecStreamEvent::WorkflowEvent { run_id, event })?;
11850 }
11851 Event::ApprovalRequired { id, .. } => {
11852 if auto_approve {
11853 let _ = engine_handle.approve_tool_call(id).await;
11854 } else {
11855 approval_required = true;
11856 let _ = engine_handle.deny_tool_call(id).await;
11857 }
11858 }
11859 Event::ElevationRequired {
11860 tool_id,
11861 tool_name,
11862 denial_reason,
11863 ..
11864 } => {
11865 if can_elevate_sandbox {
11866 let policy = crate::sandbox::SandboxPolicy::DangerFullAccess;
11867 let _ = engine_handle.retry_tool_with_policy(tool_id, policy).await;
11868 } else {
11869 sandbox_denied = true;
11870 approval_required = true;
11871 summary.outcomes.push(ExecOutcome {
11872 kind: "sandbox_denied".to_string(),
11873 outcome: "approval_required".to_string(),
11874 tool_name: tool_name.clone(),
11875 reason: denial_reason.clone(),
11876 });
11877 if !reported_sandbox_contract {
11878 eprintln!(
11879 "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"
11880 );
11881 reported_sandbox_contract = true;
11882 }
11883 if output_format == ExecOutputFormat::StreamJson {
11884 emit_exec_stream_event(&ExecStreamEvent::SandboxDenied {
11885 tool_id: tool_id.clone(),
11886 tool_name,
11887 reason: denial_reason,
11888 outcome: "approval_required".to_string(),
11889 })?;
11890 }
11891 let _ = engine_handle.deny_tool_call(tool_id).await;
11892 }
11893 }
11894 Event::Error {
11895 envelope,
11896 recoverable: _,
11897 } => {
11898 if exec_error_event_is_fatal(&envelope) {
11906 last_error_category = Some(envelope.category);
11907 summary.error_category = Some(envelope.category.to_string());
11908 summary.error = Some(envelope.message.clone());
11909 }
11910 if output_format == ExecOutputFormat::StreamJson {
11911 emit_exec_stream_event(&ExecStreamEvent::Error {
11912 error: envelope.message,
11913 })?;
11914 } else if !json_output {
11915 eprintln!("error: {}", envelope.message);
11916 }
11917 }
11918 Event::TurnUsage {
11919 usage, duration_ms, ..
11920 } => {
11921 if output_format == ExecOutputFormat::StreamJson {
11922 turn_usage_seq = turn_usage_seq.saturating_add(1);
11923 emit_exec_stream_event(&ExecStreamEvent::TurnUsage {
11924 turn: turn_usage_seq,
11925 input_tokens: usage.input_tokens,
11926 output_tokens: usage.output_tokens,
11927 reasoning_tokens: usage.reasoning_tokens,
11928 prompt_cache_hit_tokens: usage.prompt_cache_hit_tokens,
11929 prompt_cache_miss_tokens: usage.prompt_cache_miss_tokens,
11930 prompt_cache_write_tokens: usage.prompt_cache_write_tokens,
11931 reasoning_replay_tokens: usage.reasoning_replay_tokens,
11932 duration_ms,
11933 })?;
11934 }
11935 }
11936 Event::TurnComplete {
11937 status,
11938 error,
11939 usage,
11940 tool_catalog,
11941 ..
11942 } => {
11943 let (terminal_status, terminal_error) = (status, error);
11944 #[cfg(unix)]
11945 let (mut terminal_status, mut terminal_error) = (terminal_status, terminal_error);
11946 if matches!(
11947 terminal_status,
11948 crate::core::events::TurnOutcomeStatus::Completed
11949 ) && terminal_error.is_none()
11950 {
11951 #[cfg(unix)]
11952 match exec_shell_manager.lock() {
11953 Ok(mut manager) => match manager.commit_persistent_services() {
11954 Ok(receipts) => {
11955 for receipt in &receipts {
11956 if output_format == ExecOutputFormat::StreamJson {
11957 emit_exec_stream_event(
11958 &ExecStreamEvent::ServiceReleased {
11959 task_id: receipt.task_id.clone(),
11960 pid: receipt.pid,
11961 process_group_id: receipt.process_group_id,
11962 ownership: receipt.ownership.clone(),
11963 },
11964 )?;
11965 } else if !json_output {
11966 eprintln!(
11967 "persistent service released: {} pid={} pgid={} ownership={}",
11968 receipt.task_id,
11969 receipt.pid,
11970 receipt.process_group_id,
11971 receipt.ownership
11972 );
11973 }
11974 }
11975 summary.released_services.extend(receipts);
11976 }
11977 Err(error) => {
11978 manager.abort_persistent_services();
11979 terminal_status = crate::core::events::TurnOutcomeStatus::Failed;
11980 terminal_error = Some(format!(
11981 "Persistent service ownership transfer failed: {error}"
11982 ));
11983 }
11984 },
11985 Err(_) => {
11986 terminal_status = crate::core::events::TurnOutcomeStatus::Failed;
11987 terminal_error = Some(
11988 "Persistent service ownership transfer failed: shell manager lock poisoned"
11989 .to_string(),
11990 );
11991 }
11992 }
11993 } else if let Ok(mut manager) = exec_shell_manager.lock() {
11994 manager.abort_persistent_services();
11995 }
11996 summary.status = Some(format!("{terminal_status:?}").to_lowercase());
11997 if terminal_error.is_some() {
11998 summary.error = terminal_error;
11999 }
12000 if sandbox_denied
12001 && summary.error.is_none()
12002 && matches!(
12003 terminal_status,
12004 crate::core::events::TurnOutcomeStatus::Failed
12005 )
12006 {
12007 summary.error = Some(
12008 "exec turn failed after sandbox denial; explicit sandbox elevation was not authorized"
12009 .to_string(),
12010 );
12011 }
12012 if last_error_category.is_none() {
12013 last_error_category = summary
12014 .error
12015 .as_deref()
12016 .map(crate::error_taxonomy::classify_error_message);
12017 summary.error_category =
12018 last_error_category.map(|category| category.to_string());
12019 }
12020 let termination_reason = crate::core::termination::classify_turn_termination(
12021 terminal_status,
12022 last_error_category,
12023 tool_error_seen,
12024 approval_required,
12025 );
12026 summary.termination_reason = Some(termination_reason.as_str().to_string());
12027 if !termination_reason.is_success() {
12033 codewhale_telemetry::set_exit_class(codewhale_telemetry::ExitClass::Error);
12034 }
12035 let saved_session_id = if should_persist_session && !latest_messages.is_empty() {
12036 match persist_exec_session(
12037 &latest_messages,
12038 &latest_model,
12039 PersistedProviderRoute {
12040 kind: effective_provider.as_str(),
12041 id: effective_provider_id.as_deref(),
12042 },
12043 &latest_workspace,
12044 &latest_system_prompt,
12045 latest_session_id.as_deref(),
12046 u64::from(usage.input_tokens) + u64::from(usage.output_tokens),
12047 ) {
12048 Ok(id) => {
12049 if output_format == ExecOutputFormat::Text && !json_output {
12050 eprintln!("{}", exec_saved_session_line(&id));
12051 }
12052 Some(id)
12053 }
12054 Err(err) => {
12055 if output_format == ExecOutputFormat::Text && !json_output {
12056 eprintln!("warning: failed to save exec session: {err}");
12057 }
12058 latest_session_id.clone()
12059 }
12060 }
12061 } else {
12062 latest_session_id.clone()
12063 };
12064 if output_format == ExecOutputFormat::StreamJson {
12065 if let Some(id) = saved_session_id.as_ref() {
12066 emit_exec_stream_event(&ExecStreamEvent::SessionCapture {
12067 content: exec_stream_session_ref(id),
12068 })?;
12069 }
12070 let codewhale_max_output_tokens =
12074 crate::route_budget::effective_max_output_tokens_for_route(
12075 effective_provider,
12076 &latest_model,
12077 active_route_limits,
12078 );
12079 let codewhale_max_output_tokens_source =
12080 crate::route_budget::output_ceiling_source(
12081 effective_provider,
12082 &latest_model,
12083 )
12084 .as_str();
12085 emit_exec_stream_event(&ExecStreamEvent::Metadata {
12086 meta: Box::new(ExecStreamMeta {
12087 receipt_kind: "terminal",
12088 provider: effective_provider_kind.clone(),
12089 provider_id: effective_stream_provider_id.clone(),
12090 model: latest_model.clone(),
12091 route_source: route_source.clone(),
12092 input_tokens: Some(usage.input_tokens),
12093 output_tokens: Some(usage.output_tokens),
12094 prompt_cache_hit_tokens: usage.prompt_cache_hit_tokens,
12095 prompt_cache_miss_tokens: usage.prompt_cache_miss_tokens,
12096 prompt_cache_write_tokens: usage.prompt_cache_write_tokens,
12097 reasoning_tokens: usage.reasoning_tokens,
12098 codewhale_max_output_tokens: Some(codewhale_max_output_tokens),
12099 codewhale_max_output_tokens_source: Some(
12100 codewhale_max_output_tokens_source,
12101 ),
12102 duration_ms: u64::try_from(exec_started.elapsed().as_millis())
12103 .unwrap_or(u64::MAX),
12104 retry_count: None,
12105 approval_posture: approval_posture.clone(),
12106 sandbox_posture: sandbox_posture.clone(),
12107 binary_sha256: binary_sha256.clone(),
12108 config_sha256: None,
12109 prompt_sha256: prompt_sha256.clone(),
12110 tool_catalog_sha256: tool_catalog.as_ref().and_then(|catalog| {
12111 serde_json::to_vec(catalog).ok().map(|bytes| {
12112 format!("sha256:{}", crate::hashing::sha256_hex(&bytes))
12113 })
12114 }),
12115 input_analysis: exec_stream_input_analysis(
12116 &latest_messages,
12117 latest_system_prompt.as_ref(),
12118 ),
12119 visible_final_answer_chars: summary.output.chars().count(),
12120 resume_command: saved_session_id
12121 .as_deref()
12122 .map(exec_stream_resume_hint)
12123 .unwrap_or_default(),
12124 session_id: saved_session_id
12125 .as_deref()
12126 .map(exec_stream_session_ref)
12127 .unwrap_or_default(),
12128 workspace: latest_workspace.display().to_string(),
12129 message_count: latest_messages.len(),
12130 status: summary.status.clone(),
12131 termination_reason: summary.termination_reason.clone(),
12132 error_category: summary.error_category.clone(),
12133 error: summary.error.clone(),
12134 }),
12135 })?;
12136 emit_exec_stream_event(&ExecStreamEvent::Done)?;
12137 }
12138 let _ = engine_handle.send(Op::Shutdown).await;
12139 break;
12140 }
12141 Event::SessionUpdated {
12142 session_id,
12143 messages,
12144 system_prompt,
12145 model,
12146 workspace,
12147 } => {
12148 latest_session_id = Some(session_id);
12149 latest_messages = messages;
12150 latest_system_prompt = system_prompt;
12151 latest_model = model;
12152 latest_workspace = workspace;
12153 }
12154 Event::Status { message }
12157 if output_format == ExecOutputFormat::Text
12158 && !json_output
12159 && message.contains("Maximum model steps") =>
12160 {
12161 eprintln!("{message}");
12162 }
12163 _ => {}
12164 }
12165 }
12166
12167 if summary.status.is_none() {
12168 if let Ok(mut manager) = exec_shell_manager.lock() {
12169 manager.abort_persistent_services();
12170 }
12171 let error = summary.error.clone().unwrap_or_else(|| {
12172 "Engine event channel closed before a terminal turn receipt".to_string()
12173 });
12174 let category = last_error_category
12175 .unwrap_or_else(|| crate::error_taxonomy::classify_error_message(&error));
12176 let termination_reason = crate::core::termination::classify_turn_termination(
12177 crate::core::events::TurnOutcomeStatus::Failed,
12178 Some(category),
12179 tool_error_seen,
12180 approval_required,
12181 );
12182 summary.status = Some("failed".to_string());
12183 summary.error_category = Some(category.to_string());
12184 summary.termination_reason = Some(termination_reason.as_str().to_string());
12185 summary.error = Some(error.clone());
12186 if output_format == ExecOutputFormat::StreamJson {
12187 emit_exec_stream_event(&ExecStreamEvent::Error { error })?;
12188 }
12189 }
12190
12191 if json_output {
12192 println!("{}", serde_json::to_string_pretty(&summary)?);
12193 }
12194
12195 if let Some(error) = summary.error.as_ref()
12196 && !error.trim().is_empty()
12197 {
12198 let exit_code = exec_failure_exit_code(summary.error_category.as_deref());
12204 if exit_code != 1 {
12205 eprintln!("Error: exec turn failed: {error}");
12206 let _ = io::stdout().flush();
12207 std::process::exit(exit_code);
12208 }
12209 bail!("exec turn failed: {error}");
12210 }
12211
12212 if matches!(
12213 summary.status.as_deref(),
12214 Some("failed" | "canceled" | "interrupted")
12215 ) {
12216 let status = summary.status.as_deref().unwrap_or("unknown");
12217 bail!("exec turn ended with status {status}");
12218 }
12219
12220 Ok(())
12221}
12222
12223#[cfg(test)]
12224mod serve_bind_host_tests {
12225 use super::*;
12226
12227 #[test]
12228 fn http_defaults_to_loopback() {
12229 assert_eq!(
12230 resolve_serve_bind_host(false, None),
12231 ServeBindHost {
12232 host: "127.0.0.1".to_string(),
12233 mobile_rebound_to_lan: false,
12234 }
12235 );
12236 }
12237
12238 #[test]
12239 fn mobile_default_rebinds_to_lan_with_warning_flag() {
12240 assert_eq!(
12241 resolve_serve_bind_host(true, None),
12242 ServeBindHost {
12243 host: "0.0.0.0".to_string(),
12244 mobile_rebound_to_lan: true,
12245 }
12246 );
12247 }
12248
12249 #[test]
12250 fn mobile_respects_explicit_loopback_host() {
12251 assert_eq!(
12252 resolve_serve_bind_host(true, Some("127.0.0.1".to_string())),
12253 ServeBindHost {
12254 host: "127.0.0.1".to_string(),
12255 mobile_rebound_to_lan: false,
12256 }
12257 );
12258 }
12259
12260 #[test]
12261 fn http_and_mobile_are_mutually_exclusive() {
12262 let err = validate_serve_mode_selection(false, true, true, false, false).unwrap_err();
12263 assert!(
12264 err.to_string()
12265 .contains("--http and --mobile are mutually exclusive")
12266 );
12267 }
12268
12269 #[test]
12270 fn web_is_a_distinct_loopback_runtime_mode() {
12271 assert!(validate_serve_mode_selection(false, false, false, true, false).unwrap());
12272 let err = validate_serve_mode_selection(false, true, false, true, false).unwrap_err();
12273 assert!(err.to_string().contains("--web is mutually exclusive"));
12274 assert_eq!(
12275 resolve_serve_bind_host(false, None),
12276 ServeBindHost {
12277 host: "127.0.0.1".to_string(),
12278 mobile_rebound_to_lan: false,
12279 }
12280 );
12281 }
12282}
12283
12284#[cfg(test)]
12285#[path = "tests/exec_exit_semantics.rs"]
12286mod exec_exit_semantics_tests;
12287#[cfg(test)]
12288mod doctor_legacy_state_tests {
12289 use super::*;
12290 use std::env;
12291 use std::ffi::OsString;
12292 use std::fs;
12293 use tempfile::TempDir;
12294
12295 struct EnvVarRestore {
12296 key: &'static str,
12297 previous: Option<OsString>,
12298 }
12299
12300 impl EnvVarRestore {
12301 fn set(key: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
12302 let previous = env::var_os(key);
12303 unsafe {
12304 env::set_var(key, value);
12305 }
12306 Self { key, previous }
12307 }
12308 }
12309
12310 impl Drop for EnvVarRestore {
12311 fn drop(&mut self) {
12312 unsafe {
12313 match &self.previous {
12314 Some(value) => env::set_var(self.key, value),
12315 None => env::remove_var(self.key),
12316 }
12317 }
12318 }
12319 }
12320
12321 fn roots(tmp: &TempDir) -> (PathBuf, PathBuf) {
12322 (tmp.path().join(".codewhale"), tmp.path().join(".deepseek"))
12323 }
12324
12325 fn entry<'a>(report: &'a [DoctorLegacyStateEntry], name: &str) -> &'a DoctorLegacyStateEntry {
12326 report
12327 .iter()
12328 .find(|entry| entry.name == name)
12329 .expect("legacy state entry should exist")
12330 }
12331
12332 #[test]
12333 fn doctor_legacy_state_report_marks_unmigrated_legacy_entries() {
12334 let tmp = TempDir::new().expect("tempdir");
12335 let (primary_root, legacy_root) = roots(&tmp);
12336 fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
12337 fs::create_dir_all(legacy_root.join("tasks")).expect("legacy tasks");
12338 fs::create_dir_all(&primary_root).expect("primary root");
12339 fs::write(legacy_root.join("config.toml"), "api_key = 'old'").expect("legacy config");
12340
12341 let report = doctor_legacy_state_report(&primary_root, &legacy_root);
12342 let session_recovery = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12343
12344 assert_eq!(
12345 entry(&report, "sessions").status,
12346 DoctorLegacyStateStatus::LegacyOnly
12347 );
12348 assert_eq!(
12349 entry(&report, "config.toml").status,
12350 DoctorLegacyStateStatus::LegacyOnly
12351 );
12352 assert_eq!(
12353 entry(&report, "skills").status,
12354 DoctorLegacyStateStatus::Absent
12355 );
12356
12357 let json =
12358 doctor_legacy_state_json(&primary_root, &legacy_root, &report, &session_recovery);
12359 assert_eq!(json["needs_attention"], true);
12360 assert_eq!(json["legacy_only_count"], 3);
12361 assert_eq!(json["dual_present_count"], 0);
12362 }
12363
12364 #[test]
12365 fn doctor_legacy_state_report_marks_dual_present_entries() {
12366 let tmp = TempDir::new().expect("tempdir");
12367 let (primary_root, legacy_root) = roots(&tmp);
12368 fs::create_dir_all(primary_root.join("sessions")).expect("primary sessions");
12369 fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
12370 fs::write(primary_root.join("mcp.json"), "{}").expect("primary mcp");
12371 fs::write(legacy_root.join("mcp.json"), "{}").expect("legacy mcp");
12372
12373 let report = doctor_legacy_state_report(&primary_root, &legacy_root);
12374 let session_recovery = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12375
12376 assert_eq!(
12377 entry(&report, "sessions").status,
12378 DoctorLegacyStateStatus::Both
12379 );
12380 assert_eq!(
12381 entry(&report, "mcp.json").status,
12382 DoctorLegacyStateStatus::Both
12383 );
12384
12385 let json =
12386 doctor_legacy_state_json(&primary_root, &legacy_root, &report, &session_recovery);
12387 assert_eq!(json["needs_attention"], true);
12388 assert_eq!(json["legacy_only_count"], 0);
12389 assert_eq!(json["dual_present_count"], 2);
12390 }
12391
12392 #[test]
12393 fn doctor_legacy_state_report_is_clear_when_only_primary_exists() {
12394 let tmp = TempDir::new().expect("tempdir");
12395 let (primary_root, legacy_root) = roots(&tmp);
12396 fs::create_dir_all(primary_root.join("sessions")).expect("primary sessions");
12397 fs::write(primary_root.join("settings.toml"), "default_mode = 'ask'")
12398 .expect("primary settings");
12399
12400 let report = doctor_legacy_state_report(&primary_root, &legacy_root);
12401 let session_recovery = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12402
12403 assert_eq!(
12404 entry(&report, "sessions").status,
12405 DoctorLegacyStateStatus::PrimaryOnly
12406 );
12407 assert!(!report.iter().any(legacy_state_needs_attention));
12408
12409 let json =
12410 doctor_legacy_state_json(&primary_root, &legacy_root, &report, &session_recovery);
12411 assert_eq!(json["needs_attention"], false);
12412 assert_eq!(json["legacy_only_count"], 0);
12413 assert_eq!(json["dual_present_count"], 0);
12414 }
12415
12416 #[test]
12417 fn doctor_legacy_state_report_is_clear_when_neither_root_exists() {
12418 let tmp = TempDir::new().expect("tempdir");
12419 let (primary_root, legacy_root) = roots(&tmp);
12420
12421 let report = doctor_legacy_state_report(&primary_root, &legacy_root);
12422 let session_recovery = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12423
12424 assert!(
12425 report
12426 .iter()
12427 .all(|entry| entry.status == DoctorLegacyStateStatus::Absent)
12428 );
12429 assert!(!report.iter().any(legacy_state_needs_attention));
12430
12431 let json =
12432 doctor_legacy_state_json(&primary_root, &legacy_root, &report, &session_recovery);
12433 assert_eq!(json["needs_attention"], false);
12434 assert_eq!(json["legacy_only_count"], 0);
12435 assert_eq!(json["dual_present_count"], 0);
12436 }
12437
12438 #[test]
12439 fn doctor_reports_incomplete_session_migration_without_mutating_files() {
12440 let tmp = TempDir::new().expect("tempdir");
12441 let (primary_root, legacy_root) = roots(&tmp);
12442 let primary_sessions = primary_root.join("sessions");
12443 let legacy_sessions = legacy_root.join("sessions");
12444 fs::create_dir_all(&primary_sessions).expect("primary sessions");
12445 fs::create_dir_all(legacy_sessions.join("checkpoints")).expect("legacy checkpoints");
12446 fs::write(primary_sessions.join("already-there.json"), b"primary")
12447 .expect("primary session");
12448 fs::write(legacy_sessions.join("already-there.json"), b"legacy")
12449 .expect("legacy matching session");
12450 fs::write(
12451 legacy_sessions.join("recover-me.json"),
12452 b"not parsed by doctor",
12453 )
12454 .expect("legacy recoverable session");
12455 fs::write(
12456 legacy_sessions.join("checkpoints").join("latest.json"),
12457 b"checkpoint not inspected",
12458 )
12459 .expect("legacy checkpoint");
12460
12461 let legacy_before = fs::read(legacy_sessions.join("recover-me.json"))
12462 .expect("read legacy fixture before diagnostic");
12463 let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12464
12465 assert_eq!(
12466 report.status,
12467 DoctorSessionRecoveryStatus::MigrationIncomplete
12468 );
12469 assert_eq!(report.legacy_session_file_count, 2);
12470 assert_eq!(report.already_present_file_count, 1);
12471 assert_eq!(report.recoverable_file_count, 1);
12472 assert_eq!(report.recoverable.len(), 1);
12473 assert_eq!(report.recoverable[0].name, PathBuf::from("recover-me.json"));
12474 assert!(
12475 !primary_sessions.join("recover-me.json").exists(),
12476 "doctor must not copy a recoverable session"
12477 );
12478 assert_eq!(
12479 fs::read(legacy_sessions.join("recover-me.json"))
12480 .expect("legacy file remains after diagnostic"),
12481 legacy_before,
12482 "doctor must not rewrite or delete the legacy source"
12483 );
12484
12485 let json = doctor_session_recovery_json(&report);
12486 assert_eq!(json["needs_attention"], true);
12487 assert_eq!(json["read_only"], true);
12488 assert_eq!(json["chat_contents_read"], false);
12489 assert_eq!(json["checkpoint_internals_scanned"], false);
12490 assert_eq!(json["recoverable_file_count"], 1);
12491 assert_eq!(json["recovery_command"], "codewhale sessions");
12492 assert_eq!(json["recoverable_files"][0]["name"], "recover-me.json");
12493 let serialized = json.to_string();
12494 assert!(
12495 !serialized.contains("not parsed by doctor"),
12496 "the report must not expose session contents"
12497 );
12498 assert!(
12499 !serialized.contains("checkpoint not inspected"),
12500 "the report must not expose checkpoint contents"
12501 );
12502 }
12503
12504 #[test]
12505 fn doctor_treats_preserved_legacy_sessions_as_complete_by_filename() {
12506 let tmp = TempDir::new().expect("tempdir");
12507 let (primary_root, legacy_root) = roots(&tmp);
12508 let primary_sessions = primary_root.join("sessions");
12509 let legacy_sessions = legacy_root.join("sessions");
12510 fs::create_dir_all(&primary_sessions).expect("primary sessions");
12511 fs::create_dir_all(&legacy_sessions).expect("legacy sessions");
12512 fs::write(primary_sessions.join("same-name.json"), b"primary").expect("primary session");
12513 fs::write(legacy_sessions.join("same-name.json"), b"legacy").expect("legacy session");
12514
12515 let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12516
12517 assert_eq!(
12518 report.status,
12519 DoctorSessionRecoveryStatus::MigrationComplete
12520 );
12521 assert!(!report.needs_attention());
12522 assert_eq!(report.recoverable_file_count, 0);
12523 assert!(report.recoverable.is_empty());
12524 assert_eq!(report.already_present_file_count, 1);
12525 let json = doctor_session_recovery_json(&report);
12526 assert_eq!(json["session_descriptors_compared"], false);
12527 assert_eq!(
12528 json["counterpart_check"],
12529 "top_level_filename_and_regular_file_only"
12530 );
12531 }
12532
12533 #[test]
12534 fn doctor_bounds_recoverable_session_filename_samples() {
12535 let tmp = TempDir::new().expect("tempdir");
12536 let (primary_root, legacy_root) = roots(&tmp);
12537 let legacy_sessions = legacy_root.join("sessions");
12538 fs::create_dir_all(&legacy_sessions).expect("legacy sessions");
12539 for index in 0..DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT {
12540 fs::write(
12541 legacy_sessions.join(format!("late-{index:03}.json")),
12542 b"fixture",
12543 )
12544 .expect("legacy session fixture");
12545 }
12546 fs::write(legacy_sessions.join("early-000.json"), b"fixture")
12547 .expect("earliest legacy session fixture");
12548 fs::write(legacy_sessions.join("early-001.json"), b"fixture")
12549 .expect("second earliest legacy session fixture");
12550 let total = DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT + 2;
12551
12552 let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12553 let json = doctor_session_recovery_json(&report);
12554
12555 assert_eq!(report.recoverable_file_count, total);
12556 assert_eq!(
12557 report.recoverable.len(),
12558 DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT
12559 );
12560 assert_eq!(
12561 json["recoverable_files"].as_array().map(Vec::len),
12562 Some(DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT)
12563 );
12564 assert_eq!(
12565 report.recoverable.first().map(|entry| entry.name.as_path()),
12566 Some(Path::new("early-000.json")),
12567 "the bounded sample must not depend on read_dir order"
12568 );
12569 assert_eq!(
12570 report.recoverable.last().map(|entry| entry.name.as_path()),
12571 Some(Path::new("late-097.json")),
12572 "the bounded sample must retain the lexical prefix"
12573 );
12574 assert_eq!(json["recoverable_files_truncated"], true);
12575 }
12576
12577 #[test]
12578 fn doctor_session_recovery_fails_closed_on_an_unreadable_path_shape() {
12579 let tmp = TempDir::new().expect("tempdir");
12580 let (primary_root, legacy_root) = roots(&tmp);
12581 fs::create_dir_all(&legacy_root).expect("legacy root");
12582 fs::write(legacy_root.join("sessions"), b"not a directory")
12583 .expect("invalid legacy sessions path");
12584
12585 let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12586
12587 assert_eq!(report.status, DoctorSessionRecoveryStatus::ScanFailed);
12588 assert!(report.needs_attention());
12589 assert!(report.error.as_deref().is_some_and(|error| {
12590 error.contains("legacy sessions root") && error.contains("not a directory")
12591 }));
12592 }
12593
12594 #[test]
12595 fn doctor_session_recovery_rejects_a_non_directory_legacy_state_root() {
12596 let tmp = TempDir::new().expect("tempdir");
12597 let (primary_root, legacy_root) = roots(&tmp);
12598 fs::write(&legacy_root, b"not a state directory").expect("invalid legacy root");
12599
12600 let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12601
12602 assert_eq!(report.status, DoctorSessionRecoveryStatus::ScanFailed);
12603 assert!(report.error.as_deref().is_some_and(|error| {
12604 error.contains("legacy state root") && error.contains("not a directory")
12605 }));
12606 }
12607
12608 #[test]
12609 fn doctor_session_recovery_rejects_a_non_directory_primary_state_root() {
12610 let tmp = TempDir::new().expect("tempdir");
12611 let (primary_root, legacy_root) = roots(&tmp);
12612 fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
12613 fs::write(&primary_root, b"not a state directory").expect("invalid primary root");
12614
12615 let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12616
12617 assert_eq!(report.status, DoctorSessionRecoveryStatus::ScanFailed);
12618 assert!(report.error.as_deref().is_some_and(|error| {
12619 error.contains("primary state root") && error.contains("not a directory")
12620 }));
12621 }
12622
12623 #[test]
12624 fn doctor_session_recovery_rejects_a_non_directory_primary_sessions_root() {
12625 let tmp = TempDir::new().expect("tempdir");
12626 let (primary_root, legacy_root) = roots(&tmp);
12627 fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
12628 fs::create_dir_all(&primary_root).expect("primary root");
12629 fs::write(primary_root.join("sessions"), b"not a sessions directory")
12630 .expect("invalid primary sessions path");
12631
12632 let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12633
12634 assert_eq!(report.status, DoctorSessionRecoveryStatus::ScanFailed);
12635 assert!(report.error.as_deref().is_some_and(|error| {
12636 error.contains("primary sessions root") && error.contains("not a directory")
12637 }));
12638 }
12639
12640 #[cfg(unix)]
12641 #[test]
12642 fn doctor_session_recovery_rejects_a_symlinked_legacy_sessions_root() {
12643 use std::os::unix::fs::symlink;
12644
12645 let tmp = TempDir::new().expect("tempdir");
12646 let (primary_root, legacy_root) = roots(&tmp);
12647 let external_sessions = tmp.path().join("external-sessions");
12648 fs::create_dir_all(&external_sessions).expect("external sessions");
12649 fs::write(
12650 external_sessions.join("must-not-be-enumerated.json"),
12651 b"session contents must stay unread",
12652 )
12653 .expect("external session fixture");
12654 fs::create_dir_all(&legacy_root).expect("legacy root");
12655 symlink(&external_sessions, legacy_root.join("sessions"))
12656 .expect("symlinked legacy sessions root");
12657
12658 let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12659
12660 assert_eq!(report.status, DoctorSessionRecoveryStatus::ScanFailed);
12661 assert!(report.needs_attention());
12662 assert_eq!(report.legacy_session_file_count, 0);
12663 assert!(report.recoverable.is_empty());
12664 assert!(
12665 report
12666 .error
12667 .as_deref()
12668 .is_some_and(|error| error.contains("legacy sessions root")
12669 && error.contains("path is a symlink"))
12670 );
12671 }
12672
12673 #[cfg(unix)]
12674 #[test]
12675 fn doctor_session_recovery_rejects_symlinked_primary_root_and_sessions_root() {
12676 use std::os::unix::fs::symlink;
12677
12678 let tmp = TempDir::new().expect("tempdir");
12679 let (primary_root, legacy_root) = roots(&tmp);
12680 let external_primary = tmp.path().join("external-primary");
12681 fs::create_dir_all(external_primary.join("sessions")).expect("external primary");
12682 fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
12683 symlink(&external_primary, &primary_root).expect("symlinked primary root");
12684
12685 let root_report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12686 assert_eq!(root_report.status, DoctorSessionRecoveryStatus::ScanFailed);
12687 assert!(root_report.error.as_deref().is_some_and(|error| {
12688 error.contains("primary state root") && error.contains("path is a symlink")
12689 }));
12690
12691 fs::remove_file(&primary_root).expect("remove primary root symlink");
12692 fs::create_dir_all(&primary_root).expect("primary root");
12693 symlink(&external_primary, primary_root.join("sessions"))
12694 .expect("symlinked primary sessions root");
12695
12696 let sessions_report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12697 assert_eq!(
12698 sessions_report.status,
12699 DoctorSessionRecoveryStatus::ScanFailed
12700 );
12701 assert!(sessions_report.error.as_deref().is_some_and(|error| {
12702 error.contains("primary sessions root") && error.contains("path is a symlink")
12703 }));
12704 }
12705
12706 #[test]
12707 fn explicit_codewhale_home_skips_session_recovery_scan() {
12708 let tmp = TempDir::new().expect("tempdir");
12709 let (primary_root, legacy_root) = roots(&tmp);
12710 fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
12711 fs::write(legacy_root.join("sessions").join("ambient.json"), b"legacy")
12712 .expect("legacy session");
12713
12714 let report = doctor_session_recovery_report(&primary_root, &legacy_root, true);
12715
12716 assert_eq!(report.status, DoctorSessionRecoveryStatus::Isolated);
12717 assert!(report.codewhale_home_is_explicit);
12718 assert_eq!(report.legacy_session_file_count, 0);
12719 assert_eq!(report.recoverable_file_count, 0);
12720 assert!(report.recoverable.is_empty());
12721 assert!(!report.needs_attention());
12722 }
12723
12724 #[test]
12725 fn doctor_state_roots_ignore_ambient_legacy_home_when_codewhale_home_is_explicit() {
12726 let _env_lock = crate::test_support::lock_test_env();
12727 let tmp = TempDir::new().expect("tempdir");
12728 let explicit_home = tmp.path().join("isolated-codewhale");
12729 let ambient_legacy = tmp.path().join(".deepseek");
12730 fs::create_dir_all(&ambient_legacy).expect("ambient legacy root");
12731 fs::write(
12732 ambient_legacy.join("config.toml"),
12733 "provider = 'deepseek'\n",
12734 )
12735 .expect("ambient legacy config");
12736 let _home = EnvVarRestore::set("HOME", tmp.path());
12737 let _codewhale_home = EnvVarRestore::set("CODEWHALE_HOME", &explicit_home);
12738
12739 let (primary_root, legacy_root) = doctor_state_roots();
12740 let report = doctor_legacy_state_report(&primary_root, &legacy_root);
12741 let session_recovery = doctor_session_recovery_report(
12742 &primary_root,
12743 &legacy_root,
12744 codewhale_config::codewhale_home_is_explicit(),
12745 );
12746
12747 assert_eq!(primary_root, explicit_home);
12748 assert_eq!(
12749 legacy_root,
12750 primary_root.join(codewhale_config::LEGACY_APP_DIR)
12751 );
12752 assert!(
12753 report
12754 .iter()
12755 .all(|entry| entry.status == DoctorLegacyStateStatus::Absent),
12756 "doctor must not report ambient legacy state when CODEWHALE_HOME is explicit"
12757 );
12758 assert!(!report.iter().any(legacy_state_needs_attention));
12759 assert_eq!(
12760 session_recovery.status,
12761 DoctorSessionRecoveryStatus::Isolated
12762 );
12763 assert!(session_recovery.recoverable.is_empty());
12764 }
12765}
12766
12767#[cfg(test)]
12768mod doctor_setup_state_tests {
12769 use super::*;
12770 use std::fs;
12771 use tempfile::TempDir;
12772
12773 fn prepare_env(tmp: &TempDir) -> (crate::test_support::EnvVarGuard, PathBuf) {
12774 let codewhale_home = tmp.path().join(".codewhale");
12775 fs::create_dir_all(&codewhale_home).expect("codewhale home");
12776 (
12777 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", codewhale_home.as_os_str()),
12778 codewhale_home,
12779 )
12780 }
12781
12782 fn provider_step(report: &serde_json::Value) -> &serde_json::Value {
12783 report["steps"]
12784 .as_array()
12785 .expect("steps array")
12786 .iter()
12787 .find(|step| step["step"] == "provider_model")
12788 .expect("provider/model step")
12789 }
12790
12791 #[test]
12792 fn doctor_setup_consistency_flags_missing_user_constitution() {
12793 let _guard = crate::test_support::lock_test_env();
12794 let tmp = TempDir::new().expect("tempdir");
12795 let (_home_guard, _codewhale_home) = prepare_env(&tmp);
12796 let _key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
12797 let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
12798 let workspace = tmp.path().join("workspace");
12799 fs::create_dir_all(&workspace).expect("workspace");
12800
12801 let state = codewhale_config::SetupState {
12802 constitution_source: codewhale_config::ConstitutionSource::UserGlobal,
12803 ..Default::default()
12804 };
12805 state.save().expect("persist setup state");
12806
12807 let report = doctor_setup_report_json(&Config::default(), &workspace);
12808
12809 assert_eq!(report["source"], "persisted");
12810 assert_eq!(report["consistency"]["status"], "inconsistent");
12811 let issues = report["consistency"]["issues"].to_string();
12812 assert!(
12813 issues.contains("setup_state_points_at_missing_user_constitution"),
12814 "{issues}"
12815 );
12816 }
12817
12818 #[test]
12819 fn doctor_setup_consistency_flags_stale_temp_files() {
12820 let _guard = crate::test_support::lock_test_env();
12821 let tmp = TempDir::new().expect("tempdir");
12822 let (_home_guard, codewhale_home) = prepare_env(&tmp);
12823 let _key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
12824 let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
12825 let workspace = tmp.path().join("workspace");
12826 fs::create_dir_all(&workspace).expect("workspace");
12827 fs::write(codewhale_home.join(".tmpAbC123"), b"orphaned atomic write")
12828 .expect("stale temp file");
12829
12830 let report = doctor_setup_report_json(&Config::default(), &workspace);
12831
12832 assert_eq!(report["consistency"]["status"], "inconsistent");
12833 let issues = report["consistency"]["issues"].to_string();
12834 assert!(
12835 issues.contains("stale_setup_temp_files_in_codewhale_home"),
12836 "{issues}"
12837 );
12838 }
12839
12840 #[test]
12841 fn doctor_setup_consistency_reports_consistent_for_clean_home() {
12842 let _guard = crate::test_support::lock_test_env();
12843 let tmp = TempDir::new().expect("tempdir");
12844 let (_home_guard, _codewhale_home) = prepare_env(&tmp);
12845 let _key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
12846 let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
12847 let workspace = tmp.path().join("workspace");
12848 fs::create_dir_all(&workspace).expect("workspace");
12849
12850 let report = doctor_setup_report_json(&Config::default(), &workspace);
12851
12852 assert_eq!(report["consistency"]["status"], "consistent");
12853 assert_eq!(
12854 report["consistency"]["issues"]
12855 .as_array()
12856 .map(Vec::len)
12857 .unwrap_or_default(),
12858 0
12859 );
12860 }
12861
12862 #[test]
12863 fn doctor_setup_report_json_derives_state_without_sidecar() {
12864 let _guard = crate::test_support::lock_test_env();
12865 let tmp = TempDir::new().expect("tempdir");
12866 let (_home_guard, _codewhale_home) = prepare_env(&tmp);
12867 let _key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
12868 let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
12869 let workspace = tmp.path().join("workspace");
12870 fs::create_dir_all(&workspace).expect("workspace");
12871
12872 let report = doctor_setup_report_json(&Config::default(), &workspace);
12873
12874 assert_eq!(report["source"], "derived");
12875 assert_eq!(report["inherited"], true);
12876 assert_eq!(report["next_actions"]["constitution"], "/constitution");
12877 assert_eq!(report["next_actions"]["setup_report"], "/setup report");
12878 assert_eq!(
12879 report["next_actions"]["provider_model"],
12880 "/setup provider, /provider setup <name>, or /model"
12881 );
12882 assert_eq!(report["next_actions"]["runtime_posture"], "/config");
12883 assert_eq!(
12884 report["next_actions"]["operate_fleet"],
12885 "/setup fleet (readiness), /fleet setup (explicit profile authoring)"
12886 );
12887 assert_eq!(report["next_actions"]["hotbar"], "/setup hotbar");
12888 assert_eq!(report["next_actions"]["tools_mcp"], "/setup tools");
12889 assert_eq!(report["next_actions"]["remote_runtime"], "/setup remote");
12890 assert_eq!(report["next_actions"]["persistence"], "/setup persistence");
12891 assert_eq!(
12892 report["checkpoint_version"],
12893 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION
12894 );
12895 assert_eq!(report["update_ready"], false);
12896 assert_eq!(report["operate_ready"], false);
12897 assert_eq!(
12898 report["operate_fleet"]["concurrency"]["plan_limit_probed"],
12899 false
12900 );
12901 assert_eq!(
12902 report["operate_fleet"]["roster"]["readiness_rule"],
12903 "built-in starter roster or custom roster"
12904 );
12905 assert_eq!(report["provider_model"]["provider"]["id"], "deepseek");
12906 assert_eq!(report["provider_model"]["provider"]["display"], "DeepSeek");
12907 assert_eq!(
12908 report["provider_model"]["model"]["resolved"],
12909 crate::config::DEFAULT_TEXT_MODEL
12910 );
12911 assert_eq!(
12912 report["provider_model"]["auth"]["source"],
12913 "secret_store_unprobed"
12914 );
12915 assert_eq!(
12916 report["provider_model"]["auth"]["availability"],
12917 "not_probed"
12918 );
12919 assert_eq!(
12920 report["provider_model"]["auth"]["credential_url"],
12921 "https://platform.deepseek.com"
12922 );
12923 assert_eq!(
12924 report["provider_model"]["auth"]["credential_mode"],
12925 "api_key"
12926 );
12927 assert_eq!(
12928 report["provider_model"]["auth"]["env_vars"][0],
12929 "DEEPSEEK_API_KEY"
12930 );
12931 assert_eq!(report["provider_model"]["health"]["live_validation"], false);
12932 assert_eq!(report["constitution"]["source"], "bundled");
12933 assert_eq!(report["constitution"]["autonomy_preference"], "unspecified");
12934 assert_eq!(report["runtime_posture"]["source"], "unset");
12935 assert_eq!(report["runtime_posture"]["default_mode"]["value"], "agent");
12936 assert_eq!(
12937 report["runtime_posture"]["approval_policy"]["value"],
12938 "on-request"
12939 );
12940 assert_eq!(report["runtime_posture"]["allow_shell"]["value"], true);
12941 assert_eq!(
12942 report["runtime_posture"]["sandbox_mode"]["value"],
12943 "mode-derived"
12944 );
12945 assert_eq!(
12946 report["runtime_posture"]["network_default"]["value"],
12947 "prompt"
12948 );
12949 assert_eq!(provider_step(&report)["status"], "needs_action");
12950 }
12951
12952 #[test]
12953 fn doctor_setup_provider_model_json_covers_cn_codex_and_local_matrix() {
12954 let _guard = crate::test_support::lock_test_env();
12955 let tmp = TempDir::new().expect("tempdir");
12956 let (_home_guard, _codewhale_home) = prepare_env(&tmp);
12957 let _home = crate::test_support::EnvVarGuard::set("HOME", tmp.path());
12958 let _userprofile = crate::test_support::EnvVarGuard::set("USERPROFILE", tmp.path());
12959 let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
12960 let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
12961 let _codex_key = crate::test_support::EnvVarGuard::remove("OPENAI_CODEX_ACCESS_TOKEN");
12962 let _codex_legacy_key = crate::test_support::EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
12963 let codex_auth_path = tmp.path().join("external-codex-auth.json");
12964 let codex_auth_raw = serde_json::json!({
12965 "tokens": {
12966 "access_token": crate::test_support::future_test_jwt("doctor"),
12967 "account_id": "acct-doctor-read-only",
12968 "refresh_token": "must-never-be-used",
12969 "unknown": {"preserve": true}
12970 }
12971 })
12972 .to_string();
12973 fs::write(&codex_auth_path, &codex_auth_raw).expect("Codex auth trap fixture");
12974 let _codex_auth =
12975 crate::test_support::EnvVarGuard::set("OPENAI_CODEX_AUTH_FILE", &codex_auth_path);
12976 let workspace = tmp.path().join("workspace");
12977 fs::create_dir_all(&workspace).expect("workspace");
12978
12979 let cn_config = Config {
12980 provider: Some("deepseek-cn".to_string()),
12981 ..Config::default()
12982 };
12983 let cn_report = doctor_setup_report_json(&cn_config, &workspace);
12984 assert_eq!(cn_report["provider_model"]["provider"]["id"], "deepseek-cn");
12985 assert_eq!(
12986 cn_report["provider_model"]["provider"]["display"],
12987 "DeepSeek (legacy alias)"
12988 );
12989 assert_eq!(
12990 cn_report["provider_model"]["auth"]["env_vars"][0],
12991 "DEEPSEEK_API_KEY"
12992 );
12993 assert_eq!(
12994 cn_report["provider_model"]["auth"]["credential_url"],
12995 "https://platform.deepseek.com"
12996 );
12997 assert_eq!(cn_report["provider_model"]["auth"]["oauth_only"], false);
12998 assert_eq!(
12999 cn_report["provider_model"]["health"]["live_validation"],
13000 false
13001 );
13002
13003 let codex_config = Config {
13004 provider: Some("openai-codex".to_string()),
13005 ..Config::default()
13006 };
13007 crate::external_credentials::reset_side_effect_trap();
13008 let codex_report = doctor_setup_report_json(&codex_config, &workspace);
13009 assert_eq!(
13010 codex_report["provider_model"]["provider"]["id"],
13011 crate::config::ApiProvider::OpenaiCodex.as_str()
13012 );
13013 assert!(codex_report["provider_model"]["auth"]["credential_url"].is_null());
13014 assert_eq!(
13015 codex_report["provider_model"]["auth"]["credential_mode"],
13016 "oauth"
13017 );
13018 assert_eq!(codex_report["provider_model"]["auth"]["oauth_only"], true);
13019 assert_eq!(
13020 codex_report["provider_model"]["health"]["next_action"],
13021 "/setup provider or /provider setup <name>"
13022 );
13023 assert_eq!(
13024 crate::external_credentials::side_effect_trap_counts(),
13025 (0, 0),
13026 "doctor must not stat or read external credentials without consent"
13027 );
13028
13029 let mut consent = codewhale_config::ExternalCredentialConsentToml::read_only(
13030 codewhale_config::ProviderKind::OpenaiCodex,
13031 codewhale_config::ExternalCredentialSource::CodexCli,
13032 codex_auth_path.clone(),
13033 );
13034 let codex_read_only = Config {
13035 provider: Some("openai-codex".to_string()),
13036 providers: Some(crate::config::ProvidersConfig {
13037 openai_codex: crate::config::ProviderConfig {
13038 auth_mode: Some("oauth".to_string()),
13039 external_credentials: Some(consent.clone()),
13040 ..Default::default()
13041 },
13042 ..Default::default()
13043 }),
13044 ..Config::default()
13045 };
13046 let changed_ambient_path = tmp.path().join("new-ambient-codex-auth.json");
13047 let _changed_codex_auth =
13048 crate::test_support::EnvVarGuard::set("OPENAI_CODEX_AUTH_FILE", &changed_ambient_path);
13049 crate::external_credentials::reset_side_effect_trap();
13050 let codex_read_only_report = doctor_setup_report_json(&codex_read_only, &workspace);
13051 assert_eq!(
13052 codex_read_only_report["provider_model"]["auth"]["present_or_local"],
13053 false
13054 );
13055 assert_eq!(
13056 codex_read_only_report["provider_model"]["auth"]["source"],
13057 "external_consent"
13058 );
13059 let status_json = doctor_external_credential_consent_json(&codex_read_only);
13060 let codex_status = status_json
13061 .as_array()
13062 .and_then(|rows| rows.first())
13063 .expect("Codex structural status");
13064 assert_eq!(codex_status["access"], "read_only");
13065 assert_eq!(codex_status["provider"], "openai-codex");
13066 assert_eq!(codex_status["source"], "codex_cli");
13067 assert_eq!(codex_status["route_state"], "active");
13068 assert_eq!(codex_status["ambient_path_changed"], true);
13069 assert!(
13070 codex_status["ambient_path_warning"]
13071 .as_str()
13072 .is_some_and(|warning| warning.contains("remains pinned"))
13073 );
13074 assert_eq!(
13075 codex_status["revoke_command"],
13076 "codewhale auth external-revoke --provider openai-codex"
13077 );
13078 let human = doctor_external_credential_consent_lines(&codex_read_only).join("\n");
13079 assert!(human.contains("path="), "{human}");
13080 assert!(human.contains("version=1"), "{human}");
13081 assert!(human.contains("no refresh, identity-provider or discovery requests"));
13082 assert!(human.contains("normal requests to the explicitly selected provider"));
13083 assert!(human.contains("consent remains pinned"), "{human}");
13084 assert!(
13085 human.contains(&codewhale_config::quote_os_path(&codex_auth_path)),
13086 "{human}"
13087 );
13088 assert!(!human.contains(&changed_ambient_path.display().to_string()));
13089 assert_eq!(
13090 crate::external_credentials::complete_side_effect_trap_counts(),
13091 (0, 0, 0, 0, 0),
13092 "doctor consent status is structural and must not inspect the file"
13093 );
13094 assert_eq!(
13095 fs::read_to_string(&codex_auth_path).expect("unchanged Codex auth fixture"),
13096 codex_auth_raw
13097 );
13098
13099 consent.access = codewhale_config::ExternalCredentialAccess::Managed;
13100 let codex_managed = Config {
13101 provider: Some("openai-codex".to_string()),
13102 providers: Some(crate::config::ProvidersConfig {
13103 openai_codex: crate::config::ProviderConfig {
13104 auth_mode: Some("oauth".to_string()),
13105 external_credentials: Some(consent),
13106 ..Default::default()
13107 },
13108 ..Default::default()
13109 }),
13110 ..Config::default()
13111 };
13112 crate::external_credentials::reset_side_effect_trap();
13113 let codex_managed_report = doctor_setup_report_json(&codex_managed, &workspace);
13114 assert_eq!(
13115 codex_managed_report["provider_model"]["auth"]["present_or_local"],
13116 false
13117 );
13118 assert_eq!(
13119 crate::external_credentials::side_effect_trap_counts(),
13120 (0, 0),
13121 "unsupported managed mode must fail before external I/O"
13122 );
13123 assert_eq!(
13124 fs::read_to_string(&codex_auth_path).expect("unchanged managed auth fixture"),
13125 codex_auth_raw
13126 );
13127
13128 let local_config = Config {
13129 provider: Some("ollama".to_string()),
13130 ..Config::default()
13131 };
13132 let local_report = doctor_setup_report_json(&local_config, &workspace);
13133 assert_eq!(local_report["provider_model"]["provider"]["id"], "ollama");
13134 assert_eq!(
13135 local_report["provider_model"]["auth"]["present_or_local"],
13136 true
13137 );
13138 assert!(local_report["provider_model"]["auth"]["credential_url"].is_null());
13139 assert_eq!(
13140 local_report["provider_model"]["auth"]["credential_mode"],
13141 "local_optional"
13142 );
13143 assert_eq!(local_report["provider_model"]["auth"]["oauth_only"], false);
13144 assert_eq!(
13145 local_report["provider_model"]["health"]["next_action"],
13146 "/model"
13147 );
13148
13149 let kimi_config = Config {
13150 provider: Some("moonshot".to_string()),
13151 ..Config::default()
13152 };
13153 let kimi_report = doctor_setup_report_json(&kimi_config, &workspace);
13154 assert_eq!(
13155 kimi_report["provider_model"]["auth"]["credential_url"],
13156 "https://platform.kimi.ai"
13157 );
13158 assert_eq!(
13159 kimi_report["provider_model"]["auth"]["credential_docs_url"],
13160 "https://platform.kimi.ai"
13161 );
13162 assert_eq!(
13163 kimi_report["provider_model"]["auth"]["credential_mode"],
13164 "api_key"
13165 );
13166 assert!(
13167 kimi_report["provider_model"]["auth"]["credential_guidance"]
13168 .as_str()
13169 .is_some_and(|guidance| guidance.contains("OAuth is not available"))
13170 );
13171 }
13172
13173 #[test]
13174 fn doctor_setup_report_json_uses_persisted_state() {
13175 let _guard = crate::test_support::lock_test_env();
13176 let tmp = TempDir::new().expect("tempdir");
13177 let (_home_guard, _codewhale_home) = prepare_env(&tmp);
13178 let workspace = tmp.path().join("workspace");
13179 fs::create_dir_all(&workspace).expect("workspace");
13180 let mut state = codewhale_config::SetupState::default();
13181 state.set_step(
13182 codewhale_config::SetupStep::Language,
13183 codewhale_config::StepEntry::new(
13184 codewhale_config::StepStatus::Verified,
13185 true,
13186 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13187 ),
13188 );
13189 state.set_step(
13190 codewhale_config::SetupStep::ProviderModel,
13191 codewhale_config::StepEntry::new(
13192 codewhale_config::StepStatus::Verified,
13193 true,
13194 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13195 )
13196 .with_result("deepseek/deepseek-chat"),
13197 );
13198 state.set_step(
13199 codewhale_config::SetupStep::TrustSandbox,
13200 codewhale_config::StepEntry::new(
13201 codewhale_config::StepStatus::Verified,
13202 true,
13203 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13204 ),
13205 );
13206 state
13207 .complete_constitution_checkpoint(
13208 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13209 codewhale_config::ConstitutionChoice::Bundled,
13210 )
13211 .set_step(
13212 codewhale_config::SetupStep::Constitution,
13213 codewhale_config::StepEntry::new(
13214 codewhale_config::StepStatus::Verified,
13215 true,
13216 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13217 ),
13218 );
13219 state.runtime_posture_source = codewhale_config::RuntimePostureSource::Confirmed;
13220 state.save().expect("persist setup state");
13221 codewhale_config::UserConstitution {
13222 autonomy_preference: codewhale_config::AutonomyPreference::Balanced,
13223 ..Default::default()
13224 }
13225 .save()
13226 .expect("persist user constitution");
13227 let config = Config {
13228 api_key: Some("TEST-STRUCTURAL-LITERAL".to_string()),
13229 approval_policy: Some("never".to_string()),
13230 allow_shell: Some(false),
13231 sandbox_mode: Some("read-only".to_string()),
13232 network: Some(crate::config::NetworkPolicyToml {
13233 default: "deny".to_string(),
13234 ..Default::default()
13235 }),
13236 ..Config::default()
13237 };
13238
13239 let report = doctor_setup_report_json(&config, &workspace);
13240
13241 assert_eq!(report["source"], "persisted");
13242 assert_eq!(report["first_run_ready"], true);
13243 assert_eq!(report["update_ready"], true);
13244 assert_eq!(report["operate_ready"], false);
13245 assert_eq!(report["constitution"]["choice"], "bundled");
13246 assert_eq!(
13247 report["constitution"]["checkpoint_completed_for"],
13248 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION
13249 );
13250 assert_eq!(report["constitution"]["autonomy_preference"], "balanced");
13251 assert_eq!(report["runtime_posture_source"], "confirmed");
13252 assert_eq!(report["runtime_posture"]["source"], "confirmed");
13253 assert_eq!(
13254 report["runtime_posture"]["approval_policy"]["value"],
13255 "never"
13256 );
13257 assert_eq!(
13258 report["runtime_posture"]["approval_policy"]["source"],
13259 "config"
13260 );
13261 assert_eq!(report["runtime_posture"]["allow_shell"]["value"], false);
13262 assert_eq!(report["runtime_posture"]["allow_shell"]["source"], "config");
13263 assert_eq!(
13264 report["runtime_posture"]["sandbox_mode"]["value"],
13265 "read-only"
13266 );
13267 assert_eq!(
13268 report["runtime_posture"]["sandbox_mode"]["source"],
13269 "config"
13270 );
13271 assert_eq!(
13272 report["runtime_posture"]["network_default"]["value"],
13273 "deny"
13274 );
13275 assert_eq!(
13276 report["runtime_posture"]["network_default"]["source"],
13277 "config"
13278 );
13279 assert_eq!(provider_step(&report)["result"], "deepseek/deepseek-chat");
13280
13281 let unprobed_config = Config {
13282 api_key: Some(crate::config::API_KEYRING_SENTINEL.to_string()),
13283 ..config.clone()
13284 };
13285 let unprobed_report = doctor_setup_report_json(&unprobed_config, &workspace);
13286 assert_eq!(unprobed_report["credential"]["ready"], false);
13287 assert_eq!(unprobed_report["credential"]["availability"], "not_probed");
13288 assert_eq!(unprobed_report["first_run_ready"], true);
13289 assert_eq!(unprobed_report["update_ready"], true);
13290 }
13291
13292 #[test]
13293 fn doctor_reports_settings_permission_posture_when_approval_policy_unset() {
13294 let _guard = crate::test_support::lock_test_env();
13295 let tmp = TempDir::new().expect("tempdir");
13296 let (_home_guard, codewhale_home) = prepare_env(&tmp);
13297 let workspace = tmp.path().join("workspace");
13298 fs::create_dir_all(&workspace).expect("workspace");
13299 fs::write(
13300 codewhale_home.join("settings.toml"),
13301 "permission_posture = \"full-access\"\n",
13302 )
13303 .expect("write settings.toml");
13304
13305 let config = Config::default();
13306 assert!(config.approval_policy.is_none());
13307
13308 let line = doctor_runtime_posture_line(&config, &workspace);
13309 assert!(
13310 line.contains("permission_posture=full-access (settings)"),
13311 "text doctor should report saved settings posture: {line}"
13312 );
13313 assert!(
13314 line.contains("approval_policy=on-request (default)"),
13315 "text doctor should keep unset config approval_policy default: {line}"
13316 );
13317
13318 let report = doctor_setup_report_json(&config, &workspace);
13319 assert_eq!(
13320 report["runtime_posture"]["permission_posture"]["value"],
13321 "full-access"
13322 );
13323 assert_eq!(
13324 report["runtime_posture"]["permission_posture"]["source"],
13325 "settings"
13326 );
13327 assert_eq!(
13328 report["runtime_posture"]["approval_policy"]["value"],
13329 "on-request"
13330 );
13331 assert_eq!(
13332 report["runtime_posture"]["approval_policy"]["source"],
13333 "default"
13334 );
13335 }
13336
13337 #[test]
13341 fn doctor_reports_resolved_telemetry_with_its_source() {
13342 let _guard = crate::test_support::lock_test_env();
13343 let tmp = TempDir::new().expect("tempdir");
13344 let (_home_guard, _codewhale_home) = prepare_env(&tmp);
13345 let _telemetry_env = crate::test_support::EnvVarGuard::remove("CODEWHALE_TELEMETRY");
13346 let _telemetry_alias_env = crate::test_support::EnvVarGuard::remove("DEEPSEEK_TELEMETRY");
13347 let _telemetry_floor =
13348 crate::test_support::EnvVarGuard::remove("CODEWHALE_TELEMETRY_FLOOR");
13349 let workspace = tmp.path().join("workspace");
13350 fs::create_dir_all(&workspace).expect("workspace");
13351
13352 let config = Config::default();
13355 assert!(config.telemetry.is_none());
13356 let line = doctor_runtime_posture_line(&config, &workspace);
13357 assert!(
13358 line.contains("telemetry=on (default)"),
13359 "doctor line should name the defaulted consent: {line}"
13360 );
13361 let report = doctor_setup_report_json(&config, &workspace);
13362 assert_eq!(report["runtime_posture"]["telemetry"]["value"], true);
13363 assert_eq!(report["runtime_posture"]["telemetry"]["source"], "default");
13364
13365 let config = Config {
13367 telemetry: Some(false),
13368 ..Config::default()
13369 };
13370 let line = doctor_runtime_posture_line(&config, &workspace);
13371 assert!(
13372 line.contains("telemetry=off (config)"),
13373 "doctor line should name the persisted opt-out: {line}"
13374 );
13375 let report = doctor_setup_report_json(&config, &workspace);
13376 assert_eq!(report["runtime_posture"]["telemetry"]["value"], false);
13377 assert_eq!(report["runtime_posture"]["telemetry"]["source"], "config");
13378 }
13379
13380 #[test]
13381 fn doctor_setup_report_json_fails_closed_without_operate_receipts() {
13382 let _guard = crate::test_support::lock_test_env();
13383 let tmp = TempDir::new().expect("tempdir");
13384 let (_home_guard, _codewhale_home) = prepare_env(&tmp);
13385 let workspace = tmp.path().join("workspace");
13386 fs::create_dir_all(&workspace).expect("workspace");
13387 let mut state = codewhale_config::SetupState::default();
13388 state.set_step(
13389 codewhale_config::SetupStep::Language,
13390 codewhale_config::StepEntry::new(
13391 codewhale_config::StepStatus::Verified,
13392 true,
13393 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13394 ),
13395 );
13396 state.set_step(
13397 codewhale_config::SetupStep::ProviderModel,
13398 codewhale_config::StepEntry::new(
13399 codewhale_config::StepStatus::Verified,
13400 true,
13401 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13402 ),
13403 );
13404 state.runtime_posture_source = codewhale_config::RuntimePostureSource::Confirmed;
13405 state.complete_constitution_checkpoint(
13406 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13407 codewhale_config::ConstitutionChoice::Bundled,
13408 );
13409 state.set_step(
13410 codewhale_config::SetupStep::OperateFleet,
13411 codewhale_config::StepEntry::new(
13412 codewhale_config::StepStatus::Verified,
13413 false,
13414 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13415 )
13416 .with_result(
13417 "provider=ready, runtime=ready, roster=ready, concurrency=plan limit not probed",
13418 ),
13419 );
13420 state.save().expect("persist setup state");
13421
13422 let config = Config {
13423 api_key: Some("TEST-STRUCTURAL-LITERAL".to_string()),
13424 ..Config::default()
13425 };
13426 let report = doctor_setup_report_json(&config, &workspace);
13427
13428 assert_eq!(report["first_run_ready"], true);
13429 assert_eq!(report["operate_ready"], false);
13430 assert_eq!(
13431 report["operate_fleet"]["concurrency"]["plan_limit_probed"],
13432 false
13433 );
13434 assert!(
13435 report["operate_fleet"]["roster"]["built_in"]
13436 .as_u64()
13437 .is_some_and(|count| count > 0)
13438 );
13439 let operate_step = report["steps"]
13440 .as_array()
13441 .expect("steps array")
13442 .iter()
13443 .find(|step| step["step"] == "operate_fleet")
13444 .expect("operate/fleet step");
13445 assert_eq!(operate_step["status"], "verified");
13446 assert!(
13447 operate_step["result"]
13448 .as_str()
13449 .is_some_and(|result| result.contains("plan limit not probed"))
13450 );
13451 }
13452}
13453
13454#[cfg(test)]
13455mod doctor_endpoint_tests {
13456 use super::*;
13457
13458 #[test]
13459 fn doctor_api_target_reports_default_endpoint() {
13460 let config = Config::default();
13461
13462 let target = doctor_api_target(&config);
13463
13464 assert_eq!(target.provider, "deepseek");
13465 assert_eq!(target.base_url, crate::config::DEFAULT_DEEPSEEK_BASE_URL);
13466 assert_eq!(target.model, crate::config::DEFAULT_TEXT_MODEL);
13467 assert_eq!(target.resolution, DoctorModelResolution::Resolved);
13468 }
13469
13470 #[test]
13471 fn doctor_api_target_falls_back_to_configured_model_when_resolution_fails() {
13472 let config = Config {
13476 provider: Some("custom".to_string()),
13477 ..Default::default()
13478 };
13479
13480 let target = doctor_api_target(&config);
13481
13482 assert_eq!(target.resolution, DoctorModelResolution::ConfiguredOnly);
13483 assert_eq!(target.model, config.default_model());
13484 }
13485
13486 #[test]
13487 fn doctor_api_target_routes_deepseek_cn_alias_to_beta_endpoint() {
13488 let config = Config {
13489 provider: Some("deepseek-cn".to_string()),
13490 ..Default::default()
13491 };
13492
13493 let target = doctor_api_target(&config);
13494
13495 assert_eq!(target.provider, "deepseek-cn");
13496 assert_eq!(target.base_url, crate::config::DEFAULT_DEEPSEEKCN_BASE_URL);
13497 assert_eq!(target.base_url, crate::config::DEFAULT_DEEPSEEK_BASE_URL);
13498 assert_eq!(target.model, crate::config::DEFAULT_TEXT_MODEL);
13499 assert_eq!(target.resolution, DoctorModelResolution::Resolved);
13500 }
13501
13502 #[test]
13503 fn strict_tool_mode_doctor_reports_disabled_by_default() {
13504 let config = Config::default();
13505
13506 let status = doctor_strict_tool_mode_status(&config);
13507
13508 assert!(!status.enabled);
13509 assert_eq!(status.status, "disabled");
13510 assert!(!status.function_strict_sent);
13511 assert!(status.recommended_base_url.is_none());
13512 }
13513
13514 #[test]
13515 fn doctor_known_base_urls_are_ascii_case_insensitive() {
13516 assert!(doctor_xiaomi_mimo_base_url_uses_token_plan(
13517 "HTTPS://TOKEN-PLAN-CN.XIAOMIMIMO.COM/V1/"
13518 ));
13519 assert_eq!(
13520 known_deepseek_base_url_kind("HTTPS://API.DEEPSEEK.COM/BETA/"),
13521 Some(DeepSeekBaseUrlKind::Beta)
13522 );
13523 assert_eq!(
13524 known_deepseek_base_url_kind("HTTPS://API.DEEPSEEK.COM/V1/"),
13525 Some(DeepSeekBaseUrlKind::NonBeta)
13526 );
13527 }
13528
13529 #[test]
13530 fn strict_tool_mode_doctor_accepts_default_beta_endpoint() {
13531 let config = Config {
13532 strict_tool_mode: Some(true),
13533 ..Default::default()
13534 };
13535
13536 let status = doctor_strict_tool_mode_status(&config);
13537
13538 assert!(status.enabled);
13539 assert_eq!(status.status, "ready");
13540 assert!(status.function_strict_sent);
13541 assert!(status.message.contains("beta endpoint"));
13542 assert!(status.recommended_base_url.is_none());
13543 }
13544
13545 #[test]
13546 fn strict_tool_mode_doctor_warns_for_non_beta_deepseek_endpoint() {
13547 let config = Config {
13548 strict_tool_mode: Some(true),
13549 base_url: Some("https://api.deepseek.com".to_string()),
13550 ..Default::default()
13551 };
13552
13553 let status = doctor_strict_tool_mode_status(&config);
13554
13555 assert_eq!(status.status, "fallback_non_beta");
13556 assert!(!status.function_strict_sent);
13557 assert_eq!(
13558 status.recommended_base_url.as_deref(),
13559 Some(crate::config::DEFAULT_DEEPSEEK_BASE_URL)
13560 );
13561 assert_eq!(
13562 doctor_strict_tool_mode_report_json(&status)["recommended_base_url"],
13563 "https://api.deepseek.com"
13564 );
13565 }
13566
13567 #[test]
13568 fn strict_tool_mode_doctor_accepts_deepseek_cn_alias_default_endpoint() {
13569 let config = Config {
13570 provider: Some("deepseek-cn".to_string()),
13571 strict_tool_mode: Some(true),
13572 ..Default::default()
13573 };
13574
13575 let status = doctor_strict_tool_mode_status(&config);
13576
13577 assert_eq!(status.status, "ready");
13578 assert!(status.function_strict_sent);
13579 assert!(status.message.contains("beta endpoint"));
13580 assert!(status.recommended_base_url.is_none());
13581 }
13582
13583 #[test]
13584 fn strict_tool_mode_doctor_marks_custom_endpoint_as_forwarded() {
13585 let config = Config {
13586 provider: Some("vllm".to_string()),
13587 strict_tool_mode: Some(true),
13588 ..Default::default()
13589 };
13590
13591 let status = doctor_strict_tool_mode_status(&config);
13592
13593 assert_eq!(status.status, "custom_endpoint");
13594 assert!(status.function_strict_sent);
13595 assert!(status.message.contains("custom endpoint"));
13596 }
13597
13598 #[test]
13599 fn doctor_tls_status_reports_verification_enabled_by_default() {
13600 let status = doctor_tls_status(&Config::default());
13601
13602 assert!(status.certificate_verification);
13603 assert!(!status.insecure_skip_tls_verify);
13604 assert_eq!(status.provider, "deepseek");
13605 assert!(status.message.contains("enabled"));
13606 }
13607
13608 #[test]
13609 fn doctor_tls_status_warns_when_active_provider_skips_verification() {
13610 let mut providers = crate::config::ProvidersConfig::default();
13611 providers.openai.insecure_skip_tls_verify = Some(true);
13612 let config = Config {
13613 provider: Some("openai".to_string()),
13614 providers: Some(providers),
13615 ..Default::default()
13616 };
13617
13618 let status = doctor_tls_status(&config);
13619
13620 assert!(status.certificate_verification);
13621 assert!(status.insecure_skip_tls_verify);
13622 assert_eq!(status.provider, "openai");
13623 assert!(status.message.contains("cannot be disabled"));
13624 assert!(status.message.contains("SSL_CERT_FILE"));
13625 }
13626
13627 #[test]
13628 fn provider_capability_report_exposes_alias_deprecation_for_deepseek_chat() {
13629 let mut config = Config {
13630 default_text_model: Some("deepseek-chat".to_string()),
13631 ..Default::default()
13632 };
13633 crate::config::normalize_model_config_for_test(&mut config);
13634
13635 let report = provider_capability_report(&config);
13636
13637 assert_eq!(report["resolved_model"], "deepseek-v4-flash");
13638 assert_eq!(report["context_window"], 1_000_000);
13639 assert_eq!(report["thinking_supported"], true);
13640 assert_eq!(report["alias_deprecation"]["alias"], "deepseek-chat");
13641 assert_eq!(
13642 report["alias_deprecation"]["replacement"],
13643 "deepseek-v4-flash"
13644 );
13645 assert_eq!(
13646 report["alias_deprecation"]["retirement_utc"],
13647 "2026-07-24T15:59:00Z"
13648 );
13649 }
13650
13651 #[test]
13652 fn provider_capability_report_preserves_custom_deepseek_alias_namespace() {
13653 let mut config = Config {
13654 base_url: Some("https://models.example/v1".to_string()),
13655 default_text_model: Some("deepseek-chat".to_string()),
13656 ..Default::default()
13657 };
13658 crate::config::normalize_model_config_for_test(&mut config);
13659
13660 let report = provider_capability_report(&config);
13661
13662 assert_eq!(report["resolved_model"], "deepseek-chat");
13663 assert!(report["alias_deprecation"].is_null());
13664 }
13665
13666 #[test]
13667 fn provider_capability_report_leaves_canonical_flash_alias_metadata_null() {
13668 let config = Config {
13669 default_text_model: Some("deepseek-v4-flash".to_string()),
13670 ..Default::default()
13671 };
13672
13673 let report = provider_capability_report(&config);
13674
13675 assert_eq!(report["resolved_model"], "deepseek-v4-flash");
13676 assert!(report["alias_deprecation"].is_null());
13677 }
13678
13679 #[test]
13680 fn doctor_route_report_exposes_tokenhub_openai_compatible_route_without_secret() {
13681 let mut providers = crate::config::ProvidersConfig::default();
13682 providers.openai.api_key = Some("tokenhub-secret-value".to_string());
13683 providers.openai.base_url = Some("https://tokenhub.tencentmaas.com/v1".to_string());
13684 providers.openai.model = Some("deepseek-ai/DeepSeek-V4-Pro".to_string());
13685 let config = Config {
13686 provider: Some("openai".to_string()),
13687 providers: Some(providers),
13688 ..Default::default()
13689 };
13690
13691 let report = doctor_route_report(&config);
13692 let serialized = report.to_string();
13693
13694 assert_eq!(report["provider"], "openai");
13695 assert_eq!(report["provider_source"], "config");
13696 assert_eq!(report["provider_config_table"], "openai");
13697 assert_eq!(report["model"], "deepseek-ai/DeepSeek-V4-Pro");
13698 assert_eq!(report["wire_protocol"], "chat_completions");
13699 assert_eq!(
13700 report["base_url"]["redacted"],
13701 "https://tokenhub.tencentmaas.com"
13702 );
13703 assert_eq!(report["base_url"]["class"], "custom");
13704 assert_eq!(report["auth"]["scheme"], "bearer");
13705 assert_eq!(report["auth"]["source"], "config_declared");
13706 assert!(
13707 report["base_url"]["fingerprint"]
13708 .as_str()
13709 .is_some_and(|value| value.starts_with("<redacted:"))
13710 );
13711 assert!(!serialized.contains("tokenhub-secret-value"));
13712 }
13713
13714 #[test]
13715 fn doctor_route_report_exposes_siliconflow_cn_provider_route() {
13716 let mut providers = crate::config::ProvidersConfig::default();
13717 providers.siliconflow_cn.api_key = Some("sf-cn-secret-value".to_string());
13718 providers.siliconflow_cn.base_url =
13719 Some(crate::config::DEFAULT_SILICONFLOW_CN_BASE_URL.to_string());
13720 providers.siliconflow_cn.model = Some(crate::config::DEFAULT_SILICONFLOW_MODEL.to_string());
13721 let config = Config {
13722 provider: Some("siliconflow-CN".to_string()),
13723 providers: Some(providers),
13724 ..Default::default()
13725 };
13726
13727 let report = doctor_route_report(&config);
13728 let serialized = report.to_string();
13729
13730 assert_eq!(report["provider"], "siliconflow-CN");
13731 assert_eq!(report["provider_config_table"], "siliconflow_cn");
13732 assert_eq!(report["model"], crate::config::DEFAULT_SILICONFLOW_MODEL);
13733 assert_eq!(
13734 report["base_url"]["redacted"],
13735 crate::doctor::structural_url_authority(crate::config::DEFAULT_SILICONFLOW_CN_BASE_URL)
13736 );
13737 assert_eq!(report["base_url"]["class"], "default");
13738 assert_eq!(report["auth"]["scheme"], "bearer");
13739 assert_eq!(report["auth"]["source"], "config_declared");
13740 assert!(!serialized.contains("sf-cn-secret-value"));
13741 }
13742
13743 #[test]
13744 fn doctor_route_report_names_kimi_code_context_provenance() {
13745 let config = Config {
13746 provider: Some("moonshot".to_string()),
13747 providers: Some(crate::config::ProvidersConfig {
13748 moonshot: crate::config::ProviderConfig {
13749 api_key: Some("kimi-plan-secret".to_string()),
13750 base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
13751 model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
13752 ..Default::default()
13753 },
13754 ..Default::default()
13755 }),
13756 ..Default::default()
13757 };
13758
13759 let report = doctor_route_report(&config);
13760 let serialized = report.to_string();
13761
13762 assert_eq!(report["context_window"]["tokens"], 262_144);
13763 assert_eq!(
13764 report["context_window"]["source"],
13765 "static Kimi Code safe floor"
13766 );
13767 assert!(!serialized.contains("kimi-plan-secret"));
13768 }
13769
13770 #[test]
13771 fn provider_capability_report_uses_exact_kimi_code_route_facts() {
13772 let config = Config {
13773 provider: Some("moonshot".to_string()),
13774 providers: Some(crate::config::ProvidersConfig {
13775 moonshot: crate::config::ProviderConfig {
13776 api_key: Some("kimi-plan-secret".to_string()),
13777 base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
13778 model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
13779 ..Default::default()
13780 },
13781 ..Default::default()
13782 }),
13783 ..Default::default()
13784 };
13785
13786 let report = provider_capability_report(&config);
13787
13788 assert_eq!(report["resolved_model"], crate::config::KIMI_CODE_K3_MODEL);
13789 assert_eq!(report["context_window"], 262_144);
13790 assert_eq!(
13791 report["context_window_source"],
13792 "static Kimi Code safe floor"
13793 );
13794 assert_eq!(report["thinking_supported"], true);
13795 }
13796
13797 #[test]
13798 fn provider_capability_report_honors_kimi_code_context_override() {
13799 let config = Config {
13800 provider: Some("moonshot".to_string()),
13801 providers: Some(crate::config::ProvidersConfig {
13802 moonshot: crate::config::ProviderConfig {
13803 api_key: Some("kimi-plan-secret".to_string()),
13804 base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
13805 model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
13806 context_window: Some(1_048_576),
13807 ..Default::default()
13808 },
13809 ..Default::default()
13810 }),
13811 ..Default::default()
13812 };
13813
13814 let report = provider_capability_report(&config);
13815
13816 assert_eq!(
13817 report["resolved_model"],
13818 crate::config::KIMI_CODE_K3_MODEL,
13819 "the configured window must preserve Kimi Code's bare wire id"
13820 );
13821 assert_eq!(report["context_window"], 1_048_576);
13822 assert_eq!(report["context_window_source"], "configured");
13823 assert_eq!(report["thinking_supported"], true);
13824 }
13825
13826 #[test]
13827 fn provider_capability_report_uses_direct_moonshot_k3_route_facts() {
13828 let config = Config {
13829 provider: Some("moonshot".to_string()),
13830 providers: Some(crate::config::ProvidersConfig {
13831 moonshot: crate::config::ProviderConfig {
13832 api_key: Some("moonshot-secret".to_string()),
13833 base_url: Some(crate::config::DEFAULT_MOONSHOT_BASE_URL.to_string()),
13834 model: Some("kimi-k3".to_string()),
13835 ..Default::default()
13836 },
13837 ..Default::default()
13838 }),
13839 ..Default::default()
13840 };
13841
13842 let report = provider_capability_report(&config);
13843
13844 assert_eq!(report["resolved_model"], "kimi-k3");
13845 assert_eq!(report["context_window"], 1_048_576);
13846 assert_eq!(report["context_window_source"], "catalog");
13847 assert_eq!(report["max_output"], 1_048_576);
13848 assert_eq!(report["thinking_supported"], true);
13849 }
13850
13851 #[test]
13852 fn doctor_search_provider_line_includes_firecrawl_default_source_and_switch_hint() {
13853 let _guard = crate::test_support::lock_test_env();
13854 let prev = std::env::var_os("DEEPSEEK_SEARCH_PROVIDER");
13855 unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") };
13856
13857 let line = doctor_search_provider_line(&Config::default());
13858
13859 match prev {
13860 Some(value) => unsafe { std::env::set_var("DEEPSEEK_SEARCH_PROVIDER", value) },
13861 None => unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") },
13862 }
13863 assert!(line.contains("search_provider: firecrawl"));
13864 assert!(line.contains("source: default"));
13865 assert!(line.contains("[search] provider"));
13866 assert!(line.contains("provider = \"baidu\""));
13867 }
13868
13869 #[test]
13870 fn doctor_search_provider_json_reports_config_source() {
13871 let _guard = crate::test_support::lock_test_env();
13872 let prev = std::env::var_os("DEEPSEEK_SEARCH_PROVIDER");
13873 unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") };
13874 let config = Config {
13875 search: Some(crate::config::SearchConfig {
13876 provider: Some(crate::config::SearchProvider::DuckDuckGo),
13877 base_url: None,
13878 api_key: None,
13879 }),
13880 ..Default::default()
13881 };
13882
13883 let report = doctor_search_provider_json(&config);
13884
13885 match prev {
13886 Some(value) => unsafe { std::env::set_var("DEEPSEEK_SEARCH_PROVIDER", value) },
13887 None => unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") },
13888 }
13889 assert_eq!(report["provider"], "duckduckgo");
13890 assert_eq!(report["source"], "config");
13891 }
13892
13893 #[test]
13894 fn doctor_search_provider_json_reports_env_override_source() {
13895 let _guard = crate::test_support::lock_test_env();
13896 let prev = std::env::var_os("DEEPSEEK_SEARCH_PROVIDER");
13897 unsafe { std::env::set_var("DEEPSEEK_SEARCH_PROVIDER", "tavily") };
13898
13899 let report = doctor_search_provider_json(&Config::default());
13900
13901 match prev {
13902 Some(value) => unsafe { std::env::set_var("DEEPSEEK_SEARCH_PROVIDER", value) },
13903 None => unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") },
13904 }
13905 assert_eq!(report["provider"], "tavily");
13906 assert_eq!(report["source"], "env override");
13907 }
13908
13909 #[test]
13910 fn doctor_search_provider_line_omits_switch_hint_when_bing_is_configured() {
13911 let _guard = crate::test_support::lock_test_env();
13912 let prev = std::env::var_os("DEEPSEEK_SEARCH_PROVIDER");
13913 unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") };
13914 let config = Config {
13915 search: Some(crate::config::SearchConfig {
13916 provider: Some(crate::config::SearchProvider::Bing),
13917 base_url: None,
13918 api_key: None,
13919 }),
13920 ..Default::default()
13921 };
13922
13923 let line = doctor_search_provider_line(&config);
13924
13925 match prev {
13926 Some(value) => unsafe { std::env::set_var("DEEPSEEK_SEARCH_PROVIDER", value) },
13927 None => unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") },
13928 }
13929 assert!(line.contains("search_provider: bing"));
13930 assert!(line.contains("source: config"));
13931 assert!(!line.contains("[search] provider"));
13932 }
13933
13934 #[test]
13935 fn timeout_recovery_keeps_default_deepseek_users_on_default_endpoint() {
13936 let config = Config::default();
13937
13938 let text = doctor_timeout_recovery_lines(&config).join("\n");
13939
13940 assert!(text.contains("api.deepseek.com"));
13941 assert!(text.contains("custom DeepSeek-compatible endpoint"));
13942 assert!(!text.contains("provider = \"deepseek-cn\""));
13943 assert!(text.contains("codewhale doctor --json"));
13944 }
13945
13946 #[test]
13947 fn timeout_recovery_for_custom_provider_checks_openai_compatibility() {
13948 let config = Config {
13949 provider: Some("vllm".to_string()),
13950 ..Default::default()
13951 };
13952
13953 let text = doctor_timeout_recovery_lines(&config).join("\n");
13954
13955 assert!(text.contains("/v1/models"));
13956 assert!(text.contains("/v1/chat/completions"));
13957 assert!(!text.contains("api.deepseeki.com"));
13958 }
13959}
13960
13961#[cfg(test)]
13962mod terminal_mode_tests {
13963 use super::*;
13964 use clap::Parser;
13965
13966 fn parse_cli(args: &[&str]) -> Cli {
13967 Cli::try_parse_from(args).expect("CLI args should parse")
13968 }
13969
13970 #[test]
13971 fn headless_consultant_authority_overrides_network_allow_and_disables_web_search() {
13972 let config = Config {
13973 network: Some(crate::config::NetworkPolicyToml {
13974 default: "allow".to_string(),
13975 audit: false,
13976 ..crate::config::NetworkPolicyToml::default()
13977 }),
13978 ..Config::default()
13979 };
13980 let authority = crate::tools::spec::ToolAuthorityEnvelope {
13981 schema_version: 1,
13982 owner: "consultant-1".to_string(),
13983 authority: crate::tools::spec::ToolMutationAuthority::ReadOnly,
13984 network_access: Some(false),
13985 shell: crate::tools::spec::ToolShellAuthority::None,
13986 verification: crate::tools::spec::ToolVerificationAuthority::None,
13987 writable_roots: Vec::new(),
13988 writable_files: Vec::new(),
13989 coordination_contracts: Vec::new(),
13990 }
13991 .normalized()
13992 .expect("Consultant authority");
13993
13994 let policy = exec_network_policy(&config, authority.network_access)
13995 .expect("explicit network=false always installs a policy");
13996 assert_eq!(
13997 policy.evaluate("example.com", "web_search"),
13998 crate::network_policy::Decision::Deny,
13999 "the permissive user config must not widen Consultant network authority"
14000 );
14001 let mut features = crate::features::Features::default();
14002 features.enable(crate::features::Feature::ShellTool);
14003 features.enable(crate::features::Feature::WebSearch);
14004 apply_fleet_engine_feature_caps(
14005 &mut features,
14006 true,
14007 authority.network_access,
14008 authority.shell,
14009 );
14010 assert!(!features.enabled(crate::features::Feature::WebSearch));
14011 assert!(!features.enabled(crate::features::Feature::ShellTool));
14012
14013 let worker_policy = exec_network_policy(&config, Some(true)).expect("configured policy");
14014 assert_eq!(
14015 worker_policy.evaluate("example.com", "web_search"),
14016 crate::network_policy::Decision::Allow,
14017 "a network-capable role keeps the configured policy"
14018 );
14019 }
14020 #[test]
14021 fn hidden_remote_control_flag_starts_the_interactive_handoff() {
14022 let cli = parse_cli(&["codewhale-tui", "--remote-control"]);
14023 assert!(cli.remote_control);
14024 }
14025
14026 #[test]
14027 fn plugin_registry_discovery_is_route_independent_and_read_only() {
14028 let _env_lock = crate::test_support::lock_test_env();
14029 let temp = tempfile::tempdir().unwrap();
14030 let workspace = temp.path().join("workspace");
14031 let codewhale_home = temp.path().join("home");
14032 std::fs::create_dir_all(&workspace).unwrap();
14033 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
14034 let workspace_arg = workspace.to_string_lossy().into_owned();
14035
14036 for route in [
14037 Vec::<&str>::new(),
14038 vec!["resume", "--last"],
14039 vec!["fork", "--last"],
14040 vec!["exec", "hello"],
14041 vec!["serve", "--mcp"],
14042 ] {
14043 let mut args = vec![
14044 "codewhale-tui".to_string(),
14045 "--workspace".to_string(),
14046 workspace_arg.clone(),
14047 ];
14048 args.extend(route.into_iter().map(str::to_string));
14049 let cli = Cli::try_parse_from(args).expect("route should parse");
14050 let discovery = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv();
14051 let registry = discovery
14052 .registry_for_workspace(cli.workspace.as_deref().unwrap_or(workspace.as_path()));
14053 assert_eq!(registry.workspace(), workspace.as_path());
14054 assert!(
14055 !codewhale_home.join("plugins/state.json").exists(),
14056 "startup discovery must remain read-only"
14057 );
14058 }
14059 }
14060
14061 fn custom_exec_config(active: &str) -> Config {
14062 let mut custom = std::collections::HashMap::new();
14063 for (name, base_url, model) in [
14064 (
14065 "custom-a",
14066 "http://127.0.0.1:18181/v1",
14067 crate::config::ZAI_GLM_5_2_MODEL,
14068 ),
14069 ("custom-b", "http://127.0.0.1:18182/v1", "model-b"),
14070 ] {
14071 custom.insert(
14072 name.to_string(),
14073 crate::config::ProviderConfig {
14074 kind: Some("openai-compatible".to_string()),
14075 base_url: Some(base_url.to_string()),
14076 model: Some(model.to_string()),
14077 api_key: Some("local-test-key".to_string()),
14078 ..Default::default()
14079 },
14080 );
14081 }
14082 Config {
14083 provider: Some(active.to_string()),
14084 providers: Some(crate::config::ProvidersConfig {
14085 custom,
14086 ..Default::default()
14087 }),
14088 ..Default::default()
14089 }
14090 }
14091
14092 #[test]
14093 fn doctor_json_surfaces_keep_exact_named_custom_provider() {
14094 let config = custom_exec_config("custom-a");
14095 let workspace = tempfile::tempdir().expect("doctor workspace");
14096
14097 let operate = doctor_operate_fleet_report_json(&config, workspace.path());
14098 let provider_model = doctor_provider_model_report_json(&config);
14099 let capability = provider_capability_report(&config);
14100 let route = doctor_route_report(&config);
14101
14102 assert_eq!(operate["provider"]["id"], "custom-a");
14103 assert_eq!(provider_model["provider"]["id"], "custom-a");
14104 assert_eq!(capability["resolved_provider"], "custom-a");
14105 assert_eq!(route["provider"], "custom-a");
14106 assert_eq!(route["provider_config_table"], "providers.custom-a");
14107 let serialized = serde_json::to_string(&serde_json::json!({
14108 "operate": operate,
14109 "provider_model": provider_model,
14110 "capability": capability,
14111 "route": route,
14112 }))
14113 .expect("doctor JSON");
14114 assert!(!serialized.contains("local-test-key"));
14115 }
14116
14117 #[test]
14118 fn doctor_operate_fleet_json_lists_multi_layer_profile_paths() {
14119 let _env_lock = crate::test_support::lock_test_env();
14122 let tmp = tempfile::TempDir::new().expect("tempdir");
14123 let home = tmp.path().join("home");
14124 let workspace = tmp.path().join("workspace");
14125 let personal = home.join("agents");
14126 let project = workspace.join(".codewhale").join("agents");
14127 std::fs::create_dir_all(&personal).expect("personal agents");
14128 std::fs::create_dir_all(&project).expect("project agents");
14129 std::fs::write(
14130 personal.join("builder.toml"),
14131 "id = \"builder\"\nrole_hint = \"builder\"\nmodel = \"deepseek-v4-flash\"\n",
14132 )
14133 .expect("personal builder");
14134 std::fs::write(
14135 project.join("builder.toml"),
14136 "id = \"builder\"\nrole_hint = \"builder\"\nmodel = \"deepseek-v4-pro\"\n",
14137 )
14138 .expect("project builder");
14139 let _codewhale_home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
14140
14141 let operate = doctor_operate_fleet_report_json(&Config::default(), &workspace);
14142 let layers = operate["roster"]["multi_layer"]
14143 .as_array()
14144 .expect("multi_layer array");
14145 let builder = layers
14146 .iter()
14147 .find(|entry| entry["id"] == "builder")
14148 .expect("builder multi-layer entry");
14149 assert_eq!(builder["effective"], "project");
14150 let paths: Vec<&str> = builder["layers"]
14151 .as_array()
14152 .expect("layers")
14153 .iter()
14154 .filter_map(|layer| layer["path"].as_str())
14155 .collect();
14156 assert!(
14157 paths.iter().any(|path| path.ends_with("builder.toml")),
14158 "layer paths include the profile files: {builder}"
14159 );
14160 assert!(
14161 builder["layers"]
14162 .as_array()
14163 .expect("layers")
14164 .iter()
14165 .any(|layer| layer["origin"] == "personal" && layer["wins"] == false),
14166 "personal layer is listed as ignored: {builder}"
14167 );
14168 assert!(
14169 builder["layers"]
14170 .as_array()
14171 .expect("layers")
14172 .iter()
14173 .any(|layer| layer["origin"] == "project" && layer["wins"] == true),
14174 "project layer wins: {builder}"
14175 );
14176 }
14177
14178 fn saved_exec_session(provider: &str, model: &str) -> session_manager::SavedSession {
14179 let mut saved = session_manager::create_saved_session_with_mode(
14180 &[],
14181 model,
14182 Path::new("/tmp/exec-resume"),
14183 0,
14184 None,
14185 Some("exec"),
14186 );
14187 let kind = crate::config::ApiProvider::parse(provider)
14188 .unwrap_or(crate::config::ApiProvider::Custom)
14189 .as_str();
14190 let exact_id = (!provider
14191 .eq_ignore_ascii_case(crate::config::ApiProvider::Custom.as_str()))
14192 .then_some(provider);
14193 saved.metadata.set_model_provider_route(kind, exact_id);
14194 saved
14195 }
14196
14197 #[test]
14198 fn prompt_flag_accepts_split_prompt_words_for_windows_cmd_shims() {
14199 let cli = parse_cli(&["codewhale", "-p", "hello", "world"]);
14200
14201 assert_eq!(cli.prompt, vec!["hello", "world"]);
14202 }
14203
14204 #[test]
14205 fn prompt_flag_starts_interactive_submit_input() {
14206 let cli = parse_cli(&["codewhale", "-p", "read", "the", "project"]);
14207
14208 assert_eq!(
14209 top_level_prompt_initial_input(&cli.prompt),
14210 Some(tui::InitialInput::Submit("read the project".to_string()))
14211 );
14212 }
14213
14214 #[test]
14215 fn companion_binary_reports_its_own_name() {
14216 assert_eq!(Cli::command().get_name(), "codewhale-tui");
14217 }
14218
14219 #[test]
14220 fn xai_device_auth_subcommand_parses() {
14221 let cli = parse_cli(&["codewhale-tui", "auth", "xai-device"]);
14222 assert!(matches!(
14223 cli.command,
14224 Some(Commands::Auth(TuiAuthArgs {
14225 command: TuiAuthCommand::XaiDevice
14226 }))
14227 ));
14228 }
14229
14230 #[test]
14231 fn workflow_tool_internal_subcommand_parses_exact_json() {
14232 let cli = parse_cli(&[
14233 "codewhale-tui",
14234 "workflow-tool",
14235 "--approval-source",
14236 "explicit-workflow-command",
14237 "--input-json",
14238 r#"{"action":"run","source_path":"workflows/demo.js"}"#,
14239 ]);
14240 let Some(Commands::WorkflowTool(args)) = cli.command else {
14241 panic!("expected workflow-tool command");
14242 };
14243 assert!(args.input_json.contains("\"action\":\"run\""));
14244 }
14245
14246 #[tokio::test]
14247 async fn direct_workflow_tool_runs_without_an_operator_model_turn() {
14248 use crate::tools::spec::ToolSpec;
14249
14250 let workspace = tempfile::tempdir().expect("workspace");
14251 let config = Config {
14252 provider: Some("vllm".to_string()),
14253 mcp_config_path: Some(
14254 workspace
14255 .path()
14256 .join("missing-mcp.json")
14257 .display()
14258 .to_string(),
14259 ),
14260 providers: Some(crate::config::ProvidersConfig {
14261 vllm: crate::config::ProviderConfig {
14262 base_url: Some("http://127.0.0.1:9/v1".to_string()),
14263 model: Some("offline-test-model".to_string()),
14264 ..Default::default()
14265 },
14266 ..Default::default()
14267 }),
14268 ..Default::default()
14269 };
14270 let route = CliAutoRoute {
14271 provider: crate::config::ApiProvider::Vllm,
14272 model: "offline-test-model".to_string(),
14273 reasoning_effort: None,
14274 auto_controls_reasoning: false,
14275 auto_model: false,
14276 };
14277 let (event_tx, mut event_rx) = tokio::sync::mpsc::channel(64);
14278 let plugins = Arc::new(crate::plugins::PluginRegistry::empty(workspace.path()));
14279 let (tool, context) =
14280 build_direct_workflow_tool(&config, &route, workspace.path(), event_tx, plugins)
14281 .await
14282 .expect("build direct workflow runtime");
14283
14284 let result = tool
14285 .execute(
14286 serde_json::json!({
14287 "action": "run",
14288 "script": "phase('offline'); return { ok: true };",
14289 "token_budget": 1_000_000
14290 }),
14291 &context,
14292 )
14293 .await
14294 .expect("model-free workflow run");
14295 let payload: serde_json::Value =
14296 serde_json::from_str(&result.content).expect("workflow JSON");
14297
14298 assert_eq!(payload["status"], "completed");
14299 assert_eq!(payload["result"]["ok"], true);
14300 assert_eq!(payload["child_ids"].as_array().map(Vec::len), Some(0));
14301 assert_eq!(
14302 payload["plan_approval"]["decision"],
14303 "approved_explicit_cli_command"
14304 );
14305 assert!(!context.auto_approve);
14306 assert!(!context.trust_mode);
14307 assert_eq!(
14308 context.shell_policy,
14309 crate::worker_profile::ShellPolicy::None
14310 );
14311 assert!(matches!(
14312 context.elevated_sandbox_policy,
14313 Some(crate::sandbox::SandboxPolicy::WorkspaceWrite { .. })
14314 ));
14315 let mut event_types = Vec::new();
14316 while let Ok(event) = event_rx.try_recv() {
14317 if let crate::core::events::Event::WorkflowUi { event, .. } = event
14318 && let Some(kind) = event["type"].as_str()
14319 {
14320 event_types.push(kind.to_string());
14321 }
14322 }
14323 assert!(event_types.iter().any(|kind| kind == "run_started"));
14324 assert!(event_types.iter().any(|kind| kind == "run_completed"));
14325 }
14326
14327 #[tokio::test]
14328 async fn direct_workflow_mcp_pool_applies_network_policy_before_connect() {
14329 let workspace = tempfile::tempdir().expect("workspace");
14330 let mcp_path = workspace.path().join("mcp.json");
14331 std::fs::write(
14332 &mcp_path,
14333 r#"{
14334 "mcpServers": {
14335 "blocked": { "url": "https://blocked.invalid/mcp" }
14336 }
14337 }"#,
14338 )
14339 .expect("write MCP config");
14340 let config = Config {
14341 mcp_config_path: Some(mcp_path.display().to_string()),
14342 ..Default::default()
14343 };
14344 let policy = crate::network_policy::NetworkPolicyDecider::new(
14345 crate::network_policy::NetworkPolicy {
14346 default: crate::network_policy::DecisionToml::Deny,
14347 allow: Vec::new(),
14348 deny: Vec::new(),
14349 proxy: Vec::new(),
14350 proxy_fake_ip_cidrs: Vec::new(),
14351 audit: false,
14352 },
14353 None,
14354 );
14355
14356 let plugins = Arc::new(crate::plugins::PluginRegistry::empty(workspace.path()));
14357 let (_pool, failures) =
14358 initialize_direct_workflow_mcp_pool(&config, workspace.path(), Some(policy), plugins)
14359 .await
14360 .expect("MCP feature enabled");
14361 assert_eq!(failures.len(), 1, "failures={failures:?}");
14362 assert_eq!(failures[0].0, "blocked");
14363 assert!(failures[0].1.contains("blocked by network policy"));
14364 }
14365
14366 #[test]
14367 fn exec_model_resolution_uses_provider_scoped_default() {
14368 let _env_lock = crate::test_support::lock_test_env();
14369 let _codewhale_model = crate::test_support::EnvVarGuard::remove("CODEWHALE_MODEL");
14370 let _deepseek_model = crate::test_support::EnvVarGuard::remove("DEEPSEEK_MODEL");
14371 let config = Config {
14372 provider: Some("openrouter".to_string()),
14373 default_text_model: Some("deepseek/deepseek-v4-pro".to_string()),
14374 providers: Some(crate::config::ProvidersConfig {
14375 openrouter: crate::config::ProviderConfig {
14376 model: Some("arcee-ai/trinity-large-thinking".to_string()),
14377 ..Default::default()
14378 },
14379 ..Default::default()
14380 }),
14381 ..Default::default()
14382 };
14383
14384 assert_eq!(
14385 resolve_exec_model(&config, None),
14386 "arcee-ai/trinity-large-thinking"
14387 );
14388 assert_eq!(
14389 resolve_exec_model(&config, Some("arcee-ai/trinity-large-thinking")),
14390 "arcee-ai/trinity-large-thinking"
14391 );
14392 }
14393
14394 #[test]
14395 fn exec_model_resolution_prefers_codewhale_model_env_override() {
14396 let _env_lock = crate::test_support::lock_test_env();
14397 let _codewhale_model = crate::test_support::EnvVarGuard::set("CODEWHALE_MODEL", " auto ");
14398 let _deepseek_model =
14399 crate::test_support::EnvVarGuard::set("DEEPSEEK_MODEL", "stale-deepseek-model");
14400 let config = Config {
14401 default_text_model: Some("deepseek/deepseek-v4-pro".to_string()),
14402 ..Default::default()
14403 };
14404
14405 assert_eq!(resolve_exec_model(&config, None), "auto");
14406 }
14407
14408 #[test]
14409 fn exec_model_resolution_uses_legacy_deepseek_model_env_override() {
14410 let _env_lock = crate::test_support::lock_test_env();
14411 let _codewhale_model = crate::test_support::EnvVarGuard::remove("CODEWHALE_MODEL");
14412 let _deepseek_model = crate::test_support::EnvVarGuard::set("DEEPSEEK_MODEL", " auto ");
14413 let config = Config {
14414 default_text_model: Some("deepseek/deepseek-v4-pro".to_string()),
14415 ..Default::default()
14416 };
14417
14418 assert_eq!(resolve_exec_model(&config, None), "auto");
14419 }
14420
14421 #[test]
14422 fn exec_model_resolution_uses_provider_safe_default_for_zai() {
14423 let _env_lock = crate::test_support::lock_test_env();
14424 let _codewhale_model = crate::test_support::EnvVarGuard::remove("CODEWHALE_MODEL");
14425 let _deepseek_model = crate::test_support::EnvVarGuard::remove("DEEPSEEK_MODEL");
14426 let config = Config {
14427 provider: Some("zai".to_string()),
14428 default_text_model: Some(crate::config::DEFAULT_TEXT_MODEL.to_string()),
14429 ..Default::default()
14430 };
14431
14432 assert_eq!(
14433 resolve_exec_model(&config, None),
14434 crate::config::DEFAULT_ZAI_MODEL
14435 );
14436 }
14437
14438 #[tokio::test]
14439 #[allow(clippy::await_holding_lock)]
14440 async fn explicit_exec_model_routes_to_unique_authenticated_provider_candidate() {
14441 let _env_lock = crate::test_support::lock_test_env();
14442 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
14443 let _openrouter = crate::test_support::EnvVarGuard::remove("OPENROUTER_API_KEY");
14444 let config = Config {
14445 provider: Some("deepseek".to_string()),
14446 default_text_model: Some(crate::config::DEFAULT_TEXT_MODEL.to_string()),
14447 ..Default::default()
14448 };
14449
14450 let route = resolve_cli_auto_route(&config, crate::config::ZAI_GLM_5_2_MODEL, "pong")
14451 .await
14452 .expect("explicit GLM should route to the configured Z.ai provider");
14453
14454 assert_eq!(route.provider, crate::config::ApiProvider::Zai);
14455 assert_eq!(route.model, crate::config::ZAI_GLM_5_2_MODEL);
14456 assert!(!route.auto_model);
14457 }
14458
14459 #[tokio::test]
14460 #[allow(clippy::await_holding_lock)]
14461 async fn explicit_exec_model_reports_ambiguous_authenticated_provider_candidates() {
14462 let _env_lock = crate::test_support::lock_test_env();
14463 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
14464 let _openrouter = crate::test_support::EnvVarGuard::set("OPENROUTER_API_KEY", "or-key");
14465 let config = Config {
14466 provider: Some("deepseek".to_string()),
14467 default_text_model: Some(crate::config::DEFAULT_TEXT_MODEL.to_string()),
14468 ..Default::default()
14469 };
14470
14471 let err = resolve_cli_auto_route(&config, crate::config::ZAI_GLM_5_2_MODEL, "pong")
14472 .await
14473 .expect_err("ambiguous GLM route should ask for an explicit provider");
14474 let message = err.to_string();
14475
14476 assert!(message.contains("model `GLM-5.2` is available"));
14477 assert!(message.contains("openrouter"));
14478 assert!(message.contains("zai"));
14479 assert!(message.contains("--provider"));
14480 assert!(message.contains("/provider"));
14481 assert!(message.contains("/model"));
14482 assert!(message.contains("/setup"));
14483 }
14484
14485 #[tokio::test]
14486 async fn cli_auto_model_honors_a_fixed_reasoning_preference() {
14487 let config = Config {
14488 provider: Some("vllm".to_string()),
14489 reasoning_effort: Some("low".to_string()),
14490 providers: Some(crate::config::ProvidersConfig {
14491 vllm: crate::config::ProviderConfig {
14492 base_url: Some("http://127.0.0.1:18190/v1".to_string()),
14493 model: Some("local-auto-model".to_string()),
14494 ..Default::default()
14495 },
14496 ..Default::default()
14497 }),
14498 ..Default::default()
14499 };
14500
14501 let route = resolve_cli_auto_route(&config, "auto", "debug a failing test")
14502 .await
14503 .expect("Auto model route");
14504
14505 assert!(route.auto_model);
14506 assert_eq!(
14507 route.reasoning_effort,
14508 Some(crate::tui::app::ReasoningEffort::Low)
14509 );
14510 assert!(
14511 !route.auto_controls_reasoning,
14512 "a fixed saved tier must not be replaced per prompt"
14513 );
14514 }
14515
14516 #[test]
14517 fn cli_route_execution_config_stamps_routed_model_into_provider_slot() {
14518 let mut providers = crate::config::ProvidersConfig::default();
14519 providers.deepseek.model = Some("deepseek-v4-pro".to_string());
14520 let config = Config {
14521 provider: Some("deepseek".to_string()),
14522 providers: Some(providers),
14523 ..Default::default()
14524 };
14525 let route = CliAutoRoute {
14526 provider: crate::config::ApiProvider::Deepseek,
14527 model: "deepseek-v4-flash".to_string(),
14528 reasoning_effort: None,
14529 auto_controls_reasoning: true,
14530 auto_model: true,
14531 };
14532
14533 let execution_config = config_for_cli_route(&config, &route);
14534
14535 assert_eq!(execution_config.default_model(), "deepseek-v4-flash");
14536 assert_eq!(
14537 execution_config
14538 .provider_config_for(crate::config::ApiProvider::Deepseek)
14539 .and_then(|entry| entry.model.as_deref()),
14540 Some("deepseek-v4-flash")
14541 );
14542 }
14543
14544 #[test]
14545 fn cli_route_execution_config_preserves_legacy_literal_custom_root_route() {
14546 let _lock = crate::test_support::lock_test_env();
14547 let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
14548 let _cli_key = crate::test_support::EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
14549 let config = Config {
14550 provider: Some("custom".to_string()),
14551 api_key: Some("legacy-root-key".to_string()),
14552 base_url: Some("http://127.0.0.1:18183/v1".to_string()),
14553 default_text_model: Some("legacy-model".to_string()),
14554 ..Default::default()
14555 };
14556 let route = CliAutoRoute {
14557 provider: crate::config::ApiProvider::Custom,
14558 model: "routed-legacy-model".to_string(),
14559 reasoning_effort: None,
14560 auto_controls_reasoning: false,
14561 auto_model: false,
14562 };
14563
14564 let execution = config_for_cli_route(&config, &route);
14565
14566 assert!(execution.uses_legacy_literal_custom_route());
14567 assert!(
14568 execution
14569 .providers
14570 .as_ref()
14571 .is_none_or(|providers| !providers.custom.contains_key("custom"))
14572 );
14573 assert_eq!(execution.provider.as_deref(), Some("custom"));
14574 assert_eq!(execution.default_model(), "routed-legacy-model");
14575 assert_eq!(execution.deepseek_base_url(), "http://127.0.0.1:18183/v1");
14576 assert_eq!(execution.deepseek_api_key().unwrap(), "legacy-root-key");
14577 for _ in 0..2 {
14578 let identity = execution
14579 .resolve_provider_identity("custom")
14580 .expect("legacy identity remains repeatedly resolvable");
14581 assert_eq!(identity.key, "custom");
14582 }
14583 let client =
14584 crate::client::DeepSeekClient::new(&execution).expect("legacy execution client");
14585 assert_eq!(client.base_url(), "http://127.0.0.1:18183/v1");
14586 }
14587
14588 #[test]
14589 fn exec_accepts_split_prompt_words_for_windows_cmd_shims() {
14590 let cli = parse_cli(&["codewhale", "exec", "hello", "world"]);
14591 let Some(Commands::Exec(args)) = cli.command else {
14592 panic!("expected exec command");
14593 };
14594
14595 assert_eq!(args.prompt, vec!["hello", "world"]);
14596 }
14597
14598 #[test]
14599 fn exec_keeps_model_flag_before_split_prompt_words() {
14600 let cli = parse_cli(&["codewhale", "exec", "--model", "auto", "hello", "world"]);
14601 let Some(Commands::Exec(args)) = cli.command else {
14602 panic!("expected exec command");
14603 };
14604
14605 assert_eq!(args.model.as_deref(), Some("auto"));
14606 assert_eq!(args.prompt, vec!["hello", "world"]);
14607 }
14608
14609 #[test]
14610 fn exec_keeps_flags_before_split_prompt_words() {
14611 let cli = parse_cli(&["codewhale", "exec", "--json", "hello", "world"]);
14612 let Some(Commands::Exec(args)) = cli.command else {
14613 panic!("expected exec command");
14614 };
14615
14616 assert!(args.json);
14617 assert_eq!(args.prompt, vec!["hello", "world"]);
14618 }
14619
14620 #[test]
14621 fn exec_parses_provider_flag_alongside_model() {
14622 let cli = parse_cli(&[
14625 "codewhale",
14626 "exec",
14627 "--provider",
14628 "openrouter",
14629 "--model",
14630 "glm-5.2",
14631 "audit",
14632 ]);
14633 let Some(Commands::Exec(args)) = cli.command else {
14634 panic!("expected exec command");
14635 };
14636
14637 assert_eq!(args.provider.as_deref(), Some("openrouter"));
14638 assert_eq!(args.model.as_deref(), Some("glm-5.2"));
14639 assert_eq!(args.prompt, vec!["audit"]);
14640 assert_eq!(
14643 crate::config::ApiProvider::parse(args.provider.as_deref().unwrap()),
14644 Some(crate::config::ApiProvider::Openrouter)
14645 );
14646 }
14647
14648 #[test]
14649 fn exec_provider_override_accepts_configured_custom_provider() {
14650 let mut custom = std::collections::HashMap::new();
14651 custom.insert(
14652 "lm-studio".to_string(),
14653 crate::config::ProviderConfig {
14654 kind: Some("openai-compatible".to_string()),
14655 base_url: Some("http://127.0.0.1:1234/v1".to_string()),
14656 model: Some("qwen-2.5-7b".to_string()),
14657 api_key: Some("lm-studio".to_string()),
14658 ..Default::default()
14659 },
14660 );
14661 let mut config = Config {
14662 provider: Some("deepseek".to_string()),
14663 providers: Some(crate::config::ProvidersConfig {
14664 custom,
14665 ..Default::default()
14666 }),
14667 ..Default::default()
14668 };
14669
14670 apply_exec_provider_override(&mut config, "lm-studio")
14671 .expect("configured custom provider should be accepted");
14672
14673 assert_eq!(config.provider.as_deref(), Some("lm-studio"));
14674 assert_eq!(config.api_provider(), crate::config::ApiProvider::Custom);
14675 }
14676
14677 #[test]
14678 fn exec_provider_override_prefers_exact_case_colliding_custom_key() {
14679 let mut config = Config {
14680 provider: Some("deepseek".to_string()),
14681 providers: Some(crate::config::ProvidersConfig {
14682 custom: std::collections::HashMap::from([(
14683 "CUSTOM".to_string(),
14684 crate::config::ProviderConfig {
14685 kind: Some("openai-compatible".to_string()),
14686 base_url: Some("http://127.0.0.1:5678/v1".to_string()),
14687 model: Some("case-model".to_string()),
14688 api_key: Some("case-key".to_string()),
14689 ..Default::default()
14690 },
14691 )]),
14692 ..Default::default()
14693 }),
14694 ..Default::default()
14695 };
14696
14697 apply_exec_provider_override(&mut config, "CUSTOM")
14698 .expect("exact case-colliding custom provider");
14699 assert_eq!(config.provider.as_deref(), Some("CUSTOM"));
14700 assert_eq!(config.api_provider(), crate::config::ApiProvider::Custom);
14701 assert_eq!(
14702 config.provider_identity_for(crate::config::ApiProvider::Custom),
14703 "CUSTOM"
14704 );
14705 let route = crate::route_runtime::resolve_runtime_route(
14706 &config,
14707 crate::config::ApiProvider::Custom,
14708 Some("case-model"),
14709 )
14710 .expect("resolve exact case-colliding route")
14711 .validate()
14712 .expect("preflight exact case-colliding route");
14713 assert_eq!(route.identity.key, "CUSTOM");
14714 assert_eq!(route.client.base_url(), "http://127.0.0.1:5678/v1");
14715 }
14716
14717 #[test]
14718 fn exec_provider_override_rejects_unknown_provider() {
14719 let mut config = Config {
14720 provider: Some("deepseek".to_string()),
14721 ..Default::default()
14722 };
14723
14724 let err = apply_exec_provider_override(&mut config, "lm-studio")
14725 .expect_err("unconfigured custom provider should fail closed");
14726 let message = err.to_string();
14727
14728 assert!(message.contains("Unrecognized --provider"));
14729 assert!(message.contains("[providers.<name>] custom provider"));
14730 assert_eq!(config.provider.as_deref(), Some("deepseek"));
14731 }
14732
14733 #[test]
14734 fn exec_resume_route_matrix_preserves_or_overrides_exact_provider_deliberately() {
14735 let saved = saved_exec_session("custom-a", crate::config::ZAI_GLM_5_2_MODEL);
14736
14737 let mut restored = custom_exec_config("custom-b");
14738 let model = resolve_exec_resume_route(&mut restored, &saved, false, None)
14739 .expect("plain resume restores saved route");
14740 assert_eq!(restored.provider.as_deref(), Some("custom-a"));
14741 assert_eq!(model, crate::config::ZAI_GLM_5_2_MODEL);
14742
14743 let mut explicit_provider = custom_exec_config("custom-a");
14744 apply_exec_provider_override(&mut explicit_provider, "custom-b").expect("custom B");
14745 let model = resolve_exec_resume_route(&mut explicit_provider, &saved, true, None)
14746 .expect("explicit provider wins");
14747 assert_eq!(explicit_provider.provider.as_deref(), Some("custom-b"));
14748 assert_eq!(model, "model-b");
14749
14750 let mut explicit_model = custom_exec_config("custom-b");
14751 let model =
14752 resolve_exec_resume_route(&mut explicit_model, &saved, false, Some("override-model"))
14753 .expect("explicit model keeps saved provider");
14754 assert_eq!(explicit_model.provider.as_deref(), Some("custom-a"));
14755 assert_eq!(model, "override-model");
14756
14757 let mut missing = custom_exec_config("custom-b");
14758 missing
14759 .providers
14760 .as_mut()
14761 .expect("providers")
14762 .custom
14763 .remove("custom-a");
14764 let before = missing.provider.clone();
14765 let err = resolve_exec_resume_route(&mut missing, &saved, false, None)
14766 .expect_err("removed saved provider must fail closed");
14767 assert!(err.to_string().contains("will not fall back"), "{err}");
14768 assert_eq!(missing.provider, before);
14769 }
14770
14771 #[test]
14772 fn exec_model_reads_wait_for_foreign_test_env_overrides_to_restore() {
14773 let (started_tx, started_rx) = std::sync::mpsc::channel();
14774 let (tx, rx) = std::sync::mpsc::channel();
14775
14776 let (reader, expected_after_restore) = {
14777 let lock = crate::test_support::lock_test_env();
14778 let expected_after_restore = exec_model_env_override();
14779 let temporary =
14780 crate::test_support::EnvVarGuard::set("CODEWHALE_MODEL", "temporary-model");
14781 let reader = std::thread::spawn(move || {
14782 started_tx.send(()).expect("signal model read start");
14783 tx.send(exec_model_env_override())
14784 .expect("send resolved model override");
14785 });
14786
14787 started_rx
14788 .recv_timeout(std::time::Duration::from_secs(2))
14789 .expect("reader reached model read");
14790 assert!(
14791 rx.recv_timeout(std::time::Duration::from_millis(50))
14792 .is_err(),
14793 "a foreign reader observed another test's temporary model override"
14794 );
14795 drop(temporary);
14796 drop(lock);
14797 (reader, expected_after_restore)
14798 };
14799
14800 let observed = rx
14801 .recv_timeout(std::time::Duration::from_secs(2))
14802 .expect("reader resumed after model override was restored");
14803 reader.join().expect("reader thread");
14804 assert_eq!(observed, expected_after_restore);
14805 }
14806
14807 #[tokio::test]
14808 async fn forced_exec_route_keeps_custom_provider_when_model_matches_builtin_catalog() {
14809 let config = custom_exec_config("custom-a");
14810
14811 let route =
14812 resolve_cli_exec_route(&config, crate::config::ZAI_GLM_5_2_MODEL, "audit", true)
14813 .await
14814 .expect("forced route");
14815 let execution = config_for_cli_route(&config, &route);
14816
14817 assert_eq!(route.provider, crate::config::ApiProvider::Custom);
14818 assert_eq!(route.model, crate::config::ZAI_GLM_5_2_MODEL);
14819 assert_eq!(execution.provider.as_deref(), Some("custom-a"));
14820 }
14821
14822 #[tokio::test]
14823 async fn no_flag_exec_keeps_configured_named_custom_route_for_matching_builtin_model() {
14824 let mut config = custom_exec_config("custom-a");
14825 config
14826 .providers
14827 .as_mut()
14828 .expect("providers")
14829 .custom
14830 .get_mut("custom-a")
14831 .expect("custom A")
14832 .model = Some(crate::config::ZAI_GLM_5_2_MODEL.to_string());
14833 let model = resolve_exec_model(&config, None);
14834 let force = should_force_configured_exec_route(false, None, None);
14835
14836 assert!(force, "configured/default exec route must be authoritative");
14837 assert!(!should_force_configured_exec_route(
14838 false,
14839 None,
14840 Some(crate::config::ZAI_GLM_5_2_MODEL)
14841 ));
14842 assert!(should_force_configured_exec_route(
14843 false,
14844 Some("custom-a"),
14845 Some(crate::config::ZAI_GLM_5_2_MODEL)
14846 ));
14847 assert!(should_force_configured_exec_route(
14848 true,
14849 None,
14850 Some("override-model")
14851 ));
14852
14853 let route = resolve_cli_exec_route(&config, &model, "audit", force)
14854 .await
14855 .expect("no-flag configured route");
14856 let execution = config_for_cli_route(&config, &route);
14857 assert_eq!(route.provider, crate::config::ApiProvider::Custom);
14858 assert_eq!(route.model, crate::config::ZAI_GLM_5_2_MODEL);
14859 assert_eq!(execution.provider.as_deref(), Some("custom-a"));
14860 }
14861
14862 #[tokio::test]
14863 async fn configured_review_default_keeps_named_custom_route_and_exact_receipt() {
14864 let mut config = custom_exec_config("custom-a");
14865 config
14866 .providers
14867 .as_mut()
14868 .expect("providers")
14869 .custom
14870 .get_mut("custom-a")
14871 .expect("custom A")
14872 .model = Some("model-a".to_string());
14873 config.default_text_model = Some("stale-root-deepseek-model".to_string());
14874 let model = resolve_review_model(&config, None);
14875 assert_eq!(model, "model-a");
14876 assert_eq!(
14877 resolve_review_model(&config, Some("explicit-review-model")),
14878 "explicit-review-model"
14879 );
14880
14881 let route = resolve_cli_exec_route(&config, &model, "review diff", true)
14882 .await
14883 .expect("configured review route");
14884 let execution = config_for_cli_route(&config, &route);
14885 let provider = execution.provider_identity_for(route.provider);
14886
14887 assert_eq!(route.provider, crate::config::ApiProvider::Custom);
14888 assert_eq!(provider, "custom-a");
14889 assert_eq!(execution.deepseek_base_url(), "http://127.0.0.1:18181/v1");
14890 let output = crate::tools::review::ReviewOutput::from_str("{}");
14891 let receipt = crate::tools::review::build_review_receipt(
14892 "working tree",
14893 "diff --git a/a b/a",
14894 provider,
14895 &route.model,
14896 &output,
14897 "{}",
14898 Vec::new(),
14899 );
14900 assert_eq!(receipt.provider, "custom-a");
14901 let serialized = serde_json::to_string(&receipt).expect("review receipt");
14902 assert!(!serialized.contains("127.0.0.1"));
14903 assert!(!serialized.contains("local-test-key"));
14904 }
14905
14906 #[tokio::test]
14907 async fn configured_workflow_default_keeps_named_custom_route() {
14908 let config = custom_exec_config("custom-a");
14909 let model = config.default_model();
14910
14911 let route = resolve_cli_exec_route(
14912 &config,
14913 &model,
14914 "Run a checked-in Workflow through the host runtime",
14915 true,
14916 )
14917 .await
14918 .expect("configured workflow route");
14919 let execution = config_for_cli_route(&config, &route);
14920
14921 assert_eq!(route.provider, crate::config::ApiProvider::Custom);
14922 assert_eq!(execution.provider_identity_for(route.provider), "custom-a");
14923 assert_eq!(execution.deepseek_base_url(), "http://127.0.0.1:18181/v1");
14924 let client = crate::client::DeepSeekClient::new(&execution).expect("workflow client");
14925 assert_eq!(client.base_url(), "http://127.0.0.1:18181/v1");
14926 }
14927
14928 #[test]
14929 fn exec_json_receipts_keep_exact_named_custom_provider() {
14930 let config = custom_exec_config("custom-a");
14931 let provider = config.provider_identity_for(crate::config::ApiProvider::Custom);
14932 let one_shot = one_shot_exec_json_receipt(
14933 provider.clone(),
14934 "model-a".to_string(),
14935 "done".to_string(),
14936 Some("end_turn".to_string()),
14937 crate::models::Usage {
14938 input_tokens: 12,
14939 output_tokens: 3,
14940 ..Default::default()
14941 },
14942 );
14943 assert_eq!(one_shot["provider"], "custom-a");
14944 assert_eq!(one_shot["success"], true);
14945
14946 let truncated = one_shot_exec_json_receipt(
14947 provider.clone(),
14948 "model-a".to_string(),
14949 "partial".to_string(),
14950 Some("max_output_tokens".to_string()),
14951 crate::models::Usage {
14952 input_tokens: 20,
14953 output_tokens: 9,
14954 ..Default::default()
14955 },
14956 );
14957 assert_eq!(truncated["success"], false);
14958 assert_eq!(truncated["stop_reason"], "max_output_tokens");
14959 assert_eq!(truncated["usage"]["input_tokens"], 20);
14960 assert_eq!(truncated["usage"]["output_tokens"], 9);
14961 assert!(truncated["error"].as_str().is_some_and(|error| {
14962 error.contains("Model response incomplete") && error.contains("max_output_tokens")
14963 }));
14964
14965 let agent = serde_json::to_value(ExecSummary {
14966 mode: "agent".to_string(),
14967 provider,
14968 model: "model-a".to_string(),
14969 ..ExecSummary::default()
14970 })
14971 .expect("agent exec JSON receipt");
14972 assert_eq!(agent["provider"], "custom-a");
14973 let serialized = serde_json::to_string(&agent).expect("serialize receipt");
14974 assert!(!serialized.contains("127.0.0.1"));
14975 assert!(!serialized.contains("local-test-key"));
14976 }
14977
14978 #[test]
14979 fn exec_stream_provider_pair_preserves_named_literal_and_root_custom_provenance() {
14980 let named = crate::config::ProviderIdentity {
14981 provider: crate::config::ApiProvider::Custom,
14982 key: "lm-studio".to_string(),
14983 exact_id: Some("lm-studio".to_string()),
14984 migrated_legacy_ollama_cloud_route: false,
14985 };
14986 let literal = crate::config::ProviderIdentity {
14987 provider: crate::config::ApiProvider::Custom,
14988 key: "custom".to_string(),
14989 exact_id: Some("custom".to_string()),
14990 migrated_legacy_ollama_cloud_route: false,
14991 };
14992 let root = crate::config::ProviderIdentity {
14993 provider: crate::config::ApiProvider::Custom,
14994 key: "custom".to_string(),
14995 exact_id: None,
14996 migrated_legacy_ollama_cloud_route: false,
14997 };
14998 let built_in = crate::config::ProviderIdentity {
14999 provider: crate::config::ApiProvider::Deepseek,
15000 key: "deepseek".to_string(),
15001 exact_id: Some("deepseek".to_string()),
15002 migrated_legacy_ollama_cloud_route: false,
15003 };
15004
15005 assert_eq!(
15006 exec_stream_provider_route(&named),
15007 ("custom".to_string(), Some("lm-studio".to_string()))
15008 );
15009 assert_eq!(
15010 exec_stream_provider_route(&literal),
15011 ("custom".to_string(), Some("custom".to_string()))
15012 );
15013 assert_eq!(
15014 exec_stream_provider_route(&root),
15015 ("custom".to_string(), None)
15016 );
15017 assert_eq!(
15018 exec_stream_provider_route(&built_in),
15019 ("deepseek".to_string(), None)
15020 );
15021 }
15022
15023 #[test]
15024 fn resumed_exec_persistence_updates_provider_and_model_as_one_route() {
15025 let saved_a = saved_exec_session("custom-a", crate::config::ZAI_GLM_5_2_MODEL);
15026 let mut config = custom_exec_config("custom-a");
15027 apply_exec_provider_override(&mut config, "custom-b").expect("custom B");
15028 let model = resolve_exec_resume_route(&mut config, &saved_a, true, None)
15029 .expect("explicit provider route");
15030 let mut persisted = saved_a;
15031 stamp_exec_session_metadata(
15032 &mut persisted,
15033 &model,
15034 crate::config::ApiProvider::Custom.as_str(),
15035 Some("custom-b"),
15036 Path::new("/tmp/exec-resume"),
15037 );
15038
15039 let mut next_config = custom_exec_config("custom-a");
15040 let resumed_model = resolve_exec_resume_route(&mut next_config, &persisted, false, None)
15041 .expect("next plain resume");
15042
15043 assert_eq!(persisted.metadata.model_provider, "custom");
15044 assert_eq!(
15045 persisted.metadata.model_provider_id.as_deref(),
15046 Some("custom-b")
15047 );
15048 assert_eq!(persisted.metadata.model, "model-b");
15049 assert_eq!(next_config.provider.as_deref(), Some("custom-b"));
15050 assert_eq!(resumed_model, "model-b");
15051 }
15052
15053 #[test]
15054 fn exec_persistence_omits_id_for_legacy_root_custom_route() {
15055 let mut saved = session_manager::create_saved_session_with_mode(
15056 &[],
15057 "legacy-root-model",
15058 Path::new("/tmp/exec-root"),
15059 0,
15060 None,
15061 Some("exec"),
15062 );
15063 stamp_exec_session_metadata(
15064 &mut saved,
15065 "legacy-root-model",
15066 crate::config::ApiProvider::Custom.as_str(),
15067 None,
15068 Path::new("/tmp/exec-root"),
15069 );
15070
15071 assert_eq!(saved.metadata.model_provider, "custom");
15072 assert_eq!(saved.metadata.model_provider_id, None);
15073 assert!(
15074 !serde_json::to_string(&saved)
15075 .expect("serialize exec session")
15076 .contains("model_provider_id")
15077 );
15078 }
15079
15080 #[test]
15081 fn exec_parses_reasoning_effort_flag_alongside_provider() {
15082 let cli = parse_cli(&[
15083 "codewhale",
15084 "exec",
15085 "--provider",
15086 "openrouter",
15087 "--model",
15088 "glm-5.2",
15089 "--reasoning-effort",
15090 "max",
15091 "audit",
15092 ]);
15093 let Some(Commands::Exec(args)) = cli.command else {
15094 panic!("expected exec command");
15095 };
15096
15097 assert_eq!(args.provider.as_deref(), Some("openrouter"));
15098 assert_eq!(args.model.as_deref(), Some("glm-5.2"));
15099 assert_eq!(args.reasoning_effort.as_deref(), Some("max"));
15100 assert_eq!(args.prompt, vec!["audit"]);
15101 }
15102
15103 #[test]
15104 fn cli_reasoning_effort_normalizes_aliases_and_rejects_typos() {
15105 assert_eq!(
15108 normalize_cli_reasoning_effort("xhigh").unwrap().as_deref(),
15109 Some("xhigh")
15110 );
15111 assert_eq!(
15112 normalize_cli_reasoning_effort("ultracode")
15113 .unwrap()
15114 .as_deref(),
15115 Some("ultra")
15116 );
15117 assert_eq!(normalize_cli_reasoning_effort("default").unwrap(), None);
15118 assert!(normalize_cli_reasoning_effort("expensive").is_err());
15119 }
15120
15121 #[test]
15122 fn cli_prompt_paths_resolve_auto_before_k3_route_normalization() {
15123 let config = Config {
15124 provider: Some("moonshot".to_string()),
15125 providers: Some(crate::config::ProvidersConfig {
15126 moonshot: crate::config::ProviderConfig {
15127 base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
15128 model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
15129 ..Default::default()
15130 },
15131 ..Default::default()
15132 }),
15133 ..Default::default()
15134 };
15135
15136 for (prompt, expected) in [
15137 ("lookup the public docs", "low"),
15138 ("debug this error", "max"),
15139 ("review this ordinary change", "high"),
15140 ] {
15141 assert_eq!(
15142 cli_reasoning_effort_value_for_prompt(
15143 &config,
15144 crate::config::KIMI_CODE_K3_MODEL,
15145 crate::tui::app::ReasoningEffort::Auto,
15146 prompt,
15147 )
15148 .as_deref(),
15149 Some(expected),
15150 "prompt selector must resolve Auto for `{prompt}`"
15151 );
15152 }
15153
15154 assert_eq!(
15155 cli_reasoning_effort_value_for_prompt(
15156 &config,
15157 crate::config::KIMI_CODE_K3_MODEL,
15158 crate::tui::app::ReasoningEffort::Off,
15159 "debug must not override an explicit effort",
15160 )
15161 .as_deref(),
15162 Some("low"),
15163 "membership K3 still applies its exact-route always-thinking floor"
15164 );
15165 }
15166
15167 #[test]
15168 fn cli_route_tracks_auto_reasoning_independently_from_auto_model() {
15169 use crate::tui::app::ReasoningEffort;
15170
15171 let fixed_model_auto_reasoning = CliAutoRoute {
15172 provider: crate::config::ApiProvider::Deepseek,
15173 model: crate::config::DEFAULT_TEXT_MODEL.to_string(),
15174 reasoning_effort: Some(ReasoningEffort::Auto),
15175 auto_controls_reasoning: true,
15176 auto_model: false,
15177 };
15178 let auto_model_fixed_reasoning = CliAutoRoute {
15179 provider: crate::config::ApiProvider::OpenaiCodex,
15180 model: crate::config::DEFAULT_OPENAI_CODEX_MODEL.to_string(),
15181 reasoning_effort: Some(ReasoningEffort::High),
15182 auto_controls_reasoning: false,
15183 auto_model: true,
15184 };
15185
15186 assert!(fixed_model_auto_reasoning.auto_controls_reasoning);
15187 assert!(!fixed_model_auto_reasoning.auto_model);
15188 assert!(!auto_model_fixed_reasoning.auto_controls_reasoning);
15189 assert!(auto_model_fixed_reasoning.auto_model);
15190 }
15191
15192 #[test]
15193 fn saved_reasoning_preference_overrides_config_for_non_tui_runtimes() {
15194 let mut config = Config {
15195 reasoning_effort: Some("max".to_string()),
15196 reasoning_effort_inferred_from_legacy_alias: true,
15197 ..Default::default()
15198 };
15199 let settings = crate::settings::Settings {
15200 reasoning_effort: Some("low".to_string()),
15201 ..Default::default()
15202 };
15203
15204 apply_saved_reasoning_preference(&mut config, &settings);
15205
15206 assert_eq!(config.reasoning_effort(), Some("low"));
15207 assert!(config.reasoning_effort_is_explicit());
15208 }
15209
15210 #[test]
15213 fn fixed_model_exec_auto_resolves_to_a_concrete_tier_not_the_auto_sentinel() {
15214 let config = Config {
15215 provider: Some("zai".to_string()),
15216 ..Default::default()
15217 };
15218
15219 let resolved = cli_reasoning_effort_value_for_prompt(
15220 &config,
15221 crate::config::ZAI_GLM_5_2_MODEL,
15222 crate::tui::app::ReasoningEffort::Auto,
15223 "debug this failing integration test",
15224 )
15225 .expect("Auto must resolve to a concrete tier");
15226
15227 assert_ne!(
15228 resolved, "auto",
15229 "the literal auto sentinel must never reach a provider"
15230 );
15231 assert!(
15232 matches!(resolved.as_str(), "off" | "low" | "medium" | "high" | "max"),
15233 "unexpected resolved tier: {resolved}"
15234 );
15235 }
15236
15237 #[test]
15238 fn exec_accepts_resume_session_flags_for_harnesses() {
15239 let cli = parse_cli(&[
15240 "codewhale",
15241 "exec",
15242 "--resume",
15243 "abc123",
15244 "--output-format",
15245 "stream-json",
15246 "follow up",
15247 ]);
15248 let Some(Commands::Exec(args)) = cli.command else {
15249 panic!("expected exec command");
15250 };
15251
15252 assert_eq!(args.resume.as_deref(), Some("abc123"));
15253 assert_eq!(args.output_format, ExecOutputFormat::StreamJson);
15254 assert_eq!(args.prompt, vec!["follow up"]);
15255 }
15256
15257 #[test]
15258 fn exec_accepts_session_id_alias() {
15259 let cli = parse_cli(&["codewhale", "exec", "--session-id", "abc123", "follow up"]);
15260 let Some(Commands::Exec(args)) = cli.command else {
15261 panic!("expected exec command");
15262 };
15263
15264 assert_eq!(args.session_id.as_deref(), Some("abc123"));
15265 assert_eq!(args.output_format, ExecOutputFormat::Text);
15266 }
15267
15268 #[test]
15269 fn exec_parses_tool_gate_and_hardening_flags() {
15270 let envelope = r#"{"schema_version":1,"owner":"fleet-worker-1","authority":"read_only"}"#;
15271 let cli = parse_cli(&[
15272 "codewhale",
15273 "exec",
15274 "--allowed-tools",
15275 "File,Git",
15276 "--disallowed-tools",
15277 "Bash",
15278 "--max-turns",
15279 "7",
15280 "--append-system-prompt",
15281 "extra rules",
15282 "--tool-authority-json",
15283 envelope,
15284 "do the thing",
15285 ]);
15286 let Some(Commands::Exec(args)) = cli.command else {
15287 panic!("expected exec command");
15288 };
15289
15290 assert_eq!(
15291 args.allowed_tools.as_deref(),
15292 Some(&["File".to_string(), "Git".to_string()][..])
15293 );
15294 assert_eq!(
15295 args.disallowed_tools.as_deref(),
15296 Some(&["Bash".to_string()][..])
15297 );
15298 assert_eq!(args.max_turns, Some(7));
15299 assert_eq!(args.append_system_prompt.as_deref(), Some("extra rules"));
15300 assert_eq!(args.tool_authority_json.as_deref(), Some(envelope));
15301 assert_eq!(args.prompt, vec!["do the thing"]);
15302 }
15303
15304 #[test]
15305 fn fleet_tool_authority_cannot_cross_an_exec_resume_boundary() {
15306 assert!(validate_exec_tool_authority_resume(None, true).is_ok());
15307 assert!(validate_exec_tool_authority_resume(Some("{}"), false).is_ok());
15308 let error = validate_exec_tool_authority_resume(Some("{}"), true)
15309 .expect_err("authority must remain bound to its fresh Fleet launch")
15310 .to_string();
15311 assert!(error.contains("cannot be combined with exec --resume"));
15312 }
15313
15314 #[test]
15315 fn exec_auto_does_not_authorize_sandbox_elevation() {
15316 let cli = parse_cli(&["codewhale", "exec", "--auto", "run it"]);
15317 let Some(Commands::Exec(args)) = cli.command else {
15318 panic!("expected exec command");
15319 };
15320
15321 assert!(!exec_sandbox_elevation_authorized(
15322 args.allow_sandbox_elevation,
15323 args.sandbox.as_deref()
15324 ));
15325 }
15326
15327 #[test]
15328 fn exec_explicit_sandbox_elevation_opt_ins_authorize_retry() {
15329 let danger = parse_cli(&[
15330 "codewhale",
15331 "exec",
15332 "--auto",
15333 "--sandbox",
15334 "danger-full-access",
15335 "run it",
15336 ]);
15337 let Some(Commands::Exec(args)) = danger.command else {
15338 panic!("expected exec command");
15339 };
15340 assert!(exec_sandbox_elevation_authorized(
15341 args.allow_sandbox_elevation,
15342 args.sandbox.as_deref()
15343 ));
15344
15345 let flag = parse_cli(&[
15346 "codewhale",
15347 "exec",
15348 "--auto",
15349 "--allow-sandbox-elevation",
15350 "run it",
15351 ]);
15352 let Some(Commands::Exec(args)) = flag.command else {
15353 panic!("expected exec command");
15354 };
15355 assert!(exec_sandbox_elevation_authorized(
15356 args.allow_sandbox_elevation,
15357 args.sandbox.as_deref()
15358 ));
15359 }
15360
15361 #[test]
15362 fn exec_sandbox_denial_stream_event_is_typed() {
15363 let event = ExecStreamEvent::SandboxDenied {
15364 tool_id: "call_1".to_string(),
15365 tool_name: "exec_shell".to_string(),
15366 reason: "write blocked".to_string(),
15367 outcome: "approval_required".to_string(),
15368 };
15369 let value: serde_json::Value =
15370 serde_json::from_str(&serde_json::to_string(&event).expect("serializes"))
15371 .expect("valid json");
15372 assert_eq!(value["type"], "sandbox_denied");
15373 assert_eq!(value["outcome"], "approval_required");
15374 }
15375
15376 #[test]
15377 fn exec_help_separates_agent_mode_from_sandbox_elevation() {
15378 let mut cli = Cli::command();
15379 let help = cli
15380 .find_subcommand_mut("exec")
15381 .expect("exec command")
15382 .render_help()
15383 .to_string();
15384 assert!(help.contains("--auto"));
15385 assert!(help.contains("--sandbox"));
15386 assert!(help.contains("--allow-sandbox-elevation"));
15387 assert!(help.contains("does not change the"));
15388 assert!(help.contains("explicitly authorize sandbox elevation"));
15389 }
15390
15391 #[test]
15392 fn exec_shell_only_tool_surface_env_sets_shell_allowlist() {
15393 let _env_lock = crate::test_support::lock_test_env();
15394 let _surface =
15395 crate::test_support::EnvVarGuard::set(CODEWHALE_TOOL_SURFACE_ENV, " shell-only ");
15396
15397 let allowed_tools = resolve_exec_allowed_tools(None, exec_tool_surface_from_env())
15398 .expect("shell-only surface should set an allowlist");
15399
15400 assert_eq!(allowed_tools, vec!["bash".to_string()]);
15401 }
15402
15403 #[test]
15404 fn exec_explicit_allowed_tools_override_shell_only_env() {
15405 let _env_lock = crate::test_support::lock_test_env();
15406 let _surface =
15407 crate::test_support::EnvVarGuard::set(CODEWHALE_TOOL_SURFACE_ENV, "shell-only");
15408 let explicit = vec![" File ".to_string(), "GIT".to_string()];
15409
15410 let allowed_tools =
15411 resolve_exec_allowed_tools(Some(&explicit), exec_tool_surface_from_env())
15412 .expect("explicit allowlist should be preserved");
15413
15414 assert_eq!(allowed_tools, vec!["file".to_string(), "git".to_string()]);
15415 }
15416
15417 #[test]
15418 fn exec_full_tool_surface_env_leaves_allowlist_unset() {
15419 let _env_lock = crate::test_support::lock_test_env();
15420 let _surface = crate::test_support::EnvVarGuard::set(CODEWHALE_TOOL_SURFACE_ENV, "full");
15421
15422 assert_eq!(
15423 resolve_exec_allowed_tools(None, exec_tool_surface_from_env()),
15424 None
15425 );
15426 }
15427
15428 #[test]
15429 fn exec_unknown_tool_surface_env_warns_without_allowlist() {
15430 assert!(should_warn_unknown_exec_tool_surface("shell_onyl"));
15431 assert!(!should_warn_unknown_exec_tool_surface("shell-only"));
15432 assert!(!should_warn_unknown_exec_tool_surface("native-tools"));
15433 assert!(!should_warn_unknown_exec_tool_surface("full"));
15434 assert!(!should_warn_unknown_exec_tool_surface(" "));
15435 assert_eq!(parse_exec_tool_surface("shell_onyl"), None);
15436 }
15437
15438 #[test]
15439 fn exec_rejects_zero_max_turns() {
15440 let err = Cli::try_parse_from(["codewhale", "exec", "--max-turns", "0", "hello"])
15441 .expect_err("max-turns must be >= 1");
15442 assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation);
15443 }
15444
15445 #[test]
15446 fn exec_omits_the_headless_turn_cap_by_default() {
15447 let cli = parse_cli(&["codewhale", "exec", "--auto", "benchmark this"]);
15448 let Some(Commands::Exec(args)) = cli.command else {
15449 panic!("expected exec command");
15450 };
15451
15452 assert_eq!(args.max_turns, None);
15453 assert_eq!(exec_max_steps(args.max_turns), u32::MAX);
15454 assert_eq!(exec_max_steps(Some(7)), 7);
15455 }
15456
15457 #[test]
15458 fn exec_accepts_continue_for_latest_workspace_session() {
15459 let cli = parse_cli(&["codewhale", "exec", "--continue", "follow up"]);
15460 let Some(Commands::Exec(args)) = cli.command else {
15461 panic!("expected exec command");
15462 };
15463
15464 assert!(args.continue_session);
15465 }
15466
15467 #[test]
15468 fn sessions_footer_points_to_resume_subcommand() {
15469 let cli = parse_cli(&["codewhale", "resume", "abc123"]);
15470 let Some(Commands::Resume { session_id, last }) = cli.command else {
15471 panic!("expected resume command");
15472 };
15473
15474 assert_eq!(session_id.as_deref(), Some("abc123"));
15475 assert!(!last);
15476 assert_eq!(sessions_resume_command(), "codewhale resume");
15477 assert!(!sessions_resume_command().contains("--resume"));
15478 }
15479
15480 #[test]
15481 fn plugin_registry_initialization_precedes_dotenv_for_all_launch_paths() {
15482 use std::cell::Cell;
15483
15484 #[derive(Clone, Copy)]
15485 enum Expected {
15486 Plain,
15487 Resume,
15488 Fork,
15489 Exec,
15490 Serve,
15491 }
15492
15493 let cases: &[(&[&str], Expected)] = &[
15494 (&["codewhale"], Expected::Plain),
15495 (&["codewhale", "resume", "--last"], Expected::Resume),
15496 (&["codewhale", "fork", "--last"], Expected::Fork),
15497 (&["codewhale", "exec", "probe"], Expected::Exec),
15498 (&["codewhale", "serve", "--mcp"], Expected::Serve),
15499 ];
15500
15501 for (args, expected) in cases {
15502 let phase = Cell::new(0);
15503 let (_cli, command) = prepare_cli_startup(
15504 parse_cli(args),
15505 || {
15506 assert_eq!(phase.get(), 0, "plugin init order for {args:?}");
15507 phase.set(1);
15508 },
15509 || {
15510 assert_eq!(phase.get(), 1, "dotenv load order for {args:?}");
15511 phase.set(2);
15512 },
15513 );
15514
15515 assert_eq!(phase.get(), 2, "startup phases for {args:?}");
15516 let correct_variant = matches!(
15517 (expected, command.as_ref()),
15518 (Expected::Plain, None)
15519 | (Expected::Resume, Some(Commands::Resume { .. }))
15520 | (Expected::Fork, Some(Commands::Fork { .. }))
15521 | (Expected::Exec, Some(Commands::Exec(_)))
15522 | (Expected::Serve, Some(Commands::Serve(_)))
15523 );
15524 assert!(correct_variant, "unexpected command for {args:?}");
15525 }
15526 }
15527
15528 #[test]
15529 fn workspace_dotenv_loads_only_provider_credentials_and_preserves_shell_values() {
15530 let _lock = crate::test_support::lock_test_env();
15531 let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
15532 let _nvidia = crate::test_support::EnvVarGuard::set("NVIDIA_API_KEY", "shell-key");
15533 let _home = crate::test_support::EnvVarGuard::remove("CODEWHALE_HOME");
15534 let _config = crate::test_support::EnvVarGuard::remove("CODEWHALE_CONFIG_PATH");
15535 let _shell = crate::test_support::EnvVarGuard::remove("DEEPSEEK_ALLOW_SHELL");
15536 let tmp = tempfile::TempDir::new().expect("temp workspace");
15537 let dotenv = tmp.path().join(".env");
15538 std::fs::write(
15539 &dotenv,
15540 "DEEPSEEK_API_KEY=workspace-key\n\
15541 NVIDIA_API_KEY=repo-must-not-override-shell\n\
15542 CODEWHALE_HOME=./attacker-home\n\
15543 CODEWHALE_CONFIG_PATH=./attacker.toml\n\
15544 DEEPSEEK_ALLOW_SHELL=true\n",
15545 )
15546 .expect("write dotenv");
15547
15548 let report = load_workspace_dotenv_credentials_from_path(&dotenv).expect("safe load");
15549
15550 assert_eq!(
15551 std::env::var("DEEPSEEK_API_KEY").as_deref(),
15552 Ok("workspace-key")
15553 );
15554 assert_eq!(std::env::var("NVIDIA_API_KEY").as_deref(), Ok("shell-key"));
15555 assert!(std::env::var_os("CODEWHALE_HOME").is_none());
15556 assert!(std::env::var_os("CODEWHALE_CONFIG_PATH").is_none());
15557 assert!(std::env::var_os("DEEPSEEK_ALLOW_SHELL").is_none());
15558 assert_eq!(
15559 report.loaded,
15560 BTreeSet::from(["DEEPSEEK_API_KEY".to_string()])
15561 );
15562 assert_eq!(
15563 report.ignored,
15564 BTreeSet::from([
15565 "CODEWHALE_CONFIG_PATH".to_string(),
15566 "CODEWHALE_HOME".to_string(),
15567 "DEEPSEEK_ALLOW_SHELL".to_string(),
15568 ])
15569 );
15570 }
15571
15572 #[test]
15573 fn workspace_dotenv_rejects_ambient_variable_substitution() {
15574 let _lock = crate::test_support::lock_test_env();
15575 let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
15576 let _ambient = crate::test_support::EnvVarGuard::set(
15577 "CODEWHALE_JS_SECRET_LEAK_TEST",
15578 "ambient-secret-must-not-expand",
15579 );
15580 let tmp = tempfile::TempDir::new().expect("temp workspace");
15581 let dotenv = tmp.path().join(".env");
15582 std::fs::write(
15583 &dotenv,
15584 "DEEPSEEK_API_KEY=${CODEWHALE_JS_SECRET_LEAK_TEST}\n",
15585 )
15586 .expect("write dotenv");
15587
15588 let error = load_workspace_dotenv_credentials_from_path(&dotenv)
15589 .expect_err("expansion must fail closed")
15590 .to_string();
15591
15592 assert!(error.contains("variable expansion"));
15593 assert!(!error.contains("ambient-secret-must-not-expand"));
15594 assert!(std::env::var_os("DEEPSEEK_API_KEY").is_none());
15595 }
15596
15597 #[test]
15598 fn workspace_dotenv_rejects_multiline_ambient_variable_substitution() {
15599 let _lock = crate::test_support::lock_test_env();
15600 let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
15601 let _ambient = crate::test_support::EnvVarGuard::set(
15602 "CODEWHALE_JS_SECRET_LEAK_TEST",
15603 "ambient-secret-must-not-expand",
15604 );
15605 let tmp = tempfile::TempDir::new().expect("temp workspace");
15606 let dotenv = tmp.path().join(".env");
15607 std::fs::write(
15608 &dotenv,
15609 "DEEPSEEK_API_KEY=\"prefix\n$CODEWHALE_JS_SECRET_LEAK_TEST=bar\nsuffix\"\n",
15610 )
15611 .expect("write dotenv");
15612
15613 let error = load_workspace_dotenv_credentials_from_path(&dotenv)
15614 .expect_err("multiline expansion must fail closed")
15615 .to_string();
15616
15617 assert!(error.contains("variable expansion"));
15618 assert!(!error.contains("ambient-secret-must-not-expand"));
15619 assert!(std::env::var_os("DEEPSEEK_API_KEY").is_none());
15620 }
15621
15622 #[test]
15623 fn workspace_dotenv_comment_quote_cannot_hide_later_expansion() {
15624 let _lock = crate::test_support::lock_test_env();
15625 let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
15626 let _ambient = crate::test_support::EnvVarGuard::set(
15627 "CODEWHALE_JS_SECRET_LEAK_TEST",
15628 "ambient-secret-must-not-expand",
15629 );
15630 let tmp = tempfile::TempDir::new().expect("temp workspace");
15631 let dotenv = tmp.path().join(".env");
15632 std::fs::write(
15633 &dotenv,
15634 "# unmatched quote in ignored comment: '\n\
15635 DEEPSEEK_API_KEY=$CODEWHALE_JS_SECRET_LEAK_TEST\n",
15636 )
15637 .expect("write dotenv");
15638
15639 let error = load_workspace_dotenv_credentials_from_path(&dotenv)
15640 .expect_err("comment quote must not hide expansion")
15641 .to_string();
15642
15643 assert!(error.contains("variable expansion"));
15644 assert!(!error.contains("ambient-secret-must-not-expand"));
15645 assert!(std::env::var_os("DEEPSEEK_API_KEY").is_none());
15646 }
15647
15648 #[test]
15649 fn workspace_dotenv_allows_single_quoted_literal_dollar() {
15650 let _lock = crate::test_support::lock_test_env();
15651 let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
15652 let tmp = tempfile::TempDir::new().expect("temp workspace");
15653 let dotenv = tmp.path().join(".env");
15654 std::fs::write(&dotenv, "DEEPSEEK_API_KEY='$literal-value'\n").expect("write dotenv");
15655
15656 load_workspace_dotenv_credentials_from_path(&dotenv).expect("literal dollar load");
15657
15658 assert_eq!(
15659 std::env::var("DEEPSEEK_API_KEY").as_deref(),
15660 Ok("$literal-value")
15661 );
15662 }
15663
15664 #[test]
15665 fn workspace_dotenv_parse_failure_applies_no_earlier_credentials() {
15666 let _lock = crate::test_support::lock_test_env();
15667 let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
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=must-not-survive\nBROKEN=\"unterminated\n",
15673 )
15674 .expect("write dotenv");
15675
15676 let error = load_workspace_dotenv_credentials_from_path(&dotenv)
15677 .expect_err("parse failure must be transactional")
15678 .to_string();
15679
15680 assert!(error.contains("could not be parsed safely"), "{error}");
15681 assert!(!error.contains("must-not-survive"));
15682 assert!(std::env::var_os("DEEPSEEK_API_KEY").is_none());
15683 }
15684
15685 #[test]
15686 fn workspace_dotenv_credential_allowlist_excludes_control_plane_names() {
15687 for provider in codewhale_config::provider::providers_sorted_for_display() {
15688 for key in provider.env_vars() {
15689 assert!(
15690 is_workspace_dotenv_credential_key(key),
15691 "provider credential {key} must remain supported"
15692 );
15693 }
15694 }
15695 for key in [
15696 "CODEWHALE_HOME",
15697 "CODEWHALE_CONFIG_PATH",
15698 "DEEPSEEK_CONFIG_PATH",
15699 "DEEPSEEK_PROFILE",
15700 "DEEPSEEK_MANAGED_CONFIG_PATH",
15701 "DEEPSEEK_REQUIREMENTS_PATH",
15702 "DEEPSEEK_PROVIDER",
15703 "DEEPSEEK_BASE_URL",
15704 "DEEPSEEK_MODEL",
15705 "DEEPSEEK_APPROVAL_POLICY",
15706 "DEEPSEEK_SANDBOX_MODE",
15707 "DEEPSEEK_ALLOW_SHELL",
15708 "DEEPSEEK_YOLO",
15709 "DEEPSEEK_MCP_CONFIG",
15710 "CODEWHALE_RUNTIME_TOKEN",
15711 "PATH",
15712 "NODE_OPTIONS",
15713 "PYTHONPATH",
15714 "LD_PRELOAD",
15715 "DYLD_INSERT_LIBRARIES",
15716 ] {
15717 assert!(
15718 !is_workspace_dotenv_credential_key(key),
15719 "control-plane variable {key} must not load from a workspace"
15720 );
15721 }
15722 }
15723
15724 #[cfg(unix)]
15725 #[test]
15726 fn workspace_dotenv_does_not_follow_symbolic_links() {
15727 use std::os::unix::fs::symlink;
15728
15729 let tmp = tempfile::TempDir::new().expect("temp workspace");
15730 let external = tmp.path().join("external-credentials");
15731 let dotenv = tmp.path().join(".env");
15732 std::fs::write(&external, "DEEPSEEK_API_KEY=external-secret\n")
15733 .expect("write external fixture");
15734 symlink(&external, &dotenv).expect("create dotenv symlink");
15735
15736 let error = load_workspace_dotenv_credentials_from_path(&dotenv)
15737 .expect_err("symlink must fail closed")
15738 .to_string();
15739
15740 assert!(error.contains("securely open"), "{error}");
15741 assert!(!error.contains("external-secret"));
15742 }
15743
15744 #[cfg(unix)]
15745 #[test]
15746 fn workspace_dotenv_rejects_hard_links_to_external_files() {
15747 let tmp = tempfile::TempDir::new().expect("temp workspace");
15748 let external = tmp.path().join("external-credentials");
15749 let dotenv = tmp.path().join(".env");
15750 std::fs::write(&external, "DEEPSEEK_API_KEY=external-secret\n")
15751 .expect("write external fixture");
15752 std::fs::hard_link(&external, &dotenv).expect("create dotenv hard link");
15753
15754 let error = load_workspace_dotenv_credentials_from_path(&dotenv)
15755 .expect_err("hard link must fail closed")
15756 .to_string();
15757
15758 assert!(error.contains("multiple filesystem links"), "{error}");
15759 assert!(!error.contains("external-secret"));
15760 }
15761
15762 #[cfg(unix)]
15763 #[test]
15764 fn workspace_dotenv_rejects_fifo_without_blocking_startup() {
15765 use std::ffi::CString;
15766 use std::os::unix::ffi::OsStrExt;
15767 use std::sync::mpsc;
15768 use std::time::Duration;
15769
15770 let tmp = tempfile::TempDir::new().expect("temp workspace");
15771 let dotenv = tmp.path().join(".env");
15772 let c_path = CString::new(dotenv.as_os_str().as_bytes()).expect("fifo path");
15773 let result = unsafe { libc::mkfifo(c_path.as_ptr(), libc::S_IRUSR | libc::S_IWUSR) };
15776 assert_eq!(result, 0, "mkfifo failed: {}", io::Error::last_os_error());
15777
15778 let (tx, rx) = mpsc::channel();
15779 let worker_path = dotenv.clone();
15780 let worker = std::thread::spawn(move || {
15781 let result = load_workspace_dotenv_credentials_from_path(&worker_path)
15782 .map(|_| "unexpected success".to_string())
15783 .unwrap_or_else(|error| error.to_string());
15784 tx.send(result).expect("send loader result");
15785 });
15786
15787 let error = match rx.recv_timeout(Duration::from_secs(1)) {
15788 Ok(error) => error,
15789 Err(timeout) => {
15790 let _writer = std::fs::OpenOptions::new()
15793 .write(true)
15794 .open(&dotenv)
15795 .expect("open fifo writer to release blocked reader");
15796 let _ = rx.recv_timeout(Duration::from_secs(1));
15797 worker.join().expect("join released loader");
15798 panic!("workspace .env FIFO blocked startup: {timeout}");
15799 }
15800 };
15801 worker.join().expect("join loader");
15802
15803 assert!(error.contains("not a regular file"), "{error}");
15804 }
15805
15806 #[test]
15807 fn exec_json_conflicts_with_stream_json_output() {
15808 let err = Cli::try_parse_from([
15809 "codewhale",
15810 "exec",
15811 "--json",
15812 "--output-format",
15813 "stream-json",
15814 "hello",
15815 ])
15816 .expect_err("json summary and stream-json must not mix");
15817
15818 assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
15819 }
15820
15821 #[test]
15822 fn exec_stream_turn_usage_event_serializes_reported_fields() {
15823 let event = ExecStreamEvent::TurnUsage {
15824 turn: 2,
15825 input_tokens: 1200,
15826 output_tokens: 180,
15827 reasoning_tokens: Some(90),
15828 prompt_cache_hit_tokens: Some(900),
15829 prompt_cache_miss_tokens: Some(300),
15830 prompt_cache_write_tokens: Some(0),
15831 reasoning_replay_tokens: Some(40),
15832 duration_ms: 1834,
15833 };
15834
15835 let value = exec_stream_value(&event).expect("serializes");
15836 let json = serde_json::to_string(&value).expect("serializes");
15837 assert!(!json.contains('\n'));
15838 let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json");
15839 assert_eq!(parsed["type"], "turn_usage");
15840 assert_eq!(parsed["schema"], "codewhale.exec-stream");
15841 assert_eq!(parsed["schema_version"], 1);
15842 assert_eq!(parsed["turn"], 2);
15843 assert_eq!(parsed["input_tokens"], 1200);
15844 assert_eq!(parsed["output_tokens"], 180);
15845 assert_eq!(parsed["reasoning_tokens"], 90);
15846 assert_eq!(parsed["prompt_cache_hit_tokens"], 900);
15847 assert_eq!(parsed["prompt_cache_miss_tokens"], 300);
15848 assert_eq!(parsed["prompt_cache_write_tokens"], 0);
15849 assert_eq!(parsed["reasoning_replay_tokens"], 40);
15850 assert_eq!(parsed["duration_ms"], 1834);
15851 }
15852
15853 #[test]
15854 fn exec_stream_turn_usage_event_omits_unreported_fields() {
15855 let event = ExecStreamEvent::TurnUsage {
15859 turn: 1,
15860 input_tokens: 11,
15861 output_tokens: 3,
15862 reasoning_tokens: None,
15863 prompt_cache_hit_tokens: None,
15864 prompt_cache_miss_tokens: None,
15865 prompt_cache_write_tokens: None,
15866 reasoning_replay_tokens: None,
15867 duration_ms: 250,
15868 };
15869
15870 let value = exec_stream_value(&event).expect("serializes");
15871 let parsed = value;
15872 assert_eq!(parsed["type"], "turn_usage");
15873 assert_eq!(parsed["input_tokens"], 11);
15874 assert_eq!(parsed["output_tokens"], 3);
15875 assert_eq!(parsed["duration_ms"], 250);
15876 let object = parsed.as_object().expect("event object");
15877 for absent in [
15878 "reasoning_tokens",
15879 "prompt_cache_hit_tokens",
15880 "prompt_cache_miss_tokens",
15881 "prompt_cache_write_tokens",
15882 "reasoning_replay_tokens",
15883 ] {
15884 assert!(!object.contains_key(absent), "{absent} leaked: {parsed}");
15885 }
15886 }
15887
15888 #[test]
15889 fn exec_stream_pre_existing_event_type_tags_are_unchanged() {
15890 let cases: Vec<(ExecStreamEvent, &str)> = vec![
15893 (
15894 ExecStreamEvent::Content {
15895 content: "hi".to_string(),
15896 },
15897 "content",
15898 ),
15899 (
15900 ExecStreamEvent::ToolUse {
15901 name: "read_file".to_string(),
15902 id: "call_1".to_string(),
15903 input: serde_json::json!({}),
15904 started_at: "2026-08-03T00:00:00Z".to_string(),
15905 },
15906 "tool_use",
15907 ),
15908 (
15909 ExecStreamEvent::ToolResult {
15910 id: "call_1".to_string(),
15911 name: "read_file".to_string(),
15912 output: "ok".to_string(),
15913 status: "success".to_string(),
15914 started_at: "2026-08-03T00:00:00Z".to_string(),
15915 completed_at: "2026-08-03T00:00:01Z".to_string(),
15916 duration_ms: 1,
15917 side_effect_status: "unknown".to_string(),
15918 error_category: None,
15919 truncated: None,
15920 artifact: None,
15921 result_metadata: None,
15922 },
15923 "tool_result",
15924 ),
15925 (
15926 ExecStreamEvent::SandboxDenied {
15927 tool_id: "call_1".to_string(),
15928 tool_name: "exec_shell".to_string(),
15929 reason: "denied".to_string(),
15930 outcome: "approval_required".to_string(),
15931 },
15932 "sandbox_denied",
15933 ),
15934 (
15935 ExecStreamEvent::WorkflowEvent {
15936 run_id: "workflow_1".to_string(),
15937 event: serde_json::json!({"type": "task_completed"}),
15938 },
15939 "workflow_event",
15940 ),
15941 (
15942 ExecStreamEvent::SessionCapture {
15943 content: "x".to_string(),
15944 },
15945 "session_capture",
15946 ),
15947 (
15948 ExecStreamEvent::Error {
15949 error: "boom".to_string(),
15950 },
15951 "error",
15952 ),
15953 (ExecStreamEvent::Done, "done"),
15954 ];
15955
15956 for (event, expected_type) in cases {
15957 let value = exec_stream_value(&event).expect("serializes");
15958 assert_eq!(value["type"], expected_type, "event tag drifted");
15959 assert_eq!(value["schema"], "codewhale.exec-stream");
15960 assert_eq!(value["schema_version"], 1);
15961 }
15962 }
15963
15964 #[test]
15965 fn exec_stream_events_are_json_lines() {
15966 let event = ExecStreamEvent::ToolResult {
15967 id: "call_1".to_string(),
15968 name: "read_file".to_string(),
15969 output: "line 1\nline 2".to_string(),
15970 status: "success".to_string(),
15971 started_at: "2026-07-13T00:00:00Z".to_string(),
15972 completed_at: "2026-07-13T00:00:01Z".to_string(),
15973 duration_ms: 1000,
15974 side_effect_status: "not_started".to_string(),
15975 error_category: None,
15976 truncated: Some(false),
15977 artifact: None,
15978 result_metadata: None,
15979 };
15980
15981 let value = exec_stream_value(&event).expect("serializes");
15982 let json = serde_json::to_string(&value).expect("serializes");
15983 assert!(!json.contains('\n'));
15984 let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json");
15985 assert_eq!(parsed["type"], "tool_result");
15986 assert_eq!(parsed["schema"], "codewhale.exec-stream");
15987 assert_eq!(parsed["schema_version"], 1);
15988 assert_eq!(parsed["duration_ms"], 1000);
15989 assert_eq!(parsed["side_effect_status"], "not_started");
15990 }
15991
15992 #[test]
15993 fn workflow_receipt_stream_event_is_one_json_line() {
15994 let event = ExecStreamEvent::WorkflowEvent {
15995 run_id: "workflow_1234".to_string(),
15996 event: serde_json::json!({
15997 "type": "handoff_promoted",
15998 "artifact_id": "workflow_1234:agent_1:review-gate:review_report",
15999 "gate_id": "review-gate",
16000 "kind": "review_report",
16001 "from_role": "reviewer",
16002 "to_role": "verifier",
16003 "producer_task_id": "agent_1"
16004 }),
16005 };
16006
16007 let value = exec_stream_value(&event).expect("serializes");
16008 let json = serde_json::to_string(&value).expect("serializes");
16009 assert!(!json.contains('\n'));
16010 let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json");
16011 assert_eq!(parsed["type"], "workflow_event");
16012 assert_eq!(parsed["schema"], "codewhale.exec-stream");
16013 assert_eq!(parsed["schema_version"], 1);
16014 assert_eq!(parsed["run_id"], "workflow_1234");
16015 assert_eq!(parsed["event"]["type"], "handoff_promoted");
16016 assert_eq!(
16017 parsed["event"]["artifact_id"],
16018 "workflow_1234:agent_1:review-gate:review_report"
16019 );
16020 assert_eq!(parsed["event"]["gate_id"], "review-gate");
16021 assert_eq!(parsed["event"]["kind"], "review_report");
16022 assert_eq!(parsed["event"]["from_role"], "reviewer");
16023 assert_eq!(parsed["event"]["to_role"], "verifier");
16024 assert_eq!(parsed["event"]["producer_task_id"], "agent_1");
16025 assert!(parsed["event"].get("payload").is_none(), "{parsed}");
16026
16027 let consumed = ExecStreamEvent::WorkflowEvent {
16028 run_id: "workflow_1234".to_string(),
16029 event: serde_json::json!({
16030 "type": "handoff_consumed",
16031 "artifact_id": "workflow_1234:agent_1:review-gate:review_report",
16032 "kind": "review_report",
16033 "from_role": "reviewer",
16034 "to_role": "verifier",
16035 "consumer_task_id": "agent_2"
16036 }),
16037 };
16038 let consumed = exec_stream_value(&consumed).expect("serializes consumed receipt");
16039 assert_eq!(consumed["type"], "workflow_event");
16040 assert_eq!(consumed["schema"], "codewhale.exec-stream");
16041 assert_eq!(consumed["schema_version"], 1);
16042 assert_eq!(consumed["event"]["type"], "handoff_consumed");
16043 assert_eq!(
16044 consumed["event"]["artifact_id"],
16045 "workflow_1234:agent_1:review-gate:review_report"
16046 );
16047 assert_eq!(consumed["event"]["consumer_task_id"], "agent_2");
16048 assert!(consumed["event"].get("payload").is_none(), "{consumed}");
16049 }
16050
16051 #[test]
16052 fn exec_stream_metadata_redacts_resume_breadcrumbs() {
16053 let raw_session_id = "abc123fullsecret";
16054 let event = ExecStreamEvent::Metadata {
16055 meta: Box::new(ExecStreamMeta {
16056 receipt_kind: "terminal",
16057 provider: "deepseek".to_string(),
16058 provider_id: None,
16059 model: "deepseek-v4-flash".to_string(),
16060 route_source: "explicit_or_configured".to_string(),
16061 input_tokens: Some(123),
16062 output_tokens: Some(45),
16063 prompt_cache_hit_tokens: Some(10),
16064 prompt_cache_miss_tokens: None,
16065 prompt_cache_write_tokens: None,
16066 reasoning_tokens: Some(3),
16067 codewhale_max_output_tokens: Some(384_000),
16068 codewhale_max_output_tokens_source: Some("documented"),
16069 duration_ms: 2500,
16070 retry_count: None,
16071 approval_posture: "ask".to_string(),
16072 sandbox_posture: "configured_default".to_string(),
16073 binary_sha256: Some("sha256:binary".to_string()),
16074 config_sha256: None,
16075 prompt_sha256: "sha256:prompt".to_string(),
16076 tool_catalog_sha256: Some("sha256:tools".to_string()),
16077 input_analysis: ExecStreamInputAnalysis::default(),
16078 visible_final_answer_chars: 17,
16079 session_id: exec_stream_session_ref(raw_session_id),
16080 resume_command: exec_stream_resume_hint(raw_session_id),
16081 workspace: "/tmp/work".to_string(),
16082 message_count: 4,
16083 status: Some("completed".to_string()),
16084 termination_reason: Some("resolved".to_string()),
16085 error_category: None,
16086 error: None,
16087 }),
16088 };
16089
16090 let json = serde_json::to_string(&event).expect("serializes");
16091 assert!(!json.contains('\n'));
16092 assert!(!json.contains(raw_session_id));
16093 let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json");
16094 assert_eq!(parsed["type"], "metadata");
16095 assert_ne!(parsed["meta"]["session_id"], raw_session_id);
16096 assert!(
16097 parsed["meta"]["session_id"]
16098 .as_str()
16099 .unwrap()
16100 .starts_with("<redacted:")
16101 );
16102 assert_eq!(
16103 parsed["meta"]["resume_command"],
16104 "codewhale exec --resume <redacted-session-id>"
16105 );
16106 assert_eq!(parsed["meta"]["workspace"], "/tmp/work");
16107 assert_eq!(parsed["meta"]["message_count"], 4);
16108 assert_eq!(parsed["meta"]["visible_final_answer_chars"], 17);
16109
16110 let capture = ExecStreamEvent::SessionCapture {
16111 content: exec_stream_session_ref(raw_session_id),
16112 };
16113 let capture_json = serde_json::to_string(&capture).expect("serializes");
16114 assert!(!capture_json.contains(raw_session_id));
16115 let parsed_capture: serde_json::Value =
16116 serde_json::from_str(&capture_json).expect("valid json");
16117 assert_eq!(parsed_capture["type"], "session_capture");
16118 assert_ne!(parsed_capture["content"], raw_session_id);
16119 }
16120
16121 #[test]
16122 fn exec_stream_input_analysis_reports_prompt_composition() {
16123 let system = SystemPrompt::Text("system rules".to_string());
16124 let messages = vec![
16125 Message {
16126 role: "user".to_string(),
16127 content: vec![ContentBlock::Text {
16128 text: "run tests".to_string(),
16129 cache_control: None,
16130 }],
16131 },
16132 Message {
16133 role: "assistant".to_string(),
16134 content: vec![
16135 ContentBlock::thinking("checking context"),
16136 ContentBlock::Text {
16137 text: "working".to_string(),
16138 cache_control: None,
16139 },
16140 ContentBlock::ToolUse {
16141 id: "call-1".to_string(),
16142 name: "exec_shell".to_string(),
16143 input: serde_json::json!({"command": "cargo test"}),
16144 caller: None,
16145 thought_signature: None,
16146 },
16147 ],
16148 },
16149 Message {
16150 role: "user".to_string(),
16151 content: vec![ContentBlock::ToolResult {
16152 tool_use_id: "call-1".to_string(),
16153 content: "stdout line\nstderr line".to_string(),
16154 is_error: Some(false),
16155 content_blocks: Some(vec![serde_json::json!({
16156 "type": "text",
16157 "text": "structured output"
16158 })]),
16159 }],
16160 },
16161 ];
16162
16163 let analysis = exec_stream_input_analysis(&messages, Some(&system));
16164
16165 assert_eq!(analysis.user_message_count, 2);
16166 assert_eq!(analysis.assistant_message_count, 1);
16167 assert_eq!(analysis.tool_message_count, 0);
16168 assert_eq!(analysis.tool_use_count, 1);
16169 assert_eq!(analysis.tool_result_count, 1);
16170 assert_eq!(analysis.thinking_chars, "checking context".chars().count());
16171 assert!(analysis.text_chars >= "run testsworking".chars().count());
16172 assert!(analysis.tool_use_input_chars > 0);
16173 assert!(analysis.tool_result_chars >= "stdout line\nstderr line".chars().count());
16174 assert!(analysis.estimated_system_tokens > 0);
16175 assert!(analysis.estimated_message_content_tokens > 0);
16176 assert!(
16177 analysis.estimated_request_tokens
16178 >= analysis.estimated_system_tokens
16179 + analysis.estimated_message_content_tokens
16180 + analysis.estimated_framing_tokens
16181 );
16182 }
16183
16184 #[test]
16185 fn review_receipt_check_public_json_omits_private_details() {
16186 let validation = crate::tools::review::ReviewReceiptValidation {
16187 passed: false,
16188 reason: "secret reason with /tmp/private/receipt.json".to_string(),
16189 diff_fingerprint: "sha256:current".to_string(),
16190 receipt_fingerprint: Some("sha256:current".to_string()),
16191 receipt_path: Some(PathBuf::from("/tmp/private/receipt.json")),
16192 unresolved_risk: Some(crate::tools::review::ReviewReceiptRisk {
16193 unresolved: true,
16194 level: "error".to_string(),
16195 summary: "secret summary".to_string(),
16196 }),
16197 };
16198
16199 let public = review_receipt_validation_public_json(&validation);
16200 let encoded = serde_json::to_string(&public).expect("public json");
16201
16202 assert_eq!(public["passed"], false);
16203 assert_eq!(public["status"], "unresolved_risk");
16204 assert_eq!(public["risk_level"], "error");
16205 assert!(!encoded.contains("secret"));
16206 assert!(!encoded.contains("/tmp/private"));
16207 }
16208
16209 #[test]
16210 fn exec_text_session_breadcrumbs_use_compact_ids() {
16211 let session_id = "1234567890abcdef";
16212
16213 assert_eq!(exec_saved_session_line(session_id), "session: 12345678");
16214 assert_eq!(
16215 exec_resumed_session_line(session_id),
16216 "resumed session: 12345678"
16217 );
16218 assert!(!exec_saved_session_line(session_id).contains(session_id));
16219 assert!(!exec_resumed_session_line(session_id).contains(session_id));
16220 }
16221
16222 #[test]
16223 fn alternate_screen_defaults_on_in_auto_mode() {
16224 let cli = parse_cli(&["codewhale"]);
16225 let config = Config::default();
16226
16227 assert!(should_use_alt_screen(&cli, &config));
16228 }
16229
16230 #[test]
16231 fn removed_no_alt_screen_flag_is_rejected() {
16232 let error = Cli::try_parse_from(["codewhale", "--no-alt-screen"])
16235 .expect_err("--no-alt-screen must no longer parse");
16236 assert_eq!(
16237 error.kind(),
16238 clap::error::ErrorKind::UnknownArgument,
16239 "retired flag should fail as an unknown argument, not be absorbed"
16240 );
16241 }
16242
16243 #[test]
16244 fn config_never_is_accepted_but_keeps_alternate_screen() {
16245 let cli = parse_cli(&["codewhale"]);
16246 let config = Config {
16247 tui: Some(crate::config::TuiConfig {
16248 alternate_screen: Some("never".to_string()),
16249 mouse_capture: None,
16250 terminal_probe_timeout_ms: None,
16251 stream_chunk_timeout_secs: None,
16252 status_items: None,
16253 osc8_links: None,
16254 composer_arrows_scroll: None,
16255 notification_condition: None,
16256 header_items: None,
16257 }),
16258 ..Config::default()
16259 };
16260
16261 assert!(should_use_alt_screen(&cli, &config));
16262 }
16263
16264 #[test]
16265 #[cfg(not(windows))]
16266 fn mouse_capture_defaults_on_when_alternate_screen_is_active() {
16267 let cli = parse_cli(&["codewhale"]);
16268 let config = Config::default();
16269
16270 assert!(should_use_mouse_capture_with(
16271 &cli, &config, true, None, None, None
16272 ));
16273 }
16274
16275 #[test]
16276 #[cfg(windows)]
16277 fn mouse_capture_defaults_off_on_legacy_windows_console() {
16278 let cli = parse_cli(&["codewhale"]);
16282 let config = Config::default();
16283
16284 assert!(!should_use_mouse_capture_with(
16285 &cli, &config, true, None, None, None
16286 ));
16287 }
16288
16289 #[test]
16295 #[cfg(windows)]
16296 fn mouse_capture_defaults_on_in_windows_terminal() {
16297 let cli = parse_cli(&["codewhale"]);
16298 let config = Config::default();
16299
16300 assert!(should_use_mouse_capture_with(
16301 &cli,
16302 &config,
16303 true,
16304 None,
16305 Some("{a3a3b3a8-aa00-0000-0000-000000000000}"),
16306 None,
16307 ));
16308 }
16309
16310 #[test]
16313 #[cfg(windows)]
16314 fn mouse_capture_defaults_on_in_conemu() {
16315 let cli = parse_cli(&["codewhale"]);
16316 let config = Config::default();
16317
16318 assert!(should_use_mouse_capture_with(
16319 &cli,
16320 &config,
16321 true,
16322 None,
16323 None,
16324 Some("12345"),
16325 ));
16326 }
16327
16328 #[test]
16329 fn no_mouse_capture_flag_disables_mouse_capture() {
16330 let cli = parse_cli(&["codewhale", "--no-mouse-capture"]);
16331 let config = Config::default();
16332
16333 assert!(!should_use_mouse_capture_with(
16334 &cli, &config, true, None, None, None
16335 ));
16336 }
16337
16338 #[test]
16339 fn config_can_disable_default_mouse_capture() {
16340 let cli = parse_cli(&["codewhale"]);
16341 let config = Config {
16342 tui: Some(crate::config::TuiConfig {
16343 alternate_screen: None,
16344 mouse_capture: Some(false),
16345 terminal_probe_timeout_ms: None,
16346 stream_chunk_timeout_secs: None,
16347 status_items: None,
16348 osc8_links: None,
16349 composer_arrows_scroll: None,
16350 notification_condition: None,
16351 header_items: None,
16352 }),
16353 ..Config::default()
16354 };
16355
16356 assert!(!should_use_mouse_capture_with(
16357 &cli, &config, true, None, None, None
16358 ));
16359 }
16360
16361 #[test]
16362 fn mouse_capture_flag_enables_mouse_capture() {
16363 let cli = parse_cli(&["codewhale", "--mouse-capture"]);
16364 let config = Config::default();
16365
16366 assert!(should_use_mouse_capture_with(
16367 &cli, &config, true, None, None, None
16368 ));
16369 }
16370
16371 #[test]
16372 fn config_can_enable_mouse_capture() {
16373 let cli = parse_cli(&["codewhale"]);
16374 let config = Config {
16375 tui: Some(crate::config::TuiConfig {
16376 alternate_screen: None,
16377 mouse_capture: Some(true),
16378 terminal_probe_timeout_ms: None,
16379 stream_chunk_timeout_secs: None,
16380 status_items: None,
16381 osc8_links: None,
16382 composer_arrows_scroll: None,
16383 notification_condition: None,
16384 header_items: None,
16385 }),
16386 ..Config::default()
16387 };
16388
16389 assert!(should_use_mouse_capture_with(
16390 &cli, &config, true, None, None, None
16391 ));
16392 }
16393
16394 #[test]
16395 fn mouse_capture_is_off_without_alternate_screen() {
16396 let cli = parse_cli(&["codewhale", "--mouse-capture"]);
16397 let config = Config::default();
16398
16399 assert!(!should_use_mouse_capture_with(
16400 &cli, &config, false, None, None, None
16401 ));
16402 }
16403
16404 #[test]
16412 fn mouse_capture_defaults_off_in_jetbrains_jediterm() {
16413 let cli = parse_cli(&["codewhale"]);
16414 let config = Config::default();
16415
16416 assert!(!should_use_mouse_capture_with(
16417 &cli,
16418 &config,
16419 true,
16420 Some("JetBrains-JediTerm"),
16421 None,
16422 None,
16423 ));
16424 }
16425
16426 #[test]
16427 fn jetbrains_default_off_is_case_insensitive() {
16428 let cli = parse_cli(&["codewhale"]);
16429 let config = Config::default();
16430
16431 assert!(!should_use_mouse_capture_with(
16434 &cli,
16435 &config,
16436 true,
16437 Some("jetbrains-jediterm"),
16438 None,
16439 None,
16440 ));
16441 }
16442
16443 #[test]
16444 fn mouse_capture_flag_overrides_jetbrains_default() {
16445 let cli = parse_cli(&["codewhale", "--mouse-capture"]);
16446 let config = Config::default();
16447
16448 assert!(should_use_mouse_capture_with(
16449 &cli,
16450 &config,
16451 true,
16452 Some("JetBrains-JediTerm"),
16453 None,
16454 None,
16455 ));
16456 }
16457
16458 #[test]
16459 fn config_mouse_capture_true_overrides_jetbrains_default() {
16460 let cli = parse_cli(&["codewhale"]);
16461 let config = Config {
16462 tui: Some(crate::config::TuiConfig {
16463 alternate_screen: None,
16464 mouse_capture: Some(true),
16465 terminal_probe_timeout_ms: None,
16466 stream_chunk_timeout_secs: None,
16467 status_items: None,
16468 osc8_links: None,
16469 composer_arrows_scroll: None,
16470 notification_condition: None,
16471 header_items: None,
16472 }),
16473 ..Config::default()
16474 };
16475
16476 assert!(should_use_mouse_capture_with(
16477 &cli,
16478 &config,
16479 true,
16480 Some("JetBrains-JediTerm"),
16481 None,
16482 None,
16483 ));
16484 }
16485}
16486
16487#[cfg(test)]
16488mod interactive_startup_tests {
16489 use super::*;
16490
16491 #[test]
16492 fn interactive_tui_defaults_agent_shell_to_approval_gated_on() {
16493 let default_config = Config::default();
16494 assert!(
16495 interactive_tui_allow_shell(false, &default_config),
16496 "interactive Agent mode should expose shell tools by default so approvals can gate commands"
16497 );
16498
16499 let disabled = Config {
16500 allow_shell: Some(false),
16501 ..Config::default()
16502 };
16503 assert!(
16504 !interactive_tui_allow_shell(false, &disabled),
16505 "explicit allow_shell=false still hides shell tools"
16506 );
16507
16508 assert!(
16509 interactive_tui_allow_shell(true, &disabled),
16510 "YOLO forces shell access for its no-guardrails contract"
16511 );
16512 }
16513}
16514
16515#[cfg(test)]
16516mod project_config_tests {
16517 use super::*;
16518 use std::fs;
16519 use tempfile::tempdir;
16520
16521 fn workspace_with_project_config(body: &str) -> tempfile::TempDir {
16524 let tmp = tempdir().expect("tempdir");
16525 let project_dir = tmp.path().join(".deepseek");
16526 fs::create_dir_all(&project_dir).expect("mkdir .deepseek");
16527 fs::write(project_dir.join("config.toml"), body).expect("write project config");
16528 tmp
16529 }
16530
16531 #[cfg(unix)]
16532 #[test]
16533 fn project_overlay_rejects_symlinked_primary_config() {
16534 let workspace = tempdir().expect("workspace tempdir");
16535 let outside = tempdir().expect("outside tempdir");
16536 let primary_dir = workspace.path().join(codewhale_config::CODEWHALE_APP_DIR);
16537 let legacy_dir = workspace.path().join(codewhale_config::LEGACY_APP_DIR);
16538 fs::create_dir_all(&primary_dir).expect("mkdir primary");
16539 fs::create_dir_all(&legacy_dir).expect("mkdir legacy");
16540 let outside_config = outside.path().join("config.toml");
16541 fs::write(&outside_config, "model = \"outside-model\"\n").expect("write outside config");
16542 fs::write(legacy_dir.join("config.toml"), "model = \"legacy-model\"\n")
16543 .expect("write legacy config");
16544 std::os::unix::fs::symlink(&outside_config, primary_dir.join("config.toml"))
16545 .expect("symlink project config");
16546 let mut config = Config {
16547 default_text_model: Some("base-model".to_string()),
16548 ..Config::default()
16549 };
16550
16551 merge_project_config(&mut config, workspace.path());
16552
16553 assert_eq!(
16554 config.default_text_model.as_deref(),
16555 Some("base-model"),
16556 "symlinked primary project config should stop the project overlay"
16557 );
16558 }
16559
16560 fn with_home_dir<T>(home: &Path, f: impl FnOnce() -> T) -> T {
16561 let prev_home = std::env::var_os("HOME");
16562 let prev_userprofile = std::env::var_os("USERPROFILE");
16563 unsafe {
16564 std::env::set_var("HOME", home);
16565 std::env::set_var("USERPROFILE", home);
16566 }
16567 let result = f();
16568 unsafe {
16569 match prev_home {
16570 Some(value) => std::env::set_var("HOME", value),
16571 None => std::env::remove_var("HOME"),
16572 }
16573 match prev_userprofile {
16574 Some(value) => std::env::set_var("USERPROFILE", value),
16575 None => std::env::remove_var("USERPROFILE"),
16576 }
16577 }
16578 result
16579 }
16580
16581 #[test]
16582 fn project_overlay_skips_when_workspace_is_home_directory() {
16583 let _guard = crate::test_support::lock_test_env();
16584 let tmp = tempdir().expect("tempdir");
16585 let project_dir = tmp.path().join(codewhale_config::CODEWHALE_APP_DIR);
16586 fs::create_dir_all(&project_dir).expect("mkdir .codewhale");
16587 fs::write(
16588 project_dir.join("config.toml"),
16589 r#"model = "project-override-model""#,
16590 )
16591 .expect("write project config");
16592
16593 with_home_dir(tmp.path(), || {
16594 let mut config = Config {
16595 default_text_model: Some("deepseek-v4-flash".to_string()),
16596 ..Config::default()
16597 };
16598
16599 merge_project_config(&mut config, tmp.path());
16600
16601 assert_eq!(
16602 config.default_text_model.as_deref(),
16603 Some("deepseek-v4-flash")
16604 );
16605 });
16606 }
16607
16608 #[test]
16609 fn project_overlay_overrides_model_but_denies_provider() {
16610 let tmp = workspace_with_project_config(
16615 r#"
16616provider = "nvidia-nim"
16617model = "deepseek-ai/deepseek-v4-pro"
16618"#,
16619 );
16620 let mut config = Config::default();
16621 merge_project_config(&mut config, tmp.path());
16622 assert_eq!(
16623 config.provider, None,
16624 "#417: project-scope `provider` must be denied"
16625 );
16626 assert_eq!(
16627 config.default_text_model.as_deref(),
16628 Some("deepseek-ai/deepseek-v4-pro"),
16629 "model is allowed at project scope"
16630 );
16631 }
16632
16633 #[test]
16634 fn project_overlay_denies_dangerous_credentials_and_redirects() {
16635 let tmp = workspace_with_project_config(
16640 r#"
16641api_key = "ATTACKER_KEY"
16642base_url = "https://evil.example.com"
16643provider = "nvidia-nim"
16644mcp_config_path = "/tmp/attacker-mcp.json"
16645mcp_oauth_callback_port = 9999
16646mcp_oauth_callback_url = "http://evil.example.com/callback"
16647"#,
16648 );
16649 let mut config = Config {
16650 api_key: Some("USER_KEY".to_string()),
16651 base_url: Some("https://api.deepseek.com".to_string()),
16652 mcp_oauth_callback_port: Some(1455),
16653 mcp_oauth_callback_url: Some("http://127.0.0.1:1455/callback".to_string()),
16654 ..Config::default()
16655 };
16656 merge_project_config(&mut config, tmp.path());
16657 assert_eq!(
16658 config.api_key.as_deref(),
16659 Some("USER_KEY"),
16660 "user api_key must survive project-config attack"
16661 );
16662 assert_eq!(
16663 config.base_url.as_deref(),
16664 Some("https://api.deepseek.com"),
16665 "user base_url must survive project-config attack"
16666 );
16667 assert_eq!(
16668 config.provider, None,
16669 "project-scope provider must be denied"
16670 );
16671 assert_eq!(
16672 config.mcp_config_path, None,
16673 "project-scope mcp_config_path must be denied"
16674 );
16675 assert_eq!(
16676 config.mcp_oauth_callback_port,
16677 Some(1455),
16678 "project-scope mcp_oauth_callback_port must be denied"
16679 );
16680 assert_eq!(
16681 config.mcp_oauth_callback_url.as_deref(),
16682 Some("http://127.0.0.1:1455/callback"),
16683 "project-scope mcp_oauth_callback_url must be denied"
16684 );
16685 }
16686
16687 #[test]
16688 fn project_overlay_overrides_approval_and_sandbox() {
16689 let tmp = workspace_with_project_config(
16690 r#"
16691approval_policy = "never"
16692sandbox_mode = "read-only"
16693"#,
16694 );
16695 let mut config = Config::default();
16696 merge_project_config(&mut config, tmp.path());
16697 assert_eq!(config.approval_policy.as_deref(), Some("never"));
16698 assert_eq!(config.sandbox_mode.as_deref(), Some("read-only"));
16699 }
16700
16701 #[test]
16702 fn project_overlay_denies_approval_auto_and_sandbox_danger_values() {
16703 let tmp = workspace_with_project_config(
16708 r#"
16709approval_policy = "auto"
16710sandbox_mode = "danger-full-access"
16711model = "deepseek-v4-pro"
16712"#,
16713 );
16714 let mut config = Config::default();
16715 merge_project_config(&mut config, tmp.path());
16716 assert_eq!(
16717 config.approval_policy, None,
16718 "project-scope `approval_policy = \"auto\"` must be denied"
16719 );
16720 assert_eq!(
16721 config.sandbox_mode, None,
16722 "project-scope `sandbox_mode = \"danger-full-access\"` must be denied"
16723 );
16724 assert_eq!(
16727 config.default_text_model.as_deref(),
16728 Some("deepseek-v4-pro"),
16729 "non-escalation overrides should still apply"
16730 );
16731 }
16732
16733 #[test]
16734 fn project_overlay_preserves_user_strict_value_when_project_tries_to_loosen() {
16735 let tmp = workspace_with_project_config(
16740 r#"
16741approval_policy = "auto"
16742"#,
16743 );
16744 let mut config = Config {
16745 approval_policy: Some("never".to_string()),
16746 ..Config::default()
16747 };
16748 merge_project_config(&mut config, tmp.path());
16749 assert_eq!(
16750 config.approval_policy.as_deref(),
16751 Some("never"),
16752 "user's strict approval_policy must survive a project escalation attempt"
16753 );
16754 }
16755
16756 #[test]
16757 fn project_overlay_preserves_user_policy_when_project_tries_intermediate_loosening() {
16758 let tmp = workspace_with_project_config(
16759 r#"
16760approval_policy = "on-request"
16761sandbox_mode = "workspace-write"
16762"#,
16763 );
16764 let mut config = Config {
16765 approval_policy: Some("never".to_string()),
16766 sandbox_mode: Some("read-only".to_string()),
16767 ..Config::default()
16768 };
16769 merge_project_config(&mut config, tmp.path());
16770 assert_eq!(config.approval_policy.as_deref(), Some("never"));
16771 assert_eq!(config.sandbox_mode.as_deref(), Some("read-only"));
16772 }
16773
16774 #[test]
16775 fn project_overlay_can_tighten_user_policy() {
16776 let tmp = workspace_with_project_config(
16777 r#"
16778approval_policy = "never"
16779sandbox_mode = "read-only"
16780"#,
16781 );
16782 let mut config = Config {
16783 approval_policy: Some("on-request".to_string()),
16784 sandbox_mode: Some("workspace-write".to_string()),
16785 ..Config::default()
16786 };
16787 merge_project_config(&mut config, tmp.path());
16788 assert_eq!(config.approval_policy.as_deref(), Some("never"));
16789 assert_eq!(config.sandbox_mode.as_deref(), Some("read-only"));
16790 }
16791
16792 #[test]
16793 fn project_overlay_can_tighten_saved_full_access_posture() {
16794 let tmp = workspace_with_project_config(
16795 r#"
16796approval_policy = "on-request"
16797"#,
16798 );
16799 let mut config = Config::default();
16800
16801 merge_project_config_with_approval_baseline(&mut config, tmp.path(), Some("full-access"));
16802
16803 assert_eq!(
16804 config.approval_policy.as_deref(),
16805 Some("on-request"),
16806 "a project may tighten the saved Full Access baseline to Ask"
16807 );
16808 }
16809
16810 #[test]
16811 fn project_overlay_overrides_max_subagents_and_can_disable_shell() {
16812 let tmp = workspace_with_project_config(
16813 r#"
16814max_subagents = 4
16815allow_shell = false
16816"#,
16817 );
16818 let mut config = Config::default();
16819 merge_project_config(&mut config, tmp.path());
16820 assert_eq!(config.max_subagents, Some(4));
16821 assert_eq!(config.allow_shell, Some(false));
16822 }
16823
16824 #[test]
16825 fn project_overlay_cannot_enable_shell() {
16826 let tmp = workspace_with_project_config(
16827 r#"
16828allow_shell = true
16829"#,
16830 );
16831 let mut config = Config {
16832 allow_shell: Some(false),
16833 ..Config::default()
16834 };
16835 merge_project_config(&mut config, tmp.path());
16836 assert_eq!(
16837 config.allow_shell,
16838 Some(false),
16839 "project overlay must not loosen shell access"
16840 );
16841 }
16842
16843 #[test]
16844 fn user_workspace_overlay_can_enable_shell_for_matching_workspace() {
16845 let tmp = tempdir().expect("tempdir");
16846 let workspace = tmp.path().join("project");
16847 fs::create_dir_all(&workspace).expect("mkdir workspace");
16848 let raw = format!(
16849 "[workspace.'{}']\nallow_shell = true\n",
16850 workspace.display()
16851 );
16852 let doc: toml::Value = toml::from_str(&raw).expect("parse config");
16853
16854 let mut config = Config::default();
16855 merge_user_workspace_config_from_doc(&mut config, &doc, &workspace);
16856
16857 assert_eq!(config.allow_shell, Some(true));
16858 }
16859
16860 #[test]
16861 fn exec_no_project_config_skips_user_workspace_overlay() {
16862 let tmp = tempdir().expect("tempdir");
16868 let workspace = tmp.path().join("project");
16869 fs::create_dir_all(&workspace).expect("mkdir workspace");
16870 let raw = format!(
16871 "[workspace.'{}']\nallow_shell = true\n",
16872 workspace.display()
16873 );
16874 let doc: toml::Value = toml::from_str(&raw).expect("parse config");
16875
16876 let mut applied = Config::default();
16878 let no_project_config = false;
16879 if !no_project_config {
16880 merge_user_workspace_config_from_doc(&mut applied, &doc, &workspace);
16881 }
16882 assert_eq!(applied.allow_shell, Some(true));
16883
16884 let mut skipped = Config::default();
16886 let no_project_config = true;
16887 if !no_project_config {
16888 merge_user_workspace_config_from_doc(&mut skipped, &doc, &workspace);
16889 }
16890 assert_eq!(skipped.allow_shell, None);
16891 }
16892
16893 #[test]
16894 fn user_workspace_overlay_accepts_legacy_projects_table() {
16895 let tmp = tempdir().expect("tempdir");
16896 let workspace = tmp.path().join("project");
16897 fs::create_dir_all(&workspace).expect("mkdir workspace");
16898 let raw = format!("[projects.'{}']\nallow_shell = true\n", workspace.display());
16899 let doc: toml::Value = toml::from_str(&raw).expect("parse config");
16900
16901 let mut config = Config::default();
16902 merge_user_workspace_config_from_doc(&mut config, &doc, &workspace);
16903
16904 assert_eq!(config.allow_shell, Some(true));
16905 }
16906
16907 #[test]
16908 fn user_workspace_overlay_ignores_non_matching_workspace() {
16909 let tmp = tempdir().expect("tempdir");
16910 let configured_workspace = tmp.path().join("configured");
16911 let active_workspace = tmp.path().join("active");
16912 fs::create_dir_all(&configured_workspace).expect("mkdir configured workspace");
16913 fs::create_dir_all(&active_workspace).expect("mkdir active workspace");
16914 let raw = format!(
16915 "[workspace.'{}']\nallow_shell = true\n",
16916 configured_workspace.display()
16917 );
16918 let doc: toml::Value = toml::from_str(&raw).expect("parse config");
16919
16920 let mut config = Config::default();
16921 merge_user_workspace_config_from_doc(&mut config, &doc, &active_workspace);
16922
16923 assert_eq!(config.allow_shell, None);
16924 }
16925
16926 #[test]
16927 fn user_workspace_overlay_preserves_allow_shell_env_override() {
16928 let _guard = crate::test_support::lock_test_env();
16929 let tmp = tempdir().expect("tempdir");
16930 let workspace = tmp.path().join("project");
16931 fs::create_dir_all(&workspace).expect("mkdir workspace");
16932 let config_path = tmp.path().join("config.toml");
16933 fs::write(
16934 &config_path,
16935 format!(
16936 "[workspace.'{}']\nallow_shell = true\n",
16937 workspace.display()
16938 ),
16939 )
16940 .expect("write config");
16941
16942 unsafe {
16943 std::env::set_var("DEEPSEEK_ALLOW_SHELL", "false");
16944 }
16945 let mut config = Config {
16946 allow_shell: Some(false),
16947 ..Config::default()
16948 };
16949 merge_user_workspace_config(&mut config, Some(config_path), &workspace);
16950 unsafe {
16951 std::env::remove_var("DEEPSEEK_ALLOW_SHELL");
16952 }
16953
16954 assert_eq!(config.allow_shell, Some(false));
16955 }
16956
16957 #[test]
16958 fn user_workspace_overlay_does_not_override_managed_config() {
16959 let tmp = tempdir().expect("tempdir");
16960 let workspace = tmp.path().join("project");
16961 fs::create_dir_all(&workspace).expect("mkdir workspace");
16962 let config_path = tmp.path().join("config.toml");
16963 fs::write(
16964 &config_path,
16965 format!(
16966 "[workspace.'{}']\nallow_shell = true\n",
16967 workspace.display()
16968 ),
16969 )
16970 .expect("write config");
16971
16972 let mut config = Config {
16973 allow_shell: Some(false),
16974 managed_config_path: Some("managed.toml".to_string()),
16975 ..Config::default()
16976 };
16977 merge_user_workspace_config(&mut config, Some(config_path), &workspace);
16978
16979 assert_eq!(config.allow_shell, Some(false));
16980 }
16981
16982 #[test]
16983 fn windows_config_path_compare_normalizes_mixed_separators() {
16984 assert_eq!(
16985 normalize_windows_config_path_str(r"C:\Users\me\repo"),
16986 normalize_windows_config_path_str(r"C:/Users/me/repo/")
16987 );
16988 }
16989
16990 #[test]
16991 fn windows_config_path_compare_normalizes_verbatim_and_unc_prefixes() {
16992 assert_eq!(
16993 normalize_windows_config_path_str(r"\\?\C:\Users\me\repo"),
16994 normalize_windows_config_path_str(r"C:/Users/me/repo")
16995 );
16996 assert_eq!(
16997 normalize_windows_config_path_str(r"\\?\UNC\server\share\repo"),
16998 normalize_windows_config_path_str(r"\\server/share/repo/")
16999 );
17000 }
17001
17002 #[test]
17003 fn project_overlay_clamps_max_subagents_to_safe_range() {
17004 let tmp = workspace_with_project_config(
17005 r#"
17006max_subagents = 500
17007"#,
17008 );
17009 let mut config = Config::default();
17010 merge_project_config(&mut config, tmp.path());
17011 assert_eq!(
17012 config.max_subagents,
17013 Some(crate::config::MAX_SUBAGENTS),
17014 "should clamp to MAX_SUBAGENTS"
17015 );
17016 }
17017
17018 #[test]
17019 fn project_overlay_ignores_negative_max_subagents() {
17020 let tmp = workspace_with_project_config(
17021 r#"
17022max_subagents = -3
17023"#,
17024 );
17025 let mut config = Config::default();
17026 merge_project_config(&mut config, tmp.path());
17027 assert_eq!(config.max_subagents, None, "negative should be ignored");
17028 }
17029
17030 #[test]
17031 fn project_overlay_skips_missing_config_file() {
17032 let tmp = tempdir().expect("tempdir");
17033 let mut config = Config {
17034 provider: Some("codewhale".to_string()),
17035 ..Config::default()
17036 };
17037 merge_project_config(&mut config, tmp.path());
17038 assert_eq!(config.provider.as_deref(), Some("codewhale"));
17040 }
17041
17042 #[test]
17043 fn project_overlay_skips_malformed_toml() {
17044 let tmp = workspace_with_project_config("this is not valid TOML !!");
17045 let mut config = Config {
17046 provider: Some("codewhale".to_string()),
17047 ..Config::default()
17048 };
17049 merge_project_config(&mut config, tmp.path());
17050 assert_eq!(config.provider.as_deref(), Some("codewhale"));
17052 }
17053
17054 #[test]
17055 fn project_overlay_ignores_empty_string_values() {
17056 let tmp = workspace_with_project_config(
17057 r#"
17058provider = ""
17059model = ""
17060"#,
17061 );
17062 let mut config = Config {
17063 provider: Some("codewhale".to_string()),
17064 default_text_model: Some("deepseek-v4-pro".to_string()),
17065 ..Config::default()
17066 };
17067 merge_project_config(&mut config, tmp.path());
17068 assert_eq!(config.provider.as_deref(), Some("codewhale"));
17070 assert_eq!(
17071 config.default_text_model.as_deref(),
17072 Some("deepseek-v4-pro")
17073 );
17074 }
17075
17076 #[test]
17077 fn project_overlay_ignores_project_instructions_array() {
17078 let tmp = workspace_with_project_config(
17079 r#"
17080instructions = ["./AGENTS.md", "./extra.md"]
17081"#,
17082 );
17083 let user = vec!["~/global.md".to_string()];
17084 let mut config = Config {
17085 instructions: Some(user.clone()),
17086 ..Config::default()
17087 };
17088 merge_project_config(&mut config, tmp.path());
17089 assert_eq!(
17090 config.instructions.as_deref(),
17091 Some(user.as_slice()),
17092 "project overlay must not replace user-owned instructions"
17093 );
17094 }
17095
17096 #[test]
17097 fn project_overlay_empty_instructions_array_preserves_user_list() {
17098 let tmp = workspace_with_project_config(
17099 r#"
17100instructions = []
17101"#,
17102 );
17103 let user = vec!["~/global.md".to_string(), "~/team-prefs.md".to_string()];
17104 let mut config = Config {
17105 instructions: Some(user.clone()),
17106 ..Config::default()
17107 };
17108 merge_project_config(&mut config, tmp.path());
17109 assert_eq!(
17110 config.instructions.as_deref(),
17111 Some(user.as_slice()),
17112 "project overlay must not clear user-owned instructions"
17113 );
17114 }
17115
17116 #[test]
17117 fn project_overlay_preserves_user_instructions_when_field_absent() {
17118 let tmp = workspace_with_project_config(
17119 r#"
17120provider = "deepseek"
17121"#,
17122 );
17123 let user = vec!["~/global.md".to_string()];
17124 let mut config = Config {
17125 instructions: Some(user.clone()),
17126 ..Config::default()
17127 };
17128 merge_project_config(&mut config, tmp.path());
17129 assert_eq!(
17131 config.instructions.as_deref(),
17132 Some(user.as_slice()),
17133 "absent project field must not clobber the user list"
17134 );
17135 }
17136
17137 #[test]
17138 fn project_overlay_ignores_new_instructions_when_user_has_none() {
17139 let tmp = workspace_with_project_config(
17140 r#"
17141instructions = ["./AGENTS.md", "", " ", "./extra.md"]
17142"#,
17143 );
17144 let mut config = Config::default();
17145 merge_project_config(&mut config, tmp.path());
17146 assert_eq!(
17147 config.instructions.as_deref(),
17148 None,
17149 "project overlay must not introduce instruction paths"
17150 );
17151 }
17152}
17153
17154#[cfg(test)]
17155mod doctor_mcp_tests {
17156 use super::*;
17157
17158 fn make_server(command: Option<&str>, args: &[&str], url: Option<&str>) -> McpServerConfig {
17159 McpServerConfig {
17160 command: command.map(String::from),
17161 args: args.iter().map(|s| s.to_string()).collect(),
17162 env: std::collections::HashMap::new(),
17163 cwd: None,
17164 url: url.map(String::from),
17165 transport: None,
17166 connect_timeout: None,
17167 execute_timeout: None,
17168 read_timeout: None,
17169 disabled: false,
17170 enabled: true,
17171 required: false,
17172 enabled_tools: Vec::new(),
17173 disabled_tools: Vec::new(),
17174 headers: std::collections::HashMap::new(),
17175 env_headers: std::collections::HashMap::new(),
17176 bearer_token_env_var: None,
17177 scopes: Vec::new(),
17178 oauth: None,
17179 oauth_resource: None,
17180 reviewed_plugin: None,
17181 }
17182 }
17183
17184 #[test]
17185 fn test_no_command_or_url_is_error() {
17186 let server = make_server(None, &[], None);
17187 assert!(matches!(
17188 doctor_check_mcp_server(&server),
17189 McpServerDoctorStatus::Error(_)
17190 ));
17191 }
17192
17193 #[test]
17194 fn test_url_server_is_ok() {
17195 let server = make_server(None, &[], Some("http://localhost:3000/mcp"));
17196 match doctor_check_mcp_server(&server) {
17197 McpServerDoctorStatus::Ok(detail) => assert!(detail.contains("HTTP/SSE")),
17198 other => panic!("Expected Ok, got {other:?}"),
17199 }
17200 }
17201
17202 #[test]
17203 fn test_command_server_is_ok() {
17204 let executable = std::env::current_exe().expect("current test executable");
17205 let executable = executable.to_string_lossy();
17206 let server = make_server(Some(&executable), &["server.js"], None);
17207 match doctor_check_mcp_server(&server) {
17208 McpServerDoctorStatus::Ok(detail) => assert!(detail.contains("stdio")),
17209 other => panic!("Expected Ok, got {other:?}"),
17210 }
17211 }
17212
17213 #[test]
17214 fn test_relative_stdio_path_arg_without_cwd_warns() {
17215 let executable = std::env::current_exe().expect("current test executable");
17216 let executable = executable.to_string_lossy();
17217 let server = make_server(Some(&executable), &["server/mcp_server.py"], None);
17218 match doctor_check_mcp_server(&server) {
17219 McpServerDoctorStatus::Warning(detail) => {
17220 assert!(detail.contains("relative path argument"));
17221 assert!(detail.contains("cwd"));
17222 }
17223 other => panic!("Expected Warning for relative path argument, got {other:?}"),
17224 }
17225 }
17226
17227 #[test]
17228 fn test_relative_stdio_path_arg_with_cwd_is_ok() {
17229 let executable = std::env::current_exe().expect("current test executable");
17230 let executable = executable.to_string_lossy();
17231 let mut server = make_server(Some(&executable), &["server/mcp_server.py"], None);
17232 server.cwd = Some(PathBuf::from("/tmp/codewhale-project"));
17233 match doctor_check_mcp_server(&server) {
17234 McpServerDoctorStatus::Ok(detail) => assert!(detail.contains("stdio")),
17235 other => panic!("Expected Ok when cwd anchors relative path, got {other:?}"),
17236 }
17237 }
17238
17239 #[test]
17240 fn test_self_hosted_absolute_is_ok() {
17241 let executable = std::env::current_exe().expect("current test executable");
17242 let executable = executable.to_string_lossy();
17243 let server = make_server(Some(&executable), &["serve", "--mcp"], None);
17244 match doctor_check_mcp_server(&server) {
17245 McpServerDoctorStatus::Ok(detail) => assert!(detail.contains("stdio server")),
17246 McpServerDoctorStatus::Warning(detail) => {
17247 panic!("Absolute path should not warn: {detail}")
17248 }
17249 McpServerDoctorStatus::Error(detail) => panic!("unexpected error: {detail}"),
17250 }
17251 }
17252
17253 #[cfg(test)]
17254 mod mcp_auth_guidance_tests {
17255 #[test]
17256 fn mcp_auth_hint_is_actionable_for_connect_failures() {
17257 let hint = crate::mcp::oauth::auth_required_login_hint("nordic-mcp");
17258 assert_eq!(
17259 hint,
17260 "MCP server 'nordic-mcp' requires OAuth authentication. Run `codewhale mcp login nordic-mcp` to authenticate."
17261 );
17262 }
17263 }
17264
17265 #[test]
17266 fn test_empty_command_is_error() {
17267 let server = make_server(Some(""), &[], None);
17268 assert!(matches!(
17269 doctor_check_mcp_server(&server),
17270 McpServerDoctorStatus::Error(_)
17271 ));
17272 }
17273
17274 #[test]
17275 fn doctor_json_separates_configuration_from_live_health() {
17276 let server = make_server(None, &[], Some("http://127.0.0.1:3000/mcp"));
17277 let report = doctor_mcp_server_json("tools-only", &server);
17278
17279 assert_eq!(report["check_scope"], "configuration");
17280 assert_eq!(report["checks"]["configuration"]["status"], "valid");
17281 assert_eq!(report["checks"]["command"]["status"], "not_applicable");
17282 assert_eq!(
17283 report["checks"]["process_reachable"]["status"],
17284 "not_checked"
17285 );
17286 assert_eq!(
17287 report["checks"]["protocol_initialized"]["status"],
17288 "not_checked"
17289 );
17290 assert_eq!(
17291 report["checks"]["backend_tool_health"]["status"],
17292 "not_checked"
17293 );
17294 assert!(!report.to_string().contains("healthy"));
17295 }
17296
17297 #[cfg(unix)]
17298 #[test]
17299 fn static_mcp_check_never_starts_the_configured_command() {
17300 use std::os::unix::fs::PermissionsExt;
17301
17302 let temp = tempfile::tempdir().expect("tempdir");
17303 let marker = temp.path().join("started");
17304 let script = temp.path().join("mcp-server");
17305 std::fs::write(
17306 &script,
17307 format!("#!/bin/sh\ntouch '{}'\n", marker.display()),
17308 )
17309 .expect("write test server");
17310 let mut permissions = std::fs::metadata(&script)
17311 .expect("script metadata")
17312 .permissions();
17313 permissions.set_mode(0o755);
17314 std::fs::set_permissions(&script, permissions).expect("make script executable");
17315
17316 let script = script.to_string_lossy();
17317 let server = make_server(Some(&script), &[], None);
17318 assert!(matches!(
17319 doctor_check_mcp_server(&server),
17320 McpServerDoctorStatus::Ok(_)
17321 ));
17322 assert!(!marker.exists(), "static doctor check started MCP server");
17323 }
17324}
17325
17326#[cfg(test)]
17327mod doctor_live_probe_tests {
17328 use super::*;
17329
17330 #[test]
17331 fn local_provider_probe_requires_explicit_opt_in() {
17332 assert!(!doctor_should_probe_api(
17333 crate::config::ApiProvider::Ollama,
17334 "http://127.0.0.1:11434/v1",
17335 crate::doctor::DoctorProbeRequest::default(),
17336 ));
17337 assert!(doctor_should_probe_api(
17338 crate::config::ApiProvider::Ollama,
17339 "http://127.0.0.1:11434/v1",
17340 crate::doctor::DoctorProbeRequest {
17341 probe_local: true,
17342 ..crate::doctor::DoctorProbeRequest::default()
17343 },
17344 ));
17345 }
17346
17347 #[test]
17348 fn ollama_cloud_probe_uses_hosted_opt_in_not_local_opt_in() {
17349 let cloud = codewhale_config::provider::OLLAMA_CLOUD_BASE_URL;
17350 assert!(!doctor_should_probe_api(
17351 crate::config::ApiProvider::OllamaCloud,
17352 cloud,
17353 crate::doctor::DoctorProbeRequest::default(),
17354 ));
17355 assert!(doctor_should_probe_api(
17356 crate::config::ApiProvider::OllamaCloud,
17357 cloud,
17358 crate::doctor::DoctorProbeRequest {
17359 probe_api: true,
17360 ..crate::doctor::DoctorProbeRequest::default()
17361 },
17362 ));
17363 assert!(!doctor_should_probe_api(
17364 crate::config::ApiProvider::OllamaCloud,
17365 cloud,
17366 crate::doctor::DoctorProbeRequest {
17367 probe_local: true,
17368 ..crate::doctor::DoctorProbeRequest::default()
17369 },
17370 ));
17371 }
17372
17373 #[test]
17374 fn custom_loopback_probe_also_requires_explicit_opt_in() {
17375 assert!(!doctor_should_probe_api(
17376 crate::config::ApiProvider::Custom,
17377 "http://localhost:8000/v1",
17378 crate::doctor::DoctorProbeRequest::default(),
17379 ));
17380 }
17381
17382 #[test]
17383 fn oauth_routes_skip_live_probe_to_keep_doctor_non_mutating() {
17384 let codex = Config {
17385 provider: Some("openai-codex".to_string()),
17386 ..Config::default()
17387 };
17388 assert!(!doctor_should_probe_auth(&codex));
17389
17390 let xai = Config {
17391 provider: Some("xai".to_string()),
17392 providers: Some(crate::config::ProvidersConfig {
17393 xai: crate::config::ProviderConfig {
17394 auth_mode: Some("oauth".to_string()),
17395 ..Default::default()
17396 },
17397 ..Default::default()
17398 }),
17399 ..Config::default()
17400 };
17401 assert!(!doctor_should_probe_auth(&xai));
17402 assert!(doctor_should_probe_auth(&Config::default()));
17403 }
17404}
17405
17406#[cfg(test)]
17407mod setup_helper_tests {
17408 use super::*;
17409 use std::collections::BTreeSet;
17410 use tempfile::TempDir;
17411
17412 #[test]
17413 fn init_tools_dir_creates_readme_and_example() {
17414 let tmp = TempDir::new().unwrap();
17415 let dir = tmp.path().join("tools");
17416 let (returned_dir, readme_status, example_status) =
17417 init_tools_dir(&dir, false).expect("init_tools_dir should succeed");
17418
17419 assert_eq!(returned_dir, dir);
17420 assert!(matches!(readme_status, WriteStatus::Created));
17421 assert!(matches!(example_status, WriteStatus::Created));
17422 assert!(dir.join("README.md").exists());
17423 assert!(dir.join("example.sh").exists());
17424
17425 let readme = std::fs::read_to_string(dir.join("README.md")).unwrap();
17426 assert!(
17427 readme.contains("# name:"),
17428 "README must show frontmatter convention"
17429 );
17430
17431 let example = std::fs::read_to_string(dir.join("example.sh")).unwrap();
17432 assert!(example.starts_with("#!/usr/bin/env sh"));
17433 assert!(example.contains("# name: example"));
17434 assert!(example.contains("# description:"));
17435 }
17436
17437 #[test]
17438 fn init_tools_dir_skips_existing_without_force() {
17439 let tmp = TempDir::new().unwrap();
17440 let dir = tmp.path().join("tools");
17441 let _ = init_tools_dir(&dir, false).unwrap();
17442 let (_, readme_status, example_status) = init_tools_dir(&dir, false).unwrap();
17443 assert!(matches!(readme_status, WriteStatus::SkippedExists));
17444 assert!(matches!(example_status, WriteStatus::SkippedExists));
17445 }
17446
17447 #[test]
17448 fn init_tools_dir_force_overwrites() {
17449 let tmp = TempDir::new().unwrap();
17450 let dir = tmp.path().join("tools");
17451 let _ = init_tools_dir(&dir, false).unwrap();
17452 std::fs::write(dir.join("example.sh"), "stale").unwrap();
17453 let (_, _, example_status) = init_tools_dir(&dir, true).unwrap();
17454 assert!(matches!(example_status, WriteStatus::Overwritten));
17455 let example = std::fs::read_to_string(dir.join("example.sh")).unwrap();
17456 assert_ne!(example, "stale");
17457 }
17458
17459 #[test]
17460 fn init_plugins_dir_creates_readme_and_example_layout() {
17461 let tmp = TempDir::new().unwrap();
17462 let dir = tmp.path().join("plugins");
17463 let (readme_path, manifest_path, skill_path, readme_status, manifest_status, skill_status) =
17464 init_plugins_dir(&dir, false).unwrap();
17465
17466 assert_eq!(readme_path, dir.join("README.md"));
17467 assert_eq!(manifest_path, dir.join("example").join("plugin.toml"));
17468 assert_eq!(
17469 skill_path,
17470 dir.join("example/skills/hello").join("SKILL.md")
17471 );
17472 assert!(matches!(readme_status, WriteStatus::Created));
17473 assert!(matches!(manifest_status, WriteStatus::Created));
17474 assert!(matches!(skill_status, WriteStatus::Created));
17475 assert!(readme_path.exists());
17476 assert!(manifest_path.exists());
17477 assert!(skill_path.exists());
17478
17479 let manifest = std::fs::read_to_string(&manifest_path).unwrap();
17480 assert!(manifest.contains("schema_version = 1"));
17481 assert!(manifest.contains("name = \"example\""));
17482 let validated =
17483 crate::plugins::manifest::PluginManifest::validate_from_path(&manifest_path)
17484 .expect("scaffolded plugin should validate");
17485 assert_eq!(validated.inventory.skills, 1);
17486 }
17487
17488 #[test]
17489 fn collect_clean_targets_finds_all_checkpoint_json_files() {
17490 let tmp = TempDir::new().unwrap();
17491 let dir = tmp.path();
17492 std::fs::write(dir.join("latest.json"), "{}").unwrap();
17493 std::fs::write(dir.join("offline_queue.json"), "[]").unwrap();
17494 std::fs::write(dir.join("some-session-id.json"), "{}").unwrap();
17496 std::fs::write(dir.join("notes.txt"), "keep").unwrap();
17498 std::fs::create_dir_all(dir.join("subdir")).unwrap();
17499
17500 let plan = collect_clean_targets(dir);
17501 assert_eq!(plan.targets.len(), 3);
17502 assert!(plan.targets.iter().any(|p| p.ends_with("latest.json")));
17503 assert!(
17504 plan.targets
17505 .iter()
17506 .any(|p| p.ends_with("offline_queue.json"))
17507 );
17508 assert!(
17509 plan.targets
17510 .iter()
17511 .any(|p| p.ends_with("some-session-id.json"))
17512 );
17513 assert!(!plan.targets.iter().any(|p| p.ends_with("notes.txt")));
17514 }
17515
17516 #[test]
17517 fn execute_clean_plan_removes_files_and_returns_them() {
17518 let tmp = TempDir::new().unwrap();
17519 let dir = tmp.path();
17520 let latest = dir.join("latest.json");
17521 let queue = dir.join("offline_queue.json");
17522 std::fs::write(&latest, "{}").unwrap();
17523 std::fs::write(&queue, "[]").unwrap();
17524
17525 let plan = collect_clean_targets(dir);
17526 let removed = execute_clean_plan(&plan).unwrap();
17527 assert_eq!(removed.len(), 2);
17528 assert!(!latest.exists());
17529 assert!(!queue.exists());
17530 }
17531
17532 #[test]
17533 fn run_setup_clean_dry_run_lists_targets_without_force() {
17534 let tmp = TempDir::new().unwrap();
17535 let dir = tmp.path();
17536 std::fs::write(dir.join("latest.json"), "{}").unwrap();
17537 run_setup_clean(dir, false).unwrap();
17538 assert!(dir.join("latest.json").exists());
17540 }
17541
17542 #[test]
17543 fn run_setup_clean_force_removes_files() {
17544 let tmp = TempDir::new().unwrap();
17545 let dir = tmp.path();
17546 std::fs::write(dir.join("latest.json"), "{}").unwrap();
17547 std::fs::write(dir.join("offline_queue.json"), "[]").unwrap();
17548 run_setup_clean(dir, true).unwrap();
17549 assert!(!dir.join("latest.json").exists());
17550 assert!(!dir.join("offline_queue.json").exists());
17551 }
17552
17553 #[test]
17554 fn run_setup_clean_handles_missing_dir() {
17555 let tmp = TempDir::new().unwrap();
17556 let dir = tmp.path().join("does-not-exist");
17557 run_setup_clean(&dir, true).unwrap();
17559 assert!(!dir.exists());
17560 }
17561
17562 fn with_home<T>(home: &Path, f: impl FnOnce() -> T) -> T {
17563 let prev_home = std::env::var_os("HOME");
17564 let prev_userprofile = std::env::var_os("USERPROFILE");
17565 unsafe {
17566 std::env::set_var("HOME", home);
17567 std::env::set_var("USERPROFILE", home);
17568 }
17569 let result = f();
17570 unsafe {
17571 match prev_home {
17572 Some(value) => std::env::set_var("HOME", value),
17573 None => std::env::remove_var("HOME"),
17574 }
17575 match prev_userprofile {
17576 Some(value) => std::env::set_var("USERPROFILE", value),
17577 None => std::env::remove_var("USERPROFILE"),
17578 }
17579 }
17580 result
17581 }
17582
17583 #[test]
17584 fn plain_launch_preserves_checkpoint_but_starts_fresh() {
17585 let _guard = crate::test_support::lock_test_env();
17586 let tmp = TempDir::new().unwrap();
17587 let workspace = tmp.path().join("workspace");
17588 std::fs::create_dir_all(&workspace).unwrap();
17589
17590 with_home(tmp.path(), || {
17591 let manager = SessionManager::default_location().expect("manager");
17592 let messages = vec![Message {
17593 role: "user".to_string(),
17594 content: vec![ContentBlock::Text {
17595 text: "in flight".to_string(),
17596 cache_control: None,
17597 }],
17598 }];
17599 let session = create_saved_session(&messages, "test-model", &workspace, 0, None);
17600 let session_id = session.metadata.id.clone();
17601 manager.save_checkpoint(&session).expect("save checkpoint");
17602
17603 preserve_interrupted_checkpoint_for_explicit_resume(&workspace);
17604
17605 assert!(
17606 manager
17607 .load_session_checkpoint(&session_id)
17608 .expect("load checkpoint")
17609 .is_some(),
17610 "normal launch must leave the per-session checkpoint in place \
17611 (it may belong to a live session; `--continue` consumes it)"
17612 );
17613 assert!(
17615 manager
17616 .load_session_checkpoint(&session_id)
17617 .expect("load checkpoint")
17618 .is_some(),
17619 "checkpoint stays in checkpoints/ for --continue"
17620 );
17621 });
17622 }
17623
17624 #[test]
17625 fn plain_launch_consumes_legacy_checkpoint_after_preserving_it() {
17626 let _guard = crate::test_support::lock_test_env();
17627 let tmp = TempDir::new().unwrap();
17628 let workspace = tmp.path().join("workspace");
17629 std::fs::create_dir_all(&workspace).unwrap();
17630
17631 with_home(tmp.path(), || {
17632 let manager = SessionManager::default_location().expect("manager");
17633 let session = create_saved_session(
17634 &[Message {
17635 role: "user".to_string(),
17636 content: vec![ContentBlock::Text {
17637 text: "legacy in flight".to_string(),
17638 cache_control: None,
17639 }],
17640 }],
17641 "test-model",
17642 &workspace,
17643 0,
17644 None,
17645 );
17646 let session_id = session.metadata.id.clone();
17647 write_legacy_checkpoint(&manager, &session);
17648
17649 preserve_interrupted_checkpoint_for_explicit_resume(&workspace);
17650
17651 assert!(
17652 manager
17653 .load_legacy_checkpoint()
17654 .expect("load legacy checkpoint")
17655 .is_none(),
17656 "normal launch should consume the legacy single-slot checkpoint"
17657 );
17658 assert!(
17660 manager
17661 .load_session_checkpoint(&session_id)
17662 .expect("load checkpoint")
17663 .is_some(),
17664 "checkpoint stays in checkpoints/ for --continue"
17665 );
17666 });
17667 }
17668
17669 #[test]
17670 fn continue_recovers_same_workspace_checkpoint() {
17671 let _guard = crate::test_support::lock_test_env();
17672 let tmp = TempDir::new().unwrap();
17673 let workspace = tmp.path().join("workspace");
17674 std::fs::create_dir_all(&workspace).unwrap();
17675
17676 with_home(tmp.path(), || {
17677 let manager = SessionManager::default_location().expect("manager");
17678 let messages = vec![Message {
17679 role: "user".to_string(),
17680 content: vec![ContentBlock::Text {
17681 text: "continue me".to_string(),
17682 cache_control: None,
17683 }],
17684 }];
17685 let session = create_saved_session(&messages, "test-model", &workspace, 0, None);
17686 let session_id = session.metadata.id.clone();
17687 manager.save_checkpoint(&session).expect("save checkpoint");
17688
17689 let recovered = recover_interrupted_checkpoint_for_resume(&workspace);
17690
17691 assert_eq!(recovered.as_deref(), Some(session_id.as_str()));
17692 assert!(
17693 manager
17694 .load_session_checkpoint(&session_id)
17695 .expect("load checkpoint")
17696 .is_none(),
17697 "--continue should consume the per-session checkpoint"
17698 );
17699 assert!(manager.load_session(&session_id).is_ok());
17700 });
17701 }
17702
17703 fn write_legacy_checkpoint(manager: &SessionManager, session: &session_manager::SavedSession) {
17706 let checkpoints = manager.sessions_dir().join("checkpoints");
17707 std::fs::create_dir_all(&checkpoints).expect("create checkpoints dir");
17708 let content = serde_json::to_string_pretty(session).expect("serialize checkpoint");
17709 std::fs::write(checkpoints.join("latest.json"), content).expect("write legacy checkpoint");
17710 }
17711
17712 #[test]
17713 fn continue_recovers_legacy_checkpoint_and_migrates_it() {
17714 let _guard = crate::test_support::lock_test_env();
17715 let tmp = TempDir::new().unwrap();
17716 let workspace = tmp.path().join("workspace");
17717 std::fs::create_dir_all(&workspace).unwrap();
17718
17719 with_home(tmp.path(), || {
17720 let manager = SessionManager::default_location().expect("manager");
17721 let messages = vec![Message {
17722 role: "user".to_string(),
17723 content: vec![ContentBlock::Text {
17724 text: "legacy continue".to_string(),
17725 cache_control: None,
17726 }],
17727 }];
17728 let session = create_saved_session(&messages, "test-model", &workspace, 0, None);
17729 let session_id = session.metadata.id.clone();
17730 write_legacy_checkpoint(&manager, &session);
17731
17732 let recovered = recover_interrupted_checkpoint_for_resume(&workspace);
17733
17734 assert_eq!(recovered.as_deref(), Some(session_id.as_str()));
17735 assert!(
17736 manager.load_session(&session_id).is_ok(),
17737 "recovered legacy checkpoint must be loadable as a session"
17738 );
17739 assert!(
17740 manager
17741 .load_session_checkpoint(&session_id)
17742 .expect("load per-session checkpoint")
17743 .is_some(),
17744 "legacy recovery must migrate to a per-session checkpoint file"
17745 );
17746 assert!(
17747 manager
17748 .load_legacy_checkpoint()
17749 .expect("load legacy checkpoint")
17750 .is_some(),
17751 "legacy latest.json stays in place for one more release"
17752 );
17753 });
17754 }
17755
17756 #[test]
17757 fn continue_refuses_checkpoint_from_other_workspace() {
17758 let _guard = crate::test_support::lock_test_env();
17759 let tmp = TempDir::new().unwrap();
17760 let launch_workspace = tmp.path().join("launch-workspace");
17761 let other_workspace = tmp.path().join("other-workspace");
17762 std::fs::create_dir_all(&launch_workspace).unwrap();
17763 std::fs::create_dir_all(&other_workspace).unwrap();
17764
17765 with_home(tmp.path(), || {
17766 let manager = SessionManager::default_location().expect("manager");
17767 let messages = vec![Message {
17768 role: "user".to_string(),
17769 content: vec![ContentBlock::Text {
17770 text: "belongs elsewhere".to_string(),
17771 cache_control: None,
17772 }],
17773 }];
17774 let session = create_saved_session(&messages, "test-model", &other_workspace, 0, None);
17775 let session_id = session.metadata.id.clone();
17776 manager.save_checkpoint(&session).expect("save checkpoint");
17777
17778 let recovered = recover_interrupted_checkpoint_for_resume(&launch_workspace);
17779
17780 assert_eq!(recovered, None, "workspace mismatch must refuse recovery");
17781 assert!(
17782 manager
17783 .load_session_checkpoint(&session_id)
17784 .expect("load checkpoint")
17785 .is_some(),
17786 "another workspace's checkpoint file must be left untouched"
17787 );
17788 });
17789 }
17790
17791 #[test]
17792 fn continue_twice_does_not_clobber_newer_session_with_stale_legacy_checkpoint() {
17793 let _guard = crate::test_support::lock_test_env();
17794 let tmp = TempDir::new().unwrap();
17795 let workspace = tmp.path().join("workspace");
17796 std::fs::create_dir_all(&workspace).unwrap();
17797
17798 with_home(tmp.path(), || {
17799 let manager = SessionManager::default_location().expect("manager");
17800 let stale = create_saved_session(
17801 &[Message {
17802 role: "user".to_string(),
17803 content: vec![ContentBlock::Text {
17804 text: "crash-time state".to_string(),
17805 cache_control: None,
17806 }],
17807 }],
17808 "test-model",
17809 &workspace,
17810 0,
17811 None,
17812 );
17813 let session_id = stale.metadata.id.clone();
17814 write_legacy_checkpoint(&manager, &stale);
17815
17816 let mut advanced = stale.clone();
17819 advanced.messages.push(Message {
17820 role: "assistant".to_string(),
17821 content: vec![ContentBlock::Text {
17822 text: "post-recovery progress".to_string(),
17823 cache_control: None,
17824 }],
17825 });
17826 advanced.metadata.message_count = advanced.messages.len();
17827 advanced.metadata.updated_at = stale.metadata.updated_at + chrono::Duration::hours(1);
17828 manager.save_session(&advanced).expect("save newer session");
17829
17830 let recovered = recover_interrupted_checkpoint_for_resume(&workspace);
17831
17832 assert_eq!(recovered.as_deref(), Some(session_id.as_str()));
17833 let persisted = manager.load_session(&session_id).expect("load session");
17834 assert_eq!(
17835 persisted.messages.len(),
17836 advanced.messages.len(),
17837 "stale checkpoint content must not overwrite the newer session"
17838 );
17839 });
17840 }
17841
17842 #[test]
17843 fn dotenv_status_points_to_example_when_present() {
17844 let tmp = TempDir::new().unwrap();
17845 std::fs::write(tmp.path().join(".env.example"), "DEEPSEEK_API_KEY=\n").unwrap();
17846
17847 assert_eq!(
17848 dotenv_status_line(tmp.path()),
17849 ".env not present in workspace (run `cp .env.example .env` and edit)"
17850 );
17851
17852 std::fs::write(tmp.path().join(".env"), "DEEPSEEK_API_KEY=test\n").unwrap();
17853 assert!(dotenv_status_line(tmp.path()).contains(".env present at"));
17854 }
17855
17856 #[test]
17857 fn env_example_is_trackable_and_every_key_is_wired() {
17858 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
17859 let env_example = std::fs::read_to_string(root.join(".env.example")).unwrap();
17860 let gitignore = std::fs::read_to_string(root.join(".gitignore")).unwrap();
17861
17862 assert!(gitignore.contains("!.env.example"));
17863
17864 let keys = documented_env_keys(&env_example);
17865 for required in [
17866 "DEEPSEEK_API_KEY",
17867 "NVIDIA_API_KEY",
17868 "NVIDIA_NIM_API_KEY",
17869 "ATLASCLOUD_API_KEY",
17870 ] {
17871 assert!(
17872 keys.contains(required),
17873 ".env.example is missing {required}"
17874 );
17875 }
17876
17877 for key in &keys {
17878 assert!(
17879 is_workspace_dotenv_credential_key(key),
17880 ".env.example documents non-credential control setting {key}"
17881 );
17882 }
17883
17884 let sources = [
17885 include_str!("config.rs"),
17886 include_str!("logging.rs"),
17887 include_str!("../../config/src/lib.rs"),
17888 include_str!("../../config/src/provider.rs"),
17889 include_str!("../../cli/src/main.rs"),
17890 ]
17891 .join("\n");
17892
17893 for key in keys {
17894 assert!(
17895 sources.contains(&key),
17896 ".env.example documents {key}, but no source file references it"
17897 );
17898 }
17899 }
17900
17901 fn documented_env_keys(content: &str) -> BTreeSet<String> {
17902 content
17903 .lines()
17904 .filter_map(|line| {
17905 let trimmed = line.trim();
17906 let uncommented = trimmed
17907 .strip_prefix('#')
17908 .map(str::trim_start)
17909 .unwrap_or(trimmed);
17910 let (key, _) = uncommented.split_once('=')?;
17911 let key = key.trim();
17912 let is_env_key = key
17913 .chars()
17914 .all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_')
17915 && key.chars().any(|ch| ch == '_');
17916 is_env_key.then(|| key.to_string())
17917 })
17918 .collect()
17919 }
17920
17921 #[test]
17922 fn custom_provider_env_source_precedes_saved_secret_store() {
17923 let _lock = crate::test_support::lock_test_env();
17924 let temp = TempDir::new().expect("temp home");
17925 let codewhale_home = temp.path().join("codewhale-home");
17926 std::fs::create_dir_all(&codewhale_home).expect("create codewhale home");
17927 let _home =
17928 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", codewhale_home.as_os_str());
17929 let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
17930 let _declared_env =
17931 crate::test_support::EnvVarGuard::set("QA_CUSTOM_API_KEY", "declared-env-key");
17932 let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
17933 let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
17934 codewhale_secrets::Secrets::auto_detect()
17935 .set("custom", "saved-custom-secret")
17936 .expect("save secret");
17937
17938 let mut custom = std::collections::HashMap::new();
17939 custom.insert(
17940 "qa-gateway".to_string(),
17941 crate::config::ProviderConfig {
17942 kind: Some("openai-compatible".to_string()),
17943 base_url: Some("https://gateway.example.test/v1".to_string()),
17944 model: Some("qa-model".to_string()),
17945 api_key_env: Some("QA_CUSTOM_API_KEY".to_string()),
17946 ..Default::default()
17947 },
17948 );
17949 let config = Config {
17950 provider: Some("qa-gateway".to_string()),
17951 providers: Some(crate::config::ProvidersConfig {
17952 custom,
17953 ..Default::default()
17954 }),
17955 ..Config::default()
17956 };
17957
17958 assert_eq!(resolve_api_key_source(&config), ApiKeySource::EnvDeclared);
17959 assert_eq!(
17960 config.deepseek_api_key().expect("custom key"),
17961 "declared-env-key"
17962 );
17963 }
17964
17965 #[test]
17966 fn named_custom_provider_does_not_report_generic_secret_store() {
17967 let _lock = crate::test_support::lock_test_env();
17968 let temp = TempDir::new().expect("temp home");
17969 let codewhale_home = temp.path().join("codewhale-home");
17970 std::fs::create_dir_all(&codewhale_home).expect("create codewhale home");
17971 let _home =
17972 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", codewhale_home.as_os_str());
17973 let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
17974 let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
17975 let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
17976 codewhale_secrets::Secrets::auto_detect()
17977 .set("custom", "unrelated-custom-secret")
17978 .expect("save secret");
17979
17980 let mut custom = std::collections::HashMap::new();
17981 custom.insert(
17982 "qa-gateway".to_string(),
17983 crate::config::ProviderConfig {
17984 kind: Some("openai-compatible".to_string()),
17985 base_url: Some("https://gateway.example.test/v1".to_string()),
17986 model: Some("qa-model".to_string()),
17987 auth_mode: Some("api_key".to_string()),
17988 ..Default::default()
17989 },
17990 );
17991 let config = Config {
17992 provider: Some("qa-gateway".to_string()),
17993 providers: Some(crate::config::ProvidersConfig {
17994 custom,
17995 ..Default::default()
17996 }),
17997 ..Config::default()
17998 };
17999
18000 assert_eq!(resolve_api_key_source(&config), ApiKeySource::Unknown);
18001 assert!(config.deepseek_api_key().is_err());
18002 }
18003
18004 #[test]
18005 fn custom_built_in_endpoint_does_not_report_ambient_provider_key() {
18006 let _lock = crate::test_support::lock_test_env();
18007 let _openrouter =
18008 crate::test_support::EnvVarGuard::set("OPENROUTER_API_KEY", "ambient-key");
18009 let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
18010 let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
18011 let mut providers = crate::config::ProvidersConfig::default();
18012 providers.openrouter.base_url = Some("https://gateway.example.test/v1".to_string());
18013 let config = Config {
18014 provider: Some("openrouter".to_string()),
18015 providers: Some(providers),
18016 ..Config::default()
18017 };
18018
18019 assert_eq!(resolve_api_key_source(&config), ApiKeySource::Unknown);
18020 assert!(config.deepseek_api_key().is_err());
18021 }
18022
18023 #[test]
18024 fn ollama_doctor_credential_source_is_route_aware() {
18025 let local = Config {
18026 provider: Some("ollama".to_string()),
18027 ..Config::default()
18028 };
18029 assert_eq!(resolve_api_key_source(&local), ApiKeySource::LocalRuntime);
18030 assert_eq!(
18031 resolve_credential_diagnostic(&local).availability,
18032 CredentialAvailability::NotRequired
18033 );
18034
18035 let ollama_config = |base_url: &str| Config {
18036 provider: Some("ollama".to_string()),
18037 providers: Some(crate::config::ProvidersConfig {
18038 ollama: crate::config::ProviderConfig {
18039 base_url: Some(base_url.to_string()),
18040 ..Default::default()
18041 },
18042 ..Default::default()
18043 }),
18044 ..Config::default()
18045 };
18046 let cloud = ollama_config(codewhale_config::provider::OLLAMA_CLOUD_BASE_URL);
18047 assert_eq!(
18048 cloud.api_provider(),
18049 crate::config::ApiProvider::OllamaCloud
18050 );
18051 assert_eq!(
18052 resolve_api_key_source(&cloud),
18053 ApiKeySource::SecretStoreUnprobed
18054 );
18055 assert_eq!(
18056 resolve_credential_diagnostic(&cloud).availability,
18057 CredentialAvailability::NotProbed
18058 );
18059 assert_eq!(doctor_auth_scheme(&cloud), "bearer");
18060 let report = doctor_route_report(&cloud);
18061 assert_eq!(report["provider"], "ollama-cloud");
18062 assert_eq!(report["provider_config_table"], "ollama_cloud");
18063
18064 let custom_remote = ollama_config("https://ollama-gateway.example.test/v1");
18065 assert_eq!(
18066 resolve_api_key_source(&custom_remote),
18067 ApiKeySource::Unknown
18068 );
18069 assert_eq!(
18070 resolve_credential_diagnostic(&custom_remote).availability,
18071 CredentialAvailability::Unknown
18072 );
18073 }
18074
18075 #[test]
18076 fn auth_mode_none_reports_distinct_no_auth_source_and_scheme() {
18077 let _lock = crate::test_support::lock_test_env();
18078 let _openrouter =
18079 crate::test_support::EnvVarGuard::set("OPENROUTER_API_KEY", "ambient-key");
18080 let mut providers = crate::config::ProvidersConfig::default();
18081 providers.openrouter.auth_mode = Some("none".to_string());
18082 providers.openrouter.api_key = Some("configured-key".to_string());
18083 let config = Config {
18084 provider: Some("openrouter".to_string()),
18085 providers: Some(providers),
18086 ..Config::default()
18087 };
18088
18089 assert_eq!(resolve_api_key_source(&config), ApiKeySource::NoAuth);
18090 assert_eq!(doctor_api_key_source_label(ApiKeySource::NoAuth), "none");
18091 assert_eq!(doctor_auth_scheme(&config), "none");
18092 assert_eq!(config.deepseek_api_key().expect("no-auth route"), "");
18093 }
18094
18095 #[test]
18096 fn resolve_api_key_source_prefers_config_over_env() {
18097 let _guard = crate::test_support::lock_test_env();
18098 let prev = std::env::var("DEEPSEEK_API_KEY").ok();
18099 let prev_source = std::env::var("DEEPSEEK_API_KEY_SOURCE").ok();
18100 unsafe {
18101 std::env::set_var("DEEPSEEK_API_KEY", "stale-env-key");
18102 std::env::remove_var("DEEPSEEK_API_KEY_SOURCE");
18103 }
18104 let cfg = Config {
18105 api_key: Some("fresh-config-key".to_string()),
18106 ..Config::default()
18107 };
18108 let source = resolve_api_key_source(&cfg);
18109 match prev {
18110 Some(value) => unsafe { std::env::set_var("DEEPSEEK_API_KEY", value) },
18111 None => unsafe { std::env::remove_var("DEEPSEEK_API_KEY") },
18112 }
18113 match prev_source {
18114 Some(value) => unsafe { std::env::set_var("DEEPSEEK_API_KEY_SOURCE", value) },
18115 None => unsafe { std::env::remove_var("DEEPSEEK_API_KEY_SOURCE") },
18116 }
18117 assert_eq!(source, ApiKeySource::ConfigDeclared);
18118 }
18119
18120 #[test]
18121 fn resolve_api_key_source_reports_active_provider_env_from_metadata() {
18122 let _guard = crate::test_support::lock_test_env();
18123 let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
18124 let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
18125 let _anthropic_key =
18126 crate::test_support::EnvVarGuard::set("ANTHROPIC_API_KEY", "test-anthropic-key");
18127 let cfg = Config {
18128 provider: Some("anthropic".to_string()),
18129 ..Config::default()
18130 };
18131
18132 let source = resolve_api_key_source(&cfg);
18133
18134 assert_eq!(source, ApiKeySource::SecretStoreUnprobed);
18135 }
18136
18137 #[test]
18138 fn resolve_api_key_source_ignores_unresolved_provider_command_metadata() {
18139 let _guard = crate::test_support::lock_test_env();
18140 let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
18141 let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
18142 let _openai_key = crate::test_support::EnvVarGuard::remove("OPENAI_API_KEY");
18143 let mut providers = crate::config::ProvidersConfig::default();
18144 providers.openai.auth = Some(codewhale_config::ProviderAuthSourceToml {
18145 source: codewhale_config::AuthSourceKind::Command,
18146 command: vec!["secret-tool".to_string(), "lookup".to_string()],
18147 timeout_ms: Some(2000),
18148 secret_id: None,
18149 });
18150 let cfg = Config {
18151 provider: Some("openai".to_string()),
18152 providers: Some(providers),
18153 ..Config::default()
18154 };
18155
18156 let source = resolve_api_key_source(&cfg);
18157
18158 assert_eq!(source, ApiKeySource::ExternalAuthDeclared);
18159 assert!(cfg.deepseek_api_key().is_err());
18160 }
18161
18162 #[test]
18163 fn resolve_api_key_source_ignores_unresolved_provider_secret_metadata() {
18164 let _guard = crate::test_support::lock_test_env();
18165 let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
18166 let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
18167 let _openai_key = crate::test_support::EnvVarGuard::remove("OPENAI_API_KEY");
18168 let mut providers = crate::config::ProvidersConfig::default();
18169 providers.openai.auth = Some(codewhale_config::ProviderAuthSourceToml {
18170 source: codewhale_config::AuthSourceKind::Secret,
18171 command: Vec::new(),
18172 timeout_ms: None,
18173 secret_id: Some("codewhale/openai".to_string()),
18174 });
18175 let cfg = Config {
18176 provider: Some("openai".to_string()),
18177 providers: Some(providers),
18178 ..Config::default()
18179 };
18180
18181 let source = resolve_api_key_source(&cfg);
18182
18183 assert_eq!(source, ApiKeySource::ExternalAuthDeclared);
18184 assert!(cfg.deepseek_api_key().is_err());
18185 }
18186
18187 #[test]
18188 fn resolve_api_key_source_ignores_root_deepseek_key_for_other_provider() {
18189 let _guard = crate::test_support::lock_test_env();
18190 let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
18191 let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
18192 let _openrouter_key = crate::test_support::EnvVarGuard::remove("OPENROUTER_API_KEY");
18193 let cfg = Config {
18194 provider: Some("openrouter".to_string()),
18195 api_key: Some("legacy-deepseek-root-key".to_string()),
18196 ..Config::default()
18197 };
18198
18199 let source = resolve_api_key_source(&cfg);
18200
18201 assert_eq!(source, ApiKeySource::SecretStoreUnprobed);
18202 }
18203
18204 #[test]
18205 fn provider_status_helpers_use_provider_metadata() {
18206 assert_eq!(
18207 provider_config_table_key(crate::config::ApiProvider::Anthropic),
18208 "anthropic"
18209 );
18210 assert_eq!(
18211 provider_config_table_key(crate::config::ApiProvider::SiliconflowCn),
18212 "siliconflow_cn"
18213 );
18214 }
18215
18216 #[test]
18217 fn skills_count_for_returns_zero_for_missing_dir() {
18218 let tmp = TempDir::new().unwrap();
18219 let dir = tmp.path().join("nope");
18220 assert_eq!(skills_count_for(&dir), 0);
18221 }
18222
18223 #[test]
18224 fn skills_count_for_counts_valid_skill_dirs() {
18225 let tmp = TempDir::new().unwrap();
18226 let dir = tmp.path().join("skills");
18227 let skill_dir = dir.join("getting-started");
18228 std::fs::create_dir_all(&skill_dir).unwrap();
18229 std::fs::write(
18230 skill_dir.join("SKILL.md"),
18231 "---\nname: getting-started\ndescription: hi\n---\nbody",
18232 )
18233 .unwrap();
18234 assert_eq!(skills_count_for(&dir), 1);
18235 }
18236}
18237
18238#[cfg(test)]
18239#[path = "tests/pr_prompt.rs"]
18240mod pr_prompt_tests;
18241
18242#[cfg(test)]
18243#[path = "tests/telemetry_surface.rs"]
18244mod telemetry_surface_tests;
18245
18246#[cfg(test)]
18247#[path = "tests/telemetry_counters.rs"]
18248mod telemetry_counter_tests;