Coverage for src / moai_adk / core / claude_integration.py: 13.91%

115 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2025-11-20 20:52 +0900

1""" 

2Claude Code CLI integration for advanced variable substitution and automation. 

3 

4Enables headless operation with template variable processing, JSON streaming, 

5and multi-language support for commands, agents, and output styles. 

6""" 

7 

8import json 

9import subprocess 

10import tempfile 

11from pathlib import Path 

12from typing import Any, Dict, Optional, Union 

13 

14from .language_config import get_language_info 

15from .template_engine import TemplateEngine 

16 

17 

18class ClaudeCLIIntegration: 

19 """ 

20 Advanced Claude CLI integration with template variable processing. 

21 

22 Features: 

23 - Template variable substitution using MoAI-ADK's TemplateEngine 

24 - JSON streaming input/output support 

25 - Multi-language description processing 

26 - Headless automation capabilities 

27 - Configuration file generation and management 

28 """ 

29 

30 def __init__(self, template_engine: Optional[TemplateEngine] = None): 

31 """Initialize Claude CLI integration. 

32 

33 Args: 

34 template_engine: TemplateEngine instance for variable processing 

35 """ 

36 self.template_engine = template_engine or TemplateEngine() 

37 

38 def generate_claude_settings( 

39 self, variables: Dict[str, Any], output_path: Optional[Path] = None 

40 ) -> Path: 

41 """Generate Claude settings JSON file with variables. 

42 

43 Args: 

44 variables: Template variables to include 

45 output_path: Path for settings file (auto-generated if None) 

46 

47 Returns: 

48 Path to generated settings file 

49 """ 

50 if output_path is None: 

51 temp_file = tempfile.NamedTemporaryFile( 

52 mode="w", suffix=".json", delete=False 

53 ) 

54 output_path = Path(temp_file.name) 

55 temp_file.close() 

56 

57 settings = { 

58 "variables": variables, 

59 "template_context": { 

60 "conversation_language": variables.get("CONVERSATION_LANGUAGE", "en"), 

61 "conversation_language_name": variables.get( 

62 "CONVERSATION_LANGUAGE_NAME", "English" 

63 ), 

64 "project_name": variables.get("PROJECT_NAME", ""), 

65 "codebase_language": variables.get("CODEBASE_LANGUAGE", "python"), 

66 }, 

67 } 

68 

69 output_path.write_text(json.dumps(settings, indent=2, ensure_ascii=False)) 

70 return output_path 

71 

72 def process_template_command( 

73 self, 

74 command_template: str, 

75 variables: Dict[str, Any], 

76 print_mode: bool = True, 

77 output_format: str = "json", 

78 ) -> Dict[str, Any]: 

79 """Process Claude command with template variables. 

80 

81 Args: 

82 command_template: Command template with {{VARIABLE}} placeholders 

83 variables: Variables for substitution 

84 print_mode: Use --print flag for non-interactive execution 

85 output_format: Output format (text, json, stream-json) 

86 

87 Returns: 

88 Process result dictionary 

89 """ 

90 try: 

91 # Process template variables 

92 processed_command = self.template_engine.render_string( 

93 command_template, variables 

94 ) 

95 

96 # Build Claude CLI command 

97 cmd_parts = ["claude"] 

98 

99 if print_mode: 

100 cmd_parts.extend(["--print"]) 

101 cmd_parts.extend(["--output-format", output_format]) 

102 

103 # Add variable settings 

104 settings_file = self.generate_claude_settings(variables) 

105 cmd_parts.extend(["--settings", str(settings_file)]) 

106 

107 # Add processed command 

108 cmd_parts.append(processed_command) 

109 

110 # Execute Claude CLI 

111 result = subprocess.run( 

112 cmd_parts, capture_output=True, text=True, encoding="utf-8" 

113 ) 

114 

115 # Cleanup settings file 

116 try: 

117 settings_file.unlink() 

118 except OSError: 

119 pass # Ignore cleanup errors 

120 

121 return { 

122 "success": result.returncode == 0, 

123 "stdout": result.stdout, 

124 "stderr": result.stderr, 

125 "returncode": result.returncode, 

126 "processed_command": processed_command, 

127 "variables_used": variables, 

128 } 

129 

130 except Exception as e: 

131 return { 

132 "success": False, 

133 "error": str(e), 

134 "processed_command": command_template, 

135 "variables_used": variables, 

136 } 

137 

