feat(copilot): default integration to skills (#3976)

* feat(copilot): default integration to skills

Make Copilot skills the default while retaining the commands layout behind --integration-options="--commands". Preserve historical project layouts and validate conflicting mode flags before switch teardown.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 930d846b-8921-44ef-9f45-3e77c036b6b5

* fix(copilot): preserve layout state during migration

Keep target integration options isolated from fallback state, prefer the Copilot manifest when resolving layouts, and update dispatch coverage for the skills-first default.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 930d846b-8921-44ef-9f45-3e77c036b6b5

---------

Copilot-Session: 930d846b-8921-44ef-9f45-3e77c036b6b5
This commit is contained in:
Manfred Riem
2026-08-04 06:15:40 -05:00
committed by GitHub
co-authored by Copilot App
parent ab468c4db7
commit 0fbd99d594
12 changed files with 564 additions and 263 deletions
+2 -2
View File
@@ -70,8 +70,8 @@ def with_integration_setting(
# ``script_type`` changes (``parsed_options`` and ``raw_options`` both
# None), the previously-stored ``parsed_options`` are retained above, so
# deriving the separator from the argument (None) would drop an
# options-dependent separator (e.g. Copilot ``--skills`` -> "-") back to
# the default ".".
# options-dependent separator (e.g. Copilot ``--commands`` -> ".") back to
# the default "-".
current["invoke_separator"] = integration.effective_invoke_separator(
current.get("parsed_options"), project_root
)
@@ -331,6 +331,14 @@ def integration_switch(
selected_script = _resolve_script_type(project_root, script)
# Resolve and validate target options before uninstalling the current
# integration. Invalid options must not leave the project partially
# switched with the previous integration already removed.
target_raw_options, target_parsed_options = _resolve_integration_options(
target_integration, current, target, integration_options
)
target_integration.is_skills_mode(target_parsed_options, project_root)
# Phase 1: Uninstall current integration (if any)
if installed_key:
current_integration = get_integration(installed_key)
@@ -403,7 +411,10 @@ def integration_switch(
fallback_key = installed_keys[0]
fallback_integration = get_integration(fallback_key)
if fallback_integration is not None:
raw_options, parsed_options = _resolve_integration_options(
(
fallback_raw_options,
fallback_parsed_options,
) = _resolve_integration_options(
fallback_integration, current, fallback_key, None
)
_set_default_integration_or_exit(
@@ -412,8 +423,8 @@ def integration_switch(
fallback_key,
fallback_integration,
installed_keys,
raw_options=raw_options,
parsed_options=parsed_options,
raw_options=fallback_raw_options,
parsed_options=fallback_parsed_options,
)
else:
_write_integration_json(
@@ -423,13 +434,6 @@ def integration_switch(
_remove_integration_json(project_root)
current = _read_integration_json(project_root)
# Build parsed options from --integration-options so the integration
# can determine its effective invoke separator before shared infra
# is installed.
raw_options, parsed_options = _resolve_integration_options(
target_integration, current, target, integration_options
)
# Refresh shared infrastructure to the current CLI version. Switching
# integrations is exactly when stale vendored shared scripts (e.g.
# update-agent-context.sh that pre-dates the target integration's
@@ -445,11 +449,11 @@ def integration_switch(
force=refresh_shared_infra,
refresh_managed=True,
invoke_separator=_invoke_separator_for_integration(
target_integration, current, target, parsed_options,
target_integration, current, target, target_parsed_options,
project_root=project_root,
),
invoke_prefix=_invoke_prefix_for_integration(
target_integration, target, parsed_options, project_root
target_integration, target, target_parsed_options, project_root
),
refresh_hint=(
"To overwrite customizations, re-run with "
@@ -471,14 +475,14 @@ def integration_switch(
target_integration.key,
target_integration.config,
project_root,
parsed_options,
target_parsed_options,
)
try:
target_integration.setup(
project_root, manifest,
parsed_options=parsed_options,
parsed_options=target_parsed_options,
script_type=selected_script,
raw_options=raw_options,
raw_options=target_raw_options,
events=events_map,
)
manifest.save()
@@ -489,8 +493,8 @@ def integration_switch(
target_integration,
_dedupe_integration_keys([*installed_keys, target_integration.key]),
script_type=selected_script,
raw_options=raw_options,
parsed_options=parsed_options,
raw_options=target_raw_options,
parsed_options=target_parsed_options,
)
except Exception as exc:
+2 -2
View File
@@ -225,8 +225,8 @@ class IntegrationBase(ABC):
on-disk layout to avoid silently migrating an existing project to a
different mode. The default ignores it.
The default (command-first integrations, e.g. Copilot's default
layout) is skills mode only when ``--skills`` was requested.
The default for command-first integrations is skills mode only when
``--skills`` was requested.
``SkillsIntegration`` overrides this to return ``True`` by default;
skills-first integrations that expose a legacy opt-out (e.g. Bob)
override it to honor their own flag.
+145 -61
View File
@@ -1,13 +1,19 @@
"""Copilot integration — GitHub Copilot in VS Code.
Copilot has several unique behaviors compared to standard markdown agents:
Copilot supports two layouts:
- Skills are the default and use ``speckit-<name>/SKILL.md`` directories under
``.github/skills/``
- ``--commands`` uses ``.agent.md`` files, companion ``.prompt.md`` files, and
a VS Code settings merge
The two modes are mutually exclusive. The commands layout remains supported,
but is no longer the preferred default.
The commands layout has several unique behaviors compared to standard markdown
agents:
- Commands use ``.agent.md`` extension (not ``.md``)
- Each command gets a companion ``.prompt.md`` file in ``.github/prompts/``
- Installs ``.vscode/settings.json`` with prompt file recommendations
When ``--skills`` is passed via ``--integration-options``, Copilot scaffolds
commands as ``speckit-<name>/SKILL.md`` directories under ``.github/skills/``
instead. The two modes are mutually exclusive.
"""
from __future__ import annotations
@@ -19,9 +25,24 @@ import warnings
from pathlib import Path
from typing import Any
import typer
from ..base import IntegrationBase, IntegrationOption, SkillsIntegration
from ..manifest import IntegrationManifest
_COPILOT_CORE_COMMANDS = {
"analyze",
"checklist",
"clarify",
"constitution",
"converge",
"implement",
"plan",
"specify",
"tasks",
"taskstoissues",
}
def _copilot_executable() -> str:
"""Return the executable name for Copilot CLI on this platform.
@@ -57,22 +78,24 @@ def _allow_all() -> bool:
return True
def _warn_legacy_markdown_default() -> None:
"""Warn that Copilot's default markdown scaffold is being phased out."""
warnings.warn(
"Copilot legacy markdown mode is deprecated and will stop being the "
'default in a future Spec Kit release; pass --integration-options "--skills" '
"to opt in to Copilot skills mode now.",
UserWarning,
stacklevel=3,
)
def _validate_mode_options(parsed_options: dict[str, Any] | None) -> None:
"""Reject the two explicit Copilot layout selectors used together."""
opts = parsed_options or {}
if opts.get("skills") and opts.get("commands"):
from ..._console import console
console.print(
"[red]Error:[/red] --skills and --commands are mutually exclusive; "
"pass only one."
)
raise typer.Exit(1)
class _CopilotSkillsHelper(SkillsIntegration):
"""Internal helper used when Copilot is scaffolded in skills mode.
Not registered in the integration registry — only used as a delegate
by ``CopilotIntegration`` when ``--skills`` is passed.
Not registered in the integration registry — only used as the default
skills-layout delegate by ``CopilotIntegration``.
"""
key = "copilot"
@@ -94,13 +117,11 @@ class _CopilotSkillsHelper(SkillsIntegration):
class CopilotIntegration(IntegrationBase):
"""Integration for GitHub Copilot (VS Code IDE + CLI).
The IDE integration (``requires_cli: False``) installs ``.agent.md``
command files. Workflow dispatch additionally requires the
``copilot`` CLI to be installed separately.
When ``--skills`` is passed via ``--integration-options``, commands
are scaffolded as ``speckit-<name>/SKILL.md`` under ``.github/skills/``
instead of the default ``.agent.md`` + ``.prompt.md`` layout.
The default IDE integration (``requires_cli: False``) installs skills under
``.github/skills/``. Pass ``--commands`` via ``--integration-options`` to
install the supported ``.agent.md`` + ``.prompt.md`` layout instead.
Workflow dispatch additionally requires the ``copilot`` CLI to be installed
separately.
"""
key = "copilot"
@@ -117,6 +138,7 @@ class CopilotIntegration(IntegrationBase):
"args": "$ARGUMENTS",
"extension": ".agent.md",
}
invoke_separator = "-"
CANONICAL_TO_NATIVE = {
"session_start": "sessionStart",
@@ -132,38 +154,91 @@ class CopilotIntegration(IntegrationBase):
events_format = "copilot-json"
# Mutable flag set by setup() — indicates the active scaffolding mode.
_skills_mode: bool = False
_skills_mode: bool = True
def effective_invoke_separator(
self,
parsed_options: dict[str, Any] | None = None,
project_root: Path | None = None,
) -> str:
"""Return ``"-"`` when skills mode is requested, ``"."`` otherwise."""
if parsed_options and parsed_options.get("skills"):
return "-"
if self._skills_mode:
return "-"
return self.invoke_separator
"""Return the separator for the resolved Copilot layout."""
return "-" if self.is_skills_mode(parsed_options, project_root) else "."
def is_skills_mode(
self,
parsed_options: dict[str, Any] | None = None,
project_root: Path | None = None,
) -> bool:
"""Copilot is skills mode when ``--skills`` was requested.
"""Copilot defaults to skills; ``--commands`` opts into commands mode.
On the init path ``setup()`` has already recorded the choice in
``self._skills_mode``; on the ``use``/``install`` path (where no
``setup()`` runs) the signal comes from *parsed_options* (#3550), which
round-trips because ``--skills`` is persisted in the stored options.
Explicit flags override on-disk detection. Without a flag, existing
projects retain their managed Spec Kit layout while fresh projects use
skills. This prevents ``use`` and ``upgrade`` from silently migrating
projects created before skills became the default.
"""
if parsed_options and parsed_options.get("skills"):
opts = parsed_options or {}
_validate_mode_options(opts)
if opts.get("skills"):
return True
return self._skills_mode
if opts.get("commands"):
return False
if project_root is not None:
project_root = Path(project_root)
manifest_path = (
project_root
/ ".specify"
/ "integrations"
/ "copilot.manifest.json"
)
if manifest_path.is_file():
try:
manifest_files = IntegrationManifest.load(
self.key, Path(project_root)
).files
except (OSError, ValueError):
manifest_files = None
if manifest_files is not None and any(
path.startswith(".github/skills/speckit-")
and path.endswith("/SKILL.md")
for path in manifest_files
):
return True
if manifest_files is not None and any(
path.startswith(".github/agents/speckit.")
and path.endswith(".agent.md")
for path in manifest_files
):
return False
github_dir = project_root / ".github"
has_managed_skills = any(
(
github_dir
/ "skills"
/ f"speckit-{command}"
/ "SKILL.md"
).is_file()
for command in _COPILOT_CORE_COMMANDS
)
has_managed_commands = any(
(
github_dir
/ "agents"
/ f"speckit.{command}.agent.md"
).is_file()
or (
github_dir
/ "prompts"
/ f"speckit.{command}.prompt.md"
).is_file()
for command in _COPILOT_CORE_COMMANDS
)
if has_managed_commands and not has_managed_skills:
return False
return True
def invoke_separator_for_mode(self, skills_enabled: bool) -> str:
"""Skills projects render ``/speckit-<cmd>``; default markdown ``.``.
"""Skills projects render ``/speckit-<cmd>``; commands use ``.``.
Copilot is dual-layout, so — like Bob — the command-reference
separator depends on the persisted ``ai_skills`` state rather than a
@@ -171,7 +246,7 @@ class CopilotIntegration(IntegrationBase):
Copilot skills project consistent with ``build_command_invocation``
(which emits ``/speckit-<stem>``).
"""
return "-" if skills_enabled else self.invoke_separator
return "-" if skills_enabled else "."
@classmethod
def options(cls) -> list[IntegrationOption]:
@@ -184,7 +259,22 @@ class CopilotIntegration(IntegrationBase):
"--skills",
is_flag=True,
default=False,
help="Scaffold commands as agent skills (speckit-<name>/SKILL.md) instead of .agent.md files",
help=(
"Force the default skills layout (.github/skills/), "
"overriding on-disk auto-detection"
),
),
)
opts.append(
IntegrationOption(
"--commands",
is_flag=True,
default=False,
help=(
"Scaffold .github/agents/*.agent.md commands with companion "
".github/prompts/*.prompt.md files instead of the default "
"skills layout"
),
),
)
return opts
@@ -228,8 +318,8 @@ class CopilotIntegration(IntegrationBase):
def build_command_invocation(self, command_name: str, args: str = "") -> str:
"""Build the native invocation for a Copilot command.
Default mode: agents are not slash-commands — return args as prompt.
Skills mode: ``/speckit-<stem>`` slash-command dispatch.
Commands mode: agents are not slash-commands — return args as prompt.
Skills mode (default): ``/speckit-<stem>`` slash-command dispatch.
"""
if self._skills_mode:
stem = command_name
@@ -266,15 +356,11 @@ class CopilotIntegration(IntegrationBase):
if stem.startswith("speckit."):
stem = stem[len("speckit."):]
# Detect skills mode from project layout when not set via setup()
skills_mode = self._skills_mode
if not skills_mode and project_root:
skills_dir = project_root / ".github" / "skills"
if skills_dir.is_dir():
skills_mode = any(
d.is_dir() and (d / "SKILL.md").is_file()
for d in skills_dir.glob("speckit-*")
)
skills_mode = (
self.is_skills_mode(project_root=project_root)
if project_root
else self._skills_mode
)
if skills_mode:
prompt = "/speckit-" + stem.replace(".", "-")
@@ -366,20 +452,18 @@ class CopilotIntegration(IntegrationBase):
parsed_options: dict[str, Any] | None = None,
**opts: Any,
) -> list[Path]:
"""Install copilot commands, companion prompts, and VS Code settings.
"""Install Copilot skills or the opt-in commands layout.
When ``parsed_options["skills"]`` is truthy, delegates to skills
scaffolding (``speckit-<name>/SKILL.md`` under ``.github/skills/``).
Otherwise uses the default ``.agent.md`` + ``.prompt.md`` layout.
Skills are the default. ``parsed_options["commands"]`` selects
``.agent.md`` files, companion prompts, and the VS Code settings merge.
Existing managed command layouts are preserved when no mode is explicit.
"""
parsed_options = parsed_options or {}
self._skills_mode = bool(parsed_options.get("skills"))
self._skills_mode = self.is_skills_mode(parsed_options, project_root)
if self._skills_mode:
created = self._setup_skills(project_root, manifest, parsed_options, **opts)
else:
if "skills" not in parsed_options:
_warn_legacy_markdown_default()
created = self._setup_default(project_root, manifest, parsed_options, **opts)
created = self._setup_commands(project_root, manifest, parsed_options, **opts)
# Install agent runtime events
event_files = self.emit_events(
@@ -388,14 +472,14 @@ class CopilotIntegration(IntegrationBase):
created.extend(event_files)
return created
def _setup_default(
def _setup_commands(
self,
project_root: Path,
manifest: IntegrationManifest,
parsed_options: dict[str, Any] | None = None,
**opts: Any,
) -> list[Path]:
"""Default mode: .agent.md + .prompt.md + VS Code settings merge."""
"""Commands mode: .agent.md + .prompt.md + VS Code settings merge."""
project_root_resolved = project_root.resolve()
if manifest.project_root != project_root_resolved:
raise ValueError(