cognautic-cli 1.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cognautic/config.py ADDED
@@ -0,0 +1,245 @@
1
+ """
2
+ Configuration management for Cognautic CLI
3
+ """
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Dict, Any, Optional
9
+ from cryptography.fernet import Fernet
10
+ import keyring
11
+ from rich.console import Console
12
+ from rich.prompt import Prompt, Confirm
13
+
14
+ console = Console()
15
+
16
+ class ConfigManager:
17
+ """Manages configuration and secure API key storage"""
18
+
19
+ def __init__(self):
20
+ self.config_dir = Path.home() / ".cognautic"
21
+ self.config_file = self.config_dir / "config.json"
22
+ self.api_keys_file = self.config_dir / "api_keys.json"
23
+
24
+ # Create config directory if it doesn't exist
25
+ self.config_dir.mkdir(exist_ok=True)
26
+
27
+ # Default configuration
28
+ self.default_config = {
29
+ "default_provider": "openai",
30
+ "default_model": "gpt-4",
31
+ "websocket_port": 8765,
32
+ "max_tokens": 4096,
33
+ "temperature": 0.7,
34
+ "auto_save": True,
35
+ "verbose_logging": False,
36
+ "provider_models": {} # Store model selection per provider
37
+ }
38
+
39
+ # Initialize configuration
40
+ self._init_config()
41
+
42
+ # Initialize encryption key
43
+ self._init_encryption()
44
+
45
+ def _init_config(self):
46
+ """Initialize configuration file with defaults"""
47
+ if not self.config_file.exists():
48
+ self._save_config(self.default_config)
49
+
50
+ def _init_encryption(self):
51
+ """Initialize encryption for API keys"""
52
+ try:
53
+ # Try to get existing key from keyring
54
+ key = keyring.get_password("cognautic", "encryption_key")
55
+ if not key:
56
+ # Generate new key
57
+ key = Fernet.generate_key().decode()
58
+ keyring.set_password("cognautic", "encryption_key", key)
59
+
60
+ self.cipher = Fernet(key.encode())
61
+ except Exception:
62
+ # Silently fallback to no encryption if keyring is not available
63
+ self.cipher = None
64
+
65
+ def get_config(self) -> Dict[str, Any]:
66
+ """Get current configuration"""
67
+ try:
68
+ with open(self.config_file, 'r') as f:
69
+ return json.load(f)
70
+ except Exception:
71
+ return self.default_config.copy()
72
+
73
+ def get_config_value(self, key: str) -> Any:
74
+ """Get a specific configuration value"""
75
+ config = self.get_config()
76
+ return config.get(key)
77
+
78
+ def set_config(self, key: str, value: Any):
79
+ """Set a configuration value"""
80
+ config = self.get_config()
81
+ config[key] = value
82
+ self._save_config(config)
83
+
84
+ def delete_config(self, key: str):
85
+ """Delete a configuration key"""
86
+ config = self.get_config()
87
+ if key in config:
88
+ del config[key]
89
+ self._save_config(config)
90
+
91
+ def reset_config(self):
92
+ """Reset configuration to defaults"""
93
+ self._save_config(self.default_config.copy())
94
+
95
+ def _save_config(self, config: Dict[str, Any]):
96
+ """Save configuration to file"""
97
+ try:
98
+ with open(self.config_file, 'w') as f:
99
+ json.dump(config, f, indent=2)
100
+ except Exception as e:
101
+ console.print(f"❌ Error saving configuration: {e}", style="red")
102
+
103
+ def set_api_key(self, provider: str, api_key: str):
104
+ """Set API key for a provider"""
105
+ try:
106
+ # Load existing API keys
107
+ api_keys = self._load_api_keys()
108
+
109
+ # Encrypt the API key if encryption is available
110
+ if self.cipher:
111
+ encrypted_key = self.cipher.encrypt(api_key.encode()).decode()
112
+ api_keys[provider] = encrypted_key
113
+ else:
114
+ api_keys[provider] = api_key
115
+
116
+ # Save API keys
117
+ self._save_api_keys(api_keys)
118
+
119
+ except Exception as e:
120
+ console.print(f"❌ Error saving API key: {e}", style="red")
121
+
122
+ def get_api_key(self, provider: str) -> Optional[str]:
123
+ """Get API key for a provider"""
124
+ try:
125
+ # Ensure provider is a string
126
+ if not isinstance(provider, str):
127
+ return None
128
+
129
+ # First check environment variables
130
+ env_var = f"{provider.upper()}_API_KEY"
131
+ if env_var in os.environ:
132
+ return os.environ[env_var]
133
+
134
+ # Then check stored keys
135
+ api_keys = self._load_api_keys()
136
+ encrypted_key = api_keys.get(provider)
137
+
138
+ if not encrypted_key:
139
+ return None
140
+
141
+ # Decrypt the API key if encryption is available
142
+ if self.cipher:
143
+ try:
144
+ return self.cipher.decrypt(encrypted_key.encode()).decode()
145
+ except Exception:
146
+ # If decryption fails, assume it's unencrypted (backward compatibility)
147
+ return encrypted_key
148
+ else:
149
+ return encrypted_key
150
+
151
+ except Exception as e:
152
+ console.print(f"❌ Error loading API key: {e}", style="red")
153
+ return None
154
+
155
+ def has_api_key(self, provider: str) -> bool:
156
+ """Check if API key exists for a provider"""
157
+ return self.get_api_key(provider) is not None
158
+
159
+ def list_providers(self) -> list:
160
+ """List all configured providers"""
161
+ api_keys = self._load_api_keys()
162
+ env_providers = [
163
+ key.replace('_API_KEY', '').lower()
164
+ for key in os.environ.keys()
165
+ if key.endswith('_API_KEY')
166
+ ]
167
+ return list(set(list(api_keys.keys()) + env_providers))
168
+
169
+ def _load_api_keys(self) -> Dict[str, str]:
170
+ """Load API keys from file"""
171
+ try:
172
+ if self.api_keys_file.exists():
173
+ with open(self.api_keys_file, 'r') as f:
174
+ return json.load(f)
175
+ return {}
176
+ except Exception:
177
+ return {}
178
+
179
+ def _save_api_keys(self, api_keys: Dict[str, str]):
180
+ """Save API keys to file"""
181
+ try:
182
+ with open(self.api_keys_file, 'w') as f:
183
+ json.dump(api_keys, f, indent=2)
184
+
185
+ # Set secure file permissions
186
+ os.chmod(self.api_keys_file, 0o600)
187
+
188
+ except Exception as e:
189
+ console.print(f"❌ Error saving API keys: {e}", style="red")
190
+
191
+ def set_provider_model(self, provider: str, model: str):
192
+ """Set the preferred model for a specific provider"""
193
+ config = self.get_config()
194
+ if 'provider_models' not in config:
195
+ config['provider_models'] = {}
196
+ config['provider_models'][provider] = model
197
+ self._save_config(config)
198
+
199
+ def get_provider_model(self, provider: str) -> Optional[str]:
200
+ """Get the preferred model for a specific provider"""
201
+ config = self.get_config()
202
+ provider_models = config.get('provider_models', {})
203
+ return provider_models.get(provider)
204
+
205
+ def interactive_setup(self):
206
+ """Interactive setup wizard"""
207
+ console.print("🔧 Cognautic CLI Interactive Setup", style="bold blue")
208
+ console.print("This wizard will help you configure API keys for AI providers.\n")
209
+
210
+ providers = [
211
+ ("openai", "OpenAI", "OPENAI_API_KEY"),
212
+ ("anthropic", "Anthropic", "ANTHROPIC_API_KEY"),
213
+ ("google", "Google", "GOOGLE_API_KEY"),
214
+ ("together", "Together AI", "TOGETHER_API_KEY"),
215
+ ("openrouter", "OpenRouter", "OPENROUTER_API_KEY")
216
+ ]
217
+
218
+ for provider_id, provider_name, env_var in providers:
219
+ if Confirm.ask(f"Configure {provider_name}?"):
220
+ console.print(f"Get your API key from the {provider_name} dashboard")
221
+ console.print(f"Environment variable: {env_var}")
222
+
223
+ api_key = Prompt.ask(f"Enter {provider_name} API key", password=True)
224
+ if api_key:
225
+ self.set_api_key(provider_id, api_key)
226
+ console.print(f"✅ {provider_name} API key saved", style="green")
227
+ else:
228
+ console.print(f"⏭️ Skipping {provider_name}", style="yellow")
229
+
230
+ console.print()
231
+
232
+ # Configure default provider
233
+ configured_providers = self.list_providers()
234
+ if configured_providers:
235
+ console.print("Available providers:", ", ".join(configured_providers))
236
+ default_provider = Prompt.ask(
237
+ "Choose default provider",
238
+ choices=configured_providers,
239
+ default=configured_providers[0]
240
+ )
241
+ self.set_config("default_provider", default_provider)
242
+ console.print(f"✅ Default provider set to {default_provider}", style="green")
243
+
244
+ console.print("\n🎉 Setup complete! You can now use Cognautic CLI.", style="bold green")
245
+ console.print("Try: cognautic chat --help")
@@ -0,0 +1,194 @@
1
+ """
2
+ File tagging system for referencing files in AI conversations
3
+ """
4
+
5
+ import os
6
+ import re
7
+ from pathlib import Path
8
+ from typing import List, Dict, Tuple, Optional
9
+ import fnmatch
10
+
11
+
12
+ class FileTag:
13
+ """Represents a file tag with its path and content"""
14
+
15
+ def __init__(self, tag: str, file_path: str, content: str = None, exists: bool = True):
16
+ self.tag = tag # Original @tag from user input
17
+ self.file_path = file_path # Resolved absolute path
18
+ self.content = content # File content (loaded lazily)
19
+ self.exists = exists # Whether file exists
20
+ self.relative_path = None # Relative path from workspace
21
+
22
+ def load_content(self) -> str:
23
+ """Load file content if not already loaded"""
24
+ if self.content is None and self.exists:
25
+ try:
26
+ with open(self.file_path, 'r', encoding='utf-8') as f:
27
+ self.content = f.read()
28
+ except Exception as e:
29
+ self.content = f"Error reading file: {str(e)}"
30
+ self.exists = False
31
+ return self.content or ""
32
+
33
+
34
+ class FileTagger:
35
+ """Handles file tagging and resolution"""
36
+
37
+ def __init__(self, workspace_path: str = None):
38
+ self.workspace_path = Path(workspace_path).resolve() if workspace_path else None
39
+
40
+ def find_file_tags(self, text: str) -> List[str]:
41
+ """Find all @file tags in text"""
42
+ # Match @filename, @path/filename, @./filename, @../filename, @/absolute/path
43
+ pattern = r'@([a-zA-Z0-9._/-]+(?:\.[a-zA-Z0-9]+)?)'
44
+ matches = re.findall(pattern, text)
45
+ return [f"@{match}" for match in matches]
46
+
47
+ def resolve_file_path(self, tag: str) -> Tuple[str, bool]:
48
+ """Resolve a file tag to an absolute path"""
49
+ # Remove @ prefix
50
+ file_ref = tag[1:] if tag.startswith('@') else tag
51
+
52
+ # Handle different path types
53
+ if os.path.isabs(file_ref):
54
+ # Absolute path
55
+ resolved_path = Path(file_ref).resolve()
56
+ elif file_ref.startswith('./') or file_ref.startswith('../'):
57
+ # Relative path from current working directory
58
+ resolved_path = Path(file_ref).resolve()
59
+ elif self.workspace_path and '/' not in file_ref:
60
+ # Simple filename - look in workspace root first
61
+ resolved_path = self.workspace_path / file_ref
62
+ if not resolved_path.exists():
63
+ # If not found in workspace, try current directory
64
+ resolved_path = Path(file_ref).resolve()
65
+ elif self.workspace_path:
66
+ # Relative path from workspace
67
+ resolved_path = self.workspace_path / file_ref
68
+ else:
69
+ # No workspace, treat as relative to current directory
70
+ resolved_path = Path(file_ref).resolve()
71
+
72
+ return str(resolved_path), resolved_path.exists()
73
+
74
+ def get_file_suggestions(self, partial_path: str = "", limit: int = 20) -> List[str]:
75
+ """Get file suggestions for autocompletion"""
76
+ suggestions = []
77
+
78
+ # Get files from workspace if available
79
+ if self.workspace_path and self.workspace_path.exists():
80
+ suggestions.extend(self._get_workspace_suggestions(partial_path, limit // 2))
81
+
82
+ # Get files from current directory
83
+ suggestions.extend(self._get_current_dir_suggestions(partial_path, limit // 2))
84
+
85
+ return list(set(suggestions))[:limit]
86
+
87
+ def _get_workspace_suggestions(self, partial_path: str, limit: int) -> List[str]:
88
+ """Get file suggestions from workspace"""
89
+ suggestions = []
90
+ try:
91
+ if not partial_path:
92
+ # Show files in workspace root
93
+ for item in self.workspace_path.iterdir():
94
+ if item.is_file() and not item.name.startswith('.'):
95
+ suggestions.append(item.name)
96
+ else:
97
+ # Search for matching files - both prefix and contains matches
98
+ # First, try exact prefix matches
99
+ for item in self.workspace_path.rglob("*"):
100
+ if item.is_file() and not any(part.startswith('.') for part in item.parts):
101
+ rel_path = item.relative_to(self.workspace_path)
102
+ rel_path_str = str(rel_path)
103
+ # Check if filename starts with partial_path
104
+ if item.name.startswith(partial_path) or rel_path_str.startswith(partial_path):
105
+ suggestions.append(rel_path_str)
106
+
107
+ # If no prefix matches, try contains matches
108
+ if not suggestions:
109
+ pattern = f"*{partial_path}*"
110
+ for item in self.workspace_path.rglob(pattern):
111
+ if item.is_file() and not any(part.startswith('.') for part in item.parts):
112
+ rel_path = item.relative_to(self.workspace_path)
113
+ suggestions.append(str(rel_path))
114
+ except Exception:
115
+ pass
116
+
117
+ return suggestions[:limit]
118
+
119
+ def _get_current_dir_suggestions(self, partial_path: str, limit: int) -> List[str]:
120
+ """Get file suggestions from current directory"""
121
+ suggestions = []
122
+ try:
123
+ current_dir = Path.cwd()
124
+ if not partial_path:
125
+ # Show files in current directory
126
+ for item in current_dir.iterdir():
127
+ if item.is_file() and not item.name.startswith('.'):
128
+ suggestions.append(f"./{item.name}")
129
+ else:
130
+ # Search for matching files - prefix matches first
131
+ for item in current_dir.rglob("*"):
132
+ if item.is_file() and not any(part.startswith('.') for part in item.parts):
133
+ rel_path = item.relative_to(current_dir)
134
+ rel_path_str = str(rel_path)
135
+ # Check if filename starts with partial_path
136
+ if item.name.startswith(partial_path) or rel_path_str.startswith(partial_path):
137
+ suggestions.append(f"./{rel_path_str}")
138
+
139
+ # If no prefix matches, try contains matches
140
+ if not suggestions:
141
+ pattern = f"*{partial_path}*"
142
+ for item in current_dir.rglob(pattern):
143
+ if item.is_file() and not any(part.startswith('.') for part in item.parts):
144
+ rel_path = item.relative_to(current_dir)
145
+ suggestions.append(f"./{rel_path}")
146
+ except Exception:
147
+ pass
148
+
149
+ return suggestions[:limit]
150
+
151
+ def process_message_with_tags(self, message: str) -> Tuple[str, List[FileTag]]:
152
+ """Process a message and resolve all file tags"""
153
+ tags = self.find_file_tags(message)
154
+ file_tags = []
155
+ processed_message = message
156
+
157
+ for tag in tags:
158
+ file_path, exists = self.resolve_file_path(tag)
159
+ file_tag = FileTag(tag, file_path, exists=exists)
160
+
161
+ # Set relative path if in workspace
162
+ if self.workspace_path and file_path.startswith(str(self.workspace_path)):
163
+ file_tag.relative_path = str(Path(file_path).relative_to(self.workspace_path))
164
+
165
+ file_tags.append(file_tag)
166
+
167
+ return processed_message, file_tags
168
+
169
+ def format_file_context(self, file_tags: List[FileTag]) -> str:
170
+ """Format file tags into context for AI"""
171
+ if not file_tags:
172
+ return ""
173
+
174
+ context_parts = ["## Referenced Files:\n"]
175
+
176
+ for file_tag in file_tags:
177
+ if file_tag.exists:
178
+ content = file_tag.load_content()
179
+ display_path = file_tag.relative_path or file_tag.file_path
180
+
181
+ context_parts.append(f"### {file_tag.tag} ({display_path})")
182
+ context_parts.append("```")
183
+ context_parts.append(content)
184
+ context_parts.append("```\n")
185
+ else:
186
+ context_parts.append(f"### {file_tag.tag} (FILE NOT FOUND)")
187
+ context_parts.append(f"Path: {file_tag.file_path}\n")
188
+
189
+ return "\n".join(context_parts)
190
+
191
+
192
+ def create_file_tagger(workspace_path: str = None) -> FileTagger:
193
+ """Create a FileTagger instance"""
194
+ return FileTagger(workspace_path)