44into a user-local Copilot agents directory.
55"""
66
7+ import re
8+ import shutil
79from pathlib import Path
810
9- from .base import BaseAgentHandler
11+ import yaml
12+
13+ from .base import BaseAgentHandler , logger
14+
15+
16+ def _normalize_front_matter (front_raw : str ) -> dict :
17+ """Parse YAML front matter, handling malformed values.
18+
19+ Some agent files have unquoted values containing colons which break
20+ standard YAML parsing. This function attempts standard parsing first,
21+ then falls back to line-by-line extraction for simple key: value pairs.
22+ """
23+ # Try standard YAML parsing first
24+ try :
25+ meta = yaml .safe_load (front_raw )
26+ if isinstance (meta , dict ):
27+ return meta
28+ except yaml .YAMLError :
29+ pass
30+
31+ # Fallback: extract key-value pairs line by line
32+ # This handles cases where values contain unquoted colons
33+ meta = {}
34+ lines = front_raw .split ("\n " )
35+ current_key = None
36+ current_value_lines = []
37+
38+ for line in lines :
39+ # Check if line starts a new key (word followed by colon at start)
40+ match = re .match (r"^([a-zA-Z_][a-zA-Z0-9_-]*)\s*:\s*(.*)$" , line )
41+ if match :
42+ # Save previous key-value if exists
43+ if current_key is not None :
44+ value = "\n " .join (current_value_lines ).strip ()
45+ meta [current_key ] = _parse_yaml_value (value )
46+ current_key = match .group (1 )
47+ current_value_lines = [match .group (2 )]
48+ elif current_key is not None :
49+ # Continuation of previous value
50+ current_value_lines .append (line )
51+
52+ # Save last key-value
53+ if current_key is not None :
54+ value = "\n " .join (current_value_lines ).strip ()
55+ meta [current_key ] = _parse_yaml_value (value )
56+
57+ return meta
58+
59+
60+ def _parse_yaml_value (value : str ):
61+ """Parse a YAML value string, handling common types."""
62+ if not value :
63+ return None
64+
65+ # Try to parse as YAML for simple types (numbers, booleans, lists)
66+ try :
67+ parsed = yaml .safe_load (value )
68+ # Only use parsed value for simple types, not if it failed to parse properly
69+ if isinstance (parsed , (bool , int , float , list )):
70+ return parsed
71+ except yaml .YAMLError :
72+ pass
73+
74+ # Return as string (handles unquoted strings with colons, etc.)
75+ return value
1076
1177
1278class CopilotAgentHandler (BaseAgentHandler ):
@@ -23,3 +89,82 @@ def app_name(self) -> str:
2389 @property
2490 def _default_agents_dir (self ) -> Path :
2591 return Path .home () / ".copilot" / "agents"
92+
93+ def install (self , agent ) -> Path :
94+ """Install a Copilot agent as a Markdown file with normalized YAML frontmatter.
95+
96+ Copilot agent profiles are Markdown files with YAML frontmatter that specifies
97+ the agent's name, description, tools, and MCP server configurations.
98+ """
99+ if not agent .repo_owner or not agent .repo_name :
100+ raise ValueError (f"Agent '{ agent .key } ' has no repository information" )
101+
102+ # Ensure install directory exists
103+ self .agents_dir .mkdir (parents = True , exist_ok = True )
104+
105+ # Download repository
106+ temp_dir , _ = self ._download_repo (
107+ agent .repo_owner , agent .repo_name , agent .repo_branch or "main"
108+ )
109+
110+ try :
111+ if agent .agents_path :
112+ source_path = temp_dir / agent .agents_path .strip ("/" ) / agent .filename
113+ else :
114+ source_path = temp_dir / agent .filename
115+
116+ if not source_path .exists ():
117+ raise ValueError (f"Agent file not found in repository: { source_path } " )
118+
119+ # Read and transform content
120+ content = source_path .read_text (encoding = "utf-8" ).lstrip ("\ufeff " )
121+ parts = content .split ("---" , 2 )
122+ if len (parts ) >= 3 :
123+ front_raw = parts [1 ].strip ()
124+ body = parts [2 ].lstrip ("\n " )
125+
126+ # Parse YAML front matter with normalization for malformed values
127+ meta = _normalize_front_matter (front_raw )
128+
129+ # Description is required for agent profiles
130+ if not meta .get ("description" ):
131+ raise ValueError (
132+ "Copilot agent profile must include a 'description' in YAML front matter"
133+ )
134+
135+ # Default name to filename (without extension) if not provided
136+ if not meta .get ("name" ):
137+ meta ["name" ] = agent .filename .rsplit ("." , 1 )[0 ]
138+
139+ # Normalize tools: allow string (comma separated) or list
140+ if "tools" in meta :
141+ tools_raw = meta .get ("tools" )
142+ if isinstance (tools_raw , str ):
143+ meta ["tools" ] = [
144+ t .strip () for t in tools_raw .split ("," ) if t .strip ()
145+ ]
146+ elif isinstance (tools_raw , list ):
147+ meta ["tools" ] = tools_raw
148+ else :
149+ # Unexpected type, remove to allow access to all tools
150+ meta .pop ("tools" , None )
151+
152+ # Leave mcp-servers, model, target as provided (if any)
153+
154+ # Render normalized front matter back to YAML
155+ front_serialized = yaml .safe_dump (meta , sort_keys = False ).strip ()
156+ new_content = f"---\n { front_serialized } \n ---\n \n { body } "
157+ else :
158+ # No front matter: agent profile requires a description, so error
159+ raise ValueError (
160+ "Copilot agent file must include YAML front matter with a 'description' field"
161+ )
162+
163+ # Keep as .md file (Copilot agent profiles are Markdown)
164+ dest_path = self .agents_dir / agent .filename
165+ dest_path .write_text (new_content , encoding = "utf-8" )
166+ logger .info (f"Installed agent to: { dest_path } " )
167+ return dest_path
168+ finally :
169+ if temp_dir .exists ():
170+ shutil .rmtree (temp_dir )
0 commit comments