Skip to content

Commit b6fc821

Browse files
committed
Ship the agent plugin sub-agents to GitHub Copilot clients
Agent Plugins 1.0 standardizes skills, auto-discovered from skills/, but not sub-agents: Copilot clients load those from com.github.copilot/agents, as <name>.agent.md files. The plugin only declared them in the Claude Code and Cursor manifests, which Copilot never reads, so a Copilot user got no sub-agent at all while both the skill and its INSTALL.md told the agent to skip installing them "because the plugin ships them". The three definitions are now generated into com.github.copilot/agents from the Claude Code ones, so the bodies can not drift, and validate_agent_plugins.py fails when they are out of sync. Only the frontmatter differs: Copilot tool aliases already accept the Claude tool names (Read -> read, Grep/Glob -> search, Bash -> execute), but "haiku" is not a Copilot model id, so it is dropped. Also corrected in the skills: - installing the sub-agents by hand on Copilot needs the .agent.md suffix in .github/agents/ and no model override; tools are valid and portable as-is - the install mode is no longer inferred from skill naming (only some platforms namespace plugin skills, and none exposes a skill's origin): it is read from the filesystem, and the user is asked when it stays ambiguous - the frontmatter key `licence` is corrected to `license`, which is the spelling the agents actually read CI now loads the repository as an external plugin in the GitHub Copilot CLI and asserts it is accepted with no manifest error. It runs offline through --plugin-dir, with no marketplace and no authentication. `plugins list` never enumerates the skills a plugin provides, verified against a control plugin, so the check asserts the plugin loads, not that each skill is exposed.
1 parent 56c1f36 commit b6fc821

12 files changed

Lines changed: 346 additions & 15 deletions

File tree

.automation/agent_plugin_manifests.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
REPO_HOME = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
99
REFERENCE_MANIFEST = "plugin.json"
10+
NEWLINE = chr(10)
11+
SEPARATOR = "---" + NEWLINE
1012

1113
# Per-vendor plugin manifests mirroring the identity declared in the Agent Plugins
1214
# 1.0 manifest.
@@ -27,6 +29,22 @@
2729
SHARED_FIELDS = ["name", "version", "description", "license", "homepage", "repository"]
2830
SHARED_ENTRY_FIELDS = ["name", "description", "license", "homepage", "repository"]
2931

32+
# Agent Plugins 1.0 standardizes skills, auto-discovered from skills/, but not
33+
# sub-agents: Copilot clients load those from com.github.copilot/agents, named
34+
# <name>.agent.md. Their bodies are identical to the Claude Code definitions, so
35+
# they are generated from them to avoid drift. Only the frontmatter differs:
36+
# Copilot tool aliases already accept the Claude tool names (Read -> read,
37+
# Grep/Glob -> search, Bash -> execute, WebFetch/WebSearch -> web), but "haiku"
38+
# is not a Copilot model id, so the model override is dropped.
39+
AGENT_SOURCE_DIR = "skills/megalinter-setup/agents"
40+
AGENT_NAMES = ["megalinter-watcher", "megalinter-runner", "megalinter-fixer"]
41+
COPILOT_AGENTS_DIR = "com.github.copilot/agents"
42+
COPILOT_DROPPED_FRONTMATTER_KEYS = ["model"]
43+
COPILOT_GENERATED_NOTICE = (
44+
f"<!-- @generated from {AGENT_SOURCE_DIR}/<name>.md "
45+
"by .automation/agent_plugin_manifests.py, do not edit -->"
46+
)
47+
3048

3149
def read_manifest(relative_path: str) -> dict:
3250
return json.loads(Path(f"{REPO_HOME}/{relative_path}").read_text(encoding="utf-8"))
@@ -61,6 +79,41 @@ def sync() -> None:
6179
if updated is True:
6280
write_manifest(target, manifest)
6381
logging.info(f"Updated agent plugin marketplace {target}")
82+
sync_copilot_agents()
83+
84+
85+
def build_copilot_agent(source_text: str) -> str:
86+
_, frontmatter, body = source_text.split(SEPARATOR, 2)
87+
kept = [
88+
line
89+
for line in frontmatter.splitlines()
90+
if line.split(":", 1)[0].strip() not in COPILOT_DROPPED_FRONTMATTER_KEYS
91+
]
92+
return (
93+
SEPARATOR
94+
+ NEWLINE.join(kept)
95+
+ NEWLINE
96+
+ SEPARATOR
97+
+ NEWLINE
98+
+ COPILOT_GENERATED_NOTICE
99+
+ NEWLINE
100+
+ body.rstrip(NEWLINE)
101+
+ NEWLINE
102+
)
103+
104+
105+
def sync_copilot_agents() -> None:
106+
target_dir = Path(f"{REPO_HOME}/{COPILOT_AGENTS_DIR}")
107+
target_dir.mkdir(parents=True, exist_ok=True)
108+
for name in AGENT_NAMES:
109+
source_path = Path(f"{REPO_HOME}/{AGENT_SOURCE_DIR}/{name}.md")
110+
content = build_copilot_agent(source_path.read_text(encoding="utf-8"))
111+
target = target_dir / f"{name}.agent.md"
112+
current = target.read_text(encoding="utf-8") if target.is_file() else None
113+
if current != content:
114+
with target.open("w", encoding="utf-8", newline=NEWLINE) as target_file:
115+
target_file.write(content)
116+
logging.info(f"Updated Copilot agent {COPILOT_AGENTS_DIR}/{name}.agent.md")
64117

65118

66119
def bump_patch() -> str:

.automation/validate_agent_plugins.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,16 @@
77

88
import jsonschema
99
from agent_plugin_manifests import (
10+
AGENT_NAMES,
11+
AGENT_SOURCE_DIR,
12+
COPILOT_AGENTS_DIR,
1013
REFERENCE_MANIFEST,
1114
REPO_HOME,
1215
SHARED_ENTRY_FIELDS,
1316
SHARED_FIELDS,
1417
TARGET_MARKETPLACE_MANIFESTS,
1518
TARGET_PLUGIN_MANIFESTS,
19+
build_copilot_agent,
1620
read_manifest,
1721
)
1822

@@ -91,12 +95,37 @@ def check_referenced_paths(errors: list[str]) -> None:
9195
)
9296

