Merge upstream/main — keep updated plugin counts, add GitHub Copilot to harnesses

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ndesv21
2026-05-26 20:53:20 +02:00
18 changed files with 1240 additions and 32 deletions
+12
View File
@@ -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/
+3 -2
View File
@@ -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/<name>/`. 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/<name>/`. 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=<x>`
│ ├── 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).
+4 -2
View File
@@ -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;
+11 -2
View File
@@ -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=<h>] 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=<h>] [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
+8 -6
View File
@@ -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=<codex|cursor|opencode|gemini>
make generate HARNESS=<codex|cursor|opencode|gemini|copilot>
```
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
+9 -10
View File
@@ -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/<name>.md` | `name`, `description` | `model`, optional `tools:`, optional `color:` | `tools:` allowlist becomes a per-harness permission block where supported, dropped otherwise. |
| `skills/<name>/SKILL.md` | `name`, `description` | (none) | Other Anthropic SKILL.md fields work on Claude Code only. |
| `commands/<name>.md` | `description` | `argument-hint:` | Codex converts these to skills (it deprecated `~/.codex/prompts/`). |
| `commands/<name>.md` | `description` | `argument-hint:` | Codex converts these to skills (it deprecated `~/.codex/prompts/`). Copilot emits `.copilot/commands/<plugin>/<name>.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
+18
View File
@@ -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
+26 -1
View File
@@ -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 `@<agent>` 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.
+39
View File
@@ -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",
+239
View File
@@ -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/<plugin>__<agent>.agent.md``, skills to
``.copilot/skills/<plugin>__<skill>/SKILL.md``. Plugin commands are emitted as
runnable skills at ``.copilot/skills/<plugin>-<command>/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 (``<plugin>-<command>``) 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 <plugin>/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 <plugin>/<command>.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 <plugin>/<command>.md inside .copilot/commands/
result.written.append(
self.write(
Path(".copilot") / "commands" / plugin.name / f"{command.name}.md",
content,
)
)
+24
View File
@@ -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.
+19 -4
View File
@@ -2,7 +2,7 @@
"""Unified CLI for emitting per-harness artifacts from claude-agents plugin sources.
Usage:
python tools/generate.py --harness <codex|cursor|opencode|gemini> [--plugin <name>] [--all] [--clean] [--prune] [--strict]
python tools/generate.py --harness <codex|copilot|cursor|opencode|gemini> [--plugin <name>] [--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(
+220
View File
@@ -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/<plugin>/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/<plugin>/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())
+218 -3
View File
@@ -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):
+108
View File
@@ -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"
+54 -1
View File
@@ -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) ───────────────────────────────────
+96
View File
@@ -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 ─────────────────────────────────────────────────────────────────
+132 -1
View File
@@ -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,
}