138 def generate_multilingual_descriptions( 

139 self, 

140 base_descriptions: Dict[str, str], 

141 target_languages: Optional[list[str]] = None, 

142 ) -> Dict[str, Dict[str, str]]: 

143 """Generate multilingual descriptions for commands/agents. 

144 

145 Args: 

146 base_descriptions: English base descriptions 

147 target_languages: Target language codes (auto-detected if None) 

148 

149 Returns: 

150 Multilingual descriptions dictionary 

151 """ 

152 if target_languages is None: 

153 # Auto-detect from variables or use common languages 

154 target_languages = ["en", "ko", "ja", "es", "fr", "de"] 

155 

156 multilingual = {} 

157 

158 for item_id, base_desc in base_descriptions.items(): 

159 multilingual[item_id] = {"en": base_desc} 

160 

161 # Generate descriptions for target languages 

162 for lang_code in target_languages: 

163 if lang_code == "en": 

164 continue # Already have base 

165 

166 lang_info = get_language_info(lang_code) 

167 if not lang_info: 

168 continue 

169 

170 # Create language-specific description prompt 

171 translation_prompt = f"""Translate the following Claude Code description to {lang_info['native_name']}. 

172Keep technical terms in English. Provide only the translation without explanation: 

173 

174Original: {base_desc} 

175 

176Translation:""" 

177 

178 # Use Claude CLI for translation 

179 translation_result = self.process_template_command( 

180 translation_prompt, 

181 {"CONVERSATION_LANGUAGE": lang_code}, 

182 print_mode=True, 

183 output_format="text", 

184 ) 

185 

186 if translation_result["success"]: 

187 # Extract translation from output 

188 translation = translation_result["stdout"].strip() 

189 if translation: 

190 multilingual[item_id][lang_code] = translation 

191 

192 return multilingual 

193 

194 def create_agent_with_multilingual_support( 

195 self, 

196 agent_name: str, 

197 base_description: str, 

198 tools: list[str], 

199 model: str = "sonnet", 

200 target_languages: Optional[list[str]] = None, 

201 ) -> Dict[str, Any]: 

202 """Create Claude agent with multilingual description support. 

203 

204 Args: 

205 agent_name: Agent name (kebab-case) 

206 base_description: English base description 

207 tools: List of required tools 

208 model: Claude model to use 

209 target_languages: Target languages for descriptions 

210 

211 Returns: 

212 Agent configuration dictionary 

213 """ 

214 if target_languages is None: 

215 target_languages = ["en", "ko", "ja", "es", "fr", "de"] 

216 

217 # Generate multilingual descriptions 

218 descriptions = self.generate_multilingual_descriptions( 

219 {agent_name: base_description}, target_languages 

220 ) 

221 

222 agent_config = { 

223 "name": agent_name, 

224 "description": descriptions[agent_name][ 

225 "en" 

226 ], # Primary English description 

227 "tools": tools, 

228 "model": model, 

229 "descriptions": descriptions[agent_name], # All language versions 

230 "multilingual_support": True, 

231 } 

232 

233 return agent_config 

234 

235 def create_command_with_multilingual_support( 

236 self, 

237 command_name: str, 

238 base_description: str, 

239 argument_hint: list[str], 

240 tools: list[str], 

241 model: str = "haiku", 

242 target_languages: Optional[list[str]] = None, 

243 ) -> Dict[str, Any]: 

244 """Create Claude command with multilingual description support. 

245 

246 Args: 

247 command_name: Command name (kebab-case) 

248 base_description: English base description 

249 argument_hint: List of argument hints 

250 tools: List of required tools 

251 model: Claude model to use 

252 target_languages: Target languages for descriptions 

253 

254 Returns: 

255 Command configuration dictionary 

256 """ 

257 if target_languages is None: 

258 target_languages = ["en", "ko", "ja", "es", "fr", "de"] 

259 

260 # Generate multilingual descriptions 

261 descriptions = self.generate_multilingual_descriptions( 

262 {command_name: base_description}, target_languages 

263 ) 

264 

265 command_config = { 

266 "name": command_name, 

267 "description": descriptions[command_name][ 

268 "en" 

269 ], # Primary English description 

270 "argument-hint": argument_hint, 

271 "tools": tools, 

272 "model": model, 

273 "descriptions": descriptions[command_name], # All language versions 

274 "multilingual_support": True, 

275 } 

276 

277 return command_config 

278 

