diff --git a/AGENTS.md b/AGENTS.md index 42aa4a8..fa60fbe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -139,6 +139,7 @@ Defined in `targets.py` TARGETS dict. Each assistant has different output format | Assistant | Skills | Commands | Agents | |-----------|--------|----------|--------| | claude-code | `.claude/skills//SKILL.md` | `.claude/commands/.md` | `.claude/agents/.md` | +| copilot | `.github/skills//SKILL.md` (project) / `~/.copilot/skills//SKILL.md` (user) | `.github/prompts/.prompt.md` (project) / `~/.copilot/prompts/.prompt.md` (user) | `.github/agents/.agent.md` (project) / `~/.copilot/agents/.agent.md` (user) | | cursor | `.cursor/skills//SKILL.md` | `.cursor/commands/.md` | `.cursor/agents/.md` | | gemini-cli | `GEMINI.md` (managed section) | `.gemini/commands/.toml` | N/A | | openclaw | `~/.openclaw/workspace/skills//SKILL.md` | N/A | N/A | @@ -146,6 +147,7 @@ Defined in `targets.py` TARGETS dict. Each assistant has different output format Agent frontmatter is modified during generation: - Claude Code: `name` (agent name) and `model: inherit` are added +- Copilot: `generate_agent` is passthrough (content copied as-is); skill frontmatter is rewritten to include `name` and `description` - Cursor: `name` (agent name) and `model: inherit` are added - OpenCode: `mode: subagent` is added diff --git a/src/lola/targets/__init__.py b/src/lola/targets/__init__.py index b9be7bf..1d2577e 100644 --- a/src/lola/targets/__init__.py +++ b/src/lola/targets/__init__.py @@ -29,6 +29,7 @@ # Concrete target implementations from lola.targets.claude_code import ClaudeCodeTarget +from lola.targets.copilot import CopilotTarget from lola.targets.cursor import CursorTarget from lola.targets.gemini import GeminiTarget, _convert_to_gemini_args from lola.targets.openclaw import OpenClawTarget @@ -49,6 +50,7 @@ TARGETS: dict[str, AssistantTarget] = { "claude-code": ClaudeCodeTarget(), + "copilot": CopilotTarget(), "cursor": CursorTarget(), "gemini-cli": GeminiTarget(), "openclaw": OpenClawTarget(), @@ -76,6 +78,7 @@ def get_target(assistant: str) -> AssistantTarget: "MCPSupportMixin", # Concrete targets "ClaudeCodeTarget", + "CopilotTarget", "CursorTarget", "GeminiTarget", "OpenClawTarget", diff --git a/src/lola/targets/copilot.py b/src/lola/targets/copilot.py new file mode 100644 index 0000000..1bd12d1 --- /dev/null +++ b/src/lola/targets/copilot.py @@ -0,0 +1,198 @@ +"""GitHub Copilot target implementation.""" + +from __future__ import annotations + +from pathlib import Path + +import lola.config as config +import lola.frontmatter as fm +from .base import ( + BaseAssistantTarget, + ManagedInstructionsTarget, + MCPSupportMixin, + _generate_passthrough_command, +) + + +class CopilotTarget(MCPSupportMixin, ManagedInstructionsTarget, BaseAssistantTarget): + """Target for GitHub Copilot (VS Code + Visual Studio). + + Copilot supports: + - Skills in .copilot/skills//SKILL.md (with name+description frontmatter) + - Prompt files in .github/prompts/*.prompt.md + - Agents in .github/agents/*.agent.md + - Global instructions in .github/copilot-instructions.md + - MCP servers in .github/copilot/mcp.json + """ + + name = "copilot" + supports_agents = True + INSTRUCTIONS_FILE = "copilot-instructions.md" + + def get_skill_path(self, project_path: str, scope: str = "project") -> Path: + if scope == "user": + return Path.home() / ".copilot" / "skills" + return Path(project_path) / ".github" / "skills" + + def get_command_path(self, project_path: str, scope: str = "project") -> Path: + if scope == "user": + return Path.home() / ".copilot" / "prompts" + return Path(project_path) / ".github" / "prompts" + + def get_agent_path(self, project_path: str, scope: str = "project") -> Path: + if scope == "user": + return Path.home() / ".copilot" / "agents" + return Path(project_path) / ".github" / "agents" + + def get_instructions_path(self, project_path: str, scope: str = "project") -> Path: + if scope == "user": + return Path.home() / ".copilot" / self.INSTRUCTIONS_FILE + return Path(project_path) / ".github" / self.INSTRUCTIONS_FILE + + def get_mcp_path(self, project_path: str, scope: str = "project") -> Path: + if scope == "user": + return Path.home() / ".copilot" / "mcp.json" + return Path(project_path) / ".github" / "copilot" / "mcp.json" + + def generate_skill( + self, + source_path: Path, + dest_path: Path, + skill_name: str, + project_path: str | None = None, # noqa: ARG002 + ) -> bool: + """Generate SKILL.md in .copilot/skills// directory. + + Copilot skills use a directory-per-skill structure with + name + description in YAML frontmatter. + """ + if not source_path.exists(): + return False + + skill_file = source_path / config.SKILL_FILE + if not skill_file.exists(): + return False + + content = skill_file.read_text() + frontmatter, body = fm.parse(content) + + description = frontmatter.get("description") + if not description: + return False + + skill_dir = dest_path / skill_name + skill_dir.mkdir(parents=True, exist_ok=True) + + # Build Copilot-compatible frontmatter (requires name + description) + import yaml + + copilot_fm: dict = { + "name": skill_name, + "description": description, + } + if frontmatter.get("applyTo"): + copilot_fm["applyTo"] = frontmatter["applyTo"] + elif frontmatter.get("globs"): + copilot_fm["applyTo"] = frontmatter["globs"] + + fm_str = yaml.dump( + copilot_fm, default_flow_style=False, sort_keys=False + ).rstrip() + output = f"---\n{fm_str}\n---\n{body}" + + dest_file = skill_dir / "SKILL.md" + dest_file.write_text(output) + return True + + def remove_skill(self, dest_path: Path, skill_name: str) -> bool: + """Remove a skill's directory.""" + import shutil + + removed = False + skill_dir = dest_path / skill_name + if skill_dir.exists(): + shutil.rmtree(skill_dir) + removed = True + # Legacy cleanup: old .instructions.md format + legacy_file = ( + dest_path.parent / "instructions" / f"{skill_name}.instructions.md" + ) + if legacy_file.exists(): + legacy_file.unlink() + removed = True + return removed + + def generate_command( + self, + source_path: Path, + dest_dir: Path, + cmd_name: str, + module_name: str, + ) -> bool: + filename = self.get_command_filename(module_name, cmd_name) + return _generate_passthrough_command(source_path, dest_dir, filename) + + def get_command_filename(self, module_name: str, cmd_name: str) -> str: # noqa: ARG002 + """Copilot uses .prompt.md extension for commands.""" + return f"{cmd_name}.prompt.md" + + def generate_agent( + self, + source_path: Path, + dest_dir: Path, + agent_name: str, + module_name: str, + ) -> bool: + """Generate agent file with .agent.md extension. + + Copilot agents use YAML frontmatter with fields like: + - description: when to use this agent + - tools: list of tools the agent can use + """ + if not source_path.exists(): + return False + dest_dir.mkdir(parents=True, exist_ok=True) + + filename = self.get_agent_filename(module_name, agent_name) + content = source_path.read_text() + + (dest_dir / filename).write_text(content) + return True + + def get_agent_filename(self, module_name: str, agent_name: str) -> str: # noqa: ARG002 + """Copilot uses .agent.md extension for agents.""" + return f"{agent_name}.agent.md" + + def remove_command( + self, + dest_dir: Path, + cmd_name: str, + module_name: str, + ) -> bool: + """Delete command file (.prompt.md).""" + filename = self.get_command_filename(module_name, cmd_name) + cmd_file = dest_dir / filename + if cmd_file.exists(): + cmd_file.unlink() + # Legacy cleanup + legacy_file = dest_dir / f"{module_name}.{cmd_name}.prompt.md" + if legacy_file.exists(): + legacy_file.unlink() + return True + + def remove_agent( + self, + dest_dir: Path, + agent_name: str, + module_name: str, + ) -> bool: + """Delete agent file (.agent.md).""" + filename = self.get_agent_filename(module_name, agent_name) + agent_file = dest_dir / filename + if agent_file.exists(): + agent_file.unlink() + # Legacy cleanup + legacy_file = dest_dir / f"{module_name}.{agent_name}.agent.md" + if legacy_file.exists(): + legacy_file.unlink() + return True diff --git a/tests/test_copilot_target.py b/tests/test_copilot_target.py new file mode 100644 index 0000000..d1a4c48 --- /dev/null +++ b/tests/test_copilot_target.py @@ -0,0 +1,342 @@ +"""Tests for CopilotTarget scope-aware path resolution and file generation.""" + +from pathlib import Path + +from lola.targets.copilot import CopilotTarget + + +# --- Project scope path tests --- + + +def test_copilot_skill_path_project_scope(): + target = CopilotTarget() + path = target.get_skill_path("/home/user/project", "project") + assert path == Path("/home/user/project/.github/skills") + + +def test_copilot_command_path_project_scope(): + target = CopilotTarget() + path = target.get_command_path("/home/user/project", "project") + assert path == Path("/home/user/project/.github/prompts") + + +def test_copilot_agent_path_project_scope(): + target = CopilotTarget() + path = target.get_agent_path("/home/user/project", "project") + assert path == Path("/home/user/project/.github/agents") + + +def test_copilot_instructions_path_project_scope(): + target = CopilotTarget() + path = target.get_instructions_path("/home/user/project", "project") + assert path == Path("/home/user/project/.github/copilot-instructions.md") + + +def test_copilot_mcp_path_project_scope(): + target = CopilotTarget() + path = target.get_mcp_path("/home/user/project", "project") + assert path == Path("/home/user/project/.github/copilot/mcp.json") + + +# --- User scope path tests --- + + +def test_copilot_skill_path_user_scope(): + target = CopilotTarget() + path = target.get_skill_path("/home/user/project", "user") + assert path == Path.home() / ".copilot" / "skills" + + +def test_copilot_command_path_user_scope(): + target = CopilotTarget() + path = target.get_command_path("/home/user/project", "user") + assert path == Path.home() / ".copilot" / "prompts" + + +def test_copilot_agent_path_user_scope(): + target = CopilotTarget() + path = target.get_agent_path("/home/user/project", "user") + assert path == Path.home() / ".copilot" / "agents" + + +def test_copilot_instructions_path_user_scope(): + target = CopilotTarget() + path = target.get_instructions_path("/home/user/project", "user") + assert path == Path.home() / ".copilot" / "copilot-instructions.md" + + +def test_copilot_mcp_path_user_scope(): + target = CopilotTarget() + path = target.get_mcp_path("/home/user/project", "user") + assert path == Path.home() / ".copilot" / "mcp.json" + + +# --- Default scope tests --- + + +def test_copilot_skill_path_default_scope(): + target = CopilotTarget() + path = target.get_skill_path("/home/user/project") + assert path == Path("/home/user/project/.github/skills") + + +def test_copilot_command_path_default_scope(): + target = CopilotTarget() + path = target.get_command_path("/home/user/project") + assert path == Path("/home/user/project/.github/prompts") + + +def test_copilot_agent_path_default_scope(): + target = CopilotTarget() + path = target.get_agent_path("/home/user/project") + assert path == Path("/home/user/project/.github/agents") + + +def test_copilot_instructions_path_default_scope(): + target = CopilotTarget() + path = target.get_instructions_path("/home/user/project") + assert path == Path("/home/user/project/.github/copilot-instructions.md") + + +def test_copilot_mcp_path_default_scope(): + target = CopilotTarget() + path = target.get_mcp_path("/home/user/project") + assert path == Path("/home/user/project/.github/copilot/mcp.json") + + +# --- Skill generation tests --- + + +def test_generate_skill_basic(tmp_path): + """Generate SKILL.md in skill directory with name + description frontmatter.""" + target = CopilotTarget() + source = tmp_path / "my-skill" + source.mkdir() + (source / "SKILL.md").write_text( + "---\ndescription: Does the thing\n---\n\nDo the thing.\n" + ) + + dest = tmp_path / "skills" + result = target.generate_skill(source, dest, "my-skill") + + assert result is True + output_file = dest / "my-skill" / "SKILL.md" + assert output_file.exists() + content = output_file.read_text() + assert "name: my-skill" in content + assert "description: Does the thing" in content + assert "Do the thing." in content + + +def test_generate_skill_missing_description(tmp_path): + """Return False if SKILL.md has no description (required by Copilot).""" + target = CopilotTarget() + source = tmp_path / "my-skill" + source.mkdir() + (source / "SKILL.md").write_text("No frontmatter here.\n") + + dest = tmp_path / "skills" + result = target.generate_skill(source, dest, "my-skill") + assert result is False + + +def test_generate_skill_with_apply_to(tmp_path): + """Generate SKILL.md preserving applyTo and description frontmatter.""" + target = CopilotTarget() + source = tmp_path / "tf-skill" + source.mkdir() + (source / "SKILL.md").write_text( + '---\napplyTo: "**/*.tf"\ndescription: Terraform help\n---\n\nTerraform instructions.\n' + ) + + dest = tmp_path / "skills" + result = target.generate_skill(source, dest, "tf-skill") + + assert result is True + output_file = dest / "tf-skill" / "SKILL.md" + content = output_file.read_text() + assert "name: tf-skill" in content + assert "description: Terraform help" in content + assert "applyTo:" in content + assert "**/*.tf" in content + assert "Terraform instructions." in content + + +def test_generate_skill_with_globs_as_apply_to(tmp_path): + """Generate SKILL.md converting globs to applyTo.""" + target = CopilotTarget() + source = tmp_path / "py-skill" + source.mkdir() + (source / "SKILL.md").write_text( + '---\nglobs: "**/*.py"\ndescription: Python help\n---\n\nPython instructions.\n' + ) + + dest = tmp_path / "skills" + result = target.generate_skill(source, dest, "py-skill") + + assert result is True + output_file = dest / "py-skill" / "SKILL.md" + content = output_file.read_text() + assert "applyTo:" in content + assert "**/*.py" in content + + +def test_generate_skill_missing_source(tmp_path): + """Return False if source doesn't exist.""" + target = CopilotTarget() + source = tmp_path / "nonexistent" + dest = tmp_path / "skills" + + result = target.generate_skill(source, dest, "nonexistent") + assert result is False + + +def test_generate_skill_missing_skill_md(tmp_path): + """Return False if SKILL.md doesn't exist in source.""" + target = CopilotTarget() + source = tmp_path / "empty-skill" + source.mkdir() + dest = tmp_path / "skills" + + result = target.generate_skill(source, dest, "empty-skill") + assert result is False + + +# --- Skill removal tests --- + + +def test_remove_skill(tmp_path): + """Remove existing skill directory.""" + target = CopilotTarget() + dest = tmp_path / "skills" + skill_dir = dest / "my-skill" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text("content") + + result = target.remove_skill(dest, "my-skill") + assert result is True + assert not skill_dir.exists() + + +def test_remove_skill_not_found(tmp_path): + """Return False if skill directory doesn't exist.""" + target = CopilotTarget() + dest = tmp_path / "skills" + dest.mkdir() + + result = target.remove_skill(dest, "missing") + assert result is False + + +def test_remove_skill_legacy_instructions(tmp_path): + """Remove both skill dir and legacy .instructions.md during uninstall.""" + target = CopilotTarget() + # dest_path is .github/skills, so parent is .github + dest = tmp_path / ".github" / "skills" + skill_dir = dest / "my-skill" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text("content") + instructions_dir = tmp_path / ".github" / "instructions" + instructions_dir.mkdir() + legacy_file = instructions_dir / "my-skill.instructions.md" + legacy_file.write_text("old format") + + result = target.remove_skill(dest, "my-skill") + assert result is True + assert not skill_dir.exists() + assert not legacy_file.exists() + + +# --- Command generation tests --- + + +def test_generate_command(tmp_path): + """Generate .prompt.md command file.""" + target = CopilotTarget() + source = tmp_path / "review.md" + source.write_text("Review the code.\n") + dest = tmp_path / "prompts" + + result = target.generate_command(source, dest, "review", "my-module") + assert result is True + assert (dest / "review.prompt.md").exists() + assert (dest / "review.prompt.md").read_text() == "Review the code.\n" + + +def test_command_filename(): + """Command filename uses .prompt.md extension.""" + target = CopilotTarget() + assert target.get_command_filename("my-module", "review") == "review.prompt.md" + + +# --- Agent generation tests --- + + +def test_generate_agent(tmp_path): + """Generate .agent.md agent file.""" + target = CopilotTarget() + source = tmp_path / "reviewer.md" + source.write_text("---\ndescription: Reviews code\n---\n\nAgent content.\n") + dest = tmp_path / "agents" + + result = target.generate_agent(source, dest, "reviewer", "my-module") + assert result is True + output = dest / "reviewer.agent.md" + assert output.exists() + assert "description: Reviews code" in output.read_text() + + +def test_generate_agent_missing_source(tmp_path): + """Return False if agent source doesn't exist.""" + target = CopilotTarget() + source = tmp_path / "missing.md" + dest = tmp_path / "agents" + + result = target.generate_agent(source, dest, "missing", "my-module") + assert result is False + + +def test_agent_filename(): + """Agent filename uses .agent.md extension.""" + target = CopilotTarget() + assert target.get_agent_filename("my-module", "reviewer") == "reviewer.agent.md" + + +# --- Remove command/agent tests --- + + +def test_remove_command(tmp_path): + """Remove .prompt.md command file.""" + target = CopilotTarget() + dest = tmp_path / "prompts" + dest.mkdir() + (dest / "review.prompt.md").write_text("content") + + result = target.remove_command(dest, "review", "my-module") + assert result is True + assert not (dest / "review.prompt.md").exists() + + +def test_remove_agent(tmp_path): + """Remove .agent.md agent file.""" + target = CopilotTarget() + dest = tmp_path / "agents" + dest.mkdir() + (dest / "reviewer.agent.md").write_text("content") + + result = target.remove_agent(dest, "reviewer", "my-module") + assert result is True + assert not (dest / "reviewer.agent.md").exists() + + +# --- Target metadata --- + + +def test_copilot_target_name(): + target = CopilotTarget() + assert target.name == "copilot" + + +def test_copilot_supports_agents(): + target = CopilotTarget() + assert target.supports_agents is True