1use 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#[derive(Debug, Clone, Copy, Default)]
46pub struct CopilotAgent {
47 _private: (),
48}
49
50impl CopilotAgent {
51 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 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 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 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 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 other => other.as_str().into(),
597 }
598}
599
600#[cfg(test)]
601mod tests {
602 use super::*;
603 use serde_json::{json, Value};
604 use tempfile::tempdir;
605
606 fn local_spec(tag: &str) -> HookSpec {
607 HookSpec::builder(tag)
608 .command_program("myapp", ["hook"])
609 .matcher(Matcher::Bash)
610 .event(Event::PreToolUse)
611 .build()
612 }
613
614 fn mcp_spec(name: &str, owner: &str) -> McpSpec {
615 McpSpec::builder(name)
616 .owner(owner)
617 .stdio("npx", ["-y", "@example/server"])
618 .build()
619 }
620
621 fn read_json(p: &std::path::Path) -> Value {
622 serde_json::from_slice(&std::fs::read(p).unwrap()).unwrap()
623 }
624
625 #[test]
626 fn install_writes_per_tag_file_with_bash_field() {
627 let dir = tempdir().unwrap();
628 let agent = CopilotAgent::new();
629 let scope = Scope::Local(dir.path().to_path_buf());
630 agent.install(&scope, &local_spec("alpha")).unwrap();
631
632 let p = dir.path().join(".github/hooks/alpha-rewrite.json");
633 let v = read_json(&p);
634 assert_eq!(v["version"], json!(1));
635 assert_eq!(v["hooks"]["preToolUse"][0]["bash"], json!("myapp hook"));
636 assert_eq!(v["hooks"]["preToolUse"][0]["matcher"], json!("Shell"));
637 }
638
639 #[test]
640 fn distinct_tags_get_distinct_files() {
641 let dir = tempdir().unwrap();
642 let agent = CopilotAgent::new();
643 let scope = Scope::Local(dir.path().to_path_buf());
644 agent.install(&scope, &local_spec("alpha")).unwrap();
645 agent.install(&scope, &local_spec("beta")).unwrap();
646 assert!(dir.path().join(".github/hooks/alpha-rewrite.json").exists());
647 assert!(dir.path().join(".github/hooks/beta-rewrite.json").exists());
648 }
649
650 #[test]
651 fn uninstall_removes_only_our_file() {
652 let dir = tempdir().unwrap();
653 let agent = CopilotAgent::new();
654 let scope = Scope::Local(dir.path().to_path_buf());
655 agent.install(&scope, &local_spec("alpha")).unwrap();
656 agent.install(&scope, &local_spec("beta")).unwrap();
657 agent.uninstall(&scope, "alpha").unwrap();
658
659 assert!(!dir.path().join(".github/hooks/alpha-rewrite.json").exists());
660 assert!(dir.path().join(".github/hooks/beta-rewrite.json").exists());
661 }
662
663 #[test]
664 fn rejects_global_scope() {
665 let agent = CopilotAgent::new();
666 let err = agent.is_installed(&Scope::Global, "alpha").unwrap_err();
667 assert!(matches!(err, AgentConfigError::UnsupportedScope { .. }));
668 }
669
670 #[test]
671 fn install_mcp_writes_cli_workspace_file() {
672 let dir = tempdir().unwrap();
673 let agent = CopilotAgent::new();
674 let scope = Scope::Local(dir.path().to_path_buf());
675 agent
676 .install_mcp(&scope, &mcp_spec("memory", "myapp"))
677 .unwrap();
678
679 let p = dir.path().join(".mcp.json");
680 let v = read_json(&p);
681 assert_eq!(v["mcpServers"]["memory"]["command"], json!("npx"));
682 }
683
684 #[test]
685 fn install_mcp_idempotent() {
686 let dir = tempdir().unwrap();
687 let agent = CopilotAgent::new();
688 let scope = Scope::Local(dir.path().to_path_buf());
689 let s = mcp_spec("memory", "myapp");
690 agent.install_mcp(&scope, &s).unwrap();
691 let r = agent.install_mcp(&scope, &s).unwrap();
692 assert!(r.already_installed);
693 }
694
695 #[test]
696 fn uninstall_mcp_owner_mismatch_refused() {
697 let dir = tempdir().unwrap();
698 let agent = CopilotAgent::new();
699 let scope = Scope::Local(dir.path().to_path_buf());
700 agent
701 .install_mcp(&scope, &mcp_spec("memory", "appA"))
702 .unwrap();
703 let err = agent.uninstall_mcp(&scope, "memory", "appB").unwrap_err();
704 assert!(matches!(err, AgentConfigError::NotOwnedByCaller { .. }));
705 }
706}