tweek 0.1.0__py3-none-any.whl → 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. tweek/__init__.py +2 -2
  2. tweek/_keygen.py +53 -0
  3. tweek/audit.py +288 -0
  4. tweek/cli.py +5303 -2396
  5. tweek/cli_model.py +380 -0
  6. tweek/config/families.yaml +609 -0
  7. tweek/config/manager.py +42 -5
  8. tweek/config/patterns.yaml +1510 -8
  9. tweek/config/tiers.yaml +161 -11
  10. tweek/diagnostics.py +71 -2
  11. tweek/hooks/break_glass.py +163 -0
  12. tweek/hooks/feedback.py +223 -0
  13. tweek/hooks/overrides.py +531 -0
  14. tweek/hooks/post_tool_use.py +472 -0
  15. tweek/hooks/pre_tool_use.py +1024 -62
  16. tweek/integrations/openclaw.py +443 -0
  17. tweek/integrations/openclaw_server.py +385 -0
  18. tweek/licensing.py +14 -54
  19. tweek/logging/bundle.py +2 -2
  20. tweek/logging/security_log.py +56 -13
  21. tweek/mcp/approval.py +57 -16
  22. tweek/mcp/proxy.py +18 -0
  23. tweek/mcp/screening.py +5 -5
  24. tweek/mcp/server.py +4 -1
  25. tweek/memory/__init__.py +24 -0
  26. tweek/memory/queries.py +223 -0
  27. tweek/memory/safety.py +140 -0
  28. tweek/memory/schemas.py +80 -0
  29. tweek/memory/store.py +989 -0
  30. tweek/platform/__init__.py +4 -4
  31. tweek/plugins/__init__.py +40 -24
  32. tweek/plugins/base.py +1 -1
  33. tweek/plugins/detectors/__init__.py +3 -3
  34. tweek/plugins/detectors/{moltbot.py → openclaw.py} +30 -27
  35. tweek/plugins/git_discovery.py +16 -4
  36. tweek/plugins/git_registry.py +8 -2
  37. tweek/plugins/git_security.py +21 -9
  38. tweek/plugins/screening/__init__.py +10 -1
  39. tweek/plugins/screening/heuristic_scorer.py +477 -0
  40. tweek/plugins/screening/llm_reviewer.py +14 -6
  41. tweek/plugins/screening/local_model_reviewer.py +161 -0
  42. tweek/proxy/__init__.py +38 -37
  43. tweek/proxy/addon.py +22 -3
  44. tweek/proxy/interceptor.py +1 -0
  45. tweek/proxy/server.py +4 -2
  46. tweek/sandbox/__init__.py +11 -0
  47. tweek/sandbox/docker_bridge.py +143 -0
  48. tweek/sandbox/executor.py +9 -6
  49. tweek/sandbox/layers.py +97 -0
  50. tweek/sandbox/linux.py +1 -0
  51. tweek/sandbox/project.py +548 -0
  52. tweek/sandbox/registry.py +149 -0
  53. tweek/security/__init__.py +9 -0
  54. tweek/security/language.py +250 -0
  55. tweek/security/llm_reviewer.py +1146 -60
  56. tweek/security/local_model.py +331 -0
  57. tweek/security/local_reviewer.py +146 -0
  58. tweek/security/model_registry.py +371 -0
  59. tweek/security/rate_limiter.py +11 -6
  60. tweek/security/secret_scanner.py +70 -4
  61. tweek/security/session_analyzer.py +26 -2
  62. tweek/skill_template/SKILL.md +200 -0
  63. tweek/skill_template/__init__.py +0 -0
  64. tweek/skill_template/cli-reference.md +331 -0
  65. tweek/skill_template/overrides-reference.md +184 -0
  66. tweek/skill_template/scripts/__init__.py +0 -0
  67. tweek/skill_template/scripts/check_installed.py +170 -0
  68. tweek/skills/__init__.py +38 -0
  69. tweek/skills/config.py +150 -0
  70. tweek/skills/fingerprints.py +198 -0
  71. tweek/skills/guard.py +293 -0
  72. tweek/skills/isolation.py +469 -0
  73. tweek/skills/scanner.py +715 -0
  74. tweek/vault/__init__.py +0 -1
  75. tweek/vault/cross_platform.py +12 -1
  76. tweek/vault/keychain.py +87 -29
  77. tweek-0.2.0.dist-info/METADATA +281 -0
  78. tweek-0.2.0.dist-info/RECORD +121 -0
  79. {tweek-0.1.0.dist-info → tweek-0.2.0.dist-info}/entry_points.txt +8 -1
  80. {tweek-0.1.0.dist-info → tweek-0.2.0.dist-info}/licenses/LICENSE +80 -0
  81. tweek/integrations/moltbot.py +0 -243
  82. tweek-0.1.0.dist-info/METADATA +0 -335
  83. tweek-0.1.0.dist-info/RECORD +0 -85
  84. {tweek-0.1.0.dist-info → tweek-0.2.0.dist-info}/WHEEL +0 -0
  85. {tweek-0.1.0.dist-info → tweek-0.2.0.dist-info}/top_level.txt +0 -0
