返回 CodeWhale
tools_mcp.rs
根目录 / crates / tui / src / tui / setup / tools_mcp.rs
1 //! Tools / MCP / skills / plugins setup inventory (#3407).
2 //!
3 //! Read-only discovery surface for the setup wizard. Classifies each surface as
4 //! `healthy` / `needs_config` / `off`, redacts secrets, and never spawns MCP
5 //! servers, installs skills, or runs plugins. Side-effectful bootstrap stays
6 //! behind explicit CLI/TUI commands listed in the on-ramp.
7
8 use std::path::{Path, PathBuf};
9
10 use crate::config::Config;
11 use crate::mcp::{
12 McpCommandAvailability, McpConfig, McpManagerSnapshot, McpServerConfig, McpServerSnapshot,
13 static_mcp_command_availability,
14 };
15 use crate::tui::app::App;
16 use crate::tui::hotbar::actions::HotbarActionCategory;
17 use crate::utils::display_path;
18 use codewhale_localization::{Locale, MessageId, tr};
19
20 /// Per-surface readiness vocabulary shared with setup summaries and doctor-like
21 /// copy. These never block first-run; they only describe optional power tools.
22 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
23 pub(super) enum InventoryStatus {
24 /// Configured and statically sound (or live-connected when a snapshot exists).
25 Healthy,
26 /// Present but incomplete/broken — needs user action outside first-run.
27 NeedsConfig,
28 /// Disabled or not configured. Friendly empty state, not an error.
29 Off,
30 }
31
32 impl InventoryStatus {
33 pub(super) fn as_str(self) -> &'static str {
34 match self {
35 Self::Healthy => "healthy",
36 Self::NeedsConfig => "needs_config",
37 Self::Off => "off",
38 }
39 }
40
41 fn rank(self) -> u8 {
42 match self {
43 Self::Off => 0,
44 Self::Healthy => 1,
45 Self::NeedsConfig => 2,
46 }
47 }
48
49 fn worse(self, other: Self) -> Self {
50 if other.rank() > self.rank() {
51 other
52 } else {
53 self
54 }
55 }
56 }
57
58 #[derive(Debug, Clone, PartialEq, Eq)]
59 struct InventoryRow {
60 status: InventoryStatus,
61 detail: String,
62 }
63
64 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
65 enum McpInventoryScope {
66 Configuration,
67 Protocol,
68 }
69
70 #[derive(Debug, Clone, PartialEq, Eq)]
71 struct McpInventoryRow {
72 status: InventoryStatus,
73 detail: String,
74 scope: McpInventoryScope,
75 }
76
77 impl McpInventoryRow {
78 fn status_label(&self) -> &'static str {
79 match (self.status, self.scope) {
80 (InventoryStatus::Healthy, McpInventoryScope::Configuration) => "configured",
81 (InventoryStatus::Healthy, McpInventoryScope::Protocol) => "protocol_ready",
82 (status, _) => status.as_str(),
83 }
84 }
85 }
86
87 #[derive(Debug, Clone, PartialEq, Eq)]
88 pub(super) struct SetupToolsMcpFacts {
89 pub(super) servers_result: String,
90 pub(super) skills_result: String,
91 pub(super) tools_result: String,
92 pub(super) plugins_result: String,
93 pub(super) hotbar_result: String,
94 /// DeepSeek Harness (dsh) integration state; read-only probe.
95 pub(super) dsh_result: String,
96 pub(super) result: String,
97 pub(super) overall_status: InventoryStatus,
98 pub(super) needs_action: bool,
99 pub(super) mcp_path_display: String,
100 pub(super) skills_path_display: String,
101 pub(super) plugins_path_display: String,
102 }
103
104 impl Default for SetupToolsMcpFacts {
105 fn default() -> Self {
106 Self {
107 servers_result: "MCP config not loaded".to_string(),
108 skills_result: "skills dir not loaded".to_string(),
109 tools_result: "tools dir not loaded".to_string(),
110 plugins_result: "plugins dir not loaded".to_string(),
111 hotbar_result: "hotbar source metadata not loaded".to_string(),
112 dsh_result: "DeepSeek Harness not probed".to_string(),
113 result: "tools/MCP not loaded".to_string(),
114 overall_status: InventoryStatus::Off,
115 needs_action: false,
116 mcp_path_display: String::new(),
117 skills_path_display: String::new(),
118 plugins_path_display: String::new(),
119 }
120 }
121 }
122
123 impl SetupToolsMcpFacts {
124 pub(super) fn from_app_config(app: &App, config: &Config, codewhale_home: &Path) -> Self {
125 let project_mcp_path = crate::mcp::workspace_mcp_config_path(&app.workspace);
126 let mcp = mcp_inventory(app, &project_mcp_path);
127 let skills = skills_inventory(app);
128 let tools_dir = codewhale_home.join("tools");
129 let tools = tools_dir_inventory(&tools_dir);
130 let plugins = plugins_inventory(app, config, codewhale_home);
131 let hotbar = hotbar_source_inventory(app);
132
133 let overall = mcp
134 .status
135 .worse(skills.status)
136 .worse(tools.status)
137 .worse(plugins.status);
138 // Only configured-but-broken surfaces need action. Empty/off is fine.
139 let needs_action = matches!(mcp.status, InventoryStatus::NeedsConfig)
140 || matches!(skills.status, InventoryStatus::NeedsConfig)
141 || matches!(tools.status, InventoryStatus::NeedsConfig)
142 || matches!(plugins.status, InventoryStatus::NeedsConfig);
143
144 let mcp_path_display = display_path(&app.mcp_config_path);
145 let skills_path_display = display_path(&app.skills_dir);
146 let plugins_path_display = display_path(&plugins_dir_for(app, config, codewhale_home));
147
148 let servers_result = format!("{} — {}", mcp.status_label(), mcp.detail);
149 let skills_result = format!("{} — {}", skills.status.as_str(), skills.detail);
150 let tools_result = format!("{} — {}", tools.status.as_str(), tools.detail);
151 let plugins_result = format!("{} — {}", plugins.status.as_str(), plugins.detail);
152 let hotbar_result = format!("{} — {}", hotbar.status.as_str(), hotbar.detail);
153 let dsh_result = dsh_integration_result(config, &app.workspace);
154
155 let result = format!(
156 "mcp={}, skills={}, tools={}, plugins={}, hotbar_sources={}, overall={}, mode=read_only_safe_probe",
157 mcp.status_label(),
158 skills.status.as_str(),
159 tools.status.as_str(),
160 plugins.status.as_str(),
161 hotbar.detail,
162 overall.as_str(),
163 );
164
165 Self {
166 servers_result,
167 skills_result,
168 tools_result,
169 plugins_result,
170 hotbar_result,
171 dsh_result,
172 result,
173 overall_status: overall,
174 needs_action,
175 mcp_path_display,
176 skills_path_display,
177 plugins_path_display,
178 }
179 }
180 }
181
182 /// Side-effect-free DeepSeek Harness state for the on-ramp card. Detection
183 /// only runs `dsh --version`/`--help` and reads `$DSH_HOME` inventory; it
184 /// never writes or reads a credential value.
185 fn dsh_integration_result(config: &Config, workspace: &Path) -> String {
186 use crate::integrations::dsh;
187 let paths = match dsh::DshPaths::from_process() {
188 Ok(paths) => paths,
189 Err(error) => return format!("unavailable — {error}"),
190 };
191 let detection = dsh::detect::detect(&dsh::DetectEnv::from_process(), &dsh::ProcessRunner);
192 let identity = dsh::codewhale_route_identity(config, workspace);
193 match dsh::compute_status(
194 &paths,
195 detection,
196 identity,
197 false,
198 dsh::bundle_availability_now(),
199 ) {
200 Ok(report) => dsh::status_line(&report),
201 Err(error) => format!("unavailable — {error}"),
202 }
203 }
204
205 pub(super) fn on_ramp_text(locale: Locale, facts: &SetupToolsMcpFacts) -> String {
206 let base = tr(locale, MessageId::SetupToolsMcpOnRampText);
207 let dsh_row =
208 tr(locale, MessageId::SetupToolsMcpDshRow).replace("{dsh_result}", &facts.dsh_result);
209 let base = format!("{base}\n\n{dsh_row}");
210 base.replace("{mcp_result}", &facts.servers_result)
211 .replace("{skills_result}", &facts.skills_result)
212 .replace("{tools_result}", &facts.tools_result)
213 .replace("{plugins_result}", &facts.plugins_result)
214 .replace("{hotbar_result}", &facts.hotbar_result)
215 .replace("{mcp_path}", &facts.mcp_path_display)
216 .replace("{skills_path}", &facts.skills_path_display)
217 .replace("{plugins_path}", &facts.plugins_path_display)
218 }
219
220 fn mcp_inventory(app: &App, project_mcp_path: &Path) -> McpInventoryRow {
221 if let Some(snapshot) = app.mcp_snapshot.as_ref() {
222 return mcp_snapshot_inventory(snapshot, &app.mcp_config_path, project_mcp_path);
223 }
224
225 match crate::mcp::load_config_with_workspace_and_plugins(
226 &app.mcp_config_path,
227 &app.workspace,
228 app.plugin_registry.as_ref(),
229 ) {
230 Ok(cfg) => mcp_config_inventory(&app.mcp_config_path, project_mcp_path, &cfg),
231 Err(_) => McpInventoryRow {
232 status: InventoryStatus::NeedsConfig,
233 detail: format!(
234 "config unreadable at {} (and project {}); open /mcp or run `codewhale doctor` — secrets not shown",
235 display_path(&app.mcp_config_path),
236 display_path(project_mcp_path)
237 ),
238 scope: McpInventoryScope::Configuration,
239 },
240 }
241 }
242
243 fn mcp_path_presence(global: &Path, project: &Path) -> String {
244 let global_state = if global.exists() {
245 "global present"
246 } else {
247 "global missing"
248 };
249 let project_state = if project.exists() {
250 "project present"
251 } else {
252 "project missing"
253 };
254 format!(
255 "{global_state} at {}; {project_state} at {}",
256 display_path(global),
257 display_path(project)
258 )
259 }
260
261 fn mcp_snapshot_inventory(
262 snapshot: &McpManagerSnapshot,
263 global_path: &Path,
264 project_path: &Path,
265 ) -> McpInventoryRow {
266 let total = snapshot.servers.len();
267 let paths = mcp_path_presence(global_path, project_path);
268 if total == 0 {
269 return McpInventoryRow {
270 status: InventoryStatus::Off,
271 detail: format!(
272 "nothing configured yet ({paths}); optional — use /mcp or `codewhale mcp init` later"
273 ),
274 scope: McpInventoryScope::Protocol,
275 };
276 }
277
278 let mut protocol_ready = 0usize;
279 let mut needs_config = 0usize;
280 let mut off = 0usize;
281 let mut names_ok: Vec<&str> = Vec::new();
282 let mut names_bad: Vec<&str> = Vec::new();
283 let mut names_off: Vec<&str> = Vec::new();
284
285 for server in &snapshot.servers {
286 match classify_snapshot_server(server) {
287 InventoryStatus::Healthy => {
288 protocol_ready += 1;
289 if names_ok.len() < 4 {
290 names_ok.push(server.name.as_str());
291 }
292 }
293 InventoryStatus::NeedsConfig => {
294 needs_config += 1;
295 if names_bad.len() < 4 {
296 names_bad.push(server.name.as_str());
297 }
298 }
299 InventoryStatus::Off => {
300 off += 1;
301 if names_off.len() < 4 {
302 names_off.push(server.name.as_str());
303 }
304 }
305 }
306 }
307
308 let status = if needs_config > 0 {
309 InventoryStatus::NeedsConfig
310 } else if protocol_ready > 0 {
311 InventoryStatus::Healthy
312 } else {
313 InventoryStatus::Off
314 };
315
316 let mut detail = format!(
317 "{total} configured ({protocol_ready} protocol_ready, {needs_config} needs_config, {off} off; {paths}); backend/tool health not checked"
318 );
319 if !names_ok.is_empty() {
320 detail.push_str(&format!("; protocol_ready: {}", names_ok.join(", ")));
321 }
322 if !names_bad.is_empty() {
323 detail.push_str(&format!("; needs_config: {}", names_bad.join(", ")));
324 }
325 if !names_off.is_empty() {
326 detail.push_str(&format!("; off: {}", names_off.join(", ")));
327 }
328 if snapshot.reload_required {
329 detail.push_str("; /mcp reload required for live tool list");
330 }
331 detail.push_str("; /mcp for details (commands/tokens never shown here)");
332 McpInventoryRow {
333 status,
334 detail,
335 scope: McpInventoryScope::Protocol,
336 }
337 }
338
339 fn classify_snapshot_server(server: &McpServerSnapshot) -> InventoryStatus {
340 if !server.enabled {
341 return InventoryStatus::Off;
342 }
343 if server.connected {
344 return InventoryStatus::Healthy;
345 }
346 match server.error.as_deref() {
347 None => InventoryStatus::Healthy,
348 Some("disabled") => InventoryStatus::Off,
349 Some(_) => InventoryStatus::NeedsConfig,
350 }
351 }
352
353 fn mcp_config_inventory(global: &Path, project: &Path, cfg: &McpConfig) -> McpInventoryRow {
354 let total = cfg.servers.len();
355 let paths = mcp_path_presence(global, project);
356 if total == 0 {
357 return McpInventoryRow {
358 status: InventoryStatus::Off,
359 detail: format!(
360 "nothing configured yet ({paths}); optional — use /mcp or `codewhale mcp init` later"
361 ),
362 scope: McpInventoryScope::Configuration,
363 };
364 }
365
366 let mut configured = 0usize;
367 let mut needs_config = 0usize;
368 let mut off = 0usize;
369 let mut names_ok: Vec<&str> = Vec::new();
370 let mut names_bad: Vec<&str> = Vec::new();
371 let mut names_off: Vec<&str> = Vec::new();
372
373 for (name, server) in &cfg.servers {
374 match classify_config_server(server) {
375 InventoryStatus::Healthy => {
376 configured += 1;
377 if names_ok.len() < 4 {
378 names_ok.push(name.as_str());
379 }
380 }
381 InventoryStatus::NeedsConfig => {
382 needs_config += 1;
383 if names_bad.len() < 4 {
384 names_bad.push(name.as_str());
385 }
386 }
387 InventoryStatus::Off => {
388 off += 1;
389 if names_off.len() < 4 {
390 names_off.push(name.as_str());
391 }
392 }
393 }
394 }
395
396 let status = if needs_config > 0 {
397 InventoryStatus::NeedsConfig
398 } else if configured > 0 {
399 InventoryStatus::Healthy
400 } else {
401 InventoryStatus::Off
402 };
403
404 let mut detail = format!(
405 "{total} configured ({configured} configuration valid, {needs_config} needs_config, {off} off; {paths}); live health not checked — servers not started"
406 );
407 if !names_ok.is_empty() {
408 detail.push_str(&format!("; configuration valid: {}", names_ok.join(", ")));
409 }
410 if !names_bad.is_empty() {
411 detail.push_str(&format!("; needs_config: {}", names_bad.join(", ")));
412 }
413 if !names_off.is_empty() {
414 detail.push_str(&format!("; off: {}", names_off.join(", ")));
415 }
416 detail.push_str("; /mcp or `codewhale doctor` for full checks");
417 McpInventoryRow {
418 status,
419 detail,
420 scope: McpInventoryScope::Configuration,
421 }
422 }
423
424 /// Safe static probe aligned with `doctor_check_mcp_server` without spawning.
425 fn classify_config_server(server: &McpServerConfig) -> InventoryStatus {
426 if !server.is_enabled() {
427 return InventoryStatus::Off;
428 }
429 if server.command.is_none() && server.url.is_none() {
430 return InventoryStatus::NeedsConfig;
431 }
432 if matches!(
433 static_mcp_command_availability(server),
434 Ok(McpCommandAvailability::Missing) | Ok(McpCommandAvailability::NotChecked) | Err(_)
435 ) {
436 return InventoryStatus::NeedsConfig;
437 }
438 // Env-backed bearer tokens: missing env is needs_config when URL-based.
439 if server.url.is_some()
440 && let Some(env_var) = server.bearer_token_env_var.as_deref()
441 && !env_var.is_empty()
442 && std::env::var_os(env_var).is_none()
443 {
444 return InventoryStatus::NeedsConfig;
445 }
446 InventoryStatus::Healthy
447 }
448
449 fn skills_inventory(app: &App) -> InventoryRow {
450 let path = display_path(&app.skills_dir);
451 let discovered = app.cached_skills.len();
452 let dir_exists = app.skills_dir.exists();
453 let dir_is_dir = app.skills_dir.is_dir();
454
455 if !dir_exists {
456 return InventoryRow {
457 status: InventoryStatus::Off,
458 detail: format!(
459 "nothing configured yet (missing at {path}); optional — /skills or `codewhale setup --skills`"
460 ),
461 };
462 }
463 if !dir_is_dir {
464 return InventoryRow {
465 status: InventoryStatus::NeedsConfig,
466 detail: format!("skills path exists but is not a directory at {path}"),
467 };
468 }
469
470 // Count on-disk SKILL.md entries without executing anything.
471 let on_disk = count_skill_dirs(&app.skills_dir);
472 if discovered == 0 && on_disk == 0 {
473 return InventoryRow {
474 status: InventoryStatus::Off,
475 detail: format!(
476 "dir present at {path} with 0 skills; optional — install later via /skills install"
477 ),
478 };
479 }
480
481 InventoryRow {
482 status: InventoryStatus::Healthy,
483 detail: format!(
484 "{discovered} discovered (hotbar skill sources), {on_disk} on disk at {path}; /skills lists names and trust"
485 ),
486 }
487 }
488
489 fn tools_dir_inventory(tools_dir: &Path) -> InventoryRow {
490 let path = display_path(tools_dir);
491 if !tools_dir.exists() {
492 return InventoryRow {
493 status: InventoryStatus::Off,
494 detail: format!(
495 "nothing configured yet (missing at {path}); optional — `codewhale setup --tools`"
496 ),
497 };
498 }
499 if !tools_dir.is_dir() {
500 return InventoryRow {
501 status: InventoryStatus::NeedsConfig,
502 detail: format!("tools path exists but is not a directory at {path}"),
503 };
504 }
505 let script_plugins = crate::tools::plugin::scan_plugin_dir(tools_dir).len();
506 let entries = count_dir_entries(tools_dir);
507 if entries == 0 {
508 return InventoryRow {
509 status: InventoryStatus::Off,
510 detail: format!("empty tools dir at {path}; optional"),
511 };
512 }
513 InventoryRow {
514 status: InventoryStatus::Healthy,
515 detail: format!(
516 "{entries} entries, {script_plugins} script-plugin tools at {path}; not executed during setup"
517 ),
518 }
519 }
520
521 fn plugins_inventory(app: &App, config: &Config, codewhale_home: &Path) -> InventoryRow {
522 let plugins_dir = plugins_dir_for(app, config, codewhale_home);
523 let path = display_path(&plugins_dir);
524
525 // Manifest-based plugins (plugin.toml) are owned by this App's immutable,
526 // workspace-scoped registry snapshot. Never consult process-global state:
527 // concurrent sessions may be rooted in different workspaces.
528 let list = app.plugin_registry.list();
529 let manifest_total = list.len();
530 let manifest_active = list.iter().filter(|plugin| plugin.active()).count();
531 let active_commands = list
532 .iter()
533 .filter(|plugin| {
534 plugin
535 .component_active(crate::plugins::activation::PluginActivationCapability::Commands)
536 })
537 .map(|plugin| plugin.inventory.commands)
538 .sum::<usize>();
539 let active_agents = list
540 .iter()
541 .filter(|plugin| {
542 plugin.component_active(crate::plugins::activation::PluginActivationCapability::Agents)
543 })
544 .map(|plugin| plugin.inventory.agents)
545 .sum::<usize>();
546 let active_hooks = list
547 .iter()
548 .filter(|plugin| {
549 plugin.component_active(crate::plugins::activation::PluginActivationCapability::Hooks)
550 })
551 .map(|plugin| plugin.inventory.hooks)
552 .sum::<usize>();
553
554 // Script plugins under [tools].plugin_dir (distinct from slash commands;
555 // Hotbar Plugin source remains deferred/exploratory).
556 let script_dir = config
557 .tools
558 .as_ref()
559 .and_then(|tools| tools.plugin_dir.as_ref())
560 .map(PathBuf::from)
561 .filter(|p| p.as_path() != plugins_dir.as_path());
562 let script_count = script_dir
563 .as_ref()
564 .filter(|p| p.is_dir())
565 .map(|p| crate::tools::plugin::scan_plugin_dir(p).len())
566 .unwrap_or(0);
567
568 if !plugins_dir.exists() && manifest_total == 0 && script_count == 0 {
569 return InventoryRow {
570 status: InventoryStatus::Off,
571 detail: format!(
572 "nothing configured yet (missing at {path}); optional — use /plugin to add or review plugins"
573 ),
574 };
575 }
576
577 if plugins_dir.exists() && !plugins_dir.is_dir() {
578 return InventoryRow {
579 status: InventoryStatus::NeedsConfig,
580 detail: format!("plugins path exists but is not a directory at {path}"),
581 };
582 }
583
584 if manifest_total == 0 && script_count == 0 {
585 return InventoryRow {
586 status: InventoryStatus::Off,
587 detail: format!("dir present at {path} with 0 plugins; use /plugin to add one"),
588 };
589 }
590
591 let inactive = manifest_total.saturating_sub(manifest_active);
592 InventoryRow {
593 status: InventoryStatus::Healthy,
594 detail: format!(
595 "{manifest_total} bundles ({manifest_active} trusted+active, {inactive} inactive); active adapters: {active_commands} commands, {active_agents} agents, {active_hooks} hooks; {script_count} legacy script tools; use /plugin to review trust and enablement"
596 ),
597 }
598 }
599
600 fn plugins_dir_for(_app: &App, _config: &Config, codewhale_home: &Path) -> PathBuf {
601 codewhale_home.join("plugins")
602 }
603
604 fn hotbar_source_inventory(app: &App) -> InventoryRow {
605 // Reuse the same Hotbar action registry the setup Hotbar step and command
606 // palette already share — do not re-discover MCP/skills here.
607 let mut mcp = 0usize;
608 let mut skill = 0usize;
609 let mut plugin = 0usize;
610 let mut slash = 0usize;
611 for action in app.hotbar_actions.iter() {
612 match action.category() {
613 c if c == HotbarActionCategory::Mcp.as_str() => mcp += 1,
614 c if c == HotbarActionCategory::Skill.as_str() => skill += 1,
615 c if c == HotbarActionCategory::Plugin.as_str() => plugin += 1,
616 c if c == HotbarActionCategory::Slash.as_str() => slash += 1,
617 _ => {}
618 }
619 }
620 // Plugin source is deferred by design (#3399) — zero dispatchable plugin
621 // actions is healthy, not a failure.
622 let status = if mcp > 0 || skill > 0 {
623 InventoryStatus::Healthy
624 } else {
625 InventoryStatus::Off
626 };
627 InventoryRow {
628 status,
629 detail: format!(
630 "shared adapters: mcp_actions={mcp}, skill_actions={skill}, plugin_actions={plugin} (deferred), slash_actions={slash}"
631 ),
632 }
633 }
634
635 fn count_dir_entries(dir: &Path) -> usize {
636 std::fs::read_dir(dir)
637 .map(|entries| {
638 entries
639 .filter_map(Result::ok)
640 .filter(|entry| entry.file_name().to_string_lossy() != ".DS_Store")
641 .count()
642 })
643 .unwrap_or(0)
644 }
645
646 fn count_skill_dirs(dir: &Path) -> usize {
647 std::fs::read_dir(dir)
648 .map(|entries| {
649 entries
650 .filter_map(Result::ok)
651 .filter(|entry| entry.path().join("SKILL.md").is_file())
652 .count()
653 })
654 .unwrap_or(0)
655 }
656
657 #[cfg(test)]
658 mod tests {
659 use super::*;
660 use crate::config::Config;
661 use crate::mcp::{McpDiscoveredItem, McpManagerSnapshot, McpServerSnapshot};
662 use crate::tui::app::TuiOptions;
663 use crate::tui::hotbar::actions::HotbarActionRegistry;
664 use codewhale_localization::Locale;
665 use tempfile::TempDir;
666
667 fn test_app(
668 workspace: &Path,
669 config_path: Option<PathBuf>,
670 mcp_config_path: PathBuf,
671 skills_dir: PathBuf,
672 ) -> App {
673 let options = TuiOptions {
674 config_path,
675 skills_dir: skills_dir.clone(),
676 memory_path: workspace.join("memory.md"),
677 notes_path: workspace.join("notes.txt"),
678 mcp_config_path,
679 ..crate::test_support::test_tui_options(workspace)
680 };
681 let mut app = App::new(options, &Config::default());
682 app.ui_locale = Locale::En;
683 // App::new re-resolves skills via global/workspace discovery; pin the
684 // hermetic test path and empty cache so host ~/.agents/skills cannot
685 // leak into inventory assertions.
686 app.skills_dir = skills_dir;
687 app.cached_skills.clear();
688 app.hotbar_actions = HotbarActionRegistry::with_builtins();
689 app
690 }
691
692 fn write_path_only_command(dir: &Path) -> String {
693 let command = "codewhale-setup-mcp-path-only-test";
694 #[cfg(windows)]
695 let file_name = format!("{command}.exe");
696 #[cfg(not(windows))]
697 let file_name = command.to_string();
698 let path = dir.join(file_name);
699 std::fs::write(&path, b"test executable").expect("write path-only command");
700 #[cfg(unix)]
701 {
702 use std::os::unix::fs::PermissionsExt;
703
704 let mut permissions = std::fs::metadata(&path)
705 .expect("path-only command metadata")
706 .permissions();
707 permissions.set_mode(0o755);
708 std::fs::set_permissions(&path, permissions)
709 .expect("make path-only command executable");
710 }
711 command.to_string()
712 }
713
714 fn path_server(command: &str, path: &Path) -> McpServerConfig {
715 serde_json::from_value(serde_json::json!({
716 "command": command,
717 "env": {"PATH": path},
718 }))
719 .expect("stdio server config")
720 }
721
722 #[test]
723 fn empty_inventory_is_off_not_error() {
724 let tmp = TempDir::new().expect("tempdir");
725 let home = tmp.path().join("home");
726 std::fs::create_dir_all(&home).expect("home");
727 let app = test_app(
728 tmp.path(),
729 None,
730 tmp.path().join("mcp.json"),
731 tmp.path().join("skills"),
732 );
733
734 let facts = SetupToolsMcpFacts::from_app_config(&app, &Config::default(), &home);
735
736 assert!(
737 facts.servers_result.contains("off"),
738 "empty MCP should be off: {}",
739 facts.servers_result
740 );
741 assert!(
742 facts.skills_result.contains("off"),
743 "missing skills dir should be off: {}",
744 facts.skills_result
745 );
746 assert!(
747 facts.plugins_result.contains("off"),
748 "missing plugins should be off: {}",
749 facts.plugins_result
750 );
751 assert!(
752 !facts.needs_action,
753 "empty optional inventory must not block"
754 );
755 assert_eq!(facts.overall_status, InventoryStatus::Off);
756 assert!(facts.result.contains("mode=read_only_safe_probe"));
757 assert!(facts.servers_result.contains("/mcp") || facts.servers_result.contains("optional"));
758 }
759
760 #[test]
761 fn configured_mcp_is_not_reported_as_live_healthy() {
762 let tmp = TempDir::new().expect("tempdir");
763 let home = tmp.path().join("cw-home");
764 std::fs::create_dir_all(&home).expect("home");
765 let mcp_path = tmp.path().join("mcp.json");
766 let executable = std::env::current_exe().expect("current test executable");
767 let mcp_config = serde_json::json!({
768 "servers": {
769 "docs": {
770 "command": executable,
771 "args": ["-y", "secret-mcp-package"],
772 "env": {"API_KEY": "sk-mcp-secret-token"},
773 "headers": {"Authorization": "Bearer sk-header-secret"}
774 }
775 }
776 });
777 std::fs::write(
778 &mcp_path,
779 serde_json::to_vec(&mcp_config).expect("serialize mcp config"),
780 )
781 .expect("write mcp");
782
783 let skills_dir = tmp.path().join("skills");
784 std::fs::create_dir_all(skills_dir.join("alpha")).expect("skill dir");
785 std::fs::write(
786 skills_dir.join("alpha").join("SKILL.md"),
787 "---\nname: alpha\ndescription: hides sk-skill-secret\n---\nbody\n",
788 )
789 .expect("skill");
790
791 let plugins_dir = home.join("plugins");
792 std::fs::create_dir_all(plugins_dir.join("demo")).expect("plugin");
793 std::fs::write(
794 plugins_dir.join("demo").join("plugin.toml"),
795 "schema_version = 1\n[plugin]\nname = \"demo\"\nversion = \"1.0.0\"\ndescription = \"hides sk-plugin-secret\"\n",
796 )
797 .expect("manifest");
798
799 let mut app = test_app(tmp.path(), None, mcp_path, skills_dir);
800 let discovery_config = crate::plugins::discovery::DiscoveryConfig {
801 workspace: tmp.path().to_path_buf(),
802 user_plugins_dir: plugins_dir,
803 workspace_plugins_dir: tmp.path().join("workspace-plugins-unused"),
804 builtin_plugin_dirs: Vec::new(),
805 state_path: home.join("plugins/state.json"),
806 };
807 let discovery = crate::plugins::PluginDiscoveryContext::from_config_and_environment(
808 &discovery_config,
809 crate::plugins::HostEnvironment::default(),
810 );
811 app.plugin_registry = discovery.registry_for_workspace(tmp.path());
812 // Simulate the same skill registration Hotbar uses at startup.
813 app.cached_skills = vec![("alpha".into(), "alpha skill".into())];
814 app.hotbar_actions = HotbarActionRegistry::with_builtins();
815 app.hotbar_actions.register_skills(&app.cached_skills);
816
817 let facts = SetupToolsMcpFacts::from_app_config(&app, &Config::default(), &home);
818
819 assert!(
820 facts.servers_result.starts_with("configured"),
821 "configured MCP should report configuration evidence: {}",
822 facts.servers_result
823 );
824 assert!(facts.servers_result.contains("live health not checked"));
825 assert!(!facts.servers_result.contains("healthy"));
826 assert!(facts.servers_result.contains("docs"));
827 assert!(
828 facts.skills_result.contains("healthy"),
829 "installed skills: {}",
830 facts.skills_result
831 );
832 assert!(
833 facts.plugins_result.contains("healthy"),
834 "manifest plugins: {}",
835 facts.plugins_result
836 );
837 assert!(facts.hotbar_result.contains("skill_actions=1"));
838 assert!(!facts.needs_action);
839
840 // Redaction: never leak tokens, env values, or full command args.
841 let blob = format!(
842 "{} {} {} {} {}",
843 facts.servers_result,
844 facts.skills_result,
845 facts.plugins_result,
846 facts.hotbar_result,
847 facts.result
848 );
849 assert!(!blob.contains("sk-mcp-secret-token"));
850 assert!(!blob.contains("sk-header-secret"));
851 assert!(!blob.contains("sk-skill-secret"));
852 assert!(!blob.contains("sk-plugin-secret"));
853 assert!(!blob.contains("secret-mcp-package"));
854 assert!(!blob.contains("API_KEY"));
855 assert!(!blob.contains("Bearer"));
856 }
857
858 #[test]
859 fn setup_resolves_server_path_while_doctor_stays_structural() {
860 let temp = TempDir::new().expect("tempdir");
861 let command = write_path_only_command(temp.path());
862 let mut server = path_server(&command, temp.path());
863
864 assert_eq!(classify_config_server(&server), InventoryStatus::Healthy);
865 assert!(matches!(
866 crate::doctor_check_mcp_server(&server),
867 crate::McpServerDoctorStatus::Ok(_)
868 ));
869 assert_eq!(
870 crate::doctor_mcp_command_status(&server),
871 crate::McpCommandAvailability::NotChecked
872 );
873
874 server.command = Some("codewhale-setup-mcp-command-that-does-not-exist".to_string());
875 assert_eq!(
876 classify_config_server(&server),
877 InventoryStatus::NeedsConfig
878 );
879 assert!(matches!(
880 crate::doctor_check_mcp_server(&server),
881 crate::McpServerDoctorStatus::Ok(_)
882 ));
883 assert_eq!(
884 crate::doctor_mcp_command_status(&server),
885 crate::McpCommandAvailability::NotChecked
886 );
887 }
888
889 #[test]
890 fn failed_mcp_reports_needs_config() {
891 let tmp = TempDir::new().expect("tempdir");
892 let home = tmp.path().join("home");
893 std::fs::create_dir_all(&home).expect("home");
894 let mcp_path = tmp.path().join("mcp.json");
895 std::fs::write(
896 &mcp_path,
897 r#"{
898 "servers": {
899 "broken": {
900 "command": "/definitely/missing/mcp-server-binary",
901 "args": ["--token", "sk-should-not-leak"]
902 },
903 "off-server": {
904 "command": "npx",
905 "enabled": false
906 }
907 }
908 }"#,
909 )
910 .expect("write mcp");
911
912 let app = test_app(
913 tmp.path(),
914 None,
915 mcp_path,
916 tmp.path().join("skills-missing"),
917 );
918 let facts = SetupToolsMcpFacts::from_app_config(&app, &Config::default(), &home);
919
920 assert!(
921 facts.servers_result.contains("needs_config"),
922 "broken absolute command should needs_config: {}",
923 facts.servers_result
924 );
925 assert!(facts.servers_result.contains("broken"));
926 assert!(facts.servers_result.contains("off"));
927 assert!(facts.needs_action);
928 assert!(!facts.servers_result.contains("sk-should-not-leak"));
929 assert!(!facts.result.contains("sk-should-not-leak"));
930 }
931
932 #[test]
933 fn live_snapshot_failed_server_is_needs_config() {
934 let tmp = TempDir::new().expect("tempdir");
935 let home = tmp.path().join("home");
936 std::fs::create_dir_all(&home).expect("home");
937 let mut app = test_app(
938 tmp.path(),
939 None,
940 tmp.path().join("mcp.json"),
941 tmp.path().join("skills"),
942 );
943 app.mcp_snapshot = Some(McpManagerSnapshot {
944 config_path: tmp.path().join("mcp.json"),
945 config_exists: true,
946 reload_required: false,
947 servers: vec![
948 McpServerSnapshot {
949 name: "ok".into(),
950 enabled: true,
951 required: false,
952 transport: "stdio".into(),
953 command_or_url: "npx secret-should-not-appear".into(),
954 connect_timeout: 10,
955 execute_timeout: 10,
956 read_timeout: 10,
957 connected: true,
958 error: None,
959 auth_required: false,
960 capability_metadata: crate::mcp::McpServerCapabilityMetadata::LegacyFallback,
961 tools: vec![McpDiscoveredItem {
962 name: "tool_a".into(),
963 model_name: "mcp_ok_tool_a".into(),
964 description: Some("desc".into()),
965 }],
966 resources: Vec::new(),
967 prompts: Vec::new(),
968 },
969 McpServerSnapshot {
970 name: "bad".into(),
971 enabled: true,
972 required: false,
973 transport: "stdio".into(),
974 command_or_url: "run --token sk-live-secret".into(),
975 connect_timeout: 10,
976 execute_timeout: 10,
977 read_timeout: 10,
978 connected: false,
979 error: Some("spawn failed: connection refused".into()),
980 auth_required: false,
981 capability_metadata: crate::mcp::McpServerCapabilityMetadata::NotObserved,
982 tools: Vec::new(),
983 resources: Vec::new(),
984 prompts: Vec::new(),
985 },
986 ],
987 });
988
989 let facts = SetupToolsMcpFacts::from_app_config(&app, &Config::default(), &home);
990 assert!(facts.servers_result.contains("needs_config"));
991 assert!(facts.servers_result.contains("bad"));
992 assert!(facts.servers_result.contains("ok"));
993 assert!(facts.servers_result.contains("protocol_ready"));
994 assert!(
995 facts
996 .servers_result
997 .contains("backend/tool health not checked")
998 );
999 assert!(facts.needs_action);
1000 assert!(!facts.servers_result.contains("sk-live-secret"));
1001 assert!(!facts.servers_result.contains("secret-should-not-appear"));
1002 // Error detail text may mention connection refused but not secrets.
1003 assert!(!facts.servers_result.contains("spawn failed"));
1004 }
1005
1006 #[test]
1007 fn missing_skills_dir_is_off_not_needs_config() {
1008 let tmp = TempDir::new().expect("tempdir");
1009 let home = tmp.path().join("home");
1010 std::fs::create_dir_all(&home).expect("home");
1011 let missing = tmp.path().join("no-such-skills");
1012 let app = test_app(tmp.path(), None, tmp.path().join("mcp.json"), missing);
1013 let facts = SetupToolsMcpFacts::from_app_config(&app, &Config::default(), &home);
1014 assert!(facts.skills_result.starts_with("off"));
1015 assert!(!facts.skills_result.contains("needs_config"));
1016 }
1017
1018 #[test]
1019 fn skills_path_not_directory_is_needs_config() {
1020 let tmp = TempDir::new().expect("tempdir");
1021 let home = tmp.path().join("home");
1022 std::fs::create_dir_all(&home).expect("home");
1023 let skills_file = tmp.path().join("skills-as-file");
1024 std::fs::write(&skills_file, "not a dir").expect("file");
1025 let app = test_app(tmp.path(), None, tmp.path().join("mcp.json"), skills_file);
1026 let facts = SetupToolsMcpFacts::from_app_config(&app, &Config::default(), &home);
1027 assert!(facts.skills_result.contains("needs_config"));
1028 assert!(facts.needs_action);
1029 }
1030
1031 #[test]
1032 fn plugin_unavailable_is_off_with_actionable_hint() {
1033 let tmp = TempDir::new().expect("tempdir");
1034 let home = tmp.path().join("home");
1035 std::fs::create_dir_all(&home).expect("home");
1036 // No plugins dir under home.
1037 let app = test_app(
1038 tmp.path(),
1039 None,
1040 tmp.path().join("mcp.json"),
1041 tmp.path().join("skills"),
1042 );
1043 let facts = SetupToolsMcpFacts::from_app_config(&app, &Config::default(), &home);
1044 assert!(
1045 facts.plugins_result.contains("off"),
1046 "{}",
1047 facts.plugins_result
1048 );
1049 assert!(
1050 facts.plugins_result.contains("optional")
1051 || facts.plugins_result.contains("setup --plugins")
1052 || facts.plugins_result.contains("deferred"),
1053 "actionable empty-plugin copy: {}",
1054 facts.plugins_result
1055 );
1056 }
1057
1058 #[test]
1059 fn on_ramp_text_mentions_safe_commands_and_redacts() {
1060 let facts = SetupToolsMcpFacts {
1061 servers_result: "off — nothing configured".into(),
1062 skills_result: "off — missing".into(),
1063 tools_result: "off — missing".into(),
1064 plugins_result: "off — missing".into(),
1065 hotbar_result: "off — shared adapters: mcp_actions=0".into(),
1066 dsh_result: "detected — dsh 0.1.0-rc.6, not connected".into(),
1067 result: "overall=off".into(),
1068 overall_status: InventoryStatus::Off,
1069 needs_action: false,
1070 mcp_path_display: "~/.codewhale/mcp.json".into(),
1071 skills_path_display: "~/.codewhale/skills".into(),
1072 plugins_path_display: "~/.codewhale/plugins".into(),
1073 };
1074 let text = on_ramp_text(Locale::En, &facts);
1075 assert!(text.contains("codewhale mcp init") || text.contains("/mcp"));
1076 assert!(text.contains("/skills") || text.contains("setup --skills"));
1077 assert!(text.contains("does not") || text.contains("never") || text.contains("not run"));
1078 assert!(text.contains("~/.codewhale/mcp.json"));
1079 assert!(!text.contains("sk-"));
1080 }
1081
1082 #[test]
1083 fn redacted_result_summary_omits_paths_with_home_secrets() {
1084 // result summary uses status tokens only — no raw env secrets.
1085 let tmp = TempDir::new().expect("tempdir");
1086 let home = tmp.path().join("home");
1087 std::fs::create_dir_all(&home).expect("home");
1088 let mcp_path = tmp.path().join("mcp.json");
1089 std::fs::write(
1090 &mcp_path,
1091 r#"{"servers":{"s":{"command":"npx","env":{"TOKEN":"sk-result-secret"}}}}"#,
1092 )
1093 .expect("mcp");
1094 let app = test_app(tmp.path(), None, mcp_path, tmp.path().join("skills"));
1095 let facts = SetupToolsMcpFacts::from_app_config(&app, &Config::default(), &home);
1096 assert!(!facts.result.contains("sk-result-secret"));
1097 assert!(facts.result.contains("mcp="));
1098 assert!(facts.result.contains("mode=read_only_safe_probe"));
1099 }
1100 }
1101
1101 lines RUST