diff --git a/AGENTS.md b/AGENTS.md index 42aa4a8..07f8101 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -139,13 +139,25 @@ 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-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` - Cursor: `name` (agent name) and `model: inherit` are added - OpenCode: `mode: subagent` is added 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..8ac9db7 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, @@ -295,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 @@ -458,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" @@ -907,8 +912,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 = ( @@ -1163,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/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 b9be7bf..cc0d6ef 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 CopilotCliTarget, CopilotVSCodeTarget 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,8 @@ TARGETS: dict[str, AssistantTarget] = { "claude-code": ClaudeCodeTarget(), + "copilot-cli": CopilotCliTarget(), + "copilot-vscode": CopilotVSCodeTarget(), "cursor": CursorTarget(), "gemini-cli": GeminiTarget(), "openclaw": OpenClawTarget(), @@ -67,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", @@ -76,6 +96,8 @@ def get_target(assistant: str) -> AssistantTarget: "MCPSupportMixin", # Concrete targets "ClaudeCodeTarget", + "CopilotCliTarget", + "CopilotVSCodeTarget", "CursorTarget", "GeminiTarget", "OpenClawTarget", @@ -84,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 new file mode 100644 index 0000000..925fc27 --- /dev/null +++ b/src/lola/targets/copilot.py @@ -0,0 +1,346 @@ +"""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 +from .base import ( + BaseAssistantTarget, + ManagedInstructionsTarget, + MCPSupportMixin, + _generate_passthrough_command, +) + + +class CopilotCliTarget(MCPSupportMixin, ManagedInstructionsTarget, BaseAssistantTarget): + """Target for GitHub Copilot CLI. + + 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 ~/.copilot/mcp-config.json (user) / .vscode/mcp.json (project), + using the "mcpServers" key + """ + + name = "copilot-cli" + 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 | None: + 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 | None: + if scope == "user": + return Path.home() / ".copilot" / "mcp-config.json" + return Path(project_path) / ".vscode" / "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) + + # 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: + """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 + + +# ============================================================================= +# 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..de65adc 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,15 @@ 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}-scoped installs; skipping. Please submit an issue or " + f"PR if this has changed: " + f"https://github.com/LobsterTrap/lola[/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 +346,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 +400,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 +490,13 @@ 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}-scoped installs; skipping. Please submit an issue or " + f"PR if this has changed: " + f"https://github.com/LobsterTrap/lola[/yellow]" + ) return [], [] # Load mcps.json from local module (respecting module/ subdirectory) @@ -666,6 +750,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 new file mode 100644 index 0000000..29c0094 --- /dev/null +++ b/tests/test_copilot_target.py @@ -0,0 +1,437 @@ +"""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 ( + CopilotCliTarget as CopilotTarget, + CopilotVSCodeTarget, +) + + +# --- 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/.vscode/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-config.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/.vscode/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 + + +# --- 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() + # 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-cli" + + +def test_copilot_supports_agents(): + target = CopilotTarget() + assert target.supports_agents is True diff --git a/tests/test_installer.py b/tests/test_installer.py index 293fcac..5992be8 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -265,6 +265,87 @@ 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) + assert module is not None + + 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"