Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions amplifier_app_cli/commands/reset.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,13 @@

from __future__ import annotations

import shutil
import subprocess
from pathlib import Path

import click

from ..console import console
from ..utils.cache_management import rmtree_safe
from ..utils.error_format import escape_markup
from ..utils.uv_utils import remove_stale_uv_lock as _remove_stale_uv_lock
from .reset_interactive import ChecklistItem, run_checklist
Expand Down Expand Up @@ -302,7 +302,7 @@ def _remove_amplifier_dir(preserve: set[str], dry_run: bool = False) -> bool:
return True

try:
shutil.rmtree(amplifier_dir)
rmtree_safe(amplifier_dir)
console.print(" [green]Removed entire directory[/green]")
return True
except OSError as e:
Expand Down Expand Up @@ -339,7 +339,7 @@ def _remove_amplifier_dir(preserve: set[str], dry_run: bool = False) -> bool:
else:
# Standard removal (fallback or other items)
if item.is_dir():
shutil.rmtree(item)
rmtree_safe(item)
else:
item.unlink()
removed_count += 1
Expand Down
4 changes: 2 additions & 2 deletions amplifier_app_cli/lib/sources_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ async def install_to(self, target_dir: Path) -> None:
Raises:
InstallError: Git clone failed
"""
import shutil
from ..utils.cache_management import rmtree_safe

logger.info(f"Installing git repo to {target_dir}: {self.url}@{self.ref}")

Expand All @@ -225,7 +225,7 @@ async def install_to(self, target_dir: Path) -> None:
# Clean up partial install
if target_dir.exists():
logger.debug(f"Cleaning up partial install at {target_dir}")
shutil.rmtree(target_dir)
rmtree_safe(target_dir)
raise InstallError(
f"Failed to install {self.url}@{self.ref} to {target_dir}: {e}"
)
Expand Down
27 changes: 26 additions & 1 deletion amplifier_app_cli/utils/cache_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,37 @@
from __future__ import annotations

import logging
import os
import shutil
import stat
import sys
from pathlib import Path

logger = logging.getLogger(__name__)


def rmtree_safe(path: Path) -> None:
"""Remove a directory tree, handling read-only files on Windows.

Git pack files (.idx, .pack) are created read-only on Windows, causing
shutil.rmtree() to fail with [WinError 5] Access is denied. This wrapper
adds an error handler that strips the read-only bit before retrying.
"""
if sys.platform != "win32":
shutil.rmtree(path)
return

def _handle_readonly(func: object, path: str, exc: BaseException) -> None:
os.chmod(path, stat.S_IWRITE)
if callable(func):
func(path)

if sys.version_info >= (3, 12):
shutil.rmtree(path, onexc=_handle_readonly)
else:
shutil.rmtree(path, onerror=_handle_readonly) # type: ignore[call-arg]


def get_amplifier_dir() -> Path:
"""Return the ~/.amplifier directory path."""
return Path.home() / ".amplifier"
Expand Down Expand Up @@ -59,7 +84,7 @@ def clear_download_cache(dry_run: bool = False) -> tuple[int, bool]:
return (count, True)

# Remove entire cache directory
shutil.rmtree(cache_dir)
rmtree_safe(cache_dir)
logger.debug(f"Cleared {count} items from cache")

# Recreate empty cache directory
Expand Down
22 changes: 15 additions & 7 deletions amplifier_app_cli/utils/module_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,12 @@
import json
import logging
import re
import shutil
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path

from .cache_management import rmtree_safe

try:
import tomllib
except ImportError:
Expand Down Expand Up @@ -62,7 +63,10 @@ def get_bundle_name(cache_path: Path) -> str | None:
frontmatter = yaml.safe_load(parts[1])
if isinstance(frontmatter, dict):
bundle_section = frontmatter.get("bundle", {})
if isinstance(bundle_section, dict) and "name" in bundle_section:
if (
isinstance(bundle_section, dict)
and "name" in bundle_section
):
return bundle_section["name"]
except Exception as e:
logger.debug(f"Could not parse bundle.md frontmatter: {e}")
Expand Down Expand Up @@ -98,7 +102,9 @@ def get_module_info_from_pyproject(cache_path: Path) -> tuple[str | None, str |

try:
data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
entry_points = data.get("project", {}).get("entry-points", {}).get("amplifier.modules", {})
entry_points = (
data.get("project", {}).get("entry-points", {}).get("amplifier.modules", {})
)

if not entry_points:
return None, None
Expand Down Expand Up @@ -146,7 +152,9 @@ class CachedModuleInfo:
distinguishes between them (module_type="bundle" for bundles).
"""

module_id: str # Entry point key for modules (e.g., "tool-bash"), bundle name for bundles
module_id: (
str # Entry point key for modules (e.g., "tool-bash"), bundle name for bundles
)
module_type: str # tool, hook, provider, orchestrator, context, agent, bundle
ref: str
sha: str
Expand Down Expand Up @@ -408,7 +416,7 @@ def clear_module_cache(
try:
if progress_callback:
progress_callback(entry.name, "clearing")
shutil.rmtree(entry)
rmtree_safe(entry)
cleared += 1
except Exception as e:
logger.warning(f"Could not clear {entry}: {e}")
Expand All @@ -435,7 +443,7 @@ def clear_module_cache(
# Delete cache directory
try:
if module.cache_path.exists():
shutil.rmtree(module.cache_path)
rmtree_safe(module.cache_path)
cleared += 1
logger.debug(f"Cleared cache for {module.module_id}@{module.ref}")
except Exception as e:
Expand Down Expand Up @@ -470,7 +478,7 @@ async def update_module(
# pyproject.toml entry points (e.g., "provider-anthropic" not
# "amplifier-module-provider-anthropic")
if repo_name.startswith("amplifier-module-"):
module_id = repo_name[len("amplifier-module-"):]
module_id = repo_name[len("amplifier-module-") :]
else:
module_id = repo_name

Expand Down