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
+16 -24
View File
@@ -260,13 +260,13 @@ The base classes handle most work automatically. Override only when the agent de
| Override | When to use | Example |
|---|---|---|
| `command_filename(template_name)` | Custom file naming or extension | Copilot → `speckit.{name}.agent.md` |
| `options()` | Integration-specific CLI flags via `--integration-options` | Codex → `--skills` flag, Copilot → `--skills` flag |
| `setup()` | Custom install logic (companion files, settings merge) | Copilot → `.agent.md` + `.prompt.md` + `.vscode/settings.json` (default) or `speckit-<name>/SKILL.md` (skills mode) |
| `options()` | Integration-specific CLI flags via `--integration-options` | Codex → `--skills` flag, Copilot → `--commands` flag |
| `setup()` | Custom install logic (companion files, settings merge) | Copilot → `speckit-<name>/SKILL.md` (default) or `.agent.md` + `.prompt.md` + `.vscode/settings.json` (`--commands`) |
| `teardown()` | Custom uninstall logic | Rarely needed; base handles manifest-tracked files |
**Example — Copilot (fully custom `setup`):**
Copilot extends `IntegrationBase` directly because it creates `.agent.md` commands, companion `.prompt.md` files, and merges `.vscode/settings.json`. It also supports a `--skills` mode that scaffolds `speckit-<name>/SKILL.md` under `.github/skills/` using composition with an internal `_CopilotSkillsHelper`. See `src/specify_cli/integrations/copilot/__init__.py` for the full implementation.
Copilot extends `IntegrationBase` directly because it supports two layouts. It scaffolds `speckit-<name>/SKILL.md` under `.github/skills/` by default using composition with an internal `_CopilotSkillsHelper`. Its `--commands` mode creates `.agent.md` commands, companion `.prompt.md` files, and merges `.vscode/settings.json`. See `src/specify_cli/integrations/copilot/__init__.py` for the full implementation.
### 7. Update Devcontainer files (Optional)
@@ -415,36 +415,28 @@ Some agents require custom processing beyond the standard template transformatio
### Copilot Integration
GitHub Copilot has unique requirements:
GitHub Copilot uses skills by default, scaffolded as
`speckit-<name>/SKILL.md` under `.github/skills/`.
- Commands use `.agent.md` extension (not `.md`)
**Commands mode (`--commands`):** Copilot also supports a commands-based layout
via `--integration-options="--commands"`. When enabled:
- Commands use `.agent.md` extension under `.github/agents/`
- Each command gets a companion `.prompt.md` file in `.github/prompts/`
- Installs `.vscode/settings.json` with prompt file recommendations
- Context file lives at `.github/copilot-instructions.md`
- `.vscode/settings.json` is merged with prompt file recommendations
- `build_command_invocation()` returns bare args for `--agent` dispatch
Implementation: Extends `IntegrationBase` with custom `setup()` method that:
1. Processes templates with `process_template()`
2. Generates companion `.prompt.md` files
3. Merges VS Code settings
**Skills mode (`--skills`):** Copilot also supports an alternative skills-based layout
via `--integration-options="--skills"`. When enabled:
- Commands are scaffolded as `speckit-<name>/SKILL.md` under `.github/skills/`
- No companion `.prompt.md` files are generated
- No `.vscode/settings.json` merge
- `post_process_skill_content()` injects a `mode: speckit.<stem>` frontmatter field
- `build_command_invocation()` returns `/speckit-<stem>` instead of bare args
In the default skills mode, no companion prompts or VS Code settings merge are
created, and `build_command_invocation()` returns `/speckit-<stem>`.
The two modes are mutually exclusive — a project uses one or the other:
```bash
# Default mode: .agent.md agents + .prompt.md companions + settings merge
# Default skills mode: speckit-<name>/SKILL.md under .github/skills/
specify init my-project --integration copilot
# Skills mode: speckit-<name>/SKILL.md under .github/skills/
specify init my-project --integration copilot --integration-options="--skills"
# Commands mode: .agent.md agents + .prompt.md companions + settings merge
specify init my-project --integration copilot --integration-options="--commands"
```
### Forge Integration
+3 -2
View File
@@ -20,7 +20,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
| [Firebender](https://firebender.com/) | `firebender` | IDE-based agent for Android Studio / IntelliJ |
| [Forge](https://forgecode.dev/) | `forge` | |
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | |
| [GitHub Copilot](https://code.visualstudio.com/) | `copilot` | Defaults to legacy markdown mode: `.agent.md` command files under `.github/agents/`, companion `.prompt.md` files under `.github/prompts/`, and a `.vscode/settings.json` merge. Pass `--integration-options="--skills"` to scaffold skills as `speckit-<command>/SKILL.md` under `.github/skills/` instead. Legacy markdown mode is deprecated and will stop being the default in a future release. |
| [GitHub Copilot](https://code.visualstudio.com/) | `copilot` | Skills-based by default; installs `speckit-<command>/SKILL.md` under `.github/skills/`. Pass `--integration-options="--commands"` to use the supported commands layout: `.agent.md` files under `.github/agents/`, companion `.prompt.md` files under `.github/prompts/`, and a `.vscode/settings.json` merge. |
| [Goose](https://goose-docs.ai/) | `goose` | Uses YAML recipe format in `.goose/recipes/` |
| [Grok Build](https://docs.x.ai/build/overview) | `grok` | Skills-based integration; installs skills into `.grok/skills` and invokes them as `/speckit-<command>` |
| [Hermes](https://github.com/NousResearch/hermes-agent) | `hermes` | Skills-based integration; installs skills globally into `~/.hermes/skills/` |
@@ -234,7 +234,8 @@ Some integrations accept additional options via `--integration-options`:
| ----------- | ------------------- | -------------------------------------------------------------- |
| `generic` | `--commands-dir` | Required. Directory for command files |
| `kimi` | `--migrate-legacy` | Migrate legacy `.kimi/skills/` installs to `.kimi-code/skills/` (including dotted→hyphenated skill naming, e.g. `speckit.xxx` → `speckit-xxx`) |
| `copilot` | `--skills` | Scaffold commands as agent skills (`speckit-<command>/SKILL.md` under `.github/skills/`, invoked as `/speckit-<command>`) instead of the default legacy markdown mode (`.github/agents/*.agent.md` plus `.github/prompts/*.prompt.md` and a `.vscode/settings.json` merge). Without this flag, install warns that legacy markdown mode is deprecated. |
| `copilot` | `--commands` | Scaffold `.github/agents/*.agent.md` commands with `.github/prompts/*.prompt.md` companions and merge `.vscode/settings.json` instead of using the default skills layout. |
| `copilot` | `--skills` | Force the default skills layout, overriding an existing commands layout during an explicit migration. |
Example:
+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(
+25 -16
View File
@@ -69,8 +69,11 @@ class TestInitIntegrationFlag:
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists()
assert (project / ".github" / "prompts" / "speckit.plan.prompt.md").exists()
assert (
project / ".github" / "skills" / "speckit-plan" / "SKILL.md"
).exists()
assert not (project / ".github" / "agents").exists()
assert not (project / ".github" / "prompts").exists()
assert (project / ".specify" / "scripts" / "bash" / "common.sh").exists()
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
@@ -78,6 +81,7 @@ class TestInitIntegrationFlag:
opts = json.loads((project / ".specify" / "init-options.json").read_text(encoding="utf-8"))
assert opts["integration"] == "copilot"
assert opts["ai_skills"] is True
# init must not leave any legacy agent-context keys in init-options.json
assert "context_file" not in opts
@@ -111,7 +115,9 @@ class TestInitIntegrationFlag:
assert result.exit_code == 0, result.output
assert f"defaulting to '{specify_cli.DEFAULT_INIT_INTEGRATION}'" in result.output
assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists()
assert (
project / ".github" / "skills" / "speckit-plan" / "SKILL.md"
).exists()
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
assert data["integration"] == specify_cli.DEFAULT_INIT_INTEGRATION
@@ -250,7 +256,9 @@ class TestInitIntegrationFlag:
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists()
assert (
project / ".github" / "skills" / "speckit-plan" / "SKILL.md"
).exists()
def test_init_optional_preset_failure_reports_target_and_continues(
self, tmp_path, monkeypatch
@@ -1373,7 +1381,7 @@ class TestSharedInfraCommandRefs:
assert "/speckit.specify" not in script_content
def test_full_init_copilot_resolves_page_templates(self, tmp_path):
"""Full CLI init with Copilot (markdown agent) produces dot refs in page templates."""
"""Default Copilot skills mode produces hyphen refs in page templates."""
from typer.testing import CliRunner
from specify_cli import app
@@ -1395,27 +1403,28 @@ class TestSharedInfraCommandRefs:
plan = project / ".specify" / "templates" / "plan-template.md"
content = plan.read_text(encoding="utf-8")
assert "/speckit.plan" in content, "Copilot (markdown) should use /speckit.plan"
assert "/speckit-plan" in content, "Copilot skills should use /speckit-plan"
assert "/speckit.plan" not in content
assert "__SPECKIT_COMMAND_" not in content
script_content = self._combined_script_content(project, "sh")
assert "/speckit.specify" in script_content
assert "/speckit-specify" not in script_content
assert "/speckit-specify" in script_content
assert "/speckit.specify" not in script_content
def test_full_init_copilot_skills_resolves_page_templates(self, tmp_path):
"""Full CLI init with Copilot --skills produces hyphen refs in page templates."""
def test_full_init_copilot_commands_resolves_page_templates(self, tmp_path):
"""Copilot --commands produces dot refs in page templates."""
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
project = tmp_path / "init-copilot-skills"
project = tmp_path / "init-copilot-commands"
old_cwd = os.getcwd()
try:
os.chdir(tmp_path)
result = runner.invoke(app, [
"init", str(project),
"--integration", "copilot",
"--integration-options", "--skills",
"--integration-options", "--commands",
"--script", "sh",
"--ignore-agent-tools",
], catch_exceptions=False)
@@ -1426,13 +1435,13 @@ class TestSharedInfraCommandRefs:
plan = project / ".specify" / "templates" / "plan-template.md"
content = plan.read_text(encoding="utf-8")
assert "/speckit-plan" in content, "Copilot --skills should use /speckit-plan"
assert "/speckit.plan" not in content, "dot-notation leaked into Copilot skills page template"
assert "/speckit.plan" in content, "Copilot --commands should use /speckit.plan"
assert "/speckit-plan" not in content
assert "__SPECKIT_COMMAND_" not in content
script_content = self._combined_script_content(project, "sh")
assert "/speckit-specify" in script_content
assert "/speckit.specify" not in script_content
assert "/speckit.specify" in script_content
assert "/speckit-specify" not in script_content
class TestIntegrationCatalogDiscoveryCLI:
+4 -4
View File
@@ -426,7 +426,7 @@ class _RunCapture:
return _Result()
def test_copilot_dispatch_command_includes_extra_args(monkeypatch):
def test_copilot_commands_dispatch_includes_extra_args(monkeypatch):
"""Locks the bypass fix: `CopilotIntegration.dispatch_command`
must honour `SPECKIT_INTEGRATION_COPILOT_EXTRA_ARGS`, not just `build_exec_args`.
"""
@@ -441,9 +441,9 @@ def test_copilot_dispatch_command_includes_extra_args(monkeypatch):
"SPECKIT_INTEGRATION_COPILOT_EXTRA_ARGS", "--allow-tool 'shell(echo)'"
)
CopilotIntegration().dispatch_command(
"speckit.plan", args="body", stream=False
)
integration = CopilotIntegration()
integration._skills_mode = False
integration.dispatch_command("speckit.plan", args="body", stream=False)
assert capture.captured_args is not None
# Hook inserted between `-p prompt` and the canonical Copilot flags.
+199 -120
View File
@@ -2,16 +2,16 @@
import json
import os
import warnings
import pytest
import typer
import yaml
from specify_cli.integrations import get_integration
from specify_cli.integrations.manifest import IntegrationManifest
class TestCopilotIntegration:
class TestCopilotCommandsMode:
def test_copilot_key_and_config(self):
copilot = get_integration("copilot")
assert copilot is not None
@@ -28,7 +28,7 @@ class TestCopilotIntegration:
from specify_cli.integrations.copilot import CopilotIntegration
copilot = CopilotIntegration()
m = IntegrationManifest("copilot", tmp_path)
created = copilot.setup(tmp_path, m)
created = copilot.setup(tmp_path, m, parsed_options={"commands": True})
assert len(created) > 0
agent_files = [f for f in created if ".agent." in f.name]
assert len(agent_files) > 0
@@ -36,36 +36,11 @@ class TestCopilotIntegration:
assert f.parent == tmp_path / ".github" / "agents"
assert f.name.endswith(".agent.md")
def test_setup_warns_legacy_markdown_default_is_deprecated(self, tmp_path):
from specify_cli.integrations.copilot import CopilotIntegration
copilot = CopilotIntegration()
m = IntegrationManifest("copilot", tmp_path)
with pytest.warns(UserWarning, match="Copilot legacy markdown mode is deprecated"):
created = copilot.setup(tmp_path, m)
assert any(f.name.endswith(".agent.md") for f in created)
def test_skills_setup_does_not_warn_about_legacy_default(self, tmp_path):
from specify_cli.integrations.copilot import CopilotIntegration
copilot = CopilotIntegration()
m = IntegrationManifest("copilot", tmp_path)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
created = copilot.setup(tmp_path, m, parsed_options={"skills": True})
assert not any(
"Copilot legacy markdown mode is deprecated" in str(item.message)
for item in caught
)
assert any(f.name == "SKILL.md" for f in created)
def test_setup_creates_companion_prompts(self, tmp_path):
from specify_cli.integrations.copilot import CopilotIntegration
copilot = CopilotIntegration()
m = IntegrationManifest("copilot", tmp_path)
created = copilot.setup(tmp_path, m)
created = copilot.setup(tmp_path, m, parsed_options={"commands": True})
prompt_files = [f for f in created if f.parent.name == "prompts"]
assert len(prompt_files) > 0
for f in prompt_files:
@@ -77,7 +52,7 @@ class TestCopilotIntegration:
from specify_cli.integrations.copilot import CopilotIntegration
copilot = CopilotIntegration()
m = IntegrationManifest("copilot", tmp_path)
created = copilot.setup(tmp_path, m)
created = copilot.setup(tmp_path, m, parsed_options={"commands": True})
agents = [f for f in created if ".agent.md" in f.name]
prompts = [f for f in created if ".prompt.md" in f.name]
assert len(agents) == len(prompts)
@@ -87,7 +62,7 @@ class TestCopilotIntegration:
copilot = CopilotIntegration()
assert copilot._vscode_settings_path() is not None
m = IntegrationManifest("copilot", tmp_path)
created = copilot.setup(tmp_path, m)
created = copilot.setup(tmp_path, m, parsed_options={"commands": True})
settings = tmp_path / ".vscode" / "settings.json"
assert settings.exists()
assert settings in created
@@ -101,7 +76,7 @@ class TestCopilotIntegration:
existing = {"editor.fontSize": 14, "custom.setting": True}
(vscode_dir / "settings.json").write_text(json.dumps(existing, indent=4), encoding="utf-8")
m = IntegrationManifest("copilot", tmp_path)
created = copilot.setup(tmp_path, m)
created = copilot.setup(tmp_path, m, parsed_options={"commands": True})
settings = tmp_path / ".vscode" / "settings.json"
data = json.loads(settings.read_text(encoding="utf-8"))
assert data["editor.fontSize"] == 14
@@ -119,7 +94,7 @@ class TestCopilotIntegration:
settings.write_bytes(original)
m = IntegrationManifest("copilot", tmp_path)
copilot.setup(tmp_path, m)
copilot.setup(tmp_path, m, parsed_options={"commands": True})
assert settings.read_bytes() == original
assert "Could not parse" in caplog.text
@@ -128,7 +103,7 @@ class TestCopilotIntegration:
from specify_cli.integrations.copilot import CopilotIntegration
copilot = CopilotIntegration()
m = IntegrationManifest("copilot", tmp_path)
created = copilot.setup(tmp_path, m)
created = copilot.setup(tmp_path, m, parsed_options={"commands": True})
for f in created:
rel = f.resolve().relative_to(tmp_path.resolve()).as_posix()
assert rel in m.files, f"Created file {rel} not tracked in manifest"
@@ -137,7 +112,9 @@ class TestCopilotIntegration:
from specify_cli.integrations.copilot import CopilotIntegration
copilot = CopilotIntegration()
m = IntegrationManifest("copilot", tmp_path)
created = copilot.install(tmp_path, m)
created = copilot.install(
tmp_path, m, parsed_options={"commands": True}
)
assert len(created) > 0
m.save()
for f in created:
@@ -150,7 +127,9 @@ class TestCopilotIntegration:
from specify_cli.integrations.copilot import CopilotIntegration
copilot = CopilotIntegration()
m = IntegrationManifest("copilot", tmp_path)
created = copilot.install(tmp_path, m)
created = copilot.install(
tmp_path, m, parsed_options={"commands": True}
)
m.save()
modified_file = created[0]
modified_file.write_text("user modified this", encoding="utf-8")
@@ -162,7 +141,7 @@ class TestCopilotIntegration:
from specify_cli.integrations.copilot import CopilotIntegration
copilot = CopilotIntegration()
m = IntegrationManifest("copilot", tmp_path)
copilot.setup(tmp_path, m)
copilot.setup(tmp_path, m, parsed_options={"commands": True})
agents_dir = tmp_path / ".github" / "agents"
assert agents_dir.is_dir()
agent_files = sorted(agents_dir.glob("speckit.*.agent.md"))
@@ -178,7 +157,7 @@ class TestCopilotIntegration:
from specify_cli.integrations.copilot import CopilotIntegration
copilot = CopilotIntegration()
m = IntegrationManifest("copilot", tmp_path)
copilot.setup(tmp_path, m)
copilot.setup(tmp_path, m, parsed_options={"commands": True})
agents_dir = tmp_path / ".github" / "agents"
for agent_file in agents_dir.glob("speckit.*.agent.md"):
content = agent_file.read_text(encoding="utf-8")
@@ -193,7 +172,7 @@ class TestCopilotIntegration:
from specify_cli.integrations.copilot import CopilotIntegration
copilot = CopilotIntegration()
m = IntegrationManifest("copilot", tmp_path)
copilot.setup(tmp_path, m)
copilot.setup(tmp_path, m, parsed_options={"commands": True})
specify_file = tmp_path / ".github" / "agents" / "speckit.specify.agent.md"
content = specify_file.read_text(encoding="utf-8")
@@ -209,7 +188,7 @@ class TestCopilotIntegration:
copilot = CopilotIntegration()
m = IntegrationManifest("copilot", tmp_path)
copilot.setup(tmp_path, m)
copilot.setup(tmp_path, m, parsed_options={"commands": True})
specify_file = tmp_path / ".github" / "agents" / "speckit.specify.agent.md"
content = specify_file.read_text(encoding="utf-8")
@@ -233,7 +212,7 @@ class TestCopilotIntegration:
encoding="utf-8",
)
copilot.setup(tmp_path, m)
copilot.setup(tmp_path, m, parsed_options={"commands": True})
specify_file = tmp_path / ".github" / "agents" / "speckit.specify.agent.md"
content = specify_file.read_text(encoding="utf-8")
@@ -246,14 +225,14 @@ class TestCopilotIntegration:
from specify_cli.integrations.copilot import CopilotIntegration
copilot = CopilotIntegration()
m = IntegrationManifest("copilot", tmp_path)
copilot.setup(tmp_path, m)
copilot.setup(tmp_path, m, parsed_options={"commands": True})
plan_file = tmp_path / ".github" / "agents" / "speckit.plan.agent.md"
assert plan_file.exists()
content = plan_file.read_text(encoding="utf-8")
assert "__CONTEXT_FILE__" not in content
def test_complete_file_inventory_sh(self, tmp_path):
"""Every file produced by specify init --integration copilot --script sh."""
"""Every file produced by Copilot commands mode with shell scripts."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "inventory-sh"
@@ -262,7 +241,8 @@ class TestCopilotIntegration:
try:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", "copilot", "--script", "sh",
"init", "--here", "--integration", "copilot",
"--integration-options", "--commands", "--script", "sh",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
@@ -315,7 +295,7 @@ class TestCopilotIntegration:
)
def test_complete_file_inventory_ps(self, tmp_path):
"""Every file produced by specify init --integration copilot --script ps."""
"""Every file produced by Copilot commands mode with PowerShell scripts."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "inventory-ps"
@@ -324,7 +304,8 @@ class TestCopilotIntegration:
try:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", "copilot", "--script", "ps",
"init", "--here", "--integration", "copilot",
"--integration-options", "--commands", "--script", "ps",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
@@ -376,54 +357,8 @@ class TestCopilotIntegration:
f"Extra: {sorted(set(actual) - set(expected))}"
)
def test_default_cli_init_warns_legacy_markdown_is_deprecated(self, tmp_path):
"""Default Copilot init should warn users about the future skills default."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "default-warning"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
with pytest.warns(
UserWarning,
match="Copilot legacy markdown mode is deprecated",
):
result = CliRunner().invoke(app, [
"init", "--here", "--integration", "copilot", "--script", "sh",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, result.output
def test_skills_cli_init_does_not_warn_about_legacy_markdown(self, tmp_path):
"""Explicit Copilot skills mode should not warn about the legacy default."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "skills-no-warning"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
result = CliRunner().invoke(app, [
"init", "--here", "--integration", "copilot",
"--integration-options", "--skills", "--script", "sh",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, result.output
assert not any(
"Copilot legacy markdown mode is deprecated" in str(item.message)
for item in caught
)
class TestCopilotSkillsMode:
"""Tests for Copilot integration in --skills mode."""
"""Tests for Copilot's default skills mode."""
_SKILL_COMMANDS = [
"analyze", "clarify", "constitution", "converge", "implement",
@@ -436,7 +371,7 @@ class TestCopilotSkillsMode:
def _setup_skills(self, copilot, tmp_path):
m = IntegrationManifest("copilot", tmp_path)
created = copilot.setup(tmp_path, m, parsed_options={"skills": True})
created = copilot.setup(tmp_path, m)
return created, m
# -- Options ----------------------------------------------------------
@@ -449,6 +384,137 @@ class TestCopilotSkillsMode:
assert skills_opts[0].is_flag is True
assert skills_opts[0].default is False
def test_options_include_commands_flag(self):
copilot = get_integration("copilot")
commands_opts = [o for o in copilot.options() if o.name == "--commands"]
assert len(commands_opts) == 1
assert commands_opts[0].is_flag is True
assert commands_opts[0].default is False
def test_default_is_skills_mode(self):
copilot = self._make_copilot()
assert copilot.is_skills_mode() is True
assert copilot.is_skills_mode({}) is True
def test_commands_flag_disables_skills_mode(self):
copilot = self._make_copilot()
assert copilot.is_skills_mode({"commands": True}) is False
def test_existing_commands_layout_is_preserved(self, tmp_path):
copilot = self._make_copilot()
agents_dir = tmp_path / ".github" / "agents"
agents_dir.mkdir(parents=True)
(agents_dir / "speckit.plan.agent.md").write_text(
"# plan\n", encoding="utf-8"
)
assert copilot.is_skills_mode(project_root=tmp_path) is False
def test_setup_preserves_existing_commands_without_stored_options(
self, tmp_path
):
copilot = self._make_copilot()
agents_dir = tmp_path / ".github" / "agents"
agents_dir.mkdir(parents=True)
(agents_dir / "speckit.plan.agent.md").write_text(
"# old plan\n", encoding="utf-8"
)
manifest = IntegrationManifest("copilot", tmp_path)
created = copilot.setup(tmp_path, manifest)
assert any(path.name.endswith(".agent.md") for path in created)
assert not (tmp_path / ".github" / "skills").exists()
assert copilot._skills_mode is False
def test_existing_skills_layout_stays_in_skills_mode(self, tmp_path):
copilot = self._make_copilot()
(tmp_path / ".github" / "skills" / "speckit-plan").mkdir(parents=True)
assert copilot.is_skills_mode(project_root=tmp_path) is True
def test_commands_manifest_wins_over_untracked_skill(self, tmp_path):
copilot = self._make_copilot()
manifest = IntegrationManifest("copilot", tmp_path)
copilot.setup(
tmp_path, manifest, parsed_options={"commands": True}
)
manifest.save()
stale_skill = (
tmp_path
/ ".github"
/ "skills"
/ "speckit-plan"
/ "SKILL.md"
)
stale_skill.parent.mkdir(parents=True)
stale_skill.write_text("# user-authored skill\n", encoding="utf-8")
assert copilot.is_skills_mode(project_root=tmp_path) is False
def test_skills_manifest_wins_over_untracked_command(self, tmp_path):
copilot = self._make_copilot()
manifest = IntegrationManifest("copilot", tmp_path)
copilot.setup(tmp_path, manifest)
manifest.save()
stale_agent = (
tmp_path
/ ".github"
/ "agents"
/ "speckit.plan.agent.md"
)
stale_agent.parent.mkdir(parents=True)
stale_agent.write_text("# stale command\n", encoding="utf-8")
assert copilot.is_skills_mode(project_root=tmp_path) is True
def test_explicit_skills_forces_migration_from_commands(self, tmp_path):
copilot = self._make_copilot()
agents_dir = tmp_path / ".github" / "agents"
agents_dir.mkdir(parents=True)
(agents_dir / "speckit.plan.agent.md").write_text(
"# plan\n", encoding="utf-8"
)
assert (
copilot.is_skills_mode({"skills": True}, project_root=tmp_path)
is True
)
def test_skills_and_commands_flags_are_mutually_exclusive(self):
copilot = self._make_copilot()
with pytest.raises(typer.Exit):
copilot.is_skills_mode({"skills": True, "commands": True})
def test_cli_rejects_skills_and_commands_together(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "conflicting-modes"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = CliRunner().invoke(
app,
[
"init",
"--here",
"--integration",
"copilot",
"--integration-options",
"--skills --commands",
"--script",
"sh",
],
catch_exceptions=False,
)
finally:
os.chdir(old_cwd)
assert result.exit_code == 1
assert "--skills and --commands are mutually exclusive" in result.output
assert not (project / ".github" / "skills").exists()
assert not (project / ".github" / "agents").exists()
# -- Skills directory structure ---------------------------------------
def test_skills_creates_skill_files(self, tmp_path):
@@ -623,16 +689,16 @@ class TestCopilotSkillsMode:
def test_skills_mode_invoke_separator(self):
"""Copilot effective_invoke_separator should reflect skills mode."""
copilot = self._make_copilot()
assert copilot.effective_invoke_separator() == "."
assert copilot.effective_invoke_separator() == "-"
assert copilot.effective_invoke_separator({"skills": True}) == "-"
assert copilot.effective_invoke_separator({"skills": False}) == "."
assert copilot.effective_invoke_separator({"commands": True}) == "."
def test_invoke_separator_for_mode_tracks_persisted_state(self):
"""Regression (review #3415): registration paths (preset/extension
command refs) must resolve the separator from the persisted ai_skills
state. A Copilot skills project renders ``/speckit-<cmd>`` (hyphen),
matching ``build_command_invocation``; the default markdown layout
renders ``/speckit.<cmd>`` (dot).
matching ``build_command_invocation``; commands mode renders
``/speckit.<cmd>`` (dot).
"""
copilot = self._make_copilot()
assert copilot.invoke_separator_for_mode(True) == "-"
@@ -673,7 +739,7 @@ class TestCopilotSkillsMode:
def test_install_uninstall_roundtrip(self, tmp_path):
copilot = self._make_copilot()
m = IntegrationManifest("copilot", tmp_path)
created = copilot.install(tmp_path, m, parsed_options={"skills": True})
created = copilot.install(tmp_path, m)
assert len(created) > 0
m.save()
for f in created:
@@ -685,7 +751,7 @@ class TestCopilotSkillsMode:
def test_modified_file_survives_uninstall(self, tmp_path):
copilot = self._make_copilot()
m = IntegrationManifest("copilot", tmp_path)
created = copilot.install(tmp_path, m, parsed_options={"skills": True})
created = copilot.install(tmp_path, m)
m.save()
modified_file = created[0]
modified_file.write_text("user modified this", encoding="utf-8")
@@ -710,6 +776,12 @@ class TestCopilotSkillsMode:
def test_build_command_invocation_default_mode(self):
copilot = self._make_copilot()
assert copilot.build_command_invocation("plan", "my args") == "/speckit-plan my args"
assert copilot.build_command_invocation("plan") == "/speckit-plan"
def test_build_command_invocation_commands_mode(self):
copilot = self._make_copilot()
copilot._skills_mode = False
assert copilot.build_command_invocation("plan", "my args") == "my args"
assert copilot.build_command_invocation("plan") == ""
@@ -725,8 +797,8 @@ class TestCopilotSkillsMode:
# -- CLI integration test ---------------------------------------------
def test_init_with_integration_options_skills(self, tmp_path):
"""specify init --integration copilot --integration-options='--skills' scaffolds skills."""
def test_init_defaults_to_skills(self, tmp_path):
"""specify init --integration copilot scaffolds skills by default."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "copilot-skills"
@@ -736,7 +808,6 @@ class TestCopilotSkillsMode:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", "copilot",
"--integration-options", "--skills",
"--script", "sh",
], catch_exceptions=False)
finally:
@@ -752,7 +823,7 @@ class TestCopilotSkillsMode:
assert not (project / ".vscode" / "settings.json").exists()
def test_complete_file_inventory_skills_sh(self, tmp_path):
"""Every file produced by specify init --integration copilot --integration-options='--skills' --script sh."""
"""Every file produced by default Copilot init with shell scripts."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "inventory-skills-sh"
@@ -762,7 +833,6 @@ class TestCopilotSkillsMode:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", "copilot",
"--integration-options", "--skills",
"--script", "sh",
], catch_exceptions=False)
finally:
@@ -802,36 +872,46 @@ class TestCopilotSkillsMode:
# -- Singleton leak: _skills_mode must reset --------------------------
def test_skills_mode_resets_on_default_setup(self, tmp_path):
"""setup() with skills=True then without must reset _skills_mode."""
def test_skills_mode_resets_between_layouts(self, tmp_path):
"""setup() must reset the singleton mode for each selected layout."""
copilot = self._make_copilot()
# First call: skills mode
# First call: default skills mode
(tmp_path / "proj1").mkdir()
m1 = IntegrationManifest("copilot", tmp_path / "proj1")
copilot.setup(tmp_path / "proj1", m1, parsed_options={"skills": True})
copilot.setup(tmp_path / "proj1", m1)
assert copilot._skills_mode is True
# Second call: default mode (no skills option)
# Second call: explicit commands mode
(tmp_path / "proj2").mkdir()
m2 = IntegrationManifest("copilot", tmp_path / "proj2")
copilot.setup(tmp_path / "proj2", m2)
copilot.setup(
tmp_path / "proj2", m2, parsed_options={"commands": True}
)
assert copilot._skills_mode is False
# build_command_invocation must use default (dotted) mode
assert copilot.build_command_invocation("plan", "args") == "args"
# -- Auto-detection must ignore unrelated .github/skills/ -------------
# Third call: a fresh default project must switch back to skills.
(tmp_path / "proj3").mkdir()
m3 = IntegrationManifest("copilot", tmp_path / "proj3")
copilot.setup(tmp_path / "proj3", m3)
assert copilot._skills_mode is True
assert copilot.build_command_invocation("plan") == "/speckit-plan"
def test_dispatch_ignores_unrelated_skills_directory(self, tmp_path):
"""dispatch_command() must not treat unrelated .github/skills/ as skills mode."""
# -- Auto-detection must preserve managed commands --------------------
def test_dispatch_preserves_commands_with_unrelated_skills(self, tmp_path):
"""Unrelated skills must not migrate a managed commands layout."""
copilot = self._make_copilot()
# Create a .github/skills/ with non-speckit content (e.g. GitHub Skills training)
agents_dir = tmp_path / ".github" / "agents"
agents_dir.mkdir(parents=True)
(agents_dir / "speckit.plan.agent.md").write_text(
"# plan\n", encoding="utf-8"
)
unrelated = tmp_path / ".github" / "skills" / "introduction-to-github"
unrelated.mkdir(parents=True)
(unrelated / "README.md").write_text("# GitHub Skills training\n")
# Should NOT detect skills mode — cli_args should contain --agent
import unittest.mock as mock
with mock.patch("subprocess.run") as mock_run:
mock_run.return_value = mock.Mock(returncode=0, stdout="", stderr="")
@@ -868,7 +948,7 @@ class TestCopilotSkillsMode:
# -- Next-steps display for Copilot skills mode -----------------------
def test_init_skills_next_steps_show_skill_syntax(self, tmp_path):
"""specify init --integration copilot --integration-options='--skills' shows /speckit-plan not /speckit.plan."""
"""Default Copilot init shows /speckit-plan, not /speckit.plan."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "copilot-nextsteps"
@@ -878,7 +958,6 @@ class TestCopilotSkillsMode:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", "copilot",
"--integration-options", "--skills",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
+7 -7
View File
@@ -89,10 +89,10 @@ def test_write_integration_json_strips_integration_key(tmp_path):
def test_with_integration_setting_recomputes_separator_from_retained_options():
"""Updating only script_type must not drop an options-dependent separator.
Copilot resolves the command-ref separator to '-' when '--skills' options
are stored and '.' otherwise. A second call that changes only script_type
Copilot resolves the command-ref separator to '.' when '--commands' is
stored and '-' by default. A second call that changes only script_type
(parsed_options=None, raw_options=None) retains the stored parsed_options,
so invoke_separator must stay '-', not be recomputed from the None argument.
so invoke_separator must stay '.', not be recomputed from the None argument.
"""
from specify_cli.integrations import get_integration
from specify_cli.integration_runtime import with_integration_setting
@@ -100,15 +100,15 @@ def test_with_integration_setting_recomputes_separator_from_retained_options():
copilot = get_integration("copilot")
settings = with_integration_setting(
{}, "copilot", copilot, parsed_options={"skills": True}
{}, "copilot", copilot, parsed_options={"commands": True}
)
assert settings["copilot"]["invoke_separator"] == "-"
assert settings["copilot"]["invoke_separator"] == "."
settings2 = with_integration_setting(
{"integration_settings": settings}, "copilot", copilot, script_type="ps"
)
# parsed_options are retained (only script_type changed) ...
assert settings2["copilot"]["parsed_options"] == {"skills": True}
assert settings2["copilot"]["parsed_options"] == {"commands": True}
assert settings2["copilot"]["script"] == "ps"
# ... so the separator must reflect them, not the (None) argument.
assert settings2["copilot"]["invoke_separator"] == "-"
assert settings2["copilot"]["invoke_separator"] == "."
@@ -2224,15 +2224,99 @@ class TestIntegrationSwitch:
# Old claude files removed
assert not (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
# New copilot files created
assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists()
assert "/speckit.specify" in shared_script.read_text(encoding="utf-8")
assert "/speckit-specify" not in shared_script.read_text(encoding="utf-8")
# New default Copilot skills created
assert (
project / ".github" / "skills" / "speckit-plan" / "SKILL.md"
).exists()
assert "/speckit-specify" in shared_script.read_text(encoding="utf-8")
assert "/speckit.specify" not in shared_script.read_text(encoding="utf-8")
# integration.json updated
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
assert data["integration"] == "copilot"
def test_switch_rejects_conflicting_copilot_modes_before_uninstall(
self, tmp_path
):
project = _init_project(tmp_path, "claude")
claude_skill = (
project / ".claude" / "skills" / "speckit-plan" / "SKILL.md"
)
before_state = json.loads(
(project / ".specify" / "integration.json").read_text(
encoding="utf-8"
)
)
result = _run_in_project(
project,
[
"integration",
"switch",
"copilot",
"--integration-options",
"--skills --commands",
"--script",
"sh",
],
)
assert result.exit_code == 1
assert "--skills and --commands are mutually exclusive" in result.output
assert claude_skill.exists()
assert not (project / ".github" / "skills").exists()
assert not (project / ".github" / "agents").exists()
after_state = json.loads(
(project / ".specify" / "integration.json").read_text(
encoding="utf-8"
)
)
assert after_state == before_state
def test_switch_preserves_target_options_with_fallback_integration(
self, tmp_path
):
project = _init_project(tmp_path, "claude")
install = _run_in_project(
project,
[
"integration",
"install",
"opencode",
"--script",
"sh",
"--force",
],
)
assert install.exit_code == 0, install.output
result = _run_in_project(
project,
[
"integration",
"switch",
"copilot",
"--integration-options",
"--commands",
"--script",
"sh",
],
)
assert result.exit_code == 0, result.output
assert (
project / ".github" / "agents" / "speckit.plan.agent.md"
).exists()
assert not (project / ".github" / "skills").exists()
state = json.loads(
(project / ".specify" / "integration.json").read_text(
encoding="utf-8"
)
)
assert state["integration_settings"]["copilot"]["parsed_options"] == {
"commands": True
}
def test_switch_migrates_extension_commands(self, tmp_path):
"""Switching should migrate extension commands to the new agent directory."""
project = _init_project(tmp_path, "kimi")
@@ -2492,6 +2576,7 @@ class TestIntegrationSwitch:
os.chdir(project)
result = runner.invoke(app, [
"integration", "switch", "copilot",
"--integration-options", "--commands",
"--script", "sh",
], catch_exceptions=False)
finally:
@@ -2530,6 +2615,7 @@ class TestIntegrationSwitch:
os.chdir(project)
result = runner.invoke(app, [
"integration", "switch", "copilot",
"--integration-options", "--commands",
"--script", "sh",
], catch_exceptions=False)
finally:
@@ -2558,6 +2644,7 @@ class TestIntegrationSwitch:
os.chdir(project)
result = runner.invoke(app, [
"integration", "switch", "copilot",
"--integration-options", "--commands",
"--script", "sh",
], catch_exceptions=False)
finally:
@@ -2582,6 +2669,7 @@ class TestIntegrationSwitch:
os.chdir(project)
result = runner.invoke(app, [
"integration", "switch", "copilot",
"--integration-options", "--commands",
"--script", "sh",
"--refresh-shared-infra",
], catch_exceptions=False)
@@ -2894,7 +2982,9 @@ class TestIntegrationUpgrade:
assert manifest_path.read_text(encoding="utf-8") == before_manifest
def test_upgrade_default_refreshes_shared_script_refs_for_option_separator_change(self, tmp_path):
project = _init_project(tmp_path, "copilot")
project = _init_project(
tmp_path, "copilot", integration_options="--commands"
)
template = project / ".specify" / "templates" / "plan-template.md"
managed_script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
customized_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
@@ -2916,6 +3006,46 @@ class TestIntegrationUpgrade:
assert "/speckit.specify" not in managed_content
assert customized_script.read_text(encoding="utf-8") == customized_before
def test_upgrade_preserves_historical_copilot_commands_without_options(
self, tmp_path
):
"""A command manifest restores missing files instead of migrating."""
project = _init_project(
tmp_path, "copilot", integration_options="--commands"
)
state_path = project / ".specify" / "integration.json"
state = json.loads(state_path.read_text(encoding="utf-8"))
copilot_settings = state["integration_settings"]["copilot"]
copilot_settings.pop("raw_options", None)
copilot_settings.pop("parsed_options", None)
state_path.write_text(json.dumps(state), encoding="utf-8")
for path in (project / ".github" / "agents").glob(
"speckit.*.agent.md"
):
path.unlink()
for path in (project / ".github" / "prompts").glob(
"speckit.*.prompt.md"
):
path.unlink()
result = _run_in_project(
project,
["integration", "upgrade", "copilot", "--script", "sh", "--force"],
)
assert result.exit_code == 0, result.output
assert (
project / ".github" / "agents" / "speckit.plan.agent.md"
).exists()
assert not (project / ".github" / "skills").exists()
init_options = json.loads(
(project / ".specify" / "init-options.json").read_text(
encoding="utf-8"
)
)
assert init_options.get("ai_skills") is not True
def test_upgrade_non_default_keeps_default_template_invocations(self, tmp_path):
project = _init_project(tmp_path, "gemini")
template = project / ".specify" / "templates" / "plan-template.md"
@@ -3721,7 +3851,9 @@ class TestIntegrationUpgrade:
tracking it, so without ``stale_cleanup_exclusions()`` the Phase 2
stale cleanup would delete it (destroying the user's settings).
"""
project = _init_project(tmp_path, "copilot")
project = _init_project(
tmp_path, "copilot", integration_options="--commands"
)
settings = project / ".vscode" / "settings.json"
assert settings.is_file(), "init should create .vscode/settings.json"
before = json.loads(settings.read_text(encoding="utf-8"))
+2 -2
View File
@@ -2043,7 +2043,7 @@ class TestExtensionSkillRegistration:
assert skill_file.exists(), "sanity: skills mode should write SKILL.md"
# Toggle ai_skills off for the same active agent (copilot) and
# rescaffold, mirroring `integration upgrade copilot` (no --skills).
# rescaffold, mirroring `integration upgrade copilot --commands`.
_create_init_options(project_dir, ai="copilot", ai_skills=False)
manager.register_enabled_extensions_for_agent("copilot")
@@ -2117,7 +2117,7 @@ class TestExtensionSkillRegistration:
)
# Toggle copilot to command mode (mirroring `integration upgrade
# copilot` with no --skills) — copilot's mirror is now stale.
# copilot --commands`) — copilot's mirror is now stale.
_create_init_options(project_dir, ai="copilot", ai_skills=False)
manager.register_enabled_extensions_for_agent("copilot")