huskies: merge 1137 story First-run project init flow — walk through config instead of leaving defaults silently
This commit is contained in:
@@ -215,7 +215,74 @@ The work breaks naturally into:
|
||||
- **Phase 4:** git integration — `--git <url>` clones, host SSH key
|
||||
mount, push verification.
|
||||
- **Phase 5:** per-project resource limits + cleanup chat commands.
|
||||
- **Phase 6:** `--adopt <dir>` 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 <project> <key>=<value>
|
||||
```
|
||||
|
||||
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** (`<stage_or_name>.<field>=<value>`):
|
||||
|
||||
| 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>=<value>`):
|
||||
|
||||
| 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.
|
||||
|
||||
@@ -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 <project> <key>=<value>` — override an agent or project setting.
|
||||
if cmd == "config" {
|
||||
let response = if let Some(ref store) = ctx.gateway_projects_store {
|
||||
// Parse: "<project> <key>=<value>"
|
||||
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 <project> <key>=<value>`\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,
|
||||
)
|
||||
|
||||
@@ -47,7 +47,7 @@ use tokio::sync::RwLock;
|
||||
|
||||
use crate::service::gateway::config::ProjectEntry;
|
||||
|
||||
/// Parsed result of a `new project <name> [--stack <s>] [--git <url>] [--git-token <tok>] [--path <dir>] [--adopt <dir>]` command.
|
||||
/// Parsed result of a `new project <name> [--stack <s>] [--git <url>] [--git-token <tok>] [--path <dir>] [--adopt <dir>] [--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<String>,
|
||||
/// 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 <name> [--stack <s>] [--git <url>] [--git-token <tok>] [--path <dir>] [--adopt <dir>]` 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<String> {
|
||||
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::<toml::Value>(&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 `<agent_specifier>.<field>` (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<String, String> {
|
||||
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<String, String> {
|
||||
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<String, String> {
|
||||
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<String> = 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<str> = 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<String, String> {
|
||||
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::<i64>().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<RwLock<BTreeMap<String, ProjectEntry>>>,
|
||||
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<RwLock<BTreeMap<String, ProjectEntry>>>,
|
||||
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/<name>/`.
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user