Skip to content

Commit 7c7aff7

Browse files
Fix upgrade-overwrites-copilot-skills: pass force=True to extension skill re-registration after upgrade
Apply the remediation from the bug assessment on issue #3849. _register_extension_skills() had a skip guard that refused to overwrite existing SKILL.md files (protecting user customizations). In the upgrade path, setup() regenerates all core-template SKILL.md files first, then calls register_enabled_extensions_for_agent(). The guard then sees those freshly-written core files as 'existing' and skips every extension, leaving only core template content on disk. Fix: add force: bool = False to _register_extension_skills() and thread it through register_enabled_extensions_for_agent() and _register_extensions_for_agent(). In integration_upgrade(), pass force=True so extension content layers on top of the just-regenerated core files. The force flag is off-by-default so plain extension add still protects user-modified skill files. Refs #3849 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 1fff7a1 commit 7c7aff7

4 files changed

Lines changed: 153 additions & 6 deletions

File tree

‎src/specify_cli/extensions/__init__.py‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1292,6 +1292,7 @@ def _register_extension_skills(
12921292
manifest: ExtensionManifest,
12931293
extension_dir: Path,
12941294
link_outputs: bool = False,
1295+
force: bool = False,
12951296
) -> List[str]:
12961297
"""Generate SKILL.md files for extension commands as agent skills.
12971298
@@ -1305,6 +1306,11 @@ def _register_extension_skills(
13051306
extension_dir: Installed extension directory.
13061307
link_outputs: If True, create dev-mode symlinks for rendered
13071308
skill files when supported by the OS.
1309+
force: If True, overwrite existing SKILL.md files even when they
1310+
are not dev-mode symlinks. Use in the upgrade path, where
1311+
``setup()`` has just freshly regenerated core-template skill
1312+
files and the skip guard would otherwise prevent extension
1313+
content from being layered on top.
13081314
13091315
Returns:
13101316
List of skill names that were created (for registry storage).
@@ -1392,13 +1398,16 @@ def _replacement(match: re.Match[str]) -> str:
13921398
)
13931399
# Do not overwrite user-customized skills, but allow dev-mode
13941400
# symlinks that point back to this extension's generated cache
1395-
# to be refreshed on a subsequent dev install.
1396-
if not is_expected_dev_symlink:
1401+
# to be refreshed on a subsequent dev install. In the upgrade
1402+
# path (force=True) the file was just written by setup(), so
1403+
# overwriting it with the composed extension content is correct.
1404+
if not is_expected_dev_symlink and not force:
13971405
continue
1398-
elif skill_dir_preexists:
1406+
elif skill_dir_preexists and not force:
13991407
# Never add files to a pre-existing user directory. Without a
14001408
# verifiable SKILL.md ownership marker, rollback/removal cannot
14011409
# distinguish our output from unrelated user artifacts.
1410+
# Skipped when force=True (upgrade path).
14021411
continue
14031412

14041413
# Create skill directory; track whether we created it so we can clean
@@ -2637,7 +2646,7 @@ def unregister_agent_artifacts(
26372646
if updates:
26382647
self.registry.update(ext_id, updates)
26392648

2640-
def register_enabled_extensions_for_agent(self, agent_name: str) -> None:
2649+
def register_enabled_extensions_for_agent(self, agent_name: str, *, force: bool = False) -> None:
26412650
"""Register installed, enabled extensions for ``agent_name``.
26422651
26432652
Command-file registration is scoped to the explicit ``agent_name``
@@ -2755,7 +2764,7 @@ def register_enabled_extensions_for_agent(self, agent_name: str) -> None:
27552764
if agent_name == active_agent:
27562765
try:
27572766
registered_skills = self._register_extension_skills(
2758-
manifest, ext_dir
2767+
manifest, ext_dir, force=force
27592768
)
27602769
except Exception as skills_err:
27612770
# Skills are a companion artifact. If command registration

