storkit: merge 432_story_complete_setup_wizard_with_mcp_tools_and_agent_driven_file_generation
This commit is contained in:
@@ -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,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String> {
|
||||
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<crate::agents::AgentPool>,
|
||||
ambient_rooms: &'a Arc<Mutex<HashSet<String>>>,
|
||||
) -> 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"));
|
||||
}
|
||||
}
|
||||
@@ -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<Value>) -> 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]
|
||||
|
||||
@@ -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<std::path::PathBuf> {
|
||||
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<bool, String> {
|
||||
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<String, String> {
|
||||
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<String, String> {
|
||||
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<String, String> {
|
||||
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<String, String> {
|
||||
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<String, String> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
Reference in New Issue
Block a user