279 def process_json_stream_input( 

280 self, 

281 input_data: Union[Dict[str, Any], str], 

282 variables: Optional[Dict[str, Any]] = None, 

283 ) -> Dict[str, Any]: 

284 """Process JSON stream input for Claude CLI. 

285 

286 Args: 

287 input_data: JSON data as dict or JSON string 

288 variables: Additional variables for processing 

289 

290 Returns: 

291 Processed input data 

292 """ 

293 # Convert string to dict if needed 

294 processed_data: Dict[str, Any] 

295 if isinstance(input_data, str): 

296 try: 

297 processed_data = json.loads(input_data) 

298 except json.JSONDecodeError as e: 

299 raise ValueError(f"Invalid JSON input: {e}") 

300 else: 

301 processed_data = input_data 

302 

303 if variables: 

304 # Process any string values in processed_data with variables 

305 for key, value in processed_data.items(): 

306 if isinstance(value, str) and "{{" in value and "}}" in value: 

307 processed_data[key] = self.template_engine.render_string( 

308 value, variables 

309 ) 

310 

311 return processed_data 

312 

313 def execute_headless_command( 

314 self, 

315 prompt_template: str, 

316 variables: Dict[str, Any], 

317 input_format: str = "stream-json", 

318 output_format: str = "stream-json", 

319 additional_options: Optional[list[str]] = None, 

320 ) -> Dict[str, Any]: 

321 """Execute Claude command in headless mode with full variable processing. 

322 

323 Args: 

324 prompt_template: Prompt template with variables 

325 variables: Variables for substitution 

326 input_format: Input format (text, stream-json) 

327 output_format: Output format (text, json, stream-json) 

328 additional_options: Additional CLI options 

329 

330 Returns: 

331 Command execution result 

332 """ 

333 try: 

334 # Process prompt template 

335 processed_prompt = self.template_engine.render_string( 

336 prompt_template, variables 

337 ) 

338 

339 # Build Claude command 

340 cmd_parts = ["claude", "--print"] 

341 cmd_parts.extend(["--input-format", input_format]) 

342 cmd_parts.extend(["--output-format", output_format]) 

343 

344 # Add settings 

345 settings_file = self.generate_claude_settings(variables) 

346 cmd_parts.extend(["--settings", str(settings_file)]) 

347 

348 # Add additional options 

349 if additional_options: 

350 cmd_parts.extend(additional_options) 

351 

352 # Add processed prompt 

353 cmd_parts.append(processed_prompt) 

354 

355 # Execute with streaming support 

356 if output_format == "stream-json": 

357 # For streaming, use subprocess with real-time output 

358 process = subprocess.Popen( 

359 cmd_parts, 

360 stdout=subprocess.PIPE, 

361 stderr=subprocess.PIPE, 

362 text=True, 

363 encoding="utf-8", 

364 ) 

365 

366 stdout_lines = [] 

367 stderr_lines = [] 

368 

369 # Stream output in real-time 

370 while True: 

371 stdout_line = process.stdout.readline() 

372 stderr_line = process.stderr.readline() 

373 

374 if ( 

375 stdout_line == "" 

376 and stderr_line == "" 

377 and process.poll() is not None 

378 ): 

379 break 

380 

381 if stdout_line: 

382 stdout_lines.append(stdout_line.strip()) 

383 # You can process each line here for real-time handling 

384 

385 if stderr_line: 

386 stderr_lines.append(stderr_line.strip()) 

387 

388 returncode = process.poll() 

389 

390 else: 

391 # Non-streaming execution 

392 result = subprocess.run( 

393 cmd_parts, capture_output=True, text=True, encoding="utf-8" 

394 ) 

395 

396 stdout_lines = ( 

397 result.stdout.strip().split("\n") if result.stdout else [] 

398 ) 

399 stderr_lines = ( 

400 result.stderr.strip().split("\n") if result.stderr else [] 

401 ) 

402 returncode = result.returncode 

403 

404 # Cleanup 

405 try: 

406 settings_file.unlink() 

407 except OSError: 

408 pass 

409 

410 return { 

411 "success": returncode == 0, 

412 "stdout": stdout_lines, 

413 "stderr": stderr_lines, 

414 "returncode": returncode, 

415 "processed_prompt": processed_prompt, 

416 "variables": variables, 

417 } 

418 

419 except Exception as e: 

420 return { 

421 "success": False, 

422 "error": str(e), 

423 "prompt_template": prompt_template, 

424 "variables": variables, 

425 }