Skip to content

Commit 082e421

Browse files
committed
fix: render extension skills for target agent, not just active agent (#2948)
Extension skill rendering was previously scoped only to the active agent (init-options.json's 'ai' field), even when register_enabled_extensions_for_agent was called for a different, non-active agent (e.g. after 'integration install <agent> --skills' or 'integration upgrade <agent>' for a secondary integration). This meant a skills-mode agent that wasn't the active one received command files instead of skills. Changes: - Add is_agent_skills_enabled() in _init_options.py: resolves per-agent skills-mode from .specify/integration.json's integration_settings[agent].parsed_options.skills, falling back to the legacy global init-options ai_skills flag for the active agent. - ExtensionManager._get_skills_dir() now accepts an optional agent_name and resolves the skills directory for that specific agent instead of always using the active agent. - ExtensionManager._register_extension_skills() now accepts and threads through agent_name. - register_enabled_extensions_for_agent() now computes skills_mode_active per the target agent_name (not the active agent), and always attempts skill registration for that agent (matching the prior best-effort error handling), while still skipping duplicate command-file registration when the target is in skills mode. Verified manually: 'specify integration upgrade copilot --force' with a non-active, skills-mode Copilot integration now renders .github/skills/speckit-git-*/SKILL.md files instead of command files, with no change to the active agent's own skills. Test suite: 616/616 extension-related tests pass. Full suite has 44 pre-existing failures unrelated to this change (Windows symlink-privilege and PowerShell path-resolution issues, confirmed present on main via git stash).
1 parent 208d386 commit 082e421

3 files changed

Lines changed: 124 additions & 78 deletions

File tree

src/specify_cli/_init_options.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,28 @@ def load_init_options(project_path: Path) -> dict[str, Any]:
3434
def is_ai_skills_enabled(opts: Mapping[str, Any] | None) -> bool:
3535
"""Return True only when init options explicitly enable AI skills."""
3636
return isinstance(opts, Mapping) and opts.get("ai_skills") is True
37+
38+
39+
def is_agent_skills_enabled(project_path: Path, agent_name: str, opts: Mapping[str, Any] | None) -> bool:
40+
"""Return True when *agent_name* should render extension skills.
41+
42+
Prefers the per-agent ``skills`` flag recorded in
43+
``.specify/integration.json`` (``integration_settings[agent_name].parsed_options.skills``),
44+
which reflects the ``--skills`` option passed to that specific agent's
45+
install/upgrade/switch. Falls back to the legacy global
46+
``init-options.json`` ``ai_skills`` flag only when *agent_name* is the
47+
active agent recorded there (pre-multi-install behaviour).
48+
"""
49+
from .integration_state import try_read_integration_json, integration_setting
50+
51+
state, _error = try_read_integration_json(project_path)
52+
if state:
53+
setting = integration_setting(state, agent_name)
54+
parsed = setting.get("parsed_options")
55+
if isinstance(parsed, Mapping) and "skills" in parsed:
56+
return parsed.get("skills") is True
57+
58+
if isinstance(opts, Mapping) and opts.get("ai") == agent_name:
59+
return is_ai_skills_enabled(opts)
60+
61+
return False

src/specify_cli/extensions/__init__.py

Lines changed: 93 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -910,8 +910,15 @@ def _ignore(directory: str, entries: List[str]) -> Set[str]:
910910

911911
return _ignore
912912

913-
def _get_skills_dir(self) -> Optional[Path]:
914-
"""Return the active skills directory for extension skill registration.
913+
def _get_skills_dir(self, agent_name: Optional[str] = None) -> Optional[Path]:
914+
"""Return the skills directory for extension skill registration.
915+
916+
When *agent_name* is given, resolves the skills directory and
917+
skills-enabled state for that specific agent (using per-agent
918+
settings in ``.specify/integration.json``), so skill rendering
919+
works correctly for non-active agents (#2948). When omitted,
920+
falls back to the previous behaviour of using the active agent
921+
from init-options.
915922
916923
Delegates to :func:`resolve_active_skills_dir` which reads
917924
init-options, applies the Kimi native-skills fallback, and
@@ -926,6 +933,8 @@ def _get_skills_dir(self) -> Optional[Path]:
926933
load_init_options,
927934
resolve_active_skills_dir,
928935
)
936+
from .._init_options import is_agent_skills_enabled
937+
from .. import _get_skills_dir as resolve_agent_skills_dir
929938

930939
def _ensure_usable(skills_dir: Path) -> Optional[Path]:
931940
try:
@@ -943,26 +952,42 @@ def _ensure_usable(skills_dir: Path) -> Optional[Path]:
943952
return None
944953
return skills_dir
945954

946-
try:
947-
skills_dir = resolve_active_skills_dir(self.project_root)
948-
except (ValueError, OSError) as exc:
949-
_print_cli_warning(
950-
"resolve",
951-
"skills directory",
952-
None,
953-
exc,
954-
continuing="Continuing without skill registration.",
955-
)
956-
return None
957-
if skills_dir is None:
958-
return None
959-
960955
opts = load_init_options(self.project_root)
961956
if not isinstance(opts, dict):
962-
return _ensure_usable(skills_dir)
963-
selected_ai = opts.get("ai")
964-
if not isinstance(selected_ai, str) or not selected_ai:
965-
return _ensure_usable(skills_dir)
957+
opts = {}
958+
959+
if agent_name is None:
960+
try:
961+
skills_dir = resolve_active_skills_dir(self.project_root)
962+
except (ValueError, OSError) as exc:
963+
_print_cli_warning(
964+
"resolve",
965+
"skills directory",
966+
None,
967+
exc,
968+
continuing="Continuing without skill registration.",
969+
)
970+
return None
971+
if skills_dir is None:
972+
return None
973+
selected_ai = opts.get("ai")
974+
if not isinstance(selected_ai, str) or not selected_ai:
975+
return _ensure_usable(skills_dir)
976+
else:
977+
if not is_agent_skills_enabled(self.project_root, agent_name, opts):
978+
return None
979+
try:
980+
skills_dir = resolve_agent_skills_dir(self.project_root, agent_name)
981+
except (ValueError, OSError) as exc:
982+
_print_cli_warning(
983+
"resolve",
984+
"skills directory",
985+
None,
986+
exc,
987+
continuing="Continuing without skill registration.",
988+
)
989+
return None
990+
selected_ai = agent_name
966991

967992
from ..agents import CommandRegistrar
968993

@@ -980,6 +1005,7 @@ def _register_extension_skills(
9801005
manifest: ExtensionManifest,
9811006
extension_dir: Path,
9821007
link_outputs: bool = False,
1008+
agent_name: Optional[str] = None,
9831009
) -> List[str]:
9841010
"""Generate SKILL.md files for extension commands as agent skills.
9851011
@@ -997,11 +1023,12 @@ def _register_extension_skills(
9971023
Returns:
9981024
List of skill names that were created (for registry storage).
9991025
"""
1000-
skills_dir = self._get_skills_dir()
1026+
skills_dir = self._get_skills_dir(agent_name)
10011027
if not skills_dir:
10021028
return []
10031029

10041030
from .. import load_init_options
1031+
from .._init_options import is_agent_skills_enabled
10051032
from ..agents import CommandRegistrar
10061033
from ..integrations import get_integration
10071034
from ..integrations.base import IntegrationBase
@@ -1010,13 +1037,13 @@ def _register_extension_skills(
10101037
opts = load_init_options(self.project_root)
10111038
if not isinstance(opts, dict):
10121039
opts = {}
1013-
selected_ai = opts.get("ai")
1040+
selected_ai = agent_name if agent_name else opts.get("ai")
10141041
if not isinstance(selected_ai, str) or not selected_ai:
10151042
return []
10161043
registrar = CommandRegistrar()
10171044
agent_config = registrar.AGENT_CONFIGS.get(selected_ai, {})
10181045
integration = get_integration(selected_ai)
1019-
ai_skills_enabled = is_ai_skills_enabled(opts)
1046+
ai_skills_enabled = is_agent_skills_enabled(self.project_root, selected_ai, opts)
10201047

10211048
def _resolve_command_ref_tokens(body: str) -> str:
10221049
"""Resolve explicit command-ref tokens with the active skill style."""
@@ -1731,17 +1758,16 @@ def unregister_agent_artifacts(self, agent_name: str) -> None:
17311758
def register_enabled_extensions_for_agent(self, agent_name: str) -> None:
17321759
"""Register installed, enabled extensions for ``agent_name``.
17331760
1734-
Command-file registration is scoped to the explicit ``agent_name``
1735-
argument, so this method can be used after install, upgrade, or switch.
1736-
Extension skill rendering is still scoped to the active ``ai`` /
1737-
``ai_skills`` settings in init-options, so non-active skills-mode
1738-
targets receive command files here. Per-agent skills parity is tracked
1739-
separately in #2948.
1761+
Both command-file and skill registration are scoped to the explicit
1762+
``agent_name`` argument, so this method can be used after install,
1763+
upgrade, or switch and renders skills correctly for any enabled
1764+
skills-mode agent, not just the active one (#2948).
17401765
"""
17411766
if not agent_name:
17421767
return
17431768

17441769
from .. import load_init_options
1770+
from .._init_options import is_agent_skills_enabled
17451771

17461772
registrar = CommandRegistrar()
17471773
agent_config = registrar.AGENT_CONFIGS.get(agent_name)
@@ -1750,10 +1776,11 @@ def register_enabled_extensions_for_agent(self, agent_name: str) -> None:
17501776
init_options = {}
17511777

17521778
active_agent = init_options.get("ai")
1753-
ai_skills_enabled = is_ai_skills_enabled(init_options)
1779+
ai_skills_enabled = is_agent_skills_enabled(
1780+
self.project_root, agent_name, init_options
1781+
)
17541782
skills_mode_active = (
1755-
active_agent == agent_name
1756-
and ai_skills_enabled
1783+
ai_skills_enabled
17571784
and bool(agent_config)
17581785
and agent_config.get("extension") != "/SKILL.md"
17591786
)
@@ -1793,46 +1820,42 @@ def register_enabled_extensions_for_agent(self, agent_name: str) -> None:
17931820
if new_registered != registered_commands:
17941821
updates["registered_commands"] = new_registered
17951822

1796-
# Extension *skills* are only ever rendered for the active agent:
1797-
# `_register_extension_skills` resolves the skills dir and
1798-
# frontmatter from init-options["ai"], ignoring ``agent_name``.
1799-
# When this method runs for a non-active agent — as install/upgrade
1800-
# now do for a secondary integration (#2886) — the skills pass would
1801-
# re-render the *active* agent's extension skills as a side effect,
1802-
# resurrecting skill files the user deliberately deleted. Skip it
1803-
# unless the target is the active agent; `switch` is unaffected
1804-
# because it activates the target before registering. (Rendering
1805-
# skills for a non-active target is tracked separately in #2948.)
1806-
if agent_name == active_agent:
1807-
try:
1808-
registered_skills = self._register_extension_skills(
1809-
manifest, ext_dir
1823+
# Extension skills are rendered whenever *agent_name* itself
1824+
# has skills mode enabled (checked via per-agent settings in
1825+
# `.specify/integration.json`, falling back to the legacy
1826+
# global init-options for the active agent). This makes
1827+
# skill rendering agent-aware instead of only ever
1828+
# targeting the active agent (#2948), while still avoiding
1829+
# unrelated side effects on other installed agents.
1830+
try:
1831+
registered_skills = self._register_extension_skills(
1832+
manifest, ext_dir, agent_name=agent_name
1833+
)
1834+
except Exception as skills_err:
1835+
# Skills are a companion artifact. If command registration
1836+
# already succeeded, still persist it so later cleanup can
1837+
# find those command files.
1838+
from .. import _print_cli_warning
1839+
1840+
_print_cli_warning(
1841+
"register extension skills for",
1842+
"extension",
1843+
ext_id,
1844+
skills_err,
1845+
continuing=(
1846+
"Continuing with available registration results for this "
1847+
"extension and the remaining extensions."
1848+
),
1849+
)
1850+
else:
1851+
if registered_skills:
1852+
existing_skills = self._valid_name_list(
1853+
metadata.get("registered_skills", [])
18101854
)
1811-
except Exception as skills_err:
1812-
# Skills are a companion artifact. If command registration
1813-
# already succeeded, still persist it so later cleanup can
1814-
# find those command files.
1815-
from .. import _print_cli_warning
1816-
1817-
_print_cli_warning(
1818-
"register extension skills for",
1819-
"extension",
1820-
ext_id,
1821-
skills_err,
1822-
continuing=(
1823-
"Continuing with available registration results for this "
1824-
"extension and the remaining extensions."
1825-
),
1855+
merged_skills = list(
1856+
dict.fromkeys(existing_skills + registered_skills)
18261857
)
1827-
else:
1828-
if registered_skills:
1829-
existing_skills = self._valid_name_list(
1830-
metadata.get("registered_skills", [])
1831-
)
1832-
merged_skills = list(
1833-
dict.fromkeys(existing_skills + registered_skills)
1834-
)
1835-
updates["registered_skills"] = merged_skills
1858+
updates["registered_skills"] = merged_skills
18361859

18371860
if updates:
18381861
self.registry.update(ext_id, updates)

src/specify_cli/integrations/_helpers.py

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -402,14 +402,12 @@ def _register_extensions_for_agent(
402402
integration has no extension side effects until it is selected or upgraded.
403403
See issue #2886.
404404
405-
Known limitation: extension *skill* rendering is scoped to the active
406-
agent (init-options track a single ``ai`` / ``ai_skills`` pair). A
407-
skills-mode agent registered while it is *not* the active agent (e.g.
408-
Copilot ``--skills`` registered while non-active) therefore
409-
receives command files rather than skills here — matching ``extension
410-
add``'s multi-agent behavior. ``use`` / ``switch`` avoid this because they
411-
make the target the active agent first. Per-agent skills parity is tracked in
412-
#2948.
405+
Extension *skill* rendering is scoped to ``agent_key`` itself (using
406+
per-agent settings in ``.specify/integration.json`` when available, with
407+
a fallback to the legacy global init-options for the active agent), so a
408+
skills-mode agent registered while it is not the active agent (e.g.
409+
Copilot ``--skills`` registered while non-active) still receives skill
410+
files here instead of only command files (#2948).
413411
414412
Best-effort: never aborts the surrounding integration operation. Callers
415413
invoke it *after* the use/upgrade/switch transaction has committed so a

0 commit comments

Comments
 (0)