Coverage for src / osiris_cli / osiris_config.py: 0%

174 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2025-12-31 05:01 +0200

1""" 

2OSIRIS.md Configuration Parser 

3 

4Enables project-level AI customization via OSIRIS.md files. 

5Inspired by Gemini CLI's GEMINI.md for project-specific behavior. 

6""" 

7 

8import os 

9from pathlib import Path 

10from typing import Dict, List, Optional, Any 

11import re 

12 

13from .logger import get_logger 

14from .config import settings 

15 

16logger = get_logger() 

17 

18 

19class OsirisConfig: 

20 """ 

21 Parser for OSIRIS.md project configuration files. 

22  

23 OSIRIS.md allows projects to customize: 

24 - AI instructions/system prompt 

25 - Allowed/blocked tools 

26 - Context files to include 

27 - Model preferences 

28 - Safety settings 

29 """ 

30 

31 def __init__(self, project_root: Optional[Path] = None): 

32 """ 

33 Initialize OSIRIS.md config parser. 

34  

35 Args: 

36 project_root: Project root directory (defaults to current directory) 

37 """ 

38 self.project_root = project_root or Path.cwd() 

39 self.config_file = self.project_root / "OSIRIS.md" 

40 

41 # Configuration values 

42 self.instructions: Optional[str] = None 

43 self.allowed_tools: List[str] = [] 

44 self.blocked_tools: List[str] = [] 

45 self.context_files: List[str] = [] 

46 self.model_preferences: Dict[str, str] = {} 

47 self.safety_profile: Optional[str] = None 

48 self.custom_settings: Dict[str, Any] = {} 

49 

50 # Load if exists 

51 if self.config_file.exists(): 

52 self.load() 

53 

54 def load(self) -> bool: 

55 """ 

56 Load and parse OSIRIS.md file. 

57  

58 Returns: 

59 True if loaded successfully 

60 """ 

61 if not self.config_file.exists(): 

62 logger.debug("No OSIRIS.md found in project root") 

63 return False 

64 

65 try: 

66 with open(self.config_file, 'r', encoding='utf-8') as f: 

67 content = f.read() 

68 

69 self._parse_config(content) 

70 logger.info(f"Loaded OSIRIS.md from {self.config_file}") 

71 return True 

72 

73 except Exception as e: 

74 logger.error(f"Failed to load OSIRIS.md: {e}", exc_info=True) 

75 return False 

76 

77 def _parse_config(self, content: str): 

78 """Parse OSIRIS.md content""" 

79 

80 # Extract sections using markdown headers 

81 sections = self._extract_sections(content) 

82 

83 # Parse each section 

84 for section_name, section_content in sections.items(): 

85 section_lower = section_name.lower() 

86 

87 if section_lower in ['instructions', 'system prompt', 'prompt']: 

88 self.instructions = section_content.strip() 

89 logger.debug(f"Loaded instructions: {len(self.instructions)} chars") 

90 

91 elif section_lower in ['allowed tools', 'tools']: 

92 self.allowed_tools = self._parse_list(section_content) 

93 logger.debug(f"Allowed tools: {self.allowed_tools}") 

94 

95 elif section_lower in ['blocked tools', 'disable tools']: 

96 self.blocked_tools = self._parse_list(section_content) 

97 logger.debug(f"Blocked tools: {self.blocked_tools}") 

98 

99 elif section_lower in ['context files', 'context', 'files']: 

100 self.context_files = self._parse_list(section_content) 

101 logger.debug(f"Context files: {len(self.context_files)} files") 

102 

103 elif section_lower in ['model preferences', 'models', 'model']: 

104 self.model_preferences = self._parse_key_value(section_content) 

105 logger.debug(f"Model preferences: {self.model_preferences}") 

106 

107 elif section_lower in ['safety', 'safety profile']: 

108 self.safety_profile = section_content.strip().lower() 

109 logger.debug(f"Safety profile: {self.safety_profile}") 

110 

111 elif section_lower in ['settings', 'config']: 

112 self.custom_settings = self._parse_key_value(section_content) 

113 logger.debug(f"Custom settings: {self.custom_settings}") 

114 

115 def _extract_sections(self, content: str) -> Dict[str, str]: 

116 """Extract markdown sections by headers""" 

