diff --git a/.huskies/specs/tech/CHAT_DRIVEN_PROJECT_BOOTSTRAP.md b/.huskies/specs/tech/CHAT_DRIVEN_PROJECT_BOOTSTRAP.md index 1e7e0e02..f2cc6ac1 100644 --- a/.huskies/specs/tech/CHAT_DRIVEN_PROJECT_BOOTSTRAP.md +++ b/.huskies/specs/tech/CHAT_DRIVEN_PROJECT_BOOTSTRAP.md @@ -215,7 +215,74 @@ The work breaks naturally into: - **Phase 4:** git integration — `--git ` clones, host SSH key mount, push verification. - **Phase 5:** per-project resource limits + cleanup chat commands. +- **Phase 6:** `--adopt ` wraps a container around an existing + checkout. No clone or init — bind-mount only. +- **Phase 7 (story 1137):** First-run init flow — config summary and + chat-driven overrides (see below). Each phase ships independently and is usable on its own. Phase 1 alone gives chat-only users a working project; later phases add the editor and git polish. + +## First-Run Init Flow (Story 1137) + +After a successful `new project ... --adopt` (or any new-project +bootstrap), the bot appends a **Default configuration** block to the +adoption success reply. This block lists every scaffolded agent with +its model, budget cap, and turn limit, and provides ready-to-send +override commands. + +### Example reply tail + +``` +**Default configuration** (3 agents): +- coder-1 (coder): model=`sonnet`, budget=$5.00, max_turns=50 +- qa (qa): model=`sonnet`, budget=$4.00, max_turns=40 +- mergemaster (mergemaster): model=`sonnet`, budget=$5.00, max_turns=30 + +Override via chat: `huskies config myapp coder.model=opus` +Project settings: `huskies config myapp default_qa=human` +Accept all defaults silently: add `--skip-config` to the bootstrap command. +``` + +### Config override command + +``` +huskies config = +``` + +The gateway resolves the project's `host_path` from `projects.toml`, +then writes the setting to `.huskies/agents.toml` or +`.huskies/project.toml` on the host. + +**Agent fields** (`.=`): + +| Key | Target | Supported values | +|-----|--------|-----------------| +| `coder.model` | agents.toml, coder stage | `sonnet`, `opus`, any model string | +| `qa.model` | agents.toml, qa stage | same | +| `mergemaster.model` | agents.toml, mergemaster stage | same | +| `coder.max_turns` | agents.toml, coder stage | integer | +| `coder.max_budget` | agents.toml, coder stage | decimal (USD) | + +**Project keys** (bare `=`): + +| Key | Notes | +|-----|-------| +| `default_qa` | `"server"`, `"agent"`, or `"human"` | +| `max_retries` | integer | +| `max_coders` | integer | +| `base_branch` | branch name string | +| `timezone` | IANA timezone (e.g. `"Europe/London"`) | +| `default_coder_model` | model string | + +### Skip path + +Pass `--skip-config` to suppress the config block entirely: + +``` +new project myapp --adopt /path/to/checkout --skip-config +``` + +The success reply is identical to pre-1137 output — only the SSH +command and registration summary, no agent listing. diff --git a/server/src/chat/transport/matrix/bot/messages/on_room_message.rs b/server/src/chat/transport/matrix/bot/messages/on_room_message.rs index 724e980c..9cc5f596 100644 --- a/server/src/chat/transport/matrix/bot/messages/on_room_message.rs +++ b/server/src/chat/transport/matrix/bot/messages/on_room_message.rs @@ -217,8 +217,15 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message( // endpoint. Only a small set of gateway-local commands are handled here. if ctx.is_gateway() { // Commands that are meaningful on the gateway itself (no project state needed). - const GATEWAY_LOCAL_COMMANDS: &[&str] = - &["help", "ambient", "reset", "switch", "all_status", "new"]; + const GATEWAY_LOCAL_COMMANDS: &[&str] = &[ + "help", + "ambient", + "reset", + "switch", + "all_status", + "new", + "config", + ]; let stripped = crate::chat::util::strip_bot_mention( &user_message, @@ -293,6 +300,63 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message( return; } + // `config =` — override an agent or project setting. + if cmd == "config" { + let response = if let Some(ref store) = ctx.gateway_projects_store { + // Parse: " =" + let mut parts = args.splitn(2, char::is_whitespace); + let project = parts.next().unwrap_or("").trim(); + let setting = parts.next().unwrap_or("").trim(); + if project.is_empty() || setting.is_empty() { + "Usage: `config =`\n\ + Examples:\n\ + - `config myapp coder.model=opus`\n\ + - `config myapp default_qa=human`" + .to_string() + } else { + match setting.split_once('=') { + None => { + "Usage: setting must be in `key=value` form, e.g. `coder.model=opus`" + .to_string() + } + Some((key, value)) => { + let host_path_opt = { + let projects = store.read().await; + projects.get(project).and_then(|e| e.host_path.clone()) + }; + match host_path_opt { + None => format!( + "Project `{project}` not found or has no host path configured." + ), + Some(path) => { + match super::super::super::new_project::apply_project_config( + std::path::Path::new(&path), + key.trim(), + value.trim(), + ) { + Ok(msg) => msg, + Err(e) => format!("Config error: {e}"), + } + } + } + } + } + } + } else { + "Gateway projects store unavailable.".to_string() + }; + let html = markdown_to_html(&response); + if let Ok(msg_id) = ctx + .transport + .send_message(&room_id_str, &response, &html) + .await + && let Ok(event_id) = msg_id.parse() + { + ctx.bot_sent_event_ids.lock().await.insert(event_id); + } + return; + } + // Gateway-local commands and freeform text fall through to normal handling below. } @@ -320,6 +384,7 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message( cmd.git_token.as_deref(), cmd.host_path.as_deref(), cmd.adopt_path.as_deref(), + cmd.skip_config, store, &ctx.services.project_root, ) diff --git a/server/src/chat/transport/matrix/new_project.rs b/server/src/chat/transport/matrix/new_project.rs index 4feb99a1..29545016 100644 --- a/server/src/chat/transport/matrix/new_project.rs +++ b/server/src/chat/transport/matrix/new_project.rs @@ -47,7 +47,7 @@ use tokio::sync::RwLock; use crate::service::gateway::config::ProjectEntry; -/// Parsed result of a `new project [--stack ] [--git ] [--git-token ] [--path ] [--adopt ]` command. +/// Parsed result of a `new project [--stack ] [--git ] [--git-token ] [--path ] [--adopt ] [--skip-config]` command. pub struct NewProjectCommand { /// Project name (alphanumeric, hyphens, underscores). pub name: String, @@ -75,6 +75,11 @@ pub struct NewProjectCommand { /// existing directory bind-mounted at `/workspace`. /// Mutually exclusive with `--path` and `--git`. pub adopt_path: Option, + /// Suppress the first-run configuration summary in the bootstrap reply. + /// + /// When `true`, the success reply omits the "Default configuration" block + /// that lists agents, models, and override commands. + pub skip_config: bool, } /// Parse a `new project [--stack ] [--git ] [--git-token ] [--path ] [--adopt ]` command. @@ -111,6 +116,7 @@ pub fn extract_new_project_command( let git_token = parse_flag(&remaining, "--git-token"); let host_path = parse_flag(&remaining, "--path"); let adopt_path = parse_flag(&remaining, "--adopt"); + let skip_config = remaining.contains(&"--skip-config"); Some(NewProjectCommand { name, @@ -119,6 +125,7 @@ pub fn extract_new_project_command( git_token, host_path, adopt_path, + skip_config, }) } @@ -135,6 +142,281 @@ fn parse_flag(tokens: &[&str], flag: &str) -> Option { None } +/// Build the first-run configuration summary appended to the bootstrap success reply. +/// +/// Reads `.huskies/agents.toml` from the project root and formats a Markdown +/// block listing each agent's name, stage, model, budget, and turn limit. +/// Returns an empty string when the file is missing or unreadable so callers +/// can always concatenate it unconditionally. +fn format_config_summary(host_path: &Path, name: &str) -> String { + let agents_path = host_path.join(".huskies").join("agents.toml"); + let Ok(content) = std::fs::read_to_string(&agents_path) else { + return String::new(); + }; + let Ok(val) = toml::from_str::(&content) else { + return String::new(); + }; + let Some(agents) = val.get("agent").and_then(|v| v.as_array()) else { + return String::new(); + }; + + let mut lines = vec![format!( + "\n**Default configuration** ({} agent{}):", + agents.len(), + if agents.len() == 1 { "" } else { "s" } + )]; + for agent in agents { + let agent_name = agent.get("name").and_then(|v| v.as_str()).unwrap_or("?"); + let stage = agent.get("stage").and_then(|v| v.as_str()).unwrap_or("?"); + let model = agent + .get("model") + .and_then(|v| v.as_str()) + .unwrap_or("sonnet"); + let budget = agent + .get("max_budget_usd") + .and_then(|v| v.as_float()) + .unwrap_or(5.0); + let turns = agent + .get("max_turns") + .and_then(|v| v.as_integer()) + .unwrap_or(50); + lines.push(format!( + "- **{agent_name}** ({stage}): model=`{model}`, budget=${budget:.2}, max_turns={turns}" + )); + } + lines.push(String::new()); + lines.push(format!( + "Override via chat: `huskies config {name} coder.model=opus`" + )); + lines.push(format!( + "Project settings: `huskies config {name} default_qa=human`" + )); + lines.push( + "Accept all defaults silently: add `--skip-config` to the bootstrap command.".to_string(), + ); + lines.join("\n") +} + +/// Apply a single configuration override to a project's `.huskies/` files. +/// +/// `key` is either `.` (writes to `agents.toml`) or a +/// bare project-level key (writes to `project.toml`). +/// +/// Supported agent fields: `model`, `max_turns`, `max_budget` / `max_budget_usd`. +/// Supported project keys: `default_qa`, `max_retries`, `max_coders`, +/// `base_branch`, `timezone`, `default_coder_model`. +pub fn apply_project_config(host_path: &Path, key: &str, value: &str) -> Result { + if let Some((specifier, field)) = key.split_once('.') { + // Agent-scoped key: write to agents.toml + let path = host_path.join(".huskies").join("agents.toml"); + apply_agent_config_file(&path, specifier, field, value) + } else { + // Project-level key: write to project.toml + const SUPPORTED: &[&str] = &[ + "default_qa", + "max_retries", + "max_coders", + "base_branch", + "timezone", + "default_coder_model", + ]; + if !SUPPORTED.contains(&key) { + return Err(format!( + "Unknown project key `{key}`. Supported: {}", + SUPPORTED.join(", ") + )); + } + let path = host_path.join(".huskies").join("project.toml"); + apply_project_toml_key(&path, key, value) + } +} + +/// Modify a single `[[agent]]` entry in an `agents.toml` file. +fn apply_agent_config_file( + path: &std::path::Path, + specifier: &str, + field: &str, + value: &str, +) -> Result { + let content = + std::fs::read_to_string(path).map_err(|e| format!("Cannot read agents.toml: {e}"))?; + + let new_content = apply_agent_field_in_text(&content, specifier, field, value)?; + + std::fs::write(path, &new_content).map_err(|e| format!("Cannot write agents.toml: {e}"))?; + + let canonical_field = if field == "max_budget" { + "max_budget_usd" + } else { + field + }; + Ok(format!( + "Set `{specifier}.{canonical_field} = {value}` in `agents.toml`." + )) +} + +/// Rewrite the agent field in raw TOML text without re-serializing the whole file. +/// +/// Splits the file into `[[agent]]` blocks, finds the one matching `specifier` +/// by name or stage, replaces the target field line, and rejoins. +fn apply_agent_field_in_text( + content: &str, + specifier: &str, + field: &str, + value: &str, +) -> Result { + const SUPPORTED_FIELDS: &[&str] = &["model", "max_turns", "max_budget", "max_budget_usd"]; + if !SUPPORTED_FIELDS.contains(&field) { + return Err(format!( + "Unknown agent field `{field}`. Supported: model, max_turns, max_budget" + )); + } + let canonical_field = if field == "max_budget" { + "max_budget_usd" + } else { + field + }; + + // Format the replacement TOML value. + let toml_value = match canonical_field { + "max_turns" => { + let _n: i64 = value + .parse() + .map_err(|_| format!("`max_turns` must be an integer, got `{value}`"))?; + value.to_string() + } + "max_budget_usd" => { + let _f: f64 = value + .parse() + .map_err(|_| format!("`max_budget` must be a number, got `{value}`"))?; + value.to_string() + } + _ => format!("\"{value}\""), + }; + + // Split on [[agent]] boundaries (keeping the marker with each block). + let raw_blocks: Vec<&str> = content.split("\n[[agent]]").collect(); + let mut out_blocks: Vec = Vec::with_capacity(raw_blocks.len()); + let mut matched = false; + + for (i, block) in raw_blocks.iter().enumerate() { + // Re-attach the [[agent]] prefix that split() removed. + let prefixed: std::borrow::Cow = if i == 0 { + std::borrow::Cow::Borrowed(block) + } else { + std::borrow::Cow::Owned(format!("\n[[agent]]{block}")) + }; + + // Check if this block contains a matching name or stage line. + let matches_name = prefixed + .lines() + .any(|l| l.trim() == format!("name = \"{specifier}\"")); + let matches_stage = prefixed + .lines() + .any(|l| l.trim() == format!("stage = \"{specifier}\"")); + + if (matches_name || matches_stage) && !matched { + matched = true; + // Replace the field line in this block. + let mut new_block = String::new(); + let mut field_written = false; + for line in prefixed.lines() { + let trimmed = line.trim(); + if trimmed.starts_with(&format!("{canonical_field} =")) { + new_block.push_str(&format!("{canonical_field} = {toml_value}")); + new_block.push('\n'); + field_written = true; + } else { + new_block.push_str(line); + new_block.push('\n'); + } + } + // Remove the trailing newline we added (the original may not have one at end). + if new_block.ends_with('\n') && !prefixed.ends_with('\n') { + new_block.pop(); + } + if !field_written { + return Err(format!( + "Field `{canonical_field}` not found in the `{specifier}` agent block" + )); + } + out_blocks.push(new_block); + } else { + out_blocks.push(prefixed.into_owned()); + } + } + + if !matched { + return Err(format!( + "No agent with name or stage `{specifier}` found in agents.toml" + )); + } + + Ok(out_blocks.join("")) +} + +/// Set or uncomment a key in a `project.toml` file. +/// +/// Tries the following in order: +/// 1. Replace an existing uncommented `key = ...` line. +/// 2. Uncomment and replace a `# key = ...` line. +/// 3. Append the key at the end of the file. +fn apply_project_toml_key( + path: &std::path::Path, + key: &str, + value: &str, +) -> Result { + let content = + std::fs::read_to_string(path).map_err(|e| format!("Cannot read project.toml: {e}"))?; + + // Format as TOML: integer if parseable, else quoted string. + let toml_value = if value.parse::().is_ok() { + value.to_string() + } else { + format!("\"{value}\"") + }; + let replacement_line = format!("{key} = {toml_value}"); + + let mut found = false; + let new_content: String = content + .lines() + .map(|line| { + if found { + return format!("{line}\n"); + } + let trimmed = line.trim(); + // Match uncommented `key = ...` + if trimmed.starts_with(&format!("{key} =")) { + found = true; + return format!("{replacement_line}\n"); + } + // Match commented `# key = ...` variants + let without_hash = trimmed.strip_prefix('#').map(|s| s.trim()).unwrap_or(""); + if without_hash.starts_with(&format!("{key} =")) { + found = true; + return format!("{replacement_line}\n"); + } + format!("{line}\n") + }) + .collect(); + + let new_content = if found { + // Trim the extra newline added by our map if the original didn't end with one. + if content.ends_with('\n') { + new_content + } else { + new_content.trim_end_matches('\n').to_string() + } + } else { + // Append. + format!("{new_content}{replacement_line}\n") + }; + + std::fs::write(path, &new_content).map_err(|e| format!("Cannot write project.toml: {e}"))?; + + Ok(format!("Set `{key} = {toml_value}` in `project.toml`.")) +} + /// Scan `stacks_dir` for per-stack `markers` files and detect which stacks /// match the given project directory. /// @@ -387,6 +669,7 @@ async fn handle_adopt_project( home: &str, projects_store: &Arc>>, config_dir: &Path, + skip_config: bool, ) -> String { // ── Credentials pre-flight ─────────────────────────────────────────────── // Agents inside the container need Claude credentials to spawn. Fail fast @@ -522,6 +805,11 @@ async fn handle_adopt_project( format!("\n> {}\n", detect_warnings.join("\n> ")) }; + let config_block = if skip_config { + String::new() + } else { + format_config_summary(host_path, name) + }; format!( "{warning_block}Project **{name}** adopted.\n\ - Host path: `{host}` (existing checkout, bind-mounted)\n\ @@ -530,7 +818,8 @@ async fn handle_adopt_project( - SSH: `ssh huskies@127.0.0.1 -p {ssh_port} \ -i ~/.huskies/{name}/id_ed25519`\n\ \n\ - Use `switch {name}` then `status` to view the pipeline.", + Use `switch {name}` then `status` to view the pipeline.\ + {config_block}", host = host_path.display() ) } @@ -571,6 +860,7 @@ pub async fn handle_new_project( git_token: Option<&str>, host_path_override: Option<&str>, adopt_path_override: Option<&str>, + skip_config: bool, projects_store: &Arc>>, config_dir: &Path, ) -> String { @@ -622,8 +912,16 @@ pub async fn handle_new_project( if !host_path.is_dir() { return format!("Adopt path `{}` is not a directory.", host_path.display()); } - return handle_adopt_project(name, stack, &host_path, &home, projects_store, config_dir) - .await; + return handle_adopt_project( + name, + stack, + &host_path, + &home, + projects_store, + config_dir, + skip_config, + ) + .await; } // `--path` overrides the default `~/huskies//`. @@ -910,6 +1208,11 @@ pub async fn handle_new_project( format!("\n> {}\n", detect_warnings.join("\n> ")) }; + let config_block = if skip_config { + String::new() + } else { + format_config_summary(&host_path, name) + }; format!( "{warning_block}Project **{name}** is ready.\n\ - Host path: `{host}`\n\ @@ -919,7 +1222,8 @@ pub async fn handle_new_project( - SSH: `ssh huskies@127.0.0.1 -p {ssh_port} \ -i ~/.huskies/{name}/id_ed25519`\n\ \n\ - Use `switch {name}` then `status` to view the pipeline.", + Use `switch {name}` then `status` to view the pipeline.\ + {config_block}", host = host_path.display() ) } @@ -1483,6 +1787,7 @@ mod tests { home_dir.path().to_str().unwrap(), &store, config_dir.path(), + false, ) .await; @@ -1808,6 +2113,7 @@ mod tests { None, Some("/tmp/something"), Some("/existing/checkout"), + false, &store, config_dir.path(), ) @@ -1829,6 +2135,7 @@ mod tests { None, None, Some("/existing/checkout"), + false, &store, config_dir.path(), ) @@ -1850,6 +2157,7 @@ mod tests { None, None, Some("/nonexistent/path/that/does/not/exist"), + false, &store, config_dir.path(), ) @@ -1875,6 +2183,7 @@ mod tests { None, None, Some(file_path.to_str().unwrap()), + false, &store, config_dir.path(), ) @@ -1884,4 +2193,198 @@ mod tests { "expected not-a-directory error, got: {result}" ); } + + // ── Story 1137: first-run config summary and config override ───────────── + + // Minimal agents.toml matching the scaffold default (no comments to preserve). + const TEST_AGENTS_TOML: &str = r#"[[agent]] +name = "coder-1" +stage = "coder" +model = "sonnet" +max_turns = 50 +max_budget_usd = 5.00 + +[[agent]] +name = "qa" +stage = "qa" +model = "sonnet" +max_turns = 40 +max_budget_usd = 4.00 + +[[agent]] +name = "mergemaster" +stage = "mergemaster" +model = "sonnet" +max_turns = 30 +max_budget_usd = 5.00 +"#; + + // Minimal project.toml with one active key and one commented key. + const TEST_PROJECT_TOML: &str = r#"default_qa = "server" +max_retries = 2 +# max_coders = 3 +# default_coder_model = "sonnet" +"#; + + #[test] + fn apply_agent_field_in_text_changes_coder_model() { + let result = apply_agent_field_in_text(TEST_AGENTS_TOML, "coder", "model", "opus") + .expect("should succeed"); + // The coder agent block should now contain `model = "opus"`. + assert!( + result.contains("model = \"opus\""), + "expected model = \"opus\" in result, got:\n{result}" + ); + // Verify the qa agent block still contains the original model. + // Split on [[agent]] boundaries and check the qa block independently. + let qa_block = result + .split("\n[[agent]]") + .find(|block| block.contains("stage = \"qa\"")); + assert!( + qa_block.is_some_and(|b| b.contains("model = \"sonnet\"")), + "qa agent model should remain sonnet in result:\n{result}" + ); + } + + #[test] + fn apply_agent_field_in_text_changes_qa_max_turns() { + let result = apply_agent_field_in_text(TEST_AGENTS_TOML, "qa", "max_turns", "25") + .expect("should succeed"); + assert!( + result.contains("max_turns = 25"), + "expected max_turns = 25 in result, got:\n{result}" + ); + } + + #[test] + fn apply_agent_field_in_text_unknown_agent_returns_error() { + let err = apply_agent_field_in_text(TEST_AGENTS_TOML, "bogus", "model", "opus") + .expect_err("should fail for unknown agent"); + assert!( + err.contains("bogus"), + "error should mention the unknown specifier, got: {err}" + ); + } + + #[test] + fn apply_agent_field_in_text_unknown_field_returns_error() { + let err = apply_agent_field_in_text(TEST_AGENTS_TOML, "coder", "unknown_field", "value") + .expect_err("should fail for unknown field"); + assert!( + err.contains("unknown_field"), + "error should mention the unknown field, got: {err}" + ); + } + + #[test] + fn apply_project_config_coder_model_writes_to_agents_toml() { + let dir = tempfile::tempdir().unwrap(); + let huskies_dir = dir.path().join(".huskies"); + std::fs::create_dir_all(&huskies_dir).unwrap(); + std::fs::write(huskies_dir.join("agents.toml"), TEST_AGENTS_TOML).unwrap(); + + let msg = apply_project_config(dir.path(), "coder.model", "opus").expect("should succeed"); + assert!( + msg.contains("agents.toml"), + "success message should mention agents.toml, got: {msg}" + ); + + // Verify the file was mutated correctly. + let content = std::fs::read_to_string(huskies_dir.join("agents.toml")).unwrap(); + assert!( + content.contains("model = \"opus\""), + "agents.toml should contain model = \"opus\" after override, got:\n{content}" + ); + } + + #[test] + fn apply_project_config_default_qa_writes_to_project_toml() { + let dir = tempfile::tempdir().unwrap(); + let huskies_dir = dir.path().join(".huskies"); + std::fs::create_dir_all(&huskies_dir).unwrap(); + std::fs::write(huskies_dir.join("project.toml"), TEST_PROJECT_TOML).unwrap(); + + let msg = apply_project_config(dir.path(), "default_qa", "human").expect("should succeed"); + assert!( + msg.contains("project.toml"), + "success message should mention project.toml, got: {msg}" + ); + + let content = std::fs::read_to_string(huskies_dir.join("project.toml")).unwrap(); + assert!( + content.contains("default_qa = \"human\""), + "project.toml should contain default_qa = \"human\" after override, got:\n{content}" + ); + } + + #[test] + fn apply_project_config_uncomments_commented_key() { + let dir = tempfile::tempdir().unwrap(); + let huskies_dir = dir.path().join(".huskies"); + std::fs::create_dir_all(&huskies_dir).unwrap(); + std::fs::write(huskies_dir.join("project.toml"), TEST_PROJECT_TOML).unwrap(); + + // max_coders is commented out in the template; setting it should uncomment it. + apply_project_config(dir.path(), "max_coders", "4").expect("should succeed"); + + let content = std::fs::read_to_string(huskies_dir.join("project.toml")).unwrap(); + assert!( + content.contains("max_coders = 4"), + "project.toml should contain uncommented max_coders = 4, got:\n{content}" + ); + // Should not still appear as a comment. + assert!( + !content.contains("# max_coders = 4"), + "commented form should have been replaced, got:\n{content}" + ); + } + + #[test] + fn format_config_summary_lists_default_agents() { + let dir = tempfile::tempdir().unwrap(); + let huskies_dir = dir.path().join(".huskies"); + std::fs::create_dir_all(&huskies_dir).unwrap(); + std::fs::write(huskies_dir.join("agents.toml"), TEST_AGENTS_TOML).unwrap(); + + let summary = format_config_summary(dir.path(), "myapp"); + assert!( + summary.contains("3 agents"), + "summary should show agent count, got: {summary}" + ); + assert!( + summary.contains("sonnet"), + "summary should list default model, got: {summary}" + ); + assert!( + summary.contains("huskies config myapp"), + "summary should include override command, got: {summary}" + ); + assert!( + summary.contains("--skip-config"), + "summary should mention --skip-config, got: {summary}" + ); + } + + #[test] + fn extract_parses_skip_config_flag() { + let cmd = extract_new_project_command( + "@timmy new project myapp --adopt /srv/myapp --skip-config", + "Timmy", + "@timmy:srv.local", + ) + .unwrap(); + assert_eq!(cmd.adopt_path, Some("/srv/myapp".to_string())); + assert!(cmd.skip_config, "skip_config should be true"); + } + + #[test] + fn extract_skip_config_false_by_default() { + let cmd = extract_new_project_command( + "@timmy new project myapp --adopt /srv/myapp", + "Timmy", + "@timmy:srv.local", + ) + .unwrap(); + assert!(!cmd.skip_config, "skip_config should default to false"); + } } diff --git a/server/src/http/gateway/mcp.rs b/server/src/http/gateway/mcp.rs index a5291487..d4231ed2 100644 --- a/server/src/http/gateway/mcp.rs +++ b/server/src/http/gateway/mcp.rs @@ -608,6 +608,7 @@ async fn handle_adopt_project_tool( None, None, Some(path_str), + false, &state.projects, &state.config_dir, ) @@ -884,6 +885,7 @@ mod tests { None, None, Some(file_path), + false, &store, dir.path(), )