1"""Dependency validation for headless ll-init."""

2

3 from __future__ import annotations

4

5 import importlib.metadata

6 import shutil

7 import subprocess

8 import sys

9 from dataclasses import dataclass

10 from pathlib import Path

11 from typing import Any

12

13

14@dataclass

15 class DepWarning:

16 """A non-blocking dependency warning from validate_deps()."""

17

18 message: str

19 install_hint: str | None = None

20

21

22 def _check_jq() -> DepWarning | None:

23 """Check that jq is on PATH (required by all Claude Code hook adapters)."""

24 if shutil.which("jq") is None:

25 return DepWarning(

26 message=(

27 "'jq' not found in PATH — Claude Code hook adapters "

28 "(hooks/adapters/claude-code/*.sh) will fail silently. "

29 "Adapters parse the host JSON envelope with jq before invoking "

30 "the Python handlers in little_loops.hooks."

31 ),

32 install_hint="https://stedolan.github.io/jq/download/",

33 )

34 return None

35

36

37 def _check_python3() -> DepWarning | None:

38 """Check that python3 is on PATH (required by the SessionStart adapter)."""

39 if shutil.which("python3") is None:

40 return DepWarning(

41 message="'python3' not found in PATH — SessionStart adapter will fail silently",

42 )

43 return None

44

45

46 def _check_pyyaml() -> DepWarning | None:

47 """Check that pyyaml is importable (required for ll.local.md parsing)."""

48 try:

49 result = subprocess.run(

50 [sys.executable, "-c", "import yaml"],

51 capture_output=True,

52 timeout=10,

53 )

54 if result.returncode != 0:

55 return DepWarning(

56 message=(

57 "'pyyaml' not installed — SessionStart config merge will fail silently "

58 "(little_loops.hooks.session_start uses yaml to parse .ll/ll.local.md)"

59 ),

60 install_hint="pip install pyyaml",

61 )

62 except (subprocess.TimeoutExpired, FileNotFoundError):

63 pass

64 return None

65

66

67 def _check_little_loops_version(plugin_version: str, project_root: Path) -> DepWarning | None:

68 """Check that the installed little-loops pip package matches *plugin_version*."""

69 try:

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

71 except importlib.metadata.PackageNotFoundError:

72 scripts_dir = project_root / "scripts"

73 hint = (

74 f"pip install -e '{scripts_dir}'"

75 if scripts_dir.exists()

76 else "pip install little-loops"

77 )

78 return DepWarning(

79 message=("'little-loops' pip package not installed — ll-* CLI tools unavailable."),

80 install_hint=hint,

81 )

82

83 if installed != plugin_version:

84 scripts_dir = project_root / "scripts"

85 hint = (

86 f"pip install -e '{scripts_dir}'"

87 if scripts_dir.exists()

88 else "pip install --upgrade little-loops"

89 )

90 return DepWarning(

91 message=(

92 f"little-loops version mismatch: installed {installed!r}, "

93 f"plugin expects {plugin_version!r}."

94 ),

95 install_hint=hint,

96 )

97

98 return None

99

100

101 def _check_tool_commands(config: dict[str, Any]) -> list[DepWarning]:

102 """Check PATH availability of base commands in test/lint/type/format_cmd.

103

104 Ports Step 7.5 of the skill: extracts the first token of each configured

105 command, deduplicates, and warns for any not found on PATH.

106 """

107 project = config.get("project", {})

108 cmd_fields = ["test_cmd", "lint_cmd", "type_cmd", "format_cmd"]

109 base_commands: dict[str, str] = {} # base_cmd → first source field

110

111 for field in cmd_fields:

112 value = project.get(field)

113 if not value:

114 continue

115 base = value.strip().split()[0] if value.strip() else None

116 if base and base not in base_commands:

117 base_commands[base] = field

118

119 warnings: list[DepWarning] = []

120 for base_cmd, source_field in base_commands.items():

121 if shutil.which(base_cmd) is None:

122 skill_hint = "/ll:run-tests" if source_field == "test_cmd" else "/ll:check-code"

123 warnings.append(

124 DepWarning(

125 message=(

126 f"'{base_cmd}' not found in PATH — install it before running {skill_hint}"

127 ),

128 )

129 )

130

131 return warnings

132

133

134 def validate_deps(

135 config: dict[str, Any] | None = None,

136 plugin_version: str | None = None,

137 project_root: Path | None = None,

138) -> list[DepWarning]:

139 """Validate runtime dependencies and pip package version alignment.

140

141 Combines Step 7.5 (tool command PATH checks) and Step 9.5 (hook

142 dependencies + pip package check) from the /ll:init skill.

143

144 All checks are non-blocking: every warning is collected and returned

145 regardless of whether earlier checks failed.

146

147 Args:

148 config: ll-config.json dict (used for tool command PATH checks).

149 plugin_version: Expected little-loops version string; skipped if None.

150 project_root: Project root for scripts/ dir resolution; skipped if None.

151

152 Returns:

153 List of DepWarning instances (may be empty).

154 """

155 warnings: list[DepWarning] = []

156

157 if config:

158 warnings.extend(_check_tool_commands(config))

159

160 w = _check_jq()

161 if w:

162 warnings.append(w)

163

164 w = _check_python3()

165 if w:

166 warnings.append(w)

167

168 w = _check_pyyaml()

169 if w:

170 warnings.append(w)

171

172 if plugin_version is not None and project_root is not None:

173 w = _check_little_loops_version(plugin_version, project_root)

174 if w:

175 warnings.append(w)

176

177 return warnings