mirror of
https://github.com/wshobson/agents
synced 2026-06-21 14:13:58 +00:00
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:
+12
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) [](docs/harnesses.md) [](docs/harnesses.md) [](docs/harnesses.md) [](GEMINI.md)
|
||||
[](#claude-code) [](docs/harnesses.md) [](docs/harnesses.md) [](docs/harnesses.md) [](GEMINI.md) [](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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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())
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
@@ -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) ───────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user