Copilot CLI (#915)

This commit is contained in:
Solomon
2025-10-03 13:12:53 +01:00
committed by GitHub
parent 86f7c00d94
commit a43fa76079
8 changed files with 415 additions and 6 deletions

View File

@@ -119,6 +119,31 @@
"model": "grok"
}
}
},
"COPILOT": {
"DEFAULT": {
"COPILOT": {
"allow_all_tools": true
}
},
"GPT_5": {
"COPILOT": {
"allow_all_tools": true,
"model": "gpt-5"
}
},
"CLAUDE_SONNET_4_5": {
"COPILOT": {
"allow_all_tools": true,
"model": "claude-sonnet-4.5"
}
},
"CLAUDE_SONNET_4": {
"COPILOT": {
"allow_all_tools": true,
"model": "claude-sonnet-4"
}
}
}
}
}

View File

@@ -0,0 +1,280 @@
use std::{
path::{Path, PathBuf},
process::Stdio,
sync::Arc,
time::Duration,
};
use async_trait::async_trait;
use command_group::AsyncCommandGroup;
use futures::StreamExt;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use tokio::{
fs,
io::AsyncWriteExt,
process::Command,
time::{interval, timeout},
};
use ts_rs::TS;
use uuid::Uuid;
use workspace_utils::{
msg_store::MsgStore, path::get_vibe_kanban_temp_dir, shell::get_shell_command,
};
use crate::{
command::{CmdOverrides, CommandBuilder, apply_overrides},
executors::{AppendPrompt, ExecutorError, SpawnedChild, StandardCodingAgentExecutor},
logs::{
NormalizedEntry, NormalizedEntryType, plain_text_processor::PlainTextLogProcessor,
stderr_processor::normalize_stderr_logs, utils::EntryIndexProvider,
},
stdout_dup::{self, StdoutAppender},
};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS, JsonSchema)]
pub struct Copilot {
#[serde(default)]
pub append_prompt: AppendPrompt,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub allow_all_tools: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub allow_tool: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deny_tool: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub add_dir: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub disable_mcp_server: Option<Vec<String>>,
#[serde(flatten)]
pub cmd: CmdOverrides,
}
impl Copilot {
fn build_command_builder(&self, log_dir: &str) -> CommandBuilder {
let mut builder = CommandBuilder::new("npx -y @github/copilot@0.0.332").params([
"--no-color",
"--log-level",
"debug",
"--log-dir",
log_dir,
]);
if self.allow_all_tools.unwrap_or(false) {
builder = builder.extend_params(["--allow-all-tools"]);
}
if let Some(model) = &self.model {
builder = builder.extend_params(["--model", model]);
}
if let Some(tool) = &self.allow_tool {
builder = builder.extend_params(["--allow-tool", tool]);
}
if let Some(tool) = &self.deny_tool {
builder = builder.extend_params(["--deny-tool", tool]);
}
if let Some(dirs) = &self.add_dir {
for dir in dirs {
builder = builder.extend_params(["--add-dir", dir]);
}
}
if let Some(servers) = &self.disable_mcp_server {
for server in servers {
builder = builder.extend_params(["--disable-mcp-server", server]);
}
}
apply_overrides(builder, &self.cmd)
}
}
#[async_trait]
impl StandardCodingAgentExecutor for Copilot {
async fn spawn(&self, current_dir: &Path, prompt: &str) -> Result<SpawnedChild, ExecutorError> {
let (shell_cmd, shell_arg) = get_shell_command();
let log_dir = Self::create_temp_log_dir(current_dir).await?;
let copilot_command = self
.build_command_builder(&log_dir.to_string_lossy())
.build_initial();
let combined_prompt = self.append_prompt.combine_prompt(prompt);
let mut command = Command::new(shell_cmd);
command
.kill_on_drop(true)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.current_dir(current_dir)
.arg(shell_arg)
.arg(copilot_command)
.env("NODE_NO_WARNINGS", "1");
let mut child = command.group_spawn()?;
// Write prompt to stdin
if let Some(mut stdin) = child.inner().stdin.take() {
stdin.write_all(combined_prompt.as_bytes()).await?;
stdin.shutdown().await?;
}
let (_, appender) = stdout_dup::tee_stdout_with_appender(&mut child)?;
Self::send_session_id(log_dir, appender);
Ok(child.into())
}
async fn spawn_follow_up(
&self,
current_dir: &Path,
prompt: &str,
session_id: &str,
) -> Result<SpawnedChild, ExecutorError> {
let (shell_cmd, shell_arg) = get_shell_command();
let log_dir = Self::create_temp_log_dir(current_dir).await?;
let copilot_command = self
.build_command_builder(&log_dir.to_string_lossy())
.build_follow_up(&["--resume".to_string(), session_id.to_string()]);
let combined_prompt = self.append_prompt.combine_prompt(prompt);
let mut command = Command::new(shell_cmd);
command
.kill_on_drop(true)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.current_dir(current_dir)
.arg(shell_arg)
.arg(copilot_command)
.env("NODE_NO_WARNINGS", "1");
let mut child = command.group_spawn()?;
// Write comprehensive prompt to stdin
if let Some(mut stdin) = child.inner().stdin.take() {
stdin.write_all(combined_prompt.as_bytes()).await?;
stdin.shutdown().await?;
}
let (_, appender) = stdout_dup::tee_stdout_with_appender(&mut child)?;
Self::send_session_id(log_dir, appender);
Ok(child.into())
}
/// Parses both stderr and stdout logs for Copilot executor using PlainTextLogProcessor.
///
/// Each entry is converted into an `AssistantMessage` or `ErrorMessage` and emitted as patches.
fn normalize_logs(&self, msg_store: Arc<MsgStore>, _worktree_path: &Path) {
let entry_index_counter = EntryIndexProvider::start_from(&msg_store);
normalize_stderr_logs(msg_store.clone(), entry_index_counter.clone());
// Normalize Agent logs
tokio::spawn(async move {
let mut stdout_lines = msg_store.stdout_lines_stream();
let mut processor = Self::create_simple_stdout_normalizer(entry_index_counter);
while let Some(Ok(line)) = stdout_lines.next().await {
if let Some(session_id) = line.strip_prefix(Self::SESSION_PREFIX) {
msg_store.push_session_id(session_id.trim().to_string());
continue;
}
for patch in processor.process(line + "\n") {
msg_store.push_patch(patch);
}
}
});
}
// MCP configuration methods
fn default_mcp_config_path(&self) -> Option<std::path::PathBuf> {
dirs::home_dir().map(|home| home.join(".copilot").join("mcp-config.json"))
}
}
impl Copilot {
fn create_simple_stdout_normalizer(
index_provider: EntryIndexProvider,
) -> PlainTextLogProcessor {
PlainTextLogProcessor::builder()
.normalized_entry_producer(Box::new(|content: String| NormalizedEntry {
timestamp: None,
entry_type: NormalizedEntryType::AssistantMessage,
content,
metadata: None,
}))
.transform_lines(Box::new(|lines| {
lines.iter_mut().for_each(|line| {
*line = strip_ansi_escapes::strip_str(&line);
})
}))
.index_provider(index_provider)
.build()
}
async fn create_temp_log_dir(current_dir: &Path) -> Result<PathBuf, ExecutorError> {
let base_log_dir = get_vibe_kanban_temp_dir().join("copilot_logs");
fs::create_dir_all(&base_log_dir)
.await
.map_err(ExecutorError::Io)?;
let run_log_dir = base_log_dir
.join(current_dir.file_name().unwrap_or_default())
.join(Uuid::new_v4().to_string());
fs::create_dir_all(&run_log_dir)
.await
.map_err(ExecutorError::Io)?;
Ok(run_log_dir)
}
// Scan the log directory for a file named `<UUID>.log` and extract the UUID as session ID.
async fn watch_session_id(log_dir_path: PathBuf) -> Result<String, String> {
let mut ticker = interval(Duration::from_millis(200));
timeout(Duration::from_secs(600), async {
loop {
if let Ok(mut rd) = fs::read_dir(&log_dir_path).await {
while let Ok(Some(e)) = rd.next_entry().await {
if let Some(name) =
e.file_name().to_str().and_then(|n| n.strip_suffix(".log"))
&& Uuid::parse_str(name).is_ok()
{
return name.to_string();
}
}
}
ticker.tick().await;
}
})
.await
.map_err(|_| format!("No <uuid>.log found in {log_dir_path:?}"))
}
const SESSION_PREFIX: &'static str = "[copilot-session] ";
// Find session id and write it to stdout prefixed
fn send_session_id(log_dir_path: PathBuf, stdout_appender: StdoutAppender) {
tokio::spawn(async move {
match Self::watch_session_id(log_dir_path).await {
Ok(session_id) => {
let session_line = format!("{}{}\n", Self::SESSION_PREFIX, session_id);
stdout_appender.append_line(&session_line);
}
Err(e) => {
tracing::error!("Failed to find session ID: {}", e);
}
}
});
}
}

