storkit: merge 433_story_setup_wizard_interviews_user_on_bare_projects_with_no_existing_code
This commit is contained in:
@@ -180,7 +180,7 @@ pub fn commands() -> &'static [BotCommand] {
|
|||||||
},
|
},
|
||||||
BotCommand {
|
BotCommand {
|
||||||
name: "setup",
|
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,
|
handler: setup::handle_setup,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -9,7 +9,9 @@
|
|||||||
//! - `setup retry` — discard staged content and reset the current step
|
//! - `setup retry` — discard staged content and reset the current step
|
||||||
|
|
||||||
use super::CommandContext;
|
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};
|
use crate::io::wizard::{format_wizard_state, StepStatus, WizardState};
|
||||||
|
|
||||||
pub(super) fn handle_setup(ctx: &CommandContext) -> Option<String> {
|
pub(super) fn handle_setup(ctx: &CommandContext) -> Option<String> {
|
||||||
@@ -17,15 +19,45 @@ pub(super) fn handle_setup(ctx: &CommandContext) -> Option<String> {
|
|||||||
|
|
||||||
match sub.as_str() {
|
match sub.as_str() {
|
||||||
"" => Some(wizard_status_reply(ctx)),
|
"" => Some(wizard_status_reply(ctx)),
|
||||||
|
"generate" => Some(wizard_generate_reply(ctx)),
|
||||||
"confirm" => Some(wizard_confirm_reply(ctx)),
|
"confirm" => Some(wizard_confirm_reply(ctx)),
|
||||||
"skip" => Some(wizard_skip_reply(ctx)),
|
"skip" => Some(wizard_skip_reply(ctx)),
|
||||||
"retry" => Some(wizard_retry_reply(ctx)),
|
"retry" => Some(wizard_retry_reply(ctx)),
|
||||||
_ => Some(format!(
|
_ => 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).
|
/// Compose a status reply for the `setup` command (no args).
|
||||||
fn wizard_status_reply(ctx: &CommandContext) -> String {
|
fn wizard_status_reply(ctx: &CommandContext) -> String {
|
||||||
match WizardState::load(ctx.project_root) {
|
match WizardState::load(ctx.project_root) {
|
||||||
@@ -263,4 +295,45 @@ mod tests {
|
|||||||
assert!(result.contains("Unknown sub-command"));
|
assert!(result.contains("Unknown sub-command"));
|
||||||
assert!(result.contains("Usage"));
|
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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ pub(super) fn tool_wizard_generate(args: &Value, ctx: &AppContext) -> Result<Str
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Return true if the project directory has no meaningful source files.
|
/// Return true if the project directory has no meaningful source files.
|
||||||
fn is_bare_project(project_root: &Path) -> bool {
|
pub(crate) fn is_bare_project(project_root: &Path) -> bool {
|
||||||
std::fs::read_dir(project_root)
|
std::fs::read_dir(project_root)
|
||||||
.ok()
|
.ok()
|
||||||
.map(|entries| {
|
.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.
|
/// 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);
|
let bare = is_bare_project(project_root);
|
||||||
|
|
||||||
match step {
|
match step {
|
||||||
@@ -214,30 +214,54 @@ fn generation_hint(step: WizardStep, project_root: &Path) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
WizardStep::TestScript => {
|
WizardStep::TestScript => {
|
||||||
let has_cargo = project_root.join("Cargo.toml").exists();
|
if bare {
|
||||||
let has_pkg = project_root.join("package.json").exists();
|
"This is a bare project with no existing code. Read the STACK.md generated \
|
||||||
let has_pnpm = project_root.join("pnpm-lock.yaml").exists();
|
in the previous step (or ask the user about their stack if it was skipped) \
|
||||||
let mut cmds = Vec::new();
|
and generate a `script/test` shell script (#!/usr/bin/env bash, set -euo pipefail) \
|
||||||
if has_cargo {
|
with appropriate test commands for their chosen language and framework."
|
||||||
cmds.push("cargo nextest run");
|
.to_string()
|
||||||
}
|
|
||||||
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 {
|
} else {
|
||||||
format!(
|
let has_cargo = project_root.join("Cargo.toml").exists();
|
||||||
"Generate a `script/test` shell script (#!/usr/bin/env bash, set -euo pipefail) that runs: {}",
|
let has_pkg = project_root.join("package.json").exists();
|
||||||
cmds.join(", ")
|
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 => {
|
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 => {
|
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(),
|
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("Scaffold"));
|
||||||
assert!(output.contains("← current"));
|
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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user