Skip to content

Commit d12ba9b

Browse files
Copilotmnriem
andauthored
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>
1 parent c59e302 commit d12ba9b

3 files changed

Lines changed: 516 additions & 13 deletions

File tree

src/specify_cli/__init__.py

Lines changed: 10 additions & 2 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

@@ -2166,7 +2174,7 @@ def _update_init_options_for_integration(
21662174
opts["context_file"] = integration.context_file
21672175
if script_type:
21682176
opts["script"] = script_type
2169-
if isinstance(integration, SkillsIntegration):
2177+
if isinstance(integration, SkillsIntegration) or getattr(integration, "_skills_mode", False):
21702178
opts["ai_skills"] = True
21712179
else:
21722180
opts.pop("ai_skills", None)

src/specify_cli/integrations/copilot/__init__.py

Lines changed: 180 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,32 @@ 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() and any(skills_dir.iterdir()):
186+
skills_mode = True
187+
188+
if skills_mode:
189+
prompt = self.build_command_invocation(command_name, args)
190+
else:
191+
agent_name = f"speckit.{stem}"
192+
prompt = args or ""
193+
194+
cli_args = ["copilot", "-p", prompt]
195+
if not skills_mode:
196+
cli_args.extend(["--agent", f"speckit.{stem}"])
126197
if _allow_all():
127198
cli_args.append("--yolo")
128199
if model:
@@ -168,6 +239,59 @@ def command_filename(self, template_name: str) -> str:
168239
"""Copilot commands use ``.agent.md`` extension."""
169240
return f"speckit.{template_name}.agent.md"
170241

242+
def post_process_skill_content(self, content: str) -> str:
243+
"""Inject Copilot-specific ``mode:`` field into SKILL.md frontmatter.
244+
245+
Inserts ``mode: "speckit.<stem>"`` before the closing ``---`` so
246+
Copilot can associate the skill with its agent mode.
247+
"""
248+
lines = content.splitlines(keepends=True)
249+
250+
# Extract skill name from frontmatter to derive the mode value
251+
dash_count = 0
252+
skill_name = ""
253+
for line in lines:
254+
stripped = line.rstrip("\n\r")
255+
if stripped == "---":
256+
dash_count += 1
257+
if dash_count == 2:
258+
break
259+
continue
260+
if dash_count == 1:
261+
if stripped.startswith("mode:"):
262+
return content # already present
263+
if stripped.startswith("name:"):
264+
# Parse: name: "speckit-plan" → speckit.plan
265+
val = stripped.split(":", 1)[1].strip().strip('"').strip("'")
266+
# Convert speckit-plan → speckit.plan
267+
if val.startswith("speckit-"):
268+
skill_name = "speckit." + val[len("speckit-"):]
269+
else:
270+
skill_name = val
271+
272+
if not skill_name:
273+
return content
274+
275+
# Inject mode: before the closing --- of frontmatter
276+
out: list[str] = []
277+
dash_count = 0
278+
injected = False
279+
for line in lines:
280+
stripped = line.rstrip("\n\r")
281+
if stripped == "---":
282+
dash_count += 1
283+
if dash_count == 2 and not injected:
284+
if line.endswith("\r\n"):
285+
eol = "\r\n"
286+
elif line.endswith("\n"):
287+
eol = "\n"
288+
else:
289+
eol = ""
290+
out.append(f"mode: {skill_name}{eol}")
291+
injected = True
292+
out.append(line)
293+
return "".join(out)
294+
171295
def setup(
172296
self,
173297
project_root: Path,
@@ -177,10 +301,24 @@ def setup(
177301
) -> list[Path]:
178302
"""Install copilot commands, companion prompts, and VS Code settings.
179303
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.
304+
When ``parsed_options["skills"]`` is truthy, delegates to skills
305+
scaffolding (``speckit-<name>/SKILL.md`` under ``.github/skills/``).
306+
Otherwise uses the default ``.agent.md`` + ``.prompt.md`` layout.
183307
"""
308+
parsed_options = parsed_options or {}
309+
if parsed_options.get("skills"):
310+
self._skills_mode = True
311+
return self._setup_skills(project_root, manifest, parsed_options, **opts)
312+
return self._setup_default(project_root, manifest, parsed_options, **opts)
313+
314+
def _setup_default(
315+
self,
316+
project_root: Path,
317+
manifest: IntegrationManifest,
318+
parsed_options: dict[str, Any] | None = None,
319+
**opts: Any,
320+
) -> list[Path]:
321+
"""Default mode: .agent.md + .prompt.md + VS Code settings merge."""
184322
project_root_resolved = project_root.resolve()
185323
if manifest.project_root != project_root_resolved:
186324
raise ValueError(
@@ -252,6 +390,37 @@ def setup(
252390

253391
return created
254392

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

0 commit comments

Comments
 (0)