File without changes
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Tweek Installation Status Checker
4
+
5
+ Checks whether tweek is installed and operational on this system.
6
+ Returns structured JSON for deterministic parsing by AI assistants.
7
+
8
+ Usage:
9
+ python3 check_installed.py # Check status (read-only)
10
+ python3 check_installed.py --decline # Record that user declined install
11
+ python3 check_installed.py --reset # Clear the decline preference
12
+
13
+ This script uses ONLY Python stdlib — no tweek imports, no pip imports.
14
+ """
15
+
16
+ import json
17
+ import os
18
+ import shutil
19
+ import sys
20
+ from datetime import datetime, timezone
21
+ from pathlib import Path
22
+
23
+
24
+ def _preferences_path():
25
+ """Path to the preferences file stored alongside this script."""
26
+ return Path(__file__).resolve().parent.parent / ".preferences.json"
27
+
28
+
29
+ def _load_preferences():
30
+ """Load preferences from disk. Returns empty dict if missing/corrupt."""
31
+ path = _preferences_path()
32
+ if not path.is_file():
33
+ return {}
34
+ try:
35
+ with open(path) as f:
36
+ return json.load(f)
37
+ except (json.JSONDecodeError, IOError):
38
+ return {}
39
+
40
+
41
+ def _save_preferences(prefs):
42
+ """Write preferences to disk."""
43
+ path = _preferences_path()
44
+ with open(path, "w") as f:
45
+ json.dump(prefs, f, indent=2)
46
+
47
+
48
+ def decline_install():
49
+ """Record that the user declined tweek installation."""
50
+ prefs = _load_preferences()
51
+ prefs["install_declined"] = True
52
+ prefs["declined_at"] = datetime.now(timezone.utc).isoformat()
53
+ _save_preferences(prefs)
54
+ print(json.dumps({"action": "decline_saved", "path": str(_preferences_path())}))
55
+
56
+
57
+ def reset_preference():
58
+ """Clear the decline preference so the offer can be made again."""
59
+ prefs = _load_preferences()
60
+ prefs.pop("install_declined", None)
61
+ prefs.pop("declined_at", None)
62
+ _save_preferences(prefs)
63
+ print(json.dumps({"action": "preference_reset", "path": str(_preferences_path())}))
64
+
65
+
66
+ def check_installation():
67
+ """Check tweek installation status across multiple signals."""
68
+
69
+ result = {
70
+ "tweek_in_path": False,
71
+ "tweek_path": None,
72
+ "tweek_data_dir": False,
73
+ "hooks_registered": False,
74
+ "hooks_detail": {
75
+ "pre_tool_use": False,
76
+ "post_tool_use": False,
77
+ },
78
+ "install_declined": False,
79
+ "declined_at": None,
80
+ "pip_available": False,
81
+ "pipx_available": False,
82
+ "python_version": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
83
+ "status": "not_installed",
84
+ "install_command": None,
85
+ }
86
+
87
+ # Check user preferences
88
+ prefs = _load_preferences()
89
+ if prefs.get("install_declined"):
90
+ result["install_declined"] = True
91
+ result["declined_at"] = prefs.get("declined_at")
92
+
93
+ # Check 1: Is tweek in PATH?
94
+ tweek_path = shutil.which("tweek")
95
+ if tweek_path:
96
+ result["tweek_in_path"] = True
97
+ result["tweek_path"] = tweek_path
98
+
99
+ # Check 2: Does ~/.tweek/ data directory exist?
100
+ tweek_dir = Path.home() / ".tweek"
101
+ if tweek_dir.is_dir():
102
+ result["tweek_data_dir"] = True
103
+
104
+ # Check 3: Are hooks registered in ~/.claude/settings.json?
105
+ for settings_path in [
106
+ Path.home() / ".claude" / "settings.json",
107
+ Path.cwd() / ".claude" / "settings.json",
108
+ ]:
109
+ if settings_path.is_file():
110
+ try:
111
+ with open(settings_path) as f:
112
+ settings = json.load(f)
113
+ hooks = settings.get("hooks", {})
114
+
115
+ pre_hooks = hooks.get("PreToolUse", [])
116
+ for hook_group in pre_hooks:
117
+ for hook in hook_group.get("hooks", []):
118
+ cmd = hook.get("command", "")
119
+ if "pre_tool_use.py" in cmd and "tweek" in cmd:
120
+ result["hooks_detail"]["pre_tool_use"] = True
121
+
122
+ post_hooks = hooks.get("PostToolUse", [])
123
+ for hook_group in post_hooks:
124
+ for hook in hook_group.get("hooks", []):
125
+ cmd = hook.get("command", "")
126
+ if "post_tool_use.py" in cmd and "tweek" in cmd:
127
+ result["hooks_detail"]["post_tool_use"] = True
128
+
129
+ if result["hooks_detail"]["pre_tool_use"] or result["hooks_detail"]["post_tool_use"]:
130
+ result["hooks_registered"] = True
131
+ break
132
+
133
+ except (json.JSONDecodeError, IOError, KeyError):
134
+ pass
135
+
136
+ # Check 4: Package manager availability
137
+ if shutil.which("pip") or shutil.which("pip3"):
138
+ result["pip_available"] = True
139
+ if shutil.which("pipx"):
140
+ result["pipx_available"] = True
141
+
142
+ # Determine overall status
143
+ if result["tweek_in_path"] and result["hooks_registered"]:
144
+ result["status"] = "fully_operational"
145
+ elif result["tweek_in_path"] and not result["hooks_registered"]:
146
+ result["status"] = "installed_no_hooks"
147
+ result["install_command"] = "tweek install"
148
+ elif not result["tweek_in_path"] and result["hooks_registered"]:
149
+ result["status"] = "hooks_only"
150
+ result["install_command"] = "pip install tweek"
151
+ else:
152
+ result["status"] = "not_installed"
153
+ if result["pipx_available"]:
154
+ result["install_command"] = "pipx install tweek && tweek install"
155
+ elif result["pip_available"]:
156
+ result["install_command"] = "pip install tweek && tweek install"
157
+ else:
158
+ result["install_command"] = "python3 -m pip install tweek && tweek install"
159
+
160
+ return result
161
+
162
+
163
+ if __name__ == "__main__":
164
+ if "--decline" in sys.argv:
165
+ decline_install()
166
+ elif "--reset" in sys.argv:
167
+ reset_preference()
168
+ else:
169
+ status = check_installation()
170
+ print(json.dumps(status, indent=2))
@@ -0,0 +1,38 @@
1
+ """
2
+ Tweek Skill Isolation Chamber
3
+
4
+ Security quarantine layer for Claude Code skills. Skills are placed in an
5
+ isolation chamber, scanned by a multi-layer pipeline, and either auto-installed
6
+ or jailed based on scan results.
7
+
8
+ Components:
9
+ - config: IsolationConfig dataclass and loader
10
+ - scanner: 7-layer security scanning pipeline
11
+ - isolation: Chamber lifecycle management
12
+ - guard: Self-protection against AI circumvention
13
+ - fingerprints: SHA-256 fingerprint cache for git-arrived skill detection
14
+ """
15
+
16
+ from pathlib import Path
17
+
18
+ # Directory constants
19
+ TWEEK_HOME = Path.home() / ".tweek"
20
+ SKILLS_DIR = TWEEK_HOME / "skills"
21
+ CHAMBER_DIR = SKILLS_DIR / "chamber"
22
+ JAIL_DIR = SKILLS_DIR / "jail"
23
+ REPORTS_DIR = SKILLS_DIR / "reports"
24
+
25
+ # Claude's actual skill install locations
26
+ CLAUDE_GLOBAL_SKILLS = Path.home() / ".claude" / "skills"
27
+
28
+
29
+ def get_claude_project_skills(working_dir: Path = None) -> Path:
30
+ """Get the project-level Claude skills directory."""
31
+ base = working_dir or Path.cwd()
32
+ return base / ".claude" / "skills"
33
+
34
+
35
+ def ensure_directories():
36
+ """Create all required isolation chamber directories."""
37
+ for d in (CHAMBER_DIR, JAIL_DIR, REPORTS_DIR):
38
+ d.mkdir(parents=True, exist_ok=True)
tweek/skills/config.py ADDED
@@ -0,0 +1,150 @@
1
+ """
2
+ Isolation Chamber Configuration
3
+
4
+ Defines the IsolationConfig dataclass and loading logic.
5
+ Configuration is stored in ~/.tweek/config.yaml under the 'isolation_chamber' key.
6
+ """
7
+
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+ from typing import Dict, List, Any, Optional
11
+
12
+ import yaml
13
+
14
+
15
+ # Default allowed file extensions for skills
16
+ DEFAULT_ALLOWED_EXTENSIONS = [
17
+ ".md", ".py", ".json", ".yaml", ".yml", ".txt", ".sh", ".toml",
18
+ ]
19
+
20
+ # Extensions that should never appear in a skill
21
+ DEFAULT_BLOCKED_EXTENSIONS = [
22
+ ".exe", ".dll", ".so", ".dylib", ".bin", ".msi", ".dmg",
23
+ ".com", ".bat", ".cmd", ".scr", ".pif",
24
+ ]
25
+
26
+
27
+ @dataclass
28
+ class IsolationConfig:
29
+ """Configuration for the Skill Isolation Chamber."""
30
+
31
+ enabled: bool = True
32
+ mode: str = "auto" # "auto" or "manual"
33
+ scan_timeout_seconds: float = 30.0
34
+ llm_review_enabled: bool = True
35
+ max_skill_size_bytes: int = 1_048_576 # 1MB
36
+ max_file_count: int = 50
37
+ max_directory_depth: int = 3
38
+ allowed_file_extensions: List[str] = field(
39
+ default_factory=lambda: list(DEFAULT_ALLOWED_EXTENSIONS)
40
+ )
41
+ blocked_file_extensions: List[str] = field(
42
+ default_factory=lambda: list(DEFAULT_BLOCKED_EXTENSIONS)
43
+ )
44
+ trusted_sources: List[str] = field(default_factory=list)
45
+
46
+ # Verdict thresholds
47
+ fail_on_critical: bool = True
48
+ fail_on_high_count: int = 3
49
+ review_on_high_count: int = 1
50
+
51
+ # Notifications
52
+ notify_on_jail: bool = True
53
+
54
+ def validate(self) -> List[str]:
55
+ """Validate configuration values. Returns list of issues."""
56
+ issues = []
57
+ if self.mode not in ("auto", "manual"):
58
+ issues.append(f"Invalid mode '{self.mode}': must be 'auto' or 'manual'")
59
+ if self.scan_timeout_seconds <= 0:
60
+ issues.append("scan_timeout_seconds must be positive")
61
+ if self.max_skill_size_bytes <= 0:
62
+ issues.append("max_skill_size_bytes must be positive")
63
+ if self.fail_on_high_count < 1:
64
+ issues.append("fail_on_high_count must be >= 1")
65
+ if self.review_on_high_count < 1:
66
+ issues.append("review_on_high_count must be >= 1")
67
+ return issues
68
+
69
+
70
+ def load_isolation_config(config_path: Optional[Path] = None) -> IsolationConfig:
71
+ """
72
+ Load isolation chamber configuration from ~/.tweek/config.yaml.
73
+
74
+ Falls back to defaults if file is missing or section is absent.
75
+
76
+ Args:
77
+ config_path: Override config file path (for testing)
78
+
79
+ Returns:
80
+ IsolationConfig with loaded or default values
81
+ """
82
+ if config_path is None:
83
+ config_path = Path.home() / ".tweek" / "config.yaml"
84
+
85
+ if not config_path.exists():
86
+ return IsolationConfig()
87
+
88
+ try:
89
+ with open(config_path) as f:
90
+ full_config = yaml.safe_load(f) or {}
91
+ except Exception:
92
+ return IsolationConfig()
93
+
94
+ section = full_config.get("isolation_chamber", {})
95
+ if not section or not isinstance(section, dict):
96
+ return IsolationConfig()
97
+
98
+ return _config_from_dict(section)
99
+
100
+
101
+ def _config_from_dict(data: Dict[str, Any]) -> IsolationConfig:
102
+ """Build IsolationConfig from a dict, ignoring unknown keys."""
103
+ known_fields = {f.name for f in IsolationConfig.__dataclass_fields__.values()}
104
+ filtered = {k: v for k, v in data.items() if k in known_fields}
105
+ return IsolationConfig(**filtered)
106
+
107
+
108
+ def save_isolation_config(
109
+ config: IsolationConfig,
110
+ config_path: Optional[Path] = None,
111
+ ) -> None:
112
+ """
113
+ Save isolation chamber configuration to ~/.tweek/config.yaml.
114
+
115
+ Preserves other sections in the config file.
116
+
117
+ Args:
118
+ config: The configuration to save
119
+ config_path: Override config file path (for testing)
120
+ """
121
+ if config_path is None:
122
+ config_path = Path.home() / ".tweek" / "config.yaml"
123
+
124
+ config_path.parent.mkdir(parents=True, exist_ok=True)
125
+
126
+ # Load existing config to preserve other sections
127
+ full_config = {}
128
+ if config_path.exists():
129
+ try:
130
+ with open(config_path) as f:
131
+ full_config = yaml.safe_load(f) or {}
132
+ except Exception:
133
+ full_config = {}
134
+
135
+ # Update just the isolation_chamber section
136
+ section = {}
137
+ for field_name in IsolationConfig.__dataclass_fields__:
138
+ value = getattr(config, field_name)
139
+ default_value = getattr(IsolationConfig(), field_name)
140
+ # Only write non-default values to keep config clean
141
+ if value != default_value:
142
+ section[field_name] = value
143
+
144
+ if section:
145
+ full_config["isolation_chamber"] = section
146
+ elif "isolation_chamber" in full_config:
147
+ del full_config["isolation_chamber"]
148
+
149
+ with open(config_path, "w") as f:
150
+ yaml.dump(full_config, f, default_flow_style=False, sort_keys=False)
@@ -0,0 +1,198 @@
1
+ """
2
+ Tweek Skill Fingerprint Cache
3
+
4
+ Tracks SHA-256 hashes of known-approved SKILL.md files to detect
5
+ new or modified skills that arrive via git pull, clone, or branch switch.
6
+
7
+ When the PreToolUse hook encounters a SKILL.md with no fingerprint or a
8
+ changed hash, it routes the skill through the isolation chamber.
9
+ """
10
+
11
+ import hashlib
12
+ import json
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+ from typing import Dict, List, Optional, Tuple
16
+
17
+ from tweek.skills import SKILLS_DIR, get_claude_project_skills
18
+
19
+
20
+ FINGERPRINT_PATH = SKILLS_DIR / "fingerprints.json"
21
+
22
+
23
+ class SkillFingerprints:
24
+ """
25
+ SHA-256 fingerprint cache for known-approved skills.
26
+
27
+ Format:
28
+ {
29
+ "schema_version": 1,
30
+ "skills": {
31
+ "/abs/path/.claude/skills/my-skill/SKILL.md": {
32
+ "sha256": "abc123...",
33
+ "scanned_at": "2026-02-01T15:30:00Z",
34
+ "verdict": "pass",
35
+ "report_path": "..."
36
+ }
37
+ }
38
+ }
39
+ """
40
+
41
+ def __init__(self, cache_path: Optional[Path] = None):
42
+ self.cache_path = cache_path or FINGERPRINT_PATH
43
+ self._data = self._load()
44
+
45
+ def _load(self) -> Dict:
46
+ """Load fingerprints from disk."""
47
+ if not self.cache_path.exists():
48
+ return {"schema_version": 1, "skills": {}}
49
+ try:
50
+ data = json.loads(self.cache_path.read_text())
51
+ if not isinstance(data, dict) or "skills" not in data:
52
+ return {"schema_version": 1, "skills": {}}
53
+ return data
54
+ except (json.JSONDecodeError, IOError):
55
+ return {"schema_version": 1, "skills": {}}
56
+
57
+ def _save(self) -> None:
58
+ """Persist fingerprints to disk."""
59
+ self.cache_path.parent.mkdir(parents=True, exist_ok=True)
60
+ self.cache_path.write_text(json.dumps(self._data, indent=2))
61
+
62
+ @staticmethod
63
+ def _hash_file(file_path: Path) -> str:
64
+ """Compute SHA-256 hash of a file."""
65
+ h = hashlib.sha256()
66
+ with open(file_path, "rb") as f:
67
+ for chunk in iter(lambda: f.read(8192), b""):
68
+ h.update(chunk)
69
+ return h.hexdigest()
70
+
71
+ def is_known(self, skill_md_path: Path) -> bool:
72
+ """
73
+ Check if a SKILL.md file is known and unchanged.
74
+
75
+ Args:
76
+ skill_md_path: Absolute path to a SKILL.md file
77
+
78
+ Returns:
79
+ True if the file hash matches the stored fingerprint
80
+ """
81
+ key = str(skill_md_path.resolve())
82
+ entry = self._data["skills"].get(key)
83
+ if not entry:
84
+ return False
85
+
86
+ try:
87
+ current_hash = self._hash_file(skill_md_path)
88
+ except (IOError, OSError):
89
+ return False
90
+
91
+ return entry.get("sha256") == current_hash
92
+
93
+ def register(
94
+ self,
95
+ skill_md_path: Path,
96
+ verdict: str = "pass",
97
+ report_path: Optional[str] = None,
98
+ ) -> None:
99
+ """
100
+ Register a SKILL.md file as known-approved.
101
+
102
+ Args:
103
+ skill_md_path: Absolute path to the SKILL.md
104
+ verdict: The scan verdict that approved it
105
+ report_path: Path to the scan report
106
+ """
107
+ key = str(skill_md_path.resolve())
108
+ try:
109
+ current_hash = self._hash_file(skill_md_path)
110
+ except (IOError, OSError):
111
+ return
112
+
113
+ self._data["skills"][key] = {
114
+ "sha256": current_hash,
115
+ "scanned_at": datetime.now(timezone.utc).isoformat(),
116
+ "verdict": verdict,
117
+ "report_path": report_path or "",
118
+ }
119
+ self._save()
120
+
121
+ def remove(self, skill_md_path: Path) -> None:
122
+ """Remove a fingerprint entry."""
123
+ key = str(skill_md_path.resolve())
124
+ if key in self._data["skills"]:
125
+ del self._data["skills"][key]
126
+ self._save()
127
+
128
+ def check_project_skills(
129
+ self, working_dir: Optional[Path] = None
130
+ ) -> List[Tuple[Path, str]]:
131
+ """
132
+ Check all SKILL.md files in a project's .claude/skills/ directory.
133
+
134
+ Returns a list of (path, status) tuples where status is:
135
+ - "known": file hash matches fingerprint
136
+ - "new": file has no fingerprint
137
+ - "modified": file hash differs from fingerprint
138
+
139
+ Args:
140
+ working_dir: Project working directory (defaults to cwd)
141
+
142
+ Returns:
143
+ List of (skill_md_path, status) tuples for unknown/modified skills
144
+ """
145
+ skills_dir = get_claude_project_skills(working_dir)
146
+ if not skills_dir.exists():
147
+ return []
148
+
149
+ results = []
150
+ for skill_md in skills_dir.rglob("SKILL.md"):
151
+ resolved = skill_md.resolve()
152
+ key = str(resolved)
153
+ entry = self._data["skills"].get(key)
154
+
155
+ if not entry:
156
+ results.append((skill_md, "new"))
157
+ else:
158
+ try:
159
+ current_hash = self._hash_file(skill_md)
160
+ if current_hash != entry.get("sha256"):
161
+ results.append((skill_md, "modified"))
162
+ # "known" skills are not returned (nothing to do)
163
+ except (IOError, OSError):
164
+ results.append((skill_md, "new"))
165
+
166
+ return results
167
+
168
+ def cleanup_stale(self) -> int:
169
+ """
170
+ Remove fingerprints for files that no longer exist.
171
+
172
+ Returns:
173
+ Number of stale entries removed
174
+ """
175
+ stale_keys = []
176
+ for key in self._data["skills"]:
177
+ if not Path(key).exists():
178
+ stale_keys.append(key)
179
+
180
+ for key in stale_keys:
181
+ del self._data["skills"][key]
182
+
183
+ if stale_keys:
184
+ self._save()
185
+
186
+ return len(stale_keys)
187
+
188
+
189
+ # Module-level singleton
190
+ _fingerprints: Optional[SkillFingerprints] = None
191
+
192
+
193
+ def get_fingerprints() -> SkillFingerprints:
194
+ """Get the singleton SkillFingerprints instance."""
195
+ global _fingerprints
196
+ if _fingerprints is None:
197
+ _fingerprints = SkillFingerprints()
198
+ return _fingerprints