From 48392ea865a224b9b02d2b344da6e4f78365d7cd Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 23 Mar 2026 11:24:53 -0500 Subject: [PATCH] fix: hash-check before deletion, track all files, fix overrides bug, update help text - remove_tracked_files: always compare SHA-256 hash before deleting, even when called with explicit files dict; skip modified files unless --force is set (was unconditionally deleting all tracked files) - finalize_setup: track ALL files from setup() (no agent-root filter); safe because removal now checks hashes - list_all_agents: track embedded versions in separate dict so overrides always reference the correct embedded version, not a catalog/project pack that overwrote the seen dict - --ai-skills help text: updated to say 'requires --ai or --agent' --- src/specify_cli/__init__.py | 2 +- src/specify_cli/agent_pack.py | 32 +++++++++++++++++++------------- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 5282b1980..ea6646334 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1725,7 +1725,7 @@ def init( skip_tls: bool = typer.Option(False, "--skip-tls", help="Skip SSL/TLS verification (not recommended)"), debug: bool = typer.Option(False, "--debug", help="Show verbose diagnostic output for network and extraction failures"), github_token: str = typer.Option(None, "--github-token", help="GitHub token to use for API requests (or set GH_TOKEN or GITHUB_TOKEN environment variable)"), - ai_skills: bool = typer.Option(False, "--ai-skills", help="Install Prompt.MD templates as agent skills (requires --ai)"), + ai_skills: bool = typer.Option(False, "--ai-skills", help="Install Prompt.MD templates as agent skills (requires --ai or --agent)"), offline: bool = typer.Option(False, "--offline", help="Use assets bundled in the specify-cli package instead of downloading from GitHub (no network access required). Bundled assets will become the default in v0.6.0 and this flag will be removed."), preset: str = typer.Option(None, "--preset", help="Install a preset during initialization (by preset ID)"), branch_numbering: str = typer.Option(None, "--branch-numbering", help="Branch numbering strategy: 'sequential' (001, 002, ...) or 'timestamp' (YYYYMMDD-HHMMSS)"), diff --git a/src/specify_cli/agent_pack.py b/src/specify_cli/agent_pack.py index 5e24c2ee2..2530ef454 100644 --- a/src/specify_cli/agent_pack.py +++ b/src/specify_cli/agent_pack.py @@ -346,21 +346,17 @@ class AgentBootstrap: the agent's directory tree for anything additional. All files returned by ``setup()`` are tracked — including shared - project infrastructure — so that teardown/switch can precisely - remove everything the agent installed. This is intentional: - ``remove_tracked_files()`` only deletes files whose SHA-256 - hash still matches the original, so user-modified files are - always preserved (unless ``--force`` is used). + project infrastructure — so that teardown/switch can detect + modifications. ``remove_tracked_files()`` compares SHA-256 + hashes before deleting and will only remove files whose hash + still matches, preserving any user-modified files (unless + ``--force`` is used). Args: agent_files: Files reported by :meth:`setup`. extension_files: Files created by extension registration. """ all_extension = list(extension_files or []) - # Track ALL files returned by setup(), not just those under the - # agent's directory tree. This is safe because teardown only - # removes files that are unmodified (hash check) and prompts - # for confirmation on modified files. all_agent: List[Path] = list(agent_files or []) # Scan the agent's directory tree for files created by later @@ -641,9 +637,13 @@ def remove_tracked_files( ) removed: List[str] = [] - for rel_path in entries: + for rel_path, original_hash in entries.items(): abs_path = project_path / rel_path if abs_path.is_file(): + if original_hash and _sha256(abs_path) != original_hash: + # File was modified since installation — skip unless forced + if not force: + continue abs_path.unlink() removed.append(rel_path) @@ -792,6 +792,11 @@ def list_all_agents(project_path: Optional[Path] = None) -> List[ResolvedPack]: """ seen: dict[str, ResolvedPack] = {} + # Track embedded versions separately so overrides can accurately + # reference what they replace, even after catalog/project/user + # packs have overwritten the seen dict entry. + embedded_versions: dict[str, str] = {} + # Start from lowest priority (embedded) so higher priorities overwrite for manifest in list_embedded_agents(): seen[manifest.id] = ResolvedPack( @@ -799,6 +804,7 @@ def list_all_agents(project_path: Optional[Path] = None) -> List[ResolvedPack]: source="embedded", path=manifest.pack_path or _embedded_agents_dir() / manifest.id, ) + embedded_versions[manifest.id] = manifest.version # Catalog cache catalog_dir = _catalog_agents_dir() @@ -808,7 +814,7 @@ def list_all_agents(project_path: Optional[Path] = None) -> List[ResolvedPack]: if child.is_dir() and mf.is_file(): try: m = AgentManifest.from_yaml(mf) - overrides = f"embedded v{seen[m.id].manifest.version}" if m.id in seen else None + overrides = f"embedded v{embedded_versions[m.id]}" if m.id in embedded_versions else None seen[m.id] = ResolvedPack(manifest=m, source="catalog", path=child, overrides=overrides) except AgentPackError: continue @@ -822,7 +828,7 @@ def list_all_agents(project_path: Optional[Path] = None) -> List[ResolvedPack]: if child.is_dir() and mf.is_file(): try: m = AgentManifest.from_yaml(mf) - overrides = f"embedded v{seen[m.id].manifest.version}" if m.id in seen else None + overrides = f"embedded v{embedded_versions[m.id]}" if m.id in embedded_versions else None seen[m.id] = ResolvedPack(manifest=m, source="project", path=child, overrides=overrides) except AgentPackError: continue @@ -835,7 +841,7 @@ def list_all_agents(project_path: Optional[Path] = None) -> List[ResolvedPack]: if child.is_dir() and mf.is_file(): try: m = AgentManifest.from_yaml(mf) - overrides = f"embedded v{seen[m.id].manifest.version}" if m.id in seen else None + overrides = f"embedded v{embedded_versions[m.id]}" if m.id in embedded_versions else None seen[m.id] = ResolvedPack(manifest=m, source="user", path=child, overrides=overrides) except AgentPackError: continue