9397

98+
# Copilot clients do not read the vendor manifests declaring the sub-agents: they
99+
# load them from com.github.copilot/agents. Those files are generated, so a source
100+
# definition edited without re-running the sync would silently ship a stale agent.
101+
def check_copilot_agents(errors: list[str]) -> None:
102+
for name in AGENT_NAMES:
103+
source = os.path.join(REPO_HOME, AGENT_SOURCE_DIR, f"{name}.md")
104+
target = os.path.join(REPO_HOME, COPILOT_AGENTS_DIR, f"{name}.agent.md")
105+
if not os.path.isfile(target):
106+
errors.append(
107+
f"{COPILOT_AGENTS_DIR}/{name}.agent.md is missing: "
108+
"run python .automation/agent_plugin_manifests.py"
109+
)
110+
continue
111+
with open(source, encoding="utf-8") as source_file:
112+
expected = build_copilot_agent(source_file.read())
113+
with open(target, encoding="utf-8") as target_file:
114+
if target_file.read() != expected:
115+
errors.append(
116+
f"{COPILOT_AGENTS_DIR}/{name}.agent.md is out of sync with "
117+
f"{AGENT_SOURCE_DIR}/{name}.md: "
118+
"run python .automation/agent_plugin_manifests.py"
119+
)
120+
121+
94122
def main() -> int:
95123
logging.basicConfig(level=logging.INFO, format="%(message)s")
96124
errors: list[str] = []
97125
check_agent_plugins_schema(errors)
98126
check_shared_fields(errors)
99127
check_referenced_paths(errors)
128+
check_copilot_agents(errors)
100129
if len(errors) > 0:
101130
for error in errors:
102131
logging.error(f" {error}")

