@@ -827,7 +827,7 @@ def _wcswidth(s: str) -> int:
827827class Tool :
828828 name : str
829829 candidates : tuple [str , ...]
830- source_kind : str # "gh" | "pypi" | "crates" | "npm" | "gnu" | "skip"
830+ source_kind : str # "gh" | "gitlab" | " pypi" | "crates" | "npm" | "gnu" | "skip"
831831 source_args : tuple [str , ...] # e.g., (owner, repo) or (package,) or (crate,) or (npm_pkg,) or (gnu_project,)
832832
833833
@@ -902,7 +902,7 @@ class Tool:
902902 # 5) VCS & platforms
903903 Tool ("git" , ("git" ,), "gh" , ("git" , "git" )),
904904 Tool ("gh" , ("gh" ,), "gh" , ("cli" , "cli" )),
905- Tool ("glab" , ("glab" ,), "gh " , ("profclems " , "glab " )),
905+ Tool ("glab" , ("glab" ,), "gitlab " , ("gitlab-org " , "cli " )),
906906 Tool ("gam" , ("gam" ,), "gh" , ("GAM-team" , "GAM" )),
907907 # 6) Task runners & build systems
908908 Tool ("just" , ("just" ,), "gh" , ("casey" , "just" )),
@@ -1892,6 +1892,8 @@ def upstream_method_for(tool: Tool) -> str:
18921892 return "npm (nvm)"
18931893 if kind == "gh" :
18941894 return "github"
1895+ if kind == "gitlab" :
1896+ return "gitlab"
18951897 if kind == "gnu" :
18961898 return "gnu-ftp"
18971899 return ""
@@ -1904,6 +1906,9 @@ def tool_homepage_url(tool: Tool) -> str:
19041906 if kind == "gh" :
19051907 owner , repo = args # type: ignore[misc]
19061908 return f"https://github.com/{ owner } /{ repo } "
1909+ if kind == "gitlab" :
1910+ group , project = args # type: ignore[misc]
1911+ return f"https://gitlab.com/{ group } /{ project } "
19071912 if kind == "pypi" :
19081913 (pkg ,) = args # type: ignore[misc]
19091914 return f"https://pypi.org/project/{ pkg } /"
@@ -1930,6 +1935,11 @@ def latest_target_url(tool: Tool, latest_tag: str, latest_num: str) -> str:
19301935 if latest_tag :
19311936 return f"https://github.com/{ owner } /{ repo } /releases/tag/{ latest_tag } "
19321937 return f"https://github.com/{ owner } /{ repo } /releases/latest"
1938+ if kind == "gitlab" :
1939+ group , project = args # type: ignore[misc]
1940+ if latest_tag :
1941+ return f"https://gitlab.com/{ group } /{ project } /-/releases/{ latest_tag } "
1942+ return f"https://gitlab.com/{ group } /{ project } /-/releases"
19331943 if kind == "pypi" :
19341944 (pkg ,) = args # type: ignore[misc]
19351945 return f"https://pypi.org/project/{ pkg } /"
@@ -2162,6 +2172,92 @@ def latest_github(owner: str, repo: str) -> tuple[str, str]:
21622172 return "" , ""
21632173
21642174
2175+ def latest_gitlab (group : str , project : str ) -> tuple [str , str ]:
2176+ """
2177+ Fetch the latest release from GitLab using the GitLab API.
2178+ Args:
2179+ group: GitLab group/namespace (e.g., "gitlab-org")
2180+ project: Project name (e.g., "cli")
2181+ Returns:
2182+ (tag_name, version_number) tuple or ("", "") if not found
2183+ """
2184+ if OFFLINE_MODE :
2185+ return "" , ""
2186+
2187+ # GitLab API requires URL-encoded project path
2188+ project_path = f"{ group } %2F{ project } "
2189+
2190+ # Try releases API first (excludes pre-releases by default)
2191+ try :
2192+ url = f"https://gitlab.com/api/v4/projects/{ project_path } /releases"
2193+ if AUDIT_DEBUG :
2194+ print (f"# DEBUG: GitLab API { url } (timeout={ TIMEOUT_SECONDS } s)" , file = sys .stderr , flush = True )
2195+
2196+ data = json .loads (http_get (url ))
2197+
2198+ if isinstance (data , list ) and data :
2199+ # GitLab releases API returns releases in descending order by default
2200+ # First release is the latest
2201+ release = data [0 ]
2202+ tag = normalize_version_tag ((release .get ("tag_name" ) or "" ).strip ())
2203+
2204+ if tag :
2205+ result = (tag , extract_version_number (tag ))
2206+ set_manual_latest (project , tag )
2207+ set_hint (f"gitlab:{ group } /{ project } " , "releases_api" )
2208+ if AUDIT_DEBUG :
2209+ print (f"# DEBUG: GitLab found release: { tag } " , file = sys .stderr , flush = True )
2210+ return result
2211+ except Exception as e :
2212+ if AUDIT_DEBUG :
2213+ print (f"# DEBUG: GitLab releases API failed: { e } " , file = sys .stderr , flush = True )
2214+ pass
2215+
2216+ # Fallback to tags API
2217+ try :
2218+ url = f"https://gitlab.com/api/v4/projects/{ project_path } /repository/tags?per_page=20"
2219+ if AUDIT_DEBUG :
2220+ print (f"# DEBUG: GitLab tags API { url } " , file = sys .stderr , flush = True )
2221+
2222+ data = json .loads (http_get (url ))
2223+
2224+ if isinstance (data , list ):
2225+ # Filter stable releases and find highest version
2226+ best : tuple [tuple [int , ...], str , str ] | None = None
2227+
2228+ for item in data :
2229+ tag_name = (item .get ("name" ) or "" ).strip ()
2230+ tag = normalize_version_tag (tag_name )
2231+
2232+ # Accept only stable final release tags (v1.2.3, 1.2.3)
2233+ # Exclude rc, alpha, beta, pre, dev suffixes
2234+ if tag and re .match (r"^v?\d+\.\d+(\.\d+)?$" , tag ):
2235+ ver = extract_version_number (tag )
2236+ if ver :
2237+ try :
2238+ nums = tuple (int (x ) for x in ver .split ("." ))
2239+ tup = (nums , tag , ver )
2240+ if best is None or tup [0 ] > best [0 ]:
2241+ best = tup
2242+ except Exception :
2243+ continue
2244+
2245+ if best is not None :
2246+ _ , tag , ver = best
2247+ result = (tag , ver )
2248+ set_manual_latest (project , tag )
2249+ set_hint (f"gitlab:{ group } /{ project } " , "tags_api" )
2250+ if AUDIT_DEBUG :
2251+ print (f"# DEBUG: GitLab found tag: { tag } " , file = sys .stderr , flush = True )
2252+ return result
2253+ except Exception as e :
2254+ if AUDIT_DEBUG :
2255+ print (f"# DEBUG: GitLab tags API failed: { e } " , file = sys .stderr , flush = True )
2256+ pass
2257+
2258+ return "" , ""
2259+
2260+
21652261def latest_pypi (package : str ) -> tuple [str , str ]:
21662262 if OFFLINE_MODE :
21672263 return "" , ""
@@ -2365,6 +2461,18 @@ def get_latest(tool: Tool) -> tuple[str, str]:
23652461 return man_tag , man_num
23662462 MANUAL_USED [tool .name ] = False
23672463 return tag , num
2464+ if kind == "gitlab" :
2465+ group , project = args # type: ignore[misc]
2466+ tag , num = latest_gitlab (group , project )
2467+ if tag or num :
2468+ MANUAL_USED [tool .name ] = False
2469+ set_manual_method (tool .name , "gitlab" )
2470+ return tag , num
2471+ if manual_available :
2472+ MANUAL_USED [tool .name ] = True
2473+ return man_tag , man_num
2474+ MANUAL_USED [tool .name ] = False
2475+ return tag , num
23682476 if kind == "pypi" :
23692477 (pkg ,) = args # type: ignore[misc]
23702478 tag , num = latest_pypi (pkg )
@@ -2530,20 +2638,9 @@ def audit_tool(tool: Tool) -> tuple[str, str, str, str, str, str, str, str]:
25302638 except Exception :
25312639 pass # Catalog read failed, continue with original status
25322640
2533- # Check if tool is marked as "never install"
2534- if status == "NOT INSTALLED" :
2535- script_dir = os .path .dirname (os .path .abspath (__file__ ))
2536- catalog_file = os .path .join (script_dir , "catalog" , f"{ tool .name } .json" )
2537- if os .path .exists (catalog_file ):
2538- try :
2539- with open (catalog_file , "r" , encoding = "utf-8" ) as f :
2540- catalog_data = json .load (f )
2541- pinned_version = catalog_data .get ("pinned_version" , "" )
2542- if pinned_version == "never" :
2543- # Tool is marked as never install - treat as up-to-date to suppress prompts
2544- status = "UP-TO-DATE"
2545- except Exception :
2546- pass # Catalog read failed, continue with original status
2641+ # Note: Tools with pinned_version="never" are filtered out in guide.sh,
2642+ # so we don't need to change their status here. Keep them as NOT INSTALLED
2643+ # to avoid confusion (showing ✅ icon when tool isn't actually installed).
25472644
25482645 # Sanitize latest display to numeric (like installed)
25492646 if latest_num :
0 commit comments