diff --git a/server/src/chat/commands/mod.rs b/server/src/chat/commands/mod.rs index 66786d0d..92f32593 100644 --- a/server/src/chat/commands/mod.rs +++ b/server/src/chat/commands/mod.rs @@ -13,6 +13,7 @@ mod help; pub(crate) mod loc; mod move_story; mod overview; +mod setup; mod show; mod status; mod timer; @@ -177,6 +178,11 @@ pub fn commands() -> &'static [BotCommand] { description: "Show stories merged to master since the last release tag", handler: unreleased::handle_unreleased, }, + BotCommand { + name: "setup", + description: "Show setup wizard progress; or `setup confirm` / `setup skip` / `setup retry` to drive the wizard from chat", + handler: setup::handle_setup, + }, ] } diff --git a/server/src/chat/commands/setup.rs b/server/src/chat/commands/setup.rs new file mode 100644 index 00000000..ac9cf13e --- /dev/null +++ b/server/src/chat/commands/setup.rs @@ -0,0 +1,266 @@ +//! Handler for the `setup` bot command. +//! +//! Drives the setup wizard from any chat transport (Matrix, Slack, WhatsApp). +//! +//! Usage: +//! - `setup` — show wizard progress and current step instructions +//! - `setup confirm` — confirm the current step (writes staged content to disk) +//! - `setup skip` — skip the current step +//! - `setup retry` — discard staged content and reset the current step + +use super::CommandContext; +use crate::http::mcp::wizard_tools::{is_script_step, step_output_path, write_if_missing}; +use crate::io::wizard::{format_wizard_state, StepStatus, WizardState}; + +pub(super) fn handle_setup(ctx: &CommandContext) -> Option { + let sub = ctx.args.trim().to_ascii_lowercase(); + + match sub.as_str() { + "" => Some(wizard_status_reply(ctx)), + "confirm" => Some(wizard_confirm_reply(ctx)), + "skip" => Some(wizard_skip_reply(ctx)), + "retry" => Some(wizard_retry_reply(ctx)), + _ => Some(format!( + "Unknown sub-command `{sub}`. Usage: `setup`, `setup confirm`, `setup skip`, `setup retry`." + )), + } +} + +/// Compose a status reply for the `setup` command (no args). +fn wizard_status_reply(ctx: &CommandContext) -> String { + match WizardState::load(ctx.project_root) { + Some(state) => format_wizard_state(&state), + None => { + "No setup wizard active. Run `storkit init` in the project root to begin.".to_string() + } + } +} + +/// Confirm the current wizard step, writing any staged content to disk. +fn wizard_confirm_reply(ctx: &CommandContext) -> String { + let root = ctx.project_root; + let mut state = match WizardState::load(root) { + Some(s) => s, + None => return "No wizard active.".to_string(), + }; + if state.completed { + return "Wizard is already complete.".to_string(); + } + + let idx = state.current_step_index(); + let step = state.steps[idx].step; + let content = state.steps[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) { + Ok(true) => format!(" File written: `{}`.", path.display()), + Ok(false) => format!(" File `{}` already exists — skipped.", path.display()), + Err(e) => return format!("Error: {e}"), + } + } else { + String::new() + }; + + if let Err(e) = state.confirm_step(step) { + return format!("Cannot confirm step: {e}"); + } + if let Err(e) = state.save(root) { + return format!("Failed to save wizard state: {e}"); + } + + if state.completed { + format!( + "Step '{}' confirmed.{write_msg}\n\nSetup wizard complete!", + step.label() + ) + } else { + let next = &state.steps[state.current_step_index()]; + format!( + "Step '{}' confirmed.{write_msg}\n\nNext: {} — run `wizard_generate` to begin.", + step.label(), + next.step.label() + ) + } +} + +/// Skip the current wizard step without writing any file. +fn wizard_skip_reply(ctx: &CommandContext) -> String { + let root = ctx.project_root; + let mut state = match WizardState::load(root) { + Some(s) => s, + None => return "No wizard active.".to_string(), + }; + if state.completed { + return "Wizard is already complete.".to_string(); + } + + let idx = state.current_step_index(); + let step = state.steps[idx].step; + + if let Err(e) = state.skip_step(step) { + return format!("Cannot skip step: {e}"); + } + if let Err(e) = state.save(root) { + return format!("Failed to save wizard state: {e}"); + } + + if state.completed { + format!( + "Step '{}' skipped. Setup wizard complete!", + step.label() + ) + } else { + let next = &state.steps[state.current_step_index()]; + format!( + "Step '{}' skipped.\n\nNext: {} — run `wizard_generate` to begin.", + step.label(), + next.step.label() + ) + } +} + +/// Discard staged content and reset the current step to pending. +fn wizard_retry_reply(ctx: &CommandContext) -> String { + let root = ctx.project_root; + let mut state = match WizardState::load(root) { + Some(s) => s, + None => return "No wizard active.".to_string(), + }; + if state.completed { + return "Wizard is already complete.".to_string(); + } + + let idx = state.current_step_index(); + let step = state.steps[idx].step; + + if let Some(s) = state.steps.iter_mut().find(|s| s.step == step) { + s.status = StepStatus::Pending; + s.content = None; + } + if let Err(e) = state.save(root) { + return format!("Failed to save wizard state: {e}"); + } + + format!( + "Step '{}' reset to pending. Run `wizard_generate` to regenerate content.", + step.label() + ) +} + +// ── tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::io::wizard::WizardState; + use std::collections::HashSet; + use std::sync::{Arc, Mutex}; + use tempfile::TempDir; + + fn make_ctx<'a>( + args: &'a str, + project_root: &'a std::path::Path, + agents: &'a Arc, + ambient_rooms: &'a Arc>>, + ) -> CommandContext<'a> { + CommandContext { + bot_name: "Bot", + args, + project_root, + agents, + ambient_rooms, + room_id: "!test:example.com", + } + } + + #[test] + fn setup_no_wizard_returns_helpful_message() { + let dir = TempDir::new().unwrap(); + let agents = Arc::new(crate::agents::AgentPool::new_test(4000)); + let rooms = Arc::new(Mutex::new(HashSet::new())); + let ctx = make_ctx("", dir.path(), &agents, &rooms); + let result = handle_setup(&ctx).unwrap(); + assert!(result.contains("storkit init")); + } + + #[test] + fn setup_with_wizard_shows_status() { + let dir = TempDir::new().unwrap(); + std::fs::create_dir_all(dir.path().join(".storkit")).unwrap(); + WizardState::init_if_missing(dir.path()); + let agents = Arc::new(crate::agents::AgentPool::new_test(4001)); + let rooms = Arc::new(Mutex::new(HashSet::new())); + let ctx = make_ctx("", dir.path(), &agents, &rooms); + let result = handle_setup(&ctx).unwrap(); + assert!(result.contains("Setup wizard")); + } + + #[test] + fn setup_skip_advances_wizard() { + let dir = TempDir::new().unwrap(); + std::fs::create_dir_all(dir.path().join(".storkit")).unwrap(); + WizardState::init_if_missing(dir.path()); + let agents = Arc::new(crate::agents::AgentPool::new_test(4002)); + let rooms = Arc::new(Mutex::new(HashSet::new())); + let ctx = make_ctx("skip", dir.path(), &agents, &rooms); + let result = handle_setup(&ctx).unwrap(); + assert!(result.contains("skipped")); + let state = WizardState::load(dir.path()).unwrap(); + assert_eq!(state.current_step_index(), 2); + } + + #[test] + fn setup_confirm_advances_wizard() { + let dir = TempDir::new().unwrap(); + std::fs::create_dir_all(dir.path().join(".storkit")).unwrap(); + WizardState::init_if_missing(dir.path()); + let agents = Arc::new(crate::agents::AgentPool::new_test(4003)); + let rooms = Arc::new(Mutex::new(HashSet::new())); + let ctx = make_ctx("confirm", dir.path(), &agents, &rooms); + let result = handle_setup(&ctx).unwrap(); + assert!(result.contains("confirmed")); + let state = WizardState::load(dir.path()).unwrap(); + assert_eq!(state.current_step_index(), 2); + } + + #[test] + fn setup_retry_resets_step() { + let dir = TempDir::new().unwrap(); + std::fs::create_dir_all(dir.path().join(".storkit")).unwrap(); + WizardState::init_if_missing(dir.path()); + // Stage some content first. + { + let mut state = WizardState::load(dir.path()).unwrap(); + state.set_step_status( + crate::io::wizard::WizardStep::Context, + crate::io::wizard::StepStatus::AwaitingConfirmation, + Some("content".to_string()), + ); + state.save(dir.path()).unwrap(); + } + let agents = Arc::new(crate::agents::AgentPool::new_test(4004)); + let rooms = Arc::new(Mutex::new(HashSet::new())); + let ctx = make_ctx("retry", dir.path(), &agents, &rooms); + let result = handle_setup(&ctx).unwrap(); + assert!(result.contains("reset")); + let state = WizardState::load(dir.path()).unwrap(); + assert_eq!( + state.steps[1].status, + crate::io::wizard::StepStatus::Pending + ); + } + + #[test] + fn setup_unknown_sub_command_returns_usage() { + let dir = TempDir::new().unwrap(); + let agents = Arc::new(crate::agents::AgentPool::new_test(4005)); + let rooms = Arc::new(Mutex::new(HashSet::new())); + let ctx = make_ctx("foobar", dir.path(), &agents, &rooms); + let result = handle_setup(&ctx).unwrap(); + assert!(result.contains("Unknown sub-command")); + assert!(result.contains("Usage")); + } +} diff --git a/server/src/http/mcp/mod.rs b/server/src/http/mcp/mod.rs index a08930e6..1fe86fb6 100644 --- a/server/src/http/mcp/mod.rs +++ b/server/src/http/mcp/mod.rs @@ -14,8 +14,9 @@ pub mod git_tools; pub mod merge_tools; pub mod qa_tools; pub mod shell_tools; -pub mod story_tools; pub mod status_tools; +pub mod story_tools; +pub mod wizard_tools; /// Returns true when the Accept header includes text/event-stream. fn wants_sse(req: &Request) -> bool { @@ -1164,6 +1165,51 @@ fn handle_tools_list(id: Option) -> JsonRpcResponse { }, "required": ["file_path"] } + }, + { + "name": "wizard_status", + "description": "Return the current setup wizard state: which step is active, and which are done/skipped/pending. Use this to inspect progress before calling wizard_generate, wizard_confirm, wizard_skip, or wizard_retry.", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "wizard_generate", + "description": "Drive content generation for the current wizard step. Call with no arguments to mark the step as 'generating' and receive a hint about what to produce. Call again with a 'content' argument (the full file body you generated) to stage it for review. Content is NOT written to disk until wizard_confirm is called.", + "inputSchema": { + "type": "object", + "properties": { + "content": { + "type": "string", + "description": "The generated file content to stage for the current step. Omit to receive a generation hint and mark the step as generating." + } + } + } + }, + { + "name": "wizard_confirm", + "description": "Confirm the current wizard step: writes any staged content to disk (only if the target file does not already exist) and advances to the next step. Existing files are never overwritten.", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "wizard_skip", + "description": "Skip the current wizard step without writing any file. Use when a step does not apply to this project.", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "wizard_retry", + "description": "Discard any staged content for the current wizard step and reset it to pending so it can be regenerated. Use when the generated content needs improvement.", + "inputSchema": { + "type": "object", + "properties": {} + } } ] }), @@ -1258,6 +1304,12 @@ async fn handle_tools_call( "status" => status_tools::tool_status(&args, ctx).await, // File line count "loc_file" => diagnostics::tool_loc_file(&args, ctx), + // Setup wizard tools + "wizard_status" => wizard_tools::tool_wizard_status(ctx), + "wizard_generate" => wizard_tools::tool_wizard_generate(&args, ctx), + "wizard_confirm" => wizard_tools::tool_wizard_confirm(ctx), + "wizard_skip" => wizard_tools::tool_wizard_skip(ctx), + "wizard_retry" => wizard_tools::tool_wizard_retry(ctx), _ => Err(format!("Unknown tool: {tool_name}")), }; @@ -1376,7 +1428,7 @@ mod tests { assert!(names.contains(&"git_log")); assert!(names.contains(&"status")); assert!(names.contains(&"loc_file")); - assert_eq!(tools.len(), 51); + assert_eq!(tools.len(), 56); } #[test] diff --git a/server/src/http/mcp/wizard_tools.rs b/server/src/http/mcp/wizard_tools.rs new file mode 100644 index 00000000..183249b2 --- /dev/null +++ b/server/src/http/mcp/wizard_tools.rs @@ -0,0 +1,476 @@ +//! 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 `.storkit/` directory structure and is handled by +/// `storkit 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(".storkit") + .join("specs") + .join("00_CONTEXT.md"), + ), + WizardStep::Stack => Some( + project_root + .join(".storkit") + .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 `storkit 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 a generation hint for a step based on the project root. +fn generation_hint(step: WizardStep, project_root: &Path) -> String { + match step { + WizardStep::Context => { + "Read the project source tree and generate a `.storkit/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 => { + "Read the project source tree and generate a `.storkit/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 => { + 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 => { + "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 => { + "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 `storkit 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(".storkit")).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(".storkit")).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(".storkit") + .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(".storkit").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")); + } +} diff --git a/server/src/io/wizard.rs b/server/src/io/wizard.rs index 4c284a99..3f35acc9 100644 --- a/server/src/io/wizard.rs +++ b/server/src/io/wizard.rs @@ -1,4 +1,5 @@ use serde::{Deserialize, Serialize}; +use serde_json; use std::fs; use std::path::Path; @@ -190,6 +191,67 @@ impl WizardState { } } +/// Format a `WizardState` as a human-readable Markdown summary for display in +/// bot messages and MCP responses. +pub fn format_wizard_state(state: &WizardState) -> String { + let total = state.steps.len(); + let current_idx = state.current_step_index(); + + let header = if state.completed { + format!("**Setup wizard — complete** ({total}/{total} steps done)") + } else { + format!("**Setup wizard — step {}/{}**", current_idx + 1, total) + }; + + let mut lines = vec![header, String::new()]; + + for (i, step) in state.steps.iter().enumerate() { + let marker = match step.status { + StepStatus::Confirmed => "✓", + StepStatus::Skipped => "~", + StepStatus::Generating => "⟳", + StepStatus::AwaitingConfirmation => "?", + StepStatus::Pending => "○", + }; + let is_current = !state.completed && i == current_idx; + let suffix = if is_current { " ← current" } else { "" }; + let status_str = serde_json::to_value(&step.status) + .ok() + .and_then(|v| v.as_str().map(String::from)) + .unwrap_or_default(); + lines.push(format!( + " {} {} ({}){suffix}", + marker, + step.step.label(), + status_str + )); + } + + if state.completed { + lines.push(String::new()); + lines.push("All steps done. Your project is fully configured.".to_string()); + } else { + let current = &state.steps[current_idx]; + lines.push(String::new()); + lines.push(format!("**Current:** {}", current.step.label())); + let hint = match current.status { + StepStatus::Pending => { + "Run `wizard_generate` to generate content for this step.".to_string() + } + StepStatus::Generating => "Agent is generating content…".to_string(), + StepStatus::AwaitingConfirmation => { + "Content ready for review. Run `wizard_confirm` to write to disk, `wizard_retry` to regenerate, or `wizard_skip` to skip.".to_string() + } + StepStatus::Confirmed | StepStatus::Skipped => String::new(), + }; + if !hint.is_empty() { + lines.push(hint); + } + } + + lines.join("\n") +} + #[cfg(test)] mod tests { use super::*;