.github/workflows/test-agent-plugins.yml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ on:
99
- ".codex-plugin/**"
1010
- ".cursor-plugin/**"
1111
- ".agents/**"
12+
- "com.github.copilot/**"
1213
- "skills/**"
1314
- ".automation/validate_agent_plugins.py"
1415
- ".github/workflows/test-agent-plugins.yml"
@@ -20,6 +21,7 @@ on:
2021
- ".codex-plugin/**"
2122
- ".cursor-plugin/**"
2223
- ".agents/**"
24+
- "com.github.copilot/**"
2325
- "skills/**"
2426
- ".automation/validate_agent_plugins.py"
2527
- ".github/workflows/test-agent-plugins.yml"
@@ -51,3 +53,34 @@ jobs:
5153
# Validates .claude-plugin/marketplace.json and, transitively, the plugin
5254
# manifest it points to. Runs offline, no authentication required
5355
- run: claude plugin validate ./ --strict
56+
- run: npm install --global @github/copilot
57+
# Loads the repository as an external plugin in the Copilot CLI and checks
58+
# it is accepted with no manifest error. --plugin-dir mounts a local plugin,
59+
# so this runs offline, without a marketplace and without authentication.
60+
# Note: `plugins list` never enumerates the skills a plugin provides (checked
61+
# against a control plugin), so this asserts the plugin loads, not that each
62+
# skill is exposed
63+
- name: Validate the plugin loads in the GitHub Copilot CLI
64+
run: |
65+
copilot --plugin-dir "${GITHUB_WORKSPACE}" plugins list --json > copilot-plugins.json
66+
cat copilot-plugins.json
67+
python - <<'EOF'
68+
import json
69+
import sys
70+
71+
with open("copilot-plugins.json", encoding="utf-8") as inventory_file:
72+
inventory = json.load(inventory_file)
73+
errors = inventory.get("errors", [])
74+
if errors:
75+
sys.exit(f"Copilot CLI reported plugin errors: {errors}")
76+
loaded = [
77+
entry
78+
for entry in inventory.get("plugins", [])
79+
if entry.get("kind") == "plugin" and entry.get("name") == "megalinter"
80+
]
81+
if not loaded:
82+
sys.exit("Copilot CLI did not load the megalinter plugin")
83+
if not loaded[0].get("enabled"):
84+
sys.exit(f"Copilot CLI loaded the megalinter plugin disabled: {loaded[0]}")
85+
print(f"Copilot CLI loaded the megalinter plugin: {loaded[0]}")
86+
EOF

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,11 @@ Note: Can be used with `oxsecurity/megalinter@beta` in your GitHub Action mega-l
9696
- Fixed `mega-linter-runner --version` displaying `error` instead of the version when the `npm_package_version` environment variable is not set
9797

