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
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-31 05:01 +0200
1"""
2OSIRIS.md Configuration Parser
4Enables project-level AI customization via OSIRIS.md files.
5Inspired by Gemini CLI's GEMINI.md for project-specific behavior.
6"""
8import os
9from pathlib import Path
10from typing import Dict, List, Optional, Any
11import re
13from .logger import get_logger
14from .config import settings
16logger = get_logger()
19class OsirisConfig:
20 """
21 Parser for OSIRIS.md project configuration files.
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 """
31 def __init__(self, project_root: Optional[Path] = None):
32 """
33 Initialize OSIRIS.md config parser.
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"
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] = {}
50 # Load if exists
51 if self.config_file.exists():
52 self.load()
54 def load(self) -> bool:
55 """
56 Load and parse OSIRIS.md file.
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
65 try:
66 with open(self.config_file, 'r', encoding='utf-8') as f:
67 content = f.read()
69 self._parse_config(content)
70 logger.info(f"Loaded OSIRIS.md from {self.config_file}")
71 return True
73 except Exception as e:
74 logger.error(f"Failed to load OSIRIS.md: {e}", exc_info=True)
75 return False
77 def _parse_config(self, content: str):
78 """Parse OSIRIS.md content"""
80 # Extract sections using markdown headers
81 sections = self._extract_sections(content)
83 # Parse each section
84 for section_name, section_content in sections.items():
85 section_lower = section_name.lower()
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")
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}")
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}")
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")
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}")
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}")
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}")
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 = []
121 for line in content.split('\n'):
122 # Check for header (## Header or # Header)
123 header_match = re.match(r'^#{1,6}\s+(.+)$', line)
125 if header_match:
126 # Save previous section
127 if current_section:
128 sections[current_section] = '\n'.join(current_content)
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)
137 # Save last section
138 if current_section:
139 sections[current_section] = '\n'.join(current_content)
141 return sections
143 def _parse_list(self, content: str) -> List[str]:
144 """Parse markdown list or comma-separated values"""
145 items = []
147 for line in content.split('\n'):
148 line = line.strip()
150 # Skip empty lines and code blocks
151 if not line or line.startswith('```'):
152 continue
154 # Markdown list (- item or * item)
155 if line.startswith(('-', '*', '+')):
156 item = line[1:].strip()
157 if item:
158 items.append(item)
160 # Comma-separated
161 elif ',' in line:
162 for item in line.split(','):
163 item = item.strip()
164 if item:
165 items.append(item)
167 # Plain line
168 elif line:
169 items.append(line)
171 return items
173 def _parse_key_value(self, content: str) -> Dict[str, str]:
174 """Parse key-value pairs (key: value or key = value)"""
175 pairs = {}
177 for line in content.split('\n'):
178 line = line.strip()
180 # Skip empty lines and code blocks
181 if not line or line.startswith('```'):
182 continue
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()
192 return pairs
194 def apply_to_settings(self):
195 """Apply OSIRIS.md config to global settings"""
197 # Apply system prompt
198 if self.instructions:
199 settings.system_prompt = self.instructions
200 logger.info("Applied custom instructions from OSIRIS.md")
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}")
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}")
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}")
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)
228 setattr(settings, key, value)
229 logger.info(f"Applied custom setting: {key}={value}")
231 def get_context_files(self) -> List[Path]:
232 """
233 Get context files with glob pattern support.
235 Returns:
236 List of resolved file paths
237 """
238 resolved_files = []
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}")
252 return resolved_files
254 def is_tool_allowed(self, tool_name: str) -> bool:
255 """
256 Check if tool is allowed by OSIRIS.md config.
258 Args:
259 tool_name: Name of tool to check
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
268 # If allowed list specified, only allow those
269 if self.allowed_tools:
270 return tool_name in self.allowed_tools
272 # Otherwise allow (default behavior)
273 return True
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 }
290def load_osiris_config(project_root: Optional[Path] = None) -> OsirisConfig:
291 """
292 Load OSIRIS.md configuration for current project.
294 Args:
295 project_root: Project root directory (defaults to current directory)
297 Returns:
298 OsirisConfig instance
299 """
300 config = OsirisConfig(project_root)
302 if config.config_file.exists():
303 logger.info(f"Found OSIRIS.md in {config.project_root}")
304 config.apply_to_settings()
306 return config
309def create_osiris_template(project_root: Optional[Path] = None) -> Path:
310 """
311 Create OSIRIS.md template in project root.
313 Args:
314 project_root: Project root directory (defaults to current directory)
316 Returns:
317 Path to created template
318 """
319 project_root = project_root or Path.cwd()
320 osiris_file = project_root / "OSIRIS.md"
322 if osiris_file.exists():
323 logger.warning(f"OSIRIS.md already exists at {osiris_file}")
324 return osiris_file
326 template = """# OSIRIS.md - Project AI Configuration
328This file customizes Osiris CLI behavior for this project.
330## Instructions
332Custom instructions for AI behavior in this project:
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
340## Allowed Tools
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
350## Blocked Tools
352Tools that should NOT be used:
353- web_search
355## Context Files
357Files to automatically include in context:
358- README.md
359- src/**/*.py
360- tests/**/*.py
361- docs/**/*.md
363## Model Preferences
365provider: openai
366model: gpt-4
368## Safety
370Safety profile for this project: standard
372## Settings
374Custom settings:
375temperature: 0.7
376max_tokens: 4000
377yolo_mode: false
378"""
380 try:
381 with open(osiris_file, 'w', encoding='utf-8') as f:
382 f.write(template)
384 logger.info(f"Created OSIRIS.md template at {osiris_file}")
385 return osiris_file
387 except Exception as e:
388 logger.error(f"Failed to create OSIRIS.md: {e}")
389 raise
392# Global config instance
393_osiris_config: Optional[OsirisConfig] = None
396def get_osiris_config() -> OsirisConfig:
397 """Get or load global OSIRIS.md config"""
398 global _osiris_config
400 if _osiris_config is None:
401 _osiris_config = load_osiris_config()
403 return _osiris_config