Skip to content

Commit 8fefd2a

Browse files
Copilotmnriemgithub-code-quality[bot]
authored
feat(copilot): support --integration-options="--skills" for skills-based scaffolding (#2324)
* Initial plan * feat(copilot): add --skills flag for skills-based scaffolding Add --skills integration option to CopilotIntegration that scaffolds commands as speckit-<name>/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-<stem>) - 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> * 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> * 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> * 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 * 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-<stem> 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. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
1 parent b278d66 commit 8fefd2a

5 files changed

Lines changed: 658 additions & 25 deletions

File tree

AGENTS.md

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -264,13 +264,13 @@ The base classes handle most work automatically. Override only when the agent de
264264
| Override | When to use | Example |
265265
|---|---|---|
266266
| `command_filename(template_name)` | Custom file naming or extension | Copilot → `speckit.{name}.agent.md` |
267-
| `options()` | Integration-specific CLI flags via `--integration-options` | Codex → `--skills` flag |
268-
| `setup()` | Custom install logic (companion files, settings merge) | Copilot → `.agent.md` + `.prompt.md` + `.vscode/settings.json` |
267+
| `options()` | Integration-specific CLI flags via `--integration-options` | Codex → `--skills` flag, Copilot → `--skills` flag |
268+
| `setup()` | Custom install logic (companion files, settings merge) | Copilot → `.agent.md` + `.prompt.md` + `.vscode/settings.json` (default) or `speckit-<name>/SKILL.md` (skills mode) |
269269
| `teardown()` | Custom uninstall logic | Rarely needed; base handles manifest-tracked files |
270270

271271
**Example — Copilot (fully custom `setup`):**
272272

273-
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.
273+
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.
274274

275275
### 7. Update Devcontainer files (Optional)
276276

@@ -391,6 +391,24 @@ Implementation: Extends `IntegrationBase` with custom `setup()` method that:
391391
2. Generates companion `.prompt.md` files
392392
3. Merges VS Code settings
393393

394+
**Skills mode (`--skills`):** Copilot also supports an alternative skills-based layout
395+
via `--integration-options="--skills"`. When enabled:
396+
- Commands are scaffolded as `speckit-<name>/SKILL.md` under `.github/skills/`
397+
- No companion `.prompt.md` files are generated
398+
- No `.vscode/settings.json` merge
399+
- `post_process_skill_content()` injects a `mode: speckit.<stem>` frontmatter field
400+
- `build_command_invocation()` returns `/speckit-<stem>` instead of bare args
401+
402+
The two modes are mutually exclusive — a project uses one or the other:
403+
404+
```bash
405+
# Default mode: .agent.md agents + .prompt.md companions + settings merge
406+
specify init my-project --integration copilot
407+
408+
# Skills mode: speckit-<name>/SKILL.md under .github/skills/
409+
specify init my-project --integration copilot --integration-options="--skills"
410+
```
411+
394412
### Forge Integration
395413

396414
Forge has special frontmatter and argument requirements:

