huskies: merge 1137 story First-run project init flow — walk through config instead of leaving defaults silently

This commit is contained in:
dave
2026-05-18 12:54:18 +00:00
parent f2c13c7d29
commit a7bad217eb
4 changed files with 644 additions and 7 deletions
@@ -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,
)
+508 -5
View File
@@ -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");
}
}
+2
View File
@@ -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(),
)