11#!/usr/bin/env python3
22import argparse
3+ import difflib
4+ import hashlib
35import subprocess
6+ import urllib .request
7+ import urllib .error
48from datetime import datetime , timezone
59from pathlib import Path
610
@@ -17,9 +21,40 @@ def get_head_sha(repo_url: str) -> str:
1721 return out .split ()[0 ]
1822
1923
24+ def fetch_skill_content (repo_url : str , commit : str , skill_name : str ) -> str | None :
25+ """Fetch SKILL.md content from raw.githubusercontent.com. Returns None on failure."""
26+ owner_repo = repo_url .removeprefix ('https://github.com/' )
27+ raw_url = f'https://raw.githubusercontent.com/{ owner_repo } /{ commit } /{ skill_name } /SKILL.md'
28+ try :
29+ with urllib .request .urlopen (raw_url , timeout = 15 ) as resp :
30+ return resp .read ().decode ('utf-8' )
31+ except urllib .error .HTTPError as e :
32+ print (f' WARNING: HTTP { e .code } fetching { raw_url } ' )
33+ return None
34+ except Exception as e :
35+ print (f' WARNING: Failed to fetch { raw_url } : { e } ' )
36+ return None
37+
38+
39+ def sha256_of (content : str ) -> str :
40+ return hashlib .sha256 (content .encode ('utf-8' )).hexdigest ()
41+
42+
43+ def show_diff (skill_id : str , old_content : str | None , new_content : str | None ) -> None :
44+ old_lines = (old_content or '(content unavailable)\n ' ).splitlines (keepends = True )
45+ new_lines = (new_content or '(content unavailable)\n ' ).splitlines (keepends = True )
46+ diff = list (difflib .unified_diff (old_lines , new_lines , fromfile = f'{ skill_id } (old)' , tofile = f'{ skill_id } (new)' ))
47+ if diff :
48+ print (f'\n --- Diff for { skill_id } ---' )
49+ print ('' .join (diff ), end = '' )
50+ else :
51+ print (f' (no content diff for { skill_id } )' )
52+
53+
2054def main () -> int :
2155 parser = argparse .ArgumentParser (description = 'Refresh pinned commits in all critic manifests.' )
2256 parser .add_argument ('--check' , action = 'store_true' , help = 'Fail if updates are needed without writing files.' )
57+ parser .add_argument ('--approve' , action = 'store_true' , help = 'Apply pin and content hash updates (writes manifests).' )
2358 args = parser .parse_args ()
2459
2560 changes = []
@@ -30,15 +65,40 @@ def main() -> int:
3065 skills = data .get ('skills' , [])
3166 checked += len (skills )
3267 local_changes = []
68+
3369 for s in skills :
3470 latest = get_head_sha (s ['repo_url' ])
3571 current = s .get ('pinned_commit' , '' )
36- if latest != current :
72+ if latest == current :
73+ continue
74+
75+ skill_name = s ['id' ].split ('/' )[- 1 ]
76+ print (f'\n Skill { s ["id" ]} : { current or "-" } -> { latest } ' )
77+
78+ old_content = fetch_skill_content (s ['repo_url' ], current , skill_name ) if current else None
79+ new_content = fetch_skill_content (s ['repo_url' ], latest , skill_name )
80+
81+ if not args .check :
82+ show_diff (s ['id' ], old_content , new_content )
83+
84+ if new_content is not None :
85+ new_hash = sha256_of (new_content )
86+ print (f' content_sha256 (new): { new_hash } ' )
87+ else :
88+ new_hash = None
89+ print (f' WARNING: Could not fetch new content — content_sha256 will be unverified.' )
90+
91+ local_changes .append ((s ['id' ], current , latest , new_hash ))
92+ changes .append ((manifest , s ['id' ], current , latest , new_hash ))
93+
94+ if args .approve :
3795 s ['pinned_commit' ] = latest
38- local_changes .append ((s ['id' ], current , latest ))
39- changes .append ((manifest , s ['id' ], current , latest ))
96+ if new_hash is not None :
97+ s ['content_sha256' ] = new_hash
98+ else :
99+ s .pop ('content_sha256' , None )
40100
41- if local_changes and not args .check :
101+ if local_changes and args .approve :
42102 data ['generated_at' ] = datetime .now (timezone .utc ).replace (microsecond = 0 ).isoformat ().replace ('+00:00' , 'Z' )
43103 manifest .write_text (yaml .safe_dump (data , sort_keys = False ), encoding = 'utf-8' )
44104
@@ -52,10 +112,11 @@ def main() -> int:
52112 ]
53113
54114 if changes :
55- lines .append ('| Manifest | Skill | Old | New |' )
56- lines .append ('|---|---|---|---|' )
57- for m , sid , old , new in changes :
58- lines .append (f'| `{ m .relative_to (ROOT )} ` | `{ sid } ` | `{ old or "-" } ` | `{ new } ` |' )
115+ lines .append ('| Manifest | Skill | Old | New | Hash Verified |' )
116+ lines .append ('|---|---|---|---|---|' )
117+ for m , sid , old , new , new_hash in changes :
118+ verified = 'yes' if new_hash else '# UNVERIFIED'
119+ lines .append (f'| `{ m .relative_to (ROOT )} ` | `{ sid } ` | `{ old or "-" } ` | `{ new } ` | { verified } |' )
59120 else :
60121 lines .append ('No pin changes detected.' )
61122
@@ -66,6 +127,9 @@ def main() -> int:
66127 print (f'Manifest updates required for { len (changes )} skill entries.' )
67128 return 1
68129
130+ if changes and not args .approve and not args .check :
131+ print ('\n Run with --approve to apply these changes.' )
132+
69133 print (f'Checked { checked } skill entries across { len (MANIFESTS )} manifests.' )
70134 print (f'Wrote report: { REPORT } ' )
71135 return 0
0 commit comments