Skip to content

Commit db78903

Browse files
marcelsafinCopilot
andcommitted
fix: address fourth round of review feedback (skill registration provenance)
Replace the "enumerate every skill-mode directory and restore all of them" approach from the previous round with precise per-agent provenance tracking, per reviewer feedback that the enumerate-and-restore-everything design was unsound: - registered_skills changes from a flat List[str] to Dict[str, List[str]] (agent name -> skill names actually written), mirroring the shape registered_commands already uses. _register_skills now returns this per-agent mapping instead of a bare list, and every call site (register_enabled_presets_for_agent, install_from_directory, the _reconcile_skills "was this skill previously managed" check) is updated to read/merge the new shape. Legacy flat-list registry entries from before this change are still readable: writes self-migrate the format, and _normalize_registered_skills() handles the transitional read paths. - _unregister_skills now restores exactly the agent directories recorded for a preset instead of guessing at every skill-mode integration that happens to exist on disk. This fixes two problems with the old enumerate-everything design: (1) it could silently overwrite or delete another preset's (or a user's) override in an agent directory the current preset never actually touched, and (2) it depended on transient per-process integration state (_skills_mode), which is unset in a fresh CLI invocation for mode-selectable integrations like Copilot --skills, permanently orphaning their overrides after a process restart. Registries written before this change (flat list, no agent provenance) fall back to best-effort restoration under only the currently active agent, matching the pre-existing guarantee level. - Every directory resolved from persisted provenance is now validated through the project's shared symlink/containment guard (_ensure_safe_shared_directory) before any file in it is read, written, or removed, since restoration may target an agent that isn't currently active and its directory can't be assumed safe just because a name was recorded for it. - _tracked_skill_agent_dirs() (the enumeration helper introduced last round) is removed; it's superseded by the provenance-based design. Adds regression tests: a symlinked skills directory is rejected during removal; removing one preset does not disturb a different preset's override in another agent's directory; and a Copilot --skills registration installed, then removed after switching agents in a fresh PresetManager instance (simulating a new process), is still correctly restored. Updates existing skill-registration assertions across test_presets.py and test_integration_claude.py for the new per-agent registry shape. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 12a3d67 commit db78903

3 files changed

Lines changed: 301 additions & 80 deletions

File tree

src/specify_cli/presets/__init__.py

Lines changed: 123 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
import shutil
1717
from dataclasses import dataclass
1818
from pathlib import Path
19-
from typing import TYPE_CHECKING, Optional, Dict, List, Any
19+
from typing import TYPE_CHECKING, Optional, Dict, List, Any, Union
2020

2121
if TYPE_CHECKING:
2222
from ..agents import CommandRegistrar
@@ -774,15 +774,14 @@ def register_enabled_presets_for_agent(self, agent_name: str) -> None:
774774
updates["registered_commands"] = merged_commands
775775

776776
registered_skills = self._register_skills(manifest, pack_dir)
777-
if registered_skills:
778-
existing_skills = metadata.get("registered_skills", [])
779-
if not isinstance(existing_skills, list):
780-
existing_skills = []
781-
merged_skills = list(
782-
dict.fromkeys(existing_skills + registered_skills)
783-
)
784-
if merged_skills != existing_skills:
785-
updates["registered_skills"] = merged_skills
777+
existing_skills = self._normalize_registered_skills(
778+
metadata.get("registered_skills"), fallback_agent=agent_name
779+
)
780+
merged_skills = copy.deepcopy(existing_skills)
781+
if registered_skills.get(agent_name):
782+
merged_skills[agent_name] = registered_skills[agent_name]
783+
if merged_skills != existing_skills:
784+
updates["registered_skills"] = merged_skills
786785

787786
if updates:
788787
self.registry.update(pack_id, updates)
@@ -1159,7 +1158,16 @@ def _reconcile_skills(self, command_names: List[str]) -> None:
11591158
for _pid, meta in presets_by_priority:
11601159
if not isinstance(meta, dict):
11611160
continue
1162-
if skill_name in meta.get("registered_skills", []):
1161+
recorded = meta.get("registered_skills", [])
1162+
if isinstance(recorded, dict):
1163+
in_any_agent = any(
1164+
skill_name in names
1165+
for names in recorded.values()
1166+
if isinstance(names, list)
1167+
)
1168+
else:
1169+
in_any_agent = skill_name in recorded
1170+
if in_any_agent:
11631171
was_managed = True
11641172
break
11651173
if was_managed:
@@ -1373,7 +1381,7 @@ def _register_skills(
13731381
self,
13741382
manifest: "PresetManifest",
13751383
preset_dir: Path,
1376-
) -> List[str]:
1384+
) -> Dict[str, List[str]]:
13771385
"""Generate SKILL.md files for preset command overrides.
13781386
13791387
For every command template in the preset, checks whether a
@@ -1388,13 +1396,16 @@ def _register_skills(
13881396
preset_dir: Installed preset directory.
13891397
13901398
Returns:
1391-
List of skill names that were written (for registry storage).
1399+
``{agent_name: [skill_name, ...]}`` for the single active
1400+
agent skills were written for (empty if none were written),
1401+
matching the shape ``registered_commands`` already uses so the
1402+
two can be tracked/restored consistently (#2948).
13921403
"""
13931404
command_templates = [
13941405
t for t in manifest.templates if t.get("type") == "command"
13951406
]
13961407
if not command_templates:
1397-
return []
1408+
return {}
13981409

