storkit: merge 429_story_interactive_project_setup_wizard_for_new_storkit_projects

This commit is contained in:
dave
2026-03-28 13:26:29 +00:00
parent 9feed0f882
commit 0b50c66caa
10 changed files with 1217 additions and 59 deletions
+5 -1
View File
@@ -14,6 +14,7 @@ pub mod settings;
pub mod workflow;
pub mod project;
pub mod wizard;
pub mod ws;
use agents::AgentsApi;
@@ -131,6 +132,7 @@ type ApiTuple = (
SettingsApi,
HealthApi,
BotCommandApi,
wizard::WizardApi,
);
type ApiService = OpenApiService<ApiTuple, ()>;
@@ -147,6 +149,7 @@ pub fn build_openapi_service(ctx: Arc<AppContext>) -> (ApiService, ApiService) {
SettingsApi { ctx: ctx.clone() },
HealthApi,
BotCommandApi { ctx: ctx.clone() },
wizard::WizardApi { ctx: ctx.clone() },
);
let api_service =
@@ -161,7 +164,8 @@ pub fn build_openapi_service(ctx: Arc<AppContext>) -> (ApiService, ApiService) {
AgentsApi { ctx: ctx.clone() },
SettingsApi { ctx: ctx.clone() },
HealthApi,
BotCommandApi { ctx },
BotCommandApi { ctx: ctx.clone() },
wizard::WizardApi { ctx },
);
let docs_service =
+303
View File
@@ -0,0 +1,303 @@
use crate::http::context::{AppContext, OpenApiResult, bad_request, not_found};
use crate::io::wizard::{StepStatus, WizardState, WizardStep};
use poem_openapi::{Object, OpenApi, Tags, param::Path, payload::Json};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
#[derive(Tags)]
enum WizardTags {
Wizard,
}
/// Response for a single wizard step.
#[derive(Serialize, Object)]
struct StepResponse {
step: String,
label: String,
status: String,
#[oai(skip_serializing_if = "Option::is_none")]
content: Option<String>,
}
/// Full wizard state response.
#[derive(Serialize, Object)]
struct WizardResponse {
steps: Vec<StepResponse>,
current_step_index: usize,
completed: bool,
}
/// Request body for confirming/skipping a step or submitting content.
#[derive(Deserialize, Object)]
struct StepActionPayload {
/// Optional content to store for the step (e.g., generated spec).
#[oai(skip_serializing_if = "Option::is_none")]
content: Option<String>,
}
impl From<&WizardState> for WizardResponse {
fn from(state: &WizardState) -> Self {
WizardResponse {
steps: state
.steps
.iter()
.map(|s| StepResponse {
step: serde_json::to_value(s.step)
.ok()
.and_then(|v| v.as_str().map(String::from))
.unwrap_or_default(),
label: s.step.label().to_string(),
status: serde_json::to_value(&s.status)
.ok()
.and_then(|v| v.as_str().map(String::from))
.unwrap_or_default(),
content: s.content.clone(),
})
.collect(),
current_step_index: state.current_step_index(),
completed: state.completed,
}
}
}
fn parse_step(step_str: &str) -> Result<WizardStep, poem::Error> {
let quoted = format!("\"{step_str}\"");
serde_json::from_str::<WizardStep>(&quoted)
.map_err(|_| not_found(format!("Unknown wizard step: {step_str}")))
}
pub struct WizardApi {
pub ctx: Arc<AppContext>,
}
#[OpenApi(tag = "WizardTags::Wizard")]
impl WizardApi {
/// Get the current wizard state.
///
/// Returns the full setup wizard progress including all steps and their
/// statuses. Returns 404 if no wizard is active.
#[oai(path = "/wizard", method = "get")]
async fn get_wizard_state(&self) -> OpenApiResult<Json<WizardResponse>> {
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
let state =
WizardState::load(&root).ok_or_else(|| not_found("No wizard active".to_string()))?;
Ok(Json(WizardResponse::from(&state)))
}
/// Set a step's content and mark it as awaiting confirmation.
///
/// Used after the agent generates content for a step. The content is
/// stored for preview and the step is marked as awaiting user confirmation.
#[oai(path = "/wizard/step/:step/content", method = "put")]
async fn set_step_content(
&self,
step: Path<String>,
payload: Json<StepActionPayload>,
) -> OpenApiResult<Json<WizardResponse>> {
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
let wizard_step = parse_step(&step.0)?;
let mut state =
WizardState::load(&root).ok_or_else(|| not_found("No wizard active".to_string()))?;
state.set_step_status(
wizard_step,
StepStatus::AwaitingConfirmation,
payload.0.content,
);
state.save(&root).map_err(bad_request)?;
Ok(Json(WizardResponse::from(&state)))
}
/// Confirm a step and advance to the next.
///
/// The step must be the current active step. Returns the updated wizard state.
#[oai(path = "/wizard/step/:step/confirm", method = "post")]
async fn confirm_step(&self, step: Path<String>) -> OpenApiResult<Json<WizardResponse>> {
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
let wizard_step = parse_step(&step.0)?;
let mut state =
WizardState::load(&root).ok_or_else(|| not_found("No wizard active".to_string()))?;
state.confirm_step(wizard_step).map_err(bad_request)?;
state.save(&root).map_err(bad_request)?;
Ok(Json(WizardResponse::from(&state)))
}
/// Skip a step and advance to the next.
///
/// The step must be the current active step.
#[oai(path = "/wizard/step/:step/skip", method = "post")]
async fn skip_step(&self, step: Path<String>) -> OpenApiResult<Json<WizardResponse>> {
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
let wizard_step = parse_step(&step.0)?;
let mut state =
WizardState::load(&root).ok_or_else(|| not_found("No wizard active".to_string()))?;
state.skip_step(wizard_step).map_err(bad_request)?;
state.save(&root).map_err(bad_request)?;
Ok(Json(WizardResponse::from(&state)))
}
/// Mark a step as generating (agent is working on it).
#[oai(path = "/wizard/step/:step/generating", method = "post")]
async fn mark_generating(&self, step: Path<String>) -> OpenApiResult<Json<WizardResponse>> {
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
let wizard_step = parse_step(&step.0)?;
let mut state =
WizardState::load(&root).ok_or_else(|| not_found("No wizard active".to_string()))?;
state.set_step_status(wizard_step, StepStatus::Generating, None);
state.save(&root).map_err(bad_request)?;
Ok(Json(WizardResponse::from(&state)))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::http::context::AppContext;
use poem::http::StatusCode;
use poem::test::TestClient;
use poem_openapi::OpenApiService;
use tempfile::TempDir;
fn setup() -> (TempDir, TestClient<impl poem::Endpoint>) {
let dir = TempDir::new().unwrap();
let root = dir.path().to_path_buf();
std::fs::create_dir_all(root.join(".storkit")).unwrap();
let ctx = Arc::new(AppContext::new_test(root.clone()));
let api = WizardApi { ctx };
let service = OpenApiService::new(api, "test", "0.1.0");
let client = TestClient::new(service);
(dir, client)
}
#[tokio::test]
async fn get_wizard_returns_404_when_no_wizard() {
let (_dir, client) = setup();
let resp = client.get("/wizard").send().await;
resp.assert_status(StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn get_wizard_returns_state_when_active() {
let (dir, client) = setup();
WizardState::init_if_missing(dir.path());
let resp = client.get("/wizard").send().await;
resp.assert_status_is_ok();
let body: serde_json::Value = resp.0.into_body().into_json().await.unwrap();
assert_eq!(body["current_step_index"], 1);
assert!(!body["completed"].as_bool().unwrap());
assert_eq!(body["steps"].as_array().unwrap().len(), 6);
assert_eq!(body["steps"][0]["status"], "confirmed");
}
#[tokio::test]
async fn confirm_step_advances_wizard() {
let (dir, client) = setup();
WizardState::init_if_missing(dir.path());
let resp = client.post("/wizard/step/context/confirm").send().await;
resp.assert_status_is_ok();
let body: serde_json::Value = resp.0.into_body().into_json().await.unwrap();
assert_eq!(body["current_step_index"], 2);
assert_eq!(body["steps"][1]["status"], "confirmed");
}
#[tokio::test]
async fn confirm_wrong_step_returns_error() {
let (dir, client) = setup();
WizardState::init_if_missing(dir.path());
// Try to confirm step 3 (stack) when current is step 2 (context)
let resp = client.post("/wizard/step/stack/confirm").send().await;
resp.assert_status(StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn skip_step_advances_wizard() {
let (dir, client) = setup();
WizardState::init_if_missing(dir.path());
let resp = client.post("/wizard/step/context/skip").send().await;
resp.assert_status_is_ok();
let body: serde_json::Value = resp.0.into_body().into_json().await.unwrap();
assert_eq!(body["steps"][1]["status"], "skipped");
assert_eq!(body["current_step_index"], 2);
}
#[tokio::test]
async fn set_step_content_marks_awaiting_confirmation() {
let (dir, client) = setup();
WizardState::init_if_missing(dir.path());
let resp = client
.put("/wizard/step/context/content")
.body_json(&serde_json::json!({
"content": "# My Project\n\nA great project."
}))
.send()
.await;
resp.assert_status_is_ok();
let body: serde_json::Value = resp.0.into_body().into_json().await.unwrap();
assert_eq!(body["steps"][1]["status"], "awaiting_confirmation");
assert_eq!(
body["steps"][1]["content"],
"# My Project\n\nA great project."
);
}
#[tokio::test]
async fn mark_generating_updates_step() {
let (dir, client) = setup();
WizardState::init_if_missing(dir.path());
let resp = client
.post("/wizard/step/context/generating")
.send()
.await;
resp.assert_status_is_ok();
let body: serde_json::Value = resp.0.into_body().into_json().await.unwrap();
assert_eq!(body["steps"][1]["status"], "generating");
}
#[tokio::test]
async fn unknown_step_returns_404() {
let (dir, client) = setup();
WizardState::init_if_missing(dir.path());
let resp = client
.post("/wizard/step/nonexistent/confirm")
.send()
.await;
resp.assert_status(StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn full_wizard_flow_completes() {
let (dir, client) = setup();
WizardState::init_if_missing(dir.path());
// Steps 2-6 (scaffold is already confirmed)
let steps = ["context", "stack", "test_script", "release_script", "test_coverage"];
for step in steps {
let resp = client
.post(format!("/wizard/step/{step}/confirm"))
.send()
.await;
resp.assert_status_is_ok();
}
// Check final state
let resp = client.get("/wizard").send().await;
resp.assert_status_is_ok();
let body: serde_json::Value = resp.0.into_body().into_json().await.unwrap();
assert!(body["completed"].as_bool().unwrap());
}
}
+47
View File
@@ -2,6 +2,7 @@ use crate::http::context::{AppContext, PermissionDecision};
use crate::http::workflow::{PipelineState, load_pipeline_state};
use crate::io::onboarding;
use crate::io::watcher::WatcherEvent;
use crate::io::wizard;
use crate::llm::chat;
use crate::llm::types::Message;
use crate::log_buffer;
@@ -46,6 +47,16 @@ enum WsRequest {
},
}
/// Serialisable summary of a single wizard step for WebSocket broadcast.
#[derive(Serialize, Clone)]
pub struct WizardStepInfo {
pub step: String,
pub label: String,
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
}
#[derive(Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
/// WebSocket response messages sent by the server.
@@ -125,6 +136,13 @@ enum WsResponse {
OnboardingStatus {
needs_onboarding: bool,
},
/// Sent on connect when a setup wizard is active. Contains the full
/// wizard state so the frontend can render the step-by-step UI.
WizardState {
steps: Vec<WizardStepInfo>,
current_step_index: usize,
completed: bool,
},
/// Streaming token from a `/btw` side question response.
SideQuestionToken {
content: String,
@@ -219,6 +237,35 @@ pub async fn ws_handler(ws: WebSocket, ctx: Data<&Arc<AppContext>>) -> impl poem
});
}
// Push wizard state if an active wizard exists.
{
if let Ok(root) = ctx.state.get_project_root()
&& let Some(ws) = wizard::WizardState::load(&root)
{
let steps: Vec<WizardStepInfo> = ws
.steps
.iter()
.map(|s| WizardStepInfo {
step: serde_json::to_value(s.step)
.ok()
.and_then(|v| v.as_str().map(String::from))
.unwrap_or_default(),
label: s.step.label().to_string(),
status: serde_json::to_value(&s.status)
.ok()
.and_then(|v| v.as_str().map(String::from))
.unwrap_or_default(),
content: s.content.clone(),
})
.collect();
let _ = tx.send(WsResponse::WizardState {
steps,
current_step_index: ws.current_step_index(),
completed: ws.completed,
});
}
}
// Push recent server log entries so the client has history on connect.
{
let entries = log_buffer::global().get_recent_entries(100, None, None);
+1
View File
@@ -289,6 +289,7 @@ fn write_story_kit_gitignore(root: &Path) -> Result<(), String> {
"work/4_merge/",
"logs/",
"token_usage.jsonl",
"wizard_state.json",
];
let gitignore_path = root.join(".storkit").join(".gitignore");
+1
View File
@@ -4,3 +4,4 @@ pub mod search;
pub mod shell;
pub mod story_metadata;
pub mod watcher;
pub mod wizard;
+351
View File
@@ -0,0 +1,351 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
/// Ordered wizard steps for project setup.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WizardStep {
/// Step 1: scaffold .storkit/ directory structure and project.toml
Scaffold,
/// Step 2: generate specs/00_CONTEXT.md
Context,
/// Step 3: generate specs/tech/STACK.md
Stack,
/// Step 4: create script/test
TestScript,
/// Step 5: create script/release
ReleaseScript,
/// Step 6: create script/test_coverage
TestCoverage,
}
impl WizardStep {
/// All steps in order.
pub const ALL: &[WizardStep] = &[
WizardStep::Scaffold,
WizardStep::Context,
WizardStep::Stack,
WizardStep::TestScript,
WizardStep::ReleaseScript,
WizardStep::TestCoverage,
];
/// Human-readable label for this step.
pub fn label(&self) -> &'static str {
match self {
WizardStep::Scaffold => "Scaffold directory structure",
WizardStep::Context => "Generate project context (00_CONTEXT.md)",
WizardStep::Stack => "Generate tech stack spec (STACK.md)",
WizardStep::TestScript => "Create test script (script/test)",
WizardStep::ReleaseScript => "Create release script (script/release)",
WizardStep::TestCoverage => "Create test coverage script (script/test_coverage)",
}
}
/// Zero-based index of this step.
pub fn index(&self) -> usize {
Self::ALL.iter().position(|s| s == self).unwrap_or(0)
}
}
/// Status of an individual wizard step.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StepStatus {
/// Not yet started.
Pending,
/// Agent is generating content for this step.
Generating,
/// Content generated, awaiting user confirmation.
AwaitingConfirmation,
/// User confirmed this step.
Confirmed,
/// User skipped this step.
Skipped,
}
/// State of a single wizard step.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StepState {
pub step: WizardStep,
pub status: StepStatus,
/// The generated content (if any) for preview.
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
}
/// Persistent wizard state, stored in `.storkit/wizard_state.json`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WizardState {
pub steps: Vec<StepState>,
/// True when all steps are confirmed or skipped.
pub completed: bool,
}
impl Default for WizardState {
fn default() -> Self {
Self {
steps: WizardStep::ALL
.iter()
.map(|&step| StepState {
step,
status: StepStatus::Pending,
content: None,
})
.collect(),
completed: false,
}
}
}
impl WizardState {
/// Path to the wizard state file relative to the project root.
fn state_path(project_root: &Path) -> std::path::PathBuf {
project_root.join(".storkit").join("wizard_state.json")
}
/// Load wizard state from disk, or return None if it doesn't exist.
pub fn load(project_root: &Path) -> Option<Self> {
let path = Self::state_path(project_root);
let content = fs::read_to_string(&path).ok()?;
serde_json::from_str(&content).ok()
}
/// Save wizard state to disk.
pub fn save(&self, project_root: &Path) -> Result<(), String> {
let path = Self::state_path(project_root);
let content =
serde_json::to_string_pretty(self).map_err(|e| format!("Serialize error: {e}"))?;
fs::write(&path, content).map_err(|e| format!("Failed to write wizard state: {e}"))
}
/// Create wizard state file if it doesn't already exist.
/// Step 1 (Scaffold) is automatically confirmed since `storkit init`
/// has already run the scaffold.
pub fn init_if_missing(project_root: &Path) {
if Self::load(project_root).is_some() {
return;
}
let mut state = Self::default();
// Scaffold step is done by the time the server starts.
state.steps[0].status = StepStatus::Confirmed;
let _ = state.save(project_root);
}
/// Get the current step index (0-based).
pub fn current_step_index(&self) -> usize {
self.steps
.iter()
.position(|s| !matches!(s.status, StepStatus::Confirmed | StepStatus::Skipped))
.unwrap_or(self.steps.len())
}
/// Mark a step's status and update completion state.
pub fn set_step_status(
&mut self,
step: WizardStep,
status: StepStatus,
content: Option<String>,
) {
if let Some(s) = self.steps.iter_mut().find(|s| s.step == step) {
s.status = status;
if content.is_some() {
s.content = content;
}
}
self.completed = self
.steps
.iter()
.all(|s| matches!(s.status, StepStatus::Confirmed | StepStatus::Skipped));
}
/// Confirm a step. Returns error if the step is not the current one
/// (enforces sequential progression).
pub fn confirm_step(&mut self, step: WizardStep) -> Result<(), String> {
let current_idx = self.current_step_index();
let target_idx = step.index();
if target_idx != current_idx {
return Err(format!(
"Cannot confirm step {:?}: current step is {}",
step, current_idx
));
}
self.set_step_status(step, StepStatus::Confirmed, None);
Ok(())
}
/// Skip a step. Only the current step can be skipped.
pub fn skip_step(&mut self, step: WizardStep) -> Result<(), String> {
let current_idx = self.current_step_index();
let target_idx = step.index();
if target_idx != current_idx {
return Err(format!(
"Cannot skip step {:?}: current step is {}",
step, current_idx
));
}
self.set_step_status(step, StepStatus::Skipped, None);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn setup_project(dir: &TempDir) -> std::path::PathBuf {
let root = dir.path().to_path_buf();
let sk = root.join(".storkit");
std::fs::create_dir_all(&sk).unwrap();
root
}
#[test]
fn default_state_has_all_steps_pending() {
let state = WizardState::default();
assert_eq!(state.steps.len(), 6);
for step in &state.steps {
assert_eq!(step.status, StepStatus::Pending);
}
assert!(!state.completed);
}
#[test]
fn init_if_missing_creates_state_with_scaffold_confirmed() {
let dir = TempDir::new().unwrap();
let root = setup_project(&dir);
WizardState::init_if_missing(&root);
let state = WizardState::load(&root).unwrap();
assert_eq!(state.steps[0].status, StepStatus::Confirmed);
assert_eq!(state.steps[0].step, WizardStep::Scaffold);
// Rest should be pending
for step in &state.steps[1..] {
assert_eq!(step.status, StepStatus::Pending);
}
}
#[test]
fn init_if_missing_does_not_overwrite_existing() {
let dir = TempDir::new().unwrap();
let root = setup_project(&dir);
// Create a custom state
let mut state = WizardState::default();
state.steps[0].status = StepStatus::Confirmed;
state.steps[1].status = StepStatus::Confirmed;
state.save(&root).unwrap();
// init_if_missing should not overwrite
WizardState::init_if_missing(&root);
let loaded = WizardState::load(&root).unwrap();
assert_eq!(loaded.steps[1].status, StepStatus::Confirmed);
}
#[test]
fn save_and_load_round_trip() {
let dir = TempDir::new().unwrap();
let root = setup_project(&dir);
let mut state = WizardState::default();
state.steps[0].status = StepStatus::Confirmed;
state.steps[1].status = StepStatus::AwaitingConfirmation;
state.steps[1].content = Some("# My Project\n\nA cool project.".to_string());
state.save(&root).unwrap();
let loaded = WizardState::load(&root).unwrap();
assert_eq!(loaded.steps[0].status, StepStatus::Confirmed);
assert_eq!(loaded.steps[1].status, StepStatus::AwaitingConfirmation);
assert_eq!(
loaded.steps[1].content.as_deref(),
Some("# My Project\n\nA cool project.")
);
}
#[test]
fn current_step_index_correct() {
let mut state = WizardState::default();
state.steps[0].status = StepStatus::Confirmed;
assert_eq!(state.current_step_index(), 1);
state.steps[1].status = StepStatus::Skipped;
assert_eq!(state.current_step_index(), 2);
}
#[test]
fn confirm_step_enforces_order() {
let mut state = WizardState::default();
state.steps[0].status = StepStatus::Confirmed;
// Can confirm the current step (Context, index 1)
assert!(state.confirm_step(WizardStep::Context).is_ok());
// Cannot confirm a step that's not current
assert!(state.confirm_step(WizardStep::TestScript).is_err());
}
#[test]
fn skip_step_works() {
let mut state = WizardState::default();
state.steps[0].status = StepStatus::Confirmed;
assert!(state.skip_step(WizardStep::Context).is_ok());
assert_eq!(state.steps[1].status, StepStatus::Skipped);
assert_eq!(state.current_step_index(), 2);
}
#[test]
fn completed_when_all_confirmed_or_skipped() {
let mut state = WizardState::default();
for step in WizardStep::ALL {
state.set_step_status(*step, StepStatus::Confirmed, None);
}
assert!(state.completed);
}
#[test]
fn not_completed_when_some_pending() {
let mut state = WizardState::default();
state.set_step_status(WizardStep::Scaffold, StepStatus::Confirmed, None);
assert!(!state.completed);
}
#[test]
fn set_step_status_with_content() {
let mut state = WizardState::default();
state.set_step_status(
WizardStep::Context,
StepStatus::AwaitingConfirmation,
Some("generated content".to_string()),
);
assert_eq!(state.steps[1].status, StepStatus::AwaitingConfirmation);
assert_eq!(
state.steps[1].content.as_deref(),
Some("generated content")
);
}
#[test]
fn load_returns_none_when_no_file() {
let dir = TempDir::new().unwrap();
assert!(WizardState::load(dir.path()).is_none());
}
#[test]
fn step_labels_are_non_empty() {
for step in WizardStep::ALL {
assert!(!step.label().is_empty());
}
}
#[test]
fn step_indices_are_sequential() {
for (i, step) in WizardStep::ALL.iter().enumerate() {
assert_eq!(step.index(), i);
}
}
}
+49 -3
View File
@@ -39,6 +39,8 @@ enum CliDirective {
Help,
/// `--version` / `-V`
Version,
/// `init [PATH]` — scaffold and start the setup wizard.
Init,
/// An unrecognised flag (starts with `-`).
UnknownFlag(String),
/// A positional path argument.
@@ -53,6 +55,7 @@ fn classify_cli_args(args: &[String]) -> CliDirective {
None => CliDirective::None,
Some("--help" | "-h") => CliDirective::Help,
Some("--version" | "-V") => CliDirective::Version,
Some("init") => CliDirective::Init,
Some(a) if a.starts_with('-') => CliDirective::UnknownFlag(a.to_string()),
Some(_) => CliDirective::Path,
}
@@ -79,14 +82,22 @@ async fn main() -> Result<(), std::io::Error> {
let cli_args: Vec<String> = std::env::args().skip(1).collect();
// Handle CLI flags before treating anything as a project path.
let is_init = matches!(classify_cli_args(&cli_args), CliDirective::Init);
match classify_cli_args(&cli_args) {
CliDirective::Help => {
println!("storkit [PATH]");
println!("storkit init [PATH]");
println!();
println!("Serve a storkit project.");
println!();
println!("USAGE:");
println!(" storkit [PATH]");
println!(" storkit init [PATH]");
println!();
println!("COMMANDS:");
println!(
" init Scaffold a new .storkit/ project and start the interactive setup wizard."
);
println!();
println!("ARGS:");
println!(
@@ -108,10 +119,15 @@ async fn main() -> Result<(), std::io::Error> {
eprintln!("Run 'storkit --help' for usage.");
std::process::exit(1);
}
CliDirective::Path | CliDirective::None => {}
CliDirective::Init | CliDirective::Path | CliDirective::None => {}
}
let explicit_path = parse_project_path_arg(&cli_args, &cwd);
// For `storkit init [PATH]`, the path argument follows "init".
let explicit_path = if is_init {
parse_project_path_arg(&cli_args[1..], &cwd)
} else {
parse_project_path_arg(&cli_args, &cwd)
};
// When a path is given explicitly on the CLI, it must already exist as a
// directory. We do not create directories from the command line.
@@ -126,7 +142,37 @@ async fn main() -> Result<(), std::io::Error> {
}
}
if let Some(explicit_root) = explicit_path {
if is_init {
// `storkit init [PATH]` — always scaffold, never search parents.
let init_root = explicit_path.unwrap_or_else(|| cwd.clone());
if !init_root.exists() {
std::fs::create_dir_all(&init_root).unwrap_or_else(|e| {
eprintln!("error: cannot create directory {}: {e}", init_root.display());
std::process::exit(1);
});
}
match io::fs::open_project(
init_root.to_string_lossy().to_string(),
&app_state,
store.as_ref(),
port,
)
.await
{
Ok(_) => {
if let Some(root) = app_state.project_root.lock().unwrap().as_ref() {
config::ProjectConfig::load(root)
.unwrap_or_else(|e| panic!("Invalid project.toml: {e}"));
// Initialize wizard state for the setup flow.
io::wizard::WizardState::init_if_missing(root);
}
}
Err(e) => {
eprintln!("error: {e}");
std::process::exit(1);
}
}
} else if let Some(explicit_root) = explicit_path {
// An explicit path was given on the command line.
// Open it directly — scaffold .storkit/ if it is missing — and
// exit with a clear error message if the path is invalid.