1#![allow(clippy::uninlined_format_args)]
4
5use std::collections::{BTreeSet, HashMap};
6use std::io::{self, IsTerminal, Read, Write};
7use std::path::{Path, PathBuf};
8use std::process::{Command, Stdio};
9use std::sync::Arc;
10use std::time::{Duration, Instant};
11
12use anyhow::{Context, Result, anyhow, bail};
13use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};
14use clap_complete::{Shell, generate};
15use tempfile::NamedTempFile;
16use wait_timeout::ChildExt;
17
18use crate::dependencies::ExternalTool;
19
20use rust_i18n::i18n;
21i18n!("locales", fallback = ["en"]);
22
23mod acp_server;
24mod artifacts;
25mod audit;
26mod auto_reasoning;
27mod automation_manager;
28mod child_env;
29mod client;
30mod codex_model_cache;
31mod command_safety;
32mod commands;
33mod compaction;
34mod composer_history;
35mod composer_stash;
36mod config;
37mod config_persistence;
38mod config_ui;
39mod context_budget;
40mod context_report;
41mod continual_harness;
42mod core;
43mod cost_status;
44mod deepseek_theme;
45mod dependencies;
46mod doctor;
47mod elapsed;
48mod error_taxonomy;
49mod eval;
50mod execpolicy;
51mod external_credentials;
52mod fast_hash;
53mod features;
54mod fleet;
55mod goal_loop;
56mod hashing;
57mod hooks;
58mod image_attach;
59mod lane_control;
60mod llm_client;
61mod llm_response_cache;
62mod localization;
63mod logging;
64mod lsp;
65mod mcp;
66mod mcp_server;
67mod model_catalog;
68mod model_context;
69mod model_inventory;
70mod model_profile;
71mod model_registry;
72mod model_routing;
73mod models;
74mod models_dev_live;
75mod native_memory;
76mod network_policy;
77mod oauth;
78mod palette;
79mod plugins;
80mod prefix_cache;
81mod pricing;
82mod project_context;
83mod project_context_cache;
84mod prompt_zones;
85mod prompts;
86mod provider_lake;
87mod provider_readiness;
88mod purge;
89mod regex_cache;
90mod remote_control;
91mod remote_setup;
92pub mod repl;
93mod repo_law;
94mod request_manifest;
95mod request_tuning;
96mod resource_telemetry;
97mod retry_status;
98pub mod rlm;
99mod route_billing;
100mod route_budget;
101mod route_receipt;
102mod route_runtime;
103mod runtime_api;
104mod runtime_handoff;
105mod runtime_log;
106mod runtime_policy;
107mod runtime_threads;
108mod safe_label;
109mod sandbox;
110mod scorecard;
111#[allow(dead_code)]
112mod session_diagnostics;
113#[cfg(test)]
117#[path = "main/tests.rs"]
118mod doctor_loader_tests;
119#[cfg(test)]
120mod session_control_acceptance;
121#[allow(dead_code)]
122mod session_manager;
123mod session_peek;
124mod session_projection;
125mod session_resume;
126pub mod session_tree;
127mod settings;
128mod shell_dispatcher;
129mod skill_state;
130mod skills;
131mod snapshot;
132mod startup_trace;
133mod task_manager;
134mod telemetry_notice;
135#[cfg(test)]
136mod test_support;
137mod tls;
138mod todo_snapshot;
139mod tool_history_repair;
140mod tool_inspection;
141mod tool_output_receipts;
142mod tools;
143mod tui;
144mod turn_route_plan;
145mod utils;
146mod vision;
147mod work_graph;
148mod worker_profile;
149mod working_set;
150mod workspace_discovery;
151mod workspace_trust;
152mod xai_oauth;
153
154use crate::config::{Config, DEFAULT_TEXT_MODEL, MAX_SUBAGENTS, effective_home_dir};
155use crate::eval::{EvalHarness, EvalHarnessConfig, ScenarioStepKind};
156use crate::features::{Feature, render_feature_table};
157use crate::llm_client::LlmClient;
158use crate::mcp::{
159 McpCommandAvailability, McpConfig, McpPool, McpServerConfig, McpServerOAuthConfig,
160 is_relative_stdio_path_arg,
161};
162use crate::models::{ContentBlock, Message, MessageRequest, SystemPrompt};
163use crate::session_manager::{SessionManager, create_saved_session, truncate_id};
164use crate::tui::history::{summarize_tool_args, summarize_tool_output};
165
166#[cfg(windows)]
167fn configure_windows_console_utf8() {
168 use windows::Win32::System::Console::{SetConsoleCP, SetConsoleOutputCP};
169
170 const CP_UTF8: u32 = 65001;
171 unsafe {
172 let _ = SetConsoleCP(CP_UTF8);
173 let _ = SetConsoleOutputCP(CP_UTF8);
174 }
175}
176
177#[cfg(not(windows))]
178fn configure_windows_console_utf8() {}
179
180fn install_rustls_crypto_provider() {
181 crate::tls::ensure_rustls_crypto_provider();
182}
183
184#[derive(Parser, Debug)]
185#[command(
186 name = "codewhale-tui",
187 bin_name = "codewhale-tui",
188 author,
189 version = env!("DEEPSEEK_BUILD_VERSION"),
190 about = "Codewhale terminal coding agent",
191 long_about = "Terminal-native TUI and CLI for open-source and open-weight coding models.\n\nRun 'codewhale' to start.\n\nProvider routes include DeepSeek, Arcee, Hugging Face, OpenRouter, Xiaomi MiMo, local vLLM/SGLang/Ollama, and more."
192)]
193struct Cli {
194 #[command(subcommand)]
196 command: Option<Commands>,
197
198 #[command(flatten)]
199 feature_toggles: FeatureToggles,
200
201 #[arg(short, long, value_name = "PROMPT", num_args = 1..)]
203 prompt: Vec<String>,
204
205 #[arg(long, hide = true)]
207 yolo: bool,
208
209 #[arg(long)]
211 max_subagents: Option<usize>,
212
213 #[arg(long)]
215 config: Option<PathBuf>,
216
217 #[arg(short, long)]
219 verbose: bool,
220
221 #[arg(long)]
223 profile: Option<String>,
224
225 #[arg(short, long)]
227 workspace: Option<PathBuf>,
228
229 #[arg(short, long)]
231 resume: Option<String>,
232
233 #[arg(short = 'c', long = "continue")]
235 continue_session: bool,
236
237 #[arg(long = "mouse-capture", conflicts_with = "no_mouse_capture")]
241 mouse_capture: bool,
242
243 #[arg(long = "no-mouse-capture", conflicts_with = "mouse_capture")]
245 no_mouse_capture: bool,
246
247 #[arg(long)]
249 skip_onboarding: bool,
250
251 #[arg(long, hide = true)]
253 remote_control: bool,
254
255 #[arg(long = "fresh")]
257 fresh: bool,
258
259 #[arg(long = "no-project-config")]
261 no_project_config: bool,
262}
263
264#[derive(Subcommand, Debug, Clone)]
265#[allow(clippy::large_enum_variant)]
266enum Commands {
267 Doctor(DoctorArgs),
269 SessionDiagnostics(SessionDiagnosticsArgs),
271 Setup(SetupArgs),
273 RemoteSetup(remote_setup::RemoteSetupArgs),
275 Completions {
277 #[arg(value_enum)]
279 shell: Shell,
280 },
281 Sessions {
283 #[arg(short, long, default_value = "20")]
285 limit: usize,
286 #[arg(short, long)]
288 search: Option<String>,
289 },
290 Init,
292 Login {
294 #[arg(long)]
296 api_key: Option<String>,
297 },
298 Logout,
300 Auth(TuiAuthArgs),
302 Models(ModelsArgs),
304 #[command(visible_alias = "tts")]
306 Speech(SpeechArgs),
307 Exec(ExecArgs),
309 Fleet(FleetArgs),
311 #[command(name = "workflow-tool", hide = true)]
313 WorkflowTool(WorkflowToolArgs),
314 Review(ReviewArgs),
316 Pr {
318 #[arg(value_name = "NUMBER")]
320 number: u32,
321 #[arg(short = 'R', long)]
324 repo: Option<String>,
325 #[arg(long, default_value_t = false)]
329 checkout: bool,
330 },
331 Apply(ApplyArgs),
333 Eval(EvalArgs),
335 Scorecard(ScorecardArgs),
337 Mcp {
339 #[command(subcommand)]
340 command: McpCommand,
341 },
342 Features(FeaturesCli),
344 Sandbox(SandboxArgs),
346 Serve(ServeArgs),
348 Resume {
350 #[arg(value_name = "SESSION_ID")]
352 session_id: Option<String>,
353 #[arg(long = "last", default_value_t = false, conflicts_with = "session_id")]
355 last: bool,
356 },
357 Fork {
359 #[arg(value_name = "SESSION_ID")]
361 session_id: Option<String>,
362 #[arg(long = "last", default_value_t = false, conflicts_with = "session_id")]
364 last: bool,
365 },
366}
367
368#[derive(Args, Debug, Clone)]
369#[command(after_help = "\
370Examples:
371 codewhale exec \"explain this function\"
372 codewhale exec --auto \"list crates/ with ls\"
373 codewhale exec --auto --output-format stream-json \"fix the failing test\"
374
375Plain `codewhale exec` is a one-shot model response. Use `--auto` for
376non-interactive agent-with-tools execution. `--auto` does not change the
377sandbox posture or elevate a denied tool. Use `--sandbox danger-full-access`
378or `--allow-sandbox-elevation` to explicitly authorize sandbox elevation.
379")]
380struct ExecArgs {
381 #[arg(long)]
383 model: Option<String>,
384 #[arg(long)]
390 provider: Option<String>,
391 #[arg(long = "reasoning-effort", value_name = "EFFORT")]
394 reasoning_effort: Option<String>,
395 #[arg(long, default_value_t = false)]
398 auto: bool,
399 #[arg(long, value_name = "POLICY")]
401 sandbox: Option<String>,
402 #[arg(long, default_value_t = false)]
404 allow_sandbox_elevation: bool,
405 #[arg(long, default_value_t = false, conflicts_with = "output_format")]
407 json: bool,
408 #[arg(long, value_name = "SESSION_ID", conflicts_with_all = ["session_id", "continue_session"])]
410 resume: Option<String>,
411 #[arg(long = "session-id", value_name = "SESSION_ID", conflicts_with_all = ["resume", "continue_session"])]
413 session_id: Option<String>,
414 #[arg(long = "continue", default_value_t = false, conflicts_with_all = ["resume", "session_id"])]
416 continue_session: bool,
417 #[arg(long, value_enum, default_value_t = ExecOutputFormat::Text)]
419 output_format: ExecOutputFormat,
420 #[arg(long, value_delimiter = ',')]
423 allowed_tools: Option<Vec<String>>,
424 #[arg(long, value_delimiter = ',')]
426 disallowed_tools: Option<Vec<String>>,
427 #[arg(long, value_parser = clap::value_parser!(u32).range(1..))]
429 max_turns: Option<u32>,
430 #[arg(long)]
432 append_system_prompt: Option<String>,
433 #[arg(long, value_name = "JSON", hide = true)]
435 tool_authority_json: Option<String>,
436 #[arg(
438 value_name = "PROMPT",
439 required = true,
440 trailing_var_arg = true,
441 allow_hyphen_values = true
442 )]
443 prompt: Vec<String>,
444}
445
446#[derive(Args, Debug, Clone)]
447struct WorkflowToolArgs {
448 #[arg(long, value_name = "SOURCE")]
450 approval_source: String,
451 #[arg(long, value_name = "JSON")]
453 input_json: String,
454}
455
456#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
457enum ExecOutputFormat {
458 Text,
459 #[value(name = "stream-json")]
460 StreamJson,
461}
462
463#[derive(Args, Debug, Clone)]
464struct TuiAuthArgs {
465 #[command(subcommand)]
466 command: TuiAuthCommand,
467}
468
469#[derive(Subcommand, Debug, Clone)]
470enum TuiAuthCommand {
471 #[command(name = "xai-device")]
473 XaiDevice,
474}
475
476const CODEWHALE_TOOL_SURFACE_ENV: &str = "CODEWHALE_TOOL_SURFACE";
477const SHELL_ONLY_EXEC_TOOLS: &[&str] = &["bash"];
478
479#[derive(Debug, Clone, Copy, PartialEq, Eq)]
480enum ExecToolSurface {
481 ShellOnly,
482}
483
484fn exec_tool_surface_from_env() -> Option<ExecToolSurface> {
485 std::env::var(CODEWHALE_TOOL_SURFACE_ENV)
486 .ok()
487 .and_then(|value| {
488 if should_warn_unknown_exec_tool_surface(&value) {
489 eprintln!(
490 "warning: unrecognized {CODEWHALE_TOOL_SURFACE_ENV}; leaving exec tool surface unchanged. Use `shell-only`, `full`, or `native-tools`."
491 );
492 }
493 parse_exec_tool_surface(&value)
494 })
495}
496
497fn parse_exec_tool_surface(value: &str) -> Option<ExecToolSurface> {
498 match value.trim().to_ascii_lowercase().as_str() {
499 "shell-only" | "shell_only" | "shell" => Some(ExecToolSurface::ShellOnly),
500 "full" | "native-tools" | "native_tools" | "" => None,
501 _ => None,
502 }
503}
504
505fn should_warn_unknown_exec_tool_surface(value: &str) -> bool {
506 let normalized = value.trim().to_ascii_lowercase();
507 !matches!(
508 normalized.as_str(),
509 "" | "shell-only" | "shell_only" | "shell" | "full" | "native-tools" | "native_tools"
510 )
511}
512
513fn normalize_exec_tool_names(tools: &[String]) -> Vec<String> {
514 tools
515 .iter()
516 .map(|name| name.to_ascii_lowercase().trim().to_string())
517 .collect()
518}
519
520fn shell_only_exec_allowed_tools() -> Vec<String> {
521 SHELL_ONLY_EXEC_TOOLS
522 .iter()
523 .map(|name| (*name).to_string())
524 .collect()
525}
526
527fn resolve_exec_allowed_tools(
528 cli_allowed_tools: Option<&[String]>,
529 env_tool_surface: Option<ExecToolSurface>,
530) -> Option<Vec<String>> {
531 if let Some(tools) = cli_allowed_tools {
532 return Some(normalize_exec_tool_names(tools));
533 }
534
535 env_tool_surface.map(|ExecToolSurface::ShellOnly| shell_only_exec_allowed_tools())
536}
537
538#[derive(Args, Debug, Clone)]
539struct FleetArgs {
540 #[command(subcommand)]
541 command: FleetCommand,
542}
543
544#[derive(Subcommand, Debug, Clone)]
545enum FleetCommand {
546 Init,
548 Run(FleetRunArgs),
550 List,
552 Status,
554 Inspect {
556 worker_id: String,
558 },
559 Logs {
561 worker_id: String,
563 },
564 Artifacts {
566 worker_id: String,
568 },
569 Interrupt {
571 worker_id: String,
573 },
574 Restart {
576 worker_id: String,
578 },
579 Resume {
581 run_id: String,
583 #[arg(long, default_value_t = 300)]
585 stale_after_seconds: u64,
586 },
587 Stop {
589 #[arg(long, required = true)]
591 all: bool,
592 },
593 AlertDryRun(FleetAlertDryRunArgs),
595}
596
597#[derive(Args, Debug, Clone)]
598struct FleetRunArgs {
599 #[arg(value_name = "TASK_SPEC")]
601 task_spec: PathBuf,
602 #[arg(long, default_value_t = 4)]
604 max_workers: usize,
605 #[arg(long, default_value_t = 300)]
607 stale_after_seconds: u64,
608 #[arg(long, hide = true, default_value_t = false)]
610 once: bool,
611}
612
613#[derive(Args, Debug, Clone)]
614struct FleetAlertDryRunArgs {
615 #[arg(long, value_enum)]
617 event: FleetAlertEventArg,
618 #[arg(long)]
620 run_id: String,
621 #[arg(long)]
623 worker_id: Option<String>,
624 #[arg(long)]
626 task_id: Option<String>,
627 #[arg(long, default_value = "manual fleet alert dry-run")]
629 reason: String,
630 #[arg(long)]
632 status: Option<String>,
633 #[arg(long, value_enum, default_value_t = FleetAlertAdapterArg::Slack)]
635 adapter: FleetAlertAdapterArg,
636 #[arg(long, default_value = "CODEWHALE_FLEET_SLACK_WEBHOOK")]
638 slack_webhook_env: String,
639 #[arg(long, default_value = "CODEWHALE_FLEET_WEBHOOK_URL")]
641 webhook_url_env: String,
642 #[arg(long)]
644 webhook_secret_env: Option<String>,
645 #[arg(long, default_value = "CODEWHALE_FLEET_PAGERDUTY_ROUTING_KEY")]
647 pagerduty_routing_key_env: String,
648 #[arg(long, default_value = "error")]
650 pagerduty_severity: String,
651}
652
653#[derive(ValueEnum, Debug, Clone, Copy)]
654enum FleetAlertEventArg {
655 Stale,
656 RestartExhausted,
657 NeedsHuman,
658 BudgetExceeded,
659 VerifierFailed,
660 RunCompleted,
661}
662
663#[derive(ValueEnum, Debug, Clone, Copy)]
664enum FleetAlertAdapterArg {
665 Slack,
666 Webhook,
667 PagerDuty,
668}
669
670fn spawn_signal_cleanup_task() {
687 let signals = TerminatingSignals::register();
688 tokio::spawn(async move {
689 let exit_code = signals.wait().await;
690 static CLEANED_UP: std::sync::atomic::AtomicBool =
694 std::sync::atomic::AtomicBool::new(false);
695 if !CLEANED_UP.swap(true, std::sync::atomic::Ordering::SeqCst) {
696 #[cfg(unix)]
697 crate::tools::shell::abort_pending_persistent_process_groups_for_exit();
698 crate::tui::ui::emergency_restore_terminal();
699 record_signal_session_end();
710 }
711 std::process::exit(exit_code);
712 });
713}
714
715static TELEMETRY_SESSION_START: std::sync::OnceLock<std::time::Instant> =
718 std::sync::OnceLock::new();
719
720fn telemetry_session_end() -> codewhale_telemetry::Event {
731 let counters = codewhale_telemetry::session_counters();
732 codewhale_telemetry::Event::SessionEnd {
733 duration_bucket: codewhale_telemetry::DurationBucket::from_secs(
734 TELEMETRY_SESSION_START
735 .get()
736 .map_or(0, |start| start.elapsed().as_secs()),
737 ),
738 exit_class: codewhale_telemetry::exit_class(),
739 cold_start_bucket: crate::startup_trace::cold_start_ms()
740 .map(codewhale_telemetry::ColdStartBucket::from_millis),
741 providers: counters.providers(),
742 counters: counters.counters(),
743 errors: counters.errors(),
744 turn_wall: counters.turn_wall(),
745 }
746}
747
748fn record_signal_session_end() {
752 codewhale_telemetry::set_exit_class(codewhale_telemetry::ExitClass::Signal);
753 codewhale_telemetry::record_blocking(telemetry_session_end());
754}
755
756#[cfg(unix)]
761struct TerminatingSignals {
762 sigint: Option<tokio::signal::unix::Signal>,
763 sigterm: Option<tokio::signal::unix::Signal>,
764 sighup: Option<tokio::signal::unix::Signal>,
765}
766
767#[cfg(unix)]
768impl TerminatingSignals {
769 fn register() -> Self {
772 use tokio::signal::unix::{SignalKind, signal};
773 Self {
774 sigint: signal(SignalKind::interrupt()).ok(),
775 sigterm: signal(SignalKind::terminate()).ok(),
776 sighup: signal(SignalKind::hangup()).ok(),
777 }
778 }
779
780 async fn wait(mut self) -> i32 {
784 tokio::select! {
785 _ = async { match self.sigint.as_mut() { Some(s) => { s.recv().await; }, None => std::future::pending::<()>().await, } } => 130,
786 _ = async { match self.sigterm.as_mut() { Some(s) => { s.recv().await; }, None => std::future::pending::<()>().await, } } => 143,
787 _ = async { match self.sighup.as_mut() { Some(s) => { s.recv().await; }, None => std::future::pending::<()>().await, } } => 129,
788 }
789 }
790}
791
792#[cfg(not(unix))]
796struct TerminatingSignals {
797 ctrl_c: Option<tokio::signal::windows::CtrlC>,
798}
799
800#[cfg(not(unix))]
801impl TerminatingSignals {
802 fn register() -> Self {
803 Self {
804 ctrl_c: tokio::signal::windows::ctrl_c().ok(),
805 }
806 }
807
808 async fn wait(mut self) -> i32 {
809 match self.ctrl_c.as_mut() {
810 Some(s) => {
811 s.recv().await;
812 }
813 None => std::future::pending::<()>().await,
814 }
815 130
816 }
817}
818
819fn join_prompt_parts(parts: &[String]) -> String {
820 parts.join(" ")
821}
822
823fn resolve_exec_model(config: &Config, explicit_model: Option<&str>) -> String {
824 explicit_model
825 .map(str::trim)
826 .filter(|model| !model.is_empty())
827 .map(ToOwned::to_owned)
828 .or_else(exec_model_env_override)
829 .unwrap_or_else(|| config.default_model())
830}
831
832fn apply_exec_provider_override(config: &mut Config, provider_arg: &str) -> Result<()> {
833 let provider_arg = provider_arg.trim();
834 if provider_arg.is_empty() {
835 return Ok(());
836 }
837 if config
838 .providers
839 .as_ref()
840 .and_then(|providers| providers.custom_provider_config(provider_arg))
841 .is_some()
842 {
843 config.provider = Some(provider_arg.to_string());
844 return Ok(());
845 }
846 if let Some(provider) = crate::config::ApiProvider::parse(provider_arg) {
847 config.provider = Some(provider.as_str().to_string());
848 return Ok(());
849 }
850 bail!(
851 "Unrecognized --provider {provider_arg:?}. Known providers: {} \
852 or a configured [providers.<name>] custom provider",
853 crate::config::ApiProvider::names_hint()
854 );
855}
856
857fn exec_model_env_override() -> Option<String> {
858 let read = || {
859 ["CODEWHALE_MODEL", "DEEPSEEK_MODEL"]
860 .into_iter()
861 .find_map(|key| {
862 std::env::var(key)
863 .ok()
864 .map(|model| model.trim().to_string())
865 .filter(|model| !model.is_empty())
866 })
867 };
868 #[cfg(test)]
869 {
870 crate::test_support::with_test_env_lock(read)
871 }
872 #[cfg(not(test))]
873 {
874 read()
875 }
876}
877
878fn top_level_prompt_initial_input(parts: &[String]) -> Option<tui::InitialInput> {
879 (!parts.is_empty()).then(|| tui::InitialInput::Submit(join_prompt_parts(parts)))
880}
881
882fn resolve_exec_resume_session_id(args: &ExecArgs, workspace: &Path) -> Result<Option<String>> {
883 if let Some(id) = args.resume.as_ref().or(args.session_id.as_ref()) {
884 return Ok(Some(id.clone()));
885 }
886 if !args.continue_session {
887 return Ok(None);
888 }
889 latest_session_id_for_workspace(workspace)?.map_or_else(
890 || {
891 bail!(
892 "No saved sessions found for workspace {}. Use `codewhale sessions` to list sessions, or pass `codewhale exec --resume <SESSION_ID> ...`.",
893 workspace.display()
894 )
895 },
896 |id| Ok(Some(id)),
897 )
898}
899
900fn load_exec_resume_session(session_id: &str) -> Result<session_manager::SavedSession> {
901 let session_ref = exec_stream_session_ref(session_id);
902 SessionManager::default_location()
903 .context("could not open session manager for resume")?
904 .load_session_by_prefix(session_id)
905 .with_context(|| format!("could not load session {session_ref}"))
906}
907
908fn resolve_exec_resume_route(
916 config: &mut Config,
917 saved: &session_manager::SavedSession,
918 explicit_provider: bool,
919 explicit_model: Option<&str>,
920) -> Result<String> {
921 if !explicit_provider {
922 let saved_provider_identity = saved
923 .metadata
924 .model_provider_id
925 .as_deref()
926 .filter(|identity| !identity.trim().is_empty())
927 .unwrap_or(&saved.metadata.model_provider);
928 let identity = config
929 .resolve_persisted_provider_identity(
930 Some(&saved.metadata.model_provider),
931 saved.metadata.model_provider_id.as_deref(),
932 )
933 .map_err(anyhow::Error::msg)
934 .with_context(|| {
935 format!(
936 "saved session provider '{}' is unavailable; Codewhale will not fall back",
937 saved_provider_identity
938 )
939 })?;
940 config.scope_to_provider_identity(&identity);
941 }
942
943 if let Some(model) = explicit_model {
944 return Ok(resolve_exec_model(config, Some(model)));
945 }
946 if explicit_provider {
947 return Ok(resolve_exec_model(config, None));
948 }
949 Ok(saved.metadata.model.clone())
950}
951
952#[derive(Args, Debug, Clone, Default)]
953struct SetupArgs {
954 #[arg(long, default_value_t = false)]
956 mcp: bool,
957 #[arg(long, default_value_t = false)]
959 skills: bool,
960 #[arg(long, default_value_t = false)]
962 tools: bool,
963 #[arg(long, default_value_t = false)]
965 plugins: bool,
966 #[arg(long, default_value_t = false)]
968 all: bool,
969 #[arg(long, default_value_t = false)]
971 local: bool,
972 #[arg(long, default_value_t = false)]
974 force: bool,
975 #[arg(long, default_value_t = false, conflicts_with_all = ["mcp", "skills", "tools", "plugins", "all", "local", "clean"])]
977 status: bool,
978 #[arg(long, default_value_t = false, conflicts_with_all = ["mcp", "skills", "tools", "plugins", "all", "local", "status"])]
980 clean: bool,
981}
982
983#[derive(Args, Debug, Clone, Default)]
984struct DoctorArgs {
985 #[arg(long, default_value_t = false)]
987 json: bool,
988 #[arg(long, default_value_t = false, conflicts_with = "json")]
990 context_json: bool,
991 #[arg(
993 long,
994 default_value_t = false,
995 conflicts_with_all = ["json", "context_json"]
996 )]
997 probe_local: bool,
998 #[arg(
1000 long,
1001 default_value_t = false,
1002 conflicts_with_all = ["json", "context_json"]
1003 )]
1004 probe_api: bool,
1005 #[arg(
1007 long,
1008 default_value_t = false,
1009 conflicts_with_all = ["json", "context_json"]
1010 )]
1011 check_updates: bool,
1012 #[arg(
1014 long,
1015 default_value_t = false,
1016 conflicts_with_all = ["json", "context_json"]
1017 )]
1018 probe_mcp: bool,
1019}
1020
1021#[derive(Args, Debug, Clone)]
1022struct SessionDiagnosticsArgs {
1023 #[arg(value_name = "JSONL")]
1025 path: PathBuf,
1026 #[arg(long, default_value_t = false)]
1028 json: bool,
1029}
1030
1031#[derive(Args, Debug, Clone)]
1032struct ScorecardArgs {
1033 #[arg(long, value_name = "FILE")]
1041 input: PathBuf,
1042 #[arg(long, value_name = "FILE")]
1045 baseline: Option<PathBuf>,
1046 #[arg(long, default_value_t = 5.0)]
1048 threshold: f64,
1049 #[arg(long, default_value_t = false)]
1051 json: bool,
1052}
1053
1054#[derive(Args, Debug, Clone)]
1055struct EvalArgs {
1056 #[arg(long, value_name = "STEP")]
1058 fail_step: Option<String>,
1059 #[arg(long, default_value = "printf eval-harness")]
1061 shell_command: String,
1062 #[arg(long, default_value = "eval-harness")]
1064 shell_expect_token: String,
1065 #[arg(long, default_value_t = 240)]
1067 max_output_chars: usize,
1068 #[arg(long, default_value_t = false)]
1070 json: bool,
1071 #[arg(long, value_name = "DIR")]
1074 record: Option<PathBuf>,
1075}
1076
1077#[derive(Args, Debug, Clone, Default)]
1078struct ModelsArgs {
1079 #[arg(long, default_value_t = false)]
1081 json: bool,
1082}
1083
1084#[derive(Args, Debug, Clone)]
1085struct SpeechArgs {
1086 #[arg(value_name = "TEXT")]
1088 text: String,
1089
1090 #[arg(short, long, value_name = "FILE")]
1093 output: Option<PathBuf>,
1094
1095 #[arg(long = "output-dir", value_name = "DIR")]
1097 output_dir: Option<PathBuf>,
1098
1099 #[arg(long)]
1101 model: Option<String>,
1102
1103 #[arg(long)]
1105 voice: Option<String>,
1106
1107 #[arg(long)]
1109 instruction: Option<String>,
1110
1111 #[arg(long = "voice-prompt")]
1113 voice_prompt: Option<String>,
1114
1115 #[arg(long = "clone-voice", value_name = "FILE")]
1117 clone_voice: Option<PathBuf>,
1118
1119 #[arg(long, default_value = "wav")]
1121 format: String,
1122
1123 #[arg(long, default_value_t = false)]
1125 json: bool,
1126}
1127
1128#[derive(Args, Debug, Default, Clone)]
1129struct FeatureToggles {
1130 #[arg(long = "enable", value_name = "FEATURE", action = clap::ArgAction::Append, global = true)]
1132 enable: Vec<String>,
1133
1134 #[arg(long = "disable", value_name = "FEATURE", action = clap::ArgAction::Append, global = true)]
1136 disable: Vec<String>,
1137}
1138
1139impl FeatureToggles {
1140 fn apply(&self, config: &mut Config) -> Result<()> {
1141 for feature in &self.enable {
1142 config.set_feature(feature, true)?;
1143 }
1144 for feature in &self.disable {
1145 config.set_feature(feature, false)?;
1146 }
1147 Ok(())
1148 }
1149}
1150
1151#[derive(Args, Debug, Clone)]
1152struct ReviewArgs {
1153 #[arg(long, conflicts_with = "base")]
1155 staged: bool,
1156 #[arg(long)]
1158 base: Option<String>,
1159 #[arg(long)]
1161 path: Option<PathBuf>,
1162 #[arg(long)]
1164 model: Option<String>,
1165 #[arg(long, default_value_t = 200_000)]
1167 max_chars: usize,
1168 #[arg(long, default_value_t = false)]
1170 write_receipt: bool,
1171 #[arg(long, default_value_t = false)]
1173 check_receipt: bool,
1174 #[arg(long)]
1176 receipt_path: Option<PathBuf>,
1177 #[arg(long, default_value_t = false)]
1179 json: bool,
1180}
1181
1182#[derive(Args, Debug, Clone)]
1183struct ApplyArgs {
1184 #[arg(value_name = "PATCH_FILE")]
1186 patch_file: Option<PathBuf>,
1187}
1188
1189#[derive(Args, Debug, Clone)]
1190struct ServeArgs {
1191 #[arg(long)]
1193 mcp: bool,
1194 #[arg(long)]
1196 http: bool,
1197 #[arg(long)]
1199 mobile: bool,
1200 #[arg(long)]
1202 web: bool,
1203 #[arg(long, requires = "mobile")]
1205 qr: bool,
1206 #[arg(long)]
1208 acp: bool,
1209 #[arg(long)]
1211 host: Option<String>,
1212 #[arg(long, default_value_t = 7878)]
1214 port: u16,
1215 #[arg(long, default_value_t = 2)]
1217 workers: usize,
1218 #[arg(long = "cors-origin", value_name = "URL")]
1224 cors_origin: Vec<String>,
1225 #[arg(long = "auth-token", value_name = "TOKEN")]
1229 auth_token: Option<String>,
1230 #[arg(long = "insecure")]
1232 insecure_no_auth: bool,
1233}
1234
1235#[derive(Debug, Clone, PartialEq, Eq)]
1236struct ServeBindHost {
1237 host: String,
1238 mobile_rebound_to_lan: bool,
1239}
1240
1241fn resolve_serve_bind_host(mobile: bool, host: Option<String>) -> ServeBindHost {
1242 match (mobile, host) {
1243 (true, None) => ServeBindHost {
1244 host: "0.0.0.0".to_string(),
1245 mobile_rebound_to_lan: true,
1246 },
1247 (_, Some(host)) => ServeBindHost {
1248 host,
1249 mobile_rebound_to_lan: false,
1250 },
1251 (false, None) => ServeBindHost {
1252 host: "127.0.0.1".to_string(),
1253 mobile_rebound_to_lan: false,
1254 },
1255 }
1256}
1257
1258fn validate_serve_mode_selection(
1259 mcp: bool,
1260 http: bool,
1261 mobile: bool,
1262 web: bool,
1263 acp: bool,
1264) -> Result<bool> {
1265 if http && mobile {
1266 bail!("--http and --mobile are mutually exclusive; choose one");
1267 }
1268 if web && (http || mobile) {
1269 bail!("--web is mutually exclusive with --http and --mobile");
1270 }
1271 let http_selected = http || mobile || web;
1272 let selected_modes = [mcp, http_selected, acp]
1273 .into_iter()
1274 .filter(|selected| *selected)
1275 .count();
1276 if selected_modes != 1 {
1277 bail!("Choose exactly one server mode: --mcp, --http/--mobile/--web, or --acp");
1278 }
1279 Ok(http_selected)
1280}
1281
1282#[derive(Subcommand, Debug, Clone)]
1283enum McpCommand {
1284 List,
1286 Init {
1288 #[arg(long, default_value_t = false)]
1290 force: bool,
1291 },
1292 Connect {
1294 #[arg(value_name = "SERVER")]
1296 server: Option<String>,
1297 },
1298 Tools {
1300 #[arg(value_name = "SERVER")]
1302 server: Option<String>,
1303 },
1304 Add {
1306 name: String,
1308 #[arg(long, conflicts_with = "url")]
1310 command: Option<String>,
1311 #[arg(long, conflicts_with = "command")]
1313 url: Option<String>,
1314 #[arg(long, requires = "url")]
1316 transport: Option<String>,
1317 #[arg(long, requires = "url")]
1319 bearer_token_env_var: Option<String>,
1320 #[arg(long, requires = "url")]
1322 oauth_client_id: Option<String>,
1323 #[arg(long, requires = "url")]
1325 oauth_resource: Option<String>,
1326 #[arg(long = "scope", requires = "url", value_delimiter = ',')]
1328 scopes: Vec<String>,
1329 #[arg(long = "arg")]
1331 args: Vec<String>,
1332 },
1333 Login {
1335 name: String,
1337 #[arg(long = "scope", value_delimiter = ',')]
1339 scopes: Vec<String>,
1340 },
1341 Logout {
1343 name: String,
1345 },
1346 Remove {
1348 name: String,
1350 },
1351 Enable {
1353 name: String,
1355 },
1356 Disable {
1358 name: String,
1360 },
1361 Validate,
1363 #[command(
1368 name = "add-self",
1369 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."
1370 )]
1371 AddSelf {
1372 #[arg(long, default_value = "codewhale")]
1374 name: String,
1375 #[arg(long)]
1377 workspace: Option<String>,
1378 },
1379}
1380
1381#[derive(Args, Debug, Clone)]
1382struct FeaturesCli {
1383 #[command(subcommand)]
1384 command: FeaturesSubcommand,
1385}
1386
1387#[derive(Subcommand, Debug, Clone)]
1388enum FeaturesSubcommand {
1389 List,
1391}
1392
1393#[derive(Args, Debug, Clone)]
1394struct SandboxArgs {
1395 #[command(subcommand)]
1396 command: SandboxCommand,
1397}
1398
1399#[derive(Subcommand, Debug, Clone)]
1400enum SandboxCommand {
1401 Run {
1403 #[arg(long, default_value = "workspace-write")]
1405 policy: String,
1406 #[arg(long)]
1408 network: bool,
1409 #[arg(long, value_name = "PATH")]
1411 writable_root: Vec<PathBuf>,
1412 #[arg(long)]
1414 exclude_tmpdir: bool,
1415 #[arg(long)]
1417 exclude_slash_tmp: bool,
1418 #[arg(long)]
1420 cwd: Option<PathBuf>,
1421 #[arg(long, default_value_t = 60_000)]
1423 timeout_ms: u64,
1424 #[arg(required = true, trailing_var_arg = true)]
1426 command: Vec<String>,
1427 },
1428}
1429
1430const CODEWHALE_MAIN_STACK_BYTES: usize = 16 * 1024 * 1024;
1431
1432pub fn run(args: Vec<String>) -> std::process::ExitCode {
1436 match run_with_args(args) {
1437 Ok(()) => std::process::ExitCode::SUCCESS,
1438 Err(err) => {
1439 eprintln!("error: {err}");
1440 for cause in err.chain().skip(1) {
1441 eprintln!(" caused by: {cause}");
1442 }
1443 std::process::ExitCode::FAILURE
1444 }
1445 }
1446}
1447
1448fn run_with_args(args: Vec<String>) -> Result<()> {
1452 #[cfg(unix)]
1456 unsafe {
1457 libc::signal(libc::SIGPIPE, libc::SIG_DFL);
1458 }
1459
1460 startup_trace::mark_process_start();
1461 configure_windows_console_utf8();
1462 install_rustls_crypto_provider();
1463
1464 crate::sandbox::process_hardening::apply_process_hardening();
1468
1469 let orig_hook = std::panic::take_hook();
1474 std::panic::set_hook(Box::new(move |panic_info| {
1475 crate::tui::ui::emergency_restore_terminal();
1481
1482 let msg = if let Some(s) = panic_info.payload().downcast_ref::<&str>() {
1483 s.to_string()
1484 } else if let Some(s) = panic_info.payload().downcast_ref::<String>() {
1485 s.clone()
1486 } else {
1487 format!("{:?}", panic_info.payload())
1488 };
1489 let location = panic_info
1490 .location()
1491 .map(|loc| loc.to_string())
1492 .unwrap_or_else(|| "unknown".to_string());
1493 tracing::error!(target: "panic", "Process panicked at {location}: {msg}");
1494
1495 codewhale_telemetry::set_exit_class(codewhale_telemetry::ExitClass::Panic);
1506 if let Some(site) = panic_info
1507 .location()
1508 .map(|loc| codewhale_telemetry::reduce_panic_site(loc.file(), loc.line(), loc.column()))
1509 {
1510 codewhale_telemetry::record_blocking(codewhale_telemetry::Event::Panic { site });
1511 }
1512 if let Some(home) = crate::config::effective_home_dir() {
1514 let crash_dir = home.join(".deepseek").join("crashes");
1515 let _ = std::fs::create_dir_all(&crash_dir);
1516 use chrono::Utc;
1517 let ts = Utc::now().format("%Y%m%dT%H%M%S%.3fZ");
1518 let path = crash_dir.join(format!("{ts}-process-panic.log"));
1519 let contents =
1520 format!("Process panicked\nLocation: {location}\nTimestamp: {ts}\nPanic: {msg}\n",);
1521 let _ = std::fs::write(&path, contents);
1522 }
1523 orig_hook(panic_info);
1525 }));
1526
1527 let cli = match Cli::try_parse_from(args) {
1534 Ok(c) => c,
1535 Err(e) => {
1536 e.exit();
1537 }
1538 };
1539 crate::fleet::roster::set_project_agent_profiles_enabled(!cli.no_project_config);
1544 let workspace = resolve_workspace(&cli);
1545 let mut plugin_discovery = None;
1546 let mut plugin_registry = None;
1547 let (cli, command) = prepare_cli_startup(
1548 cli,
1549 || {
1550 let discovery = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv();
1551 plugin_registry = Some(discovery.registry_for_workspace(&workspace));
1552 plugin_discovery = Some(discovery);
1553 },
1554 warn_on_workspace_dotenv_result,
1555 );
1556 let plugin_discovery = plugin_discovery
1557 .expect("plugin discovery initialization must precede workspace dotenv loading");
1558 let plugin_registry = plugin_registry
1559 .expect("plugin discovery initialization must precede workspace dotenv loading");
1560
1561 let runtime_thread = std::thread::Builder::new()
1569 .name("codewhale-main".to_string())
1570 .stack_size(CODEWHALE_MAIN_STACK_BYTES)
1571 .spawn(move || run_async_main(cli, command, plugin_discovery, plugin_registry))
1572 .context("Failed to start the Codewhale runtime thread")?;
1573 match runtime_thread.join() {
1574 Ok(result) => result,
1575 Err(payload) => {
1576 let message = payload
1577 .downcast_ref::<&str>()
1578 .map(|value| (*value).to_string())
1579 .or_else(|| payload.downcast_ref::<String>().cloned())
1580 .unwrap_or_else(|| "unknown panic payload".to_string());
1581 Err(anyhow!("Codewhale runtime thread panicked: {message}"))
1582 }
1583 }
1584}
1585
1586fn run_async_main(
1587 cli: Cli,
1588 command: Option<Commands>,
1589 plugin_discovery: Arc<crate::plugins::PluginDiscoveryContext>,
1590 plugin_registry: Arc<crate::plugins::PluginRegistry>,
1591) -> Result<()> {
1592 build_runtime()?.block_on(run_async_main_inner(
1593 cli,
1594 command,
1595 plugin_discovery,
1596 plugin_registry,
1597 ))
1598}
1599
1600pub(crate) fn build_runtime() -> Result<tokio::runtime::Runtime> {
1619 tokio::runtime::Builder::new_multi_thread()
1620 .enable_all()
1621 .thread_stack_size(CODEWHALE_MAIN_STACK_BYTES)
1622 .build()
1623 .context("Failed to build the Codewhale Tokio runtime")
1624}
1625
1626fn telemetry_surface(command: Option<&Commands>) -> codewhale_telemetry::Surface {
1632 use codewhale_telemetry::Surface;
1633 match command {
1634 None | Some(Commands::Resume { .. } | Commands::Fork { .. } | Commands::Pr { .. }) => {
1635 Surface::Tui
1636 }
1637 Some(Commands::Exec(_)) => Surface::Exec,
1638 Some(Commands::Serve(args)) => {
1639 if args.mcp {
1640 Surface::McpServer
1641 } else {
1642 Surface::Serve
1643 }
1644 }
1645 Some(_) => Surface::Cli,
1646 }
1647}
1648
1649fn telemetry_session_source(command: Option<&Commands>) -> codewhale_telemetry::SessionSource {
1651 use codewhale_telemetry::SessionSource;
1652 match command {
1653 None | Some(Commands::Pr { .. }) => SessionSource::Interactive,
1654 Some(Commands::Resume { .. }) => SessionSource::Resume,
1655 Some(Commands::Fork { .. }) => SessionSource::Fork,
1656 Some(Commands::Serve(_)) => SessionSource::Api,
1657 Some(_) => SessionSource::Unknown,
1658 }
1659}
1660
1661fn telemetry_command_is_read_only(command: Option<&Commands>) -> bool {
1663 matches!(
1664 command,
1665 Some(Commands::Doctor(_) | Commands::SessionDiagnostics(_) | Commands::Sessions { .. })
1666 ) || matches!(command, Some(Commands::Setup(args)) if args.status)
1667}
1668
1669fn arm_telemetry_with_setup(
1683 config_path: Option<PathBuf>,
1684 surface: codewhale_telemetry::Surface,
1685 source: codewhale_telemetry::SessionSource,
1686 setup_override: Option<&codewhale_config::SetupState>,
1687) {
1688 let Ok(store) = codewhale_config::ConfigStore::load(config_path) else {
1689 return;
1690 };
1691 let resolved = store
1692 .config
1693 .resolve_runtime_options(&codewhale_config::CliRuntimeOverrides::default());
1694 let setup = if let Some(setup) = setup_override {
1695 setup.clone()
1696 } else {
1697 let Some(setup) = codewhale_telemetry::load_setup_state_for_decision() else {
1698 return;
1701 };
1702 setup
1703 };
1704 let codewhale_telemetry::TelemetryDecision::Enabled(consent) =
1705 codewhale_telemetry::decide(&resolved, &setup, surface)
1706 else {
1707 return;
1708 };
1709 codewhale_telemetry::init(consent.with_config_path(Some(store.path().to_path_buf())));
1710 let _ = TELEMETRY_SESSION_START.set(std::time::Instant::now());
1711 codewhale_telemetry::record(codewhale_telemetry::Event::SessionStart { source });
1712}
1713
1714fn arm_telemetry(cli: &Cli, command: Option<&Commands>) {
1715 if telemetry_command_is_read_only(command) {
1716 return;
1717 }
1718 arm_telemetry_with_setup(
1719 cli.config.clone(),
1720 telemetry_surface(command),
1721 telemetry_session_source(command),
1722 None,
1723 );
1724}
1725
1726pub(crate) fn apply_tui_telemetry_decision(
1733 pending: &crate::telemetry_notice::PendingTelemetryNotice,
1734 setup: &codewhale_config::SetupState,
1735) {
1736 arm_telemetry_with_setup(
1737 pending.config_path.clone(),
1738 codewhale_telemetry::Surface::Tui,
1739 pending.session_source,
1740 Some(setup),
1741 );
1742}
1743
1744async fn finish_telemetry(outcome: &Result<()>) {
1746 if !codewhale_telemetry::is_armed() {
1747 return;
1748 }
1749 if outcome.is_err()
1752 && codewhale_telemetry::exit_class() == codewhale_telemetry::ExitClass::Clean
1753 {
1754 codewhale_telemetry::set_exit_class(codewhale_telemetry::ExitClass::Error);
1755 }
1756 codewhale_telemetry::record(telemetry_session_end());
1757 let _ = tokio::time::timeout(
1762 codewhale_telemetry::SHUTDOWN_FLUSH_TIMEOUT,
1763 tokio::task::spawn_blocking(|| {
1764 codewhale_telemetry::shutdown_blocking(codewhale_telemetry::SHUTDOWN_FLUSH_TIMEOUT)
1765 }),
1766 )
1767 .await;
1768}
1769
1770async fn run_async_main_inner(
1771 cli: Cli,
1772 command: Option<Commands>,
1773 plugin_discovery: Arc<crate::plugins::PluginDiscoveryContext>,
1774 plugin_registry: Arc<crate::plugins::PluginRegistry>,
1775) -> Result<()> {
1776 spawn_signal_cleanup_task();
1797
1798 let surface = telemetry_surface(command.as_ref());
1804 let telemetry_notice_plan = if surface == codewhale_telemetry::Surface::Tui {
1805 crate::telemetry_notice::plan_if_due(
1806 cli.config.clone(),
1807 telemetry_session_source(command.as_ref()),
1808 )
1809 } else {
1810 crate::telemetry_notice::TelemetryNoticePlan::NotDue
1811 };
1812 let should_arm_before_dispatch = surface != codewhale_telemetry::Surface::Tui
1813 || telemetry_notice_plan.should_arm_before_tui();
1814 let pending_telemetry_notice = telemetry_notice_plan.into_pending();
1815 if should_arm_before_dispatch {
1816 arm_telemetry(&cli, command.as_ref());
1817 }
1818 let outcome = run_async_main_dispatch(
1819 cli,
1820 command,
1821 plugin_discovery,
1822 plugin_registry,
1823 pending_telemetry_notice,
1824 )
1825 .await;
1826 finish_telemetry(&outcome).await;
1827 outcome
1828}
1829
1830async fn run_async_main_dispatch(
1831 cli: Cli,
1832 command: Option<Commands>,
1833 plugin_discovery: Arc<crate::plugins::PluginDiscoveryContext>,
1834 plugin_registry: Arc<crate::plugins::PluginRegistry>,
1835 mut pending_telemetry_notice: Option<crate::telemetry_notice::PendingTelemetryNotice>,
1836) -> Result<()> {
1837 logging::set_verbose(cli.verbose || logging::env_requests_verbose_logging());
1838
1839 crate::prompts::load_prompt_overrides_from_config_home();
1844
1845 if let Some(command) = command {
1853 return match command {
1854 Commands::Doctor(args) => {
1855 let config = match load_doctor_config_from_cli(&cli, &args) {
1856 Ok(config) => config,
1857 Err(error) if args.json => return run_doctor_json_config_error(&error),
1858 Err(_) => {
1859 bail!(
1860 "doctor configuration validation failed; details omitted because configuration errors may contain credential material"
1861 )
1862 }
1863 };
1864 let workspace = resolve_workspace(&cli);
1865 if args.context_json {
1866 run_doctor_context_json(&config, &workspace)
1867 } else if args.json {
1868 run_doctor_json(
1869 &config,
1870 &workspace,
1871 cli.config.as_deref(),
1872 plugin_registry.as_ref(),
1873 )
1874 } else {
1875 let probes = crate::doctor::DoctorProbeRequest {
1876 check_updates: args.check_updates,
1877 probe_api: args.probe_api,
1878 probe_local: args.probe_local,
1879 probe_mcp: args.probe_mcp,
1880 };
1881 run_doctor(
1882 &config,
1883 &workspace,
1884 cli.config.as_deref(),
1885 probes,
1886 plugin_registry.as_ref(),
1887 )
1888 .await;
1889 Ok(())
1890 }
1891 }
1892 Commands::SessionDiagnostics(args) => run_session_diagnostics(args),
1893 Commands::Setup(args) => {
1894 let config = load_config_from_cli(&cli)?;
1895 let workspace = resolve_workspace(&cli);
1896 run_setup(&config, &workspace, args, plugin_registry.as_ref())
1897 }
1898 Commands::RemoteSetup(args) => remote_setup::run_remote_setup(args),
1899 Commands::Completions { shell } => {
1900 generate_completions(shell);
1901 Ok(())
1902 }
1903 Commands::Sessions { limit, search } => list_sessions(limit, search),
1904 Commands::Init => init_project(),
1905 Commands::Login { api_key } => run_login(api_key),
1906 Commands::Logout => run_logout(),
1907 Commands::Auth(args) => match args.command {
1908 TuiAuthCommand::XaiDevice => run_xai_device_auth(cli.config.as_deref()).await,
1909 },
1910 Commands::Models(args) => {
1911 let config = load_config_from_cli(&cli)?;
1912 run_models(&config, args).await
1913 }
1914 Commands::Speech(args) => {
1915 let config = load_config_from_cli(&cli)?;
1916 run_speech(&config, args).await
1917 }
1918 Commands::Exec(args) => {
1919 let config = load_config_from_cli(&cli)?;
1920 let workspace = cli.workspace.clone().unwrap_or_else(|| {
1921 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
1922 });
1923 let mut config = config.clone();
1924 if !cli.no_project_config {
1929 merge_user_workspace_config(&mut config, cli.config.clone(), &workspace);
1930 }
1931 if let Some(sandbox) = args.sandbox.as_deref() {
1932 let _ = parse_sandbox_policy(sandbox, true, Vec::new(), false, false)?;
1933 config.sandbox_mode = Some(sandbox.to_ascii_lowercase());
1934 }
1935 if let Ok(env_url) = std::env::var("CODEWHALE_BASE_URL")
1938 .or_else(|_| std::env::var("DEEPSEEK_BASE_URL"))
1939 {
1940 let trimmed = env_url.trim();
1941 if !trimmed.is_empty() {
1942 config.base_url = Some(trimmed.to_string());
1943 }
1944 }
1945 let explicit_provider = args
1955 .provider
1956 .as_deref()
1957 .map(str::trim)
1958 .filter(|provider| !provider.is_empty());
1959 if let Some(provider_arg) = explicit_provider {
1960 apply_exec_provider_override(&mut config, provider_arg)?;
1961 }
1962 if let Some(reasoning_arg) = args
1963 .reasoning_effort
1964 .as_deref()
1965 .map(str::trim)
1966 .filter(|value| !value.is_empty())
1967 {
1968 config.reasoning_effort = normalize_cli_reasoning_effort(reasoning_arg)?;
1969 config.reasoning_effort_inferred_from_legacy_alias = false;
1970 }
1971 let prompt = join_prompt_parts(&args.prompt);
1972 let resume_session_id = resolve_exec_resume_session_id(&args, &workspace)?;
1973 validate_exec_tool_authority_resume(
1974 args.tool_authority_json.as_deref(),
1975 resume_session_id.is_some(),
1976 )?;
1977 let resume_session = resume_session_id
1978 .as_deref()
1979 .map(load_exec_resume_session)
1980 .transpose()?;
1981 let explicit_model = args
1982 .model
1983 .as_deref()
1984 .map(str::trim)
1985 .filter(|model| !model.is_empty());
1986 let model = if let Some(saved) = resume_session.as_ref() {
1987 resolve_exec_resume_route(
1988 &mut config,
1989 saved,
1990 explicit_provider.is_some(),
1991 explicit_model,
1992 )?
1993 } else {
1994 resolve_exec_model(&config, explicit_model)
1995 };
1996 let force_configured_route = should_force_configured_exec_route(
1997 resume_session.is_some(),
1998 explicit_provider,
1999 explicit_model,
2000 );
2001 let yolo = cli.yolo || config.yolo.unwrap_or(false);
2005 let env_tool_surface = exec_tool_surface_from_env();
2006 let needs_engine = args.auto
2007 || yolo
2008 || resume_session_id.is_some()
2009 || args.output_format == ExecOutputFormat::StreamJson
2010 || args.max_turns.is_some()
2011 || args.allowed_tools.is_some()
2012 || args.disallowed_tools.is_some()
2013 || args.append_system_prompt.is_some()
2014 || args.tool_authority_json.is_some()
2015 || args.sandbox.is_some()
2016 || args.allow_sandbox_elevation
2017 || env_tool_surface.is_some();
2018 if needs_engine {
2019 let provider = config.api_provider();
2020 let max_subagents = cli.max_subagents.map_or_else(
2021 || config.max_subagents_for_provider(provider),
2022 |value| value.clamp(1, MAX_SUBAGENTS),
2023 );
2024 let auto_mode = args.auto || yolo;
2025 let max_turns = exec_max_steps(args.max_turns);
2026 let allowed_tools =
2027 resolve_exec_allowed_tools(args.allowed_tools.as_deref(), env_tool_surface);
2028 let disallowed_tools = args
2029 .disallowed_tools
2030 .as_deref()
2031 .map(normalize_exec_tool_names);
2032 run_exec_agent(
2033 &config,
2034 &model,
2035 &prompt,
2036 workspace,
2037 max_subagents,
2038 auto_mode,
2039 args.allow_sandbox_elevation,
2040 args.sandbox.as_deref(),
2041 auto_mode,
2042 args.json,
2043 resume_session,
2044 force_configured_route,
2045 args.output_format,
2046 max_turns,
2047 allowed_tools,
2048 disallowed_tools,
2049 args.append_system_prompt.clone(),
2050 args.tool_authority_json.clone(),
2051 std::sync::Arc::clone(&plugin_registry),
2052 )
2053 .await
2054 } else if args.json {
2055 run_one_shot_json(&config, &model, &prompt, force_configured_route).await
2056 } else {
2057 run_one_shot(&config, &model, &prompt, force_configured_route).await
2058 }
2059 }
2060 Commands::Fleet(args) => {
2061 let config = load_config_from_cli(&cli)?;
2062 let workspace = resolve_workspace(&cli);
2063 run_fleet_command(&workspace, &config, args).await
2064 }
2065 Commands::WorkflowTool(args) => {
2066 run_workflow_tool_command(&cli, args, std::sync::Arc::clone(&plugin_registry)).await
2067 }
2068 Commands::Review(args) => {
2069 let config = load_config_from_cli(&cli)?;
2070 run_review(&config, args).await
2071 }
2072 Commands::Pr {
2073 number,
2074 repo,
2075 checkout,
2076 } => {
2077 let config = load_config_from_cli(&cli)?;
2078 run_pr(
2079 &cli,
2080 &config,
2081 number,
2082 repo.as_deref(),
2083 checkout,
2084 pending_telemetry_notice.take(),
2085 Arc::clone(&plugin_registry),
2086 )
2087 .await
2088 }
2089 Commands::Apply(args) => run_apply(args),
2090 Commands::Eval(args) => run_eval(args),
2091 Commands::Scorecard(args) => run_scorecard(args),
2092 Commands::Mcp { command } => {
2093 let config = load_config_from_cli(&cli)?;
2094 let workspace = resolve_workspace(&cli);
2095 run_mcp_command(&config, &workspace, command, plugin_registry.as_ref()).await
2096 }
2097 Commands::Features(command) => {
2098 let config = load_config_from_cli(&cli)?;
2099 run_features_command(&config, command)
2100 }
2101 Commands::Sandbox(args) => run_sandbox_command(args),
2102 Commands::Serve(args) => {
2103 let workspace = cli.workspace.clone().unwrap_or_else(|| {
2104 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
2105 });
2106 let http_selected = validate_serve_mode_selection(
2107 args.mcp,
2108 args.http,
2109 args.mobile,
2110 args.web,
2111 args.acp,
2112 )?;
2113 if args.mcp {
2114 tokio::task::block_in_place(|| mcp_server::run_mcp_server(workspace))
2115 } else if http_selected {
2116 let (config, config_profile) =
2117 load_config_from_cli_with_effective_profile(&cli)?;
2118 let cors_origins = resolve_cors_origins(&config, &args.cors_origin);
2119 let bind_host = resolve_serve_bind_host(args.mobile, args.host);
2120 if args.web && bind_host.host != "127.0.0.1" {
2121 bail!("Codewhale web is loopback-only and must bind to 127.0.0.1");
2122 }
2123 if bind_host.mobile_rebound_to_lan {
2124 println!(
2125 "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."
2126 );
2127 }
2128 runtime_api::run_http_server(
2129 config,
2130 workspace,
2131 std::sync::Arc::clone(&plugin_discovery),
2132 runtime_api::RuntimeApiOptions {
2133 host: bind_host.host,
2134 port: args.port,
2135 workers: args.workers.clamp(1, 8),
2136 cors_origins,
2137 auth_token: args.auth_token,
2138 insecure_no_auth: args.insecure_no_auth,
2139 mobile: args.mobile,
2140 web: args.web,
2141 show_qr: args.qr,
2142 config_path: cli.config.clone(),
2143 config_profile,
2144 },
2145 )
2146 .await
2147 } else if args.acp {
2148 let config = load_config_from_cli(&cli)?;
2149 let model = config.default_model();
2150 acp_server::run_acp_server(config, model, workspace).await
2151 } else {
2152 unreachable!("server mode count checked above")
2153 }
2154 }
2155 Commands::Resume { session_id, last } => {
2156 let config = load_config_from_cli(&cli)?;
2157 let workspace = resolve_workspace(&cli);
2158 let resume_id = resolve_session_id(session_id, last, &workspace)?;
2159 run_interactive(
2160 &cli,
2161 &config,
2162 Some(resume_id),
2163 None,
2164 pending_telemetry_notice.take(),
2165 std::sync::Arc::clone(&plugin_registry),
2166 )
2167 .await
2168 }
2169 Commands::Fork { session_id, last } => {
2170 let config = load_config_from_cli(&cli)?;
2171 let workspace = resolve_workspace(&cli);
2172 let new_session_id = fork_session(&config, session_id, last, &workspace)?;
2173 run_interactive(
2174 &cli,
2175 &config,
2176 Some(new_session_id),
2177 None,
2178 pending_telemetry_notice.take(),
2179 std::sync::Arc::clone(&plugin_registry),
2180 )
2181 .await
2182 }
2183 };
2184 }
2185
2186 let config = load_config_from_cli(&cli)?;
2190 if let Some(initial_input) = top_level_prompt_initial_input(&cli.prompt) {
2191 return run_interactive(
2192 &cli,
2193 &config,
2194 None,
2195 Some(initial_input),
2196 pending_telemetry_notice.take(),
2197 std::sync::Arc::clone(&plugin_registry),
2198 )
2199 .await;
2200 }
2201
2202 let mut startup_notice = None;
2205 let resume_session_id = if cli.continue_session {
2206 let workspace = resolve_workspace(&cli);
2207 recover_interrupted_checkpoint_for_resume(&workspace)
2208 .or_else(|| latest_session_id_for_workspace(&workspace).ok().flatten())
2209 } else if let Some(id) = cli.resume.clone() {
2210 Some(id)
2211 } else if !cli.fresh {
2212 let workspace = resolve_workspace(&cli);
2213 preserve_interrupted_checkpoint_for_explicit_resume(&workspace);
2214 let (session_id, notice) = resolve_auto_resume(&workspace);
2220 startup_notice = notice;
2221 session_id
2222 } else {
2223 None
2224 };
2225
2226 run_interactive_with_notice(
2229 &cli,
2230 &config,
2231 resume_session_id,
2232 None,
2233 startup_notice,
2234 pending_telemetry_notice.take(),
2235 plugin_registry,
2236 )
2237 .await
2238}
2239
2240fn resolve_auto_resume(workspace: &Path) -> (Option<String>, Option<String>) {
2247 use crate::session_resume::{ResumeRequest, decide_auto_resume};
2248
2249 let enabled = crate::settings::Settings::load_persisted()
2250 .map(|settings| settings.session_auto_resume)
2251 .unwrap_or(false);
2252 if !enabled {
2253 return (None, None);
2254 }
2255 let Ok(manager) = SessionManager::default_location() else {
2256 return (None, None);
2257 };
2258 let decision = decide_auto_resume(true, &ResumeRequest::default(), workspace, &manager);
2259 (
2260 decision.session_id().map(str::to_string),
2261 decision.status_message(),
2262 )
2263}
2264
2265fn prepare_cli_startup(
2266 cli: Cli,
2267 initialize_plugins: impl FnOnce(),
2268 load_dotenv: impl FnOnce(),
2269) -> (Cli, Option<Commands>) {
2270 initialize_plugins();
2271 let command = cli.command.clone();
2272 let should_load_dotenv = match command.as_ref() {
2273 Some(Commands::Doctor(args)) => args.probe_api || args.probe_local,
2274 _ => true,
2275 };
2276 if should_load_dotenv {
2277 load_dotenv();
2278 }
2279 (cli, command)
2280}
2281
2282const MAX_WORKSPACE_DOTENV_BYTES: u64 = 1024 * 1024;
2283
2284#[derive(Debug, Default)]
2285struct WorkspaceDotenvReport {
2286 path: PathBuf,
2287 loaded: BTreeSet<String>,
2288 ignored: BTreeSet<String>,
2289}
2290
2291fn warn_on_workspace_dotenv_result() {
2299 match load_workspace_dotenv_credentials() {
2300 Ok(Some(report)) if !report.ignored.is_empty() => {
2301 eprintln!(
2302 "Codewhale ignored non-credential settings in {}: {}. Use config.toml, CLI flags, or the launching shell for control settings.",
2303 report.path.display(),
2304 display_env_key_set(&report.ignored)
2305 );
2306 }
2307 Ok(_) => {}
2308 Err(error) => {
2309 eprintln!("Codewhale did not load workspace .env: {error}");
2313 }
2314 }
2315}
2316
2317fn display_env_key_set(keys: &BTreeSet<String>) -> String {
2318 const MAX_DISPLAYED: usize = 12;
2319 let mut labels = keys
2320 .iter()
2321 .take(MAX_DISPLAYED)
2322 .map(|key| {
2323 if key
2324 .chars()
2325 .all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_')
2326 {
2327 key.as_str()
2328 } else {
2329 "<invalid-name>"
2330 }
2331 })
2332 .collect::<Vec<_>>();
2333 if keys.len() > MAX_DISPLAYED {
2334 labels.push("...");
2335 }
2336 labels.join(", ")
2337}
2338
2339fn load_workspace_dotenv_credentials() -> Result<Option<WorkspaceDotenvReport>> {
2340 let Some(path) = find_workspace_dotenv()? else {
2341 return Ok(None);
2342 };
2343 load_workspace_dotenv_credentials_from_path(&path).map(Some)
2344}
2345
2346fn find_workspace_dotenv() -> Result<Option<PathBuf>> {
2347 let cwd = std::env::current_dir().context("could not resolve the current workspace")?;
2348 let boundary = cwd
2349 .ancestors()
2350 .find(|ancestor| std::fs::symlink_metadata(ancestor.join(".git")).is_ok())
2351 .unwrap_or(cwd.as_path());
2352
2353 for ancestor in cwd.ancestors() {
2354 let candidate = ancestor.join(".env");
2355 match std::fs::symlink_metadata(&candidate) {
2356 Ok(_) => return Ok(Some(candidate)),
2357 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
2358 Err(error) => {
2359 return Err(anyhow!(
2360 "could not inspect {}: {error}",
2361 candidate.display()
2362 ));
2363 }
2364 }
2365 if ancestor == boundary {
2366 break;
2367 }
2368 }
2369 Ok(None)
2370}
2371
2372fn load_workspace_dotenv_credentials_from_path(path: &Path) -> Result<WorkspaceDotenvReport> {
2373 let contents = read_stable_workspace_dotenv(path)?;
2374 let text = std::str::from_utf8(&contents)
2375 .map_err(|_| anyhow!("{} is not valid UTF-8", path.display()))?;
2376 if dotenv_has_variable_expansion(text) {
2377 bail!(
2378 "{} uses variable expansion; workspace .env values must be literal to prevent ambient-secret substitution",
2379 path.display()
2380 );
2381 }
2382
2383 let mut report = WorkspaceDotenvReport {
2384 path: path.to_path_buf(),
2385 ..WorkspaceDotenvReport::default()
2386 };
2387 let entries = dotenvy::from_read_iter(std::io::Cursor::new(contents))
2388 .collect::<std::result::Result<Vec<_>, _>>()
2389 .map_err(|_| anyhow!("{} could not be parsed safely", path.display()))?;
2390 for entry in entries {
2391 let (key, value) = entry;
2392 if !is_workspace_dotenv_credential_key(&key) {
2393 report.ignored.insert(key);
2394 continue;
2395 }
2396 if std::env::var_os(&key).is_some() {
2397 continue;
2398 }
2399
2400 unsafe { std::env::set_var(&key, value) };
2405 report.loaded.insert(key);
2406 }
2407 Ok(report)
2408}
2409
2410fn is_workspace_dotenv_credential_key(key: &str) -> bool {
2411 codewhale_config::provider::providers_sorted_for_display()
2412 .into_iter()
2413 .any(|provider| provider.env_vars().contains(&key))
2414 || matches!(
2415 key,
2416 "DEEPSEEK_SEARCH_API_KEY"
2417 | "SOFYA_API_KEY"
2418 | "METASO_API_KEY"
2419 | "BAIDU_SEARCH_API_KEY"
2420 | "DEEPSEEK_SANDBOX_API_KEY"
2421 )
2422}
2423
2424fn dotenv_has_variable_expansion(contents: &str) -> bool {
2425 let mut escaped = false;
2426 let mut single_quoted = false;
2427 let mut double_quoted = false;
2428 let mut comment = false;
2429
2430 for ch in contents.chars() {
2431 if comment {
2432 if ch == '$' {
2436 return true;
2437 }
2438 if ch == '\n' {
2439 comment = false;
2440 escaped = false;
2441 }
2442 continue;
2443 }
2444 if single_quoted {
2445 if ch == '\'' {
2446 single_quoted = false;
2447 }
2448 continue;
2449 }
2450 if escaped {
2451 escaped = false;
2452 continue;
2453 }
2454 if ch == '\\' {
2455 escaped = true;
2456 continue;
2457 }
2458 if ch == '\'' && !double_quoted {
2459 single_quoted = true;
2460 continue;
2461 }
2462 if ch == '"' {
2463 double_quoted = !double_quoted;
2464 continue;
2465 }
2466 if ch == '#' && !double_quoted {
2467 comment = true;
2468 continue;
2469 }
2470 if ch == '$' {
2471 return true;
2472 }
2473 }
2474 false
2475}
2476
2477fn read_stable_workspace_dotenv(path: &Path) -> Result<Vec<u8>> {
2478 let mut file = open_workspace_dotenv_without_following_links(path)?;
2479 let metadata = file
2480 .metadata()
2481 .map_err(|error| anyhow!("could not inspect {}: {error}", path.display()))?;
2482 if !metadata.is_file() {
2483 bail!("{} is not a regular file", path.display());
2484 }
2485 if workspace_dotenv_has_multiple_links(&file, &metadata)? {
2486 bail!(
2487 "{} has multiple filesystem links, not a unique workspace-owned file",
2488 path.display()
2489 );
2490 }
2491 if metadata.len() > MAX_WORKSPACE_DOTENV_BYTES {
2492 bail!(
2493 "{} exceeds the {} byte workspace .env limit",
2494 path.display(),
2495 MAX_WORKSPACE_DOTENV_BYTES
2496 );
2497 }
2498
2499 let mut contents = Vec::with_capacity(metadata.len() as usize);
2500 (&mut file)
2501 .take(MAX_WORKSPACE_DOTENV_BYTES + 1)
2502 .read_to_end(&mut contents)
2503 .map_err(|error| anyhow!("could not read {}: {error}", path.display()))?;
2504 if contents.len() as u64 > MAX_WORKSPACE_DOTENV_BYTES {
2505 bail!(
2506 "{} exceeds the {} byte workspace .env limit",
2507 path.display(),
2508 MAX_WORKSPACE_DOTENV_BYTES
2509 );
2510 }
2511 Ok(contents)
2512}
2513
2514#[cfg(unix)]
2515fn workspace_dotenv_has_multiple_links(
2516 _file: &std::fs::File,
2517 metadata: &std::fs::Metadata,
2518) -> Result<bool> {
2519 use std::os::unix::fs::MetadataExt;
2520
2521 Ok(metadata.nlink() > 1)
2522}
2523
2524#[cfg(windows)]
2525fn workspace_dotenv_has_multiple_links(
2526 file: &std::fs::File,
2527 _metadata: &std::fs::Metadata,
2528) -> Result<bool> {
2529 use std::os::windows::io::AsRawHandle;
2530 use windows::Win32::Foundation::HANDLE;
2531 use windows::Win32::Storage::FileSystem::{
2532 BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle,
2533 };
2534
2535 let mut information = BY_HANDLE_FILE_INFORMATION::default();
2536 unsafe {
2540 GetFileInformationByHandle(HANDLE(file.as_raw_handle()), &mut information)
2541 .map_err(|error| anyhow!("could not inspect workspace .env link count: {error}"))?;
2542 }
2543 Ok(information.nNumberOfLinks > 1)
2544}
2545
2546#[cfg(not(any(unix, windows)))]
2547fn workspace_dotenv_has_multiple_links(
2548 _file: &std::fs::File,
2549 _metadata: &std::fs::Metadata,
2550) -> Result<bool> {
2551 Ok(false)
2552}
2553
2554#[cfg(unix)]
2555fn open_workspace_dotenv_without_following_links(path: &Path) -> Result<std::fs::File> {
2556 use std::os::unix::fs::OpenOptionsExt;
2557
2558 std::fs::OpenOptions::new()
2559 .read(true)
2560 .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK)
2563 .open(path)
2564 .map_err(|error| anyhow!("could not securely open {}: {error}", path.display()))
2565}
2566
2567#[cfg(windows)]
2568fn open_workspace_dotenv_without_following_links(path: &Path) -> Result<std::fs::File> {
2569 use std::os::windows::fs::{MetadataExt, OpenOptionsExt};
2570
2571 const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
2572 const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
2573 let file = std::fs::OpenOptions::new()
2574 .read(true)
2575 .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
2576 .open(path)
2577 .map_err(|error| anyhow!("could not securely open {}: {error}", path.display()))?;
2578 let metadata = file
2579 .metadata()
2580 .map_err(|error| anyhow!("could not inspect {}: {error}", path.display()))?;
2581 if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
2582 bail!(
2583 "{} is a reparse point, not a workspace-owned file",
2584 path.display()
2585 );
2586 }
2587 Ok(file)
2588}
2589
2590#[cfg(not(any(unix, windows)))]
2591fn open_workspace_dotenv_without_following_links(path: &Path) -> Result<std::fs::File> {
2592 let metadata = std::fs::symlink_metadata(path)
2593 .map_err(|error| anyhow!("could not inspect {}: {error}", path.display()))?;
2594 if metadata.file_type().is_symlink() {
2595 bail!(
2596 "{} is a symbolic link, not a workspace-owned file",
2597 path.display()
2598 );
2599 }
2600 std::fs::File::open(path)
2601 .map_err(|error| anyhow!("could not securely open {}: {error}", path.display()))
2602}
2603
2604fn generate_completions(shell: Shell) {
2606 let mut cmd = Cli::command();
2607 let name = cmd.get_name().to_string();
2608 generate(shell, &mut cmd, name, &mut io::stdout());
2609}
2610
2611fn run_eval(args: EvalArgs) -> Result<()> {
2613 let fail_step = match args.fail_step.as_deref() {
2614 Some(value) => ScenarioStepKind::parse(value)
2615 .map(Some)
2616 .ok_or_else(|| anyhow!("invalid --fail-step '{value}'"))?,
2617 None => None,
2618 };
2619
2620 let config = EvalHarnessConfig {
2621 fail_step,
2622 shell_command: args.shell_command,
2623 shell_expect_token: args.shell_expect_token,
2624 max_output_chars: args.max_output_chars,
2625 record_dir: args.record.clone(),
2626 ..EvalHarnessConfig::default()
2627 };
2628
2629 let harness = EvalHarness::new(config);
2630 let run = harness.run().context("evaluation harness failed")?;
2631 let report = run.to_report();
2632
2633 if args.json {
2634 let json = serde_json::to_string_pretty(&report)?;
2635 println!("{json}");
2636 } else {
2637 println!("Offline Eval Harness");
2638 println!("scenario: {}", report.scenario_name);
2639 println!("workspace: {}", report.workspace_root.display());
2640 println!("success: {}", report.metrics.success);
2641 println!("steps: {}", report.metrics.steps);
2642 println!("tool_errors: {}", report.metrics.tool_errors);
2643 println!("duration_ms: {}", report.metrics.duration.as_millis());
2644
2645 if !report.metrics.per_tool.is_empty() {
2646 println!("per_tool:");
2647 for (kind, stats) in &report.metrics.per_tool {
2648 println!(
2649 " {} invocations={} errors={} duration_ms={}",
2650 kind.tool_name(),
2651 stats.invocations,
2652 stats.errors,
2653 stats.total_duration.as_millis()
2654 );
2655 }
2656 }
2657
2658 let failed_steps: Vec<_> = report.steps.iter().filter(|s| !s.success).collect();
2659 if !failed_steps.is_empty() {
2660 println!("failed_steps:");
2661 for step in failed_steps {
2662 let error = step.error.as_deref().unwrap_or("unknown error");
2663 println!(
2664 " {} tool={} error={}",
2665 step.kind.tool_name(),
2666 step.tool_name,
2667 error
2668 );
2669 }
2670 }
2671 }
2672
2673 if report.metrics.success {
2674 Ok(())
2675 } else {
2676 bail!("offline evaluation harness reported failure")
2677 }
2678}
2679
2680fn run_scorecard(args: ScorecardArgs) -> Result<()> {
2686 use crate::scorecard::{RecordedTurn, Scorecard, ScorecardMetrics};
2687
2688 let raw = std::fs::read_to_string(&args.input)
2689 .with_context(|| format!("failed to read scorecard input {}", args.input.display()))?;
2690 let recorded: Vec<RecordedTurn> = serde_json::from_str(&raw)
2691 .with_context(|| format!("failed to parse scorecard input {}", args.input.display()))?;
2692
2693 let card = Scorecard::from_recorded_turns(&recorded);
2694
2695 let regressions = match &args.baseline {
2696 Some(path) => {
2697 let baseline_raw = std::fs::read_to_string(path)
2698 .with_context(|| format!("failed to read baseline {}", path.display()))?;
2699 let baseline: ScorecardMetrics = serde_json::from_str(&baseline_raw)
2700 .with_context(|| format!("failed to parse baseline {}", path.display()))?;
2701 card.metrics.regressions_against(&baseline, args.threshold)
2702 }
2703 None => Vec::new(),
2704 };
2705
2706 if args.json {
2707 let out = serde_json::json!({
2708 "per_turn": card.per_turn,
2709 "metrics": card.metrics,
2710 "regressions": regressions,
2711 });
2712 println!("{}", serde_json::to_string_pretty(&out)?);
2713 } else {
2714 print!("{}", card.to_summary());
2715 for r in ®ressions {
2716 println!(
2717 "REGRESSION {}: baseline {:.4} -> current {:.4} (+{:.1}%)",
2718 r.metric, r.baseline, r.current, r.pct_increase
2719 );
2720 }
2721 }
2722
2723 if regressions.is_empty() {
2724 Ok(())
2725 } else {
2726 bail!(
2727 "{} metric(s) regressed past the {:.1}% threshold",
2728 regressions.len(),
2729 args.threshold
2730 )
2731 }
2732}
2733
2734async fn run_fleet_command(workspace: &Path, config: &Config, args: FleetArgs) -> Result<()> {
2735 use crate::fleet::alerts::{
2736 FleetAlertAdapterConfig, FleetAlertConfig, FleetAlertDispatcher, FleetAlertEvent,
2737 FleetEnvSecretResolver,
2738 };
2739 use crate::fleet::control as fleet_control;
2740 use crate::fleet::executor::FleetExecutor;
2741 use crate::fleet::manager::{FleetManager, FleetStatusSnapshot, FleetWorkerInspection};
2742 use codewhale_lane::{ControlOperation, ControlSurface};
2743 use codewhale_protocol::fleet::{FleetAlertEventClass, FleetArtifactKind, FleetRunId};
2744
2745 fn print_status(status: &FleetStatusSnapshot) {
2749 println!("{}", fleet_control::render_fleet_status_snapshot(status));
2750 }
2751
2752 fn print_inspection(inspection: &FleetWorkerInspection) {
2753 println!("{}", fleet_control::render_inspection(inspection));
2754 }
2755
2756 fn print_artifacts(inspection: &FleetWorkerInspection) {
2757 println!("{}", fleet_control::render_artifacts(inspection));
2758 }
2759
2760 fn emit_fleet_receipt(receipt: &codewhale_lane::ControlReceipt) -> Result<()> {
2762 if receipt.is_error() {
2763 eprintln!("{}", receipt.render());
2764 let detail = receipt
2765 .failure
2766 .as_ref()
2767 .map(ToString::to_string)
2768 .unwrap_or_else(|| receipt.outcome.as_str().to_string());
2769 bail!("{}: {detail}", receipt.operation_id);
2770 }
2771 println!("{}", receipt.render());
2772 Ok(())
2773 }
2774
2775 fn print_logs(workspace: &Path, inspection: &FleetWorkerInspection) -> Result<()> {
2776 let mut printed = false;
2777 for artifact in inspection
2778 .artifacts
2779 .iter()
2780 .filter(|artifact| matches!(artifact.kind, FleetArtifactKind::Log))
2781 {
2782 let path = workspace.join(&artifact.path);
2783 println!("== {} ==", artifact.path.display());
2784 let contents = std::fs::read_to_string(&path)
2785 .with_context(|| format!("reading fleet log {}", path.display()))?;
2786 let preview: String = contents.chars().take(16 * 1024).collect();
2787 let mut safe_preview = String::with_capacity(preview.len());
2792 crate::tui::osc8::strip_ansi_into(&preview, &mut safe_preview);
2793 print!("{safe_preview}");
2794 if contents.chars().count() > preview.chars().count() {
2795 println!("\n[truncated]");
2796 } else if !preview.ends_with('\n') {
2797 println!();
2798 }
2799 printed = true;
2800 }
2801 if !printed {
2802 println!("logs: none");
2803 }
2804 Ok(())
2805 }
2806
2807 fn alert_event_class(arg: FleetAlertEventArg) -> FleetAlertEventClass {
2808 match arg {
2809 FleetAlertEventArg::Stale => FleetAlertEventClass::Stale,
2810 FleetAlertEventArg::RestartExhausted => FleetAlertEventClass::RestartExhausted,
2811 FleetAlertEventArg::NeedsHuman => FleetAlertEventClass::NeedsHuman,
2812 FleetAlertEventArg::BudgetExceeded => FleetAlertEventClass::BudgetExceeded,
2813 FleetAlertEventArg::VerifierFailed => FleetAlertEventClass::VerifierFailed,
2814 FleetAlertEventArg::RunCompleted => FleetAlertEventClass::RunCompleted,
2815 }
2816 }
2817
2818 fn alert_status(class: FleetAlertEventClass, override_status: Option<String>) -> String {
2819 if let Some(status) = override_status {
2820 return status;
2821 }
2822 match class {
2823 FleetAlertEventClass::Stale => "stale",
2824 FleetAlertEventClass::RestartExhausted => "failed",
2825 FleetAlertEventClass::NeedsHuman => "needs_human",
2826 FleetAlertEventClass::BudgetExceeded => "budget_exceeded",
2827 FleetAlertEventClass::VerifierFailed => "verifier_failed",
2828 FleetAlertEventClass::RunCompleted => "completed",
2829 }
2830 .to_string()
2831 }
2832
2833 fn alert_adapter(args: &FleetAlertDryRunArgs) -> FleetAlertAdapterConfig {
2834 match args.adapter {
2835 FleetAlertAdapterArg::Slack => FleetAlertAdapterConfig::Slack {
2836 webhook_env: args.slack_webhook_env.clone(),
2837 channel: None,
2838 },
2839 FleetAlertAdapterArg::Webhook => FleetAlertAdapterConfig::Webhook {
2840 url_env: args.webhook_url_env.clone(),
2841 secret_env: args.webhook_secret_env.clone(),
2842 },
2843 FleetAlertAdapterArg::PagerDuty => FleetAlertAdapterConfig::PagerDuty {
2844 routing_key_env: args.pagerduty_routing_key_env.clone(),
2845 severity: args.pagerduty_severity.clone(),
2846 },
2847 }
2848 }
2849
2850 let fleet_config = config.fleet_config();
2851 let provider = config.api_provider();
2852 let max_subagents = config.max_subagents_for_provider(provider);
2853 let coordination_manager = crate::tools::subagent::new_shared_subagent_manager_with_timeout(
2854 workspace.to_path_buf(),
2855 max_subagents,
2856 config
2857 .max_admitted_subagents_for_provider(provider)
2858 .max(max_subagents),
2859 Duration::from_secs(config.subagent_heartbeat_timeout_secs_for_provider(provider)),
2860 config.launch_concurrency_for_provider(provider),
2861 config.subagent_token_budget_for_provider(provider),
2862 );
2863 let fleet_context = fleet_control::fleet_control_context(workspace);
2868 if let Some(operation) = match &args.command {
2877 FleetCommand::List => Some(ControlOperation::FleetList),
2878 FleetCommand::Status => Some(ControlOperation::FleetStatus),
2879 FleetCommand::Interrupt { .. } => Some(ControlOperation::FleetInterrupt),
2880 FleetCommand::Resume { .. } => Some(ControlOperation::FleetResume),
2881 _ => None,
2882 } {
2883 let descriptor = operation.descriptor();
2884 let availability = descriptor.availability(ControlSurface::Cli, fleet_context);
2885 if !availability.is_available() {
2886 return emit_fleet_receipt(&codewhale_lane::ControlReceipt::unavailable(
2887 descriptor,
2888 ControlSurface::Cli,
2889 availability,
2890 ));
2891 }
2892 }
2893
2894 let manager = FleetManager::open(workspace)?
2897 .with_exec_config(fleet_config.exec.clone())
2898 .with_fleet_config(fleet_config)
2899 .with_sub_agent_manager(coordination_manager)
2900 .with_session_model(config.default_model())
2901 .with_route_config(config.clone());
2902 match args.command {
2903 FleetCommand::Init => {
2904 println!("fleet ledger: {}", manager.ledger_path().display());
2905 Ok(())
2906 }
2907 FleetCommand::Run(args) => {
2908 let max_workers = args.max_workers.clamp(1, 128);
2909 let manager =
2910 manager.with_stale_after(Duration::from_secs(args.stale_after_seconds.max(1)));
2911 let report = manager.create_run_from_task_spec_path(&args.task_spec, max_workers)?;
2912 println!(
2913 "fleet run: {} tasks={} leased={} queued={}",
2914 report.run_id.0, report.task_count, report.leased, report.queued
2915 );
2916 for warning in &report.warnings {
2917 println!("warning: {warning}");
2918 }
2919 println!("workers:");
2920 for worker_id in &report.worker_ids {
2921 println!(" {worker_id}");
2922 }
2923 if args.once {
2924 print_status(&manager.run_status(&report.run_id)?);
2925 return Ok(());
2926 }
2927 println!(
2928 "manager loop running; use `codewhale fleet status`, `inspect`, `interrupt`, or `stop --all` from another terminal."
2929 );
2930 let mut executor = FleetExecutor::new(workspace);
2931 let codewhale_binary = fleet::executor::configured_codewhale_binary();
2932 let status = manager
2933 .run_to_completion(
2934 &report.run_id,
2935 max_workers,
2936 &mut executor,
2937 &codewhale_binary,
2938 None,
2939 Duration::from_secs(2),
2940 )
2941 .await?;
2942 print_status(&status);
2943 Ok(())
2944 }
2945 FleetCommand::List => emit_fleet_receipt(&fleet_control::execute_fleet_control_with(
2946 ControlSurface::Cli,
2947 workspace,
2948 fleet_context,
2949 &manager,
2950 ControlOperation::FleetList,
2951 None,
2952 )),
2953 FleetCommand::Status => emit_fleet_receipt(&fleet_control::execute_fleet_control_with(
2954 ControlSurface::Cli,
2955 workspace,
2956 fleet_context,
2957 &manager,
2958 ControlOperation::FleetStatus,
2959 None,
2960 )),
2961 FleetCommand::Inspect { worker_id } => {
2962 print_inspection(&manager.inspect_worker(&worker_id)?);
2963 Ok(())
2964 }
2965 FleetCommand::Logs { worker_id } => {
2966 let inspection = manager.inspect_worker(&worker_id)?;
2967 print_logs(workspace, &inspection)
2968 }
2969 FleetCommand::Artifacts { worker_id } => {
2970 let inspection = manager.inspect_worker(&worker_id)?;
2971 print_artifacts(&inspection);
2972 Ok(())
2973 }
2974 FleetCommand::Interrupt { worker_id } => {
2975 emit_fleet_receipt(&fleet_control::execute_fleet_control_with(
2976 ControlSurface::Cli,
2977 workspace,
2978 fleet_context,
2979 &manager,
2980 ControlOperation::FleetInterrupt,
2981 Some(&worker_id),
2982 ))
2983 }
2984 FleetCommand::Restart { worker_id } => {
2985 let report = manager.restart_worker(&worker_id)?;
2986 print_inspection(&report.inspection);
2987 println!(
2988 "manager loop running for restarted run {}; use `codewhale fleet status`, `inspect`, `interrupt`, or `stop --all` from another terminal.",
2989 report.run_id.0
2990 );
2991 let mut executor = FleetExecutor::new(workspace);
2992 let codewhale_binary = fleet::executor::configured_codewhale_binary();
2993 let status = manager
2994 .run_to_completion(
2995 &report.run_id,
2996 report.max_workers,
2997 &mut executor,
2998 &codewhale_binary,
2999 None,
3000 Duration::from_secs(2),
3001 )
3002 .await?;
3003 print_status(&status);
3004 Ok(())
3005 }
3006 FleetCommand::Resume {
3007 run_id,
3008 stale_after_seconds,
3009 } => {
3010 let manager = manager.with_stale_after(Duration::from_secs(stale_after_seconds.max(1)));
3011 emit_fleet_receipt(&fleet_control::execute_fleet_control_with(
3012 ControlSurface::Cli,
3013 workspace,
3014 fleet_context,
3015 &manager,
3016 ControlOperation::FleetResume,
3017 Some(&run_id),
3018 ))
3019 }
3020 FleetCommand::Stop { all } => {
3021 if !all {
3022 bail!("pass --all to stop all fleet work");
3023 }
3024 let stopped = manager.stop_all()?;
3025 println!("stopped: {stopped}");
3026 Ok(())
3027 }
3028 FleetCommand::AlertDryRun(args) => {
3029 let class = alert_event_class(args.event);
3030 let adapter = alert_adapter(&args);
3031 let event = FleetAlertEvent {
3032 class,
3033 run_id: FleetRunId::from(args.run_id.clone()),
3034 worker_id: args.worker_id.clone(),
3035 task_id: args.task_id.clone(),
3036 status: alert_status(class, args.status.clone()),
3037 reason: args.reason.clone(),
3038 };
3039 let dispatcher = FleetAlertDispatcher::new(
3040 FleetAlertConfig::dry_run_for_adapter(adapter),
3041 FleetEnvSecretResolver,
3042 );
3043 let deliveries = dispatcher.dispatch(&event)?;
3044 for delivery in deliveries {
3045 println!(
3046 "{}",
3047 serde_json::to_string_pretty(&delivery.redacted_payload)?
3048 );
3049 }
3050 Ok(())
3051 }
3052 }
3053}
3054
3055#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3056enum WriteStatus {
3057 Created,
3058 Overwritten,
3059 SkippedExists,
3060}
3061
3062fn ensure_parent_dir(path: &Path) -> Result<()> {
3063 if let Some(parent) = path.parent()
3064 && !parent.as_os_str().is_empty()
3065 {
3066 std::fs::create_dir_all(parent)
3067 .with_context(|| format!("Failed to create directory for {}", parent.display()))?;
3068 }
3069 Ok(())
3070}
3071
3072fn write_template_file(path: &Path, contents: &str, force: bool) -> Result<WriteStatus> {
3073 ensure_parent_dir(path)?;
3074
3075 if path.exists() && !force {
3076 return Ok(WriteStatus::SkippedExists);
3077 }
3078
3079 let status = if path.exists() {
3080 WriteStatus::Overwritten
3081 } else {
3082 WriteStatus::Created
3083 };
3084
3085 std::fs::write(path, contents)
3086 .with_context(|| format!("Failed to write template at {}", path.display()))?;
3087
3088 Ok(status)
3089}
3090
3091fn mcp_template_json() -> Result<String> {
3092 let mut cfg = McpConfig::default();
3093 cfg.servers.insert(
3094 "example".to_string(),
3095 McpServerConfig {
3096 command: Some("node".to_string()),
3097 args: vec!["./path/to/your-mcp-server.js".to_string()],
3098 env: std::collections::HashMap::new(),
3099 cwd: None,
3100 url: None,
3101 transport: None,
3102 connect_timeout: None,
3103 execute_timeout: None,
3104 read_timeout: None,
3105 disabled: true,
3106 enabled: true,
3107 required: false,
3108 enabled_tools: Vec::new(),
3109 disabled_tools: Vec::new(),
3110 headers: std::collections::HashMap::new(),
3111 env_headers: std::collections::HashMap::new(),
3112 bearer_token_env_var: None,
3113 scopes: Vec::new(),
3114 oauth: None,
3115 oauth_resource: None,
3116 reviewed_plugin: None,
3117 },
3118 );
3119 serde_json::to_string_pretty(&cfg)
3120 .map_err(|e| anyhow!("Failed to render MCP template JSON: {e}"))
3121}
3122
3123fn init_mcp_config(path: &Path, force: bool) -> Result<WriteStatus> {
3124 let template = mcp_template_json()?;
3125 write_template_file(path, &template, force)
3126}
3127
3128fn skills_template(name: &str) -> String {
3129 format!(
3130 "\
3131---\n\
3132name: {name}\n\
3133description: Quick repo diagnostics and setup guidance\n\
3134allowed-tools: diagnostics, list_dir, read_file, grep_files, git_status, git_diff\n\
3135---\n\n\
3136When this skill is active:\n\
31371. Run the diagnostics tool to report workspace and sandbox status.\n\
31382. Skim key project files (README.md, Cargo.toml, AGENTS.md) before editing.\n\
31393. Prefer small, validated changes and summarize what you verified.\n\
3140"
3141 )
3142}
3143
3144fn init_skills_dir(skills_dir: &Path, force: bool) -> Result<(PathBuf, WriteStatus)> {
3145 std::fs::create_dir_all(skills_dir)
3146 .with_context(|| format!("Failed to create skills dir {}", skills_dir.display()))?;
3147
3148 let skill_name = "getting-started";
3149 let skill_path = skills_dir.join(skill_name).join("SKILL.md");
3150 ensure_parent_dir(&skill_path)?;
3151
3152 let status = write_template_file(&skill_path, &skills_template(skill_name), force)?;
3153 Ok((skill_path, status))
3154}
3155
3156fn tools_readme_template() -> &'static str {
3157 "# Local tools\n\n\
3158 Drop self-describing scripts here so they can be discovered by\n\
3159 `codewhale-tui setup --status` and surfaced in `codewhale-tui doctor`.\n\n\
3160 When `[tools.plugin_dir]` is set in config.toml (or when the default\n\
3161 `~/.codewhale/tools/` directory exists), they are auto-discovered and\n\
3162 registered as model-visible tools.\n\n\
3163 Each script should start with a frontmatter-style header so the\n\
3164 description is visible without executing the file and the agent knows\n\
3165 the tool name, description, and input schema:\n\n\
3166 ```\n\
3167 # name: my-tool\n\
3168 # description: One-line summary of what this tool does\n\
3169 # usage: my-tool [args...]\n\
3170 ```\n\n\
3171 The directory is intentionally not auto-loaded into the agent's tool\n\
3172 catalog. Wire individual tools through MCP, hooks, or skills when you\n\
3173 want them available inside a session.\n"
3174}
3175
3176fn tools_example_script() -> &'static str {
3177 "#!/usr/bin/env sh\n\
3178 # name: example\n\
3179 # description: Print a confirmation that local tool discovery works\n\
3180 # usage: example [name]\n\
3181 printf 'codewhale-tui local tool ok: %s\\n' \"${1:-world}\"\n"
3182}
3183
3184fn init_tools_dir(tools_dir: &Path, force: bool) -> Result<(PathBuf, WriteStatus, WriteStatus)> {
3185 std::fs::create_dir_all(tools_dir)
3186 .with_context(|| format!("Failed to create tools dir {}", tools_dir.display()))?;
3187
3188 let readme_path = tools_dir.join("README.md");
3189 let readme_status = write_template_file(&readme_path, tools_readme_template(), force)?;
3190
3191 let example_path = tools_dir.join("example.sh");
3192 let example_status = write_template_file(&example_path, tools_example_script(), force)?;
3193
3194 Ok((tools_dir.to_path_buf(), readme_status, example_status))
3195}
3196
3197fn plugins_readme_template() -> &'static str {
3198 "# Local plugins\n\n\
3199 Each Codewhale plugin bundle lives in its own subdirectory with a\n\
3200 versioned `plugin.toml`. User bundles live here; workspace bundles live\n\
3201 under `<workspace>/.codewhale/plugins/`. Both are discovered read-only,\n\
3202 untrusted, and disabled by default.\n\n\
3203 A v0.9.1 bundle layout looks like:\n\n\
3204 ```\n\
3205 plugins/\n\
3206 my-plugin/\n\
3207 plugin.toml\n\
3208 skills/\n\
3209 my-skill/SKILL.md\n\
3210 ```\n\n\
3211 Run `/plugin validate`, `/plugin show <name>`, then `/plugin enable <name>`.\n\
3212 Enablement opens a content- and capability-bound trust review;\n\
3213 confirm the displayed `/plugin trust` command to create an owner-only,\n\
3214 content-addressed runtime snapshot, then enable the bundle. Remote MCP\n\
3215 authentication must name environment sources; never store secret values\n\
3216 in `plugin.toml`.\n\n\
3217 v0.9.1 activates only declarative Skills and MCP servers through their\n\
3218 existing engines. Commands, agents, hooks, LSP, native extensions,\n\
3219 filesystem grants, and lifecycle mutation are inventoried but inactive.\n\
3220 There is no marketplace, install, update, ambient compatibility scan, or\n\
3221 automatic trust surface in this release.\n"
3222}
3223
3224fn plugin_example_manifest_template() -> &'static str {
3225 "schema_version = 1\n\n\
3226 [plugin]\n\
3227 name = \"example\"\n\
3228 version = \"0.1.0\"\n\
3229 description = \"Starter Codewhale plugin bundle\"\n\n\
3230 [skills]\n\
3231 path = \"skills\"\n"
3232}
3233
3234fn plugin_example_skill_template() -> &'static str {
3235 "---\n\
3236 name: hello\n\
3237 description: Explain that the example plugin bundle is active.\n\
3238 ---\n\n\
3239 Tell the user this instruction came from the namespaced\n\
3240 `example:hello` plugin skill. Do not perform side effects.\n"
3241}
3242
3243fn init_plugins_dir(
3244 plugins_dir: &Path,
3245 force: bool,
3246) -> Result<(
3247 PathBuf,
3248 PathBuf,
3249 PathBuf,
3250 WriteStatus,
3251 WriteStatus,
3252 WriteStatus,
3253)> {
3254 std::fs::create_dir_all(plugins_dir)
3255 .with_context(|| format!("Failed to create plugins dir {}", plugins_dir.display()))?;
3256
3257 let readme_path = plugins_dir.join("README.md");
3258 let readme_status = write_template_file(&readme_path, plugins_readme_template(), force)?;
3259
3260 let manifest_path = plugins_dir.join("example").join("plugin.toml");
3261 ensure_parent_dir(&manifest_path)?;
3262 let manifest_status =
3263 write_template_file(&manifest_path, plugin_example_manifest_template(), force)?;
3264
3265 let skill_path = plugins_dir
3266 .join("example")
3267 .join("skills")
3268 .join("hello")
3269 .join("SKILL.md");
3270 ensure_parent_dir(&skill_path)?;
3271 let skill_status = write_template_file(&skill_path, plugin_example_skill_template(), force)?;
3272
3273 Ok((
3274 readme_path,
3275 manifest_path,
3276 skill_path,
3277 readme_status,
3278 manifest_status,
3279 skill_status,
3280 ))
3281}
3282
3283fn resolve_cors_origins(config: &Config, flag_origins: &[String]) -> Vec<String> {
3296 let mut out: Vec<String> = Vec::new();
3297 let mut push = |raw: &str| {
3298 let trimmed = raw.trim();
3299 if trimmed.is_empty() {
3300 return;
3301 }
3302 if !out.iter().any(|existing| existing == trimmed) {
3303 out.push(trimmed.to_string());
3304 }
3305 };
3306 for o in flag_origins {
3307 push(o);
3308 }
3309 if let Ok(env_value) =
3310 std::env::var("CODEWHALE_CORS_ORIGINS").or_else(|_| std::env::var("DEEPSEEK_CORS_ORIGINS"))
3311 {
3312 for piece in env_value.split(',') {
3313 push(piece);
3314 }
3315 }
3316 if let Some(rt) = &config.runtime_api
3317 && let Some(list) = &rt.cors_origins
3318 {
3319 for o in list {
3320 push(o);
3321 }
3322 }
3323 out
3324}
3325
3326fn deepseek_home_dir() -> PathBuf {
3327 codewhale_config::codewhale_home().unwrap_or_else(|_| {
3328 crate::config::effective_home_dir()
3329 .map_or_else(|| PathBuf::from(".codewhale"), |h| h.join(".codewhale"))
3330 })
3331}
3332
3333fn default_tools_dir() -> PathBuf {
3335 deepseek_home_dir().join("tools")
3336}
3337
3338fn default_plugins_dir() -> PathBuf {
3340 deepseek_home_dir().join("plugins")
3341}
3342
3343fn default_checkpoints_dir() -> PathBuf {
3345 deepseek_home_dir().join("sessions").join("checkpoints")
3346}
3347
3348#[derive(Debug, Clone, PartialEq, Eq)]
3349struct CleanPlan {
3350 targets: Vec<PathBuf>,
3351}
3352
3353fn collect_clean_targets(checkpoints_dir: &Path) -> CleanPlan {
3354 let mut targets: Vec<PathBuf> = std::fs::read_dir(checkpoints_dir)
3360 .map(|entries| {
3361 entries
3362 .filter_map(|entry| entry.ok().map(|e| e.path()))
3363 .filter(|p| p.is_file() && p.extension().is_some_and(|ext| ext == "json"))
3364 .collect()
3365 })
3366 .unwrap_or_default();
3367 targets.sort();
3368 CleanPlan { targets }
3369}
3370
3371fn execute_clean_plan(plan: &CleanPlan) -> Result<Vec<PathBuf>> {
3372 let mut removed = Vec::with_capacity(plan.targets.len());
3373 for path in &plan.targets {
3374 std::fs::remove_file(path)
3375 .with_context(|| format!("Failed to remove {}", path.display()))?;
3376 removed.push(path.clone());
3377 }
3378 Ok(removed)
3379}
3380
3381fn run_setup(
3382 config: &Config,
3383 workspace: &Path,
3384 args: SetupArgs,
3385 plugins: &crate::plugins::PluginRegistry,
3386) -> Result<()> {
3387 if args.status {
3388 return run_setup_status(config, workspace, plugins);
3389 }
3390 if args.clean {
3391 return run_setup_clean(&default_checkpoints_dir(), args.force);
3392 }
3393
3394 use crate::palette;
3395 use colored::Colorize;
3396
3397 let (aqua_r, aqua_g, aqua_b) = palette::WHALE_INFO_RGB;
3398 let (sky_r, sky_g, sky_b) = palette::WHALE_INFO_RGB;
3399
3400 let any_explicit = args.mcp || args.skills || args.tools || args.plugins;
3401 let run_mcp = args.mcp || args.all || !any_explicit;
3402 let run_skills = args.skills || args.all || !any_explicit;
3403 let run_tools = args.tools || args.all;
3404 let run_plugins = args.plugins || args.all;
3405
3406 println!(
3407 "{}",
3408 "Codewhale Setup".truecolor(aqua_r, aqua_g, aqua_b).bold()
3409 );
3410 println!("{}", "==============".truecolor(sky_r, sky_g, sky_b));
3411 println!("Workspace: {}", crate::utils::display_path(workspace));
3412
3413 if run_mcp {
3414 let mcp_path = config.mcp_config_path();
3415 let status = init_mcp_config(&mcp_path, args.force)?;
3416 match status {
3417 WriteStatus::Created => {
3418 println!(" ✓ Created MCP config at {}", mcp_path.display());
3419 }
3420 WriteStatus::Overwritten => {
3421 println!(" ✓ Overwrote MCP config at {}", mcp_path.display());
3422 }
3423 WriteStatus::SkippedExists => {
3424 println!(" · MCP config already exists at {}", mcp_path.display());
3425 }
3426 }
3427 println!(
3428 " Next: edit the file, then run `codewhale mcp list` or `codewhale mcp tools`."
3429 );
3430 }
3431
3432 if run_skills {
3433 let skills_dir = if args.local {
3434 workspace.join("skills")
3435 } else {
3436 config.skills_dir()
3437 };
3438 let (skill_path, status) = init_skills_dir(&skills_dir, args.force)?;
3439 match status {
3440 WriteStatus::Created => {
3441 println!(" ✓ Created example skill at {}", skill_path.display());
3442 }
3443 WriteStatus::Overwritten => {
3444 println!(" ✓ Overwrote example skill at {}", skill_path.display());
3445 }
3446 WriteStatus::SkippedExists => {
3447 println!(
3448 " · Example skill already exists at {}",
3449 skill_path.display()
3450 );
3451 }
3452 }
3453 if args.local {
3454 println!(
3455 " Local skills dir enabled for this workspace: {}",
3456 crate::utils::display_path(&skills_dir)
3457 );
3458 } else {
3459 println!(
3460 " Skills dir: {}",
3461 crate::utils::display_path(&skills_dir)
3462 );
3463 }
3464 println!(" Next: run the TUI and use `/skills` then `/skill getting-started`.");
3465 }
3466
3467 if run_tools {
3468 let tools_dir = default_tools_dir();
3469 let (dir, readme_status, example_status) = init_tools_dir(&tools_dir, args.force)?;
3470 report_write_status("Tools README", &dir.join("README.md"), readme_status);
3471 report_write_status("Example tool", &dir.join("example.sh"), example_status);
3472 println!(" Tools dir: {}", crate::utils::display_path(&dir));
3473 println!(" Next: drop scripts here; surface them via skills/MCP when ready.");
3474 }
3475
3476 if run_plugins {
3477 let plugins_dir = default_plugins_dir();
3478 let (readme_path, manifest_path, skill_path, readme_status, manifest_status, skill_status) =
3479 init_plugins_dir(&plugins_dir, args.force)?;
3480 report_write_status("Plugins README", &readme_path, readme_status);
3481 report_write_status("Example plugin manifest", &manifest_path, manifest_status);
3482 report_write_status("Example plugin skill", &skill_path, skill_status);
3483 println!(
3484 " Plugins dir: {}",
3485 crate::utils::display_path(&plugins_dir)
3486 );
3487 println!(" Next: run `/plugin validate`, review `example`, then trust and enable it.");
3488 }
3489
3490 let sandbox = crate::sandbox::get_platform_sandbox_with_bwrap_preference(
3491 config.prefer_bwrap.unwrap_or(false),
3492 );
3493 if let Some(kind) = sandbox {
3494 println!(" ✓ Sandbox available: {kind}");
3495 } else {
3496 println!(" · Sandbox not available on this platform (best-effort only).");
3497 }
3498
3499 Ok(())
3500}
3501
3502fn report_write_status(label: &str, path: &Path, status: WriteStatus) {
3503 match status {
3504 WriteStatus::Created => {
3505 println!(" ✓ Created {label} at {}", path.display());
3506 }
3507 WriteStatus::Overwritten => {
3508 println!(" ✓ Overwrote {label} at {}", path.display());
3509 }
3510 WriteStatus::SkippedExists => {
3511 println!(" · {label} already exists at {}", path.display());
3512 }
3513 }
3514}
3515
3516#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3521enum ApiKeySource {
3522 ConfigDeclared,
3523 EnvDeclared,
3524 ExternalAuthDeclared,
3525 SecretStoreUnprobed,
3526 SecretStoreUnavailable,
3527 OAuth,
3528 ExternalConsent,
3529 NoAuth,
3530 LocalRuntime,
3531 Unknown,
3532}
3533
3534#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3537enum CredentialAvailability {
3538 Present,
3539 NotRequired,
3540 Unknown,
3541 NotProbed,
3542 Unavailable,
3543}
3544
3545impl CredentialAvailability {
3546 fn label(self) -> &'static str {
3547 match self {
3548 Self::Present => "present",
3549 Self::NotRequired => "not_required",
3550 Self::Unknown => "unknown",
3551 Self::NotProbed => "not_probed",
3552 Self::Unavailable => "unavailable",
3553 }
3554 }
3555
3556 fn certifies_ready(self) -> bool {
3557 matches!(self, Self::Present | Self::NotRequired)
3558 }
3559}
3560
3561#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3562struct CredentialDiagnostic {
3563 source: ApiKeySource,
3564 availability: CredentialAvailability,
3565}
3566
3567impl CredentialDiagnostic {
3568 const fn new(source: ApiKeySource, availability: CredentialAvailability) -> Self {
3569 Self {
3570 source,
3571 availability,
3572 }
3573 }
3574}
3575
3576fn resolve_credential_diagnostic(config: &Config) -> CredentialDiagnostic {
3577 let provider = config.api_provider();
3578 let auth_mode = config.auth_mode_for_provider(provider);
3579 if crate::config::auth_mode_disables_api_key(auth_mode.as_deref()) {
3580 return CredentialDiagnostic::new(
3581 ApiKeySource::NoAuth,
3582 CredentialAvailability::NotRequired,
3583 );
3584 }
3585 if !crate::config::auth_mode_requires_api_key(auth_mode.as_deref())
3586 && (provider.is_self_hosted()
3587 || crate::config::base_url_uses_local_host(&config.deepseek_base_url()))
3588 {
3589 return CredentialDiagnostic::new(
3590 ApiKeySource::LocalRuntime,
3591 CredentialAvailability::NotRequired,
3592 );
3593 }
3594 let custom_endpoint = config.provider_uses_custom_endpoint(provider);
3595 if !custom_endpoint && provider == crate::config::ApiProvider::OpenaiCodex {
3596 return config
3597 .external_credential_consent_status(provider)
3598 .filter(|status| status.route_state == "active")
3599 .map_or_else(
3600 || {
3601 CredentialDiagnostic::new(
3602 ApiKeySource::OAuth,
3603 CredentialAvailability::NotProbed,
3604 )
3605 },
3606 |_| {
3607 CredentialDiagnostic::new(
3608 ApiKeySource::ExternalConsent,
3609 CredentialAvailability::NotProbed,
3610 )
3611 },
3612 );
3613 }
3614 if !custom_endpoint
3615 && provider == crate::config::ApiProvider::Xai
3616 && auth_mode
3617 .as_deref()
3618 .is_some_and(crate::xai_oauth::auth_mode_uses_xai_oauth)
3619 {
3620 return config
3621 .external_credential_consent_status(provider)
3622 .filter(|status| status.route_state == "active")
3623 .map_or_else(
3624 || {
3625 CredentialDiagnostic::new(
3626 ApiKeySource::OAuth,
3627 CredentialAvailability::NotProbed,
3628 )
3629 },
3630 |_| {
3631 CredentialDiagnostic::new(
3632 ApiKeySource::ExternalConsent,
3633 CredentialAvailability::NotProbed,
3634 )
3635 },
3636 );
3637 }
3638 let provider_config = config.provider_config();
3639 let provider_config_key_kind = provider_config
3640 .and_then(|entry| entry.api_key.as_deref())
3641 .map(crate::config::classify_config_api_key_value);
3642 let root_key_applies = matches!(
3643 provider,
3644 crate::config::ApiProvider::Deepseek | crate::config::ApiProvider::DeepseekCN
3645 ) || (provider == crate::config::ApiProvider::Custom
3646 && config.uses_legacy_literal_custom_route());
3647 let root_key_kind = root_key_applies
3648 .then_some(config.api_key.as_deref())
3649 .flatten()
3650 .map(crate::config::classify_config_api_key_value);
3651
3652 if matches!(
3653 provider_config_key_kind,
3654 Some(crate::config::ConfigApiKeyValueKind::Literal)
3655 ) || matches!(
3656 root_key_kind,
3657 Some(crate::config::ConfigApiKeyValueKind::Literal)
3658 ) {
3659 CredentialDiagnostic::new(
3660 ApiKeySource::ConfigDeclared,
3661 CredentialAvailability::Present,
3662 )
3663 } else if config
3664 .provider_config()
3665 .and_then(|entry| entry.api_key_env.as_deref())
3666 .is_some_and(|name| !name.trim().is_empty())
3667 {
3668 CredentialDiagnostic::new(ApiKeySource::EnvDeclared, CredentialAvailability::NotProbed)
3669 } else if config
3670 .provider_config()
3671 .and_then(|entry| entry.auth.as_ref())
3672 .is_some()
3673 {
3674 CredentialDiagnostic::new(
3675 ApiKeySource::ExternalAuthDeclared,
3676 CredentialAvailability::NotProbed,
3677 )
3678 } else if matches!(
3679 provider_config_key_kind,
3680 Some(crate::config::ConfigApiKeyValueKind::SecretStoreSentinel)
3681 ) || matches!(
3682 root_key_kind,
3683 Some(crate::config::ConfigApiKeyValueKind::SecretStoreSentinel)
3684 ) {
3685 if config.should_skip_secret_store_for_provider(provider) {
3686 return CredentialDiagnostic::new(
3687 ApiKeySource::SecretStoreUnavailable,
3688 CredentialAvailability::Unavailable,
3689 );
3690 }
3691 CredentialDiagnostic::new(
3694 ApiKeySource::SecretStoreUnprobed,
3695 CredentialAvailability::NotProbed,
3696 )
3697 } else if !config.should_skip_secret_store_for_provider(provider) {
3698 CredentialDiagnostic::new(
3702 ApiKeySource::SecretStoreUnprobed,
3703 CredentialAvailability::NotProbed,
3704 )
3705 } else {
3706 CredentialDiagnostic::new(ApiKeySource::Unknown, CredentialAvailability::Unknown)
3707 }
3708}
3709
3710#[cfg(test)]
3711fn resolve_api_key_source(config: &Config) -> ApiKeySource {
3712 resolve_credential_diagnostic(config).source
3713}
3714
3715fn provider_config_table_key(provider: crate::config::ApiProvider) -> &'static str {
3716 provider
3717 .metadata()
3718 .map(|metadata| metadata.provider_config_key())
3719 .unwrap_or("deepseek_cn")
3720}
3721
3722fn count_dir_entries(dir: &Path) -> usize {
3723 std::fs::read_dir(dir)
3724 .map(|entries| entries.filter_map(std::result::Result::ok).count())
3725 .unwrap_or(0)
3726}
3727
3728fn skills_count_for(dir: &Path) -> usize {
3729 if !dir.exists() {
3730 return 0;
3731 }
3732 crate::skills::SkillRegistry::discover(dir).len()
3733}
3734
3735fn run_setup_status(
3736 config: &Config,
3737 workspace: &Path,
3738 plugins: &crate::plugins::PluginRegistry,
3739) -> Result<()> {
3740 use crate::palette;
3741 use colored::Colorize;
3742
3743 let (aqua_r, aqua_g, aqua_b) = palette::WHALE_INFO_RGB;
3744 let (sky_r, sky_g, sky_b) = palette::WHALE_INFO_RGB;
3745
3746 println!(
3747 "{}",
3748 "Codewhale Status".truecolor(aqua_r, aqua_g, aqua_b).bold()
3749 );
3750 println!("{}", "===============".truecolor(sky_r, sky_g, sky_b));
3751 println!("workspace: {}", workspace.display());
3752
3753 let credential = resolve_credential_diagnostic(config);
3754 match credential.source {
3755 ApiKeySource::ConfigDeclared => println!(
3756 " {} api_key: literal config value structurally present",
3757 "✓".truecolor(aqua_r, aqua_g, aqua_b)
3758 ),
3759 ApiKeySource::EnvDeclared => println!(
3760 " {} api_key: environment source declared (value not inspected)",
3761 "·".dimmed()
3762 ),
3763 ApiKeySource::ExternalAuthDeclared => println!(
3764 " {} api_key: external auth source declared (value not inspected)",
3765 "·".dimmed()
3766 ),
3767 ApiKeySource::SecretStoreUnprobed => println!(
3768 " {} api_key: secret store eligible (store not probed)",
3769 "·".dimmed()
3770 ),
3771 ApiKeySource::SecretStoreUnavailable => println!(
3772 " {} api_key: secret-store sentinel declared, but this route cannot use that store",
3773 "!".truecolor(sky_r, sky_g, sky_b)
3774 ),
3775 ApiKeySource::OAuth => println!(
3776 " {} oauth: Codewhale-owned route selected (token availability not probed)",
3777 "·".dimmed()
3778 ),
3779 ApiKeySource::ExternalConsent => println!(
3780 " {} oauth: external read-only consent configured (credential file not probed)",
3781 "·".dimmed()
3782 ),
3783 ApiKeySource::NoAuth => println!(
3784 " {} api_key: disabled for this route",
3785 "✓".truecolor(aqua_r, aqua_g, aqua_b)
3786 ),
3787 ApiKeySource::LocalRuntime => println!(
3788 " {} api_key: not required for this local runtime",
3789 "✓".truecolor(aqua_r, aqua_g, aqua_b)
3790 ),
3791 ApiKeySource::Unknown => println!(
3792 " {} api_key: unknown (credential environment and durable stores not inspected)",
3793 "·".dimmed()
3794 ),
3795 }
3796 println!(
3797 " · credential availability: {}",
3798 credential.availability.label()
3799 );
3800 println!(
3801 " · base_url: {}",
3802 crate::doctor::structural_url_authority(&config.deepseek_base_url())
3803 );
3804 let model = config
3805 .default_text_model
3806 .clone()
3807 .unwrap_or_else(|| DEFAULT_TEXT_MODEL.to_string());
3808 println!(" · default_text_model: {model}");
3809 let (default_mode, default_mode_source) = doctor_runtime_default_mode();
3810 println!(" · default_mode: {default_mode} ({default_mode_source})");
3811
3812 let mcp_path = config.mcp_config_path();
3813 let project_mcp_path = crate::mcp::workspace_mcp_config_path(workspace);
3814 let mcp_count =
3815 match crate::mcp::load_config_with_workspace_and_plugins(&mcp_path, workspace, plugins) {
3816 Ok(cfg) => cfg.servers.len(),
3817 Err(_) => 0,
3818 };
3819 let mcp_present = if mcp_path.exists() { "" } else { " (missing)" };
3820 let project_mcp_present = if project_mcp_path.exists() {
3821 ""
3822 } else {
3823 " (missing)"
3824 };
3825 println!(
3826 " · mcp servers: {mcp_count} from {}{mcp_present} + {}{project_mcp_present}",
3827 mcp_path.display(),
3828 project_mcp_path.display()
3829 );
3830
3831 let skills_dir = config.skills_dir();
3832 println!(
3833 " · skills: {} at {}",
3834 skills_count_for(&skills_dir),
3835 crate::utils::display_path(&skills_dir)
3836 );
3837
3838 let tools_dir = default_tools_dir();
3839 let tools_present = if tools_dir.exists() {
3840 ""
3841 } else {
3842 " (missing — run `setup --tools`)"
3843 };
3844 println!(
3845 " · tools: {} entries at {}{tools_present}",
3846 if tools_dir.exists() {
3847 count_dir_entries(&tools_dir)
3848 } else {
3849 0
3850 },
3851 crate::utils::display_path(&tools_dir)
3852 );
3853
3854 let plugins_dir = default_plugins_dir();
3855 let plugins_present = if plugins_dir.exists() {
3856 ""
3857 } else {
3858 " (missing — run `setup --plugins`)"
3859 };
3860 println!(
3861 " · plugins: {} entries at {}{plugins_present}",
3862 if plugins_dir.exists() {
3863 count_dir_entries(&plugins_dir)
3864 } else {
3865 0
3866 },
3867 crate::utils::display_path(&plugins_dir)
3868 );
3869
3870 let sandbox = crate::sandbox::get_platform_sandbox_with_bwrap_preference(
3871 config.prefer_bwrap.unwrap_or(false),
3872 );
3873 match sandbox {
3874 Some(kind) => println!(
3875 " {} sandbox: {kind}",
3876 "✓".truecolor(aqua_r, aqua_g, aqua_b)
3877 ),
3878 None => println!(
3879 " {} sandbox: unavailable (commands run best-effort)",
3880 "!".truecolor(sky_r, sky_g, sky_b)
3881 ),
3882 }
3883
3884 println!(" {} {}", "·".dimmed(), dotenv_status_line(workspace));
3885
3886 println!();
3887 println!("Run `codewhale doctor --json` for a machine-readable check.");
3888 Ok(())
3889}
3890
3891fn dotenv_status_line(workspace: &Path) -> String {
3892 let dotenv = workspace.join(".env");
3893 if dotenv.exists() {
3894 return format!(
3895 ".env present at {} (literal provider credentials only)",
3896 dotenv.display()
3897 );
3898 }
3899
3900 if workspace.join(".env.example").exists() {
3901 return ".env not present in workspace (run `cp .env.example .env` and edit)".to_string();
3902 }
3903
3904 ".env not present in workspace".to_string()
3905}
3906
3907fn run_setup_clean(checkpoints_dir: &Path, force: bool) -> Result<()> {
3908 use colored::Colorize;
3909
3910 if !checkpoints_dir.exists() {
3911 println!(
3912 "Nothing to clean — checkpoints dir does not exist: {}",
3913 checkpoints_dir.display()
3914 );
3915 return Ok(());
3916 }
3917
3918 let plan = collect_clean_targets(checkpoints_dir);
3919 if plan.targets.is_empty() {
3920 println!(
3921 "Nothing to clean — no checkpoint files in {}",
3922 checkpoints_dir.display()
3923 );
3924 return Ok(());
3925 }
3926
3927 if !force {
3928 println!(
3929 "Would remove {} checkpoint file(s) (use --force to apply):",
3930 plan.targets.len()
3931 );
3932 for path in &plan.targets {
3933 println!(" · {}", path.display());
3934 }
3935 return Ok(());
3936 }
3937
3938 let removed = execute_clean_plan(&plan)?;
3939 println!("{}", "Cleaned checkpoints:".bold());
3940 for path in &removed {
3941 println!(" ✓ {}", path.display());
3942 }
3943 Ok(())
3944}
3945
3946fn run_session_diagnostics(args: SessionDiagnosticsArgs) -> Result<()> {
3947 let contents = std::fs::read_to_string(&args.path).with_context(|| {
3948 format!(
3949 "read session diagnostic JSONL from {}",
3950 crate::utils::display_path(&args.path)
3951 )
3952 })?;
3953 let summary = crate::session_diagnostics::analyze_session_failure_jsonl(&contents);
3954 if args.json {
3955 println!("{}", serde_json::to_string_pretty(&summary)?);
3956 } else {
3957 println!(
3958 "{}",
3959 crate::session_diagnostics::format_redacted_failure_summary(&summary)
3960 );
3961 }
3962 Ok(())
3963}
3964
3965fn doctor_should_probe_api(
3968 provider: crate::config::ApiProvider,
3969 base_url: &str,
3970 probes: crate::doctor::DoctorProbeRequest,
3971) -> bool {
3972 let local = provider.is_self_hosted() || crate::config::base_url_uses_local_host(base_url);
3973 probes.should_probe_api(local)
3974}
3975
3976fn doctor_should_probe_auth(config: &Config) -> bool {
3980 let provider = config.api_provider();
3981 if provider == crate::config::ApiProvider::OpenaiCodex
3982 && !config.provider_uses_custom_endpoint(provider)
3983 {
3984 return false;
3985 }
3986 let auth_mode = config.auth_mode_for_provider(provider);
3987 if provider == crate::config::ApiProvider::Xai
3988 && auth_mode
3989 .as_deref()
3990 .is_some_and(crate::xai_oauth::auth_mode_uses_xai_oauth)
3991 {
3992 return false;
3993 }
3994 !(provider == crate::config::ApiProvider::Moonshot
3995 && auth_mode
3996 .as_deref()
3997 .is_some_and(crate::config::auth_mode_uses_kimi_imported_token))
3998}
3999
4000async fn run_doctor(
4002 config: &Config,
4003 workspace: &Path,
4004 config_path_override: Option<&Path>,
4005 probes: crate::doctor::DoctorProbeRequest,
4006 plugins: &crate::plugins::PluginRegistry,
4007) {
4008 use crate::palette;
4009 use colored::Colorize;
4010
4011 let (accent_r, accent_g, accent_b) = palette::WHALE_HUMAN_RGB;
4012 let (sky_r, sky_g, sky_b) = palette::WHALE_INFO_RGB;
4013 let (aqua_r, aqua_g, aqua_b) = palette::WHALE_INFO_RGB;
4014 let (red_r, red_g, red_b) = palette::WHALE_ERROR_RGB;
4015
4016 println!(
4017 "{}",
4018 "codewhale Doctor"
4019 .truecolor(accent_r, accent_g, accent_b)
4020 .bold()
4021 );
4022 println!("{}", "==================".truecolor(sky_r, sky_g, sky_b));
4023 println!();
4024
4025 println!("{}", "Version Information:".bold());
4027 println!(" codewhale-tui: {}", env!("DEEPSEEK_BUILD_VERSION"));
4028 println!(" rust: {}", rustc_version());
4029 println!();
4030
4031 println!("{}", "Updates:".bold());
4032 crate::doctor::print_update_report(probes).await;
4033 println!();
4034
4035 let doctor_paths = match crate::doctor::DoctorPathReport::resolve(config_path_override) {
4037 Ok(paths) => paths,
4038 Err(error) => {
4039 println!("{}", "Resolved User Paths:".bold());
4040 println!(
4041 " {} unavailable: {error:#}",
4042 "✗".truecolor(red_r, red_g, red_b)
4043 );
4044 return;
4045 }
4046 };
4047 println!("{}", "Configuration:".bold());
4048 let config_path = &doctor_paths.config;
4049
4050 if config_path.exists() {
4051 println!(
4052 " {} config.toml found at {}",
4053 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4054 crate::utils::display_path(config_path)
4055 );
4056 if let Ok(raw) = std::fs::read_to_string(config_path) {
4059 let flagged = crate::doctor::config_credential_shaped_keys(&raw);
4060 if !flagged.is_empty() {
4061 println!(
4062 " {} credential-shaped value(s) in config.toml ({}): move them to the secret backend, then scrub the file — config.toml is plain text",
4063 "!".truecolor(sky_r, sky_g, sky_b),
4064 flagged.join(", ")
4065 );
4066 }
4067 }
4068 } else {
4069 println!(
4070 " {} config.toml not found at {} (using defaults/env)",
4071 "!".truecolor(sky_r, sky_g, sky_b),
4072 crate::utils::display_path(config_path)
4073 );
4074 }
4075 println!(" workspace: {}", crate::utils::display_path(workspace));
4076 println!(" {}", doctor_search_provider_line(config));
4077
4078 println!();
4079 println!("{}", "Resolved User Paths (read-only):".bold());
4080 for (label, path) in doctor_paths.entries() {
4081 println!(" · {label}: {}", crate::utils::display_path(path));
4082 }
4083
4084 let secret_backend = codewhale_secrets::diagnose_secret_backend();
4085 println!();
4086 println!("{}", "Secret Backend (structural only):".bold());
4087 for line in crate::doctor::secret_backend_human_lines(&secret_backend) {
4088 println!(" · {line}");
4089 }
4090
4091 println!();
4093 println!("{}", "State Root:".bold());
4094 let (code_home, legacy_home) = doctor_state_roots();
4095 let active_root = if code_home.exists() {
4096 &code_home
4097 } else if legacy_home.exists() {
4098 &legacy_home
4099 } else {
4100 &code_home
4101 };
4102 println!(" active: {}", crate::utils::display_path(active_root));
4103 if active_root != &code_home {
4104 println!(
4105 " note: legacy {} found; start Codewhale once to trigger safe migration where available.",
4106 crate::utils::display_path(&legacy_home)
4107 );
4108 }
4109 if legacy_home.exists() && code_home.exists() {
4110 println!(
4111 " dual roots: {} (primary) + {} (legacy)",
4112 crate::utils::display_path(&code_home),
4113 crate::utils::display_path(&legacy_home)
4114 );
4115 }
4116 let legacy_state_report = doctor_legacy_state_report(&code_home, &legacy_home);
4117 let session_recovery = doctor_session_recovery_report(
4118 &code_home,
4119 &legacy_home,
4120 codewhale_config::codewhale_home_is_explicit(),
4121 );
4122 print_doctor_legacy_state_report(
4123 &legacy_state_report,
4124 &session_recovery,
4125 (aqua_r, aqua_g, aqua_b),
4126 (sky_r, sky_g, sky_b),
4127 );
4128
4129 let (setup_state, setup_source) = doctor_setup_state(config, workspace);
4130 print_doctor_setup_report(
4131 config,
4132 workspace,
4133 &setup_state,
4134 setup_source,
4135 (aqua_r, aqua_g, aqua_b),
4136 (sky_r, sky_g, sky_b),
4137 );
4138
4139 println!();
4141 println!("{}", "API Keys:".bold());
4142
4143 for provider in crate::config::ApiProvider::all().iter().copied() {
4147 let slot = provider.as_str();
4148 let provider_config = config.provider_config_for(provider);
4149 let config_declared = provider_config.is_some_and(|entry| {
4150 entry.api_key.as_deref().is_some_and(|key| {
4151 crate::config::classify_config_api_key_value(key)
4152 == crate::config::ConfigApiKeyValueKind::Literal
4153 })
4154 }) || (matches!(provider, crate::config::ApiProvider::Deepseek)
4155 && config.api_key.as_deref().is_some_and(|key| {
4156 crate::config::classify_config_api_key_value(key)
4157 == crate::config::ConfigApiKeyValueKind::Literal
4158 }));
4159 let env_source_declared = provider_config
4160 .and_then(|entry| entry.api_key_env.as_deref())
4161 .is_some_and(|name| !name.trim().is_empty());
4162 let icon = if config_declared || env_source_declared {
4163 "·".truecolor(aqua_r, aqua_g, aqua_b)
4164 } else {
4165 "·".dimmed()
4166 };
4167 println!(
4168 " {} {slot}: env_source={}, config_source={}",
4169 icon,
4170 if env_source_declared {
4171 "declared (value not inspected)"
4172 } else {
4173 "not inspected"
4174 },
4175 if config_declared {
4176 "declared (value not inspected)"
4177 } else {
4178 "not declared"
4179 }
4180 );
4181 }
4182 println!(" · credential precedence is unchanged; doctor does not inspect credential values");
4183 println!();
4184 println!(
4185 "{}",
4186 "External credential consent (configuration only):".bold()
4187 );
4188 for line in doctor_external_credential_consent_lines(config) {
4189 println!(" {line}");
4190 }
4191
4192 let credential = resolve_credential_diagnostic(config);
4193 let source_label = match credential.source {
4194 ApiKeySource::ConfigDeclared => "literal config value structurally present",
4195 ApiKeySource::EnvDeclared => "environment source declared; value not inspected",
4196 ApiKeySource::ExternalAuthDeclared => {
4197 "external auth source declared; credential not resolved"
4198 }
4199 ApiKeySource::SecretStoreUnprobed => "secret store eligible; store not probed",
4200 ApiKeySource::SecretStoreUnavailable => {
4201 "secret-store sentinel declared, but this route cannot use that store"
4202 }
4203 ApiKeySource::OAuth => "OAuth route configured; token availability not probed",
4204 ApiKeySource::ExternalConsent => "external consent configured; token file not read",
4205 ApiKeySource::NoAuth => "no-auth route",
4206 ApiKeySource::LocalRuntime => "local runtime; credentials not required",
4207 ApiKeySource::Unknown => "unknown; credential environment and stores not inspected",
4208 };
4209 println!(
4210 " {} active provider credential source: {source_label}",
4211 "·".dimmed()
4212 );
4213 println!(
4214 " · active provider credential availability: {}",
4215 credential.availability.label()
4216 );
4217
4218 println!();
4220 println!("{}", "API Connectivity:".bold());
4221 let api_target = doctor_api_target(config);
4222 println!(
4226 " · 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)"
4227 );
4228 println!(" · provider: {}", api_target.provider);
4229 println!(
4230 " · base_url: {}",
4231 crate::doctor::structural_url_authority(&api_target.base_url)
4232 );
4233 match api_target.resolution {
4234 DoctorModelResolution::Resolved => {
4235 println!(" · model: {} (resolved)", api_target.model);
4236 }
4237 DoctorModelResolution::ConfiguredOnly => {
4238 println!(
4239 " · model: {} (configured; route resolution unavailable)",
4240 api_target.model
4241 );
4242 }
4243 }
4244 let tls_status = doctor_tls_status(config);
4245 if !tls_status.certificate_verification {
4246 println!(" ! {}", tls_status.message);
4247 println!(" Prefer SSL_CERT_FILE with a trusted custom CA bundle when possible.");
4248 }
4249 let strict_tool_mode = doctor_strict_tool_mode_status(config);
4250 let strict_icon = match strict_tool_mode.status {
4251 "ready" => "✓".truecolor(aqua_r, aqua_g, aqua_b),
4252 "fallback_non_beta" | "custom_endpoint" => "!".truecolor(sky_r, sky_g, sky_b),
4253 _ => "·".dimmed(),
4254 };
4255 println!(
4256 " {} strict_tool_mode: {}",
4257 strict_icon, strict_tool_mode.message
4258 );
4259 if let Some(recommended) = strict_tool_mode.recommended_base_url.as_deref() {
4260 println!(
4261 " Use the {} endpoint for DeepSeek strict schemas.",
4262 crate::doctor::structural_url_authority(recommended)
4263 );
4264 }
4265 let capability = crate::config::provider_capability(config.api_provider(), &api_target.model);
4266 if let Some(alias) = capability.alias_deprecation.as_ref() {
4267 println!(
4268 " ! model alias {} retires {}; switch to {}",
4269 alias.alias, alias.retirement_date, alias.replacement
4270 );
4271 }
4272 let live_api_requested =
4273 doctor_should_probe_api(config.api_provider(), &api_target.base_url, probes);
4274 let endpoint_is_local = config.api_provider().is_self_hosted()
4275 || crate::config::base_url_uses_local_host(&api_target.base_url);
4276 if doctor_should_probe_auth(config) && live_api_requested {
4277 print!(" {} Testing connection...", "·".dimmed());
4278 use std::io::Write;
4279 std::io::stdout().flush().ok();
4280
4281 let connectivity_result = match config.with_read_only_api_key_for_diagnostic() {
4286 Ok(diagnostic_config) => test_api_connectivity(&diagnostic_config).await,
4287 Err(error) => Err(error),
4288 };
4289 match connectivity_result {
4290 Ok(()) => {
4291 println!(
4292 "\r {} API connection successful",
4293 "✓".truecolor(aqua_r, aqua_g, aqua_b)
4294 );
4295 }
4296 Err(e) => {
4297 let error_msg = e.to_string();
4298 println!(
4299 "\r {} API connection failed",
4300 "✗".truecolor(red_r, red_g, red_b)
4301 );
4302 if error_msg.contains("401") || error_msg.contains("Unauthorized") {
4303 println!(
4304 " Invalid API key. Check `codewhale auth status`, DEEPSEEK_API_KEY, or config.toml"
4305 );
4306 } else if error_msg.contains("403") || error_msg.contains("Forbidden") {
4307 println!(
4308 " API key lacks permissions. Verify key is active at platform.deepseek.com"
4309 );
4310 } else if error_msg.contains("timeout") || error_msg.contains("Timeout") {
4311 for line in doctor_timeout_recovery_lines(config) {
4312 println!(" {line}");
4313 }
4314 } else if error_msg.contains("dns") || error_msg.contains("resolve") {
4315 println!(" DNS resolution failed. Check your network connection");
4316 } else if error_msg.contains("connect") {
4317 println!(" Connection failed. Check firewall settings or try again");
4318 } else {
4319 println!(
4320 " Error details omitted because provider failures can contain credential material."
4321 );
4322 }
4323 }
4324 }
4325 } else if !doctor_should_probe_auth(config) {
4326 println!(
4327 " {} Live OAuth connectivity not checked by non-mutating doctor",
4328 "·".dimmed()
4329 );
4330 println!(
4331 " Doctor never refreshes or rewrites credentials; exercise the route with a normal request."
4332 );
4333 } else {
4334 if endpoint_is_local {
4335 println!(
4336 " {} Live connectivity not checked for this local endpoint",
4337 "·".dimmed()
4338 );
4339 println!(
4340 " Run `codewhale doctor --probe-local` to opt in; the request may start a local service."
4341 );
4342 } else {
4343 println!(
4344 " {} Live hosted connectivity not checked (offline default)",
4345 "·".dimmed()
4346 );
4347 println!(" Run `codewhale doctor --probe-api` to opt in.");
4348 }
4349 }
4350
4351 println!();
4353 println!("{}", "MCP Servers (configuration only):".bold());
4354 println!(" · Static check only; no server process was started.");
4355 let features = config.features();
4356 if features.enabled(Feature::Mcp) {
4357 println!(
4358 " {} MCP feature flag enabled",
4359 "✓".truecolor(aqua_r, aqua_g, aqua_b)
4360 );
4361 } else {
4362 println!(
4363 " {} MCP feature flag disabled",
4364 "!".truecolor(sky_r, sky_g, sky_b)
4365 );
4366 }
4367
4368 let mcp_config_path = config.mcp_config_path();
4369 let project_mcp_config_path = crate::mcp::workspace_mcp_config_path(workspace);
4370 if mcp_config_path.exists() {
4371 println!(
4372 " {} MCP config found at {}",
4373 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4374 crate::utils::display_path(&mcp_config_path)
4375 );
4376 } else {
4377 println!(
4378 " {} MCP config not found at {}",
4379 "·".dimmed(),
4380 crate::utils::display_path(&mcp_config_path)
4381 );
4382 }
4383 if project_mcp_config_path.exists() {
4384 println!(
4385 " {} Project MCP config found at {}",
4386 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4387 crate::utils::display_path(&project_mcp_config_path)
4388 );
4389 } else {
4390 println!(
4391 " {} Project MCP config not found at {}",
4392 "·".dimmed(),
4393 crate::utils::display_path(&project_mcp_config_path)
4394 );
4395 }
4396
4397 match crate::mcp::load_config_with_workspace_and_plugins(&mcp_config_path, workspace, plugins) {
4398 Ok(cfg) if cfg.servers.is_empty() => {
4399 println!(" {} 0 merged server(s) configured", "·".dimmed());
4400 if !mcp_config_path.exists() && !project_mcp_config_path.exists() {
4401 println!(" Run `codewhale mcp init` or add `.codewhale/mcp.json`.");
4402 }
4403 }
4404 Ok(cfg) => {
4405 println!(
4406 " {} {} merged server(s) configured",
4407 "·".dimmed(),
4408 cfg.servers.len()
4409 );
4410 for (name, server) in &cfg.servers {
4411 let status = doctor_check_mcp_server(server);
4412 let icon = match &status {
4413 McpServerDoctorStatus::Ok(detail) => {
4414 format!(
4415 " {} {name}: configuration valid; {}",
4416 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4417 detail
4418 )
4419 }
4420 McpServerDoctorStatus::Warning(detail) => {
4421 format!(
4422 " {} {name}: configuration warning; {}",
4423 "!".truecolor(sky_r, sky_g, sky_b),
4424 detail
4425 )
4426 }
4427 McpServerDoctorStatus::Error(detail) => {
4428 format!(
4429 " {} {name}: configuration invalid; {}",
4430 "✗".truecolor(red_r, red_g, red_b),
4431 detail
4432 )
4433 }
4434 };
4435 println!("{icon}");
4436 if !server.is_enabled() {
4437 println!(" disabled; live health not checked");
4438 } else {
4439 println!(
4440 " process/protocol/backend: not checked; `codewhale mcp validate` explicitly starts and initializes configured servers"
4441 );
4442 }
4443 }
4444 if probes.should_probe_mcp() {
4445 println!();
4446 println!(
4447 " {} Live MCP probe enabled: starting enabled servers; backend tool health remains untested.",
4448 "!".truecolor(sky_r, sky_g, sky_b)
4449 );
4450 match crate::mcp::McpPool::from_config_path_with_workspace_and_plugins(
4451 &mcp_config_path,
4452 workspace,
4453 std::sync::Arc::new(plugins.clone()),
4454 ) {
4455 Ok(mut pool) => {
4456 let errors = pool.connect_all().await;
4457 let failed = errors
4458 .iter()
4459 .map(|(name, _)| name.as_str())
4460 .collect::<std::collections::BTreeSet<_>>();
4461 for (name, server) in &cfg.servers {
4462 if !server.is_enabled() {
4463 continue;
4464 }
4465 if failed.contains(name.as_str()) {
4466 println!(
4467 " {} {name}: process/protocol unreachable; error details omitted",
4468 "✗".truecolor(red_r, red_g, red_b)
4469 );
4470 } else {
4471 println!(
4472 " {} {name}: process reachable and protocol initialized; backend tool health not checked",
4473 "✓".truecolor(aqua_r, aqua_g, aqua_b)
4474 );
4475 }
4476 }
4477 }
4478 Err(_) => println!(
4479 " {} live MCP probe could not load merged configuration; details omitted",
4480 "✗".truecolor(red_r, red_g, red_b)
4481 ),
4482 }
4483 } else {
4484 println!(
4485 " Use codewhale doctor --probe-mcp to opt in to live process/protocol checks; it may start configured servers."
4486 );
4487 }
4488 }
4489 Err(_) => {
4490 println!(
4491 " {} MCP configuration could not be loaded; details omitted",
4492 "✗".truecolor(red_r, red_g, red_b)
4493 );
4494 }
4495 }
4496
4497 println!();
4499 println!("{}", "Skills:".bold());
4500 let global_skills_dir = config.skills_dir();
4501 let agents_skills_dir = workspace.join(".agents").join("skills");
4502 let local_skills_dir = workspace.join("skills");
4503 let agents_global_skills_dir = crate::skills::agents_global_skills_dir();
4504 let opencode_skills_dir = workspace.join(".opencode").join("skills");
4510 let claude_skills_dir = workspace.join(".claude").join("skills");
4511 let selected_skills_dir = if agents_skills_dir.exists() {
4512 agents_skills_dir.clone()
4513 } else if local_skills_dir.exists() {
4514 local_skills_dir.clone()
4515 } else if config.skills_dir.is_none()
4516 && let Some(global_agents) = agents_global_skills_dir.as_ref()
4517 && global_agents.exists()
4518 {
4519 global_agents.clone()
4520 } else {
4521 global_skills_dir.clone()
4522 };
4523
4524 let describe_dir = |dir: &Path| -> usize {
4525 std::fs::read_dir(dir)
4526 .map(|entries| entries.filter_map(std::result::Result::ok).count())
4527 .unwrap_or(0)
4528 };
4529
4530 if local_skills_dir.exists() {
4531 println!(
4532 " {} local skills dir found at {} ({} items)",
4533 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4534 crate::utils::display_path(&local_skills_dir),
4535 describe_dir(&local_skills_dir)
4536 );
4537 } else {
4538 println!(
4539 " {} local skills dir not found at {}",
4540 "·".dimmed(),
4541 crate::utils::display_path(&local_skills_dir)
4542 );
4543 }
4544
4545 if agents_skills_dir.exists() {
4546 println!(
4547 " {} .agents skills dir found at {} ({} items)",
4548 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4549 crate::utils::display_path(&agents_skills_dir),
4550 describe_dir(&agents_skills_dir)
4551 );
4552 } else {
4553 println!(
4554 " {} .agents skills dir not found at {}",
4555 "·".dimmed(),
4556 crate::utils::display_path(&agents_skills_dir)
4557 );
4558 }
4559
4560 if let Some(agents_global_skills_dir) = agents_global_skills_dir.as_ref() {
4561 if agents_global_skills_dir.exists() {
4562 println!(
4563 " {} global .agents skills dir found at {} ({} items)",
4564 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4565 crate::utils::display_path(agents_global_skills_dir),
4566 describe_dir(agents_global_skills_dir)
4567 );
4568 } else {
4569 println!(
4570 " {} global .agents skills dir not found at {}",
4571 "·".dimmed(),
4572 crate::utils::display_path(agents_global_skills_dir)
4573 );
4574 }
4575 }
4576
4577 if global_skills_dir.exists() {
4578 println!(
4579 " {} global skills dir found at {} ({} items)",
4580 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4581 crate::utils::display_path(&global_skills_dir),
4582 describe_dir(&global_skills_dir)
4583 );
4584 } else {
4585 println!(
4586 " {} global skills dir not found at {}",
4587 "·".dimmed(),
4588 crate::utils::display_path(&global_skills_dir)
4589 );
4590 }
4591
4592 if opencode_skills_dir.exists() {
4596 println!(
4597 " {} .opencode skills dir found at {} ({} items)",
4598 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4599 crate::utils::display_path(&opencode_skills_dir),
4600 describe_dir(&opencode_skills_dir)
4601 );
4602 }
4603 if claude_skills_dir.exists() {
4604 println!(
4605 " {} .claude skills dir found at {} ({} items)",
4606 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4607 crate::utils::display_path(&claude_skills_dir),
4608 describe_dir(&claude_skills_dir)
4609 );
4610 }
4611
4612 println!(
4613 " {} selected skills dir: {}",
4614 "·".dimmed(),
4615 crate::utils::display_path(&selected_skills_dir)
4616 );
4617 if !agents_skills_dir.exists()
4618 && !local_skills_dir.exists()
4619 && !agents_global_skills_dir
4620 .as_ref()
4621 .is_some_and(|dir| dir.exists())
4622 && !global_skills_dir.exists()
4623 {
4624 println!(" Run `codewhale setup --skills` (or add --local for ./skills).");
4625 }
4626
4627 println!();
4629 println!("{}", "Tools:".bold());
4630 let tools_dir = default_tools_dir();
4631 if tools_dir.exists() {
4632 let count = count_dir_entries(&tools_dir);
4633 println!(
4634 " {} tools dir found at {} ({} items)",
4635 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4636 crate::utils::display_path(&tools_dir),
4637 count
4638 );
4639 } else {
4640 println!(
4641 " {} tools dir not found at {}",
4642 "·".dimmed(),
4643 crate::utils::display_path(&tools_dir)
4644 );
4645 println!(" Run `codewhale setup --tools` to scaffold a starter dir.");
4646 }
4647
4648 println!();
4650 println!("{}", "Plugins:".bold());
4651 let plugins_dir = default_plugins_dir();
4652 if plugins_dir.exists() {
4653 let count = count_dir_entries(&plugins_dir);
4654 println!(
4655 " {} plugins dir found at {} ({} items)",
4656 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4657 crate::utils::display_path(&plugins_dir),
4658 count
4659 );
4660 } else {
4661 println!(
4662 " {} plugins dir not found at {}",
4663 "·".dimmed(),
4664 crate::utils::display_path(&plugins_dir)
4665 );
4666 println!(" Run `codewhale setup --plugins` to scaffold a starter dir.");
4667 }
4668
4669 println!();
4671 println!("{}", "Storage:".bold());
4672 if let Some(spillover_root) = crate::tools::truncate::spillover_root() {
4673 let (present, count) = if spillover_root.is_dir() {
4674 (true, count_dir_entries(&spillover_root))
4675 } else {
4676 (false, 0)
4677 };
4678 if present {
4679 println!(
4680 " {} tool-output spillover at {} ({} file{})",
4681 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4682 crate::utils::display_path(&spillover_root),
4683 count,
4684 if count == 1 { "" } else { "s" }
4685 );
4686 } else {
4687 println!(
4688 " {} tool-output spillover dir not yet created at {}",
4689 "·".dimmed(),
4690 crate::utils::display_path(&spillover_root)
4691 );
4692 }
4693 }
4694 let stash = crate::composer_stash::diagnostic_stash_report();
4695 if let Some(stash_path) = stash.path.as_ref() {
4696 if let Some(error) = stash.error.as_deref() {
4697 println!(
4698 " {} composer stash was not inspected at {}: {error}",
4699 "!".truecolor(sky_r, sky_g, sky_b),
4700 crate::utils::display_path(stash_path),
4701 );
4702 } else if stash.present {
4703 println!(
4704 " {} composer stash at {} ({} parked draft{})",
4705 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4706 crate::utils::display_path(stash_path),
4707 stash.count,
4708 if stash.count == 1 { "" } else { "s" }
4709 );
4710 } else {
4711 println!(
4712 " {} composer stash empty (Ctrl+G or Ctrl+S in the composer to park a draft)",
4713 "·".dimmed()
4714 );
4715 }
4716 } else if let Some(error) = stash.error.as_deref() {
4717 println!(
4718 " {} composer stash was not inspected: {error}",
4719 "!".truecolor(sky_r, sky_g, sky_b),
4720 );
4721 }
4722
4723 println!();
4728 println!("{}", "Tool Dependencies:".bold());
4729
4730 match crate::dependencies::resolve_python_interpreter() {
4731 Some(name) => println!(
4732 " {} Python: {} → code_execution tool registered",
4733 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4734 name
4735 ),
4736 None => {
4737 println!(
4738 " {} Python: not found (tried {:?})",
4739 "✗".truecolor(red_r, red_g, red_b),
4740 crate::dependencies::PYTHON_CANDIDATES,
4741 );
4742 println!(" code_execution tool is NOT advertised to the model on this install.");
4743 println!(" Install Python 3 and ensure one of those names is on PATH:");
4744 match std::env::consts::OS {
4745 "macos" => {
4746 println!(" brew install python@3.12 (or download from python.org)")
4747 }
4748 "linux" => println!(
4749 " sudo apt install python3 (Debian/Ubuntu) — or your distro's equivalent"
4750 ),
4751 "windows" => {
4752 println!(" winget install Python.Python.3 (or download from python.org)")
4753 }
4754 other => println!(" install Python 3 for {other} from python.org"),
4755 }
4756 }
4757 }
4758
4759 match crate::dependencies::resolve_node() {
4760 Some(_) => println!(
4761 " {} Node.js: present → js_execution tool registered",
4762 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4763 ),
4764 None => {
4765 println!(
4766 " {} Node.js: not found (tried `node`)",
4767 "✗".truecolor(red_r, red_g, red_b),
4768 );
4769 println!(" js_execution tool is NOT advertised to the model on this install.");
4770 println!(" Install Node 18+ and ensure `node` is on PATH:");
4771 match std::env::consts::OS {
4772 "macos" => println!(" brew install node (or download from nodejs.org)"),
4773 "linux" => println!(
4774 " sudo apt install nodejs (Debian/Ubuntu) — or your distro's equivalent"
4775 ),
4776 "windows" => {
4777 println!(" winget install OpenJS.NodeJS (or download from nodejs.org)")
4778 }
4779 other => println!(" install Node.js for {other} from nodejs.org"),
4780 }
4781 }
4782 }
4783
4784 match crate::dependencies::resolve_pandoc() {
4785 Some(_) => println!(
4786 " {} pandoc: present → pandoc_convert tool registered",
4787 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4788 ),
4789 None => {
4790 println!(" {} pandoc: not found (optional)", "·".dimmed(),);
4791 println!(
4792 " pandoc_convert tool is NOT advertised to the model. Install pandoc to enable:"
4793 );
4794 match std::env::consts::OS {
4795 "macos" => println!(" brew install pandoc"),
4796 "linux" => println!(
4797 " sudo apt install pandoc (Debian/Ubuntu) — or your distro's equivalent"
4798 ),
4799 "windows" => {
4800 println!(" winget install JohnMacFarlane.Pandoc")
4801 }
4802 other => println!(" install pandoc for {other} from pandoc.org"),
4803 }
4804 }
4805 }
4806
4807 match crate::dependencies::resolve_tesseract() {
4808 Some(_) => {
4809 if cfg!(target_os = "macos") {
4810 println!(
4811 " {} OCR: macOS Vision + tesseract available → image_ocr/read_file screenshot OCR enabled",
4812 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4813 );
4814 } else {
4815 println!(
4816 " {} tesseract: present → image_ocr/read_file screenshot OCR enabled",
4817 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4818 );
4819 }
4820 }
4821 None => {
4822 if cfg!(target_os = "macos") {
4823 println!(
4824 " {} OCR: macOS Vision available → image_ocr/read_file screenshot OCR enabled",
4825 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4826 );
4827 println!(
4828 " tesseract not found (optional; install only for alternate OCR packs)."
4829 );
4830 } else {
4831 println!(" {} tesseract: not found (optional)", "·".dimmed(),);
4832 println!(
4833 " image_ocr tool is NOT advertised to the model. Install tesseract to enable:"
4834 );
4835 match std::env::consts::OS {
4836 "macos" => println!(" brew install tesseract"),
4837 "linux" => println!(
4838 " sudo apt install tesseract-ocr (Debian/Ubuntu) — or your distro's equivalent"
4839 ),
4840 "windows" => println!(" winget install UB-Mannheim.TesseractOCR"),
4841 other => {
4842 println!(" install tesseract for {other} from tesseract-ocr.github.io")
4843 }
4844 }
4845 }
4846 }
4847 }
4848
4849 match crate::dependencies::resolve_pdftotext() {
4853 Some(_) => println!(
4854 " {} pdftotext: available → PDF text extraction enabled",
4855 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4856 ),
4857 None => {
4858 println!(
4859 " {} pdftotext: not found (optional; PDF text reads fail as `binary_unavailable`)",
4860 "·".dimmed(),
4861 );
4862 match std::env::consts::OS {
4863 "macos" => println!(" Install via: brew install poppler"),
4864 "linux" => {
4865 println!(" Install via: sudo apt install poppler-utils (Debian/Ubuntu)")
4866 }
4867 "windows" => println!(
4868 " Install Poppler for Windows from https://blog.alivate.com.au/poppler-windows/"
4869 ),
4870 _ => {}
4871 }
4872 }
4873 }
4874
4875 println!();
4879 println!("{}", "Terminal Quirks:".bold());
4880 let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default();
4881 let term_program_lc = term_program.to_ascii_lowercase();
4882 let mut any_quirk = false;
4883 if matches!(term_program.as_str(), "vscode" | "ghostty") {
4884 println!(
4885 " {} TERM_PROGRAM={} → low_motion + fancy_animations=false (auto)",
4886 "•".truecolor(sky_r, sky_g, sky_b),
4887 term_program
4888 );
4889 any_quirk = true;
4890 }
4891 if term_program == "Termius"
4892 || std::env::var_os("SSH_CLIENT").is_some_and(|v| !v.is_empty())
4893 || std::env::var_os("SSH_TTY").is_some_and(|v| !v.is_empty())
4894 {
4895 println!(
4896 " {} SSH/Termius session → low_motion + fancy_animations=false (auto, #1433)",
4897 "•".truecolor(sky_r, sky_g, sky_b)
4898 );
4899 any_quirk = true;
4900 }
4901 if term_program_lc.contains("ptyxis")
4902 || std::env::var_os("PTYXIS_VERSION").is_some_and(|v| !v.is_empty())
4903 {
4904 println!(
4905 " {} Ptyxis detected → synchronized_output=off (auto, v0.8.31)",
4906 "•".truecolor(sky_r, sky_g, sky_b)
4907 );
4908 any_quirk = true;
4909 }
4910 if crate::settings::detected_legacy_windows_console_host() {
4911 println!(
4912 " {} legacy Windows console host → low_motion + fancy_animations=false + bracketed_paste=false + synchronized_output=off (auto)",
4913 "•".truecolor(sky_r, sky_g, sky_b)
4914 );
4915 any_quirk = true;
4916 }
4917 if !any_quirk {
4918 println!(
4919 " {} no env-driven terminal-quirk overrides active",
4920 "·".dimmed()
4921 );
4922 }
4923
4924 println!();
4926 println!("{}", "Platform:".bold());
4927 println!(" OS: {}", std::env::consts::OS);
4928 println!(" Arch: {}", std::env::consts::ARCH);
4929
4930 let sandbox = crate::sandbox::get_platform_sandbox_with_bwrap_preference(
4931 config.prefer_bwrap.unwrap_or(false),
4932 );
4933 if let Some(kind) = sandbox {
4934 println!(
4935 " {} sandbox available: {}",
4936 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4937 kind
4938 );
4939 } else {
4940 println!(
4941 " {} sandbox not available (commands run best-effort)",
4942 "!".truecolor(sky_r, sky_g, sky_b)
4943 );
4944 }
4945
4946 println!();
4947 println!(
4948 "{}",
4949 "All checks complete!"
4950 .truecolor(aqua_r, aqua_g, aqua_b)
4951 .bold()
4952 );
4953}
4954
4955const DOCTOR_LEGACY_STATE_ITEMS: &[&str] = &[
4956 "sessions",
4957 "tasks",
4958 "skills",
4959 "slop_ledger",
4960 "trophies",
4961 "catalog",
4962 "review-receipts",
4963 "config.toml",
4964 "settings.toml",
4965 "mcp.json",
4966];
4967const DOCTOR_SESSION_RECOVERY_HUMAN_SAMPLE_LIMIT: usize = 20;
4968const DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT: usize = 100;
4969
4970#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4971enum DoctorLegacyStateStatus {
4972 PrimaryOnly,
4973 LegacyOnly,
4974 Both,
4975 Absent,
4976}
4977
4978impl DoctorLegacyStateStatus {
4979 fn as_str(self) -> &'static str {
4980 match self {
4981 Self::PrimaryOnly => "primary_only",
4982 Self::LegacyOnly => "legacy_only",
4983 Self::Both => "both",
4984 Self::Absent => "absent",
4985 }
4986 }
4987}
4988
4989#[derive(Debug, Clone)]
4990struct DoctorLegacyStateEntry {
4991 name: &'static str,
4992 primary_path: PathBuf,
4993 legacy_path: PathBuf,
4994 primary_present: bool,
4995 legacy_present: bool,
4996 status: DoctorLegacyStateStatus,
4997}
4998
4999#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5000enum DoctorSessionRecoveryStatus {
5001 Isolated,
5002 NoLegacySessions,
5003 MigrationPending,
5004 MigrationIncomplete,
5005 MigrationComplete,
5006 ScanFailed,
5007}
5008
5009impl DoctorSessionRecoveryStatus {
5010 fn as_str(self) -> &'static str {
5011 match self {
5012 Self::Isolated => "isolated",
5013 Self::NoLegacySessions => "no_legacy_sessions",
5014 Self::MigrationPending => "migration_pending",
5015 Self::MigrationIncomplete => "migration_incomplete",
5016 Self::MigrationComplete => "migration_complete",
5017 Self::ScanFailed => "scan_failed",
5018 }
5019 }
5020}
5021
5022#[derive(Debug, Clone)]
5023struct DoctorRecoverableSessionEntry {
5024 name: PathBuf,
5025 source_path: PathBuf,
5026 destination_path: PathBuf,
5027}
5028
5029#[derive(Debug, Clone)]
5030struct DoctorSessionRecoveryReport {
5031 status: DoctorSessionRecoveryStatus,
5032 primary_sessions_path: PathBuf,
5033 legacy_sessions_path: PathBuf,
5034 codewhale_home_is_explicit: bool,
5035 legacy_session_file_count: usize,
5036 already_present_file_count: usize,
5037 recoverable_file_count: usize,
5038 recoverable: Vec<DoctorRecoverableSessionEntry>,
5040 error: Option<String>,
5041}
5042
5043impl DoctorSessionRecoveryReport {
5044 fn needs_attention(&self) -> bool {
5045 matches!(
5046 self.status,
5047 DoctorSessionRecoveryStatus::MigrationPending
5048 | DoctorSessionRecoveryStatus::MigrationIncomplete
5049 | DoctorSessionRecoveryStatus::ScanFailed
5050 )
5051 }
5052}
5053
5054fn doctor_legacy_state_status(
5055 primary_present: bool,
5056 legacy_present: bool,
5057) -> DoctorLegacyStateStatus {
5058 match (primary_present, legacy_present) {
5059 (true, false) => DoctorLegacyStateStatus::PrimaryOnly,
5060 (false, true) => DoctorLegacyStateStatus::LegacyOnly,
5061 (true, true) => DoctorLegacyStateStatus::Both,
5062 (false, false) => DoctorLegacyStateStatus::Absent,
5063 }
5064}
5065
5066fn doctor_state_roots() -> (PathBuf, PathBuf) {
5067 let code_home =
5068 codewhale_config::codewhale_home().unwrap_or_else(|_| PathBuf::from("~/.codewhale"));
5069 let legacy_home = if codewhale_config::codewhale_home_is_explicit() {
5070 code_home.join(codewhale_config::LEGACY_APP_DIR)
5071 } else {
5072 codewhale_config::legacy_deepseek_home().unwrap_or_else(|_| PathBuf::from("~/.deepseek"))
5073 };
5074 (code_home, legacy_home)
5075}
5076
5077fn doctor_legacy_state_report(
5078 primary_root: &Path,
5079 legacy_root: &Path,
5080) -> Vec<DoctorLegacyStateEntry> {
5081 DOCTOR_LEGACY_STATE_ITEMS
5082 .iter()
5083 .copied()
5084 .map(|name| {
5085 let primary_path = primary_root.join(name);
5086 let legacy_path = legacy_root.join(name);
5087 let primary_present = primary_path.exists();
5088 let legacy_present = legacy_path.exists();
5089 let status = doctor_legacy_state_status(primary_present, legacy_present);
5090 DoctorLegacyStateEntry {
5091 name,
5092 primary_path,
5093 legacy_path,
5094 primary_present,
5095 legacy_present,
5096 status,
5097 }
5098 })
5099 .collect()
5100}
5101
5102fn doctor_session_recovery_report(
5114 primary_root: &Path,
5115 legacy_root: &Path,
5116 codewhale_home_is_explicit: bool,
5117) -> DoctorSessionRecoveryReport {
5118 let primary_sessions_path = primary_root.join("sessions");
5119 let legacy_sessions_path = legacy_root.join("sessions");
5120 let mut report = DoctorSessionRecoveryReport {
5121 status: DoctorSessionRecoveryStatus::NoLegacySessions,
5122 primary_sessions_path,
5123 legacy_sessions_path,
5124 codewhale_home_is_explicit,
5125 legacy_session_file_count: 0,
5126 already_present_file_count: 0,
5127 recoverable_file_count: 0,
5128 recoverable: Vec::new(),
5129 error: None,
5130 };
5131
5132 if codewhale_home_is_explicit {
5133 report.status = DoctorSessionRecoveryStatus::Isolated;
5134 return report;
5135 }
5136
5137 let legacy_root_is_present =
5138 match doctor_session_directory_is_safe(legacy_root, "legacy state root") {
5139 Ok(present) => present,
5140 Err(error) => {
5141 report.status = DoctorSessionRecoveryStatus::ScanFailed;
5142 report.error = Some(error);
5143 return report;
5144 }
5145 };
5146 if !legacy_root_is_present {
5147 return report;
5148 }
5149 if let Err(error) = doctor_session_directory_is_safe(primary_root, "primary state root") {
5150 report.status = DoctorSessionRecoveryStatus::ScanFailed;
5151 report.error = Some(error);
5152 return report;
5153 }
5154
5155 let legacy_sessions_are_present = match doctor_session_directory_is_safe(
5156 &report.legacy_sessions_path,
5157 "legacy sessions root",
5158 ) {
5159 Ok(present) => present,
5160 Err(error) => {
5161 report.status = DoctorSessionRecoveryStatus::ScanFailed;
5162 report.error = Some(error);
5163 return report;
5164 }
5165 };
5166 if !legacy_sessions_are_present {
5167 return report;
5168 }
5169 let primary_sessions_are_present = match doctor_session_directory_is_safe(
5170 &report.primary_sessions_path,
5171 "primary sessions root",
5172 ) {
5173 Ok(present) => present,
5174 Err(error) => {
5175 report.status = DoctorSessionRecoveryStatus::ScanFailed;
5176 report.error = Some(error);
5177 return report;
5178 }
5179 };
5180
5181 let entries = match std::fs::read_dir(&report.legacy_sessions_path) {
5182 Ok(entries) => entries,
5183 Err(err) => {
5184 report.status = DoctorSessionRecoveryStatus::ScanFailed;
5185 report.error = Some(format!(
5186 "could not inspect legacy session filenames at {}: {err}",
5187 crate::utils::display_path(&report.legacy_sessions_path)
5188 ));
5189 return report;
5190 }
5191 };
5192
5193 for entry in entries {
5194 let entry = match entry {
5195 Ok(entry) => entry,
5196 Err(err) => {
5197 report.status = DoctorSessionRecoveryStatus::ScanFailed;
5198 report.error = Some(format!(
5199 "could not inspect an entry under {}: {err}",
5200 crate::utils::display_path(&report.legacy_sessions_path)
5201 ));
5202 return report;
5203 }
5204 };
5205 let file_type = match entry.file_type() {
5206 Ok(file_type) => file_type,
5207 Err(err) => {
5208 report.status = DoctorSessionRecoveryStatus::ScanFailed;
5209 report.error = Some(format!(
5210 "could not inspect legacy session entry metadata under {}: {err}",
5211 crate::utils::display_path(&report.legacy_sessions_path)
5212 ));
5213 return report;
5214 }
5215 };
5216 if !file_type.is_file() || entry.path().extension().is_none_or(|ext| ext != "json") {
5217 continue;
5218 }
5219
5220 report.legacy_session_file_count += 1;
5221 let name = PathBuf::from(entry.file_name());
5222 let destination_path = report.primary_sessions_path.join(&name);
5223 match std::fs::symlink_metadata(&destination_path) {
5224 Ok(metadata) if metadata.file_type().is_file() => {
5225 report.already_present_file_count += 1;
5226 }
5227 Ok(metadata) => {
5228 report.status = DoctorSessionRecoveryStatus::ScanFailed;
5229 let shape = if metadata.file_type().is_symlink() {
5230 "destination session entry is a symlink"
5231 } else {
5232 "destination session entry is not a regular file"
5233 };
5234 report.error = Some(format!(
5235 "could not inspect destination session metadata at {}: {shape}",
5236 crate::utils::display_path(&destination_path)
5237 ));
5238 return report;
5239 }
5240 Err(err) if err.kind() == io::ErrorKind::NotFound => {
5241 report.recoverable_file_count += 1;
5242 record_doctor_recoverable_session(
5243 &mut report.recoverable,
5244 DoctorRecoverableSessionEntry {
5245 source_path: entry.path(),
5246 destination_path,
5247 name,
5248 },
5249 );
5250 }
5251 Err(err) => {
5252 report.status = DoctorSessionRecoveryStatus::ScanFailed;
5253 report.error = Some(format!(
5254 "could not inspect destination metadata at {}: {err}",
5255 crate::utils::display_path(&destination_path)
5256 ));
5257 return report;
5258 }
5259 }
5260 }
5261
5262 report.status = if report.legacy_session_file_count == 0 {
5263 DoctorSessionRecoveryStatus::NoLegacySessions
5264 } else if report.recoverable_file_count == 0 {
5265 DoctorSessionRecoveryStatus::MigrationComplete
5266 } else if primary_sessions_are_present {
5267 DoctorSessionRecoveryStatus::MigrationIncomplete
5268 } else {
5269 DoctorSessionRecoveryStatus::MigrationPending
5270 };
5271 report
5272}
5273
5274fn doctor_session_directory_is_safe(path: &Path, label: &str) -> std::result::Result<bool, String> {
5282 let metadata = match std::fs::symlink_metadata(path) {
5283 Ok(metadata) => metadata,
5284 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false),
5285 Err(error) => {
5286 return Err(format!(
5287 "could not inspect {label} at {}: {error}",
5288 crate::utils::display_path(path)
5289 ));
5290 }
5291 };
5292 if metadata.file_type().is_symlink() {
5293 return Err(format!(
5294 "could not inspect {label} at {}: path is a symlink",
5295 crate::utils::display_path(path)
5296 ));
5297 }
5298 if !metadata.file_type().is_dir() {
5299 return Err(format!(
5300 "could not inspect {label} at {}: path is not a directory",
5301 crate::utils::display_path(path)
5302 ));
5303 }
5304 Ok(true)
5305}
5306
5307fn record_doctor_recoverable_session(
5311 recoverable: &mut Vec<DoctorRecoverableSessionEntry>,
5312 entry: DoctorRecoverableSessionEntry,
5313) {
5314 let insert_at = recoverable
5315 .binary_search_by(|existing| existing.name.cmp(&entry.name))
5316 .unwrap_or_else(|index| index);
5317 if recoverable.len() == DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT
5318 && insert_at == recoverable.len()
5319 {
5320 return;
5321 }
5322 recoverable.insert(insert_at, entry);
5323 if recoverable.len() > DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT {
5324 recoverable.pop();
5325 }
5326}
5327
5328fn legacy_state_needs_attention(entry: &DoctorLegacyStateEntry) -> bool {
5329 entry.name != "sessions"
5330 && matches!(
5331 entry.status,
5332 DoctorLegacyStateStatus::LegacyOnly | DoctorLegacyStateStatus::Both
5333 )
5334}
5335
5336fn print_doctor_legacy_state_report(
5337 report: &[DoctorLegacyStateEntry],
5338 session_recovery: &DoctorSessionRecoveryReport,
5339 ok_rgb: (u8, u8, u8),
5340 warn_rgb: (u8, u8, u8),
5341) {
5342 use colored::Colorize;
5343
5344 let attention: Vec<_> = report
5345 .iter()
5346 .filter(|entry| legacy_state_needs_attention(entry))
5347 .collect();
5348 if attention.is_empty()
5349 && !session_recovery.needs_attention()
5350 && session_recovery.status != DoctorSessionRecoveryStatus::Isolated
5351 {
5352 println!(
5353 " {} legacy state: no known .deepseek entries need migration",
5354 "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2)
5355 );
5356 } else if !attention.is_empty() {
5357 println!(
5358 " {} legacy state needs review:",
5359 "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2)
5360 );
5361 for entry in attention {
5362 match entry.status {
5363 DoctorLegacyStateStatus::LegacyOnly => {
5364 println!(
5365 " {} {} exists but {} is missing",
5366 "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2),
5367 crate::utils::display_path(&entry.legacy_path),
5368 crate::utils::display_path(&entry.primary_path),
5369 );
5370 }
5371 DoctorLegacyStateStatus::Both => {
5372 println!(
5373 " {} {} exists alongside primary {}; legacy data may still need review",
5374 "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2),
5375 crate::utils::display_path(&entry.legacy_path),
5376 crate::utils::display_path(&entry.primary_path),
5377 );
5378 }
5379 DoctorLegacyStateStatus::PrimaryOnly | DoctorLegacyStateStatus::Absent => {}
5380 }
5381 }
5382 println!(
5383 " Start Codewhale once to trigger safe migration where available, then rerun `codewhale doctor`."
5384 );
5385 }
5386
5387 print_doctor_session_recovery_report(session_recovery, ok_rgb, warn_rgb);
5388}
5389
5390fn print_doctor_session_recovery_report(
5391 report: &DoctorSessionRecoveryReport,
5392 ok_rgb: (u8, u8, u8),
5393 warn_rgb: (u8, u8, u8),
5394) {
5395 use colored::Colorize;
5396
5397 match report.status {
5398 DoctorSessionRecoveryStatus::Isolated => {
5399 println!(
5400 " {} legacy sessions: ambient ~/.deepseek/sessions was not inspected because CODEWHALE_HOME is set",
5401 "·".dimmed()
5402 );
5403 println!(
5404 " This preserves the explicit home boundary. To inspect the default home, use a separate shell with CODEWHALE_HOME unset and rerun `codewhale doctor`."
5405 );
5406 }
5407 DoctorSessionRecoveryStatus::NoLegacySessions => {
5408 println!(
5409 " {} legacy sessions: no top-level session JSON files found",
5410 "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2)
5411 );
5412 }
5413 DoctorSessionRecoveryStatus::MigrationComplete => {
5414 println!(
5415 " {} legacy sessions: all {} filename(s) have regular-file counterparts under {}; descriptor contents were not compared and legacy originals remain preserved",
5416 "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2),
5417 report.legacy_session_file_count,
5418 crate::utils::display_path(&report.primary_sessions_path),
5419 );
5420 }
5421 DoctorSessionRecoveryStatus::MigrationPending
5422 | DoctorSessionRecoveryStatus::MigrationIncomplete => {
5423 let label = if report.status == DoctorSessionRecoveryStatus::MigrationIncomplete {
5424 "migration is incomplete"
5425 } else {
5426 "migration has not completed"
5427 };
5428 println!(
5429 " {} legacy sessions: {label}; {} recoverable file(s) are absent from {}",
5430 "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2),
5431 report.recoverable_file_count,
5432 crate::utils::display_path(&report.primary_sessions_path),
5433 );
5434 for entry in report
5435 .recoverable
5436 .iter()
5437 .take(DOCTOR_SESSION_RECOVERY_HUMAN_SAMPLE_LIMIT)
5438 {
5439 println!(
5440 " {} {} -> {}",
5441 "·".dimmed(),
5442 crate::utils::display_path(&entry.source_path),
5443 crate::utils::display_path(&entry.destination_path),
5444 );
5445 }
5446 if report.recoverable_file_count > DOCTOR_SESSION_RECOVERY_HUMAN_SAMPLE_LIMIT {
5447 println!(
5448 " · {} more filename(s); `codewhale doctor --json` includes a bounded metadata-only sample",
5449 report.recoverable_file_count - DOCTOR_SESSION_RECOVERY_HUMAN_SAMPLE_LIMIT
5450 );
5451 }
5452 println!(" Safe recovery:");
5453 println!(
5454 " 1. Back up {} and {} (if present).",
5455 crate::utils::display_path(&report.legacy_sessions_path),
5456 crate::utils::display_path(&report.primary_sessions_path),
5457 );
5458 println!(
5459 " 2. Close other Codewhale processes, then run `codewhale sessions`; migration adds only missing files, never overwrites primary files, and leaves legacy originals in place."
5460 );
5461 println!(
5462 " 3. Rerun `codewhale doctor`. If filenames remain, keep both backups and report only the listed source/destination names."
5463 );
5464 }
5465 DoctorSessionRecoveryStatus::ScanFailed => {
5466 println!(
5467 " {} legacy sessions: recovery diagnostic could not complete",
5468 "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2)
5469 );
5470 if let Some(error) = report.error.as_deref() {
5471 println!(" {error}");
5472 }
5473 println!(
5474 " Keep both session directories unchanged, back them up, fix path permissions or shape, and rerun `codewhale doctor` before attempting migration."
5475 );
5476 }
5477 }
5478 if report.status != DoctorSessionRecoveryStatus::Isolated {
5479 println!(
5480 " Doctor inspected filenames and filesystem metadata only; it did not read chat contents, traverse checkpoints, or modify session files."
5481 );
5482 }
5483}
5484
5485fn doctor_session_recovery_json(report: &DoctorSessionRecoveryReport) -> serde_json::Value {
5486 use serde_json::json;
5487
5488 let recoverable: Vec<_> = report
5489 .recoverable
5490 .iter()
5491 .take(DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT)
5492 .map(|entry| {
5493 json!({
5494 "name": entry.name.display().to_string(),
5495 "source_path": entry.source_path.display().to_string(),
5496 "destination_path": entry.destination_path.display().to_string(),
5497 })
5498 })
5499 .collect();
5500
5501 json!({
5502 "status": report.status.as_str(),
5503 "needs_attention": report.needs_attention(),
5504 "read_only": true,
5505 "chat_contents_read": false,
5506 "checkpoint_internals_scanned": false,
5507 "session_descriptors_compared": false,
5508 "counterpart_check": "top_level_filename_and_regular_file_only",
5509 "codewhale_home_is_explicit": report.codewhale_home_is_explicit,
5510 "legacy_sessions_path": report.legacy_sessions_path.display().to_string(),
5511 "primary_sessions_path": report.primary_sessions_path.display().to_string(),
5512 "legacy_session_file_count": report.legacy_session_file_count,
5513 "already_present_file_count": report.already_present_file_count,
5514 "recoverable_file_count": report.recoverable_file_count,
5515 "recoverable_files": recoverable,
5516 "recoverable_files_truncated": report.recoverable_file_count > report.recoverable.len(),
5517 "error": report.error,
5518 "recovery_command": if report.needs_attention() && report.status != DoctorSessionRecoveryStatus::ScanFailed {
5519 Some("codewhale sessions")
5520 } else {
5521 None
5522 },
5523 })
5524}
5525
5526fn doctor_legacy_state_json(
5527 primary_root: &Path,
5528 legacy_root: &Path,
5529 report: &[DoctorLegacyStateEntry],
5530 session_recovery: &DoctorSessionRecoveryReport,
5531) -> serde_json::Value {
5532 use serde_json::json;
5533
5534 let legacy_only = report
5535 .iter()
5536 .filter(|entry| entry.status == DoctorLegacyStateStatus::LegacyOnly)
5537 .count();
5538 let both = report
5539 .iter()
5540 .filter(|entry| entry.status == DoctorLegacyStateStatus::Both)
5541 .count();
5542 let entries: Vec<_> = report
5543 .iter()
5544 .map(|entry| {
5545 json!({
5546 "name": entry.name,
5547 "primary_path": entry.primary_path.display().to_string(),
5548 "legacy_path": entry.legacy_path.display().to_string(),
5549 "primary_present": entry.primary_present,
5550 "legacy_present": entry.legacy_present,
5551 "status": entry.status.as_str(),
5552 })
5553 })
5554 .collect();
5555
5556 json!({
5557 "primary_root": primary_root.display().to_string(),
5558 "legacy_root": legacy_root.display().to_string(),
5559 "needs_attention": report.iter().any(legacy_state_needs_attention) || session_recovery.needs_attention(),
5560 "legacy_only_count": legacy_only,
5561 "dual_present_count": both,
5562 "session_recovery": doctor_session_recovery_json(session_recovery),
5563 "entries": entries,
5564 })
5565}
5566
5567fn doctor_setup_state(
5568 config: &Config,
5569 workspace: &Path,
5570) -> (codewhale_config::SetupState, &'static str) {
5571 if let Ok(Some(state)) = codewhale_config::SetupState::load() {
5572 return (state, "persisted");
5573 }
5574
5575 (
5576 codewhale_config::SetupState::derive_inherited(&doctor_inherited_setup_facts(
5577 config, workspace,
5578 )),
5579 "derived",
5580 )
5581}
5582
5583fn doctor_inherited_setup_facts(
5584 config: &Config,
5585 workspace: &Path,
5586) -> codewhale_config::InheritedConfigFacts {
5587 let user_constitution = codewhale_config::UserConstitution::load().ok();
5588 let user_constitution_validity = user_constitution.as_ref().map_or(
5589 codewhale_config::ConstitutionValidity::Unknown,
5590 codewhale_config::UserConstitutionLoad::validity,
5591 );
5592 let has_user_constitution = user_constitution
5593 .as_ref()
5594 .is_some_and(|loaded| !matches!(loaded, codewhale_config::UserConstitutionLoad::Missing));
5595 let has_expert_override = codewhale_config::codewhale_home()
5596 .ok()
5597 .map(|home| home.join(Path::new(crate::prompts::CONSTITUTION_OVERRIDE_FILE)))
5598 .is_some_and(|path| path.exists());
5599
5600 codewhale_config::InheritedConfigFacts {
5601 language: None,
5602 has_provider_route: !config.default_model().trim().is_empty(),
5603 has_credentials_or_local_runtime: doctor_has_credentials_or_local_runtime(config),
5604 trust_chosen: !crate::tui::onboarding::needs_trust(workspace),
5605 has_expert_override,
5606 has_user_constitution,
5607 user_constitution_validity,
5608 }
5609}
5610
5611fn doctor_has_credentials_or_local_runtime(config: &Config) -> bool {
5612 resolve_credential_diagnostic(config)
5613 .availability
5614 .certifies_ready()
5615}
5616
5617fn print_doctor_setup_report(
5618 config: &Config,
5619 workspace: &Path,
5620 state: &codewhale_config::SetupState,
5621 source: &str,
5622 ok_rgb: (u8, u8, u8),
5623 warn_rgb: (u8, u8, u8),
5624) {
5625 use colored::Colorize;
5626
5627 let credential = resolve_credential_diagnostic(config);
5628 let credential_ready = credential.availability.certifies_ready();
5629 let first_run_ready = state.first_run_ready() && credential_ready;
5630 let update_ready =
5631 state.update_ready(crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION) && credential_ready;
5632 let operate_ready = state.operate_ready() && credential_ready;
5633 let first_run_icon = if first_run_ready {
5634 "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2)
5635 } else {
5636 "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2)
5637 };
5638 let update_icon = if update_ready {
5639 "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2)
5640 } else {
5641 "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2)
5642 };
5643 let operate_icon = if operate_ready {
5644 "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2)
5645 } else {
5646 "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2)
5647 };
5648
5649 println!();
5650 println!("{}", "Setup State:".bold());
5651 println!(" · source: {source}");
5652 println!(
5653 " · credential: source={}, availability={}",
5654 doctor_api_key_source_label(credential.source),
5655 credential.availability.label()
5656 );
5657 println!(
5658 " {first_run_icon} first-run: {}",
5659 doctor_ready_label(first_run_ready)
5660 );
5661 println!(
5662 " {update_icon} update checkpoint {}: {}",
5663 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
5664 doctor_ready_label(update_ready)
5665 );
5666 println!(
5667 " {operate_icon} operate/fleet: {}",
5668 doctor_ready_label(operate_ready)
5669 );
5670 println!(
5671 " · constitution autonomy: {} (guidance only)",
5672 doctor_constitution_autonomy_preference_id()
5673 );
5674 println!(
5675 " · runtime posture: {}",
5676 doctor_runtime_posture_line(config, workspace)
5677 );
5678 let consistency = doctor_setup_consistency(state, source);
5679 if consistency["status"] == "inconsistent" {
5680 let issues = consistency["issues"]
5681 .as_array()
5682 .map(|issues| {
5683 issues
5684 .iter()
5685 .filter_map(serde_json::Value::as_str)
5686 .collect::<Vec<_>>()
5687 .join(", ")
5688 })
5689 .unwrap_or_default();
5690 println!(
5691 " {} consistency: half-applied setup detected ({issues}) — {}",
5692 "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2),
5693 consistency["repair"].as_str().unwrap_or("/setup"),
5694 );
5695 }
5696 println!(
5697 " · 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)"
5698 );
5699 for step in codewhale_config::SetupStep::ALL {
5700 let entry = state.steps.get(&step);
5701 let required = entry.is_some_and(|entry| entry.required);
5702 let version = entry.and_then(|entry| entry.version.as_deref());
5703 let result = entry.and_then(|entry| entry.result.as_deref());
5704 let required_label = if required { "required" } else { "optional" };
5705 let version_label = version.unwrap_or("unversioned");
5706 let result_label = result.unwrap_or("no result");
5707 println!(
5708 " · {}: {} ({required_label}, {version_label}, {result_label})",
5709 setup_step_id(step),
5710 setup_status_id(state.status(step))
5711 );
5712 }
5713}
5714
5715fn doctor_ready_label(ready: bool) -> &'static str {
5716 if ready { "ready" } else { "needs action" }
5717}
5718
5719fn doctor_setup_consistency(
5727 state: &codewhale_config::SetupState,
5728 source: &str,
5729) -> serde_json::Value {
5730 use serde_json::json;
5731
5732 let mut issues: Vec<&'static str> = Vec::new();
5733
5734 if source == "persisted"
5735 && matches!(
5736 state.constitution_source,
5737 codewhale_config::ConstitutionSource::UserGlobal
5738 )
5739 {
5740 match codewhale_config::UserConstitution::load() {
5741 Ok(codewhale_config::UserConstitutionLoad::Missing) => {
5742 issues.push("setup_state_points_at_missing_user_constitution");
5743 }
5744 Ok(codewhale_config::UserConstitutionLoad::Empty) => {
5745 issues.push("user_constitution_empty");
5746 }
5747 Ok(codewhale_config::UserConstitutionLoad::Invalid(_)) => {
5748 issues.push("user_constitution_invalid");
5749 }
5750 Ok(codewhale_config::UserConstitutionLoad::Unreadable(_)) | Err(_) => {
5751 issues.push("user_constitution_unreadable");
5752 }
5753 Ok(codewhale_config::UserConstitutionLoad::Loaded(_)) => {}
5754 }
5755 }
5756
5757 if doctor_home_has_stale_setup_temp_files() {
5758 issues.push("stale_setup_temp_files_in_codewhale_home");
5759 }
5760
5761 json!({
5762 "status": if issues.is_empty() { "consistent" } else { "inconsistent" },
5763 "issues": issues,
5764 "repair": "/constitution to rebuild standing law, /setup to re-run the checkpoint",
5765 })
5766}
5767
5768fn doctor_home_has_stale_setup_temp_files() -> bool {
5769 let Ok(home) = codewhale_config::codewhale_home() else {
5770 return false;
5771 };
5772 let Ok(entries) = std::fs::read_dir(&home) else {
5773 return false;
5774 };
5775 entries.flatten().any(|entry| {
5776 entry.file_name().to_string_lossy().starts_with(".tmp")
5777 && entry.file_type().is_ok_and(|kind| kind.is_file())
5778 })
5779}
5780
5781fn doctor_constitution_autonomy_preference() -> codewhale_config::AutonomyPreference {
5782 codewhale_config::UserConstitution::load()
5783 .ok()
5784 .and_then(|load| {
5785 load.constitution()
5786 .map(|constitution| constitution.autonomy_preference)
5787 })
5788 .unwrap_or(codewhale_config::AutonomyPreference::Unspecified)
5789}
5790
5791fn doctor_constitution_autonomy_preference_id() -> &'static str {
5792 autonomy_preference_id(doctor_constitution_autonomy_preference())
5793}
5794
5795fn autonomy_preference_id(preference: codewhale_config::AutonomyPreference) -> &'static str {
5796 match preference {
5797 codewhale_config::AutonomyPreference::Unspecified => "unspecified",
5798 codewhale_config::AutonomyPreference::Cautious => "cautious",
5799 codewhale_config::AutonomyPreference::Balanced => "balanced",
5800 codewhale_config::AutonomyPreference::Autonomous => "autonomous",
5801 }
5802}
5803
5804fn doctor_runtime_default_mode() -> (String, &'static str) {
5805 match crate::settings::Settings::load_read_only() {
5806 Ok(settings) => (settings.default_mode, "settings"),
5807 Err(_) => (crate::settings::Settings::default().default_mode, "default"),
5808 }
5809}
5810
5811fn doctor_runtime_permission_posture() -> (String, &'static str) {
5815 match crate::settings::Settings::load_read_only() {
5816 Ok(settings) => match settings.permission_posture {
5817 Some(posture) => (posture, "settings"),
5818 None => ("unset".to_string(), "default"),
5819 },
5820 Err(_) => ("unset".to_string(), "default"),
5821 }
5822}
5823
5824fn doctor_runtime_posture_line(config: &Config, workspace: &Path) -> String {
5825 let (default_mode, default_mode_source) = doctor_runtime_default_mode();
5826 let (permission_posture, permission_posture_source) = doctor_runtime_permission_posture();
5827 let approval = config.approval_policy.as_deref().unwrap_or("on-request");
5828 let approval_source = if config.approval_policy.is_some() {
5829 "config"
5830 } else {
5831 "default"
5832 };
5833 let allow_shell = config.interactive_allow_shell();
5834 let allow_shell_source = if config.allow_shell.is_some() {
5835 "config"
5836 } else {
5837 "interactive default"
5838 };
5839 let sandbox = config.sandbox_mode.as_deref().unwrap_or("mode-derived");
5840 let sandbox_source = if config.sandbox_mode.is_some() {
5841 "config"
5842 } else {
5843 "default"
5844 };
5845 let network = config
5846 .network
5847 .as_ref()
5848 .map_or("prompt", |policy| policy.default.as_str());
5849 let network_source = if config.network.is_some() {
5850 "config"
5851 } else {
5852 "default"
5853 };
5854 let trust = if crate::tui::onboarding::needs_trust(workspace) {
5855 "workspace not elevated"
5856 } else {
5857 "workspace trusted"
5858 };
5859
5860 format!(
5861 "default_mode={default_mode} ({default_mode_source}), permission_posture={permission_posture} ({permission_posture_source}), approval_policy={approval} ({approval_source}), allow_shell={allow_shell} ({allow_shell_source}), sandbox={sandbox} ({sandbox_source}), network.default={network} ({network_source}), trust={trust}"
5862 )
5863}
5864
5865fn doctor_operate_fleet_report_json(config: &Config, workspace: &Path) -> serde_json::Value {
5866 use serde_json::json;
5867
5868 let provider = config.api_provider();
5869 let credential = resolve_credential_diagnostic(config);
5872 let has_credentials_or_local = credential.availability.certifies_ready();
5873 let subagents_enabled = config.subagents_enabled_for_provider(provider);
5874 let disabled_reason = if subagents_enabled {
5875 None
5876 } else {
5877 Some(
5878 config
5879 .subagents_disabled_reason()
5880 .unwrap_or("disabled for active provider"),
5881 )
5882 };
5883 let max_subagents = config.max_subagents_for_provider(provider);
5884 let launch_concurrency = config.launch_concurrency_for_provider(provider);
5885 let max_admitted = config.max_admitted_subagents_for_provider(provider);
5886 let max_spawn_depth = config.subagent_max_spawn_depth_for_provider(provider);
5887 let roster = crate::fleet::roster::FleetRoster::load(&config.fleet_config(), workspace);
5888 let mut built_in_members = 0usize;
5889 let mut config_members = 0usize;
5890 let mut personal_members = 0usize;
5891 let mut workspace_members = 0usize;
5892 for member in roster.members() {
5893 match member.origin {
5894 crate::fleet::roster::ProfileOrigin::BuiltIn => built_in_members += 1,
5895 crate::fleet::roster::ProfileOrigin::Config => config_members += 1,
5896 crate::fleet::roster::ProfileOrigin::Personal => personal_members += 1,
5897 crate::fleet::roster::ProfileOrigin::Workspace => workspace_members += 1,
5898 }
5899 }
5900 let roster_members = roster.members().len();
5901 let custom_members = config_members + personal_members + workspace_members;
5902 let roster_ready = roster_members > 0;
5903 let runtime_ready =
5904 subagents_enabled && max_subagents > 0 && launch_concurrency > 0 && max_spawn_depth > 0;
5905
5906 json!({
5907 "ready": has_credentials_or_local && runtime_ready && roster_ready,
5908 "provider": {
5909 "id": config.provider_identity_for(provider),
5910 "auth": {
5911 "present_or_local": has_credentials_or_local,
5912 "source": doctor_api_key_source_label(credential.source),
5913 "availability": credential.availability.label(),
5914 },
5915 },
5916 "worker_runtime": {
5917 "ready": runtime_ready,
5918 "enabled": subagents_enabled,
5919 "disabled_reason": disabled_reason,
5920 "max_subagents": max_subagents,
5921 "launch_concurrency": launch_concurrency,
5922 "max_admitted": max_admitted,
5923 "max_spawn_depth": max_spawn_depth,
5924 "host_enforced_workflow_receipts": true,
5925 },
5926 "roster": {
5927 "ready": roster_ready,
5928 "total": roster_members,
5929 "built_in": built_in_members,
5930 "config": config_members,
5931 "personal": personal_members,
5932 "workspace": workspace_members,
5933 "custom": custom_members,
5934 "starter_roster_available": built_in_members > 0,
5935 "readiness_rule": "built-in starter roster or custom roster",
5936 },
5937 "concurrency": {
5938 "launch_concurrency": launch_concurrency,
5939 "max_subagents": max_subagents,
5940 "max_admitted": max_admitted,
5941 "plan_limit_probed": false,
5942 },
5943 })
5944}
5945
5946fn doctor_provider_model_report_json(config: &Config) -> serde_json::Value {
5947 use serde_json::json;
5948
5949 let provider = config.api_provider();
5950 let credential = resolve_credential_diagnostic(config);
5951 let auth_present_or_local = credential.availability.certifies_ready();
5952 let credential_help = provider.credential_help();
5953 let credential_url = credential_help
5954 .credential_url
5955 .map(crate::doctor::structural_url_authority);
5956 let credential_docs_url = credential_help
5957 .docs_url
5958 .map(crate::doctor::structural_url_authority);
5959
5960 json!({
5961 "provider": {
5962 "id": config.provider_identity_for(provider),
5963 "display": provider.display_name(),
5964 },
5965 "model": {
5966 "resolved": config.default_model(),
5967 },
5968 "auth": {
5969 "present_or_local": auth_present_or_local,
5970 "source": doctor_api_key_source_label(credential.source),
5971 "availability": credential.availability.label(),
5972 "env_vars": provider.env_vars(),
5973 "credential_mode": credential_help.acquisition.as_str(),
5974 "credential_url": credential_url,
5975 "credential_docs_url": credential_docs_url,
5976 "credential_guidance": credential_help.guidance,
5977 "oauth_only": credential_help.acquisition
5978 == codewhale_config::provider::CredentialAcquisition::OAuth,
5979 },
5980 "health": {
5981 "live_validation": false,
5982 "next_action": if auth_present_or_local {
5983 "/model"
5984 } else {
5985 "/setup provider or /provider setup <name>"
5986 },
5987 },
5988 })
5989}
5990
5991fn doctor_external_credential_consent_statuses(
5992 config: &Config,
5993) -> Vec<codewhale_config::ExternalCredentialConsentStatus> {
5994 [
5995 crate::config::ApiProvider::OpenaiCodex,
5996 crate::config::ApiProvider::Xai,
5997 ]
5998 .into_iter()
5999 .filter_map(|provider| config.external_credential_consent_status(provider))
6000 .collect()
6001}
6002
6003fn doctor_external_credential_consent_lines(config: &Config) -> Vec<String> {
6004 doctor_external_credential_consent_statuses(config)
6005 .into_iter()
6006 .flat_map(|status| {
6007 let mut lines = vec![
6008 format!(
6009 "{}: access={}, provider={}, source={}, owner={}, path={}, version={}, state={}, ambient_path_changed={}",
6010 status.provider,
6011 status.access.as_str(),
6012 status.provider,
6013 status.source.as_str(),
6014 status.owner,
6015 codewhale_config::quote_os_path(&status.path),
6016 status.consent_version,
6017 status.route_state,
6018 status.ambient_path_changed,
6019 ),
6020 format!(" semantics: {}", status.semantics),
6021 format!(" revoke: {}", status.revoke_command),
6022 ];
6023 if let Some(warning) = status.ambient_path_warning() {
6024 lines.push(format!(" {warning}"));
6025 }
6026 lines
6027 })
6028 .collect()
6029}
6030
6031fn doctor_external_credential_consent_json(config: &Config) -> serde_json::Value {
6032 serde_json::Value::Array(
6033 doctor_external_credential_consent_statuses(config)
6034 .into_iter()
6035 .map(|status| {
6036 serde_json::json!({
6037 "provider": status.provider,
6038 "access": status.access.as_str(),
6039 "source": status.source.as_str(),
6040 "owner": status.owner,
6041 "path": codewhale_config::quote_os_path(&status.path),
6042 "consent_version": status.consent_version,
6043 "scope_valid": status.scope_valid,
6044 "ambient_path_changed": status.ambient_path_changed,
6045 "ambient_path_warning": status.ambient_path_warning(),
6046 "route_state": status.route_state,
6047 "semantics": status.semantics,
6048 "revoke_command": status.revoke_command,
6049 })
6050 })
6051 .collect(),
6052 )
6053}
6054
6055fn doctor_setup_report_json(config: &Config, workspace: &Path) -> serde_json::Value {
6056 use serde_json::json;
6057
6058 let (state, source) = doctor_setup_state(config, workspace);
6059 let (default_mode, default_mode_source) = doctor_runtime_default_mode();
6060 let (permission_posture, permission_posture_source) = doctor_runtime_permission_posture();
6061 let approval_policy = config.approval_policy.as_deref().unwrap_or("on-request");
6062 let approval_policy_source = if config.approval_policy.is_some() {
6063 "config"
6064 } else {
6065 "default"
6066 };
6067 let allow_shell = config.interactive_allow_shell();
6068 let allow_shell_source = if config.allow_shell.is_some() {
6069 "config"
6070 } else {
6071 "interactive_default"
6072 };
6073 let sandbox_mode = config.sandbox_mode.as_deref().unwrap_or("mode-derived");
6074 let sandbox_mode_source = if config.sandbox_mode.is_some() {
6075 "config"
6076 } else {
6077 "default"
6078 };
6079 let network_default = config
6080 .network
6081 .as_ref()
6082 .map_or("prompt", |policy| policy.default.as_str());
6083 let network_source = if config.network.is_some() {
6084 "config"
6085 } else {
6086 "default"
6087 };
6088 let workspace_trusted = !crate::tui::onboarding::needs_trust(workspace);
6089 let credential = resolve_credential_diagnostic(config);
6090 let credential_ready = credential.availability.certifies_ready();
6091 let steps: Vec<_> = codewhale_config::SetupStep::ALL
6092 .into_iter()
6093 .map(|step| {
6094 let entry = state.steps.get(&step);
6095 json!({
6096 "step": setup_step_id(step),
6097 "status": setup_status_id(state.status(step)),
6098 "required": entry.is_some_and(|entry| entry.required),
6099 "version": entry.and_then(|entry| entry.version.clone()),
6100 "result": entry.and_then(|entry| entry.result.clone()),
6101 })
6102 })
6103 .collect();
6104
6105 json!({
6106 "source": source,
6107 "schema_version": state.schema_version,
6108 "inherited": state.inherited,
6109 "checkpoint_version": crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
6110 "first_run_ready": state.first_run_ready() && credential_ready,
6111 "update_ready": state.update_ready(crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION)
6112 && credential_ready,
6113 "operate_ready": state.operate_ready() && credential_ready,
6114 "credential": {
6115 "ready": credential_ready,
6116 "source": doctor_api_key_source_label(credential.source),
6117 "availability": credential.availability.label(),
6118 },
6119 "constitution": {
6120 "choice": constitution_choice_id(state.constitution_choice),
6121 "source": constitution_source_id(state.constitution_source),
6122 "validity": constitution_validity_id(state.constitution_validity),
6123 "checkpoint_completed_for": state.constitution_checkpoint_completed_for.clone(),
6124 "language": state.constitution_language.clone(),
6125 "preview_hash_present": state.constitution_preview_hash.is_some(),
6126 "preview_version": state.constitution_preview_version,
6127 "autonomy_preference": doctor_constitution_autonomy_preference_id(),
6128 },
6129 "runtime_posture_source": runtime_posture_source_id(state.runtime_posture_source),
6130 "runtime_posture": {
6131 "source": runtime_posture_source_id(state.runtime_posture_source),
6132 "default_mode": {
6133 "value": default_mode,
6134 "source": default_mode_source,
6135 },
6136 "permission_posture": {
6137 "value": permission_posture,
6138 "source": permission_posture_source,
6139 },
6140 "approval_policy": {
6141 "value": approval_policy,
6142 "source": approval_policy_source,
6143 },
6144 "allow_shell": {
6145 "value": allow_shell,
6146 "source": allow_shell_source,
6147 },
6148 "sandbox_mode": {
6149 "value": sandbox_mode,
6150 "source": sandbox_mode_source,
6151 },
6152 "network_default": {
6153 "value": network_default,
6154 "source": network_source,
6155 },
6156 "workspace_trust": {
6157 "trusted": workspace_trusted,
6158 "source": "workspace",
6159 },
6160 },
6161 "provider_model": doctor_provider_model_report_json(config),
6162 "operate_fleet": doctor_operate_fleet_report_json(config, workspace),
6163 "consistency": doctor_setup_consistency(&state, source),
6164 "next_actions": {
6165 "constitution": "/constitution",
6166 "setup_report": "/setup report",
6167 "provider_model": "/setup provider, /provider setup <name>, or /model",
6168 "runtime_posture": "/config",
6169 "operate_fleet": "/setup fleet (readiness), /fleet setup (explicit profile authoring)",
6170 "hotbar": "/setup hotbar",
6171 "tools_mcp": "/setup tools",
6172 "remote_runtime": "/setup remote",
6173 "persistence": "/setup persistence",
6174 },
6175 "steps": steps,
6176 })
6177}
6178
6179fn setup_step_id(step: codewhale_config::SetupStep) -> &'static str {
6180 match step {
6181 codewhale_config::SetupStep::Language => "language",
6182 codewhale_config::SetupStep::ProviderModel => "provider_model",
6183 codewhale_config::SetupStep::TrustSandbox => "trust_sandbox",
6184 codewhale_config::SetupStep::ToolsMcp => "tools_mcp",
6185 codewhale_config::SetupStep::Hotbar => "hotbar",
6186 codewhale_config::SetupStep::RemoteRuntime => "remote_runtime",
6187 codewhale_config::SetupStep::Persistence => "persistence",
6188 codewhale_config::SetupStep::Constitution => "constitution",
6189 codewhale_config::SetupStep::OperateFleet => "operate_fleet",
6190 codewhale_config::SetupStep::Verification => "verification",
6191 }
6192}
6193
6194fn setup_status_id(status: codewhale_config::StepStatus) -> &'static str {
6195 match status {
6196 codewhale_config::StepStatus::NotStarted => "not_started",
6197 codewhale_config::StepStatus::Recommended => "recommended",
6198 codewhale_config::StepStatus::Optional => "optional",
6199 codewhale_config::StepStatus::Deferred => "deferred",
6200 codewhale_config::StepStatus::InProgress => "in_progress",
6201 codewhale_config::StepStatus::Verified => "verified",
6202 codewhale_config::StepStatus::NeedsAction => "needs_action",
6203 codewhale_config::StepStatus::Failed => "failed",
6204 codewhale_config::StepStatus::Skipped => "skipped",
6205 }
6206}
6207
6208fn constitution_choice_id(choice: codewhale_config::ConstitutionChoice) -> &'static str {
6209 match choice {
6210 codewhale_config::ConstitutionChoice::Unset => "unset",
6211 codewhale_config::ConstitutionChoice::Bundled => "bundled",
6212 codewhale_config::ConstitutionChoice::GuidedCustom => "guided_custom",
6213 codewhale_config::ConstitutionChoice::ExpertOverride => "expert_override",
6214 codewhale_config::ConstitutionChoice::Deferred => "deferred",
6215 }
6216}
6217
6218fn constitution_source_id(source: codewhale_config::ConstitutionSource) -> &'static str {
6219 match source {
6220 codewhale_config::ConstitutionSource::Bundled => "bundled",
6221 codewhale_config::ConstitutionSource::UserGlobal => "user_global",
6222 codewhale_config::ConstitutionSource::ExpertOverride => "expert_override",
6223 }
6224}
6225
6226fn constitution_validity_id(validity: codewhale_config::ConstitutionValidity) -> &'static str {
6227 match validity {
6228 codewhale_config::ConstitutionValidity::Unknown => "unknown",
6229 codewhale_config::ConstitutionValidity::Valid => "valid",
6230 codewhale_config::ConstitutionValidity::Invalid => "invalid",
6231 codewhale_config::ConstitutionValidity::Empty => "empty",
6232 codewhale_config::ConstitutionValidity::Unreadable => "unreadable",
6233 }
6234}
6235
6236fn runtime_posture_source_id(source: codewhale_config::RuntimePostureSource) -> &'static str {
6237 match source {
6238 codewhale_config::RuntimePostureSource::Unset => "unset",
6239 codewhale_config::RuntimePostureSource::Inherited => "inherited",
6240 codewhale_config::RuntimePostureSource::Confirmed => "confirmed",
6241 }
6242}
6243
6244fn run_doctor_json_config_error(error: &anyhow::Error) -> Result<()> {
6248 let safe_message = error
6249 .downcast_ref::<crate::config::SafeConfigDiagnostic>()
6250 .map(ToString::to_string);
6251 let report = serde_json::json!({
6252 "status": "error",
6253 "error": {
6254 "kind": "config_validation",
6255 "message": safe_message.as_deref().unwrap_or("configuration validation failed; details omitted because configuration errors may contain credential material"),
6256 },
6257 });
6258 println!("{}", serde_json::to_string_pretty(&report)?);
6259
6260 bail!("doctor configuration validation failed; see JSON output")
6263}
6264
6265fn run_doctor_json(
6268 config: &Config,
6269 workspace: &Path,
6270 config_path_override: Option<&Path>,
6271 plugins: &crate::plugins::PluginRegistry,
6272) -> Result<()> {
6273 use serde_json::json;
6274
6275 let doctor_paths = crate::doctor::DoctorPathReport::resolve(config_path_override)?;
6276 let config_path = &doctor_paths.config;
6277 let secret_backend = codewhale_secrets::diagnose_secret_backend();
6278
6279 let credential = resolve_credential_diagnostic(config);
6280
6281 let mcp_config_path = config.mcp_config_path();
6282 let project_mcp_config_path = crate::mcp::workspace_mcp_config_path(workspace);
6283 let mcp_present = mcp_config_path.exists();
6284 let project_mcp_present = project_mcp_config_path.exists();
6285 let mcp_summary = match crate::mcp::load_config_with_workspace_and_plugins(
6286 &mcp_config_path,
6287 workspace,
6288 plugins,
6289 ) {
6290 Ok(cfg) => {
6291 let servers: Vec<serde_json::Value> = cfg
6292 .servers
6293 .iter()
6294 .map(|(name, server)| doctor_mcp_server_json(name, server))
6295 .collect();
6296 json!({
6297 "config_path": mcp_config_path.display().to_string(),
6298 "present": mcp_present,
6299 "project_config_path": project_mcp_config_path.display().to_string(),
6300 "project_present": project_mcp_present,
6301 "probe_scope": "configuration",
6302 "live_health_checked": false,
6303 "servers": servers,
6304 })
6305 }
6306 Err(_) => json!({
6307 "config_path": mcp_config_path.display().to_string(),
6308 "present": mcp_present,
6309 "project_config_path": project_mcp_config_path.display().to_string(),
6310 "project_present": project_mcp_present,
6311 "probe_scope": "configuration",
6312 "live_health_checked": false,
6313 "servers": [],
6314 "error": "configuration_unavailable_details_omitted",
6315 }),
6316 };
6317
6318 let global_skills_dir = config.skills_dir();
6319 let agents_skills_dir = workspace.join(".agents").join("skills");
6320 let local_skills_dir = workspace.join("skills");
6321 let agents_global_skills_dir = crate::skills::agents_global_skills_dir();
6322 let opencode_skills_dir = workspace.join(".opencode").join("skills");
6327 let claude_skills_dir = workspace.join(".claude").join("skills");
6328 let selected_skills_dir = if agents_skills_dir.exists() {
6329 agents_skills_dir.clone()
6330 } else if local_skills_dir.exists() {
6331 local_skills_dir.clone()
6332 } else if config.skills_dir.is_none()
6333 && let Some(global_agents) = agents_global_skills_dir.as_ref()
6334 && global_agents.exists()
6335 {
6336 global_agents.clone()
6337 } else {
6338 global_skills_dir.clone()
6339 };
6340 let agents_global_summary = agents_global_skills_dir
6341 .as_ref()
6342 .map(|path| {
6343 json!({
6344 "path": path.display().to_string(),
6345 "present": path.exists(),
6346 "count": skills_count_for(path),
6347 })
6348 })
6349 .unwrap_or_else(|| {
6350 json!({
6351 "path": null,
6352 "present": false,
6353 "count": 0,
6354 })
6355 });
6356
6357 let tools_dir = default_tools_dir();
6358 let plugins_dir = default_plugins_dir();
6359
6360 let memory_path = config.memory_path();
6369 let memory_enabled_env = std::env::var("CODEWHALE_MEMORY")
6370 .or_else(|_| std::env::var("DEEPSEEK_MEMORY"))
6371 .ok()
6372 .map(|raw| {
6373 matches!(
6374 raw.trim().to_ascii_lowercase().as_str(),
6375 "1" | "on" | "true" | "yes" | "y" | "enabled"
6376 )
6377 })
6378 .unwrap_or(false);
6379 let memory_summary = json!({
6380 "enabled": memory_enabled_env,
6383 "path": memory_path.display().to_string(),
6384 "file_present": memory_path.exists(),
6385 });
6386 let api_target = doctor_api_target(config);
6387 let strict_tool_mode = doctor_strict_tool_mode_status(config);
6388 let tls_status = doctor_tls_status(config);
6389 let (code_home, legacy_home) = doctor_state_roots();
6390 let legacy_state_report = doctor_legacy_state_report(&code_home, &legacy_home);
6391 let session_recovery = doctor_session_recovery_report(
6392 &code_home,
6393 &legacy_home,
6394 codewhale_config::codewhale_home_is_explicit(),
6395 );
6396
6397 let stash = crate::composer_stash::diagnostic_stash_report();
6398 let report = json!({
6399 "version": env!("CARGO_PKG_VERSION"),
6400 "config_path": config_path.display().to_string(),
6401 "config_present": config_path.exists(),
6402 "paths": doctor_paths,
6403 "secret_backend": secret_backend,
6404 "workspace": workspace.display().to_string(),
6405 "legacy_state": doctor_legacy_state_json(
6406 &code_home,
6407 &legacy_home,
6408 &legacy_state_report,
6409 &session_recovery,
6410 ),
6411 "setup": doctor_setup_report_json(config, workspace),
6412 "api_key": {
6413 "source": doctor_api_key_source_label(credential.source),
6414 "availability": credential.availability.label(),
6415 },
6416 "external_credentials": doctor_external_credential_consent_json(config),
6417 "base_url": crate::doctor::structural_url_authority(&api_target.base_url),
6418 "default_text_model": api_target.model,
6419 "route_scope": "configured_at_launch",
6422 "model_resolution": match api_target.resolution {
6423 DoctorModelResolution::Resolved => "resolved",
6424 DoctorModelResolution::ConfiguredOnly => "configured_unresolved",
6425 },
6426 "route": doctor_route_report(config),
6427 "strict_tool_mode": doctor_strict_tool_mode_report_json(&strict_tool_mode),
6428 "tls": {
6429 "certificate_verification": tls_status.certificate_verification,
6430 "insecure_skip_tls_verify": tls_status.insecure_skip_tls_verify,
6431 "provider": tls_status.provider,
6432 "message": tls_status.message,
6433 },
6434 "search_provider": doctor_search_provider_json(config),
6435 "memory": memory_summary,
6436 "mcp": mcp_summary,
6437 "skills": {
6438 "selected": selected_skills_dir.display().to_string(),
6439 "global": {
6440 "path": global_skills_dir.display().to_string(),
6441 "present": global_skills_dir.exists(),
6442 "count": skills_count_for(&global_skills_dir),
6443 },
6444 "agents": {
6445 "path": agents_skills_dir.display().to_string(),
6446 "present": agents_skills_dir.exists(),
6447 "count": skills_count_for(&agents_skills_dir),
6448 },
6449 "agents_global": agents_global_summary,
6450 "local": {
6451 "path": local_skills_dir.display().to_string(),
6452 "present": local_skills_dir.exists(),
6453 "count": skills_count_for(&local_skills_dir),
6454 },
6455 "opencode": {
6456 "path": opencode_skills_dir.display().to_string(),
6457 "present": opencode_skills_dir.exists(),
6458 "count": skills_count_for(&opencode_skills_dir),
6459 },
6460 "claude": {
6461 "path": claude_skills_dir.display().to_string(),
6462 "present": claude_skills_dir.exists(),
6463 "count": skills_count_for(&claude_skills_dir),
6464 },
6465 },
6466 "tools": {
6467 "path": tools_dir.display().to_string(),
6468 "present": tools_dir.exists(),
6469 "count": if tools_dir.exists() { count_dir_entries(&tools_dir) } else { 0 },
6470 },
6471 "plugins": {
6472 "path": plugins_dir.display().to_string(),
6473 "present": plugins_dir.exists(),
6474 "count": if plugins_dir.exists() { count_dir_entries(&plugins_dir) } else { 0 },
6475 },
6476 "storage": {
6477 "spillover": {
6478 "path": crate::tools::truncate::spillover_root()
6479 .map(|p| p.display().to_string())
6480 .unwrap_or_default(),
6481 "present": crate::tools::truncate::spillover_root()
6482 .is_some_and(|p| p.is_dir()),
6483 "count": crate::tools::truncate::spillover_root()
6484 .filter(|p| p.is_dir())
6485 .map(|p| count_dir_entries(&p))
6486 .unwrap_or(0),
6487 },
6488 "stash": {
6489 "path": stash
6490 .path
6491 .as_ref()
6492 .map(|path| path.display().to_string())
6493 .unwrap_or_default(),
6494 "present": stash.present,
6495 "count": stash.count,
6496 "error": stash.error,
6497 },
6498 },
6499 "sandbox": match crate::sandbox::get_platform_sandbox_with_bwrap_preference(
6500 config.prefer_bwrap.unwrap_or(false),
6501 ) {
6502 Some(kind) => json!({"available": true, "kind": kind.to_string()}),
6503 None => json!({"available": false, "kind": null}),
6504 },
6505 "platform": {
6506 "os": std::env::consts::OS,
6507 "arch": std::env::consts::ARCH,
6508 },
6509 "api_connectivity": {
6510 "checked": false,
6511 "status": "not_probed",
6512 "note": "JSON doctor is offline; use `codewhale doctor --probe-api` or `--probe-local` for an explicit live check.",
6513 },
6514 "capability": provider_capability_report(config),
6515 });
6516
6517 println!("{}", serde_json::to_string_pretty(&report)?);
6518 Ok(())
6519}
6520
6521fn run_doctor_context_json(config: &Config, workspace: &Path) -> Result<()> {
6522 let report = crate::context_report::build_headless_context_report(config, workspace);
6523 println!("{}", crate::context_report::context_report_json(&report));
6524 Ok(())
6525}
6526
6527fn provider_capability_report(config: &Config) -> serde_json::Value {
6533 use serde_json::json;
6534
6535 let provider = config.api_provider();
6536 let configured_model = config.default_model();
6537 let route_result =
6538 crate::route_runtime::resolve_runtime_route(config, provider, Some(&configured_model));
6539 let route_error = route_result
6540 .is_err()
6541 .then_some("route_resolution_failed_details_omitted");
6542 let route = route_result.ok();
6543 let resolved_model = route
6544 .as_ref()
6545 .map_or(configured_model.as_str(), |route| route.model.as_str());
6546 let cap = crate::config::provider_capability(provider, resolved_model);
6547 let route_profile = route.as_ref().map(|route| {
6548 crate::model_profile::resolved_capability_profile_for_route(
6549 provider,
6550 resolved_model,
6551 route.candidate.capabilities(),
6552 route.candidate.limits(),
6553 )
6554 });
6555 let context_window = route
6556 .as_ref()
6557 .map_or(cap.context_window, |route| route.context_window.tokens);
6558 let context_window_source = route.as_ref().map_or(
6559 crate::route_runtime::ContextWindowSource::Fallback.label(),
6560 |route| route.context_window.source.label(),
6561 );
6562 let max_output = route_profile
6565 .as_ref()
6566 .and_then(|profile| profile.max_output)
6567 .or(cap.max_output);
6568 let is_exact_kimi_code_k3 = route.as_ref().is_some_and(|route| {
6569 crate::config::is_exact_kimi_code_k3_route(
6570 provider,
6571 &route.candidate.endpoint().base_url,
6572 route.candidate.wire_model_id().as_str(),
6573 )
6574 });
6575 let thinking_supported = is_exact_kimi_code_k3
6576 || route_profile
6577 .as_ref()
6578 .map_or(cap.thinking_supported, |profile| {
6579 profile.supports_reasoning()
6580 });
6581 let cache_telemetry_supported = route_profile
6582 .as_ref()
6583 .map_or(cap.cache_telemetry_supported, |profile| {
6584 profile.prompt_caching.is_supported()
6585 });
6586 let request_payload_mode = route_profile
6587 .as_ref()
6588 .map_or(cap.request_payload_mode, |profile| {
6589 profile.request_payload_mode
6590 });
6591 let alias_deprecation = config.active_deepseek_alias_deprecation();
6592
6593 json!({
6594 "resolved_provider": config.provider_identity_for(provider),
6595 "resolved_model": resolved_model,
6596 "context_window": context_window,
6597 "context_window_source": context_window_source,
6598 "max_output": max_output,
6599 "thinking_supported": thinking_supported,
6600 "cache_telemetry_supported": cache_telemetry_supported,
6601 "request_payload_mode": serde_json::to_value(request_payload_mode).unwrap_or_default(),
6602 "route_error": route_error,
6603 "alias_deprecation": alias_deprecation,
6604 })
6605}
6606
6607fn doctor_route_report(config: &Config) -> serde_json::Value {
6608 use serde_json::json;
6609
6610 let target = doctor_api_target(config);
6611 let provider = config.api_provider();
6612 let redacted_base_url = crate::doctor::structural_url_authority(&target.base_url);
6613 let route_result =
6614 crate::route_runtime::resolve_runtime_route(config, provider, Some(&target.model));
6615 let route_error = route_result
6616 .is_err()
6617 .then_some("route_resolution_failed_details_omitted");
6618 let context_window = route_result
6619 .ok()
6620 .map(|route| {
6621 json!({
6622 "tokens": route.context_window.tokens,
6623 "source": route.context_window.source.label(),
6624 })
6625 })
6626 .unwrap_or_else(|| {
6627 json!({
6628 "tokens": crate::config::provider_capability(provider, &target.model).context_window,
6629 "source": crate::route_runtime::ContextWindowSource::Fallback.label(),
6630 })
6631 });
6632
6633 let route_identity =
6634 crate::config::moonshot_k3_route_display_name(&target.base_url, &target.model);
6635 let credential = resolve_credential_diagnostic(config);
6636
6637 json!({
6638 "provider": target.provider,
6639 "provider_source": doctor_provider_source(config),
6640 "provider_config_table": doctor_provider_config_table(config, provider),
6641 "model": target.model,
6642 "route_identity": route_identity,
6643 "wire_protocol": doctor_wire_protocol(provider),
6644 "base_url": {
6645 "redacted": redacted_base_url,
6646 "class": doctor_base_url_class(provider, &target.base_url),
6647 "fingerprint": crate::utils::redacted_identifier_for_log(&target.base_url),
6648 },
6649 "auth": {
6650 "scheme": doctor_auth_scheme(config),
6651 "source": doctor_api_key_source_label(credential.source),
6652 "availability": credential.availability.label(),
6653 },
6654 "context_window": context_window,
6655 "route_error": route_error,
6656 })
6657}
6658
6659fn doctor_provider_config_table(config: &Config, provider: crate::config::ApiProvider) -> String {
6660 if provider != crate::config::ApiProvider::Custom {
6661 return provider_config_table_key(provider).to_string();
6662 }
6663 if config.uses_legacy_literal_custom_route() {
6664 "root (legacy literal custom)".to_string()
6665 } else {
6666 format!("providers.{}", config.provider_identity_for(provider))
6667 }
6668}
6669
6670fn doctor_provider_source(config: &Config) -> &'static str {
6671 if config
6672 .provider
6673 .as_ref()
6674 .is_some_and(|provider| !provider.trim().is_empty())
6675 {
6676 "config"
6677 } else {
6678 "default"
6679 }
6680}
6681
6682fn doctor_wire_protocol(provider: crate::config::ApiProvider) -> &'static str {
6683 let policy = provider
6684 .metadata()
6685 .map(|metadata| metadata.wire_policy())
6686 .unwrap_or(codewhale_config::provider::WirePolicy::Fixed(
6687 codewhale_config::provider::WireFormat::ChatCompletions,
6688 ));
6689 match policy.fixed() {
6690 Some(codewhale_config::provider::WireFormat::ChatCompletions) => "chat_completions",
6691 Some(codewhale_config::provider::WireFormat::Responses) => "responses",
6692 Some(codewhale_config::provider::WireFormat::AnthropicMessages) => "anthropic_messages",
6693 None => "model_aware",
6694 }
6695}
6696
6697fn doctor_base_url_class(provider: crate::config::ApiProvider, base_url: &str) -> &'static str {
6698 let normalized = base_url.trim_end_matches('/').to_ascii_lowercase();
6699 if normalized.starts_with("http://localhost")
6700 || normalized.starts_with("http://127.0.0.1")
6701 || normalized.starts_with("http://[::1]")
6702 {
6703 return "local";
6704 }
6705 if normalized
6706 == provider
6707 .default_base_url()
6708 .trim_end_matches('/')
6709 .to_ascii_lowercase()
6710 {
6711 "default"
6712 } else {
6713 "custom"
6714 }
6715}
6716
6717fn doctor_auth_scheme(config: &Config) -> &'static str {
6718 let provider = config.api_provider();
6719 if crate::config::auth_mode_disables_api_key(config.auth_mode_for_provider(provider).as_deref())
6720 {
6721 "none"
6722 } else if provider == crate::config::ApiProvider::Anthropic {
6723 "x-api-key"
6724 } else if provider == crate::config::ApiProvider::XiaomiMimo
6725 && doctor_xiaomi_mimo_base_url_uses_token_plan(&config.deepseek_base_url())
6726 {
6727 "api-key"
6728 } else if provider == crate::config::ApiProvider::XiaomiMimo {
6729 "unknown"
6732 } else if matches!(
6733 provider,
6734 crate::config::ApiProvider::Sglang
6735 | crate::config::ApiProvider::Vllm
6736 | crate::config::ApiProvider::Ollama
6737 ) {
6738 "optional_bearer"
6739 } else {
6740 "bearer"
6741 }
6742}
6743
6744fn doctor_xiaomi_mimo_base_url_uses_token_plan(base_url: &str) -> bool {
6745 let normalized = base_url.trim_end_matches('/');
6746 [
6747 crate::config::XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL,
6748 crate::config::XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL,
6749 crate::config::XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL,
6750 ]
6751 .iter()
6752 .any(|candidate| normalized.eq_ignore_ascii_case(candidate.trim_end_matches('/')))
6753}
6754
6755fn doctor_api_key_source_label(source: ApiKeySource) -> &'static str {
6756 match source {
6757 ApiKeySource::ConfigDeclared => "config_declared",
6758 ApiKeySource::EnvDeclared => "env_declared",
6759 ApiKeySource::ExternalAuthDeclared => "external_auth_declared",
6760 ApiKeySource::SecretStoreUnprobed => "secret_store_unprobed",
6761 ApiKeySource::SecretStoreUnavailable => "secret_store_unavailable",
6762 ApiKeySource::OAuth => "oauth_unprobed",
6763 ApiKeySource::ExternalConsent => "external_consent",
6764 ApiKeySource::NoAuth => "none",
6765 ApiKeySource::LocalRuntime => "local_runtime",
6766 ApiKeySource::Unknown => "unknown",
6767 }
6768}
6769
6770fn doctor_search_provider_line(config: &Config) -> String {
6771 let search_provider = config.search_provider_resolution();
6772 let switch_hint = if matches!(
6773 (search_provider.provider, search_provider.source),
6774 (
6775 crate::config::SearchProvider::Firecrawl,
6776 crate::config::SearchProviderSource::Default
6777 )
6778 ) {
6779 "; set [search] provider = \"baidu\" | \"metaso\" | \"volcengine\" for China"
6780 } else {
6781 ""
6782 };
6783
6784 format!(
6785 "search_provider: {} (source: {}{})",
6786 search_provider.provider.as_str(),
6787 search_provider.source.as_str(),
6788 switch_hint
6789 )
6790}
6791
6792fn doctor_search_provider_json(config: &Config) -> serde_json::Value {
6793 use serde_json::json;
6794
6795 let search_provider = config.search_provider_resolution();
6796 json!({
6797 "provider": search_provider.provider.as_str(),
6798 "source": search_provider.source.as_str(),
6799 })
6800}
6801
6802#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6807enum DoctorModelResolution {
6808 Resolved,
6809 ConfiguredOnly,
6810}
6811
6812#[derive(Debug, Clone, PartialEq, Eq)]
6813struct DoctorApiTarget {
6814 provider: String,
6815 base_url: String,
6816 model: String,
6817 resolution: DoctorModelResolution,
6818}
6819
6820#[derive(Debug, Clone, PartialEq, Eq)]
6821struct DoctorStrictToolModeStatus {
6822 enabled: bool,
6823 status: &'static str,
6824 function_strict_sent: bool,
6825 message: String,
6826 recommended_base_url: Option<String>,
6827}
6828
6829fn doctor_api_target(config: &Config) -> DoctorApiTarget {
6830 let provider = config.api_provider();
6831 let (model, resolution) =
6838 match crate::route_runtime::resolve_runtime_route(config, provider, None) {
6839 Ok(route) => (route.model.clone(), DoctorModelResolution::Resolved),
6840 Err(_) => (
6841 config.default_model(),
6842 DoctorModelResolution::ConfiguredOnly,
6843 ),
6844 };
6845 DoctorApiTarget {
6846 provider: config.provider_identity_for(provider),
6847 base_url: config.deepseek_base_url(),
6848 model,
6849 resolution,
6850 }
6851}
6852
6853fn doctor_strict_tool_mode_status(config: &Config) -> DoctorStrictToolModeStatus {
6854 if !config.strict_tool_mode.unwrap_or(false) {
6855 return DoctorStrictToolModeStatus {
6856 enabled: false,
6857 status: "disabled",
6858 function_strict_sent: false,
6859 message: "disabled".to_string(),
6860 recommended_base_url: None,
6861 };
6862 }
6863
6864 let target = doctor_api_target(config);
6865 match known_deepseek_base_url_kind(&target.base_url) {
6866 Some(DeepSeekBaseUrlKind::Beta) => DoctorStrictToolModeStatus {
6867 enabled: true,
6868 status: "ready",
6869 function_strict_sent: true,
6870 message: "enabled; DeepSeek strict schemas use the beta endpoint".to_string(),
6871 recommended_base_url: None,
6872 },
6873 Some(DeepSeekBaseUrlKind::NonBeta) => {
6874 let recommended = recommended_strict_base_url(config, &target.base_url);
6875 DoctorStrictToolModeStatus {
6876 enabled: true,
6877 status: "fallback_non_beta",
6878 function_strict_sent: false,
6879 message:
6880 "enabled, but function.strict is stripped for this non-beta DeepSeek endpoint"
6881 .to_string(),
6882 recommended_base_url: Some(recommended.to_string()),
6883 }
6884 }
6885 None => DoctorStrictToolModeStatus {
6886 enabled: true,
6887 status: "custom_endpoint",
6888 function_strict_sent: true,
6889 message: "enabled; function.strict will be sent to this custom endpoint".to_string(),
6890 recommended_base_url: None,
6891 },
6892 }
6893}
6894
6895fn doctor_strict_tool_mode_report_json(status: &DoctorStrictToolModeStatus) -> serde_json::Value {
6896 serde_json::json!({
6897 "enabled": status.enabled,
6898 "status": status.status,
6899 "function_strict_sent": status.function_strict_sent,
6900 "message": status.message,
6901 "recommended_base_url": status
6902 .recommended_base_url
6903 .as_deref()
6904 .map(crate::doctor::structural_url_authority),
6905 })
6906}
6907
6908#[derive(Debug, Clone, PartialEq, Eq)]
6909struct DoctorTlsStatus {
6910 certificate_verification: bool,
6911 insecure_skip_tls_verify: bool,
6912 provider: String,
6913 message: String,
6914}
6915
6916fn doctor_tls_status(config: &Config) -> DoctorTlsStatus {
6917 let provider = config.provider_identity_for(config.api_provider());
6918 let insecure_skip_tls_verify = config.insecure_skip_tls_verify();
6919 let message = if insecure_skip_tls_verify {
6920 format!(
6921 "TLS certificate verification cannot be disabled for provider {provider}; use SSL_CERT_FILE with a trusted custom CA bundle"
6922 )
6923 } else {
6924 "TLS certificate verification enabled".to_string()
6925 };
6926 DoctorTlsStatus {
6927 certificate_verification: true,
6928 insecure_skip_tls_verify,
6929 provider,
6930 message,
6931 }
6932}
6933
6934#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6935enum DeepSeekBaseUrlKind {
6936 Beta,
6937 NonBeta,
6938}
6939
6940fn known_deepseek_base_url_kind(base_url: &str) -> Option<DeepSeekBaseUrlKind> {
6941 let normalized = base_url.trim_end_matches('/');
6942 if normalized.eq_ignore_ascii_case("https://api.deepseek.com/beta")
6943 || normalized.eq_ignore_ascii_case("https://api.deepseeki.com/beta")
6944 {
6945 Some(DeepSeekBaseUrlKind::Beta)
6946 } else if normalized.eq_ignore_ascii_case("https://api.deepseek.com")
6947 || normalized.eq_ignore_ascii_case("https://api.deepseek.com/v1")
6948 || normalized.eq_ignore_ascii_case("https://api.deepseeki.com")
6949 || normalized.eq_ignore_ascii_case("https://api.deepseeki.com/v1")
6950 {
6951 Some(DeepSeekBaseUrlKind::NonBeta)
6952 } else {
6953 None
6954 }
6955}
6956
6957fn recommended_strict_base_url(_config: &Config, _base_url: &str) -> &'static str {
6958 crate::config::DEFAULT_DEEPSEEK_BASE_URL
6959}
6960
6961fn doctor_timeout_recovery_lines(config: &Config) -> Vec<String> {
6962 let target = doctor_api_target(config);
6963 let mut lines = vec![format!(
6964 "Connection timed out while reaching {}.",
6965 crate::doctor::structural_url_authority(&target.base_url)
6966 )];
6967
6968 match config.api_provider() {
6969 crate::config::ApiProvider::Deepseek
6970 if target.base_url.contains("api.deepseek.com")
6971 && !target.base_url.contains("api.deepseeki.com") =>
6972 {
6973 lines.push(
6974 "If this is a custom DeepSeek-compatible endpoint, set its HTTPS base URL in ~/.codewhale/config.toml and rerun `codewhale doctor`."
6975 .to_string(),
6976 );
6977 }
6978 crate::config::ApiProvider::Deepseek | crate::config::ApiProvider::DeepseekCN => {
6979 lines.push(
6980 "If this is a custom DeepSeek-compatible endpoint, confirm it serves `/v1/models` and `/v1/chat/completions` over HTTPS."
6981 .to_string(),
6982 );
6983 }
6984 _ => {
6985 lines.push(
6986 "Confirm the configured provider endpoint is reachable and OpenAI-compatible for `/v1/models` and `/v1/chat/completions`."
6987 .to_string(),
6988 );
6989 }
6990 }
6991
6992 lines.push(
6993 "Run `codewhale doctor --json` and include `base_url`, `default_text_model`, and `api_connectivity` when filing an issue."
6994 .to_string(),
6995 );
6996 lines
6997}
6998
6999fn run_features_command(config: &Config, command: FeaturesCli) -> Result<()> {
7000 match command.command {
7001 FeaturesSubcommand::List => {
7002 print!("{}", render_feature_table(&config.features()));
7003 Ok(())
7004 }
7005 }
7006}
7007
7008async fn run_models(config: &Config, args: ModelsArgs) -> Result<()> {
7009 use crate::client::DeepSeekClient;
7010
7011 let client = DeepSeekClient::new(config)?;
7012 let mut models = client.list_models().await?;
7013 models.sort_by(|a, b| a.id.cmp(&b.id));
7014
7015 if args.json {
7016 println!("{}", serde_json::to_string_pretty(&models)?);
7017 return Ok(());
7018 }
7019
7020 if models.is_empty() {
7021 println!("No models returned by the API.");
7022 return Ok(());
7023 }
7024
7025 let default_model = config.default_model();
7026
7027 println!("Available models (default: {default_model})");
7028 for model in models {
7029 let marker = if model.id == default_model { "*" } else { " " };
7030 if let Some(owner) = model.owned_by {
7031 println!("{marker} {} ({owner})", model.id);
7032 } else {
7033 println!("{marker} {}", model.id);
7034 }
7035 }
7036
7037 Ok(())
7038}
7039
7040async fn run_speech(config: &Config, args: SpeechArgs) -> Result<()> {
7041 use crate::client::{DeepSeekClient, SpeechSynthesisRequest};
7042 use crate::config::ApiProvider;
7043 use crate::tools::speech::{
7044 DEFAULT_VOICE, SPEECH_MODEL_EXAMPLES, combine_speech_instructions,
7045 default_speech_output_name, describe_speech_voice, encode_voice_clone_sample_data_uri,
7046 infer_speech_model, normalize_speech_format,
7047 };
7048
7049 let SpeechArgs {
7050 text,
7051 output,
7052 output_dir,
7053 model,
7054 voice,
7055 instruction,
7056 voice_prompt,
7057 clone_voice,
7058 format,
7059 json: json_output,
7060 } = args;
7061
7062 if config.api_provider() != ApiProvider::XiaomiMimo {
7063 bail!(
7064 "`speech` requires provider = \"xiaomi-mimo\" (current: {}). Run with `--provider xiaomi-mimo` or set it in config.",
7065 config.api_provider().as_str()
7066 );
7067 }
7068
7069 if text.trim().is_empty() {
7070 bail!("Speech text cannot be empty");
7071 }
7072 let voice_is_data_uri = voice
7073 .as_deref()
7074 .map(str::trim)
7075 .is_some_and(|value| value.starts_with("data:audio/"));
7076 if clone_voice.is_some() && voice.is_some() {
7077 bail!("Use either --clone-voice or --voice for cloned voice data, not both");
7078 }
7079 let model = infer_speech_model(
7080 model.as_deref(),
7081 clone_voice.is_some() || voice_is_data_uri,
7082 voice_prompt.is_some(),
7083 );
7084 let model_lower = model.to_ascii_lowercase();
7085 if !model_lower.contains("tts") {
7086 bail!(
7087 "speech requires a TTS model (examples: {}); got {model}",
7088 SPEECH_MODEL_EXAMPLES.join(", ")
7089 );
7090 }
7091 let is_voice_design = model_lower.contains("voicedesign");
7092 let is_voice_clone = model_lower.contains("voiceclone");
7093
7094 let instruction = combine_speech_instructions(instruction, voice_prompt);
7095 if is_voice_design
7096 && instruction
7097 .as_deref()
7098 .is_none_or(|value| value.trim().is_empty())
7099 {
7100 bail!(
7101 "mimo-v2.5-tts-voicedesign requires --voice-prompt or --instruction to describe the voice"
7102 );
7103 }
7104
7105 let voice = if let Some(clone_path) = clone_voice {
7106 Some(encode_voice_clone_sample_data_uri(&clone_path)?)
7107 } else if is_voice_design {
7108 None
7109 } else if let Some(value) = voice.filter(|value| !value.trim().is_empty()) {
7110 Some(value)
7111 } else if is_voice_clone {
7112 bail!("mimo-v2.5-tts-voiceclone requires --clone-voice <mp3|wav> or --voice <data-uri>");
7113 } else {
7114 Some(DEFAULT_VOICE.to_string())
7115 };
7116 let format = normalize_speech_format(&format).with_context(|| {
7117 format!("Unsupported speech format '{format}' (allowed: wav, mp3, pcm16)")
7118 })?;
7119 let output = output.unwrap_or_else(|| {
7120 output_dir
7121 .or_else(|| config.speech_output_dir())
7122 .unwrap_or_default()
7123 .join(default_speech_output_name(&format))
7124 });
7125
7126 let client = DeepSeekClient::new(config)?;
7127 let response = client
7128 .synthesize_speech(SpeechSynthesisRequest {
7129 model: model.clone(),
7130 text,
7131 instruction,
7132 audio_format: format.clone(),
7133 voice,
7134 })
7135 .await?;
7136
7137 if let Some(parent) = output.parent().filter(|path| !path.as_os_str().is_empty()) {
7138 std::fs::create_dir_all(parent)
7139 .with_context(|| format!("Failed to create output directory {}", parent.display()))?;
7140 }
7141 std::fs::write(&output, &response.audio_bytes)
7142 .with_context(|| format!("Failed to write audio file {}", output.display()))?;
7143
7144 if json_output {
7145 println!(
7146 "{}",
7147 serde_json::to_string_pretty(&serde_json::json!({
7148 "mode": "speech",
7149 "success": true,
7150 "model": response.model,
7151 "format": response.audio_format,
7152 "output": output.display().to_string(),
7153 "bytes": response.audio_bytes.len(),
7154 "voice": response.voice.as_deref().map(describe_speech_voice),
7155 "transcript": response.transcript,
7156 }))?
7157 );
7158 } else {
7159 println!(
7160 "Generated speech: {} ({} bytes, model: {}, format: {})",
7161 output.display(),
7162 response.audio_bytes.len(),
7163 response.model,
7164 response.audio_format
7165 );
7166 }
7167
7168 Ok(())
7169}
7170
7171#[cfg(test)]
7172mod speech_cli_tests {
7173 use super::*;
7174 use crate::tools::speech::{
7175 default_speech_output_name, infer_speech_model, normalize_speech_format,
7176 };
7177
7178 #[test]
7179 fn normalizes_documented_speech_formats() {
7180 assert_eq!(normalize_speech_format("WAV").as_deref(), Some("wav"));
7181 assert_eq!(normalize_speech_format("pcm16").as_deref(), Some("pcm16"));
7182 assert_eq!(normalize_speech_format("pcm").as_deref(), Some("pcm16"));
7183 assert_eq!(normalize_speech_format("flac"), None);
7184 }
7185
7186 #[test]
7187 fn default_speech_output_tracks_requested_format() {
7188 assert_eq!(
7189 PathBuf::from(default_speech_output_name("mp3")),
7190 PathBuf::from("speech.mp3")
7191 );
7192 assert_eq!(
7193 PathBuf::from("audio").join(default_speech_output_name("pcm")),
7194 PathBuf::from("audio").join("speech.pcm16")
7195 );
7196 }
7197
7198 #[test]
7199 fn speech_command_parses_cli_passthrough_smoke() {
7200 let cli = Cli::try_parse_from([
7201 "codewhale-tui",
7202 "speech",
7203 "hello",
7204 "--model",
7205 "tts",
7206 "--format",
7207 "pcm",
7208 "--output-dir",
7209 "audio",
7210 "--voice",
7211 "Mia",
7212 ])
7213 .expect("speech command parses");
7214
7215 let Some(Commands::Speech(args)) = cli.command else {
7216 panic!("expected speech command");
7217 };
7218 assert_eq!(args.text, "hello");
7219 assert_eq!(
7220 infer_speech_model(args.model.as_deref(), false, false),
7221 "mimo-v2.5-tts"
7222 );
7223 assert_eq!(
7224 normalize_speech_format(&args.format).as_deref(),
7225 Some("pcm16")
7226 );
7227 assert_eq!(args.output_dir, Some(PathBuf::from("audio")));
7228 assert_eq!(args.voice.as_deref(), Some("Mia"));
7229 }
7230}
7231
7232async fn test_api_connectivity(config: &Config) -> Result<()> {
7234 use crate::client::DeepSeekClient;
7235 use crate::models::{ContentBlock, Message, MessageRequest};
7236
7237 let client = DeepSeekClient::new(config)?;
7238 let model = client.model().to_string();
7239
7240 let request = MessageRequest {
7242 model: model.clone(),
7243 messages: vec![Message {
7244 role: "user".to_string(),
7245 content: vec![ContentBlock::Text {
7246 text: "hi".to_string(),
7247 cache_control: None,
7248 }],
7249 }],
7250 max_tokens: 1,
7251 system: None,
7252 tools: None,
7253 tool_choice: None,
7254 metadata: None,
7255 thinking: None,
7256 reasoning_effort: Some("off".to_string()),
7258 stream: Some(false),
7259 temperature: None,
7260 top_p: None,
7261 };
7262
7263 let timeout_duration = std::time::Duration::from_secs(15);
7265 match tokio::time::timeout(timeout_duration, client.create_message(request)).await {
7266 Ok(Ok(_response)) => Ok(()),
7267 Ok(Err(e)) => Err(e),
7268 Err(_) => anyhow::bail!("Request timeout after 15 seconds"),
7269 }
7270}
7271
7272fn rustc_version() -> String {
7273 let Some(mut cmd) = crate::dependencies::RustC::command() else {
7274 return "unknown".to_string();
7275 };
7276 let Ok(output) = cmd.arg("--version").output() else {
7277 return "unknown".to_string();
7278 };
7279 String::from_utf8(output.stdout)
7280 .map(|s| s.trim().to_string())
7281 .unwrap_or_else(|_| "unknown".to_string())
7282}
7283
7284fn sessions_resume_command() -> &'static str {
7286 "codewhale resume"
7287}
7288
7289fn list_sessions(limit: usize, search: Option<String>) -> Result<()> {
7290 use crate::palette;
7291 use colored::Colorize;
7292 use session_manager::{SessionManager, format_session_line};
7293
7294 let (action_r, action_g, action_b) = palette::WHALE_ACTION_RGB;
7295 let (human_r, human_g, human_b) = palette::WHALE_HUMAN_RGB;
7296 let (sky_r, sky_g, sky_b) = palette::WHALE_INFO_RGB;
7297 let (aqua_r, aqua_g, aqua_b) = palette::WHALE_INFO_RGB;
7298
7299 let manager = SessionManager::default_location()?;
7300
7301 let sessions = if let Some(query) = search {
7302 manager.search_sessions(&query)?
7303 } else {
7304 manager.list_sessions()?
7305 };
7306
7307 if sessions.is_empty() {
7308 println!("{}", "No sessions found.".truecolor(sky_r, sky_g, sky_b));
7309 println!(
7310 "Start a new session with: {}",
7311 "codewhale".truecolor(human_r, human_g, human_b)
7312 );
7313 return Ok(());
7314 }
7315
7316 println!(
7317 "{}",
7318 "Saved Sessions"
7319 .truecolor(action_r, action_g, action_b)
7320 .bold()
7321 );
7322 println!("{}", "==============".truecolor(sky_r, sky_g, sky_b));
7323 println!();
7324
7325 for (i, session) in sessions.iter().take(limit).enumerate() {
7326 let line = format_session_line(session);
7327 if i == 0 {
7328 println!(" {} {}", "*".truecolor(aqua_r, aqua_g, aqua_b), line);
7329 } else {
7330 println!(" {line}");
7331 }
7332 }
7333
7334 let total = sessions.len();
7335 if total > limit {
7336 println!();
7337 println!(
7338 " {} more session(s). Use --limit to show more.",
7339 total - limit
7340 );
7341 }
7342
7343 println!();
7344 println!(
7345 "Resume with: {} {}",
7346 sessions_resume_command().truecolor(action_r, action_g, action_b),
7347 "<session-id>".dimmed()
7348 );
7349 println!(
7350 "Continue latest in this workspace: {}",
7351 "codewhale --continue".truecolor(action_r, action_g, action_b)
7352 );
7353
7354 Ok(())
7355}
7356
7357fn init_project() -> Result<()> {
7359 use crate::palette;
7360 use colored::Colorize;
7361 use project_context::create_default_agents_md;
7362
7363 let (sky_r, sky_g, sky_b) = palette::WHALE_INFO_RGB;
7364 let (aqua_r, aqua_g, aqua_b) = palette::WHALE_INFO_RGB;
7365 let (red_r, red_g, red_b) = palette::WHALE_ERROR_RGB;
7366
7367 let workspace = std::env::current_dir()?;
7368 let agents_path = workspace.join("AGENTS.md");
7369
7370 if agents_path.exists() {
7371 println!(
7372 "{} AGENTS.md already exists at {}",
7373 "!".truecolor(sky_r, sky_g, sky_b),
7374 agents_path.display()
7375 );
7376 return Ok(());
7377 }
7378
7379 match create_default_agents_md(&workspace) {
7380 Ok(path) => {
7381 println!(
7382 "{} Created {}",
7383 "✓".truecolor(aqua_r, aqua_g, aqua_b),
7384 path.display()
7385 );
7386 println!();
7387 println!("Edit this file to customize how the AI agent works with your project.");
7388 println!("The instructions will be loaded automatically when you run codewhale.");
7389 }
7390 Err(e) => {
7391 println!(
7392 "{} Failed to create AGENTS.md: {}",
7393 "✗".truecolor(red_r, red_g, red_b),
7394 e
7395 );
7396 }
7397 }
7398
7399 Ok(())
7400}
7401
7402fn resolve_workspace(cli: &Cli) -> PathBuf {
7403 cli.workspace
7404 .clone()
7405 .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
7406}
7407
7408fn load_config_from_cli(cli: &Cli) -> Result<Config> {
7409 load_config_from_cli_with_effective_profile(cli).map(|(config, _)| config)
7410}
7411
7412fn load_doctor_config_from_cli(cli: &Cli, args: &DoctorArgs) -> Result<Config> {
7417 if args.probe_api || args.probe_local {
7418 return load_config_from_cli(cli);
7419 }
7420 load_structural_config_from_cli(cli)
7421}
7422
7423fn load_structural_config_from_cli(cli: &Cli) -> Result<Config> {
7424 let profile = effective_config_profile(cli);
7425 let mut config = Config::load_structural(cli.config.clone(), profile.as_deref())?;
7426 if let Ok(settings) = crate::settings::Settings::load_read_only() {
7427 apply_saved_reasoning_preference(&mut config, &settings);
7428 }
7429 cli.feature_toggles.apply(&mut config)?;
7430 Ok(config)
7431}
7432
7433fn effective_config_profile(cli: &Cli) -> Option<String> {
7434 cli.profile
7435 .clone()
7436 .or_else(|| std::env::var("CODEWHALE_PROFILE").ok())
7437 .or_else(|| std::env::var("DEEPSEEK_PROFILE").ok())
7438}
7439
7440fn load_config_from_cli_with_effective_profile(cli: &Cli) -> Result<(Config, Option<String>)> {
7441 let profile = effective_config_profile(cli);
7442 let mut config = Config::load(cli.config.clone(), profile.as_deref())?;
7443 if let Ok(settings) = crate::settings::Settings::load_read_only() {
7447 apply_saved_reasoning_preference(&mut config, &settings);
7448 }
7449 cli.feature_toggles.apply(&mut config)?;
7450 Ok((config, profile))
7451}
7452
7453fn apply_saved_reasoning_preference(config: &mut Config, settings: &crate::settings::Settings) {
7461 let Some(reasoning_effort) = settings.reasoning_effort.as_ref() else {
7462 return;
7463 };
7464 config.reasoning_effort = Some(reasoning_effort.clone());
7465 config.reasoning_effort_inferred_from_legacy_alias = false;
7466}
7467
7468fn read_api_key_from_stdin() -> Result<String> {
7469 let mut stdin = io::stdin();
7470 if stdin.is_terminal() {
7471 bail!("No API key provided. Pass --api-key or pipe one via stdin.");
7472 }
7473 let mut buffer = String::new();
7474 stdin.read_to_string(&mut buffer)?;
7475 let api_key = buffer.trim().to_string();
7476 if api_key.is_empty() {
7477 bail!("No API key provided via stdin.");
7478 }
7479 Ok(api_key)
7480}
7481
7482fn run_login(api_key: Option<String>) -> Result<()> {
7483 let api_key = match api_key {
7484 Some(key) => key,
7485 None => read_api_key_from_stdin()?,
7486 };
7487 let saved = config::save_api_key(&api_key)?;
7488 println!("Saved API key to {}", saved.describe());
7489 Ok(())
7490}
7491
7492fn run_logout() -> Result<()> {
7493 config::clear_api_key()?;
7494 println!("Cleared saved API key.");
7495 Ok(())
7496}
7497
7498async fn run_xai_device_auth(config_path: Option<&Path>) -> Result<()> {
7499 let pending = xai_oauth::device_code_login().await?;
7500 let activation = xai_oauth::activate_device_login(pending, config_path, None)?;
7501 println!(
7502 "xAI OAuth is ready; activated {} via {}",
7503 codewhale_config::quote_os_path(&activation.auth_path),
7504 codewhale_config::quote_os_path(&activation.config_path)
7505 );
7506 Ok(())
7507}
7508
7509fn resolve_session_id(session_id: Option<String>, last: bool, workspace: &Path) -> Result<String> {
7510 if last {
7511 return latest_session_id_for_workspace(workspace)?.ok_or_else(|| {
7512 anyhow!(
7513 "No saved sessions found for workspace {}. Use `codewhale sessions` to list all sessions, or `codewhale resume <SESSION_ID>` to resume one explicitly.",
7514 workspace.display()
7515 )
7516 });
7517 }
7518 if let Some(id) = session_id {
7519 return Ok(id);
7520 }
7521 pick_session_id()
7522}
7523
7524fn latest_session_id_for_workspace(workspace: &Path) -> std::io::Result<Option<String>> {
7525 let manager = SessionManager::default_location()?;
7526 Ok(manager
7527 .get_latest_session_for_workspace(workspace)?
7528 .map(|session| session.id))
7529}
7530
7531fn fork_session(
7532 config: &Config,
7533 session_id: Option<String>,
7534 last: bool,
7535 workspace: &Path,
7536) -> Result<String> {
7537 let manager = SessionManager::default_location()?;
7538 let saved = if last {
7539 let Some(meta) = manager.get_latest_session_for_workspace(workspace)? else {
7540 bail!(
7541 "No saved sessions found for workspace {}.",
7542 workspace.display()
7543 );
7544 };
7545 manager.load_session(&meta.id)?
7546 } else {
7547 let id = resolve_session_id(session_id, false, workspace)?;
7548 manager.load_session_by_prefix(&id)?
7549 };
7550 let saved_provider_identity = saved
7551 .metadata
7552 .model_provider_id
7553 .as_deref()
7554 .filter(|identity| !identity.trim().is_empty())
7555 .unwrap_or(&saved.metadata.model_provider);
7556 let provider_identity = config
7557 .resolve_persisted_provider_identity(
7558 Some(&saved.metadata.model_provider),
7559 saved.metadata.model_provider_id.as_deref(),
7560 )
7561 .map_err(anyhow::Error::msg)
7562 .with_context(|| {
7563 format!(
7564 "saved session provider '{}' is unavailable; fork will not fall back",
7565 saved_provider_identity
7566 )
7567 })?;
7568
7569 let system_prompt = saved
7570 .system_prompt
7571 .as_ref()
7572 .map(|text| SystemPrompt::Text(text.clone()));
7573 let mut forked = create_saved_session(
7574 &saved.messages,
7575 &saved.metadata.model,
7576 &saved.metadata.workspace,
7577 saved.metadata.total_tokens,
7578 system_prompt.as_ref(),
7579 );
7580 forked.metadata.set_model_provider_route(
7581 provider_identity.provider.as_str(),
7582 provider_identity.persisted_id(),
7583 );
7584 forked.metadata.copy_cost_from(&saved.metadata);
7585 forked.metadata.mark_forked_from(&saved.metadata);
7586 manager.save_session(&forked)?;
7587
7588 let source_title = saved.metadata.title.trim();
7589 let source_label = if source_title.is_empty() {
7590 "session".to_string()
7591 } else {
7592 format!("\"{source_title}\"")
7593 };
7594 println!(
7595 "Forked {source_label} ({source_id}) → new session {new_id}",
7596 source_id = truncate_id(&saved.metadata.id),
7597 new_id = truncate_id(&forked.metadata.id),
7598 );
7599
7600 Ok(forked.metadata.id)
7601}
7602
7603fn pick_session_id() -> Result<String> {
7604 let manager = SessionManager::default_location()?;
7605 let sessions = manager.list_sessions()?;
7606 if sessions.is_empty() {
7607 bail!("No saved sessions found.");
7608 }
7609
7610 println!("Select a session to resume:");
7611 for (idx, session) in sessions.iter().enumerate() {
7612 println!(" {:>2}. {} ({})", idx + 1, session.title, session.id);
7613 }
7614 print!("Enter a number (or press Enter to cancel): ");
7615 io::stdout().flush()?;
7616
7617 let mut input = String::new();
7618 io::stdin().read_line(&mut input)?;
7619 let input = input.trim();
7620 if input.is_empty() {
7621 bail!("No session selected.");
7622 }
7623 let idx: usize = input
7624 .parse()
7625 .map_err(|_| anyhow::anyhow!("Invalid input"))?;
7626 let session = sessions
7627 .get(idx.saturating_sub(1))
7628 .ok_or_else(|| anyhow::anyhow!("Selection out of range"))?;
7629 Ok(session.id.clone())
7630}
7631
7632async fn run_review(config: &Config, args: ReviewArgs) -> Result<()> {
7633 use crate::client::DeepSeekClient;
7634
7635 let diff = collect_diff(&args)?;
7636 if diff.trim().is_empty() {
7637 bail!("No diff to review.");
7638 }
7639 validate_review_receipt_args(&args)?;
7640 if args.check_receipt {
7641 return run_review_receipt_check(&diff, &args);
7642 }
7643
7644 let model = resolve_review_model(config, args.model.as_deref());
7645 let route = resolve_cli_exec_route(config, &model, &diff, args.model.is_none()).await?;
7646 let execution_config = config_for_cli_route(config, &route);
7647 let route_provider = execution_config.provider_identity_for(route.provider);
7648 let model = route.model.clone();
7649 let user_prompt =
7650 format!("Review the following diff and provide feedback:\n\n{diff}\n\nEnd of diff.");
7651 let reasoning_effort = route.reasoning_effort.and_then(|effort| {
7652 cli_reasoning_effort_value_for_prompt(&execution_config, &model, effort, &user_prompt)
7653 });
7654
7655 let system = SystemPrompt::Text(
7656 "You are a senior code reviewer. Focus on bugs, risks, behavioral regressions, and missing tests. \
7657Provide findings ordered by severity with file references, then open questions, then a brief summary."
7658 .to_string(),
7659 );
7660 let client = DeepSeekClient::new(&execution_config)?;
7661 let request_route = client.effective_route_envelope(&model, chrono::Utc::now());
7662 let request = MessageRequest {
7663 model: model.clone(),
7664 messages: vec![Message {
7665 role: "user".to_string(),
7666 content: vec![ContentBlock::Text {
7667 text: user_prompt,
7668 cache_control: None,
7669 }],
7670 }],
7671 max_tokens: crate::route_budget::effective_max_output_tokens_for_route(
7672 request_route.provider,
7673 &request_route.model,
7674 None,
7675 ),
7676 system: Some(system),
7677 tools: None,
7678 tool_choice: None,
7679 metadata: None,
7680 thinking: None,
7681 reasoning_effort,
7682 stream: Some(false),
7683 temperature: None,
7684 top_p: None,
7685 };
7686
7687 let response = client.create_message(request).await?;
7688 let review_stop_reason = response.stop_reason.clone();
7689 let review_incomplete = crate::models::is_incomplete_stop_reason(review_stop_reason.as_deref());
7690 let mut output = String::new();
7691 for block in response.content {
7692 if let ContentBlock::Text { text, .. } = block {
7693 output.push_str(&text);
7694 }
7695 }
7696 let receipt = if args.write_receipt && !review_incomplete {
7699 let parsed_output = crate::tools::review::ReviewOutput::from_str(&output);
7700 let receipt = crate::tools::review::build_review_receipt(
7701 review_target_label(&args),
7702 &diff,
7703 &route_provider,
7704 &model,
7705 &parsed_output,
7706 &output,
7707 Vec::new(),
7708 );
7709 let path =
7710 crate::tools::review::write_review_receipt(&receipt, args.receipt_path.as_deref())?;
7711 Some((path, receipt))
7712 } else {
7713 None
7714 };
7715 let review_error = review_incomplete.then(|| {
7716 format!(
7717 "Model response incomplete: provider stop reason `{}`; the partial review was not accepted.",
7718 crate::models::stop_reason_detail(review_stop_reason.as_deref())
7719 )
7720 });
7721 if args.json {
7722 println!(
7723 "{}",
7724 serde_json::to_string_pretty(&serde_json::json!({
7725 "mode": "review",
7726 "provider": route_provider,
7727 "model": model,
7728 "success": !review_incomplete,
7729 "content": output,
7730 "stop_reason": review_stop_reason,
7731 "error": review_error,
7732 "receipt_path": receipt
7733 .as_ref()
7734 .map(|(path, _)| path.display().to_string()),
7735 "receipt": receipt.as_ref().map(|(_, receipt)| receipt),
7736 }))?
7737 );
7738 if let Some(error) = review_error {
7739 anyhow::bail!(error);
7740 }
7741 } else {
7742 println!("{output}");
7743 if let Some((path, _)) = receipt {
7744 eprintln!("Review receipt written: {}", path.display());
7745 }
7746 if let Some(error) = review_error {
7747 anyhow::bail!(error);
7748 }
7749 }
7750 Ok(())
7751}
7752
7753fn resolve_review_model(config: &Config, explicit_model: Option<&str>) -> String {
7754 explicit_model
7755 .map(str::trim)
7756 .filter(|model| !model.is_empty())
7757 .map(str::to_string)
7758 .unwrap_or_else(|| config.default_model())
7759}
7760
7761fn validate_review_receipt_args(args: &ReviewArgs) -> Result<()> {
7762 if args.receipt_path.is_some() && !args.write_receipt && !args.check_receipt {
7763 bail!("--receipt-path requires --write-receipt or --check-receipt");
7764 }
7765 if args.write_receipt && args.check_receipt {
7766 bail!("--write-receipt and --check-receipt are mutually exclusive");
7767 }
7768 Ok(())
7769}
7770
7771fn run_review_receipt_check(diff: &str, args: &ReviewArgs) -> Result<()> {
7772 let (path, receipt) = if let Some(path) = args.receipt_path.as_ref() {
7773 (
7774 path.clone(),
7775 crate::tools::review::read_review_receipt(path)
7776 .with_context(|| format!("failed to read review receipt {}", path.display()))?,
7777 )
7778 } else {
7779 crate::tools::review::latest_review_receipt_for_diff(diff)?.ok_or_else(|| {
7780 anyhow!(
7781 "No review receipt found for the current diff. Run `codewhale review --write-receipt` first, or pass --receipt-path."
7782 )
7783 })?
7784 };
7785 let validation =
7786 crate::tools::review::validate_review_receipt_for_diff(diff, &receipt, Some(path.clone()));
7787
7788 if args.json {
7789 println!(
7790 "{}",
7791 serde_json::to_string_pretty(&serde_json::json!({
7792 "mode": "review_receipt_check",
7793 "success": validation.passed,
7794 "validation": review_receipt_validation_public_json(&validation),
7795 }))?
7796 );
7797 } else if validation.passed {
7798 println!("Review receipt valid: {}", path.display());
7799 }
7800
7801 if !validation.passed {
7802 bail!("Review receipt check failed: {}", validation.reason);
7803 }
7804 Ok(())
7805}
7806
7807fn review_receipt_validation_public_json(
7808 validation: &crate::tools::review::ReviewReceiptValidation,
7809) -> serde_json::Value {
7810 let unresolved_risk = validation.unresolved_risk.as_ref();
7811 serde_json::json!({
7812 "passed": validation.passed,
7813 "status": review_receipt_validation_status(validation),
7814 "diff_fingerprint": validation.diff_fingerprint.as_str(),
7815 "receipt_fingerprint": validation.receipt_fingerprint.as_deref(),
7816 "unresolved": unresolved_risk.is_some_and(|risk| risk.unresolved),
7817 "risk_level": unresolved_risk.map(|risk| risk.level.as_str()),
7818 })
7819}
7820
7821fn review_receipt_validation_status(
7822 validation: &crate::tools::review::ReviewReceiptValidation,
7823) -> &'static str {
7824 if validation.passed {
7825 "valid"
7826 } else if validation
7827 .receipt_fingerprint
7828 .as_deref()
7829 .is_some_and(|fingerprint| fingerprint != validation.diff_fingerprint.as_str())
7830 {
7831 "diff_mismatch"
7832 } else if validation
7833 .unresolved_risk
7834 .as_ref()
7835 .is_some_and(|risk| risk.unresolved)
7836 {
7837 "unresolved_risk"
7838 } else if validation
7839 .reason
7840 .starts_with("unsupported review receipt schema version")
7841 {
7842 "unsupported_schema"
7843 } else if validation.reason.starts_with("review receipt check ") {
7844 "check_failed"
7845 } else {
7846 "invalid"
7847 }
7848}
7849
7850async fn run_pr(
7854 cli: &Cli,
7855 config: &Config,
7856 number: u32,
7857 repo: Option<&str>,
7858 checkout: bool,
7859 pending_telemetry_notice: Option<crate::telemetry_notice::PendingTelemetryNotice>,
7860 plugin_registry: Arc<crate::plugins::PluginRegistry>,
7861) -> Result<()> {
7862 if !is_command_available("gh") {
7863 bail!(
7864 "`gh` CLI not found on PATH. Install GitHub CLI \
7865 (https://cli.github.com) and authenticate (`gh auth login`) \
7866 so `codewhale pr <N>` can fetch PR metadata and the diff."
7867 );
7868 }
7869
7870 let view = run_gh_pr_view(number, repo)?;
7871 let diff = run_gh_pr_diff(number, repo)?;
7872
7873 if checkout {
7874 match run_gh_pr_checkout(number, repo) {
7875 Ok(()) => eprintln!("Checked out PR #{number} into the current workspace."),
7876 Err(err) => eprintln!(
7877 "warning: gh pr checkout #{number} failed ({err}). Continuing without checkout."
7878 ),
7879 }
7880 }
7881
7882 let prompt = format_pr_prompt(number, &view, &diff);
7883 let resume_session_id = if cli.continue_session {
7884 let workspace = resolve_workspace(cli);
7885 latest_session_id_for_workspace(&workspace).ok().flatten()
7886 } else {
7887 cli.resume.clone()
7888 };
7889 run_interactive(
7890 cli,
7891 config,
7892 resume_session_id,
7893 Some(tui::InitialInput::Prefill(prompt)),
7894 pending_telemetry_notice,
7895 plugin_registry,
7896 )
7897 .await
7898}
7899
7900fn is_command_available(name: &str) -> bool {
7914 let Some(path) = std::env::var_os("PATH") else {
7915 return false;
7916 };
7917 for dir in std::env::split_paths(&path) {
7918 let candidate = dir.join(name);
7919 if candidate.is_file() {
7920 return true;
7921 }
7922 #[cfg(windows)]
7923 {
7924 if candidate.extension().is_none() && candidate.with_extension("exe").is_file() {
7928 return true;
7929 }
7930 }
7931 }
7932 false
7933}
7934
7935#[derive(Debug, Clone, Default)]
7936struct GhPullRequest {
7937 title: String,
7938 body: String,
7939 base: String,
7940 head: String,
7941 url: String,
7942}
7943
7944fn run_gh_pr_view(number: u32, repo: Option<&str>) -> Result<GhPullRequest> {
7945 let mut cmd = crate::dependencies::Gh::command()
7946 .ok_or_else(|| anyhow::anyhow!("gh not found on PATH"))?;
7947 cmd.arg("pr").arg("view").arg(number.to_string());
7948 if let Some(r) = repo {
7949 cmd.arg("--repo").arg(r);
7950 }
7951 cmd.arg("--json")
7952 .arg("title,body,baseRefName,headRefName,url");
7953 let output = cmd
7954 .output()
7955 .map_err(|e| anyhow::anyhow!("Failed to run `gh pr view`: {e}"))?;
7956 if !output.status.success() {
7957 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
7958 bail!("gh pr view #{number} failed: {stderr}");
7959 }
7960 let raw = String::from_utf8_lossy(&output.stdout).to_string();
7961 let value: serde_json::Value = serde_json::from_str(&raw)
7962 .map_err(|e| anyhow::anyhow!("gh pr view returned non-JSON output: {e}"))?;
7963 let pick = |key: &str| {
7964 value
7965 .get(key)
7966 .and_then(serde_json::Value::as_str)
7967 .unwrap_or_default()
7968 .to_string()
7969 };
7970 Ok(GhPullRequest {
7971 title: pick("title"),
7972 body: pick("body"),
7973 base: pick("baseRefName"),
7974 head: pick("headRefName"),
7975 url: pick("url"),
7976 })
7977}
7978
7979fn run_gh_pr_diff(number: u32, repo: Option<&str>) -> Result<String> {
7980 let mut cmd = crate::dependencies::Gh::command()
7981 .ok_or_else(|| anyhow::anyhow!("gh not found on PATH"))?;
7982 cmd.arg("pr").arg("diff").arg(number.to_string());
7983 if let Some(r) = repo {
7984 cmd.arg("--repo").arg(r);
7985 }
7986 let output = cmd
7987 .output()
7988 .map_err(|e| anyhow::anyhow!("Failed to run `gh pr diff`: {e}"))?;
7989 if !output.status.success() {
7990 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
7991 bail!("gh pr diff #{number} failed: {stderr}");
7992 }
7993 Ok(String::from_utf8_lossy(&output.stdout).to_string())
7994}
7995
7996fn run_gh_pr_checkout(number: u32, repo: Option<&str>) -> Result<()> {
7997 let mut cmd = crate::dependencies::Gh::command()
7998 .ok_or_else(|| anyhow::anyhow!("gh not found on PATH"))?;
7999 cmd.arg("pr").arg("checkout").arg(number.to_string());
8000 if let Some(r) = repo {
8001 cmd.arg("--repo").arg(r);
8002 }
8003 let output = cmd
8004 .output()
8005 .map_err(|e| anyhow::anyhow!("Failed to run `gh pr checkout`: {e}"))?;
8006 if !output.status.success() {
8007 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
8008 bail!("gh pr checkout #{number} failed: {stderr}");
8009 }
8010 Ok(())
8011}
8012
8013fn format_pr_prompt(number: u32, view: &GhPullRequest, diff: &str) -> String {
8018 const MAX_DIFF_BYTES: usize = 200 * 1024;
8019 let diff_section = if diff.len() > MAX_DIFF_BYTES {
8020 let cut = (0..=MAX_DIFF_BYTES)
8021 .rev()
8022 .find(|&i| diff.is_char_boundary(i))
8023 .unwrap_or(0);
8024 format!(
8025 "{}\n\n[…diff truncated at {} KiB; ask me to fetch more if needed]\n",
8026 &diff[..cut],
8027 MAX_DIFF_BYTES / 1024
8028 )
8029 } else {
8030 diff.to_string()
8031 };
8032 let body = if view.body.trim().is_empty() {
8033 "(no description)".to_string()
8034 } else {
8035 view.body.trim().to_string()
8036 };
8037 let title = if view.title.trim().is_empty() {
8038 format!("(PR #{number})")
8039 } else {
8040 view.title.trim().to_string()
8041 };
8042 let branches = match (view.base.is_empty(), view.head.is_empty()) {
8043 (false, false) => format!("{} ← {}", view.base, view.head),
8044 (false, true) => view.base.clone(),
8045 (true, false) => view.head.clone(),
8046 _ => "(unknown)".to_string(),
8047 };
8048 format!(
8049 "Review PR #{number} — {title}\n\
8050 \n\
8051 URL: {url}\n\
8052 Branches: {branches}\n\
8053 \n\
8054 ## Description\n\
8055 \n\
8056 {body}\n\
8057 \n\
8058 ## Diff\n\
8059 \n\
8060 ```diff\n\
8061 {diff_section}\n\
8062 ```\n",
8063 url = if view.url.is_empty() {
8064 "(unavailable)"
8065 } else {
8066 view.url.as_str()
8067 },
8068 )
8069}
8070
8071fn collect_diff(args: &ReviewArgs) -> Result<String> {
8072 let mut cmd = crate::dependencies::Git::command()
8073 .ok_or_else(|| anyhow::anyhow!("git not found on PATH"))?;
8074 cmd.arg("diff");
8075 if args.staged {
8076 cmd.arg("--cached");
8077 }
8078 if let Some(base) = &args.base {
8079 cmd.arg(format!("{base}...HEAD"));
8080 }
8081 if let Some(path) = &args.path {
8082 cmd.arg("--").arg(path);
8083 }
8084
8085 let output = cmd
8086 .output()
8087 .map_err(|e| anyhow::anyhow!("Failed to run git diff. Is git installed? ({e})"))?;
8088 if !output.status.success() {
8089 let stderr = String::from_utf8_lossy(&output.stderr);
8090 bail!("git diff failed: {}", stderr.trim());
8091 }
8092 let mut diff = String::from_utf8_lossy(&output.stdout).to_string();
8093 if diff.len() > args.max_chars {
8094 diff = crate::utils::truncate_with_ellipsis(&diff, args.max_chars, "\n...[truncated]\n");
8095 }
8096 Ok(diff)
8097}
8098
8099fn review_target_label(args: &ReviewArgs) -> String {
8100 let mut label = if args.staged {
8101 "staged".to_string()
8102 } else if let Some(base) = args
8103 .base
8104 .as_deref()
8105 .map(str::trim)
8106 .filter(|base| !base.is_empty())
8107 {
8108 format!("base:{base}")
8109 } else {
8110 "working-tree".to_string()
8111 };
8112 if let Some(path) = &args.path {
8113 label.push(' ');
8114 label.push_str(path.to_string_lossy().as_ref());
8115 }
8116 label
8117}
8118
8119fn run_apply(args: ApplyArgs) -> Result<()> {
8120 let patch = if let Some(path) = args.patch_file {
8121 std::fs::read_to_string(&path)
8122 .map_err(|e| anyhow::anyhow!("Failed to read patch {}: {}", path.display(), e))?
8123 } else {
8124 read_patch_from_stdin()?
8125 };
8126 if patch.trim().is_empty() {
8127 bail!("Patch is empty.");
8128 }
8129
8130 let mut tmp = NamedTempFile::new()?;
8131 tmp.write_all(patch.as_bytes())?;
8132 let tmp_path = tmp.path().to_path_buf();
8133
8134 let output = crate::dependencies::Git::command()
8135 .ok_or_else(|| anyhow::anyhow!("git not found on PATH"))?
8136 .arg("apply")
8137 .arg("--whitespace=nowarn")
8138 .arg(&tmp_path)
8139 .output()
8140 .map_err(|e| anyhow::anyhow!("Failed to run git apply: {e}"))?;
8141
8142 if !output.status.success() {
8143 let stderr = String::from_utf8_lossy(&output.stderr);
8144 bail!("git apply failed: {}", stderr.trim());
8145 }
8146 println!("Applied patch successfully.");
8147 Ok(())
8148}
8149
8150fn read_patch_from_stdin() -> Result<String> {
8151 let mut stdin = io::stdin();
8152 if stdin.is_terminal() {
8153 bail!("No patch file provided and stdin is empty.");
8154 }
8155 let mut buffer = String::new();
8156 stdin.read_to_string(&mut buffer)?;
8157 Ok(buffer)
8158}
8159
8160async fn run_mcp_command(
8161 config: &Config,
8162 workspace: &Path,
8163 command: McpCommand,
8164 plugins: &crate::plugins::PluginRegistry,
8165) -> Result<()> {
8166 let config_path = config.mcp_config_path();
8167 match command {
8168 McpCommand::Init { force } => {
8169 let status = init_mcp_config(&config_path, force)?;
8170 match status {
8171 WriteStatus::Created => {
8172 println!("Created MCP config at {}", config_path.display());
8173 }
8174 WriteStatus::Overwritten => {
8175 println!("Overwrote MCP config at {}", config_path.display());
8176 }
8177 WriteStatus::SkippedExists => {
8178 println!(
8179 "MCP config already exists at {} (use --force to overwrite)",
8180 config_path.display()
8181 );
8182 }
8183 }
8184 println!("Edit the file, then run `codewhale mcp list` or `codewhale mcp tools`.");
8185 Ok(())
8186 }
8187 McpCommand::List => {
8188 let cfg = crate::mcp::load_config_with_workspace_and_plugins(
8189 &config_path,
8190 workspace,
8191 plugins,
8192 )?;
8193 if cfg.servers.is_empty() {
8194 println!(
8195 "No MCP servers configured in {} or {}",
8196 config_path.display(),
8197 crate::mcp::workspace_mcp_config_path(workspace).display()
8198 );
8199 return Ok(());
8200 }
8201 println!("MCP servers ({}):", cfg.servers.len());
8202 for (name, server) in cfg.servers {
8203 let status = if server.enabled && !server.disabled {
8204 "enabled"
8205 } else {
8206 "disabled"
8207 };
8208 let auth_status = crate::mcp::oauth::auth_status_for_server(&name, &server).await;
8209 let auth = if auth_status == crate::mcp::oauth::McpAuthStatus::Unsupported {
8210 String::new()
8211 } else {
8212 format!(
8213 " auth={}",
8214 auth_status
8215 .to_string()
8216 .to_ascii_lowercase()
8217 .replace(' ', "-")
8218 )
8219 };
8220 let args = if server.args.is_empty() {
8221 "".to_string()
8222 } else {
8223 format!(" {}", server.args.join(" "))
8224 };
8225 let cmd_str = if let Some(cmd) = server.command {
8226 format!("{cmd}{args}")
8227 } else if let Some(url) = server.url {
8228 url
8229 } else {
8230 "unknown".to_string()
8231 };
8232 let required = if server.required { " required" } else { "" };
8233 println!(" - {name} [{status}{required}{auth}] {cmd_str}");
8234 }
8235 Ok(())
8236 }
8237 McpCommand::Connect { server } => {
8238 let mut pool = McpPool::from_config_path_with_workspace_and_plugins(
8239 &config_path,
8240 workspace,
8241 std::sync::Arc::new(plugins.clone()),
8242 )?;
8243 if let Some(name) = server {
8244 if let Err(err) = pool.get_or_connect(&name).await {
8245 if crate::mcp::oauth::error_looks_auth_required(&err) {
8246 let hint = crate::mcp::oauth::auth_required_login_hint(&name);
8247 return Err(err).context(hint);
8248 }
8249 return Err(err);
8250 }
8251 println!("Connected to MCP server: {name}");
8252 } else {
8253 let errors = pool.connect_all().await;
8254 if errors.is_empty() {
8255 println!("Connected to all configured MCP servers.");
8256 } else {
8257 for (name, err) in errors {
8258 eprintln!("Failed to connect {name}: {err:#}");
8259 if crate::mcp::oauth::error_looks_auth_required(&err) {
8260 eprintln!(" {}", crate::mcp::oauth::auth_required_login_hint(&name));
8261 }
8262 }
8263 }
8264 }
8265 Ok(())
8266 }
8267 McpCommand::Tools { server } => {
8268 let mut pool = McpPool::from_config_path_with_workspace_and_plugins(
8269 &config_path,
8270 workspace,
8271 std::sync::Arc::new(plugins.clone()),
8272 )?;
8273 if let Some(name) = server {
8274 let conn = match pool.get_or_connect(&name).await {
8275 Ok(conn) => conn,
8276 Err(err) => {
8277 if crate::mcp::oauth::error_looks_auth_required(&err) {
8278 let hint = crate::mcp::oauth::auth_required_login_hint(&name);
8279 return Err(err).context(hint);
8280 }
8281 return Err(err);
8282 }
8283 };
8284 if conn.tools().is_empty() {
8285 println!("No tools found for MCP server: {name}");
8286 } else {
8287 println!("Tools for {name}:");
8288 for tool in conn.tools() {
8289 println!(
8290 " - {}{}",
8291 tool.name,
8292 crate::mcp::format_mcp_tool_description(tool.description.as_deref())
8293 );
8294 }
8295 }
8296 } else {
8297 let errors = pool.connect_all().await;
8298 for (name, err) in errors {
8299 eprintln!("Failed to connect {name}: {err:#}");
8300 if crate::mcp::oauth::error_looks_auth_required(&err) {
8301 eprintln!(" {}", crate::mcp::oauth::auth_required_login_hint(&name));
8302 }
8303 }
8304 let tools = pool.all_tools();
8305 if tools.is_empty() {
8306 println!("No MCP tools discovered.");
8307 } else {
8308 println!("MCP tools:");
8309 for (name, tool) in tools {
8310 println!(
8311 " - {}{}",
8312 name,
8313 crate::mcp::format_mcp_tool_description(tool.description.as_deref())
8314 );
8315 }
8316 }
8317 }
8318 Ok(())
8319 }
8320 McpCommand::Add {
8321 name,
8322 command,
8323 url,
8324 transport,
8325 bearer_token_env_var,
8326 oauth_client_id,
8327 oauth_resource,
8328 scopes,
8329 args,
8330 } => {
8331 if command.is_none() && url.is_none() {
8332 bail!("Provide either --command or --url for `mcp add`.");
8333 }
8334 if let Some(transport) = transport.as_deref()
8335 && !transport.trim().eq_ignore_ascii_case("sse")
8336 {
8337 bail!("Unsupported MCP transport '{transport}'. Supported values: sse");
8338 }
8339 let added_server = McpServerConfig {
8340 command,
8341 args,
8342 env: std::collections::HashMap::new(),
8343 cwd: None,
8344 url,
8345 transport,
8346 connect_timeout: None,
8347 execute_timeout: None,
8348 read_timeout: None,
8349 disabled: false,
8350 enabled: true,
8351 required: false,
8352 enabled_tools: Vec::new(),
8353 disabled_tools: Vec::new(),
8354 headers: std::collections::HashMap::new(),
8355 env_headers: std::collections::HashMap::new(),
8356 bearer_token_env_var,
8357 scopes,
8358 oauth: oauth_client_id.map(|client_id| McpServerOAuthConfig {
8359 client_id: Some(client_id),
8360 }),
8361 oauth_resource,
8362 reviewed_plugin: None,
8363 };
8364 let can_suggest_oauth = added_server.url.is_some()
8365 && added_server.bearer_token_env_var.is_none()
8366 && added_server
8367 .headers
8368 .keys()
8369 .all(|key| !key.trim().eq_ignore_ascii_case("authorization"))
8370 && added_server
8371 .env_headers
8372 .keys()
8373 .all(|key| !key.trim().eq_ignore_ascii_case("authorization"));
8374 let mut cfg = load_mcp_config(&config_path)?;
8375 cfg.servers.insert(name.clone(), added_server.clone());
8376 save_mcp_config(&config_path, &cfg)?;
8377 println!("Added MCP server '{name}' in {}", config_path.display());
8378 if can_suggest_oauth
8379 && crate::mcp::oauth::oauth_login_support(&added_server)
8380 .await
8381 .is_ok_and(|support| support.is_some())
8382 {
8383 println!(
8384 "OAuth is available for '{name}'. Run `codewhale mcp login {name}` to authenticate."
8385 );
8386 }
8387 Ok(())
8388 }
8389 McpCommand::Login { name, scopes } => {
8390 let cfg = crate::mcp::load_config_with_workspace_and_plugins(
8391 &config_path,
8392 workspace,
8393 plugins,
8394 )?;
8395 let server = cfg
8396 .servers
8397 .get(&name)
8398 .ok_or_else(|| anyhow!("MCP server '{name}' not found"))?;
8399 let explicit_scopes = (!scopes.is_empty()).then_some(scopes);
8400 crate::mcp::oauth::perform_oauth_login_for_server(
8401 &name,
8402 server,
8403 explicit_scopes,
8404 config.mcp_oauth_callback_port,
8405 config.mcp_oauth_callback_url.as_deref(),
8406 )
8407 .await?;
8408 println!("Stored OAuth credentials for MCP server '{name}'.");
8409 Ok(())
8410 }
8411 McpCommand::Logout { name } => {
8412 let cfg = crate::mcp::load_config_with_workspace_and_plugins(
8413 &config_path,
8414 workspace,
8415 plugins,
8416 )?;
8417 let server = cfg
8418 .servers
8419 .get(&name)
8420 .ok_or_else(|| anyhow!("MCP server '{name}' not found"))?;
8421 if crate::mcp::oauth::delete_oauth_tokens_for_server(&name, server)? {
8422 println!("Deleted stored OAuth credentials for MCP server '{name}'.");
8423 } else {
8424 println!("No stored OAuth credentials found for MCP server '{name}'.");
8425 }
8426 Ok(())
8427 }
8428 McpCommand::Remove { name } => {
8429 let mut cfg = load_mcp_config(&config_path)?;
8430 if cfg.servers.remove(&name).is_none() {
8431 bail!("MCP server '{name}' not found");
8432 }
8433 save_mcp_config(&config_path, &cfg)?;
8434 println!("Removed MCP server '{name}'");
8435 Ok(())
8436 }
8437 McpCommand::Enable { name } => {
8438 let mut cfg = load_mcp_config(&config_path)?;
8439 let server = cfg
8440 .servers
8441 .get_mut(&name)
8442 .ok_or_else(|| anyhow!("MCP server '{name}' not found"))?;
8443 server.enabled = true;
8444 server.disabled = false;
8445 save_mcp_config(&config_path, &cfg)?;
8446 println!("Enabled MCP server '{name}'");
8447 Ok(())
8448 }
8449 McpCommand::Disable { name } => {
8450 let mut cfg = load_mcp_config(&config_path)?;
8451 let server = cfg
8452 .servers
8453 .get_mut(&name)
8454 .ok_or_else(|| anyhow!("MCP server '{name}' not found"))?;
8455 server.enabled = false;
8456 server.disabled = true;
8457 save_mcp_config(&config_path, &cfg)?;
8458 println!("Disabled MCP server '{name}'");
8459 Ok(())
8460 }
8461 McpCommand::Validate => {
8462 let mut pool = McpPool::from_config_path_with_workspace_and_plugins(
8463 &config_path,
8464 workspace,
8465 std::sync::Arc::new(plugins.clone()),
8466 )?;
8467 let errors = pool.connect_all().await;
8468 if errors.is_empty() {
8469 println!("MCP config is valid. All enabled servers connected.");
8470 return Ok(());
8471 }
8472 eprintln!("MCP validation failed:");
8473 for (name, err) in errors {
8474 eprintln!(" - {name}: {err:#}");
8475 }
8476 bail!("one or more MCP servers failed validation");
8477 }
8478 McpCommand::AddSelf { name, workspace } => {
8479 let exe_path = std::env::current_exe()
8480 .map_err(|e| anyhow!("Cannot resolve current binary path: {e}"))?;
8481 let exe_str = exe_path.to_string_lossy().to_string();
8482
8483 let mut args = vec!["serve".to_string(), "--mcp".to_string()];
8484 if let Some(ref ws) = workspace {
8485 args.push("--workspace".to_string());
8486 args.push(ws.clone());
8487 }
8488
8489 let mut cfg = load_mcp_config(&config_path)?;
8490 if cfg.servers.contains_key(&name) {
8491 bail!(
8492 "MCP server '{name}' already exists in {}. Use `codewhale mcp remove {name}` first, or choose a different --name.",
8493 config_path.display()
8494 );
8495 }
8496 cfg.servers.insert(
8497 name.clone(),
8498 McpServerConfig {
8499 command: Some(exe_str.clone()),
8500 args,
8501 env: std::collections::HashMap::new(),
8502 cwd: None,
8503 url: None,
8504 transport: None,
8505 connect_timeout: None,
8506 execute_timeout: None,
8507 read_timeout: None,
8508 disabled: false,
8509 enabled: true,
8510 required: false,
8511 enabled_tools: Vec::new(),
8512 disabled_tools: Vec::new(),
8513 headers: std::collections::HashMap::new(),
8514 env_headers: std::collections::HashMap::new(),
8515 bearer_token_env_var: None,
8516 scopes: Vec::new(),
8517 oauth: None,
8518 oauth_resource: None,
8519 reviewed_plugin: None,
8520 },
8521 );
8522 save_mcp_config(&config_path, &cfg)?;
8523 println!(
8524 "Registered Codewhale as MCP server '{name}' in {}",
8525 config_path.display()
8526 );
8527 println!(" command: {exe_str}");
8528 println!(
8529 " args: serve --mcp{}",
8530 workspace.map_or(String::new(), |ws| format!(" --workspace {ws}"))
8531 );
8532 println!();
8533 println!("Tip: Use `codewhale mcp validate` to test the connection.");
8534 println!(" Use `codewhale serve --http` for the HTTP/SSE runtime API instead.");
8535 Ok(())
8536 }
8537 }
8538}
8539
8540fn load_mcp_config(path: &Path) -> Result<McpConfig> {
8541 if !path.exists() {
8542 return Ok(McpConfig::default());
8543 }
8544 let contents = std::fs::read_to_string(path)
8545 .map_err(|e| anyhow::anyhow!("Failed to read MCP config {}: {}", path.display(), e))?;
8546 let cfg: McpConfig = serde_json::from_str(&contents).map_err(|_| {
8547 anyhow::anyhow!(
8548 "Failed to parse MCP config {}; file contents were omitted",
8549 codewhale_config::quote_os_path(path)
8550 )
8551 })?;
8552 Ok(cfg)
8553}
8554
8555#[derive(Debug)]
8557enum McpServerDoctorStatus {
8558 Ok(String),
8559 Warning(String),
8560 Error(String),
8561}
8562
8563impl McpServerDoctorStatus {
8564 fn legacy_status(&self) -> &'static str {
8565 match self {
8566 Self::Ok(_) => "ok",
8567 Self::Warning(_) => "warning",
8568 Self::Error(_) => "error",
8569 }
8570 }
8571
8572 fn configuration_status(&self) -> &'static str {
8573 match self {
8574 Self::Ok(_) => "valid",
8575 Self::Warning(_) => "warning",
8576 Self::Error(_) => "invalid",
8577 }
8578 }
8579
8580 fn detail(&self) -> &str {
8581 match self {
8582 Self::Ok(detail) | Self::Warning(detail) | Self::Error(detail) => detail,
8583 }
8584 }
8585}
8586
8587fn doctor_mcp_command_status(server: &McpServerConfig) -> McpCommandAvailability {
8589 if server.url.is_some() {
8590 return McpCommandAvailability::NotApplicable;
8591 }
8592 match server.command.as_deref() {
8593 Some("") => McpCommandAvailability::Missing,
8594 Some(_) | None => McpCommandAvailability::NotChecked,
8595 }
8596}
8597
8598fn doctor_mcp_server_json(name: &str, server: &McpServerConfig) -> serde_json::Value {
8599 use serde_json::json;
8600
8601 let status = doctor_check_mcp_server(server);
8602 json!({
8603 "name": name,
8604 "enabled": server.enabled && !server.disabled,
8605 "status": status.legacy_status(),
8608 "detail": status.detail(),
8609 "transport": if server.url.is_some() { "http" } else { "stdio" },
8610 "endpoint": server.url.as_deref().map(crate::doctor::structural_url_authority),
8611 "command_configured": server.command.is_some(),
8612 "args_count": server.args.len(),
8613 "env_count": server.env.len(),
8614 "headers_count": server.headers.len(),
8615 "env_headers_count": server.env_headers.len(),
8616 "check_scope": "configuration",
8617 "checks": {
8618 "configuration": {
8619 "status": status.configuration_status(),
8620 "detail": status.detail(),
8621 },
8622 "command": {
8623 "status": doctor_mcp_command_status(server).as_str(),
8624 },
8625 "process_reachable": {
8626 "status": "not_checked",
8627 },
8628 "protocol_initialized": {
8629 "status": "not_checked",
8630 },
8631 "backend_tool_health": {
8632 "status": "not_checked",
8633 },
8634 },
8635 })
8636}
8637
8638fn doctor_check_mcp_server(server: &McpServerConfig) -> McpServerDoctorStatus {
8640 if server.command.is_none() && server.url.is_none() {
8642 return McpServerDoctorStatus::Error("no command or url configured".to_string());
8643 }
8644
8645 if let Some(ref url) = server.url {
8647 let authority = crate::doctor::structural_url_authority(url);
8648 return if authority.starts_with("unparseable") {
8649 McpServerDoctorStatus::Warning(
8650 "HTTP/SSE server URL is invalid; configured value omitted".to_string(),
8651 )
8652 } else {
8653 McpServerDoctorStatus::Ok(format!("HTTP/SSE server at {authority}"))
8654 };
8655 }
8656
8657 let cmd = server.command.as_deref().unwrap_or("");
8659 if cmd.is_empty() {
8660 return McpServerDoctorStatus::Error("empty command".to_string());
8661 }
8662
8663 if server.cwd.is_none() {
8664 if is_relative_stdio_path_arg(cmd) {
8665 return McpServerDoctorStatus::Warning(
8666 "stdio server uses a relative command without cwd; command value omitted"
8667 .to_string(),
8668 );
8669 }
8670 if server
8671 .args
8672 .iter()
8673 .any(|arg| is_relative_stdio_path_arg(arg))
8674 {
8675 return McpServerDoctorStatus::Warning(
8676 "stdio server uses a relative path argument without cwd; argument values omitted"
8677 .to_string(),
8678 );
8679 }
8680 }
8681
8682 McpServerDoctorStatus::Ok(format!(
8683 "stdio server configured (command omitted; {} argument(s), {} environment binding(s))",
8684 server.args.len(),
8685 server.env.len()
8686 ))
8687}
8688
8689fn save_mcp_config(path: &Path, cfg: &McpConfig) -> Result<()> {
8690 if let Some(parent) = path.parent() {
8691 std::fs::create_dir_all(parent).with_context(|| {
8692 format!("Failed to create MCP config directory {}", parent.display())
8693 })?;
8694 }
8695 let rendered = serde_json::to_string_pretty(cfg)
8696 .map_err(|e| anyhow!("Failed to serialize MCP config: {e}"))?;
8697 crate::utils::write_atomic(path, rendered.as_bytes())
8698 .map_err(|e| anyhow!("Failed to write MCP config {}: {}", path.display(), e))?;
8699 Ok(())
8700}
8701
8702fn run_sandbox_command(args: SandboxArgs) -> Result<()> {
8703 use crate::sandbox::{CommandSpec, SandboxManager};
8704
8705 let SandboxCommand::Run {
8706 policy,
8707 network,
8708 writable_root,
8709 exclude_tmpdir,
8710 exclude_slash_tmp,
8711 cwd,
8712 timeout_ms,
8713 command,
8714 } = args.command;
8715
8716 let policy = parse_sandbox_policy(
8717 &policy,
8718 network,
8719 writable_root,
8720 exclude_tmpdir,
8721 exclude_slash_tmp,
8722 )?;
8723 let cwd = cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
8724 let timeout = Duration::from_millis(timeout_ms.clamp(1000, 600_000));
8725
8726 let (program, args) = command
8727 .split_first()
8728 .ok_or_else(|| anyhow::anyhow!("Command is required"))?;
8729 let spec =
8730 CommandSpec::program(program, args.to_vec(), cwd.clone(), timeout).with_policy(policy);
8731 let manager = SandboxManager::new();
8732 let exec_env = manager.prepare(&spec);
8733
8734 let mut cmd = Command::new(exec_env.program());
8735 cmd.args(exec_env.args())
8736 .current_dir(&exec_env.cwd)
8737 .stdout(Stdio::piped())
8738 .stderr(Stdio::piped());
8739 child_env::apply_to_command(&mut cmd, child_env::string_map_env(&exec_env.env));
8740
8741 let mut child = cmd
8742 .spawn()
8743 .map_err(|e| anyhow::anyhow!("Failed to run command: {e}"))?;
8744 let stdout_handle = child
8745 .stdout
8746 .take()
8747 .ok_or_else(|| anyhow::anyhow!("stdout unavailable"))?;
8748 let stderr_handle = child
8749 .stderr
8750 .take()
8751 .ok_or_else(|| anyhow::anyhow!("stderr unavailable"))?;
8752
8753 let timeout = exec_env.timeout;
8754 let stdout_thread = std::thread::spawn(move || {
8755 let mut reader = stdout_handle;
8756 let mut buf = Vec::new();
8757 let _ = reader.read_to_end(&mut buf);
8758 buf
8759 });
8760 let stderr_thread = std::thread::spawn(move || {
8761 let mut reader = stderr_handle;
8762 let mut buf = Vec::new();
8763 let _ = reader.read_to_end(&mut buf);
8764 buf
8765 });
8766
8767 if let Some(status) = child.wait_timeout(timeout)? {
8768 let stdout = stdout_thread.join().unwrap_or_default();
8769 let stderr = stderr_thread.join().unwrap_or_default();
8770 let stderr_str = String::from_utf8_lossy(&stderr);
8771 let exit_code = status.code().unwrap_or(-1);
8772 let sandbox_type = exec_env.sandbox_type;
8773 let sandbox_denied = SandboxManager::was_denied(sandbox_type, exit_code, &stderr_str);
8774
8775 if !stdout.is_empty() {
8776 print!("{}", String::from_utf8_lossy(&stdout));
8777 }
8778 if !stderr.is_empty() {
8779 eprint!("{stderr_str}");
8780 }
8781 if sandbox_denied {
8782 eprintln!(
8783 "{}",
8784 SandboxManager::denial_message(sandbox_type, &stderr_str)
8785 );
8786 }
8787
8788 if !status.success() {
8789 bail!("Command failed with exit code {exit_code}");
8790 }
8791 } else {
8792 let _ = child.kill();
8793 let _ = child.wait();
8794 bail!("Command timed out after {}ms", timeout.as_millis());
8795 }
8796 Ok(())
8797}
8798
8799fn parse_sandbox_policy(
8800 policy: &str,
8801 network: bool,
8802 writable_root: Vec<PathBuf>,
8803 exclude_tmpdir: bool,
8804 exclude_slash_tmp: bool,
8805) -> Result<crate::sandbox::SandboxPolicy> {
8806 use crate::sandbox::SandboxPolicy;
8807
8808 match policy {
8809 "danger-full-access" => Ok(SandboxPolicy::DangerFullAccess),
8810 "read-only" => Ok(SandboxPolicy::ReadOnly),
8811 "external-sandbox" => Ok(SandboxPolicy::ExternalSandbox {
8812 network_access: network,
8813 }),
8814 "workspace-write" => Ok(SandboxPolicy::WorkspaceWrite {
8815 writable_roots: writable_root,
8816 network_access: network,
8817 exclude_tmpdir,
8818 exclude_slash_tmp,
8819 }),
8820 other => bail!("Unknown sandbox policy: {other}"),
8821 }
8822}
8823
8824fn should_use_alt_screen(_cli: &Cli, _config: &Config) -> bool {
8825 true
8826}
8827
8828fn should_use_mouse_capture(cli: &Cli, config: &Config, use_alt_screen: bool) -> bool {
8829 let terminal_emulator = std::env::var("TERMINAL_EMULATOR").ok();
8830 let wt_session = std::env::var("WT_SESSION").ok().filter(|s| !s.is_empty());
8831 let conemu_pid = std::env::var("ConEmuPID").ok().filter(|s| !s.is_empty());
8832 should_use_mouse_capture_with(
8833 cli,
8834 config,
8835 use_alt_screen,
8836 terminal_emulator.as_deref(),
8837 wt_session.as_deref(),
8838 conemu_pid.as_deref(),
8839 )
8840}
8841
8842fn should_use_mouse_capture_with(
8843 cli: &Cli,
8844 config: &Config,
8845 use_alt_screen: bool,
8846 terminal_emulator: Option<&str>,
8847 wt_session: Option<&str>,
8848 conemu_pid: Option<&str>,
8849) -> bool {
8850 if !use_alt_screen || cli.no_mouse_capture {
8851 return false;
8852 }
8853 if cli.mouse_capture {
8854 return true;
8855 }
8856 config
8857 .tui
8858 .as_ref()
8859 .and_then(|tui| tui.mouse_capture)
8860 .unwrap_or_else(|| default_mouse_capture_enabled(terminal_emulator, wt_session, conemu_pid))
8861}
8862
8863fn default_mouse_capture_enabled(
8878 terminal_emulator: Option<&str>,
8879 wt_session: Option<&str>,
8880 conemu_pid: Option<&str>,
8881) -> bool {
8882 if cfg!(windows) {
8883 return wt_session.is_some() || conemu_pid.is_some();
8884 }
8885 if matches!(terminal_emulator, Some(t) if t.eq_ignore_ascii_case("JetBrains-JediTerm")) {
8886 return false;
8887 }
8888 true
8889}
8890
8891struct RecentCheckpoint {
8895 session: session_manager::SavedSession,
8896 age: std::time::Duration,
8897 source: session_manager::CheckpointSource,
8898}
8899
8900const CHECKPOINT_MAX_AGE: std::time::Duration = std::time::Duration::from_secs(24 * 3600);
8901
8902fn load_recent_checkpoints(manager: &session_manager::SessionManager) -> Vec<RecentCheckpoint> {
8909 let refs = manager.list_checkpoints().unwrap_or_default();
8910 let mut recent = Vec::new();
8911 for checkpoint_ref in refs {
8912 let Ok(age) = std::time::SystemTime::now().duration_since(checkpoint_ref.modified) else {
8913 continue;
8914 };
8915 if age > CHECKPOINT_MAX_AGE {
8916 let _ = match &checkpoint_ref.source {
8917 session_manager::CheckpointSource::Session(id) => {
8918 manager.clear_session_checkpoint(id)
8919 }
8920 session_manager::CheckpointSource::Legacy => manager.clear_legacy_checkpoint(),
8921 };
8922 continue;
8923 }
8924 let loaded = match &checkpoint_ref.source {
8925 session_manager::CheckpointSource::Session(id) => manager.load_session_checkpoint(id),
8926 session_manager::CheckpointSource::Legacy => manager.load_legacy_checkpoint(),
8927 };
8928 let Ok(Some(session)) = loaded else {
8929 continue;
8930 };
8931 recent.push(RecentCheckpoint {
8932 session,
8933 age,
8934 source: checkpoint_ref.source,
8935 });
8936 }
8937 recent.sort_by_key(|c| c.age);
8940 recent
8941}
8942
8943fn checkpoint_age_label(age: std::time::Duration) -> String {
8944 if age.as_secs() < 60 {
8945 format!("{}s ago", age.as_secs())
8946 } else if age.as_secs() < 3600 {
8947 format!("{}m ago", age.as_secs() / 60)
8948 } else {
8949 format!("{}h ago", age.as_secs() / 3600)
8950 }
8951}
8952
8953fn recover_interrupted_checkpoint_for_resume(launch_workspace: &Path) -> Option<String> {
8965 let manager = session_manager::SessionManager::default_location().ok()?;
8966 let candidates = load_recent_checkpoints(&manager);
8967 if candidates.is_empty() {
8968 return None;
8969 }
8970
8971 let (matching, mismatched): (Vec<_>, Vec<_>) = candidates.into_iter().partition(|candidate| {
8976 session_manager::workspace_scope_matches(
8977 &candidate.session.metadata.workspace,
8978 launch_workspace,
8979 )
8980 });
8981
8982 let Some(best) = matching.into_iter().next() else {
8983 if let Some(newest) = mismatched.first() {
8984 eprintln!(
8985 "Note: an interrupted session from another workspace ({}) is \
8986 available. Run `codewhale sessions` to list saved sessions. Starting \
8987 fresh in {}.",
8988 newest.session.metadata.workspace.display(),
8989 launch_workspace.display(),
8990 );
8991 }
8992 return None;
8993 };
8994
8995 let session_id = best.session.metadata.id.clone();
8996
8997 if !saved_session_is_newer(&manager, &best.session)
9002 && manager.save_session(&best.session).is_err()
9003 {
9004 return None;
9005 }
9006
9007 match &best.source {
9008 session_manager::CheckpointSource::Session(id) => {
9009 let _ = manager.clear_session_checkpoint(id);
9011 }
9012 session_manager::CheckpointSource::Legacy => {
9013 let _ = manager.write_session_checkpoint_if_absent(&best.session);
9018 }
9019 }
9020
9021 let age_str = checkpoint_age_label(best.age);
9022 eprintln!("Recovered interrupted session ({age_str}). Use --fresh to start fresh.",);
9023
9024 Some(session_id)
9025}
9026
9027fn saved_session_is_newer(
9032 manager: &session_manager::SessionManager,
9033 checkpoint: &session_manager::SavedSession,
9034) -> bool {
9035 manager
9036 .load_session(&checkpoint.metadata.id)
9037 .is_ok_and(|existing| existing.metadata.updated_at >= checkpoint.metadata.updated_at)
9038}
9039
9040fn preserve_interrupted_checkpoint_for_explicit_resume(launch_workspace: &Path) {
9051 let Some(manager) = session_manager::SessionManager::default_location().ok() else {
9052 return;
9053 };
9054 let Some(newest) = load_recent_checkpoints(&manager).into_iter().next() else {
9055 return;
9056 };
9057
9058 let session_workspace = newest.session.metadata.workspace.clone();
9059 if newest.source == session_manager::CheckpointSource::Legacy {
9061 let _ = manager.save_checkpoint(&newest.session);
9064 let _ = manager.clear_legacy_checkpoint();
9065 }
9066
9067 let age_str = checkpoint_age_label(newest.age);
9068 if session_manager::workspace_scope_matches(&session_workspace, launch_workspace) {
9069 eprintln!(
9070 "Found an in-flight session snapshot ({age_str}). Starting a new \
9071 session. Run `codewhale --continue` to resume it."
9072 );
9073 } else {
9074 eprintln!(
9075 "Note: an interrupted session from another workspace ({}) is \
9076 available. Run `codewhale sessions` to list saved sessions. Starting \
9077 fresh in {}.",
9078 session_workspace.display(),
9079 launch_workspace.display(),
9080 );
9081 }
9082}
9083
9084#[cfg(test)]
9090fn merge_project_config(config: &mut Config, workspace: &Path) {
9091 merge_project_config_with_approval_baseline(config, workspace, None);
9092}
9093
9094fn merge_project_config_with_approval_baseline(
9099 config: &mut Config,
9100 workspace: &Path,
9101 saved_permission_posture: Option<&str>,
9102) {
9103 if let Some(home) = effective_home_dir()
9108 && let (Ok(w), Ok(h)) = (
9109 std::fs::canonicalize(workspace),
9110 std::fs::canonicalize(&home),
9111 )
9112 && w == h
9113 {
9114 return;
9115 }
9116
9117 let path = workspace
9119 .join(codewhale_config::CODEWHALE_APP_DIR)
9120 .join("config.toml");
9121 let raw = match read_project_config_file(&path) {
9122 Ok(Some(r)) => r,
9123 Ok(None) => {
9124 let legacy = workspace
9125 .join(codewhale_config::LEGACY_APP_DIR)
9126 .join("config.toml");
9127 match read_project_config_file(&legacy) {
9128 Ok(Some(r)) => r,
9129 Ok(None) => return,
9130 Err(err) => {
9131 eprintln!(
9132 "warning: failed to read project-scope config {}: {err}",
9133 legacy.display()
9134 );
9135 return;
9136 }
9137 }
9138 }
9139 Err(err) => {
9140 eprintln!(
9141 "warning: failed to read project-scope config {}: {err}",
9142 path.display()
9143 );
9144 return;
9145 }
9146 };
9147 let project: toml::Value = match toml::from_str(&raw) {
9148 Ok(v) => v,
9149 Err(_) => return,
9150 };
9151 let table = match project.as_table() {
9152 Some(t) => t,
9153 None => return,
9154 };
9155
9156 const DENY_AT_PROJECT_SCOPE: &[&str] = &[
9172 "api_key",
9173 "base_url",
9174 "provider",
9175 "mcp_config_path",
9176 "mcp_oauth_callback_port",
9177 "mcp_oauth_callback_url",
9178 ];
9179 for key in DENY_AT_PROJECT_SCOPE {
9180 if table.contains_key(*key) {
9181 eprintln!(
9182 "warning: project-scope config key `{key}` is ignored — \
9183 set it in `~/.codewhale/config.toml` instead. \
9184 (See #417 for the deny-list rationale.)"
9185 );
9186 }
9187 }
9188
9189 for (key, field) in [
9192 ("model", &mut config.default_text_model),
9193 ("reasoning_effort", &mut config.reasoning_effort),
9194 ("notes_path", &mut config.notes_path),
9195 ] {
9196 if let Some(v) = table.get(key).and_then(toml::Value::as_str)
9197 && !v.is_empty()
9198 {
9199 *field = Some(v.to_string());
9200 }
9201 }
9202
9203 if let Some(v) = table.get("approval_policy").and_then(toml::Value::as_str)
9204 && !v.is_empty()
9205 {
9206 let saved_approval_baseline =
9207 crate::config::approval_policy_baseline_from_permission_posture(
9208 saved_permission_posture,
9209 );
9210 let approval_baseline = config
9211 .approval_policy
9212 .as_deref()
9213 .or(saved_approval_baseline);
9214 if codewhale_config::project_approval_policy_is_allowed(approval_baseline, v) {
9215 config.approval_policy = Some(v.to_string());
9216 } else {
9217 eprintln!(
9218 "warning: project-scope `approval_policy = \"{v}\"` is ignored — \
9219 project config can only tighten the user's approval policy. \
9220 (See #417.)"
9221 );
9222 }
9223 }
9224
9225 if let Some(v) = table.get("sandbox_mode").and_then(toml::Value::as_str)
9226 && !v.is_empty()
9227 {
9228 if codewhale_config::project_sandbox_mode_is_allowed(config.sandbox_mode.as_deref(), v) {
9229 config.sandbox_mode = Some(v.to_string());
9230 } else {
9231 eprintln!(
9232 "warning: project-scope `sandbox_mode = \"{v}\"` is ignored — \
9233 project config can only tighten the user's sandbox mode. \
9234 (See #417.)"
9235 );
9236 }
9237 }
9238
9239 if let Some(v) = table.get("max_subagents").and_then(toml::Value::as_integer)
9241 && v > 0
9242 {
9243 config.max_subagents = Some((v as usize).clamp(1, crate::config::MAX_SUBAGENTS));
9244 }
9245 if let Some(v) = table.get("allow_shell").and_then(toml::Value::as_bool) {
9246 if v {
9247 eprintln!(
9248 "warning: project-scope `allow_shell = true` is ignored — \
9249 enable shell from user config for this workspace instead. \
9250 (See #417.)"
9251 );
9252 } else {
9253 config.allow_shell = Some(false);
9254 }
9255 }
9256
9257 if table.contains_key("instructions") {
9258 eprintln!(
9259 "warning: project-scope `instructions` is ignored — \
9260 configure instruction files from user config instead. \
9261 (See #417.)"
9262 );
9263 }
9264}
9265
9266fn read_project_config_file(path: &Path) -> io::Result<Option<String>> {
9267 let metadata = match std::fs::symlink_metadata(path) {
9268 Ok(metadata) => metadata,
9269 Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
9270 Err(err) => return Err(err),
9271 };
9272 let file_type = metadata.file_type();
9273 if file_type.is_symlink() {
9274 return Err(io::Error::new(
9275 io::ErrorKind::InvalidInput,
9276 "project-scope config must not be a symlink",
9277 ));
9278 }
9279 if !file_type.is_file() {
9280 return Ok(None);
9281 }
9282
9283 let mut file = open_project_config_file(path)?;
9284 let mut raw = String::new();
9285 file.read_to_string(&mut raw)?;
9286 Ok(Some(raw))
9287}
9288
9289#[cfg(unix)]
9290fn open_project_config_file(path: &Path) -> io::Result<std::fs::File> {
9291 use std::os::unix::fs::OpenOptionsExt;
9292
9293 std::fs::OpenOptions::new()
9294 .read(true)
9295 .custom_flags(libc::O_NOFOLLOW)
9296 .open(path)
9297}
9298
9299#[cfg(not(unix))]
9300fn open_project_config_file(path: &Path) -> io::Result<std::fs::File> {
9301 std::fs::File::open(path)
9302}
9303
9304fn merge_user_workspace_config(
9305 config: &mut Config,
9306 config_path: Option<PathBuf>,
9307 workspace: &Path,
9308) {
9309 if config.managed_config_path.is_some() || config.requirements_path.is_some() {
9310 return;
9311 }
9312 let allow_shell_before = config.allow_shell;
9313 let allow_shell_from_env = std::env::var_os("CODEWHALE_ALLOW_SHELL").is_some()
9314 || std::env::var_os("DEEPSEEK_ALLOW_SHELL").is_some();
9315 let path = match crate::config::resolve_load_config_path(config_path) {
9316 Ok(Some(path)) => path,
9317 Ok(None) => return,
9318 Err(error) => {
9319 tracing::error!(
9320 error = %error,
9321 "failed to resolve workspace config overlay; refusing to substitute another file"
9322 );
9323 return;
9324 }
9325 };
9326 let raw = match std::fs::read_to_string(&path) {
9327 Ok(raw) => raw,
9328 Err(error) => {
9329 eprintln!(
9330 "warning: could not read user config at {}: {error}. \
9331 Ignoring it — `[workspace]`/`[projects]` grants (e.g. `allow_shell`) \
9332 revert to defaults for this session. Fix or remove the file to \
9333 restore them.",
9334 path.display()
9335 );
9336 return;
9337 }
9338 };
9339 let doc = match toml::from_str::<toml::Value>(&raw) {
9340 Ok(doc) => doc,
9341 Err(error) => {
9342 eprintln!(
9343 "warning: could not parse user config at {}: {error}. \
9344 Ignoring it — `[workspace]`/`[projects]` grants (e.g. `allow_shell`) \
9345 revert to defaults for this session. Fix the TOML syntax to \
9346 restore them.",
9347 path.display()
9348 );
9349 return;
9350 }
9351 };
9352 merge_user_workspace_config_from_doc(config, &doc, workspace);
9353 if allow_shell_from_env {
9354 config.allow_shell = allow_shell_before;
9355 }
9356}
9357
9358fn merge_user_workspace_config_from_doc(config: &mut Config, doc: &toml::Value, workspace: &Path) {
9359 for table_name in ["workspace", "projects"] {
9360 let Some(entries) = doc.get(table_name).and_then(toml::Value::as_table) else {
9361 continue;
9362 };
9363 for (raw_path, entry) in entries {
9364 if !workspace_config_path_matches(raw_path, workspace) {
9365 continue;
9366 }
9367 if let Some(allow_shell) = entry.get("allow_shell").and_then(toml::Value::as_bool) {
9368 config.allow_shell = Some(allow_shell);
9369 }
9370 }
9371 }
9372}
9373
9374fn workspace_config_path_matches(raw_path: &str, workspace: &Path) -> bool {
9375 let configured = crate::config::expand_path(raw_path);
9376 let configured = configured.canonicalize().unwrap_or(configured);
9377 let workspace = workspace
9378 .canonicalize()
9379 .unwrap_or_else(|_| workspace.to_path_buf());
9380 paths_equal_for_config(&configured, &workspace)
9381}
9382
9383#[cfg(windows)]
9384fn paths_equal_for_config(left: &Path, right: &Path) -> bool {
9385 normalize_windows_config_path_for_compare(left)
9386 == normalize_windows_config_path_for_compare(right)
9387}
9388
9389#[cfg(not(windows))]
9390fn paths_equal_for_config(left: &Path, right: &Path) -> bool {
9391 left == right
9392}
9393
9394#[cfg(windows)]
9395fn normalize_windows_config_path_for_compare(path: &Path) -> String {
9396 normalize_windows_config_path_str(&path.to_string_lossy())
9397}
9398
9399#[cfg(any(windows, test))]
9400fn normalize_windows_config_path_str(path: &str) -> String {
9401 let mut normalized = path.replace('/', "\\");
9402 if let Some(rest) = normalized.strip_prefix(r"\\?\UNC\") {
9403 normalized = format!("\\\\{rest}");
9404 } else if let Some(rest) = normalized.strip_prefix(r"\\?\") {
9405 normalized = rest.to_string();
9406 }
9407 while normalized.len() > 3 && normalized.ends_with('\\') {
9408 normalized.pop();
9409 }
9410 normalized.to_ascii_lowercase()
9411}
9412
9413fn interactive_tui_allow_shell(yolo: bool, config: &Config) -> bool {
9414 yolo || config.interactive_allow_shell()
9415}
9416
9417async fn run_interactive(
9418 cli: &Cli,
9419 config: &Config,
9420 resume_session_id: Option<String>,
9421 initial_input: Option<tui::InitialInput>,
9422 pending_telemetry_notice: Option<crate::telemetry_notice::PendingTelemetryNotice>,
9423 plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
9424) -> Result<()> {
9425 run_interactive_with_notice(
9426 cli,
9427 config,
9428 resume_session_id,
9429 initial_input,
9430 None,
9431 pending_telemetry_notice,
9432 plugin_registry,
9433 )
9434 .await
9435}
9436
9437async fn run_interactive_with_notice(
9441 cli: &Cli,
9442 config: &Config,
9443 resume_session_id: Option<String>,
9444 initial_input: Option<tui::InitialInput>,
9445 startup_notice: Option<String>,
9446 pending_telemetry_notice: Option<crate::telemetry_notice::PendingTelemetryNotice>,
9447 plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
9448) -> Result<()> {
9449 let initial_input = if cli.remote_control {
9450 Some(tui::InitialInput::RemoteControl)
9451 } else {
9452 initial_input
9453 };
9454 let workspace = cli
9455 .workspace
9456 .clone()
9457 .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
9458
9459 let mut merged_config = config.clone();
9463 merge_user_workspace_config(&mut merged_config, cli.config.clone(), &workspace);
9464 if !cli.no_project_config {
9465 let saved_permission_posture = crate::settings::Settings::load_persisted()
9466 .ok()
9467 .and_then(|settings| settings.permission_posture);
9468 merge_project_config_with_approval_baseline(
9469 &mut merged_config,
9470 &workspace,
9471 saved_permission_posture.as_deref(),
9472 );
9473 }
9474 let config = &merged_config;
9475
9476 if !cli.skip_onboarding {
9477 match crate::config::ensure_config_file_exists(cli.config.clone()) {
9478 Ok(Some(path)) => logging::info(format!(
9479 "Created first-run config file at {}",
9480 path.display()
9481 )),
9482 Ok(None) => {}
9483 Err(err) => logging::warn(format!("Failed to create first-run config file: {err}")),
9484 }
9485 }
9486
9487 match codewhale_config::migrate_config_if_needed() {
9490 Ok(Some(migration)) => {
9491 eprintln!("{}", migration.user_notice());
9492 }
9493 Ok(None) => {}
9494 Err(err) => logging::warn(format!("Config migration skipped: {err}")),
9495 }
9496
9497 let model = config.default_model();
9498 let provider = config.api_provider();
9499 let max_subagents = cli.max_subagents.map_or_else(
9500 || config.max_subagents_for_provider(provider),
9501 |value| value.clamp(1, MAX_SUBAGENTS),
9502 );
9503 let use_alt_screen = should_use_alt_screen(cli, config);
9504 let use_mouse_capture = should_use_mouse_capture(cli, config, use_alt_screen);
9505 let use_bracketed_paste = crate::settings::Settings::load()
9506 .map(|s| s.effective_bracketed_paste())
9507 .unwrap_or_else(|_| !crate::settings::detected_legacy_windows_console_host());
9508
9509 let skills_dir = config.skills_dir();
9512 if let Err(e) = crate::skills::install_system_skills(&skills_dir) {
9513 logging::warn(format!("Failed to install system skills: {e}"));
9514 }
9515
9516 startup_trace::mark("interactive_config");
9517
9518 crate::models_dev_live::maybe_load_persisted_cache();
9522 crate::models_dev_live::spawn_background_refresh();
9523 crate::client::DeepSeekClient::spawn_active_provider_catalog_refresh(config);
9528
9529 let snapshots = config.snapshots_config();
9536 let janitor_snapshots_enabled = snapshots.enabled;
9537 let janitor_max_age = snapshots.max_age();
9538 let janitor_workspace = workspace.clone();
9539 let janitor_resume_id = resume_session_id.clone();
9544 let janitor_skip_session_cleanup = resume_session_id.is_some() || cli.continue_session;
9545 tokio::task::spawn_blocking(move || {
9546 if janitor_snapshots_enabled {
9547 session_manager::prune_workspace_snapshots(&janitor_workspace, janitor_max_age);
9548 }
9549
9550 match crate::tools::truncate::prune_older_than(crate::tools::truncate::SPILLOVER_MAX_AGE) {
9551 Ok(0) => {}
9552 Ok(n) => tracing::debug!(
9553 target: "spillover",
9554 "boot prune removed {n} spillover file(s)"
9555 ),
9556 Err(err) => tracing::warn!(
9557 target: "spillover",
9558 ?err,
9559 "spillover prune skipped on boot"
9560 ),
9561 }
9562
9563 if !janitor_skip_session_cleanup
9564 && let Ok(manager) = session_manager::SessionManager::default_location()
9565 {
9566 let _ = manager.cleanup_old_sessions_keeping(janitor_resume_id.as_deref());
9567 }
9568 });
9569
9570 let yolo = cli.yolo || config.yolo.unwrap_or(false);
9573
9574 tui::run_tui(
9575 config,
9576 tui::TuiOptions {
9577 model,
9578 workspace,
9579 config_path: cli.config.clone(),
9580 config_profile: effective_config_profile(cli),
9581 allow_shell: interactive_tui_allow_shell(yolo, config),
9582 use_alt_screen,
9583 use_mouse_capture,
9584 use_bracketed_paste,
9585 skills_dir,
9586 memory_path: config.memory_path(),
9587 notes_path: config.notes_path(),
9588 mcp_config_path: config.mcp_config_path(),
9589 use_memory: config.memory_enabled(),
9590 start_in_agent_mode: yolo,
9591 skip_onboarding: cli.skip_onboarding,
9592 yolo, resume_session_id,
9594 initial_input,
9595 startup_notice,
9596 max_subagents,
9597 },
9598 plugin_registry,
9599 pending_telemetry_notice,
9600 )
9601 .await
9602}
9603
9604#[derive(Debug)]
9605struct CliAutoRoute {
9606 provider: crate::config::ApiProvider,
9607 model: String,
9608 reasoning_effort: Option<crate::tui::app::ReasoningEffort>,
9609 auto_controls_reasoning: bool,
9614 auto_model: bool,
9615}
9616
9617fn cli_reasoning_effort_value(
9618 config: &Config,
9619 model: &str,
9620 effort: crate::tui::app::ReasoningEffort,
9621) -> Option<String> {
9622 effort
9623 .api_value_for_route(config.api_provider(), &config.deepseek_base_url(), model)
9624 .map(str::to_string)
9625}
9626
9627fn cli_reasoning_effort_value_for_prompt(
9628 config: &Config,
9629 model: &str,
9630 effort: crate::tui::app::ReasoningEffort,
9631 prompt: &str,
9632) -> Option<String> {
9633 let resolved = if effort == crate::tui::app::ReasoningEffort::Auto {
9634 crate::auto_reasoning::select(false, prompt)
9635 } else {
9636 effort
9637 };
9638 cli_reasoning_effort_value(config, model, resolved)
9639}
9640
9641fn normalize_cli_reasoning_effort(value: &str) -> Result<Option<String>> {
9642 let trimmed = value.trim();
9643 if trimmed.is_empty() {
9644 return Ok(None);
9645 }
9646 if matches!(
9647 trimmed.to_ascii_lowercase().as_str(),
9648 "inherit" | "parent" | "same" | "current" | "default" | "unset"
9649 ) {
9650 return Ok(None);
9651 }
9652 crate::tui::app::ReasoningEffort::parse_strict(trimmed)
9653 .map(|effort| Some(effort.as_setting().to_string()))
9654 .map_err(anyhow::Error::msg)
9655}
9656
9657fn config_for_cli_route(config: &Config, route: &CliAutoRoute) -> Config {
9658 let mut execution_config = config.clone();
9659 execution_config.provider = Some(config.provider_identity_for(route.provider));
9660 execution_config.set_provider_model_override(route.provider, Some(route.model.clone()));
9661 if matches!(
9662 route.provider,
9663 crate::config::ApiProvider::Deepseek | crate::config::ApiProvider::DeepseekCN
9664 ) {
9665 execution_config.default_text_model = Some(route.model.clone());
9666 }
9667 execution_config
9668}
9669
9670async fn resolve_cli_auto_route(
9671 config: &Config,
9672 model: &str,
9673 prompt: &str,
9674) -> Result<CliAutoRoute> {
9675 if model.trim().eq_ignore_ascii_case("auto") {
9676 let selection =
9677 model_routing::resolve_auto_route_with_inventory(config, prompt, "", "auto", "auto")
9678 .await?;
9679 let preference = config
9680 .reasoning_effort()
9681 .filter(|_| config.reasoning_effort_is_explicit())
9682 .map(crate::tui::app::ReasoningEffort::from_setting);
9683 let (reasoning_effort, auto_controls_reasoning) =
9684 model_routing::resolve_auto_model_reasoning(preference, selection.reasoning_effort);
9685 Ok(CliAutoRoute {
9686 provider: selection.provider,
9687 model: selection.model,
9688 reasoning_effort,
9689 auto_controls_reasoning,
9690 auto_model: true,
9691 })
9692 } else {
9693 if let Some(selection) = model_routing::resolve_explicit_route_with_inventory(config, model)
9694 {
9695 let auto_controls_reasoning = matches!(
9696 selection.reasoning_effort,
9697 Some(crate::tui::app::ReasoningEffort::Auto)
9698 );
9699 return Ok(CliAutoRoute {
9700 provider: selection.provider,
9701 model: selection.model,
9702 reasoning_effort: selection.reasoning_effort,
9703 auto_controls_reasoning,
9704 auto_model: false,
9705 });
9706 }
9707
9708 let candidate_providers = model_routing::explicit_route_candidate_providers(config, model);
9709 if !candidate_providers.is_empty() && !candidate_providers.contains(&config.api_provider())
9710 {
9711 let providers = candidate_providers
9712 .iter()
9713 .map(|provider| provider.as_str())
9714 .collect::<Vec<_>>()
9715 .join(", ");
9716 bail!(
9717 "model `{model}` is available from configured provider route(s): {providers}. \
9718 Pass `--provider <provider>` with `--model {model}` to choose one explicitly. \
9719 In the TUI, use `/provider`, `/model`, or `/setup` to resolve the route before sending."
9720 );
9721 }
9722
9723 let reasoning_effort = config
9730 .reasoning_effort()
9731 .map(crate::tui::app::ReasoningEffort::from_setting);
9732 Ok(CliAutoRoute {
9733 provider: config.api_provider(),
9734 model: model.to_string(),
9735 auto_controls_reasoning: matches!(
9736 reasoning_effort,
9737 Some(crate::tui::app::ReasoningEffort::Auto)
9738 ),
9739 reasoning_effort,
9740 auto_model: false,
9741 })
9742 }
9743}
9744
9745async fn resolve_cli_exec_route(
9746 config: &Config,
9747 model: &str,
9748 prompt: &str,
9749 force_configured_route: bool,
9750) -> Result<CliAutoRoute> {
9751 if force_configured_route && !model.trim().eq_ignore_ascii_case("auto") {
9752 let reasoning_effort = config
9753 .reasoning_effort()
9754 .map(crate::tui::app::ReasoningEffort::from_setting);
9755 return Ok(CliAutoRoute {
9756 provider: config.api_provider(),
9757 model: model.to_string(),
9758 auto_controls_reasoning: matches!(
9759 reasoning_effort,
9760 Some(crate::tui::app::ReasoningEffort::Auto)
9761 ),
9762 reasoning_effort,
9763 auto_model: false,
9764 });
9765 }
9766 resolve_cli_auto_route(config, model, prompt).await
9767}
9768
9769fn should_force_configured_exec_route(
9770 resuming: bool,
9771 explicit_provider: Option<&str>,
9772 explicit_model: Option<&str>,
9773) -> bool {
9774 resuming || explicit_provider.is_some() || explicit_model.is_none()
9779}
9780
9781async fn run_one_shot(
9782 config: &Config,
9783 model: &str,
9784 prompt: &str,
9785 force_configured_route: bool,
9786) -> Result<()> {
9787 use crate::client::DeepSeekClient;
9788 use crate::models::{
9789 ContentBlock, Message, MessageRequest, is_incomplete_stop_reason, stop_reason_detail,
9790 };
9791
9792 let route = resolve_cli_exec_route(config, model, prompt, force_configured_route).await?;
9793 let execution_config = config_for_cli_route(config, &route);
9794 let client = DeepSeekClient::new(&execution_config)?;
9795 let reasoning_effort = route.reasoning_effort.and_then(|effort| {
9796 cli_reasoning_effort_value_for_prompt(&execution_config, &route.model, effort, prompt)
9797 });
9798 let model = route.model;
9799 let request_route = client.effective_route_envelope(&model, chrono::Utc::now());
9800
9801 let request = MessageRequest {
9802 model,
9803 messages: vec![Message {
9804 role: "user".to_string(),
9805 content: vec![ContentBlock::Text {
9806 text: prompt.to_string(),
9807 cache_control: None,
9808 }],
9809 }],
9810 max_tokens: crate::route_budget::effective_max_output_tokens_for_route(
9811 request_route.provider,
9812 &request_route.model,
9813 None,
9814 ),
9815 system: None,
9816 tools: None,
9817 tool_choice: None,
9818 metadata: None,
9819 thinking: None,
9820 reasoning_effort,
9821 stream: Some(false),
9822 temperature: None,
9823 top_p: None,
9824 };
9825
9826 let response = client.create_message(request).await?;
9827 let stop_reason = response.stop_reason.clone();
9828
9829 for block in response.content {
9830 if let ContentBlock::Text { text, .. } = block {
9831 println!("{text}");
9832 }
9833 }
9834
9835 if is_incomplete_stop_reason(stop_reason.as_deref()) {
9836 anyhow::bail!(
9837 "Model response incomplete: provider stop reason `{}`; the partial response was printed but the command did not succeed.",
9838 stop_reason_detail(stop_reason.as_deref())
9839 );
9840 }
9841
9842 Ok(())
9843}
9844
9845async fn run_one_shot_json(
9846 config: &Config,
9847 model: &str,
9848 prompt: &str,
9849 force_configured_route: bool,
9850) -> Result<()> {
9851 use crate::client::DeepSeekClient;
9852 use crate::models::{
9853 ContentBlock, Message, MessageRequest, SystemPrompt, is_incomplete_stop_reason,
9854 stop_reason_detail,
9855 };
9856
9857 let route = resolve_cli_exec_route(config, model, prompt, force_configured_route).await?;
9858 let execution_config = config_for_cli_route(config, &route);
9859 let provider = execution_config.provider_identity_for(route.provider);
9860 let client = DeepSeekClient::new(&execution_config)?;
9861 let model = route.model.clone();
9862 let reasoning_effort = route.reasoning_effort.and_then(|effort| {
9863 cli_reasoning_effort_value_for_prompt(&execution_config, &model, effort, prompt)
9864 });
9865 let request_route = client.effective_route_envelope(&model, chrono::Utc::now());
9866 let request = MessageRequest {
9867 model: model.clone(),
9868 messages: vec![Message {
9869 role: "user".to_string(),
9870 content: vec![ContentBlock::Text {
9871 text: prompt.to_string(),
9872 cache_control: None,
9873 }],
9874 }],
9875 max_tokens: crate::route_budget::effective_max_output_tokens_for_route(
9876 request_route.provider,
9877 &request_route.model,
9878 None,
9879 ),
9880 system: Some(SystemPrompt::Text(
9881 "You are a coding assistant. Give concise, actionable responses.".to_string(),
9882 )),
9883 tools: None,
9884 tool_choice: None,
9885 metadata: None,
9886 thinking: None,
9887 reasoning_effort,
9888 stream: Some(false),
9889 temperature: None,
9890 top_p: None,
9891 };
9892
9893 let response = client.create_message(request).await?;
9894 let stop_reason = response.stop_reason.clone();
9895 let usage = response.usage.clone();
9896 let mut output = String::new();
9897 for block in response.content {
9898 if let ContentBlock::Text { text, .. } = block {
9899 output.push_str(&text);
9900 }
9901 }
9902 println!(
9903 "{}",
9904 serde_json::to_string_pretty(&one_shot_exec_json_receipt(
9905 provider,
9906 model,
9907 output,
9908 stop_reason.clone(),
9909 usage,
9910 ))?
9911 );
9912 if is_incomplete_stop_reason(stop_reason.as_deref()) {
9913 anyhow::bail!(
9914 "Model response incomplete: provider stop reason `{}`; the JSON receipt records success=false.",
9915 stop_reason_detail(stop_reason.as_deref())
9916 );
9917 }
9918 Ok(())
9919}
9920
9921fn one_shot_exec_json_receipt(
9922 provider: String,
9923 model: String,
9924 output: String,
9925 stop_reason: Option<String>,
9926 usage: crate::models::Usage,
9927) -> serde_json::Value {
9928 let incomplete = crate::models::is_incomplete_stop_reason(stop_reason.as_deref());
9929 let error = incomplete.then(|| {
9930 format!(
9931 "Model response incomplete: provider stop reason `{}`.",
9932 crate::models::stop_reason_detail(stop_reason.as_deref())
9933 )
9934 });
9935 serde_json::json!({
9936 "mode": "one-shot",
9937 "provider": provider,
9938 "model": model,
9939 "success": !incomplete,
9940 "output": output,
9941 "stop_reason": stop_reason,
9942 "usage": usage,
9943 "error": error,
9944 })
9945}
9946
9947fn exec_stream_provider_route(
9948 identity: &crate::config::ProviderIdentity,
9949) -> (String, Option<String>) {
9950 let provider = identity.provider.as_str().to_string();
9951 let provider_id = if identity.provider == crate::config::ApiProvider::Custom {
9952 identity.exact_id.clone()
9953 } else {
9954 None
9955 };
9956 (provider, provider_id)
9957}
9958
9959#[derive(serde::Serialize)]
9960struct ExecStreamMeta {
9961 receipt_kind: &'static str,
9962 provider: String,
9963 #[serde(skip_serializing_if = "Option::is_none")]
9967 provider_id: Option<String>,
9968 model: String,
9969 route_source: String,
9970 #[serde(skip_serializing_if = "Option::is_none")]
9971 input_tokens: Option<u32>,
9972 #[serde(skip_serializing_if = "Option::is_none")]
9973 output_tokens: Option<u32>,
9974 #[serde(skip_serializing_if = "Option::is_none")]
9975 prompt_cache_hit_tokens: Option<u32>,
9976 #[serde(skip_serializing_if = "Option::is_none")]
9977 prompt_cache_miss_tokens: Option<u32>,
9978 #[serde(skip_serializing_if = "Option::is_none")]
9979 prompt_cache_write_tokens: Option<u32>,
9980 #[serde(skip_serializing_if = "Option::is_none")]
9981 reasoning_tokens: Option<u32>,
9982 duration_ms: u64,
9983 #[serde(skip_serializing_if = "Option::is_none")]
9984 retry_count: Option<u32>,
9985 approval_posture: String,
9986 sandbox_posture: String,
9987 #[serde(skip_serializing_if = "Option::is_none")]
9988 binary_sha256: Option<String>,
9989 #[serde(skip_serializing_if = "Option::is_none")]
9990 config_sha256: Option<String>,
9991 prompt_sha256: String,
9992 #[serde(skip_serializing_if = "Option::is_none")]
9993 tool_catalog_sha256: Option<String>,
9994 input_analysis: ExecStreamInputAnalysis,
9995 visible_final_answer_chars: usize,
9996 session_id: String,
9997 resume_command: String,
9998 workspace: String,
9999 message_count: usize,
10000 #[serde(skip_serializing_if = "Option::is_none")]
10001 status: Option<String>,
10002 #[serde(skip_serializing_if = "Option::is_none")]
10003 termination_reason: Option<String>,
10004 #[serde(skip_serializing_if = "Option::is_none")]
10005 error_category: Option<String>,
10006 #[serde(skip_serializing_if = "Option::is_none")]
10007 error: Option<String>,
10008}
10009
10010#[derive(Debug, Default, Clone, serde::Serialize, PartialEq, Eq)]
10011struct ExecStreamInputAnalysis {
10012 estimated_request_tokens: usize,
10013 estimated_message_content_tokens: usize,
10014 estimated_system_tokens: usize,
10015 estimated_framing_tokens: usize,
10016 user_message_count: usize,
10017 assistant_message_count: usize,
10018 tool_message_count: usize,
10019 tool_use_count: usize,
10020 tool_result_count: usize,
10021 text_chars: usize,
10022 thinking_chars: usize,
10023 tool_use_input_chars: usize,
10024 tool_result_chars: usize,
10025 text_estimated_tokens: usize,
10026 thinking_estimated_tokens: usize,
10027 tool_use_input_estimated_tokens: usize,
10028 tool_result_estimated_tokens: usize,
10029}
10030
10031#[derive(serde::Serialize)]
10032#[serde(tag = "type")]
10033#[allow(clippy::large_enum_variant)]
10036enum ExecStreamEvent {
10037 #[serde(rename = "content")]
10038 Content { content: String },
10039 #[serde(rename = "tool_use")]
10040 ToolUse {
10041 name: String,
10042 id: String,
10043 input: serde_json::Value,
10044 started_at: String,
10045 },
10046 #[serde(rename = "tool_result")]
10047 ToolResult {
10048 id: String,
10049 name: String,
10050 output: String,
10051 status: String,
10052 started_at: String,
10053 completed_at: String,
10054 duration_ms: u64,
10055 side_effect_status: String,
10056 #[serde(skip_serializing_if = "Option::is_none")]
10057 error_category: Option<String>,
10058 #[serde(skip_serializing_if = "Option::is_none")]
10059 truncated: Option<bool>,
10060 #[serde(skip_serializing_if = "Option::is_none")]
10061 artifact: Option<serde_json::Value>,
10062 #[serde(skip_serializing_if = "Option::is_none")]
10063 result_metadata: Option<serde_json::Value>,
10064 },
10065 #[serde(rename = "agent_spawned")]
10073 AgentSpawned {
10074 id: String,
10075 model: String,
10076 spawn_depth: u32,
10077 #[serde(skip_serializing_if = "Option::is_none")]
10078 parent_run_id: Option<String>,
10079 #[serde(skip_serializing_if = "Option::is_none")]
10081 route_source: Option<String>,
10082 },
10083 #[serde(rename = "sandbox_denied")]
10084 SandboxDenied {
10085 tool_id: String,
10086 tool_name: String,
10087 reason: String,
10088 outcome: String,
10089 },
10090 #[serde(rename = "workflow_event")]
10091 WorkflowEvent {
10092 run_id: String,
10093 event: serde_json::Value,
10094 },
10095 #[serde(rename = "session_capture")]
10096 SessionCapture { content: String },
10097 #[serde(rename = "service_released")]
10098 #[cfg(unix)]
10099 ServiceReleased {
10100 task_id: String,
10101 pid: u32,
10102 process_group_id: u32,
10103 ownership: String,
10104 },
10105 #[serde(rename = "turn_usage")]
10112 TurnUsage {
10113 turn: u32,
10115 input_tokens: u32,
10116 output_tokens: u32,
10117 #[serde(skip_serializing_if = "Option::is_none")]
10118 reasoning_tokens: Option<u32>,
10119 #[serde(skip_serializing_if = "Option::is_none")]
10120 prompt_cache_hit_tokens: Option<u32>,
10121 #[serde(skip_serializing_if = "Option::is_none")]
10122 prompt_cache_miss_tokens: Option<u32>,
10123 #[serde(skip_serializing_if = "Option::is_none")]
10124 prompt_cache_write_tokens: Option<u32>,
10125 #[serde(skip_serializing_if = "Option::is_none")]
10126 reasoning_replay_tokens: Option<u32>,
10127 duration_ms: u64,
10128 },
10129 #[serde(rename = "metadata")]
10130 Metadata { meta: Box<ExecStreamMeta> },
10131 #[serde(rename = "done")]
10132 Done,
10133 #[serde(rename = "error")]
10134 Error { error: String },
10135}
10136
10137fn exec_sandbox_elevation_authorized(
10138 allow_sandbox_elevation: bool,
10139 explicit_sandbox: Option<&str>,
10140) -> bool {
10141 allow_sandbox_elevation
10142 || explicit_sandbox.is_some_and(|policy| policy.eq_ignore_ascii_case("danger-full-access"))
10143}
10144
10145fn emit_exec_stream_event(event: &ExecStreamEvent) -> Result<()> {
10146 println!("{}", serde_json::to_string(&exec_stream_value(event)?)?);
10147 Ok(())
10148}
10149
10150const EXEC_EXIT_RETRYABLE_INFRA: i32 = 75; fn exec_failure_exit_code(error_category: Option<&str>) -> i32 {
10166 match error_category {
10167 Some("network" | "timeout") => EXEC_EXIT_RETRYABLE_INFRA,
10168 _ => 1,
10169 }
10170}
10171
10172fn exec_error_event_is_fatal(envelope: &crate::error_taxonomy::ErrorEnvelope) -> bool {
10179 !envelope.recoverable
10180}
10181
10182fn exec_stream_value(event: &ExecStreamEvent) -> Result<serde_json::Value> {
10183 let mut value = serde_json::to_value(event)?;
10184 if let Some(object) = value.as_object_mut() {
10185 object.insert("schema_version".to_string(), serde_json::json!(1));
10186 object.insert(
10187 "schema".to_string(),
10188 serde_json::json!("codewhale.exec-stream"),
10189 );
10190 }
10191 Ok(value)
10192}
10193
10194fn tool_error_receipt_category(error: &crate::tools::spec::ToolError) -> &'static str {
10195 use crate::tools::spec::ToolError;
10196 match error {
10197 ToolError::InvalidInput { .. } => "invalid_input",
10198 ToolError::MissingField { .. } => "missing_field",
10199 ToolError::PathEscape { .. } => "path_escape",
10200 ToolError::ExecutionFailed { .. } => "execution_failed",
10201 ToolError::Timeout { .. } => "timeout",
10202 ToolError::Cancelled { .. } => "cancelled",
10203 ToolError::NotAvailable { .. } => "not_available",
10204 ToolError::PermissionDenied { .. } => "permission_denied",
10205 }
10206}
10207
10208fn tool_artifact_receipt(metadata: Option<&serde_json::Value>) -> Option<serde_json::Value> {
10209 let object = metadata?.as_object()?;
10210 let mut artifact = serde_json::Map::new();
10211 for key in [
10212 "artifact_id",
10213 "artifact_path",
10214 "artifact_relative_path",
10215 "artifact_byte_size",
10216 "spillover_path",
10217 "content_digest",
10218 "original_byte_count",
10219 "retained_head_bytes",
10220 "retained_tail_bytes",
10221 ] {
10222 if let Some(value) = object.get(key) {
10223 artifact.insert(key.to_string(), value.clone());
10224 }
10225 }
10226 (!artifact.is_empty()).then_some(serde_json::Value::Object(artifact))
10227}
10228
10229fn current_binary_sha256() -> Option<String> {
10230 let bytes = std::fs::read(std::env::current_exe().ok()?).ok()?;
10231 Some(format!("sha256:{}", crate::hashing::sha256_hex(&bytes)))
10232}
10233
10234async fn run_workflow_tool_command(
10235 cli: &Cli,
10236 args: WorkflowToolArgs,
10237 plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
10238) -> Result<()> {
10239 match run_workflow_tool_command_inner(cli, args, plugin_registry).await {
10240 Ok(()) => Ok(()),
10241 Err(error) => {
10242 let _ = emit_exec_stream_event(&ExecStreamEvent::Error {
10243 error: format!("{error:#}"),
10244 });
10245 exit_workflow_tool_failure();
10246 }
10247 }
10248}
10249
10250async fn run_workflow_tool_command_inner(
10251 cli: &Cli,
10252 args: WorkflowToolArgs,
10253 plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
10254) -> Result<()> {
10255 use crate::tools::spec::ToolSpec;
10256
10257 if args.approval_source != "explicit-workflow-command" {
10258 bail!("workflow-tool requires --approval-source explicit-workflow-command");
10259 }
10260 let input: serde_json::Value = serde_json::from_str(&args.input_json)
10261 .context("--input-json must be a valid Workflow tool input object")?;
10262 if !input.is_object() {
10263 bail!("--input-json must be a JSON object");
10264 }
10265 if !input
10266 .get("action")
10267 .and_then(serde_json::Value::as_str)
10268 .is_some_and(|action| action.eq_ignore_ascii_case("run"))
10269 {
10270 bail!("workflow-tool accepts only action=run");
10271 }
10272
10273 let workspace = resolve_workspace(cli);
10274 let mut config = load_config_from_cli(cli)?;
10275 merge_user_workspace_config(&mut config, cli.config.clone(), &workspace);
10276 if let Ok(env_url) =
10277 std::env::var("CODEWHALE_BASE_URL").or_else(|_| std::env::var("DEEPSEEK_BASE_URL"))
10278 {
10279 let trimmed = env_url.trim();
10280 if !trimmed.is_empty() {
10281 config.base_url = Some(trimmed.to_string());
10282 }
10283 }
10284
10285 let model = resolve_exec_model(&config, None);
10286 let route = resolve_cli_exec_route(
10287 &config,
10288 &model,
10289 "Run a checked-in Workflow through the host runtime",
10290 true,
10291 )
10292 .await?;
10293 let execution_config = config_for_cli_route(&config, &route);
10294 let route_identity = execution_config
10295 .active_provider_identity(route.provider)
10296 .map_err(anyhow::Error::msg)
10297 .context("workflow terminal route lost its exact provider identity")?;
10298 let (route_provider, route_provider_id) = exec_stream_provider_route(&route_identity);
10299 let workflow_input_sha256 = format!(
10300 "sha256:{}",
10301 crate::hashing::sha256_hex(&serde_json::to_vec(&input)?)
10302 );
10303 let tool_id = format!("workflow_host_{}", &uuid::Uuid::new_v4().to_string()[..8]);
10304 let tool_started = Instant::now();
10305 let tool_started_at = chrono::Utc::now().to_rfc3339();
10306
10307 emit_exec_stream_event(&ExecStreamEvent::ToolUse {
10308 name: "workflow".to_string(),
10309 id: tool_id.clone(),
10310 input: input.clone(),
10311 started_at: tool_started_at.clone(),
10312 })?;
10313
10314 let (event_tx, event_rx) = tokio::sync::mpsc::channel(1024);
10315 let (stop_tx, stop_rx) = tokio::sync::oneshot::channel();
10316 let event_forwarder = tokio::spawn(forward_direct_workflow_events(event_rx, stop_rx));
10317 let (tool, context) = match build_direct_workflow_tool(
10318 &execution_config,
10319 &route,
10320 &workspace,
10321 event_tx,
10322 plugin_registry,
10323 )
10324 .await
10325 {
10326 Ok(built) => built,
10327 Err(err) => {
10328 let _ = stop_tx.send(());
10329 let _ = event_forwarder.await;
10330 exit_workflow_tool_error(&tool_id, err.to_string());
10331 }
10332 };
10333
10334 let result = tool.execute(input, &context).await;
10335 drop(tool);
10336 let _ = stop_tx.send(());
10337 event_forwarder
10338 .await
10339 .context("workflow event forwarder task failed")??;
10340
10341 let result = match result {
10342 Ok(result) => result,
10343 Err(err) => {
10344 let error = err.to_string();
10345 exit_workflow_tool_error(&tool_id, error);
10346 }
10347 };
10348
10349 let workflow_status =
10350 direct_workflow_status(&result.content).unwrap_or_else(|| "unknown".to_string());
10351 let completed = result.success && workflow_status == "completed";
10352 emit_exec_stream_event(&ExecStreamEvent::ToolResult {
10353 id: tool_id,
10354 name: "workflow".to_string(),
10355 output: result.content.clone(),
10356 status: if completed { "success" } else { "error" }.to_string(),
10357 started_at: tool_started_at,
10358 completed_at: chrono::Utc::now().to_rfc3339(),
10359 duration_ms: u64::try_from(tool_started.elapsed().as_millis()).unwrap_or(u64::MAX),
10360 side_effect_status: result
10361 .metadata
10362 .as_ref()
10363 .and_then(|metadata| metadata.get("side_effect_status"))
10364 .and_then(serde_json::Value::as_str)
10365 .unwrap_or("unknown")
10366 .to_string(),
10367 error_category: (!completed).then(|| "tool_error".to_string()),
10368 truncated: result
10369 .metadata
10370 .as_ref()
10371 .and_then(|metadata| metadata.get("truncated"))
10372 .and_then(serde_json::Value::as_bool),
10373 artifact: tool_artifact_receipt(result.metadata.as_ref()),
10374 result_metadata: result.metadata.clone(),
10375 })?;
10376 emit_exec_stream_event(&ExecStreamEvent::Metadata {
10377 meta: Box::new(ExecStreamMeta {
10378 receipt_kind: "terminal",
10379 provider: route_provider,
10380 provider_id: route_provider_id,
10381 model: "host-workflow".to_string(),
10385 route_source: "host_workflow".to_string(),
10386 input_tokens: None,
10387 output_tokens: None,
10388 prompt_cache_hit_tokens: None,
10389 prompt_cache_miss_tokens: None,
10390 prompt_cache_write_tokens: None,
10391 reasoning_tokens: None,
10392 duration_ms: u64::try_from(tool_started.elapsed().as_millis()).unwrap_or(u64::MAX),
10393 retry_count: None,
10394 approval_posture: "explicit_workflow_command".to_string(),
10395 sandbox_posture: "configured".to_string(),
10396 binary_sha256: current_binary_sha256(),
10397 config_sha256: None,
10398 prompt_sha256: workflow_input_sha256,
10399 tool_catalog_sha256: None,
10400 input_analysis: ExecStreamInputAnalysis::default(),
10401 visible_final_answer_chars: result.content.chars().count(),
10402 session_id: String::new(),
10403 resume_command: String::new(),
10404 workspace: workspace.display().to_string(),
10405 message_count: 0,
10406 status: Some(workflow_status.clone()),
10407 termination_reason: Some(if completed { "resolved" } else { "tool_error" }.to_string()),
10408 error_category: (!completed).then(|| "tool".to_string()),
10409 error: (!completed)
10410 .then(|| format!("workflow run ended with terminal status {workflow_status}")),
10411 }),
10412 })?;
10413 if !completed {
10414 let error = format!("workflow run ended with terminal status {workflow_status}");
10415 emit_exec_stream_event(&ExecStreamEvent::Error {
10416 error: error.clone(),
10417 })?;
10418 exit_workflow_tool_failure();
10419 }
10420 emit_exec_stream_event(&ExecStreamEvent::Done)?;
10421 Ok(())
10422}
10423
10424fn exit_workflow_tool_failure() -> ! {
10425 let _ = io::stdout().flush();
10426 std::process::exit(1)
10427}
10428
10429fn exit_workflow_tool_error(tool_id: &str, error: String) -> ! {
10430 let now = chrono::Utc::now().to_rfc3339();
10431 let _ = emit_exec_stream_event(&ExecStreamEvent::ToolResult {
10432 id: tool_id.to_string(),
10433 name: "workflow".to_string(),
10434 output: error.clone(),
10435 status: "error".to_string(),
10436 started_at: now.clone(),
10437 completed_at: now,
10438 duration_ms: 0,
10439 side_effect_status: "unknown".to_string(),
10440 error_category: Some("execution_failed".to_string()),
10441 truncated: None,
10442 artifact: None,
10443 result_metadata: None,
10444 });
10445 let _ = emit_exec_stream_event(&ExecStreamEvent::Error { error });
10446 exit_workflow_tool_failure()
10447}
10448
10449async fn initialize_direct_workflow_mcp_pool(
10450 config: &Config,
10451 workspace: &Path,
10452 network_policy: Option<crate::network_policy::NetworkPolicyDecider>,
10453 plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
10454) -> Option<(
10455 std::sync::Arc<tokio::sync::Mutex<crate::mcp::McpPool>>,
10456 Vec<(String, String)>,
10457)> {
10458 if !config.features().enabled(Feature::Mcp) {
10459 return None;
10460 }
10461 let mut pool = crate::mcp::McpPool::from_config_path_with_workspace_and_plugins(
10462 &config.mcp_config_path(),
10463 workspace,
10464 plugin_registry,
10465 )
10466 .unwrap_or_else(|error| {
10467 tracing::debug!("No MCP config for direct Workflow runtime: {error:#}");
10468 crate::mcp::McpPool::new(crate::mcp::McpConfig::default())
10469 });
10470 if let Some(policy) = network_policy {
10471 pool = pool.with_network_policy(policy);
10472 }
10473 let failures = pool
10474 .connect_all()
10475 .await
10476 .into_iter()
10477 .map(|(server, error)| (server, format!("{error:#}")))
10478 .collect();
10479 Some((std::sync::Arc::new(tokio::sync::Mutex::new(pool)), failures))
10480}
10481
10482async fn build_direct_workflow_tool(
10483 config: &Config,
10484 route: &CliAutoRoute,
10485 workspace: &Path,
10486 event_tx: tokio::sync::mpsc::Sender<crate::core::events::Event>,
10487 plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
10488) -> Result<(
10489 crate::tools::workflow::WorkflowTool,
10490 crate::tools::ToolContext,
10491)> {
10492 use std::sync::Arc;
10493
10494 use crate::client::DeepSeekClient;
10495 use crate::core::authority::shell_policy_for_mode;
10496 use crate::fleet::roster::FleetRoster;
10497 use crate::tools::AgentToolSurfaceOptions;
10498 use crate::tools::goal::new_shared_goal_state;
10499 use crate::tools::subagent::{SubAgentRuntime, new_shared_subagent_manager_with_timeout};
10500 use crate::tools::todo::new_shared_todo_list;
10501 use crate::tui::app::AppMode;
10502
10503 let provider = config.api_provider();
10504 if !config.subagents_enabled_for_provider(provider) {
10505 bail!(
10506 "Workflow dispatch requires sub-agents for provider {} ({})",
10507 provider.as_str(),
10508 config
10509 .subagents_disabled_reason()
10510 .unwrap_or("provider-specific sub-agent configuration disabled it")
10511 );
10512 }
10513
10514 let yolo = config.yolo.unwrap_or(false);
10515 let mode = if yolo {
10516 AppMode::Yolo
10517 } else {
10518 AppMode::Operate
10519 };
10520 let allow_shell = yolo || config.allow_shell();
10521 let shell_policy = shell_policy_for_mode(mode, allow_shell);
10522 let trusted = crate::workspace_trust::WorkspaceTrust::load_for(workspace);
10523 let mut context = crate::tools::ToolContext::with_auto_approve(
10524 workspace.to_path_buf(),
10525 yolo,
10526 config.notes_path(),
10527 config.mcp_config_path(),
10528 yolo,
10529 )
10530 .with_features(config.features())
10531 .with_skills_config(
10532 config.skills_dir(),
10533 config.skills_config().scan_codewhale_only(),
10534 )
10535 .with_plugin_registry(std::sync::Arc::clone(&plugin_registry))
10536 .with_shell_policy(shell_policy)
10537 .with_trusted_external_paths(trusted.paths().to_vec())
10538 .with_elevated_sandbox_policy(crate::core::authority::sandbox_policy_for_turn(
10539 mode,
10540 if yolo {
10541 crate::tui::approval::ApprovalMode::Bypass
10542 } else {
10543 crate::tui::approval::ApprovalMode::Suggest
10544 },
10545 config.sandbox_mode.as_deref(),
10546 workspace,
10547 ));
10548 let network_policy = config.network.clone().map(|network| {
10549 crate::network_policy::NetworkPolicyDecider::with_default_audit(network.into_runtime())
10550 });
10551 if let Some(policy) = network_policy.as_ref() {
10552 context = context.with_network_policy(policy.clone());
10553 }
10554 if config.memory_enabled() {
10555 context.memory_path = Some(config.memory_path());
10556 }
10557 context.search_provider = config.search_provider();
10558 context.search_api_key = config
10559 .search
10560 .as_ref()
10561 .and_then(|search| search.api_key.clone());
10562 context.search_base_url = config
10563 .search
10564 .as_ref()
10565 .and_then(|search| search.base_url.clone());
10566 if let Some(backend) = crate::sandbox::backend::create_backend(config)? {
10567 context = context.with_sandbox_backend(Arc::from(backend));
10568 }
10569
10570 let max_subagents = config.max_subagents_for_provider(provider);
10571 let manager = new_shared_subagent_manager_with_timeout(
10572 workspace.to_path_buf(),
10573 max_subagents,
10574 config
10575 .max_admitted_subagents_for_provider(provider)
10576 .max(max_subagents),
10577 Duration::from_secs(config.subagent_heartbeat_timeout_secs_for_provider(provider)),
10578 config.launch_concurrency_for_provider(provider),
10579 config.subagent_token_budget_for_provider(provider),
10580 );
10581 let roster = Arc::new(FleetRoster::load(&config.fleet_config(), workspace));
10582 let mut role_models = roster.model_overrides();
10583 role_models.extend(config.subagent_model_overrides());
10584
10585 let features = config.features();
10586 let mut surface = AgentToolSurfaceOptions::new(shell_policy);
10587 surface.apply_patch_enabled = features.enabled(Feature::ApplyPatch);
10588 surface.web_search_enabled = features.enabled(Feature::WebSearch);
10589 surface.memory_tool_enabled = config.memory_enabled();
10590 surface.vision_config = features
10591 .enabled(Feature::VisionModel)
10592 .then(|| config.vision_model_config())
10593 .flatten();
10594 surface.speech_output_dir = config.speech_output_dir();
10595 surface.goal_state = Some(new_shared_goal_state());
10596
10597 let client = DeepSeekClient::new(config)?;
10598 let reasoning_effort_auto = route.auto_controls_reasoning;
10605 let reasoning_effort = route
10606 .reasoning_effort
10607 .and_then(|effort| cli_reasoning_effort_value(config, &route.model, effort));
10608 let mcp_pool = if let Some((pool, failures)) =
10609 initialize_direct_workflow_mcp_pool(config, workspace, network_policy, plugin_registry)
10610 .await
10611 {
10612 for (server, error) in failures {
10613 tracing::warn!(
10614 server = %server,
10615 error = %error,
10616 "direct Workflow runtime could not connect MCP server"
10617 );
10618 }
10619 Some(pool)
10620 } else {
10621 None
10622 };
10623 let runtime = SubAgentRuntime::new(
10624 client,
10625 route.model.clone(),
10626 context.clone(),
10627 allow_shell,
10628 Some(event_tx),
10629 manager.clone(),
10630 )
10631 .with_locale_tag(
10632 crate::localization::resolve_locale(
10633 &crate::settings::Settings::load_persisted()
10634 .unwrap_or_default()
10635 .locale,
10636 )
10637 .tag(),
10638 )
10639 .with_role_models(role_models)
10640 .with_api_config(config.clone())
10641 .with_fleet_roster(roster)
10642 .with_auto_model(route.auto_model)
10643 .with_reasoning_effort(reasoning_effort, reasoning_effort_auto)
10644 .with_agent_tool_surface_options(surface)
10645 .with_max_spawn_depth(config.subagent_max_spawn_depth_for_provider(provider))
10646 .with_step_api_timeout(Duration::from_secs(
10647 config.subagent_api_timeout_secs_for_provider(provider),
10648 ))
10649 .with_speech_output_dir(config.speech_output_dir())
10650 .with_mcp_pool(mcp_pool)
10651 .with_todos(new_shared_todo_list())
10652 .with_parent_mode(mode);
10653
10654 Ok((
10655 crate::tools::workflow::WorkflowTool::new(manager, runtime).with_explicit_cli_approval(),
10656 context,
10657 ))
10658}
10659
10660async fn forward_direct_workflow_events(
10661 mut event_rx: tokio::sync::mpsc::Receiver<crate::core::events::Event>,
10662 mut stop_rx: tokio::sync::oneshot::Receiver<()>,
10663) -> Result<()> {
10664 loop {
10665 tokio::select! {
10666 biased;
10667 event = event_rx.recv() => match event {
10668 Some(event) => emit_direct_workflow_event(event)?,
10669 None => return Ok(()),
10670 },
10671 _ = &mut stop_rx => {
10672 while let Ok(event) = event_rx.try_recv() {
10673 emit_direct_workflow_event(event)?;
10674 }
10675 return Ok(());
10676 }
10677 }
10678 }
10679}
10680
10681fn emit_direct_workflow_event(event: crate::core::events::Event) -> Result<()> {
10682 if let crate::core::events::Event::WorkflowUi { run_id, event } = event {
10683 emit_exec_stream_event(&ExecStreamEvent::WorkflowEvent { run_id, event })?;
10684 }
10685 Ok(())
10686}
10687
10688fn direct_workflow_status(content: &str) -> Option<String> {
10689 serde_json::from_str::<serde_json::Value>(content)
10690 .ok()?
10691 .get("status")?
10692 .as_str()
10693 .map(str::to_ascii_lowercase)
10694}
10695
10696fn exec_stream_input_analysis(
10697 messages: &[Message],
10698 system: Option<&SystemPrompt>,
10699) -> ExecStreamInputAnalysis {
10700 let mut analysis = ExecStreamInputAnalysis {
10701 estimated_request_tokens: crate::compaction::estimate_input_tokens_conservative(
10702 messages, system,
10703 ),
10704 estimated_message_content_tokens: crate::compaction::estimate_tokens(messages),
10705 estimated_system_tokens: exec_stream_estimate_system_tokens(system),
10706 estimated_framing_tokens: messages.len().saturating_mul(12).saturating_add(48),
10707 ..ExecStreamInputAnalysis::default()
10708 };
10709
10710 for message in messages {
10711 match message.role.as_str() {
10712 "user" => analysis.user_message_count += 1,
10713 "assistant" => analysis.assistant_message_count += 1,
10714 "tool" => analysis.tool_message_count += 1,
10715 _ => {}
10716 }
10717
10718 for block in &message.content {
10719 match block {
10720 ContentBlock::Text { text, .. } => {
10721 exec_stream_add_text_estimate(
10722 text,
10723 &mut analysis.text_chars,
10724 &mut analysis.text_estimated_tokens,
10725 );
10726 }
10727 ContentBlock::Thinking { thinking, .. } => {
10728 exec_stream_add_text_estimate(
10729 thinking,
10730 &mut analysis.thinking_chars,
10731 &mut analysis.thinking_estimated_tokens,
10732 );
10733 }
10734 ContentBlock::ToolUse { input, .. } | ContentBlock::ServerToolUse { input, .. } => {
10735 analysis.tool_use_count += 1;
10736 exec_stream_add_json_estimate(
10737 input,
10738 &mut analysis.tool_use_input_chars,
10739 &mut analysis.tool_use_input_estimated_tokens,
10740 );
10741 }
10742 ContentBlock::ToolResult {
10743 content,
10744 content_blocks,
10745 ..
10746 } => {
10747 analysis.tool_result_count += 1;
10748 exec_stream_add_text_estimate(
10749 content,
10750 &mut analysis.tool_result_chars,
10751 &mut analysis.tool_result_estimated_tokens,
10752 );
10753 if let Some(blocks) = content_blocks {
10754 exec_stream_add_json_estimate(
10755 blocks,
10756 &mut analysis.tool_result_chars,
10757 &mut analysis.tool_result_estimated_tokens,
10758 );
10759 }
10760 }
10761 ContentBlock::ToolSearchToolResult { content, .. }
10762 | ContentBlock::CodeExecutionToolResult { content, .. } => {
10763 analysis.tool_result_count += 1;
10764 exec_stream_add_json_estimate(
10765 content,
10766 &mut analysis.tool_result_chars,
10767 &mut analysis.tool_result_estimated_tokens,
10768 );
10769 }
10770 ContentBlock::ImageUrl { .. } => {}
10771 }
10772 }
10773 }
10774
10775 analysis
10776}
10777
10778fn exec_stream_add_text_estimate(text: &str, chars: &mut usize, tokens: &mut usize) {
10779 *chars = chars.saturating_add(text.chars().count());
10780 *tokens = tokens.saturating_add(crate::compaction::estimate_text_tokens_conservative(text));
10781}
10782
10783fn exec_stream_add_json_estimate<T: serde::Serialize>(
10784 value: &T,
10785 chars: &mut usize,
10786 tokens: &mut usize,
10787) {
10788 let text = serde_json::to_string(value).unwrap_or_default();
10789 exec_stream_add_text_estimate(&text, chars, tokens);
10790}
10791
10792fn exec_stream_estimate_system_tokens(system: Option<&SystemPrompt>) -> usize {
10793 match system {
10794 Some(SystemPrompt::Text(text)) => {
10795 crate::compaction::estimate_text_tokens_conservative(text)
10796 }
10797 Some(SystemPrompt::Blocks(blocks)) => blocks
10798 .iter()
10799 .map(|block| crate::compaction::estimate_text_tokens_conservative(&block.text))
10800 .sum(),
10801 None => 0,
10802 }
10803}
10804
10805fn exec_saved_session_line(session_id: &str) -> String {
10806 format!("session: {}", truncate_id(session_id))
10807}
10808
10809fn exec_resumed_session_line(session_id: &str) -> String {
10810 format!("resumed session: {}", truncate_id(session_id))
10811}
10812
10813fn exec_stream_session_ref(session_id: &str) -> String {
10814 crate::utils::redacted_identifier_for_log(session_id)
10815}
10816
10817fn exec_stream_resume_hint(session_id: &str) -> String {
10818 if session_id.trim().is_empty() {
10819 String::new()
10820 } else {
10821 "codewhale exec --resume <redacted-session-id>".to_string()
10822 }
10823}
10824
10825#[derive(Clone, Copy)]
10826struct PersistedProviderRoute<'a> {
10827 kind: &'a str,
10828 id: Option<&'a str>,
10829}
10830
10831fn persist_exec_session(
10832 messages: &[Message],
10833 model: &str,
10834 provider_route: PersistedProviderRoute<'_>,
10835 workspace: &Path,
10836 system_prompt: &Option<SystemPrompt>,
10837 session_id: Option<&str>,
10838 total_tokens: u64,
10839) -> Result<String> {
10840 let manager =
10841 SessionManager::default_location().context("could not open session manager for save")?;
10842 let mut saved = if let Some(id) = session_id.filter(|id| !id.trim().is_empty()) {
10843 match manager.load_session(id) {
10844 Ok(existing) => session_manager::update_session(
10845 existing,
10846 messages,
10847 total_tokens,
10848 system_prompt.as_ref(),
10849 ),
10850 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
10851 session_manager::create_saved_session_with_id_and_mode(
10852 id.to_string(),
10853 messages,
10854 model,
10855 workspace,
10856 total_tokens,
10857 system_prompt.as_ref(),
10858 Some("exec"),
10859 )
10860 }
10861 Err(err) => return Err(err).context("could not load existing exec session"),
10862 }
10863 } else {
10864 session_manager::create_saved_session_with_mode(
10865 messages,
10866 model,
10867 workspace,
10868 total_tokens,
10869 system_prompt.as_ref(),
10870 Some("exec"),
10871 )
10872 };
10873 stamp_exec_session_metadata(
10874 &mut saved,
10875 model,
10876 provider_route.kind,
10877 provider_route.id,
10878 workspace,
10879 );
10880 let id = saved.metadata.id.clone();
10881 manager
10882 .save_session(&saved)
10883 .context("could not save exec session")?;
10884 Ok(id)
10885}
10886
10887fn stamp_exec_session_metadata(
10888 saved: &mut session_manager::SavedSession,
10889 model: &str,
10890 model_provider_kind: &str,
10891 model_provider_id: Option<&str>,
10892 workspace: &Path,
10893) {
10894 saved.metadata.model = model.to_string();
10895 saved
10896 .metadata
10897 .set_model_provider_route(model_provider_kind, model_provider_id);
10898 saved.metadata.workspace = workspace.to_path_buf();
10899 saved.metadata.mode = Some("exec".to_string());
10900}
10901
10902#[derive(serde::Serialize)]
10903struct ExecToolEntry {
10904 name: String,
10905 success: bool,
10906 output: String,
10907}
10908
10909#[derive(serde::Serialize)]
10910struct ExecOutcome {
10911 kind: String,
10912 outcome: String,
10913 tool_name: String,
10914 reason: String,
10915}
10916
10917#[derive(serde::Serialize, Default)]
10918struct ExecSummary {
10919 mode: String,
10920 provider: String,
10921 model: String,
10922 prompt: String,
10923 output: String,
10924 tools: Vec<ExecToolEntry>,
10925 outcomes: Vec<ExecOutcome>,
10926 status: Option<String>,
10927 termination_reason: Option<String>,
10928 error_category: Option<String>,
10929 error: Option<String>,
10930 #[serde(skip_serializing_if = "Vec::is_empty")]
10931 released_services: Vec<crate::tools::shell::PersistentServiceReceipt>,
10932}
10933
10934fn validate_exec_tool_authority_resume(
10935 tool_authority_json: Option<&str>,
10936 resuming: bool,
10937) -> Result<()> {
10938 if tool_authority_json.is_some() && resuming {
10939 bail!(
10940 "Fleet tool authority cannot be combined with exec --resume, --session-id, or --continue"
10941 );
10942 }
10943 Ok(())
10944}
10945
10946fn exec_network_policy(
10947 config: &Config,
10948 outer_network_access: Option<bool>,
10949) -> Option<crate::network_policy::NetworkPolicyDecider> {
10950 if outer_network_access == Some(false) {
10953 return Some(crate::network_policy::NetworkPolicyDecider::new(
10954 crate::network_policy::NetworkPolicy {
10955 default: crate::network_policy::DecisionToml::Deny,
10956 ..crate::network_policy::NetworkPolicy::default()
10957 },
10958 None,
10959 ));
10960 }
10961 config.network.clone().map(|toml_cfg| {
10962 crate::network_policy::NetworkPolicyDecider::with_default_audit(toml_cfg.into_runtime())
10963 })
10964}
10965
10966fn apply_fleet_engine_feature_caps(
10967 features: &mut crate::features::Features,
10968 fleet_authority_active: bool,
10969 outer_network_access: Option<bool>,
10970 shell_authority: crate::tools::spec::ToolShellAuthority,
10971) {
10972 if fleet_authority_active {
10973 features.disable(crate::features::Feature::Subagents);
10974 features.disable(crate::features::Feature::Mcp);
10975 if shell_authority != crate::tools::spec::ToolShellAuthority::ReadOnly {
10976 features.disable(crate::features::Feature::ShellTool);
10977 }
10978 }
10979 if outer_network_access == Some(false) {
10980 features.disable(crate::features::Feature::WebSearch);
10981 }
10982}
10983
10984fn exec_max_steps(max_turns: Option<u32>) -> u32 {
10988 max_turns.unwrap_or(u32::MAX)
10989}
10990
10991#[allow(clippy::too_many_arguments)]
10992async fn run_exec_agent(
10993 config: &Config,
10994 model: &str,
10995 prompt: &str,
10996 workspace: PathBuf,
10997 max_subagents: usize,
10998 auto_approve: bool,
10999 allow_sandbox_elevation: bool,
11000 explicit_sandbox: Option<&str>,
11001 trust_mode: bool,
11002 json_output: bool,
11003 resume_session: Option<session_manager::SavedSession>,
11004 force_configured_route: bool,
11005 output_format: ExecOutputFormat,
11006 max_turns: u32,
11007 allowed_tools: Option<Vec<String>>,
11008 disallowed_tools: Option<Vec<String>>,
11009 append_system_prompt: Option<String>,
11010 tool_authority_json: Option<String>,
11011 plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
11012) -> Result<()> {
11013 use crate::compaction::CompactionConfig;
11014 use crate::core::engine::{EngineConfig, spawn_engine};
11015 use crate::core::events::Event;
11016 use crate::core::ops::Op;
11017 use crate::tools::plan::new_shared_plan_state;
11018 use crate::tools::todo::new_shared_todo_list;
11019 use crate::tui::app::AppMode;
11020
11021 validate_exec_tool_authority_resume(tool_authority_json.as_deref(), resume_session.is_some())?;
11022 let fleet_authority = tool_authority_json
11023 .as_deref()
11024 .map(crate::tools::spec::ToolAuthorityEnvelope::from_json)
11025 .transpose()
11026 .map_err(anyhow::Error::msg)?;
11027 let fleet_authority_active = fleet_authority.is_some();
11028 let outer_network_access = fleet_authority
11029 .as_ref()
11030 .and_then(|authority| authority.network_access);
11031 let outer_shell_authority = fleet_authority
11032 .as_ref()
11033 .map(|authority| authority.shell)
11034 .unwrap_or_default();
11035 if let Some(envelope) = fleet_authority {
11036 crate::tools::spec::install_process_tool_authority(envelope).map_err(anyhow::Error::msg)?;
11037 }
11038
11039 let route = resolve_cli_exec_route(config, model, prompt, force_configured_route).await?;
11040 let execution_config = config_for_cli_route(config, &route);
11041 let auto_model = route.auto_model;
11042 let effective_provider = route.provider;
11043 let effective_model = route.model;
11044 let validated_route = crate::route_runtime::resolve_runtime_route(
11045 &execution_config,
11046 effective_provider,
11047 Some(&effective_model),
11048 )
11049 .map_err(anyhow::Error::msg)?
11050 .validate()
11051 .map_err(anyhow::Error::msg)?;
11052 let effective_provider_name = validated_route.identity.key.clone();
11053 let effective_provider_id = validated_route.identity.exact_id.clone();
11054 let (effective_provider_kind, effective_stream_provider_id) =
11055 exec_stream_provider_route(&validated_route.identity);
11056 let route_source = if auto_model {
11057 "auto_resolver"
11058 } else {
11059 "explicit_or_configured"
11060 }
11061 .to_string();
11062 let exec_started = Instant::now();
11063 let prompt_sha256 = format!("sha256:{}", crate::hashing::sha256_hex(prompt.as_bytes()));
11064 let binary_sha256 = current_binary_sha256();
11065 let approval_posture = if auto_approve { "auto_tools" } else { "ask" }.to_string();
11066 let sandbox_posture = explicit_sandbox.unwrap_or("configured_default").to_string();
11067 let active_route_limits =
11068 crate::route_budget::known_route_limits(validated_route.candidate.limits());
11069 let max_subagents = if max_subagents == config.max_subagents_for_provider(config.api_provider())
11070 {
11071 execution_config
11072 .max_subagents_for_provider(effective_provider)
11073 .clamp(1, MAX_SUBAGENTS)
11074 } else {
11075 max_subagents
11076 };
11077 let reasoning_effort_auto = route.auto_controls_reasoning;
11084 let effective_reasoning_effort = route.reasoning_effort.and_then(|effort| {
11088 cli_reasoning_effort_value_for_prompt(&execution_config, &effective_model, effort, prompt)
11089 });
11090
11091 let settings = crate::settings::Settings::load().unwrap_or_default();
11092 let auto_compact_enabled = if crate::settings::Settings::auto_compact_explicitly_configured() {
11093 settings.auto_compact
11094 } else {
11095 crate::route_budget::auto_compact_default_for_route(
11096 effective_provider,
11097 &effective_model,
11098 active_route_limits,
11099 )
11100 };
11101 let compaction = CompactionConfig {
11102 enabled: auto_compact_enabled,
11103 model: effective_model.clone(),
11104 effective_context_window: Some(crate::route_budget::route_context_window_tokens(
11105 effective_provider,
11106 &effective_model,
11107 active_route_limits,
11108 )),
11109 token_threshold: crate::route_budget::compaction_threshold_for_route_at_percent(
11110 effective_provider,
11111 &effective_model,
11112 active_route_limits,
11113 settings.auto_compact_threshold_percent,
11114 ),
11115 ..Default::default()
11116 };
11117
11118 let network_policy = exec_network_policy(&execution_config, outer_network_access);
11119
11120 let lsp_config = (!fleet_authority_active)
11121 .then(|| {
11122 execution_config
11123 .lsp
11124 .clone()
11125 .map(crate::config::LspConfigToml::into_runtime)
11126 })
11127 .flatten();
11128 let mut engine_features = execution_config.features();
11129 apply_fleet_engine_feature_caps(
11130 &mut engine_features,
11131 fleet_authority_active,
11132 outer_network_access,
11133 outer_shell_authority,
11134 );
11135 if crate::core::allowlist_is_native_file_and_shell_only(allowed_tools.as_deref()) {
11136 engine_features.disable(crate::features::Feature::Mcp);
11137 }
11138 let engine_plugin_registry = if fleet_authority_active {
11139 std::sync::Arc::new(crate::plugins::PluginRegistry::empty(&workspace))
11140 } else {
11141 plugin_registry
11142 };
11143 let exec_allow_shell = crate::tools::spec::fleet_exec_shell_enabled(
11144 fleet_authority_active,
11145 outer_shell_authority,
11146 disallowed_tools.as_deref(),
11147 ) || (!fleet_authority_active
11148 && (auto_approve || execution_config.allow_shell()));
11149 let persist_services_enabled = cfg!(unix)
11150 && !fleet_authority_active
11151 && exec_allow_shell
11152 && explicit_sandbox
11153 .is_some_and(|sandbox| sandbox.eq_ignore_ascii_case("danger-full-access"));
11154 let exec_shell_manager = crate::tools::shell::new_shared_shell_manager(workspace.clone());
11155 let runtime_services = crate::tools::spec::RuntimeToolServices {
11156 shell_manager: Some(exec_shell_manager.clone()),
11157 persist_services_enabled,
11158 ..crate::tools::spec::RuntimeToolServices::default()
11159 };
11160
11161 let engine_config = EngineConfig {
11162 model: effective_model.clone(),
11163 active_route_limits,
11164 workspace: workspace.clone(),
11165 subagent_state_root: None,
11166 plugin_registry: Some(engine_plugin_registry),
11167 allow_shell: exec_allow_shell,
11168 trust_mode,
11169 notes_path: execution_config.notes_path(),
11170 mcp_config_path: execution_config.mcp_config_path(),
11171 skills_dir: execution_config.skills_dir(),
11172 skills_scan_codewhale_only: execution_config.skills_config().scan_codewhale_only(),
11173 instructions: {
11174 let mut instrs: Vec<crate::prompts::InstructionSource> = execution_config
11175 .instructions_paths()
11176 .into_iter()
11177 .map(Into::into)
11178 .collect();
11179 if let Some(ref extra) = append_system_prompt {
11180 instrs.push(crate::prompts::InstructionSource::Inline {
11181 name: "cli:append-system-prompt".into(),
11182 content: extra.clone(),
11183 });
11184 }
11185 instrs
11186 },
11187 project_context_pack_enabled: execution_config.project_context_pack_enabled(),
11188 translation_enabled: false,
11189 max_steps: max_turns,
11190 max_subagents,
11191 max_admitted_subagents: execution_config
11192 .max_admitted_subagents_for_provider(effective_provider)
11193 .max(max_subagents),
11194 launch_concurrency: execution_config.launch_concurrency_for_provider(effective_provider),
11195 subagents_enabled: !fleet_authority_active
11196 && execution_config.subagents_enabled_for_provider(effective_provider),
11197 features: engine_features,
11198 auto_review_policy: execution_config.auto_review_policy(),
11199 compaction: compaction.clone(),
11200 todos: new_shared_todo_list(),
11201 plan_state: new_shared_plan_state(),
11202 goal_state: crate::tools::goal::new_shared_goal_state(),
11203 max_spawn_depth: if fleet_authority_active {
11204 0
11205 } else {
11206 execution_config.subagent_max_spawn_depth_for_provider(effective_provider)
11207 },
11208 subagent_token_budget: execution_config
11209 .subagent_token_budget_for_provider(effective_provider),
11210 network_policy,
11211 snapshots_enabled: !fleet_authority_active && execution_config.snapshots_config().enabled,
11212 snapshots_max_workspace_bytes: execution_config
11213 .snapshots_config()
11214 .max_workspace_gb
11215 .saturating_mul(1024 * 1024 * 1024),
11216 lsp_config,
11217 runtime_services,
11218 subagent_model_overrides: execution_config.subagent_model_overrides(),
11219 fleet_roster: std::sync::Arc::new(crate::fleet::roster::FleetRoster::load(
11220 &execution_config.fleet_config(),
11221 &workspace,
11222 )),
11223 subagent_api_timeout: std::time::Duration::from_secs(
11224 execution_config.subagent_api_timeout_secs_for_provider(effective_provider),
11225 ),
11226 stream_chunk_timeout: std::time::Duration::from_secs(
11227 execution_config.stream_chunk_timeout_secs(),
11228 ),
11229 subagent_heartbeat_timeout: std::time::Duration::from_secs(
11230 execution_config.subagent_heartbeat_timeout_secs_for_provider(effective_provider),
11231 ),
11232 prefer_bwrap: execution_config.prefer_bwrap.unwrap_or(false),
11233 memory_enabled: execution_config.memory_enabled(),
11234 memory_path: execution_config.memory_path(),
11235 speech_output_dir: execution_config.speech_output_dir(),
11236 vision_config: execution_config.vision_model_config(),
11237 strict_tool_mode: execution_config.strict_tool_mode.unwrap_or(false),
11238 goal_objective: None,
11239 goal_token_budget: None,
11240 goal_status: crate::tools::goal::GoalStatus::Active,
11241 goal_max_continuations: execution_config.goal_max_continuations(),
11242 allowed_tools: allowed_tools.clone(),
11243 disallowed_tools: disallowed_tools.clone(),
11244 max_tool_calls: None,
11245 hook_executor: None,
11246 locale_tag: crate::localization::resolve_locale(&settings.locale)
11247 .tag()
11248 .to_string(),
11249 workshop: config.workshop.clone(),
11250 search_provider: execution_config.search_provider(),
11251 search_api_key: execution_config
11252 .search
11253 .as_ref()
11254 .and_then(|s| s.api_key.clone()),
11255 search_base_url: execution_config
11256 .search
11257 .as_ref()
11258 .and_then(|s| s.base_url.clone()),
11259 tools_always_load: if fleet_authority_active {
11260 std::collections::HashSet::new()
11261 } else {
11262 execution_config.tools_always_load()
11263 },
11264 tools: if fleet_authority_active {
11265 None
11266 } else {
11267 execution_config.tools.clone()
11268 },
11269 verbosity: execution_config.verbosity.clone(),
11270 workspace_follow_symlinks: settings.workspace_follow_symlinks,
11271 exec_policy_engine: execution_config.exec_policy_engine.clone(),
11272 terminal_chrome_enabled: false,
11273 advisor_config: execution_config
11274 .advisor
11275 .as_ref()
11276 .map(crate::tools::subagent::AdvisorConfig::from_toml)
11277 .unwrap_or_else(crate::tools::subagent::AdvisorConfig::disabled),
11278 };
11279
11280 let engine_handle = spawn_engine(engine_config, &execution_config);
11281 let mode = if auto_approve {
11282 AppMode::Yolo
11283 } else {
11284 AppMode::Agent
11285 };
11286
11287 let resuming_session = resume_session.is_some();
11288 let mut loaded_session_id = None;
11289 if let Some(saved) = resume_session {
11290 let saved_id = saved.metadata.id.clone();
11291 if saved.metadata.workspace != workspace && output_format == ExecOutputFormat::Text {
11292 eprintln!(
11293 "Warning: session {} was created in a different workspace ({}). Resuming anyway.",
11294 truncate_id(&saved_id),
11295 saved.metadata.workspace.display(),
11296 );
11297 }
11298
11299 engine_handle
11300 .send(Op::SyncSession {
11301 session_id: Some(saved_id.clone()),
11302 messages: saved.messages,
11303 system_prompt: saved.system_prompt.map(SystemPrompt::Text),
11304 system_prompt_override: false,
11305 model: saved.metadata.model,
11306 workspace: saved.metadata.workspace,
11307 mode,
11308 })
11309 .await?;
11310 loaded_session_id = Some(saved_id.clone());
11311 if output_format == ExecOutputFormat::Text && !json_output {
11312 eprintln!("{}", exec_resumed_session_line(&saved_id));
11313 }
11314 }
11315
11316 engine_handle
11317 .send(Op::SendMessage {
11318 content: prompt.to_string(),
11319 mode,
11320 route: Box::new(validated_route.into_resolved()),
11321 compaction: Box::new(compaction.clone()),
11322 goal_objective: None,
11323 goal_token_budget: None,
11324 goal_status: crate::tools::goal::GoalStatus::Active,
11325 allowed_tools: allowed_tools.clone(),
11326 dynamic_tools: Vec::new(),
11327 hook_executor: None,
11328 reasoning_effort: effective_reasoning_effort,
11329 reasoning_effort_auto,
11330 auto_model,
11331 allow_shell: auto_approve || execution_config.allow_shell(),
11332 trust_mode,
11333 auto_approve,
11334 translation_enabled: false,
11335 approval_mode: if auto_approve {
11336 crate::tui::approval::ApprovalMode::Bypass
11337 } else {
11338 execution_config
11339 .approval_policy
11340 .as_deref()
11341 .and_then(crate::tui::approval::ApprovalMode::from_config_value)
11342 .unwrap_or_default()
11343 },
11344 verbosity: execution_config.verbosity.clone(),
11345 provenance: crate::core::ops::UserInputProvenance::ExternalUser,
11346 })
11347 .await?;
11348
11349 let mut summary = ExecSummary {
11350 mode: "agent".to_string(),
11351 provider: effective_provider_name.clone(),
11352 model: effective_model.clone(),
11353 prompt: prompt.to_string(),
11354 ..ExecSummary::default()
11355 };
11356 let can_elevate_sandbox =
11357 exec_sandbox_elevation_authorized(allow_sandbox_elevation, explicit_sandbox);
11358 let mut sandbox_denied = false;
11359 let mut approval_required = false;
11360 let mut tool_error_seen = false;
11361 let mut last_error_category = None;
11362 let mut reported_sandbox_contract = false;
11363
11364 let should_persist_session = resuming_session || output_format == ExecOutputFormat::StreamJson;
11365 let mut latest_session_id = loaded_session_id;
11366 let mut latest_messages: Vec<Message> = Vec::new();
11367 let mut latest_system_prompt: Option<SystemPrompt> = None;
11368 let mut latest_model = effective_model;
11369 let mut latest_workspace = workspace.clone();
11370 let mut tool_starts: HashMap<String, (Instant, String)> = HashMap::new();
11371 let mut turn_usage_seq: u32 = 0;
11372
11373 let mut stdout = io::stdout();
11374 let mut ends_with_newline = false;
11375 loop {
11376 let event = {
11377 let mut rx = engine_handle.rx_event.write().await;
11378 rx.recv().await
11379 };
11380
11381 let Some(event) = event else {
11382 break;
11383 };
11384
11385 match event {
11386 Event::MessageDelta { content, .. } => {
11387 summary.output.push_str(&content);
11388 if output_format == ExecOutputFormat::StreamJson {
11389 emit_exec_stream_event(&ExecStreamEvent::Content { content })?;
11390 } else if !json_output {
11391 print!("{content}");
11392 stdout.flush()?;
11393 }
11394 ends_with_newline = summary.output.ends_with('\n');
11395 }
11396 Event::MessageComplete { .. }
11397 if output_format == ExecOutputFormat::Text
11398 && !json_output
11399 && !ends_with_newline =>
11400 {
11401 println!();
11402 }
11403 Event::ThinkingDelta { .. } => {
11404 }
11407 Event::ToolCallStarted { id, name, input } => {
11408 let started_at = chrono::Utc::now().to_rfc3339();
11409 tool_starts.insert(id.clone(), (Instant::now(), started_at.clone()));
11410 if output_format == ExecOutputFormat::StreamJson {
11411 emit_exec_stream_event(&ExecStreamEvent::ToolUse {
11412 name,
11413 id,
11414 input,
11415 started_at,
11416 })?;
11417 } else if !json_output {
11418 let summary = summarize_tool_args(&input);
11419 if let Some(summary) = summary {
11420 eprintln!("tool: {name} ({summary})");
11421 } else {
11422 eprintln!("tool: {name}");
11423 }
11424 }
11425 }
11426 Event::ToolCallComplete {
11427 id, name, result, ..
11428 } => {
11429 let (duration_ms, started_at) = tool_starts
11430 .remove(&id)
11431 .map(|(started, timestamp)| {
11432 (
11433 u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
11434 timestamp,
11435 )
11436 })
11437 .unwrap_or_else(|| (0, chrono::Utc::now().to_rfc3339()));
11438 let receipt_name = name.clone();
11439 match result {
11440 Ok(output) => {
11441 tool_error_seen |= !output.success;
11442 summary.tools.push(ExecToolEntry {
11443 name: name.clone(),
11444 success: output.success,
11445 output: output.content.clone(),
11446 });
11447 if output_format == ExecOutputFormat::StreamJson {
11448 emit_exec_stream_event(&ExecStreamEvent::ToolResult {
11449 id,
11450 name: receipt_name,
11451 output: output.content,
11452 status: if output.success {
11453 "success".to_string()
11454 } else {
11455 "error".to_string()
11456 },
11457 started_at,
11458 completed_at: chrono::Utc::now().to_rfc3339(),
11459 duration_ms,
11460 side_effect_status: output
11461 .metadata
11462 .as_ref()
11463 .and_then(|metadata| metadata.get("side_effect_status"))
11464 .and_then(serde_json::Value::as_str)
11465 .unwrap_or("unknown")
11466 .to_string(),
11467 error_category: (!output.success).then(|| {
11468 output
11469 .metadata
11470 .as_ref()
11471 .and_then(|metadata| metadata.get("error_category"))
11472 .and_then(serde_json::Value::as_str)
11473 .unwrap_or("tool_reported_failure")
11474 .to_string()
11475 }),
11476 truncated: output
11477 .metadata
11478 .as_ref()
11479 .and_then(|metadata| metadata.get("truncated"))
11480 .and_then(serde_json::Value::as_bool),
11481 artifact: tool_artifact_receipt(output.metadata.as_ref()),
11482 result_metadata: output.metadata,
11483 })?;
11484 } else if !json_output {
11485 if name == "exec_shell" && !output.content.trim().is_empty() {
11486 eprintln!("tool {name} completed");
11487 eprintln!(
11488 "--- stdout/stderr ---\n{}\n---------------------",
11489 output.content
11490 );
11491 } else {
11492 eprintln!(
11493 "tool {name} completed: {}",
11494 summarize_tool_output(&output.content)
11495 );
11496 }
11497 }
11498 }
11499 Err(err) => {
11500 tool_error_seen = true;
11501 let error_text = err.to_string();
11502 summary.tools.push(ExecToolEntry {
11503 name: name.clone(),
11504 success: false,
11505 output: error_text.clone(),
11506 });
11507 if output_format == ExecOutputFormat::StreamJson {
11508 emit_exec_stream_event(&ExecStreamEvent::ToolResult {
11509 id,
11510 name: receipt_name,
11511 output: error_text,
11512 status: "error".to_string(),
11513 started_at,
11514 completed_at: chrono::Utc::now().to_rfc3339(),
11515 duration_ms,
11516 side_effect_status: "not_started_or_unknown".to_string(),
11517 error_category: Some(tool_error_receipt_category(&err).to_string()),
11518 truncated: None,
11519 artifact: None,
11520 result_metadata: None,
11521 })?;
11522 } else if !json_output {
11523 eprintln!("tool {name} failed: {err}");
11524 }
11525 }
11526 }
11527 }
11528 Event::AgentSpawned { id, prompt, .. }
11529 if output_format == ExecOutputFormat::Text && !json_output =>
11530 {
11531 eprintln!("sub-agent {id} spawned: {}", summarize_tool_output(&prompt));
11532 }
11533 Event::AgentProgress { id, status, .. }
11534 if output_format == ExecOutputFormat::Text && !json_output =>
11535 {
11536 eprintln!("sub-agent {id}: {status}");
11537 }
11538 Event::AgentComplete { id, result }
11539 if output_format == ExecOutputFormat::Text && !json_output =>
11540 {
11541 eprintln!(
11542 "sub-agent {id} completed: {}",
11543 summarize_tool_output(&result)
11544 );
11545 }
11546 Event::AgentSpawned {
11547 id,
11548 parent_run_id,
11549 spawn_depth,
11550 model,
11551 route_source,
11552 ..
11553 } if output_format == ExecOutputFormat::StreamJson => {
11554 emit_exec_stream_event(&ExecStreamEvent::AgentSpawned {
11555 id,
11556 model,
11557 spawn_depth,
11558 parent_run_id,
11559 route_source,
11560 })?;
11561 }
11562 Event::AgentSpawned { .. }
11563 | Event::AgentProgress { .. }
11564 | Event::AgentComplete { .. } => {}
11565 Event::WorkflowUi { run_id, event }
11566 if output_format == ExecOutputFormat::StreamJson =>
11567 {
11568 emit_exec_stream_event(&ExecStreamEvent::WorkflowEvent { run_id, event })?;
11569 }
11570 Event::ApprovalRequired { id, .. } => {
11571 if auto_approve {
11572 let _ = engine_handle.approve_tool_call(id).await;
11573 } else {
11574 approval_required = true;
11575 let _ = engine_handle.deny_tool_call(id).await;
11576 }
11577 }
11578 Event::ElevationRequired {
11579 tool_id,
11580 tool_name,
11581 denial_reason,
11582 ..
11583 } => {
11584 if can_elevate_sandbox {
11585 let policy = crate::sandbox::SandboxPolicy::DangerFullAccess;
11586 let _ = engine_handle.retry_tool_with_policy(tool_id, policy).await;
11587 } else {
11588 sandbox_denied = true;
11589 approval_required = true;
11590 summary.outcomes.push(ExecOutcome {
11591 kind: "sandbox_denied".to_string(),
11592 outcome: "approval_required".to_string(),
11593 tool_name: tool_name.clone(),
11594 reason: denial_reason.clone(),
11595 });
11596 if !reported_sandbox_contract {
11597 eprintln!(
11598 "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"
11599 );
11600 reported_sandbox_contract = true;
11601 }
11602 if output_format == ExecOutputFormat::StreamJson {
11603 emit_exec_stream_event(&ExecStreamEvent::SandboxDenied {
11604 tool_id: tool_id.clone(),
11605 tool_name,
11606 reason: denial_reason,
11607 outcome: "approval_required".to_string(),
11608 })?;
11609 }
11610 let _ = engine_handle.deny_tool_call(tool_id).await;
11611 }
11612 }
11613 Event::Error {
11614 envelope,
11615 recoverable: _,
11616 } => {
11617 if exec_error_event_is_fatal(&envelope) {
11625 last_error_category = Some(envelope.category);
11626 summary.error_category = Some(envelope.category.to_string());
11627 summary.error = Some(envelope.message.clone());
11628 }
11629 if output_format == ExecOutputFormat::StreamJson {
11630 emit_exec_stream_event(&ExecStreamEvent::Error {
11631 error: envelope.message,
11632 })?;
11633 } else if !json_output {
11634 eprintln!("error: {}", envelope.message);
11635 }
11636 }
11637 Event::TurnUsage { usage, duration_ms } => {
11638 if output_format == ExecOutputFormat::StreamJson {
11639 turn_usage_seq = turn_usage_seq.saturating_add(1);
11640 emit_exec_stream_event(&ExecStreamEvent::TurnUsage {
11641 turn: turn_usage_seq,
11642 input_tokens: usage.input_tokens,
11643 output_tokens: usage.output_tokens,
11644 reasoning_tokens: usage.reasoning_tokens,
11645 prompt_cache_hit_tokens: usage.prompt_cache_hit_tokens,
11646 prompt_cache_miss_tokens: usage.prompt_cache_miss_tokens,
11647 prompt_cache_write_tokens: usage.prompt_cache_write_tokens,
11648 reasoning_replay_tokens: usage.reasoning_replay_tokens,
11649 duration_ms,
11650 })?;
11651 }
11652 }
11653 Event::TurnComplete {
11654 status,
11655 error,
11656 usage,
11657 tool_catalog,
11658 ..
11659 } => {
11660 let (terminal_status, terminal_error) = (status, error);
11661 #[cfg(unix)]
11662 let (mut terminal_status, mut terminal_error) = (terminal_status, terminal_error);
11663 if matches!(
11664 terminal_status,
11665 crate::core::events::TurnOutcomeStatus::Completed
11666 ) && terminal_error.is_none()
11667 {
11668 #[cfg(unix)]
11669 match exec_shell_manager.lock() {
11670 Ok(mut manager) => match manager.commit_persistent_services() {
11671 Ok(receipts) => {
11672 for receipt in &receipts {
11673 if output_format == ExecOutputFormat::StreamJson {
11674 emit_exec_stream_event(
11675 &ExecStreamEvent::ServiceReleased {
11676 task_id: receipt.task_id.clone(),
11677 pid: receipt.pid,
11678 process_group_id: receipt.process_group_id,
11679 ownership: receipt.ownership.clone(),
11680 },
11681 )?;
11682 } else if !json_output {
11683 eprintln!(
11684 "persistent service released: {} pid={} pgid={} ownership={}",
11685 receipt.task_id,
11686 receipt.pid,
11687 receipt.process_group_id,
11688 receipt.ownership
11689 );
11690 }
11691 }
11692 summary.released_services.extend(receipts);
11693 }
11694 Err(error) => {
11695 manager.abort_persistent_services();
11696 terminal_status = crate::core::events::TurnOutcomeStatus::Failed;
11697 terminal_error = Some(format!(
11698 "Persistent service ownership transfer failed: {error}"
11699 ));
11700 }
11701 },
11702 Err(_) => {
11703 terminal_status = crate::core::events::TurnOutcomeStatus::Failed;
11704 terminal_error = Some(
11705 "Persistent service ownership transfer failed: shell manager lock poisoned"
11706 .to_string(),
11707 );
11708 }
11709 }
11710 } else if let Ok(mut manager) = exec_shell_manager.lock() {
11711 manager.abort_persistent_services();
11712 }
11713 summary.status = Some(format!("{terminal_status:?}").to_lowercase());
11714 if terminal_error.is_some() {
11715 summary.error = terminal_error;
11716 }
11717 if sandbox_denied
11718 && summary.error.is_none()
11719 && matches!(
11720 terminal_status,
11721 crate::core::events::TurnOutcomeStatus::Failed
11722 )
11723 {
11724 summary.error = Some(
11725 "exec turn failed after sandbox denial; explicit sandbox elevation was not authorized"
11726 .to_string(),
11727 );
11728 }
11729 if last_error_category.is_none() {
11730 last_error_category = summary
11731 .error
11732 .as_deref()
11733 .map(crate::error_taxonomy::classify_error_message);
11734 summary.error_category =
11735 last_error_category.map(|category| category.to_string());
11736 }
11737 let termination_reason = crate::core::termination::classify_turn_termination(
11738 terminal_status,
11739 last_error_category,
11740 tool_error_seen,
11741 approval_required,
11742 );
11743 summary.termination_reason = Some(termination_reason.as_str().to_string());
11744 if !termination_reason.is_success() {
11750 codewhale_telemetry::set_exit_class(codewhale_telemetry::ExitClass::Error);
11751 }
11752 let saved_session_id = if should_persist_session && !latest_messages.is_empty() {
11753 match persist_exec_session(
11754 &latest_messages,
11755 &latest_model,
11756 PersistedProviderRoute {
11757 kind: effective_provider.as_str(),
11758 id: effective_provider_id.as_deref(),
11759 },
11760 &latest_workspace,
11761 &latest_system_prompt,
11762 latest_session_id.as_deref(),
11763 u64::from(usage.input_tokens) + u64::from(usage.output_tokens),
11764 ) {
11765 Ok(id) => {
11766 if output_format == ExecOutputFormat::Text && !json_output {
11767 eprintln!("{}", exec_saved_session_line(&id));
11768 }
11769 Some(id)
11770 }
11771 Err(err) => {
11772 if output_format == ExecOutputFormat::Text && !json_output {
11773 eprintln!("warning: failed to save exec session: {err}");
11774 }
11775 latest_session_id.clone()
11776 }
11777 }
11778 } else {
11779 latest_session_id.clone()
11780 };
11781 if output_format == ExecOutputFormat::StreamJson {
11782 if let Some(id) = saved_session_id.as_ref() {
11783 emit_exec_stream_event(&ExecStreamEvent::SessionCapture {
11784 content: exec_stream_session_ref(id),
11785 })?;
11786 }
11787 emit_exec_stream_event(&ExecStreamEvent::Metadata {
11788 meta: Box::new(ExecStreamMeta {
11789 receipt_kind: "terminal",
11790 provider: effective_provider_kind.clone(),
11791 provider_id: effective_stream_provider_id.clone(),
11792 model: latest_model.clone(),
11793 route_source: route_source.clone(),
11794 input_tokens: Some(usage.input_tokens),
11795 output_tokens: Some(usage.output_tokens),
11796 prompt_cache_hit_tokens: usage.prompt_cache_hit_tokens,
11797 prompt_cache_miss_tokens: usage.prompt_cache_miss_tokens,
11798 prompt_cache_write_tokens: usage.prompt_cache_write_tokens,
11799 reasoning_tokens: usage.reasoning_tokens,
11800 duration_ms: u64::try_from(exec_started.elapsed().as_millis())
11801 .unwrap_or(u64::MAX),
11802 retry_count: None,
11803 approval_posture: approval_posture.clone(),
11804 sandbox_posture: sandbox_posture.clone(),
11805 binary_sha256: binary_sha256.clone(),
11806 config_sha256: None,
11807 prompt_sha256: prompt_sha256.clone(),
11808 tool_catalog_sha256: tool_catalog.as_ref().and_then(|catalog| {
11809 serde_json::to_vec(catalog).ok().map(|bytes| {
11810 format!("sha256:{}", crate::hashing::sha256_hex(&bytes))
11811 })
11812 }),
11813 input_analysis: exec_stream_input_analysis(
11814 &latest_messages,
11815 latest_system_prompt.as_ref(),
11816 ),
11817 visible_final_answer_chars: summary.output.chars().count(),
11818 resume_command: saved_session_id
11819 .as_deref()
11820 .map(exec_stream_resume_hint)
11821 .unwrap_or_default(),
11822 session_id: saved_session_id
11823 .as_deref()
11824 .map(exec_stream_session_ref)
11825 .unwrap_or_default(),
11826 workspace: latest_workspace.display().to_string(),
11827 message_count: latest_messages.len(),
11828 status: summary.status.clone(),
11829 termination_reason: summary.termination_reason.clone(),
11830 error_category: summary.error_category.clone(),
11831 error: summary.error.clone(),
11832 }),
11833 })?;
11834 emit_exec_stream_event(&ExecStreamEvent::Done)?;
11835 }
11836 let _ = engine_handle.send(Op::Shutdown).await;
11837 break;
11838 }
11839 Event::SessionUpdated {
11840 session_id,
11841 messages,
11842 system_prompt,
11843 model,
11844 workspace,
11845 } => {
11846 latest_session_id = Some(session_id);
11847 latest_messages = messages;
11848 latest_system_prompt = system_prompt;
11849 latest_model = model;
11850 latest_workspace = workspace;
11851 }
11852 Event::Status { message }
11855 if output_format == ExecOutputFormat::Text
11856 && !json_output
11857 && message.contains("Maximum model steps") =>
11858 {
11859 eprintln!("{message}");
11860 }
11861 _ => {}
11862 }
11863 }
11864
11865 if summary.status.is_none() {
11866 if let Ok(mut manager) = exec_shell_manager.lock() {
11867 manager.abort_persistent_services();
11868 }
11869 let error = summary.error.clone().unwrap_or_else(|| {
11870 "Engine event channel closed before a terminal turn receipt".to_string()
11871 });
11872 let category = last_error_category
11873 .unwrap_or_else(|| crate::error_taxonomy::classify_error_message(&error));
11874 let termination_reason = crate::core::termination::classify_turn_termination(
11875 crate::core::events::TurnOutcomeStatus::Failed,
11876 Some(category),
11877 tool_error_seen,
11878 approval_required,
11879 );
11880 summary.status = Some("failed".to_string());
11881 summary.error_category = Some(category.to_string());
11882 summary.termination_reason = Some(termination_reason.as_str().to_string());
11883 summary.error = Some(error.clone());
11884 if output_format == ExecOutputFormat::StreamJson {
11885 emit_exec_stream_event(&ExecStreamEvent::Error { error })?;
11886 }
11887 }
11888
11889 if json_output {
11890 println!("{}", serde_json::to_string_pretty(&summary)?);
11891 }
11892
11893 if let Some(error) = summary.error.as_ref()
11894 && !error.trim().is_empty()
11895 {
11896 let exit_code = exec_failure_exit_code(summary.error_category.as_deref());
11902 if exit_code != 1 {
11903 eprintln!("Error: exec turn failed: {error}");
11904 let _ = io::stdout().flush();
11905 std::process::exit(exit_code);
11906 }
11907 bail!("exec turn failed: {error}");
11908 }
11909
11910 if matches!(
11911 summary.status.as_deref(),
11912 Some("failed" | "canceled" | "interrupted")
11913 ) {
11914 let status = summary.status.as_deref().unwrap_or("unknown");
11915 bail!("exec turn ended with status {status}");
11916 }
11917
11918 Ok(())
11919}
11920
11921#[cfg(test)]
11922mod serve_bind_host_tests {
11923 use super::*;
11924
11925 #[test]
11926 fn http_defaults_to_loopback() {
11927 assert_eq!(
11928 resolve_serve_bind_host(false, None),
11929 ServeBindHost {
11930 host: "127.0.0.1".to_string(),
11931 mobile_rebound_to_lan: false,
11932 }
11933 );
11934 }
11935
11936 #[test]
11937 fn mobile_default_rebinds_to_lan_with_warning_flag() {
11938 assert_eq!(
11939 resolve_serve_bind_host(true, None),
11940 ServeBindHost {
11941 host: "0.0.0.0".to_string(),
11942 mobile_rebound_to_lan: true,
11943 }
11944 );
11945 }
11946
11947 #[test]
11948 fn mobile_respects_explicit_loopback_host() {
11949 assert_eq!(
11950 resolve_serve_bind_host(true, Some("127.0.0.1".to_string())),
11951 ServeBindHost {
11952 host: "127.0.0.1".to_string(),
11953 mobile_rebound_to_lan: false,
11954 }
11955 );
11956 }
11957
11958 #[test]
11959 fn http_and_mobile_are_mutually_exclusive() {
11960 let err = validate_serve_mode_selection(false, true, true, false, false).unwrap_err();
11961 assert!(
11962 err.to_string()
11963 .contains("--http and --mobile are mutually exclusive")
11964 );
11965 }
11966
11967 #[test]
11968 fn web_is_a_distinct_loopback_runtime_mode() {
11969 assert!(validate_serve_mode_selection(false, false, false, true, false).unwrap());
11970 let err = validate_serve_mode_selection(false, true, false, true, false).unwrap_err();
11971 assert!(err.to_string().contains("--web is mutually exclusive"));
11972 assert_eq!(
11973 resolve_serve_bind_host(false, None),
11974 ServeBindHost {
11975 host: "127.0.0.1".to_string(),
11976 mobile_rebound_to_lan: false,
11977 }
11978 );
11979 }
11980}
11981
11982#[cfg(test)]
11983#[path = "tests/exec_exit_semantics.rs"]
11984mod exec_exit_semantics_tests;
11985#[cfg(test)]
11986mod doctor_legacy_state_tests {
11987 use super::*;
11988 use std::env;
11989 use std::ffi::OsString;
11990 use std::fs;
11991 use tempfile::TempDir;
11992
11993 struct EnvVarRestore {
11994 key: &'static str,
11995 previous: Option<OsString>,
11996 }
11997
11998 impl EnvVarRestore {
11999 fn set(key: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
12000 let previous = env::var_os(key);
12001 unsafe {
12002 env::set_var(key, value);
12003 }
12004 Self { key, previous }
12005 }
12006 }
12007
12008 impl Drop for EnvVarRestore {
12009 fn drop(&mut self) {
12010 unsafe {
12011 match &self.previous {
12012 Some(value) => env::set_var(self.key, value),
12013 None => env::remove_var(self.key),
12014 }
12015 }
12016 }
12017 }
12018
12019 fn roots(tmp: &TempDir) -> (PathBuf, PathBuf) {
12020 (tmp.path().join(".codewhale"), tmp.path().join(".deepseek"))
12021 }
12022
12023 fn entry<'a>(report: &'a [DoctorLegacyStateEntry], name: &str) -> &'a DoctorLegacyStateEntry {
12024 report
12025 .iter()
12026 .find(|entry| entry.name == name)
12027 .expect("legacy state entry should exist")
12028 }
12029
12030 #[test]
12031 fn doctor_legacy_state_report_marks_unmigrated_legacy_entries() {
12032 let tmp = TempDir::new().expect("tempdir");
12033 let (primary_root, legacy_root) = roots(&tmp);
12034 fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
12035 fs::create_dir_all(legacy_root.join("tasks")).expect("legacy tasks");
12036 fs::create_dir_all(&primary_root).expect("primary root");
12037 fs::write(legacy_root.join("config.toml"), "api_key = 'old'").expect("legacy config");
12038
12039 let report = doctor_legacy_state_report(&primary_root, &legacy_root);
12040 let session_recovery = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12041
12042 assert_eq!(
12043 entry(&report, "sessions").status,
12044 DoctorLegacyStateStatus::LegacyOnly
12045 );
12046 assert_eq!(
12047 entry(&report, "config.toml").status,
12048 DoctorLegacyStateStatus::LegacyOnly
12049 );
12050 assert_eq!(
12051 entry(&report, "skills").status,
12052 DoctorLegacyStateStatus::Absent
12053 );
12054
12055 let json =
12056 doctor_legacy_state_json(&primary_root, &legacy_root, &report, &session_recovery);
12057 assert_eq!(json["needs_attention"], true);
12058 assert_eq!(json["legacy_only_count"], 3);
12059 assert_eq!(json["dual_present_count"], 0);
12060 }
12061
12062 #[test]
12063 fn doctor_legacy_state_report_marks_dual_present_entries() {
12064 let tmp = TempDir::new().expect("tempdir");
12065 let (primary_root, legacy_root) = roots(&tmp);
12066 fs::create_dir_all(primary_root.join("sessions")).expect("primary sessions");
12067 fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
12068 fs::write(primary_root.join("mcp.json"), "{}").expect("primary mcp");
12069 fs::write(legacy_root.join("mcp.json"), "{}").expect("legacy mcp");
12070
12071 let report = doctor_legacy_state_report(&primary_root, &legacy_root);
12072 let session_recovery = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12073
12074 assert_eq!(
12075 entry(&report, "sessions").status,
12076 DoctorLegacyStateStatus::Both
12077 );
12078 assert_eq!(
12079 entry(&report, "mcp.json").status,
12080 DoctorLegacyStateStatus::Both
12081 );
12082
12083 let json =
12084 doctor_legacy_state_json(&primary_root, &legacy_root, &report, &session_recovery);
12085 assert_eq!(json["needs_attention"], true);
12086 assert_eq!(json["legacy_only_count"], 0);
12087 assert_eq!(json["dual_present_count"], 2);
12088 }
12089
12090 #[test]
12091 fn doctor_legacy_state_report_is_clear_when_only_primary_exists() {
12092 let tmp = TempDir::new().expect("tempdir");
12093 let (primary_root, legacy_root) = roots(&tmp);
12094 fs::create_dir_all(primary_root.join("sessions")).expect("primary sessions");
12095 fs::write(primary_root.join("settings.toml"), "default_mode = 'ask'")
12096 .expect("primary settings");
12097
12098 let report = doctor_legacy_state_report(&primary_root, &legacy_root);
12099 let session_recovery = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12100
12101 assert_eq!(
12102 entry(&report, "sessions").status,
12103 DoctorLegacyStateStatus::PrimaryOnly
12104 );
12105 assert!(!report.iter().any(legacy_state_needs_attention));
12106
12107 let json =
12108 doctor_legacy_state_json(&primary_root, &legacy_root, &report, &session_recovery);
12109 assert_eq!(json["needs_attention"], false);
12110 assert_eq!(json["legacy_only_count"], 0);
12111 assert_eq!(json["dual_present_count"], 0);
12112 }
12113
12114 #[test]
12115 fn doctor_legacy_state_report_is_clear_when_neither_root_exists() {
12116 let tmp = TempDir::new().expect("tempdir");
12117 let (primary_root, legacy_root) = roots(&tmp);
12118
12119 let report = doctor_legacy_state_report(&primary_root, &legacy_root);
12120 let session_recovery = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12121
12122 assert!(
12123 report
12124 .iter()
12125 .all(|entry| entry.status == DoctorLegacyStateStatus::Absent)
12126 );
12127 assert!(!report.iter().any(legacy_state_needs_attention));
12128
12129 let json =
12130 doctor_legacy_state_json(&primary_root, &legacy_root, &report, &session_recovery);
12131 assert_eq!(json["needs_attention"], false);
12132 assert_eq!(json["legacy_only_count"], 0);
12133 assert_eq!(json["dual_present_count"], 0);
12134 }
12135
12136 #[test]
12137 fn doctor_reports_incomplete_session_migration_without_mutating_files() {
12138 let tmp = TempDir::new().expect("tempdir");
12139 let (primary_root, legacy_root) = roots(&tmp);
12140 let primary_sessions = primary_root.join("sessions");
12141 let legacy_sessions = legacy_root.join("sessions");
12142 fs::create_dir_all(&primary_sessions).expect("primary sessions");
12143 fs::create_dir_all(legacy_sessions.join("checkpoints")).expect("legacy checkpoints");
12144 fs::write(primary_sessions.join("already-there.json"), b"primary")
12145 .expect("primary session");
12146 fs::write(legacy_sessions.join("already-there.json"), b"legacy")
12147 .expect("legacy matching session");
12148 fs::write(
12149 legacy_sessions.join("recover-me.json"),
12150 b"not parsed by doctor",
12151 )
12152 .expect("legacy recoverable session");
12153 fs::write(
12154 legacy_sessions.join("checkpoints").join("latest.json"),
12155 b"checkpoint not inspected",
12156 )
12157 .expect("legacy checkpoint");
12158
12159 let legacy_before = fs::read(legacy_sessions.join("recover-me.json"))
12160 .expect("read legacy fixture before diagnostic");
12161 let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12162
12163 assert_eq!(
12164 report.status,
12165 DoctorSessionRecoveryStatus::MigrationIncomplete
12166 );
12167 assert_eq!(report.legacy_session_file_count, 2);
12168 assert_eq!(report.already_present_file_count, 1);
12169 assert_eq!(report.recoverable_file_count, 1);
12170 assert_eq!(report.recoverable.len(), 1);
12171 assert_eq!(report.recoverable[0].name, PathBuf::from("recover-me.json"));
12172 assert!(
12173 !primary_sessions.join("recover-me.json").exists(),
12174 "doctor must not copy a recoverable session"
12175 );
12176 assert_eq!(
12177 fs::read(legacy_sessions.join("recover-me.json"))
12178 .expect("legacy file remains after diagnostic"),
12179 legacy_before,
12180 "doctor must not rewrite or delete the legacy source"
12181 );
12182
12183 let json = doctor_session_recovery_json(&report);
12184 assert_eq!(json["needs_attention"], true);
12185 assert_eq!(json["read_only"], true);
12186 assert_eq!(json["chat_contents_read"], false);
12187 assert_eq!(json["checkpoint_internals_scanned"], false);
12188 assert_eq!(json["recoverable_file_count"], 1);
12189 assert_eq!(json["recovery_command"], "codewhale sessions");
12190 assert_eq!(json["recoverable_files"][0]["name"], "recover-me.json");
12191 let serialized = json.to_string();
12192 assert!(
12193 !serialized.contains("not parsed by doctor"),
12194 "the report must not expose session contents"
12195 );
12196 assert!(
12197 !serialized.contains("checkpoint not inspected"),
12198 "the report must not expose checkpoint contents"
12199 );
12200 }
12201
12202 #[test]
12203 fn doctor_treats_preserved_legacy_sessions_as_complete_by_filename() {
12204 let tmp = TempDir::new().expect("tempdir");
12205 let (primary_root, legacy_root) = roots(&tmp);
12206 let primary_sessions = primary_root.join("sessions");
12207 let legacy_sessions = legacy_root.join("sessions");
12208 fs::create_dir_all(&primary_sessions).expect("primary sessions");
12209 fs::create_dir_all(&legacy_sessions).expect("legacy sessions");
12210 fs::write(primary_sessions.join("same-name.json"), b"primary").expect("primary session");
12211 fs::write(legacy_sessions.join("same-name.json"), b"legacy").expect("legacy session");
12212
12213 let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12214
12215 assert_eq!(
12216 report.status,
12217 DoctorSessionRecoveryStatus::MigrationComplete
12218 );
12219 assert!(!report.needs_attention());
12220 assert_eq!(report.recoverable_file_count, 0);
12221 assert!(report.recoverable.is_empty());
12222 assert_eq!(report.already_present_file_count, 1);
12223 let json = doctor_session_recovery_json(&report);
12224 assert_eq!(json["session_descriptors_compared"], false);
12225 assert_eq!(
12226 json["counterpart_check"],
12227 "top_level_filename_and_regular_file_only"
12228 );
12229 }
12230
12231 #[test]
12232 fn doctor_bounds_recoverable_session_filename_samples() {
12233 let tmp = TempDir::new().expect("tempdir");
12234 let (primary_root, legacy_root) = roots(&tmp);
12235 let legacy_sessions = legacy_root.join("sessions");
12236 fs::create_dir_all(&legacy_sessions).expect("legacy sessions");
12237 for index in 0..DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT {
12238 fs::write(
12239 legacy_sessions.join(format!("late-{index:03}.json")),
12240 b"fixture",
12241 )
12242 .expect("legacy session fixture");
12243 }
12244 fs::write(legacy_sessions.join("early-000.json"), b"fixture")
12245 .expect("earliest legacy session fixture");
12246 fs::write(legacy_sessions.join("early-001.json"), b"fixture")
12247 .expect("second earliest legacy session fixture");
12248 let total = DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT + 2;
12249
12250 let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12251 let json = doctor_session_recovery_json(&report);
12252
12253 assert_eq!(report.recoverable_file_count, total);
12254 assert_eq!(
12255 report.recoverable.len(),
12256 DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT
12257 );
12258 assert_eq!(
12259 json["recoverable_files"].as_array().map(Vec::len),
12260 Some(DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT)
12261 );
12262 assert_eq!(
12263 report.recoverable.first().map(|entry| entry.name.as_path()),
12264 Some(Path::new("early-000.json")),
12265 "the bounded sample must not depend on read_dir order"
12266 );
12267 assert_eq!(
12268 report.recoverable.last().map(|entry| entry.name.as_path()),
12269 Some(Path::new("late-097.json")),
12270 "the bounded sample must retain the lexical prefix"
12271 );
12272 assert_eq!(json["recoverable_files_truncated"], true);
12273 }
12274
12275 #[test]
12276 fn doctor_session_recovery_fails_closed_on_an_unreadable_path_shape() {
12277 let tmp = TempDir::new().expect("tempdir");
12278 let (primary_root, legacy_root) = roots(&tmp);
12279 fs::create_dir_all(&legacy_root).expect("legacy root");
12280 fs::write(legacy_root.join("sessions"), b"not a directory")
12281 .expect("invalid legacy sessions path");
12282
12283 let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12284
12285 assert_eq!(report.status, DoctorSessionRecoveryStatus::ScanFailed);
12286 assert!(report.needs_attention());
12287 assert!(report.error.as_deref().is_some_and(|error| {
12288 error.contains("legacy sessions root") && error.contains("not a directory")
12289 }));
12290 }
12291
12292 #[test]
12293 fn doctor_session_recovery_rejects_a_non_directory_legacy_state_root() {
12294 let tmp = TempDir::new().expect("tempdir");
12295 let (primary_root, legacy_root) = roots(&tmp);
12296 fs::write(&legacy_root, b"not a state directory").expect("invalid legacy root");
12297
12298 let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12299
12300 assert_eq!(report.status, DoctorSessionRecoveryStatus::ScanFailed);
12301 assert!(report.error.as_deref().is_some_and(|error| {
12302 error.contains("legacy state root") && error.contains("not a directory")
12303 }));
12304 }
12305
12306 #[test]
12307 fn doctor_session_recovery_rejects_a_non_directory_primary_state_root() {
12308 let tmp = TempDir::new().expect("tempdir");
12309 let (primary_root, legacy_root) = roots(&tmp);
12310 fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
12311 fs::write(&primary_root, b"not a state directory").expect("invalid primary root");
12312
12313 let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12314
12315 assert_eq!(report.status, DoctorSessionRecoveryStatus::ScanFailed);
12316 assert!(report.error.as_deref().is_some_and(|error| {
12317 error.contains("primary state root") && error.contains("not a directory")
12318 }));
12319 }
12320
12321 #[test]
12322 fn doctor_session_recovery_rejects_a_non_directory_primary_sessions_root() {
12323 let tmp = TempDir::new().expect("tempdir");
12324 let (primary_root, legacy_root) = roots(&tmp);
12325 fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
12326 fs::create_dir_all(&primary_root).expect("primary root");
12327 fs::write(primary_root.join("sessions"), b"not a sessions directory")
12328 .expect("invalid primary sessions path");
12329
12330 let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12331
12332 assert_eq!(report.status, DoctorSessionRecoveryStatus::ScanFailed);
12333 assert!(report.error.as_deref().is_some_and(|error| {
12334 error.contains("primary sessions root") && error.contains("not a directory")
12335 }));
12336 }
12337
12338 #[cfg(unix)]
12339 #[test]
12340 fn doctor_session_recovery_rejects_a_symlinked_legacy_sessions_root() {
12341 use std::os::unix::fs::symlink;
12342
12343 let tmp = TempDir::new().expect("tempdir");
12344 let (primary_root, legacy_root) = roots(&tmp);
12345 let external_sessions = tmp.path().join("external-sessions");
12346 fs::create_dir_all(&external_sessions).expect("external sessions");
12347 fs::write(
12348 external_sessions.join("must-not-be-enumerated.json"),
12349 b"session contents must stay unread",
12350 )
12351 .expect("external session fixture");
12352 fs::create_dir_all(&legacy_root).expect("legacy root");
12353 symlink(&external_sessions, legacy_root.join("sessions"))
12354 .expect("symlinked legacy sessions root");
12355
12356 let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12357
12358 assert_eq!(report.status, DoctorSessionRecoveryStatus::ScanFailed);
12359 assert!(report.needs_attention());
12360 assert_eq!(report.legacy_session_file_count, 0);
12361 assert!(report.recoverable.is_empty());
12362 assert!(
12363 report
12364 .error
12365 .as_deref()
12366 .is_some_and(|error| error.contains("legacy sessions root")
12367 && error.contains("path is a symlink"))
12368 );
12369 }
12370
12371 #[cfg(unix)]
12372 #[test]
12373 fn doctor_session_recovery_rejects_symlinked_primary_root_and_sessions_root() {
12374 use std::os::unix::fs::symlink;
12375
12376 let tmp = TempDir::new().expect("tempdir");
12377 let (primary_root, legacy_root) = roots(&tmp);
12378 let external_primary = tmp.path().join("external-primary");
12379 fs::create_dir_all(external_primary.join("sessions")).expect("external primary");
12380 fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
12381 symlink(&external_primary, &primary_root).expect("symlinked primary root");
12382
12383 let root_report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12384 assert_eq!(root_report.status, DoctorSessionRecoveryStatus::ScanFailed);
12385 assert!(root_report.error.as_deref().is_some_and(|error| {
12386 error.contains("primary state root") && error.contains("path is a symlink")
12387 }));
12388
12389 fs::remove_file(&primary_root).expect("remove primary root symlink");
12390 fs::create_dir_all(&primary_root).expect("primary root");
12391 symlink(&external_primary, primary_root.join("sessions"))
12392 .expect("symlinked primary sessions root");
12393
12394 let sessions_report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12395 assert_eq!(
12396 sessions_report.status,
12397 DoctorSessionRecoveryStatus::ScanFailed
12398 );
12399 assert!(sessions_report.error.as_deref().is_some_and(|error| {
12400 error.contains("primary sessions root") && error.contains("path is a symlink")
12401 }));
12402 }
12403
12404 #[test]
12405 fn explicit_codewhale_home_skips_session_recovery_scan() {
12406 let tmp = TempDir::new().expect("tempdir");
12407 let (primary_root, legacy_root) = roots(&tmp);
12408 fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
12409 fs::write(legacy_root.join("sessions").join("ambient.json"), b"legacy")
12410 .expect("legacy session");
12411
12412 let report = doctor_session_recovery_report(&primary_root, &legacy_root, true);
12413
12414 assert_eq!(report.status, DoctorSessionRecoveryStatus::Isolated);
12415 assert!(report.codewhale_home_is_explicit);
12416 assert_eq!(report.legacy_session_file_count, 0);
12417 assert_eq!(report.recoverable_file_count, 0);
12418 assert!(report.recoverable.is_empty());
12419 assert!(!report.needs_attention());
12420 }
12421
12422 #[test]
12423 fn doctor_state_roots_ignore_ambient_legacy_home_when_codewhale_home_is_explicit() {
12424 let _env_lock = crate::test_support::lock_test_env();
12425 let tmp = TempDir::new().expect("tempdir");
12426 let explicit_home = tmp.path().join("isolated-codewhale");
12427 let ambient_legacy = tmp.path().join(".deepseek");
12428 fs::create_dir_all(&ambient_legacy).expect("ambient legacy root");
12429 fs::write(
12430 ambient_legacy.join("config.toml"),
12431 "provider = 'deepseek'\n",
12432 )
12433 .expect("ambient legacy config");
12434 let _home = EnvVarRestore::set("HOME", tmp.path());
12435 let _codewhale_home = EnvVarRestore::set("CODEWHALE_HOME", &explicit_home);
12436
12437 let (primary_root, legacy_root) = doctor_state_roots();
12438 let report = doctor_legacy_state_report(&primary_root, &legacy_root);
12439 let session_recovery = doctor_session_recovery_report(
12440 &primary_root,
12441 &legacy_root,
12442 codewhale_config::codewhale_home_is_explicit(),
12443 );
12444
12445 assert_eq!(primary_root, explicit_home);
12446 assert_eq!(
12447 legacy_root,
12448 primary_root.join(codewhale_config::LEGACY_APP_DIR)
12449 );
12450 assert!(
12451 report
12452 .iter()
12453 .all(|entry| entry.status == DoctorLegacyStateStatus::Absent),
12454 "doctor must not report ambient legacy state when CODEWHALE_HOME is explicit"
12455 );
12456 assert!(!report.iter().any(legacy_state_needs_attention));
12457 assert_eq!(
12458 session_recovery.status,
12459 DoctorSessionRecoveryStatus::Isolated
12460 );
12461 assert!(session_recovery.recoverable.is_empty());
12462 }
12463}
12464
12465#[cfg(test)]
12466mod doctor_setup_state_tests {
12467 use super::*;
12468 use std::fs;
12469 use tempfile::TempDir;
12470
12471 fn prepare_env(tmp: &TempDir) -> (crate::test_support::EnvVarGuard, PathBuf) {
12472 let codewhale_home = tmp.path().join(".codewhale");
12473 fs::create_dir_all(&codewhale_home).expect("codewhale home");
12474 (
12475 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", codewhale_home.as_os_str()),
12476 codewhale_home,
12477 )
12478 }
12479
12480 fn provider_step(report: &serde_json::Value) -> &serde_json::Value {
12481 report["steps"]
12482 .as_array()
12483 .expect("steps array")
12484 .iter()
12485 .find(|step| step["step"] == "provider_model")
12486 .expect("provider/model step")
12487 }
12488
12489 #[test]
12490 fn doctor_setup_consistency_flags_missing_user_constitution() {
12491 let _guard = crate::test_support::lock_test_env();
12492 let tmp = TempDir::new().expect("tempdir");
12493 let (_home_guard, _codewhale_home) = prepare_env(&tmp);
12494 let _key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
12495 let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
12496 let workspace = tmp.path().join("workspace");
12497 fs::create_dir_all(&workspace).expect("workspace");
12498
12499 let state = codewhale_config::SetupState {
12500 constitution_source: codewhale_config::ConstitutionSource::UserGlobal,
12501 ..Default::default()
12502 };
12503 state.save().expect("persist setup state");
12504
12505 let report = doctor_setup_report_json(&Config::default(), &workspace);
12506
12507 assert_eq!(report["source"], "persisted");
12508 assert_eq!(report["consistency"]["status"], "inconsistent");
12509 let issues = report["consistency"]["issues"].to_string();
12510 assert!(
12511 issues.contains("setup_state_points_at_missing_user_constitution"),
12512 "{issues}"
12513 );
12514 }
12515
12516 #[test]
12517 fn doctor_setup_consistency_flags_stale_temp_files() {
12518 let _guard = crate::test_support::lock_test_env();
12519 let tmp = TempDir::new().expect("tempdir");
12520 let (_home_guard, codewhale_home) = prepare_env(&tmp);
12521 let _key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
12522 let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
12523 let workspace = tmp.path().join("workspace");
12524 fs::create_dir_all(&workspace).expect("workspace");
12525 fs::write(codewhale_home.join(".tmpAbC123"), b"orphaned atomic write")
12526 .expect("stale temp file");
12527
12528 let report = doctor_setup_report_json(&Config::default(), &workspace);
12529
12530 assert_eq!(report["consistency"]["status"], "inconsistent");
12531 let issues = report["consistency"]["issues"].to_string();
12532 assert!(
12533 issues.contains("stale_setup_temp_files_in_codewhale_home"),
12534 "{issues}"
12535 );
12536 }
12537
12538 #[test]
12539 fn doctor_setup_consistency_reports_consistent_for_clean_home() {
12540 let _guard = crate::test_support::lock_test_env();
12541 let tmp = TempDir::new().expect("tempdir");
12542 let (_home_guard, _codewhale_home) = prepare_env(&tmp);
12543 let _key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
12544 let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
12545 let workspace = tmp.path().join("workspace");
12546 fs::create_dir_all(&workspace).expect("workspace");
12547
12548 let report = doctor_setup_report_json(&Config::default(), &workspace);
12549
12550 assert_eq!(report["consistency"]["status"], "consistent");
12551 assert_eq!(
12552 report["consistency"]["issues"]
12553 .as_array()
12554 .map(Vec::len)
12555 .unwrap_or_default(),
12556 0
12557 );
12558 }
12559
12560 #[test]
12561 fn doctor_setup_report_json_derives_state_without_sidecar() {
12562 let _guard = crate::test_support::lock_test_env();
12563 let tmp = TempDir::new().expect("tempdir");
12564 let (_home_guard, _codewhale_home) = prepare_env(&tmp);
12565 let _key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
12566 let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
12567 let workspace = tmp.path().join("workspace");
12568 fs::create_dir_all(&workspace).expect("workspace");
12569
12570 let report = doctor_setup_report_json(&Config::default(), &workspace);
12571
12572 assert_eq!(report["source"], "derived");
12573 assert_eq!(report["inherited"], true);
12574 assert_eq!(report["next_actions"]["constitution"], "/constitution");
12575 assert_eq!(report["next_actions"]["setup_report"], "/setup report");
12576 assert_eq!(
12577 report["next_actions"]["provider_model"],
12578 "/setup provider, /provider setup <name>, or /model"
12579 );
12580 assert_eq!(report["next_actions"]["runtime_posture"], "/config");
12581 assert_eq!(
12582 report["next_actions"]["operate_fleet"],
12583 "/setup fleet (readiness), /fleet setup (explicit profile authoring)"
12584 );
12585 assert_eq!(report["next_actions"]["hotbar"], "/setup hotbar");
12586 assert_eq!(report["next_actions"]["tools_mcp"], "/setup tools");
12587 assert_eq!(report["next_actions"]["remote_runtime"], "/setup remote");
12588 assert_eq!(report["next_actions"]["persistence"], "/setup persistence");
12589 assert_eq!(
12590 report["checkpoint_version"],
12591 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION
12592 );
12593 assert_eq!(report["update_ready"], false);
12594 assert_eq!(report["operate_ready"], false);
12595 assert_eq!(
12596 report["operate_fleet"]["concurrency"]["plan_limit_probed"],
12597 false
12598 );
12599 assert_eq!(
12600 report["operate_fleet"]["roster"]["readiness_rule"],
12601 "built-in starter roster or custom roster"
12602 );
12603 assert_eq!(report["provider_model"]["provider"]["id"], "deepseek");
12604 assert_eq!(report["provider_model"]["provider"]["display"], "DeepSeek");
12605 assert_eq!(
12606 report["provider_model"]["model"]["resolved"],
12607 crate::config::DEFAULT_TEXT_MODEL
12608 );
12609 assert_eq!(
12610 report["provider_model"]["auth"]["source"],
12611 "secret_store_unprobed"
12612 );
12613 assert_eq!(
12614 report["provider_model"]["auth"]["availability"],
12615 "not_probed"
12616 );
12617 assert_eq!(
12618 report["provider_model"]["auth"]["credential_url"],
12619 "https://platform.deepseek.com"
12620 );
12621 assert_eq!(
12622 report["provider_model"]["auth"]["credential_mode"],
12623 "api_key"
12624 );
12625 assert_eq!(
12626 report["provider_model"]["auth"]["env_vars"][0],
12627 "DEEPSEEK_API_KEY"
12628 );
12629 assert_eq!(report["provider_model"]["health"]["live_validation"], false);
12630 assert_eq!(report["constitution"]["source"], "bundled");
12631 assert_eq!(report["constitution"]["autonomy_preference"], "unspecified");
12632 assert_eq!(report["runtime_posture"]["source"], "unset");
12633 assert_eq!(report["runtime_posture"]["default_mode"]["value"], "agent");
12634 assert_eq!(
12635 report["runtime_posture"]["approval_policy"]["value"],
12636 "on-request"
12637 );
12638 assert_eq!(report["runtime_posture"]["allow_shell"]["value"], true);
12639 assert_eq!(
12640 report["runtime_posture"]["sandbox_mode"]["value"],
12641 "mode-derived"
12642 );
12643 assert_eq!(
12644 report["runtime_posture"]["network_default"]["value"],
12645 "prompt"
12646 );
12647 assert_eq!(provider_step(&report)["status"], "needs_action");
12648 }
12649
12650 #[test]
12651 fn doctor_setup_provider_model_json_covers_cn_codex_and_local_matrix() {
12652 let _guard = crate::test_support::lock_test_env();
12653 let tmp = TempDir::new().expect("tempdir");
12654 let (_home_guard, _codewhale_home) = prepare_env(&tmp);
12655 let _home = crate::test_support::EnvVarGuard::set("HOME", tmp.path());
12656 let _userprofile = crate::test_support::EnvVarGuard::set("USERPROFILE", tmp.path());
12657 let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
12658 let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
12659 let _codex_key = crate::test_support::EnvVarGuard::remove("OPENAI_CODEX_ACCESS_TOKEN");
12660 let _codex_legacy_key = crate::test_support::EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
12661 let codex_auth_path = tmp.path().join("external-codex-auth.json");
12662 let codex_auth_raw = serde_json::json!({
12663 "tokens": {
12664 "access_token": crate::test_support::future_test_jwt("doctor"),
12665 "account_id": "acct-doctor-read-only",
12666 "refresh_token": "must-never-be-used",
12667 "unknown": {"preserve": true}
12668 }
12669 })
12670 .to_string();
12671 fs::write(&codex_auth_path, &codex_auth_raw).expect("Codex auth trap fixture");
12672 let _codex_auth =
12673 crate::test_support::EnvVarGuard::set("OPENAI_CODEX_AUTH_FILE", &codex_auth_path);
12674 let workspace = tmp.path().join("workspace");
12675 fs::create_dir_all(&workspace).expect("workspace");
12676
12677 let cn_config = Config {
12678 provider: Some("deepseek-cn".to_string()),
12679 ..Config::default()
12680 };
12681 let cn_report = doctor_setup_report_json(&cn_config, &workspace);
12682 assert_eq!(cn_report["provider_model"]["provider"]["id"], "deepseek-cn");
12683 assert_eq!(
12684 cn_report["provider_model"]["provider"]["display"],
12685 "DeepSeek (legacy alias)"
12686 );
12687 assert_eq!(
12688 cn_report["provider_model"]["auth"]["env_vars"][0],
12689 "DEEPSEEK_API_KEY"
12690 );
12691 assert_eq!(
12692 cn_report["provider_model"]["auth"]["credential_url"],
12693 "https://platform.deepseek.com"
12694 );
12695 assert_eq!(cn_report["provider_model"]["auth"]["oauth_only"], false);
12696 assert_eq!(
12697 cn_report["provider_model"]["health"]["live_validation"],
12698 false
12699 );
12700
12701 let codex_config = Config {
12702 provider: Some("openai-codex".to_string()),
12703 ..Config::default()
12704 };
12705 crate::external_credentials::reset_side_effect_trap();
12706 let codex_report = doctor_setup_report_json(&codex_config, &workspace);
12707 assert_eq!(
12708 codex_report["provider_model"]["provider"]["id"],
12709 crate::config::ApiProvider::OpenaiCodex.as_str()
12710 );
12711 assert!(codex_report["provider_model"]["auth"]["credential_url"].is_null());
12712 assert_eq!(
12713 codex_report["provider_model"]["auth"]["credential_mode"],
12714 "oauth"
12715 );
12716 assert_eq!(codex_report["provider_model"]["auth"]["oauth_only"], true);
12717 assert_eq!(
12718 codex_report["provider_model"]["health"]["next_action"],
12719 "/setup provider or /provider setup <name>"
12720 );
12721 assert_eq!(
12722 crate::external_credentials::side_effect_trap_counts(),
12723 (0, 0),
12724 "doctor must not stat or read external credentials without consent"
12725 );
12726
12727 let mut consent = codewhale_config::ExternalCredentialConsentToml::read_only(
12728 codewhale_config::ProviderKind::OpenaiCodex,
12729 codewhale_config::ExternalCredentialSource::CodexCli,
12730 codex_auth_path.clone(),
12731 );
12732 let codex_read_only = Config {
12733 provider: Some("openai-codex".to_string()),
12734 providers: Some(crate::config::ProvidersConfig {
12735 openai_codex: crate::config::ProviderConfig {
12736 auth_mode: Some("oauth".to_string()),
12737 external_credentials: Some(consent.clone()),
12738 ..Default::default()
12739 },
12740 ..Default::default()
12741 }),
12742 ..Config::default()
12743 };
12744 let changed_ambient_path = tmp.path().join("new-ambient-codex-auth.json");
12745 let _changed_codex_auth =
12746 crate::test_support::EnvVarGuard::set("OPENAI_CODEX_AUTH_FILE", &changed_ambient_path);
12747 crate::external_credentials::reset_side_effect_trap();
12748 let codex_read_only_report = doctor_setup_report_json(&codex_read_only, &workspace);
12749 assert_eq!(
12750 codex_read_only_report["provider_model"]["auth"]["present_or_local"],
12751 false
12752 );
12753 assert_eq!(
12754 codex_read_only_report["provider_model"]["auth"]["source"],
12755 "external_consent"
12756 );
12757 let status_json = doctor_external_credential_consent_json(&codex_read_only);
12758 let codex_status = status_json
12759 .as_array()
12760 .and_then(|rows| rows.first())
12761 .expect("Codex structural status");
12762 assert_eq!(codex_status["access"], "read_only");
12763 assert_eq!(codex_status["provider"], "openai-codex");
12764 assert_eq!(codex_status["source"], "codex_cli");
12765 assert_eq!(codex_status["route_state"], "active");
12766 assert_eq!(codex_status["ambient_path_changed"], true);
12767 assert!(
12768 codex_status["ambient_path_warning"]
12769 .as_str()
12770 .is_some_and(|warning| warning.contains("remains pinned"))
12771 );
12772 assert_eq!(
12773 codex_status["revoke_command"],
12774 "codewhale auth external-revoke --provider openai-codex"
12775 );
12776 let human = doctor_external_credential_consent_lines(&codex_read_only).join("\n");
12777 assert!(human.contains("path="), "{human}");
12778 assert!(human.contains("version=1"), "{human}");
12779 assert!(human.contains("no refresh, identity-provider or discovery requests"));
12780 assert!(human.contains("normal requests to the explicitly selected provider"));
12781 assert!(human.contains("consent remains pinned"), "{human}");
12782 assert!(
12783 human.contains(&codewhale_config::quote_os_path(&codex_auth_path)),
12784 "{human}"
12785 );
12786 assert!(!human.contains(&changed_ambient_path.display().to_string()));
12787 assert_eq!(
12788 crate::external_credentials::complete_side_effect_trap_counts(),
12789 (0, 0, 0, 0, 0),
12790 "doctor consent status is structural and must not inspect the file"
12791 );
12792 assert_eq!(
12793 fs::read_to_string(&codex_auth_path).expect("unchanged Codex auth fixture"),
12794 codex_auth_raw
12795 );
12796
12797 consent.access = codewhale_config::ExternalCredentialAccess::Managed;
12798 let codex_managed = Config {
12799 provider: Some("openai-codex".to_string()),
12800 providers: Some(crate::config::ProvidersConfig {
12801 openai_codex: crate::config::ProviderConfig {
12802 auth_mode: Some("oauth".to_string()),
12803 external_credentials: Some(consent),
12804 ..Default::default()
12805 },
12806 ..Default::default()
12807 }),
12808 ..Config::default()
12809 };
12810 crate::external_credentials::reset_side_effect_trap();
12811 let codex_managed_report = doctor_setup_report_json(&codex_managed, &workspace);
12812 assert_eq!(
12813 codex_managed_report["provider_model"]["auth"]["present_or_local"],
12814 false
12815 );
12816 assert_eq!(
12817 crate::external_credentials::side_effect_trap_counts(),
12818 (0, 0),
12819 "unsupported managed mode must fail before external I/O"
12820 );
12821 assert_eq!(
12822 fs::read_to_string(&codex_auth_path).expect("unchanged managed auth fixture"),
12823 codex_auth_raw
12824 );
12825
12826 let local_config = Config {
12827 provider: Some("ollama".to_string()),
12828 ..Config::default()
12829 };
12830 let local_report = doctor_setup_report_json(&local_config, &workspace);
12831 assert_eq!(local_report["provider_model"]["provider"]["id"], "ollama");
12832 assert_eq!(
12833 local_report["provider_model"]["auth"]["present_or_local"],
12834 true
12835 );
12836 assert!(local_report["provider_model"]["auth"]["credential_url"].is_null());
12837 assert_eq!(
12838 local_report["provider_model"]["auth"]["credential_mode"],
12839 "local_optional"
12840 );
12841 assert_eq!(local_report["provider_model"]["auth"]["oauth_only"], false);
12842 assert_eq!(
12843 local_report["provider_model"]["health"]["next_action"],
12844 "/model"
12845 );
12846
12847 let kimi_config = Config {
12848 provider: Some("moonshot".to_string()),
12849 ..Config::default()
12850 };
12851 let kimi_report = doctor_setup_report_json(&kimi_config, &workspace);
12852 assert_eq!(
12853 kimi_report["provider_model"]["auth"]["credential_url"],
12854 "https://platform.kimi.ai"
12855 );
12856 assert_eq!(
12857 kimi_report["provider_model"]["auth"]["credential_docs_url"],
12858 "https://platform.kimi.ai"
12859 );
12860 assert_eq!(
12861 kimi_report["provider_model"]["auth"]["credential_mode"],
12862 "api_key"
12863 );
12864 assert!(
12865 kimi_report["provider_model"]["auth"]["credential_guidance"]
12866 .as_str()
12867 .is_some_and(|guidance| guidance.contains("OAuth is not available"))
12868 );
12869 }
12870
12871 #[test]
12872 fn doctor_setup_report_json_uses_persisted_state() {
12873 let _guard = crate::test_support::lock_test_env();
12874 let tmp = TempDir::new().expect("tempdir");
12875 let (_home_guard, _codewhale_home) = prepare_env(&tmp);
12876 let workspace = tmp.path().join("workspace");
12877 fs::create_dir_all(&workspace).expect("workspace");
12878 let mut state = codewhale_config::SetupState::default();
12879 state.set_step(
12880 codewhale_config::SetupStep::Language,
12881 codewhale_config::StepEntry::new(
12882 codewhale_config::StepStatus::Verified,
12883 true,
12884 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
12885 ),
12886 );
12887 state.set_step(
12888 codewhale_config::SetupStep::ProviderModel,
12889 codewhale_config::StepEntry::new(
12890 codewhale_config::StepStatus::Verified,
12891 true,
12892 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
12893 )
12894 .with_result("deepseek/deepseek-chat"),
12895 );
12896 state.set_step(
12897 codewhale_config::SetupStep::TrustSandbox,
12898 codewhale_config::StepEntry::new(
12899 codewhale_config::StepStatus::Verified,
12900 true,
12901 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
12902 ),
12903 );
12904 state
12905 .complete_constitution_checkpoint(
12906 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
12907 codewhale_config::ConstitutionChoice::Bundled,
12908 )
12909 .set_step(
12910 codewhale_config::SetupStep::Constitution,
12911 codewhale_config::StepEntry::new(
12912 codewhale_config::StepStatus::Verified,
12913 true,
12914 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
12915 ),
12916 );
12917 state.runtime_posture_source = codewhale_config::RuntimePostureSource::Confirmed;
12918 state.save().expect("persist setup state");
12919 codewhale_config::UserConstitution {
12920 autonomy_preference: codewhale_config::AutonomyPreference::Balanced,
12921 ..Default::default()
12922 }
12923 .save()
12924 .expect("persist user constitution");
12925 let config = Config {
12926 api_key: Some("TEST-STRUCTURAL-LITERAL".to_string()),
12927 approval_policy: Some("never".to_string()),
12928 allow_shell: Some(false),
12929 sandbox_mode: Some("read-only".to_string()),
12930 network: Some(crate::config::NetworkPolicyToml {
12931 default: "deny".to_string(),
12932 ..Default::default()
12933 }),
12934 ..Config::default()
12935 };
12936
12937 let report = doctor_setup_report_json(&config, &workspace);
12938
12939 assert_eq!(report["source"], "persisted");
12940 assert_eq!(report["first_run_ready"], true);
12941 assert_eq!(report["update_ready"], true);
12942 assert_eq!(report["operate_ready"], false);
12943 assert_eq!(report["constitution"]["choice"], "bundled");
12944 assert_eq!(
12945 report["constitution"]["checkpoint_completed_for"],
12946 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION
12947 );
12948 assert_eq!(report["constitution"]["autonomy_preference"], "balanced");
12949 assert_eq!(report["runtime_posture_source"], "confirmed");
12950 assert_eq!(report["runtime_posture"]["source"], "confirmed");
12951 assert_eq!(
12952 report["runtime_posture"]["approval_policy"]["value"],
12953 "never"
12954 );
12955 assert_eq!(
12956 report["runtime_posture"]["approval_policy"]["source"],
12957 "config"
12958 );
12959 assert_eq!(report["runtime_posture"]["allow_shell"]["value"], false);
12960 assert_eq!(report["runtime_posture"]["allow_shell"]["source"], "config");
12961 assert_eq!(
12962 report["runtime_posture"]["sandbox_mode"]["value"],
12963 "read-only"
12964 );
12965 assert_eq!(
12966 report["runtime_posture"]["sandbox_mode"]["source"],
12967 "config"
12968 );
12969 assert_eq!(
12970 report["runtime_posture"]["network_default"]["value"],
12971 "deny"
12972 );
12973 assert_eq!(
12974 report["runtime_posture"]["network_default"]["source"],
12975 "config"
12976 );
12977 assert_eq!(provider_step(&report)["result"], "deepseek/deepseek-chat");
12978 }
12979
12980 #[test]
12981 fn doctor_reports_settings_permission_posture_when_approval_policy_unset() {
12982 let _guard = crate::test_support::lock_test_env();
12983 let tmp = TempDir::new().expect("tempdir");
12984 let (_home_guard, codewhale_home) = prepare_env(&tmp);
12985 let workspace = tmp.path().join("workspace");
12986 fs::create_dir_all(&workspace).expect("workspace");
12987 fs::write(
12988 codewhale_home.join("settings.toml"),
12989 "permission_posture = \"full-access\"\n",
12990 )
12991 .expect("write settings.toml");
12992
12993 let config = Config::default();
12994 assert!(config.approval_policy.is_none());
12995
12996 let line = doctor_runtime_posture_line(&config, &workspace);
12997 assert!(
12998 line.contains("permission_posture=full-access (settings)"),
12999 "text doctor should report saved settings posture: {line}"
13000 );
13001 assert!(
13002 line.contains("approval_policy=on-request (default)"),
13003 "text doctor should keep unset config approval_policy default: {line}"
13004 );
13005
13006 let report = doctor_setup_report_json(&config, &workspace);
13007 assert_eq!(
13008 report["runtime_posture"]["permission_posture"]["value"],
13009 "full-access"
13010 );
13011 assert_eq!(
13012 report["runtime_posture"]["permission_posture"]["source"],
13013 "settings"
13014 );
13015 assert_eq!(
13016 report["runtime_posture"]["approval_policy"]["value"],
13017 "on-request"
13018 );
13019 assert_eq!(
13020 report["runtime_posture"]["approval_policy"]["source"],
13021 "default"
13022 );
13023 }
13024
13025 #[test]
13026 fn doctor_setup_report_json_fails_closed_without_operate_receipts() {
13027 let _guard = crate::test_support::lock_test_env();
13028 let tmp = TempDir::new().expect("tempdir");
13029 let (_home_guard, _codewhale_home) = prepare_env(&tmp);
13030 let workspace = tmp.path().join("workspace");
13031 fs::create_dir_all(&workspace).expect("workspace");
13032 let mut state = codewhale_config::SetupState::default();
13033 state.set_step(
13034 codewhale_config::SetupStep::Language,
13035 codewhale_config::StepEntry::new(
13036 codewhale_config::StepStatus::Verified,
13037 true,
13038 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13039 ),
13040 );
13041 state.set_step(
13042 codewhale_config::SetupStep::ProviderModel,
13043 codewhale_config::StepEntry::new(
13044 codewhale_config::StepStatus::Verified,
13045 true,
13046 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13047 ),
13048 );
13049 state.runtime_posture_source = codewhale_config::RuntimePostureSource::Confirmed;
13050 state.complete_constitution_checkpoint(
13051 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13052 codewhale_config::ConstitutionChoice::Bundled,
13053 );
13054 state.set_step(
13055 codewhale_config::SetupStep::OperateFleet,
13056 codewhale_config::StepEntry::new(
13057 codewhale_config::StepStatus::Verified,
13058 false,
13059 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13060 )
13061 .with_result(
13062 "provider=ready, runtime=ready, roster=ready, concurrency=plan limit not probed",
13063 ),
13064 );
13065 state.save().expect("persist setup state");
13066
13067 let config = Config {
13068 api_key: Some("TEST-STRUCTURAL-LITERAL".to_string()),
13069 ..Config::default()
13070 };
13071 let report = doctor_setup_report_json(&config, &workspace);
13072
13073 assert_eq!(report["first_run_ready"], true);
13074 assert_eq!(report["operate_ready"], false);
13075 assert_eq!(
13076 report["operate_fleet"]["concurrency"]["plan_limit_probed"],
13077 false
13078 );
13079 assert!(
13080 report["operate_fleet"]["roster"]["built_in"]
13081 .as_u64()
13082 .is_some_and(|count| count > 0)
13083 );
13084 let operate_step = report["steps"]
13085 .as_array()
13086 .expect("steps array")
13087 .iter()
13088 .find(|step| step["step"] == "operate_fleet")
13089 .expect("operate/fleet step");
13090 assert_eq!(operate_step["status"], "verified");
13091 assert!(
13092 operate_step["result"]
13093 .as_str()
13094 .is_some_and(|result| result.contains("plan limit not probed"))
13095 );
13096 }
13097}
13098
13099#[cfg(test)]
13100mod doctor_endpoint_tests {
13101 use super::*;
13102
13103 #[test]
13104 fn doctor_api_target_reports_default_endpoint() {
13105 let config = Config::default();
13106
13107 let target = doctor_api_target(&config);
13108
13109 assert_eq!(target.provider, "deepseek");
13110 assert_eq!(target.base_url, crate::config::DEFAULT_DEEPSEEK_BASE_URL);
13111 assert_eq!(target.model, crate::config::DEFAULT_TEXT_MODEL);
13112 assert_eq!(target.resolution, DoctorModelResolution::Resolved);
13113 }
13114
13115 #[test]
13116 fn doctor_api_target_falls_back_to_configured_model_when_resolution_fails() {
13117 let config = Config {
13121 provider: Some("custom".to_string()),
13122 ..Default::default()
13123 };
13124
13125 let target = doctor_api_target(&config);
13126
13127 assert_eq!(target.resolution, DoctorModelResolution::ConfiguredOnly);
13128 assert_eq!(target.model, config.default_model());
13129 }
13130
13131 #[test]
13132 fn doctor_api_target_routes_deepseek_cn_alias_to_beta_endpoint() {
13133 let config = Config {
13134 provider: Some("deepseek-cn".to_string()),
13135 ..Default::default()
13136 };
13137
13138 let target = doctor_api_target(&config);
13139
13140 assert_eq!(target.provider, "deepseek-cn");
13141 assert_eq!(target.base_url, crate::config::DEFAULT_DEEPSEEKCN_BASE_URL);
13142 assert_eq!(target.base_url, crate::config::DEFAULT_DEEPSEEK_BASE_URL);
13143 assert_eq!(target.model, crate::config::DEFAULT_TEXT_MODEL);
13144 assert_eq!(target.resolution, DoctorModelResolution::Resolved);
13145 }
13146
13147 #[test]
13148 fn strict_tool_mode_doctor_reports_disabled_by_default() {
13149 let config = Config::default();
13150
13151 let status = doctor_strict_tool_mode_status(&config);
13152
13153 assert!(!status.enabled);
13154 assert_eq!(status.status, "disabled");
13155 assert!(!status.function_strict_sent);
13156 assert!(status.recommended_base_url.is_none());
13157 }
13158
13159 #[test]
13160 fn doctor_known_base_urls_are_ascii_case_insensitive() {
13161 assert!(doctor_xiaomi_mimo_base_url_uses_token_plan(
13162 "HTTPS://TOKEN-PLAN-CN.XIAOMIMIMO.COM/V1/"
13163 ));
13164 assert_eq!(
13165 known_deepseek_base_url_kind("HTTPS://API.DEEPSEEK.COM/BETA/"),
13166 Some(DeepSeekBaseUrlKind::Beta)
13167 );
13168 assert_eq!(
13169 known_deepseek_base_url_kind("HTTPS://API.DEEPSEEK.COM/V1/"),
13170 Some(DeepSeekBaseUrlKind::NonBeta)
13171 );
13172 }
13173
13174 #[test]
13175 fn strict_tool_mode_doctor_accepts_default_beta_endpoint() {
13176 let config = Config {
13177 strict_tool_mode: Some(true),
13178 ..Default::default()
13179 };
13180
13181 let status = doctor_strict_tool_mode_status(&config);
13182
13183 assert!(status.enabled);
13184 assert_eq!(status.status, "ready");
13185 assert!(status.function_strict_sent);
13186 assert!(status.message.contains("beta endpoint"));
13187 assert!(status.recommended_base_url.is_none());
13188 }
13189
13190 #[test]
13191 fn strict_tool_mode_doctor_warns_for_non_beta_deepseek_endpoint() {
13192 let config = Config {
13193 strict_tool_mode: Some(true),
13194 base_url: Some("https://api.deepseek.com".to_string()),
13195 ..Default::default()
13196 };
13197
13198 let status = doctor_strict_tool_mode_status(&config);
13199
13200 assert_eq!(status.status, "fallback_non_beta");
13201 assert!(!status.function_strict_sent);
13202 assert_eq!(
13203 status.recommended_base_url.as_deref(),
13204 Some(crate::config::DEFAULT_DEEPSEEK_BASE_URL)
13205 );
13206 assert_eq!(
13207 doctor_strict_tool_mode_report_json(&status)["recommended_base_url"],
13208 "https://api.deepseek.com"
13209 );
13210 }
13211
13212 #[test]
13213 fn strict_tool_mode_doctor_accepts_deepseek_cn_alias_default_endpoint() {
13214 let config = Config {
13215 provider: Some("deepseek-cn".to_string()),
13216 strict_tool_mode: Some(true),
13217 ..Default::default()
13218 };
13219
13220 let status = doctor_strict_tool_mode_status(&config);
13221
13222 assert_eq!(status.status, "ready");
13223 assert!(status.function_strict_sent);
13224 assert!(status.message.contains("beta endpoint"));
13225 assert!(status.recommended_base_url.is_none());
13226 }
13227
13228 #[test]
13229 fn strict_tool_mode_doctor_marks_custom_endpoint_as_forwarded() {
13230 let config = Config {
13231 provider: Some("vllm".to_string()),
13232 strict_tool_mode: Some(true),
13233 ..Default::default()
13234 };
13235
13236 let status = doctor_strict_tool_mode_status(&config);
13237
13238 assert_eq!(status.status, "custom_endpoint");
13239 assert!(status.function_strict_sent);
13240 assert!(status.message.contains("custom endpoint"));
13241 }
13242
13243 #[test]
13244 fn doctor_tls_status_reports_verification_enabled_by_default() {
13245 let status = doctor_tls_status(&Config::default());
13246
13247 assert!(status.certificate_verification);
13248 assert!(!status.insecure_skip_tls_verify);
13249 assert_eq!(status.provider, "deepseek");
13250 assert!(status.message.contains("enabled"));
13251 }
13252
13253 #[test]
13254 fn doctor_tls_status_warns_when_active_provider_skips_verification() {
13255 let mut providers = crate::config::ProvidersConfig::default();
13256 providers.openai.insecure_skip_tls_verify = Some(true);
13257 let config = Config {
13258 provider: Some("openai".to_string()),
13259 providers: Some(providers),
13260 ..Default::default()
13261 };
13262
13263 let status = doctor_tls_status(&config);
13264
13265 assert!(status.certificate_verification);
13266 assert!(status.insecure_skip_tls_verify);
13267 assert_eq!(status.provider, "openai");
13268 assert!(status.message.contains("cannot be disabled"));
13269 assert!(status.message.contains("SSL_CERT_FILE"));
13270 }
13271
13272 #[test]
13273 fn provider_capability_report_exposes_alias_deprecation_for_deepseek_chat() {
13274 let mut config = Config {
13275 default_text_model: Some("deepseek-chat".to_string()),
13276 ..Default::default()
13277 };
13278 crate::config::normalize_model_config_for_test(&mut config);
13279
13280 let report = provider_capability_report(&config);
13281
13282 assert_eq!(report["resolved_model"], "deepseek-v4-flash");
13283 assert_eq!(report["context_window"], 1_000_000);
13284 assert_eq!(report["thinking_supported"], true);
13285 assert_eq!(report["alias_deprecation"]["alias"], "deepseek-chat");
13286 assert_eq!(
13287 report["alias_deprecation"]["replacement"],
13288 "deepseek-v4-flash"
13289 );
13290 assert_eq!(
13291 report["alias_deprecation"]["retirement_utc"],
13292 "2026-07-24T15:59:00Z"
13293 );
13294 }
13295
13296 #[test]
13297 fn provider_capability_report_preserves_custom_deepseek_alias_namespace() {
13298 let mut config = Config {
13299 base_url: Some("https://models.example/v1".to_string()),
13300 default_text_model: Some("deepseek-chat".to_string()),
13301 ..Default::default()
13302 };
13303 crate::config::normalize_model_config_for_test(&mut config);
13304
13305 let report = provider_capability_report(&config);
13306
13307 assert_eq!(report["resolved_model"], "deepseek-chat");
13308 assert!(report["alias_deprecation"].is_null());
13309 }
13310
13311 #[test]
13312 fn provider_capability_report_leaves_canonical_flash_alias_metadata_null() {
13313 let config = Config {
13314 default_text_model: Some("deepseek-v4-flash".to_string()),
13315 ..Default::default()
13316 };
13317
13318 let report = provider_capability_report(&config);
13319
13320 assert_eq!(report["resolved_model"], "deepseek-v4-flash");
13321 assert!(report["alias_deprecation"].is_null());
13322 }
13323
13324 #[test]
13325 fn doctor_route_report_exposes_tokenhub_openai_compatible_route_without_secret() {
13326 let mut providers = crate::config::ProvidersConfig::default();
13327 providers.openai.api_key = Some("tokenhub-secret-value".to_string());
13328 providers.openai.base_url = Some("https://tokenhub.tencentmaas.com/v1".to_string());
13329 providers.openai.model = Some("deepseek-ai/DeepSeek-V4-Pro".to_string());
13330 let config = Config {
13331 provider: Some("openai".to_string()),
13332 providers: Some(providers),
13333 ..Default::default()
13334 };
13335
13336 let report = doctor_route_report(&config);
13337 let serialized = report.to_string();
13338
13339 assert_eq!(report["provider"], "openai");
13340 assert_eq!(report["provider_source"], "config");
13341 assert_eq!(report["provider_config_table"], "openai");
13342 assert_eq!(report["model"], "deepseek-ai/DeepSeek-V4-Pro");
13343 assert_eq!(report["wire_protocol"], "chat_completions");
13344 assert_eq!(
13345 report["base_url"]["redacted"],
13346 "https://tokenhub.tencentmaas.com"
13347 );
13348 assert_eq!(report["base_url"]["class"], "custom");
13349 assert_eq!(report["auth"]["scheme"], "bearer");
13350 assert_eq!(report["auth"]["source"], "config_declared");
13351 assert!(
13352 report["base_url"]["fingerprint"]
13353 .as_str()
13354 .is_some_and(|value| value.starts_with("<redacted:"))
13355 );
13356 assert!(!serialized.contains("tokenhub-secret-value"));
13357 }
13358
13359 #[test]
13360 fn doctor_route_report_exposes_siliconflow_cn_provider_route() {
13361 let mut providers = crate::config::ProvidersConfig::default();
13362 providers.siliconflow_cn.api_key = Some("sf-cn-secret-value".to_string());
13363 providers.siliconflow_cn.base_url =
13364 Some(crate::config::DEFAULT_SILICONFLOW_CN_BASE_URL.to_string());
13365 providers.siliconflow_cn.model = Some(crate::config::DEFAULT_SILICONFLOW_MODEL.to_string());
13366 let config = Config {
13367 provider: Some("siliconflow-CN".to_string()),
13368 providers: Some(providers),
13369 ..Default::default()
13370 };
13371
13372 let report = doctor_route_report(&config);
13373 let serialized = report.to_string();
13374
13375 assert_eq!(report["provider"], "siliconflow-CN");
13376 assert_eq!(report["provider_config_table"], "siliconflow_cn");
13377 assert_eq!(report["model"], crate::config::DEFAULT_SILICONFLOW_MODEL);
13378 assert_eq!(
13379 report["base_url"]["redacted"],
13380 crate::doctor::structural_url_authority(crate::config::DEFAULT_SILICONFLOW_CN_BASE_URL)
13381 );
13382 assert_eq!(report["base_url"]["class"], "default");
13383 assert_eq!(report["auth"]["scheme"], "bearer");
13384 assert_eq!(report["auth"]["source"], "config_declared");
13385 assert!(!serialized.contains("sf-cn-secret-value"));
13386 }
13387
13388 #[test]
13389 fn doctor_route_report_names_kimi_code_context_provenance() {
13390 let config = Config {
13391 provider: Some("moonshot".to_string()),
13392 providers: Some(crate::config::ProvidersConfig {
13393 moonshot: crate::config::ProviderConfig {
13394 api_key: Some("kimi-plan-secret".to_string()),
13395 base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
13396 model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
13397 ..Default::default()
13398 },
13399 ..Default::default()
13400 }),
13401 ..Default::default()
13402 };
13403
13404 let report = doctor_route_report(&config);
13405 let serialized = report.to_string();
13406
13407 assert_eq!(report["context_window"]["tokens"], 262_144);
13408 assert_eq!(
13409 report["context_window"]["source"],
13410 "static Kimi Code safe floor"
13411 );
13412 assert!(!serialized.contains("kimi-plan-secret"));
13413 }
13414
13415 #[test]
13416 fn provider_capability_report_uses_exact_kimi_code_route_facts() {
13417 let config = Config {
13418 provider: Some("moonshot".to_string()),
13419 providers: Some(crate::config::ProvidersConfig {
13420 moonshot: crate::config::ProviderConfig {
13421 api_key: Some("kimi-plan-secret".to_string()),
13422 base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
13423 model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
13424 ..Default::default()
13425 },
13426 ..Default::default()
13427 }),
13428 ..Default::default()
13429 };
13430
13431 let report = provider_capability_report(&config);
13432
13433 assert_eq!(report["resolved_model"], crate::config::KIMI_CODE_K3_MODEL);
13434 assert_eq!(report["context_window"], 262_144);
13435 assert_eq!(
13436 report["context_window_source"],
13437 "static Kimi Code safe floor"
13438 );
13439 assert_eq!(report["thinking_supported"], true);
13440 }
13441
13442 #[test]
13443 fn provider_capability_report_honors_kimi_code_context_override() {
13444 let config = Config {
13445 provider: Some("moonshot".to_string()),
13446 providers: Some(crate::config::ProvidersConfig {
13447 moonshot: crate::config::ProviderConfig {
13448 api_key: Some("kimi-plan-secret".to_string()),
13449 base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
13450 model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
13451 context_window: Some(1_048_576),
13452 ..Default::default()
13453 },
13454 ..Default::default()
13455 }),
13456 ..Default::default()
13457 };
13458
13459 let report = provider_capability_report(&config);
13460
13461 assert_eq!(
13462 report["resolved_model"],
13463 crate::config::KIMI_CODE_K3_MODEL,
13464 "the configured window must preserve Kimi Code's bare wire id"
13465 );
13466 assert_eq!(report["context_window"], 1_048_576);
13467 assert_eq!(report["context_window_source"], "configured");
13468 assert_eq!(report["thinking_supported"], true);
13469 }
13470
13471 #[test]
13472 fn provider_capability_report_uses_direct_moonshot_k3_route_facts() {
13473 let config = Config {
13474 provider: Some("moonshot".to_string()),
13475 providers: Some(crate::config::ProvidersConfig {
13476 moonshot: crate::config::ProviderConfig {
13477 api_key: Some("moonshot-secret".to_string()),
13478 base_url: Some(crate::config::DEFAULT_MOONSHOT_BASE_URL.to_string()),
13479 model: Some("kimi-k3".to_string()),
13480 ..Default::default()
13481 },
13482 ..Default::default()
13483 }),
13484 ..Default::default()
13485 };
13486
13487 let report = provider_capability_report(&config);
13488
13489 assert_eq!(report["resolved_model"], "kimi-k3");
13490 assert_eq!(report["context_window"], 1_048_576);
13491 assert_eq!(report["context_window_source"], "catalog");
13492 assert_eq!(report["max_output"], 1_048_576);
13493 assert_eq!(report["thinking_supported"], true);
13494 }
13495
13496 #[test]
13497 fn doctor_search_provider_line_includes_firecrawl_default_source_and_switch_hint() {
13498 let _guard = crate::test_support::lock_test_env();
13499 let prev = std::env::var_os("DEEPSEEK_SEARCH_PROVIDER");
13500 unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") };
13501
13502 let line = doctor_search_provider_line(&Config::default());
13503
13504 match prev {
13505 Some(value) => unsafe { std::env::set_var("DEEPSEEK_SEARCH_PROVIDER", value) },
13506 None => unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") },
13507 }
13508 assert!(line.contains("search_provider: firecrawl"));
13509 assert!(line.contains("source: default"));
13510 assert!(line.contains("[search] provider"));
13511 assert!(line.contains("provider = \"baidu\""));
13512 }
13513
13514 #[test]
13515 fn doctor_search_provider_json_reports_config_source() {
13516 let _guard = crate::test_support::lock_test_env();
13517 let prev = std::env::var_os("DEEPSEEK_SEARCH_PROVIDER");
13518 unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") };
13519 let config = Config {
13520 search: Some(crate::config::SearchConfig {
13521 provider: Some(crate::config::SearchProvider::DuckDuckGo),
13522 base_url: None,
13523 api_key: None,
13524 }),
13525 ..Default::default()
13526 };
13527
13528 let report = doctor_search_provider_json(&config);
13529
13530 match prev {
13531 Some(value) => unsafe { std::env::set_var("DEEPSEEK_SEARCH_PROVIDER", value) },
13532 None => unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") },
13533 }
13534 assert_eq!(report["provider"], "duckduckgo");
13535 assert_eq!(report["source"], "config");
13536 }
13537
13538 #[test]
13539 fn doctor_search_provider_json_reports_env_override_source() {
13540 let _guard = crate::test_support::lock_test_env();
13541 let prev = std::env::var_os("DEEPSEEK_SEARCH_PROVIDER");
13542 unsafe { std::env::set_var("DEEPSEEK_SEARCH_PROVIDER", "tavily") };
13543
13544 let report = doctor_search_provider_json(&Config::default());
13545
13546 match prev {
13547 Some(value) => unsafe { std::env::set_var("DEEPSEEK_SEARCH_PROVIDER", value) },
13548 None => unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") },
13549 }
13550 assert_eq!(report["provider"], "tavily");
13551 assert_eq!(report["source"], "env override");
13552 }
13553
13554 #[test]
13555 fn doctor_search_provider_line_omits_switch_hint_when_bing_is_configured() {
13556 let _guard = crate::test_support::lock_test_env();
13557 let prev = std::env::var_os("DEEPSEEK_SEARCH_PROVIDER");
13558 unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") };
13559 let config = Config {
13560 search: Some(crate::config::SearchConfig {
13561 provider: Some(crate::config::SearchProvider::Bing),
13562 base_url: None,
13563 api_key: None,
13564 }),
13565 ..Default::default()
13566 };
13567
13568 let line = doctor_search_provider_line(&config);
13569
13570 match prev {
13571 Some(value) => unsafe { std::env::set_var("DEEPSEEK_SEARCH_PROVIDER", value) },
13572 None => unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") },
13573 }
13574 assert!(line.contains("search_provider: bing"));
13575 assert!(line.contains("source: config"));
13576 assert!(!line.contains("[search] provider"));
13577 }
13578
13579 #[test]
13580 fn timeout_recovery_keeps_default_deepseek_users_on_default_endpoint() {
13581 let config = Config::default();
13582
13583 let text = doctor_timeout_recovery_lines(&config).join("\n");
13584
13585 assert!(text.contains("api.deepseek.com"));
13586 assert!(text.contains("custom DeepSeek-compatible endpoint"));
13587 assert!(!text.contains("provider = \"deepseek-cn\""));
13588 assert!(text.contains("codewhale doctor --json"));
13589 }
13590
13591 #[test]
13592 fn timeout_recovery_for_custom_provider_checks_openai_compatibility() {
13593 let config = Config {
13594 provider: Some("vllm".to_string()),
13595 ..Default::default()
13596 };
13597
13598 let text = doctor_timeout_recovery_lines(&config).join("\n");
13599
13600 assert!(text.contains("/v1/models"));
13601 assert!(text.contains("/v1/chat/completions"));
13602 assert!(!text.contains("api.deepseeki.com"));
13603 }
13604}
13605
13606#[cfg(test)]
13607mod terminal_mode_tests {
13608 use super::*;
13609 use clap::Parser;
13610
13611 fn parse_cli(args: &[&str]) -> Cli {
13612 Cli::try_parse_from(args).expect("CLI args should parse")
13613 }
13614
13615 #[test]
13616 fn headless_consultant_authority_overrides_network_allow_and_disables_web_search() {
13617 let config = Config {
13618 network: Some(crate::config::NetworkPolicyToml {
13619 default: "allow".to_string(),
13620 audit: false,
13621 ..crate::config::NetworkPolicyToml::default()
13622 }),
13623 ..Config::default()
13624 };
13625 let authority = crate::tools::spec::ToolAuthorityEnvelope {
13626 schema_version: 1,
13627 owner: "consultant-1".to_string(),
13628 authority: crate::tools::spec::ToolMutationAuthority::ReadOnly,
13629 network_access: Some(false),
13630 shell: crate::tools::spec::ToolShellAuthority::None,
13631 verification: crate::tools::spec::ToolVerificationAuthority::None,
13632 writable_roots: Vec::new(),
13633 writable_files: Vec::new(),
13634 coordination_contracts: Vec::new(),
13635 }
13636 .normalized()
13637 .expect("Consultant authority");
13638
13639 let policy = exec_network_policy(&config, authority.network_access)
13640 .expect("explicit network=false always installs a policy");
13641 assert_eq!(
13642 policy.evaluate("example.com", "web_search"),
13643 crate::network_policy::Decision::Deny,
13644 "the permissive user config must not widen Consultant network authority"
13645 );
13646 let mut features = crate::features::Features::default();
13647 features.enable(crate::features::Feature::ShellTool);
13648 features.enable(crate::features::Feature::WebSearch);
13649 apply_fleet_engine_feature_caps(
13650 &mut features,
13651 true,
13652 authority.network_access,
13653 authority.shell,
13654 );
13655 assert!(!features.enabled(crate::features::Feature::WebSearch));
13656 assert!(!features.enabled(crate::features::Feature::ShellTool));
13657
13658 let worker_policy = exec_network_policy(&config, Some(true)).expect("configured policy");
13659 assert_eq!(
13660 worker_policy.evaluate("example.com", "web_search"),
13661 crate::network_policy::Decision::Allow,
13662 "a network-capable role keeps the configured policy"
13663 );
13664 }
13665 #[test]
13666 fn hidden_remote_control_flag_starts_the_interactive_handoff() {
13667 let cli = parse_cli(&["codewhale-tui", "--remote-control"]);
13668 assert!(cli.remote_control);
13669 }
13670
13671 #[test]
13672 fn plugin_registry_discovery_is_route_independent_and_read_only() {
13673 let _env_lock = crate::test_support::lock_test_env();
13674 let temp = tempfile::tempdir().unwrap();
13675 let workspace = temp.path().join("workspace");
13676 let codewhale_home = temp.path().join("home");
13677 std::fs::create_dir_all(&workspace).unwrap();
13678 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
13679 let workspace_arg = workspace.to_string_lossy().into_owned();
13680
13681 for route in [
13682 Vec::<&str>::new(),
13683 vec!["resume", "--last"],
13684 vec!["fork", "--last"],
13685 vec!["exec", "hello"],
13686 vec!["serve", "--mcp"],
13687 ] {
13688 let mut args = vec![
13689 "codewhale-tui".to_string(),
13690 "--workspace".to_string(),
13691 workspace_arg.clone(),
13692 ];
13693 args.extend(route.into_iter().map(str::to_string));
13694 let cli = Cli::try_parse_from(args).expect("route should parse");
13695 let discovery = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv();
13696 let registry = discovery
13697 .registry_for_workspace(cli.workspace.as_deref().unwrap_or(workspace.as_path()));
13698 assert_eq!(registry.workspace(), workspace.as_path());
13699 assert!(
13700 !codewhale_home.join("plugins/state.json").exists(),
13701 "startup discovery must remain read-only"
13702 );
13703 }
13704 }
13705
13706 fn custom_exec_config(active: &str) -> Config {
13707 let mut custom = std::collections::HashMap::new();
13708 for (name, base_url, model) in [
13709 (
13710 "custom-a",
13711 "http://127.0.0.1:18181/v1",
13712 crate::config::ZAI_GLM_5_2_MODEL,
13713 ),
13714 ("custom-b", "http://127.0.0.1:18182/v1", "model-b"),
13715 ] {
13716 custom.insert(
13717 name.to_string(),
13718 crate::config::ProviderConfig {
13719 kind: Some("openai-compatible".to_string()),
13720 base_url: Some(base_url.to_string()),
13721 model: Some(model.to_string()),
13722 api_key: Some("local-test-key".to_string()),
13723 ..Default::default()
13724 },
13725 );
13726 }
13727 Config {
13728 provider: Some(active.to_string()),
13729 providers: Some(crate::config::ProvidersConfig {
13730 custom,
13731 ..Default::default()
13732 }),
13733 ..Default::default()
13734 }
13735 }
13736
13737 #[test]
13738 fn doctor_json_surfaces_keep_exact_named_custom_provider() {
13739 let config = custom_exec_config("custom-a");
13740 let workspace = tempfile::tempdir().expect("doctor workspace");
13741
13742 let operate = doctor_operate_fleet_report_json(&config, workspace.path());
13743 let provider_model = doctor_provider_model_report_json(&config);
13744 let capability = provider_capability_report(&config);
13745 let route = doctor_route_report(&config);
13746
13747 assert_eq!(operate["provider"]["id"], "custom-a");
13748 assert_eq!(provider_model["provider"]["id"], "custom-a");
13749 assert_eq!(capability["resolved_provider"], "custom-a");
13750 assert_eq!(route["provider"], "custom-a");
13751 assert_eq!(route["provider_config_table"], "providers.custom-a");
13752 let serialized = serde_json::to_string(&serde_json::json!({
13753 "operate": operate,
13754 "provider_model": provider_model,
13755 "capability": capability,
13756 "route": route,
13757 }))
13758 .expect("doctor JSON");
13759 assert!(!serialized.contains("local-test-key"));
13760 }
13761
13762 fn saved_exec_session(provider: &str, model: &str) -> session_manager::SavedSession {
13763 let mut saved = session_manager::create_saved_session_with_mode(
13764 &[],
13765 model,
13766 Path::new("/tmp/exec-resume"),
13767 0,
13768 None,
13769 Some("exec"),
13770 );
13771 let kind = crate::config::ApiProvider::parse(provider)
13772 .unwrap_or(crate::config::ApiProvider::Custom)
13773 .as_str();
13774 let exact_id = (!provider
13775 .eq_ignore_ascii_case(crate::config::ApiProvider::Custom.as_str()))
13776 .then_some(provider);
13777 saved.metadata.set_model_provider_route(kind, exact_id);
13778 saved
13779 }
13780
13781 #[test]
13782 fn prompt_flag_accepts_split_prompt_words_for_windows_cmd_shims() {
13783 let cli = parse_cli(&["codewhale", "-p", "hello", "world"]);
13784
13785 assert_eq!(cli.prompt, vec!["hello", "world"]);
13786 }
13787
13788 #[test]
13789 fn prompt_flag_starts_interactive_submit_input() {
13790 let cli = parse_cli(&["codewhale", "-p", "read", "the", "project"]);
13791
13792 assert_eq!(
13793 top_level_prompt_initial_input(&cli.prompt),
13794 Some(tui::InitialInput::Submit("read the project".to_string()))
13795 );
13796 }
13797
13798 #[test]
13799 fn companion_binary_reports_its_own_name() {
13800 assert_eq!(Cli::command().get_name(), "codewhale-tui");
13801 }
13802
13803 #[test]
13804 fn xai_device_auth_subcommand_parses() {
13805 let cli = parse_cli(&["codewhale-tui", "auth", "xai-device"]);
13806 assert!(matches!(
13807 cli.command,
13808 Some(Commands::Auth(TuiAuthArgs {
13809 command: TuiAuthCommand::XaiDevice
13810 }))
13811 ));
13812 }
13813
13814 #[test]
13815 fn workflow_tool_internal_subcommand_parses_exact_json() {
13816 let cli = parse_cli(&[
13817 "codewhale-tui",
13818 "workflow-tool",
13819 "--approval-source",
13820 "explicit-workflow-command",
13821 "--input-json",
13822 r#"{"action":"run","source_path":"workflows/demo.js"}"#,
13823 ]);
13824 let Some(Commands::WorkflowTool(args)) = cli.command else {
13825 panic!("expected workflow-tool command");
13826 };
13827 assert!(args.input_json.contains("\"action\":\"run\""));
13828 }
13829
13830 #[tokio::test]
13831 async fn direct_workflow_tool_runs_without_an_operator_model_turn() {
13832 use crate::tools::spec::ToolSpec;
13833
13834 let workspace = tempfile::tempdir().expect("workspace");
13835 let config = Config {
13836 provider: Some("vllm".to_string()),
13837 mcp_config_path: Some(
13838 workspace
13839 .path()
13840 .join("missing-mcp.json")
13841 .display()
13842 .to_string(),
13843 ),
13844 providers: Some(crate::config::ProvidersConfig {
13845 vllm: crate::config::ProviderConfig {
13846 base_url: Some("http://127.0.0.1:9/v1".to_string()),
13847 model: Some("offline-test-model".to_string()),
13848 ..Default::default()
13849 },
13850 ..Default::default()
13851 }),
13852 ..Default::default()
13853 };
13854 let route = CliAutoRoute {
13855 provider: crate::config::ApiProvider::Vllm,
13856 model: "offline-test-model".to_string(),
13857 reasoning_effort: None,
13858 auto_controls_reasoning: false,
13859 auto_model: false,
13860 };
13861 let (event_tx, mut event_rx) = tokio::sync::mpsc::channel(64);
13862 let plugins = Arc::new(crate::plugins::PluginRegistry::empty(workspace.path()));
13863 let (tool, context) =
13864 build_direct_workflow_tool(&config, &route, workspace.path(), event_tx, plugins)
13865 .await
13866 .expect("build direct workflow runtime");
13867
13868 let result = tool
13869 .execute(
13870 serde_json::json!({
13871 "action": "run",
13872 "script": "phase('offline'); return { ok: true };",
13873 "token_budget": 1_000_000
13874 }),
13875 &context,
13876 )
13877 .await
13878 .expect("model-free workflow run");
13879 let payload: serde_json::Value =
13880 serde_json::from_str(&result.content).expect("workflow JSON");
13881
13882 assert_eq!(payload["status"], "completed");
13883 assert_eq!(payload["result"]["ok"], true);
13884 assert_eq!(payload["child_ids"].as_array().map(Vec::len), Some(0));
13885 assert_eq!(
13886 payload["plan_approval"]["decision"],
13887 "approved_explicit_cli_command"
13888 );
13889 assert!(!context.auto_approve);
13890 assert!(!context.trust_mode);
13891 assert_eq!(
13892 context.shell_policy,
13893 crate::worker_profile::ShellPolicy::None
13894 );
13895 assert!(matches!(
13896 context.elevated_sandbox_policy,
13897 Some(crate::sandbox::SandboxPolicy::WorkspaceWrite { .. })
13898 ));
13899 let mut event_types = Vec::new();
13900 while let Ok(event) = event_rx.try_recv() {
13901 if let crate::core::events::Event::WorkflowUi { event, .. } = event
13902 && let Some(kind) = event["type"].as_str()
13903 {
13904 event_types.push(kind.to_string());
13905 }
13906 }
13907 assert!(event_types.iter().any(|kind| kind == "run_started"));
13908 assert!(event_types.iter().any(|kind| kind == "run_completed"));
13909 }
13910
13911 #[tokio::test]
13912 async fn direct_workflow_mcp_pool_applies_network_policy_before_connect() {
13913 let workspace = tempfile::tempdir().expect("workspace");
13914 let mcp_path = workspace.path().join("mcp.json");
13915 std::fs::write(
13916 &mcp_path,
13917 r#"{
13918 "mcpServers": {
13919 "blocked": { "url": "https://blocked.invalid/mcp" }
13920 }
13921 }"#,
13922 )
13923 .expect("write MCP config");
13924 let config = Config {
13925 mcp_config_path: Some(mcp_path.display().to_string()),
13926 ..Default::default()
13927 };
13928 let policy = crate::network_policy::NetworkPolicyDecider::new(
13929 crate::network_policy::NetworkPolicy {
13930 default: crate::network_policy::DecisionToml::Deny,
13931 allow: Vec::new(),
13932 deny: Vec::new(),
13933 proxy: Vec::new(),
13934 proxy_fake_ip_cidrs: Vec::new(),
13935 audit: false,
13936 },
13937 None,
13938 );
13939
13940 let plugins = Arc::new(crate::plugins::PluginRegistry::empty(workspace.path()));
13941 let (_pool, failures) =
13942 initialize_direct_workflow_mcp_pool(&config, workspace.path(), Some(policy), plugins)
13943 .await
13944 .expect("MCP feature enabled");
13945 assert_eq!(failures.len(), 1, "failures={failures:?}");
13946 assert_eq!(failures[0].0, "blocked");
13947 assert!(failures[0].1.contains("blocked by network policy"));
13948 }
13949
13950 #[test]
13951 fn exec_model_resolution_uses_provider_scoped_default() {
13952 let _env_lock = crate::test_support::lock_test_env();
13953 let _codewhale_model = crate::test_support::EnvVarGuard::remove("CODEWHALE_MODEL");
13954 let _deepseek_model = crate::test_support::EnvVarGuard::remove("DEEPSEEK_MODEL");
13955 let config = Config {
13956 provider: Some("openrouter".to_string()),
13957 default_text_model: Some("deepseek/deepseek-v4-pro".to_string()),
13958 providers: Some(crate::config::ProvidersConfig {
13959 openrouter: crate::config::ProviderConfig {
13960 model: Some("arcee-ai/trinity-large-thinking".to_string()),
13961 ..Default::default()
13962 },
13963 ..Default::default()
13964 }),
13965 ..Default::default()
13966 };
13967
13968 assert_eq!(
13969 resolve_exec_model(&config, None),
13970 "arcee-ai/trinity-large-thinking"
13971 );
13972 assert_eq!(
13973 resolve_exec_model(&config, Some("arcee-ai/trinity-large-thinking")),
13974 "arcee-ai/trinity-large-thinking"
13975 );
13976 }
13977
13978 #[test]
13979 fn exec_model_resolution_prefers_codewhale_model_env_override() {
13980 let _env_lock = crate::test_support::lock_test_env();
13981 let _codewhale_model = crate::test_support::EnvVarGuard::set("CODEWHALE_MODEL", " auto ");
13982 let _deepseek_model =
13983 crate::test_support::EnvVarGuard::set("DEEPSEEK_MODEL", "stale-deepseek-model");
13984 let config = Config {
13985 default_text_model: Some("deepseek/deepseek-v4-pro".to_string()),
13986 ..Default::default()
13987 };
13988
13989 assert_eq!(resolve_exec_model(&config, None), "auto");
13990 }
13991
13992 #[test]
13993 fn exec_model_resolution_uses_legacy_deepseek_model_env_override() {
13994 let _env_lock = crate::test_support::lock_test_env();
13995 let _codewhale_model = crate::test_support::EnvVarGuard::remove("CODEWHALE_MODEL");
13996 let _deepseek_model = crate::test_support::EnvVarGuard::set("DEEPSEEK_MODEL", " auto ");
13997 let config = Config {
13998 default_text_model: Some("deepseek/deepseek-v4-pro".to_string()),
13999 ..Default::default()
14000 };
14001
14002 assert_eq!(resolve_exec_model(&config, None), "auto");
14003 }
14004
14005 #[test]
14006 fn exec_model_resolution_uses_provider_safe_default_for_zai() {
14007 let _env_lock = crate::test_support::lock_test_env();
14008 let _codewhale_model = crate::test_support::EnvVarGuard::remove("CODEWHALE_MODEL");
14009 let _deepseek_model = crate::test_support::EnvVarGuard::remove("DEEPSEEK_MODEL");
14010 let config = Config {
14011 provider: Some("zai".to_string()),
14012 default_text_model: Some(crate::config::DEFAULT_TEXT_MODEL.to_string()),
14013 ..Default::default()
14014 };
14015
14016 assert_eq!(
14017 resolve_exec_model(&config, None),
14018 crate::config::DEFAULT_ZAI_MODEL
14019 );
14020 }
14021
14022 #[tokio::test]
14023 #[allow(clippy::await_holding_lock)]
14024 async fn explicit_exec_model_routes_to_unique_authenticated_provider_candidate() {
14025 let _env_lock = crate::test_support::lock_test_env();
14026 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
14027 let _openrouter = crate::test_support::EnvVarGuard::remove("OPENROUTER_API_KEY");
14028 let config = Config {
14029 provider: Some("deepseek".to_string()),
14030 default_text_model: Some(crate::config::DEFAULT_TEXT_MODEL.to_string()),
14031 ..Default::default()
14032 };
14033
14034 let route = resolve_cli_auto_route(&config, crate::config::ZAI_GLM_5_2_MODEL, "pong")
14035 .await
14036 .expect("explicit GLM should route to the configured Z.ai provider");
14037
14038 assert_eq!(route.provider, crate::config::ApiProvider::Zai);
14039 assert_eq!(route.model, crate::config::ZAI_GLM_5_2_MODEL);
14040 assert!(!route.auto_model);
14041 }
14042
14043 #[tokio::test]
14044 #[allow(clippy::await_holding_lock)]
14045 async fn explicit_exec_model_reports_ambiguous_authenticated_provider_candidates() {
14046 let _env_lock = crate::test_support::lock_test_env();
14047 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
14048 let _openrouter = crate::test_support::EnvVarGuard::set("OPENROUTER_API_KEY", "or-key");
14049 let config = Config {
14050 provider: Some("deepseek".to_string()),
14051 default_text_model: Some(crate::config::DEFAULT_TEXT_MODEL.to_string()),
14052 ..Default::default()
14053 };
14054
14055 let err = resolve_cli_auto_route(&config, crate::config::ZAI_GLM_5_2_MODEL, "pong")
14056 .await
14057 .expect_err("ambiguous GLM route should ask for an explicit provider");
14058 let message = err.to_string();
14059
14060 assert!(message.contains("model `GLM-5.2` is available"));
14061 assert!(message.contains("openrouter"));
14062 assert!(message.contains("zai"));
14063 assert!(message.contains("--provider"));
14064 assert!(message.contains("/provider"));
14065 assert!(message.contains("/model"));
14066 assert!(message.contains("/setup"));
14067 }
14068
14069 #[tokio::test]
14070 async fn cli_auto_model_honors_a_fixed_reasoning_preference() {
14071 let config = Config {
14072 provider: Some("vllm".to_string()),
14073 reasoning_effort: Some("low".to_string()),
14074 providers: Some(crate::config::ProvidersConfig {
14075 vllm: crate::config::ProviderConfig {
14076 base_url: Some("http://127.0.0.1:18190/v1".to_string()),
14077 model: Some("local-auto-model".to_string()),
14078 ..Default::default()
14079 },
14080 ..Default::default()
14081 }),
14082 ..Default::default()
14083 };
14084
14085 let route = resolve_cli_auto_route(&config, "auto", "debug a failing test")
14086 .await
14087 .expect("Auto model route");
14088
14089 assert!(route.auto_model);
14090 assert_eq!(
14091 route.reasoning_effort,
14092 Some(crate::tui::app::ReasoningEffort::Low)
14093 );
14094 assert!(
14095 !route.auto_controls_reasoning,
14096 "a fixed saved tier must not be replaced per prompt"
14097 );
14098 }
14099
14100 #[test]
14101 fn cli_route_execution_config_stamps_routed_model_into_provider_slot() {
14102 let mut providers = crate::config::ProvidersConfig::default();
14103 providers.deepseek.model = Some("deepseek-v4-pro".to_string());
14104 let config = Config {
14105 provider: Some("deepseek".to_string()),
14106 providers: Some(providers),
14107 ..Default::default()
14108 };
14109 let route = CliAutoRoute {
14110 provider: crate::config::ApiProvider::Deepseek,
14111 model: "deepseek-v4-flash".to_string(),
14112 reasoning_effort: None,
14113 auto_controls_reasoning: true,
14114 auto_model: true,
14115 };
14116
14117 let execution_config = config_for_cli_route(&config, &route);
14118
14119 assert_eq!(execution_config.default_model(), "deepseek-v4-flash");
14120 assert_eq!(
14121 execution_config
14122 .provider_config_for(crate::config::ApiProvider::Deepseek)
14123 .and_then(|entry| entry.model.as_deref()),
14124 Some("deepseek-v4-flash")
14125 );
14126 }
14127
14128 #[test]
14129 fn cli_route_execution_config_preserves_legacy_literal_custom_root_route() {
14130 let _lock = crate::test_support::lock_test_env();
14131 let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
14132 let _cli_key = crate::test_support::EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
14133 let config = Config {
14134 provider: Some("custom".to_string()),
14135 api_key: Some("legacy-root-key".to_string()),
14136 base_url: Some("http://127.0.0.1:18183/v1".to_string()),
14137 default_text_model: Some("legacy-model".to_string()),
14138 ..Default::default()
14139 };
14140 let route = CliAutoRoute {
14141 provider: crate::config::ApiProvider::Custom,
14142 model: "routed-legacy-model".to_string(),
14143 reasoning_effort: None,
14144 auto_controls_reasoning: false,
14145 auto_model: false,
14146 };
14147
14148 let execution = config_for_cli_route(&config, &route);
14149
14150 assert!(execution.uses_legacy_literal_custom_route());
14151 assert!(
14152 execution
14153 .providers
14154 .as_ref()
14155 .is_none_or(|providers| !providers.custom.contains_key("custom"))
14156 );
14157 assert_eq!(execution.provider.as_deref(), Some("custom"));
14158 assert_eq!(execution.default_model(), "routed-legacy-model");
14159 assert_eq!(execution.deepseek_base_url(), "http://127.0.0.1:18183/v1");
14160 assert_eq!(execution.deepseek_api_key().unwrap(), "legacy-root-key");
14161 for _ in 0..2 {
14162 let identity = execution
14163 .resolve_provider_identity("custom")
14164 .expect("legacy identity remains repeatedly resolvable");
14165 assert_eq!(identity.key, "custom");
14166 }
14167 let client =
14168 crate::client::DeepSeekClient::new(&execution).expect("legacy execution client");
14169 assert_eq!(client.base_url(), "http://127.0.0.1:18183/v1");
14170 }
14171
14172 #[test]
14173 fn exec_accepts_split_prompt_words_for_windows_cmd_shims() {
14174 let cli = parse_cli(&["codewhale", "exec", "hello", "world"]);
14175 let Some(Commands::Exec(args)) = cli.command else {
14176 panic!("expected exec command");
14177 };
14178
14179 assert_eq!(args.prompt, vec!["hello", "world"]);
14180 }
14181
14182 #[test]
14183 fn exec_keeps_model_flag_before_split_prompt_words() {
14184 let cli = parse_cli(&["codewhale", "exec", "--model", "auto", "hello", "world"]);
14185 let Some(Commands::Exec(args)) = cli.command else {
14186 panic!("expected exec command");
14187 };
14188
14189 assert_eq!(args.model.as_deref(), Some("auto"));
14190 assert_eq!(args.prompt, vec!["hello", "world"]);
14191 }
14192
14193 #[test]
14194 fn exec_keeps_flags_before_split_prompt_words() {
14195 let cli = parse_cli(&["codewhale", "exec", "--json", "hello", "world"]);
14196 let Some(Commands::Exec(args)) = cli.command else {
14197 panic!("expected exec command");
14198 };
14199
14200 assert!(args.json);
14201 assert_eq!(args.prompt, vec!["hello", "world"]);
14202 }
14203
14204 #[test]
14205 fn exec_parses_provider_flag_alongside_model() {
14206 let cli = parse_cli(&[
14209 "codewhale",
14210 "exec",
14211 "--provider",
14212 "openrouter",
14213 "--model",
14214 "glm-5.2",
14215 "audit",
14216 ]);
14217 let Some(Commands::Exec(args)) = cli.command else {
14218 panic!("expected exec command");
14219 };
14220
14221 assert_eq!(args.provider.as_deref(), Some("openrouter"));
14222 assert_eq!(args.model.as_deref(), Some("glm-5.2"));
14223 assert_eq!(args.prompt, vec!["audit"]);
14224 assert_eq!(
14227 crate::config::ApiProvider::parse(args.provider.as_deref().unwrap()),
14228 Some(crate::config::ApiProvider::Openrouter)
14229 );
14230 }
14231
14232 #[test]
14233 fn exec_provider_override_accepts_configured_custom_provider() {
14234 let mut custom = std::collections::HashMap::new();
14235 custom.insert(
14236 "lm-studio".to_string(),
14237 crate::config::ProviderConfig {
14238 kind: Some("openai-compatible".to_string()),
14239 base_url: Some("http://127.0.0.1:1234/v1".to_string()),
14240 model: Some("qwen-2.5-7b".to_string()),
14241 api_key: Some("lm-studio".to_string()),
14242 ..Default::default()
14243 },
14244 );
14245 let mut config = Config {
14246 provider: Some("deepseek".to_string()),
14247 providers: Some(crate::config::ProvidersConfig {
14248 custom,
14249 ..Default::default()
14250 }),
14251 ..Default::default()
14252 };
14253
14254 apply_exec_provider_override(&mut config, "lm-studio")
14255 .expect("configured custom provider should be accepted");
14256
14257 assert_eq!(config.provider.as_deref(), Some("lm-studio"));
14258 assert_eq!(config.api_provider(), crate::config::ApiProvider::Custom);
14259 }
14260
14261 #[test]
14262 fn exec_provider_override_prefers_exact_case_colliding_custom_key() {
14263 let mut config = Config {
14264 provider: Some("deepseek".to_string()),
14265 providers: Some(crate::config::ProvidersConfig {
14266 custom: std::collections::HashMap::from([(
14267 "CUSTOM".to_string(),
14268 crate::config::ProviderConfig {
14269 kind: Some("openai-compatible".to_string()),
14270 base_url: Some("http://127.0.0.1:5678/v1".to_string()),
14271 model: Some("case-model".to_string()),
14272 api_key: Some("case-key".to_string()),
14273 ..Default::default()
14274 },
14275 )]),
14276 ..Default::default()
14277 }),
14278 ..Default::default()
14279 };
14280
14281 apply_exec_provider_override(&mut config, "CUSTOM")
14282 .expect("exact case-colliding custom provider");
14283 assert_eq!(config.provider.as_deref(), Some("CUSTOM"));
14284 assert_eq!(config.api_provider(), crate::config::ApiProvider::Custom);
14285 assert_eq!(
14286 config.provider_identity_for(crate::config::ApiProvider::Custom),
14287 "CUSTOM"
14288 );
14289 let route = crate::route_runtime::resolve_runtime_route(
14290 &config,
14291 crate::config::ApiProvider::Custom,
14292 Some("case-model"),
14293 )
14294 .expect("resolve exact case-colliding route")
14295 .validate()
14296 .expect("preflight exact case-colliding route");
14297 assert_eq!(route.identity.key, "CUSTOM");
14298 assert_eq!(route.client.base_url(), "http://127.0.0.1:5678/v1");
14299 }
14300
14301 #[test]
14302 fn exec_provider_override_rejects_unknown_provider() {
14303 let mut config = Config {
14304 provider: Some("deepseek".to_string()),
14305 ..Default::default()
14306 };
14307
14308 let err = apply_exec_provider_override(&mut config, "lm-studio")
14309 .expect_err("unconfigured custom provider should fail closed");
14310 let message = err.to_string();
14311
14312 assert!(message.contains("Unrecognized --provider"));
14313 assert!(message.contains("[providers.<name>] custom provider"));
14314 assert_eq!(config.provider.as_deref(), Some("deepseek"));
14315 }
14316
14317 #[test]
14318 fn exec_resume_route_matrix_preserves_or_overrides_exact_provider_deliberately() {
14319 let saved = saved_exec_session("custom-a", crate::config::ZAI_GLM_5_2_MODEL);
14320
14321 let mut restored = custom_exec_config("custom-b");
14322 let model = resolve_exec_resume_route(&mut restored, &saved, false, None)
14323 .expect("plain resume restores saved route");
14324 assert_eq!(restored.provider.as_deref(), Some("custom-a"));
14325 assert_eq!(model, crate::config::ZAI_GLM_5_2_MODEL);
14326
14327 let mut explicit_provider = custom_exec_config("custom-a");
14328 apply_exec_provider_override(&mut explicit_provider, "custom-b").expect("custom B");
14329 let model = resolve_exec_resume_route(&mut explicit_provider, &saved, true, None)
14330 .expect("explicit provider wins");
14331 assert_eq!(explicit_provider.provider.as_deref(), Some("custom-b"));
14332 assert_eq!(model, "model-b");
14333
14334 let mut explicit_model = custom_exec_config("custom-b");
14335 let model =
14336 resolve_exec_resume_route(&mut explicit_model, &saved, false, Some("override-model"))
14337 .expect("explicit model keeps saved provider");
14338 assert_eq!(explicit_model.provider.as_deref(), Some("custom-a"));
14339 assert_eq!(model, "override-model");
14340
14341 let mut missing = custom_exec_config("custom-b");
14342 missing
14343 .providers
14344 .as_mut()
14345 .expect("providers")
14346 .custom
14347 .remove("custom-a");
14348 let before = missing.provider.clone();
14349 let err = resolve_exec_resume_route(&mut missing, &saved, false, None)
14350 .expect_err("removed saved provider must fail closed");
14351 assert!(err.to_string().contains("will not fall back"), "{err}");
14352 assert_eq!(missing.provider, before);
14353 }
14354
14355 #[test]
14356 fn exec_model_reads_wait_for_foreign_test_env_overrides_to_restore() {
14357 let (started_tx, started_rx) = std::sync::mpsc::channel();
14358 let (tx, rx) = std::sync::mpsc::channel();
14359
14360 let (reader, expected_after_restore) = {
14361 let lock = crate::test_support::lock_test_env();
14362 let expected_after_restore = exec_model_env_override();
14363 let temporary =
14364 crate::test_support::EnvVarGuard::set("CODEWHALE_MODEL", "temporary-model");
14365 let reader = std::thread::spawn(move || {
14366 started_tx.send(()).expect("signal model read start");
14367 tx.send(exec_model_env_override())
14368 .expect("send resolved model override");
14369 });
14370
14371 started_rx
14372 .recv_timeout(std::time::Duration::from_secs(2))
14373 .expect("reader reached model read");
14374 assert!(
14375 rx.recv_timeout(std::time::Duration::from_millis(50))
14376 .is_err(),
14377 "a foreign reader observed another test's temporary model override"
14378 );
14379 drop(temporary);
14380 drop(lock);
14381 (reader, expected_after_restore)
14382 };
14383
14384 let observed = rx
14385 .recv_timeout(std::time::Duration::from_secs(2))
14386 .expect("reader resumed after model override was restored");
14387 reader.join().expect("reader thread");
14388 assert_eq!(observed, expected_after_restore);
14389 }
14390
14391 #[tokio::test]
14392 async fn forced_exec_route_keeps_custom_provider_when_model_matches_builtin_catalog() {
14393 let config = custom_exec_config("custom-a");
14394
14395 let route =
14396 resolve_cli_exec_route(&config, crate::config::ZAI_GLM_5_2_MODEL, "audit", true)
14397 .await
14398 .expect("forced route");
14399 let execution = config_for_cli_route(&config, &route);
14400
14401 assert_eq!(route.provider, crate::config::ApiProvider::Custom);
14402 assert_eq!(route.model, crate::config::ZAI_GLM_5_2_MODEL);
14403 assert_eq!(execution.provider.as_deref(), Some("custom-a"));
14404 }
14405
14406 #[tokio::test]
14407 async fn no_flag_exec_keeps_configured_named_custom_route_for_matching_builtin_model() {
14408 let mut config = custom_exec_config("custom-a");
14409 config
14410 .providers
14411 .as_mut()
14412 .expect("providers")
14413 .custom
14414 .get_mut("custom-a")
14415 .expect("custom A")
14416 .model = Some(crate::config::ZAI_GLM_5_2_MODEL.to_string());
14417 let model = resolve_exec_model(&config, None);
14418 let force = should_force_configured_exec_route(false, None, None);
14419
14420 assert!(force, "configured/default exec route must be authoritative");
14421 assert!(!should_force_configured_exec_route(
14422 false,
14423 None,
14424 Some(crate::config::ZAI_GLM_5_2_MODEL)
14425 ));
14426 assert!(should_force_configured_exec_route(
14427 false,
14428 Some("custom-a"),
14429 Some(crate::config::ZAI_GLM_5_2_MODEL)
14430 ));
14431 assert!(should_force_configured_exec_route(
14432 true,
14433 None,
14434 Some("override-model")
14435 ));
14436
14437 let route = resolve_cli_exec_route(&config, &model, "audit", force)
14438 .await
14439 .expect("no-flag configured route");
14440 let execution = config_for_cli_route(&config, &route);
14441 assert_eq!(route.provider, crate::config::ApiProvider::Custom);
14442 assert_eq!(route.model, crate::config::ZAI_GLM_5_2_MODEL);
14443 assert_eq!(execution.provider.as_deref(), Some("custom-a"));
14444 }
14445
14446 #[tokio::test]
14447 async fn configured_review_default_keeps_named_custom_route_and_exact_receipt() {
14448 let mut config = custom_exec_config("custom-a");
14449 config
14450 .providers
14451 .as_mut()
14452 .expect("providers")
14453 .custom
14454 .get_mut("custom-a")
14455 .expect("custom A")
14456 .model = Some("model-a".to_string());
14457 config.default_text_model = Some("stale-root-deepseek-model".to_string());
14458 let model = resolve_review_model(&config, None);
14459 assert_eq!(model, "model-a");
14460 assert_eq!(
14461 resolve_review_model(&config, Some("explicit-review-model")),
14462 "explicit-review-model"
14463 );
14464
14465 let route = resolve_cli_exec_route(&config, &model, "review diff", true)
14466 .await
14467 .expect("configured review route");
14468 let execution = config_for_cli_route(&config, &route);
14469 let provider = execution.provider_identity_for(route.provider);
14470
14471 assert_eq!(route.provider, crate::config::ApiProvider::Custom);
14472 assert_eq!(provider, "custom-a");
14473 assert_eq!(execution.deepseek_base_url(), "http://127.0.0.1:18181/v1");
14474 let output = crate::tools::review::ReviewOutput::from_str("{}");
14475 let receipt = crate::tools::review::build_review_receipt(
14476 "working tree",
14477 "diff --git a/a b/a",
14478 provider,
14479 &route.model,
14480 &output,
14481 "{}",
14482 Vec::new(),
14483 );
14484 assert_eq!(receipt.provider, "custom-a");
14485 let serialized = serde_json::to_string(&receipt).expect("review receipt");
14486 assert!(!serialized.contains("127.0.0.1"));
14487 assert!(!serialized.contains("local-test-key"));
14488 }
14489
14490 #[tokio::test]
14491 async fn configured_workflow_default_keeps_named_custom_route() {
14492 let config = custom_exec_config("custom-a");
14493 let model = config.default_model();
14494
14495 let route = resolve_cli_exec_route(
14496 &config,
14497 &model,
14498 "Run a checked-in Workflow through the host runtime",
14499 true,
14500 )
14501 .await
14502 .expect("configured workflow route");
14503 let execution = config_for_cli_route(&config, &route);
14504
14505 assert_eq!(route.provider, crate::config::ApiProvider::Custom);
14506 assert_eq!(execution.provider_identity_for(route.provider), "custom-a");
14507 assert_eq!(execution.deepseek_base_url(), "http://127.0.0.1:18181/v1");
14508 let client = crate::client::DeepSeekClient::new(&execution).expect("workflow client");
14509 assert_eq!(client.base_url(), "http://127.0.0.1:18181/v1");
14510 }
14511
14512 #[test]
14513 fn exec_json_receipts_keep_exact_named_custom_provider() {
14514 let config = custom_exec_config("custom-a");
14515 let provider = config.provider_identity_for(crate::config::ApiProvider::Custom);
14516 let one_shot = one_shot_exec_json_receipt(
14517 provider.clone(),
14518 "model-a".to_string(),
14519 "done".to_string(),
14520 Some("end_turn".to_string()),
14521 crate::models::Usage {
14522 input_tokens: 12,
14523 output_tokens: 3,
14524 ..Default::default()
14525 },
14526 );
14527 assert_eq!(one_shot["provider"], "custom-a");
14528 assert_eq!(one_shot["success"], true);
14529
14530 let truncated = one_shot_exec_json_receipt(
14531 provider.clone(),
14532 "model-a".to_string(),
14533 "partial".to_string(),
14534 Some("max_output_tokens".to_string()),
14535 crate::models::Usage {
14536 input_tokens: 20,
14537 output_tokens: 9,
14538 ..Default::default()
14539 },
14540 );
14541 assert_eq!(truncated["success"], false);
14542 assert_eq!(truncated["stop_reason"], "max_output_tokens");
14543 assert_eq!(truncated["usage"]["input_tokens"], 20);
14544 assert_eq!(truncated["usage"]["output_tokens"], 9);
14545 assert!(truncated["error"].as_str().is_some_and(|error| {
14546 error.contains("Model response incomplete") && error.contains("max_output_tokens")
14547 }));
14548
14549 let agent = serde_json::to_value(ExecSummary {
14550 mode: "agent".to_string(),
14551 provider,
14552 model: "model-a".to_string(),
14553 ..ExecSummary::default()
14554 })
14555 .expect("agent exec JSON receipt");
14556 assert_eq!(agent["provider"], "custom-a");
14557 let serialized = serde_json::to_string(&agent).expect("serialize receipt");
14558 assert!(!serialized.contains("127.0.0.1"));
14559 assert!(!serialized.contains("local-test-key"));
14560 }
14561
14562 #[test]
14563 fn exec_stream_provider_pair_preserves_named_literal_and_root_custom_provenance() {
14564 let named = crate::config::ProviderIdentity {
14565 provider: crate::config::ApiProvider::Custom,
14566 key: "lm-studio".to_string(),
14567 exact_id: Some("lm-studio".to_string()),
14568 };
14569 let literal = crate::config::ProviderIdentity {
14570 provider: crate::config::ApiProvider::Custom,
14571 key: "custom".to_string(),
14572 exact_id: Some("custom".to_string()),
14573 };
14574 let root = crate::config::ProviderIdentity {
14575 provider: crate::config::ApiProvider::Custom,
14576 key: "custom".to_string(),
14577 exact_id: None,
14578 };
14579 let built_in = crate::config::ProviderIdentity {
14580 provider: crate::config::ApiProvider::Deepseek,
14581 key: "deepseek".to_string(),
14582 exact_id: Some("deepseek".to_string()),
14583 };
14584
14585 assert_eq!(
14586 exec_stream_provider_route(&named),
14587 ("custom".to_string(), Some("lm-studio".to_string()))
14588 );
14589 assert_eq!(
14590 exec_stream_provider_route(&literal),
14591 ("custom".to_string(), Some("custom".to_string()))
14592 );
14593 assert_eq!(
14594 exec_stream_provider_route(&root),
14595 ("custom".to_string(), None)
14596 );
14597 assert_eq!(
14598 exec_stream_provider_route(&built_in),
14599 ("deepseek".to_string(), None)
14600 );
14601 }
14602
14603 #[test]
14604 fn resumed_exec_persistence_updates_provider_and_model_as_one_route() {
14605 let saved_a = saved_exec_session("custom-a", crate::config::ZAI_GLM_5_2_MODEL);
14606 let mut config = custom_exec_config("custom-a");
14607 apply_exec_provider_override(&mut config, "custom-b").expect("custom B");
14608 let model = resolve_exec_resume_route(&mut config, &saved_a, true, None)
14609 .expect("explicit provider route");
14610 let mut persisted = saved_a;
14611 stamp_exec_session_metadata(
14612 &mut persisted,
14613 &model,
14614 crate::config::ApiProvider::Custom.as_str(),
14615 Some("custom-b"),
14616 Path::new("/tmp/exec-resume"),
14617 );
14618
14619 let mut next_config = custom_exec_config("custom-a");
14620 let resumed_model = resolve_exec_resume_route(&mut next_config, &persisted, false, None)
14621 .expect("next plain resume");
14622
14623 assert_eq!(persisted.metadata.model_provider, "custom");
14624 assert_eq!(
14625 persisted.metadata.model_provider_id.as_deref(),
14626 Some("custom-b")
14627 );
14628 assert_eq!(persisted.metadata.model, "model-b");
14629 assert_eq!(next_config.provider.as_deref(), Some("custom-b"));
14630 assert_eq!(resumed_model, "model-b");
14631 }
14632
14633 #[test]
14634 fn exec_persistence_omits_id_for_legacy_root_custom_route() {
14635 let mut saved = session_manager::create_saved_session_with_mode(
14636 &[],
14637 "legacy-root-model",
14638 Path::new("/tmp/exec-root"),
14639 0,
14640 None,
14641 Some("exec"),
14642 );
14643 stamp_exec_session_metadata(
14644 &mut saved,
14645 "legacy-root-model",
14646 crate::config::ApiProvider::Custom.as_str(),
14647 None,
14648 Path::new("/tmp/exec-root"),
14649 );
14650
14651 assert_eq!(saved.metadata.model_provider, "custom");
14652 assert_eq!(saved.metadata.model_provider_id, None);
14653 assert!(
14654 !serde_json::to_string(&saved)
14655 .expect("serialize exec session")
14656 .contains("model_provider_id")
14657 );
14658 }
14659
14660 #[test]
14661 fn exec_parses_reasoning_effort_flag_alongside_provider() {
14662 let cli = parse_cli(&[
14663 "codewhale",
14664 "exec",
14665 "--provider",
14666 "openrouter",
14667 "--model",
14668 "glm-5.2",
14669 "--reasoning-effort",
14670 "max",
14671 "audit",
14672 ]);
14673 let Some(Commands::Exec(args)) = cli.command else {
14674 panic!("expected exec command");
14675 };
14676
14677 assert_eq!(args.provider.as_deref(), Some("openrouter"));
14678 assert_eq!(args.model.as_deref(), Some("glm-5.2"));
14679 assert_eq!(args.reasoning_effort.as_deref(), Some("max"));
14680 assert_eq!(args.prompt, vec!["audit"]);
14681 }
14682
14683 #[test]
14684 fn cli_reasoning_effort_normalizes_aliases_and_rejects_typos() {
14685 assert_eq!(
14686 normalize_cli_reasoning_effort("xhigh").unwrap().as_deref(),
14687 Some("max")
14688 );
14689 assert_eq!(normalize_cli_reasoning_effort("default").unwrap(), None);
14690 assert!(normalize_cli_reasoning_effort("expensive").is_err());
14691 }
14692
14693 #[test]
14694 fn cli_prompt_paths_resolve_auto_before_k3_route_normalization() {
14695 let config = Config {
14696 provider: Some("moonshot".to_string()),
14697 providers: Some(crate::config::ProvidersConfig {
14698 moonshot: crate::config::ProviderConfig {
14699 base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
14700 model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
14701 ..Default::default()
14702 },
14703 ..Default::default()
14704 }),
14705 ..Default::default()
14706 };
14707
14708 for (prompt, expected) in [
14709 ("lookup the public docs", "low"),
14710 ("debug this error", "max"),
14711 ("review this ordinary change", "high"),
14712 ] {
14713 assert_eq!(
14714 cli_reasoning_effort_value_for_prompt(
14715 &config,
14716 crate::config::KIMI_CODE_K3_MODEL,
14717 crate::tui::app::ReasoningEffort::Auto,
14718 prompt,
14719 )
14720 .as_deref(),
14721 Some(expected),
14722 "prompt selector must resolve Auto for `{prompt}`"
14723 );
14724 }
14725
14726 assert_eq!(
14727 cli_reasoning_effort_value_for_prompt(
14728 &config,
14729 crate::config::KIMI_CODE_K3_MODEL,
14730 crate::tui::app::ReasoningEffort::Off,
14731 "debug must not override an explicit effort",
14732 )
14733 .as_deref(),
14734 Some("low"),
14735 "membership K3 still applies its exact-route always-thinking floor"
14736 );
14737 }
14738
14739 #[test]
14740 fn cli_route_tracks_auto_reasoning_independently_from_auto_model() {
14741 use crate::tui::app::ReasoningEffort;
14742
14743 let fixed_model_auto_reasoning = CliAutoRoute {
14744 provider: crate::config::ApiProvider::Deepseek,
14745 model: crate::config::DEFAULT_TEXT_MODEL.to_string(),
14746 reasoning_effort: Some(ReasoningEffort::Auto),
14747 auto_controls_reasoning: true,
14748 auto_model: false,
14749 };
14750 let auto_model_fixed_reasoning = CliAutoRoute {
14751 provider: crate::config::ApiProvider::OpenaiCodex,
14752 model: crate::config::DEFAULT_OPENAI_CODEX_MODEL.to_string(),
14753 reasoning_effort: Some(ReasoningEffort::High),
14754 auto_controls_reasoning: false,
14755 auto_model: true,
14756 };
14757
14758 assert!(fixed_model_auto_reasoning.auto_controls_reasoning);
14759 assert!(!fixed_model_auto_reasoning.auto_model);
14760 assert!(!auto_model_fixed_reasoning.auto_controls_reasoning);
14761 assert!(auto_model_fixed_reasoning.auto_model);
14762 }
14763
14764 #[test]
14765 fn saved_reasoning_preference_overrides_config_for_non_tui_runtimes() {
14766 let mut config = Config {
14767 reasoning_effort: Some("max".to_string()),
14768 reasoning_effort_inferred_from_legacy_alias: true,
14769 ..Default::default()
14770 };
14771 let settings = crate::settings::Settings {
14772 reasoning_effort: Some("low".to_string()),
14773 ..Default::default()
14774 };
14775
14776 apply_saved_reasoning_preference(&mut config, &settings);
14777
14778 assert_eq!(config.reasoning_effort(), Some("low"));
14779 assert!(config.reasoning_effort_is_explicit());
14780 }
14781
14782 #[test]
14785 fn fixed_model_exec_auto_resolves_to_a_concrete_tier_not_the_auto_sentinel() {
14786 let config = Config {
14787 provider: Some("zai".to_string()),
14788 ..Default::default()
14789 };
14790
14791 let resolved = cli_reasoning_effort_value_for_prompt(
14792 &config,
14793 crate::config::ZAI_GLM_5_2_MODEL,
14794 crate::tui::app::ReasoningEffort::Auto,
14795 "debug this failing integration test",
14796 )
14797 .expect("Auto must resolve to a concrete tier");
14798
14799 assert_ne!(
14800 resolved, "auto",
14801 "the literal auto sentinel must never reach a provider"
14802 );
14803 assert!(
14804 matches!(resolved.as_str(), "off" | "low" | "medium" | "high" | "max"),
14805 "unexpected resolved tier: {resolved}"
14806 );
14807 }
14808
14809 #[test]
14810 fn exec_accepts_resume_session_flags_for_harnesses() {
14811 let cli = parse_cli(&[
14812 "codewhale",
14813 "exec",
14814 "--resume",
14815 "abc123",
14816 "--output-format",
14817 "stream-json",
14818 "follow up",
14819 ]);
14820 let Some(Commands::Exec(args)) = cli.command else {
14821 panic!("expected exec command");
14822 };
14823
14824 assert_eq!(args.resume.as_deref(), Some("abc123"));
14825 assert_eq!(args.output_format, ExecOutputFormat::StreamJson);
14826 assert_eq!(args.prompt, vec!["follow up"]);
14827 }
14828
14829 #[test]
14830 fn exec_accepts_session_id_alias() {
14831 let cli = parse_cli(&["codewhale", "exec", "--session-id", "abc123", "follow up"]);
14832 let Some(Commands::Exec(args)) = cli.command else {
14833 panic!("expected exec command");
14834 };
14835
14836 assert_eq!(args.session_id.as_deref(), Some("abc123"));
14837 assert_eq!(args.output_format, ExecOutputFormat::Text);
14838 }
14839
14840 #[test]
14841 fn exec_parses_tool_gate_and_hardening_flags() {
14842 let envelope = r#"{"schema_version":1,"owner":"fleet-worker-1","authority":"read_only"}"#;
14843 let cli = parse_cli(&[
14844 "codewhale",
14845 "exec",
14846 "--allowed-tools",
14847 "File,Git",
14848 "--disallowed-tools",
14849 "Bash",
14850 "--max-turns",
14851 "7",
14852 "--append-system-prompt",
14853 "extra rules",
14854 "--tool-authority-json",
14855 envelope,
14856 "do the thing",
14857 ]);
14858 let Some(Commands::Exec(args)) = cli.command else {
14859 panic!("expected exec command");
14860 };
14861
14862 assert_eq!(
14863 args.allowed_tools.as_deref(),
14864 Some(&["File".to_string(), "Git".to_string()][..])
14865 );
14866 assert_eq!(
14867 args.disallowed_tools.as_deref(),
14868 Some(&["Bash".to_string()][..])
14869 );
14870 assert_eq!(args.max_turns, Some(7));
14871 assert_eq!(args.append_system_prompt.as_deref(), Some("extra rules"));
14872 assert_eq!(args.tool_authority_json.as_deref(), Some(envelope));
14873 assert_eq!(args.prompt, vec!["do the thing"]);
14874 }
14875
14876 #[test]
14877 fn fleet_tool_authority_cannot_cross_an_exec_resume_boundary() {
14878 assert!(validate_exec_tool_authority_resume(None, true).is_ok());
14879 assert!(validate_exec_tool_authority_resume(Some("{}"), false).is_ok());
14880 let error = validate_exec_tool_authority_resume(Some("{}"), true)
14881 .expect_err("authority must remain bound to its fresh Fleet launch")
14882 .to_string();
14883 assert!(error.contains("cannot be combined with exec --resume"));
14884 }
14885
14886 #[test]
14887 fn exec_auto_does_not_authorize_sandbox_elevation() {
14888 let cli = parse_cli(&["codewhale", "exec", "--auto", "run it"]);
14889 let Some(Commands::Exec(args)) = cli.command else {
14890 panic!("expected exec command");
14891 };
14892
14893 assert!(!exec_sandbox_elevation_authorized(
14894 args.allow_sandbox_elevation,
14895 args.sandbox.as_deref()
14896 ));
14897 }
14898
14899 #[test]
14900 fn exec_explicit_sandbox_elevation_opt_ins_authorize_retry() {
14901 let danger = parse_cli(&[
14902 "codewhale",
14903 "exec",
14904 "--auto",
14905 "--sandbox",
14906 "danger-full-access",
14907 "run it",
14908 ]);
14909 let Some(Commands::Exec(args)) = danger.command else {
14910 panic!("expected exec command");
14911 };
14912 assert!(exec_sandbox_elevation_authorized(
14913 args.allow_sandbox_elevation,
14914 args.sandbox.as_deref()
14915 ));
14916
14917 let flag = parse_cli(&[
14918 "codewhale",
14919 "exec",
14920 "--auto",
14921 "--allow-sandbox-elevation",
14922 "run it",
14923 ]);
14924 let Some(Commands::Exec(args)) = flag.command else {
14925 panic!("expected exec command");
14926 };
14927 assert!(exec_sandbox_elevation_authorized(
14928 args.allow_sandbox_elevation,
14929 args.sandbox.as_deref()
14930 ));
14931 }
14932
14933 #[test]
14934 fn exec_sandbox_denial_stream_event_is_typed() {
14935 let event = ExecStreamEvent::SandboxDenied {
14936 tool_id: "call_1".to_string(),
14937 tool_name: "exec_shell".to_string(),
14938 reason: "write blocked".to_string(),
14939 outcome: "approval_required".to_string(),
14940 };
14941 let value: serde_json::Value =
14942 serde_json::from_str(&serde_json::to_string(&event).expect("serializes"))
14943 .expect("valid json");
14944 assert_eq!(value["type"], "sandbox_denied");
14945 assert_eq!(value["outcome"], "approval_required");
14946 }
14947
14948 #[test]
14949 fn exec_help_separates_agent_mode_from_sandbox_elevation() {
14950 let mut cli = Cli::command();
14951 let help = cli
14952 .find_subcommand_mut("exec")
14953 .expect("exec command")
14954 .render_help()
14955 .to_string();
14956 assert!(help.contains("--auto"));
14957 assert!(help.contains("--sandbox"));
14958 assert!(help.contains("--allow-sandbox-elevation"));
14959 assert!(help.contains("does not change the"));
14960 assert!(help.contains("explicitly authorize sandbox elevation"));
14961 }
14962
14963 #[test]
14964 fn exec_shell_only_tool_surface_env_sets_shell_allowlist() {
14965 let _env_lock = crate::test_support::lock_test_env();
14966 let _surface =
14967 crate::test_support::EnvVarGuard::set(CODEWHALE_TOOL_SURFACE_ENV, " shell-only ");
14968
14969 let allowed_tools = resolve_exec_allowed_tools(None, exec_tool_surface_from_env())
14970 .expect("shell-only surface should set an allowlist");
14971
14972 assert_eq!(allowed_tools, vec!["bash".to_string()]);
14973 }
14974
14975 #[test]
14976 fn exec_explicit_allowed_tools_override_shell_only_env() {
14977 let _env_lock = crate::test_support::lock_test_env();
14978 let _surface =
14979 crate::test_support::EnvVarGuard::set(CODEWHALE_TOOL_SURFACE_ENV, "shell-only");
14980 let explicit = vec![" File ".to_string(), "GIT".to_string()];
14981
14982 let allowed_tools =
14983 resolve_exec_allowed_tools(Some(&explicit), exec_tool_surface_from_env())
14984 .expect("explicit allowlist should be preserved");
14985
14986 assert_eq!(allowed_tools, vec!["file".to_string(), "git".to_string()]);
14987 }
14988
14989 #[test]
14990 fn exec_full_tool_surface_env_leaves_allowlist_unset() {
14991 let _env_lock = crate::test_support::lock_test_env();
14992 let _surface = crate::test_support::EnvVarGuard::set(CODEWHALE_TOOL_SURFACE_ENV, "full");
14993
14994 assert_eq!(
14995 resolve_exec_allowed_tools(None, exec_tool_surface_from_env()),
14996 None
14997 );
14998 }
14999
15000 #[test]
15001 fn exec_unknown_tool_surface_env_warns_without_allowlist() {
15002 assert!(should_warn_unknown_exec_tool_surface("shell_onyl"));
15003 assert!(!should_warn_unknown_exec_tool_surface("shell-only"));
15004 assert!(!should_warn_unknown_exec_tool_surface("native-tools"));
15005 assert!(!should_warn_unknown_exec_tool_surface("full"));
15006 assert!(!should_warn_unknown_exec_tool_surface(" "));
15007 assert_eq!(parse_exec_tool_surface("shell_onyl"), None);
15008 }
15009
15010 #[test]
15011 fn exec_rejects_zero_max_turns() {
15012 let err = Cli::try_parse_from(["codewhale", "exec", "--max-turns", "0", "hello"])
15013 .expect_err("max-turns must be >= 1");
15014 assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation);
15015 }
15016
15017 #[test]
15018 fn exec_omits_the_headless_turn_cap_by_default() {
15019 let cli = parse_cli(&["codewhale", "exec", "--auto", "benchmark this"]);
15020 let Some(Commands::Exec(args)) = cli.command else {
15021 panic!("expected exec command");
15022 };
15023
15024 assert_eq!(args.max_turns, None);
15025 assert_eq!(exec_max_steps(args.max_turns), u32::MAX);
15026 assert_eq!(exec_max_steps(Some(7)), 7);
15027 }
15028
15029 #[test]
15030 fn exec_accepts_continue_for_latest_workspace_session() {
15031 let cli = parse_cli(&["codewhale", "exec", "--continue", "follow up"]);
15032 let Some(Commands::Exec(args)) = cli.command else {
15033 panic!("expected exec command");
15034 };
15035
15036 assert!(args.continue_session);
15037 }
15038
15039 #[test]
15040 fn sessions_footer_points_to_resume_subcommand() {
15041 let cli = parse_cli(&["codewhale", "resume", "abc123"]);
15042 let Some(Commands::Resume { session_id, last }) = cli.command else {
15043 panic!("expected resume command");
15044 };
15045
15046 assert_eq!(session_id.as_deref(), Some("abc123"));
15047 assert!(!last);
15048 assert_eq!(sessions_resume_command(), "codewhale resume");
15049 assert!(!sessions_resume_command().contains("--resume"));
15050 }
15051
15052 #[test]
15053 fn plugin_registry_initialization_precedes_dotenv_for_all_launch_paths() {
15054 use std::cell::Cell;
15055
15056 #[derive(Clone, Copy)]
15057 enum Expected {
15058 Plain,
15059 Resume,
15060 Fork,
15061 Exec,
15062 Serve,
15063 }
15064
15065 let cases: &[(&[&str], Expected)] = &[
15066 (&["codewhale"], Expected::Plain),
15067 (&["codewhale", "resume", "--last"], Expected::Resume),
15068 (&["codewhale", "fork", "--last"], Expected::Fork),
15069 (&["codewhale", "exec", "probe"], Expected::Exec),
15070 (&["codewhale", "serve", "--mcp"], Expected::Serve),
15071 ];
15072
15073 for (args, expected) in cases {
15074 let phase = Cell::new(0);
15075 let (_cli, command) = prepare_cli_startup(
15076 parse_cli(args),
15077 || {
15078 assert_eq!(phase.get(), 0, "plugin init order for {args:?}");
15079 phase.set(1);
15080 },
15081 || {
15082 assert_eq!(phase.get(), 1, "dotenv load order for {args:?}");
15083 phase.set(2);
15084 },
15085 );
15086
15087 assert_eq!(phase.get(), 2, "startup phases for {args:?}");
15088 let correct_variant = matches!(
15089 (expected, command.as_ref()),
15090 (Expected::Plain, None)
15091 | (Expected::Resume, Some(Commands::Resume { .. }))
15092 | (Expected::Fork, Some(Commands::Fork { .. }))
15093 | (Expected::Exec, Some(Commands::Exec(_)))
15094 | (Expected::Serve, Some(Commands::Serve(_)))
15095 );
15096 assert!(correct_variant, "unexpected command for {args:?}");
15097 }
15098 }
15099
15100 #[test]
15101 fn workspace_dotenv_loads_only_provider_credentials_and_preserves_shell_values() {
15102 let _lock = crate::test_support::lock_test_env();
15103 let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
15104 let _nvidia = crate::test_support::EnvVarGuard::set("NVIDIA_API_KEY", "shell-key");
15105 let _home = crate::test_support::EnvVarGuard::remove("CODEWHALE_HOME");
15106 let _config = crate::test_support::EnvVarGuard::remove("CODEWHALE_CONFIG_PATH");
15107 let _shell = crate::test_support::EnvVarGuard::remove("DEEPSEEK_ALLOW_SHELL");
15108 let tmp = tempfile::TempDir::new().expect("temp workspace");
15109 let dotenv = tmp.path().join(".env");
15110 std::fs::write(
15111 &dotenv,
15112 "DEEPSEEK_API_KEY=workspace-key\n\
15113 NVIDIA_API_KEY=repo-must-not-override-shell\n\
15114 CODEWHALE_HOME=./attacker-home\n\
15115 CODEWHALE_CONFIG_PATH=./attacker.toml\n\
15116 DEEPSEEK_ALLOW_SHELL=true\n",
15117 )
15118 .expect("write dotenv");
15119
15120 let report = load_workspace_dotenv_credentials_from_path(&dotenv).expect("safe load");
15121
15122 assert_eq!(
15123 std::env::var("DEEPSEEK_API_KEY").as_deref(),
15124 Ok("workspace-key")
15125 );
15126 assert_eq!(std::env::var("NVIDIA_API_KEY").as_deref(), Ok("shell-key"));
15127 assert!(std::env::var_os("CODEWHALE_HOME").is_none());
15128 assert!(std::env::var_os("CODEWHALE_CONFIG_PATH").is_none());
15129 assert!(std::env::var_os("DEEPSEEK_ALLOW_SHELL").is_none());
15130 assert_eq!(
15131 report.loaded,
15132 BTreeSet::from(["DEEPSEEK_API_KEY".to_string()])
15133 );
15134 assert_eq!(
15135 report.ignored,
15136 BTreeSet::from([
15137 "CODEWHALE_CONFIG_PATH".to_string(),
15138 "CODEWHALE_HOME".to_string(),
15139 "DEEPSEEK_ALLOW_SHELL".to_string(),
15140 ])
15141 );
15142 }
15143
15144 #[test]
15145 fn workspace_dotenv_rejects_ambient_variable_substitution() {
15146 let _lock = crate::test_support::lock_test_env();
15147 let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
15148 let _ambient = crate::test_support::EnvVarGuard::set(
15149 "CODEWHALE_JS_SECRET_LEAK_TEST",
15150 "ambient-secret-must-not-expand",
15151 );
15152 let tmp = tempfile::TempDir::new().expect("temp workspace");
15153 let dotenv = tmp.path().join(".env");
15154 std::fs::write(
15155 &dotenv,
15156 "DEEPSEEK_API_KEY=${CODEWHALE_JS_SECRET_LEAK_TEST}\n",
15157 )
15158 .expect("write dotenv");
15159
15160 let error = load_workspace_dotenv_credentials_from_path(&dotenv)
15161 .expect_err("expansion must fail closed")
15162 .to_string();
15163
15164 assert!(error.contains("variable expansion"));
15165 assert!(!error.contains("ambient-secret-must-not-expand"));
15166 assert!(std::env::var_os("DEEPSEEK_API_KEY").is_none());
15167 }
15168
15169 #[test]
15170 fn workspace_dotenv_rejects_multiline_ambient_variable_substitution() {
15171 let _lock = crate::test_support::lock_test_env();
15172 let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
15173 let _ambient = crate::test_support::EnvVarGuard::set(
15174 "CODEWHALE_JS_SECRET_LEAK_TEST",
15175 "ambient-secret-must-not-expand",
15176 );
15177 let tmp = tempfile::TempDir::new().expect("temp workspace");
15178 let dotenv = tmp.path().join(".env");
15179 std::fs::write(
15180 &dotenv,
15181 "DEEPSEEK_API_KEY=\"prefix\n$CODEWHALE_JS_SECRET_LEAK_TEST=bar\nsuffix\"\n",
15182 )
15183 .expect("write dotenv");
15184
15185 let error = load_workspace_dotenv_credentials_from_path(&dotenv)
15186 .expect_err("multiline expansion must fail closed")
15187 .to_string();
15188
15189 assert!(error.contains("variable expansion"));
15190 assert!(!error.contains("ambient-secret-must-not-expand"));
15191 assert!(std::env::var_os("DEEPSEEK_API_KEY").is_none());
15192 }
15193
15194 #[test]
15195 fn workspace_dotenv_comment_quote_cannot_hide_later_expansion() {
15196 let _lock = crate::test_support::lock_test_env();
15197 let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
15198 let _ambient = crate::test_support::EnvVarGuard::set(
15199 "CODEWHALE_JS_SECRET_LEAK_TEST",
15200 "ambient-secret-must-not-expand",
15201 );
15202 let tmp = tempfile::TempDir::new().expect("temp workspace");
15203 let dotenv = tmp.path().join(".env");
15204 std::fs::write(
15205 &dotenv,
15206 "# unmatched quote in ignored comment: '\n\
15207 DEEPSEEK_API_KEY=$CODEWHALE_JS_SECRET_LEAK_TEST\n",
15208 )
15209 .expect("write dotenv");
15210
15211 let error = load_workspace_dotenv_credentials_from_path(&dotenv)
15212 .expect_err("comment quote must not hide expansion")
15213 .to_string();
15214
15215 assert!(error.contains("variable expansion"));
15216 assert!(!error.contains("ambient-secret-must-not-expand"));
15217 assert!(std::env::var_os("DEEPSEEK_API_KEY").is_none());
15218 }
15219
15220 #[test]
15221 fn workspace_dotenv_allows_single_quoted_literal_dollar() {
15222 let _lock = crate::test_support::lock_test_env();
15223 let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
15224 let tmp = tempfile::TempDir::new().expect("temp workspace");
15225 let dotenv = tmp.path().join(".env");
15226 std::fs::write(&dotenv, "DEEPSEEK_API_KEY='$literal-value'\n").expect("write dotenv");
15227
15228 load_workspace_dotenv_credentials_from_path(&dotenv).expect("literal dollar load");
15229
15230 assert_eq!(
15231 std::env::var("DEEPSEEK_API_KEY").as_deref(),
15232 Ok("$literal-value")
15233 );
15234 }
15235
15236 #[test]
15237 fn workspace_dotenv_parse_failure_applies_no_earlier_credentials() {
15238 let _lock = crate::test_support::lock_test_env();
15239 let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
15240 let tmp = tempfile::TempDir::new().expect("temp workspace");
15241 let dotenv = tmp.path().join(".env");
15242 std::fs::write(
15243 &dotenv,
15244 "DEEPSEEK_API_KEY=must-not-survive\nBROKEN=\"unterminated\n",
15245 )
15246 .expect("write dotenv");
15247
15248 let error = load_workspace_dotenv_credentials_from_path(&dotenv)
15249 .expect_err("parse failure must be transactional")
15250 .to_string();
15251
15252 assert!(error.contains("could not be parsed safely"), "{error}");
15253 assert!(!error.contains("must-not-survive"));
15254 assert!(std::env::var_os("DEEPSEEK_API_KEY").is_none());
15255 }
15256
15257 #[test]
15258 fn workspace_dotenv_credential_allowlist_excludes_control_plane_names() {
15259 for provider in codewhale_config::provider::providers_sorted_for_display() {
15260 for key in provider.env_vars() {
15261 assert!(
15262 is_workspace_dotenv_credential_key(key),
15263 "provider credential {key} must remain supported"
15264 );
15265 }
15266 }
15267 for key in [
15268 "CODEWHALE_HOME",
15269 "CODEWHALE_CONFIG_PATH",
15270 "DEEPSEEK_CONFIG_PATH",
15271 "DEEPSEEK_PROFILE",
15272 "DEEPSEEK_MANAGED_CONFIG_PATH",
15273 "DEEPSEEK_REQUIREMENTS_PATH",
15274 "DEEPSEEK_PROVIDER",
15275 "DEEPSEEK_BASE_URL",
15276 "DEEPSEEK_MODEL",
15277 "DEEPSEEK_APPROVAL_POLICY",
15278 "DEEPSEEK_SANDBOX_MODE",
15279 "DEEPSEEK_ALLOW_SHELL",
15280 "DEEPSEEK_YOLO",
15281 "DEEPSEEK_MCP_CONFIG",
15282 "CODEWHALE_RUNTIME_TOKEN",
15283 "PATH",
15284 "NODE_OPTIONS",
15285 "PYTHONPATH",
15286 "LD_PRELOAD",
15287 "DYLD_INSERT_LIBRARIES",
15288 ] {
15289 assert!(
15290 !is_workspace_dotenv_credential_key(key),
15291 "control-plane variable {key} must not load from a workspace"
15292 );
15293 }
15294 }
15295
15296 #[cfg(unix)]
15297 #[test]
15298 fn workspace_dotenv_does_not_follow_symbolic_links() {
15299 use std::os::unix::fs::symlink;
15300
15301 let tmp = tempfile::TempDir::new().expect("temp workspace");
15302 let external = tmp.path().join("external-credentials");
15303 let dotenv = tmp.path().join(".env");
15304 std::fs::write(&external, "DEEPSEEK_API_KEY=external-secret\n")
15305 .expect("write external fixture");
15306 symlink(&external, &dotenv).expect("create dotenv symlink");
15307
15308 let error = load_workspace_dotenv_credentials_from_path(&dotenv)
15309 .expect_err("symlink must fail closed")
15310 .to_string();
15311
15312 assert!(error.contains("securely open"), "{error}");
15313 assert!(!error.contains("external-secret"));
15314 }
15315
15316 #[cfg(unix)]
15317 #[test]
15318 fn workspace_dotenv_rejects_hard_links_to_external_files() {
15319 let tmp = tempfile::TempDir::new().expect("temp workspace");
15320 let external = tmp.path().join("external-credentials");
15321 let dotenv = tmp.path().join(".env");
15322 std::fs::write(&external, "DEEPSEEK_API_KEY=external-secret\n")
15323 .expect("write external fixture");
15324 std::fs::hard_link(&external, &dotenv).expect("create dotenv hard link");
15325
15326 let error = load_workspace_dotenv_credentials_from_path(&dotenv)
15327 .expect_err("hard link must fail closed")
15328 .to_string();
15329
15330 assert!(error.contains("multiple filesystem links"), "{error}");
15331 assert!(!error.contains("external-secret"));
15332 }
15333
15334 #[cfg(unix)]
15335 #[test]
15336 fn workspace_dotenv_rejects_fifo_without_blocking_startup() {
15337 use std::ffi::CString;
15338 use std::os::unix::ffi::OsStrExt;
15339 use std::sync::mpsc;
15340 use std::time::Duration;
15341
15342 let tmp = tempfile::TempDir::new().expect("temp workspace");
15343 let dotenv = tmp.path().join(".env");
15344 let c_path = CString::new(dotenv.as_os_str().as_bytes()).expect("fifo path");
15345 let result = unsafe { libc::mkfifo(c_path.as_ptr(), libc::S_IRUSR | libc::S_IWUSR) };
15348 assert_eq!(result, 0, "mkfifo failed: {}", io::Error::last_os_error());
15349
15350 let (tx, rx) = mpsc::channel();
15351 let worker_path = dotenv.clone();
15352 let worker = std::thread::spawn(move || {
15353 let result = load_workspace_dotenv_credentials_from_path(&worker_path)
15354 .map(|_| "unexpected success".to_string())
15355 .unwrap_or_else(|error| error.to_string());
15356 tx.send(result).expect("send loader result");
15357 });
15358
15359 let error = match rx.recv_timeout(Duration::from_secs(1)) {
15360 Ok(error) => error,
15361 Err(timeout) => {
15362 let _writer = std::fs::OpenOptions::new()
15365 .write(true)
15366 .open(&dotenv)
15367 .expect("open fifo writer to release blocked reader");
15368 let _ = rx.recv_timeout(Duration::from_secs(1));
15369 worker.join().expect("join released loader");
15370 panic!("workspace .env FIFO blocked startup: {timeout}");
15371 }
15372 };
15373 worker.join().expect("join loader");
15374
15375 assert!(error.contains("not a regular file"), "{error}");
15376 }
15377
15378 #[test]
15379 fn exec_json_conflicts_with_stream_json_output() {
15380 let err = Cli::try_parse_from([
15381 "codewhale",
15382 "exec",
15383 "--json",
15384 "--output-format",
15385 "stream-json",
15386 "hello",
15387 ])
15388 .expect_err("json summary and stream-json must not mix");
15389
15390 assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
15391 }
15392
15393 #[test]
15394 fn exec_stream_turn_usage_event_serializes_reported_fields() {
15395 let event = ExecStreamEvent::TurnUsage {
15396 turn: 2,
15397 input_tokens: 1200,
15398 output_tokens: 180,
15399 reasoning_tokens: Some(90),
15400 prompt_cache_hit_tokens: Some(900),
15401 prompt_cache_miss_tokens: Some(300),
15402 prompt_cache_write_tokens: Some(0),
15403 reasoning_replay_tokens: Some(40),
15404 duration_ms: 1834,
15405 };
15406
15407 let value = exec_stream_value(&event).expect("serializes");
15408 let json = serde_json::to_string(&value).expect("serializes");
15409 assert!(!json.contains('\n'));
15410 let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json");
15411 assert_eq!(parsed["type"], "turn_usage");
15412 assert_eq!(parsed["schema"], "codewhale.exec-stream");
15413 assert_eq!(parsed["schema_version"], 1);
15414 assert_eq!(parsed["turn"], 2);
15415 assert_eq!(parsed["input_tokens"], 1200);
15416 assert_eq!(parsed["output_tokens"], 180);
15417 assert_eq!(parsed["reasoning_tokens"], 90);
15418 assert_eq!(parsed["prompt_cache_hit_tokens"], 900);
15419 assert_eq!(parsed["prompt_cache_miss_tokens"], 300);
15420 assert_eq!(parsed["prompt_cache_write_tokens"], 0);
15421 assert_eq!(parsed["reasoning_replay_tokens"], 40);
15422 assert_eq!(parsed["duration_ms"], 1834);
15423 }
15424
15425 #[test]
15426 fn exec_stream_turn_usage_event_omits_unreported_fields() {
15427 let event = ExecStreamEvent::TurnUsage {
15431 turn: 1,
15432 input_tokens: 11,
15433 output_tokens: 3,
15434 reasoning_tokens: None,
15435 prompt_cache_hit_tokens: None,
15436 prompt_cache_miss_tokens: None,
15437 prompt_cache_write_tokens: None,
15438 reasoning_replay_tokens: None,
15439 duration_ms: 250,
15440 };
15441
15442 let value = exec_stream_value(&event).expect("serializes");
15443 let parsed = value;
15444 assert_eq!(parsed["type"], "turn_usage");
15445 assert_eq!(parsed["input_tokens"], 11);
15446 assert_eq!(parsed["output_tokens"], 3);
15447 assert_eq!(parsed["duration_ms"], 250);
15448 let object = parsed.as_object().expect("event object");
15449 for absent in [
15450 "reasoning_tokens",
15451 "prompt_cache_hit_tokens",
15452 "prompt_cache_miss_tokens",
15453 "prompt_cache_write_tokens",
15454 "reasoning_replay_tokens",
15455 ] {
15456 assert!(!object.contains_key(absent), "{absent} leaked: {parsed}");
15457 }
15458 }
15459
15460 #[test]
15461 fn exec_stream_pre_existing_event_type_tags_are_unchanged() {
15462 let cases: Vec<(ExecStreamEvent, &str)> = vec![
15465 (
15466 ExecStreamEvent::Content {
15467 content: "hi".to_string(),
15468 },
15469 "content",
15470 ),
15471 (
15472 ExecStreamEvent::ToolUse {
15473 name: "read_file".to_string(),
15474 id: "call_1".to_string(),
15475 input: serde_json::json!({}),
15476 started_at: "2026-08-03T00:00:00Z".to_string(),
15477 },
15478 "tool_use",
15479 ),
15480 (
15481 ExecStreamEvent::ToolResult {
15482 id: "call_1".to_string(),
15483 name: "read_file".to_string(),
15484 output: "ok".to_string(),
15485 status: "success".to_string(),
15486 started_at: "2026-08-03T00:00:00Z".to_string(),
15487 completed_at: "2026-08-03T00:00:01Z".to_string(),
15488 duration_ms: 1,
15489 side_effect_status: "unknown".to_string(),
15490 error_category: None,
15491 truncated: None,
15492 artifact: None,
15493 result_metadata: None,
15494 },
15495 "tool_result",
15496 ),
15497 (
15498 ExecStreamEvent::SandboxDenied {
15499 tool_id: "call_1".to_string(),
15500 tool_name: "exec_shell".to_string(),
15501 reason: "denied".to_string(),
15502 outcome: "approval_required".to_string(),
15503 },
15504 "sandbox_denied",
15505 ),
15506 (
15507 ExecStreamEvent::WorkflowEvent {
15508 run_id: "workflow_1".to_string(),
15509 event: serde_json::json!({"type": "task_completed"}),
15510 },
15511 "workflow_event",
15512 ),
15513 (
15514 ExecStreamEvent::SessionCapture {
15515 content: "x".to_string(),
15516 },
15517 "session_capture",
15518 ),
15519 (
15520 ExecStreamEvent::Error {
15521 error: "boom".to_string(),
15522 },
15523 "error",
15524 ),
15525 (ExecStreamEvent::Done, "done"),
15526 ];
15527
15528 for (event, expected_type) in cases {
15529 let value = exec_stream_value(&event).expect("serializes");
15530 assert_eq!(value["type"], expected_type, "event tag drifted");
15531 assert_eq!(value["schema"], "codewhale.exec-stream");
15532 assert_eq!(value["schema_version"], 1);
15533 }
15534 }
15535
15536 #[test]
15537 fn exec_stream_events_are_json_lines() {
15538 let event = ExecStreamEvent::ToolResult {
15539 id: "call_1".to_string(),
15540 name: "read_file".to_string(),
15541 output: "line 1\nline 2".to_string(),
15542 status: "success".to_string(),
15543 started_at: "2026-07-13T00:00:00Z".to_string(),
15544 completed_at: "2026-07-13T00:00:01Z".to_string(),
15545 duration_ms: 1000,
15546 side_effect_status: "not_started".to_string(),
15547 error_category: None,
15548 truncated: Some(false),
15549 artifact: None,
15550 result_metadata: None,
15551 };
15552
15553 let value = exec_stream_value(&event).expect("serializes");
15554 let json = serde_json::to_string(&value).expect("serializes");
15555 assert!(!json.contains('\n'));
15556 let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json");
15557 assert_eq!(parsed["type"], "tool_result");
15558 assert_eq!(parsed["schema"], "codewhale.exec-stream");
15559 assert_eq!(parsed["schema_version"], 1);
15560 assert_eq!(parsed["duration_ms"], 1000);
15561 assert_eq!(parsed["side_effect_status"], "not_started");
15562 }
15563
15564 #[test]
15565 fn workflow_receipt_stream_event_is_one_json_line() {
15566 let event = ExecStreamEvent::WorkflowEvent {
15567 run_id: "workflow_1234".to_string(),
15568 event: serde_json::json!({
15569 "type": "handoff_promoted",
15570 "artifact_id": "workflow_1234:agent_1:review-gate:review_report",
15571 "gate_id": "review-gate",
15572 "kind": "review_report",
15573 "from_role": "reviewer",
15574 "to_role": "verifier",
15575 "producer_task_id": "agent_1"
15576 }),
15577 };
15578
15579 let value = exec_stream_value(&event).expect("serializes");
15580 let json = serde_json::to_string(&value).expect("serializes");
15581 assert!(!json.contains('\n'));
15582 let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json");
15583 assert_eq!(parsed["type"], "workflow_event");
15584 assert_eq!(parsed["schema"], "codewhale.exec-stream");
15585 assert_eq!(parsed["schema_version"], 1);
15586 assert_eq!(parsed["run_id"], "workflow_1234");
15587 assert_eq!(parsed["event"]["type"], "handoff_promoted");
15588 assert_eq!(
15589 parsed["event"]["artifact_id"],
15590 "workflow_1234:agent_1:review-gate:review_report"
15591 );
15592 assert_eq!(parsed["event"]["gate_id"], "review-gate");
15593 assert_eq!(parsed["event"]["kind"], "review_report");
15594 assert_eq!(parsed["event"]["from_role"], "reviewer");
15595 assert_eq!(parsed["event"]["to_role"], "verifier");
15596 assert_eq!(parsed["event"]["producer_task_id"], "agent_1");
15597 assert!(parsed["event"].get("payload").is_none(), "{parsed}");
15598
15599 let consumed = ExecStreamEvent::WorkflowEvent {
15600 run_id: "workflow_1234".to_string(),
15601 event: serde_json::json!({
15602 "type": "handoff_consumed",
15603 "artifact_id": "workflow_1234:agent_1:review-gate:review_report",
15604 "kind": "review_report",
15605 "from_role": "reviewer",
15606 "to_role": "verifier",
15607 "consumer_task_id": "agent_2"
15608 }),
15609 };
15610 let consumed = exec_stream_value(&consumed).expect("serializes consumed receipt");
15611 assert_eq!(consumed["type"], "workflow_event");
15612 assert_eq!(consumed["schema"], "codewhale.exec-stream");
15613 assert_eq!(consumed["schema_version"], 1);
15614 assert_eq!(consumed["event"]["type"], "handoff_consumed");
15615 assert_eq!(
15616 consumed["event"]["artifact_id"],
15617 "workflow_1234:agent_1:review-gate:review_report"
15618 );
15619 assert_eq!(consumed["event"]["consumer_task_id"], "agent_2");
15620 assert!(consumed["event"].get("payload").is_none(), "{consumed}");
15621 }
15622
15623 #[test]
15624 fn exec_stream_metadata_redacts_resume_breadcrumbs() {
15625 let raw_session_id = "abc123fullsecret";
15626 let event = ExecStreamEvent::Metadata {
15627 meta: Box::new(ExecStreamMeta {
15628 receipt_kind: "terminal",
15629 provider: "deepseek".to_string(),
15630 provider_id: None,
15631 model: "deepseek-v4-flash".to_string(),
15632 route_source: "explicit_or_configured".to_string(),
15633 input_tokens: Some(123),
15634 output_tokens: Some(45),
15635 prompt_cache_hit_tokens: Some(10),
15636 prompt_cache_miss_tokens: None,
15637 prompt_cache_write_tokens: None,
15638 reasoning_tokens: Some(3),
15639 duration_ms: 2500,
15640 retry_count: None,
15641 approval_posture: "ask".to_string(),
15642 sandbox_posture: "configured_default".to_string(),
15643 binary_sha256: Some("sha256:binary".to_string()),
15644 config_sha256: None,
15645 prompt_sha256: "sha256:prompt".to_string(),
15646 tool_catalog_sha256: Some("sha256:tools".to_string()),
15647 input_analysis: ExecStreamInputAnalysis::default(),
15648 visible_final_answer_chars: 17,
15649 session_id: exec_stream_session_ref(raw_session_id),
15650 resume_command: exec_stream_resume_hint(raw_session_id),
15651 workspace: "/tmp/work".to_string(),
15652 message_count: 4,
15653 status: Some("completed".to_string()),
15654 termination_reason: Some("resolved".to_string()),
15655 error_category: None,
15656 error: None,
15657 }),
15658 };
15659
15660 let json = serde_json::to_string(&event).expect("serializes");
15661 assert!(!json.contains('\n'));
15662 assert!(!json.contains(raw_session_id));
15663 let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json");
15664 assert_eq!(parsed["type"], "metadata");
15665 assert_ne!(parsed["meta"]["session_id"], raw_session_id);
15666 assert!(
15667 parsed["meta"]["session_id"]
15668 .as_str()
15669 .unwrap()
15670 .starts_with("<redacted:")
15671 );
15672 assert_eq!(
15673 parsed["meta"]["resume_command"],
15674 "codewhale exec --resume <redacted-session-id>"
15675 );
15676 assert_eq!(parsed["meta"]["workspace"], "/tmp/work");
15677 assert_eq!(parsed["meta"]["message_count"], 4);
15678 assert_eq!(parsed["meta"]["visible_final_answer_chars"], 17);
15679
15680 let capture = ExecStreamEvent::SessionCapture {
15681 content: exec_stream_session_ref(raw_session_id),
15682 };
15683 let capture_json = serde_json::to_string(&capture).expect("serializes");
15684 assert!(!capture_json.contains(raw_session_id));
15685 let parsed_capture: serde_json::Value =
15686 serde_json::from_str(&capture_json).expect("valid json");
15687 assert_eq!(parsed_capture["type"], "session_capture");
15688 assert_ne!(parsed_capture["content"], raw_session_id);
15689 }
15690
15691 #[test]
15692 fn exec_stream_input_analysis_reports_prompt_composition() {
15693 let system = SystemPrompt::Text("system rules".to_string());
15694 let messages = vec![
15695 Message {
15696 role: "user".to_string(),
15697 content: vec![ContentBlock::Text {
15698 text: "run tests".to_string(),
15699 cache_control: None,
15700 }],
15701 },
15702 Message {
15703 role: "assistant".to_string(),
15704 content: vec![
15705 ContentBlock::thinking("checking context"),
15706 ContentBlock::Text {
15707 text: "working".to_string(),
15708 cache_control: None,
15709 },
15710 ContentBlock::ToolUse {
15711 id: "call-1".to_string(),
15712 name: "exec_shell".to_string(),
15713 input: serde_json::json!({"command": "cargo test"}),
15714 caller: None,
15715 },
15716 ],
15717 },
15718 Message {
15719 role: "user".to_string(),
15720 content: vec![ContentBlock::ToolResult {
15721 tool_use_id: "call-1".to_string(),
15722 content: "stdout line\nstderr line".to_string(),
15723 is_error: Some(false),
15724 content_blocks: Some(vec![serde_json::json!({
15725 "type": "text",
15726 "text": "structured output"
15727 })]),
15728 }],
15729 },
15730 ];
15731
15732 let analysis = exec_stream_input_analysis(&messages, Some(&system));
15733
15734 assert_eq!(analysis.user_message_count, 2);
15735 assert_eq!(analysis.assistant_message_count, 1);
15736 assert_eq!(analysis.tool_message_count, 0);
15737 assert_eq!(analysis.tool_use_count, 1);
15738 assert_eq!(analysis.tool_result_count, 1);
15739 assert_eq!(analysis.thinking_chars, "checking context".chars().count());
15740 assert!(analysis.text_chars >= "run testsworking".chars().count());
15741 assert!(analysis.tool_use_input_chars > 0);
15742 assert!(analysis.tool_result_chars >= "stdout line\nstderr line".chars().count());
15743 assert!(analysis.estimated_system_tokens > 0);
15744 assert!(analysis.estimated_message_content_tokens > 0);
15745 assert!(
15746 analysis.estimated_request_tokens
15747 >= analysis.estimated_system_tokens
15748 + analysis.estimated_message_content_tokens
15749 + analysis.estimated_framing_tokens
15750 );
15751 }
15752
15753 #[test]
15754 fn review_receipt_check_public_json_omits_private_details() {
15755 let validation = crate::tools::review::ReviewReceiptValidation {
15756 passed: false,
15757 reason: "secret reason with /tmp/private/receipt.json".to_string(),
15758 diff_fingerprint: "sha256:current".to_string(),
15759 receipt_fingerprint: Some("sha256:current".to_string()),
15760 receipt_path: Some(PathBuf::from("/tmp/private/receipt.json")),
15761 unresolved_risk: Some(crate::tools::review::ReviewReceiptRisk {
15762 unresolved: true,
15763 level: "error".to_string(),
15764 summary: "secret summary".to_string(),
15765 }),
15766 };
15767
15768 let public = review_receipt_validation_public_json(&validation);
15769 let encoded = serde_json::to_string(&public).expect("public json");
15770
15771 assert_eq!(public["passed"], false);
15772 assert_eq!(public["status"], "unresolved_risk");
15773 assert_eq!(public["risk_level"], "error");
15774 assert!(!encoded.contains("secret"));
15775 assert!(!encoded.contains("/tmp/private"));
15776 }
15777
15778 #[test]
15779 fn exec_text_session_breadcrumbs_use_compact_ids() {
15780 let session_id = "1234567890abcdef";
15781
15782 assert_eq!(exec_saved_session_line(session_id), "session: 12345678");
15783 assert_eq!(
15784 exec_resumed_session_line(session_id),
15785 "resumed session: 12345678"
15786 );
15787 assert!(!exec_saved_session_line(session_id).contains(session_id));
15788 assert!(!exec_resumed_session_line(session_id).contains(session_id));
15789 }
15790
15791 #[test]
15792 fn alternate_screen_defaults_on_in_auto_mode() {
15793 let cli = parse_cli(&["codewhale"]);
15794 let config = Config::default();
15795
15796 assert!(should_use_alt_screen(&cli, &config));
15797 }
15798
15799 #[test]
15800 fn removed_no_alt_screen_flag_is_rejected() {
15801 let error = Cli::try_parse_from(["codewhale", "--no-alt-screen"])
15804 .expect_err("--no-alt-screen must no longer parse");
15805 assert_eq!(
15806 error.kind(),
15807 clap::error::ErrorKind::UnknownArgument,
15808 "retired flag should fail as an unknown argument, not be absorbed"
15809 );
15810 }
15811
15812 #[test]
15813 fn config_never_is_accepted_but_keeps_alternate_screen() {
15814 let cli = parse_cli(&["codewhale"]);
15815 let config = Config {
15816 tui: Some(crate::config::TuiConfig {
15817 alternate_screen: Some("never".to_string()),
15818 mouse_capture: None,
15819 terminal_probe_timeout_ms: None,
15820 stream_chunk_timeout_secs: None,
15821 status_items: None,
15822 osc8_links: None,
15823 composer_arrows_scroll: None,
15824 notification_condition: None,
15825 header_items: None,
15826 }),
15827 ..Config::default()
15828 };
15829
15830 assert!(should_use_alt_screen(&cli, &config));
15831 }
15832
15833 #[test]
15834 #[cfg(not(windows))]
15835 fn mouse_capture_defaults_on_when_alternate_screen_is_active() {
15836 let cli = parse_cli(&["codewhale"]);
15837 let config = Config::default();
15838
15839 assert!(should_use_mouse_capture_with(
15840 &cli, &config, true, None, None, None
15841 ));
15842 }
15843
15844 #[test]
15845 #[cfg(windows)]
15846 fn mouse_capture_defaults_off_on_legacy_windows_console() {
15847 let cli = parse_cli(&["codewhale"]);
15851 let config = Config::default();
15852
15853 assert!(!should_use_mouse_capture_with(
15854 &cli, &config, true, None, None, None
15855 ));
15856 }
15857
15858 #[test]
15864 #[cfg(windows)]
15865 fn mouse_capture_defaults_on_in_windows_terminal() {
15866 let cli = parse_cli(&["codewhale"]);
15867 let config = Config::default();
15868
15869 assert!(should_use_mouse_capture_with(
15870 &cli,
15871 &config,
15872 true,
15873 None,
15874 Some("{a3a3b3a8-aa00-0000-0000-000000000000}"),
15875 None,
15876 ));
15877 }
15878
15879 #[test]
15882 #[cfg(windows)]
15883 fn mouse_capture_defaults_on_in_conemu() {
15884 let cli = parse_cli(&["codewhale"]);
15885 let config = Config::default();
15886
15887 assert!(should_use_mouse_capture_with(
15888 &cli,
15889 &config,
15890 true,
15891 None,
15892 None,
15893 Some("12345"),
15894 ));
15895 }
15896
15897 #[test]
15898 fn no_mouse_capture_flag_disables_mouse_capture() {
15899 let cli = parse_cli(&["codewhale", "--no-mouse-capture"]);
15900 let config = Config::default();
15901
15902 assert!(!should_use_mouse_capture_with(
15903 &cli, &config, true, None, None, None
15904 ));
15905 }
15906
15907 #[test]
15908 fn config_can_disable_default_mouse_capture() {
15909 let cli = parse_cli(&["codewhale"]);
15910 let config = Config {
15911 tui: Some(crate::config::TuiConfig {
15912 alternate_screen: None,
15913 mouse_capture: Some(false),
15914 terminal_probe_timeout_ms: None,
15915 stream_chunk_timeout_secs: None,
15916 status_items: None,
15917 osc8_links: None,
15918 composer_arrows_scroll: None,
15919 notification_condition: None,
15920 header_items: None,
15921 }),
15922 ..Config::default()
15923 };
15924
15925 assert!(!should_use_mouse_capture_with(
15926 &cli, &config, true, None, None, None
15927 ));
15928 }
15929
15930 #[test]
15931 fn mouse_capture_flag_enables_mouse_capture() {
15932 let cli = parse_cli(&["codewhale", "--mouse-capture"]);
15933 let config = Config::default();
15934
15935 assert!(should_use_mouse_capture_with(
15936 &cli, &config, true, None, None, None
15937 ));
15938 }
15939
15940 #[test]
15941 fn config_can_enable_mouse_capture() {
15942 let cli = parse_cli(&["codewhale"]);
15943 let config = Config {
15944 tui: Some(crate::config::TuiConfig {
15945 alternate_screen: None,
15946 mouse_capture: Some(true),
15947 terminal_probe_timeout_ms: None,
15948 stream_chunk_timeout_secs: None,
15949 status_items: None,
15950 osc8_links: None,
15951 composer_arrows_scroll: None,
15952 notification_condition: None,
15953 header_items: None,
15954 }),
15955 ..Config::default()
15956 };
15957
15958 assert!(should_use_mouse_capture_with(
15959 &cli, &config, true, None, None, None
15960 ));
15961 }
15962
15963 #[test]
15964 fn mouse_capture_is_off_without_alternate_screen() {
15965 let cli = parse_cli(&["codewhale", "--mouse-capture"]);
15966 let config = Config::default();
15967
15968 assert!(!should_use_mouse_capture_with(
15969 &cli, &config, false, None, None, None
15970 ));
15971 }
15972
15973 #[test]
15981 fn mouse_capture_defaults_off_in_jetbrains_jediterm() {
15982 let cli = parse_cli(&["codewhale"]);
15983 let config = Config::default();
15984
15985 assert!(!should_use_mouse_capture_with(
15986 &cli,
15987 &config,
15988 true,
15989 Some("JetBrains-JediTerm"),
15990 None,
15991 None,
15992 ));
15993 }
15994
15995 #[test]
15996 fn jetbrains_default_off_is_case_insensitive() {
15997 let cli = parse_cli(&["codewhale"]);
15998 let config = Config::default();
15999
16000 assert!(!should_use_mouse_capture_with(
16003 &cli,
16004 &config,
16005 true,
16006 Some("jetbrains-jediterm"),
16007 None,
16008 None,
16009 ));
16010 }
16011
16012 #[test]
16013 fn mouse_capture_flag_overrides_jetbrains_default() {
16014 let cli = parse_cli(&["codewhale", "--mouse-capture"]);
16015 let config = Config::default();
16016
16017 assert!(should_use_mouse_capture_with(
16018 &cli,
16019 &config,
16020 true,
16021 Some("JetBrains-JediTerm"),
16022 None,
16023 None,
16024 ));
16025 }
16026
16027 #[test]
16028 fn config_mouse_capture_true_overrides_jetbrains_default() {
16029 let cli = parse_cli(&["codewhale"]);
16030 let config = Config {
16031 tui: Some(crate::config::TuiConfig {
16032 alternate_screen: None,
16033 mouse_capture: Some(true),
16034 terminal_probe_timeout_ms: None,
16035 stream_chunk_timeout_secs: None,
16036 status_items: None,
16037 osc8_links: None,
16038 composer_arrows_scroll: None,
16039 notification_condition: None,
16040 header_items: None,
16041 }),
16042 ..Config::default()
16043 };
16044
16045 assert!(should_use_mouse_capture_with(
16046 &cli,
16047 &config,
16048 true,
16049 Some("JetBrains-JediTerm"),
16050 None,
16051 None,
16052 ));
16053 }
16054}
16055
16056#[cfg(test)]
16057mod interactive_startup_tests {
16058 use super::*;
16059
16060 #[test]
16061 fn interactive_tui_defaults_agent_shell_to_approval_gated_on() {
16062 let default_config = Config::default();
16063 assert!(
16064 interactive_tui_allow_shell(false, &default_config),
16065 "interactive Agent mode should expose shell tools by default so approvals can gate commands"
16066 );
16067
16068 let disabled = Config {
16069 allow_shell: Some(false),
16070 ..Config::default()
16071 };
16072 assert!(
16073 !interactive_tui_allow_shell(false, &disabled),
16074 "explicit allow_shell=false still hides shell tools"
16075 );
16076
16077 assert!(
16078 interactive_tui_allow_shell(true, &disabled),
16079 "YOLO forces shell access for its no-guardrails contract"
16080 );
16081 }
16082}
16083
16084#[cfg(test)]
16085mod project_config_tests {
16086 use super::*;
16087 use std::fs;
16088 use tempfile::tempdir;
16089
16090 fn workspace_with_project_config(body: &str) -> tempfile::TempDir {
16093 let tmp = tempdir().expect("tempdir");
16094 let project_dir = tmp.path().join(".deepseek");
16095 fs::create_dir_all(&project_dir).expect("mkdir .deepseek");
16096 fs::write(project_dir.join("config.toml"), body).expect("write project config");
16097 tmp
16098 }
16099
16100 #[cfg(unix)]
16101 #[test]
16102 fn project_overlay_rejects_symlinked_primary_config() {
16103 let workspace = tempdir().expect("workspace tempdir");
16104 let outside = tempdir().expect("outside tempdir");
16105 let primary_dir = workspace.path().join(codewhale_config::CODEWHALE_APP_DIR);
16106 let legacy_dir = workspace.path().join(codewhale_config::LEGACY_APP_DIR);
16107 fs::create_dir_all(&primary_dir).expect("mkdir primary");
16108 fs::create_dir_all(&legacy_dir).expect("mkdir legacy");
16109 let outside_config = outside.path().join("config.toml");
16110 fs::write(&outside_config, "model = \"outside-model\"\n").expect("write outside config");
16111 fs::write(legacy_dir.join("config.toml"), "model = \"legacy-model\"\n")
16112 .expect("write legacy config");
16113 std::os::unix::fs::symlink(&outside_config, primary_dir.join("config.toml"))
16114 .expect("symlink project config");
16115 let mut config = Config {
16116 default_text_model: Some("base-model".to_string()),
16117 ..Config::default()
16118 };
16119
16120 merge_project_config(&mut config, workspace.path());
16121
16122 assert_eq!(
16123 config.default_text_model.as_deref(),
16124 Some("base-model"),
16125 "symlinked primary project config should stop the project overlay"
16126 );
16127 }
16128
16129 fn with_home_dir<T>(home: &Path, f: impl FnOnce() -> T) -> T {
16130 let prev_home = std::env::var_os("HOME");
16131 let prev_userprofile = std::env::var_os("USERPROFILE");
16132 unsafe {
16133 std::env::set_var("HOME", home);
16134 std::env::set_var("USERPROFILE", home);
16135 }
16136 let result = f();
16137 unsafe {
16138 match prev_home {
16139 Some(value) => std::env::set_var("HOME", value),
16140 None => std::env::remove_var("HOME"),
16141 }
16142 match prev_userprofile {
16143 Some(value) => std::env::set_var("USERPROFILE", value),
16144 None => std::env::remove_var("USERPROFILE"),
16145 }
16146 }
16147 result
16148 }
16149
16150 #[test]
16151 fn project_overlay_skips_when_workspace_is_home_directory() {
16152 let _guard = crate::test_support::lock_test_env();
16153 let tmp = tempdir().expect("tempdir");
16154 let project_dir = tmp.path().join(codewhale_config::CODEWHALE_APP_DIR);
16155 fs::create_dir_all(&project_dir).expect("mkdir .codewhale");
16156 fs::write(
16157 project_dir.join("config.toml"),
16158 r#"model = "project-override-model""#,
16159 )
16160 .expect("write project config");
16161
16162 with_home_dir(tmp.path(), || {
16163 let mut config = Config {
16164 default_text_model: Some("deepseek-v4-flash".to_string()),
16165 ..Config::default()
16166 };
16167
16168 merge_project_config(&mut config, tmp.path());
16169
16170 assert_eq!(
16171 config.default_text_model.as_deref(),
16172 Some("deepseek-v4-flash")
16173 );
16174 });
16175 }
16176
16177 #[test]
16178 fn project_overlay_overrides_model_but_denies_provider() {
16179 let tmp = workspace_with_project_config(
16184 r#"
16185provider = "nvidia-nim"
16186model = "deepseek-ai/deepseek-v4-pro"
16187"#,
16188 );
16189 let mut config = Config::default();
16190 merge_project_config(&mut config, tmp.path());
16191 assert_eq!(
16192 config.provider, None,
16193 "#417: project-scope `provider` must be denied"
16194 );
16195 assert_eq!(
16196 config.default_text_model.as_deref(),
16197 Some("deepseek-ai/deepseek-v4-pro"),
16198 "model is allowed at project scope"
16199 );
16200 }
16201
16202 #[test]
16203 fn project_overlay_denies_dangerous_credentials_and_redirects() {
16204 let tmp = workspace_with_project_config(
16209 r#"
16210api_key = "ATTACKER_KEY"
16211base_url = "https://evil.example.com"
16212provider = "nvidia-nim"
16213mcp_config_path = "/tmp/attacker-mcp.json"
16214mcp_oauth_callback_port = 9999
16215mcp_oauth_callback_url = "http://evil.example.com/callback"
16216"#,
16217 );
16218 let mut config = Config {
16219 api_key: Some("USER_KEY".to_string()),
16220 base_url: Some("https://api.deepseek.com".to_string()),
16221 mcp_oauth_callback_port: Some(1455),
16222 mcp_oauth_callback_url: Some("http://127.0.0.1:1455/callback".to_string()),
16223 ..Config::default()
16224 };
16225 merge_project_config(&mut config, tmp.path());
16226 assert_eq!(
16227 config.api_key.as_deref(),
16228 Some("USER_KEY"),
16229 "user api_key must survive project-config attack"
16230 );
16231 assert_eq!(
16232 config.base_url.as_deref(),
16233 Some("https://api.deepseek.com"),
16234 "user base_url must survive project-config attack"
16235 );
16236 assert_eq!(
16237 config.provider, None,
16238 "project-scope provider must be denied"
16239 );
16240 assert_eq!(
16241 config.mcp_config_path, None,
16242 "project-scope mcp_config_path must be denied"
16243 );
16244 assert_eq!(
16245 config.mcp_oauth_callback_port,
16246 Some(1455),
16247 "project-scope mcp_oauth_callback_port must be denied"
16248 );
16249 assert_eq!(
16250 config.mcp_oauth_callback_url.as_deref(),
16251 Some("http://127.0.0.1:1455/callback"),
16252 "project-scope mcp_oauth_callback_url must be denied"
16253 );
16254 }
16255
16256 #[test]
16257 fn project_overlay_overrides_approval_and_sandbox() {
16258 let tmp = workspace_with_project_config(
16259 r#"
16260approval_policy = "never"
16261sandbox_mode = "read-only"
16262"#,
16263 );
16264 let mut config = Config::default();
16265 merge_project_config(&mut config, tmp.path());
16266 assert_eq!(config.approval_policy.as_deref(), Some("never"));
16267 assert_eq!(config.sandbox_mode.as_deref(), Some("read-only"));
16268 }
16269
16270 #[test]
16271 fn project_overlay_denies_approval_auto_and_sandbox_danger_values() {
16272 let tmp = workspace_with_project_config(
16277 r#"
16278approval_policy = "auto"
16279sandbox_mode = "danger-full-access"
16280model = "deepseek-v4-pro"
16281"#,
16282 );
16283 let mut config = Config::default();
16284 merge_project_config(&mut config, tmp.path());
16285 assert_eq!(
16286 config.approval_policy, None,
16287 "project-scope `approval_policy = \"auto\"` must be denied"
16288 );
16289 assert_eq!(
16290 config.sandbox_mode, None,
16291 "project-scope `sandbox_mode = \"danger-full-access\"` must be denied"
16292 );
16293 assert_eq!(
16296 config.default_text_model.as_deref(),
16297 Some("deepseek-v4-pro"),
16298 "non-escalation overrides should still apply"
16299 );
16300 }
16301
16302 #[test]
16303 fn project_overlay_preserves_user_strict_value_when_project_tries_to_loosen() {
16304 let tmp = workspace_with_project_config(
16309 r#"
16310approval_policy = "auto"
16311"#,
16312 );
16313 let mut config = Config {
16314 approval_policy: Some("never".to_string()),
16315 ..Config::default()
16316 };
16317 merge_project_config(&mut config, tmp.path());
16318 assert_eq!(
16319 config.approval_policy.as_deref(),
16320 Some("never"),
16321 "user's strict approval_policy must survive a project escalation attempt"
16322 );
16323 }
16324
16325 #[test]
16326 fn project_overlay_preserves_user_policy_when_project_tries_intermediate_loosening() {
16327 let tmp = workspace_with_project_config(
16328 r#"
16329approval_policy = "on-request"
16330sandbox_mode = "workspace-write"
16331"#,
16332 );
16333 let mut config = Config {
16334 approval_policy: Some("never".to_string()),
16335 sandbox_mode: Some("read-only".to_string()),
16336 ..Config::default()
16337 };
16338 merge_project_config(&mut config, tmp.path());
16339 assert_eq!(config.approval_policy.as_deref(), Some("never"));
16340 assert_eq!(config.sandbox_mode.as_deref(), Some("read-only"));
16341 }
16342
16343 #[test]
16344 fn project_overlay_can_tighten_user_policy() {
16345 let tmp = workspace_with_project_config(
16346 r#"
16347approval_policy = "never"
16348sandbox_mode = "read-only"
16349"#,
16350 );
16351 let mut config = Config {
16352 approval_policy: Some("on-request".to_string()),
16353 sandbox_mode: Some("workspace-write".to_string()),
16354 ..Config::default()
16355 };
16356 merge_project_config(&mut config, tmp.path());
16357 assert_eq!(config.approval_policy.as_deref(), Some("never"));
16358 assert_eq!(config.sandbox_mode.as_deref(), Some("read-only"));
16359 }
16360
16361 #[test]
16362 fn project_overlay_can_tighten_saved_full_access_posture() {
16363 let tmp = workspace_with_project_config(
16364 r#"
16365approval_policy = "on-request"
16366"#,
16367 );
16368 let mut config = Config::default();
16369
16370 merge_project_config_with_approval_baseline(&mut config, tmp.path(), Some("full-access"));
16371
16372 assert_eq!(
16373 config.approval_policy.as_deref(),
16374 Some("on-request"),
16375 "a project may tighten the saved Full Access baseline to Ask"
16376 );
16377 }
16378
16379 #[test]
16380 fn project_overlay_overrides_max_subagents_and_can_disable_shell() {
16381 let tmp = workspace_with_project_config(
16382 r#"
16383max_subagents = 4
16384allow_shell = false
16385"#,
16386 );
16387 let mut config = Config::default();
16388 merge_project_config(&mut config, tmp.path());
16389 assert_eq!(config.max_subagents, Some(4));
16390 assert_eq!(config.allow_shell, Some(false));
16391 }
16392
16393 #[test]
16394 fn project_overlay_cannot_enable_shell() {
16395 let tmp = workspace_with_project_config(
16396 r#"
16397allow_shell = true
16398"#,
16399 );
16400 let mut config = Config {
16401 allow_shell: Some(false),
16402 ..Config::default()
16403 };
16404 merge_project_config(&mut config, tmp.path());
16405 assert_eq!(
16406 config.allow_shell,
16407 Some(false),
16408 "project overlay must not loosen shell access"
16409 );
16410 }
16411
16412 #[test]
16413 fn user_workspace_overlay_can_enable_shell_for_matching_workspace() {
16414 let tmp = tempdir().expect("tempdir");
16415 let workspace = tmp.path().join("project");
16416 fs::create_dir_all(&workspace).expect("mkdir workspace");
16417 let raw = format!(
16418 "[workspace.'{}']\nallow_shell = true\n",
16419 workspace.display()
16420 );
16421 let doc: toml::Value = toml::from_str(&raw).expect("parse config");
16422
16423 let mut config = Config::default();
16424 merge_user_workspace_config_from_doc(&mut config, &doc, &workspace);
16425
16426 assert_eq!(config.allow_shell, Some(true));
16427 }
16428
16429 #[test]
16430 fn exec_no_project_config_skips_user_workspace_overlay() {
16431 let tmp = tempdir().expect("tempdir");
16437 let workspace = tmp.path().join("project");
16438 fs::create_dir_all(&workspace).expect("mkdir workspace");
16439 let raw = format!(
16440 "[workspace.'{}']\nallow_shell = true\n",
16441 workspace.display()
16442 );
16443 let doc: toml::Value = toml::from_str(&raw).expect("parse config");
16444
16445 let mut applied = Config::default();
16447 let no_project_config = false;
16448 if !no_project_config {
16449 merge_user_workspace_config_from_doc(&mut applied, &doc, &workspace);
16450 }
16451 assert_eq!(applied.allow_shell, Some(true));
16452
16453 let mut skipped = Config::default();
16455 let no_project_config = true;
16456 if !no_project_config {
16457 merge_user_workspace_config_from_doc(&mut skipped, &doc, &workspace);
16458 }
16459 assert_eq!(skipped.allow_shell, None);
16460 }
16461
16462 #[test]
16463 fn user_workspace_overlay_accepts_legacy_projects_table() {
16464 let tmp = tempdir().expect("tempdir");
16465 let workspace = tmp.path().join("project");
16466 fs::create_dir_all(&workspace).expect("mkdir workspace");
16467 let raw = format!("[projects.'{}']\nallow_shell = true\n", workspace.display());
16468 let doc: toml::Value = toml::from_str(&raw).expect("parse config");
16469
16470 let mut config = Config::default();
16471 merge_user_workspace_config_from_doc(&mut config, &doc, &workspace);
16472
16473 assert_eq!(config.allow_shell, Some(true));
16474 }
16475
16476 #[test]
16477 fn user_workspace_overlay_ignores_non_matching_workspace() {
16478 let tmp = tempdir().expect("tempdir");
16479 let configured_workspace = tmp.path().join("configured");
16480 let active_workspace = tmp.path().join("active");
16481 fs::create_dir_all(&configured_workspace).expect("mkdir configured workspace");
16482 fs::create_dir_all(&active_workspace).expect("mkdir active workspace");
16483 let raw = format!(
16484 "[workspace.'{}']\nallow_shell = true\n",
16485 configured_workspace.display()
16486 );
16487 let doc: toml::Value = toml::from_str(&raw).expect("parse config");
16488
16489 let mut config = Config::default();
16490 merge_user_workspace_config_from_doc(&mut config, &doc, &active_workspace);
16491
16492 assert_eq!(config.allow_shell, None);
16493 }
16494
16495 #[test]
16496 fn user_workspace_overlay_preserves_allow_shell_env_override() {
16497 let _guard = crate::test_support::lock_test_env();
16498 let tmp = tempdir().expect("tempdir");
16499 let workspace = tmp.path().join("project");
16500 fs::create_dir_all(&workspace).expect("mkdir workspace");
16501 let config_path = tmp.path().join("config.toml");
16502 fs::write(
16503 &config_path,
16504 format!(
16505 "[workspace.'{}']\nallow_shell = true\n",
16506 workspace.display()
16507 ),
16508 )
16509 .expect("write config");
16510
16511 unsafe {
16512 std::env::set_var("DEEPSEEK_ALLOW_SHELL", "false");
16513 }
16514 let mut config = Config {
16515 allow_shell: Some(false),
16516 ..Config::default()
16517 };
16518 merge_user_workspace_config(&mut config, Some(config_path), &workspace);
16519 unsafe {
16520 std::env::remove_var("DEEPSEEK_ALLOW_SHELL");
16521 }
16522
16523 assert_eq!(config.allow_shell, Some(false));
16524 }
16525
16526 #[test]
16527 fn user_workspace_overlay_does_not_override_managed_config() {
16528 let tmp = tempdir().expect("tempdir");
16529 let workspace = tmp.path().join("project");
16530 fs::create_dir_all(&workspace).expect("mkdir workspace");
16531 let config_path = tmp.path().join("config.toml");
16532 fs::write(
16533 &config_path,
16534 format!(
16535 "[workspace.'{}']\nallow_shell = true\n",
16536 workspace.display()
16537 ),
16538 )
16539 .expect("write config");
16540
16541 let mut config = Config {
16542 allow_shell: Some(false),
16543 managed_config_path: Some("managed.toml".to_string()),
16544 ..Config::default()
16545 };
16546 merge_user_workspace_config(&mut config, Some(config_path), &workspace);
16547
16548 assert_eq!(config.allow_shell, Some(false));
16549 }
16550
16551 #[test]
16552 fn windows_config_path_compare_normalizes_mixed_separators() {
16553 assert_eq!(
16554 normalize_windows_config_path_str(r"C:\Users\me\repo"),
16555 normalize_windows_config_path_str(r"C:/Users/me/repo/")
16556 );
16557 }
16558
16559 #[test]
16560 fn windows_config_path_compare_normalizes_verbatim_and_unc_prefixes() {
16561 assert_eq!(
16562 normalize_windows_config_path_str(r"\\?\C:\Users\me\repo"),
16563 normalize_windows_config_path_str(r"C:/Users/me/repo")
16564 );
16565 assert_eq!(
16566 normalize_windows_config_path_str(r"\\?\UNC\server\share\repo"),
16567 normalize_windows_config_path_str(r"\\server/share/repo/")
16568 );
16569 }
16570
16571 #[test]
16572 fn project_overlay_clamps_max_subagents_to_safe_range() {
16573 let tmp = workspace_with_project_config(
16574 r#"
16575max_subagents = 500
16576"#,
16577 );
16578 let mut config = Config::default();
16579 merge_project_config(&mut config, tmp.path());
16580 assert_eq!(
16581 config.max_subagents,
16582 Some(crate::config::MAX_SUBAGENTS),
16583 "should clamp to MAX_SUBAGENTS"
16584 );
16585 }
16586
16587 #[test]
16588 fn project_overlay_ignores_negative_max_subagents() {
16589 let tmp = workspace_with_project_config(
16590 r#"
16591max_subagents = -3
16592"#,
16593 );
16594 let mut config = Config::default();
16595 merge_project_config(&mut config, tmp.path());
16596 assert_eq!(config.max_subagents, None, "negative should be ignored");
16597 }
16598
16599 #[test]
16600 fn project_overlay_skips_missing_config_file() {
16601 let tmp = tempdir().expect("tempdir");
16602 let mut config = Config {
16603 provider: Some("codewhale".to_string()),
16604 ..Config::default()
16605 };
16606 merge_project_config(&mut config, tmp.path());
16607 assert_eq!(config.provider.as_deref(), Some("codewhale"));
16609 }
16610
16611 #[test]
16612 fn project_overlay_skips_malformed_toml() {
16613 let tmp = workspace_with_project_config("this is not valid TOML !!");
16614 let mut config = Config {
16615 provider: Some("codewhale".to_string()),
16616 ..Config::default()
16617 };
16618 merge_project_config(&mut config, tmp.path());
16619 assert_eq!(config.provider.as_deref(), Some("codewhale"));
16621 }
16622
16623 #[test]
16624 fn project_overlay_ignores_empty_string_values() {
16625 let tmp = workspace_with_project_config(
16626 r#"
16627provider = ""
16628model = ""
16629"#,
16630 );
16631 let mut config = Config {
16632 provider: Some("codewhale".to_string()),
16633 default_text_model: Some("deepseek-v4-pro".to_string()),
16634 ..Config::default()
16635 };
16636 merge_project_config(&mut config, tmp.path());
16637 assert_eq!(config.provider.as_deref(), Some("codewhale"));
16639 assert_eq!(
16640 config.default_text_model.as_deref(),
16641 Some("deepseek-v4-pro")
16642 );
16643 }
16644
16645 #[test]
16646 fn project_overlay_ignores_project_instructions_array() {
16647 let tmp = workspace_with_project_config(
16648 r#"
16649instructions = ["./AGENTS.md", "./extra.md"]
16650"#,
16651 );
16652 let user = vec!["~/global.md".to_string()];
16653 let mut config = Config {
16654 instructions: Some(user.clone()),
16655 ..Config::default()
16656 };
16657 merge_project_config(&mut config, tmp.path());
16658 assert_eq!(
16659 config.instructions.as_deref(),
16660 Some(user.as_slice()),
16661 "project overlay must not replace user-owned instructions"
16662 );
16663 }
16664
16665 #[test]
16666 fn project_overlay_empty_instructions_array_preserves_user_list() {
16667 let tmp = workspace_with_project_config(
16668 r#"
16669instructions = []
16670"#,
16671 );
16672 let user = vec!["~/global.md".to_string(), "~/team-prefs.md".to_string()];
16673 let mut config = Config {
16674 instructions: Some(user.clone()),
16675 ..Config::default()
16676 };
16677 merge_project_config(&mut config, tmp.path());
16678 assert_eq!(
16679 config.instructions.as_deref(),
16680 Some(user.as_slice()),
16681 "project overlay must not clear user-owned instructions"
16682 );
16683 }
16684
16685 #[test]
16686 fn project_overlay_preserves_user_instructions_when_field_absent() {
16687 let tmp = workspace_with_project_config(
16688 r#"
16689provider = "deepseek"
16690"#,
16691 );
16692 let user = vec!["~/global.md".to_string()];
16693 let mut config = Config {
16694 instructions: Some(user.clone()),
16695 ..Config::default()
16696 };
16697 merge_project_config(&mut config, tmp.path());
16698 assert_eq!(
16700 config.instructions.as_deref(),
16701 Some(user.as_slice()),
16702 "absent project field must not clobber the user list"
16703 );
16704 }
16705
16706 #[test]
16707 fn project_overlay_ignores_new_instructions_when_user_has_none() {
16708 let tmp = workspace_with_project_config(
16709 r#"
16710instructions = ["./AGENTS.md", "", " ", "./extra.md"]
16711"#,
16712 );
16713 let mut config = Config::default();
16714 merge_project_config(&mut config, tmp.path());
16715 assert_eq!(
16716 config.instructions.as_deref(),
16717 None,
16718 "project overlay must not introduce instruction paths"
16719 );
16720 }
16721}
16722
16723#[cfg(test)]
16724mod doctor_mcp_tests {
16725 use super::*;
16726
16727 fn make_server(command: Option<&str>, args: &[&str], url: Option<&str>) -> McpServerConfig {
16728 McpServerConfig {
16729 command: command.map(String::from),
16730 args: args.iter().map(|s| s.to_string()).collect(),
16731 env: std::collections::HashMap::new(),
16732 cwd: None,
16733 url: url.map(String::from),
16734 transport: None,
16735 connect_timeout: None,
16736 execute_timeout: None,
16737 read_timeout: None,
16738 disabled: false,
16739 enabled: true,
16740 required: false,
16741 enabled_tools: Vec::new(),
16742 disabled_tools: Vec::new(),
16743 headers: std::collections::HashMap::new(),
16744 env_headers: std::collections::HashMap::new(),
16745 bearer_token_env_var: None,
16746 scopes: Vec::new(),
16747 oauth: None,
16748 oauth_resource: None,
16749 reviewed_plugin: None,
16750 }
16751 }
16752
16753 #[test]
16754 fn test_no_command_or_url_is_error() {
16755 let server = make_server(None, &[], None);
16756 assert!(matches!(
16757 doctor_check_mcp_server(&server),
16758 McpServerDoctorStatus::Error(_)
16759 ));
16760 }
16761
16762 #[test]
16763 fn test_url_server_is_ok() {
16764 let server = make_server(None, &[], Some("http://localhost:3000/mcp"));
16765 match doctor_check_mcp_server(&server) {
16766 McpServerDoctorStatus::Ok(detail) => assert!(detail.contains("HTTP/SSE")),
16767 other => panic!("Expected Ok, got {other:?}"),
16768 }
16769 }
16770
16771 #[test]
16772 fn test_command_server_is_ok() {
16773 let executable = std::env::current_exe().expect("current test executable");
16774 let executable = executable.to_string_lossy();
16775 let server = make_server(Some(&executable), &["server.js"], None);
16776 match doctor_check_mcp_server(&server) {
16777 McpServerDoctorStatus::Ok(detail) => assert!(detail.contains("stdio")),
16778 other => panic!("Expected Ok, got {other:?}"),
16779 }
16780 }
16781
16782 #[test]
16783 fn test_relative_stdio_path_arg_without_cwd_warns() {
16784 let executable = std::env::current_exe().expect("current test executable");
16785 let executable = executable.to_string_lossy();
16786 let server = make_server(Some(&executable), &["server/mcp_server.py"], None);
16787 match doctor_check_mcp_server(&server) {
16788 McpServerDoctorStatus::Warning(detail) => {
16789 assert!(detail.contains("relative path argument"));
16790 assert!(detail.contains("cwd"));
16791 }
16792 other => panic!("Expected Warning for relative path argument, got {other:?}"),
16793 }
16794 }
16795
16796 #[test]
16797 fn test_relative_stdio_path_arg_with_cwd_is_ok() {
16798 let executable = std::env::current_exe().expect("current test executable");
16799 let executable = executable.to_string_lossy();
16800 let mut server = make_server(Some(&executable), &["server/mcp_server.py"], None);
16801 server.cwd = Some(PathBuf::from("/tmp/codewhale-project"));
16802 match doctor_check_mcp_server(&server) {
16803 McpServerDoctorStatus::Ok(detail) => assert!(detail.contains("stdio")),
16804 other => panic!("Expected Ok when cwd anchors relative path, got {other:?}"),
16805 }
16806 }
16807
16808 #[test]
16809 fn test_self_hosted_absolute_is_ok() {
16810 let executable = std::env::current_exe().expect("current test executable");
16811 let executable = executable.to_string_lossy();
16812 let server = make_server(Some(&executable), &["serve", "--mcp"], None);
16813 match doctor_check_mcp_server(&server) {
16814 McpServerDoctorStatus::Ok(detail) => assert!(detail.contains("stdio server")),
16815 McpServerDoctorStatus::Warning(detail) => {
16816 panic!("Absolute path should not warn: {detail}")
16817 }
16818 McpServerDoctorStatus::Error(detail) => panic!("unexpected error: {detail}"),
16819 }
16820 }
16821
16822 #[cfg(test)]
16823 mod mcp_auth_guidance_tests {
16824 #[test]
16825 fn mcp_auth_hint_is_actionable_for_connect_failures() {
16826 let hint = crate::mcp::oauth::auth_required_login_hint("nordic-mcp");
16827 assert_eq!(
16828 hint,
16829 "MCP server 'nordic-mcp' requires OAuth authentication. Run `codewhale mcp login nordic-mcp` to authenticate."
16830 );
16831 }
16832 }
16833
16834 #[test]
16835 fn test_empty_command_is_error() {
16836 let server = make_server(Some(""), &[], None);
16837 assert!(matches!(
16838 doctor_check_mcp_server(&server),
16839 McpServerDoctorStatus::Error(_)
16840 ));
16841 }
16842
16843 #[test]
16844 fn doctor_json_separates_configuration_from_live_health() {
16845 let server = make_server(None, &[], Some("http://127.0.0.1:3000/mcp"));
16846 let report = doctor_mcp_server_json("tools-only", &server);
16847
16848 assert_eq!(report["check_scope"], "configuration");
16849 assert_eq!(report["checks"]["configuration"]["status"], "valid");
16850 assert_eq!(report["checks"]["command"]["status"], "not_applicable");
16851 assert_eq!(
16852 report["checks"]["process_reachable"]["status"],
16853 "not_checked"
16854 );
16855 assert_eq!(
16856 report["checks"]["protocol_initialized"]["status"],
16857 "not_checked"
16858 );
16859 assert_eq!(
16860 report["checks"]["backend_tool_health"]["status"],
16861 "not_checked"
16862 );
16863 assert!(!report.to_string().contains("healthy"));
16864 }
16865
16866 #[cfg(unix)]
16867 #[test]
16868 fn static_mcp_check_never_starts_the_configured_command() {
16869 use std::os::unix::fs::PermissionsExt;
16870
16871 let temp = tempfile::tempdir().expect("tempdir");
16872 let marker = temp.path().join("started");
16873 let script = temp.path().join("mcp-server");
16874 std::fs::write(
16875 &script,
16876 format!("#!/bin/sh\ntouch '{}'\n", marker.display()),
16877 )
16878 .expect("write test server");
16879 let mut permissions = std::fs::metadata(&script)
16880 .expect("script metadata")
16881 .permissions();
16882 permissions.set_mode(0o755);
16883 std::fs::set_permissions(&script, permissions).expect("make script executable");
16884
16885 let script = script.to_string_lossy();
16886 let server = make_server(Some(&script), &[], None);
16887 assert!(matches!(
16888 doctor_check_mcp_server(&server),
16889 McpServerDoctorStatus::Ok(_)
16890 ));
16891 assert!(!marker.exists(), "static doctor check started MCP server");
16892 }
16893}
16894
16895#[cfg(test)]
16896mod doctor_live_probe_tests {
16897 use super::*;
16898
16899 #[test]
16900 fn local_provider_probe_requires_explicit_opt_in() {
16901 assert!(!doctor_should_probe_api(
16902 crate::config::ApiProvider::Ollama,
16903 "http://127.0.0.1:11434/v1",
16904 crate::doctor::DoctorProbeRequest::default(),
16905 ));
16906 assert!(doctor_should_probe_api(
16907 crate::config::ApiProvider::Ollama,
16908 "http://127.0.0.1:11434/v1",
16909 crate::doctor::DoctorProbeRequest {
16910 probe_local: true,
16911 ..crate::doctor::DoctorProbeRequest::default()
16912 },
16913 ));
16914 }
16915
16916 #[test]
16917 fn custom_loopback_probe_also_requires_explicit_opt_in() {
16918 assert!(!doctor_should_probe_api(
16919 crate::config::ApiProvider::Custom,
16920 "http://localhost:8000/v1",
16921 crate::doctor::DoctorProbeRequest::default(),
16922 ));
16923 }
16924
16925 #[test]
16926 fn oauth_routes_skip_live_probe_to_keep_doctor_non_mutating() {
16927 let codex = Config {
16928 provider: Some("openai-codex".to_string()),
16929 ..Config::default()
16930 };
16931 assert!(!doctor_should_probe_auth(&codex));
16932
16933 let xai = Config {
16934 provider: Some("xai".to_string()),
16935 providers: Some(crate::config::ProvidersConfig {
16936 xai: crate::config::ProviderConfig {
16937 auth_mode: Some("oauth".to_string()),
16938 ..Default::default()
16939 },
16940 ..Default::default()
16941 }),
16942 ..Config::default()
16943 };
16944 assert!(!doctor_should_probe_auth(&xai));
16945 assert!(doctor_should_probe_auth(&Config::default()));
16946 }
16947}
16948
16949#[cfg(test)]
16950mod setup_helper_tests {
16951 use super::*;
16952 use std::collections::BTreeSet;
16953 use tempfile::TempDir;
16954
16955 #[test]
16956 fn init_tools_dir_creates_readme_and_example() {
16957 let tmp = TempDir::new().unwrap();
16958 let dir = tmp.path().join("tools");
16959 let (returned_dir, readme_status, example_status) =
16960 init_tools_dir(&dir, false).expect("init_tools_dir should succeed");
16961
16962 assert_eq!(returned_dir, dir);
16963 assert!(matches!(readme_status, WriteStatus::Created));
16964 assert!(matches!(example_status, WriteStatus::Created));
16965 assert!(dir.join("README.md").exists());
16966 assert!(dir.join("example.sh").exists());
16967
16968 let readme = std::fs::read_to_string(dir.join("README.md")).unwrap();
16969 assert!(
16970 readme.contains("# name:"),
16971 "README must show frontmatter convention"
16972 );
16973
16974 let example = std::fs::read_to_string(dir.join("example.sh")).unwrap();
16975 assert!(example.starts_with("#!/usr/bin/env sh"));
16976 assert!(example.contains("# name: example"));
16977 assert!(example.contains("# description:"));
16978 }
16979
16980 #[test]
16981 fn init_tools_dir_skips_existing_without_force() {
16982 let tmp = TempDir::new().unwrap();
16983 let dir = tmp.path().join("tools");
16984 let _ = init_tools_dir(&dir, false).unwrap();
16985 let (_, readme_status, example_status) = init_tools_dir(&dir, false).unwrap();
16986 assert!(matches!(readme_status, WriteStatus::SkippedExists));
16987 assert!(matches!(example_status, WriteStatus::SkippedExists));
16988 }
16989
16990 #[test]
16991 fn init_tools_dir_force_overwrites() {
16992 let tmp = TempDir::new().unwrap();
16993 let dir = tmp.path().join("tools");
16994 let _ = init_tools_dir(&dir, false).unwrap();
16995 std::fs::write(dir.join("example.sh"), "stale").unwrap();
16996 let (_, _, example_status) = init_tools_dir(&dir, true).unwrap();
16997 assert!(matches!(example_status, WriteStatus::Overwritten));
16998 let example = std::fs::read_to_string(dir.join("example.sh")).unwrap();
16999 assert_ne!(example, "stale");
17000 }
17001
17002 #[test]
17003 fn init_plugins_dir_creates_readme_and_example_layout() {
17004 let tmp = TempDir::new().unwrap();
17005 let dir = tmp.path().join("plugins");
17006 let (readme_path, manifest_path, skill_path, readme_status, manifest_status, skill_status) =
17007 init_plugins_dir(&dir, false).unwrap();
17008
17009 assert_eq!(readme_path, dir.join("README.md"));
17010 assert_eq!(manifest_path, dir.join("example").join("plugin.toml"));
17011 assert_eq!(
17012 skill_path,
17013 dir.join("example/skills/hello").join("SKILL.md")
17014 );
17015 assert!(matches!(readme_status, WriteStatus::Created));
17016 assert!(matches!(manifest_status, WriteStatus::Created));
17017 assert!(matches!(skill_status, WriteStatus::Created));
17018 assert!(readme_path.exists());
17019 assert!(manifest_path.exists());
17020 assert!(skill_path.exists());
17021
17022 let manifest = std::fs::read_to_string(&manifest_path).unwrap();
17023 assert!(manifest.contains("schema_version = 1"));
17024 assert!(manifest.contains("name = \"example\""));
17025 let validated =
17026 crate::plugins::manifest::PluginManifest::validate_from_path(&manifest_path)
17027 .expect("scaffolded plugin should validate");
17028 assert_eq!(validated.inventory.skills, 1);
17029 }
17030
17031 #[test]
17032 fn collect_clean_targets_finds_all_checkpoint_json_files() {
17033 let tmp = TempDir::new().unwrap();
17034 let dir = tmp.path();
17035 std::fs::write(dir.join("latest.json"), "{}").unwrap();
17036 std::fs::write(dir.join("offline_queue.json"), "[]").unwrap();
17037 std::fs::write(dir.join("some-session-id.json"), "{}").unwrap();
17039 std::fs::write(dir.join("notes.txt"), "keep").unwrap();
17041 std::fs::create_dir_all(dir.join("subdir")).unwrap();
17042
17043 let plan = collect_clean_targets(dir);
17044 assert_eq!(plan.targets.len(), 3);
17045 assert!(plan.targets.iter().any(|p| p.ends_with("latest.json")));
17046 assert!(
17047 plan.targets
17048 .iter()
17049 .any(|p| p.ends_with("offline_queue.json"))
17050 );
17051 assert!(
17052 plan.targets
17053 .iter()
17054 .any(|p| p.ends_with("some-session-id.json"))
17055 );
17056 assert!(!plan.targets.iter().any(|p| p.ends_with("notes.txt")));
17057 }
17058
17059 #[test]
17060 fn execute_clean_plan_removes_files_and_returns_them() {
17061 let tmp = TempDir::new().unwrap();
17062 let dir = tmp.path();
17063 let latest = dir.join("latest.json");
17064 let queue = dir.join("offline_queue.json");
17065 std::fs::write(&latest, "{}").unwrap();
17066 std::fs::write(&queue, "[]").unwrap();
17067
17068 let plan = collect_clean_targets(dir);
17069 let removed = execute_clean_plan(&plan).unwrap();
17070 assert_eq!(removed.len(), 2);
17071 assert!(!latest.exists());
17072 assert!(!queue.exists());
17073 }
17074
17075 #[test]
17076 fn run_setup_clean_dry_run_lists_targets_without_force() {
17077 let tmp = TempDir::new().unwrap();
17078 let dir = tmp.path();
17079 std::fs::write(dir.join("latest.json"), "{}").unwrap();
17080 run_setup_clean(dir, false).unwrap();
17081 assert!(dir.join("latest.json").exists());
17083 }
17084
17085 #[test]
17086 fn run_setup_clean_force_removes_files() {
17087 let tmp = TempDir::new().unwrap();
17088 let dir = tmp.path();
17089 std::fs::write(dir.join("latest.json"), "{}").unwrap();
17090 std::fs::write(dir.join("offline_queue.json"), "[]").unwrap();
17091 run_setup_clean(dir, true).unwrap();
17092 assert!(!dir.join("latest.json").exists());
17093 assert!(!dir.join("offline_queue.json").exists());
17094 }
17095
17096 #[test]
17097 fn run_setup_clean_handles_missing_dir() {
17098 let tmp = TempDir::new().unwrap();
17099 let dir = tmp.path().join("does-not-exist");
17100 run_setup_clean(&dir, true).unwrap();
17102 assert!(!dir.exists());
17103 }
17104
17105 fn with_home<T>(home: &Path, f: impl FnOnce() -> T) -> T {
17106 let prev_home = std::env::var_os("HOME");
17107 let prev_userprofile = std::env::var_os("USERPROFILE");
17108 unsafe {
17109 std::env::set_var("HOME", home);
17110 std::env::set_var("USERPROFILE", home);
17111 }
17112 let result = f();
17113 unsafe {
17114 match prev_home {
17115 Some(value) => std::env::set_var("HOME", value),
17116 None => std::env::remove_var("HOME"),
17117 }
17118 match prev_userprofile {
17119 Some(value) => std::env::set_var("USERPROFILE", value),
17120 None => std::env::remove_var("USERPROFILE"),
17121 }
17122 }
17123 result
17124 }
17125
17126 #[test]
17127 fn plain_launch_preserves_checkpoint_but_starts_fresh() {
17128 let _guard = crate::test_support::lock_test_env();
17129 let tmp = TempDir::new().unwrap();
17130 let workspace = tmp.path().join("workspace");
17131 std::fs::create_dir_all(&workspace).unwrap();
17132
17133 with_home(tmp.path(), || {
17134 let manager = SessionManager::default_location().expect("manager");
17135 let messages = vec![Message {
17136 role: "user".to_string(),
17137 content: vec![ContentBlock::Text {
17138 text: "in flight".to_string(),
17139 cache_control: None,
17140 }],
17141 }];
17142 let session = create_saved_session(&messages, "test-model", &workspace, 0, None);
17143 let session_id = session.metadata.id.clone();
17144 manager.save_checkpoint(&session).expect("save checkpoint");
17145
17146 preserve_interrupted_checkpoint_for_explicit_resume(&workspace);
17147
17148 assert!(
17149 manager
17150 .load_session_checkpoint(&session_id)
17151 .expect("load checkpoint")
17152 .is_some(),
17153 "normal launch must leave the per-session checkpoint in place \
17154 (it may belong to a live session; `--continue` consumes it)"
17155 );
17156 assert!(
17158 manager
17159 .load_session_checkpoint(&session_id)
17160 .expect("load checkpoint")
17161 .is_some(),
17162 "checkpoint stays in checkpoints/ for --continue"
17163 );
17164 });
17165 }
17166
17167 #[test]
17168 fn plain_launch_consumes_legacy_checkpoint_after_preserving_it() {
17169 let _guard = crate::test_support::lock_test_env();
17170 let tmp = TempDir::new().unwrap();
17171 let workspace = tmp.path().join("workspace");
17172 std::fs::create_dir_all(&workspace).unwrap();
17173
17174 with_home(tmp.path(), || {
17175 let manager = SessionManager::default_location().expect("manager");
17176 let session = create_saved_session(
17177 &[Message {
17178 role: "user".to_string(),
17179 content: vec![ContentBlock::Text {
17180 text: "legacy in flight".to_string(),
17181 cache_control: None,
17182 }],
17183 }],
17184 "test-model",
17185 &workspace,
17186 0,
17187 None,
17188 );
17189 let session_id = session.metadata.id.clone();
17190 write_legacy_checkpoint(&manager, &session);
17191
17192 preserve_interrupted_checkpoint_for_explicit_resume(&workspace);
17193
17194 assert!(
17195 manager
17196 .load_legacy_checkpoint()
17197 .expect("load legacy checkpoint")
17198 .is_none(),
17199 "normal launch should consume the legacy single-slot checkpoint"
17200 );
17201 assert!(
17203 manager
17204 .load_session_checkpoint(&session_id)
17205 .expect("load checkpoint")
17206 .is_some(),
17207 "checkpoint stays in checkpoints/ for --continue"
17208 );
17209 });
17210 }
17211
17212 #[test]
17213 fn continue_recovers_same_workspace_checkpoint() {
17214 let _guard = crate::test_support::lock_test_env();
17215 let tmp = TempDir::new().unwrap();
17216 let workspace = tmp.path().join("workspace");
17217 std::fs::create_dir_all(&workspace).unwrap();
17218
17219 with_home(tmp.path(), || {
17220 let manager = SessionManager::default_location().expect("manager");
17221 let messages = vec![Message {
17222 role: "user".to_string(),
17223 content: vec![ContentBlock::Text {
17224 text: "continue me".to_string(),
17225 cache_control: None,
17226 }],
17227 }];
17228 let session = create_saved_session(&messages, "test-model", &workspace, 0, None);
17229 let session_id = session.metadata.id.clone();
17230 manager.save_checkpoint(&session).expect("save checkpoint");
17231
17232 let recovered = recover_interrupted_checkpoint_for_resume(&workspace);
17233
17234 assert_eq!(recovered.as_deref(), Some(session_id.as_str()));
17235 assert!(
17236 manager
17237 .load_session_checkpoint(&session_id)
17238 .expect("load checkpoint")
17239 .is_none(),
17240 "--continue should consume the per-session checkpoint"
17241 );
17242 assert!(manager.load_session(&session_id).is_ok());
17243 });
17244 }
17245
17246 fn write_legacy_checkpoint(manager: &SessionManager, session: &session_manager::SavedSession) {
17249 let checkpoints = manager.sessions_dir().join("checkpoints");
17250 std::fs::create_dir_all(&checkpoints).expect("create checkpoints dir");
17251 let content = serde_json::to_string_pretty(session).expect("serialize checkpoint");
17252 std::fs::write(checkpoints.join("latest.json"), content).expect("write legacy checkpoint");
17253 }
17254
17255 #[test]
17256 fn continue_recovers_legacy_checkpoint_and_migrates_it() {
17257 let _guard = crate::test_support::lock_test_env();
17258 let tmp = TempDir::new().unwrap();
17259 let workspace = tmp.path().join("workspace");
17260 std::fs::create_dir_all(&workspace).unwrap();
17261
17262 with_home(tmp.path(), || {
17263 let manager = SessionManager::default_location().expect("manager");
17264 let messages = vec![Message {
17265 role: "user".to_string(),
17266 content: vec![ContentBlock::Text {
17267 text: "legacy continue".to_string(),
17268 cache_control: None,
17269 }],
17270 }];
17271 let session = create_saved_session(&messages, "test-model", &workspace, 0, None);
17272 let session_id = session.metadata.id.clone();
17273 write_legacy_checkpoint(&manager, &session);
17274
17275 let recovered = recover_interrupted_checkpoint_for_resume(&workspace);
17276
17277 assert_eq!(recovered.as_deref(), Some(session_id.as_str()));
17278 assert!(
17279 manager.load_session(&session_id).is_ok(),
17280 "recovered legacy checkpoint must be loadable as a session"
17281 );
17282 assert!(
17283 manager
17284 .load_session_checkpoint(&session_id)
17285 .expect("load per-session checkpoint")
17286 .is_some(),
17287 "legacy recovery must migrate to a per-session checkpoint file"
17288 );
17289 assert!(
17290 manager
17291 .load_legacy_checkpoint()
17292 .expect("load legacy checkpoint")
17293 .is_some(),
17294 "legacy latest.json stays in place for one more release"
17295 );
17296 });
17297 }
17298
17299 #[test]
17300 fn continue_refuses_checkpoint_from_other_workspace() {
17301 let _guard = crate::test_support::lock_test_env();
17302 let tmp = TempDir::new().unwrap();
17303 let launch_workspace = tmp.path().join("launch-workspace");
17304 let other_workspace = tmp.path().join("other-workspace");
17305 std::fs::create_dir_all(&launch_workspace).unwrap();
17306 std::fs::create_dir_all(&other_workspace).unwrap();
17307
17308 with_home(tmp.path(), || {
17309 let manager = SessionManager::default_location().expect("manager");
17310 let messages = vec![Message {
17311 role: "user".to_string(),
17312 content: vec![ContentBlock::Text {
17313 text: "belongs elsewhere".to_string(),
17314 cache_control: None,
17315 }],
17316 }];
17317 let session = create_saved_session(&messages, "test-model", &other_workspace, 0, None);
17318 let session_id = session.metadata.id.clone();
17319 manager.save_checkpoint(&session).expect("save checkpoint");
17320
17321 let recovered = recover_interrupted_checkpoint_for_resume(&launch_workspace);
17322
17323 assert_eq!(recovered, None, "workspace mismatch must refuse recovery");
17324 assert!(
17325 manager
17326 .load_session_checkpoint(&session_id)
17327 .expect("load checkpoint")
17328 .is_some(),
17329 "another workspace's checkpoint file must be left untouched"
17330 );
17331 });
17332 }
17333
17334 #[test]
17335 fn continue_twice_does_not_clobber_newer_session_with_stale_legacy_checkpoint() {
17336 let _guard = crate::test_support::lock_test_env();
17337 let tmp = TempDir::new().unwrap();
17338 let workspace = tmp.path().join("workspace");
17339 std::fs::create_dir_all(&workspace).unwrap();
17340
17341 with_home(tmp.path(), || {
17342 let manager = SessionManager::default_location().expect("manager");
17343 let stale = create_saved_session(
17344 &[Message {
17345 role: "user".to_string(),
17346 content: vec![ContentBlock::Text {
17347 text: "crash-time state".to_string(),
17348 cache_control: None,
17349 }],
17350 }],
17351 "test-model",
17352 &workspace,
17353 0,
17354 None,
17355 );
17356 let session_id = stale.metadata.id.clone();
17357 write_legacy_checkpoint(&manager, &stale);
17358
17359 let mut advanced = stale.clone();
17362 advanced.messages.push(Message {
17363 role: "assistant".to_string(),
17364 content: vec![ContentBlock::Text {
17365 text: "post-recovery progress".to_string(),
17366 cache_control: None,
17367 }],
17368 });
17369 advanced.metadata.message_count = advanced.messages.len();
17370 advanced.metadata.updated_at = stale.metadata.updated_at + chrono::Duration::hours(1);
17371 manager.save_session(&advanced).expect("save newer session");
17372
17373 let recovered = recover_interrupted_checkpoint_for_resume(&workspace);
17374
17375 assert_eq!(recovered.as_deref(), Some(session_id.as_str()));
17376 let persisted = manager.load_session(&session_id).expect("load session");
17377 assert_eq!(
17378 persisted.messages.len(),
17379 advanced.messages.len(),
17380 "stale checkpoint content must not overwrite the newer session"
17381 );
17382 });
17383 }
17384
17385 #[test]
17386 fn dotenv_status_points_to_example_when_present() {
17387 let tmp = TempDir::new().unwrap();
17388 std::fs::write(tmp.path().join(".env.example"), "DEEPSEEK_API_KEY=\n").unwrap();
17389
17390 assert_eq!(
17391 dotenv_status_line(tmp.path()),
17392 ".env not present in workspace (run `cp .env.example .env` and edit)"
17393 );
17394
17395 std::fs::write(tmp.path().join(".env"), "DEEPSEEK_API_KEY=test\n").unwrap();
17396 assert!(dotenv_status_line(tmp.path()).contains(".env present at"));
17397 }
17398
17399 #[test]
17400 fn env_example_is_trackable_and_every_key_is_wired() {
17401 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
17402 let env_example = std::fs::read_to_string(root.join(".env.example")).unwrap();
17403 let gitignore = std::fs::read_to_string(root.join(".gitignore")).unwrap();
17404
17405 assert!(gitignore.contains("!.env.example"));
17406
17407 let keys = documented_env_keys(&env_example);
17408 for required in [
17409 "DEEPSEEK_API_KEY",
17410 "NVIDIA_API_KEY",
17411 "NVIDIA_NIM_API_KEY",
17412 "ATLASCLOUD_API_KEY",
17413 ] {
17414 assert!(
17415 keys.contains(required),
17416 ".env.example is missing {required}"
17417 );
17418 }
17419
17420 for key in &keys {
17421 assert!(
17422 is_workspace_dotenv_credential_key(key),
17423 ".env.example documents non-credential control setting {key}"
17424 );
17425 }
17426
17427 let sources = [
17428 include_str!("config.rs"),
17429 include_str!("logging.rs"),
17430 include_str!("../../config/src/lib.rs"),
17431 include_str!("../../config/src/provider.rs"),
17432 include_str!("../../cli/src/main.rs"),
17433 ]
17434 .join("\n");
17435
17436 for key in keys {
17437 assert!(
17438 sources.contains(&key),
17439 ".env.example documents {key}, but no source file references it"
17440 );
17441 }
17442 }
17443
17444 fn documented_env_keys(content: &str) -> BTreeSet<String> {
17445 content
17446 .lines()
17447 .filter_map(|line| {
17448 let trimmed = line.trim();
17449 let uncommented = trimmed
17450 .strip_prefix('#')
17451 .map(str::trim_start)
17452 .unwrap_or(trimmed);
17453 let (key, _) = uncommented.split_once('=')?;
17454 let key = key.trim();
17455 let is_env_key = key
17456 .chars()
17457 .all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_')
17458 && key.chars().any(|ch| ch == '_');
17459 is_env_key.then(|| key.to_string())
17460 })
17461 .collect()
17462 }
17463
17464 #[test]
17465 fn custom_provider_env_source_precedes_saved_secret_store() {
17466 let _lock = crate::test_support::lock_test_env();
17467 let temp = TempDir::new().expect("temp home");
17468 let codewhale_home = temp.path().join("codewhale-home");
17469 std::fs::create_dir_all(&codewhale_home).expect("create codewhale home");
17470 let _home =
17471 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", codewhale_home.as_os_str());
17472 let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
17473 let _declared_env =
17474 crate::test_support::EnvVarGuard::set("QA_CUSTOM_API_KEY", "declared-env-key");
17475 let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
17476 let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
17477 codewhale_secrets::Secrets::auto_detect()
17478 .set("custom", "saved-custom-secret")
17479 .expect("save secret");
17480
17481 let mut custom = std::collections::HashMap::new();
17482 custom.insert(
17483 "qa-gateway".to_string(),
17484 crate::config::ProviderConfig {
17485 kind: Some("openai-compatible".to_string()),
17486 base_url: Some("https://gateway.example.test/v1".to_string()),
17487 model: Some("qa-model".to_string()),
17488 api_key_env: Some("QA_CUSTOM_API_KEY".to_string()),
17489 ..Default::default()
17490 },
17491 );
17492 let config = Config {
17493 provider: Some("qa-gateway".to_string()),
17494 providers: Some(crate::config::ProvidersConfig {
17495 custom,
17496 ..Default::default()
17497 }),
17498 ..Config::default()
17499 };
17500
17501 assert_eq!(resolve_api_key_source(&config), ApiKeySource::EnvDeclared);
17502 assert_eq!(
17503 config.deepseek_api_key().expect("custom key"),
17504 "declared-env-key"
17505 );
17506 }
17507
17508 #[test]
17509 fn named_custom_provider_does_not_report_generic_secret_store() {
17510 let _lock = crate::test_support::lock_test_env();
17511 let temp = TempDir::new().expect("temp home");
17512 let codewhale_home = temp.path().join("codewhale-home");
17513 std::fs::create_dir_all(&codewhale_home).expect("create codewhale home");
17514 let _home =
17515 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", codewhale_home.as_os_str());
17516 let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
17517 let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
17518 let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
17519 codewhale_secrets::Secrets::auto_detect()
17520 .set("custom", "unrelated-custom-secret")
17521 .expect("save secret");
17522
17523 let mut custom = std::collections::HashMap::new();
17524 custom.insert(
17525 "qa-gateway".to_string(),
17526 crate::config::ProviderConfig {
17527 kind: Some("openai-compatible".to_string()),
17528 base_url: Some("https://gateway.example.test/v1".to_string()),
17529 model: Some("qa-model".to_string()),
17530 auth_mode: Some("api_key".to_string()),
17531 ..Default::default()
17532 },
17533 );
17534 let config = Config {
17535 provider: Some("qa-gateway".to_string()),
17536 providers: Some(crate::config::ProvidersConfig {
17537 custom,
17538 ..Default::default()
17539 }),
17540 ..Config::default()
17541 };
17542
17543 assert_eq!(resolve_api_key_source(&config), ApiKeySource::Unknown);
17544 assert!(config.deepseek_api_key().is_err());
17545 }
17546
17547 #[test]
17548 fn custom_built_in_endpoint_does_not_report_ambient_provider_key() {
17549 let _lock = crate::test_support::lock_test_env();
17550 let _openrouter =
17551 crate::test_support::EnvVarGuard::set("OPENROUTER_API_KEY", "ambient-key");
17552 let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
17553 let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
17554 let mut providers = crate::config::ProvidersConfig::default();
17555 providers.openrouter.base_url = Some("https://gateway.example.test/v1".to_string());
17556 let config = Config {
17557 provider: Some("openrouter".to_string()),
17558 providers: Some(providers),
17559 ..Config::default()
17560 };
17561
17562 assert_eq!(resolve_api_key_source(&config), ApiKeySource::Unknown);
17563 assert!(config.deepseek_api_key().is_err());
17564 }
17565
17566 #[test]
17567 fn auth_mode_none_reports_distinct_no_auth_source_and_scheme() {
17568 let _lock = crate::test_support::lock_test_env();
17569 let _openrouter =
17570 crate::test_support::EnvVarGuard::set("OPENROUTER_API_KEY", "ambient-key");
17571 let mut providers = crate::config::ProvidersConfig::default();
17572 providers.openrouter.auth_mode = Some("none".to_string());
17573 providers.openrouter.api_key = Some("configured-key".to_string());
17574 let config = Config {
17575 provider: Some("openrouter".to_string()),
17576 providers: Some(providers),
17577 ..Config::default()
17578 };
17579
17580 assert_eq!(resolve_api_key_source(&config), ApiKeySource::NoAuth);
17581 assert_eq!(doctor_api_key_source_label(ApiKeySource::NoAuth), "none");
17582 assert_eq!(doctor_auth_scheme(&config), "none");
17583 assert_eq!(config.deepseek_api_key().expect("no-auth route"), "");
17584 }
17585
17586 #[test]
17587 fn resolve_api_key_source_prefers_config_over_env() {
17588 let _guard = crate::test_support::lock_test_env();
17589 let prev = std::env::var("DEEPSEEK_API_KEY").ok();
17590 let prev_source = std::env::var("DEEPSEEK_API_KEY_SOURCE").ok();
17591 unsafe {
17592 std::env::set_var("DEEPSEEK_API_KEY", "stale-env-key");
17593 std::env::remove_var("DEEPSEEK_API_KEY_SOURCE");
17594 }
17595 let cfg = Config {
17596 api_key: Some("fresh-config-key".to_string()),
17597 ..Config::default()
17598 };
17599 let source = resolve_api_key_source(&cfg);
17600 match prev {
17601 Some(value) => unsafe { std::env::set_var("DEEPSEEK_API_KEY", value) },
17602 None => unsafe { std::env::remove_var("DEEPSEEK_API_KEY") },
17603 }
17604 match prev_source {
17605 Some(value) => unsafe { std::env::set_var("DEEPSEEK_API_KEY_SOURCE", value) },
17606 None => unsafe { std::env::remove_var("DEEPSEEK_API_KEY_SOURCE") },
17607 }
17608 assert_eq!(source, ApiKeySource::ConfigDeclared);
17609 }
17610
17611 #[test]
17612 fn resolve_api_key_source_reports_active_provider_env_from_metadata() {
17613 let _guard = crate::test_support::lock_test_env();
17614 let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
17615 let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
17616 let _anthropic_key =
17617 crate::test_support::EnvVarGuard::set("ANTHROPIC_API_KEY", "test-anthropic-key");
17618 let cfg = Config {
17619 provider: Some("anthropic".to_string()),
17620 ..Config::default()
17621 };
17622
17623 let source = resolve_api_key_source(&cfg);
17624
17625 assert_eq!(source, ApiKeySource::SecretStoreUnprobed);
17626 }
17627
17628 #[test]
17629 fn resolve_api_key_source_ignores_unresolved_provider_command_metadata() {
17630 let _guard = crate::test_support::lock_test_env();
17631 let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
17632 let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
17633 let _openai_key = crate::test_support::EnvVarGuard::remove("OPENAI_API_KEY");
17634 let mut providers = crate::config::ProvidersConfig::default();
17635 providers.openai.auth = Some(codewhale_config::ProviderAuthSourceToml {
17636 source: codewhale_config::AuthSourceKind::Command,
17637 command: vec!["secret-tool".to_string(), "lookup".to_string()],
17638 timeout_ms: Some(2000),
17639 secret_id: None,
17640 });
17641 let cfg = Config {
17642 provider: Some("openai".to_string()),
17643 providers: Some(providers),
17644 ..Config::default()
17645 };
17646
17647 let source = resolve_api_key_source(&cfg);
17648
17649 assert_eq!(source, ApiKeySource::ExternalAuthDeclared);
17650 assert!(cfg.deepseek_api_key().is_err());
17651 }
17652
17653 #[test]
17654 fn resolve_api_key_source_ignores_unresolved_provider_secret_metadata() {
17655 let _guard = crate::test_support::lock_test_env();
17656 let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
17657 let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
17658 let _openai_key = crate::test_support::EnvVarGuard::remove("OPENAI_API_KEY");
17659 let mut providers = crate::config::ProvidersConfig::default();
17660 providers.openai.auth = Some(codewhale_config::ProviderAuthSourceToml {
17661 source: codewhale_config::AuthSourceKind::Secret,
17662 command: Vec::new(),
17663 timeout_ms: None,
17664 secret_id: Some("codewhale/openai".to_string()),
17665 });
17666 let cfg = Config {
17667 provider: Some("openai".to_string()),
17668 providers: Some(providers),
17669 ..Config::default()
17670 };
17671
17672 let source = resolve_api_key_source(&cfg);
17673
17674 assert_eq!(source, ApiKeySource::ExternalAuthDeclared);
17675 assert!(cfg.deepseek_api_key().is_err());
17676 }
17677
17678 #[test]
17679 fn resolve_api_key_source_ignores_root_deepseek_key_for_other_provider() {
17680 let _guard = crate::test_support::lock_test_env();
17681 let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
17682 let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
17683 let _openrouter_key = crate::test_support::EnvVarGuard::remove("OPENROUTER_API_KEY");
17684 let cfg = Config {
17685 provider: Some("openrouter".to_string()),
17686 api_key: Some("legacy-deepseek-root-key".to_string()),
17687 ..Config::default()
17688 };
17689
17690 let source = resolve_api_key_source(&cfg);
17691
17692 assert_eq!(source, ApiKeySource::SecretStoreUnprobed);
17693 }
17694
17695 #[test]
17696 fn provider_status_helpers_use_provider_metadata() {
17697 assert_eq!(
17698 provider_config_table_key(crate::config::ApiProvider::Anthropic),
17699 "anthropic"
17700 );
17701 assert_eq!(
17702 provider_config_table_key(crate::config::ApiProvider::SiliconflowCn),
17703 "siliconflow_cn"
17704 );
17705 }
17706
17707 #[test]
17708 fn skills_count_for_returns_zero_for_missing_dir() {
17709 let tmp = TempDir::new().unwrap();
17710 let dir = tmp.path().join("nope");
17711 assert_eq!(skills_count_for(&dir), 0);
17712 }
17713
17714 #[test]
17715 fn skills_count_for_counts_valid_skill_dirs() {
17716 let tmp = TempDir::new().unwrap();
17717 let dir = tmp.path().join("skills");
17718 let skill_dir = dir.join("getting-started");
17719 std::fs::create_dir_all(&skill_dir).unwrap();
17720 std::fs::write(
17721 skill_dir.join("SKILL.md"),
17722 "---\nname: getting-started\ndescription: hi\n---\nbody",
17723 )
17724 .unwrap();
17725 assert_eq!(skills_count_for(&dir), 1);
17726 }
17727}
17728
17729#[cfg(test)]
17730#[path = "tests/pr_prompt.rs"]
17731mod pr_prompt_tests;
17732
17733#[cfg(test)]
17734#[path = "tests/telemetry_surface.rs"]
17735mod telemetry_surface_tests;
17736
17737#[cfg(test)]
17738#[path = "tests/telemetry_counters.rs"]
17739mod telemetry_counter_tests;