copilot: symlink command directories to ~/.copilot/<plugin>/commands/

- Commands now symlink to ~/.copilot/comprehensive-review/commands/ instead of ~/.copilot/commands/
- Adapter emits command files to .copilot/commands/<plugin>/ directory structure
- Installer symlinks each command directory to ~/.copilot/<plugin>/commands/
- This matches Copilot CLI's discovery path for plugin commands

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
GitHub Copilot
2026-05-25 14:06:31 -05:00
parent 9a178cb044
commit f5ad900ee9
13 changed files with 155 additions and 15 deletions
@@ -0,0 +1,10 @@
{
"sessionID": "ses_1a0c1bc8affexjd7idgHFAovWT",
"updatedAt": "2026-05-25T13:49:53.610Z",
"sources": {
"background-task": {
"state": "idle",
"updatedAt": "2026-05-25T13:49:53.610Z"
}
}
}
+28
View File
@@ -0,0 +1,28 @@
aposd
aposd-audit
aposd-critique
brainstorming
dispatching-parallel-agents
enhance-prompt
executing-plans
find-skills
finishing-a-development-branch
gh-cli
git-commit
impeccable
karpathy-guidelines
react-components
receiving-code-review
repo-roast
requesting-code-review
shadcn-ui
stitch-design
stitch-loop
subagent-driven-development
systematic-debugging
test-driven-development
using-git-worktrees
using-superpowers
verification-before-completion
writing-plans
writing-skills
+2 -2
View File
@@ -26,7 +26,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; 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
@@ -141,7 +141,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
+1 -2
View File
@@ -70,8 +70,7 @@ Claude Code automatically selects and coordinates the appropriate agents based o
| Command | Description |
| ----------------------------------- | -------------------------- |
| `/comprehensive-review:full-review` | Multi-perspective analysis |
| `/comprehensive-review:pr-enhance` | Enhance pull requests |
| `/comprehensive-review:full-review` | Run the comprehensive review workflow |
### Debugging & Troubleshooting
+14
View File
@@ -0,0 +1,14 @@
brainstorming
dispatching-parallel-agents
executing-plans
finishing-a-development-branch
receiving-code-review
requesting-code-review
subagent-driven-development
systematic-debugging
test-driven-development
using-git-worktrees
using-superpowers
verification-before-completion
writing-plans
writing-skills
+1
View File
@@ -466,6 +466,7 @@ class HarnessAdapter(ABC):
"WebFetch": "fetch",
"WebSearch": "search",
"TodoWrite": "todo",
"AskUserQuestion": "question",
}
out = body
for camel, replacement in replacements.items():
+2 -2
View File
@@ -94,7 +94,7 @@ CAPABILITIES: dict[str, Capability] = {
display_name="GitHub Copilot",
skills_native=True,
agents_native=True,
commands_native=False,
commands_native=True,
plugin_marketplace=False,
parallel_agents=False,
tool_allowlist_per_agent=True,
@@ -107,7 +107,7 @@ CAPABILITIES: dict[str, Capability] = {
skill_body_max_bytes=_NO_CAP,
tool_name_case="lowercase",
bare_model_aliases=False,
notes="Emits Markdown agent profiles to .github/agents/ and SKILL.md skills to .github/skills/. Copilot discovers from any clone (files are committed). Also discovers from ~/.copilot/agents/ (user-level) and .github-private/ (org-level).",
notes="Emits Markdown agent profiles to .copilot/agents/, SKILL.md skills to .copilot/skills/, and slash-command prompt files to .copilot/commands/. Copilot discovers from the repo or from ~/.copilot/ at user level.",
),
"cursor": Capability(
harness_id="cursor",
+4 -1
View File
@@ -34,7 +34,7 @@ _HARNESS_TARGETS = {
"cursor": [".cursor", ".cursor-plugin"],
"opencode": [".opencode", "opencode.json"],
"gemini": ["commands", "agents", "skills"],
"copilot": [".copilot/agents", ".copilot/skills"],
"copilot": [".copilot/agents", ".copilot/skills", ".copilot/commands"],
}
@@ -165,6 +165,9 @@ def prune_orphans(harness_id: str, output_root: Path, written: set[Path]) -> lis
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 (
+13 -3
View File
@@ -13,6 +13,7 @@ GENERATED_ROOT = REPO_ROOT / ".copilot"
ARTIFACT_GLOBS = {
"agents": "*.agent.md",
"skills": "*",
"commands": "*",
}
@@ -58,7 +59,9 @@ def _generated_artifacts(repo_root: Path) -> list[tuple[str, Path]]:
for src in sorted(source_dir.glob(pattern)):
if subdir == "skills" and not src.is_dir():
continue
if subdir != "skills" and not src.is_file():
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()))
return artifacts
@@ -100,8 +103,15 @@ def install(
return report
for subdir, src in artifacts:
dst = config_dir / subdir / src.name
_link_one(src, dst, force=force, report=report)
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
+16
View File
@@ -1015,6 +1015,22 @@ class TestCopilotAdapter:
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
):
+8 -3
View File
@@ -10,10 +10,14 @@ 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):
@@ -39,11 +43,12 @@ def test_install_creates_idempotent_symlinks(tmp_path: Path):
second = install(repo_root=repo_root, config_dir=config_dir)
assert first.ok
assert first.linked == 2
assert first.linked == 3
assert second.ok
assert second.unchanged == 2
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 / "commands" / "demo").is_symlink()
def test_install_refuses_to_overwrite_real_files(tmp_path: Path):
@@ -95,7 +100,7 @@ def test_uninstall_removes_only_repo_owned_symlinks(tmp_path: Path):
report = uninstall(repo_root=repo_root, config_dir=config_dir)
assert report.ok
assert report.removed == 2
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"
+20
View File
@@ -297,6 +297,26 @@ class TestCopilotRoundTrip:
f"skill count mismatch: source={_source_skill_count()} 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 = []
+36 -2
View File
@@ -559,9 +559,10 @@ def validate_gemini(report: Report) -> None:
def validate_copilot(report: Report) -> None:
"""Validate Copilot agent markdown files under WORKTREE/.copilot/agents.
"""Validate Copilot agent, skill, and command markdown files under WORKTREE/.copilot.
Checks that every .agent.md file has valid frontmatter with required fields.
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
@@ -679,6 +680,39 @@ def validate_copilot(report: Report) -> None:
remediation="Set `description` to a non-empty string in the skill frontmatter.",
)
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