use super::{MaterializedSkill, Vendor};
use crate::{fsutil, jsonutil, paths};
use anyhow::{bail, Context, Result};
use serde_json::json;
use std::path::{Path, PathBuf};
use std::process::Command;
pub struct Copilot;
impl Vendor for Copilot {
fn name(&self) -> &'static str {
"copilot"
}
fn materialize(
&self,
_project_root: &Path,
project_id: &str,
skills: &[MaterializedSkill],
) -> Result<()> {
let dir = market_dir(project_id)?;
prune_orphans();
if skills.is_empty() {
unregister(project_id);
if dir.exists() {
std::fs::remove_dir_all(&dir)?;
}
return Ok(());
}
if dir.exists() {
std::fs::remove_dir_all(&dir)?;
}
let plugin_dir = dir.join("plugin");
let skills_dir = plugin_dir.join("skills");
std::fs::create_dir_all(&skills_dir)?;
for s in skills {
let dest = skills_dir.join(&s.name);
fsutil::copy_tree(&s.path, &dest)
.with_context(|| format!("copying skill `{}` into plugin", s.name))?;
if !dest.join("SKILL.md").exists() {
eprintln!(
"warning: skill `{}` has no SKILL.md at its root — Copilot may ignore it",
s.name
);
}
}
jsonutil::write(
&plugin_dir.join("plugin.json"),
&json!({ "name": project_id }),
)?;
jsonutil::write(
&dir.join(".github/plugin/marketplace.json"),
&json!({
"name": project_id,
"owner": { "name": "spm", "email": "spm@example.com" },
"metadata": { "description": "spm-managed skills", "version": "0.0.0" },
"plugins": [{
"name": project_id,
"description": "spm-managed skills",
"version": "0.0.0",
"source": "plugin"
}],
}),
)?;
register(project_id, &dir)
}
fn clean(&self, _project_root: &Path, project_id: &str) -> Result<()> {
unregister(project_id);
prune_orphans();
let dir = market_dir(project_id)?;
if dir.exists() {
std::fs::remove_dir_all(&dir)?;
}
Ok(())
}
}
fn market_dir(project_id: &str) -> Result<PathBuf> {
crate::lockfile::validate_project_id(project_id)?;
Ok(paths::vendors_dir()?.join("copilot").join(project_id))
}
fn register(id: &str, dir: &Path) -> Result<()> {
if !copilot_available() {
eprintln!(
"warning: `copilot` CLI not found — skills assembled at {} but not registered.\n \
Register manually:\n copilot plugin marketplace add {}\n copilot plugin install {id}@{id}",
dir.display(),
dir.display()
);
return Ok(());
}
run(&["plugin", "uninstall", id], true)?;
run(&["plugin", "marketplace", "remove", id], true)?;
run(
&["plugin", "marketplace", "add", &dir.to_string_lossy()],
false,
)?;
run(&["plugin", "install", &format!("{id}@{id}")], false)?;
Ok(())
}
fn unregister(id: &str) {
if copilot_available() {
let _ = run(&["plugin", "uninstall", id], true);
let _ = run(&["plugin", "marketplace", "remove", id], true);
}
}
fn prune_orphans() {
if !copilot_available() {
return;
}
let Ok(out) = Command::new(copilot_bin())
.args(["plugin", "marketplace", "list"])
.output()
else {
return;
};
let listing = String::from_utf8_lossy(&out.stdout);
for (name, local) in parse_local_marketplaces(&listing) {
if name.starts_with("spm") && !Path::new(&local).exists() {
let _ = run(&["plugin", "uninstall", &name], true);
let _ = run(&["plugin", "marketplace", "remove", &name], true);
}
}
}
fn parse_local_marketplaces(listing: &str) -> Vec<(String, String)> {
listing
.lines()
.filter_map(|line| {
let (left, rest) = line.split_once(" (Local: ")?;
let name = left
.trim_start_matches(|c: char| !c.is_alphanumeric())
.trim();
let path = rest.trim_end().trim_end_matches(')');
(!name.is_empty()).then(|| (name.to_string(), path.to_string()))
})
.collect()
}
fn copilot_bin() -> String {
std::env::var("SPM_COPILOT_BIN").unwrap_or_else(|_| "copilot".to_string())
}
fn copilot_available() -> bool {
Command::new(copilot_bin())
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn run(args: &[&str], ignore_err: bool) -> Result<()> {
let out = Command::new(copilot_bin())
.args(args)
.output()
.with_context(|| format!("failed to run `copilot {}`", args.join(" ")))?;
if !out.status.success() && !ignore_err {
bail!(
"copilot {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::parse_local_marketplaces;
#[test]
fn parses_local_marketplaces_only() {
let listing = "Included with GitHub Copilot:\n \u{25c6} copilot-plugins (GitHub: github/copilot-plugins)\n \u{2022} spm-24918f1d (Local: /home/u/.spm/vendors/copilot/spm-24918f1d)\n";
let got = parse_local_marketplaces(listing);
assert_eq!(
got,
vec![(
"spm-24918f1d".to_string(),
"/home/u/.spm/vendors/copilot/spm-24918f1d".to_string()
)]
);
}
}