src/specify_cli/__init__.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1268,6 +1268,12 @@ def init(
12681268
integration_parsed_options["commands_dir"] = ai_commands_dir
12691269
if ai_skills:
12701270
integration_parsed_options["skills"] = True
1271+
# Parse --integration-options and merge into parsed_options so
1272+
# flags like --skills reach the integration's setup().
1273+
if integration_options:
1274+
extra = _parse_integration_options(resolved_integration, integration_options)
1275+
if extra:
1276+
integration_parsed_options.update(extra)
12711277

12721278
resolved_integration.setup(
12731279
project_path, manifest,
@@ -1393,8 +1399,10 @@ def init(
13931399
}
13941400
# Ensure ai_skills is set for SkillsIntegration so downstream
13951401
# tools (extensions, presets) emit SKILL.md overrides correctly.
1402+
# Also set for integrations running in skills mode (e.g. Copilot
1403+
# with --skills).
13961404
from .integrations.base import SkillsIntegration as _SkillsPersist
1397-
if isinstance(resolved_integration, _SkillsPersist):
1405+
if isinstance(resolved_integration, _SkillsPersist) or getattr(resolved_integration, "_skills_mode", False):
13981406
init_opts["ai_skills"] = True
13991407
save_init_options(project_path, init_opts)
14001408

@@ -1506,15 +1514,16 @@ def init(
15061514
# Determine skill display mode for the next-steps panel.
15071515
# Skills integrations (codex, kimi, agy, trae, cursor-agent) should show skill invocation syntax.
15081516
from .integrations.base import SkillsIntegration as _SkillsInt
1509-
_is_skills_integration = isinstance(resolved_integration, _SkillsInt)
1517+
_is_skills_integration = isinstance(resolved_integration, _SkillsInt) or getattr(resolved_integration, "_skills_mode", False)
15101518

15111519
codex_skill_mode = selected_ai == "codex" and (ai_skills or _is_skills_integration)
15121520
claude_skill_mode = selected_ai == "claude" and (ai_skills or _is_skills_integration)
15131521
kimi_skill_mode = selected_ai == "kimi"
15141522
agy_skill_mode = selected_ai == "agy" and _is_skills_integration
15151523
trae_skill_mode = selected_ai == "trae"
15161524
cursor_agent_skill_mode = selected_ai == "cursor-agent" and (ai_skills or _is_skills_integration)
1517-
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
1525+
copilot_skill_mode = selected_ai == "copilot" and _is_skills_integration
1526+
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
15181527

15191528
if codex_skill_mode and not ai_skills:
15201529
# Integration path installed skills; show the helpful notice
@@ -1535,7 +1544,7 @@ def _display_cmd(name: str) -> str:
15351544
return f"/speckit-{name}"
15361545
if kimi_skill_mode:
15371546
return f"/skill:speckit-{name}"
1538-
if cursor_agent_skill_mode:
1547+
if cursor_agent_skill_mode or copilot_skill_mode:
15391548
return f"/speckit-{name}"
15401549
return f"/speckit.{name}"
15411550

@@ -2166,7 +2175,7 @@ def _update_init_options_for_integration(
21662175
opts["context_file"] = integration.context_file
21672176
if script_type:
21682177
opts["script"] = script_type
2169-
if isinstance(integration, SkillsIntegration):
2178+
if isinstance(integration, SkillsIntegration) or getattr(integration, "_skills_mode", False):
21702179
opts["ai_skills"] = True
21712180
else:
21722181
opts.pop("ai_skills", None)

src/specify_cli/integrations/copilot/__init__.py

Lines changed: 185 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55
- Each command gets a companion ``.prompt.md`` file in ``.github/prompts/``
66
- Installs ``.vscode/settings.json`` with prompt file recommendations
77
- Context file lives at ``.github/copilot-instructions.md``
8+
9+
When ``--skills`` is passed via ``--integration-options``, Copilot scaffolds
10+
commands as ``speckit-<name>/SKILL.md`` directories under ``.github/skills/``
11+
instead. The two modes are mutually exclusive.
812
"""
913

1014
from __future__ import annotations
@@ -16,7 +20,7 @@
1620
from pathlib import Path
1721
from typing import Any
1822

19-
from ..base import IntegrationBase
23+
from ..base import IntegrationBase, IntegrationOption, SkillsIntegration
2024
from ..manifest import IntegrationManifest
2125

2226

@@ -44,12 +48,40 @@ def _allow_all() -> bool:
4448
return True
4549

4650

51+
class _CopilotSkillsHelper(SkillsIntegration):
52+
"""Internal helper used when Copilot is scaffolded in skills mode.
53+
54+
Not registered in the integration registry — only used as a delegate
55+
by ``CopilotIntegration`` when ``--skills`` is passed.
56+
"""
57+
58+
key = "copilot"
59+
config = {
60+
"name": "GitHub Copilot",
61+
"folder": ".github/",
62+
"commands_subdir": "skills",
63+
"install_url": "https://docs.github.com/en/copilot/concepts/agents/copilot-cli/about-copilot-cli",
64+
"requires_cli": False,
65+
}
66+
registrar_config = {
67+
"dir": ".github/skills",
68+
"format": "markdown",
69+
"args": "$ARGUMENTS",
70+
"extension": "/SKILL.md",
71+
}
72+
context_file = ".github/copilot-instructions.md"
73+
74+
4775
class CopilotIntegration(IntegrationBase):
4876
"""Integration for GitHub Copilot (VS Code IDE + CLI).
4977
5078
The IDE integration (``requires_cli: False``) installs ``.agent.md``
5179
command files. Workflow dispatch additionally requires the
5280
``copilot`` CLI to be installed separately.
81+
82+
When ``--skills`` is passed via ``--integration-options``, commands
83+
are scaffolded as ``speckit-<name>/SKILL.md`` under ``.github/skills/``
84+
instead of the default ``.agent.md`` + ``.prompt.md`` layout.
5385
"""
5486

5587
key = "copilot"
@@ -68,6 +100,20 @@ class CopilotIntegration(IntegrationBase):
68100
}
69101
context_file = ".github/copilot-instructions.md"
70102

103+
# Mutable flag set by setup() — indicates the active scaffolding mode.
104+
_skills_mode: bool = False
105+
106+
@classmethod
107+
def options(cls) -> list[IntegrationOption]:
108+
return [
109+
IntegrationOption(
110+
"--skills",
111+
is_flag=True,
112+
default=False,
113+
help="Scaffold commands as agent skills (speckit-<name>/SKILL.md) instead of .agent.md files",
114+
),
115+
]
116+
71117
def build_exec_args(
72118
self,
73119
prompt: str,
@@ -92,7 +138,19 @@ def build_exec_args(
92138
return args
93139

94140
def build_command_invocation(self, command_name: str, args: str = "") -> str:
95-
"""Copilot agents are not slash-commands — just return the args as prompt."""
141+
"""Build the native invocation for a Copilot command.
142+
143+
Default mode: agents are not slash-commands — return args as prompt.
144+
Skills mode: ``/speckit-<stem>`` slash-command dispatch.
145+
"""
146+
if self._skills_mode:
147+
stem = command_name
148+
if "." in stem:
149+
stem = stem.rsplit(".", 1)[-1]
150+
invocation = f"/speckit-{stem}"
151+
if args:
152+
invocation = f"{invocation} {args}"
153+
return invocation
96154
return args or ""
97155

98156
def dispatch_command(
@@ -110,19 +168,37 @@ def dispatch_command(
110168
Copilot ``.agent.md`` files are agents, not skills. The CLI
111169
selects them with ``--agent <name>`` and the prompt is just
112170
the user's arguments.
171+
172+
In skills mode, the prompt includes the skill invocation
173+
(``/speckit-<stem>``).
113174
"""
114175
import subprocess
115176

116177
stem = command_name
117178
if "." in stem:
118179
stem = stem.rsplit(".", 1)[-1]
119-
agent_name = f"speckit.{stem}"
120180

121-
prompt = args or ""
122-
cli_args = [
123-
"copilot", "-p", prompt,
124-
"--agent", agent_name,
125-
]
181+
# Detect skills mode from project layout when not set via setup()
182+
skills_mode = self._skills_mode
183+
if not skills_mode and project_root:
184+
skills_dir = project_root / ".github" / "skills"
185+
if skills_dir.is_dir():
186+
skills_mode = any(
187+
d.is_dir() and (d / "SKILL.md").is_file()
188+
for d in skills_dir.glob("speckit-*")
189+
)
190+
191+
if skills_mode:
192+
prompt = f"/speckit-{stem}"
193+
if args:
194+
prompt = f"{prompt} {args}"
195+
else:
196+
agent_name = f"speckit.{stem}"
197+
prompt = args or ""
198+
199+
cli_args = ["copilot", "-p", prompt]
200+
if not skills_mode:
201+
cli_args.extend(["--agent", agent_name])
126202
if _allow_all():
127203
cli_args.append("--yolo")
128204
if model:
@@ -168,6 +244,59 @@ def command_filename(self, template_name: str) -> str:
168244
"""Copilot commands use ``.agent.md`` extension."""
169245
return f"speckit.{template_name}.agent.md"
170246

247+
def post_process_skill_content(self, content: str) -> str:
248+
"""Inject Copilot-specific ``mode:`` field into SKILL.md frontmatter.
249+
250+
Inserts ``mode: speckit.<stem>`` before the closing ``---`` so
251+
Copilot can associate the skill with its agent mode.
252+
"""
253+
lines = content.splitlines(keepends=True)
254+
255+
# Extract skill name from frontmatter to derive the mode value
256+
dash_count = 0
257+
skill_name = ""
258+
for line in lines:
259+
stripped = line.rstrip("\n\r")
260+
if stripped == "---":
261+
dash_count += 1
262+
if dash_count == 2:
263+
break
264+
continue
265+
if dash_count == 1:
266+
if stripped.startswith("mode:"):
267+
return content # already present
268+
if stripped.startswith("name:"):
269+
# Parse: name: "speckit-plan" → speckit.plan
270+
val = stripped.split(":", 1)[1].strip().strip('"').strip("'")
271+
# Convert speckit-plan → speckit.plan
272+
if val.startswith("speckit-"):
273+
skill_name = "speckit." + val[len("speckit-"):]
274+
else:
275+
skill_name = val
276+
277+
if not skill_name:
278+
return content
279+
280+
# Inject mode: before the closing --- of frontmatter
281+
out: list[str] = []
282+
dash_count = 0
283+
injected = False
284+
for line in lines:
285+
stripped = line.rstrip("\n\r")
286+
if stripped == "---":
287+
dash_count += 1
288+
if dash_count == 2 and not injected:
289+
if line.endswith("\r\n"):
290+
eol = "\r\n"
291+
elif line.endswith("\n"):
292+
eol = "\n"
293+
else:
294+
eol = ""
295+
out.append(f"mode: {skill_name}{eol}")
296+
injected = True
297+
out.append(line)
298+
return "".join(out)
299+
171300
def setup(
172301
self,
173302
project_root: Path,
@@ -177,10 +306,24 @@ def setup(
177306
) -> list[Path]:
178307
"""Install copilot commands, companion prompts, and VS Code settings.
179308
180-
Uses base class primitives to: read templates, process them
181-
(replace placeholders, strip script blocks, rewrite paths),
182-
write as ``.agent.md``, then add companion prompts and VS Code settings.
309+
When ``parsed_options["skills"]`` is truthy, delegates to skills
310+
scaffolding (``speckit-<name>/SKILL.md`` under ``.github/skills/``).
311+
Otherwise uses the default ``.agent.md`` + ``.prompt.md`` layout.
183312
"""
313+
parsed_options = parsed_options or {}
314+
self._skills_mode = bool(parsed_options.get("skills"))
315+
if self._skills_mode:
316+
return self._setup_skills(project_root, manifest, parsed_options, **opts)
317+
return self._setup_default(project_root, manifest, parsed_options, **opts)
318+
319+
def _setup_default(
320+
self,
321+
project_root: Path,
322+
manifest: IntegrationManifest,
323+
parsed_options: dict[str, Any] | None = None,
324+
**opts: Any,
325+
) -> list[Path]:
326+
"""Default mode: .agent.md + .prompt.md + VS Code settings merge."""
184327
project_root_resolved = project_root.resolve()
185328
if manifest.project_root != project_root_resolved:
186329
raise ValueError(
@@ -252,6 +395,37 @@ def setup(
252395

253396
return created
254397

398+
def _setup_skills(
399+
self,
400+
project_root: Path,
401+
manifest: IntegrationManifest,
402+
parsed_options: dict[str, Any] | None = None,
403+
**opts: Any,
404+
) -> list[Path]:
405+
"""Skills mode: delegate to ``_CopilotSkillsHelper`` then post-process."""
406+
helper = _CopilotSkillsHelper()
407+
created = SkillsIntegration.setup(
408+
helper, project_root, manifest, parsed_options, **opts
409+
)
410+
411+
# Post-process generated skill files with Copilot-specific frontmatter
412+
skills_dir = helper.skills_dest(project_root).resolve()
413+
for path in created:
414+
try:
415+
path.resolve().relative_to(skills_dir)
416+
except ValueError:
417+
continue
418+
if path.name != "SKILL.md":
419+
continue
420+
421+
content = path.read_text(encoding="utf-8")
422+
updated = self.post_process_skill_content(content)
423+
if updated != content:
424+
path.write_bytes(updated.encode("utf-8"))
425+
self.record_file_in_manifest(path, project_root, manifest)
426+
427+
return created
428+
255429
def _vscode_settings_path(self) -> Path | None:
256430
"""Return path to the bundled vscode-settings.json template."""
257431
tpl_dir = self.shared_templates_dir()

0 commit comments

Comments
 (0)