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