Skip to content

[Bug]: extension reinstall overwrites config preserved by remove --keep-config #3427

Closed

Description

@grafvonb

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

  1. Start with a Spec Kit project that has the ralph extension installed.

  2. Edit the extension config and change it away from defaults, for example:

    model: "custom-model"
    max_iterations: 99
    agent_cli: "copilot"
  3. Remove the extension while preserving config:

    specify extension remove ralph --keep-config --force
  4. Confirm the config file still exists and still contains the customized values:

    grep -nE 'model:|max_iterations:' .specify/extensions/ralph/ralph-config.yml
  5. Reinstall the extension:

    specify extension add ralph

    Or, if installing from a direct source:

    specify extension add ralph --from <zip-url>
  6. 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: 99

back to:

model: "gpt-5.4"
max_iterations: 30

Specify 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.yml and *-config.local.yml files.
  • 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 --force reinstall 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

  1. github-actions commented on Jul 9, 2026

    @github-actions
    Contributor

    Bug assessment — reinstall-overwrites-kept-config: Valid · severity medium


    Bug Assessment: Extension reinstall overwrites config preserved by remove --keep-config

    Report (summarized)

    Reported by @grafvonb. Running specify extension remove <ext> --keep-config --force correctly preserves *-config.yml files in .specify/extensions/<ext>/. However, a subsequent specify extension add <ext> silently overwrites those preserved files with the extension's packaged defaults. The reporter confirmed the behavior with the ralph extension and correctly hypothesizes it is broader than Ralph—affecting any extension that ships top-level *-config.yml files.

    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

    1. Install an extension that ships a *-config.yml file: specify extension add ralph
    2. Customize the config: edit .specify/extensions/ralph/ralph-config.yml (change model, max_iterations, etc.)
    3. Remove preserving config: specify extension remove ralph --keep-config --force
    4. Verify config file still holds custom values: grep -nE 'model:|max_iterations:' .specify/extensions/ralph/ralph-config.yml
    5. Reinstall: specify extension add ralph
    6. Check config again — values have been silently reset to defaults.

    Suspected Code Paths

    • src/specify_cli/extensions/__init__.py:1401-1402 — the unconditional shutil.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 — the if force and self.registry.is_installed(manifest.id): guard that gates the backup/restore path. After remove --keep-config, the extension is no longer in the registry, so this guard is never entered, did_remove stays False, and the backup is never created for restoration.
    • src/specify_cli/extensions/__init__.py:1430-1452 — the restore-from-backup block. It only runs when did_remove is True, which requires the --force reinstall path above. A plain (non-force) reinstall of an unregistered extension never sets did_remove.
    • src/specify_cli/extensions/__init__.py:1570-1583 — remove(..., keep_config=True): correctly leaves *-config.yml files behind in extension_dir but 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 --force reinstall path preserves configs; no analogous test for the remove --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 where remove --keep-config intentionally left config files in that directory. The backup/restore mechanism that protects configs during --force reinstall (.backup/<ext>/) is only triggered when the extension is still registered at reinstall time; it is completely bypassed when reinstalling after a --keep-config removal. The remove --keep-config path never writes a backup, leaving no artifact for the install path to restore.

    Proposed Remediation

    Preferred: In install_from_directory(), before the unconditional shutil.rmtree(dest_dir) (lines 1401-1402), scan dest_dir for any *-config.yml and *-config.local.yml files and hold them in memory (or write to a temp location). After shutil.copytree(source_dir, dest_dir) succeeds, write them back into dest_dir, overwriting the defaults just installed. This mirrors the existing backup/restore logic for the --force path but handles the case where no .backup/ entry exists.

    # ~line 1400 (before existing dest_dir.exists() check)
    stranded_configs: dict[str, bytes] = {}
    if dest_dir.exists() and not self.registry.is_installed(manifest.id):
        for cfg_file in (
            list(dest_dir.glob("*-config.yml"))
            + list(dest_dir.glob("*-config.local.yml"))
        ):
            if cfg_file.is_file() and not cfg_file.is_symlink():
                stranded_configs[cfg_file.name] = cfg_file.read_bytes()
    
    if dest_dir.exists():
        shutil.rmtree(dest_dir)
    
    shutil.copytree(source_dir, dest_dir, ignore=ignore_fn)
    
    # Restore stranded configs (after existing backup-restore block)
    for filename, content in stranded_configs.items():
        (dest_dir / filename).write_bytes(content)

    The same pattern should be applied in install_from_zip(), though that method delegates to install_from_directory(), so fixing the latter covers both.

    Alternatives:

    • Have remove --keep-config also write the kept configs to .backup/<ext>/ (mirroring the non-keep_config path), and then the existing restore-from-backup logic in install_from_directory() would handle it — but this requires did_remove to be set True even for the keep-config path, which changes the semantics of that flag and may have unintended side effects.
    • Detect and warn the user on reinstall that a preserved-config directory exists and prompt for explicit overwrite consent — adds friction and doesn't fix the silent data loss.

    Files likely to change:

    • src/specify_cli/extensions/__init__.py — the install_from_directory() method around lines 1400-1452

    Tests to add or update:

    • A new test (e.g., test_reinstall_after_keep_config_preserves_config) that: installs an extension, customizes its config, calls remove(..., keep_config=True), calls install_from_directory(...) again (non-force), and asserts the customized config values survive.
    • The analogous scenario via install_from_zip() if a ZIP-based fixture is practical.
    • Cover local override files (*-config.local.yml) with the same test pattern.

    Risks & Considerations

    • The fix reads file contents into memory; config files are typically small (< 1 KB), so this is not a memory concern.
    • The condition 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.
    • The write-back (write_bytes) must happen after shutil.copytree so the new defaults from the source are always superseded by the user's values — no risk of the packaged defaults silently winning.
    • No API surface change; all changes are internal to install_from_directory().

    Open Questions

    • [NEEDS CLARIFICATION: The reporter mentions specify extension add ralph --from <source>. The --from argument value was redacted in the issue. It is unclear whether this path goes through install_from_zip() or install_from_directory(); either way the fix above covers both since install_from_zip() delegates to install_from_directory().]

    Posted on behalf of @mnriem by GitHub Copilot (model: claude-sonnet-4.6, autonomous).

    Generated by 🐛 Assess Bug from Labeled Issue for issue #3427 · 224 AIC · ⌖ 25.5 AIC · ⊞ 33.3K · ◷

  2. added
    bug-fixTrigger the bug-fix agentic workflow
    on Jul 10, 2026
  3. github-actions commented on Jul 10, 2026

    @github-actions
    Contributor

    Pull request created: #3449

    Generated by Fix Bug from Labeled Issue · 449.5 AIC · ⌖ 15.2 AIC · ⊞ 34K

  4. github-actions commented on Jul 10, 2026

    @github-actions
    Contributor

    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 unconditional shutil.rmtree(dest_dir), we now scan dest_dir for any *-config.yml / *-config.local.yml files belonging to an unregistered extension (i.e. one removed with --keep-config) and hold their contents in memory. After shutil.copytree completes, 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-test workflow configured — apply the bug-test label to trigger the third stage (assess → fix → test).

    Posted on behalf of @mnriem by GitHub Copilot (model: claude-sonnet-4.6, autonomous).

    Generated by 🛠️ Fix Bug from Labeled Issue for issue #3427 · 449.5 AIC · ⌖ 15.2 AIC · ⊞ 34K · ◷

  5. github-actions commented on Jul 10, 2026

    @github-actions
    Contributor

    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-config

    Summary

    All three targeted tests passed against the proposed fix. The fix correctly rescues *-config.yml and *-config.local.yml files from an unregistered extension directory before the shutil.rmtree / shutil.copytree cycle, then writes them back after the fresh copy completes. User-customized values (e.g. model: custom-model, max_iterations: 99) survive a remove --keep-config → plain reinstall cycle without any silent overwrite. The pre-existing --force reinstall path also continues to work.

    Test Results

    Metric Count
    Passed 3
    Failed 0
    Skipped 0
    Errored 0
    Duration ~0.2 s

    Failures (if any)

    None.

    Tests run

    Test Outcome
    test_reinstall_after_keep_config_preserves_config (new) ✅ PASS
    test_reinstall_after_keep_config_preserves_local_config (new) ✅ PASS
    test_install_force_config_preserved (pre-existing regression guard) ✅ PASS

    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

    • Test environment setup failure (degraded mode): The standard test command (uv sync --extra test && uv run pytest) could not be used because uv is 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.py and executed under PyPy 3.11 with pure-Python dependencies sourced from pre-installed pipx venvs (ansible-core → PyYAML 6.0.3, packaging; yamllint → pathspec). The typer, click, readchar, and json5 packages (needed only by specify_cli/__init__.py, not by the extensions submodule under test) were replaced with minimal in-process stubs.
    • The three tests executed call ExtensionManager.install_from_directory() and ExtensionManager.remove() directly, which are the exact methods changed by the fix, so the test scope is accurate despite the degraded environment.
    • The full test suite (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.
    • Because the runner uses PyPy 3.11 instead of CPython 3.12/3.13, any CPython-specific behaviour would not surface; however, the fix is pure Python (no C extensions or version-specific constructs), so this is not a concern.

    Posted on behalf of @mnriem by GitHub Copilot (model: claude-sonnet-4.6, autonomous).

    Generated by 🧪 Test a Bug Fix from a Labeled Issue for issue #3427 · 2.3K AIC · ⌖ 10.9 AIC · ⊞ 34.5K · ◷

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions