1111 rpr 42 --dry-run # Print review to terminal, don't post
1212 rpr 42 --approve # Post review + approve the PR
1313 rpr 42 --request-changes # Post review + request changes
14+ rpr update # Self-update to latest version
1415"""
1516
1617# Postpones evaluation of annotations so PEP 604 syntax (e.g. `str | None`)
2829import urllib .request
2930from pathlib import Path
3031
32+
33+ def _get_version () -> str :
34+ """Return the package version, with a fallback for npm-installed layout."""
35+ try :
36+ from rpr import __version__
37+ return __version__
38+ except ImportError :
39+ # npm shim runs cli.py directly; __init__.py sits alongside it in lib/
40+ init_file = Path (__file__ ).parent / "__init__.py"
41+ if init_file .exists ():
42+ for line in init_file .read_text ().splitlines ():
43+ if line .startswith ("__version__" ):
44+ return line .split ("=" , 1 )[1 ].strip ().strip ('"' ).strip ("'" )
45+ return "unknown"
46+
47+
3148# ---------------------------------------------------------------------------
3249# Config
3350# ---------------------------------------------------------------------------
@@ -106,7 +123,7 @@ def check_for_update() -> str | None:
106123 Results are cached for UPDATE_CHECK_INTERVAL seconds so most runs
107124 hit a local file instead of the network.
108125 """
109- from rpr import __version__
126+ __version__ = _get_version ()
110127
111128 cache_path = _update_cache_path ()
112129 latest = None
@@ -152,7 +169,7 @@ def check_for_update() -> str | None:
152169
153170def _update_msg (current : str , latest : str ) -> str :
154171 line1 = f" Update available: { current } → { latest } "
155- line2 = " pip install -U rpr "
172+ line2 = " Run: rpr update "
156173 width = max (len (line1 ), len (line2 ))
157174 return (
158175 f"\n ╭{ '─' * width } ╮\n "
@@ -162,6 +179,88 @@ def _update_msg(current: str, latest: str) -> str:
162179 )
163180
164181
182+ def _detect_update_command () -> list [str ]:
183+ """Detect how rpr was installed and return the appropriate update command."""
184+ # Check pipx
185+ try :
186+ result = subprocess .run (
187+ ["pipx" , "list" , "--short" ],
188+ capture_output = True , text = True ,
189+ )
190+ if result .returncode == 0 and "rpr " in result .stdout :
191+ return ["pipx" , "upgrade" , "rpr" ]
192+ except (FileNotFoundError , PermissionError ):
193+ pass
194+
195+ # Check brew
196+ try :
197+ result = subprocess .run (
198+ ["brew" , "list" , "rpr" ],
199+ capture_output = True , text = True ,
200+ )
201+ if result .returncode == 0 :
202+ return ["brew" , "upgrade" , "rpr" ]
203+ except (FileNotFoundError , PermissionError ):
204+ pass
205+
206+ # Check npm (script lives inside node_modules or npm prefix)
207+ script_path = str (Path (sys .argv [0 ]).resolve ())
208+ if "node_modules" in script_path or "/npm/" in script_path :
209+ return ["npm" , "update" , "-g" , "@dedev-llc/rpr" ]
210+
211+ # Default to pip
212+ return [sys .executable , "-m" , "pip" , "install" , "-U" , "rpr" ]
213+
214+
215+ def _fetch_latest_version () -> str :
216+ """Fetch latest version string from PyPI. Raises on failure."""
217+ req = urllib .request .Request (
218+ "https://pypi.org/pypi/rpr/json" ,
219+ headers = {"Accept" : "application/json" },
220+ )
221+ with urllib .request .urlopen (req , timeout = 10 ) as resp :
222+ data = json .loads (resp .read ().decode ("utf-8" ))
223+ return data ["info" ]["version" ]
224+
225+
226+ def handle_update ():
227+ """Self-update rpr to the latest version."""
228+ __version__ = _get_version ()
229+
230+ print (f"rpr v{ __version__ } " , file = sys .stderr )
231+ print ("Checking for updates..." , file = sys .stderr )
232+
233+ try :
234+ latest = _fetch_latest_version ()
235+ except Exception as e :
236+ print (f"❌ Failed to check PyPI: { e } " , file = sys .stderr )
237+ sys .exit (1 )
238+
239+ if _version_tuple (latest ) <= _version_tuple (__version__ ):
240+ print (f"✅ Already up to date." , file = sys .stderr )
241+ return
242+
243+ print (f"Updating: { __version__ } → { latest } " , file = sys .stderr )
244+
245+ cmd = _detect_update_command ()
246+ print (f"Running: { ' ' .join (cmd )} \n " , file = sys .stderr )
247+
248+ result = subprocess .run (cmd )
249+ if result .returncode != 0 :
250+ print (f"\n ❌ Update failed (exit code { result .returncode } )" , file = sys .stderr )
251+ sys .exit (1 )
252+
253+ print (f"\n ✅ Updated to v{ latest } " , file = sys .stderr )
254+
255+ # Clear the update cache so the notification doesn't linger
256+ try :
257+ cache = _update_cache_path ()
258+ if cache .exists ():
259+ cache .unlink ()
260+ except OSError :
261+ pass
262+
263+
165264# ---------------------------------------------------------------------------
166265# Review depth modes
167266# ---------------------------------------------------------------------------
@@ -851,6 +950,11 @@ def parse_review(raw: str) -> dict:
851950
852951
853952def main ():
953+ # Handle `rpr update` before argparse (which expects an int positional)
954+ if len (sys .argv ) >= 2 and sys .argv [1 ] == "update" :
955+ handle_update ()
956+ return
957+
854958 parser = argparse .ArgumentParser (
855959 prog = "rpr" ,
856960 description = "Stealth PR Reviewer — looks like you wrote every word." ,
0 commit comments