View File

@@ -14,8 +14,8 @@ use workspace_utils::msg_store::MsgStore;
use crate::{
executors::{
amp::Amp, claude::ClaudeCode, codex::Codex, cursor::Cursor, gemini::Gemini,
opencode::Opencode, qwen::QwenCode,
amp::Amp, claude::ClaudeCode, codex::Codex, copilot::Copilot, cursor::Cursor,
gemini::Gemini, opencode::Opencode, qwen::QwenCode,
},
mcp_config::McpConfig,
};
@@ -24,6 +24,7 @@ pub mod acp;
pub mod amp;
pub mod claude;
pub mod codex;
pub mod copilot;
pub mod cursor;
pub mod gemini;
pub mod opencode;
@@ -76,6 +77,7 @@ pub enum CodingAgent {
Opencode,
Cursor,
QwenCode,
Copilot,
}
impl CodingAgent {
@@ -128,7 +130,7 @@ impl CodingAgent {
Self::Codex(_) => vec![BaseAgentCapability::SessionFork],
Self::Gemini(_) => vec![BaseAgentCapability::SessionFork],
Self::QwenCode(_) => vec![BaseAgentCapability::SessionFork],
Self::Opencode(_) | Self::Cursor(_) => vec![],
Self::Opencode(_) | Self::Cursor(_) | Self::Copilot(_) => vec![],
}
}
}

