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
25 changes: 25 additions & 0 deletions src/specify_cli/_init_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,28 @@ def load_init_options(project_path: Path) -> dict[str, Any]:
def is_ai_skills_enabled(opts: Mapping[str, Any] | None) -> bool:
"""Return True only when init options explicitly enable AI skills."""
return isinstance(opts, Mapping) and opts.get("ai_skills") is True


def is_agent_skills_enabled(project_path: Path, agent_name: str, opts: Mapping[str, Any] | None) -> bool:
Comment thread
mnriem marked this conversation as resolved.
"""Return True when *agent_name* should render extension skills.

Prefers the per-agent ``skills`` flag recorded in
``.specify/integration.json`` (``integration_settings[agent_name].parsed_options.skills``),
which reflects the ``--skills`` option passed to that specific agent's
install/upgrade/switch. Falls back to the legacy global
``init-options.json`` ``ai_skills`` flag only when *agent_name* is the
active agent recorded there (pre-multi-install behaviour).
"""
from .integration_state import try_read_integration_json, integration_setting

state, _error = try_read_integration_json(project_path)
if state:
setting = integration_setting(state, agent_name)
parsed = setting.get("parsed_options")
if isinstance(parsed, Mapping) and "skills" in parsed:
return parsed.get("skills") is True

if isinstance(opts, Mapping) and opts.get("ai") == agent_name:
return is_ai_skills_enabled(opts)

return False
257 changes: 184 additions & 73 deletions src/specify_cli/extensions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -910,8 +910,15 @@ def _ignore(directory: str, entries: List[str]) -> Set[str]:

return _ignore

def _get_skills_dir(self) -> Optional[Path]:
"""Return the active skills directory for extension skill registration.
def _get_skills_dir(self, agent_name: Optional[str] = None) -> Optional[Path]:
"""Return the skills directory for extension skill registration.

When *agent_name* is given, resolves the skills directory and
skills-enabled state for that specific agent (using per-agent
settings in ``.specify/integration.json``), so skill rendering
works correctly for non-active agents (#2948). When omitted,
falls back to the previous behaviour of using the active agent
from init-options.
Comment thread
mnriem marked this conversation as resolved.

Delegates to :func:`resolve_active_skills_dir` which reads
init-options, applies the Kimi native-skills fallback, and
Expand All @@ -926,6 +933,8 @@ def _get_skills_dir(self) -> Optional[Path]:
load_init_options,
resolve_active_skills_dir,
)
from .._init_options import is_agent_skills_enabled
from .. import _get_skills_dir as resolve_agent_skills_dir

def _ensure_usable(skills_dir: Path) -> Optional[Path]:
try:
Expand All @@ -943,26 +952,61 @@ def _ensure_usable(skills_dir: Path) -> Optional[Path]:
return None
return skills_dir

try:
skills_dir = resolve_active_skills_dir(self.project_root)
except (ValueError, OSError) as exc:
_print_cli_warning(
"resolve",
"skills directory",
None,
exc,
continuing="Continuing without skill registration.",
)
return None
if skills_dir is None:
return None

opts = load_init_options(self.project_root)
if not isinstance(opts, dict):
return _ensure_usable(skills_dir)
selected_ai = opts.get("ai")
if not isinstance(selected_ai, str) or not selected_ai:
return _ensure_usable(skills_dir)
opts = {}

if agent_name is None:
try:
skills_dir = resolve_active_skills_dir(self.project_root)
except (ValueError, OSError) as exc:
_print_cli_warning(
"resolve",
"skills directory",
None,
exc,
continuing="Continuing without skill registration.",
)
return None
if skills_dir is None:
return None
selected_ai = opts.get("ai")
if not isinstance(selected_ai, str) or not selected_ai:
return _ensure_usable(skills_dir)
else:
from ..shared_infra import _ensure_safe_shared_directory

ai_skills_enabled = is_agent_skills_enabled(
self.project_root, agent_name, opts
)
if not ai_skills_enabled and agent_name != "kimi":
return None
try:
skills_dir = resolve_agent_skills_dir(self.project_root, agent_name)
if not ai_skills_enabled:
# Kimi native-skills fallback: only use the directory if
# it already exists; do not create it on demand.
if not skills_dir.is_dir():
return None
_ensure_safe_shared_directory(
self.project_root, skills_dir,
create=False, context="agent skills directory",
)
else:
_ensure_safe_shared_directory(
self.project_root, skills_dir,
context="agent skills directory",
)
except (ValueError, OSError) as exc:
_print_cli_warning(
"resolve",
"skills directory",
None,
exc,
continuing="Continuing without skill registration.",
)
return None
selected_ai = agent_name

