use std::fs;
use std::process::Command;
use tempfile::TempDir;
fn devboy_bin() -> std::path::PathBuf {
let mut path = std::env::current_exe().unwrap();
path.pop(); path.pop();
let bin_name = format!("devboy{}", std::env::consts::EXE_SUFFIX);
path.push(bin_name);
path
}
fn create_temp_git_repo(remote_url: &str) -> TempDir {
let temp_dir = TempDir::new().unwrap();
let init_output = Command::new("git")
.args(["init"])
.current_dir(temp_dir.path())
.output()
.expect("Failed to spawn git init");
assert!(
init_output.status.success(),
"git init failed: {}",
String::from_utf8_lossy(&init_output.stderr)
);
let remote_output = Command::new("git")
.args(["remote", "add", "origin", remote_url])
.current_dir(temp_dir.path())
.output()
.expect("Failed to spawn git remote add");
assert!(
remote_output.status.success(),
"git remote add failed: {}",
String::from_utf8_lossy(&remote_output.stderr)
);
temp_dir
}
#[test]
fn test_init_help() {
let output = Command::new(devboy_bin())
.args(["init", "--help"])
.output()
.expect("Failed to execute command");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(output.status.success());
assert!(stdout.contains("--yes"));
assert!(stdout.contains("--dry-run"));
assert!(stdout.contains("--force"));
assert!(stdout.contains("--claude"));
assert!(stdout.contains("--kimi"));
assert!(stdout.contains("--context"));
}
#[test]
fn test_init_dry_run_creates_no_files() {
let temp_dir = create_temp_git_repo("git@github.com:test-owner/test-repo.git");
let config_path = temp_dir.path().join(".devboy.toml");
let output = Command::new(devboy_bin())
.args(["init", "--yes", "--dry-run"])
.current_dir(temp_dir.path())
.output()
.expect("Failed to execute command");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(output.status.success(), "Command should succeed");
assert!(stdout.contains("[dry-run]"), "Should indicate dry-run mode");
assert!(stdout.contains("Would create"), "Should say would create");
assert!(
!config_path.exists(),
"Config file should NOT be created in dry-run mode"
);
}
#[test]
fn test_init_yes_creates_config_with_github() {
let temp_dir = create_temp_git_repo("git@github.com:test-owner/test-repo.git");
let config_path = temp_dir.path().join(".devboy.toml");
let output = Command::new(devboy_bin())
.args(["init", "--yes"])
.current_dir(temp_dir.path())
.output()
.expect("Failed to execute command");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(output.status.success(), "Command should succeed");
assert!(
stdout.contains("Detected GitHub repository"),
"Should detect GitHub"
);
assert!(config_path.exists(), "Config file should be created");
let content = fs::read_to_string(&config_path).unwrap();
assert!(content.contains("github"), "Should contain github section");
assert!(content.contains("test-owner"), "Should contain owner");
assert!(content.contains("test-repo"), "Should contain repo");
}
#[test]
fn test_init_yes_creates_config_with_gitlab() {
let temp_dir = create_temp_git_repo("git@gitlab.com:company/project.git");
let config_path = temp_dir.path().join(".devboy.toml");
let output = Command::new(devboy_bin())
.args(["init", "--yes"])
.current_dir(temp_dir.path())
.output()
.expect("Failed to execute command");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(output.status.success(), "Command should succeed");
assert!(
stdout.contains("Detected GitLab repository"),
"Should detect GitLab"
);
assert!(config_path.exists(), "Config file should be created");
let content = fs::read_to_string(&config_path).unwrap();
assert!(content.contains("gitlab"), "Should contain gitlab section");
assert!(
content.contains("company/project"),
"Should contain project path"
);
}
#[test]
fn test_init_yes_with_https_remote() {
let temp_dir = create_temp_git_repo("https://github.com/https-owner/https-repo.git");
let config_path = temp_dir.path().join(".devboy.toml");
let output = Command::new(devboy_bin())
.args(["init", "--yes"])
.current_dir(temp_dir.path())
.output()
.expect("Failed to execute command");
assert!(output.status.success(), "Command should succeed");
assert!(config_path.exists(), "Config file should be created");
let content = fs::read_to_string(&config_path).unwrap();
assert!(
content.contains("https-owner"),
"Should parse HTTPS remote correctly"
);
assert!(content.contains("https-repo"), "Should parse repo name");
}
#[test]
fn test_init_custom_context_name() {
let temp_dir = create_temp_git_repo("git@github.com:owner/repo.git");
let config_path = temp_dir.path().join(".devboy.toml");
let output = Command::new(devboy_bin())
.args(["init", "--yes", "--context", "my-custom-context"])
.current_dir(temp_dir.path())
.output()
.expect("Failed to execute command");
assert!(output.status.success(), "Command should succeed");
let content = fs::read_to_string(&config_path).unwrap();
assert!(
content.contains("my-custom-context"),
"Should use custom context name"
);
}
#[test]
fn test_init_fails_if_config_exists_without_force() {
let temp_dir = create_temp_git_repo("git@github.com:owner/repo.git");
let config_path = temp_dir.path().join(".devboy.toml");
fs::write(&config_path, "# existing config\n").unwrap();
let output = Command::new(devboy_bin())
.args(["init", "--yes"])
.current_dir(temp_dir.path())
.output()
.expect("Failed to execute command");
assert!(
!output.status.success(),
"Command should fail when config exists"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("already exists") || stderr.contains("--force"),
"Should mention config exists or suggest --force"
);
}
#[test]
fn test_init_force_creates_backup() {
let temp_dir = create_temp_git_repo("git@github.com:owner/repo.git");
let config_path = temp_dir.path().join(".devboy.toml");
let original_content = "# original config\n[contexts.old]\n";
fs::write(&config_path, original_content).unwrap();
let output = Command::new(devboy_bin())
.args(["init", "--yes", "--force"])
.current_dir(temp_dir.path())
.output()
.expect("Failed to execute command");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
output.status.success(),
"Command should succeed with --force"
);
assert!(stdout.contains("backup"), "Should mention backup creation");
let entries: Vec<_> = fs::read_dir(temp_dir.path())
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_string_lossy()
.starts_with(".devboy.toml.backup")
})
.collect();
assert_eq!(entries.len(), 1, "Should have exactly one backup file");
let backup_content = fs::read_to_string(entries[0].path()).unwrap();
assert_eq!(
backup_content, original_content,
"Backup should contain original content"
);
let new_content = fs::read_to_string(&config_path).unwrap();
assert_ne!(
new_content, original_content,
"New config should be different from original"
);
}
#[test]
fn test_init_no_git_remote_creates_empty_config() {
let temp_dir = TempDir::new().unwrap();
let init_output = Command::new("git")
.args(["init"])
.current_dir(temp_dir.path())
.output()
.expect("Failed to spawn git init");
assert!(
init_output.status.success(),
"git init failed: {}",
String::from_utf8_lossy(&init_output.stderr)
);
let output = Command::new(devboy_bin())
.args(["init", "--yes"])
.current_dir(temp_dir.path())
.output()
.expect("Failed to execute command");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(output.status.success(), "Command should succeed");
assert!(
stdout.contains("No git remote detected"),
"Should indicate no remote found"
);
}
#[test]
fn test_init_dry_run_with_force_shows_would_backup() {
let temp_dir = create_temp_git_repo("git@github.com:owner/repo.git");
let config_path = temp_dir.path().join(".devboy.toml");
fs::write(&config_path, "# existing\n").unwrap();
let output = Command::new(devboy_bin())
.args(["init", "--yes", "--dry-run", "--force"])
.current_dir(temp_dir.path())
.output()
.expect("Failed to execute command");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(output.status.success(), "Command should succeed");
assert!(stdout.contains("[dry-run]"), "Should be in dry-run mode");
let backups: Vec<_> = fs::read_dir(temp_dir.path())
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().contains(".backup"))
.collect();
assert!(
backups.is_empty(),
"No backup should be created in dry-run mode"
);
}
#[test]
fn test_init_unknown_provider_no_config() {
let temp_dir = create_temp_git_repo("git@bitbucket.org:owner/repo.git");
let output = Command::new(devboy_bin())
.args(["init", "--yes"])
.current_dir(temp_dir.path())
.output()
.expect("Failed to execute command");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(output.status.success(), "Command should succeed");
assert!(
stdout.contains("No git remote detected") || stdout.contains("minimal config"),
"Should indicate unknown provider or minimal config"
);
}
#[test]
fn test_init_with_proxy_flag() {
let temp_dir = create_temp_git_repo("git@github.com:owner/repo.git");
let config_path = temp_dir.path().join(".devboy.toml");
let output = Command::new(devboy_bin())
.args([
"init",
"--yes",
"--proxy",
"https://app.devboy.pro/api/mcp",
"--proxy-name",
"devboy-cloud",
"--proxy-transport",
"streamable-http",
])
.current_dir(temp_dir.path())
.output()
.expect("Failed to execute command");
assert!(output.status.success(), "Command should succeed");
assert!(config_path.exists(), "Config file should be created");
let content = fs::read_to_string(&config_path).unwrap();
assert!(
content.contains("[[proxy_mcp_servers]]"),
"Should contain proxy_mcp_servers section"
);
assert!(
content.contains("devboy-cloud"),
"Should contain proxy name"
);
assert!(
content.contains("https://app.devboy.pro/api/mcp"),
"Should contain proxy URL"
);
assert!(
content.contains("streamable-http"),
"Should contain transport type"
);
}
#[test]
fn test_init_with_proxy_and_token_key() {
let temp_dir = create_temp_git_repo("git@github.com:owner/repo.git");
let config_path = temp_dir.path().join(".devboy.toml");
let output = Command::new(devboy_bin())
.args([
"init",
"--yes",
"--proxy",
"https://example.com/mcp",
"--proxy-token-key",
"my.secret.token",
])
.current_dir(temp_dir.path())
.output()
.expect("Failed to execute command");
assert!(output.status.success(), "Command should succeed");
let content = fs::read_to_string(&config_path).unwrap();
assert!(
content.contains("token_key"),
"Should contain token_key field"
);
assert!(
content.contains("my.secret.token"),
"Should contain token key value"
);
assert!(
content.contains("bearer"),
"Should have bearer auth type when token_key is set"
);
}
#[test]
fn test_init_with_proxy_token() {
let temp_dir = create_temp_git_repo("git@github.com:owner/repo.git");
let config_path = temp_dir.path().join(".devboy.toml");
let output = Command::new(devboy_bin())
.args([
"init",
"--yes",
"--proxy",
"https://example.com/mcp",
"--proxy-name",
"my-server",
"--proxy-token",
"secret-token-value",
])
.env("DEVBOY_SKIP_KEYCHAIN", "1") .current_dir(temp_dir.path())
.output()
.expect("Failed to execute command");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(output.status.success(), "Command should succeed");
assert!(
stdout.contains("Stored") || stdout.contains("keychain"),
"Should mention token storage"
);
let content = fs::read_to_string(&config_path).unwrap();
assert!(
content.contains("proxy.my-server.token"),
"Should contain auto-generated token key"
);
assert!(content.contains("bearer"), "Should have bearer auth type");
}
#[test]
fn test_init_with_proxy_auth_type() {
let temp_dir = create_temp_git_repo("git@github.com:owner/repo.git");
let config_path = temp_dir.path().join(".devboy.toml");
let output = Command::new(devboy_bin())
.args([
"init",
"--yes",
"--proxy",
"https://example.com/mcp",
"--proxy-token-key",
"my.key",
"--proxy-auth-type",
"api_key",
])
.current_dir(temp_dir.path())
.output()
.expect("Failed to execute command");
assert!(output.status.success(), "Command should succeed");
let content = fs::read_to_string(&config_path).unwrap();
assert!(content.contains("api_key"), "Should have api_key auth type");
}
#[test]
fn test_init_with_proxy_only_skips_git_detection() {
let temp_dir = create_temp_git_repo("git@github.com:owner/repo.git");
let config_path = temp_dir.path().join(".devboy.toml");
let output = Command::new(devboy_bin())
.args([
"init",
"--yes",
"--proxy",
"https://example.com/mcp",
"--proxy-only",
"--proxy-name",
"my-server",
])
.current_dir(temp_dir.path())
.output()
.expect("Failed to execute command");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(output.status.success(), "Command should succeed");
assert!(
!stdout.contains("Detected GitHub"),
"Should NOT detect GitHub when --proxy-only is used"
);
let content = fs::read_to_string(&config_path).unwrap();
assert!(
content.contains("my-server"),
"Should contain proxy server name"
);
assert!(
content.contains("https://example.com/mcp"),
"Should contain proxy URL"
);
assert!(
!content.contains("[contexts.") || !content.contains(".github]"),
"Should NOT contain github config section"
);
assert!(
!content.contains("owner = "),
"Should NOT contain github owner"
);
}
#[test]
fn test_init_remote_config_url_skips_git_detection_by_default() {
let temp_dir = create_temp_git_repo("git@gitlab.com:company/project.git");
let config_path = temp_dir.path().join(".devboy.toml");
let output = Command::new(devboy_bin())
.args([
"init",
"--yes",
"--remote-config-url",
"https://example.com/config",
])
.current_dir(temp_dir.path())
.output()
.expect("Failed to execute command");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(output.status.success(), "Command should succeed");
assert!(
!stdout.contains("Detected GitLab"),
"Should NOT auto-detect GitLab when --remote-config-url is set"
);
let content = fs::read_to_string(&config_path).unwrap();
assert!(
content.contains("[remote_config]"),
"Should contain [remote_config] section"
);
assert!(
content.contains("https://example.com/config"),
"Should contain remote config URL"
);
assert!(
!content.contains(".gitlab]"),
"Should NOT contain a [contexts.*.gitlab] section when remote config is the source of truth"
);
assert!(
!content.contains("company/project"),
"Should NOT contain git-detected project path"
);
}
#[test]
fn test_init_remote_config_url_with_detect_git_keeps_auto_detection() {
let temp_dir = create_temp_git_repo("git@github.com:test-owner/test-repo.git");
let config_path = temp_dir.path().join(".devboy.toml");
let output = Command::new(devboy_bin())
.args([
"init",
"--yes",
"--remote-config-url",
"https://example.com/config",
"--detect-git",
])
.current_dir(temp_dir.path())
.output()
.expect("Failed to execute command");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(output.status.success(), "Command should succeed");
assert!(
stdout.contains("Detected GitHub repository"),
"Should auto-detect GitHub when --detect-git override is passed"
);
let content = fs::read_to_string(&config_path).unwrap();
assert!(
content.contains("[remote_config]"),
"Should still contain [remote_config] section"
);
assert!(
content.contains("test-owner"),
"Should contain auto-detected owner"
);
assert!(
content.contains("test-repo"),
"Should contain auto-detected repo"
);
}
#[test]
fn test_proxy_add_creates_config() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join(".devboy.toml");
fs::write(&config_path, "").unwrap();
let output = Command::new(devboy_bin())
.args([
"proxy",
"add",
"my-server",
"--url",
"https://example.com/mcp",
])
.current_dir(temp_dir.path())
.output()
.expect("Failed to execute command");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(output.status.success(), "Command should succeed");
assert!(
stdout.contains("Added proxy 'my-server'"),
"Should confirm proxy added"
);
let content = fs::read_to_string(&config_path).unwrap();
assert!(
content.contains("[[proxy_mcp_servers]]"),
"Should contain proxy section"
);
assert!(content.contains("my-server"), "Should contain proxy name");
}
#[test]
fn test_proxy_add_with_all_options() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join(".devboy.toml");
fs::write(&config_path, "").unwrap();
let output = Command::new(devboy_bin())
.args([
"proxy",
"add",
"custom-proxy",
"--url",
"https://custom.example.com/mcp",
"--transport",
"sse",
"--token-key",
"custom.token",
])
.current_dir(temp_dir.path())
.output()
.expect("Failed to execute command");
assert!(output.status.success(), "Command should succeed");
let content = fs::read_to_string(&config_path).unwrap();
assert!(
content.contains("custom-proxy"),
"Should contain proxy name"
);
assert!(
content.contains("https://custom.example.com/mcp"),
"Should contain URL"
);
assert!(content.contains("sse"), "Should contain transport");
assert!(content.contains("custom.token"), "Should contain token key");
}
#[test]
fn test_proxy_add_fails_without_force_if_exists() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join(".devboy.toml");
let existing_config = r#"
[[proxy_mcp_servers]]
name = "existing"
url = "https://old.example.com/mcp"
transport = "sse"
"#;
fs::write(&config_path, existing_config).unwrap();
let output = Command::new(devboy_bin())
.args([
"proxy",
"add",
"existing",
"--url",
"https://new.example.com/mcp",
])
.current_dir(temp_dir.path())
.output()
.expect("Failed to execute command");
assert!(
!output.status.success(),
"Command should fail without --force"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("already exists") || stderr.contains("--force"),
"Should mention proxy exists or suggest --force"
);
}
#[test]
fn test_proxy_add_with_force_overwrites() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join(".devboy.toml");
let existing_config = r#"
[[proxy_mcp_servers]]
name = "existing"
url = "https://old.example.com/mcp"
transport = "sse"
"#;
fs::write(&config_path, existing_config).unwrap();
let output = Command::new(devboy_bin())
.args([
"proxy",
"add",
"existing",
"--url",
"https://new.example.com/mcp",
"--force",
])
.current_dir(temp_dir.path())
.output()
.expect("Failed to execute command");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
output.status.success(),
"Command should succeed with --force"
);
assert!(stdout.contains("Overwriting"), "Should mention overwriting");
let content = fs::read_to_string(&config_path).unwrap();
assert!(
content.contains("https://new.example.com/mcp"),
"Should contain new URL"
);
assert!(
!content.contains("https://old.example.com/mcp"),
"Should not contain old URL"
);
}
#[test]
fn test_proxy_remove() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join(".devboy.toml");
let existing_config = r#"
[[proxy_mcp_servers]]
name = "to-remove"
url = "https://example.com/mcp"
transport = "sse"
"#;
fs::write(&config_path, existing_config).unwrap();
let output = Command::new(devboy_bin())
.args(["proxy", "remove", "to-remove"])
.current_dir(temp_dir.path())
.output()
.expect("Failed to execute command");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(output.status.success(), "Command should succeed");
assert!(
stdout.contains("Removed proxy 'to-remove'"),
"Should confirm removal"
);
let content = fs::read_to_string(&config_path).unwrap();
assert!(
!content.contains("to-remove"),
"Should not contain removed proxy"
);
}
#[test]
fn test_proxy_remove_nonexistent_fails() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join(".devboy.toml");
fs::write(&config_path, "").unwrap();
let output = Command::new(devboy_bin())
.args(["proxy", "remove", "nonexistent"])
.current_dir(temp_dir.path())
.output()
.expect("Failed to execute command");
assert!(
!output.status.success(),
"Command should fail for nonexistent proxy"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("not found"),
"Should indicate proxy not found"
);
}
#[test]
fn test_init_claude_flag_help_shows_option() {
let output = Command::new(devboy_bin())
.args(["init", "--help"])
.output()
.expect("Failed to execute command");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(output.status.success());
assert!(
stdout.contains("--claude"),
"Help should mention --claude flag"
);
assert!(
stdout.contains("in Claude Code"),
"Help should describe --claude flag"
);
}
#[test]
fn test_init_with_claude_and_proxy_name_uses_custom_name() {
let temp_dir = create_temp_git_repo("git@github.com:owner/repo.git");
let config_path = temp_dir.path().join(".devboy.toml");
let fake_home = TempDir::new().unwrap();
let output = Command::new(devboy_bin())
.args([
"init",
"--yes",
"--proxy",
"https://example.com/mcp",
"--proxy-name",
"my-custom-server",
"--claude",
])
.env("HOME", fake_home.path())
.env("USERPROFILE", fake_home.path())
.env("DEVBOY_SKIP_KEYCHAIN", "1")
.current_dir(temp_dir.path())
.output()
.expect("Failed to execute command");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(config_path.exists(), "Config file should be created");
let content = fs::read_to_string(&config_path).unwrap();
assert!(
content.contains("my-custom-server"),
"Config should contain custom proxy name"
);
assert!(
stdout.contains("my-custom-server"),
"Output should contain the custom server name 'my-custom-server': {}",
stdout
);
}
#[test]
fn test_init_with_claude_without_proxy_uses_default_name() {
let temp_dir = create_temp_git_repo("git@github.com:owner/repo.git");
let config_path = temp_dir.path().join(".devboy.toml");
let fake_home = TempDir::new().unwrap();
let output = Command::new(devboy_bin())
.args(["init", "--yes", "--claude"])
.env("HOME", fake_home.path())
.env("USERPROFILE", fake_home.path())
.current_dir(temp_dir.path())
.output()
.expect("Failed to execute command");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(config_path.exists(), "Config file should be created");
assert!(
stdout.contains("'devboy'") || stdout.contains("\"devboy\""),
"Output should contain 'devboy' as the default server name: {}",
stdout
);
}
#[test]
fn test_init_with_claude_creates_claude_json_with_custom_name() {
let temp_dir = create_temp_git_repo("git@github.com:owner/repo.git");
let fake_home = TempDir::new().unwrap();
let claude_json_path = fake_home.path().join(".claude.json");
let output = Command::new(devboy_bin())
.args([
"init",
"--yes",
"--proxy",
"https://example.com/mcp",
"--proxy-name",
"custom-mcp-server",
"--claude",
])
.env("HOME", fake_home.path())
.env("USERPROFILE", fake_home.path())
.env("DEVBOY_SKIP_KEYCHAIN", "1")
.current_dir(temp_dir.path())
.output()
.expect("Failed to execute command");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("custom-mcp-server"),
"Output should contain custom server name: {}",
stdout
);
if claude_json_path.exists() {
let claude_content = fs::read_to_string(&claude_json_path).unwrap();
let claude_config: serde_json::Value = serde_json::from_str(&claude_content).unwrap();
let global_mcp = &claude_config["mcpServers"]["custom-mcp-server"];
let registered_globally = global_mcp.is_object();
let registered_in_project = claude_config["projects"]
.as_object()
.map(|projects| {
projects
.values()
.any(|project| project["mcpServers"]["custom-mcp-server"].is_object())
})
.unwrap_or(false);
assert!(
registered_globally || registered_in_project,
"MCP server should be registered with custom name 'custom-mcp-server'. \
Global: {}, Project: {}. Config: {}",
registered_globally,
registered_in_project,
claude_content
);
let devboy_global = claude_config["mcpServers"]["devboy"].is_object();
let devboy_in_project = claude_config["projects"]
.as_object()
.map(|projects| {
projects
.values()
.any(|project| project["mcpServers"]["devboy"].is_object())
})
.unwrap_or(false);
assert!(
!devboy_global && !devboy_in_project,
"MCP server should NOT be registered as 'devboy' when --proxy-name is provided"
);
} else {
assert!(
stdout.contains("custom-mcp-server") || stdout.contains("Claude CLI"),
"Should either create .claude.json or mention Claude CLI registration"
);
}
}
#[test]
fn test_init_with_claude_preserves_existing_mcp_servers() {
let temp_dir = create_temp_git_repo("git@github.com:owner/repo.git");
let fake_home = TempDir::new().unwrap();
let claude_json_path = fake_home.path().join(".claude.json");
let existing_config = r#"{
"mcpServers": {
"existing-server": {
"command": "some-other-cmd",
"args": ["arg1", "arg2"]
}
},
"someOtherSetting": "value"
}"#;
fs::write(&claude_json_path, existing_config).unwrap();
let output = Command::new(devboy_bin())
.args([
"init",
"--yes",
"--proxy",
"https://example.com/mcp",
"--proxy-name",
"new-server",
"--claude",
])
.env("HOME", fake_home.path())
.env("USERPROFILE", fake_home.path())
.env("DEVBOY_SKIP_KEYCHAIN", "1")
.current_dir(temp_dir.path())
.output()
.expect("Failed to execute command");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
output.status.success(),
"Command should succeed: stderr={}",
String::from_utf8_lossy(&output.stderr)
);
let used_claude_cli = stdout.contains("Successfully registered via Claude CLI");
if claude_json_path.exists() {
let claude_content = fs::read_to_string(&claude_json_path).unwrap();
let claude_config: serde_json::Value = serde_json::from_str(&claude_content).unwrap();
assert!(
claude_config["mcpServers"]["existing-server"].is_object(),
"Existing MCP server should be preserved"
);
assert_eq!(
claude_config["mcpServers"]["existing-server"]["command"], "some-other-cmd",
"Existing server command should be unchanged"
);
assert_eq!(
claude_config["someOtherSetting"], "value",
"Other settings should be preserved"
);
let new_server_global = claude_config["mcpServers"]["new-server"].is_object();
let new_server_in_project = claude_config["projects"]
.as_object()
.map(|projects| {
projects
.values()
.any(|project| project["mcpServers"]["new-server"].is_object())
})
.unwrap_or(false);
if !used_claude_cli && (new_server_global || new_server_in_project) {
assert!(
new_server_global || new_server_in_project,
"New MCP server should be added either globally or in project. Config: {}",
claude_content
);
}
}
}
#[test]
fn test_init_kimi_flag_help_shows_option() {
let output = Command::new(devboy_bin())
.args(["init", "--help"])
.output()
.expect("Failed to execute command");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(output.status.success());
assert!(stdout.contains("--kimi"), "Help should mention --kimi flag");
assert!(
stdout.contains("in Kimi CLI"),
"Help should describe --kimi flag"
);
}
#[test]
fn test_init_with_kimi_and_proxy_name_uses_custom_name() {
let temp_dir = create_temp_git_repo("git@github.com:owner/repo.git");
let config_path = temp_dir.path().join(".devboy.toml");
let output = Command::new(devboy_bin())
.args([
"init",
"--yes",
"--proxy",
"https://example.com/mcp",
"--proxy-name",
"my-custom-server",
"--kimi",
])
.env("DEVBOY_SKIP_KEYCHAIN", "1")
.current_dir(temp_dir.path())
.output()
.expect("Failed to execute command");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(config_path.exists(), "Config file should be created");
let content = fs::read_to_string(&config_path).unwrap();
assert!(
content.contains("my-custom-server"),
"Config should contain custom proxy name"
);
assert!(
stdout.contains("my-custom-server"),
"Output should contain the custom server name 'my-custom-server': {}",
stdout
);
}
#[test]
fn test_init_with_kimi_without_proxy_uses_default_name() {
let temp_dir = create_temp_git_repo("git@github.com:owner/repo.git");
let config_path = temp_dir.path().join(".devboy.toml");
let output = Command::new(devboy_bin())
.args(["init", "--yes", "--kimi"])
.current_dir(temp_dir.path())
.output()
.expect("Failed to execute command");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(config_path.exists(), "Config file should be created");
assert!(
stdout.contains("'devboy'") || stdout.contains("\"devboy\""),
"Output should contain 'devboy' as the default server name: {}",
stdout
);
}
#[test]
fn test_init_with_kimi_creates_kimi_mcp_json_with_custom_name() {
let temp_dir = create_temp_git_repo("git@github.com:owner/repo.git");
let kimi_json_path = temp_dir.path().join(".kimi").join("mcp.json");
let output = Command::new(devboy_bin())
.args([
"init",
"--yes",
"--proxy",
"https://example.com/mcp",
"--proxy-name",
"custom-mcp-server",
"--kimi",
])
.env("DEVBOY_SKIP_KEYCHAIN", "1")
.current_dir(temp_dir.path())
.output()
.expect("Failed to execute command");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("custom-mcp-server"),
"Output should contain custom server name: {}",
stdout
);
assert!(kimi_json_path.exists(), ".kimi/mcp.json should be created");
let kimi_content = fs::read_to_string(&kimi_json_path).unwrap();
let kimi_config: serde_json::Value = serde_json::from_str(&kimi_content).unwrap();
assert!(
kimi_config["mcpServers"]["custom-mcp-server"].is_object(),
"MCP server should be registered with custom name 'custom-mcp-server'. Config: {}",
kimi_content
);
assert!(
kimi_config["mcpServers"]["devboy"].is_null(),
"MCP server should NOT be registered as 'devboy' when --proxy-name is provided"
);
}
#[test]
fn test_init_with_kimi_preserves_existing_mcp_servers() {
let temp_dir = create_temp_git_repo("git@github.com:owner/repo.git");
let kimi_dir = temp_dir.path().join(".kimi");
let kimi_json_path = kimi_dir.join("mcp.json");
fs::create_dir_all(&kimi_dir).unwrap();
let existing_config = r#"{
"mcpServers": {
"existing-server": {
"command": "some-other-cmd",
"args": ["arg1", "arg2"]
}
},
"someOtherSetting": "value"
}"#;
fs::write(&kimi_json_path, existing_config).unwrap();
let output = Command::new(devboy_bin())
.args([
"init",
"--yes",
"--proxy",
"https://example.com/mcp",
"--proxy-name",
"new-server",
"--kimi",
])
.env("DEVBOY_SKIP_KEYCHAIN", "1")
.current_dir(temp_dir.path())
.output()
.expect("Failed to execute command");
assert!(
output.status.success(),
"Command should succeed: stderr={}",
String::from_utf8_lossy(&output.stderr)
);
let kimi_content = fs::read_to_string(&kimi_json_path).unwrap();
let kimi_config: serde_json::Value = serde_json::from_str(&kimi_content).unwrap();
assert!(
kimi_config["mcpServers"]["existing-server"].is_object(),
"Existing MCP server should be preserved"
);
assert_eq!(
kimi_config["mcpServers"]["existing-server"]["command"], "some-other-cmd",
"Existing server command should be unchanged"
);
assert_eq!(
kimi_config["someOtherSetting"], "value",
"Other settings should be preserved"
);
assert!(
kimi_config["mcpServers"]["new-server"].is_object(),
"New MCP server should be added. Config: {}",
kimi_content
);
}
#[test]
fn test_init_codex_cli_flag_help_shows_option() {
let output = Command::new(devboy_bin())
.args(["init", "--help"])
.output()
.expect("Failed to execute command");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(output.status.success());
assert!(
stdout.contains("--codex-cli"),
"Help should mention --codex-cli flag"
);
assert!(
stdout.contains("in Codex CLI"),
"Help should describe --codex-cli flag"
);
}
#[test]
fn test_init_with_codex_cli_creates_config() {
let temp_dir = create_temp_git_repo("git@github.com:owner/repo.git");
let fake_home = TempDir::new().unwrap();
let output = Command::new(devboy_bin())
.args(["init", "--yes", "--codex-cli"])
.env("HOME", fake_home.path())
.env("USERPROFILE", fake_home.path())
.env("DEVBOY_NO_NATIVE_MCP", "1")
.current_dir(temp_dir.path())
.output()
.expect("Failed to execute command");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
output.status.success(),
"Command should succeed: stderr={}",
String::from_utf8_lossy(&output.stderr)
);
assert!(
stdout.contains("'devboy'") || stdout.contains("\"devboy\""),
"Output should contain 'devboy' as the default server name: {}",
stdout
);
let codex_toml = fake_home.path().join(".codex").join("config.toml");
if codex_toml.exists() {
let content = fs::read_to_string(&codex_toml).unwrap();
assert!(
content.contains("[mcp_servers.devboy]"),
"Codex config should contain devboy MCP server"
);
}
}
#[test]
fn test_init_copilot_flag_help_shows_option() {
let output = Command::new(devboy_bin())
.args(["init", "--help"])
.output()
.expect("Failed to execute command");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(output.status.success());
assert!(
stdout.contains("--copilot"),
"Help should mention --copilot flag"
);
assert!(
stdout.contains("in Copilot CLI"),
"Help should describe --copilot flag"
);
}
#[test]
fn test_init_with_copilot_creates_config() {
let temp_dir = create_temp_git_repo("git@github.com:owner/repo.git");
let fake_home = TempDir::new().unwrap();
let output = Command::new(devboy_bin())
.args(["init", "--yes", "--copilot"])
.env("HOME", fake_home.path())
.env("USERPROFILE", fake_home.path())
.current_dir(temp_dir.path())
.output()
.expect("Failed to execute command");
assert!(
output.status.success(),
"Command should succeed: stderr={}",
String::from_utf8_lossy(&output.stderr)
);
let copilot_json = fake_home.path().join(".copilot").join("mcp-config.json");
if copilot_json.exists() {
let content = fs::read_to_string(&copilot_json).unwrap();
let config: serde_json::Value = serde_json::from_str(&content).unwrap();
assert!(
config["mcpServers"]["devboy"].is_object(),
"MCP server should be registered"
);
assert_eq!(config["mcpServers"]["devboy"]["type"], "local");
assert_eq!(config["mcpServers"]["devboy"]["tools"][0], "*");
}
}
#[test]
fn test_init_gemini_flag_help_shows_option() {
let output = Command::new(devboy_bin())
.args(["init", "--help"])
.output()
.expect("Failed to execute command");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(output.status.success());
assert!(
stdout.contains("--gemini"),
"Help should mention --gemini flag"
);
assert!(
stdout.contains("in Gemini CLI"),
"Help should describe --gemini flag"
);
}
#[test]
fn test_init_with_gemini_creates_config() {
let temp_dir = create_temp_git_repo("git@github.com:owner/repo.git");
let output = Command::new(devboy_bin())
.args(["init", "--yes", "--gemini"])
.env("DEVBOY_NO_NATIVE_MCP", "1")
.current_dir(temp_dir.path())
.output()
.expect("Failed to execute command");
assert!(
output.status.success(),
"Command should succeed: stderr={}",
String::from_utf8_lossy(&output.stderr)
);
let gemini_json = temp_dir.path().join(".gemini").join("settings.json");
assert!(gemini_json.exists(), "Gemini config should be created");
let content = fs::read_to_string(&gemini_json).unwrap();
let config: serde_json::Value = serde_json::from_str(&content).unwrap();
assert!(
config["mcpServers"]["devboy"].is_object(),
"MCP server should be registered"
);
assert_eq!(config["mcpServers"]["devboy"]["trust"], true);
}
#[test]
fn test_init_opencode_flag_help_shows_option() {
let output = Command::new(devboy_bin())
.args(["init", "--help"])
.output()
.expect("Failed to execute command");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(output.status.success());
assert!(
stdout.contains("--opencode"),
"Help should mention --opencode flag"
);
assert!(
stdout.contains("in OpenCode"),
"Help should describe --opencode flag"
);
}
#[test]
fn test_init_with_opencode_creates_config() {
let temp_dir = create_temp_git_repo("git@github.com:owner/repo.git");
let output = Command::new(devboy_bin())
.args(["init", "--yes", "--opencode"])
.current_dir(temp_dir.path())
.output()
.expect("Failed to execute command");
assert!(
output.status.success(),
"Command should succeed: stderr={}",
String::from_utf8_lossy(&output.stderr)
);
let opencode_json = temp_dir.path().join("opencode.json");
assert!(opencode_json.exists(), "OpenCode config should be created");
let content = fs::read_to_string(&opencode_json).unwrap();
let config: serde_json::Value = serde_json::from_str(&content).unwrap();
assert!(
config["mcp"]["devboy"].is_object(),
"MCP server should be registered"
);
assert_eq!(config["mcp"]["devboy"]["type"], "local");
}
#[test]
fn test_init_forge_flag_help_shows_option() {
let output = Command::new(devboy_bin())
.args(["init", "--help"])
.output()
.expect("Failed to execute command");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(output.status.success());
assert!(
stdout.contains("--forge"),
"Help should mention --forge flag"
);
assert!(
stdout.contains("in ForgeCode"),
"Help should describe --forge flag"
);
}
#[test]
fn test_init_with_forge_creates_config() {
let temp_dir = create_temp_git_repo("git@github.com:owner/repo.git");
let output = Command::new(devboy_bin())
.args(["init", "--yes", "--forge"])
.current_dir(temp_dir.path())
.output()
.expect("Failed to execute command");
assert!(
output.status.success(),
"Command should succeed: stderr={}",
String::from_utf8_lossy(&output.stderr)
);
let forge_json = temp_dir.path().join(".mcp.json");
assert!(forge_json.exists(), "ForgeCode config should be created");
let content = fs::read_to_string(&forge_json).unwrap();
let config: serde_json::Value = serde_json::from_str(&content).unwrap();
assert!(
config["mcpServers"]["devboy"].is_object(),
"MCP server should be registered"
);
}