mirror of
https://github.com/github/spec-kit.git
synced 2026-08-15 14:02:04 +02:00
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:
co-authored by
Copilot App
parent
ab468c4db7
commit
0fbd99d594
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user