1#![allow(clippy::uninlined_format_args)]
4
5use std::collections::{BTreeSet, HashMap};
6use std::io::{self, IsTerminal, Read, Write};
7use std::path::{Path, PathBuf};
8use std::process::{Command, Stdio};
9use std::sync::Arc;
10use std::time::{Duration, Instant};
11
12use anyhow::{Context, Result, anyhow, bail};
13use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};
14use clap_complete::{Shell, generate};
15use tempfile::NamedTempFile;
16use wait_timeout::ChildExt;
17
18use crate::dependencies::ExternalTool;
19
20use rust_i18n::i18n;
21i18n!("locales", fallback = ["en"]);
22
23mod acp_server;
24mod agy_credentials;
25mod artifacts;
26mod audit;
27mod auto_reasoning;
28mod automation_manager;
29mod child_env;
30mod client;
31mod codex_model_cache;
32mod command_safety;
33mod commands;
34mod compaction;
35mod composer_history;
36mod composer_stash;
37mod config;
38mod config_persistence;
39mod config_ui;
40mod context_budget;
41mod context_report;
42mod continual_harness;
43mod core;
44mod cost_status;
45mod deepseek_theme;
46mod dependencies;
47mod doctor;
48mod dsh_credentials;
49mod elapsed;
50mod error_taxonomy;
51mod eval;
52mod execpolicy;
53mod external_credentials;
54mod fast_hash;
55mod features;
56mod fleet;
57mod goal_loop;
58mod hashing;
59mod hooks;
60mod image_attach;
61mod integrations;
62mod lane_control;
63mod llm_client;
64mod llm_response_cache;
65mod localization;
66mod logging;
67mod lsp;
68mod mcp;
69mod mcp_server;
70mod model_catalog;
71mod model_context;
72mod model_inventory;
73mod model_profile;
74mod model_registry;
75mod model_routing;
76mod models;
77mod models_dev_live;
78mod native_memory;
79mod network_policy;
80mod oauth;
81mod palette;
82mod plugins;
83mod prefix_cache;
84mod pricing;
85mod project_context;
86mod project_context_cache;
87mod prompt_zones;
88mod prompts;
89mod provider_lake;
90mod provider_readiness;
91mod purge;
92mod regex_cache;
93mod remote_control;
94mod remote_setup;
95pub mod repl;
96mod repo_law;
97mod request_manifest;
98mod request_tuning;
99mod resource_telemetry;
100mod retry_status;
101pub mod rlm;
102mod route_billing;
103mod route_budget;
104mod route_receipt;
105mod route_runtime;
106mod runtime_api;
107mod runtime_handoff;
108mod runtime_log;
109mod runtime_policy;
110mod runtime_threads;
111mod safe_label;
112mod sandbox;
113mod scorecard;
114#[allow(dead_code)]
115mod session_diagnostics;
116#[cfg(test)]
120#[path = "main/tests.rs"]
121mod doctor_loader_tests;
122#[cfg(test)]
123mod session_control_acceptance;
124#[allow(dead_code)]
125mod session_manager;
126mod session_peek;
127mod session_projection;
128mod session_resume;
129pub mod session_tree;
130mod settings;
131mod shell_dispatcher;
132mod skill_state;
133mod skills;
134mod snapshot;
135mod startup_trace;
136mod task_manager;
137mod telemetry_notice;
138#[cfg(test)]
139mod test_support;
140use codewhale_release::tls;
143mod todo_snapshot;
144mod tool_history_repair;
145mod tool_inspection;
146mod tool_output_receipts;
147mod tools;
148mod tui;
149mod turn_route_plan;
150mod utils;
151mod vision;
152mod work_graph;
153mod worker_profile;
154mod working_set;
155mod workspace_discovery;
156mod workspace_trust;
157mod xai_oauth;
158
159use crate::config::{Config, DEFAULT_TEXT_MODEL, MAX_SUBAGENTS, effective_home_dir};
160use crate::eval::{EvalHarness, EvalHarnessConfig, ScenarioStepKind};
161use crate::features::{Feature, render_feature_table};
162use crate::llm_client::LlmClient;
163use crate::mcp::{
164 McpCommandAvailability, McpConfig, McpPool, McpServerConfig, McpServerOAuthConfig,
165 is_relative_stdio_path_arg,
166};
167use crate::models::{ContentBlock, Message, MessageRequest, SystemPrompt};
168use crate::session_manager::{SessionManager, create_saved_session, truncate_id};
169use crate::tui::history::{summarize_tool_args, summarize_tool_output};
170
171#[cfg(windows)]
172fn configure_windows_console_utf8() {
173 use windows::Win32::System::Console::{SetConsoleCP, SetConsoleOutputCP};
174
175 const CP_UTF8: u32 = 65001;
176 unsafe {
177 let _ = SetConsoleCP(CP_UTF8);
178 let _ = SetConsoleOutputCP(CP_UTF8);
179 }
180}
181
182#[cfg(not(windows))]
183fn configure_windows_console_utf8() {}
184
185fn install_rustls_crypto_provider() {
186 crate::tls::ensure_rustls_crypto_provider();
187}
188
189#[derive(Parser, Debug)]
190#[command(
191 name = "codewhale-tui",
192 bin_name = "codewhale-tui",
193 author,
194 version = env!("DEEPSEEK_BUILD_VERSION"),
195 about = "Codewhale terminal coding agent",
196 long_about = "Terminal-native TUI and CLI for open-source and open-weight coding models.\n\nRun 'codewhale' to start.\n\nProvider routes include DeepSeek, Arcee, Hugging Face, OpenRouter, Xiaomi MiMo, local vLLM/SGLang/Ollama, and more."
197)]
198struct Cli {
199 #[command(subcommand)]
201 command: Option<Commands>,
202
203 #[command(flatten)]
204 feature_toggles: FeatureToggles,
205
206 #[arg(short, long, value_name = "PROMPT", num_args = 1..)]
208 prompt: Vec<String>,
209
210 #[arg(long, hide = true)]
212 yolo: bool,
213
214 #[arg(long)]
216 max_subagents: Option<usize>,
217
218 #[arg(long)]
220 config: Option<PathBuf>,
221
222 #[arg(short, long)]
224 verbose: bool,
225
226 #[arg(long)]
228 profile: Option<String>,
229
230 #[arg(short, long)]
232 workspace: Option<PathBuf>,
233
234 #[arg(short, long)]
236 resume: Option<String>,
237
238 #[arg(short = 'c', long = "continue")]
240 continue_session: bool,
241
242 #[arg(long = "mouse-capture", conflicts_with = "no_mouse_capture")]
246 mouse_capture: bool,
247
248 #[arg(long = "no-mouse-capture", conflicts_with = "mouse_capture")]
250 no_mouse_capture: bool,
251
252 #[arg(long)]
254 skip_onboarding: bool,
255
256 #[arg(long, hide = true)]
258 remote_control: bool,
259
260 #[arg(long = "fresh")]
262 fresh: bool,
263
264 #[arg(long = "no-project-config")]
266 no_project_config: bool,
267}
268
269#[derive(Subcommand, Debug, Clone)]
270#[allow(clippy::large_enum_variant)]
271enum Commands {
272 Doctor(DoctorArgs),
274 SessionDiagnostics(SessionDiagnosticsArgs),
276 Setup(SetupArgs),
278 RemoteSetup(remote_setup::RemoteSetupArgs),
280 Completions {
282 #[arg(value_enum)]
284 shell: Shell,
285 },
286 Sessions {
288 #[arg(short, long, default_value = "20")]
290 limit: usize,
291 #[arg(short, long)]
293 search: Option<String>,
294 },
295 Init,
297 Login {
299 #[arg(long)]
301 api_key: Option<String>,
302 },
303 Logout,
305 Auth(TuiAuthArgs),
307 Models(ModelsArgs),
309 #[command(visible_alias = "tts")]
311 Speech(SpeechArgs),
312 Exec(ExecArgs),
314 Fleet(FleetArgs),
316 #[command(name = "workflow-tool", hide = true)]
318 WorkflowTool(WorkflowToolArgs),
319 Review(ReviewArgs),
321 Pr {
323 #[arg(value_name = "NUMBER")]
325 number: u32,
326 #[arg(short = 'R', long)]
329 repo: Option<String>,
330 #[arg(long, default_value_t = false)]
334 checkout: bool,
335 },
336 Apply(ApplyArgs),
338 Eval(EvalArgs),
340 Scorecard(ScorecardArgs),
342 Mcp {
344 #[command(subcommand)]
345 command: McpCommand,
346 },
347 Features(FeaturesCli),
349 Integrations {
351 #[command(subcommand)]
352 command: IntegrationsCommand,
353 },
354 Sandbox(SandboxArgs),
356 Serve(ServeArgs),
358 Resume {
360 #[arg(value_name = "SESSION_ID")]
362 session_id: Option<String>,
363 #[arg(long = "last", default_value_t = false, conflicts_with = "session_id")]
365 last: bool,
366 },
367 Fork {
369 #[arg(value_name = "SESSION_ID")]
371 session_id: Option<String>,
372 #[arg(long = "last", default_value_t = false, conflicts_with = "session_id")]
374 last: bool,
375 },
376}
377
378#[derive(Args, Debug, Clone)]
379#[command(after_help = "\
380Examples:
381 codewhale exec \"explain this function\"
382 codewhale exec --auto \"list crates/ with ls\"
383 codewhale exec --auto --output-format stream-json \"fix the failing test\"
384
385Plain `codewhale exec` is a one-shot model response. Use `--auto` for
386non-interactive agent-with-tools execution. `--auto` does not change the
387sandbox posture or elevate a denied tool. Use `--sandbox danger-full-access`
388or `--allow-sandbox-elevation` to explicitly authorize sandbox elevation.
389")]
390struct ExecArgs {
391 #[arg(long)]
393 model: Option<String>,
394 #[arg(long)]
400 provider: Option<String>,
401 #[arg(long = "reasoning-effort", value_name = "EFFORT")]
404 reasoning_effort: Option<String>,
405 #[arg(long, default_value_t = false)]
408 auto: bool,
409 #[arg(long, value_name = "POLICY")]
411 sandbox: Option<String>,
412 #[arg(long, default_value_t = false)]
414 allow_sandbox_elevation: bool,
415 #[arg(long, default_value_t = false, conflicts_with = "output_format")]
417 json: bool,
418 #[arg(long, value_name = "SESSION_ID", conflicts_with_all = ["session_id", "continue_session"])]
420 resume: Option<String>,
421 #[arg(long = "session-id", value_name = "SESSION_ID", conflicts_with_all = ["resume", "continue_session"])]
423 session_id: Option<String>,
424 #[arg(long = "continue", default_value_t = false, conflicts_with_all = ["resume", "session_id"])]
426 continue_session: bool,
427 #[arg(long, value_enum, default_value_t = ExecOutputFormat::Text)]
429 output_format: ExecOutputFormat,
430 #[arg(long, value_delimiter = ',')]
433 allowed_tools: Option<Vec<String>>,
434 #[arg(long, value_delimiter = ',')]
436 disallowed_tools: Option<Vec<String>>,
437 #[arg(long, value_parser = clap::value_parser!(u32).range(1..))]
439 max_turns: Option<u32>,
440 #[arg(long)]
442 append_system_prompt: Option<String>,
443 #[arg(long, value_name = "JSON", hide = true)]
445 tool_authority_json: Option<String>,
446 #[arg(
448 value_name = "PROMPT",
449 required = true,
450 trailing_var_arg = true,
451 allow_hyphen_values = true
452 )]
453 prompt: Vec<String>,
454}
455
456#[derive(Args, Debug, Clone)]
457struct WorkflowToolArgs {
458 #[arg(long, value_name = "SOURCE")]
460 approval_source: String,
461 #[arg(long, value_name = "JSON")]
463 input_json: String,
464}
465
466#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
467enum ExecOutputFormat {
468 Text,
469 #[value(name = "stream-json")]
470 StreamJson,
471}
472
473#[derive(Args, Debug, Clone)]
474struct TuiAuthArgs {
475 #[command(subcommand)]
476 command: TuiAuthCommand,
477}
478
479#[derive(Subcommand, Debug, Clone)]
480enum TuiAuthCommand {
481 #[command(name = "xai-device")]
483 XaiDevice,
484}
485
486const CODEWHALE_TOOL_SURFACE_ENV: &str = "CODEWHALE_TOOL_SURFACE";
487const SHELL_ONLY_EXEC_TOOLS: &[&str] = &["bash"];
488
489#[derive(Debug, Clone, Copy, PartialEq, Eq)]
490enum ExecToolSurface {
491 ShellOnly,
492}
493
494fn exec_tool_surface_from_env() -> Option<ExecToolSurface> {
495 std::env::var(CODEWHALE_TOOL_SURFACE_ENV)
496 .ok()
497 .and_then(|value| {
498 if should_warn_unknown_exec_tool_surface(&value) {
499 eprintln!(
500 "warning: unrecognized {CODEWHALE_TOOL_SURFACE_ENV}; leaving exec tool surface unchanged. Use `shell-only`, `full`, or `native-tools`."
501 );
502 }
503 parse_exec_tool_surface(&value)
504 })
505}
506
507fn parse_exec_tool_surface(value: &str) -> Option<ExecToolSurface> {
508 match value.trim().to_ascii_lowercase().as_str() {
509 "shell-only" | "shell_only" | "shell" => Some(ExecToolSurface::ShellOnly),
510 "full" | "native-tools" | "native_tools" | "" => None,
511 _ => None,
512 }
513}
514
515fn should_warn_unknown_exec_tool_surface(value: &str) -> bool {
516 let normalized = value.trim().to_ascii_lowercase();
517 !matches!(
518 normalized.as_str(),
519 "" | "shell-only" | "shell_only" | "shell" | "full" | "native-tools" | "native_tools"
520 )
521}
522
523fn normalize_exec_tool_names(tools: &[String]) -> Vec<String> {
524 tools
525 .iter()
526 .map(|name| name.to_ascii_lowercase().trim().to_string())
527 .collect()
528}
529
530fn shell_only_exec_allowed_tools() -> Vec<String> {
531 SHELL_ONLY_EXEC_TOOLS
532 .iter()
533 .map(|name| (*name).to_string())
534 .collect()
535}
536
537fn resolve_exec_allowed_tools(
538 cli_allowed_tools: Option<&[String]>,
539 env_tool_surface: Option<ExecToolSurface>,
540) -> Option<Vec<String>> {
541 if let Some(tools) = cli_allowed_tools {
542 return Some(normalize_exec_tool_names(tools));
543 }
544
545 env_tool_surface.map(|ExecToolSurface::ShellOnly| shell_only_exec_allowed_tools())
546}
547
548#[derive(Args, Debug, Clone)]
549struct FleetArgs {
550 #[command(subcommand)]
551 command: FleetCommand,
552}
553
554#[derive(Subcommand, Debug, Clone)]
555enum FleetCommand {
556 Init,
558 Run(FleetRunArgs),
560 List,
562 Status,
564 Inspect {
566 worker_id: String,
568 },
569 Logs {
571 worker_id: String,
573 },
574 Artifacts {
576 worker_id: String,
578 },
579 Interrupt {
581 worker_id: String,
583 },
584 Restart {
586 worker_id: String,
588 },
589 Resume {
591 run_id: String,
593 #[arg(long, default_value_t = 300)]
595 stale_after_seconds: u64,
596 },
597 Stop {
599 #[arg(long, required = true)]
601 all: bool,
602 },
603 AlertDryRun(FleetAlertDryRunArgs),
605}
606
607#[derive(Args, Debug, Clone)]
608struct FleetRunArgs {
609 #[arg(value_name = "TASK_SPEC")]
611 task_spec: PathBuf,
612 #[arg(long, default_value_t = 4)]
614 max_workers: usize,
615 #[arg(long, default_value_t = 300)]
617 stale_after_seconds: u64,
618 #[arg(long, hide = true, default_value_t = false)]
620 once: bool,
621}
622
623#[derive(Args, Debug, Clone)]
624struct FleetAlertDryRunArgs {
625 #[arg(long, value_enum)]
627 event: FleetAlertEventArg,
628 #[arg(long)]
630 run_id: String,
631 #[arg(long)]
633 worker_id: Option<String>,
634 #[arg(long)]
636 task_id: Option<String>,
637 #[arg(long, default_value = "manual fleet alert dry-run")]
639 reason: String,
640 #[arg(long)]
642 status: Option<String>,
643 #[arg(long, value_enum, default_value_t = FleetAlertAdapterArg::Slack)]
645 adapter: FleetAlertAdapterArg,
646 #[arg(long, default_value = "CODEWHALE_FLEET_SLACK_WEBHOOK")]
648 slack_webhook_env: String,
649 #[arg(long, default_value = "CODEWHALE_FLEET_WEBHOOK_URL")]
651 webhook_url_env: String,
652 #[arg(long)]
654 webhook_secret_env: Option<String>,
655 #[arg(long, default_value = "CODEWHALE_FLEET_PAGERDUTY_ROUTING_KEY")]
657 pagerduty_routing_key_env: String,
658 #[arg(long, default_value = "error")]
660 pagerduty_severity: String,
661}
662
663#[derive(ValueEnum, Debug, Clone, Copy)]
664enum FleetAlertEventArg {
665 Stale,
666 RestartExhausted,
667 NeedsHuman,
668 BudgetExceeded,
669 VerifierFailed,
670 RunCompleted,
671}
672
673#[derive(ValueEnum, Debug, Clone, Copy)]
674enum FleetAlertAdapterArg {
675 Slack,
676 Webhook,
677 PagerDuty,
678}
679
680fn spawn_signal_cleanup_task() {
697 let signals = TerminatingSignals::register();
698 tokio::spawn(async move {
699 let exit_code = signals.wait().await;
700 static CLEANED_UP: std::sync::atomic::AtomicBool =
704 std::sync::atomic::AtomicBool::new(false);
705 if !CLEANED_UP.swap(true, std::sync::atomic::Ordering::SeqCst) {
706 #[cfg(unix)]
707 crate::tools::shell::abort_pending_persistent_process_groups_for_exit();
708 crate::tui::ui::emergency_restore_terminal();
709 record_signal_session_end();
720 }
721 std::process::exit(exit_code);
722 });
723}
724
725static TELEMETRY_SESSION_START: std::sync::OnceLock<std::time::Instant> =
728 std::sync::OnceLock::new();
729
730fn telemetry_session_end() -> codewhale_telemetry::Event {
741 let counters = codewhale_telemetry::session_counters();
742 codewhale_telemetry::Event::SessionEnd {
743 duration_bucket: codewhale_telemetry::DurationBucket::from_secs(
744 TELEMETRY_SESSION_START
745 .get()
746 .map_or(0, |start| start.elapsed().as_secs()),
747 ),
748 exit_class: codewhale_telemetry::exit_class(),
749 cold_start_bucket: crate::startup_trace::cold_start_ms()
750 .map(codewhale_telemetry::ColdStartBucket::from_millis),
751 providers: counters.providers(),
752 counters: counters.counters(),
753 errors: counters.errors(),
754 turn_wall: counters.turn_wall(),
755 }
756}
757
758fn record_signal_session_end() {
762 codewhale_telemetry::set_exit_class(codewhale_telemetry::ExitClass::Signal);
763 codewhale_telemetry::record_blocking(telemetry_session_end());
764}
765
766#[cfg(unix)]
771struct TerminatingSignals {
772 sigint: Option<tokio::signal::unix::Signal>,
773 sigterm: Option<tokio::signal::unix::Signal>,
774 sighup: Option<tokio::signal::unix::Signal>,
775}
776
777#[cfg(unix)]
778impl TerminatingSignals {
779 fn register() -> Self {
782 use tokio::signal::unix::{SignalKind, signal};
783 Self {
784 sigint: signal(SignalKind::interrupt()).ok(),
785 sigterm: signal(SignalKind::terminate()).ok(),
786 sighup: signal(SignalKind::hangup()).ok(),
787 }
788 }
789
790 async fn wait(mut self) -> i32 {
794 tokio::select! {
795 _ = async { match self.sigint.as_mut() { Some(s) => { s.recv().await; }, None => std::future::pending::<()>().await, } } => 130,
796 _ = async { match self.sigterm.as_mut() { Some(s) => { s.recv().await; }, None => std::future::pending::<()>().await, } } => 143,
797 _ = async { match self.sighup.as_mut() { Some(s) => { s.recv().await; }, None => std::future::pending::<()>().await, } } => 129,
798 }
799 }
800}
801
802#[cfg(not(unix))]
806struct TerminatingSignals {
807 ctrl_c: Option<tokio::signal::windows::CtrlC>,
808}
809
810#[cfg(not(unix))]
811impl TerminatingSignals {
812 fn register() -> Self {
813 Self {
814 ctrl_c: tokio::signal::windows::ctrl_c().ok(),
815 }
816 }
817
818 async fn wait(mut self) -> i32 {
819 match self.ctrl_c.as_mut() {
820 Some(s) => {
821 s.recv().await;
822 }
823 None => std::future::pending::<()>().await,
824 }
825 130
826 }
827}
828
829fn join_prompt_parts(parts: &[String]) -> String {
830 parts.join(" ")
831}
832
833fn resolve_exec_model(config: &Config, explicit_model: Option<&str>) -> String {
834 explicit_model
835 .map(str::trim)
836 .filter(|model| !model.is_empty())
837 .map(ToOwned::to_owned)
838 .or_else(exec_model_env_override)
839 .unwrap_or_else(|| config.default_model())
840}
841
842fn apply_exec_provider_override(config: &mut Config, provider_arg: &str) -> Result<()> {
843 let provider_arg = provider_arg.trim();
844 if provider_arg.is_empty() {
845 return Ok(());
846 }
847 if config
848 .providers
849 .as_ref()
850 .and_then(|providers| providers.custom_provider_config(provider_arg))
851 .is_some()
852 {
853 config.provider = Some(provider_arg.to_string());
854 return Ok(());
855 }
856 if let Some(provider) = crate::config::ApiProvider::parse(provider_arg) {
857 config.provider = Some(provider.as_str().to_string());
858 return Ok(());
859 }
860 bail!(
861 "Unrecognized --provider {provider_arg:?}. Known providers: {} \
862 or a configured [providers.<name>] custom provider",
863 crate::config::ApiProvider::names_hint()
864 );
865}
866
867fn exec_model_env_override() -> Option<String> {
868 let read = || {
869 ["CODEWHALE_MODEL", "DEEPSEEK_MODEL"]
870 .into_iter()
871 .find_map(|key| {
872 std::env::var(key)
873 .ok()
874 .map(|model| model.trim().to_string())
875 .filter(|model| !model.is_empty())
876 })
877 };
878 #[cfg(test)]
879 {
880 crate::test_support::with_test_env_lock(read)
881 }
882 #[cfg(not(test))]
883 {
884 read()
885 }
886}
887
888fn top_level_prompt_initial_input(parts: &[String]) -> Option<tui::InitialInput> {
889 (!parts.is_empty()).then(|| tui::InitialInput::Submit(join_prompt_parts(parts)))
890}
891
892fn resolve_exec_resume_session_id(args: &ExecArgs, workspace: &Path) -> Result<Option<String>> {
893 if let Some(id) = args.resume.as_ref().or(args.session_id.as_ref()) {
894 return Ok(Some(id.clone()));
895 }
896 if !args.continue_session {
897 return Ok(None);
898 }
899 latest_session_id_for_workspace(workspace)?.map_or_else(
900 || {
901 bail!(
902 "No saved sessions found for workspace {}. Use `codewhale sessions` to list sessions, or pass `codewhale exec --resume <SESSION_ID> ...`.",
903 workspace.display()
904 )
905 },
906 |id| Ok(Some(id)),
907 )
908}
909
910fn load_exec_resume_session(session_id: &str) -> Result<session_manager::SavedSession> {
911 let session_ref = exec_stream_session_ref(session_id);
912 SessionManager::default_location()
913 .context("could not open session manager for resume")?
914 .load_session_by_prefix(session_id)
915 .with_context(|| format!("could not load session {session_ref}"))
916}
917
918fn resolve_exec_resume_route(
926 config: &mut Config,
927 saved: &session_manager::SavedSession,
928 explicit_provider: bool,
929 explicit_model: Option<&str>,
930) -> Result<String> {
931 if !explicit_provider {
932 let saved_provider_identity = saved
933 .metadata
934 .model_provider_id
935 .as_deref()
936 .filter(|identity| !identity.trim().is_empty())
937 .unwrap_or(&saved.metadata.model_provider);
938 let identity = config
939 .resolve_persisted_provider_identity(
940 Some(&saved.metadata.model_provider),
941 saved.metadata.model_provider_id.as_deref(),
942 )
943 .map_err(anyhow::Error::msg)
944 .with_context(|| {
945 format!(
946 "saved session provider '{}' is unavailable; Codewhale will not fall back",
947 saved_provider_identity
948 )
949 })?;
950 config.scope_to_provider_identity(&identity);
951 }
952
953 if let Some(model) = explicit_model {
954 return Ok(resolve_exec_model(config, Some(model)));
955 }
956 if explicit_provider {
957 return Ok(resolve_exec_model(config, None));
958 }
959 Ok(saved.metadata.model.clone())
960}
961
962#[derive(Args, Debug, Clone, Default)]
963struct SetupArgs {
964 #[arg(long, default_value_t = false)]
966 mcp: bool,
967 #[arg(long, default_value_t = false)]
969 skills: bool,
970 #[arg(long, default_value_t = false)]
972 tools: bool,
973 #[arg(long, default_value_t = false)]
975 plugins: bool,
976 #[arg(long, default_value_t = false)]
978 all: bool,
979 #[arg(long, default_value_t = false)]
981 local: bool,
982 #[arg(long, default_value_t = false)]
984 force: bool,
985 #[arg(long, default_value_t = false, conflicts_with_all = ["mcp", "skills", "tools", "plugins", "all", "local", "clean"])]
987 status: bool,
988 #[arg(long, default_value_t = false, conflicts_with_all = ["mcp", "skills", "tools", "plugins", "all", "local", "status"])]
990 clean: bool,
991}
992
993#[derive(Args, Debug, Clone, Default)]
994struct DoctorArgs {
995 #[arg(long, default_value_t = false)]
997 json: bool,
998 #[arg(long, default_value_t = false, conflicts_with = "json")]
1000 context_json: bool,
1001 #[arg(
1003 long,
1004 default_value_t = false,
1005 conflicts_with_all = ["json", "context_json"]
1006 )]
1007 probe_local: bool,
1008 #[arg(
1010 long,
1011 default_value_t = false,
1012 conflicts_with_all = ["json", "context_json"]
1013 )]
1014 probe_api: bool,
1015 #[arg(
1017 long,
1018 default_value_t = false,
1019 conflicts_with_all = ["json", "context_json"]
1020 )]
1021 check_updates: bool,
1022 #[arg(
1024 long,
1025 default_value_t = false,
1026 conflicts_with_all = ["json", "context_json"]
1027 )]
1028 probe_mcp: bool,
1029}
1030
1031#[derive(Args, Debug, Clone)]
1032struct SessionDiagnosticsArgs {
1033 #[arg(value_name = "JSONL")]
1035 path: PathBuf,
1036 #[arg(long, default_value_t = false)]
1038 json: bool,
1039}
1040
1041#[derive(Args, Debug, Clone)]
1042struct ScorecardArgs {
1043 #[arg(long, value_name = "FILE")]
1051 input: PathBuf,
1052 #[arg(long, value_name = "FILE")]
1055 baseline: Option<PathBuf>,
1056 #[arg(long, default_value_t = 5.0)]
1058 threshold: f64,
1059 #[arg(long, default_value_t = false)]
1061 json: bool,
1062}
1063
1064#[derive(Args, Debug, Clone)]
1065struct EvalArgs {
1066 #[arg(long, value_name = "STEP")]
1068 fail_step: Option<String>,
1069 #[arg(long, default_value = "printf eval-harness")]
1071 shell_command: String,
1072 #[arg(long, default_value = "eval-harness")]
1074 shell_expect_token: String,
1075 #[arg(long, default_value_t = 240)]
1077 max_output_chars: usize,
1078 #[arg(long, default_value_t = false)]
1080 json: bool,
1081 #[arg(long, value_name = "DIR")]
1084 record: Option<PathBuf>,
1085}
1086
1087#[derive(Args, Debug, Clone, Default)]
1088struct ModelsArgs {
1089 #[arg(long, default_value_t = false)]
1091 json: bool,
1092}
1093
1094#[derive(Args, Debug, Clone)]
1095struct SpeechArgs {
1096 #[arg(value_name = "TEXT")]
1098 text: String,
1099
1100 #[arg(short, long, value_name = "FILE")]
1103 output: Option<PathBuf>,
1104
1105 #[arg(long = "output-dir", value_name = "DIR")]
1107 output_dir: Option<PathBuf>,
1108
1109 #[arg(long)]
1111 model: Option<String>,
1112
1113 #[arg(long)]
1115 voice: Option<String>,
1116
1117 #[arg(long)]
1119 instruction: Option<String>,
1120
1121 #[arg(long = "voice-prompt")]
1123 voice_prompt: Option<String>,
1124
1125 #[arg(long = "clone-voice", value_name = "FILE")]
1127 clone_voice: Option<PathBuf>,
1128
1129 #[arg(long, default_value = "wav")]
1131 format: String,
1132
1133 #[arg(long, default_value_t = false)]
1135 json: bool,
1136}
1137
1138#[derive(Args, Debug, Default, Clone)]
1139struct FeatureToggles {
1140 #[arg(long = "enable", value_name = "FEATURE", action = clap::ArgAction::Append, global = true)]
1142 enable: Vec<String>,
1143
1144 #[arg(long = "disable", value_name = "FEATURE", action = clap::ArgAction::Append, global = true)]
1146 disable: Vec<String>,
1147}
1148
1149impl FeatureToggles {
1150 fn apply(&self, config: &mut Config) -> Result<()> {
1151 for feature in &self.enable {
1152 config.set_feature(feature, true)?;
1153 }
1154 for feature in &self.disable {
1155 config.set_feature(feature, false)?;
1156 }
1157 Ok(())
1158 }
1159}
1160
1161#[derive(Args, Debug, Clone)]
1162struct ReviewArgs {
1163 #[arg(long, conflicts_with = "base")]
1165 staged: bool,
1166 #[arg(long)]
1168 base: Option<String>,
1169 #[arg(long)]
1171 path: Option<PathBuf>,
1172 #[arg(long)]
1174 model: Option<String>,
1175 #[arg(long, default_value_t = 200_000)]
1177 max_chars: usize,
1178 #[arg(long, default_value_t = false)]
1180 write_receipt: bool,
1181 #[arg(long, default_value_t = false)]
1183 check_receipt: bool,
1184 #[arg(long)]
1186 receipt_path: Option<PathBuf>,
1187 #[arg(long, default_value_t = false)]
1189 json: bool,
1190}
1191
1192#[derive(Args, Debug, Clone)]
1193struct ApplyArgs {
1194 #[arg(value_name = "PATCH_FILE")]
1196 patch_file: Option<PathBuf>,
1197}
1198
1199#[derive(Args, Debug, Clone)]
1200struct ServeArgs {
1201 #[arg(long)]
1203 mcp: bool,
1204 #[arg(long)]
1206 http: bool,
1207 #[arg(long)]
1209 mobile: bool,
1210 #[arg(long)]
1212 web: bool,
1213 #[arg(long, requires = "mobile")]
1215 qr: bool,
1216 #[arg(long)]
1218 acp: bool,
1219 #[arg(long)]
1221 host: Option<String>,
1222 #[arg(long, default_value_t = 7878)]
1224 port: u16,
1225 #[arg(long, default_value_t = 2)]
1227 workers: usize,
1228 #[arg(long = "cors-origin", value_name = "URL")]
1234 cors_origin: Vec<String>,
1235 #[arg(long = "auth-token", value_name = "TOKEN")]
1239 auth_token: Option<String>,
1240 #[arg(long = "insecure")]
1242 insecure_no_auth: bool,
1243}
1244
1245#[derive(Debug, Clone, PartialEq, Eq)]
1246struct ServeBindHost {
1247 host: String,
1248 mobile_rebound_to_lan: bool,
1249}
1250
1251fn resolve_serve_bind_host(mobile: bool, host: Option<String>) -> ServeBindHost {
1252 match (mobile, host) {
1253 (true, None) => ServeBindHost {
1254 host: "0.0.0.0".to_string(),
1255 mobile_rebound_to_lan: true,
1256 },
1257 (_, Some(host)) => ServeBindHost {
1258 host,
1259 mobile_rebound_to_lan: false,
1260 },
1261 (false, None) => ServeBindHost {
1262 host: "127.0.0.1".to_string(),
1263 mobile_rebound_to_lan: false,
1264 },
1265 }
1266}
1267
1268fn validate_serve_mode_selection(
1269 mcp: bool,
1270 http: bool,
1271 mobile: bool,
1272 web: bool,
1273 acp: bool,
1274) -> Result<bool> {
1275 if http && mobile {
1276 bail!("--http and --mobile are mutually exclusive; choose one");
1277 }
1278 if web && (http || mobile) {
1279 bail!("--web is mutually exclusive with --http and --mobile");
1280 }
1281 let http_selected = http || mobile || web;
1282 let selected_modes = [mcp, http_selected, acp]
1283 .into_iter()
1284 .filter(|selected| *selected)
1285 .count();
1286 if selected_modes != 1 {
1287 bail!("Choose exactly one server mode: --mcp, --http/--mobile/--web, or --acp");
1288 }
1289 Ok(http_selected)
1290}
1291
1292#[derive(Subcommand, Debug, Clone)]
1293enum McpCommand {
1294 List,
1296 Init {
1298 #[arg(long, default_value_t = false)]
1300 force: bool,
1301 },
1302 Connect {
1304 #[arg(value_name = "SERVER")]
1306 server: Option<String>,
1307 },
1308 Tools {
1310 #[arg(value_name = "SERVER")]
1312 server: Option<String>,
1313 },
1314 Add {
1316 name: String,
1318 #[arg(long, conflicts_with = "url")]
1320 command: Option<String>,
1321 #[arg(long, conflicts_with = "command")]
1323 url: Option<String>,
1324 #[arg(long, requires = "url")]
1326 transport: Option<String>,
1327 #[arg(long, requires = "url")]
1329 bearer_token_env_var: Option<String>,
1330 #[arg(long, requires = "url")]
1332 oauth_client_id: Option<String>,
1333 #[arg(long, requires = "url")]
1335 oauth_resource: Option<String>,
1336 #[arg(long = "scope", requires = "url", value_delimiter = ',')]
1338 scopes: Vec<String>,
1339 #[arg(long = "arg")]
1341 args: Vec<String>,
1342 },
1343 Login {
1345 name: String,
1347 #[arg(long = "scope", value_delimiter = ',')]
1349 scopes: Vec<String>,
1350 },
1351 Logout {
1353 name: String,
1355 },
1356 Remove {
1358 name: String,
1360 },
1361 Enable {
1363 name: String,
1365 },
1366 Disable {
1368 name: String,
1370 },
1371 Validate,
1373 #[command(
1378 name = "add-self",
1379 long_about = "Register this Codewhale binary as a local MCP stdio server.\n\nAdds a config entry to ~/.codewhale/mcp.json that launches `codewhale serve --mcp`\nvia the stdio transport. Other Codewhale sessions (or any MCP client) can then\ndiscover and call tools exposed by this server.\n\nUse `codewhale serve --http` instead if you need the HTTP/SSE runtime API."
1380 )]
1381 AddSelf {
1382 #[arg(long, default_value = "codewhale")]
1384 name: String,
1385 #[arg(long)]
1387 workspace: Option<String>,
1388 },
1389}
1390
1391#[derive(Subcommand, Debug, Clone)]
1392pub(crate) enum IntegrationsCommand {
1393 Dsh {
1395 #[command(subcommand)]
1396 command: DshIntegrationCommand,
1397 },
1398}
1399
1400#[derive(Subcommand, Debug, Clone)]
1401pub(crate) enum DshIntegrationCommand {
1402 Status {
1404 #[arg(long, default_value_t = false)]
1406 json: bool,
1407 },
1408 Plan {
1410 #[arg(long, default_value_t = false)]
1411 json: bool,
1412 #[arg(long, default_value = "web")]
1414 profile: String,
1415 #[arg(long, default_value_t = false)]
1417 allow_full_access: bool,
1418 #[arg(long, default_value_t = false)]
1420 skin: bool,
1421 },
1422 Connect {
1424 #[arg(long, default_value = "web")]
1425 profile: String,
1426 #[arg(long, default_value_t = false)]
1427 allow_full_access: bool,
1428 #[arg(long, default_value_t = false)]
1429 skin: bool,
1430 #[arg(long, default_value_t = false)]
1432 yes: bool,
1433 },
1434 Update {
1436 #[arg(long)]
1437 profile: Option<String>,
1438 #[arg(long, default_value_t = false)]
1439 allow_full_access: bool,
1440 #[arg(long)]
1442 skin: Option<bool>,
1443 #[arg(long, default_value_t = false)]
1444 yes: bool,
1445 },
1446 Launch {
1448 #[arg(long)]
1450 profile: Option<String>,
1451 #[arg(long, default_value_t = false)]
1453 dry_run: bool,
1454 #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
1455 args: Vec<String>,
1456 },
1457 Disable,
1459 Enable,
1461 Remove {
1463 #[arg(long, default_value_t = false)]
1464 yes: bool,
1465 },
1466 InstallBundle {
1468 #[arg(long, default_value = "web")]
1470 app: String,
1471 #[arg(long, default_value_t = false)]
1472 yes: bool,
1473 },
1474 RemoveBundle {
1476 #[arg(long, default_value_t = false)]
1477 yes: bool,
1478 },
1479}
1480
1481#[derive(Args, Debug, Clone)]
1482struct FeaturesCli {
1483 #[command(subcommand)]
1484 command: FeaturesSubcommand,
1485}
1486
1487#[derive(Subcommand, Debug, Clone)]
1488enum FeaturesSubcommand {
1489 List,
1491}
1492
1493#[derive(Args, Debug, Clone)]
1494struct SandboxArgs {
1495 #[command(subcommand)]
1496 command: SandboxCommand,
1497}
1498
1499#[derive(Subcommand, Debug, Clone)]
1500enum SandboxCommand {
1501 Run {
1503 #[arg(long, default_value = "workspace-write")]
1505 policy: String,
1506 #[arg(long)]
1508 network: bool,
1509 #[arg(long, value_name = "PATH")]
1511 writable_root: Vec<PathBuf>,
1512 #[arg(long)]
1514 exclude_tmpdir: bool,
1515 #[arg(long)]
1517 exclude_slash_tmp: bool,
1518 #[arg(long)]
1520 cwd: Option<PathBuf>,
1521 #[arg(long, default_value_t = 60_000)]
1523 timeout_ms: u64,
1524 #[arg(required = true, trailing_var_arg = true)]
1526 command: Vec<String>,
1527 },
1528}
1529
1530const CODEWHALE_MAIN_STACK_BYTES: usize = 16 * 1024 * 1024;
1531
1532pub fn run(args: Vec<String>) -> std::process::ExitCode {
1536 match run_with_args(args) {
1537 Ok(()) => std::process::ExitCode::SUCCESS,
1538 Err(err) => {
1539 eprintln!("error: {err}");
1540 for cause in err.chain().skip(1) {
1541 eprintln!(" caused by: {cause}");
1542 }
1543 std::process::ExitCode::FAILURE
1544 }
1545 }
1546}
1547
1548fn run_with_args(args: Vec<String>) -> Result<()> {
1552 #[cfg(unix)]
1556 unsafe {
1557 libc::signal(libc::SIGPIPE, libc::SIG_DFL);
1558 }
1559
1560 startup_trace::mark_process_start();
1561 configure_windows_console_utf8();
1562 install_rustls_crypto_provider();
1563
1564 crate::sandbox::process_hardening::apply_process_hardening();
1568
1569 crate::tui::ui::fatal_signal_guard::install_fatal_signal_guard();
1576
1577 let orig_hook = std::panic::take_hook();
1582 std::panic::set_hook(Box::new(move |panic_info| {
1583 crate::tui::ui::emergency_restore_terminal();
1589
1590 let msg = if let Some(s) = panic_info.payload().downcast_ref::<&str>() {
1591 s.to_string()
1592 } else if let Some(s) = panic_info.payload().downcast_ref::<String>() {
1593 s.clone()
1594 } else {
1595 format!("{:?}", panic_info.payload())
1596 };
1597 let location = panic_info
1598 .location()
1599 .map(|loc| loc.to_string())
1600 .unwrap_or_else(|| "unknown".to_string());
1601 tracing::error!(target: "panic", "Process panicked at {location}: {msg}");
1602
1603 codewhale_telemetry::set_exit_class(codewhale_telemetry::ExitClass::Panic);
1614 if let Some(site) = panic_info
1615 .location()
1616 .map(|loc| codewhale_telemetry::reduce_panic_site(loc.file(), loc.line(), loc.column()))
1617 {
1618 codewhale_telemetry::record_blocking(codewhale_telemetry::Event::Panic { site });
1619 }
1620 if let Some(home) = crate::config::effective_home_dir() {
1622 let crash_dir = home.join(".deepseek").join("crashes");
1623 let _ = std::fs::create_dir_all(&crash_dir);
1624 use chrono::Utc;
1625 let ts = Utc::now().format("%Y%m%dT%H%M%S%.3fZ");
1626 let path = crash_dir.join(format!("{ts}-process-panic.log"));
1627 let contents =
1628 format!("Process panicked\nLocation: {location}\nTimestamp: {ts}\nPanic: {msg}\n",);
1629 let _ = std::fs::write(&path, contents);
1630 }
1631 orig_hook(panic_info);
1633 }));
1634
1635 let cli = match Cli::try_parse_from(args) {
1642 Ok(c) => c,
1643 Err(e) => {
1644 e.exit();
1645 }
1646 };
1647 crate::fleet::roster::set_project_agent_profiles_enabled(!cli.no_project_config);
1652 let workspace = resolve_workspace(&cli);
1653 let mut plugin_discovery = None;
1654 let mut plugin_registry = None;
1655 let (cli, command) = prepare_cli_startup(
1656 cli,
1657 || {
1658 let discovery = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv();
1659 plugin_registry = Some(discovery.registry_for_workspace(&workspace));
1660 plugin_discovery = Some(discovery);
1661 },
1662 warn_on_workspace_dotenv_result,
1663 );
1664 let plugin_discovery = plugin_discovery
1665 .expect("plugin discovery initialization must precede workspace dotenv loading");
1666 let plugin_registry = plugin_registry
1667 .expect("plugin discovery initialization must precede workspace dotenv loading");
1668
1669 let runtime_thread = std::thread::Builder::new()
1677 .name("codewhale-main".to_string())
1678 .stack_size(CODEWHALE_MAIN_STACK_BYTES)
1679 .spawn(move || run_async_main(cli, command, plugin_discovery, plugin_registry))
1680 .context("Failed to start the Codewhale runtime thread")?;
1681 match runtime_thread.join() {
1682 Ok(result) => result,
1683 Err(payload) => {
1684 let message = payload
1685 .downcast_ref::<&str>()
1686 .map(|value| (*value).to_string())
1687 .or_else(|| payload.downcast_ref::<String>().cloned())
1688 .unwrap_or_else(|| "unknown panic payload".to_string());
1689 Err(anyhow!("Codewhale runtime thread panicked: {message}"))
1690 }
1691 }
1692}
1693
1694fn run_async_main(
1695 cli: Cli,
1696 command: Option<Commands>,
1697 plugin_discovery: Arc<crate::plugins::PluginDiscoveryContext>,
1698 plugin_registry: Arc<crate::plugins::PluginRegistry>,
1699) -> Result<()> {
1700 build_runtime()?.block_on(run_async_main_inner(
1701 cli,
1702 command,
1703 plugin_discovery,
1704 plugin_registry,
1705 ))
1706}
1707
1708pub(crate) fn build_runtime() -> Result<tokio::runtime::Runtime> {
1727 tokio::runtime::Builder::new_multi_thread()
1728 .enable_all()
1729 .thread_stack_size(CODEWHALE_MAIN_STACK_BYTES)
1730 .build()
1731 .context("Failed to build the Codewhale Tokio runtime")
1732}
1733
1734fn telemetry_surface(command: Option<&Commands>) -> codewhale_telemetry::Surface {
1740 use codewhale_telemetry::Surface;
1741 match command {
1742 None | Some(Commands::Resume { .. } | Commands::Fork { .. } | Commands::Pr { .. }) => {
1743 Surface::Tui
1744 }
1745 Some(Commands::Exec(_)) => Surface::Exec,
1746 Some(Commands::Serve(args)) => {
1747 if args.mcp {
1748 Surface::McpServer
1749 } else {
1750 Surface::Serve
1751 }
1752 }
1753 Some(_) => Surface::Cli,
1754 }
1755}
1756
1757fn telemetry_session_source(command: Option<&Commands>) -> codewhale_telemetry::SessionSource {
1759 use codewhale_telemetry::SessionSource;
1760 match command {
1761 None | Some(Commands::Pr { .. }) => SessionSource::Interactive,
1762 Some(Commands::Resume { .. }) => SessionSource::Resume,
1763 Some(Commands::Fork { .. }) => SessionSource::Fork,
1764 Some(Commands::Serve(_)) => SessionSource::Api,
1765 Some(_) => SessionSource::Unknown,
1766 }
1767}
1768
1769fn telemetry_command_is_read_only(command: Option<&Commands>) -> bool {
1771 matches!(
1772 command,
1773 Some(Commands::Doctor(_) | Commands::SessionDiagnostics(_) | Commands::Sessions { .. })
1774 ) || matches!(command, Some(Commands::Setup(args)) if args.status)
1775}
1776
1777fn arm_telemetry_with_setup(
1791 config_path: Option<PathBuf>,
1792 surface: codewhale_telemetry::Surface,
1793 source: codewhale_telemetry::SessionSource,
1794 setup_override: Option<&codewhale_config::SetupState>,
1795) {
1796 let Ok(store) = codewhale_config::ConfigStore::load(config_path) else {
1797 return;
1798 };
1799 let resolved = store
1800 .config
1801 .resolve_runtime_options(&codewhale_config::CliRuntimeOverrides::default());
1802 let setup = if let Some(setup) = setup_override {
1803 setup.clone()
1804 } else {
1805 let Some(setup) = codewhale_telemetry::load_setup_state_for_decision() else {
1806 return;
1809 };
1810 setup
1811 };
1812 let codewhale_telemetry::TelemetryDecision::Enabled(consent) =
1813 codewhale_telemetry::decide(&resolved, &setup, surface)
1814 else {
1815 return;
1816 };
1817 codewhale_telemetry::init(consent.with_config_path(Some(store.path().to_path_buf())));
1818 let _ = TELEMETRY_SESSION_START.set(std::time::Instant::now());
1819 codewhale_telemetry::record(codewhale_telemetry::Event::SessionStart { source });
1820}
1821
1822fn arm_telemetry(cli: &Cli, command: Option<&Commands>) {
1823 if telemetry_command_is_read_only(command) {
1824 return;
1825 }
1826 arm_telemetry_with_setup(
1827 cli.config.clone(),
1828 telemetry_surface(command),
1829 telemetry_session_source(command),
1830 None,
1831 );
1832}
1833
1834pub(crate) fn apply_tui_telemetry_decision(
1841 pending: &crate::telemetry_notice::PendingTelemetryNotice,
1842 setup: &codewhale_config::SetupState,
1843) {
1844 arm_telemetry_with_setup(
1845 pending.config_path.clone(),
1846 codewhale_telemetry::Surface::Tui,
1847 pending.session_source,
1848 Some(setup),
1849 );
1850}
1851
1852async fn finish_telemetry(outcome: &Result<()>) {
1854 if !codewhale_telemetry::is_armed() {
1855 return;
1856 }
1857 if outcome.is_err()
1860 && codewhale_telemetry::exit_class() == codewhale_telemetry::ExitClass::Clean
1861 {
1862 codewhale_telemetry::set_exit_class(codewhale_telemetry::ExitClass::Error);
1863 }
1864 codewhale_telemetry::record(telemetry_session_end());
1865 let _ = tokio::time::timeout(
1870 codewhale_telemetry::SHUTDOWN_FLUSH_TIMEOUT,
1871 tokio::task::spawn_blocking(|| {
1872 codewhale_telemetry::shutdown_blocking(codewhale_telemetry::SHUTDOWN_FLUSH_TIMEOUT)
1873 }),
1874 )
1875 .await;
1876}
1877
1878async fn run_async_main_inner(
1879 cli: Cli,
1880 command: Option<Commands>,
1881 plugin_discovery: Arc<crate::plugins::PluginDiscoveryContext>,
1882 plugin_registry: Arc<crate::plugins::PluginRegistry>,
1883) -> Result<()> {
1884 spawn_signal_cleanup_task();
1905
1906 let surface = telemetry_surface(command.as_ref());
1912 let telemetry_notice_plan = if surface == codewhale_telemetry::Surface::Tui {
1913 crate::telemetry_notice::plan_if_due(
1914 cli.config.clone(),
1915 telemetry_session_source(command.as_ref()),
1916 )
1917 } else {
1918 crate::telemetry_notice::TelemetryNoticePlan::NotDue
1919 };
1920 let should_arm_before_dispatch = surface != codewhale_telemetry::Surface::Tui
1921 || telemetry_notice_plan.should_arm_before_tui();
1922 let pending_telemetry_notice = telemetry_notice_plan.into_pending();
1923 if should_arm_before_dispatch {
1924 arm_telemetry(&cli, command.as_ref());
1925 }
1926 let outcome = run_async_main_dispatch(
1927 cli,
1928 command,
1929 plugin_discovery,
1930 plugin_registry,
1931 pending_telemetry_notice,
1932 )
1933 .await;
1934 finish_telemetry(&outcome).await;
1935 outcome
1936}
1937
1938async fn run_async_main_dispatch(
1939 cli: Cli,
1940 command: Option<Commands>,
1941 plugin_discovery: Arc<crate::plugins::PluginDiscoveryContext>,
1942 plugin_registry: Arc<crate::plugins::PluginRegistry>,
1943 mut pending_telemetry_notice: Option<crate::telemetry_notice::PendingTelemetryNotice>,
1944) -> Result<()> {
1945 logging::set_verbose(cli.verbose || logging::env_requests_verbose_logging());
1946
1947 crate::prompts::load_prompt_overrides_from_config_home();
1952
1953 if let Some(command) = command {
1961 return match command {
1962 Commands::Doctor(args) => {
1963 let config = match load_doctor_config_from_cli(&cli, &args) {
1964 Ok(config) => config,
1965 Err(error) if args.json => return run_doctor_json_config_error(&error),
1966 Err(_) => {
1967 bail!(
1968 "doctor configuration validation failed; details omitted because configuration errors may contain credential material"
1969 )
1970 }
1971 };
1972 let workspace = resolve_workspace(&cli);
1973 if args.context_json {
1974 run_doctor_context_json(&config, &workspace)
1975 } else if args.json {
1976 run_doctor_json(
1977 &config,
1978 &workspace,
1979 cli.config.as_deref(),
1980 plugin_registry.as_ref(),
1981 )
1982 } else {
1983 let probes = crate::doctor::DoctorProbeRequest {
1984 check_updates: args.check_updates,
1985 probe_api: args.probe_api,
1986 probe_local: args.probe_local,
1987 probe_mcp: args.probe_mcp,
1988 };
1989 run_doctor(
1990 &config,
1991 &workspace,
1992 cli.config.as_deref(),
1993 probes,
1994 plugin_registry.as_ref(),
1995 )
1996 .await;
1997 Ok(())
1998 }
1999 }
2000 Commands::SessionDiagnostics(args) => run_session_diagnostics(args),
2001 Commands::Setup(args) => {
2002 let config = load_config_from_cli(&cli)?;
2003 let workspace = resolve_workspace(&cli);
2004 run_setup(&config, &workspace, args, plugin_registry.as_ref())
2005 }
2006 Commands::RemoteSetup(args) => remote_setup::run_remote_setup(args),
2007 Commands::Completions { shell } => {
2008 generate_completions(shell);
2009 Ok(())
2010 }
2011 Commands::Sessions { limit, search } => list_sessions(limit, search),
2012 Commands::Init => init_project(),
2013 Commands::Login { api_key } => run_login(api_key),
2014 Commands::Logout => run_logout(),
2015 Commands::Auth(args) => match args.command {
2016 TuiAuthCommand::XaiDevice => run_xai_device_auth(cli.config.as_deref()).await,
2017 },
2018 Commands::Models(args) => {
2019 let config = load_config_from_cli(&cli)?;
2020 run_models(&config, args).await
2021 }
2022 Commands::Speech(args) => {
2023 let config = load_config_from_cli(&cli)?;
2024 run_speech(&config, args).await
2025 }
2026 Commands::Exec(args) => {
2027 let config = load_config_from_cli(&cli)?;
2028 let workspace = cli.workspace.clone().unwrap_or_else(|| {
2029 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
2030 });
2031 let mut config = config.clone();
2032 if !cli.no_project_config {
2037 merge_user_workspace_config(&mut config, cli.config.clone(), &workspace);
2038 }
2039 if let Some(sandbox) = args.sandbox.as_deref() {
2040 let _ = parse_sandbox_policy(sandbox, true, Vec::new(), false, false)?;
2041 config.sandbox_mode = Some(sandbox.to_ascii_lowercase());
2042 }
2043 if let Ok(env_url) = std::env::var("CODEWHALE_BASE_URL")
2046 .or_else(|_| std::env::var("DEEPSEEK_BASE_URL"))
2047 {
2048 let trimmed = env_url.trim();
2049 if !trimmed.is_empty() {
2050 config.base_url = Some(trimmed.to_string());
2051 }
2052 }
2053 let explicit_provider = args
2063 .provider
2064 .as_deref()
2065 .map(str::trim)
2066 .filter(|provider| !provider.is_empty());
2067 if let Some(provider_arg) = explicit_provider {
2068 apply_exec_provider_override(&mut config, provider_arg)?;
2069 }
2070 if let Some(reasoning_arg) = args
2071 .reasoning_effort
2072 .as_deref()
2073 .map(str::trim)
2074 .filter(|value| !value.is_empty())
2075 {
2076 config.reasoning_effort = normalize_cli_reasoning_effort(reasoning_arg)?;
2077 config.reasoning_effort_inferred_from_legacy_alias = false;
2078 }
2079 let prompt = join_prompt_parts(&args.prompt);
2080 let resume_session_id = resolve_exec_resume_session_id(&args, &workspace)?;
2081 validate_exec_tool_authority_resume(
2082 args.tool_authority_json.as_deref(),
2083 resume_session_id.is_some(),
2084 )?;
2085 let resume_session = resume_session_id
2086 .as_deref()
2087 .map(load_exec_resume_session)
2088 .transpose()?;
2089 let explicit_model = args
2090 .model
2091 .as_deref()
2092 .map(str::trim)
2093 .filter(|model| !model.is_empty());
2094 let model = if let Some(saved) = resume_session.as_ref() {
2095 resolve_exec_resume_route(
2096 &mut config,
2097 saved,
2098 explicit_provider.is_some(),
2099 explicit_model,
2100 )?
2101 } else {
2102 resolve_exec_model(&config, explicit_model)
2103 };
2104 let force_configured_route = should_force_configured_exec_route(
2105 resume_session.is_some(),
2106 explicit_provider,
2107 explicit_model,
2108 );
2109 let yolo = cli.yolo || config.yolo.unwrap_or(false);
2113 let env_tool_surface = exec_tool_surface_from_env();
2114 let needs_engine = args.auto
2115 || yolo
2116 || resume_session_id.is_some()
2117 || args.output_format == ExecOutputFormat::StreamJson
2118 || args.max_turns.is_some()
2119 || args.allowed_tools.is_some()
2120 || args.disallowed_tools.is_some()
2121 || args.append_system_prompt.is_some()
2122 || args.tool_authority_json.is_some()
2123 || args.sandbox.is_some()
2124 || args.allow_sandbox_elevation
2125 || env_tool_surface.is_some();
2126 if needs_engine {
2127 let provider = config.api_provider();
2128 let max_subagents = cli.max_subagents.map_or_else(
2129 || config.max_subagents_for_provider(provider),
2130 |value| value.clamp(1, MAX_SUBAGENTS),
2131 );
2132 let auto_mode = args.auto || yolo;
2133 let max_turns = exec_max_steps(args.max_turns);
2134 let allowed_tools =
2135 resolve_exec_allowed_tools(args.allowed_tools.as_deref(), env_tool_surface);
2136 let disallowed_tools = args
2137 .disallowed_tools
2138 .as_deref()
2139 .map(normalize_exec_tool_names);
2140 run_exec_agent(
2141 &config,
2142 &model,
2143 &prompt,
2144 workspace,
2145 max_subagents,
2146 auto_mode,
2147 args.allow_sandbox_elevation,
2148 args.sandbox.as_deref(),
2149 auto_mode,
2150 args.json,
2151 resume_session,
2152 force_configured_route,
2153 args.output_format,
2154 max_turns,
2155 allowed_tools,
2156 disallowed_tools,
2157 args.append_system_prompt.clone(),
2158 args.tool_authority_json.clone(),
2159 std::sync::Arc::clone(&plugin_registry),
2160 )
2161 .await
2162 } else if args.json {
2163 run_one_shot_json(&config, &model, &prompt, force_configured_route).await
2164 } else {
2165 run_one_shot(&config, &model, &prompt, force_configured_route).await
2166 }
2167 }
2168 Commands::Fleet(args) => {
2169 let config = load_config_from_cli(&cli)?;
2170 let workspace = resolve_workspace(&cli);
2171 run_fleet_command(&workspace, &config, args).await
2172 }
2173 Commands::WorkflowTool(args) => {
2174 run_workflow_tool_command(&cli, args, std::sync::Arc::clone(&plugin_registry)).await
2175 }
2176 Commands::Review(args) => {
2177 let config = load_config_from_cli(&cli)?;
2178 run_review(&config, args).await
2179 }
2180 Commands::Pr {
2181 number,
2182 repo,
2183 checkout,
2184 } => {
2185 let config = load_config_from_cli(&cli)?;
2186 run_pr(
2187 &cli,
2188 &config,
2189 number,
2190 repo.as_deref(),
2191 checkout,
2192 pending_telemetry_notice.take(),
2193 Arc::clone(&plugin_registry),
2194 )
2195 .await
2196 }
2197 Commands::Apply(args) => run_apply(args),
2198 Commands::Eval(args) => run_eval(args),
2199 Commands::Scorecard(args) => run_scorecard(args),
2200 Commands::Mcp { command } => {
2201 let config = load_config_from_cli(&cli)?;
2202 let workspace = resolve_workspace(&cli);
2203 run_mcp_command(&config, &workspace, command, plugin_registry.as_ref()).await
2204 }
2205 Commands::Features(command) => {
2206 let config = load_config_from_cli(&cli)?;
2207 run_features_command(&config, command)
2208 }
2209 Commands::Integrations { command } => {
2210 let config = load_structural_config_from_cli(&cli)?;
2213 let workspace = resolve_workspace(&cli);
2214 integrations::cli::run(&config, &workspace, command)
2215 }
2216 Commands::Sandbox(args) => run_sandbox_command(args),
2217 Commands::Serve(args) => {
2218 let workspace = cli.workspace.clone().unwrap_or_else(|| {
2219 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
2220 });
2221 let http_selected = validate_serve_mode_selection(
2222 args.mcp,
2223 args.http,
2224 args.mobile,
2225 args.web,
2226 args.acp,
2227 )?;
2228 if args.mcp {
2229 tokio::task::block_in_place(|| mcp_server::run_mcp_server(workspace))
2230 } else if http_selected {
2231 let (config, config_profile) =
2232 load_config_from_cli_with_effective_profile(&cli)?;
2233 let cors_origins = resolve_cors_origins(&config, &args.cors_origin);
2234 let bind_host = resolve_serve_bind_host(args.mobile, args.host);
2235 if args.web && bind_host.host != "127.0.0.1" {
2236 bail!("Codewhale web is loopback-only and must bind to 127.0.0.1");
2237 }
2238 if bind_host.mobile_rebound_to_lan {
2239 println!(
2240 "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."
2241 );
2242 }
2243 runtime_api::run_http_server(
2244 config,
2245 workspace,
2246 std::sync::Arc::clone(&plugin_discovery),
2247 runtime_api::RuntimeApiOptions {
2248 host: bind_host.host,
2249 port: args.port,
2250 workers: args.workers.clamp(1, 8),
2251 cors_origins,
2252 auth_token: args.auth_token,
2253 insecure_no_auth: args.insecure_no_auth,
2254 mobile: args.mobile,
2255 web: args.web,
2256 show_qr: args.qr,
2257 config_path: cli.config.clone(),
2258 config_profile,
2259 },
2260 )
2261 .await
2262 } else if args.acp {
2263 let config = load_config_from_cli(&cli)?;
2264 let model = config.default_model();
2265 acp_server::run_acp_server(config, model, workspace).await
2266 } else {
2267 unreachable!("server mode count checked above")
2268 }
2269 }
2270 Commands::Resume { session_id, last } => {
2271 let config = load_config_from_cli(&cli)?;
2272 let workspace = resolve_workspace(&cli);
2273 let resume_id = resolve_session_id(session_id, last, &workspace)?;
2274 run_interactive(
2275 &cli,
2276 &config,
2277 Some(resume_id),
2278 None,
2279 pending_telemetry_notice.take(),
2280 std::sync::Arc::clone(&plugin_registry),
2281 )
2282 .await
2283 }
2284 Commands::Fork { session_id, last } => {
2285 let config = load_config_from_cli(&cli)?;
2286 let workspace = resolve_workspace(&cli);
2287 let new_session_id = fork_session(&config, session_id, last, &workspace)?;
2288 run_interactive(
2289 &cli,
2290 &config,
2291 Some(new_session_id),
2292 None,
2293 pending_telemetry_notice.take(),
2294 std::sync::Arc::clone(&plugin_registry),
2295 )
2296 .await
2297 }
2298 };
2299 }
2300
2301 let config = load_config_from_cli(&cli)?;
2305 if let Some(initial_input) = top_level_prompt_initial_input(&cli.prompt) {
2306 return run_interactive(
2307 &cli,
2308 &config,
2309 None,
2310 Some(initial_input),
2311 pending_telemetry_notice.take(),
2312 std::sync::Arc::clone(&plugin_registry),
2313 )
2314 .await;
2315 }
2316
2317 let mut startup_notice = None;
2320 let resume_session_id = if cli.continue_session {
2321 let workspace = resolve_workspace(&cli);
2322 recover_interrupted_checkpoint_for_resume(&workspace)
2323 .or_else(|| latest_session_id_for_workspace(&workspace).ok().flatten())
2324 } else if let Some(id) = cli.resume.clone() {
2325 Some(id)
2326 } else if !cli.fresh {
2327 let workspace = resolve_workspace(&cli);
2328 preserve_interrupted_checkpoint_for_explicit_resume(&workspace);
2329 let (session_id, notice) = resolve_auto_resume(&workspace);
2335 startup_notice = notice;
2336 session_id
2337 } else {
2338 None
2339 };
2340
2341 run_interactive_with_notice(
2344 &cli,
2345 &config,
2346 resume_session_id,
2347 None,
2348 startup_notice,
2349 pending_telemetry_notice.take(),
2350 plugin_registry,
2351 )
2352 .await
2353}
2354
2355fn resolve_auto_resume(workspace: &Path) -> (Option<String>, Option<String>) {
2362 use crate::session_resume::{ResumeRequest, decide_auto_resume};
2363
2364 let enabled = crate::settings::Settings::load_persisted()
2365 .map(|settings| settings.session_auto_resume)
2366 .unwrap_or(false);
2367 if !enabled {
2368 return (None, None);
2369 }
2370 let Ok(manager) = SessionManager::default_location() else {
2371 return (None, None);
2372 };
2373 let decision = decide_auto_resume(true, &ResumeRequest::default(), workspace, &manager);
2374 (
2375 decision.session_id().map(str::to_string),
2376 decision.status_message(),
2377 )
2378}
2379
2380fn prepare_cli_startup(
2381 cli: Cli,
2382 initialize_plugins: impl FnOnce(),
2383 load_dotenv: impl FnOnce(),
2384) -> (Cli, Option<Commands>) {
2385 initialize_plugins();
2386 let command = cli.command.clone();
2387 let should_load_dotenv = match command.as_ref() {
2388 Some(Commands::Doctor(args)) => args.probe_api || args.probe_local,
2389 _ => true,
2390 };
2391 if should_load_dotenv {
2392 load_dotenv();
2393 }
2394 (cli, command)
2395}
2396
2397const MAX_WORKSPACE_DOTENV_BYTES: u64 = 1024 * 1024;
2398
2399#[derive(Debug, Default)]
2400struct WorkspaceDotenvReport {
2401 path: PathBuf,
2402 loaded: BTreeSet<String>,
2403 ignored: BTreeSet<String>,
2404}
2405
2406fn warn_on_workspace_dotenv_result() {
2414 match load_workspace_dotenv_credentials() {
2415 Ok(Some(report)) if !report.ignored.is_empty() => {
2416 eprintln!(
2417 "Codewhale ignored non-credential settings in {}: {}. Use config.toml, CLI flags, or the launching shell for control settings.",
2418 report.path.display(),
2419 display_env_key_set(&report.ignored)
2420 );
2421 }
2422 Ok(_) => {}
2423 Err(error) => {
2424 eprintln!("Codewhale did not load workspace .env: {error}");
2428 }
2429 }
2430}
2431
2432fn display_env_key_set(keys: &BTreeSet<String>) -> String {
2433 const MAX_DISPLAYED: usize = 12;
2434 let mut labels = keys
2435 .iter()
2436 .take(MAX_DISPLAYED)
2437 .map(|key| {
2438 if key
2439 .chars()
2440 .all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_')
2441 {
2442 key.as_str()
2443 } else {
2444 "<invalid-name>"
2445 }
2446 })
2447 .collect::<Vec<_>>();
2448 if keys.len() > MAX_DISPLAYED {
2449 labels.push("...");
2450 }
2451 labels.join(", ")
2452}
2453
2454fn load_workspace_dotenv_credentials() -> Result<Option<WorkspaceDotenvReport>> {
2455 let Some(path) = find_workspace_dotenv()? else {
2456 return Ok(None);
2457 };
2458 load_workspace_dotenv_credentials_from_path(&path).map(Some)
2459}
2460
2461fn find_workspace_dotenv() -> Result<Option<PathBuf>> {
2462 let cwd = std::env::current_dir().context("could not resolve the current workspace")?;
2463 let boundary = cwd
2464 .ancestors()
2465 .find(|ancestor| std::fs::symlink_metadata(ancestor.join(".git")).is_ok())
2466 .unwrap_or(cwd.as_path());
2467
2468 for ancestor in cwd.ancestors() {
2469 let candidate = ancestor.join(".env");
2470 match std::fs::symlink_metadata(&candidate) {
2471 Ok(_) => return Ok(Some(candidate)),
2472 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
2473 Err(error) => {
2474 return Err(anyhow!(
2475 "could not inspect {}: {error}",
2476 candidate.display()
2477 ));
2478 }
2479 }
2480 if ancestor == boundary {
2481 break;
2482 }
2483 }
2484 Ok(None)
2485}
2486
2487fn load_workspace_dotenv_credentials_from_path(path: &Path) -> Result<WorkspaceDotenvReport> {
2488 let contents = read_stable_workspace_dotenv(path)?;
2489 let text = std::str::from_utf8(&contents)
2490 .map_err(|_| anyhow!("{} is not valid UTF-8", path.display()))?;
2491 if dotenv_has_variable_expansion(text) {
2492 bail!(
2493 "{} uses variable expansion; workspace .env values must be literal to prevent ambient-secret substitution",
2494 path.display()
2495 );
2496 }
2497
2498 let mut report = WorkspaceDotenvReport {
2499 path: path.to_path_buf(),
2500 ..WorkspaceDotenvReport::default()
2501 };
2502 let entries = dotenvy::from_read_iter(std::io::Cursor::new(contents))
2503 .collect::<std::result::Result<Vec<_>, _>>()
2504 .map_err(|_| anyhow!("{} could not be parsed safely", path.display()))?;
2505 for entry in entries {
2506 let (key, value) = entry;
2507 if !is_workspace_dotenv_credential_key(&key) {
2508 report.ignored.insert(key);
2509 continue;
2510 }
2511 if std::env::var_os(&key).is_some() {
2512 continue;
2513 }
2514
2515 unsafe { std::env::set_var(&key, value) };
2520 report.loaded.insert(key);
2521 }
2522 Ok(report)
2523}
2524
2525fn is_workspace_dotenv_credential_key(key: &str) -> bool {
2526 codewhale_config::provider::providers_sorted_for_display()
2527 .into_iter()
2528 .any(|provider| provider.env_vars().contains(&key))
2529 || matches!(
2530 key,
2531 "DEEPSEEK_SEARCH_API_KEY"
2532 | "SOFYA_API_KEY"
2533 | "METASO_API_KEY"
2534 | "BAIDU_SEARCH_API_KEY"
2535 | "DEEPSEEK_SANDBOX_API_KEY"
2536 )
2537}
2538
2539fn dotenv_has_variable_expansion(contents: &str) -> bool {
2540 let mut escaped = false;
2541 let mut single_quoted = false;
2542 let mut double_quoted = false;
2543 let mut comment = false;
2544
2545 for ch in contents.chars() {
2546 if comment {
2547 if ch == '$' {
2551 return true;
2552 }
2553 if ch == '\n' {
2554 comment = false;
2555 escaped = false;
2556 }
2557 continue;
2558 }
2559 if single_quoted {
2560 if ch == '\'' {
2561 single_quoted = false;
2562 }
2563 continue;
2564 }
2565 if escaped {
2566 escaped = false;
2567 continue;
2568 }
2569 if ch == '\\' {
2570 escaped = true;
2571 continue;
2572 }
2573 if ch == '\'' && !double_quoted {
2574 single_quoted = true;
2575 continue;
2576 }
2577 if ch == '"' {
2578 double_quoted = !double_quoted;
2579 continue;
2580 }
2581 if ch == '#' && !double_quoted {
2582 comment = true;
2583 continue;
2584 }
2585 if ch == '$' {
2586 return true;
2587 }
2588 }
2589 false
2590}
2591
2592fn read_stable_workspace_dotenv(path: &Path) -> Result<Vec<u8>> {
2593 let mut file = open_workspace_dotenv_without_following_links(path)?;
2594 let metadata = file
2595 .metadata()
2596 .map_err(|error| anyhow!("could not inspect {}: {error}", path.display()))?;
2597 if !metadata.is_file() {
2598 bail!("{} is not a regular file", path.display());
2599 }
2600 if workspace_dotenv_has_multiple_links(&file, &metadata)? {
2601 bail!(
2602 "{} has multiple filesystem links, not a unique workspace-owned file",
2603 path.display()
2604 );
2605 }
2606 if metadata.len() > MAX_WORKSPACE_DOTENV_BYTES {
2607 bail!(
2608 "{} exceeds the {} byte workspace .env limit",
2609 path.display(),
2610 MAX_WORKSPACE_DOTENV_BYTES
2611 );
2612 }
2613
2614 let mut contents = Vec::with_capacity(metadata.len() as usize);
2615 (&mut file)
2616 .take(MAX_WORKSPACE_DOTENV_BYTES + 1)
2617 .read_to_end(&mut contents)
2618 .map_err(|error| anyhow!("could not read {}: {error}", path.display()))?;
2619 if contents.len() as u64 > MAX_WORKSPACE_DOTENV_BYTES {
2620 bail!(
2621 "{} exceeds the {} byte workspace .env limit",
2622 path.display(),
2623 MAX_WORKSPACE_DOTENV_BYTES
2624 );
2625 }
2626 Ok(contents)
2627}
2628
2629#[cfg(unix)]
2630fn workspace_dotenv_has_multiple_links(
2631 _file: &std::fs::File,
2632 metadata: &std::fs::Metadata,
2633) -> Result<bool> {
2634 use std::os::unix::fs::MetadataExt;
2635
2636 Ok(metadata.nlink() > 1)
2637}
2638
2639#[cfg(windows)]
2640fn workspace_dotenv_has_multiple_links(
2641 file: &std::fs::File,
2642 _metadata: &std::fs::Metadata,
2643) -> Result<bool> {
2644 use std::os::windows::io::AsRawHandle;
2645 use windows::Win32::Foundation::HANDLE;
2646 use windows::Win32::Storage::FileSystem::{
2647 BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle,
2648 };
2649
2650 let mut information = BY_HANDLE_FILE_INFORMATION::default();
2651 unsafe {
2655 GetFileInformationByHandle(HANDLE(file.as_raw_handle()), &mut information)
2656 .map_err(|error| anyhow!("could not inspect workspace .env link count: {error}"))?;
2657 }
2658 Ok(information.nNumberOfLinks > 1)
2659}
2660
2661#[cfg(not(any(unix, windows)))]
2662fn workspace_dotenv_has_multiple_links(
2663 _file: &std::fs::File,
2664 _metadata: &std::fs::Metadata,
2665) -> Result<bool> {
2666 Ok(false)
2667}
2668
2669#[cfg(unix)]
2670fn open_workspace_dotenv_without_following_links(path: &Path) -> Result<std::fs::File> {
2671 use std::os::unix::fs::OpenOptionsExt;
2672
2673 std::fs::OpenOptions::new()
2674 .read(true)
2675 .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK)
2678 .open(path)
2679 .map_err(|error| anyhow!("could not securely open {}: {error}", path.display()))
2680}
2681
2682#[cfg(windows)]
2683fn open_workspace_dotenv_without_following_links(path: &Path) -> Result<std::fs::File> {
2684 use std::os::windows::fs::{MetadataExt, OpenOptionsExt};
2685
2686 const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
2687 const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
2688 let file = std::fs::OpenOptions::new()
2689 .read(true)
2690 .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
2691 .open(path)
2692 .map_err(|error| anyhow!("could not securely open {}: {error}", path.display()))?;
2693 let metadata = file
2694 .metadata()
2695 .map_err(|error| anyhow!("could not inspect {}: {error}", path.display()))?;
2696 if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
2697 bail!(
2698 "{} is a reparse point, not a workspace-owned file",
2699 path.display()
2700 );
2701 }
2702 Ok(file)
2703}
2704
2705#[cfg(not(any(unix, windows)))]
2706fn open_workspace_dotenv_without_following_links(path: &Path) -> Result<std::fs::File> {
2707 let metadata = std::fs::symlink_metadata(path)
2708 .map_err(|error| anyhow!("could not inspect {}: {error}", path.display()))?;
2709 if metadata.file_type().is_symlink() {
2710 bail!(
2711 "{} is a symbolic link, not a workspace-owned file",
2712 path.display()
2713 );
2714 }
2715 std::fs::File::open(path)
2716 .map_err(|error| anyhow!("could not securely open {}: {error}", path.display()))
2717}
2718
2719fn generate_completions(shell: Shell) {
2721 let mut cmd = Cli::command();
2722 let name = cmd.get_name().to_string();
2723 generate(shell, &mut cmd, name, &mut io::stdout());
2724}
2725
2726fn run_eval(args: EvalArgs) -> Result<()> {
2728 let fail_step = match args.fail_step.as_deref() {
2729 Some(value) => ScenarioStepKind::parse(value)
2730 .map(Some)
2731 .ok_or_else(|| anyhow!("invalid --fail-step '{value}'"))?,
2732 None => None,
2733 };
2734
2735 let config = EvalHarnessConfig {
2736 fail_step,
2737 shell_command: args.shell_command,
2738 shell_expect_token: args.shell_expect_token,
2739 max_output_chars: args.max_output_chars,
2740 record_dir: args.record.clone(),
2741 ..EvalHarnessConfig::default()
2742 };
2743
2744 let harness = EvalHarness::new(config);
2745 let run = harness.run().context("evaluation harness failed")?;
2746 let report = run.to_report();
2747
2748 if args.json {
2749 let json = serde_json::to_string_pretty(&report)?;
2750 println!("{json}");
2751 } else {
2752 println!("Offline Eval Harness");
2753 println!("scenario: {}", report.scenario_name);
2754 println!("workspace: {}", report.workspace_root.display());
2755 println!("success: {}", report.metrics.success);
2756 println!("steps: {}", report.metrics.steps);
2757 println!("tool_errors: {}", report.metrics.tool_errors);
2758 println!("duration_ms: {}", report.metrics.duration.as_millis());
2759
2760 if !report.metrics.per_tool.is_empty() {
2761 println!("per_tool:");
2762 for (kind, stats) in &report.metrics.per_tool {
2763 println!(
2764 " {} invocations={} errors={} duration_ms={}",
2765 kind.tool_name(),
2766 stats.invocations,
2767 stats.errors,
2768 stats.total_duration.as_millis()
2769 );
2770 }
2771 }
2772
2773 let failed_steps: Vec<_> = report.steps.iter().filter(|s| !s.success).collect();
2774 if !failed_steps.is_empty() {
2775 println!("failed_steps:");
2776 for step in failed_steps {
2777 let error = step.error.as_deref().unwrap_or("unknown error");
2778 println!(
2779 " {} tool={} error={}",
2780 step.kind.tool_name(),
2781 step.tool_name,
2782 error
2783 );
2784 }
2785 }
2786 }
2787
2788 if report.metrics.success {
2789 Ok(())
2790 } else {
2791 bail!("offline evaluation harness reported failure")
2792 }
2793}
2794
2795fn run_scorecard(args: ScorecardArgs) -> Result<()> {
2801 use crate::scorecard::{RecordedTurn, Scorecard, ScorecardMetrics};
2802
2803 let raw = std::fs::read_to_string(&args.input)
2804 .with_context(|| format!("failed to read scorecard input {}", args.input.display()))?;
2805 let recorded: Vec<RecordedTurn> = serde_json::from_str(&raw)
2806 .with_context(|| format!("failed to parse scorecard input {}", args.input.display()))?;
2807
2808 let card = Scorecard::from_recorded_turns(&recorded);
2809
2810 let regressions = match &args.baseline {
2811 Some(path) => {
2812 let baseline_raw = std::fs::read_to_string(path)
2813 .with_context(|| format!("failed to read baseline {}", path.display()))?;
2814 let baseline: ScorecardMetrics = serde_json::from_str(&baseline_raw)
2815 .with_context(|| format!("failed to parse baseline {}", path.display()))?;
2816 card.metrics.regressions_against(&baseline, args.threshold)
2817 }
2818 None => Vec::new(),
2819 };
2820
2821 if args.json {
2822 let out = serde_json::json!({
2823 "per_turn": card.per_turn,
2824 "metrics": card.metrics,
2825 "regressions": regressions,
2826 });
2827 println!("{}", serde_json::to_string_pretty(&out)?);
2828 } else {
2829 print!("{}", card.to_summary());
2830 for r in ®ressions {
2831 println!(
2832 "REGRESSION {}: baseline {:.4} -> current {:.4} (+{:.1}%)",
2833 r.metric, r.baseline, r.current, r.pct_increase
2834 );
2835 }
2836 }
2837
2838 if regressions.is_empty() {
2839 Ok(())
2840 } else {
2841 bail!(
2842 "{} metric(s) regressed past the {:.1}% threshold",
2843 regressions.len(),
2844 args.threshold
2845 )
2846 }
2847}
2848
2849async fn run_fleet_command(workspace: &Path, config: &Config, args: FleetArgs) -> Result<()> {
2850 use crate::fleet::alerts::{
2851 FleetAlertAdapterConfig, FleetAlertConfig, FleetAlertDispatcher, FleetAlertEvent,
2852 FleetEnvSecretResolver,
2853 };
2854 use crate::fleet::control as fleet_control;
2855 use crate::fleet::executor::FleetExecutor;
2856 use crate::fleet::manager::{FleetManager, FleetStatusSnapshot, FleetWorkerInspection};
2857 use codewhale_lane::{ControlOperation, ControlSurface};
2858 use codewhale_protocol::fleet::{FleetAlertEventClass, FleetArtifactKind, FleetRunId};
2859
2860 fn print_status(status: &FleetStatusSnapshot) {
2864 println!("{}", fleet_control::render_fleet_status_snapshot(status));
2865 }
2866
2867 fn print_inspection(inspection: &FleetWorkerInspection) {
2868 println!("{}", fleet_control::render_inspection(inspection));
2869 }
2870
2871 fn print_artifacts(inspection: &FleetWorkerInspection) {
2872 println!("{}", fleet_control::render_artifacts(inspection));
2873 }
2874
2875 fn emit_fleet_receipt(receipt: &codewhale_lane::ControlReceipt) -> Result<()> {
2877 if receipt.is_error() {
2878 eprintln!("{}", receipt.render());
2879 let detail = receipt
2880 .failure
2881 .as_ref()
2882 .map(ToString::to_string)
2883 .unwrap_or_else(|| receipt.outcome.as_str().to_string());
2884 bail!("{}: {detail}", receipt.operation_id);
2885 }
2886 println!("{}", receipt.render());
2887 Ok(())
2888 }
2889
2890 fn print_logs(workspace: &Path, inspection: &FleetWorkerInspection) -> Result<()> {
2891 let mut printed = false;
2892 for artifact in inspection
2893 .artifacts
2894 .iter()
2895 .filter(|artifact| matches!(artifact.kind, FleetArtifactKind::Log))
2896 {
2897 let path = workspace.join(&artifact.path);
2898 println!("== {} ==", artifact.path.display());
2899 let contents = std::fs::read_to_string(&path)
2900 .with_context(|| format!("reading fleet log {}", path.display()))?;
2901 let preview: String = contents.chars().take(16 * 1024).collect();
2902 let mut safe_preview = String::with_capacity(preview.len());
2907 crate::tui::osc8::strip_ansi_into(&preview, &mut safe_preview);
2908 print!("{safe_preview}");
2909 if contents.chars().count() > preview.chars().count() {
2910 println!("\n[truncated]");
2911 } else if !preview.ends_with('\n') {
2912 println!();
2913 }
2914 printed = true;
2915 }
2916 if !printed {
2917 println!("logs: none");
2918 }
2919 Ok(())
2920 }
2921
2922 fn alert_event_class(arg: FleetAlertEventArg) -> FleetAlertEventClass {
2923 match arg {
2924 FleetAlertEventArg::Stale => FleetAlertEventClass::Stale,
2925 FleetAlertEventArg::RestartExhausted => FleetAlertEventClass::RestartExhausted,
2926 FleetAlertEventArg::NeedsHuman => FleetAlertEventClass::NeedsHuman,
2927 FleetAlertEventArg::BudgetExceeded => FleetAlertEventClass::BudgetExceeded,
2928 FleetAlertEventArg::VerifierFailed => FleetAlertEventClass::VerifierFailed,
2929 FleetAlertEventArg::RunCompleted => FleetAlertEventClass::RunCompleted,
2930 }
2931 }
2932
2933 fn alert_status(class: FleetAlertEventClass, override_status: Option<String>) -> String {
2934 if let Some(status) = override_status {
2935 return status;
2936 }
2937 match class {
2938 FleetAlertEventClass::Stale => "stale",
2939 FleetAlertEventClass::RestartExhausted => "failed",
2940 FleetAlertEventClass::NeedsHuman => "needs_human",
2941 FleetAlertEventClass::BudgetExceeded => "budget_exceeded",
2942 FleetAlertEventClass::VerifierFailed => "verifier_failed",
2943 FleetAlertEventClass::RunCompleted => "completed",
2944 }
2945 .to_string()
2946 }
2947
2948 fn alert_adapter(args: &FleetAlertDryRunArgs) -> FleetAlertAdapterConfig {
2949 match args.adapter {
2950 FleetAlertAdapterArg::Slack => FleetAlertAdapterConfig::Slack {
2951 webhook_env: args.slack_webhook_env.clone(),
2952 channel: None,
2953 },
2954 FleetAlertAdapterArg::Webhook => FleetAlertAdapterConfig::Webhook {
2955 url_env: args.webhook_url_env.clone(),
2956 secret_env: args.webhook_secret_env.clone(),
2957 },
2958 FleetAlertAdapterArg::PagerDuty => FleetAlertAdapterConfig::PagerDuty {
2959 routing_key_env: args.pagerduty_routing_key_env.clone(),
2960 severity: args.pagerduty_severity.clone(),
2961 },
2962 }
2963 }
2964
2965 let fleet_config = config.fleet_config();
2966 let provider = config.api_provider();
2967 let max_subagents = config.max_subagents_for_provider(provider);
2968 let coordination_manager = crate::tools::subagent::new_shared_subagent_manager_with_timeout(
2969 workspace.to_path_buf(),
2970 max_subagents,
2971 config
2972 .max_admitted_subagents_for_provider(provider)
2973 .max(max_subagents),
2974 Duration::from_secs(config.subagent_heartbeat_timeout_secs_for_provider(provider)),
2975 config.launch_concurrency_for_provider(provider),
2976 config.subagent_token_budget_for_provider(provider),
2977 );
2978 let fleet_context = fleet_control::fleet_control_context(workspace);
2983 if let Some(operation) = match &args.command {
2992 FleetCommand::List => Some(ControlOperation::FleetList),
2993 FleetCommand::Status => Some(ControlOperation::FleetStatus),
2994 FleetCommand::Interrupt { .. } => Some(ControlOperation::FleetInterrupt),
2995 FleetCommand::Resume { .. } => Some(ControlOperation::FleetResume),
2996 _ => None,
2997 } {
2998 let descriptor = operation.descriptor();
2999 let availability = descriptor.availability(ControlSurface::Cli, fleet_context);
3000 if !availability.is_available() {
3001 return emit_fleet_receipt(&codewhale_lane::ControlReceipt::unavailable(
3002 descriptor,
3003 ControlSurface::Cli,
3004 availability,
3005 ));
3006 }
3007 }
3008
3009 let manager = FleetManager::open(workspace)?
3012 .with_exec_config(fleet_config.exec.clone())
3013 .with_fleet_config(fleet_config)
3014 .with_sub_agent_manager(coordination_manager)
3015 .with_session_model(config.default_model())
3016 .with_route_config(config.clone());
3017 match args.command {
3018 FleetCommand::Init => {
3019 println!("fleet ledger: {}", manager.ledger_path().display());
3020 Ok(())
3021 }
3022 FleetCommand::Run(args) => {
3023 let max_workers = args.max_workers.clamp(1, 128);
3024 let manager =
3025 manager.with_stale_after(Duration::from_secs(args.stale_after_seconds.max(1)));
3026 let report = manager.create_run_from_task_spec_path(&args.task_spec, max_workers)?;
3027 println!(
3028 "fleet run: {} tasks={} leased={} queued={}",
3029 report.run_id.0, report.task_count, report.leased, report.queued
3030 );
3031 for warning in &report.warnings {
3032 println!("warning: {warning}");
3033 }
3034 println!("workers:");
3035 for worker_id in &report.worker_ids {
3036 println!(" {worker_id}");
3037 }
3038 if args.once {
3039 print_status(&manager.run_status(&report.run_id)?);
3040 return Ok(());
3041 }
3042 println!(
3043 "manager loop running; use `codewhale fleet status`, `inspect`, `interrupt`, or `stop --all` from another terminal."
3044 );
3045 let mut executor = FleetExecutor::new(workspace);
3046 let codewhale_binary = fleet::executor::configured_codewhale_binary();
3047 let status = manager
3048 .run_to_completion(
3049 &report.run_id,
3050 max_workers,
3051 &mut executor,
3052 &codewhale_binary,
3053 None,
3054 Duration::from_secs(2),
3055 )
3056 .await?;
3057 print_status(&status);
3058 Ok(())
3059 }
3060 FleetCommand::List => emit_fleet_receipt(&fleet_control::execute_fleet_control_with(
3061 ControlSurface::Cli,
3062 workspace,
3063 fleet_context,
3064 &manager,
3065 ControlOperation::FleetList,
3066 None,
3067 )),
3068 FleetCommand::Status => emit_fleet_receipt(&fleet_control::execute_fleet_control_with(
3069 ControlSurface::Cli,
3070 workspace,
3071 fleet_context,
3072 &manager,
3073 ControlOperation::FleetStatus,
3074 None,
3075 )),
3076 FleetCommand::Inspect { worker_id } => {
3077 print_inspection(&manager.inspect_worker(&worker_id)?);
3078 Ok(())
3079 }
3080 FleetCommand::Logs { worker_id } => {
3081 let inspection = manager.inspect_worker(&worker_id)?;
3082 print_logs(workspace, &inspection)
3083 }
3084 FleetCommand::Artifacts { worker_id } => {
3085 let inspection = manager.inspect_worker(&worker_id)?;
3086 print_artifacts(&inspection);
3087 Ok(())
3088 }
3089 FleetCommand::Interrupt { worker_id } => {
3090 emit_fleet_receipt(&fleet_control::execute_fleet_control_with(
3091 ControlSurface::Cli,
3092 workspace,
3093 fleet_context,
3094 &manager,
3095 ControlOperation::FleetInterrupt,
3096 Some(&worker_id),
3097 ))
3098 }
3099 FleetCommand::Restart { worker_id } => {
3100 let report = manager.restart_worker(&worker_id)?;
3101 print_inspection(&report.inspection);
3102 println!(
3103 "manager loop running for restarted run {}; use `codewhale fleet status`, `inspect`, `interrupt`, or `stop --all` from another terminal.",
3104 report.run_id.0
3105 );
3106 let mut executor = FleetExecutor::new(workspace);
3107 let codewhale_binary = fleet::executor::configured_codewhale_binary();
3108 let status = manager
3109 .run_to_completion(
3110 &report.run_id,
3111 report.max_workers,
3112 &mut executor,
3113 &codewhale_binary,
3114 None,
3115 Duration::from_secs(2),
3116 )
3117 .await?;
3118 print_status(&status);
3119 Ok(())
3120 }
3121 FleetCommand::Resume {
3122 run_id,
3123 stale_after_seconds,
3124 } => {
3125 let manager = manager.with_stale_after(Duration::from_secs(stale_after_seconds.max(1)));
3126 emit_fleet_receipt(&fleet_control::execute_fleet_control_with(
3127 ControlSurface::Cli,
3128 workspace,
3129 fleet_context,
3130 &manager,
3131 ControlOperation::FleetResume,
3132 Some(&run_id),
3133 ))
3134 }
3135 FleetCommand::Stop { all } => {
3136 if !all {
3137 bail!("pass --all to stop all fleet work");
3138 }
3139 let stopped = manager.stop_all()?;
3140 println!("stopped: {stopped}");
3141 Ok(())
3142 }
3143 FleetCommand::AlertDryRun(args) => {
3144 let class = alert_event_class(args.event);
3145 let adapter = alert_adapter(&args);
3146 let event = FleetAlertEvent {
3147 class,
3148 run_id: FleetRunId::from(args.run_id.clone()),
3149 worker_id: args.worker_id.clone(),
3150 task_id: args.task_id.clone(),
3151 status: alert_status(class, args.status.clone()),
3152 reason: args.reason.clone(),
3153 };
3154 let dispatcher = FleetAlertDispatcher::new(
3155 FleetAlertConfig::dry_run_for_adapter(adapter),
3156 FleetEnvSecretResolver,
3157 );
3158 let deliveries = dispatcher.dispatch(&event)?;
3159 for delivery in deliveries {
3160 println!(
3161 "{}",
3162 serde_json::to_string_pretty(&delivery.redacted_payload)?
3163 );
3164 }
3165 Ok(())
3166 }
3167 }
3168}
3169
3170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3171enum WriteStatus {
3172 Created,
3173 Overwritten,
3174 SkippedExists,
3175}
3176
3177fn ensure_parent_dir(path: &Path) -> Result<()> {
3178 if let Some(parent) = path.parent()
3179 && !parent.as_os_str().is_empty()
3180 {
3181 std::fs::create_dir_all(parent)
3182 .with_context(|| format!("Failed to create directory for {}", parent.display()))?;
3183 }
3184 Ok(())
3185}
3186
3187fn write_template_file(path: &Path, contents: &str, force: bool) -> Result<WriteStatus> {
3188 ensure_parent_dir(path)?;
3189
3190 if path.exists() && !force {
3191 return Ok(WriteStatus::SkippedExists);
3192 }
3193
3194 let status = if path.exists() {
3195 WriteStatus::Overwritten
3196 } else {
3197 WriteStatus::Created
3198 };
3199
3200 std::fs::write(path, contents)
3201 .with_context(|| format!("Failed to write template at {}", path.display()))?;
3202
3203 Ok(status)
3204}
3205
3206fn mcp_template_json() -> Result<String> {
3207 let mut cfg = McpConfig::default();
3208 cfg.servers.insert(
3209 "example".to_string(),
3210 McpServerConfig {
3211 command: Some("node".to_string()),
3212 args: vec!["./path/to/your-mcp-server.js".to_string()],
3213 env: std::collections::HashMap::new(),
3214 cwd: None,
3215 url: None,
3216 transport: None,
3217 connect_timeout: None,
3218 execute_timeout: None,
3219 read_timeout: None,
3220 disabled: true,
3221 enabled: true,
3222 required: false,
3223 enabled_tools: Vec::new(),
3224 disabled_tools: Vec::new(),
3225 headers: std::collections::HashMap::new(),
3226 env_headers: std::collections::HashMap::new(),
3227 bearer_token_env_var: None,
3228 scopes: Vec::new(),
3229 oauth: None,
3230 oauth_resource: None,
3231 reviewed_plugin: None,
3232 },
3233 );
3234 serde_json::to_string_pretty(&cfg)
3235 .map_err(|e| anyhow!("Failed to render MCP template JSON: {e}"))
3236}
3237
3238fn init_mcp_config(path: &Path, force: bool) -> Result<WriteStatus> {
3239 let template = mcp_template_json()?;
3240 write_template_file(path, &template, force)
3241}
3242
3243fn skills_template(name: &str) -> String {
3244 format!(
3245 "\
3246---\n\
3247name: {name}\n\
3248description: Quick repo diagnostics and setup guidance\n\
3249allowed-tools: diagnostics, list_dir, read_file, grep_files, git_status, git_diff\n\
3250---\n\n\
3251When this skill is active:\n\
32521. Run the diagnostics tool to report workspace and sandbox status.\n\
32532. Skim key project files (README.md, Cargo.toml, AGENTS.md) before editing.\n\
32543. Prefer small, validated changes and summarize what you verified.\n\
3255"
3256 )
3257}
3258
3259fn init_skills_dir(skills_dir: &Path, force: bool) -> Result<(PathBuf, WriteStatus)> {
3260 std::fs::create_dir_all(skills_dir)
3261 .with_context(|| format!("Failed to create skills dir {}", skills_dir.display()))?;
3262
3263 let skill_name = "getting-started";
3264 let skill_path = skills_dir.join(skill_name).join("SKILL.md");
3265 ensure_parent_dir(&skill_path)?;
3266
3267 let status = write_template_file(&skill_path, &skills_template(skill_name), force)?;
3268 Ok((skill_path, status))
3269}
3270
3271fn tools_readme_template() -> &'static str {
3272 "# Local tools\n\n\
3273 Drop self-describing scripts here so they can be discovered by\n\
3274 `codewhale-tui setup --status` and surfaced in `codewhale-tui doctor`.\n\n\
3275 When `[tools.plugin_dir]` is set in config.toml (or when the default\n\
3276 `~/.codewhale/tools/` directory exists), they are auto-discovered and\n\
3277 registered as model-visible tools.\n\n\
3278 Each script should start with a frontmatter-style header so the\n\
3279 description is visible without executing the file and the agent knows\n\
3280 the tool name, description, and input schema:\n\n\
3281 ```\n\
3282 # name: my-tool\n\
3283 # description: One-line summary of what this tool does\n\
3284 # usage: my-tool [args...]\n\
3285 ```\n\n\
3286 The directory is intentionally not auto-loaded into the agent's tool\n\
3287 catalog. Wire individual tools through MCP, hooks, or skills when you\n\
3288 want them available inside a session.\n"
3289}
3290
3291fn tools_example_script() -> &'static str {
3292 "#!/usr/bin/env sh\n\
3293 # name: example\n\
3294 # description: Print a confirmation that local tool discovery works\n\
3295 # usage: example [name]\n\
3296 printf 'codewhale-tui local tool ok: %s\\n' \"${1:-world}\"\n"
3297}
3298
3299fn init_tools_dir(tools_dir: &Path, force: bool) -> Result<(PathBuf, WriteStatus, WriteStatus)> {
3300 std::fs::create_dir_all(tools_dir)
3301 .with_context(|| format!("Failed to create tools dir {}", tools_dir.display()))?;
3302
3303 let readme_path = tools_dir.join("README.md");
3304 let readme_status = write_template_file(&readme_path, tools_readme_template(), force)?;
3305
3306 let example_path = tools_dir.join("example.sh");
3307 let example_status = write_template_file(&example_path, tools_example_script(), force)?;
3308
3309 Ok((tools_dir.to_path_buf(), readme_status, example_status))
3310}
3311
3312fn plugins_readme_template() -> &'static str {
3313 "# Local plugins\n\n\
3314 Each Codewhale plugin bundle lives in its own subdirectory with a\n\
3315 versioned `plugin.toml`. User bundles live here; workspace bundles live\n\
3316 under `<workspace>/.codewhale/plugins/`. Both are discovered read-only,\n\
3317 untrusted, and disabled by default.\n\n\
3318 A v0.9.1 bundle layout looks like:\n\n\
3319 ```\n\
3320 plugins/\n\
3321 my-plugin/\n\
3322 plugin.toml\n\
3323 skills/\n\
3324 my-skill/SKILL.md\n\
3325 ```\n\n\
3326 Run `/plugin validate`, `/plugin show <name>`, then `/plugin enable <name>`.\n\
3327 Enablement opens a content- and capability-bound trust review;\n\
3328 confirm the displayed `/plugin trust` command to create an owner-only,\n\
3329 content-addressed runtime snapshot, then enable the bundle. Remote MCP\n\
3330 authentication must name environment sources; never store secret values\n\
3331 in `plugin.toml`.\n\n\
3332 Codewhale activates only declarative Skills and MCP servers through their\n\
3333 existing engines. Commands, agents, hooks, LSP, native extensions,\n\
3334 filesystem grants, and lifecycle mutation stay inventoried and inactive;\n\
3335 a mixed bundle can still activate its supported Skills and MCP.\n\
3336 There is no marketplace, install, update, ambient compatibility scan, or\n\
3337 automatic trust surface in this release.\n"
3338}
3339
3340fn plugin_example_manifest_template() -> &'static str {
3341 "schema_version = 1\n\n\
3342 [plugin]\n\
3343 name = \"example\"\n\
3344 version = \"0.1.0\"\n\
3345 description = \"Starter Codewhale plugin bundle\"\n\n\
3346 [skills]\n\
3347 path = \"skills\"\n"
3348}
3349
3350fn plugin_example_skill_template() -> &'static str {
3351 "---\n\
3352 name: hello\n\
3353 description: Explain that the example plugin bundle is active.\n\
3354 ---\n\n\
3355 Tell the user this instruction came from the namespaced\n\
3356 `example:hello` plugin skill. Do not perform side effects.\n"
3357}
3358
3359fn init_plugins_dir(
3360 plugins_dir: &Path,
3361 force: bool,
3362) -> Result<(
3363 PathBuf,
3364 PathBuf,
3365 PathBuf,
3366 WriteStatus,
3367 WriteStatus,
3368 WriteStatus,
3369)> {
3370 std::fs::create_dir_all(plugins_dir)
3371 .with_context(|| format!("Failed to create plugins dir {}", plugins_dir.display()))?;
3372
3373 let readme_path = plugins_dir.join("README.md");
3374 let readme_status = write_template_file(&readme_path, plugins_readme_template(), force)?;
3375
3376 let manifest_path = plugins_dir.join("example").join("plugin.toml");
3377 ensure_parent_dir(&manifest_path)?;
3378 let manifest_status =
3379 write_template_file(&manifest_path, plugin_example_manifest_template(), force)?;
3380
3381 let skill_path = plugins_dir
3382 .join("example")
3383 .join("skills")
3384 .join("hello")
3385 .join("SKILL.md");
3386 ensure_parent_dir(&skill_path)?;
3387 let skill_status = write_template_file(&skill_path, plugin_example_skill_template(), force)?;
3388
3389 Ok((
3390 readme_path,
3391 manifest_path,
3392 skill_path,
3393 readme_status,
3394 manifest_status,
3395 skill_status,
3396 ))
3397}
3398
3399fn resolve_cors_origins(config: &Config, flag_origins: &[String]) -> Vec<String> {
3412 let mut out: Vec<String> = Vec::new();
3413 let mut push = |raw: &str| {
3414 let trimmed = raw.trim();
3415 if trimmed.is_empty() {
3416 return;
3417 }
3418 if !out.iter().any(|existing| existing == trimmed) {
3419 out.push(trimmed.to_string());
3420 }
3421 };
3422 for o in flag_origins {
3423 push(o);
3424 }
3425 if let Ok(env_value) =
3426 std::env::var("CODEWHALE_CORS_ORIGINS").or_else(|_| std::env::var("DEEPSEEK_CORS_ORIGINS"))
3427 {
3428 for piece in env_value.split(',') {
3429 push(piece);
3430 }
3431 }
3432 if let Some(rt) = &config.runtime_api
3433 && let Some(list) = &rt.cors_origins
3434 {
3435 for o in list {
3436 push(o);
3437 }
3438 }
3439 out
3440}
3441
3442fn deepseek_home_dir() -> PathBuf {
3443 codewhale_config::codewhale_home().unwrap_or_else(|_| {
3444 crate::config::effective_home_dir()
3445 .map_or_else(|| PathBuf::from(".codewhale"), |h| h.join(".codewhale"))
3446 })
3447}
3448
3449fn default_tools_dir() -> PathBuf {
3451 deepseek_home_dir().join("tools")
3452}
3453
3454fn default_plugins_dir() -> PathBuf {
3456 deepseek_home_dir().join("plugins")
3457}
3458
3459fn default_checkpoints_dir() -> PathBuf {
3461 deepseek_home_dir().join("sessions").join("checkpoints")
3462}
3463
3464#[derive(Debug, Clone, PartialEq, Eq)]
3465struct CleanPlan {
3466 targets: Vec<PathBuf>,
3467}
3468
3469fn collect_clean_targets(checkpoints_dir: &Path) -> CleanPlan {
3470 let mut targets: Vec<PathBuf> = std::fs::read_dir(checkpoints_dir)
3476 .map(|entries| {
3477 entries
3478 .filter_map(|entry| entry.ok().map(|e| e.path()))
3479 .filter(|p| p.is_file() && p.extension().is_some_and(|ext| ext == "json"))
3480 .collect()
3481 })
3482 .unwrap_or_default();
3483 targets.sort();
3484 CleanPlan { targets }
3485}
3486
3487fn execute_clean_plan(plan: &CleanPlan) -> Result<Vec<PathBuf>> {
3488 let mut removed = Vec::with_capacity(plan.targets.len());
3489 for path in &plan.targets {
3490 std::fs::remove_file(path)
3491 .with_context(|| format!("Failed to remove {}", path.display()))?;
3492 removed.push(path.clone());
3493 }
3494 Ok(removed)
3495}
3496
3497fn run_setup(
3498 config: &Config,
3499 workspace: &Path,
3500 args: SetupArgs,
3501 plugins: &crate::plugins::PluginRegistry,
3502) -> Result<()> {
3503 if args.status {
3504 return run_setup_status(config, workspace, plugins);
3505 }
3506 if args.clean {
3507 return run_setup_clean(&default_checkpoints_dir(), args.force);
3508 }
3509
3510 use crate::palette;
3511 use colored::Colorize;
3512
3513 let (aqua_r, aqua_g, aqua_b) = palette::WHALE_INFO_RGB;
3514 let (sky_r, sky_g, sky_b) = palette::WHALE_INFO_RGB;
3515
3516 let any_explicit = args.mcp || args.skills || args.tools || args.plugins;
3517 let run_mcp = args.mcp || args.all || !any_explicit;
3518 let run_skills = args.skills || args.all || !any_explicit;
3519 let run_tools = args.tools || args.all;
3520 let run_plugins = args.plugins || args.all;
3521
3522 println!(
3523 "{}",
3524 "Codewhale Setup".truecolor(aqua_r, aqua_g, aqua_b).bold()
3525 );
3526 println!("{}", "==============".truecolor(sky_r, sky_g, sky_b));
3527 println!("Workspace: {}", crate::utils::display_path(workspace));
3528
3529 if run_mcp {
3530 let mcp_path = config.mcp_config_path();
3531 let status = init_mcp_config(&mcp_path, args.force)?;
3532 match status {
3533 WriteStatus::Created => {
3534 println!(" ✓ Created MCP config at {}", mcp_path.display());
3535 }
3536 WriteStatus::Overwritten => {
3537 println!(" ✓ Overwrote MCP config at {}", mcp_path.display());
3538 }
3539 WriteStatus::SkippedExists => {
3540 println!(" · MCP config already exists at {}", mcp_path.display());
3541 }
3542 }
3543 println!(
3544 " Next: edit the file, then run `codewhale mcp list` or `codewhale mcp tools`."
3545 );
3546 }
3547
3548 if run_skills {
3549 let skills_dir = if args.local {
3550 workspace.join("skills")
3551 } else {
3552 config.skills_dir()
3553 };
3554 let (skill_path, status) = init_skills_dir(&skills_dir, args.force)?;
3555 match status {
3556 WriteStatus::Created => {
3557 println!(" ✓ Created example skill at {}", skill_path.display());
3558 }
3559 WriteStatus::Overwritten => {
3560 println!(" ✓ Overwrote example skill at {}", skill_path.display());
3561 }
3562 WriteStatus::SkippedExists => {
3563 println!(
3564 " · Example skill already exists at {}",
3565 skill_path.display()
3566 );
3567 }
3568 }
3569 if args.local {
3570 println!(
3571 " Local skills dir enabled for this workspace: {}",
3572 crate::utils::display_path(&skills_dir)
3573 );
3574 } else {
3575 println!(
3576 " Skills dir: {}",
3577 crate::utils::display_path(&skills_dir)
3578 );
3579 }
3580 println!(" Next: run the TUI and use `/skills` then `/skill getting-started`.");
3581 }
3582
3583 if run_tools {
3584 let tools_dir = default_tools_dir();
3585 let (dir, readme_status, example_status) = init_tools_dir(&tools_dir, args.force)?;
3586 report_write_status("Tools README", &dir.join("README.md"), readme_status);
3587 report_write_status("Example tool", &dir.join("example.sh"), example_status);
3588 println!(" Tools dir: {}", crate::utils::display_path(&dir));
3589 println!(" Next: drop scripts here; surface them via skills/MCP when ready.");
3590 }
3591
3592 if run_plugins {
3593 let plugins_dir = default_plugins_dir();
3594 let (readme_path, manifest_path, skill_path, readme_status, manifest_status, skill_status) =
3595 init_plugins_dir(&plugins_dir, args.force)?;
3596 report_write_status("Plugins README", &readme_path, readme_status);
3597 report_write_status("Example plugin manifest", &manifest_path, manifest_status);
3598 report_write_status("Example plugin skill", &skill_path, skill_status);
3599 println!(
3600 " Plugins dir: {}",
3601 crate::utils::display_path(&plugins_dir)
3602 );
3603 println!(" Next: run `/plugin validate`, review `example`, then trust and enable it.");
3604 }
3605
3606 let sandbox = crate::sandbox::get_platform_sandbox_with_bwrap_preference(
3607 config.prefer_bwrap.unwrap_or(false),
3608 );
3609 if let Some(kind) = sandbox {
3610 println!(" ✓ Sandbox available: {kind}");
3611 } else {
3612 println!(" · Sandbox not available on this platform (best-effort only).");
3613 }
3614
3615 Ok(())
3616}
3617
3618fn report_write_status(label: &str, path: &Path, status: WriteStatus) {
3619 match status {
3620 WriteStatus::Created => {
3621 println!(" ✓ Created {label} at {}", path.display());
3622 }
3623 WriteStatus::Overwritten => {
3624 println!(" ✓ Overwrote {label} at {}", path.display());
3625 }
3626 WriteStatus::SkippedExists => {
3627 println!(" · {label} already exists at {}", path.display());
3628 }
3629 }
3630}
3631
3632#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3637enum ApiKeySource {
3638 ConfigDeclared,
3639 EnvDeclared,
3640 ExternalAuthDeclared,
3641 SecretStoreUnprobed,
3642 SecretStoreUnavailable,
3643 OAuth,
3644 ExternalConsent,
3645 NoAuth,
3646 LocalRuntime,
3647 Unknown,
3648}
3649
3650#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3653enum CredentialAvailability {
3654 Present,
3655 NotRequired,
3656 Unknown,
3657 NotProbed,
3658 Unavailable,
3659}
3660
3661impl CredentialAvailability {
3662 fn label(self) -> &'static str {
3663 match self {
3664 Self::Present => "present",
3665 Self::NotRequired => "not_required",
3666 Self::Unknown => "unknown",
3667 Self::NotProbed => "not_probed",
3668 Self::Unavailable => "unavailable",
3669 }
3670 }
3671
3672 fn certifies_ready(self) -> bool {
3673 matches!(self, Self::Present | Self::NotRequired)
3674 }
3675}
3676
3677#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3678struct CredentialDiagnostic {
3679 source: ApiKeySource,
3680 availability: CredentialAvailability,
3681}
3682
3683impl CredentialDiagnostic {
3684 const fn new(source: ApiKeySource, availability: CredentialAvailability) -> Self {
3685 Self {
3686 source,
3687 availability,
3688 }
3689 }
3690}
3691
3692fn resolve_credential_diagnostic(config: &Config) -> CredentialDiagnostic {
3693 let provider = config.api_provider();
3694 let base_url = config.deepseek_base_url();
3695 let auth_mode = config.auth_mode_for_provider(provider);
3696 if crate::config::auth_mode_disables_api_key(auth_mode.as_deref()) {
3697 return CredentialDiagnostic::new(
3698 ApiKeySource::NoAuth,
3699 CredentialAvailability::NotRequired,
3700 );
3701 }
3702 if !crate::config::auth_mode_requires_api_key(auth_mode.as_deref())
3703 && (crate::config::provider_route_is_keyless_self_hosted(provider, &base_url)
3704 || crate::config::base_url_uses_local_host(&base_url))
3705 {
3706 return CredentialDiagnostic::new(
3707 ApiKeySource::LocalRuntime,
3708 CredentialAvailability::NotRequired,
3709 );
3710 }
3711 let custom_endpoint = config.provider_uses_custom_endpoint(provider);
3712 if !custom_endpoint && provider == crate::config::ApiProvider::OpenaiCodex {
3713 return config
3714 .external_credential_consent_status(provider)
3715 .filter(|status| status.route_state == "active")
3716 .map_or_else(
3717 || {
3718 CredentialDiagnostic::new(
3719 ApiKeySource::OAuth,
3720 CredentialAvailability::NotProbed,
3721 )
3722 },
3723 |_| {
3724 CredentialDiagnostic::new(
3725 ApiKeySource::ExternalConsent,
3726 CredentialAvailability::NotProbed,
3727 )
3728 },
3729 );
3730 }
3731 if !custom_endpoint
3732 && provider == crate::config::ApiProvider::Xai
3733 && auth_mode
3734 .as_deref()
3735 .is_some_and(crate::xai_oauth::auth_mode_uses_xai_oauth)
3736 {
3737 return config
3738 .external_credential_consent_status(provider)
3739 .filter(|status| status.route_state == "active")
3740 .map_or_else(
3741 || {
3742 CredentialDiagnostic::new(
3743 ApiKeySource::OAuth,
3744 CredentialAvailability::NotProbed,
3745 )
3746 },
3747 |_| {
3748 CredentialDiagnostic::new(
3749 ApiKeySource::ExternalConsent,
3750 CredentialAvailability::NotProbed,
3751 )
3752 },
3753 );
3754 }
3755 let provider_config = config.provider_config();
3756 let provider_config_key_kind = provider_config
3757 .and_then(|entry| entry.api_key.as_deref())
3758 .map(crate::config::classify_config_api_key_value);
3759 let root_key_applies = matches!(
3760 provider,
3761 crate::config::ApiProvider::Deepseek | crate::config::ApiProvider::DeepseekCN
3762 ) || (provider == crate::config::ApiProvider::Custom
3763 && config.uses_legacy_literal_custom_route());
3764 let root_key_kind = root_key_applies
3765 .then_some(config.api_key.as_deref())
3766 .flatten()
3767 .map(crate::config::classify_config_api_key_value);
3768
3769 if matches!(
3770 provider_config_key_kind,
3771 Some(crate::config::ConfigApiKeyValueKind::Literal)
3772 ) || matches!(
3773 root_key_kind,
3774 Some(crate::config::ConfigApiKeyValueKind::Literal)
3775 ) {
3776 CredentialDiagnostic::new(
3777 ApiKeySource::ConfigDeclared,
3778 CredentialAvailability::Present,
3779 )
3780 } else if config
3781 .provider_config()
3782 .and_then(|entry| entry.api_key_env.as_deref())
3783 .is_some_and(|name| !name.trim().is_empty())
3784 {
3785 CredentialDiagnostic::new(ApiKeySource::EnvDeclared, CredentialAvailability::NotProbed)
3786 } else if config
3787 .provider_config()
3788 .and_then(|entry| entry.auth.as_ref())
3789 .is_some()
3790 {
3791 CredentialDiagnostic::new(
3792 ApiKeySource::ExternalAuthDeclared,
3793 CredentialAvailability::NotProbed,
3794 )
3795 } else if matches!(
3796 provider_config_key_kind,
3797 Some(crate::config::ConfigApiKeyValueKind::SecretStoreSentinel)
3798 ) || matches!(
3799 root_key_kind,
3800 Some(crate::config::ConfigApiKeyValueKind::SecretStoreSentinel)
3801 ) {
3802 if config.should_skip_secret_store_for_provider(provider) {
3803 return CredentialDiagnostic::new(
3804 ApiKeySource::SecretStoreUnavailable,
3805 CredentialAvailability::Unavailable,
3806 );
3807 }
3808 CredentialDiagnostic::new(
3811 ApiKeySource::SecretStoreUnprobed,
3812 CredentialAvailability::NotProbed,
3813 )
3814 } else if !config.should_skip_secret_store_for_provider(provider) {
3815 CredentialDiagnostic::new(
3819 ApiKeySource::SecretStoreUnprobed,
3820 CredentialAvailability::NotProbed,
3821 )
3822 } else {
3823 CredentialDiagnostic::new(ApiKeySource::Unknown, CredentialAvailability::Unknown)
3824 }
3825}
3826
3827#[cfg(test)]
3828fn resolve_api_key_source(config: &Config) -> ApiKeySource {
3829 resolve_credential_diagnostic(config).source
3830}
3831
3832fn provider_config_table_key(provider: crate::config::ApiProvider) -> &'static str {
3833 provider
3834 .metadata()
3835 .map(|metadata| metadata.provider_config_key())
3836 .unwrap_or("deepseek_cn")
3837}
3838
3839fn count_dir_entries(dir: &Path) -> usize {
3840 std::fs::read_dir(dir)
3841 .map(|entries| entries.filter_map(std::result::Result::ok).count())
3842 .unwrap_or(0)
3843}
3844
3845fn skills_count_for(dir: &Path) -> usize {
3846 if !dir.exists() {
3847 return 0;
3848 }
3849 crate::skills::SkillRegistry::discover(dir).len()
3850}
3851
3852fn run_setup_status(
3853 config: &Config,
3854 workspace: &Path,
3855 plugins: &crate::plugins::PluginRegistry,
3856) -> Result<()> {
3857 use crate::palette;
3858 use colored::Colorize;
3859
3860 let (aqua_r, aqua_g, aqua_b) = palette::WHALE_INFO_RGB;
3861 let (sky_r, sky_g, sky_b) = palette::WHALE_INFO_RGB;
3862
3863 println!(
3864 "{}",
3865 "Codewhale Status".truecolor(aqua_r, aqua_g, aqua_b).bold()
3866 );
3867 println!("{}", "===============".truecolor(sky_r, sky_g, sky_b));
3868 println!("workspace: {}", workspace.display());
3869
3870 let credential = resolve_credential_diagnostic(config);
3871 match credential.source {
3872 ApiKeySource::ConfigDeclared => println!(
3873 " {} api_key: literal config value structurally present",
3874 "✓".truecolor(aqua_r, aqua_g, aqua_b)
3875 ),
3876 ApiKeySource::EnvDeclared => println!(
3877 " {} api_key: environment source declared (value not inspected)",
3878 "·".dimmed()
3879 ),
3880 ApiKeySource::ExternalAuthDeclared => println!(
3881 " {} api_key: external auth source declared (value not inspected)",
3882 "·".dimmed()
3883 ),
3884 ApiKeySource::SecretStoreUnprobed => println!(
3885 " {} api_key: secret store eligible (store not probed)",
3886 "·".dimmed()
3887 ),
3888 ApiKeySource::SecretStoreUnavailable => println!(
3889 " {} api_key: secret-store sentinel declared, but this route cannot use that store",
3890 "!".truecolor(sky_r, sky_g, sky_b)
3891 ),
3892 ApiKeySource::OAuth => println!(
3893 " {} oauth: Codewhale-owned route selected (token availability not probed)",
3894 "·".dimmed()
3895 ),
3896 ApiKeySource::ExternalConsent => println!(
3897 " {} oauth: external read-only consent configured (credential file not probed)",
3898 "·".dimmed()
3899 ),
3900 ApiKeySource::NoAuth => println!(
3901 " {} api_key: disabled for this route",
3902 "✓".truecolor(aqua_r, aqua_g, aqua_b)
3903 ),
3904 ApiKeySource::LocalRuntime => println!(
3905 " {} api_key: not required for this local runtime",
3906 "✓".truecolor(aqua_r, aqua_g, aqua_b)
3907 ),
3908 ApiKeySource::Unknown => println!(
3909 " {} api_key: unknown (credential environment and durable stores not inspected)",
3910 "·".dimmed()
3911 ),
3912 }
3913 println!(
3914 " · credential availability: {}",
3915 credential.availability.label()
3916 );
3917 println!(
3918 " · base_url: {}",
3919 crate::doctor::structural_url_authority(&config.deepseek_base_url())
3920 );
3921 let model = config
3922 .default_text_model
3923 .clone()
3924 .unwrap_or_else(|| DEFAULT_TEXT_MODEL.to_string());
3925 println!(" · default_text_model: {model}");
3926 let (default_mode, default_mode_source) = doctor_runtime_default_mode();
3927 println!(" · default_mode: {default_mode} ({default_mode_source})");
3928
3929 let mcp_path = config.mcp_config_path();
3930 let project_mcp_path = crate::mcp::workspace_mcp_config_path(workspace);
3931 let mcp_count =
3932 match crate::mcp::load_config_with_workspace_and_plugins(&mcp_path, workspace, plugins) {
3933 Ok(cfg) => cfg.servers.len(),
3934 Err(_) => 0,
3935 };
3936 let mcp_present = if mcp_path.exists() { "" } else { " (missing)" };
3937 let project_mcp_present = if project_mcp_path.exists() {
3938 ""
3939 } else {
3940 " (missing)"
3941 };
3942 println!(
3943 " · mcp servers: {mcp_count} from {}{mcp_present} + {}{project_mcp_present}",
3944 mcp_path.display(),
3945 project_mcp_path.display()
3946 );
3947
3948 let skills_dir = config.skills_dir();
3949 println!(
3950 " · skills: {} at {}",
3951 skills_count_for(&skills_dir),
3952 crate::utils::display_path(&skills_dir)
3953 );
3954
3955 let tools_dir = default_tools_dir();
3956 let tools_present = if tools_dir.exists() {
3957 ""
3958 } else {
3959 " (missing — run `setup --tools`)"
3960 };
3961 println!(
3962 " · tools: {} entries at {}{tools_present}",
3963 if tools_dir.exists() {
3964 count_dir_entries(&tools_dir)
3965 } else {
3966 0
3967 },
3968 crate::utils::display_path(&tools_dir)
3969 );
3970
3971 let plugins_dir = default_plugins_dir();
3972 let plugins_present = if plugins_dir.exists() {
3973 ""
3974 } else {
3975 " (missing — run `setup --plugins`)"
3976 };
3977 println!(
3978 " · plugins: {} entries at {}{plugins_present}",
3979 if plugins_dir.exists() {
3980 count_dir_entries(&plugins_dir)
3981 } else {
3982 0
3983 },
3984 crate::utils::display_path(&plugins_dir)
3985 );
3986
3987 let sandbox = crate::sandbox::get_platform_sandbox_with_bwrap_preference(
3988 config.prefer_bwrap.unwrap_or(false),
3989 );
3990 match sandbox {
3991 Some(kind) => println!(
3992 " {} sandbox: {kind}",
3993 "✓".truecolor(aqua_r, aqua_g, aqua_b)
3994 ),
3995 None => println!(
3996 " {} sandbox: unavailable (commands run best-effort)",
3997 "!".truecolor(sky_r, sky_g, sky_b)
3998 ),
3999 }
4000
4001 println!(" {} {}", "·".dimmed(), dotenv_status_line(workspace));
4002
4003 println!();
4004 println!("Run `codewhale doctor --json` for a machine-readable check.");
4005 Ok(())
4006}
4007
4008fn dotenv_status_line(workspace: &Path) -> String {
4009 let dotenv = workspace.join(".env");
4010 if dotenv.exists() {
4011 return format!(
4012 ".env present at {} (literal provider credentials only)",
4013 dotenv.display()
4014 );
4015 }
4016
4017 if workspace.join(".env.example").exists() {
4018 return ".env not present in workspace (run `cp .env.example .env` and edit)".to_string();
4019 }
4020
4021 ".env not present in workspace".to_string()
4022}
4023
4024fn run_setup_clean(checkpoints_dir: &Path, force: bool) -> Result<()> {
4025 use colored::Colorize;
4026
4027 if !checkpoints_dir.exists() {
4028 println!(
4029 "Nothing to clean — checkpoints dir does not exist: {}",
4030 checkpoints_dir.display()
4031 );
4032 return Ok(());
4033 }
4034
4035 let plan = collect_clean_targets(checkpoints_dir);
4036 if plan.targets.is_empty() {
4037 println!(
4038 "Nothing to clean — no checkpoint files in {}",
4039 checkpoints_dir.display()
4040 );
4041 return Ok(());
4042 }
4043
4044 if !force {
4045 println!(
4046 "Would remove {} checkpoint file(s) (use --force to apply):",
4047 plan.targets.len()
4048 );
4049 for path in &plan.targets {
4050 println!(" · {}", path.display());
4051 }
4052 return Ok(());
4053 }
4054
4055 let removed = execute_clean_plan(&plan)?;
4056 println!("{}", "Cleaned checkpoints:".bold());
4057 for path in &removed {
4058 println!(" ✓ {}", path.display());
4059 }
4060 Ok(())
4061}
4062
4063fn run_session_diagnostics(args: SessionDiagnosticsArgs) -> Result<()> {
4064 let contents = std::fs::read_to_string(&args.path).with_context(|| {
4065 format!(
4066 "read session diagnostic JSONL from {}",
4067 crate::utils::display_path(&args.path)
4068 )
4069 })?;
4070 let summary = crate::session_diagnostics::analyze_session_failure_jsonl(&contents);
4071 if args.json {
4072 println!("{}", serde_json::to_string_pretty(&summary)?);
4073 } else {
4074 println!(
4075 "{}",
4076 crate::session_diagnostics::format_redacted_failure_summary(&summary)
4077 );
4078 }
4079 Ok(())
4080}
4081
4082fn doctor_should_probe_api(
4085 provider: crate::config::ApiProvider,
4086 base_url: &str,
4087 probes: crate::doctor::DoctorProbeRequest,
4088) -> bool {
4089 let local = crate::config::provider_route_is_keyless_self_hosted(provider, base_url)
4090 || crate::config::base_url_uses_local_host(base_url);
4091 probes.should_probe_api(local)
4092}
4093
4094fn doctor_should_probe_auth(config: &Config) -> bool {
4098 let provider = config.api_provider();
4099 if provider == crate::config::ApiProvider::OpenaiCodex
4100 && !config.provider_uses_custom_endpoint(provider)
4101 {
4102 return false;
4103 }
4104 let auth_mode = config.auth_mode_for_provider(provider);
4105 if provider == crate::config::ApiProvider::Xai
4106 && auth_mode
4107 .as_deref()
4108 .is_some_and(crate::xai_oauth::auth_mode_uses_xai_oauth)
4109 {
4110 return false;
4111 }
4112 !(provider == crate::config::ApiProvider::Moonshot
4113 && auth_mode
4114 .as_deref()
4115 .is_some_and(crate::config::auth_mode_uses_kimi_imported_token))
4116}
4117
4118async fn run_doctor(
4120 config: &Config,
4121 workspace: &Path,
4122 config_path_override: Option<&Path>,
4123 probes: crate::doctor::DoctorProbeRequest,
4124 plugins: &crate::plugins::PluginRegistry,
4125) {
4126 use crate::palette;
4127 use colored::Colorize;
4128
4129 let (accent_r, accent_g, accent_b) = palette::WHALE_HUMAN_RGB;
4130 let (sky_r, sky_g, sky_b) = palette::WHALE_INFO_RGB;
4131 let (aqua_r, aqua_g, aqua_b) = palette::WHALE_INFO_RGB;
4132 let (red_r, red_g, red_b) = palette::WHALE_ERROR_RGB;
4133
4134 println!(
4135 "{}",
4136 "codewhale Doctor"
4137 .truecolor(accent_r, accent_g, accent_b)
4138 .bold()
4139 );
4140 println!("{}", "==================".truecolor(sky_r, sky_g, sky_b));
4141 println!();
4142
4143 println!("{}", "Version Information:".bold());
4145 println!(" codewhale-tui: {}", env!("DEEPSEEK_BUILD_VERSION"));
4146 println!(" rust: {}", rustc_version());
4147 println!();
4148
4149 println!("{}", "Updates:".bold());
4150 crate::doctor::print_update_report(probes).await;
4151 println!();
4152
4153 let doctor_paths = match crate::doctor::DoctorPathReport::resolve(config_path_override) {
4155 Ok(paths) => paths,
4156 Err(error) => {
4157 println!("{}", "Resolved User Paths:".bold());
4158 println!(
4159 " {} unavailable: {error:#}",
4160 "✗".truecolor(red_r, red_g, red_b)
4161 );
4162 return;
4163 }
4164 };
4165 println!("{}", "Configuration:".bold());
4166 let config_path = &doctor_paths.config;
4167
4168 if config_path.exists() {
4169 println!(
4170 " {} config.toml found at {}",
4171 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4172 crate::utils::display_path(config_path)
4173 );
4174 if let Ok(raw) = std::fs::read_to_string(config_path) {
4177 let flagged = crate::doctor::config_credential_shaped_keys(&raw);
4178 if !flagged.is_empty() {
4179 println!(
4180 " {} credential-shaped value(s) in config.toml ({}): move them to the secret backend, then scrub the file — config.toml is plain text",
4181 "!".truecolor(sky_r, sky_g, sky_b),
4182 flagged.join(", ")
4183 );
4184 }
4185 }
4186 } else {
4187 println!(
4188 " {} config.toml not found at {} (using defaults/env)",
4189 "!".truecolor(sky_r, sky_g, sky_b),
4190 crate::utils::display_path(config_path)
4191 );
4192 }
4193 println!(" workspace: {}", crate::utils::display_path(workspace));
4194 println!(" {}", doctor_search_provider_line(config));
4195
4196 println!();
4197 println!("{}", "Resolved User Paths (read-only):".bold());
4198 for (label, path) in doctor_paths.entries() {
4199 println!(" · {label}: {}", crate::utils::display_path(path));
4200 }
4201
4202 let secret_backend = codewhale_secrets::diagnose_secret_backend();
4203 println!();
4204 println!("{}", "Secret Backend (structural only):".bold());
4205 for line in crate::doctor::secret_backend_human_lines(&secret_backend) {
4206 println!(" · {line}");
4207 }
4208
4209 println!();
4211 println!("{}", "State Root:".bold());
4212 let (code_home, legacy_home) = doctor_state_roots();
4213 let active_root = if code_home.exists() {
4214 &code_home
4215 } else if legacy_home.exists() {
4216 &legacy_home
4217 } else {
4218 &code_home
4219 };
4220 println!(" active: {}", crate::utils::display_path(active_root));
4221 if active_root != &code_home {
4222 println!(
4223 " note: legacy {} found; start Codewhale once to trigger safe migration where available.",
4224 crate::utils::display_path(&legacy_home)
4225 );
4226 }
4227 if legacy_home.exists() && code_home.exists() {
4228 println!(
4229 " dual roots: {} (primary) + {} (legacy)",
4230 crate::utils::display_path(&code_home),
4231 crate::utils::display_path(&legacy_home)
4232 );
4233 }
4234 let legacy_state_report = doctor_legacy_state_report(&code_home, &legacy_home);
4235 let session_recovery = doctor_session_recovery_report(
4236 &code_home,
4237 &legacy_home,
4238 codewhale_config::codewhale_home_is_explicit(),
4239 );
4240 print_doctor_legacy_state_report(
4241 &legacy_state_report,
4242 &session_recovery,
4243 (aqua_r, aqua_g, aqua_b),
4244 (sky_r, sky_g, sky_b),
4245 );
4246
4247 let (setup_state, setup_source) = doctor_setup_state(config, workspace);
4248 print_doctor_setup_report(
4249 config,
4250 workspace,
4251 &setup_state,
4252 setup_source,
4253 (aqua_r, aqua_g, aqua_b),
4254 (sky_r, sky_g, sky_b),
4255 );
4256
4257 println!();
4259 println!("{}", "API Keys:".bold());
4260
4261 for provider in crate::config::ApiProvider::all().iter().copied() {
4265 let slot = provider.as_str();
4266 let provider_config = config.provider_config_for(provider);
4267 let config_declared = provider_config.is_some_and(|entry| {
4268 entry.api_key.as_deref().is_some_and(|key| {
4269 crate::config::classify_config_api_key_value(key)
4270 == crate::config::ConfigApiKeyValueKind::Literal
4271 })
4272 }) || (matches!(provider, crate::config::ApiProvider::Deepseek)
4273 && config.api_key.as_deref().is_some_and(|key| {
4274 crate::config::classify_config_api_key_value(key)
4275 == crate::config::ConfigApiKeyValueKind::Literal
4276 }));
4277 let env_source_declared = provider_config
4278 .and_then(|entry| entry.api_key_env.as_deref())
4279 .is_some_and(|name| !name.trim().is_empty());
4280 let icon = if config_declared || env_source_declared {
4281 "·".truecolor(aqua_r, aqua_g, aqua_b)
4282 } else {
4283 "·".dimmed()
4284 };
4285 println!(
4286 " {} {slot}: env_source={}, config_source={}",
4287 icon,
4288 if env_source_declared {
4289 "declared (value not inspected)"
4290 } else {
4291 "not inspected"
4292 },
4293 if config_declared {
4294 "declared (value not inspected)"
4295 } else {
4296 "not declared"
4297 }
4298 );
4299 }
4300 println!(" · credential precedence is unchanged; doctor does not inspect credential values");
4301 println!();
4302 println!(
4303 "{}",
4304 "External credential consent (configuration only):".bold()
4305 );
4306 for line in doctor_external_credential_consent_lines(config) {
4307 println!(" {line}");
4308 }
4309
4310 println!();
4311 println!(
4312 "{}",
4313 "DeepSeek Harness integration (read-only detection):".bold()
4314 );
4315 for line in doctor_dsh_integration_lines(config, workspace) {
4316 println!(" {line}");
4317 }
4318
4319 let credential = resolve_credential_diagnostic(config);
4320 let source_label = match credential.source {
4321 ApiKeySource::ConfigDeclared => "literal config value structurally present",
4322 ApiKeySource::EnvDeclared => "environment source declared; value not inspected",
4323 ApiKeySource::ExternalAuthDeclared => {
4324 "external auth source declared; credential not resolved"
4325 }
4326 ApiKeySource::SecretStoreUnprobed => "secret store eligible; store not probed",
4327 ApiKeySource::SecretStoreUnavailable => {
4328 "secret-store sentinel declared, but this route cannot use that store"
4329 }
4330 ApiKeySource::OAuth => "OAuth route configured; token availability not probed",
4331 ApiKeySource::ExternalConsent => "external consent configured; token file not read",
4332 ApiKeySource::NoAuth => "no-auth route",
4333 ApiKeySource::LocalRuntime => "local runtime; credentials not required",
4334 ApiKeySource::Unknown => "unknown; credential environment and stores not inspected",
4335 };
4336 println!(
4337 " {} active provider credential source: {source_label}",
4338 "·".dimmed()
4339 );
4340 println!(
4341 " · active provider credential availability: {}",
4342 credential.availability.label()
4343 );
4344
4345 println!();
4347 println!("{}", "API Connectivity:".bold());
4348 let api_target = doctor_api_target(config);
4349 println!(
4353 " · 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)"
4354 );
4355 println!(" · provider: {}", api_target.provider);
4356 println!(
4357 " · base_url: {}",
4358 crate::doctor::structural_url_authority(&api_target.base_url)
4359 );
4360 match api_target.resolution {
4361 DoctorModelResolution::Resolved => {
4362 println!(" · model: {} (resolved)", api_target.model);
4363 }
4364 DoctorModelResolution::ConfiguredOnly => {
4365 println!(
4366 " · model: {} (configured; route resolution unavailable)",
4367 api_target.model
4368 );
4369 }
4370 }
4371 let tls_status = doctor_tls_status(config);
4372 if !tls_status.certificate_verification {
4373 println!(" ! {}", tls_status.message);
4374 println!(" Prefer SSL_CERT_FILE with a trusted custom CA bundle when possible.");
4375 }
4376 let strict_tool_mode = doctor_strict_tool_mode_status(config);
4377 let strict_icon = match strict_tool_mode.status {
4378 "ready" => "✓".truecolor(aqua_r, aqua_g, aqua_b),
4379 "fallback_non_beta" | "custom_endpoint" => "!".truecolor(sky_r, sky_g, sky_b),
4380 _ => "·".dimmed(),
4381 };
4382 println!(
4383 " {} strict_tool_mode: {}",
4384 strict_icon, strict_tool_mode.message
4385 );
4386 if let Some(recommended) = strict_tool_mode.recommended_base_url.as_deref() {
4387 println!(
4388 " Use the {} endpoint for DeepSeek strict schemas.",
4389 crate::doctor::structural_url_authority(recommended)
4390 );
4391 }
4392 let capability = crate::config::provider_capability(config.api_provider(), &api_target.model);
4393 if let Some(alias) = capability.alias_deprecation.as_ref() {
4394 println!(
4395 " ! model alias {} retires {}; switch to {}",
4396 alias.alias, alias.retirement_date, alias.replacement
4397 );
4398 }
4399 let live_api_requested =
4400 doctor_should_probe_api(config.api_provider(), &api_target.base_url, probes);
4401 let endpoint_is_local = crate::config::provider_route_is_keyless_self_hosted(
4402 config.api_provider(),
4403 &api_target.base_url,
4404 ) || crate::config::base_url_uses_local_host(&api_target.base_url);
4405 if doctor_should_probe_auth(config) && live_api_requested {
4406 print!(" {} Testing connection...", "·".dimmed());
4407 use std::io::Write;
4408 std::io::stdout().flush().ok();
4409
4410 let connectivity_result = match config.with_read_only_api_key_for_diagnostic() {
4415 Ok(diagnostic_config) => test_api_connectivity(&diagnostic_config).await,
4416 Err(error) => Err(error),
4417 };
4418 match connectivity_result {
4419 Ok(()) => {
4420 println!(
4421 "\r {} API connection successful",
4422 "✓".truecolor(aqua_r, aqua_g, aqua_b)
4423 );
4424 }
4425 Err(e) => {
4426 let error_msg = e.to_string();
4427 println!(
4428 "\r {} API connection failed",
4429 "✗".truecolor(red_r, red_g, red_b)
4430 );
4431 if error_msg.contains("401") || error_msg.contains("Unauthorized") {
4432 println!(
4433 " Invalid API key. Check `codewhale auth status`, DEEPSEEK_API_KEY, or config.toml"
4434 );
4435 } else if error_msg.contains("403") || error_msg.contains("Forbidden") {
4436 println!(
4437 " API key lacks permissions. Verify key is active at platform.deepseek.com"
4438 );
4439 } else if error_msg.contains("timeout") || error_msg.contains("Timeout") {
4440 for line in doctor_timeout_recovery_lines(config) {
4441 println!(" {line}");
4442 }
4443 } else if error_msg.contains("dns") || error_msg.contains("resolve") {
4444 println!(" DNS resolution failed. Check your network connection");
4445 } else if error_msg.contains("connect") {
4446 println!(" Connection failed. Check firewall settings or try again");
4447 } else if crate::doctor::is_keyless_ds4_route(config) {
4448 println!(" {error_msg}");
4449 } else {
4450 println!(
4451 " Error details omitted because provider failures can contain credential material."
4452 );
4453 }
4454 }
4455 }
4456 } else if !doctor_should_probe_auth(config) {
4457 println!(
4458 " {} Live OAuth connectivity not checked by non-mutating doctor",
4459 "·".dimmed()
4460 );
4461 println!(
4462 " Doctor never refreshes or rewrites credentials; exercise the route with a normal request."
4463 );
4464 } else {
4465 if endpoint_is_local {
4466 println!(
4467 " {} Live connectivity not checked for this local endpoint",
4468 "·".dimmed()
4469 );
4470 println!(
4471 " Run `codewhale doctor --probe-local` to opt in; the request may start a local service."
4472 );
4473 } else {
4474 println!(
4475 " {} Live hosted connectivity not checked (offline default)",
4476 "·".dimmed()
4477 );
4478 println!(" Run `codewhale doctor --probe-api` to opt in.");
4479 }
4480 }
4481
4482 println!();
4484 println!("{}", "MCP Servers (configuration only):".bold());
4485 println!(" · Static check only; no server process was started.");
4486 let features = config.features();
4487 if features.enabled(Feature::Mcp) {
4488 println!(
4489 " {} MCP feature flag enabled",
4490 "✓".truecolor(aqua_r, aqua_g, aqua_b)
4491 );
4492 } else {
4493 println!(
4494 " {} MCP feature flag disabled",
4495 "!".truecolor(sky_r, sky_g, sky_b)
4496 );
4497 }
4498
4499 let mcp_config_path = config.mcp_config_path();
4500 let project_mcp_config_path = crate::mcp::workspace_mcp_config_path(workspace);
4501 if mcp_config_path.exists() {
4502 println!(
4503 " {} MCP config found at {}",
4504 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4505 crate::utils::display_path(&mcp_config_path)
4506 );
4507 } else {
4508 println!(
4509 " {} MCP config not found at {}",
4510 "·".dimmed(),
4511 crate::utils::display_path(&mcp_config_path)
4512 );
4513 }
4514 if project_mcp_config_path.exists() {
4515 println!(
4516 " {} Project MCP config found at {}",
4517 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4518 crate::utils::display_path(&project_mcp_config_path)
4519 );
4520 } else {
4521 println!(
4522 " {} Project MCP config not found at {}",
4523 "·".dimmed(),
4524 crate::utils::display_path(&project_mcp_config_path)
4525 );
4526 }
4527
4528 match crate::mcp::load_config_with_workspace_and_plugins(&mcp_config_path, workspace, plugins) {
4529 Ok(cfg) if cfg.servers.is_empty() => {
4530 println!(" {} 0 merged server(s) configured", "·".dimmed());
4531 if !mcp_config_path.exists() && !project_mcp_config_path.exists() {
4532 println!(" Run `codewhale mcp init` or add `.codewhale/mcp.json`.");
4533 }
4534 }
4535 Ok(cfg) => {
4536 println!(
4537 " {} {} merged server(s) configured",
4538 "·".dimmed(),
4539 cfg.servers.len()
4540 );
4541 for (name, server) in &cfg.servers {
4542 let status = doctor_check_mcp_server(server);
4543 let icon = match &status {
4544 McpServerDoctorStatus::Ok(detail) => {
4545 format!(
4546 " {} {name}: configuration valid; {}",
4547 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4548 detail
4549 )
4550 }
4551 McpServerDoctorStatus::Warning(detail) => {
4552 format!(
4553 " {} {name}: configuration warning; {}",
4554 "!".truecolor(sky_r, sky_g, sky_b),
4555 detail
4556 )
4557 }
4558 McpServerDoctorStatus::Error(detail) => {
4559 format!(
4560 " {} {name}: configuration invalid; {}",
4561 "✗".truecolor(red_r, red_g, red_b),
4562 detail
4563 )
4564 }
4565 };
4566 println!("{icon}");
4567 if !server.is_enabled() {
4568 println!(" disabled; live health not checked");
4569 } else {
4570 println!(
4571 " process/protocol/backend: not checked; `codewhale mcp validate` explicitly starts and initializes configured servers"
4572 );
4573 }
4574 }
4575 if probes.should_probe_mcp() {
4576 println!();
4577 println!(
4578 " {} Live MCP probe enabled: starting enabled servers; backend tool health remains untested.",
4579 "!".truecolor(sky_r, sky_g, sky_b)
4580 );
4581 match crate::mcp::McpPool::from_config_path_with_workspace_and_plugins(
4582 &mcp_config_path,
4583 workspace,
4584 std::sync::Arc::new(plugins.clone()),
4585 ) {
4586 Ok(mut pool) => {
4587 let errors = pool.connect_all().await;
4588 let failed = errors
4589 .iter()
4590 .map(|(name, _)| name.as_str())
4591 .collect::<std::collections::BTreeSet<_>>();
4592 for (name, server) in &cfg.servers {
4593 if !server.is_enabled() {
4594 continue;
4595 }
4596 if failed.contains(name.as_str()) {
4597 println!(
4598 " {} {name}: process/protocol unreachable; error details omitted",
4599 "✗".truecolor(red_r, red_g, red_b)
4600 );
4601 } else {
4602 println!(
4603 " {} {name}: process reachable and protocol initialized; backend tool health not checked",
4604 "✓".truecolor(aqua_r, aqua_g, aqua_b)
4605 );
4606 }
4607 }
4608 }
4609 Err(_) => println!(
4610 " {} live MCP probe could not load merged configuration; details omitted",
4611 "✗".truecolor(red_r, red_g, red_b)
4612 ),
4613 }
4614 } else {
4615 println!(
4616 " Use codewhale doctor --probe-mcp to opt in to live process/protocol checks; it may start configured servers."
4617 );
4618 }
4619 }
4620 Err(_) => {
4621 println!(
4622 " {} MCP configuration could not be loaded; details omitted",
4623 "✗".truecolor(red_r, red_g, red_b)
4624 );
4625 }
4626 }
4627
4628 println!();
4630 println!("{}", "Skills:".bold());
4631 let global_skills_dir = config.skills_dir();
4632 let agents_skills_dir = workspace.join(".agents").join("skills");
4633 let local_skills_dir = workspace.join("skills");
4634 let agents_global_skills_dir = crate::skills::agents_global_skills_dir();
4635 let opencode_skills_dir = workspace.join(".opencode").join("skills");
4641 let claude_skills_dir = workspace.join(".claude").join("skills");
4642 let selected_skills_dir = if agents_skills_dir.exists() {
4643 agents_skills_dir.clone()
4644 } else if local_skills_dir.exists() {
4645 local_skills_dir.clone()
4646 } else if config.skills_dir.is_none()
4647 && let Some(global_agents) = agents_global_skills_dir.as_ref()
4648 && global_agents.exists()
4649 {
4650 global_agents.clone()
4651 } else {
4652 global_skills_dir.clone()
4653 };
4654
4655 let describe_dir = |dir: &Path| -> usize {
4656 std::fs::read_dir(dir)
4657 .map(|entries| entries.filter_map(std::result::Result::ok).count())
4658 .unwrap_or(0)
4659 };
4660
4661 if local_skills_dir.exists() {
4662 println!(
4663 " {} local skills dir found at {} ({} items)",
4664 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4665 crate::utils::display_path(&local_skills_dir),
4666 describe_dir(&local_skills_dir)
4667 );
4668 } else {
4669 println!(
4670 " {} local skills dir not found at {}",
4671 "·".dimmed(),
4672 crate::utils::display_path(&local_skills_dir)
4673 );
4674 }
4675
4676 if agents_skills_dir.exists() {
4677 println!(
4678 " {} .agents skills dir found at {} ({} items)",
4679 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4680 crate::utils::display_path(&agents_skills_dir),
4681 describe_dir(&agents_skills_dir)
4682 );
4683 } else {
4684 println!(
4685 " {} .agents skills dir not found at {}",
4686 "·".dimmed(),
4687 crate::utils::display_path(&agents_skills_dir)
4688 );
4689 }
4690
4691 if let Some(agents_global_skills_dir) = agents_global_skills_dir.as_ref() {
4692 if agents_global_skills_dir.exists() {
4693 println!(
4694 " {} global .agents skills dir found at {} ({} items)",
4695 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4696 crate::utils::display_path(agents_global_skills_dir),
4697 describe_dir(agents_global_skills_dir)
4698 );
4699 } else {
4700 println!(
4701 " {} global .agents skills dir not found at {}",
4702 "·".dimmed(),
4703 crate::utils::display_path(agents_global_skills_dir)
4704 );
4705 }
4706 }
4707
4708 if global_skills_dir.exists() {
4709 println!(
4710 " {} global skills dir found at {} ({} items)",
4711 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4712 crate::utils::display_path(&global_skills_dir),
4713 describe_dir(&global_skills_dir)
4714 );
4715 } else {
4716 println!(
4717 " {} global skills dir not found at {}",
4718 "·".dimmed(),
4719 crate::utils::display_path(&global_skills_dir)
4720 );
4721 }
4722
4723 if opencode_skills_dir.exists() {
4727 println!(
4728 " {} .opencode skills dir found at {} ({} items)",
4729 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4730 crate::utils::display_path(&opencode_skills_dir),
4731 describe_dir(&opencode_skills_dir)
4732 );
4733 }
4734 if claude_skills_dir.exists() {
4735 println!(
4736 " {} .claude skills dir found at {} ({} items)",
4737 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4738 crate::utils::display_path(&claude_skills_dir),
4739 describe_dir(&claude_skills_dir)
4740 );
4741 }
4742
4743 println!(
4744 " {} selected skills dir: {}",
4745 "·".dimmed(),
4746 crate::utils::display_path(&selected_skills_dir)
4747 );
4748 if !agents_skills_dir.exists()
4749 && !local_skills_dir.exists()
4750 && !agents_global_skills_dir
4751 .as_ref()
4752 .is_some_and(|dir| dir.exists())
4753 && !global_skills_dir.exists()
4754 {
4755 println!(" Run `codewhale setup --skills` (or add --local for ./skills).");
4756 }
4757
4758 println!();
4760 println!("{}", "Tools:".bold());
4761 let tools_dir = default_tools_dir();
4762 if tools_dir.exists() {
4763 let count = count_dir_entries(&tools_dir);
4764 println!(
4765 " {} tools dir found at {} ({} items)",
4766 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4767 crate::utils::display_path(&tools_dir),
4768 count
4769 );
4770 } else {
4771 println!(
4772 " {} tools dir not found at {}",
4773 "·".dimmed(),
4774 crate::utils::display_path(&tools_dir)
4775 );
4776 println!(" Run `codewhale setup --tools` to scaffold a starter dir.");
4777 }
4778
4779 println!();
4781 println!("{}", "Plugins:".bold());
4782 let plugins_dir = default_plugins_dir();
4783 if plugins_dir.exists() {
4784 let count = count_dir_entries(&plugins_dir);
4785 println!(
4786 " {} plugins dir found at {} ({} items)",
4787 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4788 crate::utils::display_path(&plugins_dir),
4789 count
4790 );
4791 } else {
4792 println!(
4793 " {} plugins dir not found at {}",
4794 "·".dimmed(),
4795 crate::utils::display_path(&plugins_dir)
4796 );
4797 println!(" Run `codewhale setup --plugins` to scaffold a starter dir.");
4798 }
4799
4800 println!();
4802 println!("{}", "Storage:".bold());
4803 if let Some(spillover_root) = crate::tools::truncate::spillover_root() {
4804 let (present, count) = if spillover_root.is_dir() {
4805 (true, count_dir_entries(&spillover_root))
4806 } else {
4807 (false, 0)
4808 };
4809 if present {
4810 println!(
4811 " {} tool-output spillover at {} ({} file{})",
4812 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4813 crate::utils::display_path(&spillover_root),
4814 count,
4815 if count == 1 { "" } else { "s" }
4816 );
4817 } else {
4818 println!(
4819 " {} tool-output spillover dir not yet created at {}",
4820 "·".dimmed(),
4821 crate::utils::display_path(&spillover_root)
4822 );
4823 }
4824 }
4825 let stash = crate::composer_stash::diagnostic_stash_report();
4826 if let Some(stash_path) = stash.path.as_ref() {
4827 if let Some(error) = stash.error.as_deref() {
4828 println!(
4829 " {} composer stash was not inspected at {}: {error}",
4830 "!".truecolor(sky_r, sky_g, sky_b),
4831 crate::utils::display_path(stash_path),
4832 );
4833 } else if stash.present {
4834 println!(
4835 " {} composer stash at {} ({} parked draft{})",
4836 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4837 crate::utils::display_path(stash_path),
4838 stash.count,
4839 if stash.count == 1 { "" } else { "s" }
4840 );
4841 } else {
4842 println!(
4843 " {} composer stash empty (Ctrl+G or Ctrl+S in the composer to park a draft)",
4844 "·".dimmed()
4845 );
4846 }
4847 } else if let Some(error) = stash.error.as_deref() {
4848 println!(
4849 " {} composer stash was not inspected: {error}",
4850 "!".truecolor(sky_r, sky_g, sky_b),
4851 );
4852 }
4853
4854 println!();
4859 println!("{}", "Tool Dependencies:".bold());
4860
4861 match crate::dependencies::resolve_python_interpreter() {
4862 Some(name) => println!(
4863 " {} Python: {} → code_execution tool registered",
4864 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4865 name
4866 ),
4867 None => {
4868 println!(
4869 " {} Python: not found (tried {:?})",
4870 "✗".truecolor(red_r, red_g, red_b),
4871 crate::dependencies::PYTHON_CANDIDATES,
4872 );
4873 println!(" code_execution tool is NOT advertised to the model on this install.");
4874 println!(" Install Python 3 and ensure one of those names is on PATH:");
4875 match std::env::consts::OS {
4876 "macos" => {
4877 println!(" brew install python@3.12 (or download from python.org)")
4878 }
4879 "linux" => println!(
4880 " sudo apt install python3 (Debian/Ubuntu) — or your distro's equivalent"
4881 ),
4882 "windows" => {
4883 println!(" winget install Python.Python.3 (or download from python.org)")
4884 }
4885 other => println!(" install Python 3 for {other} from python.org"),
4886 }
4887 }
4888 }
4889
4890 match crate::dependencies::resolve_node() {
4891 Some(_) => println!(
4892 " {} Node.js: present → js_execution tool registered",
4893 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4894 ),
4895 None => {
4896 println!(
4897 " {} Node.js: not found (tried `node`)",
4898 "✗".truecolor(red_r, red_g, red_b),
4899 );
4900 println!(" js_execution tool is NOT advertised to the model on this install.");
4901 println!(" Install Node 18+ and ensure `node` is on PATH:");
4902 match std::env::consts::OS {
4903 "macos" => println!(" brew install node (or download from nodejs.org)"),
4904 "linux" => println!(
4905 " sudo apt install nodejs (Debian/Ubuntu) — or your distro's equivalent"
4906 ),
4907 "windows" => {
4908 println!(" winget install OpenJS.NodeJS (or download from nodejs.org)")
4909 }
4910 other => println!(" install Node.js for {other} from nodejs.org"),
4911 }
4912 }
4913 }
4914
4915 match crate::dependencies::resolve_pandoc() {
4916 Some(_) => println!(
4917 " {} pandoc: present → pandoc_convert tool registered",
4918 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4919 ),
4920 None => {
4921 println!(" {} pandoc: not found (optional)", "·".dimmed(),);
4922 println!(
4923 " pandoc_convert tool is NOT advertised to the model. Install pandoc to enable:"
4924 );
4925 match std::env::consts::OS {
4926 "macos" => println!(" brew install pandoc"),
4927 "linux" => println!(
4928 " sudo apt install pandoc (Debian/Ubuntu) — or your distro's equivalent"
4929 ),
4930 "windows" => {
4931 println!(" winget install JohnMacFarlane.Pandoc")
4932 }
4933 other => println!(" install pandoc for {other} from pandoc.org"),
4934 }
4935 }
4936 }
4937
4938 match crate::dependencies::resolve_tesseract() {
4939 Some(_) => {
4940 if cfg!(target_os = "macos") {
4941 println!(
4942 " {} OCR: macOS Vision + tesseract available → image_ocr/read_file screenshot OCR enabled",
4943 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4944 );
4945 } else {
4946 println!(
4947 " {} tesseract: present → image_ocr/read_file screenshot OCR enabled",
4948 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4949 );
4950 }
4951 }
4952 None => {
4953 if cfg!(target_os = "macos") {
4954 println!(
4955 " {} OCR: macOS Vision available → image_ocr/read_file screenshot OCR enabled",
4956 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4957 );
4958 println!(
4959 " tesseract not found (optional; install only for alternate OCR packs)."
4960 );
4961 } else {
4962 println!(" {} tesseract: not found (optional)", "·".dimmed(),);
4963 println!(
4964 " image_ocr tool is NOT advertised to the model. Install tesseract to enable:"
4965 );
4966 match std::env::consts::OS {
4967 "macos" => println!(" brew install tesseract"),
4968 "linux" => println!(
4969 " sudo apt install tesseract-ocr (Debian/Ubuntu) — or your distro's equivalent"
4970 ),
4971 "windows" => println!(" winget install UB-Mannheim.TesseractOCR"),
4972 other => {
4973 println!(" install tesseract for {other} from tesseract-ocr.github.io")
4974 }
4975 }
4976 }
4977 }
4978 }
4979
4980 match crate::dependencies::resolve_pdftotext() {
4984 Some(_) => println!(
4985 " {} pdftotext: available → PDF text extraction enabled",
4986 "✓".truecolor(aqua_r, aqua_g, aqua_b),
4987 ),
4988 None => {
4989 println!(
4990 " {} pdftotext: not found (optional; PDF text reads fail as `binary_unavailable`)",
4991 "·".dimmed(),
4992 );
4993 match std::env::consts::OS {
4994 "macos" => println!(" Install via: brew install poppler"),
4995 "linux" => {
4996 println!(" Install via: sudo apt install poppler-utils (Debian/Ubuntu)")
4997 }
4998 "windows" => println!(
4999 " Install Poppler for Windows from https://blog.alivate.com.au/poppler-windows/"
5000 ),
5001 _ => {}
5002 }
5003 }
5004 }
5005
5006 println!();
5010 println!("{}", "Terminal Quirks:".bold());
5011 let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default();
5012 let term_program_lc = term_program.to_ascii_lowercase();
5013 let mut any_quirk = false;
5014 if matches!(term_program.as_str(), "vscode" | "ghostty") {
5015 println!(
5016 " {} TERM_PROGRAM={} → low_motion + fancy_animations=false (auto)",
5017 "•".truecolor(sky_r, sky_g, sky_b),
5018 term_program
5019 );
5020 any_quirk = true;
5021 }
5022 if term_program == "Termius"
5023 || std::env::var_os("SSH_CLIENT").is_some_and(|v| !v.is_empty())
5024 || std::env::var_os("SSH_TTY").is_some_and(|v| !v.is_empty())
5025 {
5026 println!(
5027 " {} SSH/Termius session → low_motion + fancy_animations=false (auto, #1433)",
5028 "•".truecolor(sky_r, sky_g, sky_b)
5029 );
5030 any_quirk = true;
5031 }
5032 if term_program_lc.contains("ptyxis")
5033 || std::env::var_os("PTYXIS_VERSION").is_some_and(|v| !v.is_empty())
5034 {
5035 println!(
5036 " {} Ptyxis detected → synchronized_output=off (auto, v0.8.31)",
5037 "•".truecolor(sky_r, sky_g, sky_b)
5038 );
5039 any_quirk = true;
5040 }
5041 if crate::settings::detected_legacy_windows_console_host() {
5042 println!(
5043 " {} legacy Windows console host → low_motion + fancy_animations=false + bracketed_paste=false + synchronized_output=off (auto)",
5044 "•".truecolor(sky_r, sky_g, sky_b)
5045 );
5046 any_quirk = true;
5047 }
5048 if !any_quirk {
5049 println!(
5050 " {} no env-driven terminal-quirk overrides active",
5051 "·".dimmed()
5052 );
5053 }
5054
5055 println!();
5057 println!("{}", "Platform:".bold());
5058 println!(" OS: {}", std::env::consts::OS);
5059 println!(" Arch: {}", std::env::consts::ARCH);
5060
5061 let sandbox = crate::sandbox::get_platform_sandbox_with_bwrap_preference(
5062 config.prefer_bwrap.unwrap_or(false),
5063 );
5064 if let Some(kind) = sandbox {
5065 println!(
5066 " {} sandbox available: {}",
5067 "✓".truecolor(aqua_r, aqua_g, aqua_b),
5068 kind
5069 );
5070 } else {
5071 println!(
5072 " {} sandbox not available (commands run best-effort)",
5073 "!".truecolor(sky_r, sky_g, sky_b)
5074 );
5075 }
5076
5077 println!();
5078 println!(
5079 "{}",
5080 "All checks complete!"
5081 .truecolor(aqua_r, aqua_g, aqua_b)
5082 .bold()
5083 );
5084}
5085
5086const DOCTOR_LEGACY_STATE_ITEMS: &[&str] = &[
5087 "sessions",
5088 "tasks",
5089 "skills",
5090 "slop_ledger",
5091 "trophies",
5092 "catalog",
5093 "review-receipts",
5094 "config.toml",
5095 "settings.toml",
5096 "mcp.json",
5097];
5098const DOCTOR_SESSION_RECOVERY_HUMAN_SAMPLE_LIMIT: usize = 20;
5099const DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT: usize = 100;
5100
5101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5102enum DoctorLegacyStateStatus {
5103 PrimaryOnly,
5104 LegacyOnly,
5105 Both,
5106 Absent,
5107}
5108
5109impl DoctorLegacyStateStatus {
5110 fn as_str(self) -> &'static str {
5111 match self {
5112 Self::PrimaryOnly => "primary_only",
5113 Self::LegacyOnly => "legacy_only",
5114 Self::Both => "both",
5115 Self::Absent => "absent",
5116 }
5117 }
5118}
5119
5120#[derive(Debug, Clone)]
5121struct DoctorLegacyStateEntry {
5122 name: &'static str,
5123 primary_path: PathBuf,
5124 legacy_path: PathBuf,
5125 primary_present: bool,
5126 legacy_present: bool,
5127 status: DoctorLegacyStateStatus,
5128}
5129
5130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5131enum DoctorSessionRecoveryStatus {
5132 Isolated,
5133 NoLegacySessions,
5134 MigrationPending,
5135 MigrationIncomplete,
5136 MigrationComplete,
5137 ScanFailed,
5138}
5139
5140impl DoctorSessionRecoveryStatus {
5141 fn as_str(self) -> &'static str {
5142 match self {
5143 Self::Isolated => "isolated",
5144 Self::NoLegacySessions => "no_legacy_sessions",
5145 Self::MigrationPending => "migration_pending",
5146 Self::MigrationIncomplete => "migration_incomplete",
5147 Self::MigrationComplete => "migration_complete",
5148 Self::ScanFailed => "scan_failed",
5149 }
5150 }
5151}
5152
5153#[derive(Debug, Clone)]
5154struct DoctorRecoverableSessionEntry {
5155 name: PathBuf,
5156 source_path: PathBuf,
5157 destination_path: PathBuf,
5158}
5159
5160#[derive(Debug, Clone)]
5161struct DoctorSessionRecoveryReport {
5162 status: DoctorSessionRecoveryStatus,
5163 primary_sessions_path: PathBuf,
5164 legacy_sessions_path: PathBuf,
5165 codewhale_home_is_explicit: bool,
5166 legacy_session_file_count: usize,
5167 already_present_file_count: usize,
5168 recoverable_file_count: usize,
5169 recoverable: Vec<DoctorRecoverableSessionEntry>,
5171 error: Option<String>,
5172}
5173
5174impl DoctorSessionRecoveryReport {
5175 fn needs_attention(&self) -> bool {
5176 matches!(
5177 self.status,
5178 DoctorSessionRecoveryStatus::MigrationPending
5179 | DoctorSessionRecoveryStatus::MigrationIncomplete
5180 | DoctorSessionRecoveryStatus::ScanFailed
5181 )
5182 }
5183}
5184
5185fn doctor_legacy_state_status(
5186 primary_present: bool,
5187 legacy_present: bool,
5188) -> DoctorLegacyStateStatus {
5189 match (primary_present, legacy_present) {
5190 (true, false) => DoctorLegacyStateStatus::PrimaryOnly,
5191 (false, true) => DoctorLegacyStateStatus::LegacyOnly,
5192 (true, true) => DoctorLegacyStateStatus::Both,
5193 (false, false) => DoctorLegacyStateStatus::Absent,
5194 }
5195}
5196
5197fn doctor_state_roots() -> (PathBuf, PathBuf) {
5198 let code_home =
5199 codewhale_config::codewhale_home().unwrap_or_else(|_| PathBuf::from("~/.codewhale"));
5200 let legacy_home = if codewhale_config::codewhale_home_is_explicit() {
5201 code_home.join(codewhale_config::LEGACY_APP_DIR)
5202 } else {
5203 codewhale_config::legacy_deepseek_home().unwrap_or_else(|_| PathBuf::from("~/.deepseek"))
5204 };
5205 (code_home, legacy_home)
5206}
5207
5208fn doctor_legacy_state_report(
5209 primary_root: &Path,
5210 legacy_root: &Path,
5211) -> Vec<DoctorLegacyStateEntry> {
5212 DOCTOR_LEGACY_STATE_ITEMS
5213 .iter()
5214 .copied()
5215 .map(|name| {
5216 let primary_path = primary_root.join(name);
5217 let legacy_path = legacy_root.join(name);
5218 let primary_present = primary_path.exists();
5219 let legacy_present = legacy_path.exists();
5220 let status = doctor_legacy_state_status(primary_present, legacy_present);
5221 DoctorLegacyStateEntry {
5222 name,
5223 primary_path,
5224 legacy_path,
5225 primary_present,
5226 legacy_present,
5227 status,
5228 }
5229 })
5230 .collect()
5231}
5232
5233fn doctor_session_recovery_report(
5245 primary_root: &Path,
5246 legacy_root: &Path,
5247 codewhale_home_is_explicit: bool,
5248) -> DoctorSessionRecoveryReport {
5249 let primary_sessions_path = primary_root.join("sessions");
5250 let legacy_sessions_path = legacy_root.join("sessions");
5251 let mut report = DoctorSessionRecoveryReport {
5252 status: DoctorSessionRecoveryStatus::NoLegacySessions,
5253 primary_sessions_path,
5254 legacy_sessions_path,
5255 codewhale_home_is_explicit,
5256 legacy_session_file_count: 0,
5257 already_present_file_count: 0,
5258 recoverable_file_count: 0,
5259 recoverable: Vec::new(),
5260 error: None,
5261 };
5262
5263 if codewhale_home_is_explicit {
5264 report.status = DoctorSessionRecoveryStatus::Isolated;
5265 return report;
5266 }
5267
5268 let legacy_root_is_present =
5269 match doctor_session_directory_is_safe(legacy_root, "legacy state root") {
5270 Ok(present) => present,
5271 Err(error) => {
5272 report.status = DoctorSessionRecoveryStatus::ScanFailed;
5273 report.error = Some(error);
5274 return report;
5275 }
5276 };
5277 if !legacy_root_is_present {
5278 return report;
5279 }
5280 if let Err(error) = doctor_session_directory_is_safe(primary_root, "primary state root") {
5281 report.status = DoctorSessionRecoveryStatus::ScanFailed;
5282 report.error = Some(error);
5283 return report;
5284 }
5285
5286 let legacy_sessions_are_present = match doctor_session_directory_is_safe(
5287 &report.legacy_sessions_path,
5288 "legacy sessions root",
5289 ) {
5290 Ok(present) => present,
5291 Err(error) => {
5292 report.status = DoctorSessionRecoveryStatus::ScanFailed;
5293 report.error = Some(error);
5294 return report;
5295 }
5296 };
5297 if !legacy_sessions_are_present {
5298 return report;
5299 }
5300 let primary_sessions_are_present = match doctor_session_directory_is_safe(
5301 &report.primary_sessions_path,
5302 "primary sessions root",
5303 ) {
5304 Ok(present) => present,
5305 Err(error) => {
5306 report.status = DoctorSessionRecoveryStatus::ScanFailed;
5307 report.error = Some(error);
5308 return report;
5309 }
5310 };
5311
5312 let entries = match std::fs::read_dir(&report.legacy_sessions_path) {
5313 Ok(entries) => entries,
5314 Err(err) => {
5315 report.status = DoctorSessionRecoveryStatus::ScanFailed;
5316 report.error = Some(format!(
5317 "could not inspect legacy session filenames at {}: {err}",
5318 crate::utils::display_path(&report.legacy_sessions_path)
5319 ));
5320 return report;
5321 }
5322 };
5323
5324 for entry in entries {
5325 let entry = match entry {
5326 Ok(entry) => entry,
5327 Err(err) => {
5328 report.status = DoctorSessionRecoveryStatus::ScanFailed;
5329 report.error = Some(format!(
5330 "could not inspect an entry under {}: {err}",
5331 crate::utils::display_path(&report.legacy_sessions_path)
5332 ));
5333 return report;
5334 }
5335 };
5336 let file_type = match entry.file_type() {
5337 Ok(file_type) => file_type,
5338 Err(err) => {
5339 report.status = DoctorSessionRecoveryStatus::ScanFailed;
5340 report.error = Some(format!(
5341 "could not inspect legacy session entry metadata under {}: {err}",
5342 crate::utils::display_path(&report.legacy_sessions_path)
5343 ));
5344 return report;
5345 }
5346 };
5347 if !file_type.is_file() || entry.path().extension().is_none_or(|ext| ext != "json") {
5348 continue;
5349 }
5350
5351 report.legacy_session_file_count += 1;
5352 let name = PathBuf::from(entry.file_name());
5353 let destination_path = report.primary_sessions_path.join(&name);
5354 match std::fs::symlink_metadata(&destination_path) {
5355 Ok(metadata) if metadata.file_type().is_file() => {
5356 report.already_present_file_count += 1;
5357 }
5358 Ok(metadata) => {
5359 report.status = DoctorSessionRecoveryStatus::ScanFailed;
5360 let shape = if metadata.file_type().is_symlink() {
5361 "destination session entry is a symlink"
5362 } else {
5363 "destination session entry is not a regular file"
5364 };
5365 report.error = Some(format!(
5366 "could not inspect destination session metadata at {}: {shape}",
5367 crate::utils::display_path(&destination_path)
5368 ));
5369 return report;
5370 }
5371 Err(err) if err.kind() == io::ErrorKind::NotFound => {
5372 report.recoverable_file_count += 1;
5373 record_doctor_recoverable_session(
5374 &mut report.recoverable,
5375 DoctorRecoverableSessionEntry {
5376 source_path: entry.path(),
5377 destination_path,
5378 name,
5379 },
5380 );
5381 }
5382 Err(err) => {
5383 report.status = DoctorSessionRecoveryStatus::ScanFailed;
5384 report.error = Some(format!(
5385 "could not inspect destination metadata at {}: {err}",
5386 crate::utils::display_path(&destination_path)
5387 ));
5388 return report;
5389 }
5390 }
5391 }
5392
5393 report.status = if report.legacy_session_file_count == 0 {
5394 DoctorSessionRecoveryStatus::NoLegacySessions
5395 } else if report.recoverable_file_count == 0 {
5396 DoctorSessionRecoveryStatus::MigrationComplete
5397 } else if primary_sessions_are_present {
5398 DoctorSessionRecoveryStatus::MigrationIncomplete
5399 } else {
5400 DoctorSessionRecoveryStatus::MigrationPending
5401 };
5402 report
5403}
5404
5405fn doctor_session_directory_is_safe(path: &Path, label: &str) -> std::result::Result<bool, String> {
5413 let metadata = match std::fs::symlink_metadata(path) {
5414 Ok(metadata) => metadata,
5415 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false),
5416 Err(error) => {
5417 return Err(format!(
5418 "could not inspect {label} at {}: {error}",
5419 crate::utils::display_path(path)
5420 ));
5421 }
5422 };
5423 if metadata.file_type().is_symlink() {
5424 return Err(format!(
5425 "could not inspect {label} at {}: path is a symlink",
5426 crate::utils::display_path(path)
5427 ));
5428 }
5429 if !metadata.file_type().is_dir() {
5430 return Err(format!(
5431 "could not inspect {label} at {}: path is not a directory",
5432 crate::utils::display_path(path)
5433 ));
5434 }
5435 Ok(true)
5436}
5437
5438fn record_doctor_recoverable_session(
5442 recoverable: &mut Vec<DoctorRecoverableSessionEntry>,
5443 entry: DoctorRecoverableSessionEntry,
5444) {
5445 let insert_at = recoverable
5446 .binary_search_by(|existing| existing.name.cmp(&entry.name))
5447 .unwrap_or_else(|index| index);
5448 if recoverable.len() == DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT
5449 && insert_at == recoverable.len()
5450 {
5451 return;
5452 }
5453 recoverable.insert(insert_at, entry);
5454 if recoverable.len() > DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT {
5455 recoverable.pop();
5456 }
5457}
5458
5459fn legacy_state_needs_attention(entry: &DoctorLegacyStateEntry) -> bool {
5460 entry.name != "sessions"
5461 && matches!(
5462 entry.status,
5463 DoctorLegacyStateStatus::LegacyOnly | DoctorLegacyStateStatus::Both
5464 )
5465}
5466
5467fn print_doctor_legacy_state_report(
5468 report: &[DoctorLegacyStateEntry],
5469 session_recovery: &DoctorSessionRecoveryReport,
5470 ok_rgb: (u8, u8, u8),
5471 warn_rgb: (u8, u8, u8),
5472) {
5473 use colored::Colorize;
5474
5475 let attention: Vec<_> = report
5476 .iter()
5477 .filter(|entry| legacy_state_needs_attention(entry))
5478 .collect();
5479 if attention.is_empty()
5480 && !session_recovery.needs_attention()
5481 && session_recovery.status != DoctorSessionRecoveryStatus::Isolated
5482 {
5483 println!(
5484 " {} legacy state: no known .deepseek entries need migration",
5485 "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2)
5486 );
5487 } else if !attention.is_empty() {
5488 println!(
5489 " {} legacy state needs review:",
5490 "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2)
5491 );
5492 for entry in attention {
5493 match entry.status {
5494 DoctorLegacyStateStatus::LegacyOnly => {
5495 println!(
5496 " {} {} exists but {} is missing",
5497 "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2),
5498 crate::utils::display_path(&entry.legacy_path),
5499 crate::utils::display_path(&entry.primary_path),
5500 );
5501 }
5502 DoctorLegacyStateStatus::Both => {
5503 println!(
5504 " {} {} exists alongside primary {}; legacy data may still need review",
5505 "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2),
5506 crate::utils::display_path(&entry.legacy_path),
5507 crate::utils::display_path(&entry.primary_path),
5508 );
5509 }
5510 DoctorLegacyStateStatus::PrimaryOnly | DoctorLegacyStateStatus::Absent => {}
5511 }
5512 }
5513 println!(
5514 " Start Codewhale once to trigger safe migration where available, then rerun `codewhale doctor`."
5515 );
5516 }
5517
5518 print_doctor_session_recovery_report(session_recovery, ok_rgb, warn_rgb);
5519}
5520
5521fn print_doctor_session_recovery_report(
5522 report: &DoctorSessionRecoveryReport,
5523 ok_rgb: (u8, u8, u8),
5524 warn_rgb: (u8, u8, u8),
5525) {
5526 use colored::Colorize;
5527
5528 match report.status {
5529 DoctorSessionRecoveryStatus::Isolated => {
5530 println!(
5531 " {} legacy sessions: ambient ~/.deepseek/sessions was not inspected because CODEWHALE_HOME is set",
5532 "·".dimmed()
5533 );
5534 println!(
5535 " This preserves the explicit home boundary. To inspect the default home, use a separate shell with CODEWHALE_HOME unset and rerun `codewhale doctor`."
5536 );
5537 }
5538 DoctorSessionRecoveryStatus::NoLegacySessions => {
5539 println!(
5540 " {} legacy sessions: no top-level session JSON files found",
5541 "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2)
5542 );
5543 }
5544 DoctorSessionRecoveryStatus::MigrationComplete => {
5545 println!(
5546 " {} legacy sessions: all {} filename(s) have regular-file counterparts under {}; descriptor contents were not compared and legacy originals remain preserved",
5547 "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2),
5548 report.legacy_session_file_count,
5549 crate::utils::display_path(&report.primary_sessions_path),
5550 );
5551 }
5552 DoctorSessionRecoveryStatus::MigrationPending
5553 | DoctorSessionRecoveryStatus::MigrationIncomplete => {
5554 let label = if report.status == DoctorSessionRecoveryStatus::MigrationIncomplete {
5555 "migration is incomplete"
5556 } else {
5557 "migration has not completed"
5558 };
5559 println!(
5560 " {} legacy sessions: {label}; {} recoverable file(s) are absent from {}",
5561 "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2),
5562 report.recoverable_file_count,
5563 crate::utils::display_path(&report.primary_sessions_path),
5564 );
5565 for entry in report
5566 .recoverable
5567 .iter()
5568 .take(DOCTOR_SESSION_RECOVERY_HUMAN_SAMPLE_LIMIT)
5569 {
5570 println!(
5571 " {} {} -> {}",
5572 "·".dimmed(),
5573 crate::utils::display_path(&entry.source_path),
5574 crate::utils::display_path(&entry.destination_path),
5575 );
5576 }
5577 if report.recoverable_file_count > DOCTOR_SESSION_RECOVERY_HUMAN_SAMPLE_LIMIT {
5578 println!(
5579 " · {} more filename(s); `codewhale doctor --json` includes a bounded metadata-only sample",
5580 report.recoverable_file_count - DOCTOR_SESSION_RECOVERY_HUMAN_SAMPLE_LIMIT
5581 );
5582 }
5583 println!(" Safe recovery:");
5584 println!(
5585 " 1. Back up {} and {} (if present).",
5586 crate::utils::display_path(&report.legacy_sessions_path),
5587 crate::utils::display_path(&report.primary_sessions_path),
5588 );
5589 println!(
5590 " 2. Close other Codewhale processes, then run `codewhale sessions`; migration adds only missing files, never overwrites primary files, and leaves legacy originals in place."
5591 );
5592 println!(
5593 " 3. Rerun `codewhale doctor`. If filenames remain, keep both backups and report only the listed source/destination names."
5594 );
5595 }
5596 DoctorSessionRecoveryStatus::ScanFailed => {
5597 println!(
5598 " {} legacy sessions: recovery diagnostic could not complete",
5599 "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2)
5600 );
5601 if let Some(error) = report.error.as_deref() {
5602 println!(" {error}");
5603 }
5604 println!(
5605 " Keep both session directories unchanged, back them up, fix path permissions or shape, and rerun `codewhale doctor` before attempting migration."
5606 );
5607 }
5608 }
5609 if report.status != DoctorSessionRecoveryStatus::Isolated {
5610 println!(
5611 " Doctor inspected filenames and filesystem metadata only; it did not read chat contents, traverse checkpoints, or modify session files."
5612 );
5613 }
5614}
5615
5616fn doctor_session_recovery_json(report: &DoctorSessionRecoveryReport) -> serde_json::Value {
5617 use serde_json::json;
5618
5619 let recoverable: Vec<_> = report
5620 .recoverable
5621 .iter()
5622 .take(DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT)
5623 .map(|entry| {
5624 json!({
5625 "name": entry.name.display().to_string(),
5626 "source_path": entry.source_path.display().to_string(),
5627 "destination_path": entry.destination_path.display().to_string(),
5628 })
5629 })
5630 .collect();
5631
5632 json!({
5633 "status": report.status.as_str(),
5634 "needs_attention": report.needs_attention(),
5635 "read_only": true,
5636 "chat_contents_read": false,
5637 "checkpoint_internals_scanned": false,
5638 "session_descriptors_compared": false,
5639 "counterpart_check": "top_level_filename_and_regular_file_only",
5640 "codewhale_home_is_explicit": report.codewhale_home_is_explicit,
5641 "legacy_sessions_path": report.legacy_sessions_path.display().to_string(),
5642 "primary_sessions_path": report.primary_sessions_path.display().to_string(),
5643 "legacy_session_file_count": report.legacy_session_file_count,
5644 "already_present_file_count": report.already_present_file_count,
5645 "recoverable_file_count": report.recoverable_file_count,
5646 "recoverable_files": recoverable,
5647 "recoverable_files_truncated": report.recoverable_file_count > report.recoverable.len(),
5648 "error": report.error,
5649 "recovery_command": if report.needs_attention() && report.status != DoctorSessionRecoveryStatus::ScanFailed {
5650 Some("codewhale sessions")
5651 } else {
5652 None
5653 },
5654 })
5655}
5656
5657fn doctor_legacy_state_json(
5658 primary_root: &Path,
5659 legacy_root: &Path,
5660 report: &[DoctorLegacyStateEntry],
5661 session_recovery: &DoctorSessionRecoveryReport,
5662) -> serde_json::Value {
5663 use serde_json::json;
5664
5665 let legacy_only = report
5666 .iter()
5667 .filter(|entry| entry.status == DoctorLegacyStateStatus::LegacyOnly)
5668 .count();
5669 let both = report
5670 .iter()
5671 .filter(|entry| entry.status == DoctorLegacyStateStatus::Both)
5672 .count();
5673 let entries: Vec<_> = report
5674 .iter()
5675 .map(|entry| {
5676 json!({
5677 "name": entry.name,
5678 "primary_path": entry.primary_path.display().to_string(),
5679 "legacy_path": entry.legacy_path.display().to_string(),
5680 "primary_present": entry.primary_present,
5681 "legacy_present": entry.legacy_present,
5682 "status": entry.status.as_str(),
5683 })
5684 })
5685 .collect();
5686
5687 json!({
5688 "primary_root": primary_root.display().to_string(),
5689 "legacy_root": legacy_root.display().to_string(),
5690 "needs_attention": report.iter().any(legacy_state_needs_attention) || session_recovery.needs_attention(),
5691 "legacy_only_count": legacy_only,
5692 "dual_present_count": both,
5693 "session_recovery": doctor_session_recovery_json(session_recovery),
5694 "entries": entries,
5695 })
5696}
5697
5698fn doctor_setup_state(
5699 config: &Config,
5700 workspace: &Path,
5701) -> (codewhale_config::SetupState, &'static str) {
5702 if let Ok(Some(state)) = codewhale_config::SetupState::load() {
5703 return (state, "persisted");
5704 }
5705
5706 (
5707 codewhale_config::SetupState::derive_inherited(&doctor_inherited_setup_facts(
5708 config, workspace,
5709 )),
5710 "derived",
5711 )
5712}
5713
5714fn doctor_inherited_setup_facts(
5715 config: &Config,
5716 workspace: &Path,
5717) -> codewhale_config::InheritedConfigFacts {
5718 let user_constitution = codewhale_config::UserConstitution::load().ok();
5719 let user_constitution_validity = user_constitution.as_ref().map_or(
5720 codewhale_config::ConstitutionValidity::Unknown,
5721 codewhale_config::UserConstitutionLoad::validity,
5722 );
5723 let has_user_constitution = user_constitution
5724 .as_ref()
5725 .is_some_and(|loaded| !matches!(loaded, codewhale_config::UserConstitutionLoad::Missing));
5726 let has_expert_override = codewhale_config::codewhale_home()
5727 .ok()
5728 .map(|home| home.join(Path::new(crate::prompts::CONSTITUTION_OVERRIDE_FILE)))
5729 .is_some_and(|path| path.exists());
5730
5731 codewhale_config::InheritedConfigFacts {
5732 language: None,
5733 has_provider_route: !config.default_model().trim().is_empty(),
5734 has_credentials_or_local_runtime: doctor_has_credentials_or_local_runtime(config),
5735 trust_chosen: !crate::tui::onboarding::needs_trust(workspace),
5736 has_expert_override,
5737 has_user_constitution,
5738 user_constitution_validity,
5739 }
5740}
5741
5742fn doctor_has_credentials_or_local_runtime(config: &Config) -> bool {
5743 resolve_credential_diagnostic(config)
5744 .availability
5745 .certifies_ready()
5746}
5747
5748fn print_doctor_setup_report(
5749 config: &Config,
5750 workspace: &Path,
5751 state: &codewhale_config::SetupState,
5752 source: &str,
5753 ok_rgb: (u8, u8, u8),
5754 warn_rgb: (u8, u8, u8),
5755) {
5756 use colored::Colorize;
5757
5758 let credential = resolve_credential_diagnostic(config);
5759 let first_run_ready = state.first_run_ready();
5763 let update_ready = state.update_ready(crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION);
5764 let operate_ready = state.operate_ready();
5765 let first_run_icon = if first_run_ready {
5766 "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2)
5767 } else {
5768 "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2)
5769 };
5770 let update_icon = if update_ready {
5771 "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2)
5772 } else {
5773 "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2)
5774 };
5775 let operate_icon = if operate_ready {
5776 "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2)
5777 } else {
5778 "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2)
5779 };
5780
5781 println!();
5782 println!("{}", "Setup State:".bold());
5783 println!(" · source: {source}");
5784 println!(
5785 " · credential: source={}, availability={}",
5786 doctor_api_key_source_label(credential.source),
5787 credential.availability.label()
5788 );
5789 println!(
5790 " {first_run_icon} first-run: {}",
5791 doctor_ready_label(first_run_ready)
5792 );
5793 println!(
5794 " {update_icon} update checkpoint {}: {}",
5795 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
5796 doctor_ready_label(update_ready)
5797 );
5798 println!(
5799 " {operate_icon} operate/fleet: {}",
5800 doctor_ready_label(operate_ready)
5801 );
5802 println!(
5803 " · constitution autonomy: {} (guidance only)",
5804 doctor_constitution_autonomy_preference_id()
5805 );
5806 println!(
5807 " · runtime posture: {}",
5808 doctor_runtime_posture_line(config, workspace)
5809 );
5810 let consistency = doctor_setup_consistency(state, source);
5811 if consistency["status"] == "inconsistent" {
5812 let issues = consistency["issues"]
5813 .as_array()
5814 .map(|issues| {
5815 issues
5816 .iter()
5817 .filter_map(serde_json::Value::as_str)
5818 .collect::<Vec<_>>()
5819 .join(", ")
5820 })
5821 .unwrap_or_default();
5822 println!(
5823 " {} consistency: half-applied setup detected ({issues}) — {}",
5824 "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2),
5825 consistency["repair"].as_str().unwrap_or("/setup"),
5826 );
5827 }
5828 println!(
5829 " · 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)"
5830 );
5831 for step in codewhale_config::SetupStep::ALL {
5832 let entry = state.steps.get(&step);
5833 let required = entry.is_some_and(|entry| entry.required);
5834 let version = entry.and_then(|entry| entry.version.as_deref());
5835 let result = entry.and_then(|entry| entry.result.as_deref());
5836 let required_label = if required { "required" } else { "optional" };
5837 let version_label = version.unwrap_or("unversioned");
5838 let result_label = result.unwrap_or("no result");
5839 println!(
5840 " · {}: {} ({required_label}, {version_label}, {result_label})",
5841 setup_step_id(step),
5842 setup_status_id(state.status(step))
5843 );
5844 }
5845}
5846
5847fn doctor_ready_label(ready: bool) -> &'static str {
5848 if ready { "ready" } else { "needs action" }
5849}
5850
5851fn doctor_setup_consistency(
5859 state: &codewhale_config::SetupState,
5860 source: &str,
5861) -> serde_json::Value {
5862 use serde_json::json;
5863
5864 let mut issues: Vec<&'static str> = Vec::new();
5865
5866 if source == "persisted"
5867 && matches!(
5868 state.constitution_source,
5869 codewhale_config::ConstitutionSource::UserGlobal
5870 )
5871 {
5872 match codewhale_config::UserConstitution::load() {
5873 Ok(codewhale_config::UserConstitutionLoad::Missing) => {
5874 issues.push("setup_state_points_at_missing_user_constitution");
5875 }
5876 Ok(codewhale_config::UserConstitutionLoad::Empty) => {
5877 issues.push("user_constitution_empty");
5878 }
5879 Ok(codewhale_config::UserConstitutionLoad::Invalid(_)) => {
5880 issues.push("user_constitution_invalid");
5881 }
5882 Ok(codewhale_config::UserConstitutionLoad::Unreadable(_)) | Err(_) => {
5883 issues.push("user_constitution_unreadable");
5884 }
5885 Ok(codewhale_config::UserConstitutionLoad::Loaded(_)) => {}
5886 }
5887 }
5888
5889 if doctor_home_has_stale_setup_temp_files() {
5890 issues.push("stale_setup_temp_files_in_codewhale_home");
5891 }
5892
5893 json!({
5894 "status": if issues.is_empty() { "consistent" } else { "inconsistent" },
5895 "issues": issues,
5896 "repair": "/constitution to rebuild standing law, /setup to re-run the checkpoint",
5897 })
5898}
5899
5900fn doctor_home_has_stale_setup_temp_files() -> bool {
5901 let Ok(home) = codewhale_config::codewhale_home() else {
5902 return false;
5903 };
5904 let Ok(entries) = std::fs::read_dir(&home) else {
5905 return false;
5906 };
5907 entries.flatten().any(|entry| {
5908 entry.file_name().to_string_lossy().starts_with(".tmp")
5909 && entry.file_type().is_ok_and(|kind| kind.is_file())
5910 })
5911}
5912
5913fn doctor_constitution_autonomy_preference() -> codewhale_config::AutonomyPreference {
5914 codewhale_config::UserConstitution::load()
5915 .ok()
5916 .and_then(|load| {
5917 load.constitution()
5918 .map(|constitution| constitution.autonomy_preference)
5919 })
5920 .unwrap_or(codewhale_config::AutonomyPreference::Unspecified)
5921}
5922
5923fn doctor_constitution_autonomy_preference_id() -> &'static str {
5924 autonomy_preference_id(doctor_constitution_autonomy_preference())
5925}
5926
5927fn autonomy_preference_id(preference: codewhale_config::AutonomyPreference) -> &'static str {
5928 match preference {
5929 codewhale_config::AutonomyPreference::Unspecified => "unspecified",
5930 codewhale_config::AutonomyPreference::Cautious => "cautious",
5931 codewhale_config::AutonomyPreference::Balanced => "balanced",
5932 codewhale_config::AutonomyPreference::Autonomous => "autonomous",
5933 }
5934}
5935
5936fn doctor_runtime_default_mode() -> (String, &'static str) {
5937 match crate::settings::Settings::load_read_only() {
5938 Ok(settings) => (settings.default_mode, "settings"),
5939 Err(_) => (crate::settings::Settings::default().default_mode, "default"),
5940 }
5941}
5942
5943fn doctor_runtime_permission_posture() -> (String, &'static str) {
5947 match crate::settings::Settings::load_read_only() {
5948 Ok(settings) => match settings.permission_posture {
5949 Some(posture) => (posture, "settings"),
5950 None => ("unset".to_string(), "default"),
5951 },
5952 Err(_) => ("unset".to_string(), "default"),
5953 }
5954}
5955
5956fn doctor_runtime_posture_line(config: &Config, workspace: &Path) -> String {
5957 let (default_mode, default_mode_source) = doctor_runtime_default_mode();
5958 let (permission_posture, permission_posture_source) = doctor_runtime_permission_posture();
5959 let approval = config.approval_policy.as_deref().unwrap_or("on-request");
5960 let approval_source = if config.approval_policy.is_some() {
5961 "config"
5962 } else {
5963 "default"
5964 };
5965 let allow_shell = config.interactive_allow_shell();
5966 let allow_shell_source = if config.allow_shell.is_some() {
5967 "config"
5968 } else {
5969 "interactive default"
5970 };
5971 let sandbox = config.sandbox_mode.as_deref().unwrap_or("mode-derived");
5972 let sandbox_source = if config.sandbox_mode.is_some() {
5973 "config"
5974 } else {
5975 "default"
5976 };
5977 let network = config
5978 .network
5979 .as_ref()
5980 .map_or("prompt", |policy| policy.default.as_str());
5981 let network_source = if config.network.is_some() {
5982 "config"
5983 } else {
5984 "default"
5985 };
5986 let trust = if crate::tui::onboarding::needs_trust(workspace) {
5987 "workspace not elevated"
5988 } else {
5989 "workspace trusted"
5990 };
5991
5992 format!(
5993 "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}"
5994 )
5995}
5996
5997fn doctor_operate_fleet_report_json(config: &Config, workspace: &Path) -> serde_json::Value {
5998 use serde_json::json;
5999
6000 let provider = config.api_provider();
6001 let credential = resolve_credential_diagnostic(config);
6004 let has_credentials_or_local = credential.availability.certifies_ready();
6005 let subagents_enabled = config.subagents_enabled_for_provider(provider);
6006 let disabled_reason = if subagents_enabled {
6007 None
6008 } else {
6009 Some(
6010 config
6011 .subagents_disabled_reason()
6012 .unwrap_or("disabled for active provider"),
6013 )
6014 };
6015 let max_subagents = config.max_subagents_for_provider(provider);
6016 let launch_concurrency = config.launch_concurrency_for_provider(provider);
6017 let max_admitted = config.max_admitted_subagents_for_provider(provider);
6018 let max_spawn_depth = config.subagent_max_spawn_depth_for_provider(provider);
6019 let roster = crate::fleet::roster::FleetRoster::load(&config.fleet_config(), workspace);
6020 let mut built_in_members = 0usize;
6021 let mut config_members = 0usize;
6022 let mut personal_members = 0usize;
6023 let mut workspace_members = 0usize;
6024 for member in roster.members() {
6025 match member.origin {
6026 crate::fleet::roster::ProfileOrigin::BuiltIn => built_in_members += 1,
6027 crate::fleet::roster::ProfileOrigin::Config => config_members += 1,
6028 crate::fleet::roster::ProfileOrigin::Personal => personal_members += 1,
6029 crate::fleet::roster::ProfileOrigin::Workspace => workspace_members += 1,
6030 }
6031 }
6032 let roster_members = roster.members().len();
6033 let custom_members = config_members + personal_members + workspace_members;
6034 let roster_ready = roster_members > 0;
6035 let runtime_ready =
6036 subagents_enabled && max_subagents > 0 && launch_concurrency > 0 && max_spawn_depth > 0;
6037
6038 json!({
6039 "ready": has_credentials_or_local && runtime_ready && roster_ready,
6040 "provider": {
6041 "id": config.provider_identity_for(provider),
6042 "auth": {
6043 "present_or_local": has_credentials_or_local,
6044 "source": doctor_api_key_source_label(credential.source),
6045 "availability": credential.availability.label(),
6046 },
6047 },
6048 "worker_runtime": {
6049 "ready": runtime_ready,
6050 "enabled": subagents_enabled,
6051 "disabled_reason": disabled_reason,
6052 "max_subagents": max_subagents,
6053 "launch_concurrency": launch_concurrency,
6054 "max_admitted": max_admitted,
6055 "max_spawn_depth": max_spawn_depth,
6056 "host_enforced_workflow_receipts": true,
6057 },
6058 "roster": {
6059 "ready": roster_ready,
6060 "total": roster_members,
6061 "built_in": built_in_members,
6062 "config": config_members,
6063 "personal": personal_members,
6064 "workspace": workspace_members,
6065 "custom": custom_members,
6066 "starter_roster_available": built_in_members > 0,
6067 "readiness_rule": "built-in starter roster or custom roster",
6068 },
6069 "concurrency": {
6070 "launch_concurrency": launch_concurrency,
6071 "max_subagents": max_subagents,
6072 "max_admitted": max_admitted,
6073 "plan_limit_probed": false,
6074 },
6075 })
6076}
6077
6078fn doctor_provider_model_report_json(config: &Config) -> serde_json::Value {
6079 use serde_json::json;
6080
6081 let provider = config.api_provider();
6082 let credential = resolve_credential_diagnostic(config);
6083 let auth_present_or_local = credential.availability.certifies_ready();
6084 let credential_help = provider.credential_help();
6085 let credential_url = credential_help
6086 .credential_url
6087 .map(crate::doctor::structural_url_authority);
6088 let credential_docs_url = credential_help
6089 .docs_url
6090 .map(crate::doctor::structural_url_authority);
6091
6092 json!({
6093 "provider": {
6094 "id": config.provider_identity_for(provider),
6095 "display": provider.display_name(),
6096 },
6097 "model": {
6098 "resolved": config.default_model(),
6099 },
6100 "auth": {
6101 "present_or_local": auth_present_or_local,
6102 "source": doctor_api_key_source_label(credential.source),
6103 "availability": credential.availability.label(),
6104 "env_vars": provider.env_vars(),
6105 "credential_mode": credential_help.acquisition.as_str(),
6106 "credential_url": credential_url,
6107 "credential_docs_url": credential_docs_url,
6108 "credential_guidance": credential_help.guidance,
6109 "oauth_only": credential_help.acquisition
6110 == codewhale_config::provider::CredentialAcquisition::OAuth,
6111 },
6112 "health": {
6113 "live_validation": false,
6114 "next_action": if auth_present_or_local {
6115 "/model"
6116 } else {
6117 "/setup provider or /provider setup <name>"
6118 },
6119 },
6120 })
6121}
6122
6123fn doctor_dsh_integration_report(
6124 config: &Config,
6125 workspace: &Path,
6126) -> anyhow::Result<crate::integrations::dsh::DshStatusReport> {
6127 use crate::integrations::dsh;
6128 let paths = dsh::DshPaths::from_process()?;
6129 let detection = dsh::detect::detect(&dsh::DetectEnv::from_process(), &dsh::ProcessRunner);
6130 let identity = dsh::codewhale_route_identity(config, workspace);
6131 dsh::compute_status(
6132 &paths,
6133 detection,
6134 identity,
6135 false,
6136 dsh::bundle_availability_now(),
6137 )
6138}
6139
6140fn doctor_dsh_integration_lines(config: &Config, workspace: &Path) -> Vec<String> {
6141 match doctor_dsh_integration_report(config, workspace) {
6142 Ok(report) => {
6143 let mut lines = vec![
6144 format!("state: {}", report.state.label()),
6145 crate::integrations::dsh::status_line(&report),
6146 format!(
6147 "owned files: {} (overlay {})",
6148 crate::utils::display_path(&report.paths_root),
6149 if report.overlay_present {
6150 "present"
6151 } else {
6152 "absent"
6153 }
6154 ),
6155 ];
6156 if !report.shadowing_namespaces.is_empty() {
6157 lines.push(format!(
6158 "dsh settings.yaml sections that can shadow the overlay: {}",
6159 report.shadowing_namespaces.join(", ")
6160 ));
6161 }
6162 lines
6163 }
6164 Err(error) => vec![format!("unavailable: {error}")],
6165 }
6166}
6167
6168fn doctor_dsh_integration_json(config: &Config, workspace: &Path) -> serde_json::Value {
6169 match doctor_dsh_integration_report(config, workspace) {
6170 Ok(report) => serde_json::json!({
6171 "state": report.state.label(),
6172 "summary": crate::integrations::dsh::status_line(&report),
6173 "dsh_version": report.detection.version,
6174 "compatibility": report.detection.compatibility.label(),
6175 "overlay_present": report.overlay_present,
6176 "shadowing_namespaces": report.shadowing_namespaces,
6177 }),
6178 Err(error) => serde_json::json!({ "state": "unavailable", "error": error.to_string() }),
6179 }
6180}
6181
6182fn doctor_external_credential_consent_statuses(
6183 config: &Config,
6184) -> Vec<codewhale_config::ExternalCredentialConsentStatus> {
6185 [
6186 crate::config::ApiProvider::OpenaiCodex,
6187 crate::config::ApiProvider::Xai,
6188 crate::config::ApiProvider::Deepseek,
6189 ]
6190 .into_iter()
6191 .filter_map(|provider| config.external_credential_consent_status(provider))
6192 .collect()
6193}
6194
6195fn doctor_external_credential_consent_lines(config: &Config) -> Vec<String> {
6196 doctor_external_credential_consent_statuses(config)
6197 .into_iter()
6198 .flat_map(|status| {
6199 let mut lines = vec![
6200 format!(
6201 "{}: access={}, provider={}, source={}, owner={}, path={}, version={}, state={}, ambient_path_changed={}",
6202 status.provider,
6203 status.access.as_str(),
6204 status.provider,
6205 status.source.as_str(),
6206 status.owner,
6207 codewhale_config::quote_os_path(&status.path),
6208 status.consent_version,
6209 status.route_state,
6210 status.ambient_path_changed,
6211 ),
6212 format!(" semantics: {}", status.semantics),
6213 format!(" revoke: {}", status.revoke_command),
6214 ];
6215 if let Some(warning) = status.ambient_path_warning() {
6216 lines.push(format!(" {warning}"));
6217 }
6218 lines
6219 })
6220 .collect()
6221}
6222
6223fn doctor_external_credential_consent_json(config: &Config) -> serde_json::Value {
6224 serde_json::Value::Array(
6225 doctor_external_credential_consent_statuses(config)
6226 .into_iter()
6227 .map(|status| {
6228 serde_json::json!({
6229 "provider": status.provider,
6230 "access": status.access.as_str(),
6231 "source": status.source.as_str(),
6232 "owner": status.owner,
6233 "path": codewhale_config::quote_os_path(&status.path),
6234 "consent_version": status.consent_version,
6235 "scope_valid": status.scope_valid,
6236 "ambient_path_changed": status.ambient_path_changed,
6237 "ambient_path_warning": status.ambient_path_warning(),
6238 "route_state": status.route_state,
6239 "semantics": status.semantics,
6240 "revoke_command": status.revoke_command,
6241 })
6242 })
6243 .collect(),
6244 )
6245}
6246
6247fn doctor_setup_report_json(config: &Config, workspace: &Path) -> serde_json::Value {
6248 use serde_json::json;
6249
6250 let (state, source) = doctor_setup_state(config, workspace);
6251 let (default_mode, default_mode_source) = doctor_runtime_default_mode();
6252 let (permission_posture, permission_posture_source) = doctor_runtime_permission_posture();
6253 let approval_policy = config.approval_policy.as_deref().unwrap_or("on-request");
6254 let approval_policy_source = if config.approval_policy.is_some() {
6255 "config"
6256 } else {
6257 "default"
6258 };
6259 let allow_shell = config.interactive_allow_shell();
6260 let allow_shell_source = if config.allow_shell.is_some() {
6261 "config"
6262 } else {
6263 "interactive_default"
6264 };
6265 let sandbox_mode = config.sandbox_mode.as_deref().unwrap_or("mode-derived");
6266 let sandbox_mode_source = if config.sandbox_mode.is_some() {
6267 "config"
6268 } else {
6269 "default"
6270 };
6271 let network_default = config
6272 .network
6273 .as_ref()
6274 .map_or("prompt", |policy| policy.default.as_str());
6275 let network_source = if config.network.is_some() {
6276 "config"
6277 } else {
6278 "default"
6279 };
6280 let workspace_trusted = !crate::tui::onboarding::needs_trust(workspace);
6281 let credential = resolve_credential_diagnostic(config);
6282 let credential_ready = credential.availability.certifies_ready();
6283 let steps: Vec<_> = codewhale_config::SetupStep::ALL
6284 .into_iter()
6285 .map(|step| {
6286 let entry = state.steps.get(&step);
6287 json!({
6288 "step": setup_step_id(step),
6289 "status": setup_status_id(state.status(step)),
6290 "required": entry.is_some_and(|entry| entry.required),
6291 "version": entry.and_then(|entry| entry.version.clone()),
6292 "result": entry.and_then(|entry| entry.result.clone()),
6293 })
6294 })
6295 .collect();
6296
6297 json!({
6298 "source": source,
6299 "schema_version": state.schema_version,
6300 "inherited": state.inherited,
6301 "checkpoint_version": crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
6302 "first_run_ready": state.first_run_ready(),
6303 "update_ready": state.update_ready(crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION),
6304 "operate_ready": state.operate_ready(),
6305 "credential": {
6306 "ready": credential_ready,
6307 "source": doctor_api_key_source_label(credential.source),
6308 "availability": credential.availability.label(),
6309 },
6310 "constitution": {
6311 "choice": constitution_choice_id(state.constitution_choice),
6312 "source": constitution_source_id(state.constitution_source),
6313 "validity": constitution_validity_id(state.constitution_validity),
6314 "checkpoint_completed_for": state.constitution_checkpoint_completed_for.clone(),
6315 "language": state.constitution_language.clone(),
6316 "preview_hash_present": state.constitution_preview_hash.is_some(),
6317 "preview_version": state.constitution_preview_version,
6318 "autonomy_preference": doctor_constitution_autonomy_preference_id(),
6319 },
6320 "runtime_posture_source": runtime_posture_source_id(state.runtime_posture_source),
6321 "runtime_posture": {
6322 "source": runtime_posture_source_id(state.runtime_posture_source),
6323 "default_mode": {
6324 "value": default_mode,
6325 "source": default_mode_source,
6326 },
6327 "permission_posture": {
6328 "value": permission_posture,
6329 "source": permission_posture_source,
6330 },
6331 "approval_policy": {
6332 "value": approval_policy,
6333 "source": approval_policy_source,
6334 },
6335 "allow_shell": {
6336 "value": allow_shell,
6337 "source": allow_shell_source,
6338 },
6339 "sandbox_mode": {
6340 "value": sandbox_mode,
6341 "source": sandbox_mode_source,
6342 },
6343 "network_default": {
6344 "value": network_default,
6345 "source": network_source,
6346 },
6347 "workspace_trust": {
6348 "trusted": workspace_trusted,
6349 "source": "workspace",
6350 },
6351 },
6352 "provider_model": doctor_provider_model_report_json(config),
6353 "operate_fleet": doctor_operate_fleet_report_json(config, workspace),
6354 "consistency": doctor_setup_consistency(&state, source),
6355 "next_actions": {
6356 "constitution": "/constitution",
6357 "setup_report": "/setup report",
6358 "provider_model": "/setup provider, /provider setup <name>, or /model",
6359 "runtime_posture": "/config",
6360 "operate_fleet": "/setup fleet (readiness), /fleet setup (explicit profile authoring)",
6361 "hotbar": "/setup hotbar",
6362 "tools_mcp": "/setup tools",
6363 "remote_runtime": "/setup remote",
6364 "persistence": "/setup persistence",
6365 },
6366 "steps": steps,
6367 })
6368}
6369
6370fn setup_step_id(step: codewhale_config::SetupStep) -> &'static str {
6371 match step {
6372 codewhale_config::SetupStep::Language => "language",
6373 codewhale_config::SetupStep::ProviderModel => "provider_model",
6374 codewhale_config::SetupStep::TrustSandbox => "trust_sandbox",
6375 codewhale_config::SetupStep::ToolsMcp => "tools_mcp",
6376 codewhale_config::SetupStep::Hotbar => "hotbar",
6377 codewhale_config::SetupStep::RemoteRuntime => "remote_runtime",
6378 codewhale_config::SetupStep::Persistence => "persistence",
6379 codewhale_config::SetupStep::Constitution => "constitution",
6380 codewhale_config::SetupStep::OperateFleet => "operate_fleet",
6381 codewhale_config::SetupStep::Verification => "verification",
6382 }
6383}
6384
6385fn setup_status_id(status: codewhale_config::StepStatus) -> &'static str {
6386 match status {
6387 codewhale_config::StepStatus::NotStarted => "not_started",
6388 codewhale_config::StepStatus::Recommended => "recommended",
6389 codewhale_config::StepStatus::Optional => "optional",
6390 codewhale_config::StepStatus::Deferred => "deferred",
6391 codewhale_config::StepStatus::InProgress => "in_progress",
6392 codewhale_config::StepStatus::Verified => "verified",
6393 codewhale_config::StepStatus::NeedsAction => "needs_action",
6394 codewhale_config::StepStatus::Failed => "failed",
6395 codewhale_config::StepStatus::Skipped => "skipped",
6396 }
6397}
6398
6399fn constitution_choice_id(choice: codewhale_config::ConstitutionChoice) -> &'static str {
6400 match choice {
6401 codewhale_config::ConstitutionChoice::Unset => "unset",
6402 codewhale_config::ConstitutionChoice::Bundled => "bundled",
6403 codewhale_config::ConstitutionChoice::GuidedCustom => "guided_custom",
6404 codewhale_config::ConstitutionChoice::ExpertOverride => "expert_override",
6405 codewhale_config::ConstitutionChoice::Deferred => "deferred",
6406 }
6407}
6408
6409fn constitution_source_id(source: codewhale_config::ConstitutionSource) -> &'static str {
6410 match source {
6411 codewhale_config::ConstitutionSource::Bundled => "bundled",
6412 codewhale_config::ConstitutionSource::UserGlobal => "user_global",
6413 codewhale_config::ConstitutionSource::ExpertOverride => "expert_override",
6414 }
6415}
6416
6417fn constitution_validity_id(validity: codewhale_config::ConstitutionValidity) -> &'static str {
6418 match validity {
6419 codewhale_config::ConstitutionValidity::Unknown => "unknown",
6420 codewhale_config::ConstitutionValidity::Valid => "valid",
6421 codewhale_config::ConstitutionValidity::Invalid => "invalid",
6422 codewhale_config::ConstitutionValidity::Empty => "empty",
6423 codewhale_config::ConstitutionValidity::Unreadable => "unreadable",
6424 }
6425}
6426
6427fn runtime_posture_source_id(source: codewhale_config::RuntimePostureSource) -> &'static str {
6428 match source {
6429 codewhale_config::RuntimePostureSource::Unset => "unset",
6430 codewhale_config::RuntimePostureSource::Inherited => "inherited",
6431 codewhale_config::RuntimePostureSource::Confirmed => "confirmed",
6432 }
6433}
6434
6435fn run_doctor_json_config_error(error: &anyhow::Error) -> Result<()> {
6439 let safe_message = error
6440 .downcast_ref::<crate::config::SafeConfigDiagnostic>()
6441 .map(ToString::to_string);
6442 let report = serde_json::json!({
6443 "status": "error",
6444 "error": {
6445 "kind": "config_validation",
6446 "message": safe_message.as_deref().unwrap_or("configuration validation failed; details omitted because configuration errors may contain credential material"),
6447 },
6448 });
6449 println!("{}", serde_json::to_string_pretty(&report)?);
6450
6451 bail!("doctor configuration validation failed; see JSON output")
6454}
6455
6456fn run_doctor_json(
6459 config: &Config,
6460 workspace: &Path,
6461 config_path_override: Option<&Path>,
6462 plugins: &crate::plugins::PluginRegistry,
6463) -> Result<()> {
6464 use serde_json::json;
6465
6466 let doctor_paths = crate::doctor::DoctorPathReport::resolve(config_path_override)?;
6467 let config_path = &doctor_paths.config;
6468 let secret_backend = codewhale_secrets::diagnose_secret_backend();
6469
6470 let credential = resolve_credential_diagnostic(config);
6471
6472 let mcp_config_path = config.mcp_config_path();
6473 let project_mcp_config_path = crate::mcp::workspace_mcp_config_path(workspace);
6474 let mcp_present = mcp_config_path.exists();
6475 let project_mcp_present = project_mcp_config_path.exists();
6476 let mcp_summary = match crate::mcp::load_config_with_workspace_and_plugins(
6477 &mcp_config_path,
6478 workspace,
6479 plugins,
6480 ) {
6481 Ok(cfg) => {
6482 let servers: Vec<serde_json::Value> = cfg
6483 .servers
6484 .iter()
6485 .map(|(name, server)| doctor_mcp_server_json(name, server))
6486 .collect();
6487 json!({
6488 "config_path": mcp_config_path.display().to_string(),
6489 "present": mcp_present,
6490 "project_config_path": project_mcp_config_path.display().to_string(),
6491 "project_present": project_mcp_present,
6492 "probe_scope": "configuration",
6493 "live_health_checked": false,
6494 "servers": servers,
6495 })
6496 }
6497 Err(_) => json!({
6498 "config_path": mcp_config_path.display().to_string(),
6499 "present": mcp_present,
6500 "project_config_path": project_mcp_config_path.display().to_string(),
6501 "project_present": project_mcp_present,
6502 "probe_scope": "configuration",
6503 "live_health_checked": false,
6504 "servers": [],
6505 "error": "configuration_unavailable_details_omitted",
6506 }),
6507 };
6508
6509 let global_skills_dir = config.skills_dir();
6510 let agents_skills_dir = workspace.join(".agents").join("skills");
6511 let local_skills_dir = workspace.join("skills");
6512 let agents_global_skills_dir = crate::skills::agents_global_skills_dir();
6513 let opencode_skills_dir = workspace.join(".opencode").join("skills");
6518 let claude_skills_dir = workspace.join(".claude").join("skills");
6519 let selected_skills_dir = if agents_skills_dir.exists() {
6520 agents_skills_dir.clone()
6521 } else if local_skills_dir.exists() {
6522 local_skills_dir.clone()
6523 } else if config.skills_dir.is_none()
6524 && let Some(global_agents) = agents_global_skills_dir.as_ref()
6525 && global_agents.exists()
6526 {
6527 global_agents.clone()
6528 } else {
6529 global_skills_dir.clone()
6530 };
6531 let agents_global_summary = agents_global_skills_dir
6532 .as_ref()
6533 .map(|path| {
6534 json!({
6535 "path": path.display().to_string(),
6536 "present": path.exists(),
6537 "count": skills_count_for(path),
6538 })
6539 })
6540 .unwrap_or_else(|| {
6541 json!({
6542 "path": null,
6543 "present": false,
6544 "count": 0,
6545 })
6546 });
6547
6548 let tools_dir = default_tools_dir();
6549 let plugins_dir = default_plugins_dir();
6550
6551 let memory_path = config.memory_path();
6560 let memory_enabled_env = std::env::var("CODEWHALE_MEMORY")
6561 .or_else(|_| std::env::var("DEEPSEEK_MEMORY"))
6562 .ok()
6563 .map(|raw| {
6564 matches!(
6565 raw.trim().to_ascii_lowercase().as_str(),
6566 "1" | "on" | "true" | "yes" | "y" | "enabled"
6567 )
6568 })
6569 .unwrap_or(false);
6570 let memory_summary = json!({
6571 "enabled": memory_enabled_env,
6574 "path": memory_path.display().to_string(),
6575 "file_present": memory_path.exists(),
6576 });
6577 let api_target = doctor_api_target(config);
6578 let strict_tool_mode = doctor_strict_tool_mode_status(config);
6579 let tls_status = doctor_tls_status(config);
6580 let (code_home, legacy_home) = doctor_state_roots();
6581 let legacy_state_report = doctor_legacy_state_report(&code_home, &legacy_home);
6582 let session_recovery = doctor_session_recovery_report(
6583 &code_home,
6584 &legacy_home,
6585 codewhale_config::codewhale_home_is_explicit(),
6586 );
6587
6588 let stash = crate::composer_stash::diagnostic_stash_report();
6589 let report = json!({
6590 "version": env!("CARGO_PKG_VERSION"),
6591 "config_path": config_path.display().to_string(),
6592 "config_present": config_path.exists(),
6593 "paths": doctor_paths,
6594 "secret_backend": secret_backend,
6595 "workspace": workspace.display().to_string(),
6596 "legacy_state": doctor_legacy_state_json(
6597 &code_home,
6598 &legacy_home,
6599 &legacy_state_report,
6600 &session_recovery,
6601 ),
6602 "setup": doctor_setup_report_json(config, workspace),
6603 "api_key": {
6604 "source": doctor_api_key_source_label(credential.source),
6605 "availability": credential.availability.label(),
6606 },
6607 "external_credentials": doctor_external_credential_consent_json(config),
6608 "dsh_integration": doctor_dsh_integration_json(config, workspace),
6609 "base_url": crate::doctor::structural_url_authority(&api_target.base_url),
6610 "default_text_model": api_target.model,
6611 "route_scope": "configured_at_launch",
6614 "model_resolution": match api_target.resolution {
6615 DoctorModelResolution::Resolved => "resolved",
6616 DoctorModelResolution::ConfiguredOnly => "configured_unresolved",
6617 },
6618 "route": doctor_route_report(config),
6619 "strict_tool_mode": doctor_strict_tool_mode_report_json(&strict_tool_mode),
6620 "tls": {
6621 "certificate_verification": tls_status.certificate_verification,
6622 "insecure_skip_tls_verify": tls_status.insecure_skip_tls_verify,
6623 "provider": tls_status.provider,
6624 "message": tls_status.message,
6625 },
6626 "search_provider": doctor_search_provider_json(config),
6627 "memory": memory_summary,
6628 "mcp": mcp_summary,
6629 "skills": {
6630 "selected": selected_skills_dir.display().to_string(),
6631 "global": {
6632 "path": global_skills_dir.display().to_string(),
6633 "present": global_skills_dir.exists(),
6634 "count": skills_count_for(&global_skills_dir),
6635 },
6636 "agents": {
6637 "path": agents_skills_dir.display().to_string(),
6638 "present": agents_skills_dir.exists(),
6639 "count": skills_count_for(&agents_skills_dir),
6640 },
6641 "agents_global": agents_global_summary,
6642 "local": {
6643 "path": local_skills_dir.display().to_string(),
6644 "present": local_skills_dir.exists(),
6645 "count": skills_count_for(&local_skills_dir),
6646 },
6647 "opencode": {
6648 "path": opencode_skills_dir.display().to_string(),
6649 "present": opencode_skills_dir.exists(),
6650 "count": skills_count_for(&opencode_skills_dir),
6651 },
6652 "claude": {
6653 "path": claude_skills_dir.display().to_string(),
6654 "present": claude_skills_dir.exists(),
6655 "count": skills_count_for(&claude_skills_dir),
6656 },
6657 },
6658 "tools": {
6659 "path": tools_dir.display().to_string(),
6660 "present": tools_dir.exists(),
6661 "count": if tools_dir.exists() { count_dir_entries(&tools_dir) } else { 0 },
6662 },
6663 "plugins": {
6664 "path": plugins_dir.display().to_string(),
6665 "present": plugins_dir.exists(),
6666 "count": if plugins_dir.exists() { count_dir_entries(&plugins_dir) } else { 0 },
6667 },
6668 "storage": {
6669 "spillover": {
6670 "path": crate::tools::truncate::spillover_root()
6671 .map(|p| p.display().to_string())
6672 .unwrap_or_default(),
6673 "present": crate::tools::truncate::spillover_root()
6674 .is_some_and(|p| p.is_dir()),
6675 "count": crate::tools::truncate::spillover_root()
6676 .filter(|p| p.is_dir())
6677 .map(|p| count_dir_entries(&p))
6678 .unwrap_or(0),
6679 },
6680 "stash": {
6681 "path": stash
6682 .path
6683 .as_ref()
6684 .map(|path| path.display().to_string())
6685 .unwrap_or_default(),
6686 "present": stash.present,
6687 "count": stash.count,
6688 "error": stash.error,
6689 },
6690 },
6691 "sandbox": match crate::sandbox::get_platform_sandbox_with_bwrap_preference(
6692 config.prefer_bwrap.unwrap_or(false),
6693 ) {
6694 Some(kind) => json!({"available": true, "kind": kind.to_string()}),
6695 None => json!({"available": false, "kind": null}),
6696 },
6697 "platform": {
6698 "os": std::env::consts::OS,
6699 "arch": std::env::consts::ARCH,
6700 },
6701 "api_connectivity": {
6702 "checked": false,
6703 "status": "not_probed",
6704 "note": "JSON doctor is offline; use `codewhale doctor --probe-api` or `--probe-local` for an explicit live check.",
6705 },
6706 "capability": provider_capability_report(config),
6707 });
6708
6709 println!("{}", serde_json::to_string_pretty(&report)?);
6710 Ok(())
6711}
6712
6713fn run_doctor_context_json(config: &Config, workspace: &Path) -> Result<()> {
6714 let report = crate::context_report::build_headless_context_report(config, workspace);
6715 println!("{}", crate::context_report::context_report_json(&report));
6716 Ok(())
6717}
6718
6719fn provider_capability_report(config: &Config) -> serde_json::Value {
6725 use serde_json::json;
6726
6727 let provider = config.api_provider();
6728 let configured_model = config.default_model();
6729 let route_result =
6730 crate::route_runtime::resolve_runtime_route(config, provider, Some(&configured_model));
6731 let route_error = route_result
6732 .is_err()
6733 .then_some("route_resolution_failed_details_omitted");
6734 let route = route_result.ok();
6735 let resolved_model = route
6736 .as_ref()
6737 .map_or(configured_model.as_str(), |route| route.model.as_str());
6738 let cap = crate::config::provider_capability(provider, resolved_model);
6739 let route_profile = route.as_ref().map(|route| {
6740 crate::model_profile::resolved_capability_profile_for_route(
6741 provider,
6742 resolved_model,
6743 route.candidate.capabilities(),
6744 route.candidate.limits(),
6745 )
6746 });
6747 let context_window = route
6748 .as_ref()
6749 .map_or(cap.context_window, |route| route.context_window.tokens);
6750 let context_window_source = route.as_ref().map_or(
6751 crate::route_runtime::ContextWindowSource::Fallback.label(),
6752 |route| route.context_window.source.label(),
6753 );
6754 let max_output = route_profile
6757 .as_ref()
6758 .and_then(|profile| profile.max_output)
6759 .or(cap.max_output);
6760 let is_exact_kimi_code_k3 = route.as_ref().is_some_and(|route| {
6761 crate::config::is_exact_kimi_code_k3_route(
6762 provider,
6763 &route.candidate.endpoint().base_url,
6764 route.candidate.wire_model_id().as_str(),
6765 )
6766 });
6767 let thinking_supported = is_exact_kimi_code_k3
6768 || route_profile
6769 .as_ref()
6770 .map_or(cap.thinking_supported, |profile| {
6771 profile.supports_reasoning()
6772 });
6773 let cache_telemetry_supported = route_profile
6774 .as_ref()
6775 .map_or(cap.cache_telemetry_supported, |profile| {
6776 profile.prompt_caching.is_supported()
6777 });
6778 let request_payload_mode = route_profile
6779 .as_ref()
6780 .map_or(cap.request_payload_mode, |profile| {
6781 profile.request_payload_mode
6782 });
6783 let alias_deprecation = config.active_deepseek_alias_deprecation();
6784
6785 json!({
6786 "resolved_provider": config.provider_identity_for(provider),
6787 "resolved_model": resolved_model,
6788 "context_window": context_window,
6789 "context_window_source": context_window_source,
6790 "max_output": max_output,
6791 "thinking_supported": thinking_supported,
6792 "cache_telemetry_supported": cache_telemetry_supported,
6793 "request_payload_mode": serde_json::to_value(request_payload_mode).unwrap_or_default(),
6794 "route_error": route_error,
6795 "alias_deprecation": alias_deprecation,
6796 })
6797}
6798
6799fn doctor_route_report(config: &Config) -> serde_json::Value {
6800 use serde_json::json;
6801
6802 let target = doctor_api_target(config);
6803 let provider = config.api_provider();
6804 let redacted_base_url = crate::doctor::structural_url_authority(&target.base_url);
6805 let route_result =
6806 crate::route_runtime::resolve_runtime_route(config, provider, Some(&target.model));
6807 let route_error = route_result
6808 .is_err()
6809 .then_some("route_resolution_failed_details_omitted");
6810 let context_window = route_result
6811 .ok()
6812 .map(|route| {
6813 json!({
6814 "tokens": route.context_window.tokens,
6815 "source": route.context_window.source.label(),
6816 })
6817 })
6818 .unwrap_or_else(|| {
6819 json!({
6820 "tokens": crate::config::provider_capability(provider, &target.model).context_window,
6821 "source": crate::route_runtime::ContextWindowSource::Fallback.label(),
6822 })
6823 });
6824
6825 let route_identity =
6826 crate::config::moonshot_k3_route_display_name(&target.base_url, &target.model);
6827 let credential = resolve_credential_diagnostic(config);
6828
6829 json!({
6830 "provider": target.provider,
6831 "provider_source": doctor_provider_source(config),
6832 "provider_config_table": doctor_provider_config_table(config, provider),
6833 "model": target.model,
6834 "route_identity": route_identity,
6835 "wire_protocol": doctor_wire_protocol(provider),
6836 "base_url": {
6837 "redacted": redacted_base_url,
6838 "class": doctor_base_url_class(provider, &target.base_url),
6839 "fingerprint": crate::utils::redacted_identifier_for_log(&target.base_url),
6840 },
6841 "auth": {
6842 "scheme": doctor_auth_scheme(config),
6843 "source": doctor_api_key_source_label(credential.source),
6844 "availability": credential.availability.label(),
6845 },
6846 "context_window": context_window,
6847 "route_error": route_error,
6848 })
6849}
6850
6851fn doctor_provider_config_table(config: &Config, provider: crate::config::ApiProvider) -> String {
6852 if provider != crate::config::ApiProvider::Custom {
6853 return provider_config_table_key(provider).to_string();
6854 }
6855 if config.uses_legacy_literal_custom_route() {
6856 "root (legacy literal custom)".to_string()
6857 } else {
6858 format!("providers.{}", config.provider_identity_for(provider))
6859 }
6860}
6861
6862fn doctor_provider_source(config: &Config) -> &'static str {
6863 if config
6864 .provider
6865 .as_ref()
6866 .is_some_and(|provider| !provider.trim().is_empty())
6867 {
6868 "config"
6869 } else {
6870 "default"
6871 }
6872}
6873
6874fn doctor_wire_protocol(provider: crate::config::ApiProvider) -> &'static str {
6875 let policy = provider
6876 .metadata()
6877 .map(|metadata| metadata.wire_policy())
6878 .unwrap_or(codewhale_config::provider::WirePolicy::Fixed(
6879 codewhale_config::provider::WireFormat::ChatCompletions,
6880 ));
6881 match policy.fixed() {
6882 Some(codewhale_config::provider::WireFormat::ChatCompletions) => "chat_completions",
6883 Some(codewhale_config::provider::WireFormat::Responses) => "responses",
6884 Some(codewhale_config::provider::WireFormat::AnthropicMessages) => "anthropic_messages",
6885 None => "model_aware",
6886 }
6887}
6888
6889fn doctor_base_url_class(provider: crate::config::ApiProvider, base_url: &str) -> &'static str {
6890 let normalized = base_url.trim_end_matches('/').to_ascii_lowercase();
6891 if normalized.starts_with("http://localhost")
6892 || normalized.starts_with("http://127.0.0.1")
6893 || normalized.starts_with("http://[::1]")
6894 {
6895 return "local";
6896 }
6897 if normalized
6898 == provider
6899 .default_base_url()
6900 .trim_end_matches('/')
6901 .to_ascii_lowercase()
6902 {
6903 "default"
6904 } else {
6905 "custom"
6906 }
6907}
6908
6909fn doctor_auth_scheme(config: &Config) -> &'static str {
6910 let provider = config.api_provider();
6911 if crate::config::auth_mode_disables_api_key(config.auth_mode_for_provider(provider).as_deref())
6912 {
6913 "none"
6914 } else if provider == crate::config::ApiProvider::Anthropic {
6915 "x-api-key"
6916 } else if provider == crate::config::ApiProvider::XiaomiMimo
6917 && doctor_xiaomi_mimo_base_url_uses_token_plan(&config.deepseek_base_url())
6918 {
6919 "api-key"
6920 } else if provider == crate::config::ApiProvider::XiaomiMimo {
6921 "unknown"
6924 } else if matches!(
6925 provider,
6926 crate::config::ApiProvider::Sglang
6927 | crate::config::ApiProvider::Vllm
6928 | crate::config::ApiProvider::Ollama
6929 ) {
6930 "optional_bearer"
6931 } else {
6932 "bearer"
6933 }
6934}
6935
6936fn doctor_xiaomi_mimo_base_url_uses_token_plan(base_url: &str) -> bool {
6937 let normalized = base_url.trim_end_matches('/');
6938 [
6939 crate::config::XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL,
6940 crate::config::XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL,
6941 crate::config::XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL,
6942 ]
6943 .iter()
6944 .any(|candidate| normalized.eq_ignore_ascii_case(candidate.trim_end_matches('/')))
6945}
6946
6947fn doctor_api_key_source_label(source: ApiKeySource) -> &'static str {
6948 match source {
6949 ApiKeySource::ConfigDeclared => "config_declared",
6950 ApiKeySource::EnvDeclared => "env_declared",
6951 ApiKeySource::ExternalAuthDeclared => "external_auth_declared",
6952 ApiKeySource::SecretStoreUnprobed => "secret_store_unprobed",
6953 ApiKeySource::SecretStoreUnavailable => "secret_store_unavailable",
6954 ApiKeySource::OAuth => "oauth_unprobed",
6955 ApiKeySource::ExternalConsent => "external_consent",
6956 ApiKeySource::NoAuth => "none",
6957 ApiKeySource::LocalRuntime => "local_runtime",
6958 ApiKeySource::Unknown => "unknown",
6959 }
6960}
6961
6962fn doctor_search_provider_line(config: &Config) -> String {
6963 let search_provider = config.search_provider_resolution();
6964 let switch_hint = if matches!(
6965 (search_provider.provider, search_provider.source),
6966 (
6967 crate::config::SearchProvider::Firecrawl,
6968 crate::config::SearchProviderSource::Default
6969 )
6970 ) {
6971 "; set [search] provider = \"baidu\" | \"metaso\" | \"volcengine\" for China"
6972 } else {
6973 ""
6974 };
6975
6976 format!(
6977 "search_provider: {} (source: {}{})",
6978 search_provider.provider.as_str(),
6979 search_provider.source.as_str(),
6980 switch_hint
6981 )
6982}
6983
6984fn doctor_search_provider_json(config: &Config) -> serde_json::Value {
6985 use serde_json::json;
6986
6987 let search_provider = config.search_provider_resolution();
6988 json!({
6989 "provider": search_provider.provider.as_str(),
6990 "source": search_provider.source.as_str(),
6991 })
6992}
6993
6994#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6999enum DoctorModelResolution {
7000 Resolved,
7001 ConfiguredOnly,
7002}
7003
7004#[derive(Debug, Clone, PartialEq, Eq)]
7005struct DoctorApiTarget {
7006 provider: String,
7007 base_url: String,
7008 model: String,
7009 resolution: DoctorModelResolution,
7010}
7011
7012#[derive(Debug, Clone, PartialEq, Eq)]
7013struct DoctorStrictToolModeStatus {
7014 enabled: bool,
7015 status: &'static str,
7016 function_strict_sent: bool,
7017 message: String,
7018 recommended_base_url: Option<String>,
7019}
7020
7021fn doctor_api_target(config: &Config) -> DoctorApiTarget {
7022 let provider = config.api_provider();
7023 let (model, resolution) =
7030 match crate::route_runtime::resolve_runtime_route(config, provider, None) {
7031 Ok(route) => (route.model.clone(), DoctorModelResolution::Resolved),
7032 Err(_) => (
7033 config.default_model(),
7034 DoctorModelResolution::ConfiguredOnly,
7035 ),
7036 };
7037 DoctorApiTarget {
7038 provider: config.provider_identity_for(provider),
7039 base_url: config.deepseek_base_url(),
7040 model,
7041 resolution,
7042 }
7043}
7044
7045fn doctor_strict_tool_mode_status(config: &Config) -> DoctorStrictToolModeStatus {
7046 if !config.strict_tool_mode.unwrap_or(false) {
7047 return DoctorStrictToolModeStatus {
7048 enabled: false,
7049 status: "disabled",
7050 function_strict_sent: false,
7051 message: "disabled".to_string(),
7052 recommended_base_url: None,
7053 };
7054 }
7055
7056 let target = doctor_api_target(config);
7057 match known_deepseek_base_url_kind(&target.base_url) {
7058 Some(DeepSeekBaseUrlKind::Beta) => DoctorStrictToolModeStatus {
7059 enabled: true,
7060 status: "ready",
7061 function_strict_sent: true,
7062 message: "enabled; DeepSeek strict schemas use the beta endpoint".to_string(),
7063 recommended_base_url: None,
7064 },
7065 Some(DeepSeekBaseUrlKind::NonBeta) => {
7066 let recommended = recommended_strict_base_url(config, &target.base_url);
7067 DoctorStrictToolModeStatus {
7068 enabled: true,
7069 status: "fallback_non_beta",
7070 function_strict_sent: false,
7071 message:
7072 "enabled, but function.strict is stripped for this non-beta DeepSeek endpoint"
7073 .to_string(),
7074 recommended_base_url: Some(recommended.to_string()),
7075 }
7076 }
7077 None => DoctorStrictToolModeStatus {
7078 enabled: true,
7079 status: "custom_endpoint",
7080 function_strict_sent: true,
7081 message: "enabled; function.strict will be sent to this custom endpoint".to_string(),
7082 recommended_base_url: None,
7083 },
7084 }
7085}
7086
7087fn doctor_strict_tool_mode_report_json(status: &DoctorStrictToolModeStatus) -> serde_json::Value {
7088 serde_json::json!({
7089 "enabled": status.enabled,
7090 "status": status.status,
7091 "function_strict_sent": status.function_strict_sent,
7092 "message": status.message,
7093 "recommended_base_url": status
7094 .recommended_base_url
7095 .as_deref()
7096 .map(crate::doctor::structural_url_authority),
7097 })
7098}
7099
7100#[derive(Debug, Clone, PartialEq, Eq)]
7101struct DoctorTlsStatus {
7102 certificate_verification: bool,
7103 insecure_skip_tls_verify: bool,
7104 provider: String,
7105 message: String,
7106}
7107
7108fn doctor_tls_status(config: &Config) -> DoctorTlsStatus {
7109 let provider = config.provider_identity_for(config.api_provider());
7110 let insecure_skip_tls_verify = config.insecure_skip_tls_verify();
7111 let message = if insecure_skip_tls_verify {
7112 format!(
7113 "TLS certificate verification cannot be disabled for provider {provider}; use SSL_CERT_FILE with a trusted custom CA bundle"
7114 )
7115 } else {
7116 "TLS certificate verification enabled".to_string()
7117 };
7118 DoctorTlsStatus {
7119 certificate_verification: true,
7120 insecure_skip_tls_verify,
7121 provider,
7122 message,
7123 }
7124}
7125
7126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7127enum DeepSeekBaseUrlKind {
7128 Beta,
7129 NonBeta,
7130}
7131
7132fn known_deepseek_base_url_kind(base_url: &str) -> Option<DeepSeekBaseUrlKind> {
7133 let normalized = base_url.trim_end_matches('/');
7134 if normalized.eq_ignore_ascii_case("https://api.deepseek.com/beta")
7135 || normalized.eq_ignore_ascii_case("https://api.deepseeki.com/beta")
7136 {
7137 Some(DeepSeekBaseUrlKind::Beta)
7138 } else if normalized.eq_ignore_ascii_case("https://api.deepseek.com")
7139 || normalized.eq_ignore_ascii_case("https://api.deepseek.com/v1")
7140 || normalized.eq_ignore_ascii_case("https://api.deepseeki.com")
7141 || normalized.eq_ignore_ascii_case("https://api.deepseeki.com/v1")
7142 {
7143 Some(DeepSeekBaseUrlKind::NonBeta)
7144 } else {
7145 None
7146 }
7147}
7148
7149fn recommended_strict_base_url(_config: &Config, _base_url: &str) -> &'static str {
7150 crate::config::DEFAULT_DEEPSEEK_BASE_URL
7151}
7152
7153fn doctor_timeout_recovery_lines(config: &Config) -> Vec<String> {
7154 let target = doctor_api_target(config);
7155 let mut lines = vec![format!(
7156 "Connection timed out while reaching {}.",
7157 crate::doctor::structural_url_authority(&target.base_url)
7158 )];
7159
7160 match config.api_provider() {
7161 crate::config::ApiProvider::Deepseek
7162 if target.base_url.contains("api.deepseek.com")
7163 && !target.base_url.contains("api.deepseeki.com") =>
7164 {
7165 lines.push(
7166 "If this is a custom DeepSeek-compatible endpoint, set its HTTPS base URL in ~/.codewhale/config.toml and rerun `codewhale doctor`."
7167 .to_string(),
7168 );
7169 }
7170 crate::config::ApiProvider::Deepseek | crate::config::ApiProvider::DeepseekCN => {
7171 lines.push(
7172 "If this is a custom DeepSeek-compatible endpoint, confirm it serves `/v1/models` and `/v1/chat/completions` over HTTPS."
7173 .to_string(),
7174 );
7175 }
7176 _ => {
7177 lines.push(
7178 "Confirm the configured provider endpoint is reachable and OpenAI-compatible for `/v1/models` and `/v1/chat/completions`."
7179 .to_string(),
7180 );
7181 }
7182 }
7183
7184 lines.push(
7185 "Run `codewhale doctor --json` and include `base_url`, `default_text_model`, and `api_connectivity` when filing an issue."
7186 .to_string(),
7187 );
7188 lines
7189}
7190
7191fn run_features_command(config: &Config, command: FeaturesCli) -> Result<()> {
7192 match command.command {
7193 FeaturesSubcommand::List => {
7194 print!("{}", render_feature_table(&config.features()));
7195 Ok(())
7196 }
7197 }
7198}
7199
7200async fn run_models(config: &Config, args: ModelsArgs) -> Result<()> {
7201 use crate::client::DeepSeekClient;
7202
7203 let client = DeepSeekClient::new(config)?;
7204 let mut models = client.list_models().await?;
7205 models.sort_by(|a, b| a.id.cmp(&b.id));
7206
7207 if args.json {
7208 println!("{}", serde_json::to_string_pretty(&models)?);
7209 return Ok(());
7210 }
7211
7212 if models.is_empty() {
7213 println!("No models returned by the API.");
7214 return Ok(());
7215 }
7216
7217 let default_model = config.default_model();
7218
7219 println!("Available models (default: {default_model})");
7220 for model in models {
7221 let marker = if model.id == default_model { "*" } else { " " };
7222 if let Some(owner) = model.owned_by {
7223 println!("{marker} {} ({owner})", model.id);
7224 } else {
7225 println!("{marker} {}", model.id);
7226 }
7227 }
7228
7229 Ok(())
7230}
7231
7232async fn run_speech(config: &Config, args: SpeechArgs) -> Result<()> {
7233 use crate::client::{DeepSeekClient, SpeechSynthesisRequest};
7234 use crate::config::ApiProvider;
7235 use crate::tools::speech::{
7236 DEFAULT_VOICE, SPEECH_MODEL_EXAMPLES, combine_speech_instructions,
7237 default_speech_output_name, describe_speech_voice, encode_voice_clone_sample_data_uri,
7238 infer_speech_model, normalize_speech_format,
7239 };
7240
7241 let SpeechArgs {
7242 text,
7243 output,
7244 output_dir,
7245 model,
7246 voice,
7247 instruction,
7248 voice_prompt,
7249 clone_voice,
7250 format,
7251 json: json_output,
7252 } = args;
7253
7254 if config.api_provider() != ApiProvider::XiaomiMimo {
7255 bail!(
7256 "`speech` requires provider = \"xiaomi-mimo\" (current: {}). Run with `--provider xiaomi-mimo` or set it in config.",
7257 config.api_provider().as_str()
7258 );
7259 }
7260
7261 if text.trim().is_empty() {
7262 bail!("Speech text cannot be empty");
7263 }
7264 let voice_is_data_uri = voice
7265 .as_deref()
7266 .map(str::trim)
7267 .is_some_and(|value| value.starts_with("data:audio/"));
7268 if clone_voice.is_some() && voice.is_some() {
7269 bail!("Use either --clone-voice or --voice for cloned voice data, not both");
7270 }
7271 let model = infer_speech_model(
7272 model.as_deref(),
7273 clone_voice.is_some() || voice_is_data_uri,
7274 voice_prompt.is_some(),
7275 );
7276 let model_lower = model.to_ascii_lowercase();
7277 if !model_lower.contains("tts") {
7278 bail!(
7279 "speech requires a TTS model (examples: {}); got {model}",
7280 SPEECH_MODEL_EXAMPLES.join(", ")
7281 );
7282 }
7283 let is_voice_design = model_lower.contains("voicedesign");
7284 let is_voice_clone = model_lower.contains("voiceclone");
7285
7286 let instruction = combine_speech_instructions(instruction, voice_prompt);
7287 if is_voice_design
7288 && instruction
7289 .as_deref()
7290 .is_none_or(|value| value.trim().is_empty())
7291 {
7292 bail!(
7293 "mimo-v2.5-tts-voicedesign requires --voice-prompt or --instruction to describe the voice"
7294 );
7295 }
7296
7297 let voice = if let Some(clone_path) = clone_voice {
7298 Some(encode_voice_clone_sample_data_uri(&clone_path)?)
7299 } else if is_voice_design {
7300 None
7301 } else if let Some(value) = voice.filter(|value| !value.trim().is_empty()) {
7302 Some(value)
7303 } else if is_voice_clone {
7304 bail!("mimo-v2.5-tts-voiceclone requires --clone-voice <mp3|wav> or --voice <data-uri>");
7305 } else {
7306 Some(DEFAULT_VOICE.to_string())
7307 };
7308 let format = normalize_speech_format(&format).with_context(|| {
7309 format!("Unsupported speech format '{format}' (allowed: wav, mp3, pcm16)")
7310 })?;
7311 let output = output.unwrap_or_else(|| {
7312 output_dir
7313 .or_else(|| config.speech_output_dir())
7314 .unwrap_or_default()
7315 .join(default_speech_output_name(&format))
7316 });
7317
7318 let client = DeepSeekClient::new(config)?;
7319 let response = client
7320 .synthesize_speech(SpeechSynthesisRequest {
7321 model: model.clone(),
7322 text,
7323 instruction,
7324 audio_format: format.clone(),
7325 voice,
7326 })
7327 .await?;
7328
7329 if let Some(parent) = output.parent().filter(|path| !path.as_os_str().is_empty()) {
7330 std::fs::create_dir_all(parent)
7331 .with_context(|| format!("Failed to create output directory {}", parent.display()))?;
7332 }
7333 std::fs::write(&output, &response.audio_bytes)
7334 .with_context(|| format!("Failed to write audio file {}", output.display()))?;
7335
7336 if json_output {
7337 println!(
7338 "{}",
7339 serde_json::to_string_pretty(&serde_json::json!({
7340 "mode": "speech",
7341 "success": true,
7342 "model": response.model,
7343 "format": response.audio_format,
7344 "output": output.display().to_string(),
7345 "bytes": response.audio_bytes.len(),
7346 "voice": response.voice.as_deref().map(describe_speech_voice),
7347 "transcript": response.transcript,
7348 }))?
7349 );
7350 } else {
7351 println!(
7352 "Generated speech: {} ({} bytes, model: {}, format: {})",
7353 output.display(),
7354 response.audio_bytes.len(),
7355 response.model,
7356 response.audio_format
7357 );
7358 }
7359
7360 Ok(())
7361}
7362
7363#[cfg(test)]
7364mod speech_cli_tests {
7365 use super::*;
7366 use crate::tools::speech::{
7367 default_speech_output_name, infer_speech_model, normalize_speech_format,
7368 };
7369
7370 #[test]
7371 fn normalizes_documented_speech_formats() {
7372 assert_eq!(normalize_speech_format("WAV").as_deref(), Some("wav"));
7373 assert_eq!(normalize_speech_format("pcm16").as_deref(), Some("pcm16"));
7374 assert_eq!(normalize_speech_format("pcm").as_deref(), Some("pcm16"));
7375 assert_eq!(normalize_speech_format("flac"), None);
7376 }
7377
7378 #[test]
7379 fn default_speech_output_tracks_requested_format() {
7380 assert_eq!(
7381 PathBuf::from(default_speech_output_name("mp3")),
7382 PathBuf::from("speech.mp3")
7383 );
7384 assert_eq!(
7385 PathBuf::from("audio").join(default_speech_output_name("pcm")),
7386 PathBuf::from("audio").join("speech.pcm16")
7387 );
7388 }
7389
7390 #[test]
7391 fn speech_command_parses_cli_passthrough_smoke() {
7392 let cli = Cli::try_parse_from([
7393 "codewhale-tui",
7394 "speech",
7395 "hello",
7396 "--model",
7397 "tts",
7398 "--format",
7399 "pcm",
7400 "--output-dir",
7401 "audio",
7402 "--voice",
7403 "Mia",
7404 ])
7405 .expect("speech command parses");
7406
7407 let Some(Commands::Speech(args)) = cli.command else {
7408 panic!("expected speech command");
7409 };
7410 assert_eq!(args.text, "hello");
7411 assert_eq!(
7412 infer_speech_model(args.model.as_deref(), false, false),
7413 "mimo-v2.5-tts"
7414 );
7415 assert_eq!(
7416 normalize_speech_format(&args.format).as_deref(),
7417 Some("pcm16")
7418 );
7419 assert_eq!(args.output_dir, Some(PathBuf::from("audio")));
7420 assert_eq!(args.voice.as_deref(), Some("Mia"));
7421 }
7422}
7423
7424async fn test_api_connectivity(config: &Config) -> Result<()> {
7426 use crate::client::DeepSeekClient;
7427 use crate::models::{ContentBlock, Message, MessageRequest};
7428
7429 let client = DeepSeekClient::new(config)?;
7430 let model = client.model().to_string();
7431
7432 if crate::doctor::is_keyless_ds4_route(config) {
7433 return crate::doctor::probe_ds4_models(config).await;
7434 }
7435
7436 let request = MessageRequest {
7438 model: model.clone(),
7439 messages: vec![Message {
7440 role: "user".to_string(),
7441 content: vec![ContentBlock::Text {
7442 text: "hi".to_string(),
7443 cache_control: None,
7444 }],
7445 }],
7446 max_tokens: 1,
7447 system: None,
7448 tools: None,
7449 tool_choice: None,
7450 metadata: None,
7451 thinking: None,
7452 reasoning_effort: Some("off".to_string()),
7454 stream: Some(false),
7455 temperature: None,
7456 top_p: None,
7457 };
7458
7459 let timeout_duration = std::time::Duration::from_secs(15);
7461 match tokio::time::timeout(timeout_duration, client.create_message(request)).await {
7462 Ok(Ok(_response)) => Ok(()),
7463 Ok(Err(e)) => Err(e),
7464 Err(_) => anyhow::bail!("Request timeout after 15 seconds"),
7465 }
7466}
7467
7468fn rustc_version() -> String {
7469 let Some(mut cmd) = crate::dependencies::RustC::command() else {
7470 return "unknown".to_string();
7471 };
7472 let Ok(output) = cmd.arg("--version").output() else {
7473 return "unknown".to_string();
7474 };
7475 String::from_utf8(output.stdout)
7476 .map(|s| s.trim().to_string())
7477 .unwrap_or_else(|_| "unknown".to_string())
7478}
7479
7480fn sessions_resume_command() -> &'static str {
7482 "codewhale resume"
7483}
7484
7485fn list_sessions(limit: usize, search: Option<String>) -> Result<()> {
7486 use crate::palette;
7487 use colored::Colorize;
7488 use session_manager::{SessionManager, format_session_line};
7489
7490 let (action_r, action_g, action_b) = palette::WHALE_ACTION_RGB;
7491 let (human_r, human_g, human_b) = palette::WHALE_HUMAN_RGB;
7492 let (sky_r, sky_g, sky_b) = palette::WHALE_INFO_RGB;
7493 let (aqua_r, aqua_g, aqua_b) = palette::WHALE_INFO_RGB;
7494
7495 let manager = SessionManager::default_location()?;
7496
7497 let sessions = if let Some(query) = search {
7498 manager.search_sessions(&query)?
7499 } else {
7500 manager.list_sessions()?
7501 };
7502
7503 if sessions.is_empty() {
7504 println!("{}", "No sessions found.".truecolor(sky_r, sky_g, sky_b));
7505 println!(
7506 "Start a new session with: {}",
7507 "codewhale".truecolor(human_r, human_g, human_b)
7508 );
7509 return Ok(());
7510 }
7511
7512 println!(
7513 "{}",
7514 "Saved Sessions"
7515 .truecolor(action_r, action_g, action_b)
7516 .bold()
7517 );
7518 println!("{}", "==============".truecolor(sky_r, sky_g, sky_b));
7519 println!();
7520
7521 for (i, session) in sessions.iter().take(limit).enumerate() {
7522 let line = format_session_line(session);
7523 if i == 0 {
7524 println!(" {} {}", "*".truecolor(aqua_r, aqua_g, aqua_b), line);
7525 } else {
7526 println!(" {line}");
7527 }
7528 }
7529
7530 let total = sessions.len();
7531 if total > limit {
7532 println!();
7533 println!(
7534 " {} more session(s). Use --limit to show more.",
7535 total - limit
7536 );
7537 }
7538
7539 println!();
7540 println!(
7541 "Resume with: {} {}",
7542 sessions_resume_command().truecolor(action_r, action_g, action_b),
7543 "<session-id>".dimmed()
7544 );
7545 println!(
7546 "Continue latest in this workspace: {}",
7547 "codewhale --continue".truecolor(action_r, action_g, action_b)
7548 );
7549
7550 Ok(())
7551}
7552
7553fn init_project() -> Result<()> {
7555 use crate::palette;
7556 use colored::Colorize;
7557 use project_context::create_default_agents_md;
7558
7559 let (sky_r, sky_g, sky_b) = palette::WHALE_INFO_RGB;
7560 let (aqua_r, aqua_g, aqua_b) = palette::WHALE_INFO_RGB;
7561 let (red_r, red_g, red_b) = palette::WHALE_ERROR_RGB;
7562
7563 let workspace = std::env::current_dir()?;
7564 let agents_path = workspace.join("AGENTS.md");
7565
7566 if agents_path.exists() {
7567 println!(
7568 "{} AGENTS.md already exists at {}",
7569 "!".truecolor(sky_r, sky_g, sky_b),
7570 agents_path.display()
7571 );
7572 return Ok(());
7573 }
7574
7575 match create_default_agents_md(&workspace) {
7576 Ok(path) => {
7577 println!(
7578 "{} Created {}",
7579 "✓".truecolor(aqua_r, aqua_g, aqua_b),
7580 path.display()
7581 );
7582 println!();
7583 println!("Edit this file to customize how the AI agent works with your project.");
7584 println!("The instructions will be loaded automatically when you run codewhale.");
7585 }
7586 Err(e) => {
7587 println!(
7588 "{} Failed to create AGENTS.md: {}",
7589 "✗".truecolor(red_r, red_g, red_b),
7590 e
7591 );
7592 }
7593 }
7594
7595 Ok(())
7596}
7597
7598fn resolve_workspace(cli: &Cli) -> PathBuf {
7599 cli.workspace
7600 .clone()
7601 .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
7602}
7603
7604fn load_config_from_cli(cli: &Cli) -> Result<Config> {
7605 load_config_from_cli_with_effective_profile(cli).map(|(config, _)| config)
7606}
7607
7608fn load_doctor_config_from_cli(cli: &Cli, args: &DoctorArgs) -> Result<Config> {
7613 if args.probe_api || args.probe_local {
7614 return load_config_from_cli(cli);
7615 }
7616 load_structural_config_from_cli(cli)
7617}
7618
7619fn load_structural_config_from_cli(cli: &Cli) -> Result<Config> {
7620 let profile = effective_config_profile(cli);
7621 let mut config = Config::load_structural(cli.config.clone(), profile.as_deref())?;
7622 if let Ok(settings) = crate::settings::Settings::load_read_only() {
7623 apply_saved_reasoning_preference(&mut config, &settings);
7624 }
7625 cli.feature_toggles.apply(&mut config)?;
7626 Ok(config)
7627}
7628
7629fn effective_config_profile(cli: &Cli) -> Option<String> {
7630 cli.profile
7631 .clone()
7632 .or_else(|| std::env::var("CODEWHALE_PROFILE").ok())
7633 .or_else(|| std::env::var("DEEPSEEK_PROFILE").ok())
7634}
7635
7636fn load_config_from_cli_with_effective_profile(cli: &Cli) -> Result<(Config, Option<String>)> {
7637 let profile = effective_config_profile(cli);
7638 let mut config = Config::load(cli.config.clone(), profile.as_deref())?;
7639 if let Ok(settings) = crate::settings::Settings::load_read_only() {
7643 apply_saved_reasoning_preference(&mut config, &settings);
7644 }
7645 cli.feature_toggles.apply(&mut config)?;
7646 Ok((config, profile))
7647}
7648
7649fn apply_saved_reasoning_preference(config: &mut Config, settings: &crate::settings::Settings) {
7657 let Some(reasoning_effort) = settings.reasoning_effort.as_ref() else {
7658 return;
7659 };
7660 config.reasoning_effort = Some(reasoning_effort.clone());
7661 config.reasoning_effort_inferred_from_legacy_alias = false;
7662}
7663
7664fn read_api_key_from_stdin() -> Result<String> {
7665 let mut stdin = io::stdin();
7666 if stdin.is_terminal() {
7667 bail!("No API key provided. Pass --api-key or pipe one via stdin.");
7668 }
7669 let mut buffer = String::new();
7670 stdin.read_to_string(&mut buffer)?;
7671 let api_key = buffer.trim().to_string();
7672 if api_key.is_empty() {
7673 bail!("No API key provided via stdin.");
7674 }
7675 Ok(api_key)
7676}
7677
7678fn run_login(api_key: Option<String>) -> Result<()> {
7679 let api_key = match api_key {
7680 Some(key) => key,
7681 None => read_api_key_from_stdin()?,
7682 };
7683 let saved = config::save_api_key(&api_key)?;
7684 println!("Saved API key to {}", saved.describe());
7685 Ok(())
7686}
7687
7688fn run_logout() -> Result<()> {
7689 config::clear_api_key()?;
7690 println!("Cleared saved API key.");
7691 Ok(())
7692}
7693
7694async fn run_xai_device_auth(config_path: Option<&Path>) -> Result<()> {
7695 let pending = xai_oauth::device_code_login().await?;
7696 let activation = xai_oauth::activate_device_login(pending, config_path, None)?;
7697 println!(
7698 "xAI OAuth is ready; activated {} via {}",
7699 codewhale_config::quote_os_path(&activation.auth_path),
7700 codewhale_config::quote_os_path(&activation.config_path)
7701 );
7702 Ok(())
7703}
7704
7705fn resolve_session_id(session_id: Option<String>, last: bool, workspace: &Path) -> Result<String> {
7706 if last {
7707 return latest_session_id_for_workspace(workspace)?.ok_or_else(|| {
7708 anyhow!(
7709 "No saved sessions found for workspace {}. Use `codewhale sessions` to list all sessions, or `codewhale resume <SESSION_ID>` to resume one explicitly.",
7710 workspace.display()
7711 )
7712 });
7713 }
7714 if let Some(id) = session_id {
7715 return Ok(id);
7716 }
7717 pick_session_id()
7718}
7719
7720fn latest_session_id_for_workspace(workspace: &Path) -> std::io::Result<Option<String>> {
7721 let manager = SessionManager::default_location()?;
7722 Ok(manager
7723 .get_latest_session_for_workspace(workspace)?
7724 .map(|session| session.id))
7725}
7726
7727fn fork_session(
7728 config: &Config,
7729 session_id: Option<String>,
7730 last: bool,
7731 workspace: &Path,
7732) -> Result<String> {
7733 let manager = SessionManager::default_location()?;
7734 let saved = if last {
7735 let Some(meta) = manager.get_latest_session_for_workspace(workspace)? else {
7736 bail!(
7737 "No saved sessions found for workspace {}.",
7738 workspace.display()
7739 );
7740 };
7741 manager.load_session(&meta.id)?
7742 } else {
7743 let id = resolve_session_id(session_id, false, workspace)?;
7744 manager.load_session_by_prefix(&id)?
7745 };
7746 let saved_provider_identity = saved
7747 .metadata
7748 .model_provider_id
7749 .as_deref()
7750 .filter(|identity| !identity.trim().is_empty())
7751 .unwrap_or(&saved.metadata.model_provider);
7752 let provider_identity = config
7753 .resolve_persisted_provider_identity(
7754 Some(&saved.metadata.model_provider),
7755 saved.metadata.model_provider_id.as_deref(),
7756 )
7757 .map_err(anyhow::Error::msg)
7758 .with_context(|| {
7759 format!(
7760 "saved session provider '{}' is unavailable; fork will not fall back",
7761 saved_provider_identity
7762 )
7763 })?;
7764
7765 let system_prompt = saved
7766 .system_prompt
7767 .as_ref()
7768 .map(|text| SystemPrompt::Text(text.clone()));
7769 let mut forked = create_saved_session(
7770 &saved.messages,
7771 &saved.metadata.model,
7772 &saved.metadata.workspace,
7773 saved.metadata.total_tokens,
7774 system_prompt.as_ref(),
7775 );
7776 forked.metadata.set_model_provider_route(
7777 provider_identity.provider.as_str(),
7778 provider_identity.persisted_id(),
7779 );
7780 forked.metadata.copy_cost_from(&saved.metadata);
7781 forked.metadata.mark_forked_from(&saved.metadata);
7782 manager.save_session(&forked)?;
7783
7784 let source_title = saved.metadata.title.trim();
7785 let source_label = if source_title.is_empty() {
7786 "session".to_string()
7787 } else {
7788 format!("\"{source_title}\"")
7789 };
7790 println!(
7791 "Forked {source_label} ({source_id}) → new session {new_id}",
7792 source_id = truncate_id(&saved.metadata.id),
7793 new_id = truncate_id(&forked.metadata.id),
7794 );
7795
7796 Ok(forked.metadata.id)
7797}
7798
7799fn pick_session_id() -> Result<String> {
7800 let manager = SessionManager::default_location()?;
7801 let sessions = manager.list_sessions()?;
7802 if sessions.is_empty() {
7803 bail!("No saved sessions found.");
7804 }
7805
7806 println!("Select a session to resume:");
7807 for (idx, session) in sessions.iter().enumerate() {
7808 println!(" {:>2}. {} ({})", idx + 1, session.title, session.id);
7809 }
7810 print!("Enter a number (or press Enter to cancel): ");
7811 io::stdout().flush()?;
7812
7813 let mut input = String::new();
7814 io::stdin().read_line(&mut input)?;
7815 let input = input.trim();
7816 if input.is_empty() {
7817 bail!("No session selected.");
7818 }
7819 let idx: usize = input
7820 .parse()
7821 .map_err(|_| anyhow::anyhow!("Invalid input"))?;
7822 let session = sessions
7823 .get(idx.saturating_sub(1))
7824 .ok_or_else(|| anyhow::anyhow!("Selection out of range"))?;
7825 Ok(session.id.clone())
7826}
7827
7828async fn run_review(config: &Config, args: ReviewArgs) -> Result<()> {
7829 use crate::client::DeepSeekClient;
7830
7831 let diff = collect_diff(&args)?;
7832 if diff.trim().is_empty() {
7833 bail!("No diff to review.");
7834 }
7835 validate_review_receipt_args(&args)?;
7836 if args.check_receipt {
7837 return run_review_receipt_check(&diff, &args);
7838 }
7839
7840 let model = resolve_review_model(config, args.model.as_deref());
7841 let route = resolve_cli_exec_route(config, &model, &diff, args.model.is_none()).await?;
7842 let execution_config = config_for_cli_route(config, &route);
7843 let route_provider = execution_config.provider_identity_for(route.provider);
7844 let model = route.model.clone();
7845 let user_prompt =
7846 format!("Review the following diff and provide feedback:\n\n{diff}\n\nEnd of diff.");
7847 let reasoning_effort = route.reasoning_effort.and_then(|effort| {
7848 cli_reasoning_effort_value_for_prompt(&execution_config, &model, effort, &user_prompt)
7849 });
7850
7851 let system = SystemPrompt::Text(
7852 "You are a senior code reviewer. Focus on bugs, risks, behavioral regressions, and missing tests. \
7853Provide findings ordered by severity with file references, then open questions, then a brief summary."
7854 .to_string(),
7855 );
7856 let client = DeepSeekClient::new(&execution_config)?;
7857 let request_route = client.effective_route_envelope(&model, chrono::Utc::now());
7858 let request = MessageRequest {
7859 model: model.clone(),
7860 messages: vec![Message {
7861 role: "user".to_string(),
7862 content: vec![ContentBlock::Text {
7863 text: user_prompt,
7864 cache_control: None,
7865 }],
7866 }],
7867 max_tokens: crate::route_budget::effective_max_output_tokens_for_route(
7868 request_route.provider,
7869 &request_route.model,
7870 None,
7871 ),
7872 system: Some(system),
7873 tools: None,
7874 tool_choice: None,
7875 metadata: None,
7876 thinking: None,
7877 reasoning_effort,
7878 stream: Some(false),
7879 temperature: None,
7880 top_p: None,
7881 };
7882
7883 let response = client.create_message(request).await?;
7884 let review_stop_reason = response.stop_reason.clone();
7885 let review_incomplete = crate::models::is_incomplete_stop_reason(review_stop_reason.as_deref());
7886 let mut output = String::new();
7887 for block in response.content {
7888 if let ContentBlock::Text { text, .. } = block {
7889 output.push_str(&text);
7890 }
7891 }
7892 let receipt = if args.write_receipt && !review_incomplete {
7895 let parsed_output = crate::tools::review::ReviewOutput::from_str(&output);
7896 let receipt = crate::tools::review::build_review_receipt(
7897 review_target_label(&args),
7898 &diff,
7899 &route_provider,
7900 &model,
7901 &parsed_output,
7902 &output,
7903 Vec::new(),
7904 );
7905 let path =
7906 crate::tools::review::write_review_receipt(&receipt, args.receipt_path.as_deref())?;
7907 Some((path, receipt))
7908 } else {
7909 None
7910 };
7911 let review_error = review_incomplete.then(|| {
7912 format!(
7913 "Model response incomplete: provider stop reason `{}`; the partial review was not accepted.",
7914 crate::models::stop_reason_detail(review_stop_reason.as_deref())
7915 )
7916 });
7917 if args.json {
7918 println!(
7919 "{}",
7920 serde_json::to_string_pretty(&serde_json::json!({
7921 "mode": "review",
7922 "provider": route_provider,
7923 "model": model,
7924 "success": !review_incomplete,
7925 "content": output,
7926 "stop_reason": review_stop_reason,
7927 "error": review_error,
7928 "receipt_path": receipt
7929 .as_ref()
7930 .map(|(path, _)| path.display().to_string()),
7931 "receipt": receipt.as_ref().map(|(_, receipt)| receipt),
7932 }))?
7933 );
7934 if let Some(error) = review_error {
7935 anyhow::bail!(error);
7936 }
7937 } else {
7938 println!("{output}");
7939 if let Some((path, _)) = receipt {
7940 eprintln!("Review receipt written: {}", path.display());
7941 }
7942 if let Some(error) = review_error {
7943 anyhow::bail!(error);
7944 }
7945 }
7946 Ok(())
7947}
7948
7949fn resolve_review_model(config: &Config, explicit_model: Option<&str>) -> String {
7950 explicit_model
7951 .map(str::trim)
7952 .filter(|model| !model.is_empty())
7953 .map(str::to_string)
7954 .unwrap_or_else(|| config.default_model())
7955}
7956
7957fn validate_review_receipt_args(args: &ReviewArgs) -> Result<()> {
7958 if args.receipt_path.is_some() && !args.write_receipt && !args.check_receipt {
7959 bail!("--receipt-path requires --write-receipt or --check-receipt");
7960 }
7961 if args.write_receipt && args.check_receipt {
7962 bail!("--write-receipt and --check-receipt are mutually exclusive");
7963 }
7964 Ok(())
7965}
7966
7967fn run_review_receipt_check(diff: &str, args: &ReviewArgs) -> Result<()> {
7968 let (path, receipt) = if let Some(path) = args.receipt_path.as_ref() {
7969 (
7970 path.clone(),
7971 crate::tools::review::read_review_receipt(path)
7972 .with_context(|| format!("failed to read review receipt {}", path.display()))?,
7973 )
7974 } else {
7975 crate::tools::review::latest_review_receipt_for_diff(diff)?.ok_or_else(|| {
7976 anyhow!(
7977 "No review receipt found for the current diff. Run `codewhale review --write-receipt` first, or pass --receipt-path."
7978 )
7979 })?
7980 };
7981 let validation =
7982 crate::tools::review::validate_review_receipt_for_diff(diff, &receipt, Some(path.clone()));
7983
7984 if args.json {
7985 println!(
7986 "{}",
7987 serde_json::to_string_pretty(&serde_json::json!({
7988 "mode": "review_receipt_check",
7989 "success": validation.passed,
7990 "validation": review_receipt_validation_public_json(&validation),
7991 }))?
7992 );
7993 } else if validation.passed {
7994 println!("Review receipt valid: {}", path.display());
7995 }
7996
7997 if !validation.passed {
7998 bail!("Review receipt check failed: {}", validation.reason);
7999 }
8000 Ok(())
8001}
8002
8003fn review_receipt_validation_public_json(
8004 validation: &crate::tools::review::ReviewReceiptValidation,
8005) -> serde_json::Value {
8006 let unresolved_risk = validation.unresolved_risk.as_ref();
8007 serde_json::json!({
8008 "passed": validation.passed,
8009 "status": review_receipt_validation_status(validation),
8010 "diff_fingerprint": validation.diff_fingerprint.as_str(),
8011 "receipt_fingerprint": validation.receipt_fingerprint.as_deref(),
8012 "unresolved": unresolved_risk.is_some_and(|risk| risk.unresolved),
8013 "risk_level": unresolved_risk.map(|risk| risk.level.as_str()),
8014 })
8015}
8016
8017fn review_receipt_validation_status(
8018 validation: &crate::tools::review::ReviewReceiptValidation,
8019) -> &'static str {
8020 if validation.passed {
8021 "valid"
8022 } else if validation
8023 .receipt_fingerprint
8024 .as_deref()
8025 .is_some_and(|fingerprint| fingerprint != validation.diff_fingerprint.as_str())
8026 {
8027 "diff_mismatch"
8028 } else if validation
8029 .unresolved_risk
8030 .as_ref()
8031 .is_some_and(|risk| risk.unresolved)
8032 {
8033 "unresolved_risk"
8034 } else if validation
8035 .reason
8036 .starts_with("unsupported review receipt schema version")
8037 {
8038 "unsupported_schema"
8039 } else if validation.reason.starts_with("review receipt check ") {
8040 "check_failed"
8041 } else {
8042 "invalid"
8043 }
8044}
8045
8046async fn run_pr(
8050 cli: &Cli,
8051 config: &Config,
8052 number: u32,
8053 repo: Option<&str>,
8054 checkout: bool,
8055 pending_telemetry_notice: Option<crate::telemetry_notice::PendingTelemetryNotice>,
8056 plugin_registry: Arc<crate::plugins::PluginRegistry>,
8057) -> Result<()> {
8058 if !is_command_available("gh") {
8059 bail!(
8060 "`gh` CLI not found on PATH. Install GitHub CLI \
8061 (https://cli.github.com) and authenticate (`gh auth login`) \
8062 so `codewhale pr <N>` can fetch PR metadata and the diff."
8063 );
8064 }
8065
8066 let view = run_gh_pr_view(number, repo)?;
8067 let diff = run_gh_pr_diff(number, repo)?;
8068
8069 if checkout {
8070 match run_gh_pr_checkout(number, repo) {
8071 Ok(()) => eprintln!("Checked out PR #{number} into the current workspace."),
8072 Err(err) => eprintln!(
8073 "warning: gh pr checkout #{number} failed ({err}). Continuing without checkout."
8074 ),
8075 }
8076 }
8077
8078 let prompt = format_pr_prompt(number, &view, &diff);
8079 let resume_session_id = if cli.continue_session {
8080 let workspace = resolve_workspace(cli);
8081 latest_session_id_for_workspace(&workspace).ok().flatten()
8082 } else {
8083 cli.resume.clone()
8084 };
8085 run_interactive(
8086 cli,
8087 config,
8088 resume_session_id,
8089 Some(tui::InitialInput::Prefill(prompt)),
8090 pending_telemetry_notice,
8091 plugin_registry,
8092 )
8093 .await
8094}
8095
8096fn is_command_available(name: &str) -> bool {
8110 let Some(path) = std::env::var_os("PATH") else {
8111 return false;
8112 };
8113 for dir in std::env::split_paths(&path) {
8114 let candidate = dir.join(name);
8115 if candidate.is_file() {
8116 return true;
8117 }
8118 #[cfg(windows)]
8119 {
8120 if candidate.extension().is_none() && candidate.with_extension("exe").is_file() {
8124 return true;
8125 }
8126 }
8127 }
8128 false
8129}
8130
8131#[derive(Debug, Clone, Default)]
8132struct GhPullRequest {
8133 title: String,
8134 body: String,
8135 base: String,
8136 head: String,
8137 url: String,
8138}
8139
8140fn run_gh_pr_view(number: u32, repo: Option<&str>) -> Result<GhPullRequest> {
8141 let mut cmd = crate::dependencies::Gh::command()
8142 .ok_or_else(|| anyhow::anyhow!("gh not found on PATH"))?;
8143 cmd.arg("pr").arg("view").arg(number.to_string());
8144 if let Some(r) = repo {
8145 cmd.arg("--repo").arg(r);
8146 }
8147 cmd.arg("--json")
8148 .arg("title,body,baseRefName,headRefName,url");
8149 let output = cmd
8150 .output()
8151 .map_err(|e| anyhow::anyhow!("Failed to run `gh pr view`: {e}"))?;
8152 if !output.status.success() {
8153 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
8154 bail!("gh pr view #{number} failed: {stderr}");
8155 }
8156 let raw = String::from_utf8_lossy(&output.stdout).to_string();
8157 let value: serde_json::Value = serde_json::from_str(&raw)
8158 .map_err(|e| anyhow::anyhow!("gh pr view returned non-JSON output: {e}"))?;
8159 let pick = |key: &str| {
8160 value
8161 .get(key)
8162 .and_then(serde_json::Value::as_str)
8163 .unwrap_or_default()
8164 .to_string()
8165 };
8166 Ok(GhPullRequest {
8167 title: pick("title"),
8168 body: pick("body"),
8169 base: pick("baseRefName"),
8170 head: pick("headRefName"),
8171 url: pick("url"),
8172 })
8173}
8174
8175fn run_gh_pr_diff(number: u32, repo: Option<&str>) -> Result<String> {
8176 let mut cmd = crate::dependencies::Gh::command()
8177 .ok_or_else(|| anyhow::anyhow!("gh not found on PATH"))?;
8178 cmd.arg("pr").arg("diff").arg(number.to_string());
8179 if let Some(r) = repo {
8180 cmd.arg("--repo").arg(r);
8181 }
8182 let output = cmd
8183 .output()
8184 .map_err(|e| anyhow::anyhow!("Failed to run `gh pr diff`: {e}"))?;
8185 if !output.status.success() {
8186 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
8187 bail!("gh pr diff #{number} failed: {stderr}");
8188 }
8189 Ok(String::from_utf8_lossy(&output.stdout).to_string())
8190}
8191
8192fn run_gh_pr_checkout(number: u32, repo: Option<&str>) -> Result<()> {
8193 let mut cmd = crate::dependencies::Gh::command()
8194 .ok_or_else(|| anyhow::anyhow!("gh not found on PATH"))?;
8195 cmd.arg("pr").arg("checkout").arg(number.to_string());
8196 if let Some(r) = repo {
8197 cmd.arg("--repo").arg(r);
8198 }
8199 let output = cmd
8200 .output()
8201 .map_err(|e| anyhow::anyhow!("Failed to run `gh pr checkout`: {e}"))?;
8202 if !output.status.success() {
8203 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
8204 bail!("gh pr checkout #{number} failed: {stderr}");
8205 }
8206 Ok(())
8207}
8208
8209fn format_pr_prompt(number: u32, view: &GhPullRequest, diff: &str) -> String {
8214 const MAX_DIFF_BYTES: usize = 200 * 1024;
8215 let diff_section = if diff.len() > MAX_DIFF_BYTES {
8216 let cut = (0..=MAX_DIFF_BYTES)
8217 .rev()
8218 .find(|&i| diff.is_char_boundary(i))
8219 .unwrap_or(0);
8220 format!(
8221 "{}\n\n[…diff truncated at {} KiB; ask me to fetch more if needed]\n",
8222 &diff[..cut],
8223 MAX_DIFF_BYTES / 1024
8224 )
8225 } else {
8226 diff.to_string()
8227 };
8228 let body = if view.body.trim().is_empty() {
8229 "(no description)".to_string()
8230 } else {
8231 view.body.trim().to_string()
8232 };
8233 let title = if view.title.trim().is_empty() {
8234 format!("(PR #{number})")
8235 } else {
8236 view.title.trim().to_string()
8237 };
8238 let branches = match (view.base.is_empty(), view.head.is_empty()) {
8239 (false, false) => format!("{} ← {}", view.base, view.head),
8240 (false, true) => view.base.clone(),
8241 (true, false) => view.head.clone(),
8242 _ => "(unknown)".to_string(),
8243 };
8244 format!(
8245 "Review PR #{number} — {title}\n\
8246 \n\
8247 URL: {url}\n\
8248 Branches: {branches}\n\
8249 \n\
8250 ## Description\n\
8251 \n\
8252 {body}\n\
8253 \n\
8254 ## Diff\n\
8255 \n\
8256 ```diff\n\
8257 {diff_section}\n\
8258 ```\n",
8259 url = if view.url.is_empty() {
8260 "(unavailable)"
8261 } else {
8262 view.url.as_str()
8263 },
8264 )
8265}
8266
8267fn collect_diff(args: &ReviewArgs) -> Result<String> {
8268 let mut cmd = crate::dependencies::Git::command()
8269 .ok_or_else(|| anyhow::anyhow!("git not found on PATH"))?;
8270 cmd.arg("diff");
8271 if args.staged {
8272 cmd.arg("--cached");
8273 }
8274 if let Some(base) = &args.base {
8275 cmd.arg(format!("{base}...HEAD"));
8276 }
8277 if let Some(path) = &args.path {
8278 cmd.arg("--").arg(path);
8279 }
8280
8281 let output = cmd
8282 .output()
8283 .map_err(|e| anyhow::anyhow!("Failed to run git diff. Is git installed? ({e})"))?;
8284 if !output.status.success() {
8285 let stderr = String::from_utf8_lossy(&output.stderr);
8286 bail!("git diff failed: {}", stderr.trim());
8287 }
8288 let mut diff = String::from_utf8_lossy(&output.stdout).to_string();
8289 if diff.len() > args.max_chars {
8290 diff = crate::utils::truncate_with_ellipsis(&diff, args.max_chars, "\n...[truncated]\n");
8291 }
8292 Ok(diff)
8293}
8294
8295fn review_target_label(args: &ReviewArgs) -> String {
8296 let mut label = if args.staged {
8297 "staged".to_string()
8298 } else if let Some(base) = args
8299 .base
8300 .as_deref()
8301 .map(str::trim)
8302 .filter(|base| !base.is_empty())
8303 {
8304 format!("base:{base}")
8305 } else {
8306 "working-tree".to_string()
8307 };
8308 if let Some(path) = &args.path {
8309 label.push(' ');
8310 label.push_str(path.to_string_lossy().as_ref());
8311 }
8312 label
8313}
8314
8315fn run_apply(args: ApplyArgs) -> Result<()> {
8316 let patch = if let Some(path) = args.patch_file {
8317 std::fs::read_to_string(&path)
8318 .map_err(|e| anyhow::anyhow!("Failed to read patch {}: {}", path.display(), e))?
8319 } else {
8320 read_patch_from_stdin()?
8321 };
8322 if patch.trim().is_empty() {
8323 bail!("Patch is empty.");
8324 }
8325
8326 let mut tmp = NamedTempFile::new()?;
8327 tmp.write_all(patch.as_bytes())?;
8328 let tmp_path = tmp.path().to_path_buf();
8329
8330 let output = crate::dependencies::Git::command()
8331 .ok_or_else(|| anyhow::anyhow!("git not found on PATH"))?
8332 .arg("apply")
8333 .arg("--whitespace=nowarn")
8334 .arg(&tmp_path)
8335 .output()
8336 .map_err(|e| anyhow::anyhow!("Failed to run git apply: {e}"))?;
8337
8338 if !output.status.success() {
8339 let stderr = String::from_utf8_lossy(&output.stderr);
8340 bail!("git apply failed: {}", stderr.trim());
8341 }
8342 println!("Applied patch successfully.");
8343 Ok(())
8344}
8345
8346fn read_patch_from_stdin() -> Result<String> {
8347 let mut stdin = io::stdin();
8348 if stdin.is_terminal() {
8349 bail!("No patch file provided and stdin is empty.");
8350 }
8351 let mut buffer = String::new();
8352 stdin.read_to_string(&mut buffer)?;
8353 Ok(buffer)
8354}
8355
8356async fn run_mcp_command(
8357 config: &Config,
8358 workspace: &Path,
8359 command: McpCommand,
8360 plugins: &crate::plugins::PluginRegistry,
8361) -> Result<()> {
8362 let config_path = config.mcp_config_path();
8363 match command {
8364 McpCommand::Init { force } => {
8365 let status = init_mcp_config(&config_path, force)?;
8366 match status {
8367 WriteStatus::Created => {
8368 println!("Created MCP config at {}", config_path.display());
8369 }
8370 WriteStatus::Overwritten => {
8371 println!("Overwrote MCP config at {}", config_path.display());
8372 }
8373 WriteStatus::SkippedExists => {
8374 println!(
8375 "MCP config already exists at {} (use --force to overwrite)",
8376 config_path.display()
8377 );
8378 }
8379 }
8380 println!("Edit the file, then run `codewhale mcp list` or `codewhale mcp tools`.");
8381 Ok(())
8382 }
8383 McpCommand::List => {
8384 let cfg = crate::mcp::load_config_with_workspace_and_plugins(
8385 &config_path,
8386 workspace,
8387 plugins,
8388 )?;
8389 if cfg.servers.is_empty() {
8390 println!(
8391 "No MCP servers configured in {} or {}",
8392 config_path.display(),
8393 crate::mcp::workspace_mcp_config_path(workspace).display()
8394 );
8395 return Ok(());
8396 }
8397 println!("MCP servers ({}):", cfg.servers.len());
8398 for (name, server) in cfg.servers {
8399 let status = if server.enabled && !server.disabled {
8400 "enabled"
8401 } else {
8402 "disabled"
8403 };
8404 let auth_status = crate::mcp::oauth::auth_status_for_server(&name, &server).await;
8405 let auth = if auth_status == crate::mcp::oauth::McpAuthStatus::Unsupported {
8406 String::new()
8407 } else {
8408 format!(
8409 " auth={}",
8410 auth_status
8411 .to_string()
8412 .to_ascii_lowercase()
8413 .replace(' ', "-")
8414 )
8415 };
8416 let args = if server.args.is_empty() {
8417 "".to_string()
8418 } else {
8419 format!(" {}", server.args.join(" "))
8420 };
8421 let cmd_str = if let Some(cmd) = server.command {
8422 format!("{cmd}{args}")
8423 } else if let Some(url) = server.url {
8424 url
8425 } else {
8426 "unknown".to_string()
8427 };
8428 let required = if server.required { " required" } else { "" };
8429 println!(" - {name} [{status}{required}{auth}] {cmd_str}");
8430 }
8431 Ok(())
8432 }
8433 McpCommand::Connect { server } => {
8434 let mut pool = McpPool::from_config_path_with_workspace_and_plugins(
8435 &config_path,
8436 workspace,
8437 std::sync::Arc::new(plugins.clone()),
8438 )?;
8439 if let Some(name) = server {
8440 if let Err(err) = pool.get_or_connect(&name).await {
8441 if crate::mcp::oauth::error_looks_auth_required(&err) {
8442 let hint = crate::mcp::oauth::auth_required_login_hint(&name);
8443 return Err(err).context(hint);
8444 }
8445 return Err(err);
8446 }
8447 println!("Connected to MCP server: {name}");
8448 } else {
8449 let errors = pool.connect_all().await;
8450 if errors.is_empty() {
8451 println!("Connected to all configured MCP servers.");
8452 } else {
8453 for (name, err) in errors {
8454 eprintln!("Failed to connect {name}: {err:#}");
8455 if crate::mcp::oauth::error_looks_auth_required(&err) {
8456 eprintln!(" {}", crate::mcp::oauth::auth_required_login_hint(&name));
8457 }
8458 }
8459 }
8460 }
8461 Ok(())
8462 }
8463 McpCommand::Tools { server } => {
8464 let mut pool = McpPool::from_config_path_with_workspace_and_plugins(
8465 &config_path,
8466 workspace,
8467 std::sync::Arc::new(plugins.clone()),
8468 )?;
8469 if let Some(name) = server {
8470 let conn = match pool.get_or_connect(&name).await {
8471 Ok(conn) => conn,
8472 Err(err) => {
8473 if crate::mcp::oauth::error_looks_auth_required(&err) {
8474 let hint = crate::mcp::oauth::auth_required_login_hint(&name);
8475 return Err(err).context(hint);
8476 }
8477 return Err(err);
8478 }
8479 };
8480 if conn.tools().is_empty() {
8481 println!("No tools found for MCP server: {name}");
8482 } else {
8483 println!("Tools for {name}:");
8484 for tool in conn.tools() {
8485 println!(
8486 " - {}{}",
8487 tool.name,
8488 crate::mcp::format_mcp_tool_description(tool.description.as_deref())
8489 );
8490 }
8491 }
8492 } else {
8493 let errors = pool.connect_all().await;
8494 for (name, err) in errors {
8495 eprintln!("Failed to connect {name}: {err:#}");
8496 if crate::mcp::oauth::error_looks_auth_required(&err) {
8497 eprintln!(" {}", crate::mcp::oauth::auth_required_login_hint(&name));
8498 }
8499 }
8500 let tools = pool.all_tools();
8501 if tools.is_empty() {
8502 println!("No MCP tools discovered.");
8503 } else {
8504 println!("MCP tools:");
8505 for (name, tool) in tools {
8506 println!(
8507 " - {}{}",
8508 name,
8509 crate::mcp::format_mcp_tool_description(tool.description.as_deref())
8510 );
8511 }
8512 }
8513 }
8514 Ok(())
8515 }
8516 McpCommand::Add {
8517 name,
8518 command,
8519 url,
8520 transport,
8521 bearer_token_env_var,
8522 oauth_client_id,
8523 oauth_resource,
8524 scopes,
8525 args,
8526 } => {
8527 if command.is_none() && url.is_none() {
8528 bail!("Provide either --command or --url for `mcp add`.");
8529 }
8530 if let Some(transport) = transport.as_deref()
8531 && !transport.trim().eq_ignore_ascii_case("sse")
8532 {
8533 bail!("Unsupported MCP transport '{transport}'. Supported values: sse");
8534 }
8535 let added_server = McpServerConfig {
8536 command,
8537 args,
8538 env: std::collections::HashMap::new(),
8539 cwd: None,
8540 url,
8541 transport,
8542 connect_timeout: None,
8543 execute_timeout: None,
8544 read_timeout: None,
8545 disabled: false,
8546 enabled: true,
8547 required: false,
8548 enabled_tools: Vec::new(),
8549 disabled_tools: Vec::new(),
8550 headers: std::collections::HashMap::new(),
8551 env_headers: std::collections::HashMap::new(),
8552 bearer_token_env_var,
8553 scopes,
8554 oauth: oauth_client_id.map(|client_id| McpServerOAuthConfig {
8555 client_id: Some(client_id),
8556 }),
8557 oauth_resource,
8558 reviewed_plugin: None,
8559 };
8560 let can_suggest_oauth = added_server.url.is_some()
8561 && added_server.bearer_token_env_var.is_none()
8562 && added_server
8563 .headers
8564 .keys()
8565 .all(|key| !key.trim().eq_ignore_ascii_case("authorization"))
8566 && added_server
8567 .env_headers
8568 .keys()
8569 .all(|key| !key.trim().eq_ignore_ascii_case("authorization"));
8570 let mut cfg = load_mcp_config(&config_path)?;
8571 cfg.servers.insert(name.clone(), added_server.clone());
8572 save_mcp_config(&config_path, &cfg)?;
8573 println!("Added MCP server '{name}' in {}", config_path.display());
8574 if can_suggest_oauth
8575 && crate::mcp::oauth::oauth_login_support(&added_server)
8576 .await
8577 .is_ok_and(|support| support.is_some())
8578 {
8579 println!(
8580 "OAuth is available for '{name}'. Run `codewhale mcp login {name}` to authenticate."
8581 );
8582 }
8583 Ok(())
8584 }
8585 McpCommand::Login { name, scopes } => {
8586 let cfg = crate::mcp::load_config_with_workspace_and_plugins(
8587 &config_path,
8588 workspace,
8589 plugins,
8590 )?;
8591 let server = cfg
8592 .servers
8593 .get(&name)
8594 .ok_or_else(|| anyhow!("MCP server '{name}' not found"))?;
8595 let explicit_scopes = (!scopes.is_empty()).then_some(scopes);
8596 crate::mcp::oauth::perform_oauth_login_for_server(
8597 &name,
8598 server,
8599 explicit_scopes,
8600 config.mcp_oauth_callback_port,
8601 config.mcp_oauth_callback_url.as_deref(),
8602 )
8603 .await?;
8604 println!("Stored OAuth credentials for MCP server '{name}'.");
8605 Ok(())
8606 }
8607 McpCommand::Logout { name } => {
8608 let cfg = crate::mcp::load_config_with_workspace_and_plugins(
8609 &config_path,
8610 workspace,
8611 plugins,
8612 )?;
8613 let server = cfg
8614 .servers
8615 .get(&name)
8616 .ok_or_else(|| anyhow!("MCP server '{name}' not found"))?;
8617 if crate::mcp::oauth::delete_oauth_tokens_for_server(&name, server)? {
8618 println!("Deleted stored OAuth credentials for MCP server '{name}'.");
8619 } else {
8620 println!("No stored OAuth credentials found for MCP server '{name}'.");
8621 }
8622 Ok(())
8623 }
8624 McpCommand::Remove { name } => {
8625 let mut cfg = load_mcp_config(&config_path)?;
8626 if cfg.servers.remove(&name).is_none() {
8627 bail!("MCP server '{name}' not found");
8628 }
8629 save_mcp_config(&config_path, &cfg)?;
8630 println!("Removed MCP server '{name}'");
8631 Ok(())
8632 }
8633 McpCommand::Enable { name } => {
8634 let mut cfg = load_mcp_config(&config_path)?;
8635 let server = cfg
8636 .servers
8637 .get_mut(&name)
8638 .ok_or_else(|| anyhow!("MCP server '{name}' not found"))?;
8639 server.enabled = true;
8640 server.disabled = false;
8641 save_mcp_config(&config_path, &cfg)?;
8642 println!("Enabled MCP server '{name}'");
8643 Ok(())
8644 }
8645 McpCommand::Disable { name } => {
8646 let mut cfg = load_mcp_config(&config_path)?;
8647 let server = cfg
8648 .servers
8649 .get_mut(&name)
8650 .ok_or_else(|| anyhow!("MCP server '{name}' not found"))?;
8651 server.enabled = false;
8652 server.disabled = true;
8653 save_mcp_config(&config_path, &cfg)?;
8654 println!("Disabled MCP server '{name}'");
8655 Ok(())
8656 }
8657 McpCommand::Validate => {
8658 let mut pool = McpPool::from_config_path_with_workspace_and_plugins(
8659 &config_path,
8660 workspace,
8661 std::sync::Arc::new(plugins.clone()),
8662 )?;
8663 let errors = pool.connect_all().await;
8664 if errors.is_empty() {
8665 println!("MCP config is valid. All enabled servers connected.");
8666 return Ok(());
8667 }
8668 eprintln!("MCP validation failed:");
8669 for (name, err) in errors {
8670 eprintln!(" - {name}: {err:#}");
8671 }
8672 bail!("one or more MCP servers failed validation");
8673 }
8674 McpCommand::AddSelf { name, workspace } => {
8675 let exe_path = std::env::current_exe()
8676 .map_err(|e| anyhow!("Cannot resolve current binary path: {e}"))?;
8677 let exe_str = exe_path.to_string_lossy().to_string();
8678
8679 let mut args = vec!["serve".to_string(), "--mcp".to_string()];
8680 if let Some(ref ws) = workspace {
8681 args.push("--workspace".to_string());
8682 args.push(ws.clone());
8683 }
8684
8685 let mut cfg = load_mcp_config(&config_path)?;
8686 if cfg.servers.contains_key(&name) {
8687 bail!(
8688 "MCP server '{name}' already exists in {}. Use `codewhale mcp remove {name}` first, or choose a different --name.",
8689 config_path.display()
8690 );
8691 }
8692 cfg.servers.insert(
8693 name.clone(),
8694 McpServerConfig {
8695 command: Some(exe_str.clone()),
8696 args,
8697 env: std::collections::HashMap::new(),
8698 cwd: None,
8699 url: None,
8700 transport: None,
8701 connect_timeout: None,
8702 execute_timeout: None,
8703 read_timeout: None,
8704 disabled: false,
8705 enabled: true,
8706 required: false,
8707 enabled_tools: Vec::new(),
8708 disabled_tools: Vec::new(),
8709 headers: std::collections::HashMap::new(),
8710 env_headers: std::collections::HashMap::new(),
8711 bearer_token_env_var: None,
8712 scopes: Vec::new(),
8713 oauth: None,
8714 oauth_resource: None,
8715 reviewed_plugin: None,
8716 },
8717 );
8718 save_mcp_config(&config_path, &cfg)?;
8719 println!(
8720 "Registered Codewhale as MCP server '{name}' in {}",
8721 config_path.display()
8722 );
8723 println!(" command: {exe_str}");
8724 println!(
8725 " args: serve --mcp{}",
8726 workspace.map_or(String::new(), |ws| format!(" --workspace {ws}"))
8727 );
8728 println!();
8729 println!("Tip: Use `codewhale mcp validate` to test the connection.");
8730 println!(" Use `codewhale serve --http` for the HTTP/SSE runtime API instead.");
8731 Ok(())
8732 }
8733 }
8734}
8735
8736fn load_mcp_config(path: &Path) -> Result<McpConfig> {
8737 if !path.exists() {
8738 return Ok(McpConfig::default());
8739 }
8740 let contents = std::fs::read_to_string(path)
8741 .map_err(|e| anyhow::anyhow!("Failed to read MCP config {}: {}", path.display(), e))?;
8742 let cfg: McpConfig = serde_json::from_str(&contents).map_err(|_| {
8743 anyhow::anyhow!(
8744 "Failed to parse MCP config {}; file contents were omitted",
8745 codewhale_config::quote_os_path(path)
8746 )
8747 })?;
8748 Ok(cfg)
8749}
8750
8751#[derive(Debug)]
8753enum McpServerDoctorStatus {
8754 Ok(String),
8755 Warning(String),
8756 Error(String),
8757}
8758
8759impl McpServerDoctorStatus {
8760 fn legacy_status(&self) -> &'static str {
8761 match self {
8762 Self::Ok(_) => "ok",
8763 Self::Warning(_) => "warning",
8764 Self::Error(_) => "error",
8765 }
8766 }
8767
8768 fn configuration_status(&self) -> &'static str {
8769 match self {
8770 Self::Ok(_) => "valid",
8771 Self::Warning(_) => "warning",
8772 Self::Error(_) => "invalid",
8773 }
8774 }
8775
8776 fn detail(&self) -> &str {
8777 match self {
8778 Self::Ok(detail) | Self::Warning(detail) | Self::Error(detail) => detail,
8779 }
8780 }
8781}
8782
8783fn doctor_mcp_command_status(server: &McpServerConfig) -> McpCommandAvailability {
8785 if server.url.is_some() {
8786 return McpCommandAvailability::NotApplicable;
8787 }
8788 match server.command.as_deref() {
8789 Some("") => McpCommandAvailability::Missing,
8790 Some(_) | None => McpCommandAvailability::NotChecked,
8791 }
8792}
8793
8794fn doctor_mcp_server_json(name: &str, server: &McpServerConfig) -> serde_json::Value {
8795 use serde_json::json;
8796
8797 let status = doctor_check_mcp_server(server);
8798 json!({
8799 "name": name,
8800 "enabled": server.enabled && !server.disabled,
8801 "status": status.legacy_status(),
8804 "detail": status.detail(),
8805 "transport": if server.url.is_some() { "http" } else { "stdio" },
8806 "endpoint": server.url.as_deref().map(crate::doctor::structural_url_authority),
8807 "command_configured": server.command.is_some(),
8808 "args_count": server.args.len(),
8809 "env_count": server.env.len(),
8810 "headers_count": server.headers.len(),
8811 "env_headers_count": server.env_headers.len(),
8812 "check_scope": "configuration",
8813 "checks": {
8814 "configuration": {
8815 "status": status.configuration_status(),
8816 "detail": status.detail(),
8817 },
8818 "command": {
8819 "status": doctor_mcp_command_status(server).as_str(),
8820 },
8821 "process_reachable": {
8822 "status": "not_checked",
8823 },
8824 "protocol_initialized": {
8825 "status": "not_checked",
8826 },
8827 "backend_tool_health": {
8828 "status": "not_checked",
8829 },
8830 },
8831 })
8832}
8833
8834fn doctor_check_mcp_server(server: &McpServerConfig) -> McpServerDoctorStatus {
8836 if server.command.is_none() && server.url.is_none() {
8838 return McpServerDoctorStatus::Error("no command or url configured".to_string());
8839 }
8840
8841 if let Some(ref url) = server.url {
8843 let authority = crate::doctor::structural_url_authority(url);
8844 return if authority.starts_with("unparseable") {
8845 McpServerDoctorStatus::Warning(
8846 "HTTP/SSE server URL is invalid; configured value omitted".to_string(),
8847 )
8848 } else {
8849 McpServerDoctorStatus::Ok(format!("HTTP/SSE server at {authority}"))
8850 };
8851 }
8852
8853 let cmd = server.command.as_deref().unwrap_or("");
8855 if cmd.is_empty() {
8856 return McpServerDoctorStatus::Error("empty command".to_string());
8857 }
8858
8859 if server.cwd.is_none() {
8860 if is_relative_stdio_path_arg(cmd) {
8861 return McpServerDoctorStatus::Warning(
8862 "stdio server uses a relative command without cwd; command value omitted"
8863 .to_string(),
8864 );
8865 }
8866 if server
8867 .args
8868 .iter()
8869 .any(|arg| is_relative_stdio_path_arg(arg))
8870 {
8871 return McpServerDoctorStatus::Warning(
8872 "stdio server uses a relative path argument without cwd; argument values omitted"
8873 .to_string(),
8874 );
8875 }
8876 }
8877
8878 McpServerDoctorStatus::Ok(format!(
8879 "stdio server configured (command omitted; {} argument(s), {} environment binding(s))",
8880 server.args.len(),
8881 server.env.len()
8882 ))
8883}
8884
8885fn save_mcp_config(path: &Path, cfg: &McpConfig) -> Result<()> {
8886 if let Some(parent) = path.parent() {
8887 std::fs::create_dir_all(parent).with_context(|| {
8888 format!("Failed to create MCP config directory {}", parent.display())
8889 })?;
8890 }
8891 let rendered = serde_json::to_string_pretty(cfg)
8892 .map_err(|e| anyhow!("Failed to serialize MCP config: {e}"))?;
8893 crate::utils::write_atomic(path, rendered.as_bytes())
8894 .map_err(|e| anyhow!("Failed to write MCP config {}: {}", path.display(), e))?;
8895 Ok(())
8896}
8897
8898fn run_sandbox_command(args: SandboxArgs) -> Result<()> {
8899 use crate::sandbox::{CommandSpec, SandboxManager};
8900
8901 let SandboxCommand::Run {
8902 policy,
8903 network,
8904 writable_root,
8905 exclude_tmpdir,
8906 exclude_slash_tmp,
8907 cwd,
8908 timeout_ms,
8909 command,
8910 } = args.command;
8911
8912 let policy = parse_sandbox_policy(
8913 &policy,
8914 network,
8915 writable_root,
8916 exclude_tmpdir,
8917 exclude_slash_tmp,
8918 )?;
8919 let cwd = cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
8920 let timeout = Duration::from_millis(timeout_ms.clamp(1000, 600_000));
8921
8922 let (program, args) = command
8923 .split_first()
8924 .ok_or_else(|| anyhow::anyhow!("Command is required"))?;
8925 let spec =
8926 CommandSpec::program(program, args.to_vec(), cwd.clone(), timeout).with_policy(policy);
8927 let manager = SandboxManager::new();
8928 let exec_env = manager.prepare(&spec);
8929
8930 let mut cmd = Command::new(exec_env.program());
8931 cmd.args(exec_env.args())
8932 .current_dir(&exec_env.cwd)
8933 .stdout(Stdio::piped())
8934 .stderr(Stdio::piped());
8935 child_env::apply_to_command(&mut cmd, child_env::string_map_env(&exec_env.env));
8936
8937 let mut child = cmd
8938 .spawn()
8939 .map_err(|e| anyhow::anyhow!("Failed to run command: {e}"))?;
8940 let stdout_handle = child
8941 .stdout
8942 .take()
8943 .ok_or_else(|| anyhow::anyhow!("stdout unavailable"))?;
8944 let stderr_handle = child
8945 .stderr
8946 .take()
8947 .ok_or_else(|| anyhow::anyhow!("stderr unavailable"))?;
8948
8949 let timeout = exec_env.timeout;
8950 let stdout_thread = std::thread::spawn(move || {
8951 let mut reader = stdout_handle;
8952 let mut buf = Vec::new();
8953 let _ = reader.read_to_end(&mut buf);
8954 buf
8955 });
8956 let stderr_thread = std::thread::spawn(move || {
8957 let mut reader = stderr_handle;
8958 let mut buf = Vec::new();
8959 let _ = reader.read_to_end(&mut buf);
8960 buf
8961 });
8962
8963 if let Some(status) = child.wait_timeout(timeout)? {
8964 let stdout = stdout_thread.join().unwrap_or_default();
8965 let stderr = stderr_thread.join().unwrap_or_default();
8966 let stderr_str = String::from_utf8_lossy(&stderr);
8967 let exit_code = status.code().unwrap_or(-1);
8968 let sandbox_type = exec_env.sandbox_type;
8969 let sandbox_denied = SandboxManager::was_denied(sandbox_type, exit_code, &stderr_str);
8970
8971 if !stdout.is_empty() {
8972 print!("{}", String::from_utf8_lossy(&stdout));
8973 }
8974 if !stderr.is_empty() {
8975 eprint!("{stderr_str}");
8976 }
8977 if sandbox_denied {
8978 eprintln!(
8979 "{}",
8980 SandboxManager::denial_message(sandbox_type, &stderr_str)
8981 );
8982 }
8983
8984 if !status.success() {
8985 bail!("Command failed with exit code {exit_code}");
8986 }
8987 } else {
8988 let _ = child.kill();
8989 let _ = child.wait();
8990 bail!("Command timed out after {}ms", timeout.as_millis());
8991 }
8992 Ok(())
8993}
8994
8995fn parse_sandbox_policy(
8996 policy: &str,
8997 network: bool,
8998 writable_root: Vec<PathBuf>,
8999 exclude_tmpdir: bool,
9000 exclude_slash_tmp: bool,
9001) -> Result<crate::sandbox::SandboxPolicy> {
9002 use crate::sandbox::SandboxPolicy;
9003
9004 match policy {
9005 "danger-full-access" => Ok(SandboxPolicy::DangerFullAccess),
9006 "read-only" => Ok(SandboxPolicy::ReadOnly),
9007 "external-sandbox" => Ok(SandboxPolicy::ExternalSandbox {
9008 network_access: network,
9009 }),
9010 "workspace-write" => Ok(SandboxPolicy::WorkspaceWrite {
9011 writable_roots: writable_root,
9012 network_access: network,
9013 exclude_tmpdir,
9014 exclude_slash_tmp,
9015 }),
9016 other => bail!("Unknown sandbox policy: {other}"),
9017 }
9018}
9019
9020fn should_use_alt_screen(_cli: &Cli, _config: &Config) -> bool {
9021 true
9022}
9023
9024fn should_use_mouse_capture(cli: &Cli, config: &Config, use_alt_screen: bool) -> bool {
9025 let terminal_emulator = std::env::var("TERMINAL_EMULATOR").ok();
9026 let wt_session = std::env::var("WT_SESSION").ok().filter(|s| !s.is_empty());
9027 let conemu_pid = std::env::var("ConEmuPID").ok().filter(|s| !s.is_empty());
9028 should_use_mouse_capture_with(
9029 cli,
9030 config,
9031 use_alt_screen,
9032 terminal_emulator.as_deref(),
9033 wt_session.as_deref(),
9034 conemu_pid.as_deref(),
9035 )
9036}
9037
9038fn should_use_mouse_capture_with(
9039 cli: &Cli,
9040 config: &Config,
9041 use_alt_screen: bool,
9042 terminal_emulator: Option<&str>,
9043 wt_session: Option<&str>,
9044 conemu_pid: Option<&str>,
9045) -> bool {
9046 if !use_alt_screen || cli.no_mouse_capture {
9047 return false;
9048 }
9049 if cli.mouse_capture {
9050 return true;
9051 }
9052 config
9053 .tui
9054 .as_ref()
9055 .and_then(|tui| tui.mouse_capture)
9056 .unwrap_or_else(|| default_mouse_capture_enabled(terminal_emulator, wt_session, conemu_pid))
9057}
9058
9059fn default_mouse_capture_enabled(
9074 terminal_emulator: Option<&str>,
9075 wt_session: Option<&str>,
9076 conemu_pid: Option<&str>,
9077) -> bool {
9078 if cfg!(windows) {
9079 return wt_session.is_some() || conemu_pid.is_some();
9080 }
9081 if matches!(terminal_emulator, Some(t) if t.eq_ignore_ascii_case("JetBrains-JediTerm")) {
9082 return false;
9083 }
9084 true
9085}
9086
9087struct RecentCheckpoint {
9091 session: session_manager::SavedSession,
9092 age: std::time::Duration,
9093 source: session_manager::CheckpointSource,
9094}
9095
9096const CHECKPOINT_MAX_AGE: std::time::Duration = std::time::Duration::from_secs(24 * 3600);
9097
9098fn load_recent_checkpoints(manager: &session_manager::SessionManager) -> Vec<RecentCheckpoint> {
9105 let refs = manager.list_checkpoints().unwrap_or_default();
9106 let mut recent = Vec::new();
9107 for checkpoint_ref in refs {
9108 let Ok(age) = std::time::SystemTime::now().duration_since(checkpoint_ref.modified) else {
9109 continue;
9110 };
9111 if age > CHECKPOINT_MAX_AGE {
9112 let _ = match &checkpoint_ref.source {
9113 session_manager::CheckpointSource::Session(id) => {
9114 manager.clear_session_checkpoint(id)
9115 }
9116 session_manager::CheckpointSource::Legacy => manager.clear_legacy_checkpoint(),
9117 };
9118 continue;
9119 }
9120 let loaded = match &checkpoint_ref.source {
9121 session_manager::CheckpointSource::Session(id) => manager.load_session_checkpoint(id),
9122 session_manager::CheckpointSource::Legacy => manager.load_legacy_checkpoint(),
9123 };
9124 let Ok(Some(session)) = loaded else {
9125 continue;
9126 };
9127 recent.push(RecentCheckpoint {
9128 session,
9129 age,
9130 source: checkpoint_ref.source,
9131 });
9132 }
9133 recent.sort_by_key(|c| c.age);
9136 recent
9137}
9138
9139fn checkpoint_age_label(age: std::time::Duration) -> String {
9140 if age.as_secs() < 60 {
9141 format!("{}s ago", age.as_secs())
9142 } else if age.as_secs() < 3600 {
9143 format!("{}m ago", age.as_secs() / 60)
9144 } else {
9145 format!("{}h ago", age.as_secs() / 3600)
9146 }
9147}
9148
9149fn recover_interrupted_checkpoint_for_resume(launch_workspace: &Path) -> Option<String> {
9161 let manager = session_manager::SessionManager::default_location().ok()?;
9162 let candidates = load_recent_checkpoints(&manager);
9163 if candidates.is_empty() {
9164 return None;
9165 }
9166
9167 let (matching, mismatched): (Vec<_>, Vec<_>) = candidates.into_iter().partition(|candidate| {
9172 session_manager::workspace_scope_matches(
9173 &candidate.session.metadata.workspace,
9174 launch_workspace,
9175 )
9176 });
9177
9178 let Some(best) = matching.into_iter().next() else {
9179 if let Some(newest) = mismatched.first() {
9180 eprintln!(
9181 "Note: an interrupted session from another workspace ({}) is \
9182 available. Run `codewhale sessions` to list saved sessions. Starting \
9183 fresh in {}.",
9184 newest.session.metadata.workspace.display(),
9185 launch_workspace.display(),
9186 );
9187 }
9188 return None;
9189 };
9190
9191 let session_id = best.session.metadata.id.clone();
9192
9193 if !saved_session_is_newer(&manager, &best.session)
9198 && manager.save_session(&best.session).is_err()
9199 {
9200 return None;
9201 }
9202
9203 match &best.source {
9204 session_manager::CheckpointSource::Session(id) => {
9205 let _ = manager.clear_session_checkpoint(id);
9207 }
9208 session_manager::CheckpointSource::Legacy => {
9209 let _ = manager.write_session_checkpoint_if_absent(&best.session);
9214 }
9215 }
9216
9217 let age_str = checkpoint_age_label(best.age);
9218 eprintln!("Recovered interrupted session ({age_str}). Use --fresh to start fresh.",);
9219
9220 Some(session_id)
9221}
9222
9223fn saved_session_is_newer(
9228 manager: &session_manager::SessionManager,
9229 checkpoint: &session_manager::SavedSession,
9230) -> bool {
9231 manager
9232 .load_session(&checkpoint.metadata.id)
9233 .is_ok_and(|existing| existing.metadata.updated_at >= checkpoint.metadata.updated_at)
9234}
9235
9236fn preserve_interrupted_checkpoint_for_explicit_resume(launch_workspace: &Path) {
9247 let Some(manager) = session_manager::SessionManager::default_location().ok() else {
9248 return;
9249 };
9250 let Some(newest) = load_recent_checkpoints(&manager).into_iter().next() else {
9251 return;
9252 };
9253
9254 let session_workspace = newest.session.metadata.workspace.clone();
9255 if newest.source == session_manager::CheckpointSource::Legacy {
9257 let _ = manager.save_checkpoint(&newest.session);
9260 let _ = manager.clear_legacy_checkpoint();
9261 }
9262
9263 let age_str = checkpoint_age_label(newest.age);
9264 if session_manager::workspace_scope_matches(&session_workspace, launch_workspace) {
9265 eprintln!(
9266 "Found an in-flight session snapshot ({age_str}). Starting a new \
9267 session. Run `codewhale --continue` to resume it."
9268 );
9269 } else {
9270 eprintln!(
9271 "Note: an interrupted session from another workspace ({}) is \
9272 available. Run `codewhale sessions` to list saved sessions. Starting \
9273 fresh in {}.",
9274 session_workspace.display(),
9275 launch_workspace.display(),
9276 );
9277 }
9278}
9279
9280#[cfg(test)]
9286fn merge_project_config(config: &mut Config, workspace: &Path) {
9287 merge_project_config_with_approval_baseline(config, workspace, None);
9288}
9289
9290fn merge_project_config_with_approval_baseline(
9295 config: &mut Config,
9296 workspace: &Path,
9297 saved_permission_posture: Option<&str>,
9298) {
9299 if let Some(home) = effective_home_dir()
9304 && let (Ok(w), Ok(h)) = (
9305 std::fs::canonicalize(workspace),
9306 std::fs::canonicalize(&home),
9307 )
9308 && w == h
9309 {
9310 return;
9311 }
9312
9313 let path = workspace
9315 .join(codewhale_config::CODEWHALE_APP_DIR)
9316 .join("config.toml");
9317 let raw = match read_project_config_file(&path) {
9318 Ok(Some(r)) => r,
9319 Ok(None) => {
9320 let legacy = workspace
9321 .join(codewhale_config::LEGACY_APP_DIR)
9322 .join("config.toml");
9323 match read_project_config_file(&legacy) {
9324 Ok(Some(r)) => r,
9325 Ok(None) => return,
9326 Err(err) => {
9327 eprintln!(
9328 "warning: failed to read project-scope config {}: {err}",
9329 legacy.display()
9330 );
9331 return;
9332 }
9333 }
9334 }
9335 Err(err) => {
9336 eprintln!(
9337 "warning: failed to read project-scope config {}: {err}",
9338 path.display()
9339 );
9340 return;
9341 }
9342 };
9343 let project: toml::Value = match toml::from_str(&raw) {
9344 Ok(v) => v,
9345 Err(_) => return,
9346 };
9347 let table = match project.as_table() {
9348 Some(t) => t,
9349 None => return,
9350 };
9351
9352 const DENY_AT_PROJECT_SCOPE: &[&str] = &[
9368 "api_key",
9369 "base_url",
9370 "provider",
9371 "mcp_config_path",
9372 "mcp_oauth_callback_port",
9373 "mcp_oauth_callback_url",
9374 ];
9375 for key in DENY_AT_PROJECT_SCOPE {
9376 if table.contains_key(*key) {
9377 eprintln!(
9378 "warning: project-scope config key `{key}` is ignored — \
9379 set it in `~/.codewhale/config.toml` instead. \
9380 (See #417 for the deny-list rationale.)"
9381 );
9382 }
9383 }
9384
9385 for (key, field) in [
9388 ("model", &mut config.default_text_model),
9389 ("reasoning_effort", &mut config.reasoning_effort),
9390 ("notes_path", &mut config.notes_path),
9391 ] {
9392 if let Some(v) = table.get(key).and_then(toml::Value::as_str)
9393 && !v.is_empty()
9394 {
9395 *field = Some(v.to_string());
9396 }
9397 }
9398
9399 if let Some(v) = table.get("approval_policy").and_then(toml::Value::as_str)
9400 && !v.is_empty()
9401 {
9402 let saved_approval_baseline =
9403 crate::config::approval_policy_baseline_from_permission_posture(
9404 saved_permission_posture,
9405 );
9406 let approval_baseline = config
9407 .approval_policy
9408 .as_deref()
9409 .or(saved_approval_baseline);
9410 if codewhale_config::project_approval_policy_is_allowed(approval_baseline, v) {
9411 config.approval_policy = Some(v.to_string());
9412 } else {
9413 eprintln!(
9414 "warning: project-scope `approval_policy = \"{v}\"` is ignored — \
9415 project config can only tighten the user's approval policy. \
9416 (See #417.)"
9417 );
9418 }
9419 }
9420
9421 if let Some(v) = table.get("sandbox_mode").and_then(toml::Value::as_str)
9422 && !v.is_empty()
9423 {
9424 if codewhale_config::project_sandbox_mode_is_allowed(config.sandbox_mode.as_deref(), v) {
9425 config.sandbox_mode = Some(v.to_string());
9426 } else {
9427 eprintln!(
9428 "warning: project-scope `sandbox_mode = \"{v}\"` is ignored — \
9429 project config can only tighten the user's sandbox mode. \
9430 (See #417.)"
9431 );
9432 }
9433 }
9434
9435 if let Some(v) = table.get("max_subagents").and_then(toml::Value::as_integer)
9437 && v > 0
9438 {
9439 config.max_subagents = Some((v as usize).clamp(1, crate::config::MAX_SUBAGENTS));
9440 }
9441 if let Some(v) = table.get("allow_shell").and_then(toml::Value::as_bool) {
9442 if v {
9443 eprintln!(
9444 "warning: project-scope `allow_shell = true` is ignored — \
9445 enable shell from user config for this workspace instead. \
9446 (See #417.)"
9447 );
9448 } else {
9449 config.allow_shell = Some(false);
9450 }
9451 }
9452
9453 if table.contains_key("instructions") {
9454 eprintln!(
9455 "warning: project-scope `instructions` is ignored — \
9456 configure instruction files from user config instead. \
9457 (See #417.)"
9458 );
9459 }
9460}
9461
9462fn read_project_config_file(path: &Path) -> io::Result<Option<String>> {
9463 let metadata = match std::fs::symlink_metadata(path) {
9464 Ok(metadata) => metadata,
9465 Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
9466 Err(err) => return Err(err),
9467 };
9468 let file_type = metadata.file_type();
9469 if file_type.is_symlink() {
9470 return Err(io::Error::new(
9471 io::ErrorKind::InvalidInput,
9472 "project-scope config must not be a symlink",
9473 ));
9474 }
9475 if !file_type.is_file() {
9476 return Ok(None);
9477 }
9478
9479 let mut file = open_project_config_file(path)?;
9480 let mut raw = String::new();
9481 file.read_to_string(&mut raw)?;
9482 Ok(Some(raw))
9483}
9484
9485#[cfg(unix)]
9486fn open_project_config_file(path: &Path) -> io::Result<std::fs::File> {
9487 use std::os::unix::fs::OpenOptionsExt;
9488
9489 std::fs::OpenOptions::new()
9490 .read(true)
9491 .custom_flags(libc::O_NOFOLLOW)
9492 .open(path)
9493}
9494
9495#[cfg(not(unix))]
9496fn open_project_config_file(path: &Path) -> io::Result<std::fs::File> {
9497 std::fs::File::open(path)
9498}
9499
9500fn merge_user_workspace_config(
9501 config: &mut Config,
9502 config_path: Option<PathBuf>,
9503 workspace: &Path,
9504) {
9505 if config.managed_config_path.is_some() || config.requirements_path.is_some() {
9506 return;
9507 }
9508 let allow_shell_before = config.allow_shell;
9509 let allow_shell_from_env = std::env::var_os("CODEWHALE_ALLOW_SHELL").is_some()
9510 || std::env::var_os("DEEPSEEK_ALLOW_SHELL").is_some();
9511 let path = match crate::config::resolve_load_config_path(config_path) {
9512 Ok(Some(path)) => path,
9513 Ok(None) => return,
9514 Err(error) => {
9515 tracing::error!(
9516 error = %error,
9517 "failed to resolve workspace config overlay; refusing to substitute another file"
9518 );
9519 return;
9520 }
9521 };
9522 let raw = match std::fs::read_to_string(&path) {
9523 Ok(raw) => raw,
9524 Err(error) => {
9525 eprintln!(
9526 "warning: could not read user config at {}: {error}. \
9527 Ignoring it — `[workspace]`/`[projects]` grants (e.g. `allow_shell`) \
9528 revert to defaults for this session. Fix or remove the file to \
9529 restore them.",
9530 path.display()
9531 );
9532 return;
9533 }
9534 };
9535 let doc = match toml::from_str::<toml::Value>(&raw) {
9536 Ok(doc) => doc,
9537 Err(error) => {
9538 eprintln!(
9539 "warning: could not parse user config at {}: {error}. \
9540 Ignoring it — `[workspace]`/`[projects]` grants (e.g. `allow_shell`) \
9541 revert to defaults for this session. Fix the TOML syntax to \
9542 restore them.",
9543 path.display()
9544 );
9545 return;
9546 }
9547 };
9548 merge_user_workspace_config_from_doc(config, &doc, workspace);
9549 if allow_shell_from_env {
9550 config.allow_shell = allow_shell_before;
9551 }
9552}
9553
9554fn merge_user_workspace_config_from_doc(config: &mut Config, doc: &toml::Value, workspace: &Path) {
9555 for table_name in ["workspace", "projects"] {
9556 let Some(entries) = doc.get(table_name).and_then(toml::Value::as_table) else {
9557 continue;
9558 };
9559 for (raw_path, entry) in entries {
9560 if !workspace_config_path_matches(raw_path, workspace) {
9561 continue;
9562 }
9563 if let Some(allow_shell) = entry.get("allow_shell").and_then(toml::Value::as_bool) {
9564 config.allow_shell = Some(allow_shell);
9565 }
9566 }
9567 }
9568}
9569
9570fn workspace_config_path_matches(raw_path: &str, workspace: &Path) -> bool {
9571 let configured = crate::config::expand_path(raw_path);
9572 let configured = configured.canonicalize().unwrap_or(configured);
9573 let workspace = workspace
9574 .canonicalize()
9575 .unwrap_or_else(|_| workspace.to_path_buf());
9576 paths_equal_for_config(&configured, &workspace)
9577}
9578
9579#[cfg(windows)]
9580fn paths_equal_for_config(left: &Path, right: &Path) -> bool {
9581 normalize_windows_config_path_for_compare(left)
9582 == normalize_windows_config_path_for_compare(right)
9583}
9584
9585#[cfg(not(windows))]
9586fn paths_equal_for_config(left: &Path, right: &Path) -> bool {
9587 left == right
9588}
9589
9590#[cfg(windows)]
9591fn normalize_windows_config_path_for_compare(path: &Path) -> String {
9592 normalize_windows_config_path_str(&path.to_string_lossy())
9593}
9594
9595#[cfg(any(windows, test))]
9596fn normalize_windows_config_path_str(path: &str) -> String {
9597 let mut normalized = path.replace('/', "\\");
9598 if let Some(rest) = normalized.strip_prefix(r"\\?\UNC\") {
9599 normalized = format!("\\\\{rest}");
9600 } else if let Some(rest) = normalized.strip_prefix(r"\\?\") {
9601 normalized = rest.to_string();
9602 }
9603 while normalized.len() > 3 && normalized.ends_with('\\') {
9604 normalized.pop();
9605 }
9606 normalized.to_ascii_lowercase()
9607}
9608
9609fn interactive_tui_allow_shell(yolo: bool, config: &Config) -> bool {
9610 yolo || config.interactive_allow_shell()
9611}
9612
9613async fn run_interactive(
9614 cli: &Cli,
9615 config: &Config,
9616 resume_session_id: Option<String>,
9617 initial_input: Option<tui::InitialInput>,
9618 pending_telemetry_notice: Option<crate::telemetry_notice::PendingTelemetryNotice>,
9619 plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
9620) -> Result<()> {
9621 run_interactive_with_notice(
9622 cli,
9623 config,
9624 resume_session_id,
9625 initial_input,
9626 None,
9627 pending_telemetry_notice,
9628 plugin_registry,
9629 )
9630 .await
9631}
9632
9633async fn run_interactive_with_notice(
9637 cli: &Cli,
9638 config: &Config,
9639 resume_session_id: Option<String>,
9640 initial_input: Option<tui::InitialInput>,
9641 startup_notice: Option<String>,
9642 pending_telemetry_notice: Option<crate::telemetry_notice::PendingTelemetryNotice>,
9643 plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
9644) -> Result<()> {
9645 let initial_input = if cli.remote_control {
9646 Some(tui::InitialInput::RemoteControl)
9647 } else {
9648 initial_input
9649 };
9650 let workspace = cli
9651 .workspace
9652 .clone()
9653 .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
9654
9655 let mut merged_config = config.clone();
9659 merge_user_workspace_config(&mut merged_config, cli.config.clone(), &workspace);
9660 if !cli.no_project_config {
9661 let saved_permission_posture = crate::settings::Settings::load_persisted()
9662 .ok()
9663 .and_then(|settings| settings.permission_posture);
9664 merge_project_config_with_approval_baseline(
9665 &mut merged_config,
9666 &workspace,
9667 saved_permission_posture.as_deref(),
9668 );
9669 }
9670 let config = &merged_config;
9671
9672 if !cli.skip_onboarding {
9673 match crate::config::ensure_config_file_exists(cli.config.clone()) {
9674 Ok(Some(path)) => logging::info(format!(
9675 "Created first-run config file at {}",
9676 path.display()
9677 )),
9678 Ok(None) => {}
9679 Err(err) => logging::warn(format!("Failed to create first-run config file: {err}")),
9680 }
9681 }
9682
9683 match codewhale_config::migrate_config_if_needed() {
9686 Ok(Some(migration)) => {
9687 eprintln!("{}", migration.user_notice());
9688 }
9689 Ok(None) => {}
9690 Err(err) => logging::warn(format!("Config migration skipped: {err}")),
9691 }
9692
9693 let model = config.default_model();
9694 let provider = config.api_provider();
9695 let max_subagents = cli.max_subagents.map_or_else(
9696 || config.max_subagents_for_provider(provider),
9697 |value| value.clamp(1, MAX_SUBAGENTS),
9698 );
9699 let use_alt_screen = should_use_alt_screen(cli, config);
9700 let use_mouse_capture = should_use_mouse_capture(cli, config, use_alt_screen);
9701 let use_bracketed_paste = crate::settings::Settings::load()
9702 .map(|s| s.effective_bracketed_paste())
9703 .unwrap_or_else(|_| !crate::settings::detected_legacy_windows_console_host());
9704
9705 let skills_dir = config.skills_dir();
9708 if let Err(e) = crate::skills::install_system_skills(&skills_dir) {
9709 logging::warn(format!("Failed to install system skills: {e}"));
9710 }
9711
9712 startup_trace::mark("interactive_config");
9713
9714 crate::models_dev_live::maybe_load_persisted_cache();
9718 crate::models_dev_live::spawn_background_refresh();
9719 crate::client::DeepSeekClient::spawn_active_provider_catalog_refresh(config);
9724
9725 let snapshots = config.snapshots_config();
9732 let janitor_snapshots_enabled = snapshots.enabled;
9733 let janitor_max_age = snapshots.max_age();
9734 let janitor_workspace = workspace.clone();
9735 let janitor_resume_id = resume_session_id.clone();
9740 let janitor_skip_session_cleanup = resume_session_id.is_some() || cli.continue_session;
9741 tokio::task::spawn_blocking(move || {
9742 if janitor_snapshots_enabled {
9743 session_manager::prune_workspace_snapshots(&janitor_workspace, janitor_max_age);
9744 }
9745
9746 match crate::tools::truncate::prune_older_than(crate::tools::truncate::SPILLOVER_MAX_AGE) {
9747 Ok(0) => {}
9748 Ok(n) => tracing::debug!(
9749 target: "spillover",
9750 "boot prune removed {n} spillover file(s)"
9751 ),
9752 Err(err) => tracing::warn!(
9753 target: "spillover",
9754 ?err,
9755 "spillover prune skipped on boot"
9756 ),
9757 }
9758
9759 if !janitor_skip_session_cleanup
9760 && let Ok(manager) = session_manager::SessionManager::default_location()
9761 {
9762 let _ = manager.cleanup_old_sessions_keeping(janitor_resume_id.as_deref());
9763 }
9764 });
9765
9766 let yolo = cli.yolo || config.yolo.unwrap_or(false);
9769
9770 tui::run_tui(
9771 config,
9772 tui::TuiOptions {
9773 model,
9774 workspace,
9775 config_path: cli.config.clone(),
9776 config_profile: effective_config_profile(cli),
9777 allow_shell: interactive_tui_allow_shell(yolo, config),
9778 use_alt_screen,
9779 use_mouse_capture,
9780 use_bracketed_paste,
9781 skills_dir,
9782 memory_path: config.memory_path(),
9783 notes_path: config.notes_path(),
9784 mcp_config_path: config.mcp_config_path(),
9785 use_memory: config.memory_enabled(),
9786 start_in_agent_mode: yolo,
9787 skip_onboarding: cli.skip_onboarding,
9788 yolo, resume_session_id,
9790 initial_input,
9791 startup_notice,
9792 max_subagents,
9793 },
9794 plugin_registry,
9795 pending_telemetry_notice,
9796 )
9797 .await
9798}
9799
9800#[derive(Debug)]
9801struct CliAutoRoute {
9802 provider: crate::config::ApiProvider,
9803 model: String,
9804 reasoning_effort: Option<crate::tui::app::ReasoningEffort>,
9805 auto_controls_reasoning: bool,
9810 auto_model: bool,
9811}
9812
9813fn cli_reasoning_effort_value(
9814 config: &Config,
9815 model: &str,
9816 effort: crate::tui::app::ReasoningEffort,
9817) -> Option<String> {
9818 effort
9819 .api_value_for_route(config.api_provider(), &config.deepseek_base_url(), model)
9820 .map(str::to_string)
9821}
9822
9823fn cli_reasoning_effort_value_for_prompt(
9824 config: &Config,
9825 model: &str,
9826 effort: crate::tui::app::ReasoningEffort,
9827 prompt: &str,
9828) -> Option<String> {
9829 let resolved = if effort == crate::tui::app::ReasoningEffort::Auto {
9830 crate::auto_reasoning::select(false, prompt)
9831 } else {
9832 effort
9833 };
9834 cli_reasoning_effort_value(config, model, resolved)
9835}
9836
9837fn normalize_cli_reasoning_effort(value: &str) -> Result<Option<String>> {
9838 let trimmed = value.trim();
9839 if trimmed.is_empty() {
9840 return Ok(None);
9841 }
9842 if matches!(
9843 trimmed.to_ascii_lowercase().as_str(),
9844 "inherit" | "parent" | "same" | "current" | "default" | "unset"
9845 ) {
9846 return Ok(None);
9847 }
9848 crate::tui::app::ReasoningEffort::parse_strict(trimmed)
9849 .map(|effort| Some(effort.as_setting().to_string()))
9850 .map_err(anyhow::Error::msg)
9851}
9852
9853fn config_for_cli_route(config: &Config, route: &CliAutoRoute) -> Config {
9854 let mut execution_config = config.clone();
9855 execution_config.provider = Some(config.provider_identity_for(route.provider));
9856 execution_config.set_provider_model_override(route.provider, Some(route.model.clone()));
9857 if matches!(
9858 route.provider,
9859 crate::config::ApiProvider::Deepseek | crate::config::ApiProvider::DeepseekCN
9860 ) {
9861 execution_config.default_text_model = Some(route.model.clone());
9862 }
9863 execution_config
9864}
9865
9866async fn resolve_cli_auto_route(
9867 config: &Config,
9868 model: &str,
9869 prompt: &str,
9870) -> Result<CliAutoRoute> {
9871 if model.trim().eq_ignore_ascii_case("auto") {
9872 let selection =
9873 model_routing::resolve_auto_route_with_inventory(config, prompt, "", "auto", "auto")
9874 .await?;
9875 let preference = config
9876 .reasoning_effort()
9877 .filter(|_| config.reasoning_effort_is_explicit())
9878 .map(crate::tui::app::ReasoningEffort::from_setting);
9879 let (reasoning_effort, auto_controls_reasoning) =
9880 model_routing::resolve_auto_model_reasoning(preference, selection.reasoning_effort);
9881 Ok(CliAutoRoute {
9882 provider: selection.provider,
9883 model: selection.model,
9884 reasoning_effort,
9885 auto_controls_reasoning,
9886 auto_model: true,
9887 })
9888 } else {
9889 if let Some(selection) = model_routing::resolve_explicit_route_with_inventory(config, model)
9890 {
9891 let auto_controls_reasoning = matches!(
9892 selection.reasoning_effort,
9893 Some(crate::tui::app::ReasoningEffort::Auto)
9894 );
9895 return Ok(CliAutoRoute {
9896 provider: selection.provider,
9897 model: selection.model,
9898 reasoning_effort: selection.reasoning_effort,
9899 auto_controls_reasoning,
9900 auto_model: false,
9901 });
9902 }
9903
9904 let candidate_providers = model_routing::explicit_route_candidate_providers(config, model);
9905 if !candidate_providers.is_empty() && !candidate_providers.contains(&config.api_provider())
9906 {
9907 let providers = candidate_providers
9908 .iter()
9909 .map(|provider| provider.as_str())
9910 .collect::<Vec<_>>()
9911 .join(", ");
9912 bail!(
9913 "model `{model}` is available from configured provider route(s): {providers}. \
9914 Pass `--provider <provider>` with `--model {model}` to choose one explicitly. \
9915 In the TUI, use `/provider`, `/model`, or `/setup` to resolve the route before sending."
9916 );
9917 }
9918
9919 let reasoning_effort = config
9926 .reasoning_effort()
9927 .map(crate::tui::app::ReasoningEffort::from_setting);
9928 Ok(CliAutoRoute {
9929 provider: config.api_provider(),
9930 model: model.to_string(),
9931 auto_controls_reasoning: matches!(
9932 reasoning_effort,
9933 Some(crate::tui::app::ReasoningEffort::Auto)
9934 ),
9935 reasoning_effort,
9936 auto_model: false,
9937 })
9938 }
9939}
9940
9941async fn resolve_cli_exec_route(
9942 config: &Config,
9943 model: &str,
9944 prompt: &str,
9945 force_configured_route: bool,
9946) -> Result<CliAutoRoute> {
9947 if force_configured_route && !model.trim().eq_ignore_ascii_case("auto") {
9948 let reasoning_effort = config
9949 .reasoning_effort()
9950 .map(crate::tui::app::ReasoningEffort::from_setting);
9951 return Ok(CliAutoRoute {
9952 provider: config.api_provider(),
9953 model: model.to_string(),
9954 auto_controls_reasoning: matches!(
9955 reasoning_effort,
9956 Some(crate::tui::app::ReasoningEffort::Auto)
9957 ),
9958 reasoning_effort,
9959 auto_model: false,
9960 });
9961 }
9962 resolve_cli_auto_route(config, model, prompt).await
9963}
9964
9965fn should_force_configured_exec_route(
9966 resuming: bool,
9967 explicit_provider: Option<&str>,
9968 explicit_model: Option<&str>,
9969) -> bool {
9970 resuming || explicit_provider.is_some() || explicit_model.is_none()
9975}
9976
9977async fn run_one_shot(
9978 config: &Config,
9979 model: &str,
9980 prompt: &str,
9981 force_configured_route: bool,
9982) -> Result<()> {
9983 use crate::client::DeepSeekClient;
9984 use crate::models::{
9985 ContentBlock, Message, MessageRequest, is_incomplete_stop_reason, stop_reason_detail,
9986 };
9987
9988 let route = resolve_cli_exec_route(config, model, prompt, force_configured_route).await?;
9989 let execution_config = config_for_cli_route(config, &route);
9990 let client = DeepSeekClient::new(&execution_config)?;
9991 let reasoning_effort = route.reasoning_effort.and_then(|effort| {
9992 cli_reasoning_effort_value_for_prompt(&execution_config, &route.model, effort, prompt)
9993 });
9994 let model = route.model;
9995 let request_route = client.effective_route_envelope(&model, chrono::Utc::now());
9996
9997 let request = MessageRequest {
9998 model,
9999 messages: vec![Message {
10000 role: "user".to_string(),
10001 content: vec![ContentBlock::Text {
10002 text: prompt.to_string(),
10003 cache_control: None,
10004 }],
10005 }],
10006 max_tokens: crate::route_budget::effective_max_output_tokens_for_route(
10007 request_route.provider,
10008 &request_route.model,
10009 None,
10010 ),
10011 system: None,
10012 tools: None,
10013 tool_choice: None,
10014 metadata: None,
10015 thinking: None,
10016 reasoning_effort,
10017 stream: Some(false),
10018 temperature: None,
10019 top_p: None,
10020 };
10021
10022 let response = client.create_message(request).await?;
10023 let stop_reason = response.stop_reason.clone();
10024
10025 for block in response.content {
10026 if let ContentBlock::Text { text, .. } = block {
10027 println!("{text}");
10028 }
10029 }
10030
10031 if is_incomplete_stop_reason(stop_reason.as_deref()) {
10032 anyhow::bail!(
10033 "Model response incomplete: provider stop reason `{}`; the partial response was printed but the command did not succeed.",
10034 stop_reason_detail(stop_reason.as_deref())
10035 );
10036 }
10037
10038 Ok(())
10039}
10040
10041async fn run_one_shot_json(
10042 config: &Config,
10043 model: &str,
10044 prompt: &str,
10045 force_configured_route: bool,
10046) -> Result<()> {
10047 use crate::client::DeepSeekClient;
10048 use crate::models::{
10049 ContentBlock, Message, MessageRequest, SystemPrompt, is_incomplete_stop_reason,
10050 stop_reason_detail,
10051 };
10052
10053 let route = resolve_cli_exec_route(config, model, prompt, force_configured_route).await?;
10054 let execution_config = config_for_cli_route(config, &route);
10055 let provider = execution_config.provider_identity_for(route.provider);
10056 let client = DeepSeekClient::new(&execution_config)?;
10057 let model = route.model.clone();
10058 let reasoning_effort = route.reasoning_effort.and_then(|effort| {
10059 cli_reasoning_effort_value_for_prompt(&execution_config, &model, effort, prompt)
10060 });
10061 let request_route = client.effective_route_envelope(&model, chrono::Utc::now());
10062 let request = MessageRequest {
10063 model: model.clone(),
10064 messages: vec![Message {
10065 role: "user".to_string(),
10066 content: vec![ContentBlock::Text {
10067 text: prompt.to_string(),
10068 cache_control: None,
10069 }],
10070 }],
10071 max_tokens: crate::route_budget::effective_max_output_tokens_for_route(
10072 request_route.provider,
10073 &request_route.model,
10074 None,
10075 ),
10076 system: Some(SystemPrompt::Text(
10077 "You are a coding assistant. Give concise, actionable responses.".to_string(),
10078 )),
10079 tools: None,
10080 tool_choice: None,
10081 metadata: None,
10082 thinking: None,
10083 reasoning_effort,
10084 stream: Some(false),
10085 temperature: None,
10086 top_p: None,
10087 };
10088
10089 let response = client.create_message(request).await?;
10090 let stop_reason = response.stop_reason.clone();
10091 let usage = response.usage.clone();
10092 let mut output = String::new();
10093 for block in response.content {
10094 if let ContentBlock::Text { text, .. } = block {
10095 output.push_str(&text);
10096 }
10097 }
10098 println!(
10099 "{}",
10100 serde_json::to_string_pretty(&one_shot_exec_json_receipt(
10101 provider,
10102 model,
10103 output,
10104 stop_reason.clone(),
10105 usage,
10106 ))?
10107 );
10108 if is_incomplete_stop_reason(stop_reason.as_deref()) {
10109 anyhow::bail!(
10110 "Model response incomplete: provider stop reason `{}`; the JSON receipt records success=false.",
10111 stop_reason_detail(stop_reason.as_deref())
10112 );
10113 }
10114 Ok(())
10115}
10116
10117fn one_shot_exec_json_receipt(
10118 provider: String,
10119 model: String,
10120 output: String,
10121 stop_reason: Option<String>,
10122 usage: crate::models::Usage,
10123) -> serde_json::Value {
10124 let incomplete = crate::models::is_incomplete_stop_reason(stop_reason.as_deref());
10125 let error = incomplete.then(|| {
10126 format!(
10127 "Model response incomplete: provider stop reason `{}`.",
10128 crate::models::stop_reason_detail(stop_reason.as_deref())
10129 )
10130 });
10131 serde_json::json!({
10132 "mode": "one-shot",
10133 "provider": provider,
10134 "model": model,
10135 "success": !incomplete,
10136 "output": output,
10137 "stop_reason": stop_reason,
10138 "usage": usage,
10139 "error": error,
10140 })
10141}
10142
10143fn exec_stream_provider_route(
10144 identity: &crate::config::ProviderIdentity,
10145) -> (String, Option<String>) {
10146 let provider = identity.provider.as_str().to_string();
10147 let provider_id = if identity.provider == crate::config::ApiProvider::Custom {
10148 identity.exact_id.clone()
10149 } else {
10150 None
10151 };
10152 (provider, provider_id)
10153}
10154
10155#[derive(serde::Serialize)]
10156struct ExecStreamMeta {
10157 receipt_kind: &'static str,
10158 provider: String,
10159 #[serde(skip_serializing_if = "Option::is_none")]
10163 provider_id: Option<String>,
10164 model: String,
10165 route_source: String,
10166 #[serde(skip_serializing_if = "Option::is_none")]
10167 input_tokens: Option<u32>,
10168 #[serde(skip_serializing_if = "Option::is_none")]
10169 output_tokens: Option<u32>,
10170 #[serde(skip_serializing_if = "Option::is_none")]
10171 prompt_cache_hit_tokens: Option<u32>,
10172 #[serde(skip_serializing_if = "Option::is_none")]
10173 prompt_cache_miss_tokens: Option<u32>,
10174 #[serde(skip_serializing_if = "Option::is_none")]
10175 prompt_cache_write_tokens: Option<u32>,
10176 #[serde(skip_serializing_if = "Option::is_none")]
10177 reasoning_tokens: Option<u32>,
10178 #[serde(skip_serializing_if = "Option::is_none")]
10180 codewhale_max_output_tokens: Option<u32>,
10181 #[serde(skip_serializing_if = "Option::is_none")]
10184 codewhale_max_output_tokens_source: Option<&'static str>,
10185 duration_ms: u64,
10186 #[serde(skip_serializing_if = "Option::is_none")]
10187 retry_count: Option<u32>,
10188 approval_posture: String,
10189 sandbox_posture: String,
10190 #[serde(skip_serializing_if = "Option::is_none")]
10191 binary_sha256: Option<String>,
10192 #[serde(skip_serializing_if = "Option::is_none")]
10193 config_sha256: Option<String>,
10194 prompt_sha256: String,
10195 #[serde(skip_serializing_if = "Option::is_none")]
10196 tool_catalog_sha256: Option<String>,
10197 input_analysis: ExecStreamInputAnalysis,
10198 visible_final_answer_chars: usize,
10199 session_id: String,
10200 resume_command: String,
10201 workspace: String,
10202 message_count: usize,
10203 #[serde(skip_serializing_if = "Option::is_none")]
10204 status: Option<String>,
10205 #[serde(skip_serializing_if = "Option::is_none")]
10206 termination_reason: Option<String>,
10207 #[serde(skip_serializing_if = "Option::is_none")]
10208 error_category: Option<String>,
10209 #[serde(skip_serializing_if = "Option::is_none")]
10210 error: Option<String>,
10211}
10212
10213#[derive(Debug, Default, Clone, serde::Serialize, PartialEq, Eq)]
10214struct ExecStreamInputAnalysis {
10215 estimated_request_tokens: usize,
10216 estimated_message_content_tokens: usize,
10217 estimated_system_tokens: usize,
10218 estimated_framing_tokens: usize,
10219 user_message_count: usize,
10220 assistant_message_count: usize,
10221 tool_message_count: usize,
10222 tool_use_count: usize,
10223 tool_result_count: usize,
10224 text_chars: usize,
10225 thinking_chars: usize,
10226 tool_use_input_chars: usize,
10227 tool_result_chars: usize,
10228 text_estimated_tokens: usize,
10229 thinking_estimated_tokens: usize,
10230 tool_use_input_estimated_tokens: usize,
10231 tool_result_estimated_tokens: usize,
10232}
10233
10234#[derive(serde::Serialize)]
10235#[serde(tag = "type")]
10236#[allow(clippy::large_enum_variant)]
10239enum ExecStreamEvent {
10240 #[serde(rename = "content")]
10241 Content { content: String },
10242 #[serde(rename = "tool_use")]
10243 ToolUse {
10244 name: String,
10245 id: String,
10246 input: serde_json::Value,
10247 started_at: String,
10248 },
10249 #[serde(rename = "tool_result")]
10250 ToolResult {
10251 id: String,
10252 name: String,
10253 output: String,
10254 status: String,
10255 started_at: String,
10256 completed_at: String,
10257 duration_ms: u64,
10258 side_effect_status: String,
10259 #[serde(skip_serializing_if = "Option::is_none")]
10260 error_category: Option<String>,
10261 #[serde(skip_serializing_if = "Option::is_none")]
10262 truncated: Option<bool>,
10263 #[serde(skip_serializing_if = "Option::is_none")]
10264 artifact: Option<serde_json::Value>,
10265 #[serde(skip_serializing_if = "Option::is_none")]
10266 result_metadata: Option<serde_json::Value>,
10267 },
10268 #[serde(rename = "agent_spawned")]
10276 AgentSpawned {
10277 id: String,
10278 model: String,
10279 spawn_depth: u32,
10280 #[serde(skip_serializing_if = "Option::is_none")]
10281 parent_run_id: Option<String>,
10282 #[serde(skip_serializing_if = "Option::is_none")]
10284 route_source: Option<String>,
10285 },
10286 #[serde(rename = "sandbox_denied")]
10287 SandboxDenied {
10288 tool_id: String,
10289 tool_name: String,
10290 reason: String,
10291 outcome: String,
10292 },
10293 #[serde(rename = "workflow_event")]
10294 WorkflowEvent {
10295 run_id: String,
10296 event: serde_json::Value,
10297 },
10298 #[serde(rename = "session_capture")]
10299 SessionCapture { content: String },
10300 #[serde(rename = "service_released")]
10301 #[cfg(unix)]
10302 ServiceReleased {
10303 task_id: String,
10304 pid: u32,
10305 process_group_id: u32,
10306 ownership: String,
10307 },
10308 #[serde(rename = "turn_usage")]
10315 TurnUsage {
10316 turn: u32,
10318 input_tokens: u32,
10319 output_tokens: u32,
10320 #[serde(skip_serializing_if = "Option::is_none")]
10321 reasoning_tokens: Option<u32>,
10322 #[serde(skip_serializing_if = "Option::is_none")]
10323 prompt_cache_hit_tokens: Option<u32>,
10324 #[serde(skip_serializing_if = "Option::is_none")]
10325 prompt_cache_miss_tokens: Option<u32>,
10326 #[serde(skip_serializing_if = "Option::is_none")]
10327 prompt_cache_write_tokens: Option<u32>,
10328 #[serde(skip_serializing_if = "Option::is_none")]
10329 reasoning_replay_tokens: Option<u32>,
10330 duration_ms: u64,
10331 },
10332 #[serde(rename = "metadata")]
10333 Metadata { meta: Box<ExecStreamMeta> },
10334 #[serde(rename = "done")]
10335 Done,
10336 #[serde(rename = "error")]
10337 Error { error: String },
10338}
10339
10340fn exec_sandbox_elevation_authorized(
10341 allow_sandbox_elevation: bool,
10342 explicit_sandbox: Option<&str>,
10343) -> bool {
10344 allow_sandbox_elevation
10345 || explicit_sandbox.is_some_and(|policy| policy.eq_ignore_ascii_case("danger-full-access"))
10346}
10347
10348fn emit_exec_stream_event(event: &ExecStreamEvent) -> Result<()> {
10349 println!("{}", serde_json::to_string(&exec_stream_value(event)?)?);
10350 Ok(())
10351}
10352
10353const EXEC_EXIT_RETRYABLE_INFRA: i32 = 75; fn exec_failure_exit_code(error_category: Option<&str>) -> i32 {
10369 match error_category {
10370 Some("network" | "timeout") => EXEC_EXIT_RETRYABLE_INFRA,
10371 _ => 1,
10372 }
10373}
10374
10375fn exec_error_event_is_fatal(envelope: &crate::error_taxonomy::ErrorEnvelope) -> bool {
10382 !envelope.recoverable
10383}
10384
10385fn exec_stream_value(event: &ExecStreamEvent) -> Result<serde_json::Value> {
10386 let mut value = serde_json::to_value(event)?;
10387 if let Some(object) = value.as_object_mut() {
10388 object.insert("schema_version".to_string(), serde_json::json!(1));
10389 object.insert(
10390 "schema".to_string(),
10391 serde_json::json!("codewhale.exec-stream"),
10392 );
10393 }
10394 Ok(value)
10395}
10396
10397fn tool_error_receipt_category(error: &crate::tools::spec::ToolError) -> &'static str {
10398 use crate::tools::spec::ToolError;
10399 match error {
10400 ToolError::InvalidInput { .. } => "invalid_input",
10401 ToolError::MissingField { .. } => "missing_field",
10402 ToolError::PathEscape { .. } => "path_escape",
10403 ToolError::ExecutionFailed { .. } => "execution_failed",
10404 ToolError::Timeout { .. } => "timeout",
10405 ToolError::Cancelled { .. } => "cancelled",
10406 ToolError::NotAvailable { .. } => "not_available",
10407 ToolError::PermissionDenied { .. } => "permission_denied",
10408 }
10409}
10410
10411fn tool_artifact_receipt(metadata: Option<&serde_json::Value>) -> Option<serde_json::Value> {
10412 let object = metadata?.as_object()?;
10413 let mut artifact = serde_json::Map::new();
10414 for key in [
10415 "artifact_id",
10416 "artifact_path",
10417 "artifact_relative_path",
10418 "artifact_byte_size",
10419 "spillover_path",
10420 "content_digest",
10421 "original_byte_count",
10422 "retained_head_bytes",
10423 "retained_tail_bytes",
10424 ] {
10425 if let Some(value) = object.get(key) {
10426 artifact.insert(key.to_string(), value.clone());
10427 }
10428 }
10429 (!artifact.is_empty()).then_some(serde_json::Value::Object(artifact))
10430}
10431
10432fn current_binary_sha256() -> Option<String> {
10433 let bytes = std::fs::read(std::env::current_exe().ok()?).ok()?;
10434 Some(format!("sha256:{}", crate::hashing::sha256_hex(&bytes)))
10435}
10436
10437async fn run_workflow_tool_command(
10438 cli: &Cli,
10439 args: WorkflowToolArgs,
10440 plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
10441) -> Result<()> {
10442 match run_workflow_tool_command_inner(cli, args, plugin_registry).await {
10443 Ok(()) => Ok(()),
10444 Err(error) => {
10445 let _ = emit_exec_stream_event(&ExecStreamEvent::Error {
10446 error: format!("{error:#}"),
10447 });
10448 exit_workflow_tool_failure();
10449 }
10450 }
10451}
10452
10453async fn run_workflow_tool_command_inner(
10454 cli: &Cli,
10455 args: WorkflowToolArgs,
10456 plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
10457) -> Result<()> {
10458 use crate::tools::spec::ToolSpec;
10459
10460 if args.approval_source != "explicit-workflow-command" {
10461 bail!("workflow-tool requires --approval-source explicit-workflow-command");
10462 }
10463 let input: serde_json::Value = serde_json::from_str(&args.input_json)
10464 .context("--input-json must be a valid Workflow tool input object")?;
10465 if !input.is_object() {
10466 bail!("--input-json must be a JSON object");
10467 }
10468 if !input
10469 .get("action")
10470 .and_then(serde_json::Value::as_str)
10471 .is_some_and(|action| action.eq_ignore_ascii_case("run"))
10472 {
10473 bail!("workflow-tool accepts only action=run");
10474 }
10475
10476 let workspace = resolve_workspace(cli);
10477 let mut config = load_config_from_cli(cli)?;
10478 merge_user_workspace_config(&mut config, cli.config.clone(), &workspace);
10479 if let Ok(env_url) =
10480 std::env::var("CODEWHALE_BASE_URL").or_else(|_| std::env::var("DEEPSEEK_BASE_URL"))
10481 {
10482 let trimmed = env_url.trim();
10483 if !trimmed.is_empty() {
10484 config.base_url = Some(trimmed.to_string());
10485 }
10486 }
10487
10488 let model = resolve_exec_model(&config, None);
10489 let route = resolve_cli_exec_route(
10490 &config,
10491 &model,
10492 "Run a checked-in Workflow through the host runtime",
10493 true,
10494 )
10495 .await?;
10496 let execution_config = config_for_cli_route(&config, &route);
10497 let route_identity = execution_config
10498 .active_provider_identity(route.provider)
10499 .map_err(anyhow::Error::msg)
10500 .context("workflow terminal route lost its exact provider identity")?;
10501 let (route_provider, route_provider_id) = exec_stream_provider_route(&route_identity);
10502 let workflow_input_sha256 = format!(
10503 "sha256:{}",
10504 crate::hashing::sha256_hex(&serde_json::to_vec(&input)?)
10505 );
10506 let tool_id = format!("workflow_host_{}", &uuid::Uuid::new_v4().to_string()[..8]);
10507 let tool_started = Instant::now();
10508 let tool_started_at = chrono::Utc::now().to_rfc3339();
10509
10510 emit_exec_stream_event(&ExecStreamEvent::ToolUse {
10511 name: "workflow".to_string(),
10512 id: tool_id.clone(),
10513 input: input.clone(),
10514 started_at: tool_started_at.clone(),
10515 })?;
10516
10517 let (event_tx, event_rx) = tokio::sync::mpsc::channel(1024);
10518 let (stop_tx, stop_rx) = tokio::sync::oneshot::channel();
10519 let event_forwarder = tokio::spawn(forward_direct_workflow_events(event_rx, stop_rx));
10520 let (tool, context) = match build_direct_workflow_tool(
10521 &execution_config,
10522 &route,
10523 &workspace,
10524 event_tx,
10525 plugin_registry,
10526 )
10527 .await
10528 {
10529 Ok(built) => built,
10530 Err(err) => {
10531 let _ = stop_tx.send(());
10532 let _ = event_forwarder.await;
10533 exit_workflow_tool_error(&tool_id, err.to_string());
10534 }
10535 };
10536
10537 let result = tool.execute(input, &context).await;
10538 drop(tool);
10539 let _ = stop_tx.send(());
10540 event_forwarder
10541 .await
10542 .context("workflow event forwarder task failed")??;
10543
10544 let result = match result {
10545 Ok(result) => result,
10546 Err(err) => {
10547 let error = err.to_string();
10548 exit_workflow_tool_error(&tool_id, error);
10549 }
10550 };
10551
10552 let workflow_status =
10553 direct_workflow_status(&result.content).unwrap_or_else(|| "unknown".to_string());
10554 let completed = result.success && workflow_status == "completed";
10555 emit_exec_stream_event(&ExecStreamEvent::ToolResult {
10556 id: tool_id,
10557 name: "workflow".to_string(),
10558 output: result.content.clone(),
10559 status: if completed { "success" } else { "error" }.to_string(),
10560 started_at: tool_started_at,
10561 completed_at: chrono::Utc::now().to_rfc3339(),
10562 duration_ms: u64::try_from(tool_started.elapsed().as_millis()).unwrap_or(u64::MAX),
10563 side_effect_status: result
10564 .metadata
10565 .as_ref()
10566 .and_then(|metadata| metadata.get("side_effect_status"))
10567 .and_then(serde_json::Value::as_str)
10568 .unwrap_or("unknown")
10569 .to_string(),
10570 error_category: (!completed).then(|| "tool_error".to_string()),
10571 truncated: result
10572 .metadata
10573 .as_ref()
10574 .and_then(|metadata| metadata.get("truncated"))
10575 .and_then(serde_json::Value::as_bool),
10576 artifact: tool_artifact_receipt(result.metadata.as_ref()),
10577 result_metadata: result.metadata.clone(),
10578 })?;
10579 emit_exec_stream_event(&ExecStreamEvent::Metadata {
10580 meta: Box::new(ExecStreamMeta {
10581 receipt_kind: "terminal",
10582 provider: route_provider,
10583 provider_id: route_provider_id,
10584 model: "host-workflow".to_string(),
10588 route_source: "host_workflow".to_string(),
10589 input_tokens: None,
10590 output_tokens: None,
10591 prompt_cache_hit_tokens: None,
10592 prompt_cache_miss_tokens: None,
10593 prompt_cache_write_tokens: None,
10594 reasoning_tokens: None,
10595 codewhale_max_output_tokens: None,
10596 codewhale_max_output_tokens_source: None,
10597 duration_ms: u64::try_from(tool_started.elapsed().as_millis()).unwrap_or(u64::MAX),
10598 retry_count: None,
10599 approval_posture: "explicit_workflow_command".to_string(),
10600 sandbox_posture: "configured".to_string(),
10601 binary_sha256: current_binary_sha256(),
10602 config_sha256: None,
10603 prompt_sha256: workflow_input_sha256,
10604 tool_catalog_sha256: None,
10605 input_analysis: ExecStreamInputAnalysis::default(),
10606 visible_final_answer_chars: result.content.chars().count(),
10607 session_id: String::new(),
10608 resume_command: String::new(),
10609 workspace: workspace.display().to_string(),
10610 message_count: 0,
10611 status: Some(workflow_status.clone()),
10612 termination_reason: Some(if completed { "resolved" } else { "tool_error" }.to_string()),
10613 error_category: (!completed).then(|| "tool".to_string()),
10614 error: (!completed)
10615 .then(|| format!("workflow run ended with terminal status {workflow_status}")),
10616 }),
10617 })?;
10618 if !completed {
10619 let error = format!("workflow run ended with terminal status {workflow_status}");
10620 emit_exec_stream_event(&ExecStreamEvent::Error {
10621 error: error.clone(),
10622 })?;
10623 exit_workflow_tool_failure();
10624 }
10625 emit_exec_stream_event(&ExecStreamEvent::Done)?;
10626 Ok(())
10627}
10628
10629fn exit_workflow_tool_failure() -> ! {
10630 let _ = io::stdout().flush();
10631 std::process::exit(1)
10632}
10633
10634fn exit_workflow_tool_error(tool_id: &str, error: String) -> ! {
10635 let now = chrono::Utc::now().to_rfc3339();
10636 let _ = emit_exec_stream_event(&ExecStreamEvent::ToolResult {
10637 id: tool_id.to_string(),
10638 name: "workflow".to_string(),
10639 output: error.clone(),
10640 status: "error".to_string(),
10641 started_at: now.clone(),
10642 completed_at: now,
10643 duration_ms: 0,
10644 side_effect_status: "unknown".to_string(),
10645 error_category: Some("execution_failed".to_string()),
10646 truncated: None,
10647 artifact: None,
10648 result_metadata: None,
10649 });
10650 let _ = emit_exec_stream_event(&ExecStreamEvent::Error { error });
10651 exit_workflow_tool_failure()
10652}
10653
10654async fn initialize_direct_workflow_mcp_pool(
10655 config: &Config,
10656 workspace: &Path,
10657 network_policy: Option<crate::network_policy::NetworkPolicyDecider>,
10658 plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
10659) -> Option<(
10660 std::sync::Arc<tokio::sync::Mutex<crate::mcp::McpPool>>,
10661 Vec<(String, String)>,
10662)> {
10663 if !config.features().enabled(Feature::Mcp) {
10664 return None;
10665 }
10666 let mut pool = crate::mcp::McpPool::from_config_path_with_workspace_and_plugins(
10667 &config.mcp_config_path(),
10668 workspace,
10669 plugin_registry,
10670 )
10671 .unwrap_or_else(|error| {
10672 tracing::debug!("No MCP config for direct Workflow runtime: {error:#}");
10673 crate::mcp::McpPool::new(crate::mcp::McpConfig::default())
10674 });
10675 if let Some(policy) = network_policy {
10676 pool = pool.with_network_policy(policy);
10677 }
10678 let failures = pool
10679 .connect_all()
10680 .await
10681 .into_iter()
10682 .map(|(server, error)| (server, format!("{error:#}")))
10683 .collect();
10684 Some((std::sync::Arc::new(tokio::sync::Mutex::new(pool)), failures))
10685}
10686
10687async fn build_direct_workflow_tool(
10688 config: &Config,
10689 route: &CliAutoRoute,
10690 workspace: &Path,
10691 event_tx: tokio::sync::mpsc::Sender<crate::core::events::Event>,
10692 plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
10693) -> Result<(
10694 crate::tools::workflow::WorkflowTool,
10695 crate::tools::ToolContext,
10696)> {
10697 use std::sync::Arc;
10698
10699 use crate::client::DeepSeekClient;
10700 use crate::core::authority::shell_policy_for_mode;
10701 use crate::fleet::roster::FleetRoster;
10702 use crate::tools::AgentToolSurfaceOptions;
10703 use crate::tools::goal::new_shared_goal_state;
10704 use crate::tools::subagent::{SubAgentRuntime, new_shared_subagent_manager_with_timeout};
10705 use crate::tools::todo::new_shared_todo_list;
10706 use crate::tui::app::AppMode;
10707
10708 let provider = config.api_provider();
10709 if !config.subagents_enabled_for_provider(provider) {
10710 bail!(
10711 "Workflow dispatch requires sub-agents for provider {} ({})",
10712 provider.as_str(),
10713 config
10714 .subagents_disabled_reason()
10715 .unwrap_or("provider-specific sub-agent configuration disabled it")
10716 );
10717 }
10718
10719 let yolo = config.yolo.unwrap_or(false);
10720 let mode = if yolo {
10721 AppMode::Yolo
10722 } else {
10723 AppMode::Operate
10724 };
10725 let allow_shell = yolo || config.allow_shell();
10726 let shell_policy = shell_policy_for_mode(mode, allow_shell);
10727 let trusted = crate::workspace_trust::WorkspaceTrust::load_for(workspace);
10728 let mut context = crate::tools::ToolContext::with_auto_approve(
10729 workspace.to_path_buf(),
10730 yolo,
10731 config.notes_path(),
10732 config.mcp_config_path(),
10733 yolo,
10734 )
10735 .with_features(config.features())
10736 .with_skills_config(
10737 config.skills_dir(),
10738 config.skills_config().scan_codewhale_only(),
10739 )
10740 .with_plugin_registry(std::sync::Arc::clone(&plugin_registry))
10741 .with_shell_policy(shell_policy)
10742 .with_trusted_external_paths(trusted.paths().to_vec())
10743 .with_elevated_sandbox_policy(crate::core::authority::sandbox_policy_for_turn(
10744 mode,
10745 if yolo {
10746 crate::tui::approval::ApprovalMode::Bypass
10747 } else {
10748 crate::tui::approval::ApprovalMode::Suggest
10749 },
10750 config.sandbox_mode.as_deref(),
10751 workspace,
10752 ));
10753 let network_policy = config.network.clone().map(|network| {
10754 crate::network_policy::NetworkPolicyDecider::with_default_audit(network.into_runtime())
10755 });
10756 if let Some(policy) = network_policy.as_ref() {
10757 context = context.with_network_policy(policy.clone());
10758 }
10759 if config.memory_enabled() {
10760 context.memory_path = Some(config.memory_path());
10761 }
10762 context.search_provider = config.search_provider();
10763 context.search_api_key = config
10764 .search
10765 .as_ref()
10766 .and_then(|search| search.api_key.clone());
10767 context.search_base_url = config
10768 .search
10769 .as_ref()
10770 .and_then(|search| search.base_url.clone());
10771 if let Some(backend) = crate::sandbox::backend::create_backend(config)? {
10772 context = context.with_sandbox_backend(Arc::from(backend));
10773 }
10774
10775 let max_subagents = config.max_subagents_for_provider(provider);
10776 let manager = new_shared_subagent_manager_with_timeout(
10777 workspace.to_path_buf(),
10778 max_subagents,
10779 config
10780 .max_admitted_subagents_for_provider(provider)
10781 .max(max_subagents),
10782 Duration::from_secs(config.subagent_heartbeat_timeout_secs_for_provider(provider)),
10783 config.launch_concurrency_for_provider(provider),
10784 config.subagent_token_budget_for_provider(provider),
10785 );
10786 let roster = Arc::new(FleetRoster::load(&config.fleet_config(), workspace));
10787 let mut role_models = roster.model_overrides();
10788 role_models.extend(config.subagent_model_overrides());
10789
10790 let features = config.features();
10791 let mut surface = AgentToolSurfaceOptions::new(shell_policy);
10792 surface.apply_patch_enabled = features.enabled(Feature::ApplyPatch);
10793 surface.web_search_enabled = features.enabled(Feature::WebSearch);
10794 surface.memory_tool_enabled = config.memory_enabled();
10795 surface.vision_config = features
10796 .enabled(Feature::VisionModel)
10797 .then(|| config.vision_model_config())
10798 .flatten();
10799 surface.speech_output_dir = config.speech_output_dir();
10800 surface.goal_state = Some(new_shared_goal_state());
10801
10802 let client = DeepSeekClient::new(config)?;
10803 let reasoning_effort_auto = route.auto_controls_reasoning;
10810 let reasoning_effort = route
10811 .reasoning_effort
10812 .and_then(|effort| cli_reasoning_effort_value(config, &route.model, effort));
10813 let mcp_pool = if let Some((pool, failures)) =
10814 initialize_direct_workflow_mcp_pool(config, workspace, network_policy, plugin_registry)
10815 .await
10816 {
10817 for (server, error) in failures {
10818 tracing::warn!(
10819 server = %server,
10820 error = %error,
10821 "direct Workflow runtime could not connect MCP server"
10822 );
10823 }
10824 Some(pool)
10825 } else {
10826 None
10827 };
10828 let runtime = SubAgentRuntime::new(
10829 client,
10830 route.model.clone(),
10831 context.clone(),
10832 allow_shell,
10833 Some(event_tx),
10834 manager.clone(),
10835 )
10836 .with_locale_tag(
10837 crate::localization::resolve_locale(
10838 &crate::settings::Settings::load_persisted()
10839 .unwrap_or_default()
10840 .locale,
10841 )
10842 .tag(),
10843 )
10844 .with_role_models(role_models)
10845 .with_api_config(config.clone())
10846 .with_fleet_roster(roster)
10847 .with_auto_model(route.auto_model)
10848 .with_reasoning_effort(reasoning_effort, reasoning_effort_auto)
10849 .with_agent_tool_surface_options(surface)
10850 .with_max_spawn_depth(config.subagent_max_spawn_depth_for_provider(provider))
10851 .with_step_api_timeout(Duration::from_secs(
10852 config.subagent_api_timeout_secs_for_provider(provider),
10853 ))
10854 .with_speech_output_dir(config.speech_output_dir())
10855 .with_mcp_pool(mcp_pool)
10856 .with_todos(new_shared_todo_list())
10857 .with_parent_mode(mode);
10858
10859 Ok((
10860 crate::tools::workflow::WorkflowTool::new(manager, runtime).with_explicit_cli_approval(),
10861 context,
10862 ))
10863}
10864
10865async fn forward_direct_workflow_events(
10866 mut event_rx: tokio::sync::mpsc::Receiver<crate::core::events::Event>,
10867 mut stop_rx: tokio::sync::oneshot::Receiver<()>,
10868) -> Result<()> {
10869 loop {
10870 tokio::select! {
10871 biased;
10872 event = event_rx.recv() => match event {
10873 Some(event) => emit_direct_workflow_event(event)?,
10874 None => return Ok(()),
10875 },
10876 _ = &mut stop_rx => {
10877 while let Ok(event) = event_rx.try_recv() {
10878 emit_direct_workflow_event(event)?;
10879 }
10880 return Ok(());
10881 }
10882 }
10883 }
10884}
10885
10886fn emit_direct_workflow_event(event: crate::core::events::Event) -> Result<()> {
10887 if let crate::core::events::Event::WorkflowUi { run_id, event } = event {
10888 emit_exec_stream_event(&ExecStreamEvent::WorkflowEvent { run_id, event })?;
10889 }
10890 Ok(())
10891}
10892
10893fn direct_workflow_status(content: &str) -> Option<String> {
10894 serde_json::from_str::<serde_json::Value>(content)
10895 .ok()?
10896 .get("status")?
10897 .as_str()
10898 .map(str::to_ascii_lowercase)
10899}
10900
10901fn exec_stream_input_analysis(
10902 messages: &[Message],
10903 system: Option<&SystemPrompt>,
10904) -> ExecStreamInputAnalysis {
10905 let mut analysis = ExecStreamInputAnalysis {
10906 estimated_request_tokens: crate::compaction::estimate_input_tokens_conservative(
10907 messages, system,
10908 ),
10909 estimated_message_content_tokens: crate::compaction::estimate_tokens(messages),
10910 estimated_system_tokens: exec_stream_estimate_system_tokens(system),
10911 estimated_framing_tokens: messages.len().saturating_mul(12).saturating_add(48),
10912 ..ExecStreamInputAnalysis::default()
10913 };
10914
10915 for message in messages {
10916 match message.role.as_str() {
10917 "user" => analysis.user_message_count += 1,
10918 "assistant" => analysis.assistant_message_count += 1,
10919 "tool" => analysis.tool_message_count += 1,
10920 _ => {}
10921 }
10922
10923 for block in &message.content {
10924 match block {
10925 ContentBlock::Text { text, .. } => {
10926 exec_stream_add_text_estimate(
10927 text,
10928 &mut analysis.text_chars,
10929 &mut analysis.text_estimated_tokens,
10930 );
10931 }
10932 ContentBlock::Thinking { thinking, .. } => {
10933 exec_stream_add_text_estimate(
10934 thinking,
10935 &mut analysis.thinking_chars,
10936 &mut analysis.thinking_estimated_tokens,
10937 );
10938 }
10939 ContentBlock::ToolUse { input, .. } | ContentBlock::ServerToolUse { input, .. } => {
10940 analysis.tool_use_count += 1;
10941 exec_stream_add_json_estimate(
10942 input,
10943 &mut analysis.tool_use_input_chars,
10944 &mut analysis.tool_use_input_estimated_tokens,
10945 );
10946 }
10947 ContentBlock::ToolResult {
10948 content,
10949 content_blocks,
10950 ..
10951 } => {
10952 analysis.tool_result_count += 1;
10953 exec_stream_add_text_estimate(
10954 content,
10955 &mut analysis.tool_result_chars,
10956 &mut analysis.tool_result_estimated_tokens,
10957 );
10958 if let Some(blocks) = content_blocks {
10959 exec_stream_add_json_estimate(
10960 blocks,
10961 &mut analysis.tool_result_chars,
10962 &mut analysis.tool_result_estimated_tokens,
10963 );
10964 }
10965 }
10966 ContentBlock::ToolSearchToolResult { content, .. }
10967 | ContentBlock::CodeExecutionToolResult { content, .. } => {
10968 analysis.tool_result_count += 1;
10969 exec_stream_add_json_estimate(
10970 content,
10971 &mut analysis.tool_result_chars,
10972 &mut analysis.tool_result_estimated_tokens,
10973 );
10974 }
10975 ContentBlock::ImageUrl { .. } => {}
10976 }
10977 }
10978 }
10979
10980 analysis
10981}
10982
10983fn exec_stream_add_text_estimate(text: &str, chars: &mut usize, tokens: &mut usize) {
10984 *chars = chars.saturating_add(text.chars().count());
10985 *tokens = tokens.saturating_add(crate::compaction::estimate_text_tokens_conservative(text));
10986}
10987
10988fn exec_stream_add_json_estimate<T: serde::Serialize>(
10989 value: &T,
10990 chars: &mut usize,
10991 tokens: &mut usize,
10992) {
10993 let text = serde_json::to_string(value).unwrap_or_default();
10994 exec_stream_add_text_estimate(&text, chars, tokens);
10995}
10996
10997fn exec_stream_estimate_system_tokens(system: Option<&SystemPrompt>) -> usize {
10998 match system {
10999 Some(SystemPrompt::Text(text)) => {
11000 crate::compaction::estimate_text_tokens_conservative(text)
11001 }
11002 Some(SystemPrompt::Blocks(blocks)) => blocks
11003 .iter()
11004 .map(|block| crate::compaction::estimate_text_tokens_conservative(&block.text))
11005 .sum(),
11006 None => 0,
11007 }
11008}
11009
11010fn exec_saved_session_line(session_id: &str) -> String {
11011 format!("session: {}", truncate_id(session_id))
11012}
11013
11014fn exec_resumed_session_line(session_id: &str) -> String {
11015 format!("resumed session: {}", truncate_id(session_id))
11016}
11017
11018fn exec_stream_session_ref(session_id: &str) -> String {
11019 crate::utils::redacted_identifier_for_log(session_id)
11020}
11021
11022fn exec_stream_resume_hint(session_id: &str) -> String {
11023 if session_id.trim().is_empty() {
11024 String::new()
11025 } else {
11026 "codewhale exec --resume <redacted-session-id>".to_string()
11027 }
11028}
11029
11030#[derive(Clone, Copy)]
11031struct PersistedProviderRoute<'a> {
11032 kind: &'a str,
11033 id: Option<&'a str>,
11034}
11035
11036fn persist_exec_session(
11037 messages: &[Message],
11038 model: &str,
11039 provider_route: PersistedProviderRoute<'_>,
11040 workspace: &Path,
11041 system_prompt: &Option<SystemPrompt>,
11042 session_id: Option<&str>,
11043 total_tokens: u64,
11044) -> Result<String> {
11045 let manager =
11046 SessionManager::default_location().context("could not open session manager for save")?;
11047 let mut saved = if let Some(id) = session_id.filter(|id| !id.trim().is_empty()) {
11048 match manager.load_session(id) {
11049 Ok(existing) => session_manager::update_session(
11050 existing,
11051 messages,
11052 total_tokens,
11053 system_prompt.as_ref(),
11054 ),
11055 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
11056 session_manager::create_saved_session_with_id_and_mode(
11057 id.to_string(),
11058 messages,
11059 model,
11060 workspace,
11061 total_tokens,
11062 system_prompt.as_ref(),
11063 Some("exec"),
11064 )
11065 }
11066 Err(err) => return Err(err).context("could not load existing exec session"),
11067 }
11068 } else {
11069 session_manager::create_saved_session_with_mode(
11070 messages,
11071 model,
11072 workspace,
11073 total_tokens,
11074 system_prompt.as_ref(),
11075 Some("exec"),
11076 )
11077 };
11078 stamp_exec_session_metadata(
11079 &mut saved,
11080 model,
11081 provider_route.kind,
11082 provider_route.id,
11083 workspace,
11084 );
11085 let id = saved.metadata.id.clone();
11086 manager
11087 .save_session(&saved)
11088 .context("could not save exec session")?;
11089 Ok(id)
11090}
11091
11092fn stamp_exec_session_metadata(
11093 saved: &mut session_manager::SavedSession,
11094 model: &str,
11095 model_provider_kind: &str,
11096 model_provider_id: Option<&str>,
11097 workspace: &Path,
11098) {
11099 saved.metadata.model = model.to_string();
11100 saved
11101 .metadata
11102 .set_model_provider_route(model_provider_kind, model_provider_id);
11103 saved.metadata.workspace = workspace.to_path_buf();
11104 saved.metadata.mode = Some("exec".to_string());
11105}
11106
11107#[derive(serde::Serialize)]
11108struct ExecToolEntry {
11109 name: String,
11110 success: bool,
11111 output: String,
11112}
11113
11114#[derive(serde::Serialize)]
11115struct ExecOutcome {
11116 kind: String,
11117 outcome: String,
11118 tool_name: String,
11119 reason: String,
11120}
11121
11122#[derive(serde::Serialize, Default)]
11123struct ExecSummary {
11124 mode: String,
11125 provider: String,
11126 model: String,
11127 prompt: String,
11128 output: String,
11129 tools: Vec<ExecToolEntry>,
11130 outcomes: Vec<ExecOutcome>,
11131 status: Option<String>,
11132 termination_reason: Option<String>,
11133 error_category: Option<String>,
11134 error: Option<String>,
11135 #[serde(skip_serializing_if = "Vec::is_empty")]
11136 released_services: Vec<crate::tools::shell::PersistentServiceReceipt>,
11137}
11138
11139fn validate_exec_tool_authority_resume(
11140 tool_authority_json: Option<&str>,
11141 resuming: bool,
11142) -> Result<()> {
11143 if tool_authority_json.is_some() && resuming {
11144 bail!(
11145 "Fleet tool authority cannot be combined with exec --resume, --session-id, or --continue"
11146 );
11147 }
11148 Ok(())
11149}
11150
11151fn exec_network_policy(
11152 config: &Config,
11153 outer_network_access: Option<bool>,
11154) -> Option<crate::network_policy::NetworkPolicyDecider> {
11155 if outer_network_access == Some(false) {
11158 return Some(crate::network_policy::NetworkPolicyDecider::new(
11159 crate::network_policy::NetworkPolicy {
11160 default: crate::network_policy::DecisionToml::Deny,
11161 ..crate::network_policy::NetworkPolicy::default()
11162 },
11163 None,
11164 ));
11165 }
11166 config.network.clone().map(|toml_cfg| {
11167 crate::network_policy::NetworkPolicyDecider::with_default_audit(toml_cfg.into_runtime())
11168 })
11169}
11170
11171fn apply_fleet_engine_feature_caps(
11172 features: &mut crate::features::Features,
11173 fleet_authority_active: bool,
11174 outer_network_access: Option<bool>,
11175 shell_authority: crate::tools::spec::ToolShellAuthority,
11176) {
11177 if fleet_authority_active {
11178 features.disable(crate::features::Feature::Subagents);
11179 features.disable(crate::features::Feature::Mcp);
11180 if shell_authority != crate::tools::spec::ToolShellAuthority::ReadOnly {
11181 features.disable(crate::features::Feature::ShellTool);
11182 }
11183 }
11184 if outer_network_access == Some(false) {
11185 features.disable(crate::features::Feature::WebSearch);
11186 }
11187}
11188
11189fn exec_max_steps(max_turns: Option<u32>) -> u32 {
11193 max_turns.unwrap_or(u32::MAX)
11194}
11195
11196#[allow(clippy::too_many_arguments)]
11197async fn run_exec_agent(
11198 config: &Config,
11199 model: &str,
11200 prompt: &str,
11201 workspace: PathBuf,
11202 max_subagents: usize,
11203 auto_approve: bool,
11204 allow_sandbox_elevation: bool,
11205 explicit_sandbox: Option<&str>,
11206 trust_mode: bool,
11207 json_output: bool,
11208 resume_session: Option<session_manager::SavedSession>,
11209 force_configured_route: bool,
11210 output_format: ExecOutputFormat,
11211 max_turns: u32,
11212 allowed_tools: Option<Vec<String>>,
11213 disallowed_tools: Option<Vec<String>>,
11214 append_system_prompt: Option<String>,
11215 tool_authority_json: Option<String>,
11216 plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
11217) -> Result<()> {
11218 use crate::compaction::CompactionConfig;
11219 use crate::core::engine::{EngineConfig, spawn_engine};
11220 use crate::core::events::Event;
11221 use crate::core::ops::Op;
11222 use crate::tools::plan::new_shared_plan_state;
11223 use crate::tools::todo::new_shared_todo_list;
11224 use crate::tui::app::AppMode;
11225
11226 validate_exec_tool_authority_resume(tool_authority_json.as_deref(), resume_session.is_some())?;
11227 let fleet_authority = tool_authority_json
11228 .as_deref()
11229 .map(crate::tools::spec::ToolAuthorityEnvelope::from_json)
11230 .transpose()
11231 .map_err(anyhow::Error::msg)?;
11232 let fleet_authority_active = fleet_authority.is_some();
11233 let outer_network_access = fleet_authority
11234 .as_ref()
11235 .and_then(|authority| authority.network_access);
11236 let outer_shell_authority = fleet_authority
11237 .as_ref()
11238 .map(|authority| authority.shell)
11239 .unwrap_or_default();
11240 if let Some(envelope) = fleet_authority {
11241 crate::tools::spec::install_process_tool_authority(envelope).map_err(anyhow::Error::msg)?;
11242 }
11243
11244 let route = resolve_cli_exec_route(config, model, prompt, force_configured_route).await?;
11245 let execution_config = config_for_cli_route(config, &route);
11246 let auto_model = route.auto_model;
11247 let effective_provider = route.provider;
11248 let effective_model = route.model;
11249 let validated_route = crate::route_runtime::resolve_runtime_route(
11250 &execution_config,
11251 effective_provider,
11252 Some(&effective_model),
11253 )
11254 .map_err(anyhow::Error::msg)?
11255 .validate()
11256 .map_err(anyhow::Error::msg)?;
11257 let effective_provider_name = validated_route.identity.key.clone();
11258 let effective_provider_id = validated_route.identity.exact_id.clone();
11259 let (effective_provider_kind, effective_stream_provider_id) =
11260 exec_stream_provider_route(&validated_route.identity);
11261 let route_source = if auto_model {
11262 "auto_resolver"
11263 } else {
11264 "explicit_or_configured"
11265 }
11266 .to_string();
11267 let exec_started = Instant::now();
11268 let prompt_sha256 = format!("sha256:{}", crate::hashing::sha256_hex(prompt.as_bytes()));
11269 let binary_sha256 = current_binary_sha256();
11270 let approval_posture = if auto_approve { "auto_tools" } else { "ask" }.to_string();
11271 let sandbox_posture = explicit_sandbox.unwrap_or("configured_default").to_string();
11272 let active_route_limits =
11273 crate::route_budget::known_route_limits(validated_route.candidate.limits());
11274 let max_subagents = if max_subagents == config.max_subagents_for_provider(config.api_provider())
11275 {
11276 execution_config
11277 .max_subagents_for_provider(effective_provider)
11278 .clamp(1, MAX_SUBAGENTS)
11279 } else {
11280 max_subagents
11281 };
11282 let reasoning_effort_auto = route.auto_controls_reasoning;
11289 let effective_reasoning_effort = route.reasoning_effort.and_then(|effort| {
11293 cli_reasoning_effort_value_for_prompt(&execution_config, &effective_model, effort, prompt)
11294 });
11295
11296 let settings = crate::settings::Settings::load().unwrap_or_default();
11297 let auto_compact_enabled = if crate::settings::Settings::auto_compact_explicitly_configured() {
11298 settings.auto_compact
11299 } else {
11300 crate::route_budget::auto_compact_default_for_route(
11301 effective_provider,
11302 &effective_model,
11303 active_route_limits,
11304 )
11305 };
11306 let compaction = CompactionConfig {
11307 enabled: auto_compact_enabled,
11308 model: effective_model.clone(),
11309 effective_context_window: Some(crate::route_budget::route_context_window_tokens(
11310 effective_provider,
11311 &effective_model,
11312 active_route_limits,
11313 )),
11314 token_threshold: crate::route_budget::compaction_threshold_for_route_at_percent(
11315 effective_provider,
11316 &effective_model,
11317 active_route_limits,
11318 settings.auto_compact_threshold_percent,
11319 ),
11320 ..Default::default()
11321 };
11322
11323 let network_policy = exec_network_policy(&execution_config, outer_network_access);
11324
11325 let lsp_config = (!fleet_authority_active)
11326 .then(|| {
11327 execution_config
11328 .lsp
11329 .clone()
11330 .map(crate::config::LspConfigToml::into_runtime)
11331 })
11332 .flatten();
11333 let mut engine_features = execution_config.features();
11334 apply_fleet_engine_feature_caps(
11335 &mut engine_features,
11336 fleet_authority_active,
11337 outer_network_access,
11338 outer_shell_authority,
11339 );
11340 if crate::core::allowlist_is_native_file_and_shell_only(allowed_tools.as_deref()) {
11341 engine_features.disable(crate::features::Feature::Mcp);
11342 }
11343 let engine_plugin_registry = if fleet_authority_active {
11344 std::sync::Arc::new(crate::plugins::PluginRegistry::empty(&workspace))
11345 } else {
11346 plugin_registry
11347 };
11348 let exec_allow_shell = crate::tools::spec::fleet_exec_shell_enabled(
11349 fleet_authority_active,
11350 outer_shell_authority,
11351 disallowed_tools.as_deref(),
11352 ) || (!fleet_authority_active
11353 && (auto_approve || execution_config.allow_shell()));
11354 let persist_services_enabled = cfg!(unix)
11355 && !fleet_authority_active
11356 && exec_allow_shell
11357 && explicit_sandbox
11358 .is_some_and(|sandbox| sandbox.eq_ignore_ascii_case("danger-full-access"));
11359 let exec_shell_manager = crate::tools::shell::new_shared_shell_manager(workspace.clone());
11360 let runtime_services = crate::tools::spec::RuntimeToolServices {
11361 shell_manager: Some(exec_shell_manager.clone()),
11362 persist_services_enabled,
11363 ..crate::tools::spec::RuntimeToolServices::default()
11364 };
11365
11366 let engine_config = EngineConfig {
11367 model: effective_model.clone(),
11368 active_route_limits,
11369 workspace: workspace.clone(),
11370 subagent_state_root: None,
11371 plugin_registry: Some(engine_plugin_registry),
11372 allow_shell: exec_allow_shell,
11373 trust_mode,
11374 notes_path: execution_config.notes_path(),
11375 mcp_config_path: execution_config.mcp_config_path(),
11376 skills_dir: execution_config.skills_dir(),
11377 skills_scan_codewhale_only: execution_config.skills_config().scan_codewhale_only(),
11378 instructions: {
11379 let mut instrs: Vec<crate::prompts::InstructionSource> = execution_config
11380 .instructions_paths()
11381 .into_iter()
11382 .map(Into::into)
11383 .collect();
11384 if let Some(ref extra) = append_system_prompt {
11385 instrs.push(crate::prompts::InstructionSource::Inline {
11386 name: "cli:append-system-prompt".into(),
11387 content: extra.clone(),
11388 });
11389 }
11390 instrs
11391 },
11392 project_context_pack_enabled: execution_config.project_context_pack_enabled(),
11393 translation_enabled: false,
11394 max_steps: max_turns,
11395 max_subagents,
11396 max_admitted_subagents: execution_config
11397 .max_admitted_subagents_for_provider(effective_provider)
11398 .max(max_subagents),
11399 launch_concurrency: execution_config.launch_concurrency_for_provider(effective_provider),
11400 subagents_enabled: !fleet_authority_active
11401 && execution_config.subagents_enabled_for_provider(effective_provider),
11402 features: engine_features,
11403 auto_review_policy: execution_config.auto_review_policy(),
11404 compaction: compaction.clone(),
11405 todos: new_shared_todo_list(),
11406 plan_state: new_shared_plan_state(),
11407 goal_state: crate::tools::goal::new_shared_goal_state(),
11408 max_spawn_depth: if fleet_authority_active {
11409 0
11410 } else {
11411 execution_config.subagent_max_spawn_depth_for_provider(effective_provider)
11412 },
11413 subagent_token_budget: execution_config
11414 .subagent_token_budget_for_provider(effective_provider),
11415 network_policy,
11416 snapshots_enabled: !fleet_authority_active && execution_config.snapshots_config().enabled,
11417 snapshots_max_workspace_bytes: execution_config
11418 .snapshots_config()
11419 .max_workspace_gb
11420 .saturating_mul(1024 * 1024 * 1024),
11421 lsp_config,
11422 runtime_services,
11423 subagent_model_overrides: execution_config.subagent_model_overrides(),
11424 fleet_roster: std::sync::Arc::new(crate::fleet::roster::FleetRoster::load(
11425 &execution_config.fleet_config(),
11426 &workspace,
11427 )),
11428 subagent_api_timeout: std::time::Duration::from_secs(
11429 execution_config.subagent_api_timeout_secs_for_provider(effective_provider),
11430 ),
11431 stream_chunk_timeout: std::time::Duration::from_secs(
11432 execution_config.stream_chunk_timeout_secs(),
11433 ),
11434 subagent_heartbeat_timeout: std::time::Duration::from_secs(
11435 execution_config.subagent_heartbeat_timeout_secs_for_provider(effective_provider),
11436 ),
11437 prefer_bwrap: execution_config.prefer_bwrap.unwrap_or(false),
11438 memory_enabled: execution_config.memory_enabled(),
11439 memory_path: execution_config.memory_path(),
11440 speech_output_dir: execution_config.speech_output_dir(),
11441 vision_config: execution_config.vision_model_config(),
11442 strict_tool_mode: execution_config.strict_tool_mode.unwrap_or(false),
11443 goal_objective: None,
11444 goal_token_budget: None,
11445 goal_status: crate::tools::goal::GoalStatus::Active,
11446 goal_max_continuations: execution_config.goal_max_continuations(),
11447 allowed_tools: allowed_tools.clone(),
11448 disallowed_tools: disallowed_tools.clone(),
11449 max_tool_calls: None,
11450 hook_executor: None,
11451 locale_tag: crate::localization::resolve_locale(&settings.locale)
11452 .tag()
11453 .to_string(),
11454 workshop: {
11455 crate::tools::large_output_router::WorkshopConfig::install_active(
11456 config.workshop.as_ref(),
11457 );
11458 config.workshop.clone()
11459 },
11460 search_provider: execution_config.search_provider(),
11461 search_api_key: execution_config
11462 .search
11463 .as_ref()
11464 .and_then(|s| s.api_key.clone()),
11465 search_base_url: execution_config
11466 .search
11467 .as_ref()
11468 .and_then(|s| s.base_url.clone()),
11469 tools_always_load: if fleet_authority_active {
11470 std::collections::HashSet::new()
11471 } else {
11472 execution_config.tools_always_load()
11473 },
11474 tools: if fleet_authority_active {
11475 None
11476 } else {
11477 execution_config.tools.clone()
11478 },
11479 verbosity: execution_config.verbosity.clone(),
11480 workspace_follow_symlinks: settings.workspace_follow_symlinks,
11481 exec_policy_engine: execution_config.exec_policy_engine.clone(),
11482 terminal_chrome_enabled: false,
11483 advisor_config: execution_config
11484 .advisor
11485 .as_ref()
11486 .map(crate::tools::subagent::AdvisorConfig::from_toml)
11487 .unwrap_or_else(crate::tools::subagent::AdvisorConfig::disabled),
11488 };
11489
11490 let engine_handle = spawn_engine(engine_config, &execution_config);
11491 let mode = if auto_approve {
11492 AppMode::Yolo
11493 } else {
11494 AppMode::Agent
11495 };
11496
11497 let resuming_session = resume_session.is_some();
11498 let mut loaded_session_id = None;
11499 if let Some(saved) = resume_session {
11500 let saved_id = saved.metadata.id.clone();
11501 if saved.metadata.workspace != workspace && output_format == ExecOutputFormat::Text {
11502 eprintln!(
11503 "Warning: session {} was created in a different workspace ({}). Resuming anyway.",
11504 truncate_id(&saved_id),
11505 saved.metadata.workspace.display(),
11506 );
11507 }
11508
11509 engine_handle
11510 .send(Op::SyncSession {
11511 session_id: Some(saved_id.clone()),
11512 messages: saved.messages,
11513 system_prompt: saved.system_prompt.map(SystemPrompt::Text),
11514 system_prompt_override: false,
11515 model: saved.metadata.model,
11516 workspace: saved.metadata.workspace,
11517 mode,
11518 })
11519 .await?;
11520 loaded_session_id = Some(saved_id.clone());
11521 if output_format == ExecOutputFormat::Text && !json_output {
11522 eprintln!("{}", exec_resumed_session_line(&saved_id));
11523 }
11524 }
11525
11526 engine_handle
11527 .send(Op::SendMessage {
11528 content: prompt.to_string(),
11529 mode,
11530 route: Box::new(validated_route.into_resolved()),
11531 compaction: Box::new(compaction.clone()),
11532 goal_objective: None,
11533 goal_token_budget: None,
11534 goal_status: crate::tools::goal::GoalStatus::Active,
11535 allowed_tools: allowed_tools.clone(),
11536 dynamic_tools: Vec::new(),
11537 hook_executor: None,
11538 reasoning_effort: effective_reasoning_effort,
11539 reasoning_effort_auto,
11540 auto_model,
11541 allow_shell: auto_approve || execution_config.allow_shell(),
11542 trust_mode,
11543 auto_approve,
11544 translation_enabled: false,
11545 approval_mode: if auto_approve {
11546 crate::tui::approval::ApprovalMode::Bypass
11547 } else {
11548 execution_config
11549 .approval_policy
11550 .as_deref()
11551 .and_then(crate::tui::approval::ApprovalMode::from_config_value)
11552 .unwrap_or_default()
11553 },
11554 verbosity: execution_config.verbosity.clone(),
11555 provenance: crate::core::ops::UserInputProvenance::ExternalUser,
11556 })
11557 .await?;
11558
11559 let mut summary = ExecSummary {
11560 mode: "agent".to_string(),
11561 provider: effective_provider_name.clone(),
11562 model: effective_model.clone(),
11563 prompt: prompt.to_string(),
11564 ..ExecSummary::default()
11565 };
11566 let can_elevate_sandbox =
11567 exec_sandbox_elevation_authorized(allow_sandbox_elevation, explicit_sandbox);
11568 let mut sandbox_denied = false;
11569 let mut approval_required = false;
11570 let mut tool_error_seen = false;
11571 let mut last_error_category = None;
11572 let mut reported_sandbox_contract = false;
11573
11574 let should_persist_session = resuming_session || output_format == ExecOutputFormat::StreamJson;
11575 let mut latest_session_id = loaded_session_id;
11576 let mut latest_messages: Vec<Message> = Vec::new();
11577 let mut latest_system_prompt: Option<SystemPrompt> = None;
11578 let mut latest_model = effective_model;
11579 let mut latest_workspace = workspace.clone();
11580 let mut tool_starts: HashMap<String, (Instant, String)> = HashMap::new();
11581 let mut turn_usage_seq: u32 = 0;
11582
11583 let mut stdout = io::stdout();
11584 let mut ends_with_newline = false;
11585 loop {
11586 let event = {
11587 let mut rx = engine_handle.rx_event.write().await;
11588 rx.recv().await
11589 };
11590
11591 let Some(event) = event else {
11592 break;
11593 };
11594
11595 match event {
11596 Event::MessageDelta { content, .. } => {
11597 summary.output.push_str(&content);
11598 if output_format == ExecOutputFormat::StreamJson {
11599 emit_exec_stream_event(&ExecStreamEvent::Content { content })?;
11600 } else if !json_output {
11601 print!("{content}");
11602 stdout.flush()?;
11603 }
11604 ends_with_newline = summary.output.ends_with('\n');
11605 }
11606 Event::MessageComplete { .. }
11607 if output_format == ExecOutputFormat::Text
11608 && !json_output
11609 && !ends_with_newline =>
11610 {
11611 println!();
11612 }
11613 Event::ThinkingDelta { .. } => {
11614 }
11617 Event::ToolCallStarted { id, name, input } => {
11618 let started_at = chrono::Utc::now().to_rfc3339();
11619 tool_starts.insert(id.clone(), (Instant::now(), started_at.clone()));
11620 if output_format == ExecOutputFormat::StreamJson {
11621 emit_exec_stream_event(&ExecStreamEvent::ToolUse {
11622 name,
11623 id,
11624 input,
11625 started_at,
11626 })?;
11627 } else if !json_output {
11628 let summary = summarize_tool_args(&input);
11629 if let Some(summary) = summary {
11630 eprintln!("tool: {name} ({summary})");
11631 } else {
11632 eprintln!("tool: {name}");
11633 }
11634 }
11635 }
11636 Event::ToolCallComplete {
11637 id, name, result, ..
11638 } => {
11639 let (duration_ms, started_at) = tool_starts
11640 .remove(&id)
11641 .map(|(started, timestamp)| {
11642 (
11643 u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
11644 timestamp,
11645 )
11646 })
11647 .unwrap_or_else(|| (0, chrono::Utc::now().to_rfc3339()));
11648 let receipt_name = name.clone();
11649 match result {
11650 Ok(output) => {
11651 tool_error_seen |= !output.success;
11652 summary.tools.push(ExecToolEntry {
11653 name: name.clone(),
11654 success: output.success,
11655 output: output.content.clone(),
11656 });
11657 if output_format == ExecOutputFormat::StreamJson {
11658 emit_exec_stream_event(&ExecStreamEvent::ToolResult {
11659 id,
11660 name: receipt_name,
11661 output: output.content,
11662 status: if output.success {
11663 "success".to_string()
11664 } else {
11665 "error".to_string()
11666 },
11667 started_at,
11668 completed_at: chrono::Utc::now().to_rfc3339(),
11669 duration_ms,
11670 side_effect_status: output
11671 .metadata
11672 .as_ref()
11673 .and_then(|metadata| metadata.get("side_effect_status"))
11674 .and_then(serde_json::Value::as_str)
11675 .unwrap_or("unknown")
11676 .to_string(),
11677 error_category: (!output.success).then(|| {
11678 output
11679 .metadata
11680 .as_ref()
11681 .and_then(|metadata| metadata.get("error_category"))
11682 .and_then(serde_json::Value::as_str)
11683 .unwrap_or("tool_reported_failure")
11684 .to_string()
11685 }),
11686 truncated: output
11687 .metadata
11688 .as_ref()
11689 .and_then(|metadata| metadata.get("truncated"))
11690 .and_then(serde_json::Value::as_bool),
11691 artifact: tool_artifact_receipt(output.metadata.as_ref()),
11692 result_metadata: output.metadata,
11693 })?;
11694 } else if !json_output {
11695 if name == "exec_shell" && !output.content.trim().is_empty() {
11696 eprintln!("tool {name} completed");
11697 eprintln!(
11698 "--- stdout/stderr ---\n{}\n---------------------",
11699 output.content
11700 );
11701 } else {
11702 eprintln!(
11703 "tool {name} completed: {}",
11704 summarize_tool_output(&output.content)
11705 );
11706 }
11707 }
11708 }
11709 Err(err) => {
11710 tool_error_seen = true;
11711 let error_text = err.to_string();
11712 summary.tools.push(ExecToolEntry {
11713 name: name.clone(),
11714 success: false,
11715 output: error_text.clone(),
11716 });
11717 if output_format == ExecOutputFormat::StreamJson {
11718 emit_exec_stream_event(&ExecStreamEvent::ToolResult {
11719 id,
11720 name: receipt_name,
11721 output: error_text,
11722 status: "error".to_string(),
11723 started_at,
11724 completed_at: chrono::Utc::now().to_rfc3339(),
11725 duration_ms,
11726 side_effect_status: "not_started_or_unknown".to_string(),
11727 error_category: Some(tool_error_receipt_category(&err).to_string()),
11728 truncated: None,
11729 artifact: None,
11730 result_metadata: None,
11731 })?;
11732 } else if !json_output {
11733 eprintln!("tool {name} failed: {err}");
11734 }
11735 }
11736 }
11737 }
11738 Event::AgentSpawned { id, prompt, .. }
11739 if output_format == ExecOutputFormat::Text && !json_output =>
11740 {
11741 eprintln!("sub-agent {id} spawned: {}", summarize_tool_output(&prompt));
11742 }
11743 Event::AgentProgress { id, status, .. }
11744 if output_format == ExecOutputFormat::Text && !json_output =>
11745 {
11746 eprintln!("sub-agent {id}: {status}");
11747 }
11748 Event::AgentComplete { id, result }
11749 if output_format == ExecOutputFormat::Text && !json_output =>
11750 {
11751 eprintln!(
11752 "sub-agent {id} completed: {}",
11753 summarize_tool_output(&result)
11754 );
11755 }
11756 Event::AgentSpawned {
11757 id,
11758 parent_run_id,
11759 spawn_depth,
11760 model,
11761 route_source,
11762 ..
11763 } if output_format == ExecOutputFormat::StreamJson => {
11764 emit_exec_stream_event(&ExecStreamEvent::AgentSpawned {
11765 id,
11766 model,
11767 spawn_depth,
11768 parent_run_id,
11769 route_source,
11770 })?;
11771 }
11772 Event::AgentSpawned { .. }
11773 | Event::AgentProgress { .. }
11774 | Event::AgentComplete { .. } => {}
11775 Event::WorkflowUi { run_id, event }
11776 if output_format == ExecOutputFormat::StreamJson =>
11777 {
11778 emit_exec_stream_event(&ExecStreamEvent::WorkflowEvent { run_id, event })?;
11779 }
11780 Event::ApprovalRequired { id, .. } => {
11781 if auto_approve {
11782 let _ = engine_handle.approve_tool_call(id).await;
11783 } else {
11784 approval_required = true;
11785 let _ = engine_handle.deny_tool_call(id).await;
11786 }
11787 }
11788 Event::ElevationRequired {
11789 tool_id,
11790 tool_name,
11791 denial_reason,
11792 ..
11793 } => {
11794 if can_elevate_sandbox {
11795 let policy = crate::sandbox::SandboxPolicy::DangerFullAccess;
11796 let _ = engine_handle.retry_tool_with_policy(tool_id, policy).await;
11797 } else {
11798 sandbox_denied = true;
11799 approval_required = true;
11800 summary.outcomes.push(ExecOutcome {
11801 kind: "sandbox_denied".to_string(),
11802 outcome: "approval_required".to_string(),
11803 tool_name: tool_name.clone(),
11804 reason: denial_reason.clone(),
11805 });
11806 if !reported_sandbox_contract {
11807 eprintln!(
11808 "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"
11809 );
11810 reported_sandbox_contract = true;
11811 }
11812 if output_format == ExecOutputFormat::StreamJson {
11813 emit_exec_stream_event(&ExecStreamEvent::SandboxDenied {
11814 tool_id: tool_id.clone(),
11815 tool_name,
11816 reason: denial_reason,
11817 outcome: "approval_required".to_string(),
11818 })?;
11819 }
11820 let _ = engine_handle.deny_tool_call(tool_id).await;
11821 }
11822 }
11823 Event::Error {
11824 envelope,
11825 recoverable: _,
11826 } => {
11827 if exec_error_event_is_fatal(&envelope) {
11835 last_error_category = Some(envelope.category);
11836 summary.error_category = Some(envelope.category.to_string());
11837 summary.error = Some(envelope.message.clone());
11838 }
11839 if output_format == ExecOutputFormat::StreamJson {
11840 emit_exec_stream_event(&ExecStreamEvent::Error {
11841 error: envelope.message,
11842 })?;
11843 } else if !json_output {
11844 eprintln!("error: {}", envelope.message);
11845 }
11846 }
11847 Event::TurnUsage {
11848 usage, duration_ms, ..
11849 } => {
11850 if output_format == ExecOutputFormat::StreamJson {
11851 turn_usage_seq = turn_usage_seq.saturating_add(1);
11852 emit_exec_stream_event(&ExecStreamEvent::TurnUsage {
11853 turn: turn_usage_seq,
11854 input_tokens: usage.input_tokens,
11855 output_tokens: usage.output_tokens,
11856 reasoning_tokens: usage.reasoning_tokens,
11857 prompt_cache_hit_tokens: usage.prompt_cache_hit_tokens,
11858 prompt_cache_miss_tokens: usage.prompt_cache_miss_tokens,
11859 prompt_cache_write_tokens: usage.prompt_cache_write_tokens,
11860 reasoning_replay_tokens: usage.reasoning_replay_tokens,
11861 duration_ms,
11862 })?;
11863 }
11864 }
11865 Event::TurnComplete {
11866 status,
11867 error,
11868 usage,
11869 tool_catalog,
11870 ..
11871 } => {
11872 let (terminal_status, terminal_error) = (status, error);
11873 #[cfg(unix)]
11874 let (mut terminal_status, mut terminal_error) = (terminal_status, terminal_error);
11875 if matches!(
11876 terminal_status,
11877 crate::core::events::TurnOutcomeStatus::Completed
11878 ) && terminal_error.is_none()
11879 {
11880 #[cfg(unix)]
11881 match exec_shell_manager.lock() {
11882 Ok(mut manager) => match manager.commit_persistent_services() {
11883 Ok(receipts) => {
11884 for receipt in &receipts {
11885 if output_format == ExecOutputFormat::StreamJson {
11886 emit_exec_stream_event(
11887 &ExecStreamEvent::ServiceReleased {
11888 task_id: receipt.task_id.clone(),
11889 pid: receipt.pid,
11890 process_group_id: receipt.process_group_id,
11891 ownership: receipt.ownership.clone(),
11892 },
11893 )?;
11894 } else if !json_output {
11895 eprintln!(
11896 "persistent service released: {} pid={} pgid={} ownership={}",
11897 receipt.task_id,
11898 receipt.pid,
11899 receipt.process_group_id,
11900 receipt.ownership
11901 );
11902 }
11903 }
11904 summary.released_services.extend(receipts);
11905 }
11906 Err(error) => {
11907 manager.abort_persistent_services();
11908 terminal_status = crate::core::events::TurnOutcomeStatus::Failed;
11909 terminal_error = Some(format!(
11910 "Persistent service ownership transfer failed: {error}"
11911 ));
11912 }
11913 },
11914 Err(_) => {
11915 terminal_status = crate::core::events::TurnOutcomeStatus::Failed;
11916 terminal_error = Some(
11917 "Persistent service ownership transfer failed: shell manager lock poisoned"
11918 .to_string(),
11919 );
11920 }
11921 }
11922 } else if let Ok(mut manager) = exec_shell_manager.lock() {
11923 manager.abort_persistent_services();
11924 }
11925 summary.status = Some(format!("{terminal_status:?}").to_lowercase());
11926 if terminal_error.is_some() {
11927 summary.error = terminal_error;
11928 }
11929 if sandbox_denied
11930 && summary.error.is_none()
11931 && matches!(
11932 terminal_status,
11933 crate::core::events::TurnOutcomeStatus::Failed
11934 )
11935 {
11936 summary.error = Some(
11937 "exec turn failed after sandbox denial; explicit sandbox elevation was not authorized"
11938 .to_string(),
11939 );
11940 }
11941 if last_error_category.is_none() {
11942 last_error_category = summary
11943 .error
11944 .as_deref()
11945 .map(crate::error_taxonomy::classify_error_message);
11946 summary.error_category =
11947 last_error_category.map(|category| category.to_string());
11948 }
11949 let termination_reason = crate::core::termination::classify_turn_termination(
11950 terminal_status,
11951 last_error_category,
11952 tool_error_seen,
11953 approval_required,
11954 );
11955 summary.termination_reason = Some(termination_reason.as_str().to_string());
11956 if !termination_reason.is_success() {
11962 codewhale_telemetry::set_exit_class(codewhale_telemetry::ExitClass::Error);
11963 }
11964 let saved_session_id = if should_persist_session && !latest_messages.is_empty() {
11965 match persist_exec_session(
11966 &latest_messages,
11967 &latest_model,
11968 PersistedProviderRoute {
11969 kind: effective_provider.as_str(),
11970 id: effective_provider_id.as_deref(),
11971 },
11972 &latest_workspace,
11973 &latest_system_prompt,
11974 latest_session_id.as_deref(),
11975 u64::from(usage.input_tokens) + u64::from(usage.output_tokens),
11976 ) {
11977 Ok(id) => {
11978 if output_format == ExecOutputFormat::Text && !json_output {
11979 eprintln!("{}", exec_saved_session_line(&id));
11980 }
11981 Some(id)
11982 }
11983 Err(err) => {
11984 if output_format == ExecOutputFormat::Text && !json_output {
11985 eprintln!("warning: failed to save exec session: {err}");
11986 }
11987 latest_session_id.clone()
11988 }
11989 }
11990 } else {
11991 latest_session_id.clone()
11992 };
11993 if output_format == ExecOutputFormat::StreamJson {
11994 if let Some(id) = saved_session_id.as_ref() {
11995 emit_exec_stream_event(&ExecStreamEvent::SessionCapture {
11996 content: exec_stream_session_ref(id),
11997 })?;
11998 }
11999 let codewhale_max_output_tokens =
12003 crate::route_budget::effective_max_output_tokens_for_route(
12004 effective_provider,
12005 &latest_model,
12006 active_route_limits,
12007 );
12008 let codewhale_max_output_tokens_source =
12009 crate::route_budget::output_ceiling_source(
12010 effective_provider,
12011 &latest_model,
12012 )
12013 .as_str();
12014 emit_exec_stream_event(&ExecStreamEvent::Metadata {
12015 meta: Box::new(ExecStreamMeta {
12016 receipt_kind: "terminal",
12017 provider: effective_provider_kind.clone(),
12018 provider_id: effective_stream_provider_id.clone(),
12019 model: latest_model.clone(),
12020 route_source: route_source.clone(),
12021 input_tokens: Some(usage.input_tokens),
12022 output_tokens: Some(usage.output_tokens),
12023 prompt_cache_hit_tokens: usage.prompt_cache_hit_tokens,
12024 prompt_cache_miss_tokens: usage.prompt_cache_miss_tokens,
12025 prompt_cache_write_tokens: usage.prompt_cache_write_tokens,
12026 reasoning_tokens: usage.reasoning_tokens,
12027 codewhale_max_output_tokens: Some(codewhale_max_output_tokens),
12028 codewhale_max_output_tokens_source: Some(
12029 codewhale_max_output_tokens_source,
12030 ),
12031 duration_ms: u64::try_from(exec_started.elapsed().as_millis())
12032 .unwrap_or(u64::MAX),
12033 retry_count: None,
12034 approval_posture: approval_posture.clone(),
12035 sandbox_posture: sandbox_posture.clone(),
12036 binary_sha256: binary_sha256.clone(),
12037 config_sha256: None,
12038 prompt_sha256: prompt_sha256.clone(),
12039 tool_catalog_sha256: tool_catalog.as_ref().and_then(|catalog| {
12040 serde_json::to_vec(catalog).ok().map(|bytes| {
12041 format!("sha256:{}", crate::hashing::sha256_hex(&bytes))
12042 })
12043 }),
12044 input_analysis: exec_stream_input_analysis(
12045 &latest_messages,
12046 latest_system_prompt.as_ref(),
12047 ),
12048 visible_final_answer_chars: summary.output.chars().count(),
12049 resume_command: saved_session_id
12050 .as_deref()
12051 .map(exec_stream_resume_hint)
12052 .unwrap_or_default(),
12053 session_id: saved_session_id
12054 .as_deref()
12055 .map(exec_stream_session_ref)
12056 .unwrap_or_default(),
12057 workspace: latest_workspace.display().to_string(),
12058 message_count: latest_messages.len(),
12059 status: summary.status.clone(),
12060 termination_reason: summary.termination_reason.clone(),
12061 error_category: summary.error_category.clone(),
12062 error: summary.error.clone(),
12063 }),
12064 })?;
12065 emit_exec_stream_event(&ExecStreamEvent::Done)?;
12066 }
12067 let _ = engine_handle.send(Op::Shutdown).await;
12068 break;
12069 }
12070 Event::SessionUpdated {
12071 session_id,
12072 messages,
12073 system_prompt,
12074 model,
12075 workspace,
12076 } => {
12077 latest_session_id = Some(session_id);
12078 latest_messages = messages;
12079 latest_system_prompt = system_prompt;
12080 latest_model = model;
12081 latest_workspace = workspace;
12082 }
12083 Event::Status { message }
12086 if output_format == ExecOutputFormat::Text
12087 && !json_output
12088 && message.contains("Maximum model steps") =>
12089 {
12090 eprintln!("{message}");
12091 }
12092 _ => {}
12093 }
12094 }
12095
12096 if summary.status.is_none() {
12097 if let Ok(mut manager) = exec_shell_manager.lock() {
12098 manager.abort_persistent_services();
12099 }
12100 let error = summary.error.clone().unwrap_or_else(|| {
12101 "Engine event channel closed before a terminal turn receipt".to_string()
12102 });
12103 let category = last_error_category
12104 .unwrap_or_else(|| crate::error_taxonomy::classify_error_message(&error));
12105 let termination_reason = crate::core::termination::classify_turn_termination(
12106 crate::core::events::TurnOutcomeStatus::Failed,
12107 Some(category),
12108 tool_error_seen,
12109 approval_required,
12110 );
12111 summary.status = Some("failed".to_string());
12112 summary.error_category = Some(category.to_string());
12113 summary.termination_reason = Some(termination_reason.as_str().to_string());
12114 summary.error = Some(error.clone());
12115 if output_format == ExecOutputFormat::StreamJson {
12116 emit_exec_stream_event(&ExecStreamEvent::Error { error })?;
12117 }
12118 }
12119
12120 if json_output {
12121 println!("{}", serde_json::to_string_pretty(&summary)?);
12122 }
12123
12124 if let Some(error) = summary.error.as_ref()
12125 && !error.trim().is_empty()
12126 {
12127 let exit_code = exec_failure_exit_code(summary.error_category.as_deref());
12133 if exit_code != 1 {
12134 eprintln!("Error: exec turn failed: {error}");
12135 let _ = io::stdout().flush();
12136 std::process::exit(exit_code);
12137 }
12138 bail!("exec turn failed: {error}");
12139 }
12140
12141 if matches!(
12142 summary.status.as_deref(),
12143 Some("failed" | "canceled" | "interrupted")
12144 ) {
12145 let status = summary.status.as_deref().unwrap_or("unknown");
12146 bail!("exec turn ended with status {status}");
12147 }
12148
12149 Ok(())
12150}
12151
12152#[cfg(test)]
12153mod serve_bind_host_tests {
12154 use super::*;
12155
12156 #[test]
12157 fn http_defaults_to_loopback() {
12158 assert_eq!(
12159 resolve_serve_bind_host(false, None),
12160 ServeBindHost {
12161 host: "127.0.0.1".to_string(),
12162 mobile_rebound_to_lan: false,
12163 }
12164 );
12165 }
12166
12167 #[test]
12168 fn mobile_default_rebinds_to_lan_with_warning_flag() {
12169 assert_eq!(
12170 resolve_serve_bind_host(true, None),
12171 ServeBindHost {
12172 host: "0.0.0.0".to_string(),
12173 mobile_rebound_to_lan: true,
12174 }
12175 );
12176 }
12177
12178 #[test]
12179 fn mobile_respects_explicit_loopback_host() {
12180 assert_eq!(
12181 resolve_serve_bind_host(true, Some("127.0.0.1".to_string())),
12182 ServeBindHost {
12183 host: "127.0.0.1".to_string(),
12184 mobile_rebound_to_lan: false,
12185 }
12186 );
12187 }
12188
12189 #[test]
12190 fn http_and_mobile_are_mutually_exclusive() {
12191 let err = validate_serve_mode_selection(false, true, true, false, false).unwrap_err();
12192 assert!(
12193 err.to_string()
12194 .contains("--http and --mobile are mutually exclusive")
12195 );
12196 }
12197
12198 #[test]
12199 fn web_is_a_distinct_loopback_runtime_mode() {
12200 assert!(validate_serve_mode_selection(false, false, false, true, false).unwrap());
12201 let err = validate_serve_mode_selection(false, true, false, true, false).unwrap_err();
12202 assert!(err.to_string().contains("--web is mutually exclusive"));
12203 assert_eq!(
12204 resolve_serve_bind_host(false, None),
12205 ServeBindHost {
12206 host: "127.0.0.1".to_string(),
12207 mobile_rebound_to_lan: false,
12208 }
12209 );
12210 }
12211}
12212
12213#[cfg(test)]
12214#[path = "tests/exec_exit_semantics.rs"]
12215mod exec_exit_semantics_tests;
12216#[cfg(test)]
12217mod doctor_legacy_state_tests {
12218 use super::*;
12219 use std::env;
12220 use std::ffi::OsString;
12221 use std::fs;
12222 use tempfile::TempDir;
12223
12224 struct EnvVarRestore {
12225 key: &'static str,
12226 previous: Option<OsString>,
12227 }
12228
12229 impl EnvVarRestore {
12230 fn set(key: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
12231 let previous = env::var_os(key);
12232 unsafe {
12233 env::set_var(key, value);
12234 }
12235 Self { key, previous }
12236 }
12237 }
12238
12239 impl Drop for EnvVarRestore {
12240 fn drop(&mut self) {
12241 unsafe {
12242 match &self.previous {
12243 Some(value) => env::set_var(self.key, value),
12244 None => env::remove_var(self.key),
12245 }
12246 }
12247 }
12248 }
12249
12250 fn roots(tmp: &TempDir) -> (PathBuf, PathBuf) {
12251 (tmp.path().join(".codewhale"), tmp.path().join(".deepseek"))
12252 }
12253
12254 fn entry<'a>(report: &'a [DoctorLegacyStateEntry], name: &str) -> &'a DoctorLegacyStateEntry {
12255 report
12256 .iter()
12257 .find(|entry| entry.name == name)
12258 .expect("legacy state entry should exist")
12259 }
12260
12261 #[test]
12262 fn doctor_legacy_state_report_marks_unmigrated_legacy_entries() {
12263 let tmp = TempDir::new().expect("tempdir");
12264 let (primary_root, legacy_root) = roots(&tmp);
12265 fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
12266 fs::create_dir_all(legacy_root.join("tasks")).expect("legacy tasks");
12267 fs::create_dir_all(&primary_root).expect("primary root");
12268 fs::write(legacy_root.join("config.toml"), "api_key = 'old'").expect("legacy config");
12269
12270 let report = doctor_legacy_state_report(&primary_root, &legacy_root);
12271 let session_recovery = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12272
12273 assert_eq!(
12274 entry(&report, "sessions").status,
12275 DoctorLegacyStateStatus::LegacyOnly
12276 );
12277 assert_eq!(
12278 entry(&report, "config.toml").status,
12279 DoctorLegacyStateStatus::LegacyOnly
12280 );
12281 assert_eq!(
12282 entry(&report, "skills").status,
12283 DoctorLegacyStateStatus::Absent
12284 );
12285
12286 let json =
12287 doctor_legacy_state_json(&primary_root, &legacy_root, &report, &session_recovery);
12288 assert_eq!(json["needs_attention"], true);
12289 assert_eq!(json["legacy_only_count"], 3);
12290 assert_eq!(json["dual_present_count"], 0);
12291 }
12292
12293 #[test]
12294 fn doctor_legacy_state_report_marks_dual_present_entries() {
12295 let tmp = TempDir::new().expect("tempdir");
12296 let (primary_root, legacy_root) = roots(&tmp);
12297 fs::create_dir_all(primary_root.join("sessions")).expect("primary sessions");
12298 fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
12299 fs::write(primary_root.join("mcp.json"), "{}").expect("primary mcp");
12300 fs::write(legacy_root.join("mcp.json"), "{}").expect("legacy mcp");
12301
12302 let report = doctor_legacy_state_report(&primary_root, &legacy_root);
12303 let session_recovery = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12304
12305 assert_eq!(
12306 entry(&report, "sessions").status,
12307 DoctorLegacyStateStatus::Both
12308 );
12309 assert_eq!(
12310 entry(&report, "mcp.json").status,
12311 DoctorLegacyStateStatus::Both
12312 );
12313
12314 let json =
12315 doctor_legacy_state_json(&primary_root, &legacy_root, &report, &session_recovery);
12316 assert_eq!(json["needs_attention"], true);
12317 assert_eq!(json["legacy_only_count"], 0);
12318 assert_eq!(json["dual_present_count"], 2);
12319 }
12320
12321 #[test]
12322 fn doctor_legacy_state_report_is_clear_when_only_primary_exists() {
12323 let tmp = TempDir::new().expect("tempdir");
12324 let (primary_root, legacy_root) = roots(&tmp);
12325 fs::create_dir_all(primary_root.join("sessions")).expect("primary sessions");
12326 fs::write(primary_root.join("settings.toml"), "default_mode = 'ask'")
12327 .expect("primary settings");
12328
12329 let report = doctor_legacy_state_report(&primary_root, &legacy_root);
12330 let session_recovery = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12331
12332 assert_eq!(
12333 entry(&report, "sessions").status,
12334 DoctorLegacyStateStatus::PrimaryOnly
12335 );
12336 assert!(!report.iter().any(legacy_state_needs_attention));
12337
12338 let json =
12339 doctor_legacy_state_json(&primary_root, &legacy_root, &report, &session_recovery);
12340 assert_eq!(json["needs_attention"], false);
12341 assert_eq!(json["legacy_only_count"], 0);
12342 assert_eq!(json["dual_present_count"], 0);
12343 }
12344
12345 #[test]
12346 fn doctor_legacy_state_report_is_clear_when_neither_root_exists() {
12347 let tmp = TempDir::new().expect("tempdir");
12348 let (primary_root, legacy_root) = roots(&tmp);
12349
12350 let report = doctor_legacy_state_report(&primary_root, &legacy_root);
12351 let session_recovery = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12352
12353 assert!(
12354 report
12355 .iter()
12356 .all(|entry| entry.status == DoctorLegacyStateStatus::Absent)
12357 );
12358 assert!(!report.iter().any(legacy_state_needs_attention));
12359
12360 let json =
12361 doctor_legacy_state_json(&primary_root, &legacy_root, &report, &session_recovery);
12362 assert_eq!(json["needs_attention"], false);
12363 assert_eq!(json["legacy_only_count"], 0);
12364 assert_eq!(json["dual_present_count"], 0);
12365 }
12366
12367 #[test]
12368 fn doctor_reports_incomplete_session_migration_without_mutating_files() {
12369 let tmp = TempDir::new().expect("tempdir");
12370 let (primary_root, legacy_root) = roots(&tmp);
12371 let primary_sessions = primary_root.join("sessions");
12372 let legacy_sessions = legacy_root.join("sessions");
12373 fs::create_dir_all(&primary_sessions).expect("primary sessions");
12374 fs::create_dir_all(legacy_sessions.join("checkpoints")).expect("legacy checkpoints");
12375 fs::write(primary_sessions.join("already-there.json"), b"primary")
12376 .expect("primary session");
12377 fs::write(legacy_sessions.join("already-there.json"), b"legacy")
12378 .expect("legacy matching session");
12379 fs::write(
12380 legacy_sessions.join("recover-me.json"),
12381 b"not parsed by doctor",
12382 )
12383 .expect("legacy recoverable session");
12384 fs::write(
12385 legacy_sessions.join("checkpoints").join("latest.json"),
12386 b"checkpoint not inspected",
12387 )
12388 .expect("legacy checkpoint");
12389
12390 let legacy_before = fs::read(legacy_sessions.join("recover-me.json"))
12391 .expect("read legacy fixture before diagnostic");
12392 let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12393
12394 assert_eq!(
12395 report.status,
12396 DoctorSessionRecoveryStatus::MigrationIncomplete
12397 );
12398 assert_eq!(report.legacy_session_file_count, 2);
12399 assert_eq!(report.already_present_file_count, 1);
12400 assert_eq!(report.recoverable_file_count, 1);
12401 assert_eq!(report.recoverable.len(), 1);
12402 assert_eq!(report.recoverable[0].name, PathBuf::from("recover-me.json"));
12403 assert!(
12404 !primary_sessions.join("recover-me.json").exists(),
12405 "doctor must not copy a recoverable session"
12406 );
12407 assert_eq!(
12408 fs::read(legacy_sessions.join("recover-me.json"))
12409 .expect("legacy file remains after diagnostic"),
12410 legacy_before,
12411 "doctor must not rewrite or delete the legacy source"
12412 );
12413
12414 let json = doctor_session_recovery_json(&report);
12415 assert_eq!(json["needs_attention"], true);
12416 assert_eq!(json["read_only"], true);
12417 assert_eq!(json["chat_contents_read"], false);
12418 assert_eq!(json["checkpoint_internals_scanned"], false);
12419 assert_eq!(json["recoverable_file_count"], 1);
12420 assert_eq!(json["recovery_command"], "codewhale sessions");
12421 assert_eq!(json["recoverable_files"][0]["name"], "recover-me.json");
12422 let serialized = json.to_string();
12423 assert!(
12424 !serialized.contains("not parsed by doctor"),
12425 "the report must not expose session contents"
12426 );
12427 assert!(
12428 !serialized.contains("checkpoint not inspected"),
12429 "the report must not expose checkpoint contents"
12430 );
12431 }
12432
12433 #[test]
12434 fn doctor_treats_preserved_legacy_sessions_as_complete_by_filename() {
12435 let tmp = TempDir::new().expect("tempdir");
12436 let (primary_root, legacy_root) = roots(&tmp);
12437 let primary_sessions = primary_root.join("sessions");
12438 let legacy_sessions = legacy_root.join("sessions");
12439 fs::create_dir_all(&primary_sessions).expect("primary sessions");
12440 fs::create_dir_all(&legacy_sessions).expect("legacy sessions");
12441 fs::write(primary_sessions.join("same-name.json"), b"primary").expect("primary session");
12442 fs::write(legacy_sessions.join("same-name.json"), b"legacy").expect("legacy session");
12443
12444 let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12445
12446 assert_eq!(
12447 report.status,
12448 DoctorSessionRecoveryStatus::MigrationComplete
12449 );
12450 assert!(!report.needs_attention());
12451 assert_eq!(report.recoverable_file_count, 0);
12452 assert!(report.recoverable.is_empty());
12453 assert_eq!(report.already_present_file_count, 1);
12454 let json = doctor_session_recovery_json(&report);
12455 assert_eq!(json["session_descriptors_compared"], false);
12456 assert_eq!(
12457 json["counterpart_check"],
12458 "top_level_filename_and_regular_file_only"
12459 );
12460 }
12461
12462 #[test]
12463 fn doctor_bounds_recoverable_session_filename_samples() {
12464 let tmp = TempDir::new().expect("tempdir");
12465 let (primary_root, legacy_root) = roots(&tmp);
12466 let legacy_sessions = legacy_root.join("sessions");
12467 fs::create_dir_all(&legacy_sessions).expect("legacy sessions");
12468 for index in 0..DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT {
12469 fs::write(
12470 legacy_sessions.join(format!("late-{index:03}.json")),
12471 b"fixture",
12472 )
12473 .expect("legacy session fixture");
12474 }
12475 fs::write(legacy_sessions.join("early-000.json"), b"fixture")
12476 .expect("earliest legacy session fixture");
12477 fs::write(legacy_sessions.join("early-001.json"), b"fixture")
12478 .expect("second earliest legacy session fixture");
12479 let total = DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT + 2;
12480
12481 let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12482 let json = doctor_session_recovery_json(&report);
12483
12484 assert_eq!(report.recoverable_file_count, total);
12485 assert_eq!(
12486 report.recoverable.len(),
12487 DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT
12488 );
12489 assert_eq!(
12490 json["recoverable_files"].as_array().map(Vec::len),
12491 Some(DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT)
12492 );
12493 assert_eq!(
12494 report.recoverable.first().map(|entry| entry.name.as_path()),
12495 Some(Path::new("early-000.json")),
12496 "the bounded sample must not depend on read_dir order"
12497 );
12498 assert_eq!(
12499 report.recoverable.last().map(|entry| entry.name.as_path()),
12500 Some(Path::new("late-097.json")),
12501 "the bounded sample must retain the lexical prefix"
12502 );
12503 assert_eq!(json["recoverable_files_truncated"], true);
12504 }
12505
12506 #[test]
12507 fn doctor_session_recovery_fails_closed_on_an_unreadable_path_shape() {
12508 let tmp = TempDir::new().expect("tempdir");
12509 let (primary_root, legacy_root) = roots(&tmp);
12510 fs::create_dir_all(&legacy_root).expect("legacy root");
12511 fs::write(legacy_root.join("sessions"), b"not a directory")
12512 .expect("invalid legacy sessions path");
12513
12514 let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12515
12516 assert_eq!(report.status, DoctorSessionRecoveryStatus::ScanFailed);
12517 assert!(report.needs_attention());
12518 assert!(report.error.as_deref().is_some_and(|error| {
12519 error.contains("legacy sessions root") && error.contains("not a directory")
12520 }));
12521 }
12522
12523 #[test]
12524 fn doctor_session_recovery_rejects_a_non_directory_legacy_state_root() {
12525 let tmp = TempDir::new().expect("tempdir");
12526 let (primary_root, legacy_root) = roots(&tmp);
12527 fs::write(&legacy_root, b"not a state directory").expect("invalid legacy root");
12528
12529 let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12530
12531 assert_eq!(report.status, DoctorSessionRecoveryStatus::ScanFailed);
12532 assert!(report.error.as_deref().is_some_and(|error| {
12533 error.contains("legacy state root") && error.contains("not a directory")
12534 }));
12535 }
12536
12537 #[test]
12538 fn doctor_session_recovery_rejects_a_non_directory_primary_state_root() {
12539 let tmp = TempDir::new().expect("tempdir");
12540 let (primary_root, legacy_root) = roots(&tmp);
12541 fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
12542 fs::write(&primary_root, b"not a state directory").expect("invalid primary root");
12543
12544 let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12545
12546 assert_eq!(report.status, DoctorSessionRecoveryStatus::ScanFailed);
12547 assert!(report.error.as_deref().is_some_and(|error| {
12548 error.contains("primary state root") && error.contains("not a directory")
12549 }));
12550 }
12551
12552 #[test]
12553 fn doctor_session_recovery_rejects_a_non_directory_primary_sessions_root() {
12554 let tmp = TempDir::new().expect("tempdir");
12555 let (primary_root, legacy_root) = roots(&tmp);
12556 fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
12557 fs::create_dir_all(&primary_root).expect("primary root");
12558 fs::write(primary_root.join("sessions"), b"not a sessions directory")
12559 .expect("invalid primary sessions path");
12560
12561 let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12562
12563 assert_eq!(report.status, DoctorSessionRecoveryStatus::ScanFailed);
12564 assert!(report.error.as_deref().is_some_and(|error| {
12565 error.contains("primary sessions root") && error.contains("not a directory")
12566 }));
12567 }
12568
12569 #[cfg(unix)]
12570 #[test]
12571 fn doctor_session_recovery_rejects_a_symlinked_legacy_sessions_root() {
12572 use std::os::unix::fs::symlink;
12573
12574 let tmp = TempDir::new().expect("tempdir");
12575 let (primary_root, legacy_root) = roots(&tmp);
12576 let external_sessions = tmp.path().join("external-sessions");
12577 fs::create_dir_all(&external_sessions).expect("external sessions");
12578 fs::write(
12579 external_sessions.join("must-not-be-enumerated.json"),
12580 b"session contents must stay unread",
12581 )
12582 .expect("external session fixture");
12583 fs::create_dir_all(&legacy_root).expect("legacy root");
12584 symlink(&external_sessions, legacy_root.join("sessions"))
12585 .expect("symlinked legacy sessions root");
12586
12587 let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12588
12589 assert_eq!(report.status, DoctorSessionRecoveryStatus::ScanFailed);
12590 assert!(report.needs_attention());
12591 assert_eq!(report.legacy_session_file_count, 0);
12592 assert!(report.recoverable.is_empty());
12593 assert!(
12594 report
12595 .error
12596 .as_deref()
12597 .is_some_and(|error| error.contains("legacy sessions root")
12598 && error.contains("path is a symlink"))
12599 );
12600 }
12601
12602 #[cfg(unix)]
12603 #[test]
12604 fn doctor_session_recovery_rejects_symlinked_primary_root_and_sessions_root() {
12605 use std::os::unix::fs::symlink;
12606
12607 let tmp = TempDir::new().expect("tempdir");
12608 let (primary_root, legacy_root) = roots(&tmp);
12609 let external_primary = tmp.path().join("external-primary");
12610 fs::create_dir_all(external_primary.join("sessions")).expect("external primary");
12611 fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
12612 symlink(&external_primary, &primary_root).expect("symlinked primary root");
12613
12614 let root_report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12615 assert_eq!(root_report.status, DoctorSessionRecoveryStatus::ScanFailed);
12616 assert!(root_report.error.as_deref().is_some_and(|error| {
12617 error.contains("primary state root") && error.contains("path is a symlink")
12618 }));
12619
12620 fs::remove_file(&primary_root).expect("remove primary root symlink");
12621 fs::create_dir_all(&primary_root).expect("primary root");
12622 symlink(&external_primary, primary_root.join("sessions"))
12623 .expect("symlinked primary sessions root");
12624
12625 let sessions_report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12626 assert_eq!(
12627 sessions_report.status,
12628 DoctorSessionRecoveryStatus::ScanFailed
12629 );
12630 assert!(sessions_report.error.as_deref().is_some_and(|error| {
12631 error.contains("primary sessions root") && error.contains("path is a symlink")
12632 }));
12633 }
12634
12635 #[test]
12636 fn explicit_codewhale_home_skips_session_recovery_scan() {
12637 let tmp = TempDir::new().expect("tempdir");
12638 let (primary_root, legacy_root) = roots(&tmp);
12639 fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
12640 fs::write(legacy_root.join("sessions").join("ambient.json"), b"legacy")
12641 .expect("legacy session");
12642
12643 let report = doctor_session_recovery_report(&primary_root, &legacy_root, true);
12644
12645 assert_eq!(report.status, DoctorSessionRecoveryStatus::Isolated);
12646 assert!(report.codewhale_home_is_explicit);
12647 assert_eq!(report.legacy_session_file_count, 0);
12648 assert_eq!(report.recoverable_file_count, 0);
12649 assert!(report.recoverable.is_empty());
12650 assert!(!report.needs_attention());
12651 }
12652
12653 #[test]
12654 fn doctor_state_roots_ignore_ambient_legacy_home_when_codewhale_home_is_explicit() {
12655 let _env_lock = crate::test_support::lock_test_env();
12656 let tmp = TempDir::new().expect("tempdir");
12657 let explicit_home = tmp.path().join("isolated-codewhale");
12658 let ambient_legacy = tmp.path().join(".deepseek");
12659 fs::create_dir_all(&ambient_legacy).expect("ambient legacy root");
12660 fs::write(
12661 ambient_legacy.join("config.toml"),
12662 "provider = 'deepseek'\n",
12663 )
12664 .expect("ambient legacy config");
12665 let _home = EnvVarRestore::set("HOME", tmp.path());
12666 let _codewhale_home = EnvVarRestore::set("CODEWHALE_HOME", &explicit_home);
12667
12668 let (primary_root, legacy_root) = doctor_state_roots();
12669 let report = doctor_legacy_state_report(&primary_root, &legacy_root);
12670 let session_recovery = doctor_session_recovery_report(
12671 &primary_root,
12672 &legacy_root,
12673 codewhale_config::codewhale_home_is_explicit(),
12674 );
12675
12676 assert_eq!(primary_root, explicit_home);
12677 assert_eq!(
12678 legacy_root,
12679 primary_root.join(codewhale_config::LEGACY_APP_DIR)
12680 );
12681 assert!(
12682 report
12683 .iter()
12684 .all(|entry| entry.status == DoctorLegacyStateStatus::Absent),
12685 "doctor must not report ambient legacy state when CODEWHALE_HOME is explicit"
12686 );
12687 assert!(!report.iter().any(legacy_state_needs_attention));
12688 assert_eq!(
12689 session_recovery.status,
12690 DoctorSessionRecoveryStatus::Isolated
12691 );
12692 assert!(session_recovery.recoverable.is_empty());
12693 }
12694}
12695
12696#[cfg(test)]
12697mod doctor_setup_state_tests {
12698 use super::*;
12699 use std::fs;
12700 use tempfile::TempDir;
12701
12702 fn prepare_env(tmp: &TempDir) -> (crate::test_support::EnvVarGuard, PathBuf) {
12703 let codewhale_home = tmp.path().join(".codewhale");
12704 fs::create_dir_all(&codewhale_home).expect("codewhale home");
12705 (
12706 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", codewhale_home.as_os_str()),
12707 codewhale_home,
12708 )
12709 }
12710
12711 fn provider_step(report: &serde_json::Value) -> &serde_json::Value {
12712 report["steps"]
12713 .as_array()
12714 .expect("steps array")
12715 .iter()
12716 .find(|step| step["step"] == "provider_model")
12717 .expect("provider/model step")
12718 }
12719
12720 #[test]
12721 fn doctor_setup_consistency_flags_missing_user_constitution() {
12722 let _guard = crate::test_support::lock_test_env();
12723 let tmp = TempDir::new().expect("tempdir");
12724 let (_home_guard, _codewhale_home) = prepare_env(&tmp);
12725 let _key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
12726 let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
12727 let workspace = tmp.path().join("workspace");
12728 fs::create_dir_all(&workspace).expect("workspace");
12729
12730 let state = codewhale_config::SetupState {
12731 constitution_source: codewhale_config::ConstitutionSource::UserGlobal,
12732 ..Default::default()
12733 };
12734 state.save().expect("persist setup state");
12735
12736 let report = doctor_setup_report_json(&Config::default(), &workspace);
12737
12738 assert_eq!(report["source"], "persisted");
12739 assert_eq!(report["consistency"]["status"], "inconsistent");
12740 let issues = report["consistency"]["issues"].to_string();
12741 assert!(
12742 issues.contains("setup_state_points_at_missing_user_constitution"),
12743 "{issues}"
12744 );
12745 }
12746
12747 #[test]
12748 fn doctor_setup_consistency_flags_stale_temp_files() {
12749 let _guard = crate::test_support::lock_test_env();
12750 let tmp = TempDir::new().expect("tempdir");
12751 let (_home_guard, codewhale_home) = prepare_env(&tmp);
12752 let _key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
12753 let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
12754 let workspace = tmp.path().join("workspace");
12755 fs::create_dir_all(&workspace).expect("workspace");
12756 fs::write(codewhale_home.join(".tmpAbC123"), b"orphaned atomic write")
12757 .expect("stale temp file");
12758
12759 let report = doctor_setup_report_json(&Config::default(), &workspace);
12760
12761 assert_eq!(report["consistency"]["status"], "inconsistent");
12762 let issues = report["consistency"]["issues"].to_string();
12763 assert!(
12764 issues.contains("stale_setup_temp_files_in_codewhale_home"),
12765 "{issues}"
12766 );
12767 }
12768
12769 #[test]
12770 fn doctor_setup_consistency_reports_consistent_for_clean_home() {
12771 let _guard = crate::test_support::lock_test_env();
12772 let tmp = TempDir::new().expect("tempdir");
12773 let (_home_guard, _codewhale_home) = prepare_env(&tmp);
12774 let _key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
12775 let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
12776 let workspace = tmp.path().join("workspace");
12777 fs::create_dir_all(&workspace).expect("workspace");
12778
12779 let report = doctor_setup_report_json(&Config::default(), &workspace);
12780
12781 assert_eq!(report["consistency"]["status"], "consistent");
12782 assert_eq!(
12783 report["consistency"]["issues"]
12784 .as_array()
12785 .map(Vec::len)
12786 .unwrap_or_default(),
12787 0
12788 );
12789 }
12790
12791 #[test]
12792 fn doctor_setup_report_json_derives_state_without_sidecar() {
12793 let _guard = crate::test_support::lock_test_env();
12794 let tmp = TempDir::new().expect("tempdir");
12795 let (_home_guard, _codewhale_home) = prepare_env(&tmp);
12796 let _key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
12797 let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
12798 let workspace = tmp.path().join("workspace");
12799 fs::create_dir_all(&workspace).expect("workspace");
12800
12801 let report = doctor_setup_report_json(&Config::default(), &workspace);
12802
12803 assert_eq!(report["source"], "derived");
12804 assert_eq!(report["inherited"], true);
12805 assert_eq!(report["next_actions"]["constitution"], "/constitution");
12806 assert_eq!(report["next_actions"]["setup_report"], "/setup report");
12807 assert_eq!(
12808 report["next_actions"]["provider_model"],
12809 "/setup provider, /provider setup <name>, or /model"
12810 );
12811 assert_eq!(report["next_actions"]["runtime_posture"], "/config");
12812 assert_eq!(
12813 report["next_actions"]["operate_fleet"],
12814 "/setup fleet (readiness), /fleet setup (explicit profile authoring)"
12815 );
12816 assert_eq!(report["next_actions"]["hotbar"], "/setup hotbar");
12817 assert_eq!(report["next_actions"]["tools_mcp"], "/setup tools");
12818 assert_eq!(report["next_actions"]["remote_runtime"], "/setup remote");
12819 assert_eq!(report["next_actions"]["persistence"], "/setup persistence");
12820 assert_eq!(
12821 report["checkpoint_version"],
12822 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION
12823 );
12824 assert_eq!(report["update_ready"], false);
12825 assert_eq!(report["operate_ready"], false);
12826 assert_eq!(
12827 report["operate_fleet"]["concurrency"]["plan_limit_probed"],
12828 false
12829 );
12830 assert_eq!(
12831 report["operate_fleet"]["roster"]["readiness_rule"],
12832 "built-in starter roster or custom roster"
12833 );
12834 assert_eq!(report["provider_model"]["provider"]["id"], "deepseek");
12835 assert_eq!(report["provider_model"]["provider"]["display"], "DeepSeek");
12836 assert_eq!(
12837 report["provider_model"]["model"]["resolved"],
12838 crate::config::DEFAULT_TEXT_MODEL
12839 );
12840 assert_eq!(
12841 report["provider_model"]["auth"]["source"],
12842 "secret_store_unprobed"
12843 );
12844 assert_eq!(
12845 report["provider_model"]["auth"]["availability"],
12846 "not_probed"
12847 );
12848 assert_eq!(
12849 report["provider_model"]["auth"]["credential_url"],
12850 "https://platform.deepseek.com"
12851 );
12852 assert_eq!(
12853 report["provider_model"]["auth"]["credential_mode"],
12854 "api_key"
12855 );
12856 assert_eq!(
12857 report["provider_model"]["auth"]["env_vars"][0],
12858 "DEEPSEEK_API_KEY"
12859 );
12860 assert_eq!(report["provider_model"]["health"]["live_validation"], false);
12861 assert_eq!(report["constitution"]["source"], "bundled");
12862 assert_eq!(report["constitution"]["autonomy_preference"], "unspecified");
12863 assert_eq!(report["runtime_posture"]["source"], "unset");
12864 assert_eq!(report["runtime_posture"]["default_mode"]["value"], "agent");
12865 assert_eq!(
12866 report["runtime_posture"]["approval_policy"]["value"],
12867 "on-request"
12868 );
12869 assert_eq!(report["runtime_posture"]["allow_shell"]["value"], true);
12870 assert_eq!(
12871 report["runtime_posture"]["sandbox_mode"]["value"],
12872 "mode-derived"
12873 );
12874 assert_eq!(
12875 report["runtime_posture"]["network_default"]["value"],
12876 "prompt"
12877 );
12878 assert_eq!(provider_step(&report)["status"], "needs_action");
12879 }
12880
12881 #[test]
12882 fn doctor_setup_provider_model_json_covers_cn_codex_and_local_matrix() {
12883 let _guard = crate::test_support::lock_test_env();
12884 let tmp = TempDir::new().expect("tempdir");
12885 let (_home_guard, _codewhale_home) = prepare_env(&tmp);
12886 let _home = crate::test_support::EnvVarGuard::set("HOME", tmp.path());
12887 let _userprofile = crate::test_support::EnvVarGuard::set("USERPROFILE", tmp.path());
12888 let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
12889 let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
12890 let _codex_key = crate::test_support::EnvVarGuard::remove("OPENAI_CODEX_ACCESS_TOKEN");
12891 let _codex_legacy_key = crate::test_support::EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
12892 let codex_auth_path = tmp.path().join("external-codex-auth.json");
12893 let codex_auth_raw = serde_json::json!({
12894 "tokens": {
12895 "access_token": crate::test_support::future_test_jwt("doctor"),
12896 "account_id": "acct-doctor-read-only",
12897 "refresh_token": "must-never-be-used",
12898 "unknown": {"preserve": true}
12899 }
12900 })
12901 .to_string();
12902 fs::write(&codex_auth_path, &codex_auth_raw).expect("Codex auth trap fixture");
12903 let _codex_auth =
12904 crate::test_support::EnvVarGuard::set("OPENAI_CODEX_AUTH_FILE", &codex_auth_path);
12905 let workspace = tmp.path().join("workspace");
12906 fs::create_dir_all(&workspace).expect("workspace");
12907
12908 let cn_config = Config {
12909 provider: Some("deepseek-cn".to_string()),
12910 ..Config::default()
12911 };
12912 let cn_report = doctor_setup_report_json(&cn_config, &workspace);
12913 assert_eq!(cn_report["provider_model"]["provider"]["id"], "deepseek-cn");
12914 assert_eq!(
12915 cn_report["provider_model"]["provider"]["display"],
12916 "DeepSeek (legacy alias)"
12917 );
12918 assert_eq!(
12919 cn_report["provider_model"]["auth"]["env_vars"][0],
12920 "DEEPSEEK_API_KEY"
12921 );
12922 assert_eq!(
12923 cn_report["provider_model"]["auth"]["credential_url"],
12924 "https://platform.deepseek.com"
12925 );
12926 assert_eq!(cn_report["provider_model"]["auth"]["oauth_only"], false);
12927 assert_eq!(
12928 cn_report["provider_model"]["health"]["live_validation"],
12929 false
12930 );
12931
12932 let codex_config = Config {
12933 provider: Some("openai-codex".to_string()),
12934 ..Config::default()
12935 };
12936 crate::external_credentials::reset_side_effect_trap();
12937 let codex_report = doctor_setup_report_json(&codex_config, &workspace);
12938 assert_eq!(
12939 codex_report["provider_model"]["provider"]["id"],
12940 crate::config::ApiProvider::OpenaiCodex.as_str()
12941 );
12942 assert!(codex_report["provider_model"]["auth"]["credential_url"].is_null());
12943 assert_eq!(
12944 codex_report["provider_model"]["auth"]["credential_mode"],
12945 "oauth"
12946 );
12947 assert_eq!(codex_report["provider_model"]["auth"]["oauth_only"], true);
12948 assert_eq!(
12949 codex_report["provider_model"]["health"]["next_action"],
12950 "/setup provider or /provider setup <name>"
12951 );
12952 assert_eq!(
12953 crate::external_credentials::side_effect_trap_counts(),
12954 (0, 0),
12955 "doctor must not stat or read external credentials without consent"
12956 );
12957
12958 let mut consent = codewhale_config::ExternalCredentialConsentToml::read_only(
12959 codewhale_config::ProviderKind::OpenaiCodex,
12960 codewhale_config::ExternalCredentialSource::CodexCli,
12961 codex_auth_path.clone(),
12962 );
12963 let codex_read_only = Config {
12964 provider: Some("openai-codex".to_string()),
12965 providers: Some(crate::config::ProvidersConfig {
12966 openai_codex: crate::config::ProviderConfig {
12967 auth_mode: Some("oauth".to_string()),
12968 external_credentials: Some(consent.clone()),
12969 ..Default::default()
12970 },
12971 ..Default::default()
12972 }),
12973 ..Config::default()
12974 };
12975 let changed_ambient_path = tmp.path().join("new-ambient-codex-auth.json");
12976 let _changed_codex_auth =
12977 crate::test_support::EnvVarGuard::set("OPENAI_CODEX_AUTH_FILE", &changed_ambient_path);
12978 crate::external_credentials::reset_side_effect_trap();
12979 let codex_read_only_report = doctor_setup_report_json(&codex_read_only, &workspace);
12980 assert_eq!(
12981 codex_read_only_report["provider_model"]["auth"]["present_or_local"],
12982 false
12983 );
12984 assert_eq!(
12985 codex_read_only_report["provider_model"]["auth"]["source"],
12986 "external_consent"
12987 );
12988 let status_json = doctor_external_credential_consent_json(&codex_read_only);
12989 let codex_status = status_json
12990 .as_array()
12991 .and_then(|rows| rows.first())
12992 .expect("Codex structural status");
12993 assert_eq!(codex_status["access"], "read_only");
12994 assert_eq!(codex_status["provider"], "openai-codex");
12995 assert_eq!(codex_status["source"], "codex_cli");
12996 assert_eq!(codex_status["route_state"], "active");
12997 assert_eq!(codex_status["ambient_path_changed"], true);
12998 assert!(
12999 codex_status["ambient_path_warning"]
13000 .as_str()
13001 .is_some_and(|warning| warning.contains("remains pinned"))
13002 );
13003 assert_eq!(
13004 codex_status["revoke_command"],
13005 "codewhale auth external-revoke --provider openai-codex"
13006 );
13007 let human = doctor_external_credential_consent_lines(&codex_read_only).join("\n");
13008 assert!(human.contains("path="), "{human}");
13009 assert!(human.contains("version=1"), "{human}");
13010 assert!(human.contains("no refresh, identity-provider or discovery requests"));
13011 assert!(human.contains("normal requests to the explicitly selected provider"));
13012 assert!(human.contains("consent remains pinned"), "{human}");
13013 assert!(
13014 human.contains(&codewhale_config::quote_os_path(&codex_auth_path)),
13015 "{human}"
13016 );
13017 assert!(!human.contains(&changed_ambient_path.display().to_string()));
13018 assert_eq!(
13019 crate::external_credentials::complete_side_effect_trap_counts(),
13020 (0, 0, 0, 0, 0),
13021 "doctor consent status is structural and must not inspect the file"
13022 );
13023 assert_eq!(
13024 fs::read_to_string(&codex_auth_path).expect("unchanged Codex auth fixture"),
13025 codex_auth_raw
13026 );
13027
13028 consent.access = codewhale_config::ExternalCredentialAccess::Managed;
13029 let codex_managed = Config {
13030 provider: Some("openai-codex".to_string()),
13031 providers: Some(crate::config::ProvidersConfig {
13032 openai_codex: crate::config::ProviderConfig {
13033 auth_mode: Some("oauth".to_string()),
13034 external_credentials: Some(consent),
13035 ..Default::default()
13036 },
13037 ..Default::default()
13038 }),
13039 ..Config::default()
13040 };
13041 crate::external_credentials::reset_side_effect_trap();
13042 let codex_managed_report = doctor_setup_report_json(&codex_managed, &workspace);
13043 assert_eq!(
13044 codex_managed_report["provider_model"]["auth"]["present_or_local"],
13045 false
13046 );
13047 assert_eq!(
13048 crate::external_credentials::side_effect_trap_counts(),
13049 (0, 0),
13050 "unsupported managed mode must fail before external I/O"
13051 );
13052 assert_eq!(
13053 fs::read_to_string(&codex_auth_path).expect("unchanged managed auth fixture"),
13054 codex_auth_raw
13055 );
13056
13057 let local_config = Config {
13058 provider: Some("ollama".to_string()),
13059 ..Config::default()
13060 };
13061 let local_report = doctor_setup_report_json(&local_config, &workspace);
13062 assert_eq!(local_report["provider_model"]["provider"]["id"], "ollama");
13063 assert_eq!(
13064 local_report["provider_model"]["auth"]["present_or_local"],
13065 true
13066 );
13067 assert!(local_report["provider_model"]["auth"]["credential_url"].is_null());
13068 assert_eq!(
13069 local_report["provider_model"]["auth"]["credential_mode"],
13070 "local_optional"
13071 );
13072 assert_eq!(local_report["provider_model"]["auth"]["oauth_only"], false);
13073 assert_eq!(
13074 local_report["provider_model"]["health"]["next_action"],
13075 "/model"
13076 );
13077
13078 let kimi_config = Config {
13079 provider: Some("moonshot".to_string()),
13080 ..Config::default()
13081 };
13082 let kimi_report = doctor_setup_report_json(&kimi_config, &workspace);
13083 assert_eq!(
13084 kimi_report["provider_model"]["auth"]["credential_url"],
13085 "https://platform.kimi.ai"
13086 );
13087 assert_eq!(
13088 kimi_report["provider_model"]["auth"]["credential_docs_url"],
13089 "https://platform.kimi.ai"
13090 );
13091 assert_eq!(
13092 kimi_report["provider_model"]["auth"]["credential_mode"],
13093 "api_key"
13094 );
13095 assert!(
13096 kimi_report["provider_model"]["auth"]["credential_guidance"]
13097 .as_str()
13098 .is_some_and(|guidance| guidance.contains("OAuth is not available"))
13099 );
13100 }
13101
13102 #[test]
13103 fn doctor_setup_report_json_uses_persisted_state() {
13104 let _guard = crate::test_support::lock_test_env();
13105 let tmp = TempDir::new().expect("tempdir");
13106 let (_home_guard, _codewhale_home) = prepare_env(&tmp);
13107 let workspace = tmp.path().join("workspace");
13108 fs::create_dir_all(&workspace).expect("workspace");
13109 let mut state = codewhale_config::SetupState::default();
13110 state.set_step(
13111 codewhale_config::SetupStep::Language,
13112 codewhale_config::StepEntry::new(
13113 codewhale_config::StepStatus::Verified,
13114 true,
13115 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13116 ),
13117 );
13118 state.set_step(
13119 codewhale_config::SetupStep::ProviderModel,
13120 codewhale_config::StepEntry::new(
13121 codewhale_config::StepStatus::Verified,
13122 true,
13123 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13124 )
13125 .with_result("deepseek/deepseek-chat"),
13126 );
13127 state.set_step(
13128 codewhale_config::SetupStep::TrustSandbox,
13129 codewhale_config::StepEntry::new(
13130 codewhale_config::StepStatus::Verified,
13131 true,
13132 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13133 ),
13134 );
13135 state
13136 .complete_constitution_checkpoint(
13137 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13138 codewhale_config::ConstitutionChoice::Bundled,
13139 )
13140 .set_step(
13141 codewhale_config::SetupStep::Constitution,
13142 codewhale_config::StepEntry::new(
13143 codewhale_config::StepStatus::Verified,
13144 true,
13145 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13146 ),
13147 );
13148 state.runtime_posture_source = codewhale_config::RuntimePostureSource::Confirmed;
13149 state.save().expect("persist setup state");
13150 codewhale_config::UserConstitution {
13151 autonomy_preference: codewhale_config::AutonomyPreference::Balanced,
13152 ..Default::default()
13153 }
13154 .save()
13155 .expect("persist user constitution");
13156 let config = Config {
13157 api_key: Some("TEST-STRUCTURAL-LITERAL".to_string()),
13158 approval_policy: Some("never".to_string()),
13159 allow_shell: Some(false),
13160 sandbox_mode: Some("read-only".to_string()),
13161 network: Some(crate::config::NetworkPolicyToml {
13162 default: "deny".to_string(),
13163 ..Default::default()
13164 }),
13165 ..Config::default()
13166 };
13167
13168 let report = doctor_setup_report_json(&config, &workspace);
13169
13170 assert_eq!(report["source"], "persisted");
13171 assert_eq!(report["first_run_ready"], true);
13172 assert_eq!(report["update_ready"], true);
13173 assert_eq!(report["operate_ready"], false);
13174 assert_eq!(report["constitution"]["choice"], "bundled");
13175 assert_eq!(
13176 report["constitution"]["checkpoint_completed_for"],
13177 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION
13178 );
13179 assert_eq!(report["constitution"]["autonomy_preference"], "balanced");
13180 assert_eq!(report["runtime_posture_source"], "confirmed");
13181 assert_eq!(report["runtime_posture"]["source"], "confirmed");
13182 assert_eq!(
13183 report["runtime_posture"]["approval_policy"]["value"],
13184 "never"
13185 );
13186 assert_eq!(
13187 report["runtime_posture"]["approval_policy"]["source"],
13188 "config"
13189 );
13190 assert_eq!(report["runtime_posture"]["allow_shell"]["value"], false);
13191 assert_eq!(report["runtime_posture"]["allow_shell"]["source"], "config");
13192 assert_eq!(
13193 report["runtime_posture"]["sandbox_mode"]["value"],
13194 "read-only"
13195 );
13196 assert_eq!(
13197 report["runtime_posture"]["sandbox_mode"]["source"],
13198 "config"
13199 );
13200 assert_eq!(
13201 report["runtime_posture"]["network_default"]["value"],
13202 "deny"
13203 );
13204 assert_eq!(
13205 report["runtime_posture"]["network_default"]["source"],
13206 "config"
13207 );
13208 assert_eq!(provider_step(&report)["result"], "deepseek/deepseek-chat");
13209
13210 let unprobed_config = Config {
13211 api_key: Some(crate::config::API_KEYRING_SENTINEL.to_string()),
13212 ..config.clone()
13213 };
13214 let unprobed_report = doctor_setup_report_json(&unprobed_config, &workspace);
13215 assert_eq!(unprobed_report["credential"]["ready"], false);
13216 assert_eq!(unprobed_report["credential"]["availability"], "not_probed");
13217 assert_eq!(unprobed_report["first_run_ready"], true);
13218 assert_eq!(unprobed_report["update_ready"], true);
13219 }
13220
13221 #[test]
13222 fn doctor_reports_settings_permission_posture_when_approval_policy_unset() {
13223 let _guard = crate::test_support::lock_test_env();
13224 let tmp = TempDir::new().expect("tempdir");
13225 let (_home_guard, codewhale_home) = prepare_env(&tmp);
13226 let workspace = tmp.path().join("workspace");
13227 fs::create_dir_all(&workspace).expect("workspace");
13228 fs::write(
13229 codewhale_home.join("settings.toml"),
13230 "permission_posture = \"full-access\"\n",
13231 )
13232 .expect("write settings.toml");
13233
13234 let config = Config::default();
13235 assert!(config.approval_policy.is_none());
13236
13237 let line = doctor_runtime_posture_line(&config, &workspace);
13238 assert!(
13239 line.contains("permission_posture=full-access (settings)"),
13240 "text doctor should report saved settings posture: {line}"
13241 );
13242 assert!(
13243 line.contains("approval_policy=on-request (default)"),
13244 "text doctor should keep unset config approval_policy default: {line}"
13245 );
13246
13247 let report = doctor_setup_report_json(&config, &workspace);
13248 assert_eq!(
13249 report["runtime_posture"]["permission_posture"]["value"],
13250 "full-access"
13251 );
13252 assert_eq!(
13253 report["runtime_posture"]["permission_posture"]["source"],
13254 "settings"
13255 );
13256 assert_eq!(
13257 report["runtime_posture"]["approval_policy"]["value"],
13258 "on-request"
13259 );
13260 assert_eq!(
13261 report["runtime_posture"]["approval_policy"]["source"],
13262 "default"
13263 );
13264 }
13265
13266 #[test]
13267 fn doctor_setup_report_json_fails_closed_without_operate_receipts() {
13268 let _guard = crate::test_support::lock_test_env();
13269 let tmp = TempDir::new().expect("tempdir");
13270 let (_home_guard, _codewhale_home) = prepare_env(&tmp);
13271 let workspace = tmp.path().join("workspace");
13272 fs::create_dir_all(&workspace).expect("workspace");
13273 let mut state = codewhale_config::SetupState::default();
13274 state.set_step(
13275 codewhale_config::SetupStep::Language,
13276 codewhale_config::StepEntry::new(
13277 codewhale_config::StepStatus::Verified,
13278 true,
13279 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13280 ),
13281 );
13282 state.set_step(
13283 codewhale_config::SetupStep::ProviderModel,
13284 codewhale_config::StepEntry::new(
13285 codewhale_config::StepStatus::Verified,
13286 true,
13287 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13288 ),
13289 );
13290 state.runtime_posture_source = codewhale_config::RuntimePostureSource::Confirmed;
13291 state.complete_constitution_checkpoint(
13292 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13293 codewhale_config::ConstitutionChoice::Bundled,
13294 );
13295 state.set_step(
13296 codewhale_config::SetupStep::OperateFleet,
13297 codewhale_config::StepEntry::new(
13298 codewhale_config::StepStatus::Verified,
13299 false,
13300 crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13301 )
13302 .with_result(
13303 "provider=ready, runtime=ready, roster=ready, concurrency=plan limit not probed",
13304 ),
13305 );
13306 state.save().expect("persist setup state");
13307
13308 let config = Config {
13309 api_key: Some("TEST-STRUCTURAL-LITERAL".to_string()),
13310 ..Config::default()
13311 };
13312 let report = doctor_setup_report_json(&config, &workspace);
13313
13314 assert_eq!(report["first_run_ready"], true);
13315 assert_eq!(report["operate_ready"], false);
13316 assert_eq!(
13317 report["operate_fleet"]["concurrency"]["plan_limit_probed"],
13318 false
13319 );
13320 assert!(
13321 report["operate_fleet"]["roster"]["built_in"]
13322 .as_u64()
13323 .is_some_and(|count| count > 0)
13324 );
13325 let operate_step = report["steps"]
13326 .as_array()
13327 .expect("steps array")
13328 .iter()
13329 .find(|step| step["step"] == "operate_fleet")
13330 .expect("operate/fleet step");
13331 assert_eq!(operate_step["status"], "verified");
13332 assert!(
13333 operate_step["result"]
13334 .as_str()
13335 .is_some_and(|result| result.contains("plan limit not probed"))
13336 );
13337 }
13338}
13339
13340#[cfg(test)]
13341mod doctor_endpoint_tests {
13342 use super::*;
13343
13344 #[test]
13345 fn doctor_api_target_reports_default_endpoint() {
13346 let config = Config::default();
13347
13348 let target = doctor_api_target(&config);
13349
13350 assert_eq!(target.provider, "deepseek");
13351 assert_eq!(target.base_url, crate::config::DEFAULT_DEEPSEEK_BASE_URL);
13352 assert_eq!(target.model, crate::config::DEFAULT_TEXT_MODEL);
13353 assert_eq!(target.resolution, DoctorModelResolution::Resolved);
13354 }
13355
13356 #[test]
13357 fn doctor_api_target_falls_back_to_configured_model_when_resolution_fails() {
13358 let config = Config {
13362 provider: Some("custom".to_string()),
13363 ..Default::default()
13364 };
13365
13366 let target = doctor_api_target(&config);
13367
13368 assert_eq!(target.resolution, DoctorModelResolution::ConfiguredOnly);
13369 assert_eq!(target.model, config.default_model());
13370 }
13371
13372 #[test]
13373 fn doctor_api_target_routes_deepseek_cn_alias_to_beta_endpoint() {
13374 let config = Config {
13375 provider: Some("deepseek-cn".to_string()),
13376 ..Default::default()
13377 };
13378
13379 let target = doctor_api_target(&config);
13380
13381 assert_eq!(target.provider, "deepseek-cn");
13382 assert_eq!(target.base_url, crate::config::DEFAULT_DEEPSEEKCN_BASE_URL);
13383 assert_eq!(target.base_url, crate::config::DEFAULT_DEEPSEEK_BASE_URL);
13384 assert_eq!(target.model, crate::config::DEFAULT_TEXT_MODEL);
13385 assert_eq!(target.resolution, DoctorModelResolution::Resolved);
13386 }
13387
13388 #[test]
13389 fn strict_tool_mode_doctor_reports_disabled_by_default() {
13390 let config = Config::default();
13391
13392 let status = doctor_strict_tool_mode_status(&config);
13393
13394 assert!(!status.enabled);
13395 assert_eq!(status.status, "disabled");
13396 assert!(!status.function_strict_sent);
13397 assert!(status.recommended_base_url.is_none());
13398 }
13399
13400 #[test]
13401 fn doctor_known_base_urls_are_ascii_case_insensitive() {
13402 assert!(doctor_xiaomi_mimo_base_url_uses_token_plan(
13403 "HTTPS://TOKEN-PLAN-CN.XIAOMIMIMO.COM/V1/"
13404 ));
13405 assert_eq!(
13406 known_deepseek_base_url_kind("HTTPS://API.DEEPSEEK.COM/BETA/"),
13407 Some(DeepSeekBaseUrlKind::Beta)
13408 );
13409 assert_eq!(
13410 known_deepseek_base_url_kind("HTTPS://API.DEEPSEEK.COM/V1/"),
13411 Some(DeepSeekBaseUrlKind::NonBeta)
13412 );
13413 }
13414
13415 #[test]
13416 fn strict_tool_mode_doctor_accepts_default_beta_endpoint() {
13417 let config = Config {
13418 strict_tool_mode: Some(true),
13419 ..Default::default()
13420 };
13421
13422 let status = doctor_strict_tool_mode_status(&config);
13423
13424 assert!(status.enabled);
13425 assert_eq!(status.status, "ready");
13426 assert!(status.function_strict_sent);
13427 assert!(status.message.contains("beta endpoint"));
13428 assert!(status.recommended_base_url.is_none());
13429 }
13430
13431 #[test]
13432 fn strict_tool_mode_doctor_warns_for_non_beta_deepseek_endpoint() {
13433 let config = Config {
13434 strict_tool_mode: Some(true),
13435 base_url: Some("https://api.deepseek.com".to_string()),
13436 ..Default::default()
13437 };
13438
13439 let status = doctor_strict_tool_mode_status(&config);
13440
13441 assert_eq!(status.status, "fallback_non_beta");
13442 assert!(!status.function_strict_sent);
13443 assert_eq!(
13444 status.recommended_base_url.as_deref(),
13445 Some(crate::config::DEFAULT_DEEPSEEK_BASE_URL)
13446 );
13447 assert_eq!(
13448 doctor_strict_tool_mode_report_json(&status)["recommended_base_url"],
13449 "https://api.deepseek.com"
13450 );
13451 }
13452
13453 #[test]
13454 fn strict_tool_mode_doctor_accepts_deepseek_cn_alias_default_endpoint() {
13455 let config = Config {
13456 provider: Some("deepseek-cn".to_string()),
13457 strict_tool_mode: Some(true),
13458 ..Default::default()
13459 };
13460
13461 let status = doctor_strict_tool_mode_status(&config);
13462
13463 assert_eq!(status.status, "ready");
13464 assert!(status.function_strict_sent);
13465 assert!(status.message.contains("beta endpoint"));
13466 assert!(status.recommended_base_url.is_none());
13467 }
13468
13469 #[test]
13470 fn strict_tool_mode_doctor_marks_custom_endpoint_as_forwarded() {
13471 let config = Config {
13472 provider: Some("vllm".to_string()),
13473 strict_tool_mode: Some(true),
13474 ..Default::default()
13475 };
13476
13477 let status = doctor_strict_tool_mode_status(&config);
13478
13479 assert_eq!(status.status, "custom_endpoint");
13480 assert!(status.function_strict_sent);
13481 assert!(status.message.contains("custom endpoint"));
13482 }
13483
13484 #[test]
13485 fn doctor_tls_status_reports_verification_enabled_by_default() {
13486 let status = doctor_tls_status(&Config::default());
13487
13488 assert!(status.certificate_verification);
13489 assert!(!status.insecure_skip_tls_verify);
13490 assert_eq!(status.provider, "deepseek");
13491 assert!(status.message.contains("enabled"));
13492 }
13493
13494 #[test]
13495 fn doctor_tls_status_warns_when_active_provider_skips_verification() {
13496 let mut providers = crate::config::ProvidersConfig::default();
13497 providers.openai.insecure_skip_tls_verify = Some(true);
13498 let config = Config {
13499 provider: Some("openai".to_string()),
13500 providers: Some(providers),
13501 ..Default::default()
13502 };
13503
13504 let status = doctor_tls_status(&config);
13505
13506 assert!(status.certificate_verification);
13507 assert!(status.insecure_skip_tls_verify);
13508 assert_eq!(status.provider, "openai");
13509 assert!(status.message.contains("cannot be disabled"));
13510 assert!(status.message.contains("SSL_CERT_FILE"));
13511 }
13512
13513 #[test]
13514 fn provider_capability_report_exposes_alias_deprecation_for_deepseek_chat() {
13515 let mut config = Config {
13516 default_text_model: Some("deepseek-chat".to_string()),
13517 ..Default::default()
13518 };
13519 crate::config::normalize_model_config_for_test(&mut config);
13520
13521 let report = provider_capability_report(&config);
13522
13523 assert_eq!(report["resolved_model"], "deepseek-v4-flash");
13524 assert_eq!(report["context_window"], 1_000_000);
13525 assert_eq!(report["thinking_supported"], true);
13526 assert_eq!(report["alias_deprecation"]["alias"], "deepseek-chat");
13527 assert_eq!(
13528 report["alias_deprecation"]["replacement"],
13529 "deepseek-v4-flash"
13530 );
13531 assert_eq!(
13532 report["alias_deprecation"]["retirement_utc"],
13533 "2026-07-24T15:59:00Z"
13534 );
13535 }
13536
13537 #[test]
13538 fn provider_capability_report_preserves_custom_deepseek_alias_namespace() {
13539 let mut config = Config {
13540 base_url: Some("https://models.example/v1".to_string()),
13541 default_text_model: Some("deepseek-chat".to_string()),
13542 ..Default::default()
13543 };
13544 crate::config::normalize_model_config_for_test(&mut config);
13545
13546 let report = provider_capability_report(&config);
13547
13548 assert_eq!(report["resolved_model"], "deepseek-chat");
13549 assert!(report["alias_deprecation"].is_null());
13550 }
13551
13552 #[test]
13553 fn provider_capability_report_leaves_canonical_flash_alias_metadata_null() {
13554 let config = Config {
13555 default_text_model: Some("deepseek-v4-flash".to_string()),
13556 ..Default::default()
13557 };
13558
13559 let report = provider_capability_report(&config);
13560
13561 assert_eq!(report["resolved_model"], "deepseek-v4-flash");
13562 assert!(report["alias_deprecation"].is_null());
13563 }
13564
13565 #[test]
13566 fn doctor_route_report_exposes_tokenhub_openai_compatible_route_without_secret() {
13567 let mut providers = crate::config::ProvidersConfig::default();
13568 providers.openai.api_key = Some("tokenhub-secret-value".to_string());
13569 providers.openai.base_url = Some("https://tokenhub.tencentmaas.com/v1".to_string());
13570 providers.openai.model = Some("deepseek-ai/DeepSeek-V4-Pro".to_string());
13571 let config = Config {
13572 provider: Some("openai".to_string()),
13573 providers: Some(providers),
13574 ..Default::default()
13575 };
13576
13577 let report = doctor_route_report(&config);
13578 let serialized = report.to_string();
13579
13580 assert_eq!(report["provider"], "openai");
13581 assert_eq!(report["provider_source"], "config");
13582 assert_eq!(report["provider_config_table"], "openai");
13583 assert_eq!(report["model"], "deepseek-ai/DeepSeek-V4-Pro");
13584 assert_eq!(report["wire_protocol"], "chat_completions");
13585 assert_eq!(
13586 report["base_url"]["redacted"],
13587 "https://tokenhub.tencentmaas.com"
13588 );
13589 assert_eq!(report["base_url"]["class"], "custom");
13590 assert_eq!(report["auth"]["scheme"], "bearer");
13591 assert_eq!(report["auth"]["source"], "config_declared");
13592 assert!(
13593 report["base_url"]["fingerprint"]
13594 .as_str()
13595 .is_some_and(|value| value.starts_with("<redacted:"))
13596 );
13597 assert!(!serialized.contains("tokenhub-secret-value"));
13598 }
13599
13600 #[test]
13601 fn doctor_route_report_exposes_siliconflow_cn_provider_route() {
13602 let mut providers = crate::config::ProvidersConfig::default();
13603 providers.siliconflow_cn.api_key = Some("sf-cn-secret-value".to_string());
13604 providers.siliconflow_cn.base_url =
13605 Some(crate::config::DEFAULT_SILICONFLOW_CN_BASE_URL.to_string());
13606 providers.siliconflow_cn.model = Some(crate::config::DEFAULT_SILICONFLOW_MODEL.to_string());
13607 let config = Config {
13608 provider: Some("siliconflow-CN".to_string()),
13609 providers: Some(providers),
13610 ..Default::default()
13611 };
13612
13613 let report = doctor_route_report(&config);
13614 let serialized = report.to_string();
13615
13616 assert_eq!(report["provider"], "siliconflow-CN");
13617 assert_eq!(report["provider_config_table"], "siliconflow_cn");
13618 assert_eq!(report["model"], crate::config::DEFAULT_SILICONFLOW_MODEL);
13619 assert_eq!(
13620 report["base_url"]["redacted"],
13621 crate::doctor::structural_url_authority(crate::config::DEFAULT_SILICONFLOW_CN_BASE_URL)
13622 );
13623 assert_eq!(report["base_url"]["class"], "default");
13624 assert_eq!(report["auth"]["scheme"], "bearer");
13625 assert_eq!(report["auth"]["source"], "config_declared");
13626 assert!(!serialized.contains("sf-cn-secret-value"));
13627 }
13628
13629 #[test]
13630 fn doctor_route_report_names_kimi_code_context_provenance() {
13631 let config = Config {
13632 provider: Some("moonshot".to_string()),
13633 providers: Some(crate::config::ProvidersConfig {
13634 moonshot: crate::config::ProviderConfig {
13635 api_key: Some("kimi-plan-secret".to_string()),
13636 base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
13637 model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
13638 ..Default::default()
13639 },
13640 ..Default::default()
13641 }),
13642 ..Default::default()
13643 };
13644
13645 let report = doctor_route_report(&config);
13646 let serialized = report.to_string();
13647
13648 assert_eq!(report["context_window"]["tokens"], 262_144);
13649 assert_eq!(
13650 report["context_window"]["source"],
13651 "static Kimi Code safe floor"
13652 );
13653 assert!(!serialized.contains("kimi-plan-secret"));
13654 }
13655
13656 #[test]
13657 fn provider_capability_report_uses_exact_kimi_code_route_facts() {
13658 let config = Config {
13659 provider: Some("moonshot".to_string()),
13660 providers: Some(crate::config::ProvidersConfig {
13661 moonshot: crate::config::ProviderConfig {
13662 api_key: Some("kimi-plan-secret".to_string()),
13663 base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
13664 model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
13665 ..Default::default()
13666 },
13667 ..Default::default()
13668 }),
13669 ..Default::default()
13670 };
13671
13672 let report = provider_capability_report(&config);
13673
13674 assert_eq!(report["resolved_model"], crate::config::KIMI_CODE_K3_MODEL);
13675 assert_eq!(report["context_window"], 262_144);
13676 assert_eq!(
13677 report["context_window_source"],
13678 "static Kimi Code safe floor"
13679 );
13680 assert_eq!(report["thinking_supported"], true);
13681 }
13682
13683 #[test]
13684 fn provider_capability_report_honors_kimi_code_context_override() {
13685 let config = Config {
13686 provider: Some("moonshot".to_string()),
13687 providers: Some(crate::config::ProvidersConfig {
13688 moonshot: crate::config::ProviderConfig {
13689 api_key: Some("kimi-plan-secret".to_string()),
13690 base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
13691 model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
13692 context_window: Some(1_048_576),
13693 ..Default::default()
13694 },
13695 ..Default::default()
13696 }),
13697 ..Default::default()
13698 };
13699
13700 let report = provider_capability_report(&config);
13701
13702 assert_eq!(
13703 report["resolved_model"],
13704 crate::config::KIMI_CODE_K3_MODEL,
13705 "the configured window must preserve Kimi Code's bare wire id"
13706 );
13707 assert_eq!(report["context_window"], 1_048_576);
13708 assert_eq!(report["context_window_source"], "configured");
13709 assert_eq!(report["thinking_supported"], true);
13710 }
13711
13712 #[test]
13713 fn provider_capability_report_uses_direct_moonshot_k3_route_facts() {
13714 let config = Config {
13715 provider: Some("moonshot".to_string()),
13716 providers: Some(crate::config::ProvidersConfig {
13717 moonshot: crate::config::ProviderConfig {
13718 api_key: Some("moonshot-secret".to_string()),
13719 base_url: Some(crate::config::DEFAULT_MOONSHOT_BASE_URL.to_string()),
13720 model: Some("kimi-k3".to_string()),
13721 ..Default::default()
13722 },
13723 ..Default::default()
13724 }),
13725 ..Default::default()
13726 };
13727
13728 let report = provider_capability_report(&config);
13729
13730 assert_eq!(report["resolved_model"], "kimi-k3");
13731 assert_eq!(report["context_window"], 1_048_576);
13732 assert_eq!(report["context_window_source"], "catalog");
13733 assert_eq!(report["max_output"], 1_048_576);
13734 assert_eq!(report["thinking_supported"], true);
13735 }
13736
13737 #[test]
13738 fn doctor_search_provider_line_includes_firecrawl_default_source_and_switch_hint() {
13739 let _guard = crate::test_support::lock_test_env();
13740 let prev = std::env::var_os("DEEPSEEK_SEARCH_PROVIDER");
13741 unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") };
13742
13743 let line = doctor_search_provider_line(&Config::default());
13744
13745 match prev {
13746 Some(value) => unsafe { std::env::set_var("DEEPSEEK_SEARCH_PROVIDER", value) },
13747 None => unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") },
13748 }
13749 assert!(line.contains("search_provider: firecrawl"));
13750 assert!(line.contains("source: default"));
13751 assert!(line.contains("[search] provider"));
13752 assert!(line.contains("provider = \"baidu\""));
13753 }
13754
13755 #[test]
13756 fn doctor_search_provider_json_reports_config_source() {
13757 let _guard = crate::test_support::lock_test_env();
13758 let prev = std::env::var_os("DEEPSEEK_SEARCH_PROVIDER");
13759 unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") };
13760 let config = Config {
13761 search: Some(crate::config::SearchConfig {
13762 provider: Some(crate::config::SearchProvider::DuckDuckGo),
13763 base_url: None,
13764 api_key: None,
13765 }),
13766 ..Default::default()
13767 };
13768
13769 let report = doctor_search_provider_json(&config);
13770
13771 match prev {
13772 Some(value) => unsafe { std::env::set_var("DEEPSEEK_SEARCH_PROVIDER", value) },
13773 None => unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") },
13774 }
13775 assert_eq!(report["provider"], "duckduckgo");
13776 assert_eq!(report["source"], "config");
13777 }
13778
13779 #[test]
13780 fn doctor_search_provider_json_reports_env_override_source() {
13781 let _guard = crate::test_support::lock_test_env();
13782 let prev = std::env::var_os("DEEPSEEK_SEARCH_PROVIDER");
13783 unsafe { std::env::set_var("DEEPSEEK_SEARCH_PROVIDER", "tavily") };
13784
13785 let report = doctor_search_provider_json(&Config::default());
13786
13787 match prev {
13788 Some(value) => unsafe { std::env::set_var("DEEPSEEK_SEARCH_PROVIDER", value) },
13789 None => unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") },
13790 }
13791 assert_eq!(report["provider"], "tavily");
13792 assert_eq!(report["source"], "env override");
13793 }
13794
13795 #[test]
13796 fn doctor_search_provider_line_omits_switch_hint_when_bing_is_configured() {
13797 let _guard = crate::test_support::lock_test_env();
13798 let prev = std::env::var_os("DEEPSEEK_SEARCH_PROVIDER");
13799 unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") };
13800 let config = Config {
13801 search: Some(crate::config::SearchConfig {
13802 provider: Some(crate::config::SearchProvider::Bing),
13803 base_url: None,
13804 api_key: None,
13805 }),
13806 ..Default::default()
13807 };
13808
13809 let line = doctor_search_provider_line(&config);
13810
13811 match prev {
13812 Some(value) => unsafe { std::env::set_var("DEEPSEEK_SEARCH_PROVIDER", value) },
13813 None => unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") },
13814 }
13815 assert!(line.contains("search_provider: bing"));
13816 assert!(line.contains("source: config"));
13817 assert!(!line.contains("[search] provider"));
13818 }
13819
13820 #[test]
13821 fn timeout_recovery_keeps_default_deepseek_users_on_default_endpoint() {
13822 let config = Config::default();
13823
13824 let text = doctor_timeout_recovery_lines(&config).join("\n");
13825
13826 assert!(text.contains("api.deepseek.com"));
13827 assert!(text.contains("custom DeepSeek-compatible endpoint"));
13828 assert!(!text.contains("provider = \"deepseek-cn\""));
13829 assert!(text.contains("codewhale doctor --json"));
13830 }
13831
13832 #[test]
13833 fn timeout_recovery_for_custom_provider_checks_openai_compatibility() {
13834 let config = Config {
13835 provider: Some("vllm".to_string()),
13836 ..Default::default()
13837 };
13838
13839 let text = doctor_timeout_recovery_lines(&config).join("\n");
13840
13841 assert!(text.contains("/v1/models"));
13842 assert!(text.contains("/v1/chat/completions"));
13843 assert!(!text.contains("api.deepseeki.com"));
13844 }
13845}
13846
13847#[cfg(test)]
13848mod terminal_mode_tests {
13849 use super::*;
13850 use clap::Parser;
13851
13852 fn parse_cli(args: &[&str]) -> Cli {
13853 Cli::try_parse_from(args).expect("CLI args should parse")
13854 }
13855
13856 #[test]
13857 fn headless_consultant_authority_overrides_network_allow_and_disables_web_search() {
13858 let config = Config {
13859 network: Some(crate::config::NetworkPolicyToml {
13860 default: "allow".to_string(),
13861 audit: false,
13862 ..crate::config::NetworkPolicyToml::default()
13863 }),
13864 ..Config::default()
13865 };
13866 let authority = crate::tools::spec::ToolAuthorityEnvelope {
13867 schema_version: 1,
13868 owner: "consultant-1".to_string(),
13869 authority: crate::tools::spec::ToolMutationAuthority::ReadOnly,
13870 network_access: Some(false),
13871 shell: crate::tools::spec::ToolShellAuthority::None,
13872 verification: crate::tools::spec::ToolVerificationAuthority::None,
13873 writable_roots: Vec::new(),
13874 writable_files: Vec::new(),
13875 coordination_contracts: Vec::new(),
13876 }
13877 .normalized()
13878 .expect("Consultant authority");
13879
13880 let policy = exec_network_policy(&config, authority.network_access)
13881 .expect("explicit network=false always installs a policy");
13882 assert_eq!(
13883 policy.evaluate("example.com", "web_search"),
13884 crate::network_policy::Decision::Deny,
13885 "the permissive user config must not widen Consultant network authority"
13886 );
13887 let mut features = crate::features::Features::default();
13888 features.enable(crate::features::Feature::ShellTool);
13889 features.enable(crate::features::Feature::WebSearch);
13890 apply_fleet_engine_feature_caps(
13891 &mut features,
13892 true,
13893 authority.network_access,
13894 authority.shell,
13895 );
13896 assert!(!features.enabled(crate::features::Feature::WebSearch));
13897 assert!(!features.enabled(crate::features::Feature::ShellTool));
13898
13899 let worker_policy = exec_network_policy(&config, Some(true)).expect("configured policy");
13900 assert_eq!(
13901 worker_policy.evaluate("example.com", "web_search"),
13902 crate::network_policy::Decision::Allow,
13903 "a network-capable role keeps the configured policy"
13904 );
13905 }
13906 #[test]
13907 fn hidden_remote_control_flag_starts_the_interactive_handoff() {
13908 let cli = parse_cli(&["codewhale-tui", "--remote-control"]);
13909 assert!(cli.remote_control);
13910 }
13911
13912 #[test]
13913 fn plugin_registry_discovery_is_route_independent_and_read_only() {
13914 let _env_lock = crate::test_support::lock_test_env();
13915 let temp = tempfile::tempdir().unwrap();
13916 let workspace = temp.path().join("workspace");
13917 let codewhale_home = temp.path().join("home");
13918 std::fs::create_dir_all(&workspace).unwrap();
13919 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
13920 let workspace_arg = workspace.to_string_lossy().into_owned();
13921
13922 for route in [
13923 Vec::<&str>::new(),
13924 vec!["resume", "--last"],
13925 vec!["fork", "--last"],
13926 vec!["exec", "hello"],
13927 vec!["serve", "--mcp"],
13928 ] {
13929 let mut args = vec![
13930 "codewhale-tui".to_string(),
13931 "--workspace".to_string(),
13932 workspace_arg.clone(),
13933 ];
13934 args.extend(route.into_iter().map(str::to_string));
13935 let cli = Cli::try_parse_from(args).expect("route should parse");
13936 let discovery = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv();
13937 let registry = discovery
13938 .registry_for_workspace(cli.workspace.as_deref().unwrap_or(workspace.as_path()));
13939 assert_eq!(registry.workspace(), workspace.as_path());
13940 assert!(
13941 !codewhale_home.join("plugins/state.json").exists(),
13942 "startup discovery must remain read-only"
13943 );
13944 }
13945 }
13946
13947 fn custom_exec_config(active: &str) -> Config {
13948 let mut custom = std::collections::HashMap::new();
13949 for (name, base_url, model) in [
13950 (
13951 "custom-a",
13952 "http://127.0.0.1:18181/v1",
13953 crate::config::ZAI_GLM_5_2_MODEL,
13954 ),
13955 ("custom-b", "http://127.0.0.1:18182/v1", "model-b"),
13956 ] {
13957 custom.insert(
13958 name.to_string(),
13959 crate::config::ProviderConfig {
13960 kind: Some("openai-compatible".to_string()),
13961 base_url: Some(base_url.to_string()),
13962 model: Some(model.to_string()),
13963 api_key: Some("local-test-key".to_string()),
13964 ..Default::default()
13965 },
13966 );
13967 }
13968 Config {
13969 provider: Some(active.to_string()),
13970 providers: Some(crate::config::ProvidersConfig {
13971 custom,
13972 ..Default::default()
13973 }),
13974 ..Default::default()
13975 }
13976 }
13977
13978 #[test]
13979 fn doctor_json_surfaces_keep_exact_named_custom_provider() {
13980 let config = custom_exec_config("custom-a");
13981 let workspace = tempfile::tempdir().expect("doctor workspace");
13982
13983 let operate = doctor_operate_fleet_report_json(&config, workspace.path());
13984 let provider_model = doctor_provider_model_report_json(&config);
13985 let capability = provider_capability_report(&config);
13986 let route = doctor_route_report(&config);
13987
13988 assert_eq!(operate["provider"]["id"], "custom-a");
13989 assert_eq!(provider_model["provider"]["id"], "custom-a");
13990 assert_eq!(capability["resolved_provider"], "custom-a");
13991 assert_eq!(route["provider"], "custom-a");
13992 assert_eq!(route["provider_config_table"], "providers.custom-a");
13993 let serialized = serde_json::to_string(&serde_json::json!({
13994 "operate": operate,
13995 "provider_model": provider_model,
13996 "capability": capability,
13997 "route": route,
13998 }))
13999 .expect("doctor JSON");
14000 assert!(!serialized.contains("local-test-key"));
14001 }
14002
14003 fn saved_exec_session(provider: &str, model: &str) -> session_manager::SavedSession {
14004 let mut saved = session_manager::create_saved_session_with_mode(
14005 &[],
14006 model,
14007 Path::new("/tmp/exec-resume"),
14008 0,
14009 None,
14010 Some("exec"),
14011 );
14012 let kind = crate::config::ApiProvider::parse(provider)
14013 .unwrap_or(crate::config::ApiProvider::Custom)
14014 .as_str();
14015 let exact_id = (!provider
14016 .eq_ignore_ascii_case(crate::config::ApiProvider::Custom.as_str()))
14017 .then_some(provider);
14018 saved.metadata.set_model_provider_route(kind, exact_id);
14019 saved
14020 }
14021
14022 #[test]
14023 fn prompt_flag_accepts_split_prompt_words_for_windows_cmd_shims() {
14024 let cli = parse_cli(&["codewhale", "-p", "hello", "world"]);
14025
14026 assert_eq!(cli.prompt, vec!["hello", "world"]);
14027 }
14028
14029 #[test]
14030 fn prompt_flag_starts_interactive_submit_input() {
14031 let cli = parse_cli(&["codewhale", "-p", "read", "the", "project"]);
14032
14033 assert_eq!(
14034 top_level_prompt_initial_input(&cli.prompt),
14035 Some(tui::InitialInput::Submit("read the project".to_string()))
14036 );
14037 }
14038
14039 #[test]
14040 fn companion_binary_reports_its_own_name() {
14041 assert_eq!(Cli::command().get_name(), "codewhale-tui");
14042 }
14043
14044 #[test]
14045 fn xai_device_auth_subcommand_parses() {
14046 let cli = parse_cli(&["codewhale-tui", "auth", "xai-device"]);
14047 assert!(matches!(
14048 cli.command,
14049 Some(Commands::Auth(TuiAuthArgs {
14050 command: TuiAuthCommand::XaiDevice
14051 }))
14052 ));
14053 }
14054
14055 #[test]
14056 fn workflow_tool_internal_subcommand_parses_exact_json() {
14057 let cli = parse_cli(&[
14058 "codewhale-tui",
14059 "workflow-tool",
14060 "--approval-source",
14061 "explicit-workflow-command",
14062 "--input-json",
14063 r#"{"action":"run","source_path":"workflows/demo.js"}"#,
14064 ]);
14065 let Some(Commands::WorkflowTool(args)) = cli.command else {
14066 panic!("expected workflow-tool command");
14067 };
14068 assert!(args.input_json.contains("\"action\":\"run\""));
14069 }
14070
14071 #[tokio::test]
14072 async fn direct_workflow_tool_runs_without_an_operator_model_turn() {
14073 use crate::tools::spec::ToolSpec;
14074
14075 let workspace = tempfile::tempdir().expect("workspace");
14076 let config = Config {
14077 provider: Some("vllm".to_string()),
14078 mcp_config_path: Some(
14079 workspace
14080 .path()
14081 .join("missing-mcp.json")
14082 .display()
14083 .to_string(),
14084 ),
14085 providers: Some(crate::config::ProvidersConfig {
14086 vllm: crate::config::ProviderConfig {
14087 base_url: Some("http://127.0.0.1:9/v1".to_string()),
14088 model: Some("offline-test-model".to_string()),
14089 ..Default::default()
14090 },
14091 ..Default::default()
14092 }),
14093 ..Default::default()
14094 };
14095 let route = CliAutoRoute {
14096 provider: crate::config::ApiProvider::Vllm,
14097 model: "offline-test-model".to_string(),
14098 reasoning_effort: None,
14099 auto_controls_reasoning: false,
14100 auto_model: false,
14101 };
14102 let (event_tx, mut event_rx) = tokio::sync::mpsc::channel(64);
14103 let plugins = Arc::new(crate::plugins::PluginRegistry::empty(workspace.path()));
14104 let (tool, context) =
14105 build_direct_workflow_tool(&config, &route, workspace.path(), event_tx, plugins)
14106 .await
14107 .expect("build direct workflow runtime");
14108
14109 let result = tool
14110 .execute(
14111 serde_json::json!({
14112 "action": "run",
14113 "script": "phase('offline'); return { ok: true };",
14114 "token_budget": 1_000_000
14115 }),
14116 &context,
14117 )
14118 .await
14119 .expect("model-free workflow run");
14120 let payload: serde_json::Value =
14121 serde_json::from_str(&result.content).expect("workflow JSON");
14122
14123 assert_eq!(payload["status"], "completed");
14124 assert_eq!(payload["result"]["ok"], true);
14125 assert_eq!(payload["child_ids"].as_array().map(Vec::len), Some(0));
14126 assert_eq!(
14127 payload["plan_approval"]["decision"],
14128 "approved_explicit_cli_command"
14129 );
14130 assert!(!context.auto_approve);
14131 assert!(!context.trust_mode);
14132 assert_eq!(
14133 context.shell_policy,
14134 crate::worker_profile::ShellPolicy::None
14135 );
14136 assert!(matches!(
14137 context.elevated_sandbox_policy,
14138 Some(crate::sandbox::SandboxPolicy::WorkspaceWrite { .. })
14139 ));
14140 let mut event_types = Vec::new();
14141 while let Ok(event) = event_rx.try_recv() {
14142 if let crate::core::events::Event::WorkflowUi { event, .. } = event
14143 && let Some(kind) = event["type"].as_str()
14144 {
14145 event_types.push(kind.to_string());
14146 }
14147 }
14148 assert!(event_types.iter().any(|kind| kind == "run_started"));
14149 assert!(event_types.iter().any(|kind| kind == "run_completed"));
14150 }
14151
14152 #[tokio::test]
14153 async fn direct_workflow_mcp_pool_applies_network_policy_before_connect() {
14154 let workspace = tempfile::tempdir().expect("workspace");
14155 let mcp_path = workspace.path().join("mcp.json");
14156 std::fs::write(
14157 &mcp_path,
14158 r#"{
14159 "mcpServers": {
14160 "blocked": { "url": "https://blocked.invalid/mcp" }
14161 }
14162 }"#,
14163 )
14164 .expect("write MCP config");
14165 let config = Config {
14166 mcp_config_path: Some(mcp_path.display().to_string()),
14167 ..Default::default()
14168 };
14169 let policy = crate::network_policy::NetworkPolicyDecider::new(
14170 crate::network_policy::NetworkPolicy {
14171 default: crate::network_policy::DecisionToml::Deny,
14172 allow: Vec::new(),
14173 deny: Vec::new(),
14174 proxy: Vec::new(),
14175 proxy_fake_ip_cidrs: Vec::new(),
14176 audit: false,
14177 },
14178 None,
14179 );
14180
14181 let plugins = Arc::new(crate::plugins::PluginRegistry::empty(workspace.path()));
14182 let (_pool, failures) =
14183 initialize_direct_workflow_mcp_pool(&config, workspace.path(), Some(policy), plugins)
14184 .await
14185 .expect("MCP feature enabled");
14186 assert_eq!(failures.len(), 1, "failures={failures:?}");
14187 assert_eq!(failures[0].0, "blocked");
14188 assert!(failures[0].1.contains("blocked by network policy"));
14189 }
14190
14191 #[test]
14192 fn exec_model_resolution_uses_provider_scoped_default() {
14193 let _env_lock = crate::test_support::lock_test_env();
14194 let _codewhale_model = crate::test_support::EnvVarGuard::remove("CODEWHALE_MODEL");
14195 let _deepseek_model = crate::test_support::EnvVarGuard::remove("DEEPSEEK_MODEL");
14196 let config = Config {
14197 provider: Some("openrouter".to_string()),
14198 default_text_model: Some("deepseek/deepseek-v4-pro".to_string()),
14199 providers: Some(crate::config::ProvidersConfig {
14200 openrouter: crate::config::ProviderConfig {
14201 model: Some("arcee-ai/trinity-large-thinking".to_string()),
14202 ..Default::default()
14203 },
14204 ..Default::default()
14205 }),
14206 ..Default::default()
14207 };
14208
14209 assert_eq!(
14210 resolve_exec_model(&config, None),
14211 "arcee-ai/trinity-large-thinking"
14212 );
14213 assert_eq!(
14214 resolve_exec_model(&config, Some("arcee-ai/trinity-large-thinking")),
14215 "arcee-ai/trinity-large-thinking"
14216 );
14217 }
14218
14219 #[test]
14220 fn exec_model_resolution_prefers_codewhale_model_env_override() {
14221 let _env_lock = crate::test_support::lock_test_env();
14222 let _codewhale_model = crate::test_support::EnvVarGuard::set("CODEWHALE_MODEL", " auto ");
14223 let _deepseek_model =
14224 crate::test_support::EnvVarGuard::set("DEEPSEEK_MODEL", "stale-deepseek-model");
14225 let config = Config {
14226 default_text_model: Some("deepseek/deepseek-v4-pro".to_string()),
14227 ..Default::default()
14228 };
14229
14230 assert_eq!(resolve_exec_model(&config, None), "auto");
14231 }
14232
14233 #[test]
14234 fn exec_model_resolution_uses_legacy_deepseek_model_env_override() {
14235 let _env_lock = crate::test_support::lock_test_env();
14236 let _codewhale_model = crate::test_support::EnvVarGuard::remove("CODEWHALE_MODEL");
14237 let _deepseek_model = crate::test_support::EnvVarGuard::set("DEEPSEEK_MODEL", " auto ");
14238 let config = Config {
14239 default_text_model: Some("deepseek/deepseek-v4-pro".to_string()),
14240 ..Default::default()
14241 };
14242
14243 assert_eq!(resolve_exec_model(&config, None), "auto");
14244 }
14245
14246 #[test]
14247 fn exec_model_resolution_uses_provider_safe_default_for_zai() {
14248 let _env_lock = crate::test_support::lock_test_env();
14249 let _codewhale_model = crate::test_support::EnvVarGuard::remove("CODEWHALE_MODEL");
14250 let _deepseek_model = crate::test_support::EnvVarGuard::remove("DEEPSEEK_MODEL");
14251 let config = Config {
14252 provider: Some("zai".to_string()),
14253 default_text_model: Some(crate::config::DEFAULT_TEXT_MODEL.to_string()),
14254 ..Default::default()
14255 };
14256
14257 assert_eq!(
14258 resolve_exec_model(&config, None),
14259 crate::config::DEFAULT_ZAI_MODEL
14260 );
14261 }
14262
14263 #[tokio::test]
14264 #[allow(clippy::await_holding_lock)]
14265 async fn explicit_exec_model_routes_to_unique_authenticated_provider_candidate() {
14266 let _env_lock = crate::test_support::lock_test_env();
14267 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
14268 let _openrouter = crate::test_support::EnvVarGuard::remove("OPENROUTER_API_KEY");
14269 let config = Config {
14270 provider: Some("deepseek".to_string()),
14271 default_text_model: Some(crate::config::DEFAULT_TEXT_MODEL.to_string()),
14272 ..Default::default()
14273 };
14274
14275 let route = resolve_cli_auto_route(&config, crate::config::ZAI_GLM_5_2_MODEL, "pong")
14276 .await
14277 .expect("explicit GLM should route to the configured Z.ai provider");
14278
14279 assert_eq!(route.provider, crate::config::ApiProvider::Zai);
14280 assert_eq!(route.model, crate::config::ZAI_GLM_5_2_MODEL);
14281 assert!(!route.auto_model);
14282 }
14283
14284 #[tokio::test]
14285 #[allow(clippy::await_holding_lock)]
14286 async fn explicit_exec_model_reports_ambiguous_authenticated_provider_candidates() {
14287 let _env_lock = crate::test_support::lock_test_env();
14288 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
14289 let _openrouter = crate::test_support::EnvVarGuard::set("OPENROUTER_API_KEY", "or-key");
14290 let config = Config {
14291 provider: Some("deepseek".to_string()),
14292 default_text_model: Some(crate::config::DEFAULT_TEXT_MODEL.to_string()),
14293 ..Default::default()
14294 };
14295
14296 let err = resolve_cli_auto_route(&config, crate::config::ZAI_GLM_5_2_MODEL, "pong")
14297 .await
14298 .expect_err("ambiguous GLM route should ask for an explicit provider");
14299 let message = err.to_string();
14300
14301 assert!(message.contains("model `GLM-5.2` is available"));
14302 assert!(message.contains("openrouter"));
14303 assert!(message.contains("zai"));
14304 assert!(message.contains("--provider"));
14305 assert!(message.contains("/provider"));
14306 assert!(message.contains("/model"));
14307 assert!(message.contains("/setup"));
14308 }
14309
14310 #[tokio::test]
14311 async fn cli_auto_model_honors_a_fixed_reasoning_preference() {
14312 let config = Config {
14313 provider: Some("vllm".to_string()),
14314 reasoning_effort: Some("low".to_string()),
14315 providers: Some(crate::config::ProvidersConfig {
14316 vllm: crate::config::ProviderConfig {
14317 base_url: Some("http://127.0.0.1:18190/v1".to_string()),
14318 model: Some("local-auto-model".to_string()),
14319 ..Default::default()
14320 },
14321 ..Default::default()
14322 }),
14323 ..Default::default()
14324 };
14325
14326 let route = resolve_cli_auto_route(&config, "auto", "debug a failing test")
14327 .await
14328 .expect("Auto model route");
14329
14330 assert!(route.auto_model);
14331 assert_eq!(
14332 route.reasoning_effort,
14333 Some(crate::tui::app::ReasoningEffort::Low)
14334 );
14335 assert!(
14336 !route.auto_controls_reasoning,
14337 "a fixed saved tier must not be replaced per prompt"
14338 );
14339 }
14340
14341 #[test]
14342 fn cli_route_execution_config_stamps_routed_model_into_provider_slot() {
14343 let mut providers = crate::config::ProvidersConfig::default();
14344 providers.deepseek.model = Some("deepseek-v4-pro".to_string());
14345 let config = Config {
14346 provider: Some("deepseek".to_string()),
14347 providers: Some(providers),
14348 ..Default::default()
14349 };
14350 let route = CliAutoRoute {
14351 provider: crate::config::ApiProvider::Deepseek,
14352 model: "deepseek-v4-flash".to_string(),
14353 reasoning_effort: None,
14354 auto_controls_reasoning: true,
14355 auto_model: true,
14356 };
14357
14358 let execution_config = config_for_cli_route(&config, &route);
14359
14360 assert_eq!(execution_config.default_model(), "deepseek-v4-flash");
14361 assert_eq!(
14362 execution_config
14363 .provider_config_for(crate::config::ApiProvider::Deepseek)
14364 .and_then(|entry| entry.model.as_deref()),
14365 Some("deepseek-v4-flash")
14366 );
14367 }
14368
14369 #[test]
14370 fn cli_route_execution_config_preserves_legacy_literal_custom_root_route() {
14371 let _lock = crate::test_support::lock_test_env();
14372 let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
14373 let _cli_key = crate::test_support::EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
14374 let config = Config {
14375 provider: Some("custom".to_string()),
14376 api_key: Some("legacy-root-key".to_string()),
14377 base_url: Some("http://127.0.0.1:18183/v1".to_string()),
14378 default_text_model: Some("legacy-model".to_string()),
14379 ..Default::default()
14380 };
14381 let route = CliAutoRoute {
14382 provider: crate::config::ApiProvider::Custom,
14383 model: "routed-legacy-model".to_string(),
14384 reasoning_effort: None,
14385 auto_controls_reasoning: false,
14386 auto_model: false,
14387 };
14388
14389 let execution = config_for_cli_route(&config, &route);
14390
14391 assert!(execution.uses_legacy_literal_custom_route());
14392 assert!(
14393 execution
14394 .providers
14395 .as_ref()
14396 .is_none_or(|providers| !providers.custom.contains_key("custom"))
14397 );
14398 assert_eq!(execution.provider.as_deref(), Some("custom"));
14399 assert_eq!(execution.default_model(), "routed-legacy-model");
14400 assert_eq!(execution.deepseek_base_url(), "http://127.0.0.1:18183/v1");
14401 assert_eq!(execution.deepseek_api_key().unwrap(), "legacy-root-key");
14402 for _ in 0..2 {
14403 let identity = execution
14404 .resolve_provider_identity("custom")
14405 .expect("legacy identity remains repeatedly resolvable");
14406 assert_eq!(identity.key, "custom");
14407 }
14408 let client =
14409 crate::client::DeepSeekClient::new(&execution).expect("legacy execution client");
14410 assert_eq!(client.base_url(), "http://127.0.0.1:18183/v1");
14411 }
14412
14413 #[test]
14414 fn exec_accepts_split_prompt_words_for_windows_cmd_shims() {
14415 let cli = parse_cli(&["codewhale", "exec", "hello", "world"]);
14416 let Some(Commands::Exec(args)) = cli.command else {
14417 panic!("expected exec command");
14418 };
14419
14420 assert_eq!(args.prompt, vec!["hello", "world"]);
14421 }
14422
14423 #[test]
14424 fn exec_keeps_model_flag_before_split_prompt_words() {
14425 let cli = parse_cli(&["codewhale", "exec", "--model", "auto", "hello", "world"]);
14426 let Some(Commands::Exec(args)) = cli.command else {
14427 panic!("expected exec command");
14428 };
14429
14430 assert_eq!(args.model.as_deref(), Some("auto"));
14431 assert_eq!(args.prompt, vec!["hello", "world"]);
14432 }
14433
14434 #[test]
14435 fn exec_keeps_flags_before_split_prompt_words() {
14436 let cli = parse_cli(&["codewhale", "exec", "--json", "hello", "world"]);
14437 let Some(Commands::Exec(args)) = cli.command else {
14438 panic!("expected exec command");
14439 };
14440
14441 assert!(args.json);
14442 assert_eq!(args.prompt, vec!["hello", "world"]);
14443 }
14444
14445 #[test]
14446 fn exec_parses_provider_flag_alongside_model() {
14447 let cli = parse_cli(&[
14450 "codewhale",
14451 "exec",
14452 "--provider",
14453 "openrouter",
14454 "--model",
14455 "glm-5.2",
14456 "audit",
14457 ]);
14458 let Some(Commands::Exec(args)) = cli.command else {
14459 panic!("expected exec command");
14460 };
14461
14462 assert_eq!(args.provider.as_deref(), Some("openrouter"));
14463 assert_eq!(args.model.as_deref(), Some("glm-5.2"));
14464 assert_eq!(args.prompt, vec!["audit"]);
14465 assert_eq!(
14468 crate::config::ApiProvider::parse(args.provider.as_deref().unwrap()),
14469 Some(crate::config::ApiProvider::Openrouter)
14470 );
14471 }
14472
14473 #[test]
14474 fn exec_provider_override_accepts_configured_custom_provider() {
14475 let mut custom = std::collections::HashMap::new();
14476 custom.insert(
14477 "lm-studio".to_string(),
14478 crate::config::ProviderConfig {
14479 kind: Some("openai-compatible".to_string()),
14480 base_url: Some("http://127.0.0.1:1234/v1".to_string()),
14481 model: Some("qwen-2.5-7b".to_string()),
14482 api_key: Some("lm-studio".to_string()),
14483 ..Default::default()
14484 },
14485 );
14486 let mut config = Config {
14487 provider: Some("deepseek".to_string()),
14488 providers: Some(crate::config::ProvidersConfig {
14489 custom,
14490 ..Default::default()
14491 }),
14492 ..Default::default()
14493 };
14494
14495 apply_exec_provider_override(&mut config, "lm-studio")
14496 .expect("configured custom provider should be accepted");
14497
14498 assert_eq!(config.provider.as_deref(), Some("lm-studio"));
14499 assert_eq!(config.api_provider(), crate::config::ApiProvider::Custom);
14500 }
14501
14502 #[test]
14503 fn exec_provider_override_prefers_exact_case_colliding_custom_key() {
14504 let mut config = Config {
14505 provider: Some("deepseek".to_string()),
14506 providers: Some(crate::config::ProvidersConfig {
14507 custom: std::collections::HashMap::from([(
14508 "CUSTOM".to_string(),
14509 crate::config::ProviderConfig {
14510 kind: Some("openai-compatible".to_string()),
14511 base_url: Some("http://127.0.0.1:5678/v1".to_string()),
14512 model: Some("case-model".to_string()),
14513 api_key: Some("case-key".to_string()),
14514 ..Default::default()
14515 },
14516 )]),
14517 ..Default::default()
14518 }),
14519 ..Default::default()
14520 };
14521
14522 apply_exec_provider_override(&mut config, "CUSTOM")
14523 .expect("exact case-colliding custom provider");
14524 assert_eq!(config.provider.as_deref(), Some("CUSTOM"));
14525 assert_eq!(config.api_provider(), crate::config::ApiProvider::Custom);
14526 assert_eq!(
14527 config.provider_identity_for(crate::config::ApiProvider::Custom),
14528 "CUSTOM"
14529 );
14530 let route = crate::route_runtime::resolve_runtime_route(
14531 &config,
14532 crate::config::ApiProvider::Custom,
14533 Some("case-model"),
14534 )
14535 .expect("resolve exact case-colliding route")
14536 .validate()
14537 .expect("preflight exact case-colliding route");
14538 assert_eq!(route.identity.key, "CUSTOM");
14539 assert_eq!(route.client.base_url(), "http://127.0.0.1:5678/v1");
14540 }
14541
14542 #[test]
14543 fn exec_provider_override_rejects_unknown_provider() {
14544 let mut config = Config {
14545 provider: Some("deepseek".to_string()),
14546 ..Default::default()
14547 };
14548
14549 let err = apply_exec_provider_override(&mut config, "lm-studio")
14550 .expect_err("unconfigured custom provider should fail closed");
14551 let message = err.to_string();
14552
14553 assert!(message.contains("Unrecognized --provider"));
14554 assert!(message.contains("[providers.<name>] custom provider"));
14555 assert_eq!(config.provider.as_deref(), Some("deepseek"));
14556 }
14557
14558 #[test]
14559 fn exec_resume_route_matrix_preserves_or_overrides_exact_provider_deliberately() {
14560 let saved = saved_exec_session("custom-a", crate::config::ZAI_GLM_5_2_MODEL);
14561
14562 let mut restored = custom_exec_config("custom-b");
14563 let model = resolve_exec_resume_route(&mut restored, &saved, false, None)
14564 .expect("plain resume restores saved route");
14565 assert_eq!(restored.provider.as_deref(), Some("custom-a"));
14566 assert_eq!(model, crate::config::ZAI_GLM_5_2_MODEL);
14567
14568 let mut explicit_provider = custom_exec_config("custom-a");
14569 apply_exec_provider_override(&mut explicit_provider, "custom-b").expect("custom B");
14570 let model = resolve_exec_resume_route(&mut explicit_provider, &saved, true, None)
14571 .expect("explicit provider wins");
14572 assert_eq!(explicit_provider.provider.as_deref(), Some("custom-b"));
14573 assert_eq!(model, "model-b");
14574
14575 let mut explicit_model = custom_exec_config("custom-b");
14576 let model =
14577 resolve_exec_resume_route(&mut explicit_model, &saved, false, Some("override-model"))
14578 .expect("explicit model keeps saved provider");
14579 assert_eq!(explicit_model.provider.as_deref(), Some("custom-a"));
14580 assert_eq!(model, "override-model");
14581
14582 let mut missing = custom_exec_config("custom-b");
14583 missing
14584 .providers
14585 .as_mut()
14586 .expect("providers")
14587 .custom
14588 .remove("custom-a");
14589 let before = missing.provider.clone();
14590 let err = resolve_exec_resume_route(&mut missing, &saved, false, None)
14591 .expect_err("removed saved provider must fail closed");
14592 assert!(err.to_string().contains("will not fall back"), "{err}");
14593 assert_eq!(missing.provider, before);
14594 }
14595
14596 #[test]
14597 fn exec_model_reads_wait_for_foreign_test_env_overrides_to_restore() {
14598 let (started_tx, started_rx) = std::sync::mpsc::channel();
14599 let (tx, rx) = std::sync::mpsc::channel();
14600
14601 let (reader, expected_after_restore) = {
14602 let lock = crate::test_support::lock_test_env();
14603 let expected_after_restore = exec_model_env_override();
14604 let temporary =
14605 crate::test_support::EnvVarGuard::set("CODEWHALE_MODEL", "temporary-model");
14606 let reader = std::thread::spawn(move || {
14607 started_tx.send(()).expect("signal model read start");
14608 tx.send(exec_model_env_override())
14609 .expect("send resolved model override");
14610 });
14611
14612 started_rx
14613 .recv_timeout(std::time::Duration::from_secs(2))
14614 .expect("reader reached model read");
14615 assert!(
14616 rx.recv_timeout(std::time::Duration::from_millis(50))
14617 .is_err(),
14618 "a foreign reader observed another test's temporary model override"
14619 );
14620 drop(temporary);
14621 drop(lock);
14622 (reader, expected_after_restore)
14623 };
14624
14625 let observed = rx
14626 .recv_timeout(std::time::Duration::from_secs(2))
14627 .expect("reader resumed after model override was restored");
14628 reader.join().expect("reader thread");
14629 assert_eq!(observed, expected_after_restore);
14630 }
14631
14632 #[tokio::test]
14633 async fn forced_exec_route_keeps_custom_provider_when_model_matches_builtin_catalog() {
14634 let config = custom_exec_config("custom-a");
14635
14636 let route =
14637 resolve_cli_exec_route(&config, crate::config::ZAI_GLM_5_2_MODEL, "audit", true)
14638 .await
14639 .expect("forced route");
14640 let execution = config_for_cli_route(&config, &route);
14641
14642 assert_eq!(route.provider, crate::config::ApiProvider::Custom);
14643 assert_eq!(route.model, crate::config::ZAI_GLM_5_2_MODEL);
14644 assert_eq!(execution.provider.as_deref(), Some("custom-a"));
14645 }
14646
14647 #[tokio::test]
14648 async fn no_flag_exec_keeps_configured_named_custom_route_for_matching_builtin_model() {
14649 let mut config = custom_exec_config("custom-a");
14650 config
14651 .providers
14652 .as_mut()
14653 .expect("providers")
14654 .custom
14655 .get_mut("custom-a")
14656 .expect("custom A")
14657 .model = Some(crate::config::ZAI_GLM_5_2_MODEL.to_string());
14658 let model = resolve_exec_model(&config, None);
14659 let force = should_force_configured_exec_route(false, None, None);
14660
14661 assert!(force, "configured/default exec route must be authoritative");
14662 assert!(!should_force_configured_exec_route(
14663 false,
14664 None,
14665 Some(crate::config::ZAI_GLM_5_2_MODEL)
14666 ));
14667 assert!(should_force_configured_exec_route(
14668 false,
14669 Some("custom-a"),
14670 Some(crate::config::ZAI_GLM_5_2_MODEL)
14671 ));
14672 assert!(should_force_configured_exec_route(
14673 true,
14674 None,
14675 Some("override-model")
14676 ));
14677
14678 let route = resolve_cli_exec_route(&config, &model, "audit", force)
14679 .await
14680 .expect("no-flag configured route");
14681 let execution = config_for_cli_route(&config, &route);
14682 assert_eq!(route.provider, crate::config::ApiProvider::Custom);
14683 assert_eq!(route.model, crate::config::ZAI_GLM_5_2_MODEL);
14684 assert_eq!(execution.provider.as_deref(), Some("custom-a"));
14685 }
14686
14687 #[tokio::test]
14688 async fn configured_review_default_keeps_named_custom_route_and_exact_receipt() {
14689 let mut config = custom_exec_config("custom-a");
14690 config
14691 .providers
14692 .as_mut()
14693 .expect("providers")
14694 .custom
14695 .get_mut("custom-a")
14696 .expect("custom A")
14697 .model = Some("model-a".to_string());
14698 config.default_text_model = Some("stale-root-deepseek-model".to_string());
14699 let model = resolve_review_model(&config, None);
14700 assert_eq!(model, "model-a");
14701 assert_eq!(
14702 resolve_review_model(&config, Some("explicit-review-model")),
14703 "explicit-review-model"
14704 );
14705
14706 let route = resolve_cli_exec_route(&config, &model, "review diff", true)
14707 .await
14708 .expect("configured review route");
14709 let execution = config_for_cli_route(&config, &route);
14710 let provider = execution.provider_identity_for(route.provider);
14711
14712 assert_eq!(route.provider, crate::config::ApiProvider::Custom);
14713 assert_eq!(provider, "custom-a");
14714 assert_eq!(execution.deepseek_base_url(), "http://127.0.0.1:18181/v1");
14715 let output = crate::tools::review::ReviewOutput::from_str("{}");
14716 let receipt = crate::tools::review::build_review_receipt(
14717 "working tree",
14718 "diff --git a/a b/a",
14719 provider,
14720 &route.model,
14721 &output,
14722 "{}",
14723 Vec::new(),
14724 );
14725 assert_eq!(receipt.provider, "custom-a");
14726 let serialized = serde_json::to_string(&receipt).expect("review receipt");
14727 assert!(!serialized.contains("127.0.0.1"));
14728 assert!(!serialized.contains("local-test-key"));
14729 }
14730
14731 #[tokio::test]
14732 async fn configured_workflow_default_keeps_named_custom_route() {
14733 let config = custom_exec_config("custom-a");
14734 let model = config.default_model();
14735
14736 let route = resolve_cli_exec_route(
14737 &config,
14738 &model,
14739 "Run a checked-in Workflow through the host runtime",
14740 true,
14741 )
14742 .await
14743 .expect("configured workflow route");
14744 let execution = config_for_cli_route(&config, &route);
14745
14746 assert_eq!(route.provider, crate::config::ApiProvider::Custom);
14747 assert_eq!(execution.provider_identity_for(route.provider), "custom-a");
14748 assert_eq!(execution.deepseek_base_url(), "http://127.0.0.1:18181/v1");
14749 let client = crate::client::DeepSeekClient::new(&execution).expect("workflow client");
14750 assert_eq!(client.base_url(), "http://127.0.0.1:18181/v1");
14751 }
14752
14753 #[test]
14754 fn exec_json_receipts_keep_exact_named_custom_provider() {
14755 let config = custom_exec_config("custom-a");
14756 let provider = config.provider_identity_for(crate::config::ApiProvider::Custom);
14757 let one_shot = one_shot_exec_json_receipt(
14758 provider.clone(),
14759 "model-a".to_string(),
14760 "done".to_string(),
14761 Some("end_turn".to_string()),
14762 crate::models::Usage {
14763 input_tokens: 12,
14764 output_tokens: 3,
14765 ..Default::default()
14766 },
14767 );
14768 assert_eq!(one_shot["provider"], "custom-a");
14769 assert_eq!(one_shot["success"], true);
14770
14771 let truncated = one_shot_exec_json_receipt(
14772 provider.clone(),
14773 "model-a".to_string(),
14774 "partial".to_string(),
14775 Some("max_output_tokens".to_string()),
14776 crate::models::Usage {
14777 input_tokens: 20,
14778 output_tokens: 9,
14779 ..Default::default()
14780 },
14781 );
14782 assert_eq!(truncated["success"], false);
14783 assert_eq!(truncated["stop_reason"], "max_output_tokens");
14784 assert_eq!(truncated["usage"]["input_tokens"], 20);
14785 assert_eq!(truncated["usage"]["output_tokens"], 9);
14786 assert!(truncated["error"].as_str().is_some_and(|error| {
14787 error.contains("Model response incomplete") && error.contains("max_output_tokens")
14788 }));
14789
14790 let agent = serde_json::to_value(ExecSummary {
14791 mode: "agent".to_string(),
14792 provider,
14793 model: "model-a".to_string(),
14794 ..ExecSummary::default()
14795 })
14796 .expect("agent exec JSON receipt");
14797 assert_eq!(agent["provider"], "custom-a");
14798 let serialized = serde_json::to_string(&agent).expect("serialize receipt");
14799 assert!(!serialized.contains("127.0.0.1"));
14800 assert!(!serialized.contains("local-test-key"));
14801 }
14802
14803 #[test]
14804 fn exec_stream_provider_pair_preserves_named_literal_and_root_custom_provenance() {
14805 let named = crate::config::ProviderIdentity {
14806 provider: crate::config::ApiProvider::Custom,
14807 key: "lm-studio".to_string(),
14808 exact_id: Some("lm-studio".to_string()),
14809 migrated_legacy_ollama_cloud_route: false,
14810 };
14811 let literal = crate::config::ProviderIdentity {
14812 provider: crate::config::ApiProvider::Custom,
14813 key: "custom".to_string(),
14814 exact_id: Some("custom".to_string()),
14815 migrated_legacy_ollama_cloud_route: false,
14816 };
14817 let root = crate::config::ProviderIdentity {
14818 provider: crate::config::ApiProvider::Custom,
14819 key: "custom".to_string(),
14820 exact_id: None,
14821 migrated_legacy_ollama_cloud_route: false,
14822 };
14823 let built_in = crate::config::ProviderIdentity {
14824 provider: crate::config::ApiProvider::Deepseek,
14825 key: "deepseek".to_string(),
14826 exact_id: Some("deepseek".to_string()),
14827 migrated_legacy_ollama_cloud_route: false,
14828 };
14829
14830 assert_eq!(
14831 exec_stream_provider_route(&named),
14832 ("custom".to_string(), Some("lm-studio".to_string()))
14833 );
14834 assert_eq!(
14835 exec_stream_provider_route(&literal),
14836 ("custom".to_string(), Some("custom".to_string()))
14837 );
14838 assert_eq!(
14839 exec_stream_provider_route(&root),
14840 ("custom".to_string(), None)
14841 );
14842 assert_eq!(
14843 exec_stream_provider_route(&built_in),
14844 ("deepseek".to_string(), None)
14845 );
14846 }
14847
14848 #[test]
14849 fn resumed_exec_persistence_updates_provider_and_model_as_one_route() {
14850 let saved_a = saved_exec_session("custom-a", crate::config::ZAI_GLM_5_2_MODEL);
14851 let mut config = custom_exec_config("custom-a");
14852 apply_exec_provider_override(&mut config, "custom-b").expect("custom B");
14853 let model = resolve_exec_resume_route(&mut config, &saved_a, true, None)
14854 .expect("explicit provider route");
14855 let mut persisted = saved_a;
14856 stamp_exec_session_metadata(
14857 &mut persisted,
14858 &model,
14859 crate::config::ApiProvider::Custom.as_str(),
14860 Some("custom-b"),
14861 Path::new("/tmp/exec-resume"),
14862 );
14863
14864 let mut next_config = custom_exec_config("custom-a");
14865 let resumed_model = resolve_exec_resume_route(&mut next_config, &persisted, false, None)
14866 .expect("next plain resume");
14867
14868 assert_eq!(persisted.metadata.model_provider, "custom");
14869 assert_eq!(
14870 persisted.metadata.model_provider_id.as_deref(),
14871 Some("custom-b")
14872 );
14873 assert_eq!(persisted.metadata.model, "model-b");
14874 assert_eq!(next_config.provider.as_deref(), Some("custom-b"));
14875 assert_eq!(resumed_model, "model-b");
14876 }
14877
14878 #[test]
14879 fn exec_persistence_omits_id_for_legacy_root_custom_route() {
14880 let mut saved = session_manager::create_saved_session_with_mode(
14881 &[],
14882 "legacy-root-model",
14883 Path::new("/tmp/exec-root"),
14884 0,
14885 None,
14886 Some("exec"),
14887 );
14888 stamp_exec_session_metadata(
14889 &mut saved,
14890 "legacy-root-model",
14891 crate::config::ApiProvider::Custom.as_str(),
14892 None,
14893 Path::new("/tmp/exec-root"),
14894 );
14895
14896 assert_eq!(saved.metadata.model_provider, "custom");
14897 assert_eq!(saved.metadata.model_provider_id, None);
14898 assert!(
14899 !serde_json::to_string(&saved)
14900 .expect("serialize exec session")
14901 .contains("model_provider_id")
14902 );
14903 }
14904
14905 #[test]
14906 fn exec_parses_reasoning_effort_flag_alongside_provider() {
14907 let cli = parse_cli(&[
14908 "codewhale",
14909 "exec",
14910 "--provider",
14911 "openrouter",
14912 "--model",
14913 "glm-5.2",
14914 "--reasoning-effort",
14915 "max",
14916 "audit",
14917 ]);
14918 let Some(Commands::Exec(args)) = cli.command else {
14919 panic!("expected exec command");
14920 };
14921
14922 assert_eq!(args.provider.as_deref(), Some("openrouter"));
14923 assert_eq!(args.model.as_deref(), Some("glm-5.2"));
14924 assert_eq!(args.reasoning_effort.as_deref(), Some("max"));
14925 assert_eq!(args.prompt, vec!["audit"]);
14926 }
14927
14928 #[test]
14929 fn cli_reasoning_effort_normalizes_aliases_and_rejects_typos() {
14930 assert_eq!(
14933 normalize_cli_reasoning_effort("xhigh").unwrap().as_deref(),
14934 Some("xhigh")
14935 );
14936 assert_eq!(
14937 normalize_cli_reasoning_effort("ultracode")
14938 .unwrap()
14939 .as_deref(),
14940 Some("ultra")
14941 );
14942 assert_eq!(normalize_cli_reasoning_effort("default").unwrap(), None);
14943 assert!(normalize_cli_reasoning_effort("expensive").is_err());
14944 }
14945
14946 #[test]
14947 fn cli_prompt_paths_resolve_auto_before_k3_route_normalization() {
14948 let config = Config {
14949 provider: Some("moonshot".to_string()),
14950 providers: Some(crate::config::ProvidersConfig {
14951 moonshot: crate::config::ProviderConfig {
14952 base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
14953 model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
14954 ..Default::default()
14955 },
14956 ..Default::default()
14957 }),
14958 ..Default::default()
14959 };
14960
14961 for (prompt, expected) in [
14962 ("lookup the public docs", "low"),
14963 ("debug this error", "max"),
14964 ("review this ordinary change", "high"),
14965 ] {
14966 assert_eq!(
14967 cli_reasoning_effort_value_for_prompt(
14968 &config,
14969 crate::config::KIMI_CODE_K3_MODEL,
14970 crate::tui::app::ReasoningEffort::Auto,
14971 prompt,
14972 )
14973 .as_deref(),
14974 Some(expected),
14975 "prompt selector must resolve Auto for `{prompt}`"
14976 );
14977 }
14978
14979 assert_eq!(
14980 cli_reasoning_effort_value_for_prompt(
14981 &config,
14982 crate::config::KIMI_CODE_K3_MODEL,
14983 crate::tui::app::ReasoningEffort::Off,
14984 "debug must not override an explicit effort",
14985 )
14986 .as_deref(),
14987 Some("low"),
14988 "membership K3 still applies its exact-route always-thinking floor"
14989 );
14990 }
14991
14992 #[test]
14993 fn cli_route_tracks_auto_reasoning_independently_from_auto_model() {
14994 use crate::tui::app::ReasoningEffort;
14995
14996 let fixed_model_auto_reasoning = CliAutoRoute {
14997 provider: crate::config::ApiProvider::Deepseek,
14998 model: crate::config::DEFAULT_TEXT_MODEL.to_string(),
14999 reasoning_effort: Some(ReasoningEffort::Auto),
15000 auto_controls_reasoning: true,
15001 auto_model: false,
15002 };
15003 let auto_model_fixed_reasoning = CliAutoRoute {
15004 provider: crate::config::ApiProvider::OpenaiCodex,
15005 model: crate::config::DEFAULT_OPENAI_CODEX_MODEL.to_string(),
15006 reasoning_effort: Some(ReasoningEffort::High),
15007 auto_controls_reasoning: false,
15008 auto_model: true,
15009 };
15010
15011 assert!(fixed_model_auto_reasoning.auto_controls_reasoning);
15012 assert!(!fixed_model_auto_reasoning.auto_model);
15013 assert!(!auto_model_fixed_reasoning.auto_controls_reasoning);
15014 assert!(auto_model_fixed_reasoning.auto_model);
15015 }
15016
15017 #[test]
15018 fn saved_reasoning_preference_overrides_config_for_non_tui_runtimes() {
15019 let mut config = Config {
15020 reasoning_effort: Some("max".to_string()),
15021 reasoning_effort_inferred_from_legacy_alias: true,
15022 ..Default::default()
15023 };
15024 let settings = crate::settings::Settings {
15025 reasoning_effort: Some("low".to_string()),
15026 ..Default::default()
15027 };
15028
15029 apply_saved_reasoning_preference(&mut config, &settings);
15030
15031 assert_eq!(config.reasoning_effort(), Some("low"));
15032 assert!(config.reasoning_effort_is_explicit());
15033 }
15034
15035 #[test]
15038 fn fixed_model_exec_auto_resolves_to_a_concrete_tier_not_the_auto_sentinel() {
15039 let config = Config {
15040 provider: Some("zai".to_string()),
15041 ..Default::default()
15042 };
15043
15044 let resolved = cli_reasoning_effort_value_for_prompt(
15045 &config,
15046 crate::config::ZAI_GLM_5_2_MODEL,
15047 crate::tui::app::ReasoningEffort::Auto,
15048 "debug this failing integration test",
15049 )
15050 .expect("Auto must resolve to a concrete tier");
15051
15052 assert_ne!(
15053 resolved, "auto",
15054 "the literal auto sentinel must never reach a provider"
15055 );
15056 assert!(
15057 matches!(resolved.as_str(), "off" | "low" | "medium" | "high" | "max"),
15058 "unexpected resolved tier: {resolved}"
15059 );
15060 }
15061
15062 #[test]
15063 fn exec_accepts_resume_session_flags_for_harnesses() {
15064 let cli = parse_cli(&[
15065 "codewhale",
15066 "exec",
15067 "--resume",
15068 "abc123",
15069 "--output-format",
15070 "stream-json",
15071 "follow up",
15072 ]);
15073 let Some(Commands::Exec(args)) = cli.command else {
15074 panic!("expected exec command");
15075 };
15076
15077 assert_eq!(args.resume.as_deref(), Some("abc123"));
15078 assert_eq!(args.output_format, ExecOutputFormat::StreamJson);
15079 assert_eq!(args.prompt, vec!["follow up"]);
15080 }
15081
15082 #[test]
15083 fn exec_accepts_session_id_alias() {
15084 let cli = parse_cli(&["codewhale", "exec", "--session-id", "abc123", "follow up"]);
15085 let Some(Commands::Exec(args)) = cli.command else {
15086 panic!("expected exec command");
15087 };
15088
15089 assert_eq!(args.session_id.as_deref(), Some("abc123"));
15090 assert_eq!(args.output_format, ExecOutputFormat::Text);
15091 }
15092
15093 #[test]
15094 fn exec_parses_tool_gate_and_hardening_flags() {
15095 let envelope = r#"{"schema_version":1,"owner":"fleet-worker-1","authority":"read_only"}"#;
15096 let cli = parse_cli(&[
15097 "codewhale",
15098 "exec",
15099 "--allowed-tools",
15100 "File,Git",
15101 "--disallowed-tools",
15102 "Bash",
15103 "--max-turns",
15104 "7",
15105 "--append-system-prompt",
15106 "extra rules",
15107 "--tool-authority-json",
15108 envelope,
15109 "do the thing",
15110 ]);
15111 let Some(Commands::Exec(args)) = cli.command else {
15112 panic!("expected exec command");
15113 };
15114
15115 assert_eq!(
15116 args.allowed_tools.as_deref(),
15117 Some(&["File".to_string(), "Git".to_string()][..])
15118 );
15119 assert_eq!(
15120 args.disallowed_tools.as_deref(),
15121 Some(&["Bash".to_string()][..])
15122 );
15123 assert_eq!(args.max_turns, Some(7));
15124 assert_eq!(args.append_system_prompt.as_deref(), Some("extra rules"));
15125 assert_eq!(args.tool_authority_json.as_deref(), Some(envelope));
15126 assert_eq!(args.prompt, vec!["do the thing"]);
15127 }
15128
15129 #[test]
15130 fn fleet_tool_authority_cannot_cross_an_exec_resume_boundary() {
15131 assert!(validate_exec_tool_authority_resume(None, true).is_ok());
15132 assert!(validate_exec_tool_authority_resume(Some("{}"), false).is_ok());
15133 let error = validate_exec_tool_authority_resume(Some("{}"), true)
15134 .expect_err("authority must remain bound to its fresh Fleet launch")
15135 .to_string();
15136 assert!(error.contains("cannot be combined with exec --resume"));
15137 }
15138
15139 #[test]
15140 fn exec_auto_does_not_authorize_sandbox_elevation() {
15141 let cli = parse_cli(&["codewhale", "exec", "--auto", "run it"]);
15142 let Some(Commands::Exec(args)) = cli.command else {
15143 panic!("expected exec command");
15144 };
15145
15146 assert!(!exec_sandbox_elevation_authorized(
15147 args.allow_sandbox_elevation,
15148 args.sandbox.as_deref()
15149 ));
15150 }
15151
15152 #[test]
15153 fn exec_explicit_sandbox_elevation_opt_ins_authorize_retry() {
15154 let danger = parse_cli(&[
15155 "codewhale",
15156 "exec",
15157 "--auto",
15158 "--sandbox",
15159 "danger-full-access",
15160 "run it",
15161 ]);
15162 let Some(Commands::Exec(args)) = danger.command else {
15163 panic!("expected exec command");
15164 };
15165 assert!(exec_sandbox_elevation_authorized(
15166 args.allow_sandbox_elevation,
15167 args.sandbox.as_deref()
15168 ));
15169
15170 let flag = parse_cli(&[
15171 "codewhale",
15172 "exec",
15173 "--auto",
15174 "--allow-sandbox-elevation",
15175 "run it",
15176 ]);
15177 let Some(Commands::Exec(args)) = flag.command else {
15178 panic!("expected exec command");
15179 };
15180 assert!(exec_sandbox_elevation_authorized(
15181 args.allow_sandbox_elevation,
15182 args.sandbox.as_deref()
15183 ));
15184 }
15185
15186 #[test]
15187 fn exec_sandbox_denial_stream_event_is_typed() {
15188 let event = ExecStreamEvent::SandboxDenied {
15189 tool_id: "call_1".to_string(),
15190 tool_name: "exec_shell".to_string(),
15191 reason: "write blocked".to_string(),
15192 outcome: "approval_required".to_string(),
15193 };
15194 let value: serde_json::Value =
15195 serde_json::from_str(&serde_json::to_string(&event).expect("serializes"))
15196 .expect("valid json");
15197 assert_eq!(value["type"], "sandbox_denied");
15198 assert_eq!(value["outcome"], "approval_required");
15199 }
15200
15201 #[test]
15202 fn exec_help_separates_agent_mode_from_sandbox_elevation() {
15203 let mut cli = Cli::command();
15204 let help = cli
15205 .find_subcommand_mut("exec")
15206 .expect("exec command")
15207 .render_help()
15208 .to_string();
15209 assert!(help.contains("--auto"));
15210 assert!(help.contains("--sandbox"));
15211 assert!(help.contains("--allow-sandbox-elevation"));
15212 assert!(help.contains("does not change the"));
15213 assert!(help.contains("explicitly authorize sandbox elevation"));
15214 }
15215
15216 #[test]
15217 fn exec_shell_only_tool_surface_env_sets_shell_allowlist() {
15218 let _env_lock = crate::test_support::lock_test_env();
15219 let _surface =
15220 crate::test_support::EnvVarGuard::set(CODEWHALE_TOOL_SURFACE_ENV, " shell-only ");
15221
15222 let allowed_tools = resolve_exec_allowed_tools(None, exec_tool_surface_from_env())
15223 .expect("shell-only surface should set an allowlist");
15224
15225 assert_eq!(allowed_tools, vec!["bash".to_string()]);
15226 }
15227
15228 #[test]
15229 fn exec_explicit_allowed_tools_override_shell_only_env() {
15230 let _env_lock = crate::test_support::lock_test_env();
15231 let _surface =
15232 crate::test_support::EnvVarGuard::set(CODEWHALE_TOOL_SURFACE_ENV, "shell-only");
15233 let explicit = vec![" File ".to_string(), "GIT".to_string()];
15234
15235 let allowed_tools =
15236 resolve_exec_allowed_tools(Some(&explicit), exec_tool_surface_from_env())
15237 .expect("explicit allowlist should be preserved");
15238
15239 assert_eq!(allowed_tools, vec!["file".to_string(), "git".to_string()]);
15240 }
15241
15242 #[test]
15243 fn exec_full_tool_surface_env_leaves_allowlist_unset() {
15244 let _env_lock = crate::test_support::lock_test_env();
15245 let _surface = crate::test_support::EnvVarGuard::set(CODEWHALE_TOOL_SURFACE_ENV, "full");
15246
15247 assert_eq!(
15248 resolve_exec_allowed_tools(None, exec_tool_surface_from_env()),
15249 None
15250 );
15251 }
15252
15253 #[test]
15254 fn exec_unknown_tool_surface_env_warns_without_allowlist() {
15255 assert!(should_warn_unknown_exec_tool_surface("shell_onyl"));
15256 assert!(!should_warn_unknown_exec_tool_surface("shell-only"));
15257 assert!(!should_warn_unknown_exec_tool_surface("native-tools"));
15258 assert!(!should_warn_unknown_exec_tool_surface("full"));
15259 assert!(!should_warn_unknown_exec_tool_surface(" "));
15260 assert_eq!(parse_exec_tool_surface("shell_onyl"), None);
15261 }
15262
15263 #[test]
15264 fn exec_rejects_zero_max_turns() {
15265 let err = Cli::try_parse_from(["codewhale", "exec", "--max-turns", "0", "hello"])
15266 .expect_err("max-turns must be >= 1");
15267 assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation);
15268 }
15269
15270 #[test]
15271 fn exec_omits_the_headless_turn_cap_by_default() {
15272 let cli = parse_cli(&["codewhale", "exec", "--auto", "benchmark this"]);
15273 let Some(Commands::Exec(args)) = cli.command else {
15274 panic!("expected exec command");
15275 };
15276
15277 assert_eq!(args.max_turns, None);
15278 assert_eq!(exec_max_steps(args.max_turns), u32::MAX);
15279 assert_eq!(exec_max_steps(Some(7)), 7);
15280 }
15281
15282 #[test]
15283 fn exec_accepts_continue_for_latest_workspace_session() {
15284 let cli = parse_cli(&["codewhale", "exec", "--continue", "follow up"]);
15285 let Some(Commands::Exec(args)) = cli.command else {
15286 panic!("expected exec command");
15287 };
15288
15289 assert!(args.continue_session);
15290 }
15291
15292 #[test]
15293 fn sessions_footer_points_to_resume_subcommand() {
15294 let cli = parse_cli(&["codewhale", "resume", "abc123"]);
15295 let Some(Commands::Resume { session_id, last }) = cli.command else {
15296 panic!("expected resume command");
15297 };
15298
15299 assert_eq!(session_id.as_deref(), Some("abc123"));
15300 assert!(!last);
15301 assert_eq!(sessions_resume_command(), "codewhale resume");
15302 assert!(!sessions_resume_command().contains("--resume"));
15303 }
15304
15305 #[test]
15306 fn plugin_registry_initialization_precedes_dotenv_for_all_launch_paths() {
15307 use std::cell::Cell;
15308
15309 #[derive(Clone, Copy)]
15310 enum Expected {
15311 Plain,
15312 Resume,
15313 Fork,
15314 Exec,
15315 Serve,
15316 }
15317
15318 let cases: &[(&[&str], Expected)] = &[
15319 (&["codewhale"], Expected::Plain),
15320 (&["codewhale", "resume", "--last"], Expected::Resume),
15321 (&["codewhale", "fork", "--last"], Expected::Fork),
15322 (&["codewhale", "exec", "probe"], Expected::Exec),
15323 (&["codewhale", "serve", "--mcp"], Expected::Serve),
15324 ];
15325
15326 for (args, expected) in cases {
15327 let phase = Cell::new(0);
15328 let (_cli, command) = prepare_cli_startup(
15329 parse_cli(args),
15330 || {
15331 assert_eq!(phase.get(), 0, "plugin init order for {args:?}");
15332 phase.set(1);
15333 },
15334 || {
15335 assert_eq!(phase.get(), 1, "dotenv load order for {args:?}");
15336 phase.set(2);
15337 },
15338 );
15339
15340 assert_eq!(phase.get(), 2, "startup phases for {args:?}");
15341 let correct_variant = matches!(
15342 (expected, command.as_ref()),
15343 (Expected::Plain, None)
15344 | (Expected::Resume, Some(Commands::Resume { .. }))
15345 | (Expected::Fork, Some(Commands::Fork { .. }))
15346 | (Expected::Exec, Some(Commands::Exec(_)))
15347 | (Expected::Serve, Some(Commands::Serve(_)))
15348 );
15349 assert!(correct_variant, "unexpected command for {args:?}");
15350 }
15351 }
15352
15353 #[test]
15354 fn workspace_dotenv_loads_only_provider_credentials_and_preserves_shell_values() {
15355 let _lock = crate::test_support::lock_test_env();
15356 let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
15357 let _nvidia = crate::test_support::EnvVarGuard::set("NVIDIA_API_KEY", "shell-key");
15358 let _home = crate::test_support::EnvVarGuard::remove("CODEWHALE_HOME");
15359 let _config = crate::test_support::EnvVarGuard::remove("CODEWHALE_CONFIG_PATH");
15360 let _shell = crate::test_support::EnvVarGuard::remove("DEEPSEEK_ALLOW_SHELL");
15361 let tmp = tempfile::TempDir::new().expect("temp workspace");
15362 let dotenv = tmp.path().join(".env");
15363 std::fs::write(
15364 &dotenv,
15365 "DEEPSEEK_API_KEY=workspace-key\n\
15366 NVIDIA_API_KEY=repo-must-not-override-shell\n\
15367 CODEWHALE_HOME=./attacker-home\n\
15368 CODEWHALE_CONFIG_PATH=./attacker.toml\n\
15369 DEEPSEEK_ALLOW_SHELL=true\n",
15370 )
15371 .expect("write dotenv");
15372
15373 let report = load_workspace_dotenv_credentials_from_path(&dotenv).expect("safe load");
15374
15375 assert_eq!(
15376 std::env::var("DEEPSEEK_API_KEY").as_deref(),
15377 Ok("workspace-key")
15378 );
15379 assert_eq!(std::env::var("NVIDIA_API_KEY").as_deref(), Ok("shell-key"));
15380 assert!(std::env::var_os("CODEWHALE_HOME").is_none());
15381 assert!(std::env::var_os("CODEWHALE_CONFIG_PATH").is_none());
15382 assert!(std::env::var_os("DEEPSEEK_ALLOW_SHELL").is_none());
15383 assert_eq!(
15384 report.loaded,
15385 BTreeSet::from(["DEEPSEEK_API_KEY".to_string()])
15386 );
15387 assert_eq!(
15388 report.ignored,
15389 BTreeSet::from([
15390 "CODEWHALE_CONFIG_PATH".to_string(),
15391 "CODEWHALE_HOME".to_string(),
15392 "DEEPSEEK_ALLOW_SHELL".to_string(),
15393 ])
15394 );
15395 }
15396
15397 #[test]
15398 fn workspace_dotenv_rejects_ambient_variable_substitution() {
15399 let _lock = crate::test_support::lock_test_env();
15400 let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
15401 let _ambient = crate::test_support::EnvVarGuard::set(
15402 "CODEWHALE_JS_SECRET_LEAK_TEST",
15403 "ambient-secret-must-not-expand",
15404 );
15405 let tmp = tempfile::TempDir::new().expect("temp workspace");
15406 let dotenv = tmp.path().join(".env");
15407 std::fs::write(
15408 &dotenv,
15409 "DEEPSEEK_API_KEY=${CODEWHALE_JS_SECRET_LEAK_TEST}\n",
15410 )
15411 .expect("write dotenv");
15412
15413 let error = load_workspace_dotenv_credentials_from_path(&dotenv)
15414 .expect_err("expansion must fail closed")
15415 .to_string();
15416
15417 assert!(error.contains("variable expansion"));
15418 assert!(!error.contains("ambient-secret-must-not-expand"));
15419 assert!(std::env::var_os("DEEPSEEK_API_KEY").is_none());
15420 }
15421
15422 #[test]
15423 fn workspace_dotenv_rejects_multiline_ambient_variable_substitution() {
15424 let _lock = crate::test_support::lock_test_env();
15425 let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
15426 let _ambient = crate::test_support::EnvVarGuard::set(
15427 "CODEWHALE_JS_SECRET_LEAK_TEST",
15428 "ambient-secret-must-not-expand",
15429 );
15430 let tmp = tempfile::TempDir::new().expect("temp workspace");
15431 let dotenv = tmp.path().join(".env");
15432 std::fs::write(
15433 &dotenv,
15434 "DEEPSEEK_API_KEY=\"prefix\n$CODEWHALE_JS_SECRET_LEAK_TEST=bar\nsuffix\"\n",
15435 )
15436 .expect("write dotenv");
15437
15438 let error = load_workspace_dotenv_credentials_from_path(&dotenv)
15439 .expect_err("multiline expansion must fail closed")
15440 .to_string();
15441
15442 assert!(error.contains("variable expansion"));
15443 assert!(!error.contains("ambient-secret-must-not-expand"));
15444 assert!(std::env::var_os("DEEPSEEK_API_KEY").is_none());
15445 }
15446
15447 #[test]
15448 fn workspace_dotenv_comment_quote_cannot_hide_later_expansion() {
15449 let _lock = crate::test_support::lock_test_env();
15450 let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
15451 let _ambient = crate::test_support::EnvVarGuard::set(
15452 "CODEWHALE_JS_SECRET_LEAK_TEST",
15453 "ambient-secret-must-not-expand",
15454 );
15455 let tmp = tempfile::TempDir::new().expect("temp workspace");
15456 let dotenv = tmp.path().join(".env");
15457 std::fs::write(
15458 &dotenv,
15459 "# unmatched quote in ignored comment: '\n\
15460 DEEPSEEK_API_KEY=$CODEWHALE_JS_SECRET_LEAK_TEST\n",
15461 )
15462 .expect("write dotenv");
15463
15464 let error = load_workspace_dotenv_credentials_from_path(&dotenv)
15465 .expect_err("comment quote must not hide expansion")
15466 .to_string();
15467
15468 assert!(error.contains("variable expansion"));
15469 assert!(!error.contains("ambient-secret-must-not-expand"));
15470 assert!(std::env::var_os("DEEPSEEK_API_KEY").is_none());
15471 }
15472
15473 #[test]
15474 fn workspace_dotenv_allows_single_quoted_literal_dollar() {
15475 let _lock = crate::test_support::lock_test_env();
15476 let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
15477 let tmp = tempfile::TempDir::new().expect("temp workspace");
15478 let dotenv = tmp.path().join(".env");
15479 std::fs::write(&dotenv, "DEEPSEEK_API_KEY='$literal-value'\n").expect("write dotenv");
15480
15481 load_workspace_dotenv_credentials_from_path(&dotenv).expect("literal dollar load");
15482
15483 assert_eq!(
15484 std::env::var("DEEPSEEK_API_KEY").as_deref(),
15485 Ok("$literal-value")
15486 );
15487 }
15488
15489 #[test]
15490 fn workspace_dotenv_parse_failure_applies_no_earlier_credentials() {
15491 let _lock = crate::test_support::lock_test_env();
15492 let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
15493 let tmp = tempfile::TempDir::new().expect("temp workspace");
15494 let dotenv = tmp.path().join(".env");
15495 std::fs::write(
15496 &dotenv,
15497 "DEEPSEEK_API_KEY=must-not-survive\nBROKEN=\"unterminated\n",
15498 )
15499 .expect("write dotenv");
15500
15501 let error = load_workspace_dotenv_credentials_from_path(&dotenv)
15502 .expect_err("parse failure must be transactional")
15503 .to_string();
15504
15505 assert!(error.contains("could not be parsed safely"), "{error}");
15506 assert!(!error.contains("must-not-survive"));
15507 assert!(std::env::var_os("DEEPSEEK_API_KEY").is_none());
15508 }
15509
15510 #[test]
15511 fn workspace_dotenv_credential_allowlist_excludes_control_plane_names() {
15512 for provider in codewhale_config::provider::providers_sorted_for_display() {
15513 for key in provider.env_vars() {
15514 assert!(
15515 is_workspace_dotenv_credential_key(key),
15516 "provider credential {key} must remain supported"
15517 );
15518 }
15519 }
15520 for key in [
15521 "CODEWHALE_HOME",
15522 "CODEWHALE_CONFIG_PATH",
15523 "DEEPSEEK_CONFIG_PATH",
15524 "DEEPSEEK_PROFILE",
15525 "DEEPSEEK_MANAGED_CONFIG_PATH",
15526 "DEEPSEEK_REQUIREMENTS_PATH",
15527 "DEEPSEEK_PROVIDER",
15528 "DEEPSEEK_BASE_URL",
15529 "DEEPSEEK_MODEL",
15530 "DEEPSEEK_APPROVAL_POLICY",
15531 "DEEPSEEK_SANDBOX_MODE",
15532 "DEEPSEEK_ALLOW_SHELL",
15533 "DEEPSEEK_YOLO",
15534 "DEEPSEEK_MCP_CONFIG",
15535 "CODEWHALE_RUNTIME_TOKEN",
15536 "PATH",
15537 "NODE_OPTIONS",
15538 "PYTHONPATH",
15539 "LD_PRELOAD",
15540 "DYLD_INSERT_LIBRARIES",
15541 ] {
15542 assert!(
15543 !is_workspace_dotenv_credential_key(key),
15544 "control-plane variable {key} must not load from a workspace"
15545 );
15546 }
15547 }
15548
15549 #[cfg(unix)]
15550 #[test]
15551 fn workspace_dotenv_does_not_follow_symbolic_links() {
15552 use std::os::unix::fs::symlink;
15553
15554 let tmp = tempfile::TempDir::new().expect("temp workspace");
15555 let external = tmp.path().join("external-credentials");
15556 let dotenv = tmp.path().join(".env");
15557 std::fs::write(&external, "DEEPSEEK_API_KEY=external-secret\n")
15558 .expect("write external fixture");
15559 symlink(&external, &dotenv).expect("create dotenv symlink");
15560
15561 let error = load_workspace_dotenv_credentials_from_path(&dotenv)
15562 .expect_err("symlink must fail closed")
15563 .to_string();
15564
15565 assert!(error.contains("securely open"), "{error}");
15566 assert!(!error.contains("external-secret"));
15567 }
15568
15569 #[cfg(unix)]
15570 #[test]
15571 fn workspace_dotenv_rejects_hard_links_to_external_files() {
15572 let tmp = tempfile::TempDir::new().expect("temp workspace");
15573 let external = tmp.path().join("external-credentials");
15574 let dotenv = tmp.path().join(".env");
15575 std::fs::write(&external, "DEEPSEEK_API_KEY=external-secret\n")
15576 .expect("write external fixture");
15577 std::fs::hard_link(&external, &dotenv).expect("create dotenv hard link");
15578
15579 let error = load_workspace_dotenv_credentials_from_path(&dotenv)
15580 .expect_err("hard link must fail closed")
15581 .to_string();
15582
15583 assert!(error.contains("multiple filesystem links"), "{error}");
15584 assert!(!error.contains("external-secret"));
15585 }
15586
15587 #[cfg(unix)]
15588 #[test]
15589 fn workspace_dotenv_rejects_fifo_without_blocking_startup() {
15590 use std::ffi::CString;
15591 use std::os::unix::ffi::OsStrExt;
15592 use std::sync::mpsc;
15593 use std::time::Duration;
15594
15595 let tmp = tempfile::TempDir::new().expect("temp workspace");
15596 let dotenv = tmp.path().join(".env");
15597 let c_path = CString::new(dotenv.as_os_str().as_bytes()).expect("fifo path");
15598 let result = unsafe { libc::mkfifo(c_path.as_ptr(), libc::S_IRUSR | libc::S_IWUSR) };
15601 assert_eq!(result, 0, "mkfifo failed: {}", io::Error::last_os_error());
15602
15603 let (tx, rx) = mpsc::channel();
15604 let worker_path = dotenv.clone();
15605 let worker = std::thread::spawn(move || {
15606 let result = load_workspace_dotenv_credentials_from_path(&worker_path)
15607 .map(|_| "unexpected success".to_string())
15608 .unwrap_or_else(|error| error.to_string());
15609 tx.send(result).expect("send loader result");
15610 });
15611
15612 let error = match rx.recv_timeout(Duration::from_secs(1)) {
15613 Ok(error) => error,
15614 Err(timeout) => {
15615 let _writer = std::fs::OpenOptions::new()
15618 .write(true)
15619 .open(&dotenv)
15620 .expect("open fifo writer to release blocked reader");
15621 let _ = rx.recv_timeout(Duration::from_secs(1));
15622 worker.join().expect("join released loader");
15623 panic!("workspace .env FIFO blocked startup: {timeout}");
15624 }
15625 };
15626 worker.join().expect("join loader");
15627
15628 assert!(error.contains("not a regular file"), "{error}");
15629 }
15630
15631 #[test]
15632 fn exec_json_conflicts_with_stream_json_output() {
15633 let err = Cli::try_parse_from([
15634 "codewhale",
15635 "exec",
15636 "--json",
15637 "--output-format",
15638 "stream-json",
15639 "hello",
15640 ])
15641 .expect_err("json summary and stream-json must not mix");
15642
15643 assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
15644 }
15645
15646 #[test]
15647 fn exec_stream_turn_usage_event_serializes_reported_fields() {
15648 let event = ExecStreamEvent::TurnUsage {
15649 turn: 2,
15650 input_tokens: 1200,
15651 output_tokens: 180,
15652 reasoning_tokens: Some(90),
15653 prompt_cache_hit_tokens: Some(900),
15654 prompt_cache_miss_tokens: Some(300),
15655 prompt_cache_write_tokens: Some(0),
15656 reasoning_replay_tokens: Some(40),
15657 duration_ms: 1834,
15658 };
15659
15660 let value = exec_stream_value(&event).expect("serializes");
15661 let json = serde_json::to_string(&value).expect("serializes");
15662 assert!(!json.contains('\n'));
15663 let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json");
15664 assert_eq!(parsed["type"], "turn_usage");
15665 assert_eq!(parsed["schema"], "codewhale.exec-stream");
15666 assert_eq!(parsed["schema_version"], 1);
15667 assert_eq!(parsed["turn"], 2);
15668 assert_eq!(parsed["input_tokens"], 1200);
15669 assert_eq!(parsed["output_tokens"], 180);
15670 assert_eq!(parsed["reasoning_tokens"], 90);
15671 assert_eq!(parsed["prompt_cache_hit_tokens"], 900);
15672 assert_eq!(parsed["prompt_cache_miss_tokens"], 300);
15673 assert_eq!(parsed["prompt_cache_write_tokens"], 0);
15674 assert_eq!(parsed["reasoning_replay_tokens"], 40);
15675 assert_eq!(parsed["duration_ms"], 1834);
15676 }
15677
15678 #[test]
15679 fn exec_stream_turn_usage_event_omits_unreported_fields() {
15680 let event = ExecStreamEvent::TurnUsage {
15684 turn: 1,
15685 input_tokens: 11,
15686 output_tokens: 3,
15687 reasoning_tokens: None,
15688 prompt_cache_hit_tokens: None,
15689 prompt_cache_miss_tokens: None,
15690 prompt_cache_write_tokens: None,
15691 reasoning_replay_tokens: None,
15692 duration_ms: 250,
15693 };
15694
15695 let value = exec_stream_value(&event).expect("serializes");
15696 let parsed = value;
15697 assert_eq!(parsed["type"], "turn_usage");
15698 assert_eq!(parsed["input_tokens"], 11);
15699 assert_eq!(parsed["output_tokens"], 3);
15700 assert_eq!(parsed["duration_ms"], 250);
15701 let object = parsed.as_object().expect("event object");
15702 for absent in [
15703 "reasoning_tokens",
15704 "prompt_cache_hit_tokens",
15705 "prompt_cache_miss_tokens",
15706 "prompt_cache_write_tokens",
15707 "reasoning_replay_tokens",
15708 ] {
15709 assert!(!object.contains_key(absent), "{absent} leaked: {parsed}");
15710 }
15711 }
15712
15713 #[test]
15714 fn exec_stream_pre_existing_event_type_tags_are_unchanged() {
15715 let cases: Vec<(ExecStreamEvent, &str)> = vec![
15718 (
15719 ExecStreamEvent::Content {
15720 content: "hi".to_string(),
15721 },
15722 "content",
15723 ),
15724 (
15725 ExecStreamEvent::ToolUse {
15726 name: "read_file".to_string(),
15727 id: "call_1".to_string(),
15728 input: serde_json::json!({}),
15729 started_at: "2026-08-03T00:00:00Z".to_string(),
15730 },
15731 "tool_use",
15732 ),
15733 (
15734 ExecStreamEvent::ToolResult {
15735 id: "call_1".to_string(),
15736 name: "read_file".to_string(),
15737 output: "ok".to_string(),
15738 status: "success".to_string(),
15739 started_at: "2026-08-03T00:00:00Z".to_string(),
15740 completed_at: "2026-08-03T00:00:01Z".to_string(),
15741 duration_ms: 1,
15742 side_effect_status: "unknown".to_string(),
15743 error_category: None,
15744 truncated: None,
15745 artifact: None,
15746 result_metadata: None,
15747 },
15748 "tool_result",
15749 ),
15750 (
15751 ExecStreamEvent::SandboxDenied {
15752 tool_id: "call_1".to_string(),
15753 tool_name: "exec_shell".to_string(),
15754 reason: "denied".to_string(),
15755 outcome: "approval_required".to_string(),
15756 },
15757 "sandbox_denied",
15758 ),
15759 (
15760 ExecStreamEvent::WorkflowEvent {
15761 run_id: "workflow_1".to_string(),
15762 event: serde_json::json!({"type": "task_completed"}),
15763 },
15764 "workflow_event",
15765 ),
15766 (
15767 ExecStreamEvent::SessionCapture {
15768 content: "x".to_string(),
15769 },
15770 "session_capture",
15771 ),
15772 (
15773 ExecStreamEvent::Error {
15774 error: "boom".to_string(),
15775 },
15776 "error",
15777 ),
15778 (ExecStreamEvent::Done, "done"),
15779 ];
15780
15781 for (event, expected_type) in cases {
15782 let value = exec_stream_value(&event).expect("serializes");
15783 assert_eq!(value["type"], expected_type, "event tag drifted");
15784 assert_eq!(value["schema"], "codewhale.exec-stream");
15785 assert_eq!(value["schema_version"], 1);
15786 }
15787 }
15788
15789 #[test]
15790 fn exec_stream_events_are_json_lines() {
15791 let event = ExecStreamEvent::ToolResult {
15792 id: "call_1".to_string(),
15793 name: "read_file".to_string(),
15794 output: "line 1\nline 2".to_string(),
15795 status: "success".to_string(),
15796 started_at: "2026-07-13T00:00:00Z".to_string(),
15797 completed_at: "2026-07-13T00:00:01Z".to_string(),
15798 duration_ms: 1000,
15799 side_effect_status: "not_started".to_string(),
15800 error_category: None,
15801 truncated: Some(false),
15802 artifact: None,
15803 result_metadata: None,
15804 };
15805
15806 let value = exec_stream_value(&event).expect("serializes");
15807 let json = serde_json::to_string(&value).expect("serializes");
15808 assert!(!json.contains('\n'));
15809 let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json");
15810 assert_eq!(parsed["type"], "tool_result");
15811 assert_eq!(parsed["schema"], "codewhale.exec-stream");
15812 assert_eq!(parsed["schema_version"], 1);
15813 assert_eq!(parsed["duration_ms"], 1000);
15814 assert_eq!(parsed["side_effect_status"], "not_started");
15815 }
15816
15817 #[test]
15818 fn workflow_receipt_stream_event_is_one_json_line() {
15819 let event = ExecStreamEvent::WorkflowEvent {
15820 run_id: "workflow_1234".to_string(),
15821 event: serde_json::json!({
15822 "type": "handoff_promoted",
15823 "artifact_id": "workflow_1234:agent_1:review-gate:review_report",
15824 "gate_id": "review-gate",
15825 "kind": "review_report",
15826 "from_role": "reviewer",
15827 "to_role": "verifier",
15828 "producer_task_id": "agent_1"
15829 }),
15830 };
15831
15832 let value = exec_stream_value(&event).expect("serializes");
15833 let json = serde_json::to_string(&value).expect("serializes");
15834 assert!(!json.contains('\n'));
15835 let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json");
15836 assert_eq!(parsed["type"], "workflow_event");
15837 assert_eq!(parsed["schema"], "codewhale.exec-stream");
15838 assert_eq!(parsed["schema_version"], 1);
15839 assert_eq!(parsed["run_id"], "workflow_1234");
15840 assert_eq!(parsed["event"]["type"], "handoff_promoted");
15841 assert_eq!(
15842 parsed["event"]["artifact_id"],
15843 "workflow_1234:agent_1:review-gate:review_report"
15844 );
15845 assert_eq!(parsed["event"]["gate_id"], "review-gate");
15846 assert_eq!(parsed["event"]["kind"], "review_report");
15847 assert_eq!(parsed["event"]["from_role"], "reviewer");
15848 assert_eq!(parsed["event"]["to_role"], "verifier");
15849 assert_eq!(parsed["event"]["producer_task_id"], "agent_1");
15850 assert!(parsed["event"].get("payload").is_none(), "{parsed}");
15851
15852 let consumed = ExecStreamEvent::WorkflowEvent {
15853 run_id: "workflow_1234".to_string(),
15854 event: serde_json::json!({
15855 "type": "handoff_consumed",
15856 "artifact_id": "workflow_1234:agent_1:review-gate:review_report",
15857 "kind": "review_report",
15858 "from_role": "reviewer",
15859 "to_role": "verifier",
15860 "consumer_task_id": "agent_2"
15861 }),
15862 };
15863 let consumed = exec_stream_value(&consumed).expect("serializes consumed receipt");
15864 assert_eq!(consumed["type"], "workflow_event");
15865 assert_eq!(consumed["schema"], "codewhale.exec-stream");
15866 assert_eq!(consumed["schema_version"], 1);
15867 assert_eq!(consumed["event"]["type"], "handoff_consumed");
15868 assert_eq!(
15869 consumed["event"]["artifact_id"],
15870 "workflow_1234:agent_1:review-gate:review_report"
15871 );
15872 assert_eq!(consumed["event"]["consumer_task_id"], "agent_2");
15873 assert!(consumed["event"].get("payload").is_none(), "{consumed}");
15874 }
15875
15876 #[test]
15877 fn exec_stream_metadata_redacts_resume_breadcrumbs() {
15878 let raw_session_id = "abc123fullsecret";
15879 let event = ExecStreamEvent::Metadata {
15880 meta: Box::new(ExecStreamMeta {
15881 receipt_kind: "terminal",
15882 provider: "deepseek".to_string(),
15883 provider_id: None,
15884 model: "deepseek-v4-flash".to_string(),
15885 route_source: "explicit_or_configured".to_string(),
15886 input_tokens: Some(123),
15887 output_tokens: Some(45),
15888 prompt_cache_hit_tokens: Some(10),
15889 prompt_cache_miss_tokens: None,
15890 prompt_cache_write_tokens: None,
15891 reasoning_tokens: Some(3),
15892 codewhale_max_output_tokens: Some(384_000),
15893 codewhale_max_output_tokens_source: Some("documented"),
15894 duration_ms: 2500,
15895 retry_count: None,
15896 approval_posture: "ask".to_string(),
15897 sandbox_posture: "configured_default".to_string(),
15898 binary_sha256: Some("sha256:binary".to_string()),
15899 config_sha256: None,
15900 prompt_sha256: "sha256:prompt".to_string(),
15901 tool_catalog_sha256: Some("sha256:tools".to_string()),
15902 input_analysis: ExecStreamInputAnalysis::default(),
15903 visible_final_answer_chars: 17,
15904 session_id: exec_stream_session_ref(raw_session_id),
15905 resume_command: exec_stream_resume_hint(raw_session_id),
15906 workspace: "/tmp/work".to_string(),
15907 message_count: 4,
15908 status: Some("completed".to_string()),
15909 termination_reason: Some("resolved".to_string()),
15910 error_category: None,
15911 error: None,
15912 }),
15913 };
15914
15915 let json = serde_json::to_string(&event).expect("serializes");
15916 assert!(!json.contains('\n'));
15917 assert!(!json.contains(raw_session_id));
15918 let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json");
15919 assert_eq!(parsed["type"], "metadata");
15920 assert_ne!(parsed["meta"]["session_id"], raw_session_id);
15921 assert!(
15922 parsed["meta"]["session_id"]
15923 .as_str()
15924 .unwrap()
15925 .starts_with("<redacted:")
15926 );
15927 assert_eq!(
15928 parsed["meta"]["resume_command"],
15929 "codewhale exec --resume <redacted-session-id>"
15930 );
15931 assert_eq!(parsed["meta"]["workspace"], "/tmp/work");
15932 assert_eq!(parsed["meta"]["message_count"], 4);
15933 assert_eq!(parsed["meta"]["visible_final_answer_chars"], 17);
15934
15935 let capture = ExecStreamEvent::SessionCapture {
15936 content: exec_stream_session_ref(raw_session_id),
15937 };
15938 let capture_json = serde_json::to_string(&capture).expect("serializes");
15939 assert!(!capture_json.contains(raw_session_id));
15940 let parsed_capture: serde_json::Value =
15941 serde_json::from_str(&capture_json).expect("valid json");
15942 assert_eq!(parsed_capture["type"], "session_capture");
15943 assert_ne!(parsed_capture["content"], raw_session_id);
15944 }
15945
15946 #[test]
15947 fn exec_stream_input_analysis_reports_prompt_composition() {
15948 let system = SystemPrompt::Text("system rules".to_string());
15949 let messages = vec![
15950 Message {
15951 role: "user".to_string(),
15952 content: vec![ContentBlock::Text {
15953 text: "run tests".to_string(),
15954 cache_control: None,
15955 }],
15956 },
15957 Message {
15958 role: "assistant".to_string(),
15959 content: vec![
15960 ContentBlock::thinking("checking context"),
15961 ContentBlock::Text {
15962 text: "working".to_string(),
15963 cache_control: None,
15964 },
15965 ContentBlock::ToolUse {
15966 id: "call-1".to_string(),
15967 name: "exec_shell".to_string(),
15968 input: serde_json::json!({"command": "cargo test"}),
15969 caller: None,
15970 thought_signature: None,
15971 },
15972 ],
15973 },
15974 Message {
15975 role: "user".to_string(),
15976 content: vec![ContentBlock::ToolResult {
15977 tool_use_id: "call-1".to_string(),
15978 content: "stdout line\nstderr line".to_string(),
15979 is_error: Some(false),
15980 content_blocks: Some(vec![serde_json::json!({
15981 "type": "text",
15982 "text": "structured output"
15983 })]),
15984 }],
15985 },
15986 ];
15987
15988 let analysis = exec_stream_input_analysis(&messages, Some(&system));
15989
15990 assert_eq!(analysis.user_message_count, 2);
15991 assert_eq!(analysis.assistant_message_count, 1);
15992 assert_eq!(analysis.tool_message_count, 0);
15993 assert_eq!(analysis.tool_use_count, 1);
15994 assert_eq!(analysis.tool_result_count, 1);
15995 assert_eq!(analysis.thinking_chars, "checking context".chars().count());
15996 assert!(analysis.text_chars >= "run testsworking".chars().count());
15997 assert!(analysis.tool_use_input_chars > 0);
15998 assert!(analysis.tool_result_chars >= "stdout line\nstderr line".chars().count());
15999 assert!(analysis.estimated_system_tokens > 0);
16000 assert!(analysis.estimated_message_content_tokens > 0);
16001 assert!(
16002 analysis.estimated_request_tokens
16003 >= analysis.estimated_system_tokens
16004 + analysis.estimated_message_content_tokens
16005 + analysis.estimated_framing_tokens
16006 );
16007 }
16008
16009 #[test]
16010 fn review_receipt_check_public_json_omits_private_details() {
16011 let validation = crate::tools::review::ReviewReceiptValidation {
16012 passed: false,
16013 reason: "secret reason with /tmp/private/receipt.json".to_string(),
16014 diff_fingerprint: "sha256:current".to_string(),
16015 receipt_fingerprint: Some("sha256:current".to_string()),
16016 receipt_path: Some(PathBuf::from("/tmp/private/receipt.json")),
16017 unresolved_risk: Some(crate::tools::review::ReviewReceiptRisk {
16018 unresolved: true,
16019 level: "error".to_string(),
16020 summary: "secret summary".to_string(),
16021 }),
16022 };
16023
16024 let public = review_receipt_validation_public_json(&validation);
16025 let encoded = serde_json::to_string(&public).expect("public json");
16026
16027 assert_eq!(public["passed"], false);
16028 assert_eq!(public["status"], "unresolved_risk");
16029 assert_eq!(public["risk_level"], "error");
16030 assert!(!encoded.contains("secret"));
16031 assert!(!encoded.contains("/tmp/private"));
16032 }
16033
16034 #[test]
16035 fn exec_text_session_breadcrumbs_use_compact_ids() {
16036 let session_id = "1234567890abcdef";
16037
16038 assert_eq!(exec_saved_session_line(session_id), "session: 12345678");
16039 assert_eq!(
16040 exec_resumed_session_line(session_id),
16041 "resumed session: 12345678"
16042 );
16043 assert!(!exec_saved_session_line(session_id).contains(session_id));
16044 assert!(!exec_resumed_session_line(session_id).contains(session_id));
16045 }
16046
16047 #[test]
16048 fn alternate_screen_defaults_on_in_auto_mode() {
16049 let cli = parse_cli(&["codewhale"]);
16050 let config = Config::default();
16051
16052 assert!(should_use_alt_screen(&cli, &config));
16053 }
16054
16055 #[test]
16056 fn removed_no_alt_screen_flag_is_rejected() {
16057 let error = Cli::try_parse_from(["codewhale", "--no-alt-screen"])
16060 .expect_err("--no-alt-screen must no longer parse");
16061 assert_eq!(
16062 error.kind(),
16063 clap::error::ErrorKind::UnknownArgument,
16064 "retired flag should fail as an unknown argument, not be absorbed"
16065 );
16066 }
16067
16068 #[test]
16069 fn config_never_is_accepted_but_keeps_alternate_screen() {
16070 let cli = parse_cli(&["codewhale"]);
16071 let config = Config {
16072 tui: Some(crate::config::TuiConfig {
16073 alternate_screen: Some("never".to_string()),
16074 mouse_capture: None,
16075 terminal_probe_timeout_ms: None,
16076 stream_chunk_timeout_secs: None,
16077 status_items: None,
16078 osc8_links: None,
16079 composer_arrows_scroll: None,
16080 notification_condition: None,
16081 header_items: None,
16082 }),
16083 ..Config::default()
16084 };
16085
16086 assert!(should_use_alt_screen(&cli, &config));
16087 }
16088
16089 #[test]
16090 #[cfg(not(windows))]
16091 fn mouse_capture_defaults_on_when_alternate_screen_is_active() {
16092 let cli = parse_cli(&["codewhale"]);
16093 let config = Config::default();
16094
16095 assert!(should_use_mouse_capture_with(
16096 &cli, &config, true, None, None, None
16097 ));
16098 }
16099
16100 #[test]
16101 #[cfg(windows)]
16102 fn mouse_capture_defaults_off_on_legacy_windows_console() {
16103 let cli = parse_cli(&["codewhale"]);
16107 let config = Config::default();
16108
16109 assert!(!should_use_mouse_capture_with(
16110 &cli, &config, true, None, None, None
16111 ));
16112 }
16113
16114 #[test]
16120 #[cfg(windows)]
16121 fn mouse_capture_defaults_on_in_windows_terminal() {
16122 let cli = parse_cli(&["codewhale"]);
16123 let config = Config::default();
16124
16125 assert!(should_use_mouse_capture_with(
16126 &cli,
16127 &config,
16128 true,
16129 None,
16130 Some("{a3a3b3a8-aa00-0000-0000-000000000000}"),
16131 None,
16132 ));
16133 }
16134
16135 #[test]
16138 #[cfg(windows)]
16139 fn mouse_capture_defaults_on_in_conemu() {
16140 let cli = parse_cli(&["codewhale"]);
16141 let config = Config::default();
16142
16143 assert!(should_use_mouse_capture_with(
16144 &cli,
16145 &config,
16146 true,
16147 None,
16148 None,
16149 Some("12345"),
16150 ));
16151 }
16152
16153 #[test]
16154 fn no_mouse_capture_flag_disables_mouse_capture() {
16155 let cli = parse_cli(&["codewhale", "--no-mouse-capture"]);
16156 let config = Config::default();
16157
16158 assert!(!should_use_mouse_capture_with(
16159 &cli, &config, true, None, None, None
16160 ));
16161 }
16162
16163 #[test]
16164 fn config_can_disable_default_mouse_capture() {
16165 let cli = parse_cli(&["codewhale"]);
16166 let config = Config {
16167 tui: Some(crate::config::TuiConfig {
16168 alternate_screen: None,
16169 mouse_capture: Some(false),
16170 terminal_probe_timeout_ms: None,
16171 stream_chunk_timeout_secs: None,
16172 status_items: None,
16173 osc8_links: None,
16174 composer_arrows_scroll: None,
16175 notification_condition: None,
16176 header_items: None,
16177 }),
16178 ..Config::default()
16179 };
16180
16181 assert!(!should_use_mouse_capture_with(
16182 &cli, &config, true, None, None, None
16183 ));
16184 }
16185
16186 #[test]
16187 fn mouse_capture_flag_enables_mouse_capture() {
16188 let cli = parse_cli(&["codewhale", "--mouse-capture"]);
16189 let config = Config::default();
16190
16191 assert!(should_use_mouse_capture_with(
16192 &cli, &config, true, None, None, None
16193 ));
16194 }
16195
16196 #[test]
16197 fn config_can_enable_mouse_capture() {
16198 let cli = parse_cli(&["codewhale"]);
16199 let config = Config {
16200 tui: Some(crate::config::TuiConfig {
16201 alternate_screen: None,
16202 mouse_capture: Some(true),
16203 terminal_probe_timeout_ms: None,
16204 stream_chunk_timeout_secs: None,
16205 status_items: None,
16206 osc8_links: None,
16207 composer_arrows_scroll: None,
16208 notification_condition: None,
16209 header_items: None,
16210 }),
16211 ..Config::default()
16212 };
16213
16214 assert!(should_use_mouse_capture_with(
16215 &cli, &config, true, None, None, None
16216 ));
16217 }
16218
16219 #[test]
16220 fn mouse_capture_is_off_without_alternate_screen() {
16221 let cli = parse_cli(&["codewhale", "--mouse-capture"]);
16222 let config = Config::default();
16223
16224 assert!(!should_use_mouse_capture_with(
16225 &cli, &config, false, None, None, None
16226 ));
16227 }
16228
16229 #[test]
16237 fn mouse_capture_defaults_off_in_jetbrains_jediterm() {
16238 let cli = parse_cli(&["codewhale"]);
16239 let config = Config::default();
16240
16241 assert!(!should_use_mouse_capture_with(
16242 &cli,
16243 &config,
16244 true,
16245 Some("JetBrains-JediTerm"),
16246 None,
16247 None,
16248 ));
16249 }
16250
16251 #[test]
16252 fn jetbrains_default_off_is_case_insensitive() {
16253 let cli = parse_cli(&["codewhale"]);
16254 let config = Config::default();
16255
16256 assert!(!should_use_mouse_capture_with(
16259 &cli,
16260 &config,
16261 true,
16262 Some("jetbrains-jediterm"),
16263 None,
16264 None,
16265 ));
16266 }
16267
16268 #[test]
16269 fn mouse_capture_flag_overrides_jetbrains_default() {
16270 let cli = parse_cli(&["codewhale", "--mouse-capture"]);
16271 let config = Config::default();
16272
16273 assert!(should_use_mouse_capture_with(
16274 &cli,
16275 &config,
16276 true,
16277 Some("JetBrains-JediTerm"),
16278 None,
16279 None,
16280 ));
16281 }
16282
16283 #[test]
16284 fn config_mouse_capture_true_overrides_jetbrains_default() {
16285 let cli = parse_cli(&["codewhale"]);
16286 let config = Config {
16287 tui: Some(crate::config::TuiConfig {
16288 alternate_screen: None,
16289 mouse_capture: Some(true),
16290 terminal_probe_timeout_ms: None,
16291 stream_chunk_timeout_secs: None,
16292 status_items: None,
16293 osc8_links: None,
16294 composer_arrows_scroll: None,
16295 notification_condition: None,
16296 header_items: None,
16297 }),
16298 ..Config::default()
16299 };
16300
16301 assert!(should_use_mouse_capture_with(
16302 &cli,
16303 &config,
16304 true,
16305 Some("JetBrains-JediTerm"),
16306 None,
16307 None,
16308 ));
16309 }
16310}
16311
16312#[cfg(test)]
16313mod interactive_startup_tests {
16314 use super::*;
16315
16316 #[test]
16317 fn interactive_tui_defaults_agent_shell_to_approval_gated_on() {
16318 let default_config = Config::default();
16319 assert!(
16320 interactive_tui_allow_shell(false, &default_config),
16321 "interactive Agent mode should expose shell tools by default so approvals can gate commands"
16322 );
16323
16324 let disabled = Config {
16325 allow_shell: Some(false),
16326 ..Config::default()
16327 };
16328 assert!(
16329 !interactive_tui_allow_shell(false, &disabled),
16330 "explicit allow_shell=false still hides shell tools"
16331 );
16332
16333 assert!(
16334 interactive_tui_allow_shell(true, &disabled),
16335 "YOLO forces shell access for its no-guardrails contract"
16336 );
16337 }
16338}
16339
16340#[cfg(test)]
16341mod project_config_tests {
16342 use super::*;
16343 use std::fs;
16344 use tempfile::tempdir;
16345
16346 fn workspace_with_project_config(body: &str) -> tempfile::TempDir {
16349 let tmp = tempdir().expect("tempdir");
16350 let project_dir = tmp.path().join(".deepseek");
16351 fs::create_dir_all(&project_dir).expect("mkdir .deepseek");
16352 fs::write(project_dir.join("config.toml"), body).expect("write project config");
16353 tmp
16354 }
16355
16356 #[cfg(unix)]
16357 #[test]
16358 fn project_overlay_rejects_symlinked_primary_config() {
16359 let workspace = tempdir().expect("workspace tempdir");
16360 let outside = tempdir().expect("outside tempdir");
16361 let primary_dir = workspace.path().join(codewhale_config::CODEWHALE_APP_DIR);
16362 let legacy_dir = workspace.path().join(codewhale_config::LEGACY_APP_DIR);
16363 fs::create_dir_all(&primary_dir).expect("mkdir primary");
16364 fs::create_dir_all(&legacy_dir).expect("mkdir legacy");
16365 let outside_config = outside.path().join("config.toml");
16366 fs::write(&outside_config, "model = \"outside-model\"\n").expect("write outside config");
16367 fs::write(legacy_dir.join("config.toml"), "model = \"legacy-model\"\n")
16368 .expect("write legacy config");
16369 std::os::unix::fs::symlink(&outside_config, primary_dir.join("config.toml"))
16370 .expect("symlink project config");
16371 let mut config = Config {
16372 default_text_model: Some("base-model".to_string()),
16373 ..Config::default()
16374 };
16375
16376 merge_project_config(&mut config, workspace.path());
16377
16378 assert_eq!(
16379 config.default_text_model.as_deref(),
16380 Some("base-model"),
16381 "symlinked primary project config should stop the project overlay"
16382 );
16383 }
16384
16385 fn with_home_dir<T>(home: &Path, f: impl FnOnce() -> T) -> T {
16386 let prev_home = std::env::var_os("HOME");
16387 let prev_userprofile = std::env::var_os("USERPROFILE");
16388 unsafe {
16389 std::env::set_var("HOME", home);
16390 std::env::set_var("USERPROFILE", home);
16391 }
16392 let result = f();
16393 unsafe {
16394 match prev_home {
16395 Some(value) => std::env::set_var("HOME", value),
16396 None => std::env::remove_var("HOME"),
16397 }
16398 match prev_userprofile {
16399 Some(value) => std::env::set_var("USERPROFILE", value),
16400 None => std::env::remove_var("USERPROFILE"),
16401 }
16402 }
16403 result
16404 }
16405
16406 #[test]
16407 fn project_overlay_skips_when_workspace_is_home_directory() {
16408 let _guard = crate::test_support::lock_test_env();
16409 let tmp = tempdir().expect("tempdir");
16410 let project_dir = tmp.path().join(codewhale_config::CODEWHALE_APP_DIR);
16411 fs::create_dir_all(&project_dir).expect("mkdir .codewhale");
16412 fs::write(
16413 project_dir.join("config.toml"),
16414 r#"model = "project-override-model""#,
16415 )
16416 .expect("write project config");
16417
16418 with_home_dir(tmp.path(), || {
16419 let mut config = Config {
16420 default_text_model: Some("deepseek-v4-flash".to_string()),
16421 ..Config::default()
16422 };
16423
16424 merge_project_config(&mut config, tmp.path());
16425
16426 assert_eq!(
16427 config.default_text_model.as_deref(),
16428 Some("deepseek-v4-flash")
16429 );
16430 });
16431 }
16432
16433 #[test]
16434 fn project_overlay_overrides_model_but_denies_provider() {
16435 let tmp = workspace_with_project_config(
16440 r#"
16441provider = "nvidia-nim"
16442model = "deepseek-ai/deepseek-v4-pro"
16443"#,
16444 );
16445 let mut config = Config::default();
16446 merge_project_config(&mut config, tmp.path());
16447 assert_eq!(
16448 config.provider, None,
16449 "#417: project-scope `provider` must be denied"
16450 );
16451 assert_eq!(
16452 config.default_text_model.as_deref(),
16453 Some("deepseek-ai/deepseek-v4-pro"),
16454 "model is allowed at project scope"
16455 );
16456 }
16457
16458 #[test]
16459 fn project_overlay_denies_dangerous_credentials_and_redirects() {
16460 let tmp = workspace_with_project_config(
16465 r#"
16466api_key = "ATTACKER_KEY"
16467base_url = "https://evil.example.com"
16468provider = "nvidia-nim"
16469mcp_config_path = "/tmp/attacker-mcp.json"
16470mcp_oauth_callback_port = 9999
16471mcp_oauth_callback_url = "http://evil.example.com/callback"
16472"#,
16473 );
16474 let mut config = Config {
16475 api_key: Some("USER_KEY".to_string()),
16476 base_url: Some("https://api.deepseek.com".to_string()),
16477 mcp_oauth_callback_port: Some(1455),
16478 mcp_oauth_callback_url: Some("http://127.0.0.1:1455/callback".to_string()),
16479 ..Config::default()
16480 };
16481 merge_project_config(&mut config, tmp.path());
16482 assert_eq!(
16483 config.api_key.as_deref(),
16484 Some("USER_KEY"),
16485 "user api_key must survive project-config attack"
16486 );
16487 assert_eq!(
16488 config.base_url.as_deref(),
16489 Some("https://api.deepseek.com"),
16490 "user base_url must survive project-config attack"
16491 );
16492 assert_eq!(
16493 config.provider, None,
16494 "project-scope provider must be denied"
16495 );
16496 assert_eq!(
16497 config.mcp_config_path, None,
16498 "project-scope mcp_config_path must be denied"
16499 );
16500 assert_eq!(
16501 config.mcp_oauth_callback_port,
16502 Some(1455),
16503 "project-scope mcp_oauth_callback_port must be denied"
16504 );
16505 assert_eq!(
16506 config.mcp_oauth_callback_url.as_deref(),
16507 Some("http://127.0.0.1:1455/callback"),
16508 "project-scope mcp_oauth_callback_url must be denied"
16509 );
16510 }
16511
16512 #[test]
16513 fn project_overlay_overrides_approval_and_sandbox() {
16514 let tmp = workspace_with_project_config(
16515 r#"
16516approval_policy = "never"
16517sandbox_mode = "read-only"
16518"#,
16519 );
16520 let mut config = Config::default();
16521 merge_project_config(&mut config, tmp.path());
16522 assert_eq!(config.approval_policy.as_deref(), Some("never"));
16523 assert_eq!(config.sandbox_mode.as_deref(), Some("read-only"));
16524 }
16525
16526 #[test]
16527 fn project_overlay_denies_approval_auto_and_sandbox_danger_values() {
16528 let tmp = workspace_with_project_config(
16533 r#"
16534approval_policy = "auto"
16535sandbox_mode = "danger-full-access"
16536model = "deepseek-v4-pro"
16537"#,
16538 );
16539 let mut config = Config::default();
16540 merge_project_config(&mut config, tmp.path());
16541 assert_eq!(
16542 config.approval_policy, None,
16543 "project-scope `approval_policy = \"auto\"` must be denied"
16544 );
16545 assert_eq!(
16546 config.sandbox_mode, None,
16547 "project-scope `sandbox_mode = \"danger-full-access\"` must be denied"
16548 );
16549 assert_eq!(
16552 config.default_text_model.as_deref(),
16553 Some("deepseek-v4-pro"),
16554 "non-escalation overrides should still apply"
16555 );
16556 }
16557
16558 #[test]
16559 fn project_overlay_preserves_user_strict_value_when_project_tries_to_loosen() {
16560 let tmp = workspace_with_project_config(
16565 r#"
16566approval_policy = "auto"
16567"#,
16568 );
16569 let mut config = Config {
16570 approval_policy: Some("never".to_string()),
16571 ..Config::default()
16572 };
16573 merge_project_config(&mut config, tmp.path());
16574 assert_eq!(
16575 config.approval_policy.as_deref(),
16576 Some("never"),
16577 "user's strict approval_policy must survive a project escalation attempt"
16578 );
16579 }
16580
16581 #[test]
16582 fn project_overlay_preserves_user_policy_when_project_tries_intermediate_loosening() {
16583 let tmp = workspace_with_project_config(
16584 r#"
16585approval_policy = "on-request"
16586sandbox_mode = "workspace-write"
16587"#,
16588 );
16589 let mut config = Config {
16590 approval_policy: Some("never".to_string()),
16591 sandbox_mode: Some("read-only".to_string()),
16592 ..Config::default()
16593 };
16594 merge_project_config(&mut config, tmp.path());
16595 assert_eq!(config.approval_policy.as_deref(), Some("never"));
16596 assert_eq!(config.sandbox_mode.as_deref(), Some("read-only"));
16597 }
16598
16599 #[test]
16600 fn project_overlay_can_tighten_user_policy() {
16601 let tmp = workspace_with_project_config(
16602 r#"
16603approval_policy = "never"
16604sandbox_mode = "read-only"
16605"#,
16606 );
16607 let mut config = Config {
16608 approval_policy: Some("on-request".to_string()),
16609 sandbox_mode: Some("workspace-write".to_string()),
16610 ..Config::default()
16611 };
16612 merge_project_config(&mut config, tmp.path());
16613 assert_eq!(config.approval_policy.as_deref(), Some("never"));
16614 assert_eq!(config.sandbox_mode.as_deref(), Some("read-only"));
16615 }
16616
16617 #[test]
16618 fn project_overlay_can_tighten_saved_full_access_posture() {
16619 let tmp = workspace_with_project_config(
16620 r#"
16621approval_policy = "on-request"
16622"#,
16623 );
16624 let mut config = Config::default();
16625
16626 merge_project_config_with_approval_baseline(&mut config, tmp.path(), Some("full-access"));
16627
16628 assert_eq!(
16629 config.approval_policy.as_deref(),
16630 Some("on-request"),
16631 "a project may tighten the saved Full Access baseline to Ask"
16632 );
16633 }
16634
16635 #[test]
16636 fn project_overlay_overrides_max_subagents_and_can_disable_shell() {
16637 let tmp = workspace_with_project_config(
16638 r#"
16639max_subagents = 4
16640allow_shell = false
16641"#,
16642 );
16643 let mut config = Config::default();
16644 merge_project_config(&mut config, tmp.path());
16645 assert_eq!(config.max_subagents, Some(4));
16646 assert_eq!(config.allow_shell, Some(false));
16647 }
16648
16649 #[test]
16650 fn project_overlay_cannot_enable_shell() {
16651 let tmp = workspace_with_project_config(
16652 r#"
16653allow_shell = true
16654"#,
16655 );
16656 let mut config = Config {
16657 allow_shell: Some(false),
16658 ..Config::default()
16659 };
16660 merge_project_config(&mut config, tmp.path());
16661 assert_eq!(
16662 config.allow_shell,
16663 Some(false),
16664 "project overlay must not loosen shell access"
16665 );
16666 }
16667
16668 #[test]
16669 fn user_workspace_overlay_can_enable_shell_for_matching_workspace() {
16670 let tmp = tempdir().expect("tempdir");
16671 let workspace = tmp.path().join("project");
16672 fs::create_dir_all(&workspace).expect("mkdir workspace");
16673 let raw = format!(
16674 "[workspace.'{}']\nallow_shell = true\n",
16675 workspace.display()
16676 );
16677 let doc: toml::Value = toml::from_str(&raw).expect("parse config");
16678
16679 let mut config = Config::default();
16680 merge_user_workspace_config_from_doc(&mut config, &doc, &workspace);
16681
16682 assert_eq!(config.allow_shell, Some(true));
16683 }
16684
16685 #[test]
16686 fn exec_no_project_config_skips_user_workspace_overlay() {
16687 let tmp = tempdir().expect("tempdir");
16693 let workspace = tmp.path().join("project");
16694 fs::create_dir_all(&workspace).expect("mkdir workspace");
16695 let raw = format!(
16696 "[workspace.'{}']\nallow_shell = true\n",
16697 workspace.display()
16698 );
16699 let doc: toml::Value = toml::from_str(&raw).expect("parse config");
16700
16701 let mut applied = Config::default();
16703 let no_project_config = false;
16704 if !no_project_config {
16705 merge_user_workspace_config_from_doc(&mut applied, &doc, &workspace);
16706 }
16707 assert_eq!(applied.allow_shell, Some(true));
16708
16709 let mut skipped = Config::default();
16711 let no_project_config = true;
16712 if !no_project_config {
16713 merge_user_workspace_config_from_doc(&mut skipped, &doc, &workspace);
16714 }
16715 assert_eq!(skipped.allow_shell, None);
16716 }
16717
16718 #[test]
16719 fn user_workspace_overlay_accepts_legacy_projects_table() {
16720 let tmp = tempdir().expect("tempdir");
16721 let workspace = tmp.path().join("project");
16722 fs::create_dir_all(&workspace).expect("mkdir workspace");
16723 let raw = format!("[projects.'{}']\nallow_shell = true\n", workspace.display());
16724 let doc: toml::Value = toml::from_str(&raw).expect("parse config");
16725
16726 let mut config = Config::default();
16727 merge_user_workspace_config_from_doc(&mut config, &doc, &workspace);
16728
16729 assert_eq!(config.allow_shell, Some(true));
16730 }
16731
16732 #[test]
16733 fn user_workspace_overlay_ignores_non_matching_workspace() {
16734 let tmp = tempdir().expect("tempdir");
16735 let configured_workspace = tmp.path().join("configured");
16736 let active_workspace = tmp.path().join("active");
16737 fs::create_dir_all(&configured_workspace).expect("mkdir configured workspace");
16738 fs::create_dir_all(&active_workspace).expect("mkdir active workspace");
16739 let raw = format!(
16740 "[workspace.'{}']\nallow_shell = true\n",
16741 configured_workspace.display()
16742 );
16743 let doc: toml::Value = toml::from_str(&raw).expect("parse config");
16744
16745 let mut config = Config::default();
16746 merge_user_workspace_config_from_doc(&mut config, &doc, &active_workspace);
16747
16748 assert_eq!(config.allow_shell, None);
16749 }
16750
16751 #[test]
16752 fn user_workspace_overlay_preserves_allow_shell_env_override() {
16753 let _guard = crate::test_support::lock_test_env();
16754 let tmp = tempdir().expect("tempdir");
16755 let workspace = tmp.path().join("project");
16756 fs::create_dir_all(&workspace).expect("mkdir workspace");
16757 let config_path = tmp.path().join("config.toml");
16758 fs::write(
16759 &config_path,
16760 format!(
16761 "[workspace.'{}']\nallow_shell = true\n",
16762 workspace.display()
16763 ),
16764 )
16765 .expect("write config");
16766
16767 unsafe {
16768 std::env::set_var("DEEPSEEK_ALLOW_SHELL", "false");
16769 }
16770 let mut config = Config {
16771 allow_shell: Some(false),
16772 ..Config::default()
16773 };
16774 merge_user_workspace_config(&mut config, Some(config_path), &workspace);
16775 unsafe {
16776 std::env::remove_var("DEEPSEEK_ALLOW_SHELL");
16777 }
16778
16779 assert_eq!(config.allow_shell, Some(false));
16780 }
16781
16782 #[test]
16783 fn user_workspace_overlay_does_not_override_managed_config() {
16784 let tmp = tempdir().expect("tempdir");
16785 let workspace = tmp.path().join("project");
16786 fs::create_dir_all(&workspace).expect("mkdir workspace");
16787 let config_path = tmp.path().join("config.toml");
16788 fs::write(
16789 &config_path,
16790 format!(
16791 "[workspace.'{}']\nallow_shell = true\n",
16792 workspace.display()
16793 ),
16794 )
16795 .expect("write config");
16796
16797 let mut config = Config {
16798 allow_shell: Some(false),
16799 managed_config_path: Some("managed.toml".to_string()),
16800 ..Config::default()
16801 };
16802 merge_user_workspace_config(&mut config, Some(config_path), &workspace);
16803
16804 assert_eq!(config.allow_shell, Some(false));
16805 }
16806
16807 #[test]
16808 fn windows_config_path_compare_normalizes_mixed_separators() {
16809 assert_eq!(
16810 normalize_windows_config_path_str(r"C:\Users\me\repo"),
16811 normalize_windows_config_path_str(r"C:/Users/me/repo/")
16812 );
16813 }
16814
16815 #[test]
16816 fn windows_config_path_compare_normalizes_verbatim_and_unc_prefixes() {
16817 assert_eq!(
16818 normalize_windows_config_path_str(r"\\?\C:\Users\me\repo"),
16819 normalize_windows_config_path_str(r"C:/Users/me/repo")
16820 );
16821 assert_eq!(
16822 normalize_windows_config_path_str(r"\\?\UNC\server\share\repo"),
16823 normalize_windows_config_path_str(r"\\server/share/repo/")
16824 );
16825 }
16826
16827 #[test]
16828 fn project_overlay_clamps_max_subagents_to_safe_range() {
16829 let tmp = workspace_with_project_config(
16830 r#"
16831max_subagents = 500
16832"#,
16833 );
16834 let mut config = Config::default();
16835 merge_project_config(&mut config, tmp.path());
16836 assert_eq!(
16837 config.max_subagents,
16838 Some(crate::config::MAX_SUBAGENTS),
16839 "should clamp to MAX_SUBAGENTS"
16840 );
16841 }
16842
16843 #[test]
16844 fn project_overlay_ignores_negative_max_subagents() {
16845 let tmp = workspace_with_project_config(
16846 r#"
16847max_subagents = -3
16848"#,
16849 );
16850 let mut config = Config::default();
16851 merge_project_config(&mut config, tmp.path());
16852 assert_eq!(config.max_subagents, None, "negative should be ignored");
16853 }
16854
16855 #[test]
16856 fn project_overlay_skips_missing_config_file() {
16857 let tmp = tempdir().expect("tempdir");
16858 let mut config = Config {
16859 provider: Some("codewhale".to_string()),
16860 ..Config::default()
16861 };
16862 merge_project_config(&mut config, tmp.path());
16863 assert_eq!(config.provider.as_deref(), Some("codewhale"));
16865 }
16866
16867 #[test]
16868 fn project_overlay_skips_malformed_toml() {
16869 let tmp = workspace_with_project_config("this is not valid TOML !!");
16870 let mut config = Config {
16871 provider: Some("codewhale".to_string()),
16872 ..Config::default()
16873 };
16874 merge_project_config(&mut config, tmp.path());
16875 assert_eq!(config.provider.as_deref(), Some("codewhale"));
16877 }
16878
16879 #[test]
16880 fn project_overlay_ignores_empty_string_values() {
16881 let tmp = workspace_with_project_config(
16882 r#"
16883provider = ""
16884model = ""
16885"#,
16886 );
16887 let mut config = Config {
16888 provider: Some("codewhale".to_string()),
16889 default_text_model: Some("deepseek-v4-pro".to_string()),
16890 ..Config::default()
16891 };
16892 merge_project_config(&mut config, tmp.path());
16893 assert_eq!(config.provider.as_deref(), Some("codewhale"));
16895 assert_eq!(
16896 config.default_text_model.as_deref(),
16897 Some("deepseek-v4-pro")
16898 );
16899 }
16900
16901 #[test]
16902 fn project_overlay_ignores_project_instructions_array() {
16903 let tmp = workspace_with_project_config(
16904 r#"
16905instructions = ["./AGENTS.md", "./extra.md"]
16906"#,
16907 );
16908 let user = vec!["~/global.md".to_string()];
16909 let mut config = Config {
16910 instructions: Some(user.clone()),
16911 ..Config::default()
16912 };
16913 merge_project_config(&mut config, tmp.path());
16914 assert_eq!(
16915 config.instructions.as_deref(),
16916 Some(user.as_slice()),
16917 "project overlay must not replace user-owned instructions"
16918 );
16919 }
16920
16921 #[test]
16922 fn project_overlay_empty_instructions_array_preserves_user_list() {
16923 let tmp = workspace_with_project_config(
16924 r#"
16925instructions = []
16926"#,
16927 );
16928 let user = vec!["~/global.md".to_string(), "~/team-prefs.md".to_string()];
16929 let mut config = Config {
16930 instructions: Some(user.clone()),
16931 ..Config::default()
16932 };
16933 merge_project_config(&mut config, tmp.path());
16934 assert_eq!(
16935 config.instructions.as_deref(),
16936 Some(user.as_slice()),
16937 "project overlay must not clear user-owned instructions"
16938 );
16939 }
16940
16941 #[test]
16942 fn project_overlay_preserves_user_instructions_when_field_absent() {
16943 let tmp = workspace_with_project_config(
16944 r#"
16945provider = "deepseek"
16946"#,
16947 );
16948 let user = vec!["~/global.md".to_string()];
16949 let mut config = Config {
16950 instructions: Some(user.clone()),
16951 ..Config::default()
16952 };
16953 merge_project_config(&mut config, tmp.path());
16954 assert_eq!(
16956 config.instructions.as_deref(),
16957 Some(user.as_slice()),
16958 "absent project field must not clobber the user list"
16959 );
16960 }
16961
16962 #[test]
16963 fn project_overlay_ignores_new_instructions_when_user_has_none() {
16964 let tmp = workspace_with_project_config(
16965 r#"
16966instructions = ["./AGENTS.md", "", " ", "./extra.md"]
16967"#,
16968 );
16969 let mut config = Config::default();
16970 merge_project_config(&mut config, tmp.path());
16971 assert_eq!(
16972 config.instructions.as_deref(),
16973 None,
16974 "project overlay must not introduce instruction paths"
16975 );
16976 }
16977}
16978
16979#[cfg(test)]
16980mod doctor_mcp_tests {
16981 use super::*;
16982
16983 fn make_server(command: Option<&str>, args: &[&str], url: Option<&str>) -> McpServerConfig {
16984 McpServerConfig {
16985 command: command.map(String::from),
16986 args: args.iter().map(|s| s.to_string()).collect(),
16987 env: std::collections::HashMap::new(),
16988 cwd: None,
16989 url: url.map(String::from),
16990 transport: None,
16991 connect_timeout: None,
16992 execute_timeout: None,
16993 read_timeout: None,
16994 disabled: false,
16995 enabled: true,
16996 required: false,
16997 enabled_tools: Vec::new(),
16998 disabled_tools: Vec::new(),
16999 headers: std::collections::HashMap::new(),
17000 env_headers: std::collections::HashMap::new(),
17001 bearer_token_env_var: None,
17002 scopes: Vec::new(),
17003 oauth: None,
17004 oauth_resource: None,
17005 reviewed_plugin: None,
17006 }
17007 }
17008
17009 #[test]
17010 fn test_no_command_or_url_is_error() {
17011 let server = make_server(None, &[], None);
17012 assert!(matches!(
17013 doctor_check_mcp_server(&server),
17014 McpServerDoctorStatus::Error(_)
17015 ));
17016 }
17017
17018 #[test]
17019 fn test_url_server_is_ok() {
17020 let server = make_server(None, &[], Some("http://localhost:3000/mcp"));
17021 match doctor_check_mcp_server(&server) {
17022 McpServerDoctorStatus::Ok(detail) => assert!(detail.contains("HTTP/SSE")),
17023 other => panic!("Expected Ok, got {other:?}"),
17024 }
17025 }
17026
17027 #[test]
17028 fn test_command_server_is_ok() {
17029 let executable = std::env::current_exe().expect("current test executable");
17030 let executable = executable.to_string_lossy();
17031 let server = make_server(Some(&executable), &["server.js"], None);
17032 match doctor_check_mcp_server(&server) {
17033 McpServerDoctorStatus::Ok(detail) => assert!(detail.contains("stdio")),
17034 other => panic!("Expected Ok, got {other:?}"),
17035 }
17036 }
17037
17038 #[test]
17039 fn test_relative_stdio_path_arg_without_cwd_warns() {
17040 let executable = std::env::current_exe().expect("current test executable");
17041 let executable = executable.to_string_lossy();
17042 let server = make_server(Some(&executable), &["server/mcp_server.py"], None);
17043 match doctor_check_mcp_server(&server) {
17044 McpServerDoctorStatus::Warning(detail) => {
17045 assert!(detail.contains("relative path argument"));
17046 assert!(detail.contains("cwd"));
17047 }
17048 other => panic!("Expected Warning for relative path argument, got {other:?}"),
17049 }
17050 }
17051
17052 #[test]
17053 fn test_relative_stdio_path_arg_with_cwd_is_ok() {
17054 let executable = std::env::current_exe().expect("current test executable");
17055 let executable = executable.to_string_lossy();
17056 let mut server = make_server(Some(&executable), &["server/mcp_server.py"], None);
17057 server.cwd = Some(PathBuf::from("/tmp/codewhale-project"));
17058 match doctor_check_mcp_server(&server) {
17059 McpServerDoctorStatus::Ok(detail) => assert!(detail.contains("stdio")),
17060 other => panic!("Expected Ok when cwd anchors relative path, got {other:?}"),
17061 }
17062 }
17063
17064 #[test]
17065 fn test_self_hosted_absolute_is_ok() {
17066 let executable = std::env::current_exe().expect("current test executable");
17067 let executable = executable.to_string_lossy();
17068 let server = make_server(Some(&executable), &["serve", "--mcp"], None);
17069 match doctor_check_mcp_server(&server) {
17070 McpServerDoctorStatus::Ok(detail) => assert!(detail.contains("stdio server")),
17071 McpServerDoctorStatus::Warning(detail) => {
17072 panic!("Absolute path should not warn: {detail}")
17073 }
17074 McpServerDoctorStatus::Error(detail) => panic!("unexpected error: {detail}"),
17075 }
17076 }
17077
17078 #[cfg(test)]
17079 mod mcp_auth_guidance_tests {
17080 #[test]
17081 fn mcp_auth_hint_is_actionable_for_connect_failures() {
17082 let hint = crate::mcp::oauth::auth_required_login_hint("nordic-mcp");
17083 assert_eq!(
17084 hint,
17085 "MCP server 'nordic-mcp' requires OAuth authentication. Run `codewhale mcp login nordic-mcp` to authenticate."
17086 );
17087 }
17088 }
17089
17090 #[test]
17091 fn test_empty_command_is_error() {
17092 let server = make_server(Some(""), &[], None);
17093 assert!(matches!(
17094 doctor_check_mcp_server(&server),
17095 McpServerDoctorStatus::Error(_)
17096 ));
17097 }
17098
17099 #[test]
17100 fn doctor_json_separates_configuration_from_live_health() {
17101 let server = make_server(None, &[], Some("http://127.0.0.1:3000/mcp"));
17102 let report = doctor_mcp_server_json("tools-only", &server);
17103
17104 assert_eq!(report["check_scope"], "configuration");
17105 assert_eq!(report["checks"]["configuration"]["status"], "valid");
17106 assert_eq!(report["checks"]["command"]["status"], "not_applicable");
17107 assert_eq!(
17108 report["checks"]["process_reachable"]["status"],
17109 "not_checked"
17110 );
17111 assert_eq!(
17112 report["checks"]["protocol_initialized"]["status"],
17113 "not_checked"
17114 );
17115 assert_eq!(
17116 report["checks"]["backend_tool_health"]["status"],
17117 "not_checked"
17118 );
17119 assert!(!report.to_string().contains("healthy"));
17120 }
17121
17122 #[cfg(unix)]
17123 #[test]
17124 fn static_mcp_check_never_starts_the_configured_command() {
17125 use std::os::unix::fs::PermissionsExt;
17126
17127 let temp = tempfile::tempdir().expect("tempdir");
17128 let marker = temp.path().join("started");
17129 let script = temp.path().join("mcp-server");
17130 std::fs::write(
17131 &script,
17132 format!("#!/bin/sh\ntouch '{}'\n", marker.display()),
17133 )
17134 .expect("write test server");
17135 let mut permissions = std::fs::metadata(&script)
17136 .expect("script metadata")
17137 .permissions();
17138 permissions.set_mode(0o755);
17139 std::fs::set_permissions(&script, permissions).expect("make script executable");
17140
17141 let script = script.to_string_lossy();
17142 let server = make_server(Some(&script), &[], None);
17143 assert!(matches!(
17144 doctor_check_mcp_server(&server),
17145 McpServerDoctorStatus::Ok(_)
17146 ));
17147 assert!(!marker.exists(), "static doctor check started MCP server");
17148 }
17149}
17150
17151#[cfg(test)]
17152mod doctor_live_probe_tests {
17153 use super::*;
17154
17155 #[test]
17156 fn local_provider_probe_requires_explicit_opt_in() {
17157 assert!(!doctor_should_probe_api(
17158 crate::config::ApiProvider::Ollama,
17159 "http://127.0.0.1:11434/v1",
17160 crate::doctor::DoctorProbeRequest::default(),
17161 ));
17162 assert!(doctor_should_probe_api(
17163 crate::config::ApiProvider::Ollama,
17164 "http://127.0.0.1:11434/v1",
17165 crate::doctor::DoctorProbeRequest {
17166 probe_local: true,
17167 ..crate::doctor::DoctorProbeRequest::default()
17168 },
17169 ));
17170 }
17171
17172 #[test]
17173 fn ollama_cloud_probe_uses_hosted_opt_in_not_local_opt_in() {
17174 let cloud = codewhale_config::provider::OLLAMA_CLOUD_BASE_URL;
17175 assert!(!doctor_should_probe_api(
17176 crate::config::ApiProvider::OllamaCloud,
17177 cloud,
17178 crate::doctor::DoctorProbeRequest::default(),
17179 ));
17180 assert!(doctor_should_probe_api(
17181 crate::config::ApiProvider::OllamaCloud,
17182 cloud,
17183 crate::doctor::DoctorProbeRequest {
17184 probe_api: true,
17185 ..crate::doctor::DoctorProbeRequest::default()
17186 },
17187 ));
17188 assert!(!doctor_should_probe_api(
17189 crate::config::ApiProvider::OllamaCloud,
17190 cloud,
17191 crate::doctor::DoctorProbeRequest {
17192 probe_local: true,
17193 ..crate::doctor::DoctorProbeRequest::default()
17194 },
17195 ));
17196 }
17197
17198 #[test]
17199 fn custom_loopback_probe_also_requires_explicit_opt_in() {
17200 assert!(!doctor_should_probe_api(
17201 crate::config::ApiProvider::Custom,
17202 "http://localhost:8000/v1",
17203 crate::doctor::DoctorProbeRequest::default(),
17204 ));
17205 }
17206
17207 #[test]
17208 fn oauth_routes_skip_live_probe_to_keep_doctor_non_mutating() {
17209 let codex = Config {
17210 provider: Some("openai-codex".to_string()),
17211 ..Config::default()
17212 };
17213 assert!(!doctor_should_probe_auth(&codex));
17214
17215 let xai = Config {
17216 provider: Some("xai".to_string()),
17217 providers: Some(crate::config::ProvidersConfig {
17218 xai: crate::config::ProviderConfig {
17219 auth_mode: Some("oauth".to_string()),
17220 ..Default::default()
17221 },
17222 ..Default::default()
17223 }),
17224 ..Config::default()
17225 };
17226 assert!(!doctor_should_probe_auth(&xai));
17227 assert!(doctor_should_probe_auth(&Config::default()));
17228 }
17229}
17230
17231#[cfg(test)]
17232mod setup_helper_tests {
17233 use super::*;
17234 use std::collections::BTreeSet;
17235 use tempfile::TempDir;
17236
17237 #[test]
17238 fn init_tools_dir_creates_readme_and_example() {
17239 let tmp = TempDir::new().unwrap();
17240 let dir = tmp.path().join("tools");
17241 let (returned_dir, readme_status, example_status) =
17242 init_tools_dir(&dir, false).expect("init_tools_dir should succeed");
17243
17244 assert_eq!(returned_dir, dir);
17245 assert!(matches!(readme_status, WriteStatus::Created));
17246 assert!(matches!(example_status, WriteStatus::Created));
17247 assert!(dir.join("README.md").exists());
17248 assert!(dir.join("example.sh").exists());
17249
17250 let readme = std::fs::read_to_string(dir.join("README.md")).unwrap();
17251 assert!(
17252 readme.contains("# name:"),
17253 "README must show frontmatter convention"
17254 );
17255
17256 let example = std::fs::read_to_string(dir.join("example.sh")).unwrap();
17257 assert!(example.starts_with("#!/usr/bin/env sh"));
17258 assert!(example.contains("# name: example"));
17259 assert!(example.contains("# description:"));
17260 }
17261
17262 #[test]
17263 fn init_tools_dir_skips_existing_without_force() {
17264 let tmp = TempDir::new().unwrap();
17265 let dir = tmp.path().join("tools");
17266 let _ = init_tools_dir(&dir, false).unwrap();
17267 let (_, readme_status, example_status) = init_tools_dir(&dir, false).unwrap();
17268 assert!(matches!(readme_status, WriteStatus::SkippedExists));
17269 assert!(matches!(example_status, WriteStatus::SkippedExists));
17270 }
17271
17272 #[test]
17273 fn init_tools_dir_force_overwrites() {
17274 let tmp = TempDir::new().unwrap();
17275 let dir = tmp.path().join("tools");
17276 let _ = init_tools_dir(&dir, false).unwrap();
17277 std::fs::write(dir.join("example.sh"), "stale").unwrap();
17278 let (_, _, example_status) = init_tools_dir(&dir, true).unwrap();
17279 assert!(matches!(example_status, WriteStatus::Overwritten));
17280 let example = std::fs::read_to_string(dir.join("example.sh")).unwrap();
17281 assert_ne!(example, "stale");
17282 }
17283
17284 #[test]
17285 fn init_plugins_dir_creates_readme_and_example_layout() {
17286 let tmp = TempDir::new().unwrap();
17287 let dir = tmp.path().join("plugins");
17288 let (readme_path, manifest_path, skill_path, readme_status, manifest_status, skill_status) =
17289 init_plugins_dir(&dir, false).unwrap();
17290
17291 assert_eq!(readme_path, dir.join("README.md"));
17292 assert_eq!(manifest_path, dir.join("example").join("plugin.toml"));
17293 assert_eq!(
17294 skill_path,
17295 dir.join("example/skills/hello").join("SKILL.md")
17296 );
17297 assert!(matches!(readme_status, WriteStatus::Created));
17298 assert!(matches!(manifest_status, WriteStatus::Created));
17299 assert!(matches!(skill_status, WriteStatus::Created));
17300 assert!(readme_path.exists());
17301 assert!(manifest_path.exists());
17302 assert!(skill_path.exists());
17303
17304 let manifest = std::fs::read_to_string(&manifest_path).unwrap();
17305 assert!(manifest.contains("schema_version = 1"));
17306 assert!(manifest.contains("name = \"example\""));
17307 let validated =
17308 crate::plugins::manifest::PluginManifest::validate_from_path(&manifest_path)
17309 .expect("scaffolded plugin should validate");
17310 assert_eq!(validated.inventory.skills, 1);
17311 }
17312
17313 #[test]
17314 fn collect_clean_targets_finds_all_checkpoint_json_files() {
17315 let tmp = TempDir::new().unwrap();
17316 let dir = tmp.path();
17317 std::fs::write(dir.join("latest.json"), "{}").unwrap();
17318 std::fs::write(dir.join("offline_queue.json"), "[]").unwrap();
17319 std::fs::write(dir.join("some-session-id.json"), "{}").unwrap();
17321 std::fs::write(dir.join("notes.txt"), "keep").unwrap();
17323 std::fs::create_dir_all(dir.join("subdir")).unwrap();
17324
17325 let plan = collect_clean_targets(dir);
17326 assert_eq!(plan.targets.len(), 3);
17327 assert!(plan.targets.iter().any(|p| p.ends_with("latest.json")));
17328 assert!(
17329 plan.targets
17330 .iter()
17331 .any(|p| p.ends_with("offline_queue.json"))
17332 );
17333 assert!(
17334 plan.targets
17335 .iter()
17336 .any(|p| p.ends_with("some-session-id.json"))
17337 );
17338 assert!(!plan.targets.iter().any(|p| p.ends_with("notes.txt")));
17339 }
17340
17341 #[test]
17342 fn execute_clean_plan_removes_files_and_returns_them() {
17343 let tmp = TempDir::new().unwrap();
17344 let dir = tmp.path();
17345 let latest = dir.join("latest.json");
17346 let queue = dir.join("offline_queue.json");
17347 std::fs::write(&latest, "{}").unwrap();
17348 std::fs::write(&queue, "[]").unwrap();
17349
17350 let plan = collect_clean_targets(dir);
17351 let removed = execute_clean_plan(&plan).unwrap();
17352 assert_eq!(removed.len(), 2);
17353 assert!(!latest.exists());
17354 assert!(!queue.exists());
17355 }
17356
17357 #[test]
17358 fn run_setup_clean_dry_run_lists_targets_without_force() {
17359 let tmp = TempDir::new().unwrap();
17360 let dir = tmp.path();
17361 std::fs::write(dir.join("latest.json"), "{}").unwrap();
17362 run_setup_clean(dir, false).unwrap();
17363 assert!(dir.join("latest.json").exists());
17365 }
17366
17367 #[test]
17368 fn run_setup_clean_force_removes_files() {
17369 let tmp = TempDir::new().unwrap();
17370 let dir = tmp.path();
17371 std::fs::write(dir.join("latest.json"), "{}").unwrap();
17372 std::fs::write(dir.join("offline_queue.json"), "[]").unwrap();
17373 run_setup_clean(dir, true).unwrap();
17374 assert!(!dir.join("latest.json").exists());
17375 assert!(!dir.join("offline_queue.json").exists());
17376 }
17377
17378 #[test]
17379 fn run_setup_clean_handles_missing_dir() {
17380 let tmp = TempDir::new().unwrap();
17381 let dir = tmp.path().join("does-not-exist");
17382 run_setup_clean(&dir, true).unwrap();
17384 assert!(!dir.exists());
17385 }
17386
17387 fn with_home<T>(home: &Path, f: impl FnOnce() -> T) -> T {
17388 let prev_home = std::env::var_os("HOME");
17389 let prev_userprofile = std::env::var_os("USERPROFILE");
17390 unsafe {
17391 std::env::set_var("HOME", home);
17392 std::env::set_var("USERPROFILE", home);
17393 }
17394 let result = f();
17395 unsafe {
17396 match prev_home {
17397 Some(value) => std::env::set_var("HOME", value),
17398 None => std::env::remove_var("HOME"),
17399 }
17400 match prev_userprofile {
17401 Some(value) => std::env::set_var("USERPROFILE", value),
17402 None => std::env::remove_var("USERPROFILE"),
17403 }
17404 }
17405 result
17406 }
17407
17408 #[test]
17409 fn plain_launch_preserves_checkpoint_but_starts_fresh() {
17410 let _guard = crate::test_support::lock_test_env();
17411 let tmp = TempDir::new().unwrap();
17412 let workspace = tmp.path().join("workspace");
17413 std::fs::create_dir_all(&workspace).unwrap();
17414
17415 with_home(tmp.path(), || {
17416 let manager = SessionManager::default_location().expect("manager");
17417 let messages = vec![Message {
17418 role: "user".to_string(),
17419 content: vec![ContentBlock::Text {
17420 text: "in flight".to_string(),
17421 cache_control: None,
17422 }],
17423 }];
17424 let session = create_saved_session(&messages, "test-model", &workspace, 0, None);
17425 let session_id = session.metadata.id.clone();
17426 manager.save_checkpoint(&session).expect("save checkpoint");
17427
17428 preserve_interrupted_checkpoint_for_explicit_resume(&workspace);
17429
17430 assert!(
17431 manager
17432 .load_session_checkpoint(&session_id)
17433 .expect("load checkpoint")
17434 .is_some(),
17435 "normal launch must leave the per-session checkpoint in place \
17436 (it may belong to a live session; `--continue` consumes it)"
17437 );
17438 assert!(
17440 manager
17441 .load_session_checkpoint(&session_id)
17442 .expect("load checkpoint")
17443 .is_some(),
17444 "checkpoint stays in checkpoints/ for --continue"
17445 );
17446 });
17447 }
17448
17449 #[test]
17450 fn plain_launch_consumes_legacy_checkpoint_after_preserving_it() {
17451 let _guard = crate::test_support::lock_test_env();
17452 let tmp = TempDir::new().unwrap();
17453 let workspace = tmp.path().join("workspace");
17454 std::fs::create_dir_all(&workspace).unwrap();
17455
17456 with_home(tmp.path(), || {
17457 let manager = SessionManager::default_location().expect("manager");
17458 let session = create_saved_session(
17459 &[Message {
17460 role: "user".to_string(),
17461 content: vec![ContentBlock::Text {
17462 text: "legacy in flight".to_string(),
17463 cache_control: None,
17464 }],
17465 }],
17466 "test-model",
17467 &workspace,
17468 0,
17469 None,
17470 );
17471 let session_id = session.metadata.id.clone();
17472 write_legacy_checkpoint(&manager, &session);
17473
17474 preserve_interrupted_checkpoint_for_explicit_resume(&workspace);
17475
17476 assert!(
17477 manager
17478 .load_legacy_checkpoint()
17479 .expect("load legacy checkpoint")
17480 .is_none(),
17481 "normal launch should consume the legacy single-slot checkpoint"
17482 );
17483 assert!(
17485 manager
17486 .load_session_checkpoint(&session_id)
17487 .expect("load checkpoint")
17488 .is_some(),
17489 "checkpoint stays in checkpoints/ for --continue"
17490 );
17491 });
17492 }
17493
17494 #[test]
17495 fn continue_recovers_same_workspace_checkpoint() {
17496 let _guard = crate::test_support::lock_test_env();
17497 let tmp = TempDir::new().unwrap();
17498 let workspace = tmp.path().join("workspace");
17499 std::fs::create_dir_all(&workspace).unwrap();
17500
17501 with_home(tmp.path(), || {
17502 let manager = SessionManager::default_location().expect("manager");
17503 let messages = vec![Message {
17504 role: "user".to_string(),
17505 content: vec![ContentBlock::Text {
17506 text: "continue me".to_string(),
17507 cache_control: None,
17508 }],
17509 }];
17510 let session = create_saved_session(&messages, "test-model", &workspace, 0, None);
17511 let session_id = session.metadata.id.clone();
17512 manager.save_checkpoint(&session).expect("save checkpoint");
17513
17514 let recovered = recover_interrupted_checkpoint_for_resume(&workspace);
17515
17516 assert_eq!(recovered.as_deref(), Some(session_id.as_str()));
17517 assert!(
17518 manager
17519 .load_session_checkpoint(&session_id)
17520 .expect("load checkpoint")
17521 .is_none(),
17522 "--continue should consume the per-session checkpoint"
17523 );
17524 assert!(manager.load_session(&session_id).is_ok());
17525 });
17526 }
17527
17528 fn write_legacy_checkpoint(manager: &SessionManager, session: &session_manager::SavedSession) {
17531 let checkpoints = manager.sessions_dir().join("checkpoints");
17532 std::fs::create_dir_all(&checkpoints).expect("create checkpoints dir");
17533 let content = serde_json::to_string_pretty(session).expect("serialize checkpoint");
17534 std::fs::write(checkpoints.join("latest.json"), content).expect("write legacy checkpoint");
17535 }
17536
17537 #[test]
17538 fn continue_recovers_legacy_checkpoint_and_migrates_it() {
17539 let _guard = crate::test_support::lock_test_env();
17540 let tmp = TempDir::new().unwrap();
17541 let workspace = tmp.path().join("workspace");
17542 std::fs::create_dir_all(&workspace).unwrap();
17543
17544 with_home(tmp.path(), || {
17545 let manager = SessionManager::default_location().expect("manager");
17546 let messages = vec![Message {
17547 role: "user".to_string(),
17548 content: vec![ContentBlock::Text {
17549 text: "legacy continue".to_string(),
17550 cache_control: None,
17551 }],
17552 }];
17553 let session = create_saved_session(&messages, "test-model", &workspace, 0, None);
17554 let session_id = session.metadata.id.clone();
17555 write_legacy_checkpoint(&manager, &session);
17556
17557 let recovered = recover_interrupted_checkpoint_for_resume(&workspace);
17558
17559 assert_eq!(recovered.as_deref(), Some(session_id.as_str()));
17560 assert!(
17561 manager.load_session(&session_id).is_ok(),
17562 "recovered legacy checkpoint must be loadable as a session"
17563 );
17564 assert!(
17565 manager
17566 .load_session_checkpoint(&session_id)
17567 .expect("load per-session checkpoint")
17568 .is_some(),
17569 "legacy recovery must migrate to a per-session checkpoint file"
17570 );
17571 assert!(
17572 manager
17573 .load_legacy_checkpoint()
17574 .expect("load legacy checkpoint")
17575 .is_some(),
17576 "legacy latest.json stays in place for one more release"
17577 );
17578 });
17579 }
17580
17581 #[test]
17582 fn continue_refuses_checkpoint_from_other_workspace() {
17583 let _guard = crate::test_support::lock_test_env();
17584 let tmp = TempDir::new().unwrap();
17585 let launch_workspace = tmp.path().join("launch-workspace");
17586 let other_workspace = tmp.path().join("other-workspace");
17587 std::fs::create_dir_all(&launch_workspace).unwrap();
17588 std::fs::create_dir_all(&other_workspace).unwrap();
17589
17590 with_home(tmp.path(), || {
17591 let manager = SessionManager::default_location().expect("manager");
17592 let messages = vec![Message {
17593 role: "user".to_string(),
17594 content: vec![ContentBlock::Text {
17595 text: "belongs elsewhere".to_string(),
17596 cache_control: None,
17597 }],
17598 }];
17599 let session = create_saved_session(&messages, "test-model", &other_workspace, 0, None);
17600 let session_id = session.metadata.id.clone();
17601 manager.save_checkpoint(&session).expect("save checkpoint");
17602
17603 let recovered = recover_interrupted_checkpoint_for_resume(&launch_workspace);
17604
17605 assert_eq!(recovered, None, "workspace mismatch must refuse recovery");
17606 assert!(
17607 manager
17608 .load_session_checkpoint(&session_id)
17609 .expect("load checkpoint")
17610 .is_some(),
17611 "another workspace's checkpoint file must be left untouched"
17612 );
17613 });
17614 }
17615
17616 #[test]
17617 fn continue_twice_does_not_clobber_newer_session_with_stale_legacy_checkpoint() {
17618 let _guard = crate::test_support::lock_test_env();
17619 let tmp = TempDir::new().unwrap();
17620 let workspace = tmp.path().join("workspace");
17621 std::fs::create_dir_all(&workspace).unwrap();
17622
17623 with_home(tmp.path(), || {
17624 let manager = SessionManager::default_location().expect("manager");
17625 let stale = create_saved_session(
17626 &[Message {
17627 role: "user".to_string(),
17628 content: vec![ContentBlock::Text {
17629 text: "crash-time state".to_string(),
17630 cache_control: None,
17631 }],
17632 }],
17633 "test-model",
17634 &workspace,
17635 0,
17636 None,
17637 );
17638 let session_id = stale.metadata.id.clone();
17639 write_legacy_checkpoint(&manager, &stale);
17640
17641 let mut advanced = stale.clone();
17644 advanced.messages.push(Message {
17645 role: "assistant".to_string(),
17646 content: vec![ContentBlock::Text {
17647 text: "post-recovery progress".to_string(),
17648 cache_control: None,
17649 }],
17650 });
17651 advanced.metadata.message_count = advanced.messages.len();
17652 advanced.metadata.updated_at = stale.metadata.updated_at + chrono::Duration::hours(1);
17653 manager.save_session(&advanced).expect("save newer session");
17654
17655 let recovered = recover_interrupted_checkpoint_for_resume(&workspace);
17656
17657 assert_eq!(recovered.as_deref(), Some(session_id.as_str()));
17658 let persisted = manager.load_session(&session_id).expect("load session");
17659 assert_eq!(
17660 persisted.messages.len(),
17661 advanced.messages.len(),
17662 "stale checkpoint content must not overwrite the newer session"
17663 );
17664 });
17665 }
17666
17667 #[test]
17668 fn dotenv_status_points_to_example_when_present() {
17669 let tmp = TempDir::new().unwrap();
17670 std::fs::write(tmp.path().join(".env.example"), "DEEPSEEK_API_KEY=\n").unwrap();
17671
17672 assert_eq!(
17673 dotenv_status_line(tmp.path()),
17674 ".env not present in workspace (run `cp .env.example .env` and edit)"
17675 );
17676
17677 std::fs::write(tmp.path().join(".env"), "DEEPSEEK_API_KEY=test\n").unwrap();
17678 assert!(dotenv_status_line(tmp.path()).contains(".env present at"));
17679 }
17680
17681 #[test]
17682 fn env_example_is_trackable_and_every_key_is_wired() {
17683 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
17684 let env_example = std::fs::read_to_string(root.join(".env.example")).unwrap();
17685 let gitignore = std::fs::read_to_string(root.join(".gitignore")).unwrap();
17686
17687 assert!(gitignore.contains("!.env.example"));
17688
17689 let keys = documented_env_keys(&env_example);
17690 for required in [
17691 "DEEPSEEK_API_KEY",
17692 "NVIDIA_API_KEY",
17693 "NVIDIA_NIM_API_KEY",
17694 "ATLASCLOUD_API_KEY",
17695 ] {
17696 assert!(
17697 keys.contains(required),
17698 ".env.example is missing {required}"
17699 );
17700 }
17701
17702 for key in &keys {
17703 assert!(
17704 is_workspace_dotenv_credential_key(key),
17705 ".env.example documents non-credential control setting {key}"
17706 );
17707 }
17708
17709 let sources = [
17710 include_str!("config.rs"),
17711 include_str!("logging.rs"),
17712 include_str!("../../config/src/lib.rs"),
17713 include_str!("../../config/src/provider.rs"),
17714 include_str!("../../cli/src/main.rs"),
17715 ]
17716 .join("\n");
17717
17718 for key in keys {
17719 assert!(
17720 sources.contains(&key),
17721 ".env.example documents {key}, but no source file references it"
17722 );
17723 }
17724 }
17725
17726 fn documented_env_keys(content: &str) -> BTreeSet<String> {
17727 content
17728 .lines()
17729 .filter_map(|line| {
17730 let trimmed = line.trim();
17731 let uncommented = trimmed
17732 .strip_prefix('#')
17733 .map(str::trim_start)
17734 .unwrap_or(trimmed);
17735 let (key, _) = uncommented.split_once('=')?;
17736 let key = key.trim();
17737 let is_env_key = key
17738 .chars()
17739 .all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_')
17740 && key.chars().any(|ch| ch == '_');
17741 is_env_key.then(|| key.to_string())
17742 })
17743 .collect()
17744 }
17745
17746 #[test]
17747 fn custom_provider_env_source_precedes_saved_secret_store() {
17748 let _lock = crate::test_support::lock_test_env();
17749 let temp = TempDir::new().expect("temp home");
17750 let codewhale_home = temp.path().join("codewhale-home");
17751 std::fs::create_dir_all(&codewhale_home).expect("create codewhale home");
17752 let _home =
17753 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", codewhale_home.as_os_str());
17754 let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
17755 let _declared_env =
17756 crate::test_support::EnvVarGuard::set("QA_CUSTOM_API_KEY", "declared-env-key");
17757 let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
17758 let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
17759 codewhale_secrets::Secrets::auto_detect()
17760 .set("custom", "saved-custom-secret")
17761 .expect("save secret");
17762
17763 let mut custom = std::collections::HashMap::new();
17764 custom.insert(
17765 "qa-gateway".to_string(),
17766 crate::config::ProviderConfig {
17767 kind: Some("openai-compatible".to_string()),
17768 base_url: Some("https://gateway.example.test/v1".to_string()),
17769 model: Some("qa-model".to_string()),
17770 api_key_env: Some("QA_CUSTOM_API_KEY".to_string()),
17771 ..Default::default()
17772 },
17773 );
17774 let config = Config {
17775 provider: Some("qa-gateway".to_string()),
17776 providers: Some(crate::config::ProvidersConfig {
17777 custom,
17778 ..Default::default()
17779 }),
17780 ..Config::default()
17781 };
17782
17783 assert_eq!(resolve_api_key_source(&config), ApiKeySource::EnvDeclared);
17784 assert_eq!(
17785 config.deepseek_api_key().expect("custom key"),
17786 "declared-env-key"
17787 );
17788 }
17789
17790 #[test]
17791 fn named_custom_provider_does_not_report_generic_secret_store() {
17792 let _lock = crate::test_support::lock_test_env();
17793 let temp = TempDir::new().expect("temp home");
17794 let codewhale_home = temp.path().join("codewhale-home");
17795 std::fs::create_dir_all(&codewhale_home).expect("create codewhale home");
17796 let _home =
17797 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", codewhale_home.as_os_str());
17798 let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
17799 let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
17800 let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
17801 codewhale_secrets::Secrets::auto_detect()
17802 .set("custom", "unrelated-custom-secret")
17803 .expect("save secret");
17804
17805 let mut custom = std::collections::HashMap::new();
17806 custom.insert(
17807 "qa-gateway".to_string(),
17808 crate::config::ProviderConfig {
17809 kind: Some("openai-compatible".to_string()),
17810 base_url: Some("https://gateway.example.test/v1".to_string()),
17811 model: Some("qa-model".to_string()),
17812 auth_mode: Some("api_key".to_string()),
17813 ..Default::default()
17814 },
17815 );
17816 let config = Config {
17817 provider: Some("qa-gateway".to_string()),
17818 providers: Some(crate::config::ProvidersConfig {
17819 custom,
17820 ..Default::default()
17821 }),
17822 ..Config::default()
17823 };
17824
17825 assert_eq!(resolve_api_key_source(&config), ApiKeySource::Unknown);
17826 assert!(config.deepseek_api_key().is_err());
17827 }
17828
17829 #[test]
17830 fn custom_built_in_endpoint_does_not_report_ambient_provider_key() {
17831 let _lock = crate::test_support::lock_test_env();
17832 let _openrouter =
17833 crate::test_support::EnvVarGuard::set("OPENROUTER_API_KEY", "ambient-key");
17834 let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
17835 let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
17836 let mut providers = crate::config::ProvidersConfig::default();
17837 providers.openrouter.base_url = Some("https://gateway.example.test/v1".to_string());
17838 let config = Config {
17839 provider: Some("openrouter".to_string()),
17840 providers: Some(providers),
17841 ..Config::default()
17842 };
17843
17844 assert_eq!(resolve_api_key_source(&config), ApiKeySource::Unknown);
17845 assert!(config.deepseek_api_key().is_err());
17846 }
17847
17848 #[test]
17849 fn ollama_doctor_credential_source_is_route_aware() {
17850 let local = Config {
17851 provider: Some("ollama".to_string()),
17852 ..Config::default()
17853 };
17854 assert_eq!(resolve_api_key_source(&local), ApiKeySource::LocalRuntime);
17855 assert_eq!(
17856 resolve_credential_diagnostic(&local).availability,
17857 CredentialAvailability::NotRequired
17858 );
17859
17860 let ollama_config = |base_url: &str| Config {
17861 provider: Some("ollama".to_string()),
17862 providers: Some(crate::config::ProvidersConfig {
17863 ollama: crate::config::ProviderConfig {
17864 base_url: Some(base_url.to_string()),
17865 ..Default::default()
17866 },
17867 ..Default::default()
17868 }),
17869 ..Config::default()
17870 };
17871 let cloud = ollama_config(codewhale_config::provider::OLLAMA_CLOUD_BASE_URL);
17872 assert_eq!(
17873 cloud.api_provider(),
17874 crate::config::ApiProvider::OllamaCloud
17875 );
17876 assert_eq!(
17877 resolve_api_key_source(&cloud),
17878 ApiKeySource::SecretStoreUnprobed
17879 );
17880 assert_eq!(
17881 resolve_credential_diagnostic(&cloud).availability,
17882 CredentialAvailability::NotProbed
17883 );
17884 assert_eq!(doctor_auth_scheme(&cloud), "bearer");
17885 let report = doctor_route_report(&cloud);
17886 assert_eq!(report["provider"], "ollama-cloud");
17887 assert_eq!(report["provider_config_table"], "ollama_cloud");
17888
17889 let custom_remote = ollama_config("https://ollama-gateway.example.test/v1");
17890 assert_eq!(
17891 resolve_api_key_source(&custom_remote),
17892 ApiKeySource::Unknown
17893 );
17894 assert_eq!(
17895 resolve_credential_diagnostic(&custom_remote).availability,
17896 CredentialAvailability::Unknown
17897 );
17898 }
17899
17900 #[test]
17901 fn auth_mode_none_reports_distinct_no_auth_source_and_scheme() {
17902 let _lock = crate::test_support::lock_test_env();
17903 let _openrouter =
17904 crate::test_support::EnvVarGuard::set("OPENROUTER_API_KEY", "ambient-key");
17905 let mut providers = crate::config::ProvidersConfig::default();
17906 providers.openrouter.auth_mode = Some("none".to_string());
17907 providers.openrouter.api_key = Some("configured-key".to_string());
17908 let config = Config {
17909 provider: Some("openrouter".to_string()),
17910 providers: Some(providers),
17911 ..Config::default()
17912 };
17913
17914 assert_eq!(resolve_api_key_source(&config), ApiKeySource::NoAuth);
17915 assert_eq!(doctor_api_key_source_label(ApiKeySource::NoAuth), "none");
17916 assert_eq!(doctor_auth_scheme(&config), "none");
17917 assert_eq!(config.deepseek_api_key().expect("no-auth route"), "");
17918 }
17919
17920 #[test]
17921 fn resolve_api_key_source_prefers_config_over_env() {
17922 let _guard = crate::test_support::lock_test_env();
17923 let prev = std::env::var("DEEPSEEK_API_KEY").ok();
17924 let prev_source = std::env::var("DEEPSEEK_API_KEY_SOURCE").ok();
17925 unsafe {
17926 std::env::set_var("DEEPSEEK_API_KEY", "stale-env-key");
17927 std::env::remove_var("DEEPSEEK_API_KEY_SOURCE");
17928 }
17929 let cfg = Config {
17930 api_key: Some("fresh-config-key".to_string()),
17931 ..Config::default()
17932 };
17933 let source = resolve_api_key_source(&cfg);
17934 match prev {
17935 Some(value) => unsafe { std::env::set_var("DEEPSEEK_API_KEY", value) },
17936 None => unsafe { std::env::remove_var("DEEPSEEK_API_KEY") },
17937 }
17938 match prev_source {
17939 Some(value) => unsafe { std::env::set_var("DEEPSEEK_API_KEY_SOURCE", value) },
17940 None => unsafe { std::env::remove_var("DEEPSEEK_API_KEY_SOURCE") },
17941 }
17942 assert_eq!(source, ApiKeySource::ConfigDeclared);
17943 }
17944
17945 #[test]
17946 fn resolve_api_key_source_reports_active_provider_env_from_metadata() {
17947 let _guard = crate::test_support::lock_test_env();
17948 let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
17949 let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
17950 let _anthropic_key =
17951 crate::test_support::EnvVarGuard::set("ANTHROPIC_API_KEY", "test-anthropic-key");
17952 let cfg = Config {
17953 provider: Some("anthropic".to_string()),
17954 ..Config::default()
17955 };
17956
17957 let source = resolve_api_key_source(&cfg);
17958
17959 assert_eq!(source, ApiKeySource::SecretStoreUnprobed);
17960 }
17961
17962 #[test]
17963 fn resolve_api_key_source_ignores_unresolved_provider_command_metadata() {
17964 let _guard = crate::test_support::lock_test_env();
17965 let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
17966 let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
17967 let _openai_key = crate::test_support::EnvVarGuard::remove("OPENAI_API_KEY");
17968 let mut providers = crate::config::ProvidersConfig::default();
17969 providers.openai.auth = Some(codewhale_config::ProviderAuthSourceToml {
17970 source: codewhale_config::AuthSourceKind::Command,
17971 command: vec!["secret-tool".to_string(), "lookup".to_string()],
17972 timeout_ms: Some(2000),
17973 secret_id: None,
17974 });
17975 let cfg = Config {
17976 provider: Some("openai".to_string()),
17977 providers: Some(providers),
17978 ..Config::default()
17979 };
17980
17981 let source = resolve_api_key_source(&cfg);
17982
17983 assert_eq!(source, ApiKeySource::ExternalAuthDeclared);
17984 assert!(cfg.deepseek_api_key().is_err());
17985 }
17986
17987 #[test]
17988 fn resolve_api_key_source_ignores_unresolved_provider_secret_metadata() {
17989 let _guard = crate::test_support::lock_test_env();
17990 let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
17991 let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
17992 let _openai_key = crate::test_support::EnvVarGuard::remove("OPENAI_API_KEY");
17993 let mut providers = crate::config::ProvidersConfig::default();
17994 providers.openai.auth = Some(codewhale_config::ProviderAuthSourceToml {
17995 source: codewhale_config::AuthSourceKind::Secret,
17996 command: Vec::new(),
17997 timeout_ms: None,
17998 secret_id: Some("codewhale/openai".to_string()),
17999 });
18000 let cfg = Config {
18001 provider: Some("openai".to_string()),
18002 providers: Some(providers),
18003 ..Config::default()
18004 };
18005
18006 let source = resolve_api_key_source(&cfg);
18007
18008 assert_eq!(source, ApiKeySource::ExternalAuthDeclared);
18009 assert!(cfg.deepseek_api_key().is_err());
18010 }
18011
18012 #[test]
18013 fn resolve_api_key_source_ignores_root_deepseek_key_for_other_provider() {
18014 let _guard = crate::test_support::lock_test_env();
18015 let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
18016 let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
18017 let _openrouter_key = crate::test_support::EnvVarGuard::remove("OPENROUTER_API_KEY");
18018 let cfg = Config {
18019 provider: Some("openrouter".to_string()),
18020 api_key: Some("legacy-deepseek-root-key".to_string()),
18021 ..Config::default()
18022 };
18023
18024 let source = resolve_api_key_source(&cfg);
18025
18026 assert_eq!(source, ApiKeySource::SecretStoreUnprobed);
18027 }
18028
18029 #[test]
18030 fn provider_status_helpers_use_provider_metadata() {
18031 assert_eq!(
18032 provider_config_table_key(crate::config::ApiProvider::Anthropic),
18033 "anthropic"
18034 );
18035 assert_eq!(
18036 provider_config_table_key(crate::config::ApiProvider::SiliconflowCn),
18037 "siliconflow_cn"
18038 );
18039 }
18040
18041 #[test]
18042 fn skills_count_for_returns_zero_for_missing_dir() {
18043 let tmp = TempDir::new().unwrap();
18044 let dir = tmp.path().join("nope");
18045 assert_eq!(skills_count_for(&dir), 0);
18046 }
18047
18048 #[test]
18049 fn skills_count_for_counts_valid_skill_dirs() {
18050 let tmp = TempDir::new().unwrap();
18051 let dir = tmp.path().join("skills");
18052 let skill_dir = dir.join("getting-started");
18053 std::fs::create_dir_all(&skill_dir).unwrap();
18054 std::fs::write(
18055 skill_dir.join("SKILL.md"),
18056 "---\nname: getting-started\ndescription: hi\n---\nbody",
18057 )
18058 .unwrap();
18059 assert_eq!(skills_count_for(&dir), 1);
18060 }
18061}
18062
18063#[cfg(test)]
18064#[path = "tests/pr_prompt.rs"]
18065mod pr_prompt_tests;
18066
18067#[cfg(test)]
18068#[path = "tests/telemetry_surface.rs"]
18069mod telemetry_surface_tests;
18070
18071#[cfg(test)]
18072#[path = "tests/telemetry_counters.rs"]
18073mod telemetry_counter_tests;