2026-03-28 14:21:13 +00:00
//! 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 \n Once 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 ( ) ,
) )
}
2026-03-28 15:17:42 +00:00
/// Return true if the project directory has no meaningful source files.
fn is_bare_project ( project_root : & Path ) -> bool {
let dominated_by_storkit = std ::fs ::read_dir ( project_root )
. ok ( )
. map ( | entries | {
let names : Vec < String > = entries
. filter_map ( | e | e . ok ( ) )
. map ( | e | e . file_name ( ) . to_string_lossy ( ) . to_string ( ) )
. collect ( ) ;
// A bare project only has storkit scaffolding and no real code
names . iter ( ) . all ( | n | {
n . starts_with ( '.' )
| | n = = " CLAUDE.md "
| | n = = " LICENSE "
| | n = = " README.md "
| | n = = " script "
| | n = = " store.json "
} )
} )
. unwrap_or ( true ) ;
dominated_by_storkit
}
2026-03-28 14:21:13 +00:00
/// Return a generation hint for a step based on the project root.
fn generation_hint ( step : WizardStep , project_root : & Path ) -> String {
2026-03-28 15:17:42 +00:00
let bare = is_bare_project ( project_root ) ;
2026-03-28 14:21:13 +00:00
match step {
WizardStep ::Context = > {
2026-03-28 15:17:42 +00:00
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 `.storkit/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 `.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 ( )
}
2026-03-28 14:21:13 +00:00
}
WizardStep ::Stack = > {
2026-03-28 15:17:42 +00:00
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 `.storkit/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 `.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 ( )
}
2026-03-28 14:21:13 +00:00
}
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 \n Setup wizard complete! All steps done. " ,
step . label ( )
) )
} else {
let next = & state . steps [ next_idx ] ;
Ok ( format! (
" Step ' {} ' confirmed. {write_msg} \n \n Next: {} — 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 \n Next: {} — 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 " ) ) ;
}
}