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
|
- **Phase 4:** git integration — `--git <url>` clones, host SSH key
|
||||||
mount, push verification.
|
mount, push verification.
|
||||||
- **Phase 5:** per-project resource limits + cleanup chat commands.
|
- **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
|
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
|
gives chat-only users a working project; later phases add the editor
|
||||||
and git polish.
|
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.
|
// endpoint. Only a small set of gateway-local commands are handled here.
|
||||||
if ctx.is_gateway() {
|
if ctx.is_gateway() {
|
||||||
// Commands that are meaningful on the gateway itself (no project state needed).
|
// Commands that are meaningful on the gateway itself (no project state needed).
|
||||||
const GATEWAY_LOCAL_COMMANDS: &[&str] =
|
const GATEWAY_LOCAL_COMMANDS: &[&str] = &[
|
||||||
&["help", "ambient", "reset", "switch", "all_status", "new"];
|
"help",
|
||||||
|
"ambient",
|
||||||
|
"reset",
|
||||||
|
"switch",
|
||||||
|
"all_status",
|
||||||
|
"new",
|
||||||
|
"config",
|
||||||
|
];
|
||||||
|
|
||||||
let stripped = crate::chat::util::strip_bot_mention(
|
let stripped = crate::chat::util::strip_bot_mention(
|
||||||
&user_message,
|
&user_message,
|
||||||
@@ -293,6 +300,63 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message(
|
|||||||
return;
|
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.
|
// 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.git_token.as_deref(),
|
||||||
cmd.host_path.as_deref(),
|
cmd.host_path.as_deref(),
|
||||||
cmd.adopt_path.as_deref(),
|
cmd.adopt_path.as_deref(),
|
||||||
|
cmd.skip_config,
|
||||||
store,
|
store,
|
||||||
&ctx.services.project_root,
|
&ctx.services.project_root,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ use tokio::sync::RwLock;
|
|||||||
|
|
||||||
use crate::service::gateway::config::ProjectEntry;
|
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 {
|
pub struct NewProjectCommand {
|
||||||
/// Project name (alphanumeric, hyphens, underscores).
|
/// Project name (alphanumeric, hyphens, underscores).
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -75,6 +75,11 @@ pub struct NewProjectCommand {
|
|||||||
/// existing directory bind-mounted at `/workspace`.
|
/// existing directory bind-mounted at `/workspace`.
|
||||||
/// Mutually exclusive with `--path` and `--git`.
|
/// Mutually exclusive with `--path` and `--git`.
|
||||||
pub adopt_path: Option<String>,
|
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.
|
/// 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 git_token = parse_flag(&remaining, "--git-token");
|
||||||
let host_path = parse_flag(&remaining, "--path");
|
let host_path = parse_flag(&remaining, "--path");
|
||||||
let adopt_path = parse_flag(&remaining, "--adopt");
|
let adopt_path = parse_flag(&remaining, "--adopt");
|
||||||
|
let skip_config = remaining.contains(&"--skip-config");
|
||||||
|
|
||||||
Some(NewProjectCommand {
|
Some(NewProjectCommand {
|
||||||
name,
|
name,
|
||||||
@@ -119,6 +125,7 @@ pub fn extract_new_project_command(
|
|||||||
git_token,
|
git_token,
|
||||||
host_path,
|
host_path,
|
||||||
adopt_path,
|
adopt_path,
|
||||||
|
skip_config,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +142,281 @@ fn parse_flag(tokens: &[&str], flag: &str) -> Option<String> {
|
|||||||
None
|
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
|
/// Scan `stacks_dir` for per-stack `markers` files and detect which stacks
|
||||||
/// match the given project directory.
|
/// match the given project directory.
|
||||||
///
|
///
|
||||||
@@ -387,6 +669,7 @@ async fn handle_adopt_project(
|
|||||||
home: &str,
|
home: &str,
|
||||||
projects_store: &Arc<RwLock<BTreeMap<String, ProjectEntry>>>,
|
projects_store: &Arc<RwLock<BTreeMap<String, ProjectEntry>>>,
|
||||||
config_dir: &Path,
|
config_dir: &Path,
|
||||||
|
skip_config: bool,
|
||||||
) -> String {
|
) -> String {
|
||||||
// ── Credentials pre-flight ───────────────────────────────────────────────
|
// ── Credentials pre-flight ───────────────────────────────────────────────
|
||||||
// Agents inside the container need Claude credentials to spawn. Fail fast
|
// 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> "))
|
format!("\n> {}\n", detect_warnings.join("\n> "))
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let config_block = if skip_config {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format_config_summary(host_path, name)
|
||||||
|
};
|
||||||
format!(
|
format!(
|
||||||
"{warning_block}Project **{name}** adopted.\n\
|
"{warning_block}Project **{name}** adopted.\n\
|
||||||
- Host path: `{host}` (existing checkout, bind-mounted)\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} \
|
- SSH: `ssh huskies@127.0.0.1 -p {ssh_port} \
|
||||||
-i ~/.huskies/{name}/id_ed25519`\n\
|
-i ~/.huskies/{name}/id_ed25519`\n\
|
||||||
\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()
|
host = host_path.display()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -571,6 +860,7 @@ pub async fn handle_new_project(
|
|||||||
git_token: Option<&str>,
|
git_token: Option<&str>,
|
||||||
host_path_override: Option<&str>,
|
host_path_override: Option<&str>,
|
||||||
adopt_path_override: Option<&str>,
|
adopt_path_override: Option<&str>,
|
||||||
|
skip_config: bool,
|
||||||
projects_store: &Arc<RwLock<BTreeMap<String, ProjectEntry>>>,
|
projects_store: &Arc<RwLock<BTreeMap<String, ProjectEntry>>>,
|
||||||
config_dir: &Path,
|
config_dir: &Path,
|
||||||
) -> String {
|
) -> String {
|
||||||
@@ -622,7 +912,15 @@ pub async fn handle_new_project(
|
|||||||
if !host_path.is_dir() {
|
if !host_path.is_dir() {
|
||||||
return format!("Adopt path `{}` is not a directory.", host_path.display());
|
return format!("Adopt path `{}` is not a directory.", host_path.display());
|
||||||
}
|
}
|
||||||
return handle_adopt_project(name, stack, &host_path, &home, projects_store, config_dir)
|
return handle_adopt_project(
|
||||||
|
name,
|
||||||
|
stack,
|
||||||
|
&host_path,
|
||||||
|
&home,
|
||||||
|
projects_store,
|
||||||
|
config_dir,
|
||||||
|
skip_config,
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -910,6 +1208,11 @@ pub async fn handle_new_project(
|
|||||||
format!("\n> {}\n", detect_warnings.join("\n> "))
|
format!("\n> {}\n", detect_warnings.join("\n> "))
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let config_block = if skip_config {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format_config_summary(&host_path, name)
|
||||||
|
};
|
||||||
format!(
|
format!(
|
||||||
"{warning_block}Project **{name}** is ready.\n\
|
"{warning_block}Project **{name}** is ready.\n\
|
||||||
- Host path: `{host}`\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} \
|
- SSH: `ssh huskies@127.0.0.1 -p {ssh_port} \
|
||||||
-i ~/.huskies/{name}/id_ed25519`\n\
|
-i ~/.huskies/{name}/id_ed25519`\n\
|
||||||
\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()
|
host = host_path.display()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1483,6 +1787,7 @@ mod tests {
|
|||||||
home_dir.path().to_str().unwrap(),
|
home_dir.path().to_str().unwrap(),
|
||||||
&store,
|
&store,
|
||||||
config_dir.path(),
|
config_dir.path(),
|
||||||
|
false,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -1808,6 +2113,7 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
Some("/tmp/something"),
|
Some("/tmp/something"),
|
||||||
Some("/existing/checkout"),
|
Some("/existing/checkout"),
|
||||||
|
false,
|
||||||
&store,
|
&store,
|
||||||
config_dir.path(),
|
config_dir.path(),
|
||||||
)
|
)
|
||||||
@@ -1829,6 +2135,7 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
Some("/existing/checkout"),
|
Some("/existing/checkout"),
|
||||||
|
false,
|
||||||
&store,
|
&store,
|
||||||
config_dir.path(),
|
config_dir.path(),
|
||||||
)
|
)
|
||||||
@@ -1850,6 +2157,7 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
Some("/nonexistent/path/that/does/not/exist"),
|
Some("/nonexistent/path/that/does/not/exist"),
|
||||||
|
false,
|
||||||
&store,
|
&store,
|
||||||
config_dir.path(),
|
config_dir.path(),
|
||||||
)
|
)
|
||||||
@@ -1875,6 +2183,7 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
Some(file_path.to_str().unwrap()),
|
Some(file_path.to_str().unwrap()),
|
||||||
|
false,
|
||||||
&store,
|
&store,
|
||||||
config_dir.path(),
|
config_dir.path(),
|
||||||
)
|
)
|
||||||
@@ -1884,4 +2193,198 @@ mod tests {
|
|||||||
"expected not-a-directory error, got: {result}"
|
"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,
|
||||||
None,
|
None,
|
||||||
Some(path_str),
|
Some(path_str),
|
||||||
|
false,
|
||||||
&state.projects,
|
&state.projects,
|
||||||
&state.config_dir,
|
&state.config_dir,
|
||||||
)
|
)
|
||||||
@@ -884,6 +885,7 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
Some(file_path),
|
Some(file_path),
|
||||||
|
false,
|
||||||
&store,
|
&store,
|
||||||
dir.path(),
|
dir.path(),
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user