From fec417cb166e1d2257d3bfe9886b03979b051e5c Mon Sep 17 00:00:00 2001 From: dave Date: Sun, 29 Mar 2026 00:42:57 +0000 Subject: [PATCH] storkit: merge 433_story_setup_wizard_interviews_user_on_bare_projects_with_no_existing_code --- server/src/chat/commands/mod.rs | 2 +- server/src/chat/commands/setup.rs | 77 +++++++++++++- server/src/http/mcp/wizard_tools.rs | 159 ++++++++++++++++++++++++---- 3 files changed, 215 insertions(+), 23 deletions(-) diff --git a/server/src/chat/commands/mod.rs b/server/src/chat/commands/mod.rs index 92f32593..9cb4cf59 100644 --- a/server/src/chat/commands/mod.rs +++ b/server/src/chat/commands/mod.rs @@ -180,7 +180,7 @@ pub fn commands() -> &'static [BotCommand] { }, BotCommand { name: "setup", - description: "Show setup wizard progress; or `setup confirm` / `setup skip` / `setup retry` to drive the wizard from chat", + description: "Show setup wizard progress; or `setup generate` / `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 index ac9cf13e..74e75a18 100644 --- a/server/src/chat/commands/setup.rs +++ b/server/src/chat/commands/setup.rs @@ -9,7 +9,9 @@ //! - `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::http::mcp::wizard_tools::{ + generation_hint, 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 { @@ -17,15 +19,45 @@ pub(super) fn handle_setup(ctx: &CommandContext) -> Option { match sub.as_str() { "" => Some(wizard_status_reply(ctx)), + "generate" => Some(wizard_generate_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`." + "Unknown sub-command `{sub}`. Usage: `setup`, `setup generate`, `setup confirm`, `setup skip`, `setup retry`." )), } } +/// Mark the current step as generating and return the generation hint. +/// +/// This mirrors `wizard_generate` (with no content) from the MCP tools, making +/// the interview flow accessible from chat transports (Matrix, Slack, WhatsApp). +fn wizard_generate_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; + + state.set_step_status(step, StepStatus::Generating, None); + if let Err(e) = state.save(root) { + return format!("Failed to save wizard state: {e}"); + } + + let hint = generation_hint(step, root); + format!( + "Step '{}' marked as generating.\n\n{hint}\n\nOnce you have the content, stage it via the API and then run `setup confirm` to write it to disk.", + step.label() + ) +} + /// Compose a status reply for the `setup` command (no args). fn wizard_status_reply(ctx: &CommandContext) -> String { match WizardState::load(ctx.project_root) { @@ -263,4 +295,45 @@ mod tests { assert!(result.contains("Unknown sub-command")); assert!(result.contains("Usage")); } + + #[test] + fn setup_generate_marks_generating_and_returns_hint() { + 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(4006)); + let rooms = Arc::new(Mutex::new(HashSet::new())); + let ctx = make_ctx("generate", dir.path(), &agents, &rooms); + let result = handle_setup(&ctx).unwrap(); + assert!(result.contains("generating")); + let state = WizardState::load(dir.path()).unwrap(); + assert_eq!( + state.steps[1].status, + crate::io::wizard::StepStatus::Generating + ); + } + + #[test] + fn setup_generate_bare_project_asks_user() { + let dir = TempDir::new().unwrap(); + // Bare project — only scaffolding files + 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(4007)); + let rooms = Arc::new(Mutex::new(HashSet::new())); + let ctx = make_ctx("generate", dir.path(), &agents, &rooms); + let result = handle_setup(&ctx).unwrap(); + assert!(result.contains("bare project")); + assert!(result.contains("Ask the user")); + } + + #[test] + fn setup_generate_no_wizard_returns_error() { + let dir = TempDir::new().unwrap(); + let agents = Arc::new(crate::agents::AgentPool::new_test(4008)); + let rooms = Arc::new(Mutex::new(HashSet::new())); + let ctx = make_ctx("generate", dir.path(), &agents, &rooms); + let result = handle_setup(&ctx).unwrap(); + assert!(result.contains("No wizard active")); + } } diff --git a/server/src/http/mcp/wizard_tools.rs b/server/src/http/mcp/wizard_tools.rs index f1c24f5e..91e2fc95 100644 --- a/server/src/http/mcp/wizard_tools.rs +++ b/server/src/http/mcp/wizard_tools.rs @@ -153,7 +153,7 @@ pub(super) fn tool_wizard_generate(args: &Value, ctx: &AppContext) -> Result bool { +pub(crate) fn is_bare_project(project_root: &Path) -> bool { std::fs::read_dir(project_root) .ok() .map(|entries| { @@ -175,7 +175,7 @@ fn is_bare_project(project_root: &Path) -> bool { } /// Return a generation hint for a step based on the project root. -fn generation_hint(step: WizardStep, project_root: &Path) -> String { +pub(crate) fn generation_hint(step: WizardStep, project_root: &Path) -> String { let bare = is_bare_project(project_root); match step { @@ -214,30 +214,54 @@ fn generation_hint(step: WizardStep, project_root: &Path) -> 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() + 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 { - format!( - "Generate a `script/test` shell script (#!/usr/bin/env bash, set -euo pipefail) that runs: {}", - cmds.join(", ") - ) + 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() + 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 => { - "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() + 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 `storkit init`.".to_string(), } @@ -517,4 +541,99 @@ mod tests { 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(".storkit")).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(".storkit")).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(".storkit")).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(".storkit")).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(".storkit")).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(".storkit")).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(".storkit")).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")); + } }