diff --git a/.huskies/README.md b/.huskies/README.md index f699a6e6..6e22968b 100644 --- a/.huskies/README.md +++ b/.huskies/README.md @@ -136,6 +136,9 @@ The gateway presents a unified MCP surface to the chat agent. All tool calls are | `switch_project` | Change the active project | | `gateway_status` | Show active project and list all registered projects | | `gateway_health` | Health check all containers | +| `init_project` | Scaffold a new `.huskies/` project at a given path — prefer this over asking the user to run `huskies init` on the CLI | + +**Initialising a new project via MCP (preferred):** Instead of asking the user to run `huskies init ` in a terminal, call `init_project` with the `path` argument. Optionally pass `name` and `url` to register the project in `projects.toml` immediately. After that, start a huskies server at the path and use `switch_project` to make it active before calling `wizard_status`. ### Example: multi-project Docker Compose diff --git a/server/src/gateway.rs b/server/src/gateway.rs index f98ceb6d..26c95df4 100644 --- a/server/src/gateway.rs +++ b/server/src/gateway.rs @@ -217,7 +217,7 @@ struct JsonRpcResponse { error: Option, } -#[derive(Serialize)] +#[derive(Debug, Serialize)] struct JsonRpcError { code: i64, message: String, @@ -252,7 +252,12 @@ fn to_json_response(resp: JsonRpcResponse) -> Response { } /// Gateway-specific MCP tools exposed alongside the proxied tools. -const GATEWAY_TOOLS: &[&str] = &["switch_project", "gateway_status", "gateway_health"]; +const GATEWAY_TOOLS: &[&str] = &[ + "switch_project", + "gateway_status", + "gateway_health", + "init_project", +]; /// Main MCP POST handler for the gateway. Intercepts gateway-specific tools and /// proxies everything else to the active project's container. @@ -412,6 +417,28 @@ fn gateway_tool_definitions() -> Vec { "properties": {} } }), + json!({ + "name": "init_project", + "description": "Initialize a new huskies project at the given path by scaffolding .huskies/ and related files — the same as running `huskies init `. Prefer this tool over asking the user to run the CLI. If `name` and `url` are supplied the project is also registered in projects.toml so switch_project can reach it immediately.", + "inputSchema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Absolute filesystem path to the project directory to initialise. The directory is created if it does not exist." + }, + "name": { + "type": "string", + "description": "Optional: short name to register the project under in projects.toml (e.g. 'my-app'). Requires `url`." + }, + "url": { + "type": "string", + "description": "Optional: base URL of the huskies container that will serve this project (e.g. 'http://my-app:3001'). Required when `name` is given." + } + }, + "required": ["path"] + } + }), ] } @@ -490,6 +517,7 @@ async fn handle_gateway_tool( "switch_project" => handle_switch_project(params, state, id).await, "gateway_status" => handle_gateway_status(state, id).await, "gateway_health" => handle_gateway_health(state, id).await, + "init_project" => handle_init_project(params, state, id).await, _ => JsonRpcResponse::error(id, -32601, format!("Unknown gateway tool: {tool_name}")), } } @@ -631,6 +659,132 @@ async fn handle_gateway_health(state: &GatewayState, id: Option) -> JsonR ) } +/// Initialise a new huskies project at the given filesystem path. +/// +/// Performs the same scaffolding as `huskies init `: creates `.huskies/`, +/// default config files, pipeline directories, and the wizard state. If `name` +/// and `url` are both provided the new project is also registered in +/// `projects.toml` so `switch_project` can reach it immediately. +/// +/// Returns an error when the path already contains a `.huskies/` directory. +/// After success the tool response tells the caller what to do next to make +/// `wizard_*` MCP tools work against the new project. +async fn handle_init_project( + params: &Value, + state: &GatewayState, + id: Option, +) -> JsonRpcResponse { + let args = params.get("arguments").unwrap_or(params); + + let path_str = args + .get("path") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim(); + + if path_str.is_empty() { + return JsonRpcResponse::error(id, -32602, "missing required parameter: path".to_string()); + } + + let project_path = std::path::Path::new(path_str); + + // Guard: already a huskies project. + if project_path.join(".huskies").exists() { + return JsonRpcResponse::error( + id, + -32602, + format!( + "path '{}' is already a huskies project (.huskies/ exists). \ + Use wizard_status to check setup progress.", + project_path.display() + ), + ); + } + + // Create directory if it does not yet exist. + if !project_path.exists() + && let Err(e) = std::fs::create_dir_all(project_path) + { + return JsonRpcResponse::error( + id, + -32603, + format!( + "failed to create directory '{}': {e}", + project_path.display() + ), + ); + } + + // Scaffold .huskies/ — same logic as `huskies init`. + // Port 3001 is written into .mcp.json only when the file is absent; if it + // already exists it is never overwritten (the value is environment-specific). + if let Err(e) = crate::io::fs::scaffold::scaffold_story_kit(project_path, 3001) { + return JsonRpcResponse::error(id, -32603, format!("scaffold failed: {e}")); + } + + // Initialise wizard state so wizard_status returns a valid response + // immediately after the project server is started. + crate::io::wizard::WizardState::init_if_missing(project_path); + + // Optionally register the project in projects.toml. + let name = args.get("name").and_then(|v| v.as_str()).map(str::trim); + let url = args.get("url").and_then(|v| v.as_str()).map(str::trim); + + let registered_name: Option = match (name, url) { + (Some(n), Some(u)) if !n.is_empty() && !u.is_empty() => { + let mut projects = state.projects.write().await; + if projects.contains_key(n) { + return JsonRpcResponse::error( + id, + -32602, + format!( + "project '{n}' is already registered. \ + Choose a different name or use switch_project." + ), + ); + } + projects.insert(n.to_string(), ProjectEntry { url: u.to_string() }); + save_config(&projects, &state.config_dir).await; + crate::slog!("[gateway] init_project: registered '{n}' ({u})"); + Some(n.to_string()) + } + _ => None, + }; + + let next_steps = if let Some(ref n) = registered_name { + format!( + "Project registered as '{n}' in projects.toml.\n\ + Next steps:\n\ + 1. Start a huskies server at '{path_str}' \ + (e.g. `huskies {path_str}` or via Docker).\n\ + 2. Call switch_project with name='{n}' to make it active.\n\ + 3. Call wizard_status to begin the setup wizard." + ) + } else { + format!( + "Next steps:\n\ + 1. Start a huskies server at '{path_str}' \ + (e.g. `huskies {path_str}` or via Docker).\n\ + 2. Register the project: call init_project again with name and url \ + parameters, or add it to projects.toml manually.\n\ + 3. Call switch_project and then wizard_status to begin the setup wizard.\n\n\ + Note: wizard_* MCP tools require a running huskies server for the project." + ) + }; + + JsonRpcResponse::success( + id, + json!({ + "content": [{ + "type": "text", + "text": format!( + "Successfully initialised huskies project at '{path_str}'.\n\n{next_steps}" + ) + }] + }), + ) +} + // ── Agent join handlers ─────────────────────────────────────────────── /// `GET /gateway/mode` — returns `{"mode":"gateway"}` so clients can detect gateway mode. @@ -2304,4 +2458,247 @@ enabled = false // build_gateway_route will panic if any route is registered more than once. let _route = build_gateway_route(state); } + + // ── init_project tool tests ────────────────────────────────────────── + + #[tokio::test] + async fn init_project_scaffolds_huskies_dir() { + let dir = tempfile::tempdir().unwrap(); + let state = make_test_state(); + let params = json!({ "arguments": { "path": dir.path().to_str().unwrap() } }); + let resp = handle_init_project(¶ms, &state, Some(json!(1))).await; + assert!( + resp.result.is_some(), + "init_project should succeed: {:?}", + resp.error + ); + assert!( + dir.path().join(".huskies").exists(), + ".huskies/ should be created" + ); + assert!(dir.path().join(".huskies/project.toml").exists()); + assert!(dir.path().join(".huskies/agents.toml").exists()); + assert!(dir.path().join("script/test").exists()); + } + + #[tokio::test] + async fn init_project_creates_wizard_state() { + let dir = tempfile::tempdir().unwrap(); + let state = make_test_state(); + let params = json!({ "arguments": { "path": dir.path().to_str().unwrap() } }); + handle_init_project(¶ms, &state, None).await; + let wizard_state_path = dir.path().join(".huskies/wizard_state.json"); + assert!( + wizard_state_path.exists(), + "wizard_state.json should be created" + ); + let content = std::fs::read_to_string(&wizard_state_path).unwrap(); + let v: Value = + serde_json::from_str(&content).expect("wizard_state.json should be valid JSON"); + assert!( + v.get("steps").is_some(), + "wizard state should have a 'steps' field" + ); + assert!( + v.get("completed").is_some(), + "wizard state should have a 'completed' field" + ); + } + + #[tokio::test] + async fn init_project_already_initialised_returns_error() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join(".huskies")).unwrap(); + let state = make_test_state(); + let params = json!({ "arguments": { "path": dir.path().to_str().unwrap() } }); + let resp = handle_init_project(¶ms, &state, None).await; + assert!( + resp.error.is_some(), + "should return error for already-initialised project" + ); + let msg = &resp.error.unwrap().message; + assert!(msg.contains(".huskies/"), "error should mention .huskies/"); + } + + #[tokio::test] + async fn init_project_missing_path_returns_error() { + let state = make_test_state(); + let params = json!({ "arguments": {} }); + let resp = handle_init_project(¶ms, &state, None).await; + assert!(resp.error.is_some()); + } + + #[tokio::test] + async fn init_project_registers_in_projects_toml_when_name_and_url_given() { + let dir = tempfile::tempdir().unwrap(); + let config_dir = tempfile::tempdir().unwrap(); + let mut projects = BTreeMap::new(); + projects.insert( + "existing".into(), + ProjectEntry { + url: "http://existing:3001".into(), + }, + ); + let config = GatewayConfig { projects }; + let state = + Arc::new(GatewayState::new(config, config_dir.path().to_path_buf(), 3000).unwrap()); + + let params = json!({ + "arguments": { + "path": dir.path().to_str().unwrap(), + "name": "new-project", + "url": "http://new-project:3002" + } + }); + let resp = handle_init_project(¶ms, &state, Some(json!(1))).await; + assert!(resp.result.is_some(), "should succeed: {:?}", resp.error); + + // Project should be registered. + let projects = state.projects.read().await; + assert!( + projects.contains_key("new-project"), + "new-project should be in projects map" + ); + assert_eq!(projects["new-project"].url, "http://new-project:3002"); + } + + #[tokio::test] + async fn init_project_duplicate_name_returns_error() { + let dir = tempfile::tempdir().unwrap(); + let mut projects = BTreeMap::new(); + projects.insert( + "taken".into(), + ProjectEntry { + url: "http://taken:3001".into(), + }, + ); + let config = GatewayConfig { projects }; + let state = Arc::new(GatewayState::new(config, PathBuf::new(), 3000).unwrap()); + + let params = json!({ + "arguments": { + "path": dir.path().to_str().unwrap(), + "name": "taken", + "url": "http://new:3002" + } + }); + let resp = handle_init_project(¶ms, &state, None).await; + assert!(resp.error.is_some(), "duplicate name should return error"); + } + + /// Integration test: call init_project then call wizard_status via the MCP + /// proxy and confirm a valid wizard state response is returned. + /// + /// A lightweight mock HTTP server is started to stand in for the project + /// container, returning a pre-canned wizard_status result. + #[tokio::test] + async fn init_project_then_wizard_status_integration() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + // Start a mock project MCP server on an ephemeral port. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let mock_port = listener.local_addr().unwrap().port(); + let mock_url = format!("http://127.0.0.1:{mock_port}"); + + // Spawn the mock: accept one connection and return a wizard_status response. + tokio::spawn(async move { + if let Ok((mut stream, _)) = listener.accept().await { + let mut buf = vec![0u8; 4096]; + let _ = stream.read(&mut buf).await; + let body = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "result": { + "content": [{ + "type": "text", + "text": "{\"steps\":[{\"id\":\"scaffold\",\"title\":\"Scaffold\",\"status\":\"confirmed\"}],\"completed\":false}" + }] + } + }); + let body_bytes = serde_json::to_vec(&body).unwrap(); + let header = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body_bytes.len() + ); + let _ = stream.write_all(header.as_bytes()).await; + let _ = stream.write_all(&body_bytes).await; + } + }); + + // Give the mock a moment to start. + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + + // Create gateway state pointing at the mock server. + let mut projects = BTreeMap::new(); + projects.insert("mock-project".into(), ProjectEntry { url: mock_url }); + let config = GatewayConfig { projects }; + let config_dir = tempfile::tempdir().unwrap(); + let state = + Arc::new(GatewayState::new(config, config_dir.path().to_path_buf(), 3000).unwrap()); + + // 1. Call init_project. + let project_dir = tempfile::tempdir().unwrap(); + let params = json!({ + "arguments": { "path": project_dir.path().to_str().unwrap() } + }); + let resp = handle_init_project(¶ms, &state, Some(json!(1))).await; + assert!( + resp.result.is_some(), + "init_project should succeed: {:?}", + resp.error + ); + + // Verify scaffolding. + assert!( + project_dir.path().join(".huskies").exists(), + ".huskies/ must be created" + ); + let wizard_path = project_dir.path().join(".huskies/wizard_state.json"); + assert!(wizard_path.exists(), "wizard_state.json must be created"); + + // 2. Call wizard_status via the MCP proxy (proxied to our mock server). + let proxy_body = serde_json::to_vec(&json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { "name": "wizard_status", "arguments": {} } + })) + .unwrap(); + let proxy_resp = proxy_mcp_call(&state, &proxy_body).await; + assert!( + proxy_resp.is_ok(), + "proxy call should succeed: {:?}", + proxy_resp.err() + ); + + // 3. Confirm the response contains wizard state data. + let resp_json: Value = serde_json::from_slice(&proxy_resp.unwrap()).unwrap(); + let result = resp_json.get("result"); + assert!(result.is_some(), "response should have a result field"); + let text = result + .and_then(|r| r.get("content")) + .and_then(|c| c.get(0)) + .and_then(|c| c.get("text")) + .and_then(|t| t.as_str()) + .unwrap_or(""); + let wizard: Value = + serde_json::from_str(text).expect("text should be valid wizard state JSON"); + assert!( + wizard.get("steps").is_some(), + "wizard state should have a 'steps' field" + ); + } + + #[test] + fn gateway_tool_definitions_includes_init_project() { + let defs = gateway_tool_definitions(); + let names: Vec<&str> = defs + .iter() + .filter_map(|d| d.get("name").and_then(|n| n.as_str())) + .collect(); + assert!( + names.contains(&"init_project"), + "init_project should be in gateway tool definitions" + ); + } }