From c59e302cb5f4b98b9ffb743682fa0da3e651237d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 22 Apr 2026 22:48:46 +0000 Subject: [PATCH 1/6] Initial plan From d12ba9bf68fc6c390afb639c18953809a7d0e303 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 22 Apr 2026 22:58:55 +0000 Subject: [PATCH 2/6] feat(copilot): add --skills flag for skills-based scaffolding Add --skills integration option to CopilotIntegration that scaffolds commands as speckit-/SKILL.md under .github/skills/ instead of the default .agent.md + .prompt.md layout. - Add options() with --skills flag (default=False) - Branch setup() between default and skills modes - Add post_process_skill_content() for Copilot-specific mode: field - Adjust build_command_invocation() for skills mode (/speckit-) - Update dispatch_command() with skills mode detection - Parse --integration-options during init command - Add 22 new skills-mode tests - All 15 existing default-mode tests continue to pass Agent-Logs-Url: https://github.com/github/spec-kit/sessions/a4903fab-64ff-46c3-8eb8-a47f495a70c0 Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> --- src/specify_cli/__init__.py | 12 +- .../integrations/copilot/__init__.py | 191 +++++++++- .../integrations/test_integration_copilot.py | 326 ++++++++++++++++++ 3 files changed, 516 insertions(+), 13 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 31f1b765e1..340d7a3732 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1268,6 +1268,12 @@ def init( integration_parsed_options["commands_dir"] = ai_commands_dir if ai_skills: integration_parsed_options["skills"] = True + # Parse --integration-options and merge into parsed_options so + # flags like --skills reach the integration's setup(). + if integration_options: + extra = _parse_integration_options(resolved_integration, integration_options) + if extra: + integration_parsed_options.update(extra) resolved_integration.setup( project_path, manifest, @@ -1393,8 +1399,10 @@ def init( } # Ensure ai_skills is set for SkillsIntegration so downstream # tools (extensions, presets) emit SKILL.md overrides correctly. + # Also set for integrations running in skills mode (e.g. Copilot + # with --skills). from .integrations.base import SkillsIntegration as _SkillsPersist - if isinstance(resolved_integration, _SkillsPersist): + if isinstance(resolved_integration, _SkillsPersist) or getattr(resolved_integration, "_skills_mode", False): init_opts["ai_skills"] = True save_init_options(project_path, init_opts) @@ -2166,7 +2174,7 @@ def _update_init_options_for_integration( opts["context_file"] = integration.context_file if script_type: opts["script"] = script_type - if isinstance(integration, SkillsIntegration): + if isinstance(integration, SkillsIntegration) or getattr(integration, "_skills_mode", False): opts["ai_skills"] = True else: opts.pop("ai_skills", None) diff --git a/src/specify_cli/integrations/copilot/__init__.py b/src/specify_cli/integrations/copilot/__init__.py index 45ec6f3532..0b2c4c6736 100644 --- a/src/specify_cli/integrations/copilot/__init__.py +++ b/src/specify_cli/integrations/copilot/__init__.py @@ -5,6 +5,10 @@ - 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`` + +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 @@ -16,7 +20,7 @@ from pathlib import Path from typing import Any -from ..base import IntegrationBase +from ..base import IntegrationBase, IntegrationOption, SkillsIntegration from ..manifest import IntegrationManifest @@ -44,12 +48,40 @@ def _allow_all() -> bool: return True +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. + """ + + key = "copilot" + config = { + "name": "GitHub Copilot", + "folder": ".github/", + "commands_subdir": "skills", + "install_url": "https://docs.github.com/en/copilot/concepts/agents/copilot-cli/about-copilot-cli", + "requires_cli": False, + } + registrar_config = { + "dir": ".github/skills", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": "/SKILL.md", + } + context_file = ".github/copilot-instructions.md" + + 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-/SKILL.md`` under ``.github/skills/`` + instead of the default ``.agent.md`` + ``.prompt.md`` layout. """ key = "copilot" @@ -68,6 +100,20 @@ class CopilotIntegration(IntegrationBase): } context_file = ".github/copilot-instructions.md" + # Mutable flag set by setup() — indicates the active scaffolding mode. + _skills_mode: bool = False + + @classmethod + def options(cls) -> list[IntegrationOption]: + return [ + IntegrationOption( + "--skills", + is_flag=True, + default=False, + help="Scaffold commands as agent skills (speckit-/SKILL.md) instead of .agent.md files", + ), + ] + def build_exec_args( self, prompt: str, @@ -92,7 +138,19 @@ def build_exec_args( return args def build_command_invocation(self, command_name: str, args: str = "") -> str: - """Copilot agents are not slash-commands — just return the args as prompt.""" + """Build the native invocation for a Copilot command. + + Default mode: agents are not slash-commands — return args as prompt. + Skills mode: ``/speckit-`` slash-command dispatch. + """ + if self._skills_mode: + stem = command_name + if "." in stem: + stem = stem.rsplit(".", 1)[-1] + invocation = f"/speckit-{stem}" + if args: + invocation = f"{invocation} {args}" + return invocation return args or "" def dispatch_command( @@ -110,19 +168,32 @@ def dispatch_command( Copilot ``.agent.md`` files are agents, not skills. The CLI selects them with ``--agent `` and the prompt is just the user's arguments. + + In skills mode, the prompt includes the skill invocation + (``/speckit-``). """ import subprocess stem = command_name if "." in stem: stem = stem.rsplit(".", 1)[-1] - agent_name = f"speckit.{stem}" - prompt = args or "" - cli_args = [ - "copilot", "-p", prompt, - "--agent", agent_name, - ] + # 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() and any(skills_dir.iterdir()): + skills_mode = True + + if skills_mode: + prompt = self.build_command_invocation(command_name, args) + else: + agent_name = f"speckit.{stem}" + prompt = args or "" + + cli_args = ["copilot", "-p", prompt] + if not skills_mode: + cli_args.extend(["--agent", f"speckit.{stem}"]) if _allow_all(): cli_args.append("--yolo") if model: @@ -168,6 +239,59 @@ def command_filename(self, template_name: str) -> str: """Copilot commands use ``.agent.md`` extension.""" return f"speckit.{template_name}.agent.md" + def post_process_skill_content(self, content: str) -> str: + """Inject Copilot-specific ``mode:`` field into SKILL.md frontmatter. + + Inserts ``mode: "speckit."`` before the closing ``---`` so + Copilot can associate the skill with its agent mode. + """ + lines = content.splitlines(keepends=True) + + # Extract skill name from frontmatter to derive the mode value + dash_count = 0 + skill_name = "" + for line in lines: + stripped = line.rstrip("\n\r") + if stripped == "---": + dash_count += 1 + if dash_count == 2: + break + continue + if dash_count == 1: + if stripped.startswith("mode:"): + return content # already present + if stripped.startswith("name:"): + # Parse: name: "speckit-plan" → speckit.plan + val = stripped.split(":", 1)[1].strip().strip('"').strip("'") + # Convert speckit-plan → speckit.plan + if val.startswith("speckit-"): + skill_name = "speckit." + val[len("speckit-"):] + else: + skill_name = val + + if not skill_name: + return content + + # Inject mode: before the closing --- of frontmatter + out: list[str] = [] + dash_count = 0 + injected = False + for line in lines: + stripped = line.rstrip("\n\r") + if stripped == "---": + dash_count += 1 + if dash_count == 2 and not injected: + if line.endswith("\r\n"): + eol = "\r\n" + elif line.endswith("\n"): + eol = "\n" + else: + eol = "" + out.append(f"mode: {skill_name}{eol}") + injected = True + out.append(line) + return "".join(out) + def setup( self, project_root: Path, @@ -177,10 +301,24 @@ def setup( ) -> list[Path]: """Install copilot commands, companion prompts, and VS Code settings. - Uses base class primitives to: read templates, process them - (replace placeholders, strip script blocks, rewrite paths), - write as ``.agent.md``, then add 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. """ + parsed_options = parsed_options or {} + if parsed_options.get("skills"): + self._skills_mode = True + return self._setup_skills(project_root, manifest, parsed_options, **opts) + return self._setup_default(project_root, manifest, parsed_options, **opts) + + def _setup_default( + 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.""" project_root_resolved = project_root.resolve() if manifest.project_root != project_root_resolved: raise ValueError( @@ -252,6 +390,37 @@ def setup( return created + def _setup_skills( + self, + project_root: Path, + manifest: IntegrationManifest, + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ) -> list[Path]: + """Skills mode: delegate to ``_CopilotSkillsHelper`` then post-process.""" + helper = _CopilotSkillsHelper() + created = SkillsIntegration.setup( + helper, project_root, manifest, parsed_options, **opts + ) + + # Post-process generated skill files with Copilot-specific frontmatter + skills_dir = helper.skills_dest(project_root).resolve() + for path in created: + try: + path.resolve().relative_to(skills_dir) + except ValueError: + continue + if path.name != "SKILL.md": + continue + + content = path.read_text(encoding="utf-8") + updated = self.post_process_skill_content(content) + if updated != content: + path.write_bytes(updated.encode("utf-8")) + self.record_file_in_manifest(path, project_root, manifest) + + return created + def _vscode_settings_path(self) -> Path | None: """Return path to the bundled vscode-settings.json template.""" tpl_dir = self.shared_templates_dir() diff --git a/tests/integrations/test_integration_copilot.py b/tests/integrations/test_integration_copilot.py index 642b1e5300..a777fc1db4 100644 --- a/tests/integrations/test_integration_copilot.py +++ b/tests/integrations/test_integration_copilot.py @@ -3,6 +3,8 @@ import json import os +import yaml + from specify_cli.integrations import get_integration from specify_cli.integrations.manifest import IntegrationManifest @@ -275,3 +277,327 @@ def test_complete_file_inventory_ps(self, tmp_path): f"Missing: {sorted(set(expected) - set(actual))}\n" f"Extra: {sorted(set(actual) - set(expected))}" ) + + +class TestCopilotSkillsMode: + """Tests for Copilot integration in --skills mode.""" + + _SKILL_COMMANDS = [ + "analyze", "checklist", "clarify", "constitution", + "implement", "plan", "specify", "tasks", "taskstoissues", + ] + + def _make_copilot(self): + from specify_cli.integrations.copilot import CopilotIntegration + return CopilotIntegration() + + def _setup_skills(self, copilot, tmp_path): + m = IntegrationManifest("copilot", tmp_path) + created = copilot.setup(tmp_path, m, parsed_options={"skills": True}) + return created, m + + # -- Options ---------------------------------------------------------- + + def test_options_include_skills_flag(self): + copilot = get_integration("copilot") + opts = copilot.options() + 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 + + # -- Skills directory structure --------------------------------------- + + def test_skills_creates_skill_files(self, tmp_path): + copilot = self._make_copilot() + created, _ = self._setup_skills(copilot, tmp_path) + assert len(created) > 0 + skill_files = [f for f in created if f.name == "SKILL.md"] + assert len(skill_files) > 0 + for f in skill_files: + assert f.exists() + assert f.parent.name.startswith("speckit-") + + def test_skills_directory_under_github_skills(self, tmp_path): + copilot = self._make_copilot() + created, _ = self._setup_skills(copilot, tmp_path) + skills_dir = tmp_path / ".github" / "skills" + assert skills_dir.is_dir() + skill_files = [f for f in created if f.name == "SKILL.md"] + for f in skill_files: + assert f.resolve().parent.parent == skills_dir.resolve(), ( + f"{f} is not under {skills_dir}" + ) + + def test_skills_directory_structure(self, tmp_path): + """Each command produces speckit-/SKILL.md.""" + copilot = self._make_copilot() + created, _ = self._setup_skills(copilot, tmp_path) + skill_files = [f for f in created if f.name == "SKILL.md"] + expected_commands = set(self._SKILL_COMMANDS) + actual_commands = set() + for f in skill_files: + skill_dir_name = f.parent.name + assert skill_dir_name.startswith("speckit-") + actual_commands.add(skill_dir_name.removeprefix("speckit-")) + assert actual_commands == expected_commands + + # -- No companion files in skills mode -------------------------------- + + def test_skills_no_prompt_md_companions(self, tmp_path): + """Skills mode must not generate .prompt.md companion files.""" + copilot = self._make_copilot() + created, _ = self._setup_skills(copilot, tmp_path) + prompt_files = [f for f in created if f.name.endswith(".prompt.md")] + assert prompt_files == [] + prompts_dir = tmp_path / ".github" / "prompts" + if prompts_dir.exists(): + assert list(prompts_dir.iterdir()) == [] + + def test_skills_no_vscode_settings(self, tmp_path): + """Skills mode must not create or merge .vscode/settings.json.""" + copilot = self._make_copilot() + self._setup_skills(copilot, tmp_path) + settings = tmp_path / ".vscode" / "settings.json" + assert not settings.exists() + + def test_skills_no_agent_md_files(self, tmp_path): + """Skills mode must not produce .agent.md files.""" + copilot = self._make_copilot() + created, _ = self._setup_skills(copilot, tmp_path) + agent_files = [f for f in created if f.name.endswith(".agent.md")] + assert agent_files == [] + + # -- Frontmatter structure -------------------------------------------- + + def test_skill_frontmatter_structure(self, tmp_path): + """SKILL.md must have name, description, compatibility, metadata.""" + copilot = self._make_copilot() + created, _ = self._setup_skills(copilot, tmp_path) + skill_files = [f for f in created if f.name == "SKILL.md"] + for f in skill_files: + content = f.read_text(encoding="utf-8") + assert content.startswith("---\n"), f"{f} missing frontmatter" + parts = content.split("---", 2) + fm = yaml.safe_load(parts[1]) + assert "name" in fm, f"{f} frontmatter missing 'name'" + assert "description" in fm, f"{f} frontmatter missing 'description'" + assert "compatibility" in fm, f"{f} frontmatter missing 'compatibility'" + assert "metadata" in fm, f"{f} frontmatter missing 'metadata'" + assert fm["metadata"]["author"] == "github-spec-kit" + + # -- Copilot-specific post-processing --------------------------------- + + def test_post_process_skill_content_injects_mode(self): + """post_process_skill_content() should inject mode: field.""" + copilot = self._make_copilot() + content = ( + "---\n" + 'name: "speckit-plan"\n' + 'description: "Plan workflow"\n' + "---\n" + "\nBody content\n" + ) + updated = copilot.post_process_skill_content(content) + assert "mode: speckit.plan" in updated + + def test_post_process_idempotent(self): + """post_process_skill_content() must be idempotent.""" + copilot = self._make_copilot() + content = ( + "---\n" + 'name: "speckit-plan"\n' + 'description: "Plan workflow"\n' + "---\n" + "\nBody content\n" + ) + first = copilot.post_process_skill_content(content) + second = copilot.post_process_skill_content(first) + assert first == second + + def test_skills_have_mode_in_frontmatter(self, tmp_path): + """Generated SKILL.md files should have mode: field from post-processing.""" + copilot = self._make_copilot() + created, _ = self._setup_skills(copilot, tmp_path) + skill_files = [f for f in created if f.name == "SKILL.md"] + assert len(skill_files) > 0 + for f in skill_files: + content = f.read_text(encoding="utf-8") + parts = content.split("---", 2) + fm = yaml.safe_load(parts[1]) + assert "mode" in fm, f"{f} frontmatter missing 'mode'" + # mode should be speckit. + skill_dir_name = f.parent.name + stem = skill_dir_name.removeprefix("speckit-") + assert fm["mode"] == f"speckit.{stem}" + + # -- Template processing ---------------------------------------------- + + def test_skills_templates_are_processed(self, tmp_path): + """Skill body must have placeholders replaced.""" + copilot = self._make_copilot() + created, _ = self._setup_skills(copilot, tmp_path) + skill_files = [f for f in created if f.name == "SKILL.md"] + assert len(skill_files) > 0 + for f in skill_files: + content = f.read_text(encoding="utf-8") + assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}" + assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__" + assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}" + + def test_skill_body_has_content(self, tmp_path): + """Each SKILL.md body should contain template content.""" + copilot = self._make_copilot() + created, _ = self._setup_skills(copilot, tmp_path) + skill_files = [f for f in created if f.name == "SKILL.md"] + for f in skill_files: + content = f.read_text(encoding="utf-8") + parts = content.split("---", 2) + body = parts[2].strip() if len(parts) >= 3 else "" + assert len(body) > 0, f"{f} has empty body" + + def test_plan_references_correct_context_file(self, tmp_path): + """The generated plan skill must reference copilot's context file.""" + copilot = self._make_copilot() + self._setup_skills(copilot, tmp_path) + plan_file = tmp_path / ".github" / "skills" / "speckit-plan" / "SKILL.md" + assert plan_file.exists() + content = plan_file.read_text(encoding="utf-8") + assert copilot.context_file in content + assert "__CONTEXT_FILE__" not in content + + # -- Manifest tracking ------------------------------------------------ + + def test_all_files_tracked_in_manifest(self, tmp_path): + copilot = self._make_copilot() + created, m = self._setup_skills(copilot, tmp_path) + for f in created: + rel = f.resolve().relative_to(tmp_path.resolve()).as_posix() + assert rel in m.files, f"{rel} not tracked in manifest" + + # -- Install/uninstall roundtrip -------------------------------------- + + 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}) + assert len(created) > 0 + m.save() + for f in created: + assert f.exists() + removed, skipped = copilot.uninstall(tmp_path, m) + assert len(removed) == len(created) + assert skipped == [] + + 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}) + m.save() + modified_file = created[0] + modified_file.write_text("user modified this", encoding="utf-8") + removed, skipped = copilot.uninstall(tmp_path, m) + assert modified_file.exists() + assert modified_file in skipped + + # -- build_command_invocation ----------------------------------------- + + def test_build_command_invocation_skills_mode(self): + copilot = self._make_copilot() + copilot._skills_mode = True + assert copilot.build_command_invocation("speckit.plan") == "/speckit-plan" + assert copilot.build_command_invocation("plan") == "/speckit-plan" + assert copilot.build_command_invocation("plan", "my args") == "/speckit-plan my args" + + def test_build_command_invocation_default_mode(self): + copilot = self._make_copilot() + assert copilot.build_command_invocation("plan", "my args") == "my args" + assert copilot.build_command_invocation("plan") == "" + + # -- Context section --------------------------------------------------- + + def test_skills_setup_upserts_context_section(self, tmp_path): + copilot = self._make_copilot() + self._setup_skills(copilot, tmp_path) + ctx_path = tmp_path / copilot.context_file + assert ctx_path.exists() + content = ctx_path.read_text(encoding="utf-8") + assert "" in content + assert "" in content + + # -- CLI integration test --------------------------------------------- + + def test_init_with_integration_options_skills(self, tmp_path): + """specify init --integration copilot --integration-options='--skills' scaffolds skills.""" + from typer.testing import CliRunner + from specify_cli import app + project = tmp_path / "copilot-skills" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", "copilot", + "--integration-options", "--skills", + "--script", "sh", "--no-git", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init failed: {result.output}" + skills_dir = project / ".github" / "skills" + assert skills_dir.is_dir(), "Skills directory was not created" + plan_skill = skills_dir / "speckit-plan" / "SKILL.md" + assert plan_skill.exists(), "speckit-plan/SKILL.md not found" + # Verify no default-mode artifacts + assert not (project / ".github" / "agents").exists() + assert not (project / ".github" / "prompts").exists() + 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.""" + from typer.testing import CliRunner + from specify_cli import app + project = tmp_path / "inventory-skills-sh" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", "copilot", + "--integration-options", "--skills", + "--script", "sh", "--no-git", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init failed: {result.output}" + actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file()) + expected = sorted([ + # Skill files + *[f".github/skills/speckit-{cmd}/SKILL.md" for cmd in self._SKILL_COMMANDS], + # Context file + ".github/copilot-instructions.md", + # Integration metadata + ".specify/init-options.json", + ".specify/integration.json", + ".specify/integrations/copilot.manifest.json", + ".specify/integrations/speckit.manifest.json", + # Scripts (sh) + ".specify/scripts/bash/check-prerequisites.sh", + ".specify/scripts/bash/common.sh", + ".specify/scripts/bash/create-new-feature.sh", + ".specify/scripts/bash/setup-plan.sh", + # Templates + ".specify/templates/checklist-template.md", + ".specify/templates/constitution-template.md", + ".specify/templates/plan-template.md", + ".specify/templates/spec-template.md", + ".specify/templates/tasks-template.md", + ".specify/memory/constitution.md", + # Bundled workflow + ".specify/workflows/speckit/workflow.yml", + ".specify/workflows/workflow-registry.json", + ]) + assert actual == expected, ( + f"Missing: {sorted(set(expected) - set(actual))}\n" + f"Extra: {sorted(set(actual) - set(expected))}" + ) \ No newline at end of file From 17fdc9acbf6573daf2022e92ba46d5165c53a23a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 22 Apr 2026 22:59:57 +0000 Subject: [PATCH 3/6] docs(AGENTS.md): document Copilot --skills option Agent-Logs-Url: https://github.com/github/spec-kit/sessions/a4903fab-64ff-46c3-8eb8-a47f495a70c0 Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> --- AGENTS.md | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2b076dc384..7adfd1d12e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -264,13 +264,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 | -| `setup()` | Custom install logic (companion files, settings merge) | Copilot → `.agent.md` + `.prompt.md` + `.vscode/settings.json` | +| `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-/SKILL.md` (skills mode) | | `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`. See `src/specify_cli/integrations/copilot/__init__.py` for the full implementation. +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-/SKILL.md` under `.github/skills/` using composition with an internal `_CopilotSkillsHelper`. See `src/specify_cli/integrations/copilot/__init__.py` for the full implementation. ### 7. Update Devcontainer files (Optional) @@ -391,6 +391,24 @@ Implementation: Extends `IntegrationBase` with custom `setup()` method that: 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-/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.` frontmatter field +- `build_command_invocation()` returns `/speckit-` instead of bare args + +The two modes are mutually exclusive — a project uses one or the other: + +```bash +# Default mode: .agent.md agents + .prompt.md companions + settings merge +specify init my-project --integration copilot + +# Skills mode: speckit-/SKILL.md under .github/skills/ +specify init my-project --integration copilot --integration-options="--skills" +``` + ### Forge Integration Forge has special frontmatter and argument requirements: From 59227077cccd0dd69ef6aba511d0cfdcf4462aa5 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 22 Apr 2026 18:05:46 -0500 Subject: [PATCH 4/6] Potential fix for pull request finding 'Unused local variable' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- src/specify_cli/integrations/copilot/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/specify_cli/integrations/copilot/__init__.py b/src/specify_cli/integrations/copilot/__init__.py index 0b2c4c6736..500f021288 100644 --- a/src/specify_cli/integrations/copilot/__init__.py +++ b/src/specify_cli/integrations/copilot/__init__.py @@ -193,7 +193,7 @@ def dispatch_command( cli_args = ["copilot", "-p", prompt] if not skills_mode: - cli_args.extend(["--agent", f"speckit.{stem}"]) + cli_args.extend(["--agent", agent_name]) if _allow_all(): cli_args.append("--yolo") if model: From 5938c220d57937f3f5544c8c7ca3cabc041cd67f Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 22 Apr 2026 18:57:37 -0500 Subject: [PATCH 5/6] fix: address PR #2324 review feedback - Reset _skills_mode at start of setup() to prevent singleton state leak - Tighten skills auto-detection to require speckit-*/SKILL.md (not any non-empty .github/skills/ directory) - Add copilot_skill_mode to init next-steps so skills mode renders /speckit-plan instead of /speckit.plan - Fix docstring quoting to match actual unquoted output - Add 4 tests covering singleton reset, auto-detection false positive, speckit layout detection, and next-steps skill syntax - Fix skipped test_invalid_metadata_error_returns_unknown by simulating InvalidMetadataError on Python versions that lack it --- src/specify_cli/__init__.py | 7 +- .../integrations/copilot/__init__.py | 13 +-- .../integrations/test_integration_copilot.py | 86 +++++++++++++++++++ tests/test_upgrade.py | 25 ++++-- 4 files changed, 117 insertions(+), 14 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 340d7a3732..743ceb9954 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1514,7 +1514,7 @@ def init( # Determine skill display mode for the next-steps panel. # Skills integrations (codex, kimi, agy, trae, cursor-agent) should show skill invocation syntax. from .integrations.base import SkillsIntegration as _SkillsInt - _is_skills_integration = isinstance(resolved_integration, _SkillsInt) + _is_skills_integration = isinstance(resolved_integration, _SkillsInt) or getattr(resolved_integration, "_skills_mode", False) codex_skill_mode = selected_ai == "codex" and (ai_skills or _is_skills_integration) claude_skill_mode = selected_ai == "claude" and (ai_skills or _is_skills_integration) @@ -1522,7 +1522,8 @@ def init( agy_skill_mode = selected_ai == "agy" and _is_skills_integration trae_skill_mode = selected_ai == "trae" cursor_agent_skill_mode = selected_ai == "cursor-agent" and (ai_skills or _is_skills_integration) - native_skill_mode = codex_skill_mode or claude_skill_mode or kimi_skill_mode or agy_skill_mode or trae_skill_mode or cursor_agent_skill_mode + copilot_skill_mode = selected_ai == "copilot" and _is_skills_integration + native_skill_mode = codex_skill_mode or claude_skill_mode or kimi_skill_mode or agy_skill_mode or trae_skill_mode or cursor_agent_skill_mode or copilot_skill_mode if codex_skill_mode and not ai_skills: # Integration path installed skills; show the helpful notice @@ -1543,7 +1544,7 @@ def _display_cmd(name: str) -> str: return f"/speckit-{name}" if kimi_skill_mode: return f"/skill:speckit-{name}" - if cursor_agent_skill_mode: + if cursor_agent_skill_mode or copilot_skill_mode: return f"/speckit-{name}" return f"/speckit.{name}" diff --git a/src/specify_cli/integrations/copilot/__init__.py b/src/specify_cli/integrations/copilot/__init__.py index 500f021288..8fc0392492 100644 --- a/src/specify_cli/integrations/copilot/__init__.py +++ b/src/specify_cli/integrations/copilot/__init__.py @@ -182,8 +182,11 @@ def dispatch_command( skills_mode = self._skills_mode if not skills_mode and project_root: skills_dir = project_root / ".github" / "skills" - if skills_dir.is_dir() and any(skills_dir.iterdir()): - skills_mode = True + if skills_dir.is_dir(): + skills_mode = any( + d.is_dir() and (d / "SKILL.md").is_file() + for d in skills_dir.glob("speckit-*") + ) if skills_mode: prompt = self.build_command_invocation(command_name, args) @@ -242,7 +245,7 @@ def command_filename(self, template_name: str) -> str: def post_process_skill_content(self, content: str) -> str: """Inject Copilot-specific ``mode:`` field into SKILL.md frontmatter. - Inserts ``mode: "speckit."`` before the closing ``---`` so + Inserts ``mode: speckit.`` before the closing ``---`` so Copilot can associate the skill with its agent mode. """ lines = content.splitlines(keepends=True) @@ -306,8 +309,8 @@ def setup( Otherwise uses the default ``.agent.md`` + ``.prompt.md`` layout. """ parsed_options = parsed_options or {} - if parsed_options.get("skills"): - self._skills_mode = True + self._skills_mode = bool(parsed_options.get("skills")) + 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_integration_copilot.py b/tests/integrations/test_integration_copilot.py index a777fc1db4..12e082ede3 100644 --- a/tests/integrations/test_integration_copilot.py +++ b/tests/integrations/test_integration_copilot.py @@ -600,4 +600,90 @@ def test_complete_file_inventory_skills_sh(self, tmp_path): assert actual == expected, ( f"Missing: {sorted(set(expected) - set(actual))}\n" f"Extra: {sorted(set(actual) - set(expected))}" + ) + + # -- 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.""" + copilot = self._make_copilot() + + # First call: skills mode + (tmp_path / "proj1").mkdir() + m1 = IntegrationManifest("copilot", tmp_path / "proj1") + copilot.setup(tmp_path / "proj1", m1, parsed_options={"skills": True}) + assert copilot._skills_mode is True + + # 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 False + + # build_command_invocation must use default (dotted) mode + assert copilot.build_command_invocation("plan", "args") == "args" + + # -- Auto-detection must ignore unrelated .github/skills/ ------------- + + def test_dispatch_ignores_unrelated_skills_directory(self, tmp_path): + """dispatch_command() must not treat unrelated .github/skills/ as skills mode.""" + copilot = self._make_copilot() + # Create a .github/skills/ with non-speckit content (e.g. GitHub Skills training) + 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="") + copilot.dispatch_command("plan", "my args", project_root=tmp_path, stream=False) + call_args = mock_run.call_args[0][0] + assert "--agent" in call_args, ( + f"Expected --agent in cli_args but got: {call_args}" + ) + assert "speckit.plan" in call_args + + def test_dispatch_detects_speckit_skills_layout(self, tmp_path): + """dispatch_command() detects speckit-*/SKILL.md as skills mode.""" + copilot = self._make_copilot() + skill_dir = tmp_path / ".github" / "skills" / "speckit-plan" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text("---\nname: speckit-plan\n---\n") + + import unittest.mock as mock + with mock.patch("subprocess.run") as mock_run: + mock_run.return_value = mock.Mock(returncode=0, stdout="", stderr="") + copilot.dispatch_command("plan", "my args", project_root=tmp_path, stream=False) + call_args = mock_run.call_args[0][0] + assert "--agent" not in call_args, ( + f"Skills mode should not use --agent, got: {call_args}" + ) + + # -- 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.""" + from typer.testing import CliRunner + from specify_cli import app + project = tmp_path / "copilot-nextsteps" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", "copilot", + "--integration-options", "--skills", + "--script", "sh", "--no-git", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init failed: {result.output}" + # Skills mode should show /speckit-plan (hyphenated) + assert "/speckit-plan" in result.output, ( + f"Expected /speckit-plan in next steps but got:\n{result.output}" + ) + # Must NOT show the dotted /speckit.plan form + assert "/speckit.plan" not in result.output, ( + f"Should not show /speckit.plan in skills mode:\n{result.output}" ) \ No newline at end of file diff --git a/tests/test_upgrade.py b/tests/test_upgrade.py index 96aa627874..28a0ce6414 100644 --- a/tests/test_upgrade.py +++ b/tests/test_upgrade.py @@ -100,12 +100,25 @@ class TestInstalledVersion: def test_invalid_metadata_error_returns_unknown(self): invalid_metadata_error = getattr(importlib.metadata, "InvalidMetadataError", None) if invalid_metadata_error is None: - pytest.skip("InvalidMetadataError is not available on this Python version") - with patch( - "importlib.metadata.version", - side_effect=invalid_metadata_error("bad metadata"), - ): - assert _get_installed_version() == "unknown" + # Python versions without InvalidMetadataError: simulate with a + # custom exception to verify the guarded except path works. + class _FakeInvalidMetadataError(Exception): + pass + invalid_metadata_error = _FakeInvalidMetadataError + # Patch the attribute onto importlib.metadata so the production + # getattr() finds it during this test. + with patch.object(importlib.metadata, "InvalidMetadataError", invalid_metadata_error, create=True): + with patch( + "importlib.metadata.version", + side_effect=invalid_metadata_error("bad metadata"), + ): + assert _get_installed_version() == "unknown" + else: + with patch( + "importlib.metadata.version", + side_effect=invalid_metadata_error("bad metadata"), + ): + assert _get_installed_version() == "unknown" class TestNormalizeTag: From d28afb72f5987791d0d72127dc42a80a14eddf63 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 23 Apr 2026 07:14:48 -0500 Subject: [PATCH 6/6] fix: inline skills prompt in dispatch_command auto-detection path build_command_invocation() reads self._skills_mode which stays False when skills mode is only auto-detected from the project layout. Inline the /speckit- prompt construction so dispatch_command() sends the correct prompt regardless of how skills mode was detected. Also strengthen test_dispatch_detects_speckit_skills_layout to assert the -p prompt contains /speckit-plan and the user args. --- src/specify_cli/integrations/copilot/__init__.py | 4 +++- tests/integrations/test_integration_copilot.py | 7 +++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/integrations/copilot/__init__.py b/src/specify_cli/integrations/copilot/__init__.py index 8fc0392492..5c4d0e5410 100644 --- a/src/specify_cli/integrations/copilot/__init__.py +++ b/src/specify_cli/integrations/copilot/__init__.py @@ -189,7 +189,9 @@ def dispatch_command( ) if skills_mode: - prompt = self.build_command_invocation(command_name, args) + prompt = f"/speckit-{stem}" + if args: + prompt = f"{prompt} {args}" else: agent_name = f"speckit.{stem}" prompt = args or "" diff --git a/tests/integrations/test_integration_copilot.py b/tests/integrations/test_integration_copilot.py index 12e082ede3..462f6d120a 100644 --- a/tests/integrations/test_integration_copilot.py +++ b/tests/integrations/test_integration_copilot.py @@ -659,6 +659,13 @@ def test_dispatch_detects_speckit_skills_layout(self, tmp_path): assert "--agent" not in call_args, ( f"Skills mode should not use --agent, got: {call_args}" ) + prompt = call_args[call_args.index("-p") + 1] + assert "/speckit-plan" in prompt, ( + f"Skills mode prompt should invoke /speckit-plan, got: {prompt}" + ) + assert "my args" in prompt, ( + f"Skills mode prompt should preserve user args, got: {prompt}" + ) # -- Next-steps display for Copilot skills mode -----------------------