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

2

3 from __future__ import annotations

4

5 import importlib.metadata

6 import json

7 import shutil

8 import subprocess

9 import sys

10 from enum import Enum

11 from pathlib import Path

12

13 from little_loops.host_runner import HostNotConfigured, resolve_host

14

15

16 class InstallStatus(Enum):

17 UpToDate = "up_to_date"

18 OutOfDate = "out_of_date"

19 NotInstalled = "not_installed"

20 Unknown = "unknown"

21

22

23 def _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

39 def 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

90 def 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

113 def 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

157 def 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