Coverage for integrations / coding_agent / installer.py: 52.1%

73 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-05-12 04:49 +0000

1""" 

2Coding Agent Tool Installer — Detection and installation of external CLI coding tools. 

3 

4Detects and installs KiloCode, Claude Code, and OpenCode. 

5All tools are installed via npm on the user's machine (never bundled/redistributed). 

6 

7Licenses: 

8 KiloCode — Apache 2.0 (npm: @kilocode/cli) 

9 Claude Code — Proprietary/Anthropic Commercial ToS (npm: @anthropic-ai/claude-code) 

10 OpenCode — MIT (npm: opencode-ai) 

11""" 

12import logging 

13import os 

14import shutil 

15import subprocess 

16from typing import Dict, Optional 

17 

18logger = logging.getLogger('hevolve.coding_agent') 

19 

20# Tool registry: name → (binary_name, package, license) 

21# binary_name='' means in-process (no external binary) 

22TOOL_REGISTRY = { 

23 'kilocode': ('kilocode', '@kilocode/cli', 'Apache-2.0'), 

24 'claude_code': ('claude', '@anthropic-ai/claude-code', 'Proprietary'), 

25 'opencode': ('opencode', 'opencode-ai', 'MIT'), 

26 'aider_native': ('', 'tree-sitter tree-sitter-language-pack grep-ast diskcache diff-match-patch gitpython', 'Apache-2.0'), 

27} 

28 

29 

30def detect_installed() -> Dict[str, bool]: 

31 """Check which coding tools are available.""" 

32 result = {} 

33 for name, (binary, _, _) in TOOL_REGISTRY.items(): 

34 if not binary: 

35 # In-process backend — check Python import 

36 try: 

37 from .aider_native_backend import _check_aider_core 

38 result[name] = _check_aider_core() 

39 except ImportError: 

40 result[name] = False 

41 else: 

42 result[name] = shutil.which(binary) is not None 

43 return result 

44 

45 

46def get_versions() -> Dict[str, Optional[str]]: 

47 """Get version strings for installed tools.""" 

48 versions = {} 

49 for name, (binary, _, _) in TOOL_REGISTRY.items(): 

50 if not shutil.which(binary): 

51 versions[name] = None 

52 continue 

53 try: 

54 result = subprocess.run( 

55 [binary, '--version'], 

56 capture_output=True, text=True, timeout=10, 

57 ) 

58 versions[name] = result.stdout.strip() or result.stderr.strip() or 'unknown' 

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

60 versions[name] = 'installed (version unknown)' 

61 return versions 

62 

63 

64def install(tool_name: str) -> Dict: 

65 """Install a coding tool via npm install -g. 

66 

67 The user is installing the tool on their own machine. 

68 HARTOS never bundles or redistributes these tools. 

69 """ 

70 if tool_name not in TOOL_REGISTRY: 

71 return {'success': False, 'error': f'Unknown tool: {tool_name}'} 

72 

73 binary, package, license_type = TOOL_REGISTRY[tool_name] 

74 

75 # Check npm availability 

76 if not shutil.which('npm'): 

77 return { 

78 'success': False, 

79 'error': 'npm not found. Install Node.js first: https://nodejs.org/', 

80 } 

81 

82 # Already installed? 

83 if shutil.which(binary): 

84 return {'success': True, 'message': f'{tool_name} already installed'} 

85 

86 logger.info(f"Installing {tool_name} ({package}, license: {license_type})") 

87 try: 

88 result = subprocess.run( 

89 ['npm', 'install', '-g', package], 

90 capture_output=True, text=True, timeout=120, 

91 ) 

92 if result.returncode == 0: 

93 return {'success': True, 'message': f'{tool_name} installed successfully'} 

94 else: 

95 return {'success': False, 'error': result.stderr.strip()} 

96 except subprocess.TimeoutExpired: 

97 return {'success': False, 'error': 'Installation timed out (120s)'} 

98 except OSError as e: 

99 return {'success': False, 'error': str(e)} 

100 

101 

102def pip_install(packages: str) -> Dict: 

103 """Install Python packages via pip. 

104 

105 Used for in-process backends (aider_native) that need pip dependencies 

106 rather than npm. 

107 """ 

108 import sys 

109 pkg_list = packages.split() 

110 logger.info(f"pip installing: {pkg_list}") 

111 try: 

112 result = subprocess.run( 

113 [sys.executable, '-m', 'pip', 'install'] + pkg_list, 

114 capture_output=True, text=True, timeout=120, 

115 ) 

116 if result.returncode == 0: 

117 return {'success': True, 'message': f'Installed: {", ".join(pkg_list)}'} 

118 else: 

119 return {'success': False, 'error': result.stderr.strip()} 

120 except subprocess.TimeoutExpired: 

121 return {'success': False, 'error': 'pip install timed out (120s)'} 

122 except OSError as e: 

123 return {'success': False, 'error': str(e)} 

124 

125 

126def install_tool(tool_name: str) -> Dict: 

127 """Install a coding tool — routes to npm or pip based on tool type.""" 

128 if tool_name not in TOOL_REGISTRY: 

129 return {'success': False, 'error': f'Unknown tool: {tool_name}'} 

130 

131 binary, package, _ = TOOL_REGISTRY[tool_name] 

132 if not binary: 

133 # In-process tool — use pip 

134 return pip_install(package) 

135 else: 

136 # CLI tool — use npm 

137 return install(tool_name) 

138 

139 

140def get_tool_info() -> Dict: 

141 """Full tool information for API / Nunba settings UI.""" 

142 installed = detect_installed() 

143 versions = get_versions() 

144 info = {} 

145 for name, (binary, package, license_type) in TOOL_REGISTRY.items(): 

146 info[name] = { 

147 'installed': installed.get(name, False), 

148 'version': versions.get(name), 

149 'binary': binary or '(in-process)', 

150 'package': package, 

151 'license': license_type, 

152 'type': 'native' if not binary else 'subprocess', 

153 } 

154 return info