117 sections = {} 

118 current_section = None 

119 current_content = [] 

120 

121 for line in content.split('\n'): 

122 # Check for header (## Header or # Header) 

123 header_match = re.match(r'^#{1,6}\s+(.+)$', line) 

124 

125 if header_match: 

126 # Save previous section 

127 if current_section: 

128 sections[current_section] = '\n'.join(current_content) 

129 

130 # Start new section 

131 current_section = header_match.group(1).strip() 

132 current_content = [] 

133 else: 

134 if current_section: 

135 current_content.append(line) 

136 

137 # Save last section 

138 if current_section: 

139 sections[current_section] = '\n'.join(current_content) 

140 

141 return sections 

142 

143 def _parse_list(self, content: str) -> List[str]: 

144 """Parse markdown list or comma-separated values""" 

145 items = [] 

146 

147 for line in content.split('\n'): 

148 line = line.strip() 

149 

150 # Skip empty lines and code blocks 

151 if not line or line.startswith('```'): 

152 continue 

153 

154 # Markdown list (- item or * item) 

155 if line.startswith(('-', '*', '+')): 

156 item = line[1:].strip() 

157 if item: 

158 items.append(item) 

159 

160 # Comma-separated 

161 elif ',' in line: 

162 for item in line.split(','): 

163 item = item.strip() 

164 if item: 

165 items.append(item) 

166 

167 # Plain line 

168 elif line: 

169 items.append(line) 

170 

171 return items 

172 

173 def _parse_key_value(self, content: str) -> Dict[str, str]: 

174 """Parse key-value pairs (key: value or key = value)""" 

175 pairs = {} 

176 

177 for line in content.split('\n'): 

178 line = line.strip() 

179 

180 # Skip empty lines and code blocks 

181 if not line or line.startswith('```'): 

182 continue 

183 

184 # key: value or key = value 

185 if ':' in line: 

186 key, value = line.split(':', 1) 

187 pairs[key.strip()] = value.strip() 

188 elif '=' in line: 

189 key, value = line.split('=', 1) 

190 pairs[key.strip()] = value.strip() 

191 

192 return pairs 

193 

194 def apply_to_settings(self): 

195 """Apply OSIRIS.md config to global settings""" 

196 

197 # Apply system prompt 

198 if self.instructions: 

199 settings.system_prompt = self.instructions 

200 logger.info("Applied custom instructions from OSIRIS.md") 

201 

202 # Apply model preferences 

203 if self.model_preferences.get('provider'): 

204 settings.provider = self.model_preferences['provider'] 

205 logger.info(f"Applied provider preference: {settings.provider}") 

206 

207 if self.model_preferences.get('model'): 

208 settings.default_model = self.model_preferences['model'] 

209 logger.info(f"Applied model preference: {settings.default_model}") 

210 

211 # Apply safety profile 

212 if self.safety_profile in ['strict', 'standard', 'yolo']: 

213 settings.safety_profile = self.safety_profile 

214 logger.info(f"Applied safety profile: {self.safety_profile}") 

215 

216 # Apply custom settings 

217 for key, value in self.custom_settings.items(): 

218 if hasattr(settings, key): 

219 # Convert string values to appropriate types 

220 current_value = getattr(settings, key) 

221 if isinstance(current_value, bool): 

222 value = value.lower() in ['true', 'yes', '1'] 

223 elif isinstance(current_value, int): 

224 value = int(value) 

225 elif isinstance(current_value, float): 

226 value = float(value) 

227 

228 setattr(settings, key, value) 

229 logger.info(f"Applied custom setting: {key}={value}") 

230 

231 def get_context_files(self) -> List[Path]: 

232 """ 

233 Get context files with glob pattern support. 

234  

235 Returns: 

236 List of resolved file paths 

237 """ 

238 resolved_files = [] 

239 

240 for pattern in self.context_files: 

241 # Support glob patterns 

242 if '*' in pattern: 

243 matched = list(self.project_root.glob(pattern)) 

244 resolved_files.extend(matched) 

245 else: 

246 file_path = self.project_root / pattern 

247 if file_path.exists(): 

248 resolved_files.append(file_path) 

249 else: 

250 logger.warning(f"Context file not found: {pattern}") 

251 

252 return resolved_files 