‎src/specify_cli/integrations/_helpers.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,7 @@ def _register_extensions_for_agent(
395395
agent_key: str,
396396
*,
397397
continuing: str,
398+
force: bool = False,
398399
) -> None:
399400
"""Register all enabled extensions' commands/skills for ``agent_key``.
400401
@@ -408,14 +409,19 @@ def _register_extensions_for_agent(
408409
before registering), so extension *skill* rendering — which is scoped to
409410
the active ``ai`` / ``ai_skills`` init-options — matches ``agent_key``.
410411
412+
When ``force=True``, existing skill files are overwritten even when they
413+
are not dev-mode symlinks. Pass ``force=True`` in the upgrade path so that
414+
extension content is layered on top of the core-template files that
415+
``setup()`` just regenerated (fixes the skip-guard bug for skills mode).
416+
411417
Best-effort: never aborts the surrounding integration operation. Callers
412418
invoke it *after* the use/upgrade/switch transaction has committed so a
413419
failure here cannot trigger a rollback.
414420
"""
415421
_best_effort_extension_op(
416422
project_root,
417423
agent_key,
418-
lambda mgr, key: mgr.register_enabled_extensions_for_agent(key),
424+
lambda mgr, key: mgr.register_enabled_extensions_for_agent(key, force=force),
419425
phase="register extension artifacts for",
420426
continuing=continuing,
421427
)