9898
- Agent Skills
99+
- The MegaLinter **agent plugin** now ships its three sub-agents to **GitHub Copilot** clients (VS Code, Copilot CLI, the Copilot app)
100+
- Agent Plugins 1.0 standardizes skills but not sub-agents, so Copilot loads them from `com.github.copilot/agents`: the plugin now carries them there, generated from the Claude Code definitions so the two can not drift
101+
- **megalinter-setup** installs them correctly outside the plugin too: on Copilot the file name must end with **`.agent.md`** in `.github/agents/`, and the `model: haiku` override must be dropped
102+
- The skills stop guessing how they were installed from the skill naming, which only some platforms namespace: the install mode is now read from the filesystem, and you are asked when it stays ambiguous
103+
- The `licence` frontmatter key of the four skills is corrected to **`license`**, the spelling agents actually read
99104
- **megalinter-check** now handles the commit MegaLinter pushes itself when the repository uses `APPLY_FIXES_MODE: commit`
100105
- CI providers ignore pushes made with the CI token, so the branch used to stay stuck on the **stale checks** of the run that produced the fixes
101106
- The commit is amended with a **🤖** prefix and re-pushed with `--force-with-lease`, which re-triggers the checks (you are asked first on the default branch)
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
name: megalinter-fixer
3+
description: Fix the errors reported by ONE MegaLinter linter, following the linter's fix guide. Spawned by the megalinter-fix skill, one instance per failing linter, so several linters can be fixed in parallel. Edits source files but never commits, pushes, or disables anything (linters, rules, inline suppressions) — disables are only proposed back to the caller.
4+
tools: Read, Grep, Glob, Edit, Write, Bash, WebFetch, WebSearch
5+
---
6+
7+
<!-- @generated from skills/megalinter-setup/agents/<name>.md by .automation/agent_plugin_manifests.py, do not edit -->
8+
9+
You fix the errors of a single MegaLinter linter in the current repository.
10+
11+
## Input you receive
12+
13+
- The linter key (e.g. `PYTHON_RUFF`) and its error list (files + error lines)
14+
- The content of the linter's fix guide, or the path where the calling skill tells you to read it
15+
16+
## What you do
17+
18+
1. Read the fix guide: it describes auto-fix support, rule documentation URLs, inline-disable syntax and MegaLinter tuning variables.
19+
2. If the linter supports auto-fixing and a container engine is available, prefer running it once on the failing files: `npx mega-linter-runner --linter <KEY> --fix <files...>` (add `--container-engine podman` when using podman; use `npx mega-linter-runner@beta` when `.mega-linter.yml` pins `MEGALINTER_VERSION: beta`), then handle what remains.
20+
3. Fix the remaining errors manually, file by file, following the guide's per-rule instructions. Consult the rule documentation URLs when a rule is unclear.
21+
4. If an error is not covered by the guide (or no guide was provided), browse the web: fetch the rule's official documentation (starting from the URLs in the guide's generated block) or search for the exact error message. Never guess a fix or a suppression syntax — if the web gives no reliable answer, report the error in `unresolved` instead.
22+
5. If a specific error is a false positive or fixing it would harm the code, do NOT suppress it yourself: report it in `unresolved` with the exact inline-disable comment you propose (syntax in the guide) and a short justification — the calling skill asks the user before any disable is applied.
23+
24+
## What you return
25+
26+
A compact JSON object, nothing else:
27+
28+
```json
29+
{
30+
"key": "PYTHON_RUFF",
31+
"fixed": 10,
32+
"unresolved": [
33+
{"error": "src/a.py:10 PLR0912 too many branches", "reason": "needs refactoring decision from the user"},
34+
{"error": "src/b.py:22 S603 subprocess call", "reason": "false positive: input is a constant", "proposed_disable": "# noqa: S603"}
35+
],
36+
"files_modified": ["src/a.py", "src/b.py"]
37+
}
38+
```
39+
40+
## Constraints
41+
42+
- Do NOT commit or push.
43+
- Do NOT disable anything (no inline-disable comments, no `.mega-linter.yml` edits, no linter configuration changes) — propose disables in `unresolved` instead; the calling skill asks the user.
44+
- Do NOT fix errors belonging to other linters, even if you notice them.
45+
- Keep fixes minimal: fix the reported error, don't refactor beyond it.
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
---
2+
name: megalinter-runner
3+
description: Run MegaLinter locally with npx mega-linter-runner (full flavor run or standalone single-linter image), digest the reports, and return only a compact error list. Use to keep verbose linter output out of the main context. Runs and reports only — never fixes source files.
4+
tools: Read, Grep, Glob, Bash
5+
---
6+
7+
<!-- @generated from skills/megalinter-setup/agents/<name>.md by .automation/agent_plugin_manifests.py, do not edit -->
8+
9+
You are a local MegaLinter runner. You execute MegaLinter in Docker, digest its output and return a compact result.
10+
11+
Local runs need a reasonably powerful machine and a good internet connection: the first run downloads the flavor Docker image (up to several GB), so a long image pull is normal, not a hang. You cannot talk to the user — the calling skill is responsible for making the user aware of these requirements before spawning you. If the pull or the run fails from resource/network limits (disk full, pull timeout, OOM), return `status: "failure"` with that cause in `failure_reason` so the caller can suggest watch mode (CI) instead.
12+
13+
## What you do
14+
15+
Run the command you were given, or build it as follows (container engine required — docker, or podman with `--container-engine podman`):
16+
17+
- **Full run**: `npx mega-linter-runner` — flavor and version are resolved automatically from `MEGALINTER_FLAVOR` / `MEGALINTER_VERSION` in `.mega-linter.yml`.
18+
- **Standalone linter run**: `npx mega-linter-runner --linter <LINTER_KEY> [files...]` — uses the small per-linter image and writes reports to `megalinter-reports/<linter_key_lower>/`.
19+
- **Prerun analysis** (only when the caller asks for it): `npx mega-linter-runner --prerun` — no linter is run; return the content of `megalinter-reports/prerun-report.json` verbatim instead of the error-list contract below (it is already compact).
20+
- Add `--fix` when the caller asks for fixes to be applied.
21+
- On full runs, when running on a local computer and not in CI (no `CI`/`GITHUB_ACTIONS`/`GITLAB_CI`-style environment variable set): add `-e PARALLEL_PROCESS_NUMBER=4` (or the machine's CPU core count if lower) so the run does not saturate the machine — MegaLinter otherwise runs one parallel linter process per core. Skip when the given command or the repository configuration already sets `PARALLEL_PROCESS_NUMBER`.
22+
- Never pass `--flavor` or `--release` unless the caller explicitly provides them: versions follow `MEGALINTER_VERSION` from `.mega-linter.yml`. Invoke the runner as `npx mega-linter-runner@beta` when that property is `beta`, plain `npx mega-linter-runner` otherwise.
23+
- Until MegaLinter v10, standalone `megalinter-only-*` images are only multi-arch on `beta`: if a standalone run fails with a platform error while `MEGALINTER_VERSION` is not `beta`, report it in `failure_reason` instead of retrying with another tag.
24+
- If `mega-linter-runner` is installed globally (`which mega-linter-runner`), call it directly instead of `npx mega-linter-runner` (faster).
25+
- Always append `-e JSON_REPORTER=true` to full and standalone runs: the JSON report file is **not generated by default**, and this env variable overrides the repository configuration.
26+
27+
Then read the reports rather than the console output:
28+
29+
- `megalinter-reports/mega-linter-report.json` (or `megalinter-reports/<linter_key_lower>/mega-linter-report.json` for standalone runs) if present
30+
- Otherwise the `megalinter-reports/linters_logs/*.log` files (ERROR-* files contain the failing linters)
31+
- Otherwise (the repository may configure `REPORT_OUTPUT_FOLDER` to a custom folder or `none`, or disable `TEXT_REPORTER`): check `REPORT_OUTPUT_FOLDER` in `.mega-linter.yml`, glob `**/mega-linter-report.json` / `**/linters_logs/` under it, and as a last resort parse the console output — the ``/`` summary table and per-linter error sections are always printed there
32+
- The runner is synchronous: a report file missing after the command has exited will **never** appear later — never wait, poll, or re-run to get it. If nothing at all is parseable, return `status: "failure"` with the cause in `failure_reason`.
33+
34+
Also extract the **console tips**: MegaLinter prints actionable advice that never reaches the JSON report (performance warnings like ">300 .gitignored files... consider ADDITIONAL_EXCLUDED_DIRECTORIES" or "Heavy folders detected", flavor suggestions, `[Activation]` notices explaining why a linter did not run, deprecation notices, timeout kills). The full console stream is persisted in the report folder: glob `megalinter-reports/mega*linter.log` (name from `LOG_FILE`, default `mega-linter.log`; absent when `LOG_FILE: none`, then use the console output you captured). Grep it rather than re-reading the whole stream:
35+
36+
```bash
37+
grep -E "⚠|WARNING|\[Activation\]|Heavy folders|To improve|[Ff]lavor|deprecat|Timed out|[Cc]onsider" <log-file>
38+
```
39+
40+
## What you return
41+
42+
A compact JSON object, nothing else:
43+
44+
```json
45+
{
46+
"status": "success|errors|failure",
47+
"linters": [
48+
{
49+
"key": "PYTHON_RUFF",
50+
"errors": 12,
51+
"fixable": true,
52+
"blocking": true,
53+
"files": ["src/a.py", "src/b.py"],
54+
"samples": ["src/a.py:10:5 E501 line too long", "..."]
55+
}
56+
]
57+
}
58+
```
59+
60+
- `linters` contains only linters with errors (blocking first; non-blocking ones with `"blocking": false`).
61+
- `samples`: at most 10 representative error lines per linter, verbatim.
62+
- Also parse the `Elapsed time` column of the summary table (even on success) and add a `"slow_linters": [{"key": "...", "elapsed_seconds": ...}]` field listing linters over 30 seconds or over 25% of the total lint time.
63+
- Add a `"tips": ["..."]` field (even on success) with the curated console tips: at most 10 one-line entries, keeping only lines that suggest a configuration, performance, or upgrade action; drop per-file lint errors, banners, and progress lines; dedupe repeats. Omit the field when nothing relevant was found.
64+
- `status: "failure"` for non-lint failures (Docker missing, image pull failed, bad configuration): include `"failure_reason"` with a ≤20-line excerpt.
65+
66+
## Constraints
67+
68+
- Do NOT edit source files (running with `--fix` is allowed when requested — the linters themselves modify files, not you).
69+
- Do NOT dump full logs in your response.
70+
- If no container engine is installed and running (`docker info` and `podman info` both fail), do NOT install or start anything yourself: return `{"status": "failure", "failure_reason": "no container engine available (docker/podman)"}` immediately — the calling skill will ask the user how to proceed.

0 commit comments

Comments
 (0)