View File

@@ -249,12 +249,27 @@ fn adapt_opencode(servers: ServerMap, meta: Option<Value>) -> Value {
attach_meta(servers, meta)
}
fn adapt_copilot(mut servers: ServerMap, meta: Option<Value>) -> Value {
for (_, value) in servers.iter_mut() {
if let Value::Object(s) = value
&& !s.contains_key("tools")
{
s.insert(
"tools".to_string(),
Value::Array(vec![Value::String("*".to_string())]),
);
}
}
attach_meta(servers, meta)
}
enum Adapter {
Passthrough,
Gemini,
Cursor,
Codex,
Opencode,
Copilot,
}
fn apply_adapter(adapter: Adapter, canonical: Value) -> Value {
@@ -269,6 +284,7 @@ fn apply_adapter(adapter: Adapter, canonical: Value) -> Value {
Adapter::Cursor => adapt_cursor(servers_only, meta),
Adapter::Codex => adapt_codex(servers_only, meta),
Adapter::Opencode => adapt_opencode(servers_only, meta),
Adapter::Copilot => adapt_copilot(servers_only, meta),
}
}
@@ -282,6 +298,7 @@ impl CodingAgent {
CodingAgent::Cursor(_) => Cursor,
CodingAgent::Codex(_) => Codex,
CodingAgent::Opencode(_) => Opencode,
CodingAgent::Copilot(..) => Copilot,
};
let canonical = PRECONFIGURED_MCP_SERVERS.clone();

View File