253 

254 def is_tool_allowed(self, tool_name: str) -> bool: 

255 """ 

256 Check if tool is allowed by OSIRIS.md config. 

257  

258 Args: 

259 tool_name: Name of tool to check 

260  

261 Returns: 

262 True if allowed, False if blocked 

263 """ 

264 # If explicitly blocked, deny 

265 if tool_name in self.blocked_tools: 

266 return False 

267 

268 # If allowed list specified, only allow those 

269 if self.allowed_tools: 

270 return tool_name in self.allowed_tools 

271 

272 # Otherwise allow (default behavior) 

273 return True 

274 

275 def get_summary(self) -> Dict[str, Any]: 

276 """Get configuration summary""" 

277 return { 

278 "config_file": str(self.config_file), 

279 "loaded": self.config_file.exists(), 

280 "has_instructions": bool(self.instructions), 

281 "allowed_tools": len(self.allowed_tools), 

282 "blocked_tools": len(self.blocked_tools), 

283 "context_files": len(self.context_files), 

284 "model_preferences": self.model_preferences, 

285 "safety_profile": self.safety_profile, 

286 "custom_settings": len(self.custom_settings) 

287 } 

288 

289 

290def load_osiris_config(project_root: Optional[Path] = None) -> OsirisConfig: 

291 """ 

292 Load OSIRIS.md configuration for current project. 

293  

294 Args: 

295 project_root: Project root directory (defaults to current directory) 

296  

297 Returns: 

298 OsirisConfig instance 

299 """ 

300 config = OsirisConfig(project_root) 

301 

302 if config.config_file.exists(): 

303 logger.info(f"Found OSIRIS.md in {config.project_root}") 

304 config.apply_to_settings() 

305 

306 return config 

307 

308 

309def create_osiris_template(project_root: Optional[Path] = None) -> Path: 

310 """ 

311 Create OSIRIS.md template in project root. 

312  

313 Args: 

314 project_root: Project root directory (defaults to current directory) 

315  

316 Returns: 

317 Path to created template 

318 """ 

319 project_root = project_root or Path.cwd() 

320 osiris_file = project_root / "OSIRIS.md" 

321 

322 if osiris_file.exists(): 

323 logger.warning(f"OSIRIS.md already exists at {osiris_file}") 

324 return osiris_file 

325 

326 template = """# OSIRIS.md - Project AI Configuration 

327 

328This file customizes Osiris CLI behavior for this project. 

329 

330## Instructions 

331 

332Custom instructions for AI behavior in this project: 

333 

334You are an expert developer working on [PROJECT_NAME]. Follow these guidelines: 

335- Write clean, maintainable code 

336- Add comprehensive documentation 

337- Follow project coding standards 

338- Test all changes thoroughly 

339 

340## Allowed Tools 

341 

342Tools that AI can use in this project: 

343- read_file 

344- write_file 

345- list_directory 

346- get_file_tree 

347- search_codebase 

348- run_shell_command 

349 

350## Blocked Tools 

351 

352Tools that should NOT be used: 

353- web_search 

354 

355## Context Files 

356 

357Files to automatically include in context: 

358- README.md 

359- src/**/*.py 

360- tests/**/*.py 

361- docs/**/*.md 

362 

363## Model Preferences 

364 

365provider: openai 

366model: gpt-4 

367 

368## Safety 

369 

370Safety profile for this project: standard 

371 

372## Settings 

373 

374Custom settings: 

375temperature: 0.7 

376max_tokens: 4000 

377yolo_mode: false 

378""" 

379 

380 try: 

381 with open(osiris_file, 'w', encoding='utf-8') as f: 

382 f.write(template) 

383 

384 logger.info(f"Created OSIRIS.md template at {osiris_file}") 

385 return osiris_file 

386 

387 except Exception as e: 

388 logger.error(f"Failed to create OSIRIS.md: {e}") 

389 raise 

390 

391 

392# Global config instance 

393_osiris_config: Optional[OsirisConfig] = None 

394 

395 

396def get_osiris_config() -> OsirisConfig: 

397 """Get or load global OSIRIS.md config""" 

398 global _osiris_config 

399 

400 if _osiris_config is None: 

401 _osiris_config = load_osiris_config() 

402 

403 return _osiris_config