Skip to content

Commit 63ad566

Browse files
Merge pull request #434 from math-inc/codex/public-claude-plugin-symlink-20260328
fix(managed): make Claude plugin cleanup symlink-safe
2 parents 8e18cd7 + b1ddf9d commit 63ad566

2 files changed

Lines changed: 64 additions & 11 deletions

File tree

gauss_cli/autoformalize.py

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -881,18 +881,23 @@ def _ensure_claude_user_plugin_state(
881881

882882

883883
def _replace_tree_link(destination: Path, source: Path) -> None:
884-
if destination.exists() or destination.is_symlink():
885-
if destination.is_symlink() or destination.is_file():
886-
destination.unlink()
887-
else:
888-
shutil.rmtree(destination)
884+
_remove_existing_path(destination)
889885
destination.parent.mkdir(parents=True, exist_ok=True)
890886
try:
891887
destination.symlink_to(source, target_is_directory=source.is_dir())
892888
except OSError:
893889
shutil.copytree(source, destination, symlinks=True)
894890

895891

892+
def _remove_existing_path(path: Path) -> None:
893+
if not path.exists() and not path.is_symlink():
894+
return
895+
if path.is_symlink() or path.is_file():
896+
path.unlink()
897+
return
898+
shutil.rmtree(path)
899+
900+
896901
def _sync_prewarmed_claude_plugin(
897902
*,
898903
real_home: Path,
@@ -1489,8 +1494,7 @@ def _ensure_git_checkout(
14891494
error_prefix="Failed to check out the managed asset revision",
14901495
)
14911496
else:
1492-
if destination.exists():
1493-
shutil.rmtree(destination)
1497+
_remove_existing_path(destination)
14941498
destination.parent.mkdir(parents=True, exist_ok=True)
14951499
if revision:
14961500
_run(
@@ -1548,8 +1552,7 @@ def _stage_tree(*, source: Path, destination: Path, revision: str) -> None:
15481552
if revision_file.exists() and revision_file.read_text(encoding="utf-8").strip() == revision:
15491553
return
15501554

1551-
if destination.exists():
1552-
shutil.rmtree(destination)
1555+
_remove_existing_path(destination)
15531556
shutil.copytree(source, destination)
15541557
revision_file.write_text(f"{revision}\n", encoding="utf-8")
15551558

@@ -1683,8 +1686,7 @@ def _install_managed_claude_plugin(
16831686
plugin_id = f"{plugin_name}@{marketplace_name}"
16841687

16851688
plugin_state_root = backend_home / ".claude" / "plugins"
1686-
if plugin_state_root.exists():
1687-
shutil.rmtree(plugin_state_root)
1689+
_remove_existing_path(plugin_state_root)
16881690

16891691
cli_env = dict(base_environment)
16901692
cli_env["HOME"] = str(backend_home)

tests/gauss_cli/test_autoformalize.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,57 @@ def fake_run(argv, *, error_prefix, env=None, cwd=None):
217217
assert calls[0][1]["HOME"] == str(backend_home)
218218

219219

220+
def test_install_managed_claude_plugin_replaces_symlinked_plugin_state(monkeypatch, tmp_path: Path):
221+
backend_home = tmp_path / "claude-home"
222+
marketplace_source = tmp_path / "lean4-skills"
223+
plugin_source = marketplace_source / "plugins" / "lean4"
224+
install_path = backend_home / ".claude" / "plugins" / "cache" / "lean4-skills" / "lean4" / "4.4.0"
225+
legacy_plugins_root = tmp_path / "legacy-plugins"
226+
(marketplace_source / ".claude-plugin").mkdir(parents=True)
227+
(plugin_source / ".claude-plugin").mkdir(parents=True)
228+
(marketplace_source / ".claude-plugin" / "marketplace.json").write_text(
229+
json.dumps({"name": "lean4-skills"}),
230+
encoding="utf-8",
231+
)
232+
(plugin_source / ".claude-plugin" / "plugin.json").write_text(
233+
json.dumps({"name": "lean4", "version": "4.4.0"}),
234+
encoding="utf-8",
235+
)
236+
legacy_plugins_root.mkdir(parents=True)
237+
plugin_state_root = backend_home / ".claude" / "plugins"
238+
plugin_state_root.parent.mkdir(parents=True, exist_ok=True)
239+
plugin_state_root.symlink_to(legacy_plugins_root, target_is_directory=True)
240+
241+
def fake_run(argv, *, error_prefix, env=None, cwd=None):
242+
stdout = ""
243+
if list(argv)[1:3] == ["plugin", "install"]:
244+
install_path.mkdir(parents=True, exist_ok=True)
245+
if list(argv)[-2:] == ["list", "--json"]:
246+
stdout = json.dumps(
247+
[
248+
{
249+
"id": "lean4@lean4-skills",
250+
"installPath": str(install_path),
251+
}
252+
]
253+
)
254+
return SimpleNamespace(stdout=stdout)
255+
256+
monkeypatch.setattr(autoformalize, "_run", fake_run)
257+
258+
result = autoformalize._install_managed_claude_plugin(
259+
claude_executable="/usr/bin/claude",
260+
backend_home=backend_home,
261+
base_environment={"PATH": "/usr/bin"},
262+
marketplace_source=marketplace_source,
263+
plugin_source=plugin_source,
264+
)
265+
266+
assert result == install_path.resolve()
267+
assert not plugin_state_root.is_symlink()
268+
assert install_path.exists()
269+
270+
220271
def test_resolve_uv_runner_uses_default_user_scoped_package(monkeypatch):
221272
monkeypatch.setattr(
222273
autoformalize.shutil,

0 commit comments

Comments
 (0)