from ..agents import CommandRegistrar

Expand All @@ -980,6 +1024,7 @@ def _register_extension_skills(
manifest: ExtensionManifest,
extension_dir: Path,
link_outputs: bool = False,
agent_name: Optional[str] = None,
) -> List[str]:
"""Generate SKILL.md files for extension commands as agent skills.

Expand All @@ -997,11 +1042,12 @@ def _register_extension_skills(
Returns:
List of skill names that were created (for registry storage).
"""
skills_dir = self._get_skills_dir()
skills_dir = self._get_skills_dir(agent_name)
if not skills_dir:
return []

from .. import load_init_options
from .._init_options import is_agent_skills_enabled
from ..agents import CommandRegistrar
from ..integrations import get_integration
from ..integrations.base import IntegrationBase
Expand All @@ -1010,13 +1056,13 @@ def _register_extension_skills(
opts = load_init_options(self.project_root)
if not isinstance(opts, dict):
opts = {}
selected_ai = opts.get("ai")
selected_ai = agent_name if agent_name else opts.get("ai")
if not isinstance(selected_ai, str) or not selected_ai:
return []
registrar = CommandRegistrar()
agent_config = registrar.AGENT_CONFIGS.get(selected_ai, {})
integration = get_integration(selected_ai)
ai_skills_enabled = is_ai_skills_enabled(opts)
ai_skills_enabled = is_agent_skills_enabled(self.project_root, selected_ai, opts)

def _resolve_command_ref_tokens(body: str) -> str:
"""Resolve explicit command-ref tokens with the active skill style."""
Expand Down Expand Up @@ -1164,6 +1210,73 @@ def _replacement(match: re.Match[str]) -> str:

return written

def _register_extension_skills_for_installed_agents(
self,
manifest: ExtensionManifest,
extension_dir: Path,
link_outputs: bool = False,
) -> List[str]:
"""Render extension skills for every skills-mode agent detected.

Used by paths (like ``extension add``) that register an extension
for all agents at once, rather than a single explicit target agent.
Checks the active agent (legacy single-agent projects) plus every
agent recorded in ``.specify/integration.json``'s installed
integrations, and renders skills for whichever of those have skills
mode enabled for them specifically, instead of only the active
agent (#2948).
"""
from .. import load_init_options
from ..integration_state import (
try_read_integration_json,
installed_integration_keys,
)

opts = load_init_options(self.project_root)
if not isinstance(opts, dict):
opts = {}

candidate_agents: List[str] = []
active_agent = opts.get("ai")
if isinstance(active_agent, str) and active_agent:
candidate_agents.append(active_agent)

state, _error = try_read_integration_json(self.project_root)
if state:
for key in installed_integration_keys(state):
if key not in candidate_agents:
candidate_agents.append(key)

combined: List[str] = []
seen = set()
for agent_name in candidate_agents:
try:
agent_skills = self._register_extension_skills(
manifest,
extension_dir,
link_outputs=link_outputs,
agent_name=agent_name,
)
except Exception as skills_err:
from .. import _print_cli_warning
_print_cli_warning(
"register extension skills for",
"extension",
manifest.id,
skills_err,
continuing=(
"Continuing with available registration results for "
"this extension and the remaining agents."
),
)
continue
for skill_name in agent_skills:
if skill_name not in seen:
seen.add(skill_name)
combined.append(skill_name)
Comment on lines +1273 to +1276

return combined

@staticmethod
def _is_expected_dev_symlink(skill_file: Path, cache_file: Path) -> bool:
"""Return True when an existing skill file links to its dev cache."""
Expand Down Expand Up @@ -1448,9 +1561,11 @@ def install_from_directory(
create_missing_active_skills_dir=True,
)

# Auto-register extension commands as agent skills when skills mode
# was used during project initialisation (feature parity).
registered_skills = self._register_extension_skills(
# Auto-register extension commands as agent skills for every
# skills-mode agent detected (active agent plus any other
# installed integrations in skills mode), not just the active
# agent (#2948).
registered_skills = self._register_extension_skills_for_installed_agents(
manifest, dest_dir, link_outputs=link_commands
)

Expand Down Expand Up @@ -1731,17 +1846,16 @@ def unregister_agent_artifacts(self, agent_name: str) -> None:
def register_enabled_extensions_for_agent(self, agent_name: str) -> None:
"""Register installed, enabled extensions for ``agent_name``.

Command-file registration is scoped to the explicit ``agent_name``
argument, so this method can be used after install, upgrade, or switch.
Extension skill rendering is still scoped to the active ``ai`` /
``ai_skills`` settings in init-options, so non-active skills-mode
targets receive command files here. Per-agent skills parity is tracked
separately in #2948.
Both command-file and skill registration are scoped to the explicit
``agent_name`` argument, so this method can be used after install,
upgrade, or switch and renders skills correctly for any enabled
skills-mode agent, not just the active one (#2948).
"""
if not agent_name:
return

from .. import load_init_options
from .._init_options import is_agent_skills_enabled

registrar = CommandRegistrar()
agent_config = registrar.AGENT_CONFIGS.get(agent_name)
Expand All @@ -1750,10 +1864,11 @@ def register_enabled_extensions_for_agent(self, agent_name: str) -> None:
init_options = {}

active_agent = init_options.get("ai")
ai_skills_enabled = is_ai_skills_enabled(init_options)
ai_skills_enabled = is_agent_skills_enabled(
self.project_root, agent_name, init_options
)
skills_mode_active = (
active_agent == agent_name
and ai_skills_enabled
ai_skills_enabled
and bool(agent_config)
and agent_config.get("extension") != "/SKILL.md"
)
Expand Down Expand Up @@ -1793,46 +1908,42 @@ def register_enabled_extensions_for_agent(self, agent_name: str) -> None:
if new_registered != registered_commands:
updates["registered_commands"] = new_registered

# Extension *skills* are only ever rendered for the active agent:
# `_register_extension_skills` resolves the skills dir and
# frontmatter from init-options["ai"], ignoring ``agent_name``.
# When this method runs for a non-active agent — as install/upgrade
# now do for a secondary integration (#2886) — the skills pass would
# re-render the *active* agent's extension skills as a side effect,
# resurrecting skill files the user deliberately deleted. Skip it
# unless the target is the active agent; `switch` is unaffected
# because it activates the target before registering. (Rendering
# skills for a non-active target is tracked separately in #2948.)
if agent_name == active_agent:
try:
registered_skills = self._register_extension_skills(
manifest, ext_dir
# Extension skills are rendered whenever *agent_name* itself
# has skills mode enabled (checked via per-agent settings in
# `.specify/integration.json`, falling back to the legacy
# global init-options for the active agent). This makes
# skill rendering agent-aware instead of only ever
# targeting the active agent (#2948), while still avoiding
# unrelated side effects on other installed agents.
try:
registered_skills = self._register_extension_skills(
manifest, ext_dir, agent_name=agent_name
)
except Exception as skills_err:
# Skills are a companion artifact. If command registration
# already succeeded, still persist it so later cleanup can
# find those command files.
from .. import _print_cli_warning

_print_cli_warning(
"register extension skills for",
"extension",
ext_id,
skills_err,
continuing=(
"Continuing with available registration results for this "
"extension and the remaining extensions."
),
)
else:
if registered_skills:
existing_skills = self._valid_name_list(
metadata.get("registered_skills", [])
)
except Exception as skills_err:
# Skills are a companion artifact. If command registration
# already succeeded, still persist it so later cleanup can
# find those command files.
from .. import _print_cli_warning

_print_cli_warning(
"register extension skills for",
"extension",
ext_id,
skills_err,
continuing=(
"Continuing with available registration results for this "
"extension and the remaining extensions."
),
merged_skills = list(
dict.fromkeys(existing_skills + registered_skills)
)
else:
if registered_skills:
existing_skills = self._valid_name_list(
metadata.get("registered_skills", [])
)
merged_skills = list(
dict.fromkeys(existing_skills + registered_skills)
)
updates["registered_skills"] = merged_skills
updates["registered_skills"] = merged_skills

if updates:
self.registry.update(ext_id, updates)
Expand Down
Loading
Loading