@@ -81,6 +81,7 @@ fn generate_types_content() -> String {
executors::executors::codex::ReasoningSummary::decl(),
executors::executors::codex::ReasoningSummaryFormat::decl(),
executors::executors::cursor::Cursor::decl(),
executors::executors::copilot::Copilot::decl(),
executors::executors::opencode::Opencode::decl(),
executors::executors::qwen::QwenCode::decl(),
executors::executors::AppendPrompt::decl(),
@@ -187,6 +188,10 @@ fn generate_schemas() -> Result<HashMap<&'static str, String>, serde_json::Error
"qwen_code",
generate_json_schema::<executors::executors::qwen::QwenCode>()?,
),
(
"copilot",
generate_json_schema::<executors::executors::copilot::Copilot>()?,
),
]);
println!(
"✅ JSON schemas generated. {} schemas created.",

View File

@@ -16,6 +16,7 @@ type ExecutorType =
| 'GEMINI'
| 'CODEX'
| 'CURSOR'
| 'COPILOT'
| 'OPENCODE'
| 'QWEN_CODE';

View File

@@ -0,0 +1,77 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"append_prompt": {
"title": "Append Prompt",
"description": "Extra text appended to the prompt",
"type": [
"string",
"null"
],
"format": "textarea",
"default": null
},
"model": {
"type": [
"string",
"null"
]
},
"allow_all_tools": {
"type": [
"boolean",
"null"
]
},
"allow_tool": {
"type": [
"string",
"null"
]
},
"deny_tool": {
"type": [
"string",
"null"
]
},
"add_dir": {
"type": [
"array",
"null"
],
"items": {
"type": "string"
}
},
"disable_mcp_server": {
"type": [
"array",
"null"
],
"items": {
"type": "string"
}
},
"base_command_override": {
"title": "Base Command Override",
"description": "Override the base command with a custom command",
"type": [
"string",
"null"
]
},
"additional_params": {
"title": "Additional Parameters",
"description": "Additional parameters to append to the base command",
"type": [
"array",
"null"
],
"items": {
"type": "string"
}
}
},
"type": "object"
}

View File

@@ -30,9 +30,9 @@ export type ScriptRequest = { script: string, language: ScriptRequestLanguage, c
export type ScriptRequestLanguage = "Bash";
export enum BaseCodingAgent { CLAUDE_CODE = "CLAUDE_CODE", AMP = "AMP", GEMINI = "GEMINI", CODEX = "CODEX", OPENCODE = "OPENCODE", CURSOR = "CURSOR", QWEN_CODE = "QWEN_CODE" }
export enum BaseCodingAgent { CLAUDE_CODE = "CLAUDE_CODE", AMP = "AMP", GEMINI = "GEMINI", CODEX = "CODEX", OPENCODE = "OPENCODE", CURSOR = "CURSOR", QWEN_CODE = "QWEN_CODE", COPILOT = "COPILOT" }
export type CodingAgent = { "CLAUDE_CODE": ClaudeCode } | { "AMP": Amp } | { "GEMINI": Gemini } | { "CODEX": Codex } | { "OPENCODE": Opencode } | { "CURSOR": Cursor } | { "QWEN_CODE": QwenCode };
export type CodingAgent = { "CLAUDE_CODE": ClaudeCode } | { "AMP": Amp } | { "GEMINI": Gemini } | { "CODEX": Codex } | { "OPENCODE": Opencode } | { "CURSOR": Cursor } | { "QWEN_CODE": QwenCode } | { "COPILOT": Copilot };
export type TaskTemplate = { id: string, project_id: string | null, title: string, description: string | null, template_name: string, created_at: string, updated_at: string, };
@@ -150,7 +150,7 @@ executor: BaseCodingAgent,
*/
variant: string | null, };
export type ExecutorConfig = { [key in string]?: { "CLAUDE_CODE": ClaudeCode } | { "AMP": Amp } | { "GEMINI": Gemini } | { "CODEX": Codex } | { "OPENCODE": Opencode } | { "CURSOR": Cursor } | { "QWEN_CODE": QwenCode } };
export type ExecutorConfig = { [key in string]?: { "CLAUDE_CODE": ClaudeCode } | { "AMP": Amp } | { "GEMINI": Gemini } | { "CODEX": Codex } | { "OPENCODE": Opencode } | { "CURSOR": Cursor } | { "QWEN_CODE": QwenCode } | { "COPILOT": Copilot } };
export type BaseAgentCapability = "SESSION_FORK";
@@ -174,6 +174,8 @@ export type ReasoningSummaryFormat = "none" | "experimental";
export type Cursor = { append_prompt: AppendPrompt, force?: boolean | null, model?: string | null, base_command_override?: string | null, additional_params?: Array<string> | null, };
export type Copilot = { append_prompt: AppendPrompt, model?: string | null, allow_all_tools?: boolean | null, allow_tool?: string | null, deny_tool?: string | null, add_dir?: Array<string> | null, disable_mcp_server?: Array<string> | null, base_command_override?: string | null, additional_params?: Array<string> | null, };
export type Opencode = { append_prompt: AppendPrompt, model?: string | null, agent?: string | null, base_command_override?: string | null, additional_params?: Array<string> | null, };
export type QwenCode = { append_prompt: AppendPrompt, yolo?: boolean | null, base_command_override?: string | null, additional_params?: Array<string> | null, };