Skip to main content

agent_config/agents/
copilot.rs

1//! GitHub Copilot integration (CLI + cloud agent + VS Code agent).
2//!
3//! Copilot loads hook configs from any `.json` file under
4//! `<project>/.github/hooks/`. We write one file per consumer
5//! (`<tag>-rewrite.json`) so multiple CLIs coexist cleanly without sharing a
6//! single mutable JSON document.
7//!
8//! Copilot uses lowerCamelCase events (`preToolUse`) and a flat entry shape
9//! with `bash` (or `powershell`) as the command field, not `command`:
10//!
11//! ```json
12//! {
13//!   "version": 1,
14//!   "hooks": {
15//!     "preToolUse": [
16//!       { "type": "command", "bash": "...", "comment": "..." }
17//!     ]
18//!   }
19//! }
20//! ```
21//!
22//! Optional prompt surface: `<project>/.github/copilot-instructions.md` with
23//! a tagged HTML-comment fence.
24
25use std::path::PathBuf;
26
27use serde_json::json;
28
29use crate::agents::planning as agent_planning;
30use crate::error::AgentConfigError;
31use crate::integration::{
32    InstallReport, InstructionSurface, Integration, McpSurface, SkillSurface, UninstallReport,
33};
34use crate::paths;
35use crate::plan::{has_refusal, InstallPlan, PlanTarget, RefusalReason, UninstallPlan};
36use crate::scope::{Scope, ScopeKind};
37use crate::spec::{Event, HookSpec, InstructionSpec, Matcher, McpSpec, SkillSpec};
38use crate::status::StatusReport;
39use crate::util::{
40    file_lock, fs_atomic, instructions_dir, mcp_json_map, md_block, ownership, planning, safe_fs,
41    skills_dir,
42};
43
44/// GitHub Copilot.
45#[derive(Debug, Clone, Copy, Default)]
46pub struct CopilotAgent {
47    _private: (),
48}
49
50impl CopilotAgent {
51    /// Construct an instance. Stateless.
52    pub const fn new() -> Self {
53        Self { _private: () }
54    }
55
56    fn hooks_file(scope: &Scope, tag: &str) -> Result<PathBuf, AgentConfigError> {
57        let root = match scope {
58            Scope::Local(p) => p,
59            Scope::Global => {
60                return Err(AgentConfigError::UnsupportedScope {
61                    id: "copilot",
62                    scope: ScopeKind::Global,
63                });
64            }
65        };
66        Ok(root
67            .join(".github")
68            .join("hooks")
69            .join(format!("{tag}-rewrite.json")))
70    }
71
72    fn instructions_path(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
73        let Scope::Local(root) = scope else {
74            return Err(AgentConfigError::UnsupportedScope {
75                id: "copilot",
76                scope: ScopeKind::Global,
77            });
78        };
79        Ok(root.join(".github").join("copilot-instructions.md"))
80    }
81
82    fn mcp_path(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
83        Ok(match scope {
84            Scope::Global => paths::home_dir()?.join(".copilot").join("mcp-config.json"),
85            Scope::Local(root) => root.join(".mcp.json"),
86        })
87    }
88
89    fn skills_root(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
90        Ok(match scope {
91            Scope::Global => paths::home_dir()?.join(".copilot").join("skills"),
92            Scope::Local(root) => root.join(".github").join("skills"),
93        })
94    }
95
96    /// Directory holding the instruction ownership ledger. Local-only;
97    /// lives next to the host file under `<root>/.github/`.
98    fn instruction_config_dir(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
99        let Scope::Local(root) = scope else {
100            return Err(AgentConfigError::UnsupportedScope {
101                id: "copilot",
102                scope: ScopeKind::Global,
103            });
104        };
105        Ok(root.join(".github"))
106    }
107}
108
109impl Integration for CopilotAgent {
110    fn id(&self) -> &'static str {
111        "copilot"
112    }
113
114    fn display_name(&self) -> &'static str {
115        "GitHub Copilot"
116    }
117
118    fn supported_scopes(&self) -> &'static [ScopeKind] {
119        &[ScopeKind::Local]
120    }
121
122    fn status(&self, scope: &Scope, tag: &str) -> Result<StatusReport, AgentConfigError> {
123        HookSpec::validate_tag(tag)?;
124        let p = Self::hooks_file(scope, tag)?;
125        Ok(StatusReport::for_file_hook(tag, p))
126    }
127
128    fn plan_install(
129        &self,
130        scope: &Scope,
131        spec: &HookSpec,
132    ) -> Result<InstallPlan, AgentConfigError> {
133        HookSpec::validate_tag(&spec.tag)?;
134        let target = PlanTarget::Hook {
135            integration_id: Integration::id(self),
136            scope: scope.clone(),
137            tag: spec.tag.clone(),
138        };
139        let p = match Self::hooks_file(scope, &spec.tag) {
140            Ok(p) => p,
141            Err(AgentConfigError::UnsupportedScope { .. }) => {
142                return Ok(InstallPlan::refused(
143                    target,
144                    None,
145                    RefusalReason::UnsupportedScope,
146                ));
147            }
148            Err(e) => return Err(e),
149        };
150
151        let event_key = event_to_string(&spec.event);
152        let matcher_str = matcher_to_copilot(&spec.matcher);
153        let entry = json!({
154            "type": "command",
155            "bash": spec.command.render_shell(),
156            "matcher": matcher_str,
157        });
158        let doc = json!({
159            "version": 1,
160            "hooks": { event_key: [entry] },
161        });
162        let mut bytes = serde_json::to_vec_pretty(&doc).expect("serialize");
163        bytes.push(b'\n');
164
165        let mut changes = Vec::new();
166        planning::plan_write_file(&mut changes, &p, &bytes, true)?;
167        if has_refusal(&changes) {
168            return Ok(InstallPlan::from_changes(target, changes));
169        }
170
171        if let Some(rules) = &spec.rules {
172            let instr = Self::instructions_path(scope)?;
173            planning::plan_markdown_upsert(&mut changes, &instr, &spec.tag, &rules.content)?;
174        }
175
176        Ok(InstallPlan::from_changes(target, changes))
177    }
178
179    fn plan_uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallPlan, AgentConfigError> {
180        HookSpec::validate_tag(tag)?;
181        let target = PlanTarget::Hook {
182            integration_id: Integration::id(self),
183            scope: scope.clone(),
184            tag: tag.to_string(),
185        };
186        let p = match Self::hooks_file(scope, tag) {
187            Ok(p) => p,
188            Err(AgentConfigError::UnsupportedScope { .. }) => {
189                return Ok(UninstallPlan::refused(
190                    target,
191                    None,
192                    RefusalReason::UnsupportedScope,
193                ));
194            }
195            Err(e) => return Err(e),
196        };
197        let mut changes = Vec::new();
198        planning::plan_remove_file(&mut changes, &p);
199        let instr = Self::instructions_path(scope)?;
200        planning::plan_markdown_remove(&mut changes, &instr, tag)?;
201        Ok(UninstallPlan::from_changes(target, changes))
202    }
203
204    fn install(&self, scope: &Scope, spec: &HookSpec) -> Result<InstallReport, AgentConfigError> {
205        HookSpec::validate_tag(&spec.tag)?;
206        let mut report = InstallReport::default();
207
208        let p = Self::hooks_file(scope, &spec.tag)?;
209        scope.ensure_contained(&p)?;
210        let event_key = event_to_string(&spec.event);
211        let matcher_str = matcher_to_copilot(&spec.matcher);
212
213        // Each per-consumer file owns its whole contents. No tag dedupe inside
214        // the file because the filename itself carries the tag.
215        let entry = json!({
216            "type": "command",
217            "bash": spec.command.render_shell(),
218            "matcher": matcher_str,
219        });
220        let doc = json!({
221            "version": 1,
222            "hooks": { event_key: [entry] },
223        });
224        let bytes = {
225            let mut b = serde_json::to_vec_pretty(&doc).expect("serialize");
226            b.push(b'\n');
227            b
228        };
229        let outcome = safe_fs::write(scope, &p, &bytes, true)?;
230        if outcome.no_change {
231            report.already_installed = true;
232        } else if outcome.existed {
233            report.patched.push(outcome.path.clone());
234        } else {
235            report.created.push(outcome.path.clone());
236        }
237        if let Some(b) = outcome.backup {
238            report.backed_up.push(b);
239        }
240
241        if let Some(rules) = &spec.rules {
242            let instr = Self::instructions_path(scope)?;
243            scope.ensure_contained(&instr)?;
244            file_lock::with_lock(&instr, || {
245                let host = fs_atomic::read_to_string_or_empty(&instr)?;
246                let new_host = md_block::upsert(&host, &spec.tag, &rules.content);
247                let outcome = safe_fs::write(scope, &instr, new_host.as_bytes(), true)?;
248                if outcome.existed && !outcome.no_change {
249                    report.patched.push(outcome.path.clone());
250                    report.already_installed = false;
251                } else if !outcome.existed {
252                    report.created.push(outcome.path.clone());
253                    report.already_installed = false;
254                }
255                if let Some(b) = outcome.backup {
256                    report.backed_up.push(b);
257                }
258                Ok::<(), AgentConfigError>(())
259            })?;
260        }
261
262        Ok(report)
263    }
264
265    fn uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallReport, AgentConfigError> {
266        HookSpec::validate_tag(tag)?;
267        let mut report = UninstallReport::default();
268
269        let p = Self::hooks_file(scope, tag)?;
270        scope.ensure_contained(&p)?;
271        if p.exists() {
272            safe_fs::remove_file(scope, &p)?;
273            report.removed.push(p.clone());
274
275            // Tidy: remove .github/hooks/ if empty.
276            if let Some(parent) = p.parent() {
277                if std::fs::read_dir(parent)
278                    .map(|mut it| it.next().is_none())
279                    .unwrap_or(false)
280                {
281                    let _ = safe_fs::remove_empty_dir(scope, parent);
282                }
283            }
284        }
285
286        let instr = Self::instructions_path(scope)?;
287        scope.ensure_contained(&instr)?;
288        file_lock::with_lock(&instr, || {
289            let host = fs_atomic::read_to_string_or_empty(&instr)?;
290            let (stripped, removed) = md_block::remove(&host, tag);
291            if removed {
292                if stripped.trim().is_empty() {
293                    if safe_fs::restore_backup_if_matches(scope, &instr, stripped.as_bytes())? {
294                        report.restored.push(instr.clone());
295                    } else {
296                        safe_fs::remove_file(scope, &instr)?;
297                        report.removed.push(instr.clone());
298                    }
299                } else {
300                    safe_fs::write(scope, &instr, stripped.as_bytes(), false)?;
301                    report.patched.push(instr.clone());
302                }
303            }
304            Ok::<(), AgentConfigError>(())
305        })?;
306
307        if report.removed.is_empty() && report.patched.is_empty() && report.restored.is_empty() {
308            report.not_installed = true;
309        }
310        Ok(report)
311    }
312}
313
314impl McpSurface for CopilotAgent {
315    fn id(&self) -> &'static str {
316        "copilot"
317    }
318
319    fn supported_mcp_scopes(&self) -> &'static [ScopeKind] {
320        &[ScopeKind::Global, ScopeKind::Local]
321    }
322
323    fn mcp_status(
324        &self,
325        scope: &Scope,
326        name: &str,
327        expected_owner: &str,
328    ) -> Result<StatusReport, AgentConfigError> {
329        McpSpec::validate_name(name)?;
330        let cfg = Self::mcp_path(scope)?;
331        let ledger = ownership::mcp_ledger_for(&cfg);
332        let presence = mcp_json_map::config_presence(
333            &cfg,
334            &["mcpServers"],
335            name,
336            mcp_json_map::ConfigFormat::Json,
337        )?;
338        let recorded = ownership::owner_of(&ledger, name)?;
339        Ok(StatusReport::for_mcp(
340            name,
341            cfg,
342            ledger,
343            presence,
344            expected_owner,
345            recorded,
346        ))
347    }
348
349    fn plan_install_mcp(
350        &self,
351        scope: &Scope,
352        spec: &McpSpec,
353    ) -> Result<InstallPlan, AgentConfigError> {
354        agent_planning::mcp_json_map_install(
355            McpSurface::id(self),
356            scope,
357            spec,
358            Self::mcp_path(scope),
359            &["mcpServers"],
360            mcp_json_map::mcp_servers_value,
361            mcp_json_map::ConfigFormat::Json,
362        )
363    }
364
365    fn plan_uninstall_mcp(
366        &self,
367        scope: &Scope,
368        name: &str,
369        owner_tag: &str,
370    ) -> Result<UninstallPlan, AgentConfigError> {
371        agent_planning::mcp_json_map_uninstall(
372            McpSurface::id(self),
373            scope,
374            name,
375            owner_tag,
376            Self::mcp_path(scope),
377            &["mcpServers"],
378            mcp_json_map::ConfigFormat::Json,
379        )
380    }
381
382    fn install_mcp(
383        &self,
384        scope: &Scope,
385        spec: &McpSpec,
386    ) -> Result<InstallReport, AgentConfigError> {
387        spec.validate()?;
388        let cfg = Self::mcp_path(scope)?;
389        spec.validate_local_secret_policy(scope)?;
390        scope.ensure_contained(&cfg)?;
391        let ledger = ownership::mcp_ledger_for(&cfg);
392        mcp_json_map::install(
393            &cfg,
394            &ledger,
395            spec,
396            &["mcpServers"],
397            mcp_json_map::mcp_servers_value,
398            mcp_json_map::ConfigFormat::Json,
399        )
400    }
401
402    fn uninstall_mcp(
403        &self,
404        scope: &Scope,
405        name: &str,
406        owner_tag: &str,
407    ) -> Result<UninstallReport, AgentConfigError> {
408        McpSpec::validate_name(name)?;
409        HookSpec::validate_tag(owner_tag)?;
410        let cfg = Self::mcp_path(scope)?;
411        scope.ensure_contained(&cfg)?;
412        let ledger = ownership::mcp_ledger_for(&cfg);
413        mcp_json_map::uninstall(
414            &cfg,
415            &ledger,
416            name,
417            owner_tag,
418            "mcp server",
419            &["mcpServers"],
420            mcp_json_map::ConfigFormat::Json,
421        )
422    }
423}
424
425impl SkillSurface for CopilotAgent {
426    fn id(&self) -> &'static str {
427        "copilot"
428    }
429
430    fn supported_skill_scopes(&self) -> &'static [ScopeKind] {
431        &[ScopeKind::Global, ScopeKind::Local]
432    }
433
434    fn skill_status(
435        &self,
436        scope: &Scope,
437        name: &str,
438        expected_owner: &str,
439    ) -> Result<StatusReport, AgentConfigError> {
440        SkillSpec::validate_name(name)?;
441        let root = Self::skills_root(scope)?;
442        let (dir, manifest, ledger) = skills_dir::paths_for_status(&root, name);
443        let recorded = ownership::owner_of(&ledger, name)?;
444        Ok(StatusReport::for_skill(
445            name,
446            dir,
447            manifest,
448            ledger,
449            expected_owner,
450            recorded,
451        ))
452    }
453
454    fn plan_install_skill(
455        &self,
456        scope: &Scope,
457        spec: &SkillSpec,
458    ) -> Result<InstallPlan, AgentConfigError> {
459        agent_planning::skill_install(
460            SkillSurface::id(self),
461            scope,
462            spec,
463            Self::skills_root(scope),
464        )
465    }
466
467    fn plan_uninstall_skill(
468        &self,
469        scope: &Scope,
470        name: &str,
471        owner_tag: &str,
472    ) -> Result<UninstallPlan, AgentConfigError> {
473        agent_planning::skill_uninstall(
474            SkillSurface::id(self),
475            scope,
476            name,
477            owner_tag,
478            Self::skills_root(scope),
479        )
480    }
481
482    fn install_skill(
483        &self,
484        scope: &Scope,
485        spec: &SkillSpec,
486    ) -> Result<InstallReport, AgentConfigError> {
487        let root = Self::skills_root(scope)?;
488        scope.ensure_contained(&root)?;
489        skills_dir::install(&root, spec)
490    }
491
492    fn uninstall_skill(
493        &self,
494        scope: &Scope,
495        name: &str,
496        owner_tag: &str,
497    ) -> Result<UninstallReport, AgentConfigError> {
498        let root = Self::skills_root(scope)?;
499        scope.ensure_contained(&root)?;
500        skills_dir::uninstall(&root, name, owner_tag)
501    }
502}
503
504impl CopilotAgent {
505    fn inline_layout(
506        &self,
507        scope: &Scope,
508    ) -> Result<instructions_dir::InlineLayout, AgentConfigError> {
509        Ok(instructions_dir::InlineLayout {
510            config_dir: Self::instruction_config_dir(scope)?,
511            host_file: Self::instructions_path(scope)?,
512        })
513    }
514}
515
516impl InstructionSurface for CopilotAgent {
517    fn id(&self) -> &'static str {
518        "copilot"
519    }
520
521    fn supported_instruction_scopes(&self) -> &'static [ScopeKind] {
522        &[ScopeKind::Local]
523    }
524
525    fn instruction_status(
526        &self,
527        scope: &Scope,
528        name: &str,
529        expected_owner: &str,
530    ) -> Result<StatusReport, AgentConfigError> {
531        instructions_dir::inline_status(self.inline_layout(scope)?, name, expected_owner)
532    }
533
534    fn plan_install_instruction(
535        &self,
536        scope: &Scope,
537        spec: &InstructionSpec,
538    ) -> Result<InstallPlan, AgentConfigError> {
539        instructions_dir::inline_plan_install(
540            InstructionSurface::id(self),
541            scope,
542            self.inline_layout(scope),
543            spec,
544        )
545    }
546
547    fn plan_uninstall_instruction(
548        &self,
549        scope: &Scope,
550        name: &str,
551        owner_tag: &str,
552    ) -> Result<UninstallPlan, AgentConfigError> {
553        instructions_dir::inline_plan_uninstall(
554            InstructionSurface::id(self),
555            scope,
556            self.inline_layout(scope),
557            name,
558            owner_tag,
559        )
560    }
561
562    fn install_instruction(
563        &self,
564        scope: &Scope,
565        spec: &InstructionSpec,
566    ) -> Result<InstallReport, AgentConfigError> {
567        instructions_dir::inline_install(scope, self.inline_layout(scope)?, spec)
568    }
569
570    fn uninstall_instruction(
571        &self,
572        scope: &Scope,
573        name: &str,
574        owner_tag: &str,
575    ) -> Result<UninstallReport, AgentConfigError> {
576        instructions_dir::inline_uninstall(scope, self.inline_layout(scope)?, name, owner_tag)
577    }
578}
579
580fn matcher_to_copilot(m: &Matcher) -> String {
581    // Same family as Cursor: lowerCamelCase events, PascalCase tool names.
582    match m {
583        Matcher::All => "*".to_string(),
584        Matcher::Bash => "Shell".to_string(),
585        Matcher::Exact(s) => s.clone(),
586        Matcher::AnyOf(names) => names.join("|"),
587        Matcher::Regex(s) => s.clone(),
588    }
589}
590
591fn event_to_string(e: &Event) -> String {
592    match e {
593        Event::PreToolUse => "preToolUse".into(),
594        Event::PostToolUse => "postToolUse".into(),
595        Event::Custom(s) => s.clone(),
596    }
597}
598
599#[cfg(test)]
600mod tests {
601    use super::*;
602    use serde_json::{json, Value};
603    use tempfile::tempdir;
604
605    fn local_spec(tag: &str) -> HookSpec {
606        HookSpec::builder(tag)
607            .command_program("myapp", ["hook"])
608            .matcher(Matcher::Bash)
609            .event(Event::PreToolUse)
610            .build()
611    }
612
613    fn mcp_spec(name: &str, owner: &str) -> McpSpec {
614        McpSpec::builder(name)
615            .owner(owner)
616            .stdio("npx", ["-y", "@example/server"])
617            .build()
618    }
619
620    fn read_json(p: &std::path::Path) -> Value {
621        serde_json::from_slice(&std::fs::read(p).unwrap()).unwrap()
622    }
623
624    #[test]
625    fn install_writes_per_tag_file_with_bash_field() {
626        let dir = tempdir().unwrap();
627        let agent = CopilotAgent::new();
628        let scope = Scope::Local(dir.path().to_path_buf());
629        agent.install(&scope, &local_spec("alpha")).unwrap();
630
631        let p = dir.path().join(".github/hooks/alpha-rewrite.json");
632        let v = read_json(&p);
633        assert_eq!(v["version"], json!(1));
634        assert_eq!(v["hooks"]["preToolUse"][0]["bash"], json!("myapp hook"));
635        assert_eq!(v["hooks"]["preToolUse"][0]["matcher"], json!("Shell"));
636    }
637
638    #[test]
639    fn distinct_tags_get_distinct_files() {
640        let dir = tempdir().unwrap();
641        let agent = CopilotAgent::new();
642        let scope = Scope::Local(dir.path().to_path_buf());
643        agent.install(&scope, &local_spec("alpha")).unwrap();
644        agent.install(&scope, &local_spec("beta")).unwrap();
645        assert!(dir.path().join(".github/hooks/alpha-rewrite.json").exists());
646        assert!(dir.path().join(".github/hooks/beta-rewrite.json").exists());
647    }
648
649    #[test]
650    fn uninstall_removes_only_our_file() {
651        let dir = tempdir().unwrap();
652        let agent = CopilotAgent::new();
653        let scope = Scope::Local(dir.path().to_path_buf());
654        agent.install(&scope, &local_spec("alpha")).unwrap();
655        agent.install(&scope, &local_spec("beta")).unwrap();
656        agent.uninstall(&scope, "alpha").unwrap();
657
658        assert!(!dir.path().join(".github/hooks/alpha-rewrite.json").exists());
659        assert!(dir.path().join(".github/hooks/beta-rewrite.json").exists());
660    }
661
662    #[test]
663    fn rejects_global_scope() {
664        let agent = CopilotAgent::new();
665        let err = agent.is_installed(&Scope::Global, "alpha").unwrap_err();
666        assert!(matches!(err, AgentConfigError::UnsupportedScope { .. }));
667    }
668
669    #[test]
670    fn install_mcp_writes_cli_workspace_file() {
671        let dir = tempdir().unwrap();
672        let agent = CopilotAgent::new();
673        let scope = Scope::Local(dir.path().to_path_buf());
674        agent
675            .install_mcp(&scope, &mcp_spec("memory", "myapp"))
676            .unwrap();
677
678        let p = dir.path().join(".mcp.json");
679        let v = read_json(&p);
680        assert_eq!(v["mcpServers"]["memory"]["command"], json!("npx"));
681    }
682
683    #[test]
684    fn install_mcp_idempotent() {
685        let dir = tempdir().unwrap();
686        let agent = CopilotAgent::new();
687        let scope = Scope::Local(dir.path().to_path_buf());
688        let s = mcp_spec("memory", "myapp");
689        agent.install_mcp(&scope, &s).unwrap();
690        let r = agent.install_mcp(&scope, &s).unwrap();
691        assert!(r.already_installed);
692    }
693
694    #[test]
695    fn uninstall_mcp_owner_mismatch_refused() {
696        let dir = tempdir().unwrap();
697        let agent = CopilotAgent::new();
698        let scope = Scope::Local(dir.path().to_path_buf());
699        agent
700            .install_mcp(&scope, &mcp_spec("memory", "appA"))
701            .unwrap();
702        let err = agent.uninstall_mcp(&scope, "memory", "appB").unwrap_err();
703        assert!(matches!(err, AgentConfigError::NotOwnedByCaller { .. }));
704    }
705}