From 0b50c66caa29a3a0dbb50ca638e9c54f9720a4e9 Mon Sep 17 00:00:00 2001 From: dave Date: Sat, 28 Mar 2026 13:26:29 +0000 Subject: [PATCH] storkit: merge 429_story_interactive_project_setup_wizard_for_new_storkit_projects --- frontend/src/api/client.ts | 29 ++ frontend/src/components/Chat.tsx | 132 +++++---- frontend/src/components/SetupWizard.tsx | 354 ++++++++++++++++++++++++ server/src/http/mod.rs | 6 +- server/src/http/wizard.rs | 303 ++++++++++++++++++++ server/src/http/ws.rs | 47 ++++ server/src/io/fs/scaffold.rs | 1 + server/src/io/mod.rs | 1 + server/src/io/wizard.rs | 351 +++++++++++++++++++++++ server/src/main.rs | 52 +++- 10 files changed, 1217 insertions(+), 59 deletions(-) create mode 100644 frontend/src/components/SetupWizard.tsx create mode 100644 server/src/http/wizard.rs create mode 100644 server/src/io/wizard.rs diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index d6981242..335b8016 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -21,6 +21,19 @@ export type WsRequest = config: ProviderConfig; }; +export interface WizardStepInfo { + step: string; + label: string; + status: string; + content?: string; +} + +export interface WizardStateData { + steps: WizardStepInfo[]; + current_step_index: number; + completed: boolean; +} + export interface AgentAssignment { agent_name: string; model: string | null; @@ -80,6 +93,13 @@ export type WsResponse = | { type: "pong" } /** Sent on connect when the project still needs onboarding (specs are placeholders). */ | { type: "onboarding_status"; needs_onboarding: boolean } + /** Sent on connect when a setup wizard is active. */ + | { + type: "wizard_state"; + steps: WizardStepInfo[]; + current_step_index: number; + completed: boolean; + } /** Streaming thinking token from an extended-thinking block, separate from regular text. */ | { type: "thinking_token"; content: string } /** Streaming token from a /btw side question response. */ @@ -438,6 +458,7 @@ export class ChatWebSocket { private onAgentConfigChanged?: () => void; private onAgentStateChanged?: () => void; private onOnboardingStatus?: (needsOnboarding: boolean) => void; + private onWizardState?: (state: WizardStateData) => void; private onSideQuestionToken?: (content: string) => void; private onSideQuestionDone?: (response: string) => void; private onLogEntry?: ( @@ -528,6 +549,12 @@ export class ChatWebSocket { if (data.type === "agent_state_changed") this.onAgentStateChanged?.(); if (data.type === "onboarding_status") this.onOnboardingStatus?.(data.needs_onboarding); + if (data.type === "wizard_state") + this.onWizardState?.({ + steps: data.steps, + current_step_index: data.current_step_index, + completed: data.completed, + }); if (data.type === "side_question_token") this.onSideQuestionToken?.(data.content); if (data.type === "side_question_done") @@ -587,6 +614,7 @@ export class ChatWebSocket { onAgentConfigChanged?: () => void; onAgentStateChanged?: () => void; onOnboardingStatus?: (needsOnboarding: boolean) => void; + onWizardState?: (state: WizardStateData) => void; onSideQuestionToken?: (content: string) => void; onSideQuestionDone?: (response: string) => void; onLogEntry?: (timestamp: string, level: string, message: string) => void; @@ -606,6 +634,7 @@ export class ChatWebSocket { this.onAgentConfigChanged = handlers.onAgentConfigChanged; this.onAgentStateChanged = handlers.onAgentStateChanged; this.onOnboardingStatus = handlers.onOnboardingStatus; + this.onWizardState = handlers.onWizardState; this.onSideQuestionToken = handlers.onSideQuestionToken; this.onSideQuestionDone = handlers.onSideQuestionDone; this.onLogEntry = handlers.onLogEntry; diff --git a/frontend/src/components/Chat.tsx b/frontend/src/components/Chat.tsx index 9ce47d60..bc10e367 100644 --- a/frontend/src/components/Chat.tsx +++ b/frontend/src/components/Chat.tsx @@ -4,7 +4,11 @@ import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; import type { AgentConfigInfo } from "../api/agents"; import { agentsApi } from "../api/agents"; -import type { AnthropicModelInfo, PipelineState } from "../api/client"; +import type { + AnthropicModelInfo, + PipelineState, + WizardStateData, +} from "../api/client"; import { api, ChatWebSocket } from "../api/client"; import { useChatHistory } from "../hooks/useChatHistory"; import type { Message, ProviderConfig } from "../types"; @@ -17,6 +21,7 @@ import { LozengeFlyProvider } from "./LozengeFlyContext"; import { MessageItem } from "./MessageItem"; import type { LogEntry } from "./ServerLogsPanel"; import { ServerLogsPanel } from "./ServerLogsPanel"; +import SetupWizard from "./SetupWizard"; import { SideQuestionOverlay } from "./SideQuestionOverlay"; import { StagePanel } from "./StagePanel"; import { WorkItemDetailPanel } from "./WorkItemDetailPanel"; @@ -217,6 +222,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { new Map(), ); const [needsOnboarding, setNeedsOnboarding] = useState(false); + const [wizardState, setWizardState] = useState(null); const onboardingTriggeredRef = useRef(false); const [selectedWorkItemId, setSelectedWorkItemId] = useState( null, @@ -466,6 +472,9 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { onOnboardingStatus: (onboarding: boolean) => { setNeedsOnboarding(onboarding); }, + onWizardState: (state: WizardStateData) => { + setWizardState(state); + }, onSideQuestionToken: (content) => { setSideQuestion((prev) => prev ? { ...prev, response: prev.response + content } : prev, @@ -978,63 +987,76 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { gap: "24px", }} > - {needsOnboarding && messages.length === 0 && !loading && ( -
-

+ )} + {needsOnboarding && + !wizardState && + messages.length === 0 && + !loading && ( +
- Welcome to Storkit -

-

- This project needs to be set up before you can start writing - stories. The agent will guide you through configuring your - project goals and tech stack. -

- -
- )} +

+ Welcome to Storkit +

+

+ This project needs to be set up before you can start + writing stories. The agent will guide you through + configuring your project goals and tech stack. +

+ + + )} {messages.map((msg: Message, idx: number) => ( void; + sendMessage: (message: string) => void; +} + +/** Style constants for the wizard UI. */ +const STEP_BG_PENDING = "#1a1f2e"; +const STEP_BG_ACTIVE = "#1c2a1c"; +const STEP_BG_DONE = "#1a2a1a"; +const STEP_BORDER_PENDING = "#2a2f3e"; +const STEP_BORDER_ACTIVE = "#2d4a2d"; +const STEP_BORDER_DONE = "#2d4a2d"; +const COLOR_LABEL = "#ccc"; +const COLOR_LABEL_DONE = "#a0d4a0"; +const COLOR_ACCENT = "#a0d4a0"; + +function statusIcon(status: string): string { + switch (status) { + case "confirmed": + return "\u2713"; + case "skipped": + return "\u2013"; + case "generating": + return "\u2026"; + case "awaiting_confirmation": + return "?"; + default: + return "\u00B7"; + } +} + +function stepBackground(status: string, isActive: boolean): string { + if (status === "confirmed" || status === "skipped") return STEP_BG_DONE; + if (isActive) return STEP_BG_ACTIVE; + return STEP_BG_PENDING; +} + +function stepBorder(status: string, isActive: boolean): string { + if (status === "confirmed" || status === "skipped") return STEP_BORDER_DONE; + if (isActive) return STEP_BORDER_ACTIVE; + return STEP_BORDER_PENDING; +} + +/** Messages sent to the chat to trigger agent generation for each step. */ +const STEP_PROMPTS: Record = { + context: + "Read the codebase and generate .storkit/specs/00_CONTEXT.md with a project context spec. Include High-Level Goal, Core Features, Domain Definition, and Glossary sections. Then call the wizard API to store the content: PUT /api/wizard/step/context/content", + stack: + "Read the tech stack and generate .storkit/specs/tech/STACK.md with a tech stack spec. Include Core Stack, Coding Standards, Quality Gates, and Libraries sections. Then call the wizard API to store the content: PUT /api/wizard/step/stack/content", + test_script: + "Read the project structure and create script/test — a bash script that runs the project's actual test suite. Then call the wizard API: PUT /api/wizard/step/test_script/content", + release_script: + "Read the project's deployment setup and create script/release tailored to the project. Then call the wizard API: PUT /api/wizard/step/release_script/content", + test_coverage: + "If the stack supports coverage reporting, create script/test_coverage. Then call the wizard API: PUT /api/wizard/step/test_coverage/content", +}; + +async function apiPost(path: string): Promise { + try { + const resp = await fetch(`${API_BASE}${path}`, { method: "POST" }); + if (!resp.ok) return null; + return (await resp.json()) as WizardStateData; + } catch { + return null; + } +} + +function StepCard({ + step, + isActive, + onGenerate, + onConfirm, + onSkip, +}: { + step: WizardStepInfo; + isActive: boolean; + onGenerate: () => void; + onConfirm: () => void; + onSkip: () => void; +}) { + const isDone = step.status === "confirmed" || step.status === "skipped"; + + return ( +
+
+ + {statusIcon(step.status)} + + + {step.label} + + {isActive && step.status === "pending" && ( + + )} + {isActive && step.status === "generating" && ( + + Generating... + + )} +
+ + {step.content && step.status === "awaiting_confirmation" && ( +
+
+						{step.content}
+					
+
+ + + +
+
+ )} + + {isActive && step.status === "pending" && !step.content && ( +
+ +
+ )} +
+ ); +} + +export default function SetupWizard({ + wizardState, + onWizardUpdate, + sendMessage, +}: SetupWizardProps) { + const [, setRefreshKey] = useState(0); + + const handleGenerate = useCallback( + (step: WizardStepInfo) => { + const prompt = STEP_PROMPTS[step.step]; + if (prompt) { + sendMessage(prompt); + } + }, + [sendMessage], + ); + + const handleConfirm = useCallback( + async (step: WizardStepInfo) => { + const result = await apiPost(`/wizard/step/${step.step}/confirm`); + if (result) { + onWizardUpdate(result); + setRefreshKey((k) => k + 1); + } + }, + [onWizardUpdate], + ); + + const handleSkip = useCallback( + async (step: WizardStepInfo) => { + const result = await apiPost(`/wizard/step/${step.step}/skip`); + if (result) { + onWizardUpdate(result); + setRefreshKey((k) => k + 1); + } + }, + [onWizardUpdate], + ); + + if (wizardState.completed) { + return ( +
+

+ Setup Complete +

+

+ Your project is configured. You can start writing stories. +

+
+ ); + } + + return ( +
+
+

+ Project Setup Wizard +

+

+ Step {wizardState.current_step_index + 1} of{" "} + {wizardState.steps.length} +

+
+ + {wizardState.steps.map((step, idx) => ( + handleGenerate(step)} + onConfirm={() => handleConfirm(step)} + onSkip={() => handleSkip(step)} + /> + ))} +
+ ); +} diff --git a/server/src/http/mod.rs b/server/src/http/mod.rs index f74589c7..886a44c5 100644 --- a/server/src/http/mod.rs +++ b/server/src/http/mod.rs @@ -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; @@ -147,6 +149,7 @@ pub fn build_openapi_service(ctx: Arc) -> (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) -> (ApiService, ApiService) { AgentsApi { ctx: ctx.clone() }, SettingsApi { ctx: ctx.clone() }, HealthApi, - BotCommandApi { ctx }, + BotCommandApi { ctx: ctx.clone() }, + wizard::WizardApi { ctx }, ); let docs_service = diff --git a/server/src/http/wizard.rs b/server/src/http/wizard.rs new file mode 100644 index 00000000..8036c5bd --- /dev/null +++ b/server/src/http/wizard.rs @@ -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, +} + +/// Full wizard state response. +#[derive(Serialize, Object)] +struct WizardResponse { + steps: Vec, + 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, +} + +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 { + let quoted = format!("\"{step_str}\""); + serde_json::from_str::("ed) + .map_err(|_| not_found(format!("Unknown wizard step: {step_str}"))) +} + +pub struct WizardApi { + pub ctx: Arc, +} + +#[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> { + 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, + payload: Json, + ) -> OpenApiResult> { + 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) -> OpenApiResult> { + 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) -> OpenApiResult> { + 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) -> OpenApiResult> { + 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) { + 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()); + } +} diff --git a/server/src/http/ws.rs b/server/src/http/ws.rs index fd6ca86c..ef899f09 100644 --- a/server/src/http/ws.rs +++ b/server/src/http/ws.rs @@ -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, +} + #[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, + 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>) -> 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 = 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); diff --git a/server/src/io/fs/scaffold.rs b/server/src/io/fs/scaffold.rs index 5a00886b..8dfc353b 100644 --- a/server/src/io/fs/scaffold.rs +++ b/server/src/io/fs/scaffold.rs @@ -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"); diff --git a/server/src/io/mod.rs b/server/src/io/mod.rs index dfd80b99..60f7074d 100644 --- a/server/src/io/mod.rs +++ b/server/src/io/mod.rs @@ -4,3 +4,4 @@ pub mod search; pub mod shell; pub mod story_metadata; pub mod watcher; +pub mod wizard; diff --git a/server/src/io/wizard.rs b/server/src/io/wizard.rs new file mode 100644 index 00000000..4c284a99 --- /dev/null +++ b/server/src/io/wizard.rs @@ -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, +} + +/// Persistent wizard state, stored in `.storkit/wizard_state.json`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WizardState { + pub steps: Vec, + /// 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 { + 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, + ) { + 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); + } + } +} diff --git a/server/src/main.rs b/server/src/main.rs index 65c57b9b..9cd94305 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -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 = 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.