Repository navigation
[Bug]: extension reinstall overwrites config preserved by remove --keep-config #3427
Description
Bug Description
specify extension remove <extension> --keep-config preserves extension config files as advertised.
However, reinstalling the same extension overwrites the preserved config with the extension's packaged default config instead of keeping the user's existing values.
I reproduced this with the ralph extension, but the behavior looks broader than Ralph and appears to affect the general extension install/remove lifecycle for extensions that ship top-level *-config.yml files.
Steps to Reproduce
-
Start with a Spec Kit project that has the
ralphextension installed. -
Edit the extension config and change it away from defaults, for example:
model: "custom-model" max_iterations: 99 agent_cli: "copilot"
-
Remove the extension while preserving config:
specify extension remove ralph --keep-config --force
-
Confirm the config file still exists and still contains the customized values:
grep -nE 'model:|max_iterations:' .specify/extensions/ralph/ralph-config.yml -
Reinstall the extension:
specify extension add ralph
Or, if installing from a direct source:
specify extension add ralph --from <zip-url>
-
Check the config file again:
grep -nE 'model:|max_iterations:' .specify/extensions/ralph/ralph-config.yml
Expected Behavior
If --keep-config preserved the extension config during uninstall, reinstalling the same extension should keep that preserved config in place.
At minimum, reinstall should not silently replace a preserved config file with packaged defaults.
Actual Behavior
The config file is preserved immediately after specify extension remove ... --keep-config, but reinstalling the extension overwrites it with default values from the installer package.
In my repro, the config changed from:
model: "custom-model"
max_iterations: 99back to:
model: "gpt-5.4"
max_iterations: 30Specify CLI Version
0.12.8
AI Agent
GitHub Copilot
Operating System
macOS 26.5.1
Python Version
3.13.5
Additional Context
From local inspection, this appears to be a lifecycle bug in extension install/remove behavior:
remove(..., keep_config=True)preserves top-level*-config.ymland*-config.local.ymlfiles.- But reinstall later deletes the destination extension directory if it already exists and then copies the extension fresh.
- The backup/restore logic only appears to run for the
--forcereinstall path when an extension is still registered as installed and is first removed into.backup/.
After --keep-config, the extension is no longer installed in the registry, but its config directory still exists. On the next install, that preserved directory is deleted and replaced, which discards the kept config.
So --keep-config currently preserves config only until the next reinstall, which seems to defeat the purpose of the flag.
Activity
github-actions commentedon Jul 9, 2026
Bug assessment — reinstall-overwrites-kept-config: Valid · severity medium
Bug Assessment: Extension reinstall overwrites config preserved by
remove --keep-configreinstall-overwrites-kept-configremove --keep-config#3427Report (summarized)
Reported by @grafvonb. Running
specify extension remove <ext> --keep-config --forcecorrectly preserves*-config.ymlfiles in.specify/extensions/<ext>/. However, a subsequentspecify extension add <ext>silently overwrites those preserved files with the extension's packaged defaults. The reporter confirmed the behavior with theralphextension and correctly hypothesizes it is broader than Ralph—affecting any extension that ships top-level*-config.ymlfiles.Environment: Specify CLI 0.12.8, macOS 26.5.1, Python 3.13.5, GitHub Copilot integration.
Symptom
After
remove --keep-config, config files survive correctly, but a subsequent fresh install deletes them before the restore logic runs, replacing user-customized values (e.g.,model: "custom-model",max_iterations: 99) with packaged defaults (e.g.,model: "gpt-5.4",max_iterations: 30).Reproduction
*-config.ymlfile:specify extension add ralph.specify/extensions/ralph/ralph-config.yml(changemodel,max_iterations, etc.)specify extension remove ralph --keep-config --forcegrep -nE 'model:|max_iterations:' .specify/extensions/ralph/ralph-config.ymlspecify extension add ralphSuspected Code Paths
src/specify_cli/extensions/__init__.py:1401-1402— the unconditionalshutil.rmtree(dest_dir)that deletes the preserved-config directory before copying in the fresh extension. This is the proximate cause.src/specify_cli/extensions/__init__.py:1386— theif force and self.registry.is_installed(manifest.id):guard that gates the backup/restore path. Afterremove --keep-config, the extension is no longer in the registry, so this guard is never entered,did_removestaysFalse, and the backup is never created for restoration.src/specify_cli/extensions/__init__.py:1430-1452— the restore-from-backup block. It only runs whendid_remove is True, which requires the--forcereinstall path above. A plain (non-force) reinstall of an unregistered extension never setsdid_remove.src/specify_cli/extensions/__init__.py:1570-1583—remove(..., keep_config=True): correctly leaves*-config.ymlfiles behind inextension_dirbut does not write anything to.backup/, so the subsequent install has no backup to restore from.tests/test_extensions.py:1205-1227—test_install_force_config_preserved: verifies the--forcereinstall path preserves configs; no analogous test for theremove --keep-config→ plain reinstall path.Root Cause Hypothesis
Confidence: high.
install_from_directory()assumes that if the destination directory already exists and the extension is not in the registry, the directory is either stale or orphaned, so it can be deleted unconditionally (line 1401-1402). It does not account for the case whereremove --keep-configintentionally left config files in that directory. The backup/restore mechanism that protects configs during--forcereinstall (.backup/<ext>/) is only triggered when the extension is still registered at reinstall time; it is completely bypassed when reinstalling after a--keep-configremoval. Theremove --keep-configpath never writes a backup, leaving no artifact for the install path to restore.Proposed Remediation
Preferred: In
install_from_directory(), before the unconditionalshutil.rmtree(dest_dir)(lines 1401-1402), scandest_dirfor any*-config.ymland*-config.local.ymlfiles and hold them in memory (or write to a temp location). Aftershutil.copytree(source_dir, dest_dir)succeeds, write them back intodest_dir, overwriting the defaults just installed. This mirrors the existing backup/restore logic for the--forcepath but handles the case where no.backup/entry exists.The same pattern should be applied in
install_from_zip(), though that method delegates toinstall_from_directory(), so fixing the latter covers both.Alternatives:
remove --keep-configalso write the kept configs to.backup/<ext>/(mirroring the non-keep_config path), and then the existing restore-from-backup logic ininstall_from_directory()would handle it — but this requiresdid_removeto be set True even for the keep-config path, which changes the semantics of that flag and may have unintended side effects.Files likely to change:
src/specify_cli/extensions/__init__.py— the install_from_directory() method around lines 1400-1452Tests to add or update:
test_reinstall_after_keep_config_preserves_config) that: installs an extension, customizes its config, callsremove(..., keep_config=True), callsinstall_from_directory(...)again (non-force), and asserts the customized config values survive.install_from_zip()if a ZIP-based fixture is practical.*-config.local.yml) with the same test pattern.Risks & Considerations
not self.registry.is_installed(manifest.id)guards against picking up stale configs from a different extension that happens to share the directory. It is tight: it only rescues configs when the extension is genuinely unregistered.write_bytes) must happen aftershutil.copytreeso the new defaults from the source are always superseded by the user's values — no risk of the packaged defaults silently winning.install_from_directory().Open Questions
specify extension add ralph --from <source>. The--fromargument value was redacted in the issue. It is unclear whether this path goes throughinstall_from_zip()orinstall_from_directory(); either way the fix above covers both sinceinstall_from_zip()delegates toinstall_from_directory().]Posted on behalf of
@mnriemby GitHub Copilot (model: claude-sonnet-4.6, autonomous).Fix reinstall-overwrites-kept-config: preserve config on plain reinst…
github-actions commentedon Jul 10, 2026
Pull request created: #3449
github-actions commentedon Jul 10, 2026
A draft PR has been opened to fix this bug: reinstall-overwrites-kept-config.
What changed: In
install_from_directory()(src/specify_cli/extensions/__init__.py), before the unconditionalshutil.rmtree(dest_dir), we now scandest_dirfor any*-config.yml/*-config.local.ymlfiles belonging to an unregistered extension (i.e. one removed with--keep-config) and hold their contents in memory. Aftershutil.copytreecompletes, those files are written back, so user-customized values always override the packaged defaults.Two regression tests were added covering both the main config and local-override files.
Next step: review the draft PR, then — if this project has the
bug-testworkflow configured — apply thebug-testlabel to trigger the third stage (assess → fix → test).Posted on behalf of
@mnriemby GitHub Copilot (model: claude-sonnet-4.6, autonomous).github-actions commentedon Jul 10, 2026
Bug test — reinstall-overwrites-kept-config: ✅ passing · 3 passed, 0 failed, 0 skipped · fix from PR #3449 (head
1bf70ba)Bug Test Report: Extension reinstall overwrites config preserved by
remove --keep-configreinstall-overwrites-kept-configremove --keep-config#3427fix/3427-reinstall-overwrites-kept-config-40c76b4f5dd346d6, head SHA1bf70bafe63a2bf8839329f7d844031d21a1cfbfpython3 /tmp/run_tests.py(standalone runner — see Notes)test_install_force_config_preservedregression guardSummary
All three targeted tests passed against the proposed fix. The fix correctly rescues
*-config.ymland*-config.local.ymlfiles from an unregistered extension directory before theshutil.rmtree/shutil.copytreecycle, then writes them back after the fresh copy completes. User-customized values (e.g.model: custom-model,max_iterations: 99) survive aremove --keep-config→ plain reinstall cycle without any silent overwrite. The pre-existing--forcereinstall path also continues to work.Test Results
Failures (if any)
None.
Tests run
test_reinstall_after_keep_config_preserves_config(new)test_reinstall_after_keep_config_preserves_local_config(new)test_install_force_config_preserved(pre-existing regression guard)Verification vs. Historical Fix
Not applicable (no historical fix referenced — this is a fresh bug with a proposed fix in draft PR #3449).
Notes & Caveats
uv sync --extra test && uv run pytest) could not be used becauseuvis not installed in the runner, CPython 3.12 (/usr/bin/python3.12) is blocked by the gh-aw firewall policy, and PyPI access is firewalled (HTTP 403). A standalone Python test runner was written at/tmp/run_tests.pyand executed under PyPy 3.11 with pure-Python dependencies sourced from pre-installed pipx venvs (ansible-core→ PyYAML 6.0.3, packaging;yamllint→ pathspec). Thetyper,click,readchar, andjson5packages (needed only byspecify_cli/__init__.py, not by theextensionssubmodule under test) were replaced with minimal in-process stubs.ExtensionManager.install_from_directory()andExtensionManager.remove()directly, which are the exact methods changed by the fix, so the test scope is accurate despite the degraded environment.tests/test_extensions.py, ~1 300+ lines) could not be run. The tests exercised here match exactly the two new tests added by the fix plus the closest pre-existing regression guard.Posted on behalf of
@mnriemby GitHub Copilot (model: claude-sonnet-4.6, autonomous).Fix reinstall-overwrites-kept-config: preserve config on plain reinst…
[bug-fix] Fix reinstall-overwrites-kept-config: preserve config on pl…