From 0acd6b93e771df3e9c912c56f045f297195bb02a Mon Sep 17 00:00:00 2001 From: root Date: Tue, 30 Jun 2026 21:40:43 +0800 Subject: [PATCH 1/3] feat(copilot): default to skills mode --- .../integrations/copilot/__init__.py | 19 ++-- tests/integrations/test_cli.py | 18 ++-- .../integrations/test_integration_copilot.py | 96 +++++++------------ .../test_integration_subcommand.py | 52 ++++------ 4 files changed, 72 insertions(+), 113 deletions(-) diff --git a/src/specify_cli/integrations/copilot/__init__.py b/src/specify_cli/integrations/copilot/__init__.py index 5cc34d2b1d..ee5adfa81e 100644 --- a/src/specify_cli/integrations/copilot/__init__.py +++ b/src/specify_cli/integrations/copilot/__init__.py @@ -5,9 +5,10 @@ - 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-/SKILL.md`` directories under ``.github/skills/`` -instead. The two modes are mutually exclusive. +Copilot now defaults to skills mode, scaffolding commands as +``speckit-/SKILL.md`` directories under ``.github/skills/``. +The legacy ``.agent.md`` + ``.prompt.md`` layout remains available for +explicit non-skills installs. """ from __future__ import annotations @@ -126,8 +127,8 @@ def options(cls) -> list[IntegrationOption]: IntegrationOption( "--skills", is_flag=True, - default=False, - help="Scaffold commands as agent skills (speckit-/SKILL.md) instead of .agent.md files", + default=True, + help="Scaffold commands as agent skills (default for Copilot)", ), ] @@ -308,12 +309,12 @@ def setup( ) -> list[Path]: """Install copilot commands, companion prompts, and VS Code settings. - When ``parsed_options["skills"]`` is truthy, delegates to skills - scaffolding (``speckit-/SKILL.md`` under ``.github/skills/``). - Otherwise uses the default ``.agent.md`` + ``.prompt.md`` layout. + Defaults to skills scaffolding (``speckit-/SKILL.md`` under + ``.github/skills/``). When ``parsed_options["skills"]`` is falsy, + uses the legacy ``.agent.md`` + ``.prompt.md`` layout. """ parsed_options = parsed_options or {} - self._skills_mode = bool(parsed_options.get("skills")) + self._skills_mode = bool(parsed_options.get("skills", True)) if self._skills_mode: return self._setup_skills(project_root, manifest, parsed_options, **opts) return self._setup_default(project_root, manifest, parsed_options, **opts) diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index 25d4a7c16a..0876ece36b 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -68,8 +68,9 @@ def test_integration_copilot_creates_files(self, tmp_path): 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" / "speckit.plan.agent.md").exists() + assert not (project / ".github" / "prompts" / "speckit.plan.prompt.md").exists() assert (project / ".specify" / "scripts" / "bash" / "common.sh").exists() data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) @@ -110,7 +111,7 @@ def fail_select(*_args, **_kwargs): 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 @@ -130,7 +131,7 @@ def test_integration_copilot_auto_promotes(self, tmp_path): 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 @@ -1121,7 +1122,7 @@ def test_full_init_claude_resolves_page_templates(self, tmp_path): 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.""" + """Full CLI init with Copilot now defaults to skills-mode hyphen refs.""" from typer.testing import CliRunner from specify_cli import app @@ -1143,12 +1144,13 @@ def test_full_init_copilot_resolves_page_templates(self, tmp_path): 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 default 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.""" diff --git a/tests/integrations/test_integration_copilot.py b/tests/integrations/test_integration_copilot.py index 8a7c8ec995..7116bcde0e 100644 --- a/tests/integrations/test_integration_copilot.py +++ b/tests/integrations/test_integration_copilot.py @@ -27,6 +27,16 @@ def test_setup_creates_agent_md_files(self, tmp_path): copilot = CopilotIntegration() m = IntegrationManifest("copilot", tmp_path) created = copilot.setup(tmp_path, m) + skill_files = [f for f in created if f.name == "SKILL.md"] + assert len(skill_files) > 0 + for f in skill_files: + assert f.parent.parent == tmp_path / ".github" / "skills" + + def test_setup_creates_agent_md_files_when_skills_disabled(self, tmp_path): + from specify_cli.integrations.copilot import CopilotIntegration + copilot = CopilotIntegration() + m = IntegrationManifest("copilot", tmp_path) + created = copilot.setup(tmp_path, m, parsed_options={"skills": False}) assert len(created) > 0 agent_files = [f for f in created if ".agent." in f.name] assert len(agent_files) > 0 @@ -38,7 +48,7 @@ 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={"skills": False}) prompt_files = [f for f in created if f.parent.name == "prompts"] assert len(prompt_files) > 0 for f in prompt_files: @@ -50,7 +60,7 @@ def test_agent_and_prompt_counts_match(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={"skills": False}) 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) @@ -60,7 +70,7 @@ def test_setup_creates_vscode_settings_new(self, tmp_path): 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={"skills": False}) settings = tmp_path / ".vscode" / "settings.json" assert settings.exists() assert settings in created @@ -74,7 +84,7 @@ def test_setup_merges_existing_vscode_settings(self, tmp_path): 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={"skills": False}) settings = tmp_path / ".vscode" / "settings.json" data = json.loads(settings.read_text(encoding="utf-8")) assert data["editor.fontSize"] == 14 @@ -121,15 +131,15 @@ def test_directory_structure(self, tmp_path): copilot = CopilotIntegration() m = IntegrationManifest("copilot", tmp_path) copilot.setup(tmp_path, m) - agents_dir = tmp_path / ".github" / "agents" - assert agents_dir.is_dir() - agent_files = sorted(agents_dir.glob("speckit.*.agent.md")) - assert len(agent_files) == 10 + skills_dir = tmp_path / ".github" / "skills" + assert skills_dir.is_dir() + skill_files = sorted(skills_dir.glob("speckit-*/SKILL.md")) + assert len(skill_files) == 10 expected_commands = { "analyze", "clarify", "constitution", "converge", "implement", "plan", "checklist", "specify", "tasks", "taskstoissues", } - actual_commands = {f.name.removeprefix("speckit.").removesuffix(".agent.md") for f in agent_files} + actual_commands = {f.parent.name.removeprefix("speckit-") for f in skill_files} assert actual_commands == expected_commands def test_templates_are_processed(self, tmp_path): @@ -153,7 +163,7 @@ def test_specify_agent_resolves_active_spec_template(self, tmp_path): m = IntegrationManifest("copilot", tmp_path) copilot.setup(tmp_path, m) - specify_file = tmp_path / ".github" / "agents" / "speckit.specify.agent.md" + specify_file = tmp_path / ".github" / "skills" / "speckit-specify" / "SKILL.md" content = specify_file.read_text(encoding="utf-8") assert "specify preset resolve spec-template" in content @@ -168,13 +178,13 @@ def test_plan_command_has_no_context_placeholder(self, tmp_path): copilot = CopilotIntegration() m = IntegrationManifest("copilot", tmp_path) copilot.setup(tmp_path, m) - plan_file = tmp_path / ".github" / "agents" / "speckit.plan.agent.md" + plan_file = tmp_path / ".github" / "skills" / "speckit-plan" / "SKILL.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 default Copilot init now uses skills mode.""" from typer.testing import CliRunner from specify_cli import app project = tmp_path / "inventory-sh" @@ -190,27 +200,10 @@ def test_complete_file_inventory_sh(self, tmp_path): assert result.exit_code == 0 actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts) expected = sorted([ - ".github/agents/speckit.analyze.agent.md", - ".github/agents/speckit.checklist.agent.md", - ".github/agents/speckit.clarify.agent.md", - ".github/agents/speckit.constitution.agent.md", - ".github/agents/speckit.converge.agent.md", - ".github/agents/speckit.implement.agent.md", - ".github/agents/speckit.plan.agent.md", - ".github/agents/speckit.specify.agent.md", - ".github/agents/speckit.tasks.agent.md", - ".github/agents/speckit.taskstoissues.agent.md", - ".github/prompts/speckit.analyze.prompt.md", - ".github/prompts/speckit.checklist.prompt.md", - ".github/prompts/speckit.clarify.prompt.md", - ".github/prompts/speckit.constitution.prompt.md", - ".github/prompts/speckit.converge.prompt.md", - ".github/prompts/speckit.implement.prompt.md", - ".github/prompts/speckit.plan.prompt.md", - ".github/prompts/speckit.specify.prompt.md", - ".github/prompts/speckit.tasks.prompt.md", - ".github/prompts/speckit.taskstoissues.prompt.md", - ".vscode/settings.json", + *[f".github/skills/speckit-{cmd}/SKILL.md" for cmd in [ + "analyze", "checklist", "clarify", "constitution", "converge", + "implement", "plan", "specify", "tasks", "taskstoissues", + ]], ".specify/integration.json", ".specify/init-options.json", ".specify/integrations/copilot.manifest.json", @@ -235,7 +228,7 @@ def test_complete_file_inventory_sh(self, tmp_path): ) def test_complete_file_inventory_ps(self, tmp_path): - """Every file produced by specify init --integration copilot --script ps.""" + """Every file produced by default Copilot init with PowerShell uses skills mode.""" from typer.testing import CliRunner from specify_cli import app project = tmp_path / "inventory-ps" @@ -251,27 +244,10 @@ def test_complete_file_inventory_ps(self, tmp_path): assert result.exit_code == 0 actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts) expected = sorted([ - ".github/agents/speckit.analyze.agent.md", - ".github/agents/speckit.checklist.agent.md", - ".github/agents/speckit.clarify.agent.md", - ".github/agents/speckit.constitution.agent.md", - ".github/agents/speckit.converge.agent.md", - ".github/agents/speckit.implement.agent.md", - ".github/agents/speckit.plan.agent.md", - ".github/agents/speckit.specify.agent.md", - ".github/agents/speckit.tasks.agent.md", - ".github/agents/speckit.taskstoissues.agent.md", - ".github/prompts/speckit.analyze.prompt.md", - ".github/prompts/speckit.checklist.prompt.md", - ".github/prompts/speckit.clarify.prompt.md", - ".github/prompts/speckit.constitution.prompt.md", - ".github/prompts/speckit.converge.prompt.md", - ".github/prompts/speckit.implement.prompt.md", - ".github/prompts/speckit.plan.prompt.md", - ".github/prompts/speckit.specify.prompt.md", - ".github/prompts/speckit.tasks.prompt.md", - ".github/prompts/speckit.taskstoissues.prompt.md", - ".vscode/settings.json", + *[f".github/skills/speckit-{cmd}/SKILL.md" for cmd in [ + "analyze", "checklist", "clarify", "constitution", "converge", + "implement", "plan", "specify", "tasks", "taskstoissues", + ]], ".specify/integration.json", ".specify/init-options.json", ".specify/integrations/copilot.manifest.json", @@ -321,7 +297,7 @@ def test_options_include_skills_flag(self): skills_opts = [o for o in opts if o.name == "--skills"] assert len(skills_opts) == 1 assert skills_opts[0].is_flag is True - assert skills_opts[0].default is False + assert skills_opts[0].default is True # -- Skills directory structure --------------------------------------- @@ -674,14 +650,12 @@ def test_skills_mode_resets_on_default_setup(self, tmp_path): copilot.setup(tmp_path / "proj1", m1, parsed_options={"skills": True}) assert copilot._skills_mode is True - # Second call: default mode (no skills option) + # Second call: default mode (no explicit skills option) still means skills mode. (tmp_path / "proj2").mkdir() m2 = IntegrationManifest("copilot", tmp_path / "proj2") copilot.setup(tmp_path / "proj2", m2) - assert copilot._skills_mode is False - - # build_command_invocation must use default (dotted) mode - assert copilot.build_command_invocation("plan", "args") == "args" + assert copilot._skills_mode is True + assert copilot.build_command_invocation("plan", "args") == "/speckit-plan args" # -- Auto-detection must ignore unrelated .github/skills/ ------------- diff --git a/tests/integrations/test_integration_subcommand.py b/tests/integrations/test_integration_subcommand.py index 34114a564e..3a246e3b6f 100644 --- a/tests/integrations/test_integration_subcommand.py +++ b/tests/integrations/test_integration_subcommand.py @@ -1794,10 +1794,10 @@ def test_switch_between_integrations(self, tmp_path): # 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 copilot files created in default skills layout + 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")) @@ -1973,7 +1973,7 @@ def test_switch_does_not_register_disabled_extensions(self, tmp_path): assert "opencode" not in git_meta["registered_commands"] def test_switch_refreshes_managed_shared_script_refs(self, tmp_path): - """Switching refreshes managed shared scripts to the target command style.""" + """Switching to default Copilot keeps managed shared scripts on skills syntax.""" project = _init_project(tmp_path, "claude") shared_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh" assert shared_script.exists() @@ -1993,8 +1993,8 @@ def test_switch_refreshes_managed_shared_script_refs(self, tmp_path): assert shared_script.exists() updated = shared_script.read_text(encoding="utf-8") - assert "/speckit.plan" in updated - assert "/speckit-plan" not in updated + assert "/speckit-plan" in updated + assert "/speckit.plan" not in updated def test_switch_refreshes_stale_managed_shared_infra(self, tmp_path): """Regression for #2293: stale managed shared scripts get refreshed on switch.""" @@ -2032,8 +2032,8 @@ def test_switch_refreshes_stale_managed_shared_infra(self, tmp_path): # Stale managed file should be replaced by the target integration's rendered version. updated = shared_script.read_text(encoding="utf-8") assert "# stale vendored copy" not in updated - assert "/speckit.plan" in updated - assert "/speckit-plan" not in updated + assert "/speckit-plan" in updated + assert "/speckit.plan" not in updated def test_switch_preserves_user_customized_shared_infra(self, tmp_path): """User customizations (hash divergence from manifest) survive switch without --refresh-shared-infra.""" @@ -2084,8 +2084,8 @@ def test_switch_refresh_shared_infra_overwrites_customizations(self, tmp_path): # Customization is overwritten with the target integration's rendered version. updated = shared_script.read_text(encoding="utf-8") assert "# user customization" not in updated - assert "/speckit.plan" in updated - assert "/speckit-plan" not in updated + assert "/speckit-plan" in updated + assert "/speckit.plan" not in updated def test_switch_preserves_recovered_files(self, tmp_path): """Regression for #2918: files marked recovered in the manifest are not overwritten. @@ -2344,8 +2344,8 @@ def test_upgrade_default_refreshes_shared_script_refs_for_option_separator_chang managed_script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh" customized_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh" - assert "/speckit.plan" in template.read_text(encoding="utf-8") - assert "/speckit.specify" in managed_script.read_text(encoding="utf-8") + assert "/speckit-plan" in template.read_text(encoding="utf-8") + assert "/speckit-specify" in managed_script.read_text(encoding="utf-8") customized_before = customized_script.read_text(encoding="utf-8") + "\n# user customization\n" customized_script.write_text(customized_before, encoding="utf-8") @@ -2440,36 +2440,18 @@ def test_upgrade_migrates_opencode_legacy_dir(self, tmp_path): f"found: {[f.name for f in core_remaining]}" ) - def test_upgrade_preserves_existing_vscode_settings(self, tmp_path): - """Regression: copilot upgrade must not stale-delete .vscode/settings.json. - - On init the file is created and recorded in the manifest. On upgrade, - setup() merges into the now-existing file and intentionally stops - tracking it, so without ``stale_cleanup_exclusions()`` the Phase 2 - stale cleanup would delete it (destroying the user's settings). - """ + def test_upgrade_default_copilot_does_not_create_vscode_settings(self, tmp_path): + """Default Copilot skills mode should keep VS Code settings out of the project.""" project = _init_project(tmp_path, "copilot") settings = project / ".vscode" / "settings.json" - assert settings.is_file(), "init should create .vscode/settings.json" - before = json.loads(settings.read_text(encoding="utf-8")) - assert before, "settings.json should contain managed defaults" - - # Simulate a user editing their settings: add a custom key that the - # integration does not manage. It must survive the upgrade. - before["editor.fontSize"] = 17 - settings.write_text(json.dumps(before), encoding="utf-8") + assert not settings.exists(), "default Copilot init should not create .vscode/settings.json" result = _run_in_project(project, [ "integration", "upgrade", "copilot", "--script", "sh", "--force", ]) assert result.exit_code == 0, result.output - - assert settings.is_file(), ".vscode/settings.json must survive upgrade" - after = json.loads(settings.read_text(encoding="utf-8")) - assert after.get("editor.fontSize") == 17, ( - "user-defined settings must be preserved after upgrade" - ) + assert not settings.exists(), "default Copilot upgrade should not create .vscode/settings.json" def test_upgrade_restores_executable_bit_on_shared_scripts(self, tmp_path): """Regression: scripts refreshed by the managed-refresh step stay +x.""" From d56b80ec20b85e83c1448a76f51b96c527639db6 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 30 Jun 2026 22:35:59 +0800 Subject: [PATCH 2/3] feat(copilot): warn before skills default rollout --- .../integrations/copilot/__init__.py | 32 ++-- tests/integrations/test_cli.py | 18 +- .../integrations/test_integration_copilot.py | 165 ++++++++++++++---- .../test_integration_subcommand.py | 52 ++++-- 4 files changed, 195 insertions(+), 72 deletions(-) diff --git a/src/specify_cli/integrations/copilot/__init__.py b/src/specify_cli/integrations/copilot/__init__.py index ee5adfa81e..747b7e9cab 100644 --- a/src/specify_cli/integrations/copilot/__init__.py +++ b/src/specify_cli/integrations/copilot/__init__.py @@ -5,10 +5,9 @@ - Each command gets a companion ``.prompt.md`` file in ``.github/prompts/`` - Installs ``.vscode/settings.json`` with prompt file recommendations -Copilot now defaults to skills mode, scaffolding commands as -``speckit-/SKILL.md`` directories under ``.github/skills/``. -The legacy ``.agent.md`` + ``.prompt.md`` layout remains available for -explicit non-skills installs. +When ``--skills`` is passed via ``--integration-options``, Copilot scaffolds +commands as ``speckit-/SKILL.md`` directories under ``.github/skills/`` +instead. The two modes are mutually exclusive. """ from __future__ import annotations @@ -58,6 +57,17 @@ 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, + ) + + class _CopilotSkillsHelper(SkillsIntegration): """Internal helper used when Copilot is scaffolded in skills mode. @@ -127,8 +137,8 @@ def options(cls) -> list[IntegrationOption]: IntegrationOption( "--skills", is_flag=True, - default=True, - help="Scaffold commands as agent skills (default for Copilot)", + default=False, + help="Scaffold commands as agent skills (speckit-/SKILL.md) instead of .agent.md files", ), ] @@ -309,14 +319,16 @@ def setup( ) -> list[Path]: """Install copilot commands, companion prompts, and VS Code settings. - Defaults to skills scaffolding (``speckit-/SKILL.md`` under - ``.github/skills/``). When ``parsed_options["skills"]`` is falsy, - uses the legacy ``.agent.md`` + ``.prompt.md`` layout. + When ``parsed_options["skills"]`` is truthy, delegates to skills + scaffolding (``speckit-/SKILL.md`` under ``.github/skills/``). + Otherwise uses the default ``.agent.md`` + ``.prompt.md`` layout. """ parsed_options = parsed_options or {} - self._skills_mode = bool(parsed_options.get("skills", True)) + self._skills_mode = bool(parsed_options.get("skills")) if self._skills_mode: return self._setup_skills(project_root, manifest, parsed_options, **opts) + if "skills" not in parsed_options: + _warn_legacy_markdown_default() return self._setup_default(project_root, manifest, parsed_options, **opts) def _setup_default( diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index 0876ece36b..25d4a7c16a 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -68,9 +68,8 @@ def test_integration_copilot_creates_files(self, tmp_path): finally: os.chdir(old_cwd) assert result.exit_code == 0, f"init failed: {result.output}" - assert (project / ".github" / "skills" / "speckit-plan" / "SKILL.md").exists() - assert not (project / ".github" / "agents" / "speckit.plan.agent.md").exists() - assert not (project / ".github" / "prompts" / "speckit.plan.prompt.md").exists() + assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists() + assert (project / ".github" / "prompts" / "speckit.plan.prompt.md").exists() assert (project / ".specify" / "scripts" / "bash" / "common.sh").exists() data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) @@ -111,7 +110,7 @@ def fail_select(*_args, **_kwargs): assert result.exit_code == 0, result.output assert f"defaulting to '{specify_cli.DEFAULT_INIT_INTEGRATION}'" in result.output - assert (project / ".github" / "skills" / "speckit-plan" / "SKILL.md").exists() + assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists() data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) assert data["integration"] == specify_cli.DEFAULT_INIT_INTEGRATION @@ -131,7 +130,7 @@ def test_integration_copilot_auto_promotes(self, tmp_path): finally: os.chdir(old_cwd) assert result.exit_code == 0 - assert (project / ".github" / "skills" / "speckit-plan" / "SKILL.md").exists() + assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists() def test_init_optional_preset_failure_reports_target_and_continues( self, tmp_path, monkeypatch @@ -1122,7 +1121,7 @@ def test_full_init_claude_resolves_page_templates(self, tmp_path): assert "/speckit.specify" not in script_content def test_full_init_copilot_resolves_page_templates(self, tmp_path): - """Full CLI init with Copilot now defaults to skills-mode hyphen refs.""" + """Full CLI init with Copilot (markdown agent) produces dot refs in page templates.""" from typer.testing import CliRunner from specify_cli import app @@ -1144,13 +1143,12 @@ def test_full_init_copilot_resolves_page_templates(self, tmp_path): plan = project / ".specify" / "templates" / "plan-template.md" content = plan.read_text(encoding="utf-8") - assert "/speckit-plan" in content, "Copilot default should use /speckit-plan" - assert "/speckit.plan" not in content + assert "/speckit.plan" in content, "Copilot (markdown) should use /speckit.plan" 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.""" diff --git a/tests/integrations/test_integration_copilot.py b/tests/integrations/test_integration_copilot.py index 7116bcde0e..c5b69b9e7a 100644 --- a/tests/integrations/test_integration_copilot.py +++ b/tests/integrations/test_integration_copilot.py @@ -2,7 +2,9 @@ import json import os +import warnings +import pytest import yaml from specify_cli.integrations import get_integration @@ -27,16 +29,6 @@ def test_setup_creates_agent_md_files(self, tmp_path): copilot = CopilotIntegration() m = IntegrationManifest("copilot", tmp_path) created = copilot.setup(tmp_path, m) - skill_files = [f for f in created if f.name == "SKILL.md"] - assert len(skill_files) > 0 - for f in skill_files: - assert f.parent.parent == tmp_path / ".github" / "skills" - - def test_setup_creates_agent_md_files_when_skills_disabled(self, tmp_path): - from specify_cli.integrations.copilot import CopilotIntegration - copilot = CopilotIntegration() - m = IntegrationManifest("copilot", tmp_path) - created = copilot.setup(tmp_path, m, parsed_options={"skills": False}) assert len(created) > 0 agent_files = [f for f in created if ".agent." in f.name] assert len(agent_files) > 0 @@ -44,11 +36,33 @@ def test_setup_creates_agent_md_files_when_skills_disabled(self, tmp_path): 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 len(caught) == 0 + 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, parsed_options={"skills": False}) + created = copilot.setup(tmp_path, m) prompt_files = [f for f in created if f.parent.name == "prompts"] assert len(prompt_files) > 0 for f in prompt_files: @@ -60,7 +74,7 @@ def test_agent_and_prompt_counts_match(self, tmp_path): from specify_cli.integrations.copilot import CopilotIntegration copilot = CopilotIntegration() m = IntegrationManifest("copilot", tmp_path) - created = copilot.setup(tmp_path, m, parsed_options={"skills": False}) + created = copilot.setup(tmp_path, m) 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) @@ -70,7 +84,7 @@ def test_setup_creates_vscode_settings_new(self, tmp_path): copilot = CopilotIntegration() assert copilot._vscode_settings_path() is not None m = IntegrationManifest("copilot", tmp_path) - created = copilot.setup(tmp_path, m, parsed_options={"skills": False}) + created = copilot.setup(tmp_path, m) settings = tmp_path / ".vscode" / "settings.json" assert settings.exists() assert settings in created @@ -84,7 +98,7 @@ def test_setup_merges_existing_vscode_settings(self, tmp_path): 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, parsed_options={"skills": False}) + created = copilot.setup(tmp_path, m) settings = tmp_path / ".vscode" / "settings.json" data = json.loads(settings.read_text(encoding="utf-8")) assert data["editor.fontSize"] == 14 @@ -131,15 +145,15 @@ def test_directory_structure(self, tmp_path): copilot = CopilotIntegration() m = IntegrationManifest("copilot", tmp_path) copilot.setup(tmp_path, m) - skills_dir = tmp_path / ".github" / "skills" - assert skills_dir.is_dir() - skill_files = sorted(skills_dir.glob("speckit-*/SKILL.md")) - assert len(skill_files) == 10 + agents_dir = tmp_path / ".github" / "agents" + assert agents_dir.is_dir() + agent_files = sorted(agents_dir.glob("speckit.*.agent.md")) + assert len(agent_files) == 10 expected_commands = { "analyze", "clarify", "constitution", "converge", "implement", "plan", "checklist", "specify", "tasks", "taskstoissues", } - actual_commands = {f.parent.name.removeprefix("speckit-") for f in skill_files} + actual_commands = {f.name.removeprefix("speckit.").removesuffix(".agent.md") for f in agent_files} assert actual_commands == expected_commands def test_templates_are_processed(self, tmp_path): @@ -163,7 +177,7 @@ def test_specify_agent_resolves_active_spec_template(self, tmp_path): m = IntegrationManifest("copilot", tmp_path) copilot.setup(tmp_path, m) - specify_file = tmp_path / ".github" / "skills" / "speckit-specify" / "SKILL.md" + specify_file = tmp_path / ".github" / "agents" / "speckit.specify.agent.md" content = specify_file.read_text(encoding="utf-8") assert "specify preset resolve spec-template" in content @@ -178,13 +192,13 @@ def test_plan_command_has_no_context_placeholder(self, tmp_path): copilot = CopilotIntegration() m = IntegrationManifest("copilot", tmp_path) copilot.setup(tmp_path, m) - plan_file = tmp_path / ".github" / "skills" / "speckit-plan" / "SKILL.md" + 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 default Copilot init now uses skills mode.""" + """Every file produced by specify init --integration copilot --script sh.""" from typer.testing import CliRunner from specify_cli import app project = tmp_path / "inventory-sh" @@ -200,10 +214,27 @@ def test_complete_file_inventory_sh(self, tmp_path): assert result.exit_code == 0 actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts) expected = sorted([ - *[f".github/skills/speckit-{cmd}/SKILL.md" for cmd in [ - "analyze", "checklist", "clarify", "constitution", "converge", - "implement", "plan", "specify", "tasks", "taskstoissues", - ]], + ".github/agents/speckit.analyze.agent.md", + ".github/agents/speckit.checklist.agent.md", + ".github/agents/speckit.clarify.agent.md", + ".github/agents/speckit.constitution.agent.md", + ".github/agents/speckit.converge.agent.md", + ".github/agents/speckit.implement.agent.md", + ".github/agents/speckit.plan.agent.md", + ".github/agents/speckit.specify.agent.md", + ".github/agents/speckit.tasks.agent.md", + ".github/agents/speckit.taskstoissues.agent.md", + ".github/prompts/speckit.analyze.prompt.md", + ".github/prompts/speckit.checklist.prompt.md", + ".github/prompts/speckit.clarify.prompt.md", + ".github/prompts/speckit.constitution.prompt.md", + ".github/prompts/speckit.converge.prompt.md", + ".github/prompts/speckit.implement.prompt.md", + ".github/prompts/speckit.plan.prompt.md", + ".github/prompts/speckit.specify.prompt.md", + ".github/prompts/speckit.tasks.prompt.md", + ".github/prompts/speckit.taskstoissues.prompt.md", + ".vscode/settings.json", ".specify/integration.json", ".specify/init-options.json", ".specify/integrations/copilot.manifest.json", @@ -228,7 +259,7 @@ def test_complete_file_inventory_sh(self, tmp_path): ) def test_complete_file_inventory_ps(self, tmp_path): - """Every file produced by default Copilot init with PowerShell uses skills mode.""" + """Every file produced by specify init --integration copilot --script ps.""" from typer.testing import CliRunner from specify_cli import app project = tmp_path / "inventory-ps" @@ -244,10 +275,27 @@ def test_complete_file_inventory_ps(self, tmp_path): assert result.exit_code == 0 actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts) expected = sorted([ - *[f".github/skills/speckit-{cmd}/SKILL.md" for cmd in [ - "analyze", "checklist", "clarify", "constitution", "converge", - "implement", "plan", "specify", "tasks", "taskstoissues", - ]], + ".github/agents/speckit.analyze.agent.md", + ".github/agents/speckit.checklist.agent.md", + ".github/agents/speckit.clarify.agent.md", + ".github/agents/speckit.constitution.agent.md", + ".github/agents/speckit.converge.agent.md", + ".github/agents/speckit.implement.agent.md", + ".github/agents/speckit.plan.agent.md", + ".github/agents/speckit.specify.agent.md", + ".github/agents/speckit.tasks.agent.md", + ".github/agents/speckit.taskstoissues.agent.md", + ".github/prompts/speckit.analyze.prompt.md", + ".github/prompts/speckit.checklist.prompt.md", + ".github/prompts/speckit.clarify.prompt.md", + ".github/prompts/speckit.constitution.prompt.md", + ".github/prompts/speckit.converge.prompt.md", + ".github/prompts/speckit.implement.prompt.md", + ".github/prompts/speckit.plan.prompt.md", + ".github/prompts/speckit.specify.prompt.md", + ".github/prompts/speckit.tasks.prompt.md", + ".github/prompts/speckit.taskstoissues.prompt.md", + ".vscode/settings.json", ".specify/integration.json", ".specify/init-options.json", ".specify/integrations/copilot.manifest.json", @@ -271,6 +319,51 @@ def test_complete_file_inventory_ps(self, tmp_path): 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.""" @@ -297,7 +390,7 @@ def test_options_include_skills_flag(self): skills_opts = [o for o in opts if o.name == "--skills"] assert len(skills_opts) == 1 assert skills_opts[0].is_flag is True - assert skills_opts[0].default is True + assert skills_opts[0].default is False # -- Skills directory structure --------------------------------------- @@ -650,12 +743,14 @@ def test_skills_mode_resets_on_default_setup(self, tmp_path): copilot.setup(tmp_path / "proj1", m1, parsed_options={"skills": True}) assert copilot._skills_mode is True - # Second call: default mode (no explicit skills option) still means skills mode. + # Second call: default mode (no skills option) (tmp_path / "proj2").mkdir() m2 = IntegrationManifest("copilot", tmp_path / "proj2") copilot.setup(tmp_path / "proj2", m2) - assert copilot._skills_mode is True - assert copilot.build_command_invocation("plan", "args") == "/speckit-plan args" + 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/ ------------- diff --git a/tests/integrations/test_integration_subcommand.py b/tests/integrations/test_integration_subcommand.py index 3a246e3b6f..34114a564e 100644 --- a/tests/integrations/test_integration_subcommand.py +++ b/tests/integrations/test_integration_subcommand.py @@ -1794,10 +1794,10 @@ def test_switch_between_integrations(self, tmp_path): # Old claude files removed assert not (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists() - # New copilot files created in default skills layout - 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") + # 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") # integration.json updated data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) @@ -1973,7 +1973,7 @@ def test_switch_does_not_register_disabled_extensions(self, tmp_path): assert "opencode" not in git_meta["registered_commands"] def test_switch_refreshes_managed_shared_script_refs(self, tmp_path): - """Switching to default Copilot keeps managed shared scripts on skills syntax.""" + """Switching refreshes managed shared scripts to the target command style.""" project = _init_project(tmp_path, "claude") shared_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh" assert shared_script.exists() @@ -1993,8 +1993,8 @@ def test_switch_refreshes_managed_shared_script_refs(self, tmp_path): assert shared_script.exists() updated = shared_script.read_text(encoding="utf-8") - assert "/speckit-plan" in updated - assert "/speckit.plan" not in updated + assert "/speckit.plan" in updated + assert "/speckit-plan" not in updated def test_switch_refreshes_stale_managed_shared_infra(self, tmp_path): """Regression for #2293: stale managed shared scripts get refreshed on switch.""" @@ -2032,8 +2032,8 @@ def test_switch_refreshes_stale_managed_shared_infra(self, tmp_path): # Stale managed file should be replaced by the target integration's rendered version. updated = shared_script.read_text(encoding="utf-8") assert "# stale vendored copy" not in updated - assert "/speckit-plan" in updated - assert "/speckit.plan" not in updated + assert "/speckit.plan" in updated + assert "/speckit-plan" not in updated def test_switch_preserves_user_customized_shared_infra(self, tmp_path): """User customizations (hash divergence from manifest) survive switch without --refresh-shared-infra.""" @@ -2084,8 +2084,8 @@ def test_switch_refresh_shared_infra_overwrites_customizations(self, tmp_path): # Customization is overwritten with the target integration's rendered version. updated = shared_script.read_text(encoding="utf-8") assert "# user customization" not in updated - assert "/speckit-plan" in updated - assert "/speckit.plan" not in updated + assert "/speckit.plan" in updated + assert "/speckit-plan" not in updated def test_switch_preserves_recovered_files(self, tmp_path): """Regression for #2918: files marked recovered in the manifest are not overwritten. @@ -2344,8 +2344,8 @@ def test_upgrade_default_refreshes_shared_script_refs_for_option_separator_chang managed_script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh" customized_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh" - assert "/speckit-plan" in template.read_text(encoding="utf-8") - assert "/speckit-specify" in managed_script.read_text(encoding="utf-8") + assert "/speckit.plan" in template.read_text(encoding="utf-8") + assert "/speckit.specify" in managed_script.read_text(encoding="utf-8") customized_before = customized_script.read_text(encoding="utf-8") + "\n# user customization\n" customized_script.write_text(customized_before, encoding="utf-8") @@ -2440,18 +2440,36 @@ def test_upgrade_migrates_opencode_legacy_dir(self, tmp_path): f"found: {[f.name for f in core_remaining]}" ) - def test_upgrade_default_copilot_does_not_create_vscode_settings(self, tmp_path): - """Default Copilot skills mode should keep VS Code settings out of the project.""" + def test_upgrade_preserves_existing_vscode_settings(self, tmp_path): + """Regression: copilot upgrade must not stale-delete .vscode/settings.json. + + On init the file is created and recorded in the manifest. On upgrade, + setup() merges into the now-existing file and intentionally stops + 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") settings = project / ".vscode" / "settings.json" - assert not settings.exists(), "default Copilot init should not create .vscode/settings.json" + assert settings.is_file(), "init should create .vscode/settings.json" + before = json.loads(settings.read_text(encoding="utf-8")) + assert before, "settings.json should contain managed defaults" + + # Simulate a user editing their settings: add a custom key that the + # integration does not manage. It must survive the upgrade. + before["editor.fontSize"] = 17 + settings.write_text(json.dumps(before), encoding="utf-8") result = _run_in_project(project, [ "integration", "upgrade", "copilot", "--script", "sh", "--force", ]) assert result.exit_code == 0, result.output - assert not settings.exists(), "default Copilot upgrade should not create .vscode/settings.json" + + assert settings.is_file(), ".vscode/settings.json must survive upgrade" + after = json.loads(settings.read_text(encoding="utf-8")) + assert after.get("editor.fontSize") == 17, ( + "user-defined settings must be preserved after upgrade" + ) def test_upgrade_restores_executable_bit_on_shared_scripts(self, tmp_path): """Regression: scripts refreshed by the managed-refresh step stay +x.""" From a8c7bdc8d2fea18fcb349c33ca967b0620dbeb73 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 1 Jul 2026 09:33:04 +0800 Subject: [PATCH 3/3] Make Copilot skills warning test less brittle --- tests/integrations/test_integration_copilot.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/integrations/test_integration_copilot.py b/tests/integrations/test_integration_copilot.py index c5b69b9e7a..41261ba1cb 100644 --- a/tests/integrations/test_integration_copilot.py +++ b/tests/integrations/test_integration_copilot.py @@ -55,7 +55,10 @@ def test_skills_setup_does_not_warn_about_legacy_default(self, tmp_path): warnings.simplefilter("always") created = copilot.setup(tmp_path, m, parsed_options={"skills": True}) - assert len(caught) == 0 + 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):