Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion docs/integrations/copilot.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,49 @@ See [integration management](cli.md) for activation, upgrades, and removal.
Skills are installed as `.github/skills/speckit-<command>/SKILL.md`.
Invoke them in the agent's chat as `/speckit-specify` or
`/speckit-bug-assess` for the installed bug extension. These are agent skills,
not terminal commands.
not terminal commands. Generated skills are both user- and model-invocable by
default.

## User-Invocable-Only Skills

To stop Copilot loading the core Spec Kit command skills automatically in the
skills layout:

```bash
specify init my-project --integration copilot --integration-options="--no-model-invocation"
```

This writes `disable-model-invocation: true` into the core command skills.
VS Code's [agent skills documentation](https://code.visualstudio.com/docs/agent-customization/agent-skills)
says this requires manual invocation through the `/` slash command only.
GitHub's Copilot CLI skills documentation does not list the field, but in a
manual check with Copilot CLI 1.0.70 the model called its `skill` tool for
`speckit-specify` without the flag and did not with it, and an explicit
`/speckit-specify` still ran. With the flag the model could still read the
skill's `SKILL.md` as an ordinary file, so it is not a hard block. Neither
source says whether a skill with this flag keeps its description out of the
model's context.

- The option has no effect with `--commands`: the `.agent.md` and `.prompt.md`
files are not skills and no flag is written.
- Extension skills stay model-invocable, because mandatory extension hooks rely
on the model invoking them. If an extension's mandatory hook runs a core
command (its `command:` is a core `speckit.<command>`), leave this option off:
the model cannot run that hook while the core skill is user-invocable only.
- The option is stored with the integration's other options and reapplied by
`specify integration upgrade copilot`. Passing `--integration-options` to
`upgrade` replaces all stored options;
`--integration-options=""` clears them. Re-running `specify init` likewise
stores only the options passed to it.
- Core command skills that a preset overrides, or stops overriding, are reverted
to the default by commands that re-register preset skills without reinstalling
Copilot: commands that install, remove or update presets (including `specify
bundle` commands that carry presets), `specify integration use copilot`, and
`specify integration switch copilot` when Copilot is installed but not the
default. To reapply the option, run `specify integration upgrade copilot
--force` while Copilot is the default integration (`--force` is needed because
those commands modify installed skill files, and it overwrites local edits to
Spec Kit-managed files).

## Commands Layout

Expand Down
100 changes: 95 additions & 5 deletions src/specify_cli/integrations/copilot/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,14 @@ class CopilotIntegration(IntegrationBase):
# Mutable flag set by setup() — indicates the active scaffolding mode.
_skills_mode: bool = True

# Set by ``setup()`` from ``--no-model-invocation`` (skills layout only);
# applies to core command skills only, since mandatory extension hooks rely
# on the model invoking the hook skill. Integrations are process-wide
# singletons, so preset overrides re-registered later in the same run see
# the same value. Commands that rewrite skills without calling ``setup()``
# (e.g. ``preset add``, ``integration use``) get the default.
_disable_model_invocation: bool = False

def effective_invoke_separator(
self,
parsed_options: dict[str, Any] | None = None,
Expand Down Expand Up @@ -299,6 +307,17 @@ def options(cls) -> list[IntegrationOption]:
),
),
)
opts.append(
IntegrationOption(
"--no-model-invocation",
is_flag=True,
default=False,
help=(
"Install core command skills as user-invocable only "
"(disable-model-invocation: true); skills layout only, documented for VS Code"
),
),
)
return opts

def _resolve_executable(self) -> str:
Expand Down Expand Up @@ -482,14 +501,78 @@ def stale_cleanup_exclusions(self) -> set[str]:
exclusions.add(".vscode/settings.json")
return exclusions

@staticmethod
def _inject_frontmatter_flag(content: str, key: str, value: str = "true") -> str:
"""Insert ``key: value`` before the closing ``---`` if not already present."""
lines = content.splitlines(keepends=True)
dash_count = 0
for line in lines:
stripped = line.rstrip("\n\r")
if stripped == "---":
dash_count += 1
if dash_count == 2:
break
continue
if dash_count == 1 and stripped.startswith(f"{key}:"):
return content

out: list[str] = []
dash_count = 0
injected = False
for line in lines:
if line.rstrip("\n\r") == "---":
dash_count += 1
if dash_count == 2 and not injected:
eol = "\r\n" if line.endswith("\r\n") else "\n"
out.append(f"{key}: {value}{eol}")
injected = True
out.append(line)
return "".join(out)

@staticmethod
def _skill_name_from_content(content: str) -> str | None:
"""Return the ``name:`` field of a skill's first frontmatter block."""
dash_count = 0
for line in content.splitlines():
stripped = line.rstrip("\r\n")
if stripped == "---":
dash_count += 1
if dash_count == 2:
break
continue
if dash_count == 1 and stripped.startswith("name:"):
name = stripped[len("name:"):].strip().strip('"').strip("'")
return name or None
return None

@staticmethod
def _is_core_command_skill(name: str | None) -> bool:
from specify_cli.extensions import CORE_COMMAND_NAMES # lazy: avoids circular import

return bool(name) and name.startswith("speckit-") and (
name.removeprefix("speckit-") in CORE_COMMAND_NAMES
)

def post_process_skill_content(self, content: str) -> str:
"""Inject shared hook guidance into Copilot skill content.

Delegates to :class:`_CopilotSkillsHelper` for shared post-processing.
The ``mode:`` frontmatter field is intentionally omitted: VS Code
Copilot Agent Skills do not support it (see issue #2799).

``disable-model-invocation: true`` is added to core command skills
when the last ``setup()`` in this process was given
``--no-model-invocation`` in the skills layout. Otherwise the key is
omitted, which VS Code treats as ``false``.
"""
return _CopilotSkillsHelper().post_process_skill_content(content)
updated = _CopilotSkillsHelper().post_process_skill_content(content)
if self._disable_model_invocation and self._is_core_command_skill(
self._skill_name_from_content(updated)
):
updated = self._inject_frontmatter_flag(
updated, "disable-model-invocation"
)
return updated

def setup(
self,
Expand All @@ -506,10 +589,17 @@ def setup(
"""
parsed_options = parsed_options or {}
self._skills_mode = self.is_skills_mode(parsed_options, project_root)
if self._skills_mode:
created = self._setup_skills(project_root, manifest, parsed_options, **opts)
else:
created = self._setup_commands(project_root, manifest, parsed_options, **opts)
self._disable_model_invocation = (
self._skills_mode and parsed_options.get("no_model_invocation") is True
)
try:
if self._skills_mode:
created = self._setup_skills(project_root, manifest, parsed_options, **opts)
else:
created = self._setup_commands(project_root, manifest, parsed_options, **opts)
except BaseException:
self._disable_model_invocation = False
raise

# Install agent runtime events
event_files = self.emit_events(
Expand Down
Loading