返回 DeepSeek-TUI-2026
main.rs
根目录 / crates / tui / src / main.rs
1 //! CLI entry point for the `DeepSeek` client.
2
3 use std::io::{self, IsTerminal, Read, Write};
4 use std::path::{Path, PathBuf};
5 use std::process::{Command, Stdio};
6 use std::time::Duration;
7
8 use anyhow::{Context, Result, anyhow, bail};
9 use clap::{Args, CommandFactory, Parser, Subcommand};
10 use clap_complete::{Shell, generate};
11 use dotenvy::dotenv;
12 use tempfile::NamedTempFile;
13 use wait_timeout::ChildExt;
14
15 mod acp_server;
16 mod audit;
17 mod auto_reasoning;
18 mod automation_manager;
19 mod client;
20 mod command_safety;
21 mod commands;
22 mod compaction;
23 mod composer_history;
24 mod composer_stash;
25 mod config;
26 mod config_ui;
27 mod core;
28 mod cost_status;
29 mod cycle_manager;
30 mod deepseek_theme;
31 mod error_taxonomy;
32 mod eval;
33 mod execpolicy;
34 mod features;
35 mod handoff;
36 mod hooks;
37 mod llm_client;
38 mod localization;
39 mod logging;
40 mod lsp;
41 mod mcp;
42 mod mcp_server;
43 mod memory;
44 mod models;
45 mod network_policy;
46 mod palette;
47 mod pricing;
48 mod project_context;
49 mod project_doc;
50 mod prompts;
51 pub mod repl;
52 mod retry_status;
53 pub mod rlm;
54 mod runtime_api;
55 mod runtime_threads;
56 mod sandbox;
57 mod schema_migration;
58 mod seam_manager;
59 mod session_manager;
60 mod settings;
61 mod skills;
62 mod snapshot;
63 mod task_manager;
64 #[cfg(test)]
65 mod test_support;
66 mod tools;
67 mod tui;
68 mod utils;
69 mod working_set;
70 mod workspace_trust;
71
72 use crate::config::{Config, DEFAULT_TEXT_MODEL, MAX_SUBAGENTS};
73 use crate::eval::{EvalHarness, EvalHarnessConfig, ScenarioStepKind};
74 use crate::features::{Feature, render_feature_table};
75 use crate::llm_client::LlmClient;
76 use crate::mcp::{McpConfig, McpPool, McpServerConfig};
77 use crate::models::{ContentBlock, Message, MessageRequest, SystemPrompt};
78 use crate::session_manager::{SessionManager, create_saved_session, truncate_id};
79 use crate::tui::history::{summarize_tool_args, summarize_tool_output};
80
81 #[cfg(windows)]
82 fn configure_windows_console_utf8() {
83 use windows::Win32::System::Console::{SetConsoleCP, SetConsoleOutputCP};
84
85 const CP_UTF8: u32 = 65001;
86 unsafe {
87 let _ = SetConsoleCP(CP_UTF8);
88 let _ = SetConsoleOutputCP(CP_UTF8);
89 }
90 }
91
92 #[cfg(not(windows))]
93 fn configure_windows_console_utf8() {}
94
95 #[derive(Parser, Debug)]
96 #[command(
97 name = "deepseek",
98 author,
99 version,
100 about = "DeepSeek TUI/CLI for DeepSeek models",
101 long_about = "Terminal-native TUI and CLI for DeepSeek models.\n\nRun 'deepseek' to start.\n\nNot affiliated with DeepSeek Inc."
102 )]
103 struct Cli {
104 /// Subcommand to run
105 #[command(subcommand)]
106 command: Option<Commands>,
107
108 #[command(flatten)]
109 feature_toggles: FeatureToggles,
110
111 /// Send a one-shot prompt (non-interactive)
112 #[arg(short, long)]
113 prompt: Option<String>,
114
115 /// YOLO mode: enable agent tools + shell execution
116 #[arg(long)]
117 yolo: bool,
118
119 /// Maximum number of concurrent sub-agents (1-20)
120 #[arg(long)]
121 max_subagents: Option<usize>,
122
123 /// Path to config file
124 #[arg(long)]
125 config: Option<PathBuf>,
126
127 /// Enable verbose logging
128 #[arg(short, long)]
129 verbose: bool,
130
131 /// Config profile name
132 #[arg(long)]
133 profile: Option<String>,
134
135 /// Workspace directory for file operations
136 #[arg(short, long)]
137 workspace: Option<PathBuf>,
138
139 /// Resume a previous session by ID or prefix
140 #[arg(short, long)]
141 resume: Option<String>,
142
143 /// Continue the most recent session in this workspace
144 #[arg(short = 'c', long = "continue")]
145 continue_session: bool,
146
147 /// Disable the alternate screen buffer (inline mode)
148 #[arg(long = "no-alt-screen")]
149 no_alt_screen: bool,
150
151 /// Enable TUI mouse capture for internal scrolling and transcript selection
152 /// (default off on Windows)
153 #[arg(long = "mouse-capture", conflicts_with = "no_mouse_capture")]
154 mouse_capture: bool,
155
156 /// Disable TUI mouse capture so terminal-native text selection works
157 #[arg(long = "no-mouse-capture", conflicts_with = "mouse_capture")]
158 no_mouse_capture: bool,
159
160 /// Skip onboarding screens
161 #[arg(long)]
162 skip_onboarding: bool,
163
164 /// Start a fresh session, ignoring any crash-recovery checkpoint
165 #[arg(long = "fresh")]
166 fresh: bool,
167
168 /// Skip loading project-level config from $WORKSPACE/.deepseek/config.toml
169 #[arg(long = "no-project-config")]
170 no_project_config: bool,
171 }
172
173 #[derive(Subcommand, Debug, Clone)]
174 #[allow(clippy::large_enum_variant)]
175 enum Commands {
176 /// Run system diagnostics and check configuration
177 Doctor(DoctorArgs),
178 /// Bootstrap MCP config and/or skills directories
179 Setup(SetupArgs),
180 /// Generate shell completions
181 Completions {
182 /// Shell to generate completions for
183 #[arg(value_enum)]
184 shell: Shell,
185 },
186 /// List saved sessions
187 Sessions {
188 /// Maximum number of sessions to display
189 #[arg(short, long, default_value = "20")]
190 limit: usize,
191 /// Search sessions by title
192 #[arg(short, long)]
193 search: Option<String>,
194 },
195 /// Create default AGENTS.md in current directory
196 Init,
197 /// Save a DeepSeek API key to the shared user config
198 Login {
199 /// API key to store (otherwise read from stdin)
200 #[arg(long)]
201 api_key: Option<String>,
202 },
203 /// Remove the saved API key
204 Logout,
205 /// List available models from the configured API endpoint
206 Models(ModelsArgs),
207 /// Run a non-interactive prompt
208 Exec(ExecArgs),
209 /// Run a code review over a git diff
210 Review(ReviewArgs),
211 /// Open the TUI pre-seeded with a GitHub PR's title, body, and diff (#451)
212 Pr {
213 /// PR number
214 #[arg(value_name = "NUMBER")]
215 number: u32,
216 /// Repository in `owner/name` form. Defaults to the current
217 /// workspace's `gh` config (i.e. the repo gh thinks you're in).
218 #[arg(short = 'R', long)]
219 repo: Option<String>,
220 /// Skip `gh pr checkout` even if gh is available. By default
221 /// the working tree is left as-is — checkout is opt-in via
222 /// `--checkout` because dirty trees fail it loudly.
223 #[arg(long, default_value_t = false)]
224 checkout: bool,
225 },
226 /// Apply a patch file (or stdin) to the working tree
227 Apply(ApplyArgs),
228 /// Run the offline evaluation harness (no network/LLM calls)
229 Eval(EvalArgs),
230 /// Manage MCP servers
231 Mcp {
232 #[command(subcommand)]
233 command: McpCommand,
234 },
235 /// Execpolicy tooling
236 Execpolicy(ExecpolicyCommand),
237 /// Inspect feature flags
238 Features(FeaturesCli),
239 /// Run a command inside the sandbox
240 Sandbox(SandboxArgs),
241 /// Run a local server (e.g. MCP)
242 Serve(ServeArgs),
243 /// Resume a previous session by ID (use --last for most recent)
244 Resume {
245 /// Conversation/session id (UUID or prefix)
246 #[arg(value_name = "SESSION_ID")]
247 session_id: Option<String>,
248 /// Continue the most recent session in this workspace without a picker
249 #[arg(long = "last", default_value_t = false, conflicts_with = "session_id")]
250 last: bool,
251 },
252 /// Fork a previous session by ID (use --last for most recent)
253 Fork {
254 /// Conversation/session id (UUID or prefix)
255 #[arg(value_name = "SESSION_ID")]
256 session_id: Option<String>,
257 /// Fork the most recent session in this workspace without a picker
258 #[arg(long = "last", default_value_t = false, conflicts_with = "session_id")]
259 last: bool,
260 },
261 }
262
263 #[derive(Args, Debug, Clone)]
264 struct ExecArgs {
265 /// Prompt to send to the model
266 prompt: String,
267 /// Override model for this run
268 #[arg(long)]
269 model: Option<String>,
270 /// Enable agentic mode with tool access and auto-approvals
271 #[arg(long, default_value_t = false)]
272 auto: bool,
273 /// Emit machine-readable JSON output
274 #[arg(long, default_value_t = false)]
275 json: bool,
276 }
277
278 #[derive(Args, Debug, Clone, Default)]
279 struct SetupArgs {
280 /// Initialize MCP configuration at the configured path
281 #[arg(long, default_value_t = false)]
282 mcp: bool,
283 /// Initialize skills directory and an example skill
284 #[arg(long, default_value_t = false)]
285 skills: bool,
286 /// Initialize tools directory with a self-describing example script
287 #[arg(long, default_value_t = false)]
288 tools: bool,
289 /// Initialize plugins directory with a self-describing example
290 #[arg(long, default_value_t = false)]
291 plugins: bool,
292 /// Initialize MCP config, skills, tools, and plugins
293 #[arg(long, default_value_t = false)]
294 all: bool,
295 /// Create a local workspace skills directory (./skills)
296 #[arg(long, default_value_t = false)]
297 local: bool,
298 /// Overwrite existing template files
299 #[arg(long, default_value_t = false)]
300 force: bool,
301 /// Print a compact, read-only status report (no network calls)
302 #[arg(long, default_value_t = false, conflicts_with_all = ["mcp", "skills", "tools", "plugins", "all", "local", "clean"])]
303 status: bool,
304 /// Remove regenerable session checkpoints (latest + offline_queue)
305 #[arg(long, default_value_t = false, conflicts_with_all = ["mcp", "skills", "tools", "plugins", "all", "local", "status"])]
306 clean: bool,
307 }
308
309 #[derive(Args, Debug, Clone, Default)]
310 struct DoctorArgs {
311 /// Emit machine-readable JSON output (skips live API connectivity check)
312 #[arg(long, default_value_t = false)]
313 json: bool,
314 }
315
316 #[derive(Args, Debug, Clone)]
317 struct EvalArgs {
318 /// Intentionally fail a specific step (list, read, search, edit, patch, shell)
319 #[arg(long, value_name = "STEP")]
320 fail_step: Option<String>,
321 /// Shell command to run during the exec step
322 #[arg(long, default_value = "printf eval-harness")]
323 shell_command: String,
324 /// Token that must appear in shell output for validation
325 #[arg(long, default_value = "eval-harness")]
326 shell_expect_token: String,
327 /// Maximum characters stored per step output summary
328 #[arg(long, default_value_t = 240)]
329 max_output_chars: usize,
330 /// Emit machine-readable JSON output
331 #[arg(long, default_value_t = false)]
332 json: bool,
333 /// Append one JSONL fixture line per step to `<DIR>/<scenario>.jsonl`.
334 /// Mock LLM tests can later replay these fixtures.
335 #[arg(long, value_name = "DIR")]
336 record: Option<PathBuf>,
337 }
338
339 #[derive(Args, Debug, Clone, Default)]
340 struct ModelsArgs {
341 /// Print models as pretty JSON
342 #[arg(long, default_value_t = false)]
343 json: bool,
344 }
345
346 #[derive(Args, Debug, Default, Clone)]
347 struct FeatureToggles {
348 /// Enable a feature (repeatable). Equivalent to `features.<name>=true`.
349 #[arg(long = "enable", value_name = "FEATURE", action = clap::ArgAction::Append, global = true)]
350 enable: Vec<String>,
351
352 /// Disable a feature (repeatable). Equivalent to `features.<name>=false`.
353 #[arg(long = "disable", value_name = "FEATURE", action = clap::ArgAction::Append, global = true)]
354 disable: Vec<String>,
355 }
356
357 impl FeatureToggles {
358 fn apply(&self, config: &mut Config) -> Result<()> {
359 for feature in &self.enable {
360 config.set_feature(feature, true)?;
361 }
362 for feature in &self.disable {
363 config.set_feature(feature, false)?;
364 }
365 Ok(())
366 }
367 }
368
369 #[derive(Args, Debug, Clone)]
370 struct ReviewArgs {
371 /// Review staged changes instead of the working tree
372 #[arg(long, conflicts_with = "base")]
373 staged: bool,
374 /// Base ref to diff against (e.g. origin/main)
375 #[arg(long)]
376 base: Option<String>,
377 /// Limit diff to a specific path
378 #[arg(long)]
379 path: Option<PathBuf>,
380 /// Override model for this review
381 #[arg(long)]
382 model: Option<String>,
383 /// Maximum diff characters to include
384 #[arg(long, default_value_t = 200_000)]
385 max_chars: usize,
386 /// Emit machine-readable JSON output
387 #[arg(long, default_value_t = false)]
388 json: bool,
389 }
390
391 #[derive(Args, Debug, Clone)]
392 struct ApplyArgs {
393 /// Patch file to apply (defaults to stdin)
394 #[arg(value_name = "PATCH_FILE")]
395 patch_file: Option<PathBuf>,
396 }
397
398 #[derive(Args, Debug, Clone)]
399 struct ServeArgs {
400 /// Start MCP server over stdio
401 #[arg(long)]
402 mcp: bool,
403 /// Start runtime HTTP/SSE API server
404 #[arg(long)]
405 http: bool,
406 /// Start ACP server over stdio for editor clients such as Zed
407 #[arg(long)]
408 acp: bool,
409 /// Bind host for HTTP server (default localhost)
410 #[arg(long, default_value = "127.0.0.1")]
411 host: String,
412 /// Bind port for HTTP server
413 #[arg(long, default_value_t = 7878)]
414 port: u16,
415 /// Background task worker count (1-8)
416 #[arg(long, default_value_t = 2)]
417 workers: usize,
418 /// Additional CORS origin to allow (repeatable). Stacks on top of the
419 /// built-in defaults (localhost:3000, localhost:1420, tauri://localhost).
420 /// Also reads `DEEPSEEK_CORS_ORIGINS` (comma-separated) and
421 /// `[runtime_api] cors_origins` from `config.toml`. Whalescale#255.
422 #[arg(long = "cors-origin", value_name = "URL")]
423 cors_origin: Vec<String>,
424 /// Require this bearer token for `/v1/*` runtime API routes. Also reads
425 /// `DEEPSEEK_RUNTIME_TOKEN` when omitted.
426 #[arg(long = "auth-token", value_name = "TOKEN")]
427 auth_token: Option<String>,
428 }
429
430 #[derive(Subcommand, Debug, Clone)]
431 enum McpCommand {
432 /// List configured MCP servers
433 List,
434 /// Create a template MCP config at the configured path
435 Init {
436 /// Overwrite an existing MCP config file
437 #[arg(long, default_value_t = false)]
438 force: bool,
439 },
440 /// Connect to MCP servers and report status
441 Connect {
442 /// Optional server name to connect to
443 #[arg(value_name = "SERVER")]
444 server: Option<String>,
445 },
446 /// List tools discovered from MCP servers
447 Tools {
448 /// Optional server name to list tools for
449 #[arg(value_name = "SERVER")]
450 server: Option<String>,
451 },
452 /// Add an MCP server entry
453 Add {
454 /// Server name
455 name: String,
456 /// Command to launch stdio server
457 #[arg(long, conflicts_with = "url")]
458 command: Option<String>,
459 /// URL for streamable HTTP/SSE server
460 #[arg(long, conflicts_with = "command")]
461 url: Option<String>,
462 /// Arguments for command-based servers
463 #[arg(long = "arg")]
464 args: Vec<String>,
465 },
466 /// Remove an MCP server entry
467 Remove {
468 /// Server name
469 name: String,
470 },
471 /// Enable an MCP server
472 Enable {
473 /// Server name
474 name: String,
475 },
476 /// Disable an MCP server
477 Disable {
478 /// Server name
479 name: String,
480 },
481 /// Validate MCP config and required servers
482 Validate,
483 /// Register this DeepSeek binary as a local MCP stdio server.
484 ///
485 /// This adds a config entry that runs `deepseek serve --mcp` (stdio protocol).
486 /// For the HTTP/SSE runtime API, use `deepseek serve --http` directly instead.
487 #[command(
488 name = "add-self",
489 long_about = "Register this DeepSeek binary as a local MCP stdio server.\n\nAdds a config entry to ~/.deepseek/mcp.json that launches `deepseek serve --mcp`\nvia the stdio transport. Other DeepSeek sessions (or any MCP client) can then\ndiscover and call tools exposed by this server.\n\nUse `deepseek serve --http` instead if you need the HTTP/SSE runtime API."
490 )]
491 AddSelf {
492 /// Server name in mcp.json (default: "deepseek")
493 #[arg(long, default_value = "deepseek")]
494 name: String,
495 /// Workspace directory for the MCP server
496 #[arg(long)]
497 workspace: Option<String>,
498 },
499 }
500
501 #[derive(Args, Debug, Clone)]
502 struct ExecpolicyCommand {
503 #[command(subcommand)]
504 command: ExecpolicySubcommand,
505 }
506
507 #[derive(Subcommand, Debug, Clone)]
508 enum ExecpolicySubcommand {
509 /// Check execpolicy files against a command
510 Check(execpolicy::ExecPolicyCheckCommand),
511 }
512
513 #[derive(Args, Debug, Clone)]
514 struct FeaturesCli {
515 #[command(subcommand)]
516 command: FeaturesSubcommand,
517 }
518
519 #[derive(Subcommand, Debug, Clone)]
520 enum FeaturesSubcommand {
521 /// List known feature flags and their state
522 List,
523 }
524
525 #[derive(Args, Debug, Clone)]
526 struct SandboxArgs {
527 #[command(subcommand)]
528 command: SandboxCommand,
529 }
530
531 #[derive(Subcommand, Debug, Clone)]
532 enum SandboxCommand {
533 /// Run a command with sandboxing
534 Run {
535 /// Sandbox policy (danger-full-access, read-only, external-sandbox, workspace-write)
536 #[arg(long, default_value = "workspace-write")]
537 policy: String,
538 /// Allow outbound network access
539 #[arg(long)]
540 network: bool,
541 /// Additional writable roots (repeatable)
542 #[arg(long, value_name = "PATH")]
543 writable_root: Vec<PathBuf>,
544 /// Exclude TMPDIR from writable paths
545 #[arg(long)]
546 exclude_tmpdir: bool,
547 /// Exclude /tmp from writable paths
548 #[arg(long)]
549 exclude_slash_tmp: bool,
550 /// Command working directory
551 #[arg(long)]
552 cwd: Option<PathBuf>,
553 /// Timeout in milliseconds
554 #[arg(long, default_value_t = 60_000)]
555 timeout_ms: u64,
556 /// Command and arguments to run
557 #[arg(required = true, trailing_var_arg = true)]
558 command: Vec<String>,
559 },
560 }
561
562 #[tokio::main]
563 async fn main() -> Result<()> {
564 configure_windows_console_utf8();
565
566 // Set up process panic hook before anything else — writes crash dumps
567 // to ~/.deepseek/crashes/ even if the panic happens before tokio is up,
568 // and restores the terminal so a panicked TUI doesn't leave the user's
569 // shell stuck in alt-screen mode.
570 let orig_hook = std::panic::take_hook();
571 std::panic::set_hook(Box::new(move |panic_info| {
572 // Restore the terminal first so the panic message itself, plus the
573 // user's shell after exit, are visible. Best-effort — we may not be
574 // in raw / alt-screen mode if the panic happens pre-TUI.
575 use crossterm::event::{
576 DisableBracketedPaste, DisableMouseCapture, PopKeyboardEnhancementFlags,
577 };
578 use crossterm::terminal::{LeaveAlternateScreen, disable_raw_mode};
579 let _ = crossterm::execute!(std::io::stdout(), PopKeyboardEnhancementFlags);
580 // Best-effort: turn off bracketed paste + mouse capture so the user's
581 // parent shell doesn't get stuck wrapping pastes in `\e[200~…\e[201~`
582 // or printing `\e[<…M` on every click after a TUI panic.
583 let _ = crossterm::execute!(std::io::stdout(), DisableBracketedPaste);
584 let _ = crossterm::execute!(std::io::stdout(), DisableMouseCapture);
585 let _ = disable_raw_mode();
586 let _ = crossterm::execute!(std::io::stdout(), LeaveAlternateScreen);
587
588 let msg = if let Some(s) = panic_info.payload().downcast_ref::<&str>() {
589 s.to_string()
590 } else if let Some(s) = panic_info.payload().downcast_ref::<String>() {
591 s.clone()
592 } else {
593 format!("{:?}", panic_info.payload())
594 };
595 let location = panic_info
596 .location()
597 .map(|loc| loc.to_string())
598 .unwrap_or_else(|| "unknown".to_string());
599 tracing::error!(target: "panic", "Process panicked at {location}: {msg}");
600 // Write crash dump best-effort
601 if let Some(home) = dirs::home_dir() {
602 let crash_dir = home.join(".deepseek").join("crashes");
603 let _ = std::fs::create_dir_all(&crash_dir);
604 use chrono::Utc;
605 let ts = Utc::now().format("%Y%m%dT%H%M%S%.3fZ");
606 let path = crash_dir.join(format!("{ts}-process-panic.log"));
607 let contents =
608 format!("Process panicked\nLocation: {location}\nTimestamp: {ts}\nPanic: {msg}\n",);
609 let _ = std::fs::write(&path, contents);
610 }
611 // Invoke the original hook (prints to stderr, etc.)
612 orig_hook(panic_info);
613 }));
614
615 dotenv().ok();
616 let cli = Cli::parse();
617 logging::set_verbose(cli.verbose || logging::env_requests_verbose_logging());
618
619 // Handle subcommands first
620 if let Some(command) = cli.command.clone() {
621 return match command {
622 Commands::Doctor(args) => {
623 let config = load_config_from_cli(&cli)?;
624 let workspace = resolve_workspace(&cli);
625 if args.json {
626 run_doctor_json(&config, &workspace, cli.config.as_deref())
627 } else {
628 run_doctor(&config, &workspace, cli.config.as_deref()).await;
629 Ok(())
630 }
631 }
632 Commands::Setup(args) => {
633 let config = load_config_from_cli(&cli)?;
634 let workspace = resolve_workspace(&cli);
635 run_setup(&config, &workspace, args)
636 }
637 Commands::Completions { shell } => {
638 generate_completions(shell);
639 Ok(())
640 }
641 Commands::Sessions { limit, search } => list_sessions(limit, search),
642 Commands::Init => init_project(),
643 Commands::Login { api_key } => run_login(api_key),
644 Commands::Logout => run_logout(),
645 Commands::Models(args) => {
646 let config = load_config_from_cli(&cli)?;
647 run_models(&config, args).await
648 }
649 Commands::Exec(args) => {
650 let config = load_config_from_cli(&cli)?;
651 let model = args
652 .model
653 .or_else(|| config.default_text_model.clone())
654 .unwrap_or_else(|| config.default_model());
655 if args.auto || cli.yolo {
656 let workspace = cli.workspace.clone().unwrap_or_else(|| {
657 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
658 });
659 let max_subagents = cli.max_subagents.map_or_else(
660 || config.max_subagents(),
661 |value| value.clamp(1, MAX_SUBAGENTS),
662 );
663 let auto_mode = args.auto || cli.yolo;
664 run_exec_agent(
665 &config,
666 &model,
667 &args.prompt,
668 workspace,
669 max_subagents,
670 true,
671 auto_mode,
672 args.json,
673 )
674 .await
675 } else if args.json {
676 run_one_shot_json(&config, &model, &args.prompt).await
677 } else {
678 run_one_shot(&config, &model, &args.prompt).await
679 }
680 }
681 Commands::Review(args) => {
682 let config = load_config_from_cli(&cli)?;
683 run_review(&config, args).await
684 }
685 Commands::Pr {
686 number,
687 repo,
688 checkout,
689 } => {
690 let config = load_config_from_cli(&cli)?;
691 run_pr(&cli, &config, number, repo.as_deref(), checkout).await
692 }
693 Commands::Apply(args) => run_apply(args),
694 Commands::Eval(args) => run_eval(args),
695 Commands::Mcp { command } => {
696 let config = load_config_from_cli(&cli)?;
697 run_mcp_command(&config, command).await
698 }
699 Commands::Execpolicy(command) => {
700 let config = load_config_from_cli(&cli)?;
701 if !config.features().enabled(Feature::ExecPolicy) {
702 bail!(
703 "The `exec_policy` feature is disabled. Enable it in [features] or via profile."
704 );
705 }
706 run_execpolicy_command(command)
707 }
708 Commands::Features(command) => {
709 let config = load_config_from_cli(&cli)?;
710 run_features_command(&config, command)
711 }
712 Commands::Sandbox(args) => run_sandbox_command(args),
713 Commands::Serve(args) => {
714 let workspace = cli.workspace.clone().unwrap_or_else(|| {
715 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
716 });
717 let selected_modes = [args.mcp, args.http, args.acp]
718 .into_iter()
719 .filter(|selected| *selected)
720 .count();
721 if selected_modes != 1 {
722 bail!("Choose exactly one server mode: --mcp, --http, or --acp");
723 }
724 if args.mcp {
725 mcp_server::run_mcp_server(workspace)
726 } else if args.http {
727 let config = load_config_from_cli(&cli)?;
728 let cors_origins = resolve_cors_origins(&config, &args.cors_origin);
729 runtime_api::run_http_server(
730 config,
731 workspace,
732 runtime_api::RuntimeApiOptions {
733 host: args.host,
734 port: args.port,
735 workers: args.workers.clamp(1, 8),
736 cors_origins,
737 auth_token: args.auth_token,
738 },
739 )
740 .await
741 } else if args.acp {
742 let config = load_config_from_cli(&cli)?;
743 let model = config.default_model();
744 acp_server::run_acp_server(config, model, workspace).await
745 } else {
746 unreachable!("server mode count checked above")
747 }
748 }
749 Commands::Resume { session_id, last } => {
750 let config = load_config_from_cli(&cli)?;
751 let workspace = resolve_workspace(&cli);
752 let resume_id = resolve_session_id(session_id, last, &workspace)?;
753 run_interactive(&cli, &config, Some(resume_id), None).await
754 }
755 Commands::Fork { session_id, last } => {
756 let config = load_config_from_cli(&cli)?;
757 let workspace = resolve_workspace(&cli);
758 let new_session_id = fork_session(session_id, last, &workspace)?;
759 run_interactive(&cli, &config, Some(new_session_id), None).await
760 }
761 };
762 }
763
764 // One-shot prompt mode
765 let config = load_config_from_cli(&cli)?;
766 if let Some(prompt) = cli.prompt {
767 let model = config.default_model();
768 return run_one_shot(&config, &model, &prompt).await;
769 }
770
771 // Handle session resume
772 let resume_session_id = if cli.continue_session {
773 let workspace = resolve_workspace(&cli);
774 latest_session_id_for_workspace(&workspace).ok().flatten()
775 } else if let Some(id) = cli.resume.clone() {
776 Some(id)
777 } else if !cli.fresh {
778 // Check for crash-recovery checkpoint (unless --fresh was passed).
779 try_recover_checkpoint()
780 } else {
781 None
782 };
783
784 // Default: Interactive TUI
785 // --yolo starts in YOLO mode (shell + trust + auto-approve)
786 run_interactive(&cli, &config, resume_session_id, None).await
787 }
788
789 /// Generate shell completions for the given shell
790 fn generate_completions(shell: Shell) {
791 let mut cmd = Cli::command();
792 let name = cmd.get_name().to_string();
793 generate(shell, &mut cmd, name, &mut io::stdout());
794 }
795
796 /// Run the offline evaluation harness (no network/LLM calls).
797 fn run_eval(args: EvalArgs) -> Result<()> {
798 let fail_step = match args.fail_step.as_deref() {
799 Some(value) => ScenarioStepKind::parse(value)
800 .map(Some)
801 .ok_or_else(|| anyhow!("invalid --fail-step '{value}'"))?,
802 None => None,
803 };
804
805 let config = EvalHarnessConfig {
806 fail_step,
807 shell_command: args.shell_command,
808 shell_expect_token: args.shell_expect_token,
809 max_output_chars: args.max_output_chars,
810 record_dir: args.record.clone(),
811 ..EvalHarnessConfig::default()
812 };
813
814 let harness = EvalHarness::new(config);
815 let run = harness.run().context("evaluation harness failed")?;
816 let report = run.to_report();
817
818 if args.json {
819 let json = serde_json::to_string_pretty(&report)?;
820 println!("{json}");
821 } else {
822 println!("Offline Eval Harness");
823 println!("scenario: {}", report.scenario_name);
824 println!("workspace: {}", report.workspace_root.display());
825 println!("success: {}", report.metrics.success);
826 println!("steps: {}", report.metrics.steps);
827 println!("tool_errors: {}", report.metrics.tool_errors);
828 println!("duration_ms: {}", report.metrics.duration.as_millis());
829
830 if !report.metrics.per_tool.is_empty() {
831 println!("per_tool:");
832 for (kind, stats) in &report.metrics.per_tool {
833 println!(
834 " {} invocations={} errors={} duration_ms={}",
835 kind.tool_name(),
836 stats.invocations,
837 stats.errors,
838 stats.total_duration.as_millis()
839 );
840 }
841 }
842
843 let failed_steps: Vec<_> = report.steps.iter().filter(|s| !s.success).collect();
844 if !failed_steps.is_empty() {
845 println!("failed_steps:");
846 for step in failed_steps {
847 let error = step.error.as_deref().unwrap_or("unknown error");
848 println!(
849 " {} tool={} error={}",
850 step.kind.tool_name(),
851 step.tool_name,
852 error
853 );
854 }
855 }
856 }
857
858 if report.metrics.success {
859 Ok(())
860 } else {
861 bail!("offline evaluation harness reported failure")
862 }
863 }
864
865 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
866 enum WriteStatus {
867 Created,
868 Overwritten,
869 SkippedExists,
870 }
871
872 fn ensure_parent_dir(path: &Path) -> Result<()> {
873 if let Some(parent) = path.parent()
874 && !parent.as_os_str().is_empty()
875 {
876 std::fs::create_dir_all(parent)
877 .with_context(|| format!("Failed to create directory for {}", parent.display()))?;
878 }
879 Ok(())
880 }
881
882 fn write_template_file(path: &Path, contents: &str, force: bool) -> Result<WriteStatus> {
883 ensure_parent_dir(path)?;
884
885 if path.exists() && !force {
886 return Ok(WriteStatus::SkippedExists);
887 }
888
889 let status = if path.exists() {
890 WriteStatus::Overwritten
891 } else {
892 WriteStatus::Created
893 };
894
895 std::fs::write(path, contents)
896 .with_context(|| format!("Failed to write template at {}", path.display()))?;
897
898 Ok(status)
899 }
900
901 fn mcp_template_json() -> Result<String> {
902 let mut cfg = McpConfig::default();
903 cfg.servers.insert(
904 "example".to_string(),
905 McpServerConfig {
906 command: Some("node".to_string()),
907 args: vec!["./path/to/your-mcp-server.js".to_string()],
908 env: std::collections::HashMap::new(),
909 url: None,
910 connect_timeout: None,
911 execute_timeout: None,
912 read_timeout: None,
913 disabled: true,
914 enabled: true,
915 required: false,
916 enabled_tools: Vec::new(),
917 disabled_tools: Vec::new(),
918 },
919 );
920 serde_json::to_string_pretty(&cfg)
921 .map_err(|e| anyhow!("Failed to render MCP template JSON: {e}"))
922 }
923
924 fn init_mcp_config(path: &Path, force: bool) -> Result<WriteStatus> {
925 let template = mcp_template_json()?;
926 write_template_file(path, &template, force)
927 }
928
929 fn skills_template(name: &str) -> String {
930 format!(
931 "\
932 ---\n\
933 name: {name}\n\
934 description: Quick repo diagnostics and setup guidance\n\
935 allowed-tools: diagnostics, list_dir, read_file, grep_files, git_status, git_diff\n\
936 ---\n\n\
937 When this skill is active:\n\
938 1. Run the diagnostics tool to report workspace and sandbox status.\n\
939 2. Skim key project files (README.md, Cargo.toml, AGENTS.md) before editing.\n\
940 3. Prefer small, validated changes and summarize what you verified.\n\
941 "
942 )
943 }
944
945 fn init_skills_dir(skills_dir: &Path, force: bool) -> Result<(PathBuf, WriteStatus)> {
946 std::fs::create_dir_all(skills_dir)
947 .with_context(|| format!("Failed to create skills dir {}", skills_dir.display()))?;
948
949 let skill_name = "getting-started";
950 let skill_path = skills_dir.join(skill_name).join("SKILL.md");
951 ensure_parent_dir(&skill_path)?;
952
953 let status = write_template_file(&skill_path, &skills_template(skill_name), force)?;
954 Ok((skill_path, status))
955 }
956
957 fn tools_readme_template() -> &'static str {
958 "# Local tools\n\n\
959 Drop self-describing scripts here so they can be discovered by\n\
960 `deepseek-tui setup --status` and surfaced in `deepseek-tui doctor`.\n\n\
961 Each script should start with a frontmatter-style header so the\n\
962 description is visible without executing the file:\n\n\
963 ```\n\
964 # name: my-tool\n\
965 # description: One-line summary of what this tool does\n\
966 # usage: my-tool [args...]\n\
967 ```\n\n\
968 The directory is intentionally not auto-loaded into the agent's tool\n\
969 catalog. Wire individual tools through MCP, hooks, or skills when you\n\
970 want them available inside a session.\n"
971 }
972
973 fn tools_example_script() -> &'static str {
974 "#!/usr/bin/env sh\n\
975 # name: example\n\
976 # description: Print a confirmation that local tool discovery works\n\
977 # usage: example [name]\n\
978 printf 'deepseek-tui local tool ok: %s\\n' \"${1:-world}\"\n"
979 }
980
981 fn init_tools_dir(tools_dir: &Path, force: bool) -> Result<(PathBuf, WriteStatus, WriteStatus)> {
982 std::fs::create_dir_all(tools_dir)
983 .with_context(|| format!("Failed to create tools dir {}", tools_dir.display()))?;
984
985 let readme_path = tools_dir.join("README.md");
986 let readme_status = write_template_file(&readme_path, tools_readme_template(), force)?;
987
988 let example_path = tools_dir.join("example.sh");
989 let example_status = write_template_file(&example_path, tools_example_script(), force)?;
990
991 Ok((tools_dir.to_path_buf(), readme_status, example_status))
992 }
993
994 fn plugins_readme_template() -> &'static str {
995 "# Local plugins\n\n\
996 Plugins are richer than tools: each one lives in its own subdirectory\n\
997 with a `PLUGIN.md` describing what it does and how to enable it. The\n\
998 directory is created so users have a documented place to drop\n\
999 experiments without touching `~/.deepseek/skills/`.\n\n\
1000 A plugin layout looks like:\n\n\
1001 ```\n\
1002 plugins/\n\
1003 my-plugin/\n\
1004 PLUGIN.md # frontmatter + body, same shape as SKILL.md\n\
1005 scripts/ # optional helpers invoked by the plugin\n\
1006 ```\n\n\
1007 Plugins are not loaded automatically. Wire them up through skills,\n\
1008 hooks, or MCP servers when you want them active in a session.\n"
1009 }
1010
1011 fn plugin_example_template() -> &'static str {
1012 "---\n\
1013 name: example\n\
1014 description: Placeholder plugin so /skills and doctor have something to show\n\
1015 status: example\n\
1016 ---\n\n\
1017 This is a starter plugin layout. Edit or replace it once you have a\n\
1018 real plugin. The agent does not load this file directly; reference it\n\
1019 from a skill or MCP wrapper if you want it active in a session.\n"
1020 }
1021
1022 fn init_plugins_dir(
1023 plugins_dir: &Path,
1024 force: bool,
1025 ) -> Result<(PathBuf, PathBuf, WriteStatus, WriteStatus)> {
1026 std::fs::create_dir_all(plugins_dir)
1027 .with_context(|| format!("Failed to create plugins dir {}", plugins_dir.display()))?;
1028
1029 let readme_path = plugins_dir.join("README.md");
1030 let readme_status = write_template_file(&readme_path, plugins_readme_template(), force)?;
1031
1032 let example_path = plugins_dir.join("example").join("PLUGIN.md");
1033 ensure_parent_dir(&example_path)?;
1034 let example_status = write_template_file(&example_path, plugin_example_template(), force)?;
1035
1036 Ok((readme_path, example_path, readme_status, example_status))
1037 }
1038
1039 /// Resolve the user-supplied CORS origins for `deepseek serve --http`.
1040 ///
1041 /// Sources, in priority order (later sources extend earlier ones):
1042 /// 1. `--cors-origin URL` flags (repeatable)
1043 /// 2. `DEEPSEEK_CORS_ORIGINS` env var (comma-separated)
1044 /// 3. `[runtime_api] cors_origins = [...]` in `config.toml`
1045 ///
1046 /// The runtime API always allows the built-in dev defaults
1047 /// (localhost:3000, localhost:1420, tauri://localhost). User entries are
1048 /// appended on top — empty strings are skipped, and duplicates are deduped
1049 /// while preserving first-seen order. Whalescale#255 / #561.
1050 fn resolve_cors_origins(config: &Config, flag_origins: &[String]) -> Vec<String> {
1051 let mut out: Vec<String> = Vec::new();
1052 let mut push = |raw: &str| {
1053 let trimmed = raw.trim();
1054 if trimmed.is_empty() {
1055 return;
1056 }
1057 if !out.iter().any(|existing| existing == trimmed) {
1058 out.push(trimmed.to_string());
1059 }
1060 };
1061 for o in flag_origins {
1062 push(o);
1063 }
1064 if let Ok(env_value) = std::env::var("DEEPSEEK_CORS_ORIGINS") {
1065 for piece in env_value.split(',') {
1066 push(piece);
1067 }
1068 }
1069 if let Some(rt) = &config.runtime_api
1070 && let Some(list) = &rt.cors_origins
1071 {
1072 for o in list {
1073 push(o);
1074 }
1075 }
1076 out
1077 }
1078
1079 fn deepseek_home_dir() -> PathBuf {
1080 dirs::home_dir().map_or_else(|| PathBuf::from(".deepseek"), |h| h.join(".deepseek"))
1081 }
1082
1083 /// Resolve the default tools directory. Mirrors `default_skills_dir` shape.
1084 fn default_tools_dir() -> PathBuf {
1085 deepseek_home_dir().join("tools")
1086 }
1087
1088 /// Resolve the default plugins directory.
1089 fn default_plugins_dir() -> PathBuf {
1090 deepseek_home_dir().join("plugins")
1091 }
1092
1093 /// Default location for crash/offline-queue checkpoints managed by the TUI.
1094 fn default_checkpoints_dir() -> PathBuf {
1095 deepseek_home_dir().join("sessions").join("checkpoints")
1096 }
1097
1098 #[derive(Debug, Clone, PartialEq, Eq)]
1099 struct CleanPlan {
1100 targets: Vec<PathBuf>,
1101 }
1102
1103 fn collect_clean_targets(checkpoints_dir: &Path) -> CleanPlan {
1104 let candidates = ["latest.json", "offline_queue.json"];
1105 let targets = candidates
1106 .iter()
1107 .map(|name| checkpoints_dir.join(name))
1108 .filter(|p| p.exists())
1109 .collect();
1110 CleanPlan { targets }
1111 }
1112
1113 fn execute_clean_plan(plan: &CleanPlan) -> Result<Vec<PathBuf>> {
1114 let mut removed = Vec::with_capacity(plan.targets.len());
1115 for path in &plan.targets {
1116 std::fs::remove_file(path)
1117 .with_context(|| format!("Failed to remove {}", path.display()))?;
1118 removed.push(path.clone());
1119 }
1120 Ok(removed)
1121 }
1122
1123 fn run_setup(config: &Config, workspace: &Path, args: SetupArgs) -> Result<()> {
1124 if args.status {
1125 return run_setup_status(config, workspace);
1126 }
1127 if args.clean {
1128 return run_setup_clean(&default_checkpoints_dir(), args.force);
1129 }
1130
1131 use crate::palette;
1132 use colored::Colorize;
1133
1134 let (aqua_r, aqua_g, aqua_b) = palette::DEEPSEEK_SKY_RGB;
1135 let (sky_r, sky_g, sky_b) = palette::DEEPSEEK_SKY_RGB;
1136
1137 let any_explicit = args.mcp || args.skills || args.tools || args.plugins;
1138 let run_mcp = args.mcp || args.all || !any_explicit;
1139 let run_skills = args.skills || args.all || !any_explicit;
1140 let run_tools = args.tools || args.all;
1141 let run_plugins = args.plugins || args.all;
1142
1143 println!(
1144 "{}",
1145 "DeepSeek Setup".truecolor(aqua_r, aqua_g, aqua_b).bold()
1146 );
1147 println!("{}", "==============".truecolor(sky_r, sky_g, sky_b));
1148 println!("Workspace: {}", crate::utils::display_path(workspace));
1149
1150 if run_mcp {
1151 let mcp_path = config.mcp_config_path();
1152 let status = init_mcp_config(&mcp_path, args.force)?;
1153 match status {
1154 WriteStatus::Created => {
1155 println!(" ✓ Created MCP config at {}", mcp_path.display());
1156 }
1157 WriteStatus::Overwritten => {
1158 println!(" ✓ Overwrote MCP config at {}", mcp_path.display());
1159 }
1160 WriteStatus::SkippedExists => {
1161 println!(" · MCP config already exists at {}", mcp_path.display());
1162 }
1163 }
1164 println!(" Next: edit the file, then run `deepseek mcp list` or `deepseek mcp tools`.");
1165 }
1166
1167 if run_skills {
1168 let skills_dir = if args.local {
1169 workspace.join("skills")
1170 } else {
1171 config.skills_dir()
1172 };
1173 let (skill_path, status) = init_skills_dir(&skills_dir, args.force)?;
1174 match status {
1175 WriteStatus::Created => {
1176 println!(" ✓ Created example skill at {}", skill_path.display());
1177 }
1178 WriteStatus::Overwritten => {
1179 println!(" ✓ Overwrote example skill at {}", skill_path.display());
1180 }
1181 WriteStatus::SkippedExists => {
1182 println!(
1183 " · Example skill already exists at {}",
1184 skill_path.display()
1185 );
1186 }
1187 }
1188 if args.local {
1189 println!(
1190 " Local skills dir enabled for this workspace: {}",
1191 crate::utils::display_path(&skills_dir)
1192 );
1193 } else {
1194 println!(
1195 " Skills dir: {}",
1196 crate::utils::display_path(&skills_dir)
1197 );
1198 }
1199 println!(" Next: run the TUI and use `/skills` then `/skill getting-started`.");
1200 }
1201
1202 if run_tools {
1203 let tools_dir = default_tools_dir();
1204 let (dir, readme_status, example_status) = init_tools_dir(&tools_dir, args.force)?;
1205 report_write_status("Tools README", &dir.join("README.md"), readme_status);
1206 report_write_status("Example tool", &dir.join("example.sh"), example_status);
1207 println!(" Tools dir: {}", crate::utils::display_path(&dir));
1208 println!(" Next: drop scripts here; surface them via skills/MCP when ready.");
1209 }
1210
1211 if run_plugins {
1212 let plugins_dir = default_plugins_dir();
1213 let (readme_path, example_path, readme_status, example_status) =
1214 init_plugins_dir(&plugins_dir, args.force)?;
1215 report_write_status("Plugins README", &readme_path, readme_status);
1216 report_write_status("Example plugin", &example_path, example_status);
1217 println!(
1218 " Plugins dir: {}",
1219 crate::utils::display_path(&plugins_dir)
1220 );
1221 println!(" Next: copy the example dir, edit PLUGIN.md, wire via skill/MCP.");
1222 }
1223
1224 let sandbox = crate::sandbox::get_platform_sandbox();
1225 if let Some(kind) = sandbox {
1226 println!(" ✓ Sandbox available: {kind}");
1227 } else {
1228 println!(" · Sandbox not available on this platform (best-effort only).");
1229 }
1230
1231 Ok(())
1232 }
1233
1234 fn report_write_status(label: &str, path: &Path, status: WriteStatus) {
1235 match status {
1236 WriteStatus::Created => {
1237 println!(" ✓ Created {label} at {}", path.display());
1238 }
1239 WriteStatus::Overwritten => {
1240 println!(" ✓ Overwrote {label} at {}", path.display());
1241 }
1242 WriteStatus::SkippedExists => {
1243 println!(" · {label} already exists at {}", path.display());
1244 }
1245 }
1246 }
1247
1248 /// Source of the resolved DeepSeek API key, used in status reports.
1249 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1250 enum ApiKeySource {
1251 Env,
1252 Config,
1253 Keyring,
1254 Missing,
1255 }
1256
1257 fn resolve_api_key_source(config: &Config) -> ApiKeySource {
1258 if std::env::var("DEEPSEEK_API_KEY")
1259 .ok()
1260 .filter(|k| !k.trim().is_empty())
1261 .is_some()
1262 {
1263 match std::env::var("DEEPSEEK_API_KEY_SOURCE").ok().as_deref() {
1264 Some("config") => return ApiKeySource::Config,
1265 Some("keyring") => return ApiKeySource::Keyring,
1266 _ => {}
1267 }
1268 }
1269
1270 if config
1271 .api_key
1272 .as_ref()
1273 .is_some_and(|k| !k.trim().is_empty())
1274 || config
1275 .provider_config()
1276 .and_then(|entry| entry.api_key.as_ref())
1277 .is_some_and(|k| !k.trim().is_empty())
1278 {
1279 ApiKeySource::Config
1280 } else if std::env::var("DEEPSEEK_API_KEY")
1281 .ok()
1282 .filter(|k| !k.trim().is_empty())
1283 .is_some()
1284 {
1285 ApiKeySource::Env
1286 } else {
1287 ApiKeySource::Missing
1288 }
1289 }
1290
1291 fn count_dir_entries(dir: &Path) -> usize {
1292 std::fs::read_dir(dir)
1293 .map(|entries| entries.filter_map(std::result::Result::ok).count())
1294 .unwrap_or(0)
1295 }
1296
1297 fn skills_count_for(dir: &Path) -> usize {
1298 if !dir.exists() {
1299 return 0;
1300 }
1301 crate::skills::SkillRegistry::discover(dir).len()
1302 }
1303
1304 fn run_setup_status(config: &Config, workspace: &Path) -> Result<()> {
1305 use crate::palette;
1306 use colored::Colorize;
1307
1308 let (aqua_r, aqua_g, aqua_b) = palette::DEEPSEEK_SKY_RGB;
1309 let (sky_r, sky_g, sky_b) = palette::DEEPSEEK_SKY_RGB;
1310 let (red_r, red_g, red_b) = palette::DEEPSEEK_RED_RGB;
1311
1312 println!(
1313 "{}",
1314 "DeepSeek Status".truecolor(aqua_r, aqua_g, aqua_b).bold()
1315 );
1316 println!("{}", "===============".truecolor(sky_r, sky_g, sky_b));
1317 println!("workspace: {}", workspace.display());
1318
1319 match resolve_api_key_source(config) {
1320 ApiKeySource::Env => println!(
1321 " {} api_key: set via DEEPSEEK_API_KEY",
1322 "✓".truecolor(aqua_r, aqua_g, aqua_b)
1323 ),
1324 ApiKeySource::Keyring => println!(
1325 " {} api_key: set via OS keyring",
1326 "✓".truecolor(aqua_r, aqua_g, aqua_b)
1327 ),
1328 ApiKeySource::Config => println!(
1329 " {} api_key: set via config",
1330 "✓".truecolor(aqua_r, aqua_g, aqua_b)
1331 ),
1332 ApiKeySource::Missing => {
1333 let (env_var, login_hint) = match config.api_provider() {
1334 crate::config::ApiProvider::NvidiaNim => (
1335 "NVIDIA_API_KEY",
1336 "deepseek auth set --provider nvidia-nim --api-key \"...\"",
1337 ),
1338 crate::config::ApiProvider::Openrouter => (
1339 "OPENROUTER_API_KEY",
1340 "deepseek auth set --provider openrouter --api-key \"...\"",
1341 ),
1342 crate::config::ApiProvider::Novita => (
1343 "NOVITA_API_KEY",
1344 "deepseek auth set --provider novita --api-key \"...\"",
1345 ),
1346 crate::config::ApiProvider::Fireworks => (
1347 "FIREWORKS_API_KEY",
1348 "deepseek auth set --provider fireworks --api-key \"...\"",
1349 ),
1350 crate::config::ApiProvider::Sglang => (
1351 "SGLANG_API_KEY",
1352 "deepseek auth set --provider sglang --api-key \"...\"",
1353 ),
1354 crate::config::ApiProvider::Vllm => (
1355 "VLLM_API_KEY",
1356 "deepseek auth set --provider vllm --api-key \"...\"",
1357 ),
1358 crate::config::ApiProvider::Deepseek | crate::config::ApiProvider::DeepseekCN => {
1359 ("DEEPSEEK_API_KEY", "deepseek auth set --provider deepseek")
1360 }
1361 };
1362 println!(
1363 " {} api_key: missing (set {env_var} or `[providers.{}].api_key` in ~/.deepseek/config.toml; or run `{login_hint}`)",
1364 "✗".truecolor(red_r, red_g, red_b),
1365 match config.api_provider() {
1366 crate::config::ApiProvider::NvidiaNim => "nvidia_nim",
1367 crate::config::ApiProvider::Openrouter => "openrouter",
1368 crate::config::ApiProvider::Novita => "novita",
1369 crate::config::ApiProvider::Fireworks => "fireworks",
1370 crate::config::ApiProvider::Sglang => "sglang",
1371 crate::config::ApiProvider::Vllm => "vllm",
1372 crate::config::ApiProvider::Deepseek
1373 | crate::config::ApiProvider::DeepseekCN => "deepseek",
1374 }
1375 );
1376 }
1377 }
1378 println!(
1379 " · base_url: {}",
1380 config
1381 .base_url
1382 .as_deref()
1383 .unwrap_or("https://api.deepseek.com")
1384 );
1385 let model = config
1386 .default_text_model
1387 .clone()
1388 .unwrap_or_else(|| DEFAULT_TEXT_MODEL.to_string());
1389 println!(" · default_text_model: {model}");
1390
1391 let mcp_path = config.mcp_config_path();
1392 let mcp_count = match load_mcp_config(&mcp_path) {
1393 Ok(cfg) => cfg.servers.len(),
1394 Err(_) => 0,
1395 };
1396 let mcp_present = if mcp_path.exists() { "" } else { " (missing)" };
1397 println!(
1398 " · mcp servers: {mcp_count} at {}{mcp_present}",
1399 mcp_path.display()
1400 );
1401
1402 let skills_dir = config.skills_dir();
1403 println!(
1404 " · skills: {} at {}",
1405 skills_count_for(&skills_dir),
1406 crate::utils::display_path(&skills_dir)
1407 );
1408
1409 let tools_dir = default_tools_dir();
1410 let tools_present = if tools_dir.exists() {
1411 ""
1412 } else {
1413 " (missing — run `setup --tools`)"
1414 };
1415 println!(
1416 " · tools: {} entries at {}{tools_present}",
1417 if tools_dir.exists() {
1418 count_dir_entries(&tools_dir)
1419 } else {
1420 0
1421 },
1422 crate::utils::display_path(&tools_dir)
1423 );
1424
1425 let plugins_dir = default_plugins_dir();
1426 let plugins_present = if plugins_dir.exists() {
1427 ""
1428 } else {
1429 " (missing — run `setup --plugins`)"
1430 };
1431 println!(
1432 " · plugins: {} entries at {}{plugins_present}",
1433 if plugins_dir.exists() {
1434 count_dir_entries(&plugins_dir)
1435 } else {
1436 0
1437 },
1438 crate::utils::display_path(&plugins_dir)
1439 );
1440
1441 let sandbox = crate::sandbox::get_platform_sandbox();
1442 match sandbox {
1443 Some(kind) => println!(
1444 " {} sandbox: {kind}",
1445 "✓".truecolor(aqua_r, aqua_g, aqua_b)
1446 ),
1447 None => println!(
1448 " {} sandbox: unavailable (commands run best-effort)",
1449 "!".truecolor(sky_r, sky_g, sky_b)
1450 ),
1451 }
1452
1453 println!(" {} {}", "·".dimmed(), dotenv_status_line(workspace));
1454
1455 println!();
1456 println!("Run `deepseek-tui doctor --json` for a machine-readable check.");
1457 Ok(())
1458 }
1459
1460 fn dotenv_status_line(workspace: &Path) -> String {
1461 let dotenv = workspace.join(".env");
1462 if dotenv.exists() {
1463 return format!(".env present at {}", dotenv.display());
1464 }
1465
1466 if workspace.join(".env.example").exists() {
1467 return ".env not present in workspace (run `cp .env.example .env` and edit)".to_string();
1468 }
1469
1470 ".env not present in workspace".to_string()
1471 }
1472
1473 fn run_setup_clean(checkpoints_dir: &Path, force: bool) -> Result<()> {
1474 use colored::Colorize;
1475
1476 if !checkpoints_dir.exists() {
1477 println!(
1478 "Nothing to clean — checkpoints dir does not exist: {}",
1479 checkpoints_dir.display()
1480 );
1481 return Ok(());
1482 }
1483
1484 let plan = collect_clean_targets(checkpoints_dir);
1485 if plan.targets.is_empty() {
1486 println!(
1487 "Nothing to clean — no checkpoint files in {}",
1488 checkpoints_dir.display()
1489 );
1490 return Ok(());
1491 }
1492
1493 if !force {
1494 println!(
1495 "Would remove {} checkpoint file(s) (use --force to apply):",
1496 plan.targets.len()
1497 );
1498 for path in &plan.targets {
1499 println!(" · {}", path.display());
1500 }
1501 return Ok(());
1502 }
1503
1504 let removed = execute_clean_plan(&plan)?;
1505 println!("{}", "Cleaned checkpoints:".bold());
1506 for path in &removed {
1507 println!(" ✓ {}", path.display());
1508 }
1509 Ok(())
1510 }
1511
1512 /// Run system diagnostics
1513 async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Option<&Path>) {
1514 use crate::palette;
1515 use colored::Colorize;
1516
1517 let (blue_r, blue_g, blue_b) = palette::DEEPSEEK_BLUE_RGB;
1518 let (sky_r, sky_g, sky_b) = palette::DEEPSEEK_SKY_RGB;
1519 let (aqua_r, aqua_g, aqua_b) = palette::DEEPSEEK_SKY_RGB;
1520 let (red_r, red_g, red_b) = palette::DEEPSEEK_RED_RGB;
1521
1522 println!(
1523 "{}",
1524 "DeepSeek TUI Doctor"
1525 .truecolor(blue_r, blue_g, blue_b)
1526 .bold()
1527 );
1528 println!("{}", "==================".truecolor(sky_r, sky_g, sky_b));
1529 println!();
1530
1531 // Version info
1532 println!("{}", "Version Information:".bold());
1533 println!(" deepseek-tui: {}", env!("CARGO_PKG_VERSION"));
1534 println!(" rust: {}", rustc_version());
1535 println!();
1536
1537 // Configuration summary
1538 println!("{}", "Configuration:".bold());
1539 let default_config_dir =
1540 dirs::home_dir().map_or_else(|| PathBuf::from(".deepseek"), |h| h.join(".deepseek"));
1541 let config_path = config_path_override
1542 .map(PathBuf::from)
1543 .or_else(|| {
1544 std::env::var("DEEPSEEK_CONFIG_PATH")
1545 .ok()
1546 .map(PathBuf::from)
1547 })
1548 .unwrap_or_else(|| default_config_dir.join("config.toml"));
1549
1550 if config_path.exists() {
1551 println!(
1552 " {} config.toml found at {}",
1553 "✓".truecolor(aqua_r, aqua_g, aqua_b),
1554 crate::utils::display_path(&config_path)
1555 );
1556 } else {
1557 println!(
1558 " {} config.toml not found at {} (using defaults/env)",
1559 "!".truecolor(sky_r, sky_g, sky_b),
1560 crate::utils::display_path(&config_path)
1561 );
1562 }
1563 println!(" workspace: {}", crate::utils::display_path(workspace));
1564
1565 // Check API keys
1566 println!();
1567 println!("{}", "API Keys:".bold());
1568
1569 // Per-provider state: env + config file only (no values printed).
1570 // Keep doctor/status prompt-free even for unsigned rebuilt binaries.
1571 let dispatcher_api_key_source = std::env::var("DEEPSEEK_API_KEY_SOURCE").ok();
1572 for (provider, slot, env_names) in [
1573 (
1574 crate::config::ApiProvider::Deepseek,
1575 "deepseek",
1576 &["DEEPSEEK_API_KEY"][..],
1577 ),
1578 (
1579 crate::config::ApiProvider::NvidiaNim,
1580 "nvidia-nim",
1581 &["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY"][..],
1582 ),
1583 (
1584 crate::config::ApiProvider::Openrouter,
1585 "openrouter",
1586 &["OPENROUTER_API_KEY"][..],
1587 ),
1588 (
1589 crate::config::ApiProvider::Novita,
1590 "novita",
1591 &["NOVITA_API_KEY"][..],
1592 ),
1593 (
1594 crate::config::ApiProvider::Fireworks,
1595 "fireworks",
1596 &["FIREWORKS_API_KEY"][..],
1597 ),
1598 (
1599 crate::config::ApiProvider::Sglang,
1600 "sglang",
1601 &["SGLANG_API_KEY"][..],
1602 ),
1603 (
1604 crate::config::ApiProvider::Vllm,
1605 "vllm",
1606 &["VLLM_API_KEY"][..],
1607 ),
1608 ] {
1609 let in_env = env_names.iter().any(|n| {
1610 std::env::var(n)
1611 .ok()
1612 .filter(|v| !v.trim().is_empty())
1613 .is_some()
1614 });
1615 let injected_runtime_key = matches!(
1616 dispatcher_api_key_source.as_deref(),
1617 Some("keyring" | "env" | "cli")
1618 );
1619 let in_config = config
1620 .provider_config_for(provider)
1621 .and_then(|entry| entry.api_key.as_ref())
1622 .is_some_and(|v| !v.trim().is_empty())
1623 || (matches!(provider, crate::config::ApiProvider::Deepseek)
1624 && !injected_runtime_key
1625 && config
1626 .api_key
1627 .as_ref()
1628 .is_some_and(|v| !v.trim().is_empty()));
1629 let icon = if in_env || in_config {
1630 "✓".truecolor(aqua_r, aqua_g, aqua_b)
1631 } else {
1632 "·".dimmed()
1633 };
1634 println!(
1635 " {} {slot}: env={}, config={}",
1636 icon,
1637 if in_env { "yes" } else { "no" },
1638 if in_config { "yes" } else { "no" }
1639 );
1640 }
1641 println!(" · credential precedence: ~/.deepseek/config.toml, OS keyring, then env");
1642
1643 let api_key_source = resolve_api_key_source(config);
1644 let has_api_key = if config.deepseek_api_key().is_ok() {
1645 let source_label = match api_key_source {
1646 ApiKeySource::Config => "config.toml",
1647 ApiKeySource::Keyring => "OS keyring",
1648 ApiKeySource::Env => "environment",
1649 ApiKeySource::Missing => "unknown source",
1650 };
1651 println!(
1652 " {} active provider key resolved from {source_label}",
1653 "✓".truecolor(aqua_r, aqua_g, aqua_b)
1654 );
1655 true
1656 } else {
1657 println!(
1658 " {} active provider key not configured",
1659 "✗".truecolor(red_r, red_g, red_b)
1660 );
1661 println!(
1662 " Run 'deepseek auth set --provider <name>' to save a key to ~/.deepseek/config.toml."
1663 );
1664 false
1665 };
1666
1667 // API connectivity test
1668 println!();
1669 println!("{}", "API Connectivity:".bold());
1670 let api_target = doctor_api_target(config);
1671 println!(" · provider: {}", api_target.provider);
1672 println!(" · base_url: {}", api_target.base_url);
1673 println!(" · model: {}", api_target.model);
1674 if has_api_key {
1675 print!(" {} Testing connection...", "·".dimmed());
1676 use std::io::Write;
1677 std::io::stdout().flush().ok();
1678
1679 match test_api_connectivity(config).await {
1680 Ok(model) => {
1681 println!(
1682 "\r {} API connection successful (model: {})",
1683 "✓".truecolor(aqua_r, aqua_g, aqua_b),
1684 model
1685 );
1686 }
1687 Err(e) => {
1688 let error_msg = e.to_string();
1689 println!(
1690 "\r {} API connection failed",
1691 "✗".truecolor(red_r, red_g, red_b)
1692 );
1693 if error_msg.contains("401") || error_msg.contains("Unauthorized") {
1694 println!(
1695 " Invalid API key. Check `deepseek auth status`, DEEPSEEK_API_KEY, or config.toml"
1696 );
1697 if matches!(api_key_source, ApiKeySource::Keyring) {
1698 println!(
1699 " The rejected key came from the OS keyring via the dispatcher."
1700 );
1701 println!(
1702 " Run `deepseek auth status` to inspect config/keyring/env sources."
1703 );
1704 } else if matches!(api_key_source, ApiKeySource::Env) {
1705 println!(
1706 " The rejected key came from DEEPSEEK_API_KEY; no saved config key is present."
1707 );
1708 println!(
1709 " Run `deepseek auth set --provider deepseek` to save a config key that overrides stale env."
1710 );
1711 }
1712 } else if error_msg.contains("403") || error_msg.contains("Forbidden") {
1713 println!(
1714 " API key lacks permissions. Verify key is active at platform.deepseek.com"
1715 );
1716 } else if error_msg.contains("timeout") || error_msg.contains("Timeout") {
1717 for line in doctor_timeout_recovery_lines(config) {
1718 println!(" {line}");
1719 }
1720 } else if error_msg.contains("dns") || error_msg.contains("resolve") {
1721 println!(" DNS resolution failed. Check your network connection");
1722 } else if error_msg.contains("connect") {
1723 println!(" Connection failed. Check firewall settings or try again");
1724 } else {
1725 println!(" Error: {}", error_msg);
1726 }
1727 }
1728 }
1729 } else {
1730 println!(" {} Skipped (no API key configured)", "·".dimmed());
1731 }
1732
1733 // MCP configuration
1734 println!();
1735 println!("{}", "MCP Servers:".bold());
1736 let features = config.features();
1737 if features.enabled(Feature::Mcp) {
1738 println!(
1739 " {} MCP feature flag enabled",
1740 "✓".truecolor(aqua_r, aqua_g, aqua_b)
1741 );
1742 } else {
1743 println!(
1744 " {} MCP feature flag disabled",
1745 "!".truecolor(sky_r, sky_g, sky_b)
1746 );
1747 }
1748
1749 let mcp_config_path = config.mcp_config_path();
1750 if mcp_config_path.exists() {
1751 println!(
1752 " {} MCP config found at {}",
1753 "✓".truecolor(aqua_r, aqua_g, aqua_b),
1754 crate::utils::display_path(&mcp_config_path)
1755 );
1756 match load_mcp_config(&mcp_config_path) {
1757 Ok(cfg) if cfg.servers.is_empty() => {
1758 println!(" {} 0 server(s) configured", "·".dimmed());
1759 }
1760 Ok(cfg) => {
1761 println!(
1762 " {} {} server(s) configured",
1763 "·".dimmed(),
1764 cfg.servers.len()
1765 );
1766 for (name, server) in &cfg.servers {
1767 let status = doctor_check_mcp_server(server);
1768 let icon = match status {
1769 McpServerDoctorStatus::Ok(ref detail) => {
1770 format!(
1771 " {} {name}: {}",
1772 "✓".truecolor(aqua_r, aqua_g, aqua_b),
1773 detail
1774 )
1775 }
1776 McpServerDoctorStatus::Warning(ref detail) => {
1777 format!(
1778 " {} {name}: {}",
1779 "!".truecolor(sky_r, sky_g, sky_b),
1780 detail
1781 )
1782 }
1783 McpServerDoctorStatus::Error(ref detail) => {
1784 format!(
1785 " {} {name}: {}",
1786 "✗".truecolor(red_r, red_g, red_b),
1787 detail
1788 )
1789 }
1790 };
1791 println!("{icon}");
1792 if !server.enabled {
1793 println!(" (disabled)");
1794 }
1795 }
1796 }
1797 Err(err) => {
1798 println!(
1799 " {} MCP config parse error: {}",
1800 "✗".truecolor(red_r, red_g, red_b),
1801 err
1802 );
1803 }
1804 }
1805 } else {
1806 println!(
1807 " {} MCP config not found at {}",
1808 "·".dimmed(),
1809 crate::utils::display_path(&mcp_config_path)
1810 );
1811 println!(" Run `deepseek mcp init` or `deepseek setup --mcp`.");
1812 }
1813
1814 // Skills configuration
1815 println!();
1816 println!("{}", "Skills:".bold());
1817 let global_skills_dir = config.skills_dir();
1818 let agents_skills_dir = workspace.join(".agents").join("skills");
1819 let local_skills_dir = workspace.join("skills");
1820 let agents_global_skills_dir = crate::skills::agents_global_skills_dir();
1821 // #432: cross-tool skill discovery dirs. Presence is reported here
1822 // even though they sit lower in the precedence chain so users can
1823 // see at a glance whether a `.opencode/skills/`, `.claude/skills/`,
1824 // `.cursor/skills/`, or global agentskills.io directory is contributing
1825 // to the merged catalogue.
1826 let opencode_skills_dir = workspace.join(".opencode").join("skills");
1827 let claude_skills_dir = workspace.join(".claude").join("skills");
1828 let selected_skills_dir = if agents_skills_dir.exists() {
1829 agents_skills_dir.clone()
1830 } else if local_skills_dir.exists() {
1831 local_skills_dir.clone()
1832 } else if config.skills_dir.is_none()
1833 && let Some(global_agents) = agents_global_skills_dir.as_ref()
1834 && global_agents.exists()
1835 {
1836 global_agents.clone()
1837 } else {
1838 global_skills_dir.clone()
1839 };
1840
1841 let describe_dir = |dir: &Path| -> usize {
1842 std::fs::read_dir(dir)
1843 .map(|entries| entries.filter_map(std::result::Result::ok).count())
1844 .unwrap_or(0)
1845 };
1846
1847 if local_skills_dir.exists() {
1848 println!(
1849 " {} local skills dir found at {} ({} items)",
1850 "✓".truecolor(aqua_r, aqua_g, aqua_b),
1851 crate::utils::display_path(&local_skills_dir),
1852 describe_dir(&local_skills_dir)
1853 );
1854 } else {
1855 println!(
1856 " {} local skills dir not found at {}",
1857 "·".dimmed(),
1858 crate::utils::display_path(&local_skills_dir)
1859 );
1860 }
1861
1862 if agents_skills_dir.exists() {
1863 println!(
1864 " {} .agents skills dir found at {} ({} items)",
1865 "✓".truecolor(aqua_r, aqua_g, aqua_b),
1866 crate::utils::display_path(&agents_skills_dir),
1867 describe_dir(&agents_skills_dir)
1868 );
1869 } else {
1870 println!(
1871 " {} .agents skills dir not found at {}",
1872 "·".dimmed(),
1873 crate::utils::display_path(&agents_skills_dir)
1874 );
1875 }
1876
1877 if let Some(agents_global_skills_dir) = agents_global_skills_dir.as_ref() {
1878 if agents_global_skills_dir.exists() {
1879 println!(
1880 " {} global .agents skills dir found at {} ({} items)",
1881 "✓".truecolor(aqua_r, aqua_g, aqua_b),
1882 crate::utils::display_path(agents_global_skills_dir),
1883 describe_dir(agents_global_skills_dir)
1884 );
1885 } else {
1886 println!(
1887 " {} global .agents skills dir not found at {}",
1888 "·".dimmed(),
1889 crate::utils::display_path(agents_global_skills_dir)
1890 );
1891 }
1892 }
1893
1894 if global_skills_dir.exists() {
1895 println!(
1896 " {} global skills dir found at {} ({} items)",
1897 "✓".truecolor(aqua_r, aqua_g, aqua_b),
1898 crate::utils::display_path(&global_skills_dir),
1899 describe_dir(&global_skills_dir)
1900 );
1901 } else {
1902 println!(
1903 " {} global skills dir not found at {}",
1904 "·".dimmed(),
1905 crate::utils::display_path(&global_skills_dir)
1906 );
1907 }
1908
1909 // #432: only print interop dirs when they're populated — empty
1910 // .opencode/.claude folders are common and would just clutter
1911 // the report with false-positive "absent" lines.
1912 if opencode_skills_dir.exists() {
1913 println!(
1914 " {} .opencode skills dir found at {} ({} items)",
1915 "✓".truecolor(aqua_r, aqua_g, aqua_b),
1916 crate::utils::display_path(&opencode_skills_dir),
1917 describe_dir(&opencode_skills_dir)
1918 );
1919 }
1920 if claude_skills_dir.exists() {
1921 println!(
1922 " {} .claude skills dir found at {} ({} items)",
1923 "✓".truecolor(aqua_r, aqua_g, aqua_b),
1924 crate::utils::display_path(&claude_skills_dir),
1925 describe_dir(&claude_skills_dir)
1926 );
1927 }
1928
1929 println!(
1930 " {} selected skills dir: {}",
1931 "·".dimmed(),
1932 crate::utils::display_path(&selected_skills_dir)
1933 );
1934 if !agents_skills_dir.exists()
1935 && !local_skills_dir.exists()
1936 && !agents_global_skills_dir
1937 .as_ref()
1938 .is_some_and(|dir| dir.exists())
1939 && !global_skills_dir.exists()
1940 {
1941 println!(" Run `deepseek setup --skills` (or add --local for ./skills).");
1942 }
1943
1944 // Tools directory
1945 println!();
1946 println!("{}", "Tools:".bold());
1947 let tools_dir = default_tools_dir();
1948 if tools_dir.exists() {
1949 let count = count_dir_entries(&tools_dir);
1950 println!(
1951 " {} tools dir found at {} ({} items)",
1952 "✓".truecolor(aqua_r, aqua_g, aqua_b),
1953 crate::utils::display_path(&tools_dir),
1954 count
1955 );
1956 } else {
1957 println!(
1958 " {} tools dir not found at {}",
1959 "·".dimmed(),
1960 crate::utils::display_path(&tools_dir)
1961 );
1962 println!(" Run `deepseek-tui setup --tools` to scaffold a starter dir.");
1963 }
1964
1965 // Plugins directory
1966 println!();
1967 println!("{}", "Plugins:".bold());
1968 let plugins_dir = default_plugins_dir();
1969 if plugins_dir.exists() {
1970 let count = count_dir_entries(&plugins_dir);
1971 println!(
1972 " {} plugins dir found at {} ({} items)",
1973 "✓".truecolor(aqua_r, aqua_g, aqua_b),
1974 crate::utils::display_path(&plugins_dir),
1975 count
1976 );
1977 } else {
1978 println!(
1979 " {} plugins dir not found at {}",
1980 "·".dimmed(),
1981 crate::utils::display_path(&plugins_dir)
1982 );
1983 println!(" Run `deepseek-tui setup --plugins` to scaffold a starter dir.");
1984 }
1985
1986 // Storage surfaces (#422 / #440 / #500)
1987 println!();
1988 println!("{}", "Storage:".bold());
1989 if let Some(spillover_root) = crate::tools::truncate::spillover_root() {
1990 let (present, count) = if spillover_root.is_dir() {
1991 (true, count_dir_entries(&spillover_root))
1992 } else {
1993 (false, 0)
1994 };
1995 if present {
1996 println!(
1997 " {} tool-output spillover at {} ({} file{})",
1998 "✓".truecolor(aqua_r, aqua_g, aqua_b),
1999 crate::utils::display_path(&spillover_root),
2000 count,
2001 if count == 1 { "" } else { "s" }
2002 );
2003 } else {
2004 println!(
2005 " {} tool-output spillover dir not yet created at {}",
2006 "·".dimmed(),
2007 crate::utils::display_path(&spillover_root)
2008 );
2009 }
2010 }
2011 let stash_path = dirs::home_dir().map(|h| h.join(".deepseek").join("composer_stash.jsonl"));
2012 if let Some(stash_path) = stash_path {
2013 let stash_count = crate::composer_stash::load_stash().len();
2014 if stash_path.exists() {
2015 println!(
2016 " {} composer stash at {} ({} parked draft{})",
2017 "✓".truecolor(aqua_r, aqua_g, aqua_b),
2018 crate::utils::display_path(&stash_path),
2019 stash_count,
2020 if stash_count == 1 { "" } else { "s" }
2021 );
2022 } else {
2023 println!(
2024 " {} composer stash empty (Ctrl+S in the composer to park a draft)",
2025 "·".dimmed()
2026 );
2027 }
2028 }
2029
2030 // Platform and sandbox checks
2031 println!();
2032 println!("{}", "Platform:".bold());
2033 println!(" OS: {}", std::env::consts::OS);
2034 println!(" Arch: {}", std::env::consts::ARCH);
2035
2036 let sandbox = crate::sandbox::get_platform_sandbox();
2037 if let Some(kind) = sandbox {
2038 println!(
2039 " {} sandbox available: {}",
2040 "✓".truecolor(aqua_r, aqua_g, aqua_b),
2041 kind
2042 );
2043 } else {
2044 println!(
2045 " {} sandbox not available (commands run best-effort)",
2046 "!".truecolor(sky_r, sky_g, sky_b)
2047 );
2048 }
2049
2050 println!();
2051 println!(
2052 "{}",
2053 "All checks complete!"
2054 .truecolor(aqua_r, aqua_g, aqua_b)
2055 .bold()
2056 );
2057 }
2058
2059 /// Machine-readable counterpart to `run_doctor`. Skips the live API call so it
2060 /// is safe to run in CI and from non-interactive scripts.
2061 fn run_doctor_json(
2062 config: &Config,
2063 workspace: &Path,
2064 config_path_override: Option<&Path>,
2065 ) -> Result<()> {
2066 use serde_json::json;
2067
2068 let default_config_dir =
2069 dirs::home_dir().map_or_else(|| PathBuf::from(".deepseek"), |h| h.join(".deepseek"));
2070 let config_path = config_path_override
2071 .map(PathBuf::from)
2072 .or_else(|| {
2073 std::env::var("DEEPSEEK_CONFIG_PATH")
2074 .ok()
2075 .map(PathBuf::from)
2076 })
2077 .unwrap_or_else(|| default_config_dir.join("config.toml"));
2078
2079 let api_key_state = match resolve_api_key_source(config) {
2080 ApiKeySource::Env => "env",
2081 ApiKeySource::Config => "config",
2082 ApiKeySource::Keyring => "keyring",
2083 ApiKeySource::Missing => "missing",
2084 };
2085
2086 let mcp_config_path = config.mcp_config_path();
2087 let mcp_present = mcp_config_path.exists();
2088 let mcp_summary = match load_mcp_config(&mcp_config_path) {
2089 Ok(cfg) => {
2090 let servers: Vec<serde_json::Value> = cfg
2091 .servers
2092 .iter()
2093 .map(|(name, server)| {
2094 let status = doctor_check_mcp_server(server);
2095 let (kind, detail) = match &status {
2096 McpServerDoctorStatus::Ok(d) => ("ok", d.clone()),
2097 McpServerDoctorStatus::Warning(d) => ("warning", d.clone()),
2098 McpServerDoctorStatus::Error(d) => ("error", d.clone()),
2099 };
2100 json!({
2101 "name": name,
2102 "enabled": server.enabled && !server.disabled,
2103 "status": kind,
2104 "detail": detail,
2105 })
2106 })
2107 .collect();
2108 json!({
2109 "config_path": mcp_config_path.display().to_string(),
2110 "present": mcp_present,
2111 "servers": servers,
2112 })
2113 }
2114 Err(err) => json!({
2115 "config_path": mcp_config_path.display().to_string(),
2116 "present": mcp_present,
2117 "servers": [],
2118 "error": err.to_string(),
2119 }),
2120 };
2121
2122 let global_skills_dir = config.skills_dir();
2123 let agents_skills_dir = workspace.join(".agents").join("skills");
2124 let local_skills_dir = workspace.join("skills");
2125 let agents_global_skills_dir = crate::skills::agents_global_skills_dir();
2126 // #432: cross-tool skill discovery dirs surface in the JSON
2127 // report so external dashboards can see whether any
2128 // `.opencode/skills/`, `.claude/skills/`, `.cursor/skills/`, or
2129 // global agentskills.io content is contributing to the merged catalogue.
2130 let opencode_skills_dir = workspace.join(".opencode").join("skills");
2131 let claude_skills_dir = workspace.join(".claude").join("skills");
2132 let selected_skills_dir = if agents_skills_dir.exists() {
2133 agents_skills_dir.clone()
2134 } else if local_skills_dir.exists() {
2135 local_skills_dir.clone()
2136 } else if config.skills_dir.is_none()
2137 && let Some(global_agents) = agents_global_skills_dir.as_ref()
2138 && global_agents.exists()
2139 {
2140 global_agents.clone()
2141 } else {
2142 global_skills_dir.clone()
2143 };
2144 let agents_global_summary = agents_global_skills_dir
2145 .as_ref()
2146 .map(|path| {
2147 json!({
2148 "path": path.display().to_string(),
2149 "present": path.exists(),
2150 "count": skills_count_for(path),
2151 })
2152 })
2153 .unwrap_or_else(|| {
2154 json!({
2155 "path": null,
2156 "present": false,
2157 "count": 0,
2158 })
2159 });
2160
2161 let tools_dir = default_tools_dir();
2162 let plugins_dir = default_plugins_dir();
2163
2164 // Memory feature state (#489). Operators ask "is memory on?" and
2165 // "where does it live?" — surface both here so the question can be
2166 // answered without booting the TUI. Both inputs are checked: the
2167 // config flag and the env-var override that the runtime would
2168 // honour. (The dedicated `Config::memory_enabled()` accessor lives
2169 // on the memory-MVP branch (#518); this duplicates the same logic
2170 // until the two PRs land and it can be replaced with a single
2171 // method call.)
2172 let memory_path = config.memory_path();
2173 let memory_enabled_env = std::env::var("DEEPSEEK_MEMORY")
2174 .ok()
2175 .map(|raw| {
2176 matches!(
2177 raw.trim().to_ascii_lowercase().as_str(),
2178 "1" | "on" | "true" | "yes" | "y" | "enabled"
2179 )
2180 })
2181 .unwrap_or(false);
2182 let memory_summary = json!({
2183 // The MVP feature is opt-in by default; this defaults to false
2184 // on branches without the [memory] section in `Config`.
2185 "enabled": memory_enabled_env,
2186 "path": memory_path.display().to_string(),
2187 "file_present": memory_path.exists(),
2188 });
2189 let api_target = doctor_api_target(config);
2190
2191 let report = json!({
2192 "version": env!("CARGO_PKG_VERSION"),
2193 "config_path": config_path.display().to_string(),
2194 "config_present": config_path.exists(),
2195 "workspace": workspace.display().to_string(),
2196 "api_key": {
2197 "source": api_key_state,
2198 },
2199 "base_url": api_target.base_url,
2200 "default_text_model": api_target.model,
2201 "memory": memory_summary,
2202 "mcp": mcp_summary,
2203 "skills": {
2204 "selected": selected_skills_dir.display().to_string(),
2205 "global": {
2206 "path": global_skills_dir.display().to_string(),
2207 "present": global_skills_dir.exists(),
2208 "count": skills_count_for(&global_skills_dir),
2209 },
2210 "agents": {
2211 "path": agents_skills_dir.display().to_string(),
2212 "present": agents_skills_dir.exists(),
2213 "count": skills_count_for(&agents_skills_dir),
2214 },
2215 "agents_global": agents_global_summary,
2216 "local": {
2217 "path": local_skills_dir.display().to_string(),
2218 "present": local_skills_dir.exists(),
2219 "count": skills_count_for(&local_skills_dir),
2220 },
2221 "opencode": {
2222 "path": opencode_skills_dir.display().to_string(),
2223 "present": opencode_skills_dir.exists(),
2224 "count": skills_count_for(&opencode_skills_dir),
2225 },
2226 "claude": {
2227 "path": claude_skills_dir.display().to_string(),
2228 "present": claude_skills_dir.exists(),
2229 "count": skills_count_for(&claude_skills_dir),
2230 },
2231 },
2232 "tools": {
2233 "path": tools_dir.display().to_string(),
2234 "present": tools_dir.exists(),
2235 "count": if tools_dir.exists() { count_dir_entries(&tools_dir) } else { 0 },
2236 },
2237 "plugins": {
2238 "path": plugins_dir.display().to_string(),
2239 "present": plugins_dir.exists(),
2240 "count": if plugins_dir.exists() { count_dir_entries(&plugins_dir) } else { 0 },
2241 },
2242 "storage": {
2243 "spillover": {
2244 "path": crate::tools::truncate::spillover_root()
2245 .map(|p| p.display().to_string())
2246 .unwrap_or_default(),
2247 "present": crate::tools::truncate::spillover_root()
2248 .is_some_and(|p| p.is_dir()),
2249 "count": crate::tools::truncate::spillover_root()
2250 .filter(|p| p.is_dir())
2251 .map(|p| count_dir_entries(&p))
2252 .unwrap_or(0),
2253 },
2254 "stash": {
2255 "path": dirs::home_dir()
2256 .map(|h| h.join(".deepseek").join("composer_stash.jsonl").display().to_string())
2257 .unwrap_or_default(),
2258 "present": dirs::home_dir()
2259 .map(|h| h.join(".deepseek").join("composer_stash.jsonl"))
2260 .is_some_and(|p| p.exists()),
2261 "count": crate::composer_stash::load_stash().len(),
2262 },
2263 },
2264 "sandbox": match crate::sandbox::get_platform_sandbox() {
2265 Some(kind) => json!({"available": true, "kind": kind.to_string()}),
2266 None => json!({"available": false, "kind": null}),
2267 },
2268 "platform": {
2269 "os": std::env::consts::OS,
2270 "arch": std::env::consts::ARCH,
2271 },
2272 "api_connectivity": {
2273 "checked": false,
2274 "note": "Skipped in --json mode; run `deepseek-tui doctor` for a live check.",
2275 },
2276 "capability": provider_capability_report(config),
2277 });
2278
2279 println!("{}", serde_json::to_string_pretty(&report)?);
2280 Ok(())
2281 }
2282
2283 /// Build the `capability` section for the machine-readable doctor report.
2284 ///
2285 /// Returns a JSON value with the resolved provider, resolved model, context
2286 /// window, max output, thinking support, cache telemetry support, and request
2287 /// payload mode.
2288 fn provider_capability_report(config: &Config) -> serde_json::Value {
2289 use serde_json::json;
2290
2291 let provider = config.api_provider();
2292 let model = config.default_model();
2293
2294 let cap = crate::config::provider_capability(provider, &model);
2295
2296 json!({
2297 "resolved_provider": provider.as_str(),
2298 "resolved_model": cap.resolved_model,
2299 "context_window": cap.context_window,
2300 "max_output": cap.max_output,
2301 "thinking_supported": cap.thinking_supported,
2302 "cache_telemetry_supported": cap.cache_telemetry_supported,
2303 "request_payload_mode": serde_json::to_value(cap.request_payload_mode).unwrap_or_default(),
2304 })
2305 }
2306
2307 #[derive(Debug, Clone, PartialEq, Eq)]
2308 struct DoctorApiTarget {
2309 provider: &'static str,
2310 base_url: String,
2311 model: String,
2312 }
2313
2314 fn doctor_api_target(config: &Config) -> DoctorApiTarget {
2315 let provider = config.api_provider();
2316 DoctorApiTarget {
2317 provider: provider.as_str(),
2318 base_url: config.deepseek_base_url(),
2319 model: config.default_model(),
2320 }
2321 }
2322
2323 fn doctor_timeout_recovery_lines(config: &Config) -> Vec<String> {
2324 let target = doctor_api_target(config);
2325 let mut lines = vec![format!(
2326 "Connection timed out while reaching {}.",
2327 target.base_url
2328 )];
2329
2330 match config.api_provider() {
2331 crate::config::ApiProvider::Deepseek
2332 if target.base_url.contains("api.deepseek.com")
2333 && !target.base_url.contains("api.deepseeki.com") =>
2334 {
2335 lines.push(
2336 "If you are in mainland China, set `provider = \"deepseek-cn\"` or `base_url = \"https://api.deepseeki.com\"` in ~/.deepseek/config.toml, then rerun `deepseek doctor`."
2337 .to_string(),
2338 );
2339 }
2340 crate::config::ApiProvider::Deepseek | crate::config::ApiProvider::DeepseekCN => {
2341 lines.push(
2342 "If this is a custom DeepSeek-compatible endpoint, confirm it serves `/v1/models` and `/v1/chat/completions` over HTTPS."
2343 .to_string(),
2344 );
2345 }
2346 _ => {
2347 lines.push(
2348 "Confirm the configured provider endpoint is reachable and OpenAI-compatible for `/v1/models` and `/v1/chat/completions`."
2349 .to_string(),
2350 );
2351 }
2352 }
2353
2354 lines.push(
2355 "Run `deepseek doctor --json` and include `base_url`, `default_text_model`, and `api_connectivity` when filing an issue."
2356 .to_string(),
2357 );
2358 lines
2359 }
2360
2361 fn run_execpolicy_command(command: ExecpolicyCommand) -> Result<()> {
2362 match command.command {
2363 ExecpolicySubcommand::Check(cmd) => cmd.run(),
2364 }
2365 }
2366
2367 fn run_features_command(config: &Config, command: FeaturesCli) -> Result<()> {
2368 match command.command {
2369 FeaturesSubcommand::List => {
2370 print!("{}", render_feature_table(&config.features()));
2371 Ok(())
2372 }
2373 }
2374 }
2375
2376 async fn run_models(config: &Config, args: ModelsArgs) -> Result<()> {
2377 use crate::client::DeepSeekClient;
2378
2379 let client = DeepSeekClient::new(config)?;
2380 let mut models = client.list_models().await?;
2381 models.sort_by(|a, b| a.id.cmp(&b.id));
2382
2383 if args.json {
2384 println!("{}", serde_json::to_string_pretty(&models)?);
2385 return Ok(());
2386 }
2387
2388 if models.is_empty() {
2389 println!("No models returned by the API.");
2390 return Ok(());
2391 }
2392
2393 let default_model = config.default_model();
2394
2395 println!("Available models (default: {default_model})");
2396 for model in models {
2397 let marker = if model.id == default_model { "*" } else { " " };
2398 if let Some(owner) = model.owned_by {
2399 println!("{marker} {} ({owner})", model.id);
2400 } else {
2401 println!("{marker} {}", model.id);
2402 }
2403 }
2404
2405 Ok(())
2406 }
2407
2408 /// Test API connectivity by making a minimal request
2409 async fn test_api_connectivity(config: &Config) -> Result<String> {
2410 use crate::client::DeepSeekClient;
2411 use crate::models::{ContentBlock, Message, MessageRequest};
2412
2413 let client = DeepSeekClient::new(config)?;
2414 let model = client.model().to_string();
2415
2416 // Minimal request: single word prompt, 1 max token
2417 let request = MessageRequest {
2418 model: model.clone(),
2419 messages: vec![Message {
2420 role: "user".to_string(),
2421 content: vec![ContentBlock::Text {
2422 text: "hi".to_string(),
2423 cache_control: None,
2424 }],
2425 }],
2426 max_tokens: 1,
2427 system: None,
2428 tools: None,
2429 tool_choice: None,
2430 metadata: None,
2431 thinking: None,
2432 reasoning_effort: None,
2433 stream: Some(false),
2434 temperature: None,
2435 top_p: None,
2436 };
2437
2438 // Use tokio timeout to catch hanging requests
2439 let timeout_duration = std::time::Duration::from_secs(15);
2440 match tokio::time::timeout(timeout_duration, client.create_message(request)).await {
2441 Ok(Ok(_response)) => Ok(model),
2442 Ok(Err(e)) => Err(e),
2443 Err(_) => anyhow::bail!("Request timeout after 15 seconds"),
2444 }
2445 }
2446
2447 fn rustc_version() -> String {
2448 // Try to get rustc version, fall back to "unknown"
2449 std::process::Command::new("rustc")
2450 .arg("--version")
2451 .output()
2452 .ok()
2453 .and_then(|o| String::from_utf8(o.stdout).ok())
2454 .map_or_else(|| "unknown".to_string(), |s| s.trim().to_string())
2455 }
2456
2457 /// List saved sessions
2458 fn list_sessions(limit: usize, search: Option<String>) -> Result<()> {
2459 use crate::palette;
2460 use colored::Colorize;
2461 use session_manager::{SessionManager, format_session_line};
2462
2463 let (blue_r, blue_g, blue_b) = palette::DEEPSEEK_BLUE_RGB;
2464 let (sky_r, sky_g, sky_b) = palette::DEEPSEEK_SKY_RGB;
2465 let (aqua_r, aqua_g, aqua_b) = palette::DEEPSEEK_SKY_RGB;
2466
2467 let manager = SessionManager::default_location()?;
2468
2469 let sessions = if let Some(query) = search {
2470 manager.search_sessions(&query)?
2471 } else {
2472 manager.list_sessions()?
2473 };
2474
2475 if sessions.is_empty() {
2476 println!("{}", "No sessions found.".truecolor(sky_r, sky_g, sky_b));
2477 println!(
2478 "Start a new session with: {}",
2479 "deepseek".truecolor(blue_r, blue_g, blue_b)
2480 );
2481 return Ok(());
2482 }
2483
2484 println!(
2485 "{}",
2486 "Saved Sessions".truecolor(blue_r, blue_g, blue_b).bold()
2487 );
2488 println!("{}", "==============".truecolor(sky_r, sky_g, sky_b));
2489 println!();
2490
2491 for (i, session) in sessions.iter().take(limit).enumerate() {
2492 let line = format_session_line(session);
2493 if i == 0 {
2494 println!(" {} {}", "*".truecolor(aqua_r, aqua_g, aqua_b), line);
2495 } else {
2496 println!(" {line}");
2497 }
2498 }
2499
2500 let total = sessions.len();
2501 if total > limit {
2502 println!();
2503 println!(
2504 " {} more session(s). Use --limit to show more.",
2505 total - limit
2506 );
2507 }
2508
2509 println!();
2510 println!(
2511 "Resume with: {} {}",
2512 "deepseek --resume".truecolor(blue_r, blue_g, blue_b),
2513 "<session-id>".dimmed()
2514 );
2515 println!(
2516 "Continue latest in this workspace: {}",
2517 "deepseek --continue".truecolor(blue_r, blue_g, blue_b)
2518 );
2519
2520 Ok(())
2521 }
2522
2523 /// Initialize a new project with AGENTS.md
2524 fn init_project() -> Result<()> {
2525 use crate::palette;
2526 use colored::Colorize;
2527 use project_context::create_default_agents_md;
2528
2529 let (sky_r, sky_g, sky_b) = palette::DEEPSEEK_SKY_RGB;
2530 let (aqua_r, aqua_g, aqua_b) = palette::DEEPSEEK_SKY_RGB;
2531 let (red_r, red_g, red_b) = palette::DEEPSEEK_RED_RGB;
2532
2533 let workspace = std::env::current_dir()?;
2534 let agents_path = workspace.join("AGENTS.md");
2535
2536 if agents_path.exists() {
2537 println!(
2538 "{} AGENTS.md already exists at {}",
2539 "!".truecolor(sky_r, sky_g, sky_b),
2540 agents_path.display()
2541 );
2542 return Ok(());
2543 }
2544
2545 match create_default_agents_md(&workspace) {
2546 Ok(path) => {
2547 println!(
2548 "{} Created {}",
2549 "✓".truecolor(aqua_r, aqua_g, aqua_b),
2550 path.display()
2551 );
2552 println!();
2553 println!("Edit this file to customize how the AI agent works with your project.");
2554 println!("The instructions will be loaded automatically when you run deepseek.");
2555 }
2556 Err(e) => {
2557 println!(
2558 "{} Failed to create AGENTS.md: {}",
2559 "✗".truecolor(red_r, red_g, red_b),
2560 e
2561 );
2562 }
2563 }
2564
2565 Ok(())
2566 }
2567
2568 fn resolve_workspace(cli: &Cli) -> PathBuf {
2569 cli.workspace
2570 .clone()
2571 .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
2572 }
2573
2574 fn load_config_from_cli(cli: &Cli) -> Result<Config> {
2575 let profile = cli
2576 .profile
2577 .clone()
2578 .or_else(|| std::env::var("DEEPSEEK_PROFILE").ok());
2579 let mut config = Config::load(cli.config.clone(), profile.as_deref())?;
2580 cli.feature_toggles.apply(&mut config)?;
2581 Ok(config)
2582 }
2583
2584 fn read_api_key_from_stdin() -> Result<String> {
2585 let mut stdin = io::stdin();
2586 if stdin.is_terminal() {
2587 bail!("No API key provided. Pass --api-key or pipe one via stdin.");
2588 }
2589 let mut buffer = String::new();
2590 stdin.read_to_string(&mut buffer)?;
2591 let api_key = buffer.trim().to_string();
2592 if api_key.is_empty() {
2593 bail!("No API key provided via stdin.");
2594 }
2595 Ok(api_key)
2596 }
2597
2598 fn run_login(api_key: Option<String>) -> Result<()> {
2599 let api_key = match api_key {
2600 Some(key) => key,
2601 None => read_api_key_from_stdin()?,
2602 };
2603 let saved = config::save_api_key(&api_key)?;
2604 println!("Saved API key to {}", saved.describe());
2605 Ok(())
2606 }
2607
2608 fn run_logout() -> Result<()> {
2609 config::clear_api_key()?;
2610 println!("Cleared saved API key.");
2611 Ok(())
2612 }
2613
2614 fn resolve_session_id(session_id: Option<String>, last: bool, workspace: &Path) -> Result<String> {
2615 if last {
2616 return latest_session_id_for_workspace(workspace)?.ok_or_else(|| {
2617 anyhow!(
2618 "No saved sessions found for workspace {}. Use `deepseek sessions` to list all sessions, or `deepseek resume <SESSION_ID>` to resume one explicitly.",
2619 workspace.display()
2620 )
2621 });
2622 }
2623 if let Some(id) = session_id {
2624 return Ok(id);
2625 }
2626 pick_session_id()
2627 }
2628
2629 fn latest_session_id_for_workspace(workspace: &Path) -> std::io::Result<Option<String>> {
2630 let manager = SessionManager::default_location()?;
2631 Ok(manager
2632 .get_latest_session_for_workspace(workspace)?
2633 .map(|session| session.id))
2634 }
2635
2636 fn fork_session(session_id: Option<String>, last: bool, workspace: &Path) -> Result<String> {
2637 let manager = SessionManager::default_location()?;
2638 let saved = if last {
2639 let Some(meta) = manager.get_latest_session_for_workspace(workspace)? else {
2640 bail!(
2641 "No saved sessions found for workspace {}.",
2642 workspace.display()
2643 );
2644 };
2645 manager.load_session(&meta.id)?
2646 } else {
2647 let id = resolve_session_id(session_id, false, workspace)?;
2648 manager.load_session_by_prefix(&id)?
2649 };
2650
2651 let system_prompt = saved
2652 .system_prompt
2653 .as_ref()
2654 .map(|text| SystemPrompt::Text(text.clone()));
2655 let forked = create_saved_session(
2656 &saved.messages,
2657 &saved.metadata.model,
2658 &saved.metadata.workspace,
2659 saved.metadata.total_tokens,
2660 system_prompt.as_ref(),
2661 );
2662 manager.save_session(&forked)?;
2663
2664 let source_title = saved.metadata.title.trim();
2665 let source_label = if source_title.is_empty() {
2666 "session".to_string()
2667 } else {
2668 format!("\"{source_title}\"")
2669 };
2670 println!(
2671 "Forked {source_label} ({source_id}) → new session {new_id}",
2672 source_id = truncate_id(&saved.metadata.id),
2673 new_id = truncate_id(&forked.metadata.id),
2674 );
2675
2676 Ok(forked.metadata.id)
2677 }
2678
2679 fn pick_session_id() -> Result<String> {
2680 let manager = SessionManager::default_location()?;
2681 let sessions = manager.list_sessions()?;
2682 if sessions.is_empty() {
2683 bail!("No saved sessions found.");
2684 }
2685
2686 println!("Select a session to resume:");
2687 for (idx, session) in sessions.iter().enumerate() {
2688 println!(" {:>2}. {} ({})", idx + 1, session.title, session.id);
2689 }
2690 print!("Enter a number (or press Enter to cancel): ");
2691 io::stdout().flush()?;
2692
2693 let mut input = String::new();
2694 io::stdin().read_line(&mut input)?;
2695 let input = input.trim();
2696 if input.is_empty() {
2697 bail!("No session selected.");
2698 }
2699 let idx: usize = input
2700 .parse()
2701 .map_err(|_| anyhow::anyhow!("Invalid input"))?;
2702 let session = sessions
2703 .get(idx.saturating_sub(1))
2704 .ok_or_else(|| anyhow::anyhow!("Selection out of range"))?;
2705 Ok(session.id.clone())
2706 }
2707
2708 async fn run_review(config: &Config, args: ReviewArgs) -> Result<()> {
2709 use crate::client::DeepSeekClient;
2710
2711 let diff = collect_diff(&args)?;
2712 if diff.trim().is_empty() {
2713 bail!("No diff to review.");
2714 }
2715
2716 let model = args
2717 .model
2718 .or_else(|| config.default_text_model.clone())
2719 .unwrap_or_else(|| config.default_model());
2720 let route = resolve_cli_auto_route(config, &model, &diff).await;
2721 let model = route.model;
2722 let reasoning_effort = route
2723 .reasoning_effort
2724 .map(|effort| effort.as_setting().to_string());
2725
2726 let system = SystemPrompt::Text(
2727 "You are a senior code reviewer. Focus on bugs, risks, behavioral regressions, and missing tests. \
2728 Provide findings ordered by severity with file references, then open questions, then a brief summary."
2729 .to_string(),
2730 );
2731 let user_prompt =
2732 format!("Review the following diff and provide feedback:\n\n{diff}\n\nEnd of diff.");
2733
2734 let client = DeepSeekClient::new(config)?;
2735 let request = MessageRequest {
2736 model: model.clone(),
2737 messages: vec![Message {
2738 role: "user".to_string(),
2739 content: vec![ContentBlock::Text {
2740 text: user_prompt,
2741 cache_control: None,
2742 }],
2743 }],
2744 max_tokens: 4096,
2745 system: Some(system),
2746 tools: None,
2747 tool_choice: None,
2748 metadata: None,
2749 thinking: None,
2750 reasoning_effort,
2751 stream: Some(false),
2752 temperature: Some(0.2),
2753 top_p: Some(0.9),
2754 };
2755
2756 let response = client.create_message(request).await?;
2757 let mut output = String::new();
2758 for block in response.content {
2759 if let ContentBlock::Text { text, .. } = block {
2760 output.push_str(&text);
2761 }
2762 }
2763 if args.json {
2764 println!(
2765 "{}",
2766 serde_json::to_string_pretty(&serde_json::json!({
2767 "mode": "review",
2768 "model": model,
2769 "success": true,
2770 "content": output
2771 }))?
2772 );
2773 } else {
2774 println!("{output}");
2775 }
2776 Ok(())
2777 }
2778
2779 /// `deepseek pr <N>` (#451) — fetch a GitHub PR via `gh`, format
2780 /// title + body + diff as the composer's first message, and launch
2781 /// the interactive TUI. Falls back gracefully if `gh` is missing.
2782 async fn run_pr(
2783 cli: &Cli,
2784 config: &Config,
2785 number: u32,
2786 repo: Option<&str>,
2787 checkout: bool,
2788 ) -> Result<()> {
2789 if !is_command_available("gh") {
2790 bail!(
2791 "`gh` CLI not found on PATH. Install GitHub CLI \
2792 (https://cli.github.com) and authenticate (`gh auth login`) \
2793 so `deepseek pr <N>` can fetch PR metadata and the diff."
2794 );
2795 }
2796
2797 let view = run_gh_pr_view(number, repo)?;
2798 let diff = run_gh_pr_diff(number, repo)?;
2799
2800 if checkout {
2801 match run_gh_pr_checkout(number, repo) {
2802 Ok(()) => eprintln!("Checked out PR #{number} into the current workspace."),
2803 Err(err) => eprintln!(
2804 "warning: gh pr checkout #{number} failed ({err}). Continuing without checkout."
2805 ),
2806 }
2807 }
2808
2809 let prompt = format_pr_prompt(number, &view, &diff);
2810 let resume_session_id = if cli.continue_session {
2811 let workspace = resolve_workspace(cli);
2812 latest_session_id_for_workspace(&workspace).ok().flatten()
2813 } else {
2814 cli.resume.clone()
2815 };
2816 run_interactive(cli, config, resume_session_id, Some(prompt)).await
2817 }
2818
2819 /// Return true if `name` resolves to an executable on the current `PATH`.
2820 ///
2821 /// Walks `$PATH` directly instead of probing with `--version`. The
2822 /// previous implementation invoked `Command::new(name).arg("--version")`,
2823 /// which fails on the Ubuntu CI runner because `/bin/sh` is `dash` —
2824 /// `dash --version` exits with status 2 ("invalid option") even though
2825 /// `sh` is plainly on PATH. macOS happens to ship bash as `sh`, which
2826 /// does honor `--version`, so the bug was invisible locally and only
2827 /// surfaced in CI logs.
2828 ///
2829 /// Windows: also checks the `.exe` extension when `name` doesn't have
2830 /// one, matching the platform's PATHEXT lookup behavior for the common
2831 /// case.
2832 fn is_command_available(name: &str) -> bool {
2833 let Some(path) = std::env::var_os("PATH") else {
2834 return false;
2835 };
2836 for dir in std::env::split_paths(&path) {
2837 let candidate = dir.join(name);
2838 if candidate.is_file() {
2839 return true;
2840 }
2841 #[cfg(windows)]
2842 {
2843 // PATHEXT gives `.exe`/`.cmd`/`.bat` etc. priority — we only
2844 // probe `.exe` because that's the case that actually trips
2845 // up the negative case (`gh` resolves as `gh.exe`).
2846 if candidate.extension().is_none() && candidate.with_extension("exe").is_file() {
2847 return true;
2848 }
2849 }
2850 }
2851 false
2852 }
2853
2854 #[derive(Debug, Clone, Default)]
2855 struct GhPullRequest {
2856 title: String,
2857 body: String,
2858 base: String,
2859 head: String,
2860 url: String,
2861 }
2862
2863 fn run_gh_pr_view(number: u32, repo: Option<&str>) -> Result<GhPullRequest> {
2864 let mut cmd = Command::new("gh");
2865 cmd.arg("pr").arg("view").arg(number.to_string());
2866 if let Some(r) = repo {
2867 cmd.arg("--repo").arg(r);
2868 }
2869 cmd.arg("--json")
2870 .arg("title,body,baseRefName,headRefName,url");
2871 let output = cmd
2872 .output()
2873 .map_err(|e| anyhow::anyhow!("Failed to run `gh pr view`: {e}"))?;
2874 if !output.status.success() {
2875 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
2876 bail!("gh pr view #{number} failed: {stderr}");
2877 }
2878 let raw = String::from_utf8_lossy(&output.stdout).to_string();
2879 let value: serde_json::Value = serde_json::from_str(&raw)
2880 .map_err(|e| anyhow::anyhow!("gh pr view returned non-JSON output: {e}"))?;
2881 let pick = |key: &str| {
2882 value
2883 .get(key)
2884 .and_then(serde_json::Value::as_str)
2885 .unwrap_or_default()
2886 .to_string()
2887 };
2888 Ok(GhPullRequest {
2889 title: pick("title"),
2890 body: pick("body"),
2891 base: pick("baseRefName"),
2892 head: pick("headRefName"),
2893 url: pick("url"),
2894 })
2895 }
2896
2897 fn run_gh_pr_diff(number: u32, repo: Option<&str>) -> Result<String> {
2898 let mut cmd = Command::new("gh");
2899 cmd.arg("pr").arg("diff").arg(number.to_string());
2900 if let Some(r) = repo {
2901 cmd.arg("--repo").arg(r);
2902 }
2903 let output = cmd
2904 .output()
2905 .map_err(|e| anyhow::anyhow!("Failed to run `gh pr diff`: {e}"))?;
2906 if !output.status.success() {
2907 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
2908 bail!("gh pr diff #{number} failed: {stderr}");
2909 }
2910 Ok(String::from_utf8_lossy(&output.stdout).to_string())
2911 }
2912
2913 fn run_gh_pr_checkout(number: u32, repo: Option<&str>) -> Result<()> {
2914 let mut cmd = Command::new("gh");
2915 cmd.arg("pr").arg("checkout").arg(number.to_string());
2916 if let Some(r) = repo {
2917 cmd.arg("--repo").arg(r);
2918 }
2919 let output = cmd
2920 .output()
2921 .map_err(|e| anyhow::anyhow!("Failed to run `gh pr checkout`: {e}"))?;
2922 if !output.status.success() {
2923 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
2924 bail!("gh pr checkout #{number} failed: {stderr}");
2925 }
2926 Ok(())
2927 }
2928
2929 /// Format the PR review prompt that lands in the composer. Caps the
2930 /// diff at 200 KiB so a massive PR doesn't blow the model's context
2931 /// window before the user even hits Enter — they can always ask the
2932 /// model to fetch more via `gh pr diff #N` from inside the session.
2933 fn format_pr_prompt(number: u32, view: &GhPullRequest, diff: &str) -> String {
2934 const MAX_DIFF_BYTES: usize = 200 * 1024;
2935 let diff_section = if diff.len() > MAX_DIFF_BYTES {
2936 let cut = (0..=MAX_DIFF_BYTES)
2937 .rev()
2938 .find(|&i| diff.is_char_boundary(i))
2939 .unwrap_or(0);
2940 format!(
2941 "{}\n\n[…diff truncated at {} KiB; ask me to fetch more if needed]\n",
2942 &diff[..cut],
2943 MAX_DIFF_BYTES / 1024
2944 )
2945 } else {
2946 diff.to_string()
2947 };
2948 let body = if view.body.trim().is_empty() {
2949 "(no description)".to_string()
2950 } else {
2951 view.body.trim().to_string()
2952 };
2953 let title = if view.title.trim().is_empty() {
2954 format!("(PR #{number})")
2955 } else {
2956 view.title.trim().to_string()
2957 };
2958 let branches = match (view.base.is_empty(), view.head.is_empty()) {
2959 (false, false) => format!("{} ← {}", view.base, view.head),
2960 (false, true) => view.base.clone(),
2961 (true, false) => view.head.clone(),
2962 _ => "(unknown)".to_string(),
2963 };
2964 format!(
2965 "Review PR #{number} — {title}\n\
2966 \n\
2967 URL: {url}\n\
2968 Branches: {branches}\n\
2969 \n\
2970 ## Description\n\
2971 \n\
2972 {body}\n\
2973 \n\
2974 ## Diff\n\
2975 \n\
2976 ```diff\n\
2977 {diff_section}\n\
2978 ```\n",
2979 url = if view.url.is_empty() {
2980 "(unavailable)"
2981 } else {
2982 view.url.as_str()
2983 },
2984 )
2985 }
2986
2987 fn collect_diff(args: &ReviewArgs) -> Result<String> {
2988 let mut cmd = Command::new("git");
2989 cmd.arg("diff");
2990 if args.staged {
2991 cmd.arg("--cached");
2992 }
2993 if let Some(base) = &args.base {
2994 cmd.arg(format!("{base}...HEAD"));
2995 }
2996 if let Some(path) = &args.path {
2997 cmd.arg("--").arg(path);
2998 }
2999
3000 let output = cmd
3001 .output()
3002 .map_err(|e| anyhow::anyhow!("Failed to run git diff. Is git installed? ({})", e))?;
3003 if !output.status.success() {
3004 let stderr = String::from_utf8_lossy(&output.stderr);
3005 bail!("git diff failed: {}", stderr.trim());
3006 }
3007 let mut diff = String::from_utf8_lossy(&output.stdout).to_string();
3008 if diff.len() > args.max_chars {
3009 diff = crate::utils::truncate_with_ellipsis(&diff, args.max_chars, "\n...[truncated]\n");
3010 }
3011 Ok(diff)
3012 }
3013
3014 fn run_apply(args: ApplyArgs) -> Result<()> {
3015 let patch = if let Some(path) = args.patch_file {
3016 std::fs::read_to_string(&path)
3017 .map_err(|e| anyhow::anyhow!("Failed to read patch {}: {}", path.display(), e))?
3018 } else {
3019 read_patch_from_stdin()?
3020 };
3021 if patch.trim().is_empty() {
3022 bail!("Patch is empty.");
3023 }
3024
3025 let mut tmp = NamedTempFile::new()?;
3026 tmp.write_all(patch.as_bytes())?;
3027 let tmp_path = tmp.path().to_path_buf();
3028
3029 let output = Command::new("git")
3030 .arg("apply")
3031 .arg("--whitespace=nowarn")
3032 .arg(&tmp_path)
3033 .output()
3034 .map_err(|e| anyhow::anyhow!("Failed to run git apply: {}", e))?;
3035
3036 if !output.status.success() {
3037 let stderr = String::from_utf8_lossy(&output.stderr);
3038 bail!("git apply failed: {}", stderr.trim());
3039 }
3040 println!("Applied patch successfully.");
3041 Ok(())
3042 }
3043
3044 fn read_patch_from_stdin() -> Result<String> {
3045 let mut stdin = io::stdin();
3046 if stdin.is_terminal() {
3047 bail!("No patch file provided and stdin is empty.");
3048 }
3049 let mut buffer = String::new();
3050 stdin.read_to_string(&mut buffer)?;
3051 Ok(buffer)
3052 }
3053
3054 async fn run_mcp_command(config: &Config, command: McpCommand) -> Result<()> {
3055 let config_path = config.mcp_config_path();
3056 match command {
3057 McpCommand::Init { force } => {
3058 let status = init_mcp_config(&config_path, force)?;
3059 match status {
3060 WriteStatus::Created => {
3061 println!("Created MCP config at {}", config_path.display());
3062 }
3063 WriteStatus::Overwritten => {
3064 println!("Overwrote MCP config at {}", config_path.display());
3065 }
3066 WriteStatus::SkippedExists => {
3067 println!(
3068 "MCP config already exists at {} (use --force to overwrite)",
3069 config_path.display()
3070 );
3071 }
3072 }
3073 println!("Edit the file, then run `deepseek mcp list` or `deepseek mcp tools`.");
3074 Ok(())
3075 }
3076 McpCommand::List => {
3077 let cfg = load_mcp_config(&config_path)?;
3078 if cfg.servers.is_empty() {
3079 println!("No MCP servers configured in {}", config_path.display());
3080 return Ok(());
3081 }
3082 println!("MCP servers ({}):", cfg.servers.len());
3083 for (name, server) in cfg.servers {
3084 let status = if server.enabled && !server.disabled {
3085 "enabled"
3086 } else {
3087 "disabled"
3088 };
3089 let args = if server.args.is_empty() {
3090 "".to_string()
3091 } else {
3092 format!(" {}", server.args.join(" "))
3093 };
3094 let cmd_str = if let Some(cmd) = server.command {
3095 format!("{cmd}{args}")
3096 } else if let Some(url) = server.url {
3097 url
3098 } else {
3099 "unknown".to_string()
3100 };
3101 let required = if server.required { " required" } else { "" };
3102 println!(" - {name} [{status}{required}] {cmd_str}");
3103 }
3104 Ok(())
3105 }
3106 McpCommand::Connect { server } => {
3107 let mut pool = McpPool::from_config_path(&config_path)?;
3108 if let Some(name) = server {
3109 pool.get_or_connect(&name).await?;
3110 println!("Connected to MCP server: {name}");
3111 } else {
3112 let errors = pool.connect_all().await;
3113 if errors.is_empty() {
3114 println!("Connected to all configured MCP servers.");
3115 } else {
3116 for (name, err) in errors {
3117 eprintln!("Failed to connect {name}: {err}");
3118 }
3119 }
3120 }
3121 Ok(())
3122 }
3123 McpCommand::Tools { server } => {
3124 let mut pool = McpPool::from_config_path(&config_path)?;
3125 if let Some(name) = server {
3126 let conn = pool.get_or_connect(&name).await?;
3127 if conn.tools().is_empty() {
3128 println!("No tools found for MCP server: {name}");
3129 } else {
3130 println!("Tools for {name}:");
3131 for tool in conn.tools() {
3132 println!(
3133 " - {}{}",
3134 tool.name,
3135 tool.description
3136 .as_ref()
3137 .map_or(String::new(), |d| format!(": {d}"))
3138 );
3139 }
3140 }
3141 } else {
3142 let _ = pool.connect_all().await;
3143 let tools = pool.all_tools();
3144 if tools.is_empty() {
3145 println!("No MCP tools discovered.");
3146 } else {
3147 println!("MCP tools:");
3148 for (name, tool) in tools {
3149 println!(
3150 " - {}{}",
3151 name,
3152 tool.description
3153 .as_ref()
3154 .map_or(String::new(), |d| format!(": {d}"))
3155 );
3156 }
3157 }
3158 }
3159 Ok(())
3160 }
3161 McpCommand::Add {
3162 name,
3163 command,
3164 url,
3165 args,
3166 } => {
3167 if command.is_none() && url.is_none() {
3168 bail!("Provide either --command or --url for `mcp add`.");
3169 }
3170 let mut cfg = load_mcp_config(&config_path)?;
3171 cfg.servers.insert(
3172 name.clone(),
3173 McpServerConfig {
3174 command,
3175 args,
3176 env: std::collections::HashMap::new(),
3177 url,
3178 connect_timeout: None,
3179 execute_timeout: None,
3180 read_timeout: None,
3181 disabled: false,
3182 enabled: true,
3183 required: false,
3184 enabled_tools: Vec::new(),
3185 disabled_tools: Vec::new(),
3186 },
3187 );
3188 save_mcp_config(&config_path, &cfg)?;
3189 println!("Added MCP server '{name}' in {}", config_path.display());
3190 Ok(())
3191 }
3192 McpCommand::Remove { name } => {
3193 let mut cfg = load_mcp_config(&config_path)?;
3194 if cfg.servers.remove(&name).is_none() {
3195 bail!("MCP server '{name}' not found");
3196 }
3197 save_mcp_config(&config_path, &cfg)?;
3198 println!("Removed MCP server '{name}'");
3199 Ok(())
3200 }
3201 McpCommand::Enable { name } => {
3202 let mut cfg = load_mcp_config(&config_path)?;
3203 let server = cfg
3204 .servers
3205 .get_mut(&name)
3206 .ok_or_else(|| anyhow!("MCP server '{name}' not found"))?;
3207 server.enabled = true;
3208 server.disabled = false;
3209 save_mcp_config(&config_path, &cfg)?;
3210 println!("Enabled MCP server '{name}'");
3211 Ok(())
3212 }
3213 McpCommand::Disable { name } => {
3214 let mut cfg = load_mcp_config(&config_path)?;
3215 let server = cfg
3216 .servers
3217 .get_mut(&name)
3218 .ok_or_else(|| anyhow!("MCP server '{name}' not found"))?;
3219 server.enabled = false;
3220 server.disabled = true;
3221 save_mcp_config(&config_path, &cfg)?;
3222 println!("Disabled MCP server '{name}'");
3223 Ok(())
3224 }
3225 McpCommand::Validate => {
3226 let mut pool = McpPool::from_config_path(&config_path)?;
3227 let errors = pool.connect_all().await;
3228 if errors.is_empty() {
3229 println!("MCP config is valid. All enabled servers connected.");
3230 return Ok(());
3231 }
3232 eprintln!("MCP validation failed:");
3233 for (name, err) in errors {
3234 eprintln!(" - {name}: {err}");
3235 }
3236 bail!("one or more MCP servers failed validation");
3237 }
3238 McpCommand::AddSelf { name, workspace } => {
3239 let exe_path = std::env::current_exe()
3240 .map_err(|e| anyhow!("Cannot resolve current binary path: {e}"))?;
3241 let exe_str = exe_path.to_string_lossy().to_string();
3242
3243 let mut args = vec!["serve".to_string(), "--mcp".to_string()];
3244 if let Some(ref ws) = workspace {
3245 args.push("--workspace".to_string());
3246 args.push(ws.clone());
3247 }
3248
3249 let mut cfg = load_mcp_config(&config_path)?;
3250 if cfg.servers.contains_key(&name) {
3251 bail!(
3252 "MCP server '{name}' already exists in {}. Use `deepseek mcp remove {name}` first, or choose a different --name.",
3253 config_path.display()
3254 );
3255 }
3256 cfg.servers.insert(
3257 name.clone(),
3258 McpServerConfig {
3259 command: Some(exe_str.clone()),
3260 args,
3261 env: std::collections::HashMap::new(),
3262 url: None,
3263 connect_timeout: None,
3264 execute_timeout: None,
3265 read_timeout: None,
3266 disabled: false,
3267 enabled: true,
3268 required: false,
3269 enabled_tools: Vec::new(),
3270 disabled_tools: Vec::new(),
3271 },
3272 );
3273 save_mcp_config(&config_path, &cfg)?;
3274 println!(
3275 "Registered DeepSeek as MCP server '{name}' in {}",
3276 config_path.display()
3277 );
3278 println!(" command: {exe_str}");
3279 println!(
3280 " args: serve --mcp{}",
3281 workspace.map_or(String::new(), |ws| format!(" --workspace {ws}"))
3282 );
3283 println!();
3284 println!("Tip: Use `deepseek mcp validate` to test the connection.");
3285 println!(" Use `deepseek serve --http` for the HTTP/SSE runtime API instead.");
3286 Ok(())
3287 }
3288 }
3289 }
3290
3291 fn load_mcp_config(path: &Path) -> Result<McpConfig> {
3292 if !path.exists() {
3293 return Ok(McpConfig::default());
3294 }
3295 let contents = std::fs::read_to_string(path)
3296 .map_err(|e| anyhow::anyhow!("Failed to read MCP config {}: {}", path.display(), e))?;
3297 let cfg: McpConfig = serde_json::from_str(&contents)
3298 .map_err(|e| anyhow::anyhow!("Failed to parse MCP config: {e}"))?;
3299 Ok(cfg)
3300 }
3301
3302 /// Diagnostic status for an MCP server entry.
3303 #[derive(Debug)]
3304 enum McpServerDoctorStatus {
3305 Ok(String),
3306 Warning(String),
3307 Error(String),
3308 }
3309
3310 /// Check an MCP server config entry for common issues.
3311 fn doctor_check_mcp_server(server: &McpServerConfig) -> McpServerDoctorStatus {
3312 // No command or URL — incomplete entry.
3313 if server.command.is_none() && server.url.is_none() {
3314 return McpServerDoctorStatus::Error("no command or url configured".to_string());
3315 }
3316
3317 // URL-based server — just report the URL.
3318 if let Some(ref url) = server.url {
3319 return McpServerDoctorStatus::Ok(format!("HTTP/SSE server at {url}"));
3320 }
3321
3322 // Command-based: validate command path exists.
3323 let cmd = server.command.as_deref().unwrap_or("");
3324 if cmd.is_empty() {
3325 return McpServerDoctorStatus::Error("empty command".to_string());
3326 }
3327
3328 let cmd_path = Path::new(cmd);
3329 // Also accept Unix-style `/` prefix on Windows, where Path::is_absolute()
3330 // requires a drive letter.
3331 let is_absolute = cmd_path.is_absolute() || cmd.starts_with('/');
3332
3333 if is_absolute && !cmd_path.exists() {
3334 return McpServerDoctorStatus::Error(format!("command not found: {cmd}"));
3335 }
3336
3337 // Detect self-hosted DeepSeek server entries.
3338 let is_self_hosted = server
3339 .args
3340 .windows(2)
3341 .any(|w| w[0] == "serve" && w[1] == "--mcp");
3342
3343 let args_str = server.args.join(" ");
3344 if is_self_hosted {
3345 if is_absolute {
3346 McpServerDoctorStatus::Ok(format!("self-hosted MCP server ({cmd} {args_str})"))
3347 } else {
3348 McpServerDoctorStatus::Warning(format!(
3349 "self-hosted MCP server uses relative command \"{cmd}\" — consider using an absolute path"
3350 ))
3351 }
3352 } else {
3353 McpServerDoctorStatus::Ok(format!(
3354 "stdio server ({cmd}{})",
3355 if args_str.is_empty() {
3356 String::new()
3357 } else {
3358 format!(" {args_str}")
3359 }
3360 ))
3361 }
3362 }
3363
3364 fn save_mcp_config(path: &Path, cfg: &McpConfig) -> Result<()> {
3365 if let Some(parent) = path.parent() {
3366 std::fs::create_dir_all(parent).with_context(|| {
3367 format!("Failed to create MCP config directory {}", parent.display())
3368 })?;
3369 }
3370 let rendered = serde_json::to_string_pretty(cfg)
3371 .map_err(|e| anyhow!("Failed to serialize MCP config: {e}"))?;
3372 crate::utils::write_atomic(path, rendered.as_bytes())
3373 .map_err(|e| anyhow!("Failed to write MCP config {}: {}", path.display(), e))?;
3374 Ok(())
3375 }
3376
3377 fn run_sandbox_command(args: SandboxArgs) -> Result<()> {
3378 use crate::sandbox::{CommandSpec, SandboxManager};
3379
3380 let SandboxCommand::Run {
3381 policy,
3382 network,
3383 writable_root,
3384 exclude_tmpdir,
3385 exclude_slash_tmp,
3386 cwd,
3387 timeout_ms,
3388 command,
3389 } = args.command;
3390
3391 let policy = parse_sandbox_policy(
3392 &policy,
3393 network,
3394 writable_root,
3395 exclude_tmpdir,
3396 exclude_slash_tmp,
3397 )?;
3398 let cwd = cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
3399 let timeout = Duration::from_millis(timeout_ms.clamp(1000, 600_000));
3400
3401 let (program, args) = command
3402 .split_first()
3403 .ok_or_else(|| anyhow::anyhow!("Command is required"))?;
3404 let spec =
3405 CommandSpec::program(program, args.to_vec(), cwd.clone(), timeout).with_policy(policy);
3406 let manager = SandboxManager::new();
3407 let exec_env = manager.prepare(&spec);
3408
3409 let mut cmd = Command::new(exec_env.program());
3410 cmd.args(exec_env.args())
3411 .current_dir(&exec_env.cwd)
3412 .stdout(Stdio::piped())
3413 .stderr(Stdio::piped());
3414 for (key, value) in &exec_env.env {
3415 cmd.env(key, value);
3416 }
3417
3418 let mut child = cmd
3419 .spawn()
3420 .map_err(|e| anyhow::anyhow!("Failed to run command: {e}"))?;
3421 let stdout_handle = child
3422 .stdout
3423 .take()
3424 .ok_or_else(|| anyhow::anyhow!("stdout unavailable"))?;
3425 let stderr_handle = child
3426 .stderr
3427 .take()
3428 .ok_or_else(|| anyhow::anyhow!("stderr unavailable"))?;
3429
3430 let timeout = exec_env.timeout;
3431 let stdout_thread = std::thread::spawn(move || {
3432 let mut reader = stdout_handle;
3433 let mut buf = Vec::new();
3434 let _ = reader.read_to_end(&mut buf);
3435 buf
3436 });
3437 let stderr_thread = std::thread::spawn(move || {
3438 let mut reader = stderr_handle;
3439 let mut buf = Vec::new();
3440 let _ = reader.read_to_end(&mut buf);
3441 buf
3442 });
3443
3444 if let Some(status) = child.wait_timeout(timeout)? {
3445 let stdout = stdout_thread.join().unwrap_or_default();
3446 let stderr = stderr_thread.join().unwrap_or_default();
3447 let stderr_str = String::from_utf8_lossy(&stderr);
3448 let exit_code = status.code().unwrap_or(-1);
3449 let sandbox_type = exec_env.sandbox_type;
3450 let sandbox_denied = SandboxManager::was_denied(sandbox_type, exit_code, &stderr_str);
3451
3452 if !stdout.is_empty() {
3453 print!("{}", String::from_utf8_lossy(&stdout));
3454 }
3455 if !stderr.is_empty() {
3456 eprint!("{}", stderr_str);
3457 }
3458 if sandbox_denied {
3459 eprintln!(
3460 "{}",
3461 SandboxManager::denial_message(sandbox_type, &stderr_str)
3462 );
3463 }
3464
3465 if !status.success() {
3466 bail!("Command failed with exit code {exit_code}");
3467 }
3468 } else {
3469 let _ = child.kill();
3470 let _ = child.wait();
3471 bail!("Command timed out after {}ms", timeout.as_millis());
3472 }
3473 Ok(())
3474 }
3475
3476 fn parse_sandbox_policy(
3477 policy: &str,
3478 network: bool,
3479 writable_root: Vec<PathBuf>,
3480 exclude_tmpdir: bool,
3481 exclude_slash_tmp: bool,
3482 ) -> Result<crate::sandbox::SandboxPolicy> {
3483 use crate::sandbox::SandboxPolicy;
3484
3485 match policy {
3486 "danger-full-access" => Ok(SandboxPolicy::DangerFullAccess),
3487 "read-only" => Ok(SandboxPolicy::ReadOnly),
3488 "external-sandbox" => Ok(SandboxPolicy::ExternalSandbox {
3489 network_access: network,
3490 }),
3491 "workspace-write" => Ok(SandboxPolicy::WorkspaceWrite {
3492 writable_roots: writable_root,
3493 network_access: network,
3494 exclude_tmpdir,
3495 exclude_slash_tmp,
3496 }),
3497 other => bail!("Unknown sandbox policy: {other}"),
3498 }
3499 }
3500
3501 fn should_use_alt_screen(cli: &Cli, config: &Config) -> bool {
3502 if cli.no_alt_screen {
3503 return false;
3504 }
3505
3506 let mode = config
3507 .tui
3508 .as_ref()
3509 .and_then(|tui| tui.alternate_screen.as_deref())
3510 .unwrap_or("auto")
3511 .to_ascii_lowercase();
3512
3513 match mode.as_str() {
3514 "always" => true,
3515 "never" => false,
3516 _ => !is_zellij(),
3517 }
3518 }
3519
3520 fn should_use_mouse_capture(cli: &Cli, config: &Config, use_alt_screen: bool) -> bool {
3521 let terminal_emulator = std::env::var("TERMINAL_EMULATOR").ok();
3522 should_use_mouse_capture_with(cli, config, use_alt_screen, terminal_emulator.as_deref())
3523 }
3524
3525 fn should_use_mouse_capture_with(
3526 cli: &Cli,
3527 config: &Config,
3528 use_alt_screen: bool,
3529 terminal_emulator: Option<&str>,
3530 ) -> bool {
3531 if !use_alt_screen || cli.no_mouse_capture {
3532 return false;
3533 }
3534 if cli.mouse_capture {
3535 return true;
3536 }
3537 config
3538 .tui
3539 .as_ref()
3540 .and_then(|tui| tui.mouse_capture)
3541 .unwrap_or_else(|| default_mouse_capture_enabled(terminal_emulator))
3542 }
3543
3544 /// Whether to enable terminal mouse capture by default for this platform/host.
3545 ///
3546 /// Returns `false` on Windows (legacy console mouse-mode reporting is flaky;
3547 /// `--mouse-capture` opts in) and on JetBrains' JediTerm, which advertises
3548 /// mouse support but delivers SGR mouse-event escape sequences as raw text
3549 /// in the input stream — visible to users as garbled characters in the
3550 /// composer when they move the mouse over the TUI (#878, #898). The user
3551 /// can still opt back in with `[tui] mouse_capture = true` in
3552 /// `~/.deepseek/config.toml` or `--mouse-capture`.
3553 fn default_mouse_capture_enabled(terminal_emulator: Option<&str>) -> bool {
3554 if cfg!(windows) {
3555 return false;
3556 }
3557 if matches!(terminal_emulator, Some(t) if t.eq_ignore_ascii_case("JetBrains-JediTerm")) {
3558 return false;
3559 }
3560 true
3561 }
3562
3563 fn is_zellij() -> bool {
3564 std::env::var_os("ZELLIJ").is_some()
3565 }
3566
3567 /// Check for a crash-recovery checkpoint and return the session ID if
3568 /// recovery is possible *and* the checkpoint belongs to the current
3569 /// workspace.
3570 ///
3571 /// The checkpoint must exist and its file mtime must be within 24 hours.
3572 /// **The checkpoint's workspace must also match `std::env::current_dir()`
3573 /// after canonicalisation.** If the workspace doesn't match, the
3574 /// checkpoint is persisted as a regular session (so the user can find it
3575 /// via `deepseek sessions` / `deepseek resume <id>`) and cleared, and the
3576 /// new launch starts fresh — silently importing a session from another
3577 /// project would leak api_messages, working_set entries, and possibly
3578 /// secrets across directories (see v0.8.12 cross-workspace bleed report).
3579 ///
3580 /// On a successful match the checkpoint is persisted as a regular session,
3581 /// cleared, and a notice is printed to stderr. Returns `None` if there is
3582 /// nothing to recover or the workspace doesn't match.
3583 fn try_recover_checkpoint() -> Option<String> {
3584 let manager = session_manager::SessionManager::default_location().ok()?;
3585 let session = manager.load_checkpoint().ok().flatten()?;
3586
3587 // Verify the checkpoint file is recent (within 24 hours).
3588 let home = dirs::home_dir()?;
3589 let checkpoint_path = home
3590 .join(".deepseek")
3591 .join("sessions")
3592 .join("checkpoints")
3593 .join("latest.json");
3594 let metadata = std::fs::metadata(&checkpoint_path).ok()?;
3595 let mtime = metadata.modified().ok()?;
3596 let age = std::time::SystemTime::now().duration_since(mtime).ok()?;
3597 if age > std::time::Duration::from_secs(24 * 3600) {
3598 // Stale checkpoint — clean it up.
3599 let _ = manager.clear_checkpoint();
3600 return None;
3601 }
3602
3603 // Refuse to silently restore a session from another workspace. We compare
3604 // canonicalised paths so that `~/foo` vs `/Users/x/foo` and symlink
3605 // variants resolve consistently. If either side fails to canonicalise
3606 // (e.g. the saved workspace was deleted), fall back to a strict equality
3607 // check on the raw paths.
3608 let session_workspace = session.metadata.workspace.clone();
3609 let current_workspace = std::env::current_dir().ok()?;
3610 let workspace_matches = {
3611 let lhs = std::fs::canonicalize(&session_workspace).ok();
3612 let rhs = std::fs::canonicalize(&current_workspace).ok();
3613 match (lhs, rhs) {
3614 (Some(a), Some(b)) => a == b,
3615 _ => session_workspace == current_workspace,
3616 }
3617 };
3618
3619 if !workspace_matches {
3620 // Persist the checkpoint so the user can find it via `deepseek
3621 // sessions`, then clear it so the next launch in this folder doesn't
3622 // re-trip the nag. Print a one-line notice pointing at the explicit
3623 // resume command — but DO NOT auto-load the session here.
3624 let session_id_for_notice = session.metadata.id.clone();
3625 let _ = manager.save_session(&session);
3626 let _ = manager.clear_checkpoint();
3627 eprintln!(
3628 "Note: an interrupted session ({}…) from another workspace ({}) is \
3629 available. Run `deepseek resume {}` from there to recover it, or \
3630 use `deepseek sessions` to list all saved sessions. Starting fresh \
3631 here.",
3632 &session_id_for_notice.chars().take(8).collect::<String>(),
3633 session_workspace.display(),
3634 session_id_for_notice,
3635 );
3636 return None;
3637 }
3638
3639 let session_id = session.metadata.id.clone();
3640
3641 // Persist the checkpoint as a regular session so the TUI can load it by id.
3642 if manager.save_session(&session).is_err() {
3643 return None;
3644 }
3645
3646 // Clear the checkpoint now that it has been recovered.
3647 let _ = manager.clear_checkpoint();
3648
3649 // Format age for the notice.
3650 let age_str = if age.as_secs() < 60 {
3651 format!("{}s ago", age.as_secs())
3652 } else if age.as_secs() < 3600 {
3653 format!("{}m ago", age.as_secs() / 60)
3654 } else {
3655 format!("{}h ago", age.as_secs() / 3600)
3656 };
3657 eprintln!("Recovered interrupted session ({age_str}). Use --fresh to start fresh.",);
3658
3659 Some(session_id)
3660 }
3661
3662 /// Load project-level config from `$WORKSPACE/.deepseek/config.toml` and
3663 /// apply its fields as overrides on top of the global config (#485).
3664 /// Only explicitly set fields in the project file are applied; everything
3665 /// else falls back to the global value.
3666 fn merge_project_config(config: &mut Config, workspace: &Path) {
3667 let path = workspace.join(".deepseek").join("config.toml");
3668 let raw = match std::fs::read_to_string(&path) {
3669 Ok(r) => r,
3670 Err(_) => return,
3671 };
3672 let project: toml::Value = match toml::from_str(&raw) {
3673 Ok(v) => v,
3674 Err(_) => return,
3675 };
3676 let table = match project.as_table() {
3677 Some(t) => t,
3678 None => return,
3679 };
3680
3681 // #417: dangerous keys are denied at project scope. A malicious
3682 // `<workspace>/.deepseek/config.toml` could otherwise:
3683 // * `api_key` / `base_url` / `provider` — exfiltrate prompts to a
3684 // look-alike endpoint by swapping the user's credentials and
3685 // target host with project-controlled values.
3686 // * `mcp_config_path` — point the loader at an MCP config that
3687 // spawns arbitrary stdio servers under the user's identity.
3688 //
3689 // The overlay path is non-interactive; users can't visually
3690 // confirm a rogue project config is hijacking these. We surface
3691 // a stderr warning on first encounter so a user who *did* expect
3692 // the override has a chance to notice the deny instead of silent
3693 // discard.
3694 const DENY_AT_PROJECT_SCOPE: &[&str] = &["api_key", "base_url", "provider", "mcp_config_path"];
3695 for key in DENY_AT_PROJECT_SCOPE {
3696 if table.contains_key(*key) {
3697 eprintln!(
3698 "warning: project-scope config key `{key}` is ignored — \
3699 set it in `~/.deepseek/config.toml` instead. \
3700 (See #417 for the deny-list rationale.)"
3701 );
3702 }
3703 }
3704
3705 // String fields a project may legitimately override (model,
3706 // approval/sandbox tightening, notes path, reasoning effort).
3707 // Loosening *values* like `approval_policy = "auto"` and
3708 // `sandbox_mode = "danger-full-access"` are denied unconditionally
3709 // — those are pure escalation regardless of the user's prior
3710 // value. Sub-tightening comparisons (e.g. user `"never"` →
3711 // project `"on-request"`) stay v0.8.9 follow-up because they
3712 // need a richer ordering check.
3713 for (key, field) in [
3714 ("model", &mut config.default_text_model),
3715 ("reasoning_effort", &mut config.reasoning_effort),
3716 ("approval_policy", &mut config.approval_policy),
3717 ("sandbox_mode", &mut config.sandbox_mode),
3718 ("notes_path", &mut config.notes_path),
3719 ] {
3720 if let Some(v) = table.get(key).and_then(toml::Value::as_str)
3721 && !v.is_empty()
3722 {
3723 // #417 escalation deny: project cannot push the session
3724 // to the loosest values. Other strings flow through the
3725 // existing config validator on load.
3726 let is_escalation = matches!(
3727 (key, v),
3728 ("approval_policy", "auto") | ("sandbox_mode", "danger-full-access")
3729 );
3730 if is_escalation {
3731 eprintln!(
3732 "warning: project-scope `{key} = \"{v}\"` is ignored — \
3733 project config cannot escalate to the loosest value. \
3734 (See #417.)"
3735 );
3736 continue;
3737 }
3738 *field = Some(v.to_string());
3739 }
3740 }
3741
3742 // Numeric / bool fields that benefit from per-project overrides.
3743 if let Some(v) = table.get("max_subagents").and_then(toml::Value::as_integer)
3744 && v > 0
3745 {
3746 config.max_subagents = Some((v as usize).clamp(1, crate::config::MAX_SUBAGENTS));
3747 }
3748 if let Some(v) = table.get("allow_shell").and_then(toml::Value::as_bool) {
3749 config.allow_shell = Some(v);
3750 }
3751
3752 // #454: instructions array — project replaces user. Empty arrays
3753 // count: explicit `instructions = []` clears the user's list for
3754 // this repo, useful when the user has a verbose global file that
3755 // doesn't apply to the current project. Non-string entries are
3756 // skipped silently rather than failing the load.
3757 if let Some(arr) = table.get("instructions").and_then(toml::Value::as_array) {
3758 let entries: Vec<String> = arr
3759 .iter()
3760 .filter_map(|v| v.as_str().map(str::to_string))
3761 .filter(|s| !s.trim().is_empty())
3762 .collect();
3763 config.instructions = Some(entries);
3764 }
3765 }
3766
3767 async fn run_interactive(
3768 cli: &Cli,
3769 config: &Config,
3770 resume_session_id: Option<String>,
3771 initial_input: Option<String>,
3772 ) -> Result<()> {
3773 let workspace = cli
3774 .workspace
3775 .clone()
3776 .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
3777
3778 // Merge project-level config from $WORKSPACE/.deepseek/config.toml
3779 // unless --no-project-config was passed (#485).
3780 let mut merged_config = config.clone();
3781 if !cli.no_project_config {
3782 merge_project_config(&mut merged_config, &workspace);
3783 }
3784 let config = &merged_config;
3785
3786 if !cli.skip_onboarding {
3787 match crate::config::ensure_config_file_exists(cli.config.clone()) {
3788 Ok(Some(path)) => logging::info(format!(
3789 "Created first-run config file at {}",
3790 path.display()
3791 )),
3792 Ok(None) => {}
3793 Err(err) => logging::warn(format!("Failed to create first-run config file: {err}")),
3794 }
3795 }
3796
3797 let model = config.default_model();
3798 let max_subagents = cli.max_subagents.map_or_else(
3799 || config.max_subagents(),
3800 |value| value.clamp(1, MAX_SUBAGENTS),
3801 );
3802 let use_alt_screen = should_use_alt_screen(cli, config);
3803 let use_mouse_capture = should_use_mouse_capture(cli, config, use_alt_screen);
3804 let use_bracketed_paste = crate::settings::Settings::load()
3805 .map(|s| s.bracketed_paste)
3806 .unwrap_or(true);
3807
3808 // Auto-install bundled system skills (e.g. skill-creator) on first launch.
3809 // Errors are non-fatal: log a warning and continue.
3810 let skills_dir = config.skills_dir();
3811 if let Err(e) = crate::skills::install_system_skills(&skills_dir) {
3812 logging::warn(format!("Failed to install system skills: {e}"));
3813 }
3814
3815 // Prune stale workspace snapshots from prior sessions (7-day default).
3816 // Non-fatal: a flaky disk, missing `git`, or read-only home should
3817 // never block the TUI from starting.
3818 let snapshots = config.snapshots_config();
3819 if snapshots.enabled {
3820 session_manager::prune_workspace_snapshots(&workspace, snapshots.max_age());
3821 }
3822
3823 // Prune stale tool-output spillover files (#422). Non-fatal: home
3824 // missing or directory unreadable just means nothing got pruned;
3825 // we never block startup. Runs unconditionally because the
3826 // spillover store is created lazily on first write — there's no
3827 // user-facing setting to gate.
3828 match crate::tools::truncate::prune_older_than(crate::tools::truncate::SPILLOVER_MAX_AGE) {
3829 Ok(0) => {}
3830 Ok(n) => tracing::debug!(
3831 target: "spillover",
3832 "boot prune removed {n} spillover file(s)"
3833 ),
3834 Err(err) => tracing::warn!(
3835 target: "spillover",
3836 ?err,
3837 "spillover prune skipped on boot"
3838 ),
3839 }
3840
3841 tui::run_tui(
3842 config,
3843 tui::TuiOptions {
3844 model,
3845 workspace,
3846 config_path: cli.config.clone(),
3847 config_profile: cli.profile.clone(),
3848 allow_shell: cli.yolo || config.allow_shell(),
3849 use_alt_screen,
3850 use_mouse_capture,
3851 use_bracketed_paste,
3852 skills_dir,
3853 memory_path: config.memory_path(),
3854 notes_path: config.notes_path(),
3855 mcp_config_path: config.mcp_config_path(),
3856 use_memory: config.memory_enabled(),
3857 start_in_agent_mode: cli.yolo,
3858 skip_onboarding: cli.skip_onboarding,
3859 yolo: cli.yolo, // YOLO mode auto-approves all tool executions
3860 resume_session_id,
3861 initial_input,
3862 max_subagents,
3863 },
3864 )
3865 .await
3866 }
3867
3868 struct CliAutoRoute {
3869 model: String,
3870 reasoning_effort: Option<crate::tui::app::ReasoningEffort>,
3871 auto_model: bool,
3872 }
3873
3874 async fn resolve_cli_auto_route(config: &Config, model: &str, prompt: &str) -> CliAutoRoute {
3875 if model.trim().eq_ignore_ascii_case("auto") {
3876 let selection =
3877 commands::resolve_auto_route_with_flash(config, prompt, "", "auto", "auto").await;
3878 CliAutoRoute {
3879 model: selection.model,
3880 reasoning_effort: selection.reasoning_effort,
3881 auto_model: true,
3882 }
3883 } else {
3884 CliAutoRoute {
3885 model: model.to_string(),
3886 reasoning_effort: None,
3887 auto_model: false,
3888 }
3889 }
3890 }
3891
3892 async fn run_one_shot(config: &Config, model: &str, prompt: &str) -> Result<()> {
3893 use crate::client::DeepSeekClient;
3894 use crate::models::{ContentBlock, Message, MessageRequest};
3895
3896 let client = DeepSeekClient::new(config)?;
3897 let route = resolve_cli_auto_route(config, model, prompt).await;
3898 let reasoning_effort = route
3899 .reasoning_effort
3900 .map(|effort| effort.as_setting().to_string());
3901
3902 let request = MessageRequest {
3903 model: route.model,
3904 messages: vec![Message {
3905 role: "user".to_string(),
3906 content: vec![ContentBlock::Text {
3907 text: prompt.to_string(),
3908 cache_control: None,
3909 }],
3910 }],
3911 max_tokens: 4096,
3912 system: None,
3913 tools: None,
3914 tool_choice: None,
3915 metadata: None,
3916 thinking: None,
3917 reasoning_effort,
3918 stream: Some(false),
3919 temperature: None,
3920 top_p: None,
3921 };
3922
3923 let response = client.create_message(request).await?;
3924
3925 for block in response.content {
3926 if let ContentBlock::Text { text, .. } = block {
3927 println!("{text}");
3928 }
3929 }
3930
3931 Ok(())
3932 }
3933
3934 async fn run_one_shot_json(config: &Config, model: &str, prompt: &str) -> Result<()> {
3935 use crate::client::DeepSeekClient;
3936 use crate::models::{ContentBlock, Message, MessageRequest, SystemPrompt};
3937
3938 let client = DeepSeekClient::new(config)?;
3939 let route = resolve_cli_auto_route(config, model, prompt).await;
3940 let model = route.model;
3941 let reasoning_effort = route
3942 .reasoning_effort
3943 .map(|effort| effort.as_setting().to_string());
3944 let request = MessageRequest {
3945 model: model.clone(),
3946 messages: vec![Message {
3947 role: "user".to_string(),
3948 content: vec![ContentBlock::Text {
3949 text: prompt.to_string(),
3950 cache_control: None,
3951 }],
3952 }],
3953 max_tokens: 4096,
3954 system: Some(SystemPrompt::Text(
3955 "You are a coding assistant. Give concise, actionable responses.".to_string(),
3956 )),
3957 tools: None,
3958 tool_choice: None,
3959 metadata: None,
3960 thinking: None,
3961 reasoning_effort,
3962 stream: Some(false),
3963 temperature: Some(0.2),
3964 top_p: Some(0.9),
3965 };
3966
3967 let response = client.create_message(request).await?;
3968 let mut output = String::new();
3969 for block in response.content {
3970 if let ContentBlock::Text { text, .. } = block {
3971 output.push_str(&text);
3972 }
3973 }
3974 println!(
3975 "{}",
3976 serde_json::to_string_pretty(&serde_json::json!({
3977 "mode": "one-shot",
3978 "model": model,
3979 "success": true,
3980 "output": output
3981 }))?
3982 );
3983 Ok(())
3984 }
3985
3986 #[allow(clippy::too_many_arguments)]
3987 async fn run_exec_agent(
3988 config: &Config,
3989 model: &str,
3990 prompt: &str,
3991 workspace: PathBuf,
3992 max_subagents: usize,
3993 auto_approve: bool,
3994 trust_mode: bool,
3995 json_output: bool,
3996 ) -> Result<()> {
3997 use crate::compaction::CompactionConfig;
3998 use crate::core::engine::{EngineConfig, spawn_engine};
3999 use crate::core::events::Event;
4000 use crate::core::ops::Op;
4001 use crate::models::compaction_threshold_for_model;
4002 use crate::tools::plan::new_shared_plan_state;
4003 use crate::tools::todo::new_shared_todo_list;
4004 use crate::tui::app::AppMode;
4005
4006 let route = resolve_cli_auto_route(config, model, prompt).await;
4007 let auto_model = route.auto_model;
4008 let effective_model = route.model;
4009 let effective_reasoning_effort = route
4010 .reasoning_effort
4011 .map(|effort| effort.as_setting().to_string());
4012
4013 // Compaction defaults to disabled in v0.6.6: the checkpoint-restart cycle
4014 // architecture (issue #124) handles long-context resets via fresh contexts
4015 // rather than progressive summarization. The compaction config is still
4016 // wired through so users who explicitly opt back in through TUI settings
4017 // or direct engine config keep their old behavior.
4018 let compaction = CompactionConfig {
4019 enabled: false,
4020 model: effective_model.clone(),
4021 token_threshold: compaction_threshold_for_model(&effective_model),
4022 ..Default::default()
4023 };
4024
4025 let network_policy = config.network.clone().map(|toml_cfg| {
4026 crate::network_policy::NetworkPolicyDecider::with_default_audit(toml_cfg.into_runtime())
4027 });
4028
4029 let lsp_config = config
4030 .lsp
4031 .clone()
4032 .map(crate::config::LspConfigToml::into_runtime);
4033
4034 let engine_config = EngineConfig {
4035 model: effective_model.clone(),
4036 workspace: workspace.clone(),
4037 allow_shell: auto_approve || config.allow_shell(),
4038 trust_mode,
4039 notes_path: config.notes_path(),
4040 mcp_config_path: config.mcp_config_path(),
4041 skills_dir: config.skills_dir(),
4042 instructions: config.instructions_paths(),
4043 max_steps: 100,
4044 max_subagents,
4045 features: config.features(),
4046 compaction,
4047 cycle: crate::cycle_manager::CycleConfig::default(),
4048 capacity: crate::core::capacity::CapacityControllerConfig::from_app_config(config),
4049 todos: new_shared_todo_list(),
4050 plan_state: new_shared_plan_state(),
4051 max_spawn_depth: crate::tools::subagent::DEFAULT_MAX_SPAWN_DEPTH,
4052 network_policy,
4053 snapshots_enabled: config.snapshots_config().enabled,
4054 lsp_config,
4055 runtime_services: crate::tools::spec::RuntimeToolServices::default(),
4056 subagent_model_overrides: config.subagent_model_overrides(),
4057 memory_enabled: config.memory_enabled(),
4058 memory_path: config.memory_path(),
4059 strict_tool_mode: config.strict_tool_mode.unwrap_or(false),
4060 goal_objective: None,
4061 locale_tag: crate::localization::resolve_locale(
4062 &crate::settings::Settings::load().unwrap_or_default().locale,
4063 )
4064 .tag()
4065 .to_string(),
4066 workshop: config.workshop.clone(),
4067 };
4068
4069 let engine_handle = spawn_engine(engine_config, config);
4070 let mode = if auto_approve {
4071 AppMode::Yolo
4072 } else {
4073 AppMode::Agent
4074 };
4075
4076 engine_handle
4077 .send(Op::SendMessage {
4078 content: prompt.to_string(),
4079 mode,
4080 model: effective_model.clone(),
4081 goal_objective: None,
4082 reasoning_effort: effective_reasoning_effort,
4083 reasoning_effort_auto: auto_model,
4084 auto_model,
4085 allow_shell: auto_approve || config.allow_shell(),
4086 trust_mode,
4087 auto_approve,
4088 approval_mode: if auto_approve {
4089 crate::tui::approval::ApprovalMode::Auto
4090 } else {
4091 config
4092 .approval_policy
4093 .as_deref()
4094 .and_then(crate::tui::approval::ApprovalMode::from_config_value)
4095 .unwrap_or_default()
4096 },
4097 })
4098 .await?;
4099
4100 #[derive(serde::Serialize)]
4101 struct ExecToolEntry {
4102 name: String,
4103 success: bool,
4104 output: String,
4105 }
4106 #[derive(serde::Serialize, Default)]
4107 struct ExecSummary {
4108 mode: String,
4109 model: String,
4110 prompt: String,
4111 output: String,
4112 tools: Vec<ExecToolEntry>,
4113 status: Option<String>,
4114 error: Option<String>,
4115 }
4116 let mut summary = ExecSummary {
4117 mode: "agent".to_string(),
4118 model: effective_model,
4119 prompt: prompt.to_string(),
4120 ..ExecSummary::default()
4121 };
4122
4123 let mut stdout = io::stdout();
4124 let mut ends_with_newline = false;
4125 loop {
4126 let event = {
4127 let mut rx = engine_handle.rx_event.write().await;
4128 rx.recv().await
4129 };
4130
4131 let Some(event) = event else {
4132 break;
4133 };
4134
4135 match event {
4136 Event::MessageDelta { content, .. } => {
4137 summary.output.push_str(&content);
4138 if !json_output {
4139 print!("{content}");
4140 stdout.flush()?;
4141 }
4142 ends_with_newline = content.ends_with('\n');
4143 }
4144 Event::MessageComplete { .. } if !json_output && !ends_with_newline => {
4145 println!();
4146 }
4147 Event::ToolCallStarted { name, input, .. } if !json_output => {
4148 let summary = summarize_tool_args(&input);
4149 if let Some(summary) = summary {
4150 eprintln!("tool: {name} ({summary})");
4151 } else {
4152 eprintln!("tool: {name}");
4153 }
4154 }
4155 Event::ToolCallProgress { id, output } if !json_output => {
4156 eprintln!("tool {id}: {}", summarize_tool_output(&output));
4157 }
4158 Event::ToolCallComplete { name, result, .. } => match result {
4159 Ok(output) => {
4160 summary.tools.push(ExecToolEntry {
4161 name: name.clone(),
4162 success: output.success,
4163 output: output.content.clone(),
4164 });
4165 if name == "exec_shell" && !output.content.trim().is_empty() {
4166 if !json_output {
4167 eprintln!("tool {name} completed");
4168 eprintln!(
4169 "--- stdout/stderr ---\n{}\n---------------------",
4170 output.content
4171 );
4172 }
4173 } else if !json_output {
4174 eprintln!(
4175 "tool {name} completed: {}",
4176 summarize_tool_output(&output.content)
4177 );
4178 }
4179 }
4180 Err(err) => {
4181 summary.tools.push(ExecToolEntry {
4182 name: name.clone(),
4183 success: false,
4184 output: err.to_string(),
4185 });
4186 if !json_output {
4187 eprintln!("tool {name} failed: {err}");
4188 }
4189 }
4190 },
4191 Event::AgentSpawned { id, prompt } => {
4192 eprintln!("sub-agent {id} spawned: {}", summarize_tool_output(&prompt));
4193 }
4194 Event::AgentProgress { id, status } => {
4195 eprintln!("sub-agent {id}: {status}");
4196 }
4197 Event::AgentComplete { id, result } => {
4198 eprintln!(
4199 "sub-agent {id} completed: {}",
4200 summarize_tool_output(&result)
4201 );
4202 }
4203 Event::ApprovalRequired { id, .. } => {
4204 if auto_approve {
4205 let _ = engine_handle.approve_tool_call(id).await;
4206 } else {
4207 let _ = engine_handle.deny_tool_call(id).await;
4208 }
4209 }
4210 Event::ElevationRequired {
4211 tool_id,
4212 tool_name,
4213 denial_reason,
4214 ..
4215 } => {
4216 if auto_approve {
4217 eprintln!("sandbox denied {tool_name}: {denial_reason} (auto-elevating)");
4218 let policy = crate::sandbox::SandboxPolicy::DangerFullAccess;
4219 let _ = engine_handle.retry_tool_with_policy(tool_id, policy).await;
4220 } else {
4221 eprintln!("sandbox denied {tool_name}: {denial_reason}");
4222 let _ = engine_handle.deny_tool_call(tool_id).await;
4223 }
4224 }
4225 Event::Error {
4226 envelope,
4227 recoverable: _,
4228 } => {
4229 summary.error = Some(envelope.message.clone());
4230 if !json_output {
4231 eprintln!("error: {}", envelope.message);
4232 }
4233 }
4234 Event::TurnComplete { status, error, .. } => {
4235 summary.status = Some(format!("{status:?}").to_lowercase());
4236 summary.error = error;
4237 let _ = engine_handle.send(Op::Shutdown).await;
4238 break;
4239 }
4240 _ => {}
4241 }
4242 }
4243
4244 if json_output {
4245 println!("{}", serde_json::to_string_pretty(&summary)?);
4246 }
4247
4248 Ok(())
4249 }
4250
4251 #[cfg(test)]
4252 mod doctor_endpoint_tests {
4253 use super::*;
4254
4255 #[test]
4256 fn doctor_api_target_reports_default_endpoint() {
4257 let config = Config::default();
4258
4259 let target = doctor_api_target(&config);
4260
4261 assert_eq!(target.provider, "deepseek");
4262 assert_eq!(target.base_url, "https://api.deepseek.com");
4263 assert_eq!(target.model, crate::config::DEFAULT_TEXT_MODEL);
4264 }
4265
4266 #[test]
4267 fn doctor_api_target_reports_deepseek_cn_endpoint() {
4268 let config = Config {
4269 provider: Some("deepseek-cn".to_string()),
4270 ..Default::default()
4271 };
4272
4273 let target = doctor_api_target(&config);
4274
4275 assert_eq!(target.provider, "deepseek-cn");
4276 assert_eq!(target.base_url, crate::config::DEFAULT_DEEPSEEKCN_BASE_URL);
4277 assert_eq!(target.model, crate::config::DEFAULT_TEXT_MODEL);
4278 }
4279
4280 #[test]
4281 fn timeout_recovery_points_global_deepseek_users_to_cn_endpoint() {
4282 let config = Config::default();
4283
4284 let text = doctor_timeout_recovery_lines(&config).join("\n");
4285
4286 assert!(text.contains("api.deepseeki.com"));
4287 assert!(text.contains("provider = \"deepseek-cn\""));
4288 assert!(text.contains("deepseek doctor --json"));
4289 }
4290
4291 #[test]
4292 fn timeout_recovery_for_custom_provider_checks_openai_compatibility() {
4293 let config = Config {
4294 provider: Some("vllm".to_string()),
4295 ..Default::default()
4296 };
4297
4298 let text = doctor_timeout_recovery_lines(&config).join("\n");
4299
4300 assert!(text.contains("/v1/models"));
4301 assert!(text.contains("/v1/chat/completions"));
4302 assert!(!text.contains("api.deepseeki.com"));
4303 }
4304 }
4305
4306 #[cfg(test)]
4307 mod terminal_mode_tests {
4308 use super::*;
4309 use clap::Parser;
4310
4311 fn parse_cli(args: &[&str]) -> Cli {
4312 Cli::try_parse_from(args).expect("CLI args should parse")
4313 }
4314
4315 #[test]
4316 #[cfg(not(windows))]
4317 fn mouse_capture_defaults_on_when_alternate_screen_is_active() {
4318 let cli = parse_cli(&["deepseek"]);
4319 let config = Config::default();
4320
4321 assert!(should_use_mouse_capture_with(&cli, &config, true, None));
4322 }
4323
4324 #[test]
4325 #[cfg(windows)]
4326 fn mouse_capture_defaults_off_on_windows_when_alternate_screen_is_active() {
4327 let cli = parse_cli(&["deepseek"]);
4328 let config = Config::default();
4329
4330 assert!(!should_use_mouse_capture_with(&cli, &config, true, None));
4331 }
4332
4333 #[test]
4334 fn no_mouse_capture_flag_disables_mouse_capture() {
4335 let cli = parse_cli(&["deepseek", "--no-mouse-capture"]);
4336 let config = Config::default();
4337
4338 assert!(!should_use_mouse_capture_with(&cli, &config, true, None));
4339 }
4340
4341 #[test]
4342 fn config_can_disable_default_mouse_capture() {
4343 let cli = parse_cli(&["deepseek"]);
4344 let config = Config {
4345 tui: Some(crate::config::TuiConfig {
4346 alternate_screen: None,
4347 mouse_capture: Some(false),
4348 terminal_probe_timeout_ms: None,
4349 status_items: None,
4350 osc8_links: None,
4351 notification_condition: None,
4352 }),
4353 ..Config::default()
4354 };
4355
4356 assert!(!should_use_mouse_capture_with(&cli, &config, true, None));
4357 }
4358
4359 #[test]
4360 fn mouse_capture_flag_enables_mouse_capture() {
4361 let cli = parse_cli(&["deepseek", "--mouse-capture"]);
4362 let config = Config::default();
4363
4364 assert!(should_use_mouse_capture_with(&cli, &config, true, None));
4365 }
4366
4367 #[test]
4368 fn config_can_enable_mouse_capture() {
4369 let cli = parse_cli(&["deepseek"]);
4370 let config = Config {
4371 tui: Some(crate::config::TuiConfig {
4372 alternate_screen: None,
4373 mouse_capture: Some(true),
4374 terminal_probe_timeout_ms: None,
4375 status_items: None,
4376 osc8_links: None,
4377 notification_condition: None,
4378 }),
4379 ..Config::default()
4380 };
4381
4382 assert!(should_use_mouse_capture_with(&cli, &config, true, None));
4383 }
4384
4385 #[test]
4386 fn mouse_capture_is_off_without_alternate_screen() {
4387 let cli = parse_cli(&["deepseek", "--mouse-capture"]);
4388 let config = Config::default();
4389
4390 assert!(!should_use_mouse_capture_with(&cli, &config, false, None));
4391 }
4392
4393 // Issue #878 / #898: JetBrains JediTerm advertises mouse support but
4394 // forwards SGR mouse-event escapes as raw input characters, producing
4395 // the "input box auto-fills with garbled characters when I move the
4396 // mouse" failure mode in PyCharm/IDEA terminals. Default the capture
4397 // off when we see TERMINAL_EMULATOR=JetBrains-JediTerm; explicit
4398 // config / --mouse-capture still wins.
4399
4400 #[test]
4401 fn mouse_capture_defaults_off_in_jetbrains_jediterm() {
4402 let cli = parse_cli(&["deepseek"]);
4403 let config = Config::default();
4404
4405 assert!(!should_use_mouse_capture_with(
4406 &cli,
4407 &config,
4408 true,
4409 Some("JetBrains-JediTerm"),
4410 ));
4411 }
4412
4413 #[test]
4414 fn jetbrains_default_off_is_case_insensitive() {
4415 let cli = parse_cli(&["deepseek"]);
4416 let config = Config::default();
4417
4418 // JetBrains has occasionally varied the casing across releases;
4419 // a case-insensitive match keeps the protection in place.
4420 assert!(!should_use_mouse_capture_with(
4421 &cli,
4422 &config,
4423 true,
4424 Some("jetbrains-jediterm"),
4425 ));
4426 }
4427
4428 #[test]
4429 fn mouse_capture_flag_overrides_jetbrains_default() {
4430 let cli = parse_cli(&["deepseek", "--mouse-capture"]);
4431 let config = Config::default();
4432
4433 assert!(should_use_mouse_capture_with(
4434 &cli,
4435 &config,
4436 true,
4437 Some("JetBrains-JediTerm"),
4438 ));
4439 }
4440
4441 #[test]
4442 fn config_mouse_capture_true_overrides_jetbrains_default() {
4443 let cli = parse_cli(&["deepseek"]);
4444 let config = Config {
4445 tui: Some(crate::config::TuiConfig {
4446 alternate_screen: None,
4447 mouse_capture: Some(true),
4448 terminal_probe_timeout_ms: None,
4449 status_items: None,
4450 osc8_links: None,
4451 notification_condition: None,
4452 }),
4453 ..Config::default()
4454 };
4455
4456 assert!(should_use_mouse_capture_with(
4457 &cli,
4458 &config,
4459 true,
4460 Some("JetBrains-JediTerm"),
4461 ));
4462 }
4463 }
4464
4465 #[cfg(test)]
4466 mod project_config_tests {
4467 use super::*;
4468 use std::fs;
4469 use tempfile::tempdir;
4470
4471 /// Write a `<workspace>/.deepseek/config.toml` and return the workspace
4472 /// root so the merge function can find it.
4473 fn workspace_with_project_config(body: &str) -> tempfile::TempDir {
4474 let tmp = tempdir().expect("tempdir");
4475 let project_dir = tmp.path().join(".deepseek");
4476 fs::create_dir_all(&project_dir).expect("mkdir .deepseek");
4477 fs::write(project_dir.join("config.toml"), body).expect("write project config");
4478 tmp
4479 }
4480
4481 #[test]
4482 fn project_overlay_overrides_model_but_denies_provider() {
4483 // #417: `provider` is on the deny-list; only the `model`
4484 // override applies. The denied key emits a stderr warning
4485 // (verified by integration runs; here we assert the post-
4486 // merge state).
4487 let tmp = workspace_with_project_config(
4488 r#"
4489 provider = "nvidia-nim"
4490 model = "deepseek-ai/deepseek-v4-pro"
4491 "#,
4492 );
4493 let mut config = Config::default();
4494 merge_project_config(&mut config, tmp.path());
4495 assert_eq!(
4496 config.provider, None,
4497 "#417: project-scope `provider` must be denied"
4498 );
4499 assert_eq!(
4500 config.default_text_model.as_deref(),
4501 Some("deepseek-ai/deepseek-v4-pro"),
4502 "model is allowed at project scope"
4503 );
4504 }
4505
4506 #[test]
4507 fn project_overlay_denies_dangerous_credentials_and_redirects() {
4508 // #417: `api_key` / `base_url` / `provider` / `mcp_config_path`
4509 // are all on the deny-list. A malicious project must not be
4510 // able to redirect prompts or hijack MCP servers via these.
4511 let tmp = workspace_with_project_config(
4512 r#"
4513 api_key = "ATTACKER_KEY"
4514 base_url = "https://evil.example.com"
4515 provider = "nvidia-nim"
4516 mcp_config_path = "/tmp/attacker-mcp.json"
4517 "#,
4518 );
4519 let mut config = Config {
4520 api_key: Some("USER_KEY".to_string()),
4521 base_url: Some("https://api.deepseek.com".to_string()),
4522 ..Config::default()
4523 };
4524 merge_project_config(&mut config, tmp.path());
4525 assert_eq!(
4526 config.api_key.as_deref(),
4527 Some("USER_KEY"),
4528 "user api_key must survive project-config attack"
4529 );
4530 assert_eq!(
4531 config.base_url.as_deref(),
4532 Some("https://api.deepseek.com"),
4533 "user base_url must survive project-config attack"
4534 );
4535 assert_eq!(
4536 config.provider, None,
4537 "project-scope provider must be denied"
4538 );
4539 assert_eq!(
4540 config.mcp_config_path, None,
4541 "project-scope mcp_config_path must be denied"
4542 );
4543 }
4544
4545 #[test]
4546 fn project_overlay_overrides_approval_and_sandbox() {
4547 let tmp = workspace_with_project_config(
4548 r#"
4549 approval_policy = "never"
4550 sandbox_mode = "read-only"
4551 "#,
4552 );
4553 let mut config = Config::default();
4554 merge_project_config(&mut config, tmp.path());
4555 assert_eq!(config.approval_policy.as_deref(), Some("never"));
4556 assert_eq!(config.sandbox_mode.as_deref(), Some("read-only"));
4557 }
4558
4559 #[test]
4560 fn project_overlay_denies_approval_auto_and_sandbox_danger_values() {
4561 // #417 value-deny: the loosest values (`approval_policy = "auto"`,
4562 // `sandbox_mode = "danger-full-access"`) are pure escalation.
4563 // Even when the user hasn't set these fields, the project
4564 // can't push the session to the loosest posture.
4565 let tmp = workspace_with_project_config(
4566 r#"
4567 approval_policy = "auto"
4568 sandbox_mode = "danger-full-access"
4569 model = "deepseek-v4-pro"
4570 "#,
4571 );
4572 let mut config = Config::default();
4573 merge_project_config(&mut config, tmp.path());
4574 assert_eq!(
4575 config.approval_policy, None,
4576 "project-scope `approval_policy = \"auto\"` must be denied"
4577 );
4578 assert_eq!(
4579 config.sandbox_mode, None,
4580 "project-scope `sandbox_mode = \"danger-full-access\"` must be denied"
4581 );
4582 // Non-escalation overrides on the same merge succeed —
4583 // the deny is per-key, not per-file.
4584 assert_eq!(
4585 config.default_text_model.as_deref(),
4586 Some("deepseek-v4-pro"),
4587 "non-escalation overrides should still apply"
4588 );
4589 }
4590
4591 #[test]
4592 fn project_overlay_preserves_user_strict_value_when_project_tries_to_loosen() {
4593 // Belt-and-suspenders: if the user has `approval_policy = "never"`
4594 // and the project tries `approval_policy = "auto"`, the deny
4595 // keeps the user's strict value rather than falling through to
4596 // None.
4597 let tmp = workspace_with_project_config(
4598 r#"
4599 approval_policy = "auto"
4600 "#,
4601 );
4602 let mut config = Config {
4603 approval_policy: Some("never".to_string()),
4604 ..Config::default()
4605 };
4606 merge_project_config(&mut config, tmp.path());
4607 assert_eq!(
4608 config.approval_policy.as_deref(),
4609 Some("never"),
4610 "user's strict approval_policy must survive a project escalation attempt"
4611 );
4612 }
4613
4614 #[test]
4615 fn project_overlay_overrides_max_subagents_and_allow_shell() {
4616 let tmp = workspace_with_project_config(
4617 r#"
4618 max_subagents = 4
4619 allow_shell = false
4620 "#,
4621 );
4622 let mut config = Config::default();
4623 merge_project_config(&mut config, tmp.path());
4624 assert_eq!(config.max_subagents, Some(4));
4625 assert_eq!(config.allow_shell, Some(false));
4626 }
4627
4628 #[test]
4629 fn project_overlay_clamps_max_subagents_to_safe_range() {
4630 let tmp = workspace_with_project_config(
4631 r#"
4632 max_subagents = 500
4633 "#,
4634 );
4635 let mut config = Config::default();
4636 merge_project_config(&mut config, tmp.path());
4637 assert_eq!(
4638 config.max_subagents,
4639 Some(crate::config::MAX_SUBAGENTS),
4640 "should clamp to MAX_SUBAGENTS"
4641 );
4642 }
4643
4644 #[test]
4645 fn project_overlay_ignores_negative_max_subagents() {
4646 let tmp = workspace_with_project_config(
4647 r#"
4648 max_subagents = -3
4649 "#,
4650 );
4651 let mut config = Config::default();
4652 merge_project_config(&mut config, tmp.path());
4653 assert_eq!(config.max_subagents, None, "negative should be ignored");
4654 }
4655
4656 #[test]
4657 fn project_overlay_skips_missing_config_file() {
4658 let tmp = tempdir().expect("tempdir");
4659 let mut config = Config {
4660 provider: Some("deepseek".to_string()),
4661 ..Config::default()
4662 };
4663 merge_project_config(&mut config, tmp.path());
4664 // Untouched.
4665 assert_eq!(config.provider.as_deref(), Some("deepseek"));
4666 }
4667
4668 #[test]
4669 fn project_overlay_skips_malformed_toml() {
4670 let tmp = workspace_with_project_config("this is not valid TOML !!");
4671 let mut config = Config {
4672 provider: Some("deepseek".to_string()),
4673 ..Config::default()
4674 };
4675 merge_project_config(&mut config, tmp.path());
4676 // Untouched on parse error — better to fall back to global than crash.
4677 assert_eq!(config.provider.as_deref(), Some("deepseek"));
4678 }
4679
4680 #[test]
4681 fn project_overlay_ignores_empty_string_values() {
4682 let tmp = workspace_with_project_config(
4683 r#"
4684 provider = ""
4685 model = ""
4686 "#,
4687 );
4688 let mut config = Config {
4689 provider: Some("deepseek".to_string()),
4690 default_text_model: Some("deepseek-v4-pro".to_string()),
4691 ..Config::default()
4692 };
4693 merge_project_config(&mut config, tmp.path());
4694 // Empty strings are ignored — they're rarely a deliberate override.
4695 assert_eq!(config.provider.as_deref(), Some("deepseek"));
4696 assert_eq!(
4697 config.default_text_model.as_deref(),
4698 Some("deepseek-v4-pro")
4699 );
4700 }
4701
4702 #[test]
4703 fn project_overlay_replaces_user_instructions_array_wholesale() {
4704 let tmp = workspace_with_project_config(
4705 r#"
4706 instructions = ["./AGENTS.md", "./extra.md"]
4707 "#,
4708 );
4709 // User had a global file in their config; the project array
4710 // should REPLACE it, not merge.
4711 let mut config = Config {
4712 instructions: Some(vec!["~/global.md".to_string()]),
4713 ..Config::default()
4714 };
4715 merge_project_config(&mut config, tmp.path());
4716 assert_eq!(
4717 config.instructions.as_deref(),
4718 Some(&["./AGENTS.md".to_string(), "./extra.md".to_string()][..]),
4719 "project instructions array replaces user array wholesale"
4720 );
4721 }
4722
4723 #[test]
4724 fn project_overlay_empty_instructions_array_clears_user_list() {
4725 let tmp = workspace_with_project_config(
4726 r#"
4727 instructions = []
4728 "#,
4729 );
4730 let mut config = Config {
4731 instructions: Some(vec![
4732 "~/global.md".to_string(),
4733 "~/team-prefs.md".to_string(),
4734 ]),
4735 ..Config::default()
4736 };
4737 merge_project_config(&mut config, tmp.path());
4738 // Explicit empty array clears the user list — project says
4739 // "this repo doesn't want any of those globals".
4740 assert_eq!(
4741 config.instructions.as_deref(),
4742 Some(&[][..]),
4743 "explicit empty array clears the user instructions list"
4744 );
4745 }
4746
4747 #[test]
4748 fn project_overlay_preserves_user_instructions_when_field_absent() {
4749 let tmp = workspace_with_project_config(
4750 r#"
4751 provider = "deepseek"
4752 "#,
4753 );
4754 let user = vec!["~/global.md".to_string()];
4755 let mut config = Config {
4756 instructions: Some(user.clone()),
4757 ..Config::default()
4758 };
4759 merge_project_config(&mut config, tmp.path());
4760 // No `instructions` key in the project file → user list intact.
4761 assert_eq!(
4762 config.instructions.as_deref(),
4763 Some(user.as_slice()),
4764 "absent project field must not clobber the user list"
4765 );
4766 }
4767
4768 #[test]
4769 fn project_overlay_drops_empty_string_entries_in_instructions_array() {
4770 let tmp = workspace_with_project_config(
4771 r#"
4772 instructions = ["./AGENTS.md", "", " ", "./extra.md"]
4773 "#,
4774 );
4775 let mut config = Config::default();
4776 merge_project_config(&mut config, tmp.path());
4777 assert_eq!(
4778 config.instructions.as_deref(),
4779 Some(&["./AGENTS.md".to_string(), "./extra.md".to_string()][..]),
4780 "empty / whitespace-only entries are filtered"
4781 );
4782 }
4783 }
4784
4785 #[cfg(test)]
4786 mod doctor_mcp_tests {
4787 use super::*;
4788
4789 fn make_server(command: Option<&str>, args: &[&str], url: Option<&str>) -> McpServerConfig {
4790 McpServerConfig {
4791 command: command.map(String::from),
4792 args: args.iter().map(|s| s.to_string()).collect(),
4793 env: std::collections::HashMap::new(),
4794 url: url.map(String::from),
4795 connect_timeout: None,
4796 execute_timeout: None,
4797 read_timeout: None,
4798 disabled: false,
4799 enabled: true,
4800 required: false,
4801 enabled_tools: Vec::new(),
4802 disabled_tools: Vec::new(),
4803 }
4804 }
4805
4806 #[test]
4807 fn test_no_command_or_url_is_error() {
4808 let server = make_server(None, &[], None);
4809 assert!(matches!(
4810 doctor_check_mcp_server(&server),
4811 McpServerDoctorStatus::Error(_)
4812 ));
4813 }
4814
4815 #[test]
4816 fn test_url_server_is_ok() {
4817 let server = make_server(None, &[], Some("http://localhost:3000/mcp"));
4818 match doctor_check_mcp_server(&server) {
4819 McpServerDoctorStatus::Ok(detail) => assert!(detail.contains("HTTP/SSE")),
4820 other => panic!("Expected Ok, got {other:?}"),
4821 }
4822 }
4823
4824 #[test]
4825 fn test_command_server_is_ok() {
4826 let server = make_server(Some("node"), &["server.js"], None);
4827 match doctor_check_mcp_server(&server) {
4828 McpServerDoctorStatus::Ok(detail) => assert!(detail.contains("stdio")),
4829 other => panic!("Expected Ok, got {other:?}"),
4830 }
4831 }
4832
4833 #[test]
4834 fn test_self_hosted_absolute_is_ok() {
4835 let server = make_server(Some("/usr/local/bin/deepseek"), &["serve", "--mcp"], None);
4836 match doctor_check_mcp_server(&server) {
4837 McpServerDoctorStatus::Ok(detail) | McpServerDoctorStatus::Error(detail) => {
4838 // On systems where the path doesn't exist, this will be Error.
4839 // On systems where it does, it'll be Ok. Either is valid for the test.
4840 assert!(
4841 detail.contains("self-hosted") || detail.contains("not found"),
4842 "unexpected detail: {detail}"
4843 );
4844 }
4845 McpServerDoctorStatus::Warning(detail) => {
4846 panic!("Absolute path should not warn: {detail}")
4847 }
4848 }
4849 }
4850
4851 #[test]
4852 fn test_self_hosted_relative_is_warning() {
4853 let server = make_server(Some("deepseek"), &["serve", "--mcp"], None);
4854 match doctor_check_mcp_server(&server) {
4855 McpServerDoctorStatus::Warning(detail) => {
4856 assert!(detail.contains("relative"));
4857 }
4858 other => panic!("Expected Warning for relative path, got {other:?}"),
4859 }
4860 }
4861
4862 #[test]
4863 fn test_empty_command_is_error() {
4864 let server = make_server(Some(""), &[], None);
4865 assert!(matches!(
4866 doctor_check_mcp_server(&server),
4867 McpServerDoctorStatus::Error(_)
4868 ));
4869 }
4870 }
4871
4872 #[cfg(test)]
4873 mod setup_helper_tests {
4874 use super::*;
4875 use std::collections::BTreeSet;
4876 use tempfile::TempDir;
4877
4878 // Serialize tests that mutate process-global env vars. Without this,
4879 // `cargo test` runs them in parallel and they race on `DEEPSEEK_API_KEY`,
4880 // causing intermittent CI failures (one test reads while another's set
4881 // is still active). `unwrap_or_else` recovers from poisoning so a panic
4882 // in one test doesn't cascade through the whole module.
4883 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
4884
4885 #[test]
4886 fn init_tools_dir_creates_readme_and_example() {
4887 let tmp = TempDir::new().unwrap();
4888 let dir = tmp.path().join("tools");
4889 let (returned_dir, readme_status, example_status) =
4890 init_tools_dir(&dir, false).expect("init_tools_dir should succeed");
4891
4892 assert_eq!(returned_dir, dir);
4893 assert!(matches!(readme_status, WriteStatus::Created));
4894 assert!(matches!(example_status, WriteStatus::Created));
4895 assert!(dir.join("README.md").exists());
4896 assert!(dir.join("example.sh").exists());
4897
4898 let readme = std::fs::read_to_string(dir.join("README.md")).unwrap();
4899 assert!(
4900 readme.contains("# name:"),
4901 "README must show frontmatter convention"
4902 );
4903
4904 let example = std::fs::read_to_string(dir.join("example.sh")).unwrap();
4905 assert!(example.starts_with("#!/usr/bin/env sh"));
4906 assert!(example.contains("# name: example"));
4907 assert!(example.contains("# description:"));
4908 }
4909
4910 #[test]
4911 fn init_tools_dir_skips_existing_without_force() {
4912 let tmp = TempDir::new().unwrap();
4913 let dir = tmp.path().join("tools");
4914 let _ = init_tools_dir(&dir, false).unwrap();
4915 let (_, readme_status, example_status) = init_tools_dir(&dir, false).unwrap();
4916 assert!(matches!(readme_status, WriteStatus::SkippedExists));
4917 assert!(matches!(example_status, WriteStatus::SkippedExists));
4918 }
4919
4920 #[test]
4921 fn init_tools_dir_force_overwrites() {
4922 let tmp = TempDir::new().unwrap();
4923 let dir = tmp.path().join("tools");
4924 let _ = init_tools_dir(&dir, false).unwrap();
4925 std::fs::write(dir.join("example.sh"), "stale").unwrap();
4926 let (_, _, example_status) = init_tools_dir(&dir, true).unwrap();
4927 assert!(matches!(example_status, WriteStatus::Overwritten));
4928 let example = std::fs::read_to_string(dir.join("example.sh")).unwrap();
4929 assert_ne!(example, "stale");
4930 }
4931
4932 #[test]
4933 fn init_plugins_dir_creates_readme_and_example_layout() {
4934 let tmp = TempDir::new().unwrap();
4935 let dir = tmp.path().join("plugins");
4936 let (readme_path, example_path, readme_status, example_status) =
4937 init_plugins_dir(&dir, false).unwrap();
4938
4939 assert_eq!(readme_path, dir.join("README.md"));
4940 assert_eq!(example_path, dir.join("example").join("PLUGIN.md"));
4941 assert!(matches!(readme_status, WriteStatus::Created));
4942 assert!(matches!(example_status, WriteStatus::Created));
4943 assert!(readme_path.exists());
4944 assert!(example_path.exists());
4945
4946 let plugin_md = std::fs::read_to_string(&example_path).unwrap();
4947 assert!(plugin_md.contains("---"));
4948 assert!(plugin_md.contains("name: example"));
4949 }
4950
4951 #[test]
4952 fn collect_clean_targets_finds_only_known_files() {
4953 let tmp = TempDir::new().unwrap();
4954 let dir = tmp.path();
4955 std::fs::write(dir.join("latest.json"), "{}").unwrap();
4956 std::fs::write(dir.join("offline_queue.json"), "[]").unwrap();
4957 std::fs::write(dir.join("unrelated.json"), "{}").unwrap();
4958
4959 let plan = collect_clean_targets(dir);
4960 assert_eq!(plan.targets.len(), 2);
4961 assert!(plan.targets.iter().any(|p| p.ends_with("latest.json")));
4962 assert!(
4963 plan.targets
4964 .iter()
4965 .any(|p| p.ends_with("offline_queue.json"))
4966 );
4967 assert!(!plan.targets.iter().any(|p| p.ends_with("unrelated.json")));
4968 }
4969
4970 #[test]
4971 fn execute_clean_plan_removes_files_and_returns_them() {
4972 let tmp = TempDir::new().unwrap();
4973 let dir = tmp.path();
4974 let latest = dir.join("latest.json");
4975 let queue = dir.join("offline_queue.json");
4976 std::fs::write(&latest, "{}").unwrap();
4977 std::fs::write(&queue, "[]").unwrap();
4978
4979 let plan = collect_clean_targets(dir);
4980 let removed = execute_clean_plan(&plan).unwrap();
4981 assert_eq!(removed.len(), 2);
4982 assert!(!latest.exists());
4983 assert!(!queue.exists());
4984 }
4985
4986 #[test]
4987 fn run_setup_clean_dry_run_lists_targets_without_force() {
4988 let tmp = TempDir::new().unwrap();
4989 let dir = tmp.path();
4990 std::fs::write(dir.join("latest.json"), "{}").unwrap();
4991 run_setup_clean(dir, false).unwrap();
4992 // Without --force, files must remain on disk.
4993 assert!(dir.join("latest.json").exists());
4994 }
4995
4996 #[test]
4997 fn run_setup_clean_force_removes_files() {
4998 let tmp = TempDir::new().unwrap();
4999 let dir = tmp.path();
5000 std::fs::write(dir.join("latest.json"), "{}").unwrap();
5001 std::fs::write(dir.join("offline_queue.json"), "[]").unwrap();
5002 run_setup_clean(dir, true).unwrap();
5003 assert!(!dir.join("latest.json").exists());
5004 assert!(!dir.join("offline_queue.json").exists());
5005 }
5006
5007 #[test]
5008 fn run_setup_clean_handles_missing_dir() {
5009 let tmp = TempDir::new().unwrap();
5010 let dir = tmp.path().join("does-not-exist");
5011 // Should print and return Ok without error.
5012 run_setup_clean(&dir, true).unwrap();
5013 assert!(!dir.exists());
5014 }
5015
5016 #[test]
5017 fn dotenv_status_points_to_example_when_present() {
5018 let tmp = TempDir::new().unwrap();
5019 std::fs::write(tmp.path().join(".env.example"), "DEEPSEEK_API_KEY=\n").unwrap();
5020
5021 assert_eq!(
5022 dotenv_status_line(tmp.path()),
5023 ".env not present in workspace (run `cp .env.example .env` and edit)"
5024 );
5025
5026 std::fs::write(tmp.path().join(".env"), "DEEPSEEK_API_KEY=test\n").unwrap();
5027 assert!(dotenv_status_line(tmp.path()).contains(".env present at"));
5028 }
5029
5030 #[test]
5031 fn env_example_is_trackable_and_every_key_is_wired() {
5032 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
5033 let env_example = std::fs::read_to_string(root.join(".env.example")).unwrap();
5034 let gitignore = std::fs::read_to_string(root.join(".gitignore")).unwrap();
5035
5036 assert!(gitignore.contains("!.env.example"));
5037
5038 let keys = documented_env_keys(&env_example);
5039 for required in [
5040 "DEEPSEEK_API_KEY",
5041 "DEEPSEEK_BASE_URL",
5042 "DEEPSEEK_MODEL",
5043 "NVIDIA_API_KEY",
5044 "NIM_BASE_URL",
5045 "RUST_LOG",
5046 "DEEPSEEK_APPROVAL_POLICY",
5047 "DEEPSEEK_SANDBOX_MODE",
5048 ] {
5049 assert!(
5050 keys.contains(required),
5051 ".env.example is missing {required}"
5052 );
5053 }
5054
5055 let sources = [
5056 include_str!("config.rs"),
5057 include_str!("logging.rs"),
5058 include_str!("../../config/src/lib.rs"),
5059 include_str!("../../cli/src/main.rs"),
5060 ]
5061 .join("\n");
5062
5063 for key in keys {
5064 assert!(
5065 sources.contains(&key),
5066 ".env.example documents {key}, but no source file references it"
5067 );
5068 }
5069 }
5070
5071 fn documented_env_keys(content: &str) -> BTreeSet<String> {
5072 content
5073 .lines()
5074 .filter_map(|line| {
5075 let trimmed = line.trim();
5076 let uncommented = trimmed
5077 .strip_prefix('#')
5078 .map(str::trim_start)
5079 .unwrap_or(trimmed);
5080 let (key, _) = uncommented.split_once('=')?;
5081 let key = key.trim();
5082 let is_env_key = key
5083 .chars()
5084 .all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_')
5085 && key.chars().any(|ch| ch == '_');
5086 is_env_key.then(|| key.to_string())
5087 })
5088 .collect()
5089 }
5090
5091 #[test]
5092 fn resolve_api_key_source_reports_env_when_set() {
5093 let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
5094 let prev = std::env::var("DEEPSEEK_API_KEY").ok();
5095 let prev_source = std::env::var("DEEPSEEK_API_KEY_SOURCE").ok();
5096 unsafe {
5097 std::env::set_var("DEEPSEEK_API_KEY", "test-helper-value");
5098 std::env::remove_var("DEEPSEEK_API_KEY_SOURCE");
5099 }
5100 let cfg = Config::default();
5101 let source = resolve_api_key_source(&cfg);
5102 match prev {
5103 Some(value) => unsafe { std::env::set_var("DEEPSEEK_API_KEY", value) },
5104 None => unsafe { std::env::remove_var("DEEPSEEK_API_KEY") },
5105 }
5106 match prev_source {
5107 Some(value) => unsafe { std::env::set_var("DEEPSEEK_API_KEY_SOURCE", value) },
5108 None => unsafe { std::env::remove_var("DEEPSEEK_API_KEY_SOURCE") },
5109 }
5110 assert_eq!(source, ApiKeySource::Env);
5111 }
5112
5113 #[test]
5114 fn resolve_api_key_source_reports_dispatcher_keyring() {
5115 let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
5116 let prev = std::env::var("DEEPSEEK_API_KEY").ok();
5117 let prev_source = std::env::var("DEEPSEEK_API_KEY_SOURCE").ok();
5118 unsafe {
5119 std::env::set_var("DEEPSEEK_API_KEY", "test-helper-value");
5120 std::env::set_var("DEEPSEEK_API_KEY_SOURCE", "keyring");
5121 }
5122 let cfg = Config::default();
5123 let source = resolve_api_key_source(&cfg);
5124 match prev {
5125 Some(value) => unsafe { std::env::set_var("DEEPSEEK_API_KEY", value) },
5126 None => unsafe { std::env::remove_var("DEEPSEEK_API_KEY") },
5127 }
5128 match prev_source {
5129 Some(value) => unsafe { std::env::set_var("DEEPSEEK_API_KEY_SOURCE", value) },
5130 None => unsafe { std::env::remove_var("DEEPSEEK_API_KEY_SOURCE") },
5131 }
5132 assert_eq!(source, ApiKeySource::Keyring);
5133 }
5134
5135 #[test]
5136 fn resolve_api_key_source_prefers_config_over_env() {
5137 let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
5138 let prev = std::env::var("DEEPSEEK_API_KEY").ok();
5139 let prev_source = std::env::var("DEEPSEEK_API_KEY_SOURCE").ok();
5140 unsafe {
5141 std::env::set_var("DEEPSEEK_API_KEY", "stale-env-key");
5142 std::env::remove_var("DEEPSEEK_API_KEY_SOURCE");
5143 }
5144 let cfg = Config {
5145 api_key: Some("fresh-config-key".to_string()),
5146 ..Config::default()
5147 };
5148 let source = resolve_api_key_source(&cfg);
5149 match prev {
5150 Some(value) => unsafe { std::env::set_var("DEEPSEEK_API_KEY", value) },
5151 None => unsafe { std::env::remove_var("DEEPSEEK_API_KEY") },
5152 }
5153 match prev_source {
5154 Some(value) => unsafe { std::env::set_var("DEEPSEEK_API_KEY_SOURCE", value) },
5155 None => unsafe { std::env::remove_var("DEEPSEEK_API_KEY_SOURCE") },
5156 }
5157 assert_eq!(source, ApiKeySource::Config);
5158 }
5159
5160 #[test]
5161 fn skills_count_for_returns_zero_for_missing_dir() {
5162 let tmp = TempDir::new().unwrap();
5163 let dir = tmp.path().join("nope");
5164 assert_eq!(skills_count_for(&dir), 0);
5165 }
5166
5167 #[test]
5168 fn skills_count_for_counts_valid_skill_dirs() {
5169 let tmp = TempDir::new().unwrap();
5170 let dir = tmp.path().join("skills");
5171 let skill_dir = dir.join("getting-started");
5172 std::fs::create_dir_all(&skill_dir).unwrap();
5173 std::fs::write(
5174 skill_dir.join("SKILL.md"),
5175 "---\nname: getting-started\ndescription: hi\n---\nbody",
5176 )
5177 .unwrap();
5178 assert_eq!(skills_count_for(&dir), 1);
5179 }
5180 }
5181
5182 #[cfg(test)]
5183 mod pr_prompt_tests {
5184 use super::*;
5185
5186 fn sample_pr() -> GhPullRequest {
5187 GhPullRequest {
5188 title: "Add cool feature".to_string(),
5189 body: "Closes #99.\n\nAlso:\n- bullet a\n- bullet b".to_string(),
5190 base: "main".to_string(),
5191 head: "feat/cool".to_string(),
5192 url: "https://github.com/example/repo/pull/123".to_string(),
5193 }
5194 }
5195
5196 #[test]
5197 fn format_pr_prompt_includes_title_url_branches_body_and_diff() {
5198 let prompt = format_pr_prompt(123, &sample_pr(), "diff --git a/x b/x\n+y");
5199 assert!(prompt.contains("Review PR #123 — Add cool feature"));
5200 assert!(prompt.contains("URL: https://github.com/example/repo/pull/123"));
5201 assert!(prompt.contains("Branches: main ← feat/cool"));
5202 assert!(prompt.contains("Closes #99."));
5203 assert!(prompt.contains("- bullet a"));
5204 assert!(prompt.contains("```diff"));
5205 assert!(prompt.contains("diff --git a/x b/x"));
5206 }
5207
5208 #[test]
5209 fn format_pr_prompt_handles_empty_body_and_unknown_branches() {
5210 let pr = GhPullRequest {
5211 title: String::new(),
5212 body: " ".to_string(),
5213 base: String::new(),
5214 head: String::new(),
5215 url: String::new(),
5216 };
5217 let prompt = format_pr_prompt(7, &pr, "(diff body)");
5218 // Empty title falls back to a placeholder.
5219 assert!(prompt.contains("(PR #7)"));
5220 // Empty body renders the explicit placeholder.
5221 assert!(prompt.contains("(no description)"));
5222 assert!(prompt.contains("Branches: (unknown)"));
5223 assert!(prompt.contains("URL: (unavailable)"));
5224 }
5225
5226 #[test]
5227 fn format_pr_prompt_truncates_oversize_diff_at_a_codepoint_boundary() {
5228 // 300 KiB of `X` bytes with a multibyte char near the cap.
5229 let mut diff = "X".repeat(190 * 1024);
5230 diff.push_str(&"🚀".repeat(5_000));
5231 let prompt = format_pr_prompt(1, &sample_pr(), &diff);
5232 assert!(prompt.contains("[…diff truncated"));
5233 assert!(prompt.contains("at 200 KiB"));
5234 // Ensure we didn't slice mid-codepoint — the result still
5235 // round-trips as valid UTF-8 (it's a String, so this is by
5236 // construction; the test pins behaviour against silent panics
5237 // if the cut logic regresses).
5238 assert!(prompt.is_ascii() || prompt.contains('🚀'));
5239 }
5240
5241 #[test]
5242 fn is_command_available_detects_present_and_absent_binaries() {
5243 // `sh` is part of the POSIX baseline on every Unix runner and
5244 // ships with `git-bash` on Windows CI. It should be present.
5245 // (Skip on Windows CI without git-bash because the runner
5246 // could legitimately lack `sh.exe`.)
5247 #[cfg(unix)]
5248 assert!(is_command_available("sh"), "POSIX `sh` should be on PATH");
5249
5250 // A deliberately-implausible name to confirm the negative
5251 // branch — `--version` on this would exec(3) → ENOENT.
5252 assert!(
5253 !is_command_available("this-command-cannot-exist-deepseek-tui-test-ENOENT-marker"),
5254 "missing command should return false, not panic"
5255 );
5256 }
5257 }
5258
5258 lines RUST