2026-03-28 14:21:13 +00:00
|
|
|
//! 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;
|
2026-03-29 00:42:57 +00:00
|
|
|
use crate::http::mcp::wizard_tools::{
|
|
|
|
|
generation_hint, is_script_step, step_output_path, write_if_missing,
|
|
|
|
|
};
|
2026-03-28 14:21:13 +00:00
|
|
|
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)),
|
2026-03-29 00:42:57 +00:00
|
|
|
"generate" => Some(wizard_generate_reply(ctx)),
|
2026-03-28 14:21:13 +00:00
|
|
|
"confirm" => Some(wizard_confirm_reply(ctx)),
|
|
|
|
|
"skip" => Some(wizard_skip_reply(ctx)),
|
|
|
|
|
"retry" => Some(wizard_retry_reply(ctx)),
|
|
|
|
|
_ => Some(format!(
|
2026-03-29 00:42:57 +00:00
|
|
|
"Unknown sub-command `{sub}`. Usage: `setup`, `setup generate`, `setup confirm`, `setup skip`, `setup retry`."
|
2026-03-28 14:21:13 +00:00
|
|
|
)),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-29 00:42:57 +00:00
|
|
|
/// 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()
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 14:21:13 +00:00
|
|
|
/// 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 => {
|
2026-04-03 16:12:52 +01:00
|
|
|
"No setup wizard active. Run `huskies init` in the project root to begin.".to_string()
|
2026-03-28 14:21:13 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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();
|
2026-04-03 16:12:52 +01:00
|
|
|
assert!(result.contains("huskies init"));
|
2026-03-28 14:21:13 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn setup_with_wizard_shows_status() {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
2026-04-03 16:12:52 +01:00
|
|
|
std::fs::create_dir_all(dir.path().join(".huskies")).unwrap();
|
2026-03-28 14:21:13 +00:00
|
|
|
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();
|
2026-04-03 16:12:52 +01:00
|
|
|
std::fs::create_dir_all(dir.path().join(".huskies")).unwrap();
|
2026-03-28 14:21:13 +00:00
|
|
|
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();
|
2026-04-03 16:12:52 +01:00
|
|
|
std::fs::create_dir_all(dir.path().join(".huskies")).unwrap();
|
2026-03-28 14:21:13 +00:00
|
|
|
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();
|
2026-04-03 16:12:52 +01:00
|
|
|
std::fs::create_dir_all(dir.path().join(".huskies")).unwrap();
|
2026-03-28 14:21:13 +00:00
|
|
|
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"));
|
|
|
|
|
}
|
2026-03-29 00:42:57 +00:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn setup_generate_marks_generating_and_returns_hint() {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
2026-04-03 16:12:52 +01:00
|
|
|
std::fs::create_dir_all(dir.path().join(".huskies")).unwrap();
|
2026-03-29 00:42:57 +00:00
|
|
|
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
|
2026-04-03 16:12:52 +01:00
|
|
|
std::fs::create_dir_all(dir.path().join(".huskies")).unwrap();
|
2026-03-29 00:42:57 +00:00
|
|
|
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"));
|
|
|
|
|
}
|
2026-03-28 14:21:13 +00:00
|
|
|
}
|