fix(cli): report the install mode that actually happened (#205)

On Windows without symlink privilege, install_skills falls back to copying
per agent directory. The fallback was tracked per call, so the warning
printed 12 times and the setup summary still claimed 'mode: symlink'.

Track the fallback module-wide: warn once, and report 'mode: copy'.

Verified on a Windows Server 2022 box (ACP 936, non-admin user).
This commit is contained in:
Arnav
2026-08-31 15:54:37 -07:00
committed by GitHub
parent c4b71c5f83
commit 3f29e56d0b
2 changed files with 39 additions and 5 deletions
+10 -5
View File
@@ -138,6 +138,11 @@ def _is_symlink_privilege_error(error: OSError) -> bool:
return os.name == "nt" and getattr(error, "winerror", None) == 1314
# Set when a symlink install falls back to copying, so the warning prints once
# and the setup summary can report the mode that actually happened.
_SYMLINK_FALLBACK = False
def install_skills(
target_dir: Path,
label: str,
@@ -149,9 +154,9 @@ def install_skills(
"""Install bundled skills into *target_dir*. Returns the count installed."""
src_root = skills_dir()
target_dir.mkdir(parents=True, exist_ok=True)
global _SYMLINK_FALLBACK
installed = 0
install_mode = mode
warned_copy_fallback = False
install_mode = "copy" if _SYMLINK_FALLBACK else mode
for skill in sorted(p for p in src_root.iterdir() if p.is_dir()):
name = skill.name
if subset is not None and name not in subset:
@@ -176,13 +181,13 @@ def install_skills(
if not _is_symlink_privilege_error(error):
raise
install_mode = "copy"
if not warned_copy_fallback:
if not _SYMLINK_FALLBACK:
print(
"Warning: symbolic links are unavailable; "
"copying skills instead. Use Developer Mode or "
"--copy to choose this explicitly."
)
warned_copy_fallback = True
_SYMLINK_FALLBACK = True
shutil.copytree(skill, link_path)
else: # copy
shutil.copytree(skill, link_path)
@@ -1010,7 +1015,7 @@ def cmd_setup(args: argparse.Namespace) -> int:
n = len(list_skills())
print("\n───────────────────────────────────────────────────")
print(" Setup complete!\n")
print(f" Skills installed: {n} (mode: {mode})")
print(f" Skills installed: {n} (mode: {'copy' if _SYMLINK_FALLBACK else mode})")
if vault_path:
print(f" Vault: {vault_path}")
print(f" Writing profile: {writing_profile.resolve()}")
+29
View File
@@ -102,3 +102,32 @@ def test_install_skills_keeps_unrelated_symlink_errors(
with pytest.raises(OSError, match="access denied"):
cli.install_skills(tmp_path / "target-skills", "test")
def test_symlink_fallback_reports_copy_mode_once(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
source_root = tmp_path / "bundled-skills"
for name in ("a-skill", "b-skill"):
skill = source_root / name
skill.mkdir(parents=True)
(skill / "SKILL.md").write_text(f"---\nname: {name}\n---\n", encoding="utf-8")
monkeypatch.setattr(cli, "skills_dir", lambda: source_root)
monkeypatch.setattr(cli, "_SYMLINK_FALLBACK", False)
def deny_symlink(self, target, target_is_directory=False): # noqa: ANN001
error = OSError(1314, "A required privilege is not held by the client")
error.winerror = 1314
raise error
monkeypatch.setattr(Path, "symlink_to", deny_symlink)
monkeypatch.setattr(cli, "_is_symlink_privilege_error", lambda error: True)
for target in ("agent-one", "agent-two"):
assert cli.install_skills(tmp_path / target, target, mode="symlink") == 2
out = capsys.readouterr().out
assert out.count("symbolic links are unavailable") == 1
assert cli._SYMLINK_FALLBACK is True
assert (tmp_path / "agent-two" / "a-skill" / "SKILL.md").is_file()