//! MCP tool implementations for the interactive setup wizard. //! //! These tools allow Claude Code (and other MCP clients) to drive the setup //! wizard entirely from the terminal without requiring the web UI or chat bot. //! //! Typical flow: //! 1. `wizard_status` — inspect current state //! 2. `wizard_generate` — read the codebase and call again with `content` to //! stage generated text for review //! 3. `wizard_confirm` — write staged content to disk and advance the wizard //! 4. `wizard_skip` — skip a step that does not apply //! 5. `wizard_retry` — discard staged content and regenerate from scratch use crate::http::context::AppContext; use crate::io::wizard::{StepStatus, WizardState, WizardStep, format_wizard_state}; use serde_json::Value; use std::fs; use std::path::Path; // ── helpers ─────────────────────────────────────────────────────────────────── /// Return the filesystem path (relative to `project_root`) for a step's output. /// /// Returns `None` for `Scaffold` since that step has no single output file — it /// creates the full `.huskies/` directory structure and is handled by /// `huskies init` before the server starts. pub(crate) fn step_output_path( project_root: &Path, step: WizardStep, ) -> Option { match step { WizardStep::Context => Some( project_root .join(".huskies") .join("specs") .join("00_CONTEXT.md"), ), WizardStep::Stack => Some( project_root .join(".huskies") .join("specs") .join("tech") .join("STACK.md"), ), WizardStep::TestScript => Some(project_root.join("script").join("test")), WizardStep::ReleaseScript => Some(project_root.join("script").join("release")), WizardStep::TestCoverage => Some(project_root.join("script").join("test_coverage")), WizardStep::Scaffold => None, } } pub(crate) fn is_script_step(step: WizardStep) -> bool { matches!( step, WizardStep::TestScript | WizardStep::ReleaseScript | WizardStep::TestCoverage ) } /// Write `content` to `path` only when the file does not already exist. /// /// Existing files (including `CLAUDE.md`) are never overwritten — the wizard /// appends or skips per the acceptance criteria. For script steps the file is /// also made executable after writing. pub(crate) fn write_if_missing( path: &Path, content: &str, executable: bool, ) -> Result { if path.exists() { return Ok(false); // already present — skip silently } if let Some(parent) = path.parent() { fs::create_dir_all(parent) .map_err(|e| format!("Failed to create directory {}: {e}", parent.display()))?; } fs::write(path, content).map_err(|e| format!("Failed to write {}: {e}", path.display()))?; if executable { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let mut perms = fs::metadata(path) .map_err(|e| format!("Failed to read permissions: {e}"))? .permissions(); perms.set_mode(0o755); fs::set_permissions(path, perms) .map_err(|e| format!("Failed to set permissions: {e}"))?; } } Ok(true) } /// Serialise a `WizardStep` to its snake_case string (e.g. `"test_script"`). fn step_slug(step: WizardStep) -> String { serde_json::to_value(step) .ok() .and_then(|v| v.as_str().map(String::from)) .unwrap_or_default() } // ── MCP tool handlers ───────────────────────────────────────────────────────── /// `wizard_status` — return current wizard state as a human-readable summary. pub(super) fn tool_wizard_status(ctx: &AppContext) -> Result { let root = ctx.state.get_project_root()?; let state = WizardState::load(&root).ok_or("No wizard active. Run `huskies init` to begin setup.")?; Ok(format_wizard_state(&state)) } /// `wizard_generate` — mark the current step as generating or stage content. /// /// Call with no `content` argument to mark the step as `Generating` and /// receive a hint describing what to generate. Call again with a `content` /// argument (the generated file body) to stage it for review; the step will /// transition to `AwaitingConfirmation`. Content is **not** written to disk /// until `wizard_confirm` is called. pub(super) fn tool_wizard_generate(args: &Value, ctx: &AppContext) -> Result { let root = ctx.state.get_project_root()?; let mut state = WizardState::load(&root).ok_or("No wizard active.")?; if state.completed { return Ok("Wizard is already complete.".to_string()); } let current_idx = state.current_step_index(); let step = state.steps[current_idx].step; // If content is provided, stage it for confirmation. if let Some(content) = args.get("content").and_then(|v| v.as_str()) { state.set_step_status( step, StepStatus::AwaitingConfirmation, Some(content.to_string()), ); state .save(&root) .map_err(|e| format!("Failed to save wizard state: {e}"))?; return Ok(format!( "Content staged for '{}'. Run `wizard_confirm` to write it to disk, `wizard_retry` to regenerate, or `wizard_skip` to skip.", step.label() )); } // No content provided — mark as generating and return a hint. state.set_step_status(step, StepStatus::Generating, None); state .save(&root) .map_err(|e| format!("Failed to save wizard state: {e}"))?; let hint = generation_hint(step, &root); let slug = step_slug(step); Ok(format!( "Step '{}' marked as generating.\n\n{hint}\n\nOnce you have the content, call `wizard_generate` again with a `content` argument (or PUT /wizard/step/{slug}/content). Then call `wizard_confirm` to write it to disk.", step.label(), )) } /// Return true if the project directory has no meaningful source files. pub(crate) fn is_bare_project(project_root: &Path) -> bool { std::fs::read_dir(project_root) .ok() .map(|entries| { let names: Vec = entries .filter_map(|e| e.ok()) .map(|e| e.file_name().to_string_lossy().to_string()) .collect(); // A bare project only has huskies scaffolding and no real code names.iter().all(|n| { n.starts_with('.') || n == "CLAUDE.md" || n == "LICENSE" || n == "README.md" || n == "script" }) }) .unwrap_or(true) } /// Return a generation hint for a step based on the project root. pub(crate) fn generation_hint(step: WizardStep, project_root: &Path) -> String { let bare = is_bare_project(project_root); match step { WizardStep::Context => { if bare { "This is a bare project with no existing code. Ask the user what they want \ to build — the project's purpose, goals, target users, and key features. \ Then generate `.huskies/specs/00_CONTEXT.md` from their answers covering:\n\ - High-level goal of the project\n\ - Core features\n\ - Domain concepts and entities\n\ - Glossary of abbreviations and technical terms" .to_string() } else { "Read the project source tree and generate a `.huskies/specs/00_CONTEXT.md` describing:\n\ - High-level goal of the project\n\ - Core features\n\ - Domain concepts and entities\n\ - Glossary of abbreviations and technical terms".to_string() } } WizardStep::Stack => { if bare { "This is a bare project with no existing code. Ask the user what language, \ frameworks, and tools they plan to use. Then generate `.huskies/specs/tech/STACK.md` \ from their answers covering:\n\ - Language, frameworks, and runtimes\n\ - Coding standards and linting rules\n\ - Quality gates (commands that must pass before merging)\n\ - Approved libraries and their purpose".to_string() } else { "Read the project source tree and generate a `.huskies/specs/tech/STACK.md` describing:\n\ - Language, frameworks, and runtimes\n\ - Coding standards and linting rules\n\ - Quality gates (commands that must pass before merging)\n\ - Approved libraries and their purpose".to_string() } } WizardStep::TestScript => { if bare { "This is a bare project with no existing code. Read the STACK.md generated \ in the previous step (or ask the user about their stack if it was skipped) \ and generate a `script/test` shell script (#!/usr/bin/env bash, set -euo pipefail) \ with appropriate test commands for their chosen language and framework." .to_string() } else { let has_cargo = project_root.join("Cargo.toml").exists(); let has_pkg = project_root.join("package.json").exists(); let has_pnpm = project_root.join("pnpm-lock.yaml").exists(); let mut cmds = Vec::new(); if has_cargo { cmds.push("cargo nextest run"); } if has_pkg { cmds.push(if has_pnpm { "pnpm test" } else { "npm test" }); } if cmds.is_empty() { "Generate a `script/test` shell script (#!/usr/bin/env bash, set -euo pipefail) that runs the project's test suite.".to_string() } else { format!( "Generate a `script/test` shell script (#!/usr/bin/env bash, set -euo pipefail) that runs: {}", cmds.join(", ") ) } } } WizardStep::ReleaseScript => { if bare { "This is a bare project with no existing code. Read the STACK.md generated \ in the previous step (or ask the user about their stack if it was skipped) \ and generate a `script/release` shell script (#!/usr/bin/env bash, set -euo pipefail) \ with appropriate build/release commands for their chosen language and framework." .to_string() } else { "Generate a `script/release` shell script (#!/usr/bin/env bash, set -euo pipefail) that builds and releases the project (e.g. `cargo build --release` or `npm run build`).".to_string() } } WizardStep::TestCoverage => { if bare { "This is a bare project with no existing code. Read the STACK.md generated \ in the previous step (or ask the user about their stack if it was skipped) \ and generate a `script/test_coverage` shell script (#!/usr/bin/env bash, set -euo pipefail) \ with appropriate test coverage commands for their chosen language and framework." .to_string() } else { "Generate a `script/test_coverage` shell script (#!/usr/bin/env bash, set -euo pipefail) that generates a test coverage report (e.g. `cargo llvm-cov nextest` or `npm run coverage`).".to_string() } } WizardStep::Scaffold => { "Scaffold step is handled automatically by `huskies init`.".to_string() } } } /// `wizard_confirm` — confirm the current step and write its content to disk. /// /// If the step has staged content, the content is written to its target file /// (only if that file does not already exist — existing files are never /// overwritten). The step is then marked as `Confirmed` and the wizard /// advances to the next pending step. pub(super) fn tool_wizard_confirm(ctx: &AppContext) -> Result { let root = ctx.state.get_project_root()?; let mut state = WizardState::load(&root).ok_or("No wizard active.")?; if state.completed { return Ok("Wizard is already complete.".to_string()); } let current_idx = state.current_step_index(); let step = state.steps[current_idx].step; let content = state.steps[current_idx].content.clone(); // Write content to disk (only if a file path exists and the file is absent). let write_msg = if let (Some(c), Some(ref path)) = (&content, step_output_path(&root, step)) { let executable = is_script_step(step); match write_if_missing(path, c, executable)? { true => format!(" File written: `{}`.", path.display()), false => format!(" File `{}` already exists — skipped.", path.display()), } } else { String::new() }; state .confirm_step(step) .map_err(|e| format!("Cannot confirm step: {e}"))?; state .save(&root) .map_err(|e| format!("Failed to save wizard state: {e}"))?; let next_idx = state.current_step_index(); if state.completed { Ok(format!( "Step '{}' confirmed.{write_msg}\n\nSetup wizard complete! All steps done.", step.label() )) } else { let next = &state.steps[next_idx]; Ok(format!( "Step '{}' confirmed.{write_msg}\n\nNext: {} — run `wizard_generate` to begin.", step.label(), next.step.label() )) } } /// `wizard_skip` — skip the current step without writing any file. pub(super) fn tool_wizard_skip(ctx: &AppContext) -> Result { let root = ctx.state.get_project_root()?; let mut state = WizardState::load(&root).ok_or("No wizard active.")?; if state.completed { return Ok("Wizard is already complete.".to_string()); } let current_idx = state.current_step_index(); let step = state.steps[current_idx].step; state .skip_step(step) .map_err(|e| format!("Cannot skip step: {e}"))?; state .save(&root) .map_err(|e| format!("Failed to save wizard state: {e}"))?; let next_idx = state.current_step_index(); if state.completed { Ok(format!( "Step '{}' skipped. Setup wizard complete!", step.label() )) } else { let next = &state.steps[next_idx]; Ok(format!( "Step '{}' skipped.\n\nNext: {} — run `wizard_generate` to begin.", step.label(), next.step.label() )) } } /// `wizard_retry` — discard staged content and reset the current step to /// `Pending` so it can be regenerated. pub(super) fn tool_wizard_retry(ctx: &AppContext) -> Result { let root = ctx.state.get_project_root()?; let mut state = WizardState::load(&root).ok_or("No wizard active.")?; if state.completed { return Ok("Wizard is already complete.".to_string()); } let current_idx = state.current_step_index(); let step = state.steps[current_idx].step; // Clear content and reset to pending. if let Some(s) = state.steps.iter_mut().find(|s| s.step == step) { s.status = StepStatus::Pending; s.content = None; } state .save(&root) .map_err(|e| format!("Failed to save wizard state: {e}"))?; Ok(format!( "Step '{}' reset to pending. Run `wizard_generate` to regenerate content.", step.label() )) } // ── tests ───────────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; use crate::http::context::AppContext; use tempfile::TempDir; fn setup(dir: &TempDir) -> AppContext { let root = dir.path().to_path_buf(); std::fs::create_dir_all(root.join(".huskies")).unwrap(); WizardState::init_if_missing(&root); AppContext::new_test(root) } #[test] fn wizard_status_returns_state() { let dir = TempDir::new().unwrap(); let ctx = setup(&dir); let result = tool_wizard_status(&ctx).unwrap(); assert!(result.contains("Setup wizard")); assert!(result.contains("context")); } #[test] fn wizard_status_no_wizard_returns_error() { let dir = TempDir::new().unwrap(); std::fs::create_dir_all(dir.path().join(".huskies")).unwrap(); let ctx = AppContext::new_test(dir.path().to_path_buf()); assert!(tool_wizard_status(&ctx).is_err()); } #[test] fn wizard_generate_marks_generating() { let dir = TempDir::new().unwrap(); let ctx = setup(&dir); let result = tool_wizard_generate(&serde_json::json!({}), &ctx).unwrap(); assert!(result.contains("generating")); let state = WizardState::load(dir.path()).unwrap(); assert_eq!(state.steps[1].status, StepStatus::Generating); } #[test] fn wizard_generate_with_content_stages_content() { let dir = TempDir::new().unwrap(); let ctx = setup(&dir); let result = tool_wizard_generate(&serde_json::json!({"content": "# My Project"}), &ctx).unwrap(); assert!(result.contains("staged")); let state = WizardState::load(dir.path()).unwrap(); assert_eq!(state.steps[1].status, StepStatus::AwaitingConfirmation); assert_eq!(state.steps[1].content.as_deref(), Some("# My Project")); } #[test] fn wizard_confirm_writes_file_and_advances() { let dir = TempDir::new().unwrap(); let ctx = setup(&dir); // Stage content for Context step. tool_wizard_generate(&serde_json::json!({"content": "# Context content"}), &ctx).unwrap(); let result = tool_wizard_confirm(&ctx).unwrap(); assert!(result.contains("confirmed")); // File should now exist. let context_path = dir .path() .join(".huskies") .join("specs") .join("00_CONTEXT.md"); assert!(context_path.exists()); assert_eq!( std::fs::read_to_string(&context_path).unwrap(), "# Context content" ); // Wizard should have advanced. let state = WizardState::load(dir.path()).unwrap(); assert_eq!(state.steps[1].status, StepStatus::Confirmed); assert_eq!(state.current_step_index(), 2); } #[test] fn wizard_confirm_does_not_overwrite_existing_file() { let dir = TempDir::new().unwrap(); let ctx = setup(&dir); // Pre-create the specs directory and file. let specs_dir = dir.path().join(".huskies").join("specs"); std::fs::create_dir_all(&specs_dir).unwrap(); let context_path = specs_dir.join("00_CONTEXT.md"); std::fs::write(&context_path, "original content").unwrap(); // Stage and confirm — existing file should NOT be overwritten. tool_wizard_generate(&serde_json::json!({"content": "new content"}), &ctx).unwrap(); let result = tool_wizard_confirm(&ctx).unwrap(); assert!(result.contains("already exists")); assert_eq!( std::fs::read_to_string(&context_path).unwrap(), "original content" ); } #[test] fn wizard_skip_advances_wizard() { let dir = TempDir::new().unwrap(); let ctx = setup(&dir); let result = tool_wizard_skip(&ctx).unwrap(); assert!(result.contains("skipped")); let state = WizardState::load(dir.path()).unwrap(); assert_eq!(state.steps[1].status, StepStatus::Skipped); assert_eq!(state.current_step_index(), 2); } #[test] fn wizard_retry_resets_to_pending() { let dir = TempDir::new().unwrap(); let ctx = setup(&dir); // Stage content first. tool_wizard_generate(&serde_json::json!({"content": "some content"}), &ctx).unwrap(); let result = tool_wizard_retry(&ctx).unwrap(); assert!(result.contains("reset")); let state = WizardState::load(dir.path()).unwrap(); assert_eq!(state.steps[1].status, StepStatus::Pending); assert!(state.steps[1].content.is_none()); } #[test] fn wizard_complete_returns_done_message() { let dir = TempDir::new().unwrap(); let ctx = setup(&dir); // Skip all remaining steps. for _ in 0..5 { tool_wizard_skip(&ctx).unwrap(); } let result = tool_wizard_status(&ctx).unwrap(); assert!(result.contains("complete")); } #[test] fn format_wizard_state_shows_all_steps() { let mut state = WizardState::default(); state.steps[0].status = StepStatus::Confirmed; let output = format_wizard_state(&state); assert!(output.contains("✓")); assert!(output.contains("Scaffold")); assert!(output.contains("← current")); } #[test] fn is_bare_project_detects_empty_dir() { let dir = TempDir::new().unwrap(); assert!(is_bare_project(dir.path())); } #[test] fn is_bare_project_detects_scaffold_only_dir() { let dir = TempDir::new().unwrap(); std::fs::create_dir_all(dir.path().join(".huskies")).unwrap(); std::fs::write(dir.path().join("CLAUDE.md"), "# Claude").unwrap(); std::fs::write(dir.path().join("README.md"), "# Readme").unwrap(); std::fs::create_dir_all(dir.path().join("script")).unwrap(); assert!(is_bare_project(dir.path())); } #[test] fn is_bare_project_false_when_source_files_exist() { let dir = TempDir::new().unwrap(); std::fs::create_dir_all(dir.path().join(".huskies")).unwrap(); std::fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap(); assert!(!is_bare_project(dir.path())); } #[test] fn is_bare_project_false_with_src_directory() { let dir = TempDir::new().unwrap(); std::fs::create_dir_all(dir.path().join("src")).unwrap(); assert!(!is_bare_project(dir.path())); } #[test] fn generation_hint_bare_context_asks_user() { let dir = TempDir::new().unwrap(); // Bare project — only scaffolding std::fs::create_dir_all(dir.path().join(".huskies")).unwrap(); let hint = generation_hint(WizardStep::Context, dir.path()); assert!(hint.contains("bare project")); assert!(hint.contains("Ask the user")); } #[test] fn generation_hint_bare_stack_asks_user() { let dir = TempDir::new().unwrap(); std::fs::create_dir_all(dir.path().join(".huskies")).unwrap(); let hint = generation_hint(WizardStep::Stack, dir.path()); assert!(hint.contains("bare project")); assert!(hint.contains("Ask the user")); } #[test] fn generation_hint_bare_test_script_references_stack() { let dir = TempDir::new().unwrap(); std::fs::create_dir_all(dir.path().join(".huskies")).unwrap(); let hint = generation_hint(WizardStep::TestScript, dir.path()); assert!(hint.contains("bare project")); assert!(hint.contains("STACK.md")); } #[test] fn generation_hint_bare_release_script_references_stack() { let dir = TempDir::new().unwrap(); std::fs::create_dir_all(dir.path().join(".huskies")).unwrap(); let hint = generation_hint(WizardStep::ReleaseScript, dir.path()); assert!(hint.contains("bare project")); assert!(hint.contains("STACK.md")); } #[test] fn generation_hint_bare_test_coverage_references_stack() { let dir = TempDir::new().unwrap(); std::fs::create_dir_all(dir.path().join(".huskies")).unwrap(); let hint = generation_hint(WizardStep::TestCoverage, dir.path()); assert!(hint.contains("bare project")); assert!(hint.contains("STACK.md")); } #[test] fn generation_hint_existing_project_reads_code() { let dir = TempDir::new().unwrap(); std::fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap(); let hint = generation_hint(WizardStep::Context, dir.path()); assert!(hint.contains("Read the project")); assert!(!hint.contains("bare project")); } #[test] fn generation_hint_existing_project_test_script_detects_cargo() { let dir = TempDir::new().unwrap(); std::fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap(); let hint = generation_hint(WizardStep::TestScript, dir.path()); assert!(hint.contains("cargo nextest")); assert!(!hint.contains("bare project")); } }