‎src/specify_cli/integrations/_migrate_commands.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -870,6 +870,7 @@ def integration_upgrade(
870870
_register_extensions_for_agent(
871871
project_root,
872872
key,
873+
force=True,
873874
continuing="The integration was upgraded, but installed extensions may need re-registration.",
874875
)
875876
_register_presets_for_agent(

‎tests/test_extension_skills.py‎

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2943,6 +2943,137 @@ def fail_recovered_claude_registration(self, agent_name, *args, **kwargs):
29432943
assert "speckit-early-ext-world" in metadata["registered_skills"]
29442944

29452945

2946+
2947+
# ===== Regression test: upgrade-overwrites-copilot-skills (#3849) =====
2948+
2949+
class TestRegisterExtensionSkillsForceFlag:
2950+
"""Regression tests for the ``force`` flag on ``_register_extension_skills``.
2951+
2952+
Issue #3849: ``integration upgrade --force`` called ``setup()`` which
2953+
regenerated all core-template SKILL.md files, then called
2954+
``register_enabled_extensions_for_agent()``. The skip-guard in
2955+
``_register_extension_skills`` treated the freshly-written core files as
2956+
existing user content and skipped every extension skill, leaving only core
2957+
template content on disk.
2958+
2959+
The fix introduces ``force=True`` in the upgrade path so the guard does not
2960+
fire for core-template files that setup() just wrote.
2961+
"""
2962+
2963+
def test_force_false_skips_existing_skill(self, project_dir, temp_dir):
2964+
"""Without force=True the skip guard must still protect existing files."""
2965+
_create_init_options(project_dir, ai="claude", ai_skills=True)
2966+
skills_dir = _create_skills_dir(project_dir, ai="claude")
2967+
ext_dir = _create_extension_dir(temp_dir)
2968+
2969+
# Manually pre-create a SKILL.md as if setup() had already written it
2970+
skill_subdir = skills_dir / "speckit-test-ext-hello"
2971+
skill_subdir.mkdir(parents=True, exist_ok=True)
2972+
skill_file = skill_subdir / "SKILL.md"
2973+
skill_file.write_text("core-template content only", encoding="utf-8")
2974+
2975+
manager = ExtensionManager(project_dir)
2976+
manifest = ExtensionManifest(ext_dir / "extension.yml")
2977+
2978+
# Default (force=False): existing file must not be overwritten
2979+
written = manager._register_extension_skills(manifest, ext_dir, force=False)
2980+
assert "speckit-test-ext-hello" not in written
2981+
assert skill_file.read_text(encoding="utf-8") == "core-template content only"
2982+
2983+
def test_force_true_overwrites_existing_skill(self, project_dir, temp_dir):
2984+
"""With force=True the function must overwrite the existing SKILL.md.
2985+
2986+
This is the core regression test for #3849: calling
2987+
``_register_extension_skills(force=True)`` after ``setup()`` has
2988+
written a fresh core-template SKILL.md must replace it with the
2989+
composed extension content.
2990+
"""
2991+
_create_init_options(project_dir, ai="claude", ai_skills=True)
2992+
skills_dir = _create_skills_dir(project_dir, ai="claude")
2993+
ext_dir = _create_extension_dir(temp_dir)
2994+
2995+
# Simulate what setup() writes: a bare core-template SKILL.md
2996+
skill_subdir = skills_dir / "speckit-test-ext-hello"
2997+
skill_subdir.mkdir(parents=True, exist_ok=True)
2998+
skill_file = skill_subdir / "SKILL.md"
2999+
skill_file.write_text("core-template content only", encoding="utf-8")
3000+
3001+
manager = ExtensionManager(project_dir)
3002+
manifest = ExtensionManifest(ext_dir / "extension.yml")
3003+
3004+
# Upgrade path (force=True): extension content should replace the core file
3005+
written = manager._register_extension_skills(manifest, ext_dir, force=True)
3006+
assert "speckit-test-ext-hello" in written, (
3007+
"force=True should overwrite the core-template file and return the skill name"
3008+
)
3009+
content = skill_file.read_text(encoding="utf-8")
3010+
assert "Run this to say hello." in content, (
3011+
"Extension command body must appear in the overwritten SKILL.md"
3012+
)
3013+
assert "core-template content only" not in content, (
3014+
"Core-template placeholder must have been replaced by extension content"
3015+
)
3016+
3017+
def test_register_enabled_extensions_for_agent_force_flag_threads_through(
3018+
self, project_dir, temp_dir
3019+
):
3020+
"""force=True on register_enabled_extensions_for_agent must reach _register_extension_skills.
3021+
3022+
End-to-end check: after an upgrade writes a fresh core-template SKILL.md,
3023+
``register_enabled_extensions_for_agent(force=True)`` must produce a
3024+
SKILL.md that contains the extension content.
3025+
"""
3026+
_create_init_options(project_dir, ai="claude", ai_skills=True)
3027+
skills_dir = _create_skills_dir(project_dir, ai="claude")
3028+
ext_dir = _create_extension_dir(temp_dir)
3029+
3030+
manager = ExtensionManager(project_dir)
3031+
# Install extension so it is in the registry
3032+
ext_manifest = manager.install_from_directory(
3033+
ext_dir, "0.1.0", register_commands=False
3034+
)
3035+
3036+
# Simulate a freshly-regenerated core-template SKILL.md (as setup() would write)
3037+
skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md"
3038+
skill_file.write_text("core-template content only", encoding="utf-8")
3039+
3040+
# Re-register with force=True (upgrade path)
3041+
manager.register_enabled_extensions_for_agent("claude", force=True)
3042+
3043+
content = skill_file.read_text(encoding="utf-8")
3044+
assert "Run this to say hello." in content, (
3045+
"After register_enabled_extensions_for_agent(force=True), the SKILL.md "
3046+
"must contain the extension body, not just the core-template stub."
3047+
)
3048+
3049+
def test_force_true_with_preexisting_dir_but_no_skill_file(
3050+
self, project_dir, temp_dir
3051+
):
3052+
"""force=True must write into a pre-existing directory with no SKILL.md.
3053+
3054+
The second skip guard (``elif skill_dir_preexists``) should also be
3055+
bypassed by force=True so an upgrade can create a missing SKILL.md
3056+
even when the skill sub-directory already exists.
3057+
"""
3058+
_create_init_options(project_dir, ai="claude", ai_skills=True)
3059+
skills_dir = _create_skills_dir(project_dir, ai="claude")
3060+
ext_dir = _create_extension_dir(temp_dir)
3061+
3062+
# Create the skill directory without the SKILL.md file
3063+
skill_subdir = skills_dir / "speckit-test-ext-hello"
3064+
skill_subdir.mkdir(parents=True, exist_ok=True)
3065+
skill_file = skill_subdir / "SKILL.md"
3066+
assert not skill_file.exists()
3067+
3068+
manager = ExtensionManager(project_dir)
3069+
manifest = ExtensionManifest(ext_dir / "extension.yml")
3070+
3071+
written = manager._register_extension_skills(manifest, ext_dir, force=True)
3072+
assert "speckit-test-ext-hello" in written
3073+
assert skill_file.exists()
3074+
assert "Run this to say hello." in skill_file.read_text(encoding="utf-8")
3075+
3076+
29463077
# ===== Extension Skill Unregistration Tests =====
29473078

29483079
class TestExtensionSkillUnregistration:

0 commit comments

Comments
 (0)