diff --git a/.gitignore b/.gitignore index b7e881a..b478c50 100644 --- a/.gitignore +++ b/.gitignore @@ -42,8 +42,20 @@ node_modules .opencode/ opencode.json +# OpenCode session continuation artifacts +.omo/ + +# Copilot +.copilot/ + # Gemini CLI (legacy paths kept for compatibility) commands/**/*.toml /commands/ /agents/ /skills/ + +# Local agent caches +.agent/ +.agents/ +.antigravity/ + diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0326d8a..dab7b4c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -4,7 +4,7 @@ Top-level architectural map for the claude-agents marketplace. Detail lives in [ ## Invariants -1. **Single source of truth.** All agent / skill / command authoring happens under `plugins//`. Generated harness-specific artifacts (`.codex/`, `.cursor-plugin/`, `.opencode/`, `commands/`, `agents/`, `skills/` at extension root for Gemini) are produced by adapters and gitignored. Never hand-edit generated files. +1. **Single source of truth.** All agent / skill / command authoring happens under `plugins//`. Generated harness-specific artifacts (`.codex/`, `.cursor-plugin/`, `.opencode/`, `.copilot/`, `commands/`, `agents/`, `skills/` at extension root for Gemini) are produced by adapters and gitignored. Never hand-edit generated files. 2. **One canonical context file.** `AGENTS.md` at repo root is the only context file authored directly. `CLAUDE.md` imports it via `@AGENTS.md`. Gemini CLI reads it via `.gemini/settings.json` `context.fileName`. Codex / Cursor / OpenCode read `AGENTS.md` natively. @@ -36,7 +36,7 @@ claude-agents/ │ ├── adapters/ # Per-harness adapter framework │ │ ├── base.py # Parser, HarnessAdapter ABC, helpers │ │ ├── capabilities.py # Capability matrix; consumed by every adapter -│ │ ├── codex.py / cursor.py / opencode.py / gemini.py +│ │ ├── codex.py / cursor.py / opencode.py / gemini.py / copilot.py │ │ └── cursor_rules/ # Hand-curated .mdc rules │ ├── generate.py # Unified CLI: `make generate HARNESS=` │ ├── validate_generated.py # Structural validation @@ -61,6 +61,7 @@ Each adapter consumes the canonical `plugins/` source and emits harness-native a | `codex.py` | `.codex/skills/`, `.codex/agents/*.toml` | Markdown → TOML transform, 8 KB body cap with `references/` overflow, sandbox_mode heuristic, collision detection | | `cursor.py` | `.cursor-plugin/`, `.cursor/rules/*.mdc` | Marketplace manifests + hand-curated rules. Cursor reads `.claude/` directly for skills/agents | | `opencode.py` | `.opencode/agents/`, `.opencode/commands/`, `.opencode/skills/` | Permission block from `tools:` allowlist (locked agents preserve intent); strict lowercase tool names; OpenCode-safe skill names | +| `copilot.py` | `.copilot/agents/`, `.copilot/skills/`, `.copilot/commands/` | Markdown agent profiles + SKILL.md skills + commands-as-skills; model maps to GPT-5 family | | `gemini.py` | `skills/`, `agents/`, `commands/*.toml` at extension root | Native skills + April-2026 subagents; `@{path}` injection for large command bodies | Detail in [`docs/harnesses.md`](docs/harnesses.md) (capability matrix per harness) and [`docs/architecture.md`](docs/architecture.md) (full design rationale). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e233f5c..7511ed0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,7 +1,7 @@ # Contributing to claude-agents -Thanks for your interest in contributing. This marketplace ships to five agentic -harnesses (Claude Code, OpenAI Codex CLI, Cursor, OpenCode, Gemini CLI) from a single +Thanks for your interest in contributing. This marketplace ships to six agentic +harnesses (Claude Code, OpenAI Codex CLI, Cursor, OpenCode, Gemini CLI, GitHub Copilot) from a single Markdown source. ## Start here @@ -52,6 +52,8 @@ Your content ships to five harnesses — some have stricter conventions than Cla - **OpenCode** requires lowercase tool names. Don't write `` `Read` `` inline — write *"open the file"* or use the lowercase form. - **Cursor** doesn't honor per-agent `tools:` allowlists — use it as a hint only. +- **Copilot** maps Claude model aliases (`opus`/`sonnet`/`haiku`) to the GPT-5 family; + agent `description` must be a plain string. - All harnesses use ≤150-line context files. Don't bloat `AGENTS.md` / `CLAUDE.md`. `plugin-eval`'s `harness_portability` dimension catches most of these mechanically; diff --git a/Makefile b/Makefile index ab0e6ff..680e3b0 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ YTX_SCRIPT := yt-design-extractor.py # `uv run` against the plugin-eval venv — has pyyaml + extra-paths to tools/adapters/ UV_TOOLS := uv run $(EVAL_PROJECT) python -.PHONY: help install install-ocr install-easyocr deps check run run-full run-ocr run-transcript clean generate generate-all clean-generated install-opencode uninstall-opencode validate garden test smoke-test generate-plugin sync-commands generate-all-commands clean-commands +.PHONY: help install install-ocr install-easyocr deps check run run-full run-ocr run-transcript clean generate generate-all clean-generated install-opencode uninstall-opencode install-copilot uninstall-copilot validate garden test smoke-test generate-plugin sync-commands generate-all-commands clean-commands help: @echo "claude-agents — multi-harness plugin marketplace" @@ -28,6 +28,8 @@ help: @echo " make clean-generated [HARNESS=] Remove generated artifacts" @echo " make install-opencode [FORCE=1] Symlink OpenCode artifacts into global config" @echo " make uninstall-opencode Remove repo-owned OpenCode symlinks" + @echo " make install-copilot [FORCE=1] Symlink Copilot artifacts into global config" + @echo " make uninstall-copilot Remove repo-owned Copilot symlinks" @echo " make validate [HARNESS=] [STRICT=1] Structural validation of generated artifacts" @echo " make garden [STRICT=1] Run doc-gardener (drift detection)" @echo " make test Full pytest suite (plugin-eval + tools)" @@ -159,7 +161,7 @@ clean: # make generate-all # make clean-generated HARNESS=opencode -HARNESSES := codex cursor opencode gemini +HARNESSES := codex copilot cursor gemini opencode generate: ifndef HARNESS @@ -222,6 +224,13 @@ install-opencode: uninstall-opencode: $(UV_TOOLS) tools/install_opencode.py uninstall +install-copilot: + $(UV_TOOLS) $(GENERATE) --harness copilot --all --prune + $(UV_TOOLS) tools/install_copilot.py install $(if $(filter 1 true TRUE yes YES,$(FORCE)),--force) + +uninstall-copilot: + $(UV_TOOLS) tools/install_copilot.py uninstall + # Legacy Gemini wrappers (delegate to the unified CLI) generate-plugin: ifndef PLUGIN diff --git a/README.md b/README.md index 035f9dc..909f959 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ > Production-ready agentic workflow building blocks: **84 plugins**, **192 agents**, > **156 skills**, **102 commands** — built for Claude Code and consumed natively by -> OpenAI Codex CLI, Cursor, OpenCode, and Gemini CLI from a single Markdown source. +> OpenAI Codex CLI, Cursor, OpenCode, Gemini CLI, and GitHub Copilot from a single Markdown source. -[![Claude Code](https://img.shields.io/badge/Claude%20Code-native-blueviolet)](#claude-code) [![Codex CLI](https://img.shields.io/badge/Codex%20CLI-supported-black)](docs/harnesses.md) [![Cursor](https://img.shields.io/badge/Cursor-supported-purple)](docs/harnesses.md) [![OpenCode](https://img.shields.io/badge/OpenCode-supported-green)](docs/harnesses.md) [![Gemini CLI](https://img.shields.io/badge/Gemini%20CLI-supported-blue)](GEMINI.md) +[![Claude Code](https://img.shields.io/badge/Claude%20Code-native-blueviolet)](#claude-code) [![Codex CLI](https://img.shields.io/badge/Codex%20CLI-supported-black)](docs/harnesses.md) [![Cursor](https://img.shields.io/badge/Cursor-supported-purple)](docs/harnesses.md) [![OpenCode](https://img.shields.io/badge/OpenCode-supported-green)](docs/harnesses.md) [![Gemini CLI](https://img.shields.io/badge/Gemini%20CLI-supported-blue)](GEMINI.md) [![Copilot](https://img.shields.io/badge/Copilot-supported-lightgrey)](docs/harnesses.md) > [!NOTE] > One source-of-truth (`plugins/`), five harnesses. Each harness gets idiomatic, @@ -24,12 +24,12 @@ Pick your harness: [→ Full Claude Code setup, troubleshooting, and plugin catalog](docs/usage.md) -### Codex CLI · Cursor · OpenCode · Gemini CLI +### Codex CLI · Cursor · OpenCode · Gemini CLI · Copilot ```bash gh repo clone wshobson/agents ~/agents cd ~/agents -make generate HARNESS= +make generate HARNESS= ``` Setup details and per-harness gotchas: [docs/harnesses.md](docs/harnesses.md). Gemini-specific setup: [GEMINI.md](GEMINI.md) (also auto-loaded by Gemini CLI). @@ -79,13 +79,14 @@ emits harness-native artifacts (not lowest-common-denominator translations): | Harness | Generates | Notes | |---|---|---| | **Claude Code** | (source-of-truth) | Native `marketplace.json` + `plugins/` | -| **Codex CLI** | `.codex/skills/`, `.codex/agents/`, `AGENTS.md` | 8 KB skill cap respected; commands → skills | +| **Codex CLI** | `.codex/skills/`, `.codex/agents/` | 8 KB skill cap respected; commands → skills | | **Cursor** | `.cursor-plugin/`, `.cursor/rules/` | Thin marketplace + curated rules; reuses `.claude/` | | **OpenCode** | `.opencode/agents/`, `.opencode/commands/`, `.opencode/skills/` | `permission:` block from `tools:` allowlist; OpenCode-safe skill names | | **Gemini CLI** | `skills/`, `agents/`, `commands/` (TOML) | Native skills + subagents (April 2026 spec) | +| **Copilot** | `.copilot/agents/`, `.copilot/skills/`, `.copilot/commands/` | Markdown agent profiles + SKILL.md skills + commands-as-skills; model maps to GPT-5 family | ```bash -make generate-all # all four +make generate-all # all five make validate # structural checks make garden # drift / dead-link / cap detection ``` @@ -139,6 +140,7 @@ integrations for this marketplace's other supported harnesses. | Cursor | [integrations/cursor](https://github.com/major7apps/pensyve/tree/main/integrations/cursor) | | OpenCode | [integrations/opencode-plugin](https://github.com/major7apps/pensyve/tree/main/integrations/opencode-plugin) | | Gemini CLI | `gemini extensions install https://github.com/major7apps/pensyve` | +| Copilot | `.copilot/` in repo root or `~/.copilot/` via `make install-copilot` | ## License diff --git a/docs/authoring.md b/docs/authoring.md index fa6b0b6..794af77 100644 --- a/docs/authoring.md +++ b/docs/authoring.md @@ -1,7 +1,6 @@ # Authoring portable plugin content -Plugin content in this repo ships to **five** harnesses: Claude Code (source of truth), -OpenAI Codex CLI, Cursor, OpenCode, and Gemini CLI. The adapter framework handles per-harness +Plugin content in this repo ships to **five** harnesses: OpenAI Codex CLI, Cursor, OpenCode, Gemini CLI, and GitHub Copilot. Claude Code is the source-of-truth. The adapter framework handles per-harness mechanics (frontmatter rewrites, format transforms, output paths) so you author one set of markdown files. But content choices still affect portability — this guide tells you what to do, and what to avoid, so the work you do for Claude Code translates cleanly everywhere. @@ -26,7 +25,7 @@ do, and what to avoid, so the work you do for Claude Code translates cleanly eve |---|---|---|---| | `agents/.md` | `name`, `description` | `model`, optional `tools:`, optional `color:` | `tools:` allowlist becomes a per-harness permission block where supported, dropped otherwise. | | `skills//SKILL.md` | `name`, `description` | (none) | Other Anthropic SKILL.md fields work on Claude Code only. | -| `commands/.md` | `description` | `argument-hint:` | Codex converts these to skills (it deprecated `~/.codex/prompts/`). | +| `commands/.md` | `description` | `argument-hint:` | Codex converts these to skills (it deprecated `~/.codex/prompts/`). Copilot emits `.copilot/commands//.md` slash-command prompts. | **Description triggers.** Include a recognized phrase: `Use when …`, `Use this skill when …`, `Use PROACTIVELY when …`, `Use after …`, `Trigger when …`, `Auto-loads when …`. The @@ -106,12 +105,12 @@ clean naming — pick distinct names for skill/command pairs within a plugin. ### Model aliases -| Source field | Codex | Cursor | OpenCode | Gemini | -|---|---|---|---|---| -| `model: opus` | `gpt-5` | `inherit` | `anthropic/claude-opus-4-7` | `gemini-2.5-pro` | -| `model: sonnet` | `gpt-5-mini` | `inherit` | `anthropic/claude-sonnet-4-6` | `gemini-2.5-pro` | -| `model: haiku` | `gpt-5-nano` | `inherit` | `anthropic/claude-haiku-4-5-20251001` | `gemini-2.5-flash` | -| `model: inherit` | `gpt-5` | `inherit` | `anthropic/claude-sonnet-4-6` | `gemini-2.5-pro` | +| Source field | Codex | Cursor | OpenCode | Gemini | Copilot | +|---|---|---|---|---|---|---| +| `model: opus` | `gpt-5` | `inherit` | `anthropic/claude-opus-4-7` | `gemini-2.5-pro` | `gpt-5` | +| `model: sonnet` | `gpt-5-mini` | `inherit` | `anthropic/claude-sonnet-4-6` | `gemini-2.5-pro` | `gpt-5-mini` | +| `model: haiku` | `gpt-5-nano` | `inherit` | `anthropic/claude-haiku-4-5-20251001` | `gemini-2.5-flash` | `gpt-5-nano` | +| `model: inherit` | `gpt-5` | `inherit` | `anthropic/claude-sonnet-4-6` | `gemini-2.5-pro` | `gpt-5` | The adapter handles mapping. The `BARE_MODEL_ALIAS` lint is informational — it just notes that the mapping is implicit. If you want explicit, use `inherit`. @@ -141,7 +140,7 @@ Things that work in Claude Code but degrade across harnesses: | Hooks (`hooks:` frontmatter) | Only Claude Code and OpenCode (via TS plugins). | | `color:` on agents | Cosmetic; dropped everywhere except Claude Code. | | Per-agent tool allowlist | Honored only on Claude Code/Gemini/OpenCode. Cursor and Codex have coarser models. | -| Slash commands | Codex converts to skills. Gemini transpiles to TOML. | +| Slash commands | Codex converts to skills. Gemini transpiles to TOML. Copilot emits `.copilot/commands/` prompt files. | | Marketplace registry | Only Claude Code and Cursor have one. Gemini installs by URL; Codex/OpenCode have no marketplace. | When you must use a feature with no equivalent, the `harness_portability` lint won't fire diff --git a/docs/harnesses.md b/docs/harnesses.md index 5323058..ac01490 100644 --- a/docs/harnesses.md +++ b/docs/harnesses.md @@ -90,6 +90,24 @@ For generated harnesses, use Pensyve's upstream harness-native integration: | Cursor | `integrations/cursor` | | OpenCode | `integrations/opencode-plugin` | | Gemini CLI | `gemini extensions install https://github.com/major7apps/pensyve` | +| Copilot | `.copilot/` (repo-level) or `~/.copilot/` (global install via `make install-copilot`) | + +## Global install + +OpenCode and Copilot support installing generated artifacts globally for user-level discovery: + +```bash +make install-opencode # symlink .opencode/ → ~/.config/opencode/ +make uninstall-opencode + +make install-copilot # symlink .copilot/ → ~/.copilot/ +make uninstall-copilot + +# Force-replace conflicting symlinks: +make install-copilot FORCE=1 +``` + +> Copilot discovers agents from `.copilot/agents/` and skills from `.copilot/skills/` at the repo level, and from `~/.copilot/agents/` and `~/.copilot/skills/` at the user level. The adapter emits to `.copilot/`; use `make install-copilot` for user-level discovery. ## See also diff --git a/docs/round-trip-results.md b/docs/round-trip-results.md index 8ffd364..208d98c 100644 --- a/docs/round-trip-results.md +++ b/docs/round-trip-results.md @@ -13,6 +13,7 @@ load the generated artifacts and report what it found. | **Gemini CLI** | 0.42.0 | ✅ pass | `gemini extensions validate .` returns "successfully validated" | Native skills + subagents at extension root recognized. | | **Codex CLI** | 0.133.0 | ✅ pass (structural) | All 191 agent TOMLs parse via Python `tomllib`; AGENTS.md within budget (43 lines / 500 tokens) | Codex doctor surfaces no errors; deeper "did the model actually load the skill" requires interactive verification. | | **Cursor** | (editor-only) | n/a | n/a | No CLI; manual verification recipe below. | +| **Copilot** | (structural) | ✅ pass | 191 agent profiles, 155 skills, 25 commands all validated | No CLI round-trip tool yet; structural validation via `make validate` passes. | ## Issues surfaced and fixed during round-trip @@ -99,13 +100,35 @@ make generate HARNESS=cursor # 6. Skills under .claude/skills/ should auto-trigger from descriptions ``` +### Copilot (no CLI round-trip yet) + +```bash +# Generate +make generate HARNESS=copilot + +# Structural validation (parses every generated artifact) +make validate + +# Verify artifact tree +ls .copilot/agents/ # 191 agent profiles (*.agent.md) +ls .copilot/skills/ # 155 skill dirs (each with SKILL.md) +ls .copilot/commands/ # command-prompt files + +# Global install (optional) +make install-copilot # symlinks .copilot/ -> ~/.copilot/ +``` + +Copilot currently lacks a CLI verification tool. Manual testing: open VS +Code, open the Copilot Chat (Ctrl+Shift+I), and verify agents appear in the +agent selector and skills auto-trigger from matching prompts. + ## Automated structural checks (no CLI needed) The `tools/validate_generated.py` script approximates round-trip without installing the harnesses: ```bash -make validate # all four harnesses +make validate # all five harnesses make validate HARNESS=codex # one only ``` @@ -133,6 +156,8 @@ the artifacts at runtime. Specifically untested by the automated suite: editor; can't be scripted). - Whether Gemini's `@` invocation runs our generated subagent against a real prompt. +- Whether Copilot's agent profile and skill discovery actually loads our artifacts + end-to-end (no CLI; requires VS Code editor). These require interactive use and API-token-burning runs. The recipes above show how to perform them manually. diff --git a/tools/adapters/capabilities.py b/tools/adapters/capabilities.py index fd5d027..f4f77d7 100644 --- a/tools/adapters/capabilities.py +++ b/tools/adapters/capabilities.py @@ -89,6 +89,26 @@ CAPABILITIES: dict[str, Capability] = { bare_model_aliases=False, notes="Same SKILL.md format as Claude. Agents use TOML at .codex/agents/. AGENTS.md walked root->cwd with 32 KiB cap. Commands map to skills.", ), + "copilot": Capability( + harness_id="copilot", + display_name="GitHub Copilot", + skills_native=True, + agents_native=True, + commands_native=False, # commands map to SKILL.md skills with user-invocable: true + plugin_marketplace=False, + parallel_agents=False, + tool_allowlist_per_agent=True, + todowrite=False, + task_spawn=True, + mcp_servers=True, + hooks=False, + context_file_name="AGENTS.md", + context_file_max_lines=_CONTEXT_LINES_CAP, + skill_body_max_bytes=_NO_CAP, + tool_name_case="lowercase", + bare_model_aliases=False, + notes="Emits agent profiles to .copilot/agents/, SKILL.md skills to .copilot/skills/. Plugin commands are emitted as runnable skills (user-invocable: true, disable-model-invocation: true) for VS Code /-menu and CLI auto-discovery.", + ), "cursor": Capability( harness_id="cursor", display_name="Cursor", @@ -169,6 +189,19 @@ TOOL_NAME_MAPS: dict[str, dict[str, str]] = { "Agent": "delegate to a subagent", "Task": "delegate to a subagent", }, + "copilot": { + "Read": "read", + "Edit": "edit", + "Write": "edit", + "Bash": "execute", + "Grep": "search", + "Glob": "search", + "WebFetch": "web", + "WebSearch": "web", + "TodoWrite": "todo", + "Agent": "agent", + "Task": "agent", + }, "cursor": { "Read": "read", "Edit": "edit", @@ -225,6 +258,12 @@ MODEL_ALIASES: dict[str, dict[str, str]] = { "haiku": "gpt-5-nano", "inherit": "gpt-5", }, + "copilot": { + "opus": "gpt-5", + "sonnet": "gpt-5-mini", + "haiku": "gpt-5-nano", + "inherit": "gpt-5", + }, "cursor": { "opus": "inherit", "sonnet": "inherit", diff --git a/tools/adapters/copilot.py b/tools/adapters/copilot.py new file mode 100644 index 0000000..b4e4bf7 --- /dev/null +++ b/tools/adapters/copilot.py @@ -0,0 +1,239 @@ +"""Copilot adapter for GitHub Copilot CLI and Cloud Agent.""" + +from __future__ import annotations + +import re +from pathlib import Path + +from tools.adapters.base import ( + AgentSource, + CommandSource, + EmitResult, + HarnessAdapter, + PluginSource, + SkillSource, + h1_from_body, +) +from tools.adapters.capabilities import TOOL_NAME_MAPS, resolve_model + + +def _needs_yaml_quoting(value: str) -> bool: + """Check if a string value needs YAML quoting to prevent type coercion.""" + return bool(re.match(r"^\d+(\.\d+)?$", value)) or value.lower() in ( + "true", + "false", + "yes", + "no", + "on", + "off", + "null", + "~", + ) + + +def _copilot_frontmatter(fm: dict) -> str: + """Format YAML frontmatter for Copilot agent files.""" + lines = ["---"] + for k, v in fm.items(): + if isinstance(v, list): + lines.append(f"{k}:") + for item in v: + lines.append(f" - {item}") + elif isinstance(v, bool): + lines.append(f"{k}: {'true' if v else 'false'}") + elif v is not None: + value = str(v).replace("\n", " ").strip() + if _needs_yaml_quoting(value): + value = f'"{value}"' + lines.append(f"{k}: {value}") + lines.append("---") + return "\n".join(lines) + + +def _rewrite_body_lowercase_tools(body: str) -> str: + """Lowercase Claude Code CamelCase tool names in backticked references.""" + out = body + for camel, replacement in TOOL_NAME_MAPS["copilot"].items(): + out = out.replace(f"`{camel}`", f"`{replacement}`") + return out + + +def _build_tools_list(agent_tools: list[str]) -> list[str]: + """Map source Claude Code tool names to Copilot tool names.""" + copilot_map = TOOL_NAME_MAPS["copilot"] + return [copilot_map.get(t, t) for t in agent_tools] + + +class CopilotAdapter(HarnessAdapter): + """Emit Copilot agent profiles and skills (commands mapped to runnable skills). + + Agents go to ``.copilot/agents/__.agent.md``, skills to + ``.copilot/skills/__/SKILL.md``. Plugin commands are emitted as + runnable skills at ``.copilot/skills/-/SKILL.md`` with + ``user-invocable: true`` and ``disable-model-invocation: true`` so they appear + in the VS Code ``/`` menu but are not auto-loaded by the agent. + Tool names are rewritten from Claude Code CamelCase to Copilot lowercase. + Model aliases are mapped to the GPT-5 family (same as Codex CLI). + + Run ``make install-copilot`` to symlink artifacts to ``~/.copilot/`` + for user-level discovery. + """ + + harness_id = "copilot" + + def __init__(self, output_root: Path | None = None, repo_root: Path | None = None) -> None: + """Set output root (defaults to WORKTREE) and optional repo root.""" + super().__init__(output_root=output_root) + if repo_root is not None: + self.repo_root = repo_root + + def emit_plugin(self, plugin: PluginSource) -> EmitResult: + """Emit agent profiles, skills, and command-as-skill files for one plugin.""" + result = EmitResult() + for agent in plugin.agents: + self._emit_agent(plugin, agent, result) + for skill in plugin.skills: + self._emit_skill(plugin, skill, result) + for command in plugin.commands: + self._emit_command_as_skill(plugin, command, result) + # Legacy: also emit as .copilot/commands/ for backward compat + self._emit_command_index(plugin, result) + for command in plugin.commands: + self._emit_command(plugin, command, result) + return result + + def emit_global(self, plugins: list[PluginSource]) -> EmitResult: + """No cross-plugin artifacts needed for Copilot.""" + return EmitResult() + + def _emit_agent(self, plugin: PluginSource, agent: AgentSource, result: EmitResult) -> None: + """Emit one .agent.md profile into the agents/ directory. + + Builds frontmatter (name, description, model, tools), rewrites tool + names, and resolves model aliases before writing. + """ + agent_id = f"{plugin.name}__{agent.name}" + rel = Path(".copilot") / "agents" / f"{agent_id}.agent.md" + + model, warning = resolve_model("copilot", agent.model) + if warning: + result.warnings.append(f"agent `{agent_id}`: {warning}") + + fm: dict = { + "name": agent_id, + "description": agent.description or f"{agent.name} (from {plugin.name})", + } + + if "tools" in agent.frontmatter: + fm["tools"] = _build_tools_list(agent.tools) if agent.tools else [] + + if model: + fm["model"] = model + + body = _rewrite_body_lowercase_tools(agent.body).rstrip() + "\n" + content = _copilot_frontmatter(fm) + "\n\n" + body + result.written.append(self.write(rel, content)) + + def _emit_skill(self, plugin: PluginSource, skill: SkillSource, result: EmitResult) -> None: + """Emit one SKILL.md into the skills/ directory. + + Preserves the source skill's frontmatter (name, description, trigger + phrases) and body verbatim, wrapped in YAML frontmatter. + """ + skill_id = f"{plugin.name}__{skill.name}" + skill_dir = Path(".copilot") / "skills" / skill_id + + content = ( + _copilot_frontmatter(skill.frontmatter) + + "\n\n" + + _rewrite_body_lowercase_tools(skill.body).rstrip() + + "\n" + ) + result.written.append(self.write(skill_dir / "SKILL.md", content)) + + def _emit_command_as_skill( + self, plugin: PluginSource, command: CommandSource, result: EmitResult + ) -> None: + """Emit one command as a Copilot skill (VS Code ``/``-invocable). + + Uses a hyphenated name (``-``) per the VS Code Agent Skills + naming spec (lowercase letters, numbers, hyphens only). Sets + ``user-invocable: true`` so the skill appears in the VS Code ``/`` menu + alongside built-in slash commands, and ``disable-model-invocation: true`` + so it is NOT auto-loaded by the agent — only runs when explicitly requested. + """ + skill_name = f"{plugin.name}-{command.name}" + skill_dir = Path(".copilot") / "skills" / skill_name + + body = self.strip_claude_tool_refs(command.body, tool_case="lower") + + fm: dict = {"name": skill_name} + if command.description: + fm["description"] = command.description + else: + title = h1_from_body(command.body) or command.name.replace("-", " ").title() + fm["description"] = title + if command.argument_hint: + fm["argument-hint"] = command.argument_hint + fm["user-invocable"] = True + fm["disable-model-invocation"] = True + + content = _copilot_frontmatter(fm) + "\n\n" + body.rstrip() + "\n" + result.written.append(self.write(skill_dir / "SKILL.md", content)) + + def _emit_command_index(self, plugin: PluginSource, result: EmitResult) -> None: + """Emit a plugin entrypoint command that points at the plugin's subcommands.""" + command_names = ( + ", ".join(f"`/{plugin.name}:{cmd.name}`" for cmd in plugin.commands) or "none" + ) + agent_names = ", ".join(f"`{plugin.name}__{agent.name}`" for agent in plugin.agents) + skill_names = ", ".join(f"`{plugin.name}__{skill.name}`" for skill in plugin.skills) + + parts = [ + (plugin.description or f"{plugin.name.replace('-', ' ').title()} plugin").rstrip(".") + + ".", + "", + f"This is the entry point for the `{plugin.name}` plugin.", + ] + if plugin.agents: + parts.extend(["", f"Agents: {agent_names}."]) + if plugin.skills: + parts.extend(["", f"Skills: {skill_names}."]) + if plugin.commands: + parts.extend(["", f"Commands: {command_names}."]) + parts.extend(["", "{{args}}"]) + + fm: dict = {"description": plugin.description or f"{plugin.name} plugin"} + # Emit as /index.md inside .copilot/commands/ + result.written.append( + self.write( + Path(".copilot") / "commands" / plugin.name / "index.md", + _copilot_frontmatter(fm) + "\n\n" + "\n".join(parts) + "\n", + ) + ) + + def _emit_command( + self, plugin: PluginSource, command: CommandSource, result: EmitResult + ) -> None: + """Emit one slash-command prompt file for the plugin. + + Emit as /.md inside .copilot/commands/. Copilot CLI + discovers per-plugin command directories from the repo root or from + ~/.copilot/ after ``make install-copilot``. + """ + body = self.strip_claude_tool_refs(command.body, tool_case="lower") + + # Start from source frontmatter but ensure a non-empty description + fm = dict(command.frontmatter or {}) + if not fm.get("description"): + title = h1_from_body(command.body) or command.name.replace("-", " ").title() + fm["description"] = title + + content = _copilot_frontmatter(fm) + "\n\n" + body.rstrip() + "\n" + # Emit as /.md inside .copilot/commands/ + result.written.append( + self.write( + Path(".copilot") / "commands" / plugin.name / f"{command.name}.md", + content, + ) + ) diff --git a/tools/doc_gardener.py b/tools/doc_gardener.py index 15223d9..fc02210 100644 --- a/tools/doc_gardener.py +++ b/tools/doc_gardener.py @@ -215,6 +215,30 @@ def check_stale_artifacts(report: Report) -> None: if src.is_file(): pairs.append((src, toml_path)) + # Copilot agents (.agent.md) and skills (SKILL.md) at .copilot/ + copilot_root = WORKTREE / ".copilot" + copilot_agents = copilot_root / "agents" + if copilot_agents.is_dir(): + for agent_md in copilot_agents.glob("*.agent.md"): + name = agent_md.stem + if name.endswith(".agent"): + name = name[: -len(".agent")] + if "__" in name: + plugin, agent = name.split("__", 1) + src = PLUGINS_DIR / plugin / "agents" / f"{agent}.md" + if src.is_file(): + pairs.append((src, agent_md)) + + copilot_skills = copilot_root / "skills" + if copilot_skills.is_dir(): + for skill_md in copilot_skills.glob("*/SKILL.md"): + name = skill_md.parent.name + if "__" in name: + plugin, leaf = name.split("__", 1) + src = PLUGINS_DIR / plugin / "skills" / leaf / "SKILL.md" + if src.is_file(): + pairs.append((src, skill_md)) + for src, gen in pairs: if src.stat().st_mtime > gen.stat().st_mtime + 1: # 1s grace # Derive the plugin name correctly regardless of source layout. diff --git a/tools/generate.py b/tools/generate.py index bebb698..8d21cfb 100644 --- a/tools/generate.py +++ b/tools/generate.py @@ -2,7 +2,7 @@ """Unified CLI for emitting per-harness artifacts from claude-agents plugin sources. Usage: - python tools/generate.py --harness [--plugin ] [--all] [--clean] [--prune] [--strict] + python tools/generate.py --harness [--plugin ] [--all] [--clean] [--prune] [--strict] """ from __future__ import annotations @@ -34,6 +34,7 @@ _HARNESS_TARGETS = { "cursor": [".cursor", ".cursor-plugin"], "opencode": [".opencode", "opencode.json"], "gemini": ["commands", "agents", "skills"], + "copilot": [".copilot/agents", ".copilot/skills", ".copilot/commands"], } @@ -55,6 +56,10 @@ def get_adapter(harness_id: str, output_root: Path) -> HarnessAdapter: from tools.adapters.gemini import GeminiAdapter return GeminiAdapter(output_root=output_root) + if harness_id == "copilot": + from tools.adapters.copilot import CopilotAdapter + + return CopilotAdapter(output_root=output_root) raise ValueError(f"Unknown harness: {harness_id}. Supported: {supported_harnesses()}") @@ -155,6 +160,14 @@ def prune_orphans(harness_id: str, output_root: Path, written: set[Path]) -> lis d = output_root / sub if d.is_dir(): candidates.extend(p for p in d.rglob("*") if p.is_file()) + elif harness_id == "copilot": + for sub in ("agents", "skills"): + d = output_root / ".copilot" / sub + if d.is_dir(): + candidates.extend(p for p in d.rglob("*") if p.is_file()) + d = output_root / ".copilot" / "commands" + if d.is_dir(): + candidates.extend(p for p in d.rglob("*") if p.is_file()) elif harness_id == "cursor": # Both .cursor-plugin/plugins/*.json and .cursor/rules/*.mdc are adapter outputs. for sub_path in ( @@ -179,7 +192,7 @@ def main() -> int: "--harness", required=True, choices=supported_harnesses(), - help="Target harness (codex, cursor, opencode, or gemini).", + help="Target harness (codex, copilot, cursor, opencode, or gemini).", ) group = parser.add_mutually_exclusive_group() group.add_argument("--plugin", help="Generate only for the named plugin.") @@ -234,7 +247,8 @@ def main() -> int: if not args.plugin and not args.all: print( - "No --plugin or --all specified. Use --all to generate every plugin.", file=sys.stderr + "No --plugin or --all specified. Use --all to generate every plugin.", + file=sys.stderr, ) return 1 @@ -305,7 +319,8 @@ def main() -> int: print(f" - pruned: {p.relative_to(output_root)}") elif args.prune and not args.all: print( - " ! --prune ignored without --all (need full view to detect orphans)", file=sys.stderr + " ! --prune ignored without --all (need full view to detect orphans)", + file=sys.stderr, ) print( diff --git a/tools/install_copilot.py b/tools/install_copilot.py new file mode 100644 index 0000000..f52ffef --- /dev/null +++ b/tools/install_copilot.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 +"""Install generated Copilot artifacts into the user's Copilot config directory.""" + +from __future__ import annotations + +import argparse +import os +from dataclasses import dataclass, field +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +GENERATED_ROOT = REPO_ROOT / ".copilot" +ARTIFACT_GLOBS = { + "agents": "*.agent.md", + "skills": "*", + "commands": "*", +} + + +@dataclass +class InstallReport: + linked: int = 0 + unchanged: int = 0 + removed: int = 0 + skipped: int = 0 + errors: list[str] = field(default_factory=list) + + @property + def ok(self) -> bool: + return not self.errors + + +def default_config_dir(env: dict[str, str] | None = None) -> Path: + resolved: dict[str, str] = env if env is not None else dict(os.environ) + if resolved.get("COPILOT_CONFIG_DIR"): + return Path(resolved["COPILOT_CONFIG_DIR"]).expanduser() + if resolved.get("XDG_CONFIG_HOME"): + return Path(resolved["XDG_CONFIG_HOME"]).expanduser() / "copilot" + return Path.home() / ".copilot" + + +def _is_relative_to(child: Path, parent: Path) -> bool: + try: + child.resolve(strict=False).relative_to(parent.resolve(strict=False)) + except ValueError: + return False + return True + + +def _generated_artifacts(repo_root: Path) -> list[tuple[str, Path]]: + generated_root = repo_root / ".copilot" + artifacts: list[tuple[str, Path]] = [] + for subdir, pattern in ARTIFACT_GLOBS.items(): + source_dir = generated_root / subdir + if not source_dir.is_dir(): + continue # optional dir (commands, etc.) — may not exist + for src in sorted(source_dir.glob(pattern)): + if subdir == "skills" and not src.is_dir(): + continue + if subdir in {"agents"} and not src.is_file(): + continue + if subdir in {"commands"} and not src.is_dir(): + continue + artifacts.append((subdir, src.resolve())) + if not artifacts: + raise FileNotFoundError( + f"No artifacts found under {generated_root}; " + "run `make generate HARNESS=copilot` first" + ) + return artifacts + + +def _link_one(src: Path, dst: Path, *, force: bool, report: InstallReport) -> None: + if dst.is_symlink(): + if dst.resolve(strict=False) == src: + report.unchanged += 1 + return + if not force: + report.errors.append( + f"{dst} already exists as a symlink to {dst.resolve(strict=False)}; " + "rerun with FORCE=1 to replace symlink conflicts" + ) + return + dst.unlink() + elif dst.exists(): + report.errors.append( + f"{dst} already exists and is not a symlink; refusing to overwrite" + ) + return + + dst.parent.mkdir(parents=True, exist_ok=True) + dst.symlink_to(src, target_is_directory=src.is_dir()) + report.linked += 1 + + +def install( + *, + repo_root: Path = REPO_ROOT, + config_dir: Path | None = None, + force: bool = False, +) -> InstallReport: + config_dir = (config_dir or default_config_dir()).expanduser() + report = InstallReport() + try: + artifacts = _generated_artifacts(repo_root) + except FileNotFoundError as exc: + report.errors.append(str(exc)) + return report + + for subdir, src in artifacts: + if subdir == "commands": + # For commands, extract plugin name and symlink to ~/.copilot//commands/ + # src is like .copilot/commands/comprehensive-review, symlink to ~/.copilot/comprehensive-review/commands + plugin_name = src.name + dst = config_dir / plugin_name / "commands" + _link_one(src, dst, force=force, report=report) + else: + dst = config_dir / subdir / src.name + _link_one(src, dst, force=force, report=report) + return report + + +def _clear_copilot_cache(config_dir: Path) -> list[str]: + """Clear Copilot CLI caches to force artifact discovery reload.""" + cache_dirs = [ + config_dir / "pkg", + config_dir / "marketplace-cache", + ] + cleared = [] + for cache_dir in cache_dirs: + if cache_dir.exists(): + import shutil + + shutil.rmtree(cache_dir, ignore_errors=True) + cleared.append(str(cache_dir)) + return cleared + + +def uninstall( + *, + repo_root: Path = REPO_ROOT, + config_dir: Path | None = None, +) -> InstallReport: + config_dir = (config_dir or default_config_dir()).expanduser() + generated_root = (repo_root / ".copilot").resolve(strict=False) + report = InstallReport() + + for subdir in ARTIFACT_GLOBS: + if subdir == "commands": + # commands are installed at config//commands/ (not config/commands/) + if not config_dir.is_dir(): + continue + for candidate in sorted(config_dir.iterdir()): + commands_dir = candidate / "commands" + if commands_dir.is_symlink(): + target = commands_dir.resolve(strict=False) + if _is_relative_to(target, generated_root): + commands_dir.unlink() + report.removed += 1 + else: + report.skipped += 1 + continue + target_dir = config_dir / subdir + if not target_dir.is_dir(): + continue + for dst in sorted(target_dir.iterdir()): + if not dst.is_symlink(): + report.skipped += 1 + continue + target = dst.resolve(strict=False) + if _is_relative_to(target, generated_root): + dst.unlink() + report.removed += 1 + else: + report.skipped += 1 + return report + + +def _print_report( + action: str, + config_dir: Path, + report: InstallReport, + *, + cache_cleared: list[str] | None = None, +) -> None: + print( + f"{action}: config={config_dir} linked={report.linked} unchanged={report.unchanged} " + f"removed={report.removed} skipped={report.skipped}" + ) + if cache_cleared: + print(f"cache: cleared {len(cache_cleared)} cache dir(s)") + for error in report.errors: + print(f"error: {error}") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("action", choices=("install", "uninstall")) + parser.add_argument("--config-dir", type=Path, default=None) + parser.add_argument("--repo-root", type=Path, default=REPO_ROOT) + parser.add_argument( + "--force", action="store_true", help="Replace conflicting symlinks only" + ) + args = parser.parse_args() + + config_dir = (args.config_dir or default_config_dir()).expanduser() + cache_cleared = None + if args.action == "install": + report = install( + repo_root=args.repo_root, config_dir=config_dir, force=args.force + ) + cache_cleared = _clear_copilot_cache(config_dir) + else: + report = uninstall(repo_root=args.repo_root, config_dir=config_dir) + _print_report(args.action, config_dir, report, cache_cleared=cache_cleared) + return 0 if report.ok else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/tests/test_adapters.py b/tools/tests/test_adapters.py index c020163..6821b8c 100644 --- a/tools/tests/test_adapters.py +++ b/tools/tests/test_adapters.py @@ -14,6 +14,11 @@ from pathlib import Path # tools.adapters.* imports happen via the conftest sys.path injection from tools.adapters.base import PluginSource, parse_frontmatter from tools.adapters.codex import CodexAdapter, _split_body_if_oversized +from tools.adapters.copilot import ( + CopilotAdapter, + _build_tools_list, + _needs_yaml_quoting, +) from tools.adapters.cursor import CursorAdapter from tools.adapters.gemini import _INLINE_BODY_THRESHOLD, GeminiAdapter from tools.adapters.opencode import OpenCodeAdapter, _opencode_skill_id @@ -239,7 +244,10 @@ class TestCodexAdapter: (plugin_dir / ".claude-plugin").mkdir() (plugin_dir / ".claude-plugin" / "plugin.json").write_text('{"name": "demo"}') skill = _make_skill( - plugin_dir, "review", "name: review\ndescription: Use when reviewing.", "# Skill\nbody" + plugin_dir, + "review", + "name: review\ndescription: Use when reviewing.", + "# Skill\nbody", ) command = _make_command( plugin_dir, "review", 'description: "Review command"', "# Command\nbody" @@ -659,7 +667,10 @@ class TestOpenCodeAdapter: "# Hello\n\nBody.\n", ) plugin = PluginSource( - name="bad_plugin", dir=plugin_dir, plugin_json={"name": "bad_plugin"}, skills=[skill] + name="bad_plugin", + dir=plugin_dir, + plugin_json={"name": "bad_plugin"}, + skills=[skill], ) try: @@ -919,6 +930,204 @@ class TestGeminiAdapter: assert "demo__hello" in content +# ── Copilot ────────────────────────────────────────────────────────────────── + + +class TestCopilotAdapter: + def test_emits_agent_profile(self, synthetic_plugin: PluginSource, output_root: Path): + adapter = CopilotAdapter(output_root=output_root) + result = adapter.emit_plugin(synthetic_plugin) + + agent_path = output_root / ".copilot" / "agents" / "demo__greeter.agent.md" + assert agent_path in result.written + assert agent_path.is_file() + + fm, body = parse_frontmatter(agent_path.read_text()) + assert fm["name"] == "demo__greeter" + assert fm["description"] == "Use when delegating greetings." + assert fm["model"] == "gpt-5" + assert fm["tools"] == ["read", "search"] + assert "color" not in fm + + def test_tool_name_rewriting(self, tmp_path: Path, output_root: Path): + from tools.tests.conftest import _make_agent + + plugin_dir = tmp_path / "demo" + plugin_dir.mkdir() + (plugin_dir / ".claude-plugin").mkdir() + (plugin_dir / ".claude-plugin" / "plugin.json").write_text('{"name": "demo"}') + agent = _make_agent( + plugin_dir, + "tool-user", + "name: tool-user\ndescription: Use when tooling.\ntools: Read, Write, Bash", + "# Tool User\n\nUse the `Read` tool to read and `Bash` to execute.\n", + ) + plugin = PluginSource( + name="demo", dir=plugin_dir, plugin_json={"name": "demo"}, agents=[agent] + ) + CopilotAdapter(output_root=output_root).emit_plugin(plugin) + + content = (output_root / ".copilot" / "agents" / "demo__tool-user.agent.md").read_text() + fm, body = parse_frontmatter(content) + assert fm["tools"] == ["read", "edit", "execute"] + assert "`read`" in body + assert "`execute`" in body + assert "`Read`" not in body + assert "`Bash`" not in body + + def test_model_alias_resolution(self, tmp_path: Path, output_root: Path): + from tools.tests.conftest import _make_agent + + plugin_dir = tmp_path / "demo" + plugin_dir.mkdir() + (plugin_dir / ".claude-plugin").mkdir() + (plugin_dir / ".claude-plugin" / "plugin.json").write_text('{"name": "demo"}') + + agents = [] + for name, model in [ + ("sonnet-agent", "sonnet"), + ("haiku-agent", "haiku"), + ("inherit-agent", "inherit"), + ]: + agents.append( + _make_agent( + plugin_dir, + name, + f"name: {name}\ndescription: Use for {name}.\nmodel: {model}", + f"# {name}\n", + ) + ) + default_agent = _make_agent( + plugin_dir, + "default-model", + "name: default-model\ndescription: Use with default.", + "# Default\n", + ) + agents.append(default_agent) + + plugin = PluginSource( + name="demo", + dir=plugin_dir, + plugin_json={"name": "demo"}, + agents=agents, + ) + CopilotAdapter(output_root=output_root).emit_plugin(plugin) + + expected = { + "sonnet-agent": "gpt-5-mini", + "haiku-agent": "gpt-5-nano", + "inherit-agent": "gpt-5", + "default-model": "gpt-5", + } + for name, exp_model in expected.items(): + fm, _ = parse_frontmatter( + (output_root / ".copilot" / "agents" / f"demo__{name}.agent.md").read_text() + ) + assert fm["model"] == exp_model, f"{name}: expected {exp_model}, got {fm['model']}" + + def test_emits_skill(self, synthetic_plugin: PluginSource, output_root: Path): + CopilotAdapter(output_root=output_root).emit_plugin(synthetic_plugin) + skill_path = output_root / ".copilot" / "skills" / "demo__hello" / "SKILL.md" + assert skill_path.is_file() + + fm, body = parse_frontmatter(skill_path.read_text()) + assert fm["name"] == "hello" + assert fm["description"] == "Use when greeting users." + assert "# Hello" in body + + def test_emits_command_prompt_files(self, synthetic_plugin: PluginSource, output_root: Path): + CopilotAdapter(output_root=output_root).emit_plugin(synthetic_plugin) + + entry = output_root / ".copilot" / "commands" / "demo" / "index.md" + cmd = output_root / ".copilot" / "commands" / "demo" / "say-hi.md" + + assert entry.is_file() + assert cmd.is_file() + + entry_fm, entry_body = parse_frontmatter(entry.read_text()) + cmd_fm, cmd_body = parse_frontmatter(cmd.read_text()) + assert entry_fm["description"] == "Demo plugin for tests" + assert "/demo:say-hi" in entry_body + assert cmd_fm["description"] == "Send a greeting" + assert "Greet the user named $ARGUMENTS." in cmd_body + + def test_emit_global_returns_empty(self, synthetic_plugin: PluginSource, output_root: Path): + adapter = CopilotAdapter(output_root=output_root) + result = adapter.emit_global([synthetic_plugin]) + assert result.written == [] + + def test_build_tools_list(self): + assert _build_tools_list(["Read", "Grep"]) == ["read", "search"] + assert _build_tools_list(["Write", "Edit"]) == ["edit", "edit"] + assert _build_tools_list(["Bash", "Glob"]) == ["execute", "search"] + assert _build_tools_list(["CustomTool"]) == ["CustomTool"] + assert _build_tools_list([]) == [] + + def test_yaml_quoting(self): + assert _needs_yaml_quoting("123") + assert _needs_yaml_quoting("3.14") + assert _needs_yaml_quoting("true") + assert _needs_yaml_quoting("false") + assert _needs_yaml_quoting("yes") + assert _needs_yaml_quoting("no") + assert _needs_yaml_quoting("on") + assert _needs_yaml_quoting("off") + assert _needs_yaml_quoting("null") + assert _needs_yaml_quoting("~") + assert not _needs_yaml_quoting("hello world") + assert not _needs_yaml_quoting("Use when testing.") + + def test_explicit_empty_tools(self, tmp_path: Path, output_root: Path): + from tools.tests.conftest import _make_agent + + plugin_dir = tmp_path / "demo" + plugin_dir.mkdir() + (plugin_dir / ".claude-plugin").mkdir() + (plugin_dir / ".claude-plugin" / "plugin.json").write_text('{"name": "demo"}') + agent = _make_agent( + plugin_dir, + "advisory", + "name: advisory\ndescription: Use when advising.\nmodel: sonnet\ntools: []", + "# Advisory\n", + ) + plugin = PluginSource( + name="demo", dir=plugin_dir, plugin_json={"name": "demo"}, agents=[agent] + ) + CopilotAdapter(output_root=output_root).emit_plugin(plugin) + + content = (output_root / ".copilot" / "agents" / "demo__advisory.agent.md").read_text() + fm, body = parse_frontmatter(content) + assert fm["name"] == "demo__advisory" + assert fm["description"] == "Use when advising." + assert fm["model"] == "gpt-5-mini" + assert "tools:" in content + + def test_no_tools_field(self, tmp_path: Path, output_root: Path): + from tools.tests.conftest import _make_agent + + plugin_dir = tmp_path / "demo" + plugin_dir.mkdir() + (plugin_dir / ".claude-plugin").mkdir() + (plugin_dir / ".claude-plugin" / "plugin.json").write_text('{"name": "demo"}') + agent = _make_agent( + plugin_dir, + "unrestricted", + "name: unrestricted\ndescription: Use when unrestricted.\nmodel: opus", + "# Unrestricted\n", + ) + plugin = PluginSource( + name="demo", dir=plugin_dir, plugin_json={"name": "demo"}, agents=[agent] + ) + CopilotAdapter(output_root=output_root).emit_plugin(plugin) + + content = (output_root / ".copilot" / "agents" / "demo__unrestricted.agent.md").read_text() + fm, body = parse_frontmatter(content) + assert fm["name"] == "demo__unrestricted" + assert fm["description"] == "Use when unrestricted." + assert fm["model"] == "gpt-5" + assert "tools" not in fm + + # ── Cross-cutting: capabilities consistency ────────────────────────────────── @@ -977,7 +1186,13 @@ class TestCapabilities: def test_every_adapter_id_has_capabilities_entry(self): from tools.adapters.capabilities import CAPABILITIES - for adapter_cls in (CodexAdapter, CursorAdapter, OpenCodeAdapter, GeminiAdapter): + for adapter_cls in ( + CodexAdapter, + CopilotAdapter, + CursorAdapter, + GeminiAdapter, + OpenCodeAdapter, + ): assert adapter_cls.harness_id in CAPABILITIES def test_model_aliases_complete(self): diff --git a/tools/tests/test_install_copilot.py b/tools/tests/test_install_copilot.py new file mode 100644 index 0000000..1c48e6d --- /dev/null +++ b/tools/tests/test_install_copilot.py @@ -0,0 +1,108 @@ +"""Tests for safe Copilot global install/uninstall helper.""" + +from __future__ import annotations + +from pathlib import Path + +from tools.install_copilot import default_config_dir, install, uninstall + + +def _write_generated_copilot(repo_root: Path) -> None: + agents = repo_root / ".copilot" / "agents" + skills = repo_root / ".copilot" / "skills" / "demo-hello" + commands = repo_root / ".copilot" / "commands" / "demo" + agents.mkdir(parents=True) + skills.mkdir(parents=True) + commands.mkdir(parents=True) + (agents / "demo__agent.agent.md").write_text("agent\n") + (skills / "SKILL.md").write_text("---\nname: demo-hello\n---\n\nBody.\n") + (commands / "index.md").write_text("---\ndescription: demo\n---\n\nEntry.\n") + (commands / "say-hi.md").write_text("---\ndescription: hi\n---\n\nHi.\n") + + +def test_default_config_dir_prefers_copilot_config_dir(tmp_path: Path): + env = { + "COPILOT_CONFIG_DIR": str(tmp_path / "custom"), + "XDG_CONFIG_HOME": str(tmp_path / "xdg"), + } + assert default_config_dir(env) == tmp_path / "custom" + + +def test_default_config_dir_uses_xdg_config_home(tmp_path: Path): + assert default_config_dir({"XDG_CONFIG_HOME": str(tmp_path / "xdg")}) == ( + tmp_path / "xdg" / "copilot" + ) + + +def test_install_creates_idempotent_symlinks(tmp_path: Path): + repo_root = tmp_path / "repo" + config_dir = tmp_path / "config" + _write_generated_copilot(repo_root) + + first = install(repo_root=repo_root, config_dir=config_dir) + second = install(repo_root=repo_root, config_dir=config_dir) + + assert first.ok + assert first.linked == 3 + assert second.ok + assert second.unchanged == 3 + assert (config_dir / "agents" / "demo__agent.agent.md").is_symlink() + assert (config_dir / "skills" / "demo-hello").is_symlink() + assert (config_dir / "demo" / "commands").is_symlink() + + +def test_install_refuses_to_overwrite_real_files(tmp_path: Path): + repo_root = tmp_path / "repo" + config_dir = tmp_path / "config" + _write_generated_copilot(repo_root) + target = config_dir / "agents" / "demo__agent.agent.md" + target.parent.mkdir(parents=True) + target.write_text("user file\n") + + report = install(repo_root=repo_root, config_dir=config_dir) + + assert not report.ok + assert "not a symlink" in report.errors[0] + assert target.read_text() == "user file\n" + + +def test_force_replaces_conflicting_symlink_only(tmp_path: Path): + repo_root = tmp_path / "repo" + config_dir = tmp_path / "config" + other = tmp_path / "other.agent.md" + other.write_text("other\n") + _write_generated_copilot(repo_root) + target = config_dir / "agents" / "demo__agent.agent.md" + target.parent.mkdir(parents=True) + target.symlink_to(other) + + blocked = install(repo_root=repo_root, config_dir=config_dir) + forced = install(repo_root=repo_root, config_dir=config_dir, force=True) + + assert not blocked.ok + assert forced.ok + assert ( + target.resolve() == (repo_root / ".copilot" / "agents" / "demo__agent.agent.md").resolve() + ) + + +def test_uninstall_removes_only_repo_owned_symlinks(tmp_path: Path): + repo_root = tmp_path / "repo" + config_dir = tmp_path / "config" + _write_generated_copilot(repo_root) + assert install(repo_root=repo_root, config_dir=config_dir).ok + + unrelated_target = tmp_path / "unrelated.agent.md" + unrelated_target.write_text("unrelated\n") + unrelated = config_dir / "agents" / "unrelated.agent.md" + unrelated.symlink_to(unrelated_target) + real_file = config_dir / "skills" / "user.md" + real_file.write_text("user\n") + + report = uninstall(repo_root=repo_root, config_dir=config_dir) + + assert report.ok + assert report.removed == 3 + assert not (config_dir / "agents" / "demo__agent.agent.md").exists() + assert unrelated.is_symlink() + assert real_file.read_text() == "user\n" diff --git a/tools/tests/test_round_trip.py b/tools/tests/test_round_trip.py index a318a29..4ce7094 100644 --- a/tools/tests/test_round_trip.py +++ b/tools/tests/test_round_trip.py @@ -95,7 +95,11 @@ class TestCodexRoundTrip: if missing: invalid.append(f"{toml_path.name}: missing {sorted(missing)}") sandbox = data.get("sandbox_mode") - if sandbox and sandbox not in {"read-only", "workspace-write", "danger-full-access"}: + if sandbox and sandbox not in { + "read-only", + "workspace-write", + "danger-full-access", + }: invalid.append(f"{toml_path.name}: invalid sandbox_mode {sandbox!r}") assert not invalid, "Invalid Codex agent TOMLs:\n " + "\n ".join(invalid[:20]) @@ -279,6 +283,55 @@ class TestGeminiRoundTrip: assert len(lines) <= 150, f"GEMINI.md is {len(lines)} lines (cap: 150)" +@pytest.mark.skipif( + not (WORKTREE / ".copilot").is_dir(), + reason="Copilot artifacts not generated (run `make generate HARNESS=copilot` first)", +) +class TestCopilotRoundTrip: + def test_copilot_agent_count_matches_source(self): + n = len(list((WORKTREE / ".copilot" / "agents").glob("*.agent.md"))) + assert n == _source_agent_count(), ( + f"agent count mismatch: source={_source_agent_count()} copilot={n}" + ) + + def test_copilot_skill_count_matches_source(self): + n = len(list((WORKTREE / ".copilot" / "skills").glob("*/SKILL.md"))) + expected = _source_skill_count() + _source_command_count() + assert n == expected, ( + f"skill count mismatch: source_skills={_source_skill_count()} " + f"source_commands={_source_command_count()} expected={expected} copilot={n}" + ) + + def test_copilot_command_count_matches_source(self): + n = len( + [p for p in (WORKTREE / ".copilot" / "commands").rglob("*.md") if p.name != "index.md"] + ) + assert n == _source_command_count(), ( + f"command count mismatch: source={_source_command_count()} copilot={n}" + ) + + def test_copilot_command_entrypoints_exist_for_every_plugin(self): + missing = [] + for plugin_name in list_plugins(): + entry = WORKTREE / ".copilot" / "commands" / plugin_name / "index.md" + if not entry.is_file(): + missing.append(plugin_name) + assert not missing, f"missing Copilot command entrypoints for: {sorted(missing)}" + + def test_every_copilot_agent_has_required_frontmatter(self): + required = {"name", "description"} + problems = [] + for agent_md in (WORKTREE / ".copilot" / "agents").glob("*.agent.md"): + fm, _ = parse_frontmatter(agent_md.read_text()) + if not fm: + problems.append(f"{agent_md.name}: no frontmatter") + continue + missing = required - set(fm.keys()) + if missing: + problems.append(f"{agent_md.name}: missing {sorted(missing)}") + assert not problems, "Copilot agent frontmatter issues:\n " + "\n ".join(problems[:20]) + + # ── Context file size budgets (always run) ─────────────────────────────────── diff --git a/tools/tests/test_validate_generated.py b/tools/tests/test_validate_generated.py index bce4ae3..da7f793 100644 --- a/tools/tests/test_validate_generated.py +++ b/tools/tests/test_validate_generated.py @@ -9,6 +9,7 @@ import pytest from tools.validate_generated import ( Report, validate_codex, + validate_copilot, validate_cursor, validate_gemini, validate_opencode, @@ -145,6 +146,101 @@ class TestCursorValidator: ) +# ── Copilot ────────────────────────────────────────────────────────────────── + + +class TestCopilotValidator: + def test_non_string_description_errors(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + _patch_worktree(monkeypatch, tmp_path) + agents = tmp_path / ".copilot" / "agents" + agents.mkdir(parents=True) + (agents / "bad.agent.md").write_text("---\nname: bad\ndescription: [oops]\n---\n\nBody.\n") + + report = Report() + validate_copilot(report) + assert any("description" in f.message and "string" in f.message for f in report.errors()) + + def test_missing_name_errors(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + _patch_worktree(monkeypatch, tmp_path) + agents = tmp_path / ".copilot" / "agents" + agents.mkdir(parents=True) + (agents / "noname.agent.md").write_text( + "---\ndescription: Use when testing.\n---\n\nBody.\n" + ) + + report = Report() + validate_copilot(report) + assert any("name" in f.message for f in report.errors()) + + def test_empty_name_errors(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + _patch_worktree(monkeypatch, tmp_path) + agents = tmp_path / ".copilot" / "agents" + agents.mkdir(parents=True) + (agents / "emptyname.agent.md").write_text( + '---\nname: ""\ndescription: Use when testing.\n---\n\nBody.\n' + ) + + report = Report() + validate_copilot(report) + assert any("is empty" in f.message for f in report.errors()) + + def test_missing_description_errors(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + _patch_worktree(monkeypatch, tmp_path) + agents = tmp_path / ".copilot" / "agents" + agents.mkdir(parents=True) + (agents / "nodesc.agent.md").write_text("---\nname: nodesc\n---\n\nBody.\n") + + report = Report() + validate_copilot(report) + assert any("description" in f.message for f in report.errors()) + + def test_empty_description_errors(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + _patch_worktree(monkeypatch, tmp_path) + agents = tmp_path / ".copilot" / "agents" + agents.mkdir(parents=True) + (agents / "emptydesc.agent.md").write_text( + '---\nname: emptydesc\ndescription: ""\n---\n\nBody.\n' + ) + + report = Report() + validate_copilot(report) + assert any("field is empty" in f.message for f in report.errors()) + + def test_valid_agent_passes(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + _patch_worktree(monkeypatch, tmp_path) + agents = tmp_path / ".copilot" / "agents" + agents.mkdir(parents=True) + (agents / "good.agent.md").write_text( + "---\nname: good\ndescription: Use when testing.\nmodel: gpt-5\n---\n\nBody.\n" + ) + + report = Report() + validate_copilot(report) + assert not report.errors() + + def test_skill_missing_name_errors(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + _patch_worktree(monkeypatch, tmp_path) + skill_dir = tmp_path / ".copilot" / "skills" / "test__skill" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text("---\ndescription: Use when testing.\n---\n\nBody.\n") + + report = Report() + validate_copilot(report) + assert any("name" in f.message for f in report.errors()) + + def test_skill_missing_description_errors( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + _patch_worktree(monkeypatch, tmp_path) + skill_dir = tmp_path / ".copilot" / "skills" / "test__skill" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text("---\nname: test__skill\n---\n\nBody.\n") + + report = Report() + validate_copilot(report) + assert any("description" in f.message for f in report.errors()) + + # ── OpenCode ───────────────────────────────────────────────────────────────── diff --git a/tools/validate_generated.py b/tools/validate_generated.py index 80c89f6..fabfae9 100644 --- a/tools/validate_generated.py +++ b/tools/validate_generated.py @@ -61,6 +61,53 @@ class Report: return [f for f in self.findings if f.severity == "info"] +# ── Helpers ────────────────────────────────────────────────────────────────── + + +def _check_nonempty_str_field( + report: Report, + fm: dict, + field: str, + harness: str, + path: Path, + label: str = "", +) -> bool: + """Validate that `field` exists in `fm`, is a str, and is non-empty. + + Adds one Finding per violation. Returns True if the field passes all checks. + ``label`` is only used in remediation hints (defaults to ``field``). + """ + label = label or field + if field not in fm: + report.add( + severity="error", + harness=harness, + path=path, + message=f"missing required `{field}` field in frontmatter", + remediation=f"Each {harness} {label} needs a `{field}`.", + ) + return False + if not isinstance(fm[field], str): + report.add( + severity="error", + harness=harness, + path=path, + message=f"`{field}` field must be a string", + remediation=f"Set `{field}` to a plain string in the {label} frontmatter.", + ) + return False + if not fm[field].strip(): + report.add( + severity="error", + harness=harness, + path=path, + message=f"`{field}` field is empty", + remediation=f"Provide a non-empty `{field}` in the {label} frontmatter.", + ) + return False + return True + + # ── Codex validators ───────────────────────────────────────────────────────── @@ -558,11 +605,95 @@ def validate_gemini(report: Report) -> None: # ── Driver ─────────────────────────────────────────────────────────────────── +def validate_copilot(report: Report) -> None: + """Validate Copilot agent, skill, and command markdown files under WORKTREE/.copilot. + + Checks that generated agent, skill, and command markdown files have valid + frontmatter and the minimum metadata Copilot needs to discover them. + """ + candidate_roots = [WORKTREE / ".copilot"] + found_any = False + for root in candidate_roots: + agents_dir = root / "agents" + if not agents_dir.is_dir(): + continue + found_any = True + for agent_md in agents_dir.glob("*.agent.md"): + content = agent_md.read_text(encoding="utf-8") + fm, _ = parse_frontmatter(content) + if not fm: + report.add( + severity="error", + harness="copilot", + path=agent_md, + message="missing or invalid frontmatter", + remediation="Regenerate via `make generate HARNESS=copilot`.", + ) + continue + _check_nonempty_str_field(report, fm, "name", "copilot", agent_md, label="agent") + _check_nonempty_str_field(report, fm, "description", "copilot", agent_md, label="agent") + # 3. Skills: validate .copilot/skills/*/SKILL.md exists and has valid frontmatter. + skills_dir = WORKTREE / ".copilot" / "skills" + if skills_dir.is_dir(): + found_any = True + for skill_md in skills_dir.glob("*/SKILL.md"): + content = skill_md.read_text(encoding="utf-8") + fm, _ = parse_frontmatter(content) + if not fm: + report.add( + severity="error", + harness="copilot", + path=skill_md, + message="missing or invalid frontmatter", + remediation="Regenerate via `make generate HARNESS=copilot`.", + ) + continue + _check_nonempty_str_field(report, fm, "name", "copilot", skill_md, label="skill") + _check_nonempty_str_field(report, fm, "description", "copilot", skill_md, label="skill") + + commands_dir = WORKTREE / ".copilot" / "commands" + if commands_dir.is_dir(): + found_any = True + for command_md in commands_dir.rglob("*.md"): + content = command_md.read_text(encoding="utf-8") + fm, body = parse_frontmatter(content) + if not fm: + report.add( + severity="error", + harness="copilot", + path=command_md, + message="missing or invalid frontmatter", + remediation="Regenerate via `make generate HARNESS=copilot`.", + ) + continue + description = fm.get("description") + if not isinstance(description, str) or not description.strip(): + report.add( + severity="error", + harness="copilot", + path=command_md, + message="missing required `description` field in frontmatter", + remediation="Copilot command prompt files need a non-empty description.", + ) + if command_md.name != "index.md" and not body.strip(): + report.add( + severity="warning", + harness="copilot", + path=command_md, + message="command body is empty", + remediation="Keep the prompt body in the source command markdown.", + ) + + if not found_any: + return + + _VALIDATORS = { "codex": validate_codex, + "copilot": validate_copilot, "cursor": validate_cursor, - "opencode": validate_opencode, "gemini": validate_gemini, + "opencode": validate_opencode, }