Skip to content

Commit 91b56c9

Browse files
committed
chore: commit all changes
1 parent 34e8702 commit 91b56c9

File tree

4 files changed

+151
-4
lines changed

4 files changed

+151
-4
lines changed

code_assistant_manager/agents/copilot.py

Lines changed: 146 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,75 @@
44
into a user-local Copilot agents directory.
55
"""
66

7+
import re
8+
import shutil
79
from 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

1278
class 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)

tests/test_tools.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,7 @@ class TestCopilotTool:
236236
@patch.object(CopilotTool, "_ensure_tool_installed", return_value=True)
237237
def test_copilot_tool_run_success(self, mock_install, mock_run, config_manager):
238238
"""Test successful Copilot tool execution."""
239+
mock_run.return_value.returncode = 0
239240
tool = CopilotTool(config_manager)
240241
result = tool.run([])
241242
assert result == 0

tests/unit/test_cli_app_types.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ def test_skill_valid_app_types(self):
2929
assert set(SKILL_APP_TYPES) == expected
3030

3131
def test_agent_valid_app_types(self):
32-
"""Test agent module supports all 5 app types."""
33-
expected = {"claude", "codex", "gemini", "droid", "codebuddy"}
32+
"""Test agent module supports all 6 app types."""
33+
expected = {"claude", "codex", "gemini", "droid", "codebuddy", "copilot"}
3434
assert set(AGENT_APP_TYPES) == expected
3535

3636
def test_plugin_valid_app_types(self):

tests/unit/test_copilot_agent_handler.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,10 @@ def fake_download(owner, name, branch):
4545

4646
dest = handler.install(dummy)
4747
assert dest.exists()
48+
# Copilot agent profiles remain as .md files with normalized YAML frontmatter
4849
assert dest.name == "my-agent.md"
4950

50-
# Now uninstall
51+
# Now uninstall should remove the .md file
5152
removed = handler.uninstall(dummy)
5253
assert removed is True
5354
assert not dest.exists()

0 commit comments

Comments
 (0)