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/__init__.py +7 -0
- cognautic/ai_engine.py +2213 -0
- cognautic/auto_continuation.py +196 -0
- cognautic/cli.py +1064 -0
- cognautic/config.py +245 -0
- cognautic/file_tagger.py +194 -0
- cognautic/memory.py +419 -0
- cognautic/provider_endpoints.py +424 -0
- cognautic/rules.py +246 -0
- cognautic/tools/__init__.py +19 -0
- cognautic/tools/base.py +59 -0
- cognautic/tools/code_analysis.py +391 -0
- cognautic/tools/command_runner.py +292 -0
- cognautic/tools/file_operations.py +394 -0
- cognautic/tools/registry.py +115 -0
- cognautic/tools/response_control.py +48 -0
- cognautic/tools/web_search.py +336 -0
- cognautic/utils.py +297 -0
- cognautic/websocket_server.py +485 -0
- cognautic_cli-1.1.1.dist-info/METADATA +604 -0
- cognautic_cli-1.1.1.dist-info/RECORD +25 -0
- cognautic_cli-1.1.1.dist-info/WHEEL +5 -0
- cognautic_cli-1.1.1.dist-info/entry_points.txt +2 -0
- cognautic_cli-1.1.1.dist-info/licenses/LICENSE +21 -0
- cognautic_cli-1.1.1.dist-info/top_level.txt +1 -0
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")
|
cognautic/file_tagger.py
ADDED
|
@@ -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)
|