13991410
# Filter out extension command overrides if the extension isn't installed,
14001411
# matching the same logic used by _register_commands().
@@ -1409,11 +1420,11 @@ def _register_skills(
14091420
filtered.append(cmd)
14101421

14111422
if not filtered:
1412-
return []
1423+
return {}
14131424

14141425
skills_dir = self._get_skills_dir()
14151426
if not skills_dir:
1416-
return []
1427+
return {}
14171428

14181429
from .. import SKILL_DESCRIPTIONS, load_init_options
14191430
from ..agents import CommandRegistrar
@@ -1423,8 +1434,8 @@ def _register_skills(
14231434
if not isinstance(init_opts, dict):
14241435
init_opts = {}
14251436
selected_ai = init_opts.get("ai")
1426-
if not isinstance(selected_ai, str):
1427-
return []
1437+
if not isinstance(selected_ai, str) or not selected_ai:
1438+
return {}
14281439
ai_skills_enabled = is_ai_skills_enabled(init_opts)
14291440
registrar = CommandRegistrar()
14301441
integration = get_integration(selected_ai)
@@ -1525,68 +1536,115 @@ def _register_skills(
15251536
skill_file.write_text(skill_content, encoding="utf-8")
15261537
written.append(target_skill_name)
15271538

1528-
return written
1539+
return {selected_ai: written} if written else {}
15291540

1530-
def _tracked_skill_agent_dirs(self) -> List[tuple]:
1531-
"""Return (skills_dir, agent_name) pairs for every skill-mode
1532-
integration directory that currently exists under the project root.
1533-
1534-
``registered_skills`` only tracks skill *names*, not which agent
1535-
directories they were written under, so a preset used first under
1536-
one skill-mode agent and later switched to another can have live
1537-
overrides in both directories at removal time. Restoring every
1538-
existing skill-mode directory (instead of only the currently active
1539-
one) ensures none of them are left permanently orphaned.
1540-
1541-
Multiple integration keys can share the same physical directory
1542-
(e.g. ``agy``/``codex``/``zed`` all use ``.agents/skills``); only one
1543-
representative agent name is kept per unique resolved directory so
1544-
each physical directory is processed exactly once.
1541+
@staticmethod
1542+
def _normalize_registered_skills(
1543+
value: Any, fallback_agent: Optional[str] = None
1544+
) -> Dict[str, List[str]]:
1545+
"""Normalize a ``registered_skills`` registry value to per-agent form.
1546+
1547+
The registry stores ``registered_skills`` as ``Dict[str, List[str]]``
1548+
(agent name -> skill names actually written for that agent),
1549+
mirroring ``registered_commands``. Older registries predate that
1550+
provenance and stored a flat ``List[str]`` with no record of which
1551+
agent directory the names were written under; since that can't be
1552+
recovered, ``fallback_agent`` (when given) attributes the legacy
1553+
list to the agent currently being processed so the format
1554+
self-migrates on the next write. Without a fallback agent, legacy
1555+
lists are dropped rather than guessed at.
1556+
"""
1557+
if isinstance(value, dict):
1558+
return {
1559+
agent: list(names)
1560+
for agent, names in value.items()
1561+
if isinstance(agent, str) and isinstance(names, list)
1562+
}
1563+
if isinstance(value, list) and value and fallback_agent:
1564+
return {fallback_agent: [n for n in value if isinstance(n, str)]}
1565+
return {}
1566+
1567+
def _safe_skills_dir_for_agent(self, agent_name: str) -> Optional[Path]:
1568+
"""Resolve ``agent_name``'s skills directory, validated for safety.
1569+
1570+
Unlike :meth:`_get_skills_dir` (which resolves only the *currently
1571+
active* integration via init-options), this resolves an arbitrary
1572+
agent's directory from persisted provenance so a preset's skill
1573+
registrations can be restored/cleaned up under an agent that isn't
1574+
currently active. The candidate directory is validated through the
1575+
project's shared symlink/containment guard before any file in it is
1576+
touched; directories that don't exist or fail validation are
1577+
skipped rather than raising.
15451578
"""
15461579
from .. import _get_skills_dir as _resolve_skills_dir
1547-
from ..integrations import INTEGRATION_REGISTRY
1548-
from ..integrations.base import SkillsIntegration
1549-
1550-
seen: Dict[Path, str] = {}
1551-
for key in sorted(INTEGRATION_REGISTRY):
1552-
integration = INTEGRATION_REGISTRY[key]
1553-
if not (
1554-
isinstance(integration, SkillsIntegration)
1555-
or getattr(integration, "_skills_mode", False)
1556-
):
1557-
continue
1558-
skills_dir = _resolve_skills_dir(self.project_root, key)
1559-
if not skills_dir.is_dir():
1560-
continue
1561-
try:
1562-
resolved = skills_dir.resolve()
1563-
except OSError:
1564-
continue
1565-
seen.setdefault(resolved, key)
1580+
from ..shared_infra import _ensure_safe_shared_directory
15661581

1567-
return [(path, agent) for path, agent in seen.items()]
1582+
skills_dir = _resolve_skills_dir(self.project_root, agent_name)
1583+
try:
1584+
_ensure_safe_shared_directory(
1585+
self.project_root, skills_dir,
1586+
create=False, context="preset skills directory",
1587+
)
1588+
except (ValueError, OSError):
1589+
return None
1590+
return skills_dir
15681591

1569-
def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None:
1592+
def _unregister_skills(
1593+
self,
1594+
registered_skills: Union[Dict[str, List[str]], List[str]],
1595+
preset_dir: Path,
1596+
) -> None:
15701597
"""Restore original SKILL.md files after a preset is removed.
15711598
15721599
For each skill that was overridden by the preset, attempts to
15731600
regenerate the skill from the core command template. If no core
15741601
template exists, the skill directory is removed.
15751602
1576-
Restores across every existing skill-mode agent directory (see
1577-
:meth:`_tracked_skill_agent_dirs`), not just the currently active
1578-
integration, so switching integrations before removal can't leave a
1579-
preset override behind permanently.
1603+
``registered_skills`` records exactly which agent directories this
1604+
preset actually wrote to (see :meth:`_register_skills`), so removal
1605+
restores precisely those directories rather than guessing at every
1606+
skill-mode agent that happens to exist on disk. Each directory is
1607+
re-resolved and safety-validated at removal time (see
1608+
:meth:`_safe_skills_dir_for_agent`) since it may belong to an agent
1609+
that isn't currently active.
15801610
15811611
Args:
1582-
skill_names: List of skill names written by the preset.
1612+
registered_skills: Per-agent skill names written by the preset
1613+
(``{agent_name: [skill_name, ...]}``), or a legacy flat
1614+
``List[str]`` from a registry written before this
1615+
provenance tracking existed.
15831616
preset_dir: The preset's installed directory (may already be deleted).
15841617
"""
1585-
if not skill_names:
1618+
if not registered_skills:
1619+
return
1620+
1621+
if isinstance(registered_skills, dict):
1622+
for agent_name, skill_names in registered_skills.items():
1623+
if not skill_names:
1624+
continue
1625+
skills_dir = self._safe_skills_dir_for_agent(agent_name)
1626+
if skills_dir is None:
1627+
continue
1628+
self._unregister_skills_in_dir(skill_names, skills_dir, agent_name)
1629+
return
1630+
1631+
# Legacy flat-list format: no record of which agent directory these
1632+
# names were written under, so best-effort restore is limited to the
1633+
# currently active agent's directory (the pre-provenance behaviour).
1634+
skills_dir = self._get_skills_dir()
1635+
if not skills_dir:
15861636
return
1637+
from .. import load_init_options
15871638

1588-
for skills_dir, agent_name in self._tracked_skill_agent_dirs():
1589-
self._unregister_skills_in_dir(skill_names, skills_dir, agent_name)
1639+
init_opts = load_init_options(self.project_root)
1640+
if not isinstance(init_opts, dict):
1641+
init_opts = {}
1642+
selected_ai = init_opts.get("ai")
1643+
self._unregister_skills_in_dir(
1644+
registered_skills,
1645+
skills_dir,
1646+
selected_ai if isinstance(selected_ai, str) else None,
1647+
)
15901648

15911649
def _unregister_skills_in_dir(
15921650
self, skill_names: List[str], skills_dir: Path, selected_ai: Optional[str]
@@ -1760,11 +1818,11 @@ def install_from_directory(
17601818
"enabled": True,
17611819
"priority": priority,
17621820
"registered_commands": {},
1763-
"registered_skills": [],
1821+
"registered_skills": {},
17641822
})
17651823

17661824
registered_commands: Dict[str, List[str]] = {}
1767-
registered_skills: List[str] = []
1825+
registered_skills: Dict[str, List[str]] = {}
17681826
try:
17691827
# Register command overrides with AI agents and persist the result
17701828
# immediately so cleanup can recover even if installation stops

tests/integrations/test_integration_claude.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,7 @@ def test_claude_preset_creates_new_skill_without_commands_dir(self, tmp_path):
303303
assert "disable-model-invocation: false" in content
304304

305305
metadata = manager.registry.get("claude-skill-command")
306-
assert "speckit-research" in metadata.get("registered_skills", [])
306+
assert "speckit-research" in metadata.get("registered_skills", {}).get("claude", [])
307307

308308

309309
class TestClaudeArgumentHints:

0 commit comments

Comments
 (0)