diff --git a/README.md b/README.md index 14fb91ab..d43e0f0b 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,14 @@ Controls: `F1` help, `Esc` backs out of the current action, `Ctrl+K` command pal Key environment overrides: `DEEPSEEK_API_KEY`, `DEEPSEEK_BASE_URL`, `DEEPSEEK_MODEL`, `DEEPSEEK_PROFILE`. +Quick checks and scaffolding: + +- `deepseek-tui setup --status` — read-only, network-free status of API key, + MCP/skills/tools/plugins, sandbox, and `.env`. +- `deepseek-tui setup --tools --plugins` — scaffold `~/.deepseek/tools/` and + `~/.deepseek/plugins/` with self-describing example templates. +- `deepseek-tui doctor --json` — machine-readable doctor output for CI. + The client targets DeepSeek's documented OpenAI-compatible Chat Completions API (`/chat/completions`). DeepSeek context caching is automatic; when the API returns cache hit/miss token fields, the TUI includes them in usage and cost diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 3571ecb8..de2fd19f 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -370,7 +370,7 @@ const TOOL_RESULT_METADATA_SUMMARY_CHARS: usize = 320; const COMPACTION_SUMMARY_MARKER: &str = "Conversation Summary (Auto-Generated)"; const WORKING_SET_SUMMARY_MARKER: &str = "## Repo Working Set"; -const TOOL_CALL_START_MARKERS: [&str; 5] = [ +pub(crate) const TOOL_CALL_START_MARKERS: [&str; 5] = [ "[TOOL_CALL]", "", "", @@ -394,6 +394,19 @@ const TOOL_CALL_END_MARKERS: [&str; 5] = [ "", ]; +/// Compact one-shot notice emitted when a model attempts to forge a tool-call +/// wrapper in plain text instead of using the API tool channel. The visible +/// content is still scrubbed; this exists so the user can see why their text +/// shrank. +pub(crate) const FAKE_WRAPPER_NOTICE: &str = + "Stripped non-API tool-call wrapper from model output (use the API tool channel)"; + +/// True if `text` contains any of the known fake-wrapper start markers. Used by +/// the streaming loop to decide whether to emit `FAKE_WRAPPER_NOTICE`. +pub(crate) fn contains_fake_tool_wrapper(text: &str) -> bool { + TOOL_CALL_START_MARKERS.iter().any(|m| text.contains(m)) +} + fn find_first_marker(text: &str, markers: &[&str]) -> Option<(usize, usize)> { markers .iter() @@ -401,7 +414,7 @@ fn find_first_marker(text: &str, markers: &[&str]) -> Option<(usize, usize)> { .min_by_key(|(idx, _)| *idx) } -fn filter_tool_call_delta(delta: &str, in_tool_call: &mut bool) -> String { +pub(crate) fn filter_tool_call_delta(delta: &str, in_tool_call: &mut bool) -> String { if delta.is_empty() { return String::new(); } @@ -2617,6 +2630,7 @@ impl Engine { let mut current_block_kind: Option = None; let mut current_tool_index: Option = None; let mut in_tool_call_block = false; + let mut fake_wrapper_notice_emitted = false; let mut pending_message_complete = false; let mut last_text_index: Option = None; let mut stream_errors = 0u32; @@ -2720,6 +2734,14 @@ impl Engine { in_tool_call_block = false; let filtered = filter_tool_call_delta(¤t_text_raw, &mut in_tool_call_block); + if !fake_wrapper_notice_emitted + && filtered.len() < current_text_raw.len() + && contains_fake_tool_wrapper(¤t_text_raw) + { + let _ = + self.tx_event.send(Event::status(FAKE_WRAPPER_NOTICE)).await; + fake_wrapper_notice_emitted = true; + } current_text_visible.push_str(&filtered); current_block_kind = Some(ContentBlockKind::Text); last_text_index = Some(index as usize); @@ -2797,6 +2819,14 @@ impl Engine { stream_content_bytes = stream_content_bytes.saturating_add(text.len()); current_text_raw.push_str(&text); let filtered = filter_tool_call_delta(&text, &mut in_tool_call_block); + if !fake_wrapper_notice_emitted + && filtered.len() < text.len() + && contains_fake_tool_wrapper(&text) + { + let _ = + self.tx_event.send(Event::status(FAKE_WRAPPER_NOTICE)).await; + fake_wrapper_notice_emitted = true; + } if !filtered.is_empty() { current_text_visible.push_str(&filtered); let _ = self diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index 099a0844..b16e7ac0 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -746,3 +746,140 @@ fn missing_tool_error_message_includes_discovery_guidance_when_no_match() { assert!(message.contains("not available in the current tool catalog")); assert!(message.contains(TOOL_SEARCH_BM25_NAME)); } + +#[test] +fn filter_tool_call_delta_strips_bracket_marker() { + let mut in_block = false; + let visible = filter_tool_call_delta( + "intro [TOOL_CALL]\n{\"tool\":\"x\"}\n[/TOOL_CALL] outro", + &mut in_block, + ); + assert!(!in_block); + assert!(!visible.contains("[TOOL_CALL]")); + assert!(!visible.contains("[/TOOL_CALL]")); + assert!(!visible.contains("\"tool\":\"x\"")); + assert!(visible.contains("intro")); + assert!(visible.contains("outro")); +} + +#[test] +fn filter_tool_call_delta_strips_deepseek_xml_marker() { + let mut in_block = false; + let visible = filter_tool_call_delta( + "before payload after", + &mut in_block, + ); + assert!(!in_block); + for marker in TOOL_CALL_START_MARKERS { + assert!( + !visible.contains(marker), + "visible text leaked start marker `{marker}`: {visible:?}" + ); + } + assert!(visible.contains("before")); + assert!(visible.contains("after")); +} + +#[test] +fn filter_tool_call_delta_strips_generic_tool_call_marker() { + let mut in_block = false; + let visible = filter_tool_call_delta( + "lead \n{\"name\":\"do\"}\n tail", + &mut in_block, + ); + assert!(!in_block); + assert!(!visible.contains("")); + assert!(visible.contains("lead")); + assert!(visible.contains("tail")); +} + +#[test] +fn filter_tool_call_delta_strips_invoke_marker() { + let mut in_block = false; + let visible = filter_tool_call_delta( + "alpha v beta", + &mut in_block, + ); + assert!(!in_block); + assert!(!visible.contains("")); + assert!(visible.contains("alpha")); + assert!(visible.contains("beta")); +} + +#[test] +fn filter_tool_call_delta_strips_function_calls_marker() { + let mut in_block = false; + let visible = filter_tool_call_delta( + "head \n{\"name\":\"x\"}\n tail", + &mut in_block, + ); + assert!(!in_block); + assert!(!visible.contains("")); + assert!(!visible.contains("")); + assert!(visible.contains("head")); + assert!(visible.contains("tail")); +} + +#[test] +fn filter_tool_call_delta_handles_chunk_split_marker() { + let mut in_block = false; + // First chunk opens the wrapper but does not close it. + let visible_a = filter_tool_call_delta("hello partial", &mut in_block); + assert!(in_block, "filter must remember it is mid-wrapper"); + assert_eq!(visible_a, "hello "); + + // Second chunk continues inside the wrapper, then closes it and adds tail. + let visible_b = filter_tool_call_delta("payload tail", &mut in_block); + assert!(!in_block); + assert_eq!(visible_b, " tail"); +} + +#[test] +fn filter_tool_call_delta_unmatched_open_suppresses_remainder() { + let mut in_block = false; + let visible = filter_tool_call_delta("ok [TOOL_CALL]rest of stream", &mut in_block); + assert_eq!(visible, "ok "); + assert!( + in_block, + "unmatched open must leave filter in tool-call mode" + ); +} + +#[test] +fn filter_tool_call_delta_passes_through_clean_text() { + let mut in_block = false; + let input = "no markers here, just prose with code ``."; + let visible = filter_tool_call_delta(input, &mut in_block); + assert!(!in_block); + assert_eq!(visible, input); +} + +#[test] +fn contains_fake_tool_wrapper_detects_each_marker() { + for marker in TOOL_CALL_START_MARKERS { + let needle = format!("noise {marker} more noise"); + assert!( + contains_fake_tool_wrapper(&needle), + "marker `{marker}` should be detected" + ); + } +} + +#[test] +fn contains_fake_tool_wrapper_returns_false_on_clean_text() { + assert!(!contains_fake_tool_wrapper( + "plain assistant text without wrappers" + )); + assert!(!contains_fake_tool_wrapper( + "` Result<()> { // Handle subcommands first if let Some(command) = cli.command.clone() { return match command { - Commands::Doctor => { + Commands::Doctor(args) => { let config = load_config_from_cli(&cli)?; let workspace = resolve_workspace(&cli); - run_doctor(&config, &workspace, cli.config.as_deref()).await; - Ok(()) + if args.json { + run_doctor_json(&config, &workspace, cli.config.as_deref()) + } else { + run_doctor(&config, &workspace, cli.config.as_deref()).await; + Ok(()) + } } Commands::Setup(args) => { let config = load_config_from_cli(&cli)?; @@ -793,19 +816,151 @@ fn init_skills_dir(skills_dir: &Path, force: bool) -> Result<(PathBuf, WriteStat Ok((skill_path, status)) } +fn tools_readme_template() -> &'static str { + "# Local tools\n\n\ + Drop self-describing scripts here so they can be discovered by\n\ + `deepseek-tui setup --status` and surfaced in `deepseek-tui doctor`.\n\n\ + Each script should start with a frontmatter-style header so the\n\ + description is visible without executing the file:\n\n\ + ```\n\ + # name: my-tool\n\ + # description: One-line summary of what this tool does\n\ + # usage: my-tool [args...]\n\ + ```\n\n\ + The directory is intentionally not auto-loaded into the agent's tool\n\ + catalog. Wire individual tools through MCP, hooks, or skills when you\n\ + want them available inside a session.\n" +} + +fn tools_example_script() -> &'static str { + "#!/usr/bin/env sh\n\ + # name: example\n\ + # description: Print a confirmation that local tool discovery works\n\ + # usage: example [name]\n\ + printf 'deepseek-tui local tool ok: %s\\n' \"${1:-world}\"\n" +} + +fn init_tools_dir(tools_dir: &Path, force: bool) -> Result<(PathBuf, WriteStatus, WriteStatus)> { + std::fs::create_dir_all(tools_dir) + .with_context(|| format!("Failed to create tools dir {}", tools_dir.display()))?; + + let readme_path = tools_dir.join("README.md"); + let readme_status = write_template_file(&readme_path, tools_readme_template(), force)?; + + let example_path = tools_dir.join("example.sh"); + let example_status = write_template_file(&example_path, tools_example_script(), force)?; + + Ok((tools_dir.to_path_buf(), readme_status, example_status)) +} + +fn plugins_readme_template() -> &'static str { + "# Local plugins\n\n\ + Plugins are richer than tools: each one lives in its own subdirectory\n\ + with a `PLUGIN.md` describing what it does and how to enable it. The\n\ + directory is created so users have a documented place to drop\n\ + experiments without touching `~/.deepseek/skills/`.\n\n\ + A plugin layout looks like:\n\n\ + ```\n\ + plugins/\n\ + my-plugin/\n\ + PLUGIN.md # frontmatter + body, same shape as SKILL.md\n\ + scripts/ # optional helpers invoked by the plugin\n\ + ```\n\n\ + Plugins are not loaded automatically. Wire them up through skills,\n\ + hooks, or MCP servers when you want them active in a session.\n" +} + +fn plugin_example_template() -> &'static str { + "---\n\ + name: example\n\ + description: Placeholder plugin so /skills and doctor have something to show\n\ + status: example\n\ + ---\n\n\ + This is a starter plugin layout. Edit or replace it once you have a\n\ + real plugin. The agent does not load this file directly; reference it\n\ + from a skill or MCP wrapper if you want it active in a session.\n" +} + +fn init_plugins_dir( + plugins_dir: &Path, + force: bool, +) -> Result<(PathBuf, PathBuf, WriteStatus, WriteStatus)> { + std::fs::create_dir_all(plugins_dir) + .with_context(|| format!("Failed to create plugins dir {}", plugins_dir.display()))?; + + let readme_path = plugins_dir.join("README.md"); + let readme_status = write_template_file(&readme_path, plugins_readme_template(), force)?; + + let example_path = plugins_dir.join("example").join("PLUGIN.md"); + ensure_parent_dir(&example_path)?; + let example_status = write_template_file(&example_path, plugin_example_template(), force)?; + + Ok((readme_path, example_path, readme_status, example_status)) +} + +fn deepseek_home_dir() -> PathBuf { + dirs::home_dir().map_or_else(|| PathBuf::from(".deepseek"), |h| h.join(".deepseek")) +} + +/// Resolve the default tools directory. Mirrors `default_skills_dir` shape. +fn default_tools_dir() -> PathBuf { + deepseek_home_dir().join("tools") +} + +/// Resolve the default plugins directory. +fn default_plugins_dir() -> PathBuf { + deepseek_home_dir().join("plugins") +} + +/// Default location for crash/offline-queue checkpoints managed by the TUI. +fn default_checkpoints_dir() -> PathBuf { + deepseek_home_dir().join("sessions").join("checkpoints") +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct CleanPlan { + targets: Vec, +} + +fn collect_clean_targets(checkpoints_dir: &Path) -> CleanPlan { + let candidates = ["latest.json", "offline_queue.json"]; + let targets = candidates + .iter() + .map(|name| checkpoints_dir.join(name)) + .filter(|p| p.exists()) + .collect(); + CleanPlan { targets } +} + +fn execute_clean_plan(plan: &CleanPlan) -> Result> { + let mut removed = Vec::with_capacity(plan.targets.len()); + for path in &plan.targets { + std::fs::remove_file(path) + .with_context(|| format!("Failed to remove {}", path.display()))?; + removed.push(path.clone()); + } + Ok(removed) +} + fn run_setup(config: &Config, workspace: &Path, args: SetupArgs) -> Result<()> { + if args.status { + return run_setup_status(config, workspace); + } + if args.clean { + return run_setup_clean(&default_checkpoints_dir(), args.force); + } + use crate::palette; use colored::Colorize; let (aqua_r, aqua_g, aqua_b) = palette::DEEPSEEK_SKY_RGB; let (sky_r, sky_g, sky_b) = palette::DEEPSEEK_SKY_RGB; - let mut run_mcp = args.mcp || args.all; - let mut run_skills = args.skills || args.all; - if !run_mcp && !run_skills { - run_mcp = true; - run_skills = true; - } + let any_explicit = args.mcp || args.skills || args.tools || args.plugins; + let run_mcp = args.mcp || args.all || !any_explicit; + let run_skills = args.skills || args.all || !any_explicit; + let run_tools = args.tools || args.all; + let run_plugins = args.plugins || args.all; println!( "{}", @@ -863,6 +1018,25 @@ fn run_setup(config: &Config, workspace: &Path, args: SetupArgs) -> Result<()> { println!(" Next: run the TUI and use `/skills` then `/skill getting-started`."); } + if run_tools { + let tools_dir = default_tools_dir(); + let (dir, readme_status, example_status) = init_tools_dir(&tools_dir, args.force)?; + report_write_status("Tools README", &dir.join("README.md"), readme_status); + report_write_status("Example tool", &dir.join("example.sh"), example_status); + println!(" Tools dir: {}", dir.display()); + println!(" Next: drop scripts here; surface them via skills/MCP when ready."); + } + + if run_plugins { + let plugins_dir = default_plugins_dir(); + let (readme_path, example_path, readme_status, example_status) = + init_plugins_dir(&plugins_dir, args.force)?; + report_write_status("Plugins README", &readme_path, readme_status); + report_write_status("Example plugin", &example_path, example_status); + println!(" Plugins dir: {}", plugins_dir.display()); + println!(" Next: copy the example dir, edit PLUGIN.md, wire via skill/MCP."); + } + let sandbox = crate::sandbox::get_platform_sandbox(); if let Some(kind) = sandbox { println!(" ✓ Sandbox available: {kind}"); @@ -873,6 +1047,210 @@ fn run_setup(config: &Config, workspace: &Path, args: SetupArgs) -> Result<()> { Ok(()) } +fn report_write_status(label: &str, path: &Path, status: WriteStatus) { + match status { + WriteStatus::Created => { + println!(" ✓ Created {label} at {}", path.display()); + } + WriteStatus::Overwritten => { + println!(" ✓ Overwrote {label} at {}", path.display()); + } + WriteStatus::SkippedExists => { + println!(" · {label} already exists at {}", path.display()); + } + } +} + +/// Source of the resolved DeepSeek API key, used in status reports. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ApiKeySource { + Env, + Config, + Missing, +} + +fn resolve_api_key_source(config: &Config) -> ApiKeySource { + if std::env::var("DEEPSEEK_API_KEY") + .ok() + .filter(|k| !k.trim().is_empty()) + .is_some() + { + ApiKeySource::Env + } else if config.deepseek_api_key().is_ok() { + ApiKeySource::Config + } else { + ApiKeySource::Missing + } +} + +fn count_dir_entries(dir: &Path) -> usize { + std::fs::read_dir(dir) + .map(|entries| entries.filter_map(std::result::Result::ok).count()) + .unwrap_or(0) +} + +fn skills_count_for(dir: &Path) -> usize { + if !dir.exists() { + return 0; + } + crate::skills::SkillRegistry::discover(dir).len() +} + +fn run_setup_status(config: &Config, workspace: &Path) -> Result<()> { + use crate::palette; + use colored::Colorize; + + let (aqua_r, aqua_g, aqua_b) = palette::DEEPSEEK_SKY_RGB; + let (sky_r, sky_g, sky_b) = palette::DEEPSEEK_SKY_RGB; + let (red_r, red_g, red_b) = palette::DEEPSEEK_RED_RGB; + + println!( + "{}", + "DeepSeek Status".truecolor(aqua_r, aqua_g, aqua_b).bold() + ); + println!("{}", "===============".truecolor(sky_r, sky_g, sky_b)); + println!("workspace: {}", workspace.display()); + + match resolve_api_key_source(config) { + ApiKeySource::Env => println!( + " {} api_key: set via DEEPSEEK_API_KEY", + "✓".truecolor(aqua_r, aqua_g, aqua_b) + ), + ApiKeySource::Config => println!( + " {} api_key: set via config", + "✓".truecolor(aqua_r, aqua_g, aqua_b) + ), + ApiKeySource::Missing => println!( + " {} api_key: missing (set DEEPSEEK_API_KEY or run `deepseek login`)", + "✗".truecolor(red_r, red_g, red_b) + ), + } + println!( + " · base_url: {}", + config + .base_url + .as_deref() + .unwrap_or("https://api.deepseek.com") + ); + let model = config + .default_text_model + .clone() + .unwrap_or_else(|| DEFAULT_TEXT_MODEL.to_string()); + println!(" · default_text_model: {model}"); + + let mcp_path = config.mcp_config_path(); + let mcp_count = match load_mcp_config(&mcp_path) { + Ok(cfg) => cfg.servers.len(), + Err(_) => 0, + }; + let mcp_present = if mcp_path.exists() { "" } else { " (missing)" }; + println!( + " · mcp servers: {mcp_count} at {}{mcp_present}", + mcp_path.display() + ); + + let skills_dir = config.skills_dir(); + println!( + " · skills: {} at {}", + skills_count_for(&skills_dir), + skills_dir.display() + ); + + let tools_dir = default_tools_dir(); + let tools_present = if tools_dir.exists() { + "" + } else { + " (missing — run `setup --tools`)" + }; + println!( + " · tools: {} entries at {}{tools_present}", + if tools_dir.exists() { + count_dir_entries(&tools_dir) + } else { + 0 + }, + tools_dir.display() + ); + + let plugins_dir = default_plugins_dir(); + let plugins_present = if plugins_dir.exists() { + "" + } else { + " (missing — run `setup --plugins`)" + }; + println!( + " · plugins: {} entries at {}{plugins_present}", + if plugins_dir.exists() { + count_dir_entries(&plugins_dir) + } else { + 0 + }, + plugins_dir.display() + ); + + let sandbox = crate::sandbox::get_platform_sandbox(); + match sandbox { + Some(kind) => println!( + " {} sandbox: {kind}", + "✓".truecolor(aqua_r, aqua_g, aqua_b) + ), + None => println!( + " {} sandbox: unavailable (commands run best-effort)", + "!".truecolor(sky_r, sky_g, sky_b) + ), + } + + let dotenv = workspace.join(".env"); + if dotenv.exists() { + println!(" {} .env present at {}", "·".dimmed(), dotenv.display()); + } else { + println!(" {} .env not present in workspace", "·".dimmed()); + } + + println!(); + println!("Run `deepseek-tui doctor --json` for a machine-readable check."); + Ok(()) +} + +fn run_setup_clean(checkpoints_dir: &Path, force: bool) -> Result<()> { + use colored::Colorize; + + if !checkpoints_dir.exists() { + println!( + "Nothing to clean — checkpoints dir does not exist: {}", + checkpoints_dir.display() + ); + return Ok(()); + } + + let plan = collect_clean_targets(checkpoints_dir); + if plan.targets.is_empty() { + println!( + "Nothing to clean — no checkpoint files in {}", + checkpoints_dir.display() + ); + return Ok(()); + } + + if !force { + println!( + "Would remove {} checkpoint file(s) (use --force to apply):", + plan.targets.len() + ); + for path in &plan.targets { + println!(" · {}", path.display()); + } + return Ok(()); + } + + let removed = execute_clean_plan(&plan)?; + println!("{}", "Cleaned checkpoints:".bold()); + for path in &removed { + println!(" ✓ {}", path.display()); + } + Ok(()) +} + /// Run system diagnostics async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Option<&Path>) { use crate::palette; @@ -1152,6 +1530,48 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt println!(" Run `deepseek setup --skills` (or add --local for ./skills)."); } + // Tools directory + println!(); + println!("{}", "Tools:".bold()); + let tools_dir = default_tools_dir(); + if tools_dir.exists() { + let count = count_dir_entries(&tools_dir); + println!( + " {} tools dir found at {} ({} items)", + "✓".truecolor(aqua_r, aqua_g, aqua_b), + tools_dir.display(), + count + ); + } else { + println!( + " {} tools dir not found at {}", + "·".dimmed(), + tools_dir.display() + ); + println!(" Run `deepseek-tui setup --tools` to scaffold a starter dir."); + } + + // Plugins directory + println!(); + println!("{}", "Plugins:".bold()); + let plugins_dir = default_plugins_dir(); + if plugins_dir.exists() { + let count = count_dir_entries(&plugins_dir); + println!( + " {} plugins dir found at {} ({} items)", + "✓".truecolor(aqua_r, aqua_g, aqua_b), + plugins_dir.display(), + count + ); + } else { + println!( + " {} plugins dir not found at {}", + "·".dimmed(), + plugins_dir.display() + ); + println!(" Run `deepseek-tui setup --plugins` to scaffold a starter dir."); + } + // Platform and sandbox checks println!(); println!("{}", "Platform:".bold()); @@ -1181,6 +1601,145 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt ); } +/// Machine-readable counterpart to `run_doctor`. Skips the live API call so it +/// is safe to run in CI and from non-interactive scripts. +fn run_doctor_json( + config: &Config, + workspace: &Path, + config_path_override: Option<&Path>, +) -> Result<()> { + use serde_json::json; + + let default_config_dir = + dirs::home_dir().map_or_else(|| PathBuf::from(".deepseek"), |h| h.join(".deepseek")); + let config_path = config_path_override + .map(PathBuf::from) + .or_else(|| { + std::env::var("DEEPSEEK_CONFIG_PATH") + .ok() + .map(PathBuf::from) + }) + .unwrap_or_else(|| default_config_dir.join("config.toml")); + + let api_key_state = match resolve_api_key_source(config) { + ApiKeySource::Env => "env", + ApiKeySource::Config => "config", + ApiKeySource::Missing => "missing", + }; + + let mcp_config_path = config.mcp_config_path(); + let mcp_present = mcp_config_path.exists(); + let mcp_summary = match load_mcp_config(&mcp_config_path) { + Ok(cfg) => { + let servers: Vec = cfg + .servers + .iter() + .map(|(name, server)| { + let status = doctor_check_mcp_server(server); + let (kind, detail) = match &status { + McpServerDoctorStatus::Ok(d) => ("ok", d.clone()), + McpServerDoctorStatus::Warning(d) => ("warning", d.clone()), + McpServerDoctorStatus::Error(d) => ("error", d.clone()), + }; + json!({ + "name": name, + "enabled": server.enabled && !server.disabled, + "status": kind, + "detail": detail, + }) + }) + .collect(); + json!({ + "config_path": mcp_config_path.display().to_string(), + "present": mcp_present, + "servers": servers, + }) + } + Err(err) => json!({ + "config_path": mcp_config_path.display().to_string(), + "present": mcp_present, + "servers": [], + "error": err.to_string(), + }), + }; + + let global_skills_dir = config.skills_dir(); + let agents_skills_dir = workspace.join(".agents").join("skills"); + let local_skills_dir = workspace.join("skills"); + let selected_skills_dir = if agents_skills_dir.exists() { + agents_skills_dir.clone() + } else if local_skills_dir.exists() { + local_skills_dir.clone() + } else { + global_skills_dir.clone() + }; + + let tools_dir = default_tools_dir(); + let plugins_dir = default_plugins_dir(); + + let report = json!({ + "version": env!("CARGO_PKG_VERSION"), + "config_path": config_path.display().to_string(), + "config_present": config_path.exists(), + "workspace": workspace.display().to_string(), + "api_key": { + "source": api_key_state, + }, + "base_url": config + .base_url + .clone() + .unwrap_or_else(|| "https://api.deepseek.com".to_string()), + "default_text_model": config + .default_text_model + .clone() + .unwrap_or_else(|| DEFAULT_TEXT_MODEL.to_string()), + "mcp": mcp_summary, + "skills": { + "selected": selected_skills_dir.display().to_string(), + "global": { + "path": global_skills_dir.display().to_string(), + "present": global_skills_dir.exists(), + "count": skills_count_for(&global_skills_dir), + }, + "agents": { + "path": agents_skills_dir.display().to_string(), + "present": agents_skills_dir.exists(), + "count": skills_count_for(&agents_skills_dir), + }, + "local": { + "path": local_skills_dir.display().to_string(), + "present": local_skills_dir.exists(), + "count": skills_count_for(&local_skills_dir), + }, + }, + "tools": { + "path": tools_dir.display().to_string(), + "present": tools_dir.exists(), + "count": if tools_dir.exists() { count_dir_entries(&tools_dir) } else { 0 }, + }, + "plugins": { + "path": plugins_dir.display().to_string(), + "present": plugins_dir.exists(), + "count": if plugins_dir.exists() { count_dir_entries(&plugins_dir) } else { 0 }, + }, + "sandbox": match crate::sandbox::get_platform_sandbox() { + Some(kind) => json!({"available": true, "kind": kind.to_string()}), + None => json!({"available": false, "kind": null}), + }, + "platform": { + "os": std::env::consts::OS, + "arch": std::env::consts::ARCH, + }, + "api_connectivity": { + "checked": false, + "note": "Skipped in --json mode; run `deepseek-tui doctor` for a live check.", + }, + }); + + println!("{}", serde_json::to_string_pretty(&report)?); + Ok(()) +} + fn run_execpolicy_command(command: ExecpolicyCommand) -> Result<()> { match command.command { ExecpolicySubcommand::Check(cmd) => cmd.run(), @@ -2613,3 +3172,180 @@ mod doctor_mcp_tests { )); } } + +#[cfg(test)] +mod setup_helper_tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn init_tools_dir_creates_readme_and_example() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().join("tools"); + let (returned_dir, readme_status, example_status) = + init_tools_dir(&dir, false).expect("init_tools_dir should succeed"); + + assert_eq!(returned_dir, dir); + assert!(matches!(readme_status, WriteStatus::Created)); + assert!(matches!(example_status, WriteStatus::Created)); + assert!(dir.join("README.md").exists()); + assert!(dir.join("example.sh").exists()); + + let readme = std::fs::read_to_string(dir.join("README.md")).unwrap(); + assert!( + readme.contains("# name:"), + "README must show frontmatter convention" + ); + + let example = std::fs::read_to_string(dir.join("example.sh")).unwrap(); + assert!(example.starts_with("#!/usr/bin/env sh")); + assert!(example.contains("# name: example")); + assert!(example.contains("# description:")); + } + + #[test] + fn init_tools_dir_skips_existing_without_force() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().join("tools"); + let _ = init_tools_dir(&dir, false).unwrap(); + let (_, readme_status, example_status) = init_tools_dir(&dir, false).unwrap(); + assert!(matches!(readme_status, WriteStatus::SkippedExists)); + assert!(matches!(example_status, WriteStatus::SkippedExists)); + } + + #[test] + fn init_tools_dir_force_overwrites() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().join("tools"); + let _ = init_tools_dir(&dir, false).unwrap(); + std::fs::write(dir.join("example.sh"), "stale").unwrap(); + let (_, _, example_status) = init_tools_dir(&dir, true).unwrap(); + assert!(matches!(example_status, WriteStatus::Overwritten)); + let example = std::fs::read_to_string(dir.join("example.sh")).unwrap(); + assert_ne!(example, "stale"); + } + + #[test] + fn init_plugins_dir_creates_readme_and_example_layout() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().join("plugins"); + let (readme_path, example_path, readme_status, example_status) = + init_plugins_dir(&dir, false).unwrap(); + + assert_eq!(readme_path, dir.join("README.md")); + assert_eq!(example_path, dir.join("example").join("PLUGIN.md")); + assert!(matches!(readme_status, WriteStatus::Created)); + assert!(matches!(example_status, WriteStatus::Created)); + assert!(readme_path.exists()); + assert!(example_path.exists()); + + let plugin_md = std::fs::read_to_string(&example_path).unwrap(); + assert!(plugin_md.contains("---")); + assert!(plugin_md.contains("name: example")); + } + + #[test] + fn collect_clean_targets_finds_only_known_files() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path(); + std::fs::write(dir.join("latest.json"), "{}").unwrap(); + std::fs::write(dir.join("offline_queue.json"), "[]").unwrap(); + std::fs::write(dir.join("unrelated.json"), "{}").unwrap(); + + let plan = collect_clean_targets(dir); + assert_eq!(plan.targets.len(), 2); + assert!(plan.targets.iter().any(|p| p.ends_with("latest.json"))); + assert!( + plan.targets + .iter() + .any(|p| p.ends_with("offline_queue.json")) + ); + assert!(!plan.targets.iter().any(|p| p.ends_with("unrelated.json"))); + } + + #[test] + fn execute_clean_plan_removes_files_and_returns_them() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path(); + let latest = dir.join("latest.json"); + let queue = dir.join("offline_queue.json"); + std::fs::write(&latest, "{}").unwrap(); + std::fs::write(&queue, "[]").unwrap(); + + let plan = collect_clean_targets(dir); + let removed = execute_clean_plan(&plan).unwrap(); + assert_eq!(removed.len(), 2); + assert!(!latest.exists()); + assert!(!queue.exists()); + } + + #[test] + fn run_setup_clean_dry_run_lists_targets_without_force() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path(); + std::fs::write(dir.join("latest.json"), "{}").unwrap(); + run_setup_clean(dir, false).unwrap(); + // Without --force, files must remain on disk. + assert!(dir.join("latest.json").exists()); + } + + #[test] + fn run_setup_clean_force_removes_files() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path(); + std::fs::write(dir.join("latest.json"), "{}").unwrap(); + std::fs::write(dir.join("offline_queue.json"), "[]").unwrap(); + run_setup_clean(dir, true).unwrap(); + assert!(!dir.join("latest.json").exists()); + assert!(!dir.join("offline_queue.json").exists()); + } + + #[test] + fn run_setup_clean_handles_missing_dir() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().join("does-not-exist"); + // Should print and return Ok without error. + run_setup_clean(&dir, true).unwrap(); + assert!(!dir.exists()); + } + + #[test] + fn resolve_api_key_source_reports_env_when_set() { + // Snapshot env so we can restore it. + let prev = std::env::var("DEEPSEEK_API_KEY").ok(); + // SAFETY: tests in this binary may run in parallel; use a marker that + // is unmistakably a test value so concurrent reads can detect it. + // To avoid clobbering CI keys we save/restore around the assertion. + unsafe { + std::env::set_var("DEEPSEEK_API_KEY", "test-helper-value"); + } + let cfg = Config::default(); + let source = resolve_api_key_source(&cfg); + match prev { + Some(value) => unsafe { std::env::set_var("DEEPSEEK_API_KEY", value) }, + None => unsafe { std::env::remove_var("DEEPSEEK_API_KEY") }, + } + assert_eq!(source, ApiKeySource::Env); + } + + #[test] + fn skills_count_for_returns_zero_for_missing_dir() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().join("nope"); + assert_eq!(skills_count_for(&dir), 0); + } + + #[test] + fn skills_count_for_counts_valid_skill_dirs() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().join("skills"); + let skill_dir = dir.join("getting-started"); + std::fs::create_dir_all(&skill_dir).unwrap(); + std::fs::write( + skill_dir.join("SKILL.md"), + "---\nname: getting-started\ndescription: hi\n---\nbody", + ) + .unwrap(); + assert_eq!(skills_count_for(&dir), 1); + } +} diff --git a/crates/tui/tests/protocol_recovery.rs b/crates/tui/tests/protocol_recovery.rs new file mode 100644 index 00000000..3de0080e --- /dev/null +++ b/crates/tui/tests/protocol_recovery.rs @@ -0,0 +1,152 @@ +//! Protocol-recovery contract tests. +//! +//! These tests exist to keep the engine hostile to fake tool-call wrappers +//! (XML/Replit/markdown pseudo-calls in assistant text). Their job is to make +//! sure that: +//! +//! 1. The known wrapper markers are still present in `core/engine.rs` so the +//! streaming filter has something to scrub. +//! 2. The legacy text-based `tool_parser` does NOT treat the newer +//! `` wrapper as a real tool call — only the legacy +//! `[TOOL_CALL]` and `` shapes ever produced structured calls, and +//! nothing should silently re-enable text-based execution. +//! 3. The closing-marker list stays the same length as the start-marker list, +//! so filter logic cannot get stuck in tool-call mode forever. +//! +//! The point is that protocol drift in the model output should be visible (we +//! still strip it and emit a status notice), not silently turned into tool +//! execution. + +use std::fs; + +#[path = "../src/core/tool_parser.rs"] +#[allow(dead_code)] +mod tool_parser; + +const ENGINE_SRC: &str = include_str!("../src/core/engine.rs"); + +const EXPECTED_START_MARKERS: &[&str] = &[ + "[TOOL_CALL]", + "", +]; + +const EXPECTED_END_MARKERS: &[&str] = &[ + "[/TOOL_CALL]", + "", + "", + "", + "", +]; + +#[test] +fn engine_keeps_known_fake_wrapper_start_markers() { + for marker in EXPECTED_START_MARKERS { + let needle = format!("\"{marker}\""); + assert!( + ENGINE_SRC.contains(&needle), + "engine.rs no longer mentions start marker `{marker}` — protocol \ + scrubbing may have regressed. Searched for {needle:?}." + ); + } +} + +#[test] +fn engine_keeps_known_fake_wrapper_end_markers() { + for marker in EXPECTED_END_MARKERS { + let needle = format!("\"{marker}\""); + assert!( + ENGINE_SRC.contains(&needle), + "engine.rs no longer mentions end marker `{marker}` — protocol \ + scrubbing may have regressed. Searched for {needle:?}." + ); + } +} + +#[test] +fn engine_marker_counts_stay_paired() { + // A future contributor could quietly drop a closing marker and leave the + // filter able to enter tool-call mode without ever leaving it. Lock the + // count to whatever the constants currently declare. + assert_eq!(EXPECTED_START_MARKERS.len(), EXPECTED_END_MARKERS.len()); + assert!(ENGINE_SRC.contains("TOOL_CALL_START_MARKERS")); + assert!(ENGINE_SRC.contains("TOOL_CALL_END_MARKERS")); +} + +#[test] +fn engine_emits_compact_fake_wrapper_notice() { + assert!( + ENGINE_SRC.contains("FAKE_WRAPPER_NOTICE"), + "engine.rs no longer references the protocol-recovery notice constant" + ); + assert!( + ENGINE_SRC.contains("API tool channel"), + "the protocol-recovery notice should mention the API tool channel" + ); +} + +#[test] +fn legacy_parser_extracts_bracket_tool_call() { + let result = tool_parser::parse_tool_calls( + "intro [TOOL_CALL]\n{\"tool\":\"x\",\"args\":{}}\n[/TOOL_CALL]", + ); + assert_eq!(result.tool_calls.len(), 1); + assert_eq!(result.tool_calls[0].name, "x"); + assert_eq!(result.clean_text, "intro"); +} + +#[test] +fn legacy_parser_extracts_invoke_block() { + let result = tool_parser::parse_tool_calls( + "before v after", + ); + assert_eq!(result.tool_calls.len(), 1); + assert_eq!(result.tool_calls[0].name, "do_thing"); +} + +#[test] +fn legacy_parser_does_not_execute_function_calls_wrapper() { + // The newer `` wrapper is the kind of forged shape that + // shows up in non-DeepSeek tool-call leakage. The legacy text parser must + // NOT turn it into a structured tool call (the engine's filter still + // strips it from visible text and the model is expected to use the API + // tool channel instead). + let raw = "narrative \n{\"name\":\"x\",\"input\":{}}\n end"; + let result = tool_parser::parse_tool_calls(raw); + assert!( + result.tool_calls.is_empty(), + "function_calls wrapper must not be parsed as a real tool call: {:?}", + result.tool_calls + ); +} + +#[test] +fn legacy_parser_has_marker_helper_for_legacy_shapes_only() { + // The legacy parser's `has_tool_call_markers` is documentation of which + // shapes it ever knew how to handle. If it ever starts returning true for + // ``, the parser may also have started producing fake + // tool calls — we want to fail loudly in that case. + assert!(tool_parser::has_tool_call_markers( + "noise [TOOL_CALL]x[/TOOL_CALL]" + )); + assert!(tool_parser::has_tool_call_markers( + "noise " + )); + assert!(!tool_parser::has_tool_call_markers( + "noise {}" + )); +} + +#[test] +fn engine_source_file_still_exists_and_is_non_trivial() { + // Sanity check so the `include_str!` above is meaningful — if the engine + // module ever moves, this test must be updated alongside it. + let metadata = fs::metadata("src/core/engine.rs").expect("engine.rs must exist next to tests"); + assert!( + metadata.len() > 10_000, + "engine.rs is unexpectedly small ({} bytes); did the file move?", + metadata.len() + ); +} diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 1e0c7f95..fd809a0c 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -232,3 +232,48 @@ checks use the resolved `mcp_config_path` / `skills_dir` (including env override To bootstrap missing MCP/skills paths, run `deepseek-tui setup --all`. You can also run `deepseek-tui setup --skills --local` to create a workspace-local `./skills` dir. + +`deepseek-tui doctor --json` prints a machine-readable report that skips the +live API connectivity probe. Top-level keys: `version`, `config_path`, +`config_present`, `workspace`, `api_key.source`, `base_url`, +`default_text_model`, `mcp`, `skills`, `tools`, `plugins`, `sandbox`, +`platform`, `api_connectivity`. CI consumers should rely on `api_key.source` +(`env`/`config`/`missing`) rather than parsing the human-readable `doctor` +text. + +## Setup status, clean, and extension dirs + +`deepseek-tui setup` accepts a few flags beyond the existing `--mcp`, +`--skills`, `--local`, `--all`, and `--force`: + +- `--status` — print a compact one-screen status (api key, base URL, model, + MCP/skills/tools/plugins counts, sandbox, `.env` presence). Read-only and + network-free; safe to run in CI. +- `--tools` — scaffold `~/.deepseek/tools/` with a `README.md` describing the + self-describing frontmatter convention (`# name:` / `# description:` / + `# usage:`) and an `example.sh` that follows it. The directory is + intentionally not auto-loaded; wire individual scripts into the agent via + MCP, hooks, or skills. +- `--plugins` — scaffold `~/.deepseek/plugins/` with a `README.md` and an + `example/PLUGIN.md` placeholder using the same frontmatter shape as + `SKILL.md`. Plugins are not loaded automatically either; reference them + from a skill or MCP wrapper when you want them active. +- `--all` now scaffolds MCP + skills + tools + plugins together. +- `--clean` — list `~/.deepseek/sessions/checkpoints/latest.json` and + `offline_queue.json` if they exist. Pass `--force` to actually remove them. + This never touches real session history or the task queue. + +`--status` and `--clean` are mutually exclusive with the scaffold flags. + +## Why the engine strips XML/`[TOOL_CALL]` text + +DeepSeek TUI sends and receives tool calls only over the API tool channel +(structured `tool_use` / `tool_call` items). The streaming loop in +`crates/tui/src/core/engine.rs` recognizes a fixed set of fake-wrapper start +markers — `[TOOL_CALL]`, `` — and scrubs them from visible assistant text without ever +turning them into structured tool calls. When a wrapper is stripped, the loop +emits one compact `status` notice per turn so the user can see why their +visible text shrank. Treat any change that re-enables text-based tool +execution as a regression; the protocol-recovery tests in +`crates/tui/tests/protocol_recovery.rs` lock the contract.