diff --git a/.gitignore b/.gitignore index da0bacf0..a87404b8 100644 --- a/.gitignore +++ b/.gitignore @@ -18,8 +18,18 @@ Thumbs.db # Node node_modules/ tests/evals/js/eval-bun/test-data.txt + .bt/ +# Agents +# claude is tracked +.agents +.codex +.copilot +.cursor +.gemini +.qwen + __pycache__ bt-sync diff --git a/README.md b/README.md index 78221b0d..ef4f9d95 100644 --- a/README.md +++ b/README.md @@ -306,7 +306,7 @@ Use setup/docs commands to configure coding-agent skills and workflow docs for B Current behavior: -- Supported agents: `claude`, `codex`, `cursor`, `gemini`, `opencode`. +- Supported agents: `claude`, `codex`, `copilot`, `cursor`, `gemini`, `opencode`, `qwen`. - If no `--agent` values are provided, `bt` auto-detects likely agents from local/global context and falls back to all supported agents when none are detected. - In interactive TTY mode, skills setup shows a checklist so you can select/deselect agents before install. - In interactive TTY mode, setup also shows a workflow checklist and prefetches those docs automatically. @@ -319,7 +319,8 @@ Current behavior: - Use `--refresh-docs` in setup (or `bt docs fetch --refresh`) to clear old docs before re-fetching. - `cursor` is local-only in this flow. If selected with `--global`, `bt` prints a warning and continues installing the other selected agents. - Claude integration installs the Braintrust skill file under `.claude/skills/braintrust/SKILL.md`. -- Gemini integration symlinks `.gemini/skills` to `.agents/skills/braintrust/SKILL.md`. +- Gemini and Qwen integration symlink `.gemini/skills`/`.qwen/skills` to `.agents/skills/braintrust/SKILL.md`. +- Copilot integration symlinks `.copilot/skills` to `.agents/skills/braintrust/SKILL.md`. MCP config is written via `copilot mcp add` to the project `.copilot` dir (local) or the default user config (global). - Cursor integration installs `.cursor/rules/braintrust.mdc` with the same shared Braintrust guidance plus an auto-generated command-reference excerpt from this README. - Setup-time docs prefetch writes to `.bt/skills/docs` for `--local` and `~/.config/bt/skills/docs` (or `$XDG_CONFIG_HOME/bt/skills/docs`) for `--global`. - Docs fetch writes LLM-friendly local indexes: `.bt/skills/docs/README.md` and per-section `.bt/skills/docs/
/_index.md` (or the global equivalents under `~/.config/bt/skills/docs`). diff --git a/scripts/skill-smoke-test.sh b/scripts/skill-smoke-test.sh index 0d46aec9..7cb18219 100755 --- a/scripts/skill-smoke-test.sh +++ b/scripts/skill-smoke-test.sh @@ -16,7 +16,7 @@ Usage: scripts/skill-smoke-test.sh [options] Options: - --agent Agent to install (claude|codex|cursor|gemini|opencode). Default: codex + --agent Agent to install (claude|codex|copilot|cursor|gemini|opencode|qwen). Default: codex --bt-bin bt binary path. Default: bt --demo-dir Demo repo directory. Default: create temp dir --agent-cmd Command to run the agent after scaffold (optional) @@ -83,7 +83,7 @@ while [[ $# -gt 0 ]]; do done case "$AGENT" in - claude|codex|cursor|gemini|opencode) ;; + claude|codex|copilot|cursor|gemini|opencode|qwen) ;; *) echo "Unsupported --agent value: $AGENT" >&2 exit 2 @@ -169,12 +169,18 @@ find_skill_path() { codex|opencode) echo ".agents/skills/braintrust/SKILL.md" ;; + copilot) + echo ".copilot/skills/braintrust/SKILL.md" + ;; cursor) echo ".cursor/rules/braintrust.mdc" ;; gemini) echo ".gemini/skills/braintrust/SKILL.md" ;; + qwen) + echo ".qwen/skills/braintrust/SKILL.md" + ;; esac } @@ -211,7 +217,7 @@ verify_demo() { [[ -n "$line" ]] && changed_user_files+=("$line") done < <( printf '%s\n' "${changed_files[@]}" \ - | rg -v '^(\.claude/|\.agents/|\.cursor/|skills/docs/|AGENT_TASK\.md$)' || true + | rg -v '^(\.claude/|\.agents/|\.copilot/|\.cursor/|\.qwen/|skills/docs/|AGENT_TASK\.md$)' || true ) fi diff --git a/src/setup/agent_stream.rs b/src/setup/agent_stream.rs index 886942de..f0f43364 100644 --- a/src/setup/agent_stream.rs +++ b/src/setup/agent_stream.rs @@ -30,6 +30,37 @@ enum StreamLine { #[serde(flatten)] _extra: Value, }, + // cursor-agent emits tool_call events instead of stream_event + #[serde(rename = "tool_call")] + ToolCall { + subtype: String, + tool_call: Value, + #[serde(flatten)] + _extra: Value, + }, + // gemini emits tool_use / tool_result pairs + #[serde(rename = "tool_use")] + GeminiToolUse { + tool_name: String, + parameters: Value, + #[serde(flatten)] + _extra: Value, + }, + #[serde(rename = "tool_result")] + GeminiToolResult { + #[serde(flatten)] + _extra: Value, + }, + // gemini streams assistant text as message events + #[serde(rename = "message")] + GeminiMessage { + role: String, + content: String, + #[serde(default)] + delta: bool, + #[serde(flatten)] + _extra: Value, + }, #[serde(rename = "user")] User { #[serde(flatten)] @@ -111,6 +142,7 @@ struct AgentStreamDisplay { spinner: Option, has_text_output: bool, is_tty: bool, + current_tool_desc: Option, } impl AgentStreamDisplay { @@ -120,12 +152,71 @@ impl AgentStreamDisplay { spinner: None, has_text_output: false, is_tty: std::io::stderr().is_terminal(), + current_tool_desc: None, } } fn handle(&mut self, line: StreamLine) { match line { StreamLine::StreamEvent { event, .. } => self.handle_event(event), + StreamLine::ToolCall { + subtype, tool_call, .. + } => { + if subtype == "started" { + let desc = cursor_tool_display(&tool_call); + self.current_tool_desc = Some(desc.clone()); + self.clear_spinner(); + if self.has_text_output { + eprintln!(); + self.has_text_output = false; + } + self.start_spinner(&desc); + } else if subtype == "completed" { + let done = self + .current_tool_desc + .take() + .as_deref() + .map(tool_done_from_in_progress) + .unwrap_or_else(|| cursor_tool_done_display(&tool_call)); + self.finish_spinner_with(&done); + } + } + StreamLine::GeminiToolUse { + tool_name, + parameters, + .. + } => { + let desc = gemini_tool_display(&tool_name, ¶meters); + self.current_tool_desc = Some(desc.clone()); + self.clear_spinner(); + if self.has_text_output { + eprintln!(); + self.has_text_output = false; + } + self.start_spinner(&desc); + } + StreamLine::GeminiToolResult { .. } => { + let done = self + .current_tool_desc + .take() + .as_deref() + .map(tool_done_from_in_progress) + .unwrap_or_else(|| "Done".to_string()); + self.finish_spinner_with(&done); + } + StreamLine::GeminiMessage { + role, + content, + delta, + .. + } => { + if role == "assistant" && delta && !content.is_empty() { + self.clear_spinner(); + eprint!("{}", style(&content).dim()); + let _ = std::io::stderr().flush(); + self.has_text_output = true; + } + } StreamLine::Assistant { .. } | StreamLine::User { .. } | StreamLine::Unknown => {} StreamLine::Result { .. } => {} } @@ -342,6 +433,123 @@ fn short_path(path: &str) -> String { .join("/") } +fn gemini_tool_display(tool_name: &str, parameters: &Value) -> String { + match tool_name { + "read_file" | "view_file" => { + if let Some(p) = parameters.get("file_path").and_then(|v| v.as_str()) { + return format!("Reading {}", short_path(p)); + } + "Reading".to_string() + } + "write_file" | "create_file" => { + if let Some(p) = parameters.get("file_path").and_then(|v| v.as_str()) { + return format!("Writing {}", short_path(p)); + } + "Writing".to_string() + } + "edit_file" | "replace_in_file" => { + if let Some(p) = parameters.get("file_path").and_then(|v| v.as_str()) { + return format!("Editing {}", short_path(p)); + } + "Editing".to_string() + } + "run_shell_command" | "bash" | "shell" | "run_command" => { + if let Some(cmd) = parameters.get("command").and_then(|v| v.as_str()) { + return format!("Running: {}", truncate(cmd, 50)); + } + "Running command".to_string() + } + "search_files" | "grep" | "search" => { + if let Some(q) = parameters + .get("pattern") + .or_else(|| parameters.get("query")) + .and_then(|v| v.as_str()) + { + return format!("Searching {}", truncate(q, 30)); + } + "Searching".to_string() + } + "list_directory" | "ls" => { + if let Some(p) = parameters + .get("directory_path") + .or_else(|| parameters.get("path")) + .and_then(|v| v.as_str()) + { + return format!("Listing {}", short_path(p)); + } + "Listing directory".to_string() + } + "glob" | "find_files" => "Finding files".to_string(), + other => other.to_string(), + } +} + +fn tool_done_from_in_progress(desc: &str) -> String { + for (prefix, done) in [ + ("Running: ", "Ran: "), + ("Reading ", "Read "), + ("Writing ", "Wrote "), + ("Editing ", "Edited "), + ("Searching ", "Searched "), + ("Listing ", "Listed "), + ("Finding ", "Found "), + ] { + if let Some(rest) = desc.strip_prefix(prefix) { + return format!("{done}{rest}"); + } + } + desc.to_string() +} + +fn cursor_tool_display(tool_call: &Value) -> String { + if let Some(cmd) = tool_call + .pointer("/shellToolCall/args/command") + .and_then(|v| v.as_str()) + { + return format!("Running: {}", truncate(cmd, 50)); + } + cursor_tool_label(tool_call, false) +} + +fn cursor_tool_done_display(tool_call: &Value) -> String { + if let Some(cmd) = tool_call + .pointer("/shellToolCall/args/command") + .and_then(|v| v.as_str()) + { + return format!("Ran: {}", truncate(cmd, 50)); + } + if let Some(cmd) = tool_call + .pointer("/shellToolCall/result/success/command") + .and_then(|v| v.as_str()) + { + return format!("Ran: {}", truncate(cmd, 50)); + } + cursor_tool_label(tool_call, true) +} + +fn cursor_tool_label(tool_call: &Value, done: bool) -> String { + let key = tool_call + .as_object() + .and_then(|o| o.keys().next()) + .map(|s| s.to_lowercase()) + .unwrap_or_default(); + let k = key.as_str(); + match done { + false if k.contains("read") || k.contains("view") => "Reading".to_string(), + true if k.contains("read") || k.contains("view") => "Read".to_string(), + false if k.contains("edit") || k.contains("write") => "Editing".to_string(), + true if k.contains("edit") || k.contains("write") => "Edited".to_string(), + false if k.contains("create") => "Creating".to_string(), + true if k.contains("create") => "Created".to_string(), + false if k.contains("glob") => "Finding files".to_string(), + true if k.contains("glob") => "Found files".to_string(), + false if k.contains("search") || k.contains("grep") => "Searching".to_string(), + true if k.contains("search") || k.contains("grep") => "Searched".to_string(), + true => "Done".to_string(), + false => "Working".to_string(), + } +} + fn truncate(s: &str, max: usize) -> String { if s.chars().count() <= max { s.to_string() diff --git a/src/setup/mod.rs b/src/setup/mod.rs index c4a9df8f..b35f9dcf 100644 --- a/src/setup/mod.rs +++ b/src/setup/mod.rs @@ -36,12 +36,14 @@ const BT_README: &str = include_str!("../../README.md"); const README_AGENT_SECTION_MARKERS: &[&str] = &[ "bt eval", "bt sql", "bt view", "bt auth", "bt setup", "bt docs", ]; -const ALL_AGENTS: [Agent; 5] = [ +const ALL_AGENTS: [Agent; 7] = [ Agent::Claude, Agent::Codex, + Agent::Copilot, Agent::Cursor, Agent::Gemini, Agent::Opencode, + Agent::Qwen, ]; const ALL_WORKFLOWS: [WorkflowArg; 5] = [ WorkflowArg::Instrument, @@ -264,9 +266,11 @@ struct AgentsDoctorArgs { enum AgentArg { Claude, Codex, + Copilot, Cursor, Gemini, Opencode, + Qwen, } #[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Serialize)] @@ -274,9 +278,18 @@ enum AgentArg { enum Agent { Claude, Codex, + Copilot, Cursor, Gemini, Opencode, + Qwen, +} + +struct AgentMetadata { + binary: &'static str, + repo_marker: Option<&'static str>, + home_markers: &'static [&'static str], + skill_alias_dir: Option<&'static str>, } impl Agent { @@ -284,9 +297,11 @@ impl Agent { match self { Agent::Claude => "claude", Agent::Codex => "codex", + Agent::Copilot => "copilot", Agent::Cursor => "cursor", Agent::Gemini => "gemini", Agent::Opencode => "opencode", + Agent::Qwen => "qwen", } } @@ -294,11 +309,70 @@ impl Agent { match self { Agent::Claude => "Claude", Agent::Codex => "Codex", + Agent::Copilot => "Copilot", Agent::Cursor => "Cursor", Agent::Gemini => "Gemini", Agent::Opencode => "Opencode", + Agent::Qwen => "Qwen", + } + } + + fn metadata(self) -> AgentMetadata { + match self { + Agent::Claude => AgentMetadata { + binary: "claude", + repo_marker: Some(".claude"), + home_markers: &[".claude"], + skill_alias_dir: Some(".claude"), + }, + Agent::Codex => AgentMetadata { + binary: "codex", + repo_marker: None, + home_markers: &[".codex"], + skill_alias_dir: None, + }, + Agent::Copilot => AgentMetadata { + binary: "copilot", + repo_marker: Some(".copilot"), + home_markers: &[".copilot"], + skill_alias_dir: Some(".copilot"), + }, + Agent::Cursor => AgentMetadata { + binary: "cursor-agent", + repo_marker: Some(".cursor"), + home_markers: &[".cursor"], + skill_alias_dir: Some(".cursor"), + }, + Agent::Gemini => AgentMetadata { + binary: "gemini", + repo_marker: Some(".gemini"), + home_markers: &[".gemini"], + skill_alias_dir: Some(".gemini"), + }, + Agent::Opencode => AgentMetadata { + binary: "opencode", + repo_marker: Some(".opencode"), + home_markers: &[".opencode", ".config/opencode"], + skill_alias_dir: None, + }, + Agent::Qwen => AgentMetadata { + binary: "qwen", + repo_marker: Some(".qwen"), + home_markers: &[".qwen"], + skill_alias_dir: Some(".qwen"), + }, } } + + fn install_skill( + self, + scope: InstallScope, + local_root: Option<&Path>, + home: &Path, + ) -> Result { + let alias_dir = self.metadata().skill_alias_dir; + install_agent_skill(self, scope, local_root, home, alias_dir) + } } #[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, ValueEnum)] @@ -1872,13 +1946,7 @@ async fn execute_skills_setup( } for agent in selected_agents.iter().copied() { - let result = match agent { - Agent::Claude => install_claude(scope, local_root.as_deref(), &home), - Agent::Codex => install_codex(scope, local_root.as_deref(), &home), - Agent::Cursor => install_cursor(scope, local_root.as_deref(), &home), - Agent::Gemini => install_gemini(scope, local_root.as_deref(), &home), - Agent::Opencode => install_opencode(scope, local_root.as_deref(), &home), - }; + let result = agent.install_skill(scope, local_root.as_deref(), &home); match result { Ok(r) => { @@ -1941,9 +2009,11 @@ async fn execute_skills_setup( enum InstrumentAgentArg { Claude, Codex, + Copilot, Cursor, Gemini, Opencode, + Qwen, } /// Languages supported by `--language`. Variants map to canonical display @@ -2374,9 +2444,11 @@ fn map_agent_to_agent_arg(agent: Agent) -> AgentArg { match agent { Agent::Claude => AgentArg::Claude, Agent::Codex => AgentArg::Codex, + Agent::Copilot => AgentArg::Copilot, Agent::Cursor => AgentArg::Cursor, Agent::Gemini => AgentArg::Gemini, Agent::Opencode => AgentArg::Opencode, + Agent::Qwen => AgentArg::Qwen, } } @@ -2384,9 +2456,11 @@ fn map_agent_to_instrument_agent_arg(agent: Agent) -> InstrumentAgentArg { match agent { Agent::Claude => InstrumentAgentArg::Claude, Agent::Codex => InstrumentAgentArg::Codex, + Agent::Copilot => InstrumentAgentArg::Copilot, Agent::Cursor => InstrumentAgentArg::Cursor, Agent::Gemini => InstrumentAgentArg::Gemini, Agent::Opencode => InstrumentAgentArg::Opencode, + Agent::Qwen => InstrumentAgentArg::Qwen, } } @@ -2399,9 +2473,12 @@ fn skill_config_path( let root = scope_root(scope, local_root, home)?; let path = match agent { Agent::Claude => root.join(".claude/skills/braintrust/SKILL.md"), - Agent::Codex | Agent::Opencode | Agent::Cursor | Agent::Gemini => { - root.join(".agents/skills/braintrust/SKILL.md") - } + Agent::Codex + | Agent::Copilot + | Agent::Opencode + | Agent::Cursor + | Agent::Gemini + | Agent::Qwen => root.join(".agents/skills/braintrust/SKILL.md"), }; Ok(path) } @@ -2647,6 +2724,7 @@ fn resolve_instrument_invocation( } } else { gemini_args.extend([ + "--skip-trust".to_string(), "-p".to_string(), String::new(), "--output-format".to_string(), @@ -2683,7 +2761,6 @@ fn resolve_instrument_invocation( "-p".to_string(), "--output-format".to_string(), "stream-json".to_string(), - "--stream-partial-output".to_string(), ]); InstrumentInvocation::Program { program: "cursor-agent".to_string(), @@ -2696,6 +2773,75 @@ fn resolve_instrument_invocation( } } } + Agent::Qwen => { + let mut qwen_args = vec![]; + if bypass_permissions { + qwen_args.push("--yolo".to_string()); + } + if interactive { + qwen_args.push("-i".to_string()); + InstrumentInvocation::Program { + program: "qwen".to_string(), + args: qwen_args, + stdin_file: None, + prompt_file_arg: Some(task_path.to_path_buf()), + initial_prompt: None, + stream_json: false, + interactive: true, + } + } else { + qwen_args.extend([ + "-p".to_string(), + "--output-format".to_string(), + "stream-json".to_string(), + "--include-partial-messages".to_string(), + ]); + InstrumentInvocation::Program { + program: "qwen".to_string(), + args: qwen_args, + stdin_file: None, + prompt_file_arg: Some(task_path.to_path_buf()), + initial_prompt: None, + stream_json: true, + interactive: false, + } + } + } + Agent::Copilot => { + let mut copilot_args = vec![]; + if bypass_permissions { + copilot_args.push("--yolo".to_string()); + } + if interactive { + copilot_args.push("-i".to_string()); + InstrumentInvocation::Program { + program: "copilot".to_string(), + args: copilot_args, + stdin_file: None, + prompt_file_arg: Some(task_path.to_path_buf()), + initial_prompt: None, + stream_json: false, + interactive: true, + } + } else { + copilot_args.extend([ + "--no-ask-user".to_string(), + "--stream".to_string(), + "on".to_string(), + "-s".to_string(), + "-p".to_string(), + ]); + InstrumentInvocation::Program { + program: "copilot".to_string(), + args: copilot_args, + stdin_file: None, + prompt_file_arg: Some(task_path.to_path_buf()), + initial_prompt: None, + stream_json: false, + interactive: false, + } + } + } }; Ok(invocation) } @@ -3225,9 +3371,11 @@ fn run_doctor(base: BaseArgs, args: AgentsDoctorArgs) -> Result<()> { let agents = [ Agent::Claude, Agent::Codex, + Agent::Copilot, Agent::Cursor, Agent::Gemini, Agent::Opencode, + Agent::Qwen, ] .iter() .map(|agent| doctor_agent_status(*agent, scope, local_root.as_deref(), &home, &detected)) @@ -3595,9 +3743,11 @@ fn map_instrument_agent_arg_to_agent_arg(agent: InstrumentAgentArg) -> AgentArg match agent { InstrumentAgentArg::Claude => AgentArg::Claude, InstrumentAgentArg::Codex => AgentArg::Codex, + InstrumentAgentArg::Copilot => AgentArg::Copilot, InstrumentAgentArg::Cursor => AgentArg::Cursor, InstrumentAgentArg::Gemini => AgentArg::Gemini, InstrumentAgentArg::Opencode => AgentArg::Opencode, + InstrumentAgentArg::Qwen => AgentArg::Qwen, } } @@ -3605,9 +3755,11 @@ fn map_agent_arg(agent: AgentArg) -> Agent { match agent { AgentArg::Claude => Agent::Claude, AgentArg::Codex => Agent::Codex, + AgentArg::Copilot => Agent::Copilot, AgentArg::Cursor => Agent::Cursor, AgentArg::Gemini => Agent::Gemini, AgentArg::Opencode => Agent::Opencode, + AgentArg::Qwen => Agent::Qwen, } } @@ -3620,6 +3772,8 @@ fn pick_agent_mode_target(candidates: &[Agent]) -> Option { Agent::Codex, Agent::Claude, Agent::Gemini, + Agent::Qwen, + Agent::Copilot, Agent::Cursor, Agent::Opencode, ]; @@ -3670,23 +3824,11 @@ fn detected_agents_on_path(detected: &[DetectionSignal]) -> Vec { } fn detect_runnable_agents() -> Vec { - let mut agents = Vec::new(); - if command_exists("claude") { - agents.push(Agent::Claude); - } - if command_exists("codex") { - agents.push(Agent::Codex); - } - if command_exists("cursor-agent") { - agents.push(Agent::Cursor); - } - if command_exists("gemini") { - agents.push(Agent::Gemini); - } - if command_exists("opencode") { - agents.push(Agent::Opencode); - } - agents + ALL_AGENTS + .iter() + .copied() + .filter(|agent| command_exists(agent.metadata().binary)) + .collect() } fn resolve_workflow_selection(requested: &[WorkflowArg]) -> Vec { @@ -3707,37 +3849,17 @@ fn detect_agents(local_root: Option<&Path>, home: &Path) -> Vec let mut by_agent: BTreeMap> = BTreeMap::new(); if let Some(root) = local_root { - if root.join(".claude").exists() { - add_signal( - &mut by_agent, - Agent::Claude, - false, - ".claude exists in repo root", - ); - } - if root.join(".cursor").exists() { - add_signal( - &mut by_agent, - Agent::Cursor, - false, - ".cursor exists in repo root", - ); - } - if root.join(".gemini").exists() { - add_signal( - &mut by_agent, - Agent::Gemini, - false, - ".gemini exists in repo root", - ); - } - if root.join(".opencode").exists() { - add_signal( - &mut by_agent, - Agent::Opencode, - false, - ".opencode exists in repo root", - ); + for agent in ALL_AGENTS { + if let Some(marker) = agent.metadata().repo_marker { + if root.join(marker).exists() { + add_signal( + &mut by_agent, + agent, + false, + &format!("{marker} exists in repo root"), + ); + } + } } if root.join(".agents").exists() || root.join(".agents/skills").exists() { add_signal( @@ -3763,17 +3885,12 @@ fn detect_agents(local_root: Option<&Path>, home: &Path) -> Vec } } - if home.join(".claude").exists() { - add_signal(&mut by_agent, Agent::Claude, false, "~/.claude exists"); - } - if home.join(".cursor").exists() { - add_signal(&mut by_agent, Agent::Cursor, false, "~/.cursor exists"); - } - if home.join(".gemini").exists() { - add_signal(&mut by_agent, Agent::Gemini, false, "~/.gemini exists"); - } - if home.join(".codex").exists() { - add_signal(&mut by_agent, Agent::Codex, false, "~/.codex exists"); + for agent in ALL_AGENTS { + for marker in agent.metadata().home_markers { + if home.join(marker).exists() { + add_signal(&mut by_agent, agent, false, &format!("~/{} exists", marker)); + } + } } if home.join(".agents/skills").exists() { add_signal( @@ -3798,45 +3915,16 @@ fn detect_agents(local_root: Option<&Path>, home: &Path) -> Vec ); } - if command_exists("claude") { - add_signal( - &mut by_agent, - Agent::Claude, - true, - "`claude` binary found in PATH", - ); - } - if command_exists("codex") { - add_signal( - &mut by_agent, - Agent::Codex, - true, - "`codex` binary found in PATH", - ); - } - if command_exists("cursor-agent") { - add_signal( - &mut by_agent, - Agent::Cursor, - true, - "`cursor-agent` binary found in PATH", - ); - } - if command_exists("gemini") { - add_signal( - &mut by_agent, - Agent::Gemini, - true, - "`gemini` binary found in PATH", - ); - } - if command_exists("opencode") { - add_signal( - &mut by_agent, - Agent::Opencode, - true, - "`opencode` binary found in PATH", - ); + for agent in ALL_AGENTS { + let binary = agent.metadata().binary; + if command_exists(binary) { + add_signal( + &mut by_agent, + agent, + true, + &format!("`{binary}` binary found in PATH"), + ); + } } let mut out = Vec::new(); @@ -3863,129 +3951,27 @@ fn add_signal( .insert((on_path, reason.to_string())); } -fn install_claude( - scope: InstallScope, - local_root: Option<&Path>, - home: &Path, -) -> Result { - let root = scope_root(scope, local_root, home)?; - let skill_content = render_braintrust_skill(); - let (skill_changed, skill_path) = install_canonical_skill(root, &skill_content)?; - let alias = ensure_agent_skills_alias(root, ".claude", &skill_content)?; - let changed = skill_changed || alias.changed; - - Ok(AgentInstallResult { - agent: Agent::Claude, - status: if changed { - InstallStatus::Installed - } else { - InstallStatus::Skipped - }, - message: if changed { - "installed skill".to_string() - } else { - "already configured".to_string() - }, - paths: vec![ - skill_path.display().to_string(), - alias.path.display().to_string(), - ], - }) -} - -fn install_codex( - scope: InstallScope, - local_root: Option<&Path>, - home: &Path, -) -> Result { - let root = scope_root(scope, local_root, home)?; - let skill_content = render_braintrust_skill(); - let (changed, skill_path) = install_canonical_skill(root, &skill_content)?; - - Ok(AgentInstallResult { - agent: Agent::Codex, - status: if changed { - InstallStatus::Installed - } else { - InstallStatus::Skipped - }, - message: if changed { - "installed skill".to_string() - } else { - "already configured".to_string() - }, - paths: vec![skill_path.display().to_string()], - }) -} - -fn install_opencode( - scope: InstallScope, - local_root: Option<&Path>, - home: &Path, -) -> Result { - let root = scope_root(scope, local_root, home)?; - let skill_content = render_braintrust_skill(); - let (changed, skill_path) = install_canonical_skill(root, &skill_content)?; - - Ok(AgentInstallResult { - agent: Agent::Opencode, - status: if changed { - InstallStatus::Installed - } else { - InstallStatus::Skipped - }, - message: if changed { - "installed skill".to_string() - } else { - "already configured".to_string() - }, - paths: vec![skill_path.display().to_string()], - }) -} - -fn install_cursor( +fn install_agent_skill( + agent: Agent, scope: InstallScope, local_root: Option<&Path>, home: &Path, + alias_dir: Option<&str>, ) -> Result { let root = scope_root(scope, local_root, home)?; let skill_content = render_braintrust_skill(); - let (skill_changed, skill_path) = install_canonical_skill(root, &skill_content)?; - let alias = ensure_agent_skills_alias(root, ".cursor", &skill_content)?; - let changed = skill_changed || alias.changed; + let (canonical_changed, skill_path) = install_canonical_skill(root, &skill_content)?; + let mut changed = canonical_changed; + let mut paths = vec![skill_path.display().to_string()]; - Ok(AgentInstallResult { - agent: Agent::Cursor, - status: if changed { - InstallStatus::Installed - } else { - InstallStatus::Skipped - }, - message: if changed { - "installed skill".to_string() - } else { - "already configured".to_string() - }, - paths: vec![ - skill_path.display().to_string(), - alias.path.display().to_string(), - ], - }) -} - -fn install_gemini( - scope: InstallScope, - local_root: Option<&Path>, - home: &Path, -) -> Result { - let root = scope_root(scope, local_root, home)?; - let skill_content = render_braintrust_skill(); - let (skill_changed, skill_path) = install_canonical_skill(root, &skill_content)?; - let alias = ensure_agent_skills_alias(root, ".gemini", &skill_content)?; - let changed = skill_changed || alias.changed; + if let Some(alias_dir) = alias_dir { + let alias = ensure_agent_skills_alias(root, alias_dir, &skill_content)?; + changed |= alias.changed; + paths.push(alias.path.display().to_string()); + } Ok(AgentInstallResult { - agent: Agent::Gemini, + agent, status: if changed { InstallStatus::Installed } else { @@ -3996,10 +3982,7 @@ fn install_gemini( } else { "already configured".to_string() }, - paths: vec![ - skill_path.display().to_string(), - alias.path.display().to_string(), - ], + paths, }) } @@ -4121,8 +4104,10 @@ fn install_mcp_for_agent( InstallScope::Global => install_mcp_for_codex(mcp_url, api_key), }, Agent::Cursor => install_mcp_for_cursor(scope, local_root, home, api_key, mcp_url), + Agent::Copilot => install_mcp_for_copilot(scope, local_root, home, api_key, mcp_url), Agent::Gemini => install_mcp_for_gemini(scope, local_root, home, api_key, mcp_url), Agent::Opencode => install_mcp_for_opencode(scope, local_root, home, api_key, mcp_url), + Agent::Qwen => install_mcp_for_qwen(scope, local_root, home, api_key, mcp_url), } } @@ -4162,13 +4147,8 @@ fn install_mcp_for_codex_local( ) -> Result { let root = scope_root(InstallScope::Local, local_root, home)?; let path = root.join(".codex/config.toml"); - merge_codex_config(&path, api_key, mcp_url)?; - - Ok(AgentInstallResult { - agent: Agent::Codex, - status: InstallStatus::Installed, - message: "installed MCP config".to_string(), - paths: vec![path.display().to_string()], + install_mcp_config_file(Agent::Codex, path, "installed MCP config", |path| { + merge_codex_config(path, api_key, mcp_url) }) } @@ -4183,13 +4163,32 @@ fn install_mcp_for_cursor( InstallScope::Local => scope_root(scope, local_root, home)?.join(".cursor/mcp.json"), InstallScope::Global => home.join(".cursor/mcp.json"), }; - merge_mcp_config(&path, api_key, mcp_url)?; - enable_cursor_mcp(local_root)?; + install_mcp_config_file( + Agent::Cursor, + path, + "installed MCP config and enabled server", + |path| { + merge_mcp_config(path, api_key, mcp_url)?; + enable_cursor_mcp(local_root) + }, + ) +} + +fn install_mcp_config_file( + agent: Agent, + path: PathBuf, + message: &str, + install: F, +) -> Result +where + F: FnOnce(&Path) -> Result<()>, +{ + install(&path)?; Ok(AgentInstallResult { - agent: Agent::Cursor, + agent, status: InstallStatus::Installed, - message: "installed MCP config and enabled server".to_string(), + message: message.to_string(), paths: vec![path.display().to_string()], }) } @@ -4224,62 +4223,133 @@ fn install_mcp_for_claude( mcp_url: &str, api_key: &str, ) -> Result { - let (scope_name, cwd, path_label) = match scope { - InstallScope::Local => ( - "project", - Some(scope_root(scope, local_root, home)?.to_path_buf()), - "claude:project".to_string(), - ), - InstallScope::Global => ("user", None, "claude:user".to_string()), - }; - - let status = std::process::Command::new("claude") - .args([ - "mcp", - "add", - "-s", - scope_name, - "--transport", - "http", - "braintrust", - mcp_url, - "--header", - &format!("Authorization: Bearer {api_key}"), - ]) - .current_dir(cwd.unwrap_or_else(|| home.to_path_buf())) - .stdout(Stdio::null()) - .status() - .with_context(|| format!("failed to run `claude mcp add -s {scope_name}`"))?; - - if !status.success() { - bail!("`claude mcp add -s {scope_name}` exited with status {status}"); - } + install_mcp_for_http_cli_agent( + McpHttpCliAgentConfig { + agent: Agent::Claude, + binary: "claude", + header_flag: "--header", + }, + scope, + local_root, + home, + api_key, + mcp_url, + ) +} - Ok(AgentInstallResult { - agent: Agent::Claude, - status: InstallStatus::Installed, +fn install_mcp_for_gemini( + scope: InstallScope, + local_root: Option<&Path>, + home: &Path, + api_key: &str, + mcp_url: &str, +) -> Result { + install_mcp_for_http_cli_agent( + McpHttpCliAgentConfig { + agent: Agent::Gemini, + binary: "gemini", + header_flag: "-H", + }, + scope, + local_root, + home, + api_key, + mcp_url, + ) +} + +fn install_mcp_for_qwen( + scope: InstallScope, + local_root: Option<&Path>, + home: &Path, + api_key: &str, + mcp_url: &str, +) -> Result { + install_mcp_for_http_cli_agent( + McpHttpCliAgentConfig { + agent: Agent::Qwen, + binary: "qwen", + header_flag: "-H", + }, + scope, + local_root, + home, + api_key, + mcp_url, + ) +} + +fn install_mcp_for_copilot( + scope: InstallScope, + local_root: Option<&Path>, + home: &Path, + api_key: &str, + mcp_url: &str, +) -> Result { + let mut args = vec![ + "mcp".to_string(), + "add".to_string(), + "--transport".to_string(), + "http".to_string(), + "--header".to_string(), + format!("Authorization: Bearer {api_key}"), + ]; + let (cwd, scope_name) = match scope { + InstallScope::Local => { + let root = scope_root(scope, local_root, home)?; + args.extend([ + "--config-dir".to_string(), + root.join(".copilot").display().to_string(), + ]); + (root.to_path_buf(), "project") + } + InstallScope::Global => (home.to_path_buf(), "user"), + }; + args.extend(["braintrust".to_string(), mcp_url.to_string()]); + + let status = std::process::Command::new("copilot") + .args(&args) + .current_dir(&cwd) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .with_context(|| "failed to run `copilot mcp add`")?; + + if !status.success() { + bail!("`copilot mcp add` exited with status {status}"); + } + + Ok(AgentInstallResult { + agent: Agent::Copilot, + status: InstallStatus::Installed, message: "installed MCP config".to_string(), - paths: vec![path_label], + paths: vec![format!("copilot:{scope_name}")], }) } -fn install_mcp_for_gemini( +struct McpHttpCliAgentConfig { + agent: Agent, + binary: &'static str, + header_flag: &'static str, +} + +fn install_mcp_for_http_cli_agent( + config: McpHttpCliAgentConfig, scope: InstallScope, local_root: Option<&Path>, home: &Path, api_key: &str, mcp_url: &str, ) -> Result { - let (scope_name, cwd, path_label) = match scope { + let (scope_name, cwd) = match scope { InstallScope::Local => ( "project", scope_root(scope, local_root, home)?.to_path_buf(), - "gemini:project".to_string(), ), - InstallScope::Global => ("user", home.to_path_buf(), "gemini:user".to_string()), + InstallScope::Global => ("user", home.to_path_buf()), }; - let status = std::process::Command::new("gemini") + let status = std::process::Command::new(config.binary) .args([ "mcp", "add", @@ -4289,23 +4359,26 @@ fn install_mcp_for_gemini( "http", "braintrust", mcp_url, - "-H", + config.header_flag, &format!("Authorization: Bearer {api_key}"), ]) .current_dir(&cwd) .stdout(Stdio::null()) .status() - .with_context(|| format!("failed to run `gemini mcp add -s {scope_name}`"))?; + .with_context(|| format!("failed to run `{} mcp add -s {scope_name}`", config.binary))?; if !status.success() { - bail!("`gemini mcp add -s {scope_name}` exited with status {status}"); + bail!( + "`{} mcp add -s {scope_name}` exited with status {status}", + config.binary + ); } Ok(AgentInstallResult { - agent: Agent::Gemini, + agent: config.agent, status: InstallStatus::Installed, message: "installed MCP config".to_string(), - paths: vec![path_label], + paths: vec![format!("{}:{scope_name}", config.agent.as_str())], }) } @@ -4324,13 +4397,8 @@ fn install_mcp_for_opencode( InstallScope::Local => scope_root(scope, local_root, home)?.join("opencode.json"), InstallScope::Global => home.join(".config/opencode/opencode.json"), }; - merge_opencode_config(&path, api_key, mcp_url)?; - - Ok(AgentInstallResult { - agent: Agent::Opencode, - status: InstallStatus::Installed, - message: "installed MCP config".to_string(), - paths: vec![path.display().to_string()], + install_mcp_config_file(Agent::Opencode, path, "installed MCP config", |path| { + merge_opencode_config(path, api_key, mcp_url) }) } @@ -5638,6 +5706,7 @@ mod tests { assert_eq!( args, vec![ + "--skip-trust".to_string(), "-p".to_string(), String::new(), "--output-format".to_string(), @@ -5704,7 +5773,6 @@ mod tests { "-p".to_string(), "--output-format".to_string(), "stream-json".to_string(), - "--stream-partial-output".to_string(), ] ); assert_eq!(stdin_file, None); @@ -5743,6 +5811,135 @@ mod tests { } } + #[test] + fn qwen_instrument_invocation_uses_stream_json_with_prompt_arg() { + let task_path = PathBuf::from("/tmp/AGENT_TASK.instrument.md"); + let invocation = + resolve_instrument_invocation(Agent::Qwen, None, &task_path, false, false, &[]) + .expect("resolve instrument invocation"); + + match invocation { + InstrumentInvocation::Program { + program, + args, + stdin_file, + prompt_file_arg, + stream_json, + interactive, + .. + } => { + assert_eq!(program, "qwen"); + assert_eq!( + args, + vec![ + "-p".to_string(), + "--output-format".to_string(), + "stream-json".to_string(), + "--include-partial-messages".to_string(), + ] + ); + assert_eq!(stdin_file, None); + assert_eq!(prompt_file_arg, Some(task_path)); + assert!(stream_json); + assert!(!interactive); + } + InstrumentInvocation::Shell(_) => panic!("expected program invocation"), + } + } + + #[test] + fn qwen_interactive_instrument_invocation_uses_prompt_interactive() { + let task_path = PathBuf::from("/tmp/AGENT_TASK.instrument.md"); + let invocation = + resolve_instrument_invocation(Agent::Qwen, None, &task_path, true, true, &[]) + .expect("resolve instrument invocation"); + + match invocation { + InstrumentInvocation::Program { + program, + args, + stdin_file, + prompt_file_arg, + stream_json, + interactive, + .. + } => { + assert_eq!(program, "qwen"); + assert_eq!(args, vec!["--yolo".to_string(), "-i".to_string()]); + assert_eq!(stdin_file, None); + assert_eq!(prompt_file_arg, Some(task_path)); + assert!(!stream_json); + assert!(interactive); + } + InstrumentInvocation::Shell(_) => panic!("expected program invocation"), + } + } + + #[test] + fn copilot_instrument_invocation_uses_json_output_with_prompt_arg() { + let task_path = PathBuf::from("/tmp/AGENT_TASK.instrument.md"); + let invocation = + resolve_instrument_invocation(Agent::Copilot, None, &task_path, false, false, &[]) + .expect("resolve instrument invocation"); + + match invocation { + InstrumentInvocation::Program { + program, + args, + stdin_file, + prompt_file_arg, + stream_json, + interactive, + .. + } => { + assert_eq!(program, "copilot"); + assert_eq!( + args, + vec![ + "--no-ask-user".to_string(), + "--stream".to_string(), + "on".to_string(), + "-s".to_string(), + "-p".to_string(), + ] + ); + assert_eq!(stdin_file, None); + assert_eq!(prompt_file_arg, Some(task_path)); + assert!(!stream_json); + assert!(!interactive); + } + InstrumentInvocation::Shell(_) => panic!("expected program invocation"), + } + } + + #[test] + fn copilot_interactive_instrument_invocation_uses_interactive_flag() { + let task_path = PathBuf::from("/tmp/AGENT_TASK.instrument.md"); + let invocation = + resolve_instrument_invocation(Agent::Copilot, None, &task_path, true, true, &[]) + .expect("resolve instrument invocation"); + + match invocation { + InstrumentInvocation::Program { + program, + args, + stdin_file, + prompt_file_arg, + stream_json, + interactive, + .. + } => { + assert_eq!(program, "copilot"); + assert_eq!(args, vec!["--yolo".to_string(), "-i".to_string()]); + assert_eq!(stdin_file, None); + assert_eq!(prompt_file_arg, Some(task_path)); + assert!(!stream_json); + assert!(interactive); + } + InstrumentInvocation::Shell(_) => panic!("expected program invocation"), + } + } + #[test] fn sync_setup_api_key_sets_base_and_process_env() { let _guard = env_test_lock().lock().expect("lock env test"); @@ -5905,6 +6102,38 @@ mod tests { assert!(status.notes.is_empty()); } + #[test] + fn doctor_agent_status_reports_qwen_global_skill_path() { + let home = std::env::temp_dir(); + let status = doctor_agent_status(Agent::Qwen, InstallScope::Global, None, &home, &[]); + assert!(!status.configured); + assert_eq!( + status.config_path, + Some( + home.join(".agents/skills/braintrust/SKILL.md") + .display() + .to_string() + ) + ); + assert!(status.notes.is_empty()); + } + + #[test] + fn doctor_agent_status_reports_copilot_global_skill_path() { + let home = std::env::temp_dir(); + let status = doctor_agent_status(Agent::Copilot, InstallScope::Global, None, &home, &[]); + assert!(!status.configured); + assert_eq!( + status.config_path, + Some( + home.join(".agents/skills/braintrust/SKILL.md") + .display() + .to_string() + ) + ); + assert!(status.notes.is_empty()); + } + #[test] fn resolve_scope_from_flags_respects_global_flag() { let scope = @@ -6416,6 +6645,210 @@ mod tests { assert!(log.contains(&home.display().to_string())); } + #[test] + fn install_mcp_for_agent_invokes_qwen_project_scope_for_local() { + let _guard = cwd_test_lock().lock().expect("lock cwd test"); + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let root = env::temp_dir().join(format!("bt-agents-qwen-mcp-local-{unique}")); + let bin_dir = root.join("bin"); + let log_path = root.join("qwen.log"); + fs::create_dir_all(&bin_dir).expect("create bin dir"); + fs::create_dir_all(&root).expect("create root"); + + write_executable( + &bin_dir.join("qwen"), + &format!( + "#!/bin/sh\nprintf '%s\\n' \"$@\" >> \"{}\"\npwd >> \"{}\"\nexit 0\n", + log_path.display(), + log_path.display() + ), + ); + + let old_path = env::var("PATH").unwrap_or_default(); + env::set_var("PATH", format!("{}:{old_path}", bin_dir.display())); + + let home = root.join("home"); + fs::create_dir_all(&home).expect("create temp home"); + let result = install_mcp_for_agent( + Agent::Qwen, + InstallScope::Local, + Some(&root), + &home, + "qwen-api-key", + "https://api.example.com/mcp", + ) + .expect("install qwen local mcp"); + + env::set_var("PATH", old_path); + + assert!(matches!(result.status, InstallStatus::Installed)); + assert_eq!(result.paths, vec!["qwen:project".to_string()]); + let log = fs::read_to_string(&log_path).expect("read qwen log"); + assert!(log.contains("mcp")); + assert!(log.contains("add")); + assert!(log.contains("-s")); + assert!(log.contains("project")); + assert!(log.contains("--transport")); + assert!(log.contains("http")); + assert!(log.contains("braintrust")); + assert!(log.contains("https://api.example.com/mcp")); + assert!(log.contains("Authorization: Bearer qwen-api-key")); + assert!(log.contains(&root.display().to_string())); + } + + #[test] + fn install_mcp_for_agent_invokes_qwen_user_scope_for_global() { + let _guard = cwd_test_lock().lock().expect("lock cwd test"); + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let root = env::temp_dir().join(format!("bt-agents-qwen-mcp-global-{unique}")); + let bin_dir = root.join("bin"); + let log_path = root.join("qwen-global.log"); + fs::create_dir_all(&bin_dir).expect("create bin dir"); + fs::create_dir_all(&root).expect("create root"); + + write_executable( + &bin_dir.join("qwen"), + &format!( + "#!/bin/sh\nprintf '%s\\n' \"$@\" >> \"{}\"\npwd >> \"{}\"\nexit 0\n", + log_path.display(), + log_path.display() + ), + ); + + let old_path = env::var("PATH").unwrap_or_default(); + env::set_var("PATH", format!("{}:{old_path}", bin_dir.display())); + + let home = root.join("home"); + fs::create_dir_all(&home).expect("create temp home"); + let result = install_mcp_for_agent( + Agent::Qwen, + InstallScope::Global, + None, + &home, + "qwen-api-key", + "https://api.example.com/mcp", + ) + .expect("install qwen global mcp"); + + env::set_var("PATH", old_path); + + assert!(matches!(result.status, InstallStatus::Installed)); + assert_eq!(result.paths, vec!["qwen:user".to_string()]); + let log = fs::read_to_string(&log_path).expect("read qwen log"); + assert!(log.contains("user")); + assert!(log.contains(&home.display().to_string())); + } + + #[test] + fn install_mcp_for_agent_invokes_copilot_with_config_dir_for_local() { + let _guard = cwd_test_lock().lock().expect("lock cwd test"); + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let root = env::temp_dir().join(format!("bt-agents-copilot-mcp-local-{unique}")); + let bin_dir = root.join("bin"); + let log_path = root.join("copilot.log"); + fs::create_dir_all(&bin_dir).expect("create bin dir"); + fs::create_dir_all(&root).expect("create root"); + + write_executable( + &bin_dir.join("copilot"), + &format!( + "#!/bin/sh\nprintf '%s\\n' \"$@\" >> \"{}\"\npwd >> \"{}\"\nexit 0\n", + log_path.display(), + log_path.display() + ), + ); + + let old_path = env::var("PATH").unwrap_or_default(); + env::set_var("PATH", format!("{}:{old_path}", bin_dir.display())); + + let home = root.join("home"); + fs::create_dir_all(&home).expect("create temp home"); + let result = install_mcp_for_agent( + Agent::Copilot, + InstallScope::Local, + Some(&root), + &home, + "copilot-api-key", + "https://api.example.com/mcp", + ) + .expect("install copilot local mcp"); + + env::set_var("PATH", old_path); + + assert!(matches!(result.status, InstallStatus::Installed)); + assert_eq!(result.paths, vec!["copilot:project".to_string()]); + let log = fs::read_to_string(&log_path).expect("read copilot log"); + assert!(log.contains("mcp")); + assert!(log.contains("add")); + assert!(log.contains("--transport")); + assert!(log.contains("http")); + assert!(log.contains("--header")); + assert!(log.contains("Authorization: Bearer copilot-api-key")); + assert!(log.contains("--config-dir")); + assert!(log.contains(".copilot")); + assert!(log.contains("braintrust")); + assert!(log.contains("https://api.example.com/mcp")); + assert!(log.contains(&root.display().to_string())); + } + + #[test] + fn install_mcp_for_agent_invokes_copilot_without_config_dir_for_global() { + let _guard = cwd_test_lock().lock().expect("lock cwd test"); + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let root = env::temp_dir().join(format!("bt-agents-copilot-mcp-global-{unique}")); + let bin_dir = root.join("bin"); + let log_path = root.join("copilot-global.log"); + fs::create_dir_all(&bin_dir).expect("create bin dir"); + fs::create_dir_all(&root).expect("create root"); + + write_executable( + &bin_dir.join("copilot"), + &format!( + "#!/bin/sh\nprintf '%s\\n' \"$@\" >> \"{}\"\npwd >> \"{}\"\nexit 0\n", + log_path.display(), + log_path.display() + ), + ); + + let old_path = env::var("PATH").unwrap_or_default(); + env::set_var("PATH", format!("{}:{old_path}", bin_dir.display())); + + let home = root.join("home"); + fs::create_dir_all(&home).expect("create temp home"); + let result = install_mcp_for_agent( + Agent::Copilot, + InstallScope::Global, + None, + &home, + "copilot-api-key", + "https://api.example.com/mcp", + ) + .expect("install copilot global mcp"); + + env::set_var("PATH", old_path); + + assert!(matches!(result.status, InstallStatus::Installed)); + assert_eq!(result.paths, vec!["copilot:user".to_string()]); + let log = fs::read_to_string(&log_path).expect("read copilot log"); + assert!(!log.contains("--config-dir")); + assert!(log.contains("Authorization: Bearer copilot-api-key")); + assert!(log.contains("braintrust")); + assert!(log.contains("https://api.example.com/mcp")); + assert!(log.contains(&home.display().to_string())); + } + #[test] fn install_codex_is_idempotent_when_skill_is_unchanged() { let unique = SystemTime::now() @@ -6427,11 +6860,14 @@ mod tests { let home = root.join("home"); fs::create_dir_all(&home).expect("create temp home"); - let first = install_codex(InstallScope::Local, Some(&root), &home).expect("first install"); + let first = Agent::Codex + .install_skill(InstallScope::Local, Some(&root), &home) + .expect("first install"); assert!(matches!(first.status, InstallStatus::Installed)); - let second = - install_codex(InstallScope::Local, Some(&root), &home).expect("second install"); + let second = Agent::Codex + .install_skill(InstallScope::Local, Some(&root), &home) + .expect("second install"); assert!(matches!(second.status, InstallStatus::Skipped)); assert!(second.message.contains("already configured")); } @@ -6447,13 +6883,52 @@ mod tests { let home = root.join("home"); fs::create_dir_all(&home).expect("create temp home"); - let result = - install_gemini(InstallScope::Local, Some(&root), &home).expect("install gemini"); + let result = Agent::Gemini + .install_skill(InstallScope::Local, Some(&root), &home) + .expect("install gemini"); assert!(matches!(result.status, InstallStatus::Installed)); assert!(root.join(".agents/skills/braintrust/SKILL.md").exists()); assert!(root.join(".gemini/skills").exists()); } + #[test] + fn install_qwen_uses_canonical_agents_skill_path() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let root = std::env::temp_dir().join(format!("bt-agents-qwen-skill-{unique}")); + fs::create_dir_all(&root).expect("create temp root"); + let home = root.join("home"); + fs::create_dir_all(&home).expect("create temp home"); + + let result = Agent::Qwen + .install_skill(InstallScope::Local, Some(&root), &home) + .expect("install qwen"); + assert!(matches!(result.status, InstallStatus::Installed)); + assert!(root.join(".agents/skills/braintrust/SKILL.md").exists()); + assert!(root.join(".qwen/skills").exists()); + } + + #[test] + fn install_copilot_uses_canonical_agents_skill_path() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let root = std::env::temp_dir().join(format!("bt-agents-copilot-skill-{unique}")); + fs::create_dir_all(&root).expect("create temp root"); + let home = root.join("home"); + fs::create_dir_all(&home).expect("create temp home"); + + let result = Agent::Copilot + .install_skill(InstallScope::Local, Some(&root), &home) + .expect("install copilot"); + assert!(matches!(result.status, InstallStatus::Installed)); + assert!(root.join(".agents/skills/braintrust/SKILL.md").exists()); + assert!(root.join(".copilot/skills").exists()); + } + #[test] fn install_cursor_uses_canonical_agents_skill_path() { let unique = SystemTime::now() @@ -6465,8 +6940,9 @@ mod tests { let home = root.join("home"); fs::create_dir_all(&home).expect("create temp home"); - let result = - install_cursor(InstallScope::Local, Some(&root), &home).expect("install cursor"); + let result = Agent::Cursor + .install_skill(InstallScope::Local, Some(&root), &home) + .expect("install cursor"); assert!(matches!(result.status, InstallStatus::Installed)); assert!(root.join(".agents/skills/braintrust/SKILL.md").exists()); assert!(root.join(".cursor/skills").exists()); diff --git a/tests/cli.rs b/tests/cli.rs index e49519e6..1b1158f2 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -135,13 +135,7 @@ fn setup_uses_codex_detected_on_path_without_explicit_agent() { .env("HOME", home.path()) .env("XDG_CONFIG_HOME", config_home.path()) .env("PATH", bin_dir.path()) - .args([ - "setup", - "--global", - "--no-instrument", - "--no-workflow", - "--no-input", - ]) + .args(["setup", "skills", "--global", "--no-workflow", "--no-input"]) .assert() .success() .stdout(predicate::str::contains("Selected agents: codex").not()); @@ -166,13 +160,7 @@ fn setup_uses_gemini_detected_on_path_without_explicit_agent() { .env("HOME", home.path()) .env("XDG_CONFIG_HOME", config_home.path()) .env("PATH", bin_dir.path()) - .args([ - "setup", - "--global", - "--no-instrument", - "--no-workflow", - "--no-input", - ]) + .args(["setup", "skills", "--global", "--no-workflow", "--no-input"]) .assert() .success() .stdout(predicate::str::contains("Selected agents: gemini").not()); @@ -183,6 +171,57 @@ fn setup_uses_gemini_detected_on_path_without_explicit_agent() { .exists()); } +#[test] +fn setup_uses_qwen_detected_on_path_without_explicit_agent() { + let repo = make_git_repo(); + let home = tempfile::tempdir().expect("home tempdir"); + let config_home = tempfile::tempdir().expect("config tempdir"); + let bin_dir = tempfile::tempdir().expect("bin tempdir"); + write_executable(&bin_dir.path().join("qwen")); + + let mut cmd = bt_command(); + clear_braintrust_auth_env(&mut cmd); + cmd.current_dir(repo.path()) + .env("HOME", home.path()) + .env("XDG_CONFIG_HOME", config_home.path()) + .env("PATH", bin_dir.path()) + .args(["setup", "skills", "--global", "--no-workflow", "--no-input"]) + .assert() + .success() + .stdout(predicate::str::contains("Selected agents: qwen").not()); + + assert!(home + .path() + .join(".agents/skills/braintrust/SKILL.md") + .exists()); +} + +#[test] +fn setup_uses_copilot_detected_on_path_without_explicit_agent() { + let repo = make_git_repo(); + let home = tempfile::tempdir().expect("home tempdir"); + let config_home = tempfile::tempdir().expect("config tempdir"); + let bin_dir = tempfile::tempdir().expect("bin tempdir"); + write_executable(&bin_dir.path().join("copilot")); + + let mut cmd = bt_command(); + clear_braintrust_auth_env(&mut cmd); + cmd.current_dir(repo.path()) + .env("HOME", home.path()) + .env("XDG_CONFIG_HOME", config_home.path()) + .env("PATH", bin_dir.path()) + .args(["setup", "skills", "--global", "--no-workflow", "--no-input"]) + .assert() + .success() + .stdout(predicate::str::contains("Selected agents: copilot").not()); + + assert!(home + .path() + .join(".agents/skills/braintrust/SKILL.md") + .exists()); + assert!(home.path().join(".copilot/skills").exists()); +} + #[test] fn setup_verbose_prints_agent_summary() { let repo = make_git_repo(); @@ -199,9 +238,9 @@ fn setup_verbose_prints_agent_summary() { .env("PATH", bin_dir.path()) .args([ "setup", + "skills", "--verbose", "--global", - "--no-instrument", "--no-workflow", "--no-input", ])