From 2c139ca1aeb6a08ae3fae3a451c45eab0da892c8 Mon Sep 17 00:00:00 2001 From: Mo Khan Date: Thu, 21 May 2026 15:37:36 -0500 Subject: [PATCH 01/13] feat: add GitHub Copilot target (#149) --- AGENTS.md | 2 + src/lola/targets/__init__.py | 3 + src/lola/targets/copilot.py | 193 ++++++++++++++++++++++ tests/test_copilot_target.py | 308 +++++++++++++++++++++++++++++++++++ 4 files changed, 506 insertions(+) create mode 100644 src/lola/targets/copilot.py create mode 100644 tests/test_copilot_target.py diff --git a/AGENTS.md b/AGENTS.md index 42aa4a8..a40e18c 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` | `.github/agents/.agent.md` | | 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: `name` (matching directory name) and `description` are added to skill frontmatter - 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..3f76cc5 --- /dev/null +++ b/src/lola/targets/copilot.py @@ -0,0 +1,193 @@ +"""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: + base = Path.home() if scope == "user" else Path(project_path) + return base / ".github" / self.INSTRUCTIONS_FILE + + def get_mcp_path(self, project_path: str, scope: str = "project") -> Path: + base = Path.home() if scope == "user" else Path(project_path) + return base / ".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 + + skill_dir = dest_path / skill_name + skill_dir.mkdir(parents=True, exist_ok=True) + + content = skill_file.read_text() + frontmatter, body = fm.parse(content) + + # Build Copilot-compatible frontmatter (requires name + description) + import yaml + + copilot_fm: dict = {"name": skill_name} + if frontmatter.get("description"): + copilot_fm["description"] = frontmatter["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 + + skill_dir = dest_path / skill_name + if skill_dir.exists(): + shutil.rmtree(skill_dir) + return True + # Legacy cleanup: old .instructions.md format + legacy_file = ( + dest_path.parent + / ".github" + / "instructions" + / f"{skill_name}.instructions.md" + ) + if legacy_file.exists(): + legacy_file.unlink() + return True + return False + + 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..e242fbe --- /dev/null +++ b/tests/test_copilot_target.py @@ -0,0 +1,308 @@ +"""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() / ".github" / "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() / ".github" / "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 frontmatter.""" + target = CopilotTarget() + source = tmp_path / "my-skill" + source.mkdir() + (source / "SKILL.md").write_text("Do 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 "Do the thing." in content + + +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 + + +# --- 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 From 14c0aa28c9cfe386734994fef61f4fb1dcd021b3 Mon Sep 17 00:00:00 2001 From: Mo Khan Date: Thu, 21 May 2026 16:14:41 -0500 Subject: [PATCH 02/13] fix: require description in skill frontmatter, fix legacy path --- src/lola/targets/copilot.py | 22 ++++++++++++---------- tests/test_copilot_target.py | 19 +++++++++++++++++-- 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/src/lola/targets/copilot.py b/src/lola/targets/copilot.py index 3f76cc5..917aa65 100644 --- a/src/lola/targets/copilot.py +++ b/src/lola/targets/copilot.py @@ -71,18 +71,23 @@ def generate_skill( if not skill_file.exists(): return False - skill_dir = dest_path / skill_name - skill_dir.mkdir(parents=True, exist_ok=True) - 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} - if frontmatter.get("description"): - copilot_fm["description"] = frontmatter["description"] + copilot_fm: dict = { + "name": skill_name, + "description": description, + } if frontmatter.get("applyTo"): copilot_fm["applyTo"] = frontmatter["applyTo"] elif frontmatter.get("globs"): @@ -107,10 +112,7 @@ def remove_skill(self, dest_path: Path, skill_name: str) -> bool: return True # Legacy cleanup: old .instructions.md format legacy_file = ( - dest_path.parent - / ".github" - / "instructions" - / f"{skill_name}.instructions.md" + dest_path.parent / "instructions" / f"{skill_name}.instructions.md" ) if legacy_file.exists(): legacy_file.unlink() diff --git a/tests/test_copilot_target.py b/tests/test_copilot_target.py index e242fbe..067fbca 100644 --- a/tests/test_copilot_target.py +++ b/tests/test_copilot_target.py @@ -108,11 +108,13 @@ def test_copilot_mcp_path_default_scope(): def test_generate_skill_basic(tmp_path): - """Generate SKILL.md in skill directory with name frontmatter.""" + """Generate SKILL.md in skill directory with name + description frontmatter.""" target = CopilotTarget() source = tmp_path / "my-skill" source.mkdir() - (source / "SKILL.md").write_text("Do the thing.\n") + (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") @@ -122,9 +124,22 @@ def test_generate_skill_basic(tmp_path): 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() From a3ed82f6a4e1c6639b7eb3b514e397267eacff6b Mon Sep 17 00:00:00 2001 From: Mo Khan Date: Thu, 21 May 2026 16:15:12 -0500 Subject: [PATCH 03/13] docs: fix frontmatter section wording for copilot --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index a40e18c..510b358 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -147,7 +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: `name` (matching directory name) and `description` are added to skill frontmatter +- Copilot: `name` (matching directory name) and `description` are added - Cursor: `name` (agent name) and `model: inherit` are added - OpenCode: `mode: subagent` is added From 0a40d530e0350cb7c17585c01c6226aa47c6a998 Mon Sep 17 00:00:00 2001 From: Mo Khan Date: Thu, 21 May 2026 16:28:36 -0500 Subject: [PATCH 04/13] fix: ensure legacy skill cleanup runs even when skill dir exists --- src/lola/targets/copilot.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/lola/targets/copilot.py b/src/lola/targets/copilot.py index 917aa65..8df47ef 100644 --- a/src/lola/targets/copilot.py +++ b/src/lola/targets/copilot.py @@ -106,18 +106,19 @@ 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) - return True + 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() - return True - return False + removed = True + return removed def generate_command( self, From 99ed96aeb1826dc5e7a58c79b6aacd53b2a433f6 Mon Sep 17 00:00:00 2001 From: Mo Khan Date: Thu, 21 May 2026 16:29:03 -0500 Subject: [PATCH 05/13] test: add regression test for legacy skill cleanup --- tests/test_copilot_target.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/test_copilot_target.py b/tests/test_copilot_target.py index 067fbca..b225cae 100644 --- a/tests/test_copilot_target.py +++ b/tests/test_copilot_target.py @@ -228,6 +228,25 @@ def test_remove_skill_not_found(tmp_path): 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 --- From 1a22a7b8b2176a4b62431763d4d6aeb761da13ec Mon Sep 17 00:00:00 2001 From: Mo Khan Date: Sun, 24 May 2026 22:26:24 -0500 Subject: [PATCH 06/13] docs: clarify copilot target paths and agent passthrough behavior --- AGENTS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 510b358..fa60fbe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -139,7 +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` | `.github/agents/.agent.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 | @@ -147,7 +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: `name` (matching directory name) and `description` 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 From 00d94c842cfb528f7f86440d7b05efc09030cf29 Mon Sep 17 00:00:00 2001 From: Mo Khan Date: Sun, 24 May 2026 22:34:26 -0500 Subject: [PATCH 07/13] fix: use ~/.copilot/ for user-scope instructions and MCP paths --- src/lola/targets/copilot.py | 10 ++++++---- tests/test_copilot_target.py | 4 ++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/lola/targets/copilot.py b/src/lola/targets/copilot.py index 8df47ef..1bd12d1 100644 --- a/src/lola/targets/copilot.py +++ b/src/lola/targets/copilot.py @@ -45,12 +45,14 @@ def get_agent_path(self, project_path: str, scope: str = "project") -> Path: return Path(project_path) / ".github" / "agents" def get_instructions_path(self, project_path: str, scope: str = "project") -> Path: - base = Path.home() if scope == "user" else Path(project_path) - return base / ".github" / self.INSTRUCTIONS_FILE + 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: - base = Path.home() if scope == "user" else Path(project_path) - return base / ".github" / "copilot" / "mcp.json" + if scope == "user": + return Path.home() / ".copilot" / "mcp.json" + return Path(project_path) / ".github" / "copilot" / "mcp.json" def generate_skill( self, diff --git a/tests/test_copilot_target.py b/tests/test_copilot_target.py index b225cae..d1a4c48 100644 --- a/tests/test_copilot_target.py +++ b/tests/test_copilot_target.py @@ -62,13 +62,13 @@ def test_copilot_agent_path_user_scope(): def test_copilot_instructions_path_user_scope(): target = CopilotTarget() path = target.get_instructions_path("/home/user/project", "user") - assert path == Path.home() / ".github" / "copilot-instructions.md" + 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() / ".github" / "copilot" / "mcp.json" + assert path == Path.home() / ".copilot" / "mcp.json" # --- Default scope tests --- From da9197ac42e08460d009b2347c833e5fdfefaaf4 Mon Sep 17 00:00:00 2001 From: Mo Khan Date: Thu, 28 May 2026 20:14:45 -0500 Subject: [PATCH 08/13] fix: copy supporting files when installing Copilot skills Skills that include scripts, examples, or other resources alongside SKILL.md now have those files copied to the destination directory, matching the behavior of other file-based targets. --- src/lola/targets/copilot.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/lola/targets/copilot.py b/src/lola/targets/copilot.py index 1bd12d1..8f8335e 100644 --- a/src/lola/targets/copilot.py +++ b/src/lola/targets/copilot.py @@ -102,6 +102,21 @@ def generate_skill( dest_file = skill_dir / "SKILL.md" dest_file.write_text(output) + + # Copy supporting files (scripts, examples, etc.) + import shutil + + for item in source_path.iterdir(): + if item.name == config.SKILL_FILE: + continue + dest_item = skill_dir / item.name + if item.is_dir(): + if dest_item.exists(): + shutil.rmtree(dest_item) + shutil.copytree(item, dest_item) + else: + shutil.copy2(item, dest_item) + return True def remove_skill(self, dest_path: Path, skill_name: str) -> bool: From fc6f0f4a6f18e58781b21e8cb90c617a4587246e Mon Sep 17 00:00:00 2001 From: Mo Khan Date: Thu, 28 May 2026 20:22:36 -0500 Subject: [PATCH 09/13] fix: use correct MCP paths for VS Code and Copilot CLI --- src/lola/targets/copilot.py | 4 ++-- tests/test_copilot_target.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/lola/targets/copilot.py b/src/lola/targets/copilot.py index 8f8335e..920540d 100644 --- a/src/lola/targets/copilot.py +++ b/src/lola/targets/copilot.py @@ -51,8 +51,8 @@ def get_instructions_path(self, project_path: str, scope: str = "project") -> Pa 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" + return Path.home() / ".copilot" / "mcp-config.json" + return Path(project_path) / ".vscode" / "mcp.json" def generate_skill( self, diff --git a/tests/test_copilot_target.py b/tests/test_copilot_target.py index d1a4c48..655d38e 100644 --- a/tests/test_copilot_target.py +++ b/tests/test_copilot_target.py @@ -35,7 +35,7 @@ def test_copilot_instructions_path_project_scope(): 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") + assert path == Path("/home/user/project/.vscode/mcp.json") # --- User scope path tests --- @@ -68,7 +68,7 @@ def test_copilot_instructions_path_user_scope(): 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" + assert path == Path.home() / ".copilot" / "mcp-config.json" # --- Default scope tests --- @@ -101,7 +101,7 @@ def test_copilot_instructions_path_default_scope(): 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") + assert path == Path("/home/user/project/.vscode/mcp.json") # --- Skill generation tests --- From ed4ef2ce3dc64d048fe876825adcad281e49a124 Mon Sep 17 00:00:00 2001 From: Mo Khan Date: Tue, 2 Jun 2026 23:50:46 -0500 Subject: [PATCH 10/13] Split Copilot into copilot-cli and copilot-vscode targets --- AGENTS.md | 12 ++- README.md | 28 +++++-- src/lola/cli/install.py | 6 +- src/lola/cli/sync.py | 4 +- src/lola/targets/__init__.py | 26 ++++++- src/lola/targets/base.py | 6 +- src/lola/targets/copilot.py | 145 +++++++++++++++++++++++++++++++++-- src/lola/targets/install.py | 85 +++++++++++++++++++- tests/test_copilot_target.py | 101 +++++++++++++++++++++++- tests/test_installer.py | 80 +++++++++++++++++++ tests/test_integration.py | 6 ++ 11 files changed, 473 insertions(+), 26 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fa60fbe..07f8101 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -139,12 +139,22 @@ 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) | +| copilot-cli | `.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) | +| copilot-vscode | `.github/skills//SKILL.md` (project) / `~/.copilot/skills//SKILL.md` (user) | `.github/prompts/.prompt.md` (project only) | `.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 | | opencode | `AGENTS.md` (managed section) | `.opencode/commands/.md` | `.opencode/agents/.md` | +`copilot-cli` and `copilot-vscode` share the same `.github/` (project) and +`~/.copilot/` (user) files and differ only in MCP handling: `copilot-cli` writes +MCP servers with the `mcpServers` key (`~/.copilot/mcp-config.json` at user +scope), while `copilot-vscode` writes them to `.vscode/mcp.json` using VS Code's +`servers` key. VS Code has no user-scope location for slash commands or MCP, so +those are skipped (with a warning) when installing `copilot-vscode` at user +scope. When no assistant is selected explicitly, `copilot-vscode` is preferred +over `copilot-cli` to avoid writing the same project files twice. + 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` diff --git a/README.md b/README.md index e5d5346..10c9659 100644 --- a/README.md +++ b/README.md @@ -11,12 +11,28 @@ Lola is a universal AI Package Manager. If an agent's skills were an RPM, Lola i ## Supported AI Assistants -| Assistant | Skills | Commands | Agents | -| ----------- | ------ | -------- | ------ | -| Claude Code | Yes | Yes | Yes | -| Cursor | Yes | Yes | Yes | -| Gemini CLI | Yes | Yes | N/A | -| OpenCode | Yes | Yes | Yes | +| Assistant | Skills | Commands | Agents | +| ----------------- | ------ | -------- | ------ | +| Claude Code | Yes | Yes | Yes | +| Copilot CLI | Yes | Yes | Yes | +| Copilot (VS Code) | Yes | Yes¹ | Yes | +| Cursor | Yes | Yes | Yes | +| Gemini CLI | Yes | Yes | N/A | +| OpenCode | Yes | Yes | Yes | + +¹ Project scope only — VS Code has no user-scope location for slash commands. + +GitHub Copilot has two targets that share the same `.github/` (project) and +`~/.copilot/` (user) files but differ in MCP handling: + +- `copilot-cli` - writes MCP servers using the `mcpServers` key + (`~/.copilot/mcp-config.json` for user scope). +- `copilot-vscode` - writes MCP servers to `.vscode/mcp.json` using VS + Code's `servers` key. VS Code has no user-scope location for slash commands + or MCP, so those are skipped (with a warning) when installing at user scope. + +When no assistant is selected explicitly, `copilot-vscode` is preferred over +`copilot-cli` to avoid writing the same project files twice. ## Installation diff --git a/src/lola/cli/install.py b/src/lola/cli/install.py index 79c8463..1ea5f0a 100644 --- a/src/lola/cli/install.py +++ b/src/lola/cli/install.py @@ -42,6 +42,7 @@ _get_skill_description, _skill_source_dir, copy_module_to_local, + default_assistants, get_registry, get_target, install_to_assistant, @@ -907,8 +908,9 @@ def install_cmd( raise SystemExit(130) assistants_to_install = chosen else: - # Non-interactive: preserve original default (all assistants) - assistants_to_install = list(TARGETS.keys()) + # Non-interactive: default to all assistants (collapsing the copilot + # variants to the project-granular copilot-vscode to avoid collisions). + assistants_to_install = default_assistants() # Resolve hooks with precedence: CLI flags > module lola.yaml > marketplace effective_pre_install = ( diff --git a/src/lola/cli/sync.py b/src/lola/cli/sync.py index 7e5eee1..24d2055 100644 --- a/src/lola/cli/sync.py +++ b/src/lola/cli/sync.py @@ -8,7 +8,7 @@ from lola.config import MODULES_DIR, MARKET_DIR, CACHE_DIR from lola.sync import load_lolareq, ModuleSpec from lola.cli.mod import load_registered_module, save_source_info -from lola.targets import get_registry, TARGETS +from lola.targets import get_registry, TARGETS, default_assistants from lola.targets.install import install_to_assistant from lola.market.manager import parse_market_ref, MarketplaceRegistry from lola.parsers import detect_source_type, fetch_module, fetch_module_as_name @@ -197,7 +197,7 @@ def sync_module_spec( raise ValueError(f"Unknown assistant in fragment: {asst}") target_assistants = unique_assistants else: - target_assistants = list(TARGETS.keys()) + target_assistants = default_assistants() # Check if already installed already_installed_assistants = {inst.assistant for inst in project_installations} diff --git a/src/lola/targets/__init__.py b/src/lola/targets/__init__.py index 1d2577e..cc0d6ef 100644 --- a/src/lola/targets/__init__.py +++ b/src/lola/targets/__init__.py @@ -29,7 +29,7 @@ # Concrete target implementations from lola.targets.claude_code import ClaudeCodeTarget -from lola.targets.copilot import CopilotTarget +from lola.targets.copilot import CopilotCliTarget, CopilotVSCodeTarget from lola.targets.cursor import CursorTarget from lola.targets.gemini import GeminiTarget, _convert_to_gemini_args from lola.targets.openclaw import OpenClawTarget @@ -50,7 +50,8 @@ TARGETS: dict[str, AssistantTarget] = { "claude-code": ClaudeCodeTarget(), - "copilot": CopilotTarget(), + "copilot-cli": CopilotCliTarget(), + "copilot-vscode": CopilotVSCodeTarget(), "cursor": CursorTarget(), "gemini-cli": GeminiTarget(), "openclaw": OpenClawTarget(), @@ -69,6 +70,23 @@ def get_target(assistant: str) -> AssistantTarget: return TARGETS[assistant] +# Targets skipped when expanding to "all assistants" implicitly (sync, or a +# non-interactive install with no -a). copilot-cli and copilot-vscode write the +# same project-scope .github/ files and differ only in MCP handling, so an +# implicit install of both collides. We prefer the project-granular +# copilot-vscode; copilot-cli remains explicitly selectable via -a. +_IMPLICIT_ALL_EXCLUDE = {"copilot-cli"} + + +def default_assistants() -> list[str]: + """Assistant names to use when none is explicitly selected. + + Excludes targets that would collide with a more granular sibling when + installed implicitly (see ``_IMPLICIT_ALL_EXCLUDE``). + """ + return [name for name in TARGETS if name not in _IMPLICIT_ALL_EXCLUDE] + + __all__ = [ # ABC and base classes "AssistantTarget", @@ -78,7 +96,8 @@ def get_target(assistant: str) -> AssistantTarget: "MCPSupportMixin", # Concrete targets "ClaudeCodeTarget", - "CopilotTarget", + "CopilotCliTarget", + "CopilotVSCodeTarget", "CursorTarget", "GeminiTarget", "OpenClawTarget", @@ -87,6 +106,7 @@ def get_target(assistant: str) -> AssistantTarget: "TARGETS", "get_target", "get_registry", + "default_assistants", # Install functions "console", "copy_module_to_local", diff --git a/src/lola/targets/base.py b/src/lola/targets/base.py index bc7710a..0a95af3 100644 --- a/src/lola/targets/base.py +++ b/src/lola/targets/base.py @@ -61,9 +61,13 @@ def get_skill_path(self, project_path: str, scope: str = "project") -> Path: ... @abstractmethod - def get_command_path(self, project_path: str, scope: str = "project") -> Path: + def get_command_path( + self, project_path: str, scope: str = "project" + ) -> Path | None: """Get the command output path for this assistant. + Returns None if commands are not supported for the given scope. + Args: project_path: Project directory path (ignored for user scope) scope: "project" for project-local, "user" for user-global diff --git a/src/lola/targets/copilot.py b/src/lola/targets/copilot.py index 920540d..d860457 100644 --- a/src/lola/targets/copilot.py +++ b/src/lola/targets/copilot.py @@ -1,8 +1,10 @@ -"""GitHub Copilot target implementation.""" +"""GitHub Copilot target implementations (CLI and VS Code).""" from __future__ import annotations +import json from pathlib import Path +from typing import Any import lola.config as config import lola.frontmatter as fm @@ -14,18 +16,19 @@ ) -class CopilotTarget(MCPSupportMixin, ManagedInstructionsTarget, BaseAssistantTarget): - """Target for GitHub Copilot (VS Code + Visual Studio). +class CopilotCliTarget(MCPSupportMixin, ManagedInstructionsTarget, BaseAssistantTarget): + """Target for GitHub Copilot CLI. - Copilot supports: - - Skills in .copilot/skills//SKILL.md (with name+description frontmatter) + Copilot CLI 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 + - MCP servers in ~/.copilot/mcp-config.json (user) / .vscode/mcp.json (project), + using the "mcpServers" key """ - name = "copilot" + name = "copilot-cli" supports_agents = True INSTRUCTIONS_FILE = "copilot-instructions.md" @@ -211,3 +214,131 @@ def remove_agent( if legacy_file.exists(): legacy_file.unlink() return True + + +# ============================================================================= +# VS Code MCP helpers (.vscode/mcp.json uses the "servers" key) +# ============================================================================= + + +def _transform_mcp_to_vscode(server_config: dict[str, Any]) -> dict[str, Any]: + """Transform a Lola MCP server config into VS Code's mcp.json format. + + VS Code expects an explicit ``type`` field: ``stdio`` for command-based + servers and ``http``/``sse`` for remote servers. Remote configs already + carry their ``type``; command-based (stdio) configs do not, so it is added. + """ + result = dict(server_config) + if "type" not in result: + result["type"] = "http" if "url" in result else "stdio" + return result + + +def _merge_mcps_into_vscode_file( + dest_path: Path, + module_name: str, # noqa: ARG001 - kept for API symmetry, not used + mcps: dict[str, dict[str, Any]], +) -> bool: + """Merge MCP servers into a VS Code mcp.json config. + + VS Code uses the top-level ``servers`` key (not ``mcpServers``). Server + keys are written as-is (no module-name prefix). + """ + if dest_path.exists(): + try: + existing_config = json.loads(dest_path.read_text()) + except json.JSONDecodeError: + existing_config = {} + else: + existing_config = {} + + if "servers" not in existing_config: + existing_config["servers"] = {} + + for name, server_config in mcps.items(): + existing_config["servers"][name] = _transform_mcp_to_vscode(server_config) + + dest_path.parent.mkdir(parents=True, exist_ok=True) + dest_path.write_text(json.dumps(existing_config, indent=2) + "\n") + return True + + +def _remove_mcps_from_vscode_file( + dest_path: Path, + module_name: str, # noqa: ARG001 - kept for API symmetry, not used + mcp_names: list[str] | None = None, +) -> bool: + """Remove a module's MCP servers from a VS Code mcp.json config.""" + if not mcp_names: # handles None and empty list — nothing to remove + return True + + if not dest_path.exists(): + return True + + try: + existing_config = json.loads(dest_path.read_text()) + except json.JSONDecodeError: + return True + + if "servers" not in existing_config: + return True + + for name in mcp_names: + existing_config["servers"].pop(name, None) + + remaining_keys = {k for k in existing_config.keys() if k != "$schema"} + if not existing_config["servers"] and remaining_keys == {"servers"}: + dest_path.unlink() + else: + dest_path.write_text(json.dumps(existing_config, indent=2) + "\n") + return True + + +class CopilotVSCodeTarget(CopilotCliTarget): + """Target for GitHub Copilot in VS Code. + + Identical to copilot-cli except: + - MCP servers are written to .vscode/mcp.json using VS Code's ``servers`` + key (not ``mcpServers``), with an explicit per-server ``type``. + - Slash commands have no user-scope filesystem location in VS Code, so + user-scope command installs are skipped with a warning. + - MCP servers have no user-scope filesystem location in VS Code, so + user-scope MCP installs are skipped with a warning. + """ + + name = "copilot-vscode" + + def get_command_path( + self, project_path: str, scope: str = "project" + ) -> Path | None: + # VS Code has no user-scope prompts directory; signal "unsupported". + if scope == "user": + return None + return Path(project_path) / ".github" / "prompts" + + def get_mcp_path(self, project_path: str, scope: str = "project") -> Path | None: + # VS Code reads project-scoped .vscode/mcp.json; there is no working + # user-scope MCP file, so signal "unsupported" at user scope. + if scope == "user": + return None + return Path(project_path) / ".vscode" / "mcp.json" + + def generate_mcps( + self, + mcps: dict[str, dict[str, Any]], + dest_path: Path, + module_name: str, + ) -> bool: + """Merge MCP servers using VS Code's mcp.json format.""" + if not mcps: + return False + return _merge_mcps_into_vscode_file(dest_path, module_name, mcps) + + def remove_mcps( + self, + dest_path: Path, + module_name: str, + mcp_names: list[str] | None = None, + ) -> bool: + """Remove a module's MCP servers from VS Code's mcp.json.""" + return _remove_mcps_from_vscode_file(dest_path, module_name, mcp_names) diff --git a/src/lola/targets/install.py b/src/lola/targets/install.py index 39f5b62..4af1ac9 100644 --- a/src/lola/targets/install.py +++ b/src/lola/targets/install.py @@ -14,6 +14,7 @@ import os import shutil import subprocess # nosec B404 - required for running install hook scripts +from collections.abc import Callable from pathlib import Path from typing import Optional, cast @@ -35,6 +36,50 @@ console = Console() +# ============================================================================= +# Idempotency helpers +# ============================================================================= + + +def _generation_is_idempotent( + generate_fn: Callable[[Path], bool], real_dest: Path +) -> bool: + """Report whether generating now would reproduce existing files exactly. + + ``generate_fn`` is invoked with a temporary destination mirroring + ``real_dest``. Every file it produces is compared, byte-for-byte, against + the corresponding file already present under ``real_dest``. Returns True + only if the generation succeeds and every generated file already exists + with identical content (i.e. re-installing would be a no-op). + + This lets two targets that write identical output to the same path (for + example copilot-cli and copilot-vscode sharing ``.github/``) coexist + without a spurious overwrite conflict. + """ + import tempfile + + with tempfile.TemporaryDirectory() as tmp: + tmp_dest = Path(tmp) + try: + if not generate_fn(tmp_dest): + return False + except Exception: + return False + + produced_any = False + for gen_file in tmp_dest.rglob("*"): + if gen_file.is_dir(): + continue + produced_any = True + rel = gen_file.relative_to(tmp_dest) + real_file = real_dest / rel + if not real_file.exists() or real_file.is_dir(): + return False + if gen_file.read_bytes() != real_file.read_bytes(): + return False + return produced_any + + # ============================================================================= # Hook execution # ============================================================================= @@ -232,6 +277,16 @@ def _install_skills( if force: # Force mode: overwrite without prompting pass + elif _generation_is_idempotent( + lambda d: target.generate_skill( + source, d, skill_name, project_path + ), + skill_dest, + ): + # Identical content already present (e.g. another Copilot + # variant wrote it): treat as an installed no-op. + installed.append(skill_name) + continue elif click.confirm( f"Skill '{skill_name}' already exists. Overwrite?", default=False ): @@ -273,6 +328,13 @@ def _install_commands( path_context = project_path or "" command_dest = target.get_command_path(path_context, scope) + if command_dest is None: + console.print( + f" [yellow]Slash commands are not supported by {target.name} " + f"in {scope} scope; skipping.[/yellow]" + ) + return [], [] + content_dirname = _get_content_dirname(module) content_path = _get_content_path(local_module_path, content_dirname) commands_dir = content_path / "commands" @@ -282,6 +344,13 @@ def _install_commands( dest_file = command_dest / target.get_command_filename(module.name, cmd) if dest_file.exists() and not force: + if _generation_is_idempotent( + lambda d: target.generate_command(source, d, cmd, module.name), + command_dest, + ): + # Identical content already present: installed no-op. + installed.append(cmd) + continue if not is_interactive(): failed.append(cmd) continue @@ -329,6 +398,13 @@ def _install_agents( dest_file = agent_dest / target.get_agent_filename(module.name, agent) if dest_file.exists() and not force: + if _generation_is_idempotent( + lambda d: target.generate_agent(source, d, agent, module.name), + agent_dest, + ): + # Identical content already present: installed no-op. + installed.append(agent) + continue if not is_interactive(): failed.append(agent) continue @@ -412,7 +488,11 @@ def _install_mcps( path_context = project_path or "" mcp_dest = target.get_mcp_path(path_context, scope) - if not mcp_dest: + if mcp_dest is None: + console.print( + f" [yellow]MCP servers are not supported by {target.name} " + f"in {scope} scope; skipping.[/yellow]" + ) return [], [] # Load mcps.json from local module (respecting module/ subdirectory) @@ -666,6 +746,9 @@ def _uninstall_commands( scope = inst.scope command_dest = target.get_command_path(path_context, scope) + if command_dest is None: + return [], [] + for cmd in inst.commands: if target.remove_command(command_dest, cmd, inst.module_name): removed.append(cmd) diff --git a/tests/test_copilot_target.py b/tests/test_copilot_target.py index 655d38e..29c0094 100644 --- a/tests/test_copilot_target.py +++ b/tests/test_copilot_target.py @@ -1,8 +1,16 @@ -"""Tests for CopilotTarget scope-aware path resolution and file generation.""" +"""Tests for Copilot target scope-aware path resolution and file generation. + +``CopilotCliTarget`` is aliased as ``CopilotTarget`` here because copilot-cli +retains all of the original copilot behavior; copilot-vscode differences are +covered separately below. +""" from pathlib import Path -from lola.targets.copilot import CopilotTarget +from lola.targets.copilot import ( + CopilotCliTarget as CopilotTarget, + CopilotVSCodeTarget, +) # --- Project scope path tests --- @@ -228,6 +236,93 @@ def test_remove_skill_not_found(tmp_path): assert result is False +# --- copilot-vscode variant tests --- + + +def test_vscode_target_name(): + assert CopilotVSCodeTarget().name == "copilot-vscode" + + +def test_vscode_command_path_project_scope(): + """copilot-vscode writes prompts to .github/prompts at project scope.""" + target = CopilotVSCodeTarget() + path = target.get_command_path("/home/user/project", "project") + assert path == Path("/home/user/project/.github/prompts") + + +def test_vscode_command_path_user_scope_unsupported(): + """copilot-vscode has no user-scope prompts dir; returns None to skip.""" + target = CopilotVSCodeTarget() + assert target.get_command_path("/home/user/project", "user") is None + + +def test_vscode_mcp_path_project_scope(): + """copilot-vscode writes MCP config to .vscode/mcp.json at project scope.""" + target = CopilotVSCodeTarget() + path = target.get_mcp_path("/home/user/project", "project") + assert path == Path("/home/user/project/.vscode/mcp.json") + + +def test_vscode_mcp_path_user_scope_unsupported(): + """copilot-vscode has no working user-scope MCP file; returns None to skip.""" + target = CopilotVSCodeTarget() + assert target.get_mcp_path("/home/user/project", "user") is None + + +def test_vscode_mcp_uses_servers_key(tmp_path): + """copilot-vscode writes the VS Code 'servers' key with explicit type.""" + target = CopilotVSCodeTarget() + dest = tmp_path / "mcp.json" + + result = target.generate_mcps( + {"rosa-portal": {"command": "python3", "args": ["server.py"]}}, + dest, + "rosa-skills", + ) + + assert result is True + import json + + data = json.loads(dest.read_text()) + assert "mcpServers" not in data + assert data["servers"]["rosa-portal"]["type"] == "stdio" + assert data["servers"]["rosa-portal"]["command"] == "python3" + assert data["servers"]["rosa-portal"]["args"] == ["server.py"] + + +def test_vscode_mcp_preserves_remote_type(tmp_path): + """Remote (http) servers keep their declared type.""" + target = CopilotVSCodeTarget() + dest = tmp_path / "mcp.json" + + target.generate_mcps( + {"remote": {"type": "http", "url": "https://example.com/mcp"}}, + dest, + "mod", + ) + + import json + + data = json.loads(dest.read_text()) + assert data["servers"]["remote"]["type"] == "http" + assert data["servers"]["remote"]["url"] == "https://example.com/mcp" + + +def test_vscode_mcp_remove(tmp_path): + """Removing the last server deletes the mcp.json file.""" + target = CopilotVSCodeTarget() + dest = tmp_path / "mcp.json" + target.generate_mcps( + {"rosa-portal": {"command": "python3", "args": ["server.py"]}}, + dest, + "rosa-skills", + ) + + result = target.remove_mcps(dest, "rosa-skills", ["rosa-portal"]) + assert result is True + assert not dest.exists() + + def test_remove_skill_legacy_instructions(tmp_path): """Remove both skill dir and legacy .instructions.md during uninstall.""" target = CopilotTarget() @@ -334,7 +429,7 @@ def test_remove_agent(tmp_path): def test_copilot_target_name(): target = CopilotTarget() - assert target.name == "copilot" + assert target.name == "copilot-cli" def test_copilot_supports_agents(): diff --git a/tests/test_installer.py b/tests/test_installer.py index 293fcac..d6f95fa 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -265,6 +265,86 @@ def test_install_records_installation(self, tmp_path): # discovered if they actually exist. There's no manifest to list non-existent items. +class TestGenerationIsIdempotent: + """Tests for _generation_is_idempotent() and idempotent re-installs.""" + + def test_returns_true_when_identical(self, tmp_path): + from lola.targets.install import _generation_is_idempotent + + real = tmp_path / "dest" + (real / "sub").mkdir(parents=True) + (real / "sub" / "f.txt").write_text("same") + + def generate(d): + (d / "sub").mkdir(parents=True) + (d / "sub" / "f.txt").write_text("same") + return True + + assert _generation_is_idempotent(generate, real) is True + + def test_returns_false_when_content_differs(self, tmp_path): + from lola.targets.install import _generation_is_idempotent + + real = tmp_path / "dest" + real.mkdir() + (real / "f.txt").write_text("old") + + def generate(d): + (d / "f.txt").write_text("new") + return True + + assert _generation_is_idempotent(generate, real) is False + + def test_returns_false_when_file_missing(self, tmp_path): + from lola.targets.install import _generation_is_idempotent + + real = tmp_path / "dest" + real.mkdir() + + def generate(d): + (d / "f.txt").write_text("data") + return True + + assert _generation_is_idempotent(generate, real) is False + + def test_returns_false_when_generate_fails(self, tmp_path): + from lola.targets.install import _generation_is_idempotent + + real = tmp_path / "dest" + real.mkdir() + assert _generation_is_idempotent(lambda d: False, real) is False + + def test_copilot_variants_share_project_skill(self, tmp_path): + """Installing the same skill to copilot-cli then copilot-vscode at + project scope must not fail on the shared .github/ files.""" + from lola.targets.install import _install_skills + from lola.targets.copilot import CopilotCliTarget, CopilotVSCodeTarget + + module_dir = tmp_path / "modules" / "testmod" + skill_dir = module_dir / "skills" / "skill1" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text( + "---\ndescription: skill1 description\n---\n\n# skill1\n\nContent.\n" + ) + module = Module.from_path(module_dir) + + cli = CopilotCliTarget() + vscode = CopilotVSCodeTarget() + + installed_cli, failed_cli = _install_skills( + cli, module, module_dir, str(tmp_path), scope="project" + ) + assert installed_cli == ["skill1"] + assert failed_cli == [] + + # Second target writes byte-identical .github/ files: idempotent no-op. + installed_vscode, failed_vscode = _install_skills( + vscode, module, module_dir, str(tmp_path), scope="project" + ) + assert installed_vscode == ["skill1"] + assert failed_vscode == [] + + class TestRunInstallHook: """Tests for _run_install_hook().""" diff --git a/tests/test_integration.py b/tests/test_integration.py index d1850e0..3169a1e 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -434,6 +434,12 @@ def test_command_overwrite_prompt(self, integration_env, monkeypatch): def test_command_rename_prompt(self, integration_env, monkeypatch): """When rename is chosen, a new file is created under the new name.""" _install(integration_env, "claude-code") + # Make the existing command differ so the conflict prompt fires instead + # of the identical-content idempotent no-op. + existing_file = ( + integration_env["project"] / ".claude" / "commands" / "review-pr.md" + ) + existing_file.write_text("sentinel content") renamed_file = ( integration_env["project"] / ".claude" From 66a92de46bed59ef1d886967cee0ded65eb209fc Mon Sep 17 00:00:00 2001 From: Mo Khan Date: Wed, 3 Jun 2026 00:01:18 -0500 Subject: [PATCH 11/13] Fix ty type-check errors for Copilot target paths --- src/lola/cli/install.py | 23 +++++++++++++++-------- src/lola/targets/copilot.py | 8 ++++++-- tests/test_installer.py | 1 + 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/src/lola/cli/install.py b/src/lola/cli/install.py index 1ea5f0a..8ac9db7 100644 --- a/src/lola/cli/install.py +++ b/src/lola/cli/install.py @@ -296,6 +296,8 @@ def _remove_orphaned_commands(ctx: UpdateContext, verbose: bool) -> int: path_context = ctx.inst.project_path or "" scope = ctx.inst.scope command_dest = ctx.target.get_command_path(path_context, scope) + if command_dest is None: + return 0 for cmd_name in ctx.orphaned_commands: if ctx.target.remove_command(command_dest, cmd_name, ctx.inst.module_name): removed += 1 @@ -459,6 +461,8 @@ def _update_commands(ctx: UpdateContext, verbose: bool) -> tuple[int, int]: path_context = ctx.inst.project_path or "" scope = ctx.inst.scope command_dest = ctx.target.get_command_path(path_context, scope) + if command_dest is None: + return 0, 0 content_path = _get_content_path(ctx.source_module) commands_dir = content_path / "commands" @@ -1165,14 +1169,17 @@ def uninstall_cmd( if inst.commands: command_dest = target.get_command_path(path_context, inst_scope) - for cmd_name in inst.commands: - if target.remove_command(command_dest, cmd_name, module_name): - removed_count += 1 - if verbose: - filename = target.get_command_filename(module_name, cmd_name) - console.print( - f" [green]Removed {command_dest / filename}[/green]" - ) + if command_dest: + for cmd_name in inst.commands: + if target.remove_command(command_dest, cmd_name, module_name): + removed_count += 1 + if verbose: + filename = target.get_command_filename( + module_name, cmd_name + ) + console.print( + f" [green]Removed {command_dest / filename}[/green]" + ) # Remove agent files if inst.agents: diff --git a/src/lola/targets/copilot.py b/src/lola/targets/copilot.py index d860457..c0c53b5 100644 --- a/src/lola/targets/copilot.py +++ b/src/lola/targets/copilot.py @@ -37,7 +37,9 @@ def get_skill_path(self, project_path: str, scope: str = "project") -> Path: return Path.home() / ".copilot" / "skills" return Path(project_path) / ".github" / "skills" - def get_command_path(self, project_path: str, scope: str = "project") -> Path: + def get_command_path( + self, project_path: str, scope: str = "project" + ) -> Path | None: if scope == "user": return Path.home() / ".copilot" / "prompts" return Path(project_path) / ".github" / "prompts" @@ -52,7 +54,9 @@ def get_instructions_path(self, project_path: str, scope: str = "project") -> Pa 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: + def get_mcp_path( + self, project_path: str, scope: str = "project" + ) -> Path | None: if scope == "user": return Path.home() / ".copilot" / "mcp-config.json" return Path(project_path) / ".vscode" / "mcp.json" diff --git a/tests/test_installer.py b/tests/test_installer.py index d6f95fa..5992be8 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -327,6 +327,7 @@ def test_copilot_variants_share_project_skill(self, tmp_path): "---\ndescription: skill1 description\n---\n\n# skill1\n\nContent.\n" ) module = Module.from_path(module_dir) + assert module is not None cli = CopilotCliTarget() vscode = CopilotVSCodeTarget() From d3f389f1b3ebfc46860958bb270ed2984a7fbb75 Mon Sep 17 00:00:00 2001 From: Mo Khan Date: Wed, 3 Jun 2026 00:05:07 -0500 Subject: [PATCH 12/13] Apply ruff formatting to copilot.py --- src/lola/targets/copilot.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/lola/targets/copilot.py b/src/lola/targets/copilot.py index c0c53b5..925fc27 100644 --- a/src/lola/targets/copilot.py +++ b/src/lola/targets/copilot.py @@ -54,9 +54,7 @@ def get_instructions_path(self, project_path: str, scope: str = "project") -> Pa 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 | None: + def get_mcp_path(self, project_path: str, scope: str = "project") -> Path | None: if scope == "user": return Path.home() / ".copilot" / "mcp-config.json" return Path(project_path) / ".vscode" / "mcp.json" From 898a7a6e893ed6a08283951c2b6a89efce7ebe18 Mon Sep 17 00:00:00 2001 From: Mo Khan Date: Wed, 3 Jun 2026 14:28:06 -0500 Subject: [PATCH 13/13] Point user-scope skip warnings at lola repo for issues/PRs --- src/lola/targets/install.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/lola/targets/install.py b/src/lola/targets/install.py index 4af1ac9..de65adc 100644 --- a/src/lola/targets/install.py +++ b/src/lola/targets/install.py @@ -331,7 +331,9 @@ def _install_commands( if command_dest is None: console.print( f" [yellow]Slash commands are not supported by {target.name} " - f"in {scope} scope; skipping.[/yellow]" + f"in {scope}-scoped installs; skipping. Please submit an issue or " + f"PR if this has changed: " + f"https://github.com/LobsterTrap/lola[/yellow]" ) return [], [] @@ -491,7 +493,9 @@ def _install_mcps( if mcp_dest is None: console.print( f" [yellow]MCP servers are not supported by {target.name} " - f"in {scope} scope; skipping.[/yellow]" + f"in {scope}-scoped installs; skipping. Please submit an issue or " + f"PR if this has changed: " + f"https://github.com/LobsterTrap/lola[/yellow]" ) return [], []