@@ -97,12 +97,31 @@ class _AutoUpdateGroup(click.Group):
9797
9898 def invoke (self , ctx : click .Context ) -> object :
9999 import os
100-
101- # Skip if explicitly disabled or if this is a meta-command
102- # ctx.protected_args is deprecated in Click 9.0; suppress the warning
103- # on access (it still works in 8.x). In 9.0 the subcommand moves to args.
104100 import warnings
105101
102+ # ── Pipx-only enforcement ─────────────────────────────────────────────
103+ # specsmith MUST be installed and invoked through pipx.
104+ # Any invocation from a non-pipx Python environment is rejected unless
105+ # the escape hatch SPECSMITH_ALLOW_NON_PIPX=1 is set (CI / dev only).
106+ if not os .environ .get ("SPECSMITH_ALLOW_NON_PIPX" ):
107+ from specsmith .updater import is_pipx_install
108+ if not is_pipx_install ():
109+ click .echo (
110+ "ERROR: specsmith must be installed and run via pipx only.\n "
111+ " Any pip install, venv install, or editable dev install\n "
112+ " is rejected to prevent version conflicts.\n "
113+ "\n "
114+ " Install: pipx install specsmith\n "
115+ " Upgrade: pipx upgrade specsmith\n "
116+ " Remove other: pip uninstall specsmith\n "
117+ "\n "
118+ " CI/testing override: set SPECSMITH_ALLOW_NON_PIPX=1" ,
119+ err = True ,
120+ )
121+ raise SystemExit (1 )
122+
123+ # ── Version checks (skip for meta-commands) ───────────────────────────
124+ # ctx.protected_args is deprecated in Click 9.0; suppress the warning.
106125 with warnings .catch_warnings ():
107126 warnings .simplefilter ("ignore" , DeprecationWarning )
108127 protected = list (ctx .protected_args ) # [subcommand] in 8.x, [] in 9.0
@@ -182,17 +201,55 @@ def _maybe_prompt_project_update() -> None:
182201def _maybe_notify_pypi_update () -> None :
183202 """Check PyPI for a newer specsmith version. Prints one-liner if outdated.
184203
185- Runs at most once per shell session (tracked via env var). Uses a 3-second
186- timeout to avoid blocking the CLI. Only checks stable versions.
204+ Persists the last-check timestamp to ``~/.specsmith/last-update-check``
205+ so the network call is only made when it has been more than
206+ ``SPECSMITH_UPDATE_INTERVAL_HOURS`` hours since the previous check
207+ (default: 24h). Within that window the function returns immediately
208+ without any I/O — adding zero latency to every CLI invocation.
209+
210+ Override the interval::
211+
212+ SPECSMITH_UPDATE_INTERVAL_HOURS=4 specsmith audit
213+
214+ Disable entirely::
215+
216+ SPECSMITH_NO_UPDATE_CHECK=1 specsmith audit
217+
218+ Uses a 3-second network timeout so a slow/offline connection never
219+ blocks the user.
187220 """
188221 import os
222+ import time
223+ from pathlib import Path
189224
225+ if os .environ .get ("SPECSMITH_NO_UPDATE_CHECK" ):
226+ return
227+
228+ # One check per shell session — never fire twice in the same process tree.
190229 session_key = "SPECSMITH_PYPI_CHECKED"
191230 if os .environ .get (session_key ):
192231 return
193232 os .environ [session_key ] = "1"
194233
195234 try :
235+ interval_h = float (os .environ .get ("SPECSMITH_UPDATE_INTERVAL_HOURS" , "24" ))
236+ interval_s = interval_h * 3600
237+
238+ stamp_file = Path .home () / ".specsmith" / "last-update-check"
239+ now = time .time ()
240+
241+ # Read persisted last-check time (best-effort).
242+ last_check = 0.0
243+ if stamp_file .is_file ():
244+ try :
245+ last_check = float (stamp_file .read_text (encoding = "utf-8" ).strip ())
246+ except (ValueError , OSError ):
247+ pass
248+
249+ # Not due yet — skip entirely (no network call).
250+ if now - last_check < interval_s :
251+ return
252+
196253 import json as _json # noqa: PLC0415
197254 from urllib .request import urlopen # noqa: PLC0415
198255
@@ -202,9 +259,16 @@ def _maybe_notify_pypi_update() -> None:
202259 if not latest :
203260 return
204261
205- # Simple version comparison: split into tuples of ints
262+ # Persist the timestamp now that we have a successful response.
263+ try :
264+ stamp_file .parent .mkdir (parents = True , exist_ok = True )
265+ stamp_file .write_text (str (now ), encoding = "utf-8" )
266+ except OSError :
267+ pass # Never fail the CLI over a timestamp write error
268+
269+ # Simple version comparison — no packaging dep needed.
206270 def _ver (v : str ) -> tuple [int , ...]:
207- import re
271+ import re # noqa: PLC0415
208272
209273 clean = re .match (r"(\d+\.\d+\.\d+)" , v )
210274 return tuple (int (x ) for x in clean .group (1 ).split ("." )) if clean else (0 ,)
0 commit comments