diff --git a/lib/cli/src/crewai_cli/skills/main.py b/lib/cli/src/crewai_cli/skills/main.py index 0500caacdf..2e946229fe 100644 --- a/lib/cli/src/crewai_cli/skills/main.py +++ b/lib/cli/src/crewai_cli/skills/main.py @@ -327,7 +327,7 @@ def list_cached(self) -> None: meta.get("version") or "-", str(skill_dir), ) - except (json.JSONDecodeError, KeyError): + except (json.JSONDecodeError, UnicodeDecodeError, KeyError): console.print( f"[yellow]Warning: skipping malformed cache entry at {meta_file}[/yellow]" ) diff --git a/lib/cli/tests/skills/test_main.py b/lib/cli/tests/skills/test_main.py index db8fb5d887..b202a7434c 100644 --- a/lib/cli/tests/skills/test_main.py +++ b/lib/cli/tests/skills/test_main.py @@ -3,12 +3,14 @@ from __future__ import annotations import io +import json import os import tempfile import zipfile from contextlib import contextmanager from datetime import datetime, timedelta from pathlib import Path +from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch import pytest @@ -16,6 +18,10 @@ from crewai_cli.shared.token_manager import TokenManager +if TYPE_CHECKING: + from crewai_cli.skills.main import SkillCommand + + @contextmanager def in_temp_dir(): original = os.getcwd() @@ -257,6 +263,36 @@ def test_publish_proceeds_outside_git_repo(self, skill_command): class TestSkillListCached: + @pytest.mark.parametrize("metadata", [b"\xff\xfe", b"{invalid json"]) + def test_list_cached_skips_unreadable_metadata( + self, + skill_command: SkillCommand, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + metadata: bytes, + ) -> None: + cache_root = tmp_path / ".crewai" / "skills" / "acme" + broken = cache_root / "a-broken" + healthy = cache_root / "z-healthy" + broken.mkdir(parents=True) + healthy.mkdir() + (broken / ".crewai_meta.json").write_bytes(metadata) + (healthy / ".crewai_meta.json").write_text( + json.dumps({"org": "acme", "name": "z-healthy", "version": "1.0.0"}), + encoding="utf-8", + ) + + with ( + in_temp_dir(), + patch("crewai_cli.skills.main.Path.home", return_value=tmp_path), + ): + skill_command.list_cached() + + output = capsys.readouterr().out + assert "@acme/z-healthy" in output + assert "skipping malformed cache entry" in output + assert (broken / ".crewai_meta.json").read_bytes() == metadata + def test_list_cached_empty(self, skill_command, capsys): with in_temp_dir(): skill_command.list_cached() diff --git a/lib/crewai/src/crewai/skills/cache.py b/lib/crewai/src/crewai/skills/cache.py index 7e35fbc6b6..29af6a3597 100644 --- a/lib/crewai/src/crewai/skills/cache.py +++ b/lib/crewai/src/crewai/skills/cache.py @@ -142,7 +142,7 @@ def list_cached(self) -> list[SkillMetadata]: results.append( json.loads(meta_file.read_text(encoding="utf-8")) ) - except (json.JSONDecodeError, KeyError): + except (json.JSONDecodeError, UnicodeDecodeError, KeyError): _logger.debug( "Skipping malformed cache entry: %s", meta_file, diff --git a/lib/crewai/tests/skills/test_cache.py b/lib/crewai/tests/skills/test_cache.py index ba052094ad..5d2f5388b6 100644 --- a/lib/crewai/tests/skills/test_cache.py +++ b/lib/crewai/tests/skills/test_cache.py @@ -165,6 +165,20 @@ def test_list_cached(self, tmp_path: Path) -> None: names = {e["name"] for e in entries} assert names == {"skill-a", "skill-b", "skill-c"} + @pytest.mark.parametrize("metadata", [b"\xff\xfe", b"{invalid json"]) + def test_list_cached_skips_unreadable_metadata( + self, tmp_path: Path, metadata: bytes + ) -> None: + cache = SkillCacheManager(cache_root=tmp_path) + archive = _make_tar_gz({"SKILL.md": "content"}) + broken = cache.store("acme", "a-broken", "1.0.0", archive) + healthy = cache.store("acme", "z-healthy", "1.0.0", archive) + (broken / ".crewai_meta.json").write_bytes(metadata) + expected = json.loads((healthy / ".crewai_meta.json").read_text()) + + assert cache.list_cached() == [expected] + assert (broken / ".crewai_meta.json").read_bytes() == metadata + def test_invalidate_existing(self, tmp_path: Path) -> None: cache = SkillCacheManager(cache_root=tmp_path) archive = _make_tar_gz({"SKILL.md": "x"})