use std::path::PathBuf;
use super::statuslinejson::{self, SlotShape};
use super::{AgentBackend, BackendState};
use crate::cli::{CopilotCli, CopilotPlugin, MIN_COPILOT_VERSION, copilot_meets_floor, version_lt};
use crate::doctor::{CheckStatus, DoctorCheck, DoctorReport};
use crate::error::{Error, Result};
use crate::host::{Capabilities, Desired, Outcome, Plugin, Scope, Source};
use crate::materialize::{TreeSource, materialize};
const STATUSLINE_SLOT: &[&str] = &["statusLine"];
const STATUSLINE_SHAPE: SlotShape = SlotShape::typed_command();
pub(crate) struct CopilotCliBackend;
impl AgentBackend for CopilotCliBackend {
fn id(&self) -> &'static str {
"copilot-cli"
}
fn detect(&self) -> bool {
which::which("copilot").is_ok()
}
fn capabilities(&self) -> Capabilities {
Capabilities {
plugins: true,
mcp: true,
hooks: true,
commands: true,
agents: true,
skills: true,
instructions: false,
statusline: true,
scopes: &["user"],
}
}
fn probe(&self, plugin: &Plugin, scope: &Scope, source: &Source) -> Result<BackendState> {
let cli = CopilotCli::locate()?;
let registry = classify(source, find_plugin(&cli, plugin)?.as_ref(), plugin.version);
if matches!(registry, BackendState::Absent) {
return Ok(BackendState::Absent);
}
Ok(match statusline_state(plugin, scope)? {
None | Some(BackendState::Healthy) => registry,
Some(_) => BackendState::NeedsRepair,
})
}
fn reconcile(&self, plugin: &Plugin, desired: &Desired, scope: &Scope) -> Result<Outcome> {
reconcile(plugin, desired, scope)
}
fn remove(&self, plugin: &Plugin, scope: &Scope, _source: &Source) -> Result<Outcome> {
remove(plugin, scope)
}
fn forget(&self, plugin: &Plugin, scope: &Scope) -> Result<()> {
statusline_remove(plugin, scope).map(|_| ())
}
fn report(&self, plugin: &Plugin, _source: &Source) -> DoctorReport {
DoctorReport::from_checks(report_checks(plugin))
}
}
fn find_plugin(cli: &CopilotCli, plugin: &Plugin) -> Result<Option<CopilotPlugin>> {
Ok(cli.plugin_list(None)?.into_iter().find(|e| e.plugin == plugin.name && e.marketplace == plugin.marketplace))
}
fn marketplace_present(cli: &CopilotCli, name: &str) -> Result<bool> {
Ok(cli.marketplace_list(None)?.iter().any(|m| m.name == name))
}
fn marketplace_add(cli: &CopilotCli, dir: &str) -> Result<()> {
cli.run(&["plugin", "marketplace", "add", dir], None)?;
Ok(())
}
fn plugin_install(cli: &CopilotCli, spec: &str) -> Result<()> {
cli.run(&["plugin", "install", spec], None)?;
Ok(())
}
fn plugin_update(cli: &CopilotCli, id: &str) -> Result<()> {
cli.run(&["plugin", "update", id], None)?;
Ok(())
}
fn plugin_uninstall(cli: &CopilotCli, id: &str) -> Result<()> {
let out = cli.run_capturing(&["plugin", "uninstall", id], None)?;
if out.code == 0 {
return Ok(());
}
let text = format!("{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
if text.contains("is not installed") {
return Ok(());
}
Err(Error::Cli { bin: "copilot", args: format!("plugin uninstall {id}"), code: out.code, stderr: text.trim().to_string() })
}
fn reconcile(plugin: &Plugin, desired: &Desired, scope: &Scope) -> Result<Outcome> {
ensure_statusline_resolves(plugin, scope)?;
let cli = CopilotCli::locate()?;
let id = plugin.id();
let registry = match find_plugin(&cli, plugin)? {
None => {
cli.ensure_min_version()?;
ensure_marketplace(&cli, plugin, &desired.source)?;
plugin_install(&cli, &id)?;
verify_present(&cli, plugin)?;
Outcome::Installed
}
Some(entry) => match present_action(&desired.source, entry.version.as_deref(), plugin.version) {
PresentAction::Frozen => return Ok(Outcome::NoOp),
PresentAction::NoOp => Outcome::NoOp,
PresentAction::Update => {
cli.ensure_min_version()?;
ensure_marketplace(&cli, plugin, &desired.source)?;
plugin_update(&cli, &id)?;
verify_present(&cli, plugin)?;
Outcome::Updated { from: entry.version.clone(), to: plugin.version.to_string() }
}
},
};
let changed = statusline_reconcile(plugin, desired, scope)?;
Ok(match (registry, changed) {
(Outcome::NoOp, true) => Outcome::Repaired,
(outcome, _) => outcome,
})
}
fn ensure_marketplace(cli: &CopilotCli, plugin: &Plugin, source: &Source) -> Result<()> {
let client = CopilotCliBackend.id();
let add_source = match source {
Source::Embedded => materialize(plugin, TreeSource::Blob(plugin.blob()), client)?.display().to_string(),
Source::Path(p) => materialize(plugin, TreeSource::Dir(p), client)?.display().to_string(),
Source::GitHub { repo, ref_ } => github_marketplace_source(repo, ref_),
};
if !marketplace_present(cli, plugin.marketplace)? {
marketplace_add(cli, &add_source)?;
}
Ok(())
}
fn github_marketplace_source(repo: &str, _ref: &str) -> String {
repo.to_string()
}
fn verify_present(cli: &CopilotCli, plugin: &Plugin) -> Result<()> {
if find_plugin(cli, plugin)?.is_some() {
Ok(())
} else {
Err(Error::Verify(format!("{} absent from `copilot plugin list` after the operation", plugin.id())))
}
}
#[derive(Debug, PartialEq, Eq)]
enum PresentAction {
NoOp,
Update,
Frozen,
}
fn present_action(source: &Source, installed: Option<&str>, embedded: &str) -> PresentAction {
match source {
Source::GitHub { .. } => PresentAction::NoOp,
_ if version_lt(installed, embedded) => PresentAction::Update,
_ if installed.is_some_and(|v| version_lt(Some(embedded), v)) => PresentAction::Frozen,
_ => PresentAction::NoOp,
}
}
fn classify(source: &Source, entry: Option<&CopilotPlugin>, embedded: &str) -> BackendState {
match entry {
None => BackendState::Absent,
Some(_) if matches!(source, Source::GitHub { .. }) => BackendState::Healthy,
Some(e) if version_lt(e.version.as_deref(), embedded) => BackendState::NeedsRepair,
Some(_) => BackendState::Healthy,
}
}
fn remove(plugin: &Plugin, scope: &Scope) -> Result<Outcome> {
ensure_statusline_resolves(plugin, scope)?;
let cli = CopilotCli::locate()?;
let mut changed = false;
if find_plugin(&cli, plugin)?.is_some() {
plugin_uninstall(&cli, &plugin.id())?;
changed = true;
}
changed |= statusline_remove(plugin, scope)?;
Ok(if changed { Outcome::Removed } else { Outcome::NoOp })
}
fn copilot_home() -> Result<PathBuf> {
if let Some(dir) = super::config_dir_override("COPILOT_HOME")? {
return Ok(dir);
}
dirs::home_dir()
.map(|home| home.join(".copilot"))
.ok_or_else(|| Error::Tree("no home directory (HOME unset); cannot locate ~/.copilot".into()))
}
fn statusline_file() -> Result<PathBuf> {
Ok(copilot_home()?.join("settings.json"))
}
fn statusline_target(plugin: &Plugin, scope: &Scope) -> Result<Option<PathBuf>> {
let Scope::User = scope else {
return Ok(None);
};
statuslinejson::target(plugin, CopilotCliBackend.id(), STATUSLINE_SHAPE, statusline_file)
}
fn ensure_statusline_resolves(plugin: &Plugin, scope: &Scope) -> Result<()> {
statusline_target(plugin, scope)?;
Ok(())
}
fn statusline_reconcile(plugin: &Plugin, desired: &Desired, scope: &Scope) -> Result<bool> {
let Some(path) = statusline_target(plugin, scope)? else {
return Ok(false);
};
statuslinejson::reconcile(&path, STATUSLINE_SLOT, plugin, &desired.source, scope, CopilotCliBackend.id(), STATUSLINE_SHAPE)
}
fn statusline_remove(plugin: &Plugin, scope: &Scope) -> Result<bool> {
let Some(path) = statusline_target(plugin, scope)? else {
return Ok(false);
};
statuslinejson::remove(&path, STATUSLINE_SLOT, plugin, scope, CopilotCliBackend.id(), STATUSLINE_SHAPE)
}
fn statusline_state(plugin: &Plugin, scope: &Scope) -> Result<Option<BackendState>> {
let Some(path) = statusline_target(plugin, scope)? else {
return Ok(None);
};
statuslinejson::state(&path, STATUSLINE_SLOT, plugin, scope, CopilotCliBackend.id(), STATUSLINE_SHAPE)
}
fn report_checks(plugin: &Plugin) -> Vec<DoctorCheck> {
let mut checks = Vec::new();
let cli = match CopilotCli::locate() {
Ok(cli) => cli,
Err(_) => {
checks.push(DoctorCheck {
name: "copilot on PATH",
status: CheckStatus::Fail {
problem: "`copilot` not found on PATH".into(),
fix: "install it with `npm install -g @github/copilot`".into(),
},
});
return checks;
}
};
checks.push(check_version(&cli));
check_registered(&cli, plugin, &mut checks);
checks.extend(statuslinejson::check(
STATUSLINE_SLOT,
plugin,
&Scope::User,
CopilotCliBackend.id(),
STATUSLINE_SHAPE,
"GitHub Copilot CLI",
|_| statusline_file(),
));
checks
}
fn check_version(cli: &CopilotCli) -> DoctorCheck {
let name = "copilot version";
let raw = match cli.raw_version() {
Ok(v) => v,
Err(e) => return DoctorCheck { name, status: CheckStatus::Warn(format!("could not read `copilot --version`: {e}")) },
};
match copilot_meets_floor(&raw) {
Some(false) => DoctorCheck {
name,
status: CheckStatus::Fail {
problem: format!("`copilot` {raw} is below {MIN_COPILOT_VERSION}, required for plugin management"),
fix: "upgrade with `copilot update`".into(),
},
},
Some(true) => DoctorCheck { name, status: CheckStatus::Ok(raw) },
None => DoctorCheck { name, status: CheckStatus::Warn(format!("could not parse version {raw:?}; proceeding")) },
}
}
fn check_registered(cli: &CopilotCli, plugin: &Plugin, checks: &mut Vec<DoctorCheck>) {
let name = "plugin registered";
match cli.plugin_list(None) {
Err(e) => checks.push(DoctorCheck {
name,
status: CheckStatus::Fail {
problem: format!("`copilot plugin list` failed: {e}"),
fix: "re-run `copilot plugin list` and report the output".into(),
},
}),
Ok(entries) => match entries.iter().find(|e| e.plugin == plugin.name && e.marketplace == plugin.marketplace) {
Some(entry) => {
let version = entry.version.clone().unwrap_or_else(|| "?".into());
checks.push(DoctorCheck { name, status: CheckStatus::Ok(format!("{} v{version}", plugin.id())) });
}
None => checks.push(DoctorCheck {
name,
status: CheckStatus::Fail {
problem: format!("{} is not installed", plugin.id()),
fix: "run the host binary's `setup` (or `install`) subcommand".into(),
},
}),
},
}
}
#[cfg(test)]
#[path = "../../tests/unit/copilot_cli.rs"]
mod copilot_cli_tests;