Coverage for little_loops / init / install_check.py: 24%

80 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-29 00:55 -0500

1"""Installation detection and version comparison for ll-init.""" 

2 

3from __future__ import annotations 

4 

5import importlib.metadata 

6import json 

7import shutil 

8import subprocess 

9import sys 

10from enum import Enum 

11from pathlib import Path 

12 

13from little_loops.host_runner import HostNotConfigured, resolve_host 

14 

15 

16class InstallStatus(Enum): 

17 UpToDate = "up_to_date" 

18 OutOfDate = "out_of_date" 

19 NotInstalled = "not_installed" 

20 Unknown = "unknown" 

21 

22 

23def _is_editable_install() -> bool: 

24 """Return True if little-loops is installed as an editable (dev) install.""" 

25 try: 

26 result = subprocess.run( 

27 [sys.executable, "-m", "pip", "show", "little-loops"], 

28 capture_output=True, 

29 text=True, 

30 timeout=10, 

31 ) 

32 return any( 

33 line.startswith("Editable project location:") for line in result.stdout.splitlines() 

34 ) 

35 except (subprocess.TimeoutExpired, FileNotFoundError, OSError): 

36 return False 

37 

38 

39def detect_installation( 

40 project_root: Path, 

41) -> tuple[str | None, str | None, str | None]: 

42 """Detect local or global little-loops installation. 

43 

44 Returns: 

45 (install_source, installed_version, install_path) where install_source is one of 

46 "local-editable", "pypi", "global-claude-code", "project-claude-code", or None 

47 (not found). installed_version is the pip version string for pip-based installs, 

48 or the plugin version string for claude-code plugin installs. install_path is the 

49 installPath from the plugin JSON (claude-code installs only), or None otherwise. 

50 """ 

51 # Check pip metadata first. 

52 try: 

53 installed = importlib.metadata.version("little-loops") 

54 source = "local-editable" if _is_editable_install() else "pypi" 

55 return source, installed, None 

56 except importlib.metadata.PackageNotFoundError: 

57 pass 

58 

59 # Global claude plugin check — use --json to retrieve version and scope. 

60 if shutil.which("claude"): 

61 try: 

62 result = subprocess.run( 

63 ["claude", "plugin", "list", "--json"], 

64 capture_output=True, 

65 text=True, 

66 timeout=10, 

67 ) 

68 if result.returncode == 0: 

69 try: 

70 plugins = json.loads(result.stdout) 

71 for plugin in plugins: 

72 if isinstance(plugin, dict) and plugin.get("name") == "ll@little-loops": 

73 scope = plugin.get("scope", "user") 

74 source = ( 

75 "project-claude-code" 

76 if scope == "project" 

77 else "global-claude-code" 

78 ) 

79 return source, plugin.get("version"), plugin.get("installPath") 

80 except (json.JSONDecodeError, TypeError, AttributeError): 

81 # Older CLI without --json: fall back to plain-text presence check. 

82 if "ll@little-loops" in result.stdout: 

83 return "global-claude-code", None, None 

84 except (subprocess.TimeoutExpired, FileNotFoundError, OSError): 

85 pass 

86 

87 return None, None, None 

88 

89 

90def fetch_latest_pypi(timeout: float = 10.0) -> str | None: 

91 """Fetch the latest little-loops version from PyPI. 

92 

93 Uses ``pip index versions`` and parses the ``LATEST:`` line. 

94 

95 Returns: 

96 Latest version string, or None on any failure (offline, timeout, etc.). 

97 """ 

98 try: 

99 result = subprocess.run( 

100 [sys.executable, "-m", "pip", "index", "versions", "little-loops"], 

101 capture_output=True, 

102 text=True, 

103 timeout=timeout, 

104 ) 

105 for line in result.stdout.splitlines(): 

106 if line.startswith("LATEST:"): 

107 return line.split(":", 1)[1].strip() 

108 except (subprocess.TimeoutExpired, FileNotFoundError, OSError): 

109 pass 

110 return None 

111 

112 

113def fetch_latest_plugin(timeout: float = 10.0) -> str | None: 

114 """Fetch the latest ll@little-loops plugin version from the marketplace. 

115 

116 Uses ``resolve_host()`` so the binary name is never hardcoded. 

117 

118 Returns: 

119 Latest version string, or None on any failure (offline, no host, etc.). 

120 Only meaningful when the claude-code host is active. 

121 """ 

122 try: 

123 runner = resolve_host() 

124 invocation = runner.build_version_check() 

125 binary = invocation.binary 

126 except HostNotConfigured: 

127 return None 

128 

129 try: 

130 # Update marketplace index (best-effort — ignore failure). 

131 subprocess.run( 

132 [binary, "plugin", "marketplace", "update", "little-loops"], 

133 capture_output=True, 

134 text=True, 

135 timeout=timeout, 

136 ) 

137 except (subprocess.TimeoutExpired, FileNotFoundError, OSError): 

138 pass 

139 

140 try: 

141 result = subprocess.run( 

142 [binary, "plugin", "list", "--available", "--json"], 

143 capture_output=True, 

144 text=True, 

145 timeout=timeout, 

146 ) 

147 if result.returncode == 0: 

148 plugins = json.loads(result.stdout) 

149 for plugin in plugins: 

150 if isinstance(plugin, dict) and plugin.get("name") == "ll@little-loops": 

151 return plugin.get("version") 

152 except (subprocess.TimeoutExpired, FileNotFoundError, OSError, json.JSONDecodeError): 

153 pass 

154 return None 

155 

156 

157def check_version(installed: str, latest: str) -> InstallStatus: 

158 """Compare installed version against the latest available version. 

159 

160 Args: 

161 installed: Version string from the installed pip package or plugin. 

162 latest: Version string from the latest available release (PyPI or marketplace). 

163 

164 Returns: 

165 UpToDate if installed >= latest (semver), OutOfDate if installed < latest. 

166 """ 

167 

168 def _parse(v: str) -> tuple[int, ...]: 

169 return tuple(int(x) for x in v.split(".")) 

170 

171 if _parse(installed) >= _parse(latest): 

172 return InstallStatus.UpToDate 

173 return InstallStatus.OutOfDate