Copilot CLI (#915)
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
280
crates/executors/src/executors/copilot.rs
Normal file
280
crates/executors/src/executors/copilot.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -16,6 +16,7 @@ type ExecutorType =
|
||||
| 'GEMINI'
|
||||
| 'CODEX'
|
||||
| 'CURSOR'
|
||||
| 'COPILOT'
|
||||
| 'OPENCODE'
|
||||
| 'QWEN_CODE';
|
||||
|
||||
|
||||
77
shared/schemas/copilot.json
Normal file
77
shared/schemas/copilot.json
Normal 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"
|
||||
}
|
||||
@@ -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, };
|
||||
|
||||
Reference in New Issue
Block a user